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