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

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.
@@ -35,7 +35,7 @@ function renderText(report) {
35
35
  else if (report.impact.note) {
36
36
  process.stdout.write(kv('impact', `(skipped — ${report.impact.note})`) + '\n');
37
37
  }
38
- process.stdout.write(kv('verdict', report.verdict) + '\n\n');
38
+ process.stdout.write(kv('verdict', `${report.verdict} (exit ${report.exit})`) + '\n\n');
39
39
  process.stdout.write(report.summary + '\n');
40
40
  const failing = report.gates.filter((g) => g.status === 'fail');
41
41
  for (const g of failing) {
@@ -52,7 +52,7 @@ function renderText(report) {
52
52
  }
53
53
  export const finishCommand = {
54
54
  name: 'finish',
55
- description: 'Composite "is this changeset safe to finish?" gate: EXECUTES every deterministic changed-only check inline — boundaries + import-hygiene + wiring + policy + deleted-orphans — plus an impact summary, and returns ONE pass/fail. The single trustworthy "done?" call after editing (superset of `diff-check`; honors 0-rules→skipped). Read-only.',
55
+ description: 'Composite "is this changeset safe to finish?" gate: EXECUTES every deterministic changed-only check inline — boundaries + import-hygiene + wiring + unprovided (DI graph) + policy + deleted-orphans + arch (advisory cycles) over tracked AND untracked changes, and returns ONE honest 0/1/2 verdict (0 pass · 1 fail · 2 not-verified — "evaluated nothing" is 2, never a green 0). The single trustworthy "done?" call after editing (superset of `diff-check`). Read-only.',
56
56
  usage: 'shrk [--cwd <dir>] finish [files... | --files a.ts,b.ts | --staged | --since <ref>] [--json]',
57
57
  booleanFlags: new Set(['json', 'staged']),
58
58
  async run(args) {
@@ -62,9 +62,9 @@ export const finishCommand = {
62
62
  const report = await runFinishGates({ cwd, mode, scope: options });
63
63
  if (wantJson) {
64
64
  process.stdout.write(asJson(report) + '\n');
65
- return report.verdict === 'fail' ? 1 : 0;
65
+ return report.exit;
66
66
  }
67
67
  renderText(report);
68
- return report.verdict === 'fail' ? 1 : 0;
68
+ return report.exit;
69
69
  },
70
70
  };
@@ -1 +1 @@
1
- {"version":3,"file":"help.command.d.ts","sourceRoot":"","sources":["../../src/commands/help.command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AA4B9D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAwC1C;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe;;;;cAK3C;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,GAAG,MAAM;EAwIpF"}
1
+ {"version":3,"file":"help.command.d.ts","sourceRoot":"","sources":["../../src/commands/help.command.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,wBAAwB,CAAC;AAqF9D;;;;;;;;GAQG;AACH,wBAAgB,iBAAiB,IAAI,MAAM,CAwC1C;AAED,wBAAgB,eAAe,CAAC,QAAQ,EAAE,eAAe;;;;cAK3C;QAAE,UAAU,EAAE,MAAM,EAAE,CAAC;QAAC,KAAK,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,GAAG,OAAO,CAAC,CAAA;KAAE,GAAG,MAAM;EAqJpF"}
@@ -6,6 +6,65 @@ function firstSentence(description) {
6
6
  const head = dot > 0 ? description.slice(0, dot + 1) : description;
7
7
  return head.length > 100 ? head.slice(0, 97).trimEnd() + '…' : head;
8
8
  }
9
+ /** Levenshtein edit distance — small, local helper for the unknown-topic guard. */
10
+ function editDistance(a, b) {
11
+ const m = a.length;
12
+ const n = b.length;
13
+ if (m === 0)
14
+ return n;
15
+ if (n === 0)
16
+ return m;
17
+ let prev = Array.from({ length: n + 1 }, (_, i) => i);
18
+ let curr = new Array(n + 1);
19
+ for (let i = 1; i <= m; i += 1) {
20
+ curr[0] = i;
21
+ for (let j = 1; j <= n; j += 1) {
22
+ const cost = a[i - 1] === b[j - 1] ? 0 : 1;
23
+ curr[j] = Math.min(prev[j] + 1, curr[j - 1] + 1, prev[j - 1] + cost);
24
+ }
25
+ [prev, curr] = [curr, prev];
26
+ }
27
+ return prev[n];
28
+ }
29
+ /**
30
+ * The set of real, callable help topics: every registered top-level command
31
+ * and group name, plus every top-level verb in the catalog. Used only to
32
+ * suggest a near-typo when an unknown topic is requested — never to fabricate
33
+ * one that isn't real.
34
+ */
35
+ function realHelpTopics(registry) {
36
+ const topics = new Set();
37
+ for (const c of registry.list())
38
+ topics.add(c.name);
39
+ for (const g of registry.listGroups())
40
+ topics.add(g);
41
+ for (const entry of COMMAND_CATALOG) {
42
+ const verb = entry.command.split(/\s+/)[0];
43
+ if (verb)
44
+ topics.add(verb);
45
+ }
46
+ return topics;
47
+ }
48
+ /**
49
+ * Nearest real topic to `attempt` within a typo-tolerant edit-distance bound,
50
+ * or undefined when nothing is close enough. Mirrors main.ts's confidence
51
+ * tolerance (`max(1, len/4)` edits) so a fingers-on-keys typo suggests but a
52
+ * genuinely-unrelated token does not. Deterministic: ties break lexically.
53
+ */
54
+ function nearestHelpTopic(attempt, topics) {
55
+ const lower = attempt.toLowerCase();
56
+ const tolerance = Math.max(1, Math.floor(lower.length / 4));
57
+ let best;
58
+ let bestDist = Number.POSITIVE_INFINITY;
59
+ for (const topic of topics) {
60
+ const dist = editDistance(lower, topic.toLowerCase());
61
+ if (dist < bestDist || (dist === bestDist && best !== undefined && topic < best)) {
62
+ bestDist = dist;
63
+ best = topic;
64
+ }
65
+ }
66
+ return best !== undefined && bestDist <= tolerance ? best : undefined;
67
+ }
9
68
  const EXTRA_HELP_LINES = Object.freeze({
10
69
  graph: [
11
70
  '',
@@ -89,6 +148,20 @@ export function makeHelpCommand(registry) {
89
148
  ? args.positional[0].split(/\s+/).filter(Boolean)
90
149
  : args.positional.filter(Boolean);
91
150
  const { handler, matchedPath, node } = registry.resolve(tokens);
151
+ if (matchedPath.length === 0 && tokens.length > 0) {
152
+ // Unknown topic: the descent matched NOTHING and stopped at the root
153
+ // (which carries every top-level verb as a child). Do NOT fall through
154
+ // to the group-listing branch below — that reprints the entire real
155
+ // catalog re-prefixed with the bogus token, a false self-discovery
156
+ // that exits 0. Error out honestly instead, with a did-you-mean when
157
+ // a real topic is a near-typo of the request.
158
+ const attempt = tokens.join(' ');
159
+ process.stderr.write(`no such help topic: '${attempt}'\n`);
160
+ const suggestion = nearestHelpTopic(attempt, realHelpTopics(registry));
161
+ if (suggestion)
162
+ process.stderr.write(`Did you mean: ${suggestion}?\n`);
163
+ return 1;
164
+ }
92
165
  if (handler && matchedPath.join(' ') === tokens.join(' ') && node.children.size === 0) {
93
166
  // Exact match on a callable command.
94
167
  const canonical = registry.listCommandAliases().get(tokens[0]);
@@ -12,9 +12,14 @@
12
12
  * 3. singular/plural normalization (`commands` ↔ `command`),
13
13
  * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
14
14
  *
15
- * The first layer that lands on a DECLARED id wins; when nothing resolves, the
16
- * noun is returned unchanged and unmatched (the honest "genuinely not declared"
17
- * answer). No layer invents an id that isn't in the registry.
15
+ * Layers 3 and 4 CHAIN: the singular/plural variants and the suffix transform
16
+ * compose, so a doubly-off noun still lands (`buttons` →(plural)→ `button`
17
+ * →(suffix) `button-command`) where a single layer would no-op. When both
18
+ * transforms fire, the resolution reports `SingularPluralSuffix`.
19
+ *
20
+ * The first candidate that lands on a DECLARED id wins; when nothing resolves,
21
+ * the noun is returned unchanged and unmatched (the honest "genuinely not
22
+ * declared" answer). No layer invents an id that isn't in the registry.
18
23
  */
19
24
  /** How a noun resolved to its canonical registered id (for a truthful report). */
20
25
  export declare enum ERegistryResolveVia {
@@ -22,7 +27,9 @@ export declare enum ERegistryResolveVia {
22
27
  Alias = "alias",
23
28
  Case = "case-fold",
24
29
  SingularPlural = "singular/plural",
25
- Suffix = "suffix"
30
+ Suffix = "suffix",
31
+ /** Both a singular/plural AND a suffix transform were applied (chained). */
32
+ SingularPluralSuffix = "singular/plural+suffix"
26
33
  }
27
34
  export interface IRegistryResolution {
28
35
  /** The canonical id to test for existence (may equal the input noun). */
@@ -1 +1 @@
1
- {"version":3,"file":"registry-resolve.d.ts","sourceRoot":"","sources":["../../src/commands/registry-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;GAiBG;AAEH,kFAAkF;AAClF,oBAAY,mBAAmB;IAC7B,QAAQ,aAAa;IACrB,KAAK,UAAU;IACf,IAAI,cAAc;IAClB,cAAc,oBAAoB;IAClC,MAAM,WAAW;CAClB;AAED,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,mBAAmB,CAAC;CACnC;AAYD;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,EACrD,IAAI,EAAE,MAAM,GACX,mBAAmB,CAmDrB"}
1
+ {"version":3,"file":"registry-resolve.d.ts","sourceRoot":"","sources":["../../src/commands/registry-resolve.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;GAsBG;AAEH,kFAAkF;AAClF,oBAAY,mBAAmB;IAC7B,QAAQ,aAAa;IACrB,KAAK,UAAU;IACf,IAAI,cAAc;IAClB,cAAc,oBAAoB;IAClC,MAAM,WAAW;IACjB,4EAA4E;IAC5E,oBAAoB,2BAA2B;CAChD;AAED,MAAM,WAAW,mBAAmB;IAClC,yEAAyE;IACzE,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;IAC3B,uEAAuE;IACvE,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,sDAAsD;IACtD,QAAQ,CAAC,GAAG,EAAE,mBAAmB,CAAC;CACnC;AAYD;;;;GAIG;AACH,wBAAgB,mBAAmB,CACjC,WAAW,EAAE,SAAS,MAAM,EAAE,EAC9B,OAAO,EAAE,QAAQ,CAAC,MAAM,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC,GAAG,SAAS,EACrD,IAAI,EAAE,MAAM,GACX,mBAAmB,CAwErB"}
@@ -12,9 +12,14 @@
12
12
  * 3. singular/plural normalization (`commands` ↔ `command`),
13
13
  * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
14
14
  *
15
- * The first layer that lands on a DECLARED id wins; when nothing resolves, the
16
- * noun is returned unchanged and unmatched (the honest "genuinely not declared"
17
- * answer). No layer invents an id that isn't in the registry.
15
+ * Layers 3 and 4 CHAIN: the singular/plural variants and the suffix transform
16
+ * compose, so a doubly-off noun still lands (`buttons` →(plural)→ `button`
17
+ * →(suffix) `button-command`) where a single layer would no-op. When both
18
+ * transforms fire, the resolution reports `SingularPluralSuffix`.
19
+ *
20
+ * The first candidate that lands on a DECLARED id wins; when nothing resolves,
21
+ * the noun is returned unchanged and unmatched (the honest "genuinely not
22
+ * declared" answer). No layer invents an id that isn't in the registry.
18
23
  */
19
24
  /** How a noun resolved to its canonical registered id (for a truthful report). */
20
25
  export var ERegistryResolveVia;
@@ -24,6 +29,8 @@ export var ERegistryResolveVia;
24
29
  ERegistryResolveVia["Case"] = "case-fold";
25
30
  ERegistryResolveVia["SingularPlural"] = "singular/plural";
26
31
  ERegistryResolveVia["Suffix"] = "suffix";
32
+ /** Both a singular/plural AND a suffix transform were applied (chained). */
33
+ ERegistryResolveVia["SingularPluralSuffix"] = "singular/plural+suffix";
27
34
  })(ERegistryResolveVia || (ERegistryResolveVia = {}));
28
35
  function singularPluralVariants(noun) {
29
36
  const out = [];
@@ -60,28 +67,47 @@ export function resolveRegistryNoun(declaredIds, aliases, noun) {
60
67
  if (ciHit !== undefined) {
61
68
  return { canonical: ciHit, matched: true, via: ERegistryResolveVia.Case };
62
69
  }
63
- // 3. Singular/plural.
64
- for (const variant of singularPluralVariants(noun)) {
65
- const v = variant.toLowerCase();
66
- const hit = declaredIds.find((d) => d.toLowerCase() === v);
67
- if (hit !== undefined) {
68
- return { canonical: hit, matched: true, via: ERegistryResolveVia.SingularPlural };
70
+ // 3 + 4. Chained singular/plural × suffix strip/append.
71
+ //
72
+ // Build an ordered candidate list — the noun itself first, then its
73
+ // singular/plural variants (plural variants BEFORE the suffix test) and for
74
+ // EACH candidate test an exact (case-insensitive) match against the declared
75
+ // ids, then a suffix strip, then a suffix append. The first candidate that
76
+ // lands on a DECLARED id wins. Because the transforms compose, a doubly-off
77
+ // noun (`buttons` →(plural)→ `button` →(suffix)→ `button-command`) resolves
78
+ // where either layer alone would no-op. `via` records which transforms fired:
79
+ // a variant matching exactly is `SingularPlural`, the original noun matching
80
+ // by suffix is `Suffix`, and a variant matching by suffix is the chained
81
+ // `SingularPluralSuffix`. The original noun can never match here EXACTLY (the
82
+ // identity / case-fold short-circuits above already handled that).
83
+ const candidates = [noun, ...singularPluralVariants(noun)];
84
+ for (const candidate of candidates) {
85
+ const isVariant = candidate !== noun;
86
+ const cLower = candidate.toLowerCase();
87
+ const exact = declaredIds.find((d) => d.toLowerCase() === cLower);
88
+ if (exact !== undefined && isVariant) {
89
+ return { canonical: exact, matched: true, via: ERegistryResolveVia.SingularPlural };
69
90
  }
70
- }
71
- // 4. Suffix strip/append. A declared id whose trailing `-`/`.`/`_` segment,
72
- // once stripped, equals the noun (`button` `button-command`); or the noun
73
- // plus a suffix another declared id carries yields a declared id.
74
- const suffixStripped = declaredIds.find((d) => {
75
- const stripped = d.replace(/[-_.][a-z0-9]+$/i, '');
76
- return stripped.toLowerCase() === lower && stripped.toLowerCase() !== d.toLowerCase();
77
- });
78
- if (suffixStripped !== undefined) {
79
- return { canonical: suffixStripped, matched: true, via: ERegistryResolveVia.Suffix };
80
- }
81
- for (const d of declaredIds) {
82
- const m = d.match(/([-_.][a-z0-9]+)$/i);
83
- if (m && `${lower}${m[1].toLowerCase()}` === d.toLowerCase()) {
84
- return { canonical: d, matched: true, via: ERegistryResolveVia.Suffix };
91
+ const suffixStripped = declaredIds.find((d) => {
92
+ const stripped = d.replace(/[-_.][a-z0-9]+$/i, '');
93
+ return stripped.toLowerCase() === cLower && stripped.toLowerCase() !== d.toLowerCase();
94
+ });
95
+ if (suffixStripped !== undefined) {
96
+ return {
97
+ canonical: suffixStripped,
98
+ matched: true,
99
+ via: isVariant ? ERegistryResolveVia.SingularPluralSuffix : ERegistryResolveVia.Suffix,
100
+ };
101
+ }
102
+ for (const d of declaredIds) {
103
+ const m = d.match(/([-_.][a-z0-9]+)$/i);
104
+ if (m && `${cLower}${m[1].toLowerCase()}` === d.toLowerCase()) {
105
+ return {
106
+ canonical: d,
107
+ matched: true,
108
+ via: isVariant ? ERegistryResolveVia.SingularPluralSuffix : ERegistryResolveVia.Suffix,
109
+ };
110
+ }
85
111
  }
86
112
  }
87
113
  // Nothing resolved — the honest unmatched identity.
@@ -1 +1 @@
1
- {"version":3,"file":"trace.command.d.ts","sourceRoot":"","sources":["../../src/commands/trace.command.ts"],"names":[],"mappings":"AAmBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA6EhC,eAAO,MAAM,YAAY,EAAE,eAwH1B,CAAC"}
1
+ {"version":3,"file":"trace.command.d.ts","sourceRoot":"","sources":["../../src/commands/trace.command.ts"],"names":[],"mappings":"AAmBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA8EhC,eAAO,MAAM,YAAY,EAAE,eA+H1B,CAAC"}
@@ -17,6 +17,7 @@ const TRACE_ROLE_ORDER = [
17
17
  TraceRole.Declare,
18
18
  TraceRole.Register,
19
19
  TraceRole.Consume,
20
+ TraceRole.Render,
20
21
  TraceRole.Reference,
21
22
  ];
22
23
  function renderTraceLiteral(report, limit) {
@@ -31,7 +32,7 @@ function renderTraceLiteral(report, limit) {
31
32
  }
32
33
  // Mirror `shrk registry <name> where`'s `<role> file:line` line idiom so the
33
34
  // two surfaces read the same — `trace literal` is the same scanner + classifier
34
- // without a pre-declared registry, just with two extra roles (`registered`,
35
+ // without a pre-declared registry, just with extra roles (`register`, `render`,
35
36
  // `reference`). The raw source line stays in `--json` (`text`); the human view
36
37
  // is classification-first (direction), not a grep-style text dump.
37
38
  process.stdout.write('\n');
@@ -154,6 +155,11 @@ export const traceCommand = {
154
155
  process.stdout.write(header(`Trace: ${query}`));
155
156
  if (!resolution.bestMatch) {
156
157
  process.stdout.write(' no matches found.\n');
158
+ // The bare-`trace` path resolves a FUZZY registry query, not an exact
159
+ // string literal — quotes are stripped by the shell, so we can't tell a
160
+ // literal from a query and must not auto-route. Point the agent at the
161
+ // exact-literal tracer (`trace literal`) so a no-match here isn't a dead end.
162
+ process.stderr.write(`hint: to trace an exact string literal across files, use: shrk trace literal "${query}"\n`);
157
163
  return 1;
158
164
  }
159
165
  process.stdout.write(`Confidence: ${resolution.confidence}\n`);
@@ -1 +1 @@
1
- {"version":3,"file":"wiring.command.d.ts","sourceRoot":"","sources":["../../src/commands/wiring.command.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAAwB,KAAK,eAAe,EAAmB,MAAM,wBAAwB,CAAC;AAKrG;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,CAsDrF;AA+TD,eAAO,MAAM,aAAa,EAAE,eAgB3B,CAAC"}
1
+ {"version":3,"file":"wiring.command.d.ts","sourceRoot":"","sources":["../../src/commands/wiring.command.ts"],"names":[],"mappings":"AAAA,OAAO,EASL,KAAK,cAAc,EACpB,MAAM,sBAAsB,CAAC;AAM9B,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAMhC;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,MAAM,EAAE,cAAc,EAAE,QAAQ,EAAE,OAAO,GAAG,MAAM,CAsDrF;AAyZD,eAAO,MAAM,aAAa,EAAE,eAgB3B,CAAC"}
@@ -1,9 +1,10 @@
1
1
  import { buildRegistrationGraph, explainWiring, registrationChain, registrationGraphSignature, registrationOrphans, registrationUnprovided, } from '@shrkcrft/boundaries';
2
- import { resolveProjectConfig } from '@shrkcrft/inspector';
2
+ import { refExists, resolveChangedFiles, resolveProjectConfig } from '@shrkcrft/inspector';
3
3
  import { createHash } from 'node:crypto';
4
4
  import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
5
  import * as nodePath from 'node:path';
6
- import { flagBool, resolveCwd } from "../command-registry.js";
6
+ import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
7
+ import { ExitCode } from "../exit-codes.js";
7
8
  import { asJson, header, kv } from "../output/format-output.js";
8
9
  const SITE_DISPLAY_CAP = 50;
9
10
  /**
@@ -245,6 +246,33 @@ function noIdiomsHint(wantJson) {
245
246
  function siteLine(s) {
246
247
  return `${s.file}:${s.line} [${s.idiom}]`;
247
248
  }
249
+ /**
250
+ * The changed-file scope for a `--changed-only` / `--base <ref>` query, or an
251
+ * empty result when neither flag is set (whole-graph query). `--base <ref>` diffs
252
+ * against that ref; bare `--changed-only` uses the working tree. Reuses the same
253
+ * {@link resolveChangedFiles} the `finish` composite and boundary gates use, so
254
+ * the scope semantics match across every changed-only surface.
255
+ *
256
+ * Two honesty guards: an unresolvable `--base` ref returns a distinct `error`
257
+ * (never a silent empty scope that reads as "nothing changed" over a typo'd ref);
258
+ * and SHRK's own engine-written state under `.sharkcraft/` (this command writes a
259
+ * cache + usage log) is excluded so it can't pollute an otherwise-clean tree into
260
+ * a false non-empty scope.
261
+ */
262
+ function changedScopeFor(args, cwd) {
263
+ const base = flagString(args, 'base');
264
+ const changedOnly = flagBool(args, 'changed-only');
265
+ if (!base && !changedOnly)
266
+ return {};
267
+ if (base && !refExists(cwd, base)) {
268
+ return { error: `cannot resolve --base ref '${base}' — not a valid commit/branch` };
269
+ }
270
+ const opts = base
271
+ ? { projectRoot: cwd, since: base }
272
+ : { projectRoot: cwd, includeWorktree: true };
273
+ const files = resolveChangedFiles(opts).files.filter((f) => f !== '.sharkcraft' && !f.startsWith('.sharkcraft/'));
274
+ return { files };
275
+ }
248
276
  async function wiringChain(args) {
249
277
  const cwd = resolveCwd(args);
250
278
  const wantJson = flagBool(args, 'json');
@@ -312,15 +340,39 @@ async function wiringUnprovided(args) {
312
340
  }
313
341
  if (!loaded.graph)
314
342
  return noIdiomsHint(wantJson);
315
- const unprovided = registrationUnprovided(loaded.graph);
343
+ const scoped = changedScopeFor(args, cwd);
344
+ if (scoped.error) {
345
+ if (wantJson) {
346
+ process.stdout.write(asJson({ schema: loaded.graph.schema, scoped: true, error: scoped.error, verified: false }) + '\n');
347
+ }
348
+ else {
349
+ process.stderr.write(`error: ${scoped.error}\n`);
350
+ }
351
+ return ExitCode.NotVerified;
352
+ }
353
+ const scope = scoped.files;
354
+ // An empty changed scope evaluated NOTHING — honest `2` (not-verified), never
355
+ // a green `0` that reads as "no unprovided tokens" (a25 §1.1 exit contract).
356
+ if (scope && scope.length === 0) {
357
+ if (wantJson) {
358
+ process.stdout.write(asJson({ schema: loaded.graph.schema, scoped: true, total: 0, unprovided: [], verified: false }) + '\n');
359
+ return ExitCode.NotVerified;
360
+ }
361
+ process.stdout.write(header('Unprovided tokens (declared/injected but never provided)'));
362
+ process.stdout.write(' – No files in the changed scope — nothing to verify (not verified).\n');
363
+ return ExitCode.NotVerified;
364
+ }
365
+ const unprovided = registrationUnprovided(loaded.graph, scope);
316
366
  if (wantJson) {
317
- process.stdout.write(asJson({ schema: loaded.graph.schema, total: unprovided.length, unprovided }) + '\n');
318
- return unprovided.length > 0 ? 1 : 0;
367
+ process.stdout.write(asJson({ schema: loaded.graph.schema, scoped: scope !== undefined, total: unprovided.length, unprovided }) + '\n');
368
+ return unprovided.length > 0 ? ExitCode.Failure : ExitCode.VerifiedPass;
319
369
  }
320
370
  process.stdout.write(header('Unprovided tokens (declared/injected but never provided)'));
371
+ if (scope)
372
+ process.stdout.write(kv('scope', `changed-only (${scope.length} file(s))`) + '\n');
321
373
  if (unprovided.length === 0) {
322
- process.stdout.write(' ✓ Every declared/injected token has a provider. ✓\n');
323
- return 0;
374
+ process.stdout.write(` ✓ Every declared/injected token${scope ? ' in the changed scope' : ''} has a provider. ✓\n`);
375
+ return ExitCode.VerifiedPass;
324
376
  }
325
377
  process.stdout.write(` ${unprovided.length} token(s) resolve to nothing at runtime:\n`);
326
378
  for (const u of unprovided) {
@@ -328,7 +380,7 @@ async function wiringUnprovided(args) {
328
380
  const where = site ? ` (${siteLine(site)})` : '';
329
381
  process.stdout.write(` ✗ ${u.token}${where}\n`);
330
382
  }
331
- return 1;
383
+ return ExitCode.Failure;
332
384
  }
333
385
  async function wiringOrphans(args) {
334
386
  const cwd = resolveCwd(args);
@@ -343,14 +395,36 @@ async function wiringOrphans(args) {
343
395
  }
344
396
  if (!loaded.graph)
345
397
  return noIdiomsHint(wantJson);
346
- const orphans = registrationOrphans(loaded.graph);
398
+ const scoped = changedScopeFor(args, cwd);
399
+ if (scoped.error) {
400
+ if (wantJson) {
401
+ process.stdout.write(asJson({ schema: loaded.graph.schema, scoped: true, error: scoped.error, verified: false }) + '\n');
402
+ }
403
+ else {
404
+ process.stderr.write(`error: ${scoped.error}\n`);
405
+ }
406
+ return ExitCode.NotVerified;
407
+ }
408
+ const scope = scoped.files;
409
+ if (scope && scope.length === 0) {
410
+ if (wantJson) {
411
+ process.stdout.write(asJson({ schema: loaded.graph.schema, scoped: true, total: 0, orphans: [], verified: false }) + '\n');
412
+ return ExitCode.NotVerified;
413
+ }
414
+ process.stdout.write(header('Orphan registrations (provided but nothing consumes)'));
415
+ process.stdout.write(' – No files in the changed scope — nothing to verify (not verified).\n');
416
+ return ExitCode.NotVerified;
417
+ }
418
+ const orphans = registrationOrphans(loaded.graph, scope);
347
419
  if (wantJson) {
348
- process.stdout.write(asJson({ schema: loaded.graph.schema, total: orphans.length, orphans }) + '\n');
420
+ process.stdout.write(asJson({ schema: loaded.graph.schema, scoped: scope !== undefined, total: orphans.length, orphans }) + '\n');
349
421
  return 0;
350
422
  }
351
423
  process.stdout.write(header('Orphan registrations (provided but nothing consumes)'));
424
+ if (scope)
425
+ process.stdout.write(kv('scope', `changed-only (${scope.length} file(s))`) + '\n');
352
426
  if (orphans.length === 0) {
353
- process.stdout.write(' ✓ Every provided token is consumed somewhere. ✓\n');
427
+ process.stdout.write(` ✓ Every provided token${scope ? ' in the changed scope' : ''} is consumed somewhere. ✓\n`);
354
428
  return 0;
355
429
  }
356
430
  process.stdout.write(` ${orphans.length} provided token(s) nothing injects:\n`);
@@ -360,12 +434,12 @@ async function wiringOrphans(args) {
360
434
  }
361
435
  return 0;
362
436
  }
363
- const WIRING_USAGE = 'shrk wiring explain <ruleId> | test <candidate.json|inline> | chain <token> | unprovided | orphans [--json]';
437
+ const WIRING_USAGE = 'shrk wiring explain <ruleId> | test <candidate.json|inline> | chain <token> | unprovided | orphans [--changed-only | --base <ref>] [--json]';
364
438
  export const wiringCommand = {
365
439
  name: 'wiring',
366
- description: 'Author-loop + runtime-wiring queries (no config write): `explain <ruleId>` / `test <candidate>` show what a wiring rule extracts; `chain <token>` / `unprovided` / `orphans` query the DI/registration graph (declared→provided→consumed) for the silent-at-runtime bugs imports can\'t see.',
440
+ description: 'Author-loop + runtime-wiring queries (no config write): `explain <ruleId>` / `test <candidate>` show what a wiring rule extracts; `chain <token>` / `unprovided` / `orphans` query the DI/registration graph (declared→provided→consumed) for the silent-at-runtime bugs imports can\'t see. `unprovided` / `orphans` accept `--changed-only` (working tree) or `--base <ref>` to scope the verdict to the changeset.',
367
441
  usage: WIRING_USAGE,
368
- booleanFlags: new Set(['json']),
442
+ booleanFlags: new Set(['json', 'changed-only']),
369
443
  async run(args) {
370
444
  const sub = args.positional[0];
371
445
  if (sub === 'explain')
@@ -46,4 +46,45 @@ export declare function promoteForStrict(code: number, strict: boolean): number;
46
46
  * command that actually returned `2`.
47
47
  */
48
48
  export declare function argvHasStrict(argv: readonly string[]): boolean;
49
+ /**
50
+ * True when the argv carries the global `--exit-trailer` (before the `--`
51
+ * sentinel). This is the machine channel that survives a pipe: when set, the
52
+ * final verdict is written as the LAST stderr line (`shrk-exit: <code>`), so an
53
+ * agent that pipes a gate to `head`/`grep` can still read shrk's real exit off a
54
+ * channel the pipe can't swallow. See {@link emitPipeExitSignal}.
55
+ */
56
+ export declare function argvHasExitTrailer(argv: readonly string[]): boolean;
57
+ /**
58
+ * Is `commandPath` (space-joined, e.g. `check boundaries` / `wiring unprovided`
59
+ * / `finish`) a gate/verify verb whose exit code carries a chained verdict?
60
+ * Matches the exact path, its first-two-token subverb, or its top-level verb —
61
+ * so `check boundaries --json` (2 tokens) and a bare `finish` (1) both resolve.
62
+ */
63
+ export declare function isGateVerb(commandPath: string): boolean;
64
+ /** Injectable surface for {@link emitPipeExitSignal} (isTTY + writer + trailer). */
65
+ export interface IPipeExitOptions {
66
+ /** True when shrk's stdout is NOT a terminal (i.e. piped/redirected). */
67
+ readonly piped: boolean;
68
+ /** True when `--exit-trailer` was requested. */
69
+ readonly trailer: boolean;
70
+ /** stderr writer; defaults to `process.stderr.write`. Overridable for tests. */
71
+ readonly write?: (s: string) => void;
72
+ }
73
+ /**
74
+ * Keep the honest `0`/`1`/`2` exit code READABLE through the shape agents reach
75
+ * for first — the trailing pipe. `<gate> | head` reports `head`'s `$?`, so a true
76
+ * `2` (not-verified) or `1` (failure) evaporates into a `0`. Two channels survive
77
+ * the pipe because both go to stderr:
78
+ *
79
+ * (a) a one-line WARNING when stdout is piped AND the code is non-zero — a
80
+ * masked `0`→`0` is harmless, so the note is reserved for the case that
81
+ * actually loses information (a masked `1`/`2`);
82
+ * (b) the `shrk-exit: <code>` TRAILER whenever `--exit-trailer` is set (any
83
+ * code), so a caller that opts in gets the verdict machine-readably.
84
+ *
85
+ * A no-op for non-gate verbs. Called once in {@link runCli} after the final
86
+ * (strict-promoted) code is known, so every gate/verify verb is covered without
87
+ * threading anything through each command.
88
+ */
89
+ export declare function emitPipeExitSignal(commandPath: string, code: number, opts: IPipeExitOptions): void;
49
90
  //# sourceMappingURL=exit-codes.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"exit-codes.d.ts","sourceRoot":"","sources":["../src/exit-codes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,oBAAY,QAAQ;IAClB,YAAY,IAAI;IAChB,OAAO,IAAI;IACX,WAAW,IAAI;CAChB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAGtE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAM9D"}
1
+ {"version":3,"file":"exit-codes.d.ts","sourceRoot":"","sources":["../src/exit-codes.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;GAyBG;AACH,oBAAY,QAAQ;IAClB,YAAY,IAAI;IAChB,OAAO,IAAI;IACX,WAAW,IAAI;CAChB;AAED;;;;;;;GAOG;AACH,wBAAgB,gBAAgB,CAAC,IAAI,EAAE,MAAM,EAAE,MAAM,EAAE,OAAO,GAAG,MAAM,CAGtE;AAED;;;;;;GAMG;AACH,wBAAgB,aAAa,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAM9D;AAED;;;;;;GAMG;AACH,wBAAgB,kBAAkB,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAMnE;AA4BD;;;;;GAKG;AACH,wBAAgB,UAAU,CAAC,WAAW,EAAE,MAAM,GAAG,OAAO,CAMvD;AAED,oFAAoF;AACpF,MAAM,WAAW,gBAAgB;IAC/B,yEAAyE;IACzE,QAAQ,CAAC,KAAK,EAAE,OAAO,CAAC;IACxB,gDAAgD;IAChD,QAAQ,CAAC,OAAO,EAAE,OAAO,CAAC;IAC1B,gFAAgF;IAChF,QAAQ,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC,EAAE,MAAM,KAAK,IAAI,CAAC;CACtC;AAED;;;;;;;;;;;;;;;GAeG;AACH,wBAAgB,kBAAkB,CAChC,WAAW,EAAE,MAAM,EACnB,IAAI,EAAE,MAAM,EACZ,IAAI,EAAE,gBAAgB,GACrB,IAAI,CAWN"}
@@ -59,3 +59,88 @@ export function argvHasStrict(argv) {
59
59
  }
60
60
  return false;
61
61
  }
62
+ /**
63
+ * True when the argv carries the global `--exit-trailer` (before the `--`
64
+ * sentinel). This is the machine channel that survives a pipe: when set, the
65
+ * final verdict is written as the LAST stderr line (`shrk-exit: <code>`), so an
66
+ * agent that pipes a gate to `head`/`grep` can still read shrk's real exit off a
67
+ * channel the pipe can't swallow. See {@link emitPipeExitSignal}.
68
+ */
69
+ export function argvHasExitTrailer(argv) {
70
+ for (const t of argv) {
71
+ if (t === '--')
72
+ break;
73
+ if (t === '--exit-trailer')
74
+ return true;
75
+ }
76
+ return false;
77
+ }
78
+ /**
79
+ * Command paths (space-joined top-level + subverb, as {@link extractCommandPath}
80
+ * emits) whose exit code is a HONEST verdict an agent chains on — the set for
81
+ * which a masked exit is a real hazard. Kept deliberately broad over the gate /
82
+ * verify surface; membership only ever gates whether {@link emitPipeExitSignal}
83
+ * may write a one-line stderr note, never behavior.
84
+ */
85
+ const GATE_VERB_PATHS = new Set([
86
+ 'finish',
87
+ 'gate',
88
+ 'arch',
89
+ 'doctor',
90
+ 'diff-check',
91
+ 'check boundaries',
92
+ 'check wiring',
93
+ 'check orphans',
94
+ 'check policy',
95
+ 'check imports',
96
+ 'wiring unprovided',
97
+ 'wiring orphans',
98
+ 'wiring chain',
99
+ 'registry',
100
+ 'graph why',
101
+ 'graph cycles',
102
+ ]);
103
+ /**
104
+ * Is `commandPath` (space-joined, e.g. `check boundaries` / `wiring unprovided`
105
+ * / `finish`) a gate/verify verb whose exit code carries a chained verdict?
106
+ * Matches the exact path, its first-two-token subverb, or its top-level verb —
107
+ * so `check boundaries --json` (2 tokens) and a bare `finish` (1) both resolve.
108
+ */
109
+ export function isGateVerb(commandPath) {
110
+ if (GATE_VERB_PATHS.has(commandPath))
111
+ return true;
112
+ const parts = commandPath.split(' ').filter((p) => p.length > 0);
113
+ if (parts.length >= 2 && GATE_VERB_PATHS.has(`${parts[0]} ${parts[1]}`))
114
+ return true;
115
+ if (parts.length >= 1 && GATE_VERB_PATHS.has(parts[0]))
116
+ return true;
117
+ return false;
118
+ }
119
+ /**
120
+ * Keep the honest `0`/`1`/`2` exit code READABLE through the shape agents reach
121
+ * for first — the trailing pipe. `<gate> | head` reports `head`'s `$?`, so a true
122
+ * `2` (not-verified) or `1` (failure) evaporates into a `0`. Two channels survive
123
+ * the pipe because both go to stderr:
124
+ *
125
+ * (a) a one-line WARNING when stdout is piped AND the code is non-zero — a
126
+ * masked `0`→`0` is harmless, so the note is reserved for the case that
127
+ * actually loses information (a masked `1`/`2`);
128
+ * (b) the `shrk-exit: <code>` TRAILER whenever `--exit-trailer` is set (any
129
+ * code), so a caller that opts in gets the verdict machine-readably.
130
+ *
131
+ * A no-op for non-gate verbs. Called once in {@link runCli} after the final
132
+ * (strict-promoted) code is known, so every gate/verify verb is covered without
133
+ * threading anything through each command.
134
+ */
135
+ export function emitPipeExitSignal(commandPath, code, opts) {
136
+ if (!isGateVerb(commandPath))
137
+ return;
138
+ const write = opts.write ?? ((s) => void process.stderr.write(s));
139
+ if (opts.piped && code !== 0) {
140
+ write(`note: stdout is piped — $? reflects the downstream command, not shrk (exit ${code}); ` +
141
+ `use PIPESTATUS[0] or --exit-trailer to read shrk's verdict.\n`);
142
+ }
143
+ // The trailer is written LAST so it is the final stderr line a caller reads.
144
+ if (opts.trailer)
145
+ write(`shrk-exit: ${code}\n`);
146
+ }
@@ -1,4 +1,5 @@
1
1
  import { type IChangedScopeOptions } from '@shrkcrft/inspector';
2
+ import { ExitCode } from '../exit-codes.js';
2
3
  export declare const FINISH_SCHEMA: "sharkcraft.finish/v1";
3
4
  /** Outcome of one sub-gate. `skipped` = nothing to evaluate (loud, never silent green). */
4
5
  export type FinishGateStatus = 'pass' | 'fail' | 'skipped';
@@ -9,7 +10,7 @@ export interface IFinishItem {
9
10
  readonly message: string;
10
11
  }
11
12
  export interface IFinishGate {
12
- readonly name: 'boundaries' | 'imports' | 'wiring' | 'policy' | 'orphans';
13
+ readonly name: 'boundaries' | 'imports' | 'wiring' | 'unprovided' | 'policy' | 'orphans' | 'arch';
13
14
  readonly status: FinishGateStatus;
14
15
  /** One-line reason (e.g. why skipped, or the error/warning counts). */
15
16
  readonly detail: string;
@@ -17,6 +18,14 @@ export interface IFinishGate {
17
18
  readonly warnings: number;
18
19
  /** Failing/notable items (capped by the renderer, full in JSON). */
19
20
  readonly items: readonly IFinishItem[];
21
+ /**
22
+ * Advisory gates report signal but NEVER decide the verdict: they cannot fail
23
+ * the composite and do not count as "something was evaluated" (so an advisory
24
+ * pass can't turn an all-skipped run green). Used by `arch`, whose cycle
25
+ * findings are change-informative but must not attribute a pre-existing cycle
26
+ * to this changeset.
27
+ */
28
+ readonly advisory?: boolean;
20
29
  }
21
30
  export interface IFinishImpact {
22
31
  readonly ran: boolean;
@@ -35,8 +44,18 @@ export interface IFinishReport {
35
44
  };
36
45
  readonly gates: readonly IFinishGate[];
37
46
  readonly impact: IFinishImpact;
38
- /** `fail` iff any gate failed OR the config could not load; else `pass`. */
39
- readonly verdict: 'pass' | 'fail';
47
+ /**
48
+ * The honest tri-state verdict:
49
+ * `fail` — a deciding gate failed (or the config could not load).
50
+ * `not-verified` — NOTHING was actually evaluated (every deciding gate
51
+ * skipped / the changed scope had nothing to gate). Never a
52
+ * green `pass` — "evaluated nothing" is `2`, not `0`.
53
+ * `pass` — at least one deciding gate ran over a real scope and every
54
+ * deciding gate passed.
55
+ */
56
+ readonly verdict: 'pass' | 'fail' | 'not-verified';
57
+ /** The exit code this verdict maps to (0 pass / 1 fail / 2 not-verified). */
58
+ readonly exit: ExitCode;
40
59
  /** Total warning-severity findings across gates (non-blocking). */
41
60
  readonly warnings: number;
42
61
  /** Set when sharkcraft.config.ts could not be loaded — forces a `fail`. */