@shrkcrft/cli 0.1.0-alpha.29 → 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 (41) 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 +23 -7
  4. package/dist/commands/changelog-data.d.ts.map +1 -1
  5. package/dist/commands/changelog-data.js +26 -0
  6. package/dist/commands/check.command.d.ts.map +1 -1
  7. package/dist/commands/check.command.js +19 -2
  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/docs-references.command.d.ts +7 -0
  11. package/dist/commands/docs-references.command.d.ts.map +1 -0
  12. package/dist/commands/docs-references.command.js +323 -0
  13. package/dist/commands/doctor.command.d.ts.map +1 -1
  14. package/dist/commands/doctor.command.js +3 -2
  15. package/dist/commands/gates.command.d.ts +2 -0
  16. package/dist/commands/gates.command.d.ts.map +1 -1
  17. package/dist/commands/gates.command.js +640 -16
  18. package/dist/commands/generated.command.d.ts +32 -0
  19. package/dist/commands/generated.command.d.ts.map +1 -1
  20. package/dist/commands/generated.command.js +180 -49
  21. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  22. package/dist/commands/graph-code-subverbs.js +5 -1
  23. package/dist/commands/wiring.command.d.ts.map +1 -1
  24. package/dist/commands/wiring.command.js +4 -3
  25. package/dist/gates/gate-envelope.d.ts +1 -1
  26. package/dist/gates/gate-envelope.d.ts.map +1 -1
  27. package/dist/gates/gate-rule-globs.d.ts +33 -0
  28. package/dist/gates/gate-rule-globs.d.ts.map +1 -0
  29. package/dist/gates/gate-rule-globs.js +101 -0
  30. package/dist/gates/gate-rule-view.d.ts +9 -3
  31. package/dist/gates/gate-rule-view.d.ts.map +1 -1
  32. package/dist/gates/gate-rule-view.js +13 -0
  33. package/dist/gates/rule-coverage.d.ts +39 -1
  34. package/dist/gates/rule-coverage.d.ts.map +1 -1
  35. package/dist/gates/rule-coverage.js +123 -8
  36. package/dist/gates/run-gate-planes.d.ts +44 -0
  37. package/dist/gates/run-gate-planes.d.ts.map +1 -0
  38. package/dist/gates/run-gate-planes.js +261 -0
  39. package/dist/main.d.ts.map +1 -1
  40. package/dist/main.js +5 -1
  41. package/package.json +33 -33
@@ -14,22 +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";
25
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";
26
32
  import { baselineExplainCommand } from "./baseline.command.js";
27
33
  import { generatedExplainCommand } from "./generated.command.js";
34
+ import { docsReferencesExplainCommand } from "./docs-references.command.js";
28
35
  import { renderPolicyExplain, runPolicyExplain } from "./policy-lint.command.js";
29
36
  import { renderWiringExplain } from "./wiring.command.js";
30
37
  const SCHEMA = 'sharkcraft.gates/v1';
31
38
  async function prepare(args) {
32
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();
33
45
  const json = flagBool(args, 'json');
34
46
  const loaded = await resolveProjectConfig(cwd);
35
47
  if (!loaded.ok) {
@@ -48,6 +60,7 @@ async function prepare(args) {
48
60
  rules: collectGateRules(loaded.value.config),
49
61
  excludeDirs: rel && !rel.startsWith('..') ? [rel] : [],
50
62
  planeDiagnostics: loaded.value.planeDiagnostics,
63
+ extractors: loaded.value.config.extractors ?? {},
51
64
  },
52
65
  };
53
66
  }
@@ -79,6 +92,119 @@ function writeNoRules(json) {
79
92
  ' generatedArtifacts[] generated files that must not be hand-edited\n');
80
93
  return ExitCode.NotVerified;
81
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
+ }
82
208
  export const gatesListCommand = {
83
209
  name: 'list',
84
210
  description: 'Every data-defined rule across every plane, with its severity and empty-match policy.',
@@ -139,9 +265,12 @@ export const gatesListCommand = {
139
265
  export const gatesCoverageCommand = {
140
266
  name: 'coverage',
141
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.',
142
- usage: 'shrk gates coverage [--plane <p>] [--strict] [--json]',
143
- 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']),
144
270
  async run(args) {
271
+ const flagReject = rejectUnknownFlags(args);
272
+ if (flagReject !== undefined)
273
+ return flagReject;
145
274
  const prep = await prepare(args);
146
275
  if (!prep.ok)
147
276
  return prep.code;
@@ -149,12 +278,41 @@ export const gatesCoverageCommand = {
149
278
  if (!planes.ok)
150
279
  return ExitCode.UsageError;
151
280
  const json = flagBool(args, 'json');
152
- const rules = planes.planes
153
- ? prep.value.rules.filter((r) => planes.planes.has(r.plane))
154
- : prep.value.rules;
155
- 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)
156
285
  return writeNoRules(json);
157
- 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));
158
316
  // A rule that matched nothing is NOT-VERIFIED (2) by default — it neither
159
317
  // passed nor failed, it never ran. `failOnEmpty` on the rule (or the global
160
318
  // --strict promotion) turns that into a hard failure.
@@ -169,6 +327,7 @@ export const gatesCoverageCommand = {
169
327
  process.stdout.write(asJson({
170
328
  ...report,
171
329
  hardFailures: hardFailures.length,
330
+ ...(scope.files ? { scoped: true, skippedByScope } : {}),
172
331
  exitCode: exit,
173
332
  gate: buildGateEnvelope('gates coverage', exit, report.rules.map((r) => ({
174
333
  id: r.id,
@@ -192,22 +351,49 @@ export const gatesCoverageCommand = {
192
351
  return exit;
193
352
  }
194
353
  process.stdout.write(header('Gate-rule coverage'));
195
- 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
+ }
196
358
  process.stdout.write(kv('matched nothing', `${report.empty}${report.empty > 0 ? ' ← stale selector suspects' : ''}`) + '\n');
197
359
  if (report.errored > 0)
198
360
  process.stdout.write(kv('misconfigured', String(report.errored)) + '\n');
199
361
  if (report.expectationFailures > 0) {
200
362
  process.stdout.write(kv('broken selfTest', String(report.expectationFailures)) + '\n');
201
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
+ }
202
382
  process.stdout.write('\n');
203
383
  for (const r of report.rules) {
204
384
  const mark = r.status === 'ok' ? '✓' : r.status === 'empty' ? (r.failOnEmpty ? '✗' : '–') : '✗';
205
- 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`);
206
387
  if (r.sampleIds.length > 0) {
207
388
  process.stdout.write(` e.g. ${r.sampleIds.join(', ')}\n`);
208
389
  }
209
390
  if (r.status === 'empty') {
210
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`);
211
397
  }
212
398
  if (r.error)
213
399
  process.stdout.write(` ! ${r.error}\n`);
@@ -224,10 +410,432 @@ export const gatesCoverageCommand = {
224
410
  return exit;
225
411
  },
226
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
+ }
227
833
  /** Render a registry inventory as the trust-layer explain view. */
228
834
  function explainRegistry(cwd, decl, excludeDirs) {
229
835
  const inventory = scanRegistry(cwd, decl, { excludeDirs });
230
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');
231
839
  process.stdout.write(kv('files scanned', String(insp.filesScanned)) + '\n');
232
840
  process.stdout.write(kv('ids', String(inventory.entries.length)) + '\n');
233
841
  for (const e of inventory.entries.slice(0, 60)) {
@@ -247,7 +855,8 @@ function explainRegistration(cwd, idiom, excludeDirs) {
247
855
  ['consumed', idiom.consumed],
248
856
  ]) {
249
857
  const insp = inspectSource(cwd, source, excludeDirs);
250
- 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');
251
860
  if (insp.error)
252
861
  process.stdout.write(` ! ${insp.error}\n`);
253
862
  for (const s of insp.sites.slice(0, 20)) {
@@ -306,6 +915,13 @@ export const gatesExplainCommand = {
306
915
  args.flags.set('id', view.id);
307
916
  return generatedExplainCommand.run(args);
308
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
+ }
309
925
  if (view.plane === 'wiring') {
310
926
  const explain = explainWiring(prep.value.cwd, view.raw, {
311
927
  excludeDirs: prep.value.excludeDirs,
@@ -364,13 +980,21 @@ export async function tryExplainGateRule(args, id) {
364
980
  }
365
981
  export const gatesCommand = {
366
982
  name: 'gates',
367
- 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.',
368
- usage: 'shrk gates list | coverage [--strict] | explain <id>',
369
- 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']),
370
986
  async run(args) {
371
987
  const sub = args.positional[0];
372
988
  process.stderr.write((sub ? `Unknown subcommand "${sub}". ` : '') +
373
- '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' +
374
998
  '(`shrk gate`, singular, runs the quality-gate pipeline — a different verb.)\n');
375
999
  return ExitCode.UsageError;
376
1000
  },