@getripple/core 1.0.4
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 +18 -0
- package/README.md +53 -0
- package/dist/adapters.d.ts +39 -0
- package/dist/adapters.js +396 -0
- package/dist/adapters.js.map +1 -0
- package/dist/agent-workflow.d.ts +86 -0
- package/dist/agent-workflow.js +404 -0
- package/dist/agent-workflow.js.map +1 -0
- package/dist/approval.d.ts +45 -0
- package/dist/approval.js +272 -0
- package/dist/approval.js.map +1 -0
- package/dist/audit.d.ts +80 -0
- package/dist/audit.js +271 -0
- package/dist/audit.js.map +1 -0
- package/dist/change-intent.d.ts +242 -0
- package/dist/change-intent.js +1758 -0
- package/dist/change-intent.js.map +1 -0
- package/dist/graph.d.ts +346 -0
- package/dist/graph.js +4221 -0
- package/dist/graph.js.map +1 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +28 -0
- package/dist/index.js.map +1 -0
- package/dist/normalizer.d.ts +34 -0
- package/dist/normalizer.js +455 -0
- package/dist/normalizer.js.map +1 -0
- package/dist/policy.d.ts +55 -0
- package/dist/policy.js +380 -0
- package/dist/policy.js.map +1 -0
- package/dist/readiness.d.ts +35 -0
- package/dist/readiness.js +200 -0
- package/dist/readiness.js.map +1 -0
- package/dist/staged-check.d.ts +96 -0
- package/dist/staged-check.js +853 -0
- package/dist/staged-check.js.map +1 -0
- package/dist/types.d.ts +122 -0
- package/dist/types.js +71 -0
- package/dist/types.js.map +1 -0
- package/package.json +52 -0
package/dist/graph.js
ADDED
|
@@ -0,0 +1,4221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* graph.ts — Ripple
|
|
4
|
+
* Core dependency graph engine for Ripple.
|
|
5
|
+
*
|
|
6
|
+
* This file owns the local architectural model that powers:
|
|
7
|
+
* - Impact Lens file relationships
|
|
8
|
+
* - CodeLens caller counts
|
|
9
|
+
* - Safety Check blast-radius warnings
|
|
10
|
+
* - AI-agent context files under .ripple/
|
|
11
|
+
*
|
|
12
|
+
* Design constraints:
|
|
13
|
+
* - Keep generated machine-readable files in .ripple/.cache/.
|
|
14
|
+
* - Keep human-readable workflow/history files in .ripple/.
|
|
15
|
+
* - Treat the first scan as a baseline, not as user-created file events.
|
|
16
|
+
* - Preserve reverse edges when repairing stale cache entries.
|
|
17
|
+
* - Update agent instruction files only inside Ripple-managed markers.
|
|
18
|
+
* - Queue file events that arrive while a scan is already running.
|
|
19
|
+
*
|
|
20
|
+
* Path invariant:
|
|
21
|
+
* - graph.files keys use OS-native separators.
|
|
22
|
+
* - graph.symbols keys contain an OS-native file path followed by "::symbol".
|
|
23
|
+
* - ts-morph APIs use forward slash paths.
|
|
24
|
+
* - Use toTsMorphPath() before ts-morph calls.
|
|
25
|
+
* - Use toGraphPath() before storing normalized import paths.
|
|
26
|
+
*/
|
|
27
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
28
|
+
if (k2 === undefined) k2 = k;
|
|
29
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
30
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
31
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
32
|
+
}
|
|
33
|
+
Object.defineProperty(o, k2, desc);
|
|
34
|
+
}) : (function(o, m, k, k2) {
|
|
35
|
+
if (k2 === undefined) k2 = k;
|
|
36
|
+
o[k2] = m[k];
|
|
37
|
+
}));
|
|
38
|
+
var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
|
|
39
|
+
Object.defineProperty(o, "default", { enumerable: true, value: v });
|
|
40
|
+
}) : function(o, v) {
|
|
41
|
+
o["default"] = v;
|
|
42
|
+
});
|
|
43
|
+
var __importStar = (this && this.__importStar) || function (mod) {
|
|
44
|
+
if (mod && mod.__esModule) return mod;
|
|
45
|
+
var result = {};
|
|
46
|
+
if (mod != null) for (var k in mod) if (k !== "default" && Object.prototype.hasOwnProperty.call(mod, k)) __createBinding(result, mod, k);
|
|
47
|
+
__setModuleDefault(result, mod);
|
|
48
|
+
return result;
|
|
49
|
+
};
|
|
50
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
51
|
+
exports.GraphEngine = exports.HIGH_RISK_CALLER_COUNT = exports.CAUTION_CHURN = exports.DANGEROUS_CHURN = exports.CAUTION_BLAST_RADIUS = exports.DANGEROUS_BLAST_RADIUS = void 0;
|
|
52
|
+
const path = __importStar(require("path"));
|
|
53
|
+
const fs = __importStar(require("fs"));
|
|
54
|
+
const crypto = __importStar(require("crypto"));
|
|
55
|
+
const glob_1 = require("glob");
|
|
56
|
+
const ts_morph_1 = require("ts-morph");
|
|
57
|
+
const types_1 = require("./types");
|
|
58
|
+
const normalizer_1 = require("./normalizer");
|
|
59
|
+
const adapters_1 = require("./adapters");
|
|
60
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
61
|
+
// PERSISTENCE AND GENERATED CONTEXT
|
|
62
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
63
|
+
/**
|
|
64
|
+
* Handles durable project state:
|
|
65
|
+
* - .ripple/history.json for human-auditable change history
|
|
66
|
+
* - .ripple/.cache/graph.cache.json for fast startup
|
|
67
|
+
* - .ripple/.cache/context*.json and focus files for AI agents
|
|
68
|
+
* - .ripple/WORKFLOW.md for agent operating rules
|
|
69
|
+
*/
|
|
70
|
+
class GraphPersistence {
|
|
71
|
+
/**
|
|
72
|
+
* Pattern hints are derived from source files and cached between context
|
|
73
|
+
* generations. File changes and project-config rebuilds invalidate them.
|
|
74
|
+
*/
|
|
75
|
+
invalidatePatternCache() {
|
|
76
|
+
this.patternCache = null;
|
|
77
|
+
}
|
|
78
|
+
constructor(workspaceRoot) {
|
|
79
|
+
this.contextGenerationEnabled = true;
|
|
80
|
+
this.patternCache = null;
|
|
81
|
+
this.workspaceRoot = workspaceRoot;
|
|
82
|
+
const rippleDir = path.join(workspaceRoot, ".ripple");
|
|
83
|
+
if (!fs.existsSync(rippleDir)) {
|
|
84
|
+
fs.mkdirSync(rippleDir, { recursive: true });
|
|
85
|
+
}
|
|
86
|
+
const cacheDir = path.join(rippleDir, ".cache");
|
|
87
|
+
if (!fs.existsSync(cacheDir)) {
|
|
88
|
+
fs.mkdirSync(cacheDir, { recursive: true });
|
|
89
|
+
}
|
|
90
|
+
// Human-auditable files stay in .ripple/ root
|
|
91
|
+
this.persistPath = path.join(rippleDir, "history.json");
|
|
92
|
+
// All machine-generated files stay in .ripple/.cache/
|
|
93
|
+
this.cachePath = path.join(cacheDir, "graph.cache.json");
|
|
94
|
+
}
|
|
95
|
+
// ── HISTORY ───────────────────────────────────────────────────────────────
|
|
96
|
+
/**
|
|
97
|
+
* Loads historical change events from disk. Corrupt history should not block
|
|
98
|
+
* the extension from starting, so parse failures fall back to an empty log.
|
|
99
|
+
*/
|
|
100
|
+
load(log) {
|
|
101
|
+
if (!fs.existsSync(this.persistPath)) {
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
try {
|
|
105
|
+
const raw = fs.readFileSync(this.persistPath, "utf8");
|
|
106
|
+
const events = JSON.parse(raw);
|
|
107
|
+
events.forEach((e) => {
|
|
108
|
+
log.log({
|
|
109
|
+
...e,
|
|
110
|
+
source: this.historyEntityFromDisk(e.source) ?? e.source,
|
|
111
|
+
target: this.historyEntityFromDisk(e.target),
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
}
|
|
115
|
+
catch {
|
|
116
|
+
console.warn("[Ripple] history.json could not be parsed — starting fresh.");
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
/**
|
|
120
|
+
* Persists bounded history. The baseline event is retained when possible so
|
|
121
|
+
* agents and users can distinguish initial project state from later edits.
|
|
122
|
+
*/
|
|
123
|
+
flush(log) {
|
|
124
|
+
try {
|
|
125
|
+
const MAX_EVENTS = 10000;
|
|
126
|
+
let eventsToWrite = log.events;
|
|
127
|
+
if (eventsToWrite.length > MAX_EVENTS) {
|
|
128
|
+
const baseline = eventsToWrite.find((e) => e.type === "baseline_snapshot");
|
|
129
|
+
const recent = eventsToWrite
|
|
130
|
+
.filter((e) => e.type !== "baseline_snapshot")
|
|
131
|
+
.slice(-(MAX_EVENTS - 1));
|
|
132
|
+
eventsToWrite = baseline ? [baseline, ...recent] : recent;
|
|
133
|
+
}
|
|
134
|
+
const serializedEvents = eventsToWrite.map((event) => ({
|
|
135
|
+
...event,
|
|
136
|
+
source: this.historyEntityToDisk(event.source) ?? event.source,
|
|
137
|
+
target: this.historyEntityToDisk(event.target),
|
|
138
|
+
}));
|
|
139
|
+
fs.writeFileSync(this.persistPath, JSON.stringify(serializedEvents, null, 2));
|
|
140
|
+
}
|
|
141
|
+
catch (err) {
|
|
142
|
+
console.error("[Ripple] HistoryLog flush failed:", err);
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
// ── GRAPH CACHE ────────────────────────────────────────────────────────────
|
|
146
|
+
/**
|
|
147
|
+
* Stores the in-memory graph in a compact cache file. Sets are serialized as
|
|
148
|
+
* arrays and rebuilt during loadCache().
|
|
149
|
+
*/
|
|
150
|
+
saveCache(graph) {
|
|
151
|
+
try {
|
|
152
|
+
const files = {};
|
|
153
|
+
graph.files.forEach((node, filePath) => {
|
|
154
|
+
files[filePath] = {
|
|
155
|
+
path: node.path,
|
|
156
|
+
imports: Array.from(node.imports),
|
|
157
|
+
importedBy: Array.from(node.importedBy),
|
|
158
|
+
symbols: Array.from(node.symbols),
|
|
159
|
+
hash: node.hash,
|
|
160
|
+
createdAt: node.createdAt,
|
|
161
|
+
lastModifiedAt: node.lastModifiedAt,
|
|
162
|
+
changeCount: node.changeCount,
|
|
163
|
+
hasParseError: node.hasParseError ?? false,
|
|
164
|
+
};
|
|
165
|
+
});
|
|
166
|
+
const symbols = {};
|
|
167
|
+
graph.symbols.forEach((sym, symbolId) => {
|
|
168
|
+
symbols[symbolId] = {
|
|
169
|
+
id: sym.id,
|
|
170
|
+
name: sym.name,
|
|
171
|
+
file: sym.file,
|
|
172
|
+
kind: sym.kind,
|
|
173
|
+
layer: sym.layer,
|
|
174
|
+
containsLayers: sym.containsLayers,
|
|
175
|
+
symbolHash: sym.symbolHash,
|
|
176
|
+
calls: Array.from(sym.calls),
|
|
177
|
+
calledBy: Array.from(sym.calledBy),
|
|
178
|
+
createdAt: sym.createdAt,
|
|
179
|
+
lastModifiedAt: sym.lastModifiedAt,
|
|
180
|
+
};
|
|
181
|
+
});
|
|
182
|
+
fs.writeFileSync(this.cachePath, JSON.stringify({ files, symbols, savedAt: Date.now() }, null, 0));
|
|
183
|
+
}
|
|
184
|
+
catch (err) {
|
|
185
|
+
console.warn("[Ripple] Cache save failed:", err);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* Restores graph nodes from cache and returns files whose content hash changed
|
|
190
|
+
* since the cache was written. Stale files are repaired by the scan phase.
|
|
191
|
+
*/
|
|
192
|
+
loadCache(graph) {
|
|
193
|
+
try {
|
|
194
|
+
if (!fs.existsSync(this.cachePath)) {
|
|
195
|
+
return [];
|
|
196
|
+
}
|
|
197
|
+
const raw = fs.readFileSync(this.cachePath, "utf8");
|
|
198
|
+
const data = JSON.parse(raw);
|
|
199
|
+
const staleFiles = [];
|
|
200
|
+
const loadedFilePaths = new Set();
|
|
201
|
+
Object.entries(data.files).forEach(([filePath, node]) => {
|
|
202
|
+
if (!fs.existsSync(filePath)) {
|
|
203
|
+
return;
|
|
204
|
+
}
|
|
205
|
+
const currentContent = (() => {
|
|
206
|
+
try {
|
|
207
|
+
return fs.readFileSync(filePath, "utf8");
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return null;
|
|
211
|
+
}
|
|
212
|
+
})();
|
|
213
|
+
if (currentContent === null) {
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
const currentHash = crypto
|
|
217
|
+
.createHash("sha1")
|
|
218
|
+
.update(currentContent)
|
|
219
|
+
.digest("hex");
|
|
220
|
+
const fileNode = {
|
|
221
|
+
path: node.path,
|
|
222
|
+
imports: new Set(node.imports),
|
|
223
|
+
importedBy: new Set(node.importedBy),
|
|
224
|
+
symbols: new Set(node.symbols),
|
|
225
|
+
hash: node.hash,
|
|
226
|
+
createdAt: node.createdAt,
|
|
227
|
+
lastModifiedAt: node.lastModifiedAt,
|
|
228
|
+
changeCount: node.changeCount,
|
|
229
|
+
hasParseError: node.hasParseError ?? false,
|
|
230
|
+
};
|
|
231
|
+
graph.files.set(filePath, fileNode);
|
|
232
|
+
loadedFilePaths.add(filePath);
|
|
233
|
+
if (currentHash !== node.hash) {
|
|
234
|
+
staleFiles.push(filePath);
|
|
235
|
+
}
|
|
236
|
+
});
|
|
237
|
+
Object.entries(data.symbols).forEach(([symbolId, sym]) => {
|
|
238
|
+
if (!loadedFilePaths.has(sym.file)) {
|
|
239
|
+
return;
|
|
240
|
+
}
|
|
241
|
+
const symbolNode = {
|
|
242
|
+
id: sym.id,
|
|
243
|
+
name: sym.name,
|
|
244
|
+
file: sym.file,
|
|
245
|
+
kind: sym.kind,
|
|
246
|
+
layer: sym.layer,
|
|
247
|
+
containsLayers: sym.containsLayers,
|
|
248
|
+
symbolHash: sym.symbolHash,
|
|
249
|
+
calls: new Set(sym.calls.filter((id) => loadedFilePaths.has(id.split("::")[0]))),
|
|
250
|
+
calledBy: new Set(sym.calledBy.filter((id) => loadedFilePaths.has(id.split("::")[0]))),
|
|
251
|
+
createdAt: sym.createdAt,
|
|
252
|
+
lastModifiedAt: sym.lastModifiedAt,
|
|
253
|
+
};
|
|
254
|
+
graph.symbols.set(symbolId, symbolNode);
|
|
255
|
+
});
|
|
256
|
+
console.log(`[Ripple] Cache loaded — ${graph.files.size} files, ${staleFiles.length} stale`);
|
|
257
|
+
return staleFiles;
|
|
258
|
+
}
|
|
259
|
+
catch (err) {
|
|
260
|
+
console.warn("[Ripple] Cache load failed — full scan required:", err);
|
|
261
|
+
graph.files.clear();
|
|
262
|
+
graph.symbols.clear();
|
|
263
|
+
return [];
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
toProjectPath(filePath) {
|
|
267
|
+
return path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* History stays absolute in memory so graph comparisons are exact, but is
|
|
271
|
+
* written project-relative on disk to avoid leaking local workspace paths.
|
|
272
|
+
*/
|
|
273
|
+
historyEntityToDisk(entity) {
|
|
274
|
+
if (!entity) {
|
|
275
|
+
return entity;
|
|
276
|
+
}
|
|
277
|
+
const separator = entity.indexOf("::");
|
|
278
|
+
const filePath = separator === -1 ? entity : entity.slice(0, separator);
|
|
279
|
+
const suffix = separator === -1 ? "" : entity.slice(separator);
|
|
280
|
+
if (filePath === "initial_scan") {
|
|
281
|
+
return entity;
|
|
282
|
+
}
|
|
283
|
+
if (!path.isAbsolute(filePath)) {
|
|
284
|
+
return `${filePath.split(path.sep).join("/")}${suffix}`;
|
|
285
|
+
}
|
|
286
|
+
return `${this.toProjectPath(filePath)}${suffix}`;
|
|
287
|
+
}
|
|
288
|
+
historyEntityFromDisk(entity) {
|
|
289
|
+
if (!entity) {
|
|
290
|
+
return entity;
|
|
291
|
+
}
|
|
292
|
+
const separator = entity.indexOf("::");
|
|
293
|
+
const filePath = separator === -1 ? entity : entity.slice(0, separator);
|
|
294
|
+
const suffix = separator === -1 ? "" : entity.slice(separator);
|
|
295
|
+
if (filePath === "initial_scan" || path.isAbsolute(filePath)) {
|
|
296
|
+
return entity;
|
|
297
|
+
}
|
|
298
|
+
return `${path.join(this.workspaceRoot, filePath.split(/[\\/]/).join(path.sep))}${suffix}`;
|
|
299
|
+
}
|
|
300
|
+
/**
|
|
301
|
+
* Filters generated folders and build output from agent-facing context.
|
|
302
|
+
* The graph may know about some of these nodes, but agents should not treat
|
|
303
|
+
* them as source files to inspect or edit.
|
|
304
|
+
*/
|
|
305
|
+
isContextSourceFile(filePath) {
|
|
306
|
+
return (!filePath.includes("node_modules") &&
|
|
307
|
+
!filePath.includes(".ripple") &&
|
|
308
|
+
!filePath.includes(".next") &&
|
|
309
|
+
!filePath.includes(`${path.sep}dist${path.sep}`) &&
|
|
310
|
+
!filePath.includes(`${path.sep}out${path.sep}`) &&
|
|
311
|
+
!filePath.includes(".turbo"));
|
|
312
|
+
}
|
|
313
|
+
/**
|
|
314
|
+
* Converts dependency fan-out, edit churn, and parse quality into the risk
|
|
315
|
+
* vocabulary used consistently by focus files, WORKFLOW.md, and Safety Check.
|
|
316
|
+
*/
|
|
317
|
+
modificationRiskFor(node) {
|
|
318
|
+
const blastSize = node.importedBy.size;
|
|
319
|
+
if (blastSize >= exports.DANGEROUS_BLAST_RADIUS || node.changeCount > exports.DANGEROUS_CHURN) {
|
|
320
|
+
return "dangerous";
|
|
321
|
+
}
|
|
322
|
+
if (blastSize >= exports.CAUTION_BLAST_RADIUS ||
|
|
323
|
+
node.changeCount > exports.CAUTION_CHURN ||
|
|
324
|
+
node.hasParseError) {
|
|
325
|
+
return "caution";
|
|
326
|
+
}
|
|
327
|
+
return "safe";
|
|
328
|
+
}
|
|
329
|
+
focusPathFor(filePath, graph) {
|
|
330
|
+
return `.ripple/.cache/focus/${makeFocusKey(filePath, graph)}.json`;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Entry points often sit at the boundary of a feature or framework route.
|
|
334
|
+
* Marking them helps agents decide which callers deserve extra verification.
|
|
335
|
+
*/
|
|
336
|
+
isEntryPointFile(filePath) {
|
|
337
|
+
const base = path.basename(filePath);
|
|
338
|
+
return (base === "route.ts" ||
|
|
339
|
+
base === "route.tsx" ||
|
|
340
|
+
base === "page.tsx" ||
|
|
341
|
+
base === "page.ts" ||
|
|
342
|
+
filePath.includes(`${path.sep}pages${path.sep}api${path.sep}`) ||
|
|
343
|
+
filePath.includes(`${path.sep}app${path.sep}api${path.sep}`));
|
|
344
|
+
}
|
|
345
|
+
/**
|
|
346
|
+
* Builds both basename and full-path focus lookups. Basename lookups are only
|
|
347
|
+
* exposed when unambiguous; duplicate names in monorepos must use full paths.
|
|
348
|
+
*/
|
|
349
|
+
buildFocusLookup(graph) {
|
|
350
|
+
const candidates = [];
|
|
351
|
+
graph.files.forEach((node, filePath) => {
|
|
352
|
+
if (!this.isContextSourceFile(filePath)) {
|
|
353
|
+
return;
|
|
354
|
+
}
|
|
355
|
+
if (node.imports.size === 0 && node.importedBy.size === 0 && node.symbols.size === 0) {
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
candidates.push({
|
|
359
|
+
basename: path.basename(filePath),
|
|
360
|
+
relativePath: this.toProjectPath(filePath),
|
|
361
|
+
focusPath: this.focusPathFor(filePath, graph),
|
|
362
|
+
risk: this.modificationRiskFor(node),
|
|
363
|
+
importers: node.importedBy.size,
|
|
364
|
+
});
|
|
365
|
+
});
|
|
366
|
+
candidates.sort((a, b) => a.relativePath.localeCompare(b.relativePath));
|
|
367
|
+
const candidatesByPath = new Map(candidates.map((candidate) => [candidate.relativePath, candidate]));
|
|
368
|
+
const basenameBuckets = new Map();
|
|
369
|
+
candidates.forEach((candidate) => {
|
|
370
|
+
const bucket = basenameBuckets.get(candidate.basename) ?? [];
|
|
371
|
+
bucket.push(candidate.relativePath);
|
|
372
|
+
basenameBuckets.set(candidate.basename, bucket);
|
|
373
|
+
});
|
|
374
|
+
const availableFocusFiles = {};
|
|
375
|
+
const availableFocusFilesByPath = {};
|
|
376
|
+
const availableFocusFilesByBasename = {};
|
|
377
|
+
const ambiguousFocusFileNames = {};
|
|
378
|
+
const ambiguousFocusFileMatches = {};
|
|
379
|
+
candidates.forEach((candidate) => {
|
|
380
|
+
const value = `${candidate.focusPath} [${candidate.risk}]`;
|
|
381
|
+
availableFocusFilesByPath[candidate.relativePath] = value;
|
|
382
|
+
const bucket = basenameBuckets.get(candidate.basename) ?? [];
|
|
383
|
+
if (bucket.length === 1) {
|
|
384
|
+
availableFocusFiles[candidate.basename] = value;
|
|
385
|
+
}
|
|
386
|
+
});
|
|
387
|
+
basenameBuckets.forEach((relativePaths, basename) => {
|
|
388
|
+
const sortedPaths = relativePaths.sort();
|
|
389
|
+
const matches = sortedPaths.map((relativePath) => {
|
|
390
|
+
const candidate = candidatesByPath.get(relativePath);
|
|
391
|
+
return {
|
|
392
|
+
path: candidate.relativePath,
|
|
393
|
+
focus: candidate.focusPath,
|
|
394
|
+
risk: candidate.risk,
|
|
395
|
+
importers: candidate.importers,
|
|
396
|
+
};
|
|
397
|
+
});
|
|
398
|
+
if (relativePaths.length > 1) {
|
|
399
|
+
ambiguousFocusFileNames[basename] = sortedPaths;
|
|
400
|
+
ambiguousFocusFileMatches[basename] = matches;
|
|
401
|
+
availableFocusFilesByBasename[basename] = {
|
|
402
|
+
status: "ambiguous",
|
|
403
|
+
matches,
|
|
404
|
+
};
|
|
405
|
+
}
|
|
406
|
+
else if (matches[0]) {
|
|
407
|
+
availableFocusFilesByBasename[basename] = {
|
|
408
|
+
status: "unique",
|
|
409
|
+
...matches[0],
|
|
410
|
+
};
|
|
411
|
+
}
|
|
412
|
+
});
|
|
413
|
+
return {
|
|
414
|
+
availableFocusFiles,
|
|
415
|
+
availableFocusFilesByPath,
|
|
416
|
+
availableFocusFilesByBasename,
|
|
417
|
+
ambiguousFocusFileNames,
|
|
418
|
+
ambiguousFocusFileMatches,
|
|
419
|
+
};
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Creates the compact, file-specific context document agents should read
|
|
423
|
+
* before editing a target file. Large caller lists are intentionally truncated
|
|
424
|
+
* and paired with neighbor focus-file pointers to keep prompts manageable.
|
|
425
|
+
*/
|
|
426
|
+
buildFocusedContext(graph, filePath) {
|
|
427
|
+
const node = graph.files.get(filePath);
|
|
428
|
+
if (!node) {
|
|
429
|
+
return null;
|
|
430
|
+
}
|
|
431
|
+
if (!this.isContextSourceFile(filePath)) {
|
|
432
|
+
return null;
|
|
433
|
+
}
|
|
434
|
+
if (node.imports.size === 0 && node.importedBy.size === 0 && node.symbols.size === 0) {
|
|
435
|
+
return null;
|
|
436
|
+
}
|
|
437
|
+
const focusKey = makeFocusKey(filePath, graph);
|
|
438
|
+
const relativePath = this.toProjectPath(filePath);
|
|
439
|
+
const modificationRisk = this.modificationRiskFor(node);
|
|
440
|
+
const blastSize = node.importedBy.size;
|
|
441
|
+
const MAX_IMPORTEDBY = 10;
|
|
442
|
+
const MAX_CALLERS = 10;
|
|
443
|
+
const byRiskThenName = (a, b) => {
|
|
444
|
+
const aNode = graph.files.get(a);
|
|
445
|
+
const bNode = graph.files.get(b);
|
|
446
|
+
const aBlast = aNode?.importedBy.size ?? 0;
|
|
447
|
+
const bBlast = bNode?.importedBy.size ?? 0;
|
|
448
|
+
if (aBlast !== bBlast) {
|
|
449
|
+
return bBlast - aBlast;
|
|
450
|
+
}
|
|
451
|
+
return this.toProjectPath(a).localeCompare(this.toProjectPath(b));
|
|
452
|
+
};
|
|
453
|
+
const allImportedBy = Array.from(node.importedBy)
|
|
454
|
+
.filter((f) => this.isContextSourceFile(f))
|
|
455
|
+
.sort(byRiskThenName);
|
|
456
|
+
const importedBy = allImportedBy.slice(0, MAX_IMPORTEDBY).map((f) => {
|
|
457
|
+
const importerNode = graph.files.get(f);
|
|
458
|
+
return {
|
|
459
|
+
file: this.toProjectPath(f),
|
|
460
|
+
focus: this.focusPathFor(f, graph),
|
|
461
|
+
modificationRisk: importerNode ? this.modificationRiskFor(importerNode) : "safe",
|
|
462
|
+
...(this.isEntryPointFile(f) ? { isEntryPoint: true } : {}),
|
|
463
|
+
};
|
|
464
|
+
});
|
|
465
|
+
const allImports = Array.from(node.imports)
|
|
466
|
+
.filter((f) => this.isContextSourceFile(f))
|
|
467
|
+
.sort((a, b) => this.toProjectPath(a).localeCompare(this.toProjectPath(b)));
|
|
468
|
+
const imports = allImports.map((f) => this.toProjectPath(f));
|
|
469
|
+
const isBarrel = ["index.ts", "index.tsx", "index.js", "index.jsx"].includes(path.basename(filePath));
|
|
470
|
+
const reExports = isBarrel ? imports : [];
|
|
471
|
+
const toRelSymbol = (id) => {
|
|
472
|
+
const p = id.split("::");
|
|
473
|
+
if (p.length < 2) {
|
|
474
|
+
return id;
|
|
475
|
+
}
|
|
476
|
+
return `${this.toProjectPath(p[0])}::${p[1]}`;
|
|
477
|
+
};
|
|
478
|
+
const symbols = [];
|
|
479
|
+
node.symbols.forEach((symbolId) => {
|
|
480
|
+
const sym = graph.symbols.get(symbolId);
|
|
481
|
+
if (!sym) {
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
const allCalledBy = Array.from(sym.calledBy).sort();
|
|
485
|
+
const allCalls = Array.from(sym.calls).sort();
|
|
486
|
+
const callerCount = sym.calledBy.size;
|
|
487
|
+
symbols.push({
|
|
488
|
+
name: sym.name,
|
|
489
|
+
kind: sym.kind,
|
|
490
|
+
layer: sym.layer ?? "unknown",
|
|
491
|
+
containsLayers: sym.containsLayers ?? [],
|
|
492
|
+
callerCount,
|
|
493
|
+
calledBy: allCalledBy.slice(0, MAX_CALLERS).map(toRelSymbol).filter(Boolean),
|
|
494
|
+
...(allCalledBy.length > MAX_CALLERS ? { calledByTruncated: true } : {}),
|
|
495
|
+
calls: allCalls.slice(0, MAX_CALLERS).map(toRelSymbol).filter(Boolean),
|
|
496
|
+
changeGuidance: callerCount >= exports.HIGH_RISK_CALLER_COUNT
|
|
497
|
+
? "Preserve signature and behavior unless user explicitly requested a contract change."
|
|
498
|
+
: "Check direct callers before changing signature or return shape.",
|
|
499
|
+
});
|
|
500
|
+
});
|
|
501
|
+
symbols.sort((a, b) => b.callerCount - a.callerCount || a.name.localeCompare(b.name));
|
|
502
|
+
const topImporterNames = importedBy.slice(0, 3).map((i) => i.file).join(", ");
|
|
503
|
+
const decision = modificationRisk === "dangerous"
|
|
504
|
+
? "announce_risk_then_proceed_with_contract_guardrails"
|
|
505
|
+
: modificationRisk === "caution"
|
|
506
|
+
? "proceed_only_after_callers_are_checked"
|
|
507
|
+
: "proceed_with_targeted_checks";
|
|
508
|
+
const neighborFocusFiles = {
|
|
509
|
+
imports: allImports.slice(0, 6).map((f) => ({
|
|
510
|
+
file: this.toProjectPath(f),
|
|
511
|
+
focus: this.focusPathFor(f, graph),
|
|
512
|
+
})),
|
|
513
|
+
importedBy: allImportedBy.slice(0, 6).map((f) => ({
|
|
514
|
+
file: this.toProjectPath(f),
|
|
515
|
+
focus: this.focusPathFor(f, graph),
|
|
516
|
+
})),
|
|
517
|
+
};
|
|
518
|
+
return {
|
|
519
|
+
file: path.basename(filePath),
|
|
520
|
+
relativePath,
|
|
521
|
+
projectPath: relativePath,
|
|
522
|
+
focusKey,
|
|
523
|
+
focusPath: this.focusPathFor(filePath, graph),
|
|
524
|
+
dataQuality: node.hasParseError ? "partial" : "complete",
|
|
525
|
+
hasParseError: node.hasParseError ?? false,
|
|
526
|
+
risk: {
|
|
527
|
+
modificationRisk,
|
|
528
|
+
decision,
|
|
529
|
+
totalImporterCount: blastSize,
|
|
530
|
+
changeCount: node.changeCount,
|
|
531
|
+
importedByTruncated: allImportedBy.length > MAX_IMPORTEDBY,
|
|
532
|
+
},
|
|
533
|
+
agentPreflight: [
|
|
534
|
+
"Read this focus file before editing this file.",
|
|
535
|
+
modificationRisk === "dangerous"
|
|
536
|
+
? `DANGER: ${blastSize} importers. Announce the blast radius, keep the edit single-file and contract-preserving, and stop before public contract, behavior, or caller changes. Top importers: ${topImporterNames || "none listed"}.`
|
|
537
|
+
: modificationRisk === "caution"
|
|
538
|
+
? `CAUTION: ${blastSize} importers. Inspect affected callers before editing.`
|
|
539
|
+
: "Safe: low direct blast radius. Still preserve exports and symbol contracts.",
|
|
540
|
+
"For every edited symbol, inspect calledBy and preserve the expected contract.",
|
|
541
|
+
"Use layer and containsLayers to avoid touching unrelated UI, data, state, handler, or logic code.",
|
|
542
|
+
],
|
|
543
|
+
changeContract: {
|
|
544
|
+
preserve: [
|
|
545
|
+
"Public exports",
|
|
546
|
+
"Function signatures",
|
|
547
|
+
"Return shapes",
|
|
548
|
+
"Existing import style",
|
|
549
|
+
],
|
|
550
|
+
askFirstWhen: [
|
|
551
|
+
"A public export, function/type signature, return shape, type structure, or runtime behavior would change",
|
|
552
|
+
"A symbol layer is mixed and the task targets only one layer",
|
|
553
|
+
"A change requires touching callers or files outside the requested scope",
|
|
554
|
+
"The needed focus file is missing or dataQuality is partial for the target",
|
|
555
|
+
],
|
|
556
|
+
afterChange: [
|
|
557
|
+
"Run the narrowest relevant test or compile check",
|
|
558
|
+
"Verify listed importedBy files still satisfy the changed contract",
|
|
559
|
+
"If verification is missing or incomplete, report the residual risk",
|
|
560
|
+
],
|
|
561
|
+
},
|
|
562
|
+
importedBy,
|
|
563
|
+
imports,
|
|
564
|
+
neighborFocusFiles,
|
|
565
|
+
...(isBarrel && reExports.length > 0 ? { isBarrel: true, reExports } : {}),
|
|
566
|
+
symbols,
|
|
567
|
+
verificationTargets: {
|
|
568
|
+
files: importedBy.slice(0, 5).map((i) => i.file),
|
|
569
|
+
symbols: symbols
|
|
570
|
+
.filter((s) => s.callerCount > 0)
|
|
571
|
+
.slice(0, 5)
|
|
572
|
+
.map((s) => `${relativePath}::${s.name} (${s.callerCount} callers)`),
|
|
573
|
+
},
|
|
574
|
+
};
|
|
575
|
+
}
|
|
576
|
+
// ── CONTEXT GENERATION ─────────────────────────────────────────────────────
|
|
577
|
+
/**
|
|
578
|
+
* Writes the project-level AI context bundle:
|
|
579
|
+
* - context.json for routing and risk policy
|
|
580
|
+
* - context.files.json for file dependency details
|
|
581
|
+
* - context.symbols.json for call graph details
|
|
582
|
+
* - focus files and WORKFLOW.md for targeted agent workflows
|
|
583
|
+
*/
|
|
584
|
+
generateContext(graph, history) {
|
|
585
|
+
if (!this.contextGenerationEnabled) {
|
|
586
|
+
return;
|
|
587
|
+
}
|
|
588
|
+
try {
|
|
589
|
+
const contextDir = path.join(path.dirname(this.persistPath), ".cache");
|
|
590
|
+
// Entry points are usually framework routes or top-level modules. Agents
|
|
591
|
+
// use this signal when deciding which files deserve extra verification.
|
|
592
|
+
const entryPointFiles = new Set();
|
|
593
|
+
graph.files.forEach((node, filePath) => {
|
|
594
|
+
const isSource = !filePath.includes("node_modules") &&
|
|
595
|
+
!filePath.includes(".ripple") &&
|
|
596
|
+
!filePath.includes(".next");
|
|
597
|
+
if (isSource && node.importedBy.size === 0 && node.imports.size > 0) {
|
|
598
|
+
entryPointFiles.add(filePath);
|
|
599
|
+
}
|
|
600
|
+
});
|
|
601
|
+
// Generated JSON uses project-relative references so prompts remain
|
|
602
|
+
// stable across machines and do not expose local workspace paths.
|
|
603
|
+
const toContextFileRef = (filePath) => {
|
|
604
|
+
const normalized = filePath.split(path.sep).join("/");
|
|
605
|
+
return path.isAbsolute(filePath) ? this.toProjectPath(filePath) : normalized;
|
|
606
|
+
};
|
|
607
|
+
const fileRefFromEntity = (entity) => {
|
|
608
|
+
const filePath = entity.includes("::") ? entity.split("::")[0] : entity;
|
|
609
|
+
return toContextFileRef(filePath);
|
|
610
|
+
};
|
|
611
|
+
const symbolRefFromId = (symbolId) => {
|
|
612
|
+
const separator = symbolId.indexOf("::");
|
|
613
|
+
if (separator === -1) {
|
|
614
|
+
return symbolId;
|
|
615
|
+
}
|
|
616
|
+
const filePath = symbolId.slice(0, separator);
|
|
617
|
+
const symbolName = symbolId.slice(separator + 2);
|
|
618
|
+
return `${toContextFileRef(filePath)}::${symbolName}`;
|
|
619
|
+
};
|
|
620
|
+
// Small query hints let agents route themselves before loading heavier
|
|
621
|
+
// files like context.files.json or context.symbols.json.
|
|
622
|
+
const mostConnectedFiles = [];
|
|
623
|
+
Array.from(graph.files.entries())
|
|
624
|
+
.filter(([_, n]) => n.importedBy.size >= exports.CAUTION_BLAST_RADIUS)
|
|
625
|
+
.sort((a, b) => b[1].importedBy.size - a[1].importedBy.size)
|
|
626
|
+
.slice(0, 10)
|
|
627
|
+
.forEach(([filePath]) => mostConnectedFiles.push(this.toProjectPath(filePath)));
|
|
628
|
+
const recentlyChangedFiles = [];
|
|
629
|
+
const seenFiles = new Set();
|
|
630
|
+
for (let i = history.events.length - 1; i >= 0; i--) {
|
|
631
|
+
const e = history.events[i];
|
|
632
|
+
if (e.type !== "baseline_snapshot") {
|
|
633
|
+
const fileRef = fileRefFromEntity(e.source);
|
|
634
|
+
if (!seenFiles.has(fileRef)) {
|
|
635
|
+
seenFiles.add(fileRef);
|
|
636
|
+
recentlyChangedFiles.push(fileRef);
|
|
637
|
+
if (recentlyChangedFiles.length >= 5) {
|
|
638
|
+
break;
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
const highRiskSymbols = [];
|
|
644
|
+
graph.symbols.forEach((sym) => {
|
|
645
|
+
if (sym.calledBy.size >= exports.HIGH_RISK_CALLER_COUNT) {
|
|
646
|
+
highRiskSymbols.push(symbolRefFromId(sym.id));
|
|
647
|
+
}
|
|
648
|
+
});
|
|
649
|
+
const entryPoints = [];
|
|
650
|
+
entryPointFiles.forEach((filePath) => {
|
|
651
|
+
entryPoints.push(this.toProjectPath(filePath));
|
|
652
|
+
});
|
|
653
|
+
const lastChangeGroup = {
|
|
654
|
+
id: null,
|
|
655
|
+
filesChanged: [],
|
|
656
|
+
symbolsChanged: [],
|
|
657
|
+
message: "No changes recorded yet — Ripple was just installed",
|
|
658
|
+
};
|
|
659
|
+
const lastGroupId = (() => {
|
|
660
|
+
for (let i = history.events.length - 1; i >= 0; i--) {
|
|
661
|
+
if (history.events[i].changeGroup) {
|
|
662
|
+
return history.events[i].changeGroup;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
return null;
|
|
666
|
+
})();
|
|
667
|
+
if (lastGroupId) {
|
|
668
|
+
const groupEvents = history.getGroup(lastGroupId);
|
|
669
|
+
const changedFiles = new Set();
|
|
670
|
+
const changedSymbols = new Set();
|
|
671
|
+
const relatedFiles = new Set();
|
|
672
|
+
groupEvents.forEach((e) => {
|
|
673
|
+
const sourceFile = fileRefFromEntity(e.source);
|
|
674
|
+
if (sourceFile) {
|
|
675
|
+
changedFiles.add(sourceFile);
|
|
676
|
+
}
|
|
677
|
+
if (e.source.includes("::")) {
|
|
678
|
+
changedSymbols.add(symbolRefFromId(e.source));
|
|
679
|
+
}
|
|
680
|
+
if (e.target) {
|
|
681
|
+
const targetFile = fileRefFromEntity(e.target);
|
|
682
|
+
if (targetFile && targetFile !== sourceFile) {
|
|
683
|
+
relatedFiles.add(targetFile);
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
});
|
|
687
|
+
lastChangeGroup.id = lastGroupId;
|
|
688
|
+
lastChangeGroup.filesChanged = Array.from(changedFiles).filter(Boolean);
|
|
689
|
+
lastChangeGroup.relatedFiles = Array.from(relatedFiles)
|
|
690
|
+
.filter((filePath) => filePath && !changedFiles.has(filePath));
|
|
691
|
+
lastChangeGroup.symbolsChanged = Array.from(changedSymbols).filter(Boolean);
|
|
692
|
+
delete lastChangeGroup.message;
|
|
693
|
+
const ts = parseInt(lastGroupId.split("_")[1]);
|
|
694
|
+
if (!isNaN(ts)) {
|
|
695
|
+
lastChangeGroup.changedAt = new Date(ts).toISOString();
|
|
696
|
+
}
|
|
697
|
+
}
|
|
698
|
+
const criticalFiles = [];
|
|
699
|
+
graph.files.forEach((node, filePath) => {
|
|
700
|
+
const blastRadius = node.importedBy.size;
|
|
701
|
+
const isHighChurn = node.changeCount > HIGH_CHURN_CRITICAL;
|
|
702
|
+
const isHighBlast = blastRadius >= HIGH_BLAST_CRITICAL;
|
|
703
|
+
if (isHighBlast || isHighChurn) {
|
|
704
|
+
criticalFiles.push({
|
|
705
|
+
path: this.toProjectPath(filePath),
|
|
706
|
+
importedBy: blastRadius,
|
|
707
|
+
changeCount: node.changeCount,
|
|
708
|
+
modificationRisk: blastRadius >= exports.DANGEROUS_BLAST_RADIUS || node.changeCount > exports.DANGEROUS_CHURN
|
|
709
|
+
? "dangerous" : "caution",
|
|
710
|
+
reasons: [
|
|
711
|
+
...(isHighBlast ? [`imported by ${blastRadius} files`] : []),
|
|
712
|
+
...(isHighChurn ? [`modified ${node.changeCount} times`] : []),
|
|
713
|
+
],
|
|
714
|
+
});
|
|
715
|
+
}
|
|
716
|
+
});
|
|
717
|
+
criticalFiles.sort((a, b) => b.importedBy - a.importedBy);
|
|
718
|
+
// Warnings are intentionally short and user-facing. They summarize the
|
|
719
|
+
// most important files without duplicating the full context files.
|
|
720
|
+
const warnings = [];
|
|
721
|
+
criticalFiles.slice(0, 5).forEach((f) => {
|
|
722
|
+
if (f.importedBy >= HIGH_BLAST_CRITICAL) {
|
|
723
|
+
warnings.push({
|
|
724
|
+
type: "high_blast_radius",
|
|
725
|
+
file: f.path,
|
|
726
|
+
message: `Imported by ${f.importedBy} files — verify all callers before changing`,
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
if (f.changeCount > HIGH_CHURN_CRITICAL) {
|
|
730
|
+
warnings.push({
|
|
731
|
+
type: "high_churn",
|
|
732
|
+
file: f.path,
|
|
733
|
+
message: `Modified ${f.changeCount} times — frequently changed, high risk`,
|
|
734
|
+
});
|
|
735
|
+
}
|
|
736
|
+
});
|
|
737
|
+
// Stack detection is heuristic. The output should guide agents toward
|
|
738
|
+
// local conventions without pretending to be a full framework analyzer.
|
|
739
|
+
const techStack = {
|
|
740
|
+
hasNextJs: fs.existsSync(path.join(this.workspaceRoot, "next.config.ts")) ||
|
|
741
|
+
fs.existsSync(path.join(this.workspaceRoot, "next.config.js")),
|
|
742
|
+
hasVite: fs.existsSync(path.join(this.workspaceRoot, "vite.config.ts")) ||
|
|
743
|
+
fs.existsSync(path.join(this.workspaceRoot, "vite.config.js")),
|
|
744
|
+
hasReactRouter: fs.existsSync(path.join(this.workspaceRoot, "react-router.config.ts")) ||
|
|
745
|
+
fs.existsSync(path.join(this.workspaceRoot, "react-router.config.js")),
|
|
746
|
+
hasTurborepo: fs.existsSync(path.join(this.workspaceRoot, "turbo.json")),
|
|
747
|
+
hasTypeScript: fs.existsSync(path.join(this.workspaceRoot, "tsconfig.json")),
|
|
748
|
+
hasTailwind: fs.existsSync(path.join(this.workspaceRoot, "tailwind.config.ts")) ||
|
|
749
|
+
fs.existsSync(path.join(this.workspaceRoot, "tailwind.config.js")),
|
|
750
|
+
hasTests: fs.existsSync(path.join(this.workspaceRoot, "jest.config.ts")) ||
|
|
751
|
+
fs.existsSync(path.join(this.workspaceRoot, "jest.config.js")) ||
|
|
752
|
+
fs.existsSync(path.join(this.workspaceRoot, "vitest.config.ts")),
|
|
753
|
+
packageManager: fs.existsSync(path.join(this.workspaceRoot, "pnpm-lock.yaml"))
|
|
754
|
+
? "pnpm"
|
|
755
|
+
: fs.existsSync(path.join(this.workspaceRoot, "yarn.lock"))
|
|
756
|
+
? "yarn"
|
|
757
|
+
: "npm",
|
|
758
|
+
};
|
|
759
|
+
// Prefer directories that already exist in the project. This keeps agents
|
|
760
|
+
// from inventing new architecture during file-creation tasks.
|
|
761
|
+
const safeToCreateIn = [];
|
|
762
|
+
const candidateDirs = [
|
|
763
|
+
"app/components", "src/components", "components",
|
|
764
|
+
"app/hooks", "src/hooks", "hooks",
|
|
765
|
+
"app/lib", "src/lib", "lib",
|
|
766
|
+
"app/utils", "src/utils", "utils",
|
|
767
|
+
"app/types", "src/types", "types",
|
|
768
|
+
"app/services", "src/services", "services",
|
|
769
|
+
];
|
|
770
|
+
candidateDirs.forEach((dir) => {
|
|
771
|
+
if (fs.existsSync(path.join(this.workspaceRoot, dir))) {
|
|
772
|
+
safeToCreateIn.push(dir);
|
|
773
|
+
}
|
|
774
|
+
});
|
|
775
|
+
["apps", "packages", "web"].forEach((prefix) => {
|
|
776
|
+
const prefixDir = path.join(this.workspaceRoot, prefix);
|
|
777
|
+
if (!fs.existsSync(prefixDir)) {
|
|
778
|
+
return;
|
|
779
|
+
}
|
|
780
|
+
try {
|
|
781
|
+
if (prefix === "web") {
|
|
782
|
+
["core/components", "core/hooks", "core/store", "core/services", "core/lib",
|
|
783
|
+
"components", "hooks", "store", "services"].forEach((sub) => {
|
|
784
|
+
const subPath = path.join(prefixDir, sub);
|
|
785
|
+
if (fs.existsSync(subPath)) {
|
|
786
|
+
const rel = `${prefix}/${sub}`;
|
|
787
|
+
if (!safeToCreateIn.includes(rel)) {
|
|
788
|
+
safeToCreateIn.push(rel);
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
});
|
|
792
|
+
return;
|
|
793
|
+
}
|
|
794
|
+
fs.readdirSync(prefixDir).forEach((pkg) => {
|
|
795
|
+
const pkgPath = path.join(prefixDir, pkg);
|
|
796
|
+
try {
|
|
797
|
+
if (!fs.statSync(pkgPath).isDirectory()) {
|
|
798
|
+
return;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
catch {
|
|
802
|
+
return;
|
|
803
|
+
}
|
|
804
|
+
["components", "hooks", "lib", "store", "services"].forEach((sub) => {
|
|
805
|
+
const subPath = path.join(pkgPath, sub);
|
|
806
|
+
if (fs.existsSync(subPath)) {
|
|
807
|
+
const rel = `${prefix}/${pkg}/${sub}`;
|
|
808
|
+
if (!safeToCreateIn.includes(rel)) {
|
|
809
|
+
safeToCreateIn.push(rel);
|
|
810
|
+
}
|
|
811
|
+
}
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
}
|
|
815
|
+
catch { /* skip */ }
|
|
816
|
+
});
|
|
817
|
+
// Orphaned symbols are useful suggestions, not deletion instructions.
|
|
818
|
+
// Entry points and imported files are excluded to avoid false positives.
|
|
819
|
+
const orphanedSymbols = [];
|
|
820
|
+
graph.symbols.forEach((sym) => {
|
|
821
|
+
if (entryPointFiles.has(sym.file)) {
|
|
822
|
+
return;
|
|
823
|
+
}
|
|
824
|
+
const fileNode = graph.files.get(sym.file);
|
|
825
|
+
if (fileNode && fileNode.importedBy.size > 0) {
|
|
826
|
+
return;
|
|
827
|
+
}
|
|
828
|
+
if (sym.calledBy.size === 0 && sym.kind === "function" &&
|
|
829
|
+
!sym.name.startsWith("_") && sym.name !== "default") {
|
|
830
|
+
orphanedSymbols.push(`${this.toProjectPath(sym.file)}::${sym.name}`);
|
|
831
|
+
}
|
|
832
|
+
});
|
|
833
|
+
let rippleVersion = "1.0.1";
|
|
834
|
+
try {
|
|
835
|
+
const ripplePkg = JSON.parse(fs.readFileSync(path.join(__dirname, "..", "package.json"), "utf8"));
|
|
836
|
+
if (ripplePkg.version) {
|
|
837
|
+
rippleVersion = ripplePkg.version;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
catch { /* stay with fallback */ }
|
|
841
|
+
let projectName = path.basename(this.workspaceRoot);
|
|
842
|
+
let projectDescription = "";
|
|
843
|
+
let importAlias = "";
|
|
844
|
+
try {
|
|
845
|
+
const pkgPath = path.join(this.workspaceRoot, "package.json");
|
|
846
|
+
if (fs.existsSync(pkgPath)) {
|
|
847
|
+
const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
|
|
848
|
+
if (pkg.name) {
|
|
849
|
+
projectName = pkg.name;
|
|
850
|
+
}
|
|
851
|
+
if (pkg.description) {
|
|
852
|
+
projectDescription = pkg.description;
|
|
853
|
+
}
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
catch { /* use directory name */ }
|
|
857
|
+
try {
|
|
858
|
+
const tsconfigPath = path.join(this.workspaceRoot, "tsconfig.json");
|
|
859
|
+
if (fs.existsSync(tsconfigPath)) {
|
|
860
|
+
const raw = fs.readFileSync(tsconfigPath, "utf8")
|
|
861
|
+
.replace(/\/\/.*$/gm, "")
|
|
862
|
+
.replace(/\/\*[\s\S]*?\*\//g, "")
|
|
863
|
+
.replace(/,\s*([}\]])/g, "$1");
|
|
864
|
+
const tsconfig = JSON.parse(raw);
|
|
865
|
+
const tsPaths = tsconfig?.compilerOptions?.paths ?? {};
|
|
866
|
+
const firstAlias = Object.keys(tsPaths)[0];
|
|
867
|
+
if (firstAlias) {
|
|
868
|
+
importAlias = firstAlias.replace("/*", "");
|
|
869
|
+
}
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
catch { /* no alias */ }
|
|
873
|
+
if (!this.patternCache) {
|
|
874
|
+
// Coding style hints are cached because they require reading many files.
|
|
875
|
+
// The cache is invalidated when a file or project config changes.
|
|
876
|
+
let arrowFileCount = 0;
|
|
877
|
+
let namedFileCount = 0;
|
|
878
|
+
let hasClassComponents = false;
|
|
879
|
+
graph.files.forEach((_, filePath) => {
|
|
880
|
+
if (filePath.includes("node_modules") || filePath.includes(".next") ||
|
|
881
|
+
filePath.includes(".ripple") || filePath.includes(".config.") ||
|
|
882
|
+
filePath.includes(".setup.")) {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
try {
|
|
886
|
+
const content = fs.readFileSync(filePath, "utf8");
|
|
887
|
+
const namedFns = (content.match(/^(export\s+)?(async\s+)?function\s+\w+/gm) || []).length;
|
|
888
|
+
const arrowFns = (content.match(/^(export\s+)?(const|let)\s+\w+\s*=\s*(async\s+)?\(/gm) || []).length;
|
|
889
|
+
if (arrowFns > namedFns) {
|
|
890
|
+
arrowFileCount++;
|
|
891
|
+
}
|
|
892
|
+
else if (namedFns > 0) {
|
|
893
|
+
namedFileCount++;
|
|
894
|
+
}
|
|
895
|
+
if (/extends\s+(React\.)?Component/.test(content)) {
|
|
896
|
+
hasClassComponents = true;
|
|
897
|
+
}
|
|
898
|
+
if (/^class\s+\w+Store/.test(content) || /makeObservable|makeAutoObservable/.test(content)) {
|
|
899
|
+
hasClassComponents = true;
|
|
900
|
+
}
|
|
901
|
+
}
|
|
902
|
+
catch { /* skip unreadable */ }
|
|
903
|
+
});
|
|
904
|
+
this.patternCache = {
|
|
905
|
+
prefersArrowFunctions: arrowFileCount > namedFileCount,
|
|
906
|
+
hasClassComponents,
|
|
907
|
+
};
|
|
908
|
+
}
|
|
909
|
+
const { prefersArrowFunctions, hasClassComponents } = this.patternCache;
|
|
910
|
+
const sourceImportPaths = Array.from(graph.files.entries())
|
|
911
|
+
.filter(([fp]) => !fp.includes("node_modules") && !fp.includes(".next") && !fp.includes(".ripple"))
|
|
912
|
+
.flatMap(([_, node]) => Array.from(node.imports));
|
|
913
|
+
const importBasenames = sourceImportPaths.map((i) => path.basename(i, path.extname(i)));
|
|
914
|
+
const stateManagement = [];
|
|
915
|
+
if (importBasenames.some((i) => i.includes("zustand"))) {
|
|
916
|
+
stateManagement.push("zustand");
|
|
917
|
+
}
|
|
918
|
+
if (importBasenames.some((i) => i.includes("redux"))) {
|
|
919
|
+
stateManagement.push("redux");
|
|
920
|
+
}
|
|
921
|
+
if (importBasenames.some((i) => i.includes("jotai"))) {
|
|
922
|
+
stateManagement.push("jotai");
|
|
923
|
+
}
|
|
924
|
+
if (importBasenames.some((i) => i.includes("recoil"))) {
|
|
925
|
+
stateManagement.push("recoil");
|
|
926
|
+
}
|
|
927
|
+
if (importBasenames.some((i) => i === "mobx" || i.includes("mobx-react"))) {
|
|
928
|
+
stateManagement.push("mobx");
|
|
929
|
+
}
|
|
930
|
+
try {
|
|
931
|
+
const pkgRaw2 = fs.readFileSync(path.join(this.workspaceRoot, "package.json"), "utf8");
|
|
932
|
+
const pkg2 = JSON.parse(pkgRaw2);
|
|
933
|
+
const allDeps2 = { ...(pkg2.dependencies ?? {}), ...(pkg2.devDependencies ?? {}) };
|
|
934
|
+
if (allDeps2["@tanstack/react-query"] || allDeps2["react-query"]) {
|
|
935
|
+
stateManagement.push("react-query");
|
|
936
|
+
}
|
|
937
|
+
if (allDeps2["@trpc/client"] || allDeps2["@trpc/react-query"]) {
|
|
938
|
+
stateManagement.push("trpc");
|
|
939
|
+
}
|
|
940
|
+
if (allDeps2["swr"]) {
|
|
941
|
+
stateManagement.push("swr");
|
|
942
|
+
}
|
|
943
|
+
if (allDeps2["mobx"] && !stateManagement.includes("mobx")) {
|
|
944
|
+
stateManagement.push("mobx");
|
|
945
|
+
}
|
|
946
|
+
if (allDeps2["mobx-state-tree"] && !stateManagement.includes("mobx-state-tree")) {
|
|
947
|
+
stateManagement.push("mobx-state-tree");
|
|
948
|
+
}
|
|
949
|
+
}
|
|
950
|
+
catch { /* no package.json */ }
|
|
951
|
+
if (stateManagement.length === 0) {
|
|
952
|
+
stateManagement.push("useState");
|
|
953
|
+
}
|
|
954
|
+
// Styling and state-management hints are merged from imports, package
|
|
955
|
+
// dependencies, and config files to produce practical agent constraints.
|
|
956
|
+
const stylingApproach = [];
|
|
957
|
+
const cssImports = sourceImportPaths.filter((i) => i.endsWith(".css") || i.endsWith(".scss"));
|
|
958
|
+
if (cssImports.some((i) => i.includes("module"))) {
|
|
959
|
+
stylingApproach.push("css-modules");
|
|
960
|
+
}
|
|
961
|
+
try {
|
|
962
|
+
const pkgRaw = fs.readFileSync(path.join(this.workspaceRoot, "package.json"), "utf8");
|
|
963
|
+
const pkg = JSON.parse(pkgRaw);
|
|
964
|
+
const deps = { ...(pkg.dependencies ?? {}), ...(pkg.devDependencies ?? {}) };
|
|
965
|
+
if (deps["styled-components"]) {
|
|
966
|
+
stylingApproach.push("styled-components");
|
|
967
|
+
}
|
|
968
|
+
if (deps["@emotion/react"] || deps["@emotion/styled"]) {
|
|
969
|
+
stylingApproach.push("emotion");
|
|
970
|
+
}
|
|
971
|
+
}
|
|
972
|
+
catch { /* no package.json */ }
|
|
973
|
+
if (techStack.hasTailwind) {
|
|
974
|
+
stylingApproach.push("tailwind");
|
|
975
|
+
}
|
|
976
|
+
if (stylingApproach.length === 0 && cssImports.length > 0) {
|
|
977
|
+
stylingApproach.push("css");
|
|
978
|
+
}
|
|
979
|
+
const testingFramework = [];
|
|
980
|
+
if (fs.existsSync(path.join(this.workspaceRoot, "jest.config.ts")) ||
|
|
981
|
+
fs.existsSync(path.join(this.workspaceRoot, "jest.config.js"))) {
|
|
982
|
+
testingFramework.push("jest");
|
|
983
|
+
}
|
|
984
|
+
if (fs.existsSync(path.join(this.workspaceRoot, "vitest.config.ts"))) {
|
|
985
|
+
testingFramework.push("vitest");
|
|
986
|
+
}
|
|
987
|
+
if (fs.existsSync(path.join(this.workspaceRoot, "playwright.config.ts"))) {
|
|
988
|
+
testingFramework.push("playwright");
|
|
989
|
+
}
|
|
990
|
+
const codingPatterns = {
|
|
991
|
+
prefersArrowFunctions,
|
|
992
|
+
stateManagement,
|
|
993
|
+
stylingApproach,
|
|
994
|
+
testingFramework,
|
|
995
|
+
componentPattern: hasClassComponents ? "class or functional" : "functional only",
|
|
996
|
+
};
|
|
997
|
+
const detectedConstraints = [];
|
|
998
|
+
if (!hasClassComponents) {
|
|
999
|
+
detectedConstraints.push("No class components detected — use functional components only");
|
|
1000
|
+
}
|
|
1001
|
+
const hasApiDir = fs.existsSync(path.join(this.workspaceRoot, "app/api")) ||
|
|
1002
|
+
fs.existsSync(path.join(this.workspaceRoot, "pages/api"));
|
|
1003
|
+
if (hasApiDir) {
|
|
1004
|
+
detectedConstraints.push("API routes detected — use framework route handlers for external calls");
|
|
1005
|
+
}
|
|
1006
|
+
const hasDataFetching = stateManagement.some((s) => ["react-query", "trpc", "swr"].includes(s));
|
|
1007
|
+
const hasMobX = stateManagement.includes("mobx") || stateManagement.includes("mobx-state-tree");
|
|
1008
|
+
if (hasMobX) {
|
|
1009
|
+
detectedConstraints.push("State management uses MobX — add new state as MobX stores, not useState");
|
|
1010
|
+
}
|
|
1011
|
+
else if (stateManagement.length === 1 && stateManagement[0] === "useState" && !hasDataFetching) {
|
|
1012
|
+
detectedConstraints.push("State management uses useState only — do not introduce Redux, Zustand, or Jotai");
|
|
1013
|
+
}
|
|
1014
|
+
else if (hasDataFetching) {
|
|
1015
|
+
const dfLibs = stateManagement.filter((s) => ["react-query", "trpc", "swr"].includes(s)).join(", ");
|
|
1016
|
+
detectedConstraints.push(`Data fetching uses ${dfLibs} — use existing patterns for server state`);
|
|
1017
|
+
}
|
|
1018
|
+
// Public API contains exported symbols already used elsewhere. Agents can
|
|
1019
|
+
// use this list to reuse existing contracts before creating new ones.
|
|
1020
|
+
const publicApi = {};
|
|
1021
|
+
graph.files.forEach((node, filePath) => {
|
|
1022
|
+
const isSource = !filePath.includes("node_modules") && !filePath.includes(".ripple") && !filePath.includes(".next");
|
|
1023
|
+
if (!isSource) {
|
|
1024
|
+
return;
|
|
1025
|
+
}
|
|
1026
|
+
const exported = [];
|
|
1027
|
+
node.symbols.forEach((symbolId) => {
|
|
1028
|
+
const sym = graph.symbols.get(symbolId);
|
|
1029
|
+
if (sym && (node.importedBy.size > 0 || sym.calledBy.size > 0)) {
|
|
1030
|
+
exported.push(sym.name);
|
|
1031
|
+
}
|
|
1032
|
+
});
|
|
1033
|
+
if (exported.length > 0) {
|
|
1034
|
+
const key = path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
|
|
1035
|
+
publicApi[key] = exported;
|
|
1036
|
+
}
|
|
1037
|
+
});
|
|
1038
|
+
// Full file dependency map. Kept out of context.json so simple tasks can
|
|
1039
|
+
// start from a smaller routing file.
|
|
1040
|
+
const filesMap = {};
|
|
1041
|
+
graph.files.forEach((node, filePath) => {
|
|
1042
|
+
const isSource = !filePath.includes("node_modules") && !filePath.includes(".ripple") && !filePath.includes(".next");
|
|
1043
|
+
if (!isSource) {
|
|
1044
|
+
return;
|
|
1045
|
+
}
|
|
1046
|
+
const importedBy = Array.from(node.importedBy)
|
|
1047
|
+
.filter((f) => !f.includes("node_modules"))
|
|
1048
|
+
.map((f) => path.relative(this.workspaceRoot, f).split(path.sep).join("/"));
|
|
1049
|
+
const imports = Array.from(node.imports)
|
|
1050
|
+
.filter((f) => !f.includes("node_modules"))
|
|
1051
|
+
.map((f) => path.relative(this.workspaceRoot, f).split(path.sep).join("/"));
|
|
1052
|
+
const symbols = Array.from(node.symbols).map((id) => id.split("::")[1]).filter(Boolean);
|
|
1053
|
+
if (importedBy.length === 0 && imports.length === 0 && symbols.length === 0) {
|
|
1054
|
+
return;
|
|
1055
|
+
}
|
|
1056
|
+
const fileKey = path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
|
|
1057
|
+
const blastSize = node.importedBy.size;
|
|
1058
|
+
const modificationRisk = blastSize >= exports.DANGEROUS_BLAST_RADIUS || node.changeCount > exports.DANGEROUS_CHURN
|
|
1059
|
+
? "dangerous"
|
|
1060
|
+
: blastSize >= exports.CAUTION_BLAST_RADIUS || node.changeCount > exports.CAUTION_CHURN
|
|
1061
|
+
? "caution" : "safe";
|
|
1062
|
+
filesMap[fileKey] = {
|
|
1063
|
+
projectPath: fileKey,
|
|
1064
|
+
importedBy,
|
|
1065
|
+
imports,
|
|
1066
|
+
symbols,
|
|
1067
|
+
changeCount: node.changeCount,
|
|
1068
|
+
lastModified: node.lastModifiedAt,
|
|
1069
|
+
modificationRisk,
|
|
1070
|
+
};
|
|
1071
|
+
});
|
|
1072
|
+
// Full symbol call graph. This is the heavier context file used for
|
|
1073
|
+
// debugging, refactors, and caller/callee tracing.
|
|
1074
|
+
const symbolsMap = {};
|
|
1075
|
+
graph.symbols.forEach((sym) => {
|
|
1076
|
+
const toRelSymId = (id) => {
|
|
1077
|
+
const p = id.split("::");
|
|
1078
|
+
if (p.length < 2) {
|
|
1079
|
+
return id;
|
|
1080
|
+
}
|
|
1081
|
+
return `${path.relative(this.workspaceRoot, p[0]).split(path.sep).join("/")}::${p[1]}`;
|
|
1082
|
+
};
|
|
1083
|
+
const calledBy = Array.from(sym.calledBy).map(toRelSymId).filter(Boolean);
|
|
1084
|
+
const calls = Array.from(sym.calls).map(toRelSymId).filter(Boolean);
|
|
1085
|
+
if (calledBy.length === 0 && calls.length === 0) {
|
|
1086
|
+
return;
|
|
1087
|
+
}
|
|
1088
|
+
const symbolKey = `${path.relative(this.workspaceRoot, sym.file).split(path.sep).join("/")}::${sym.name}`;
|
|
1089
|
+
symbolsMap[symbolKey] = {
|
|
1090
|
+
file: path.basename(sym.file),
|
|
1091
|
+
kind: sym.kind,
|
|
1092
|
+
layer: sym.layer ?? "unknown",
|
|
1093
|
+
containsLayers: sym.containsLayers ?? [],
|
|
1094
|
+
calledBy,
|
|
1095
|
+
calls,
|
|
1096
|
+
};
|
|
1097
|
+
});
|
|
1098
|
+
const focusLookup = this.buildFocusLookup(graph);
|
|
1099
|
+
const { availableFocusFiles, availableFocusFilesByPath, availableFocusFilesByBasename, ambiguousFocusFileNames, ambiguousFocusFileMatches, } = focusLookup;
|
|
1100
|
+
const focusExamplesForContext = Object.entries(availableFocusFilesByPath)
|
|
1101
|
+
.slice(0, 3)
|
|
1102
|
+
.map(([file, focus]) => `${file} -> ${focus}`);
|
|
1103
|
+
const lightContext = {
|
|
1104
|
+
rippleVersion,
|
|
1105
|
+
projectName,
|
|
1106
|
+
projectDescription: projectDescription || "Add description to package.json for richer agent context",
|
|
1107
|
+
importAlias: importAlias
|
|
1108
|
+
? `Use '${importAlias}/...' for imports (detected from tsconfig)`
|
|
1109
|
+
: "Use relative imports (no tsconfig alias detected)",
|
|
1110
|
+
generated: new Date().toISOString(),
|
|
1111
|
+
instructions: [
|
|
1112
|
+
"FASTEST: Look up your target relative path in availableFocusFilesByPath and read that path first.",
|
|
1113
|
+
focusExamplesForContext.length > 0
|
|
1114
|
+
? `Real examples: ${focusExamplesForContext.join(" | ")}`
|
|
1115
|
+
: "Focus files generated after first scan.",
|
|
1116
|
+
"If the user gives only a basename, check availableFocusFilesByBasename. If status is 'ambiguous', ask which path they mean before reading or editing.",
|
|
1117
|
+
"Never choose among ambiguous basenames using top focus files, risk, recency, or perceived importance.",
|
|
1118
|
+
"Do not guess focus file names. Collision-safe focus keys may include a hash suffix.",
|
|
1119
|
+
"Check criticalFiles and warnings before touching any file.",
|
|
1120
|
+
"Use safeToCreateIn to know where to put new files.",
|
|
1121
|
+
"Never modify or delete files inside .ripple/ — these are Ripple internal files.",
|
|
1122
|
+
],
|
|
1123
|
+
project: {
|
|
1124
|
+
totalFiles: graph.files.size,
|
|
1125
|
+
totalSymbols: graph.symbols.size,
|
|
1126
|
+
totalDependencies: Array.from(graph.files.values()).reduce((sum, f) => sum + f.imports.size, 0),
|
|
1127
|
+
},
|
|
1128
|
+
_tieredContext: {
|
|
1129
|
+
summary: "Read the smallest file that answers your question. Start with the target file's focus file.",
|
|
1130
|
+
decisionTree: {
|
|
1131
|
+
"I know which file to modify": "Look up relative path in availableFocusFilesByPath -> read that focus file",
|
|
1132
|
+
"I only know a filename like options.ts": "Check availableFocusFilesByBasename -> if ambiguous, ask user which path",
|
|
1133
|
+
"I need to add a new file": "Check safeToCreateIn and publicApi in this file",
|
|
1134
|
+
"I need to understand file connections": "Read .ripple/.cache/context.files.json",
|
|
1135
|
+
"I need to trace a call chain": "Read .ripple/.cache/context.symbols.json",
|
|
1136
|
+
"I am debugging across files": "Check lastChangeGroup → read .ripple/.cache/context.symbols.json",
|
|
1137
|
+
},
|
|
1138
|
+
files: {
|
|
1139
|
+
".ripple/.cache/focus/{key}.json": "PRIMARY. Targeted file contract. Read for any targeted file change.",
|
|
1140
|
+
".ripple/.cache/context.json": "Project routing, risk policy, and focus lookup.",
|
|
1141
|
+
".ripple/.cache/context.files.json": "Full file map.",
|
|
1142
|
+
".ripple/.cache/context.symbols.json": "Full call graph.",
|
|
1143
|
+
},
|
|
1144
|
+
tokenEstimates: {
|
|
1145
|
+
"focus file only": "~700-1500 tokens, depending on callers",
|
|
1146
|
+
"context.json only": "~1000-2000 tokens",
|
|
1147
|
+
"context.json + context.files.json": "project-size dependent",
|
|
1148
|
+
"all context files": "project-size dependent; avoid unless truly needed",
|
|
1149
|
+
},
|
|
1150
|
+
},
|
|
1151
|
+
agentOperatingMode: {
|
|
1152
|
+
mission: "Use Ripple as live architectural memory before changing code.",
|
|
1153
|
+
preflight: [
|
|
1154
|
+
"Classify the task: targeted edit, new file, debugging, or broad refactor.",
|
|
1155
|
+
"For targeted edits, read the target focus file before opening broad context.",
|
|
1156
|
+
"For new files, reuse publicApi and safeToCreateIn before inventing structure.",
|
|
1157
|
+
"For debugging, inspect lastChangeGroup, warnings, then symbol call chains.",
|
|
1158
|
+
],
|
|
1159
|
+
stopConditions: [
|
|
1160
|
+
"The user named only a basename that is listed as ambiguous",
|
|
1161
|
+
"A dangerous file change would modify public exports, signatures, type structures, runtime behavior, callers, or multiple files",
|
|
1162
|
+
"calledBy shows callers outside the requested scope",
|
|
1163
|
+
"A mixed-layer symbol would require changing more layers than the user asked for",
|
|
1164
|
+
"The needed focus file is missing or dataQuality is partial for the target",
|
|
1165
|
+
],
|
|
1166
|
+
verificationContract: [
|
|
1167
|
+
"After edits, verify direct callers or explain why they are unaffected.",
|
|
1168
|
+
"Prefer the narrowest compile/test command that covers touched files.",
|
|
1169
|
+
"Report residual risk when tests are missing or context is partial.",
|
|
1170
|
+
],
|
|
1171
|
+
},
|
|
1172
|
+
riskPolicy: {
|
|
1173
|
+
safe: "Proceed with normal focused checks.",
|
|
1174
|
+
caution: "Inspect callers and imports before editing; mention verification.",
|
|
1175
|
+
dangerous: "Announce high blast radius. For exact paths, proceed only with single-file contract-preserving edits; stop before public contract, behavior, caller, or multi-file changes.",
|
|
1176
|
+
},
|
|
1177
|
+
queryHints: { mostConnectedFiles, recentlyChangedFiles, highRiskSymbols },
|
|
1178
|
+
entryPoints,
|
|
1179
|
+
lastChangeGroup,
|
|
1180
|
+
criticalFiles: criticalFiles.slice(0, 10),
|
|
1181
|
+
warnings,
|
|
1182
|
+
techStack,
|
|
1183
|
+
safeToCreateIn,
|
|
1184
|
+
orphanedSymbols: orphanedSymbols.slice(0, 20),
|
|
1185
|
+
availableFocusFiles,
|
|
1186
|
+
availableFocusFilesByPath,
|
|
1187
|
+
availableFocusFilesByBasename,
|
|
1188
|
+
ambiguousFocusFileNames,
|
|
1189
|
+
ambiguousFocusFileMatches,
|
|
1190
|
+
agentTasks: {
|
|
1191
|
+
addNewComponent: [
|
|
1192
|
+
"1. Check safeToCreateIn for the correct directory",
|
|
1193
|
+
`2. Use ${stylingApproach[0] ?? "detected styling"} for styling`,
|
|
1194
|
+
"3. Check publicApi and orphanedSymbols — reuse before creating new",
|
|
1195
|
+
],
|
|
1196
|
+
modifyExistingFile: [
|
|
1197
|
+
"1. Resolve the target: relative path -> availableFocusFilesByPath; basename -> availableFocusFilesByBasename",
|
|
1198
|
+
"2. If basename resolution is ambiguous, ask which listed path the user means",
|
|
1199
|
+
"3. Read the resolved focus file",
|
|
1200
|
+
"4. If modificationRisk is dangerous, announce the importer count and proceed only within single-file contract-preserving bounds",
|
|
1201
|
+
"5. Stop and ask before public contract, behavior, caller, or multi-file changes",
|
|
1202
|
+
"6. Check each symbol's layer — only touch the layer the user requested",
|
|
1203
|
+
"7. Check calledBy — every caller must still work",
|
|
1204
|
+
],
|
|
1205
|
+
debugBug: [
|
|
1206
|
+
"1. Check lastChangeGroup.changedAt — what changed and when",
|
|
1207
|
+
"2. Check warnings — high_churn files are most likely sources",
|
|
1208
|
+
"3. Read .ripple/.cache/context.symbols.json — trace full call chain",
|
|
1209
|
+
],
|
|
1210
|
+
},
|
|
1211
|
+
};
|
|
1212
|
+
fs.writeFileSync(path.join(contextDir, "context.files.json"), JSON.stringify({
|
|
1213
|
+
rippleVersion,
|
|
1214
|
+
generated: new Date().toISOString(),
|
|
1215
|
+
description: "File dependency map. Read when modifying files, adding imports, or checking blast radius.",
|
|
1216
|
+
codingPatterns,
|
|
1217
|
+
detectedConstraints,
|
|
1218
|
+
publicApi,
|
|
1219
|
+
files: filesMap,
|
|
1220
|
+
}, null, 2));
|
|
1221
|
+
fs.writeFileSync(path.join(contextDir, "context.symbols.json"), JSON.stringify({
|
|
1222
|
+
rippleVersion,
|
|
1223
|
+
generated: new Date().toISOString(),
|
|
1224
|
+
description: "Symbol call graph with layer classification. Read when modifying functions or tracing call chains.",
|
|
1225
|
+
symbols: symbolsMap,
|
|
1226
|
+
}, null, 2));
|
|
1227
|
+
fs.writeFileSync(path.join(contextDir, "context.json"), JSON.stringify(lightContext, null, 2));
|
|
1228
|
+
this.generateWorkflow(projectName, projectDescription, importAlias, safeToCreateIn, stateManagement, stylingApproach, testingFramework, entryPoints, graph, rippleVersion);
|
|
1229
|
+
this.generateFocusedContexts(graph);
|
|
1230
|
+
}
|
|
1231
|
+
catch (err) {
|
|
1232
|
+
console.warn("[Ripple] Context generation failed:", err);
|
|
1233
|
+
}
|
|
1234
|
+
}
|
|
1235
|
+
// ── FOCUSED CONTEXT ────────────────────────────────────────────────────────
|
|
1236
|
+
/**
|
|
1237
|
+
* Rewrites the focus directory from the current graph and removes stale focus
|
|
1238
|
+
* files for deleted or now-irrelevant source files.
|
|
1239
|
+
*/
|
|
1240
|
+
generateFocusedContexts(graph) {
|
|
1241
|
+
try {
|
|
1242
|
+
const focusDir = path.join(path.dirname(this.persistPath), ".cache", "focus");
|
|
1243
|
+
if (!fs.existsSync(focusDir)) {
|
|
1244
|
+
fs.mkdirSync(focusDir, { recursive: true });
|
|
1245
|
+
}
|
|
1246
|
+
const validFocusKeys = new Set();
|
|
1247
|
+
graph.files.forEach((fnode, fp) => {
|
|
1248
|
+
if (!this.isContextSourceFile(fp)) {
|
|
1249
|
+
return;
|
|
1250
|
+
}
|
|
1251
|
+
if (fnode.imports.size === 0 && fnode.importedBy.size === 0 && fnode.symbols.size === 0) {
|
|
1252
|
+
return;
|
|
1253
|
+
}
|
|
1254
|
+
validFocusKeys.add(makeFocusKey(fp, graph));
|
|
1255
|
+
});
|
|
1256
|
+
try {
|
|
1257
|
+
fs.readdirSync(focusDir).forEach((fname) => {
|
|
1258
|
+
if (!fname.endsWith(".json")) {
|
|
1259
|
+
return;
|
|
1260
|
+
}
|
|
1261
|
+
if (!validFocusKeys.has(fname.slice(0, -5))) {
|
|
1262
|
+
fs.unlinkSync(path.join(focusDir, fname));
|
|
1263
|
+
}
|
|
1264
|
+
});
|
|
1265
|
+
}
|
|
1266
|
+
catch { /* focus dir may be empty */ }
|
|
1267
|
+
graph.files.forEach((_, filePath) => {
|
|
1268
|
+
const focused = this.buildFocusedContext(graph, filePath);
|
|
1269
|
+
if (!focused) {
|
|
1270
|
+
return;
|
|
1271
|
+
}
|
|
1272
|
+
fs.writeFileSync(path.join(focusDir, `${focused.focusKey}.json`), JSON.stringify(focused, null, 2));
|
|
1273
|
+
});
|
|
1274
|
+
}
|
|
1275
|
+
catch (err) {
|
|
1276
|
+
console.warn("[Ripple] Focused context generation failed:", err);
|
|
1277
|
+
}
|
|
1278
|
+
}
|
|
1279
|
+
// ── WORKFLOW.MD ───────────────────────────────────────────────────────────
|
|
1280
|
+
/**
|
|
1281
|
+
* Generates the human-readable operating protocol for AI agents and refreshes
|
|
1282
|
+
* Ripple-managed sections in supported instruction files.
|
|
1283
|
+
*/
|
|
1284
|
+
generateWorkflow(projectName, projectDescription, importAlias, safeToCreateIn, stateManagement, stylingApproach, testingFramework, entryPoints, graph, rippleVersion) {
|
|
1285
|
+
try {
|
|
1286
|
+
const hasMobX = stateManagement.includes("mobx") || stateManagement.includes("mobx-state-tree");
|
|
1287
|
+
const techStack = {
|
|
1288
|
+
hasNextJs: fs.existsSync(path.join(this.workspaceRoot, "next.config.ts")) ||
|
|
1289
|
+
fs.existsSync(path.join(this.workspaceRoot, "next.config.js")),
|
|
1290
|
+
hasVite: fs.existsSync(path.join(this.workspaceRoot, "vite.config.ts")) ||
|
|
1291
|
+
fs.existsSync(path.join(this.workspaceRoot, "vite.config.js")),
|
|
1292
|
+
hasReactRouter: fs.existsSync(path.join(this.workspaceRoot, "react-router.config.ts")) ||
|
|
1293
|
+
fs.existsSync(path.join(this.workspaceRoot, "react-router.config.js")),
|
|
1294
|
+
};
|
|
1295
|
+
// WORKFLOW.md is meant for humans and agents, so it stays in .ripple/.
|
|
1296
|
+
const workflowPath = path.join(path.dirname(this.persistPath), "WORKFLOW.md");
|
|
1297
|
+
const dangerousFiles = Array.from(graph.files.values())
|
|
1298
|
+
.filter((node) => node.importedBy.size >= exports.DANGEROUS_BLAST_RADIUS)
|
|
1299
|
+
.map((node) => ({
|
|
1300
|
+
projectPath: this.toProjectPath(node.path),
|
|
1301
|
+
importerCount: node.importedBy.size,
|
|
1302
|
+
}))
|
|
1303
|
+
.sort((a, b) => b.importerCount - a.importerCount ||
|
|
1304
|
+
a.projectPath.localeCompare(b.projectPath))
|
|
1305
|
+
.map((node) => `${node.projectPath} (${node.importerCount} importers)`);
|
|
1306
|
+
const riskPriority = { dangerous: 0, caution: 1, safe: 2 };
|
|
1307
|
+
const focusExamples = Array.from(graph.files.values())
|
|
1308
|
+
.filter((node) => {
|
|
1309
|
+
const isSource = !node.path.includes("node_modules") &&
|
|
1310
|
+
!node.path.includes(".next") &&
|
|
1311
|
+
!node.path.includes(".ripple");
|
|
1312
|
+
return isSource && (node.imports.size > 0 || node.importedBy.size > 0);
|
|
1313
|
+
})
|
|
1314
|
+
.map((node) => {
|
|
1315
|
+
const blastSize = node.importedBy.size;
|
|
1316
|
+
const risk = blastSize >= exports.DANGEROUS_BLAST_RADIUS || node.changeCount > exports.DANGEROUS_CHURN ? "dangerous"
|
|
1317
|
+
: blastSize >= exports.CAUTION_BLAST_RADIUS || node.changeCount > exports.CAUTION_CHURN ? "caution" : "safe";
|
|
1318
|
+
const projectPath = this.toProjectPath(node.path);
|
|
1319
|
+
return {
|
|
1320
|
+
projectPath,
|
|
1321
|
+
blastSize,
|
|
1322
|
+
risk,
|
|
1323
|
+
line: `${projectPath} [${risk}] -> ${this.focusPathFor(node.path, graph)}`,
|
|
1324
|
+
};
|
|
1325
|
+
})
|
|
1326
|
+
.sort((a, b) => riskPriority[a.risk] - riskPriority[b.risk] ||
|
|
1327
|
+
b.blastSize - a.blastSize ||
|
|
1328
|
+
a.projectPath.localeCompare(b.projectPath))
|
|
1329
|
+
.slice(0, 10)
|
|
1330
|
+
.map((entry) => entry.line);
|
|
1331
|
+
const workflowFocusLookup = this.buildFocusLookup(graph);
|
|
1332
|
+
const ambiguousBasenameExamples = Object.entries(workflowFocusLookup.ambiguousFocusFileMatches)
|
|
1333
|
+
.slice(0, 6)
|
|
1334
|
+
.map(([basename, matches]) => `${basename}: ${matches
|
|
1335
|
+
.map((match) => `${match.path} [${match.risk}, ${match.importers} importers]`)
|
|
1336
|
+
.join(", ")}`);
|
|
1337
|
+
const createDirs = safeToCreateIn.slice(0, 4).join(", ") ||
|
|
1338
|
+
"existing source directories after checking project structure";
|
|
1339
|
+
const stylingRule = stylingApproach[0] ?? "the existing styling approach";
|
|
1340
|
+
const content = `# ${projectName} — Ripple Workflow
|
|
1341
|
+
*Auto-generated by Ripple v${rippleVersion} — always current*
|
|
1342
|
+
*Use the setup panel to copy this into AGENTS.md, CLAUDE.md, or .cursorrules. Ripple keeps its managed section updated.*
|
|
1343
|
+
|
|
1344
|
+
---
|
|
1345
|
+
|
|
1346
|
+
## YOUR AUTOMATIC PROTOCOL
|
|
1347
|
+
|
|
1348
|
+
Run this before every task — automatically:
|
|
1349
|
+
|
|
1350
|
+
**Step 1:** Classify the task: targeted edit, new file, debugging, or broad refactor.
|
|
1351
|
+
**Step 2:** If a file is involved, open \`.ripple/.cache/context.json\` and resolve the target before reading or editing:
|
|
1352
|
+
- If the user gave a relative path, look it up in \`availableFocusFilesByPath\`.
|
|
1353
|
+
- If the user gave only a basename such as \`options.ts\`, check \`availableFocusFilesByBasename\`.
|
|
1354
|
+
- If that basename is \`"ambiguous"\`, STOP and ask which listed path they mean.
|
|
1355
|
+
- Do not choose among ambiguous basenames using top focus files, risk, recency, or project importance.
|
|
1356
|
+
- Do not guess focus filenames. Collision-safe keys can include a hash suffix.
|
|
1357
|
+
- Use \`availableFocusFiles\` only for unique basename shortcuts.
|
|
1358
|
+
**Step 3:** Read the target focus file before editing.
|
|
1359
|
+
**Step 4:** Check \`risk.modificationRisk\`:
|
|
1360
|
+
- \`"safe"\` -> proceed with targeted checks
|
|
1361
|
+
- \`"caution"\` -> inspect callers/importers first
|
|
1362
|
+
- \`"dangerous"\` -> announce importer count and proceed only if the target path is exact, the edit stays single-file, and public contracts are preserved
|
|
1363
|
+
**Step 5:** For every edited symbol, check \`calledBy\`, \`layer\`, and \`containsLayers\`.
|
|
1364
|
+
**Step 6:** Preserve public exports, function signatures, return shapes, and import style unless the user explicitly asks for a contract change.
|
|
1365
|
+
|
|
1366
|
+
---
|
|
1367
|
+
|
|
1368
|
+
## DANGEROUS FILE PROTOCOL
|
|
1369
|
+
|
|
1370
|
+
Dangerous files need informed autonomy, not a blanket permission stop:
|
|
1371
|
+
|
|
1372
|
+
1. **Ambiguous path** — if the user gave only a basename and multiple files match, STOP and ask which listed path they mean.
|
|
1373
|
+
2. **Exact path, dangerous file** — announce the blast radius, then proceed without waiting only when the edit is single-file and contract-preserving:
|
|
1374
|
+
- No public export changes
|
|
1375
|
+
- No function or type signature changes
|
|
1376
|
+
- No return-shape or type-structure changes
|
|
1377
|
+
- No runtime behavior changes
|
|
1378
|
+
- No caller updates required
|
|
1379
|
+
3. **Contract change required** — STOP before editing. Tell the user exactly which contract, behavior, caller, or multi-file change is required and ask whether to proceed.
|
|
1380
|
+
|
|
1381
|
+
After any dangerous-file edit, run the narrowest relevant compile/test check, verify the listed importedBy files still satisfy the contract, and report residual risk when verification is missing or incomplete.
|
|
1382
|
+
|
|
1383
|
+
---
|
|
1384
|
+
|
|
1385
|
+
## PLANNING FOR COMPLEX TASKS
|
|
1386
|
+
|
|
1387
|
+
For any task touching more than one file, BEFORE writing any code:
|
|
1388
|
+
|
|
1389
|
+
**Step 1 — Find the starting file.** Read its focus file.
|
|
1390
|
+
**Step 2 — Chain exploration (1-2 levels).** Look at imports and importedBy. Read focus files for relevant neighbors.
|
|
1391
|
+
**Step 3 — Formulate the plan:**
|
|
1392
|
+
\`\`\`
|
|
1393
|
+
To implement [task]:
|
|
1394
|
+
1. types/auth.ts — add type [caution, 3 importers]
|
|
1395
|
+
2. lib/authService.ts — update logic [dangerous, 7 importers]
|
|
1396
|
+
3. components/LoginButton.tsx — update UI [safe, 0 importers]
|
|
1397
|
+
Shall I proceed?
|
|
1398
|
+
\`\`\`
|
|
1399
|
+
**Step 4 — Wait for confirmation before writing any code.**
|
|
1400
|
+
|
|
1401
|
+
---
|
|
1402
|
+
|
|
1403
|
+
## TASK ROUTING
|
|
1404
|
+
|
|
1405
|
+
| User intent | First context to read | Then |
|
|
1406
|
+
|-------------|----------------------|------|
|
|
1407
|
+
| Modify one file | Target focus file | Verify \`calledBy\` and importedBy |
|
|
1408
|
+
| Add a file | \`.ripple/.cache/context.json\` | Check \`safeToCreateIn\`, \`publicApi\`, existing patterns |
|
|
1409
|
+
| Debug behavior | \`lastChangeGroup\` and warnings | Trace \`.ripple/.cache/context.symbols.json\` |
|
|
1410
|
+
| Refactor shared code | Focus file + neighbor focus files | Announce danger; stop only for ambiguity, contract, behavior, caller, or multi-file changes |
|
|
1411
|
+
|
|
1412
|
+
---
|
|
1413
|
+
|
|
1414
|
+
## THIS PROJECT
|
|
1415
|
+
|
|
1416
|
+
${projectDescription ? `**What this project does:** ${projectDescription}\n` : ""}\
|
|
1417
|
+
**Files tracked:** ${graph.files.size}
|
|
1418
|
+
**Framework:** ${techStack.hasNextJs ? "Next.js" : techStack.hasVite ? "Vite" : techStack.hasReactRouter ? "React Router" : "Unknown"}
|
|
1419
|
+
**Import style:** ${importAlias ? `Use '${importAlias}/...'` : "Use relative imports"}
|
|
1420
|
+
**State management:** ${stateManagement.join(", ")}
|
|
1421
|
+
**Styling:** ${stylingApproach.join(", ") || "see .ripple/.cache/context.files.json"}
|
|
1422
|
+
**Testing:** ${testingFramework.length > 0 ? testingFramework.join(", ") : "none detected"}
|
|
1423
|
+
**New files go in:** ${createDirs}${dangerousFiles.length > 0 ? `
|
|
1424
|
+
**Top high-blast files (announce + guard contracts):** ${dangerousFiles.slice(0, 10).join(", ")}` : ""}
|
|
1425
|
+
|
|
1426
|
+
---
|
|
1427
|
+
|
|
1428
|
+
## TOP FOCUS FILES IN THIS PROJECT
|
|
1429
|
+
|
|
1430
|
+
${focusExamples.length > 0
|
|
1431
|
+
? focusExamples.map(e => `- ${e}`).join("\n")
|
|
1432
|
+
: "- Save any file to generate focus files"}
|
|
1433
|
+
|
|
1434
|
+
${ambiguousBasenameExamples.length > 0 ? `## AMBIGUOUS FILE NAMES
|
|
1435
|
+
|
|
1436
|
+
If the user names only one of these basenames, ask which path they mean before reading or editing:
|
|
1437
|
+
${ambiguousBasenameExamples.map(e => `- ${e}`).join("\n")}` : ""}
|
|
1438
|
+
|
|
1439
|
+
---
|
|
1440
|
+
|
|
1441
|
+
## LAYER TARGETING
|
|
1442
|
+
|
|
1443
|
+
| Layer | Touch when |
|
|
1444
|
+
|-------|-----------|
|
|
1445
|
+
| \`logic\` | "change the logic/algorithm" |
|
|
1446
|
+
| \`ui\` | "update the UI/design/layout" |
|
|
1447
|
+
| \`handler\` | "change what happens on click/submit" |
|
|
1448
|
+
| \`state\` | "update the state management" |
|
|
1449
|
+
| \`data\` | "change the data fetching" |
|
|
1450
|
+
| \`mixed\` | ASK user before touching |
|
|
1451
|
+
|
|
1452
|
+
---
|
|
1453
|
+
|
|
1454
|
+
## ABSOLUTE RULES
|
|
1455
|
+
|
|
1456
|
+
1. Never modify \`.ripple/\` files
|
|
1457
|
+
2. Never change a function signature without checking ALL calledBy callers
|
|
1458
|
+
3. Never create files outside: ${createDirs}
|
|
1459
|
+
4. ${hasMobX
|
|
1460
|
+
? "New state goes in a MobX store — never introduce useState for shared state"
|
|
1461
|
+
: stateManagement[0] === "useState"
|
|
1462
|
+
? "Never introduce Redux, Zustand, or Jotai without user confirmation"
|
|
1463
|
+
: `Use ${stateManagement.join(", ")} for state`}
|
|
1464
|
+
5. Always use ${stylingRule} for new UI
|
|
1465
|
+
6. If tests are missing, say what you verified manually and what risk remains
|
|
1466
|
+
|
|
1467
|
+
---
|
|
1468
|
+
*Auto-generated by Ripple v${rippleVersion} — updates on every save*
|
|
1469
|
+
`;
|
|
1470
|
+
fs.writeFileSync(workflowPath, content);
|
|
1471
|
+
// Section-based sync preserves user content outside Ripple markers.
|
|
1472
|
+
// Files without markers or a legacy Ripple signature are left untouched.
|
|
1473
|
+
const RIPPLE_START = "<!-- RIPPLE:START -->";
|
|
1474
|
+
const RIPPLE_END = "<!-- RIPPLE:END -->";
|
|
1475
|
+
const rippleSection = `${RIPPLE_START}\n${content}\n${RIPPLE_END}`;
|
|
1476
|
+
const agentFiles = [
|
|
1477
|
+
{ name: "AGENTS.md", filePath: path.join(this.workspaceRoot, "AGENTS.md") },
|
|
1478
|
+
{ name: "CLAUDE.md", filePath: path.join(this.workspaceRoot, "CLAUDE.md") },
|
|
1479
|
+
{ name: ".cursorrules", filePath: path.join(this.workspaceRoot, ".cursorrules") },
|
|
1480
|
+
];
|
|
1481
|
+
for (const agentFile of agentFiles) {
|
|
1482
|
+
try {
|
|
1483
|
+
if (!fs.existsSync(agentFile.filePath)) {
|
|
1484
|
+
continue;
|
|
1485
|
+
}
|
|
1486
|
+
const existing = fs.readFileSync(agentFile.filePath, "utf8");
|
|
1487
|
+
if (existing.includes(RIPPLE_START) && existing.includes(RIPPLE_END)) {
|
|
1488
|
+
// Replace only the Ripple section — preserve everything outside markers
|
|
1489
|
+
const before = existing.substring(0, existing.indexOf(RIPPLE_START));
|
|
1490
|
+
const after = existing.substring(existing.indexOf(RIPPLE_END) + RIPPLE_END.length);
|
|
1491
|
+
fs.writeFileSync(agentFile.filePath, `${before}${rippleSection}${after}`);
|
|
1492
|
+
}
|
|
1493
|
+
else if (existing.includes("Auto-generated by Ripple")) {
|
|
1494
|
+
// Legacy file written before markers existed — migrate to marker format
|
|
1495
|
+
const signatureIndex = existing.indexOf("# ");
|
|
1496
|
+
const beforeRipple = signatureIndex > 0 ? existing.substring(0, signatureIndex) : "";
|
|
1497
|
+
fs.writeFileSync(agentFile.filePath, `${beforeRipple}${rippleSection}`);
|
|
1498
|
+
}
|
|
1499
|
+
// No markers and no legacy signature: developer-owned file, leave it unchanged.
|
|
1500
|
+
}
|
|
1501
|
+
catch { /* best-effort */ }
|
|
1502
|
+
}
|
|
1503
|
+
}
|
|
1504
|
+
catch { /* best-effort */ }
|
|
1505
|
+
}
|
|
1506
|
+
// ── SINGLE FILE FOCUS (per-save fast write) ────────────────────────────────
|
|
1507
|
+
/**
|
|
1508
|
+
* Fast path used after a single file update. The full context bundle is
|
|
1509
|
+
* debounced separately, but the target focus file should be fresh immediately.
|
|
1510
|
+
*/
|
|
1511
|
+
generateSingleFocus(graph, filePath) {
|
|
1512
|
+
if (!this.contextGenerationEnabled) {
|
|
1513
|
+
return;
|
|
1514
|
+
}
|
|
1515
|
+
try {
|
|
1516
|
+
const focused = this.buildFocusedContext(graph, filePath);
|
|
1517
|
+
if (!focused) {
|
|
1518
|
+
return;
|
|
1519
|
+
}
|
|
1520
|
+
const focusDir = path.join(path.dirname(this.persistPath), ".cache", "focus");
|
|
1521
|
+
if (!fs.existsSync(focusDir)) {
|
|
1522
|
+
fs.mkdirSync(focusDir, { recursive: true });
|
|
1523
|
+
}
|
|
1524
|
+
fs.writeFileSync(path.join(focusDir, `${focused.focusKey}.json`), JSON.stringify(focused, null, 2));
|
|
1525
|
+
}
|
|
1526
|
+
catch (err) {
|
|
1527
|
+
console.warn("[Ripple] Single focus write failed:", err);
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
} // end GraphPersistence
|
|
1531
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1532
|
+
// HELPERS
|
|
1533
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1534
|
+
function sha1(content) {
|
|
1535
|
+
return crypto.createHash("sha1").update(content).digest("hex");
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Creates the readable portion of a focus-file key from nearby path segments.
|
|
1539
|
+
* The collision guard in makeFocusKey() appends a hash when this is not unique.
|
|
1540
|
+
*/
|
|
1541
|
+
function getBaseKey(filePath) {
|
|
1542
|
+
const parts = filePath.split(path.sep);
|
|
1543
|
+
const base = path.basename(filePath, path.extname(filePath));
|
|
1544
|
+
if (parts.length >= 4) {
|
|
1545
|
+
return `${parts[parts.length - 3]}-${parts[parts.length - 2]}-${base}`;
|
|
1546
|
+
}
|
|
1547
|
+
if (parts.length >= 2) {
|
|
1548
|
+
return `${parts[parts.length - 2]}-${base}`;
|
|
1549
|
+
}
|
|
1550
|
+
return base;
|
|
1551
|
+
}
|
|
1552
|
+
/**
|
|
1553
|
+
* Creates a stable, unique focus-file key. Monorepos often contain many files
|
|
1554
|
+
* named index.ts or route.ts, so duplicate readable keys get a short path hash.
|
|
1555
|
+
*/
|
|
1556
|
+
function makeFocusKey(filePath, graph) {
|
|
1557
|
+
const key = getBaseKey(filePath);
|
|
1558
|
+
let collision = false;
|
|
1559
|
+
for (const otherPath of graph.files.keys()) {
|
|
1560
|
+
if (otherPath === filePath) {
|
|
1561
|
+
continue;
|
|
1562
|
+
}
|
|
1563
|
+
if (getBaseKey(otherPath) === key) {
|
|
1564
|
+
collision = true;
|
|
1565
|
+
break;
|
|
1566
|
+
}
|
|
1567
|
+
}
|
|
1568
|
+
if (collision) {
|
|
1569
|
+
const shortHash = crypto.createHash("sha1").update(filePath).digest("hex").slice(0, 6);
|
|
1570
|
+
return `${key}-${shortHash}`;
|
|
1571
|
+
}
|
|
1572
|
+
return key;
|
|
1573
|
+
}
|
|
1574
|
+
function makeSymbolId(filePath, symbolName) {
|
|
1575
|
+
return `${filePath}::${symbolName}`;
|
|
1576
|
+
}
|
|
1577
|
+
function makeChangeGroup() {
|
|
1578
|
+
return `save_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`;
|
|
1579
|
+
}
|
|
1580
|
+
const SOURCE_GLOB = "**/*.{ts,tsx,js,jsx,py}";
|
|
1581
|
+
// Risk thresholds are deliberately simple and visible. They are safety signals
|
|
1582
|
+
// for users and agents, not proofs of semantic impact.
|
|
1583
|
+
exports.DANGEROUS_BLAST_RADIUS = 5;
|
|
1584
|
+
exports.CAUTION_BLAST_RADIUS = 2;
|
|
1585
|
+
exports.DANGEROUS_CHURN = 15;
|
|
1586
|
+
exports.CAUTION_CHURN = 8;
|
|
1587
|
+
const HIGH_BLAST_CRITICAL = 3;
|
|
1588
|
+
const HIGH_CHURN_CRITICAL = 10;
|
|
1589
|
+
exports.HIGH_RISK_CALLER_COUNT = 3;
|
|
1590
|
+
const IGNORE_DIRS = [
|
|
1591
|
+
"node_modules", ".git", "dist", "out", "build",
|
|
1592
|
+
".next", ".ripple", ".turbo", ".vercel", "coverage",
|
|
1593
|
+
];
|
|
1594
|
+
function shouldIgnore(filePath) {
|
|
1595
|
+
return IGNORE_DIRS.some((dir) => filePath.includes(`${path.sep}${dir}${path.sep}`) ||
|
|
1596
|
+
filePath.endsWith(`${path.sep}${dir}`));
|
|
1597
|
+
}
|
|
1598
|
+
async function findWorkspaceSourceFiles(workspaceRoot) {
|
|
1599
|
+
const files = await (0, glob_1.glob)(SOURCE_GLOB, {
|
|
1600
|
+
cwd: workspaceRoot,
|
|
1601
|
+
absolute: true,
|
|
1602
|
+
ignore: IGNORE_DIRS.map((dir) => `**/${dir}/**`),
|
|
1603
|
+
nodir: true,
|
|
1604
|
+
});
|
|
1605
|
+
return files
|
|
1606
|
+
.map((filePath) => path.resolve(filePath).split(/[\\/]/).join(path.sep))
|
|
1607
|
+
.filter((filePath) => !shouldIgnore(filePath));
|
|
1608
|
+
}
|
|
1609
|
+
function isPythonFile(filePath) {
|
|
1610
|
+
return path.extname(filePath).toLowerCase() === ".py";
|
|
1611
|
+
}
|
|
1612
|
+
function isPythonIdentifier(value) {
|
|
1613
|
+
return /^[A-Za-z_][A-Za-z0-9_]*$/.test(value);
|
|
1614
|
+
}
|
|
1615
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1616
|
+
// GRAPH ENGINE
|
|
1617
|
+
// ────────────────────────────────────────────────────────────────────────────
|
|
1618
|
+
class GraphEngine {
|
|
1619
|
+
/**
|
|
1620
|
+
* Disables writing .ripple/ context files while keeping the in-memory graph
|
|
1621
|
+
* available for editor features.
|
|
1622
|
+
*/
|
|
1623
|
+
setContextGeneration(enabled) {
|
|
1624
|
+
this.persistence.contextGenerationEnabled = enabled;
|
|
1625
|
+
}
|
|
1626
|
+
constructor(workspaceRoot) {
|
|
1627
|
+
// Scans rebuild a large part of the graph. File-system events that arrive
|
|
1628
|
+
// during a scan are queued and replayed afterward instead of mutating midway.
|
|
1629
|
+
this.isScanning = false;
|
|
1630
|
+
this.pendingUpdates = new Set();
|
|
1631
|
+
this.pendingAdds = new Set();
|
|
1632
|
+
this.pendingDeletes = new Set();
|
|
1633
|
+
this.pendingFullRescan = false;
|
|
1634
|
+
this.sessionNewFiles = new Set();
|
|
1635
|
+
this.workspaceRoot = workspaceRoot;
|
|
1636
|
+
this.graph = new types_1.SystemGraph();
|
|
1637
|
+
this.history = new types_1.HistoryLog();
|
|
1638
|
+
this.persistence = new GraphPersistence(workspaceRoot);
|
|
1639
|
+
this.persistence.load(this.history);
|
|
1640
|
+
this.project = this.createProject();
|
|
1641
|
+
}
|
|
1642
|
+
getAdapterSupport() {
|
|
1643
|
+
if (!this.adapterSupportCache) {
|
|
1644
|
+
this.adapterSupportCache = (0, adapters_1.detectWorkspaceAdapters)(this.workspaceRoot);
|
|
1645
|
+
}
|
|
1646
|
+
return this.adapterSupportCache;
|
|
1647
|
+
}
|
|
1648
|
+
invalidateAdapterSupport() {
|
|
1649
|
+
this.adapterSupportCache = undefined;
|
|
1650
|
+
}
|
|
1651
|
+
createProject() {
|
|
1652
|
+
return new ts_morph_1.Project({
|
|
1653
|
+
compilerOptions: {
|
|
1654
|
+
allowJs: true,
|
|
1655
|
+
jsx: 4, // JsxEmit.ReactJSX for .tsx files
|
|
1656
|
+
allowSyntheticDefaultImports: true,
|
|
1657
|
+
esModuleInterop: true,
|
|
1658
|
+
moduleResolution: 2, // NodeJs
|
|
1659
|
+
target: 99, // ESNext
|
|
1660
|
+
strict: false, // Analysis should survive user type errors
|
|
1661
|
+
},
|
|
1662
|
+
skipAddingFilesFromTsConfig: true,
|
|
1663
|
+
skipFileDependencyResolution: true,
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
dispose() {
|
|
1667
|
+
if (this.cacheWriteTimer) {
|
|
1668
|
+
clearTimeout(this.cacheWriteTimer);
|
|
1669
|
+
this.cacheWriteTimer = undefined;
|
|
1670
|
+
this.persistence.saveCache(this.graph);
|
|
1671
|
+
this.persistence.generateContext(this.graph, this.history);
|
|
1672
|
+
}
|
|
1673
|
+
}
|
|
1674
|
+
// ── PATH CONVERSION ───────────────────────────────────────────────────────
|
|
1675
|
+
// Keep this invariant strict: ts-morph receives forward slashes, while graph
|
|
1676
|
+
// Maps use OS-native paths so fs/path comparisons remain reliable.
|
|
1677
|
+
toTsMorphPath(filePath) {
|
|
1678
|
+
return filePath.split(path.sep).join("/");
|
|
1679
|
+
}
|
|
1680
|
+
toGraphPath(filePath) {
|
|
1681
|
+
return filePath.split("/").join(path.sep);
|
|
1682
|
+
}
|
|
1683
|
+
getProjectSourceFile(filePath) {
|
|
1684
|
+
const tsMorphPath = this.toTsMorphPath(filePath);
|
|
1685
|
+
return (this.project.getSourceFile(tsMorphPath) ??
|
|
1686
|
+
this.project.addSourceFileAtPathIfExists(tsMorphPath));
|
|
1687
|
+
}
|
|
1688
|
+
sourceFileImportsTarget(sourceFile, importerPath, targetPath) {
|
|
1689
|
+
// Used during incremental updates to determine whether a known importer
|
|
1690
|
+
// still points at a re-parsed target file.
|
|
1691
|
+
for (const decl of sourceFile.getImportDeclarations()) {
|
|
1692
|
+
const rawSpecifier = decl.getModuleSpecifierValue();
|
|
1693
|
+
const rawTarget = rawSpecifier.startsWith(".") &&
|
|
1694
|
+
(rawSpecifier.endsWith(".css") || rawSpecifier.endsWith(".scss") ||
|
|
1695
|
+
rawSpecifier.endsWith(".sass") || rawSpecifier.endsWith(".less"))
|
|
1696
|
+
? path.join(path.dirname(importerPath), rawSpecifier).split(path.sep).join("/")
|
|
1697
|
+
: (0, normalizer_1.normalizeImportPath)(rawSpecifier, importerPath, this.workspaceRoot);
|
|
1698
|
+
if (!rawTarget) {
|
|
1699
|
+
continue;
|
|
1700
|
+
}
|
|
1701
|
+
const graphTarget = this.toGraphPath(rawTarget);
|
|
1702
|
+
if (graphTarget === targetPath) {
|
|
1703
|
+
return true;
|
|
1704
|
+
}
|
|
1705
|
+
if (this.isBarrelFile(graphTarget) &&
|
|
1706
|
+
this.resolveBarrelSources(graphTarget).includes(targetPath)) {
|
|
1707
|
+
return true;
|
|
1708
|
+
}
|
|
1709
|
+
}
|
|
1710
|
+
return false;
|
|
1711
|
+
}
|
|
1712
|
+
debouncedCacheWrite() {
|
|
1713
|
+
if (this.cacheWriteTimer) {
|
|
1714
|
+
clearTimeout(this.cacheWriteTimer);
|
|
1715
|
+
}
|
|
1716
|
+
this.cacheWriteTimer = setTimeout(() => {
|
|
1717
|
+
this.cacheWriteTimer = undefined;
|
|
1718
|
+
this.persistence.saveCache(this.graph);
|
|
1719
|
+
this.persistence.generateContext(this.graph, this.history);
|
|
1720
|
+
}, 2000);
|
|
1721
|
+
}
|
|
1722
|
+
async rebuildFromDisk(onProgress, priorityFile) {
|
|
1723
|
+
// Full rebuild is used when project configuration changes, such as
|
|
1724
|
+
// tsconfig paths or workspace package definitions.
|
|
1725
|
+
if (this.isScanning) {
|
|
1726
|
+
this.pendingFullRescan = true;
|
|
1727
|
+
return;
|
|
1728
|
+
}
|
|
1729
|
+
this.persistence.invalidatePatternCache();
|
|
1730
|
+
if (this.cacheWriteTimer) {
|
|
1731
|
+
clearTimeout(this.cacheWriteTimer);
|
|
1732
|
+
this.cacheWriteTimer = undefined;
|
|
1733
|
+
}
|
|
1734
|
+
this.isScanning = true;
|
|
1735
|
+
try {
|
|
1736
|
+
const validFiles = await findWorkspaceSourceFiles(this.workspaceRoot);
|
|
1737
|
+
validFiles.sort((a, b) => {
|
|
1738
|
+
const aBarrel = /[\/\\]index\.(ts|tsx|js|jsx)$/.test(a) ? 0 : 1;
|
|
1739
|
+
const bBarrel = /[\/\\]index\.(ts|tsx|js|jsx)$/.test(b) ? 0 : 1;
|
|
1740
|
+
return aBarrel - bBarrel;
|
|
1741
|
+
});
|
|
1742
|
+
if (priorityFile && !shouldIgnore(priorityFile)) {
|
|
1743
|
+
validFiles.sort((a, b) => (a === priorityFile ? -1 : b === priorityFile ? 1 : 0));
|
|
1744
|
+
}
|
|
1745
|
+
this.graph.files.clear();
|
|
1746
|
+
this.graph.symbols.clear();
|
|
1747
|
+
this.project = this.createProject();
|
|
1748
|
+
this.sessionNewFiles.clear();
|
|
1749
|
+
let scanned = 0;
|
|
1750
|
+
const total = validFiles.length * 2;
|
|
1751
|
+
for (const fp of validFiles) {
|
|
1752
|
+
this.ensureFileNode(fp);
|
|
1753
|
+
}
|
|
1754
|
+
for (const fp of validFiles) {
|
|
1755
|
+
try {
|
|
1756
|
+
this.parseImportsAndExports(fp, false);
|
|
1757
|
+
}
|
|
1758
|
+
catch {
|
|
1759
|
+
console.warn("[Ripple] Rebuild parse error:", fp);
|
|
1760
|
+
}
|
|
1761
|
+
scanned++;
|
|
1762
|
+
onProgress?.(scanned, total);
|
|
1763
|
+
}
|
|
1764
|
+
for (const fp of validFiles) {
|
|
1765
|
+
try {
|
|
1766
|
+
this.parseCallsOnly(fp);
|
|
1767
|
+
}
|
|
1768
|
+
catch {
|
|
1769
|
+
console.warn("[Ripple] Rebuild call parse error:", fp);
|
|
1770
|
+
}
|
|
1771
|
+
scanned++;
|
|
1772
|
+
onProgress?.(scanned, total);
|
|
1773
|
+
}
|
|
1774
|
+
this.isScanning = false;
|
|
1775
|
+
this.processPendingChanges();
|
|
1776
|
+
this.persistence.saveCache(this.graph);
|
|
1777
|
+
this.persistence.generateContext(this.graph, this.history);
|
|
1778
|
+
if (!this.history.hasBaseline()) {
|
|
1779
|
+
this.history.log({
|
|
1780
|
+
timestamp: Date.now(),
|
|
1781
|
+
type: "baseline_snapshot",
|
|
1782
|
+
source: "initial_scan",
|
|
1783
|
+
metadata: `files:${validFiles.length}|symbols:${this.graph.symbols.size}`,
|
|
1784
|
+
});
|
|
1785
|
+
}
|
|
1786
|
+
this.persistence.flush(this.history);
|
|
1787
|
+
}
|
|
1788
|
+
catch (err) {
|
|
1789
|
+
this.isScanning = false;
|
|
1790
|
+
throw err;
|
|
1791
|
+
}
|
|
1792
|
+
if (this.pendingFullRescan) {
|
|
1793
|
+
this.pendingFullRescan = false;
|
|
1794
|
+
await this.rebuildFromDisk(onProgress, priorityFile);
|
|
1795
|
+
}
|
|
1796
|
+
}
|
|
1797
|
+
// ── INITIAL SCAN ──────────────────────────────────────────────────────────
|
|
1798
|
+
/**
|
|
1799
|
+
* Starts from cache when possible and repairs only stale or newly discovered
|
|
1800
|
+
* files. With no cache, performs a full two-pass scan.
|
|
1801
|
+
*/
|
|
1802
|
+
async initialScan(onProgress, priorityFile) {
|
|
1803
|
+
this.isScanning = true;
|
|
1804
|
+
this.invalidateAdapterSupport();
|
|
1805
|
+
const validFiles = await findWorkspaceSourceFiles(this.workspaceRoot);
|
|
1806
|
+
const staleFiles = this.persistence.loadCache(this.graph);
|
|
1807
|
+
if (this.graph.files.size === 0) {
|
|
1808
|
+
console.log("[Ripple] No cache — full scan.");
|
|
1809
|
+
validFiles.sort((a, b) => {
|
|
1810
|
+
const aBarrel = /[\/\\]index\.(ts|tsx|js|jsx)$/.test(a) ? 0 : 1;
|
|
1811
|
+
const bBarrel = /[\/\\]index\.(ts|tsx|js|jsx)$/.test(b) ? 0 : 1;
|
|
1812
|
+
return aBarrel - bBarrel;
|
|
1813
|
+
});
|
|
1814
|
+
if (priorityFile && !shouldIgnore(priorityFile)) {
|
|
1815
|
+
validFiles.sort((a, b) => (a === priorityFile ? -1 : b === priorityFile ? 1 : 0));
|
|
1816
|
+
}
|
|
1817
|
+
for (const fp of validFiles) {
|
|
1818
|
+
this.ensureFileNode(fp);
|
|
1819
|
+
}
|
|
1820
|
+
// First install is a baseline, not a stream of file_created events.
|
|
1821
|
+
// Real file_created events come from addFile() and cache repair.
|
|
1822
|
+
let scanned = 0;
|
|
1823
|
+
const total = validFiles.length * 2;
|
|
1824
|
+
for (const fp of validFiles) {
|
|
1825
|
+
try {
|
|
1826
|
+
this.parseImportsAndExports(fp, false);
|
|
1827
|
+
}
|
|
1828
|
+
catch {
|
|
1829
|
+
console.warn("[Ripple] Parse error:", fp);
|
|
1830
|
+
}
|
|
1831
|
+
scanned++;
|
|
1832
|
+
onProgress?.(scanned, total);
|
|
1833
|
+
}
|
|
1834
|
+
for (const fp of validFiles) {
|
|
1835
|
+
try {
|
|
1836
|
+
this.parseCallsOnly(fp);
|
|
1837
|
+
}
|
|
1838
|
+
catch {
|
|
1839
|
+
console.warn("[Ripple] Call parse error:", fp);
|
|
1840
|
+
}
|
|
1841
|
+
scanned++;
|
|
1842
|
+
onProgress?.(scanned, total);
|
|
1843
|
+
}
|
|
1844
|
+
}
|
|
1845
|
+
else {
|
|
1846
|
+
const newFilesOnDisk = validFiles.filter((f) => !this.graph.files.has(f));
|
|
1847
|
+
const newFilesOnDiskSet = new Set(newFilesOnDisk);
|
|
1848
|
+
const repairSet = new Set([...staleFiles, ...newFilesOnDisk]);
|
|
1849
|
+
// Expand to the connected neighborhood until stable. Partial repair is
|
|
1850
|
+
// risky because reverse edges can become stale if only one side is parsed.
|
|
1851
|
+
let expanded = true;
|
|
1852
|
+
while (expanded) {
|
|
1853
|
+
expanded = false;
|
|
1854
|
+
Array.from(repairSet).forEach((filePath) => {
|
|
1855
|
+
const node = this.graph.files.get(filePath);
|
|
1856
|
+
if (!node) {
|
|
1857
|
+
return;
|
|
1858
|
+
}
|
|
1859
|
+
node.importedBy.forEach((importerPath) => {
|
|
1860
|
+
if (!repairSet.has(importerPath)) {
|
|
1861
|
+
repairSet.add(importerPath);
|
|
1862
|
+
expanded = true;
|
|
1863
|
+
}
|
|
1864
|
+
});
|
|
1865
|
+
node.imports.forEach((importedPath) => {
|
|
1866
|
+
if (this.graph.files.has(importedPath) && !repairSet.has(importedPath)) {
|
|
1867
|
+
repairSet.add(importedPath);
|
|
1868
|
+
expanded = true;
|
|
1869
|
+
}
|
|
1870
|
+
});
|
|
1871
|
+
});
|
|
1872
|
+
}
|
|
1873
|
+
repairSet.forEach((filePath) => {
|
|
1874
|
+
const node = this.graph.files.get(filePath);
|
|
1875
|
+
if (!node) {
|
|
1876
|
+
return;
|
|
1877
|
+
}
|
|
1878
|
+
this.removeFileEdges(filePath, true);
|
|
1879
|
+
node.importedBy.forEach((importerPath) => {
|
|
1880
|
+
this.graph.files.get(importerPath)?.imports.delete(filePath);
|
|
1881
|
+
});
|
|
1882
|
+
node.importedBy.clear();
|
|
1883
|
+
});
|
|
1884
|
+
const repairArray = Array.from(repairSet).filter((f) => fs.existsSync(f));
|
|
1885
|
+
console.log(`[Ripple] Cache repair: ${staleFiles.length} stale + expanded to ${repairArray.length} files`);
|
|
1886
|
+
const knownFilePaths = new Set(this.history.events
|
|
1887
|
+
.filter(e => e.type === "file_created")
|
|
1888
|
+
.map(e => e.source));
|
|
1889
|
+
let scanned = 0;
|
|
1890
|
+
const total = repairArray.length * 2;
|
|
1891
|
+
for (const fp of repairArray) {
|
|
1892
|
+
this.ensureFileNode(fp);
|
|
1893
|
+
}
|
|
1894
|
+
for (const fp of repairArray) {
|
|
1895
|
+
try {
|
|
1896
|
+
const isNew = newFilesOnDiskSet.has(fp) && !knownFilePaths.has(fp);
|
|
1897
|
+
this.parseImportsAndExports(fp, isNew);
|
|
1898
|
+
}
|
|
1899
|
+
catch {
|
|
1900
|
+
console.warn("[Ripple] Repair parse error:", fp);
|
|
1901
|
+
}
|
|
1902
|
+
scanned++;
|
|
1903
|
+
onProgress?.(scanned, total);
|
|
1904
|
+
}
|
|
1905
|
+
for (const fp of repairArray) {
|
|
1906
|
+
try {
|
|
1907
|
+
this.parseCallsOnly(fp);
|
|
1908
|
+
}
|
|
1909
|
+
catch {
|
|
1910
|
+
console.warn("[Ripple] Repair call error:", fp);
|
|
1911
|
+
}
|
|
1912
|
+
scanned++;
|
|
1913
|
+
onProgress?.(scanned, total);
|
|
1914
|
+
}
|
|
1915
|
+
const deleted = Array.from(this.graph.files.keys()).filter((fp) => !fs.existsSync(fp));
|
|
1916
|
+
deleted.forEach((fp) => this.removeFile(fp));
|
|
1917
|
+
}
|
|
1918
|
+
this.isScanning = false;
|
|
1919
|
+
this.processPendingChanges();
|
|
1920
|
+
this.persistence.saveCache(this.graph);
|
|
1921
|
+
this.persistence.generateContext(this.graph, this.history);
|
|
1922
|
+
if (!this.history.hasBaseline()) {
|
|
1923
|
+
this.history.log({
|
|
1924
|
+
timestamp: Date.now(),
|
|
1925
|
+
type: "baseline_snapshot",
|
|
1926
|
+
source: "initial_scan",
|
|
1927
|
+
metadata: `files:${validFiles.length}|symbols:${this.graph.symbols.size}`,
|
|
1928
|
+
});
|
|
1929
|
+
}
|
|
1930
|
+
this.persistence.flush(this.history);
|
|
1931
|
+
if (this.pendingFullRescan) {
|
|
1932
|
+
this.pendingFullRescan = false;
|
|
1933
|
+
await this.rebuildFromDisk(onProgress, priorityFile);
|
|
1934
|
+
}
|
|
1935
|
+
}
|
|
1936
|
+
// ── PENDING QUEUE PROCESSOR ───────────────────────────────────────────────
|
|
1937
|
+
/**
|
|
1938
|
+
* Replays file-system events that arrived while a scan was active. Deletes
|
|
1939
|
+
* run first, then adds, then updates so graph edges are removed before new
|
|
1940
|
+
* nodes and symbols are introduced.
|
|
1941
|
+
*/
|
|
1942
|
+
processPendingChanges() {
|
|
1943
|
+
if (this.pendingDeletes.size === 0 &&
|
|
1944
|
+
this.pendingAdds.size === 0 &&
|
|
1945
|
+
this.pendingUpdates.size === 0) {
|
|
1946
|
+
return;
|
|
1947
|
+
}
|
|
1948
|
+
const total = this.pendingDeletes.size + this.pendingAdds.size + this.pendingUpdates.size;
|
|
1949
|
+
console.log(`[Ripple] Processing ${total} queued change(s) from scan window.`);
|
|
1950
|
+
const addedFiles = new Set(this.pendingAdds);
|
|
1951
|
+
this.pendingDeletes.forEach((fp) => {
|
|
1952
|
+
if (!addedFiles.has(fp)) {
|
|
1953
|
+
this.removeFile(fp);
|
|
1954
|
+
}
|
|
1955
|
+
});
|
|
1956
|
+
this.pendingDeletes.clear();
|
|
1957
|
+
this.pendingAdds.forEach((fp) => this.addFile(fp));
|
|
1958
|
+
this.pendingAdds.clear();
|
|
1959
|
+
this.pendingUpdates.forEach((fp) => {
|
|
1960
|
+
if (addedFiles.has(fp)) {
|
|
1961
|
+
return;
|
|
1962
|
+
}
|
|
1963
|
+
this.updateFile(fp);
|
|
1964
|
+
});
|
|
1965
|
+
this.pendingUpdates.clear();
|
|
1966
|
+
}
|
|
1967
|
+
// ── UPDATE FILE ───────────────────────────────────────────────────────────
|
|
1968
|
+
/**
|
|
1969
|
+
* Incrementally reparses one changed file and logs the semantic diff.
|
|
1970
|
+
* Existing importers are snapshotted before detach, then rechecked after the
|
|
1971
|
+
* target file is rebuilt. New importers are discovered by their own updates.
|
|
1972
|
+
*/
|
|
1973
|
+
updateFile(filePath) {
|
|
1974
|
+
if (this.isScanning) {
|
|
1975
|
+
this.pendingUpdates.add(filePath);
|
|
1976
|
+
return;
|
|
1977
|
+
}
|
|
1978
|
+
if (shouldIgnore(filePath)) {
|
|
1979
|
+
return;
|
|
1980
|
+
}
|
|
1981
|
+
const changeGroup = makeChangeGroup();
|
|
1982
|
+
let content;
|
|
1983
|
+
try {
|
|
1984
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
1985
|
+
}
|
|
1986
|
+
catch {
|
|
1987
|
+
this.removeFile(filePath, changeGroup);
|
|
1988
|
+
return;
|
|
1989
|
+
}
|
|
1990
|
+
const newHash = sha1(content);
|
|
1991
|
+
const existing = this.graph.files.get(filePath);
|
|
1992
|
+
if (existing?.hash === newHash) {
|
|
1993
|
+
return;
|
|
1994
|
+
}
|
|
1995
|
+
const isNewFile = !existing && !this.sessionNewFiles.has(filePath);
|
|
1996
|
+
if (isNewFile) {
|
|
1997
|
+
this.sessionNewFiles.add(filePath);
|
|
1998
|
+
}
|
|
1999
|
+
// Capture the old graph state before removing edges so history can describe
|
|
2000
|
+
// exactly what changed.
|
|
2001
|
+
const oldImports = new Set(existing?.imports ?? []);
|
|
2002
|
+
const oldSymbols = new Set(existing?.symbols ?? []);
|
|
2003
|
+
const knownImporters = new Set(existing?.importedBy ?? []);
|
|
2004
|
+
const oldSymbolHashes = new Map();
|
|
2005
|
+
const oldCalls = new Map();
|
|
2006
|
+
const oldCalledBy = new Map();
|
|
2007
|
+
const snapshotSourceFile = this.getProjectSourceFile(filePath);
|
|
2008
|
+
oldSymbols.forEach((symbolId) => {
|
|
2009
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2010
|
+
if (!sym) {
|
|
2011
|
+
return;
|
|
2012
|
+
}
|
|
2013
|
+
oldCalls.set(symbolId, new Set(sym.calls));
|
|
2014
|
+
oldCalledBy.set(symbolId, new Set(sym.calledBy));
|
|
2015
|
+
if (sym.symbolHash) {
|
|
2016
|
+
oldSymbolHashes.set(symbolId, sym.symbolHash);
|
|
2017
|
+
}
|
|
2018
|
+
else if (snapshotSourceFile) {
|
|
2019
|
+
const symbolText = this.getSymbolText(snapshotSourceFile, sym.name);
|
|
2020
|
+
if (symbolText) {
|
|
2021
|
+
oldSymbolHashes.set(symbolId, sha1(symbolText));
|
|
2022
|
+
}
|
|
2023
|
+
}
|
|
2024
|
+
});
|
|
2025
|
+
// Remove the old target node and its edges before parsing the new version.
|
|
2026
|
+
this.persistence.invalidatePatternCache();
|
|
2027
|
+
this.removeFileEdges(filePath, true);
|
|
2028
|
+
knownImporters.forEach((importerPath) => {
|
|
2029
|
+
this.graph.files.get(importerPath)?.imports.delete(filePath);
|
|
2030
|
+
});
|
|
2031
|
+
this.graph.files.delete(filePath);
|
|
2032
|
+
// Parse the changed file in place and rebuild its imports, symbols, calls,
|
|
2033
|
+
// hash, and reverse edges.
|
|
2034
|
+
this.ensureFileNode(filePath);
|
|
2035
|
+
this.parseFile(filePath, content);
|
|
2036
|
+
const newFileNode = this.graph.files.get(filePath);
|
|
2037
|
+
// Reattach known importers only. This keeps updates bounded by the existing
|
|
2038
|
+
// blast radius instead of scanning every file in the project.
|
|
2039
|
+
knownImporters.forEach((importerPath) => {
|
|
2040
|
+
const importerNode = this.graph.files.get(importerPath);
|
|
2041
|
+
if (!importerNode || importerPath === filePath || !fs.existsSync(importerPath)) {
|
|
2042
|
+
return;
|
|
2043
|
+
}
|
|
2044
|
+
if (isPythonFile(importerPath)) {
|
|
2045
|
+
let importerContent;
|
|
2046
|
+
try {
|
|
2047
|
+
importerContent = fs.readFileSync(importerPath, "utf8");
|
|
2048
|
+
}
|
|
2049
|
+
catch {
|
|
2050
|
+
return;
|
|
2051
|
+
}
|
|
2052
|
+
if (this.pythonSourceImportsTarget(importerPath, importerContent, filePath)) {
|
|
2053
|
+
importerNode.imports.add(filePath);
|
|
2054
|
+
newFileNode.importedBy.add(importerPath);
|
|
2055
|
+
}
|
|
2056
|
+
this.parsePythonCalls(importerPath, importerContent);
|
|
2057
|
+
return;
|
|
2058
|
+
}
|
|
2059
|
+
const sourceFile = this.getProjectSourceFile(importerPath);
|
|
2060
|
+
if (!sourceFile) {
|
|
2061
|
+
return;
|
|
2062
|
+
}
|
|
2063
|
+
if (this.sourceFileImportsTarget(sourceFile, importerPath, filePath)) {
|
|
2064
|
+
importerNode.imports.add(filePath);
|
|
2065
|
+
newFileNode.importedBy.add(importerPath);
|
|
2066
|
+
}
|
|
2067
|
+
this.parseCalls(importerPath, sourceFile);
|
|
2068
|
+
});
|
|
2069
|
+
// Diff old and new state into history events for agent context and audits.
|
|
2070
|
+
const now = Date.now();
|
|
2071
|
+
const newSourceFile = this.getProjectSourceFile(filePath);
|
|
2072
|
+
if (isNewFile) {
|
|
2073
|
+
this.history.log({
|
|
2074
|
+
timestamp: now,
|
|
2075
|
+
type: "file_created",
|
|
2076
|
+
source: filePath,
|
|
2077
|
+
fileHash: newFileNode.hash,
|
|
2078
|
+
changeGroup,
|
|
2079
|
+
});
|
|
2080
|
+
}
|
|
2081
|
+
oldImports.forEach((imp) => {
|
|
2082
|
+
if (!newFileNode.imports.has(imp)) {
|
|
2083
|
+
this.history.log({ timestamp: now, type: "import_removed", source: filePath, target: imp, changeGroup });
|
|
2084
|
+
}
|
|
2085
|
+
});
|
|
2086
|
+
newFileNode.imports.forEach((imp) => {
|
|
2087
|
+
if (!oldImports.has(imp)) {
|
|
2088
|
+
this.history.log({ timestamp: now, type: "import_added", source: filePath, target: imp, changeGroup });
|
|
2089
|
+
}
|
|
2090
|
+
});
|
|
2091
|
+
const removedCallEvents = new Set();
|
|
2092
|
+
const logCallRemoved = (source, target) => {
|
|
2093
|
+
const key = `${source}\n${target}`;
|
|
2094
|
+
if (removedCallEvents.has(key)) {
|
|
2095
|
+
return;
|
|
2096
|
+
}
|
|
2097
|
+
removedCallEvents.add(key);
|
|
2098
|
+
this.history.log({ timestamp: now, type: "call_removed", source, target, changeGroup });
|
|
2099
|
+
};
|
|
2100
|
+
oldSymbols.forEach((symbolId) => {
|
|
2101
|
+
if (!newFileNode.symbols.has(symbolId)) {
|
|
2102
|
+
(oldCalls.get(symbolId) ?? new Set()).forEach((targetId) => {
|
|
2103
|
+
logCallRemoved(symbolId, targetId);
|
|
2104
|
+
});
|
|
2105
|
+
(oldCalledBy.get(symbolId) ?? new Set()).forEach((callerId) => {
|
|
2106
|
+
logCallRemoved(callerId, symbolId);
|
|
2107
|
+
});
|
|
2108
|
+
this.history.log({ timestamp: now, type: "symbol_deleted", source: symbolId, changeGroup });
|
|
2109
|
+
}
|
|
2110
|
+
});
|
|
2111
|
+
newFileNode.symbols.forEach((symbolId) => {
|
|
2112
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2113
|
+
const symbolName = sym?.name ?? symbolId.split("::").slice(1).join("::");
|
|
2114
|
+
const symbolText = this.getSymbolText(newSourceFile, symbolName);
|
|
2115
|
+
const newSymbolHash = symbolText ? sha1(symbolText) : sym?.symbolHash;
|
|
2116
|
+
if (!oldSymbols.has(symbolId)) {
|
|
2117
|
+
this.history.log({
|
|
2118
|
+
timestamp: now,
|
|
2119
|
+
type: "symbol_created",
|
|
2120
|
+
source: symbolId,
|
|
2121
|
+
kind: sym?.kind,
|
|
2122
|
+
symbolHash: newSymbolHash,
|
|
2123
|
+
layer: sym?.layer,
|
|
2124
|
+
changeGroup,
|
|
2125
|
+
});
|
|
2126
|
+
}
|
|
2127
|
+
else {
|
|
2128
|
+
const oldHash = oldSymbolHashes.get(symbolId);
|
|
2129
|
+
if (oldHash && newSymbolHash && oldHash !== newSymbolHash) {
|
|
2130
|
+
this.history.log({
|
|
2131
|
+
timestamp: now,
|
|
2132
|
+
type: "symbol_modified",
|
|
2133
|
+
source: symbolId,
|
|
2134
|
+
kind: sym?.kind,
|
|
2135
|
+
symbolHash: newSymbolHash,
|
|
2136
|
+
previousHash: oldHash,
|
|
2137
|
+
layer: sym?.layer,
|
|
2138
|
+
changeGroup,
|
|
2139
|
+
});
|
|
2140
|
+
}
|
|
2141
|
+
}
|
|
2142
|
+
});
|
|
2143
|
+
newFileNode.symbols.forEach((symbolId) => {
|
|
2144
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2145
|
+
if (!sym) {
|
|
2146
|
+
return;
|
|
2147
|
+
}
|
|
2148
|
+
const oldCallSet = oldCalls.get(symbolId) ?? new Set();
|
|
2149
|
+
sym.calls.forEach((targetId) => {
|
|
2150
|
+
if (!oldCallSet.has(targetId)) {
|
|
2151
|
+
const targetNode = this.graph.symbols.get(targetId);
|
|
2152
|
+
this.history.log({ timestamp: now, type: "call_added", source: symbolId, target: targetId, targetCallerCount: targetNode?.calledBy.size, changeGroup });
|
|
2153
|
+
}
|
|
2154
|
+
});
|
|
2155
|
+
oldCallSet.forEach((targetId) => {
|
|
2156
|
+
if (!sym.calls.has(targetId)) {
|
|
2157
|
+
logCallRemoved(symbolId, targetId);
|
|
2158
|
+
}
|
|
2159
|
+
});
|
|
2160
|
+
});
|
|
2161
|
+
this.persistence.flush(this.history);
|
|
2162
|
+
this.persistence.generateSingleFocus(this.graph, filePath);
|
|
2163
|
+
this.debouncedCacheWrite();
|
|
2164
|
+
}
|
|
2165
|
+
// ── ADD FILE ──────────────────────────────────────────────────────────────
|
|
2166
|
+
/**
|
|
2167
|
+
* Adds a newly discovered source file and records its initial imports,
|
|
2168
|
+
* symbols, and call edges as a single change group.
|
|
2169
|
+
*/
|
|
2170
|
+
addFile(filePath) {
|
|
2171
|
+
if (this.isScanning) {
|
|
2172
|
+
this.pendingAdds.add(filePath);
|
|
2173
|
+
return;
|
|
2174
|
+
}
|
|
2175
|
+
if (shouldIgnore(filePath)) {
|
|
2176
|
+
return;
|
|
2177
|
+
}
|
|
2178
|
+
let content;
|
|
2179
|
+
try {
|
|
2180
|
+
content = fs.readFileSync(filePath, "utf8");
|
|
2181
|
+
}
|
|
2182
|
+
catch {
|
|
2183
|
+
// File may have been created and deleted before the watcher settled.
|
|
2184
|
+
return;
|
|
2185
|
+
}
|
|
2186
|
+
this.sessionNewFiles.add(filePath);
|
|
2187
|
+
this.invalidateAdapterSupport();
|
|
2188
|
+
this.persistence.invalidatePatternCache();
|
|
2189
|
+
this.ensureFileNode(filePath);
|
|
2190
|
+
this.parseFile(filePath, content);
|
|
2191
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2192
|
+
if (!fileNode || fileNode.hash === "") {
|
|
2193
|
+
return;
|
|
2194
|
+
}
|
|
2195
|
+
const changeGroup = makeChangeGroup();
|
|
2196
|
+
const now = Date.now();
|
|
2197
|
+
const sourceFile = this.getProjectSourceFile(filePath);
|
|
2198
|
+
this.history.log({ timestamp: now, type: "file_created", source: filePath, fileHash: fileNode.hash, changeGroup });
|
|
2199
|
+
fileNode.symbols.forEach((symbolId) => {
|
|
2200
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2201
|
+
const symbolText = sym ? this.getSymbolText(sourceFile, sym.name) : null;
|
|
2202
|
+
this.history.log({
|
|
2203
|
+
timestamp: now,
|
|
2204
|
+
type: "symbol_created",
|
|
2205
|
+
source: symbolId,
|
|
2206
|
+
kind: sym?.kind,
|
|
2207
|
+
symbolHash: symbolText ? sha1(symbolText) : sym?.symbolHash,
|
|
2208
|
+
layer: sym?.layer,
|
|
2209
|
+
changeGroup,
|
|
2210
|
+
});
|
|
2211
|
+
});
|
|
2212
|
+
fileNode.imports.forEach((importedPath) => {
|
|
2213
|
+
this.history.log({ timestamp: now, type: "import_added", source: filePath, target: importedPath, changeGroup });
|
|
2214
|
+
});
|
|
2215
|
+
fileNode.symbols.forEach((symbolId) => {
|
|
2216
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2217
|
+
if (!sym) {
|
|
2218
|
+
return;
|
|
2219
|
+
}
|
|
2220
|
+
sym.calls.forEach((targetId) => {
|
|
2221
|
+
const targetNode = this.graph.symbols.get(targetId);
|
|
2222
|
+
this.history.log({
|
|
2223
|
+
timestamp: now,
|
|
2224
|
+
type: "call_added",
|
|
2225
|
+
source: symbolId,
|
|
2226
|
+
target: targetId,
|
|
2227
|
+
targetCallerCount: targetNode?.calledBy.size,
|
|
2228
|
+
changeGroup,
|
|
2229
|
+
});
|
|
2230
|
+
});
|
|
2231
|
+
});
|
|
2232
|
+
this.persistence.flush(this.history);
|
|
2233
|
+
this.persistence.generateSingleFocus(this.graph, filePath);
|
|
2234
|
+
this.debouncedCacheWrite();
|
|
2235
|
+
}
|
|
2236
|
+
// ── REMOVE FILE ───────────────────────────────────────────────────────────
|
|
2237
|
+
/**
|
|
2238
|
+
* Removes a source file from the graph and deletes all forward/reverse edges
|
|
2239
|
+
* that reference it.
|
|
2240
|
+
*/
|
|
2241
|
+
removeFile(filePath, existingChangeGroup) {
|
|
2242
|
+
if (this.isScanning) {
|
|
2243
|
+
this.pendingDeletes.add(filePath);
|
|
2244
|
+
return;
|
|
2245
|
+
}
|
|
2246
|
+
if (!this.graph.files.has(filePath)) {
|
|
2247
|
+
return;
|
|
2248
|
+
}
|
|
2249
|
+
const changeGroup = existingChangeGroup ?? makeChangeGroup();
|
|
2250
|
+
const fileNodeToDelete = this.graph.files.get(filePath);
|
|
2251
|
+
this.invalidateAdapterSupport();
|
|
2252
|
+
this.persistence.invalidatePatternCache();
|
|
2253
|
+
fileNodeToDelete.importedBy.forEach((importerPath) => {
|
|
2254
|
+
const importerNode = this.graph.files.get(importerPath);
|
|
2255
|
+
if (importerNode) {
|
|
2256
|
+
importerNode.imports.delete(filePath);
|
|
2257
|
+
}
|
|
2258
|
+
});
|
|
2259
|
+
this.removeFileEdges(filePath, false, changeGroup);
|
|
2260
|
+
this.graph.files.delete(filePath);
|
|
2261
|
+
this.history.log({ timestamp: Date.now(), type: "file_deleted", source: filePath, changeGroup });
|
|
2262
|
+
this.persistence.flush(this.history);
|
|
2263
|
+
this.debouncedCacheWrite();
|
|
2264
|
+
}
|
|
2265
|
+
// ── PARSE PIPELINE ────────────────────────────────────────────────────────
|
|
2266
|
+
/**
|
|
2267
|
+
* First scan pass: imports, exports, and local symbols. Call edges are parsed
|
|
2268
|
+
* later after every file has had a chance to register its symbols.
|
|
2269
|
+
*/
|
|
2270
|
+
parseImportsAndExports(filePath, isNewFile = false) {
|
|
2271
|
+
const src = (() => {
|
|
2272
|
+
try {
|
|
2273
|
+
return fs.readFileSync(filePath, "utf8");
|
|
2274
|
+
}
|
|
2275
|
+
catch {
|
|
2276
|
+
return null;
|
|
2277
|
+
}
|
|
2278
|
+
})();
|
|
2279
|
+
if (src === null) {
|
|
2280
|
+
return;
|
|
2281
|
+
}
|
|
2282
|
+
const fileNode = this.ensureFileNode(filePath);
|
|
2283
|
+
fileNode.hash = sha1(src);
|
|
2284
|
+
fileNode.lastModifiedAt = Date.now();
|
|
2285
|
+
if (isNewFile) {
|
|
2286
|
+
this.history.log({ timestamp: Date.now(), type: "file_created", source: filePath });
|
|
2287
|
+
}
|
|
2288
|
+
if (isPythonFile(filePath)) {
|
|
2289
|
+
try {
|
|
2290
|
+
this.parsePythonImportsAndSymbols(filePath, src);
|
|
2291
|
+
fileNode.hasParseError = false;
|
|
2292
|
+
}
|
|
2293
|
+
catch {
|
|
2294
|
+
fileNode.hasParseError = true;
|
|
2295
|
+
}
|
|
2296
|
+
return;
|
|
2297
|
+
}
|
|
2298
|
+
const tsMorphPath = this.toTsMorphPath(filePath);
|
|
2299
|
+
let sourceFile = this.project.getSourceFile(tsMorphPath);
|
|
2300
|
+
if (sourceFile) {
|
|
2301
|
+
sourceFile.replaceWithText(src);
|
|
2302
|
+
}
|
|
2303
|
+
else {
|
|
2304
|
+
sourceFile = this.project.createSourceFile(tsMorphPath, src, { overwrite: true });
|
|
2305
|
+
}
|
|
2306
|
+
try {
|
|
2307
|
+
this.parseImports(filePath, sourceFile);
|
|
2308
|
+
this.parseExports(filePath, sourceFile);
|
|
2309
|
+
this.parseInternalSymbols(filePath, sourceFile);
|
|
2310
|
+
fileNode.hasParseError = false;
|
|
2311
|
+
}
|
|
2312
|
+
catch {
|
|
2313
|
+
fileNode.hasParseError = true;
|
|
2314
|
+
}
|
|
2315
|
+
}
|
|
2316
|
+
parseCallsOnly(filePath) {
|
|
2317
|
+
if (isPythonFile(filePath)) {
|
|
2318
|
+
try {
|
|
2319
|
+
const src = fs.readFileSync(filePath, "utf8");
|
|
2320
|
+
this.parsePythonCalls(filePath, src);
|
|
2321
|
+
}
|
|
2322
|
+
catch {
|
|
2323
|
+
console.warn("[Ripple] Python call parse error:", filePath);
|
|
2324
|
+
}
|
|
2325
|
+
return;
|
|
2326
|
+
}
|
|
2327
|
+
const sourceFile = this.project.getSourceFile(this.toTsMorphPath(filePath));
|
|
2328
|
+
if (!sourceFile) {
|
|
2329
|
+
return;
|
|
2330
|
+
}
|
|
2331
|
+
this.parseCalls(filePath, sourceFile);
|
|
2332
|
+
}
|
|
2333
|
+
parseFile(filePath, content) {
|
|
2334
|
+
const src = content ?? (() => {
|
|
2335
|
+
try {
|
|
2336
|
+
return fs.readFileSync(filePath, "utf8");
|
|
2337
|
+
}
|
|
2338
|
+
catch {
|
|
2339
|
+
return null;
|
|
2340
|
+
}
|
|
2341
|
+
})();
|
|
2342
|
+
if (src === null) {
|
|
2343
|
+
return;
|
|
2344
|
+
}
|
|
2345
|
+
const fileNode = this.ensureFileNode(filePath);
|
|
2346
|
+
fileNode.hash = sha1(src);
|
|
2347
|
+
fileNode.lastModifiedAt = Date.now();
|
|
2348
|
+
fileNode.changeCount += 1;
|
|
2349
|
+
if (isPythonFile(filePath)) {
|
|
2350
|
+
try {
|
|
2351
|
+
this.parsePythonImportsAndSymbols(filePath, src);
|
|
2352
|
+
this.parsePythonCalls(filePath, src);
|
|
2353
|
+
fileNode.hasParseError = false;
|
|
2354
|
+
}
|
|
2355
|
+
catch {
|
|
2356
|
+
fileNode.hasParseError = true;
|
|
2357
|
+
console.warn("[Ripple] Python parse error in:", filePath);
|
|
2358
|
+
}
|
|
2359
|
+
return;
|
|
2360
|
+
}
|
|
2361
|
+
const tsMorphPath = this.toTsMorphPath(filePath);
|
|
2362
|
+
let sourceFile = this.project.getSourceFile(tsMorphPath);
|
|
2363
|
+
if (sourceFile) {
|
|
2364
|
+
sourceFile.replaceWithText(src);
|
|
2365
|
+
}
|
|
2366
|
+
else {
|
|
2367
|
+
sourceFile = this.project.createSourceFile(tsMorphPath, src, { overwrite: true });
|
|
2368
|
+
}
|
|
2369
|
+
try {
|
|
2370
|
+
this.parseImports(filePath, sourceFile);
|
|
2371
|
+
this.parseExports(filePath, sourceFile);
|
|
2372
|
+
this.parseInternalSymbols(filePath, sourceFile);
|
|
2373
|
+
this.parseCalls(filePath, sourceFile);
|
|
2374
|
+
fileNode.hasParseError = false;
|
|
2375
|
+
}
|
|
2376
|
+
catch {
|
|
2377
|
+
fileNode.hasParseError = true;
|
|
2378
|
+
console.warn("[Ripple] Parse error in:", filePath);
|
|
2379
|
+
}
|
|
2380
|
+
}
|
|
2381
|
+
// ── PYTHON PARSER ────────────────────────────────────────────────────────
|
|
2382
|
+
parsePythonImportsAndSymbols(filePath, sourceText) {
|
|
2383
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2384
|
+
this.parsePythonImportTargets(filePath, sourceText).forEach((targetPath) => {
|
|
2385
|
+
fileNode.imports.add(targetPath);
|
|
2386
|
+
this.ensureFileNode(targetPath).importedBy.add(filePath);
|
|
2387
|
+
});
|
|
2388
|
+
this.parsePythonSymbolRanges(filePath, sourceText).forEach((symbol) => {
|
|
2389
|
+
const symbolId = makeSymbolId(filePath, symbol.name);
|
|
2390
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
2391
|
+
const existing = this.graph.symbols.get(symbolId);
|
|
2392
|
+
existing.lastModifiedAt = Date.now();
|
|
2393
|
+
existing.symbolHash = sha1(symbol.text);
|
|
2394
|
+
return;
|
|
2395
|
+
}
|
|
2396
|
+
const layerInfo = symbol.kind === "class"
|
|
2397
|
+
? { layer: "unknown", containsLayers: ["unknown"] }
|
|
2398
|
+
: this.detectPythonSymbolLayer(symbol.name, symbol.text);
|
|
2399
|
+
this.graph.symbols.set(symbolId, {
|
|
2400
|
+
id: symbolId,
|
|
2401
|
+
name: symbol.name,
|
|
2402
|
+
file: filePath,
|
|
2403
|
+
kind: symbol.kind,
|
|
2404
|
+
layer: layerInfo.layer,
|
|
2405
|
+
containsLayers: layerInfo.containsLayers,
|
|
2406
|
+
symbolHash: sha1(symbol.text),
|
|
2407
|
+
calls: new Set(),
|
|
2408
|
+
calledBy: new Set(),
|
|
2409
|
+
createdAt: Date.now(),
|
|
2410
|
+
lastModifiedAt: Date.now(),
|
|
2411
|
+
});
|
|
2412
|
+
fileNode.symbols.add(symbolId);
|
|
2413
|
+
});
|
|
2414
|
+
}
|
|
2415
|
+
parsePythonImportTargets(filePath, sourceText) {
|
|
2416
|
+
const targets = [];
|
|
2417
|
+
sourceText.split(/\r?\n/).forEach((rawLine) => {
|
|
2418
|
+
const line = rawLine.replace(/#.*/, "").trim();
|
|
2419
|
+
if (!line) {
|
|
2420
|
+
return;
|
|
2421
|
+
}
|
|
2422
|
+
const importMatch = /^import\s+(.+)$/.exec(line);
|
|
2423
|
+
if (importMatch) {
|
|
2424
|
+
importMatch[1]
|
|
2425
|
+
.split(",")
|
|
2426
|
+
.map((item) => item.trim().split(/\s+as\s+/i)[0]?.trim())
|
|
2427
|
+
.filter((moduleName) => Boolean(moduleName))
|
|
2428
|
+
.forEach((moduleName) => {
|
|
2429
|
+
targets.push(...this.resolvePythonImportTargets(filePath, moduleName));
|
|
2430
|
+
});
|
|
2431
|
+
return;
|
|
2432
|
+
}
|
|
2433
|
+
const fromMatch = /^from\s+([.\w]+)\s+import\s+(.+)$/.exec(line);
|
|
2434
|
+
if (!fromMatch) {
|
|
2435
|
+
return;
|
|
2436
|
+
}
|
|
2437
|
+
const moduleName = fromMatch[1];
|
|
2438
|
+
const importedNames = fromMatch[2]
|
|
2439
|
+
.replace(/[()]/g, "")
|
|
2440
|
+
.split(",")
|
|
2441
|
+
.map((item) => item.trim().split(/\s+as\s+/i)[0]?.trim())
|
|
2442
|
+
.filter((name) => Boolean(name) && isPythonIdentifier(name));
|
|
2443
|
+
targets.push(...this.resolvePythonImportTargets(filePath, moduleName, importedNames));
|
|
2444
|
+
});
|
|
2445
|
+
return Array.from(new Set(targets));
|
|
2446
|
+
}
|
|
2447
|
+
resolvePythonImportTargets(importerPath, moduleName, importedNames = []) {
|
|
2448
|
+
const targets = [
|
|
2449
|
+
...this.resolvePythonModulePaths(importerPath, moduleName),
|
|
2450
|
+
...importedNames.flatMap((name) => this.resolvePythonModulePaths(importerPath, this.appendPythonImportedName(moduleName, name))),
|
|
2451
|
+
];
|
|
2452
|
+
return Array.from(new Set(targets));
|
|
2453
|
+
}
|
|
2454
|
+
appendPythonImportedName(moduleName, importedName) {
|
|
2455
|
+
return moduleName.replace(/\./g, "").length === 0
|
|
2456
|
+
? `${moduleName}${importedName}`
|
|
2457
|
+
: `${moduleName}.${importedName}`;
|
|
2458
|
+
}
|
|
2459
|
+
resolvePythonModulePaths(importerPath, moduleName) {
|
|
2460
|
+
const leadingDots = /^(\.+)/.exec(moduleName)?.[1] ?? "";
|
|
2461
|
+
const moduleWithoutDots = leadingDots ? moduleName.slice(leadingDots.length) : moduleName;
|
|
2462
|
+
const parts = moduleWithoutDots.split(".").filter(Boolean);
|
|
2463
|
+
if (parts.length === 0) {
|
|
2464
|
+
return [];
|
|
2465
|
+
}
|
|
2466
|
+
const baseDirs = [];
|
|
2467
|
+
if (leadingDots) {
|
|
2468
|
+
let baseDir = path.dirname(importerPath);
|
|
2469
|
+
for (let i = 1; i < leadingDots.length; i++) {
|
|
2470
|
+
baseDir = path.dirname(baseDir);
|
|
2471
|
+
}
|
|
2472
|
+
baseDirs.push(baseDir);
|
|
2473
|
+
}
|
|
2474
|
+
else {
|
|
2475
|
+
baseDirs.push(this.workspaceRoot, path.dirname(importerPath));
|
|
2476
|
+
}
|
|
2477
|
+
const results = [];
|
|
2478
|
+
baseDirs.forEach((baseDir) => {
|
|
2479
|
+
const moduleBase = path.resolve(baseDir, ...parts);
|
|
2480
|
+
this.pythonModuleCandidates(moduleBase).forEach((candidate) => {
|
|
2481
|
+
if (!results.includes(candidate)) {
|
|
2482
|
+
results.push(candidate);
|
|
2483
|
+
}
|
|
2484
|
+
});
|
|
2485
|
+
});
|
|
2486
|
+
return results;
|
|
2487
|
+
}
|
|
2488
|
+
pythonModuleCandidates(moduleBase) {
|
|
2489
|
+
return [
|
|
2490
|
+
`${moduleBase}.py`,
|
|
2491
|
+
path.join(moduleBase, "__init__.py"),
|
|
2492
|
+
]
|
|
2493
|
+
.map((candidate) => path.resolve(candidate).split(/[\\/]/).join(path.sep))
|
|
2494
|
+
.filter((candidate) => !shouldIgnore(candidate) && fs.existsSync(candidate))
|
|
2495
|
+
.filter((candidate) => {
|
|
2496
|
+
try {
|
|
2497
|
+
return fs.statSync(candidate).isFile();
|
|
2498
|
+
}
|
|
2499
|
+
catch {
|
|
2500
|
+
return false;
|
|
2501
|
+
}
|
|
2502
|
+
});
|
|
2503
|
+
}
|
|
2504
|
+
pythonSourceImportsTarget(importerPath, sourceText, targetPath) {
|
|
2505
|
+
return this.parsePythonImportTargets(importerPath, sourceText).includes(targetPath);
|
|
2506
|
+
}
|
|
2507
|
+
parsePythonSymbolRanges(filePath, sourceText) {
|
|
2508
|
+
const lines = sourceText.split(/\r?\n/);
|
|
2509
|
+
const ranges = [];
|
|
2510
|
+
const seen = new Set();
|
|
2511
|
+
lines.forEach((line, index) => {
|
|
2512
|
+
const functionMatch = /^(\s*)(?:async\s+def|def)\s+([A-Za-z_][A-Za-z0-9_]*)\s*\(/.exec(line);
|
|
2513
|
+
const classMatch = /^(\s*)class\s+([A-Za-z_][A-Za-z0-9_]*)\s*(?:\(|:)/.exec(line);
|
|
2514
|
+
const match = functionMatch ?? classMatch;
|
|
2515
|
+
if (!match) {
|
|
2516
|
+
return;
|
|
2517
|
+
}
|
|
2518
|
+
const indent = this.pythonIndent(match[1]);
|
|
2519
|
+
const name = match[2];
|
|
2520
|
+
const kind = classMatch
|
|
2521
|
+
? "class"
|
|
2522
|
+
: indent > 0
|
|
2523
|
+
? "method"
|
|
2524
|
+
: "function";
|
|
2525
|
+
const key = `${name}:${kind}:${index}`;
|
|
2526
|
+
if (seen.has(key)) {
|
|
2527
|
+
return;
|
|
2528
|
+
}
|
|
2529
|
+
seen.add(key);
|
|
2530
|
+
const endLine = this.pythonBlockEndLine(lines, index, indent);
|
|
2531
|
+
ranges.push({
|
|
2532
|
+
name,
|
|
2533
|
+
kind,
|
|
2534
|
+
startLine: index + 1,
|
|
2535
|
+
endLine,
|
|
2536
|
+
indent,
|
|
2537
|
+
text: lines.slice(index, endLine).join("\n"),
|
|
2538
|
+
});
|
|
2539
|
+
});
|
|
2540
|
+
return ranges;
|
|
2541
|
+
}
|
|
2542
|
+
pythonBlockEndLine(lines, startIndex, indent) {
|
|
2543
|
+
for (let i = startIndex + 1; i < lines.length; i++) {
|
|
2544
|
+
const line = lines[i];
|
|
2545
|
+
if (!line.trim() || line.trimStart().startsWith("#") || line.trimStart().startsWith("@")) {
|
|
2546
|
+
continue;
|
|
2547
|
+
}
|
|
2548
|
+
const lineIndent = this.pythonIndent(line.match(/^\s*/)?.[0] ?? "");
|
|
2549
|
+
if (lineIndent <= indent) {
|
|
2550
|
+
return i;
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
return lines.length;
|
|
2554
|
+
}
|
|
2555
|
+
pythonIndent(value) {
|
|
2556
|
+
return value.replace(/\t/g, " ").length;
|
|
2557
|
+
}
|
|
2558
|
+
detectPythonSymbolLayer(symbolName, symbolText) {
|
|
2559
|
+
const lowerName = symbolName.toLowerCase();
|
|
2560
|
+
const lowerText = symbolText.toLowerCase();
|
|
2561
|
+
const layers = [];
|
|
2562
|
+
if (/^(handle_|on_)/.test(lowerName)) {
|
|
2563
|
+
layers.push("handler");
|
|
2564
|
+
}
|
|
2565
|
+
if (/\b(requests|httpx|urllib|sqlalchemy|django\.db|session|cursor|query|select|insert|update|delete)\b/.test(lowerText) ||
|
|
2566
|
+
/\b(open|read_text|write_text|read_csv|to_csv)\s*\(/.test(lowerText)) {
|
|
2567
|
+
layers.push("data");
|
|
2568
|
+
}
|
|
2569
|
+
if (/\b(if|elif|for|while|try|except|match|return)\b/.test(lowerText) || symbolText.length > 80) {
|
|
2570
|
+
layers.push("logic");
|
|
2571
|
+
}
|
|
2572
|
+
if (layers.length === 0) {
|
|
2573
|
+
layers.push("unknown");
|
|
2574
|
+
}
|
|
2575
|
+
return {
|
|
2576
|
+
layer: layers.length > 1 ? "mixed" : layers[0],
|
|
2577
|
+
containsLayers: layers,
|
|
2578
|
+
};
|
|
2579
|
+
}
|
|
2580
|
+
parsePythonCalls(filePath, sourceText) {
|
|
2581
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2582
|
+
if (!fileNode) {
|
|
2583
|
+
return;
|
|
2584
|
+
}
|
|
2585
|
+
const callableSymbols = new Map();
|
|
2586
|
+
fileNode.imports.forEach((importedPath) => {
|
|
2587
|
+
this.graph.files.get(importedPath)?.symbols.forEach((symbolId) => {
|
|
2588
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2589
|
+
if (sym) {
|
|
2590
|
+
callableSymbols.set(sym.name, symbolId);
|
|
2591
|
+
}
|
|
2592
|
+
});
|
|
2593
|
+
});
|
|
2594
|
+
fileNode.symbols.forEach((symbolId) => {
|
|
2595
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2596
|
+
if (sym) {
|
|
2597
|
+
callableSymbols.set(sym.name, symbolId);
|
|
2598
|
+
}
|
|
2599
|
+
});
|
|
2600
|
+
if (callableSymbols.size === 0) {
|
|
2601
|
+
return;
|
|
2602
|
+
}
|
|
2603
|
+
const ranges = this.parsePythonSymbolRanges(filePath, sourceText);
|
|
2604
|
+
const lines = sourceText.split(/\r?\n/);
|
|
2605
|
+
lines.forEach((line, index) => {
|
|
2606
|
+
const lineNumber = index + 1;
|
|
2607
|
+
const enclosing = this.findPythonEnclosingSymbol(filePath, ranges, lineNumber);
|
|
2608
|
+
if (!enclosing) {
|
|
2609
|
+
return;
|
|
2610
|
+
}
|
|
2611
|
+
const calls = line.matchAll(/\b([A-Za-z_][A-Za-z0-9_]*)\s*\(/g);
|
|
2612
|
+
for (const call of calls) {
|
|
2613
|
+
const calledName = call[1];
|
|
2614
|
+
const targetSymbolId = callableSymbols.get(calledName);
|
|
2615
|
+
if (!targetSymbolId) {
|
|
2616
|
+
continue;
|
|
2617
|
+
}
|
|
2618
|
+
this.addCallEdge(enclosing, targetSymbolId);
|
|
2619
|
+
}
|
|
2620
|
+
});
|
|
2621
|
+
}
|
|
2622
|
+
findPythonEnclosingSymbol(filePath, ranges, lineNumber) {
|
|
2623
|
+
const enclosing = ranges
|
|
2624
|
+
.filter((range) => range.startLine <= lineNumber && range.endLine >= lineNumber)
|
|
2625
|
+
.sort((a, b) => b.indent - a.indent)[0];
|
|
2626
|
+
if (!enclosing || enclosing.kind === "class") {
|
|
2627
|
+
return null;
|
|
2628
|
+
}
|
|
2629
|
+
const symbolId = makeSymbolId(filePath, enclosing.name);
|
|
2630
|
+
return this.graph.symbols.has(symbolId) ? symbolId : null;
|
|
2631
|
+
}
|
|
2632
|
+
// ── IMPORTS ───────────────────────────────────────────────────────────────
|
|
2633
|
+
/**
|
|
2634
|
+
* Adds file-level dependency edges. Style imports are treated as local
|
|
2635
|
+
* dependencies, while external npm packages are ignored by normalizeImportPath.
|
|
2636
|
+
*/
|
|
2637
|
+
parseImports(filePath, sourceFile) {
|
|
2638
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2639
|
+
sourceFile.getImportDeclarations().forEach((decl) => {
|
|
2640
|
+
const rawSpecifier = decl.getModuleSpecifierValue();
|
|
2641
|
+
let rawTarget = null;
|
|
2642
|
+
if (rawSpecifier.startsWith(".") &&
|
|
2643
|
+
(rawSpecifier.endsWith(".css") || rawSpecifier.endsWith(".scss") ||
|
|
2644
|
+
rawSpecifier.endsWith(".sass") || rawSpecifier.endsWith(".less"))) {
|
|
2645
|
+
rawTarget = path.join(path.dirname(filePath), rawSpecifier).split(path.sep).join("/");
|
|
2646
|
+
}
|
|
2647
|
+
else {
|
|
2648
|
+
rawTarget = (0, normalizer_1.normalizeImportPath)(rawSpecifier, filePath, this.workspaceRoot);
|
|
2649
|
+
}
|
|
2650
|
+
if (!rawTarget) {
|
|
2651
|
+
return;
|
|
2652
|
+
}
|
|
2653
|
+
const absoluteTarget = this.toGraphPath(rawTarget);
|
|
2654
|
+
fileNode.imports.add(absoluteTarget);
|
|
2655
|
+
this.ensureFileNode(absoluteTarget).importedBy.add(filePath);
|
|
2656
|
+
if (this.isBarrelFile(absoluteTarget)) {
|
|
2657
|
+
this.resolveBarrelSources(absoluteTarget).forEach((src) => {
|
|
2658
|
+
if (fileNode.imports.has(src)) {
|
|
2659
|
+
return;
|
|
2660
|
+
}
|
|
2661
|
+
fileNode.imports.add(src);
|
|
2662
|
+
this.ensureFileNode(src).importedBy.add(filePath);
|
|
2663
|
+
});
|
|
2664
|
+
}
|
|
2665
|
+
});
|
|
2666
|
+
}
|
|
2667
|
+
// ── EXPORTS / SYMBOLS ─────────────────────────────────────────────────────
|
|
2668
|
+
/**
|
|
2669
|
+
* Registers exported functions, classes, methods, and variables as graph
|
|
2670
|
+
* symbols. Exported functions and methods also get coarse layer classification.
|
|
2671
|
+
*/
|
|
2672
|
+
parseExports(filePath, sourceFile) {
|
|
2673
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2674
|
+
const exportedDeclarations = sourceFile.getExportedDeclarations();
|
|
2675
|
+
exportedDeclarations.forEach((declarations, exportName) => {
|
|
2676
|
+
declarations.forEach((decl) => {
|
|
2677
|
+
const kind = this.resolveSymbolKind(decl);
|
|
2678
|
+
if (!kind) {
|
|
2679
|
+
return;
|
|
2680
|
+
}
|
|
2681
|
+
// Prefer declared name over export key, including default exports.
|
|
2682
|
+
const actualName = decl.getName?.() ?? exportName;
|
|
2683
|
+
const symbolId = makeSymbolId(filePath, actualName);
|
|
2684
|
+
const symbolHash = sha1(decl.getText());
|
|
2685
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
2686
|
+
const existing = this.graph.symbols.get(symbolId);
|
|
2687
|
+
existing.lastModifiedAt = Date.now();
|
|
2688
|
+
existing.symbolHash = symbolHash;
|
|
2689
|
+
return;
|
|
2690
|
+
}
|
|
2691
|
+
const layerInfo = kind === "function" || kind === "method"
|
|
2692
|
+
? this.detectSymbolLayer(decl, decl.getText())
|
|
2693
|
+
: { layer: "unknown", containsLayers: ["unknown"] };
|
|
2694
|
+
this.graph.symbols.set(symbolId, {
|
|
2695
|
+
id: symbolId,
|
|
2696
|
+
name: actualName,
|
|
2697
|
+
file: filePath,
|
|
2698
|
+
kind,
|
|
2699
|
+
layer: layerInfo.layer,
|
|
2700
|
+
containsLayers: layerInfo.containsLayers,
|
|
2701
|
+
symbolHash,
|
|
2702
|
+
calls: new Set(),
|
|
2703
|
+
calledBy: new Set(),
|
|
2704
|
+
createdAt: Date.now(),
|
|
2705
|
+
lastModifiedAt: Date.now(),
|
|
2706
|
+
});
|
|
2707
|
+
fileNode.symbols.add(symbolId);
|
|
2708
|
+
});
|
|
2709
|
+
});
|
|
2710
|
+
}
|
|
2711
|
+
// ── INTERNAL SYMBOLS ──────────────────────────────────────────────────────
|
|
2712
|
+
/**
|
|
2713
|
+
* Registers non-exported functions and function-valued variables. These
|
|
2714
|
+
* symbols make same-file call relationships and CodeLens hints more useful.
|
|
2715
|
+
*/
|
|
2716
|
+
parseInternalSymbols(filePath, sourceFile) {
|
|
2717
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2718
|
+
sourceFile.getFunctions().forEach((funcDecl) => {
|
|
2719
|
+
const name = funcDecl.getName();
|
|
2720
|
+
if (!name) {
|
|
2721
|
+
return;
|
|
2722
|
+
}
|
|
2723
|
+
const symbolId = makeSymbolId(filePath, name);
|
|
2724
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
2725
|
+
return;
|
|
2726
|
+
}
|
|
2727
|
+
const layerInfo = this.detectSymbolLayer(funcDecl, funcDecl.getText());
|
|
2728
|
+
this.graph.symbols.set(symbolId, {
|
|
2729
|
+
id: symbolId, name, file: filePath, kind: "function",
|
|
2730
|
+
layer: layerInfo.layer, containsLayers: layerInfo.containsLayers,
|
|
2731
|
+
symbolHash: sha1(funcDecl.getText()),
|
|
2732
|
+
calls: new Set(), calledBy: new Set(),
|
|
2733
|
+
createdAt: Date.now(), lastModifiedAt: Date.now(),
|
|
2734
|
+
});
|
|
2735
|
+
fileNode.symbols.add(symbolId);
|
|
2736
|
+
});
|
|
2737
|
+
sourceFile.getVariableDeclarations().forEach((varDecl) => {
|
|
2738
|
+
const name = varDecl.getName();
|
|
2739
|
+
if (!name || name.includes("{") || name.includes("[")) {
|
|
2740
|
+
return;
|
|
2741
|
+
}
|
|
2742
|
+
const initializer = varDecl.getInitializer();
|
|
2743
|
+
if (!initializer) {
|
|
2744
|
+
return;
|
|
2745
|
+
}
|
|
2746
|
+
const kind = initializer.getKind();
|
|
2747
|
+
if (kind !== ts_morph_1.SyntaxKind.ArrowFunction && kind !== ts_morph_1.SyntaxKind.FunctionExpression) {
|
|
2748
|
+
return;
|
|
2749
|
+
}
|
|
2750
|
+
const symbolId = makeSymbolId(filePath, name);
|
|
2751
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
2752
|
+
return;
|
|
2753
|
+
}
|
|
2754
|
+
const layerInfo = this.detectSymbolLayer(varDecl, varDecl.getText());
|
|
2755
|
+
this.graph.symbols.set(symbolId, {
|
|
2756
|
+
id: symbolId, name, file: filePath, kind: "function",
|
|
2757
|
+
layer: layerInfo.layer, containsLayers: layerInfo.containsLayers,
|
|
2758
|
+
symbolHash: sha1(varDecl.getText()),
|
|
2759
|
+
calls: new Set(), calledBy: new Set(),
|
|
2760
|
+
createdAt: Date.now(), lastModifiedAt: Date.now(),
|
|
2761
|
+
});
|
|
2762
|
+
fileNode.symbols.add(symbolId);
|
|
2763
|
+
});
|
|
2764
|
+
}
|
|
2765
|
+
// ── SYMBOL LAYER DETECTION ────────────────────────────────────────────────
|
|
2766
|
+
/**
|
|
2767
|
+
* Best-effort layer classifier used in agent guidance. It intentionally uses
|
|
2768
|
+
* simple syntax/call-name signals instead of requiring type-checking.
|
|
2769
|
+
*/
|
|
2770
|
+
detectSymbolLayer(funcNode, funcText) {
|
|
2771
|
+
const layers = [];
|
|
2772
|
+
const callNames = funcNode
|
|
2773
|
+
.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression)
|
|
2774
|
+
.map((c) => c.getExpression().getText().split(".").pop() ?? "")
|
|
2775
|
+
.filter(Boolean);
|
|
2776
|
+
let hasJsx = false;
|
|
2777
|
+
try {
|
|
2778
|
+
hasJsx =
|
|
2779
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxElement).length > 0 ||
|
|
2780
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxSelfClosingElement).length > 0 ||
|
|
2781
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxFragment).length > 0;
|
|
2782
|
+
}
|
|
2783
|
+
catch {
|
|
2784
|
+
hasJsx =
|
|
2785
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxElement).length > 0 ||
|
|
2786
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxSelfClosingElement).length > 0;
|
|
2787
|
+
}
|
|
2788
|
+
if (hasJsx) {
|
|
2789
|
+
layers.push("ui");
|
|
2790
|
+
}
|
|
2791
|
+
if (callNames.some((n) => ["useState", "useReducer", "useRef", "useContext", "useAtom", "useSignal", "createSignal"].includes(n))) {
|
|
2792
|
+
layers.push("state");
|
|
2793
|
+
}
|
|
2794
|
+
if (callNames.some((n) => ["useEffect", "useLayoutEffect", "useInsertionEffect", "useMemo", "useCallback"].includes(n))) {
|
|
2795
|
+
layers.push("effect");
|
|
2796
|
+
}
|
|
2797
|
+
const dataPatterns = [
|
|
2798
|
+
"fetch", "axios", "useQuery", "useMutation", "useInfiniteQuery", "trpc", "supabase", "prisma",
|
|
2799
|
+
"getServerSideProps", "getStaticProps", "findFirst", "findMany", "findUnique",
|
|
2800
|
+
"create", "update", "upsert", "deleteMany",
|
|
2801
|
+
];
|
|
2802
|
+
if (callNames.some((n) => dataPatterns.some((p) => n.toLowerCase().includes(p.toLowerCase())))) {
|
|
2803
|
+
layers.push("data");
|
|
2804
|
+
}
|
|
2805
|
+
const funcName = funcNode.getName?.() ?? "";
|
|
2806
|
+
if (/^(handle[A-Z]|on[A-Z])/.test(funcName) ||
|
|
2807
|
+
/^(handle[A-Z]|on[A-Z])/.test(funcText.slice(0, 80))) {
|
|
2808
|
+
layers.push("handler");
|
|
2809
|
+
}
|
|
2810
|
+
if (layers.length === 0) {
|
|
2811
|
+
const hasConditionals = funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.IfStatement).length > 0 ||
|
|
2812
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.SwitchStatement).length > 0 ||
|
|
2813
|
+
funcNode.getDescendantsOfKind(ts_morph_1.SyntaxKind.ConditionalExpression).length > 0;
|
|
2814
|
+
if (hasConditionals || funcText.length > 50) {
|
|
2815
|
+
layers.push("logic");
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
if (layers.length === 0) {
|
|
2819
|
+
layers.push("unknown");
|
|
2820
|
+
}
|
|
2821
|
+
const layer = layers.length > 1 ? "mixed" : layers[0];
|
|
2822
|
+
return { layer, containsLayers: layers };
|
|
2823
|
+
}
|
|
2824
|
+
// ── SYMBOL KIND RESOLVER ──────────────────────────────────────────────────
|
|
2825
|
+
/**
|
|
2826
|
+
* Converts ts-morph node kinds into Ripple's smaller symbol-kind vocabulary.
|
|
2827
|
+
*/
|
|
2828
|
+
resolveSymbolKind(decl) {
|
|
2829
|
+
const kind = decl.getKind();
|
|
2830
|
+
if (kind === ts_morph_1.SyntaxKind.FunctionDeclaration ||
|
|
2831
|
+
kind === ts_morph_1.SyntaxKind.FunctionExpression ||
|
|
2832
|
+
kind === ts_morph_1.SyntaxKind.ArrowFunction) {
|
|
2833
|
+
return "function";
|
|
2834
|
+
}
|
|
2835
|
+
if (kind === ts_morph_1.SyntaxKind.ClassDeclaration || kind === ts_morph_1.SyntaxKind.ClassExpression) {
|
|
2836
|
+
return "class";
|
|
2837
|
+
}
|
|
2838
|
+
if (kind === ts_morph_1.SyntaxKind.MethodDeclaration) {
|
|
2839
|
+
return "method";
|
|
2840
|
+
}
|
|
2841
|
+
if (kind === ts_morph_1.SyntaxKind.VariableDeclaration) {
|
|
2842
|
+
// Function-valued variables should behave like functions for CodeLens and
|
|
2843
|
+
// caller tracking; ordinary values remain variables.
|
|
2844
|
+
const init = decl.getInitializer?.();
|
|
2845
|
+
if (init) {
|
|
2846
|
+
const initKind = init.getKind();
|
|
2847
|
+
if (initKind === ts_morph_1.SyntaxKind.ArrowFunction || initKind === ts_morph_1.SyntaxKind.FunctionExpression) {
|
|
2848
|
+
return "function";
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
return "variable";
|
|
2852
|
+
}
|
|
2853
|
+
if (kind === ts_morph_1.SyntaxKind.VariableStatement) {
|
|
2854
|
+
return "variable";
|
|
2855
|
+
}
|
|
2856
|
+
return null;
|
|
2857
|
+
}
|
|
2858
|
+
// ── FUNCTION CALLS ────────────────────────────────────────────────────────
|
|
2859
|
+
/**
|
|
2860
|
+
* Builds symbol-level call edges for direct function calls and JSX component
|
|
2861
|
+
* usage. Resolution is name-based within known imported/local symbols.
|
|
2862
|
+
*/
|
|
2863
|
+
parseCalls(filePath, sourceFile) {
|
|
2864
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2865
|
+
const importedSymbolNames = new Map();
|
|
2866
|
+
fileNode.imports.forEach((importedPath) => {
|
|
2867
|
+
this.graph.files.get(importedPath)?.symbols.forEach((symbolId) => {
|
|
2868
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2869
|
+
if (sym) {
|
|
2870
|
+
importedSymbolNames.set(sym.name, symbolId);
|
|
2871
|
+
}
|
|
2872
|
+
});
|
|
2873
|
+
});
|
|
2874
|
+
fileNode.symbols.forEach((symbolId) => {
|
|
2875
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2876
|
+
if (sym) {
|
|
2877
|
+
importedSymbolNames.set(sym.name, symbolId);
|
|
2878
|
+
}
|
|
2879
|
+
});
|
|
2880
|
+
if (importedSymbolNames.size === 0) {
|
|
2881
|
+
return;
|
|
2882
|
+
}
|
|
2883
|
+
const callerSymbolId = this.findComponentSymbol(filePath);
|
|
2884
|
+
sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.CallExpression).forEach((callExpr) => {
|
|
2885
|
+
const calledName = this.extractCalledName(callExpr.getExpression().getText());
|
|
2886
|
+
if (!calledName) {
|
|
2887
|
+
return;
|
|
2888
|
+
}
|
|
2889
|
+
const targetSymbolId = importedSymbolNames.get(calledName);
|
|
2890
|
+
if (!targetSymbolId) {
|
|
2891
|
+
return;
|
|
2892
|
+
}
|
|
2893
|
+
const enclosing = this.findEnclosingSymbol(callExpr, filePath);
|
|
2894
|
+
if (!enclosing) {
|
|
2895
|
+
return;
|
|
2896
|
+
}
|
|
2897
|
+
this.addCallEdge(enclosing, targetSymbolId);
|
|
2898
|
+
});
|
|
2899
|
+
sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxSelfClosingElement).forEach((el) => {
|
|
2900
|
+
const name = el.getTagNameNode().getText();
|
|
2901
|
+
const targetSymbolId = importedSymbolNames.get(name);
|
|
2902
|
+
if (!targetSymbolId || !callerSymbolId) {
|
|
2903
|
+
return;
|
|
2904
|
+
}
|
|
2905
|
+
this.addCallEdge(callerSymbolId, targetSymbolId);
|
|
2906
|
+
});
|
|
2907
|
+
sourceFile.getDescendantsOfKind(ts_morph_1.SyntaxKind.JsxOpeningElement).forEach((el) => {
|
|
2908
|
+
const name = el.getTagNameNode().getText();
|
|
2909
|
+
const targetSymbolId = importedSymbolNames.get(name);
|
|
2910
|
+
if (!targetSymbolId || !callerSymbolId) {
|
|
2911
|
+
return;
|
|
2912
|
+
}
|
|
2913
|
+
this.addCallEdge(callerSymbolId, targetSymbolId);
|
|
2914
|
+
});
|
|
2915
|
+
}
|
|
2916
|
+
addCallEdge(callerSymbolId, targetSymbolId) {
|
|
2917
|
+
const callerNode = this.graph.symbols.get(callerSymbolId);
|
|
2918
|
+
const targetNode = this.graph.symbols.get(targetSymbolId);
|
|
2919
|
+
if (!callerNode || !targetNode) {
|
|
2920
|
+
return;
|
|
2921
|
+
}
|
|
2922
|
+
if (callerSymbolId === targetSymbolId) {
|
|
2923
|
+
return;
|
|
2924
|
+
} // No self-calls
|
|
2925
|
+
if (callerNode.calls.has(targetSymbolId)) {
|
|
2926
|
+
return;
|
|
2927
|
+
} // Idempotent
|
|
2928
|
+
callerNode.calls.add(targetSymbolId);
|
|
2929
|
+
targetNode.calledBy.add(callerSymbolId);
|
|
2930
|
+
}
|
|
2931
|
+
findComponentSymbol(filePath) {
|
|
2932
|
+
const fileNode = this.graph.files.get(filePath);
|
|
2933
|
+
if (!fileNode) {
|
|
2934
|
+
return null;
|
|
2935
|
+
}
|
|
2936
|
+
for (const symbolId of fileNode.symbols) {
|
|
2937
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
2938
|
+
if (sym && (sym.kind === "function" || sym.kind === "variable")) {
|
|
2939
|
+
return symbolId;
|
|
2940
|
+
}
|
|
2941
|
+
}
|
|
2942
|
+
return null;
|
|
2943
|
+
}
|
|
2944
|
+
extractCalledName(exprText) {
|
|
2945
|
+
const parts = exprText.split(".");
|
|
2946
|
+
const name = parts[parts.length - 1];
|
|
2947
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name) ? name : null;
|
|
2948
|
+
}
|
|
2949
|
+
findEnclosingSymbol(node, filePath) {
|
|
2950
|
+
let current = node.getParent();
|
|
2951
|
+
while (current) {
|
|
2952
|
+
const kind = current.getKind();
|
|
2953
|
+
if (kind === ts_morph_1.SyntaxKind.FunctionDeclaration ||
|
|
2954
|
+
kind === ts_morph_1.SyntaxKind.ArrowFunction ||
|
|
2955
|
+
kind === ts_morph_1.SyntaxKind.FunctionExpression ||
|
|
2956
|
+
kind === ts_morph_1.SyntaxKind.MethodDeclaration) {
|
|
2957
|
+
const nameNode = current.getNameNode?.();
|
|
2958
|
+
const name = nameNode?.getText();
|
|
2959
|
+
if (name) {
|
|
2960
|
+
const symbolId = makeSymbolId(filePath, name);
|
|
2961
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
2962
|
+
return symbolId;
|
|
2963
|
+
}
|
|
2964
|
+
}
|
|
2965
|
+
if (kind === ts_morph_1.SyntaxKind.ArrowFunction) {
|
|
2966
|
+
const parent = current.getParent();
|
|
2967
|
+
if (parent?.getKind() === ts_morph_1.SyntaxKind.VariableDeclaration) {
|
|
2968
|
+
const varName = parent.getNameNode?.()?.getText();
|
|
2969
|
+
if (varName) {
|
|
2970
|
+
const symbolId = makeSymbolId(filePath, varName);
|
|
2971
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
2972
|
+
return symbolId;
|
|
2973
|
+
}
|
|
2974
|
+
}
|
|
2975
|
+
}
|
|
2976
|
+
}
|
|
2977
|
+
}
|
|
2978
|
+
current = current.getParent();
|
|
2979
|
+
}
|
|
2980
|
+
return null;
|
|
2981
|
+
}
|
|
2982
|
+
// ── SYMBOL TEXT EXTRACTION ────────────────────────────────────────────────
|
|
2983
|
+
/**
|
|
2984
|
+
* Returns source text for a named function or function-valued variable. Symbol
|
|
2985
|
+
* hashes are based on this text so history can distinguish real body changes
|
|
2986
|
+
* from unchanged symbol IDs.
|
|
2987
|
+
*/
|
|
2988
|
+
getSymbolText(sourceFile, symbolName) {
|
|
2989
|
+
if (!sourceFile || !symbolName) {
|
|
2990
|
+
return null;
|
|
2991
|
+
}
|
|
2992
|
+
try {
|
|
2993
|
+
const funcDecl = sourceFile.getFunctions().find(f => f.getName() === symbolName);
|
|
2994
|
+
if (funcDecl) {
|
|
2995
|
+
return funcDecl.getText();
|
|
2996
|
+
}
|
|
2997
|
+
const varDecl = sourceFile.getVariableDeclarations().find(v => v.getName() === symbolName);
|
|
2998
|
+
if (varDecl) {
|
|
2999
|
+
return varDecl.getText();
|
|
3000
|
+
}
|
|
3001
|
+
}
|
|
3002
|
+
catch {
|
|
3003
|
+
return null;
|
|
3004
|
+
}
|
|
3005
|
+
return null;
|
|
3006
|
+
}
|
|
3007
|
+
// ── EDGE REMOVAL ──────────────────────────────────────────────────────────
|
|
3008
|
+
/**
|
|
3009
|
+
* Detaches all imports, symbols, calls, and reverse edges for a file. The
|
|
3010
|
+
* silent mode is used during reparsing, where updateFile logs a cleaner diff.
|
|
3011
|
+
*/
|
|
3012
|
+
removeFileEdges(filePath, silent, changeGroup) {
|
|
3013
|
+
const fileNode = this.graph.files.get(filePath);
|
|
3014
|
+
if (!fileNode) {
|
|
3015
|
+
return;
|
|
3016
|
+
}
|
|
3017
|
+
const now = Date.now();
|
|
3018
|
+
fileNode.imports.forEach((importedPath) => {
|
|
3019
|
+
this.graph.files.get(importedPath)?.importedBy.delete(filePath);
|
|
3020
|
+
if (!silent) {
|
|
3021
|
+
this.history.log({ timestamp: now, type: "import_removed", source: filePath, target: importedPath, changeGroup });
|
|
3022
|
+
}
|
|
3023
|
+
});
|
|
3024
|
+
fileNode.imports.clear();
|
|
3025
|
+
fileNode.symbols.forEach((symbolId) => {
|
|
3026
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
3027
|
+
if (!sym) {
|
|
3028
|
+
return;
|
|
3029
|
+
}
|
|
3030
|
+
sym.calls.forEach((targetId) => {
|
|
3031
|
+
this.graph.symbols.get(targetId)?.calledBy.delete(symbolId);
|
|
3032
|
+
if (!silent) {
|
|
3033
|
+
this.history.log({ timestamp: now, type: "call_removed", source: symbolId, target: targetId, changeGroup });
|
|
3034
|
+
}
|
|
3035
|
+
});
|
|
3036
|
+
sym.calledBy.forEach((callerId) => {
|
|
3037
|
+
const callerNode = this.graph.symbols.get(callerId);
|
|
3038
|
+
if (!callerNode) {
|
|
3039
|
+
return;
|
|
3040
|
+
}
|
|
3041
|
+
callerNode.calls.delete(symbolId);
|
|
3042
|
+
if (!silent) {
|
|
3043
|
+
this.history.log({ timestamp: now, type: "call_removed", source: callerId, target: symbolId, changeGroup });
|
|
3044
|
+
}
|
|
3045
|
+
});
|
|
3046
|
+
this.graph.symbols.delete(symbolId);
|
|
3047
|
+
if (!silent) {
|
|
3048
|
+
this.history.log({ timestamp: now, type: "symbol_deleted", source: symbolId, changeGroup });
|
|
3049
|
+
}
|
|
3050
|
+
});
|
|
3051
|
+
fileNode.symbols.clear();
|
|
3052
|
+
}
|
|
3053
|
+
// ── NODE CREATION ─────────────────────────────────────────────────────────
|
|
3054
|
+
/**
|
|
3055
|
+
* Returns an existing FileNode or creates a placeholder node so imports can
|
|
3056
|
+
* point at files before those files are parsed.
|
|
3057
|
+
*/
|
|
3058
|
+
ensureFileNode(filePath) {
|
|
3059
|
+
if (!this.graph.files.has(filePath)) {
|
|
3060
|
+
this.graph.files.set(filePath, {
|
|
3061
|
+
path: filePath,
|
|
3062
|
+
imports: new Set(),
|
|
3063
|
+
importedBy: new Set(),
|
|
3064
|
+
symbols: new Set(),
|
|
3065
|
+
createdAt: Date.now(),
|
|
3066
|
+
lastModifiedAt: Date.now(),
|
|
3067
|
+
changeCount: 0,
|
|
3068
|
+
hash: "",
|
|
3069
|
+
});
|
|
3070
|
+
}
|
|
3071
|
+
return this.graph.files.get(filePath);
|
|
3072
|
+
}
|
|
3073
|
+
// ── BARREL FILE HELPERS ───────────────────────────────────────────────────
|
|
3074
|
+
isBarrelFile(filePath) {
|
|
3075
|
+
const base = path.basename(filePath);
|
|
3076
|
+
return base === "index.ts" || base === "index.tsx" ||
|
|
3077
|
+
base === "index.js" || base === "index.jsx";
|
|
3078
|
+
}
|
|
3079
|
+
resolveBarrelSources(barrelPath) {
|
|
3080
|
+
const sources = [];
|
|
3081
|
+
const sourceFile = this.project.getSourceFile(this.toTsMorphPath(barrelPath));
|
|
3082
|
+
if (!sourceFile) {
|
|
3083
|
+
return sources;
|
|
3084
|
+
}
|
|
3085
|
+
sourceFile.getExportDeclarations().forEach((exportDecl) => {
|
|
3086
|
+
const moduleSpecifier = exportDecl.getModuleSpecifierValue();
|
|
3087
|
+
if (!moduleSpecifier) {
|
|
3088
|
+
return;
|
|
3089
|
+
}
|
|
3090
|
+
const rawTarget = (0, normalizer_1.normalizeImportPath)(moduleSpecifier, barrelPath, this.workspaceRoot);
|
|
3091
|
+
if (!rawTarget) {
|
|
3092
|
+
return;
|
|
3093
|
+
}
|
|
3094
|
+
const absoluteTarget = this.toGraphPath(rawTarget);
|
|
3095
|
+
if (this.isBarrelFile(absoluteTarget)) {
|
|
3096
|
+
return;
|
|
3097
|
+
}
|
|
3098
|
+
sources.push(absoluteTarget);
|
|
3099
|
+
});
|
|
3100
|
+
return sources;
|
|
3101
|
+
}
|
|
3102
|
+
// ── IMPACT QUERY FUNCTIONS ────────────────────────────────────────────────
|
|
3103
|
+
// Public query methods used by editor features. They are thin wrappers around
|
|
3104
|
+
// graph Sets, keeping UI code away from graph internals.
|
|
3105
|
+
toProjectPath(filePath) {
|
|
3106
|
+
return path.relative(this.workspaceRoot, filePath).split(path.sep).join("/");
|
|
3107
|
+
}
|
|
3108
|
+
modificationRiskFor(node) {
|
|
3109
|
+
const blastSize = node.importedBy.size;
|
|
3110
|
+
if (blastSize >= exports.DANGEROUS_BLAST_RADIUS || node.changeCount > exports.DANGEROUS_CHURN) {
|
|
3111
|
+
return "dangerous";
|
|
3112
|
+
}
|
|
3113
|
+
if (blastSize >= exports.CAUTION_BLAST_RADIUS ||
|
|
3114
|
+
node.changeCount > exports.CAUTION_CHURN ||
|
|
3115
|
+
node.hasParseError) {
|
|
3116
|
+
return "caution";
|
|
3117
|
+
}
|
|
3118
|
+
return "safe";
|
|
3119
|
+
}
|
|
3120
|
+
focusPathFor(filePath) {
|
|
3121
|
+
return `.ripple/.cache/focus/${makeFocusKey(filePath, this.graph)}.json`;
|
|
3122
|
+
}
|
|
3123
|
+
dependencyLinkFor(filePath) {
|
|
3124
|
+
const node = this.graph.files.get(filePath);
|
|
3125
|
+
return {
|
|
3126
|
+
file: this.toProjectPath(filePath),
|
|
3127
|
+
focus: this.focusPathFor(filePath),
|
|
3128
|
+
modificationRisk: node ? this.modificationRiskFor(node) : "safe",
|
|
3129
|
+
importCount: node?.imports.size ?? 0,
|
|
3130
|
+
importerCount: node?.importedBy.size ?? 0,
|
|
3131
|
+
};
|
|
3132
|
+
}
|
|
3133
|
+
contextPlanFileFor(filePath, reason, role, score, signals, adapterSignals) {
|
|
3134
|
+
const node = this.graph.files.get(filePath);
|
|
3135
|
+
if (!node) {
|
|
3136
|
+
return null;
|
|
3137
|
+
}
|
|
3138
|
+
return {
|
|
3139
|
+
file: this.toProjectPath(filePath),
|
|
3140
|
+
focus: this.focusPathFor(filePath),
|
|
3141
|
+
modificationRisk: this.modificationRiskFor(node),
|
|
3142
|
+
reason,
|
|
3143
|
+
estimatedTokens: this.estimateFileContextTokens(node),
|
|
3144
|
+
...(role ? { role } : {}),
|
|
3145
|
+
...(score !== undefined ? { score } : {}),
|
|
3146
|
+
...(signals && signals.length > 0 ? { signals } : {}),
|
|
3147
|
+
...(adapterSignals && adapterSignals.length > 0 ? { adapterSignals } : {}),
|
|
3148
|
+
};
|
|
3149
|
+
}
|
|
3150
|
+
estimateFileContextTokens(node) {
|
|
3151
|
+
const symbolCost = node.symbols.size * 80;
|
|
3152
|
+
const dependencyCost = (node.imports.size + node.importedBy.size) * 35;
|
|
3153
|
+
const churnCost = Math.min(node.changeCount, 10) * 20;
|
|
3154
|
+
return Math.max(180, Math.min(1200, 220 + symbolCost + dependencyCost + churnCost));
|
|
3155
|
+
}
|
|
3156
|
+
projectSymbolId(symbolId) {
|
|
3157
|
+
const separator = symbolId.indexOf("::");
|
|
3158
|
+
if (separator === -1) {
|
|
3159
|
+
return symbolId;
|
|
3160
|
+
}
|
|
3161
|
+
const filePath = symbolId.slice(0, separator);
|
|
3162
|
+
const suffix = symbolId.slice(separator);
|
|
3163
|
+
return `${this.toProjectPath(filePath)}${suffix}`;
|
|
3164
|
+
}
|
|
3165
|
+
entityToProjectRef(entity) {
|
|
3166
|
+
const separator = entity.indexOf("::");
|
|
3167
|
+
const filePath = separator === -1 ? entity : entity.slice(0, separator);
|
|
3168
|
+
const suffix = separator === -1 ? "" : entity.slice(separator);
|
|
3169
|
+
if (filePath === "initial_scan") {
|
|
3170
|
+
return entity;
|
|
3171
|
+
}
|
|
3172
|
+
if (!path.isAbsolute(filePath)) {
|
|
3173
|
+
return `${filePath.split(path.sep).join("/")}${suffix}`;
|
|
3174
|
+
}
|
|
3175
|
+
return `${this.toProjectPath(filePath)}${suffix}`;
|
|
3176
|
+
}
|
|
3177
|
+
fileRefFromEntity(entity) {
|
|
3178
|
+
const separator = entity.indexOf("::");
|
|
3179
|
+
const filePath = separator === -1 ? entity : entity.slice(0, separator);
|
|
3180
|
+
return this.entityToProjectRef(filePath);
|
|
3181
|
+
}
|
|
3182
|
+
symbolLinkFor(symbolId) {
|
|
3183
|
+
const symbol = this.graph.symbols.get(symbolId);
|
|
3184
|
+
if (!symbol) {
|
|
3185
|
+
return null;
|
|
3186
|
+
}
|
|
3187
|
+
return {
|
|
3188
|
+
symbolId,
|
|
3189
|
+
projectSymbolId: this.projectSymbolId(symbolId),
|
|
3190
|
+
file: this.toProjectPath(symbol.file),
|
|
3191
|
+
name: symbol.name,
|
|
3192
|
+
kind: symbol.kind,
|
|
3193
|
+
layer: symbol.layer,
|
|
3194
|
+
focus: this.focusPathFor(symbol.file),
|
|
3195
|
+
callerCount: symbol.calledBy.size,
|
|
3196
|
+
callCount: symbol.calls.size,
|
|
3197
|
+
};
|
|
3198
|
+
}
|
|
3199
|
+
symbolGraphSummaryFor(symbolId) {
|
|
3200
|
+
const symbol = this.graph.symbols.get(symbolId);
|
|
3201
|
+
const link = this.symbolLinkFor(symbolId);
|
|
3202
|
+
if (!symbol || !link) {
|
|
3203
|
+
return null;
|
|
3204
|
+
}
|
|
3205
|
+
const byProjectSymbolId = (a, b) => this.projectSymbolId(a).localeCompare(this.projectSymbolId(b));
|
|
3206
|
+
return {
|
|
3207
|
+
...link,
|
|
3208
|
+
containsLayers: symbol.containsLayers,
|
|
3209
|
+
calls: Array.from(symbol.calls)
|
|
3210
|
+
.sort(byProjectSymbolId)
|
|
3211
|
+
.map((targetId) => this.symbolLinkFor(targetId))
|
|
3212
|
+
.filter((target) => Boolean(target)),
|
|
3213
|
+
calledBy: Array.from(symbol.calledBy)
|
|
3214
|
+
.sort(byProjectSymbolId)
|
|
3215
|
+
.map((callerId) => this.symbolLinkFor(callerId))
|
|
3216
|
+
.filter((caller) => Boolean(caller)),
|
|
3217
|
+
};
|
|
3218
|
+
}
|
|
3219
|
+
resolveFilePath(filePath) {
|
|
3220
|
+
const absolutePath = path.isAbsolute(filePath)
|
|
3221
|
+
? filePath
|
|
3222
|
+
: path.join(this.workspaceRoot, filePath);
|
|
3223
|
+
return path.resolve(absolutePath).split(/[\\/]/).join(path.sep);
|
|
3224
|
+
}
|
|
3225
|
+
resolveSymbolId(symbolId) {
|
|
3226
|
+
const separator = symbolId.indexOf("::");
|
|
3227
|
+
if (separator === -1) {
|
|
3228
|
+
return null;
|
|
3229
|
+
}
|
|
3230
|
+
const filePath = symbolId.slice(0, separator);
|
|
3231
|
+
const symbolName = symbolId.slice(separator + 2);
|
|
3232
|
+
if (!filePath || !symbolName) {
|
|
3233
|
+
return null;
|
|
3234
|
+
}
|
|
3235
|
+
return makeSymbolId(this.resolveFilePath(filePath), symbolName);
|
|
3236
|
+
}
|
|
3237
|
+
downstreamFiles(filePath) {
|
|
3238
|
+
return Array.from(this.graph.files.get(filePath)?.importedBy ?? []);
|
|
3239
|
+
}
|
|
3240
|
+
upstreamFiles(filePath) {
|
|
3241
|
+
return Array.from(this.graph.files.get(filePath)?.imports ?? []);
|
|
3242
|
+
}
|
|
3243
|
+
focusKeyForFile(filePath) {
|
|
3244
|
+
return makeFocusKey(filePath, this.graph);
|
|
3245
|
+
}
|
|
3246
|
+
symbolImpact(symbolId) {
|
|
3247
|
+
return Array.from(this.graph.symbols.get(symbolId)?.calledBy ?? []);
|
|
3248
|
+
}
|
|
3249
|
+
getRecentHistorySummary(limit = 10) {
|
|
3250
|
+
const normalizedLimit = Math.max(1, Math.min(100, Math.floor(limit)));
|
|
3251
|
+
const groups = [];
|
|
3252
|
+
const seenGroups = new Set();
|
|
3253
|
+
for (let i = this.history.events.length - 1; i >= 0; i--) {
|
|
3254
|
+
const event = this.history.events[i];
|
|
3255
|
+
const groupId = event.changeGroup ?? `${event.type}:${event.timestamp}:${event.source}`;
|
|
3256
|
+
if (seenGroups.has(groupId)) {
|
|
3257
|
+
continue;
|
|
3258
|
+
}
|
|
3259
|
+
seenGroups.add(groupId);
|
|
3260
|
+
const groupEvents = event.changeGroup
|
|
3261
|
+
? this.history.getGroup(event.changeGroup)
|
|
3262
|
+
: [event];
|
|
3263
|
+
const filesChanged = new Set();
|
|
3264
|
+
const symbolsChanged = new Set();
|
|
3265
|
+
const relatedFiles = new Set();
|
|
3266
|
+
let changedAt = event.timestamp;
|
|
3267
|
+
const events = groupEvents
|
|
3268
|
+
.slice()
|
|
3269
|
+
.sort((a, b) => a.timestamp - b.timestamp)
|
|
3270
|
+
.map((groupEvent) => {
|
|
3271
|
+
changedAt = Math.max(changedAt, groupEvent.timestamp);
|
|
3272
|
+
const sourceRef = this.entityToProjectRef(groupEvent.source);
|
|
3273
|
+
const sourceFileRef = this.fileRefFromEntity(groupEvent.source);
|
|
3274
|
+
if (sourceFileRef && sourceFileRef !== "initial_scan") {
|
|
3275
|
+
filesChanged.add(sourceFileRef);
|
|
3276
|
+
}
|
|
3277
|
+
if (groupEvent.source.includes("::")) {
|
|
3278
|
+
symbolsChanged.add(sourceRef);
|
|
3279
|
+
}
|
|
3280
|
+
const targetRef = groupEvent.target
|
|
3281
|
+
? this.entityToProjectRef(groupEvent.target)
|
|
3282
|
+
: undefined;
|
|
3283
|
+
if (groupEvent.target) {
|
|
3284
|
+
const targetFileRef = this.fileRefFromEntity(groupEvent.target);
|
|
3285
|
+
if (targetFileRef && targetFileRef !== sourceFileRef) {
|
|
3286
|
+
relatedFiles.add(targetFileRef);
|
|
3287
|
+
}
|
|
3288
|
+
}
|
|
3289
|
+
return {
|
|
3290
|
+
timestamp: groupEvent.timestamp,
|
|
3291
|
+
changedAt: new Date(groupEvent.timestamp).toISOString(),
|
|
3292
|
+
type: groupEvent.type,
|
|
3293
|
+
source: sourceRef,
|
|
3294
|
+
target: targetRef,
|
|
3295
|
+
changeGroup: groupEvent.changeGroup,
|
|
3296
|
+
kind: groupEvent.kind,
|
|
3297
|
+
layer: groupEvent.layer,
|
|
3298
|
+
metadata: groupEvent.metadata,
|
|
3299
|
+
};
|
|
3300
|
+
});
|
|
3301
|
+
groups.push({
|
|
3302
|
+
id: groupId,
|
|
3303
|
+
changedAt: new Date(changedAt).toISOString(),
|
|
3304
|
+
eventCount: events.length,
|
|
3305
|
+
filesChanged: Array.from(filesChanged).sort(),
|
|
3306
|
+
symbolsChanged: Array.from(symbolsChanged).sort(),
|
|
3307
|
+
relatedFiles: Array.from(relatedFiles).sort(),
|
|
3308
|
+
events,
|
|
3309
|
+
});
|
|
3310
|
+
if (groups.length >= normalizedLimit) {
|
|
3311
|
+
break;
|
|
3312
|
+
}
|
|
3313
|
+
}
|
|
3314
|
+
return {
|
|
3315
|
+
totalEvents: this.history.events.length,
|
|
3316
|
+
returnedGroups: groups.length,
|
|
3317
|
+
groups,
|
|
3318
|
+
};
|
|
3319
|
+
}
|
|
3320
|
+
isTestFileForPlan(filePath) {
|
|
3321
|
+
const projectPath = this.toProjectPath(filePath).toLowerCase();
|
|
3322
|
+
const normalized = projectPath.split(/[\\/]/).join("/");
|
|
3323
|
+
const base = path.basename(filePath).toLowerCase();
|
|
3324
|
+
const segments = normalized.split("/");
|
|
3325
|
+
return (/\.(test|spec)\.[cm]?[jt]sx?$/.test(base) ||
|
|
3326
|
+
segments.includes("__tests__") ||
|
|
3327
|
+
segments.includes("test") ||
|
|
3328
|
+
segments.includes("tests") ||
|
|
3329
|
+
segments.includes("spec") ||
|
|
3330
|
+
segments.includes("specs"));
|
|
3331
|
+
}
|
|
3332
|
+
isIndexBoundaryFileForPlan(filePath) {
|
|
3333
|
+
const base = path.basename(filePath).toLowerCase();
|
|
3334
|
+
if (!["index.ts", "index.tsx", "index.js", "index.jsx"].includes(base)) {
|
|
3335
|
+
return false;
|
|
3336
|
+
}
|
|
3337
|
+
const projectPath = this.toProjectPath(filePath).toLowerCase();
|
|
3338
|
+
if (this.isTestFileForPlan(filePath)) {
|
|
3339
|
+
return false;
|
|
3340
|
+
}
|
|
3341
|
+
return (projectPath === "src/index.ts" ||
|
|
3342
|
+
projectPath === "src/index.tsx" ||
|
|
3343
|
+
projectPath === "src/index.js" ||
|
|
3344
|
+
projectPath === "src/index.jsx" ||
|
|
3345
|
+
projectPath.includes("/app/") ||
|
|
3346
|
+
projectPath.includes("/pages/") ||
|
|
3347
|
+
projectPath.includes("/routes/") ||
|
|
3348
|
+
projectPath.includes("/api/"));
|
|
3349
|
+
}
|
|
3350
|
+
isRouteEntryPointFileForPlan(filePath) {
|
|
3351
|
+
const base = path.basename(filePath).toLowerCase();
|
|
3352
|
+
return (base === "route.ts" ||
|
|
3353
|
+
base === "route.tsx" ||
|
|
3354
|
+
base === "page.ts" ||
|
|
3355
|
+
base === "page.tsx" ||
|
|
3356
|
+
filePath.includes(`${path.sep}pages${path.sep}api${path.sep}`) ||
|
|
3357
|
+
filePath.includes(`${path.sep}app${path.sep}api${path.sep}`));
|
|
3358
|
+
}
|
|
3359
|
+
wordsForPlan(text) {
|
|
3360
|
+
return text
|
|
3361
|
+
.replace(/([a-z0-9])([A-Z])/g, "$1 $2")
|
|
3362
|
+
.replace(/([A-Z]+)([A-Z][a-z])/g, "$1 $2")
|
|
3363
|
+
.toLowerCase()
|
|
3364
|
+
.split(/[^a-z0-9]+/)
|
|
3365
|
+
.map((word) => word.trim())
|
|
3366
|
+
.filter(Boolean);
|
|
3367
|
+
}
|
|
3368
|
+
taskTermsForPlan(task) {
|
|
3369
|
+
const stopWords = new Set([
|
|
3370
|
+
"a",
|
|
3371
|
+
"an",
|
|
3372
|
+
"and",
|
|
3373
|
+
"are",
|
|
3374
|
+
"as",
|
|
3375
|
+
"behavior",
|
|
3376
|
+
"bug",
|
|
3377
|
+
"change",
|
|
3378
|
+
"class",
|
|
3379
|
+
"code",
|
|
3380
|
+
"component",
|
|
3381
|
+
"do",
|
|
3382
|
+
"does",
|
|
3383
|
+
"file",
|
|
3384
|
+
"fix",
|
|
3385
|
+
"for",
|
|
3386
|
+
"from",
|
|
3387
|
+
"function",
|
|
3388
|
+
"how",
|
|
3389
|
+
"in",
|
|
3390
|
+
"into",
|
|
3391
|
+
"is",
|
|
3392
|
+
"issue",
|
|
3393
|
+
"js",
|
|
3394
|
+
"jsx",
|
|
3395
|
+
"logic",
|
|
3396
|
+
"method",
|
|
3397
|
+
"modify",
|
|
3398
|
+
"new",
|
|
3399
|
+
"of",
|
|
3400
|
+
"old",
|
|
3401
|
+
"on",
|
|
3402
|
+
"or",
|
|
3403
|
+
"refactor",
|
|
3404
|
+
"remove",
|
|
3405
|
+
"should",
|
|
3406
|
+
"src",
|
|
3407
|
+
"test",
|
|
3408
|
+
"tests",
|
|
3409
|
+
"that",
|
|
3410
|
+
"the",
|
|
3411
|
+
"this",
|
|
3412
|
+
"to",
|
|
3413
|
+
"ts",
|
|
3414
|
+
"tsx",
|
|
3415
|
+
"update",
|
|
3416
|
+
"use",
|
|
3417
|
+
"using",
|
|
3418
|
+
"when",
|
|
3419
|
+
"with",
|
|
3420
|
+
]);
|
|
3421
|
+
const terms = new Set();
|
|
3422
|
+
this.wordsForPlan(task).forEach((word) => {
|
|
3423
|
+
if (word.length < 3 || stopWords.has(word)) {
|
|
3424
|
+
return;
|
|
3425
|
+
}
|
|
3426
|
+
terms.add(word);
|
|
3427
|
+
if (word.length > 4 && word.endsWith("s")) {
|
|
3428
|
+
terms.add(word.slice(0, -1));
|
|
3429
|
+
}
|
|
3430
|
+
});
|
|
3431
|
+
return Array.from(terms).slice(0, 12);
|
|
3432
|
+
}
|
|
3433
|
+
taskTermsMatchForPlan(taskTerm, candidateTerm) {
|
|
3434
|
+
if (taskTerm === candidateTerm) {
|
|
3435
|
+
return true;
|
|
3436
|
+
}
|
|
3437
|
+
if (taskTerm.length >= 4 && candidateTerm.includes(taskTerm)) {
|
|
3438
|
+
return true;
|
|
3439
|
+
}
|
|
3440
|
+
if (candidateTerm.length >= 4 && taskTerm.includes(candidateTerm)) {
|
|
3441
|
+
return true;
|
|
3442
|
+
}
|
|
3443
|
+
const shorterLength = Math.min(taskTerm.length, candidateTerm.length);
|
|
3444
|
+
if (shorterLength < 5) {
|
|
3445
|
+
return false;
|
|
3446
|
+
}
|
|
3447
|
+
let commonPrefix = 0;
|
|
3448
|
+
while (commonPrefix < shorterLength &&
|
|
3449
|
+
taskTerm[commonPrefix] === candidateTerm[commonPrefix]) {
|
|
3450
|
+
commonPrefix++;
|
|
3451
|
+
}
|
|
3452
|
+
return commonPrefix >= 5;
|
|
3453
|
+
}
|
|
3454
|
+
taskRelevanceForPlan(filePath, taskTerms) {
|
|
3455
|
+
if (taskTerms.length === 0) {
|
|
3456
|
+
return { score: 0, matches: [] };
|
|
3457
|
+
}
|
|
3458
|
+
const node = this.graph.files.get(filePath);
|
|
3459
|
+
if (!node) {
|
|
3460
|
+
return { score: 0, matches: [] };
|
|
3461
|
+
}
|
|
3462
|
+
const pathWords = this.wordsForPlan(this.toProjectPath(filePath));
|
|
3463
|
+
const symbolWords = [];
|
|
3464
|
+
node.symbols.forEach((symbolId) => {
|
|
3465
|
+
const symbol = this.graph.symbols.get(symbolId);
|
|
3466
|
+
if (symbol) {
|
|
3467
|
+
symbolWords.push(...this.wordsForPlan(symbol.name));
|
|
3468
|
+
}
|
|
3469
|
+
});
|
|
3470
|
+
const pathMatches = new Set();
|
|
3471
|
+
const symbolMatches = new Set();
|
|
3472
|
+
taskTerms.forEach((term) => {
|
|
3473
|
+
if (pathWords.some((word) => this.taskTermsMatchForPlan(term, word))) {
|
|
3474
|
+
pathMatches.add(term);
|
|
3475
|
+
}
|
|
3476
|
+
if (symbolWords.some((word) => this.taskTermsMatchForPlan(term, word))) {
|
|
3477
|
+
symbolMatches.add(term);
|
|
3478
|
+
}
|
|
3479
|
+
});
|
|
3480
|
+
const matches = new Set([...pathMatches, ...symbolMatches]);
|
|
3481
|
+
if (matches.size === 0) {
|
|
3482
|
+
return { score: 0, matches: [] };
|
|
3483
|
+
}
|
|
3484
|
+
const symbolOnlyMatches = Array.from(symbolMatches)
|
|
3485
|
+
.filter((term) => !pathMatches.has(term));
|
|
3486
|
+
const score = Math.min(360, pathMatches.size * 180) +
|
|
3487
|
+
Math.min(280, symbolOnlyMatches.length * 140);
|
|
3488
|
+
return {
|
|
3489
|
+
score,
|
|
3490
|
+
matches: Array.from(matches).sort().slice(0, 6),
|
|
3491
|
+
};
|
|
3492
|
+
}
|
|
3493
|
+
symbolTaskRelevanceForPlan(symbol, taskTerms) {
|
|
3494
|
+
if (taskTerms.length === 0) {
|
|
3495
|
+
return { score: 0, matches: [] };
|
|
3496
|
+
}
|
|
3497
|
+
const symbolWords = this.wordsForPlan(symbol.name);
|
|
3498
|
+
const matches = taskTerms.filter((term) => symbolWords.some((word) => this.taskTermsMatchForPlan(term, word)));
|
|
3499
|
+
if (matches.length === 0) {
|
|
3500
|
+
return { score: 0, matches: [] };
|
|
3501
|
+
}
|
|
3502
|
+
return {
|
|
3503
|
+
score: Math.min(520, matches.length * 260),
|
|
3504
|
+
matches: matches.sort().slice(0, 6),
|
|
3505
|
+
};
|
|
3506
|
+
}
|
|
3507
|
+
symbolFocusForPlan(candidateFilePaths, resolvedPath, taskTerms, taskMatchedFiles) {
|
|
3508
|
+
const candidateFileSet = new Set(candidateFilePaths);
|
|
3509
|
+
const rankedSymbols = [];
|
|
3510
|
+
candidateFilePaths.forEach((filePath) => {
|
|
3511
|
+
if (this.isTestFileForPlan(filePath) && filePath !== resolvedPath) {
|
|
3512
|
+
return;
|
|
3513
|
+
}
|
|
3514
|
+
const fileNode = this.graph.files.get(filePath);
|
|
3515
|
+
if (!fileNode) {
|
|
3516
|
+
return;
|
|
3517
|
+
}
|
|
3518
|
+
fileNode.symbols.forEach((symbolId) => {
|
|
3519
|
+
const symbol = this.graph.symbols.get(symbolId);
|
|
3520
|
+
if (!symbol) {
|
|
3521
|
+
return;
|
|
3522
|
+
}
|
|
3523
|
+
let score = 0;
|
|
3524
|
+
const reasons = [];
|
|
3525
|
+
const signals = new Set();
|
|
3526
|
+
const addSignal = (signal, amount, reason) => {
|
|
3527
|
+
score += amount;
|
|
3528
|
+
signals.add(signal);
|
|
3529
|
+
if (!reasons.includes(reason)) {
|
|
3530
|
+
reasons.push(reason);
|
|
3531
|
+
}
|
|
3532
|
+
};
|
|
3533
|
+
if (symbol.file === resolvedPath) {
|
|
3534
|
+
addSignal("target-file", 320, "Defined in the target file.");
|
|
3535
|
+
}
|
|
3536
|
+
const taskMatch = this.symbolTaskRelevanceForPlan(symbol, taskTerms);
|
|
3537
|
+
if (taskMatch.score > 0) {
|
|
3538
|
+
addSignal("task-match", taskMatch.score, `Symbol name matches task terms: ${taskMatch.matches.join(", ")}.`);
|
|
3539
|
+
}
|
|
3540
|
+
const calledTaskMatchedFiles = Array.from(symbol.calls)
|
|
3541
|
+
.map((calledSymbolId) => this.graph.symbols.get(calledSymbolId)?.file)
|
|
3542
|
+
.filter((calledFile) => typeof calledFile === "string" &&
|
|
3543
|
+
taskMatchedFiles.has(calledFile) &&
|
|
3544
|
+
calledFile !== symbol.file)
|
|
3545
|
+
.map((calledFile) => this.toProjectPath(calledFile));
|
|
3546
|
+
const uniqueCalledTaskMatchedFiles = Array.from(new Set(calledTaskMatchedFiles));
|
|
3547
|
+
if (uniqueCalledTaskMatchedFiles.length > 0) {
|
|
3548
|
+
addSignal("calls-task-matched-file", Math.min(420, 220 + uniqueCalledTaskMatchedFiles.length * 80), `Calls task-matched file(s): ${uniqueCalledTaskMatchedFiles.slice(0, 3).join(", ")}.`);
|
|
3549
|
+
}
|
|
3550
|
+
const plannedCallerFiles = Array.from(symbol.calledBy)
|
|
3551
|
+
.map((callerId) => this.graph.symbols.get(callerId)?.file)
|
|
3552
|
+
.filter((callerFile) => typeof callerFile === "string" &&
|
|
3553
|
+
candidateFileSet.has(callerFile) &&
|
|
3554
|
+
callerFile !== symbol.file)
|
|
3555
|
+
.map((callerFile) => this.toProjectPath(callerFile));
|
|
3556
|
+
const uniquePlannedCallerFiles = Array.from(new Set(plannedCallerFiles));
|
|
3557
|
+
if (uniquePlannedCallerFiles.length > 0) {
|
|
3558
|
+
addSignal("called-by-planned-file", Math.min(320, 180 + uniquePlannedCallerFiles.length * 50), `Called by planned file(s): ${uniquePlannedCallerFiles.slice(0, 3).join(", ")}.`);
|
|
3559
|
+
}
|
|
3560
|
+
if (symbol.calledBy.size > 0) {
|
|
3561
|
+
addSignal("has-callers", Math.min(180, 60 + symbol.calledBy.size * 20), `${symbol.calledBy.size} tracked caller(s).`);
|
|
3562
|
+
}
|
|
3563
|
+
if (score === 0) {
|
|
3564
|
+
return;
|
|
3565
|
+
}
|
|
3566
|
+
rankedSymbols.push({
|
|
3567
|
+
symbol: this.projectSymbolId(symbol.id),
|
|
3568
|
+
file: this.toProjectPath(symbol.file),
|
|
3569
|
+
name: symbol.name,
|
|
3570
|
+
kind: symbol.kind,
|
|
3571
|
+
layer: symbol.layer,
|
|
3572
|
+
reason: reasons.join(" "),
|
|
3573
|
+
score,
|
|
3574
|
+
signals: Array.from(signals).sort(),
|
|
3575
|
+
callers: symbol.calledBy.size,
|
|
3576
|
+
calls: symbol.calls.size,
|
|
3577
|
+
});
|
|
3578
|
+
});
|
|
3579
|
+
});
|
|
3580
|
+
return rankedSymbols
|
|
3581
|
+
.sort((a, b) => {
|
|
3582
|
+
if (a.file === this.toProjectPath(resolvedPath) && b.file !== this.toProjectPath(resolvedPath)) {
|
|
3583
|
+
return -1;
|
|
3584
|
+
}
|
|
3585
|
+
if (b.file === this.toProjectPath(resolvedPath) && a.file !== this.toProjectPath(resolvedPath)) {
|
|
3586
|
+
return 1;
|
|
3587
|
+
}
|
|
3588
|
+
if (a.score !== b.score) {
|
|
3589
|
+
return b.score - a.score;
|
|
3590
|
+
}
|
|
3591
|
+
return a.symbol.localeCompare(b.symbol);
|
|
3592
|
+
})
|
|
3593
|
+
.slice(0, 12);
|
|
3594
|
+
}
|
|
3595
|
+
planContext(task, targetFile, tokenBudget = 4000) {
|
|
3596
|
+
const resolvedPath = this.resolveFilePath(targetFile);
|
|
3597
|
+
const node = this.graph.files.get(resolvedPath);
|
|
3598
|
+
if (!node) {
|
|
3599
|
+
return null;
|
|
3600
|
+
}
|
|
3601
|
+
const normalizedBudget = Math.max(1000, Math.min(32000, Math.floor(tokenBudget)));
|
|
3602
|
+
const risk = this.modificationRiskFor(node);
|
|
3603
|
+
const targetProjectPath = this.toProjectPath(resolvedPath);
|
|
3604
|
+
const adapterSupport = this.getAdapterSupport();
|
|
3605
|
+
const taskTerms = this.taskTermsForPlan(task);
|
|
3606
|
+
const taskMatchedTerms = new Set();
|
|
3607
|
+
const taskMatchedFiles = new Set();
|
|
3608
|
+
let taskMatchedFileCount = 0;
|
|
3609
|
+
const candidateMap = new Map();
|
|
3610
|
+
const directTestFiles = new Set();
|
|
3611
|
+
const importerTestFiles = new Set();
|
|
3612
|
+
const entryPointFiles = new Set();
|
|
3613
|
+
const symbolVerificationTargets = new Set();
|
|
3614
|
+
const roleRank = {
|
|
3615
|
+
target: 7,
|
|
3616
|
+
test: 6,
|
|
3617
|
+
entrypoint: 5,
|
|
3618
|
+
importer: 4,
|
|
3619
|
+
caller: 3,
|
|
3620
|
+
dependency: 2,
|
|
3621
|
+
related: 1,
|
|
3622
|
+
};
|
|
3623
|
+
const riskRank = { dangerous: 3, caution: 2, safe: 1 };
|
|
3624
|
+
let candidateOrder = 0;
|
|
3625
|
+
const adapterCapabilityForSignal = (signal) => {
|
|
3626
|
+
switch (signal) {
|
|
3627
|
+
case "target":
|
|
3628
|
+
case "parse-warning":
|
|
3629
|
+
return "files";
|
|
3630
|
+
case "direct-dependency":
|
|
3631
|
+
return "dependencies";
|
|
3632
|
+
case "direct-importer":
|
|
3633
|
+
case "transitive-entrypoint":
|
|
3634
|
+
return "reverse-dependencies";
|
|
3635
|
+
case "symbol-caller":
|
|
3636
|
+
return "call-edges";
|
|
3637
|
+
case "direct-test":
|
|
3638
|
+
case "test-for-importer":
|
|
3639
|
+
return "tests";
|
|
3640
|
+
case "recent-change":
|
|
3641
|
+
case "task-match":
|
|
3642
|
+
case "risk":
|
|
3643
|
+
return null;
|
|
3644
|
+
}
|
|
3645
|
+
};
|
|
3646
|
+
const adapterSignalFor = (signal) => {
|
|
3647
|
+
const capability = adapterCapabilityForSignal(signal);
|
|
3648
|
+
if (!capability) {
|
|
3649
|
+
return null;
|
|
3650
|
+
}
|
|
3651
|
+
const profile = adapterSupport.primaryAdapter.capabilityProfile.find((item) => item.capability === capability);
|
|
3652
|
+
if (!profile) {
|
|
3653
|
+
return null;
|
|
3654
|
+
}
|
|
3655
|
+
return {
|
|
3656
|
+
capability,
|
|
3657
|
+
confidence: profile.confidence,
|
|
3658
|
+
agentUse: profile.agentUse,
|
|
3659
|
+
reason: profile.reason,
|
|
3660
|
+
};
|
|
3661
|
+
};
|
|
3662
|
+
const adapterMultiplierFor = (signal) => {
|
|
3663
|
+
const adapterSignal = adapterSignalFor(signal);
|
|
3664
|
+
if (!adapterSignal) {
|
|
3665
|
+
return 1;
|
|
3666
|
+
}
|
|
3667
|
+
if (signal === "target") {
|
|
3668
|
+
return Math.max(0.95, adapterSignal.confidence);
|
|
3669
|
+
}
|
|
3670
|
+
if (adapterSignal.agentUse === "trust") {
|
|
3671
|
+
return Math.max(0.85, adapterSignal.confidence);
|
|
3672
|
+
}
|
|
3673
|
+
if (adapterSignal.agentUse === "verify") {
|
|
3674
|
+
return Math.max(0.55, adapterSignal.confidence);
|
|
3675
|
+
}
|
|
3676
|
+
return Math.max(0.25, adapterSignal.confidence);
|
|
3677
|
+
};
|
|
3678
|
+
const adapterReasonFor = (adapterSignal) => {
|
|
3679
|
+
if (!adapterSignal || adapterSignal.agentUse === "trust") {
|
|
3680
|
+
return null;
|
|
3681
|
+
}
|
|
3682
|
+
const percent = Math.round(adapterSignal.confidence * 100);
|
|
3683
|
+
return `Adapter ranks ${adapterSignal.capability} as ${adapterSignal.agentUse} (${percent}% confidence); verify before relying on this edge alone.`;
|
|
3684
|
+
};
|
|
3685
|
+
const recordAdapterSignal = (candidate, adapterSignal) => {
|
|
3686
|
+
if (!adapterSignal) {
|
|
3687
|
+
return;
|
|
3688
|
+
}
|
|
3689
|
+
const existing = candidate.adapterSignals.get(adapterSignal.capability);
|
|
3690
|
+
if (!existing || adapterSignal.confidence > existing.confidence) {
|
|
3691
|
+
candidate.adapterSignals.set(adapterSignal.capability, adapterSignal);
|
|
3692
|
+
}
|
|
3693
|
+
};
|
|
3694
|
+
const byRiskThenConnectivity = (a, b) => {
|
|
3695
|
+
const aNode = this.graph.files.get(a);
|
|
3696
|
+
const bNode = this.graph.files.get(b);
|
|
3697
|
+
const aRisk = aNode ? riskRank[this.modificationRiskFor(aNode)] : 0;
|
|
3698
|
+
const bRisk = bNode ? riskRank[this.modificationRiskFor(bNode)] : 0;
|
|
3699
|
+
if (aRisk !== bRisk) {
|
|
3700
|
+
return bRisk - aRisk;
|
|
3701
|
+
}
|
|
3702
|
+
const aEdges = (aNode?.imports.size ?? 0) + (aNode?.importedBy.size ?? 0);
|
|
3703
|
+
const bEdges = (bNode?.imports.size ?? 0) + (bNode?.importedBy.size ?? 0);
|
|
3704
|
+
if (aEdges !== bEdges) {
|
|
3705
|
+
return bEdges - aEdges;
|
|
3706
|
+
}
|
|
3707
|
+
return this.toProjectPath(a).localeCompare(this.toProjectPath(b));
|
|
3708
|
+
};
|
|
3709
|
+
const importers = Array.from(node.importedBy).sort(byRiskThenConnectivity);
|
|
3710
|
+
const imports = Array.from(node.imports).sort(byRiskThenConnectivity);
|
|
3711
|
+
const testFiles = Array.from(this.graph.files.keys())
|
|
3712
|
+
.filter((filePath) => this.isTestFileForPlan(filePath))
|
|
3713
|
+
.sort((a, b) => this.toProjectPath(a).localeCompare(this.toProjectPath(b)));
|
|
3714
|
+
const targetSymbolIds = new Set(node.symbols);
|
|
3715
|
+
const addCandidate = (filePath, role, score, reason, signal) => {
|
|
3716
|
+
const fileNode = this.graph.files.get(filePath);
|
|
3717
|
+
if (!fileNode) {
|
|
3718
|
+
return;
|
|
3719
|
+
}
|
|
3720
|
+
const adapterSignal = adapterSignalFor(signal);
|
|
3721
|
+
const weightedScore = Math.round(score * adapterMultiplierFor(signal));
|
|
3722
|
+
const adapterReason = adapterReasonFor(adapterSignal);
|
|
3723
|
+
const existing = candidateMap.get(filePath);
|
|
3724
|
+
if (existing) {
|
|
3725
|
+
existing.score += weightedScore;
|
|
3726
|
+
if (!existing.reasons.includes(reason)) {
|
|
3727
|
+
existing.reasons.push(reason);
|
|
3728
|
+
}
|
|
3729
|
+
if (adapterReason && !existing.reasons.includes(adapterReason)) {
|
|
3730
|
+
existing.reasons.push(adapterReason);
|
|
3731
|
+
}
|
|
3732
|
+
existing.signals.add(signal);
|
|
3733
|
+
recordAdapterSignal(existing, adapterSignal);
|
|
3734
|
+
if (roleRank[role] > roleRank[existing.role]) {
|
|
3735
|
+
existing.role = role;
|
|
3736
|
+
}
|
|
3737
|
+
return;
|
|
3738
|
+
}
|
|
3739
|
+
candidateMap.set(filePath, {
|
|
3740
|
+
filePath,
|
|
3741
|
+
role,
|
|
3742
|
+
score: weightedScore,
|
|
3743
|
+
reasons: adapterReason ? [reason, adapterReason] : [reason],
|
|
3744
|
+
signals: new Set([signal]),
|
|
3745
|
+
adapterSignals: new Map(),
|
|
3746
|
+
order: candidateOrder++,
|
|
3747
|
+
});
|
|
3748
|
+
recordAdapterSignal(candidateMap.get(filePath), adapterSignal);
|
|
3749
|
+
};
|
|
3750
|
+
const callsAnySymbol = (filePath, symbolIds) => {
|
|
3751
|
+
const fileNode = this.graph.files.get(filePath);
|
|
3752
|
+
if (!fileNode || symbolIds.size === 0) {
|
|
3753
|
+
return false;
|
|
3754
|
+
}
|
|
3755
|
+
for (const symbolId of fileNode.symbols) {
|
|
3756
|
+
const symbol = this.graph.symbols.get(symbolId);
|
|
3757
|
+
if (!symbol) {
|
|
3758
|
+
continue;
|
|
3759
|
+
}
|
|
3760
|
+
for (const calledSymbolId of symbol.calls) {
|
|
3761
|
+
if (symbolIds.has(calledSymbolId)) {
|
|
3762
|
+
return true;
|
|
3763
|
+
}
|
|
3764
|
+
}
|
|
3765
|
+
}
|
|
3766
|
+
return false;
|
|
3767
|
+
};
|
|
3768
|
+
const fileSymbolIds = (filePath) => new Set(this.graph.files.get(filePath)?.symbols ?? []);
|
|
3769
|
+
const isEntryPoint = (filePath) => this.isRouteEntryPointFileForPlan(filePath) || this.isIndexBoundaryFileForPlan(filePath);
|
|
3770
|
+
const historyEntityToFilePath = (entity) => {
|
|
3771
|
+
if (!entity) {
|
|
3772
|
+
return null;
|
|
3773
|
+
}
|
|
3774
|
+
const separator = entity.indexOf("::");
|
|
3775
|
+
const filePath = separator === -1 ? entity : entity.slice(0, separator);
|
|
3776
|
+
if (!filePath || filePath === "initial_scan") {
|
|
3777
|
+
return null;
|
|
3778
|
+
}
|
|
3779
|
+
return this.resolveFilePath(filePath);
|
|
3780
|
+
};
|
|
3781
|
+
const recentScores = new Map();
|
|
3782
|
+
let recentRank = 0;
|
|
3783
|
+
for (let i = this.history.events.length - 1; i >= 0 && recentRank < 80; i--) {
|
|
3784
|
+
const event = this.history.events[i];
|
|
3785
|
+
[historyEntityToFilePath(event.source), historyEntityToFilePath(event.target)]
|
|
3786
|
+
.filter((filePath) => Boolean(filePath))
|
|
3787
|
+
.forEach((filePath) => {
|
|
3788
|
+
if (!recentScores.has(filePath)) {
|
|
3789
|
+
recentScores.set(filePath, Math.max(20, 90 - recentRank * 3));
|
|
3790
|
+
recentRank++;
|
|
3791
|
+
}
|
|
3792
|
+
});
|
|
3793
|
+
}
|
|
3794
|
+
addCandidate(resolvedPath, "target", 10000, "Target file for the requested task; read this before every other file.", "target");
|
|
3795
|
+
testFiles.forEach((testFile) => {
|
|
3796
|
+
const testNode = this.graph.files.get(testFile);
|
|
3797
|
+
if (!testNode) {
|
|
3798
|
+
return;
|
|
3799
|
+
}
|
|
3800
|
+
if (testNode.imports.has(resolvedPath) || callsAnySymbol(testFile, targetSymbolIds)) {
|
|
3801
|
+
directTestFiles.add(testFile);
|
|
3802
|
+
addCandidate(testFile, "test", 1600, `Direct test for ${targetProjectPath}; use it as the first verification target.`, "direct-test");
|
|
3803
|
+
}
|
|
3804
|
+
});
|
|
3805
|
+
importers.slice(0, 8).forEach((filePath) => {
|
|
3806
|
+
const role = this.isTestFileForPlan(filePath)
|
|
3807
|
+
? "test"
|
|
3808
|
+
: isEntryPoint(filePath)
|
|
3809
|
+
? "entrypoint"
|
|
3810
|
+
: "importer";
|
|
3811
|
+
const signal = role === "test"
|
|
3812
|
+
? "direct-test"
|
|
3813
|
+
: role === "entrypoint"
|
|
3814
|
+
? "transitive-entrypoint"
|
|
3815
|
+
: "direct-importer";
|
|
3816
|
+
if (role === "test") {
|
|
3817
|
+
directTestFiles.add(filePath);
|
|
3818
|
+
}
|
|
3819
|
+
if (role === "entrypoint") {
|
|
3820
|
+
entryPointFiles.add(filePath);
|
|
3821
|
+
}
|
|
3822
|
+
addCandidate(filePath, role, role === "test" ? 1500 : role === "entrypoint" ? 1050 : 900, "Direct importer; verify it still satisfies the target file contract.", signal);
|
|
3823
|
+
});
|
|
3824
|
+
imports.slice(0, 6).forEach((filePath) => {
|
|
3825
|
+
addCandidate(filePath, "dependency", 420, "Direct dependency imported by the target file; read if the task changes how the target delegates work.", "direct-dependency");
|
|
3826
|
+
});
|
|
3827
|
+
importers.forEach((importerPath) => {
|
|
3828
|
+
const importerNode = this.graph.files.get(importerPath);
|
|
3829
|
+
if (!importerNode) {
|
|
3830
|
+
return;
|
|
3831
|
+
}
|
|
3832
|
+
const importerSymbolIds = fileSymbolIds(importerPath);
|
|
3833
|
+
testFiles.forEach((testFile) => {
|
|
3834
|
+
if (directTestFiles.has(testFile)) {
|
|
3835
|
+
return;
|
|
3836
|
+
}
|
|
3837
|
+
const testNode = this.graph.files.get(testFile);
|
|
3838
|
+
if (!testNode) {
|
|
3839
|
+
return;
|
|
3840
|
+
}
|
|
3841
|
+
if (testNode.imports.has(importerPath) || callsAnySymbol(testFile, importerSymbolIds)) {
|
|
3842
|
+
importerTestFiles.add(testFile);
|
|
3843
|
+
addCandidate(testFile, "test", 980, `Test for direct importer ${this.toProjectPath(importerPath)}; use after target-level tests.`, "test-for-importer");
|
|
3844
|
+
}
|
|
3845
|
+
});
|
|
3846
|
+
Array.from(importerNode.importedBy)
|
|
3847
|
+
.filter((filePath) => isEntryPoint(filePath))
|
|
3848
|
+
.sort(byRiskThenConnectivity)
|
|
3849
|
+
.slice(0, 4)
|
|
3850
|
+
.forEach((entryPointPath) => {
|
|
3851
|
+
entryPointFiles.add(entryPointPath);
|
|
3852
|
+
addCandidate(entryPointPath, "entrypoint", 620, `Entry point reaches ${targetProjectPath} through ${this.toProjectPath(importerPath)}.`, "transitive-entrypoint");
|
|
3853
|
+
});
|
|
3854
|
+
});
|
|
3855
|
+
node.symbols.forEach((symbolId) => {
|
|
3856
|
+
const symbol = this.graph.symbols.get(symbolId);
|
|
3857
|
+
if (!symbol) {
|
|
3858
|
+
return;
|
|
3859
|
+
}
|
|
3860
|
+
if (symbol.calledBy.size > 0) {
|
|
3861
|
+
symbolVerificationTargets.add(`${this.projectSymbolId(symbol.id)} (${symbol.calledBy.size} callers)`);
|
|
3862
|
+
}
|
|
3863
|
+
symbol.calledBy.forEach((callerId) => {
|
|
3864
|
+
const caller = this.graph.symbols.get(callerId);
|
|
3865
|
+
if (caller) {
|
|
3866
|
+
const role = this.isTestFileForPlan(caller.file)
|
|
3867
|
+
? "test"
|
|
3868
|
+
: isEntryPoint(caller.file)
|
|
3869
|
+
? "entrypoint"
|
|
3870
|
+
: "caller";
|
|
3871
|
+
if (role === "test") {
|
|
3872
|
+
directTestFiles.add(caller.file);
|
|
3873
|
+
}
|
|
3874
|
+
if (role === "entrypoint") {
|
|
3875
|
+
entryPointFiles.add(caller.file);
|
|
3876
|
+
}
|
|
3877
|
+
addCandidate(caller.file, role, role === "test" ? 1350 : role === "entrypoint" ? 850 : 700, `Caller of ${this.projectSymbolId(symbolId)}; inspect expected input/output behavior.`, role === "test" ? "direct-test" : role === "entrypoint" ? "transitive-entrypoint" : "symbol-caller");
|
|
3878
|
+
}
|
|
3879
|
+
});
|
|
3880
|
+
symbol.calls.forEach((targetId) => {
|
|
3881
|
+
const target = this.graph.symbols.get(targetId);
|
|
3882
|
+
if (target) {
|
|
3883
|
+
addCandidate(target.file, "dependency", 360, `Called by ${this.projectSymbolId(symbolId)}; read if the task touches this call path.`, "direct-dependency");
|
|
3884
|
+
}
|
|
3885
|
+
});
|
|
3886
|
+
});
|
|
3887
|
+
const directNeighborhood = new Set([resolvedPath, ...importers, ...imports]);
|
|
3888
|
+
recentScores.forEach((score, filePath) => {
|
|
3889
|
+
if (directNeighborhood.has(filePath)) {
|
|
3890
|
+
addCandidate(filePath, filePath === resolvedPath ? "target" : "related", score, "Recently changed in Ripple history; check for active churn around this task.", "recent-change");
|
|
3891
|
+
}
|
|
3892
|
+
});
|
|
3893
|
+
candidateMap.forEach((candidate) => {
|
|
3894
|
+
const fileNode = this.graph.files.get(candidate.filePath);
|
|
3895
|
+
if (!fileNode) {
|
|
3896
|
+
return;
|
|
3897
|
+
}
|
|
3898
|
+
const candidateRisk = this.modificationRiskFor(fileNode);
|
|
3899
|
+
if (candidateRisk !== "safe") {
|
|
3900
|
+
candidate.score += candidateRisk === "dangerous" ? 260 : 140;
|
|
3901
|
+
candidate.signals.add("risk");
|
|
3902
|
+
const reason = `${candidateRisk} file; preserve public contracts and verify callers.`;
|
|
3903
|
+
if (!candidate.reasons.includes(reason)) {
|
|
3904
|
+
candidate.reasons.push(reason);
|
|
3905
|
+
}
|
|
3906
|
+
}
|
|
3907
|
+
if (fileNode.hasParseError) {
|
|
3908
|
+
candidate.score += 120;
|
|
3909
|
+
candidate.signals.add("parse-warning");
|
|
3910
|
+
}
|
|
3911
|
+
const taskMatch = this.taskRelevanceForPlan(candidate.filePath, taskTerms);
|
|
3912
|
+
if (taskMatch.score > 0) {
|
|
3913
|
+
candidate.score += taskMatch.score;
|
|
3914
|
+
candidate.signals.add("task-match");
|
|
3915
|
+
taskMatchedFileCount++;
|
|
3916
|
+
taskMatchedFiles.add(candidate.filePath);
|
|
3917
|
+
taskMatch.matches.forEach((term) => taskMatchedTerms.add(term));
|
|
3918
|
+
const reason = `Matches task terms: ${taskMatch.matches.join(", ")}.`;
|
|
3919
|
+
if (!candidate.reasons.includes(reason)) {
|
|
3920
|
+
candidate.reasons.push(reason);
|
|
3921
|
+
}
|
|
3922
|
+
}
|
|
3923
|
+
});
|
|
3924
|
+
const sortedCandidateEntries = Array.from(candidateMap.values())
|
|
3925
|
+
.sort((a, b) => {
|
|
3926
|
+
if (a.filePath === resolvedPath) {
|
|
3927
|
+
return -1;
|
|
3928
|
+
}
|
|
3929
|
+
if (b.filePath === resolvedPath) {
|
|
3930
|
+
return 1;
|
|
3931
|
+
}
|
|
3932
|
+
if (a.score !== b.score) {
|
|
3933
|
+
return b.score - a.score;
|
|
3934
|
+
}
|
|
3935
|
+
const aNode = this.graph.files.get(a.filePath);
|
|
3936
|
+
const bNode = this.graph.files.get(b.filePath);
|
|
3937
|
+
const aRisk = aNode ? riskRank[this.modificationRiskFor(aNode)] : 0;
|
|
3938
|
+
const bRisk = bNode ? riskRank[this.modificationRiskFor(bNode)] : 0;
|
|
3939
|
+
if (aRisk !== bRisk) {
|
|
3940
|
+
return bRisk - aRisk;
|
|
3941
|
+
}
|
|
3942
|
+
if (a.order !== b.order) {
|
|
3943
|
+
return a.order - b.order;
|
|
3944
|
+
}
|
|
3945
|
+
return this.toProjectPath(a.filePath).localeCompare(this.toProjectPath(b.filePath));
|
|
3946
|
+
});
|
|
3947
|
+
const candidates = sortedCandidateEntries
|
|
3948
|
+
.map((candidate) => this.contextPlanFileFor(candidate.filePath, candidate.reasons.join(" "), candidate.role, Math.round(candidate.score), Array.from(candidate.signals).sort(), Array.from(candidate.adapterSignals.values()).sort((a, b) => a.capability.localeCompare(b.capability))))
|
|
3949
|
+
.filter((candidate) => Boolean(candidate));
|
|
3950
|
+
const readFirst = [];
|
|
3951
|
+
const readIfNeeded = [];
|
|
3952
|
+
let estimatedTokens = 0;
|
|
3953
|
+
candidates.forEach((candidate, index) => {
|
|
3954
|
+
const mustRead = index === 0;
|
|
3955
|
+
if (mustRead || estimatedTokens + candidate.estimatedTokens <= normalizedBudget) {
|
|
3956
|
+
readFirst.push(candidate);
|
|
3957
|
+
estimatedTokens += candidate.estimatedTokens;
|
|
3958
|
+
}
|
|
3959
|
+
else {
|
|
3960
|
+
readIfNeeded.push(candidate);
|
|
3961
|
+
}
|
|
3962
|
+
});
|
|
3963
|
+
const symbolFocus = this.symbolFocusForPlan(sortedCandidateEntries.map((candidate) => candidate.filePath), resolvedPath, taskTerms, taskMatchedFiles);
|
|
3964
|
+
const verificationTargets = [];
|
|
3965
|
+
const pushVerificationTarget = (target) => {
|
|
3966
|
+
if (!verificationTargets.includes(target)) {
|
|
3967
|
+
verificationTargets.push(target);
|
|
3968
|
+
}
|
|
3969
|
+
};
|
|
3970
|
+
Array.from(directTestFiles)
|
|
3971
|
+
.sort(byRiskThenConnectivity)
|
|
3972
|
+
.slice(0, 8)
|
|
3973
|
+
.forEach((filePath) => pushVerificationTarget(this.toProjectPath(filePath)));
|
|
3974
|
+
Array.from(importerTestFiles)
|
|
3975
|
+
.sort(byRiskThenConnectivity)
|
|
3976
|
+
.slice(0, 6)
|
|
3977
|
+
.forEach((filePath) => pushVerificationTarget(this.toProjectPath(filePath)));
|
|
3978
|
+
Array.from(entryPointFiles)
|
|
3979
|
+
.sort(byRiskThenConnectivity)
|
|
3980
|
+
.slice(0, 6)
|
|
3981
|
+
.forEach((filePath) => pushVerificationTarget(this.toProjectPath(filePath)));
|
|
3982
|
+
importers.slice(0, 8).forEach((filePath) => pushVerificationTarget(this.toProjectPath(filePath)));
|
|
3983
|
+
Array.from(symbolVerificationTargets)
|
|
3984
|
+
.sort()
|
|
3985
|
+
.slice(0, 8)
|
|
3986
|
+
.forEach(pushVerificationTarget);
|
|
3987
|
+
const adapterCapabilitiesByUse = (agentUse) => adapterSupport.primaryAdapter.capabilityProfile
|
|
3988
|
+
.filter((capability) => capability.agentUse === agentUse)
|
|
3989
|
+
.map((capability) => capability.capability)
|
|
3990
|
+
.join(", ") || "none";
|
|
3991
|
+
const planningSignals = [
|
|
3992
|
+
`Adapter ranking: ${adapterSupport.primaryAdapter.capabilities.displayName} ${adapterSupport.supportLevel} adapter (${Math.round(adapterSupport.primaryAdapter.confidence * 100)}% confidence); trust ${adapterCapabilitiesByUse("trust")}; verify ${adapterCapabilitiesByUse("verify")}; manual ${adapterCapabilitiesByUse("manual")}.`,
|
|
3993
|
+
`${directTestFiles.size} direct test file(s) found`,
|
|
3994
|
+
`${importerTestFiles.size} importer test file(s) found`,
|
|
3995
|
+
`${entryPointFiles.size} entry point file(s) found`,
|
|
3996
|
+
`${importers.length} direct importer(s)`,
|
|
3997
|
+
`${imports.length} direct dependenc${imports.length === 1 ? "y" : "ies"}`,
|
|
3998
|
+
`${symbolFocus.length} symbol focus item(s) ranked`,
|
|
3999
|
+
...(taskTerms.length > 0
|
|
4000
|
+
? [
|
|
4001
|
+
`${taskMatchedFileCount} task-matched file(s) for: ${Array.from(taskMatchedTerms).sort().join(", ") || taskTerms.join(", ")}`,
|
|
4002
|
+
]
|
|
4003
|
+
: []),
|
|
4004
|
+
];
|
|
4005
|
+
return {
|
|
4006
|
+
task: task.trim() || "Unspecified task",
|
|
4007
|
+
targetFile: targetProjectPath,
|
|
4008
|
+
risk,
|
|
4009
|
+
adapterSupport,
|
|
4010
|
+
tokenBudget: normalizedBudget,
|
|
4011
|
+
estimatedTokens,
|
|
4012
|
+
readFirst,
|
|
4013
|
+
readIfNeeded,
|
|
4014
|
+
symbolFocus,
|
|
4015
|
+
avoidInitially: [
|
|
4016
|
+
"Generated folders and caches such as .ripple/.cache, dist, out, build, .next, and coverage.",
|
|
4017
|
+
"Docs, package metadata, and unrelated tests unless the readFirst files point there.",
|
|
4018
|
+
"Files outside the listed import, importer, and caller neighborhoods until needed.",
|
|
4019
|
+
],
|
|
4020
|
+
doNotReadFirst: [
|
|
4021
|
+
"Unrelated tests that neither import the target nor test its direct importers.",
|
|
4022
|
+
"Broad documentation, package metadata, generated files, and snapshots until a readFirst file points there.",
|
|
4023
|
+
"Transitive dependencies beyond one hop unless verification fails or the task explicitly requires deeper tracing.",
|
|
4024
|
+
],
|
|
4025
|
+
verificationTargets,
|
|
4026
|
+
planningSignals,
|
|
4027
|
+
why: `${targetProjectPath} is ${risk}; it imports ${node.imports.size} file(s), is imported by ${node.importedBy.size} file(s), and has ${node.symbols.size} tracked symbol(s). The plan prioritizes direct tests, contract importers, entry points, symbol callers, risky files, recent churn, and task term matches within the token budget.`,
|
|
4028
|
+
};
|
|
4029
|
+
}
|
|
4030
|
+
getFileFocusSummary(filePath) {
|
|
4031
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
4032
|
+
const node = this.graph.files.get(resolvedPath);
|
|
4033
|
+
if (!node) {
|
|
4034
|
+
return null;
|
|
4035
|
+
}
|
|
4036
|
+
const imports = Array.from(node.imports)
|
|
4037
|
+
.sort((a, b) => this.toProjectPath(a).localeCompare(this.toProjectPath(b)))
|
|
4038
|
+
.map((importPath) => this.toProjectPath(importPath));
|
|
4039
|
+
const importedBy = Array.from(node.importedBy)
|
|
4040
|
+
.sort((a, b) => this.toProjectPath(a).localeCompare(this.toProjectPath(b)))
|
|
4041
|
+
.map((importerPath) => {
|
|
4042
|
+
const importerNode = this.graph.files.get(importerPath);
|
|
4043
|
+
return {
|
|
4044
|
+
file: this.toProjectPath(importerPath),
|
|
4045
|
+
focus: this.focusPathFor(importerPath),
|
|
4046
|
+
modificationRisk: importerNode ? this.modificationRiskFor(importerNode) : "safe",
|
|
4047
|
+
};
|
|
4048
|
+
});
|
|
4049
|
+
const symbols = Array.from(node.symbols)
|
|
4050
|
+
.map((symbolId) => this.graph.symbols.get(symbolId))
|
|
4051
|
+
.filter((symbol) => Boolean(symbol))
|
|
4052
|
+
.sort((a, b) => a.name.localeCompare(b.name))
|
|
4053
|
+
.map((symbol) => ({
|
|
4054
|
+
name: symbol.name,
|
|
4055
|
+
kind: symbol.kind,
|
|
4056
|
+
layer: symbol.layer,
|
|
4057
|
+
callerCount: symbol.calledBy.size,
|
|
4058
|
+
}));
|
|
4059
|
+
return {
|
|
4060
|
+
filePath: resolvedPath,
|
|
4061
|
+
projectPath: this.toProjectPath(resolvedPath),
|
|
4062
|
+
focusKey: makeFocusKey(resolvedPath, this.graph),
|
|
4063
|
+
focusPath: this.focusPathFor(resolvedPath),
|
|
4064
|
+
modificationRisk: this.modificationRiskFor(node),
|
|
4065
|
+
imports,
|
|
4066
|
+
importedBy,
|
|
4067
|
+
symbols,
|
|
4068
|
+
};
|
|
4069
|
+
}
|
|
4070
|
+
getFileSymbolsSummary(filePath) {
|
|
4071
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
4072
|
+
const node = this.graph.files.get(resolvedPath);
|
|
4073
|
+
if (!node) {
|
|
4074
|
+
return null;
|
|
4075
|
+
}
|
|
4076
|
+
const symbols = Array.from(node.symbols)
|
|
4077
|
+
.sort((a, b) => this.projectSymbolId(a).localeCompare(this.projectSymbolId(b)))
|
|
4078
|
+
.map((symbolId) => this.symbolGraphSummaryFor(symbolId))
|
|
4079
|
+
.filter((symbol) => Boolean(symbol));
|
|
4080
|
+
return {
|
|
4081
|
+
filePath: resolvedPath,
|
|
4082
|
+
projectPath: this.toProjectPath(resolvedPath),
|
|
4083
|
+
modificationRisk: this.modificationRiskFor(node),
|
|
4084
|
+
symbols,
|
|
4085
|
+
};
|
|
4086
|
+
}
|
|
4087
|
+
getSymbolCallersSummary(symbolId) {
|
|
4088
|
+
const resolvedSymbolId = this.resolveSymbolId(symbolId);
|
|
4089
|
+
if (!resolvedSymbolId) {
|
|
4090
|
+
return null;
|
|
4091
|
+
}
|
|
4092
|
+
const symbol = this.symbolGraphSummaryFor(resolvedSymbolId);
|
|
4093
|
+
if (!symbol) {
|
|
4094
|
+
return null;
|
|
4095
|
+
}
|
|
4096
|
+
return {
|
|
4097
|
+
symbol,
|
|
4098
|
+
callers: symbol.calledBy,
|
|
4099
|
+
callerCount: symbol.calledBy.length,
|
|
4100
|
+
};
|
|
4101
|
+
}
|
|
4102
|
+
getFileDependencySummary(filePath) {
|
|
4103
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
4104
|
+
const node = this.graph.files.get(resolvedPath);
|
|
4105
|
+
if (!node) {
|
|
4106
|
+
return null;
|
|
4107
|
+
}
|
|
4108
|
+
const byProjectPath = (a, b) => this.toProjectPath(a).localeCompare(this.toProjectPath(b));
|
|
4109
|
+
return {
|
|
4110
|
+
filePath: resolvedPath,
|
|
4111
|
+
projectPath: this.toProjectPath(resolvedPath),
|
|
4112
|
+
modificationRisk: this.modificationRiskFor(node),
|
|
4113
|
+
imports: Array.from(node.imports).sort(byProjectPath).map((importPath) => this.dependencyLinkFor(importPath)),
|
|
4114
|
+
importers: Array.from(node.importedBy).sort(byProjectPath).map((importerPath) => this.dependencyLinkFor(importerPath)),
|
|
4115
|
+
};
|
|
4116
|
+
}
|
|
4117
|
+
getBlastRadiusSummary(filePath) {
|
|
4118
|
+
const resolvedPath = this.resolveFilePath(filePath);
|
|
4119
|
+
const node = this.graph.files.get(resolvedPath);
|
|
4120
|
+
if (!node) {
|
|
4121
|
+
return null;
|
|
4122
|
+
}
|
|
4123
|
+
const directImporters = this.blastRadius([resolvedPath])
|
|
4124
|
+
.sort((a, b) => this.toProjectPath(a).localeCompare(this.toProjectPath(b)))
|
|
4125
|
+
.map((affectedPath) => {
|
|
4126
|
+
const affectedNode = this.graph.files.get(affectedPath);
|
|
4127
|
+
return {
|
|
4128
|
+
file: this.toProjectPath(affectedPath),
|
|
4129
|
+
focus: this.focusPathFor(affectedPath),
|
|
4130
|
+
modificationRisk: affectedNode ? this.modificationRiskFor(affectedNode) : "safe",
|
|
4131
|
+
importerCount: affectedNode?.importedBy.size ?? 0,
|
|
4132
|
+
};
|
|
4133
|
+
});
|
|
4134
|
+
return {
|
|
4135
|
+
filePath: resolvedPath,
|
|
4136
|
+
projectPath: this.toProjectPath(resolvedPath),
|
|
4137
|
+
modificationRisk: this.modificationRiskFor(node),
|
|
4138
|
+
directImporters,
|
|
4139
|
+
affectedCount: directImporters.length,
|
|
4140
|
+
};
|
|
4141
|
+
}
|
|
4142
|
+
blastRadius(filePaths) {
|
|
4143
|
+
const affected = new Set();
|
|
4144
|
+
for (const filePath of filePaths) {
|
|
4145
|
+
for (const downstream of this.downstreamFiles(filePath)) {
|
|
4146
|
+
affected.add(downstream);
|
|
4147
|
+
}
|
|
4148
|
+
}
|
|
4149
|
+
return Array.from(affected);
|
|
4150
|
+
}
|
|
4151
|
+
symbolAtPosition(filePath, line, character) {
|
|
4152
|
+
const sourceFile = this.getProjectSourceFile(filePath);
|
|
4153
|
+
if (!sourceFile) {
|
|
4154
|
+
return null;
|
|
4155
|
+
}
|
|
4156
|
+
let offset;
|
|
4157
|
+
try {
|
|
4158
|
+
offset = sourceFile.compilerNode.getPositionOfLineAndCharacter(line, character);
|
|
4159
|
+
}
|
|
4160
|
+
catch {
|
|
4161
|
+
return null;
|
|
4162
|
+
}
|
|
4163
|
+
let current = sourceFile.getDescendantAtPos(offset);
|
|
4164
|
+
while (current) {
|
|
4165
|
+
const kind = current.getKind();
|
|
4166
|
+
if (kind === ts_morph_1.SyntaxKind.FunctionDeclaration ||
|
|
4167
|
+
kind === ts_morph_1.SyntaxKind.FunctionExpression ||
|
|
4168
|
+
kind === ts_morph_1.SyntaxKind.MethodDeclaration) {
|
|
4169
|
+
const nameNode = current.getNameNode?.();
|
|
4170
|
+
const name = nameNode?.getText();
|
|
4171
|
+
if (name) {
|
|
4172
|
+
const symbolId = makeSymbolId(filePath, name);
|
|
4173
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
4174
|
+
return symbolId;
|
|
4175
|
+
}
|
|
4176
|
+
}
|
|
4177
|
+
}
|
|
4178
|
+
if (kind === ts_morph_1.SyntaxKind.ArrowFunction) {
|
|
4179
|
+
const parent = current.getParent();
|
|
4180
|
+
if (parent?.getKind() === ts_morph_1.SyntaxKind.VariableDeclaration) {
|
|
4181
|
+
const nameNode = parent.getNameNode?.();
|
|
4182
|
+
const name = nameNode?.getText();
|
|
4183
|
+
if (name) {
|
|
4184
|
+
const symbolId = makeSymbolId(filePath, name);
|
|
4185
|
+
if (this.graph.symbols.has(symbolId)) {
|
|
4186
|
+
return symbolId;
|
|
4187
|
+
}
|
|
4188
|
+
}
|
|
4189
|
+
}
|
|
4190
|
+
}
|
|
4191
|
+
current = current.getParent();
|
|
4192
|
+
}
|
|
4193
|
+
return null;
|
|
4194
|
+
}
|
|
4195
|
+
getSymbolDeclarationLine(symbolId) {
|
|
4196
|
+
const sym = this.graph.symbols.get(symbolId);
|
|
4197
|
+
if (!sym) {
|
|
4198
|
+
return null;
|
|
4199
|
+
}
|
|
4200
|
+
const sourceFile = this.getProjectSourceFile(sym.file);
|
|
4201
|
+
if (!sourceFile) {
|
|
4202
|
+
return null;
|
|
4203
|
+
}
|
|
4204
|
+
try {
|
|
4205
|
+
const funcDecl = sourceFile.getFunctions().find((f) => f.getName() === sym.name);
|
|
4206
|
+
if (funcDecl) {
|
|
4207
|
+
return Math.max(0, funcDecl.getStartLineNumber() - 1);
|
|
4208
|
+
}
|
|
4209
|
+
const varDecl = sourceFile.getVariableDeclarations().find((v) => v.getName() === sym.name);
|
|
4210
|
+
if (varDecl) {
|
|
4211
|
+
return Math.max(0, varDecl.getStartLineNumber() - 1);
|
|
4212
|
+
}
|
|
4213
|
+
}
|
|
4214
|
+
catch {
|
|
4215
|
+
return null;
|
|
4216
|
+
}
|
|
4217
|
+
return null;
|
|
4218
|
+
}
|
|
4219
|
+
}
|
|
4220
|
+
exports.GraphEngine = GraphEngine;
|
|
4221
|
+
//# sourceMappingURL=graph.js.map
|