@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.
@@ -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
+ };