@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.
- package/CHANGELOG.md +76 -6
- package/README.md +15 -8
- package/README.tr.md +15 -8
- package/docs/FIGMA_PIPELINE.md +3 -3
- package/docs/adr/0006-skills-core-external-split.md +1 -1
- package/docs/adr/0009-claude-stack-skills-plugin-only.md +31 -0
- package/docs/adr/README.md +1 -0
- package/docs/architecture.md +7 -7
- package/docs/ecosystem.md +28 -28
- package/docs/features.md +5 -5
- package/index.js +2 -0
- package/install/_codex-agents.mjs +11 -2
- package/install/_common.mjs +65 -1
- package/install/_dev-only-files.mjs +0 -1
- package/install/_platform-filter.mjs +73 -7
- package/install/_plugin-skills.mjs +19 -8
- package/install/claude.mjs +144 -59
- package/install/codex.mjs +28 -3
- package/install/copilot.mjs +36 -11
- package/install/index.mjs +6 -2
- package/install/templates/codex-instructions.md +1 -1
- package/install/templates/copilot-instructions.md +3 -3
- package/package.json +1 -2
- package/pipeline/commands/multi-agent/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/analysis/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +9 -9
- package/pipeline/commands/multi-agent/channels/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/complaint-analysis/SKILL.md +186 -0
- package/pipeline/commands/multi-agent/dev/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +19 -4
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/jira/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/prune-prompts/SKILL.md +81 -0
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/{ship → resume-local}/SKILL.md +8 -8
- package/pipeline/commands/multi-agent/setup/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/stack/SKILL.md +55 -43
- package/pipeline/commands/multi-agent/store-ready/SKILL.md +3 -3
- package/pipeline/commands/multi-agent/sync/SKILL.md +18 -11
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/uninstall/SKILL.md +2 -0
- package/pipeline/commands/multi-agent/update/SKILL.md +1 -1
- package/pipeline/lib/issue-fetcher.sh +1 -1
- package/pipeline/lib/parse-complaints.sh +306 -0
- package/pipeline/multi-agent-refs/channels/wiki.md +3 -3
- package/pipeline/multi-agent-refs/complaint-analysis-template.md +99 -0
- package/pipeline/multi-agent-refs/component-dispatch.md +6 -6
- package/pipeline/multi-agent-refs/cross-cli-contract.md +16 -16
- package/pipeline/multi-agent-refs/features/external-context-injection.md +1 -1
- package/pipeline/multi-agent-refs/features/stack-skill-routing.md +5 -5
- package/pipeline/multi-agent-refs/generate-issue.md +1 -1
- package/pipeline/multi-agent-refs/phases/modes.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +1 -1
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +5 -5
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +3 -3
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +12 -12
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +1 -1
- package/pipeline/multi-agent-refs/wiki-capture.md +2 -2
- package/pipeline/preferences-template.json +13 -5
- package/pipeline/rules/figma-pipeline.md +2 -2
- package/pipeline/schemas/agent-state.schema.json +1 -1
- package/pipeline/schemas/complaint-analysis-spec.schema.json +216 -0
- package/pipeline/schemas/migrations/prefs-2.5.0-to-2.6.0.mjs +46 -0
- package/pipeline/schemas/prefs.schema.json +276 -66
- package/pipeline/schemas/token-budget.json +2 -2
- package/pipeline/scripts/_stack-routing.mjs +79 -0
- package/pipeline/scripts/audit-log-rotate.sh +4 -1
- package/pipeline/scripts/build-skills-index.mjs +11 -0
- package/pipeline/scripts/build-stack-plugins.mjs +28 -60
- package/pipeline/scripts/check-derived-drift.mjs +52 -28
- package/pipeline/scripts/gc-worktrees.sh +4 -1
- package/pipeline/scripts/gen-skills-index.mjs +1 -1
- package/pipeline/scripts/match-skills.mjs +8 -2
- package/pipeline/scripts/migrate-prefs.mjs +28 -20
- package/pipeline/scripts/phase-tracker.sh +13 -5
- package/pipeline/scripts/phase0-exit-gate.mjs +3 -2
- package/pipeline/scripts/run-aggregator.mjs +7 -2
- package/pipeline/scripts/scan-agent-config.sh +1 -1
- package/pipeline/scripts/skill-conformance.mjs +165 -30
- package/pipeline/scripts/smoke-cross-cli-behavior.sh +1 -1
- package/pipeline/scripts/test-gap-rules/android.json +25 -0
- package/pipeline/scripts/test-gap-rules/ios.json +34 -0
- package/pipeline/scripts/test-gap-rules/node.json +29 -0
- package/pipeline/scripts/test-gap-rules/python.json +25 -0
- package/pipeline/scripts/uninstall.mjs +158 -11
- package/pipeline/scripts/validate-complaint-doc.mjs +229 -0
- package/pipeline/scripts/validate-reviewer.mjs +9 -3
- package/pipeline/skills/.skill-manifest.json +156 -108
- package/pipeline/skills/.skills-index.json +449 -12
- package/pipeline/skills/shared/README.md +14 -10
- package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-complaint-analysis/SKILL.md +49 -0
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-prune-prompts/SKILL.md +83 -0
- package/pipeline/skills/shared/core/{multi-agent-ship → multi-agent-resume-local}/SKILL.md +6 -6
- package/pipeline/skills/shared/core/multi-agent-stack/SKILL.md +79 -22
- package/pipeline/skills/shared/core/multi-agent-store-ready/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-sync/SKILL.md +8 -8
- package/pipeline/skills/shared/core/multi-agent-testflight-validation/SKILL.md +1 -1
- package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +2 -2
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +368 -33
- package/pipeline/skills/shared/external/ios-coding-standard/references/swiftlint.draft.yml +1 -2
- package/pipeline/skills/shared/external/ios-coding-standard/scripts/check_structure.py +765 -0
- package/pipeline/skills/shared/external/ios-module-structure/SKILL.md +75 -0
- package/pipeline/skills/shared/external/ios-module-structure/modules/_TEMPLATE.yml +131 -0
- package/pipeline/skills/shared/external/ios-module-structure/references/rules.yml +559 -0
- package/pipeline/skills/shared/external/ios-module-structure/scripts/check_structure.py +765 -0
- package/pipeline/skills/shared/external/localization-reuse-map/example-mapping.json +53 -10
- package/pipeline/skills/shared/external/localization-reuse-map/reference/sources-and-recipes.md +4 -3
- 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())
|