@tekyzinc/gsd-t 5.8.10 → 5.10.10

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,467 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * gsd-t-rule-mine.cjs
4
+ *
5
+ * M109-D2 — Finds every rule already stated in a project, so David can tick the
6
+ * ones that can never be broken instead of trying to recall them.
7
+ *
8
+ * [RULE] rule-mine-never-invents-a-rule
9
+ * [RULE] rule-mine-shows-where-each-came-from
10
+ * [RULE] rule-mine-keeps-what-it-cannot-rank
11
+ *
12
+ * Six sources, best evidence first. A rule found in three places ranks top —
13
+ * repetition IS the evidence.
14
+ *
15
+ * Every rule carries where it came from. That column is what makes the list
16
+ * fast to read: David is confirming something he already said, not recalling it.
17
+ *
18
+ * ─── Usage ──────────────────────────────────────────────────────────────────
19
+ * node gsd-t-rule-mine.cjs --project <dir> [--json] [--top N]
20
+ *
21
+ * ─── Exit codes ─────────────────────────────────────────────────────────────
22
+ * 0 found rules
23
+ * 4 found none — the caller must ask, never write a rules section anyway
24
+ * 64 a source exists but could not be read
25
+ *
26
+ * Zero dependencies.
27
+ */
28
+
29
+ "use strict";
30
+
31
+ const fs = require("fs");
32
+ const path = require("path");
33
+ const { execFileSync } = require("child_process");
34
+
35
+ const EXIT_OK = 0;
36
+ const EXIT_NONE = 4;
37
+ const EXIT_UNREADABLE = 64;
38
+
39
+ const DEFAULT_TOP = 12; // more than this on screen has already failed
40
+
41
+ /** Thrown when a source exists but cannot be read. Never treated as absent. */
42
+ class Unreadable extends Error {}
43
+
44
+ /**
45
+ * Lines that state a rule. Deliberately broad — a rule wrongly included costs
46
+ * one glance, a rule missed costs a broken build.
47
+ */
48
+ const RULE_SHAPE = new RegExp([
49
+ "\\bnever\\b",
50
+ "\\balways\\b",
51
+ "\\bmust (not |never )?\\b",
52
+ "\\bdo not\\b|\\bdon'?t\\b",
53
+ "\\bforbidden\\b|\\bbanned\\b|\\bprohibited\\b",
54
+ "\\brequired\\b|\\bmandatory\\b",
55
+ "\\bonly ever\\b|\\bunder no circumstances\\b",
56
+ "\\bread-?only\\b",
57
+ "\\bno-?fallback\\b",
58
+ ].join("|"), "i");
59
+
60
+ // Prose that mentions a rule without being one.
61
+ const NOT_A_RULE = [
62
+ /^#{1,6}\s/, // a heading
63
+ /^\|.*\|.*\|/, // a table row
64
+ /^\s*```/,
65
+ /^\s*[-*]\s*\[[ x]\]/i, // a checklist item
66
+ /\bfor example\b|\be\.g\.\b/i,
67
+ /^\s*<!--/,
68
+ ];
69
+
70
+ function clean(line) {
71
+ return line
72
+ .replace(/^\s*[-*+]\s+/, "")
73
+ .replace(/^\s*\d+\.\s+/, "")
74
+ .replace(/\*\*/g, "")
75
+ .replace(/`/g, "")
76
+ .replace(/\[([^\]]+)\]\([^)]*\)/g, "$1")
77
+ .trim();
78
+ }
79
+
80
+ /**
81
+ * The claim a rule makes, stripped of wording, so the same rule stated three
82
+ * different ways collapses to one entry.
83
+ */
84
+ function claimKey(text) {
85
+ return text
86
+ .toLowerCase()
87
+ .replace(/[^a-z0-9\s]/g, " ")
88
+ .split(/\s+/)
89
+ .filter((w) => w.length > 3 && !STOP.has(w))
90
+ .sort()
91
+ .slice(0, 8)
92
+ .join(" ");
93
+ }
94
+
95
+ const STOP = new Set([
96
+ "never", "always", "must", "should", "would", "could", "does", "this", "that",
97
+ "with", "from", "into", "when", "what", "which", "have", "been", "will",
98
+ "they", "them", "then", "than", "your", "yours", "make", "made", "only",
99
+ "ever", "under", "unless", "because", "there", "their", "these", "those",
100
+ ]);
101
+
102
+ /**
103
+ * A fragment is a piece of a sentence torn out of a paragraph — "domain never
104
+ * opens orders.ts.", "empty/flagged), never javascript:/data:.". It reads as a
105
+ * rule to a pattern but tells a human nothing they can act on, and it buries
106
+ * the real rules. A rule must start like a sentence and stand on its own.
107
+ */
108
+ function isFragment(t) {
109
+ if (/^[a-z]/.test(t)) return true; // starts mid-sentence
110
+ if (/^[>)\]}]/.test(t)) return true; // a quote or a stray bracket
111
+ if (/^(and|but|or|so|then|which|that|where|when)\b/i.test(t)) return true;
112
+ if (/[([{]/.test(t) && !/[)\]}]/.test(t)) return true; // an unclosed bracket
113
+ if (/:$/.test(t)) return true; // a label, not a rule
114
+ const words = t.split(/\s+/).length;
115
+ return words < 4;
116
+ }
117
+
118
+ function push(out, text, source, detail) {
119
+ const t = clean(text);
120
+ if (t.length < 15 || t.length > 300) return;
121
+ if (!RULE_SHAPE.test(t)) return;
122
+ if (NOT_A_RULE.some((re) => re.test(text))) return;
123
+ if (isFragment(t)) return;
124
+ out.push({ text: t, source, detail: detail || "" });
125
+ }
126
+
127
+ /** Read a file, or throw. An unreadable source is never reported as absent. */
128
+ function readOr(p, label) {
129
+ try {
130
+ return fs.readFileSync(p, "utf8");
131
+ } catch (e) {
132
+ throw new Unreadable(`${label} exists but could not be read: ${e.message}`);
133
+ }
134
+ }
135
+
136
+ /** 1. Contracts — already written as rules, with their reasons. */
137
+ function fromContracts(projectDir, found) {
138
+ const dir = path.join(projectDir, ".gsd-t", "contracts");
139
+ if (!fs.existsSync(dir)) return { present: false };
140
+ let names;
141
+ try {
142
+ names = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
143
+ } catch (e) {
144
+ throw new Unreadable(`${dir} exists but could not be listed: ${e.message}`);
145
+ }
146
+ let n = 0;
147
+ for (const name of names) {
148
+ const text = readOr(path.join(dir, name), name);
149
+ for (const line of text.split("\n")) {
150
+ const before = found.length;
151
+ push(found, line, "contract", name.replace(/\.md$/, ""));
152
+ if (found.length > before) n++;
153
+ }
154
+ }
155
+ return { present: true, count: n, files: names.length };
156
+ }
157
+
158
+ /**
159
+ * A rule announced by its own heading — "## The Inviolable Rule (HC-005) —
160
+ * NEVER scan the whole page". These are the rules that matter most, and reading
161
+ * line-by-line misses them entirely: the heading states the rule and the
162
+ * paragraph beneath explains it.
163
+ */
164
+ const RULE_HEADING = /^#{2,4}\s+(.*(?:inviolable|hard rule|hc-\d+|never|must not|forbidden|non-goal|do not).*)$/i;
165
+
166
+ function fromHeadings(text, found, source, detail) {
167
+ let n = 0;
168
+ const lines = text.split("\n");
169
+ for (let i = 0; i < lines.length; i++) {
170
+ const m = lines[i].match(RULE_HEADING);
171
+ if (!m) continue;
172
+ // The heading is the rule. Strip the section marker and any id in brackets,
173
+ // then keep the part that states the requirement.
174
+ let t = clean(m[1]).replace(/^The Inviolable Rule\s*/i, "").replace(/^\(([^)]+)\)\s*[—-]?\s*/, "");
175
+ const id = (m[1].match(/HC-\d+/i) || [])[0] || "";
176
+ if (t.length < 8) {
177
+ // The heading is only an id — the rule is the first real line beneath it.
178
+ for (let j = i + 1; j < Math.min(i + 6, lines.length); j++) {
179
+ const c = clean(lines[j]);
180
+ if (c.length > 20 && !/^#{1,6}\s/.test(lines[j])) { t = c; break; }
181
+ }
182
+ }
183
+ if (t.length < 15) continue;
184
+ found.push({
185
+ text: t.length > 200 ? t.slice(0, 200) + "…" : t,
186
+ source,
187
+ detail: id || detail || "",
188
+ heading: true,
189
+ });
190
+ n++;
191
+ }
192
+ return n;
193
+ }
194
+
195
+ /** 2. The existing CLAUDE.md, minus anything restating a global rule. */
196
+ function fromClaudeMd(projectDir, found, globalText) {
197
+ const p = path.join(projectDir, "CLAUDE.md");
198
+ if (!fs.existsSync(p)) return { present: false };
199
+ const text = readOr(p, "CLAUDE.md");
200
+
201
+ // Headed rules first — these are the ones that matter.
202
+ let n = fromHeadings(text, found, "existing CLAUDE.md", "");
203
+
204
+ let restated = 0;
205
+ for (const line of text.split("\n")) {
206
+ const t = clean(line);
207
+ if (globalText && t.length > 40 && globalText.includes(t)) { restated++; continue; }
208
+ const before = found.length;
209
+ push(found, line, "existing CLAUDE.md", "");
210
+ if (found.length > before) n++;
211
+ }
212
+ return { present: true, count: n, restatedFromGlobal: restated };
213
+ }
214
+
215
+ /** 3. [RULE] markers in the pseudocode docs — machine-findable by design. */
216
+ function fromPseudocode(projectDir, found) {
217
+ const dir = path.join(projectDir, ".gsd-t", "pseudocode");
218
+ if (!fs.existsSync(dir)) return { present: false };
219
+ let names;
220
+ try {
221
+ names = fs.readdirSync(dir).filter((f) => f.endsWith(".md"));
222
+ } catch (e) {
223
+ throw new Unreadable(`${dir} exists but could not be listed: ${e.message}`);
224
+ }
225
+ let n = 0;
226
+ for (const name of names) {
227
+ const text = readOr(path.join(dir, name), name);
228
+ for (const line of text.split("\n")) {
229
+ if (!/\[RULE\]/.test(line)) continue;
230
+ const t = clean(line.replace(/\[RULE\]\s*/, "").replace(/-/g, " "));
231
+ if (t.length < 10) continue;
232
+ found.push({ text: t, source: "pseudocode", detail: name.replace(/^PseudoCode-|\.md$/g, "") });
233
+ n++;
234
+ }
235
+ }
236
+ return { present: true, count: n };
237
+ }
238
+
239
+ /** 4. A project-local hook is a rule somebody already enforced in code. */
240
+ function fromHooks(projectDir, found) {
241
+ const p = path.join(projectDir, ".claude", "settings.json");
242
+ if (!fs.existsSync(p)) return { present: false };
243
+ const raw = readOr(p, ".claude/settings.json");
244
+ let settings;
245
+ try {
246
+ settings = JSON.parse(raw);
247
+ } catch (e) {
248
+ throw new Unreadable(`.claude/settings.json exists but is not valid JSON: ${e.message}`);
249
+ }
250
+ let n = 0;
251
+ for (const [event, arr] of Object.entries((settings && settings.hooks) || {})) {
252
+ for (const m of arr || []) {
253
+ for (const h of (m.hooks || [])) {
254
+ const cmd = h.command || "";
255
+ const script = (cmd.match(/([\w-]+)\.(?:js|cjs|sh)/) || [])[1];
256
+ if (!script) continue;
257
+ // A project-local guard names the rule it enforces; only its own count.
258
+ if (!cmd.includes(projectDir) && !cmd.includes("./")) continue;
259
+ found.push({
260
+ text: `Enforced by a hook on ${event}: ${script.replace(/-/g, " ")}`,
261
+ source: "project hook",
262
+ detail: script,
263
+ });
264
+ n++;
265
+ }
266
+ }
267
+ }
268
+ return { present: true, count: n };
269
+ }
270
+
271
+ /** 5. Decision-log entries recording a directive from the user. */
272
+ function fromDecisionLog(projectDir, found) {
273
+ const p = path.join(projectDir, ".gsd-t", "progress.md");
274
+ if (!fs.existsSync(p)) return { present: false };
275
+ const text = readOr(p, "progress.md");
276
+ const start = text.indexOf("## Decision Log");
277
+ if (start === -1) return { present: false };
278
+ let n = 0;
279
+ for (const line of text.slice(start).split("\n")) {
280
+ if (!/^-\s+\d{4}-\d{2}-\d{2}/.test(line)) continue;
281
+ if (!/USER DIRECTIVE|hard rule|David'?s (call|ruling|decision)|never again/i.test(line)) continue;
282
+ // Take the sentence that states the rule, not the whole entry.
283
+ const sentences = line.split(/(?<=[.!?])\s+/);
284
+ for (const s of sentences) {
285
+ const before = found.length;
286
+ push(found, s, "decision log", (line.match(/^-\s+(\d{4}-\d{2}-\d{2})/) || [])[1] || "");
287
+ if (found.length > before) { n++; break; }
288
+ }
289
+ }
290
+ return { present: true, count: n };
291
+ }
292
+
293
+ /** 6. Files fixed the same way repeatedly — a rule nobody wrote down. */
294
+ function fromGit(projectDir, found) {
295
+ if (!fs.existsSync(path.join(projectDir, ".git"))) return { present: false };
296
+ let out;
297
+ try {
298
+ out = execFileSync("git", ["-C", projectDir, "log", "-i", "--name-only",
299
+ "--pretty=format:", "--grep=fix", "-n", "300"],
300
+ { encoding: "utf8", timeout: 20000, maxBuffer: 32 * 1024 * 1024 });
301
+ } catch (e) {
302
+ throw new Unreadable(`.git exists but git log failed: ${e.message}`);
303
+ }
304
+ const counts = {};
305
+ for (const f of out.split("\n")) {
306
+ const name = f.trim();
307
+ if (!name) continue;
308
+ counts[name] = (counts[name] || 0) + 1;
309
+ }
310
+ const hot = Object.entries(counts).filter(([, n]) => n >= 5)
311
+ .sort((a, b) => b[1] - a[1]).slice(0, 6);
312
+ for (const [file, fixes] of hot) {
313
+ found.push({
314
+ text: `Changes to ${file} are riskier than they look — fixed ${fixes} times`,
315
+ source: "git",
316
+ detail: `${fixes} fixes`,
317
+ });
318
+ }
319
+ return { present: true, count: hot.length };
320
+ }
321
+
322
+ /**
323
+ * Collapse the same rule stated in several places into one entry, keeping the
324
+ * clearest wording and merging where it came from. Rank by how many DIFFERENT
325
+ * sources agree — repetition is the evidence.
326
+ */
327
+ function merge(found) {
328
+ const byClaim = new Map();
329
+ for (const r of found) {
330
+ const key = claimKey(r.text);
331
+ if (!key) continue;
332
+ if (!byClaim.has(key)) {
333
+ byClaim.set(key, { text: r.text, sources: new Map() });
334
+ }
335
+ const entry = byClaim.get(key);
336
+ // A rule that had its own heading is the authoritative wording — it beats a
337
+ // sentence found mid-paragraph, whatever the lengths.
338
+ if (r.heading && !entry.heading) { entry.text = r.text; entry.heading = true; }
339
+ else if (!entry.heading && r.text.length < entry.text.length && r.text.length > 25) entry.text = r.text;
340
+
341
+ if (!entry.sources.has(r.source)) entry.sources.set(r.source, []);
342
+ const list = entry.sources.get(r.source);
343
+ if (r.detail && !list.includes(r.detail)) list.push(r.detail);
344
+ }
345
+
346
+ return [...byClaim.values()]
347
+ .map((e) => ({
348
+ text: e.text,
349
+ seenIn: [...e.sources.entries()].map(([s, d]) => (d.length ? `${s} (${d.slice(0, 2).join(", ")})` : s)),
350
+ sourceCount: e.sources.size,
351
+ heading: !!e.heading,
352
+ // A rule stated as a whole sentence beats a fragment. "Consumers
353
+ // (read-only):" is a table header, not a rule anybody can act on.
354
+ wellFormed: /\s(never|always|must|do not|don't|only|no)\s/i.test(e.text) && !/:$/.test(e.text),
355
+ }))
356
+ .sort((a, b) =>
357
+ (Number(b.heading) - Number(a.heading)) ||
358
+ (Number(b.wellFormed) - Number(a.wellFormed)) ||
359
+ (b.sourceCount - a.sourceCount) ||
360
+ (a.text.length - b.text.length));
361
+ }
362
+
363
+ function parseArgs(argv) {
364
+ const args = { project: process.cwd(), top: DEFAULT_TOP };
365
+ for (let i = 2; i < argv.length; i++) {
366
+ const a = argv[i];
367
+ if (a === "--json") args.json = true;
368
+ else if (a === "--project") args.project = argv[++i];
369
+ else if (a === "--top") args.top = parseInt(argv[++i], 10) || DEFAULT_TOP;
370
+ }
371
+ return args;
372
+ }
373
+
374
+ function main() {
375
+ const args = parseArgs(process.argv);
376
+ const projectDir = path.resolve(args.project);
377
+ const asJson = args.json || !process.stdout.isTTY;
378
+
379
+ let globalText = "";
380
+ const globalPath = path.join(process.env.HOME || "", ".claude", "CLAUDE.md");
381
+ if (fs.existsSync(globalPath)) {
382
+ try { globalText = fs.readFileSync(globalPath, "utf8"); } catch (e) {
383
+ // The global file decides which project rules are mere restatements.
384
+ // Without it every restated rule would be listed as project-specific.
385
+ process.stdout.write(JSON.stringify({
386
+ ok: false, exitCode: EXIT_UNREADABLE,
387
+ halt: `The global CLAUDE.md exists at ${globalPath} but could not be read: ${e.message}. ` +
388
+ `Without it, rules copied from the global file would be listed as if this project invented them.`,
389
+ }, null, 2) + "\n");
390
+ process.exit(EXIT_UNREADABLE);
391
+ }
392
+ }
393
+
394
+ const found = [];
395
+ let sources;
396
+ try {
397
+ sources = {
398
+ contracts: fromContracts(projectDir, found),
399
+ claudeMd: fromClaudeMd(projectDir, found, globalText),
400
+ pseudocode: fromPseudocode(projectDir, found),
401
+ hooks: fromHooks(projectDir, found),
402
+ decisionLog: fromDecisionLog(projectDir, found),
403
+ git: fromGit(projectDir, found),
404
+ };
405
+ } catch (e) {
406
+ if (e instanceof Unreadable) {
407
+ const msg = `${e.message}\n\nThis source exists, so the rules in it belong in the list. ` +
408
+ `Continuing would offer a list that looks complete but isn't.`;
409
+ if (asJson) process.stdout.write(JSON.stringify({ ok: false, exitCode: EXIT_UNREADABLE, halt: msg }, null, 2) + "\n");
410
+ else process.stderr.write(`[gsd-t] ${msg}\n`);
411
+ process.exit(EXIT_UNREADABLE);
412
+ }
413
+ throw e;
414
+ }
415
+
416
+ const rules = merge(found);
417
+ const shown = rules.slice(0, args.top);
418
+ const rest = rules.slice(args.top);
419
+
420
+ const result = {
421
+ ok: rules.length > 0,
422
+ exitCode: rules.length > 0 ? EXIT_OK : EXIT_NONE,
423
+ project: path.basename(projectDir),
424
+ sources: Object.fromEntries(Object.entries(sources).map(([k, v]) =>
425
+ [k, v.present ? `${v.count} found` : "none"])),
426
+ total: rules.length,
427
+ shown: shown.map((r, i) => ({ n: i + 1, ...r })),
428
+ remainder: rest.length,
429
+ remainderRules: rest,
430
+ };
431
+
432
+ if (!rules.length) {
433
+ result.halt = "No rules found anywhere in this project. Do not write a rules section — ask the user.";
434
+ }
435
+
436
+ if (asJson) {
437
+ process.stdout.write(JSON.stringify(result, null, 2) + "\n");
438
+ } else if (!rules.length) {
439
+ process.stdout.write(`No rules found in ${result.project}.\n`);
440
+ } else {
441
+ process.stdout.write(`\n${shown.length} candidate rules for ${result.project}. `);
442
+ process.stdout.write(`Tick the ones that must NEVER be broken.\n\n`);
443
+ process.stdout.write(` RULE${" ".repeat(62)}SEEN IN\n`);
444
+ shown.forEach((r, i) => {
445
+ const words = r.text.split(" ");
446
+ const lines = [];
447
+ let cur = "";
448
+ for (const w of words) {
449
+ if ((cur + " " + w).trim().length > 60) { lines.push(cur.trim()); cur = w; }
450
+ else cur += " " + w;
451
+ }
452
+ if (cur.trim()) lines.push(cur.trim());
453
+ const seen = r.seenIn.join(" + ");
454
+ process.stdout.write(`${String(i + 1).padStart(2)} [ ] ${lines[0].padEnd(62)}${seen}\n`);
455
+ for (const l of lines.slice(1)) process.stdout.write(` ${l}\n`);
456
+ });
457
+ if (rest.length) {
458
+ process.stdout.write(`\n (${rest.length} more written to .gsd-t/setup-rules-full.md — none dropped)\n`);
459
+ }
460
+ process.stdout.write(`\nTick numbers (e.g. 1,3,4) or 'all':\n`);
461
+ }
462
+ process.exit(result.exitCode);
463
+ }
464
+
465
+ if (require.main === module) main();
466
+
467
+ module.exports = { merge, claimKey, RULE_SHAPE, clean, Unreadable };
@@ -311,6 +311,8 @@ function _detectDefaultTrack2(projectDir, notes) {
311
311
 
312
312
  plan.push({ id: 'schema-id', cmd: 'node', args: [path.join(__dirname, 'gsd-t-schema-id-check.cjs'), '--project', projectDir], timeoutMs: 30000 }); // integer-identity PK on every NEW relational table, FAIL-CLOSED (no-op PASS when no new schema files)
313
313
 
314
+ plan.push({ id: 'fallbacks', cmd: 'node', args: [path.join(__dirname, 'gsd-t-fallback-detect.cjs'), '--scan', '--project', projectDir, '--json'], timeoutMs: 120000 }); // M106: no unapproved continue-after-failure branch, FAIL-CLOSED (pre-existing ones excluded via .gsd-t/fallbacks-baseline.json)
315
+
314
316
  // secrets — gitleaks (PATH detection deferred to runtime)
315
317
  if (_hasOnPath('gitleaks')) {
316
318
  plan.push({