@shrkcrft/cli 0.1.0-alpha.22 → 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 (108) hide show
  1. package/dist/command-registry.d.ts +9 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +15 -0
  4. package/dist/commands/ai-status.command.d.ts.map +1 -1
  5. package/dist/commands/ai-status.command.js +10 -0
  6. package/dist/commands/apply.command.d.ts.map +1 -1
  7. package/dist/commands/apply.command.js +9 -2
  8. package/dist/commands/ask.command.d.ts.map +1 -1
  9. package/dist/commands/ask.command.js +2 -1
  10. package/dist/commands/changes.command.d.ts.map +1 -1
  11. package/dist/commands/changes.command.js +14 -4
  12. package/dist/commands/check.command.d.ts.map +1 -1
  13. package/dist/commands/check.command.js +139 -7
  14. package/dist/commands/command-catalog.d.ts +11 -0
  15. package/dist/commands/command-catalog.d.ts.map +1 -1
  16. package/dist/commands/command-catalog.js +291 -0
  17. package/dist/commands/commands.command.d.ts +15 -0
  18. package/dist/commands/commands.command.d.ts.map +1 -1
  19. package/dist/commands/commands.command.js +162 -3
  20. package/dist/commands/compress.command.d.ts.map +1 -1
  21. package/dist/commands/compress.command.js +39 -4
  22. package/dist/commands/context.command.d.ts.map +1 -1
  23. package/dist/commands/context.command.js +3 -1
  24. package/dist/commands/daily.commands.d.ts.map +1 -1
  25. package/dist/commands/daily.commands.js +48 -5
  26. package/dist/commands/diff-check.command.d.ts.map +1 -1
  27. package/dist/commands/diff-check.command.js +6 -2
  28. package/dist/commands/feedback.command.d.ts.map +1 -1
  29. package/dist/commands/feedback.command.js +4 -0
  30. package/dist/commands/finish.command.d.ts +3 -0
  31. package/dist/commands/finish.command.d.ts.map +1 -0
  32. package/dist/commands/finish.command.js +70 -0
  33. package/dist/commands/framework.command.d.ts.map +1 -1
  34. package/dist/commands/framework.command.js +20 -3
  35. package/dist/commands/gate.command.d.ts.map +1 -1
  36. package/dist/commands/gate.command.js +98 -13
  37. package/dist/commands/graph-code-subverbs.d.ts.map +1 -1
  38. package/dist/commands/graph-code-subverbs.js +215 -81
  39. package/dist/commands/graph.command.d.ts.map +1 -1
  40. package/dist/commands/graph.command.js +6 -0
  41. package/dist/commands/help.command.d.ts.map +1 -1
  42. package/dist/commands/help.command.js +20 -2
  43. package/dist/commands/impact.command.d.ts.map +1 -1
  44. package/dist/commands/impact.command.js +86 -1
  45. package/dist/commands/knowledge.command.d.ts.map +1 -1
  46. package/dist/commands/knowledge.command.js +21 -1
  47. package/dist/commands/onboard.command.d.ts.map +1 -1
  48. package/dist/commands/onboard.command.js +11 -2
  49. package/dist/commands/orchestrate.command.d.ts.map +1 -1
  50. package/dist/commands/orchestrate.command.js +6 -1
  51. package/dist/commands/pack-author.command.d.ts.map +1 -1
  52. package/dist/commands/pack-author.command.js +13 -1
  53. package/dist/commands/packs.command.d.ts.map +1 -1
  54. package/dist/commands/packs.command.js +95 -17
  55. package/dist/commands/paths.command.d.ts.map +1 -1
  56. package/dist/commands/paths.command.js +8 -1
  57. package/dist/commands/pipelines.command.d.ts.map +1 -1
  58. package/dist/commands/pipelines.command.js +2 -1
  59. package/dist/commands/plan-context.command.d.ts.map +1 -1
  60. package/dist/commands/plan-context.command.js +6 -3
  61. package/dist/commands/plan-simulate.command.js +1 -1
  62. package/dist/commands/policy-lint.command.d.ts.map +1 -1
  63. package/dist/commands/policy-lint.command.js +20 -7
  64. package/dist/commands/recommend.command.d.ts.map +1 -1
  65. package/dist/commands/recommend.command.js +10 -2
  66. package/dist/commands/registry.command.d.ts.map +1 -1
  67. package/dist/commands/registry.command.js +117 -9
  68. package/dist/commands/report.command.js +1 -1
  69. package/dist/commands/reuse.command.d.ts.map +1 -1
  70. package/dist/commands/reuse.command.js +17 -5
  71. package/dist/commands/review.command.d.ts.map +1 -1
  72. package/dist/commands/review.command.js +20 -2
  73. package/dist/commands/rule-graph-subverbs.d.ts.map +1 -1
  74. package/dist/commands/rule-graph-subverbs.js +31 -4
  75. package/dist/commands/search-structural.command.d.ts.map +1 -1
  76. package/dist/commands/search-structural.command.js +2 -2
  77. package/dist/commands/search.command.d.ts.map +1 -1
  78. package/dist/commands/search.command.js +19 -1
  79. package/dist/commands/simulate.command.d.ts.map +1 -1
  80. package/dist/commands/simulate.command.js +6 -1
  81. package/dist/commands/smart-context.command.d.ts.map +1 -1
  82. package/dist/commands/smart-context.command.js +101 -41
  83. package/dist/commands/spec.command.d.ts.map +1 -1
  84. package/dist/commands/spec.command.js +73 -13
  85. package/dist/commands/task-context.command.d.ts.map +1 -1
  86. package/dist/commands/task-context.command.js +26 -2
  87. package/dist/commands/test.command.d.ts.map +1 -1
  88. package/dist/commands/test.command.js +17 -0
  89. package/dist/commands/trace.command.d.ts.map +1 -1
  90. package/dist/commands/trace.command.js +87 -4
  91. package/dist/commands/wiring.command.d.ts +12 -0
  92. package/dist/commands/wiring.command.d.ts.map +1 -0
  93. package/dist/commands/wiring.command.js +384 -0
  94. package/dist/diff/collect-changed-paths.d.ts +5 -3
  95. package/dist/diff/collect-changed-paths.d.ts.map +1 -1
  96. package/dist/diff/collect-changed-paths.js +73 -36
  97. package/dist/diff/deleted-orphans.d.ts +42 -0
  98. package/dist/diff/deleted-orphans.d.ts.map +1 -0
  99. package/dist/diff/deleted-orphans.js +46 -0
  100. package/dist/finish/run-finish.d.ts +62 -0
  101. package/dist/finish/run-finish.d.ts.map +1 -0
  102. package/dist/finish/run-finish.js +239 -0
  103. package/dist/main.d.ts.map +1 -1
  104. package/dist/main.js +19 -1
  105. package/dist/status/freshness.d.ts +30 -0
  106. package/dist/status/freshness.d.ts.map +1 -0
  107. package/dist/status/freshness.js +81 -0
  108. package/package.json +33 -33
@@ -5,14 +5,14 @@
5
5
  * focused. The entry command imports each `run*` and routes when the
6
6
  * first positional matches the subverb name.
7
7
  */
8
- import { buildFullIndex, changedFilesSince, detectChangedAndDeleted, detectGraphFreshness, EdgeKind, GraphQueryApi, GraphStore, hasCallGraphReferences, NodeKind, updateChanged, } from '@shrkcrft/graph';
8
+ import { buildFullIndex, changedFilesSince, detectChangedAndDeleted, detectGraphFreshness, EdgeKind, GraphQueryApi, GraphStore, hasCallGraphReferences, isGraphStoreCorruptError, NodeKind, updateChanged, } from '@shrkcrft/graph';
9
9
  import { analyzeGraphImpact } from '@shrkcrft/impact-engine';
10
10
  import { BridgeStore, RuleGraphQueryApi } from '@shrkcrft/rule-graph';
11
11
  import { FrameworkQueryApi, FrameworkStore } from '@shrkcrft/framework-scanners';
12
12
  import { existsSync } from 'node:fs';
13
13
  import * as nodePath from 'node:path';
14
14
  import { compactArrayToColumnar } from '@shrkcrft/compress';
15
- import { flagBool, flagString, resolveCwd } from "../command-registry.js";
15
+ import { flagBool, flagNumber, flagPositiveInt, flagString, resolveCwd } from "../command-registry.js";
16
16
  import { asJson, header, kv } from "../output/format-output.js";
17
17
  import { maybeRunInWatchMode } from "../output/watch-loop.js";
18
18
  /**
@@ -59,6 +59,32 @@ function maybeColumnarize(payload, args) {
59
59
  }
60
60
  const STALE_HINT = `Index is missing or stale. Run 'shrk graph index' to build it.`;
61
61
  const STALE_RESULT_HINT = 'Some result files changed since the index was built — auto-refresh is on by default (you passed --no-refresh / SHRK_GRAPH_NO_REFRESH). Drop the opt-out, or run `shrk graph index --changed`, for fresh results.';
62
+ const CORRUPT_HINT = 'code graph store is corrupt — run `shrk graph index` to rebuild.';
63
+ /**
64
+ * Per-list display cap for `graph context`. The JSON payload always reports the
65
+ * true pre-slice count (`total<List>`) and a `<list>Truncated` flag so a
66
+ * high-fan-in node is never silently capped (mirrors runGraphCycles /
67
+ * runGraphCallers). Kept in sync with the MCP `get_graph_context` tool.
68
+ */
69
+ const CONTEXT_LIST_CAP = 50;
70
+ /**
71
+ * Resolve a `--limit N` per-list cap for the graph read commands. Absent → the
72
+ * command's `fallback` (the historical default — 50 for `context`, 200 for
73
+ * `callers`/`impact`); `--limit 0` (or any non-positive) → unbounded so a
74
+ * consumer can pull the full set off a high-fan-in barrel/hub in one call.
75
+ * Returns `Infinity` for the unbounded case: `arr.slice(0, Infinity)` yields
76
+ * every row and `len > Infinity` is `false`, so the `<list>Truncated` flag stays
77
+ * honest (never "truncated" when nothing was dropped). Malformed `--limit abc`
78
+ * (NaN) degrades to `fallback`, matching `flagPositiveInt`.
79
+ */
80
+ function resolveListLimit(args, fallback) {
81
+ const v = flagNumber(args, 'limit');
82
+ if (v === undefined)
83
+ return fallback;
84
+ if (v <= 0)
85
+ return Number.POSITIVE_INFINITY;
86
+ return Math.floor(v);
87
+ }
62
88
  /**
63
89
  * Refresh-by-default: incrementally reindex changed/deleted files BEFORE
64
90
  * querying so an agent's just-saved edits are reflected, then print a one-line
@@ -122,8 +148,31 @@ function indexBehindHint(cwd) {
122
148
  return null;
123
149
  return `Index is ${behind} file(s) behind (${f.modified.length} modified, ${f.added.length} new, ${f.deleted.length} deleted) — run \`shrk graph index --changed\` and retry.`;
124
150
  }
151
+ /**
152
+ * Reject a stray positional on a subverb that takes NO file argument
153
+ * (status/cycles/unresolved/index). These previously printed a byte-identical
154
+ * global report for any/no/bogus arg, so `shrk graph status foo` silently
155
+ * swallowed `foo`. Returns the exit code to use (2) when an unexpected
156
+ * positional[1+] is present, or null when the args are clean. `subverb` is
157
+ * positional[0]; anything at positional[1] is the offending extra.
158
+ */
159
+ function strayPositionalError(args, subverb) {
160
+ const extra = args.positional[1];
161
+ if (extra === undefined)
162
+ return null;
163
+ if (flagBool(args, 'json')) {
164
+ process.stdout.write(asJson({ ok: false, error: 'unexpected-argument', argument: extra }) + '\n');
165
+ }
166
+ else {
167
+ process.stderr.write(`'shrk graph ${subverb}' takes no positional argument (got "${extra}"). Usage: shrk graph ${subverb} [--json]\n`);
168
+ }
169
+ return 2;
170
+ }
125
171
  // ─── shrk graph index ─────────────────────────────────────────────────
126
172
  export async function runGraphIndex(args) {
173
+ const stray = strayPositionalError(args, 'index');
174
+ if (stray !== null)
175
+ return stray;
127
176
  // --watch: run the index once, then re-run on file changes. Every
128
177
  // tick after the first uses the incremental updater so a 5-file edit
129
178
  // takes < 100ms. Default watch path is the project root; pass
@@ -214,6 +263,9 @@ async function runGraphIndexOnce(args) {
214
263
  export async function runGraphCycles(args) {
215
264
  const cwd = resolveCwd(args);
216
265
  const wantJson = flagBool(args, 'json');
266
+ const stray = strayPositionalError(args, 'cycles');
267
+ if (stray !== null)
268
+ return stray;
217
269
  const limit = parseLimit(args);
218
270
  const minSize = parseMinSize(args);
219
271
  const store = new GraphStore(cwd);
@@ -230,7 +282,9 @@ export async function runGraphCycles(args) {
230
282
  process.stderr.write(STALE_HINT + '\n');
231
283
  return 1;
232
284
  }
233
- const api = GraphQueryApi.fromStore(cwd);
285
+ const api = loadGuarded(() => GraphQueryApi.fromStore(cwd), wantJson);
286
+ if (!api)
287
+ return 1;
234
288
  const allCycles = api.cycles();
235
289
  const filtered = allCycles.filter((c) => c.size >= minSize);
236
290
  const limited = filtered.slice(0, limit);
@@ -289,6 +343,9 @@ function parseMinSize(args) {
289
343
  export async function runGraphUnresolved(args) {
290
344
  const cwd = resolveCwd(args);
291
345
  const wantJson = flagBool(args, 'json');
346
+ const stray = strayPositionalError(args, 'unresolved');
347
+ if (stray !== null)
348
+ return stray;
292
349
  const limit = parseLimit(args);
293
350
  const store = new GraphStore(cwd);
294
351
  if (!store.exists()) {
@@ -304,7 +361,9 @@ export async function runGraphUnresolved(args) {
304
361
  process.stderr.write(STALE_HINT + '\n');
305
362
  return 1;
306
363
  }
307
- const snap = store.loadSnapshot();
364
+ const snap = loadGuarded(() => store.loadSnapshot(), wantJson);
365
+ if (!snap)
366
+ return 1;
308
367
  const groups = new Map();
309
368
  for (const e of snap.edges.values()) {
310
369
  if (e.kind !== EdgeKind.ImportsFile)
@@ -396,7 +455,9 @@ export async function runGraphDeps(args) {
396
455
  process.stderr.write(STALE_HINT + '\n');
397
456
  return 1;
398
457
  }
399
- const api = GraphQueryApi.fromStore(cwd);
458
+ const api = loadGuarded(() => GraphQueryApi.fromStore(cwd), wantJson);
459
+ if (!api)
460
+ return 1;
400
461
  const pkgId = `package:${pkg}`;
401
462
  // Existence guard (mirrors the MCP tool): without it, an unknown package
402
463
  // name returns a confidently-wrong empty `dependsOn/dependedOnBy` that reads
@@ -453,6 +514,9 @@ export async function runGraphDeps(args) {
453
514
  export async function runGraphStatus(args) {
454
515
  const cwd = resolveCwd(args);
455
516
  const wantJson = flagBool(args, 'json');
517
+ const stray = strayPositionalError(args, 'status');
518
+ if (stray !== null)
519
+ return stray;
456
520
  const store = new GraphStore(cwd);
457
521
  if (!store.exists()) {
458
522
  const payload = {
@@ -468,8 +532,20 @@ export async function runGraphStatus(args) {
468
532
  process.stderr.write(STALE_HINT + '\n');
469
533
  return 1;
470
534
  }
471
- const verify = store.verifyDigest();
472
- const snap = store.loadSnapshot();
535
+ // A corrupt store (a truncated/garbled JSONL row) must surface as a `corrupt`
536
+ // status line + non-zero exit, NOT a raw `Fatal: JSON Parse error` crash.
537
+ let verify;
538
+ let snap;
539
+ try {
540
+ verify = store.verifyDigest();
541
+ snap = store.loadSnapshot();
542
+ }
543
+ catch (err) {
544
+ const code = emitCorruptStore(err, wantJson);
545
+ if (code !== null)
546
+ return code;
547
+ throw err;
548
+ }
473
549
  const manifestNodeCount = sumValues(snap.manifest.nodesByKind);
474
550
  const manifestEdgeCount = sumValues(snap.manifest.edgesByKind);
475
551
  // Honest freshness vs the working tree. `corrupt` (store self-integrity) and
@@ -536,40 +612,63 @@ export async function runGraphSearch(args) {
536
612
  return 2;
537
613
  }
538
614
  const kindFlag = flagString(args, 'kind');
539
- const limit = Number(flagString(args, 'limit') ?? '20');
615
+ // NaN-safe --limit: a fat-fingered `--limit abc` must fall back to the default,
616
+ // not become `Number('abc') === NaN` (which zeroed the result via a NaN slice).
617
+ const limit = flagPositiveInt(args, 'limit', 20);
540
618
  maybeRefresh(args, cwd);
541
619
  const api = loadOrFail(cwd, wantJson);
542
620
  if (!api)
543
621
  return 1;
544
- let matches;
622
+ // Compute the TRUE pre-slice match count so `total`/`truncated` stay honest —
623
+ // the old code reported the post-cap length as the total, so 285 matches read
624
+ // as `total: 20` with no signal there was more.
625
+ let page;
626
+ let total;
545
627
  if (hasUnresolved) {
546
628
  const all = api.filesWithUnresolvedImports();
547
- matches = (query
629
+ const filtered = query
548
630
  ? all.filter((n) => (n.path ?? '').toLowerCase().includes(query.toLowerCase()))
549
- : [...all]).slice(0, limit);
631
+ : [...all];
632
+ total = filtered.length;
633
+ page = filtered.slice(0, limit);
550
634
  }
551
635
  else {
552
- matches = collectSearchMatches(api, query, kindFlag, limit);
553
- }
636
+ const res = collectSearchMatches(api, query, kindFlag, limit);
637
+ page = res.matches;
638
+ total = res.total;
639
+ }
640
+ // Targeted staleness over the displayed page (parity with MCP / callers): drop
641
+ // deleted result files, flag modified ones, and reduce `total` by the
642
+ // deletions we observed so a dead file is never counted.
643
+ const summarised = page.map(toSearchHit);
644
+ const fresh = resultStaleness(api, cwd, summarised.map((m) => m.path));
645
+ const live = summarised.filter((m) => !m.path || !fresh.deletedSet.has(m.path));
646
+ const adjustedTotal = total - (summarised.length - live.length);
647
+ const truncated = adjustedTotal > limit;
554
648
  if (wantJson) {
555
649
  process.stdout.write(asJson({
556
650
  schema: 'sharkcraft.graph-search/v1',
557
651
  query,
558
652
  kind: kindFlag ?? 'any',
559
- total: matches.length,
560
- matches: matches.map(toSearchHit),
653
+ total: adjustedTotal,
654
+ truncated,
655
+ matches: live,
656
+ ...(fresh.field ?? {}),
561
657
  }) + '\n');
562
658
  return 0;
563
659
  }
564
660
  const headerLabel = query ?? (hasUnresolved ? 'files with unresolved imports' : '');
565
- if (matches.length === 0) {
661
+ if (live.length === 0) {
566
662
  process.stdout.write(`No matches for "${headerLabel}".\n`);
567
663
  return 0;
568
664
  }
569
665
  process.stdout.write(header(`Graph search: ${headerLabel}`));
570
- for (const m of matches) {
666
+ for (const m of live) {
571
667
  process.stdout.write(` ${m.kind.padEnd(8)} ${m.label}${m.path ? ' ' + m.path : ''}${m.line ? ':' + m.line : ''}\n`);
572
668
  }
669
+ if (truncated) {
670
+ process.stdout.write(`\n(${adjustedTotal - live.length} more — pass --limit ${adjustedTotal} to see all)\n`);
671
+ }
573
672
  return 0;
574
673
  }
575
674
  // ─── shrk graph context ───────────────────────────────────────────────
@@ -578,10 +677,11 @@ export async function runGraphContext(args) {
578
677
  const wantJson = flagBool(args, 'json');
579
678
  const target = args.positional[1];
580
679
  if (!target) {
581
- process.stderr.write('Usage: shrk graph context <fileOrSymbol> [--depth N] [--no-bridge] [--no-framework]\n');
680
+ process.stderr.write('Usage: shrk graph context <fileOrSymbol> [--depth N] [--limit N|0] [--no-bridge] [--no-framework]\n');
582
681
  return 2;
583
682
  }
584
- const depth = Math.max(1, Math.min(3, Number(flagString(args, 'depth') ?? '1')));
683
+ const depth = Math.min(3, flagPositiveInt(args, 'depth', 1));
684
+ const cap = resolveListLimit(args, CONTEXT_LIST_CAP);
585
685
  const includeBridge = !flagBool(args, 'no-bridge');
586
686
  const includeFramework = !flagBool(args, 'no-framework');
587
687
  maybeRefresh(args, cwd);
@@ -622,16 +722,18 @@ export async function runGraphContext(args) {
622
722
  const frameworkEntities = (includeFramework && frameworkStore.exists() && anchorFile?.path)
623
723
  ? FrameworkQueryApi.fromStore(cwd).forFile(anchorFile.path)
624
724
  : [];
625
- const importsFromList = neighbours.out
626
- .filter((o) => o.edge.kind === 'imports-file')
627
- .slice(0, 50)
725
+ // Pre-slice the homogeneous filtered arrays so the payload can report the TRUE
726
+ // count + a truncated flag instead of silently capping each list at 50.
727
+ const importsFromAll = neighbours.out.filter((o) => o.edge.kind === 'imports-file');
728
+ const importedByAll = neighbours.in.filter((i) => i.edge.kind === 'imports-file');
729
+ const importsFromList = importsFromAll
730
+ .slice(0, cap)
628
731
  .map((o) => ('target' in o ? targetSummary(o.target) : { id: 'unknown', resolved: false }));
629
- const importedByList = neighbours.in
630
- .filter((i) => i.edge.kind === 'imports-file')
631
- .slice(0, 50)
732
+ const importedByList = importedByAll
733
+ .slice(0, cap)
632
734
  .map((i) => ('source' in i ? sourceSummary(i.source) : { id: 'unknown', resolved: false }));
633
- const referencedByList = references.slice(0, 50).map(nodeSummary);
634
- const calledByList = callers.slice(0, 50).map(nodeSummary);
735
+ const referencedByList = references.slice(0, cap).map(nodeSummary);
736
+ const calledByList = callers.slice(0, cap).map(nodeSummary);
635
737
  // Staleness over the anchor + every referenced file: drop dead paths from the
636
738
  // usage lists, flag changed ones.
637
739
  const ctxPathOf = (x) => x.path;
@@ -649,12 +751,20 @@ export async function runGraphContext(args) {
649
751
  declaredIn: anchor.kind === NodeKind.Symbol && anchorFile ? nodeSummary(anchorFile) : null,
650
752
  depth,
651
753
  importsFrom: ctxDropDel(importsFromList),
754
+ totalImportsFrom: importsFromAll.length,
755
+ importsFromTruncated: importsFromAll.length > cap,
652
756
  importedBy: ctxDropDel(importedByList),
653
- symbols: symbols.slice(0, 50).map(nodeSummary),
757
+ totalImportedBy: importedByAll.length,
758
+ importedByTruncated: importedByAll.length > cap,
759
+ symbols: symbols.slice(0, cap).map(nodeSummary),
654
760
  referencedBy: ctxDropDel(referencedByList),
761
+ totalReferencedBy: references.length,
762
+ referencedByTruncated: references.length > cap,
655
763
  calledBy: ctxDropDel(calledByList),
656
- ...(subtypes.length > 0 ? { subtypes: subtypes.slice(0, 50).map(nodeSummary) } : {}),
657
- ...(supertypes.length > 0 ? { supertypes: supertypes.slice(0, 50).map(nodeSummary) } : {}),
764
+ totalCalledBy: callers.length,
765
+ calledByTruncated: callers.length > cap,
766
+ ...(subtypes.length > 0 ? { subtypes: subtypes.slice(0, cap).map(nodeSummary) } : {}),
767
+ ...(supertypes.length > 0 ? { supertypes: supertypes.slice(0, cap).map(nodeSummary) } : {}),
658
768
  ...(fresh.field ?? {}),
659
769
  bridge: bridgeFor
660
770
  ? {
@@ -769,11 +879,11 @@ export async function runGraphImpact(args) {
769
879
  const wantFull = flagBool(args, 'full');
770
880
  const target = args.positional[1];
771
881
  if (!target) {
772
- process.stderr.write('Usage: shrk graph impact <fileOrSymbol> [--max-depth N] [--limit N] [--full]\n');
882
+ process.stderr.write('Usage: shrk graph impact <fileOrSymbol> [--max-depth N] [--limit N|0] [--full]\n');
773
883
  return 2;
774
884
  }
775
- const maxDepth = Math.max(1, Math.min(10, Number(flagString(args, 'max-depth') ?? '5')));
776
- const limit = Math.max(1, Number(flagString(args, 'limit') ?? '200'));
885
+ const maxDepth = Math.min(10, flagPositiveInt(args, 'max-depth', 5));
886
+ const limit = resolveListLimit(args, 200);
777
887
  maybeRefresh(args, cwd);
778
888
  // --full → delegate to the impact-engine for a richer v3 payload.
779
889
  if (wantFull) {
@@ -858,7 +968,8 @@ export async function runGraphImpact(args) {
858
968
  schema: 'sharkcraft.graph-impact/v1',
859
969
  anchor: nodeSummary(anchor),
860
970
  maxDepth,
861
- limit,
971
+ // 0 signals "unbounded" (`--limit 0`); JSON can't carry Infinity.
972
+ limit: Number.isFinite(limit) ? limit : 0,
862
973
  truncated: closure.truncated,
863
974
  directDependents: liveDirect,
864
975
  transitiveDependents: liveTransitive,
@@ -899,8 +1010,22 @@ export async function runGraphImpact(args) {
899
1010
  export async function runGraphHubs(args) {
900
1011
  const cwd = resolveCwd(args);
901
1012
  const wantJson = flagBool(args, 'json');
902
- const limit = Math.max(1, Math.min(100, Number(flagString(args, 'limit') ?? '15')));
903
- const pathScope = flagString(args, 'path');
1013
+ const limit = Math.min(100, flagPositiveInt(args, 'limit', 15));
1014
+ // A positional scope (`shrk graph hubs packages/cli`) is honoured the same as
1015
+ // --path; passing BOTH is ambiguous, so error rather than silently ignoring
1016
+ // the positional (which previously yielded a byte-identical global report).
1017
+ const pathFlag = flagString(args, 'path');
1018
+ const positionalPath = args.positional[1];
1019
+ if (pathFlag && positionalPath) {
1020
+ if (wantJson) {
1021
+ process.stdout.write(asJson({ ok: false, error: 'ambiguous-path', positional: positionalPath, flag: pathFlag }) + '\n');
1022
+ }
1023
+ else {
1024
+ process.stderr.write(`Pass the scope path either as a positional or with --path, not both (got "${positionalPath}" and --path ${pathFlag}).\n`);
1025
+ }
1026
+ return 2;
1027
+ }
1028
+ const pathScope = pathFlag ?? positionalPath;
904
1029
  maybeRefresh(args, cwd);
905
1030
  const api = loadOrFail(cwd, wantJson);
906
1031
  if (!api)
@@ -947,16 +1072,15 @@ export async function runGraphCallers(args) {
947
1072
  const wantJson = flagBool(args, 'json');
948
1073
  const target = args.positional[1];
949
1074
  if (!target) {
950
- process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference] [--limit N] [--no-refresh]\n');
1075
+ process.stderr.write('Usage: shrk graph callers <symbol> [--mode call|reference] [--limit N|0] [--no-refresh]\n');
951
1076
  return 2;
952
1077
  }
953
1078
  const mode = (flagString(args, 'mode') ?? 'call');
954
- // --limit N: cap the returned call sites (default 200). `total` still reports
955
- // the true uncapped count, so a truncated result stays honest. Guard against
956
- // non-numeric input — `Number('foo')` is NaN and `slice(0, NaN)` would zero
957
- // the callers list while `total` kept showing the real count.
958
- const parsedLimit = Number.parseInt(flagString(args, 'limit') ?? '200', 10);
959
- const limit = Number.isFinite(parsedLimit) && parsedLimit > 0 ? parsedLimit : 200;
1079
+ // --limit N: cap the returned call sites (default 200; `--limit 0` = all).
1080
+ // `total` still reports the true uncapped count, so a truncated result stays
1081
+ // honest. resolveListLimit guards non-numeric input (NaN fallback) so a
1082
+ // fat-fingered `--limit foo` can't zero the list via `slice(0, NaN)`.
1083
+ const limit = resolveListLimit(args, 200);
960
1084
  maybeRefresh(args, cwd);
961
1085
  const api = loadOrFail(cwd, wantJson);
962
1086
  if (!api)
@@ -1081,7 +1205,7 @@ export async function runGraphPath(args) {
1081
1205
  process.stderr.write('Usage: shrk graph path <from> <to> [--max-depth N] [--no-refresh] [--json]\n');
1082
1206
  return 2;
1083
1207
  }
1084
- const maxDepth = Math.max(1, Math.min(32, Number(flagString(args, 'max-depth') ?? '16')));
1208
+ const maxDepth = Math.min(32, flagPositiveInt(args, 'max-depth', 16));
1085
1209
  maybeRefresh(args, cwd);
1086
1210
  const api = loadOrFail(cwd, wantJson);
1087
1211
  if (!api)
@@ -1183,6 +1307,45 @@ export async function runGraphPath(args) {
1183
1307
  return 0;
1184
1308
  }
1185
1309
  // ─── helpers ──────────────────────────────────────────────────────────
1310
+ /**
1311
+ * Convert the typed corrupt-store error (one bad JSONL line) into a
1312
+ * deterministic "rebuild the index" message + non-zero exit, instead of letting
1313
+ * an unhandled `Fatal: JSON Parse error` escape the CLI. Returns the exit code
1314
+ * (1) when `err` is a corrupt-store error, or null to signal "not mine — rethrow".
1315
+ */
1316
+ function emitCorruptStore(err, wantJson) {
1317
+ if (!isGraphStoreCorruptError(err))
1318
+ return null;
1319
+ if (wantJson) {
1320
+ process.stdout.write(asJson({
1321
+ ok: false,
1322
+ state: 'corrupt',
1323
+ nextCommand: 'shrk graph index',
1324
+ message: CORRUPT_HINT,
1325
+ ...(err.details ?? {}),
1326
+ }) + '\n');
1327
+ }
1328
+ else {
1329
+ process.stderr.write(CORRUPT_HINT + '\n');
1330
+ }
1331
+ return 1;
1332
+ }
1333
+ /**
1334
+ * Run a store-loading thunk, mapping a corrupt store to a clean rebuild hint.
1335
+ * Returns the loaded value, or undefined when the store was corrupt (the
1336
+ * message + exit have already been emitted; the caller should `return 1`). Any
1337
+ * other error is rethrown unchanged.
1338
+ */
1339
+ function loadGuarded(load, wantJson) {
1340
+ try {
1341
+ return load();
1342
+ }
1343
+ catch (err) {
1344
+ if (emitCorruptStore(err, wantJson) !== null)
1345
+ return undefined;
1346
+ throw err;
1347
+ }
1348
+ }
1186
1349
  function loadOrFail(cwd, wantJson) {
1187
1350
  const store = new GraphStore(cwd);
1188
1351
  if (!store.exists()) {
@@ -1199,7 +1362,7 @@ function loadOrFail(cwd, wantJson) {
1199
1362
  }
1200
1363
  return undefined;
1201
1364
  }
1202
- return GraphQueryApi.fromStore(cwd);
1365
+ return loadGuarded(() => GraphQueryApi.fromStore(cwd), wantJson);
1203
1366
  }
1204
1367
  function resolveAnchor(api, target) {
1205
1368
  // Exact node id wins.
@@ -1221,43 +1384,14 @@ function resolveAnchor(api, target) {
1221
1384
  return syms[0];
1222
1385
  return undefined;
1223
1386
  }
1387
+ /**
1388
+ * Delegate to the shared {@link GraphQueryApi.searchNodes} so the CLI and the
1389
+ * MCP `get_graph_search` tool can never disagree on results OR the honest
1390
+ * pre-slice `total` (the two surfaces used to maintain divergent copies of this
1391
+ * fuzzy-match logic). Returns the display page + the true match count.
1392
+ */
1224
1393
  function collectSearchMatches(api, query, kind, limit) {
1225
- const out = [];
1226
- if (!kind || kind === 'file') {
1227
- const f = api.findFile(query);
1228
- if (f)
1229
- out.push(f);
1230
- // Fuzzy fallback: substring match on path/basename so `shrk graph
1231
- // search Foo --kind file` finds `libs/x/y/Foo.ts` without forcing the
1232
- // caller to type the full path. Skips the node if exact match already
1233
- // included it.
1234
- if (out.length < limit) {
1235
- const q = query.toLowerCase();
1236
- const seen = new Set(out.map((n) => n.id));
1237
- for (const node of api.allFiles()) {
1238
- if (seen.has(node.id))
1239
- continue;
1240
- const p = node.path?.toLowerCase() ?? '';
1241
- const base = p.includes('/') ? p.slice(p.lastIndexOf('/') + 1) : p;
1242
- if (base.includes(q) || p.includes(q)) {
1243
- out.push(node);
1244
- seen.add(node.id);
1245
- if (out.length >= limit)
1246
- break;
1247
- }
1248
- }
1249
- }
1250
- }
1251
- if (!kind || kind === 'symbol') {
1252
- for (const s of api.findSymbol(query, { exact: false, limit }))
1253
- out.push(s);
1254
- }
1255
- if (!kind || kind === 'package') {
1256
- const p = api.neighbours(`package:${query}`);
1257
- if (p)
1258
- out.push(p.node);
1259
- }
1260
- return out.slice(0, limit);
1394
+ return api.searchNodes(query, { ...(kind ? { kind } : {}), limit });
1261
1395
  }
1262
1396
  function reverseClosure(api, anchor, maxDepth, limit) {
1263
1397
  const seen = new Set([anchor.id]);
@@ -1 +1 @@
1
- {"version":3,"file":"graph.command.d.ts","sourceRoot":"","sources":["../../src/commands/graph.command.ts"],"names":[],"mappings":"AAUA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA4BhC,eAAO,MAAM,YAAY,EAAE,eA+M1B,CAAC"}
1
+ {"version":3,"file":"graph.command.d.ts","sourceRoot":"","sources":["../../src/commands/graph.command.ts"],"names":[],"mappings":"AAUA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA4BhC,eAAO,MAAM,YAAY,EAAE,eAuN1B,CAAC"}
@@ -52,6 +52,12 @@ export const graphCommand = {
52
52
  const graph = buildKnowledgeGraph(inspection);
53
53
  const sub = args.positional[0];
54
54
  const typeFlag = flagString(args, 'type');
55
+ // A typo'd `--type` would otherwise silently match zero nodes and return an
56
+ // empty summary / "no node" at exit 0/1. Reject loudly with the valid list.
57
+ if (typeFlag && !KNOWN_KINDS.includes(typeFlag)) {
58
+ process.stderr.write(`Unknown --type ${typeFlag}. Valid: ${KNOWN_KINDS.join(', ')}\n`);
59
+ return 2;
60
+ }
55
61
  const formatFlag = (flagString(args, 'format') ?? 'text');
56
62
  const outputFlag = flagString(args, 'output');
57
63
  // `shrk graph imports` — import-graph analysis.
@@ -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;AAqB9D;;;;;;;;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;EA2HpF"}
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,5 +1,11 @@
1
1
  import { header } from "../output/format-output.js";
2
- import { COMMAND_CATALOG, defaultShowInHelp } from "./command-catalog.js";
2
+ import { COMMAND_CATALOG, defaultShowInHelp, listExplainFamily } from "./command-catalog.js";
3
+ /** First sentence of a catalog description, for the compact explain-family list. */
4
+ function firstSentence(description) {
5
+ const dot = description.indexOf('. ');
6
+ const head = dot > 0 ? description.slice(0, dot + 1) : description;
7
+ return head.length > 100 ? head.slice(0, 97).trimEnd() + '…' : head;
8
+ }
3
9
  const EXTRA_HELP_LINES = Object.freeze({
4
10
  graph: [
5
11
  '',
@@ -60,7 +66,7 @@ export function renderStartScreen() {
60
66
  lines.push('Discover the rest (everything stays callable — this screen shows ~17 of ~70 verbs):');
61
67
  lines.push(' $ shrk surface list — full catalog by tier');
62
68
  lines.push(' $ shrk help <command> — usage for a specific command');
63
- lines.push(' $ shrk --full-help — long, exhaustive help');
69
+ lines.push(' $ shrk --full-help — long, exhaustive help (incl. the explain/dry-run family)');
64
70
  lines.push(' $ shrk --about — what shrk is and is not');
65
71
  lines.push('');
66
72
  lines.push('Free-form input is fine — `shrk "<task>"` routes to `shrk recommend`.');
@@ -188,6 +194,18 @@ export function makeHelpCommand(registry) {
188
194
  process.stdout.write(` ${group} ${s.name.padEnd(10)} — ${s.description}\n`);
189
195
  }
190
196
  }
197
+ // Explain / dry-run family — several carry an Advanced surface and are
198
+ // filtered out of the listings above, so surface them explicitly. These
199
+ // show you what a gate, ranker, or graph SEES before you act; an agent
200
+ // would otherwise find them only by guessing (the #4.4 friction).
201
+ const explainFamily = listExplainFamily();
202
+ if (explainFamily.length > 0) {
203
+ process.stdout.write(header('Inspect / explain (dry-run what a gate, ranker, or graph sees)'));
204
+ for (const e of explainFamily) {
205
+ process.stdout.write(` ${e.command.padEnd(24)} — ${firstSentence(e.description)}\n`);
206
+ }
207
+ process.stdout.write(' (also: shrk check wiring --explain <ruleId>)\n');
208
+ }
191
209
  process.stdout.write('\nRun `shrk help <command>` for detailed usage.\n');
192
210
  return 0;
193
211
  },
@@ -1 +1 @@
1
- {"version":3,"file":"impact.command.d.ts","sourceRoot":"","sources":["../../src/commands/impact.command.ts"],"names":[],"mappings":"AAsBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA0VhC,eAAO,MAAM,aAAa,EAAE,eA8V3B,CAAC"}
1
+ {"version":3,"file":"impact.command.d.ts","sourceRoot":"","sources":["../../src/commands/impact.command.ts"],"names":[],"mappings":"AAsBA,OAAO,EAML,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA2ahC,eAAO,MAAM,aAAa,EAAE,eAsW3B,CAAC"}