@ngockhoale/ukit 2.6.7 → 2.6.8

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 (44) hide show
  1. package/manifests/documentation.yaml +77 -6
  2. package/manifests/hostCapabilities.yaml +49 -0
  3. package/manifests/instructionRules.yaml +7 -0
  4. package/package.json +1 -1
  5. package/scripts/bench/goldTasks.json +38 -0
  6. package/scripts/bench/runGold.mjs +220 -0
  7. package/scripts/release/verify-release.mjs +6 -0
  8. package/src/cli/commands/code.js +182 -0
  9. package/src/cli/commands/doctor.js +35 -3
  10. package/src/cli/commands/indexTools.js +102 -1
  11. package/src/cli/commands/memory.js +137 -0
  12. package/src/cli/index.js +7 -0
  13. package/src/core/codeintel/compiler.js +316 -0
  14. package/src/core/codeintel/diagnostics.js +114 -0
  15. package/src/core/codeintel/freshness.js +295 -0
  16. package/src/core/codeintel/impact.js +251 -0
  17. package/src/core/codeintel/invalidation.js +150 -0
  18. package/src/core/codeintel/manifest.js +176 -0
  19. package/src/core/codeintel/packet.js +146 -0
  20. package/src/core/codeintel/providers.js +201 -0
  21. package/src/core/codeintel/retriever.js +372 -0
  22. package/src/core/codeintel/router.js +149 -0
  23. package/src/core/codeintel/semanticProvider.js +235 -0
  24. package/src/core/docContracts.js +723 -0
  25. package/src/core/memory/migrate.js +324 -0
  26. package/src/core/memory/records.js +172 -0
  27. package/src/core/memory/retrieval.js +161 -11
  28. package/src/core/memory/store.js +398 -0
  29. package/src/core/memory/storeV2.js +171 -0
  30. package/src/core/memory/storeV2Loader.js +22 -0
  31. package/src/core/runtimeConfig.js +125 -0
  32. package/src/core/runtimePaths.js +3 -0
  33. package/src/index/taskRouting.js +39 -0
  34. package/templates/.claude/ukit/index/route-task.mjs +40 -0
  35. package/templates/AGENTS.md +46 -99
  36. package/templates/CLAUDE.md +46 -99
  37. package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
  38. package/templates/docs/BUGFIX.md +2 -19
  39. package/templates/docs/BUG_INDEX.md +43 -0
  40. package/templates/docs/BUG_METRICS.md +1 -5
  41. package/templates/docs/BUG_TEMPLATE.md +1 -11
  42. package/templates/docs/UKIT_INTERNALS.md +4 -0
  43. package/templates/instructions/core.md +46 -99
  44. package/templates/ukit/storage/config.json +30 -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,251 @@
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 { loadRuntimeConfig } from '../runtimeConfig.js';
6
+
7
+ // Multi-hop impact/trace engine (SPEC §4) — cycle-safe BFS over imports.json +
8
+ // calls.json edges. `dependents` walks reverse edges (who imports the seed),
9
+ // `dependencies` walks forward edges (what the seed imports/calls), `both`
10
+ // unions the two with signed hops. Never throws: missing artifacts degrade to
11
+ // an empty result with an `omitted` reason.
12
+
13
+ const DEFAULT_DEPTH = 2;
14
+ const DEFAULT_MAX_DEPTH = 4;
15
+ const DEFAULT_MAX_NODES = 200;
16
+
17
+ async function readArtifact(rootDir, name) {
18
+ try {
19
+ const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
20
+ const parsed = JSON.parse(raw);
21
+ if (parsed?.schemaVersion !== undefined && parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
22
+ return null;
23
+ }
24
+ return parsed;
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ // Local copy of the specifier resolver (SPEC §4 — do NOT import retriever.js,
31
+ // a same-wave file). Resolves a relative/bare import specifier against the
32
+ // indexed file set.
33
+ function resolveRelativeSpecifier(fromFile, specifier, fileSet) {
34
+ const candidates = [];
35
+ if (specifier.startsWith('.')) {
36
+ const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), specifier));
37
+ candidates.push(base, `${base}.js`, `${base}.ts`, `${base}.mjs`, `${base}.jsx`, `${base}.tsx`, `${base}/index.js`, `${base}/index.ts`);
38
+ } else {
39
+ candidates.push(specifier);
40
+ }
41
+ for (const candidate of candidates) {
42
+ if (fileSet.has(candidate)) return candidate;
43
+ }
44
+ return null;
45
+ }
46
+
47
+ function impactDefaults(config) {
48
+ const impact = config?.codeIntel?.impact;
49
+ return {
50
+ defaultDepth: typeof impact?.defaultDepth === 'number' ? impact.defaultDepth : DEFAULT_DEPTH,
51
+ maxDepth: typeof impact?.maxDepth === 'number' ? impact.maxDepth : DEFAULT_MAX_DEPTH,
52
+ maxNodes: typeof impact?.maxNodes === 'number' ? impact.maxNodes : DEFAULT_MAX_NODES,
53
+ };
54
+ }
55
+
56
+ function clampInt(value, min, max) {
57
+ const n = Math.floor(Number(value));
58
+ if (!Number.isFinite(n)) return min;
59
+ return Math.min(Math.max(n, min), max);
60
+ }
61
+
62
+ function addAdjacency(map, key, edge) {
63
+ let list = map.get(key);
64
+ if (!list) {
65
+ list = [];
66
+ map.set(key, list);
67
+ }
68
+ list.push(edge);
69
+ }
70
+
71
+ /**
72
+ * impactSet(projectRoot, seeds, { depth?, direction?, maxDepth?, maxNodes? })
73
+ * → { nodes: [{ file, hop, via: edge[] }],
74
+ * edges: [{ from, to, kind:'import'|'call', symbol? }],
75
+ * truncated: boolean,
76
+ * omitted: [{ what, why }] }
77
+ */
78
+ export async function impactSet(projectRoot, seeds, {
79
+ depth,
80
+ direction = 'dependents',
81
+ maxDepth,
82
+ maxNodes,
83
+ } = {}) {
84
+ const rootDir = path.resolve(projectRoot ?? process.cwd());
85
+ const omitted = [];
86
+ const empty = { nodes: [], edges: [], truncated: false, omitted };
87
+
88
+ let config = null;
89
+ try {
90
+ config = await loadRuntimeConfig(rootDir);
91
+ } catch {
92
+ config = null;
93
+ }
94
+ const defaults = impactDefaults(config);
95
+
96
+ const maxDepthCap = clampInt(maxDepth ?? defaults.maxDepth, 1, Number.MAX_SAFE_INTEGER);
97
+ const nodeCap = clampInt(maxNodes ?? defaults.maxNodes, 1, Number.MAX_SAFE_INTEGER);
98
+ const requestedDepth = typeof depth === 'number' ? depth : defaults.defaultDepth;
99
+ const depthLimit = clampInt(requestedDepth, 1, maxDepthCap);
100
+ const depthClamped = requestedDepth > depthLimit;
101
+
102
+ const seedList = Array.isArray(seeds) ? seeds.filter((s) => typeof s === 'string' && s.length > 0) : [];
103
+ if (seedList.length === 0) {
104
+ omitted.push({ what: 'impact', why: 'empty-seeds' });
105
+ return empty;
106
+ }
107
+
108
+ const [filesArtifact, importsArtifact, callsArtifact] = await Promise.all([
109
+ readArtifact(rootDir, INDEX_ARTIFACTS.files),
110
+ readArtifact(rootDir, INDEX_ARTIFACTS.imports),
111
+ readArtifact(rootDir, INDEX_ARTIFACTS.calls),
112
+ ]);
113
+
114
+ if (!importsArtifact && !callsArtifact) {
115
+ omitted.push({ what: 'impact', why: 'index-missing' });
116
+ return empty;
117
+ }
118
+
119
+ // File set for specifier resolution — files.json first, with imports' `from`
120
+ // endpoints as fallback so a partial index still resolves.
121
+ const fileSet = new Set((filesArtifact?.items ?? []).map((f) => f.filePath).filter(Boolean));
122
+ for (const imp of importsArtifact?.items ?? []) {
123
+ if (imp?.from) fileSet.add(imp.from);
124
+ }
125
+
126
+ // Adjacency: forward = dependencies (from → to), reverse = dependents (to → from).
127
+ const forward = new Map();
128
+ const reverse = new Map();
129
+ for (const imp of importsArtifact?.items ?? []) {
130
+ if (!imp?.from || !imp?.to) continue;
131
+ const target = resolveRelativeSpecifier(imp.from, imp.to, fileSet);
132
+ if (!target || target === imp.from) continue;
133
+ const edge = { from: imp.from, to: target, kind: 'import' };
134
+ addAdjacency(forward, edge.from, edge);
135
+ // Reverse edges are flipped so `from`/`to` always read seed→impacted.
136
+ addAdjacency(reverse, edge.to, { from: edge.to, to: edge.from, kind: 'import' });
137
+ }
138
+
139
+ // calls.json → symbol map; call edges are forward-only (dependencies side).
140
+ const symbolToFile = new Map();
141
+ for (const call of callsArtifact?.items ?? []) {
142
+ if (call?.filePath && call?.symbol && !symbolToFile.has(call.symbol)) {
143
+ symbolToFile.set(call.symbol, call.filePath);
144
+ }
145
+ }
146
+ const wantDeps = direction === 'dependencies' || direction === 'both';
147
+ const wantRev = direction === 'dependents' || direction === 'both';
148
+ if (wantDeps) {
149
+ for (const call of callsArtifact?.items ?? []) {
150
+ if (!call?.filePath) continue;
151
+ for (const name of call.calls ?? []) {
152
+ const target = symbolToFile.get(name);
153
+ if (!target || target === call.filePath) continue;
154
+ addAdjacency(forward, call.filePath, { from: call.filePath, to: target, kind: 'call', symbol: name });
155
+ }
156
+ }
157
+ }
158
+
159
+ // Resolve seeds → files. File seeds match indexed paths; symbol seeds match
160
+ // calls.json `symbol` fields (their filePath becomes the node).
161
+ const seedFiles = [];
162
+ for (const seed of seedList) {
163
+ if (fileSet.has(seed) || forward.has(seed) || reverse.has(seed)) {
164
+ seedFiles.push(seed);
165
+ continue;
166
+ }
167
+ const viaSymbol = symbolToFile.get(seed);
168
+ if (viaSymbol) {
169
+ seedFiles.push(viaSymbol);
170
+ }
171
+ }
172
+ if (seedFiles.length === 0) {
173
+ omitted.push({ what: 'impact', why: 'seeds-unmatched' });
174
+ return empty;
175
+ }
176
+
177
+ const nodes = new Map(); // file → { file, hop, via }
178
+ const edges = [];
179
+ const edgeKeys = new Set();
180
+ const visited = new Set(); // `${file}|${side}`
181
+ const sideHop = new Map(); // `${file}|${side}` → unsigned hop
182
+ let truncated = false;
183
+
184
+ const emitEdge = (edge) => {
185
+ const key = `${edge.from}|${edge.to}|${edge.kind}|${edge.symbol ?? ''}`;
186
+ if (edgeKeys.has(key)) return;
187
+ edgeKeys.add(key);
188
+ edges.push(edge);
189
+ };
190
+
191
+ // side: 'rev' (dependents, positive hop) | 'fwd' (dependencies, negative hop)
192
+ const bfs = (side) => {
193
+ const adjacency = side === 'rev' ? reverse : forward;
194
+ const sign = side === 'rev' ? 1 : -1;
195
+ const queue = [];
196
+ for (const seed of seedFiles) {
197
+ const key = `${seed}|${side}`;
198
+ if (!visited.has(key)) {
199
+ visited.add(key);
200
+ sideHop.set(key, 0);
201
+ queue.push({ file: seed, hop: 0 });
202
+ }
203
+ }
204
+ while (queue.length > 0) {
205
+ const { file, hop } = queue.shift();
206
+ if (hop >= depthLimit) continue;
207
+ for (const edge of adjacency.get(file) ?? []) {
208
+ const next = edge.to;
209
+ emitEdge(edge);
210
+ const visitKey = `${next}|${side}`;
211
+ if (visited.has(visitKey)) continue;
212
+ visited.add(visitKey);
213
+ const nextHop = hop + 1;
214
+ sideHop.set(visitKey, nextHop);
215
+ if (nodes.size >= nodeCap && !nodes.has(next)) {
216
+ truncated = true;
217
+ continue;
218
+ }
219
+ const existing = nodes.get(next);
220
+ if (!existing || Math.abs(nextHop) < Math.abs(existing.hop)) {
221
+ nodes.set(next, { file: next, hop: sign * nextHop, via: [edge] });
222
+ } else if (Math.abs(nextHop) === Math.abs(existing.hop)) {
223
+ existing.via.push(edge);
224
+ }
225
+ queue.push({ file: next, hop: nextHop });
226
+ }
227
+ }
228
+ };
229
+
230
+ if (wantRev) bfs('rev');
231
+ if (wantDeps) bfs('fwd');
232
+
233
+ // Detect hop-clamp truncation: any visited node at the cap whose adjacency
234
+ // still has unvisited neighbors would have produced more nodes.
235
+ const frontierCheck = (side) => {
236
+ const adjacency = side === 'rev' ? reverse : forward;
237
+ for (const [key, hop] of sideHop) {
238
+ if (hop !== depthLimit) continue;
239
+ const sep = key.lastIndexOf('|');
240
+ const file = key.slice(0, sep);
241
+ if (key.slice(sep + 1) !== side) continue;
242
+ for (const edge of adjacency.get(file) ?? []) {
243
+ if (!visited.has(`${edge.to}|${side}`)) return true;
244
+ }
245
+ }
246
+ return false;
247
+ };
248
+ if (frontierCheck('rev') || (wantDeps && frontierCheck('fwd'))) truncated = true;
249
+
250
+ return { nodes: [...nodes.values()], edges, truncated, omitted };
251
+ }
@@ -0,0 +1,150 @@
1
+ import path from 'node:path';
2
+ import { getIndexDir } from '../../index/paths.js';
3
+ import { ensureDir, readJsonIfExists, withFileLock, writeJson } from '../fileOps.js';
4
+
5
+ /**
6
+ * Post-edit invalidation (SPEC §6 — CI-205).
7
+ *
8
+ * `.cache/index/dirty.json` is the shared on-disk contract between the edit
9
+ * lane (`notifyEdit`), the freshness lane (TASK-204 reads the file directly),
10
+ * the compiler (TASK-206 reads via `readDirty`, non-destructive), and the
11
+ * index-refresh lane (`drainDirty`).
12
+ *
13
+ * File shape: `{ schemaVersion: 1, paths: string[], saturated: boolean, updatedAt: string }`
14
+ * - `paths`: sorted, deduped, posix repo-relative paths.
15
+ * - `saturated`: true once the set hits `DIRTY_MAX_PATHS`; consumers treat a
16
+ * saturated set as full invalidation (freshness falls back to full scan).
17
+ *
18
+ * Writes are atomic (tmp+rename via `writeJson`). `notifyEdit` merges via
19
+ * set-union inside a file lock (`withFileLock`) so concurrent callers never
20
+ * lose updates; read-merge-write retry (max 3) is kept as a fallback when the
21
+ * lock itself cannot be acquired. Missing/corrupt file → `[]`, never throws.
22
+ */
23
+
24
+ export const DIRTY_SCHEMA_VERSION = 1;
25
+ export const DIRTY_MAX_PATHS = 5000;
26
+
27
+ const MERGE_MAX_ATTEMPTS = 3;
28
+
29
+ export function getDirtyPath(projectRoot) {
30
+ return path.join(getIndexDir(projectRoot), 'dirty.json');
31
+ }
32
+
33
+ /**
34
+ * Normalize a candidate dirty path to a posix repo-relative form.
35
+ * Returns null for unsafe input: absolute paths, `..` escapes, empties.
36
+ */
37
+ function normalizeDirtyPath(relPath) {
38
+ if (typeof relPath !== 'string') return null;
39
+ let p = relPath.trim().replace(/\\/g, '/');
40
+ if (!p) return null;
41
+ if (p.startsWith('/') || /^[A-Za-z]:\//.test(p)) return null; // absolute
42
+ while (p.startsWith('./')) p = p.slice(2);
43
+ if (!p || p === '.' ) return null;
44
+ const segments = p.split('/');
45
+ if (segments.some((s) => s === '..')) return null;
46
+ const normalized = path.posix.normalize(p);
47
+ if (!normalized || normalized === '.' || normalized.startsWith('../') || normalized === '..') {
48
+ return null;
49
+ }
50
+ return normalized;
51
+ }
52
+
53
+ function parseDirtyData(data) {
54
+ if (!data || typeof data !== 'object') return { paths: [], saturated: false };
55
+ const paths = Array.isArray(data.paths)
56
+ ? data.paths.filter((p) => typeof p === 'string')
57
+ : [];
58
+ return { paths, saturated: data.saturated === true };
59
+ }
60
+
61
+ async function readDirtyData(projectRoot) {
62
+ try {
63
+ const data = await readJsonIfExists(getDirtyPath(projectRoot));
64
+ return parseDirtyData(data);
65
+ } catch {
66
+ return { paths: [], saturated: false };
67
+ }
68
+ }
69
+
70
+ async function writeDirtyData(projectRoot, paths, saturated) {
71
+ const dirtyPath = getDirtyPath(projectRoot);
72
+ await ensureDir(path.dirname(dirtyPath));
73
+ await writeJson(dirtyPath, {
74
+ schemaVersion: DIRTY_SCHEMA_VERSION,
75
+ paths,
76
+ saturated,
77
+ updatedAt: new Date().toISOString(),
78
+ });
79
+ }
80
+
81
+ /**
82
+ * Merge `relPaths` into the dirty set. Atomic, merge-safe (read-merge-write,
83
+ * retry max 3 on write failure), saturating at DIRTY_MAX_PATHS.
84
+ * @returns {Promise<{ dirty: string[] }>} sorted resulting set
85
+ */
86
+ export async function notifyEdit(projectRoot, relPaths) {
87
+ const incoming = new Set();
88
+ for (const relPath of Array.isArray(relPaths) ? relPaths : []) {
89
+ const normalized = normalizeDirtyPath(relPath);
90
+ if (normalized) incoming.add(normalized);
91
+ }
92
+
93
+ const mergeOnce = async () => {
94
+ const current = await readDirtyData(projectRoot);
95
+ const merged = new Set(current.paths);
96
+ for (const p of incoming) merged.add(p);
97
+ const saturated = merged.size > DIRTY_MAX_PATHS;
98
+ const paths = [...merged].sort().slice(0, DIRTY_MAX_PATHS);
99
+ await writeDirtyData(projectRoot, paths, saturated);
100
+ return paths;
101
+ };
102
+
103
+ const dirtyPath = getDirtyPath(projectRoot);
104
+ let lastError = null;
105
+ for (let attempt = 0; attempt < MERGE_MAX_ATTEMPTS; attempt += 1) {
106
+ try {
107
+ const paths = await withFileLock(dirtyPath, mergeOnce);
108
+ return { dirty: paths };
109
+ } catch (error) {
110
+ lastError = error;
111
+ // Fall back to unlocked merge if locking is unavailable on this fs.
112
+ if (attempt === MERGE_MAX_ATTEMPTS - 1) break;
113
+ try {
114
+ const paths = await mergeOnce();
115
+ return { dirty: paths };
116
+ } catch (error2) {
117
+ lastError = error2;
118
+ }
119
+ }
120
+ }
121
+ // Retry budget exhausted — surface the last write error's message without
122
+ // throwing past the never-throw contract boundary: return current state.
123
+ void lastError;
124
+ const current = await readDirtyData(projectRoot);
125
+ return { dirty: [...current.paths].sort() };
126
+ }
127
+
128
+ /**
129
+ * Non-destructive read of the dirty set (compiler lane uses this).
130
+ * @returns {Promise<string[]>} sorted paths, `[]` on missing/corrupt.
131
+ */
132
+ export async function readDirty(projectRoot) {
133
+ const { paths } = await readDirtyData(projectRoot);
134
+ return [...paths].sort();
135
+ }
136
+
137
+ /**
138
+ * Return the accumulated dirty set and clear the file (index-refresh lane).
139
+ * @returns {Promise<string[]>} sorted paths, `[]` on missing/corrupt.
140
+ */
141
+ export async function drainDirty(projectRoot) {
142
+ const { paths, saturated } = await readDirtyData(projectRoot);
143
+ if (paths.length === 0 && !saturated) return [];
144
+ try {
145
+ await writeDirtyData(projectRoot, [], false);
146
+ } catch {
147
+ // never throw — drained set is still returned
148
+ }
149
+ return [...paths].sort();
150
+ }