@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.
- package/CHANGELOG.md +52 -0
- package/package.json +1 -1
- package/scripts/index/build-index.mjs +2 -1
- package/scripts/index/query-index.mjs +2 -0
- package/src/cli/commands/indexTools.js +5 -0
- package/src/cli/commands/install.js +2 -1
- package/src/cli/commands/metrics.js +127 -0
- package/src/cli/index.js +7 -0
- package/src/core/codeintel/analogy.js +197 -0
- package/src/core/codeintel/cochange.js +205 -0
- package/src/core/codeintel/compiler.js +82 -9
- package/src/core/codeintel/graph.js +291 -0
- package/src/core/codeintel/impact.js +23 -0
- package/src/core/codeintel/packet.js +1 -0
- package/src/core/codeintel/retriever.js +49 -3
- package/src/core/codeintel/summaries.js +194 -0
- package/src/core/codeintel/vectorProvider.js +213 -0
- package/src/core/runtimeConfig.js +50 -2
- package/src/diagnostics/failurePatterns.js +187 -0
- package/src/diagnostics/routeOutcomes.js +146 -0
- package/src/index/buildIndex.js +29 -0
- package/src/index/paths.js +2 -0
- package/templates/.claude/hooks/block-dangerous.sh +29 -5
- package/templates/.claude/hooks/context-hardcap-gate.sh +4 -1
- package/templates/.claude/hooks/protect-files.sh +26 -3
- package/templates/.claude/ukit/index/route-task.mjs +57 -6
- package/templates/.claude/ukit/runtime/hook-chain-runner.mjs +9 -1
- package/templates/.claude/ukit/runtime/hook-input.sh +26 -3
- package/templates/ukit/storage/config.json +7 -2
|
@@ -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
|
-
//
|
|
25
|
-
//
|
|
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: '
|
|
44
|
-
analogy: '
|
|
47
|
+
deep_flow: 'L3',
|
|
48
|
+
analogy: 'L3',
|
|
45
49
|
});
|
|
46
50
|
|
|
47
|
-
|
|
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)');
|