@ngockhoale/ukit 2.6.8 → 2.6.10

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.
@@ -0,0 +1,205 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import { spawnSync } from 'node:child_process';
4
+
5
+ import { getArtifactPath, INDEX_ARTIFACTS } from '../../index/paths.js';
6
+ import { writeJson } from '../fileOps.js';
7
+
8
+ // Co-change mining (SPEC §6 — CI-303). Parses bounded `git log --name-only`
9
+ // history into co-commit pair counts persisted as `.cache/index/cochange.json`.
10
+ // The artifact feeds `impactSet` context edges (kind:'cochange') only — it does
11
+ // NOT create edges in the structural import/call graph. Never throws: non-git
12
+ // dirs and git failures yield null.
13
+
14
+ export const COCHANGE_SCHEMA_VERSION = 1;
15
+
16
+ const MAX_FILES_PER_COMMIT = 50;
17
+ const GIT_MAX_BUFFER = 16 * 1024 * 1024;
18
+
19
+ // Same discipline as freshness.js runGit: spawnSync (no shell), bounded
20
+ // timeout/maxBuffer, null on any failure — never throws.
21
+ function runGit(rootDir, args, timeoutMs) {
22
+ try {
23
+ const result = spawnSync('git', args, {
24
+ cwd: rootDir,
25
+ encoding: 'utf8',
26
+ timeout: timeoutMs,
27
+ maxBuffer: GIT_MAX_BUFFER,
28
+ });
29
+ if (result.error || result.status !== 0) return null;
30
+ return result.stdout ?? '';
31
+ } catch {
32
+ return null;
33
+ }
34
+ }
35
+
36
+ function getHeadSha(rootDir, timeoutMs) {
37
+ const out = runGit(rootDir, ['rev-parse', 'HEAD'], timeoutMs);
38
+ const sha = (out || '').trim();
39
+ return /^[0-9a-f]{40}$/i.test(sha) ? sha.toLowerCase() : null;
40
+ }
41
+
42
+ // Parses `git log --name-only --format=sha:%H` output into
43
+ // [{ sha, files: string[] }] commits. File lines are the non-sha, non-blank
44
+ // lines following a `sha:` header.
45
+ function parseLog(output) {
46
+ const commits = [];
47
+ let current = null;
48
+ for (const line of output.split('\n')) {
49
+ if (line.startsWith('sha:')) {
50
+ const sha = line.slice(4).trim();
51
+ if (/^[0-9a-f]{40}$/i.test(sha)) {
52
+ current = { sha: sha.toLowerCase(), files: [] };
53
+ commits.push(current);
54
+ } else {
55
+ current = null;
56
+ }
57
+ continue;
58
+ }
59
+ if (!current) continue;
60
+ const rel = line.trim();
61
+ if (rel) current.files.push(rel);
62
+ }
63
+ return commits;
64
+ }
65
+
66
+ function pairKey(a, b) {
67
+ return a < b ? `${a}|${b}` : `${b}|${a}`;
68
+ }
69
+
70
+ /**
71
+ * mineCoChanges(projectRoot, { maxCommits?, timeoutMs? })
72
+ * → artifact { schemaVersion, generatedAt, headSha,
73
+ * pairs: [{a,b,count,lastSha}] sorted a|b asc,
74
+ * fileCounts: {file: n}, truncated: bool }
75
+ * → null when git is unavailable/fails.
76
+ * Writes getArtifactPath(root, 'cochange') when non-null.
77
+ */
78
+ export async function mineCoChanges(projectRoot, { maxCommits = 500, timeoutMs = 5000 } = {}) {
79
+ const rootDir = path.resolve(projectRoot ?? process.cwd());
80
+ const cap = Number.isFinite(Number(maxCommits)) && Number(maxCommits) > 0
81
+ ? Math.floor(Number(maxCommits))
82
+ : 500;
83
+ const timeout = Number.isFinite(Number(timeoutMs)) && Number(timeoutMs) > 0
84
+ ? Math.floor(Number(timeoutMs))
85
+ : 5000;
86
+
87
+ const headSha = getHeadSha(rootDir, timeout);
88
+ if (!headSha) return null;
89
+
90
+ // Fetch cap+1 so we can detect truncation when history runs deeper.
91
+ const log = runGit(
92
+ rootDir,
93
+ ['-c', 'core.quotePath=false', 'log', '--name-only', '--format=sha:%H', '-n', String(cap + 1)],
94
+ timeout,
95
+ );
96
+ if (log === null) return null;
97
+
98
+ let commits = parseLog(log);
99
+ const truncated = commits.length > cap;
100
+ if (truncated) commits = commits.slice(0, cap);
101
+
102
+ const fileCounts = {};
103
+ const pairs = new Map(); // 'a|b' → { a, b, count, lastSha }
104
+ for (const commit of commits) {
105
+ const seen = new Set(commit.files);
106
+ for (const rel of seen) {
107
+ fileCounts[rel] = (fileCounts[rel] ?? 0) + 1;
108
+ }
109
+ // Giant commits (merge/squash noise) still count for fileCounts but are
110
+ // skipped for pair accumulation.
111
+ if (seen.size > MAX_FILES_PER_COMMIT) continue;
112
+ const files = [...seen];
113
+ for (let i = 0; i < files.length; i += 1) {
114
+ for (let j = i + 1; j < files.length; j += 1) {
115
+ const a = files[i] < files[j] ? files[i] : files[j];
116
+ const b = files[i] < files[j] ? files[j] : files[i];
117
+ const key = pairKey(a, b);
118
+ const existing = pairs.get(key);
119
+ if (existing) {
120
+ existing.count += 1;
121
+ // git log is newest-first: keep the first-seen sha (latest commit).
122
+ } else {
123
+ pairs.set(key, { a, b, count: 1, lastSha: commit.sha });
124
+ }
125
+ }
126
+ }
127
+ }
128
+
129
+ const artifact = {
130
+ schemaVersion: COCHANGE_SCHEMA_VERSION,
131
+ generatedAt: new Date().toISOString(),
132
+ headSha,
133
+ pairs: [...pairs.values()].sort((x, y) => pairKey(x.a, x.b).localeCompare(pairKey(y.a, y.b))),
134
+ fileCounts,
135
+ truncated,
136
+ };
137
+
138
+ try {
139
+ await writeJson(getArtifactPath(rootDir, INDEX_ARTIFACTS.cochange), artifact);
140
+ } catch {
141
+ // Artifact write failure is non-fatal: caller still gets the data.
142
+ }
143
+ return artifact;
144
+ }
145
+
146
+ /**
147
+ * readCoChanges(projectRoot) → artifact | null
148
+ * Schema-guarded: schemaVersion !== COCHANGE_SCHEMA_VERSION → null.
149
+ */
150
+ export async function readCoChanges(projectRoot) {
151
+ const rootDir = path.resolve(projectRoot ?? process.cwd());
152
+ try {
153
+ const raw = await fs.readFile(getArtifactPath(rootDir, INDEX_ARTIFACTS.cochange), 'utf8');
154
+ const artifact = JSON.parse(raw);
155
+ if (!artifact || typeof artifact !== 'object') return null;
156
+ if (artifact.schemaVersion !== COCHANGE_SCHEMA_VERSION) return null;
157
+ if (!Array.isArray(artifact.pairs)) return null;
158
+ return artifact;
159
+ } catch {
160
+ return null;
161
+ }
162
+ }
163
+
164
+ /**
165
+ * coChangeEdges(projectRoot, seeds, { minCount? })
166
+ * → edge[] { from: seed, to: peer, kind: 'cochange',
167
+ * confidence: min(1, count/10), provider: 'git-cochange',
168
+ * evidence: 'cochange.json', snapshot: headSha }
169
+ * Returns [] when the artifact is missing/stale or no pairs qualify.
170
+ */
171
+ export async function coChangeEdges(projectRoot, seeds, { minCount = 2 } = {}) {
172
+ const artifact = await readCoChanges(projectRoot);
173
+ if (!artifact) return [];
174
+ const threshold = Number.isFinite(Number(minCount)) ? Number(minCount) : 2;
175
+ const seedSet = new Set(
176
+ Array.isArray(seeds) ? seeds.filter((s) => typeof s === 'string' && s.length > 0) : [],
177
+ );
178
+ if (seedSet.size === 0) return [];
179
+
180
+ const edges = [];
181
+ for (const pair of artifact.pairs) {
182
+ if (!pair?.a || !pair?.b || typeof pair.count !== 'number') continue;
183
+ if (pair.count < threshold) continue;
184
+ let from = null;
185
+ let to = null;
186
+ if (seedSet.has(pair.a) && !seedSet.has(pair.b)) {
187
+ from = pair.a;
188
+ to = pair.b;
189
+ } else if (seedSet.has(pair.b) && !seedSet.has(pair.a)) {
190
+ from = pair.b;
191
+ to = pair.a;
192
+ }
193
+ if (!from) continue;
194
+ edges.push({
195
+ from,
196
+ to,
197
+ kind: 'cochange',
198
+ confidence: Math.min(1, pair.count / 10),
199
+ provider: 'git-cochange',
200
+ evidence: 'cochange.json',
201
+ snapshot: artifact.headSha,
202
+ });
203
+ }
204
+ return edges;
205
+ }
@@ -8,6 +8,8 @@ import { retrieve } from './retriever.js';
8
8
  import { impactSet } from './impact.js';
9
9
  import { readDirty } from './invalidation.js';
10
10
  import { runDiagnostics } from './diagnostics.js';
11
+ import { findAnalogies } from './analogy.js';
12
+ import { summarizeFile } from './summaries.js';
11
13
  import { createEdge } from './providers.js';
12
14
  import { loadRuntimeConfig } from '../runtimeConfig.js';
13
15
 
@@ -21,8 +23,10 @@ import { loadRuntimeConfig } from '../runtimeConfig.js';
21
23
  // peek → L0 outline only (no excerpts, no relations)
22
24
  // targeted | explore → L1 + anchors + relations
23
25
  // impact → L2 + 1-hop reverse-import edges (retrieve mode impact)
24
- // deep_flow | analogy → routed but no graph/embedding lanes in v1 →
25
- // `omitted: { what:'graph evidence', why:'provider unavailable' }`
26
+ // analogy → L3 + procedural-fingerprint lane (memory procedures
27
+ // + index symbols/files ranked by Jaccard) + summaries
28
+ // deep_flow → L3 + deterministic extractive summaries on top-N
29
+ // path evidence (summaries.js — no graph lane in v1)
26
30
  // read → L2 excerpts; emitted when depth >= 2 or `read:true`.
27
31
  //
28
32
  // Budget units are token estimates (≈ 4 chars/token over serialized evidence).
@@ -40,11 +44,13 @@ const DEPTH_LEVEL = Object.freeze({
40
44
  targeted: 'L1',
41
45
  explore: 'L1',
42
46
  impact: 'L2',
43
- deep_flow: 'L2',
44
- analogy: 'L2',
47
+ deep_flow: 'L3',
48
+ analogy: 'L3',
45
49
  });
46
50
 
47
- const UNAVAILABLE_LANES = new Set(['deep_flow', 'analogy']);
51
+ // Kept for the unavailable-lane contract — deep_flow became a real L3 lane in
52
+ // CI-304, so the set is empty but the omission mechanism remains for future lanes.
53
+ const UNAVAILABLE_LANES = new Set();
48
54
 
49
55
  function estimateTokens(value) {
50
56
  try {
@@ -218,10 +224,10 @@ export async function compileContext(projectRoot, task, options = {}) {
218
224
  from: edge.from,
219
225
  to: edge.to,
220
226
  kind: edge.kind ?? 'import',
221
- confidence: 1,
222
- provider: 'index-file',
223
- evidence: 'imports.json',
224
- snapshot: typeof identity.identity === 'string' ? identity.identity : null,
227
+ confidence: typeof edge.confidence === 'number' ? edge.confidence : 1,
228
+ provider: edge.provider ?? 'index-file',
229
+ evidence: edge.evidence ?? 'imports.json',
230
+ snapshot: edge.snapshot ?? (typeof identity.identity === 'string' ? identity.identity : null),
225
231
  })),
226
232
  };
227
233
  for (const entry of impact.omitted ?? []) packet.omitted.push(entry);
@@ -233,6 +239,50 @@ export async function compileContext(projectRoot, task, options = {}) {
233
239
  }
234
240
  }
235
241
 
242
+ // Analogy lane (SPEC §4/§5, CI-302): fingerprints memory `procedure`
243
+ // records + index symbols/files, ranks by Jaccard. Procedure hits land in
244
+ // `packet.memory` + L2 memory evidence; code hits become anchors + L2 index
245
+ // evidence. Read-only, degrades into `omitted` — never throws.
246
+ if (mode === 'analogy' && config?.codeIntel?.analogy?.enabled !== false) {
247
+ try {
248
+ const analogyLimit = typeof config?.codeIntel?.analogy?.limit === 'number'
249
+ ? config.codeIntel.analogy.limit
250
+ : 5;
251
+ const lane = await findAnalogies(rootDir, query, { limit: analogyLimit });
252
+ const seenAnchorPaths = new Set((result.anchors ?? []).map((a) => a?.path));
253
+ for (const entry of lane.analogies ?? []) {
254
+ if (entry.kind === 'procedure') {
255
+ result.evidence.push({
256
+ level: 'L2',
257
+ source: 'memory',
258
+ why: `analogy: ${entry.ref} (score ${entry.score.toFixed(2)})`,
259
+ ...(entry.path ? { path: entry.path } : {}),
260
+ });
261
+ packet.memory.push({ id: entry.ref, score: entry.score });
262
+ } else {
263
+ result.evidence.push({
264
+ level: 'L2',
265
+ source: 'index',
266
+ why: `analogy: ${entry.ref} (score ${entry.score.toFixed(2)})`,
267
+ path: entry.path,
268
+ });
269
+ if (entry.path && !seenAnchorPaths.has(entry.path)) {
270
+ seenAnchorPaths.add(entry.path);
271
+ result.anchors.push({ path: entry.path });
272
+ }
273
+ }
274
+ }
275
+ if ((lane.analogies ?? []).length === 0) {
276
+ packet.omitted.push({ what: 'analogy-lane', why: 'no-match' });
277
+ }
278
+ for (const entry of lane.omitted ?? []) {
279
+ packet.omitted.push(entry);
280
+ }
281
+ } catch {
282
+ packet.omitted.push({ what: 'analogy-lane', why: 'analogy-error' });
283
+ }
284
+ }
285
+
236
286
  // L0 peek emits outline-level evidence only: same paths, no excerpts.
237
287
  const candidates = (result.evidence ?? []).map((item) => {
238
288
  const next = { ...item, level };
@@ -240,6 +290,29 @@ export async function compileContext(projectRoot, task, options = {}) {
240
290
  return next;
241
291
  });
242
292
 
293
+ // L3 summaries lane (SPEC §7, CI-304): attach a deterministic extractive
294
+ // summary to the top `codeIntel.summaries.maxItems` (default 5) evidence
295
+ // items that carry a path. Pre-budget so token cost is accounted for.
296
+ if (level === 'L3' && config?.codeIntel?.summaries?.enabled !== false) {
297
+ const maxItems = typeof config?.codeIntel?.summaries?.maxItems === 'number'
298
+ ? config.codeIntel.summaries.maxItems
299
+ : 5;
300
+ let attached = 0;
301
+ for (const item of candidates) {
302
+ if (attached >= maxItems) break;
303
+ if (!item.path) continue;
304
+ try {
305
+ const result = await summarizeFile(rootDir, item.path);
306
+ if (result && result.summary) {
307
+ item.summary = result.summary;
308
+ attached += 1;
309
+ }
310
+ } catch {
311
+ // summary lane degrades silently — evidence stays valid without it
312
+ }
313
+ }
314
+ }
315
+
243
316
  // L2 read lane: attach excerpts to the top candidates only.
244
317
  if (wantRead && level !== 'L0') {
245
318
  for (const item of candidates) {
@@ -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
+ }
@@ -3,6 +3,7 @@ import path from 'node:path';
3
3
 
4
4
  import { getArtifactPath, INDEX_ARTIFACTS, INDEX_SCHEMA_VERSION } from '../../index/paths.js';
5
5
  import { loadRuntimeConfig } from '../runtimeConfig.js';
6
+ import { readCoChanges, coChangeEdges } from './cochange.js';
6
7
 
7
8
  // Multi-hop impact/trace engine (SPEC §4) — cycle-safe BFS over imports.json +
8
9
  // calls.json edges. `dependents` walks reverse edges (who imports the seed),
@@ -247,5 +248,27 @@ export async function impactSet(projectRoot, seeds, {
247
248
  };
248
249
  if (frontierCheck('rev') || (wantDeps && frontierCheck('fwd'))) truncated = true;
249
250
 
251
+ // Co-change lane (SPEC §6): when enabled (in-code default true) and the
252
+ // direction touches dependents, union kind:'cochange' edges for seed peers
253
+ // (count ≥ minCount) as context — peers join `nodes` at hop:0 with via:[],
254
+ // not as graph hops. Missing artifact → omitted reason; never fatal.
255
+ const cochangeConfig = config?.codeIntel?.cochange;
256
+ const cochangeEnabled = cochangeConfig?.enabled !== false;
257
+ if (cochangeEnabled && wantRev) {
258
+ const cochangeArtifact = await readCoChanges(rootDir);
259
+ if (!cochangeArtifact) {
260
+ omitted.push({ what: 'cochange-lane', why: 'index-missing' });
261
+ } else {
262
+ const minCount = typeof cochangeConfig?.minCount === 'number' ? cochangeConfig.minCount : 2;
263
+ const coEdges = await coChangeEdges(rootDir, seedFiles, { minCount });
264
+ for (const edge of coEdges) {
265
+ emitEdge(edge);
266
+ if (!nodes.has(edge.to)) {
267
+ nodes.set(edge.to, { file: edge.to, hop: 0, via: [] });
268
+ }
269
+ }
270
+ }
271
+ }
272
+
250
273
  return { nodes: [...nodes.values()], edges, truncated, omitted };
251
274
  }
@@ -108,6 +108,7 @@ export function packetToText(packet) {
108
108
  lines.push('## Evidence');
109
109
  for (const e of packet.evidence ?? []) {
110
110
  lines.push(`- [${e.level}/${e.source}] ${e.path ?? ''} — ${e.why ?? ''}`);
111
+ if (e.summary) lines.push(` summary: ${e.summary}`);
111
112
  if (e.excerpt) lines.push(` ${e.excerpt}`);
112
113
  }
113
114
  if ((packet.evidence ?? []).length === 0) lines.push('(none)');