@shrkcrft/cli 0.1.0-alpha.27 → 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 (36) hide show
  1. package/dist/commands/baseline.command.d.ts +8 -0
  2. package/dist/commands/baseline.command.d.ts.map +1 -0
  3. package/dist/commands/baseline.command.js +511 -0
  4. package/dist/commands/changelog-data.d.ts.map +1 -1
  5. package/dist/commands/changelog-data.js +22 -0
  6. package/dist/commands/check.command.d.ts.map +1 -1
  7. package/dist/commands/check.command.js +28 -1
  8. package/dist/commands/command-catalog.d.ts.map +1 -1
  9. package/dist/commands/command-catalog.js +112 -0
  10. package/dist/commands/gates.command.d.ts +6 -0
  11. package/dist/commands/gates.command.d.ts.map +1 -0
  12. package/dist/commands/gates.command.js +334 -0
  13. package/dist/commands/generated.command.d.ts +6 -0
  14. package/dist/commands/generated.command.d.ts.map +1 -0
  15. package/dist/commands/generated.command.js +514 -0
  16. package/dist/commands/ingest.command.d.ts +11 -0
  17. package/dist/commands/ingest.command.d.ts.map +1 -1
  18. package/dist/commands/ingest.command.js +49 -23
  19. package/dist/commands/policy-lint.command.d.ts +37 -0
  20. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  21. package/dist/commands/policy-lint.command.js +119 -2
  22. package/dist/commands/registry.command.d.ts.map +1 -1
  23. package/dist/commands/registry.command.js +36 -4
  24. package/dist/commands/wiring.command.d.ts.map +1 -1
  25. package/dist/commands/wiring.command.js +25 -0
  26. package/dist/finish/run-finish.d.ts.map +1 -1
  27. package/dist/finish/run-finish.js +9 -3
  28. package/dist/gates/gate-rule-view.d.ts +35 -0
  29. package/dist/gates/gate-rule-view.d.ts.map +1 -0
  30. package/dist/gates/gate-rule-view.js +80 -0
  31. package/dist/gates/rule-coverage.d.ts +53 -0
  32. package/dist/gates/rule-coverage.d.ts.map +1 -0
  33. package/dist/gates/rule-coverage.js +165 -0
  34. package/dist/main.d.ts.map +1 -1
  35. package/dist/main.js +25 -2
  36. 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,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"}
@@ -458,32 +458,16 @@ export const contradictionsCommand = {
458
458
  return 0;
459
459
  },
460
460
  };
461
- export const generatedCommand = {
462
- name: 'generated',
463
- description: 'Generated-code classifier. Subcommands: `report` (default), `protect --write-drafts`. Read-only by default.',
464
- usage: 'shrk [--cwd <dir>] generated [report|protect] [--format text|markdown|json] [--write-drafts]',
461
+ /** Classify the tree's generated code (the `report` half of `shrk generated`). */
462
+ export const generatedReportCommand = {
463
+ name: 'report',
464
+ description: 'Classify which parts of the tree are generated code. Read-only.',
465
+ usage: 'shrk [--cwd <dir>] generated report [--format text|markdown|json]',
465
466
  async run(args) {
466
- const sub = args.positional[0] === 'protect' ? 'protect' : 'report';
467
- const rest = { ...args, positional: args.positional.slice(args.positional[0] === 'report' || args.positional[0] === 'protect' ? 1 : 0) };
468
- const cwd = resolveCwd(rest);
469
- const format = parseFormat(flagString(rest, 'format'));
467
+ const cwd = resolveCwd(args);
468
+ const format = parseFormat(flagString(args, 'format'));
470
469
  const inspection = await inspectSharkcraft({ cwd });
471
470
  const report = buildGeneratedCodeReport({ inspection });
472
- if (sub === 'protect') {
473
- const writeDrafts = flagBool(rest, 'write-drafts');
474
- const outDir = nodePath.join(cwd, INGEST_BASE);
475
- if (writeDrafts) {
476
- if (!existsSync(outDir))
477
- mkdirSync(outDir, { recursive: true });
478
- const target = nodePath.join(outDir, 'GENERATED_PROTECT.md');
479
- writeFileSync(target, renderGeneratedCodeReportMarkdown(report), 'utf8');
480
- process.stdout.write(`Wrote ${target}\n`);
481
- return 0;
482
- }
483
- process.stdout.write(renderGeneratedCodeReportMarkdown(report) + '\n');
484
- process.stdout.write('\nRe-run with --write-drafts to save the recommended protect rules under sharkcraft/ingestion/.\n');
485
- return 0;
486
- }
487
471
  if (format === 'json')
488
472
  process.stdout.write(renderGeneratedCodeReportJson(report) + '\n');
489
473
  else if (format === 'markdown')
@@ -493,6 +477,48 @@ export const generatedCommand = {
493
477
  return 0;
494
478
  },
495
479
  };
480
+ /** Recommend protect rules for the classified generated roots. */
481
+ export const generatedProtectCommand = {
482
+ name: 'protect',
483
+ description: 'Recommend protect rules for the detected generated roots. Read-only unless --write-drafts (writes under sharkcraft/ingestion/).',
484
+ usage: 'shrk [--cwd <dir>] generated protect [--write-drafts]',
485
+ booleanFlags: new Set(['write-drafts']),
486
+ async run(args) {
487
+ const cwd = resolveCwd(args);
488
+ const inspection = await inspectSharkcraft({ cwd });
489
+ const report = buildGeneratedCodeReport({ inspection });
490
+ if (flagBool(args, 'write-drafts')) {
491
+ const outDir = nodePath.join(cwd, INGEST_BASE);
492
+ if (!existsSync(outDir))
493
+ mkdirSync(outDir, { recursive: true });
494
+ const target = nodePath.join(outDir, 'GENERATED_PROTECT.md');
495
+ writeFileSync(target, renderGeneratedCodeReportMarkdown(report), 'utf8');
496
+ process.stdout.write(`Wrote ${target}\n`);
497
+ return 0;
498
+ }
499
+ process.stdout.write(renderGeneratedCodeReportMarkdown(report) + '\n');
500
+ process.stdout.write('\nRe-run with --write-drafts to save the recommended protect rules under sharkcraft/ingestion/.\n');
501
+ return 0;
502
+ },
503
+ };
504
+ /**
505
+ * The `generated` group. The bare verb keeps its historical behaviour (the
506
+ * classifier report); `report` / `protect` are the classifier subverbs, and
507
+ * `list` / `check` / `update` / `explain` are the drift GATE (registered from
508
+ * `generated.command.ts`). One noun: that half FINDS what is generated, this
509
+ * half proves it has not drifted.
510
+ */
511
+ export const generatedCommand = {
512
+ name: 'generated',
513
+ description: 'Generated-code classifier (`report` default, `protect --write-drafts`) + the generated-artifact drift GATE (`list | check | update | explain`, see docs/generated-drift.md). Read-only by default.',
514
+ usage: 'shrk [--cwd <dir>] generated [report|protect] [--format text|markdown|json] [--write-drafts]\n (drift gate: shrk generated list | check [--headers-only] | update | explain --id <id>)',
515
+ booleanFlags: new Set(['write-drafts']),
516
+ async run(args) {
517
+ // Bare `shrk generated` (and any stray positional) → the classifier report,
518
+ // exactly as before the gate verbs joined the group.
519
+ return generatedReportCommand.run({ ...args, positional: [] });
520
+ },
521
+ };
496
522
  export const stabilityCommand = {
497
523
  name: 'stability',
498
524
  description: 'Stability classification (stable/experimental/deprecated/legacy/generated/internal/public-api/high-risk). Read-only.',
@@ -1,3 +1,40 @@
1
+ import type { IPolicyRule, PolicySurface } from '@shrkcrft/core';
2
+ import { type IPolicyFinding, type IPolicySuppression } from '@shrkcrft/boundaries';
1
3
  import { type ICommandHandler } from '../command-registry.js';
4
+ export declare const POLICY_EXPLAIN_SCHEMA: "sharkcraft.policy-explain/v1";
5
+ /** What ONE policy rule resolved to against the live tree. */
6
+ export interface IPolicyExplain {
7
+ readonly schema: typeof POLICY_EXPLAIN_SCHEMA;
8
+ readonly ruleId: string;
9
+ readonly description?: string;
10
+ readonly surface: PolicySurface;
11
+ readonly severity: 'error' | 'warning';
12
+ readonly pattern: string;
13
+ readonly scan: string;
14
+ /** Content units the rule scanned (files, or inline-template bodies). */
15
+ readonly unitsScanned: number;
16
+ readonly status: string;
17
+ /** Every hit that COUNTED, with file:line. */
18
+ readonly findings: readonly IPolicyFinding[];
19
+ /** Every hit an exemption or the scan zone dropped, and which one applied. */
20
+ readonly suppressed: readonly IPolicySuppression[];
21
+ readonly exemptFiles: readonly string[];
22
+ readonly exemptLines?: string;
23
+ readonly diagnostics: readonly string[];
24
+ /** Why the rule scanned nothing, when it did. */
25
+ readonly skipReason?: string;
26
+ }
27
+ /**
28
+ * Dry-run ONE policy rule and return everything it saw — including the hits an
29
+ * exemption swallowed.
30
+ *
31
+ * Showing suppressed hits is the point: an exemption that silently deletes a
32
+ * finding is indistinguishable from a stale glob, so both the kept and the
33
+ * dropped hits are reported, each labelled with the exemption that applied.
34
+ */
35
+ export declare function runPolicyExplain(cwd: string, rule: IPolicyRule, excludeDirs: readonly string[]): IPolicyExplain;
36
+ /** Render an {@link IPolicyExplain}. Always returns 0 — explain is informational. */
37
+ export declare function renderPolicyExplain(explain: IPolicyExplain, wantJson: boolean): number;
38
+ export declare const policyLintExplainCommand: ICommandHandler;
2
39
  export declare const policyLintCommand: ICommandHandler;
3
40
  //# sourceMappingURL=policy-lint.command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"policy-lint.command.d.ts","sourceRoot":"","sources":["../../src/commands/policy-lint.command.ts"],"names":[],"mappings":"AAIA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAKhC,eAAO,MAAM,iBAAiB,EAAE,eA6L/B,CAAC"}
1
+ {"version":3,"file":"policy-lint.command.d.ts","sourceRoot":"","sources":["../../src/commands/policy-lint.command.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,WAAW,EAAE,aAAa,EAAE,MAAM,gBAAgB,CAAC;AACjE,OAAO,EAEL,KAAK,cAAc,EAEnB,KAAK,kBAAkB,EACxB,MAAM,sBAAsB,CAAC;AAE9B,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAMhC,eAAO,MAAM,qBAAqB,EAAG,8BAAuC,CAAC;AAE7E,8DAA8D;AAC9D,MAAM,WAAW,cAAc;IAC7B,QAAQ,CAAC,MAAM,EAAE,OAAO,qBAAqB,CAAC;IAC9C,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,aAAa,CAAC;IAChC,QAAQ,CAAC,QAAQ,EAAE,OAAO,GAAG,SAAS,CAAC;IACvC,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IACtB,yEAAyE;IACzE,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,8CAA8C;IAC9C,QAAQ,CAAC,QAAQ,EAAE,SAAS,cAAc,EAAE,CAAC;IAC7C,8EAA8E;IAC9E,QAAQ,CAAC,UAAU,EAAE,SAAS,kBAAkB,EAAE,CAAC;IACnD,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,WAAW,EAAE,SAAS,MAAM,EAAE,CAAC;IACxC,iDAAiD;IACjD,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAC;CAC9B;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAC9B,GAAG,EAAE,MAAM,EACX,IAAI,EAAE,WAAW,EACjB,WAAW,EAAE,SAAS,MAAM,EAAE,GAC7B,cAAc,CAqBhB;AAED,qFAAqF;AACrF,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,CA0CtF;AAGD,eAAO,MAAM,wBAAwB,EAAE,eA8BtC,CAAC;AAEF,eAAO,MAAM,iBAAiB,EAAE,eAmN/B,CAAC"}