@shrkcrft/cli 0.1.0-alpha.26 → 0.1.0-alpha.28

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.
Files changed (56) hide show
  1. package/dist/command-registry.d.ts +12 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +25 -0
  4. package/dist/commands/baseline.command.d.ts +8 -0
  5. package/dist/commands/baseline.command.d.ts.map +1 -0
  6. package/dist/commands/baseline.command.js +511 -0
  7. package/dist/commands/changelog-data.d.ts.map +1 -1
  8. package/dist/commands/changelog-data.js +43 -0
  9. package/dist/commands/check.command.d.ts.map +1 -1
  10. package/dist/commands/check.command.js +28 -1
  11. package/dist/commands/command-catalog.d.ts.map +1 -1
  12. package/dist/commands/command-catalog.js +112 -0
  13. package/dist/commands/delegate.command.d.ts +76 -1
  14. package/dist/commands/delegate.command.d.ts.map +1 -1
  15. package/dist/commands/delegate.command.js +585 -25
  16. package/dist/commands/finish.command.js +4 -4
  17. package/dist/commands/gates.command.d.ts +6 -0
  18. package/dist/commands/gates.command.d.ts.map +1 -0
  19. package/dist/commands/gates.command.js +334 -0
  20. package/dist/commands/generated.command.d.ts +6 -0
  21. package/dist/commands/generated.command.d.ts.map +1 -0
  22. package/dist/commands/generated.command.js +514 -0
  23. package/dist/commands/help.command.d.ts.map +1 -1
  24. package/dist/commands/help.command.js +73 -0
  25. package/dist/commands/ingest.command.d.ts +11 -0
  26. package/dist/commands/ingest.command.d.ts.map +1 -1
  27. package/dist/commands/ingest.command.js +49 -23
  28. package/dist/commands/policy-lint.command.d.ts +37 -0
  29. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  30. package/dist/commands/policy-lint.command.js +119 -2
  31. package/dist/commands/registry-resolve.d.ts +11 -4
  32. package/dist/commands/registry-resolve.d.ts.map +1 -1
  33. package/dist/commands/registry-resolve.js +50 -24
  34. package/dist/commands/registry.command.d.ts.map +1 -1
  35. package/dist/commands/registry.command.js +36 -4
  36. package/dist/commands/trace.command.d.ts.map +1 -1
  37. package/dist/commands/trace.command.js +7 -1
  38. package/dist/commands/wiring.command.d.ts.map +1 -1
  39. package/dist/commands/wiring.command.js +113 -14
  40. package/dist/exit-codes.d.ts +41 -0
  41. package/dist/exit-codes.d.ts.map +1 -1
  42. package/dist/exit-codes.js +85 -0
  43. package/dist/finish/run-finish.d.ts +22 -3
  44. package/dist/finish/run-finish.d.ts.map +1 -1
  45. package/dist/finish/run-finish.js +194 -18
  46. package/dist/gates/gate-rule-view.d.ts +35 -0
  47. package/dist/gates/gate-rule-view.d.ts.map +1 -0
  48. package/dist/gates/gate-rule-view.js +80 -0
  49. package/dist/gates/rule-coverage.d.ts +53 -0
  50. package/dist/gates/rule-coverage.d.ts.map +1 -0
  51. package/dist/gates/rule-coverage.js +165 -0
  52. package/dist/main.d.ts.map +1 -1
  53. package/dist/main.js +46 -6
  54. package/dist/output/output-compression.d.ts.map +1 -1
  55. package/dist/output/output-compression.js +4 -1
  56. package/package.json +33 -33
@@ -0,0 +1,514 @@
1
+ /**
2
+ * `shrk generated {list,check,update,explain}` — the generated-artifact drift +
3
+ * provenance GATE. It shares the `generated` noun with the classifier verbs
4
+ * (`report` / `protect`, in `ingest.command.ts`) on purpose: that half FINDS
5
+ * what is generated, this half proves it has not drifted.
6
+ *
7
+ * shrk generated list # every declared artifact set
8
+ * shrk generated check [--id X] # regen → temp dir → diff BOTH ways + headers
9
+ * [--headers-only] # header contract only; never spawns
10
+ * shrk generated update [--id X] # run regen in place (the bless step)
11
+ * shrk generated explain --id X # what it will run + what it currently sees
12
+ *
13
+ * The build compiles a hand-edited generated file perfectly happily; only
14
+ * regenerate-into-a-temp-dir-and-diff catches the edit. `regen` therefore
15
+ * SPAWNS a shell command, which is why the pack-plane merge seam drops any
16
+ * pack-contributed rule declaring one — a header-only rule (no `regen`) is
17
+ * fully useful and never spawns, so packs can still ship it.
18
+ */
19
+ import { spawnSync } from 'node:child_process';
20
+ import { mkdtempSync, readFileSync, readdirSync, rmSync, statSync } from 'node:fs';
21
+ import * as nodeOs from 'node:os';
22
+ import * as nodePath from 'node:path';
23
+ import { checkProvenanceHeaders, compareGeneratedTrees, scanGeneratedFiles, } from '@shrkcrft/boundaries';
24
+ import { resolveProjectConfig } from '@shrkcrft/inspector';
25
+ import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
26
+ import { ExitCode } from "../exit-codes.js";
27
+ import { asJson, header, kv } from "../output/format-output.js";
28
+ const SCHEMA = 'sharkcraft.generated-drift/v1';
29
+ const DEFAULT_TIMEOUT_MS = 120_000;
30
+ /** Cap on the temp tree read — a runaway regen must not be read into memory whole. */
31
+ const MAX_REGEN_FILE_BYTES = 2_000_000;
32
+ async function loadRules(cwd) {
33
+ const loaded = await resolveProjectConfig(cwd);
34
+ if (!loaded.ok)
35
+ return { ok: false, message: loaded.error.message };
36
+ const rel = nodePath.relative(cwd, loaded.value.sharkcraftDir).split(nodePath.sep).join('/');
37
+ return {
38
+ ok: true,
39
+ value: {
40
+ rules: loaded.value.config.generatedArtifacts ?? [],
41
+ planeDiagnostics: loaded.value.planeDiagnostics,
42
+ excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
43
+ },
44
+ };
45
+ }
46
+ /** Read a regenerated temp tree into path→content, relative to `root`. */
47
+ function readTree(root) {
48
+ const out = new Map();
49
+ const visit = (abs) => {
50
+ let entries;
51
+ try {
52
+ entries = readdirSync(abs, { withFileTypes: true });
53
+ }
54
+ catch {
55
+ return;
56
+ }
57
+ for (const e of entries) {
58
+ const child = nodePath.join(abs, e.name);
59
+ if (e.isDirectory()) {
60
+ visit(child);
61
+ continue;
62
+ }
63
+ if (!e.isFile())
64
+ continue;
65
+ try {
66
+ if (statSync(child).size > MAX_REGEN_FILE_BYTES)
67
+ continue;
68
+ out.set(nodePath.relative(root, child).split(nodePath.sep).join('/'), readFileSync(child, 'utf8'));
69
+ }
70
+ catch {
71
+ // unreadable — skip
72
+ }
73
+ }
74
+ };
75
+ visit(root);
76
+ return out;
77
+ }
78
+ /** Number of trailing path SEGMENTS `a` and `b` share (0 when none). */
79
+ function sharedSuffixSegments(a, b) {
80
+ const x = a.split('/');
81
+ const y = b.split('/');
82
+ let n = 0;
83
+ while (n < x.length && n < y.length && x[x.length - 1 - n] === y[y.length - 1 - n])
84
+ n += 1;
85
+ return n;
86
+ }
87
+ /**
88
+ * Re-key a regenerated tree onto the committed paths.
89
+ *
90
+ * A regen writes into `{TMP}` under its own root, which is rarely the repo
91
+ * root, while committed files are keyed project-relative. The two sets are
92
+ * matched on the LONGEST shared path suffix — not the first suffix that
93
+ * happens to match, because `a.json` alone would otherwise bind to whichever
94
+ * `…/a.json` the iteration reached first and silently compare two unrelated
95
+ * files. Ties break lexically so the mapping is deterministic, each committed
96
+ * path is claimed at most once, and anything unmatched keeps its temp-relative
97
+ * key and surfaces as `only-regenerated` rather than disappearing.
98
+ */
99
+ function alignToCommitted(temp, committed) {
100
+ const committedPaths = [...committed.keys()].sort();
101
+ const claimed = new Set();
102
+ const out = new Map();
103
+ // Best-match first: a temp path with a longer shared suffix has the stronger
104
+ // claim on a committed path, so resolve those before the weaker ones.
105
+ const scored = [...temp.keys()]
106
+ .map((tempPath) => {
107
+ let best;
108
+ for (const c of committedPaths) {
109
+ const score = sharedSuffixSegments(tempPath, c);
110
+ if (score > 0 && (best === undefined || score > best.score))
111
+ best = { path: c, score };
112
+ }
113
+ return { tempPath, best };
114
+ })
115
+ .sort((a, b) => (b.best?.score ?? 0) - (a.best?.score ?? 0) || a.tempPath.localeCompare(b.tempPath));
116
+ for (const { tempPath, best } of scored) {
117
+ const content = temp.get(tempPath);
118
+ if (best && !claimed.has(best.path)) {
119
+ claimed.add(best.path);
120
+ out.set(best.path, content);
121
+ }
122
+ else {
123
+ out.set(tempPath, content);
124
+ }
125
+ }
126
+ return out;
127
+ }
128
+ /** Run `regen` into a fresh temp dir and read the result back. Always cleans up. */
129
+ function runRegen(cwd, rule, committed) {
130
+ const tmp = mkdtempSync(nodePath.join(nodeOs.tmpdir(), 'shrk-generated-'));
131
+ try {
132
+ const command = rule.regen.split('{TMP}').join(tmp);
133
+ const child = spawnSync(command, {
134
+ cwd,
135
+ shell: true,
136
+ encoding: 'utf8',
137
+ timeout: rule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
138
+ maxBuffer: 16 * 1024 * 1024,
139
+ });
140
+ if (child.error)
141
+ return { error: `regen failed to start: ${child.error.message}` };
142
+ if (child.status !== 0) {
143
+ const tail = String(child.stderr ?? '').trim().split('\n').slice(-3).join(' | ');
144
+ return { error: `regen exited ${child.status ?? 'null'}${tail ? ` — ${tail}` : ''}` };
145
+ }
146
+ const tree = readTree(tmp);
147
+ if (tree.size === 0) {
148
+ return { error: 'regen wrote no files into {TMP} — the command probably ignores the output path' };
149
+ }
150
+ return { files: alignToCommitted(tree, committed) };
151
+ }
152
+ finally {
153
+ rmSync(tmp, { recursive: true, force: true });
154
+ }
155
+ }
156
+ function evaluateRule(cwd, rule, excludeDirs, headersOnly) {
157
+ const scan = scanGeneratedFiles(cwd, rule, excludeDirs);
158
+ const severity = rule.severity ?? 'error';
159
+ if (scan.generated.size === 0) {
160
+ const failed = rule.failOnEmpty === true;
161
+ return {
162
+ rule,
163
+ status: failed ? 'failed' : 'skipped',
164
+ committedCount: 0,
165
+ provenance: [],
166
+ driftChecked: false,
167
+ skipReason: `0 files matched generatedGlob (${rule.generatedGlob.join(', ')})`,
168
+ };
169
+ }
170
+ const headers = checkProvenanceHeaders(rule, scan.generated, scan.outside);
171
+ if (headers.error) {
172
+ return {
173
+ rule,
174
+ status: 'error',
175
+ committedCount: scan.generated.size,
176
+ provenance: [],
177
+ driftChecked: false,
178
+ error: headers.error,
179
+ };
180
+ }
181
+ let treeDiff;
182
+ let error;
183
+ const wantDrift = !headersOnly && rule.regen !== undefined;
184
+ if (wantDrift) {
185
+ const regen = runRegen(cwd, rule, scan.generated);
186
+ if (regen.error)
187
+ error = regen.error;
188
+ else
189
+ treeDiff = compareGeneratedTrees(scan.generated, regen.files, rule.compare ?? 'bytes');
190
+ }
191
+ const hardFindings = headers.findings.filter((f) => f.severity === 'error');
192
+ const drifted = (treeDiff?.differences.length ?? 0) > 0;
193
+ const status = error !== undefined
194
+ ? 'error'
195
+ : drifted || (hardFindings.length > 0 && severity === 'error')
196
+ ? 'failed'
197
+ : 'passed';
198
+ return {
199
+ rule,
200
+ status,
201
+ committedCount: scan.generated.size,
202
+ ...(treeDiff ? { treeDiff } : {}),
203
+ provenance: headers.findings,
204
+ ...(error ? { error } : {}),
205
+ driftChecked: wantDrift && error === undefined,
206
+ };
207
+ }
208
+ function hintFor(rule) {
209
+ return (rule.hint ??
210
+ (rule.regen
211
+ ? `regenerate with \`shrk generated update --id ${rule.id}\` and commit the result`
212
+ : 'add the provenance header to the generated file, or move it out of the generated glob'));
213
+ }
214
+ function outcomeJson(o) {
215
+ return {
216
+ id: o.rule.id,
217
+ ...(o.rule.description ? { description: o.rule.description } : {}),
218
+ status: o.status,
219
+ severity: o.rule.severity ?? 'error',
220
+ committedCount: o.committedCount,
221
+ driftChecked: o.driftChecked,
222
+ ...(o.treeDiff ? { differences: o.treeDiff.differences, regeneratedCount: o.treeDiff.regeneratedCount } : {}),
223
+ provenance: o.provenance,
224
+ ...(o.error ? { error: o.error } : {}),
225
+ ...(o.skipReason ? { skipReason: o.skipReason } : {}),
226
+ hint: hintFor(o.rule),
227
+ };
228
+ }
229
+ async function prepare(args) {
230
+ const cwd = resolveCwd(args);
231
+ const json = flagBool(args, 'json');
232
+ const loaded = await loadRules(cwd);
233
+ if (!loaded.ok) {
234
+ if (json)
235
+ process.stdout.write(asJson({ schema: SCHEMA, error: loaded.message }) + '\n');
236
+ else
237
+ process.stderr.write(`Could not load config: ${loaded.message}\n Run \`shrk doctor\` for details.\n`);
238
+ return { ok: false, code: ExitCode.NotVerified };
239
+ }
240
+ const id = flagString(args, 'id');
241
+ let rules = loaded.value.rules;
242
+ if (id) {
243
+ const wanted = id.split(',').map((s) => s.trim()).filter(Boolean);
244
+ const known = new Set(rules.map((r) => r.id));
245
+ const unknown = wanted.filter((w) => !known.has(w));
246
+ if (unknown.length > 0) {
247
+ process.stderr.write(`Unknown generated-artifact id(s): ${unknown.join(', ')}. Declared: ${[...known].join(', ') || '(none)'}\n`);
248
+ return { ok: false, code: ExitCode.NotVerified };
249
+ }
250
+ rules = rules.filter((r) => wanted.includes(r.id));
251
+ }
252
+ return {
253
+ ok: true,
254
+ cwd,
255
+ rules,
256
+ all: loaded.value.rules,
257
+ excludeDirs: loaded.value.excludeDirs,
258
+ planeDiagnostics: loaded.value.planeDiagnostics,
259
+ };
260
+ }
261
+ function writeNoRules(json) {
262
+ if (json) {
263
+ process.stdout.write(asJson({ schema: SCHEMA, results: [], evaluated: 0, verdict: 'not-verified' }) + '\n');
264
+ return ExitCode.NotVerified;
265
+ }
266
+ process.stdout.write(header('Generated artifacts'));
267
+ process.stdout.write(' No generated-artifact rules declared. Add `generatedArtifacts[]` to\n' +
268
+ ' sharkcraft.config.ts to catch hand-edited generated files and missing\n' +
269
+ ' "do not edit" headers (see docs/generated-drift.md).\n');
270
+ return ExitCode.NotVerified;
271
+ }
272
+ export const generatedListCommand = {
273
+ name: 'list',
274
+ description: 'List every declared generated-artifact rule: its globs, regen command, and header contract.',
275
+ usage: 'shrk generated list [--json]',
276
+ booleanFlags: new Set(['json']),
277
+ async run(args) {
278
+ const prep = await prepare(args);
279
+ if (!prep.ok)
280
+ return prep.code;
281
+ const json = flagBool(args, 'json');
282
+ if (prep.all.length === 0)
283
+ return writeNoRules(json);
284
+ if (json) {
285
+ process.stdout.write(asJson({
286
+ schema: SCHEMA,
287
+ rules: prep.all.map((r) => ({
288
+ id: r.id,
289
+ description: r.description ?? null,
290
+ generatedGlob: r.generatedGlob,
291
+ regen: r.regen ?? null,
292
+ compare: r.compare ?? 'bytes',
293
+ provenanceHeader: r.provenanceHeader ?? null,
294
+ failOnEmpty: r.failOnEmpty === true,
295
+ })),
296
+ diagnostics: prep.planeDiagnostics,
297
+ }) + '\n');
298
+ return ExitCode.VerifiedPass;
299
+ }
300
+ process.stdout.write(header(`Generated artifacts (${prep.all.length})`));
301
+ for (const r of prep.all) {
302
+ process.stdout.write(` • ${r.id}\n`);
303
+ process.stdout.write(` glob ${r.generatedGlob.join(', ')}\n`);
304
+ process.stdout.write(` regen ${r.regen ?? '(header-only — never spawns)'}\n`);
305
+ if (r.provenanceHeader) {
306
+ process.stdout.write(` header /${r.provenanceHeader.mustMatch}/${r.provenanceHeader.forbidOutside ? ' + mislabel check' : ''}\n`);
307
+ }
308
+ if (r.description)
309
+ process.stdout.write(` ${r.description}\n`);
310
+ }
311
+ for (const d of prep.planeDiagnostics)
312
+ process.stdout.write(` ! ${d}\n`);
313
+ return ExitCode.VerifiedPass;
314
+ },
315
+ };
316
+ export const generatedCheckCommand = {
317
+ name: 'check',
318
+ description: 'Regenerate into a temp dir and diff BOTH ways (hand-edited file AND regen-writes-a-subset), plus the "do not edit" header contract.',
319
+ usage: 'shrk generated check [--id <ids>] [--headers-only] [--json]',
320
+ booleanFlags: new Set(['json', 'headers-only']),
321
+ async run(args) {
322
+ const prep = await prepare(args);
323
+ if (!prep.ok)
324
+ return prep.code;
325
+ const json = flagBool(args, 'json');
326
+ const headersOnly = flagBool(args, 'headers-only');
327
+ if (prep.rules.length === 0)
328
+ return writeNoRules(json);
329
+ const outcomes = prep.rules.map((r) => evaluateRule(prep.cwd, r, prep.excludeDirs, headersOnly));
330
+ const failed = outcomes.filter((o) => o.status === 'failed' || (o.status === 'error' && (o.rule.severity ?? 'error') === 'error'));
331
+ const evaluated = outcomes.filter((o) => o.status !== 'skipped').length;
332
+ const exit = failed.length > 0
333
+ ? ExitCode.Failure
334
+ : evaluated === 0
335
+ ? ExitCode.NotVerified
336
+ : ExitCode.VerifiedPass;
337
+ if (json) {
338
+ process.stdout.write(asJson({
339
+ schema: SCHEMA,
340
+ headersOnly,
341
+ results: outcomes.map(outcomeJson),
342
+ evaluated,
343
+ skipped: outcomes.filter((o) => o.status === 'skipped').length,
344
+ verdict: failed.length > 0 ? 'errors' : evaluated === 0 ? 'not-verified' : 'pass',
345
+ diagnostics: prep.planeDiagnostics,
346
+ }) + '\n');
347
+ return exit;
348
+ }
349
+ process.stdout.write(header('Generated-artifact drift'));
350
+ process.stdout.write(kv('evaluated', `${evaluated} of ${prep.rules.length}`) + '\n');
351
+ if (headersOnly)
352
+ process.stdout.write(kv('scope', 'headers only — no regen was run') + '\n');
353
+ for (const o of outcomes) {
354
+ if (o.status === 'skipped') {
355
+ process.stdout.write(` – ${o.rule.id} SKIPPED — ${o.skipReason}\n`);
356
+ continue;
357
+ }
358
+ if (o.status === 'error') {
359
+ process.stdout.write(` ! ${o.rule.id} ${o.error}\n`);
360
+ continue;
361
+ }
362
+ const diffs = o.treeDiff?.differences ?? [];
363
+ if (o.status === 'passed') {
364
+ process.stdout.write(` ✓ ${o.rule.id} (${o.committedCount} files` +
365
+ `${o.driftChecked ? ', byte-identical to a fresh regen' : ', headers only'})\n`);
366
+ }
367
+ else {
368
+ process.stdout.write(` ✗ ${o.rule.id} ${diffs.length} file(s) differ from a fresh regen\n`);
369
+ for (const d of diffs.slice(0, 25)) {
370
+ const label = d.kind === 'content'
371
+ ? 'hand-edited (or source changed)'
372
+ : d.kind === 'only-committed'
373
+ ? 'committed but regen no longer produces it'
374
+ : 'regen produces it but it is not committed';
375
+ process.stdout.write(` • ${d.file} — ${label}\n`);
376
+ }
377
+ if (diffs.length > 25)
378
+ process.stdout.write(` … (${diffs.length - 25} more)\n`);
379
+ }
380
+ for (const f of o.provenance.slice(0, 25)) {
381
+ process.stdout.write(` [${f.severity}] ${f.file} — ${f.message}\n`);
382
+ }
383
+ if (o.provenance.length > 25) {
384
+ process.stdout.write(` … (${o.provenance.length - 25} more header finding(s))\n`);
385
+ }
386
+ if (o.status === 'failed')
387
+ process.stdout.write(` → ${hintFor(o.rule)}\n`);
388
+ }
389
+ for (const d of prep.planeDiagnostics)
390
+ process.stdout.write(` ! ${d}\n`);
391
+ if (exit === ExitCode.NotVerified) {
392
+ process.stdout.write('\nNothing was checked — this is NOT a pass. Every rule matched 0 files.\n');
393
+ }
394
+ else if (exit === ExitCode.VerifiedPass) {
395
+ process.stdout.write('\nEvery generated artifact matches its source. ✓\n');
396
+ }
397
+ return exit;
398
+ },
399
+ };
400
+ export const generatedUpdateCommand = {
401
+ name: 'update',
402
+ description: 'Run the declared regen command in place — the one-command bless step after an intentional source change. Writes files.',
403
+ usage: 'shrk generated update [--id <ids>] [--json]',
404
+ booleanFlags: new Set(['json']),
405
+ async run(args) {
406
+ const prep = await prepare(args);
407
+ if (!prep.ok)
408
+ return prep.code;
409
+ const json = flagBool(args, 'json');
410
+ if (prep.rules.length === 0)
411
+ return writeNoRules(json);
412
+ const results = [];
413
+ for (const rule of prep.rules) {
414
+ if (!rule.regen) {
415
+ results.push({ id: rule.id, ran: false, error: 'header-only rule — nothing to regenerate' });
416
+ continue;
417
+ }
418
+ // `{TMP}` is the CHECK contract; `update` writes in place, so it is
419
+ // substituted with the project root and the regen writes its real output.
420
+ const command = rule.regen.split('{TMP}').join(prep.cwd);
421
+ const child = spawnSync(command, {
422
+ cwd: prep.cwd,
423
+ shell: true,
424
+ encoding: 'utf8',
425
+ timeout: rule.timeoutMs ?? DEFAULT_TIMEOUT_MS,
426
+ maxBuffer: 16 * 1024 * 1024,
427
+ stdio: json ? 'pipe' : 'inherit',
428
+ });
429
+ if (child.error) {
430
+ results.push({ id: rule.id, ran: false, error: child.error.message });
431
+ }
432
+ else if (child.status !== 0) {
433
+ results.push({ id: rule.id, ran: true, error: `exited ${child.status ?? 'null'}` });
434
+ }
435
+ else {
436
+ results.push({ id: rule.id, ran: true });
437
+ }
438
+ }
439
+ const failed = results.filter((r) => r.error !== undefined);
440
+ if (json) {
441
+ process.stdout.write(asJson({ schema: SCHEMA, results }) + '\n');
442
+ return failed.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
443
+ }
444
+ process.stdout.write(header('Generated update'));
445
+ for (const r of results) {
446
+ process.stdout.write(` ${r.error ? '!' : '✓'} ${r.id}${r.error ? ` — ${r.error}` : ''}\n`);
447
+ }
448
+ if (failed.length === 0) {
449
+ process.stdout.write('\nRegenerated. Review `git diff` before committing.\n');
450
+ }
451
+ return failed.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
452
+ },
453
+ };
454
+ export const generatedExplainCommand = {
455
+ name: 'explain',
456
+ description: 'Show what ONE generated-artifact rule sees right now: files matched, header contract results, mislabel candidates — without running the regen.',
457
+ usage: 'shrk generated explain --id <id> [--json]',
458
+ booleanFlags: new Set(['json']),
459
+ async run(args) {
460
+ const id = flagString(args, 'id') ?? args.positional[0];
461
+ if (!id) {
462
+ process.stderr.write('Usage: shrk generated explain --id <id>\n');
463
+ return ExitCode.NotVerified;
464
+ }
465
+ const prep = await prepare(args);
466
+ if (!prep.ok)
467
+ return prep.code;
468
+ const rule = prep.all.find((r) => r.id === id);
469
+ if (!rule) {
470
+ process.stderr.write(`No generated-artifact rule "${id}". Declared: ${prep.all.map((r) => r.id).join(', ') || '(none)'}\n`);
471
+ return ExitCode.NotVerified;
472
+ }
473
+ // explain never spawns — the point is to show what the rule SEES.
474
+ const scan = scanGeneratedFiles(prep.cwd, rule, prep.excludeDirs);
475
+ const outcome = evaluateRule(prep.cwd, rule, prep.excludeDirs, true);
476
+ if (flagBool(args, 'json')) {
477
+ process.stdout.write(asJson({
478
+ schema: 'sharkcraft.generated-explain/v1',
479
+ ...outcomeJson(outcome),
480
+ files: [...scan.generated.keys()],
481
+ outsideScanned: scan.outside.size,
482
+ outsideGlobs: scan.outsideGlobs,
483
+ regen: rule.regen ?? null,
484
+ }) + '\n');
485
+ return ExitCode.VerifiedPass;
486
+ }
487
+ process.stdout.write(header(`Generated artifact: ${rule.id}`));
488
+ if (rule.description)
489
+ process.stdout.write(` ${rule.description}\n`);
490
+ process.stdout.write(kv('glob', rule.generatedGlob.join(', ')) + '\n');
491
+ process.stdout.write(kv('files matched', String(scan.generated.size)) + '\n');
492
+ process.stdout.write(kv('regen', rule.regen ?? '(header-only)') + '\n');
493
+ process.stdout.write(kv('compare', rule.compare ?? 'bytes') + '\n');
494
+ if (rule.provenanceHeader) {
495
+ process.stdout.write(kv('header', `/${rule.provenanceHeader.mustMatch}/`) + '\n');
496
+ if (rule.provenanceHeader.forbidOutside) {
497
+ process.stdout.write(kv('mislabel scan', `${scan.outside.size} file(s) via ${scan.outsideGlobs.join(', ')}`) + '\n');
498
+ }
499
+ }
500
+ process.stdout.write(kv('header findings', String(outcome.provenance.length)) + '\n');
501
+ for (const f of outcome.provenance.slice(0, 50)) {
502
+ process.stdout.write(` [${f.kind}] ${f.file} — ${f.message}\n`);
503
+ }
504
+ if (scan.generated.size > 0) {
505
+ process.stdout.write('\n files:\n');
506
+ for (const f of [...scan.generated.keys()].slice(0, 50))
507
+ process.stdout.write(` ${f}\n`);
508
+ if (scan.generated.size > 50)
509
+ process.stdout.write(` … (${scan.generated.size - 50} more)\n`);
510
+ }
511
+ process.stdout.write(`\n Run \`shrk generated check --id ${rule.id}\` to regenerate into a temp dir and diff.\n`);
512
+ return ExitCode.VerifiedPass;
513
+ },
514
+ };
@@ -1 +1 @@
1
- {"version":3,"file":"help.command.d.ts","sourceRoot":"","sources":["../../src/commands/help.command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AA4B9D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAwC1C;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe;;;;cAK3C;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,GAAG,MAAM;EAwIpF"}
1
+ {"version":3,"file":"help.command.d.ts","sourceRoot":"","sources":["../../src/commands/help.command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAqF9D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAwC1C;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe;;;;cAK3C;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,GAAG,MAAM;EAqJpF"}
@@ -6,6 +6,65 @@ function firstSentence(description) {
6
6
  const head = dot > 0 ? description.slice(0, dot + 1) : description;
7
7
  return head.length > 100 ? head.slice(0, 97).trimEnd() + '…' : head;
8
8
  }
9
+ /** Levenshtein edit distance — small, local helper for the unknown-topic guard. */
10
+ function editDistance(a, b) {
11
+ const m = a.length;
12
+ const n = b.length;
13
+ if (m === 0)
14
+ return n;
15
+ if (n === 0)
16
+ return m;
17
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
18
+ let curr = new Array(n + 1);
19
+ for (let i = 1; i <= m; i += 1) {
20
+ curr[0] = i;
21
+ for (let j = 1; j <= n; j += 1) {
22
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
23
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
24
+ }
25
+ [prev, curr] = [curr, prev];
26
+ }
27
+ return prev[n];
28
+ }
29
+ /**
30
+ * The set of real, callable help topics: every registered top-level command
31
+ * and group name, plus every top-level verb in the catalog. Used only to
32
+ * suggest a near-typo when an unknown topic is requested — never to fabricate
33
+ * one that isn't real.
34
+ */
35
+ function realHelpTopics(registry) {
36
+ const topics = new Set();
37
+ for (const c of registry.list())
38
+ topics.add(c.name);
39
+ for (const g of registry.listGroups())
40
+ topics.add(g);
41
+ for (const entry of COMMAND_CATALOG) {
42
+ const verb = entry.command.split(/\s+/)[0];
43
+ if (verb)
44
+ topics.add(verb);
45
+ }
46
+ return topics;
47
+ }
48
+ /**
49
+ * Nearest real topic to `attempt` within a typo-tolerant edit-distance bound,
50
+ * or undefined when nothing is close enough. Mirrors main.ts's confidence
51
+ * tolerance (`max(1, len/4)` edits) so a fingers-on-keys typo suggests but a
52
+ * genuinely-unrelated token does not. Deterministic: ties break lexically.
53
+ */
54
+ function nearestHelpTopic(attempt, topics) {
55
+ const lower = attempt.toLowerCase();
56
+ const tolerance = Math.max(1, Math.floor(lower.length / 4));
57
+ let best;
58
+ let bestDist = Number.POSITIVE_INFINITY;
59
+ for (const topic of topics) {
60
+ const dist = editDistance(lower, topic.toLowerCase());
61
+ if (dist < bestDist || (dist === bestDist && best !== undefined && topic < best)) {
62
+ bestDist = dist;
63
+ best = topic;
64
+ }
65
+ }
66
+ return best !== undefined && bestDist <= tolerance ? best : undefined;
67
+ }
9
68
  const EXTRA_HELP_LINES = Object.freeze({
10
69
  graph: [
11
70
  '',
@@ -89,6 +148,20 @@ export function makeHelpCommand(registry) {
89
148
  ? args.positional[0].split(/\s+/).filter(Boolean)
90
149
  : args.positional.filter(Boolean);
91
150
  const { handler, matchedPath, node } = registry.resolve(tokens);
151
+ if (matchedPath.length === 0 && tokens.length > 0) {
152
+ // Unknown topic: the descent matched NOTHING and stopped at the root
153
+ // (which carries every top-level verb as a child). Do NOT fall through
154
+ // to the group-listing branch below — that reprints the entire real
155
+ // catalog re-prefixed with the bogus token, a false self-discovery
156
+ // that exits 0. Error out honestly instead, with a did-you-mean when
157
+ // a real topic is a near-typo of the request.
158
+ const attempt = tokens.join(' ');
159
+ process.stderr.write(`no such help topic: '${attempt}'\n`);
160
+ const suggestion = nearestHelpTopic(attempt, realHelpTopics(registry));
161
+ if (suggestion)
162
+ process.stderr.write(`Did you mean: ${suggestion}?\n`);
163
+ return 1;
164
+ }
92
165
  if (handler && matchedPath.join(' ') === tokens.join(' ') && node.children.size === 0) {
93
166
  // Exact match on a callable command.
94
167
  const canonical = registry.listCommandAliases().get(tokens[0]);
@@ -1,6 +1,17 @@
1
1
  import { type ICommandHandler } from '../command-registry.js';
2
2
  export declare const ingestCommand: ICommandHandler;
3
3
  export declare const contradictionsCommand: ICommandHandler;
4
+ /** Classify the tree's generated code (the `report` half of `shrk generated`). */
5
+ export declare const generatedReportCommand: ICommandHandler;
6
+ /** Recommend protect rules for the classified generated roots. */
7
+ export declare const generatedProtectCommand: ICommandHandler;
8
+ /**
9
+ * The `generated` group. The bare verb keeps its historical behaviour (the
10
+ * classifier report); `report` / `protect` are the classifier subverbs, and
11
+ * `list` / `check` / `update` / `explain` are the drift GATE (registered from
12
+ * `generated.command.ts`). One noun: that half FINDS what is generated, this
13
+ * half proves it has not drifted.
14
+ */
4
15
  export declare const generatedCommand: ICommandHandler;
5
16
  export declare const stabilityCommand: ICommandHandler;
6
17
  //# sourceMappingURL=ingest.command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"ingest.command.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.command.ts"],"names":[],"mappings":"AA0CA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAOhC,eAAO,MAAM,aAAa,EAAE,eA4B3B,CAAC;AAmZF,eAAO,MAAM,qBAAqB,EAAE,eAkBnC,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,eA8B9B,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,eAkC9B,CAAC"}
1
+ {"version":3,"file":"ingest.command.d.ts","sourceRoot":"","sources":["../../src/commands/ingest.command.ts"],"names":[],"mappings":"AA0CA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAOhC,eAAO,MAAM,aAAa,EAAE,eA4B3B,CAAC;AAmZF,eAAO,MAAM,qBAAqB,EAAE,eAkBnC,CAAC;AAEF,kFAAkF;AAClF,eAAO,MAAM,sBAAsB,EAAE,eAcpC,CAAC;AAEF,kEAAkE;AAClE,eAAO,MAAM,uBAAuB,EAAE,eAsBrC,CAAC;AAEF;;;;;;GAMG;AACH,eAAO,MAAM,gBAAgB,EAAE,eAY9B,CAAC;AAEF,eAAO,MAAM,gBAAgB,EAAE,eAkC9B,CAAC"}