@tekyzinc/gsd-t 5.11.30 → 5.11.32
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 +121 -0
- package/README.md +1 -1
- package/bin/gsd-t-graph-use-gate.cjs +239 -0
- package/bin/gsd-t-slice-budget.cjs +38 -2
- package/bin/gsd-t-verify-gate.cjs +2 -0
- package/bin/gsd-t.js +4 -0
- package/commands/gsd-t-debug.md +1 -1
- package/commands/gsd-t-design-build.md +1 -1
- package/commands/gsd-t-quick.md +1 -1
- package/commands/gsd-t-test-sync.md +1 -1
- package/package.json +2 -2
- package/templates/workflows/gsd-t-integrate.workflow.js +46 -15
- package/templates/workflows/gsd-t-scan.workflow.js +421 -49
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,127 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [5.11.32] - 2026-08-14
|
|
6
|
+
|
|
7
|
+
### Fixed — a missing graph is now BUILT, and a "wired" claim has to prove itself
|
|
8
|
+
|
|
9
|
+
A TimeTracking scan finished, reported success, and had done its whole deep
|
|
10
|
+
analysis with grep. The graph was healthy, current, and the run even logged
|
|
11
|
+
`graphWiringMode: WIRED` — but of 666 ledger events, every single one carried
|
|
12
|
+
`consumer: "cli"`. Not one came from an analyst agent. The reports say it
|
|
13
|
+
plainly in their own words: *"a repo-wide grep shows no caller anywhere."*
|
|
14
|
+
Nothing failed, because nothing was watching for this.
|
|
15
|
+
|
|
16
|
+
Three defects, one theme — **a check that cannot fail is not a check**:
|
|
17
|
+
|
|
18
|
+
- **An absent graph left consumers blind instead of building it.** Every
|
|
19
|
+
FAIL-LOUD clause said: don't fall back to grep, then *proceed without the
|
|
20
|
+
structural slice*. That is a third bad outcome sitting between the two the
|
|
21
|
+
doctrine names — the agent answers "what calls this?" with no structural
|
|
22
|
+
knowledge at all. The clauses in `/quick`, `/debug`, `/design-build` and
|
|
23
|
+
`/test-sync` now BUILD the index and re-run the query; only a failed build
|
|
24
|
+
halts. This was already the contract's own rule (§FAIL-LOUD line 64,
|
|
25
|
+
"REPAIR IT, do not merely halt") — the command docs had been contradicting
|
|
26
|
+
it. `[RULE] graph-absent-builds-not-degrades`
|
|
27
|
+
- **`/integrate` had a licensed exemption.** It printed `⚠ graph ABSENT —
|
|
28
|
+
structural wiring-check skipped (announced carve-out)`. A fresh worktree
|
|
29
|
+
carries no graph (it is gitignored), so the check did nothing on exactly the
|
|
30
|
+
runs it was written for. Announcing a skip does not stop it being a skip. It
|
|
31
|
+
now builds. The verify/integrate exception covers the HARD-FAIL only; it
|
|
32
|
+
never covered the build.
|
|
33
|
+
- **The anti-grep lint aimed at the wrong target.** Being static, its only
|
|
34
|
+
failure mode is a code shape (`try graph → catch → grep`). A consumer that
|
|
35
|
+
never calls the graph has no catch block, so the lint reports clean while the
|
|
36
|
+
entire run uses text search.
|
|
37
|
+
|
|
38
|
+
### Added — `gsd-t graph-use-gate` (runtime, `[RULE] wired-claim-requires-query-evidence`)
|
|
39
|
+
|
|
40
|
+
`bin/gsd-t-graph-use-gate.cjs` reads the append-only ledger and fails any
|
|
41
|
+
consumer that logged `WIRED` with zero `kind:"query"` events. It detects the
|
|
42
|
+
**absence of use**, not the presence of a fallback — the thing a static lint
|
|
43
|
+
structurally cannot see. `kind:"read"` (the Read-intercept hook) is never
|
|
44
|
+
accepted as evidence, or it would mask the very failure being detected.
|
|
45
|
+
|
|
46
|
+
Run against the two projects that prompted this, it flags both — confirming the
|
|
47
|
+
defect is systemic, not a one-off. Wired into `gsd-t-verify-gate.cjs` as the
|
|
48
|
+
FAIL-CLOSED `graph-use` check, propagated via `PROJECT_BIN_TOOLS` (it reads each
|
|
49
|
+
project's own ledger), and covered by `test/m113-graph-use-gate.test.js` — whose
|
|
50
|
+
leading test is the negative one, because a gate nobody has watched fail is a
|
|
51
|
+
gate nobody knows works. Exit `0` clean · `4` violations · `64` bad input; a
|
|
52
|
+
missing ledger is a *documented* no-op PASS under `--verify-mode`, tagged
|
|
53
|
+
`noOpPass:true` so it stays distinguishable from wired-and-clean.
|
|
54
|
+
|
|
55
|
+
**Still open:** none of this covers ordinary conversational code-reading, which
|
|
56
|
+
is where the original complaint arose — an agent asked "what calls this?" in a
|
|
57
|
+
worktree with no graph and grepped, with nothing objecting. That needs a
|
|
58
|
+
PreToolUse hook on structural Grep/Read, and is deliberately left for its own
|
|
59
|
+
milestone rather than bolted on here.
|
|
60
|
+
|
|
61
|
+
## [5.11.31] - 2026-08-11
|
|
62
|
+
|
|
63
|
+
### Added — the scanner now ranks its own findings before it numbers them
|
|
64
|
+
|
|
65
|
+
A 492-finding scan of hilo-figma-atos was grouped by severity and ordered by
|
|
66
|
+
discovery inside each group, so "fix the criticals in order" gave you a dead
|
|
67
|
+
marketing page before the missing database access rules on 193 tenant tables.
|
|
68
|
+
Run by hand over that register, an architect pass changed the answer enough to
|
|
69
|
+
become a permanent stage:
|
|
70
|
+
|
|
71
|
+
- The worst defect in the codebase was filed HIGH. Account credits that cover a
|
|
72
|
+
whole invoice are never marked used, so the same credit is given away again
|
|
73
|
+
every month, forever. It sat at position 127.
|
|
74
|
+
- A typo in one text box silently routes every real card payment to the practice
|
|
75
|
+
gateway, where charges report success and no money moves. Filed MEDIUM.
|
|
76
|
+
- Every school's signed legal agreements are downloadable by any other school.
|
|
77
|
+
Filed LOW.
|
|
78
|
+
- A fabricated thunderstorm advisory for a named airport is shown to pilots on
|
|
79
|
+
every page. Filed MEDIUM.
|
|
80
|
+
- 22 findings were confirmed unreachable code, carried as risk.
|
|
81
|
+
|
|
82
|
+
And the work changed shape: 492 findings collapsed into 28 root causes, with 144
|
|
83
|
+
of the 328 medium/low findings attaching to a root that already existed. The
|
|
84
|
+
codebase does not have 492 problems; it has about 28, most repeated dozens of
|
|
85
|
+
times. Scheduled individually, that produces dozens of half-fixes of one defect.
|
|
86
|
+
|
|
87
|
+
**Four changes, all measured on that run:**
|
|
88
|
+
|
|
89
|
+
**Architect stage** (`phase("Architect")`, after Synthesis, before ordering) —
|
|
90
|
+
re-tiers by consequence, groups by root cause, ranks roots within tier and
|
|
91
|
+
members within root, and marks confirmed dead code so it sinks below live
|
|
92
|
+
findings. TD numbers are assigned AFTER it, so TD-1 is the most urgent thing in
|
|
93
|
+
the codebase. Adds an EXTREME tier: leaves wrong data behind, crosses a tenant
|
|
94
|
+
boundary, moves money wrongly, or touches safety — as against CRITICAL, where
|
|
95
|
+
the feature merely does not work. Carries the dynamic-import warning that nearly
|
|
96
|
+
cost a live credit-card form its rating, and halts if the ordering loses a
|
|
97
|
+
finding.
|
|
98
|
+
|
|
99
|
+
**Verification is batched, 10 findings per agent.** Measured head to head on the
|
|
100
|
+
same 20 findings spanning all four severities: batched caught 1 false positive
|
|
101
|
+
and 2 severity corrections against the individual arm's 0 and 1, for 71 fewer
|
|
102
|
+
characters of evidence and no changed verdict. Severity is comparative — an agent
|
|
103
|
+
seeing findings together can rank them, one seeing a single finding confirms
|
|
104
|
+
whatever it was handed. 492 findings went from ~570 verifier agents to ~50.
|
|
105
|
+
|
|
106
|
+
**Design-export snapshots are left out of slicing**, and named in the plan.
|
|
107
|
+
`.figma-make-exports/` held 1,532 source files and 968,597 lines on that project
|
|
108
|
+
— six near-identical copies of a design prototype, read by finders as if they
|
|
109
|
+
were the product, for two findings that were both about the folder itself. That
|
|
110
|
+
is roughly 19 slices of finder-and-verifier work.
|
|
111
|
+
|
|
112
|
+
**One severity phrase per tier.** The plain-English companion labelled all 61
|
|
113
|
+
criticals "fix before launch" for a system already serving customers, and had
|
|
114
|
+
drifted to twelve phrasings for four tiers. An unmapped tier is now announced
|
|
115
|
+
rather than silently labelled "review".
|
|
116
|
+
|
|
117
|
+
- `templates/workflows/gsd-t-scan.workflow.js`: architect stage + schema, batched
|
|
118
|
+
verify, tier-derived labels, EXTREME throughout.
|
|
119
|
+
- `bin/gsd-t-slice-budget.cjs`: snapshot-directory exclusion, reported by name.
|
|
120
|
+
- `test/m112-architect-stage.test.js`, `test/m112-severity-labels.test.js`: 15 new
|
|
121
|
+
regressions; slice-budget and schema-tolerance suites extended.
|
|
122
|
+
|
|
123
|
+
Two existing tests were pinned to variable names rather than behaviour and failed
|
|
124
|
+
on a change that preserved what they protect; both now assert the property.
|
|
125
|
+
|
|
5
126
|
## [5.11.30] - 2026-08-11
|
|
6
127
|
|
|
7
128
|
### Fixed — the scan crashed before any finder ran: `slices is not defined`
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v5.11.
|
|
3
|
+
**v5.11.32** - A methodology for reliable, parallelizable development using Claude Code with optional Agent Teams support.
|
|
4
4
|
|
|
5
5
|
**Eliminates context rot** — task-level fresh dispatch (one subagent per task, ~10-20% context each) means compaction never triggers.
|
|
6
6
|
**Compaction-proof debug loops** — `gsd-t headless --debug-loop` runs test-fix-retest cycles as separate `claude -p` sessions. A JSONL debug ledger persists all hypothesis/fix/learning history across fresh sessions. Anti-repetition preamble injection prevents retrying failed hypotheses. Escalation tiers (sonnet → opus → human) and a hard iteration ceiling enforced externally.
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* gsd-t-graph-use-gate.cjs
|
|
4
|
+
*
|
|
5
|
+
* Graph USE gate — proves a consumer that declared itself WIRED actually
|
|
6
|
+
* QUERIED the graph.
|
|
7
|
+
*
|
|
8
|
+
* [RULE] wired-claim-requires-query-evidence
|
|
9
|
+
* [RULE] use-gate-detects-absence-of-use-not-presence-of-fallback
|
|
10
|
+
*
|
|
11
|
+
* WHY THIS EXISTS (the gap it closes):
|
|
12
|
+
* `gsd-t-graph-anti-grep-lint.cjs` is STATIC. It reads source files and looks
|
|
13
|
+
* for a `try graph-query -> catch -> structural grep` fallback. That shape is
|
|
14
|
+
* the only way it can fail.
|
|
15
|
+
*
|
|
16
|
+
* A real scan failed a different way. Every analyst agent simply NEVER CALLED
|
|
17
|
+
* the graph — so there was no catch block, no fallback branch, nothing for a
|
|
18
|
+
* static scan to find. The lint passed, the run logged
|
|
19
|
+
* `{"kind":"wiring","graphWiringMode":"WIRED"}`, and the whole scan ran on
|
|
20
|
+
* text search anyway. A check whose only failure mode is a code shape cannot
|
|
21
|
+
* see a consumer that never tried.
|
|
22
|
+
*
|
|
23
|
+
* This gate reads the RUNTIME ledger instead. A consumer that stamped WIRED
|
|
24
|
+
* and issued zero graph queries in that run is a violation, because "wired"
|
|
25
|
+
* is a claim about behaviour and the ledger is the only record of behaviour.
|
|
26
|
+
*
|
|
27
|
+
* INPUT : .gsd-t/graphDB/logs/graph-events-*.jsonl (append-only, JSONL)
|
|
28
|
+
* OUTPUT : {ok, violations:[{consumer, wiringMode, queryCount, firstWiringTs}], ...}
|
|
29
|
+
* EXIT : 0 clean | 4 violations | 64 bad input (no ledger / unreadable)
|
|
30
|
+
*
|
|
31
|
+
* Unreadable input THROWS (LedgerUnavailable) rather than returning a value —
|
|
32
|
+
* a gate that hands back a result when it could not read its own evidence is
|
|
33
|
+
* the exact failure mode this file exists to remove.
|
|
34
|
+
*
|
|
35
|
+
* Node built-ins only (zero-dep invariant).
|
|
36
|
+
*/
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const fs = require('fs');
|
|
41
|
+
const path = require('path');
|
|
42
|
+
|
|
43
|
+
// A `kind:"read"` event is the Read-intercept hook, NOT a structural query — it
|
|
44
|
+
// fires on ordinary file reads and would mask the exact failure this gate exists
|
|
45
|
+
// to catch. Only `kind:"query"` counts as evidence that the graph was consulted.
|
|
46
|
+
const EVIDENCE_KIND = 'query';
|
|
47
|
+
const WIRING_KIND = 'wiring';
|
|
48
|
+
|
|
49
|
+
// `cli` is the operator at a terminal, not a workflow consumer; it has no WIRED
|
|
50
|
+
// claim to honour. Consumers are exempt ONLY by explicit name here.
|
|
51
|
+
const NON_CONSUMER_IDS = new Set(['cli']);
|
|
52
|
+
|
|
53
|
+
/** Thrown when the ledger cannot be located or read. Never swallowed internally. */
|
|
54
|
+
class LedgerUnavailable extends Error {
|
|
55
|
+
constructor(message) {
|
|
56
|
+
super(message);
|
|
57
|
+
this.name = 'LedgerUnavailable';
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function ledgerDir(projectDir) {
|
|
62
|
+
return path.join(projectDir, '.gsd-t', 'graphDB', 'logs');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/** Resolve every graph-events-*.jsonl file, oldest first. Throws if none. */
|
|
66
|
+
function ledgerFiles(projectDir) {
|
|
67
|
+
const dir = ledgerDir(projectDir);
|
|
68
|
+
let names;
|
|
69
|
+
try {
|
|
70
|
+
names = fs.readdirSync(dir);
|
|
71
|
+
} catch (err) {
|
|
72
|
+
throw new LedgerUnavailable(`graph event ledger not readable: ${dir} (${err.code || err.message})`);
|
|
73
|
+
}
|
|
74
|
+
const files = names
|
|
75
|
+
.filter((n) => /^graph-events-\d+\.jsonl$/.test(n))
|
|
76
|
+
.sort()
|
|
77
|
+
.map((n) => path.join(dir, n));
|
|
78
|
+
if (files.length === 0) {
|
|
79
|
+
throw new LedgerUnavailable(`no graph-events-*.jsonl in ${dir}`);
|
|
80
|
+
}
|
|
81
|
+
return files;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Parse the ledger into per-consumer counters.
|
|
86
|
+
* A malformed line is SKIPPED and COUNTED — it is never treated as a query,
|
|
87
|
+
* because counting an unparseable line as evidence would let a corrupt ledger
|
|
88
|
+
* satisfy the gate.
|
|
89
|
+
*/
|
|
90
|
+
function readLedger(files, sinceTs) {
|
|
91
|
+
const consumers = Object.create(null); // prototype-less: consumer ids come from a file
|
|
92
|
+
let malformed = 0;
|
|
93
|
+
let total = 0;
|
|
94
|
+
|
|
95
|
+
const ensure = (id) => {
|
|
96
|
+
if (!consumers[id]) {
|
|
97
|
+
consumers[id] = { consumer: id, queryCount: 0, wiringModes: [], firstWiringTs: null };
|
|
98
|
+
}
|
|
99
|
+
return consumers[id];
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
for (const file of files) {
|
|
103
|
+
let text;
|
|
104
|
+
try {
|
|
105
|
+
text = fs.readFileSync(file, 'utf8');
|
|
106
|
+
} catch (err) {
|
|
107
|
+
throw new LedgerUnavailable(`cannot read ${file}: ${err.code || err.message}`);
|
|
108
|
+
}
|
|
109
|
+
for (const line of text.split('\n')) {
|
|
110
|
+
if (!line.trim()) continue;
|
|
111
|
+
total++;
|
|
112
|
+
let ev;
|
|
113
|
+
try {
|
|
114
|
+
ev = JSON.parse(line);
|
|
115
|
+
} catch (err) {
|
|
116
|
+
malformed++;
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
if (sinceTs && typeof ev.ts === 'string' && ev.ts < sinceTs) continue;
|
|
120
|
+
|
|
121
|
+
const id = typeof ev.consumer === 'string' && ev.consumer ? ev.consumer : 'unknown';
|
|
122
|
+
const kind = String(ev.kind || '');
|
|
123
|
+
|
|
124
|
+
if (kind === EVIDENCE_KIND) {
|
|
125
|
+
ensure(id).queryCount++;
|
|
126
|
+
} else if (kind === WIRING_KIND) {
|
|
127
|
+
const c = ensure(id);
|
|
128
|
+
c.wiringModes.push(String(ev.graphWiringMode || ''));
|
|
129
|
+
if (!c.firstWiringTs && typeof ev.ts === 'string') c.firstWiringTs = ev.ts;
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return { consumers, malformed, total };
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* A consumer violates the gate when it CLAIMED wired and issued ZERO queries.
|
|
138
|
+
* Claiming `fallback-announced` or `disabled` is not a violation here — those
|
|
139
|
+
* are honest declarations, and the anti-grep lint governs whether the fallback
|
|
140
|
+
* itself is permitted.
|
|
141
|
+
*/
|
|
142
|
+
function evaluate(consumers) {
|
|
143
|
+
const violations = [];
|
|
144
|
+
const checked = [];
|
|
145
|
+
|
|
146
|
+
for (const id of Object.keys(consumers)) {
|
|
147
|
+
if (NON_CONSUMER_IDS.has(id)) continue;
|
|
148
|
+
const c = consumers[id];
|
|
149
|
+
if (c.wiringModes.length === 0) continue; // never declared; nothing claimed, nothing to prove
|
|
150
|
+
|
|
151
|
+
const claimedWired = c.wiringModes.some((m) => m.toLowerCase() === 'wired');
|
|
152
|
+
checked.push({ consumer: id, wiringModes: c.wiringModes, queryCount: c.queryCount });
|
|
153
|
+
|
|
154
|
+
if (claimedWired && c.queryCount === 0) {
|
|
155
|
+
violations.push({
|
|
156
|
+
consumer: id,
|
|
157
|
+
wiringMode: 'WIRED',
|
|
158
|
+
queryCount: 0,
|
|
159
|
+
firstWiringTs: c.firstWiringTs,
|
|
160
|
+
evidence:
|
|
161
|
+
`consumer "${id}" logged graphWiringMode=WIRED but issued 0 graph queries ` +
|
|
162
|
+
`(kind:"query") in this window — a WIRED claim with no query evidence means ` +
|
|
163
|
+
`the structural question was answered some other way.`,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return { violations, checked };
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** Throws LedgerUnavailable when the evidence cannot be read. */
|
|
171
|
+
function runGate(opts = {}) {
|
|
172
|
+
const projectDir = opts.projectDir || process.cwd();
|
|
173
|
+
|
|
174
|
+
// Test override: callers may inject parsed consumers directly.
|
|
175
|
+
if (opts.consumers) {
|
|
176
|
+
const { violations, checked } = evaluate(opts.consumers);
|
|
177
|
+
return { ok: violations.length === 0, violations, checked, malformed: 0, ledgerFiles: [] };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const files = ledgerFiles(projectDir);
|
|
181
|
+
const { consumers, malformed } = readLedger(files, opts.since || null);
|
|
182
|
+
const { violations, checked } = evaluate(consumers);
|
|
183
|
+
return { ok: violations.length === 0, violations, checked, malformed, ledgerFiles: files };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// ─── CLI entry point ─────────────────────────────────────────────────────────
|
|
187
|
+
|
|
188
|
+
if (require.main === module) {
|
|
189
|
+
const argv = process.argv.slice(2);
|
|
190
|
+
const opts = { projectDir: process.cwd() };
|
|
191
|
+
for (let i = 0; i < argv.length; i++) {
|
|
192
|
+
if (argv[i] === '--project' || argv[i] === '--repo') opts.projectDir = argv[++i];
|
|
193
|
+
else if (argv[i] === '--since') opts.since = argv[++i];
|
|
194
|
+
else if (argv[i] === '--verify-mode') opts.verifyMode = true;
|
|
195
|
+
else if (!argv[i].startsWith('-')) opts.projectDir = argv[i];
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
let result;
|
|
199
|
+
try {
|
|
200
|
+
result = runGate(opts);
|
|
201
|
+
} catch (err) {
|
|
202
|
+
if (err instanceof LedgerUnavailable) {
|
|
203
|
+
// No ledger means NO CONSUMER HAS RUN YET — nothing has been claimed, so
|
|
204
|
+
// there is nothing to disprove. That is a documented no-op PASS in
|
|
205
|
+
// --verify-mode, distinguishable in the JSON (`noOpPass:true`) from a
|
|
206
|
+
// wired-but-broken run. Invoked directly it is BAD INPUT (exit 64): an
|
|
207
|
+
// operator asking about evidence that does not exist deserves to be told,
|
|
208
|
+
// not handed a clean bill.
|
|
209
|
+
if (opts.verifyMode) {
|
|
210
|
+
process.stdout.write(JSON.stringify(
|
|
211
|
+
{ ok: true, noOpPass: true, reason: 'no-graph-event-ledger', violations: [], checked: [] }, null, 2) + '\n');
|
|
212
|
+
process.stderr.write('[graph-use-gate] PASS (no-op): no graph event ledger — no consumer has claimed WIRED yet.\n');
|
|
213
|
+
process.exit(0);
|
|
214
|
+
}
|
|
215
|
+
process.stderr.write(`[graph-use-gate] ERROR: ${err.message}\n`);
|
|
216
|
+
process.stdout.write(JSON.stringify({ ok: false, violations: [], error: err.message }, null, 2) + '\n');
|
|
217
|
+
process.exit(64);
|
|
218
|
+
}
|
|
219
|
+
throw err;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
process.stdout.write(JSON.stringify(result, null, 2) + '\n');
|
|
223
|
+
|
|
224
|
+
if (!result.ok) {
|
|
225
|
+
process.stderr.write(
|
|
226
|
+
`[graph-use-gate] FAIL: ${result.violations.length} consumer(s) claimed WIRED with zero graph queries.\n`
|
|
227
|
+
);
|
|
228
|
+
for (const v of result.violations) {
|
|
229
|
+
process.stderr.write(` ${v.consumer}: ${v.evidence}\n`);
|
|
230
|
+
}
|
|
231
|
+
process.exit(4);
|
|
232
|
+
}
|
|
233
|
+
process.stderr.write(
|
|
234
|
+
`[graph-use-gate] PASS: ${result.checked.length} declaring consumer(s), all WIRED claims backed by queries.\n`
|
|
235
|
+
);
|
|
236
|
+
process.exit(0);
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
module.exports = { runGate, readLedger, evaluate, ledgerFiles, LedgerUnavailable, EVIDENCE_KIND, NON_CONSUMER_IDS };
|
|
@@ -59,6 +59,32 @@ const SKIP_DIRS = new Set([
|
|
|
59
59
|
'coverage', 'out', '.turbo', '.venv', 'venv', 'Pods', 'vendor', '.gradle',
|
|
60
60
|
]);
|
|
61
61
|
|
|
62
|
+
// [RULE] slice-budget-skips-design-export-snapshots
|
|
63
|
+
//
|
|
64
|
+
// hilo-figma-atos, 2026-08-11: `.figma-make-exports/` held 1,879 tracked files
|
|
65
|
+
// and 57 MB of design-tool exports — six dated snapshots of a prototype, each a
|
|
66
|
+
// near-copy of the last. They were measured, sliced, and read by finders as if
|
|
67
|
+
// they were the application. Out of the whole scan, exactly TWO findings came
|
|
68
|
+
// from them, and both were about the directory itself (it has no type-check
|
|
69
|
+
// exclusion; the same key is committed six times) rather than about defects in
|
|
70
|
+
// the product.
|
|
71
|
+
//
|
|
72
|
+
// These are recognised by NAME rather than by a project's own ignore rules,
|
|
73
|
+
// because the project had no such rule — that absence was itself one of the two
|
|
74
|
+
// findings. Matched as a path SEGMENT so a real source folder that merely
|
|
75
|
+
// contains the word (say `src/exports/`) is untouched.
|
|
76
|
+
const SNAPSHOT_DIR_PATTERNS = [
|
|
77
|
+
/^\.?figma-make-exports$/i,
|
|
78
|
+
/^\.?figma-exports$/i,
|
|
79
|
+
/^design-exports?$/i,
|
|
80
|
+
/^ui-snapshots?$/i,
|
|
81
|
+
/^__snapshots-export__$/i,
|
|
82
|
+
];
|
|
83
|
+
|
|
84
|
+
function isSnapshotExportDir(name) {
|
|
85
|
+
return SNAPSHOT_DIR_PATTERNS.some((re) => re.test(name));
|
|
86
|
+
}
|
|
87
|
+
|
|
62
88
|
/** Lines in one file. A file that cannot be read is reported, never counted as 0. */
|
|
63
89
|
function countLines(file) {
|
|
64
90
|
const text = fs.readFileSync(file, 'utf8');
|
|
@@ -72,7 +98,7 @@ function countLines(file) {
|
|
|
72
98
|
}
|
|
73
99
|
|
|
74
100
|
/** Every source file under a path, with its line count. */
|
|
75
|
-
function measurePath(projectDir, rel, problems) {
|
|
101
|
+
function measurePath(projectDir, rel, problems, skippedSnapshots) {
|
|
76
102
|
const abs = path.resolve(projectDir, rel);
|
|
77
103
|
const out = [];
|
|
78
104
|
|
|
@@ -107,6 +133,12 @@ function measurePath(projectDir, rel, problems) {
|
|
|
107
133
|
for (const ent of entries) {
|
|
108
134
|
if (ent.isDirectory()) {
|
|
109
135
|
if (SKIP_DIRS.has(ent.name)) continue;
|
|
136
|
+
if (isSnapshotExportDir(ent.name)) {
|
|
137
|
+
// Reported, never silent: a directory this large vanishing from the
|
|
138
|
+
// measurement without a word is how a coverage hole hides.
|
|
139
|
+
skippedSnapshots.push(path.relative(projectDir, path.join(dir, ent.name)));
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
110
142
|
walk(path.join(dir, ent.name));
|
|
111
143
|
continue;
|
|
112
144
|
}
|
|
@@ -178,6 +210,7 @@ function splitSlice(slice, files, min, max, oversizedFiles) {
|
|
|
178
210
|
|
|
179
211
|
function plan(projectDir, slices, min, max) {
|
|
180
212
|
const problems = [];
|
|
213
|
+
const skippedSnapshots = [];
|
|
181
214
|
const oversizedFiles = [];
|
|
182
215
|
const out = [];
|
|
183
216
|
let measuredLines = 0;
|
|
@@ -188,7 +221,7 @@ function plan(projectDir, slices, min, max) {
|
|
|
188
221
|
const files = [];
|
|
189
222
|
const seen = new Set();
|
|
190
223
|
for (const p of paths) {
|
|
191
|
-
for (const f of measurePath(projectDir, p, problems)) {
|
|
224
|
+
for (const f of measurePath(projectDir, p, problems, skippedSnapshots)) {
|
|
192
225
|
// A file listed under two paths of one slice is one file, counted once.
|
|
193
226
|
if (seen.has(f.file)) continue;
|
|
194
227
|
seen.add(f.file);
|
|
@@ -232,6 +265,9 @@ function plan(projectDir, slices, min, max) {
|
|
|
232
265
|
.sort((a, b) => b.lines - a.lines)
|
|
233
266
|
.map((f) => ({ file: f.file, lines: f.lines })),
|
|
234
267
|
problems,
|
|
268
|
+
// Design-tool export snapshots left out of the measurement, named so the
|
|
269
|
+
// omission is visible rather than inferred from a smaller total.
|
|
270
|
+
skippedSnapshots: Array.from(new Set(skippedSnapshots)).sort(),
|
|
235
271
|
slices: out,
|
|
236
272
|
};
|
|
237
273
|
}
|
|
@@ -313,6 +313,8 @@ function _detectDefaultTrack2(projectDir, notes) {
|
|
|
313
313
|
|
|
314
314
|
plan.push({ id: 'fallbacks', cmd: 'node', args: [path.join(__dirname, 'gsd-t-fallback-detect.cjs'), '--scan', '--project', projectDir, '--json'], timeoutMs: 120000 }); // M106: no unapproved continue-after-failure branch, FAIL-CLOSED (pre-existing ones excluded via .gsd-t/fallbacks-baseline.json)
|
|
315
315
|
|
|
316
|
+
plan.push({ id: 'graph-use', cmd: 'node', args: [path.join(__dirname, 'gsd-t-graph-use-gate.cjs'), '--project', projectDir, '--verify-mode'], timeoutMs: 30000 }); // M113: a consumer that logged graphWiringMode=WIRED must have issued >=1 graph query, FAIL-CLOSED (documented no-op PASS when no ledger exists yet). Catches what the STATIC anti-grep lint structurally cannot: a consumer that never queried at all.
|
|
317
|
+
|
|
316
318
|
// secrets — gitleaks (PATH detection deferred to runtime)
|
|
317
319
|
if (_hasOnPath('gitleaks')) {
|
|
318
320
|
plan.push({
|
package/bin/gsd-t.js
CHANGED
|
@@ -3487,6 +3487,10 @@ const PROJECT_BIN_TOOLS = [
|
|
|
3487
3487
|
// every Write/Edit is DENIED (the guard halts rather than allowing unchecked),
|
|
3488
3488
|
// so this entry is load-bearing — [[project_global_bin_propagation_gap]].
|
|
3489
3489
|
"gsd-t-fallback-detect.cjs",
|
|
3490
|
+
// M113 — Graph USE gate. The verify gate dispatches to it by absolute path,
|
|
3491
|
+
// and it reads the project's OWN .gsd-t/graphDB/logs ledger, so it must live
|
|
3492
|
+
// in the project — [[project_global_bin_propagation_gap]].
|
|
3493
|
+
"gsd-t-graph-use-gate.cjs",
|
|
3490
3494
|
// M107 — Concise rewriter, invoked by the Stop hook.
|
|
3491
3495
|
// M108 — Install self-check. Every project carries its own copy so it can
|
|
3492
3496
|
// verify and repair itself even when the global install is what broke.
|
package/commands/gsd-t-debug.md
CHANGED
|
@@ -98,7 +98,7 @@ Debug applies the **WRITER pattern** from `graph-consumer-wiring-contract.md`:
|
|
|
98
98
|
|
|
99
99
|
**WRITER half (re-index after fix):** After the fix lands, the workflow triggers a re-index of the edited files so downstream graph queries see fresh edges (`graph-freshness-contract.md` D4 surface — `freshness_check_on_query` over the touched set). `[RULE] debug-reader-and-writer-both`.
|
|
100
100
|
|
|
101
|
-
**
|
|
101
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the debug workflow **builds the index right then** (`gsd-t graph index`) and re-runs the localization query. It does NOT fall back to grep for the structural question, and it does NOT skip the query. Only a **failed build** halts the graph-query step, with the build's own stderr surfaced. The existing debug-loop logic (2-cycle cap, loop-ledger halt) is NOT disrupted — the graph query is additive, injected before the fix agent receives context. `[RULE] graph-absent-builds-not-degrades`.
|
|
102
102
|
|
|
103
103
|
## Contract-Boundary Debugging
|
|
104
104
|
|
|
@@ -10,7 +10,7 @@ Design-build applies the **WRITER pattern** from `graph-consumer-wiring-contract
|
|
|
10
10
|
|
|
11
11
|
**WRITER half:** After each tier (elements → widgets → pages) generates files, the pipeline triggers a re-index of the generated files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so the next tier's `who-imports` / `cluster` query sees the new edges from the generated components. `[RULE] design-build-writer-pattern`.
|
|
12
12
|
|
|
13
|
-
**
|
|
13
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the pipeline **builds the index right then** (`gsd-t graph index`) and re-runs the structural query. It does NOT fall back to grep, and it does NOT proceed without the structural answer. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
|
|
14
14
|
|
|
15
15
|
## Step 1: Launch the Orchestrator
|
|
16
16
|
|
package/commands/gsd-t-quick.md
CHANGED
|
@@ -182,7 +182,7 @@ Quick applies the **WRITER pattern** from `graph-consumer-wiring-contract.md`:
|
|
|
182
182
|
|
|
183
183
|
**WRITER half:** After edits land, the workflow triggers a re-index of the touched files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so downstream graph queries see fresh edges. `[RULE] quick-writer-pattern`.
|
|
184
184
|
|
|
185
|
-
**
|
|
185
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the workflow **builds the index right then** (`gsd-t graph index`) and re-runs the query. It does NOT fall back to grep, and it does NOT proceed without the structural slice — proceeding blind is a third failure mode, not a safe default. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
|
|
186
186
|
|
|
187
187
|
## Step 1.5: Graph-Enhanced Scope Check
|
|
188
188
|
|
|
@@ -40,7 +40,7 @@ Test-sync applies the **WRITER pattern** from `graph-consumer-wiring-contract.md
|
|
|
40
40
|
|
|
41
41
|
**WRITER half:** After writing or updating tests, trigger a re-index of the edited test files (`freshness_check_on_query` from `graph-freshness-contract.md` D4 surface) so the next `test-impl` query sees the updated call-site edges from the new test code. `[RULE] test-sync-uses-test-impl-verb`.
|
|
42
42
|
|
|
43
|
-
**
|
|
43
|
+
**BUILD-THEN-QUERY on graph-absent:** On `{ok:false, reason:"graph-unavailable"}` the workflow **builds the index right then** (`gsd-t graph index`) and re-runs the `test-impl` / `untested-impl` queries. Filesystem discovery is NOT an acceptable substitute — announcing a fallback does not stop it being one, and "which test exercises which impl" answered by filename convention is exactly the wrong-answer class the graph exists to remove. Only a **failed build** halts, with the build's own stderr surfaced. `[RULE] consumer-structural-grep-removed`, `[RULE] graph-absent-builds-not-degrades`.
|
|
44
44
|
|
|
45
45
|
## Step 1.5: Graph-Enhanced Test Discovery
|
|
46
46
|
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@tekyzinc/gsd-t",
|
|
3
|
-
"version": "5.11.
|
|
4
|
-
"description": "GSD-T: Contract-Driven Development for Claude Code
|
|
3
|
+
"version": "5.11.32",
|
|
4
|
+
"description": "GSD-T: Contract-Driven Development for Claude Code — 54 slash commands with headless-by-default workflow spawning, unattended supervisor relay with event stream, graph-powered code analysis, real-time agent dashboard, task telemetry, doc-ripple enforcement, backlog management, impact analysis, test sync, milestone archival, and PRD generation",
|
|
5
5
|
"author": "Tekyz, Inc.",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -79,6 +79,15 @@ async function classifyGraphFailure(projectDir, reason, detail, phaseName) {
|
|
|
79
79
|
if (env && (env.state === "ABSENT" || env.state === "BROKEN")) return env.state;
|
|
80
80
|
return "BROKEN";
|
|
81
81
|
}
|
|
82
|
+
// [RULE] absent-graph-auto-builds-once — build the index once via the existing
|
|
83
|
+
// `gsd-t graph index` path (local bin: gsd-t-graph-index.cjs build --repo <dir>).
|
|
84
|
+
async function buildGraphIndex(projectDir, phaseName) {
|
|
85
|
+
const r = await runCli(
|
|
86
|
+
projectDir, "graph index", ["build", "--repo", projectDir],
|
|
87
|
+
"gsd-t-graph-index.cjs", "graph-index", false, phaseName
|
|
88
|
+
).catch(() => null);
|
|
89
|
+
return !!(r && r.ok);
|
|
90
|
+
}
|
|
82
91
|
async function runVerifyGate(projectDir, label = "verify-gate", phaseName) { return runCli(projectDir, "verify-gate", ["--json"], "gsd-t-verify-gate.cjs", label, true, phaseName); }
|
|
83
92
|
async function generateBrief(projectDir, { kind = "execute", milestone, domain, id, label = "brief", phaseName } = {}) {
|
|
84
93
|
const argv = ["--kind", kind, "--spawn-id", id, "--out", `${projectDir}/.gsd-t/briefs/${id}.json`];
|
|
@@ -133,32 +142,54 @@ await persistWiringMode("Preflight");
|
|
|
133
142
|
|
|
134
143
|
// M94-D10-T6: Graph Structural Slice — who-imports + blast-radius (ADDITIVE, announced-degradation)
|
|
135
144
|
// [RULE] integrate-uses-graph-for-wiring-verification
|
|
136
|
-
// [RULE] verify-integrate-graph-additive-announced-not-hard-fail —
|
|
137
|
-
//
|
|
145
|
+
// [RULE] verify-integrate-graph-additive-announced-not-hard-fail — integrate does NOT
|
|
146
|
+
// hard-fail on a graph it cannot use. It DOES build an ABSENT index first
|
|
147
|
+
// ([RULE] graph-absent-builds-not-degrades); only a BROKEN graph, or a build that
|
|
148
|
+
// itself fails, degrades announced.
|
|
138
149
|
let _graphWhoImportsSlice = null;
|
|
139
150
|
let _graphBlastRadiusSlice = null;
|
|
140
151
|
let _graphIntegrateWarning = null;
|
|
141
152
|
|
|
142
153
|
{
|
|
143
|
-
|
|
154
|
+
// [RULE] graph-absent-builds-not-degrades — an ABSENT graph is BUILT here, not
|
|
155
|
+
// skipped. The former "announced carve-out" skipped the structural wiring-check
|
|
156
|
+
// whenever the index had never been built — which is exactly when integrate most
|
|
157
|
+
// needs it, because a fresh worktree carries no graph (it is gitignored). The
|
|
158
|
+
// check therefore did nothing on the very runs it was written for. Announcing a
|
|
159
|
+
// skip does not stop it being a skip.
|
|
160
|
+
let wiEnv = (await runCli(
|
|
144
161
|
projectDir, "graph who-imports", [], "gsd-t-graph-query-cli.cjs",
|
|
145
162
|
"graph:who-imports", true, "Integrate"
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
if (wiEnv.ok ===
|
|
149
|
-
|
|
150
|
-
log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
|
|
151
|
-
} else if (wiEnv.ok === false) {
|
|
152
|
-
// [RULE] one-availability-classifier — distinguish ABSENT (announced skip) from BROKEN (LOUD).
|
|
163
|
+
)).envelope || {};
|
|
164
|
+
|
|
165
|
+
if (wiEnv.ok === false) {
|
|
166
|
+
// [RULE] one-availability-classifier — ONE classify call, ONE state, no re-derivation.
|
|
153
167
|
const _state = await classifyGraphFailure(projectDir, wiEnv.reason, wiEnv.detail, "Integrate");
|
|
154
|
-
if (_state === "
|
|
155
|
-
|
|
168
|
+
if (_state === "ABSENT") {
|
|
169
|
+
log("M94 graph who-imports: index ABSENT (never indexed) — building it now (gsd-t graph index)...");
|
|
170
|
+
if (await buildGraphIndex(projectDir, "Integrate")) {
|
|
171
|
+
wiEnv = (await runCli(
|
|
172
|
+
projectDir, "graph who-imports", [], "gsd-t-graph-query-cli.cjs",
|
|
173
|
+
"graph:who-imports", true, "Integrate"
|
|
174
|
+
)).envelope || {};
|
|
175
|
+
if (wiEnv.ok !== true) {
|
|
176
|
+
_graphIntegrateWarning = `⚠ graph built but who-imports still failed (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. FIX it (gsd-t graph status).`;
|
|
177
|
+
}
|
|
178
|
+
} else {
|
|
179
|
+
// A failed BUILD is loud, and distinct from a merely-missing index.
|
|
180
|
+
_graphIntegrateWarning = "⚠ graph ABSENT and `gsd-t graph index` FAILED — structural wiring-check cannot run. FIX it (gsd-t graph status).";
|
|
181
|
+
}
|
|
156
182
|
} else {
|
|
157
|
-
_graphIntegrateWarning =
|
|
183
|
+
_graphIntegrateWarning = `⚠ graph BROKEN (reason=${wiEnv.reason || "?"}) — structural wiring-check skipped. This is NOT merely un-indexed; FIX it (gsd-t graph status).`;
|
|
158
184
|
}
|
|
159
|
-
|
|
160
|
-
} else {
|
|
185
|
+
} else if (wiEnv.ok !== true) {
|
|
161
186
|
_graphIntegrateWarning = `⚠ graph who-imports query unexpected envelope (reason: ${wiEnv.reason || "?"}); structural wiring-check skipped`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (wiEnv.ok === true) {
|
|
190
|
+
_graphWhoImportsSlice = wiEnv;
|
|
191
|
+
log(`M94 graph who-imports: ${(wiEnv.results || []).length} result(s) (tier: ${wiEnv.tier || "?"})`);
|
|
192
|
+
} else {
|
|
162
193
|
log(`M94 graph who-imports: ${_graphIntegrateWarning}`);
|
|
163
194
|
}
|
|
164
195
|
|
|
@@ -183,6 +183,55 @@ const VERIFY_SCHEMA = {
|
|
|
183
183
|
},
|
|
184
184
|
};
|
|
185
185
|
|
|
186
|
+
// One verifier reads a BATCH of findings and returns a verdict for each.
|
|
187
|
+
//
|
|
188
|
+
// [RULE] verify-batched-not-one-agent-per-finding
|
|
189
|
+
//
|
|
190
|
+
// Measured on hilo-figma-atos, 2026-08-11 — the same 20 findings, spanning all
|
|
191
|
+
// four severities, verified both ways against real code:
|
|
192
|
+
//
|
|
193
|
+
// one-agent-per-finding batched (20 in one)
|
|
194
|
+
// false positives caught 0 1
|
|
195
|
+
// severity corrections 1 2
|
|
196
|
+
// mean evidence length 426 chars 355 chars
|
|
197
|
+
//
|
|
198
|
+
// Batching did not merely cost less — it judged BETTER, and both disagreements
|
|
199
|
+
// were checked by hand and went the batch's way. An agent holding many findings
|
|
200
|
+
// sees them in relation to each other, and severity is a comparative judgment:
|
|
201
|
+
// "help articles leaking" is HIGH *next to* the cross-tenant student-record
|
|
202
|
+
// leaks beside it. An agent handed one finding alone has no yardstick, so it
|
|
203
|
+
// tends to confirm whatever it was given — which is what 20-of-20 confirmed,
|
|
204
|
+
// zero false positives, looked like on the unbatched side.
|
|
205
|
+
//
|
|
206
|
+
// The cost is ~70 characters less evidence per finding, which changed no verdict.
|
|
207
|
+
// The saving is the run: 492 findings went from ~570 verifier agents to ~50.
|
|
208
|
+
const VERIFY_BATCH_SCHEMA = {
|
|
209
|
+
type: "object",
|
|
210
|
+
required: ["verdicts"],
|
|
211
|
+
additionalProperties: true,
|
|
212
|
+
properties: {
|
|
213
|
+
verdicts: {
|
|
214
|
+
type: "array",
|
|
215
|
+
minItems: 1,
|
|
216
|
+
items: {
|
|
217
|
+
type: "object",
|
|
218
|
+
required: ["index", "verdict"],
|
|
219
|
+
additionalProperties: true,
|
|
220
|
+
properties: {
|
|
221
|
+
// The finding's position in the batch it was given. Position rather
|
|
222
|
+
// than title: a title can be paraphrased back, an index cannot.
|
|
223
|
+
index: { type: "integer" },
|
|
224
|
+
verdict: { type: "string", enum: ["confirmed", "CONFIRMED", "Confirmed", "false-positive", "FALSE-POSITIVE", "False-positive", "needs-detail", "NEEDS-DETAIL", "Needs-detail"] },
|
|
225
|
+
confirmed: { type: "boolean" },
|
|
226
|
+
note: { type: "string" },
|
|
227
|
+
evidence: { type: "string" },
|
|
228
|
+
correctedSeverity: { type: "string", enum: ["CRITICAL", "critical", "Critical", "HIGH", "high", "High", "MEDIUM", "medium", "Medium", "LOW", "low", "Low"] },
|
|
229
|
+
},
|
|
230
|
+
},
|
|
231
|
+
},
|
|
232
|
+
},
|
|
233
|
+
};
|
|
234
|
+
|
|
186
235
|
// M75: synthesis no longer writes the register via one agent (the Hilo Scan #14
|
|
187
236
|
// synthesis stalled after 9 of 322 items typing a 466KB file). Instead: a bounded
|
|
188
237
|
// dedup agent (inline DEDUP_SCHEMA, small input) decides merge groups; the
|
|
@@ -988,44 +1037,81 @@ async function scanSlice(slice) {
|
|
|
988
1037
|
if (verifyMode === "none" || finderResult.findings.length === 0) {
|
|
989
1038
|
return { slice: sliceKey, findings: finderResult.findings || [], failed: false };
|
|
990
1039
|
}
|
|
991
|
-
//
|
|
992
|
-
//
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
})
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
1012
|
-
|
|
1013
|
-
|
|
1040
|
+
// Verify in BATCHES — see [RULE] verify-batched-not-one-agent-per-finding at
|
|
1041
|
+
// VERIFY_BATCH_SCHEMA for the measurement that chose this over one agent per
|
|
1042
|
+
// finding. Ten is small enough that every finding still gets its files opened,
|
|
1043
|
+
// and large enough that the verifier can weigh them against each other, which
|
|
1044
|
+
// is what severity judgment actually requires.
|
|
1045
|
+
const VERIFY_BATCH_SIZE = 10;
|
|
1046
|
+
const batches = [];
|
|
1047
|
+
for (let i = 0; i < finderResult.findings.length; i += VERIFY_BATCH_SIZE) {
|
|
1048
|
+
batches.push(finderResult.findings.slice(i, i + VERIFY_BATCH_SIZE));
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
const verifiedBatches = await parallel(
|
|
1052
|
+
batches.map((batch, batchNo) => async () => {
|
|
1053
|
+
const verifyPrompt = [
|
|
1054
|
+
`You are a VERIFIER for ${batch.length} tech-debt finding(s) in \`${projectDir}\`. Confirm EACH against the ACTUAL code (open the referenced files with Read) — do not trust the finder.`,
|
|
1055
|
+
``,
|
|
1056
|
+
`Findings, as a numbered list. Return one verdict per finding, using its \`index\`:`,
|
|
1057
|
+
...batch.map((f, i) => `[${i}] ${JSON.stringify(f)}`),
|
|
1058
|
+
``,
|
|
1059
|
+
`For EACH finding: verdict="confirmed" only if the defect genuinely exists. If the finder misread the code → verdict="false-positive". If real but underspecified → verdict="needs-detail" (kept).`,
|
|
1060
|
+
`Set correctedSeverity when the severity is wrong. You are seeing these findings TOGETHER — use that: severity is comparative, and a finding that looks alarming alone is often plainly lesser beside the others in this batch. Judge each one's consequence relative to the rest.`,
|
|
1061
|
+
`Put the file and line you actually checked in \`evidence\`. A verdict with no evidence from the code is the failure this step exists to prevent.`,
|
|
1062
|
+
`Return ONE object per finding, ${batch.length} in total, each carrying its \`index\`. Missing verdicts are treated as unverified.`,
|
|
1063
|
+
SHAPE_RULE,
|
|
1064
|
+
].join("\n");
|
|
1065
|
+
|
|
1066
|
+
// Attempt 1 — sonnet. Attempt 2 names the likely mistake and escalates to
|
|
1067
|
+
// the model that has never made it. Same shape as the finder escalation.
|
|
1068
|
+
const attempt1 = await gatedAgent(verifyPrompt, {
|
|
1069
|
+
label: `verify:${sliceKey}#${batchNo + 1}`, phase: "Deep Scan",
|
|
1070
|
+
schema: VERIFY_BATCH_SCHEMA, model: "sonnet",
|
|
1071
|
+
});
|
|
1072
|
+
const v = (attempt1 && Array.isArray(attempt1.verdicts)) ? attempt1 : await gatedAgent(
|
|
1073
|
+
verifyPrompt + UNWRAP_HINT,
|
|
1074
|
+
{
|
|
1075
|
+
label: `verify:${sliceKey}#${batchNo + 1} (retry on opus)`, phase: "Deep Scan",
|
|
1076
|
+
schema: VERIFY_BATCH_SCHEMA, model: "opus",
|
|
1014
1077
|
}
|
|
1015
|
-
|
|
1016
|
-
|
|
1078
|
+
);
|
|
1079
|
+
|
|
1080
|
+
const byIndex = new Map();
|
|
1081
|
+
for (const r of ((v && Array.isArray(v.verdicts) && v.verdicts) || [])) {
|
|
1082
|
+
if (Number.isInteger(r.index)) byIndex.set(r.index, r);
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
return batch.map((f, i) => {
|
|
1086
|
+
const r = byIndex.get(i);
|
|
1087
|
+
// No verdict came back for this finding. It is NOT dropped and NOT
|
|
1088
|
+
// silently passed as verified — it is kept and MARKED, so a batch that
|
|
1089
|
+
// answered for eight of ten cannot quietly delete the other two, and
|
|
1090
|
+
// the register can show which findings nobody checked.
|
|
1091
|
+
if (!r) return { ...f, _verify: "unverified" };
|
|
1092
|
+
|
|
1093
|
+
// Compared case-INSENSITIVELY: the schema accepts "false-positive" in
|
|
1094
|
+
// any casing, so an exact match would silently KEEP a finding the
|
|
1017
1095
|
// verifier had rejected.
|
|
1018
|
-
const verdict = String(
|
|
1019
|
-
if (
|
|
1020
|
-
|
|
1096
|
+
const verdict = String(r.verdict || "").toLowerCase();
|
|
1097
|
+
if (verdict === "false-positive" || r.confirmed === false) return null;
|
|
1098
|
+
|
|
1099
|
+
// Severity normalised to the shouted form here, once, so the report
|
|
1021
1100
|
// reads consistently no matter how a finder typed it.
|
|
1022
|
-
const sev = String(
|
|
1023
|
-
|
|
1024
|
-
|
|
1025
|
-
return
|
|
1026
|
-
}
|
|
1101
|
+
const sev = String(r.correctedSeverity || f.severity || "").toUpperCase();
|
|
1102
|
+
const out = { ...f, severity: sev, _verify: verdict || "confirmed" };
|
|
1103
|
+
if (r.evidence) out._evidence = r.evidence;
|
|
1104
|
+
return out;
|
|
1105
|
+
});
|
|
1027
1106
|
})
|
|
1028
1107
|
);
|
|
1108
|
+
|
|
1109
|
+
// A batch whose agent died resolves to null; its findings are kept and marked
|
|
1110
|
+
// rather than lost — losing a real defect is worse than carrying an unchecked
|
|
1111
|
+
// one, and the mark is what stops it reading as verified.
|
|
1112
|
+
const verified = verifiedBatches.flatMap((res, batchNo) =>
|
|
1113
|
+
res === null ? batches[batchNo].map((f) => ({ ...f, _verify: "verify-errored" })) : res
|
|
1114
|
+
);
|
|
1029
1115
|
return { slice: sliceKey, findings: verified.filter(Boolean), failed: false };
|
|
1030
1116
|
}
|
|
1031
1117
|
|
|
@@ -1181,7 +1267,9 @@ if (allFindings.length > 1) {
|
|
|
1181
1267
|
}
|
|
1182
1268
|
|
|
1183
1269
|
// (a)+(c) Deterministically merge dups, sort by severity, assign TD numbers, format.
|
|
1184
|
-
|
|
1270
|
+
// EXTREME is the architect's tier for anything that leaves wrong data behind,
|
|
1271
|
+
// breaches a tenant boundary, moves money wrongly, or touches safety.
|
|
1272
|
+
const SEV_ORDER = { EXTREME: 0, CRITICAL: 1, HIGH: 2, MEDIUM: 3, LOW: 4 };
|
|
1185
1273
|
const dropped = new Set();
|
|
1186
1274
|
const merged = [];
|
|
1187
1275
|
for (const group of mergeGroups) {
|
|
@@ -1208,6 +1296,156 @@ for (const f of finalFindings) {
|
|
|
1208
1296
|
counts.total = finalFindings.length;
|
|
1209
1297
|
|
|
1210
1298
|
|
|
1299
|
+
const ARCHITECT_SCHEMA = {
|
|
1300
|
+
type: "object",
|
|
1301
|
+
required: ["placements"],
|
|
1302
|
+
additionalProperties: true,
|
|
1303
|
+
properties: {
|
|
1304
|
+
roots: {
|
|
1305
|
+
type: "array",
|
|
1306
|
+
items: {
|
|
1307
|
+
type: "object",
|
|
1308
|
+
required: ["key", "name"],
|
|
1309
|
+
additionalProperties: true,
|
|
1310
|
+
properties: {
|
|
1311
|
+
key: { type: "string", description: "short id, e.g. R1" },
|
|
1312
|
+
// Short by contract, not by request. The first architect run produced
|
|
1313
|
+
// headings like "Doors that check who you are but never which flight
|
|
1314
|
+
// school you belong to" over 250-word explanations; a reader scanning
|
|
1315
|
+
// 28 roots for the one to fix next cannot skim that.
|
|
1316
|
+
name: { type: "string", maxLength: 40, description: "2-5 word noun phrase, e.g. 'Missing tenant scoping'. NOT a sentence." },
|
|
1317
|
+
why: { type: "string", maxLength: 320, description: "ONE sentence naming the shared mistake, plus the file:line citations that prove it. Keep every citation; compress the prose around them." },
|
|
1318
|
+
fix: { type: "string", maxLength: 200, description: "ONE sentence: the single change that closes them all." },
|
|
1319
|
+
tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
|
|
1320
|
+
rank: { type: "integer", description: "rank among roots of the same tier, 1 = worst" },
|
|
1321
|
+
rankReason:{ type: "string", maxLength: 220, description: "ONE sentence: why this root outranks the ones below it in its tier." },
|
|
1322
|
+
},
|
|
1323
|
+
},
|
|
1324
|
+
},
|
|
1325
|
+
placements: {
|
|
1326
|
+
type: "array",
|
|
1327
|
+
minItems: 1,
|
|
1328
|
+
items: {
|
|
1329
|
+
type: "object",
|
|
1330
|
+
required: ["index", "tier"],
|
|
1331
|
+
additionalProperties: true,
|
|
1332
|
+
properties: {
|
|
1333
|
+
index: { type: "integer", description: "the finding's index in the list given" },
|
|
1334
|
+
tier: { type: "string", enum: ["EXTREME", "CRITICAL", "HIGH", "MEDIUM", "LOW", "extreme", "critical", "high", "medium", "low"] },
|
|
1335
|
+
rootKey: { type: "string", description: "the root this belongs to, or omitted if standalone" },
|
|
1336
|
+
rankInRoot: { type: "integer", description: "rank among that root's members, 1 = worst" },
|
|
1337
|
+
alsoRoots: { type: "array", items: { type: "string" }, description: "other roots that also cause this" },
|
|
1338
|
+
deadCode: { type: "boolean", description: "true ONLY when confirmed unreachable — say which checks in `note`" },
|
|
1339
|
+
notADefect: { type: "boolean", description: "true when this is not a real defect" },
|
|
1340
|
+
reason: { type: "string", description: "one line, grounded in consequence" },
|
|
1341
|
+
note: { type: "string" },
|
|
1342
|
+
},
|
|
1343
|
+
},
|
|
1344
|
+
},
|
|
1345
|
+
},
|
|
1346
|
+
};
|
|
1347
|
+
|
|
1348
|
+
// ─── Architect — re-tier by consequence, group by root cause, rank ──────────
|
|
1349
|
+
//
|
|
1350
|
+
// [RULE] architect-ranks-before-numbering
|
|
1351
|
+
//
|
|
1352
|
+
// Run by hand over the hilo-figma-atos register on 2026-08-11, and it changed
|
|
1353
|
+
// the answer enough to become a permanent stage:
|
|
1354
|
+
//
|
|
1355
|
+
// · The worst finding in the codebase was filed HIGH. Account credits that
|
|
1356
|
+
// cover a whole invoice are never marked used, so the same credit is given
|
|
1357
|
+
// away again every month, forever. It sat at position 127.
|
|
1358
|
+
// · A typo in one text box silently routes every real card payment to the
|
|
1359
|
+
// practice gateway, where charges report success and no money moves. Filed
|
|
1360
|
+
// MEDIUM.
|
|
1361
|
+
// · Every school's signed legal agreements are downloadable by any other
|
|
1362
|
+
// school. Filed LOW.
|
|
1363
|
+
// · A fabricated thunderstorm advisory for a named airport is shown to pilots
|
|
1364
|
+
// on every page. Filed MEDIUM.
|
|
1365
|
+
// · 22 findings were genuinely unreachable code — carried as risk when they
|
|
1366
|
+
// cannot execute.
|
|
1367
|
+
//
|
|
1368
|
+
// And the shape of the work changed: 492 findings collapsed into 28 root causes.
|
|
1369
|
+
// 144 of the 328 medium/low findings attached to a root that already existed,
|
|
1370
|
+
// and only 7 new causes were needed. The codebase does not have 492 problems; it
|
|
1371
|
+
// has about 28, most of them repeated dozens of times. Scheduling the findings
|
|
1372
|
+
// individually produces dozens of half-fixes of one defect.
|
|
1373
|
+
//
|
|
1374
|
+
// Two things make this stage worth its cost, and both were measured, not assumed:
|
|
1375
|
+
// 1. Severity assigned per-finding is unreliable, because severity is
|
|
1376
|
+
// COMPARATIVE. An agent seeing findings together ranks them; an agent
|
|
1377
|
+
// seeing one finding alone confirms whatever it was handed.
|
|
1378
|
+
// 2. TD numbers must be assigned AFTER this, or the register's numbering
|
|
1379
|
+
// encodes the order slices happened to finish.
|
|
1380
|
+
async function architectPass(findings) {
|
|
1381
|
+
if (!findings.length) return null;
|
|
1382
|
+
|
|
1383
|
+
// One agent per ~150 findings — the size that held its judgment in the manual
|
|
1384
|
+
// run. More than that and the later findings get thinner treatment; fewer and
|
|
1385
|
+
// the agent loses the comparison that makes ranking possible.
|
|
1386
|
+
const ARCH_BATCH = 150;
|
|
1387
|
+
const chunks = [];
|
|
1388
|
+
for (let i = 0; i < findings.length; i += ARCH_BATCH) chunks.push(findings.slice(i, i + ARCH_BATCH));
|
|
1389
|
+
|
|
1390
|
+
log(`architect: ${findings.length} findings across ${chunks.length} pass(es) — re-tier by consequence, group by root cause, rank`);
|
|
1391
|
+
|
|
1392
|
+
const TIER_RULES = [
|
|
1393
|
+
`TIERS ARE ABOUT CONSEQUENCE, NOT THE KIND OF BUG. The dividing question for every finding: DOES IT LEAVE WRONG DATA BEHIND, OR DOES IT JUST FAIL TO DO ANYTHING?`,
|
|
1394
|
+
` EXTREME — breach, data loss, money wrong, or safety. Irreversible or legally reportable. Cross-tenant read OR write, missing database-level access rules, leaked credentials, remote code execution, privilege escalation — AND ALSO anything that CORRUPTS DATA or REPORTS SUCCESS WHILE WRITING NOTHING (money shown as moved but not moved; a record referenced elsewhere that was never created; a kill switch that reports "off" while the thing runs).`,
|
|
1395
|
+
` CRITICAL — the feature does not work; recoverable once fixed; NO bad data left behind. A page that never loads. An endpoint that fails and shows an empty list.`,
|
|
1396
|
+
` HIGH — real, should be fixed, neither of the above.`,
|
|
1397
|
+
` MEDIUM / LOW — confirm as filed.`,
|
|
1398
|
+
` DEAD CODE is NOT a risk tier. Genuinely unreachable code cannot cause a problem — mark it dead and it moves to a cleanup list.`,
|
|
1399
|
+
``,
|
|
1400
|
+
`⚠ AN EMPTY IMPORT LIST DOES NOT PROVE UNREACHABILITY. A page reached by dynamic import shows zero importers in the code graph and is still live and routed — this nearly cost a live credit-card form its EXTREME rating. Before calling anything dead: check the graph, search the name across the source tree, AND search for dynamic loading and the router file. Say which checks you ran.`,
|
|
1401
|
+
].join("\n");
|
|
1402
|
+
|
|
1403
|
+
const results = await parallel(chunks.map((chunk, ci) => async () => {
|
|
1404
|
+
const listing = chunk.map((f, i) =>
|
|
1405
|
+
`[${i}] severity=${f.severity} area=${ascii(f.area) || "?"} | ${ascii(f.title)} | at ${(f.files && f.files.join(", ")) || "?"} | ${ascii(f.description || "").slice(0, 400)}`
|
|
1406
|
+
).join("\n");
|
|
1407
|
+
|
|
1408
|
+
const prompt = [
|
|
1409
|
+
`⛔ Work ONLY inside \`${projectDir}\`. Read real code with Read/Grep to settle any question; the code graph answers structural questions (\`gsd-t graph who-imports <file>\`, \`who-calls\`, \`blast-radius\`) and must be preferred over grep for those.`,
|
|
1410
|
+
``,
|
|
1411
|
+
`You are the ARCHITECT for a completed tech-debt scan. ${chunk.length} findings, already verified against the code by an earlier pass. DO NOT re-verify them all. Your job is judgment about TIER, ROOT CAUSE and ORDER.`,
|
|
1412
|
+
``,
|
|
1413
|
+
TIER_RULES,
|
|
1414
|
+
``,
|
|
1415
|
+
`YOUR HIGHEST-VALUE OUTPUT IS FINDING THE MIS-FILED ONES. Severity as assigned is not reliable: in the run that created this stage, the single worst defect in the codebase (account credits re-spent every month, forever) was filed HIGH, and a payment misrouting that makes charges succeed while no money moves was filed MEDIUM. Hunt specifically for money, cross-tenant access, safety, and silent data corruption hiding at a low severity.`,
|
|
1416
|
+
``,
|
|
1417
|
+
`FINDINGS:`,
|
|
1418
|
+
listing,
|
|
1419
|
+
``,
|
|
1420
|
+
`GROUP BY ROOT CAUSE. A root is one underlying cause where ONE fix closes several findings — e.g. many routes missing the same tenant check. A root with ONE member is not a group; leave it standalone. A root's tier is the tier of its WORST member.`,
|
|
1421
|
+
`WRITE IT SHORT. The name is a 2-5 word noun phrase ("Missing tenant scoping", "Silent write failures"), never a sentence. \`why\` and \`fix\` and \`rankReason\` are ONE sentence each. Someone scanning 28 roots to pick what to fix next cannot read a paragraph per root — but KEEP every file:line citation inside \`why\`, because those are what make the grouping checkable. Compress the prose, never the evidence.`,
|
|
1422
|
+
`Rank roots within their tier by worst consequence, and members within a root by consequence. RISK order, never the order the findings arrived.`,
|
|
1423
|
+
``,
|
|
1424
|
+
`Return JSON per the schema. Every one of the ${chunk.length} findings must appear exactly once in \`placements\` — a finding you drop is a defect nobody will see again.`,
|
|
1425
|
+
SHAPE_RULE,
|
|
1426
|
+
].join("\n");
|
|
1427
|
+
|
|
1428
|
+
const r = await gatedAgent(prompt, {
|
|
1429
|
+
label: `architect ${ci + 1}/${chunks.length}`, phase: "Architect",
|
|
1430
|
+
schema: ARCHITECT_SCHEMA, model: "opus",
|
|
1431
|
+
});
|
|
1432
|
+
return (r && Array.isArray(r.placements)) ? r : null;
|
|
1433
|
+
}));
|
|
1434
|
+
|
|
1435
|
+
const ok = results.filter(Boolean);
|
|
1436
|
+
if (!ok.length) {
|
|
1437
|
+
// Nothing to rank with. The register is still written, in its filed order —
|
|
1438
|
+
// said out loud, because a register that looks ranked and is not is worse
|
|
1439
|
+
// than one that never claimed to be.
|
|
1440
|
+
log(`⚠ ARCHITECT PRODUCED NOTHING — the register keeps its filed severities and discovery order. It is NOT prioritised; treat its ordering as arbitrary.`);
|
|
1441
|
+
return null;
|
|
1442
|
+
}
|
|
1443
|
+
if (ok.length < chunks.length) {
|
|
1444
|
+
log(`⚠ architect: ${ok.length} of ${chunks.length} passes returned — findings in the missing pass(es) keep their filed severity and sit unranked at the end.`);
|
|
1445
|
+
}
|
|
1446
|
+
return { chunks, results: ok.length === chunks.length ? results : results, partial: ok.length < chunks.length };
|
|
1447
|
+
}
|
|
1448
|
+
|
|
1211
1449
|
// M75 chunked formatter: returns an ARRAY of markdown chunks, each ≤ ~30KB, so each
|
|
1212
1450
|
// can be written through one bounded agent prompt WITHOUT truncation (a single write
|
|
1213
1451
|
// of a 466KB register truncates at ~165KB — verified). Chunk 0 is the header+summary
|
|
@@ -1244,20 +1482,101 @@ function typeOf(f) {
|
|
|
1244
1482
|
return "Other";
|
|
1245
1483
|
}
|
|
1246
1484
|
const TYPE_ORDER = ["Security / Vulnerability", "Dead Code", "Duplication", "Data Integrity / Concurrency", "Performance", "Contract Drift", "Testing", "Other"];
|
|
1247
|
-
const SEV_ORDER2 = {
|
|
1248
|
-
|
|
1485
|
+
const SEV_ORDER2 = { EXTREME: 0, CRITICAL: 1, HIGH: 2, MEDIUM: 3, LOW: 4 };
|
|
1486
|
+
|
|
1487
|
+
// The architect runs HERE — before anything is numbered. Its output re-tiers the
|
|
1488
|
+
// findings and decides their order; TD numbers then follow that order, so TD-1 is
|
|
1489
|
+
// the most urgent thing in the codebase rather than whatever a slice finished
|
|
1490
|
+
// first. See [RULE] architect-ranks-before-numbering above.
|
|
1491
|
+
phase("Architect");
|
|
1492
|
+
const architect = await architectPass(finalFindings);
|
|
1493
|
+
|
|
1494
|
+
// Apply the architect's tiers and grouping back onto the findings.
|
|
1495
|
+
if (architect) {
|
|
1496
|
+
let retiered = 0, deadFound = 0, notDefect = 0;
|
|
1497
|
+
architect.chunks.forEach((chunk, ci) => {
|
|
1498
|
+
const res = architect.results[ci];
|
|
1499
|
+
if (!res) return; // that pass returned nothing; its findings keep their filed severity
|
|
1500
|
+
const roots = new Map();
|
|
1501
|
+
for (const r of (res.roots || [])) if (r && r.key) roots.set(r.key, r);
|
|
1502
|
+
for (const p of res.placements) {
|
|
1503
|
+
const f = chunk[p.index];
|
|
1504
|
+
if (!f) continue; // an index outside the batch is not a finding to place
|
|
1505
|
+
const tier = String(p.tier || "").toUpperCase();
|
|
1506
|
+
if (tier && tier !== f.severity) { f.severity = tier; retiered++; }
|
|
1507
|
+
if (p.deadCode) { f._deadCode = true; deadFound++; }
|
|
1508
|
+
if (p.notADefect) { f._notADefect = true; notDefect++; }
|
|
1509
|
+
if (p.rootKey) {
|
|
1510
|
+
const root = roots.get(p.rootKey);
|
|
1511
|
+
f._rootKey = p.rootKey;
|
|
1512
|
+
f._rootName = (root && root.name) || p.rootKey;
|
|
1513
|
+
f._rootFix = root && root.fix;
|
|
1514
|
+
f._rootWhy = root && root.why;
|
|
1515
|
+
f._rootRankReason = root && root.rankReason;
|
|
1516
|
+
f._rootRank = (root && Number.isInteger(root.rank)) ? root.rank : 99;
|
|
1517
|
+
f._rankInRoot = Number.isInteger(p.rankInRoot) ? p.rankInRoot : 99;
|
|
1518
|
+
}
|
|
1519
|
+
if (Array.isArray(p.alsoRoots) && p.alsoRoots.length) f._alsoRoots = p.alsoRoots;
|
|
1520
|
+
if (p.reason) f._archReason = p.reason;
|
|
1521
|
+
}
|
|
1522
|
+
});
|
|
1523
|
+
log(`architect: ${retiered} finding(s) re-tiered, ${deadFound} confirmed dead code, ${notDefect} judged not a defect`);
|
|
1524
|
+
|
|
1525
|
+
// Recount — the tiers just changed, so the header's numbers must follow.
|
|
1526
|
+
counts.critical = 0; counts.high = 0; counts.medium = 0; counts.low = 0; counts.extreme = 0;
|
|
1527
|
+
for (const f of finalFindings) {
|
|
1528
|
+
const s = String(f.severity || "").toUpperCase();
|
|
1529
|
+
if (s === "EXTREME") counts.extreme++;
|
|
1530
|
+
else if (s === "CRITICAL") counts.critical++;
|
|
1531
|
+
else if (s === "HIGH") counts.high++;
|
|
1532
|
+
else if (s === "MEDIUM") counts.medium++;
|
|
1533
|
+
else if (s === "LOW") counts.low++;
|
|
1534
|
+
}
|
|
1535
|
+
}
|
|
1536
|
+
|
|
1537
|
+
// Ordering — the single source of truth for both the register's TD numbering and
|
|
1538
|
+
// the consolidation stage's references.
|
|
1539
|
+
//
|
|
1540
|
+
// With an architect result: tier, then the root's rank within that tier, then the
|
|
1541
|
+
// finding's rank within its root — the order a person should work through them.
|
|
1542
|
+
// Confirmed dead code sinks below everything: it cannot cause a problem, so it
|
|
1543
|
+
// must not sit above things that can.
|
|
1544
|
+
//
|
|
1545
|
+
// Without one: the old severity-then-type-then-arrival order, which is arbitrary
|
|
1546
|
+
// inside a severity and was announced as such by architectPass().
|
|
1249
1547
|
const orderedFindings = finalFindings
|
|
1250
1548
|
.map((f, i) => ({ f, i, t: typeOf(f) }))
|
|
1251
1549
|
.sort((a, b) => {
|
|
1252
|
-
const
|
|
1550
|
+
const deadA = a.f._deadCode ? 1 : 0, deadB = b.f._deadCode ? 1 : 0;
|
|
1551
|
+
if (deadA !== deadB) return deadA - deadB;
|
|
1552
|
+
const sv = (SEV_ORDER2[String(a.f.severity || "").toUpperCase()] ?? 9)
|
|
1553
|
+
- (SEV_ORDER2[String(b.f.severity || "").toUpperCase()] ?? 9);
|
|
1253
1554
|
if (sv !== 0) return sv;
|
|
1254
|
-
|
|
1255
|
-
|
|
1555
|
+
if (architect) {
|
|
1556
|
+
// A standalone finding ranks beside the roots, not after them: it is one
|
|
1557
|
+
// item the architect chose not to group, not a lesser item.
|
|
1558
|
+
const rr = (a.f._rootRank ?? 50) - (b.f._rootRank ?? 50);
|
|
1559
|
+
if (rr !== 0) return rr;
|
|
1560
|
+
const ri = (a.f._rankInRoot ?? 50) - (b.f._rankInRoot ?? 50);
|
|
1561
|
+
if (ri !== 0) return ri;
|
|
1562
|
+
} else {
|
|
1563
|
+
const tv = TYPE_ORDER.indexOf(a.t) - TYPE_ORDER.indexOf(b.t);
|
|
1564
|
+
if (tv !== 0) return tv;
|
|
1565
|
+
}
|
|
1256
1566
|
return a.i - b.i;
|
|
1257
1567
|
});
|
|
1258
1568
|
|
|
1569
|
+
// Every finding must survive the ordering. A sort cannot lose one, but the
|
|
1570
|
+
// architect's placement loop can only be trusted if this is checked rather than
|
|
1571
|
+
// assumed — a merge in the same family reported full coverage while having
|
|
1572
|
+
// dropped two.
|
|
1573
|
+
if (orderedFindings.length !== finalFindings.length) {
|
|
1574
|
+
log(`⚠ ORDERING LOST FINDINGS: ${finalFindings.length} in, ${orderedFindings.length} out — the register would under-report. Halting.`);
|
|
1575
|
+
return { status: "failed", reason: "ordering-lost-findings", expected: finalFindings.length, got: orderedFindings.length };
|
|
1576
|
+
}
|
|
1577
|
+
|
|
1259
1578
|
function fmtChunks(today) {
|
|
1260
|
-
const sevHead = {
|
|
1579
|
+
const sevHead = { EXTREME: "🔴 Extreme", CRITICAL: "🟠 Critical", HIGH: "🟡 High", MEDIUM: "🔵 Medium", LOW: "🟢 Low" };
|
|
1261
1580
|
const head = [];
|
|
1262
1581
|
head.push(`# Tech Debt Register - ${projectDir.split("/").pop()}`, "");
|
|
1263
1582
|
if (scanNumber) head.push(`**Scan #${scanNumber}** - Deep codebase scan (runtime-native, ${coverageComplete ? "full coverage" : "PARTIAL coverage"})`);
|
|
@@ -1272,10 +1591,17 @@ function fmtChunks(today) {
|
|
|
1272
1591
|
head.push(`> Effort estimates use GSD-T-native units (domain / wave / spawn / token-spend). Never human-hours.`);
|
|
1273
1592
|
head.push(`> TD numbering continues from the prior register (if any, archived). This scan begins at **TD-${tdStart}**.`, "");
|
|
1274
1593
|
if (!coverageComplete) head.push(`> ⚠️ **PARTIAL COVERAGE - ${failedSlices.length} of ${slices.length} codebase areas were NOT scanned this pass** (failed to return findings): ${ascii(failedSlices.join(", "))}. Findings UNDER-COUNT the real debt. Re-run (resume) for full coverage.`, "");
|
|
1275
|
-
head.push(`## Summary`, "", `| Severity | Count |`,
|
|
1276
|
-
|
|
1277
|
-
|
|
1594
|
+
head.push(`## Summary`, "", `| Severity | Count |`, `|----------|-------|`);
|
|
1595
|
+
// EXTREME only appears when the architect produced it. A row of zero on every
|
|
1596
|
+
// register of a healthy project is noise, and an absent row hides no count.
|
|
1597
|
+
if (counts.extreme) head.push(`| 🔴 EXTREME | ${counts.extreme} |`);
|
|
1598
|
+
head.push(
|
|
1599
|
+
`| 🟠 CRITICAL | ${counts.critical} |`, `| 🟡 HIGH | ${counts.high} |`,
|
|
1600
|
+
`| 🔵 MEDIUM | ${counts.medium} |`, `| 🟢 LOW | ${counts.low} |`,
|
|
1278
1601
|
`| **Total** | **${counts.total}** |`, "", "---", "");
|
|
1602
|
+
if (counts.extreme) {
|
|
1603
|
+
head.push(`> **EXTREME** means it leaves wrong data behind, crosses a tenant boundary, moves money wrongly, or touches safety. CRITICAL means the feature simply does not work, with nothing bad left behind.`, "");
|
|
1604
|
+
}
|
|
1279
1605
|
|
|
1280
1606
|
function itemMd(f, td) {
|
|
1281
1607
|
const L = [`### TD-${td} - ${ascii(f.title) || "(untitled)"}`,
|
|
@@ -1291,17 +1617,34 @@ function fmtChunks(today) {
|
|
|
1291
1617
|
|
|
1292
1618
|
const CHUNK_MAX = 30000;
|
|
1293
1619
|
const chunks = [head.join("\n")];
|
|
1294
|
-
let buf = "", n = tdStart, lastSev = null, lastType = null;
|
|
1620
|
+
let buf = "", n = tdStart, lastSev = null, lastType = null, lastRoot = null;
|
|
1295
1621
|
const flush = () => { if (buf) { chunks.push(buf); buf = ""; } };
|
|
1296
1622
|
// Consume the shared `orderedFindings` (severity → type → original-index) so the
|
|
1297
1623
|
// TD numbers assigned here are IDENTICAL to those the consolidation stage references.
|
|
1298
1624
|
for (const { f, t } of orderedFindings) {
|
|
1299
1625
|
let piece = "";
|
|
1300
|
-
if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; lastType = null; }
|
|
1626
|
+
if (f.severity !== lastSev) { piece += `\n## ${sevHead[f.severity] || f.severity} Priority\n\n`; lastSev = f.severity; lastType = null; lastRoot = null; }
|
|
1301
1627
|
// Type sub-heading uses a bold marker line (NOT `###`) so it never collides with
|
|
1302
1628
|
// the `### TD-N` item headings that downstream tools grep for. ASCII hyphens only
|
|
1303
1629
|
// (M76: no em/en-dashes in fmtChunks literals).
|
|
1304
|
-
|
|
1630
|
+
// Root grouping is the fix unit. When the architect grouped the findings, the
|
|
1631
|
+
// register shows the root and its one-line cause/fix/rank above its members,
|
|
1632
|
+
// because scheduling the members separately produces separate half-fixes of
|
|
1633
|
+
// one defect. Bullets sit in <sub> so the eye lands on the finding titles.
|
|
1634
|
+
// Without an architect result there are no roots, and the old type marker
|
|
1635
|
+
// still separates the sections.
|
|
1636
|
+
if (f._rootKey && f._rootKey !== lastRoot) {
|
|
1637
|
+
piece += `\n### ${f._rootKey} - ${ascii(f._rootName) || f._rootKey}\n\n<sub>\n\n`;
|
|
1638
|
+
if (f._rootWhy) piece += `- **Cause:** ${ascii(f._rootWhy)}\n`;
|
|
1639
|
+
if (f._rootFix) piece += `- **Fix:** ${ascii(f._rootFix)}\n`;
|
|
1640
|
+
if (f._rootRankReason) piece += `- **Rank:** ${ascii(f._rootRankReason)}\n`;
|
|
1641
|
+
piece += `\n</sub>\n\n`;
|
|
1642
|
+
lastRoot = f._rootKey; lastType = null;
|
|
1643
|
+
} else if (!f._rootKey && lastRoot) {
|
|
1644
|
+
piece += `\n### Standalone\n\n`;
|
|
1645
|
+
lastRoot = null; lastType = null;
|
|
1646
|
+
}
|
|
1647
|
+
if (!f._rootKey && t !== lastType) { piece += `**-- ${t} --**\n\n`; lastType = t; }
|
|
1305
1648
|
piece += itemMd(f, n++);
|
|
1306
1649
|
if (buf.length + piece.length > CHUNK_MAX) flush();
|
|
1307
1650
|
buf += piece;
|
|
@@ -1557,7 +1900,25 @@ log(`document phase: ${docsOk.length}/${docTargets.length} written/merged${docsF
|
|
|
1557
1900
|
// then ASSEMBLE deterministically with severity section headers, and chunk-write.
|
|
1558
1901
|
phase("Plain-English");
|
|
1559
1902
|
const peTarget = `${projectDir}/.gsd-t/techdebt_in_plain_english.md`; // internal fixed name (shared copy suffixed in share/)
|
|
1560
|
-
|
|
1903
|
+
// [RULE] severity-label-never-assumes-unlaunched
|
|
1904
|
+
//
|
|
1905
|
+
// "fix before launch" was wrong on every scan of a system already serving
|
|
1906
|
+
// customers — which is most of them. A register handed to the owner of a live
|
|
1907
|
+
// product that dates its own advice to before go-live reads as boilerplate, and
|
|
1908
|
+
// boilerplate is skipped.
|
|
1909
|
+
//
|
|
1910
|
+
// One phrase per tier, defined here only. The plain-English companion took its
|
|
1911
|
+
// labels from this map and then drifted: the hilo-figma-atos file carried twelve
|
|
1912
|
+
// different phrasings for four tiers ("Worth scheduling" / "Worth scheduling
|
|
1913
|
+
// soon" / "Should be scheduled soon" / "Can be scheduled at normal priority"),
|
|
1914
|
+
// plus casing variants, and only 36 of 61 criticals were labelled at all.
|
|
1915
|
+
const sevLabel = {
|
|
1916
|
+
EXTREME: "immediate priority",
|
|
1917
|
+
CRITICAL: "fix soon",
|
|
1918
|
+
HIGH: "schedule this cycle",
|
|
1919
|
+
MEDIUM: "clean up eventually",
|
|
1920
|
+
LOW: "clean up eventually",
|
|
1921
|
+
};
|
|
1561
1922
|
// Attach the deterministic TD number (matches the register: severity-sorted, tdStart+).
|
|
1562
1923
|
const peItems = finalFindings.map((f, i) => ({
|
|
1563
1924
|
td: tdStart + i, severity: f.severity, title: ascii(f.title),
|
|
@@ -1577,12 +1938,23 @@ const peResults = await parallel(peBatches.map((batch, bi) => async () => {
|
|
|
1577
1938
|
`**What it is.** <1-2 sentences, no jargon; define any unavoidable term in parentheses>`,
|
|
1578
1939
|
`**Why it matters.** <business/user consequence>`,
|
|
1579
1940
|
`**Real-world analogy.** <a concrete everyday comparison that genuinely maps to THIS issue>`,
|
|
1580
|
-
`**Severity.** <the
|
|
1941
|
+
`**Severity.** <the item's \`severityPhrase\`, copied EXACTLY, capitalised, ending with a full stop — e.g. "Immediate priority.">`,
|
|
1942
|
+
`Use that phrase VERBATIM. Do not reword it, do not add "soon"/"eventually"/"worth", do not invent a variant. Four phrases exist and no others: "Immediate priority.", "Fix soon.", "Schedule this cycle.", "Clean up eventually." A reader scanning for what to do next is reading the phrase, not the sentence around it, so a rewording makes two identical priorities look different.`,
|
|
1943
|
+
`NEVER write "fix before launch" or any wording implying the system has not launched — most scanned systems are already live and serving customers.`,
|
|
1581
1944
|
`Keep the td number EXACTLY. ASCII punctuation only (hyphens, straight quotes — NO em-dashes/smart-quotes/ellipsis). No preamble.`,
|
|
1582
1945
|
``,
|
|
1583
1946
|
`Findings (batch ${bi + 1}/${peBatches.length}):`,
|
|
1584
1947
|
"```json",
|
|
1585
|
-
|
|
1948
|
+
// A severity with no phrase is a bug in the map, not a finding to label
|
|
1949
|
+
// "review" — the old default quietly turned an unrecognised tier into a word
|
|
1950
|
+
// that says nothing, and read as deliberate. Normalised for case first,
|
|
1951
|
+
// because a finder that types "Critical" must not fall through.
|
|
1952
|
+
JSON.stringify(batch.map((it) => {
|
|
1953
|
+
const tier = String(it.severity || "").toUpperCase();
|
|
1954
|
+
const phrase = sevLabel[tier];
|
|
1955
|
+
if (!phrase) log(`⚠ severity "${it.severity}" has no label in sevLabel — ${it.title || "a finding"} will be labelled by its tier name`);
|
|
1956
|
+
return { ...it, severityPhrase: phrase || tier.toLowerCase() || "unrated" };
|
|
1957
|
+
})),
|
|
1586
1958
|
"```",
|
|
1587
1959
|
].join("\n");
|
|
1588
1960
|
try {
|