@tekyzinc/gsd-t 4.7.11 → 4.9.11
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 +46 -0
- package/README.md +1 -1
- package/bin/gsd-t-architectural-trigger.cjs +80 -5
- package/bin/gsd-t-divergence-grammar.cjs +213 -0
- package/bin/gsd-t-guard-map-derive.cjs +210 -0
- package/bin/gsd-t-guard-map.cjs +279 -0
- package/bin/gsd-t-jargon-lint.cjs +363 -0
- package/bin/gsd-t-milestone-state.cjs +236 -0
- package/bin/gsd-t-rule-consume.cjs +141 -0
- package/bin/gsd-t-shrink-metric.cjs +255 -0
- package/bin/gsd-t-traceability-gate.cjs +321 -24
- package/bin/gsd-t.js +61 -0
- package/commands/gsd-t-doc-ripple.md +1 -1
- package/commands/gsd-t-milestone.md +39 -1
- package/commands/gsd-t-quick.md +13 -6
- package/package.json +1 -1
- package/scripts/gsd-t-brevity-guard.js +340 -0
- package/templates/CLAUDE-global.md +18 -0
- package/templates/PseudoCode-spec.md +194 -0
- package/templates/prompts/blind-adversary-subagent.md +4 -0
- package/templates/prompts/keep-or-supersede-subagent.md +61 -0
- package/templates/prompts/pre-mortem-subagent.md +4 -0
- package/templates/prompts/qa-subagent.md +14 -0
- package/templates/prompts/red-team-subagent.md +16 -0
- package/templates/workflows/gsd-t-execute.workflow.js +7 -1
- package/templates/workflows/gsd-t-phase.workflow.js +22 -2
- package/templates/workflows/gsd-t-quick.workflow.js +16 -2
- package/templates/workflows/gsd-t-verify.workflow.js +183 -2
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,52 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to GSD-T are documented here. Updated with each release.
|
|
4
4
|
|
|
5
|
+
## [4.9.11] - 2026-06-23 (M93 — Brevity Guard: enforce concise, answer-first output — patch)
|
|
6
|
+
|
|
7
|
+
### Added — conciseness is now enforced, not just requested
|
|
8
|
+
|
|
9
|
+
The user has asked for concise, jargon-free replies for weeks; prose instructions kept getting ignored (a "no process narration" rule even existed and was violated). The fix is a deterministic gate, because that is the only mechanism GSD-T reliably obeys. 3 file-disjoint domains; full suite 2230 / 2226 pass / 0 fail / 4 skip.
|
|
10
|
+
|
|
11
|
+
- **Brevity-guard Stop hook** (`scripts/gsd-t-brevity-guard.js`) — runs when a reply finishes. For a **question** (a pure-text answer), it blocks egregious preamble: 2+ stacked process-narration sentences ("let me find X before I answer…") before the actual answer, or an unglossed jargon code (`S2-M7`, `HC-003`). For an **action** (the turn changed code), intent-first is allowed — you still get to short-circuit a wrong direction. **Fail-open by design**: any error, malformed input, or uncertainty → allow, so it can never gag legitimate work.
|
|
12
|
+
- **Reader Contract** in `templates/CLAUDE-global.md` + the QA / Red Team / pre-mortem / blind-adversary subagent prompts — sets the concise, answer-first, gloss-jargon default at the source, so the gate rarely needs to fire.
|
|
13
|
+
- **Jargon lint** (`bin/gsd-t-jargon-lint.cjs`) — flags an unglossed jargon code in a written document (the file surface the Stop hook can't reach), so decision docs stay readable.
|
|
14
|
+
|
|
15
|
+
The dated banner at the top of each reply is unchanged (kept by request).
|
|
16
|
+
|
|
17
|
+
## [4.9.10] - 2026-06-23 (M92 — Understand-Before-Build, the paradigm half / backlog #44a — minor)
|
|
18
|
+
|
|
19
|
+
### Changed — GSD-T now prefers the SMALLEST change that hits the crux
|
|
20
|
+
|
|
21
|
+
The root cause of the BinVoice over-scoping saga: GSD-T was a purely ADDITIVE gate pipeline — when a plan started too high, every gate made it bigger, and the verdict schema couldn't even SAY "we made it smaller." M92 is the cheap, no-graph paradigm half of the fix (the graph half, #44b, is a separate later milestone gated on this one). Three file-disjoint moves, one wave; full suite 2183 / 2179 pass / 0 fail / 4 skip.
|
|
22
|
+
|
|
23
|
+
- **Cheaper-first response ladder (move 1).** M90's §2 architectural trigger (R-ARCH-2 — fires when a task touches an existing file) now resolves a **look → smallest → spike → defer** ladder: `look` (grep/read what exists before scoping) is the new DEFAULT, `spike` is DEMOTED to a later rung. The cheap "look" rung resolves most over-scoping without ever needing a spike. R-ARCH-4/5/6 and the backward-compat envelope are preserved; the live spike-feasibility decider remains backlog #42 (explicitly out of scope). Doctrine contract `unproven-assumption-doctrine-contract.md` §2.2 updated.
|
|
24
|
+
- **The verdict can say "smaller" (move 2, the keystone).** New `bin/gsd-t-shrink-metric.cjs` measures a change's leanness deterministically from `git diff --numstat` (`netLoc`, `leaner`) — MEASURED, not LLM-attested. Verify's `VERDICT_SCHEMA` gains an ADDITIVE `shrink` dimension (the correctness enum is untouched), so a net-negative diff is rewarded as a success instead of being invisible.
|
|
25
|
+
- **The default is inverted (move 3).** The milestone/quick framing now leads with the smallest-altitude change; ceremony (plan→execute, partition, competition) is opt-in, justified by the crux — instead of being the implied "Recommended" default.
|
|
26
|
+
|
|
27
|
+
### Security
|
|
28
|
+
|
|
29
|
+
- **`bin/gsd-t-shrink-metric.cjs` git-argument injection (M92 verify Red Team, HIGH, fixed in-verify).** The `--range` value was passed to `git diff --numstat <range>` without dash-leading validation — `execFileSync` blocks shell injection but not git-OPTION injection, so a range like `--output=<path>` made git overwrite an arbitrary file (and the range is LLM-derived upstream). Fixed by refusing non-string/empty/dash-leading ranges before the git call (+ `--` end-of-options as defense-in-depth), with 3 security regression tests.
|
|
30
|
+
|
|
31
|
+
## [4.8.10] - 2026-06-22 (M91 — PseudoCode Source-of-Truth, merged M87+M88 — minor)
|
|
32
|
+
|
|
33
|
+
### Added — the intention-first PseudoCode behavior map becomes the milestone source-of-truth
|
|
34
|
+
|
|
35
|
+
A `PseudoCode-[Title].md` authored BEFORE the build (a two-altitude behavior map: high-level approach → detailed per-step intention+mechanism) is now the milestone source-of-truth, enforced by deterministic gates. Eight file-disjoint domains across three waves, single-session build; full suite 2131 / 2127 pass / 0 fail / 4 skip.
|
|
36
|
+
|
|
37
|
+
**M87 core (Waves 1–2):**
|
|
38
|
+
- **Guard-map verify gate** (`bin/gsd-t-guard-map.cjs`) — parses the `[RULE …]` guard map (3 grammar forms, side-agnostic, derives `R-<SLUG>-<NN>` ids), gates a build→rule map (exit 0 / 4-names-the-rule / 64); wired FAIL-blocking into `gsd-t-verify.workflow.js` before the triad, with §7 doc+map discovery and distinct logged skips (`no-build-map` / `no-pseudocode-docs`).
|
|
39
|
+
- **Section-citation traceability** (`bin/gsd-t-traceability-gate.cjs` extended) — tasks cite `**PseudoCode-Section**: <Title>#<anchor>`; a section with no citing task is a structural coverage gap; slug-as-slug resolution, never substring. Plus a milestone→domains scoping fix (`--domains`; zero-prefix-match no longer falls back to all — exit 64 `milestone-scope-unresolved`) and a path-containment guard (`domain-path-escape`).
|
|
40
|
+
- **Two-altitude milestone flow** (`commands/gsd-t-milestone.md` + `gsd-t-phase.workflow.js`) — high-level approach sign-off precedes the detailed doc; competition solution-space probe shifts up to the approach altitude. Keep-or-supersede prompt protocol writes a `⚠ Divergence` flag on supersede.
|
|
41
|
+
- **Template + doc-ripple** — `templates/PseudoCode-spec.md` mold; `PseudoCode-[Title].md` joins the Living Documents ripple set (region-scoped A4 drift lint).
|
|
42
|
+
|
|
43
|
+
**M88 deterministic gates (Wave 3):**
|
|
44
|
+
- **Sign-off state** (`bin/gsd-t-milestone-state.cjs`) — `<!-- signed-off: … -->` marker + `isDefined(docs)` predicate; unsigned ≠ DEFINED; skip is a logged decision, never silent.
|
|
45
|
+
- **Build→map derivation** (`bin/gsd-t-guard-map-derive.cjs`) — mechanical evidence→map seam (imports the guard-map parser), end-to-end derive→gate test.
|
|
46
|
+
- **Triad-consumption seam** (`bin/gsd-t-rule-consume.cjs` + ingest directives in qa/red-team prompts) — deterministic seam-check, no live triad.
|
|
47
|
+
- **Divergence grammar** (`bin/gsd-t-divergence-grammar.cjs`) — `parseDivergence`/`formatDivergence` byte-stable round-trip + `countDivergences`.
|
|
48
|
+
|
|
49
|
+
Every gate meets the deterministic-gate bar: deterministic code, zero LLM judgment, structural-not-substring, killing test vs byte-verbatim fixtures, fail-closed. Verify: QA PASS (mutation-tested), Red Team GRUDGING-PASS (one path-traversal MEDIUM fixed in-verify; fence-awareness MEDIUM/LOW deferred to backlog #45, all fail-closed). Contract: `pseudocode-source-of-truth-contract.md` v1.1.5 STABLE.
|
|
50
|
+
|
|
5
51
|
## [4.7.11] - 2026-06-22 (backlog #40 — deterministic domain archive+sweep — patch)
|
|
6
52
|
|
|
7
53
|
### Fixed — complete-milestone now deterministically archives + sweeps a milestone's domains
|
package/README.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# GSD-T: Contract-Driven Development for Claude Code
|
|
2
2
|
|
|
3
|
-
**v4.
|
|
3
|
+
**v4.9.11** - 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.
|
|
@@ -231,12 +231,52 @@ function emitInstrumentationRecord(record) {
|
|
|
231
231
|
/**
|
|
232
232
|
* Resolve the response mode flags given the spike feasibility / spike result inputs.
|
|
233
233
|
*
|
|
234
|
+
* M92 — CHEAPER-FIRST RESPONSE LADDER (look → smallest → spike → defer).
|
|
235
|
+
* --------------------------------------------------------------------------
|
|
236
|
+
* The BinVoice evidence proves the response should default to the CHEAPEST rung
|
|
237
|
+
* (look/grep what already exists) and only climb to a spike when look+smallest
|
|
238
|
+
* leave real uncertainty. Spike is DEMOTED from the default to a later rung.
|
|
239
|
+
*
|
|
240
|
+
* Rung order (deterministic state function of the inputs — ZERO LLM):
|
|
241
|
+
* 1. look (DEFAULT, no inputs) — grep/read the touched files first.
|
|
242
|
+
* 2. smallest ({looked:true}) — propose the smallest-altitude change.
|
|
243
|
+
* 3. spike ({looked, smallestProposed}) — uncertainty REMAINS → prove via spike.
|
|
244
|
+
* 4. defer ({wartDiscovered:true}) — a wart found mid-change → capture, never clean inline.
|
|
245
|
+
*
|
|
246
|
+
* PRESERVED EXACTLY (these still fire on their explicit inputs, ahead of the ladder):
|
|
247
|
+
* R-ARCH-5: spikeFeasible===false → adversary-only + adversaryMandatory + logged skip.
|
|
248
|
+
* R-ARCH-4: spikePassed===false → STOP (stopDirective:true); agent cannot proceed.
|
|
249
|
+
* spikePassed===true → mode:spike, adversary recommended-not-mandatory.
|
|
250
|
+
*
|
|
251
|
+
* Backward-compat envelope: EVERY return shape carries `mode` (string),
|
|
252
|
+
* `stopDirective` (boolean), `adversaryMandatory`, `provenByAdversaryOnly` — the keys
|
|
253
|
+
* execute/quick/verify read. A dropped/renamed key fails-OPEN those gates.
|
|
254
|
+
*
|
|
255
|
+
* NOTE (scope boundary): this resolver does NOT decide whether a spike is feasible
|
|
256
|
+
* (that is backlog #42's EXPERIMENTAL spike-feasibility decider). It only routes
|
|
257
|
+
* across the rungs from the inputs it is given; absent a spike result it defaults to
|
|
258
|
+
* the cheap LOOK rung instead of spike-preferred.
|
|
259
|
+
*
|
|
234
260
|
* @param {object} opts
|
|
235
|
-
* @param {boolean} [opts.spikeFeasible=true]
|
|
236
|
-
* @param {boolean} [opts.spikePassed]
|
|
261
|
+
* @param {boolean} [opts.spikeFeasible=true] - Can an executable spike be run?
|
|
262
|
+
* @param {boolean} [opts.spikePassed] - Did the spike pass? (ignored if spikeFeasible=false)
|
|
263
|
+
* @param {boolean} [opts.looked] - Has the agent grep/read the touched files yet?
|
|
264
|
+
* @param {boolean} [opts.smallestProposed] - Has the smallest-altitude change been proposed?
|
|
265
|
+
* @param {boolean} [opts.wartDiscovered] - Was an out-of-scope wart discovered mid-change?
|
|
237
266
|
* @returns {object} Response mode envelope fragment.
|
|
238
267
|
*/
|
|
239
|
-
function resolveResponseMode(
|
|
268
|
+
function resolveResponseMode(opts = {}) {
|
|
269
|
+
// Never throw on garbage: coalesce null/non-object inputs to an empty options bag
|
|
270
|
+
// (the `= {}` default only fires on `undefined`, not on `null` or primitives).
|
|
271
|
+
const o = opts && typeof opts === "object" ? opts : {};
|
|
272
|
+
const {
|
|
273
|
+
spikeFeasible = true,
|
|
274
|
+
spikePassed,
|
|
275
|
+
looked,
|
|
276
|
+
smallestProposed,
|
|
277
|
+
wartDiscovered,
|
|
278
|
+
} = o;
|
|
279
|
+
|
|
240
280
|
if (!spikeFeasible) {
|
|
241
281
|
// R-ARCH-5: spike infeasible → adversary-only + adversaryMandatory + logged skip
|
|
242
282
|
return {
|
|
@@ -269,9 +309,44 @@ function resolveResponseMode({ spikeFeasible = true, spikePassed } = {}) {
|
|
|
269
309
|
};
|
|
270
310
|
}
|
|
271
311
|
|
|
272
|
-
//
|
|
312
|
+
// ----- M92 cheaper-first ladder (no spike result provided yet) -----
|
|
313
|
+
|
|
314
|
+
if (wartDiscovered === true) {
|
|
315
|
+
// DEFER (terminal): a wart discovered mid-change is captured for later, never cleaned inline.
|
|
316
|
+
return {
|
|
317
|
+
mode: "defer",
|
|
318
|
+
deferDirective: "a wart discovered mid-change is captured for later, never cleaned inline",
|
|
319
|
+
adversaryMandatory: false,
|
|
320
|
+
provenByAdversaryOnly: false,
|
|
321
|
+
stopDirective: false,
|
|
322
|
+
};
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
if (looked === true && smallestProposed === true) {
|
|
326
|
+
// SPIKE (DEMOTED here): look + smallest left REAL uncertainty and a spike is feasible.
|
|
327
|
+
return {
|
|
328
|
+
mode: "spike",
|
|
329
|
+
adversaryMandatory: false,
|
|
330
|
+
provenByAdversaryOnly: false,
|
|
331
|
+
stopDirective: false,
|
|
332
|
+
};
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
if (looked === true) {
|
|
336
|
+
// SMALLEST: looked at what exists → propose the smallest-altitude change that hits the crux.
|
|
337
|
+
return {
|
|
338
|
+
mode: "smallest",
|
|
339
|
+
smallestDirective: "propose the smallest-altitude change that hits the crux; edit inward at the source, not outward at consumers",
|
|
340
|
+
adversaryMandatory: false,
|
|
341
|
+
provenByAdversaryOnly: false,
|
|
342
|
+
stopDirective: false,
|
|
343
|
+
};
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
// DEFAULT (no inputs): LOOK — the cheapest rung. Spike is NO LONGER the default.
|
|
273
347
|
return {
|
|
274
|
-
mode: "
|
|
348
|
+
mode: "look",
|
|
349
|
+
lookDirective: "grep/read the touched existing files; confirm what already exists before choosing scope",
|
|
275
350
|
adversaryMandatory: false,
|
|
276
351
|
provenByAdversaryOnly: false,
|
|
277
352
|
stopDirective: false,
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-divergence-grammar — M88 G4 (M91 Wave 3)
|
|
5
|
+
*
|
|
6
|
+
* The deterministic round-trip over the `⚠ Divergence` flag grammar.
|
|
7
|
+
*
|
|
8
|
+
* Contract: .gsd-t/contracts/pseudocode-source-of-truth-contract.md §4 — the
|
|
9
|
+
* SINGLE source of truth for the grammar. This module IMPLEMENTS that spec; it
|
|
10
|
+
* does NOT redefine it. The canonical §4 form is, EXACTLY:
|
|
11
|
+
*
|
|
12
|
+
* ⚠ Divergence: <RULE-ID or section> — supersedes shipped <what>. Reason: <user intention>.
|
|
13
|
+
*
|
|
14
|
+
* ORIGIN: M87's D3 (`keep-or-supersede-subagent.md`) ships the keep-or-supersede
|
|
15
|
+
* ASK and the prose WRITING of a `⚠ Divergence` flag on every supersede. That
|
|
16
|
+
* makes the flag a string IN the doc — but not yet a code-checkable artifact.
|
|
17
|
+
* This module closes that gap: a `⚠ Divergence` line round-trips
|
|
18
|
+
* format→parse→format BYTE-STABLE, a malformed flag FAILS (named, no throw), and
|
|
19
|
+
* the divergence COUNT over a doc is emitted as a checkable JSON integer that can
|
|
20
|
+
* feed D1's guard-map rule set.
|
|
21
|
+
*
|
|
22
|
+
* Hard rules (contract §4 + domain constraints):
|
|
23
|
+
* - Zero external runtime deps.
|
|
24
|
+
* - Never throws. A parse failure is a RETURNED, NAMED error, not an exception
|
|
25
|
+
* (fail-closed).
|
|
26
|
+
* - Pure. parseDivergence / formatDivergence / countDivergences have no I/O and
|
|
27
|
+
* no side effects (the CLI wrapper at the bottom is the only I/O).
|
|
28
|
+
* - Round-trip is byte-stable: formatDivergence(parseDivergence(line).value)
|
|
29
|
+
* === line for every valid flag.
|
|
30
|
+
* - Structural marker parse, never a prose substring scan
|
|
31
|
+
* (feedback_coverage_check_structural_not_substring): countDivergences only
|
|
32
|
+
* counts lines that PARSE as a well-formed §4 flag, not lines that merely
|
|
33
|
+
* CONTAIN the substring "⚠ Divergence".
|
|
34
|
+
*
|
|
35
|
+
* CLI:
|
|
36
|
+
* --parse "<line>" --json → { ok, value|error }
|
|
37
|
+
* --count <docPath> --json → { ok, count } (count is the checkable artifact)
|
|
38
|
+
*
|
|
39
|
+
* Exit: 0 ok · 4 parse failure / count error · 64 bad input.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
// --- §4 grammar literals (the EXACT canonical form — single source is the contract) ---
|
|
43
|
+
const PREFIX = "⚠ Divergence: "; // "⚠ Divergence: "
|
|
44
|
+
const SEP_SUPERSEDES = " — supersedes shipped "; // " — supersedes shipped " (em-dash U+2014)
|
|
45
|
+
const SEP_REASON = ". Reason: ";
|
|
46
|
+
const SUFFIX = ".";
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Parse a single line as a §4 `⚠ Divergence` flag.
|
|
50
|
+
*
|
|
51
|
+
* Structural, anchored parse — NOT a substring scan. The line MUST be EXACTLY
|
|
52
|
+
* `⚠ Divergence: <ref> — supersedes shipped <what>. Reason: <reason>.` with no
|
|
53
|
+
* leading/trailing slop. Each field MUST be non-empty.
|
|
54
|
+
*
|
|
55
|
+
* @param {string} line
|
|
56
|
+
* @returns {{ok: true, value: {ref: string, supersedes: string, reason: string}}
|
|
57
|
+
* | {ok: false, error: string}} never throws.
|
|
58
|
+
*/
|
|
59
|
+
function parseDivergence(line) {
|
|
60
|
+
if (typeof line !== "string") {
|
|
61
|
+
return { ok: false, error: "not-a-string" };
|
|
62
|
+
}
|
|
63
|
+
// Anchored: must START with the prefix (no leading slop — structural, not substring).
|
|
64
|
+
if (!line.startsWith(PREFIX)) {
|
|
65
|
+
return { ok: false, error: "missing-prefix" };
|
|
66
|
+
}
|
|
67
|
+
// Must END with the trailing period.
|
|
68
|
+
if (!line.endsWith(SUFFIX)) {
|
|
69
|
+
return { ok: false, error: "missing-trailing-period" };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
// ref ... " — supersedes shipped " ... <what>
|
|
73
|
+
const afterPrefix = line.slice(PREFIX.length);
|
|
74
|
+
const supIdx = afterPrefix.indexOf(SEP_SUPERSEDES);
|
|
75
|
+
if (supIdx === -1) {
|
|
76
|
+
return { ok: false, error: "missing-supersedes-clause" };
|
|
77
|
+
}
|
|
78
|
+
const ref = afterPrefix.slice(0, supIdx);
|
|
79
|
+
const afterSup = afterPrefix.slice(supIdx + SEP_SUPERSEDES.length);
|
|
80
|
+
|
|
81
|
+
// <what> ". Reason: " <reason> "."
|
|
82
|
+
// Use the LAST ". Reason: " as the boundary so a <what> that itself contains
|
|
83
|
+
// ". Reason: " can never steal the reason field (round-trip stays exact).
|
|
84
|
+
const reasonIdx = afterSup.lastIndexOf(SEP_REASON);
|
|
85
|
+
if (reasonIdx === -1) {
|
|
86
|
+
return { ok: false, error: "missing-reason-clause" };
|
|
87
|
+
}
|
|
88
|
+
const supersedes = afterSup.slice(0, reasonIdx);
|
|
89
|
+
// reason is everything between ". Reason: " and the final ".", which we have
|
|
90
|
+
// already confirmed via endsWith(SUFFIX).
|
|
91
|
+
const reasonWithDot = afterSup.slice(reasonIdx + SEP_REASON.length);
|
|
92
|
+
const reason = reasonWithDot.slice(0, reasonWithDot.length - SUFFIX.length);
|
|
93
|
+
|
|
94
|
+
// Every field MUST be non-empty (an empty field is a malformed flag).
|
|
95
|
+
if (ref.length === 0) return { ok: false, error: "empty-ref" };
|
|
96
|
+
if (supersedes.length === 0) return { ok: false, error: "empty-supersedes" };
|
|
97
|
+
if (reason.length === 0) return { ok: false, error: "empty-reason" };
|
|
98
|
+
|
|
99
|
+
return { ok: true, value: { ref, supersedes, reason } };
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Format a {ref, supersedes, reason} triple back into the canonical §4 line.
|
|
104
|
+
*
|
|
105
|
+
* @param {{ref: string, supersedes: string, reason: string}} obj
|
|
106
|
+
* @returns {{ok: true, value: string} | {ok: false, error: string}} never throws.
|
|
107
|
+
*/
|
|
108
|
+
function formatDivergence(obj) {
|
|
109
|
+
if (obj === null || typeof obj !== "object") {
|
|
110
|
+
return { ok: false, error: "not-an-object" };
|
|
111
|
+
}
|
|
112
|
+
const { ref, supersedes, reason } = obj;
|
|
113
|
+
if (typeof ref !== "string" || ref.length === 0) {
|
|
114
|
+
return { ok: false, error: "empty-ref" };
|
|
115
|
+
}
|
|
116
|
+
if (typeof supersedes !== "string" || supersedes.length === 0) {
|
|
117
|
+
return { ok: false, error: "empty-supersedes" };
|
|
118
|
+
}
|
|
119
|
+
if (typeof reason !== "string" || reason.length === 0) {
|
|
120
|
+
return { ok: false, error: "empty-reason" };
|
|
121
|
+
}
|
|
122
|
+
const value = PREFIX + ref + SEP_SUPERSEDES + supersedes + SEP_REASON + reason + SUFFIX;
|
|
123
|
+
return { ok: true, value };
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Count the VALID `⚠ Divergence` flags in a document.
|
|
128
|
+
*
|
|
129
|
+
* Structural: a line is counted ONLY if it PARSES as a well-formed §4 flag — a
|
|
130
|
+
* line that merely contains the substring "⚠ Divergence" but is malformed is NOT
|
|
131
|
+
* counted (feedback_coverage_check_structural_not_substring). The returned count
|
|
132
|
+
* is a deterministic integer (the checkable artifact that can feed D1's rule map).
|
|
133
|
+
*
|
|
134
|
+
* @param {string} docText
|
|
135
|
+
* @returns {number} count of valid flags (0 on non-string input — never throws).
|
|
136
|
+
*/
|
|
137
|
+
function countDivergences(docText) {
|
|
138
|
+
if (typeof docText !== "string") return 0;
|
|
139
|
+
// Split on any newline style; trailing/leading whitespace on a line is slop,
|
|
140
|
+
// so a flag line with surrounding whitespace will fail the anchored parse and
|
|
141
|
+
// not be counted (matches the byte-stable round-trip invariant).
|
|
142
|
+
const lines = docText.split(/\r\n|\r|\n/);
|
|
143
|
+
let count = 0;
|
|
144
|
+
for (const line of lines) {
|
|
145
|
+
if (parseDivergence(line).ok) count++;
|
|
146
|
+
}
|
|
147
|
+
return count;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --------------------------------------------------------------------------
|
|
151
|
+
// CLI wrapper (the only I/O; the three functions above are pure).
|
|
152
|
+
// --------------------------------------------------------------------------
|
|
153
|
+
|
|
154
|
+
const HELP = [
|
|
155
|
+
"gsd-t-divergence-grammar — §4 ⚠ Divergence flag round-trip + count",
|
|
156
|
+
"",
|
|
157
|
+
"Usage:",
|
|
158
|
+
' gsd-t-divergence-grammar --parse "<line>" --json',
|
|
159
|
+
" gsd-t-divergence-grammar --count <docPath> --json",
|
|
160
|
+
"",
|
|
161
|
+
"Exit: 0 ok · 4 parse failure / count error · 64 bad input.",
|
|
162
|
+
].join("\n");
|
|
163
|
+
|
|
164
|
+
function parseArgs(argv) {
|
|
165
|
+
const o = { json: false, help: false, parse: null, count: null };
|
|
166
|
+
for (let i = 0; i < argv.length; i++) {
|
|
167
|
+
const a = argv[i];
|
|
168
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
169
|
+
else if (a === "--json") o.json = true;
|
|
170
|
+
else if (a === "--parse") o.parse = argv[++i];
|
|
171
|
+
else if (a === "--count") o.count = argv[++i];
|
|
172
|
+
}
|
|
173
|
+
return o;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
function main() {
|
|
177
|
+
// Lazy require of fs ONLY inside the CLI path — the module export surface
|
|
178
|
+
// stays pure/dep-free.
|
|
179
|
+
const o = parseArgs(process.argv.slice(2));
|
|
180
|
+
if (o.help) {
|
|
181
|
+
process.stdout.write(HELP + "\n");
|
|
182
|
+
process.exit(0);
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (o.parse != null) {
|
|
186
|
+
const res = parseDivergence(o.parse);
|
|
187
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
188
|
+
process.exit(res.ok ? 0 : 4);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
if (o.count != null) {
|
|
192
|
+
let text;
|
|
193
|
+
try {
|
|
194
|
+
text = require("fs").readFileSync(o.count, "utf8");
|
|
195
|
+
} catch (e) {
|
|
196
|
+
process.stdout.write(
|
|
197
|
+
JSON.stringify({ ok: false, error: "read-failed", path: o.count }, null, 2) + "\n"
|
|
198
|
+
);
|
|
199
|
+
process.exit(4);
|
|
200
|
+
}
|
|
201
|
+
const count = countDivergences(text);
|
|
202
|
+
// The count is a CHECKABLE JSON integer artifact, not prose.
|
|
203
|
+
process.stdout.write(JSON.stringify({ ok: true, count }, null, 2) + "\n");
|
|
204
|
+
process.exit(0);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
process.stdout.write(JSON.stringify({ ok: false, error: "no-mode" }, null, 2) + "\n");
|
|
208
|
+
process.exit(64);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
if (require.main === module) main();
|
|
212
|
+
|
|
213
|
+
module.exports = { parseDivergence, formatDivergence, countDivergences };
|
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-guard-map-derive — M88 G2 (map-derivation-seam)
|
|
5
|
+
*
|
|
6
|
+
* The mechanical build→rule-map DERIVATION seam. Where M87 D1
|
|
7
|
+
* (`bin/gsd-t-guard-map.cjs`) GATES a build→rule map, this module DERIVES that
|
|
8
|
+
* map from two inputs — instead of hand-authoring it:
|
|
9
|
+
*
|
|
10
|
+
* 1. a PseudoCode doc → the doc's derived RULE-ID set (via D1's parser,
|
|
11
|
+
* IMPORTED, never re-implemented — single source of truth for the grammar).
|
|
12
|
+
* 2. a build-evidence manifest → which test assertions / files back (or
|
|
13
|
+
* contradict) which RULE-ID(s).
|
|
14
|
+
*
|
|
15
|
+
* Output: `{ rules: { <id>: { backedBy:[<refs>], contradicted:bool } } }`
|
|
16
|
+
* keyed EXACTLY to the doc's derived RULE-ID set — EVERY doc rule present.
|
|
17
|
+
* A rule with no evidence → `backedBy:[]` (PRESENT, unbacked) — NOT an omitted
|
|
18
|
+
* key. Omitting it would re-open the map-side vacuous pass D1's gate closed
|
|
19
|
+
* (the gate iterates the DOC's id set; an absent key is treated as unbacked, but
|
|
20
|
+
* a faithful derivation must still emit every doc rule so the map is a complete,
|
|
21
|
+
* gate-ready artifact).
|
|
22
|
+
*
|
|
23
|
+
* Evidence manifest shape (the convention this seam defines):
|
|
24
|
+
* {
|
|
25
|
+
* "evidence": [
|
|
26
|
+
* { "ref": "test/foo.test.js:42", "backs": ["R-PAYPAL-03", "R-PAYPAL-05"] },
|
|
27
|
+
* { "ref": "test/bar.test.js:7", "contradicts": ["R-PAYPAL-07"] }
|
|
28
|
+
* ]
|
|
29
|
+
* }
|
|
30
|
+
* - `ref` (string, required): a stable evidence reference (file:line, file::name, …).
|
|
31
|
+
* - `backs` (string[], optional): RULE-IDs this evidence BACKS.
|
|
32
|
+
* - `contradicts` (string[], optional): RULE-IDs this evidence CONTRADICTS
|
|
33
|
+
* (build/test asserts the negation of the invariant). Any contradiction on a
|
|
34
|
+
* rule sets its `contradicted:true` in the derived map.
|
|
35
|
+
* An evidence entry that references a RULE-ID NOT in the doc's derived set is
|
|
36
|
+
* IGNORED (the doc is the source of truth; phantom refs never invent map keys).
|
|
37
|
+
*
|
|
38
|
+
* Hard engineering bar (mirror D1 + M83): zero deps (Node built-ins only), never
|
|
39
|
+
* throws (bad input → exitCode 64, never an uncaught throw), pure/read-only
|
|
40
|
+
* (writes nothing). Deterministic — same doc bytes + same manifest → same map.
|
|
41
|
+
*
|
|
42
|
+
* Input: --doc <PseudoCode-[Title].md> --evidence <manifest.json> [--json]
|
|
43
|
+
* Output: the derived map JSON ({ rules: {...} }) on stdout.
|
|
44
|
+
* Exit: 0 derivation succeeded · 64 bad input (unreadable doc, bad manifest,
|
|
45
|
+
* doc parse failure).
|
|
46
|
+
*/
|
|
47
|
+
|
|
48
|
+
const fs = require("node:fs");
|
|
49
|
+
|
|
50
|
+
// IMPORT D1's parser + RULE-ID derivation — the single source of truth for the
|
|
51
|
+
// `[RULE]` grammar. We consume `parseRules(md, docPath)`; we do NOT re-implement
|
|
52
|
+
// the grammar (a re-impl would drift from the gate that reads our output).
|
|
53
|
+
const { parseRules } = require("./gsd-t-guard-map.cjs");
|
|
54
|
+
|
|
55
|
+
// ─── derivation (pure) ─────────────────────────────────────────────────────
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Derive the build→rule map from a doc's derived RULE-ID set + an evidence
|
|
59
|
+
* manifest. Doc-keyed: EVERY doc rule is emitted; an unbacked rule is
|
|
60
|
+
* `backedBy:[]` present. Phantom evidence refs (RULE-IDs not in the doc) are
|
|
61
|
+
* ignored. Pure — no I/O, never throws.
|
|
62
|
+
*
|
|
63
|
+
* @param {Array<{id:string}>} rules - parseRules().rules (doc-derived id set)
|
|
64
|
+
* @param {{evidence?: Array<{ref?:string, backs?:string[], contradicts?:string[]}>}} manifest
|
|
65
|
+
* @returns {{ rules: Object<string, {backedBy:string[], contradicted:boolean}> }}
|
|
66
|
+
*/
|
|
67
|
+
function deriveMap(rules, manifest) {
|
|
68
|
+
// Seed every doc-derived id with an empty, unbacked, not-contradicted entry.
|
|
69
|
+
// Iterating the DOC's id set (not the manifest) is what preserves doc-keyed
|
|
70
|
+
// non-vacuity: a rule with zero evidence is PRESENT as backedBy:[].
|
|
71
|
+
const out = { rules: {} };
|
|
72
|
+
const docIds = new Set();
|
|
73
|
+
for (const r of Array.isArray(rules) ? rules : []) {
|
|
74
|
+
if (r && typeof r.id === "string") {
|
|
75
|
+
docIds.add(r.id);
|
|
76
|
+
if (!out.rules[r.id]) out.rules[r.id] = { backedBy: [], contradicted: false };
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const evidence = (manifest && Array.isArray(manifest.evidence)) ? manifest.evidence : [];
|
|
81
|
+
for (const item of evidence) {
|
|
82
|
+
if (!item || typeof item !== "object") continue;
|
|
83
|
+
const ref = typeof item.ref === "string" ? item.ref : null;
|
|
84
|
+
const backs = Array.isArray(item.backs) ? item.backs : [];
|
|
85
|
+
const contradicts = Array.isArray(item.contradicts) ? item.contradicts : [];
|
|
86
|
+
|
|
87
|
+
for (const id of backs) {
|
|
88
|
+
// Only attach to a DOC-derived id (doc is the source of truth; phantom
|
|
89
|
+
// ids never invent a key). A backing without a `ref` is meaningless — skip.
|
|
90
|
+
if (typeof id === "string" && docIds.has(id) && ref) {
|
|
91
|
+
const entry = out.rules[id];
|
|
92
|
+
if (!entry.backedBy.includes(ref)) entry.backedBy.push(ref);
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
for (const id of contradicts) {
|
|
96
|
+
if (typeof id === "string" && docIds.has(id)) {
|
|
97
|
+
out.rules[id].contradicted = true;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
return out;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
// ─── driver ────────────────────────────────────────────────────────────────
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Run the derivation over a doc + evidence manifest. Never throws — bad input
|
|
109
|
+
* returns an envelope with exitCode 64. On success, `map` is the derived
|
|
110
|
+
* build→rule map (gate-ready, consumable by gsd-t-guard-map.cjs unchanged).
|
|
111
|
+
*
|
|
112
|
+
* @param {{doc?:string, evidence?:string}} o
|
|
113
|
+
* @returns {{ ok:boolean, exitCode:number, map?:object, derivedIds?:string[], ... }}
|
|
114
|
+
*/
|
|
115
|
+
function runDerive(o) {
|
|
116
|
+
const { doc, evidence } = (o && typeof o === "object") ? o : {};
|
|
117
|
+
if (!doc || !evidence || typeof doc !== "string" || typeof evidence !== "string") {
|
|
118
|
+
return { ok: false, exitCode: 64, reason: "missing --doc and/or --evidence", doc: doc || null, evidence: evidence || null };
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
let md;
|
|
122
|
+
try {
|
|
123
|
+
md = fs.readFileSync(doc, "utf8");
|
|
124
|
+
} catch (e) {
|
|
125
|
+
return { ok: false, exitCode: 64, reason: `cannot read doc: ${e && e.message}`, doc };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
let manifest;
|
|
129
|
+
try {
|
|
130
|
+
manifest = JSON.parse(fs.readFileSync(evidence, "utf8"));
|
|
131
|
+
} catch (e) {
|
|
132
|
+
return { ok: false, exitCode: 64, reason: `cannot read/parse evidence JSON: ${e && e.message}`, evidence };
|
|
133
|
+
}
|
|
134
|
+
if (!manifest || typeof manifest !== "object" || Array.isArray(manifest)) {
|
|
135
|
+
return { ok: false, exitCode: 64, reason: "evidence JSON must be an object with an `evidence` array", evidence };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
const { rules, parseErrors } = parseRules(md, doc);
|
|
139
|
+
|
|
140
|
+
// A doc parse failure (empty-invariant rule) is bad input — the same severity
|
|
141
|
+
// D1's gate assigns it. We cannot derive a faithful map from a malformed doc.
|
|
142
|
+
if (parseErrors.length > 0) {
|
|
143
|
+
return {
|
|
144
|
+
ok: false,
|
|
145
|
+
exitCode: 64,
|
|
146
|
+
reason: `doc parse failure: ${parseErrors.length} rule line(s) yielded an empty invariant`,
|
|
147
|
+
parseErrors,
|
|
148
|
+
doc,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const map = deriveMap(rules, manifest);
|
|
153
|
+
const derivedIds = rules.map((r) => r.id);
|
|
154
|
+
|
|
155
|
+
return { ok: true, exitCode: 0, doc, evidence, derivedIds, ruleCount: derivedIds.length, map };
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────
|
|
159
|
+
|
|
160
|
+
function parseArgs(argv) {
|
|
161
|
+
const o = { doc: null, evidence: null, help: false };
|
|
162
|
+
for (let i = 0; i < argv.length; i++) {
|
|
163
|
+
const a = argv[i];
|
|
164
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
165
|
+
else if (a === "--doc") o.doc = argv[++i];
|
|
166
|
+
else if (a === "--evidence") o.evidence = argv[++i];
|
|
167
|
+
else if (a === "--json") {/* default output is JSON */}
|
|
168
|
+
}
|
|
169
|
+
return o;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
const HELP = `Usage: gsd-t guard-map-derive --doc <PseudoCode-[Title].md> --evidence <manifest.json> [--json]
|
|
173
|
+
|
|
174
|
+
The M88 build→rule-map DERIVATION seam. Parses every [RULE] from a PseudoCode
|
|
175
|
+
doc (via the M87 guard-map parser — single source of truth), then DERIVES the
|
|
176
|
+
build→rule map gsd-t-guard-map.cjs gates, attaching each evidence ref to the
|
|
177
|
+
RULE-ID(s) it backs/contradicts. The map is keyed EXACTLY to the doc's derived
|
|
178
|
+
id set — every doc rule present; an unbacked rule is backedBy:[] (never omitted).
|
|
179
|
+
The derived map prints to stdout and is valid --map input to the gate unchanged.
|
|
180
|
+
|
|
181
|
+
--doc PATH the PseudoCode-[Title].md guard-map doc.
|
|
182
|
+
--evidence PATH the build-evidence manifest:
|
|
183
|
+
{ "evidence": [ { "ref": "test/x.test.js:42",
|
|
184
|
+
"backs": ["R-FOO-01"],
|
|
185
|
+
"contradicts": ["R-FOO-02"] } ] }
|
|
186
|
+
|
|
187
|
+
Exit: 0 derivation succeeded (map on stdout) · 64 bad input (unreadable doc,
|
|
188
|
+
bad manifest, or doc parse failure).`;
|
|
189
|
+
|
|
190
|
+
function main() {
|
|
191
|
+
const o = parseArgs(process.argv.slice(2));
|
|
192
|
+
if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
|
|
193
|
+
let res;
|
|
194
|
+
try {
|
|
195
|
+
res = runDerive(o);
|
|
196
|
+
} catch (e) {
|
|
197
|
+
// Defense in depth — runDerive is written never to throw, but the contract
|
|
198
|
+
// mandates the module never throws, so bad input maps to 64 even here.
|
|
199
|
+
res = { ok: false, exitCode: 64, reason: `derive-error: ${e && e.message}` };
|
|
200
|
+
}
|
|
201
|
+
// On success print the derived MAP (the artifact); on failure print the
|
|
202
|
+
// diagnostic envelope. Either way, valid JSON to stdout.
|
|
203
|
+
const payload = res.ok ? res.map : res;
|
|
204
|
+
process.stdout.write(JSON.stringify(payload, null, 2) + "\n");
|
|
205
|
+
process.exit(res.exitCode);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (require.main === module) main();
|
|
209
|
+
|
|
210
|
+
module.exports = { runDerive, deriveMap };
|