@titan-design/code-graph 0.1.1 → 0.3.0
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/README.md +350 -15
- package/dist/change-coupling-CyqHgRsm.d.ts +73 -0
- package/dist/chunk-PFI5XMUG.js +332 -0
- package/dist/chunk-PFI5XMUG.js.map +1 -0
- package/dist/history/index.d.ts +56 -0
- package/dist/history/index.js +29 -0
- package/dist/history/index.js.map +1 -0
- package/dist/index.d.ts +941 -38
- package/dist/index.js +2982 -231
- package/dist/index.js.map +1 -1
- package/package.json +9 -4
package/dist/index.js
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
|
+
import {
|
|
2
|
+
aggregateChurnWindows,
|
|
3
|
+
authorLinesByPath,
|
|
4
|
+
computeChangeCoupling,
|
|
5
|
+
computeOwnership,
|
|
6
|
+
couplingFor,
|
|
7
|
+
detectGitToplevel,
|
|
8
|
+
entriesWithin,
|
|
9
|
+
loadChurnEntries,
|
|
10
|
+
loadFileFirstSeen,
|
|
11
|
+
runGit,
|
|
12
|
+
summarizeOwnership
|
|
13
|
+
} from "./chunk-PFI5XMUG.js";
|
|
14
|
+
|
|
1
15
|
// src/store.ts
|
|
2
|
-
import { openDatabase, runMigrations } from "@titan-design/store-sqlite";
|
|
16
|
+
import { openDatabase, quoteIdent, runMigrations } from "@titan-design/store-sqlite";
|
|
3
17
|
|
|
4
18
|
// src/schema.ts
|
|
5
19
|
import { kitMigration } from "@titan-design/store-sqlite";
|
|
@@ -48,11 +62,13 @@ var DOMAIN_DDL = `
|
|
|
48
62
|
PRIMARY KEY (snapshot_id, file_id)
|
|
49
63
|
);
|
|
50
64
|
`;
|
|
65
|
+
var SNAPSHOT_SCOPED_TABLES = ["node", "edge", "metric", "id_alias", "file_fingerprint"];
|
|
51
66
|
var MIGRATIONS = [
|
|
52
67
|
kitMigration(1, { snapshot: KIT.snapshot, entitySnap: KIT.entitySnap, cacheBlob: KIT.cacheBlob }),
|
|
53
68
|
{ version: 2, name: "code graph columns", up: (db) => db.exec(KIT_EXTENSIONS) },
|
|
54
69
|
{ version: 3, name: "code graph tables", up: (db) => db.exec(DOMAIN_DDL) }
|
|
55
70
|
];
|
|
71
|
+
var SCHEMA_VERSION = Math.max(...MIGRATIONS.map((m) => m.version));
|
|
56
72
|
|
|
57
73
|
// src/rows.ts
|
|
58
74
|
function rowToSnapshot(row) {
|
|
@@ -107,15 +123,81 @@ function rowToFingerprint(row) {
|
|
|
107
123
|
};
|
|
108
124
|
}
|
|
109
125
|
|
|
126
|
+
// src/store-reads.ts
|
|
127
|
+
var EDGE_COLS = "src_id, dst_id, kind, attrs";
|
|
128
|
+
var AGGREGATE_SELECT = `SELECT m.name AS name, n.kind AS node_kind, COUNT(m.value) AS count,
|
|
129
|
+
SUM(m.value) AS sum, MIN(m.value) AS min, MAX(m.value) AS max
|
|
130
|
+
FROM metric m CROSS JOIN node n ON n.snapshot_id = m.snapshot_id AND n.id = m.node_id`;
|
|
131
|
+
var TOP_SELECT = `SELECT m.node_id, m.value, m.unit, n.kind, n.name, n.role
|
|
132
|
+
FROM metric m JOIN node n ON n.snapshot_id = m.snapshot_id AND n.id = m.node_id
|
|
133
|
+
WHERE m.snapshot_id = ? AND m.name = ?`;
|
|
134
|
+
var TOP_ORDER = "ORDER BY m.value DESC LIMIT ?";
|
|
135
|
+
function prepareTargetedStatements(db) {
|
|
136
|
+
return {
|
|
137
|
+
listMetricsForNode: db.prepare(
|
|
138
|
+
"SELECT node_id, name, value, unit FROM metric WHERE snapshot_id = ? AND node_id = ?"
|
|
139
|
+
),
|
|
140
|
+
// The second arm skips self-loops so an edge from a node to itself comes back once.
|
|
141
|
+
listEdgesTouching: db.prepare(
|
|
142
|
+
`SELECT ${EDGE_COLS} FROM edge WHERE snapshot_id = @snapshotId AND src_id = @nodeId
|
|
143
|
+
UNION ALL
|
|
144
|
+
SELECT ${EDGE_COLS} FROM edge WHERE snapshot_id = @snapshotId AND dst_id = @nodeId AND src_id <> @nodeId`
|
|
145
|
+
),
|
|
146
|
+
aggregateMetric: db.prepare(
|
|
147
|
+
`${AGGREGATE_SELECT} WHERE m.snapshot_id = ? AND m.name = ? GROUP BY n.kind ORDER BY n.kind`
|
|
148
|
+
),
|
|
149
|
+
aggregateMetrics: db.prepare(
|
|
150
|
+
`${AGGREGATE_SELECT} WHERE m.snapshot_id = ? GROUP BY m.name, n.kind ORDER BY m.name, n.kind`
|
|
151
|
+
),
|
|
152
|
+
listMetricNames: db.prepare("SELECT DISTINCT name FROM metric WHERE snapshot_id = ? ORDER BY name"),
|
|
153
|
+
topByMetric: db.prepare(`${TOP_SELECT} ${TOP_ORDER}`),
|
|
154
|
+
topByMetricOfKind: db.prepare(`${TOP_SELECT} AND n.kind = ? ${TOP_ORDER}`)
|
|
155
|
+
};
|
|
156
|
+
}
|
|
157
|
+
function readMetricsForNode(stmts, snapshotId, nodeId) {
|
|
158
|
+
return stmts.listMetricsForNode.all(snapshotId, nodeId).map(rowToMetric);
|
|
159
|
+
}
|
|
160
|
+
function readEdgesTouching(stmts, snapshotId, nodeId) {
|
|
161
|
+
return stmts.listEdgesTouching.all({ snapshotId, nodeId }).map(rowToEdge);
|
|
162
|
+
}
|
|
163
|
+
function readMetricNames(stmts, snapshotId) {
|
|
164
|
+
return stmts.listMetricNames.all(snapshotId).map((r) => r.name);
|
|
165
|
+
}
|
|
166
|
+
function readTopByMetric(stmts, opts) {
|
|
167
|
+
const limit = opts.limit ?? 20;
|
|
168
|
+
const rows = opts.kind === void 0 ? stmts.topByMetric.all(opts.snapshotId, opts.metric, limit) : stmts.topByMetricOfKind.all(opts.snapshotId, opts.metric, opts.kind, limit);
|
|
169
|
+
return rows.map((r) => ({
|
|
170
|
+
nodeId: r.node_id,
|
|
171
|
+
name: r.name,
|
|
172
|
+
kind: r.kind,
|
|
173
|
+
role: r.role,
|
|
174
|
+
value: r.value,
|
|
175
|
+
unit: r.unit
|
|
176
|
+
}));
|
|
177
|
+
}
|
|
178
|
+
function readMetricAggregates(stmts, snapshotId, name) {
|
|
179
|
+
const rows = name === void 0 ? stmts.aggregateMetrics.all(snapshotId) : stmts.aggregateMetric.all(snapshotId, name);
|
|
180
|
+
return rows.map((r) => ({
|
|
181
|
+
name: r.name,
|
|
182
|
+
nodeKind: r.node_kind,
|
|
183
|
+
count: r.count,
|
|
184
|
+
sum: r.sum,
|
|
185
|
+
min: r.min,
|
|
186
|
+
max: r.max
|
|
187
|
+
}));
|
|
188
|
+
}
|
|
189
|
+
|
|
110
190
|
// src/store.ts
|
|
111
191
|
var NODE_COLS = "id, kind, name, parent_id, language, role, attrs";
|
|
112
192
|
var CodeGraphStore = class {
|
|
113
193
|
constructor(db) {
|
|
114
194
|
this.db = db;
|
|
115
195
|
this.statements = prepareStatements(db);
|
|
196
|
+
this.targeted = prepareTargetedStatements(db);
|
|
116
197
|
}
|
|
117
198
|
db;
|
|
118
199
|
statements;
|
|
200
|
+
targeted;
|
|
119
201
|
createSnapshot(input) {
|
|
120
202
|
const result = this.statements.insertSnapshot.run({
|
|
121
203
|
ref: input.ref,
|
|
@@ -164,6 +246,21 @@ var CodeGraphStore = class {
|
|
|
164
246
|
}
|
|
165
247
|
})();
|
|
166
248
|
}
|
|
249
|
+
/**
|
|
250
|
+
* Replace every metric of one `name` for a snapshot in a single transaction —
|
|
251
|
+
* for an overlay such as coverage, which is re-ingested wholesale and must not
|
|
252
|
+
* accumulate stale rows across runs.
|
|
253
|
+
*/
|
|
254
|
+
replaceMetricsByName(snapshotId, name, metrics) {
|
|
255
|
+
const del = this.statements.deleteMetricsByName;
|
|
256
|
+
const insert = this.statements.insertMetric;
|
|
257
|
+
this.db.transaction(() => {
|
|
258
|
+
del.run(snapshotId, name);
|
|
259
|
+
for (const m of metrics) {
|
|
260
|
+
insert.run({ snapshotId, nodeId: m.nodeId, name: m.name, value: m.value, unit: m.unit ?? null });
|
|
261
|
+
}
|
|
262
|
+
})();
|
|
263
|
+
}
|
|
167
264
|
insertAliases(snapshotId, aliases) {
|
|
168
265
|
const stmt = this.statements.insertAlias;
|
|
169
266
|
this.db.transaction(() => {
|
|
@@ -218,12 +315,53 @@ var CodeGraphStore = class {
|
|
|
218
315
|
listMetrics(snapshotId) {
|
|
219
316
|
return this.statements.listMetrics.all(snapshotId).map(rowToMetric);
|
|
220
317
|
}
|
|
318
|
+
/** Every metric on one node, from the primary key instead of a whole-snapshot read. */
|
|
319
|
+
listMetricsForNode(snapshotId, nodeId) {
|
|
320
|
+
return readMetricsForNode(this.targeted, snapshotId, nodeId);
|
|
321
|
+
}
|
|
322
|
+
/** Distinct metric names stored for a snapshot, ascending. */
|
|
323
|
+
listMetricNames(snapshotId) {
|
|
324
|
+
return readMetricNames(this.targeted, snapshotId);
|
|
325
|
+
}
|
|
326
|
+
/** The highest-valued nodes for one metric, descending; `limit` defaults to 20. */
|
|
327
|
+
topByMetric(opts) {
|
|
328
|
+
return readTopByMetric(this.targeted, opts);
|
|
329
|
+
}
|
|
330
|
+
/** Edges into or out of one node; `references` edges are excluded unless asked, as in {@link listEdges}. */
|
|
331
|
+
listEdgesTouching(snapshotId, nodeId, opts) {
|
|
332
|
+
const edges = readEdgesTouching(this.targeted, snapshotId, nodeId);
|
|
333
|
+
return opts?.includeReferences ? edges : edges.filter((e) => e.kind !== "references");
|
|
334
|
+
}
|
|
335
|
+
/** Count, sum, min, and max per metric name and node kind; pass `name` for one metric. */
|
|
336
|
+
aggregateMetrics(snapshotId, opts) {
|
|
337
|
+
return readMetricAggregates(this.targeted, snapshotId, opts?.name);
|
|
338
|
+
}
|
|
221
339
|
listAliases(snapshotId) {
|
|
222
340
|
return this.statements.listAliases.all(snapshotId).map(rowToAlias);
|
|
223
341
|
}
|
|
224
342
|
listFingerprints(snapshotId) {
|
|
225
343
|
return this.statements.listFingerprints.all(snapshotId).map(rowToFingerprint);
|
|
226
344
|
}
|
|
345
|
+
/** Drop whole snapshots. The domain tables carry no foreign key, so each is cleared by hand. */
|
|
346
|
+
deleteSnapshots(ids) {
|
|
347
|
+
if (ids.length === 0) return;
|
|
348
|
+
this.db.transaction(() => {
|
|
349
|
+
for (const id of ids) {
|
|
350
|
+
for (const stmt of this.statements.deleteBySnapshot) stmt.run(id);
|
|
351
|
+
}
|
|
352
|
+
})();
|
|
353
|
+
}
|
|
354
|
+
vacuum() {
|
|
355
|
+
this.db.exec("VACUUM");
|
|
356
|
+
}
|
|
357
|
+
countRowsByTable(tables) {
|
|
358
|
+
const out = {};
|
|
359
|
+
for (const table of tables) {
|
|
360
|
+
const row = this.db.prepare(`SELECT COUNT(*) AS n FROM ${quoteIdent(table)}`).get();
|
|
361
|
+
out[table] = row.n;
|
|
362
|
+
}
|
|
363
|
+
return out;
|
|
364
|
+
}
|
|
227
365
|
close() {
|
|
228
366
|
this.db.close();
|
|
229
367
|
}
|
|
@@ -266,130 +404,54 @@ function prepareStatements(db) {
|
|
|
266
404
|
listAliases: db.prepare("SELECT old_id, new_id, reason FROM id_alias WHERE snapshot_id = ?"),
|
|
267
405
|
listFingerprints: db.prepare(
|
|
268
406
|
"SELECT file_id, content_hash, structural_hash FROM file_fingerprint WHERE snapshot_id = ?"
|
|
407
|
+
),
|
|
408
|
+
deleteMetricsByName: db.prepare("DELETE FROM metric WHERE snapshot_id = ? AND name = ?"),
|
|
409
|
+
deleteBySnapshot: [...SNAPSHOT_SCOPED_TABLES, "snapshot"].map(
|
|
410
|
+
(table) => db.prepare(`DELETE FROM ${quoteIdent(table)} WHERE ${table === "snapshot" ? "id" : "snapshot_id"} = ?`)
|
|
269
411
|
)
|
|
270
412
|
};
|
|
271
413
|
}
|
|
272
414
|
function openCodeGraph(dbPath) {
|
|
273
|
-
const db = openDatabase(dbPath);
|
|
415
|
+
const db = openDatabase(dbPath, { schemaVersion: SCHEMA_VERSION });
|
|
274
416
|
runMigrations(db, MIGRATIONS);
|
|
275
417
|
return new CodeGraphStore(db);
|
|
276
418
|
}
|
|
277
419
|
|
|
278
|
-
// src/
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
return require2.resolve("tree-sitter-typescript/tree-sitter-typescript.wasm");
|
|
293
|
-
case "tsx":
|
|
294
|
-
return require2.resolve("tree-sitter-typescript/tree-sitter-tsx.wasm");
|
|
295
|
-
case "python":
|
|
296
|
-
return require2.resolve("tree-sitter-python/tree-sitter-python.wasm");
|
|
297
|
-
default:
|
|
298
|
-
throw new Error(`Unsupported language: ${language}`);
|
|
299
|
-
}
|
|
300
|
-
}
|
|
301
|
-
async function ensureInitialized() {
|
|
302
|
-
if (initialized) return;
|
|
303
|
-
await Parser.init();
|
|
304
|
-
initialized = true;
|
|
305
|
-
}
|
|
306
|
-
async function getLanguage(language) {
|
|
307
|
-
const cached = languages.get(language);
|
|
308
|
-
if (cached) return cached;
|
|
309
|
-
const wasmPath = resolveWasmPath(language);
|
|
310
|
-
const lang = await Language.load(wasmPath);
|
|
311
|
-
languages.set(language, lang);
|
|
312
|
-
return lang;
|
|
313
|
-
}
|
|
314
|
-
async function getParser(language) {
|
|
315
|
-
const cached = parsers.get(language);
|
|
316
|
-
if (cached) return cached;
|
|
317
|
-
resolveWasmPath(language);
|
|
318
|
-
await ensureInitialized();
|
|
319
|
-
const lang = await getLanguage(language);
|
|
320
|
-
const parser = new Parser();
|
|
321
|
-
parser.setLanguage(lang);
|
|
322
|
-
parsers.set(language, parser);
|
|
323
|
-
return parser;
|
|
324
|
-
}
|
|
325
|
-
async function parseFile(content, filePath, language) {
|
|
326
|
-
const parser = await getParser(language);
|
|
327
|
-
const tree = parser.parse(content);
|
|
328
|
-
if (!tree) {
|
|
329
|
-
throw new Error(`Failed to parse ${filePath}`);
|
|
330
|
-
}
|
|
331
|
-
return { tree, content, filePath, language };
|
|
332
|
-
}
|
|
333
|
-
function getSupportedLanguages() {
|
|
334
|
-
return ["typescript", "tsx", "python"];
|
|
335
|
-
}
|
|
336
|
-
|
|
337
|
-
// src/parser/file-filter.ts
|
|
338
|
-
import * as path from "path";
|
|
339
|
-
var EXCLUDED_DIRS = [
|
|
340
|
-
"node_modules",
|
|
341
|
-
"vendor",
|
|
342
|
-
"dist",
|
|
343
|
-
"build",
|
|
344
|
-
".next",
|
|
345
|
-
"__generated__",
|
|
346
|
-
".git",
|
|
347
|
-
".claude",
|
|
348
|
-
"coverage"
|
|
349
|
-
];
|
|
350
|
-
var EXCLUDED_PATTERNS = [
|
|
351
|
-
/\.min\.[jt]sx?$/,
|
|
352
|
-
/\.d\.ts$/,
|
|
353
|
-
/\.map$/,
|
|
354
|
-
/lock\.(json|yaml)$/,
|
|
355
|
-
/pnpm-lock\.yaml$/,
|
|
356
|
-
/package-lock\.json$/,
|
|
357
|
-
/yarn\.lock$/
|
|
358
|
-
];
|
|
359
|
-
var LANGUAGE_EXTENSIONS = {
|
|
360
|
-
typescript: [".ts", ".tsx"],
|
|
361
|
-
javascript: [".js", ".jsx"],
|
|
362
|
-
python: [".py"]
|
|
363
|
-
};
|
|
364
|
-
function isExcludedDir(dirName) {
|
|
365
|
-
return EXCLUDED_DIRS.includes(dirName);
|
|
366
|
-
}
|
|
367
|
-
function shouldIncludeFile(filePath, languages2) {
|
|
368
|
-
const segments = filePath.split("/");
|
|
369
|
-
if (segments.some((s) => EXCLUDED_DIRS.includes(s))) {
|
|
370
|
-
return false;
|
|
371
|
-
}
|
|
372
|
-
if (EXCLUDED_PATTERNS.some((p) => p.test(filePath))) {
|
|
373
|
-
return false;
|
|
374
|
-
}
|
|
375
|
-
const ext = path.extname(filePath);
|
|
376
|
-
const allowedExtensions = languages2.flatMap(
|
|
377
|
-
(lang) => LANGUAGE_EXTENSIONS[lang] ?? []
|
|
378
|
-
);
|
|
379
|
-
return allowedExtensions.includes(ext);
|
|
380
|
-
}
|
|
381
|
-
function getLanguageFromPath(filePath) {
|
|
382
|
-
const ext = path.extname(filePath);
|
|
383
|
-
for (const [language, extensions] of Object.entries(LANGUAGE_EXTENSIONS)) {
|
|
384
|
-
if (extensions.includes(ext)) {
|
|
385
|
-
return language;
|
|
420
|
+
// src/prune.ts
|
|
421
|
+
var CASCADE_TABLES = SNAPSHOT_SCOPED_TABLES;
|
|
422
|
+
function planPrune(store, options = {}) {
|
|
423
|
+
const keepCount = options.keep ?? 10;
|
|
424
|
+
const keepRefs = new Set(options.keepRefs ?? []);
|
|
425
|
+
const all = store.listSnapshots({ limit: 1e6 });
|
|
426
|
+
const keep = [];
|
|
427
|
+
const remove = [];
|
|
428
|
+
for (let i = 0; i < all.length; i++) {
|
|
429
|
+
const snap = all[i];
|
|
430
|
+
if (i < keepCount || keepRefs.has(snap.ref)) {
|
|
431
|
+
keep.push(snap);
|
|
432
|
+
} else {
|
|
433
|
+
remove.push(snap);
|
|
386
434
|
}
|
|
387
435
|
}
|
|
388
|
-
return
|
|
436
|
+
return { keep, remove };
|
|
437
|
+
}
|
|
438
|
+
function runPrune(store, options = {}) {
|
|
439
|
+
const plan = planPrune(store, options);
|
|
440
|
+
const tables = ["snapshot", ...CASCADE_TABLES];
|
|
441
|
+
const rowsBefore = store.countRowsByTable(tables);
|
|
442
|
+
store.deleteSnapshots(plan.remove.map((s) => s.id));
|
|
443
|
+
if (options.vacuum) store.vacuum();
|
|
444
|
+
const rowsAfter = store.countRowsByTable(tables);
|
|
445
|
+
return { plan, rowsBefore, rowsAfter, vacuumed: !!options.vacuum };
|
|
389
446
|
}
|
|
390
447
|
|
|
448
|
+
// src/indexer.ts
|
|
449
|
+
import { realpathSync } from "fs";
|
|
450
|
+
import * as path9 from "path";
|
|
451
|
+
import { parseFile } from "@titan-design/code-parser";
|
|
452
|
+
|
|
391
453
|
// src/extractors/ts-morph-extractor.ts
|
|
392
|
-
import * as
|
|
454
|
+
import * as path4 from "path";
|
|
393
455
|
import { existsSync } from "fs";
|
|
394
456
|
import {
|
|
395
457
|
Project,
|
|
@@ -399,16 +461,17 @@ import {
|
|
|
399
461
|
} from "ts-morph";
|
|
400
462
|
|
|
401
463
|
// src/extractors/file-nodes.ts
|
|
402
|
-
import * as
|
|
464
|
+
import * as path2 from "path";
|
|
465
|
+
import { getLanguageFromPath } from "@titan-design/code-parser";
|
|
403
466
|
|
|
404
467
|
// src/extractors/ids.ts
|
|
405
|
-
import * as
|
|
468
|
+
import * as path from "path";
|
|
406
469
|
var MODULE_EXT_RE = /\.(?:tsx|ts|jsx|js|mts|cts|mjs|cjs|py)$/;
|
|
407
470
|
function toPosix(p) {
|
|
408
|
-
return p.split(
|
|
471
|
+
return p.split(path.sep).join("/");
|
|
409
472
|
}
|
|
410
473
|
function repoRelative(repoRoot, absPath) {
|
|
411
|
-
const rel =
|
|
474
|
+
const rel = path.relative(repoRoot, absPath);
|
|
412
475
|
return toPosix(rel);
|
|
413
476
|
}
|
|
414
477
|
function fileId(repoRoot, absPath) {
|
|
@@ -427,8 +490,8 @@ function packageId(name) {
|
|
|
427
490
|
return name;
|
|
428
491
|
}
|
|
429
492
|
var SYMBOL_ID_SEP = "#";
|
|
430
|
-
function symbolId(fileId2,
|
|
431
|
-
return `${fileId2}${SYMBOL_ID_SEP}${
|
|
493
|
+
function symbolId(fileId2, qualifiedName) {
|
|
494
|
+
return `${fileId2}${SYMBOL_ID_SEP}${qualifiedName}`;
|
|
432
495
|
}
|
|
433
496
|
function parseSymbolId(id) {
|
|
434
497
|
const idx = id.indexOf(SYMBOL_ID_SEP);
|
|
@@ -457,8 +520,8 @@ function buildFileModuleNodes(repoRoot, absPath) {
|
|
|
457
520
|
const parentId = parentModuleId(mId) ?? void 0;
|
|
458
521
|
const language = getLanguageFromPath(absPath) ?? "typescript";
|
|
459
522
|
return [
|
|
460
|
-
{ id: fId, kind: "file", name:
|
|
461
|
-
{ id: mId, kind: "module", name:
|
|
523
|
+
{ id: fId, kind: "file", name: path2.basename(fId), parentId: mId, language },
|
|
524
|
+
{ id: mId, kind: "module", name: path2.basename(mId), parentId, language }
|
|
462
525
|
];
|
|
463
526
|
}
|
|
464
527
|
|
|
@@ -534,6 +597,65 @@ function isPropertyNamePosition(id) {
|
|
|
534
597
|
return false;
|
|
535
598
|
}
|
|
536
599
|
|
|
600
|
+
// src/scope-path.ts
|
|
601
|
+
var TS_NAMED_SCOPES = /* @__PURE__ */ new Set([
|
|
602
|
+
"class_declaration",
|
|
603
|
+
"abstract_class_declaration",
|
|
604
|
+
"class",
|
|
605
|
+
"function_declaration",
|
|
606
|
+
"generator_function_declaration",
|
|
607
|
+
"method_definition",
|
|
608
|
+
"internal_module"
|
|
609
|
+
]);
|
|
610
|
+
var TS_BOUND_SCOPES = /* @__PURE__ */ new Set([
|
|
611
|
+
"class",
|
|
612
|
+
"arrow_function",
|
|
613
|
+
"function_expression",
|
|
614
|
+
"generator_function",
|
|
615
|
+
"object"
|
|
616
|
+
]);
|
|
617
|
+
var PY_NAMED_SCOPES = /* @__PURE__ */ new Set(["function_definition", "class_definition"]);
|
|
618
|
+
var ANONYMOUS_SCOPE = "<anonymous>";
|
|
619
|
+
function qualify(scope, name) {
|
|
620
|
+
return scope ? `${scope}.${name}` : name;
|
|
621
|
+
}
|
|
622
|
+
function bindingName(node) {
|
|
623
|
+
const parent = node.parent;
|
|
624
|
+
if (!parent) return null;
|
|
625
|
+
if (parent.type === "variable_declarator" || parent.type === "public_field_definition") {
|
|
626
|
+
return parent.childForFieldName("name")?.text ?? null;
|
|
627
|
+
}
|
|
628
|
+
if (parent.type === "pair") return parent.childForFieldName("key")?.text ?? null;
|
|
629
|
+
if (parent.type === "export_statement") return "default";
|
|
630
|
+
return null;
|
|
631
|
+
}
|
|
632
|
+
function scopeSegment(node, python) {
|
|
633
|
+
if (python) {
|
|
634
|
+
return PY_NAMED_SCOPES.has(node.type) ? node.childForFieldName("name")?.text ?? null : null;
|
|
635
|
+
}
|
|
636
|
+
const own = TS_NAMED_SCOPES.has(node.type) ? node.childForFieldName("name")?.text : void 0;
|
|
637
|
+
if (own) return own;
|
|
638
|
+
if (!TS_BOUND_SCOPES.has(node.type)) return null;
|
|
639
|
+
return bindingName(node) ?? ANONYMOUS_SCOPE;
|
|
640
|
+
}
|
|
641
|
+
function enter(scope, segment) {
|
|
642
|
+
if (!segment) return scope;
|
|
643
|
+
if (segment === ANONYMOUS_SCOPE && (scope === ANONYMOUS_SCOPE || scope.endsWith(`.${ANONYMOUS_SCOPE}`))) {
|
|
644
|
+
return scope;
|
|
645
|
+
}
|
|
646
|
+
return qualify(scope, segment);
|
|
647
|
+
}
|
|
648
|
+
function walkScopes(root, python, visit) {
|
|
649
|
+
const step2 = (node, scope) => {
|
|
650
|
+
visit(node, scope);
|
|
651
|
+
const inner = enter(scope, scopeSegment(node, python));
|
|
652
|
+
for (const child of node.children) {
|
|
653
|
+
if (child) step2(child, inner);
|
|
654
|
+
}
|
|
655
|
+
};
|
|
656
|
+
step2(root, "");
|
|
657
|
+
}
|
|
658
|
+
|
|
537
659
|
// src/declared-names.ts
|
|
538
660
|
var TS_DECL_TYPES = /* @__PURE__ */ new Set([
|
|
539
661
|
"function_declaration",
|
|
@@ -541,22 +663,24 @@ var TS_DECL_TYPES = /* @__PURE__ */ new Set([
|
|
|
541
663
|
"class_declaration"
|
|
542
664
|
]);
|
|
543
665
|
var PY_DECL_TYPES = /* @__PURE__ */ new Set(["function_definition", "class_definition"]);
|
|
666
|
+
function collectDeclarations(file) {
|
|
667
|
+
const python = file.language === "python";
|
|
668
|
+
const declTypes = python ? PY_DECL_TYPES : TS_DECL_TYPES;
|
|
669
|
+
const out = [];
|
|
670
|
+
walkScopes(file.tree.rootNode, python, (node, scope) => {
|
|
671
|
+
const named = declaredNodeAt(node, declTypes);
|
|
672
|
+
if (!named) return;
|
|
673
|
+
out.push({
|
|
674
|
+
name: named.name,
|
|
675
|
+
qualifiedName: qualify(scope, named.name),
|
|
676
|
+
span: { startLine: named.node.startPosition.row + 1, endLine: named.node.endPosition.row + 1 }
|
|
677
|
+
});
|
|
678
|
+
});
|
|
679
|
+
return out;
|
|
680
|
+
}
|
|
544
681
|
function collectDeclaredSpans(file) {
|
|
545
|
-
const declTypes = file.language === "python" ? PY_DECL_TYPES : TS_DECL_TYPES;
|
|
546
682
|
const spans = /* @__PURE__ */ new Map();
|
|
547
|
-
const
|
|
548
|
-
const named = declaredNodeAt(node, declTypes);
|
|
549
|
-
if (named) {
|
|
550
|
-
spans.set(named.name, {
|
|
551
|
-
startLine: named.node.startPosition.row + 1,
|
|
552
|
-
endLine: named.node.endPosition.row + 1
|
|
553
|
-
});
|
|
554
|
-
}
|
|
555
|
-
for (const child of node.children) {
|
|
556
|
-
if (child) visit(child);
|
|
557
|
-
}
|
|
558
|
-
};
|
|
559
|
-
visit(file.tree.rootNode);
|
|
683
|
+
for (const d of collectDeclarations(file)) spans.set(d.qualifiedName, d.span);
|
|
560
684
|
return spans;
|
|
561
685
|
}
|
|
562
686
|
function collectDeclaredNames(file) {
|
|
@@ -754,7 +878,7 @@ function recordDynamicSymbolRefEdges(sourceFile, srcFileId, agg, resolveSpecifie
|
|
|
754
878
|
}
|
|
755
879
|
|
|
756
880
|
// src/extractors/module-resolution.ts
|
|
757
|
-
import * as
|
|
881
|
+
import * as path3 from "path";
|
|
758
882
|
var RESOLVABLE_EXTS = [
|
|
759
883
|
".ts",
|
|
760
884
|
".tsx",
|
|
@@ -776,20 +900,20 @@ function* relativeResolutionCandidates(base) {
|
|
|
776
900
|
const stem = base.slice(0, base.length - jsExt[0].length);
|
|
777
901
|
for (const ext of RESOLVABLE_EXTS) yield stem + ext;
|
|
778
902
|
}
|
|
779
|
-
for (const ext of RESOLVABLE_EXTS) yield
|
|
903
|
+
for (const ext of RESOLVABLE_EXTS) yield path3.join(base, "index" + ext);
|
|
780
904
|
}
|
|
781
905
|
function resolveRelativeAbs(fs3, fromAbs, specifier) {
|
|
782
906
|
if (!isRelativeSpecifier(specifier)) return null;
|
|
783
|
-
const base =
|
|
907
|
+
const base = path3.resolve(path3.dirname(fromAbs), specifier);
|
|
784
908
|
for (const candidate of relativeResolutionCandidates(base)) {
|
|
785
909
|
if (fs3.fileExistsSync(candidate)) return candidate;
|
|
786
910
|
}
|
|
787
911
|
return null;
|
|
788
912
|
}
|
|
789
913
|
function inRepoFileId(repoRoot, abs) {
|
|
790
|
-
const relative4 =
|
|
791
|
-
if (relative4.startsWith("..") ||
|
|
792
|
-
if (relative4.split(
|
|
914
|
+
const relative4 = path3.relative(repoRoot, abs);
|
|
915
|
+
if (relative4.startsWith("..") || path3.isAbsolute(relative4)) return null;
|
|
916
|
+
if (relative4.split(path3.sep).includes("node_modules")) return null;
|
|
793
917
|
return fileId(repoRoot, abs);
|
|
794
918
|
}
|
|
795
919
|
|
|
@@ -1028,7 +1152,7 @@ function remapDistToSrc(abs) {
|
|
|
1028
1152
|
const base = m[1];
|
|
1029
1153
|
const sub = m[2];
|
|
1030
1154
|
for (const ext of [".ts", ".tsx"]) {
|
|
1031
|
-
const candidate =
|
|
1155
|
+
const candidate = path4.join(base, "src", sub + ext);
|
|
1032
1156
|
if (existsSync(candidate)) return candidate;
|
|
1033
1157
|
}
|
|
1034
1158
|
return abs;
|
|
@@ -1038,11 +1162,11 @@ function isTypeScriptFile(file) {
|
|
|
1038
1162
|
}
|
|
1039
1163
|
|
|
1040
1164
|
// src/extractors/python-extractor.ts
|
|
1041
|
-
import * as
|
|
1165
|
+
import * as path5 from "path";
|
|
1042
1166
|
import { existsSync as existsSync2 } from "fs";
|
|
1043
1167
|
function* candidates(base) {
|
|
1044
1168
|
yield `${base}.py`;
|
|
1045
|
-
yield
|
|
1169
|
+
yield path5.join(base, "__init__.py");
|
|
1046
1170
|
}
|
|
1047
1171
|
var PythonGraphExtractor = class {
|
|
1048
1172
|
constructor(repoRoot) {
|
|
@@ -1079,10 +1203,10 @@ var PythonGraphExtractor = class {
|
|
|
1079
1203
|
resolveDotted(fromAbs, specifier) {
|
|
1080
1204
|
const leadingDots = /^\.*/.exec(specifier)?.[0].length ?? 0;
|
|
1081
1205
|
const parts = specifier.slice(leadingDots).split(".").filter(Boolean);
|
|
1082
|
-
const relativeBase =
|
|
1083
|
-
const roots = leadingDots > 0 ? [relativeBase] : [
|
|
1206
|
+
const relativeBase = path5.resolve(path5.dirname(fromAbs), "../".repeat(Math.max(leadingDots - 1, 0)));
|
|
1207
|
+
const roots = leadingDots > 0 ? [relativeBase] : [path5.dirname(fromAbs), this.repoRoot];
|
|
1084
1208
|
for (const root of roots) {
|
|
1085
|
-
for (const candidate of candidates(
|
|
1209
|
+
for (const candidate of candidates(path5.join(root, ...parts))) {
|
|
1086
1210
|
const id = inRepoFileId(this.repoRoot, candidate);
|
|
1087
1211
|
if (id !== null && existsSync2(candidate)) return id;
|
|
1088
1212
|
}
|
|
@@ -1090,6 +1214,9 @@ var PythonGraphExtractor = class {
|
|
|
1090
1214
|
return null;
|
|
1091
1215
|
}
|
|
1092
1216
|
};
|
|
1217
|
+
function isPublicName(qualifiedName) {
|
|
1218
|
+
return !qualifiedName.slice(qualifiedName.lastIndexOf(".") + 1).startsWith("_");
|
|
1219
|
+
}
|
|
1093
1220
|
function symbolNodes(fId, file) {
|
|
1094
1221
|
return [...collectDeclaredSpans(file)].map(([name, span]) => ({
|
|
1095
1222
|
id: symbolId(fId, name),
|
|
@@ -1097,7 +1224,7 @@ function symbolNodes(fId, file) {
|
|
|
1097
1224
|
name,
|
|
1098
1225
|
parentId: fId,
|
|
1099
1226
|
language: "python",
|
|
1100
|
-
attrs: { exported:
|
|
1227
|
+
attrs: { exported: isPublicName(name), startLine: span.startLine, endLine: span.endLine }
|
|
1101
1228
|
}));
|
|
1102
1229
|
}
|
|
1103
1230
|
function importSpecifiers(root) {
|
|
@@ -1130,16 +1257,15 @@ var LanguageExtractor = class {
|
|
|
1130
1257
|
};
|
|
1131
1258
|
|
|
1132
1259
|
// src/git-renames.ts
|
|
1133
|
-
import
|
|
1134
|
-
import * as path7 from "path";
|
|
1260
|
+
import * as path6 from "path";
|
|
1135
1261
|
function detectGitHead(repoRoot) {
|
|
1136
1262
|
return runGit(repoRoot, ["rev-parse", "HEAD"]);
|
|
1137
1263
|
}
|
|
1138
1264
|
function isInsideGitRepo(repoRoot) {
|
|
1139
1265
|
return runGit(repoRoot, ["rev-parse", "--is-inside-work-tree"]) === "true";
|
|
1140
1266
|
}
|
|
1141
|
-
function
|
|
1142
|
-
return runGit(
|
|
1267
|
+
function resolveGitRef(repoRoot, ref) {
|
|
1268
|
+
return runGit(repoRoot, ["rev-parse", ref]);
|
|
1143
1269
|
}
|
|
1144
1270
|
function detectRenames(options) {
|
|
1145
1271
|
const threshold = options.similarityThreshold ?? 80;
|
|
@@ -1172,12 +1298,12 @@ function parseRenameOutput(text) {
|
|
|
1172
1298
|
return out;
|
|
1173
1299
|
}
|
|
1174
1300
|
function classifyRename(oldPath, newPath) {
|
|
1175
|
-
return
|
|
1301
|
+
return path6.posix.dirname(oldPath) === path6.posix.dirname(newPath) ? "rename" : "move";
|
|
1176
1302
|
}
|
|
1177
1303
|
function renameToAliases(repoRoot, pair) {
|
|
1178
1304
|
const reason = classifyRename(pair.oldPath, pair.newPath);
|
|
1179
|
-
const oldAbs =
|
|
1180
|
-
const newAbs =
|
|
1305
|
+
const oldAbs = path6.join(repoRoot, pair.oldPath);
|
|
1306
|
+
const newAbs = path6.join(repoRoot, pair.newPath);
|
|
1181
1307
|
return [
|
|
1182
1308
|
{ oldId: fileId(repoRoot, oldAbs), newId: fileId(repoRoot, newAbs), reason },
|
|
1183
1309
|
{ oldId: moduleId(repoRoot, oldAbs), newId: moduleId(repoRoot, newAbs), reason }
|
|
@@ -1195,29 +1321,164 @@ function buildAliases(repoRoot, pairs) {
|
|
|
1195
1321
|
}
|
|
1196
1322
|
return out;
|
|
1197
1323
|
}
|
|
1198
|
-
|
|
1199
|
-
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
return
|
|
1324
|
+
|
|
1325
|
+
// src/identity/lineage.ts
|
|
1326
|
+
var ALIAS_BASE_ATTR = "aliasBase";
|
|
1327
|
+
var DEFAULT_MAX_HOPS = 1e4;
|
|
1328
|
+
function recordedBase(snap) {
|
|
1329
|
+
if (!(ALIAS_BASE_ATTR in snap.attrs)) return void 0;
|
|
1330
|
+
const base = snap.attrs[ALIAS_BASE_ATTR];
|
|
1331
|
+
return typeof base === "number" && base < snap.id ? base : null;
|
|
1332
|
+
}
|
|
1333
|
+
function buildLineage(snapshots) {
|
|
1334
|
+
const out = /* @__PURE__ */ new Map();
|
|
1335
|
+
let lastWithCommit = null;
|
|
1336
|
+
for (const snap of [...snapshots].sort((a, b) => a.id - b.id)) {
|
|
1337
|
+
const recorded = recordedBase(snap);
|
|
1338
|
+
out.set(snap.id, recorded === void 0 ? lastWithCommit : recorded);
|
|
1339
|
+
if (snap.commitHash) lastWithCommit = snap.id;
|
|
1340
|
+
}
|
|
1341
|
+
return out;
|
|
1204
1342
|
}
|
|
1205
|
-
function
|
|
1206
|
-
|
|
1207
|
-
|
|
1208
|
-
|
|
1209
|
-
|
|
1210
|
-
|
|
1211
|
-
|
|
1212
|
-
|
|
1213
|
-
|
|
1214
|
-
|
|
1343
|
+
function ancestry(lineage, snapshotId, maxHops = DEFAULT_MAX_HOPS) {
|
|
1344
|
+
const out = [];
|
|
1345
|
+
let current = snapshotId;
|
|
1346
|
+
while (current !== null && current !== void 0 && out.length <= maxHops) {
|
|
1347
|
+
out.push(current);
|
|
1348
|
+
current = lineage.get(current);
|
|
1349
|
+
}
|
|
1350
|
+
return out;
|
|
1351
|
+
}
|
|
1352
|
+
function lineagePath(lineage, fromId, toId, maxHops = DEFAULT_MAX_HOPS) {
|
|
1353
|
+
const up = ancestry(lineage, fromId, maxHops);
|
|
1354
|
+
const down = ancestry(lineage, toId, maxHops);
|
|
1355
|
+
const upIndex = new Map(up.map((id, i) => [id, i]));
|
|
1356
|
+
const meet = down.findIndex((id) => upIndex.has(id));
|
|
1357
|
+
if (meet < 0) return null;
|
|
1358
|
+
const backward = up.slice(0, upIndex.get(down[meet])).map((id) => step(id, "backward"));
|
|
1359
|
+
const forward = down.slice(0, meet).reverse().map((id) => step(id, "forward"));
|
|
1360
|
+
return [...backward, ...forward];
|
|
1361
|
+
}
|
|
1362
|
+
function rootPath(lineage, toId, maxHops = DEFAULT_MAX_HOPS) {
|
|
1363
|
+
return ancestry(lineage, toId, maxHops).reverse().map((id) => step(id, "forward"));
|
|
1364
|
+
}
|
|
1365
|
+
function step(snapshotId, direction) {
|
|
1366
|
+
return { snapshotId, direction };
|
|
1367
|
+
}
|
|
1368
|
+
|
|
1369
|
+
// src/identity/alias-chain.ts
|
|
1370
|
+
function stepMap(aliases, direction) {
|
|
1371
|
+
const byId = /* @__PURE__ */ new Map();
|
|
1372
|
+
const sorted = [...aliases].sort((a, b) => compare(a.oldId, b.oldId) || compare(a.newId, b.newId));
|
|
1373
|
+
for (const alias of sorted) {
|
|
1374
|
+
const key = direction === "forward" ? alias.oldId : alias.newId;
|
|
1375
|
+
if (!byId.has(key)) byId.set(key, alias);
|
|
1376
|
+
}
|
|
1377
|
+
return { direction, byId };
|
|
1378
|
+
}
|
|
1379
|
+
function compare(a, b) {
|
|
1380
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
1381
|
+
}
|
|
1382
|
+
function overallReason(hops) {
|
|
1383
|
+
if (hops.length === 0) return void 0;
|
|
1384
|
+
return hops.some((h) => h.reason === "move") ? "move" : hops[hops.length - 1].reason;
|
|
1385
|
+
}
|
|
1386
|
+
function walk(requestedId, maps) {
|
|
1387
|
+
let id = requestedId;
|
|
1388
|
+
const hops = [];
|
|
1389
|
+
for (const { direction, byId } of maps) {
|
|
1390
|
+
const alias = byId.get(id);
|
|
1391
|
+
if (!alias) continue;
|
|
1392
|
+
hops.push(alias);
|
|
1393
|
+
id = direction === "forward" ? alias.newId : alias.oldId;
|
|
1394
|
+
}
|
|
1395
|
+
return { requestedId, id, hops, reason: overallReason(hops) };
|
|
1396
|
+
}
|
|
1397
|
+
function chainSteps(input) {
|
|
1398
|
+
if (input.from === null) return { steps: rootPath(input.lineage, input.to, input.maxHops), connected: true };
|
|
1399
|
+
const path11 = lineagePath(input.lineage, input.from, input.to, input.maxHops);
|
|
1400
|
+
if (path11) return { steps: path11, connected: true };
|
|
1401
|
+
return { steps: [{ snapshotId: input.to, direction: "forward" }], connected: false };
|
|
1402
|
+
}
|
|
1403
|
+
function createAliasChain(input) {
|
|
1404
|
+
const { steps, connected } = chainSteps(input);
|
|
1405
|
+
const maps = steps.map((s) => stepMap(input.loadAliases(s.snapshotId), s.direction));
|
|
1406
|
+
const cache = /* @__PURE__ */ new Map();
|
|
1407
|
+
const trace = (id) => {
|
|
1408
|
+
let hit = cache.get(id);
|
|
1409
|
+
if (!hit) cache.set(id, hit = walk(id, maps));
|
|
1410
|
+
return hit;
|
|
1411
|
+
};
|
|
1412
|
+
return { fromSnapshotId: input.from, toSnapshotId: input.to, connected, steps, resolve: (id) => trace(id).id, trace };
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
// src/identity/store-identity.ts
|
|
1416
|
+
function allSnapshots(store) {
|
|
1417
|
+
return store.db.prepare("SELECT * FROM snapshot ORDER BY id").all().map(rowToSnapshot);
|
|
1418
|
+
}
|
|
1419
|
+
function loadLineage(store) {
|
|
1420
|
+
return buildLineage(allSnapshots(store));
|
|
1421
|
+
}
|
|
1422
|
+
function aliasChain(store, fromSnapshotId, toSnapshotId, options = {}) {
|
|
1423
|
+
return createAliasChain({
|
|
1424
|
+
lineage: loadLineage(store),
|
|
1425
|
+
loadAliases: (id) => store.listAliases(id),
|
|
1426
|
+
from: fromSnapshotId,
|
|
1427
|
+
to: toSnapshotId,
|
|
1428
|
+
maxHops: options.maxHops
|
|
1429
|
+
});
|
|
1430
|
+
}
|
|
1431
|
+
function resolveAlias(store, id, toSnapshotId, options = {}) {
|
|
1432
|
+
return aliasChain(store, options.fromSnapshotId ?? null, toSnapshotId, options).trace(id);
|
|
1433
|
+
}
|
|
1434
|
+
function priorSnapshotForRef(store, ref, options = {}) {
|
|
1435
|
+
const before = options.before ?? Number.POSITIVE_INFINITY;
|
|
1436
|
+
const candidates2 = allSnapshots(store).filter((s) => s.id < before).reverse();
|
|
1437
|
+
const commit = options.repoRoot ? resolveGitRef(options.repoRoot, `${ref}^{commit}`) : null;
|
|
1438
|
+
const byCommit = commit ? candidates2.find((s) => s.commitHash === commit) : void 0;
|
|
1439
|
+
return byCommit ?? candidates2.find((s) => s.ref === ref) ?? null;
|
|
1440
|
+
}
|
|
1441
|
+
function aliasBaseFor(store, ref) {
|
|
1442
|
+
const committed = allSnapshots(store).filter((s) => s.commitHash).reverse();
|
|
1443
|
+
return committed.find((s) => s.ref === ref) ?? committed[0] ?? null;
|
|
1444
|
+
}
|
|
1445
|
+
|
|
1446
|
+
// src/identity/index-aliases.ts
|
|
1447
|
+
var SYMBOLS_OF_FILE = `SELECT id FROM node
|
|
1448
|
+
WHERE snapshot_id = ? AND id >= ? AND id < ? AND kind = 'symbol'`;
|
|
1449
|
+
function symbolIdsOf(store, snapshotId, fileId2) {
|
|
1450
|
+
const rows = store.db.prepare(SYMBOLS_OF_FILE).all(snapshotId, `${fileId2}#`, `${fileId2}$`);
|
|
1451
|
+
return rows.map((r) => r.id);
|
|
1452
|
+
}
|
|
1453
|
+
function movedSymbolAliases(store, baseSnapshotId, fileAliases, nodes) {
|
|
1454
|
+
const out = [];
|
|
1455
|
+
for (const alias of fileAliases) {
|
|
1456
|
+
if (nodes.get(alias.newId)?.kind !== "file") continue;
|
|
1457
|
+
for (const oldId of symbolIdsOf(store, baseSnapshotId, alias.oldId)) {
|
|
1458
|
+
const newId = alias.newId + SYMBOL_ID_SEP + oldId.slice(alias.oldId.length + 1);
|
|
1459
|
+
if (nodes.get(newId)?.kind === "symbol") out.push({ oldId, newId, reason: alias.reason });
|
|
1460
|
+
}
|
|
1215
1461
|
}
|
|
1462
|
+
return out;
|
|
1463
|
+
}
|
|
1464
|
+
function renameAliases(input, baseCommit) {
|
|
1465
|
+
if (input.detectRenames === false || !isInsideGitRepo(input.rootDir)) return [];
|
|
1466
|
+
const target = input.commitHash ?? detectGitHead(input.rootDir) ?? void 0;
|
|
1467
|
+
if (target === baseCommit) return [];
|
|
1468
|
+
const pairs = detectRenames({ repoRoot: input.rootDir, fromCommit: baseCommit, toCommit: target });
|
|
1469
|
+
return buildAliases(input.idRoot, pairs);
|
|
1470
|
+
}
|
|
1471
|
+
function computeAliasBridge(store, input) {
|
|
1472
|
+
const base = aliasBaseFor(store, input.ref);
|
|
1473
|
+
if (!base?.commitHash) return { baseSnapshotId: null, aliases: [] };
|
|
1474
|
+
const fileAliases = renameAliases(input, base.commitHash);
|
|
1475
|
+
const symbols = movedSymbolAliases(store, base.id, fileAliases, input.nodes);
|
|
1476
|
+
return { baseSnapshotId: base.id, aliases: [...fileAliases, ...symbols] };
|
|
1216
1477
|
}
|
|
1217
1478
|
|
|
1218
1479
|
// src/generated.ts
|
|
1219
1480
|
import { readFileSync } from "fs";
|
|
1220
|
-
import * as
|
|
1481
|
+
import * as path7 from "path";
|
|
1221
1482
|
var HEURISTIC_FILE_RE = /(?:^|\/)[^/]*\.(?:gen|generated)\.[^/]*$/;
|
|
1222
1483
|
var HEURISTIC_DIR_RE = /(?:^|\/)generated\//;
|
|
1223
1484
|
function isGeneratedByHeuristic(id) {
|
|
@@ -1229,7 +1490,7 @@ function isGeneratedFile(id, patterns) {
|
|
|
1229
1490
|
}
|
|
1230
1491
|
function loadGeneratedPatterns(rootDir) {
|
|
1231
1492
|
try {
|
|
1232
|
-
const content = readFileSync(
|
|
1493
|
+
const content = readFileSync(path7.join(rootDir, ".gitattributes"), "utf-8");
|
|
1233
1494
|
return parseGeneratedPatterns(content);
|
|
1234
1495
|
} catch {
|
|
1235
1496
|
return [];
|
|
@@ -1332,25 +1593,26 @@ function annotateRoles(nodes, options) {
|
|
|
1332
1593
|
|
|
1333
1594
|
// src/file-walk.ts
|
|
1334
1595
|
import * as fs from "fs/promises";
|
|
1335
|
-
import * as
|
|
1336
|
-
|
|
1596
|
+
import * as path8 from "path";
|
|
1597
|
+
import { isExcludedDir, shouldIncludeFile } from "@titan-design/code-parser";
|
|
1598
|
+
async function walkSourceFiles(rootDirs, languages) {
|
|
1337
1599
|
const seen = /* @__PURE__ */ new Set();
|
|
1338
1600
|
const out = [];
|
|
1339
1601
|
for (const rootDir of rootDirs) {
|
|
1340
|
-
await collectFiles(rootDir, rootDir,
|
|
1602
|
+
await collectFiles(rootDir, rootDir, languages, seen, out);
|
|
1341
1603
|
}
|
|
1342
1604
|
return out;
|
|
1343
1605
|
}
|
|
1344
|
-
async function collectFiles(dir, rootDir,
|
|
1606
|
+
async function collectFiles(dir, rootDir, languages, seen, out) {
|
|
1345
1607
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
1346
1608
|
for (const entry of entries) {
|
|
1347
|
-
const full =
|
|
1609
|
+
const full = path8.join(dir, entry.name);
|
|
1348
1610
|
if (entry.isDirectory()) {
|
|
1349
1611
|
if (isExcludedDir(entry.name)) continue;
|
|
1350
|
-
await collectFiles(full, rootDir,
|
|
1612
|
+
await collectFiles(full, rootDir, languages, seen, out);
|
|
1351
1613
|
} else if (entry.isFile() && !seen.has(full)) {
|
|
1352
|
-
const relative4 =
|
|
1353
|
-
if (shouldIncludeFile(relative4, [...
|
|
1614
|
+
const relative4 = path8.relative(rootDir, full);
|
|
1615
|
+
if (shouldIncludeFile(relative4, [...languages])) {
|
|
1354
1616
|
seen.add(full);
|
|
1355
1617
|
out.push(full);
|
|
1356
1618
|
}
|
|
@@ -1421,6 +1683,7 @@ function resolveBarrelEdges(nodes, edges) {
|
|
|
1421
1683
|
// src/incremental.ts
|
|
1422
1684
|
import { createHash } from "crypto";
|
|
1423
1685
|
import * as fs2 from "fs/promises";
|
|
1686
|
+
import { getLanguageFromPath as getLanguageFromPath2 } from "@titan-design/code-parser";
|
|
1424
1687
|
|
|
1425
1688
|
// src/cognitive-complexity.ts
|
|
1426
1689
|
function cognitiveComplexityOf(body, language) {
|
|
@@ -1812,21 +2075,16 @@ function countLoc(content) {
|
|
|
1812
2075
|
function analyzeFunctions(file) {
|
|
1813
2076
|
const stats = [];
|
|
1814
2077
|
const fnTypes = file.language === "python" ? PY_FUNCTION_TYPES : TS_FUNCTION_TYPES;
|
|
1815
|
-
|
|
2078
|
+
walkScopes(file.tree.rootNode, file.language === "python", (node, scope) => {
|
|
1816
2079
|
const fn = functionAt(node, fnTypes);
|
|
1817
|
-
if (fn)
|
|
1818
|
-
|
|
1819
|
-
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
|
|
1823
|
-
|
|
1824
|
-
|
|
1825
|
-
for (const child of node.children) {
|
|
1826
|
-
if (child) visit(child);
|
|
1827
|
-
}
|
|
1828
|
-
};
|
|
1829
|
-
visit(file.tree.rootNode);
|
|
2080
|
+
if (!fn) return;
|
|
2081
|
+
stats.push({
|
|
2082
|
+
name: fn.name === null ? null : qualify(scope, fn.name),
|
|
2083
|
+
cyclomatic: cyclomaticOf(fn.body, file.language),
|
|
2084
|
+
cognitive: cognitiveComplexityOf(fn.body, file.language),
|
|
2085
|
+
nestingDepth: nestingDepthOf(fn.body, file.language, 0)
|
|
2086
|
+
});
|
|
2087
|
+
});
|
|
1830
2088
|
return stats;
|
|
1831
2089
|
}
|
|
1832
2090
|
function functionAt(node, fnTypes) {
|
|
@@ -1872,6 +2130,259 @@ function cyclomaticOf(body, language) {
|
|
|
1872
2130
|
return complexity;
|
|
1873
2131
|
}
|
|
1874
2132
|
|
|
2133
|
+
// src/analysis/dead-code.ts
|
|
2134
|
+
var DEAD_CODE_METRIC_NAMES = /* @__PURE__ */ new Set([
|
|
2135
|
+
"unreachable_statements",
|
|
2136
|
+
"unused_locals",
|
|
2137
|
+
"unused_params"
|
|
2138
|
+
]);
|
|
2139
|
+
var FUNCTION_TYPES = /* @__PURE__ */ new Set([
|
|
2140
|
+
"function_declaration",
|
|
2141
|
+
"function_expression",
|
|
2142
|
+
"arrow_function",
|
|
2143
|
+
"method_definition",
|
|
2144
|
+
"generator_function_declaration",
|
|
2145
|
+
"generator_function"
|
|
2146
|
+
]);
|
|
2147
|
+
var PARAM_TYPES = /* @__PURE__ */ new Set(["required_parameter", "optional_parameter"]);
|
|
2148
|
+
var TERMINALS = /* @__PURE__ */ new Set([
|
|
2149
|
+
"return_statement",
|
|
2150
|
+
"throw_statement",
|
|
2151
|
+
"break_statement",
|
|
2152
|
+
"continue_statement"
|
|
2153
|
+
]);
|
|
2154
|
+
function computeDeadCodeMetrics(files, fileIdOf) {
|
|
2155
|
+
const out = [];
|
|
2156
|
+
for (const file of files) {
|
|
2157
|
+
if (file.language !== "typescript" && file.language !== "tsx") continue;
|
|
2158
|
+
const id = fileIdOf(file.filePath);
|
|
2159
|
+
const unreachable = countUnreachable(file.tree.rootNode);
|
|
2160
|
+
if (unreachable > 0) {
|
|
2161
|
+
out.push({ nodeId: id, name: "unreachable_statements", value: unreachable, unit: "count" });
|
|
2162
|
+
}
|
|
2163
|
+
const { locals, params } = countUnusedBindings(file.tree.rootNode);
|
|
2164
|
+
if (locals > 0) {
|
|
2165
|
+
out.push({ nodeId: id, name: "unused_locals", value: locals, unit: "count" });
|
|
2166
|
+
}
|
|
2167
|
+
if (params > 0) {
|
|
2168
|
+
out.push({ nodeId: id, name: "unused_params", value: params, unit: "count" });
|
|
2169
|
+
}
|
|
2170
|
+
}
|
|
2171
|
+
return out;
|
|
2172
|
+
}
|
|
2173
|
+
function countUnreachable(root) {
|
|
2174
|
+
let count = 0;
|
|
2175
|
+
const visit = (node) => {
|
|
2176
|
+
if (node.type === "statement_block") count += unreachableInBlock(node);
|
|
2177
|
+
for (const child of node.namedChildren) {
|
|
2178
|
+
if (child) visit(child);
|
|
2179
|
+
}
|
|
2180
|
+
};
|
|
2181
|
+
visit(root);
|
|
2182
|
+
return count;
|
|
2183
|
+
}
|
|
2184
|
+
function unreachableInBlock(block) {
|
|
2185
|
+
let seenTerminal = false;
|
|
2186
|
+
let count = 0;
|
|
2187
|
+
for (const child of block.namedChildren) {
|
|
2188
|
+
if (!child) continue;
|
|
2189
|
+
if (seenTerminal && child.type !== "comment" && child.type !== "function_declaration") {
|
|
2190
|
+
count++;
|
|
2191
|
+
}
|
|
2192
|
+
if (TERMINALS.has(child.type)) seenTerminal = true;
|
|
2193
|
+
}
|
|
2194
|
+
return count;
|
|
2195
|
+
}
|
|
2196
|
+
function countUnusedBindings(root) {
|
|
2197
|
+
let locals = 0;
|
|
2198
|
+
let params = 0;
|
|
2199
|
+
const visit = (node) => {
|
|
2200
|
+
if (FUNCTION_TYPES.has(node.type)) {
|
|
2201
|
+
const r = analyzeFunction(node);
|
|
2202
|
+
locals += r.locals;
|
|
2203
|
+
params += r.params;
|
|
2204
|
+
}
|
|
2205
|
+
for (const child of node.namedChildren) {
|
|
2206
|
+
if (child) visit(child);
|
|
2207
|
+
}
|
|
2208
|
+
};
|
|
2209
|
+
visit(root);
|
|
2210
|
+
return { locals, params };
|
|
2211
|
+
}
|
|
2212
|
+
function analyzeFunction(fn) {
|
|
2213
|
+
const declIds = /* @__PURE__ */ new Set();
|
|
2214
|
+
const nameCount = /* @__PURE__ */ new Map();
|
|
2215
|
+
collectBindingDecls(fn, declIds, nameCount);
|
|
2216
|
+
const refs = /* @__PURE__ */ new Map();
|
|
2217
|
+
countReferences(fn, declIds, refs);
|
|
2218
|
+
const isUnused = (name) => !name.startsWith("_") && (nameCount.get(name) ?? 0) <= 1 && (refs.get(name) ?? 0) === 0;
|
|
2219
|
+
const paramNames = ownParamNames(fn);
|
|
2220
|
+
let params = 0;
|
|
2221
|
+
for (let i = paramNames.length - 1; i >= 0; i--) {
|
|
2222
|
+
const name = paramNames[i] ?? null;
|
|
2223
|
+
if (name !== null && isUnused(name)) params++;
|
|
2224
|
+
else break;
|
|
2225
|
+
}
|
|
2226
|
+
let locals = 0;
|
|
2227
|
+
for (const name of ownLocalNames(fn)) {
|
|
2228
|
+
if (isUnused(name)) locals++;
|
|
2229
|
+
}
|
|
2230
|
+
return { locals, params };
|
|
2231
|
+
}
|
|
2232
|
+
function ownParamNames(fn) {
|
|
2233
|
+
const params = fn.childForFieldName("parameters");
|
|
2234
|
+
if (!params) return [];
|
|
2235
|
+
const out = [];
|
|
2236
|
+
for (const p of params.namedChildren) {
|
|
2237
|
+
if (!p || !PARAM_TYPES.has(p.type)) continue;
|
|
2238
|
+
const first = p.namedChild(0);
|
|
2239
|
+
out.push(first?.type === "identifier" ? first.text : null);
|
|
2240
|
+
}
|
|
2241
|
+
return out;
|
|
2242
|
+
}
|
|
2243
|
+
function ownLocalNames(fn) {
|
|
2244
|
+
const body = fn.childForFieldName("body");
|
|
2245
|
+
if (!body) return [];
|
|
2246
|
+
const out = [];
|
|
2247
|
+
const walk2 = (node) => {
|
|
2248
|
+
if (FUNCTION_TYPES.has(node.type)) return;
|
|
2249
|
+
if (node.type === "variable_declarator") {
|
|
2250
|
+
const name = node.childForFieldName("name");
|
|
2251
|
+
if (name?.type === "identifier") out.push(name.text);
|
|
2252
|
+
}
|
|
2253
|
+
for (const child of node.namedChildren) {
|
|
2254
|
+
if (child) walk2(child);
|
|
2255
|
+
}
|
|
2256
|
+
};
|
|
2257
|
+
walk2(body);
|
|
2258
|
+
return out;
|
|
2259
|
+
}
|
|
2260
|
+
function collectBindingDecls(fn, ids, nameCount) {
|
|
2261
|
+
const mark = (n) => {
|
|
2262
|
+
if (n?.type !== "identifier") return;
|
|
2263
|
+
ids.add(n.id);
|
|
2264
|
+
nameCount.set(n.text, (nameCount.get(n.text) ?? 0) + 1);
|
|
2265
|
+
};
|
|
2266
|
+
const walk2 = (node) => {
|
|
2267
|
+
if (PARAM_TYPES.has(node.type)) mark(node.namedChild(0));
|
|
2268
|
+
else if (node.type === "variable_declarator") mark(node.childForFieldName("name"));
|
|
2269
|
+
for (const child of node.namedChildren) {
|
|
2270
|
+
if (child) walk2(child);
|
|
2271
|
+
}
|
|
2272
|
+
};
|
|
2273
|
+
walk2(fn);
|
|
2274
|
+
}
|
|
2275
|
+
function countReferences(fn, declIds, out) {
|
|
2276
|
+
const walk2 = (node) => {
|
|
2277
|
+
if ((node.type === "identifier" || node.type === "shorthand_property_identifier") && !declIds.has(node.id)) {
|
|
2278
|
+
out.set(node.text, (out.get(node.text) ?? 0) + 1);
|
|
2279
|
+
}
|
|
2280
|
+
for (const child of node.namedChildren) {
|
|
2281
|
+
if (child) walk2(child);
|
|
2282
|
+
}
|
|
2283
|
+
};
|
|
2284
|
+
walk2(fn);
|
|
2285
|
+
}
|
|
2286
|
+
|
|
2287
|
+
// src/analysis/growth-risk.ts
|
|
2288
|
+
var GROWTH_RISK_METRIC_NAMES = /* @__PURE__ */ new Set([
|
|
2289
|
+
"loop_depth",
|
|
2290
|
+
"recursive_functions",
|
|
2291
|
+
"search_in_loop"
|
|
2292
|
+
]);
|
|
2293
|
+
var TS_LOOP_TYPES = /* @__PURE__ */ new Set([
|
|
2294
|
+
"for_statement",
|
|
2295
|
+
"for_in_statement",
|
|
2296
|
+
"while_statement",
|
|
2297
|
+
"do_statement"
|
|
2298
|
+
]);
|
|
2299
|
+
var PY_LOOP_TYPES = /* @__PURE__ */ new Set(["for_statement", "while_statement"]);
|
|
2300
|
+
var NAMED_FN_TYPES = /* @__PURE__ */ new Set([
|
|
2301
|
+
"function_declaration",
|
|
2302
|
+
"generator_function_declaration",
|
|
2303
|
+
"method_definition",
|
|
2304
|
+
"function_definition"
|
|
2305
|
+
]);
|
|
2306
|
+
var SEARCH_METHODS = /* @__PURE__ */ new Set([
|
|
2307
|
+
"includes",
|
|
2308
|
+
"indexOf",
|
|
2309
|
+
"lastIndexOf",
|
|
2310
|
+
"find",
|
|
2311
|
+
"findIndex",
|
|
2312
|
+
"some",
|
|
2313
|
+
"every",
|
|
2314
|
+
"filter"
|
|
2315
|
+
]);
|
|
2316
|
+
function computeGrowthRiskMetrics(files, fileIdOf) {
|
|
2317
|
+
const out = [];
|
|
2318
|
+
for (const file of files) {
|
|
2319
|
+
const loopTypes = file.language === "python" ? PY_LOOP_TYPES : TS_LOOP_TYPES;
|
|
2320
|
+
const root = file.tree.rootNode;
|
|
2321
|
+
const id = fileIdOf(file.filePath);
|
|
2322
|
+
const depth = maxLoopDepth(root, loopTypes, 0);
|
|
2323
|
+
if (depth >= 2) {
|
|
2324
|
+
out.push({ nodeId: id, name: "loop_depth", value: depth, unit: "count" });
|
|
2325
|
+
}
|
|
2326
|
+
const recursion = countDirectRecursion(root);
|
|
2327
|
+
if (recursion > 0) {
|
|
2328
|
+
out.push({ nodeId: id, name: "recursive_functions", value: recursion, unit: "count" });
|
|
2329
|
+
}
|
|
2330
|
+
const searches = countSearchInLoop(root, loopTypes, false);
|
|
2331
|
+
if (searches > 0) {
|
|
2332
|
+
out.push({ nodeId: id, name: "search_in_loop", value: searches, unit: "count" });
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
return out;
|
|
2336
|
+
}
|
|
2337
|
+
function countDirectRecursion(root) {
|
|
2338
|
+
let count = 0;
|
|
2339
|
+
const visit = (node) => {
|
|
2340
|
+
if (NAMED_FN_TYPES.has(node.type)) {
|
|
2341
|
+
const name = node.childForFieldName("name")?.text;
|
|
2342
|
+
const body = node.childForFieldName("body");
|
|
2343
|
+
if (name && body && callsName(body, name)) count++;
|
|
2344
|
+
}
|
|
2345
|
+
for (const child of node.namedChildren) {
|
|
2346
|
+
if (child) visit(child);
|
|
2347
|
+
}
|
|
2348
|
+
};
|
|
2349
|
+
visit(root);
|
|
2350
|
+
return count;
|
|
2351
|
+
}
|
|
2352
|
+
function callsName(node, name) {
|
|
2353
|
+
if (node.type === "call_expression") {
|
|
2354
|
+
const fn = node.childForFieldName("function");
|
|
2355
|
+
if (fn?.type === "identifier" && fn.text === name) return true;
|
|
2356
|
+
}
|
|
2357
|
+
for (const child of node.namedChildren) {
|
|
2358
|
+
if (child && callsName(child, name)) return true;
|
|
2359
|
+
}
|
|
2360
|
+
return false;
|
|
2361
|
+
}
|
|
2362
|
+
function countSearchInLoop(node, loopTypes, inLoop) {
|
|
2363
|
+
let count = 0;
|
|
2364
|
+
if (inLoop && node.type === "call_expression") {
|
|
2365
|
+
const fn = node.childForFieldName("function");
|
|
2366
|
+
const prop = fn?.type === "member_expression" ? fn.childForFieldName("property") : null;
|
|
2367
|
+
if (prop && SEARCH_METHODS.has(prop.text)) count++;
|
|
2368
|
+
}
|
|
2369
|
+
const childInLoop = inLoop || loopTypes.has(node.type);
|
|
2370
|
+
for (const child of node.namedChildren) {
|
|
2371
|
+
if (child) count += countSearchInLoop(child, loopTypes, childInLoop);
|
|
2372
|
+
}
|
|
2373
|
+
return count;
|
|
2374
|
+
}
|
|
2375
|
+
function maxLoopDepth(node, loopTypes, depth) {
|
|
2376
|
+
let max = depth;
|
|
2377
|
+
for (const child of node.namedChildren) {
|
|
2378
|
+
if (!child) continue;
|
|
2379
|
+
const next = loopTypes.has(child.type) ? depth + 1 : depth;
|
|
2380
|
+
const childMax = maxLoopDepth(child, loopTypes, next);
|
|
2381
|
+
if (childMax > max) max = childMax;
|
|
2382
|
+
}
|
|
2383
|
+
return max;
|
|
2384
|
+
}
|
|
2385
|
+
|
|
1875
2386
|
// src/reconstruct.ts
|
|
1876
2387
|
function reconstructFragment(repoRoot, absPath, fileId2, basis) {
|
|
1877
2388
|
const nodes = buildFileModuleNodes(repoRoot, absPath);
|
|
@@ -1922,7 +2433,7 @@ function structuralSignature(file) {
|
|
|
1922
2433
|
return hashContent(parts.join(""));
|
|
1923
2434
|
}
|
|
1924
2435
|
function sourceLanguage(filePath) {
|
|
1925
|
-
const language =
|
|
2436
|
+
const language = getLanguageFromPath2(filePath);
|
|
1926
2437
|
return language === "typescript" || language === "python" ? language : null;
|
|
1927
2438
|
}
|
|
1928
2439
|
async function readSourceFiles(filePaths) {
|
|
@@ -2019,7 +2530,8 @@ function loadReuseBasis(db, indexVersion) {
|
|
|
2019
2530
|
}
|
|
2020
2531
|
const sourceMetricsByFile = /* @__PURE__ */ new Map();
|
|
2021
2532
|
for (const m of db.listMetrics(snap.id)) {
|
|
2022
|
-
if (!SOURCE_METRIC_NAMES.has(m.name))
|
|
2533
|
+
if (!SOURCE_METRIC_NAMES.has(m.name) && !DEAD_CODE_METRIC_NAMES.has(m.name) && !GROWTH_RISK_METRIC_NAMES.has(m.name))
|
|
2534
|
+
continue;
|
|
2023
2535
|
const node = nodesById.get(m.nodeId);
|
|
2024
2536
|
const fileKey = node?.kind === "symbol" && node.parentId ? node.parentId : m.nodeId;
|
|
2025
2537
|
const bucket = sourceMetricsByFile.get(fileKey);
|
|
@@ -2163,12 +2675,205 @@ function computeMetrics(nodes, edges) {
|
|
|
2163
2675
|
return out;
|
|
2164
2676
|
}
|
|
2165
2677
|
|
|
2166
|
-
// src/
|
|
2167
|
-
|
|
2168
|
-
|
|
2678
|
+
// src/analysis/test-linker.ts
|
|
2679
|
+
var DEFAULT_MIN_CO_EDIT_COUNT = 2;
|
|
2680
|
+
var TEST_INFIX_RE = /\.(?:test|spec)(\.[^./]+)$/;
|
|
2681
|
+
var TESTS_DIR_RE = /(^|\/)(?:__tests__|tests?)\//;
|
|
2682
|
+
function pathCandidates(testId) {
|
|
2683
|
+
const out = /* @__PURE__ */ new Set();
|
|
2684
|
+
const deInfixed = testId.replace(TEST_INFIX_RE, "$1");
|
|
2685
|
+
if (deInfixed !== testId) out.add(deInfixed);
|
|
2686
|
+
const deDir = deInfixed.replace(TESTS_DIR_RE, "$1");
|
|
2687
|
+
if (deDir !== testId) out.add(deDir);
|
|
2688
|
+
return [...out];
|
|
2689
|
+
}
|
|
2690
|
+
function splitFileIds(nodes) {
|
|
2691
|
+
const testIds = [];
|
|
2692
|
+
const nonTestIds = /* @__PURE__ */ new Set();
|
|
2169
2693
|
for (const n of nodes) {
|
|
2170
|
-
if (n.kind !== "
|
|
2171
|
-
|
|
2694
|
+
if (n.kind !== "file") continue;
|
|
2695
|
+
if (n.role === "test") testIds.push(n.id);
|
|
2696
|
+
else nonTestIds.add(n.id);
|
|
2697
|
+
}
|
|
2698
|
+
return { testIds, nonTestIds };
|
|
2699
|
+
}
|
|
2700
|
+
function linkTestsToSources(nodes, coEditPairs, options = {}) {
|
|
2701
|
+
const minCount = options.minCoEditCount ?? DEFAULT_MIN_CO_EDIT_COUNT;
|
|
2702
|
+
const { testIds, nonTestIds } = splitFileIds(nodes);
|
|
2703
|
+
const links = [];
|
|
2704
|
+
for (const testId of testIds) {
|
|
2705
|
+
const matched = pathCandidates(testId).filter((c) => nonTestIds.has(c));
|
|
2706
|
+
if (matched.length > 0) {
|
|
2707
|
+
for (const sourceId of matched) {
|
|
2708
|
+
links.push({ testId, sourceId, method: "path" });
|
|
2709
|
+
}
|
|
2710
|
+
continue;
|
|
2711
|
+
}
|
|
2712
|
+
const partner = couplingFor(coEditPairs, testId).find((p) => p.count >= minCount && nonTestIds.has(p.partner));
|
|
2713
|
+
if (partner) {
|
|
2714
|
+
links.push({ testId, sourceId: partner.partner, method: "coedit" });
|
|
2715
|
+
}
|
|
2716
|
+
}
|
|
2717
|
+
return links;
|
|
2718
|
+
}
|
|
2719
|
+
function testCoverageCountMetrics(links) {
|
|
2720
|
+
const testsBySource = groupTestsBySource(links);
|
|
2721
|
+
const out = [];
|
|
2722
|
+
for (const [sourceId, tests] of testsBySource) {
|
|
2723
|
+
out.push({
|
|
2724
|
+
nodeId: sourceId,
|
|
2725
|
+
name: "linked_test_count",
|
|
2726
|
+
value: tests.size,
|
|
2727
|
+
unit: "count"
|
|
2728
|
+
});
|
|
2729
|
+
}
|
|
2730
|
+
return out;
|
|
2731
|
+
}
|
|
2732
|
+
function groupTestsBySource(links) {
|
|
2733
|
+
const out = /* @__PURE__ */ new Map();
|
|
2734
|
+
for (const l of links) {
|
|
2735
|
+
let set = out.get(l.sourceId);
|
|
2736
|
+
if (!set) {
|
|
2737
|
+
set = /* @__PURE__ */ new Set();
|
|
2738
|
+
out.set(l.sourceId, set);
|
|
2739
|
+
}
|
|
2740
|
+
set.add(l.testId);
|
|
2741
|
+
}
|
|
2742
|
+
return out;
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
// src/history-recency.ts
|
|
2746
|
+
var DAY_SECONDS = 86400;
|
|
2747
|
+
function windowSuffix(window) {
|
|
2748
|
+
return window === "lifetime" ? "lifetime" : `${window}d`;
|
|
2749
|
+
}
|
|
2750
|
+
function computeRecencyWindows(firstSeen, churnedIdsByWindow, nowEpoch) {
|
|
2751
|
+
const out = [];
|
|
2752
|
+
const ageEmitted = /* @__PURE__ */ new Set();
|
|
2753
|
+
for (const [windowDays, ids] of churnedIdsByWindow) {
|
|
2754
|
+
const suffix = windowSuffix(windowDays);
|
|
2755
|
+
for (const id of ids) {
|
|
2756
|
+
const seen = firstSeen.get(id);
|
|
2757
|
+
let recency = 1;
|
|
2758
|
+
if (seen !== void 0) {
|
|
2759
|
+
const ageDays = Math.max(0, (nowEpoch - seen) / DAY_SECONDS);
|
|
2760
|
+
if (windowDays !== "lifetime") recency = Math.min(1, ageDays / windowDays);
|
|
2761
|
+
if (!ageEmitted.has(id)) {
|
|
2762
|
+
out.push({ nodeId: id, name: "file_age_days", value: Math.round(ageDays), unit: "days" });
|
|
2763
|
+
ageEmitted.add(id);
|
|
2764
|
+
}
|
|
2765
|
+
}
|
|
2766
|
+
out.push({ nodeId: id, name: `recency_${suffix}`, value: round3(recency), unit: "ratio" });
|
|
2767
|
+
}
|
|
2768
|
+
}
|
|
2769
|
+
return out;
|
|
2770
|
+
}
|
|
2771
|
+
function round3(n) {
|
|
2772
|
+
return Math.round(n * 1e3) / 1e3;
|
|
2773
|
+
}
|
|
2774
|
+
|
|
2775
|
+
// src/history-metrics.ts
|
|
2776
|
+
var DEFAULT_CHURN_WINDOWS = [30, 90, 180];
|
|
2777
|
+
function resolveChurnWindows(requested, primaryWindow, includeLifetime) {
|
|
2778
|
+
const base = requested && requested.length > 0 ? requested : DEFAULT_CHURN_WINDOWS;
|
|
2779
|
+
const finite = [.../* @__PURE__ */ new Set([primaryWindow, ...base])].filter((w) => w > 0).sort((a, b) => a - b);
|
|
2780
|
+
return includeLifetime ? [...finite, "lifetime"] : finite;
|
|
2781
|
+
}
|
|
2782
|
+
function loadHistoryMetrics(nodes, idRoot, options = {}) {
|
|
2783
|
+
const knownPaths = collectFileIds(nodes);
|
|
2784
|
+
const primaryWindow = options.churnWindowDays ?? 30;
|
|
2785
|
+
const windows = resolveChurnWindows(options.churnWindows, primaryWindow, options.includeLifetime === true);
|
|
2786
|
+
const wide = loadChurnEntries({ repoRoot: idRoot, windowDays: windows[windows.length - 1] });
|
|
2787
|
+
if (wide === null) return null;
|
|
2788
|
+
const nowEpoch = options.nowEpoch ?? Math.floor(Date.now() / 1e3);
|
|
2789
|
+
const churnByWindow = aggregateChurnWindows(wide, windows, nowEpoch, knownPaths);
|
|
2790
|
+
const primaryEntries = windows.at(-1) === primaryWindow ? wide : entriesWithin(wide, primaryWindow, nowEpoch);
|
|
2791
|
+
const metrics = [
|
|
2792
|
+
...churnMetrics(churnByWindow),
|
|
2793
|
+
...ownershipMetrics(primaryEntries, primaryWindow, knownPaths),
|
|
2794
|
+
...recencyMetrics(idRoot, churnByWindow, knownPaths, nowEpoch)
|
|
2795
|
+
];
|
|
2796
|
+
if (options.includeLifetime === true) metrics.push(...ownershipMetrics(wide, "lifetime", knownPaths));
|
|
2797
|
+
return { metrics, primaryEntries };
|
|
2798
|
+
}
|
|
2799
|
+
function collectFileIds(nodes) {
|
|
2800
|
+
const out = /* @__PURE__ */ new Set();
|
|
2801
|
+
for (const n of nodes) {
|
|
2802
|
+
if (n.kind === "file") out.add(n.id);
|
|
2803
|
+
}
|
|
2804
|
+
return out;
|
|
2805
|
+
}
|
|
2806
|
+
function churnMetrics(churnByWindow) {
|
|
2807
|
+
const out = [];
|
|
2808
|
+
for (const [window, byPath] of churnByWindow) {
|
|
2809
|
+
const suffix = windowSuffix(window);
|
|
2810
|
+
for (const [nodeId, churn] of byPath) {
|
|
2811
|
+
out.push(
|
|
2812
|
+
{ nodeId, name: `churn_${suffix}`, value: churn.lines, unit: "lines" },
|
|
2813
|
+
{ nodeId, name: `churn_${suffix}_commits`, value: churn.commits, unit: "count" },
|
|
2814
|
+
{ nodeId, name: `churn_${suffix}_authors`, value: churn.authors, unit: "count" }
|
|
2815
|
+
);
|
|
2816
|
+
}
|
|
2817
|
+
}
|
|
2818
|
+
return out;
|
|
2819
|
+
}
|
|
2820
|
+
function ownershipMetrics(entries, window, knownPaths) {
|
|
2821
|
+
const suffix = windowSuffix(window);
|
|
2822
|
+
const out = [];
|
|
2823
|
+
for (const [nodeId, owner] of computeOwnership(entries, { knownPaths })) {
|
|
2824
|
+
out.push(
|
|
2825
|
+
{ nodeId, name: `bus_factor_${suffix}`, value: owner.busFactor, unit: "count" },
|
|
2826
|
+
{ nodeId, name: `top_author_share_${suffix}`, value: round3(owner.topAuthorShare), unit: "ratio" }
|
|
2827
|
+
);
|
|
2828
|
+
}
|
|
2829
|
+
return out;
|
|
2830
|
+
}
|
|
2831
|
+
function computeTestCoverageOwnership(entries, links, options = {}) {
|
|
2832
|
+
const suffix = windowSuffix(options.windowDays ?? 30);
|
|
2833
|
+
const testsBySource = groupTestsBySource(links);
|
|
2834
|
+
const testIds = /* @__PURE__ */ new Set();
|
|
2835
|
+
for (const tests of testsBySource.values()) {
|
|
2836
|
+
for (const t of tests) testIds.add(t);
|
|
2837
|
+
}
|
|
2838
|
+
const linesByTest = authorLinesByPath(entries, testIds);
|
|
2839
|
+
const out = [];
|
|
2840
|
+
for (const [nodeId, tests] of testsBySource) {
|
|
2841
|
+
const summary = summarizeOwnership(mergeAuthorChurn(tests, linesByTest), options.busFactorThreshold);
|
|
2842
|
+
if (summary === null) continue;
|
|
2843
|
+
out.push(
|
|
2844
|
+
{ nodeId, name: `test_bus_factor_${suffix}`, value: summary.busFactor, unit: "count" },
|
|
2845
|
+
{ nodeId, name: `test_top_author_share_${suffix}`, value: round3(summary.topAuthorShare), unit: "ratio" }
|
|
2846
|
+
);
|
|
2847
|
+
}
|
|
2848
|
+
return out;
|
|
2849
|
+
}
|
|
2850
|
+
function mergeAuthorChurn(tests, linesByTest) {
|
|
2851
|
+
const byAuthor = /* @__PURE__ */ new Map();
|
|
2852
|
+
for (const testId of tests) {
|
|
2853
|
+
const fileAuthors = linesByTest.get(testId);
|
|
2854
|
+
if (!fileAuthors) continue;
|
|
2855
|
+
for (const [author, lines] of fileAuthors) {
|
|
2856
|
+
byAuthor.set(author, (byAuthor.get(author) ?? 0) + lines);
|
|
2857
|
+
}
|
|
2858
|
+
}
|
|
2859
|
+
return byAuthor;
|
|
2860
|
+
}
|
|
2861
|
+
function recencyMetrics(idRoot, churnByWindow, knownPaths, nowEpoch) {
|
|
2862
|
+
const churnedByWindow = /* @__PURE__ */ new Map();
|
|
2863
|
+
for (const [window, byPath] of churnByWindow) {
|
|
2864
|
+
if (byPath.size > 0) churnedByWindow.set(window, new Set(byPath.keys()));
|
|
2865
|
+
}
|
|
2866
|
+
if (churnedByWindow.size === 0) return [];
|
|
2867
|
+
const firstSeen = loadFileFirstSeen({ repoRoot: idRoot, knownPaths }) ?? /* @__PURE__ */ new Map();
|
|
2868
|
+
return computeRecencyWindows(firstSeen, churnedByWindow, nowEpoch);
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
// src/index-metrics.ts
|
|
2872
|
+
function symbolNamesByFile(nodes) {
|
|
2873
|
+
const out = /* @__PURE__ */ new Map();
|
|
2874
|
+
for (const n of nodes) {
|
|
2875
|
+
if (n.kind !== "symbol" || !n.parentId) continue;
|
|
2876
|
+
const bucket = out.get(n.parentId);
|
|
2172
2877
|
if (bucket) bucket.add(n.name);
|
|
2173
2878
|
else out.set(n.parentId, /* @__PURE__ */ new Set([n.name]));
|
|
2174
2879
|
}
|
|
@@ -2176,6 +2881,7 @@ function symbolNamesByFile(nodes) {
|
|
|
2176
2881
|
}
|
|
2177
2882
|
function buildIndexerMetrics(input) {
|
|
2178
2883
|
const nodeList = [...input.nodes.values()];
|
|
2884
|
+
const history = input.history ? loadHistoryMetrics(nodeList, input.idRoot, input.history) : null;
|
|
2179
2885
|
return [
|
|
2180
2886
|
...computeMetrics(nodeList, [...input.edges.values()]),
|
|
2181
2887
|
...computeSourceMetrics(
|
|
@@ -2183,9 +2889,23 @@ function buildIndexerMetrics(input) {
|
|
|
2183
2889
|
(p) => fileId(input.idRoot, p),
|
|
2184
2890
|
symbolNamesByFile(nodeList)
|
|
2185
2891
|
),
|
|
2186
|
-
...input.
|
|
2892
|
+
...computeDeadCodeMetrics(input.parsedFiles, (p) => fileId(input.idRoot, p)),
|
|
2893
|
+
...computeGrowthRiskMetrics(input.parsedFiles, (p) => fileId(input.idRoot, p)),
|
|
2894
|
+
...input.reusedSourceMetrics,
|
|
2895
|
+
...history?.metrics ?? [],
|
|
2896
|
+
...computeTestCoverage(nodeList, history?.primaryEntries ?? null, input.history?.churnWindowDays)
|
|
2187
2897
|
];
|
|
2188
2898
|
}
|
|
2899
|
+
function computeTestCoverage(nodes, entries, windowDays) {
|
|
2900
|
+
const coEditPairs = entries ? computeChangeCoupling(entries, { knownPaths: collectFileIds(nodes) }).pairs : [];
|
|
2901
|
+
const links = linkTestsToSources(nodes, coEditPairs);
|
|
2902
|
+
if (links.length === 0) return [];
|
|
2903
|
+
const out = testCoverageCountMetrics(links);
|
|
2904
|
+
if (entries) {
|
|
2905
|
+
out.push(...computeTestCoverageOwnership(entries, links, { windowDays }));
|
|
2906
|
+
}
|
|
2907
|
+
return out;
|
|
2908
|
+
}
|
|
2189
2909
|
|
|
2190
2910
|
// src/merge.ts
|
|
2191
2911
|
function edgeKey(edge) {
|
|
@@ -2204,8 +2924,45 @@ function mergeFragments(fragments) {
|
|
|
2204
2924
|
return { nodes, edges };
|
|
2205
2925
|
}
|
|
2206
2926
|
|
|
2927
|
+
// src/symbol-aliases.ts
|
|
2928
|
+
var QUALIFIED_SYMBOLS_SINCE = "0.14.0";
|
|
2929
|
+
function versionParts(version) {
|
|
2930
|
+
const m = /^(\d+)\.(\d+)\.(\d+)/.exec(version);
|
|
2931
|
+
return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null;
|
|
2932
|
+
}
|
|
2933
|
+
function predatesQualifiedSymbols(indexVersion) {
|
|
2934
|
+
const have = versionParts(indexVersion);
|
|
2935
|
+
if (!have) return true;
|
|
2936
|
+
const since = versionParts(QUALIFIED_SYMBOLS_SINCE);
|
|
2937
|
+
for (let i = 0; i < 3; i++) {
|
|
2938
|
+
if (have[i] !== since[i]) return have[i] < since[i];
|
|
2939
|
+
}
|
|
2940
|
+
return false;
|
|
2941
|
+
}
|
|
2942
|
+
function qualifiedSymbolAliases(files, nodeIds) {
|
|
2943
|
+
const out = [];
|
|
2944
|
+
for (const { fileId: fileId2, file } of files) {
|
|
2945
|
+
for (const [name, qualified] of qualifiedByName(file)) {
|
|
2946
|
+
const oldId = symbolId(fileId2, name);
|
|
2947
|
+
if (qualified.size !== 1 || nodeIds.has(oldId)) continue;
|
|
2948
|
+
const newId = symbolId(fileId2, [...qualified][0]);
|
|
2949
|
+
if (nodeIds.has(newId)) out.push({ oldId, newId, reason: "requalify" });
|
|
2950
|
+
}
|
|
2951
|
+
}
|
|
2952
|
+
return out;
|
|
2953
|
+
}
|
|
2954
|
+
function qualifiedByName(file) {
|
|
2955
|
+
const out = /* @__PURE__ */ new Map();
|
|
2956
|
+
for (const d of collectDeclarations(file)) {
|
|
2957
|
+
const bucket = out.get(d.name);
|
|
2958
|
+
if (bucket) bucket.add(d.qualifiedName);
|
|
2959
|
+
else out.set(d.name, /* @__PURE__ */ new Set([d.qualifiedName]));
|
|
2960
|
+
}
|
|
2961
|
+
return out;
|
|
2962
|
+
}
|
|
2963
|
+
|
|
2207
2964
|
// src/indexer.ts
|
|
2208
|
-
var INDEX_VERSION = "0.
|
|
2965
|
+
var INDEX_VERSION = "0.15.0";
|
|
2209
2966
|
var LANGUAGES = ["typescript", "python"];
|
|
2210
2967
|
function canonicalizePath(p) {
|
|
2211
2968
|
try {
|
|
@@ -2216,32 +2973,28 @@ function canonicalizePath(p) {
|
|
|
2216
2973
|
}
|
|
2217
2974
|
function normalizePaths(paths) {
|
|
2218
2975
|
if (paths.length === 0) throw new Error("indexPaths requires at least one path");
|
|
2219
|
-
return [...new Set(paths.map((p) =>
|
|
2976
|
+
return [...new Set(paths.map((p) => path9.resolve(p)))];
|
|
2220
2977
|
}
|
|
2221
2978
|
function countByKind(items) {
|
|
2222
2979
|
const out = {};
|
|
2223
2980
|
for (const item of items) out[item.kind] = (out[item.kind] ?? 0) + 1;
|
|
2224
2981
|
return out;
|
|
2225
2982
|
}
|
|
2226
|
-
function persist(store,
|
|
2227
|
-
const
|
|
2983
|
+
function persist(store, snapshot, accumulator, aliases, metrics) {
|
|
2984
|
+
const { ref, commitHash, aliasBase } = snapshot;
|
|
2985
|
+
const attrs = { [ALIAS_BASE_ATTR]: aliasBase };
|
|
2986
|
+
const snapshotId = store.createSnapshot({ ref, commitHash, indexVersion: INDEX_VERSION, attrs });
|
|
2228
2987
|
store.insertNodes(snapshotId, [...accumulator.nodes.values()]);
|
|
2229
2988
|
store.insertEdges(snapshotId, [...accumulator.edges.values()]);
|
|
2230
2989
|
if (aliases.length > 0) store.insertAliases(snapshotId, aliases);
|
|
2231
2990
|
if (metrics.length > 0) store.insertMetrics(snapshotId, metrics);
|
|
2232
2991
|
return snapshotId;
|
|
2233
2992
|
}
|
|
2234
|
-
function
|
|
2235
|
-
|
|
2236
|
-
|
|
2237
|
-
|
|
2238
|
-
|
|
2239
|
-
if (!isInsideGitRepo(rootDir)) return [];
|
|
2240
|
-
const priorCommit = findPriorCommit(store);
|
|
2241
|
-
if (!priorCommit) return [];
|
|
2242
|
-
const target = options.commitHash ?? detectGitHead(rootDir) ?? void 0;
|
|
2243
|
-
if (target === priorCommit) return [];
|
|
2244
|
-
return buildAliases(idRoot, detectRenames({ repoRoot: rootDir, fromCommit: priorCommit, toCommit: target }));
|
|
2993
|
+
function resolveSymbolAliases(store, idRoot, parsedByPath, nodeIds) {
|
|
2994
|
+
const prior = store.listSnapshots({ limit: 1 })[0];
|
|
2995
|
+
if (!prior || !predatesQualifiedSymbols(prior.indexVersion)) return [];
|
|
2996
|
+
const files = [...parsedByPath.values()].map((file) => ({ fileId: fileId(idRoot, file.filePath), file }));
|
|
2997
|
+
return qualifiedSymbolAliases(files, nodeIds);
|
|
2245
2998
|
}
|
|
2246
2999
|
function resolveRoots(paths) {
|
|
2247
3000
|
const raw = normalizePaths(paths);
|
|
@@ -2256,6 +3009,13 @@ async function parseAll(toParse) {
|
|
|
2256
3009
|
}
|
|
2257
3010
|
return parsed;
|
|
2258
3011
|
}
|
|
3012
|
+
function historyOptions(options) {
|
|
3013
|
+
return {
|
|
3014
|
+
churnWindowDays: options.churnWindowDays,
|
|
3015
|
+
churnWindows: options.churnWindows,
|
|
3016
|
+
includeLifetime: options.lifetime === true
|
|
3017
|
+
};
|
|
3018
|
+
}
|
|
2259
3019
|
async function indexPaths(store, options) {
|
|
2260
3020
|
const { rootDirs, idRoot } = resolveRoots(options.paths);
|
|
2261
3021
|
const readFiles = await readSourceFiles(await walkSourceFiles(rootDirs, LANGUAGES));
|
|
@@ -2283,12 +3043,19 @@ async function indexPaths(store, options) {
|
|
|
2283
3043
|
edges: accumulator.edges,
|
|
2284
3044
|
parsedFiles: [...parsedByPath.values()],
|
|
2285
3045
|
reusedSourceMetrics: reuse ? reusedFileIds.flatMap((id) => reuse.sourceMetricsByFile.get(id) ?? []) : [],
|
|
2286
|
-
idRoot
|
|
3046
|
+
idRoot,
|
|
3047
|
+
history: options.computeChurn === false ? void 0 : historyOptions(options)
|
|
2287
3048
|
});
|
|
2288
3049
|
const rootDir = rootDirs[0];
|
|
2289
|
-
const
|
|
3050
|
+
const ref = options.ref ?? "wd";
|
|
3051
|
+
const bridge = computeAliasBridge(store, { ...options, rootDir, idRoot, ref, nodes: accumulator.nodes });
|
|
3052
|
+
const aliases = [
|
|
3053
|
+
...bridge.aliases,
|
|
3054
|
+
...resolveSymbolAliases(store, idRoot, parsedByPath, new Set(accumulator.nodes.keys()))
|
|
3055
|
+
];
|
|
2290
3056
|
const commitHash = options.commitHash ?? detectGitHead(rootDir) ?? void 0;
|
|
2291
|
-
const
|
|
3057
|
+
const snapshot = { ref, commitHash, aliasBase: bridge.baseSnapshotId };
|
|
3058
|
+
const snapshotId = persist(store, snapshot, accumulator, aliases, metrics);
|
|
2292
3059
|
store.insertFingerprints(snapshotId, buildFingerprints(readFiles, idRoot, classified.structuralByFileId));
|
|
2293
3060
|
return {
|
|
2294
3061
|
snapshotId,
|
|
@@ -2305,6 +3072,146 @@ async function indexPaths(store, options) {
|
|
|
2305
3072
|
};
|
|
2306
3073
|
}
|
|
2307
3074
|
|
|
3075
|
+
// src/index.ts
|
|
3076
|
+
import { getLanguageFromPath as getLanguageFromPath3, getSupportedLanguages, parseFile as parseFile2, shouldIncludeFile as shouldIncludeFile2 } from "@titan-design/code-parser";
|
|
3077
|
+
|
|
3078
|
+
// src/extractors/deep-ast.ts
|
|
3079
|
+
import { readFileSync as readFileSync2 } from "fs";
|
|
3080
|
+
import * as path10 from "path";
|
|
3081
|
+
import {
|
|
3082
|
+
Node as Node4,
|
|
3083
|
+
Project as Project2,
|
|
3084
|
+
ScriptTarget as ScriptTarget2
|
|
3085
|
+
} from "ts-morph";
|
|
3086
|
+
function computeDeepAst(input) {
|
|
3087
|
+
const sf = loadSourceFile(input.absPath);
|
|
3088
|
+
if (!sf) return null;
|
|
3089
|
+
const target = input.symbolName ? `${input.filePath}#${input.symbolName}` : input.filePath;
|
|
3090
|
+
return input.symbolName ? symbolDeepAst(sf, input.symbolName, target) : fileDeepAst(sf, target);
|
|
3091
|
+
}
|
|
3092
|
+
function loadSourceFile(absPath) {
|
|
3093
|
+
try {
|
|
3094
|
+
const text = readFileSync2(absPath, "utf8");
|
|
3095
|
+
const project = new Project2({
|
|
3096
|
+
useInMemoryFileSystem: true,
|
|
3097
|
+
compilerOptions: { target: ScriptTarget2.Latest, allowJs: true }
|
|
3098
|
+
});
|
|
3099
|
+
return project.createSourceFile(`f${path10.extname(absPath) || ".ts"}`, text);
|
|
3100
|
+
} catch {
|
|
3101
|
+
return null;
|
|
3102
|
+
}
|
|
3103
|
+
}
|
|
3104
|
+
function symbolDeepAst(sf, name, target) {
|
|
3105
|
+
const decl = lookupDeclaration(sf, name);
|
|
3106
|
+
if (!decl) {
|
|
3107
|
+
return { ...empty(target, "symbol"), note: "declaration not found in source" };
|
|
3108
|
+
}
|
|
3109
|
+
const text = declarationText(decl);
|
|
3110
|
+
return {
|
|
3111
|
+
target,
|
|
3112
|
+
kind: "symbol",
|
|
3113
|
+
declarationKind: decl.getKindName(),
|
|
3114
|
+
signature: text.signature ?? null,
|
|
3115
|
+
purpose: text.purpose ?? null,
|
|
3116
|
+
params: paramsOf(decl),
|
|
3117
|
+
returnType: returnTypeOf(decl),
|
|
3118
|
+
members: membersOf(decl)
|
|
3119
|
+
};
|
|
3120
|
+
}
|
|
3121
|
+
function fileDeepAst(sf, target) {
|
|
3122
|
+
return {
|
|
3123
|
+
...empty(target, "file"),
|
|
3124
|
+
declarationKind: "SourceFile",
|
|
3125
|
+
members: exportedDeclarations(sf)
|
|
3126
|
+
};
|
|
3127
|
+
}
|
|
3128
|
+
function empty(target, kind) {
|
|
3129
|
+
return {
|
|
3130
|
+
target,
|
|
3131
|
+
kind,
|
|
3132
|
+
declarationKind: null,
|
|
3133
|
+
signature: null,
|
|
3134
|
+
purpose: null,
|
|
3135
|
+
params: [],
|
|
3136
|
+
returnType: null,
|
|
3137
|
+
members: []
|
|
3138
|
+
};
|
|
3139
|
+
}
|
|
3140
|
+
function callableOf(decl) {
|
|
3141
|
+
if (Node4.isFunctionDeclaration(decl) || Node4.isMethodDeclaration(decl)) return decl;
|
|
3142
|
+
if (Node4.isVariableDeclaration(decl)) {
|
|
3143
|
+
const init = decl.getInitializer();
|
|
3144
|
+
if (init && (Node4.isArrowFunction(init) || Node4.isFunctionExpression(init))) return init;
|
|
3145
|
+
}
|
|
3146
|
+
return null;
|
|
3147
|
+
}
|
|
3148
|
+
function paramsOf(decl) {
|
|
3149
|
+
const callable = callableOf(decl);
|
|
3150
|
+
if (!callable) return [];
|
|
3151
|
+
return callable.getParameters().map((p) => ({
|
|
3152
|
+
name: p.getName(),
|
|
3153
|
+
type: p.getTypeNode()?.getText() ?? null
|
|
3154
|
+
}));
|
|
3155
|
+
}
|
|
3156
|
+
function returnTypeOf(decl) {
|
|
3157
|
+
return callableOf(decl)?.getReturnTypeNode()?.getText() ?? null;
|
|
3158
|
+
}
|
|
3159
|
+
function membersOf(decl) {
|
|
3160
|
+
if (Node4.isClassDeclaration(decl)) return classMembers(decl);
|
|
3161
|
+
if (Node4.isInterfaceDeclaration(decl)) return interfaceMembers(decl);
|
|
3162
|
+
return [];
|
|
3163
|
+
}
|
|
3164
|
+
function classMembers(cls) {
|
|
3165
|
+
return cls.getMembers().map((m) => ({
|
|
3166
|
+
name: memberName(m),
|
|
3167
|
+
memberKind: m.getKindName(),
|
|
3168
|
+
signature: memberSignature(m),
|
|
3169
|
+
isStatic: Node4.isStaticable(m) ? m.isStatic() : false
|
|
3170
|
+
}));
|
|
3171
|
+
}
|
|
3172
|
+
function interfaceMembers(iface) {
|
|
3173
|
+
return iface.getMembers().map((m) => ({
|
|
3174
|
+
name: memberName(m),
|
|
3175
|
+
memberKind: m.getKindName(),
|
|
3176
|
+
signature: memberSignature(m),
|
|
3177
|
+
isStatic: false
|
|
3178
|
+
}));
|
|
3179
|
+
}
|
|
3180
|
+
function exportedDeclarations(sf) {
|
|
3181
|
+
const out = [];
|
|
3182
|
+
for (const [name, decls] of sf.getExportedDeclarations()) {
|
|
3183
|
+
const decl = decls[0];
|
|
3184
|
+
if (!decl) continue;
|
|
3185
|
+
out.push({
|
|
3186
|
+
name,
|
|
3187
|
+
memberKind: decl.getKindName(),
|
|
3188
|
+
signature: declarationText(decl).signature ?? memberSignature(decl),
|
|
3189
|
+
isStatic: false
|
|
3190
|
+
});
|
|
3191
|
+
}
|
|
3192
|
+
return out;
|
|
3193
|
+
}
|
|
3194
|
+
function memberName(m) {
|
|
3195
|
+
if (Node4.isConstructorDeclaration(m)) return "constructor";
|
|
3196
|
+
return Node4.hasName(m) ? m.getName() : "";
|
|
3197
|
+
}
|
|
3198
|
+
function memberSignature(m) {
|
|
3199
|
+
if (Node4.isMethodDeclaration(m) || Node4.isMethodSignature(m) || Node4.isConstructorDeclaration(m)) {
|
|
3200
|
+
const params = m.getParameters().map((p) => oneLine2(p.getText())).join(", ");
|
|
3201
|
+
const ret = m.getReturnTypeNode()?.getText();
|
|
3202
|
+
const name = Node4.isConstructorDeclaration(m) ? "constructor" : memberName(m);
|
|
3203
|
+
return oneLine2(`${name}(${params})${ret ? `: ${ret}` : ""}`);
|
|
3204
|
+
}
|
|
3205
|
+
if (Node4.isPropertyDeclaration(m) || Node4.isPropertySignature(m)) {
|
|
3206
|
+
const type = m.getTypeNode()?.getText();
|
|
3207
|
+
return oneLine2(`${memberName(m)}${type ? `: ${type}` : ""}`);
|
|
3208
|
+
}
|
|
3209
|
+
return null;
|
|
3210
|
+
}
|
|
3211
|
+
function oneLine2(s) {
|
|
3212
|
+
return s.replace(/\s+/g, " ").trim();
|
|
3213
|
+
}
|
|
3214
|
+
|
|
2308
3215
|
// src/aliases.ts
|
|
2309
3216
|
var METRIC_ALIASES = /* @__PURE__ */ new Map([
|
|
2310
3217
|
["lines", "loc"],
|
|
@@ -2334,6 +3241,9 @@ var EDGE_KIND_ALIASES = /* @__PURE__ */ new Map([
|
|
|
2334
3241
|
["depends_on", "depends-on"],
|
|
2335
3242
|
["dependson", "depends-on"]
|
|
2336
3243
|
]);
|
|
3244
|
+
function deprecatedTarget(map, name) {
|
|
3245
|
+
return map.get(name);
|
|
3246
|
+
}
|
|
2337
3247
|
function canonicalMetricName(name) {
|
|
2338
3248
|
return METRIC_ALIASES.get(name) ?? name;
|
|
2339
3249
|
}
|
|
@@ -2343,71 +3253,1912 @@ function canonicalRole(name) {
|
|
|
2343
3253
|
function canonicalEdgeKind(name) {
|
|
2344
3254
|
return EDGE_KIND_ALIASES.get(name) ?? name;
|
|
2345
3255
|
}
|
|
3256
|
+
function metricAliasTarget(name) {
|
|
3257
|
+
return deprecatedTarget(METRIC_ALIASES, name);
|
|
3258
|
+
}
|
|
3259
|
+
function roleAliasTarget(name) {
|
|
3260
|
+
return deprecatedTarget(ROLE_ALIASES, name);
|
|
3261
|
+
}
|
|
2346
3262
|
|
|
2347
|
-
// src/
|
|
2348
|
-
|
|
2349
|
-
|
|
3263
|
+
// src/analysis/coverage.ts
|
|
3264
|
+
var COVERAGE_METRIC_NAME = "coverage_pct";
|
|
3265
|
+
function isFileCoverage(v) {
|
|
3266
|
+
return typeof v === "object" && v !== null && typeof v.fnMap === "object" && typeof v.f === "object";
|
|
3267
|
+
}
|
|
3268
|
+
function innermostContaining(symbols, line) {
|
|
3269
|
+
let best = null;
|
|
3270
|
+
for (const s of symbols) {
|
|
3271
|
+
if (s.startLine <= line && line <= s.endLine) {
|
|
3272
|
+
if (!best || s.endLine - s.startLine < best.endLine - best.startLine) best = s;
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
return best;
|
|
2350
3276
|
}
|
|
2351
|
-
function
|
|
2352
|
-
|
|
3277
|
+
function attributeCoverage(coverage, fileIdOf, symbolsByFile) {
|
|
3278
|
+
const out = [];
|
|
3279
|
+
for (const [absPath, fc] of Object.entries(coverage)) {
|
|
3280
|
+
if (!isFileCoverage(fc)) continue;
|
|
3281
|
+
const fileId2 = fileIdOf(absPath);
|
|
3282
|
+
if (!fileId2) continue;
|
|
3283
|
+
const fnIds = Object.keys(fc.fnMap);
|
|
3284
|
+
if (fnIds.length === 0) continue;
|
|
3285
|
+
const covered = fnIds.filter((k) => (fc.f[k] ?? 0) > 0).length;
|
|
3286
|
+
out.push(pctMetric(fileId2, covered, fnIds.length));
|
|
3287
|
+
out.push(...symbolCoverage(fc, fnIds, symbolsByFile.get(fileId2) ?? []));
|
|
3288
|
+
}
|
|
3289
|
+
return out;
|
|
2353
3290
|
}
|
|
2354
|
-
function
|
|
2355
|
-
|
|
3291
|
+
function symbolCoverage(fc, fnIds, symbols) {
|
|
3292
|
+
const perSymbol = /* @__PURE__ */ new Map();
|
|
3293
|
+
for (const k of fnIds) {
|
|
3294
|
+
const sym = innermostContaining(symbols, fc.fnMap[k].loc.start.line);
|
|
3295
|
+
if (!sym) continue;
|
|
3296
|
+
const acc = perSymbol.get(sym.id) ?? { hit: 0, total: 0 };
|
|
3297
|
+
acc.total++;
|
|
3298
|
+
if ((fc.f[k] ?? 0) > 0) acc.hit++;
|
|
3299
|
+
perSymbol.set(sym.id, acc);
|
|
3300
|
+
}
|
|
3301
|
+
return [...perSymbol].map(([id, { hit, total }]) => pctMetric(id, hit, total));
|
|
3302
|
+
}
|
|
3303
|
+
function pctMetric(nodeId, covered, total) {
|
|
3304
|
+
return {
|
|
3305
|
+
nodeId,
|
|
3306
|
+
name: COVERAGE_METRIC_NAME,
|
|
3307
|
+
value: Math.round(100 * covered / total),
|
|
3308
|
+
unit: "percent"
|
|
3309
|
+
};
|
|
3310
|
+
}
|
|
3311
|
+
|
|
3312
|
+
// src/analysis/pagerank.ts
|
|
3313
|
+
var DEFAULT_DAMPING = 0.85;
|
|
3314
|
+
var DEFAULT_TOLERANCE = 1e-6;
|
|
3315
|
+
var DEFAULT_MAX_ITERS = 100;
|
|
3316
|
+
var DEFAULT_EDGE_WEIGHTS = {
|
|
3317
|
+
imports: 1,
|
|
3318
|
+
"re-exports": 0.5,
|
|
3319
|
+
calls: 1.5,
|
|
3320
|
+
extends: 1,
|
|
3321
|
+
implements: 1,
|
|
3322
|
+
references: 1,
|
|
3323
|
+
"depends-on": 1
|
|
3324
|
+
};
|
|
3325
|
+
function getEdgeWeight(kind, overrides) {
|
|
3326
|
+
return overrides?.[kind] ?? DEFAULT_EDGE_WEIGHTS[kind] ?? 1;
|
|
3327
|
+
}
|
|
3328
|
+
function computePageRank(nodes, edges, options = {}) {
|
|
3329
|
+
const n = nodes.length;
|
|
3330
|
+
if (n === 0) return { rows: [], iterations: 0, converged: true };
|
|
3331
|
+
const damping = options.damping ?? DEFAULT_DAMPING;
|
|
3332
|
+
const tolerance = options.tolerance ?? DEFAULT_TOLERANCE;
|
|
3333
|
+
const maxIters = options.maxIterations ?? DEFAULT_MAX_ITERS;
|
|
3334
|
+
const weights = { ...DEFAULT_EDGE_WEIGHTS, ...options.edgeWeights ?? {} };
|
|
3335
|
+
const idx = buildIdIndex(nodes);
|
|
3336
|
+
const adj = buildAdjacency(idx, edges, weights, n);
|
|
3337
|
+
const pers = buildPersonalization(idx, n, options.personalization);
|
|
3338
|
+
const { rank, iterations, converged } = powerIterate(
|
|
3339
|
+
pers,
|
|
3340
|
+
adj,
|
|
3341
|
+
damping,
|
|
3342
|
+
tolerance,
|
|
3343
|
+
maxIters
|
|
3344
|
+
);
|
|
3345
|
+
return { rows: rankToRows(nodes, rank), iterations, converged };
|
|
3346
|
+
}
|
|
3347
|
+
function buildIdIndex(nodes) {
|
|
3348
|
+
const idx = /* @__PURE__ */ new Map();
|
|
3349
|
+
for (let i = 0; i < nodes.length; i++) idx.set(nodes[i].id, i);
|
|
3350
|
+
return idx;
|
|
3351
|
+
}
|
|
3352
|
+
function buildAdjacency(idx, edges, weights, n) {
|
|
3353
|
+
const outNeighbors = Array.from(
|
|
3354
|
+
{ length: n },
|
|
3355
|
+
() => []
|
|
3356
|
+
);
|
|
3357
|
+
const outWeightSum = new Array(n).fill(0);
|
|
3358
|
+
for (const e of edges) {
|
|
3359
|
+
const si = idx.get(e.srcId);
|
|
3360
|
+
const di = idx.get(e.dstId);
|
|
3361
|
+
if (si === void 0 || di === void 0) continue;
|
|
3362
|
+
const w = weights[e.kind] ?? 1;
|
|
3363
|
+
if (w <= 0) continue;
|
|
3364
|
+
outNeighbors[si].push({ to: di, w });
|
|
3365
|
+
outWeightSum[si] += w;
|
|
3366
|
+
}
|
|
3367
|
+
return { outNeighbors, outWeightSum };
|
|
3368
|
+
}
|
|
3369
|
+
function powerIterate(pers, adj, damping, tolerance, maxIters) {
|
|
3370
|
+
const n = pers.length;
|
|
3371
|
+
let rank = pers.slice();
|
|
3372
|
+
let next = new Array(n).fill(0);
|
|
3373
|
+
let iterations = 0;
|
|
3374
|
+
let converged = false;
|
|
3375
|
+
for (let iter = 0; iter < maxIters; iter++) {
|
|
3376
|
+
iterations = iter + 1;
|
|
3377
|
+
const dangling = sumDangling(rank, adj.outWeightSum);
|
|
3378
|
+
const teleport = 1 - damping + damping * dangling;
|
|
3379
|
+
seedTeleport(next, pers, teleport);
|
|
3380
|
+
distributeRank(next, rank, adj, damping);
|
|
3381
|
+
const diff = l1Diff(rank, next);
|
|
3382
|
+
[rank, next] = [next, rank];
|
|
3383
|
+
next.fill(0);
|
|
3384
|
+
if (diff < tolerance) {
|
|
3385
|
+
converged = true;
|
|
3386
|
+
break;
|
|
3387
|
+
}
|
|
3388
|
+
}
|
|
3389
|
+
return { rank, iterations, converged };
|
|
3390
|
+
}
|
|
3391
|
+
function sumDangling(rank, outWeightSum) {
|
|
3392
|
+
let total = 0;
|
|
3393
|
+
for (let i = 0; i < rank.length; i++) {
|
|
3394
|
+
if (outWeightSum[i] === 0) total += rank[i];
|
|
3395
|
+
}
|
|
3396
|
+
return total;
|
|
3397
|
+
}
|
|
3398
|
+
function seedTeleport(next, pers, teleport) {
|
|
3399
|
+
for (let i = 0; i < next.length; i++) next[i] = teleport * pers[i];
|
|
3400
|
+
}
|
|
3401
|
+
function distributeRank(next, rank, adj, damping) {
|
|
3402
|
+
for (let i = 0; i < rank.length; i++) {
|
|
3403
|
+
const s = adj.outWeightSum[i];
|
|
3404
|
+
if (s === 0 || rank[i] === 0) continue;
|
|
3405
|
+
const factor = damping * rank[i] / s;
|
|
3406
|
+
const list = adj.outNeighbors[i];
|
|
3407
|
+
for (let k = 0; k < list.length; k++) {
|
|
3408
|
+
next[list[k].to] += factor * list[k].w;
|
|
3409
|
+
}
|
|
3410
|
+
}
|
|
3411
|
+
}
|
|
3412
|
+
function l1Diff(a, b) {
|
|
3413
|
+
let diff = 0;
|
|
3414
|
+
for (let i = 0; i < a.length; i++) diff += Math.abs(b[i] - a[i]);
|
|
3415
|
+
return diff;
|
|
3416
|
+
}
|
|
3417
|
+
function rankToRows(nodes, rank) {
|
|
3418
|
+
return nodes.map((node, i) => ({ nodeId: node.id, score: rank[i] })).sort(compareRows);
|
|
3419
|
+
}
|
|
3420
|
+
function compareRows(a, b) {
|
|
3421
|
+
if (b.score !== a.score) return b.score - a.score;
|
|
3422
|
+
return a.nodeId < b.nodeId ? -1 : a.nodeId > b.nodeId ? 1 : 0;
|
|
3423
|
+
}
|
|
3424
|
+
function buildPersonalization(idx, n, pers) {
|
|
3425
|
+
if (!pers || pers.size === 0) return uniformVector(n);
|
|
3426
|
+
const out = new Array(n).fill(0);
|
|
3427
|
+
const total = applySeedWeights(out, idx, pers);
|
|
3428
|
+
if (total === 0) return uniformVector(n);
|
|
3429
|
+
for (let i = 0; i < n; i++) out[i] /= total;
|
|
3430
|
+
return out;
|
|
3431
|
+
}
|
|
3432
|
+
function uniformVector(n) {
|
|
3433
|
+
return new Array(n).fill(1 / n);
|
|
3434
|
+
}
|
|
3435
|
+
function applySeedWeights(out, idx, pers) {
|
|
3436
|
+
let total = 0;
|
|
3437
|
+
for (const [id, w] of pers) {
|
|
3438
|
+
if (!isValidWeight(w)) continue;
|
|
3439
|
+
const i = idx.get(id);
|
|
3440
|
+
if (i === void 0) continue;
|
|
3441
|
+
out[i] = w;
|
|
3442
|
+
total += w;
|
|
3443
|
+
}
|
|
3444
|
+
return total;
|
|
3445
|
+
}
|
|
3446
|
+
function isValidWeight(w) {
|
|
3447
|
+
return Number.isFinite(w) && w > 0;
|
|
3448
|
+
}
|
|
3449
|
+
|
|
3450
|
+
// src/analysis/relevance.ts
|
|
3451
|
+
function computeRelevance(nodes, edges, seedIds, options = {}) {
|
|
3452
|
+
const seeds = seedIds.filter((id) => id.length > 0);
|
|
3453
|
+
if (seeds.length === 0) return /* @__PURE__ */ new Map();
|
|
3454
|
+
const personalization = new Map(seeds.map((id) => [id, 1]));
|
|
3455
|
+
const result = computePageRank(nodes, symmetrize(edges), {
|
|
3456
|
+
personalization,
|
|
3457
|
+
damping: options.damping,
|
|
3458
|
+
edgeWeights: options.edgeWeights
|
|
3459
|
+
});
|
|
3460
|
+
const out = /* @__PURE__ */ new Map();
|
|
3461
|
+
for (const row of result.rows) out.set(row.nodeId, row.score);
|
|
3462
|
+
return out;
|
|
3463
|
+
}
|
|
3464
|
+
function symmetrize(edges) {
|
|
3465
|
+
const out = [];
|
|
3466
|
+
for (const e of edges) {
|
|
3467
|
+
out.push(e);
|
|
3468
|
+
out.push({ ...e, srcId: e.dstId, dstId: e.srcId });
|
|
3469
|
+
}
|
|
3470
|
+
return out;
|
|
3471
|
+
}
|
|
3472
|
+
|
|
3473
|
+
// src/analysis/symbol-coupling.ts
|
|
3474
|
+
var DEFAULT_MIN_CO_IMPORTS = 2;
|
|
3475
|
+
var DEFAULT_LARGE_IMPORTER_THRESHOLD = 40;
|
|
3476
|
+
function computeSymbolConsumers(edges) {
|
|
3477
|
+
const bySymbol = /* @__PURE__ */ new Map();
|
|
3478
|
+
for (const e of edges) {
|
|
3479
|
+
let set = bySymbol.get(e.dstId);
|
|
3480
|
+
if (!set) {
|
|
3481
|
+
set = /* @__PURE__ */ new Set();
|
|
3482
|
+
bySymbol.set(e.dstId, set);
|
|
3483
|
+
}
|
|
3484
|
+
set.add(e.srcId);
|
|
3485
|
+
}
|
|
3486
|
+
const out = [];
|
|
3487
|
+
for (const [id, consumers] of bySymbol) {
|
|
3488
|
+
const parsed = parseSymbolId(id);
|
|
3489
|
+
if (!parsed) continue;
|
|
3490
|
+
out.push({
|
|
3491
|
+
symbolId: id,
|
|
3492
|
+
fileId: parsed.fileId,
|
|
3493
|
+
name: parsed.name,
|
|
3494
|
+
consumers: [...consumers].sort()
|
|
3495
|
+
});
|
|
3496
|
+
}
|
|
3497
|
+
return out.sort(compareConsumers);
|
|
3498
|
+
}
|
|
3499
|
+
function compareConsumers(a, b) {
|
|
3500
|
+
if (b.consumers.length !== a.consumers.length) {
|
|
3501
|
+
return b.consumers.length - a.consumers.length;
|
|
3502
|
+
}
|
|
3503
|
+
return a.symbolId < b.symbolId ? -1 : a.symbolId > b.symbolId ? 1 : 0;
|
|
3504
|
+
}
|
|
3505
|
+
function computeSymbolCoupling(edges, options = {}) {
|
|
3506
|
+
const minCoImports = options.minCoImports ?? DEFAULT_MIN_CO_IMPORTS;
|
|
3507
|
+
const largeThreshold = options.largeImporterThreshold ?? DEFAULT_LARGE_IMPORTER_THRESHOLD;
|
|
3508
|
+
const bySource = groupSymbolsBySource(edges);
|
|
3509
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3510
|
+
for (const symbols of bySource.values()) {
|
|
3511
|
+
if (symbols.length < 2 || symbols.length > largeThreshold) continue;
|
|
3512
|
+
accumulateSymbolPairs(symbols, counts);
|
|
3513
|
+
}
|
|
3514
|
+
return finalizePairs(counts, minCoImports);
|
|
3515
|
+
}
|
|
3516
|
+
function groupSymbolsBySource(edges) {
|
|
3517
|
+
const perSource = /* @__PURE__ */ new Map();
|
|
3518
|
+
for (const e of edges) {
|
|
3519
|
+
if (!parseSymbolId(e.dstId)) continue;
|
|
3520
|
+
let set = perSource.get(e.srcId);
|
|
3521
|
+
if (!set) {
|
|
3522
|
+
set = /* @__PURE__ */ new Set();
|
|
3523
|
+
perSource.set(e.srcId, set);
|
|
3524
|
+
}
|
|
3525
|
+
set.add(e.dstId);
|
|
3526
|
+
}
|
|
3527
|
+
const out = /* @__PURE__ */ new Map();
|
|
3528
|
+
for (const [src, set] of perSource) out.set(src, [...set].sort());
|
|
3529
|
+
return out;
|
|
3530
|
+
}
|
|
3531
|
+
function accumulateSymbolPairs(symbols, counts) {
|
|
3532
|
+
for (let i = 0; i < symbols.length; i++) {
|
|
3533
|
+
for (let j = i + 1; j < symbols.length; j++) {
|
|
3534
|
+
const key = `${symbols[i]} ${symbols[j]}`;
|
|
3535
|
+
counts.set(key, (counts.get(key) ?? 0) + 1);
|
|
3536
|
+
}
|
|
3537
|
+
}
|
|
3538
|
+
}
|
|
3539
|
+
function finalizePairs(counts, minCoImports) {
|
|
3540
|
+
const out = [];
|
|
3541
|
+
for (const [key, coImports] of counts) {
|
|
3542
|
+
if (coImports < minCoImports) continue;
|
|
3543
|
+
const [aId, bId] = key.split(" ");
|
|
3544
|
+
const a = parseSymbolId(aId);
|
|
3545
|
+
const b = parseSymbolId(bId);
|
|
3546
|
+
out.push({
|
|
3547
|
+
aId,
|
|
3548
|
+
aFile: a.fileId,
|
|
3549
|
+
aName: a.name,
|
|
3550
|
+
bId,
|
|
3551
|
+
bFile: b.fileId,
|
|
3552
|
+
bName: b.name,
|
|
3553
|
+
coImports,
|
|
3554
|
+
crossFile: a.fileId !== b.fileId
|
|
3555
|
+
});
|
|
3556
|
+
}
|
|
3557
|
+
return out.sort(comparePairs);
|
|
3558
|
+
}
|
|
3559
|
+
function comparePairs(a, b) {
|
|
3560
|
+
if (b.coImports !== a.coImports) return b.coImports - a.coImports;
|
|
3561
|
+
if (a.crossFile !== b.crossFile) return a.crossFile ? -1 : 1;
|
|
3562
|
+
if (a.aId !== b.aId) return a.aId < b.aId ? -1 : 1;
|
|
3563
|
+
return a.bId < b.bId ? -1 : a.bId > b.bId ? 1 : 0;
|
|
3564
|
+
}
|
|
3565
|
+
|
|
3566
|
+
// src/analysis/partition-quality.ts
|
|
3567
|
+
var FOUNDATION_INSTABILITY_MAX = 0.3;
|
|
3568
|
+
var TOP_INSTABILITY_MIN = 0.9;
|
|
3569
|
+
var WEAK_COHESION_MAX = 0.5;
|
|
3570
|
+
var TIGHT_INTENSITY_MIN = 0.6;
|
|
3571
|
+
var MODERATE_INTENSITY_MIN = 0.3;
|
|
3572
|
+
function computePartitionQuality(input) {
|
|
3573
|
+
const resolveBarrels = input.resolveBarrels ?? false;
|
|
3574
|
+
const pkgByFile = invertBuckets(input.fileByPackage);
|
|
3575
|
+
const barrelTargets = resolveBarrels ? buildBarrelTargetMap(input.nodes, input.edges) : /* @__PURE__ */ new Map();
|
|
3576
|
+
const resolvedPairs = buildResolvedPairs(
|
|
3577
|
+
input.edges,
|
|
3578
|
+
pkgByFile,
|
|
3579
|
+
barrelTargets,
|
|
3580
|
+
resolveBarrels
|
|
3581
|
+
);
|
|
3582
|
+
const counts = countByPackage(resolvedPairs);
|
|
3583
|
+
const totalEdges = resolvedPairs.length;
|
|
3584
|
+
const filesByPkg = filesPerPackage(input.fileByPackage);
|
|
3585
|
+
const abstractByPkg = countAbstractFiles(input.nodes, pkgByFile);
|
|
3586
|
+
const perPackage = input.packages.map(
|
|
3587
|
+
(p) => buildPackageStats(p.id, counts, filesByPkg, abstractByPkg)
|
|
3588
|
+
);
|
|
3589
|
+
const pairCoupling = buildPairCoupling(resolvedPairs, filesByPkg);
|
|
3590
|
+
const modularityQ = computeModularity(perPackage, totalEdges);
|
|
3591
|
+
const flagsCount = perPackage.reduce((acc, p) => acc + p.flags.length, 0) + pairCoupling.filter((c) => c.flag === "tight").length;
|
|
3592
|
+
return { modularityQ, totalEdges, perPackage, pairCoupling, flagsCount };
|
|
3593
|
+
}
|
|
3594
|
+
function invertBuckets(fileByPackage) {
|
|
3595
|
+
const out = /* @__PURE__ */ new Map();
|
|
3596
|
+
for (const [pkgId, files] of fileByPackage) {
|
|
3597
|
+
if (pkgId === "") continue;
|
|
3598
|
+
for (const f of files) out.set(f, pkgId);
|
|
3599
|
+
}
|
|
3600
|
+
return out;
|
|
3601
|
+
}
|
|
3602
|
+
function filesPerPackage(fileByPackage) {
|
|
3603
|
+
const out = /* @__PURE__ */ new Map();
|
|
3604
|
+
for (const [pkgId, files] of fileByPackage) {
|
|
3605
|
+
out.set(pkgId, files.length);
|
|
3606
|
+
}
|
|
3607
|
+
return out;
|
|
3608
|
+
}
|
|
3609
|
+
function buildBarrelTargetMap(nodes, edges) {
|
|
3610
|
+
const barrelIds = new Set(
|
|
3611
|
+
nodes.filter((n) => n.role === "barrel").map((n) => n.id)
|
|
3612
|
+
);
|
|
3613
|
+
if (barrelIds.size === 0) return /* @__PURE__ */ new Map();
|
|
3614
|
+
const directReExports = /* @__PURE__ */ new Map();
|
|
3615
|
+
for (const e of edges) {
|
|
3616
|
+
if (!barrelIds.has(e.srcId)) continue;
|
|
3617
|
+
if (e.kind !== "re-exports") continue;
|
|
3618
|
+
let list = directReExports.get(e.srcId);
|
|
3619
|
+
if (!list) {
|
|
3620
|
+
list = [];
|
|
3621
|
+
directReExports.set(e.srcId, list);
|
|
3622
|
+
}
|
|
3623
|
+
list.push(e.dstId);
|
|
3624
|
+
}
|
|
3625
|
+
const resolved = /* @__PURE__ */ new Map();
|
|
3626
|
+
const resolve4 = (barrel, visiting) => {
|
|
3627
|
+
if (resolved.has(barrel)) return resolved.get(barrel);
|
|
3628
|
+
if (visiting.has(barrel)) return [];
|
|
3629
|
+
visiting.add(barrel);
|
|
3630
|
+
const out = [];
|
|
3631
|
+
for (const target of directReExports.get(barrel) ?? []) {
|
|
3632
|
+
if (barrelIds.has(target)) {
|
|
3633
|
+
for (const inner of resolve4(target, visiting)) out.push(inner);
|
|
3634
|
+
} else {
|
|
3635
|
+
out.push(target);
|
|
3636
|
+
}
|
|
3637
|
+
}
|
|
3638
|
+
visiting.delete(barrel);
|
|
3639
|
+
resolved.set(barrel, out);
|
|
3640
|
+
return out;
|
|
3641
|
+
};
|
|
3642
|
+
for (const id of barrelIds) resolve4(id, /* @__PURE__ */ new Set());
|
|
3643
|
+
return resolved;
|
|
3644
|
+
}
|
|
3645
|
+
function buildResolvedPairs(edges, pkgByFile, barrelTargets, resolveBarrels) {
|
|
3646
|
+
const out = [];
|
|
3647
|
+
for (const e of edges) {
|
|
3648
|
+
if (resolveBarrels && e.kind === "re-exports") continue;
|
|
3649
|
+
const fromPkg = pkgByFile.get(e.srcId);
|
|
3650
|
+
if (!fromPkg) continue;
|
|
3651
|
+
const targets = barrelTargets.get(e.dstId) ?? [e.dstId];
|
|
3652
|
+
for (const dst of targets) {
|
|
3653
|
+
const toPkg = pkgByFile.get(dst);
|
|
3654
|
+
if (toPkg) out.push({ from: fromPkg, to: toPkg });
|
|
3655
|
+
}
|
|
3656
|
+
}
|
|
3657
|
+
return out;
|
|
3658
|
+
}
|
|
3659
|
+
function countByPackage(pairs) {
|
|
3660
|
+
const out = /* @__PURE__ */ new Map();
|
|
3661
|
+
const get = (id) => {
|
|
3662
|
+
let c = out.get(id);
|
|
3663
|
+
if (!c) {
|
|
3664
|
+
c = { internal: 0, outgoing: 0, incoming: 0 };
|
|
3665
|
+
out.set(id, c);
|
|
3666
|
+
}
|
|
3667
|
+
return c;
|
|
3668
|
+
};
|
|
3669
|
+
for (const p of pairs) {
|
|
3670
|
+
if (p.from === p.to) {
|
|
3671
|
+
get(p.from).internal += 1;
|
|
3672
|
+
} else {
|
|
3673
|
+
get(p.from).outgoing += 1;
|
|
3674
|
+
get(p.to).incoming += 1;
|
|
3675
|
+
}
|
|
3676
|
+
}
|
|
3677
|
+
return out;
|
|
3678
|
+
}
|
|
3679
|
+
function buildPackageStats(pkgId, counts, filesByPkg, abstractByPkg) {
|
|
3680
|
+
const c = counts.get(pkgId) ?? { internal: 0, outgoing: 0, incoming: 0 };
|
|
3681
|
+
const cohesion = ratioOrZero(c.internal, c.internal + c.outgoing);
|
|
3682
|
+
const instability = ratioOrZero(c.outgoing, c.outgoing + c.incoming);
|
|
3683
|
+
const fileCount = filesByPkg.get(pkgId) ?? 0;
|
|
3684
|
+
const abstractness = ratioOrZero(abstractByPkg.get(pkgId) ?? 0, fileCount);
|
|
3685
|
+
const layer = classifyLayer(instability);
|
|
3686
|
+
const flags = [];
|
|
3687
|
+
if (layer !== "top" && cohesion < WEAK_COHESION_MAX && c.internal + c.outgoing > 0) {
|
|
3688
|
+
flags.push("weak-boundary");
|
|
3689
|
+
}
|
|
3690
|
+
return {
|
|
3691
|
+
pkgId,
|
|
3692
|
+
fileCount,
|
|
3693
|
+
internalEdges: c.internal,
|
|
3694
|
+
outgoingEdges: c.outgoing,
|
|
3695
|
+
incomingEdges: c.incoming,
|
|
3696
|
+
cohesion,
|
|
3697
|
+
instability,
|
|
3698
|
+
abstractness,
|
|
3699
|
+
layer,
|
|
3700
|
+
flags
|
|
3701
|
+
};
|
|
3702
|
+
}
|
|
3703
|
+
function countAbstractFiles(nodes, pkgByFile) {
|
|
3704
|
+
const out = /* @__PURE__ */ new Map();
|
|
3705
|
+
for (const n of nodes) {
|
|
3706
|
+
if (n.kind !== "file" || n.role !== "types") continue;
|
|
3707
|
+
const pkg = pkgByFile.get(n.id);
|
|
3708
|
+
if (pkg) out.set(pkg, (out.get(pkg) ?? 0) + 1);
|
|
3709
|
+
}
|
|
3710
|
+
return out;
|
|
3711
|
+
}
|
|
3712
|
+
function ratioOrZero(num, denom) {
|
|
3713
|
+
return denom === 0 ? 0 : num / denom;
|
|
3714
|
+
}
|
|
3715
|
+
function classifyLayer(instability) {
|
|
3716
|
+
if (instability <= FOUNDATION_INSTABILITY_MAX) return "foundation";
|
|
3717
|
+
if (instability >= TOP_INSTABILITY_MIN) return "top";
|
|
3718
|
+
return "middle";
|
|
3719
|
+
}
|
|
3720
|
+
function buildPairCoupling(pairs, filesByPkg) {
|
|
3721
|
+
const counts = /* @__PURE__ */ new Map();
|
|
3722
|
+
for (const p of pairs) {
|
|
3723
|
+
if (p.from === p.to) continue;
|
|
3724
|
+
const key = JSON.stringify([p.from, p.to]);
|
|
3725
|
+
const entry = counts.get(key);
|
|
3726
|
+
if (entry) entry.edges += 1;
|
|
3727
|
+
else counts.set(key, { from: p.from, to: p.to, edges: 1 });
|
|
3728
|
+
}
|
|
3729
|
+
const out = [];
|
|
3730
|
+
for (const { from, to, edges } of counts.values()) {
|
|
3731
|
+
const filesFrom = filesByPkg.get(from) ?? 0;
|
|
3732
|
+
const intensity = filesFrom === 0 ? 0 : edges / filesFrom;
|
|
3733
|
+
const flag = classifyPairIntensity(intensity);
|
|
3734
|
+
out.push({ from, to, edges, intensity, flag });
|
|
3735
|
+
}
|
|
3736
|
+
return out.sort(comparePairs2);
|
|
3737
|
+
}
|
|
3738
|
+
function classifyPairIntensity(intensity) {
|
|
3739
|
+
if (intensity >= TIGHT_INTENSITY_MIN) return "tight";
|
|
3740
|
+
if (intensity >= MODERATE_INTENSITY_MIN) return "moderate";
|
|
3741
|
+
return "none";
|
|
3742
|
+
}
|
|
3743
|
+
function comparePairs2(a, b) {
|
|
3744
|
+
if (a.from !== b.from) return a.from < b.from ? -1 : 1;
|
|
3745
|
+
return a.to < b.to ? -1 : 1;
|
|
3746
|
+
}
|
|
3747
|
+
function computeModularity(perPackage, totalEdges) {
|
|
3748
|
+
if (totalEdges === 0) return 0;
|
|
3749
|
+
let Q = 0;
|
|
3750
|
+
for (const p of perPackage) {
|
|
3751
|
+
const e_cc = p.internalEdges / totalEdges;
|
|
3752
|
+
const touching = p.internalEdges * 2 + p.outgoingEdges + p.incomingEdges;
|
|
3753
|
+
const a_c = touching / (2 * totalEdges);
|
|
3754
|
+
Q += e_cc - a_c * a_c;
|
|
3755
|
+
}
|
|
3756
|
+
return Q;
|
|
3757
|
+
}
|
|
3758
|
+
|
|
3759
|
+
// src/analysis/snapshot.ts
|
|
3760
|
+
function snapshotPageRank(store, snapshotId, options = {}) {
|
|
3761
|
+
return computePageRank(store.listNodes(snapshotId), store.listEdges(snapshotId), options);
|
|
3762
|
+
}
|
|
3763
|
+
function snapshotRelevance(store, snapshotId, seedIds, options = {}) {
|
|
3764
|
+
return computeRelevance(store.listNodes(snapshotId), store.listEdges(snapshotId), seedIds, options);
|
|
3765
|
+
}
|
|
3766
|
+
function snapshotReferenceEdges(store, snapshotId) {
|
|
3767
|
+
return store.listEdges(snapshotId, { includeReferences: true }).filter((e) => e.kind === "references").map((e) => ({ srcId: e.srcId, dstId: e.dstId }));
|
|
3768
|
+
}
|
|
3769
|
+
function snapshotSymbolConsumers(store, snapshotId) {
|
|
3770
|
+
return computeSymbolConsumers(snapshotReferenceEdges(store, snapshotId));
|
|
3771
|
+
}
|
|
3772
|
+
function snapshotSymbolCoupling(store, snapshotId, options = {}) {
|
|
3773
|
+
return computeSymbolCoupling(snapshotReferenceEdges(store, snapshotId), options);
|
|
3774
|
+
}
|
|
3775
|
+
|
|
3776
|
+
// src/read.ts
|
|
3777
|
+
function listNodes(store, snapshotId, opts) {
|
|
3778
|
+
return store.listNodes(snapshotId, opts);
|
|
3779
|
+
}
|
|
3780
|
+
function listEdges(store, snapshotId, opts) {
|
|
3781
|
+
return store.listEdges(snapshotId, opts);
|
|
3782
|
+
}
|
|
3783
|
+
function listMetrics(store, snapshotId) {
|
|
3784
|
+
return store.listMetrics(snapshotId);
|
|
3785
|
+
}
|
|
3786
|
+
function listMetricsForNode(store, snapshotId, nodeId) {
|
|
3787
|
+
return store.listMetricsForNode(snapshotId, nodeId);
|
|
3788
|
+
}
|
|
3789
|
+
function listEdgesTouching(store, snapshotId, nodeId, opts) {
|
|
3790
|
+
return store.listEdgesTouching(snapshotId, nodeId, opts);
|
|
3791
|
+
}
|
|
3792
|
+
function aggregateMetrics(store, snapshotId, opts) {
|
|
3793
|
+
return store.aggregateMetrics(snapshotId, opts);
|
|
3794
|
+
}
|
|
3795
|
+
|
|
3796
|
+
// src/catalogue/entries.ts
|
|
3797
|
+
var FILE = ["file"];
|
|
3798
|
+
var SYMBOL = ["symbol"];
|
|
3799
|
+
var STRUCTURAL = ["file", "module", "external"];
|
|
3800
|
+
var DEGREE = [
|
|
3801
|
+
{
|
|
3802
|
+
name: "fan_in",
|
|
3803
|
+
unit: "count",
|
|
3804
|
+
appliesTo: STRUCTURAL,
|
|
3805
|
+
rollup: "none",
|
|
3806
|
+
direction: "neutral",
|
|
3807
|
+
absent: "zero",
|
|
3808
|
+
source: "degree",
|
|
3809
|
+
description: "Import and re-export edges into the node. Summing over a group would count its internal edges."
|
|
3810
|
+
},
|
|
3811
|
+
{
|
|
3812
|
+
name: "fan_out",
|
|
3813
|
+
unit: "count",
|
|
3814
|
+
appliesTo: STRUCTURAL,
|
|
3815
|
+
rollup: "none",
|
|
3816
|
+
direction: "higher-worse",
|
|
3817
|
+
absent: "zero",
|
|
3818
|
+
source: "degree",
|
|
3819
|
+
description: "Import and re-export edges out of the node. Summing over a group would count its internal edges."
|
|
3820
|
+
},
|
|
3821
|
+
{
|
|
3822
|
+
name: "instability",
|
|
3823
|
+
unit: "ratio",
|
|
3824
|
+
appliesTo: STRUCTURAL,
|
|
3825
|
+
rollup: "none",
|
|
3826
|
+
direction: "neutral",
|
|
3827
|
+
absent: "exclude",
|
|
3828
|
+
source: "degree",
|
|
3829
|
+
description: "fan_out / (fan_in + fan_out); written only when the node has an import edge."
|
|
3830
|
+
},
|
|
3831
|
+
{
|
|
3832
|
+
name: "utilization",
|
|
3833
|
+
unit: "count",
|
|
3834
|
+
appliesTo: [...STRUCTURAL, "symbol"],
|
|
3835
|
+
rollup: "none",
|
|
3836
|
+
direction: "neutral",
|
|
3837
|
+
absent: "zero",
|
|
3838
|
+
source: "degree",
|
|
3839
|
+
description: "Inbound edges weighted by reference count over the barrel-resolved graph: how heavily a node is used."
|
|
3840
|
+
}
|
|
3841
|
+
];
|
|
3842
|
+
var SOURCE = [
|
|
3843
|
+
{
|
|
3844
|
+
name: "loc",
|
|
3845
|
+
unit: "lines",
|
|
3846
|
+
appliesTo: FILE,
|
|
3847
|
+
rollup: "sum",
|
|
3848
|
+
direction: "higher-worse",
|
|
3849
|
+
absent: "zero",
|
|
3850
|
+
source: "source-metrics",
|
|
3851
|
+
description: "Non-blank lines in the file."
|
|
3852
|
+
},
|
|
3853
|
+
{
|
|
3854
|
+
name: "function_count",
|
|
3855
|
+
unit: "count",
|
|
3856
|
+
appliesTo: FILE,
|
|
3857
|
+
rollup: "sum",
|
|
3858
|
+
direction: "neutral",
|
|
3859
|
+
absent: "zero",
|
|
3860
|
+
source: "source-metrics",
|
|
3861
|
+
description: "Functions, methods, and arrow functions declared in the file."
|
|
3862
|
+
},
|
|
3863
|
+
{
|
|
3864
|
+
name: "cyclomatic_max",
|
|
3865
|
+
unit: "count",
|
|
3866
|
+
appliesTo: FILE,
|
|
3867
|
+
rollup: "max",
|
|
3868
|
+
direction: "higher-worse",
|
|
3869
|
+
absent: "exclude",
|
|
3870
|
+
source: "source-metrics",
|
|
3871
|
+
description: "Highest cyclomatic complexity of any function in the file."
|
|
3872
|
+
},
|
|
3873
|
+
{
|
|
3874
|
+
name: "cyclomatic_sum",
|
|
3875
|
+
unit: "count",
|
|
3876
|
+
appliesTo: FILE,
|
|
3877
|
+
rollup: "sum",
|
|
3878
|
+
direction: "higher-worse",
|
|
3879
|
+
absent: "zero",
|
|
3880
|
+
source: "source-metrics",
|
|
3881
|
+
description: "Cyclomatic complexity summed over the file's functions."
|
|
3882
|
+
},
|
|
3883
|
+
{
|
|
3884
|
+
name: "cognitive_max",
|
|
3885
|
+
unit: "count",
|
|
3886
|
+
appliesTo: FILE,
|
|
3887
|
+
rollup: "max",
|
|
3888
|
+
direction: "higher-worse",
|
|
3889
|
+
absent: "exclude",
|
|
3890
|
+
source: "source-metrics",
|
|
3891
|
+
description: "Highest cognitive complexity of any function in the file."
|
|
3892
|
+
},
|
|
3893
|
+
{
|
|
3894
|
+
name: "cognitive_sum",
|
|
3895
|
+
unit: "count",
|
|
3896
|
+
appliesTo: FILE,
|
|
3897
|
+
rollup: "sum",
|
|
3898
|
+
direction: "higher-worse",
|
|
3899
|
+
absent: "zero",
|
|
3900
|
+
source: "source-metrics",
|
|
3901
|
+
description: "Cognitive complexity summed over the file's functions."
|
|
3902
|
+
},
|
|
3903
|
+
{
|
|
3904
|
+
name: "max_nesting_depth",
|
|
3905
|
+
unit: "count",
|
|
3906
|
+
appliesTo: FILE,
|
|
3907
|
+
rollup: "max",
|
|
3908
|
+
direction: "higher-worse",
|
|
3909
|
+
absent: "exclude",
|
|
3910
|
+
source: "source-metrics",
|
|
3911
|
+
description: "Deepest block nesting inside any function in the file."
|
|
3912
|
+
},
|
|
3913
|
+
{
|
|
3914
|
+
name: "symbol_cognitive",
|
|
3915
|
+
unit: "count",
|
|
3916
|
+
appliesTo: SYMBOL,
|
|
3917
|
+
rollup: "max",
|
|
3918
|
+
direction: "higher-worse",
|
|
3919
|
+
absent: "exclude",
|
|
3920
|
+
source: "source-metrics",
|
|
3921
|
+
description: "Cognitive complexity of the function a symbol names; the max when several functions share the name."
|
|
3922
|
+
},
|
|
3923
|
+
{
|
|
3924
|
+
name: "symbol_cyclomatic",
|
|
3925
|
+
unit: "count",
|
|
3926
|
+
appliesTo: SYMBOL,
|
|
3927
|
+
rollup: "max",
|
|
3928
|
+
direction: "higher-worse",
|
|
3929
|
+
absent: "exclude",
|
|
3930
|
+
source: "source-metrics",
|
|
3931
|
+
description: "Cyclomatic complexity of the function a symbol names; the max when several functions share the name."
|
|
3932
|
+
},
|
|
3933
|
+
{
|
|
3934
|
+
name: "class_count",
|
|
3935
|
+
unit: "count",
|
|
3936
|
+
appliesTo: FILE,
|
|
3937
|
+
rollup: "sum",
|
|
3938
|
+
direction: "neutral",
|
|
3939
|
+
absent: "zero",
|
|
3940
|
+
source: "lcom",
|
|
3941
|
+
description: "Classes declared in the file; written only when there is one."
|
|
3942
|
+
},
|
|
3943
|
+
{
|
|
3944
|
+
name: "lcom4_max",
|
|
3945
|
+
unit: "count",
|
|
3946
|
+
appliesTo: FILE,
|
|
3947
|
+
rollup: "max",
|
|
3948
|
+
direction: "higher-worse",
|
|
3949
|
+
absent: "exclude",
|
|
3950
|
+
source: "lcom",
|
|
3951
|
+
description: "Highest LCOM4 of any class in the file; above 1 means the class could split."
|
|
3952
|
+
}
|
|
3953
|
+
];
|
|
3954
|
+
var SMELLS = [
|
|
3955
|
+
{
|
|
3956
|
+
name: "unreachable_statements",
|
|
3957
|
+
unit: "count",
|
|
3958
|
+
appliesTo: FILE,
|
|
3959
|
+
rollup: "sum",
|
|
3960
|
+
direction: "higher-worse",
|
|
3961
|
+
absent: "zero",
|
|
3962
|
+
source: "dead-code",
|
|
3963
|
+
description: "Statements after an unconditional return, throw, break, or continue. TypeScript only."
|
|
3964
|
+
},
|
|
3965
|
+
{
|
|
3966
|
+
name: "unused_locals",
|
|
3967
|
+
unit: "count",
|
|
3968
|
+
appliesTo: FILE,
|
|
3969
|
+
rollup: "sum",
|
|
3970
|
+
direction: "higher-worse",
|
|
3971
|
+
absent: "zero",
|
|
3972
|
+
source: "dead-code",
|
|
3973
|
+
description: "Local bindings never read. TypeScript only."
|
|
3974
|
+
},
|
|
3975
|
+
{
|
|
3976
|
+
name: "unused_params",
|
|
3977
|
+
unit: "count",
|
|
3978
|
+
appliesTo: FILE,
|
|
3979
|
+
rollup: "sum",
|
|
3980
|
+
direction: "higher-worse",
|
|
3981
|
+
absent: "zero",
|
|
3982
|
+
source: "dead-code",
|
|
3983
|
+
description: "Trailing parameters never read. TypeScript only."
|
|
3984
|
+
},
|
|
3985
|
+
{
|
|
3986
|
+
name: "loop_depth",
|
|
3987
|
+
unit: "count",
|
|
3988
|
+
appliesTo: FILE,
|
|
3989
|
+
rollup: "max",
|
|
3990
|
+
direction: "higher-worse",
|
|
3991
|
+
absent: "zero",
|
|
3992
|
+
source: "growth-risk",
|
|
3993
|
+
description: "Deepest lexical loop nesting; written at 2 or more."
|
|
3994
|
+
},
|
|
3995
|
+
{
|
|
3996
|
+
name: "recursive_functions",
|
|
3997
|
+
unit: "count",
|
|
3998
|
+
appliesTo: FILE,
|
|
3999
|
+
rollup: "sum",
|
|
4000
|
+
direction: "higher-worse",
|
|
4001
|
+
absent: "zero",
|
|
4002
|
+
source: "growth-risk",
|
|
4003
|
+
description: "Functions that call themselves by name. TypeScript only."
|
|
4004
|
+
},
|
|
4005
|
+
{
|
|
4006
|
+
name: "search_in_loop",
|
|
4007
|
+
unit: "count",
|
|
4008
|
+
appliesTo: FILE,
|
|
4009
|
+
rollup: "sum",
|
|
4010
|
+
direction: "higher-worse",
|
|
4011
|
+
absent: "zero",
|
|
4012
|
+
source: "growth-risk",
|
|
4013
|
+
description: "Linear searches such as .includes or .find inside a loop. TypeScript only."
|
|
4014
|
+
}
|
|
4015
|
+
];
|
|
4016
|
+
var HISTORY = [
|
|
4017
|
+
{
|
|
4018
|
+
name: "churn_{w}",
|
|
4019
|
+
unit: "lines",
|
|
4020
|
+
appliesTo: FILE,
|
|
4021
|
+
rollup: "sum",
|
|
4022
|
+
direction: "neutral",
|
|
4023
|
+
absent: "zero",
|
|
4024
|
+
source: "history",
|
|
4025
|
+
windowed: true,
|
|
4026
|
+
description: "Lines added plus deleted in the {w} window."
|
|
4027
|
+
},
|
|
4028
|
+
{
|
|
4029
|
+
name: "churn_{w}_commits",
|
|
4030
|
+
unit: "count",
|
|
4031
|
+
appliesTo: FILE,
|
|
4032
|
+
rollup: "none",
|
|
4033
|
+
direction: "neutral",
|
|
4034
|
+
absent: "zero",
|
|
4035
|
+
source: "history",
|
|
4036
|
+
windowed: true,
|
|
4037
|
+
description: "Commits touching the file in the {w} window. A group's distinct commits are not the sum."
|
|
4038
|
+
},
|
|
4039
|
+
{
|
|
4040
|
+
name: "churn_{w}_authors",
|
|
4041
|
+
unit: "count",
|
|
4042
|
+
appliesTo: FILE,
|
|
4043
|
+
rollup: "none",
|
|
4044
|
+
direction: "neutral",
|
|
4045
|
+
absent: "zero",
|
|
4046
|
+
source: "history",
|
|
4047
|
+
windowed: true,
|
|
4048
|
+
description: "Distinct authors of the file's commits in the {w} window. Not additive."
|
|
4049
|
+
},
|
|
4050
|
+
{
|
|
4051
|
+
name: "recency_{w}",
|
|
4052
|
+
unit: "ratio",
|
|
4053
|
+
appliesTo: FILE,
|
|
4054
|
+
rollup: "none",
|
|
4055
|
+
direction: "neutral",
|
|
4056
|
+
absent: "exclude",
|
|
4057
|
+
source: "history",
|
|
4058
|
+
windowed: true,
|
|
4059
|
+
description: "min(1, file age / window) for a file that churned in the {w} window; 1 for lifetime. A hotspot discount."
|
|
4060
|
+
},
|
|
4061
|
+
{
|
|
4062
|
+
name: "file_age_days",
|
|
4063
|
+
unit: "days",
|
|
4064
|
+
appliesTo: FILE,
|
|
4065
|
+
rollup: "max",
|
|
4066
|
+
direction: "neutral",
|
|
4067
|
+
absent: "exclude",
|
|
4068
|
+
source: "history",
|
|
4069
|
+
description: "Days since the file first appeared in git history."
|
|
4070
|
+
},
|
|
4071
|
+
{
|
|
4072
|
+
name: "bus_factor_{w}",
|
|
4073
|
+
unit: "count",
|
|
4074
|
+
appliesTo: FILE,
|
|
4075
|
+
rollup: "none",
|
|
4076
|
+
direction: "lower-worse",
|
|
4077
|
+
absent: "exclude",
|
|
4078
|
+
source: "history",
|
|
4079
|
+
windowed: true,
|
|
4080
|
+
description: "Fewest authors covering half the file's churn in the {w} window."
|
|
4081
|
+
},
|
|
4082
|
+
{
|
|
4083
|
+
name: "top_author_share_{w}",
|
|
4084
|
+
unit: "ratio",
|
|
4085
|
+
appliesTo: FILE,
|
|
4086
|
+
rollup: "none",
|
|
4087
|
+
direction: "higher-worse",
|
|
4088
|
+
absent: "exclude",
|
|
4089
|
+
source: "history",
|
|
4090
|
+
windowed: true,
|
|
4091
|
+
description: "Share of the file's churn in the {w} window written by its top author."
|
|
4092
|
+
}
|
|
4093
|
+
];
|
|
4094
|
+
var TESTS = [
|
|
4095
|
+
{
|
|
4096
|
+
name: "linked_test_count",
|
|
4097
|
+
unit: "count",
|
|
4098
|
+
appliesTo: FILE,
|
|
4099
|
+
rollup: "none",
|
|
4100
|
+
direction: "lower-worse",
|
|
4101
|
+
absent: "zero",
|
|
4102
|
+
source: "test-linker",
|
|
4103
|
+
description: "Test files linked to this source. One test can link to several sources, so it does not sum."
|
|
4104
|
+
},
|
|
4105
|
+
{
|
|
4106
|
+
name: "test_bus_factor_{w}",
|
|
4107
|
+
unit: "count",
|
|
4108
|
+
appliesTo: FILE,
|
|
4109
|
+
rollup: "none",
|
|
4110
|
+
direction: "lower-worse",
|
|
4111
|
+
absent: "exclude",
|
|
4112
|
+
source: "test-linker",
|
|
4113
|
+
windowed: true,
|
|
4114
|
+
description: "Bus factor of the churn on this source's linked tests in the {w} window."
|
|
4115
|
+
},
|
|
4116
|
+
{
|
|
4117
|
+
name: "test_top_author_share_{w}",
|
|
4118
|
+
unit: "ratio",
|
|
4119
|
+
appliesTo: FILE,
|
|
4120
|
+
rollup: "none",
|
|
4121
|
+
direction: "higher-worse",
|
|
4122
|
+
absent: "exclude",
|
|
4123
|
+
source: "test-linker",
|
|
4124
|
+
windowed: true,
|
|
4125
|
+
description: "Top author's share of the churn on this source's linked tests in the {w} window."
|
|
4126
|
+
},
|
|
4127
|
+
{
|
|
4128
|
+
name: "coverage_pct",
|
|
4129
|
+
unit: "percent",
|
|
4130
|
+
appliesTo: ["file", "symbol"],
|
|
4131
|
+
rollup: "none",
|
|
4132
|
+
direction: "lower-worse",
|
|
4133
|
+
absent: "exclude",
|
|
4134
|
+
source: "coverage",
|
|
4135
|
+
description: "Functions covered over functions total, from an ingested Istanbul report. Never written by the indexer."
|
|
4136
|
+
}
|
|
4137
|
+
];
|
|
4138
|
+
var METRIC_CATALOGUE = [...DEGREE, ...SOURCE, ...SMELLS, ...HISTORY, ...TESTS];
|
|
4139
|
+
|
|
4140
|
+
// src/catalogue/describe.ts
|
|
4141
|
+
var WINDOW_TOKEN = "{w}";
|
|
4142
|
+
var WINDOW_PATTERN = "(\\d+d|lifetime)";
|
|
4143
|
+
var BY_NAME = new Map(METRIC_CATALOGUE.filter((d) => !d.windowed).map((d) => [d.name, d]));
|
|
4144
|
+
var WINDOWED = METRIC_CATALOGUE.filter((d) => d.windowed).map((template) => ({
|
|
4145
|
+
pattern: new RegExp(`^${template.name.replace(WINDOW_TOKEN, WINDOW_PATTERN)}$`),
|
|
4146
|
+
template
|
|
4147
|
+
}));
|
|
4148
|
+
function resolveWindow(template, window) {
|
|
4149
|
+
const resolved = {
|
|
4150
|
+
...template,
|
|
4151
|
+
name: template.name.replace(WINDOW_TOKEN, window),
|
|
4152
|
+
description: template.description.replace(WINDOW_TOKEN, window),
|
|
4153
|
+
window
|
|
4154
|
+
};
|
|
4155
|
+
delete resolved.windowed;
|
|
4156
|
+
return resolved;
|
|
4157
|
+
}
|
|
4158
|
+
function describeMetric(name) {
|
|
4159
|
+
const exact = BY_NAME.get(name);
|
|
4160
|
+
if (exact) return exact;
|
|
4161
|
+
for (const { pattern, template } of WINDOWED) {
|
|
4162
|
+
const match = pattern.exec(name);
|
|
4163
|
+
if (match) return resolveWindow(template, match[1]);
|
|
4164
|
+
}
|
|
4165
|
+
return null;
|
|
4166
|
+
}
|
|
4167
|
+
function describeMetrics(names) {
|
|
4168
|
+
const described = [];
|
|
4169
|
+
const unknown = [];
|
|
4170
|
+
for (const name of new Set(names)) {
|
|
4171
|
+
const descriptor = describeMetric(name);
|
|
4172
|
+
if (descriptor) described.push(descriptor);
|
|
4173
|
+
else unknown.push(name);
|
|
4174
|
+
}
|
|
4175
|
+
return { described, unknown };
|
|
4176
|
+
}
|
|
4177
|
+
|
|
4178
|
+
// src/check/context.ts
|
|
4179
|
+
function buildRuleContext(store, snapshotId) {
|
|
4180
|
+
const nodes = store.listNodes(snapshotId);
|
|
4181
|
+
const edges = store.listEdges(snapshotId);
|
|
4182
|
+
const nodesById = /* @__PURE__ */ new Map();
|
|
4183
|
+
for (const n of nodes) nodesById.set(n.id, n);
|
|
4184
|
+
return { nodes, nodesById, metricsByNode: indexMetrics(store.listMetrics(snapshotId)), edges };
|
|
4185
|
+
}
|
|
4186
|
+
function indexMetrics(metrics) {
|
|
4187
|
+
const metricsByNode = /* @__PURE__ */ new Map();
|
|
4188
|
+
for (const m of metrics) {
|
|
4189
|
+
if (m.value === null) continue;
|
|
4190
|
+
let inner = metricsByNode.get(m.nodeId);
|
|
4191
|
+
if (!inner) {
|
|
4192
|
+
inner = /* @__PURE__ */ new Map();
|
|
4193
|
+
metricsByNode.set(m.nodeId, inner);
|
|
4194
|
+
}
|
|
4195
|
+
inner.set(m.name, m.value);
|
|
4196
|
+
}
|
|
4197
|
+
return metricsByNode;
|
|
4198
|
+
}
|
|
4199
|
+
|
|
4200
|
+
// src/check/patterns.ts
|
|
4201
|
+
function patternToRegex(pattern) {
|
|
4202
|
+
if (!pattern.includes("*")) {
|
|
4203
|
+
return new RegExp(escapeRegex(pattern));
|
|
4204
|
+
}
|
|
4205
|
+
let out = "";
|
|
4206
|
+
for (let i = 0; i < pattern.length; i++) {
|
|
4207
|
+
const c = pattern[i];
|
|
4208
|
+
if (c === "*" && pattern[i + 1] === "*") {
|
|
4209
|
+
out += ".*";
|
|
4210
|
+
i++;
|
|
4211
|
+
} else if (c === "*") {
|
|
4212
|
+
out += "[^/]*";
|
|
4213
|
+
} else {
|
|
4214
|
+
out += escapeRegex(c);
|
|
4215
|
+
}
|
|
4216
|
+
}
|
|
4217
|
+
return new RegExp(`^${out}$`);
|
|
4218
|
+
}
|
|
4219
|
+
function compilePatterns(patterns) {
|
|
4220
|
+
if (!patterns) return [];
|
|
4221
|
+
return patterns.map(patternToRegex);
|
|
4222
|
+
}
|
|
4223
|
+
function matchesAny(value, patterns) {
|
|
4224
|
+
for (const rx of patterns) {
|
|
4225
|
+
if (rx.test(value)) return true;
|
|
4226
|
+
}
|
|
4227
|
+
return false;
|
|
4228
|
+
}
|
|
4229
|
+
function escapeRegex(s) {
|
|
4230
|
+
return s.replace(/[.+?(){}|^$\\[\]]/g, "\\$&");
|
|
4231
|
+
}
|
|
4232
|
+
|
|
4233
|
+
// src/check/rule-helpers.ts
|
|
4234
|
+
function severityOf(rule) {
|
|
4235
|
+
return rule.severity ?? "error";
|
|
4236
|
+
}
|
|
4237
|
+
function formatNumber(n) {
|
|
4238
|
+
if (Number.isInteger(n)) return String(n);
|
|
4239
|
+
return n.toFixed(3).replace(/\.?0+$/, "");
|
|
4240
|
+
}
|
|
4241
|
+
function isImportEdge(edge) {
|
|
4242
|
+
return edge.kind === "imports" || edge.kind === "re-exports";
|
|
4243
|
+
}
|
|
4244
|
+
function longestPrefixMatcher(prefixes) {
|
|
4245
|
+
const byLength = [...prefixes].sort((a, b) => b.length - a.length);
|
|
4246
|
+
return (id) => {
|
|
4247
|
+
for (const p of byLength) {
|
|
4248
|
+
if (id === p || id.startsWith(`${p}/`)) return p;
|
|
4249
|
+
}
|
|
4250
|
+
return null;
|
|
4251
|
+
};
|
|
4252
|
+
}
|
|
4253
|
+
|
|
4254
|
+
// src/check/import-rules.ts
|
|
4255
|
+
function layerIndex(layers) {
|
|
4256
|
+
const packageLayer = /* @__PURE__ */ new Map();
|
|
4257
|
+
for (let i = 0; i < layers.length; i++) {
|
|
4258
|
+
for (const pkg of layers[i]) packageLayer.set(pkg, i);
|
|
4259
|
+
}
|
|
4260
|
+
return packageLayer;
|
|
4261
|
+
}
|
|
4262
|
+
function runLayeredDepsRule(rule, ctx) {
|
|
4263
|
+
const packageLayer = layerIndex(rule.layers);
|
|
4264
|
+
const packageOf = longestPrefixMatcher([...packageLayer.keys()]);
|
|
4265
|
+
const out = [];
|
|
4266
|
+
for (const edge of ctx.edges) {
|
|
4267
|
+
if (!isImportEdge(edge)) continue;
|
|
4268
|
+
const srcPkg = packageOf(edge.srcId);
|
|
4269
|
+
const dstPkg = packageOf(edge.dstId);
|
|
4270
|
+
if (srcPkg === null || dstPkg === null) continue;
|
|
4271
|
+
const srcLayer = packageLayer.get(srcPkg);
|
|
4272
|
+
const dstLayer = packageLayer.get(dstPkg);
|
|
4273
|
+
if (srcLayer >= dstLayer) continue;
|
|
4274
|
+
out.push({
|
|
4275
|
+
ruleId: rule.id,
|
|
4276
|
+
severity: severityOf(rule),
|
|
4277
|
+
nodeId: edge.srcId,
|
|
4278
|
+
destinationId: edge.dstId,
|
|
4279
|
+
message: `${srcPkg} (layer ${srcLayer}) imports ${dstPkg} (layer ${dstLayer})`
|
|
4280
|
+
});
|
|
4281
|
+
}
|
|
4282
|
+
return out;
|
|
4283
|
+
}
|
|
4284
|
+
function runForbidImportRule(rule, ctx) {
|
|
4285
|
+
const fromRx = compilePatterns([rule.from]);
|
|
4286
|
+
const toRx = compilePatterns([rule.to]);
|
|
4287
|
+
const out = [];
|
|
4288
|
+
for (const edge of ctx.edges) {
|
|
4289
|
+
if (!isImportEdge(edge)) continue;
|
|
4290
|
+
if (!matchesAny(edge.srcId, fromRx)) continue;
|
|
4291
|
+
if (!matchesAny(edge.dstId, toRx)) continue;
|
|
4292
|
+
out.push({
|
|
4293
|
+
ruleId: rule.id,
|
|
4294
|
+
severity: severityOf(rule),
|
|
4295
|
+
nodeId: edge.srcId,
|
|
4296
|
+
destinationId: edge.dstId,
|
|
4297
|
+
message: `${edge.srcId} imports ${edge.dstId} (forbidden: ${rule.from} \u2192 ${rule.to})`
|
|
4298
|
+
});
|
|
4299
|
+
}
|
|
4300
|
+
return out;
|
|
4301
|
+
}
|
|
4302
|
+
function importersByDestination(edges) {
|
|
4303
|
+
const importersByDst = /* @__PURE__ */ new Map();
|
|
4304
|
+
for (const edge of edges) {
|
|
4305
|
+
if (!isImportEdge(edge)) continue;
|
|
4306
|
+
let list = importersByDst.get(edge.dstId);
|
|
4307
|
+
if (!list) {
|
|
4308
|
+
list = [];
|
|
4309
|
+
importersByDst.set(edge.dstId, list);
|
|
4310
|
+
}
|
|
4311
|
+
list.push(edge.srcId);
|
|
4312
|
+
}
|
|
4313
|
+
return importersByDst;
|
|
4314
|
+
}
|
|
4315
|
+
function runNoInternalOnlyBarrelsRule(rule, ctx) {
|
|
4316
|
+
const packageOf = longestPrefixMatcher(rule.packageRoots);
|
|
4317
|
+
const excluders = compilePatterns(rule.exclude);
|
|
4318
|
+
const importersByDst = importersByDestination(ctx.edges);
|
|
4319
|
+
const out = [];
|
|
4320
|
+
for (const node of ctx.nodes) {
|
|
4321
|
+
if (node.kind !== "file") continue;
|
|
4322
|
+
if (node.role !== "barrel") continue;
|
|
4323
|
+
if (matchesAny(node.id, excluders)) continue;
|
|
4324
|
+
const barrelPkg = packageOf(node.id);
|
|
4325
|
+
if (barrelPkg === null) continue;
|
|
4326
|
+
const importers = importersByDst.get(node.id) ?? [];
|
|
4327
|
+
const externalCount = importers.reduce((n, src) => packageOf(src) !== barrelPkg ? n + 1 : n, 0);
|
|
4328
|
+
if (externalCount > 0) continue;
|
|
4329
|
+
out.push({
|
|
4330
|
+
ruleId: rule.id,
|
|
4331
|
+
severity: severityOf(rule),
|
|
4332
|
+
nodeId: node.id,
|
|
4333
|
+
message: barrelMessage(barrelPkg, importers.length)
|
|
4334
|
+
});
|
|
4335
|
+
}
|
|
4336
|
+
return out;
|
|
4337
|
+
}
|
|
4338
|
+
function barrelMessage(barrelPkg, importerCount) {
|
|
4339
|
+
return importerCount === 0 ? `barrel in package "${barrelPkg}" has no importers` : `barrel in package "${barrelPkg}" has ${importerCount} internal importer(s) but zero external`;
|
|
4340
|
+
}
|
|
4341
|
+
|
|
4342
|
+
// src/check/metric-rules.ts
|
|
4343
|
+
function nodeFilter(rule) {
|
|
4344
|
+
return {
|
|
4345
|
+
kind: rule.kind,
|
|
4346
|
+
excluders: compilePatterns(rule.exclude),
|
|
4347
|
+
excludedRoles: new Set(rule.excludeRoles ?? [])
|
|
4348
|
+
};
|
|
4349
|
+
}
|
|
4350
|
+
function passesFilter(node, filter) {
|
|
4351
|
+
if (filter.kind && node.kind !== filter.kind) return false;
|
|
4352
|
+
if (matchesAny(node.id, filter.excluders)) return false;
|
|
4353
|
+
if (node.role && filter.excludedRoles.has(node.role)) return false;
|
|
4354
|
+
return true;
|
|
4355
|
+
}
|
|
4356
|
+
function runMetricMaxRule(rule, ctx) {
|
|
4357
|
+
const filter = nodeFilter(rule);
|
|
4358
|
+
const out = [];
|
|
4359
|
+
for (const node of ctx.nodes) {
|
|
4360
|
+
if (!passesFilter(node, filter)) continue;
|
|
4361
|
+
const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
|
|
4362
|
+
if (value === void 0) continue;
|
|
4363
|
+
if (value <= rule.max) continue;
|
|
4364
|
+
out.push({
|
|
4365
|
+
ruleId: rule.id,
|
|
4366
|
+
severity: severityOf(rule),
|
|
4367
|
+
nodeId: node.id,
|
|
4368
|
+
metric: rule.metric,
|
|
4369
|
+
value,
|
|
4370
|
+
threshold: rule.max,
|
|
4371
|
+
message: `${rule.metric}=${formatNumber(value)} > ${formatNumber(rule.max)}`
|
|
4372
|
+
});
|
|
4373
|
+
}
|
|
4374
|
+
return out;
|
|
4375
|
+
}
|
|
4376
|
+
function runMetricMinRule(rule, ctx) {
|
|
4377
|
+
const filter = nodeFilter(rule);
|
|
4378
|
+
const out = [];
|
|
4379
|
+
for (const node of ctx.nodes) {
|
|
4380
|
+
if (!passesFilter(node, filter)) continue;
|
|
4381
|
+
const value = ctx.metricsByNode.get(node.id)?.get(rule.metric);
|
|
4382
|
+
if (value === void 0) continue;
|
|
4383
|
+
if (value >= rule.min) continue;
|
|
4384
|
+
out.push({
|
|
4385
|
+
ruleId: rule.id,
|
|
4386
|
+
severity: severityOf(rule),
|
|
4387
|
+
nodeId: node.id,
|
|
4388
|
+
metric: rule.metric,
|
|
4389
|
+
value,
|
|
4390
|
+
threshold: rule.min,
|
|
4391
|
+
message: `${rule.metric}=${formatNumber(value)} < ${formatNumber(rule.min)}`
|
|
4392
|
+
});
|
|
4393
|
+
}
|
|
4394
|
+
return out;
|
|
4395
|
+
}
|
|
4396
|
+
function runMetricProductMaxRule(rule, ctx) {
|
|
4397
|
+
const filter = nodeFilter(rule);
|
|
4398
|
+
const out = [];
|
|
4399
|
+
for (const node of ctx.nodes) {
|
|
4400
|
+
if (!passesFilter(node, filter)) continue;
|
|
4401
|
+
const inner = ctx.metricsByNode.get(node.id);
|
|
4402
|
+
if (!inner) continue;
|
|
4403
|
+
const components = collectComponents(rule.metrics, inner);
|
|
4404
|
+
if (!components) continue;
|
|
4405
|
+
const product = components.reduce((a, b) => a * b, 1);
|
|
4406
|
+
if (product <= rule.max) continue;
|
|
4407
|
+
out.push(productViolation(rule, node.id, components, product));
|
|
4408
|
+
}
|
|
4409
|
+
return out;
|
|
4410
|
+
}
|
|
4411
|
+
function productViolation(rule, nodeId, components, product) {
|
|
4412
|
+
const detail = rule.metrics.map((m, i) => `${m}=${formatNumber(components[i])}`).join(" * ");
|
|
4413
|
+
return {
|
|
4414
|
+
ruleId: rule.id,
|
|
4415
|
+
severity: severityOf(rule),
|
|
4416
|
+
nodeId,
|
|
4417
|
+
metric: rule.metrics.join(" * "),
|
|
4418
|
+
value: product,
|
|
4419
|
+
threshold: rule.max,
|
|
4420
|
+
message: `${detail} = ${formatNumber(product)} > ${formatNumber(rule.max)}`
|
|
4421
|
+
};
|
|
4422
|
+
}
|
|
4423
|
+
function collectComponents(metrics, values) {
|
|
4424
|
+
const out = [];
|
|
4425
|
+
for (const m of metrics) {
|
|
4426
|
+
const v = values.get(m);
|
|
4427
|
+
if (v === void 0) return null;
|
|
4428
|
+
out.push(v);
|
|
4429
|
+
}
|
|
4430
|
+
return out;
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
// src/check/rules.ts
|
|
4434
|
+
function runRule(rule, ctx) {
|
|
4435
|
+
switch (rule.type) {
|
|
4436
|
+
case "metric-max":
|
|
4437
|
+
return runMetricMaxRule(rule, ctx);
|
|
4438
|
+
case "metric-min":
|
|
4439
|
+
return runMetricMinRule(rule, ctx);
|
|
4440
|
+
case "metric-product-max":
|
|
4441
|
+
return runMetricProductMaxRule(rule, ctx);
|
|
4442
|
+
case "forbid-import":
|
|
4443
|
+
return runForbidImportRule(rule, ctx);
|
|
4444
|
+
case "layered-deps":
|
|
4445
|
+
return runLayeredDepsRule(rule, ctx);
|
|
4446
|
+
case "no-internal-only-barrels":
|
|
4447
|
+
return runNoInternalOnlyBarrelsRule(rule, ctx);
|
|
4448
|
+
}
|
|
4449
|
+
}
|
|
4450
|
+
|
|
4451
|
+
// src/check/check.ts
|
|
4452
|
+
function runChecks(store, options) {
|
|
4453
|
+
const ctx = buildRuleContext(store, options.snapshotId);
|
|
4454
|
+
const violations = [];
|
|
4455
|
+
for (const rule of options.rules) {
|
|
4456
|
+
violations.push(...runRule(rule, ctx));
|
|
4457
|
+
}
|
|
4458
|
+
if (options.baselineSnapshotId) {
|
|
4459
|
+
const baselineKeys = collectBaselineKeys(store, options.baselineSnapshotId, options.snapshotId, options.rules);
|
|
4460
|
+
for (const v of violations) {
|
|
4461
|
+
if (baselineKeys.has(violationKey(v))) v.isCarryover = true;
|
|
4462
|
+
}
|
|
4463
|
+
}
|
|
4464
|
+
const counts = countByOriginAndSeverity(violations);
|
|
4465
|
+
return {
|
|
4466
|
+
snapshotId: options.snapshotId,
|
|
4467
|
+
baselineSnapshotId: options.baselineSnapshotId,
|
|
4468
|
+
rulesEvaluated: options.rules.length,
|
|
4469
|
+
nodesEvaluated: ctx.nodes.length,
|
|
4470
|
+
violations,
|
|
4471
|
+
...counts,
|
|
4472
|
+
passed: counts.newErrors === 0
|
|
4473
|
+
};
|
|
4474
|
+
}
|
|
4475
|
+
function snapshotViolations(store, snapshotId, rules) {
|
|
4476
|
+
const ctx = buildRuleContext(store, snapshotId);
|
|
4477
|
+
return rules.flatMap((rule) => runRule(rule, ctx));
|
|
4478
|
+
}
|
|
4479
|
+
function collectBaselineKeys(store, baselineSnapshotId, snapshotId, rules) {
|
|
4480
|
+
const ctx = buildRuleContext(store, baselineSnapshotId);
|
|
4481
|
+
const chain = aliasChain(store, baselineSnapshotId, snapshotId);
|
|
4482
|
+
const keys = /* @__PURE__ */ new Set();
|
|
4483
|
+
for (const rule of rules) {
|
|
4484
|
+
for (const v of runRule(rule, ctx)) keys.add(rebasedViolationKey(v, chain.resolve));
|
|
4485
|
+
}
|
|
4486
|
+
return keys;
|
|
4487
|
+
}
|
|
4488
|
+
function violationKey(v) {
|
|
4489
|
+
return v.destinationId ? `${v.ruleId}|${v.nodeId}|${v.destinationId}` : `${v.ruleId}|${v.nodeId}`;
|
|
4490
|
+
}
|
|
4491
|
+
function rebasedViolationKey(v, resolve4) {
|
|
4492
|
+
const destinationId = v.destinationId ? resolve4(v.destinationId) : v.destinationId;
|
|
4493
|
+
return violationKey({ ...v, nodeId: resolve4(v.nodeId), destinationId });
|
|
4494
|
+
}
|
|
4495
|
+
function countByOriginAndSeverity(violations) {
|
|
4496
|
+
const counts = { newErrors: 0, newWarnings: 0, carryoverErrors: 0, carryoverWarnings: 0 };
|
|
4497
|
+
for (const v of violations) {
|
|
4498
|
+
const isError = v.severity === "error";
|
|
4499
|
+
if (v.isCarryover) {
|
|
4500
|
+
if (isError) counts.carryoverErrors++;
|
|
4501
|
+
else counts.carryoverWarnings++;
|
|
4502
|
+
} else {
|
|
4503
|
+
if (isError) counts.newErrors++;
|
|
4504
|
+
else counts.newWarnings++;
|
|
4505
|
+
}
|
|
4506
|
+
}
|
|
4507
|
+
return counts;
|
|
4508
|
+
}
|
|
4509
|
+
|
|
4510
|
+
// src/check/validate.ts
|
|
4511
|
+
function validateRules(input, options = {}) {
|
|
4512
|
+
if (!input || typeof input !== "object") {
|
|
4513
|
+
throw new Error("rules file must be an object with a `rules` array");
|
|
4514
|
+
}
|
|
4515
|
+
const obj = input;
|
|
4516
|
+
if (!Array.isArray(obj.rules)) {
|
|
4517
|
+
throw new Error("rules file must have a `rules` array");
|
|
4518
|
+
}
|
|
4519
|
+
const warn = options.onWarn ?? (() => {
|
|
4520
|
+
});
|
|
4521
|
+
return obj.rules.map((r, i) => validateRule(r, i, warn));
|
|
4522
|
+
}
|
|
4523
|
+
function healMetricName(name, ruleId, warn) {
|
|
4524
|
+
const target = metricAliasTarget(name);
|
|
4525
|
+
if (target) {
|
|
4526
|
+
warn(`${ruleId}: metric "${name}" is deprecated \u2014 using "${target}"`);
|
|
4527
|
+
return target;
|
|
4528
|
+
}
|
|
4529
|
+
return name;
|
|
4530
|
+
}
|
|
4531
|
+
function validateRule(raw, index, warn) {
|
|
4532
|
+
if (!raw || typeof raw !== "object") {
|
|
4533
|
+
throw new Error(`rule[${index}] must be an object`);
|
|
4534
|
+
}
|
|
4535
|
+
const r = raw;
|
|
4536
|
+
if (typeof r.id !== "string" || !r.id) {
|
|
4537
|
+
throw new Error(`rule[${index}] missing string id`);
|
|
4538
|
+
}
|
|
4539
|
+
if (typeof r.type !== "string") {
|
|
4540
|
+
throw new Error(`rule[${index}] (${r.id}) missing string type`);
|
|
4541
|
+
}
|
|
4542
|
+
switch (r.type) {
|
|
4543
|
+
case "metric-max":
|
|
4544
|
+
return assertMetricMax(r, warn);
|
|
4545
|
+
case "metric-min":
|
|
4546
|
+
return assertMetricMin(r, warn);
|
|
4547
|
+
case "metric-product-max":
|
|
4548
|
+
return assertMetricProductMax(r, warn);
|
|
4549
|
+
case "forbid-import":
|
|
4550
|
+
return assertForbidImport(r);
|
|
4551
|
+
case "layered-deps":
|
|
4552
|
+
return assertLayeredDeps(r);
|
|
4553
|
+
case "no-internal-only-barrels":
|
|
4554
|
+
return assertNoInternalOnlyBarrels(r);
|
|
4555
|
+
default:
|
|
4556
|
+
throw new Error(`rule[${index}] (${r.id}) unknown type "${r.type}"`);
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4559
|
+
function assertNoInternalOnlyBarrels(r) {
|
|
4560
|
+
if (!Array.isArray(r.packageRoots) || r.packageRoots.length === 0) {
|
|
4561
|
+
throw new Error(
|
|
4562
|
+
`${r.id}: packageRoots must be a non-empty array of path-prefix strings`
|
|
4563
|
+
);
|
|
4564
|
+
}
|
|
4565
|
+
for (const p of r.packageRoots) {
|
|
4566
|
+
if (typeof p !== "string" || !p) {
|
|
4567
|
+
throw new Error(`${r.id}: each packageRoots entry must be a non-empty string`);
|
|
4568
|
+
}
|
|
4569
|
+
}
|
|
4570
|
+
return {
|
|
4571
|
+
type: "no-internal-only-barrels",
|
|
4572
|
+
id: r.id,
|
|
4573
|
+
packageRoots: r.packageRoots,
|
|
4574
|
+
severity: r.severity,
|
|
4575
|
+
exclude: parseStringArray(r.exclude)
|
|
4576
|
+
};
|
|
4577
|
+
}
|
|
4578
|
+
function assertLayeredDeps(r) {
|
|
4579
|
+
if (!Array.isArray(r.layers) || r.layers.length < 2) {
|
|
4580
|
+
throw new Error(`${r.id}: layers must be an array of 2+ string arrays`);
|
|
4581
|
+
}
|
|
4582
|
+
for (const layer of r.layers) {
|
|
4583
|
+
if (!Array.isArray(layer) || !layer.every((p) => typeof p === "string")) {
|
|
4584
|
+
throw new Error(`${r.id}: each layer must be a string[]`);
|
|
4585
|
+
}
|
|
4586
|
+
if (layer.length === 0) {
|
|
4587
|
+
throw new Error(`${r.id}: empty layers not allowed`);
|
|
4588
|
+
}
|
|
4589
|
+
}
|
|
4590
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4591
|
+
for (const layer of r.layers) {
|
|
4592
|
+
for (const pkg of layer) {
|
|
4593
|
+
if (seen.has(pkg)) {
|
|
4594
|
+
throw new Error(`${r.id}: package "${pkg}" appears in more than one layer`);
|
|
4595
|
+
}
|
|
4596
|
+
seen.add(pkg);
|
|
4597
|
+
}
|
|
4598
|
+
}
|
|
4599
|
+
return {
|
|
4600
|
+
type: "layered-deps",
|
|
4601
|
+
id: r.id,
|
|
4602
|
+
layers: r.layers,
|
|
4603
|
+
severity: r.severity
|
|
4604
|
+
};
|
|
4605
|
+
}
|
|
4606
|
+
function assertMetricProductMax(r, warn) {
|
|
4607
|
+
if (!Array.isArray(r.metrics) || r.metrics.length < 2) {
|
|
4608
|
+
throw new Error(`${r.id}: metrics must be an array of 2+ metric names`);
|
|
4609
|
+
}
|
|
4610
|
+
if (!r.metrics.every((m) => typeof m === "string")) {
|
|
4611
|
+
throw new Error(`${r.id}: metrics must be strings`);
|
|
4612
|
+
}
|
|
4613
|
+
if (typeof r.max !== "number") {
|
|
4614
|
+
throw new Error(`${r.id}: max must be a number`);
|
|
4615
|
+
}
|
|
4616
|
+
const ruleId = r.id;
|
|
4617
|
+
return {
|
|
4618
|
+
type: "metric-product-max",
|
|
4619
|
+
id: ruleId,
|
|
4620
|
+
metrics: r.metrics.map((m) => healMetricName(m, ruleId, warn)),
|
|
4621
|
+
max: r.max,
|
|
4622
|
+
kind: r.kind,
|
|
4623
|
+
severity: r.severity,
|
|
4624
|
+
exclude: parseStringArray(r.exclude),
|
|
4625
|
+
excludeRoles: parseRoleArray(ruleId, r.excludeRoles, warn)
|
|
4626
|
+
};
|
|
4627
|
+
}
|
|
4628
|
+
function assertMetricMax(r, warn) {
|
|
4629
|
+
if (typeof r.metric !== "string") throw new Error(`${r.id}: metric must be a string`);
|
|
4630
|
+
if (typeof r.max !== "number") throw new Error(`${r.id}: max must be a number`);
|
|
4631
|
+
const ruleId = r.id;
|
|
4632
|
+
return {
|
|
4633
|
+
type: "metric-max",
|
|
4634
|
+
id: ruleId,
|
|
4635
|
+
metric: healMetricName(r.metric, ruleId, warn),
|
|
4636
|
+
max: r.max,
|
|
4637
|
+
kind: r.kind,
|
|
4638
|
+
severity: r.severity,
|
|
4639
|
+
exclude: parseStringArray(r.exclude),
|
|
4640
|
+
excludeRoles: parseRoleArray(ruleId, r.excludeRoles, warn)
|
|
4641
|
+
};
|
|
4642
|
+
}
|
|
4643
|
+
function assertMetricMin(r, warn) {
|
|
4644
|
+
if (typeof r.metric !== "string") throw new Error(`${r.id}: metric must be a string`);
|
|
4645
|
+
if (typeof r.min !== "number") throw new Error(`${r.id}: min must be a number`);
|
|
4646
|
+
const ruleId = r.id;
|
|
4647
|
+
return {
|
|
4648
|
+
type: "metric-min",
|
|
4649
|
+
id: ruleId,
|
|
4650
|
+
metric: healMetricName(r.metric, ruleId, warn),
|
|
4651
|
+
min: r.min,
|
|
4652
|
+
kind: r.kind,
|
|
4653
|
+
severity: r.severity,
|
|
4654
|
+
exclude: parseStringArray(r.exclude),
|
|
4655
|
+
excludeRoles: parseRoleArray(ruleId, r.excludeRoles, warn)
|
|
4656
|
+
};
|
|
4657
|
+
}
|
|
4658
|
+
function assertForbidImport(r) {
|
|
4659
|
+
if (typeof r.from !== "string") throw new Error(`${r.id}: from must be a string`);
|
|
4660
|
+
if (typeof r.to !== "string") throw new Error(`${r.id}: to must be a string`);
|
|
4661
|
+
return {
|
|
4662
|
+
type: "forbid-import",
|
|
4663
|
+
id: r.id,
|
|
4664
|
+
from: r.from,
|
|
4665
|
+
to: r.to,
|
|
4666
|
+
severity: r.severity
|
|
4667
|
+
};
|
|
4668
|
+
}
|
|
4669
|
+
function parseStringArray(value) {
|
|
4670
|
+
return Array.isArray(value) ? value : void 0;
|
|
4671
|
+
}
|
|
4672
|
+
var ROLE_VALUES = /* @__PURE__ */ new Set([
|
|
4673
|
+
"test",
|
|
4674
|
+
"fixture",
|
|
4675
|
+
"barrel",
|
|
4676
|
+
"types",
|
|
4677
|
+
"config",
|
|
4678
|
+
"entry",
|
|
4679
|
+
"source"
|
|
4680
|
+
]);
|
|
4681
|
+
function parseRoleArray(ruleId, value, warn) {
|
|
4682
|
+
if (value === void 0) return void 0;
|
|
4683
|
+
if (!Array.isArray(value)) {
|
|
4684
|
+
throw new Error(`${ruleId}: excludeRoles must be an array`);
|
|
4685
|
+
}
|
|
4686
|
+
return value.map((entry) => healRole(entry, ruleId, warn));
|
|
4687
|
+
}
|
|
4688
|
+
function healRole(entry, ruleId, warn) {
|
|
4689
|
+
if (typeof entry === "string" && ROLE_VALUES.has(entry)) {
|
|
4690
|
+
return entry;
|
|
4691
|
+
}
|
|
4692
|
+
if (typeof entry === "string") {
|
|
4693
|
+
const target = roleAliasTarget(entry);
|
|
4694
|
+
if (target) {
|
|
4695
|
+
warn(`${ruleId}: role "${entry}" is deprecated \u2014 using "${target}"`);
|
|
4696
|
+
return target;
|
|
4697
|
+
}
|
|
4698
|
+
}
|
|
4699
|
+
throw new Error(
|
|
4700
|
+
`${ruleId}: unknown role "${entry}" \u2014 valid: ${[...ROLE_VALUES].join(", ")}`
|
|
4701
|
+
);
|
|
4702
|
+
}
|
|
4703
|
+
|
|
4704
|
+
// src/check/run.ts
|
|
4705
|
+
import { readFile as readFile2 } from "fs/promises";
|
|
4706
|
+
function resolveSnapshot(store, spec) {
|
|
4707
|
+
if (typeof spec === "number" || /^\d+$/.test(spec)) {
|
|
4708
|
+
const snap2 = store.getSnapshot(Number(spec));
|
|
4709
|
+
if (!snap2) throw new Error(`no snapshot with id ${spec}`);
|
|
4710
|
+
return snap2;
|
|
4711
|
+
}
|
|
4712
|
+
const snap = store.getLatestSnapshotByRef(spec);
|
|
4713
|
+
if (!snap) throw new Error(`no snapshot found for ref "${spec}"`);
|
|
4714
|
+
return snap;
|
|
4715
|
+
}
|
|
4716
|
+
function checkSnapshot(store, options) {
|
|
4717
|
+
const snapshot = resolveSnapshot(store, options.snapshot);
|
|
4718
|
+
const baselineSnapshot = options.baseline !== void 0 ? resolveSnapshot(store, options.baseline) : void 0;
|
|
4719
|
+
const result = runChecks(store, {
|
|
4720
|
+
snapshotId: snapshot.id,
|
|
4721
|
+
rules: options.rules,
|
|
4722
|
+
baselineSnapshotId: baselineSnapshot?.id
|
|
4723
|
+
});
|
|
4724
|
+
return { snapshot, baselineSnapshot, result };
|
|
4725
|
+
}
|
|
4726
|
+
async function loadCheckRules(path11, options = {}) {
|
|
4727
|
+
let raw;
|
|
4728
|
+
try {
|
|
4729
|
+
raw = await readFile2(path11, "utf8");
|
|
4730
|
+
} catch (err) {
|
|
4731
|
+
throw new Error(`Cannot read rules file at ${path11}: ${errorMessage(err)}`);
|
|
4732
|
+
}
|
|
4733
|
+
let parsed;
|
|
4734
|
+
try {
|
|
4735
|
+
parsed = JSON.parse(raw);
|
|
4736
|
+
} catch (err) {
|
|
4737
|
+
throw new Error(`Invalid JSON in ${path11}: ${errorMessage(err)}`);
|
|
4738
|
+
}
|
|
4739
|
+
return validateRules(parsed, options);
|
|
4740
|
+
}
|
|
4741
|
+
function errorMessage(err) {
|
|
4742
|
+
return err instanceof Error ? err.message : String(err);
|
|
4743
|
+
}
|
|
4744
|
+
|
|
4745
|
+
// src/diff/diff.ts
|
|
4746
|
+
function resolveId(id, aliases) {
|
|
4747
|
+
return aliases.resolve(id);
|
|
4748
|
+
}
|
|
4749
|
+
function edgeKey2(srcId, dstId, kind) {
|
|
4750
|
+
return JSON.stringify([srcId, dstId, canonicalEdgeKind(kind)]);
|
|
4751
|
+
}
|
|
4752
|
+
function metricKey(nodeId, name) {
|
|
4753
|
+
return JSON.stringify([nodeId, canonicalMetricName(name)]);
|
|
4754
|
+
}
|
|
4755
|
+
function indexEdges(edges) {
|
|
4756
|
+
const m = /* @__PURE__ */ new Map();
|
|
4757
|
+
for (const e of edges) m.set(edgeKey2(e.srcId, e.dstId, e.kind), e);
|
|
4758
|
+
return m;
|
|
4759
|
+
}
|
|
4760
|
+
function diffNodes(fromNodes, toNodes, aliases) {
|
|
4761
|
+
const toIndex = new Map(toNodes.map((n) => [n.id, n]));
|
|
4762
|
+
const matchedToIds = /* @__PURE__ */ new Set();
|
|
4763
|
+
const removed = [];
|
|
4764
|
+
const renamed = [];
|
|
4765
|
+
for (const fromNode of fromNodes) {
|
|
4766
|
+
const newId = resolveId(fromNode.id, aliases);
|
|
4767
|
+
const toNode = toIndex.get(newId);
|
|
4768
|
+
if (!toNode) {
|
|
4769
|
+
removed.push(fromNode);
|
|
4770
|
+
continue;
|
|
4771
|
+
}
|
|
4772
|
+
matchedToIds.add(newId);
|
|
4773
|
+
if (newId !== fromNode.id) {
|
|
4774
|
+
renamed.push({ oldId: fromNode.id, newId, reason: aliases.trace(fromNode.id).reason, node: toNode });
|
|
4775
|
+
}
|
|
4776
|
+
}
|
|
4777
|
+
const added = toNodes.filter((toNode) => !matchedToIds.has(toNode.id));
|
|
4778
|
+
return { added, removed, renamed, commonNewIds: matchedToIds };
|
|
4779
|
+
}
|
|
4780
|
+
function diffEdges(fromEdges, toEdges, aliases) {
|
|
4781
|
+
const remappedKey = (e) => edgeKey2(resolveId(e.srcId, aliases), resolveId(e.dstId, aliases), e.kind);
|
|
4782
|
+
const fromKeys = new Set(fromEdges.map(remappedKey));
|
|
4783
|
+
const toIndex = indexEdges(toEdges);
|
|
4784
|
+
const added = [];
|
|
4785
|
+
for (const [k, e] of toIndex) {
|
|
4786
|
+
if (!fromKeys.has(k)) added.push(e);
|
|
4787
|
+
}
|
|
4788
|
+
const removed = fromEdges.filter((e) => !toIndex.has(remappedKey(e)));
|
|
4789
|
+
return { added, removed };
|
|
4790
|
+
}
|
|
4791
|
+
function indexFromMetrics(metrics, aliases) {
|
|
4792
|
+
const fromIndex = /* @__PURE__ */ new Map();
|
|
4793
|
+
for (const m of metrics) {
|
|
4794
|
+
const newId = resolveId(m.nodeId, aliases);
|
|
4795
|
+
fromIndex.set(metricKey(newId, m.name), { metric: m, nodeId: newId });
|
|
4796
|
+
}
|
|
4797
|
+
return fromIndex;
|
|
4798
|
+
}
|
|
4799
|
+
function deltaOf(before, after) {
|
|
4800
|
+
if (before === null || after === null) return null;
|
|
4801
|
+
return after - before;
|
|
4802
|
+
}
|
|
4803
|
+
function metricDelta(nodeId, name, before, after) {
|
|
4804
|
+
return { nodeId, name: canonicalMetricName(name), before, after, delta: deltaOf(before, after) };
|
|
4805
|
+
}
|
|
4806
|
+
function diffMetrics(fromMetrics, toMetrics, aliases, commonNewIds) {
|
|
4807
|
+
const fromIndex = indexFromMetrics(fromMetrics, aliases);
|
|
4808
|
+
const toIndex = new Map(toMetrics.map((m) => [metricKey(m.nodeId, m.name), m]));
|
|
4809
|
+
const out = [];
|
|
4810
|
+
const seen = /* @__PURE__ */ new Set();
|
|
4811
|
+
for (const [key, { metric: before, nodeId }] of fromIndex) {
|
|
4812
|
+
if (!commonNewIds.has(nodeId)) continue;
|
|
4813
|
+
seen.add(key);
|
|
4814
|
+
const afterVal = toIndex.get(key)?.value ?? null;
|
|
4815
|
+
if (before.value !== afterVal) out.push(metricDelta(nodeId, before.name, before.value, afterVal));
|
|
4816
|
+
}
|
|
4817
|
+
for (const [key, after] of toIndex) {
|
|
4818
|
+
if (seen.has(key) || !commonNewIds.has(after.nodeId)) continue;
|
|
4819
|
+
out.push(metricDelta(after.nodeId, after.name, null, after.value));
|
|
4820
|
+
}
|
|
4821
|
+
return out;
|
|
4822
|
+
}
|
|
4823
|
+
function diffSnapshots(store, options) {
|
|
4824
|
+
const { fromSnapshotId, toSnapshotId } = options;
|
|
4825
|
+
const aliases = aliasChain(store, fromSnapshotId, toSnapshotId);
|
|
4826
|
+
const nodeDiff = diffNodes(store.listNodes(fromSnapshotId), store.listNodes(toSnapshotId), aliases);
|
|
4827
|
+
const edgeDiff = diffEdges(store.listEdges(fromSnapshotId), store.listEdges(toSnapshotId), aliases);
|
|
4828
|
+
const metricDeltas = diffMetrics(
|
|
4829
|
+
store.listMetrics(fromSnapshotId),
|
|
4830
|
+
store.listMetrics(toSnapshotId),
|
|
4831
|
+
aliases,
|
|
4832
|
+
nodeDiff.commonNewIds
|
|
4833
|
+
);
|
|
4834
|
+
return {
|
|
4835
|
+
summary: summarize(options, nodeDiff, edgeDiff, metricDeltas),
|
|
4836
|
+
addedNodes: nodeDiff.added,
|
|
4837
|
+
removedNodes: nodeDiff.removed,
|
|
4838
|
+
renamedNodes: nodeDiff.renamed,
|
|
4839
|
+
addedEdges: edgeDiff.added,
|
|
4840
|
+
removedEdges: edgeDiff.removed,
|
|
4841
|
+
metricDeltas
|
|
4842
|
+
};
|
|
4843
|
+
}
|
|
4844
|
+
function summarize({ fromSnapshotId, toSnapshotId }, nodeDiff, edgeDiff, metricDeltas) {
|
|
4845
|
+
return {
|
|
4846
|
+
fromSnapshotId,
|
|
4847
|
+
toSnapshotId,
|
|
4848
|
+
addedNodes: nodeDiff.added.length,
|
|
4849
|
+
removedNodes: nodeDiff.removed.length,
|
|
4850
|
+
renamedNodes: nodeDiff.renamed.length,
|
|
4851
|
+
unchangedNodes: nodeDiff.commonNewIds.size - nodeDiff.renamed.length,
|
|
4852
|
+
addedEdges: edgeDiff.added.length,
|
|
4853
|
+
removedEdges: edgeDiff.removed.length,
|
|
4854
|
+
metricChanges: metricDeltas.length
|
|
4855
|
+
};
|
|
4856
|
+
}
|
|
4857
|
+
|
|
4858
|
+
// src/diff/check-diff.ts
|
|
4859
|
+
function diffCheckResults(store, options) {
|
|
4860
|
+
const from = runChecks(store, { snapshotId: options.fromSnapshotId, rules: options.rules });
|
|
4861
|
+
const to = runChecks(store, { snapshotId: options.toSnapshotId, rules: options.rules });
|
|
4862
|
+
const chain = aliasChain(store, options.fromSnapshotId, options.toSnapshotId);
|
|
4863
|
+
return {
|
|
4864
|
+
fromSnapshotId: options.fromSnapshotId,
|
|
4865
|
+
toSnapshotId: options.toSnapshotId,
|
|
4866
|
+
rulesEvaluated: options.rules.length,
|
|
4867
|
+
...bucketViolations(indexByKey(from.violations, chain.resolve), indexByKey(to.violations))
|
|
4868
|
+
};
|
|
4869
|
+
}
|
|
4870
|
+
function bucketViolations(fromByKey, toByKey) {
|
|
4871
|
+
const buckets = { newViolations: [], resolvedViolations: [], unchanged: [], worsened: [], improved: [] };
|
|
4872
|
+
for (const [key, toV] of toByKey) {
|
|
4873
|
+
const fromV = fromByKey.get(key);
|
|
4874
|
+
if (!fromV) {
|
|
4875
|
+
buckets.newViolations.push(toV);
|
|
4876
|
+
continue;
|
|
4877
|
+
}
|
|
4878
|
+
const delta = computeDelta(fromV, toV);
|
|
4879
|
+
const entry = { from: fromV, to: toV, delta };
|
|
4880
|
+
buckets.unchanged.push(entry);
|
|
4881
|
+
if (delta !== null && delta > 0) buckets.worsened.push(entry);
|
|
4882
|
+
else if (delta !== null && delta < 0) buckets.improved.push(entry);
|
|
4883
|
+
}
|
|
4884
|
+
for (const [key, fromV] of fromByKey) {
|
|
4885
|
+
if (!toByKey.has(key)) buckets.resolvedViolations.push(fromV);
|
|
4886
|
+
}
|
|
4887
|
+
return buckets;
|
|
4888
|
+
}
|
|
4889
|
+
function indexByKey(violations, resolve4) {
|
|
4890
|
+
const out = /* @__PURE__ */ new Map();
|
|
4891
|
+
for (const v of violations) out.set(resolve4 ? rebasedViolationKey(v, resolve4) : violationKey(v), v);
|
|
4892
|
+
return out;
|
|
4893
|
+
}
|
|
4894
|
+
function computeDelta(from, to) {
|
|
4895
|
+
if (from.value === void 0 || to.value === void 0) return null;
|
|
4896
|
+
return to.value - from.value;
|
|
4897
|
+
}
|
|
4898
|
+
|
|
4899
|
+
// src/embeddings/corpus.ts
|
|
4900
|
+
import { contentHashOf } from "@titan-design/store-sqlite";
|
|
4901
|
+
function buildEmbedText(signature, purpose) {
|
|
4902
|
+
return purpose ? `${signature} -- ${purpose}` : signature;
|
|
4903
|
+
}
|
|
4904
|
+
function hashEmbedText(text) {
|
|
4905
|
+
return contentHashOf(text);
|
|
4906
|
+
}
|
|
4907
|
+
var TEST_OR_FIXTURE_PATH = /\.test\.|\.spec\.|__tests__|\/fixtures\//;
|
|
4908
|
+
var EXCLUDED_FILE_ROLES = /* @__PURE__ */ new Set(["test", "generated"]);
|
|
4909
|
+
function listEmbeddableSymbols(store, snapshotId) {
|
|
4910
|
+
const nodes = store.listNodes(snapshotId, { includeSymbols: true });
|
|
4911
|
+
const fileRoles = /* @__PURE__ */ new Map();
|
|
4912
|
+
for (const n of nodes) if (n.kind === "file") fileRoles.set(n.id, n.role ?? null);
|
|
4913
|
+
const out = [];
|
|
4914
|
+
for (const n of nodes) {
|
|
4915
|
+
const symbol = toEmbeddable(n, fileRoles);
|
|
4916
|
+
if (symbol) out.push(symbol);
|
|
4917
|
+
}
|
|
4918
|
+
return out;
|
|
4919
|
+
}
|
|
4920
|
+
function toEmbeddable(node, fileRoles) {
|
|
4921
|
+
if (node.kind !== "symbol") return null;
|
|
4922
|
+
const attrs = node.attrs ?? {};
|
|
4923
|
+
if (attrs.exported !== true || !attrs.signature) return null;
|
|
4924
|
+
const file = node.parentId ?? node.id.split("#")[0] ?? node.id;
|
|
4925
|
+
if (TEST_OR_FIXTURE_PATH.test(file)) return null;
|
|
4926
|
+
const role = fileRoles.get(file);
|
|
4927
|
+
if (role && EXCLUDED_FILE_ROLES.has(role)) return null;
|
|
4928
|
+
const text = buildEmbedText(attrs.signature, attrs.purpose);
|
|
4929
|
+
return {
|
|
4930
|
+
id: node.id,
|
|
4931
|
+
name: node.name,
|
|
4932
|
+
file,
|
|
4933
|
+
signature: attrs.signature,
|
|
4934
|
+
purpose: attrs.purpose,
|
|
4935
|
+
text,
|
|
4936
|
+
textHash: hashEmbedText(text)
|
|
4937
|
+
};
|
|
4938
|
+
}
|
|
4939
|
+
|
|
4940
|
+
// src/embeddings/cache.ts
|
|
4941
|
+
import { fromFloat32Buffer, normalize, toFloat32Buffer } from "@titan-design/embed";
|
|
4942
|
+
import { CacheBlobTable } from "@titan-design/store-sqlite";
|
|
4943
|
+
var SYMBOL_EMBEDDING_NAMESPACE = "code-graph/symbol-embedding";
|
|
4944
|
+
var EMBED_BATCH_SIZE = 64;
|
|
4945
|
+
function symbolVectorCache(store) {
|
|
4946
|
+
return new CacheBlobTable(store.db, { name: KIT.cacheBlob });
|
|
4947
|
+
}
|
|
4948
|
+
function loadVectorsByHash(store, model, hashes) {
|
|
4949
|
+
const cache = symbolVectorCache(store);
|
|
4950
|
+
const found = /* @__PURE__ */ new Map();
|
|
4951
|
+
for (const contentHash of new Set(hashes)) {
|
|
4952
|
+
const hit = cache.get({ namespace: SYMBOL_EMBEDDING_NAMESPACE, model, contentHash });
|
|
4953
|
+
if (hit) found.set(contentHash, fromFloat32Buffer(hit.value));
|
|
4954
|
+
}
|
|
4955
|
+
return found;
|
|
4956
|
+
}
|
|
4957
|
+
function storeVectors(store, model, rows) {
|
|
4958
|
+
const cache = symbolVectorCache(store);
|
|
4959
|
+
store.db.transaction(() => {
|
|
4960
|
+
for (const r of rows) {
|
|
4961
|
+
const key = { namespace: SYMBOL_EMBEDDING_NAMESPACE, model, contentHash: r.textHash };
|
|
4962
|
+
cache.put(key, toFloat32Buffer(r.vector), { dims: r.vector.length });
|
|
4963
|
+
}
|
|
4964
|
+
})();
|
|
4965
|
+
}
|
|
4966
|
+
async function embedTextsCached(store, embedder, texts) {
|
|
4967
|
+
const hashes = texts.map((t) => hashEmbedText(t));
|
|
4968
|
+
const byHash = loadVectorsByHash(store, embedder.model, hashes);
|
|
4969
|
+
const missing = /* @__PURE__ */ new Map();
|
|
4970
|
+
texts.forEach((text, i) => {
|
|
4971
|
+
if (!byHash.has(hashes[i])) missing.set(hashes[i], text);
|
|
4972
|
+
});
|
|
4973
|
+
const pending = [...missing.entries()];
|
|
4974
|
+
for (let i = 0; i < pending.length; i += EMBED_BATCH_SIZE) {
|
|
4975
|
+
const rows = await embedBatch(embedder, pending.slice(i, i + EMBED_BATCH_SIZE));
|
|
4976
|
+
storeVectors(store, embedder.model, rows);
|
|
4977
|
+
for (const r of rows) byHash.set(r.textHash, r.vector);
|
|
4978
|
+
}
|
|
4979
|
+
return { byHash, newHashes: new Set(missing.keys()) };
|
|
4980
|
+
}
|
|
4981
|
+
async function embedBatch(embedder, batch) {
|
|
4982
|
+
const vectors = await embedder.embed(batch.map(([, text]) => text), { role: "document" });
|
|
4983
|
+
if (vectors.length !== batch.length) {
|
|
4984
|
+
throw new Error(`Embedder returned ${vectors.length} vectors for ${batch.length} texts`);
|
|
4985
|
+
}
|
|
4986
|
+
return batch.map(([textHash], j) => ({ textHash, vector: normalize(vectors[j]) }));
|
|
4987
|
+
}
|
|
4988
|
+
|
|
4989
|
+
// src/embeddings/embeddings.ts
|
|
4990
|
+
import { BruteForceVectorIndex, vectorRetriever } from "@titan-design/retrieval";
|
|
4991
|
+
async function embedSnapshot(store, snapshotId, embedder) {
|
|
4992
|
+
const symbols = listEmbeddableSymbols(store, snapshotId);
|
|
4993
|
+
const { newHashes } = await embedTextsCached(store, embedder, symbols.map((s) => s.text));
|
|
4994
|
+
return {
|
|
4995
|
+
model: embedder.model,
|
|
4996
|
+
symbols: symbols.length,
|
|
4997
|
+
withPurpose: symbols.filter((s) => s.purpose).length,
|
|
4998
|
+
embedded: symbols.length,
|
|
4999
|
+
newlyEmbedded: newHashes.size,
|
|
5000
|
+
reused: symbols.filter((s) => !newHashes.has(s.textHash)).length
|
|
5001
|
+
};
|
|
5002
|
+
}
|
|
5003
|
+
async function tryEmbedSnapshot(store, snapshotId, embedder) {
|
|
5004
|
+
try {
|
|
5005
|
+
return { ok: true, result: await embedSnapshot(store, snapshotId, embedder) };
|
|
5006
|
+
} catch (err) {
|
|
5007
|
+
return { ok: false, model: embedder.model, error: err instanceof Error ? err.message : String(err) };
|
|
5008
|
+
}
|
|
5009
|
+
}
|
|
5010
|
+
async function findSimilarCapability(store, snapshotId, query, embedder, opts = {}) {
|
|
5011
|
+
const symbols = listEmbeddableSymbols(store, snapshotId);
|
|
5012
|
+
const index = buildSymbolIndex(store, embedder.model, symbols);
|
|
5013
|
+
if (index.size === 0) {
|
|
5014
|
+
throw new Error(
|
|
5015
|
+
`No capability embeddings stored for snapshot ${snapshotId} (model ${embedder.model}); run embedSnapshot first.`
|
|
5016
|
+
);
|
|
5017
|
+
}
|
|
5018
|
+
const retriever = vectorRetriever(embedder, index);
|
|
5019
|
+
const hits = await retriever.retrieve(query, { limit: opts.limit ?? 10 });
|
|
5020
|
+
const byId = new Map(symbols.map((s) => [s.id, s]));
|
|
5021
|
+
return {
|
|
5022
|
+
query,
|
|
5023
|
+
model: embedder.model,
|
|
5024
|
+
coverage: { symbols: symbols.length, embedded: index.size, withPurpose: symbols.filter((s) => s.purpose).length },
|
|
5025
|
+
candidates: hits.map((h) => toCandidate(byId.get(h.id), h.score ?? 0))
|
|
5026
|
+
};
|
|
5027
|
+
}
|
|
5028
|
+
function buildSymbolIndex(store, model, symbols) {
|
|
5029
|
+
const vectors = loadVectorsByHash(store, model, symbols.map((s) => s.textHash));
|
|
5030
|
+
const index = new BruteForceVectorIndex();
|
|
5031
|
+
for (const s of symbols) {
|
|
5032
|
+
const vector = vectors.get(s.textHash);
|
|
5033
|
+
if (vector) index.add(s.id, vector);
|
|
5034
|
+
}
|
|
5035
|
+
return index;
|
|
5036
|
+
}
|
|
5037
|
+
function toCandidate(s, score) {
|
|
5038
|
+
return { id: s.id, name: s.name, file: s.file, signature: s.signature, purpose: s.purpose, score };
|
|
2356
5039
|
}
|
|
2357
5040
|
export {
|
|
5041
|
+
ALIAS_BASE_ATTR,
|
|
2358
5042
|
ALL_ROLES,
|
|
5043
|
+
COVERAGE_METRIC_NAME,
|
|
2359
5044
|
CodeGraphStore,
|
|
5045
|
+
DEAD_CODE_METRIC_NAMES,
|
|
5046
|
+
DEFAULT_CHURN_WINDOWS,
|
|
2360
5047
|
DOMAIN_DDL,
|
|
5048
|
+
GROWTH_RISK_METRIC_NAMES,
|
|
2361
5049
|
INDEX_VERSION,
|
|
2362
5050
|
KIT,
|
|
2363
5051
|
LanguageExtractor,
|
|
5052
|
+
METRIC_CATALOGUE,
|
|
2364
5053
|
MIGRATIONS,
|
|
2365
5054
|
PythonGraphExtractor,
|
|
5055
|
+
SCHEMA_VERSION,
|
|
5056
|
+
SNAPSHOT_SCOPED_TABLES,
|
|
2366
5057
|
SOURCE_METRIC_NAMES,
|
|
5058
|
+
SYMBOL_EMBEDDING_NAMESPACE,
|
|
2367
5059
|
SYMBOL_ID_SEP,
|
|
2368
5060
|
TsMorphGraphExtractor,
|
|
5061
|
+
aggregateMetrics,
|
|
5062
|
+
aliasChain,
|
|
2369
5063
|
annotateRoles,
|
|
5064
|
+
attributeCoverage,
|
|
2370
5065
|
buildAliases,
|
|
5066
|
+
buildEmbedText,
|
|
2371
5067
|
buildFileModuleNodes,
|
|
2372
5068
|
buildIndexerMetrics,
|
|
5069
|
+
buildLineage,
|
|
2373
5070
|
canonicalEdgeKind,
|
|
2374
5071
|
canonicalMetricName,
|
|
2375
5072
|
canonicalRole,
|
|
5073
|
+
checkSnapshot,
|
|
2376
5074
|
classifyRole,
|
|
2377
5075
|
collectDeclaredNames,
|
|
2378
5076
|
collectDeclaredSpans,
|
|
5077
|
+
compilePatterns,
|
|
5078
|
+
computeDeadCodeMetrics,
|
|
5079
|
+
computeDeepAst,
|
|
5080
|
+
computeGrowthRiskMetrics,
|
|
2379
5081
|
computeMetrics,
|
|
5082
|
+
computePageRank,
|
|
5083
|
+
computePartitionQuality,
|
|
5084
|
+
computeRecencyWindows,
|
|
5085
|
+
computeRelevance,
|
|
2380
5086
|
computeRoleHints,
|
|
2381
5087
|
computeSourceMetrics,
|
|
5088
|
+
computeSymbolConsumers,
|
|
5089
|
+
computeSymbolCoupling,
|
|
5090
|
+
computeTestCoverageOwnership,
|
|
5091
|
+
createAliasChain,
|
|
5092
|
+
describeMetric,
|
|
5093
|
+
describeMetrics,
|
|
2382
5094
|
detectGitHead,
|
|
2383
5095
|
detectGitToplevel,
|
|
2384
5096
|
detectRenames,
|
|
5097
|
+
diffCheckResults,
|
|
5098
|
+
diffSnapshots,
|
|
2385
5099
|
edgeWeight,
|
|
5100
|
+
embedSnapshot,
|
|
5101
|
+
embedTextsCached,
|
|
2386
5102
|
externalId,
|
|
2387
5103
|
fileId,
|
|
2388
|
-
|
|
5104
|
+
findSimilarCapability,
|
|
5105
|
+
getEdgeWeight,
|
|
5106
|
+
getLanguageFromPath3 as getLanguageFromPath,
|
|
2389
5107
|
getSupportedLanguages,
|
|
5108
|
+
groupTestsBySource,
|
|
2390
5109
|
hashContent,
|
|
5110
|
+
hashEmbedText,
|
|
2391
5111
|
indexPaths,
|
|
5112
|
+
invertBuckets,
|
|
2392
5113
|
isGeneratedByHeuristic,
|
|
2393
5114
|
isGeneratedFile,
|
|
2394
5115
|
isInsideGitRepo,
|
|
5116
|
+
lineagePath,
|
|
5117
|
+
linkTestsToSources,
|
|
2395
5118
|
listEdges,
|
|
5119
|
+
listEdgesTouching,
|
|
5120
|
+
listEmbeddableSymbols,
|
|
2396
5121
|
listMetrics,
|
|
5122
|
+
listMetricsForNode,
|
|
2397
5123
|
listNodes,
|
|
5124
|
+
loadCheckRules,
|
|
2398
5125
|
loadGeneratedPatterns,
|
|
5126
|
+
loadHistoryMetrics,
|
|
5127
|
+
loadLineage,
|
|
5128
|
+
matchesAny,
|
|
2399
5129
|
moduleId,
|
|
2400
5130
|
openCodeGraph,
|
|
2401
5131
|
packageId,
|
|
2402
5132
|
parentModuleId,
|
|
2403
|
-
parseFile,
|
|
5133
|
+
parseFile2 as parseFile,
|
|
2404
5134
|
parseSymbolId,
|
|
5135
|
+
patternToRegex,
|
|
5136
|
+
planPrune,
|
|
5137
|
+
priorSnapshotForRef,
|
|
2405
5138
|
pruneDanglingReferences,
|
|
2406
5139
|
readSourceFiles,
|
|
5140
|
+
rebasedViolationKey,
|
|
5141
|
+
resolveAlias,
|
|
2407
5142
|
resolveBarrelEdges,
|
|
2408
|
-
|
|
5143
|
+
resolveChurnWindows,
|
|
5144
|
+
resolveGitRef,
|
|
5145
|
+
resolveSnapshot,
|
|
5146
|
+
runChecks,
|
|
5147
|
+
runPrune,
|
|
5148
|
+
shouldIncludeFile2 as shouldIncludeFile,
|
|
5149
|
+
snapshotPageRank,
|
|
5150
|
+
snapshotReferenceEdges,
|
|
5151
|
+
snapshotRelevance,
|
|
5152
|
+
snapshotSymbolConsumers,
|
|
5153
|
+
snapshotSymbolCoupling,
|
|
5154
|
+
snapshotViolations,
|
|
2409
5155
|
structuralSignature,
|
|
2410
5156
|
symbolId,
|
|
2411
|
-
|
|
5157
|
+
testCoverageCountMetrics,
|
|
5158
|
+
tryEmbedSnapshot,
|
|
5159
|
+
validateRules,
|
|
5160
|
+
violationKey,
|
|
5161
|
+
walkSourceFiles,
|
|
5162
|
+
windowSuffix
|
|
2412
5163
|
};
|
|
2413
5164
|
//# sourceMappingURL=index.js.map
|