@renzrollon/interlock 1.0.0
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/.claude-plugin/marketplace.json +19 -0
- package/.claude-plugin/plugin.json +58 -0
- package/CHANGELOG.md +380 -0
- package/LICENSE +21 -0
- package/README.md +358 -0
- package/agents/ping.md +8 -0
- package/agents/worker.md +8 -0
- package/bin/interlock +2655 -0
- package/bin/interlock-graph +6 -0
- package/bin/interlock-run +508 -0
- package/bin/interlock-ship-acp +39 -0
- package/hooks/_shared.mjs +162 -0
- package/hooks/guard-commit.mjs +77 -0
- package/hooks/guard-tasks.mjs +107 -0
- package/hooks/guard-tests.mjs +63 -0
- package/hooks/preflight.mjs +107 -0
- package/lib/artifacts.mjs +316 -0
- package/lib/autonomy.mjs +149 -0
- package/lib/conformance.mjs +132 -0
- package/lib/doctor.mjs +1176 -0
- package/lib/drift.mjs +409 -0
- package/lib/eval-history.mjs +304 -0
- package/lib/evals-calibrate.mjs +317 -0
- package/lib/evals-capture.mjs +437 -0
- package/lib/evals-promote.mjs +257 -0
- package/lib/evals-triage.mjs +216 -0
- package/lib/findings.mjs +340 -0
- package/lib/graph/cli.mjs +266 -0
- package/lib/graph/extract/config-json.mjs +74 -0
- package/lib/graph/extract/create-extractor.mjs +35 -0
- package/lib/graph/extract/memory.mjs +99 -0
- package/lib/graph/extract/openspec.mjs +80 -0
- package/lib/graph/extract/python.mjs +137 -0
- package/lib/graph/extract/regex-fallback.mjs +187 -0
- package/lib/graph/extract/shell.mjs +71 -0
- package/lib/graph/extract/typescript.mjs +217 -0
- package/lib/graph/extract/walk.mjs +272 -0
- package/lib/graph/format.mjs +51 -0
- package/lib/graph/graph/build.mjs +374 -0
- package/lib/graph/graph/query.mjs +249 -0
- package/lib/graph/graph/report.mjs +122 -0
- package/lib/graph/graph/update.mjs +47 -0
- package/lib/graph/paths.mjs +66 -0
- package/lib/graph/retrieve/context.mjs +86 -0
- package/lib/graph/retrieve/docs-index.mjs +116 -0
- package/lib/graph/retrieve/markdown.mjs +246 -0
- package/lib/host/acp.mjs +551 -0
- package/lib/host/claude-cli.mjs +285 -0
- package/lib/host/codex.mjs +231 -0
- package/lib/host/model-map.mjs +129 -0
- package/lib/host/qwen.mjs +176 -0
- package/lib/host/registry.mjs +213 -0
- package/lib/host.mjs +393 -0
- package/lib/ledger.mjs +444 -0
- package/lib/limits.mjs +525 -0
- package/lib/locators.mjs +99 -0
- package/lib/merge-lanes.mjs +148 -0
- package/lib/metrics.mjs +99 -0
- package/lib/notify.mjs +229 -0
- package/lib/outcomes.mjs +560 -0
- package/lib/plan-fingerprint.mjs +570 -0
- package/lib/project-slug.mjs +18 -0
- package/lib/prompts/commit.mjs +26 -0
- package/lib/prompts/dimensions.mjs +70 -0
- package/lib/prompts/handoff.mjs +86 -0
- package/lib/prompts/implementer.mjs +150 -0
- package/lib/prompts/index.mjs +250 -0
- package/lib/prompts/planner.mjs +70 -0
- package/lib/prompts/remediate.mjs +137 -0
- package/lib/prompts/replan.mjs +23 -0
- package/lib/prompts/review.mjs +107 -0
- package/lib/prompts/schemas.mjs +206 -0
- package/lib/prompts/stage.mjs +48 -0
- package/lib/prompts/verify.mjs +88 -0
- package/lib/ready.mjs +812 -0
- package/lib/receipt.mjs +434 -0
- package/lib/remediate.mjs +275 -0
- package/lib/report-html.mjs +623 -0
- package/lib/report.mjs +1124 -0
- package/lib/review-core.mjs +713 -0
- package/lib/risk.mjs +370 -0
- package/lib/run-log.mjs +1004 -0
- package/lib/run-paths.mjs +239 -0
- package/lib/run.mjs +2921 -0
- package/lib/ship-stage.mjs +223 -0
- package/lib/spill.mjs +150 -0
- package/lib/surface.mjs +195 -0
- package/lib/verify.mjs +1074 -0
- package/lib/waves.mjs +2661 -0
- package/package.json +40 -0
- package/shared/CONTEXT-HYGIENE.md +84 -0
- package/shared/DECISION-LEDGER.md +141 -0
- package/shared/EXPLORE-BRIEF.md +96 -0
- package/shared/INVARIANT-SWEEP.md +57 -0
- package/shared/SESSION-SHAPES.md +261 -0
- package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
- package/shared/TEST-PROFILE.md +204 -0
- package/shared/TOOL-ECONOMY.md +155 -0
- package/skills/bootstrap/SKILL.md +226 -0
- package/skills/commit/SKILL.md +99 -0
- package/skills/dispatch/SKILL.md +78 -0
- package/skills/docs-digest/SKILL.md +109 -0
- package/skills/evals/SKILL.md +160 -0
- package/skills/explain-code/SKILL.md +42 -0
- package/skills/explain-code/commit-teachin.md +136 -0
- package/skills/explain-code/module-walkthrough.md +358 -0
- package/skills/explore/SKILL.md +159 -0
- package/skills/fix-tests/SKILL.md +120 -0
- package/skills/graph/SKILL.md +136 -0
- package/skills/manual-test-plan/SKILL.md +121 -0
- package/skills/mr/SKILL.md +170 -0
- package/skills/report/SKILL.md +121 -0
- package/skills/review-artifacts/SKILL.md +127 -0
- package/skills/review-code/REVIEW.template.md +49 -0
- package/skills/review-code/SKILL.md +156 -0
- package/skills/review-code/dimensions/architecture.md +10 -0
- package/skills/review-code/dimensions/devops.md +12 -0
- package/skills/review-code/dimensions/language.md +12 -0
- package/skills/review-code/dimensions/qa.md +12 -0
- package/skills/review-code/dimensions/security.md +12 -0
- package/skills/review-code/dimensions/technical-lead.md +19 -0
- package/skills/ship/SKILL.md +67 -0
- package/skills/spec/SKILL.md +264 -0
- package/skills/spec/continuity.md +66 -0
- package/workflows/ship.js +565 -0
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { fileURLToPath } from 'node:url';
|
|
5
|
+
import { buildGraph, loadGraph, writeGraphArtifacts } from './graph/build.mjs';
|
|
6
|
+
import { consumers, explain, findSeedNodes, shortestPath, traverse } from './graph/query.mjs';
|
|
7
|
+
import { updateGraph } from './graph/update.mjs';
|
|
8
|
+
import { writeReport } from './graph/report.mjs';
|
|
9
|
+
import { formatContextResult, retrieveContext } from './retrieve/context.mjs';
|
|
10
|
+
import {
|
|
11
|
+
DOCS_INDEX_DEFAULT_DIRS,
|
|
12
|
+
formatDocsIndexSummary,
|
|
13
|
+
writeDocsIndex,
|
|
14
|
+
} from './retrieve/docs-index.mjs';
|
|
15
|
+
import { formatDocsResult, retrieveMarkdownSections } from './retrieve/markdown.mjs';
|
|
16
|
+
import { graphPath, reportPath, resolveRoot } from './paths.mjs';
|
|
17
|
+
|
|
18
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
19
|
+
|
|
20
|
+
function usage() {
|
|
21
|
+
return `interlock-graph — lightweight codebase knowledge graph for agents
|
|
22
|
+
|
|
23
|
+
Usage:
|
|
24
|
+
interlock-graph build [root] Build graph into <root>/.claude/graph/
|
|
25
|
+
interlock-graph update [root] Incremental rebuild when files change
|
|
26
|
+
interlock-graph report [root] Print / regenerate GRAPH_REPORT.md
|
|
27
|
+
interlock-graph query <q> [options] Neighborhood query (BFS default)
|
|
28
|
+
interlock-graph consumers <symbol|file> Reverse imports / references
|
|
29
|
+
interlock-graph path <A> <B> Shortest path
|
|
30
|
+
interlock-graph explain <node> Node + neighbors
|
|
31
|
+
interlock-graph docs <q> [options] Ranked markdown section excerpts
|
|
32
|
+
interlock-graph context <q> [options] Graph subgraph + doc excerpts (default retrieval)
|
|
33
|
+
interlock-graph docs-index [root] Deterministic docs/ + openspec/specs TOC → docs-index.json
|
|
34
|
+
interlock-graph which Print CLI path (for skills)
|
|
35
|
+
|
|
36
|
+
Query options:
|
|
37
|
+
--budget <n> Token budget for output (default 1500; docs=800, context=2000)
|
|
38
|
+
--depth <n> Traversal depth (default 2)
|
|
39
|
+
--dfs Use DFS instead of BFS
|
|
40
|
+
--root <path> Project root (default: cwd)
|
|
41
|
+
--changed <paths> Comma-separated changed file paths (context/docs seeding)
|
|
42
|
+
--commit-subject <text> Commit subject for term derivation
|
|
43
|
+
--dirs <list> Comma-separated markdown roots (docs/context default: docs,openspec/…; docs-index default: docs,openspec/specs)
|
|
44
|
+
--graph-budget <n> Structural slice of context budget
|
|
45
|
+
--docs-budget <n> Documentation slice of context budget
|
|
46
|
+
--write Write docs-index.json (default for docs-index)
|
|
47
|
+
--no-write Print docs-index summary without writing
|
|
48
|
+
|
|
49
|
+
Examples:
|
|
50
|
+
interlock-graph build .
|
|
51
|
+
interlock-graph query AuthService --budget 1500
|
|
52
|
+
interlock-graph consumers normalizeEmail
|
|
53
|
+
interlock-graph path lib/auth app/api
|
|
54
|
+
interlock-graph docs "authentication session" --budget 800
|
|
55
|
+
interlock-graph context "auth login" --changed lib/auth.ts,app/api/login.ts --budget 2000
|
|
56
|
+
interlock-graph docs-index . --write
|
|
57
|
+
`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function parseArgs(argv) {
|
|
61
|
+
const args = {
|
|
62
|
+
_: [],
|
|
63
|
+
budget: null,
|
|
64
|
+
depth: 2,
|
|
65
|
+
dfs: false,
|
|
66
|
+
root: null,
|
|
67
|
+
changed: [],
|
|
68
|
+
commitSubject: '',
|
|
69
|
+
dirs: null,
|
|
70
|
+
graphBudget: null,
|
|
71
|
+
docsBudget: null,
|
|
72
|
+
write: null,
|
|
73
|
+
};
|
|
74
|
+
for (let i = 0; i < argv.length; i++) {
|
|
75
|
+
const a = argv[i];
|
|
76
|
+
if (a === '--budget') args.budget = Number(argv[++i]);
|
|
77
|
+
else if (a === '--depth') args.depth = Number(argv[++i]);
|
|
78
|
+
else if (a === '--dfs') args.dfs = true;
|
|
79
|
+
else if (a === '--root') args.root = argv[++i];
|
|
80
|
+
else if (a === '--changed') {
|
|
81
|
+
args.changed.push(...String(argv[++i]).split(',').map((s) => s.trim()).filter(Boolean));
|
|
82
|
+
} else if (a === '--commit-subject') args.commitSubject = argv[++i];
|
|
83
|
+
else if (a === '--dirs') args.dirs = String(argv[++i]).split(',').map((s) => s.trim()).filter(Boolean);
|
|
84
|
+
else if (a === '--graph-budget') args.graphBudget = Number(argv[++i]);
|
|
85
|
+
else if (a === '--docs-budget') args.docsBudget = Number(argv[++i]);
|
|
86
|
+
else if (a === '--write') args.write = true;
|
|
87
|
+
else if (a === '--no-write') args.write = false;
|
|
88
|
+
else if (a === '-h' || a === '--help') args.help = true;
|
|
89
|
+
else args._.push(a);
|
|
90
|
+
}
|
|
91
|
+
return args;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function defaultBudget(cmd, args) {
|
|
95
|
+
if (args.budget != null) return args.budget;
|
|
96
|
+
if (cmd === 'docs') return 800;
|
|
97
|
+
if (cmd === 'context') return 2000;
|
|
98
|
+
return 1500;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function main() {
|
|
102
|
+
const args = parseArgs(process.argv.slice(2));
|
|
103
|
+
if (args.help || args._.length === 0) {
|
|
104
|
+
process.stdout.write(usage());
|
|
105
|
+
process.exit(args.help || args._.length === 0 ? 0 : 1);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
const cmd = args._[0];
|
|
109
|
+
const root = resolveRoot(
|
|
110
|
+
args.root ||
|
|
111
|
+
(cmd === 'build' || cmd === 'update' || cmd === 'report' || cmd === 'docs-index'
|
|
112
|
+
? args._[1]
|
|
113
|
+
: null) ||
|
|
114
|
+
process.cwd(),
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
try {
|
|
118
|
+
switch (cmd) {
|
|
119
|
+
case 'which': {
|
|
120
|
+
process.stdout.write(`${path.join(__dirname, 'cli.mjs')}\n`);
|
|
121
|
+
break;
|
|
122
|
+
}
|
|
123
|
+
case 'build': {
|
|
124
|
+
const result = buildGraph(root);
|
|
125
|
+
const written = writeGraphArtifacts(root, result);
|
|
126
|
+
process.stdout.write(
|
|
127
|
+
`Built graph → ${written.graphPath}\nReport → ${written.reportPath}\n` +
|
|
128
|
+
`files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges} extractor=${result.stats.extractor}\n`,
|
|
129
|
+
);
|
|
130
|
+
// An empty graph is a legitimate outcome — this repo is written in a
|
|
131
|
+
// language the walker does not structurally index. Saying so is the
|
|
132
|
+
// difference between "unsupported" and "broken".
|
|
133
|
+
if (result.stats.files === 0) {
|
|
134
|
+
process.stdout.write(
|
|
135
|
+
'\nNo source files were indexed. Structural indexing covers ' +
|
|
136
|
+
'.ts .tsx .js .jsx .mjs .cjs .py .pyi .sh .bash\n' +
|
|
137
|
+
'plus curated config JSON. Other languages (Go, Rust, Java, Ruby) ' +
|
|
138
|
+
'are not structurally indexed yet:\n' +
|
|
139
|
+
'docs, OpenSpec specs and spec→file links still work, but there ' +
|
|
140
|
+
'are no import or symbol edges.\n' +
|
|
141
|
+
'Use `interlock-graph docs` and `interlock-graph docs-index` for ' +
|
|
142
|
+
'prose retrieval on this repo.\n',
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
case 'update': {
|
|
148
|
+
const result = updateGraph(root);
|
|
149
|
+
if (!result.updated) {
|
|
150
|
+
process.stdout.write(`Graph up to date (${result.reason})\n`);
|
|
151
|
+
} else {
|
|
152
|
+
process.stdout.write(
|
|
153
|
+
`Updated graph (${result.reason})\n` +
|
|
154
|
+
`files=${result.stats.files} nodes=${result.stats.nodes} edges=${result.stats.edges}\n`,
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
break;
|
|
158
|
+
}
|
|
159
|
+
case 'report': {
|
|
160
|
+
const graph = loadGraph(root);
|
|
161
|
+
const stats = {
|
|
162
|
+
files: graph.nodes.filter((n) => n.type === 'file').length,
|
|
163
|
+
nodes: graph.nodes.length,
|
|
164
|
+
edges: graph.links.length,
|
|
165
|
+
symbols: graph.nodes.filter((n) => n.type === 'symbol').length,
|
|
166
|
+
modules: graph.nodes.filter((n) => n.type === 'module').length,
|
|
167
|
+
extractor: graph.graph?.extractor,
|
|
168
|
+
};
|
|
169
|
+
const report = writeReport(root, graph, stats);
|
|
170
|
+
fs.writeFileSync(reportPath(root), report);
|
|
171
|
+
process.stdout.write(report);
|
|
172
|
+
break;
|
|
173
|
+
}
|
|
174
|
+
case 'query': {
|
|
175
|
+
const q = args._.slice(1).join(' ');
|
|
176
|
+
if (!q) throw new Error('query requires a search string');
|
|
177
|
+
const graph = loadGraph(root);
|
|
178
|
+
const seeds = findSeedNodes(graph, q);
|
|
179
|
+
if (!seeds.length) {
|
|
180
|
+
process.stdout.write(`No nodes matched: ${q}\n(graph: ${graphPath(root)})\n`);
|
|
181
|
+
process.exit(1);
|
|
182
|
+
}
|
|
183
|
+
process.stdout.write(
|
|
184
|
+
traverse(graph, seeds, {
|
|
185
|
+
mode: args.dfs ? 'dfs' : 'bfs',
|
|
186
|
+
depth: args.depth,
|
|
187
|
+
budget: defaultBudget('query', args),
|
|
188
|
+
}),
|
|
189
|
+
);
|
|
190
|
+
break;
|
|
191
|
+
}
|
|
192
|
+
case 'consumers': {
|
|
193
|
+
const target = args._.slice(1).join(' ');
|
|
194
|
+
if (!target) throw new Error('consumers requires a symbol or file');
|
|
195
|
+
process.stdout.write(consumers(loadGraph(root), target));
|
|
196
|
+
break;
|
|
197
|
+
}
|
|
198
|
+
case 'path': {
|
|
199
|
+
const a = args._[1];
|
|
200
|
+
const b = args._[2];
|
|
201
|
+
if (!a || !b) throw new Error('path requires two endpoints');
|
|
202
|
+
process.stdout.write(shortestPath(loadGraph(root), a, b));
|
|
203
|
+
break;
|
|
204
|
+
}
|
|
205
|
+
case 'explain': {
|
|
206
|
+
const q = args._.slice(1).join(' ');
|
|
207
|
+
if (!q) throw new Error('explain requires a node query');
|
|
208
|
+
process.stdout.write(explain(loadGraph(root), q));
|
|
209
|
+
break;
|
|
210
|
+
}
|
|
211
|
+
case 'docs': {
|
|
212
|
+
const q = args._.slice(1).join(' ');
|
|
213
|
+
if (!q && args.changed.length === 0) throw new Error('docs requires a query or --changed paths');
|
|
214
|
+
const result = retrieveMarkdownSections(root, {
|
|
215
|
+
query: q,
|
|
216
|
+
changedFiles: args.changed,
|
|
217
|
+
dirs: args.dirs ?? undefined,
|
|
218
|
+
budget: defaultBudget('docs', args),
|
|
219
|
+
});
|
|
220
|
+
process.stdout.write(formatDocsResult(result));
|
|
221
|
+
if (result.empty) process.exit(1);
|
|
222
|
+
break;
|
|
223
|
+
}
|
|
224
|
+
case 'context': {
|
|
225
|
+
const q = args._.slice(1).join(' ');
|
|
226
|
+
if (!q && args.changed.length === 0 && !args.commitSubject) {
|
|
227
|
+
throw new Error('context requires a query, --changed paths, or --commit-subject');
|
|
228
|
+
}
|
|
229
|
+
const result = retrieveContext(root, {
|
|
230
|
+
query: q,
|
|
231
|
+
changedFiles: args.changed,
|
|
232
|
+
commitSubject: args.commitSubject,
|
|
233
|
+
budget: defaultBudget('context', args),
|
|
234
|
+
graphBudget: args.graphBudget ?? undefined,
|
|
235
|
+
docsBudget: args.docsBudget ?? undefined,
|
|
236
|
+
docsDirs: args.dirs ?? undefined,
|
|
237
|
+
});
|
|
238
|
+
process.stdout.write(formatContextResult(result));
|
|
239
|
+
if (result.empty) process.exit(1);
|
|
240
|
+
break;
|
|
241
|
+
}
|
|
242
|
+
case 'docs-index': {
|
|
243
|
+
const write = args.write !== false;
|
|
244
|
+
const { index, path: outPath, written } = writeDocsIndex(root, {
|
|
245
|
+
write,
|
|
246
|
+
// Omit rather than defaulting, so buildDocsIndex can probe for the
|
|
247
|
+
// docs root this repo actually uses (doc/, site/, root README, …).
|
|
248
|
+
dirs: args.dirs ?? undefined,
|
|
249
|
+
});
|
|
250
|
+
process.stdout.write(formatDocsIndexSummary(index, outPath, written));
|
|
251
|
+
if (!written) {
|
|
252
|
+
process.stdout.write(`${JSON.stringify(index, null, 2)}\n`);
|
|
253
|
+
}
|
|
254
|
+
break;
|
|
255
|
+
}
|
|
256
|
+
default:
|
|
257
|
+
process.stderr.write(`Unknown command: ${cmd}\n\n${usage()}`);
|
|
258
|
+
process.exit(1);
|
|
259
|
+
}
|
|
260
|
+
} catch (err) {
|
|
261
|
+
process.stderr.write(`interlock-graph: ${err.message || err}\n`);
|
|
262
|
+
process.exit(1);
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
main();
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lightweight extractor for curated config JSON files.
|
|
3
|
+
* Avoids lockfiles / large generated JSON — walk filters before we get here.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* @param {string} filePath relative posix path
|
|
8
|
+
* @param {string} source
|
|
9
|
+
*/
|
|
10
|
+
export function extractConfigJson(filePath, source) {
|
|
11
|
+
const imports = [];
|
|
12
|
+
const exports = [];
|
|
13
|
+
const references = [];
|
|
14
|
+
|
|
15
|
+
let data;
|
|
16
|
+
try {
|
|
17
|
+
data = JSON.parse(source);
|
|
18
|
+
} catch {
|
|
19
|
+
return { filePath, imports, exports, references };
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const base = filePath.split('/').pop() || filePath;
|
|
23
|
+
|
|
24
|
+
if (base === 'package.json' && data && typeof data === 'object') {
|
|
25
|
+
if (data.scripts && typeof data.scripts === 'object') {
|
|
26
|
+
for (const name of Object.keys(data.scripts)) {
|
|
27
|
+
exports.push({
|
|
28
|
+
name: `script:${name}`,
|
|
29
|
+
kind: 'script',
|
|
30
|
+
line: 1,
|
|
31
|
+
confidence: 'EXTRACTED',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
for (const key of ['main', 'module', 'types', 'typings']) {
|
|
36
|
+
const v = data[key];
|
|
37
|
+
if (typeof v === 'string' && v.length > 0 && !v.includes('*')) {
|
|
38
|
+
imports.push({
|
|
39
|
+
from: v.replace(/^\.\//, ''),
|
|
40
|
+
imported: null,
|
|
41
|
+
local: null,
|
|
42
|
+
line: 1,
|
|
43
|
+
confidence: 'INFERRED',
|
|
44
|
+
});
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
if (data.bin && typeof data.bin === 'object') {
|
|
48
|
+
for (const v of Object.values(data.bin)) {
|
|
49
|
+
if (typeof v === 'string') {
|
|
50
|
+
imports.push({
|
|
51
|
+
from: v.replace(/^\.\//, ''),
|
|
52
|
+
imported: null,
|
|
53
|
+
local: null,
|
|
54
|
+
line: 1,
|
|
55
|
+
confidence: 'INFERRED',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
} else if (data && typeof data === 'object' && !Array.isArray(data)) {
|
|
61
|
+
// Surface a few top-level keys so query can find config knobs
|
|
62
|
+
for (const key of Object.keys(data).slice(0, 24)) {
|
|
63
|
+
if (!/^[A-Za-z_][\w.-]*$/.test(key)) continue;
|
|
64
|
+
exports.push({
|
|
65
|
+
name: key,
|
|
66
|
+
kind: 'key',
|
|
67
|
+
line: 1,
|
|
68
|
+
confidence: 'EXTRACTED',
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return { filePath, imports, exports, references };
|
|
74
|
+
}
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { createJsExtractor } from './typescript.mjs';
|
|
3
|
+
import { extractPython } from './python.mjs';
|
|
4
|
+
import { extractShell } from './shell.mjs';
|
|
5
|
+
import { extractConfigJson } from './config-json.mjs';
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Language-dispatching extractor. JS/TS uses typescript AST when available.
|
|
9
|
+
*/
|
|
10
|
+
export function createExtractor(root) {
|
|
11
|
+
const js = createJsExtractor(root);
|
|
12
|
+
const modes = new Set([js.mode]);
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
get mode() {
|
|
16
|
+
return [...modes].sort().join('+');
|
|
17
|
+
},
|
|
18
|
+
extract(filePath, source) {
|
|
19
|
+
const ext = path.extname(filePath).toLowerCase();
|
|
20
|
+
if (ext === '.py' || ext === '.pyi') {
|
|
21
|
+
modes.add('python');
|
|
22
|
+
return extractPython(filePath, source);
|
|
23
|
+
}
|
|
24
|
+
if (ext === '.sh' || ext === '.bash') {
|
|
25
|
+
modes.add('shell');
|
|
26
|
+
return extractShell(filePath, source);
|
|
27
|
+
}
|
|
28
|
+
if (ext === '.json') {
|
|
29
|
+
modes.add('json');
|
|
30
|
+
return extractConfigJson(filePath, source);
|
|
31
|
+
}
|
|
32
|
+
return js.extract(filePath, source);
|
|
33
|
+
},
|
|
34
|
+
};
|
|
35
|
+
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { rel } from '../paths.mjs';
|
|
4
|
+
import { walkMarkdown } from './walk.mjs';
|
|
5
|
+
|
|
6
|
+
const COUPLE_RE =
|
|
7
|
+
/([`'"]?)([\w./-]+\.(?:ts|tsx|js|jsx|mjs))\1[^.\n]{0,80}(?:always changes with|couples with|paired with|together with)[^.\n]{0,80}([`'"]?)([\w./-]+\.(?:ts|tsx|js|jsx|mjs))\3/gi;
|
|
8
|
+
|
|
9
|
+
const FILE_PAIR_RE =
|
|
10
|
+
/`([\w./-]+\.(?:ts|tsx|js|jsx|mjs))`[^`\n]{0,60}`([\w./-]+\.(?:ts|tsx|js|jsx|mjs))`/g;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Extract memory nodes and coupling edges.
|
|
14
|
+
*/
|
|
15
|
+
export function extractMemory(root, knownFiles = new Set()) {
|
|
16
|
+
const candidates = [];
|
|
17
|
+
const memRoot = path.join(root, '.claude', 'memory');
|
|
18
|
+
if (fs.existsSync(memRoot)) {
|
|
19
|
+
candidates.push(...walkMarkdown(root, ['.claude/memory']));
|
|
20
|
+
}
|
|
21
|
+
const legacy = path.join(root, '.claude', 'memory.md');
|
|
22
|
+
if (fs.existsSync(legacy)) candidates.push(legacy);
|
|
23
|
+
|
|
24
|
+
const nodes = [];
|
|
25
|
+
const edges = [];
|
|
26
|
+
const seenNodes = new Set();
|
|
27
|
+
|
|
28
|
+
for (const absPath of candidates) {
|
|
29
|
+
const filePath = rel(root, absPath);
|
|
30
|
+
const text = fs.readFileSync(absPath, 'utf8');
|
|
31
|
+
const id = `memory:${filePath}`;
|
|
32
|
+
if (!seenNodes.has(id)) {
|
|
33
|
+
seenNodes.add(id);
|
|
34
|
+
nodes.push({
|
|
35
|
+
id,
|
|
36
|
+
type: 'memory',
|
|
37
|
+
label: path.basename(filePath),
|
|
38
|
+
source_file: filePath,
|
|
39
|
+
source_location: 'L1',
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let m;
|
|
44
|
+
COUPLE_RE.lastIndex = 0;
|
|
45
|
+
while ((m = COUPLE_RE.exec(text))) {
|
|
46
|
+
const a = normalizeMention(m[2], knownFiles);
|
|
47
|
+
const b = normalizeMention(m[4], knownFiles);
|
|
48
|
+
if (!a || !b) continue;
|
|
49
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
50
|
+
edges.push({
|
|
51
|
+
source: `file:${a}`,
|
|
52
|
+
target: `file:${b}`,
|
|
53
|
+
relation: 'couples_with',
|
|
54
|
+
confidence: 'INFERRED',
|
|
55
|
+
source_file: filePath,
|
|
56
|
+
source_location: `L${line}`,
|
|
57
|
+
weight: 1,
|
|
58
|
+
});
|
|
59
|
+
edges.push({
|
|
60
|
+
source: id,
|
|
61
|
+
target: `file:${a}`,
|
|
62
|
+
relation: 'couples_with',
|
|
63
|
+
confidence: 'INFERRED',
|
|
64
|
+
source_file: filePath,
|
|
65
|
+
source_location: `L${line}`,
|
|
66
|
+
weight: 0.5,
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
FILE_PAIR_RE.lastIndex = 0;
|
|
71
|
+
while ((m = FILE_PAIR_RE.exec(text))) {
|
|
72
|
+
const a = normalizeMention(m[1], knownFiles);
|
|
73
|
+
const b = normalizeMention(m[2], knownFiles);
|
|
74
|
+
if (!a || !b || a === b) continue;
|
|
75
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
76
|
+
edges.push({
|
|
77
|
+
source: `file:${a}`,
|
|
78
|
+
target: `file:${b}`,
|
|
79
|
+
relation: 'couples_with',
|
|
80
|
+
confidence: 'INFERRED',
|
|
81
|
+
source_file: filePath,
|
|
82
|
+
source_location: `L${line}`,
|
|
83
|
+
weight: 0.5,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { nodes, edges };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
function normalizeMention(p, knownFiles) {
|
|
92
|
+
const clean = p.replace(/^\.\//, '');
|
|
93
|
+
if (knownFiles.has(clean)) return clean;
|
|
94
|
+
// Allow basename match
|
|
95
|
+
for (const f of knownFiles) {
|
|
96
|
+
if (f.endsWith(`/${clean}`) || f === clean) return f;
|
|
97
|
+
}
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { rel } from '../paths.mjs';
|
|
4
|
+
import { walkMarkdown } from './walk.mjs';
|
|
5
|
+
|
|
6
|
+
// Any repo-relative path with an extension. Deliberately NOT restricted to a
|
|
7
|
+
// directory allowlist or an extension allowlist: every match is gated below by
|
|
8
|
+
// `knownFiles.has(mentioned)`, so the graph itself decides what is real. An
|
|
9
|
+
// allowlist here can only produce false negatives — it used to drop Go/Rust/Java
|
|
10
|
+
// layouts (cmd/, internal/, pkg/, apps/, services/) and indexed config files
|
|
11
|
+
// entirely. The leading boundary class excludes URL path segments, which are
|
|
12
|
+
// always preceded by `/`.
|
|
13
|
+
const PATH_RE = /(?:^|[\s`"'(\[])((?:[\w.@-]+\/)+[\w.@-]+\.[A-Za-z0-9]{1,6})\b/gm;
|
|
14
|
+
const SYMBOL_RE = /`([A-Z][A-Za-z0-9_]{2,})`|\b([A-Z][A-Za-z0-9_]{2,}(?:Service|Client|Router|Handler|Store|Provider|Context|Schema|Util|Utils)?)\b/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Extract OpenSpec artifact nodes and inferred links to code paths/symbols.
|
|
18
|
+
*/
|
|
19
|
+
export function extractOpenspec(root, knownSymbols = new Set(), knownFiles = new Set()) {
|
|
20
|
+
const files = walkMarkdown(root, ['openspec']);
|
|
21
|
+
const nodes = [];
|
|
22
|
+
const edges = [];
|
|
23
|
+
|
|
24
|
+
for (const absPath of files) {
|
|
25
|
+
const filePath = rel(root, absPath);
|
|
26
|
+
const text = fs.readFileSync(absPath, 'utf8');
|
|
27
|
+
const id = `openspec:${filePath}`;
|
|
28
|
+
nodes.push({
|
|
29
|
+
id,
|
|
30
|
+
type: 'openspec',
|
|
31
|
+
label: path.basename(filePath),
|
|
32
|
+
source_file: filePath,
|
|
33
|
+
source_location: 'L1',
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
const seen = new Set();
|
|
37
|
+
let m;
|
|
38
|
+
PATH_RE.lastIndex = 0;
|
|
39
|
+
while ((m = PATH_RE.exec(text))) {
|
|
40
|
+
const mentioned = m[1];
|
|
41
|
+
if (seen.has(mentioned)) continue;
|
|
42
|
+
seen.add(mentioned);
|
|
43
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
44
|
+
if (knownFiles.has(mentioned)) {
|
|
45
|
+
edges.push({
|
|
46
|
+
source: id,
|
|
47
|
+
target: `file:${mentioned}`,
|
|
48
|
+
relation: 'implements_spec',
|
|
49
|
+
confidence: 'INFERRED',
|
|
50
|
+
source_file: filePath,
|
|
51
|
+
source_location: `L${line}`,
|
|
52
|
+
weight: 1,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
SYMBOL_RE.lastIndex = 0;
|
|
58
|
+
while ((m = SYMBOL_RE.exec(text))) {
|
|
59
|
+
const name = m[1] || m[2];
|
|
60
|
+
if (!name || name.length < 3) continue;
|
|
61
|
+
if (!knownSymbols.has(name)) continue;
|
|
62
|
+
const key = `sym:${name}`;
|
|
63
|
+
if (seen.has(key)) continue;
|
|
64
|
+
seen.add(key);
|
|
65
|
+
const line = text.slice(0, m.index).split('\n').length;
|
|
66
|
+
// Link to all symbol nodes with that label — resolved later by build if needed.
|
|
67
|
+
edges.push({
|
|
68
|
+
source: id,
|
|
69
|
+
target: `symbol-name:${name}`,
|
|
70
|
+
relation: 'implements_spec',
|
|
71
|
+
confidence: 'INFERRED',
|
|
72
|
+
source_file: filePath,
|
|
73
|
+
source_location: `L${line}`,
|
|
74
|
+
weight: 0.5,
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
return { nodes, edges };
|
|
80
|
+
}
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Regex-based Python extractor (imports + top-level defs/classes).
|
|
3
|
+
* No AST dependency — good enough for package/module navigation.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
const FROM_IMPORT_RE =
|
|
7
|
+
/^([ \t]*)from[ \t]+(\.*[\w.]*)[ \t]+import[ \t]+(\([^)]*\)|.+)$/gm;
|
|
8
|
+
const IMPORT_RE =
|
|
9
|
+
/^([ \t]*)import[ \t]+([\w.]+)(?:[ \t]+as[ \t]+(\w+))?(?:[ \t]*#.*)?$/gm;
|
|
10
|
+
const CLASS_RE = /^class[ \t]+([A-Za-z_]\w*)\s*[:(]/gm;
|
|
11
|
+
const DEF_RE = /^(?:async[ \t]+)?def[ \t]+([A-Za-z_]\w*)\s*\(/gm;
|
|
12
|
+
|
|
13
|
+
function lineOf(text, index) {
|
|
14
|
+
return text.slice(0, index).split('\n').length;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function parseImportedNames(raw) {
|
|
18
|
+
const flat = raw.replace(/[()]/g, ' ').replace(/\\\s*\n/g, ' ').replace(/#.*/g, '');
|
|
19
|
+
const names = [];
|
|
20
|
+
for (const part of flat.split(',')) {
|
|
21
|
+
const t = part.trim();
|
|
22
|
+
if (!t) continue;
|
|
23
|
+
if (t === '*') {
|
|
24
|
+
names.push({ imported: '*', local: null });
|
|
25
|
+
continue;
|
|
26
|
+
}
|
|
27
|
+
const m = t.match(/^(\w+)(?:\s+as\s+(\w+))?$/);
|
|
28
|
+
if (!m) continue;
|
|
29
|
+
names.push({ imported: m[1], local: m[2] || m[1] });
|
|
30
|
+
}
|
|
31
|
+
return names;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Flatten parenthesized `from x import (` blocks so line-based regex can see them.
|
|
36
|
+
*/
|
|
37
|
+
function preprocessSource(source) {
|
|
38
|
+
return source.replace(
|
|
39
|
+
/^([ \t]*from[ \t]+\.*[\w.]*[ \t]+import[ \t]+)\(([\s\S]*?)\)/gm,
|
|
40
|
+
(full, head, body) => `${head}${body.replace(/\n/g, ' ')}`,
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* @param {string} filePath relative posix path
|
|
46
|
+
* @param {string} source
|
|
47
|
+
*/
|
|
48
|
+
export function extractPython(filePath, source) {
|
|
49
|
+
const imports = [];
|
|
50
|
+
const exports = [];
|
|
51
|
+
const references = [];
|
|
52
|
+
const importedLocals = new Map();
|
|
53
|
+
const text = preprocessSource(source);
|
|
54
|
+
|
|
55
|
+
let m;
|
|
56
|
+
FROM_IMPORT_RE.lastIndex = 0;
|
|
57
|
+
while ((m = FROM_IMPORT_RE.exec(text))) {
|
|
58
|
+
// Only module-level imports (no indent)
|
|
59
|
+
if (m[1] && m[1].length > 0) continue;
|
|
60
|
+
const from = m[2];
|
|
61
|
+
const line = lineOf(text, m.index);
|
|
62
|
+
const names = parseImportedNames(m[3]);
|
|
63
|
+
if (names.length === 0) {
|
|
64
|
+
imports.push({ from, imported: null, local: null, line, confidence: 'EXTRACTED' });
|
|
65
|
+
continue;
|
|
66
|
+
}
|
|
67
|
+
for (const spec of names) {
|
|
68
|
+
imports.push({
|
|
69
|
+
from,
|
|
70
|
+
imported: spec.imported,
|
|
71
|
+
local: spec.local,
|
|
72
|
+
line,
|
|
73
|
+
confidence: 'EXTRACTED',
|
|
74
|
+
});
|
|
75
|
+
if (spec.local) importedLocals.set(spec.local, { from, line });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
IMPORT_RE.lastIndex = 0;
|
|
80
|
+
while ((m = IMPORT_RE.exec(text))) {
|
|
81
|
+
if (m[1] && m[1].length > 0) continue;
|
|
82
|
+
const from = m[2];
|
|
83
|
+
const local = m[3] || from.split('.')[0];
|
|
84
|
+
const line = lineOf(text, m.index);
|
|
85
|
+
imports.push({
|
|
86
|
+
from,
|
|
87
|
+
imported: m[3] ? from : '*',
|
|
88
|
+
local,
|
|
89
|
+
line,
|
|
90
|
+
confidence: 'EXTRACTED',
|
|
91
|
+
});
|
|
92
|
+
importedLocals.set(local, { from, line });
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
CLASS_RE.lastIndex = 0;
|
|
96
|
+
while ((m = CLASS_RE.exec(source))) {
|
|
97
|
+
exports.push({
|
|
98
|
+
name: m[1],
|
|
99
|
+
kind: 'class',
|
|
100
|
+
line: lineOf(source, m.index),
|
|
101
|
+
confidence: 'EXTRACTED',
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
DEF_RE.lastIndex = 0;
|
|
106
|
+
while ((m = DEF_RE.exec(source))) {
|
|
107
|
+
const name = m[1];
|
|
108
|
+
if (name.startsWith('_') && !name.startsWith('__')) continue; // skip private helpers
|
|
109
|
+
exports.push({
|
|
110
|
+
name,
|
|
111
|
+
kind: 'function',
|
|
112
|
+
line: lineOf(source, m.index),
|
|
113
|
+
confidence: 'EXTRACTED',
|
|
114
|
+
});
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (const [local, meta] of importedLocals) {
|
|
118
|
+
if (!local || local.length < 2) continue;
|
|
119
|
+
const useRe = new RegExp(`\\b${local}\\b`, 'g');
|
|
120
|
+
let um;
|
|
121
|
+
let count = 0;
|
|
122
|
+
while ((um = useRe.exec(source))) {
|
|
123
|
+
const line = lineOf(source, um.index);
|
|
124
|
+
if (line === meta.line) continue;
|
|
125
|
+
references.push({
|
|
126
|
+
name: local,
|
|
127
|
+
from: meta.from,
|
|
128
|
+
line,
|
|
129
|
+
confidence: 'EXTRACTED',
|
|
130
|
+
});
|
|
131
|
+
count += 1;
|
|
132
|
+
if (count >= 8) break;
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
return { filePath, imports, exports, references };
|
|
137
|
+
}
|