@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.
Files changed (125) hide show
  1. package/.claude-plugin/marketplace.json +19 -0
  2. package/.claude-plugin/plugin.json +58 -0
  3. package/CHANGELOG.md +380 -0
  4. package/LICENSE +21 -0
  5. package/README.md +358 -0
  6. package/agents/ping.md +8 -0
  7. package/agents/worker.md +8 -0
  8. package/bin/interlock +2655 -0
  9. package/bin/interlock-graph +6 -0
  10. package/bin/interlock-run +508 -0
  11. package/bin/interlock-ship-acp +39 -0
  12. package/hooks/_shared.mjs +162 -0
  13. package/hooks/guard-commit.mjs +77 -0
  14. package/hooks/guard-tasks.mjs +107 -0
  15. package/hooks/guard-tests.mjs +63 -0
  16. package/hooks/preflight.mjs +107 -0
  17. package/lib/artifacts.mjs +316 -0
  18. package/lib/autonomy.mjs +149 -0
  19. package/lib/conformance.mjs +132 -0
  20. package/lib/doctor.mjs +1176 -0
  21. package/lib/drift.mjs +409 -0
  22. package/lib/eval-history.mjs +304 -0
  23. package/lib/evals-calibrate.mjs +317 -0
  24. package/lib/evals-capture.mjs +437 -0
  25. package/lib/evals-promote.mjs +257 -0
  26. package/lib/evals-triage.mjs +216 -0
  27. package/lib/findings.mjs +340 -0
  28. package/lib/graph/cli.mjs +266 -0
  29. package/lib/graph/extract/config-json.mjs +74 -0
  30. package/lib/graph/extract/create-extractor.mjs +35 -0
  31. package/lib/graph/extract/memory.mjs +99 -0
  32. package/lib/graph/extract/openspec.mjs +80 -0
  33. package/lib/graph/extract/python.mjs +137 -0
  34. package/lib/graph/extract/regex-fallback.mjs +187 -0
  35. package/lib/graph/extract/shell.mjs +71 -0
  36. package/lib/graph/extract/typescript.mjs +217 -0
  37. package/lib/graph/extract/walk.mjs +272 -0
  38. package/lib/graph/format.mjs +51 -0
  39. package/lib/graph/graph/build.mjs +374 -0
  40. package/lib/graph/graph/query.mjs +249 -0
  41. package/lib/graph/graph/report.mjs +122 -0
  42. package/lib/graph/graph/update.mjs +47 -0
  43. package/lib/graph/paths.mjs +66 -0
  44. package/lib/graph/retrieve/context.mjs +86 -0
  45. package/lib/graph/retrieve/docs-index.mjs +116 -0
  46. package/lib/graph/retrieve/markdown.mjs +246 -0
  47. package/lib/host/acp.mjs +551 -0
  48. package/lib/host/claude-cli.mjs +285 -0
  49. package/lib/host/codex.mjs +231 -0
  50. package/lib/host/model-map.mjs +129 -0
  51. package/lib/host/qwen.mjs +176 -0
  52. package/lib/host/registry.mjs +213 -0
  53. package/lib/host.mjs +393 -0
  54. package/lib/ledger.mjs +444 -0
  55. package/lib/limits.mjs +525 -0
  56. package/lib/locators.mjs +99 -0
  57. package/lib/merge-lanes.mjs +148 -0
  58. package/lib/metrics.mjs +99 -0
  59. package/lib/notify.mjs +229 -0
  60. package/lib/outcomes.mjs +560 -0
  61. package/lib/plan-fingerprint.mjs +570 -0
  62. package/lib/project-slug.mjs +18 -0
  63. package/lib/prompts/commit.mjs +26 -0
  64. package/lib/prompts/dimensions.mjs +70 -0
  65. package/lib/prompts/handoff.mjs +86 -0
  66. package/lib/prompts/implementer.mjs +150 -0
  67. package/lib/prompts/index.mjs +250 -0
  68. package/lib/prompts/planner.mjs +70 -0
  69. package/lib/prompts/remediate.mjs +137 -0
  70. package/lib/prompts/replan.mjs +23 -0
  71. package/lib/prompts/review.mjs +107 -0
  72. package/lib/prompts/schemas.mjs +206 -0
  73. package/lib/prompts/stage.mjs +48 -0
  74. package/lib/prompts/verify.mjs +88 -0
  75. package/lib/ready.mjs +812 -0
  76. package/lib/receipt.mjs +434 -0
  77. package/lib/remediate.mjs +275 -0
  78. package/lib/report-html.mjs +623 -0
  79. package/lib/report.mjs +1124 -0
  80. package/lib/review-core.mjs +713 -0
  81. package/lib/risk.mjs +370 -0
  82. package/lib/run-log.mjs +1004 -0
  83. package/lib/run-paths.mjs +239 -0
  84. package/lib/run.mjs +2921 -0
  85. package/lib/ship-stage.mjs +223 -0
  86. package/lib/spill.mjs +150 -0
  87. package/lib/surface.mjs +195 -0
  88. package/lib/verify.mjs +1074 -0
  89. package/lib/waves.mjs +2661 -0
  90. package/package.json +40 -0
  91. package/shared/CONTEXT-HYGIENE.md +84 -0
  92. package/shared/DECISION-LEDGER.md +141 -0
  93. package/shared/EXPLORE-BRIEF.md +96 -0
  94. package/shared/INVARIANT-SWEEP.md +57 -0
  95. package/shared/SESSION-SHAPES.md +261 -0
  96. package/shared/SKILL-CANDIDATE-BRIEF.md +106 -0
  97. package/shared/TEST-PROFILE.md +204 -0
  98. package/shared/TOOL-ECONOMY.md +155 -0
  99. package/skills/bootstrap/SKILL.md +226 -0
  100. package/skills/commit/SKILL.md +99 -0
  101. package/skills/dispatch/SKILL.md +78 -0
  102. package/skills/docs-digest/SKILL.md +109 -0
  103. package/skills/evals/SKILL.md +160 -0
  104. package/skills/explain-code/SKILL.md +42 -0
  105. package/skills/explain-code/commit-teachin.md +136 -0
  106. package/skills/explain-code/module-walkthrough.md +358 -0
  107. package/skills/explore/SKILL.md +159 -0
  108. package/skills/fix-tests/SKILL.md +120 -0
  109. package/skills/graph/SKILL.md +136 -0
  110. package/skills/manual-test-plan/SKILL.md +121 -0
  111. package/skills/mr/SKILL.md +170 -0
  112. package/skills/report/SKILL.md +121 -0
  113. package/skills/review-artifacts/SKILL.md +127 -0
  114. package/skills/review-code/REVIEW.template.md +49 -0
  115. package/skills/review-code/SKILL.md +156 -0
  116. package/skills/review-code/dimensions/architecture.md +10 -0
  117. package/skills/review-code/dimensions/devops.md +12 -0
  118. package/skills/review-code/dimensions/language.md +12 -0
  119. package/skills/review-code/dimensions/qa.md +12 -0
  120. package/skills/review-code/dimensions/security.md +12 -0
  121. package/skills/review-code/dimensions/technical-lead.md +19 -0
  122. package/skills/ship/SKILL.md +67 -0
  123. package/skills/spec/SKILL.md +264 -0
  124. package/skills/spec/continuity.md +66 -0
  125. package/workflows/ship.js +565 -0
@@ -0,0 +1,272 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { rel } from '../paths.mjs';
4
+
5
+ const DEFAULT_SKIP_DIRS = new Set([
6
+ 'node_modules',
7
+ '.git',
8
+ '.next',
9
+ 'dist',
10
+ 'build',
11
+ 'coverage',
12
+ '.turbo',
13
+ '.cache',
14
+ 'vendor',
15
+ 'interlock-graph-out',
16
+ '__pycache__',
17
+ '.venv',
18
+ 'venv',
19
+ '.tox',
20
+ '.mypy_cache',
21
+ '.pytest_cache',
22
+ '.ruff_cache',
23
+ 'site-packages',
24
+ '.eggs',
25
+ 'eggs',
26
+ '.idea',
27
+ '.vscode',
28
+ ]);
29
+
30
+ /** Code / scripts always indexed. */
31
+ const SOURCE_EXTS = new Set([
32
+ '.ts',
33
+ '.tsx',
34
+ '.js',
35
+ '.jsx',
36
+ '.mjs',
37
+ '.cjs',
38
+ '.py',
39
+ '.pyi',
40
+ '.sh',
41
+ '.bash',
42
+ ]);
43
+
44
+ /**
45
+ * Curated config JSON basenames (and simple globs via endsWith / includes).
46
+ * Lockfiles and large generated JSON are excluded separately.
47
+ */
48
+ const CONFIG_JSON_NAMES = new Set([
49
+ 'package.json',
50
+ 'tsconfig.json',
51
+ 'jsconfig.json',
52
+ 'pyrightconfig.json',
53
+ 'turbo.json',
54
+ 'nx.json',
55
+ 'components.json',
56
+ 'nest-cli.json',
57
+ 'openapi.json',
58
+ 'swagger.json',
59
+ 'settings.json',
60
+ 'launch.json',
61
+ 'extensions.json',
62
+ 'manifest.json',
63
+ 'appsscript.json',
64
+ ]);
65
+
66
+ const CONFIG_JSON_MAX_BYTES = 64 * 1024;
67
+
68
+ function loadIgnorePatterns(root) {
69
+ const patterns = [];
70
+ for (const name of ['.gitignore', '.interlockignore']) {
71
+ const p = path.join(root, name);
72
+ if (!fs.existsSync(p)) continue;
73
+ for (const line of fs.readFileSync(p, 'utf8').split('\n')) {
74
+ const t = line.trim();
75
+ if (!t || t.startsWith('#')) continue;
76
+ patterns.push(t.replace(/\/$/, ''));
77
+ }
78
+ }
79
+ return patterns;
80
+ }
81
+
82
+ function ignored(relPath, patterns) {
83
+ for (const pat of patterns) {
84
+ if (pat.startsWith('!')) continue;
85
+ if (pat.startsWith('*')) {
86
+ const suffix = pat.slice(1);
87
+ if (relPath.endsWith(suffix) || relPath.includes(`/${suffix}`)) return true;
88
+ continue;
89
+ }
90
+ if (relPath === pat || relPath.startsWith(`${pat}/`) || relPath.includes(`/${pat}/`)) {
91
+ return true;
92
+ }
93
+ }
94
+ return false;
95
+ }
96
+
97
+ function isConfigJson(name, relPath, size) {
98
+ if (!name.endsWith('.json')) return false;
99
+ if (size > CONFIG_JSON_MAX_BYTES) return false;
100
+ const lower = name.toLowerCase();
101
+ if (
102
+ lower.includes('lock') ||
103
+ lower.endsWith('.min.json') ||
104
+ lower === 'package-lock.json' ||
105
+ lower === 'npm-shrinkwrap.json'
106
+ ) {
107
+ return false;
108
+ }
109
+ if (CONFIG_JSON_NAMES.has(name)) return true;
110
+ if (/^tsconfig\..+\.json$/.test(name)) return true;
111
+ if (name.endsWith('.eslintrc.json') || name === '.eslintrc.json') return true;
112
+ if (name.endsWith('mcp.json') || name.endsWith('mcp_config.json')) return true;
113
+ // Root-level small *.json only (avoid sweeping fixture dumps)
114
+ if (!relPath.includes('/') && size <= CONFIG_JSON_MAX_BYTES) return true;
115
+ return false;
116
+ }
117
+
118
+ function shouldSkipDir(root, dir, entName, relPath) {
119
+ // Skip the project's own `.claude` home (Interlock graph artifacts, memory) at repo root only —
120
+ // nested trees like harness/.claude/hooks/*.sh are still indexed.
121
+ if (entName === '.claude' && relPath === '.claude') return true;
122
+ if (DEFAULT_SKIP_DIRS.has(entName)) return true;
123
+ if (entName.endsWith('.egg-info')) return true;
124
+ return false;
125
+ }
126
+
127
+ /**
128
+ * Walk root for source + curated config files. Returns absolute paths.
129
+ */
130
+ export function walkSourceFiles(root, { extraSkip = [] } = {}) {
131
+ const skipExtra = new Set(extraSkip);
132
+ const patterns = loadIgnorePatterns(root);
133
+ const out = [];
134
+
135
+ function visit(dir) {
136
+ let entries;
137
+ try {
138
+ entries = fs.readdirSync(dir, { withFileTypes: true });
139
+ } catch {
140
+ return;
141
+ }
142
+ for (const ent of entries) {
143
+ const full = path.join(dir, ent.name);
144
+ const r = rel(root, full);
145
+ if (ent.isDirectory()) {
146
+ if (skipExtra.has(ent.name) || shouldSkipDir(root, dir, ent.name, r) || ignored(r, patterns)) {
147
+ continue;
148
+ }
149
+ visit(full);
150
+ continue;
151
+ }
152
+ if (!ent.isFile()) continue;
153
+ if (ignored(r, patterns)) continue;
154
+ const ext = path.extname(ent.name);
155
+ if (SOURCE_EXTS.has(ext)) {
156
+ if (ent.name.endsWith('.d.ts')) continue;
157
+ out.push(full);
158
+ continue;
159
+ }
160
+ if (ext === '.json') {
161
+ let size = 0;
162
+ try {
163
+ size = fs.statSync(full).size;
164
+ } catch {
165
+ continue;
166
+ }
167
+ if (isConfigJson(ent.name, r, size)) out.push(full);
168
+ }
169
+ }
170
+ }
171
+
172
+ visit(root);
173
+ return out.sort();
174
+ }
175
+
176
+ // Accepts directories or individual markdown files, so callers can mix a docs
177
+ // directory with root-level files such as README.md — on many repos those root
178
+ // files are the only real documentation.
179
+ export function walkMarkdown(root, subdirs) {
180
+ const out = [];
181
+ for (const sub of subdirs) {
182
+ const base = path.join(root, sub);
183
+ let stat;
184
+ try {
185
+ stat = fs.statSync(base);
186
+ } catch {
187
+ continue;
188
+ }
189
+ if (stat.isDirectory()) walkMd(base, out);
190
+ else if (/\.mdx?$/i.test(base)) out.push(base);
191
+ }
192
+ return [...new Set(out)].sort();
193
+ }
194
+
195
+ function walkMd(dir, out) {
196
+ let entries;
197
+ try {
198
+ entries = fs.readdirSync(dir, { withFileTypes: true });
199
+ } catch {
200
+ return;
201
+ }
202
+ for (const ent of entries) {
203
+ const full = path.join(dir, ent.name);
204
+ if (ent.isDirectory()) {
205
+ if (ent.name === 'node_modules' || ent.name === '.git' || ent.name === '__pycache__') continue;
206
+ walkMd(full, out);
207
+ continue;
208
+ }
209
+ if (ent.isFile() && ent.name.endsWith('.md')) out.push(full);
210
+ }
211
+ }
212
+
213
+ export { SOURCE_EXTS, CONFIG_JSON_NAMES, CONFIG_JSON_MAX_BYTES };
214
+
215
+ /**
216
+ * Directory names projects actually use for prose, beyond `docs/`. Probed in
217
+ * order when the preferred roots turn up nothing — a repo that keeps its
218
+ * documentation in `doc/` or `site/` should not silently index zero files.
219
+ */
220
+ const DOCS_DIR_CANDIDATES = [
221
+ 'docs',
222
+ 'doc',
223
+ 'documentation',
224
+ 'site/content',
225
+ 'site',
226
+ 'website/docs',
227
+ 'guides',
228
+ 'wiki',
229
+ ];
230
+
231
+ /**
232
+ * Root-level markdown worth indexing on any repo. These are frequently the only
233
+ * real documentation a project has, and they live outside every docs directory.
234
+ */
235
+ const ROOT_DOC_FILES = [
236
+ 'README.md',
237
+ 'ARCHITECTURE.md',
238
+ 'CONTRIBUTING.md',
239
+ 'DESIGN.md',
240
+ 'ROADMAP.md',
241
+ 'AGENTS.md',
242
+ 'CLAUDE.md',
243
+ ];
244
+
245
+ /**
246
+ * Resolve which markdown roots to index for a given repo.
247
+ *
248
+ * Returns the preferred roots when they contain anything, otherwise the first
249
+ * candidate directory that exists. `openspec/specs` is always included when
250
+ * present, and root-level docs are always appended, because on many repos
251
+ * (Go projects especially) `README.md` is the documentation.
252
+ */
253
+ export function resolveDocsDirs(root, requested) {
254
+ if (requested && requested.length) return requested;
255
+
256
+ const has = (p) => {
257
+ try {
258
+ return fs.existsSync(`${root}/${p}`);
259
+ } catch {
260
+ return false;
261
+ }
262
+ };
263
+
264
+ const dirs = [];
265
+ const primary = DOCS_DIR_CANDIDATES.find(has);
266
+ if (primary) dirs.push(primary);
267
+ if (has('openspec/specs')) dirs.push('openspec/specs');
268
+
269
+ for (const f of ROOT_DOC_FILES) if (has(f)) dirs.push(f);
270
+
271
+ return dirs.length ? dirs : ['docs', 'openspec/specs'];
272
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Format a subgraph as agent-facing plain text within a token budget.
3
+ * Rough token estimate: chars / 4.
4
+ */
5
+ export function formatSubgraph({ mode, seeds, nodes, edges, budget = 1500 }) {
6
+ const lines = [];
7
+ lines.push(`Traversal: ${String(mode).toUpperCase()} | Start: [${seeds.join(', ')}] | ${nodes.length} nodes, ${edges.length} edges`);
8
+ lines.push('');
9
+
10
+ const nodeLines = [];
11
+ for (const n of nodes) {
12
+ nodeLines.push(
13
+ ` NODE ${n.id} [type=${n.type} src=${n.source_file || '?'} loc=${n.source_location || '?'}]`,
14
+ );
15
+ }
16
+ const edgeLines = [];
17
+ for (const e of edges) {
18
+ edgeLines.push(
19
+ ` EDGE ${short(e.source)} --${e.relation} [${e.confidence}]--> ${short(e.target)} (${e.source_file}:${e.source_location})`,
20
+ );
21
+ }
22
+
23
+ let body = [...nodeLines, '', ...edgeLines];
24
+ let text = `${lines.join('\n')}${body.join('\n')}\n`;
25
+ let tokens = estimateTokens(text);
26
+
27
+ if (tokens <= budget) return text;
28
+
29
+ // Trim edges first, then nodes
30
+ while (tokens > budget && edgeLines.length > 3) {
31
+ edgeLines.pop();
32
+ body = [...nodeLines, '', ...edgeLines, ` … truncated to fit budget ${budget}`];
33
+ text = `${lines.join('\n')}${body.join('\n')}\n`;
34
+ tokens = estimateTokens(text);
35
+ }
36
+ while (tokens > budget && nodeLines.length > 3) {
37
+ nodeLines.pop();
38
+ body = [...nodeLines, '', ...edgeLines, ` … truncated to fit budget ${budget}`];
39
+ text = `${lines.join('\n')}${body.join('\n')}\n`;
40
+ tokens = estimateTokens(text);
41
+ }
42
+ return text;
43
+ }
44
+
45
+ export function estimateTokens(text) {
46
+ return Math.ceil(text.length / 4);
47
+ }
48
+
49
+ function short(id) {
50
+ return id.replace(/^(file|symbol|module|openspec|memory|external|unresolved):/, '');
51
+ }
@@ -0,0 +1,374 @@
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { createExtractor } from '../extract/create-extractor.mjs';
4
+ import { extractOpenspec } from '../extract/openspec.mjs';
5
+ import { extractMemory } from '../extract/memory.mjs';
6
+ import { walkSourceFiles } from '../extract/walk.mjs';
7
+ import {
8
+ ensureGraphDir,
9
+ graphPath,
10
+ loadJson,
11
+ manifestPath,
12
+ rel,
13
+ reportPath,
14
+ sha256File,
15
+ writeJson,
16
+ } from '../paths.mjs';
17
+ import { writeReport } from './report.mjs';
18
+
19
+ function langOf(filePath) {
20
+ const ext = path.posix.extname(filePath).toLowerCase();
21
+ if (ext === '.py' || ext === '.pyi') return 'python';
22
+ if (ext === '.sh' || ext === '.bash') return 'shell';
23
+ if (ext === '.json') return 'json';
24
+ return 'js';
25
+ }
26
+
27
+ function resolvePythonImport(fromFile, specifier, fileIndex) {
28
+ let modPath;
29
+ if (specifier.startsWith('.')) {
30
+ let dots = 0;
31
+ while (specifier[dots] === '.') dots += 1;
32
+ const rest = specifier.slice(dots);
33
+ let base = path.posix.dirname(fromFile);
34
+ for (let i = 1; i < dots; i++) {
35
+ const parent = path.posix.dirname(base);
36
+ if (parent === base) break;
37
+ base = parent;
38
+ }
39
+ modPath = rest ? path.posix.normalize(path.posix.join(base, rest.replace(/\./g, '/'))) : base;
40
+ } else {
41
+ modPath = specifier.replace(/\./g, '/');
42
+ }
43
+
44
+ const tryPaths = [`${modPath}.py`, `${modPath}.pyi`, `${modPath}/__init__.py`];
45
+ for (const p of tryPaths) {
46
+ if (fileIndex.has(p)) return { kind: 'file', id: `file:${p}`, path: p };
47
+ }
48
+
49
+ // Bare `import layer` → package __init__
50
+ if (!specifier.startsWith('.') && fileIndex.has(`${modPath}/__init__.py`)) {
51
+ return { kind: 'file', id: `file:${modPath}/__init__.py`, path: `${modPath}/__init__.py` };
52
+ }
53
+
54
+ if (specifier.startsWith('.')) {
55
+ return { kind: 'unresolved', id: `unresolved:${modPath}`, path: modPath };
56
+ }
57
+ return { kind: 'external', id: `external:${specifier}` };
58
+ }
59
+
60
+ function resolveShellImport(fromFile, specifier, fileIndex) {
61
+ const dir = path.posix.dirname(fromFile);
62
+ const cleaned = String(specifier).replace(/^\.\//, '');
63
+ const candidates = [
64
+ cleaned,
65
+ path.posix.normalize(path.posix.join(dir, cleaned)),
66
+ path.posix.basename(cleaned),
67
+ ];
68
+ // Also try basename match when path was variable-stripped (unique hit)
69
+ const basenames = [...fileIndex].filter((f) => f.endsWith(`/${path.posix.basename(cleaned)}`) || f === path.posix.basename(cleaned));
70
+
71
+ for (const p of candidates) {
72
+ if (fileIndex.has(p)) return { kind: 'file', id: `file:${p}`, path: p };
73
+ }
74
+ if (basenames.length === 1) {
75
+ const p = basenames[0];
76
+ return { kind: 'file', id: `file:${p}`, path: p };
77
+ }
78
+ return { kind: 'unresolved', id: `unresolved:${cleaned}`, path: cleaned };
79
+ }
80
+
81
+ function resolveJsImport(fromFile, specifier, fileIndex) {
82
+ if (!specifier.startsWith('.') && !specifier.startsWith('/')) {
83
+ return { kind: 'external', id: `external:${specifier}` };
84
+ }
85
+ const dir = path.posix.dirname(fromFile);
86
+ let candidate = path.posix.normalize(path.posix.join(dir, specifier));
87
+ if (candidate.startsWith('./')) candidate = candidate.slice(2);
88
+
89
+ const tryPaths = [
90
+ candidate,
91
+ `${candidate}.ts`,
92
+ `${candidate}.tsx`,
93
+ `${candidate}.js`,
94
+ `${candidate}.jsx`,
95
+ `${candidate}.mjs`,
96
+ `${candidate}.cjs`,
97
+ `${candidate}/index.ts`,
98
+ `${candidate}/index.tsx`,
99
+ `${candidate}/index.js`,
100
+ `${candidate}/index.mjs`,
101
+ ];
102
+
103
+ for (const p of tryPaths) {
104
+ if (fileIndex.has(p)) return { kind: 'file', id: `file:${p}`, path: p };
105
+ }
106
+ return { kind: 'unresolved', id: `unresolved:${candidate}`, path: candidate };
107
+ }
108
+
109
+ /**
110
+ * Resolve an import/require/source specifier against an importing file.
111
+ */
112
+ export function resolveImport(fromFile, specifier, fileIndex) {
113
+ const lang = langOf(fromFile);
114
+ if (lang === 'python') return resolvePythonImport(fromFile, specifier, fileIndex);
115
+ if (lang === 'shell') return resolveShellImport(fromFile, specifier, fileIndex);
116
+ if (lang === 'json') {
117
+ // package.json main/bin paths are usually relative to package root
118
+ if (fileIndex.has(specifier)) return { kind: 'file', id: `file:${specifier}`, path: specifier };
119
+ const tryPaths = [
120
+ specifier,
121
+ specifier.replace(/^\.\//, ''),
122
+ `${specifier}.js`,
123
+ `${specifier}.mjs`,
124
+ `${specifier}.ts`,
125
+ ];
126
+ for (const p of tryPaths) {
127
+ if (fileIndex.has(p)) return { kind: 'file', id: `file:${p}`, path: p };
128
+ }
129
+ return { kind: 'unresolved', id: `unresolved:${specifier}`, path: specifier };
130
+ }
131
+ return resolveJsImport(fromFile, specifier, fileIndex);
132
+ }
133
+
134
+ function moduleIdFor(filePath) {
135
+ const parts = filePath.split('/');
136
+ if (parts.length <= 1) return 'module:.';
137
+ // Drop filename; use first dir, or first two dirs when nested (e.g. app/api)
138
+ const dirs = parts.slice(0, -1);
139
+ if (dirs.length === 1) return `module:${dirs[0]}`;
140
+ return `module:${dirs.slice(0, 2).join('/')}`;
141
+ }
142
+
143
+ /**
144
+ * Build a full graph for root. Returns { graph, stats, extractorMode }.
145
+ */
146
+ export function buildGraph(root, { fileFilter = null } = {}) {
147
+ const extractor = createExtractor(root);
148
+ let files = walkSourceFiles(root);
149
+ if (fileFilter) {
150
+ const allow = new Set(fileFilter);
151
+ files = files.filter((f) => allow.has(rel(root, f)));
152
+ }
153
+
154
+ const fileIndex = new Set(files.map((f) => rel(root, f)));
155
+ const nodes = new Map();
156
+ const edges = [];
157
+ const symbolByName = new Map(); // name -> [symbol ids]
158
+ const hashes = {};
159
+
160
+ function addNode(node) {
161
+ if (!nodes.has(node.id)) nodes.set(node.id, node);
162
+ }
163
+
164
+ for (const absPath of files) {
165
+ const filePath = rel(root, absPath);
166
+ hashes[filePath] = sha256File(absPath);
167
+ const source = fs.readFileSync(absPath, 'utf8');
168
+ const extracted = extractor.extract(filePath, source);
169
+
170
+ addNode({
171
+ id: `file:${filePath}`,
172
+ type: 'file',
173
+ label: path.posix.basename(filePath),
174
+ source_file: filePath,
175
+ source_location: 'L1',
176
+ });
177
+
178
+ const mod = moduleIdFor(filePath);
179
+ addNode({
180
+ id: mod,
181
+ type: 'module',
182
+ label: mod.replace(/^module:/, ''),
183
+ source_file: filePath,
184
+ source_location: 'L1',
185
+ });
186
+ edges.push({
187
+ source: `file:${filePath}`,
188
+ target: mod,
189
+ relation: 'belongs_to',
190
+ confidence: 'EXTRACTED',
191
+ source_file: filePath,
192
+ source_location: 'L1',
193
+ weight: 1,
194
+ });
195
+
196
+ for (const exp of extracted.exports) {
197
+ const sid = `symbol:${filePath}#${exp.name}`;
198
+ addNode({
199
+ id: sid,
200
+ type: 'symbol',
201
+ label: exp.name,
202
+ source_file: filePath,
203
+ source_location: `L${exp.line}`,
204
+ });
205
+ edges.push({
206
+ source: `file:${filePath}`,
207
+ target: sid,
208
+ relation: 'exports',
209
+ confidence: exp.confidence,
210
+ source_file: filePath,
211
+ source_location: `L${exp.line}`,
212
+ weight: 1,
213
+ });
214
+ if (!symbolByName.has(exp.name)) symbolByName.set(exp.name, []);
215
+ symbolByName.get(exp.name).push(sid);
216
+ }
217
+
218
+ for (const imp of extracted.imports) {
219
+ const resolved = resolveImport(filePath, imp.from, fileIndex);
220
+ if (resolved.kind === 'external') {
221
+ addNode({
222
+ id: resolved.id,
223
+ type: 'external',
224
+ label: imp.from,
225
+ source_file: filePath,
226
+ source_location: `L${imp.line}`,
227
+ });
228
+ } else if (resolved.kind === 'unresolved') {
229
+ addNode({
230
+ id: resolved.id,
231
+ type: 'unresolved',
232
+ label: resolved.path,
233
+ source_file: filePath,
234
+ source_location: `L${imp.line}`,
235
+ });
236
+ }
237
+ edges.push({
238
+ source: `file:${filePath}`,
239
+ target: resolved.id,
240
+ relation: 'imports',
241
+ confidence: imp.confidence,
242
+ source_file: filePath,
243
+ source_location: `L${imp.line}`,
244
+ weight: 1,
245
+ imported: imp.imported,
246
+ local: imp.local,
247
+ });
248
+
249
+ if (resolved.kind === 'file' && imp.imported && imp.imported !== '*' && imp.imported !== 'default') {
250
+ const targetSym = `symbol:${resolved.path}#${imp.imported}`;
251
+ edges.push({
252
+ source: `file:${filePath}`,
253
+ target: targetSym,
254
+ relation: 'references',
255
+ confidence: 'INFERRED',
256
+ source_file: filePath,
257
+ source_location: `L${imp.line}`,
258
+ weight: 1,
259
+ });
260
+ }
261
+ }
262
+
263
+ for (const ref of extracted.references) {
264
+ const resolved = resolveImport(filePath, ref.from, fileIndex);
265
+ if (resolved.kind !== 'file') continue;
266
+ edges.push({
267
+ source: `file:${filePath}`,
268
+ target: resolved.id,
269
+ relation: 'references',
270
+ confidence: ref.confidence,
271
+ source_file: filePath,
272
+ source_location: `L${ref.line}`,
273
+ weight: 0.5,
274
+ name: ref.name,
275
+ });
276
+ }
277
+ }
278
+
279
+ const knownSymbols = new Set(symbolByName.keys());
280
+ const openspec = extractOpenspec(root, knownSymbols, fileIndex);
281
+ for (const n of openspec.nodes) addNode(n);
282
+ for (const e of openspec.edges) {
283
+ if (e.target.startsWith('symbol-name:')) {
284
+ const name = e.target.slice('symbol-name:'.length);
285
+ const ids = symbolByName.get(name) || [];
286
+ for (const sid of ids) {
287
+ edges.push({ ...e, target: sid });
288
+ // ensure symbol node exists (it should)
289
+ }
290
+ } else {
291
+ edges.push(e);
292
+ }
293
+ }
294
+
295
+ const memory = extractMemory(root, fileIndex);
296
+ for (const n of memory.nodes) addNode(n);
297
+ for (const e of memory.edges) {
298
+ if (nodes.has(e.source) && nodes.has(e.target)) edges.push(e);
299
+ }
300
+
301
+ // Ensure external/unresolved targets exist as nodes; drop dangling symbol refs
302
+ for (const e of edges) {
303
+ if ((e.target.startsWith('external:') || e.target.startsWith('unresolved:')) && !nodes.has(e.target)) {
304
+ addNode({
305
+ id: e.target,
306
+ type: e.target.startsWith('external:') ? 'external' : 'unresolved',
307
+ label: e.target.replace(/^(external|unresolved):/, ''),
308
+ source_file: e.source_file,
309
+ source_location: e.source_location,
310
+ });
311
+ }
312
+ }
313
+ const cleanEdges = edges.filter((e) => {
314
+ if (!nodes.has(e.source)) return false;
315
+ if (!nodes.has(e.target)) return false;
316
+ return true;
317
+ });
318
+
319
+ const graph = {
320
+ directed: true,
321
+ multigraph: true,
322
+ graph: {
323
+ built_at: new Date().toISOString(),
324
+ root: root,
325
+ extractor: extractor.mode,
326
+ schema_version: 1,
327
+ },
328
+ nodes: [...nodes.values()],
329
+ links: cleanEdges.map((e) => ({
330
+ source: e.source,
331
+ target: e.target,
332
+ relation: e.relation,
333
+ confidence: e.confidence,
334
+ source_file: e.source_file,
335
+ source_location: e.source_location,
336
+ weight: e.weight ?? 1,
337
+ ...(e.imported != null ? { imported: e.imported } : {}),
338
+ ...(e.local != null ? { local: e.local } : {}),
339
+ ...(e.name != null ? { name: e.name } : {}),
340
+ })),
341
+ };
342
+
343
+ const stats = {
344
+ files: fileIndex.size,
345
+ nodes: graph.nodes.length,
346
+ edges: graph.links.length,
347
+ symbols: graph.nodes.filter((n) => n.type === 'symbol').length,
348
+ modules: graph.nodes.filter((n) => n.type === 'module').length,
349
+ extractor: extractor.mode,
350
+ };
351
+
352
+ return { graph, stats, hashes, extractorMode: extractor.mode };
353
+ }
354
+
355
+ export function writeGraphArtifacts(root, { graph, stats, hashes }) {
356
+ ensureGraphDir(root);
357
+ writeJson(graphPath(root), graph);
358
+ writeJson(manifestPath(root), {
359
+ built_at: graph.graph.built_at,
360
+ files: hashes,
361
+ stats,
362
+ });
363
+ const report = writeReport(root, graph, stats);
364
+ fs.writeFileSync(reportPath(root), report);
365
+ return { graphPath: graphPath(root), reportPath: reportPath(root), stats };
366
+ }
367
+
368
+ export function loadGraph(root) {
369
+ const g = loadJson(graphPath(root), null);
370
+ if (!g) {
371
+ throw new Error(`No graph found at ${graphPath(root)}. Run: interlock-graph build`);
372
+ }
373
+ return g;
374
+ }