diff --git a/.codex/config.toml b/.codex/config.toml new file mode 100644 index 0000000..2969647 --- /dev/null +++ b/.codex/config.toml @@ -0,0 +1,4 @@ +[mcp_servers.pcb-lens] +command = "npx" +args = ["tsx", "src/index.ts"] +cwd = "/Users/valentino/Developer/IntelligentElectron/pcb-lens" diff --git a/CHANGELOG.md b/CHANGELOG.md index b6f2ba5..7522d85 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,14 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.0.8] - 2026-02-27 + +### Changed + +- `query_net` now groups pin connectivity by component refdes (`pins: { [refdes]: string[] }`) to reduce payload size +- `query_net` now omits empty routing/via arrays and zero-value summary fields (`totalSegments`, `totalVias`) +- `query_net` now rejects patterns that match all nets and directs callers to `get_design_overview` for discovery + ## [0.0.7] - 2026-02-27 ### Fixed @@ -82,10 +90,3 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - Test fixture download script for IPC-2581 consortium samples - Release automation with CI workflows and binary compilation - Integration tests against IPC-2581 consortium fixtures - -[0.0.6]: https://github.com/IntelligentElectron/pcb-lens/releases/tag/v0.0.6 -[0.0.5]: https://github.com/IntelligentElectron/pcb-lens/releases/tag/v0.0.5 -[0.0.4]: https://github.com/IntelligentElectron/pcb-lens/releases/tag/v0.0.4 -[0.0.3]: https://github.com/IntelligentElectron/pcb-lens/releases/tag/v0.0.3 -[0.0.2]: https://github.com/IntelligentElectron/pcb-lens/releases/tag/v0.0.2 -[0.0.1]: https://github.com/IntelligentElectron/pcb-lens/releases/tag/v0.0.1 diff --git a/docs/tools/query_net.md b/docs/tools/query_net.md index 1e58231..3da6304 100644 --- a/docs/tools/query_net.md +++ b/docs/tools/query_net.md @@ -1,10 +1,12 @@ # query_net -Query a net by name pattern. Returns connected pins, routing per layer (trace widths, segment counts), and via information. +Query nets by name pattern. Returns grouped connected pins, routing per layer (trace widths, segment counts), via information, and layers used. ## Description -Finds the first net whose name matches the given regex pattern, then collects its full connectivity and routing data. Returns the list of component pins on the net, per-layer routing details (trace widths, segment counts), via usage, and a summary of layers used. Useful for inspecting signal integrity, checking trace widths on critical nets, or understanding how a net is routed across layers. +Finds all nets whose names match the given regex pattern, then collects connectivity and routing data for each match. Pin connectivity is grouped by component refdes to reduce response size. Empty routing/via fields and zero-value summary fields are omitted to keep payloads compact. + +If a pattern matches all nets in a design (for example `.`, `.*`, or `.+`), the tool rejects the query and asks for a more specific pattern. ## Input Parameters @@ -16,25 +18,25 @@ Finds the first net whose name matches the given regex pattern, then collects it ## Response Schema ```typescript +interface QueryNetsResult { + pattern: string; + units: "MICRON"; + matches: QueryNetResult[]; +} + interface QueryNetResult { netName: string; - units: string; // Always "MICRON" - pins: NetPin[]; - routing: NetRouteInfo[]; - vias: NetViaInfo[]; - totalSegments: number; - totalVias: number; + pins: Record; // { refdes: [pin, ...] } layersUsed: string[]; -} - -interface NetPin { - refdes: string; - pin: string; + routing?: NetRouteInfo[]; // omitted when empty + vias?: NetViaInfo[]; // omitted when empty + totalSegments?: number; // omitted when 0 + totalVias?: number; // omitted when 0 } interface NetRouteInfo { layerName: string; - traceWidths: number[]; // Unique widths in microns + traceWidths: number[]; // Unique widths in microns segmentCount: number; } @@ -62,92 +64,97 @@ Call: Response: ```json { - "netName": "DDR_D0", + "pattern": "^DDR_D0$", "units": "MICRON", - "pins": [ - { "refdes": "U1", "pin": "A5" }, - { "refdes": "U8", "pin": "D3" } - ], - "routing": [ + "matches": [ { - "layerName": "SIG1", - "traceWidths": [100], - "segmentCount": 12 + "netName": "DDR_D0", + "pins": { + "U1": ["A5"], + "U8": ["D3"] + }, + "routing": [ + { + "layerName": "SIG1", + "traceWidths": [100], + "segmentCount": 12 + } + ], + "totalSegments": 12, + "layersUsed": ["SIG1"] } - ], - "vias": [], - "totalSegments": 12, - "totalVias": 0, - "layersUsed": ["SIG1"] + ] } ``` **Query a power net:** -Call: +Response: ```json { - "tool": "query_net", - "arguments": { - "file": "/designs/motherboard_ipc2581.xml", - "pattern": "^VCC_3V3$" - } + "pattern": "^VCC_3V3$", + "units": "MICRON", + "matches": [ + { + "netName": "VCC_3V3", + "pins": { + "C1": ["1"], + "C2": ["1"], + "C3": ["1"], + "L1": ["2"], + "U1": ["B2", "C7"] + }, + "routing": [ + { + "layerName": "TOP", + "traceWidths": [200, 300], + "segmentCount": 28 + }, + { + "layerName": "PWR", + "traceWidths": [500], + "segmentCount": 45 + } + ], + "vias": [ + { "padstackRef": "VIA_0.3mm", "count": 8 } + ], + "totalSegments": 73, + "totalVias": 8, + "layersUsed": ["PWR", "TOP"] + } + ] } ``` -Response: +**No match:** ```json { - "netName": "VCC_3V3", + "pattern": "^MISSING_NET$", "units": "MICRON", - "pins": [ - { "refdes": "U1", "pin": "B2" }, - { "refdes": "U1", "pin": "C7" }, - { "refdes": "C1", "pin": "1" }, - { "refdes": "C2", "pin": "1" }, - { "refdes": "C3", "pin": "1" }, - { "refdes": "L1", "pin": "2" } - ], - "routing": [ - { - "layerName": "TOP", - "traceWidths": [200, 300], - "segmentCount": 28 - }, - { - "layerName": "PWR", - "traceWidths": [500], - "segmentCount": 45 - } - ], - "vias": [ - { "padstackRef": "VIA_0.3mm", "count": 8 } - ], - "totalSegments": 73, - "totalVias": 8, - "layersUsed": ["TOP", "PWR"] + "matches": [] } ``` -**No match:** +**Error (pattern matches all nets):** ```json { - "error": "No net matching pattern '^MISSING_NET$' found" + "error": "Pattern '.*' matches all 307 physical nets. Use a more specific pattern, or use get_design_overview for net counts and discovery." } ``` **Error (invalid regex):** ```json { - "error": "Invalid regex pattern: ^VCC[+" + "error": "Invalid regex pattern: '^VCC[+'" } ``` ## Notes -- Matches the **first** net whose name matches the regex; use an anchored pattern like `^DDR_D0$` for exact matches -- Uses three passes: (1) find the matching net name, (2) build a LineDesc dictionary for trace width resolution, (3) collect pins, routing, and vias from LayerFeature sections -- Reference layers (REF-route, REF-both) are skipped to avoid counting template geometry -- `traceWidths` contains unique widths found on that layer (not per-segment) -- All widths are in microns -- `layersUsed` is a flat list of all layers where the net has routing +- Returns all matching nets, sorted by net name +- Uses three passes: (1) match nets and collect LogicalNet/PhyNet data, (2) build a LineDesc dictionary, (3) collect routing and vias from LayerFeature sections +- Reference layers (`REF-route`, `REF-both`) are skipped to avoid counting template geometry +- `traceWidths` contains unique widths found on each layer (not one entry per segment) +- All physical values are normalized to microns +- `layersUsed` merges layers from PhyNet points and routing geometry diff --git a/package.json b/package.json index 2528b2d..30372d9 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@intelligentelectron/pcb-lens", - "version": "0.0.7", + "version": "0.0.8", "description": "MCP server for IPC-2581 PCB layout analysis and review", "type": "module", "main": "dist/index.js", diff --git a/src/tools/lib/types.ts b/src/tools/lib/types.ts index 816efc0..48e26ca 100644 --- a/src/tools/lib/types.ts +++ b/src/tools/lib/types.ts @@ -131,11 +131,11 @@ export interface RenderNetResult { */ export interface QueryNetResult { netName: string; - pins: NetPin[]; - routing: NetRouteInfo[]; - vias: NetViaInfo[]; - totalSegments: number; - totalVias: number; + pins: Record; + routing?: NetRouteInfo[]; + vias?: NetViaInfo[]; + totalSegments?: number; + totalVias?: number; layersUsed: string[]; } diff --git a/src/tools/query-net.test.ts b/src/tools/query-net.test.ts index 114b8bb..bd732e8 100644 --- a/src/tools/query-net.test.ts +++ b/src/tools/query-net.test.ts @@ -88,6 +88,12 @@ const expectSuccess = (result: unknown): QueryNetsResult => { return result as QueryNetsResult; }; +const hasPin = (pins: Record, refdes: string, pin: string): boolean => + (pins[refdes] ?? []).includes(pin); + +const pinCount = (pins: Record): number => + Object.values(pins).reduce((sum, componentPins) => sum + componentPins.length, 0); + // --------------------------------------------------------------------------- // Pattern validation (edge cases) // --------------------------------------------------------------------------- @@ -121,29 +127,38 @@ describe("queryNet -- pin extraction from LogicalNet", () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_A$")); expect(r.matches).toHaveLength(1); const pins = r.matches[0].pins; - expect(pins).toContainEqual({ refdes: "U1", pin: "1" }); - expect(pins).toContainEqual({ refdes: "R1", pin: "2" }); + expect(hasPin(pins, "U1", "1")).toBe(true); + expect(hasPin(pins, "R1", "2")).toBe(true); }); it("extracts pins from LogicalNet for NET_B", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_B$")); expect(r.matches).toHaveLength(1); const pins = r.matches[0].pins; - expect(pins).toContainEqual({ refdes: "U1", pin: "3" }); - expect(pins).toContainEqual({ refdes: "C1", pin: "1" }); + expect(hasPin(pins, "U1", "3")).toBe(true); + expect(hasPin(pins, "C1", "1")).toBe(true); }); it("includes supplementary pin from LayerFeature Set", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_B$")); const pins = r.matches[0].pins; - expect(pins).toContainEqual({ refdes: "U2", pin: "5" }); + expect(hasPin(pins, "U2", "5")).toBe(true); }); it("deduplicates pins appearing in both LogicalNet and LayerFeature", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_B$")); const pins = r.matches[0].pins; - const u2Pin5 = pins.filter((p) => p.refdes === "U2" && p.pin === "5"); - expect(u2Pin5).toHaveLength(1); + const u2Pins = pins.U2 ?? []; + expect(u2Pins.filter((pin) => pin === "5")).toHaveLength(1); + }); + + it("groups pins by refdes with sorted keys and pin values", async () => { + const r = expectSuccess(await queryNet(inlineXml, "^NET_B$")); + const pins = r.matches[0].pins; + expect(Object.keys(pins)).toEqual(["C1", "U1", "U2"]); + expect(pins.C1).toEqual(["1"]); + expect(pins.U1).toEqual(["3"]); + expect(pins.U2).toEqual(["5"]); }); }); @@ -183,9 +198,29 @@ describe("queryNet -- multi-match", () => { expect(r.matches[1].netName).toBe("NET_B"); }); - it("returns all nets for wildcard pattern", async () => { - const r = expectSuccess(await queryNet(inlineXml, ".")); - expect(r.matches).toHaveLength(3); + it("rejects '.' pattern when it matches all nets", async () => { + const result = await queryNet(inlineXml, "."); + expect(isErrorResult(result)).toBe(true); + if (isErrorResult(result)) { + expect(result.error).toContain("matches all 3 physical nets"); + expect(result.error).toContain("get_design_overview"); + } + }); + + it("rejects '.*' pattern when it matches all nets", async () => { + const result = await queryNet(inlineXml, ".*"); + expect(isErrorResult(result)).toBe(true); + if (isErrorResult(result)) { + expect(result.error).toContain("matches all 3 physical nets"); + } + }); + + it("rejects '.+' pattern when it matches all nets", async () => { + const result = await queryNet(inlineXml, ".+"); + expect(isErrorResult(result)).toBe(true); + if (isErrorResult(result)) { + expect(result.error).toContain("matches all 3 physical nets"); + } }); it("returns empty matches for non-existent net", async () => { @@ -207,7 +242,8 @@ describe("queryNet -- multi-match", () => { describe("queryNet -- routing and vias", () => { it("NET_A has correct routing on TOP (150 microns)", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_A$")); - const topRoute = r.matches[0].routing.find((rt) => rt.layerName === "TOP"); + expect(r.matches[0].routing).toBeDefined(); + const topRoute = r.matches[0].routing!.find((rt) => rt.layerName === "TOP"); expect(topRoute).toBeDefined(); expect(topRoute!.segmentCount).toBe(1); expect(topRoute!.traceWidths).toContain(150); @@ -215,7 +251,8 @@ describe("queryNet -- routing and vias", () => { it("NET_A has correct routing on BOTTOM (250 microns)", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_A$")); - const botRoute = r.matches[0].routing.find((rt) => rt.layerName === "BOTTOM"); + expect(r.matches[0].routing).toBeDefined(); + const botRoute = r.matches[0].routing!.find((rt) => rt.layerName === "BOTTOM"); expect(botRoute).toBeDefined(); expect(botRoute!.segmentCount).toBe(1); expect(botRoute!.traceWidths).toContain(250); @@ -225,16 +262,27 @@ describe("queryNet -- routing and vias", () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_A$")); expect(r.matches[0].totalSegments).toBe(2); expect(r.matches[0].totalVias).toBe(1); - expect(r.matches[0].vias[0].padstackRef).toBe("VIA1"); + expect(r.matches[0].vias).toBeDefined(); + expect(r.matches[0].vias![0].padstackRef).toBe("VIA1"); }); it("NET_B on TOP has trace width 200 microns (inline LineDesc)", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_B$")); - const topRoute = r.matches[0].routing.find((rt) => rt.layerName === "TOP"); + expect(r.matches[0].routing).toBeDefined(); + const topRoute = r.matches[0].routing!.find((rt) => rt.layerName === "TOP"); expect(topRoute).toBeDefined(); expect(topRoute!.traceWidths).toContain(200); }); + it("omits empty routing/via/total fields when a net has no route segments", async () => { + const r = expectSuccess(await queryNet(inlineXml, "^PWR_VCC$")); + const net = r.matches[0]; + expect(net).not.toHaveProperty("routing"); + expect(net).not.toHaveProperty("vias"); + expect(net).not.toHaveProperty("totalSegments"); + expect(net).not.toHaveProperty("totalVias"); + }); + it("units field is always MICRON", async () => { const r = expectSuccess(await queryNet(inlineXml, "^NET_A$")); expect(r.units).toBe("MICRON"); @@ -258,6 +306,9 @@ describe("queryNet -- edge cases", () => { + + + @@ -266,7 +317,48 @@ describe("queryNet -- edge cases", () => { writeFileSync(f, xml); const r = expectSuccess(await queryNet(f, "^ORPHAN_NET$")); expect(r.matches).toHaveLength(1); - expect(r.matches[0].pins).toContainEqual({ refdes: "U1", pin: "1" }); + expect(hasPin(r.matches[0].pins, "U1", "1")).toBe(true); + }); + + it("does not reject wildcard patterns when the design has zero nets", async () => { + const xml = ` + + + + + +`; + const f = path.join(tempDir, "zero-nets.xml"); + writeFileSync(f, xml); + const r = expectSuccess(await queryNet(f, ".*")); + expect(r.matches).toHaveLength(0); + }); + + it("uses PhyNet count for match-all rejection denominator", async () => { + const xml = ` + + + + + + + + + + + + + + + +`; + const f = path.join(tempDir, "phy-count.xml"); + writeFileSync(f, xml); + const result = await queryNet(f, ".*"); + expect(isErrorResult(result)).toBe(true); + if (isErrorResult(result)) { + expect(result.error).toContain("matches all 1 physical nets"); + } }); }); @@ -278,8 +370,8 @@ describe.skipIf(!hasBeagleBoneFixture)("queryNet -- BeagleBone RevB6", () => { const r = expectSuccess(await queryNet(BEAGLEBONE, "^VDD_3V3B$")); expect(r.matches).toHaveLength(1); const net = r.matches[0]; - expect(net.pins.length).toBeGreaterThan(0); - expect(net.pins).toContainEqual({ refdes: "R157", pin: "2" }); + expect(pinCount(net.pins)).toBeGreaterThan(0); + expect(hasPin(net.pins, "R157", "2")).toBe(true); expect(net.layersUsed).toContain("TOP"); expect(net.layersUsed).toContain("BOTTOM"); }); @@ -288,7 +380,7 @@ describe.skipIf(!hasBeagleBoneFixture)("queryNet -- BeagleBone RevB6", () => { const r = expectSuccess(await queryNet(BEAGLEBONE, "^VDD")); expect(r.matches.length).toBeGreaterThan(1); for (const m of r.matches) { - expect(m.pins.length).toBeGreaterThan(0); + expect(pinCount(m.pins)).toBeGreaterThan(0); expect(m.layersUsed.length).toBeGreaterThan(0); } }); diff --git a/src/tools/query-net.ts b/src/tools/query-net.ts index fda6e2f..8235af5 100644 --- a/src/tools/query-net.ts +++ b/src/tools/query-net.ts @@ -3,6 +3,7 @@ import { z } from "zod"; import type { ErrorResult, NetPin, + QueryNetResult, NetRouteInfo, NetViaInfo, QueryNetsResult, @@ -41,6 +42,24 @@ const addPin = (acc: NetAccumulator, refdes: string, pin: string): void => { } }; +const groupPinsByRefdes = (pins: NetPin[]): Record => { + const grouped = new Map(); + for (const { refdes, pin } of pins) { + if (!grouped.has(refdes)) { + grouped.set(refdes, []); + } + grouped.get(refdes)!.push(pin); + } + + const result: Record = {}; + const sortedRefdes = [...grouped.keys()].sort((a, b) => a.localeCompare(b)); + for (const refdes of sortedRefdes) { + result[refdes] = grouped.get(refdes)!.sort((a, b) => a.localeCompare(b)); + } + + return result; +}; + export const queryNet = async ( filePath: string, pattern: string @@ -57,6 +76,8 @@ export const queryNet = async ( // Pass 1: Discover matching nets from LogicalNet + PhyNet sections, // extract pins from LogicalNet, extract layers from PhyNetPoint. const accumulators = new Map(); + const phyNetNames = new Set(); + const matchedPhyNetNames = new Set(); let insideMatchedLogicalNet = false; let insideMatchedPhyNet = false; let currentLogicalNetName = ""; @@ -94,9 +115,13 @@ export const queryNet = async ( // PhyNet layer extraction if (line.includes(" 0 && matchedPhyNetNames.size === phyNetNames.size) { + return { + error: `Pattern '${pattern}' matches all ${phyNetNames.size} physical nets. Use a more specific pattern, or use get_design_overview for net counts and discovery.`, + }; + } + // Pass 2: Build LineDesc dictionary const lineDescDict = await buildLineDescDict(filePath); @@ -216,7 +247,9 @@ export const queryNet = async ( .sort(([a], [b]) => a.localeCompare(b)) .map(([netName, acc]) => { const routing: NetRouteInfo[] = []; - for (const [layerName, data] of acc.routeMap) { + for (const [layerName, data] of [...acc.routeMap.entries()].sort(([a], [b]) => + a.localeCompare(b) + )) { routing.push({ layerName, traceWidths: [...data.widths].sort((a, b) => a - b), @@ -225,7 +258,9 @@ export const queryNet = async ( } const vias: NetViaInfo[] = []; - for (const [padstackRef, count] of acc.viaMap) { + for (const [padstackRef, count] of [...acc.viaMap.entries()].sort(([a], [b]) => + a.localeCompare(b) + )) { vias.push({ padstackRef, count }); } @@ -237,7 +272,26 @@ export const queryNet = async ( for (const r of routing) layerSet.add(r.layerName); const layersUsed = [...layerSet].sort(); - return { netName, pins: acc.pins, routing, vias, totalSegments, totalVias, layersUsed }; + const result: QueryNetResult = { + netName, + pins: groupPinsByRefdes(acc.pins), + layersUsed, + }; + + if (routing.length > 0) { + result.routing = routing; + } + if (vias.length > 0) { + result.vias = vias; + } + if (totalSegments > 0) { + result.totalSegments = totalSegments; + } + if (totalVias > 0) { + result.totalVias = totalVias; + } + + return result; }); return { pattern, units: "MICRON", matches }; @@ -248,7 +302,7 @@ export const register = (server: McpServer): void => { "query_net", { description: - "Query a net by name pattern in an IPC-2581 file. Returns connected pins, routing per layer (trace widths, segment counts), and via information.", + "Query nets by name pattern in an IPC-2581 file. Returns grouped connected pins, routing per layer (trace widths, segment counts), and via information. Rejects patterns that match all nets.", inputSchema: { file: z.string().describe("Path to IPC-2581 XML file"), pattern: z diff --git a/test/integration/snapshots.test.ts b/test/integration/snapshots.test.ts index 3990c25..521efda 100644 --- a/test/integration/snapshots.test.ts +++ b/test/integration/snapshots.test.ts @@ -64,6 +64,9 @@ interface FixtureConfig { net: NetGroundTruth; } +const groupedPinCount = (pins: Record): number => + Object.values(pins).reduce((sum, componentPins) => sum + componentPins.length, 0); + // --------------------------------------------------------------------------- // Fixture definitions with independently-verified ground truth // @@ -419,14 +422,15 @@ for (const fixture of FIXTURES) { } if (fixture.net.totalVias !== undefined) { - expect(net.totalVias).toBe(fixture.net.totalVias); + expect(net.totalVias ?? 0).toBe(fixture.net.totalVias); } const minPins = fixture.net.minPins ?? 1; - expect(net.pins.length).toBeGreaterThanOrEqual(minPins); + const totalPins = groupedPinCount(net.pins); + expect(totalPins).toBeGreaterThanOrEqual(minPins); if (fixture.net.rawPinRefCount !== undefined) { - expect(net.pins.length).toBeLessThanOrEqual(fixture.net.rawPinRefCount); + expect(totalPins).toBeLessThanOrEqual(fixture.net.rawPinRefCount); } });