@shrkcrft/cli 0.1.0-alpha.25 → 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.
Files changed (42) hide show
  1. package/dist/command-registry.d.ts +22 -0
  2. package/dist/command-registry.d.ts.map +1 -1
  3. package/dist/command-registry.js +41 -0
  4. package/dist/commands/changelog-data.d.ts.map +1 -1
  5. package/dist/commands/changelog-data.js +45 -0
  6. package/dist/commands/check.command.d.ts.map +1 -1
  7. package/dist/commands/check.command.js +65 -8
  8. package/dist/commands/compress.command.d.ts.map +1 -1
  9. package/dist/commands/compress.command.js +31 -1
  10. package/dist/commands/delegate.command.d.ts +76 -1
  11. package/dist/commands/delegate.command.d.ts.map +1 -1
  12. package/dist/commands/delegate.command.js +585 -25
  13. package/dist/commands/finish.command.js +4 -4
  14. package/dist/commands/gate.command.d.ts.map +1 -1
  15. package/dist/commands/gate.command.js +57 -2
  16. package/dist/commands/graph.command.d.ts.map +1 -1
  17. package/dist/commands/graph.command.js +68 -1
  18. package/dist/commands/help.command.d.ts.map +1 -1
  19. package/dist/commands/help.command.js +73 -0
  20. package/dist/commands/registry-resolve.d.ts +48 -0
  21. package/dist/commands/registry-resolve.d.ts.map +1 -0
  22. package/dist/commands/registry-resolve.js +115 -0
  23. package/dist/commands/registry.command.d.ts.map +1 -1
  24. package/dist/commands/registry.command.js +23 -8
  25. package/dist/commands/reuse.command.d.ts +20 -0
  26. package/dist/commands/reuse.command.d.ts.map +1 -1
  27. package/dist/commands/reuse.command.js +81 -11
  28. package/dist/commands/trace.command.d.ts.map +1 -1
  29. package/dist/commands/trace.command.js +7 -1
  30. package/dist/commands/wiring.command.d.ts.map +1 -1
  31. package/dist/commands/wiring.command.js +88 -14
  32. package/dist/exit-codes.d.ts +90 -0
  33. package/dist/exit-codes.d.ts.map +1 -0
  34. package/dist/exit-codes.js +146 -0
  35. package/dist/finish/run-finish.d.ts +22 -3
  36. package/dist/finish/run-finish.d.ts.map +1 -1
  37. package/dist/finish/run-finish.js +185 -15
  38. package/dist/main.d.ts.map +1 -1
  39. package/dist/main.js +44 -4
  40. package/dist/output/output-compression.d.ts.map +1 -1
  41. package/dist/output/output-compression.js +4 -1
  42. package/package.json +33 -33
@@ -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":"gate.command.d.ts","sourceRoot":"","sources":["../../src/commands/gate.command.ts"],"names":[],"mappings":"AAaA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAGhC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,EAAE,eAsMzB,CAAC"}
1
+ {"version":3,"file":"gate.command.d.ts","sourceRoot":"","sources":["../../src/commands/gate.command.ts"],"names":[],"mappings":"AAcA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAGhC;;;;;;;;;;;GAWG;AACH,eAAO,MAAM,WAAW,EAAE,eAwNzB,CAAC"}
@@ -1,4 +1,5 @@
1
1
  import { QualityGateReportStore, renderGateReportMarkdown, runQualityGates, } from '@shrkcrft/quality-gates';
2
+ import { ArchReportStore, runArchCheck } from '@shrkcrft/architecture-guard';
2
3
  import { inspectSharkcraft, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
3
4
  import { chmodSync, existsSync, mkdirSync, writeFileSync } from 'node:fs';
4
5
  import * as nodePath from 'node:path';
@@ -19,7 +20,7 @@ import { asJson, header, kv } from "../output/format-output.js";
19
20
  export const gateCommand = {
20
21
  name: 'gate',
21
22
  description: 'Aggregator: runs the code-intelligence quality gates (graph freshness, architecture, impact-since-ref) and reports a single pass/fail.',
22
- usage: 'shrk gate [--since <gitref>] [--changed-only] [--staged] [--files a,b,c] [--fail-on critical,high] [--arch-all] [--disable arch,impact,policy,knowledge-symbol,api-diff] [--api-baseline <path>] [--no-fail-on-breaking] [--strict] [--no-persist] [--json] [--markdown] [--output <path>]\n (the arch gate is baseline-relative once a baseline is frozen — fails only on NEW errors; with no baseline it warns on errors rather than going perpetually red — --arch-all fails on total, --strict escalates the warn)\n (--changed-only / --staged / --files / --since scope the wiring + policy + knowledge-symbol gates to the changeset; they also drive the impact gate — --since diffs the gitref, the others analyze the changed-file set)\n shrk gate scaffold-ci [--provider github|generic] [--force] [--json]\n shrk gate scaffold-hook [--provider husky|raw] [--force] [--json]',
23
+ usage: 'shrk gate [--since <gitref>] [--changed-only] [--staged] [--files a,b,c] [--fail-on critical,high] [--arch-all] [--disable arch,impact,policy,knowledge-symbol,api-diff] [--api-baseline <path>] [--no-fail-on-breaking] [--strict] [--no-persist] [--json] [--markdown] [--output <path>]\n (the arch gate is change-scoped once a baseline is frozen — a NEW error blocks only when the working diff vs HEAD touched its origin file; drift in untouched files is informational — --arch-all fails on total, --strict escalates the warn)\n (--changed-only / --staged / --files / --since scope the wiring + policy + knowledge-symbol gates to the changeset; they also drive the impact gate — --since diffs the gitref, the others analyze the changed-file set)\n shrk gate scaffold-ci [--provider github|generic] [--force] [--json]\n shrk gate scaffold-hook [--provider husky|raw] [--force] [--json]\n shrk gate baseline --refreeze [--json] (operational reset: re-freeze the arch baseline to the current state — NOT the change-scoped gate)',
23
24
  async run(args) {
24
25
  if (args.positional[0] === 'scaffold-ci') {
25
26
  const sliced = { ...args, positional: args.positional.slice(1) };
@@ -29,6 +30,10 @@ export const gateCommand = {
29
30
  const sliced = { ...args, positional: args.positional.slice(1) };
30
31
  return runGateScaffoldHook(sliced);
31
32
  }
33
+ if (args.positional[0] === 'baseline') {
34
+ const sliced = { ...args, positional: args.positional.slice(1) };
35
+ return runGateBaseline(sliced);
36
+ }
32
37
  const cwd = resolveCwd(args);
33
38
  const wantJson = flagBool(args, 'json');
34
39
  const wantMarkdown = flagBool(args, 'markdown');
@@ -101,6 +106,17 @@ export const gateCommand = {
101
106
  const scopeOpts = wantChangedScope
102
107
  ? { changedOnly: true, changedFiles: changedFiles ?? [] }
103
108
  : {};
109
+ // §3.1 — the architecture gate's "NEW" is ALWAYS change-scoped: a NEW error
110
+ // means one introduced by the working change (diff vs HEAD), never drift
111
+ // against a frozen (possibly months-old) baseline in a file the change never
112
+ // touched. When an explicit scope flag is passed we reuse its resolution;
113
+ // otherwise we default to the worktree diff vs HEAD — empty in a clean tree,
114
+ // so pre-existing baseline drift stays informational and can't red the gate
115
+ // on its own. `--arch-all` (baselineRelative:false) ignores this and fails
116
+ // on total errors, keeping a clean-tree CI demand expressible.
117
+ const archChangedFiles = wantChangedScope
118
+ ? changedFiles ?? []
119
+ : resolveChangedFiles({ projectRoot: cwd, includeWorktree: true }).files;
104
120
  // Knowledge symbol-ref integrity needs the loaded knowledge entries. The
105
121
  // inspection is async, so we build it here and inject it; the gate stays
106
122
  // synchronous and resolves the code graph itself. Best-effort — a failed
@@ -116,7 +132,10 @@ export const gateCommand = {
116
132
  }
117
133
  const report = runQualityGates({
118
134
  projectRoot: cwd,
119
- ...(archAll ? { arch: { baselineRelative: false } } : {}),
135
+ arch: {
136
+ ...(archAll ? { baselineRelative: false } : {}),
137
+ changedFiles: archChangedFiles,
138
+ },
120
139
  wiring: {
121
140
  ...(configError
122
141
  ? { configError }
@@ -400,3 +419,39 @@ async function runGateScaffoldCi(args) {
400
419
  process.stdout.write(`Scaffolded ${provider} CI runner → ${target}\n`);
401
420
  return 0;
402
421
  }
422
+ /**
423
+ * `shrk gate baseline --refreeze` — deliberate operational reset that re-freezes
424
+ * the architecture baseline to the CURRENT state, absorbing accumulated drift so
425
+ * the informational "baseline drift" line resets to zero.
426
+ *
427
+ * This is an operational complement, NOT the gate itself: the gate's blocking
428
+ * verdict is change-scoped (a NEW error blocks only when the working diff touched
429
+ * its origin file), so a stale baseline never reds the gate on its own. Refreeze
430
+ * is only for tidying the informational drift line. It mirrors `shrk arch
431
+ * baseline write`.
432
+ */
433
+ async function runGateBaseline(args) {
434
+ const cwd = resolveCwd(args);
435
+ const wantJson = flagBool(args, 'json');
436
+ const refreeze = flagBool(args, 'refreeze');
437
+ if (!refreeze) {
438
+ process.stderr.write('Usage: shrk gate baseline --refreeze [--json]\n' +
439
+ ' Re-freeze the architecture baseline to the current state (operational reset,\n' +
440
+ ' not the change-scoped gate). Equivalent to `shrk arch baseline write`.\n');
441
+ return 2;
442
+ }
443
+ const report = runArchCheck({ projectRoot: cwd });
444
+ if (report.diagnostics.some((d) => d.includes('code-graph store missing'))) {
445
+ process.stderr.write('Cannot refreeze — graph index missing. Run `shrk graph index` first.\n');
446
+ return 2;
447
+ }
448
+ const store = new ArchReportStore(cwd);
449
+ const snap = store.writeBaseline(report);
450
+ if (wantJson) {
451
+ process.stdout.write(asJson({ ok: true, wrote: store.baselinePath, baseline: snap }) + '\n');
452
+ return 0;
453
+ }
454
+ process.stdout.write(`Architecture baseline re-frozen → ${store.baselinePath}\n`);
455
+ process.stdout.write(kv('violations', `${snap.countsBySeverity.error} error, ${snap.countsBySeverity.warning} warning`) + '\n');
456
+ return 0;
457
+ }
@@ -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,eAuN1B,CAAC"}
1
+ {"version":3,"file":"graph.command.d.ts","sourceRoot":"","sources":["../../src/commands/graph.command.ts"],"names":[],"mappings":"AAUA,OAAO,EAKL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAqFhC,eAAO,MAAM,YAAY,EAAE,eAqO1B,CAAC"}
@@ -1,8 +1,63 @@
1
1
  import { mkdirSync, writeFileSync } from 'node:fs';
2
2
  import * as nodePath from 'node:path';
3
3
  import { analyzeImportGraph, buildKnowledgeGraph, findGraphPath, getGraphNode, inspectSharkcraft, } from '@shrkcrft/inspector';
4
- import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
4
+ import { firstUnknownFlag, flagBool, flagString, resolveCwd, } from "../command-registry.js";
5
+ import { ExitCode } from "../exit-codes.js";
5
6
  import { asJson, header, kv } from "../output/format-output.js";
7
+ /**
8
+ * The code-intelligence subverbs that share one arg parser. Each is guarded so
9
+ * an unrecognized/misspelled flag is rejected loudly instead of parsing as a
10
+ * silent `true` that reads as a confident opt-in (a25 §2.1). The allow-set is
11
+ * the UNION of every flag any code-graph subverb reads, plus the global flags,
12
+ * so a real flag is never false-rejected — only a genuinely unknown token is.
13
+ */
14
+ const CODE_GRAPH_SUBVERBS = new Set([
15
+ 'index',
16
+ 'status',
17
+ 'search',
18
+ 'context',
19
+ 'impact',
20
+ 'path',
21
+ 'hubs',
22
+ 'callers',
23
+ 'cycles',
24
+ 'unresolved',
25
+ 'deps',
26
+ ]);
27
+ const CODE_GRAPH_ALLOWED_FLAGS = new Set([
28
+ // code-subverb flags (union across index/status/search/context/impact/path/
29
+ // hubs/callers/cycles/unresolved/deps)
30
+ 'changed',
31
+ 'compact',
32
+ 'depth',
33
+ 'full',
34
+ 'has-unresolved-imports',
35
+ 'include-type-edges',
36
+ 'json',
37
+ 'kind',
38
+ 'limit',
39
+ 'max-depth',
40
+ 'min-size',
41
+ 'mode',
42
+ 'no-bridge',
43
+ 'no-framework',
44
+ 'no-refresh',
45
+ 'path',
46
+ 'since',
47
+ 'table',
48
+ // --watch loop (graph index --watch)
49
+ 'watch',
50
+ 'paths',
51
+ 'debounce',
52
+ 'once',
53
+ // global / meta flags that reach any verb
54
+ 'cwd',
55
+ 'strict',
56
+ 'help',
57
+ 'h',
58
+ 'no-color',
59
+ 'color',
60
+ ]);
6
61
  import { runGraphCallers, runGraphContext, runGraphCycles, runGraphDeps, runGraphHubs, runGraphImpact, runGraphIndex, runGraphPath, runGraphSearch, runGraphStatus, runGraphUnresolved, } from "./graph-code-subverbs.js";
7
62
  const KNOWN_KINDS = [
8
63
  'knowledge',
@@ -26,6 +81,18 @@ export const graphCommand = {
26
81
  // Code-intelligence subverbs (R65) don't need the knowledge graph —
27
82
  // dispatch them before the expensive inspection so they stay fast.
28
83
  const earlySub = args.positional[0];
84
+ // Reject unknown/misspelled flags on the code-graph family BEFORE dispatch:
85
+ // an unrecognized flag must never parse as a silent success (a25 §2.1). Any
86
+ // real flag is in the union allow-set, so this only fires on a genuine typo.
87
+ if (typeof earlySub === 'string' && CODE_GRAPH_SUBVERBS.has(earlySub)) {
88
+ const unknown = firstUnknownFlag(args, CODE_GRAPH_ALLOWED_FLAGS);
89
+ if (unknown) {
90
+ const dash = unknown.length === 1 ? '-' : '--';
91
+ process.stderr.write(`unknown option '${dash}${unknown}' for 'shrk graph ${earlySub}'. ` +
92
+ `Run 'shrk graph --help' for valid flags.\n`);
93
+ return ExitCode.NotVerified;
94
+ }
95
+ }
29
96
  if (earlySub === 'index')
30
97
  return runGraphIndex(args);
31
98
  if (earlySub === 'status')
@@ -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]);
@@ -0,0 +1,48 @@
1
+ /**
2
+ * `--resolve` support for `shrk registry <name> exists <id>`.
3
+ *
4
+ * The noun an author types is not always the exact registered slug. `--resolve`
5
+ * bridges that vocabulary gap before the existence check so a duplicate guard
6
+ * can't return a false "not declared" for a construct that DOES exist under its
7
+ * canonical id (a25 §2.4). Resolution is deterministic and layered, most
8
+ * author-controlled first:
9
+ *
10
+ * 1. the registry's declared `aliases` map (explicit synonym → canonical),
11
+ * 2. a case-insensitive exact match against the declared ids,
12
+ * 3. singular/plural normalization (`commands` ↔ `command`),
13
+ * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
14
+ *
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.
23
+ */
24
+ /** How a noun resolved to its canonical registered id (for a truthful report). */
25
+ export declare enum ERegistryResolveVia {
26
+ Identity = "identity",
27
+ Alias = "alias",
28
+ Case = "case-fold",
29
+ SingularPlural = "singular/plural",
30
+ Suffix = "suffix",
31
+ /** Both a singular/plural AND a suffix transform were applied (chained). */
32
+ SingularPluralSuffix = "singular/plural+suffix"
33
+ }
34
+ export interface IRegistryResolution {
35
+ /** The canonical id to test for existence (may equal the input noun). */
36
+ readonly canonical: string;
37
+ /** True when `canonical` is actually a declared id in the registry. */
38
+ readonly matched: boolean;
39
+ /** Which normalization layer produced `canonical`. */
40
+ readonly via: ERegistryResolveVia;
41
+ }
42
+ /**
43
+ * Resolve a human/synonym `noun` to a canonical registered id. `declaredIds` is
44
+ * the registry's actual declared id list (so no layer can resolve to something
45
+ * that isn't declared); `aliases` is the registry's optional declared map.
46
+ */
47
+ export declare function resolveRegistryNoun(declaredIds: readonly string[], aliases: Readonly<Record<string, string>> | undefined, noun: string): IRegistryResolution;
48
+ //# sourceMappingURL=registry-resolve.d.ts.map
@@ -0,0 +1 @@
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"}
@@ -0,0 +1,115 @@
1
+ /**
2
+ * `--resolve` support for `shrk registry <name> exists <id>`.
3
+ *
4
+ * The noun an author types is not always the exact registered slug. `--resolve`
5
+ * bridges that vocabulary gap before the existence check so a duplicate guard
6
+ * can't return a false "not declared" for a construct that DOES exist under its
7
+ * canonical id (a25 §2.4). Resolution is deterministic and layered, most
8
+ * author-controlled first:
9
+ *
10
+ * 1. the registry's declared `aliases` map (explicit synonym → canonical),
11
+ * 2. a case-insensitive exact match against the declared ids,
12
+ * 3. singular/plural normalization (`commands` ↔ `command`),
13
+ * 4. suffix strip/append (`button` ↔ `button-command`, `foo` ↔ `foo.tool`).
14
+ *
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.
23
+ */
24
+ /** How a noun resolved to its canonical registered id (for a truthful report). */
25
+ export var ERegistryResolveVia;
26
+ (function (ERegistryResolveVia) {
27
+ ERegistryResolveVia["Identity"] = "identity";
28
+ ERegistryResolveVia["Alias"] = "alias";
29
+ ERegistryResolveVia["Case"] = "case-fold";
30
+ ERegistryResolveVia["SingularPlural"] = "singular/plural";
31
+ ERegistryResolveVia["Suffix"] = "suffix";
32
+ /** Both a singular/plural AND a suffix transform were applied (chained). */
33
+ ERegistryResolveVia["SingularPluralSuffix"] = "singular/plural+suffix";
34
+ })(ERegistryResolveVia || (ERegistryResolveVia = {}));
35
+ function singularPluralVariants(noun) {
36
+ const out = [];
37
+ const lower = noun.toLowerCase();
38
+ if (lower.endsWith('ies'))
39
+ out.push(noun.slice(0, -3) + 'y');
40
+ if (lower.endsWith('s'))
41
+ out.push(noun.slice(0, -1));
42
+ if (lower.endsWith('y'))
43
+ out.push(noun.slice(0, -1) + 'ies');
44
+ out.push(noun + 's');
45
+ return out;
46
+ }
47
+ /**
48
+ * Resolve a human/synonym `noun` to a canonical registered id. `declaredIds` is
49
+ * the registry's actual declared id list (so no layer can resolve to something
50
+ * that isn't declared); `aliases` is the registry's optional declared map.
51
+ */
52
+ export function resolveRegistryNoun(declaredIds, aliases, noun) {
53
+ const idSet = new Set(declaredIds);
54
+ // 1. Author-declared alias map — highest priority, honored even if it points
55
+ // at an id the current scan didn't find (the author asserted the mapping).
56
+ const aliased = aliases?.[noun];
57
+ if (aliased !== undefined) {
58
+ return { canonical: aliased, matched: idSet.has(aliased), via: ERegistryResolveVia.Alias };
59
+ }
60
+ // Exact identity.
61
+ if (idSet.has(noun)) {
62
+ return { canonical: noun, matched: true, via: ERegistryResolveVia.Identity };
63
+ }
64
+ const lower = noun.toLowerCase();
65
+ // 2. Case-insensitive exact.
66
+ const ciHit = declaredIds.find((d) => d.toLowerCase() === lower);
67
+ if (ciHit !== undefined) {
68
+ return { canonical: ciHit, matched: true, via: ERegistryResolveVia.Case };
69
+ }
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 };
90
+ }
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
+ }
111
+ }
112
+ }
113
+ // Nothing resolved — the honest unmatched identity.
114
+ return { canonical: noun, matched: false, via: ERegistryResolveVia.Identity };
115
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"registry.command.d.ts","sourceRoot":"","sources":["../../src/commands/registry.command.ts"],"names":[],"mappings":"AA4BA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAGhC,eAAO,MAAM,wBAAwB,EAAE,eAsCtC,CAAC;AAsIF,eAAO,MAAM,eAAe,EAAE,eA4B7B,CAAC"}
1
+ {"version":3,"file":"registry.command.d.ts","sourceRoot":"","sources":["../../src/commands/registry.command.ts"],"names":[],"mappings":"AA4BA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAKhC,eAAO,MAAM,wBAAwB,EAAE,eA8CtC,CAAC;AA6IF,eAAO,MAAM,eAAe,EAAE,eA4B7B,CAAC"}
@@ -16,7 +16,9 @@
16
16
  import { buildRegistryLifecycleReport, renderRegistryLifecycleReportText, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
17
17
  import { scanRegistry, registryExists, registryWhere, } from '@shrkcrft/boundaries';
18
18
  import { flagBool, flagString, resolveCwd, } from "../command-registry.js";
19
+ import { ExitCode } from "../exit-codes.js";
19
20
  import { asJson } from "../output/format-output.js";
21
+ import { resolveRegistryNoun } from "./registry-resolve.js";
20
22
  export const registryLifecycleCommand = {
21
23
  name: 'lifecycle',
22
24
  description: 'Scan the workspace for register/remove symmetry. Read-only.',
@@ -48,7 +50,14 @@ export const registryLifecycleCommand = {
48
50
  ...(scope ? { scope } : {}),
49
51
  ...(skipDirs ? { skipDirs } : {}),
50
52
  });
51
- const exit = report.timedOut ? 2 : report.missingRemovers.length === 0 ? 0 : 1;
53
+ // Honest exit-code contract (a25 §1 / §2.5): a wedged/timed-out scan OR a
54
+ // scope with zero registrations to check is NOT verified (`2`), never a
55
+ // green `0` an agent's `&& next` would march past.
56
+ const exit = report.timedOut || report.registersFound === 0
57
+ ? ExitCode.NotVerified
58
+ : report.missingRemovers.length === 0
59
+ ? ExitCode.VerifiedPass
60
+ : ExitCode.Failure;
52
61
  if (flagBool(args, 'json')) {
53
62
  process.stdout.write(asJson(report) + '\n');
54
63
  return exit;
@@ -123,11 +132,16 @@ async function runRegistryInventory(args, name) {
123
132
  process.stderr.write('Pass at most one of --fail-if-taken / --fail-if-missing.\n');
124
133
  return 2;
125
134
  }
126
- // `--resolve` maps a human noun to the canonical registered id via the
127
- // registry's `aliases` map before the existence test — so a duplicate guard
128
- // can't return a false "free" on a synonym of an already-taken slug.
135
+ // `--resolve` maps a human noun to the canonical registered id before the
136
+ // existence test — via the registry's declared `aliases` map AND generic
137
+ // normalization (case-fold, singular/plural, suffix strip/append) so a
138
+ // duplicate guard can't return a false "free" on a synonym of an
139
+ // already-taken slug (a25 §2.4).
129
140
  const doResolve = flagBool(args, 'resolve');
130
- const canonical = doResolve ? (decl.aliases?.[id] ?? id) : id;
141
+ const resolution = doResolve
142
+ ? resolveRegistryNoun(inventory.entries.map((e) => e.id), decl.aliases, id)
143
+ : undefined;
144
+ const canonical = resolution ? resolution.canonical : id;
131
145
  const resolved = canonical !== id;
132
146
  const exists = registryExists(inventory, canonical);
133
147
  // Exit-code convention:
@@ -140,14 +154,15 @@ async function runRegistryInventory(args, name) {
140
154
  process.stdout.write(asJson({
141
155
  name: inventory.name,
142
156
  id,
143
- ...(resolved ? { resolvedId: canonical } : {}),
157
+ ...(resolved ? { resolvedId: canonical, resolvedVia: resolution?.via } : {}),
144
158
  exists,
145
159
  exitCode: code,
146
160
  }) + '\n');
147
161
  return code;
148
162
  }
149
- if (resolved)
150
- process.stdout.write(`resolved "${id}" → "${canonical}" (alias)\n`);
163
+ if (resolved) {
164
+ process.stdout.write(`resolved "${id}" → "${canonical}" (${resolution?.via})\n`);
165
+ }
151
166
  process.stdout.write(`${exists ? 'yes' : 'no'} — "${canonical}" is ${exists ? 'declared' : 'NOT declared'} in registry "${inventory.name}".\n`);
152
167
  return code;
153
168
  }
@@ -1,3 +1,23 @@
1
+ import type { IReusePrimitive } from '@shrkcrft/core';
1
2
  import { type ICommandHandler } from '../command-registry.js';
3
+ /** A weak (below-confidence) candidate offered as a did-you-mean, never as an answer. */
4
+ interface IReuseSuggestion {
5
+ symbol: string;
6
+ score: number;
7
+ confidence: number;
8
+ matched: readonly string[];
9
+ roles: readonly string[];
10
+ }
11
+ /**
12
+ * Rank ALL primitives by the matcher's own score (descending; ties broken by
13
+ * symbol name so the order is deterministic), then return the top-`k` as scored
14
+ * did-you-mean suggestions. Pure — no graph, no IO — so it is directly
15
+ * unit-testable. When every candidate scores 0 (a nonsense intent that shares no
16
+ * term) the result is the alphabetically-first `k` primitives, each with
17
+ * `score: 0`; the caller states "no candidate shares any term" in that case
18
+ * rather than dumping the whole catalog.
19
+ */
20
+ export declare function rankReuseSuggestions(primitives: readonly IReusePrimitive[], tokens: readonly string[], k: number): IReuseSuggestion[];
2
21
  export declare const reuseCommand: ICommandHandler;
22
+ export {};
3
23
  //# sourceMappingURL=reuse.command.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"reuse.command.d.ts","sourceRoot":"","sources":["../../src/commands/reuse.command.ts"],"names":[],"mappings":"AAGA,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AA2GhC,eAAO,MAAM,YAAY,EAAE,eA0N1B,CAAC"}
1
+ {"version":3,"file":"reuse.command.d.ts","sourceRoot":"","sources":["../../src/commands/reuse.command.ts"],"names":[],"mappings":"AAEA,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,gBAAgB,CAAC;AACtD,OAAO,EAIL,KAAK,eAAe,EAErB,MAAM,wBAAwB,CAAC;AAkGhC,yFAAyF;AACzF,UAAU,gBAAgB;IACxB,MAAM,EAAE,MAAM,CAAC;IACf,KAAK,EAAE,MAAM,CAAC;IACd,UAAU,EAAE,MAAM,CAAC;IACnB,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;CAC1B;AAED;;;;;;;;GAQG;AACH,wBAAgB,oBAAoB,CAClC,UAAU,EAAE,SAAS,eAAe,EAAE,EACtC,MAAM,EAAE,SAAS,MAAM,EAAE,EACzB,CAAC,EAAE,MAAM,GACR,gBAAgB,EAAE,CAapB;AAED,eAAO,MAAM,YAAY,EAAE,eA6Q1B,CAAC"}