traceforge-pcb 1.0.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. traceforge/__init__.py +3 -0
  2. traceforge/__main__.py +3 -0
  3. traceforge/acp_server.py +570 -0
  4. traceforge/agent.py +680 -0
  5. traceforge/altium/__init__.py +6 -0
  6. traceforge/altium/cfb.py +70 -0
  7. traceforge/altium/cli.py +60 -0
  8. traceforge/altium/model.py +163 -0
  9. traceforge/altium/outputs.py +24 -0
  10. traceforge/altium/pcb.py +301 -0
  11. traceforge/altium/project.py +328 -0
  12. traceforge/altium/schematic.py +400 -0
  13. traceforge/altium/tools.py +166 -0
  14. traceforge/approval.py +219 -0
  15. traceforge/attachments.py +346 -0
  16. traceforge/background.py +253 -0
  17. traceforge/bench/__init__.py +5 -0
  18. traceforge/bench/adapters.py +337 -0
  19. traceforge/bench/checks.py +410 -0
  20. traceforge/bench/cli.py +121 -0
  21. traceforge/bench/design.py +439 -0
  22. traceforge/bench/kicad.py +99 -0
  23. traceforge/bench/partscheck.py +159 -0
  24. traceforge/bench/review.py +100 -0
  25. traceforge/bench/runner.py +226 -0
  26. traceforge/bench/score.py +204 -0
  27. traceforge/bench/tasks.py +102 -0
  28. traceforge/chat.py +1185 -0
  29. traceforge/checks.py +328 -0
  30. traceforge/checks_sides.py +118 -0
  31. traceforge/cli.py +2224 -0
  32. traceforge/clipboard.py +107 -0
  33. traceforge/config.py +299 -0
  34. traceforge/context.py +276 -0
  35. traceforge/design/__init__.py +1 -0
  36. traceforge/design/constraints.py +397 -0
  37. traceforge/design/generate.py +1789 -0
  38. traceforge/design/outline.py +905 -0
  39. traceforge/design/schematic_layout.py +932 -0
  40. traceforge/design/schematic_lint.py +329 -0
  41. traceforge/design/schematic_route.py +207 -0
  42. traceforge/design/sheets.py +854 -0
  43. traceforge/design/sides.py +328 -0
  44. traceforge/design/spec.py +462 -0
  45. traceforge/design/specsheet.py +2351 -0
  46. traceforge/design/specsheet_render.py +183 -0
  47. traceforge/desktop/__init__.py +109 -0
  48. traceforge/desktop/keys.py +90 -0
  49. traceforge/desktop/project.py +443 -0
  50. traceforge/desktop/review.py +20 -0
  51. traceforge/desktop/server.py +1277 -0
  52. traceforge/desktop/static/app.css +223 -0
  53. traceforge/desktop/static/app.js +683 -0
  54. traceforge/desktop/static/highlight.js +77 -0
  55. traceforge/desktop/static/index.html +89 -0
  56. traceforge/desktop/static/md.js +90 -0
  57. traceforge/desktop/static/viewer.js +213 -0
  58. traceforge/desktop/symbols.py +212 -0
  59. traceforge/desktop/update.py +65 -0
  60. traceforge/diagnostics.py +67 -0
  61. traceforge/errata.py +219 -0
  62. traceforge/fab.py +108 -0
  63. traceforge/fab_layers.py +44 -0
  64. traceforge/features/__init__.py +6 -0
  65. traceforge/features/candidates.py +117 -0
  66. traceforge/features/cli.py +200 -0
  67. traceforge/features/package.py +433 -0
  68. traceforge/features/revdiff.py +224 -0
  69. traceforge/features/sarif.py +145 -0
  70. traceforge/features/viewer.py +265 -0
  71. traceforge/firmware/__init__.py +3 -0
  72. traceforge/firmware/cli.py +174 -0
  73. traceforge/firmware/drivers.py +372 -0
  74. traceforge/firmware/drivers_data.py +256 -0
  75. traceforge/firmware/mcus.py +177 -0
  76. traceforge/firmware/netlist.py +55 -0
  77. traceforge/firmware/pinmap.py +296 -0
  78. traceforge/firmware/scaffold.py +466 -0
  79. traceforge/firmware/toolchain.py +262 -0
  80. traceforge/hooks.py +237 -0
  81. traceforge/kicad/__init__.py +0 -0
  82. traceforge/kicad/cli.py +519 -0
  83. traceforge/kicad/diffpair.py +174 -0
  84. traceforge/kicad/edgegeom.py +191 -0
  85. traceforge/kicad/edit.py +405 -0
  86. traceforge/kicad/escape.py +1410 -0
  87. traceforge/kicad/formats.py +1073 -0
  88. traceforge/kicad/impedance.py +320 -0
  89. traceforge/kicad/install.py +75 -0
  90. traceforge/kicad/libraries.py +490 -0
  91. traceforge/kicad/models3d.py +942 -0
  92. traceforge/kicad/outputs.py +81 -0
  93. traceforge/kicad/pcb.py +787 -0
  94. traceforge/kicad/planes.py +1242 -0
  95. traceforge/kicad/project.py +136 -0
  96. traceforge/kicad/route.py +2698 -0
  97. traceforge/kicad/routegeom.py +164 -0
  98. traceforge/kicad/schematic.py +268 -0
  99. traceforge/kicad/sides.py +34 -0
  100. traceforge/kicad/stackup.py +424 -0
  101. traceforge/kicad_plugin/__init__.py +12 -0
  102. traceforge/kicad_plugin/actions.py +92 -0
  103. traceforge/kicad_plugin/cli.py +66 -0
  104. traceforge/kicad_plugin/client.py +86 -0
  105. traceforge/kicad_plugin/context.py +257 -0
  106. traceforge/kicad_plugin/discovery.py +81 -0
  107. traceforge/kicad_plugin/install.py +203 -0
  108. traceforge/kicad_plugin/overlay.py +60 -0
  109. traceforge/kicad_plugin/payload/action_plugin.py +65 -0
  110. traceforge/kicad_plugin/payload/ipc_main.py +42 -0
  111. traceforge/kicad_plugin/payload/plugin.json +90 -0
  112. traceforge/kicad_plugin/payload/requirements.txt +1 -0
  113. traceforge/kicad_plugin/views.py +202 -0
  114. traceforge/langchain.py +57 -0
  115. traceforge/local.py +580 -0
  116. traceforge/mcp_server.py +304 -0
  117. traceforge/models.py +310 -0
  118. traceforge/next_steps.py +110 -0
  119. traceforge/parts/__init__.py +52 -0
  120. traceforge/parts/alternates.py +488 -0
  121. traceforge/parts/bom.py +423 -0
  122. traceforge/parts/brief.py +705 -0
  123. traceforge/parts/catalog.py +297 -0
  124. traceforge/parts/cli_sourcing.py +156 -0
  125. traceforge/parts/data/duty_rates.yaml +37 -0
  126. traceforge/parts/data/hs_codes.yaml +103 -0
  127. traceforge/parts/datasheet.py +278 -0
  128. traceforge/parts/fx.py +100 -0
  129. traceforge/parts/model.py +185 -0
  130. traceforge/parts/net.py +187 -0
  131. traceforge/parts/offers.py +46 -0
  132. traceforge/parts/packages.py +397 -0
  133. traceforge/parts/pinout.py +714 -0
  134. traceforge/parts/providers/__init__.py +17 -0
  135. traceforge/parts/providers/base.py +100 -0
  136. traceforge/parts/providers/jlc.py +364 -0
  137. traceforge/parts/providers/keyed.py +661 -0
  138. traceforge/parts/report.py +508 -0
  139. traceforge/parts/supply.py +330 -0
  140. traceforge/parts/verify.py +455 -0
  141. traceforge/parts/web.py +575 -0
  142. traceforge/plans.py +70 -0
  143. traceforge/preferences.py +127 -0
  144. traceforge/prices.py +130 -0
  145. traceforge/prices.yaml +28 -0
  146. traceforge/prompts/__init__.py +54 -0
  147. traceforge/prompts/system.md +103 -0
  148. traceforge/providers.py +764 -0
  149. traceforge/remote/__init__.py +33 -0
  150. traceforge/remote/client.py +422 -0
  151. traceforge/remote/oidc.py +240 -0
  152. traceforge/remote/orgs.py +510 -0
  153. traceforge/remote/server.py +690 -0
  154. traceforge/remote/store.py +100 -0
  155. traceforge/review_changes.py +187 -0
  156. traceforge/routing.py +99 -0
  157. traceforge/runtime.py +435 -0
  158. traceforge/sessions.py +324 -0
  159. traceforge/sexpr.py +279 -0
  160. traceforge/sim/__init__.py +17 -0
  161. traceforge/sim/acmath.py +234 -0
  162. traceforge/sim/circuit.py +499 -0
  163. traceforge/sim/cli.py +162 -0
  164. traceforge/sim/engine.py +172 -0
  165. traceforge/sim/ltspice.py +175 -0
  166. traceforge/sim/measure.py +123 -0
  167. traceforge/sim/models.py +76 -0
  168. traceforge/sim/project.py +218 -0
  169. traceforge/sim/rawfile.py +123 -0
  170. traceforge/sim/results.py +298 -0
  171. traceforge/sim/tune.py +172 -0
  172. traceforge/skill_commands.py +165 -0
  173. traceforge/skill_library/circuit-simulation/SKILL.md +30 -0
  174. traceforge/skill_library/compliance-review/SKILL.md +51 -0
  175. traceforge/skill_library/design-review/SKILL.md +47 -0
  176. traceforge/skill_library/errata-hunt/SKILL.md +30 -0
  177. traceforge/skill_library/firmware/SKILL.md +26 -0
  178. traceforge/skill_library/power-budget/SKILL.md +27 -0
  179. traceforge/skill_library/release-package/SKILL.md +29 -0
  180. traceforge/skill_library/sourcing-brief/SKILL.md +24 -0
  181. traceforge/skill_library/thermal-review/SKILL.md +15 -0
  182. traceforge/skills.py +131 -0
  183. traceforge/thermal.py +557 -0
  184. traceforge/tools/__init__.py +48 -0
  185. traceforge/tools/agent_tools.py +213 -0
  186. traceforge/tools/base.py +303 -0
  187. traceforge/tools/batch.py +155 -0
  188. traceforge/tools/connections.py +245 -0
  189. traceforge/tools/files.py +777 -0
  190. traceforge/tools/firmware_tools.py +218 -0
  191. traceforge/tools/kicad_tools.py +1072 -0
  192. traceforge/tools/models3d_tools.py +141 -0
  193. traceforge/tools/parts_tools.py +473 -0
  194. traceforge/tools/processes.py +246 -0
  195. traceforge/tools/review_tools.py +178 -0
  196. traceforge/tools/sim_tools.py +230 -0
  197. traceforge/tui.py +620 -0
  198. traceforge/updates.py +124 -0
  199. traceforge/validate/__init__.py +77 -0
  200. traceforge/validate/builtins.py +1091 -0
  201. traceforge/validate/circuits.py +1240 -0
  202. traceforge/validate/config.py +410 -0
  203. traceforge/validate/default.yaml +113 -0
  204. traceforge/validate/design.py +191 -0
  205. traceforge/validate/explain.py +112 -0
  206. traceforge/validate/grounding.py +169 -0
  207. traceforge/validate/ipc2221.py +350 -0
  208. traceforge/validate/multilayer.py +805 -0
  209. traceforge/validate/netmodel.py +261 -0
  210. traceforge/validate/partcheck.py +472 -0
  211. traceforge/validate/report.py +280 -0
  212. traceforge/validate/review.py +322 -0
  213. traceforge/validate/runner.py +662 -0
  214. traceforge/workspace.py +171 -0
  215. traceforge_pcb-1.0.0.dist-info/METADATA +463 -0
  216. traceforge_pcb-1.0.0.dist-info/RECORD +219 -0
  217. traceforge_pcb-1.0.0.dist-info/WHEEL +4 -0
  218. traceforge_pcb-1.0.0.dist-info/entry_points.txt +2 -0
  219. traceforge_pcb-1.0.0.dist-info/licenses/LICENSE +21 -0
@@ -0,0 +1,410 @@
1
+ """The validation graph: stages, checks, policy profiles, limits and reviewer settings.
2
+
3
+ The packaged ``default.yaml`` is the base. A project layers
4
+ ``.traceforge/validate.yaml`` on top, and ``--config FILE`` layers once more.
5
+ Each layer either starts with ``extends: default`` (merge into what is there)
6
+ or replaces the graph outright. Checks merge by id; ``disable: [ids]`` drops
7
+ checks; ``policy``, ``limits`` and ``reviewer`` merge key by key.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import copy
13
+ from dataclasses import asdict, dataclass, field
14
+ from importlib import resources
15
+ from pathlib import Path
16
+ from typing import Any
17
+
18
+ import yaml
19
+
20
+ SEVERITIES = ("P0", "P1", "P2", "P3")
21
+ CONFIDENCE = {"low": 0, "medium": 1, "high": 2}
22
+ GATES = ("stage", "full", "strict")
23
+ SCOPES = ("project", "per_part", "per_sheet", "per_netclass")
24
+ INPUT_KINDS = ("sch", "pcb", "pro", "libs", "intent", "outputs", "all")
25
+ KINDS = ("builtin", "review")
26
+ PROJECT_CONFIG = Path(".traceforge") / "validate.yaml"
27
+
28
+
29
+ class ConfigError(ValueError):
30
+ """The graph is invalid (exit code 2)."""
31
+
32
+
33
+ @dataclass
34
+ class Stage:
35
+ id: str
36
+ title: str
37
+ question: str = ""
38
+
39
+
40
+ @dataclass
41
+ class CheckSpec:
42
+ id: str
43
+ stage: str
44
+ kind: str # builtin | review
45
+ title: str
46
+ prompt: str = ""
47
+ needs: list[str] = field(default_factory=list) # hard prerequisites ("requires")
48
+ after: list[str] = field(default_factory=list) # ordering only
49
+ gate: str = "blocking" # blocking | advisory
50
+ builtin: str = "" # builtin implementation name (defaults to the id)
51
+ scope: str = "project"
52
+ batch: int = 4
53
+ repeat: int = 1
54
+ inputs: list[str] = field(default_factory=lambda: ["all"])
55
+ effort: str | None = None
56
+ enabled: bool = True
57
+
58
+ @property
59
+ def requires(self) -> list[str]:
60
+ return self.needs
61
+
62
+ def to_dict(self) -> dict[str, Any]:
63
+ data = asdict(self)
64
+ data["requires"] = data.pop("needs")
65
+ return {k: v for k, v in data.items() if v not in (None, "", [])}
66
+
67
+
68
+ @dataclass
69
+ class Profile:
70
+ fail_on: list[str]
71
+ min_confidence: str = "medium"
72
+
73
+
74
+ @dataclass
75
+ class ReviewerSettings:
76
+ preamble: str = ""
77
+ timeout_s: int = 1800
78
+ max_repairs: int = 2
79
+
80
+
81
+ @dataclass
82
+ class ValidationConfig:
83
+ stages: dict[str, str] # id -> title, in order (kept as a dict for compatibility)
84
+ checks: list[CheckSpec]
85
+ profiles: dict[str, dict]
86
+ default_profile: str
87
+ questions: dict[str, str] = field(default_factory=dict)
88
+ error_blocks_dependents: bool = True
89
+ limits: dict[str, Any] = field(default_factory=dict)
90
+ reviewer: ReviewerSettings = field(default_factory=ReviewerSettings)
91
+
92
+ def stage_index(self) -> dict[str, int]:
93
+ return {stage: position for position, stage in enumerate(self.stages)}
94
+
95
+ def check(self, check_id: str) -> CheckSpec:
96
+ for check in self.checks:
97
+ if check.id == check_id:
98
+ return check
99
+ raise KeyError(check_id)
100
+
101
+ def enabled_checks(self) -> list[CheckSpec]:
102
+ return [check for check in self.checks if check.enabled]
103
+
104
+ def profile(self, name: str | None) -> tuple[str, Profile]:
105
+ chosen = name or self.default_profile
106
+ if chosen not in self.profiles:
107
+ raise ConfigError(f"unknown profile {chosen!r}; choose from {sorted(self.profiles)}")
108
+ raw = self.profiles[chosen]
109
+ return chosen, Profile(
110
+ [str(s).upper() for s in raw.get("fail_on", ["P0"])],
111
+ str(raw.get("min_confidence", raw.get("min_confidence_to_fail", "medium"))).lower(),
112
+ )
113
+
114
+
115
+ # ----------------------------------------------------------------------- loading
116
+
117
+
118
+ def _read_yaml(path: Path) -> dict[str, Any]:
119
+ try:
120
+ loaded = yaml.safe_load(path.read_text(encoding="utf-8"))
121
+ except yaml.YAMLError as exc:
122
+ raise ConfigError(f"{path}: invalid YAML: {exc}") from exc
123
+ except OSError as exc:
124
+ raise ConfigError(f"{path}: {exc}") from exc
125
+ if loaded is None:
126
+ return {}
127
+ if not isinstance(loaded, dict):
128
+ raise ConfigError(f"{path}: the graph must be a mapping at the top level")
129
+ return loaded
130
+
131
+
132
+ def default_raw() -> dict[str, Any]:
133
+ text = resources.files("traceforge.validate").joinpath("default.yaml").read_text("utf-8")
134
+ return yaml.safe_load(text)
135
+
136
+
137
+ def _stage_list(raw: Any) -> list[dict[str, Any]]:
138
+ """Stages may be a mapping ``{S0: Intake}`` or a list of ``{id, title, question}``."""
139
+ if raw is None:
140
+ return []
141
+ if isinstance(raw, dict):
142
+ return [
143
+ {"id": str(k), **(v if isinstance(v, dict) else {"title": str(v)})}
144
+ for k, v in raw.items()
145
+ ]
146
+ if isinstance(raw, list):
147
+ out = []
148
+ for item in raw:
149
+ if not isinstance(item, dict) or not item.get("id"):
150
+ raise ConfigError("every stage entry needs an id")
151
+ out.append(dict(item))
152
+ return out
153
+ raise ConfigError("stages must be a mapping or a list")
154
+
155
+
156
+ def _merge_by_id(base: list[dict], overrides: list[Any], label: str) -> list[dict]:
157
+ result = [copy.deepcopy(item) for item in base]
158
+ positions = {item["id"]: index for index, item in enumerate(result)}
159
+ for item in overrides:
160
+ if not isinstance(item, dict) or not item.get("id"):
161
+ raise ConfigError(f"every {label} entry needs an id")
162
+ if item["id"] in positions:
163
+ result[positions[item["id"]]].update(copy.deepcopy(item))
164
+ else:
165
+ positions[item["id"]] = len(result)
166
+ result.append(copy.deepcopy(item))
167
+ return result
168
+
169
+
170
+ def _deep_merge(base: dict, overrides: dict) -> dict:
171
+ result = copy.deepcopy(base)
172
+ for key, value in overrides.items():
173
+ if isinstance(value, dict) and isinstance(result.get(key), dict):
174
+ result[key] = _deep_merge(result[key], value)
175
+ else:
176
+ result[key] = copy.deepcopy(value)
177
+ return result
178
+
179
+
180
+ def _normalise(raw: dict[str, Any]) -> dict[str, Any]:
181
+ raw = dict(raw)
182
+ raw["stages"] = _stage_list(raw.get("stages"))
183
+ raw["checks"] = list(raw.get("checks") or [])
184
+ return raw
185
+
186
+
187
+ def apply_layer(base: dict[str, Any], layer: dict[str, Any], source: str) -> dict[str, Any]:
188
+ layer = dict(layer)
189
+ extends = layer.pop("extends", None)
190
+ disabled = set(layer.pop("disable", []) or [])
191
+ if extends is None and ("checks" in layer and "stages" in layer):
192
+ result = _normalise(layer) # a complete graph of its own
193
+ else:
194
+ if extends not in (None, "default", "none", False):
195
+ raise ConfigError(f"{source}: extends must be 'default' or 'none', got {extends!r}")
196
+ result = copy.deepcopy(base)
197
+ if extends in ("none", False): # keep stages, policy and limits; start with no checks
198
+ result["checks"] = []
199
+ for key, value in layer.items():
200
+ if key == "checks":
201
+ if not isinstance(value, list):
202
+ raise ConfigError(f"{source}: checks must be a list")
203
+ result["checks"] = _merge_by_id(result["checks"], value, "check")
204
+ elif key == "stages":
205
+ result["stages"] = _merge_by_id(result["stages"], _stage_list(value), "stage")
206
+ elif key in {"policy", "limits", "reviewer"} and isinstance(value, dict):
207
+ result[key] = _deep_merge(result.get(key) or {}, value)
208
+ else:
209
+ result[key] = copy.deepcopy(value)
210
+ if disabled:
211
+ unknown = disabled - {c.get("id") for c in result["checks"]}
212
+ if unknown:
213
+ raise ConfigError(f"{source}: disable names unknown checks {sorted(unknown)}")
214
+ result["checks"] = [c for c in result["checks"] if c.get("id") not in disabled]
215
+ return result
216
+
217
+
218
+ def load_raw(workspace: Path, config_path: Path | None = None) -> dict[str, Any]:
219
+ raw = _normalise(default_raw())
220
+ project = Path(workspace) / PROJECT_CONFIG
221
+ if project.is_file():
222
+ raw = apply_layer(raw, _read_yaml(project), str(project))
223
+ if config_path is not None:
224
+ if not Path(config_path).is_file():
225
+ raise ConfigError(f"config file not found: {config_path}")
226
+ raw = apply_layer(raw, _read_yaml(Path(config_path)), str(config_path))
227
+ return raw
228
+
229
+
230
+ def _as_list(value: Any) -> list[str]:
231
+ if value is None:
232
+ return []
233
+ if isinstance(value, str):
234
+ return [value]
235
+ return [str(v) for v in value]
236
+
237
+
238
+ def build_config(raw: dict[str, Any]) -> ValidationConfig:
239
+ stages = _stage_list(raw.get("stages"))
240
+ policy = raw.get("policy") or {}
241
+ profiles = policy.get("profiles") or {"dev": {"fail_on": ["P0"]}}
242
+ reviewer_raw = raw.get("reviewer") or {}
243
+ unknown_reviewer = set(reviewer_raw) - {"preamble", "timeout_s", "max_repairs"}
244
+ if unknown_reviewer:
245
+ raise ConfigError(f"reviewer: unknown keys {sorted(unknown_reviewer)}")
246
+ specs: list[CheckSpec] = []
247
+ known = {
248
+ "id",
249
+ "stage",
250
+ "kind",
251
+ "title",
252
+ "prompt",
253
+ "needs",
254
+ "requires",
255
+ "after",
256
+ "gate",
257
+ "builtin",
258
+ "scope",
259
+ "batch",
260
+ "repeat",
261
+ "inputs",
262
+ "effort",
263
+ "enabled",
264
+ }
265
+ for item in raw.get("checks") or []:
266
+ if not isinstance(item, dict) or not item.get("id"):
267
+ raise ConfigError("every check needs an id")
268
+ extra = set(item) - known
269
+ if extra:
270
+ raise ConfigError(f"check {item['id']!r}: unknown keys {sorted(extra)}")
271
+ if "stage" not in item:
272
+ raise ConfigError(f"check {item['id']!r}: needs a stage")
273
+ kind = str(item.get("kind", "review"))
274
+ if kind == "agent": # accept the other common spelling
275
+ kind = "review"
276
+ specs.append(
277
+ CheckSpec(
278
+ id=str(item["id"]),
279
+ stage=str(item["stage"]),
280
+ kind=kind,
281
+ title=str(item.get("title", item["id"])),
282
+ prompt=str(item.get("prompt") or ""),
283
+ needs=_as_list(item.get("needs")) + _as_list(item.get("requires")),
284
+ after=_as_list(item.get("after")),
285
+ gate=str(item.get("gate", "blocking")),
286
+ builtin=str(item.get("builtin") or (item["id"] if kind == "builtin" else "")),
287
+ scope=str(item.get("scope", "project")),
288
+ batch=int(item.get("batch", 4)),
289
+ repeat=int(item.get("repeat", 1)),
290
+ inputs=_as_list(item.get("inputs")) or ["all"],
291
+ effort=item.get("effort"),
292
+ enabled=bool(item.get("enabled", True)),
293
+ )
294
+ )
295
+ config = ValidationConfig(
296
+ stages={s["id"]: str(s.get("title", s["id"])) for s in stages},
297
+ checks=specs,
298
+ profiles=profiles,
299
+ default_profile=str(policy.get("default_profile", "dev")),
300
+ questions={s["id"]: str(s.get("question", "")) for s in stages},
301
+ error_blocks_dependents=bool(policy.get("error_blocks_dependents", True)),
302
+ limits=dict(raw.get("limits") or {}),
303
+ reviewer=ReviewerSettings(
304
+ preamble=str(reviewer_raw.get("preamble", "")),
305
+ timeout_s=int(reviewer_raw.get("timeout_s", 1800)),
306
+ max_repairs=int(reviewer_raw.get("max_repairs", 2)),
307
+ ),
308
+ )
309
+ validate_graph(config)
310
+ return config
311
+
312
+
313
+ def load_config(workspace: Path, config_path: Path | None = None) -> ValidationConfig:
314
+ return build_config(load_raw(Path(workspace), config_path))
315
+
316
+
317
+ # -------------------------------------------------------------------- validation
318
+
319
+
320
+ def validate_graph(config: ValidationConfig) -> None:
321
+ """Reject graphs that cannot run; every message names the offending check."""
322
+ from traceforge.validate.builtins import BUILTINS
323
+
324
+ order = config.stage_index()
325
+ if not order:
326
+ raise ConfigError("the graph defines no stages")
327
+ if config.default_profile not in config.profiles:
328
+ raise ConfigError(f"default profile {config.default_profile!r} is not defined")
329
+ for name, profile in config.profiles.items():
330
+ bad = [s for s in profile.get("fail_on", []) if str(s).upper() not in SEVERITIES]
331
+ if bad:
332
+ raise ConfigError(f"profile {name!r}: unknown severities {bad}")
333
+ conf = str(profile.get("min_confidence", "medium")).lower()
334
+ if conf not in CONFIDENCE:
335
+ raise ConfigError(f"profile {name!r}: min_confidence must be one of {list(CONFIDENCE)}")
336
+ seen: set[str] = set()
337
+ for check in config.checks:
338
+ if check.id in seen:
339
+ raise ConfigError(f"check {check.id!r}: duplicate id")
340
+ seen.add(check.id)
341
+ by_id = {check.id: check for check in config.checks}
342
+ for check in config.checks:
343
+ where = f"check {check.id!r}"
344
+ if check.stage not in order:
345
+ raise ConfigError(f"{where}: unknown stage {check.stage!r}")
346
+ if check.kind not in KINDS:
347
+ raise ConfigError(f"{where}: kind must be builtin or review")
348
+ if check.gate not in {"blocking", "advisory"}:
349
+ raise ConfigError(f"{where}: gate must be blocking or advisory")
350
+ if check.scope not in SCOPES:
351
+ raise ConfigError(f"{where}: scope must be one of {list(SCOPES)}")
352
+ bad_inputs = [i for i in check.inputs if i not in INPUT_KINDS]
353
+ if bad_inputs:
354
+ raise ConfigError(f"{where}: unknown inputs {bad_inputs}; use {list(INPUT_KINDS)}")
355
+ if check.batch < 1 or check.repeat < 1:
356
+ raise ConfigError(f"{where}: batch and repeat must be at least 1")
357
+ for label, refs in (("needs", check.needs), ("after", check.after)):
358
+ for other in refs:
359
+ if other == check.id:
360
+ raise ConfigError(f"{where}: depends on itself")
361
+ if other not in by_id:
362
+ raise ConfigError(f"{where}: {label} unknown check {other!r}")
363
+ if order[by_id[other].stage] > order[check.stage]:
364
+ raise ConfigError(
365
+ f"{where}: {label} {other!r} runs in the later stage {by_id[other].stage}"
366
+ )
367
+ if check.kind == "review":
368
+ if not check.prompt.strip():
369
+ raise ConfigError(f"{where}: review checks need a prompt")
370
+ if check.scope != "project" and "{items}" not in check.prompt:
371
+ raise ConfigError(f"{where}: scope {check.scope} needs an {{items}} placeholder")
372
+ elif check.builtin not in BUILTINS:
373
+ raise ConfigError(
374
+ f"{where}: unknown builtin {check.builtin!r}; known: {', '.join(sorted(BUILTINS))}"
375
+ )
376
+ _reject_cycles(config)
377
+
378
+
379
+ def _reject_cycles(config: ValidationConfig) -> None:
380
+ edges = {check.id: [*check.needs, *check.after] for check in config.checks}
381
+ state: dict[str, int] = {}
382
+ stack: list[str] = []
383
+
384
+ def visit(node: str) -> None:
385
+ state[node] = 1
386
+ stack.append(node)
387
+ for other in edges.get(node, []):
388
+ if state.get(other) == 1:
389
+ cycle = [*stack[stack.index(other) :], other]
390
+ raise ConfigError(f"check {node!r}: dependency cycle {' -> '.join(cycle)}")
391
+ if other not in state:
392
+ visit(other)
393
+ stack.pop()
394
+ state[node] = 2
395
+
396
+ for check_id in edges:
397
+ if check_id not in state:
398
+ visit(check_id)
399
+
400
+
401
+ def prerequisites(config: ValidationConfig, check_ids: set[str]) -> set[str]:
402
+ """Close ``check_ids`` over hard prerequisites (``needs``), transitively."""
403
+ result = set(check_ids)
404
+ queue = list(check_ids)
405
+ while queue:
406
+ for other in config.check(queue.pop()).needs:
407
+ if other not in result:
408
+ result.add(other)
409
+ queue.append(other)
410
+ return result
@@ -0,0 +1,113 @@
1
+ # Default traceforge validation graph. A project can extend or override it in
2
+ # .traceforge/validate.yaml:
3
+ # extends: default
4
+ # disable: [dfa.assembly]
5
+ # checks:
6
+ # - {id: custom.usb, stage: S6, kind: review, title: USB 2.0 routing, prompt: "..."}
7
+ version: 1
8
+
9
+ stages:
10
+ S0: Intake
11
+ S1: Architecture
12
+ S2: Parts
13
+ S3: Schematic
14
+ S4: Schematic to board
15
+ S5: Placement
16
+ S6: Routing, power and signal integrity
17
+ S7: Manufacturing and assembly
18
+ S8: Release
19
+
20
+ policy:
21
+ default_profile: dev
22
+ profiles:
23
+ dev: {fail_on: [P0], min_confidence: medium}
24
+ release: {fail_on: [P0, P1], min_confidence: low}
25
+
26
+ checks:
27
+ - {id: intake.tools, stage: S0, kind: builtin, gate: advisory, title: KiCad tooling is available}
28
+ - {id: intake.project, stage: S0, kind: builtin, title: Project files are present and readable, needs: [intake.tools]}
29
+ - {id: intake.intent, stage: S0, kind: builtin, gate: advisory, inputs: [intent, sch, pcb], title: Design intent is recorded and matches the design}
30
+ - id: arch.review
31
+ stage: S1
32
+ kind: review
33
+ title: System architecture
34
+ needs: [intake.project]
35
+ prompt: |
36
+ Review the system architecture. Reconstruct the power tree from the schematic: every input source,
37
+ regulator and rail, with each rail's consumers and an estimate of its current. Check that each
38
+ regulator can supply its load with margin and that its input range, dropout and thermal dissipation
39
+ work at the worst-case input. Check interfaces (USB, UART, I2C, SPI, CAN, debug) for completeness:
40
+ pull-ups, termination, level compatibility between connected parts, ESD protection at connectors.
41
+ Check reset, boot and programming paths of every processor.
42
+ - {id: parts.builtin, stage: S2, kind: builtin, title: "Footprints, values and part numbers", needs: [intake.project], inputs: [sch]}
43
+ - {id: parts.footprints, stage: S2, kind: builtin, title: "Footprint matches symbol, part number and package", needs: [intake.project], inputs: [sch, pcb]}
44
+ - {id: parts.verified, stage: S2, kind: builtin, title: "Every BOM part verified (catalogue, package, pinout)", needs: [intake.project], inputs: [sch, pcb]}
45
+ - {id: parts.lcsc, stage: S2, kind: builtin, gate: advisory, title: "LCSC parts exist, match the MPN and are stocked", needs: [intake.project], inputs: [sch]}
46
+ - id: parts.review
47
+ stage: S2
48
+ kind: review
49
+ title: Part selection and ratings
50
+ needs: [parts.builtin]
51
+ prompt: |
52
+ Review part selection. For each non-trivial part, check that the chosen symbol and footprint match the
53
+ intended package and pinout (compare symbol pin numbers to footprint pad numbers), that voltage,
54
+ current and power ratings have margin (capacitor voltage derating, resistor power, connector current),
55
+ and that the part numbers in LCSC/MPN fields match the values. Flag parts that are obsolete,
56
+ unusual or hard to source when a common equivalent exists. Use part_lookup and datasheet_fetch
57
+ for evidence (package drawing, absolute maximum ratings, pinout) rather than recalling values.
58
+ - {id: sch.erc, stage: S3, kind: builtin, title: KiCad electrical rules check, needs: [intake.project], inputs: [sch, libs]}
59
+ - {id: sch.circuit_rules, stage: S3, kind: builtin, title: "LED, USB-C CC, button, decoupling and regulator rules", needs: [intake.project], inputs: [sch, pcb]}
60
+ - id: sch.circuits
61
+ stage: S3
62
+ kind: review
63
+ title: Circuit correctness
64
+ needs: [intake.project]
65
+ prompt: |
66
+ Check every circuit block against its datasheet's reference design and against first principles:
67
+ decoupling at each IC supply pin, bulk capacitance per rail, feedback dividers and their computed
68
+ output, timing components and their computed frequency, LED and transistor bias currents, pull-ups and
69
+ pull-downs on inputs that could float, polarity of polarized parts and diodes, and enable/reset pins.
70
+ Show the arithmetic for every computed value you rely on.
71
+ - {id: pcb.parity, stage: S4, kind: builtin, title: Schematic and board agree, needs: [intake.project]}
72
+ - {id: pcb.rules, stage: S4, kind: builtin, title: Board rules and net classes meet fab limits, needs: [intake.project], inputs: [pro]}
73
+ - {id: pcb.dfm, stage: S4, kind: builtin, title: "Fab limits, outline, courtyards and routing completeness", needs: [intake.project]}
74
+ - {id: pcb.ipc2221, stage: S4, kind: builtin, gate: advisory, title: "IPC-2221 conductor width and spacing for declared currents and voltages", needs: [intake.project]}
75
+ - {id: pcb.multilayer, stage: S6, kind: builtin, gate: advisory, title: "Layer usage, reference planes, return paths, via stubs, copper balance and fab stackup", needs: [intake.project], inputs: [pcb, pro]}
76
+ - {id: pcb.models3d, stage: S4, kind: builtin, gate: advisory, title: "Every footprint has a resolved 3D model", needs: [intake.project], inputs: [pcb]}
77
+ - {id: pcb.drc, stage: S4, kind: builtin, title: KiCad design rules check, needs: [intake.project]}
78
+ - id: pcb.placement
79
+ stage: S5
80
+ kind: review
81
+ title: Placement
82
+ needs: [pcb.parity]
83
+ prompt: |
84
+ Review component placement using the board coordinates. Decoupling capacitors should sit next to the
85
+ supply pin they serve; crystals next to their oscillator pins; regulators and their capacitors in a
86
+ compact loop; connectors at board edges with sensible orientation; switches, LEDs and test points where
87
+ a user can reach or see them; heat sources away from temperature-sensitive parts. Quote coordinates or
88
+ distances for each finding.
89
+ - id: pcb.routing
90
+ stage: S6
91
+ kind: review
92
+ title: Power and signal routing
93
+ needs: [pcb.dfm]
94
+ prompt: |
95
+ Review routing: track widths on power nets against their expected current (state the current and the
96
+ width you would require), via count on high-current paths, ground return paths (pours, stitching vias,
97
+ splits under signals), loop area of switching regulators, and routing of clocks, USB and other fast or
98
+ sensitive signals (length, layer changes, reference plane). Use kicad_board with pads=true to read
99
+ geometry when needed.
100
+ - id: dfa.assembly
101
+ stage: S7
102
+ kind: review
103
+ title: Assembly readiness
104
+ needs: [parts.builtin, pcb.dfm]
105
+ prompt: |
106
+ Review assembly readiness for the selected fab: every BOM line has an orderable part number, footprint
107
+ rotations and polarity marks are unambiguous on silkscreen, fine-pitch parts have enough clearance for
108
+ rework, fiducials exist when the board will be machine assembled, and through-hole parts are limited to
109
+ what the assembly service supports.
110
+ - {id: pcb.refdes, stage: S7, kind: builtin, title: Reference designators visible on silkscreen, needs: [intake.project], inputs: [pcb]}
111
+ - {id: release.outputs, stage: S8, kind: builtin, title: Fabrication outputs can be generated, needs: [intake.project], after: [pcb.dfm], inputs: [pcb, pro]}
112
+ - {id: release.repo, stage: S8, kind: builtin, title: Repository is portable, needs: [intake.project]}
113
+ - {id: release.cpl, stage: S8, kind: builtin, title: BOM and placement are assembler-ready, needs: [intake.project]}