@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
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-guard-map — M87 D1 (guard-bridge-spike)
|
|
5
|
+
*
|
|
6
|
+
* The A1 kill-criterion gate. Turns a prose `[RULE]` guard map in a
|
|
7
|
+
* `PseudoCode-[Title].md` doc into a machine-checkable verify gate where a
|
|
8
|
+
* divergence (an UNBACKED or CONTRADICTED rule) is a DETERMINISTIC,
|
|
9
|
+
* non-vacuous FAILURE — zero LLM judgment in the pass/fail decision.
|
|
10
|
+
*
|
|
11
|
+
* Contract: .gsd-t/contracts/pseudocode-source-of-truth-contract.md §2 (the
|
|
12
|
+
* `[RULE]` guard-map grammar + RULE-ID derivation) + §7 (discovery convention).
|
|
13
|
+
*
|
|
14
|
+
* Grammar (§2, reconciled to the real binvoice corpus):
|
|
15
|
+
* A rule = any line CONTAINING the `[RULE …]` marker (matched ANYWHERE on the
|
|
16
|
+
* line, NOT `^`-anchored — the corpus places guard prose LEFT of the marker).
|
|
17
|
+
* Three accepted marker forms:
|
|
18
|
+
* ... [RULE] <RULE-ID>: <invariant> # explicit id, invariant RIGHT
|
|
19
|
+
* GATE: ... → 409 [RULE] <invariant> # loose, invariant RIGHT (PayPal style)
|
|
20
|
+
* <invariant prose> ... [RULE — <tag>] # tagged, invariant LEFT (Extension style)
|
|
21
|
+
* Invariant capture is SIDE-AGNOSTIC (§2 v1.1.3): RIGHT-of-`]` if non-empty,
|
|
22
|
+
* ELSE the LEFT-of-marker prose. An EMPTY resolved invariant = parse FAILURE.
|
|
23
|
+
* Rule id: explicit `<RULE-ID>:` if present, else DERIVE `R-<DOC-SLUG>-<NN>`
|
|
24
|
+
* by 1-based appearance order (pure — same doc bytes → same ids).
|
|
25
|
+
*
|
|
26
|
+
* Gate (DOC-keyed, §2 "Non-vacuity on the MAP side"): iterate the DOC's derived
|
|
27
|
+
* RULE-ID set as the source of truth. For each doc rule:
|
|
28
|
+
* - map entry present + backedBy non-empty + !contradicted → backed (passes)
|
|
29
|
+
* - map entry unbacked (backedBy []) OR contradicted → FAIL (exit 4)
|
|
30
|
+
* - map key ABSENT entirely → UNBACKED → FAIL (exit 4)
|
|
31
|
+
* An incomplete map that simply omits a doc rule can NEVER pass vacuously.
|
|
32
|
+
*
|
|
33
|
+
* Hard engineering bar (mirror M83 traceability-gate): zero deps (Node built-ins
|
|
34
|
+
* only), never throws (bad input → exitCode 64, never an uncaught throw),
|
|
35
|
+
* pure/read-only (writes nothing). Deterministic pass/fail — ZERO LLM judgment.
|
|
36
|
+
*
|
|
37
|
+
* Input: --doc <path> --map <path> [--json]
|
|
38
|
+
* Output: JSON envelope { ok, exitCode, ... }.
|
|
39
|
+
* Exit: 0 every rule backed, none contradicted · 4 ≥1 unbacked/contradicted
|
|
40
|
+
* (names the RULE-ID) · 64 bad input.
|
|
41
|
+
*/
|
|
42
|
+
|
|
43
|
+
const fs = require("node:fs");
|
|
44
|
+
const path = require("node:path");
|
|
45
|
+
|
|
46
|
+
// ─── RULE marker grammar (§2) ─────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
// The marker is `[RULE]` or `[RULE — <tag>]`. Matched ANYWHERE on the line
|
|
49
|
+
// (not `^`-anchored). Path-as-marker, never a bare "RULE" substring.
|
|
50
|
+
const MARKER_RE = /\[RULE(\s*—\s*[^\]]*)?\]/;
|
|
51
|
+
|
|
52
|
+
// An explicit id form: directly after `]`, `<RULE-ID>: <invariant>` where the
|
|
53
|
+
// id is an R-prefixed token (e.g. `R-PAYPAL-01`). Captured for id resolution.
|
|
54
|
+
const EXPLICIT_ID_RE = /^\s*(R-[A-Z0-9][A-Z0-9-]*)\s*:\s*(.*)$/i;
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Derive the DOC-SLUG from a doc filename: `PseudoCode-[Title].md` → uppercased
|
|
58
|
+
* `[Title]` with non-alphanumerics collapsed (PAYPAL, EXTENSION). Pure.
|
|
59
|
+
* @param {string} docPath
|
|
60
|
+
* @returns {string}
|
|
61
|
+
*/
|
|
62
|
+
function docSlug(docPath) {
|
|
63
|
+
const base = path.basename(String(docPath || ""), ".md");
|
|
64
|
+
// Strip a leading "PseudoCode-" (case-insensitive) if present, else use whole base.
|
|
65
|
+
let title = base.replace(/^pseudocode-/i, "");
|
|
66
|
+
// Strip a trailing harness-variant suffix (e.g. `-doctored`) so a doctored COPY of a
|
|
67
|
+
// subject derives the SAME DOC-SLUG as the faithful subject — the derived RULE-IDs must
|
|
68
|
+
// stay STABLE between faithful and doctored runs (§2; the divergence lives only in the
|
|
69
|
+
// map, never the doc text or its ids). `-doctored` is a test-harness variant of the same
|
|
70
|
+
// subject (PayPal), not a distinct subject Title.
|
|
71
|
+
title = title.replace(/-doctored$/i, "");
|
|
72
|
+
return title.replace(/[^a-z0-9]+/gi, "").toUpperCase() || "DOC";
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Parse every `[RULE]` from a doc's text per §2.
|
|
77
|
+
* Side-agnostic invariant capture, deterministic id resolution.
|
|
78
|
+
* @param {string} md - the doc text
|
|
79
|
+
* @param {string} docPath - used to derive the DOC-SLUG
|
|
80
|
+
* @returns {{ rules: Array<{id, invariant, tag, explicit, line}>, parseErrors: Array<{line, raw}> }}
|
|
81
|
+
*/
|
|
82
|
+
function parseRules(md, docPath) {
|
|
83
|
+
const slug = docSlug(docPath);
|
|
84
|
+
const lines = String(md == null ? "" : md).split(/\r?\n/);
|
|
85
|
+
const rules = [];
|
|
86
|
+
const parseErrors = [];
|
|
87
|
+
let n = 0; // 1-based appearance order, only incremented for real markers
|
|
88
|
+
|
|
89
|
+
for (let i = 0; i < lines.length; i++) {
|
|
90
|
+
const line = lines[i];
|
|
91
|
+
const m = line.match(MARKER_RE);
|
|
92
|
+
if (!m) continue;
|
|
93
|
+
|
|
94
|
+
n += 1;
|
|
95
|
+
const markerStart = m.index;
|
|
96
|
+
const markerEnd = m.index + m[0].length;
|
|
97
|
+
const tag = m[1] ? m[1].replace(/^\s*—\s*/, "").trim() : null;
|
|
98
|
+
|
|
99
|
+
const leftText = line.slice(0, markerStart).trim();
|
|
100
|
+
const rightRaw = line.slice(markerEnd).trim();
|
|
101
|
+
|
|
102
|
+
// Explicit id form: the RIGHT side begins `<RULE-ID>: <invariant>`.
|
|
103
|
+
let id = `R-${slug}-${String(n).padStart(2, "0")}`;
|
|
104
|
+
let explicit = false;
|
|
105
|
+
let rightInvariant = rightRaw;
|
|
106
|
+
const idm = rightRaw.match(EXPLICIT_ID_RE);
|
|
107
|
+
if (idm) {
|
|
108
|
+
id = idm[1].toUpperCase();
|
|
109
|
+
explicit = true;
|
|
110
|
+
rightInvariant = idm[2].trim();
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// Side-agnostic capture: RIGHT-of-`]` if non-empty, else LEFT-of-marker prose.
|
|
114
|
+
const invariant = (rightInvariant && rightInvariant.length > 0) ? rightInvariant : leftText;
|
|
115
|
+
|
|
116
|
+
if (!invariant || invariant.length === 0) {
|
|
117
|
+
// A rule whose resolved invariant is empty is a PARSE FAILURE (§2).
|
|
118
|
+
parseErrors.push({ line: i + 1, raw: line.trim() });
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
rules.push({ id, invariant, tag, explicit, line: i + 1 });
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
return { rules, parseErrors };
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── gate (DOC-keyed; §2 non-vacuity on the map side) ──────────────────────
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Gate a parsed doc against a build→rule map. Iterates the DOC's derived
|
|
132
|
+
* RULE-ID set (NOT the map's keyset). A doc-derived id absent from the map is
|
|
133
|
+
* treated as UNBACKED.
|
|
134
|
+
* @param {Array} rules - parseRules().rules
|
|
135
|
+
* @param {object} map - { rules: { <id>: { backedBy:[...], contradicted:bool } } }
|
|
136
|
+
* @returns {{ violations: Array<{id, kind, detail}>, checked: number }}
|
|
137
|
+
*/
|
|
138
|
+
function gateRules(rules, map) {
|
|
139
|
+
const mapRules = (map && map.rules && typeof map.rules === "object") ? map.rules : {};
|
|
140
|
+
const violations = [];
|
|
141
|
+
for (const r of rules) {
|
|
142
|
+
const entry = mapRules[r.id];
|
|
143
|
+
if (entry === undefined || entry === null) {
|
|
144
|
+
// Key absent entirely → UNBACKED (doc-keyed iteration; no map-side vacuous pass).
|
|
145
|
+
violations.push({
|
|
146
|
+
id: r.id,
|
|
147
|
+
kind: "unbacked",
|
|
148
|
+
detail: `RULE ${r.id} ("${r.invariant}") has NO entry in the build→rule map — UNBACKED (a doc-derived rule absent from the map is unbacked, never a vacuous pass).`,
|
|
149
|
+
});
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const backedBy = Array.isArray(entry.backedBy) ? entry.backedBy : [];
|
|
153
|
+
const contradicted = entry.contradicted === true;
|
|
154
|
+
if (contradicted) {
|
|
155
|
+
violations.push({
|
|
156
|
+
id: r.id,
|
|
157
|
+
kind: "contradicted",
|
|
158
|
+
detail: `RULE ${r.id} ("${r.invariant}") is CONTRADICTED — build/test evidence asserts the negation of the invariant (contract-breach divergence).`,
|
|
159
|
+
});
|
|
160
|
+
} else if (backedBy.length === 0) {
|
|
161
|
+
violations.push({
|
|
162
|
+
id: r.id,
|
|
163
|
+
kind: "unbacked",
|
|
164
|
+
detail: `RULE ${r.id} ("${r.invariant}") is UNBACKED — no test assertion references it (backedBy is empty).`,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
return { violations, checked: rules.length };
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
// ─── driver ────────────────────────────────────────────────────────────────
|
|
172
|
+
|
|
173
|
+
function readJson(p) {
|
|
174
|
+
const txt = fs.readFileSync(p, "utf8");
|
|
175
|
+
return JSON.parse(txt);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Run the gate over a doc + map. Never throws — bad input returns exitCode 64.
|
|
180
|
+
* @param {{ doc, map }} o
|
|
181
|
+
* @returns {{ ok, exitCode, ... }}
|
|
182
|
+
*/
|
|
183
|
+
function runGate(o) {
|
|
184
|
+
const { doc, map } = (o && typeof o === "object") ? o : {};
|
|
185
|
+
if (!doc || !map || typeof doc !== "string" || typeof map !== "string") {
|
|
186
|
+
return { ok: false, exitCode: 64, reason: "missing --doc and/or --map", doc: doc || null, map: map || null };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
let md;
|
|
190
|
+
try {
|
|
191
|
+
md = fs.readFileSync(doc, "utf8");
|
|
192
|
+
} catch (e) {
|
|
193
|
+
return { ok: false, exitCode: 64, reason: `cannot read doc: ${e && e.message}`, doc };
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
let mapObj;
|
|
197
|
+
try {
|
|
198
|
+
mapObj = readJson(map);
|
|
199
|
+
} catch (e) {
|
|
200
|
+
return { ok: false, exitCode: 64, reason: `cannot read/parse map JSON: ${e && e.message}`, map };
|
|
201
|
+
}
|
|
202
|
+
if (!mapObj || typeof mapObj !== "object" || mapObj.rules === undefined || typeof mapObj.rules !== "object" || mapObj.rules === null) {
|
|
203
|
+
return { ok: false, exitCode: 64, reason: "map JSON has no `rules` object", map };
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
const { rules, parseErrors } = parseRules(md, doc);
|
|
207
|
+
|
|
208
|
+
// A parse failure (empty-invariant rule) is bad input (the doc violates the grammar).
|
|
209
|
+
if (parseErrors.length > 0) {
|
|
210
|
+
return {
|
|
211
|
+
ok: false,
|
|
212
|
+
exitCode: 64,
|
|
213
|
+
reason: `parse failure: ${parseErrors.length} rule line(s) yielded an empty invariant`,
|
|
214
|
+
parseErrors,
|
|
215
|
+
doc,
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const derivedIds = rules.map((r) => r.id);
|
|
220
|
+
const { violations, checked } = gateRules(rules, mapObj);
|
|
221
|
+
|
|
222
|
+
const ok = violations.length === 0;
|
|
223
|
+
return {
|
|
224
|
+
ok,
|
|
225
|
+
exitCode: ok ? 0 : 4,
|
|
226
|
+
doc,
|
|
227
|
+
map,
|
|
228
|
+
ruleCount: checked,
|
|
229
|
+
derivedIds,
|
|
230
|
+
rules: rules.map((r) => ({ id: r.id, invariant: r.invariant, tag: r.tag, explicit: r.explicit, line: r.line })),
|
|
231
|
+
violations,
|
|
232
|
+
...(ok ? {} : { reason: `${violations.length} unbacked/contradicted rule(s): ${violations.map((v) => v.id).join(", ")}` }),
|
|
233
|
+
};
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────
|
|
237
|
+
|
|
238
|
+
function parseArgs(argv) {
|
|
239
|
+
const o = { doc: null, map: null, help: false };
|
|
240
|
+
for (let i = 0; i < argv.length; i++) {
|
|
241
|
+
const a = argv[i];
|
|
242
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
243
|
+
else if (a === "--doc") o.doc = argv[++i];
|
|
244
|
+
else if (a === "--map") o.map = argv[++i];
|
|
245
|
+
else if (a === "--json") {/* default output is JSON */}
|
|
246
|
+
}
|
|
247
|
+
return o;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
const HELP = `Usage: gsd-t guard-map --doc <PseudoCode-[Title].md> --map <build-map.json> [--json]
|
|
251
|
+
|
|
252
|
+
The M87 guard-map gate (A1). Enumerates every [RULE] from a PseudoCode doc per
|
|
253
|
+
the source-of-truth contract §2, then gates a build→rule map: exit non-zero when
|
|
254
|
+
a doc-derived RULE-ID is UNBACKED (backedBy empty, or key absent from the map) or
|
|
255
|
+
CONTRADICTED, naming the violated RULE-ID. Deterministic, zero LLM judgment.
|
|
256
|
+
|
|
257
|
+
--doc PATH the PseudoCode-[Title].md guard-map doc.
|
|
258
|
+
--map PATH the build→rule map JSON ({ "rules": { "<id>": { backedBy, contradicted } } }).
|
|
259
|
+
|
|
260
|
+
Exit: 0 all backed/none contradicted · 4 ≥1 unbacked/contradicted · 64 bad input.`;
|
|
261
|
+
|
|
262
|
+
function main() {
|
|
263
|
+
const o = parseArgs(process.argv.slice(2));
|
|
264
|
+
if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
|
|
265
|
+
let res;
|
|
266
|
+
try {
|
|
267
|
+
res = runGate(o);
|
|
268
|
+
} catch (e) {
|
|
269
|
+
// Defense in depth — runGate is written never to throw, but the contract
|
|
270
|
+
// mandates the module never throws, so bad input maps to 64 even here.
|
|
271
|
+
res = { ok: false, exitCode: 64, reason: `gate-error: ${e && e.message}` };
|
|
272
|
+
}
|
|
273
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
274
|
+
process.exit(res.exitCode);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (require.main === module) main();
|
|
278
|
+
|
|
279
|
+
module.exports = { runGate, parseRules, gateRules, docSlug, _internal: { MARKER_RE, EXPLICIT_ID_RE } };
|
|
@@ -0,0 +1,363 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* gsd-t-jargon-lint — M93 D3 (the file-surface gate)
|
|
5
|
+
*
|
|
6
|
+
* A deterministic lint for WRITTEN docs — the surface the D1 Stop hook can't reach.
|
|
7
|
+
* Documents (briefs, progress entries, contracts) fill with unglossed high-signal
|
|
8
|
+
* jargon — `S2-M7`, `HC-003`, bare ALL-CAPS acronyms — and the real decision gets
|
|
9
|
+
* buried. This lint FLAGS an unglossed first-occurrence jargon token (with its line)
|
|
10
|
+
* so the author glosses it before commit. It NEVER auto-fixes (auto-glossing is LLM
|
|
11
|
+
* judgment, out of scope) and it NEVER nags: only the FIRST occurrence of each
|
|
12
|
+
* distinct token is checked, so a glossed-then-reused-bare token PASSES.
|
|
13
|
+
*
|
|
14
|
+
* Detection (high-signal tokens):
|
|
15
|
+
* - /\bS2-M\d+\b/ milestone-family code (e.g. S2-M7)
|
|
16
|
+
* - /\bHC-\d+\b/ contract / hard-constraint id (e.g. HC-003)
|
|
17
|
+
* - /\bM\d+(-D\d+(-T\d+)?)?\b/ GSD-T milestone/domain/task code (M93, M93-D3, M93-D3-T1)
|
|
18
|
+
* - /\b[A-Z]{3,}\b/ ALL-CAPS acronym (≥3) NOT in the allowlist
|
|
19
|
+
*
|
|
20
|
+
* A "gloss" for a token's first occurrence is any of:
|
|
21
|
+
* - an adjacent parenthetical `(...)` right after the token
|
|
22
|
+
* - an em-dash clause `— ...` after the token (same sentence)
|
|
23
|
+
* - any parenthetical / em-dash clause elsewhere in the SAME sentence
|
|
24
|
+
* - the acronym's expansion (the N capitalized words whose initials spell it)
|
|
25
|
+
*
|
|
26
|
+
* Conservative by design ([[feedback_coverage_check_structural_not_substring]]):
|
|
27
|
+
* - Skips fenced code blocks (```), and inline-code spans (`...`) — a token in
|
|
28
|
+
* code is a reference, not prose jargon.
|
|
29
|
+
* - Allowlist of common acronyms + an exempt-PATH list (mirrors the date-guard's
|
|
30
|
+
* ALLOWLIST_PATTERNS) so machine-written / archived files are never flagged.
|
|
31
|
+
*
|
|
32
|
+
* Hard engineering bar (mirror gsd-t-shrink-metric.cjs / gsd-t-guard-map.cjs):
|
|
33
|
+
* zero external deps (Node built-ins only), never throws (bad input → exitCode 64,
|
|
34
|
+
* never an uncaught throw), pure detection — ZERO LLM judgment.
|
|
35
|
+
*
|
|
36
|
+
* Exit: 0 clean · 4 unglossed token(s) found (named with line in --json) · 64 bad input.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
const fs = require("node:fs");
|
|
40
|
+
|
|
41
|
+
// ─── allowlist: exempt acronyms (never flagged) ────────────────────────────
|
|
42
|
+
// Conservative — common protocol/format/timezone/English caps so legitimate prose
|
|
43
|
+
// is never false-flagged. Extend only with truly universal acronyms.
|
|
44
|
+
const ACRONYM_ALLOWLIST = new Set([
|
|
45
|
+
"GSD-T", "QA", "CLI", "API", "URL", "DB", "JSON", "JSONL", "NDJSON", "HTML",
|
|
46
|
+
"CSS", "HTTP", "HTTPS", "DTO", "SQL", "PDT", "EST", "UTC", "PST", "AND", "OR",
|
|
47
|
+
"NOT", "TODO", "FIXME", "README", "ID", "OK", "NPM", "CPUA",
|
|
48
|
+
// M93 Red Team MEDIUM: common cloud / hardware / infra acronyms are ordinary
|
|
49
|
+
// prose, not GSD-T jargon — exempt so the lint doesn't false-flag legit docs.
|
|
50
|
+
"AWS", "GCP", "CPU", "GPU", "RAM", "ETL", "SDK", "IDE", "OS", "UI", "UX",
|
|
51
|
+
"CI", "CD", "DNS", "TLS", "SSL", "SSH", "VM", "S3", "EC2", "RDS", "IAM",
|
|
52
|
+
"REST", "RPC", "GRPC", "MCP", "LLM", "PR", "MR", "TTL", "EOL", "EOF", "ASCII",
|
|
53
|
+
"UTF", "YAML", "TOML", "XML", "CSV", "PNG", "JPG", "SVG", "PDF", "DOM", "ENV",
|
|
54
|
+
]);
|
|
55
|
+
|
|
56
|
+
// ─── exempt PATHS (skip entirely — mirror gsd-t-date-guard ALLOWLIST_PATTERNS) ──
|
|
57
|
+
// Machine-written / archived / historically-frozen surfaces are not user-facing
|
|
58
|
+
// decision docs, so they are never linted.
|
|
59
|
+
const EXEMPT_PATH_PATTERNS = [
|
|
60
|
+
/\.gsd-t\/milestones\//,
|
|
61
|
+
/\.gsd-t\/events\//,
|
|
62
|
+
/\.gsd-t\/transcripts\//,
|
|
63
|
+
/node_modules\//,
|
|
64
|
+
/\.git\//,
|
|
65
|
+
/CHANGELOG\.md$/,
|
|
66
|
+
/\.gsd-t\/token-log\.md$/,
|
|
67
|
+
];
|
|
68
|
+
|
|
69
|
+
function isExemptPath(p) {
|
|
70
|
+
if (typeof p !== "string" || p === "") return false;
|
|
71
|
+
return EXEMPT_PATH_PATTERNS.some((re) => re.test(p));
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
// ─── token detection ────────────────────────────────────────────────────────
|
|
75
|
+
// Order matters for classification only (we report the raw matched token regardless).
|
|
76
|
+
const TOKEN_PATTERNS = [
|
|
77
|
+
{ kind: "S2-M", regex: /\bS2-M\d+\b/g },
|
|
78
|
+
{ kind: "HC", regex: /\bHC-\d+\b/g },
|
|
79
|
+
// M93 Red Team MEDIUM: require a GSD-T milestone shape, not a bare `M1`/`M4`
|
|
80
|
+
// (motorway, chip, screw size — ordinary prose). A real milestone code is either
|
|
81
|
+
// 2+ digits (M82+) OR carries a -D/-T domain/task suffix. Bare single-digit M\d
|
|
82
|
+
// is NOT flagged.
|
|
83
|
+
{ kind: "M-code", regex: /\bM(?:\d{2,}|\d+-D\d+(?:-T\d+)?)\b/g },
|
|
84
|
+
// Acronym ≥3 caps, optionally with hyphenated all-caps/digit segments so a
|
|
85
|
+
// hyphenated form (GSD-T) matches WHOLE and is allowlist-checked as a unit — a
|
|
86
|
+
// bare `\b[A-Z]{3,}\b` would catch only "GSD" inside "GSD-T" and false-flag it.
|
|
87
|
+
{ kind: "acronym", regex: /\b[A-Z]{3,}(?:-[A-Z0-9]+)*\b/g },
|
|
88
|
+
];
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Strip inline-code spans (`...`) from a single line so tokens inside them are
|
|
92
|
+
* invisible to detection. Replaces the span (incl. backticks) with spaces of the
|
|
93
|
+
* SAME length to preserve column/line geometry. Pure.
|
|
94
|
+
* @param {string} line
|
|
95
|
+
* @returns {string}
|
|
96
|
+
*/
|
|
97
|
+
function stripInlineCode(line) {
|
|
98
|
+
// Match the shortest backtick-delimited span. Double-backtick spans (`` ` ``) too.
|
|
99
|
+
return line.replace(/(`+)(?:.*?)\1/g, (m) => " ".repeat(m.length));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Find all high-signal token matches on a (code-stripped) line.
|
|
104
|
+
* @param {string} line
|
|
105
|
+
* @returns {Array<{ token:string, index:number, kind:string }>}
|
|
106
|
+
*/
|
|
107
|
+
function findTokens(line) {
|
|
108
|
+
const found = [];
|
|
109
|
+
for (const { kind, regex } of TOKEN_PATTERNS) {
|
|
110
|
+
regex.lastIndex = 0;
|
|
111
|
+
let m;
|
|
112
|
+
while ((m = regex.exec(line)) !== null) {
|
|
113
|
+
const token = m[0];
|
|
114
|
+
if (kind === "acronym" && ACRONYM_ALLOWLIST.has(token)) continue;
|
|
115
|
+
// An acronym match that is part of an allowlisted hyphenated form (GSD-T) is
|
|
116
|
+
// handled by the allowlist holding the full form; a bare "GSD" (≥3) would be
|
|
117
|
+
// flagged, which is intended (bare GSD is unglossed jargon).
|
|
118
|
+
found.push({ token, index: m.index, kind });
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
return found;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── gloss proximity ──────────────────────────────────────────────────────
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Extract the sentence containing a given character index from a line. We treat a
|
|
128
|
+
* line as the gloss scope's outer bound and split on sentence terminators. Pure.
|
|
129
|
+
* @param {string} line
|
|
130
|
+
* @param {number} index
|
|
131
|
+
* @returns {{ sentence:string, relIndex:number }}
|
|
132
|
+
*/
|
|
133
|
+
function sentenceAround(line, index) {
|
|
134
|
+
// Split points: . ! ? followed by whitespace/end. Keep it simple + deterministic.
|
|
135
|
+
let start = 0;
|
|
136
|
+
let end = line.length;
|
|
137
|
+
const term = /[.!?](?=\s|$)/g;
|
|
138
|
+
let m;
|
|
139
|
+
while ((m = term.exec(line)) !== null) {
|
|
140
|
+
const pos = m.index + 1; // char after the terminator
|
|
141
|
+
if (pos <= index) start = pos;
|
|
142
|
+
else { end = pos; break; }
|
|
143
|
+
}
|
|
144
|
+
const sentence = line.slice(start, end);
|
|
145
|
+
return { sentence, relIndex: index - start };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Does the acronym's expansion appear adjacent to the token? An expansion is N
|
|
150
|
+
* Capitalized words whose initials spell the acronym, appearing immediately before
|
|
151
|
+
* the token inside a parenthetical OR immediately before/after the bare token.
|
|
152
|
+
* Heuristic but conservative. Pure.
|
|
153
|
+
* @param {string} sentence
|
|
154
|
+
* @param {string} token the acronym (already known to be ALL-CAPS)
|
|
155
|
+
* @returns {boolean}
|
|
156
|
+
*/
|
|
157
|
+
function hasExpansion(sentence, token) {
|
|
158
|
+
if (!/^[A-Z]+$/.test(token)) return false; // expansion check only for plain acronyms
|
|
159
|
+
const letters = token.split("");
|
|
160
|
+
// Build a regex of N Capitalized words: \bWord\s+Word\s+...\b matching the initials.
|
|
161
|
+
// We scan all runs of >=N capitalized words and check any window spells the acronym.
|
|
162
|
+
const wordRe = /\b([A-Z][a-z]+)\b/g;
|
|
163
|
+
const words = [];
|
|
164
|
+
let m;
|
|
165
|
+
while ((m = wordRe.exec(sentence)) !== null) words.push(m[1]);
|
|
166
|
+
if (words.length < letters.length) return false;
|
|
167
|
+
for (let i = 0; i + letters.length <= words.length; i++) {
|
|
168
|
+
let ok = true;
|
|
169
|
+
for (let j = 0; j < letters.length; j++) {
|
|
170
|
+
if (words[i + j][0] !== letters[j]) { ok = false; break; }
|
|
171
|
+
}
|
|
172
|
+
if (ok) return true;
|
|
173
|
+
}
|
|
174
|
+
return false;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* Is the token's first occurrence glossed? A gloss is:
|
|
179
|
+
* - a parenthetical `(...)` adjacent (immediately following, allowing whitespace/quote)
|
|
180
|
+
* - an em-dash clause after the token in the same sentence
|
|
181
|
+
* - any parenthetical anywhere in the same sentence
|
|
182
|
+
* - the acronym's expansion present in the sentence
|
|
183
|
+
* Pure.
|
|
184
|
+
* @param {string} line the code-stripped line containing the token
|
|
185
|
+
* @param {number} index char index of the token on the line
|
|
186
|
+
* @param {string} token
|
|
187
|
+
* @param {string} kind
|
|
188
|
+
* @returns {boolean}
|
|
189
|
+
*/
|
|
190
|
+
function isGlossed(line, index, token, kind) {
|
|
191
|
+
const { sentence, relIndex } = sentenceAround(line, index);
|
|
192
|
+
const after = sentence.slice(relIndex + token.length);
|
|
193
|
+
|
|
194
|
+
// 1. Adjacent parenthetical immediately after the token (optionally past quotes/space).
|
|
195
|
+
if (/^\s*["'“”]?\s*\([^)]*\)/.test(after)) return true;
|
|
196
|
+
|
|
197
|
+
// 2. Em-dash clause after the token (—, --, or - surrounded by spaces).
|
|
198
|
+
if (/^\s*(—|--|\s-\s)/.test(after)) return true;
|
|
199
|
+
|
|
200
|
+
// 3. Any parenthetical anywhere in the same sentence (glosses the term in context).
|
|
201
|
+
if (/\([^)]*\)/.test(sentence)) return true;
|
|
202
|
+
|
|
203
|
+
// 4. Acronym expansion present in the sentence.
|
|
204
|
+
if (kind === "acronym" && hasExpansion(sentence, token)) return true;
|
|
205
|
+
|
|
206
|
+
return false;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// ─── line scanning with fence awareness ────────────────────────────────────
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Lint a document's text. Returns the list of unglossed first-occurrence tokens.
|
|
213
|
+
* Pure; never throws.
|
|
214
|
+
* @param {string} text
|
|
215
|
+
* @returns {{ unglossed: Array<{ token:string, line:number, kind:string }>, tokensSeen:number }}
|
|
216
|
+
*/
|
|
217
|
+
function lintText(text) {
|
|
218
|
+
const src = String(text == null ? "" : text);
|
|
219
|
+
const lines = src.split(/\r?\n/);
|
|
220
|
+
|
|
221
|
+
const seen = new Set(); // distinct tokens whose first occurrence we've judged
|
|
222
|
+
const unglossed = [];
|
|
223
|
+
let inFence = false;
|
|
224
|
+
let tokensSeen = 0;
|
|
225
|
+
|
|
226
|
+
for (let i = 0; i < lines.length; i++) {
|
|
227
|
+
const raw = lines[i].replace(/\r$/, "");
|
|
228
|
+
|
|
229
|
+
// Fenced code block toggle: a line whose first non-space chars are ``` or ~~~.
|
|
230
|
+
if (/^\s*(```|~~~)/.test(raw)) {
|
|
231
|
+
inFence = !inFence;
|
|
232
|
+
continue;
|
|
233
|
+
}
|
|
234
|
+
if (inFence) continue;
|
|
235
|
+
|
|
236
|
+
const codeStripped = stripInlineCode(raw);
|
|
237
|
+
const tokens = findTokens(codeStripped);
|
|
238
|
+
|
|
239
|
+
for (const t of tokens) {
|
|
240
|
+
tokensSeen++;
|
|
241
|
+
if (seen.has(t.token)) continue; // only the FIRST occurrence is judged — no nag
|
|
242
|
+
seen.add(t.token);
|
|
243
|
+
if (!isGlossed(codeStripped, t.index, t.token, t.kind)) {
|
|
244
|
+
unglossed.push({ token: t.token, line: i + 1, kind: t.kind });
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
return { unglossed, tokensSeen };
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
// ─── driver ─────────────────────────────────────────────────────────────────
|
|
253
|
+
|
|
254
|
+
/**
|
|
255
|
+
* Run the lint over options. Never throws — bad input → exitCode 64.
|
|
256
|
+
* @param {{ file?:string, stdin?:boolean }} o
|
|
257
|
+
* @returns {{ ok, exitCode, ... }}
|
|
258
|
+
*/
|
|
259
|
+
function runLint(o) {
|
|
260
|
+
const opt = (o && typeof o === "object") ? o : {};
|
|
261
|
+
const hasFile = typeof opt.file === "string" && opt.file.length > 0;
|
|
262
|
+
const hasStdin = opt.stdin === true;
|
|
263
|
+
|
|
264
|
+
if (!hasFile && !hasStdin) {
|
|
265
|
+
return { ok: false, exitCode: 64, reason: "need --file <path> OR --stdin" };
|
|
266
|
+
}
|
|
267
|
+
if (hasFile && hasStdin) {
|
|
268
|
+
return { ok: false, exitCode: 64, reason: "give EITHER --file OR --stdin, not both" };
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// Exempt path → skip entirely (clean).
|
|
272
|
+
if (hasFile && isExemptPath(opt.file)) {
|
|
273
|
+
return { ok: true, exitCode: 0, skipped: true, reason: `exempt path: ${opt.file}`, source: `file:${opt.file}`, unglossed: [], count: 0 };
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
let text;
|
|
277
|
+
let source;
|
|
278
|
+
if (hasFile) {
|
|
279
|
+
source = `file:${opt.file}`;
|
|
280
|
+
try {
|
|
281
|
+
text = fs.readFileSync(opt.file, "utf8");
|
|
282
|
+
} catch (e) {
|
|
283
|
+
return { ok: false, exitCode: 64, reason: `cannot read --file input: ${(e && e.message) || "unknown"}`, source };
|
|
284
|
+
}
|
|
285
|
+
} else {
|
|
286
|
+
source = "stdin";
|
|
287
|
+
try {
|
|
288
|
+
text = fs.readFileSync(0, "utf8");
|
|
289
|
+
} catch (e) {
|
|
290
|
+
return { ok: false, exitCode: 64, reason: `cannot read stdin: ${(e && e.message) || "unknown"}`, source };
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
const { unglossed, tokensSeen } = lintText(text);
|
|
295
|
+
return {
|
|
296
|
+
ok: unglossed.length === 0,
|
|
297
|
+
exitCode: unglossed.length === 0 ? 0 : 4,
|
|
298
|
+
source,
|
|
299
|
+
unglossed,
|
|
300
|
+
count: unglossed.length,
|
|
301
|
+
tokensSeen,
|
|
302
|
+
};
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
// ─── CLI ─────────────────────────────────────────────────────────────────
|
|
306
|
+
|
|
307
|
+
function parseArgs(argv) {
|
|
308
|
+
const o = { file: null, stdin: false, help: false };
|
|
309
|
+
for (let i = 0; i < argv.length; i++) {
|
|
310
|
+
const a = argv[i];
|
|
311
|
+
if (a === "--help" || a === "-h") o.help = true;
|
|
312
|
+
else if (a === "--file") o.file = argv[++i];
|
|
313
|
+
else if (a === "--stdin") o.stdin = true;
|
|
314
|
+
else if (a === "--json") {/* default output is JSON */}
|
|
315
|
+
}
|
|
316
|
+
return o;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const HELP = `Usage:
|
|
320
|
+
gsd-t jargon-lint --file <path> [--json]
|
|
321
|
+
gsd-t jargon-lint --stdin [--json]
|
|
322
|
+
|
|
323
|
+
The M93 jargon-gloss lint (D3). Flags an unglossed FIRST-occurrence high-signal
|
|
324
|
+
jargon token in a written doc so it gets glossed before commit. Detects S2-M<n>,
|
|
325
|
+
HC-<n>, M<n>(-D<n>(-T<n>)) codes, and ALL-CAPS acronyms (>=3) not in the allowlist.
|
|
326
|
+
A token is "glossed" if its first occurrence has an adjacent parenthetical, an
|
|
327
|
+
em-dash clause, a same-sentence parenthetical, or the acronym's spelled-out
|
|
328
|
+
expansion. Skips code fences/inline-code spans. FLAGS, never auto-fixes. Conservative
|
|
329
|
+
allowlist + exempt-path list (mirrors the date-guard). Zero LLM judgment.
|
|
330
|
+
|
|
331
|
+
--file PATH lint a markdown/text file.
|
|
332
|
+
--stdin lint text on stdin.
|
|
333
|
+
--json emit the JSON envelope (default output is always JSON).
|
|
334
|
+
|
|
335
|
+
Exit: 0 clean · 4 unglossed token(s) (named with line in JSON) · 64 bad input.`;
|
|
336
|
+
|
|
337
|
+
function main() {
|
|
338
|
+
const o = parseArgs(process.argv.slice(2));
|
|
339
|
+
if (o.help) { process.stdout.write(HELP + "\n"); process.exit(0); }
|
|
340
|
+
let res;
|
|
341
|
+
try {
|
|
342
|
+
res = runLint(o);
|
|
343
|
+
} catch (e) {
|
|
344
|
+
// Defense in depth — runLint is written never to throw; any escape maps to 64.
|
|
345
|
+
res = { ok: false, exitCode: 64, reason: `lint-error: ${e && e.message}` };
|
|
346
|
+
}
|
|
347
|
+
process.stdout.write(JSON.stringify(res, null, 2) + "\n");
|
|
348
|
+
process.exit(res.exitCode);
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
if (require.main === module) main();
|
|
352
|
+
|
|
353
|
+
module.exports = {
|
|
354
|
+
runLint,
|
|
355
|
+
lintText,
|
|
356
|
+
findTokens,
|
|
357
|
+
isGlossed,
|
|
358
|
+
stripInlineCode,
|
|
359
|
+
hasExpansion,
|
|
360
|
+
isExemptPath,
|
|
361
|
+
ACRONYM_ALLOWLIST,
|
|
362
|
+
EXEMPT_PATH_PATTERNS,
|
|
363
|
+
};
|