@mmerterden/multi-agent-pipeline 14.2.2 → 15.0.0

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 (122) hide show
  1. package/CHANGELOG.md +76 -6
  2. package/README.md +15 -8
  3. package/README.tr.md +15 -8
  4. package/docs/FIGMA_PIPELINE.md +3 -3
  5. package/docs/adr/0006-skills-core-external-split.md +1 -1
  6. package/docs/adr/0009-claude-stack-skills-plugin-only.md +31 -0
  7. package/docs/adr/README.md +1 -0
  8. package/docs/architecture.md +7 -7
  9. package/docs/ecosystem.md +28 -28
  10. package/docs/features.md +5 -5
  11. package/index.js +2 -0
  12. package/install/_codex-agents.mjs +11 -2
  13. package/install/_common.mjs +65 -1
  14. package/install/_dev-only-files.mjs +0 -1
  15. package/install/_platform-filter.mjs +73 -7
  16. package/install/_plugin-skills.mjs +19 -8
  17. package/install/claude.mjs +144 -59
  18. package/install/codex.mjs +28 -3
  19. package/install/copilot.mjs +36 -11
  20. package/install/index.mjs +6 -2
  21. package/install/templates/codex-instructions.md +1 -1
  22. package/install/templates/copilot-instructions.md +3 -3
  23. package/package.json +1 -2
  24. package/pipeline/commands/multi-agent/SKILL.md +2 -0
  25. package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
  26. package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
  27. package/pipeline/commands/multi-agent/build-optimize/SKILL.md +9 -9
  28. package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
  29. package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +186 -0
  30. package/pipeline/commands/multi-agent/dev/SKILL.md +1 -1
  31. package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -1
  32. package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
  33. package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -1
  34. package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
  35. package/pipeline/commands/multi-agent/help/SKILL.md +19 -4
  36. package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
  37. package/pipeline/commands/multi-agent/jira/SKILL.md +1 -1
  38. package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +81 -0
  39. package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
  40. package/pipeline/commands/multi-agent/{ship → resume-local}/SKILL.md +8 -8
  41. package/pipeline/commands/multi-agent/setup/SKILL.md +5 -5
  42. package/pipeline/commands/multi-agent/stack/SKILL.md +55 -43
  43. package/pipeline/commands/multi-agent/store-ready/SKILL.md +3 -3
  44. package/pipeline/commands/multi-agent/sync/SKILL.md +18 -11
  45. package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
  46. package/pipeline/commands/multi-agent/uninstall/SKILL.md +2 -0
  47. package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
  48. package/pipeline/lib/issue-fetcher.sh +1 -1
  49. package/pipeline/lib/parse-complaints.sh +306 -0
  50. package/pipeline/multi-agent-refs/channels/wiki.md +3 -3
  51. package/pipeline/multi-agent-refs/complaint-analysis-template.md +99 -0
  52. package/pipeline/multi-agent-refs/component-dispatch.md +6 -6
  53. package/pipeline/multi-agent-refs/cross-cli-contract.md +16 -16
  54. package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
  55. package/pipeline/multi-agent-refs/features/stack-skill-routing.md +5 -5
  56. package/pipeline/multi-agent-refs/generate-issue.md +1 -1
  57. package/pipeline/multi-agent-refs/phases/modes.md +1 -1
  58. package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
  59. package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +7 -7
  60. package/pipeline/multi-agent-refs/phases/phase-2-planning.md +5 -5
  61. package/pipeline/multi-agent-refs/phases/phase-3-dev.md +3 -3
  62. package/pipeline/multi-agent-refs/phases/phase-4-review.md +12 -12
  63. package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
  64. package/pipeline/multi-agent-refs/tracker-contract.md +1 -1
  65. package/pipeline/multi-agent-refs/wiki-capture.md +2 -2
  66. package/pipeline/preferences-template.json +13 -5
  67. package/pipeline/rules/figma-pipeline.md +2 -2
  68. package/pipeline/schemas/agent-state.schema.json +1 -1
  69. package/pipeline/schemas/complaint-analysis-spec.schema.json +216 -0
  70. package/pipeline/schemas/migrations/prefs-2.5.0-to-2.6.0.mjs +46 -0
  71. package/pipeline/schemas/prefs.schema.json +276 -66
  72. package/pipeline/schemas/token-budget.json +2 -2
  73. package/pipeline/scripts/_stack-routing.mjs +79 -0
  74. package/pipeline/scripts/audit-log-rotate.sh +4 -1
  75. package/pipeline/scripts/build-skills-index.mjs +11 -0
  76. package/pipeline/scripts/build-stack-plugins.mjs +28 -60
  77. package/pipeline/scripts/check-derived-drift.mjs +52 -28
  78. package/pipeline/scripts/gc-worktrees.sh +4 -1
  79. package/pipeline/scripts/gen-skills-index.mjs +1 -1
  80. package/pipeline/scripts/match-skills.mjs +8 -2
  81. package/pipeline/scripts/migrate-prefs.mjs +28 -20
  82. package/pipeline/scripts/phase-tracker.sh +13 -5
  83. package/pipeline/scripts/phase0-exit-gate.mjs +3 -2
  84. package/pipeline/scripts/run-aggregator.mjs +7 -2
  85. package/pipeline/scripts/scan-agent-config.sh +1 -1
  86. package/pipeline/scripts/skill-conformance.mjs +165 -30
  87. package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
  88. package/pipeline/scripts/test-gap-rules/android.json +25 -0
  89. package/pipeline/scripts/test-gap-rules/ios.json +34 -0
  90. package/pipeline/scripts/test-gap-rules/node.json +29 -0
  91. package/pipeline/scripts/test-gap-rules/python.json +25 -0
  92. package/pipeline/scripts/uninstall.mjs +158 -11
  93. package/pipeline/scripts/validate-complaint-doc.mjs +229 -0
  94. package/pipeline/scripts/validate-reviewer.mjs +9 -3
  95. package/pipeline/skills/.skill-manifest.json +156 -108
  96. package/pipeline/skills/.skills-index.json +449 -12
  97. package/pipeline/skills/shared/README.md +14 -10
  98. package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +1 -1
  99. package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +1 -1
  100. package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +49 -0
  101. package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +1 -1
  102. package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +1 -1
  103. package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +1 -1
  104. package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +1 -1
  105. package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -2
  106. package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +83 -0
  107. package/pipeline/skills/shared/core/{multi-agent-ship → multi-agent-resume-local}/SKILL.md +6 -6
  108. package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +79 -22
  109. package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +1 -1
  110. package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +8 -8
  111. package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +1 -1
  112. package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +2 -2
  113. package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +368 -33
  114. package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +1 -2
  115. package/pipeline/skills/shared/external/ios-coding-standard/scripts/check_structure.py +765 -0
  116. package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +75 -0
  117. package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +131 -0
  118. package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +559 -0
  119. package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +765 -0
  120. package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +53 -10
  121. package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +4 -3
  122. package/pipeline/skills/skills-index.md +7 -4
@@ -0,0 +1,765 @@
1
+ #!/usr/bin/env python3
2
+
3
+ """Check a module's tree against the ios-module-structure registry.
4
+
5
+ The registry (references/rules.yml) states each rule over ROLES. The module's overlay
6
+ (modules/<Module>.yml) binds those roles to this module's own paths and spellings. This script
7
+ resolves the bindings, runs each rule's predicate, and reports findings by stable ID.
8
+
9
+ Three things it will not do, by design:
10
+
11
+ - guess a binding. An unbound role or slot DISABLES the rules that read it, and the run reports
12
+ them as disabled coverage rather than defaulting to one shape and manufacturing findings.
13
+ - carry vocabulary. Every literal path, suffix and prefix comes from the overlay. Nothing about
14
+ any one codebase lives in this file or in the registry beside it.
15
+ - edit anything. It reads and reports.
16
+
17
+ Requires PyYAML - the one dependency, because the registry and the overlay are YAML.
18
+ """
19
+
20
+ import argparse
21
+ import fnmatch
22
+ import json
23
+ import re
24
+ import sys
25
+ from pathlib import Path
26
+
27
+ try:
28
+ import yaml
29
+ except ImportError:
30
+ sys.stderr.write(
31
+ "ios-module-structure needs PyYAML to read the registry and the overlay.\n"
32
+ " python3 -m pip install --user pyyaml\n"
33
+ )
34
+ sys.exit(2)
35
+
36
+
37
+ EXCEPTION_RE = re.compile(r"//\s*standard:exception\(([A-Z]+-\d+)\)")
38
+
39
+ # The engine holds no language of its own: the registry declares which one it speaks for, and a
40
+ # second language is a second rules file passed to --rules, never a fork of this script.
41
+ SOURCE_SUFFIX = ".swift"
42
+
43
+
44
+ def source_suffix(registry):
45
+ scope = registry.get("scope") or {}
46
+ if scope.get("sourceExtension"):
47
+ return scope["sourceExtension"]
48
+ for pattern in scope.get("paths") or []:
49
+ if pattern.startswith("**/*."):
50
+ return pattern[4:]
51
+ return SOURCE_SUFFIX
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # Binding
56
+ # ---------------------------------------------------------------------------
57
+
58
+
59
+ def in_scope(path, registry):
60
+ """The registry declares a scope; honour it. A generated file is not a design decision."""
61
+ scope = registry.get("scope") or {}
62
+ text = str(path)
63
+ return not any(fnmatch.fnmatch(text, g) for g in (scope.get("excludePaths") or []))
64
+
65
+
66
+ class Bindings:
67
+ """Roles and dialect slots resolved from the overlay, plus what could not be resolved."""
68
+
69
+ def __init__(self, overlay, registry):
70
+ self.roles = dict(overlay.get("roles") or {})
71
+ self.dialect = {k: v for k, v in (overlay.get("dialect") or {}).items()
72
+ if v and not k.endswith("_evidence")}
73
+ self.vocabulary = dict(overlay.get("vocabulary") or {})
74
+ self.exemptions = dict(overlay.get("exemptions") or {})
75
+ self.limits = dict(overlay.get("limits") or {})
76
+ self.known_roles = set((registry.get("roles") or {}).keys())
77
+ self.known_slots = {s["id"] for s in (registry.get("module_overlay_slots") or {}).get("slots", [])}
78
+
79
+ def role(self, name):
80
+ return self.roles.get(name)
81
+
82
+ def slot(self, name):
83
+ return self.dialect.get(name)
84
+
85
+ def exempt(self, rule_id, path=None, screen=None, detail=None):
86
+ """Carve-outs are declared per rule in the overlay, never guessed here."""
87
+ rule = self.exemptions.get(rule_id)
88
+ if not rule:
89
+ return False
90
+ if screen and screen in (rule.get("screens") or []):
91
+ return True
92
+ if path:
93
+ text = str(path)
94
+ if any(fnmatch.fnmatch(text, g) for g in (rule.get("paths") or [])):
95
+ return True
96
+ if detail is not None:
97
+ for expr in (rule.get("patterns") or []):
98
+ if re.search(expr, str(detail)):
99
+ return True
100
+ return False
101
+
102
+ def unbound_roles(self):
103
+ return sorted(self.known_roles - set(self.roles))
104
+
105
+ def unbound_slots(self):
106
+ return sorted(self.known_slots - set(self.dialect))
107
+
108
+
109
+ def iter_glob(base, pattern):
110
+ """A role binds to one glob or to several; both read the same here."""
111
+ if not pattern:
112
+ return []
113
+ patterns = pattern if isinstance(pattern, list) else [pattern]
114
+ seen, out = set(), []
115
+ for one in patterns:
116
+ for hit in base.glob(one):
117
+ if hit not in seen:
118
+ seen.add(hit)
119
+ out.append(hit)
120
+ return sorted(out)
121
+
122
+
123
+ def expand(pattern, **subs):
124
+ """Substitute {dir}/{stem}/{screen}/{target} into a role pattern."""
125
+ if isinstance(pattern, list):
126
+ return [expand(one, **subs) for one in pattern]
127
+ out = pattern
128
+ for key, value in subs.items():
129
+ out = out.replace("{" + key + "}", str(value))
130
+ return out
131
+
132
+
133
+ def match_role(root, pattern):
134
+ """Every path under root matching a role's glob."""
135
+ if not pattern:
136
+ return []
137
+ return sorted(p for p in root.glob(pattern) if p.is_file() or p.is_dir())
138
+
139
+
140
+ # ---------------------------------------------------------------------------
141
+ # Screens
142
+ # ---------------------------------------------------------------------------
143
+
144
+
145
+ def screen_labels(screens, root):
146
+ """A screen's identity is its path, not its name.
147
+
148
+ In a multi-target package two targets may each carry a screen with the same folder name.
149
+ Keying a report by the name merges them into one entry that belongs to neither, so the label
150
+ only shortens to the bare name when that name is unique.
151
+ """
152
+ from collections import Counter
153
+ repeated = {n for n, c in Counter(s.name for s in screens).items() if c > 1}
154
+ labels = {}
155
+ for screen in screens:
156
+ if screen.name not in repeated:
157
+ labels[screen] = screen.name
158
+ continue
159
+ parts = screen.relative_to(root).parts
160
+ # Enough of the path to tell the two apart: the unit that owns the screen, then the screen.
161
+ labels[screen] = "/".join(parts[-3:]) if len(parts) >= 3 else "/".join(parts)
162
+ return labels
163
+
164
+
165
+ def discover_screens(root, bindings, only=None):
166
+ """Every directory the overlay's screen.root pattern selects."""
167
+ pattern = bindings.role("screen.root")
168
+ if not pattern:
169
+ return []
170
+ screens = [p for p in iter_glob(root, pattern) if p.is_dir()]
171
+ if only:
172
+ screens = [s for s in screens if s.name == only]
173
+ return sorted(screens, key=lambda p: (p.parent.name, p.name))
174
+
175
+
176
+ def read(path):
177
+ try:
178
+ return path.read_text(encoding="utf-8", errors="ignore")
179
+ except OSError:
180
+ return ""
181
+
182
+
183
+ def line_of(text, index):
184
+ return text.count("\n", 0, index) + 1
185
+
186
+
187
+ def excepted(text, line, rule_id):
188
+ """A finding is waived when its line, or the line above it, carries the marker for that rule."""
189
+ lines = text.splitlines()
190
+ for candidate in (line - 1, line - 2):
191
+ if 0 <= candidate < len(lines):
192
+ found = EXCEPTION_RE.search(lines[candidate])
193
+ if found and found.group(1) == rule_id:
194
+ return True
195
+ return False
196
+
197
+
198
+ # ---------------------------------------------------------------------------
199
+ # Predicates
200
+ # ---------------------------------------------------------------------------
201
+ # Each takes (screen_dir, root, rule, params, bindings) and returns a list of
202
+ # {path, line, detail}. The message comes from the rule, never from the predicate.
203
+
204
+
205
+ def p_dir_required_in_dir(screen, root, rule, params, b):
206
+ wanted = b.vocabulary.get(params["vocabulary_key"]) or []
207
+ if isinstance(wanted, str):
208
+ wanted = [wanted]
209
+ return [{"path": str(screen), "line": 0, "detail": name}
210
+ for name in wanted if not (screen / name).is_dir()]
211
+
212
+
213
+ def p_file_required_in_dir(screen, root, rule, params, b):
214
+ pattern = b.role(params["role"])
215
+ if not pattern:
216
+ return []
217
+
218
+ trigger_role = params.get("trigger_role")
219
+ if trigger_role:
220
+ # The requirement only bites when the screen actually has the thing the file would hold.
221
+ trigger_pattern = b.role(trigger_role)
222
+ trigger_re = re.compile(params["trigger_pattern"], re.M)
223
+ if not any(trigger_re.search(read(p)) for p in iter_glob(screen, trigger_pattern)):
224
+ return []
225
+
226
+ wanted = expand(pattern, screen=screen.name)
227
+ if iter_glob(screen, wanted):
228
+ return []
229
+ return [{"path": str(screen), "line": 0, "detail": wanted}]
230
+
231
+
232
+ def p_prefix_collision(screen, root, rule, params, b):
233
+ """A file in the shared folder whose name opens with a screen's own name."""
234
+ return [] # module-level; run once outside the screen walk
235
+
236
+
237
+ def module_prefix_collision(root, rule, params, b, screens):
238
+ """A shared file named after a screen - compared only against screens it actually shares with.
239
+
240
+ In a multi-target package a file in one target's shared folder has nothing to do with a
241
+ same-named screen in another target, so the comparison is scoped to the unit that owns both:
242
+ the directory holding the screen's container.
243
+ """
244
+ shared = b.role(params["subject_role"])
245
+ if not shared:
246
+ return []
247
+ out = []
248
+ for path in iter_glob(root, shared):
249
+ if path.suffix != SOURCE_SUFFIX:
250
+ continue
251
+ for screen in screens:
252
+ unit = screen.parent.parent # .../<unit>/<container>/<screen>
253
+ if unit not in path.parents:
254
+ continue
255
+ if path.stem.startswith(screen.name) and path.stem != screen.name:
256
+ out.append({"path": str(path), "line": 0, "detail": screen.name})
257
+ break
258
+ return out
259
+
260
+
261
+ def p_pair_required_in_dir(screen, root, rule, params, b):
262
+ container = b.role(params["container_role"])
263
+ left = b.role(params["left_role"])
264
+ right = b.role(params["right_role"])
265
+ if not (container and left and right):
266
+ return []
267
+ wanted = (left, right)
268
+ if params.get("pairing_slot") and b.slot(params["pairing_slot"]) == params.get("right_only_value"):
269
+ # Under response-only the response alone is the whole operation; only a lone request is odd.
270
+ wanted = (right,)
271
+
272
+ out = []
273
+ for directory in iter_glob(screen, container):
274
+ if not directory.is_dir():
275
+ continue
276
+ names = [f.name for f in directory.iterdir() if f.is_file()]
277
+ for role_pattern in wanted:
278
+ if not any(fnmatch.fnmatch(n, role_pattern) for n in names):
279
+ out.append({"path": str(directory), "line": 0, "detail": role_pattern})
280
+ return out
281
+
282
+
283
+ def p_sibling_required(screen, root, rule, params, b):
284
+ """Every file playing the subject role has the sibling role's file beside it.
285
+
286
+ The sibling's name is the subject's with one suffix swapped for another; both suffixes come
287
+ from the overlay, so a module that spells them differently rebinds rather than forks.
288
+ """
289
+ subject = b.role(params["subject_role"])
290
+ if not subject:
291
+ return []
292
+ strip = b.vocabulary.get(params.get("strip_suffix_from_vocabulary", ""))
293
+ append = b.vocabulary.get(params.get("append_suffix_from_vocabulary", ""))
294
+ if not append:
295
+ return []
296
+
297
+ out = []
298
+ for path in iter_glob(screen, subject):
299
+ stem = path.stem
300
+ if strip and stem.endswith(strip):
301
+ stem = stem[: -len(strip)]
302
+ sibling = path.parent / (stem + append + path.suffix)
303
+ if not sibling.exists():
304
+ out.append({"path": str(path), "line": 0, "detail": sibling.name})
305
+ return out
306
+
307
+
308
+ def pattern_for(params, b):
309
+ """A rule whose pattern depends on the module's dialect reads it from the overlay, by slot value.
310
+
311
+ Both halves of a slot are defensible, so the thing that counts as a finding flips with the
312
+ binding: under one value the copy layer is the finding, under the other a raw key at a render
313
+ site is. Neither regex belongs in the shared registry - the module names them.
314
+ """
315
+ by_slot = params.get("pattern_from_vocabulary_by_slot")
316
+ if not by_slot:
317
+ return params.get("pattern")
318
+ value = b.slot(params["slot"])
319
+ key = by_slot.get(value)
320
+ return b.vocabulary.get(key) if key else None
321
+
322
+
323
+ def p_forbidden_pattern(screen, root, rule, params, b):
324
+ subject = params.get("glob")
325
+ if params.get("subject_role"):
326
+ subject = b.role(params["subject_role"])
327
+ elif params.get("glob_from_vocabulary"):
328
+ dirs = b.vocabulary.get(params["glob_from_vocabulary"]) or []
329
+ dirs = [dirs] if isinstance(dirs, str) else dirs
330
+ subject = [f"{d}/**/*.swift" for d in dirs] or None
331
+ expression = pattern_for(params, b)
332
+ if not subject or not expression:
333
+ return []
334
+ regex = re.compile(expression, re.M)
335
+ out = []
336
+ for path in iter_glob(screen, subject):
337
+ if path.suffix != SOURCE_SUFFIX:
338
+ continue
339
+ text = read(path)
340
+ for found in regex.finditer(text):
341
+ line = line_of(text, found.start())
342
+ if excepted(text, line, rule["id"]):
343
+ continue
344
+ out.append({"path": str(path), "line": line, "detail": found.group(0).strip()[:70]})
345
+ return out
346
+
347
+
348
+ def p_required_pattern(screen, root, rule, params, b):
349
+ subject = b.role(params["subject_role"]) if params.get("subject_role") else params.get("glob")
350
+ if not subject:
351
+ return []
352
+ trigger = re.compile(params["when_pattern"], re.M)
353
+ wanted = re.compile(params["pattern"], re.M)
354
+ out = []
355
+ for path in iter_glob(screen, subject):
356
+ if path.suffix != SOURCE_SUFFIX:
357
+ continue
358
+ text = read(path)
359
+ if trigger.search(text) and not wanted.search(text):
360
+ out.append({"path": str(path), "line": 0, "detail": params.get("label", "")})
361
+ return out
362
+
363
+
364
+ def p_forbidden_member(screen, root, rule, params, b):
365
+ subject = b.role(params["subject_role"])
366
+ if not subject:
367
+ return []
368
+ regex = re.compile(params["pattern"], re.M)
369
+ allowed = set(params.get("allow") or [])
370
+ out = []
371
+ for path in iter_glob(screen, subject):
372
+ text = read(path)
373
+ for found in regex.finditer(text):
374
+ name = found.group(1)
375
+ if name in allowed:
376
+ continue
377
+ line = line_of(text, found.start())
378
+ if excepted(text, line, rule["id"]):
379
+ continue
380
+ out.append({"path": str(path), "line": line, "detail": name})
381
+ return out
382
+
383
+
384
+ def p_naming_pattern(screen, root, rule, params, b):
385
+ subject = b.role(params["subject_role"]) if params.get("subject_role") else params.get("glob")
386
+ if not subject:
387
+ return []
388
+ declaration = re.compile(params["declaration"], re.M)
389
+ accepted = params.get("accept")
390
+ vocab_key = params.get("accept_from_vocabulary")
391
+ if vocab_key:
392
+ values = b.vocabulary.get(vocab_key)
393
+ if not values:
394
+ return []
395
+ accepted = "|".join(re.escape(v) for v in values)
396
+ accept_re = re.compile(accepted) if accepted else None
397
+
398
+ rejected = params.get("reject")
399
+ reject_key = params.get("reject_from_vocabulary")
400
+ if reject_key:
401
+ values = b.vocabulary.get(reject_key)
402
+ if not values:
403
+ return []
404
+ rejected = "(" + "|".join(re.escape(v) for v in values) + ")$"
405
+ reject_re = re.compile(rejected) if rejected else None
406
+ match_stem = params.get("match_file_stem")
407
+
408
+ out = []
409
+ for path in iter_glob(screen, subject):
410
+ if path.suffix != SOURCE_SUFFIX:
411
+ continue
412
+ text = read(path)
413
+ names = [m.group(1) for m in declaration.finditer(text)]
414
+ if match_stem:
415
+ # The file is named after its one top-level declaration; extensions of it are fine.
416
+ strangers = [n for n in names if n != path.stem]
417
+ if strangers and path.stem not in names:
418
+ out.append({"path": str(path), "line": 0, "detail": ", ".join(strangers[:3])})
419
+ continue
420
+ for found in declaration.finditer(text):
421
+ name = found.group(1)
422
+ bad = (accept_re and not accept_re.match(name)) or (reject_re and reject_re.search(name))
423
+ if not bad:
424
+ continue
425
+ line = line_of(text, found.start())
426
+ if excepted(text, line, rule["id"]):
427
+ continue
428
+ out.append({"path": str(path), "line": line, "detail": name})
429
+ return out
430
+
431
+
432
+ def p_vocabulary(screen, root, rule, params, b):
433
+ allowed = b.vocabulary.get(params["vocabulary_key"])
434
+ if not allowed:
435
+ return []
436
+ allowed = set(allowed)
437
+ exempt = [re.compile(x) for x in (params.get("exempt_patterns") or [])]
438
+ declaration = re.compile(params["declaration"], re.M)
439
+ out = []
440
+ for path in screen.rglob("*" + SOURCE_SUFFIX):
441
+ text = read(path)
442
+ for found in declaration.finditer(text):
443
+ name = found.group(1).strip()
444
+ if name in allowed or any(rx.search(name) for rx in exempt):
445
+ continue
446
+ line = line_of(text, found.start())
447
+ if excepted(text, line, rule["id"]):
448
+ continue
449
+ out.append({"path": str(path), "line": line, "detail": name})
450
+ return out
451
+
452
+
453
+ def p_file_size(screen, root, rule, params, b):
454
+ limits = b.limits or {}
455
+ out = []
456
+ for path in screen.rglob("*" + SOURCE_SUFFIX):
457
+ kind = "test" if params.get("test_marker", "/Tests/") in str(path) else "source"
458
+ ceiling = limits.get(kind + "_ceiling")
459
+ target = limits.get(kind + "_target")
460
+ if not ceiling:
461
+ continue
462
+ count = read(path).count("\n") + 1
463
+ if count > ceiling:
464
+ out.append({"path": str(path), "line": 0, "detail": f"{count} > {ceiling}"})
465
+ elif target and count > target:
466
+ out.append({"path": str(path), "line": 0, "detail": f"{count} > {target}", "note": True})
467
+ return out
468
+
469
+
470
+ def p_mirror_required(screen, root, rule, params, b):
471
+ return [] # module-level; run once outside the screen walk
472
+
473
+
474
+ def module_mirror(root, rule, params, b):
475
+ """Every directory under the test root has a counterpart under the source root.
476
+
477
+ Only directories: test FILE names carry a suffix the source does not, so matching them would
478
+ measure a naming convention rather than the shape. A test folder with no source counterpart is
479
+ either testing something that moved or grouping by a concept the source does not have.
480
+ """
481
+ test_root = b.role(params["test_role"])
482
+ source_root = b.role(params["source_role"])
483
+ if not (test_root and source_root):
484
+ return []
485
+ tests, sources = root / test_root, root / source_root
486
+ if not tests.is_dir() or not sources.is_dir():
487
+ return []
488
+
489
+ out = []
490
+ for directory in sorted(p for p in tests.rglob("*") if p.is_dir()):
491
+ if not any(f.suffix == SOURCE_SUFFIX for f in directory.iterdir() if f.is_file()):
492
+ continue
493
+ if not (sources / directory.relative_to(tests)).is_dir():
494
+ out.append({"path": str(directory), "line": 0, "detail": "no source counterpart"})
495
+ return out
496
+
497
+
498
+ def p_none(screen, root, rule, params, b):
499
+ return []
500
+
501
+
502
+ # What each predicate cannot run without. A rule missing one of these is reported as disabled,
503
+ # never silently passed: a rule that quietly checks nothing is worse than one that is switched off,
504
+ # because the run then claims coverage it does not have.
505
+ PREDICATE_REQUIRES = {
506
+ "dir_required_in_dir": ["vocabulary_key"],
507
+ "file_required_in_dir": ["role"],
508
+ "pair_required_in_dir": ["container_role", "left_role", "right_role"],
509
+ "sibling_required": ["subject_role", "append_suffix_from_vocabulary"],
510
+ "forbidden_pattern": [], # pattern may arrive via the slot-keyed vocabulary instead
511
+ "required_pattern": ["when_pattern", "pattern"],
512
+ "forbidden_member": ["subject_role", "pattern"],
513
+ "naming_pattern": ["declaration"],
514
+ "vocabulary": ["vocabulary_key", "declaration"],
515
+ "prefix_collision": ["subject_role"],
516
+ "file_size": [],
517
+ "none": [],
518
+ }
519
+
520
+ UNIMPLEMENTED = set()
521
+
522
+
523
+ PREDICATES = {
524
+ "prefix_collision": p_prefix_collision,
525
+ "dir_required_in_dir": p_dir_required_in_dir,
526
+ "file_required_in_dir": p_file_required_in_dir,
527
+ "pair_required_in_dir": p_pair_required_in_dir,
528
+ "sibling_required": p_sibling_required,
529
+ "forbidden_pattern": p_forbidden_pattern,
530
+ "required_pattern": p_required_pattern,
531
+ "forbidden_member": p_forbidden_member,
532
+ "naming_pattern": p_naming_pattern,
533
+ "vocabulary": p_vocabulary,
534
+ "file_size": p_file_size,
535
+ "mirror_required": p_mirror_required,
536
+ "none": p_none,
537
+ }
538
+
539
+
540
+ # ---------------------------------------------------------------------------
541
+ # Rule selection
542
+ # ---------------------------------------------------------------------------
543
+
544
+
545
+ def rule_status(rule, bindings):
546
+ """(enabled, reason). A rule whose slot is unbound is disabled, and says which slot."""
547
+ params = rule.get("params") or {}
548
+ slot = params.get("slot")
549
+ if slot:
550
+ bound = bindings.slot(slot)
551
+ if not bound:
552
+ return False, f"slot {slot} unbound"
553
+ wanted = params.get("slot_value")
554
+ if wanted and bound != wanted:
555
+ return False, f"slot {slot} is {bound}"
556
+ for key in ("role", "subject_role", "sibling_role", "container_role", "left_role", "right_role"):
557
+ name = params.get(key)
558
+ if name and not bindings.role(name):
559
+ return False, f"role {name} unbound"
560
+ if params.get("vocabulary_key") and not bindings.vocabulary.get(params["vocabulary_key"]):
561
+ return False, f"vocabulary {params['vocabulary_key']} unbound"
562
+ if params.get("accept_from_vocabulary") and not bindings.vocabulary.get(params["accept_from_vocabulary"]):
563
+ return False, f"vocabulary {params['accept_from_vocabulary']} unbound"
564
+ if rule.get("predicate") == "file_size" and not bindings.limits:
565
+ return False, "limits unset"
566
+ if rule.get("enforcement") == "judgement":
567
+ return False, "judgement - surfaced, not checked"
568
+
569
+ predicate = rule.get("predicate")
570
+ if predicate in UNIMPLEMENTED:
571
+ return False, "predicate not implemented yet"
572
+ missing = [k for k in PREDICATE_REQUIRES.get(predicate, []) if k not in params]
573
+ if missing:
574
+ return False, "params incomplete: " + ", ".join(missing)
575
+
576
+ return True, ""
577
+
578
+
579
+ # ---------------------------------------------------------------------------
580
+ # Run
581
+ # ---------------------------------------------------------------------------
582
+
583
+
584
+ def run(root, registry, bindings, only_rule=None, only_screen=None):
585
+ screens = discover_screens(root, bindings, only_screen)
586
+ labels = screen_labels(screens, root)
587
+ findings, notes, disabled = [], [], []
588
+
589
+ for rule in registry.get("rules") or []:
590
+ if only_rule and rule["id"] != only_rule:
591
+ continue
592
+ enabled, reason = rule_status(rule, bindings)
593
+ if not enabled:
594
+ disabled.append({"rule_id": rule["id"], "reason": reason, "title": rule["title"]})
595
+ continue
596
+ predicate = PREDICATES.get(rule.get("predicate"))
597
+ if not predicate:
598
+ disabled.append({"rule_id": rule["id"], "reason": "no predicate", "title": rule["title"]})
599
+ continue
600
+ params = rule.get("params") or {}
601
+
602
+ if rule.get("predicate") == "mirror_required":
603
+ for hit in module_mirror(root, rule, params, bindings):
604
+ findings.append({
605
+ "rule_id": rule["id"], "severity": rule["severity"], "screen": "(tests)",
606
+ "path": str(Path(hit["path"]).relative_to(root)), "line": 0,
607
+ "message": rule["title"], "detail": hit["detail"],
608
+ })
609
+ continue
610
+
611
+ if rule.get("predicate") == "prefix_collision":
612
+ for hit in module_prefix_collision(root, rule, params, bindings, screens):
613
+ findings.append({
614
+ "rule_id": rule["id"], "severity": rule["severity"], "screen": "(shared)",
615
+ "path": str(Path(hit["path"]).relative_to(root)), "line": 0,
616
+ "message": rule["title"], "detail": hit["detail"],
617
+ })
618
+ continue
619
+
620
+ for screen in screens:
621
+ if bindings.exempt(rule["id"], screen=screen.name): # carve-outs name the screen
622
+ continue
623
+ for hit in predicate(screen, root, rule, params, bindings):
624
+ if not in_scope(hit["path"], registry):
625
+ continue
626
+ if bindings.exempt(rule["id"], path=hit["path"], detail=hit.get("detail")):
627
+ continue
628
+ record = {
629
+ "rule_id": rule["id"],
630
+ "severity": rule["severity"],
631
+ "screen": labels[screen],
632
+ "path": str(Path(hit["path"]).relative_to(root)) if str(hit["path"]).startswith(str(root)) else hit["path"],
633
+ "line": hit.get("line", 0),
634
+ "message": rule["title"],
635
+ "detail": hit.get("detail", ""),
636
+ }
637
+ (notes if hit.get("note") else findings).append(record)
638
+
639
+ return {
640
+ "screens": [labels[s] for s in screens],
641
+ "findings": findings,
642
+ "notes": notes,
643
+ "disabled": disabled,
644
+ "unbound_roles": bindings.unbound_roles(),
645
+ "unbound_slots": bindings.unbound_slots(),
646
+ }
647
+
648
+
649
+ def render_text(result, root):
650
+ lines = []
651
+ by_screen = {}
652
+ for f in result["findings"]:
653
+ by_screen.setdefault(f["screen"], []).append(f)
654
+ for note in result["notes"]:
655
+ by_screen.setdefault(note["screen"], [])
656
+
657
+ for screen in result["screens"]:
658
+ hits = by_screen.get(screen, [])
659
+ lines.append(f"{screen}: {len(hits)}")
660
+ for f in sorted(hits, key=lambda x: (x["rule_id"], x["path"])):
661
+ where = f"{f['path']}:{f['line']}" if f["line"] else f["path"]
662
+ lines.append(f" - [{f['rule_id']}] {f['message']} :: {where}"
663
+ + (f" ({f['detail']})" if f["detail"] else ""))
664
+ for n in sorted([x for x in result["notes"] if x["screen"] == screen], key=lambda x: x["path"]):
665
+ lines.append(f" ~ [{n['rule_id']}] {n['path']} ({n['detail']})")
666
+
667
+ for bucket in ("(tests)",):
668
+ rows = [f for f in result["findings"] if f["screen"] == bucket]
669
+ if rows:
670
+ lines.append(f"{bucket}: {len(rows)}")
671
+ for f in sorted(rows, key=lambda x: x["path"]):
672
+ lines.append(f" - [{f['rule_id']}] {f['message']} :: {f['path']}"
673
+ + (f" ({f['detail']})" if f["detail"] else ""))
674
+
675
+ shared = [f for f in result["findings"] if f["screen"] == "(shared)"]
676
+ if shared:
677
+ lines.append(f"(shared): {len(shared)}")
678
+ for f in sorted(shared, key=lambda x: (x["rule_id"], x["path"])):
679
+ lines.append(f" - [{f['rule_id']}] {f['message']} :: {f['path']}"
680
+ + (f" ({f['detail']})" if f["detail"] else ""))
681
+
682
+ lines.append("")
683
+ lines.append(f"TOTAL: {len(result['findings'])} finding(s), {len(result['notes'])} note(s)"
684
+ f" across {len(result['screens'])} screen(s)")
685
+ if result["disabled"]:
686
+ lines.append("")
687
+ lines.append(f"DISABLED ({len(result['disabled'])}) - coverage this run did not have:")
688
+ for d in result["disabled"]:
689
+ lines.append(f" {d['rule_id']}: {d['reason']}")
690
+ if result["unbound_slots"]:
691
+ lines.append("")
692
+ lines.append("UNBOUND SLOTS: " + ", ".join(result["unbound_slots"]))
693
+ return "\n".join(lines)
694
+
695
+
696
+ def main():
697
+ parser = argparse.ArgumentParser(description="Check a module's tree against the structure registry.")
698
+ parser.add_argument("--rules", required=True, help="path to references/rules.yml")
699
+ parser.add_argument("--overlay", required=True, help="path to the module's overlay yml")
700
+ parser.add_argument("--root", required=True, help="module root the overlay's globs are relative to")
701
+ parser.add_argument("--only", help="run one rule id")
702
+ parser.add_argument("--screen", help="run one screen")
703
+ parser.add_argument("--format", choices=["text", "json"], default="text")
704
+ args = parser.parse_args()
705
+
706
+ root = Path(args.root).resolve()
707
+ if not root.is_dir():
708
+ sys.stderr.write(f"no such module root: {root}\n")
709
+ return 2
710
+
711
+ for label, given in (("rules", args.rules), ("overlay", args.overlay)):
712
+ if not Path(given).is_file():
713
+ sys.stderr.write(f"no such {label} file: {given}\n")
714
+ return 2
715
+ try:
716
+ registry = yaml.safe_load(Path(args.rules).read_text(encoding="utf-8")) or {}
717
+ overlay = yaml.safe_load(Path(args.overlay).read_text(encoding="utf-8")) or {}
718
+ except yaml.YAMLError as error:
719
+ sys.stderr.write(f"could not parse YAML: {error}\n")
720
+ return 2
721
+
722
+ if not overlay.get("roles", {}).get("screen.root"):
723
+ sys.stderr.write(
724
+ "the overlay binds no screen.root, so there is nothing to walk.\n"
725
+ "Copy modules/_TEMPLATE.yml and bind it from your own tree.\n"
726
+ )
727
+ return 2
728
+
729
+ global SOURCE_SUFFIX
730
+ SOURCE_SUFFIX = source_suffix(registry)
731
+
732
+ # A run that inspected nothing must not read as a run that found nothing. Both print "0", and
733
+ # only one of them means the module is clean.
734
+ if not any(root.rglob("*" + SOURCE_SUFFIX)):
735
+ languages = ", ".join((registry.get("scope") or {}).get("languages") or ["?"])
736
+ sys.stderr.write(
737
+ f"not applicable: this registry speaks for {languages}, and {root} holds no "
738
+ f"*{SOURCE_SUFFIX} file.\n"
739
+ )
740
+ return 2
741
+
742
+ bindings = Bindings(overlay, registry)
743
+
744
+ result = run(root, registry, bindings, args.only, args.screen)
745
+
746
+ if not result["screens"]:
747
+ sys.stderr.write(
748
+ f"the overlay's screen.root matched no directory under {root}, so nothing was "
749
+ f"checked. Rebind it before reading this as a clean run.\n"
750
+ )
751
+ return 2
752
+
753
+ result["module"] = overlay.get("module", root.name)
754
+ result["registry_version"] = registry.get("version")
755
+
756
+ if args.format == "json":
757
+ print(json.dumps(result, indent=2, ensure_ascii=False))
758
+ else:
759
+ print(render_text(result, root))
760
+
761
+ return 1 if result["findings"] else 0
762
+
763
+
764
+ if __name__ == "__main__":
765
+ sys.exit(main())