@tekyzinc/gsd-t 4.8.10 → 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 +26 -0
- package/README.md +1 -1
- package/bin/gsd-t-architectural-trigger.cjs +80 -5
- package/bin/gsd-t-jargon-lint.cjs +363 -0
- package/bin/gsd-t-shrink-metric.cjs +255 -0
- package/bin/gsd-t.js +53 -0
- package/commands/gsd-t-milestone.md +6 -0
- 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 +16 -0
- package/templates/prompts/blind-adversary-subagent.md +4 -0
- package/templates/prompts/pre-mortem-subagent.md +4 -0
- package/templates/prompts/qa-subagent.md +4 -0
- package/templates/prompts/red-team-subagent.md +4 -0
- package/templates/workflows/gsd-t-execute.workflow.js +7 -1
- package/templates/workflows/gsd-t-quick.workflow.js +16 -2
- package/templates/workflows/gsd-t-verify.workflow.js +82 -1
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,32 @@
|
|
|
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
|
+
|
|
5
31
|
## [4.8.10] - 2026-06-22 (M91 — PseudoCode Source-of-Truth, merged M87+M88 — minor)
|
|
6
32
|
|
|
7
33
|
### Added — the intention-first PseudoCode behavior map becomes the milestone source-of-truth
|
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,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
|
+
};
|