@ngockhoale/ukit 2.6.7 → 2.6.9

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 (55) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/manifests/documentation.yaml +77 -6
  3. package/manifests/hostCapabilities.yaml +49 -0
  4. package/manifests/instructionRules.yaml +7 -0
  5. package/package.json +1 -1
  6. package/scripts/bench/goldTasks.json +38 -0
  7. package/scripts/bench/runGold.mjs +220 -0
  8. package/scripts/index/build-index.mjs +2 -1
  9. package/scripts/index/query-index.mjs +2 -0
  10. package/scripts/release/verify-release.mjs +6 -0
  11. package/src/cli/commands/code.js +182 -0
  12. package/src/cli/commands/doctor.js +35 -3
  13. package/src/cli/commands/indexTools.js +107 -1
  14. package/src/cli/commands/install.js +2 -1
  15. package/src/cli/commands/memory.js +137 -0
  16. package/src/cli/index.js +7 -0
  17. package/src/core/codeintel/analogy.js +197 -0
  18. package/src/core/codeintel/cochange.js +205 -0
  19. package/src/core/codeintel/compiler.js +389 -0
  20. package/src/core/codeintel/diagnostics.js +114 -0
  21. package/src/core/codeintel/freshness.js +295 -0
  22. package/src/core/codeintel/graph.js +291 -0
  23. package/src/core/codeintel/impact.js +274 -0
  24. package/src/core/codeintel/invalidation.js +150 -0
  25. package/src/core/codeintel/manifest.js +176 -0
  26. package/src/core/codeintel/packet.js +147 -0
  27. package/src/core/codeintel/providers.js +201 -0
  28. package/src/core/codeintel/retriever.js +418 -0
  29. package/src/core/codeintel/router.js +149 -0
  30. package/src/core/codeintel/semanticProvider.js +235 -0
  31. package/src/core/codeintel/summaries.js +194 -0
  32. package/src/core/codeintel/vectorProvider.js +213 -0
  33. package/src/core/docContracts.js +723 -0
  34. package/src/core/memory/migrate.js +324 -0
  35. package/src/core/memory/records.js +172 -0
  36. package/src/core/memory/retrieval.js +161 -11
  37. package/src/core/memory/store.js +398 -0
  38. package/src/core/memory/storeV2.js +171 -0
  39. package/src/core/memory/storeV2Loader.js +22 -0
  40. package/src/core/runtimeConfig.js +173 -0
  41. package/src/core/runtimePaths.js +3 -0
  42. package/src/index/buildIndex.js +29 -0
  43. package/src/index/paths.js +2 -0
  44. package/src/index/taskRouting.js +39 -0
  45. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  46. package/templates/AGENTS.md +46 -99
  47. package/templates/CLAUDE.md +46 -99
  48. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  49. package/templates/docs/BUGFIX.md +2 -19
  50. package/templates/docs/BUG_INDEX.md +43 -0
  51. package/templates/docs/BUG_METRICS.md +1 -5
  52. package/templates/docs/BUG_TEMPLATE.md +1 -11
  53. package/templates/docs/UKIT_INTERNALS.md +4 -0
  54. package/templates/instructions/core.md +46 -99
  55. package/templates/ukit/storage/config.json +35 -0
@@ -0,0 +1,295 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { spawnSync } from 'node:child_process';
5
+ import { getIndexDir, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/paths.js';
6
+ import { loadRuntimeConfig } from '../runtimeConfig.js';
7
+
8
+ const GIT_TIMEOUT_MS = 3000;
9
+ const NON_GIT_MANIFEST_CAP = 2000;
10
+ const LEVELS = ['L0', 'L1', 'L2', 'L3', 'L4'];
11
+ const SNAPSHOT_FILE = 'freshness.json';
12
+ const DIRTY_FILE = 'dirty.json';
13
+
14
+ function sha1(input) {
15
+ return crypto.createHash('sha1').update(input).digest('hex');
16
+ }
17
+
18
+ // Deterministic JSON: object keys sorted recursively so equal values hash equal.
19
+ function canonicalJson(value) {
20
+ if (value === null || typeof value !== 'object') return JSON.stringify(value);
21
+ if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
22
+ const keys = Object.keys(value).sort();
23
+ return `{${keys.map((k) => `${JSON.stringify(k)}:${canonicalJson(value[k])}`).join(',')}}`;
24
+ }
25
+
26
+ // Git spawns must never throw (SPEC §7): any failure/timeout yields null.
27
+ function runGit(rootDir, args) {
28
+ try {
29
+ const result = spawnSync('git', args, {
30
+ cwd: rootDir,
31
+ encoding: 'utf8',
32
+ timeout: GIT_TIMEOUT_MS,
33
+ maxBuffer: 16 * 1024 * 1024,
34
+ });
35
+ if (result.error || result.status !== 0) return null;
36
+ return result.stdout ?? '';
37
+ } catch {
38
+ return null;
39
+ }
40
+ }
41
+
42
+ export function getHeadSha(rootDir) {
43
+ const out = runGit(rootDir, ['rev-parse', 'HEAD']);
44
+ const sha = (out || '').trim();
45
+ return /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : 'nogit';
46
+ }
47
+
48
+ // Branch key for per-branch snapshot isolation (SPEC §5):
49
+ // sanitized branch name | 'detached-<head8>' | 'nogit'. Never throws.
50
+ export function getBranchKey(rootDir) {
51
+ const out = runGit(rootDir, ['branch', '--show-current']);
52
+ const branch = (out || '').trim();
53
+ if (branch) return branch.replace(/[^A-Za-z0-9._-]/g, '-');
54
+ const head = (runGit(rootDir, ['rev-parse', 'HEAD']) || '').trim();
55
+ if (/^[0-9a-f]{40}$/i.test(head)) return `detached-${head.slice(0, 8).toLowerCase()}`;
56
+ return 'nogit';
57
+ }
58
+
59
+ function parsePorcelainPaths(output) {
60
+ const paths = [];
61
+ for (const line of output.split('\n')) {
62
+ if (!line.trim()) continue;
63
+ let p = line.slice(3);
64
+ // rename entries: "old -> new" — track the new path
65
+ const arrow = p.indexOf(' -> ');
66
+ if (arrow !== -1) p = p.slice(arrow + 4);
67
+ p = p.trim();
68
+ if (p.startsWith('"') && p.endsWith('"')) p = p.slice(1, -1);
69
+ if (p) paths.push(p);
70
+ }
71
+ return paths;
72
+ }
73
+
74
+ async function statEntry(rootDir, rel) {
75
+ try {
76
+ const stat = await fs.stat(path.join(rootDir, rel));
77
+ return `${stat.mtimeMs}:${stat.size}`;
78
+ } catch {
79
+ return 'deleted:0';
80
+ }
81
+ }
82
+
83
+ // Read dirty.json on-disk contract {paths[], saturated} (SPEC §6).
84
+ // Returns null when the file is missing/corrupt — dirty tracking inactive.
85
+ async function readDirtySet(rootDir) {
86
+ try {
87
+ const raw = await fs.readFile(path.join(getIndexDir(rootDir), DIRTY_FILE), 'utf8');
88
+ const dirty = JSON.parse(raw);
89
+ if (!dirty || typeof dirty !== 'object') return null;
90
+ const paths = Array.isArray(dirty.paths)
91
+ ? dirty.paths.filter((p) => typeof p === 'string' && p.length > 0)
92
+ : [];
93
+ return { paths, saturated: dirty.saturated === true };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ async function overlayEntriesGit(rootDir, statusOutput, extraPaths = []) {
100
+ const entries = {};
101
+ const seen = new Set();
102
+ for (const rel of [...parsePorcelainPaths(statusOutput), ...extraPaths]) {
103
+ // Skip index-internal artifacts (.cache/) — snapshot/dirty writes must not
104
+ // self-invalidate the overlay.
105
+ if (!rel || rel.startsWith('.cache/') || seen.has(rel)) continue;
106
+ seen.add(rel);
107
+ entries[rel] = await statEntry(rootDir, rel);
108
+ }
109
+ return entries;
110
+ }
111
+
112
+ // Non-git fallback: hash LIVE mtimes for index-covered files (files.json),
113
+ // capped at NON_GIT_MANIFEST_CAP entries. Stored mtimeMs from files.json is
114
+ // stale by definition — only live fs.stat detects post-index edits.
115
+ // Incremental mode: only when dirty-set tracking is active (dirty.json exists,
116
+ // not saturated) AND a prior snapshot carries overlayEntries — reuse stored
117
+ // entries for paths proven unchanged by the dirty set; live-stat dirty,
118
+ // added, and unknown paths. Stored files.json mtimes are never trusted.
119
+ async function overlayEntriesManifest(rootDir, { incremental, snapshot, dirty } = {}) {
120
+ let items = [];
121
+ try {
122
+ const raw = await fs.readFile(path.join(getIndexDir(rootDir), INDEX_ARTIFACTS.files), 'utf8');
123
+ const manifest = JSON.parse(raw);
124
+ items = Array.isArray(manifest?.items) ? manifest.items : [];
125
+ } catch {
126
+ return {};
127
+ }
128
+ const manifestPaths = [];
129
+ const seen = new Set();
130
+ for (const item of items.slice(0, NON_GIT_MANIFEST_CAP)) {
131
+ const rel = typeof item?.filePath === 'string' ? item.filePath : null;
132
+ if (!rel || seen.has(rel)) continue;
133
+ seen.add(rel);
134
+ manifestPaths.push(rel);
135
+ }
136
+
137
+ const stored = snapshot && typeof snapshot.overlayEntries === 'object' && snapshot.overlayEntries !== null
138
+ ? snapshot.overlayEntries
139
+ : null;
140
+ const dirtyPaths = dirty && !dirty.saturated ? new Set(dirty.paths) : null;
141
+ const canReuse = incremental === true && stored !== null && dirtyPaths !== null;
142
+
143
+ const entries = {};
144
+ for (const rel of manifestPaths) {
145
+ // Reuse only when dirty tracking proves the path unchanged and a stored
146
+ // entry exists; otherwise stat live (never trust files.json mtimeMs).
147
+ if (canReuse && !dirtyPaths.has(rel) && typeof stored[rel] === 'string') {
148
+ entries[rel] = stored[rel];
149
+ continue;
150
+ }
151
+ entries[rel] = await statEntry(rootDir, rel);
152
+ }
153
+ // Dirty paths outside the manifest still affect identity (stat live);
154
+ // index-internal artifacts are skipped.
155
+ if (dirtyPaths) {
156
+ for (const rel of dirtyPaths) {
157
+ if (rel.startsWith('.cache/')) continue;
158
+ if (entries[rel] === undefined) entries[rel] = await statEntry(rootDir, rel);
159
+ }
160
+ }
161
+ return entries;
162
+ }
163
+
164
+ function hashEntries(entries) {
165
+ const lines = Object.keys(entries)
166
+ .sort()
167
+ .map((rel) => `${rel}:${entries[rel]}`);
168
+ return sha1(lines.join('\n'));
169
+ }
170
+
171
+ async function computeOverlayEntries(rootDir, headSha, { incremental = true, snapshot } = {}) {
172
+ const dirty = await readDirtySet(rootDir);
173
+ if (headSha !== 'nogit') {
174
+ const status = runGit(rootDir, ['status', '--porcelain']);
175
+ if (status !== null) {
176
+ // Git mode: porcelain output IS the diff — dirty.json unioned defensively.
177
+ const extra = dirty && !dirty.saturated ? dirty.paths : [];
178
+ return overlayEntriesGit(rootDir, status, extra);
179
+ }
180
+ }
181
+ return overlayEntriesManifest(rootDir, { incremental, snapshot, dirty });
182
+ }
183
+
184
+ export async function computeOverlayHash(rootDir, headSha, { incremental = true, snapshot } = {}) {
185
+ const entries = await computeOverlayEntries(rootDir, headSha, { incremental, snapshot });
186
+ return hashEntries(entries);
187
+ }
188
+
189
+ export async function computeConfigHash(rootDir) {
190
+ const config = await loadRuntimeConfig(rootDir);
191
+ return sha1(canonicalJson({
192
+ codeIntel: config?.codeIntel ?? null,
193
+ memoryV2: config?.memoryV2 ?? null,
194
+ compact: { enabled: config?.compact?.enabled ?? null },
195
+ INDEX_SCHEMA_VERSION,
196
+ }));
197
+ }
198
+
199
+ export async function computeIdentity(rootDir, { incremental } = {}) {
200
+ const headSha = getHeadSha(rootDir);
201
+ const config = await loadRuntimeConfig(rootDir);
202
+ const useIncremental = incremental ?? (config?.codeIntel?.freshness?.incremental !== false);
203
+ const configHash = sha1(canonicalJson({
204
+ codeIntel: config?.codeIntel ?? null,
205
+ memoryV2: config?.memoryV2 ?? null,
206
+ compact: { enabled: config?.compact?.enabled ?? null },
207
+ INDEX_SCHEMA_VERSION,
208
+ }));
209
+ const snapshot = await readSnapshot(rootDir);
210
+ const overlayEntries = await computeOverlayEntries(rootDir, headSha, {
211
+ incremental: useIncremental,
212
+ snapshot,
213
+ });
214
+ const overlayHash = hashEntries(overlayEntries);
215
+ const identity = sha1(`${headSha}|${overlayHash}|${configHash}`);
216
+ return {
217
+ identity,
218
+ headSha,
219
+ overlayHash,
220
+ configHash,
221
+ branch: getBranchKey(rootDir),
222
+ overlayEntries,
223
+ };
224
+ }
225
+
226
+ export function getSnapshotPath(rootDir, { branch } = {}) {
227
+ const key = branch ?? getBranchKey(rootDir);
228
+ return path.join(getIndexDir(rootDir), `freshness-${key}.json`);
229
+ }
230
+
231
+ async function readSnapshotFile(snapshotPath) {
232
+ try {
233
+ const raw = await fs.readFile(snapshotPath, 'utf8');
234
+ const snapshot = JSON.parse(raw);
235
+ if (!snapshot || typeof snapshot !== 'object' || typeof snapshot.identity !== 'string') {
236
+ return null;
237
+ }
238
+ return snapshot;
239
+ } catch {
240
+ return null;
241
+ }
242
+ }
243
+
244
+ export async function readSnapshot(rootDir) {
245
+ // Current branch's file first, then legacy freshness.json (C26 read-compat).
246
+ const branchSnapshot = await readSnapshotFile(getSnapshotPath(rootDir));
247
+ if (branchSnapshot) return branchSnapshot;
248
+ return readSnapshotFile(path.join(getIndexDir(rootDir), SNAPSHOT_FILE));
249
+ }
250
+
251
+ export async function writeSnapshot(rootDir, snapshot) {
252
+ const snapshotPath = getSnapshotPath(rootDir);
253
+ const legacyPath = path.join(getIndexDir(rootDir), SNAPSHOT_FILE);
254
+ await fs.mkdir(path.dirname(snapshotPath), { recursive: true });
255
+ const payload = JSON.stringify(snapshot, null, 2);
256
+ await fs.writeFile(snapshotPath, payload);
257
+ // Legacy alias: older readers still look at freshness.json.
258
+ if (legacyPath !== snapshotPath) {
259
+ await fs.writeFile(legacyPath, payload);
260
+ }
261
+ }
262
+
263
+ function levelIndex(level) {
264
+ return LEVELS.indexOf(level);
265
+ }
266
+
267
+ function clampLevel(level, maxLevel) {
268
+ const cap = levelIndex(maxLevel);
269
+ if (cap === -1) return level;
270
+ return levelIndex(level) > cap ? maxLevel : level;
271
+ }
272
+
273
+ // Lowest L that repairs the staleness:
274
+ // - no usable snapshot → L2 (batch incremental over unknown state)
275
+ // - configHash changed → L4 (schema/config drift needs full rebuild)
276
+ // - headSha changed → L2 (batch incremental)
277
+ // - overlayHash only → L1 (dirty-file refresh)
278
+ function repairLevel(snapshot, current) {
279
+ if (!snapshot) return 'L2';
280
+ if (snapshot.configHash !== current.configHash) return 'L4';
281
+ if (snapshot.headSha !== current.headSha) return 'L2';
282
+ return 'L1';
283
+ }
284
+
285
+ export async function guardLevel(rootDir, { maxLevel = 'L4' } = {}) {
286
+ const [identity, snapshot] = await Promise.all([
287
+ computeIdentity(rootDir),
288
+ readSnapshot(rootDir),
289
+ ]);
290
+ const stale = !snapshot || snapshot.identity !== identity.identity;
291
+ const recommendedLevel = stale
292
+ ? clampLevel(repairLevel(snapshot, identity), maxLevel)
293
+ : 'L0';
294
+ return { identity, snapshot, stale, recommendedLevel };
295
+ }
@@ -0,0 +1,291 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+
4
+ import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/paths.js';
5
+ import { readJsonIfExists, writeJson } from '../fileOps.js';
6
+ import { createEdge } from './providers.js';
7
+ import { COCHANGE_SCHEMA_VERSION } from './cochange.js';
8
+
9
+ // Graph contract + JSON store (SPEC §9 — CI-305). Builds `.cache/index/codegraph.json`
10
+ // from existing index artifacts (imports.json + calls.json, + cochange.json when
11
+ // present) and exposes a GraphProvider seam so SCIP/PDG/LSP backends can slot in
12
+ // later without touching consumers. No SQLite, no new deps — providers other than
13
+ // 'json' are contract-only NullGraphProvider stubs this cycle.
14
+
15
+ export const GRAPH_SCHEMA_VERSION = 1;
16
+
17
+ const VALID_GRAPH_STORES = new Set(['json', 'scip', 'pdg', 'lsp', 'null']);
18
+
19
+ async function readArtifact(rootDir, name, expectedSchemaVersion) {
20
+ try {
21
+ const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
22
+ const parsed = JSON.parse(raw);
23
+ if (!parsed || typeof parsed !== 'object') return null;
24
+ if (expectedSchemaVersion !== undefined && parsed.schemaVersion !== expectedSchemaVersion) {
25
+ return null;
26
+ }
27
+ return parsed;
28
+ } catch {
29
+ return null;
30
+ }
31
+ }
32
+
33
+ // Same specifier-resolution discipline as impact.js — local copy so this lane
34
+ // does not import same-wave sibling internals.
35
+ function resolveRelativeSpecifier(fromFile, specifier, fileSet) {
36
+ const candidates = [];
37
+ if (specifier.startsWith('.')) {
38
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), specifier));
39
+ candidates.push(base, `${base}.js`, `${base}.ts`, `${base}.mjs`, `${base}.jsx`, `${base}.tsx`, `${base}/index.js`, `${base}/index.ts`);
40
+ } else {
41
+ candidates.push(specifier);
42
+ }
43
+ for (const candidate of candidates) {
44
+ if (fileSet.has(candidate)) return candidate;
45
+ }
46
+ return null;
47
+ }
48
+
49
+ /**
50
+ * buildGraphArtifact(projectRoot) → artifact | null
51
+ *
52
+ * Merges imports.json + calls.json (+ cochange.json when present) into a
53
+ * codegraph.json artifact: { schemaVersion, generatedAt, nodes, edges }.
54
+ * Node ids: files → their relative path, symbols → `symbol:<name>`.
55
+ * Edges use the canonical createEdge shape. Writes the artifact atomically via
56
+ * fileOps.writeJson; returns null when no source artifacts exist. Never throws
57
+ * on missing/partial sources.
58
+ */
59
+ export async function buildGraphArtifact(projectRoot) {
60
+ const rootDir = path.resolve(projectRoot ?? process.cwd());
61
+
62
+ const [importsArtifact, callsArtifact, cochangeArtifact, filesArtifact] = await Promise.all([
63
+ readArtifact(rootDir, INDEX_ARTIFACTS.imports, INDEX_SCHEMA_VERSION),
64
+ readArtifact(rootDir, INDEX_ARTIFACTS.calls, INDEX_SCHEMA_VERSION),
65
+ readArtifact(rootDir, INDEX_ARTIFACTS.cochange, COCHANGE_SCHEMA_VERSION),
66
+ readArtifact(rootDir, INDEX_ARTIFACTS.files, INDEX_SCHEMA_VERSION),
67
+ ]);
68
+
69
+ if (!importsArtifact && !callsArtifact && !cochangeArtifact) {
70
+ return null;
71
+ }
72
+
73
+ const fileSet = new Set();
74
+ for (const file of filesArtifact?.items ?? []) {
75
+ if (file?.filePath) fileSet.add(file.filePath);
76
+ }
77
+ for (const imp of importsArtifact?.items ?? []) {
78
+ if (imp?.from) fileSet.add(imp.from);
79
+ }
80
+ for (const call of callsArtifact?.items ?? []) {
81
+ if (call?.filePath) fileSet.add(call.filePath);
82
+ }
83
+
84
+ const nodes = new Map(); // id → node
85
+ const edges = new Map(); // dedupe key → edge
86
+ const addFileNode = (filePath) => {
87
+ if (filePath && !nodes.has(filePath)) {
88
+ nodes.set(filePath, { id: filePath, kind: 'file', path: filePath });
89
+ }
90
+ };
91
+ const addSymbolNode = (name, filePath) => {
92
+ if (!name) return null;
93
+ const id = `symbol:${name}`;
94
+ if (!nodes.has(id)) {
95
+ nodes.set(id, { id, kind: 'symbol', path: filePath ?? null });
96
+ }
97
+ return id;
98
+ };
99
+ const addEdge = (edge, dedupeExtra = '') => {
100
+ const key = `${edge.from}»${edge.to}»${edge.kind}»${dedupeExtra}`;
101
+ if (!edges.has(key)) edges.set(key, edge);
102
+ };
103
+
104
+ // import edges: from-file → resolved target file
105
+ for (const imp of importsArtifact?.items ?? []) {
106
+ if (!imp?.from || !imp?.to) continue;
107
+ addFileNode(imp.from);
108
+ const target = resolveRelativeSpecifier(imp.from, imp.to, fileSet) ?? imp.to;
109
+ if (target === imp.from) continue;
110
+ addFileNode(target);
111
+ addEdge(createEdge({
112
+ from: imp.from,
113
+ to: target,
114
+ kind: 'import',
115
+ provider: 'json-graph',
116
+ evidence: INDEX_ARTIFACTS.imports,
117
+ }));
118
+ }
119
+
120
+ // call edges: caller file → callee file (resolved via symbol→file map);
121
+ // symbol nodes recorded regardless so queries can address them.
122
+ const symbolToFile = new Map();
123
+ for (const call of callsArtifact?.items ?? []) {
124
+ if (call?.filePath && call?.symbol && !symbolToFile.has(call.symbol)) {
125
+ symbolToFile.set(call.symbol, call.filePath);
126
+ }
127
+ }
128
+ for (const call of callsArtifact?.items ?? []) {
129
+ if (!call?.filePath) continue;
130
+ addFileNode(call.filePath);
131
+ if (call?.symbol) addSymbolNode(call.symbol, call.filePath);
132
+ for (const name of call.calls ?? []) {
133
+ const target = symbolToFile.get(name);
134
+ if (!target || target === call.filePath) continue;
135
+ addEdge(createEdge({
136
+ from: call.filePath,
137
+ to: target,
138
+ kind: 'call',
139
+ provider: 'json-graph',
140
+ evidence: INDEX_ARTIFACTS.calls,
141
+ }), name);
142
+ }
143
+ }
144
+
145
+ // co-change edges: pair endpoints become file nodes; count feeds confidence.
146
+ for (const pair of cochangeArtifact?.pairs ?? []) {
147
+ if (!pair?.a || !pair?.b) continue;
148
+ addFileNode(pair.a);
149
+ addFileNode(pair.b);
150
+ const confidence = Math.min(1, (pair.count ?? 1) / 10);
151
+ addEdge(createEdge({
152
+ from: pair.a,
153
+ to: pair.b,
154
+ kind: 'cochange',
155
+ confidence,
156
+ provider: 'json-graph',
157
+ evidence: INDEX_ARTIFACTS.cochange,
158
+ }), String(pair.count ?? ''));
159
+ addEdge(createEdge({
160
+ from: pair.b,
161
+ to: pair.a,
162
+ kind: 'cochange',
163
+ confidence,
164
+ provider: 'json-graph',
165
+ evidence: INDEX_ARTIFACTS.cochange,
166
+ }), String(pair.count ?? ''));
167
+ }
168
+
169
+ const artifact = {
170
+ schemaVersion: GRAPH_SCHEMA_VERSION,
171
+ generatedAt: new Date().toISOString(),
172
+ nodes: [...nodes.values()],
173
+ edges: [...edges.values()],
174
+ };
175
+
176
+ try {
177
+ // Content-gated write: unchanged graph (ignoring generatedAt) must not
178
+ // bump the artifact mtime — refresh's "no rewrite when nothing changed"
179
+ // guarantee covers derived artifacts too.
180
+ const graphPath = getArtifactPath(rootDir, INDEX_ARTIFACTS.codegraph);
181
+ const existing = await readJsonIfExists(graphPath);
182
+ const strip = ({ generatedAt, ...rest }) => rest;
183
+ if (!existing || JSON.stringify(strip(existing)) !== JSON.stringify(strip(artifact))) {
184
+ await writeJson(graphPath, artifact);
185
+ }
186
+ } catch {
187
+ // Artifact-write failure (read-only .cache, EACCES) is non-fatal — the
188
+ // in-memory artifact is still returned for the caller/summary.
189
+ }
190
+ return artifact;
191
+ }
192
+
193
+ /**
194
+ * JsonGraphStore — GraphProvider over `.cache/index/codegraph.json`.
195
+ * Schema-guarded (GRAPH_SCHEMA_VERSION); a missing/mismatched/corrupt artifact
196
+ * yields empty results per op, never throws.
197
+ */
198
+ export class JsonGraphStore {
199
+ constructor(projectRoot) {
200
+ this.name = 'json';
201
+ this.rootDir = path.resolve(projectRoot ?? process.cwd());
202
+ this._artifactPromise = null;
203
+ }
204
+
205
+ capabilities() {
206
+ return { nodes: true, edges: true, queries: true };
207
+ }
208
+
209
+ async _load() {
210
+ if (!this._artifactPromise) {
211
+ this._artifactPromise = (async () => {
212
+ const artifact = await readJsonIfExists(getArtifactPath(this.rootDir, INDEX_ARTIFACTS.codegraph));
213
+ if (!artifact || typeof artifact !== 'object') return null;
214
+ if (artifact.schemaVersion !== GRAPH_SCHEMA_VERSION) return null;
215
+ if (!Array.isArray(artifact.nodes) || !Array.isArray(artifact.edges)) return null;
216
+ return artifact;
217
+ })();
218
+ }
219
+ return this._artifactPromise;
220
+ }
221
+
222
+ async nodes() {
223
+ const artifact = await this._load();
224
+ return artifact?.nodes ?? [];
225
+ }
226
+
227
+ async edgesFrom(id) {
228
+ const artifact = await this._load();
229
+ if (!artifact || typeof id !== 'string') return [];
230
+ return artifact.edges.filter((edge) => edge?.from === id);
231
+ }
232
+
233
+ /**
234
+ * query(q) → edge[] | null. Resolves a node id (file path or `symbol:` id) to
235
+ * its outgoing edges; null when the node is absent or no artifact exists.
236
+ */
237
+ async query(q) {
238
+ const artifact = await this._load();
239
+ if (!artifact) return null;
240
+ const nodeId = typeof q === 'object' && q !== null ? q.path ?? q.id ?? null : q;
241
+ if (typeof nodeId !== 'string' || nodeId === '') return null;
242
+ const exists = artifact.nodes.some((node) => node?.id === nodeId);
243
+ if (!exists) return null;
244
+ return artifact.edges.filter((edge) => edge?.from === nodeId);
245
+ }
246
+ }
247
+
248
+ /**
249
+ * NullGraphProvider — contract-only stub for SCIP/PDG/LSP/'null' backends.
250
+ * All capabilities false; every op returns empty/null. Never throws.
251
+ */
252
+ export class NullGraphProvider {
253
+ constructor(backend = 'null') {
254
+ this.name = backend;
255
+ }
256
+
257
+ capabilities() {
258
+ return { nodes: false, edges: false, queries: false };
259
+ }
260
+
261
+ async nodes() {
262
+ return [];
263
+ }
264
+
265
+ async edgesFrom() {
266
+ return [];
267
+ }
268
+
269
+ async query() {
270
+ return null;
271
+ }
272
+ }
273
+
274
+ /**
275
+ * createGraphProvider({ projectRoot?, config? }) → GraphProvider
276
+ * codeIntel.graph.store: 'json' (default/unset) → JsonGraphStore;
277
+ * 'scip' | 'pdg' | 'lsp' | 'null' → NullGraphProvider (contract-only seam).
278
+ */
279
+ export function createGraphProvider({ projectRoot, config } = {}) {
280
+ if (config?.codeIntel?.graph?.enabled === false) {
281
+ return new NullGraphProvider('disabled');
282
+ }
283
+ const store = config?.codeIntel?.graph?.store;
284
+ if (store === undefined || store === 'json') {
285
+ return new JsonGraphStore(projectRoot);
286
+ }
287
+ if (VALID_GRAPH_STORES.has(store)) {
288
+ return new NullGraphProvider(store);
289
+ }
290
+ return new NullGraphProvider('null');
291
+ }