cyber-sdd 0.7.0 → 0.7.2
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/.claude-plugin/plugin.json +1 -2
- package/.codex-plugin/plugin.json +1 -2
- package/CHANGELOG.md +320 -0
- package/agents/sdd-scanner.md +1 -1
- package/package.json +11 -12
- package/plugin.json +55 -0
- package/skills/align-spec/SKILL.md +1 -1
- package/skills/align-spec/scripts/align-spec.mjs +6755 -12
- package/skills/blast-estimate/README.md +3 -3
- package/skills/blast-estimate/scripts/blast-estimate.mjs +601 -12
- package/skills/check-field-mandates/scripts/check-field-mandates.mjs +253 -12
- package/skills/check-partition-quality/scripts/check-partition-quality.mjs +235 -12
- package/skills/check-plan-safety/SKILL.md +2 -3
- package/skills/check-plan-safety/scripts/check-plan-safety.mjs +109 -12
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mjs +250 -12
- package/skills/check-project-specs/SKILL.md +1 -1
- package/skills/check-project-specs/scripts/check-project-specs.mjs +593 -12
- package/skills/check-retired-terms/SKILL.md +2 -3
- package/skills/check-retired-terms/scripts/check-retired-terms.mjs +186 -12
- package/skills/check-scenario-overlap/SKILL.md +1 -2
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mjs +177 -12
- package/skills/check-spec-references/SKILL.md +2 -2
- package/skills/check-spec-references/scripts/check-spec-references.mjs +287 -12
- package/skills/check-spec-structure/SKILL.md +1 -2
- package/skills/check-spec-structure/scripts/check-spec-structure.mjs +243 -12
- package/skills/collision-ladder/README.md +2 -2
- package/skills/collision-ladder/scripts/collision-ladder.mjs +7047 -12
- package/skills/combat-log-governance/SKILL.md +1 -1
- package/skills/concept-index/SKILL.md +1 -1
- package/skills/concept-index/scripts/concept-index.mjs +183 -12
- package/skills/discover-plans/README.md +3 -3
- package/skills/discover-plans/SKILL.md +1 -1
- package/skills/discover-plans/scripts/discover-plans.mjs +137 -12
- package/skills/discover-specs/README.md +3 -3
- package/skills/discover-specs/SKILL.md +1 -1
- package/skills/discover-specs/scripts/discover-specs.mjs +321 -12
- package/skills/doctrine-loop/SKILL.md +1 -1
- package/skills/gate-validation-governance/SKILL.md +1 -1
- package/skills/init/README.md +2 -2
- package/skills/init/scripts/wire-statusline.mjs +168 -12
- package/skills/manage-ignore/README.md +3 -3
- package/skills/manage-ignore/SKILL.md +1 -2
- package/skills/manage-ignore/scripts/manage-ignore.mjs +285 -12
- package/skills/manage-scenario-bridge/README.md +3 -3
- package/skills/manage-scenario-bridge/SKILL.md +1 -1
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mjs +239 -12
- package/skills/manage-spec-anchors/README.md +3 -3
- package/skills/manage-spec-anchors/SKILL.md +1 -1
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mjs +310 -12
- package/skills/mission-graph/README.md +2 -2
- package/skills/mission-graph/SKILL.md +1 -1
- package/skills/mission-graph/scripts/mission-graph.mjs +1040 -12
- package/skills/pause-mission/SKILL.md +1 -1
- package/skills/place-node/SKILL.md +2 -2
- package/skills/place-node/scripts/place-node.mjs +117 -12
- package/skills/plan-retirement/README.md +2 -2
- package/skills/plan-retirement/scripts/retire-plans.mjs +123 -12
- package/skills/resolve-governances/README.md +3 -3
- package/skills/resolve-governances/SKILL.md +1 -1
- package/skills/resolve-governances/scripts/resolve-governances.mjs +352 -12
- package/skills/resolve-tracking/SKILL.md +1 -1
- package/skills/resolve-tracking/scripts/resolve-tracking.mjs +152 -12
- package/skills/scaffold-project-spec/SKILL.md +1 -1
- package/skills/spec-gate/README.md +12 -9
- package/skills/spec-gate/SKILL.md +2 -2
- package/skills/spec-gate/scripts/check-spec-state.mjs +422 -12
- package/skills/spec-gate/scripts/check-suite.mjs +6500 -12
- package/skills/spec-gate/scripts/classify-edit-class.mjs +6579 -12
- package/skills/spec-producer-governance/SKILL.md +2 -2
- package/skills/start-mission/SKILL.md +1 -1
- package/skills/suite-format-governance/README.md +1 -1
- package/skills/suite-format-governance/SKILL.md +1 -1
- package/skills/touch-set-correction/README.md +5 -5
- package/skills/touch-set-correction/SKILL.md +1 -1
- package/skills/touch-set-correction/scripts/touch-set-correction.mjs +6663 -12
- package/skills/verify-scenarios/README.md +6 -6
- package/skills/verify-scenarios/scripts/verify-scenarios.mjs +6539 -12
- package/.plugin/pins.json +0 -3
- package/.plugin/plugin.json +0 -19
- package/dist/align-spec/scripts/align-spec.js +0 -102
- package/dist/check-project-specs/scripts/check-project-specs.js +0 -312
- package/dist/check-scenario-overlap/scripts/check-scenario-overlap.js +0 -175
- package/dist/check-spec-references/scripts/check-spec-references.js +0 -285
- package/dist/check-spec-structure/scripts/check-spec-structure.js +0 -241
- package/dist/concept-index/scripts/concept-index.js +0 -181
- package/dist/discover-specs/scripts/discover-specs.js +0 -318
- package/dist/spec-gate/scripts/check-spec-state.js +0 -420
- package/dist/spec-gate/scripts/check-suite.js +0 -317
- package/dist/spec-gate/scripts/classify-edit-class.js +0 -263
- package/dist/spec-gate/vendor/gherkin-cli.js +0 -6369
- package/skills/align-spec/scripts/align-spec.bundle.mjs +0 -6753
- package/skills/align-spec/scripts/align-spec.mts +0 -188
- package/skills/blast-estimate/scripts/blast-estimate.bundle.mjs +0 -593
- package/skills/blast-estimate/scripts/blast-estimate.mts +0 -586
- package/skills/check-field-mandates/scripts/check-field-mandates.bundle.mjs +0 -251
- package/skills/check-field-mandates/scripts/check-field-mandates.mts +0 -337
- package/skills/check-partition-quality/scripts/check-partition-quality.bundle.mjs +0 -233
- package/skills/check-partition-quality/scripts/check-partition-quality.mts +0 -338
- package/skills/check-plan-safety/scripts/check-plan-safety.bundle.mjs +0 -107
- package/skills/check-plan-safety/scripts/check-plan-safety.mts +0 -148
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.bundle.mjs +0 -248
- package/skills/check-plugin-manifests/scripts/check-plugin-manifests.mts +0 -303
- package/skills/check-project-specs/scripts/check-project-specs.bundle.mjs +0 -584
- package/skills/check-project-specs/scripts/check-project-specs.mts +0 -370
- package/skills/check-retired-terms/scripts/check-retired-terms.bundle.mjs +0 -184
- package/skills/check-retired-terms/scripts/check-retired-terms.mts +0 -293
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.bundle.mjs +0 -175
- package/skills/check-scenario-overlap/scripts/check-scenario-overlap.mts +0 -250
- package/skills/check-spec-references/scripts/check-spec-references.bundle.mjs +0 -285
- package/skills/check-spec-references/scripts/check-spec-references.mts +0 -422
- package/skills/check-spec-structure/scripts/check-spec-structure.bundle.mjs +0 -241
- package/skills/check-spec-structure/scripts/check-spec-structure.mts +0 -347
- package/skills/collision-ladder/scripts/collision-ladder.bundle.mjs +0 -7041
- package/skills/collision-ladder/scripts/collision-ladder.mts +0 -662
- package/skills/concept-index/scripts/concept-index.bundle.mjs +0 -181
- package/skills/concept-index/scripts/concept-index.mts +0 -246
- package/skills/discover-plans/scripts/discover-plans.bundle.mjs +0 -135
- package/skills/discover-plans/scripts/discover-plans.mts +0 -215
- package/skills/discover-specs/scripts/discover-specs.bundle.mjs +0 -319
- package/skills/discover-specs/scripts/discover-specs.mts +0 -420
- package/skills/init/scripts/wire-statusline.bundle.mjs +0 -166
- package/skills/init/scripts/wire-statusline.mts +0 -279
- package/skills/manage-ignore/scripts/manage-ignore.bundle.mjs +0 -244
- package/skills/manage-ignore/scripts/manage-ignore.mts +0 -297
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.bundle.mjs +0 -237
- package/skills/manage-scenario-bridge/scripts/manage-scenario-bridge.mts +0 -159
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.bundle.mjs +0 -308
- package/skills/manage-spec-anchors/scripts/manage-spec-anchors.mts +0 -331
- package/skills/mission-graph/scripts/mission-graph.bundle.mjs +0 -1038
- package/skills/mission-graph/scripts/mission-graph.mts +0 -1333
- package/skills/place-node/scripts/place-node.bundle.mjs +0 -115
- package/skills/place-node/scripts/place-node.mts +0 -158
- package/skills/plan-retirement/scripts/retire-plans.bundle.mjs +0 -121
- package/skills/plan-retirement/scripts/retire-plans.mts +0 -205
- package/skills/resolve-governances/scripts/resolve-governances.bundle.mjs +0 -350
- package/skills/resolve-governances/scripts/resolve-governances.mts +0 -518
- package/skills/resolve-tracking/scripts/resolve-tracking.bundle.mjs +0 -150
- package/skills/resolve-tracking/scripts/resolve-tracking.mts +0 -214
- package/skills/spec-gate/scripts/check-spec-state.bundle.mjs +0 -420
- package/skills/spec-gate/scripts/check-spec-state.mts +0 -637
- package/skills/spec-gate/scripts/check-suite.bundle.mjs +0 -6498
- package/skills/spec-gate/scripts/check-suite.mts +0 -538
- package/skills/spec-gate/scripts/classify-edit-class.bundle.mjs +0 -6577
- package/skills/spec-gate/scripts/classify-edit-class.mts +0 -414
- package/skills/spec-gate/vendor/gherkin-cli.d.mts +0 -2
- package/skills/spec-gate/vendor/gherkin-cli.mjs +0 -6545
- package/skills/touch-set-correction/scripts/touch-set-correction.bundle.mjs +0 -6655
- package/skills/touch-set-correction/scripts/touch-set-correction.mts +0 -429
- package/skills/verify-scenarios/scripts/verify-scenarios.bundle.mjs +0 -6537
- package/skills/verify-scenarios/scripts/verify-scenarios.mts +0 -470
|
@@ -1,233 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { execFileSync } from "node:child_process";
|
|
3
|
-
import { realpathSync } from "node:fs";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
//#region skills/check-partition-quality/scripts/check-partition-quality.mts
|
|
6
|
-
/** Below this many usable multi-file commits, a rate would be noise — report, never score. */
|
|
7
|
-
const DEFAULT_FLOOR = 20;
|
|
8
|
-
function parseGitLog(stdout, inScope) {
|
|
9
|
-
const out = [];
|
|
10
|
-
let cur = null;
|
|
11
|
-
for (const line of stdout.split("\n")) {
|
|
12
|
-
const t = line.trimEnd();
|
|
13
|
-
if (t === "") {
|
|
14
|
-
if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] });
|
|
15
|
-
cur = null;
|
|
16
|
-
continue;
|
|
17
|
-
}
|
|
18
|
-
if (/^[0-9a-f]{40}$/.test(t)) {
|
|
19
|
-
if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] });
|
|
20
|
-
cur = [];
|
|
21
|
-
continue;
|
|
22
|
-
}
|
|
23
|
-
if (cur !== null && inScope(t)) cur.push(t);
|
|
24
|
-
}
|
|
25
|
-
if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] });
|
|
26
|
-
return out.filter((c) => c.files.length > 1);
|
|
27
|
-
}
|
|
28
|
-
function readHistory(repo, inScope, limit = 4e3) {
|
|
29
|
-
return parseGitLog(execFileSync("git", [
|
|
30
|
-
"-C",
|
|
31
|
-
repo,
|
|
32
|
-
"log",
|
|
33
|
-
`-${limit}`,
|
|
34
|
-
"--name-only",
|
|
35
|
-
"--pretty=format:%H"
|
|
36
|
-
], {
|
|
37
|
-
encoding: "utf8",
|
|
38
|
-
maxBuffer: 268435456
|
|
39
|
-
}), inScope);
|
|
40
|
-
}
|
|
41
|
-
/** The set of nodes one change touches. A change touching none drops out of the measure. */
|
|
42
|
-
function nodesOf(c, p) {
|
|
43
|
-
const s = /* @__PURE__ */ new Set();
|
|
44
|
-
for (const f of c.files) {
|
|
45
|
-
const n = p(f);
|
|
46
|
-
if (n !== void 0) s.add(n);
|
|
47
|
-
}
|
|
48
|
-
return s;
|
|
49
|
-
}
|
|
50
|
-
function collisionRate(changes, p) {
|
|
51
|
-
const sets = changes.map((c) => nodesOf(c, p)).filter((s) => s.size > 0);
|
|
52
|
-
let pairs = 0;
|
|
53
|
-
let collided = 0;
|
|
54
|
-
for (let i = 0; i < sets.length; i++) for (let j = i + 1; j < sets.length; j++) {
|
|
55
|
-
pairs++;
|
|
56
|
-
const a = sets[i];
|
|
57
|
-
const b = sets[j];
|
|
58
|
-
for (const n of a) if (b.has(n)) {
|
|
59
|
-
collided++;
|
|
60
|
-
break;
|
|
61
|
-
}
|
|
62
|
-
}
|
|
63
|
-
return {
|
|
64
|
-
rate: pairs === 0 ? 0 : collided / pairs,
|
|
65
|
-
pairs
|
|
66
|
-
};
|
|
67
|
-
}
|
|
68
|
-
function mulberry32(seed) {
|
|
69
|
-
let a = seed >>> 0;
|
|
70
|
-
return () => {
|
|
71
|
-
a = a + 1831565813 >>> 0;
|
|
72
|
-
let t = Math.imul(a ^ a >>> 15, 1 | a);
|
|
73
|
-
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
74
|
-
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
75
|
-
};
|
|
76
|
-
}
|
|
77
|
-
function shuffledControl(changes, p, seed = 7) {
|
|
78
|
-
const files = [...new Set(changes.flatMap((c) => c.files))].filter((f) => p(f) !== void 0).sort();
|
|
79
|
-
const labels = files.map((f) => p(f));
|
|
80
|
-
const rnd = mulberry32(seed);
|
|
81
|
-
for (let i = labels.length - 1; i > 0; i--) {
|
|
82
|
-
const j = Math.floor(rnd() * (i + 1));
|
|
83
|
-
const li = labels[i];
|
|
84
|
-
const lj = labels[j];
|
|
85
|
-
labels[i] = lj;
|
|
86
|
-
labels[j] = li;
|
|
87
|
-
}
|
|
88
|
-
const m = new Map(files.map((f, i) => [f, labels[i]]));
|
|
89
|
-
return collisionRate(changes, (f) => m.get(f)).rate;
|
|
90
|
-
}
|
|
91
|
-
/**
|
|
92
|
-
* Rename `diagnostics` to `confoundedDiagnostics` on the way out to JSON.
|
|
93
|
-
*
|
|
94
|
-
* The text render carries the warning inline; a JSON consumer sees only keys, so a plain
|
|
95
|
-
* `diagnostics` object hands over two numbers indistinguishable from the headline. Renaming the key
|
|
96
|
-
* beats adding a note field: a consumer cannot read these numbers without typing the word
|
|
97
|
-
* "confounded", whereas a sibling note is trivially skipped.
|
|
98
|
-
*/
|
|
99
|
-
function toJson([name, m]) {
|
|
100
|
-
if (!("diagnostics" in m)) return [name, m];
|
|
101
|
-
const { diagnostics, ...rest } = m;
|
|
102
|
-
return [name, {
|
|
103
|
-
...rest,
|
|
104
|
-
confoundedDiagnostics: diagnostics
|
|
105
|
-
}];
|
|
106
|
-
}
|
|
107
|
-
function withinNodeCoChangeRatio(changes, p) {
|
|
108
|
-
let within = 0;
|
|
109
|
-
let total = 0;
|
|
110
|
-
for (const c of changes) {
|
|
111
|
-
const ns = c.files.map((f) => p(f)).filter((n) => n !== void 0);
|
|
112
|
-
for (let i = 0; i < ns.length; i++) for (let j = i + 1; j < ns.length; j++) {
|
|
113
|
-
total++;
|
|
114
|
-
if (ns[i] === ns[j]) within++;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return total === 0 ? 0 : within / total;
|
|
118
|
-
}
|
|
119
|
-
function meanNodesTouched(changes, p) {
|
|
120
|
-
const counts = changes.map((c) => nodesOf(c, p).size).filter((n) => n > 0);
|
|
121
|
-
return counts.length === 0 ? 0 : counts.reduce((a, b) => a + b, 0) / counts.length;
|
|
122
|
-
}
|
|
123
|
-
function measure(changes, p, floor = 20) {
|
|
124
|
-
const usable = changes.filter((c) => nodesOf(c, p).size > 0);
|
|
125
|
-
if (usable.length < floor) return {
|
|
126
|
-
thin: true,
|
|
127
|
-
usableCommits: usable.length,
|
|
128
|
-
floor
|
|
129
|
-
};
|
|
130
|
-
const { rate, pairs } = collisionRate(usable, p);
|
|
131
|
-
const control = shuffledControl(usable, p);
|
|
132
|
-
const margin = control - rate;
|
|
133
|
-
return {
|
|
134
|
-
nodes: new Set(usable.flatMap((c) => [...nodesOf(c, p)])).size,
|
|
135
|
-
pairs,
|
|
136
|
-
collisionRate: rate,
|
|
137
|
-
parallelizableShare: 1 - rate,
|
|
138
|
-
control,
|
|
139
|
-
marginOverControl: margin,
|
|
140
|
-
explainsNothing: margin <= 0,
|
|
141
|
-
diagnostics: {
|
|
142
|
-
withinNodeCoChangeRatio: withinNodeCoChangeRatio(usable, p),
|
|
143
|
-
meanNodesTouched: meanNodesTouched(usable, p)
|
|
144
|
-
}
|
|
145
|
-
};
|
|
146
|
-
}
|
|
147
|
-
function isThin(m) {
|
|
148
|
-
return m.thin === true;
|
|
149
|
-
}
|
|
150
|
-
const PARTITIONS = {
|
|
151
|
-
/** Top-level folder under the scope — the capability cut for a screaming layout. */
|
|
152
|
-
"top-folder": (scope) => (f) => {
|
|
153
|
-
const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, "") : void 0;
|
|
154
|
-
if (rel === void 0) return void 0;
|
|
155
|
-
const seg = rel.split("/")[0];
|
|
156
|
-
return seg === void 0 || seg === "" || !rel.includes("/") ? void 0 : seg;
|
|
157
|
-
},
|
|
158
|
-
/** Second-level folder — a finer cut, for a capability/unit tree. */
|
|
159
|
-
"second-folder": (scope) => (f) => {
|
|
160
|
-
const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, "") : void 0;
|
|
161
|
-
if (rel === void 0) return void 0;
|
|
162
|
-
const p = rel.split("/");
|
|
163
|
-
return p.length > 2 ? `${p[0]}/${p[1]}` : void 0;
|
|
164
|
-
},
|
|
165
|
-
/** File extension / artifact role — the layered analogue, useful as a contrast candidate. */
|
|
166
|
-
role: (scope) => (f) => {
|
|
167
|
-
if (!f.startsWith(scope)) return void 0;
|
|
168
|
-
const n = f.split("/").pop() ?? "";
|
|
169
|
-
const dot = n.indexOf(".");
|
|
170
|
-
return dot === -1 ? n : n.slice(dot + 1);
|
|
171
|
-
},
|
|
172
|
-
/** Everything in one node — the degenerate floor: no parallel work is possible. */
|
|
173
|
-
single: (scope) => (f) => f.startsWith(scope) ? "all" : void 0
|
|
174
|
-
};
|
|
175
|
-
function pct(n) {
|
|
176
|
-
return `${(n * 100).toFixed(1)}%`;
|
|
177
|
-
}
|
|
178
|
-
function renderOne(label, m) {
|
|
179
|
-
if (isThin(m)) return ` ${label}: history too thin to measure — ${m.usableCommits} usable multi-file commits, floor ${m.floor}. No rate emitted.`;
|
|
180
|
-
const lines = [
|
|
181
|
-
` ${label}`,
|
|
182
|
-
` parallelizable share : ${pct(m.parallelizableShare)} ← the headline`,
|
|
183
|
-
` collision rate : ${pct(m.collisionRate)} over ${m.pairs} change pairs, ${m.nodes} nodes`,
|
|
184
|
-
` shuffled control : ${pct(m.control)} (margin ${m.marginOverControl >= 0 ? "+" : ""}${pct(m.marginOverControl)})`
|
|
185
|
-
];
|
|
186
|
-
if (m.collisionRate >= 1) lines.push(" ⚠ every change pair collides — this partition permits no parallel work");
|
|
187
|
-
if (m.explainsNothing) lines.push(" ⚠ this partition explains no more than chance — do not trust the headline");
|
|
188
|
-
lines.push(` diagnostics (CONFOUNDED by node count — not the headline): within-node co-change ${pct(m.diagnostics.withinNodeCoChangeRatio)}, mean nodes touched ${m.diagnostics.meanNodesTouched.toFixed(2)}`);
|
|
189
|
-
return lines.join("\n");
|
|
190
|
-
}
|
|
191
|
-
function render(results, repo, scope) {
|
|
192
|
-
const out = [`check-partition-quality: repo=${repo} scope=${scope || "(whole repo)"}`];
|
|
193
|
-
for (const [label, m] of results) out.push(renderOne(label, m));
|
|
194
|
-
if (results.length > 1) {
|
|
195
|
-
const scored = results.filter(([, m]) => !isThin(m));
|
|
196
|
-
if (scored.length > 1) {
|
|
197
|
-
const best = scored.reduce((a, b) => b[1].parallelizableShare > a[1].parallelizableShare ? b : a);
|
|
198
|
-
out.push(`\nOn this history, "${best[0]}" permits the most parallel work (${pct(best[1].parallelizableShare)}).`);
|
|
199
|
-
}
|
|
200
|
-
}
|
|
201
|
-
out.push("note: a measurement, not a verdict — it moves no file and gates nothing; layout is the owner’s call");
|
|
202
|
-
return out.join("\n");
|
|
203
|
-
}
|
|
204
|
-
function main(argv, context = { readHistory }) {
|
|
205
|
-
let repo = ".";
|
|
206
|
-
let scope = "";
|
|
207
|
-
let floor = 20;
|
|
208
|
-
let limit = 4e3;
|
|
209
|
-
let format = "text";
|
|
210
|
-
const candidates = [];
|
|
211
|
-
for (let i = 0; i < argv.length; i++) {
|
|
212
|
-
const a = argv[i];
|
|
213
|
-
if (a === "--repo") repo = argv[++i] ?? ".";
|
|
214
|
-
else if (a === "--scope") scope = argv[++i] ?? "";
|
|
215
|
-
else if (a === "--floor") floor = Number(argv[++i] ?? 20);
|
|
216
|
-
else if (a === "--limit") limit = Number(argv[++i] ?? 4e3);
|
|
217
|
-
else if (a === "--format") format = argv[++i] ?? "text";
|
|
218
|
-
else if (a === "--partition") candidates.push(argv[++i] ?? "");
|
|
219
|
-
}
|
|
220
|
-
if (candidates.length === 0) candidates.push("top-folder", "role");
|
|
221
|
-
for (const c of candidates) if (!(c in PARTITIONS)) {
|
|
222
|
-
process.stderr.write(`unknown --partition "${c}" — known: ${Object.keys(PARTITIONS).join(", ")}\n`);
|
|
223
|
-
return 1;
|
|
224
|
-
}
|
|
225
|
-
const changes = context.readHistory(repo, (f) => scope ? f.startsWith(scope) : true, limit);
|
|
226
|
-
const results = candidates.map((c) => [c, measure(changes, PARTITIONS[c](scope), floor)]);
|
|
227
|
-
if (format === "json") process.stdout.write(`${JSON.stringify(Object.fromEntries(results.map(toJson)), null, 2)}\n`);
|
|
228
|
-
else process.stdout.write(`${render(results, repo, scope)}\n`);
|
|
229
|
-
return 0;
|
|
230
|
-
}
|
|
231
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
|
|
232
|
-
//#endregion
|
|
233
|
-
export { DEFAULT_FLOOR, PARTITIONS, collisionRate, isThin, main, meanNodesTouched, measure, parseGitLog, readHistory, render, renderOne, shuffledControl, toJson, withinNodeCoChangeRatio };
|
|
@@ -1,338 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
// check-partition-quality — project-spec/partition-quality's concrete engine. Measures, from a
|
|
3
|
-
// project's own git history, how much parallel work its layout permits, and compares candidate
|
|
4
|
-
// layouts on the same evidence (see this skill's README.md).
|
|
5
|
-
//
|
|
6
|
-
// THE METRIC IS COLLISION RATE, AND THAT CHOICE IS LOAD-BEARING. The scheduler cuts one mission per
|
|
7
|
-
// spec-node, so two changes touching a shared node must serialize. Collision rate is the share of
|
|
8
|
-
// change pairs that do. The headline is its complement — the parallelizable share.
|
|
9
|
-
//
|
|
10
|
-
// Two obvious alternatives were tried first and BOTH preferred a layered partition (the wrong
|
|
11
|
-
// answer), because both are confounded by node count:
|
|
12
|
-
// - within-node co-change ratio — a coarser partition scores well for being coarse
|
|
13
|
-
// - mean nodes touched per change — a single-node partition scores PERFECTLY while permitting
|
|
14
|
-
// zero parallel work
|
|
15
|
-
// They are computed and reported here as DIAGNOSTICS, explicitly labelled, so that a later reader
|
|
16
|
-
// does not "simplify" the engine back onto one of them. Do not headline them.
|
|
17
|
-
//
|
|
18
|
-
// Read-only: it runs `git log`, writes nothing, and renders no verdict — layout is the owner's call.
|
|
19
|
-
// No dependencies (the repo's node-≥23.6 / no-deps convention). Pure functions are exported for
|
|
20
|
-
// node:test; running the file directly drives the CLI.
|
|
21
|
-
|
|
22
|
-
import { execFileSync } from 'node:child_process'
|
|
23
|
-
import { realpathSync } from 'node:fs'
|
|
24
|
-
import { pathToFileURL } from 'node:url'
|
|
25
|
-
|
|
26
|
-
/** Below this many usable multi-file commits, a rate would be noise — report, never score. */
|
|
27
|
-
export const DEFAULT_FLOOR = 20
|
|
28
|
-
|
|
29
|
-
export interface Change {
|
|
30
|
-
/** In-scope files touched by one commit. */
|
|
31
|
-
files: string[]
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
/** A partition names the node each file belongs to; `undefined` drops the file from the measure. */
|
|
35
|
-
export type Partition = (file: string) => string | undefined
|
|
36
|
-
|
|
37
|
-
export interface Measurement {
|
|
38
|
-
nodes: number
|
|
39
|
-
pairs: number
|
|
40
|
-
collisionRate: number
|
|
41
|
-
parallelizableShare: number
|
|
42
|
-
control: number
|
|
43
|
-
/** Positive when the partition explains more than a shuffle of the same node sizes. */
|
|
44
|
-
marginOverControl: number
|
|
45
|
-
explainsNothing: boolean
|
|
46
|
-
diagnostics: {
|
|
47
|
-
/** CONFOUNDED by node count — a coarser partition scores higher. Never headline. */
|
|
48
|
-
withinNodeCoChangeRatio: number
|
|
49
|
-
/** CONFOUNDED by node count — a single-node partition scores a perfect 1. Never headline. */
|
|
50
|
-
meanNodesTouched: number
|
|
51
|
-
}
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export interface ThinHistory {
|
|
55
|
-
thin: true
|
|
56
|
-
usableCommits: number
|
|
57
|
-
floor: number
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
// ── Reading history ──────────────────────────────────────────────────────────
|
|
61
|
-
// Only multi-file changes inform the measure: a single-file commit contributes no pair, because one
|
|
62
|
-
// file can collide with nothing.
|
|
63
|
-
|
|
64
|
-
export function parseGitLog(stdout: string, inScope: (f: string) => boolean): Change[] {
|
|
65
|
-
const out: Change[] = []
|
|
66
|
-
let cur: string[] | null = null
|
|
67
|
-
for (const line of stdout.split('\n')) {
|
|
68
|
-
const t = line.trimEnd()
|
|
69
|
-
if (t === '') {
|
|
70
|
-
if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] })
|
|
71
|
-
cur = null
|
|
72
|
-
continue
|
|
73
|
-
}
|
|
74
|
-
if (/^[0-9a-f]{40}$/.test(t)) {
|
|
75
|
-
if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] })
|
|
76
|
-
cur = []
|
|
77
|
-
continue
|
|
78
|
-
}
|
|
79
|
-
if (cur !== null && inScope(t)) cur.push(t)
|
|
80
|
-
}
|
|
81
|
-
if (cur && cur.length > 1) out.push({ files: [...new Set(cur)] })
|
|
82
|
-
return out.filter((c) => c.files.length > 1)
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
export function readHistory(repo: string, inScope: (f: string) => boolean, limit = 4000): Change[] {
|
|
86
|
-
const stdout = execFileSync('git', ['-C', repo, 'log', `-${limit}`, '--name-only', '--pretty=format:%H'], {
|
|
87
|
-
encoding: 'utf8',
|
|
88
|
-
maxBuffer: 256 * 1024 * 1024,
|
|
89
|
-
})
|
|
90
|
-
return parseGitLog(stdout, inScope)
|
|
91
|
-
}
|
|
92
|
-
|
|
93
|
-
// ── The metric ───────────────────────────────────────────────────────────────
|
|
94
|
-
|
|
95
|
-
/** The set of nodes one change touches. A change touching none drops out of the measure. */
|
|
96
|
-
function nodesOf(c: Change, p: Partition): Set<string> {
|
|
97
|
-
const s = new Set<string>()
|
|
98
|
-
for (const f of c.files) {
|
|
99
|
-
const n = p(f)
|
|
100
|
-
if (n !== undefined) s.add(n)
|
|
101
|
-
}
|
|
102
|
-
return s
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
export function collisionRate(changes: Change[], p: Partition): { rate: number; pairs: number } {
|
|
106
|
-
const sets = changes.map((c) => nodesOf(c, p)).filter((s) => s.size > 0)
|
|
107
|
-
let pairs = 0
|
|
108
|
-
let collided = 0
|
|
109
|
-
for (let i = 0; i < sets.length; i++) {
|
|
110
|
-
for (let j = i + 1; j < sets.length; j++) {
|
|
111
|
-
pairs++
|
|
112
|
-
const a = sets[i] as Set<string>
|
|
113
|
-
const b = sets[j] as Set<string>
|
|
114
|
-
for (const n of a) {
|
|
115
|
-
if (b.has(n)) {
|
|
116
|
-
collided++
|
|
117
|
-
break
|
|
118
|
-
}
|
|
119
|
-
}
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
return { rate: pairs === 0 ? 0 : collided / pairs, pairs }
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// ── The control ──────────────────────────────────────────────────────────────
|
|
126
|
-
// The same measurement over a SHUFFLED partition of identical node sizes. A partition whose rate
|
|
127
|
-
// matches its shuffle explains nothing, and the headline should not be trusted. Deterministic seed:
|
|
128
|
-
// the run must be reproducible.
|
|
129
|
-
|
|
130
|
-
function mulberry32(seed: number): () => number {
|
|
131
|
-
let a = seed >>> 0
|
|
132
|
-
return () => {
|
|
133
|
-
a = (a + 0x6d2b79f5) >>> 0
|
|
134
|
-
let t = Math.imul(a ^ (a >>> 15), 1 | a)
|
|
135
|
-
t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
|
|
136
|
-
return ((t ^ (t >>> 14)) >>> 0) / 4294967296
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
|
|
140
|
-
export function shuffledControl(changes: Change[], p: Partition, seed = 7): number {
|
|
141
|
-
const files = [...new Set(changes.flatMap((c) => c.files))].filter((f) => p(f) !== undefined).sort()
|
|
142
|
-
const labels = files.map((f) => p(f) as string)
|
|
143
|
-
const rnd = mulberry32(seed)
|
|
144
|
-
for (let i = labels.length - 1; i > 0; i--) {
|
|
145
|
-
const j = Math.floor(rnd() * (i + 1))
|
|
146
|
-
const li = labels[i] as string
|
|
147
|
-
const lj = labels[j] as string
|
|
148
|
-
labels[i] = lj
|
|
149
|
-
labels[j] = li
|
|
150
|
-
}
|
|
151
|
-
const m = new Map(files.map((f, i) => [f, labels[i] as string]))
|
|
152
|
-
return collisionRate(changes, (f) => m.get(f)).rate
|
|
153
|
-
}
|
|
154
|
-
|
|
155
|
-
/**
|
|
156
|
-
* Rename `diagnostics` to `confoundedDiagnostics` on the way out to JSON.
|
|
157
|
-
*
|
|
158
|
-
* The text render carries the warning inline; a JSON consumer sees only keys, so a plain
|
|
159
|
-
* `diagnostics` object hands over two numbers indistinguishable from the headline. Renaming the key
|
|
160
|
-
* beats adding a note field: a consumer cannot read these numbers without typing the word
|
|
161
|
-
* "confounded", whereas a sibling note is trivially skipped.
|
|
162
|
-
*/
|
|
163
|
-
export function toJson([name, m]: [string, Measurement | ThinHistory]): [string, unknown] {
|
|
164
|
-
if (!('diagnostics' in m)) return [name, m]
|
|
165
|
-
const { diagnostics, ...rest } = m
|
|
166
|
-
return [name, { ...rest, confoundedDiagnostics: diagnostics }]
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
// ── Confounded diagnostics — labelled, never the headline ────────────────────
|
|
170
|
-
|
|
171
|
-
export function withinNodeCoChangeRatio(changes: Change[], p: Partition): number {
|
|
172
|
-
let within = 0
|
|
173
|
-
let total = 0
|
|
174
|
-
for (const c of changes) {
|
|
175
|
-
const ns = c.files.map((f) => p(f)).filter((n): n is string => n !== undefined)
|
|
176
|
-
for (let i = 0; i < ns.length; i++) {
|
|
177
|
-
for (let j = i + 1; j < ns.length; j++) {
|
|
178
|
-
total++
|
|
179
|
-
if (ns[i] === ns[j]) within++
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
}
|
|
183
|
-
return total === 0 ? 0 : within / total
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
export function meanNodesTouched(changes: Change[], p: Partition): number {
|
|
187
|
-
const counts = changes.map((c) => nodesOf(c, p).size).filter((n) => n > 0)
|
|
188
|
-
return counts.length === 0 ? 0 : counts.reduce((a, b) => a + b, 0) / counts.length
|
|
189
|
-
}
|
|
190
|
-
|
|
191
|
-
// ── Measure ──────────────────────────────────────────────────────────────────
|
|
192
|
-
|
|
193
|
-
export function measure(changes: Change[], p: Partition, floor = DEFAULT_FLOOR): Measurement | ThinHistory {
|
|
194
|
-
const usable = changes.filter((c) => nodesOf(c, p).size > 0)
|
|
195
|
-
if (usable.length < floor) return { thin: true, usableCommits: usable.length, floor }
|
|
196
|
-
const { rate, pairs } = collisionRate(usable, p)
|
|
197
|
-
const control = shuffledControl(usable, p)
|
|
198
|
-
const margin = control - rate // lower collision than the shuffle is the improvement
|
|
199
|
-
return {
|
|
200
|
-
nodes: new Set(usable.flatMap((c) => [...nodesOf(c, p)])).size,
|
|
201
|
-
pairs,
|
|
202
|
-
collisionRate: rate,
|
|
203
|
-
parallelizableShare: 1 - rate,
|
|
204
|
-
control,
|
|
205
|
-
marginOverControl: margin,
|
|
206
|
-
explainsNothing: margin <= 0,
|
|
207
|
-
diagnostics: {
|
|
208
|
-
withinNodeCoChangeRatio: withinNodeCoChangeRatio(usable, p),
|
|
209
|
-
meanNodesTouched: meanNodesTouched(usable, p),
|
|
210
|
-
},
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
export function isThin(m: Measurement | ThinHistory): m is ThinHistory {
|
|
215
|
-
return (m as ThinHistory).thin === true
|
|
216
|
-
}
|
|
217
|
-
|
|
218
|
-
// ── Built-in candidate partitions ────────────────────────────────────────────
|
|
219
|
-
// Named so a caller can compare "what the tree does now" against "what a capability cut would do"
|
|
220
|
-
// without writing code.
|
|
221
|
-
|
|
222
|
-
export const PARTITIONS: Record<string, (scope: string) => Partition> = {
|
|
223
|
-
/** Top-level folder under the scope — the capability cut for a screaming layout. */
|
|
224
|
-
'top-folder': (scope) => (f) => {
|
|
225
|
-
const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, '') : undefined
|
|
226
|
-
if (rel === undefined) return undefined
|
|
227
|
-
const seg = rel.split('/')[0]
|
|
228
|
-
return seg === undefined || seg === '' || !rel.includes('/') ? undefined : seg
|
|
229
|
-
},
|
|
230
|
-
/** Second-level folder — a finer cut, for a capability/unit tree. */
|
|
231
|
-
'second-folder': (scope) => (f) => {
|
|
232
|
-
const rel = f.startsWith(scope) ? f.slice(scope.length).replace(/^\//, '') : undefined
|
|
233
|
-
if (rel === undefined) return undefined
|
|
234
|
-
const p = rel.split('/')
|
|
235
|
-
return p.length > 2 ? `${p[0]}/${p[1]}` : undefined
|
|
236
|
-
},
|
|
237
|
-
/** File extension / artifact role — the layered analogue, useful as a contrast candidate. */
|
|
238
|
-
role: (scope) => (f) => {
|
|
239
|
-
if (!f.startsWith(scope)) return undefined
|
|
240
|
-
const n = f.split('/').pop() ?? ''
|
|
241
|
-
const dot = n.indexOf('.')
|
|
242
|
-
return dot === -1 ? n : n.slice(dot + 1)
|
|
243
|
-
},
|
|
244
|
-
/** Everything in one node — the degenerate floor: no parallel work is possible. */
|
|
245
|
-
single: (scope) => (f) => (f.startsWith(scope) ? 'all' : undefined),
|
|
246
|
-
}
|
|
247
|
-
|
|
248
|
-
// ── Render ───────────────────────────────────────────────────────────────────
|
|
249
|
-
|
|
250
|
-
function pct(n: number): string {
|
|
251
|
-
return `${(n * 100).toFixed(1)}%`
|
|
252
|
-
}
|
|
253
|
-
|
|
254
|
-
export function renderOne(label: string, m: Measurement | ThinHistory): string {
|
|
255
|
-
if (isThin(m)) {
|
|
256
|
-
return ` ${label}: history too thin to measure — ${m.usableCommits} usable multi-file commits, floor ${m.floor}. No rate emitted.`
|
|
257
|
-
}
|
|
258
|
-
const lines = [
|
|
259
|
-
` ${label}`,
|
|
260
|
-
` parallelizable share : ${pct(m.parallelizableShare)} ← the headline`,
|
|
261
|
-
` collision rate : ${pct(m.collisionRate)} over ${m.pairs} change pairs, ${m.nodes} nodes`,
|
|
262
|
-
` shuffled control : ${pct(m.control)} (margin ${m.marginOverControl >= 0 ? '+' : ''}${pct(m.marginOverControl)})`,
|
|
263
|
-
]
|
|
264
|
-
if (m.collisionRate >= 1) {
|
|
265
|
-
lines.push(' ⚠ every change pair collides — this partition permits no parallel work')
|
|
266
|
-
}
|
|
267
|
-
if (m.explainsNothing) {
|
|
268
|
-
lines.push(' ⚠ this partition explains no more than chance — do not trust the headline')
|
|
269
|
-
}
|
|
270
|
-
lines.push(
|
|
271
|
-
` diagnostics (CONFOUNDED by node count — not the headline): within-node co-change ${pct(m.diagnostics.withinNodeCoChangeRatio)}, mean nodes touched ${m.diagnostics.meanNodesTouched.toFixed(2)}`,
|
|
272
|
-
)
|
|
273
|
-
return lines.join('\n')
|
|
274
|
-
}
|
|
275
|
-
|
|
276
|
-
export function render(results: [string, Measurement | ThinHistory][], repo: string, scope: string): string {
|
|
277
|
-
const out = [`check-partition-quality: repo=${repo} scope=${scope || '(whole repo)'}`]
|
|
278
|
-
for (const [label, m] of results) out.push(renderOne(label, m))
|
|
279
|
-
if (results.length > 1) {
|
|
280
|
-
const scored = results.filter(([, m]) => !isThin(m)) as [string, Measurement][]
|
|
281
|
-
if (scored.length > 1) {
|
|
282
|
-
const best = scored.reduce((a, b) => (b[1].parallelizableShare > a[1].parallelizableShare ? b : a))
|
|
283
|
-
out.push(`\nOn this history, "${best[0]}" permits the most parallel work (${pct(best[1].parallelizableShare)}).`)
|
|
284
|
-
}
|
|
285
|
-
}
|
|
286
|
-
out.push('note: a measurement, not a verdict — it moves no file and gates nothing; layout is the owner’s call')
|
|
287
|
-
return out.join('\n')
|
|
288
|
-
}
|
|
289
|
-
|
|
290
|
-
// ── CLI ──────────────────────────────────────────────────────────────────────
|
|
291
|
-
|
|
292
|
-
/**
|
|
293
|
-
* The effects `main` reaches the outside world through. Dependencies enter at the CLI boundary and
|
|
294
|
-
* nowhere below it: everything under `main` is pure over the history it is handed.
|
|
295
|
-
*/
|
|
296
|
-
export type Context = { readHistory: typeof readHistory }
|
|
297
|
-
|
|
298
|
-
export function main(argv: string[], context: Context = { readHistory }): number {
|
|
299
|
-
let repo = '.'
|
|
300
|
-
let scope = ''
|
|
301
|
-
let floor = DEFAULT_FLOOR
|
|
302
|
-
let limit = 4000
|
|
303
|
-
let format: 'text' | 'json' = 'text'
|
|
304
|
-
const candidates: string[] = []
|
|
305
|
-
for (let i = 0; i < argv.length; i++) {
|
|
306
|
-
const a = argv[i]
|
|
307
|
-
if (a === '--repo') repo = argv[++i] ?? '.'
|
|
308
|
-
else if (a === '--scope') scope = argv[++i] ?? ''
|
|
309
|
-
else if (a === '--floor') floor = Number(argv[++i] ?? DEFAULT_FLOOR)
|
|
310
|
-
else if (a === '--limit') limit = Number(argv[++i] ?? 4000)
|
|
311
|
-
else if (a === '--format') format = (argv[++i] as 'text' | 'json') ?? 'text'
|
|
312
|
-
else if (a === '--partition') candidates.push(argv[++i] ?? '')
|
|
313
|
-
}
|
|
314
|
-
if (candidates.length === 0) candidates.push('top-folder', 'role')
|
|
315
|
-
for (const c of candidates) {
|
|
316
|
-
if (!(c in PARTITIONS)) {
|
|
317
|
-
process.stderr.write(`unknown --partition "${c}" — known: ${Object.keys(PARTITIONS).join(', ')}\n`)
|
|
318
|
-
return 1
|
|
319
|
-
}
|
|
320
|
-
}
|
|
321
|
-
// Read ONCE, then measure every candidate over that one history — the comparison is only
|
|
322
|
-
// meaningful if each candidate is scored against the same commits and the same scope.
|
|
323
|
-
const changes = context.readHistory(repo, (f) => (scope ? f.startsWith(scope) : true), limit)
|
|
324
|
-
const results = candidates.map(
|
|
325
|
-
(c) =>
|
|
326
|
-
[c, measure(changes, (PARTITIONS[c] as (s: string) => Partition)(scope), floor)] as [
|
|
327
|
-
string,
|
|
328
|
-
Measurement | ThinHistory,
|
|
329
|
-
],
|
|
330
|
-
)
|
|
331
|
-
if (format === 'json') process.stdout.write(`${JSON.stringify(Object.fromEntries(results.map(toJson)), null, 2)}\n`)
|
|
332
|
-
else process.stdout.write(`${render(results, repo, scope)}\n`)
|
|
333
|
-
return 0
|
|
334
|
-
}
|
|
335
|
-
|
|
336
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) {
|
|
337
|
-
process.exit(main(process.argv.slice(2)))
|
|
338
|
-
}
|
|
@@ -1,107 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
import { existsSync, readFileSync, readdirSync, realpathSync } from "node:fs";
|
|
3
|
-
import { join } from "node:path";
|
|
4
|
-
import { pathToFileURL } from "node:url";
|
|
5
|
-
//#region skills/check-plan-safety/scripts/check-plan-safety.mts
|
|
6
|
-
const LEAK_PATTERNS = [
|
|
7
|
-
{
|
|
8
|
-
kind: "home-abs-path",
|
|
9
|
-
re: /\/(?:home|Users)\/[^\s'"`)\]]+/g
|
|
10
|
-
},
|
|
11
|
-
{
|
|
12
|
-
kind: "home-abs-path",
|
|
13
|
-
re: /[A-Za-z]:\\Users\\[^\s'"`)\]]+/g
|
|
14
|
-
},
|
|
15
|
-
{
|
|
16
|
-
kind: "env-home",
|
|
17
|
-
re: /\$\{?HOME\}?(?![A-Za-z])|%USERPROFILE%|%HOMEPATH%/g
|
|
18
|
-
},
|
|
19
|
-
{
|
|
20
|
-
kind: "env-user",
|
|
21
|
-
re: /\$\{?USER\}?(?![A-Za-z])|%USERNAME%/g
|
|
22
|
-
}
|
|
23
|
-
];
|
|
24
|
-
/** Cap a matched token so a pathological long path does not blow up the report. */
|
|
25
|
-
function capToken(t) {
|
|
26
|
-
return t.length > 80 ? `${t.slice(0, 77)}...` : t;
|
|
27
|
-
}
|
|
28
|
-
function scanText(file, text) {
|
|
29
|
-
const out = [];
|
|
30
|
-
const lines = text.split("\n");
|
|
31
|
-
for (let i = 0; i < lines.length; i++) {
|
|
32
|
-
const line = lines[i].replace(/\r$/, "");
|
|
33
|
-
for (const { kind, re } of LEAK_PATTERNS) {
|
|
34
|
-
re.lastIndex = 0;
|
|
35
|
-
let m = re.exec(line);
|
|
36
|
-
while (m !== null) {
|
|
37
|
-
out.push({
|
|
38
|
-
file,
|
|
39
|
-
line: i + 1,
|
|
40
|
-
kind,
|
|
41
|
-
token: capToken(m[0])
|
|
42
|
-
});
|
|
43
|
-
if (m.index === re.lastIndex) re.lastIndex++;
|
|
44
|
-
m = re.exec(line);
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
return out;
|
|
49
|
-
}
|
|
50
|
-
function planFiles(root) {
|
|
51
|
-
const dir = join(root, ".agents", "plans");
|
|
52
|
-
if (!existsSync(dir)) return [];
|
|
53
|
-
let entries;
|
|
54
|
-
try {
|
|
55
|
-
entries = readdirSync(dir, { withFileTypes: true });
|
|
56
|
-
} catch {
|
|
57
|
-
return [];
|
|
58
|
-
}
|
|
59
|
-
return entries.filter((e) => e.isFile() && e.name.endsWith(".md")).map((e) => e.name).sort();
|
|
60
|
-
}
|
|
61
|
-
function collectLeaks(root, paths) {
|
|
62
|
-
const targets = paths ? paths.map((p) => ({
|
|
63
|
-
rel: p,
|
|
64
|
-
abs: p
|
|
65
|
-
})) : planFiles(root).map((name) => ({
|
|
66
|
-
rel: join(".agents", "plans", name),
|
|
67
|
-
abs: join(root, ".agents", "plans", name)
|
|
68
|
-
}));
|
|
69
|
-
const out = [];
|
|
70
|
-
for (const { rel, abs } of targets) {
|
|
71
|
-
let text;
|
|
72
|
-
try {
|
|
73
|
-
text = readFileSync(abs, "utf8");
|
|
74
|
-
} catch {
|
|
75
|
-
continue;
|
|
76
|
-
}
|
|
77
|
-
out.push(...scanText(rel, text));
|
|
78
|
-
}
|
|
79
|
-
return out.sort((a, b) => a.file !== b.file ? a.file < b.file ? -1 : 1 : a.line - b.line);
|
|
80
|
-
}
|
|
81
|
-
const COLUMNS = [
|
|
82
|
-
"file",
|
|
83
|
-
"line",
|
|
84
|
-
"kind",
|
|
85
|
-
"token"
|
|
86
|
-
];
|
|
87
|
-
function toonField(v) {
|
|
88
|
-
if (v === "" || /[",]/.test(v) || v !== v.trim()) return `"${v.replace(/"/g, "\"\"")}"`;
|
|
89
|
-
return v;
|
|
90
|
-
}
|
|
91
|
-
function toToon(leaks) {
|
|
92
|
-
return [`leaks[${leaks.length}]{${COLUMNS.join(",")}}:`, ...leaks.map((l) => ` ${COLUMNS.map((c) => toonField(String(l[c]))).join(",")}`)].join("\n");
|
|
93
|
-
}
|
|
94
|
-
function main(argv) {
|
|
95
|
-
const root = argv.includes("--root") ? argv[argv.indexOf("--root") + 1] ?? "." : ".";
|
|
96
|
-
const format = argv.includes("--format") ? argv[argv.indexOf("--format") + 1] : "toon";
|
|
97
|
-
const check = argv.includes("--check");
|
|
98
|
-
const paths = [];
|
|
99
|
-
for (let i = 0; i < argv.length; i++) if (argv[i] === "--path" && argv[i + 1]) paths.push(argv[++i]);
|
|
100
|
-
const leaks = collectLeaks(root, paths.length ? paths : void 0);
|
|
101
|
-
const out = format === "json" ? JSON.stringify(leaks, null, 2) : toToon(leaks);
|
|
102
|
-
process.stdout.write(`${out}\n`);
|
|
103
|
-
return check && leaks.length > 0 ? 1 : 0;
|
|
104
|
-
}
|
|
105
|
-
if (process.argv[1] && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href) process.exit(main(process.argv.slice(2)));
|
|
106
|
-
//#endregion
|
|
107
|
-
export { LEAK_PATTERNS, collectLeaks, main, scanText, toToon };
|