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

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 (55) hide show
  1. package/dist/commands/baseline.command.d.ts +25 -0
  2. package/dist/commands/baseline.command.d.ts.map +1 -1
  3. package/dist/commands/baseline.command.js +64 -17
  4. package/dist/commands/changelog-data.d.ts.map +1 -1
  5. package/dist/commands/changelog-data.js +46 -0
  6. package/dist/commands/check.command.d.ts.map +1 -1
  7. package/dist/commands/check.command.js +137 -12
  8. package/dist/commands/code-intel.command.d.ts.map +1 -1
  9. package/dist/commands/code-intel.command.js +5 -1
  10. package/dist/commands/command-catalog.d.ts.map +1 -1
  11. package/dist/commands/command-catalog.js +8 -0
  12. package/dist/commands/daily.commands.d.ts.map +1 -1
  13. package/dist/commands/daily.commands.js +11 -1
  14. package/dist/commands/docs-references.command.d.ts +7 -0
  15. package/dist/commands/docs-references.command.d.ts.map +1 -0
  16. package/dist/commands/docs-references.command.js +323 -0
  17. package/dist/commands/doctor.command.d.ts.map +1 -1
  18. package/dist/commands/doctor.command.js +3 -2
  19. package/dist/commands/gates.command.d.ts +13 -1
  20. package/dist/commands/gates.command.d.ts.map +1 -1
  21. package/dist/commands/gates.command.js +693 -26
  22. package/dist/commands/generated.command.d.ts +32 -0
  23. package/dist/commands/generated.command.d.ts.map +1 -1
  24. package/dist/commands/generated.command.js +214 -53
  25. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  26. package/dist/commands/graph-code-subverbs.js +5 -1
  27. package/dist/commands/help.command.d.ts.map +1 -1
  28. package/dist/commands/help.command.js +64 -2
  29. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  30. package/dist/commands/policy-lint.command.js +52 -10
  31. package/dist/commands/registry.command.d.ts.map +1 -1
  32. package/dist/commands/registry.command.js +34 -9
  33. package/dist/commands/wiring.command.d.ts.map +1 -1
  34. package/dist/commands/wiring.command.js +4 -3
  35. package/dist/exit-codes.d.ts +27 -7
  36. package/dist/exit-codes.d.ts.map +1 -1
  37. package/dist/exit-codes.js +47 -8
  38. package/dist/gates/gate-envelope.d.ts +64 -0
  39. package/dist/gates/gate-envelope.d.ts.map +1 -0
  40. package/dist/gates/gate-envelope.js +26 -0
  41. package/dist/gates/gate-rule-globs.d.ts +33 -0
  42. package/dist/gates/gate-rule-globs.d.ts.map +1 -0
  43. package/dist/gates/gate-rule-globs.js +101 -0
  44. package/dist/gates/gate-rule-view.d.ts +9 -3
  45. package/dist/gates/gate-rule-view.d.ts.map +1 -1
  46. package/dist/gates/gate-rule-view.js +18 -4
  47. package/dist/gates/rule-coverage.d.ts +39 -1
  48. package/dist/gates/rule-coverage.d.ts.map +1 -1
  49. package/dist/gates/rule-coverage.js +123 -8
  50. package/dist/gates/run-gate-planes.d.ts +44 -0
  51. package/dist/gates/run-gate-planes.d.ts.map +1 -0
  52. package/dist/gates/run-gate-planes.js +261 -0
  53. package/dist/main.d.ts.map +1 -1
  54. package/dist/main.js +7 -2
  55. package/package.json +33 -33
@@ -14,21 +14,34 @@
14
14
  * Distinct from `shrk gate` (singular), which RUNS the quality-gate pipeline.
15
15
  * This verb inspects the data-defined RULES themselves.
16
16
  */
17
+ import { existsSync, readFileSync } from 'node:fs';
17
18
  import * as nodePath from 'node:path';
19
+ import { resolvePlaneExtractors } from '@shrkcrft/core';
18
20
  import { explainWiring, inspectSource, scanRegistry } from '@shrkcrft/boundaries';
19
- import { resolveProjectConfig } from '@shrkcrft/inspector';
20
- import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
21
+ import { BaselineRuleSchema, DocReferenceRuleSchema, GeneratedArtifactRuleSchema, PolicyRuleSchema, RegistrationIdiomSchema, RegistryDeclarationSchema, WiringRuleSchema, } from '@shrkcrft/config';
22
+ import { inspectSharkcraft, warmReferenceRegistries, refExists, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
23
+ import { clearFileReadCache } from '@shrkcrft/boundaries';
24
+ import { firstUnknownFlag, flagBool, flagString, resolveCwd, } from "../command-registry.js";
21
25
  import { ExitCode } from "../exit-codes.js";
22
26
  import { asJson, header, kv } from "../output/format-output.js";
23
27
  import { collectGateRules, GATE_PLANES, } from "../gates/gate-rule-view.js";
24
28
  import { buildGateCoverage } from "../gates/rule-coverage.js";
29
+ import { buildGateEnvelope } from "../gates/gate-envelope.js";
30
+ import { ruleTouchedBy } from "../gates/gate-rule-globs.js";
31
+ import { runGatePlanes } from "../gates/run-gate-planes.js";
25
32
  import { baselineExplainCommand } from "./baseline.command.js";
26
33
  import { generatedExplainCommand } from "./generated.command.js";
34
+ import { docsReferencesExplainCommand } from "./docs-references.command.js";
27
35
  import { renderPolicyExplain, runPolicyExplain } from "./policy-lint.command.js";
28
36
  import { renderWiringExplain } from "./wiring.command.js";
29
37
  const SCHEMA = 'sharkcraft.gates/v1';
30
38
  async function prepare(args) {
31
39
  const cwd = resolveCwd(args);
40
+ // Coverage memoizes its tree reads inside one call (see `withFileReadCache`).
41
+ // `--no-cache` drops anything already memoized so a suspected caching bug can
42
+ // be ruled out without a code change.
43
+ if (flagBool(args, 'no-cache'))
44
+ clearFileReadCache();
32
45
  const json = flagBool(args, 'json');
33
46
  const loaded = await resolveProjectConfig(cwd);
34
47
  if (!loaded.ok) {
@@ -37,7 +50,7 @@ async function prepare(args) {
37
50
  process.stdout.write(asJson({ schema: SCHEMA, error: msg }) + '\n');
38
51
  else
39
52
  process.stderr.write(`Could not load config: ${msg}\n Run \`shrk doctor\` for details.\n`);
40
- return { ok: false, code: ExitCode.NotVerified };
53
+ return { ok: false, code: ExitCode.UsageError };
41
54
  }
42
55
  const rel = nodePath.relative(cwd, loaded.value.sharkcraftDir).split(nodePath.sep).join('/');
43
56
  return {
@@ -47,6 +60,7 @@ async function prepare(args) {
47
60
  rules: collectGateRules(loaded.value.config),
48
61
  excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
49
62
  planeDiagnostics: loaded.value.planeDiagnostics,
63
+ extractors: loaded.value.config.extractors ?? {},
50
64
  },
51
65
  };
52
66
  }
@@ -78,6 +92,119 @@ function writeNoRules(json) {
78
92
  ' generatedArtifacts[] generated files that must not be hand-edited\n');
79
93
  return ExitCode.NotVerified;
80
94
  }
95
+ /**
96
+ * Resolve `--changed-only` / `--since <ref>` (`--base` is a synonym) into the
97
+ * changed-file set, or `undefined` for a whole-tree run.
98
+ *
99
+ * `error` is distinct from "no files changed": an unresolvable ref must not
100
+ * silently degrade to an empty diff, which would narrow every rule out of
101
+ * scope and produce a green run that checked nothing.
102
+ */
103
+ function resolveScope(args, cwd) {
104
+ const changedOnly = flagBool(args, 'changed-only');
105
+ const since = flagString(args, 'since') ?? flagString(args, 'base');
106
+ if (!changedOnly && !since)
107
+ return {};
108
+ // The SAME helper `check wiring --changed-only` uses, so bare
109
+ // `--changed-only` means the working tree here too. Two surfaces disagreeing
110
+ // about what "changed" means is how a pre-commit hook silently checks a
111
+ // different set than the CI step it is supposed to mirror.
112
+ if (since !== undefined && !refExists(cwd, since)) {
113
+ return { error: `cannot resolve ref '${since}' — not a valid commit/branch` };
114
+ }
115
+ const changed = resolveChangedFiles({
116
+ projectRoot: cwd,
117
+ ...(since ? { since } : { includeWorktree: true }),
118
+ });
119
+ return { files: changed.files };
120
+ }
121
+ /**
122
+ * Narrow rules to those whose FOOTPRINT intersects the change.
123
+ *
124
+ * Returns the surviving rules plus how many were dropped, because a scoped run
125
+ * must never print the same headline as a full one — "0 violations across 2 of
126
+ * 9 rules" and "0 violations across 9 of 9" are different facts.
127
+ */
128
+ function narrowToScope(rules, files) {
129
+ if (files === undefined)
130
+ return { selected: rules, skippedByScope: 0 };
131
+ const selected = rules.filter((r) => ruleTouchedBy(r, files));
132
+ return { selected, skippedByScope: rules.length - selected.length };
133
+ }
134
+ /** Flags every `gates` verb accepts; anything else is a typo, not an opt-in. */
135
+ const GATES_FLAGS = new Set([
136
+ 'json',
137
+ 'strict',
138
+ 'plane',
139
+ 'only',
140
+ 'changed-only',
141
+ 'since',
142
+ 'base',
143
+ 'no-spawn',
144
+ 'no-cache',
145
+ 'full',
146
+ 'limit',
147
+ 'cwd',
148
+ 'id',
149
+ 'rule-file',
150
+ 'wiring',
151
+ 'no-hints',
152
+ 'exit-trailer',
153
+ ]);
154
+ /**
155
+ * Reject an unrecognized flag rather than ignoring it.
156
+ *
157
+ * Without this a mistyped `--changed-only` parses as an unrelated `true`, the
158
+ * verb runs its UNSCOPED form, and exit `0` reads as "the scoped check passed".
159
+ * A flag the tool does not understand must never look like a satisfied request.
160
+ */
161
+ function rejectUnknownFlags(args) {
162
+ const bad = firstUnknownFlag(args, GATES_FLAGS);
163
+ if (bad === undefined)
164
+ return undefined;
165
+ process.stderr.write(`Unknown flag "--${bad}". \`shrk gates\` accepts: ` +
166
+ `${[...GATES_FLAGS].filter((f) => f !== 'cwd' && f !== 'no-hints' && f !== 'exit-trailer').map((f) => `--${f}`).join(', ')}.\n`);
167
+ return ExitCode.UsageError;
168
+ }
169
+ /**
170
+ * Apply `--plane` and `--only` narrowing.
171
+ *
172
+ * An unknown `--only` id is REFUSED rather than silently matching nothing: a
173
+ * typo'd rule id would otherwise narrow the run to zero rules and report a
174
+ * confident pass over an empty set.
175
+ */
176
+ function filterRules(all, planes, only) {
177
+ let rules = planes ? all.filter((r) => planes.has(r.plane)) : all;
178
+ if (!only)
179
+ return { ok: true, rules };
180
+ const wanted = only.split(',').map((x) => x.trim()).filter(Boolean);
181
+ const known = new Set(all.map((r) => r.id));
182
+ const unknown = wanted.filter((w) => !known.has(w));
183
+ if (unknown.length > 0) {
184
+ process.stderr.write(`Unknown rule id(s) in --only: ${unknown.join(', ')}. Run \`shrk gates list\` to see the ${all.length} declared rule(s).\n`);
185
+ return { ok: false };
186
+ }
187
+ rules = rules.filter((r) => wanted.includes(r.id));
188
+ return { ok: true, rules };
189
+ }
190
+ /**
191
+ * Build the registries the doc-reference plane resolves against — only when a
192
+ * rule of that plane is actually in scope.
193
+ *
194
+ * Every other plane reads files; this one reads shrk's own registries, which
195
+ * cost a workspace inspection. Paying that on every `gates coverage` in a repo
196
+ * with no doc-reference rules would be a tax on the common case.
197
+ */
198
+ async function inspectionIfNeeded(cwd, rules) {
199
+ if (!rules.some((r) => r.plane === 'doc-reference'))
200
+ return undefined;
201
+ // Playbook / construct ids come from a cache an ASYNC load populates; the
202
+ // resolver is sync. Warming here is what makes a correct pack playbook cited
203
+ // in prose actually resolve.
204
+ const inspection = await inspectSharkcraft({ cwd });
205
+ await warmReferenceRegistries(inspection);
206
+ return inspection;
207
+ }
81
208
  export const gatesListCommand = {
82
209
  name: 'list',
83
210
  description: 'Every data-defined rule across every plane, with its severity and empty-match policy.',
@@ -89,7 +216,7 @@ export const gatesListCommand = {
89
216
  return prep.code;
90
217
  const planes = parsePlanes(args);
91
218
  if (!planes.ok)
92
- return ExitCode.NotVerified;
219
+ return ExitCode.UsageError;
93
220
  const json = flagBool(args, 'json');
94
221
  const rules = planes.planes
95
222
  ? prep.value.rules.filter((r) => planes.planes.has(r.plane))
@@ -138,22 +265,54 @@ export const gatesListCommand = {
138
265
  export const gatesCoverageCommand = {
139
266
  name: 'coverage',
140
267
  description: 'What every rule MATCHED against the live tree — the stale-selector detector. A rule matching 0 files/ids is a bug in the rule, never a pass. Also runs each rule\'s declared selfTest expectations.',
141
- usage: 'shrk gates coverage [--plane <p>] [--strict] [--json]',
142
- booleanFlags: new Set(['json', 'strict']),
268
+ usage: 'shrk gates coverage [--plane <p>] [--changed-only | --since <ref>] [--only <ids>] [--strict] [--json]',
269
+ booleanFlags: new Set(['json', 'strict', 'changed-only', 'no-cache']),
143
270
  async run(args) {
271
+ const flagReject = rejectUnknownFlags(args);
272
+ if (flagReject !== undefined)
273
+ return flagReject;
144
274
  const prep = await prepare(args);
145
275
  if (!prep.ok)
146
276
  return prep.code;
147
277
  const planes = parsePlanes(args);
148
278
  if (!planes.ok)
149
- return ExitCode.NotVerified;
279
+ return ExitCode.UsageError;
150
280
  const json = flagBool(args, 'json');
151
- const rules = planes.planes
152
- ? prep.value.rules.filter((r) => planes.planes.has(r.plane))
153
- : prep.value.rules;
154
- if (rules.length === 0)
281
+ const filtered = filterRules(prep.value.rules, planes.planes, flagString(args, 'only'));
282
+ if (!filtered.ok)
283
+ return ExitCode.UsageError;
284
+ if (filtered.rules.length === 0)
155
285
  return writeNoRules(json);
156
- const report = buildGateCoverage(prep.value.cwd, rules, prep.value.excludeDirs);
286
+ // Scoping the STALE-SELECTOR check to the diff is what moves it from a
287
+ // CI-only report to something you can afford on every save — a stale glob
288
+ // is then caught the moment you cause it, not the next morning.
289
+ const scope = resolveScope(args, prep.value.cwd);
290
+ if (scope.error) {
291
+ process.stderr.write(`Cannot scope to the changeset: ${scope.error}\n`);
292
+ return ExitCode.UsageError;
293
+ }
294
+ const { selected, skippedByScope } = narrowToScope(filtered.rules, scope.files);
295
+ if (selected.length === 0) {
296
+ // Nothing in scope means nothing was PROVEN — never a green.
297
+ if (json) {
298
+ process.stdout.write(asJson({
299
+ schema: 'sharkcraft.gate-coverage/v1',
300
+ rules: [],
301
+ total: 0,
302
+ scoped: true,
303
+ skippedByScope,
304
+ exitCode: ExitCode.NotVerified,
305
+ }) + '\n');
306
+ }
307
+ else {
308
+ process.stdout.write(header('Gate-rule coverage'));
309
+ process.stdout.write(` No rule's footprint intersects the changeset (${skippedByScope} skipped by scope).\n` +
310
+ ' Nothing was checked — this is NOT a pass.\n');
311
+ }
312
+ return ExitCode.NotVerified;
313
+ }
314
+ const rules = selected;
315
+ const report = buildGateCoverage(prep.value.cwd, rules, prep.value.excludeDirs, prep.value.extractors, false, await inspectionIfNeeded(prep.value.cwd, rules));
157
316
  // A rule that matched nothing is NOT-VERIFIED (2) by default — it neither
158
317
  // passed nor failed, it never ran. `failOnEmpty` on the rule (or the global
159
318
  // --strict promotion) turns that into a hard failure.
@@ -165,26 +324,76 @@ export const gatesCoverageCommand = {
165
324
  ? ExitCode.NotVerified
166
325
  : ExitCode.VerifiedPass;
167
326
  if (json) {
168
- process.stdout.write(asJson({ ...report, hardFailures: hardFailures.length, exitCode: exit }) + '\n');
327
+ process.stdout.write(asJson({
328
+ ...report,
329
+ hardFailures: hardFailures.length,
330
+ ...(scope.files ? { scoped: true, skippedByScope } : {}),
331
+ exitCode: exit,
332
+ gate: buildGateEnvelope('gates coverage', exit, report.rules.map((r) => ({
333
+ id: r.id,
334
+ type: r.plane,
335
+ status: r.status === 'ok'
336
+ ? 'passed'
337
+ : r.status === 'empty'
338
+ ? r.failOnEmpty
339
+ ? 'failed'
340
+ : 'skipped'
341
+ : r.status === 'error'
342
+ ? 'error'
343
+ : 'failed',
344
+ severity: r.failOnEmpty ? 'error' : 'warning',
345
+ counts: { files: r.filesMatched, units: r.unitsMatched },
346
+ violations: r.expectationFailures.map((f) => ({ id: r.id, message: f })),
347
+ ...(r.status === 'empty' ? { skipReason: `matched 0 ${r.unitLabel}` } : {}),
348
+ ...(r.error ? { error: r.error } : {}),
349
+ }))),
350
+ }) + '\n');
169
351
  return exit;
170
352
  }
171
353
  process.stdout.write(header('Gate-rule coverage'));
172
- process.stdout.write(kv('rules', String(report.total)) + '\n');
354
+ process.stdout.write(kv('rules', `${report.total}${skippedByScope > 0 ? ` (${skippedByScope} skipped by scope)` : ''}`) + '\n');
355
+ if (scope.files) {
356
+ process.stdout.write(kv('scope', `changed-only (${scope.files.length} file(s))`) + '\n');
357
+ }
173
358
  process.stdout.write(kv('matched nothing', `${report.empty}${report.empty > 0 ? ' ← stale selector suspects' : ''}`) + '\n');
174
359
  if (report.errored > 0)
175
360
  process.stdout.write(kv('misconfigured', String(report.errored)) + '\n');
176
361
  if (report.expectationFailures > 0) {
177
362
  process.stdout.write(kv('broken selfTest', String(report.expectationFailures)) + '\n');
178
363
  }
364
+ // The shared extractors, ONCE. Their whole value is that N consumers cannot
365
+ // disagree about which set they check — so one line proving the shared set
366
+ // is live and non-empty covers all N.
367
+ if (report.extractors.length > 0) {
368
+ process.stdout.write('\n shared extractors\n');
369
+ for (const e of report.extractors) {
370
+ const mark = e.error ? '!' : e.idsMatched === 0 ? '✗' : '✓';
371
+ process.stdout.write(` ${mark} $use:${e.id} — ${e.idsMatched} ids across ${e.filesMatched} file(s), ` +
372
+ `shared by ${e.consumers.length} rule(s): ${e.consumers.join(', ')}\n`);
373
+ if (e.sampleIds.length > 0)
374
+ process.stdout.write(` e.g. ${e.sampleIds.join(', ')}\n`);
375
+ if (e.error)
376
+ process.stdout.write(` ! ${e.error}\n`);
377
+ if (!e.error && e.idsMatched === 0) {
378
+ process.stdout.write(' matched nothing — every consumer of this extractor is checking an empty set\n');
379
+ }
380
+ }
381
+ }
179
382
  process.stdout.write('\n');
180
383
  for (const r of report.rules) {
181
384
  const mark = r.status === 'ok' ? '✓' : r.status === 'empty' ? (r.failOnEmpty ? '✗' : '–') : '✗';
182
- process.stdout.write(` ${mark} [${r.plane}] ${r.id}${r.unitsMatched} ${r.unitLabel} across ${r.filesMatched} file(s)\n`);
385
+ process.stdout.write(` ${mark} [${r.plane}] ${r.id}${r.viaExtractor ? ` (via $use:${r.viaExtractor})` : ''}` +
386
+ ` — ${r.unitsMatched} ${r.unitLabel} across ${r.filesMatched} file(s)\n`);
183
387
  if (r.sampleIds.length > 0) {
184
388
  process.stdout.write(` e.g. ${r.sampleIds.join(', ')}\n`);
185
389
  }
186
390
  if (r.status === 'empty') {
187
391
  process.stdout.write(` ${r.failOnEmpty ? 'FAILED' : 'SKIPPED'} — matched nothing; the selector is probably stale\n`);
392
+ // When the engine knows WHY a correct zero-match is probably not what
393
+ // the author meant, say so here rather than leaving them to rediscover
394
+ // it — this is the one dead end `import-edges` reliably produces.
395
+ if (r.hint)
396
+ process.stdout.write(` → ${r.hint}\n`);
188
397
  }
189
398
  if (r.error)
190
399
  process.stdout.write(` ! ${r.error}\n`);
@@ -201,10 +410,432 @@ export const gatesCoverageCommand = {
201
410
  return exit;
202
411
  },
203
412
  };
413
+ export const gatesCheckCommand = {
414
+ name: 'check',
415
+ description: 'Run EVERY data-defined rule plane\'s violation check in one pass — one exit code, one JSON envelope. The CI / pre-commit primitive. Distinct from `gates coverage` (are the rules still connected) and `shrk quality` (the whole pre-PR bundle).',
416
+ usage: 'shrk gates check [--plane <p>] [--only <ids>] [--changed-only | --since <ref>] [--no-spawn] [--strict] [--json]',
417
+ booleanFlags: new Set(['json', 'strict', 'changed-only', 'no-spawn', 'no-cache']),
418
+ async run(args) {
419
+ const flagReject = rejectUnknownFlags(args);
420
+ if (flagReject !== undefined)
421
+ return flagReject;
422
+ const prep = await prepare(args);
423
+ if (!prep.ok)
424
+ return prep.code;
425
+ const planes = parsePlanes(args);
426
+ if (!planes.ok)
427
+ return ExitCode.UsageError;
428
+ const json = flagBool(args, 'json');
429
+ const filtered = filterRules(prep.value.rules, planes.planes, flagString(args, 'only'));
430
+ if (!filtered.ok)
431
+ return ExitCode.UsageError;
432
+ if (filtered.rules.length === 0)
433
+ return writeNoRules(json);
434
+ const scope = resolveScope(args, prep.value.cwd);
435
+ if (scope.error) {
436
+ process.stderr.write(`Cannot scope to the changeset: ${scope.error}\n`);
437
+ return ExitCode.UsageError;
438
+ }
439
+ const { selected, skippedByScope } = narrowToScope(filtered.rules, scope.files);
440
+ const noSpawn = flagBool(args, 'no-spawn');
441
+ const run = selected.length === 0
442
+ ? { results: [], diagnostics: [] }
443
+ : runGatePlanes(selected, {
444
+ cwd: prep.value.cwd,
445
+ excludeDirs: prep.value.excludeDirs,
446
+ ...(scope.files ? { changedFiles: scope.files } : {}),
447
+ ...(noSpawn ? { noSpawn: true } : {}),
448
+ ...(await inspectionIfNeeded(prep.value.cwd, selected).then((i) => i ? { inspection: i } : {})),
449
+ });
450
+ // Only an ERROR-severity failure blocks. A warning-severity rule reports
451
+ // without failing, exactly as its own plane's verb does.
452
+ //
453
+ // `--strict` promotes those warnings to blocking — the documented switch for
454
+ // a zero-warning CI. It reuses the established local meaning of `--strict`
455
+ // (`shrk check --strict` already does exactly this) rather than inventing a
456
+ // second severity model; the GLOBAL `--strict` promotion of not-verified
457
+ // (`2` → `1`) is applied once in `runCli` and composes with this.
458
+ const strict = flagBool(args, 'strict');
459
+ const failedRules = run.results.filter((r) => r.status === 'failed' || r.status === 'error');
460
+ const blocking = failedRules.filter((r) => strict || r.severity === 'error');
461
+ const failedWarnings = failedRules.filter((r) => r.severity !== 'error');
462
+ // An ERRORED rule proved nothing — it never reached a subject. It is
463
+ // therefore not `evaluated`, whatever its severity; otherwise a
464
+ // warning-severity rule that could not run exits 0 with a green banner,
465
+ // which is the silent-green this engine exists to prevent.
466
+ const evaluated = run.results.filter((r) => r.status !== 'skipped' && r.status !== 'error').length;
467
+ const skipped = run.results.length - evaluated;
468
+ // Exit must match the banner. Two kinds of "didn't run" are NOT the same
469
+ // thing, and conflating them breaks the surface either way:
470
+ // • skipped BY SCOPE — the user asked for the narrowing (`--changed-only`),
471
+ // so the rules outside it are deliberately out of the question. Failing
472
+ // here would make a pre-commit hook exit non-zero on every commit.
473
+ // • skipped BY ACCIDENT — a selector matched nothing, or --no-spawn
474
+ // dropped the drift half. Nobody asked for that, and it is exactly the
475
+ // silent-green this engine exists to prevent, so it is `2`.
476
+ // Matches `check wiring --changed-only`, which draws the same line.
477
+ const exit = blocking.length > 0
478
+ ? ExitCode.Failure
479
+ : evaluated === 0 || skipped > 0
480
+ ? ExitCode.NotVerified
481
+ : ExitCode.VerifiedPass;
482
+ const diagnostics = [...run.diagnostics, ...prep.value.planeDiagnostics];
483
+ if (json) {
484
+ process.stdout.write(asJson({
485
+ schema: SCHEMA,
486
+ configured: filtered.rules.length,
487
+ selected: selected.length,
488
+ evaluated,
489
+ skipped,
490
+ skippedByScope,
491
+ ...(scope.files ? { scoped: true, changedFiles: scope.files.length } : {}),
492
+ noSpawn,
493
+ strict,
494
+ failed: blocking.length,
495
+ failedWarnings: failedWarnings.length,
496
+ verdict: blocking.length > 0 ? 'errors' : evaluated === 0 ? 'not-verified' : 'pass',
497
+ diagnostics,
498
+ exitCode: exit,
499
+ gate: buildGateEnvelope('gates check', exit, run.results),
500
+ }) + '\n');
501
+ return exit;
502
+ }
503
+ process.stdout.write(header('Gate check — every rule plane'));
504
+ process.stdout.write(kv('evaluated', `${evaluated} of ${filtered.rules.length}` +
505
+ (skippedByScope > 0 ? ` (${skippedByScope} skipped by scope)` : '')) + '\n');
506
+ if (scope.files) {
507
+ process.stdout.write(kv('scope', `changed-only (${scope.files.length} file(s))`) + '\n');
508
+ }
509
+ if (noSpawn)
510
+ process.stdout.write(kv('mode', '--no-spawn — shell-executing checks skipped') + '\n');
511
+ process.stdout.write(kv('violations', `${blocking.length} blocking rule(s), ${failedWarnings.length} warning rule(s)` +
512
+ (strict && failedWarnings.length > 0 ? ' — --strict: warnings block' : '')) + '\n\n');
513
+ for (const plane of GATE_PLANES) {
514
+ const inPlane = run.results.filter((r) => r.type === plane);
515
+ if (inPlane.length === 0)
516
+ continue;
517
+ for (const r of inPlane) {
518
+ const mark = r.status === 'passed' ? '✓' : r.status === 'skipped' ? '–' : r.severity === 'error' ? '✗' : '!';
519
+ const counts = Object.entries(r.counts).map(([k, v]) => `${k} ${v}`).join(', ');
520
+ process.stdout.write(` ${mark} [${plane}] ${r.id}${counts ? ` (${counts})` : ''}\n`);
521
+ if (r.status === 'skipped' && r.skipReason) {
522
+ process.stdout.write(` SKIPPED — ${r.skipReason}\n`);
523
+ }
524
+ if (r.error)
525
+ process.stdout.write(` ! ${r.error}\n`);
526
+ for (const v of r.violations.slice(0, 10)) {
527
+ const at = v.file ? ` (${v.file}${v.line !== undefined ? `:${v.line}` : ''})` : '';
528
+ process.stdout.write(` • ${v.id}${at}${v.message ? ` — ${v.message}` : ''}\n`);
529
+ }
530
+ if (r.violations.length > 10) {
531
+ process.stdout.write(` … (${r.violations.length - 10} more)\n`);
532
+ }
533
+ const hint = r.violations.find((v) => v.hint)?.hint;
534
+ if (hint && r.violations.length > 0)
535
+ process.stdout.write(` → ${hint}\n`);
536
+ }
537
+ }
538
+ for (const d of diagnostics)
539
+ process.stdout.write(` ! ${d}\n`);
540
+ if (strict && failedWarnings.length > 0 && exit === ExitCode.Failure) {
541
+ process.stdout.write(`\n${failedWarnings.length} warning rule(s) reported findings and --strict promoted them to failures.\n`);
542
+ }
543
+ else if (exit === ExitCode.VerifiedPass && failedWarnings.length > 0) {
544
+ // A warning-severity rule reports without blocking, but the banner must
545
+ // still say it FIRED. "Everything passed" next to a printed violation is
546
+ // the kind of half-truth that trains people to stop reading the output.
547
+ process.stdout.write(`\nNo blocking violations, but ${failedWarnings.length} warning rule(s) reported findings.\n` +
548
+ (skippedByScope > 0 ? `(${skippedByScope} rule(s) outside the changeset were not run)\n` : ''));
549
+ }
550
+ else if (exit === ExitCode.VerifiedPass && skippedByScope === 0) {
551
+ process.stdout.write('\nEvery declared rule ran and passed. ✓\n');
552
+ }
553
+ else if (exit === ExitCode.NotVerified) {
554
+ process.stdout.write(`\n${skipped} rule(s) in scope checked NOTHING — this is not a pass.\n` +
555
+ 'Run `shrk gates coverage` to see which selectors are stale.\n');
556
+ }
557
+ else if (skippedByScope > 0) {
558
+ process.stdout.write(`\nEvery rule in scope passed. ✓ (${skippedByScope} outside the changeset were not run)\n`);
559
+ }
560
+ return exit;
561
+ },
562
+ };
563
+ /** Parse `--wiring 'declared=<glob>:<pattern> registered=<glob>:<pattern>'`. */
564
+ function parseInlineWiring(spec) {
565
+ const sides = new Map();
566
+ // Split on whitespace that precedes a `<side>=`, so a glob may contain none.
567
+ for (const part of spec.trim().split(/\s+(?=(?:declared|registered)=)/)) {
568
+ const eq = part.indexOf('=');
569
+ if (eq === -1)
570
+ return { error: `"${part}" is not <side>=<glob>:<pattern>` };
571
+ const side = part.slice(0, eq).trim();
572
+ if (side !== 'declared' && side !== 'registered') {
573
+ return { error: `unknown side "${side}" — use declared= or registered=` };
574
+ }
575
+ const rest = part.slice(eq + 1);
576
+ const colon = rest.lastIndexOf(':');
577
+ if (colon <= 0)
578
+ return { error: `"${side}=" needs <glob>:<pattern>` };
579
+ sides.set(side, { files: [rest.slice(0, colon)], match: rest.slice(colon + 1) });
580
+ }
581
+ const declared = sides.get('declared');
582
+ const registered = sides.get('registered');
583
+ if (!declared || !registered) {
584
+ return { error: 'both declared=<glob>:<pattern> and registered=<glob>:<pattern> are required' };
585
+ }
586
+ const toSource = (side) => ({
587
+ files: side.files,
588
+ extract: 'regex-capture',
589
+ pattern: side.match,
590
+ });
591
+ return {
592
+ rule: { id: '(try)', declared: toSource(declared), registered: toSource(registered) },
593
+ };
594
+ }
595
+ export const gatesTryCommand = {
596
+ name: 'try',
597
+ description: 'Dry-run a rule spec against the live tree WITHOUT adding it to config — the rule-authoring REPL. Prints the resolved sets and the diff, so a selector is tightened before it is committed.',
598
+ usage: "shrk gates try --rule-file <rule.json> [--plane <p>] [--full | --limit N] | --wiring 'declared=<glob>:<pat> registered=<glob>:<pat>' [--json]",
599
+ booleanFlags: new Set(['json', 'full']),
600
+ async run(args) {
601
+ const flagReject = rejectUnknownFlags(args);
602
+ if (flagReject !== undefined)
603
+ return flagReject;
604
+ const ruleFile = flagString(args, 'rule-file') ?? args.positional[0];
605
+ const inline = flagString(args, 'wiring');
606
+ if (!ruleFile && !inline) {
607
+ process.stderr.write('Usage: shrk gates try --rule-file <rule.json> [--plane <p>] [--full | --limit N]\n' +
608
+ " shrk gates try --wiring 'declared=<glob>:<pat> registered=<glob>:<pat>'\n" +
609
+ ' Runs the extraction and prints the resolved sets — config is never touched.\n' +
610
+ ' --full dump the WHOLE extracted set (default: first 5)\n' +
611
+ ' --limit N dump the first N\n');
612
+ return ExitCode.UsageError;
613
+ }
614
+ const cwd = resolveCwd(args);
615
+ const json = flagBool(args, 'json');
616
+ // A candidate may `$use` the project's shared extractors, so the real
617
+ // config is loaded for its `extractors` map (and for nothing else — no
618
+ // declared rule is read, and nothing is written).
619
+ const loaded = await resolveProjectConfig(cwd);
620
+ const extractors = loaded.ok ? (loaded.value.config.extractors ?? {}) : {};
621
+ const excludeDirs = loaded.ok
622
+ ? (() => {
623
+ const rel = nodePath
624
+ .relative(cwd, loaded.value.sharkcraftDir)
625
+ .split(nodePath.sep)
626
+ .join('/');
627
+ return rel && !rel.startsWith('..') ? [rel] : [];
628
+ })()
629
+ : [];
630
+ let raw;
631
+ let plane = 'wiring';
632
+ if (inline) {
633
+ const parsed = parseInlineWiring(inline);
634
+ if (parsed.error) {
635
+ process.stderr.write(`Invalid --wiring spec: ${parsed.error}\n`);
636
+ return ExitCode.UsageError;
637
+ }
638
+ raw = parsed.rule;
639
+ }
640
+ else {
641
+ if (!existsSync(ruleFile)) {
642
+ process.stderr.write(`Rule file not found: ${ruleFile}\n`);
643
+ return ExitCode.UsageError;
644
+ }
645
+ try {
646
+ raw = JSON.parse(readFileSync(ruleFile, 'utf8'));
647
+ }
648
+ catch (e) {
649
+ process.stderr.write(`${ruleFile} is not valid JSON: ${e.message}\n`);
650
+ return ExitCode.UsageError;
651
+ }
652
+ const planes = parsePlanes(args);
653
+ if (!planes.ok)
654
+ return ExitCode.UsageError;
655
+ const explicit = planes.planes ? [...planes.planes][0] : undefined;
656
+ const inferred = explicit ?? inferPlane(raw);
657
+ if (!inferred) {
658
+ process.stderr.write('Could not infer the plane from the rule shape. Pass --plane wiring|policy|registry|registration|baseline|generated.\n');
659
+ return ExitCode.UsageError;
660
+ }
661
+ plane = inferred;
662
+ }
663
+ // Validate with the SAME schema the loader uses, so a spec that passes here
664
+ // is a spec that will load — the point of the REPL is that what you see is
665
+ // what you get once you paste it in.
666
+ const schema = PLANE_SCHEMAS[plane];
667
+ const parsed = schema.safeParse(raw);
668
+ if (!parsed.success) {
669
+ const summary = (parsed.error?.issues ?? [])
670
+ .map((iss) => `${iss.path.join('.') || '<root>'}: ${iss.message}`)
671
+ .join('; ');
672
+ if (json)
673
+ process.stdout.write(asJson({ schema: SCHEMA, plane, valid: false, error: summary }) + '\n');
674
+ else
675
+ process.stderr.write(`Invalid ${plane} rule: ${summary}\n`);
676
+ return ExitCode.UsageError;
677
+ }
678
+ // Resolve `$use` exactly as the loader would, so a candidate referencing a
679
+ // shared extractor is tried against the real shared definition.
680
+ const planeKey = PLANE_CONFIG_KEY[plane];
681
+ const resolved = resolvePlaneExtractors({ [planeKey]: [parsed.data] }, extractors);
682
+ if (resolved.errors.length > 0) {
683
+ const summary = resolved.errors.map((e) => e.message).join('; ');
684
+ if (json)
685
+ process.stdout.write(asJson({ schema: SCHEMA, plane, valid: false, error: summary }) + '\n');
686
+ else
687
+ process.stderr.write(`Unresolvable extractor reference: ${summary}\n`);
688
+ return ExitCode.UsageError;
689
+ }
690
+ const resolvedByKey = resolved;
691
+ const rule = resolvedByKey[planeKey]?.[0] ?? parsed.data;
692
+ const view = {
693
+ id: rule.id ?? rule.name ?? '(try)',
694
+ plane,
695
+ severity: 'error',
696
+ failOnEmpty: false,
697
+ raw: rule,
698
+ };
699
+ // The wiring plane already has a BOTH-SIDES explainer (resolved declared
700
+ // set, resolved registered set, and the set-difference between them) — the
701
+ // exact view an author tightening a selector needs. Reuse it rather than
702
+ // printing a second, thinner one that could disagree with `gates explain`.
703
+ if (plane === 'wiring') {
704
+ const explain = explainWiring(cwd, rule, { excludeDirs });
705
+ const code = renderWiringExplain(explain, json);
706
+ if (!json) {
707
+ process.stdout.write('\n Nothing was written. Paste the rule into `wiringRules[]` to keep it.\n');
708
+ }
709
+ // An empty side means the selector proved nothing — not a pass.
710
+ return explain.declared.distinctCount === 0 || explain.registered.distinctCount === 0
711
+ ? ExitCode.NotVerified
712
+ : code;
713
+ }
714
+ // The candidate's COVERAGE (what it matched) is the answer the author
715
+ // needs, and the violation run tells them whether the rule would be green.
716
+ const coverage = buildGateCoverage(cwd, [view], excludeDirs, extractors, true, await inspectionIfNeeded(cwd, [view]));
717
+ // `noSpawn` is not a performance choice here: a `--rule-file` is arbitrary
718
+ // JSON, and honouring a `regen` / `compute.run` from it would turn a
719
+ // read-only preview into shell execution from an untrusted file.
720
+ const run = runGatePlanes([view], {
721
+ cwd,
722
+ excludeDirs,
723
+ noSpawn: true,
724
+ ...(await inspectionIfNeeded(cwd, [view]).then((i) => (i ? { inspection: i } : {}))),
725
+ });
726
+ const result = run.results[0];
727
+ const cov = coverage.rules[0];
728
+ const allIds = cov.allIds ?? cov.sampleIds;
729
+ if (json) {
730
+ process.stdout.write(asJson({
731
+ schema: 'sharkcraft.gates-try/v1',
732
+ plane,
733
+ valid: true,
734
+ coverage: cov,
735
+ ...(cov.hint ? { hint: cov.hint } : {}),
736
+ result: result ?? null,
737
+ note: 'nothing was written — paste the rule into sharkcraft.config.ts to keep it',
738
+ }) + '\n');
739
+ return cov.status === 'empty' ? ExitCode.NotVerified : ExitCode.VerifiedPass;
740
+ }
741
+ process.stdout.write(header(`gates try — candidate ${plane} rule "${view.id}"`));
742
+ process.stdout.write(kv('files matched', String(cov.filesMatched)) + '\n');
743
+ process.stdout.write(kv(cov.unitLabel, String(cov.unitsMatched)) + '\n');
744
+ if (cov.viaExtractor)
745
+ process.stdout.write(kv('via extractor', `$use:${cov.viaExtractor}`) + '\n');
746
+ // Tuning a selector against a large tree needs the WHOLE candidate set —
747
+ // a five-item sample cannot tell you whether the tail is right.
748
+ const full = flagBool(args, 'full');
749
+ const limitRaw = flagString(args, 'limit');
750
+ const limit = limitRaw !== undefined ? Number.parseInt(limitRaw, 10) : undefined;
751
+ if (limitRaw !== undefined && (!Number.isFinite(limit) || (limit ?? 0) < 1)) {
752
+ process.stderr.write(`--limit must be a positive integer, got "${limitRaw}".\n`);
753
+ return ExitCode.UsageError;
754
+ }
755
+ const shown = full ? allIds : allIds.slice(0, limit ?? 5);
756
+ if (shown.length > 0) {
757
+ const label = shown.length === allIds.length ? `all ${shown.length}` : `first ${shown.length} of ${allIds.length}`;
758
+ process.stdout.write(`\n extracted (${label}):\n`);
759
+ for (const id of shown)
760
+ process.stdout.write(` ${id}\n`);
761
+ if (shown.length < allIds.length) {
762
+ process.stdout.write(` … (${allIds.length - shown.length} more — re-run with --full)\n`);
763
+ }
764
+ }
765
+ if (cov.error)
766
+ process.stdout.write(`\n ! ${cov.error}\n`);
767
+ if (result) {
768
+ process.stdout.write(`\n would be: ${result.status}${result.violations.length > 0 ? ` (${result.violations.length} violation(s))` : ''}\n`);
769
+ for (const v of result.violations.slice(0, 20)) {
770
+ const at = v.file ? ` (${v.file}${v.line !== undefined ? `:${v.line}` : ''})` : '';
771
+ process.stdout.write(` • ${v.id}${at}\n`);
772
+ }
773
+ if (result.violations.length > 20) {
774
+ process.stdout.write(` … (${result.violations.length - 20} more)\n`);
775
+ }
776
+ }
777
+ if (cov.status === 'empty') {
778
+ process.stdout.write('\n This selector matched NOTHING — tighten it before adding it to config.\n');
779
+ if (cov.hint)
780
+ process.stdout.write(` → ${cov.hint}\n`);
781
+ }
782
+ process.stdout.write('\n Nothing was written. Paste the rule into sharkcraft.config.ts to keep it.\n');
783
+ return cov.status === 'empty' ? ExitCode.NotVerified : ExitCode.VerifiedPass;
784
+ },
785
+ };
786
+ /** Which config key each plane's rules live under, for `$use` resolution. */
787
+ const PLANE_CONFIG_KEY = {
788
+ wiring: 'wiringRules',
789
+ policy: 'policyRules',
790
+ registry: 'registries',
791
+ registration: 'registrationGraph',
792
+ baseline: 'baselines',
793
+ generated: 'generatedArtifacts',
794
+ 'doc-reference': 'docReferences',
795
+ };
796
+ /** The loader's own schema for each plane — one validation, not a second one. */
797
+ const PLANE_SCHEMAS = {
798
+ wiring: WiringRuleSchema,
799
+ policy: PolicyRuleSchema,
800
+ registry: RegistryDeclarationSchema,
801
+ registration: RegistrationIdiomSchema,
802
+ baseline: BaselineRuleSchema,
803
+ generated: GeneratedArtifactRuleSchema,
804
+ 'doc-reference': DocReferenceRuleSchema,
805
+ };
806
+ /**
807
+ * Infer a candidate's plane from its shape.
808
+ *
809
+ * Each plane has a field no other plane uses, so inference is exact rather than
810
+ * a guess — and an ambiguous shape returns `undefined` so the author is asked
811
+ * with `--plane` instead of being silently run on the wrong engine.
812
+ */
813
+ function inferPlane(raw) {
814
+ if (typeof raw !== 'object' || raw === null)
815
+ return undefined;
816
+ const o = raw;
817
+ if ('generatedGlob' in o)
818
+ return 'generated';
819
+ if ('tokenPattern' in o && 'resolvesAs' in o)
820
+ return 'doc-reference';
821
+ if ('compute' in o)
822
+ return 'baseline';
823
+ if ('declared' in o && 'provided' in o && 'consumed' in o)
824
+ return 'registration';
825
+ if ('declared' in o || 'registered' in o || 'chain' in o)
826
+ return 'wiring';
827
+ if ('surface' in o && 'pattern' in o)
828
+ return 'policy';
829
+ if ('source' in o && 'name' in o)
830
+ return 'registry';
831
+ return undefined;
832
+ }
204
833
  /** Render a registry inventory as the trust-layer explain view. */
205
834
  function explainRegistry(cwd, decl, excludeDirs) {
206
835
  const inventory = scanRegistry(cwd, decl, { excludeDirs });
207
836
  const insp = inspectSource(cwd, decl.source, excludeDirs);
837
+ if (decl.source.$use)
838
+ process.stdout.write(kv('via extractor', `$use:${decl.source.$use}`) + '\n');
208
839
  process.stdout.write(kv('files scanned', String(insp.filesScanned)) + '\n');
209
840
  process.stdout.write(kv('ids', String(inventory.entries.length)) + '\n');
210
841
  for (const e of inventory.entries.slice(0, 60)) {
@@ -224,7 +855,8 @@ function explainRegistration(cwd, idiom, excludeDirs) {
224
855
  ['consumed', idiom.consumed],
225
856
  ]) {
226
857
  const insp = inspectSource(cwd, source, excludeDirs);
227
- process.stdout.write(kv(label, `${insp.ids.length} token(s) across ${insp.filesScanned} file(s)`) + '\n');
858
+ process.stdout.write(kv(label, `${insp.ids.length} token(s) across ${insp.filesScanned} file(s)` +
859
+ (source.$use ? ` (via $use:${source.$use})` : '')) + '\n');
228
860
  if (insp.error)
229
861
  process.stdout.write(` ! ${insp.error}\n`);
230
862
  for (const s of insp.sites.slice(0, 20)) {
@@ -244,7 +876,7 @@ export const gatesExplainCommand = {
244
876
  const id = args.positional[0] ?? flagString(args, 'id');
245
877
  if (!id) {
246
878
  process.stderr.write('Usage: shrk gates explain <id> [--json]\n');
247
- return ExitCode.NotVerified;
879
+ return ExitCode.UsageError;
248
880
  }
249
881
  const prep = await prepare(args);
250
882
  if (!prep.ok)
@@ -252,25 +884,25 @@ export const gatesExplainCommand = {
252
884
  const matches = prep.value.rules.filter((r) => r.id === id);
253
885
  if (matches.length === 0) {
254
886
  process.stderr.write(`No gate rule "${id}". Run \`shrk gates list\` to see the ${prep.value.rules.length} declared rule(s).\n`);
255
- return ExitCode.NotVerified;
887
+ return ExitCode.UsageError;
256
888
  }
257
889
  // An id may legitimately exist on two planes (a wiring rule and a registry
258
890
  // can share a name); `--plane` disambiguates instead of guessing.
259
891
  const planes = parsePlanes(args);
260
892
  if (!planes.ok)
261
- return ExitCode.NotVerified;
893
+ return ExitCode.UsageError;
262
894
  const candidates = planes.planes
263
895
  ? matches.filter((r) => planes.planes.has(r.plane))
264
896
  : matches;
265
897
  if (candidates.length > 1) {
266
898
  process.stderr.write(`"${id}" exists on ${candidates.length} planes (${candidates.map((c) => c.plane).join(', ')}). ` +
267
899
  'Disambiguate with --plane <p>.\n');
268
- return ExitCode.NotVerified;
900
+ return ExitCode.UsageError;
269
901
  }
270
902
  const view = candidates[0];
271
903
  if (!view) {
272
904
  process.stderr.write(`No gate rule "${id}" on the requested plane.\n`);
273
- return ExitCode.NotVerified;
905
+ return ExitCode.UsageError;
274
906
  }
275
907
  const json = flagBool(args, 'json');
276
908
  // The two shell-executing planes own their explain output (and their trust
@@ -283,6 +915,13 @@ export const gatesExplainCommand = {
283
915
  args.flags.set('id', view.id);
284
916
  return generatedExplainCommand.run(args);
285
917
  }
918
+ // The doc-reference plane owns its explain view too — it resolves against
919
+ // registries, not file globs, so the generic source-based renderer below
920
+ // has nothing to show for it.
921
+ if (view.plane === 'doc-reference') {
922
+ args.flags.set('id', view.id);
923
+ return docsReferencesExplainCommand.run(args);
924
+ }
286
925
  if (view.plane === 'wiring') {
287
926
  const explain = explainWiring(prep.value.cwd, view.raw, {
288
927
  excludeDirs: prep.value.excludeDirs,
@@ -319,16 +958,44 @@ export const gatesExplainCommand = {
319
958
  return ExitCode.VerifiedPass;
320
959
  },
321
960
  };
961
+ /**
962
+ * Try to explain `id` as a data-defined rule on ANY plane.
963
+ *
964
+ * Returns the exit code when the id resolves to exactly one declared rule, or
965
+ * `undefined` when it is not a rule id at all — which lets `shrk explain` keep
966
+ * its original topic-search behaviour for everything else. This is the D2
967
+ * unification: a user holding a rule id no longer has to know which plane owns
968
+ * it, and no existing invocation changes meaning.
969
+ */
970
+ export async function tryExplainGateRule(args, id) {
971
+ const cwd = resolveCwd(args);
972
+ const loaded = await resolveProjectConfig(cwd);
973
+ if (!loaded.ok)
974
+ return undefined;
975
+ const rules = collectGateRules(loaded.value.config);
976
+ if (!rules.some((r) => r.id === id))
977
+ return undefined;
978
+ const forwarded = { ...args, positional: [id] };
979
+ return gatesExplainCommand.run(forwarded);
980
+ }
322
981
  export const gatesCommand = {
323
982
  name: 'gates',
324
- description: 'Rule-authoring trust layer: list every data-defined rule, show what each one MATCHED (the stale-selector detector), and explain any one of them. Read-only. Not `shrk gate`, which runs the quality-gate pipeline.',
325
- usage: 'shrk gates list | coverage [--strict] | explain <id>',
326
- booleanFlags: new Set(['json', 'strict']),
983
+ description: 'Rule-authoring trust layer: run every plane\'s violation check in one pass (`check`), list every data-defined rule, show what each one MATCHED (`coverage` — the stale-selector detector), explain any one of them, and dry-run a candidate rule before adding it (`try`). Never writes config. Not `shrk gate`, singular, which runs the quality-gate pipeline.',
984
+ usage: 'shrk gates check | coverage | list | explain <id> | try --rule-file <f>',
985
+ booleanFlags: new Set(['json', 'strict', 'changed-only', 'no-spawn']),
327
986
  async run(args) {
328
987
  const sub = args.positional[0];
329
988
  process.stderr.write((sub ? `Unknown subcommand "${sub}". ` : '') +
330
- 'Usage: shrk gates list | coverage [--plane <p>] [--strict] | explain <id>\n' +
989
+ 'Usage: shrk gates <subcommand>\n' +
990
+ ' check [--plane <p>] [--only <ids>] [--changed-only|--since <ref>] [--no-spawn]\n' +
991
+ ' run EVERY plane\'s violation check — one exit code (the CI / pre-commit primitive)\n' +
992
+ ' coverage [--plane <p>] [--changed-only|--since <ref>] [--strict]\n' +
993
+ ' what each rule MATCHED — the stale-selector detector\n' +
994
+ ' list [--plane <p>] every declared rule, with severity + empty-match policy\n' +
995
+ ' explain <id> the concrete inputs one rule resolved\n' +
996
+ " try --rule-file <f> | --wiring 'declared=<g>:<p> registered=<g>:<p>'\n" +
997
+ ' dry-run a candidate rule WITHOUT touching config\n' +
331
998
  '(`shrk gate`, singular, runs the quality-gate pipeline — a different verb.)\n');
332
- return ExitCode.NotVerified;
999
+ return ExitCode.UsageError;
333
1000
  },
334
1001
  };