@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
@@ -1,7 +1,7 @@
1
1
  /**
2
- * CLI-side `--since <ref>` helper. Thin wrapper over `git diff
3
- * --name-status <ref>...HEAD` that returns the changed file set + a
4
- * deleted file set + an `isAvailable` flag so callers can fall back
2
+ * CLI-side `--since <ref>` helper. Thin wrapper over a two-dot `git diff
3
+ * --name-status <ref>` (working tree vs `<ref>`) that returns the changed file
4
+ * set + a deleted file set + an `isAvailable` flag so callers can fall back
5
5
  * to whole-tree mode cleanly.
6
6
  *
7
7
  * Default ref resolution when `--since` is passed without a value:
@@ -23,5 +23,7 @@ export declare function resolveDefaultSinceRef(cwd: string): string | undefined;
23
23
  export declare function collectChangedPaths(opts: {
24
24
  cwd: string;
25
25
  ref?: string;
26
+ /** Read the staged (index) diff instead of diffing against a ref. */
27
+ staged?: boolean;
26
28
  }): IChangedPaths;
27
29
  //# sourceMappingURL=collect-changed-paths.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"collect-changed-paths.d.ts","sourceRoot":"","sources":["../../src/diff/collect-changed-paths.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,WAAW,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAYtE;AAED,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;CACd,GAAG,aAAa,CA+ChB"}
1
+ {"version":3,"file":"collect-changed-paths.d.ts","sourceRoot":"","sources":["../../src/diff/collect-changed-paths.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;;;;GAaG;AACH,MAAM,WAAW,aAAa;IAC5B,GAAG,EAAE,MAAM,CAAC;IACZ,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IAC3B,WAAW,EAAE,OAAO,CAAC;IACrB,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAMD,wBAAgB,sBAAsB,CAAC,GAAG,EAAE,MAAM,GAAG,MAAM,GAAG,SAAS,CAOtE;AAoCD,wBAAgB,mBAAmB,CAAC,IAAI,EAAE;IACxC,GAAG,EAAE,MAAM,CAAC;IACZ,GAAG,CAAC,EAAE,MAAM,CAAC;IACb,qEAAqE;IACrE,MAAM,CAAC,EAAE,OAAO,CAAC;CAClB,GAAG,aAAa,CAwDhB"}
@@ -1,22 +1,72 @@
1
- import { execSync } from 'node:child_process';
2
1
  import * as nodePath from 'node:path';
2
+ import { runGitLines } from '@shrkcrft/shared';
3
3
  const DEFAULT_REF_CANDIDATES = ['origin/main', 'main', 'origin/master', 'master'];
4
4
  const PER_PROCESS_CACHE = new Map();
5
5
  export function resolveDefaultSinceRef(cwd) {
6
6
  for (const candidate of DEFAULT_REF_CANDIDATES) {
7
- try {
8
- execSync(`git -C "${cwd}" rev-parse --verify --quiet "${candidate}"`, {
9
- stdio: 'pipe',
10
- });
7
+ if (runGitLines(cwd, ['rev-parse', '--verify', '--quiet', candidate]).ok) {
11
8
  return candidate;
12
9
  }
13
- catch {
14
- // try next
15
- }
16
10
  }
17
11
  return undefined;
18
12
  }
13
+ function toPosix(path) {
14
+ return nodePath.normalize(path).split(nodePath.sep).join('/');
15
+ }
16
+ /**
17
+ * Split `git diff --name-status` lines into changed vs deleted POSIX paths.
18
+ * Rename/copy detection is ON by default for `git diff`, so a rename emits a
19
+ * TAB-separated `R<score>\t<old>\t<new>` (copy: `C<score>\t<old>\t<new>`) — the
20
+ * status field is `R100`, not a bare letter. The old path of a RENAME is gone
21
+ * (so the orphan check must treat it as deleted); a COPY keeps its source. Every
22
+ * other status (A/M/T/…) carries a single path. Parsing the status by its first
23
+ * letter + splitting on TAB is what makes a `git mv` that orphans an importer
24
+ * visible to `check orphans` / `finish` instead of silently dropped.
25
+ */
26
+ function parseNameStatus(lines) {
27
+ const changed = [];
28
+ const deleted = [];
29
+ for (const line of lines) {
30
+ const parts = line.split('\t');
31
+ if (parts.length < 2)
32
+ continue;
33
+ const code = parts[0][0];
34
+ if (code === 'D') {
35
+ deleted.push(toPosix(parts[1]));
36
+ }
37
+ else if ((code === 'R' || code === 'C') && parts.length >= 3) {
38
+ // R<score>\t<old>\t<new> (rename) / C<score>\t<old>\t<new> (copy).
39
+ if (code === 'R')
40
+ deleted.push(toPosix(parts[1])); // old path renamed away
41
+ changed.push(toPosix(parts[2])); // new path is added
42
+ }
43
+ else {
44
+ changed.push(toPosix(parts[1]));
45
+ }
46
+ }
47
+ return { changed, deleted };
48
+ }
19
49
  export function collectChangedPaths(opts) {
50
+ if (opts.staged) {
51
+ const cacheKey = `${opts.cwd}::staged`;
52
+ const cached = PER_PROCESS_CACHE.get(cacheKey);
53
+ if (cached)
54
+ return cached;
55
+ const out = runGitLines(opts.cwd, ['diff', '--name-status', '--cached']);
56
+ if (!out.ok) {
57
+ return {
58
+ ref: 'STAGED',
59
+ changed: [],
60
+ deleted: [],
61
+ isAvailable: false,
62
+ ...(out.error ? { error: out.error } : {}),
63
+ };
64
+ }
65
+ const { changed, deleted } = parseNameStatus(out.lines);
66
+ const result = { ref: 'STAGED', changed, deleted, isAvailable: true };
67
+ PER_PROCESS_CACHE.set(cacheKey, result);
68
+ return result;
69
+ }
20
70
  const ref = opts.ref ?? resolveDefaultSinceRef(opts.cwd);
21
71
  if (!ref) {
22
72
  return {
@@ -27,42 +77,29 @@ export function collectChangedPaths(opts) {
27
77
  error: 'no resolvable default branch (tried origin/main, main, origin/master, master)',
28
78
  };
29
79
  }
30
- const cacheKey = `${opts.cwd}::${ref}`;
80
+ // Namespace the ref cache key so a branch/tag literally named `staged` can't
81
+ // collide with the `::staged` index-diff key above.
82
+ const cacheKey = `${opts.cwd}::ref::${ref}`;
31
83
  const cached = PER_PROCESS_CACHE.get(cacheKey);
32
84
  if (cached)
33
85
  return cached;
34
- try {
35
- // Two-dot diff against the ref to match the inspector's
36
- // `getChangedFiles({ since })` behavior includes both committed
37
- // changes and the working tree relative to <ref>.
38
- const stdout = execSync(`git -C "${opts.cwd}" diff --name-status "${ref}"`, { stdio: ['ignore', 'pipe', 'pipe'], encoding: 'utf8' });
39
- const changed = [];
40
- const deleted = [];
41
- for (const line of stdout.split('\n')) {
42
- const trimmed = line.trim();
43
- if (!trimmed)
44
- continue;
45
- const m = trimmed.match(/^(\w)\s+(.+)$/);
46
- if (!m)
47
- continue;
48
- const [, status, path] = m;
49
- const norm = nodePath.normalize(path).split(nodePath.sep).join('/');
50
- if (status === 'D')
51
- deleted.push(norm);
52
- else
53
- changed.push(norm);
54
- }
55
- const result = { ref, changed, deleted, isAvailable: true };
56
- PER_PROCESS_CACHE.set(cacheKey, result);
57
- return result;
58
- }
59
- catch (e) {
86
+ // Two-dot diff against the ref to match the inspector's
87
+ // `getChangedFiles({ since })` behavior includes both committed
88
+ // changes and the working tree relative to <ref>. `runGitLines` is
89
+ // shell-free + high-maxBuffer, so a large changeset no longer ENOBUFS-crashes
90
+ // the `impact --deleted` orphan check (this is its only write-safety guard).
91
+ const out = runGitLines(opts.cwd, ['diff', '--name-status', ref]);
92
+ if (!out.ok) {
60
93
  return {
61
94
  ref,
62
95
  changed: [],
63
96
  deleted: [],
64
97
  isAvailable: false,
65
- error: e instanceof Error ? e.message : String(e),
98
+ ...(out.error ? { error: out.error } : {}),
66
99
  };
67
100
  }
101
+ const { changed, deleted } = parseNameStatus(out.lines);
102
+ const result = { ref, changed, deleted, isAvailable: true };
103
+ PER_PROCESS_CACHE.set(cacheKey, result);
104
+ return result;
68
105
  }
@@ -0,0 +1,42 @@
1
+ import type { IDeletedOrphanReport } from '@shrkcrft/impact-engine';
2
+ /**
3
+ * Why the scan could not produce an orphan report (so callers pick an exit
4
+ * code / message): the diff was unavailable (no resolvable ref, git error) or
5
+ * the code-graph store is missing.
6
+ */
7
+ export type DeletedOrphanScanFailure = 'diff-unavailable' | 'graph-missing';
8
+ /** Result of {@link computeDeletedOrphans}. */
9
+ export interface IDeletedOrphanScan {
10
+ /** True when the diff resolved (and, when anything was deleted, the graph too). */
11
+ readonly ok: boolean;
12
+ /** What was diffed: `'STAGED'` for `--staged`, otherwise the resolved ref. */
13
+ readonly ref: string;
14
+ /** Deleted files read from the diff (empty list is a valid, clean result). */
15
+ readonly deleted: readonly string[];
16
+ /**
17
+ * The alias-resolved orphan report. Present iff `ok` AND something was
18
+ * deleted — when `deleted` is empty the scan short-circuits clean without
19
+ * loading the graph, so there is no report (and none is needed).
20
+ */
21
+ readonly report?: IDeletedOrphanReport;
22
+ /** Why the scan failed. Present iff not `ok`. */
23
+ readonly reason?: DeletedOrphanScanFailure;
24
+ /** Human-readable failure detail. */
25
+ readonly error?: string;
26
+ }
27
+ /**
28
+ * Diff-driven reverse-closure: read the files DELETED in the changeset (vs
29
+ * `since`, or the staged index when `staged`), then query the code-graph
30
+ * snapshot for surviving files that still import them or reference a symbol
31
+ * they declared (alias-resolved, incl. barrel re-exports). The shared core
32
+ * behind `impact --deleted`, `check orphans`, and the composite `finish` gate.
33
+ *
34
+ * Never throws: a bad diff → `{ reason: 'diff-unavailable' }`, a missing index
35
+ * → `{ reason: 'graph-missing' }`. Graph + engine are imported lazily so a
36
+ * command that never hits the orphan path doesn't pay to load them.
37
+ */
38
+ export declare function computeDeletedOrphans(cwd: string, opts: {
39
+ since?: string;
40
+ staged?: boolean;
41
+ }): Promise<IDeletedOrphanScan>;
42
+ //# sourceMappingURL=deleted-orphans.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"deleted-orphans.d.ts","sourceRoot":"","sources":["../../src/diff/deleted-orphans.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,oBAAoB,EAAE,MAAM,yBAAyB,CAAC;AAGpE;;;;GAIG;AACH,MAAM,MAAM,wBAAwB,GAAG,kBAAkB,GAAG,eAAe,CAAC;AAE5E,+CAA+C;AAC/C,MAAM,WAAW,kBAAkB;IACjC,mFAAmF;IACnF,QAAQ,CAAC,EAAE,EAAE,OAAO,CAAC;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,QAAQ,CAAC,OAAO,EAAE,SAAS,MAAM,EAAE,CAAC;IACpC;;;;OAIG;IACH,QAAQ,CAAC,MAAM,CAAC,EAAE,oBAAoB,CAAC;IACvC,iDAAiD;IACjD,QAAQ,CAAC,MAAM,CAAC,EAAE,wBAAwB,CAAC;IAC3C,qCAAqC;IACrC,QAAQ,CAAC,KAAK,CAAC,EAAE,MAAM,CAAC;CACzB;AAED;;;;;;;;;;GAUG;AACH,wBAAsB,qBAAqB,CACzC,GAAG,EAAE,MAAM,EACX,IAAI,EAAE;IAAE,KAAK,CAAC,EAAE,MAAM,CAAC;IAAC,MAAM,CAAC,EAAE,OAAO,CAAA;CAAE,GACzC,OAAO,CAAC,kBAAkB,CAAC,CAmC7B"}
@@ -0,0 +1,46 @@
1
+ import { collectChangedPaths } from "./collect-changed-paths.js";
2
+ /**
3
+ * Diff-driven reverse-closure: read the files DELETED in the changeset (vs
4
+ * `since`, or the staged index when `staged`), then query the code-graph
5
+ * snapshot for surviving files that still import them or reference a symbol
6
+ * they declared (alias-resolved, incl. barrel re-exports). The shared core
7
+ * behind `impact --deleted`, `check orphans`, and the composite `finish` gate.
8
+ *
9
+ * Never throws: a bad diff → `{ reason: 'diff-unavailable' }`, a missing index
10
+ * → `{ reason: 'graph-missing' }`. Graph + engine are imported lazily so a
11
+ * command that never hits the orphan path doesn't pay to load them.
12
+ */
13
+ export async function computeDeletedOrphans(cwd, opts) {
14
+ const changed = collectChangedPaths({
15
+ cwd,
16
+ ...(opts.since ? { ref: opts.since } : {}),
17
+ ...(opts.staged ? { staged: true } : {}),
18
+ });
19
+ if (!changed.isAvailable) {
20
+ return {
21
+ ok: false,
22
+ ref: changed.ref,
23
+ deleted: [],
24
+ reason: 'diff-unavailable',
25
+ ...(changed.error ? { error: changed.error } : {}),
26
+ };
27
+ }
28
+ // Nothing deleted → clean by definition; don't pay to load the graph (and
29
+ // don't fail when no index exists — there is nothing to check).
30
+ if (changed.deleted.length === 0) {
31
+ return { ok: true, ref: changed.ref, deleted: [] };
32
+ }
33
+ const { GraphStore, GraphQueryApi } = await import('@shrkcrft/graph');
34
+ if (!new GraphStore(cwd).exists()) {
35
+ return {
36
+ ok: false,
37
+ ref: changed.ref,
38
+ deleted: changed.deleted,
39
+ reason: 'graph-missing',
40
+ error: 'code-graph store missing — run `shrk graph index` first.',
41
+ };
42
+ }
43
+ const { findDeletedOrphans } = await import('@shrkcrft/impact-engine');
44
+ const report = findDeletedOrphans(GraphQueryApi.fromStore(cwd), changed.deleted);
45
+ return { ok: true, ref: changed.ref, deleted: changed.deleted, report };
46
+ }
@@ -0,0 +1,62 @@
1
+ import { type IChangedScopeOptions } from '@shrkcrft/inspector';
2
+ export declare const FINISH_SCHEMA: "sharkcraft.finish/v1";
3
+ /** Outcome of one sub-gate. `skipped` = nothing to evaluate (loud, never silent green). */
4
+ export type FinishGateStatus = 'pass' | 'fail' | 'skipped';
5
+ /** One failing/relevant item, with file:line where the engine provides it. */
6
+ export interface IFinishItem {
7
+ readonly file?: string;
8
+ readonly line?: number;
9
+ readonly message: string;
10
+ }
11
+ export interface IFinishGate {
12
+ readonly name: 'boundaries' | 'imports' | 'wiring' | 'policy' | 'orphans';
13
+ readonly status: FinishGateStatus;
14
+ /** One-line reason (e.g. why skipped, or the error/warning counts). */
15
+ readonly detail: string;
16
+ readonly errors: number;
17
+ readonly warnings: number;
18
+ /** Failing/notable items (capped by the renderer, full in JSON). */
19
+ readonly items: readonly IFinishItem[];
20
+ }
21
+ export interface IFinishImpact {
22
+ readonly ran: boolean;
23
+ readonly risk?: string;
24
+ readonly directDependents?: number;
25
+ readonly transitiveDependents?: number;
26
+ /** Why the summary could not run (no graph index / no changed files). */
27
+ readonly note?: string;
28
+ }
29
+ export interface IFinishReport {
30
+ readonly schema: typeof FINISH_SCHEMA;
31
+ readonly scope: {
32
+ readonly mode: 'worktree' | 'staged' | 'since' | 'files';
33
+ readonly files: readonly string[];
34
+ readonly fileCount: number;
35
+ };
36
+ readonly gates: readonly IFinishGate[];
37
+ readonly impact: IFinishImpact;
38
+ /** `fail` iff any gate failed OR the config could not load; else `pass`. */
39
+ readonly verdict: 'pass' | 'fail';
40
+ /** Total warning-severity findings across gates (non-blocking). */
41
+ readonly warnings: number;
42
+ /** Set when sharkcraft.config.ts could not be loaded — forces a `fail`. */
43
+ readonly configError?: string;
44
+ readonly summary: string;
45
+ readonly nextAction: string;
46
+ }
47
+ export interface IRunFinishInput {
48
+ readonly cwd: string;
49
+ readonly mode: 'worktree' | 'staged' | 'since' | 'files';
50
+ readonly scope: IChangedScopeOptions;
51
+ }
52
+ /**
53
+ * The composite "is this changeset safe to finish?" orchestrator. Runs every
54
+ * deterministic CHANGED-ONLY gate inline — boundaries + import-hygiene + wiring
55
+ * + policy + deleted-orphans — plus a best-effort impact summary, and folds
56
+ * them into ONE pass/fail. This is the single trustworthy "done?" call an
57
+ * autonomous agent needs and can't reliably assemble by hand (only shrk can run
58
+ * the alias-resolved layer/wiring gates). Honors the `0-rules → skipped`
59
+ * semantics so a no-op sub-check is reported, never silently passed. Read-only.
60
+ */
61
+ export declare function runFinishGates(input: IRunFinishInput): Promise<IFinishReport>;
62
+ //# sourceMappingURL=run-finish.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"run-finish.d.ts","sourceRoot":"","sources":["../../src/finish/run-finish.ts"],"names":[],"mappings":"AAAA,OAAO,EAML,KAAK,oBAAoB,EAC1B,MAAM,qBAAqB,CAAC;AAa7B,eAAO,MAAM,aAAa,EAAG,sBAA+B,CAAC;AAE7D,2FAA2F;AAC3F,MAAM,MAAM,gBAAgB,GAAG,MAAM,GAAG,MAAM,GAAG,SAAS,CAAC;AAE3D,8EAA8E;AAC9E,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;CAC1B;AAED,MAAM,WAAW,WAAW;IAC1B,QAAQ,CAAC,IAAI,EAAE,YAAY,GAAG,SAAS,GAAG,QAAQ,GAAG,QAAQ,GAAG,SAAS,CAAC;IAC1E,QAAQ,CAAC,MAAM,EAAE,gBAAgB,CAAC;IAClC,uEAAuE;IACvE,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAC;IACxB,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,oEAAoE;IACpE,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;CACxC;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,GAAG,EAAE,OAAO,CAAC;IACtB,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;IACvB,QAAQ,CAAC,gBAAgB,CAAC,EAAE,MAAM,CAAC;IACnC,QAAQ,CAAC,oBAAoB,CAAC,EAAE,MAAM,CAAC;IACvC,yEAAyE;IACzE,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;CACxB;AAED,MAAM,WAAW,aAAa;IAC5B,QAAQ,CAAC,MAAM,EAAE,OAAO,aAAa,CAAC;IACtC,QAAQ,CAAC,KAAK,EAAE;QACd,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;QACzD,QAAQ,CAAC,KAAK,EAAE,SAAS,MAAM,EAAE,CAAC;QAClC,QAAQ,CAAC,SAAS,EAAE,MAAM,CAAC;KAC5B,CAAC;IACF,QAAQ,CAAC,KAAK,EAAE,SAAS,WAAW,EAAE,CAAC;IACvC,QAAQ,CAAC,MAAM,EAAE,aAAa,CAAC;IAC/B,4EAA4E;IAC5E,QAAQ,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAAC;IAClC,mEAAmE;IACnE,QAAQ,CAAC,QAAQ,EAAE,MAAM,CAAC;IAC1B,2EAA2E;IAC3E,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAC;IAC9B,QAAQ,CAAC,OAAO,EAAE,MAAM,CAAC;IACzB,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;CAC7B;AAED,MAAM,WAAW,eAAe;IAC9B,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAC;IACrB,QAAQ,CAAC,IAAI,EAAE,UAAU,GAAG,QAAQ,GAAG,OAAO,GAAG,OAAO,CAAC;IACzD,QAAQ,CAAC,KAAK,EAAE,oBAAoB,CAAC;CACtC;AAYD;;;;;;;;GAQG;AACH,wBAAsB,cAAc,CAAC,KAAK,EAAE,eAAe,GAAG,OAAO,CAAC,aAAa,CAAC,CAkHnF"}
@@ -0,0 +1,239 @@
1
+ import { buildImportHygieneReport, filterViolationsToChangedScope, inspectSharkcraft, resolveChangedFiles, resolveProjectConfig, } from '@shrkcrft/inspector';
2
+ import { existsSync } from 'node:fs';
3
+ import * as nodePath from 'node:path';
4
+ import { evaluateBoundaries, loadTsconfigPaths, runPolicyLint, runWiring, scanImports, } from '@shrkcrft/boundaries';
5
+ import { computeDeletedOrphans } from "../diff/deleted-orphans.js";
6
+ export const FINISH_SCHEMA = 'sharkcraft.finish/v1';
7
+ /** Map the changed-scope onto the orphan check's diff inputs. */
8
+ function orphanOptsFor(input) {
9
+ if (input.mode === 'files')
10
+ return undefined; // a file list has no diff to read deletions from
11
+ if (input.mode === 'staged')
12
+ return { staged: true };
13
+ if (input.mode === 'since' && input.scope.since)
14
+ return { since: input.scope.since };
15
+ // Worktree mode: diff deletions vs HEAD so the orphan gate's "deleted" set
16
+ // matches the working-tree scope the other gates use (not the whole branch).
17
+ return { since: 'HEAD' };
18
+ }
19
+ /**
20
+ * The composite "is this changeset safe to finish?" orchestrator. Runs every
21
+ * deterministic CHANGED-ONLY gate inline — boundaries + import-hygiene + wiring
22
+ * + policy + deleted-orphans — plus a best-effort impact summary, and folds
23
+ * them into ONE pass/fail. This is the single trustworthy "done?" call an
24
+ * autonomous agent needs and can't reliably assemble by hand (only shrk can run
25
+ * the alias-resolved layer/wiring gates). Honors the `0-rules → skipped`
26
+ * semantics so a no-op sub-check is reported, never silently passed. Read-only.
27
+ */
28
+ export async function runFinishGates(input) {
29
+ const { cwd } = input;
30
+ const changed = resolveChangedFiles(input.scope);
31
+ const changedFiles = changed.files;
32
+ const gates = [];
33
+ // ── boundaries (changed-only) ────────────────────────────────────────
34
+ const inspection = await inspectSharkcraft({ cwd });
35
+ const boundaryRules = inspection.boundaryRegistry.list();
36
+ if (boundaryRules.length === 0) {
37
+ gates.push(skip('boundaries', 'no boundary rules configured'));
38
+ }
39
+ else if (changedFiles.length === 0) {
40
+ gates.push(skip('boundaries', 'no files in changed scope'));
41
+ }
42
+ else {
43
+ const scan = scanImports({ projectRoot: cwd });
44
+ const tsconfigPaths = loadTsconfigPaths(cwd);
45
+ const evalResult = evaluateBoundaries(scan, boundaryRules, {
46
+ ...(tsconfigPaths.aliases.size > 0 ? { tsconfigPaths } : {}),
47
+ });
48
+ const filtered = filterViolationsToChangedScope(evalResult.violations, input.scope);
49
+ const errors = filtered.includedViolations.filter((v) => v.severity === 'error');
50
+ const warnings = filtered.includedViolations.filter((v) => v.severity === 'warning');
51
+ gates.push({
52
+ name: 'boundaries',
53
+ status: errors.length > 0 ? 'fail' : 'pass',
54
+ detail: `${errors.length} error(s), ${warnings.length} warning(s) across ${changedFiles.length} changed file(s)`,
55
+ errors: errors.length,
56
+ warnings: warnings.length,
57
+ items: [...errors, ...warnings].map((v) => ({
58
+ file: v.file,
59
+ line: v.line,
60
+ message: `[${v.severity}] ${v.ruleId}: ${v.message}`,
61
+ })),
62
+ });
63
+ }
64
+ // ── import hygiene (changed-only) ────────────────────────────────────
65
+ if (changedFiles.length === 0) {
66
+ gates.push(skip('imports', 'no files in changed scope'));
67
+ }
68
+ else {
69
+ const report = buildImportHygieneReport(cwd, { files: changedFiles });
70
+ const errors = report.counts?.['error'] ?? (report.verdict === 'errors' ? report.findings.length : 0);
71
+ const warnings = report.counts?.['warning'] ?? (report.verdict === 'warnings' ? report.findings.length : 0);
72
+ gates.push({
73
+ name: 'imports',
74
+ status: report.verdict === 'errors' ? 'fail' : 'pass',
75
+ detail: `verdict=${report.verdict} (${report.findings.length} finding(s))`,
76
+ errors,
77
+ warnings,
78
+ items: report.findings.map((f) => ({
79
+ file: f.file,
80
+ line: f.line,
81
+ message: `${f.kind}: ${f.suggestedFix || f.reason || f.snippet}`.trim(),
82
+ })),
83
+ });
84
+ }
85
+ // ── wiring + policy (changed-only, from resolved config) ─────────────
86
+ const loaded = await resolveProjectConfig(cwd);
87
+ let configError;
88
+ if (!loaded.ok) {
89
+ // A MALFORMED config in a real sharkcraft project is a fail (the wiring/
90
+ // policy gates can't be trusted). The mere ABSENCE of a sharkcraft/ folder
91
+ // is not — those gates simply don't apply, so skip them without failing.
92
+ const isSharkcraftProject = existsSync(nodePath.join(cwd, 'sharkcraft'));
93
+ if (isSharkcraftProject)
94
+ configError = loaded.error.message;
95
+ const detail = isSharkcraftProject
96
+ ? `config did not load: ${loaded.error.message}`
97
+ : 'no sharkcraft config (gate not applicable)';
98
+ gates.push(skip('wiring', detail));
99
+ gates.push(skip('policy', detail));
100
+ }
101
+ else {
102
+ gates.push(wiringGate(cwd, loaded.value.config.wiringRules ?? [], changedFiles), policyGate(cwd, loaded.value.config.policyRules ?? [], changedFiles));
103
+ }
104
+ // ── deleted-orphans (write-safety) ───────────────────────────────────
105
+ gates.push(await orphansGate(input));
106
+ // ── impact summary (informational, best-effort) ──────────────────────
107
+ const impact = await impactSummary(cwd, changedFiles);
108
+ // ── fold into one verdict ────────────────────────────────────────────
109
+ const failed = gates.filter((g) => g.status === 'fail');
110
+ const warnings = gates.reduce((n, g) => n + g.warnings, 0);
111
+ const verdict = failed.length > 0 || configError ? 'fail' : 'pass';
112
+ const skipped = gates.filter((g) => g.status === 'skipped').map((g) => g.name);
113
+ const summary = verdict === 'fail'
114
+ ? `Not safe to finish: ${configError ? 'config failed to load; ' : ''}${failed.map((g) => `${g.name} (${g.errors} error(s))`).join(', ') || 'see gates'}.`
115
+ : changedFiles.length === 0 && input.mode !== 'files'
116
+ ? 'No files in the changed scope — nothing to gate.'
117
+ : `Safe to finish: every applicable gate passed${warnings > 0 ? ` (${warnings} non-blocking warning(s))` : ''}${skipped.length > 0 ? `; skipped: ${skipped.join(', ')}` : ''}.`;
118
+ const nextAction = verdict === 'fail'
119
+ ? 'Fix every failing gate item (each carries file:line), then re-run `shrk finish`.'
120
+ : 'Safe to declare done.';
121
+ return {
122
+ schema: FINISH_SCHEMA,
123
+ scope: { mode: input.mode, files: changedFiles, fileCount: changedFiles.length },
124
+ gates,
125
+ impact,
126
+ verdict,
127
+ warnings,
128
+ ...(configError ? { configError } : {}),
129
+ summary,
130
+ nextAction,
131
+ };
132
+ }
133
+ function skip(name, detail) {
134
+ return { name, status: 'skipped', detail, errors: 0, warnings: 0, items: [] };
135
+ }
136
+ function wiringGate(cwd, rules, changedFiles) {
137
+ if (rules.length === 0)
138
+ return skip('wiring', 'no wiring rules configured');
139
+ const report = runWiring(cwd, rules, { changedOnly: true, changedFiles });
140
+ if (report.evaluated === 0) {
141
+ return skip('wiring', `${report.rules.length} rule(s) configured but none matched the changed scope`);
142
+ }
143
+ const errors = report.violations.filter((v) => v.severity === 'error');
144
+ const warnings = report.violations.filter((v) => v.severity === 'warning');
145
+ // A misconfigured rule (bad regex / no capture group) yields a diagnostic +
146
+ // rule-level error but NO violation — it must FAIL the gate, never read as a
147
+ // silent green ("loud, never silent green").
148
+ const diag = report.diagnostics;
149
+ return {
150
+ name: 'wiring',
151
+ status: report.verdict === 'errors' || diag.length > 0 ? 'fail' : 'pass',
152
+ detail: `${report.evaluated}/${report.rules.length} rule(s) evaluated — ${errors.length} error(s), ${warnings.length} warning(s)${diag.length > 0 ? `, ${diag.length} misconfigured` : ''}`,
153
+ errors: errors.length + diag.length,
154
+ warnings: warnings.length,
155
+ items: [
156
+ ...report.violations.map((v) => ({
157
+ file: v.file,
158
+ line: v.line,
159
+ message: `[${v.severity}] ${v.ruleId}: "${v.token}" ${v.direction === 'registered-missing' ? 'registered but not declared' : 'declared but not registered'}`,
160
+ })),
161
+ ...diag.map((d) => ({ message: `misconfigured rule: ${d}` })),
162
+ ],
163
+ };
164
+ }
165
+ function policyGate(cwd, rules, changedFiles) {
166
+ if (rules.length === 0)
167
+ return skip('policy', 'no policy rules configured');
168
+ const report = runPolicyLint(cwd, rules, { changedOnly: true, changedFiles });
169
+ if (report.evaluated === 0) {
170
+ return skip('policy', `${report.rules.length} rule(s) configured but none matched the changed scope`);
171
+ }
172
+ const errors = report.findings.filter((f) => f.severity === 'error');
173
+ const warnings = report.findings.filter((f) => f.severity === 'warning');
174
+ const diag = report.diagnostics;
175
+ return {
176
+ name: 'policy',
177
+ status: report.verdict === 'errors' || diag.length > 0 ? 'fail' : 'pass',
178
+ detail: `${report.evaluated}/${report.rules.length} rule(s) evaluated — ${errors.length} error(s), ${warnings.length} warning(s)${diag.length > 0 ? `, ${diag.length} misconfigured` : ''}`,
179
+ errors: errors.length + diag.length,
180
+ warnings: warnings.length,
181
+ items: [
182
+ ...report.findings.map((f) => ({
183
+ file: f.file,
184
+ line: f.line,
185
+ message: `[${f.severity}] ${f.ruleId}: ${f.message ?? ''}`.trim(),
186
+ })),
187
+ ...diag.map((d) => ({ message: `misconfigured rule: ${d}` })),
188
+ ],
189
+ };
190
+ }
191
+ async function orphansGate(input) {
192
+ const opts = orphanOptsFor(input);
193
+ if (!opts)
194
+ return skip('orphans', 'explicit --files scope has no diff to read deletions from');
195
+ const scan = await computeDeletedOrphans(input.cwd, opts);
196
+ if (!scan.ok) {
197
+ return skip('orphans', scan.reason === 'graph-missing'
198
+ ? 'code-graph index missing — run `shrk graph index` to enable the orphan check'
199
+ : `diff unavailable: ${scan.error ?? 'unknown'}`);
200
+ }
201
+ if (scan.deleted.length === 0)
202
+ return skip('orphans', `nothing deleted (vs ${scan.ref})`);
203
+ const orphans = scan.report?.orphans ?? [];
204
+ return {
205
+ name: 'orphans',
206
+ status: orphans.length > 0 ? 'fail' : 'pass',
207
+ detail: `${scan.deleted.length} deleted file(s) (vs ${scan.ref}) — ${orphans.length} surviving importer(s)`,
208
+ errors: orphans.length,
209
+ warnings: 0,
210
+ items: orphans.map((o) => ({
211
+ file: o.path ?? o.id,
212
+ ...(typeof o.line === 'number' ? { line: o.line } : {}),
213
+ message: o.via === 'reference' && o.symbol
214
+ ? `references \`${o.symbol}\` from deleted ${o.deletedFile}`
215
+ : `imports deleted ${o.deletedFile}`,
216
+ })),
217
+ };
218
+ }
219
+ async function impactSummary(cwd, changedFiles) {
220
+ if (changedFiles.length === 0)
221
+ return { ran: false, note: 'no changed files' };
222
+ try {
223
+ const { GraphStore } = await import('@shrkcrft/graph');
224
+ if (!new GraphStore(cwd).exists()) {
225
+ return { ran: false, note: 'code-graph index missing — run `shrk graph index`' };
226
+ }
227
+ const { analyzeGraphImpact } = await import('@shrkcrft/impact-engine');
228
+ const analysis = analyzeGraphImpact({ kind: 'files', files: [...changedFiles] }, { projectRoot: cwd, maxDepth: 5, limit: 200 });
229
+ return {
230
+ ran: true,
231
+ risk: analysis.risk,
232
+ directDependents: analysis.directDependents.length,
233
+ transitiveDependents: analysis.transitiveDependents.length,
234
+ };
235
+ }
236
+ catch (e) {
237
+ return { ran: false, note: `impact summary unavailable: ${e instanceof Error ? e.message : String(e)}` };
238
+ }
239
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,EACL,eAAe,EAKhB,MAAM,uBAAuB,CAAC;AA0X/B,wBAAgB,aAAa,IAAI,eAAe,CA8X/C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BrE;AAqID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAoDxE"}
1
+ {"version":3,"file":"main.d.ts","sourceRoot":"","sources":["../src/main.ts"],"names":[],"mappings":";AAEA,OAAO,EACL,eAAe,EAKhB,MAAM,uBAAuB,CAAC;AA4X/B,wBAAgB,aAAa,IAAI,eAAe,CAgY/C;AAED,wBAAsB,MAAM,CAAC,IAAI,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAAC,MAAM,CAAC,CA2BrE;AAqID;;;;;;;;;;;GAWG;AACH,wBAAgB,qBAAqB,CAAC,MAAM,EAAE,SAAS,MAAM,EAAE,GAAG,OAAO,CAoDxE"}
package/dist/main.js CHANGED
@@ -22,6 +22,7 @@ import { presetsListCommand, presetsGetCommand, presetsExplainCommand, presetsRe
22
22
  import { taskCommand } from "./commands/task.command.js";
23
23
  import { preflightCommand } from "./commands/preflight.command.js";
24
24
  import { checkCommand } from "./commands/check.command.js";
25
+ import { finishCommand } from "./commands/finish.command.js";
25
26
  import { diffCheckCommand } from "./commands/diff-check.command.js";
26
27
  import { driftCommand } from "./commands/drift.command.js";
27
28
  import { graphCommand } from "./commands/graph.command.js";
@@ -33,6 +34,7 @@ import { frameworkCommand } from "./commands/framework.command.js";
33
34
  import { apiDiffCommand } from "./commands/api-diff.command.js";
34
35
  import { gateCommand } from "./commands/gate.command.js";
35
36
  import { policyLintCommand } from "./commands/policy-lint.command.js";
37
+ import { wiringCommand } from "./commands/wiring.command.js";
36
38
  import { reuseCommand } from "./commands/reuse.command.js";
37
39
  import { migrateCommand } from "./commands/migrate.command.js";
38
40
  import { coverageCommand } from "./commands/coverage.command.js";
@@ -189,6 +191,7 @@ export function buildRegistry() {
189
191
  registry.register(explainCommand);
190
192
  registry.register(checkCommand);
191
193
  registry.register(diffCheckCommand);
194
+ registry.register(finishCommand);
192
195
  // changed-only preflight orchestrator.
193
196
  registry.register(preflightCommand);
194
197
  registry.register(driftCommand);
@@ -201,6 +204,7 @@ export function buildRegistry() {
201
204
  registry.register(apiDiffCommand);
202
205
  registry.register(gateCommand);
203
206
  registry.register(policyLintCommand);
207
+ registry.register(wiringCommand);
204
208
  registry.register(reuseCommand);
205
209
  registry.register(migrateCommand);
206
210
  registry.register(coverageCommand);