@shrkcrft/cli 0.1.0-alpha.23 → 0.1.0-alpha.24

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 (33) hide show
  1. package/dist/commands/check.command.d.ts.map +1 -1
  2. package/dist/commands/check.command.js +109 -2
  3. package/dist/commands/command-catalog.d.ts +11 -0
  4. package/dist/commands/command-catalog.d.ts.map +1 -1
  5. package/dist/commands/command-catalog.js +91 -0
  6. package/dist/commands/finish.command.d.ts +3 -0
  7. package/dist/commands/finish.command.d.ts.map +1 -0
  8. package/dist/commands/finish.command.js +70 -0
  9. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  10. package/dist/commands/graph-code-subverbs.js +42 -23
  11. package/dist/commands/help.command.d.ts.map +1 -1
  12. package/dist/commands/help.command.js +20 -2
  13. package/dist/commands/impact.command.d.ts.map +1 -1
  14. package/dist/commands/impact.command.js +17 -22
  15. package/dist/commands/smart-context.command.d.ts.map +1 -1
  16. package/dist/commands/smart-context.command.js +14 -37
  17. package/dist/commands/trace.command.d.ts.map +1 -1
  18. package/dist/commands/trace.command.js +73 -3
  19. package/dist/commands/wiring.command.d.ts +12 -0
  20. package/dist/commands/wiring.command.d.ts.map +1 -0
  21. package/dist/commands/wiring.command.js +384 -0
  22. package/dist/diff/collect-changed-paths.d.ts +5 -3
  23. package/dist/diff/collect-changed-paths.d.ts.map +1 -1
  24. package/dist/diff/collect-changed-paths.js +73 -36
  25. package/dist/diff/deleted-orphans.d.ts +42 -0
  26. package/dist/diff/deleted-orphans.d.ts.map +1 -0
  27. package/dist/diff/deleted-orphans.js +46 -0
  28. package/dist/finish/run-finish.d.ts +62 -0
  29. package/dist/finish/run-finish.d.ts.map +1 -0
  30. package/dist/finish/run-finish.js +239 -0
  31. package/dist/main.d.ts.map +1 -1
  32. package/dist/main.js +4 -0
  33. package/package.json +33 -33
@@ -4,7 +4,7 @@ import { analyzeImpact, analyzeTestImpact, findSymbolInProject, FuzzyImpactSourc
4
4
  import { flagBool, flagNumber, flagString, flagList, resolveCwd, } from "../command-registry.js";
5
5
  import { asJson, header, kv } from "../output/format-output.js";
6
6
  import { fuzzyImpactAmbiguousHints, renderFailureHints } from "../output/failure-hints.js";
7
- import { collectChangedPaths } from "../diff/collect-changed-paths.js";
7
+ import { computeDeletedOrphans } from "../diff/deleted-orphans.js";
8
8
  function collectFiles(args, cwd) {
9
9
  const diagnostics = [];
10
10
  const explicitFiles = flagList(args, 'files');
@@ -297,17 +297,24 @@ async function runDeletedOrphans(args) {
297
297
  const cwd = resolveCwd(args);
298
298
  const wantJson = flagBool(args, 'json') || flagString(args, 'format') === 'json';
299
299
  const sinceRef = flagString(args, 'since');
300
- const changed = collectChangedPaths({ cwd, ...(sinceRef ? { ref: sinceRef } : {}) });
301
- if (!changed.isAvailable) {
300
+ const staged = flagBool(args, 'staged');
301
+ const scan = await computeDeletedOrphans(cwd, {
302
+ ...(sinceRef ? { since: sinceRef } : {}),
303
+ ...(staged ? { staged: true } : {}),
304
+ });
305
+ if (!scan.ok) {
302
306
  if (wantJson) {
303
- process.stdout.write(asJson({ ok: false, error: changed.error }) + '\n');
307
+ process.stdout.write(asJson({ ok: false, error: scan.error }) + '\n');
308
+ }
309
+ else if (scan.reason === 'diff-unavailable') {
310
+ process.stderr.write(`Cannot resolve diff: ${scan.error ?? 'unknown'}\n`);
304
311
  }
305
312
  else {
306
- process.stderr.write(`Cannot resolve diff: ${changed.error ?? 'unknown'}\n`);
313
+ process.stderr.write(`${scan.error ?? 'orphan check unavailable'}\n`);
307
314
  }
308
315
  return 2;
309
316
  }
310
- const deleted = changed.deleted;
317
+ const deleted = scan.deleted;
311
318
  if (deleted.length === 0) {
312
319
  if (wantJson) {
313
320
  process.stdout.write(asJson({
@@ -315,33 +322,21 @@ async function runDeletedOrphans(args) {
315
322
  resolvedDeleted: [],
316
323
  unresolvedDeleted: [],
317
324
  orphans: [],
318
- diagnostics: [`no deleted files in diff vs ${changed.ref}`],
325
+ diagnostics: [`no deleted files in diff vs ${scan.ref}`],
319
326
  }) + '\n');
320
327
  return 0;
321
328
  }
322
329
  process.stdout.write(header('Deleted-symbol orphans'));
323
- process.stdout.write(`No deleted files in diff vs ${changed.ref}.\n`);
330
+ process.stdout.write(`No deleted files in diff vs ${scan.ref}.\n`);
324
331
  return 0;
325
332
  }
326
- const { GraphStore, GraphQueryApi } = await import('@shrkcrft/graph');
327
- if (!new GraphStore(cwd).exists()) {
328
- const msg = 'code-graph store missing — run `shrk graph index` first.';
329
- if (wantJson) {
330
- process.stdout.write(asJson({ ok: false, error: msg }) + '\n');
331
- }
332
- else {
333
- process.stderr.write(msg + '\n');
334
- }
335
- return 2;
336
- }
337
- const { findDeletedOrphans } = await import('@shrkcrft/impact-engine');
338
- const report = findDeletedOrphans(GraphQueryApi.fromStore(cwd), deleted);
333
+ const report = scan.report;
339
334
  if (wantJson) {
340
335
  process.stdout.write(asJson(report) + '\n');
341
336
  return report.orphans.length > 0 ? 1 : 0;
342
337
  }
343
338
  process.stdout.write(header('Deleted-symbol orphans'));
344
- process.stdout.write(`Deleted files (vs ${changed.ref}): ${deleted.length}\n`);
339
+ process.stdout.write(`Deleted files (vs ${scan.ref}): ${deleted.length}\n`);
345
340
  if (report.orphans.length === 0) {
346
341
  process.stdout.write('no orphaned importers\n');
347
342
  for (const d of report.diagnostics.slice(0, 5))
@@ -1 +1 @@
1
- {"version":3,"file":"smart-context.command.d.ts","sourceRoot":"","sources":["../../src/commands/smart-context.command.ts"],"names":[],"mappings":"AA0BA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAmFhC,eAAO,MAAM,mBAAmB,EAAE,eAmQjC,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,4BAA4B,EAAE,eAsF1C,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,uBAAuB,EAAE,eAwBrC,CAAC;AAEF,8DAA8D;AAC9D,eAAO,MAAM,uBAAuB,EAAE,eAkCrC,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,EAAE,eAmH/C,CAAC;AA2JF;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,EAAE,eAoH/C,CAAC;AA2JF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,eAgG/C,CAAC;AAyPF,4EAA4E;AAC5E,eAAO,MAAM,kCAAkC,EAAE,eAuHhD,CAAC;AAMF,iFAAiF;AACjF,eAAO,MAAM,mCAAmC,EAAE,eAsCjD,CAAC;AAqJF,UAAU,eAAe;IACvB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,SAAS,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,eAAe,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAWzF"}
1
+ {"version":3,"file":"smart-context.command.d.ts","sourceRoot":"","sources":["../../src/commands/smart-context.command.ts"],"names":[],"mappings":"AA2BA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAmFhC,eAAO,MAAM,mBAAmB,EAAE,eAmQjC,CAAC;AAEF,+EAA+E;AAC/E,eAAO,MAAM,4BAA4B,EAAE,eAsF1C,CAAC;AAEF,sDAAsD;AACtD,eAAO,MAAM,uBAAuB,EAAE,eAwBrC,CAAC;AAEF,8DAA8D;AAC9D,eAAO,MAAM,uBAAuB,EAAE,eAkCrC,CAAC;AAEF;;;;;;;;;GASG;AACH,eAAO,MAAM,iCAAiC,EAAE,eAmH/C,CAAC;AA2JF;;;;;;;GAOG;AACH,eAAO,MAAM,iCAAiC,EAAE,eAoH/C,CAAC;AA2JF;;;;;;GAMG;AACH,eAAO,MAAM,iCAAiC,EAAE,eAgG/C,CAAC;AAyPF,4EAA4E;AAC5E,eAAO,MAAM,kCAAkC,EAAE,eAuHhD,CAAC;AAMF,iFAAiF;AACjF,eAAO,MAAM,mCAAmC,EAAE,eAsCjD,CAAC;AAqJF,UAAU,eAAe;IACvB,6CAA6C;IAC7C,OAAO,EAAE,MAAM,CAAC;IAChB,kFAAkF;IAClF,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,OAAO,EAAE,MAAM,CAAC;IAChB,SAAS,EAAE,MAAM,CAAC;IAClB,uFAAuF;IACvF,SAAS,EAAE,OAAO,CAAC;IACnB;;;;OAIG;IACH,aAAa,CAAC,EAAE,MAAM,CAAC;IACvB,mEAAmE;IACnE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAED;;;;GAIG;AACH,wBAAgB,2BAA2B,CAAC,CAAC,EAAE,eAAe,GAAG,SAAS,GAAG,MAAM,GAAG,IAAI,CAWzF"}
@@ -1,9 +1,10 @@
1
- import { spawn, spawnSync } from 'node:child_process';
1
+ import { spawn } from 'node:child_process';
2
2
  import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, unlinkSync, writeFileSync } from 'node:fs';
3
3
  import * as nodePath from 'node:path';
4
4
  import * as os from 'node:os';
5
5
  import { AiMessageRole, buildPromptMessages, EnhancementPipeline, EnhancementStageKind, OllamaProvider, buildDefaultEnhancementStages, buildFastEnhancementStages, selectAiProvider, } from '@shrkcrft/ai';
6
6
  import { buildContext } from '@shrkcrft/context';
7
+ import { runGitLines } from '@shrkcrft/shared';
7
8
  import { EdgeKind, GraphQueryApi, GraphStore, NodeKind } from '@shrkcrft/graph';
8
9
  import { buildProjectOverview, buildTaskPacket, contextTuningBoostFor, inspectSharkcraft, renderOverviewText, } from '@shrkcrft/inspector';
9
10
  import { flagBool, flagList, flagNumber, flagString, resolveCwd, } from "../command-registry.js";
@@ -2182,45 +2183,21 @@ function isSemanticAutomationDisabled() {
2182
2183
  * ripples through. Empty array on git failure or no-graph.
2183
2184
  */
2184
2185
  function collectChangedPathsWithNeighbors(cwd, gitRef) {
2185
- // Use `node:child_process` spawnSync (works under both Bun and Node)
2186
- // instead of `Bun.spawnSync` so the CLI runs cleanly on a pure-Node
2187
- // runtime after `npm i -g @shrkcrft/cli`. The compat-node preflight
2188
- // gate flags `Bun.*` direct usages as publish blockers.
2189
- let changed;
2190
- try {
2191
- const out = spawnSync('git', ['-C', cwd, 'diff', '--name-only', `${gitRef}...HEAD`], {
2192
- encoding: 'utf8',
2193
- });
2194
- if (out.status !== 0)
2195
- return [];
2196
- changed = (out.stdout ?? '')
2197
- .split('\n')
2198
- .map((s) => s.trim())
2199
- .filter((s) => s.length > 0);
2200
- }
2201
- catch {
2202
- return [];
2203
- }
2204
- if (changed.length === 0)
2186
+ // `runGitLines` is shell-free + high-maxBuffer, so a large changeset can't
2187
+ // ENOBUFS-crash the feedback bundle. (It also keeps the CLI on
2188
+ // `node:child_process` rather than `Bun.*`, which the compat-node preflight
2189
+ // gate flags as a publish blocker.)
2190
+ const committed = runGitLines(cwd, ['diff', '--name-only', `${gitRef}...HEAD`]);
2191
+ if (!committed.ok || committed.lines.length === 0)
2205
2192
  return [];
2193
+ const changed = [...committed.lines];
2206
2194
  // Also include uncommitted changes — agent feedback should reflect
2207
2195
  // the *current* working tree, not just committed deltas.
2208
- try {
2209
- const out = spawnSync('git', ['-C', cwd, 'diff', '--name-only', 'HEAD'], {
2210
- encoding: 'utf8',
2211
- });
2212
- if (out.status === 0) {
2213
- const uncommitted = (out.stdout ?? '')
2214
- .split('\n')
2215
- .map((s) => s.trim())
2216
- .filter((s) => s.length > 0);
2217
- for (const p of uncommitted)
2218
- if (!changed.includes(p))
2219
- changed.push(p);
2220
- }
2221
- }
2222
- catch {
2223
- // ignore
2196
+ const uncommitted = runGitLines(cwd, ['diff', '--name-only', 'HEAD']);
2197
+ if (uncommitted.ok) {
2198
+ for (const p of uncommitted.lines)
2199
+ if (!changed.includes(p))
2200
+ changed.push(p);
2224
2201
  }
2225
2202
  const set = new Set(changed);
2226
2203
  // One-hop graph expansion if the graph is fresh.
@@ -1 +1 @@
1
- {"version":3,"file":"trace.command.d.ts","sourceRoot":"","sources":["../../src/commands/trace.command.ts"],"names":[],"mappings":"AAcA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAOhC,eAAO,MAAM,YAAY,EAAE,eAqH1B,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;AA6EhC,eAAO,MAAM,YAAY,EAAE,eAwH1B,CAAC"}
@@ -6,16 +6,86 @@
6
6
  * shared query resolver and prints structured trace output.
7
7
  */
8
8
  import { findSymbolInProject, inspectSharkcraft, QueryMatchKind, resolveQuery, } from '@shrkcrft/inspector';
9
+ import { traceLiteral, TraceRole, } from '@shrkcrft/boundaries';
9
10
  import { flagBool, flagList, flagNumber, flagString, resolveCwd, } from "../command-registry.js";
10
- import { asJson, header } from "../output/format-output.js";
11
+ import { asJson, header, kv } from "../output/format-output.js";
11
12
  function describeMatch(m) {
12
13
  return `${m.kind.padEnd(12)} ${m.id}${m.label && m.label !== m.id ? ` — ${m.label}` : ''} [${m.score.toFixed(0)}]`;
13
14
  }
15
+ const LITERAL_SITE_CAP = 30;
16
+ const TRACE_ROLE_ORDER = [
17
+ TraceRole.Declare,
18
+ TraceRole.Register,
19
+ TraceRole.Consume,
20
+ TraceRole.Reference,
21
+ ];
22
+ function renderTraceLiteral(report, limit) {
23
+ process.stdout.write(header(`Trace literal: "${report.literal}"`));
24
+ process.stdout.write(kv('found', `${report.total} site(s) across ${report.files} file(s)`) + '\n');
25
+ if (report.aliases.length > 0) {
26
+ process.stdout.write(kv('const aliases', report.aliases.join(', ')) + '\n');
27
+ }
28
+ if (report.total === 0) {
29
+ process.stdout.write('\nNo occurrences of that exact string literal in scope.\n');
30
+ return;
31
+ }
32
+ // Mirror `shrk registry <name> where`'s `<role> file:line` line idiom so the
33
+ // 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
+ // `reference`). The raw source line stays in `--json` (`text`); the human view
36
+ // is classification-first (direction), not a grep-style text dump.
37
+ process.stdout.write('\n');
38
+ for (const role of TRACE_ROLE_ORDER) {
39
+ const sites = report.byRole[role];
40
+ for (const s of sites.slice(0, limit)) {
41
+ const alias = s.viaAlias ? ` [via ${s.viaAlias}]` : '';
42
+ process.stdout.write(` ${role.padEnd(10)} ${s.file}:${s.line}${alias}\n`);
43
+ }
44
+ if (sites.length > limit) {
45
+ process.stdout.write(` ${role.padEnd(10)} … (${sites.length - limit} more — pass --json)\n`);
46
+ }
47
+ }
48
+ }
49
+ /**
50
+ * `shrk trace literal "<string>"`: trace an EXACT string literal across the
51
+ * codebase, classified by direction (declare → register → consume), resolving
52
+ * `const X = "lit"` aliases too. Generalizes `registry … where` to any
53
+ * cross-fence string contract (a kind slug, permission id, route key) with no
54
+ * pre-declared registry — the chain grep can't give (direction + role + layer).
55
+ */
56
+ function runTraceLiteral(args) {
57
+ const cwd = resolveCwd(args);
58
+ const wantJson = flagBool(args, 'json');
59
+ // `trace literal "<lit>"` → the literal is positional[1] (quoted by the user
60
+ // so an internal space stays one token); join the rest defensively.
61
+ const literal = args.positional.slice(1).join(' ');
62
+ if (literal === '') {
63
+ process.stderr.write('Usage: shrk trace literal "<string>" [--glob <g1,g2>] [--no-aliases] [--limit N] [--json]\n');
64
+ return 2;
65
+ }
66
+ const globs = flagList(args, 'glob');
67
+ const report = traceLiteral(cwd, literal, {
68
+ ...(globs.length > 0 ? { globs } : {}),
69
+ resolveAliases: !flagBool(args, 'no-aliases'),
70
+ });
71
+ if (wantJson) {
72
+ process.stdout.write(asJson(report) + '\n');
73
+ return 0;
74
+ }
75
+ const limitRaw = flagNumber(args, 'limit');
76
+ const limit = limitRaw !== undefined && limitRaw > 0 ? Math.floor(limitRaw) : LITERAL_SITE_CAP;
77
+ renderTraceLiteral(report, limit);
78
+ return 0;
79
+ }
14
80
  export const traceCommand = {
15
81
  name: 'trace',
16
- description: 'Fuzzy trace — accept any free-form query (file path, construct id, symbol, plugin key, helper id, template id, knowledge id, command). Read-only.',
17
- usage: 'shrk trace <query> [--limit <n>] [--kind file|construct|knowledge|template|helper|playbook|policy|command] [--deep] [--json]',
82
+ description: 'Fuzzy trace — accept any free-form query (file path, construct id, symbol, plugin key, helper id, template id, knowledge id, command). `trace literal "<string>"` traces an exact string literal\'s declare→register→consume chain across layers (alias-resolved). Read-only.',
83
+ usage: 'shrk trace <query> | shrk trace literal "<string>" [--glob <g>] [--no-aliases] [--limit <n>] [--kind ...] [--deep] [--json]',
84
+ booleanFlags: new Set(['json', 'deep', 'no-aliases']),
18
85
  async run(args) {
86
+ // `trace literal "<lit>"` — cross-fence string-contract tracer (3.3).
87
+ if (args.positional[0] === 'literal')
88
+ return runTraceLiteral(args);
19
89
  // Direct symbol trace via --symbol <Name>
20
90
  const symbol = flagString(args, 'symbol');
21
91
  if (symbol) {
@@ -0,0 +1,12 @@
1
+ import { type IWiringExplain } from '@shrkcrft/boundaries';
2
+ import { type ICommandHandler } from '../command-registry.js';
3
+ /**
4
+ * Render an {@link IWiringExplain} to stdout. Shared by `wiring explain`,
5
+ * `wiring test`, and `check wiring --explain` so all three speak the same
6
+ * dialect. JSON emits the full payload; text mirrors `search tuning explain`
7
+ * (header → loaded sets → per-site detail → the set-difference → verdict).
8
+ * Always returns 0 — explain is informational, the verdict is in the output.
9
+ */
10
+ export declare function renderWiringExplain(report: IWiringExplain, wantJson: boolean): number;
11
+ export declare const wiringCommand: ICommandHandler;
12
+ //# sourceMappingURL=wiring.command.d.ts.map
@@ -0,0 +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"}
@@ -0,0 +1,384 @@
1
+ import { buildRegistrationGraph, explainWiring, registrationChain, registrationGraphSignature, registrationOrphans, registrationUnprovided, } from '@shrkcrft/boundaries';
2
+ import { resolveProjectConfig } from '@shrkcrft/inspector';
3
+ import { createHash } from 'node:crypto';
4
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
5
+ import * as nodePath from 'node:path';
6
+ import { flagBool, resolveCwd } from "../command-registry.js";
7
+ import { asJson, header, kv } from "../output/format-output.js";
8
+ const SITE_DISPLAY_CAP = 50;
9
+ /**
10
+ * Render an {@link IWiringExplain} to stdout. Shared by `wiring explain`,
11
+ * `wiring test`, and `check wiring --explain` so all three speak the same
12
+ * dialect. JSON emits the full payload; text mirrors `search tuning explain`
13
+ * (header → loaded sets → per-site detail → the set-difference → verdict).
14
+ * Always returns 0 — explain is informational, the verdict is in the output.
15
+ */
16
+ export function renderWiringExplain(report, wantJson) {
17
+ if (wantJson) {
18
+ process.stdout.write(asJson(report) + '\n');
19
+ return 0;
20
+ }
21
+ process.stdout.write(header(`Wiring explain: ${report.ruleId} (${report.mode})`));
22
+ if (report.description)
23
+ process.stdout.write(` ${report.description}\n`);
24
+ if (report.groupBy)
25
+ process.stdout.write(kv('groupBy', report.groupBy) + '\n');
26
+ process.stdout.write(kv('declared', `${report.declared.distinctCount} distinct across ${report.declared.filesScanned} file(s)`) +
27
+ '\n');
28
+ process.stdout.write(kv('registered', `${report.registered.distinctCount} distinct across ${report.registered.filesScanned} file(s)`) + '\n');
29
+ if (report.declared.error)
30
+ process.stdout.write(` ! declared side: ${report.declared.error}\n`);
31
+ if (report.registered.error) {
32
+ process.stdout.write(` ! registered side: ${report.registered.error}\n`);
33
+ }
34
+ writeSites('Declared sites', report.declared.sites);
35
+ writeSites('Registered sites', report.registered.sites);
36
+ if (report.declaredNotRegistered.length > 0) {
37
+ process.stdout.write(`\nDeclared but NOT registered (${report.declaredNotRegistered.length}):\n`);
38
+ for (const s of report.declaredNotRegistered.slice(0, SITE_DISPLAY_CAP)) {
39
+ process.stdout.write(` ✗ ${s.token} (${s.file}:${s.line})\n`);
40
+ }
41
+ if (report.declaredNotRegistered.length > SITE_DISPLAY_CAP) {
42
+ process.stdout.write(` … (${report.declaredNotRegistered.length - SITE_DISPLAY_CAP} more)\n`);
43
+ }
44
+ }
45
+ if (report.registeredNotDeclared.length > 0) {
46
+ process.stdout.write(`\nRegistered but NOT declared (parity, ${report.registeredNotDeclared.length}):\n`);
47
+ for (const s of report.registeredNotDeclared.slice(0, SITE_DISPLAY_CAP)) {
48
+ process.stdout.write(` ✗ ${s.token} (${s.file}:${s.line})\n`);
49
+ }
50
+ if (report.registeredNotDeclared.length > SITE_DISPLAY_CAP) {
51
+ process.stdout.write(` … (${report.registeredNotDeclared.length - SITE_DISPLAY_CAP} more)\n`);
52
+ }
53
+ }
54
+ for (const d of report.diagnostics)
55
+ process.stdout.write(` ! ${d}\n`);
56
+ process.stdout.write(`\nVerdict: ${report.verdict}\n`);
57
+ return 0;
58
+ }
59
+ function writeSites(label, sites) {
60
+ process.stdout.write(`\n${label} (${sites.length}):\n`);
61
+ if (sites.length === 0) {
62
+ process.stdout.write(' (none extracted)\n');
63
+ return;
64
+ }
65
+ for (const s of sites.slice(0, SITE_DISPLAY_CAP)) {
66
+ process.stdout.write(` • ${s.token} (${s.file}:${s.line})\n`);
67
+ }
68
+ if (sites.length > SITE_DISPLAY_CAP) {
69
+ process.stdout.write(` … (${sites.length - SITE_DISPLAY_CAP} more)\n`);
70
+ }
71
+ }
72
+ /** Light structural check: a candidate must at least name an id + both sides. */
73
+ function validateCandidate(raw) {
74
+ if (raw === null || typeof raw !== 'object') {
75
+ return { error: 'candidate must be a JSON object describing a wiring rule' };
76
+ }
77
+ const r = raw;
78
+ if (typeof r['id'] !== 'string' || r['id'].length === 0) {
79
+ return { error: 'candidate is missing a non-empty string "id"' };
80
+ }
81
+ const declared = r['declared'];
82
+ if (declared === null || typeof declared !== 'object' || !Array.isArray(declared.files)) {
83
+ return { error: 'candidate "declared" must be a source object with a files[] glob list' };
84
+ }
85
+ if (r['registered'] === undefined) {
86
+ return { error: 'candidate is missing "registered" (a source object or an array of them)' };
87
+ }
88
+ // Deeper misconfiguration (bad regex / no capture group) is surfaced as a
89
+ // diagnostic by the engine, not rejected here — that is the point of a dry run.
90
+ return { rule: raw };
91
+ }
92
+ async function wiringExplain(args) {
93
+ const cwd = resolveCwd(args);
94
+ const wantJson = flagBool(args, 'json');
95
+ const ruleId = args.positional[1];
96
+ if (!ruleId) {
97
+ process.stderr.write('Usage: shrk wiring explain <ruleId> [--json]\n');
98
+ return 2;
99
+ }
100
+ const loaded = await resolveProjectConfig(cwd);
101
+ if (!loaded.ok) {
102
+ const msg = loaded.error.message;
103
+ if (wantJson)
104
+ process.stdout.write(asJson({ ok: false, error: msg }) + '\n');
105
+ else
106
+ process.stderr.write(`Could not load config: ${msg}\n`);
107
+ return 1;
108
+ }
109
+ const rules = loaded.value.config.wiringRules ?? [];
110
+ const rule = rules.find((r) => r.id === ruleId);
111
+ if (!rule) {
112
+ const ids = rules.map((r) => r.id);
113
+ if (wantJson) {
114
+ process.stdout.write(asJson({ ok: false, error: 'not-found', ruleId, available: ids }) + '\n');
115
+ return 2;
116
+ }
117
+ process.stderr.write(`No wiring rule "${ruleId}". Configured rules: ${ids.length > 0 ? ids.join(', ') : '(none)'}\n`);
118
+ return 2;
119
+ }
120
+ return renderWiringExplain(explainWiring(cwd, rule), wantJson);
121
+ }
122
+ async function wiringTest(args) {
123
+ const cwd = resolveCwd(args);
124
+ const wantJson = flagBool(args, 'json');
125
+ const candidateArg = args.positional[1];
126
+ if (!candidateArg) {
127
+ process.stderr.write('Usage: shrk wiring test <candidate.json | inline-json> [--json]\n' +
128
+ ' Dry-runs an ephemeral wiring rule against the live tree without writing config.\n');
129
+ return 2;
130
+ }
131
+ // A leading `{` is treated as inline JSON; otherwise the arg is a file path.
132
+ let source;
133
+ if (candidateArg.trimStart().startsWith('{')) {
134
+ source = candidateArg;
135
+ }
136
+ else {
137
+ if (!existsSync(candidateArg)) {
138
+ const msg = `candidate file not found: ${candidateArg}`;
139
+ if (wantJson)
140
+ process.stdout.write(asJson({ ok: false, error: msg }) + '\n');
141
+ else
142
+ process.stderr.write(msg + '\n');
143
+ return 2;
144
+ }
145
+ source = readFileSync(candidateArg, 'utf8');
146
+ }
147
+ let parsed;
148
+ try {
149
+ parsed = JSON.parse(source);
150
+ }
151
+ catch (e) {
152
+ const msg = `candidate is not valid JSON: ${e instanceof Error ? e.message : String(e)}`;
153
+ if (wantJson)
154
+ process.stdout.write(asJson({ ok: false, error: msg }) + '\n');
155
+ else
156
+ process.stderr.write(msg + '\n');
157
+ return 2;
158
+ }
159
+ const { rule, error } = validateCandidate(parsed);
160
+ if (!rule) {
161
+ if (wantJson)
162
+ process.stdout.write(asJson({ ok: false, error }) + '\n');
163
+ else
164
+ process.stderr.write(`Invalid candidate: ${error}\n`);
165
+ return 2;
166
+ }
167
+ return renderWiringExplain(explainWiring(cwd, rule), wantJson);
168
+ }
169
+ /**
170
+ * Load the configured DI/registration idioms and build the graph, cached by a
171
+ * signature of the exact source files it reads + an idiom hash. Repeated session
172
+ * queries (chain + unprovided + orphans) reuse ONE scan; any edit to a matched
173
+ * file shifts the signature and rebuilds, so the cache can never return a stale
174
+ * verdict (no `shrk graph index` required — the cache tracks its real data
175
+ * source, not the unrelated code-graph digest). Best-effort — any read/write
176
+ * error falls back to a fresh build.
177
+ */
178
+ async function loadRegistrationGraph(cwd) {
179
+ const loaded = await resolveProjectConfig(cwd);
180
+ if (!loaded.ok)
181
+ return { ok: false, idioms: [], error: loaded.error.message };
182
+ const idioms = loaded.value.config.registrationGraph ?? [];
183
+ if (idioms.length === 0)
184
+ return { ok: true, idioms: [] };
185
+ const cacheKey = registrationCacheKey(cwd, idioms);
186
+ const cachePath = nodePath.join(cwd, '.sharkcraft', 'cache', 'registration-graph.json');
187
+ if (cacheKey) {
188
+ const cached = readRegistrationCache(cachePath, cacheKey);
189
+ if (cached)
190
+ return { ok: true, idioms, graph: cached };
191
+ }
192
+ const graph = buildRegistrationGraph(cwd, idioms);
193
+ if (cacheKey)
194
+ writeRegistrationCache(cachePath, cacheKey, graph);
195
+ return { ok: true, idioms, graph };
196
+ }
197
+ /**
198
+ * `<file-signature>:<idiom-hash>` — keyed on the mtime/size signature of the
199
+ * exact files the graph is built from (its real data source), NOT the code-graph
200
+ * index digest. Any source edit shifts the signature even when no reindex has
201
+ * run, so the persisted cache can never return a stale wiring verdict. Undefined
202
+ * only if signing itself throws (then the query rebuilds every time).
203
+ */
204
+ function registrationCacheKey(cwd, idioms) {
205
+ try {
206
+ const signature = registrationGraphSignature(cwd, idioms);
207
+ const idiomHash = createHash('sha1').update(JSON.stringify(idioms)).digest('hex').slice(0, 16);
208
+ return `${signature}:${idiomHash}`;
209
+ }
210
+ catch {
211
+ return undefined;
212
+ }
213
+ }
214
+ function readRegistrationCache(cachePath, key) {
215
+ try {
216
+ if (!existsSync(cachePath))
217
+ return undefined;
218
+ const cached = JSON.parse(readFileSync(cachePath, 'utf8'));
219
+ return cached.key === key && cached.graph ? cached.graph : undefined;
220
+ }
221
+ catch {
222
+ return undefined;
223
+ }
224
+ }
225
+ function writeRegistrationCache(cachePath, key, graph) {
226
+ try {
227
+ mkdirSync(nodePath.dirname(cachePath), { recursive: true });
228
+ writeFileSync(cachePath, JSON.stringify({ key, graph }));
229
+ }
230
+ catch {
231
+ // best-effort cache; a write failure never breaks the query.
232
+ }
233
+ }
234
+ function noIdiomsHint(wantJson) {
235
+ if (wantJson) {
236
+ process.stdout.write(asJson({ schema: 'sharkcraft.registration-graph/v1', idioms: [], tokens: [] }) + '\n');
237
+ return 0;
238
+ }
239
+ process.stdout.write(header('Registration graph'));
240
+ process.stdout.write(' No registration idioms configured. Declare `registrationGraph[]` in\n' +
241
+ ' sharkcraft.config.ts (declared/provided/consumed shapes) to model your DI\n' +
242
+ ' wiring as a queryable graph — see docs/wiring.md.\n');
243
+ return 0;
244
+ }
245
+ function siteLine(s) {
246
+ return `${s.file}:${s.line} [${s.idiom}]`;
247
+ }
248
+ async function wiringChain(args) {
249
+ const cwd = resolveCwd(args);
250
+ const wantJson = flagBool(args, 'json');
251
+ const token = args.positional[1];
252
+ if (!token) {
253
+ process.stderr.write('Usage: shrk wiring chain <token> [--json]\n');
254
+ return 2;
255
+ }
256
+ const loaded = await loadRegistrationGraph(cwd);
257
+ if (!loaded.ok) {
258
+ if (wantJson)
259
+ process.stdout.write(asJson({ ok: false, error: loaded.error }) + '\n');
260
+ else
261
+ process.stderr.write(`Could not load config: ${loaded.error}\n`);
262
+ return 1;
263
+ }
264
+ if (!loaded.graph)
265
+ return noIdiomsHint(wantJson);
266
+ const chain = registrationChain(loaded.graph, token);
267
+ if (!chain) {
268
+ if (wantJson) {
269
+ process.stdout.write(asJson({ ok: false, error: 'not-found', token }) + '\n');
270
+ return 1;
271
+ }
272
+ process.stdout.write(header(`Wiring chain: ${token}`));
273
+ process.stdout.write(' Token not found in the registration graph.\n');
274
+ return 1;
275
+ }
276
+ if (wantJson) {
277
+ process.stdout.write(asJson(chain) + '\n');
278
+ return 0;
279
+ }
280
+ process.stdout.write(header(`Wiring chain: ${token}`));
281
+ const section = (label, sites) => {
282
+ process.stdout.write(`\n${label} (${sites.length}):\n`);
283
+ if (sites.length === 0)
284
+ process.stdout.write(' (none)\n');
285
+ for (const s of sites)
286
+ process.stdout.write(` • ${siteLine(s)}\n`);
287
+ };
288
+ section('declared', chain.declared);
289
+ section('provided', chain.provided);
290
+ section('consumed', chain.consumed);
291
+ if (!chain.isProvided && (chain.isDeclared || chain.isConsumed)) {
292
+ process.stdout.write('\n ⚠ UNPROVIDED — declared/injected but never provided (silent at runtime).\n');
293
+ }
294
+ else if (chain.isProvided && !chain.isConsumed) {
295
+ process.stdout.write('\n ⚠ ORPHAN — provided but nothing consumes it.\n');
296
+ }
297
+ else {
298
+ process.stdout.write('\n ✓ declared → provided → consumed.\n');
299
+ }
300
+ return 0;
301
+ }
302
+ async function wiringUnprovided(args) {
303
+ const cwd = resolveCwd(args);
304
+ const wantJson = flagBool(args, 'json');
305
+ const loaded = await loadRegistrationGraph(cwd);
306
+ if (!loaded.ok) {
307
+ if (wantJson)
308
+ process.stdout.write(asJson({ ok: false, error: loaded.error }) + '\n');
309
+ else
310
+ process.stderr.write(`Could not load config: ${loaded.error}\n`);
311
+ return 1;
312
+ }
313
+ if (!loaded.graph)
314
+ return noIdiomsHint(wantJson);
315
+ const unprovided = registrationUnprovided(loaded.graph);
316
+ if (wantJson) {
317
+ process.stdout.write(asJson({ schema: loaded.graph.schema, total: unprovided.length, unprovided }) + '\n');
318
+ return unprovided.length > 0 ? 1 : 0;
319
+ }
320
+ process.stdout.write(header('Unprovided tokens (declared/injected but never provided)'));
321
+ if (unprovided.length === 0) {
322
+ process.stdout.write(' ✓ Every declared/injected token has a provider. ✓\n');
323
+ return 0;
324
+ }
325
+ process.stdout.write(` ${unprovided.length} token(s) resolve to nothing at runtime:\n`);
326
+ for (const u of unprovided) {
327
+ const site = u.declared[0] ?? u.consumed[0];
328
+ const where = site ? ` (${siteLine(site)})` : '';
329
+ process.stdout.write(` ✗ ${u.token}${where}\n`);
330
+ }
331
+ return 1;
332
+ }
333
+ async function wiringOrphans(args) {
334
+ const cwd = resolveCwd(args);
335
+ const wantJson = flagBool(args, 'json');
336
+ const loaded = await loadRegistrationGraph(cwd);
337
+ if (!loaded.ok) {
338
+ if (wantJson)
339
+ process.stdout.write(asJson({ ok: false, error: loaded.error }) + '\n');
340
+ else
341
+ process.stderr.write(`Could not load config: ${loaded.error}\n`);
342
+ return 1;
343
+ }
344
+ if (!loaded.graph)
345
+ return noIdiomsHint(wantJson);
346
+ const orphans = registrationOrphans(loaded.graph);
347
+ if (wantJson) {
348
+ process.stdout.write(asJson({ schema: loaded.graph.schema, total: orphans.length, orphans }) + '\n');
349
+ return 0;
350
+ }
351
+ process.stdout.write(header('Orphan registrations (provided but nothing consumes)'));
352
+ if (orphans.length === 0) {
353
+ process.stdout.write(' ✓ Every provided token is consumed somewhere. ✓\n');
354
+ return 0;
355
+ }
356
+ process.stdout.write(` ${orphans.length} provided token(s) nothing injects:\n`);
357
+ for (const o of orphans) {
358
+ const site = o.provided[0];
359
+ process.stdout.write(` • ${o.token}${site ? ` (${siteLine(site)})` : ''}\n`);
360
+ }
361
+ return 0;
362
+ }
363
+ const WIRING_USAGE = 'shrk wiring explain <ruleId> | test <candidate.json|inline> | chain <token> | unprovided | orphans [--json]';
364
+ export const wiringCommand = {
365
+ 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.',
367
+ usage: WIRING_USAGE,
368
+ booleanFlags: new Set(['json']),
369
+ async run(args) {
370
+ const sub = args.positional[0];
371
+ if (sub === 'explain')
372
+ return wiringExplain(args);
373
+ if (sub === 'test')
374
+ return wiringTest(args);
375
+ if (sub === 'chain')
376
+ return wiringChain(args);
377
+ if (sub === 'unprovided')
378
+ return wiringUnprovided(args);
379
+ if (sub === 'orphans')
380
+ return wiringOrphans(args);
381
+ process.stderr.write(`Usage: ${WIRING_USAGE}\n`);
382
+ return 2;
383
+ },
384
+ };