@mmerterden/multi-agent-pipeline 13.5.0 → 14.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 +243 -0
- package/README.md +3 -3
- package/docs/features.md +1 -1
- package/install/_common.mjs +73 -0
- package/install/_mcp-register.mjs +70 -31
- package/install/_plugin-skills.mjs +73 -14
- package/install/claude.mjs +28 -4
- package/install/codex.mjs +33 -2
- package/install/copilot.mjs +145 -9
- package/install/index.mjs +10 -6
- package/install/templates/copilot-instructions.md +1 -1
- package/package.json +1 -1
- package/pipeline/agents/code-reviewer.md +58 -1
- package/pipeline/commands/multi-agent/SKILL.md +7 -5
- package/pipeline/commands/multi-agent/analysis/SKILL.md +7 -7
- package/pipeline/commands/multi-agent/analysis-resolve/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/build-optimize/SKILL.md +7 -7
- package/pipeline/commands/multi-agent/channels/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/dev/SKILL.md +23 -18
- package/pipeline/commands/multi-agent/dev-autopilot/SKILL.md +19 -13
- package/pipeline/commands/multi-agent/dev-local/SKILL.md +14 -12
- package/pipeline/commands/multi-agent/dev-local-autopilot/SKILL.md +17 -12
- package/pipeline/commands/multi-agent/garbage-collect/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/help/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/ios-coding-standard/SKILL.md +2 -2
- package/pipeline/commands/multi-agent/local-autopilot/SKILL.md +4 -4
- package/pipeline/commands/multi-agent/resume/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/review/SKILL.md +5 -5
- package/pipeline/commands/multi-agent/scan/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/search/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/setup/SKILL.md +6 -6
- package/pipeline/commands/multi-agent/{finish → ship}/SKILL.md +12 -12
- package/pipeline/commands/multi-agent/testflight-validation/SKILL.md +1 -1
- package/pipeline/commands/multi-agent/update/SKILL.md +5 -2
- package/pipeline/commands/sim-test.md +2 -2
- package/pipeline/lib/credential-store-resolver.sh +16 -0
- package/pipeline/lib/credential-store.sh +47 -4
- package/pipeline/lib/fetch-figma-annotations.sh +26 -28
- package/pipeline/lib/figma-screenshot.sh +28 -39
- package/pipeline/lib/figma-token.sh +63 -0
- package/pipeline/multi-agent-refs/analysis-template.md +1 -1
- package/pipeline/multi-agent-refs/android-guide.md +1 -1
- package/pipeline/multi-agent-refs/channels/issue-comment.md +1 -1
- package/pipeline/multi-agent-refs/component-dispatch.md +2 -2
- package/pipeline/multi-agent-refs/cross-cli-contract.md +4 -4
- package/pipeline/multi-agent-refs/features/dev-critic.md +2 -2
- package/pipeline/multi-agent-refs/features/model-fallback.md +35 -2
- package/pipeline/multi-agent-refs/features/plan-todos.md +1 -1
- package/pipeline/multi-agent-refs/features/repo-map.md +1 -1
- package/pipeline/multi-agent-refs/features/review-multi-repo.md +3 -3
- package/pipeline/multi-agent-refs/features/shadow-git.md +1 -1
- package/pipeline/multi-agent-refs/features/skill-conformance.md +116 -0
- package/pipeline/multi-agent-refs/features/verify-by-test.md +1 -1
- package/pipeline/multi-agent-refs/generate-issue.md +1 -1
- package/pipeline/multi-agent-refs/multi-repo-integration-build.md +1 -1
- package/pipeline/multi-agent-refs/phases/log-format.md +4 -4
- package/pipeline/multi-agent-refs/phases/modes.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-0-init.md +13 -11
- package/pipeline/multi-agent-refs/phases/phase-1-analysis.md +17 -15
- package/pipeline/multi-agent-refs/phases/phase-2-planning.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-3-dev.md +28 -13
- package/pipeline/multi-agent-refs/phases/phase-4-review.md +90 -58
- package/pipeline/multi-agent-refs/phases/phase-5-test.md +7 -7
- package/pipeline/multi-agent-refs/phases/phase-6-commit.md +8 -8
- package/pipeline/multi-agent-refs/phases/phase-7-report.md +8 -8
- package/pipeline/multi-agent-refs/phases.md +13 -13
- package/pipeline/multi-agent-refs/progress-contract.md +2 -2
- package/pipeline/multi-agent-refs/rules.md +7 -5
- package/pipeline/multi-agent-refs/swiftui-guide.md +1 -1
- package/pipeline/multi-agent-refs/tracker-contract.md +16 -15
- package/pipeline/preferences-template.json +7 -1
- package/pipeline/rules/figma-pipeline.md +2 -2
- package/pipeline/schemas/agent-state.schema.json +333 -79
- package/pipeline/schemas/criteria-manifest.schema.json +228 -0
- package/pipeline/schemas/migrations/prefs-2.4.0-to-2.5.0.mjs +64 -0
- package/pipeline/schemas/prefs.schema.json +118 -262
- package/pipeline/schemas/reviewer-output.schema.json +48 -3
- package/pipeline/schemas/token-budget.json +34 -10
- package/pipeline/schemas/triage-output.schema.json +112 -27
- package/pipeline/scripts/cost-table.json +7 -4
- package/pipeline/scripts/gc-worktrees.sh +1 -1
- package/pipeline/scripts/gen-mode-dispatch.mjs +6 -6
- package/pipeline/scripts/match-skills.mjs +37 -4
- package/pipeline/scripts/migrate-prefs.mjs +88 -17
- package/pipeline/scripts/phase-tracker.sh +14 -3
- package/pipeline/scripts/pre-commit-check.sh +49 -2
- package/pipeline/scripts/skill-conformance.mjs +960 -0
- package/pipeline/scripts/smoke-schema-validation.sh +17 -4
- package/pipeline/scripts/uninstall.mjs +35 -9
- package/pipeline/scripts/validate-reviewer.mjs +108 -1
- package/pipeline/skills/.skill-manifest.json +1 -1
- package/pipeline/skills/.skills-index.json +36 -9
- package/pipeline/skills/shared/README.md +15 -12
- package/pipeline/skills/shared/core/apple-archive-compliance/SKILL.md +1 -0
- package/pipeline/skills/shared/core/apple-archive-compliance/references/rules.yml +167 -0
- package/pipeline/skills/shared/core/google-play-compliance/SKILL.md +1 -0
- package/pipeline/skills/shared/core/google-play-compliance/references/rules.yml +184 -0
- package/pipeline/skills/shared/core/multi-agent/SKILL.md +10 -10
- package/pipeline/skills/shared/core/multi-agent-analysis/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-analysis-resolve/SKILL.md +3 -3
- package/pipeline/skills/shared/core/multi-agent-build-optimize/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-create-jira/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-dev/SKILL.md +6 -5
- package/pipeline/skills/shared/core/multi-agent-dev-autopilot/SKILL.md +7 -6
- package/pipeline/skills/shared/core/multi-agent-dev-local/SKILL.md +4 -3
- package/pipeline/skills/shared/core/multi-agent-dev-local-autopilot/SKILL.md +2 -1
- package/pipeline/skills/shared/core/multi-agent-help/SKILL.md +2 -2
- package/pipeline/skills/shared/core/multi-agent-ios-coding-standard/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-local-autopilot/SKILL.md +4 -4
- package/pipeline/skills/shared/core/multi-agent-review/SKILL.md +5 -5
- package/pipeline/skills/shared/core/multi-agent-scan/SKILL.md +1 -1
- package/pipeline/skills/shared/core/multi-agent-search/SKILL.md +1 -1
- package/pipeline/skills/shared/core/{multi-agent-finish → multi-agent-ship}/SKILL.md +8 -8
- package/pipeline/skills/shared/external/ios-coding-standard/SKILL.md +44 -5
- package/pipeline/skills/shared/external/ios-coding-standard/modules/_TEMPLATE.yml +82 -0
- package/pipeline/skills/shared/external/ios-coding-standard/references/STANDARD.md +169 -10
- package/pipeline/skills/shared/external/ios-coding-standard/references/lint-local.sh +13 -1
- package/pipeline/skills/shared/external/ios-coding-standard/references/rules.yml +335 -16
- package/pipeline/skills/skills-index.md +11 -8
|
@@ -0,0 +1,960 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// skill-conformance.mjs - resolve WHAT the changed code was supposed to honour,
|
|
3
|
+
// before any reviewer looks at it. Zero LLM calls.
|
|
4
|
+
//
|
|
5
|
+
// Phase 4 used to pick its review criteria from `detectedStack`, a string Phase 1
|
|
6
|
+
// computed before a line of code existed, and the dev phase never recorded which
|
|
7
|
+
// skills it actually applied. So "was this done correctly" could only ever be
|
|
8
|
+
// answered as "it looks fine to me". This script produces the missing denominator:
|
|
9
|
+
// a bounded, enumerable set of rule IDs plus the criteria sources they came from,
|
|
10
|
+
// written to disk BEFORE the reviewers run so it cannot be renegotiated after.
|
|
11
|
+
//
|
|
12
|
+
// Usage:
|
|
13
|
+
// skill-conformance.mjs --diff <patch-file> [--changed-files a.swift,b.kt]
|
|
14
|
+
// [--state <agent-state.json>] # reads telemetry.skillCalls[]
|
|
15
|
+
// [--repo <root>] # default: cwd
|
|
16
|
+
// [--out <criteria-manifest.json>]
|
|
17
|
+
// [--skills-root <dir>] # override discovery root
|
|
18
|
+
// [--json] # manifest to stdout
|
|
19
|
+
//
|
|
20
|
+
// Contract (full spec: multi-agent-refs/features/skill-conformance.md):
|
|
21
|
+
// 1. Registry discovery is DECLARED, never sniffed by name: a skill opts in with
|
|
22
|
+
// `standards-registry: <relative path>` in its SKILL.md frontmatter. The
|
|
23
|
+
// pipeline therefore names no stack-specific skill, and a new language's
|
|
24
|
+
// registry drops in with zero pipeline change.
|
|
25
|
+
// 2. Every registry declares its own `scope` (languages / paths). Rules outside
|
|
26
|
+
// the changed files' languages are DROPPED and counted. Without this an
|
|
27
|
+
// Objective-C or UIKit diff would collect the whole Swift/SwiftUI rule set,
|
|
28
|
+
// which manufactures findings and buries the real ones.
|
|
29
|
+
// 3. The resolver is PRIMARY; `telemetry.skillCalls[]` only corroborates. An
|
|
30
|
+
// unrecorded consultation and no consultation are byte-identical in state, so
|
|
31
|
+
// a coverage number computed from self-report reads green over an empty set.
|
|
32
|
+
// 4. Absent, empty and zero are three different states in the output.
|
|
33
|
+
//
|
|
34
|
+
// Exit codes:
|
|
35
|
+
// 0 manifest written (may still carry findings or declared coverage gaps)
|
|
36
|
+
// 1 setup error: no diff, unreadable state, or an explicit --skills-root that
|
|
37
|
+
// does not exist. A typo there used to resolve to zero roots and produce a
|
|
38
|
+
// clean review over criteria that were never loaded.
|
|
39
|
+
// 2 fail-closed: no skills root resolved at all, a declared registry could not
|
|
40
|
+
// be parsed, or a declared path escaped its skill directory. All named in the
|
|
41
|
+
// manifest (`resolutionFailure`, `unparseableRegistries`).
|
|
42
|
+
|
|
43
|
+
import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from "node:fs";
|
|
44
|
+
import { dirname, join, resolve, relative, basename, extname, sep } from "node:path";
|
|
45
|
+
import { fileURLToPath } from "node:url";
|
|
46
|
+
|
|
47
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// Skills-root resolution
|
|
51
|
+
//
|
|
52
|
+
// This script runs from four different trees: `<repo>/pipeline/scripts` in a
|
|
53
|
+
// checkout, and `~/.claude/scripts`, `~/.copilot/scripts`, `~/.codex/scripts`
|
|
54
|
+
// once installed. install/copilot.mjs copies `scripts/` byte-for-byte with no
|
|
55
|
+
// path rewrite, and Codex puts skills under `multi-agent-refs/skills`, so a
|
|
56
|
+
// hardcoded `~/.claude/skills` is inert on two of the three hosts. That exact
|
|
57
|
+
// bug already shipped once here (see match-skills.mjs header): dynamic skill
|
|
58
|
+
// loading exited 1 on every real install while passing a smoke that ran from
|
|
59
|
+
// the repo. Installed layouts are tried first - that is where a run happens.
|
|
60
|
+
// ---------------------------------------------------------------------------
|
|
61
|
+
function skillsRootCandidates() {
|
|
62
|
+
const installRoot = resolve(here, ".."); // ~/.claude | ~/.copilot | ~/.codex
|
|
63
|
+
const repoRoot = resolve(here, "..", ".."); // <repo>
|
|
64
|
+
return [
|
|
65
|
+
join(installRoot, "skills"), // ~/.claude/skills, ~/.copilot/skills
|
|
66
|
+
join(installRoot, "multi-agent-refs", "skills"), // ~/.codex/multi-agent-refs/skills
|
|
67
|
+
join(repoRoot, "pipeline", "skills"), // checkout
|
|
68
|
+
];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the roots to search, and report every candidate that was PROBED - not
|
|
73
|
+
* only the ones that existed.
|
|
74
|
+
*
|
|
75
|
+
* Returning just the survivors made `skillsRootsSearched` empty exactly when it
|
|
76
|
+
* mattered most: a run that found no roots produced `[]`, so "no registries" and
|
|
77
|
+
* "wrong root" were indistinguishable - the failure this field exists to
|
|
78
|
+
* attribute. The schema and the feature doc both promise attribution, so the
|
|
79
|
+
* candidates carry an `existed` flag instead.
|
|
80
|
+
*
|
|
81
|
+
* @returns {{roots: string[], probed: Array<{path: string, existed: boolean}>}}
|
|
82
|
+
*/
|
|
83
|
+
function resolveSkillsRoots(override) {
|
|
84
|
+
const candidates = override ? [override] : skillsRootCandidates();
|
|
85
|
+
const probed = candidates.map((p) => ({ path: p, existed: existsSync(p) }));
|
|
86
|
+
return { roots: probed.filter((p) => p.existed).map((p) => p.path), probed };
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ---------------------------------------------------------------------------
|
|
90
|
+
// Zero-dependency YAML subset reader.
|
|
91
|
+
//
|
|
92
|
+
// The runtime ships no YAML parser by policy. Semantic parsing is not needed and
|
|
93
|
+
// not attempted: the reviewers are handed the registry FILE PATH and read the
|
|
94
|
+
// YAML natively. This extracts only the flat scalars the gate must reason about,
|
|
95
|
+
// anchored so nested maps cannot contribute.
|
|
96
|
+
//
|
|
97
|
+
// Anchoring is load-bearing. A naive `grep -c ' - id:'` over the 99-rule iOS
|
|
98
|
+
// registry returns 112: the extra 13 are `module_overlay_slots.slots[].id` and
|
|
99
|
+
// friends at a deeper indent. Counting those as rules would inflate the
|
|
100
|
+
// denominator with entries that are not rules, and every conformance percentage
|
|
101
|
+
// computed from it would be quietly wrong.
|
|
102
|
+
// ---------------------------------------------------------------------------
|
|
103
|
+
function readRegistry(path) {
|
|
104
|
+
const text = readFileSync(path, "utf8");
|
|
105
|
+
const lines = text.split("\n");
|
|
106
|
+
|
|
107
|
+
const meta = { version: null, exceptionMarker: null, scope: null, notCovered: {} };
|
|
108
|
+
const rules = [];
|
|
109
|
+
|
|
110
|
+
let inRules = false;
|
|
111
|
+
let inTopScope = false;
|
|
112
|
+
let inScopeList = null; // "languages" | "paths" | "excludePaths" | "notCovered"
|
|
113
|
+
let cur = null;
|
|
114
|
+
|
|
115
|
+
const pushCur = () => {
|
|
116
|
+
if (cur) rules.push(cur);
|
|
117
|
+
cur = null;
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
for (const raw of lines) {
|
|
121
|
+
const line = raw.replace(/\s+$/, "");
|
|
122
|
+
if (!line || /^\s*#/.test(line)) continue;
|
|
123
|
+
|
|
124
|
+
// Top-level keys end any nested context.
|
|
125
|
+
const top = /^([a-z_]+):(.*)$/.exec(line);
|
|
126
|
+
if (top) {
|
|
127
|
+
const [, key, rest] = top;
|
|
128
|
+
if (key !== "rules") pushCur();
|
|
129
|
+
inRules = key === "rules";
|
|
130
|
+
inTopScope = key === "scope";
|
|
131
|
+
inScopeList = null;
|
|
132
|
+
|
|
133
|
+
if (key === "version") meta.version = rest.trim() || null;
|
|
134
|
+
if (key === "exception_marker") {
|
|
135
|
+
meta.exceptionMarker = rest.trim().replace(/^["']|["']$/g, "") || null;
|
|
136
|
+
}
|
|
137
|
+
if (key === "severity_levels") meta.severityLevels = parseInlineList(rest);
|
|
138
|
+
if (inTopScope) meta.scope = { languages: [], paths: [], excludePaths: [] };
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (inTopScope && meta.scope) {
|
|
143
|
+
const kv = /^ {2}([a-zA-Z_-]+):(.*)$/.exec(line);
|
|
144
|
+
if (kv) {
|
|
145
|
+
const [, key, rest] = kv;
|
|
146
|
+
inScopeList = null;
|
|
147
|
+
if (key === "languages") {
|
|
148
|
+
const inline = parseInlineList(rest);
|
|
149
|
+
if (inline.length) meta.scope.languages = inline;
|
|
150
|
+
else inScopeList = "languages";
|
|
151
|
+
} else if (key === "paths" || key === "excludePaths") {
|
|
152
|
+
const inline = parseInlineList(rest);
|
|
153
|
+
if (inline.length) meta.scope[key] = inline;
|
|
154
|
+
else inScopeList = key;
|
|
155
|
+
} else if (key === "notCovered") {
|
|
156
|
+
inScopeList = "notCovered";
|
|
157
|
+
}
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if (inScopeList === "notCovered") {
|
|
161
|
+
const nc = /^ {4}([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line);
|
|
162
|
+
if (nc) meta.notCovered[nc[1]] = nc[2].trim();
|
|
163
|
+
continue;
|
|
164
|
+
}
|
|
165
|
+
if (inScopeList) {
|
|
166
|
+
const item = /^ {4}- (.*)$/.exec(line);
|
|
167
|
+
if (item) meta.scope[inScopeList].push(unquote(item[1].trim()));
|
|
168
|
+
continue;
|
|
169
|
+
}
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
if (!inRules) continue;
|
|
174
|
+
|
|
175
|
+
// A rule starts at exactly two spaces. Deeper `- id:` lines belong to a
|
|
176
|
+
// nested structure and are deliberately ignored.
|
|
177
|
+
const idLine = /^ {2}- id:\s*(.+)$/.exec(line);
|
|
178
|
+
if (idLine) {
|
|
179
|
+
pushCur();
|
|
180
|
+
cur = { id: idLine[1].trim(), scope: null, statusDeclared: false };
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (!cur) continue;
|
|
184
|
+
|
|
185
|
+
const field = /^ {4}([a-zA-Z_]+):\s*(.*)$/.exec(line);
|
|
186
|
+
if (!field) continue;
|
|
187
|
+
const [, key, value] = field;
|
|
188
|
+
const v = value.trim();
|
|
189
|
+
|
|
190
|
+
if (key === "severity") cur.severity = v;
|
|
191
|
+
else if (key === "enforcement") cur.enforcement = v;
|
|
192
|
+
else if (key === "status") {
|
|
193
|
+
cur.status = v;
|
|
194
|
+
cur.statusDeclared = true;
|
|
195
|
+
} else if (key === "mechanism") cur.mechanism = v || "(block scalar)";
|
|
196
|
+
else if (key === "rationale") cur.rationale = v;
|
|
197
|
+
else if (key === "scope") cur.scope = parseInlineScope(v);
|
|
198
|
+
else if (key === "scope_reason") cur.scopeReason = v;
|
|
199
|
+
else if (key === "title") cur.title = v === ">" ? null : v;
|
|
200
|
+
}
|
|
201
|
+
pushCur();
|
|
202
|
+
|
|
203
|
+
return { meta, rules };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function unquote(s) {
|
|
207
|
+
return s.replace(/^["']|["']$/g, "");
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function parseInlineList(rest) {
|
|
211
|
+
const m = /^\s*\[(.*)\]\s*$/.exec(rest);
|
|
212
|
+
if (!m) return [];
|
|
213
|
+
return m[1]
|
|
214
|
+
.split(",")
|
|
215
|
+
.map((s) => unquote(s.trim()))
|
|
216
|
+
.filter(Boolean);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// `scope: { frameworks: [swiftui], paths: ["**/*View.swift"] }`
|
|
220
|
+
function parseInlineScope(v) {
|
|
221
|
+
if (!v.startsWith("{")) return null;
|
|
222
|
+
const out = {};
|
|
223
|
+
for (const key of ["languages", "frameworks", "paths", "excludePaths"]) {
|
|
224
|
+
const m = new RegExp(`${key}:\\s*\\[([^\\]]*)\\]`).exec(v);
|
|
225
|
+
if (m) {
|
|
226
|
+
out[key] = m[1]
|
|
227
|
+
.split(",")
|
|
228
|
+
.map((s) => unquote(s.trim()))
|
|
229
|
+
.filter(Boolean);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return Object.keys(out).length ? out : null;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
// ---------------------------------------------------------------------------
|
|
236
|
+
// Frontmatter reader: only the keys discovery needs.
|
|
237
|
+
// ---------------------------------------------------------------------------
|
|
238
|
+
function readFrontmatter(path) {
|
|
239
|
+
const text = readFileSync(path, "utf8");
|
|
240
|
+
if (!text.startsWith("---")) return {};
|
|
241
|
+
const end = text.indexOf("\n---", 3);
|
|
242
|
+
if (end < 0) return {};
|
|
243
|
+
const out = {};
|
|
244
|
+
for (const line of text.slice(3, end).split("\n")) {
|
|
245
|
+
const m = /^([a-zA-Z0-9_-]+):\s*(.*)$/.exec(line);
|
|
246
|
+
if (m) out[m[1]] = unquote(m[2].trim());
|
|
247
|
+
}
|
|
248
|
+
return out;
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// ---------------------------------------------------------------------------
|
|
252
|
+
// Language classification from file extension.
|
|
253
|
+
//
|
|
254
|
+
// This replaces `detectedStack` for the dev modes, where Phase 1 never ran. It
|
|
255
|
+
// is deliberately extension-based: it describes what the diff CONTAINS, which is
|
|
256
|
+
// what the criteria have to match, rather than what the repo is nominally built
|
|
257
|
+
// in. A Swift repo with one Objective-C bridging file must not get Swift rules
|
|
258
|
+
// applied to that file.
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
const LANG_BY_EXT = {
|
|
261
|
+
".swift": "swift",
|
|
262
|
+
".m": "objective-c",
|
|
263
|
+
".mm": "objective-c",
|
|
264
|
+
".h": "objective-c-header",
|
|
265
|
+
".kt": "kotlin",
|
|
266
|
+
".kts": "kotlin",
|
|
267
|
+
".java": "java",
|
|
268
|
+
".py": "python",
|
|
269
|
+
".js": "javascript",
|
|
270
|
+
".mjs": "javascript",
|
|
271
|
+
".cjs": "javascript",
|
|
272
|
+
".ts": "typescript",
|
|
273
|
+
".tsx": "typescript",
|
|
274
|
+
".jsx": "javascript",
|
|
275
|
+
".go": "go",
|
|
276
|
+
".rb": "ruby",
|
|
277
|
+
".rs": "rust",
|
|
278
|
+
".c": "c",
|
|
279
|
+
".cc": "cpp",
|
|
280
|
+
".cpp": "cpp",
|
|
281
|
+
".hpp": "cpp",
|
|
282
|
+
".xml": "xml",
|
|
283
|
+
".plist": "plist",
|
|
284
|
+
".gradle": "gradle",
|
|
285
|
+
".yml": "yaml",
|
|
286
|
+
".yaml": "yaml",
|
|
287
|
+
".json": "json",
|
|
288
|
+
".sh": "shell",
|
|
289
|
+
".bash": "shell",
|
|
290
|
+
};
|
|
291
|
+
|
|
292
|
+
function classifyLanguage(file) {
|
|
293
|
+
const base = basename(file);
|
|
294
|
+
if (base === "Info.plist" || base.endsWith(".plist")) return "plist";
|
|
295
|
+
if (base === "project.pbxproj") return "pbxproj";
|
|
296
|
+
if (base.endsWith(".entitlements")) return "plist";
|
|
297
|
+
if (base === "AndroidManifest.xml") return "xml";
|
|
298
|
+
if (base.endsWith(".gradle.kts")) return "gradle";
|
|
299
|
+
return LANG_BY_EXT[extname(base).toLowerCase()] ?? "other";
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
// ---------------------------------------------------------------------------
|
|
303
|
+
// Minimal glob matcher for the `**/x`, `*.ext`, `dir/**` shapes registries use.
|
|
304
|
+
// ---------------------------------------------------------------------------
|
|
305
|
+
function globToRegExp(glob) {
|
|
306
|
+
let re = "";
|
|
307
|
+
for (let i = 0; i < glob.length; i++) {
|
|
308
|
+
const c = glob[i];
|
|
309
|
+
if (c === "*") {
|
|
310
|
+
if (glob[i + 1] === "*") {
|
|
311
|
+
// `**/` matches zero or more path segments.
|
|
312
|
+
if (glob[i + 2] === "/") {
|
|
313
|
+
re += "(?:.*/)?";
|
|
314
|
+
i += 2;
|
|
315
|
+
} else {
|
|
316
|
+
re += ".*";
|
|
317
|
+
i += 1;
|
|
318
|
+
}
|
|
319
|
+
} else {
|
|
320
|
+
re += "[^/]*";
|
|
321
|
+
}
|
|
322
|
+
} else if (c === "?") re += "[^/]";
|
|
323
|
+
else re += c.replace(/[.+^${}()|[\]\\]/g, "\\$&");
|
|
324
|
+
}
|
|
325
|
+
return new RegExp(`^${re}$`);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
function matchesAnyGlob(file, globs) {
|
|
329
|
+
if (!globs || globs.length === 0) return true;
|
|
330
|
+
return globs.some((g) => globToRegExp(g).test(file));
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// ---------------------------------------------------------------------------
|
|
334
|
+
// Diff parsing: changed files and added lines (for the exception audit).
|
|
335
|
+
// ---------------------------------------------------------------------------
|
|
336
|
+
/**
|
|
337
|
+
* Decode git's quoted-path form: backslash escapes plus \NNN octal bytes, which
|
|
338
|
+
* git emits per-byte for non-ASCII (a Turkish "ü" arrives as two octal escapes).
|
|
339
|
+
* Decoding octal per byte and then interpreting the result as UTF-8 is what makes
|
|
340
|
+
* a non-ASCII filename come back intact rather than mojibake.
|
|
341
|
+
*/
|
|
342
|
+
function unquoteGitPath(p) {
|
|
343
|
+
if (!p.includes("\\")) return p;
|
|
344
|
+
const bytes = [];
|
|
345
|
+
for (let i = 0; i < p.length; i++) {
|
|
346
|
+
if (p[i] !== "\\") {
|
|
347
|
+
bytes.push(...Buffer.from(p[i], "utf8"));
|
|
348
|
+
continue;
|
|
349
|
+
}
|
|
350
|
+
const next = p[i + 1];
|
|
351
|
+
if (next >= "0" && next <= "7") {
|
|
352
|
+
bytes.push(parseInt(p.slice(i + 1, i + 4), 8));
|
|
353
|
+
i += 3;
|
|
354
|
+
} else {
|
|
355
|
+
const simple = { n: 10, t: 9, r: 13, '"': 34, "\\": 92 };
|
|
356
|
+
bytes.push(simple[next] ?? next.charCodeAt(0));
|
|
357
|
+
i += 1;
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
return Buffer.from(bytes).toString("utf8");
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
function parseDiff(patch) {
|
|
364
|
+
const files = [];
|
|
365
|
+
const addedByFile = new Map();
|
|
366
|
+
let current = null;
|
|
367
|
+
let newLineNo = 0;
|
|
368
|
+
|
|
369
|
+
for (const line of patch.split("\n")) {
|
|
370
|
+
// git QUOTES a path containing spaces or non-ASCII: `+++ "b/Sources/Ekran
|
|
371
|
+
// Gorunumu.swift"`. Matching only the bare form dropped such a file from
|
|
372
|
+
// changedFiles entirely, so it appeared neither in selectedRules nor in
|
|
373
|
+
// coverage.uncoveredFiles - an unrecorded hole in the very denominator this
|
|
374
|
+
// gate exists to make complete. Turkish, and any non-ASCII, filename hits this.
|
|
375
|
+
const bPath = /^\+\+\+ (?:"b\/(.+)"|b\/(.+))$/.exec(line);
|
|
376
|
+
if (bPath) {
|
|
377
|
+
current = unquoteGitPath(bPath[1] ?? bPath[2]);
|
|
378
|
+
if (current !== "/dev/null" && !files.includes(current)) files.push(current);
|
|
379
|
+
addedByFile.set(current, addedByFile.get(current) ?? []);
|
|
380
|
+
continue;
|
|
381
|
+
}
|
|
382
|
+
const hunk = /^@@ -\d+(?:,\d+)? \+(\d+)(?:,\d+)? @@/.exec(line);
|
|
383
|
+
if (hunk) {
|
|
384
|
+
newLineNo = parseInt(hunk[1], 10);
|
|
385
|
+
continue;
|
|
386
|
+
}
|
|
387
|
+
if (!current) continue;
|
|
388
|
+
if (line.startsWith("+") && !line.startsWith("+++")) {
|
|
389
|
+
addedByFile.get(current).push({ line: newLineNo, text: line.slice(1) });
|
|
390
|
+
newLineNo++;
|
|
391
|
+
} else if (line.startsWith("-") && !line.startsWith("---")) {
|
|
392
|
+
// deletion: new-side line number does not advance
|
|
393
|
+
} else if (line.startsWith(" ")) {
|
|
394
|
+
newLineNo++;
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { files, addedByFile };
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// ---------------------------------------------------------------------------
|
|
401
|
+
// Module guide walk. Same deterministic contract as /multi-agent:review Step 2b:
|
|
402
|
+
// walk each changed file's directory chain to the repo root, collecting guides
|
|
403
|
+
// the host CLI does not auto-load. Root-level files are excluded because the CLI
|
|
404
|
+
// already loads those. Capped, with the drop logged rather than silent.
|
|
405
|
+
// ---------------------------------------------------------------------------
|
|
406
|
+
const GUIDE_CAP = 5;
|
|
407
|
+
|
|
408
|
+
function collectModuleGuides(repoRoot, changedFiles) {
|
|
409
|
+
const found = new Set();
|
|
410
|
+
for (const f of changedFiles) {
|
|
411
|
+
let dir = dirname(resolve(repoRoot, f));
|
|
412
|
+
while (dir.startsWith(repoRoot) && dir !== repoRoot) {
|
|
413
|
+
for (const name of readdirSafe(dir)) {
|
|
414
|
+
if (name === "CLAUDE.md" || name === "AGENTS.md" || name.endsWith("-CLAUDE.md")) {
|
|
415
|
+
found.add(relative(repoRoot, join(dir, name)));
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
dir = dirname(dir);
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
const all = [...found].sort();
|
|
422
|
+
return { guides: all.slice(0, GUIDE_CAP), dropped: all.slice(GUIDE_CAP) };
|
|
423
|
+
}
|
|
424
|
+
|
|
425
|
+
function readdirSafe(dir) {
|
|
426
|
+
try {
|
|
427
|
+
return readdirSync(dir);
|
|
428
|
+
} catch {
|
|
429
|
+
return [];
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
// Toolchain wiring check.
|
|
435
|
+
//
|
|
436
|
+
// A registry rule marked `enforcement: lint` names the tool that owns it. This
|
|
437
|
+
// does NOT re-implement that tool: it checks whether the repo has it wired, and
|
|
438
|
+
// reports absence as a finding. That is the honest answer and it is
|
|
439
|
+
// stack-generic - swiftlint, ktlint, detekt, eslint and ruff all take rule ids.
|
|
440
|
+
// Executing a registry's `mechanism` regex directly was considered and rejected:
|
|
441
|
+
// across the 99-rule iOS registry only 43 rules carry `mechanism` at all, the
|
|
442
|
+
// field is prose with an embedded pattern rather than a machine field, and each
|
|
443
|
+
// carries its scope in English. Running them unscoped floods a review.
|
|
444
|
+
// ---------------------------------------------------------------------------
|
|
445
|
+
const TOOLCHAIN_CONFIGS = {
|
|
446
|
+
swiftlint: [".swiftlint.yml", ".swiftlint.yaml"],
|
|
447
|
+
ktlint: [".editorconfig", "ktlint.gradle"],
|
|
448
|
+
detekt: ["detekt.yml", "config/detekt/detekt.yml"],
|
|
449
|
+
eslint: [".eslintrc", ".eslintrc.json", ".eslintrc.cjs", "eslint.config.js", "eslint.config.mjs"],
|
|
450
|
+
ruff: ["ruff.toml", ".ruff.toml", "pyproject.toml"],
|
|
451
|
+
swiftformat: [".swiftformat"],
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
function detectToolchains(repoRoot, rules) {
|
|
455
|
+
const named = new Set();
|
|
456
|
+
for (const r of rules) {
|
|
457
|
+
const mech = (r.mechanism ?? "").toLowerCase();
|
|
458
|
+
for (const tool of Object.keys(TOOLCHAIN_CONFIGS)) {
|
|
459
|
+
if (mech.includes(tool)) named.add(tool);
|
|
460
|
+
}
|
|
461
|
+
}
|
|
462
|
+
return [...named].sort().map((tool) => ({
|
|
463
|
+
tool,
|
|
464
|
+
wired: TOOLCHAIN_CONFIGS[tool].some((c) => existsSync(join(repoRoot, c))),
|
|
465
|
+
configCandidates: TOOLCHAIN_CONFIGS[tool],
|
|
466
|
+
}));
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
// ---------------------------------------------------------------------------
|
|
470
|
+
// Exception-marker audit - the one bespoke deterministic scan kept.
|
|
471
|
+
//
|
|
472
|
+
// Cheap, language-agnostic, and it answers "completely" directly: an exception
|
|
473
|
+
// is the author's own claim that a rule does not apply here, so an expired or
|
|
474
|
+
// unjustified one is a defect the reviewers would have to take on trust. The
|
|
475
|
+
// marker template comes from the registry's `exception_marker`, never hardcoded,
|
|
476
|
+
// so a registry using a different comment syntax still works.
|
|
477
|
+
// ---------------------------------------------------------------------------
|
|
478
|
+
function auditExceptions(addedByFile, knownIds, today, markerToken) {
|
|
479
|
+
const findings = [];
|
|
480
|
+
// The token comes from the registry's own `exception_marker` (e.g.
|
|
481
|
+
// "// standard:exception(<RULE-ID>) <reason> <expiry:YYYY-MM-DD>" yields
|
|
482
|
+
// "standard:exception"). Hardcoding it, as an earlier version did, meant a
|
|
483
|
+
// registry declaring a different marker got ZERO exception auditing with no gap
|
|
484
|
+
// recorded - a silent pass, and it contradicted the shipped contract. The
|
|
485
|
+
// comment prefix stays open (`//`, `#`, `--`) so the scan is language-agnostic.
|
|
486
|
+
const token = (markerToken ?? "standard:exception").replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
487
|
+
const re = new RegExp(`(?://|#|--)\\s*${token}\\(([^)]*)\\)\\s*(.*)$`);
|
|
488
|
+
|
|
489
|
+
for (const [file, added] of addedByFile) {
|
|
490
|
+
for (const { line, text } of added) {
|
|
491
|
+
const m = re.exec(text);
|
|
492
|
+
if (!m) continue;
|
|
493
|
+
const id = m[1].trim();
|
|
494
|
+
const rest = (m[2] ?? "").trim();
|
|
495
|
+
const dateMatch = /(\d{4}-\d{2}-\d{2})/.exec(rest);
|
|
496
|
+
const reason = rest.replace(/(\d{4}-\d{2}-\d{2})/, "").trim();
|
|
497
|
+
|
|
498
|
+
if (!id) {
|
|
499
|
+
findings.push(f(file, line, "blocking", null, "Exception marker names no rule ID.",
|
|
500
|
+
"Name the rule the exception applies to: standard:exception(<RULE-ID>) <reason> <expiry>."));
|
|
501
|
+
continue;
|
|
502
|
+
}
|
|
503
|
+
if (knownIds.size > 0 && !knownIds.has(id)) {
|
|
504
|
+
findings.push(f(file, line, "blocking", id,
|
|
505
|
+
`Exception cites '${id}', which is not in any registry in scope for this diff.`,
|
|
506
|
+
"Use a real rule ID, or drop the marker. An exception against an unknown rule suppresses nothing and hides intent."));
|
|
507
|
+
}
|
|
508
|
+
if (!dateMatch) {
|
|
509
|
+
findings.push(f(file, line, "important", id,
|
|
510
|
+
`Exception for '${id}' has no expiry date.`,
|
|
511
|
+
"Add an expiry: standard:exception(<RULE-ID>) <reason> YYYY-MM-DD. An exception with no expiry is a permanent silent carve-out."));
|
|
512
|
+
} else if (dateMatch[1] < today) {
|
|
513
|
+
findings.push(f(file, line, "blocking", id,
|
|
514
|
+
`Exception for '${id}' expired on ${dateMatch[1]}.`,
|
|
515
|
+
"Fix the underlying violation, or renew the exception with a new expiry and a current reason."));
|
|
516
|
+
}
|
|
517
|
+
if (!reason) {
|
|
518
|
+
findings.push(f(file, line, "important", id,
|
|
519
|
+
`Exception for '${id}' gives no reason.`,
|
|
520
|
+
"State why the rule does not apply here. An unexplained exception cannot be reviewed, only trusted."));
|
|
521
|
+
}
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
return findings;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
function f(file, line, severity, ruleId, issue, fix) {
|
|
528
|
+
return {
|
|
529
|
+
severity,
|
|
530
|
+
file,
|
|
531
|
+
line,
|
|
532
|
+
issue,
|
|
533
|
+
fix,
|
|
534
|
+
...(ruleId ? { ruleId } : {}),
|
|
535
|
+
criteriaSource: "exception-marker-audit",
|
|
536
|
+
};
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
// ---------------------------------------------------------------------------
|
|
540
|
+
// Registry discovery: declared, not sniffed.
|
|
541
|
+
// ---------------------------------------------------------------------------
|
|
542
|
+
// Depth differs per host, which is why this is a bounded walk rather than a
|
|
543
|
+
// fixed path: an installed tree is flat (`~/.claude/skills/<skill>/SKILL.md`)
|
|
544
|
+
// while a checkout nests by group (`pipeline/skills/shared/core/<skill>/`).
|
|
545
|
+
// Hardcoding either depth finds nothing on the other host.
|
|
546
|
+
const DISCOVERY_MAX_DEPTH = 4;
|
|
547
|
+
|
|
548
|
+
function findSkillDirs(dir, depth, acc) {
|
|
549
|
+
if (depth > DISCOVERY_MAX_DEPTH) return acc;
|
|
550
|
+
if (existsSync(join(dir, "SKILL.md"))) {
|
|
551
|
+
acc.push(dir);
|
|
552
|
+
return acc; // a skill directory does not contain further skills
|
|
553
|
+
}
|
|
554
|
+
for (const entry of readdirSafe(dir)) {
|
|
555
|
+
if (entry.startsWith(".") || entry === "node_modules") continue;
|
|
556
|
+
const child = join(dir, entry);
|
|
557
|
+
let st;
|
|
558
|
+
try {
|
|
559
|
+
st = statSync(child);
|
|
560
|
+
} catch {
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
if (st.isDirectory()) findSkillDirs(child, depth + 1, acc);
|
|
564
|
+
}
|
|
565
|
+
return acc;
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function discoverRegistries(roots) {
|
|
569
|
+
const out = [];
|
|
570
|
+
const escaped = [];
|
|
571
|
+
const seen = new Set();
|
|
572
|
+
for (const root of roots) {
|
|
573
|
+
for (const skillDir of findSkillDirs(root, 0, [])) {
|
|
574
|
+
const skillFile = join(skillDir, "SKILL.md");
|
|
575
|
+
const fm = readFrontmatter(skillFile);
|
|
576
|
+
if (!fm["standards-registry"]) continue;
|
|
577
|
+
const name = fm.name ?? basename(skillDir);
|
|
578
|
+
if (seen.has(name)) continue;
|
|
579
|
+
// `standards-registry` is author-controlled frontmatter, and resolve()
|
|
580
|
+
// happily accepts `../../..` chains and absolute paths. Unconstrained, any
|
|
581
|
+
// installed skill could point this gate at an arbitrary readable file
|
|
582
|
+
// (`standards-registry: /Users/x/.aws/credentials`), whose matching lines
|
|
583
|
+
// would be extracted into the manifest and injected into every reviewer
|
|
584
|
+
// prompt via the shared ${CRITERIA} block. Contain it to the skill's own
|
|
585
|
+
// directory; a declaration that escapes is recorded, not followed.
|
|
586
|
+
const rulesPath = resolve(skillDir, fm["standards-registry"]);
|
|
587
|
+
const skillRoot = resolve(skillDir) + sep;
|
|
588
|
+
if (!rulesPath.startsWith(skillRoot)) {
|
|
589
|
+
escaped.push({
|
|
590
|
+
name,
|
|
591
|
+
path: rulesPath,
|
|
592
|
+
reason: `standards-registry escapes the skill directory (declared: ${fm["standards-registry"]})`,
|
|
593
|
+
});
|
|
594
|
+
continue;
|
|
595
|
+
}
|
|
596
|
+
seen.add(name);
|
|
597
|
+
out.push({ name, skillFile, rulesPath, declared: fm["standards-registry"] });
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return { registries: out.sort((a, b) => a.name.localeCompare(b.name)), escaped };
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// ---------------------------------------------------------------------------
|
|
604
|
+
// main
|
|
605
|
+
// ---------------------------------------------------------------------------
|
|
606
|
+
function parseArgs(argv) {
|
|
607
|
+
const o = { changedFiles: null, json: false };
|
|
608
|
+
for (let i = 0; i < argv.length; i++) {
|
|
609
|
+
const a = argv[i];
|
|
610
|
+
if (a === "--json") o.json = true;
|
|
611
|
+
else if (a === "--diff") o.diff = argv[++i];
|
|
612
|
+
else if (a === "--changed-files") o.changedFiles = argv[++i].split(",").filter(Boolean);
|
|
613
|
+
else if (a === "--state") o.state = argv[++i];
|
|
614
|
+
else if (a === "--repo") o.repo = argv[++i];
|
|
615
|
+
else if (a === "--out") o.out = argv[++i];
|
|
616
|
+
else if (a === "--skills-root") o.skillsRoot = argv[++i];
|
|
617
|
+
else if (a === "--today") o.today = argv[++i]; // tests pin the clock
|
|
618
|
+
else {
|
|
619
|
+
console.error(`unknown argument: ${a}`);
|
|
620
|
+
process.exit(1);
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
return o;
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
const opts = parseArgs(process.argv.slice(2));
|
|
627
|
+
const repoRoot = resolve(opts.repo ?? process.cwd());
|
|
628
|
+
const today = opts.today ?? new Date().toISOString().slice(0, 10);
|
|
629
|
+
|
|
630
|
+
let changedFiles = opts.changedFiles ?? [];
|
|
631
|
+
let addedByFile = new Map();
|
|
632
|
+
|
|
633
|
+
if (opts.diff) {
|
|
634
|
+
if (!existsSync(opts.diff)) {
|
|
635
|
+
console.error(`ERR: diff not found: ${opts.diff}`);
|
|
636
|
+
process.exit(1);
|
|
637
|
+
}
|
|
638
|
+
const parsed = parseDiff(readFileSync(opts.diff, "utf8"));
|
|
639
|
+
if (!opts.changedFiles) changedFiles = parsed.files;
|
|
640
|
+
addedByFile = parsed.addedByFile;
|
|
641
|
+
} else if (changedFiles.length === 0) {
|
|
642
|
+
console.error("ERR: pass --diff <patch> or --changed-files a,b");
|
|
643
|
+
process.exit(1);
|
|
644
|
+
}
|
|
645
|
+
|
|
646
|
+
// --- self-report ledger (corroborating only) -------------------------------
|
|
647
|
+
// Three distinct states. `absent` is NOT `empty`: a Phase 3 that consulted five
|
|
648
|
+
// skills and forgot to record them is byte-identical in state to one that
|
|
649
|
+
// consulted nothing, so treating a missing array as "nothing applied" would let
|
|
650
|
+
// every completeness number report green over an empty set.
|
|
651
|
+
let ledger = { status: "absent", entries: [] };
|
|
652
|
+
if (opts.state) {
|
|
653
|
+
if (!existsSync(opts.state)) {
|
|
654
|
+
console.error(`ERR: state not found: ${opts.state}`);
|
|
655
|
+
process.exit(1);
|
|
656
|
+
}
|
|
657
|
+
try {
|
|
658
|
+
const state = JSON.parse(readFileSync(opts.state, "utf8"));
|
|
659
|
+
const calls = state?.telemetry?.skillCalls;
|
|
660
|
+
if (Array.isArray(calls)) {
|
|
661
|
+
ledger = { status: calls.length ? "declared" : "empty", entries: calls };
|
|
662
|
+
}
|
|
663
|
+
} catch (e) {
|
|
664
|
+
console.error(`ERR: state is not valid JSON: ${e.message}`);
|
|
665
|
+
process.exit(1);
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
// --- language census of the diff ------------------------------------------
|
|
670
|
+
const languages = {};
|
|
671
|
+
for (const file of changedFiles) {
|
|
672
|
+
const lang = classifyLanguage(file);
|
|
673
|
+
(languages[lang] ??= []).push(file);
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// --- discover + load registries -------------------------------------------
|
|
677
|
+
// An explicit --skills-root that does not exist is a SETUP error, not an empty
|
|
678
|
+
// result: silently searching nothing produced a manifest with zero selected
|
|
679
|
+
// rules, which made validate-reviewer.mjs skip the conformance check entirely
|
|
680
|
+
// and a review report clean over criteria it never loaded. That is the single
|
|
681
|
+
// worst outcome this gate exists to prevent, so it fails loudly instead.
|
|
682
|
+
const { roots, probed: probedRoots } = resolveSkillsRoots(opts.skillsRoot);
|
|
683
|
+
if (opts.skillsRoot && roots.length === 0) {
|
|
684
|
+
console.error(`ERR: --skills-root does not exist: ${opts.skillsRoot}`);
|
|
685
|
+
process.exit(1);
|
|
686
|
+
}
|
|
687
|
+
const noRootsResolved = roots.length === 0;
|
|
688
|
+
const { registries: discovered, escaped: escapedDeclarations } = discoverRegistries(roots);
|
|
689
|
+
|
|
690
|
+
const registries = [];
|
|
691
|
+
const unparseable = [...escapedDeclarations];
|
|
692
|
+
|
|
693
|
+
for (const d of discovered) {
|
|
694
|
+
if (!existsSync(d.rulesPath)) {
|
|
695
|
+
unparseable.push({ name: d.name, path: d.rulesPath, reason: "declared standards-registry file does not exist" });
|
|
696
|
+
continue;
|
|
697
|
+
}
|
|
698
|
+
let loaded;
|
|
699
|
+
try {
|
|
700
|
+
loaded = readRegistry(d.rulesPath);
|
|
701
|
+
} catch (e) {
|
|
702
|
+
unparseable.push({ name: d.name, path: d.rulesPath, reason: `unreadable: ${e.message}` });
|
|
703
|
+
continue;
|
|
704
|
+
}
|
|
705
|
+
if (loaded.rules.length === 0) {
|
|
706
|
+
unparseable.push({ name: d.name, path: d.rulesPath, reason: "no rules extracted - rules: block missing or unexpected indentation" });
|
|
707
|
+
continue;
|
|
708
|
+
}
|
|
709
|
+
registries.push({ ...d, ...loaded });
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// --- scope resolution ------------------------------------------------------
|
|
713
|
+
const selected = [];
|
|
714
|
+
const perRegistry = [];
|
|
715
|
+
|
|
716
|
+
for (const reg of registries) {
|
|
717
|
+
const scope = reg.meta.scope;
|
|
718
|
+
const regLanguages = scope?.languages ?? [];
|
|
719
|
+
const langHit = regLanguages.length === 0
|
|
720
|
+
? Object.keys(languages)
|
|
721
|
+
: regLanguages.filter((l) => Object.prototype.hasOwnProperty.call(languages, l));
|
|
722
|
+
|
|
723
|
+
// Files this registry may speak about at all.
|
|
724
|
+
const inScopeFiles = changedFiles.filter((file) => {
|
|
725
|
+
const lang = classifyLanguage(file);
|
|
726
|
+
if (regLanguages.length && !regLanguages.includes(lang)) return false;
|
|
727
|
+
if (scope?.excludePaths?.length && matchesAnyGlob(file, scope.excludePaths)) return false;
|
|
728
|
+
if (scope?.paths?.length && !matchesAnyGlob(file, scope.paths)) return false;
|
|
729
|
+
return true;
|
|
730
|
+
});
|
|
731
|
+
|
|
732
|
+
const unscoped = !scope || (regLanguages.length === 0 && !scope.paths?.length);
|
|
733
|
+
|
|
734
|
+
let kept = 0;
|
|
735
|
+
const droppedReasons = {};
|
|
736
|
+
if (inScopeFiles.length > 0 && !unscoped) {
|
|
737
|
+
for (const rule of reg.rules) {
|
|
738
|
+
// A retired rule is retired by status, never deleted. `status` is absent
|
|
739
|
+
// from every shipped registry today, so missing means active - recorded
|
|
740
|
+
// as statusDeclared:false rather than assumed.
|
|
741
|
+
if (rule.status && rule.status !== "active") {
|
|
742
|
+
droppedReasons["retired-by-status"] = (droppedReasons["retired-by-status"] ?? 0) + 1;
|
|
743
|
+
continue;
|
|
744
|
+
}
|
|
745
|
+
// Per-rule scope narrows, never widens.
|
|
746
|
+
const files = rule.scope?.paths?.length
|
|
747
|
+
? inScopeFiles.filter((f2) => matchesAnyGlob(f2, rule.scope.paths))
|
|
748
|
+
: inScopeFiles;
|
|
749
|
+
if (files.length === 0) {
|
|
750
|
+
const why = rule.scopeReason ? `out-of-scope: ${rule.scopeReason}` : "out-of-scope: no changed file matches the rule's paths";
|
|
751
|
+
droppedReasons[why] = (droppedReasons[why] ?? 0) + 1;
|
|
752
|
+
continue;
|
|
753
|
+
}
|
|
754
|
+
selected.push({
|
|
755
|
+
ruleId: rule.id,
|
|
756
|
+
registry: reg.name,
|
|
757
|
+
severity: rule.severity ?? "important",
|
|
758
|
+
enforcement: rule.enforcement ?? "judgement",
|
|
759
|
+
files,
|
|
760
|
+
statusDeclared: rule.statusDeclared,
|
|
761
|
+
});
|
|
762
|
+
kept++;
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
766
|
+
perRegistry.push({
|
|
767
|
+
name: reg.name,
|
|
768
|
+
rulesPath: reg.rulesPath,
|
|
769
|
+
version: reg.meta.version,
|
|
770
|
+
totalRules: reg.rules.length,
|
|
771
|
+
selectedRules: kept,
|
|
772
|
+
droppedRules: reg.rules.length - kept,
|
|
773
|
+
droppedReasons,
|
|
774
|
+
scopeDeclared: !unscoped,
|
|
775
|
+
scopeLanguages: regLanguages,
|
|
776
|
+
languagesMatched: langHit,
|
|
777
|
+
inScopeFileCount: inScopeFiles.length,
|
|
778
|
+
notCovered: reg.meta.notCovered,
|
|
779
|
+
loadedAs: unscoped ? "reference-only" : inScopeFiles.length ? "rule-checklist" : "not-applicable",
|
|
780
|
+
...(unscoped
|
|
781
|
+
? { referenceOnlyReason: "registry declares no scope; applying its rules to unknown languages would manufacture findings" }
|
|
782
|
+
: {}),
|
|
783
|
+
toolchains: detectToolchains(repoRoot, reg.rules),
|
|
784
|
+
exceptionMarker: reg.meta.exceptionMarker,
|
|
785
|
+
});
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// --- coverage --------------------------------------------------------------
|
|
789
|
+
const coveredFiles = new Set(selected.flatMap((s) => s.files));
|
|
790
|
+
const uncovered = changedFiles.filter((f2) => !coveredFiles.has(f2));
|
|
791
|
+
|
|
792
|
+
const gaps = [];
|
|
793
|
+
for (const [lang, files] of Object.entries(languages)) {
|
|
794
|
+
const covered = files.filter((f2) => coveredFiles.has(f2));
|
|
795
|
+
if (covered.length === 0) {
|
|
796
|
+
const claims = perRegistry
|
|
797
|
+
.flatMap((r) => Object.entries(r.notCovered ?? {}).map(([k, v]) => ({ registry: r.name, key: k, note: v })))
|
|
798
|
+
.filter((c) => c.key === lang || lang.startsWith(c.key));
|
|
799
|
+
gaps.push({
|
|
800
|
+
language: lang,
|
|
801
|
+
fileCount: files.length,
|
|
802
|
+
files,
|
|
803
|
+
reason: claims.length
|
|
804
|
+
? `no registry in scope covers ${lang}: ${claims.map((c) => `${c.registry} declares '${c.key}' not covered`).join("; ")}`
|
|
805
|
+
: `no registry in scope declares ${lang}`,
|
|
806
|
+
consequence: "these files are reviewed on general criteria only - no rule IDs apply to them",
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
}
|
|
810
|
+
|
|
811
|
+
// --- module guides ---------------------------------------------------------
|
|
812
|
+
const { guides, dropped: guidesDropped } = collectModuleGuides(repoRoot, changedFiles);
|
|
813
|
+
|
|
814
|
+
// --- exception audit -------------------------------------------------------
|
|
815
|
+
// knownIds spans EVERY discovered registry, not just the in-scope ones. Narrowing
|
|
816
|
+
// it to the selected set would report a valid rule as unknown whenever the
|
|
817
|
+
// exception sits in a file that rule does not cover, which is a false accusation
|
|
818
|
+
// in the one place the author is trying to be explicit. The narrower question -
|
|
819
|
+
// "is this exception citing a rule that applies here?" - is answered separately
|
|
820
|
+
// below, so a mis-scoped exception is still reported, just not as a typo.
|
|
821
|
+
const knownIds = new Set(registries.flatMap((r) => r.rules.map((x) => x.id)));
|
|
822
|
+
const inScopeIds = new Set(selected.map((s) => s.ruleId));
|
|
823
|
+
const markerToken = registries
|
|
824
|
+
.map((r) => r.meta.exceptionMarker)
|
|
825
|
+
.filter(Boolean)
|
|
826
|
+
.map((m) => (m.match(/([A-Za-z0-9_:.-]+)\s*\(/) ?? [])[1])
|
|
827
|
+
.filter(Boolean)[0];
|
|
828
|
+
const findings = auditExceptions(addedByFile, knownIds, today, markerToken);
|
|
829
|
+
|
|
830
|
+
// A known rule cited where it does not apply: the exception suppresses nothing,
|
|
831
|
+
// so it is dead weight that reads as deliberate coverage.
|
|
832
|
+
if (inScopeIds.size > 0) {
|
|
833
|
+
for (const [file, added] of addedByFile) {
|
|
834
|
+
for (const { line, text } of added) {
|
|
835
|
+
const m = /(?:\/\/|#|--)\s*[A-Za-z0-9_:.-]+\(([^)]*)\)/.exec(text);
|
|
836
|
+
if (!m) continue;
|
|
837
|
+
const id = m[1].trim();
|
|
838
|
+
if (!id || !knownIds.has(id) || inScopeIds.has(id)) continue;
|
|
839
|
+
findings.push({
|
|
840
|
+
severity: "important",
|
|
841
|
+
file,
|
|
842
|
+
line,
|
|
843
|
+
issue: `Exception cites '${id}', a real rule that is not in scope for this file.`,
|
|
844
|
+
fix: "Drop the marker, or move it to a file the rule actually covers. An exception against an out-of-scope rule suppresses nothing while reading as a deliberate carve-out.",
|
|
845
|
+
ruleId: id,
|
|
846
|
+
criteriaSource: "exception-marker-audit",
|
|
847
|
+
});
|
|
848
|
+
}
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
// --- toolchain findings ----------------------------------------------------
|
|
853
|
+
for (const reg of perRegistry) {
|
|
854
|
+
if (reg.loadedAs !== "rule-checklist") continue;
|
|
855
|
+
for (const t of reg.toolchains) {
|
|
856
|
+
if (t.wired) continue;
|
|
857
|
+
findings.push({
|
|
858
|
+
severity: "important",
|
|
859
|
+
file: ".",
|
|
860
|
+
line: 0,
|
|
861
|
+
issue: `${reg.name} delegates its mechanical rules to ${t.tool}, which is not wired in this repo (looked for ${t.configCandidates.join(", ")}).`,
|
|
862
|
+
fix: `Add a ${t.tool} config and run it in CI. Until then every '${t.tool}' rule in this registry is unverified, and a review that reports no violations is reporting that nothing was measured.`,
|
|
863
|
+
criteriaSource: `${reg.name}:toolchain`,
|
|
864
|
+
});
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// --- ledger corroboration --------------------------------------------------
|
|
869
|
+
// The resolver is primary. A declared entry the resolver did not independently
|
|
870
|
+
// reach is FLAGGED, not trusted: it means the dev phase claims a criterion the
|
|
871
|
+
// review cannot bind to any changed file.
|
|
872
|
+
const resolvedNames = new Set(perRegistry.map((r) => r.name));
|
|
873
|
+
const declaredNames = ledger.entries.map((e) => e?.skill).filter(Boolean);
|
|
874
|
+
const declaredNotResolved = [...new Set(declaredNames)].filter((n) => !resolvedNames.has(n));
|
|
875
|
+
const ledgerMissingTargets = ledger.entries.filter((e) => !Array.isArray(e?.targetFiles) || e.targetFiles.length === 0).length;
|
|
876
|
+
|
|
877
|
+
// Two values, not three. An earlier version branched on
|
|
878
|
+
// `declaredNotResolved.length === 0` and returned "mixed" from BOTH arms, so the
|
|
879
|
+
// condition was dead and the flagged-vs-corroborated distinction it implied was
|
|
880
|
+
// not expressible in the field. The distinction lives in
|
|
881
|
+
// `ledger.declaredButNotResolved` instead, where a consumer can see exactly which
|
|
882
|
+
// claim failed to bind rather than inferring it from a summary word.
|
|
883
|
+
const ledgerSource = ledger.status === "declared" ? "mixed" : "derived";
|
|
884
|
+
|
|
885
|
+
const manifest = {
|
|
886
|
+
schemaVersion: "1.0.0",
|
|
887
|
+
generatedFor: { repoRoot, changedFileCount: changedFiles.length },
|
|
888
|
+
ledger: {
|
|
889
|
+
source: ledgerSource,
|
|
890
|
+
selfReport: ledger.status,
|
|
891
|
+
entryCount: ledger.entries.length,
|
|
892
|
+
entriesMissingTargetFiles: ledgerMissingTargets,
|
|
893
|
+
declaredButNotResolved: declaredNotResolved,
|
|
894
|
+
note: "The resolver below is primary. This block is a self-report from Phase 3 and is corroborating evidence only: an unrecorded consultation and no consultation are indistinguishable in state, so coverage is never computed from it.",
|
|
895
|
+
},
|
|
896
|
+
languages: Object.fromEntries(Object.entries(languages).map(([k, v]) => [k, v.length])),
|
|
897
|
+
registries: perRegistry,
|
|
898
|
+
unparseableRegistries: unparseable,
|
|
899
|
+
selectedRules: selected,
|
|
900
|
+
selectedRuleCount: selected.length,
|
|
901
|
+
coverage: {
|
|
902
|
+
changedFiles: changedFiles.length,
|
|
903
|
+
filesWithRuleCriteria: coveredFiles.size,
|
|
904
|
+
filesWithoutRuleCriteria: uncovered.length,
|
|
905
|
+
uncoveredFiles: uncovered,
|
|
906
|
+
declaredGaps: gaps,
|
|
907
|
+
},
|
|
908
|
+
moduleGuides: { guides, dropped: guidesDropped, cap: GUIDE_CAP },
|
|
909
|
+
findings,
|
|
910
|
+
skillsRootsSearched: probedRoots,
|
|
911
|
+
resolutionFailure: noRootsResolved
|
|
912
|
+
? "no skills root exists on this host - nothing was searched, so no criteria could be resolved"
|
|
913
|
+
: null,
|
|
914
|
+
};
|
|
915
|
+
|
|
916
|
+
const outPath = opts.out ?? join(repoRoot, ".pipeline", "criteria-manifest.json");
|
|
917
|
+
try {
|
|
918
|
+
const dir = dirname(outPath);
|
|
919
|
+
if (!existsSync(dir)) {
|
|
920
|
+
const { mkdirSync } = await import("node:fs");
|
|
921
|
+
mkdirSync(dir, { recursive: true });
|
|
922
|
+
}
|
|
923
|
+
writeFileSync(outPath, JSON.stringify(manifest, null, 2) + "\n");
|
|
924
|
+
} catch (e) {
|
|
925
|
+
console.error(`ERR: cannot write manifest to ${outPath}: ${e.message}`);
|
|
926
|
+
process.exit(1);
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if (opts.json) {
|
|
930
|
+
process.stdout.write(JSON.stringify(manifest, null, 2) + "\n");
|
|
931
|
+
} else {
|
|
932
|
+
console.log(`criteria-manifest: ${outPath}`);
|
|
933
|
+
console.log(` languages: ${Object.entries(manifest.languages).map(([k, v]) => `${k}:${v}`).join(" ") || "(none)"}`);
|
|
934
|
+
console.log(` registries: ${perRegistry.length} discovered, ${perRegistry.filter((r) => r.loadedAs === "rule-checklist").length} as rule-checklist`);
|
|
935
|
+
console.log(` selected rules: ${selected.length}`);
|
|
936
|
+
console.log(` coverage: ${coveredFiles.size}/${changedFiles.length} changed files carry rule criteria`);
|
|
937
|
+
for (const g of gaps) console.log(` GAP ${g.language} (${g.fileCount} file(s)): ${g.reason}`);
|
|
938
|
+
console.log(` ledger: ${manifest.ledger.source} (self-report: ${ledger.status})`);
|
|
939
|
+
console.log(` findings: ${findings.length}`);
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
// Fail closed when NO skills root existed. Continuing would emit a manifest with
|
|
943
|
+
// zero selected rules, which makes validate-reviewer.mjs skip the conformance
|
|
944
|
+
// check and the review read as clean over criteria that were never loaded. An
|
|
945
|
+
// empty registry set inside a root that DOES exist is a different, legitimate
|
|
946
|
+
// case: it yields a declared coverage gap and exit 0.
|
|
947
|
+
if (noRootsResolved) {
|
|
948
|
+
console.error("ERR: no skills root found - criteria could not be resolved. Probed:");
|
|
949
|
+
for (const c of probedRoots) console.error(` ${c.existed ? "found" : "absent"}: ${c.path}`);
|
|
950
|
+
process.exit(2);
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
// Fail closed on a declared-but-unparseable registry: silently continuing would
|
|
954
|
+
// drop that registry's whole rule set from the denominator, and the run would
|
|
955
|
+
// report a clean review over criteria it never loaded.
|
|
956
|
+
if (unparseable.length > 0) {
|
|
957
|
+
console.error("ERR: declared registries could not be parsed:");
|
|
958
|
+
for (const u of unparseable) console.error(` ${u.name}: ${u.reason} (${u.path})`);
|
|
959
|
+
process.exit(2);
|
|
960
|
+
}
|