@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.
- package/CHANGELOG.md +52 -0
- package/manifests/documentation.yaml +77 -6
- package/manifests/hostCapabilities.yaml +49 -0
- package/manifests/instructionRules.yaml +7 -0
- package/package.json +1 -1
- package/scripts/bench/goldTasks.json +38 -0
- package/scripts/bench/runGold.mjs +220 -0
- package/scripts/index/build-index.mjs +2 -1
- package/scripts/index/query-index.mjs +2 -0
- package/scripts/release/verify-release.mjs +6 -0
- package/src/cli/commands/code.js +182 -0
- package/src/cli/commands/doctor.js +35 -3
- package/src/cli/commands/indexTools.js +107 -1
- package/src/cli/commands/install.js +2 -1
- package/src/cli/commands/memory.js +137 -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 +389 -0
- package/src/core/codeintel/diagnostics.js +114 -0
- package/src/core/codeintel/freshness.js +295 -0
- package/src/core/codeintel/graph.js +291 -0
- package/src/core/codeintel/impact.js +274 -0
- package/src/core/codeintel/invalidation.js +150 -0
- package/src/core/codeintel/manifest.js +176 -0
- package/src/core/codeintel/packet.js +147 -0
- package/src/core/codeintel/providers.js +201 -0
- package/src/core/codeintel/retriever.js +418 -0
- package/src/core/codeintel/router.js +149 -0
- package/src/core/codeintel/semanticProvider.js +235 -0
- package/src/core/codeintel/summaries.js +194 -0
- package/src/core/codeintel/vectorProvider.js +213 -0
- package/src/core/docContracts.js +723 -0
- package/src/core/memory/migrate.js +324 -0
- package/src/core/memory/records.js +172 -0
- package/src/core/memory/retrieval.js +161 -11
- package/src/core/memory/store.js +398 -0
- package/src/core/memory/storeV2.js +171 -0
- package/src/core/memory/storeV2Loader.js +22 -0
- package/src/core/runtimeConfig.js +173 -0
- package/src/core/runtimePaths.js +3 -0
- package/src/index/buildIndex.js +29 -0
- package/src/index/paths.js +2 -0
- package/src/index/taskRouting.js +39 -0
- package/templates/.claude/ukit/index/route-task.mjs +40 -0
- package/templates/AGENTS.md +46 -99
- package/templates/CLAUDE.md +46 -99
- package/templates/docs/AI_HANDOFF/tasks/_TEMPLATE.md +5 -0
- package/templates/docs/BUGFIX.md +2 -19
- package/templates/docs/BUG_INDEX.md +43 -0
- package/templates/docs/BUG_METRICS.md +1 -5
- package/templates/docs/BUG_TEMPLATE.md +1 -11
- package/templates/docs/UKIT_INTERNALS.md +4 -0
- package/templates/instructions/core.md +46 -99
- package/templates/ukit/storage/config.json +35 -0
|
@@ -0,0 +1,274 @@
|
|
|
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
|
+
import { readCoChanges, coChangeEdges } from './cochange.js';
|
|
7
|
+
|
|
8
|
+
// Multi-hop impact/trace engine (SPEC §4) — cycle-safe BFS over imports.json +
|
|
9
|
+
// calls.json edges. `dependents` walks reverse edges (who imports the seed),
|
|
10
|
+
// `dependencies` walks forward edges (what the seed imports/calls), `both`
|
|
11
|
+
// unions the two with signed hops. Never throws: missing artifacts degrade to
|
|
12
|
+
// an empty result with an `omitted` reason.
|
|
13
|
+
|
|
14
|
+
const DEFAULT_DEPTH = 2;
|
|
15
|
+
const DEFAULT_MAX_DEPTH = 4;
|
|
16
|
+
const DEFAULT_MAX_NODES = 200;
|
|
17
|
+
|
|
18
|
+
async function readArtifact(rootDir, name) {
|
|
19
|
+
try {
|
|
20
|
+
const raw = await fs.readFile(getArtifactPath(rootDir, name), 'utf8');
|
|
21
|
+
const parsed = JSON.parse(raw);
|
|
22
|
+
if (parsed?.schemaVersion !== undefined && parsed.schemaVersion !== INDEX_SCHEMA_VERSION) {
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
return parsed;
|
|
26
|
+
} catch {
|
|
27
|
+
return null;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Local copy of the specifier resolver (SPEC §4 — do NOT import retriever.js,
|
|
32
|
+
// a same-wave file). Resolves a relative/bare import specifier against the
|
|
33
|
+
// indexed file set.
|
|
34
|
+
function resolveRelativeSpecifier(fromFile, specifier, fileSet) {
|
|
35
|
+
const candidates = [];
|
|
36
|
+
if (specifier.startsWith('.')) {
|
|
37
|
+
const base = path.posix.normalize(path.posix.join(path.posix.dirname(fromFile), specifier));
|
|
38
|
+
candidates.push(base, `${base}.js`, `${base}.ts`, `${base}.mjs`, `${base}.jsx`, `${base}.tsx`, `${base}/index.js`, `${base}/index.ts`);
|
|
39
|
+
} else {
|
|
40
|
+
candidates.push(specifier);
|
|
41
|
+
}
|
|
42
|
+
for (const candidate of candidates) {
|
|
43
|
+
if (fileSet.has(candidate)) return candidate;
|
|
44
|
+
}
|
|
45
|
+
return null;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function impactDefaults(config) {
|
|
49
|
+
const impact = config?.codeIntel?.impact;
|
|
50
|
+
return {
|
|
51
|
+
defaultDepth: typeof impact?.defaultDepth === 'number' ? impact.defaultDepth : DEFAULT_DEPTH,
|
|
52
|
+
maxDepth: typeof impact?.maxDepth === 'number' ? impact.maxDepth : DEFAULT_MAX_DEPTH,
|
|
53
|
+
maxNodes: typeof impact?.maxNodes === 'number' ? impact.maxNodes : DEFAULT_MAX_NODES,
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function clampInt(value, min, max) {
|
|
58
|
+
const n = Math.floor(Number(value));
|
|
59
|
+
if (!Number.isFinite(n)) return min;
|
|
60
|
+
return Math.min(Math.max(n, min), max);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function addAdjacency(map, key, edge) {
|
|
64
|
+
let list = map.get(key);
|
|
65
|
+
if (!list) {
|
|
66
|
+
list = [];
|
|
67
|
+
map.set(key, list);
|
|
68
|
+
}
|
|
69
|
+
list.push(edge);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* impactSet(projectRoot, seeds, { depth?, direction?, maxDepth?, maxNodes? })
|
|
74
|
+
* → { nodes: [{ file, hop, via: edge[] }],
|
|
75
|
+
* edges: [{ from, to, kind:'import'|'call', symbol? }],
|
|
76
|
+
* truncated: boolean,
|
|
77
|
+
* omitted: [{ what, why }] }
|
|
78
|
+
*/
|
|
79
|
+
export async function impactSet(projectRoot, seeds, {
|
|
80
|
+
depth,
|
|
81
|
+
direction = 'dependents',
|
|
82
|
+
maxDepth,
|
|
83
|
+
maxNodes,
|
|
84
|
+
} = {}) {
|
|
85
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
86
|
+
const omitted = [];
|
|
87
|
+
const empty = { nodes: [], edges: [], truncated: false, omitted };
|
|
88
|
+
|
|
89
|
+
let config = null;
|
|
90
|
+
try {
|
|
91
|
+
config = await loadRuntimeConfig(rootDir);
|
|
92
|
+
} catch {
|
|
93
|
+
config = null;
|
|
94
|
+
}
|
|
95
|
+
const defaults = impactDefaults(config);
|
|
96
|
+
|
|
97
|
+
const maxDepthCap = clampInt(maxDepth ?? defaults.maxDepth, 1, Number.MAX_SAFE_INTEGER);
|
|
98
|
+
const nodeCap = clampInt(maxNodes ?? defaults.maxNodes, 1, Number.MAX_SAFE_INTEGER);
|
|
99
|
+
const requestedDepth = typeof depth === 'number' ? depth : defaults.defaultDepth;
|
|
100
|
+
const depthLimit = clampInt(requestedDepth, 1, maxDepthCap);
|
|
101
|
+
const depthClamped = requestedDepth > depthLimit;
|
|
102
|
+
|
|
103
|
+
const seedList = Array.isArray(seeds) ? seeds.filter((s) => typeof s === 'string' && s.length > 0) : [];
|
|
104
|
+
if (seedList.length === 0) {
|
|
105
|
+
omitted.push({ what: 'impact', why: 'empty-seeds' });
|
|
106
|
+
return empty;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const [filesArtifact, importsArtifact, callsArtifact] = await Promise.all([
|
|
110
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.files),
|
|
111
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.imports),
|
|
112
|
+
readArtifact(rootDir, INDEX_ARTIFACTS.calls),
|
|
113
|
+
]);
|
|
114
|
+
|
|
115
|
+
if (!importsArtifact && !callsArtifact) {
|
|
116
|
+
omitted.push({ what: 'impact', why: 'index-missing' });
|
|
117
|
+
return empty;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// File set for specifier resolution — files.json first, with imports' `from`
|
|
121
|
+
// endpoints as fallback so a partial index still resolves.
|
|
122
|
+
const fileSet = new Set((filesArtifact?.items ?? []).map((f) => f.filePath).filter(Boolean));
|
|
123
|
+
for (const imp of importsArtifact?.items ?? []) {
|
|
124
|
+
if (imp?.from) fileSet.add(imp.from);
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Adjacency: forward = dependencies (from → to), reverse = dependents (to → from).
|
|
128
|
+
const forward = new Map();
|
|
129
|
+
const reverse = new Map();
|
|
130
|
+
for (const imp of importsArtifact?.items ?? []) {
|
|
131
|
+
if (!imp?.from || !imp?.to) continue;
|
|
132
|
+
const target = resolveRelativeSpecifier(imp.from, imp.to, fileSet);
|
|
133
|
+
if (!target || target === imp.from) continue;
|
|
134
|
+
const edge = { from: imp.from, to: target, kind: 'import' };
|
|
135
|
+
addAdjacency(forward, edge.from, edge);
|
|
136
|
+
// Reverse edges are flipped so `from`/`to` always read seed→impacted.
|
|
137
|
+
addAdjacency(reverse, edge.to, { from: edge.to, to: edge.from, kind: 'import' });
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// calls.json → symbol map; call edges are forward-only (dependencies side).
|
|
141
|
+
const symbolToFile = new Map();
|
|
142
|
+
for (const call of callsArtifact?.items ?? []) {
|
|
143
|
+
if (call?.filePath && call?.symbol && !symbolToFile.has(call.symbol)) {
|
|
144
|
+
symbolToFile.set(call.symbol, call.filePath);
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
const wantDeps = direction === 'dependencies' || direction === 'both';
|
|
148
|
+
const wantRev = direction === 'dependents' || direction === 'both';
|
|
149
|
+
if (wantDeps) {
|
|
150
|
+
for (const call of callsArtifact?.items ?? []) {
|
|
151
|
+
if (!call?.filePath) continue;
|
|
152
|
+
for (const name of call.calls ?? []) {
|
|
153
|
+
const target = symbolToFile.get(name);
|
|
154
|
+
if (!target || target === call.filePath) continue;
|
|
155
|
+
addAdjacency(forward, call.filePath, { from: call.filePath, to: target, kind: 'call', symbol: name });
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Resolve seeds → files. File seeds match indexed paths; symbol seeds match
|
|
161
|
+
// calls.json `symbol` fields (their filePath becomes the node).
|
|
162
|
+
const seedFiles = [];
|
|
163
|
+
for (const seed of seedList) {
|
|
164
|
+
if (fileSet.has(seed) || forward.has(seed) || reverse.has(seed)) {
|
|
165
|
+
seedFiles.push(seed);
|
|
166
|
+
continue;
|
|
167
|
+
}
|
|
168
|
+
const viaSymbol = symbolToFile.get(seed);
|
|
169
|
+
if (viaSymbol) {
|
|
170
|
+
seedFiles.push(viaSymbol);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
if (seedFiles.length === 0) {
|
|
174
|
+
omitted.push({ what: 'impact', why: 'seeds-unmatched' });
|
|
175
|
+
return empty;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const nodes = new Map(); // file → { file, hop, via }
|
|
179
|
+
const edges = [];
|
|
180
|
+
const edgeKeys = new Set();
|
|
181
|
+
const visited = new Set(); // `${file}|${side}`
|
|
182
|
+
const sideHop = new Map(); // `${file}|${side}` → unsigned hop
|
|
183
|
+
let truncated = false;
|
|
184
|
+
|
|
185
|
+
const emitEdge = (edge) => {
|
|
186
|
+
const key = `${edge.from}|${edge.to}|${edge.kind}|${edge.symbol ?? ''}`;
|
|
187
|
+
if (edgeKeys.has(key)) return;
|
|
188
|
+
edgeKeys.add(key);
|
|
189
|
+
edges.push(edge);
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
// side: 'rev' (dependents, positive hop) | 'fwd' (dependencies, negative hop)
|
|
193
|
+
const bfs = (side) => {
|
|
194
|
+
const adjacency = side === 'rev' ? reverse : forward;
|
|
195
|
+
const sign = side === 'rev' ? 1 : -1;
|
|
196
|
+
const queue = [];
|
|
197
|
+
for (const seed of seedFiles) {
|
|
198
|
+
const key = `${seed}|${side}`;
|
|
199
|
+
if (!visited.has(key)) {
|
|
200
|
+
visited.add(key);
|
|
201
|
+
sideHop.set(key, 0);
|
|
202
|
+
queue.push({ file: seed, hop: 0 });
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
while (queue.length > 0) {
|
|
206
|
+
const { file, hop } = queue.shift();
|
|
207
|
+
if (hop >= depthLimit) continue;
|
|
208
|
+
for (const edge of adjacency.get(file) ?? []) {
|
|
209
|
+
const next = edge.to;
|
|
210
|
+
emitEdge(edge);
|
|
211
|
+
const visitKey = `${next}|${side}`;
|
|
212
|
+
if (visited.has(visitKey)) continue;
|
|
213
|
+
visited.add(visitKey);
|
|
214
|
+
const nextHop = hop + 1;
|
|
215
|
+
sideHop.set(visitKey, nextHop);
|
|
216
|
+
if (nodes.size >= nodeCap && !nodes.has(next)) {
|
|
217
|
+
truncated = true;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
const existing = nodes.get(next);
|
|
221
|
+
if (!existing || Math.abs(nextHop) < Math.abs(existing.hop)) {
|
|
222
|
+
nodes.set(next, { file: next, hop: sign * nextHop, via: [edge] });
|
|
223
|
+
} else if (Math.abs(nextHop) === Math.abs(existing.hop)) {
|
|
224
|
+
existing.via.push(edge);
|
|
225
|
+
}
|
|
226
|
+
queue.push({ file: next, hop: nextHop });
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
};
|
|
230
|
+
|
|
231
|
+
if (wantRev) bfs('rev');
|
|
232
|
+
if (wantDeps) bfs('fwd');
|
|
233
|
+
|
|
234
|
+
// Detect hop-clamp truncation: any visited node at the cap whose adjacency
|
|
235
|
+
// still has unvisited neighbors would have produced more nodes.
|
|
236
|
+
const frontierCheck = (side) => {
|
|
237
|
+
const adjacency = side === 'rev' ? reverse : forward;
|
|
238
|
+
for (const [key, hop] of sideHop) {
|
|
239
|
+
if (hop !== depthLimit) continue;
|
|
240
|
+
const sep = key.lastIndexOf('|');
|
|
241
|
+
const file = key.slice(0, sep);
|
|
242
|
+
if (key.slice(sep + 1) !== side) continue;
|
|
243
|
+
for (const edge of adjacency.get(file) ?? []) {
|
|
244
|
+
if (!visited.has(`${edge.to}|${side}`)) return true;
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return false;
|
|
248
|
+
};
|
|
249
|
+
if (frontierCheck('rev') || (wantDeps && frontierCheck('fwd'))) truncated = true;
|
|
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
|
+
|
|
273
|
+
return { nodes: [...nodes.values()], edges, truncated, omitted };
|
|
274
|
+
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import crypto from 'node:crypto';
|
|
4
|
+
|
|
5
|
+
import { getIndexDir } from '../../index/paths.js';
|
|
6
|
+
|
|
7
|
+
// Manifest scanner (SPEC §1) — walks the project, records a sha1 per file, and
|
|
8
|
+
// caches the result at `getIndexDir()/manifest.json`. The cache is validated on
|
|
9
|
+
// (path, mtime, size) so an unchanged tree never re-hashes, and any mtime/size
|
|
10
|
+
// drift forces a full rescan.
|
|
11
|
+
|
|
12
|
+
const HARD_EXCLUDES = new Set(['.git', '.cache', 'node_modules']);
|
|
13
|
+
const MANIFEST_CACHE = 'manifest.json';
|
|
14
|
+
|
|
15
|
+
const IGNORED_BASENAMES = new Set(['.DS_Store']);
|
|
16
|
+
|
|
17
|
+
function sha1(content) {
|
|
18
|
+
return crypto.createHash('sha1').update(content).digest('hex');
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// Minimal .gitignore subset: blank lines and `#` comments skipped, `!` negation
|
|
22
|
+
// unsupported (documented — negations are rare in-repo and the safe failure mode
|
|
23
|
+
// is scanning a file that should be ignored, never dropping a needed one).
|
|
24
|
+
// Supported: `name` (basename match anywhere), `dir/` (directory), `/path`
|
|
25
|
+
// (root-anchored), `*` single-segment wildcard.
|
|
26
|
+
function parseGitignore(content) {
|
|
27
|
+
return String(content ?? '')
|
|
28
|
+
.split(/\r?\n/)
|
|
29
|
+
.map((line) => line.trim())
|
|
30
|
+
.filter((line) => line.length > 0 && !line.startsWith('#') && !line.startsWith('!'));
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function gitignorePatternToRegExp(pattern) {
|
|
34
|
+
let anchored = false;
|
|
35
|
+
let dirOnly = false;
|
|
36
|
+
let body = pattern;
|
|
37
|
+
if (body.endsWith('/')) {
|
|
38
|
+
dirOnly = true;
|
|
39
|
+
body = body.slice(0, -1);
|
|
40
|
+
}
|
|
41
|
+
if (body.startsWith('/')) {
|
|
42
|
+
anchored = true;
|
|
43
|
+
body = body.slice(1);
|
|
44
|
+
}
|
|
45
|
+
const escaped = body
|
|
46
|
+
.split('*')
|
|
47
|
+
.map((part) => part.replace(/[.+^${}()|[\]\\]/g, '\\$&'))
|
|
48
|
+
.join('[^/]*');
|
|
49
|
+
if (anchored || body.includes('/')) {
|
|
50
|
+
return { re: new RegExp(`^${escaped}(?:/.*)?$`), dirOnly };
|
|
51
|
+
}
|
|
52
|
+
return { re: new RegExp(`(?:^|/)${escaped}(?:/.*)?$`), dirOnly };
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function loadIgnoreMatchers(rootDir, extraIgnore) {
|
|
56
|
+
return fs.readFile(path.join(rootDir, '.gitignore'), 'utf8')
|
|
57
|
+
.then((content) => [...parseGitignore(content), ...(extraIgnore ?? [])])
|
|
58
|
+
.then((patterns) => patterns.map(gitignorePatternToRegExp))
|
|
59
|
+
.catch(() => (extraIgnore ?? []).map(gitignorePatternToRegExp));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isIgnored(relPath, isDir, matchers) {
|
|
63
|
+
return matchers.some(({ re, dirOnly }) => (!dirOnly || isDir) && re.test(relPath));
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function walk(rootDir, matchers, options) {
|
|
67
|
+
const results = [];
|
|
68
|
+
const stack = [''];
|
|
69
|
+
while (stack.length > 0) {
|
|
70
|
+
const relDir = stack.pop();
|
|
71
|
+
const absDir = path.join(rootDir, relDir);
|
|
72
|
+
let entries;
|
|
73
|
+
try {
|
|
74
|
+
entries = await fs.readdir(absDir, { withFileTypes: true });
|
|
75
|
+
} catch {
|
|
76
|
+
continue;
|
|
77
|
+
}
|
|
78
|
+
for (const entry of entries) {
|
|
79
|
+
const relPath = relDir ? `${relDir}/${entry.name}` : entry.name;
|
|
80
|
+
if (entry.isDirectory()) {
|
|
81
|
+
if (HARD_EXCLUDES.has(entry.name)) {
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
if (isIgnored(relPath, true, matchers)) {
|
|
85
|
+
continue;
|
|
86
|
+
}
|
|
87
|
+
stack.push(relPath);
|
|
88
|
+
} else if (entry.isFile()) {
|
|
89
|
+
if (IGNORED_BASENAMES.has(entry.name)) {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (isIgnored(relPath, false, matchers)) {
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (options?.filter && !options.filter(relPath)) {
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
try {
|
|
99
|
+
const stat = await fs.stat(path.join(rootDir, relPath));
|
|
100
|
+
results.push({ path: relPath, mtime: stat.mtimeMs, size: stat.size });
|
|
101
|
+
} catch {
|
|
102
|
+
// Vanished mid-walk — skip.
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
results.sort((a, b) => a.path.localeCompare(b.path));
|
|
108
|
+
return results;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
async function readCachedManifest(rootDir) {
|
|
112
|
+
try {
|
|
113
|
+
const raw = await fs.readFile(path.join(getIndexDir(rootDir), MANIFEST_CACHE), 'utf8');
|
|
114
|
+
const parsed = JSON.parse(raw);
|
|
115
|
+
if (!parsed || !Array.isArray(parsed.files)) {
|
|
116
|
+
return null;
|
|
117
|
+
}
|
|
118
|
+
return parsed;
|
|
119
|
+
} catch {
|
|
120
|
+
return null;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function snapshotMatches(cachedFiles, scannedFiles) {
|
|
125
|
+
if (!Array.isArray(cachedFiles) || cachedFiles.length !== scannedFiles.length) {
|
|
126
|
+
return false;
|
|
127
|
+
}
|
|
128
|
+
const byPath = new Map(cachedFiles.map((f) => [f.path, f]));
|
|
129
|
+
return scannedFiles.every((entry) => {
|
|
130
|
+
const cached = byPath.get(entry.path);
|
|
131
|
+
return cached && cached.mtime === entry.mtime && cached.size === entry.size
|
|
132
|
+
&& typeof cached.sha1 === 'string';
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* scanManifest(projectRoot, { ignore, filter }) →
|
|
138
|
+
* { files: [{ path, sha1, mtime, size }], generatedAt, fromCache }
|
|
139
|
+
*
|
|
140
|
+
* `ignore` adds .gitignore-style patterns on top of the repo .gitignore.
|
|
141
|
+
* `filter(relPath)` optionally keeps a subset of scanned files.
|
|
142
|
+
* Never throws on a missing/unreadable tree — returns an empty manifest.
|
|
143
|
+
*/
|
|
144
|
+
export async function scanManifest(projectRoot, { ignore, filter } = {}) {
|
|
145
|
+
const rootDir = path.resolve(projectRoot ?? process.cwd());
|
|
146
|
+
const matchers = await loadIgnoreMatchers(rootDir, ignore);
|
|
147
|
+
const scanned = await walk(rootDir, matchers, { filter });
|
|
148
|
+
|
|
149
|
+
const cached = await readCachedManifest(rootDir);
|
|
150
|
+
if (cached && snapshotMatches(cached.files, scanned)) {
|
|
151
|
+
return { files: cached.files, generatedAt: cached.generatedAt, fromCache: true };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const files = await Promise.all(scanned.map(async (entry) => {
|
|
155
|
+
let fileSha = null;
|
|
156
|
+
try {
|
|
157
|
+
const content = await fs.readFile(path.join(rootDir, entry.path));
|
|
158
|
+
fileSha = sha1(content);
|
|
159
|
+
} catch {
|
|
160
|
+
fileSha = null;
|
|
161
|
+
}
|
|
162
|
+
return { path: entry.path, sha1: fileSha, mtime: entry.mtime, size: entry.size };
|
|
163
|
+
}));
|
|
164
|
+
|
|
165
|
+
const manifest = { files, generatedAt: new Date().toISOString() };
|
|
166
|
+
try {
|
|
167
|
+
await fs.mkdir(getIndexDir(rootDir), { recursive: true });
|
|
168
|
+
await fs.writeFile(
|
|
169
|
+
path.join(getIndexDir(rootDir), MANIFEST_CACHE),
|
|
170
|
+
JSON.stringify({ files, generatedAt: manifest.generatedAt }, null, 2),
|
|
171
|
+
);
|
|
172
|
+
} catch {
|
|
173
|
+
// Cache write failure must not fail the scan.
|
|
174
|
+
}
|
|
175
|
+
return { ...manifest, fromCache: false };
|
|
176
|
+
}
|