@archora/core 1.1.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/LICENSE +201 -0
- package/README.md +62 -0
- package/package.json +36 -0
- package/src/README.md +4 -0
- package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
- package/src/analyzer/__tests__/_paths.ts +8 -0
- package/src/analyzer/__tests__/analyze.test.ts +522 -0
- package/src/analyzer/__tests__/archDebt.test.ts +111 -0
- package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
- package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
- package/src/analyzer/__tests__/bundle.test.ts +191 -0
- package/src/analyzer/__tests__/classify.test.ts +99 -0
- package/src/analyzer/__tests__/contracts.test.ts +372 -0
- package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
- package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
- package/src/analyzer/__tests__/cycles.test.ts +74 -0
- package/src/analyzer/__tests__/detect.test.ts +62 -0
- package/src/analyzer/__tests__/discover.test.ts +68 -0
- package/src/analyzer/__tests__/displayId.test.ts +30 -0
- package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
- package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
- package/src/analyzer/__tests__/incremental.test.ts +154 -0
- package/src/analyzer/__tests__/layers.test.ts +87 -0
- package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
- package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
- package/src/analyzer/__tests__/metrics.test.ts +59 -0
- package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
- package/src/analyzer/__tests__/parsers.test.ts +187 -0
- package/src/analyzer/__tests__/reactParser.test.ts +93 -0
- package/src/analyzer/__tests__/recommendations.test.ts +171 -0
- package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
- package/src/analyzer/__tests__/resolve.test.ts +294 -0
- package/src/analyzer/__tests__/rsc.test.ts +130 -0
- package/src/analyzer/__tests__/signals.test.ts +316 -0
- package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
- package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
- package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
- package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
- package/src/analyzer/archDebt.ts +68 -0
- package/src/analyzer/asyncLifecycleRisk.ts +234 -0
- package/src/analyzer/buildGraph.ts +683 -0
- package/src/analyzer/bundle/analyzeBundle.ts +147 -0
- package/src/analyzer/bundle/index.ts +12 -0
- package/src/analyzer/bundle/parseStats.ts +152 -0
- package/src/analyzer/bundle/types.ts +85 -0
- package/src/analyzer/classify.ts +54 -0
- package/src/analyzer/contracts.ts +265 -0
- package/src/analyzer/cyclePatterns.ts +138 -0
- package/src/analyzer/cycles.ts +98 -0
- package/src/analyzer/detect.ts +34 -0
- package/src/analyzer/discover.ts +131 -0
- package/src/analyzer/displayId.ts +21 -0
- package/src/analyzer/entryPoints.ts +136 -0
- package/src/analyzer/feedbackArcSet.ts +332 -0
- package/src/analyzer/fileSource.ts +8 -0
- package/src/analyzer/hotZones.ts +17 -0
- package/src/analyzer/incremental.ts +455 -0
- package/src/analyzer/index.ts +444 -0
- package/src/analyzer/layers.ts +183 -0
- package/src/analyzer/loadAliases.ts +288 -0
- package/src/analyzer/memoryRisk.ts +345 -0
- package/src/analyzer/metrics.ts +156 -0
- package/src/analyzer/parsers/index.ts +62 -0
- package/src/analyzer/parsers/reactParser.ts +24 -0
- package/src/analyzer/parsers/svelteParser.ts +46 -0
- package/src/analyzer/parsers/tsParser.ts +364 -0
- package/src/analyzer/parsers/vueParser.ts +109 -0
- package/src/analyzer/recommendations.ts +432 -0
- package/src/analyzer/resolve.ts +315 -0
- package/src/analyzer/rsc.ts +120 -0
- package/src/analyzer/signals.ts +684 -0
- package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
- package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
- package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
- package/src/analyzer/sources/tauriFileSource.ts +68 -0
- package/src/analyzer/suggestContracts.ts +214 -0
- package/src/analyzer/typeOnlyCandidates.ts +233 -0
- package/src/analyzer/types.ts +537 -0
- package/src/cache/__tests__/cache.test.ts +316 -0
- package/src/cache/index.ts +432 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
- package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
- package/src/codegen/__tests__/configSnippets.test.ts +230 -0
- package/src/codegen/applyTypeOnlyFix.ts +344 -0
- package/src/codegen/configSnippets.ts +172 -0
- package/src/codegen/initConfig.ts +223 -0
- package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
- package/src/config/frontScopeConfig.ts +830 -0
- package/src/diff/__tests__/diffScans.test.ts +103 -0
- package/src/diff/diffScans.ts +61 -0
- package/src/diff/index.ts +2 -0
- package/src/diff/types.ts +39 -0
- package/src/git/__tests__/computeChurn.test.ts +113 -0
- package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
- package/src/git/__tests__/parseGitLog.test.ts +120 -0
- package/src/git/computeChurn.ts +111 -0
- package/src/git/computeTemporalCoupling.ts +114 -0
- package/src/git/index.ts +24 -0
- package/src/git/parseGitLog.ts +124 -0
- package/src/git/readGitHistory.ts +130 -0
- package/src/git/types.ts +119 -0
- package/src/index.ts +137 -0
- package/src/report/__tests__/buildFixPlan.test.ts +357 -0
- package/src/report/__tests__/buildJsonReport.test.ts +34 -0
- package/src/report/buildFixPlan.ts +481 -0
- package/src/report/buildJsonReport.ts +27 -0
- package/src/search/__tests__/parseQuery.test.ts +67 -0
- package/src/search/__tests__/search.test.ts +172 -0
- package/src/search/index.ts +281 -0
- package/src/search/parseQuery.ts +75 -0
- package/src/views/__tests__/analyzerViews.test.ts +558 -0
- package/src/views/analyzerViews.ts +1294 -0
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { diffScans } from '../diffScans';
|
|
3
|
+
import type { Cycle, ModuleNode, ScanResult } from '../../analyzer/types';
|
|
4
|
+
|
|
5
|
+
function mod(id: string, overrides: Partial<ModuleNode> = {}): ModuleNode {
|
|
6
|
+
return {
|
|
7
|
+
id,
|
|
8
|
+
absPath: '/' + id,
|
|
9
|
+
kind: 'util',
|
|
10
|
+
language: 'ts',
|
|
11
|
+
loc: 10,
|
|
12
|
+
exports: [],
|
|
13
|
+
isInfra: false,
|
|
14
|
+
...overrides,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function cycle(id: string, modules: string[]): Cycle {
|
|
19
|
+
return {
|
|
20
|
+
id,
|
|
21
|
+
modules,
|
|
22
|
+
length: modules.length,
|
|
23
|
+
severity: modules.length <= 2 ? 'direct' : 'indirect',
|
|
24
|
+
};
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function scan(overrides: Partial<ScanResult>): ScanResult {
|
|
28
|
+
return {
|
|
29
|
+
project: { id: 'p', name: 'p', rootPath: '/p', detectedFramework: 'unknown' },
|
|
30
|
+
modules: [],
|
|
31
|
+
edges: [],
|
|
32
|
+
cycles: [],
|
|
33
|
+
metrics: {},
|
|
34
|
+
hotZones: [],
|
|
35
|
+
layerViolations: [],
|
|
36
|
+
archDebt: {
|
|
37
|
+
score: 0,
|
|
38
|
+
grade: 'A',
|
|
39
|
+
breakdown: { cycles: 0, layerViolations: 0, hotZones: 0, coupling: 0 },
|
|
40
|
+
},
|
|
41
|
+
recommendations: [],
|
|
42
|
+
contractViolations: [],
|
|
43
|
+
scannedAt: '2026-05-07T00:00:00Z',
|
|
44
|
+
durationMs: 0,
|
|
45
|
+
warnings: [],
|
|
46
|
+
...overrides,
|
|
47
|
+
};
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('diffScans', () => {
|
|
51
|
+
it('reports added and removed modules', () => {
|
|
52
|
+
const prev = scan({ modules: [mod('a'), mod('b')] });
|
|
53
|
+
const next = scan({ modules: [mod('a'), mod('c')] });
|
|
54
|
+
const d = diffScans(prev, next);
|
|
55
|
+
expect(d.addedModules.map((m) => m.id)).toEqual(['c']);
|
|
56
|
+
expect(d.removedModules.map((m) => m.id)).toEqual(['b']);
|
|
57
|
+
expect(d.changedModules).toEqual([]);
|
|
58
|
+
expect(d.summary).toMatchObject({ addedModules: 1, removedModules: 1, changedModules: 0 });
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('reports changed modules only on visible attributes', () => {
|
|
62
|
+
const prev = scan({ modules: [mod('a', { kind: 'util', loc: 10 })] });
|
|
63
|
+
const next = scan({ modules: [mod('a', { kind: 'route', loc: 12 })] });
|
|
64
|
+
const d = diffScans(prev, next);
|
|
65
|
+
expect(d.changedModules).toHaveLength(1);
|
|
66
|
+
expect(d.changedModules[0]?.prev.kind).toBe('util');
|
|
67
|
+
expect(d.changedModules[0]?.next.kind).toBe('route');
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('does not flag identical modules as changed', () => {
|
|
71
|
+
const prev = scan({ modules: [mod('a')] });
|
|
72
|
+
const next = scan({ modules: [mod('a')] });
|
|
73
|
+
expect(diffScans(prev, next).changedModules).toEqual([]);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('matches cycles by module-set ignoring analyzer cycle ids and order', () => {
|
|
77
|
+
const prev = scan({
|
|
78
|
+
cycles: [cycle('c1', ['a', 'b']), cycle('c2', ['x', 'y', 'z'])],
|
|
79
|
+
});
|
|
80
|
+
const next = scan({
|
|
81
|
+
// same set members, different ids and order
|
|
82
|
+
cycles: [cycle('c99', ['b', 'a']), cycle('c100', ['n', 'm'])],
|
|
83
|
+
});
|
|
84
|
+
const d = diffScans(prev, next);
|
|
85
|
+
expect(d.newCycles.map((c) => c.modules.sort())).toEqual([['m', 'n']]);
|
|
86
|
+
expect(d.resolvedCycles.map((c) => c.modules.sort())).toEqual([['x', 'y', 'z']]);
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('returns empty diff for identical scans', () => {
|
|
90
|
+
const s = scan({
|
|
91
|
+
modules: [mod('a'), mod('b')],
|
|
92
|
+
cycles: [cycle('c1', ['a', 'b'])],
|
|
93
|
+
});
|
|
94
|
+
const d = diffScans(s, s);
|
|
95
|
+
expect(d.summary).toEqual({
|
|
96
|
+
addedModules: 0,
|
|
97
|
+
removedModules: 0,
|
|
98
|
+
changedModules: 0,
|
|
99
|
+
newCycles: 0,
|
|
100
|
+
resolvedCycles: 0,
|
|
101
|
+
});
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import type { Cycle, ScanResult } from '../analyzer/types';
|
|
2
|
+
import type { ChangedModule, ScanDiff } from './types';
|
|
3
|
+
|
|
4
|
+
// module id = project-relative path; cycle id = sorted member set.
|
|
5
|
+
// module "changed" only when kind/loc/language differ. no edge-level diff.
|
|
6
|
+
export function diffScans(prev: ScanResult, next: ScanResult): ScanDiff {
|
|
7
|
+
const prevModulesById = new Map(prev.modules.map((m) => [m.id, m]));
|
|
8
|
+
const nextModulesById = new Map(next.modules.map((m) => [m.id, m]));
|
|
9
|
+
|
|
10
|
+
const addedModules = next.modules.filter((m) => !prevModulesById.has(m.id));
|
|
11
|
+
const removedModules = prev.modules.filter((m) => !nextModulesById.has(m.id));
|
|
12
|
+
|
|
13
|
+
const changedModules: ChangedModule[] = [];
|
|
14
|
+
for (const m of next.modules) {
|
|
15
|
+
const before = prevModulesById.get(m.id);
|
|
16
|
+
if (!before) continue;
|
|
17
|
+
if (before.kind !== m.kind || before.loc !== m.loc || before.language !== m.language) {
|
|
18
|
+
changedModules.push({
|
|
19
|
+
id: m.id,
|
|
20
|
+
prev: { kind: before.kind, loc: before.loc, language: before.language },
|
|
21
|
+
next: { kind: m.kind, loc: m.loc, language: m.language },
|
|
22
|
+
});
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const prevCyclesByKey = new Map(prev.cycles.map((c) => [canonicalCycleKey(c), c]));
|
|
27
|
+
const nextCyclesByKey = new Map(next.cycles.map((c) => [canonicalCycleKey(c), c]));
|
|
28
|
+
|
|
29
|
+
const newCycles: Cycle[] = [];
|
|
30
|
+
for (const [key, cycle] of nextCyclesByKey) {
|
|
31
|
+
if (!prevCyclesByKey.has(key)) newCycles.push(cycle);
|
|
32
|
+
}
|
|
33
|
+
const resolvedCycles: Cycle[] = [];
|
|
34
|
+
for (const [key, cycle] of prevCyclesByKey) {
|
|
35
|
+
if (!nextCyclesByKey.has(key)) resolvedCycles.push(cycle);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
return {
|
|
39
|
+
projectId: next.project.id,
|
|
40
|
+
projectName: next.project.name,
|
|
41
|
+
prevScannedAt: prev.scannedAt,
|
|
42
|
+
nextScannedAt: next.scannedAt,
|
|
43
|
+
addedModules,
|
|
44
|
+
removedModules,
|
|
45
|
+
changedModules,
|
|
46
|
+
newCycles,
|
|
47
|
+
resolvedCycles,
|
|
48
|
+
summary: {
|
|
49
|
+
addedModules: addedModules.length,
|
|
50
|
+
removedModules: removedModules.length,
|
|
51
|
+
changedModules: changedModules.length,
|
|
52
|
+
newCycles: newCycles.length,
|
|
53
|
+
resolvedCycles: resolvedCycles.length,
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/** Stable, order-independent identifier for a cycle's member set. */
|
|
59
|
+
function canonicalCycleKey(cycle: Cycle): string {
|
|
60
|
+
return [...cycle.modules].sort().join('\u0001');
|
|
61
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import type { Cycle, ModuleId, ModuleNode } from '../analyzer/types';
|
|
2
|
+
|
|
3
|
+
export interface ScanDiff {
|
|
4
|
+
/** Project id and name from the *current* (newer) scan, for display. */
|
|
5
|
+
projectId: string;
|
|
6
|
+
projectName: string;
|
|
7
|
+
/** ISO timestamps of the compared scans. */
|
|
8
|
+
prevScannedAt: string;
|
|
9
|
+
nextScannedAt: string;
|
|
10
|
+
|
|
11
|
+
/** Modules that exist in `next` but not in `prev`. */
|
|
12
|
+
addedModules: ModuleNode[];
|
|
13
|
+
/** Modules that existed in `prev` but were dropped in `next`. */
|
|
14
|
+
removedModules: ModuleNode[];
|
|
15
|
+
/** Modules whose `kind`, `loc` or `language` changed. */
|
|
16
|
+
changedModules: ChangedModule[];
|
|
17
|
+
|
|
18
|
+
/** Cycles that did not appear in `prev` (matched by sorted module-id set). */
|
|
19
|
+
newCycles: Cycle[];
|
|
20
|
+
/** Cycles that disappeared in `next`. */
|
|
21
|
+
resolvedCycles: Cycle[];
|
|
22
|
+
|
|
23
|
+
/** Aggregate counts for quick rendering at the top of a diff view. */
|
|
24
|
+
summary: ScanDiffSummary;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface ChangedModule {
|
|
28
|
+
id: ModuleId;
|
|
29
|
+
prev: { kind: ModuleNode['kind']; loc: number; language: ModuleNode['language'] };
|
|
30
|
+
next: { kind: ModuleNode['kind']; loc: number; language: ModuleNode['language'] };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface ScanDiffSummary {
|
|
34
|
+
addedModules: number;
|
|
35
|
+
removedModules: number;
|
|
36
|
+
changedModules: number;
|
|
37
|
+
newCycles: number;
|
|
38
|
+
resolvedCycles: number;
|
|
39
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeChurn } from '../computeChurn';
|
|
3
|
+
import type { GitCommit, GitHistory } from '../types';
|
|
4
|
+
import type { ModuleNode } from '../../analyzer/types';
|
|
5
|
+
|
|
6
|
+
function mod(id: string): ModuleNode {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
absPath: id,
|
|
10
|
+
kind: 'unknown',
|
|
11
|
+
language: 'ts',
|
|
12
|
+
loc: 10,
|
|
13
|
+
exports: [],
|
|
14
|
+
isInfra: false,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function commit(
|
|
19
|
+
sha: string,
|
|
20
|
+
email: string,
|
|
21
|
+
date: string,
|
|
22
|
+
changes: Array<{ path: string; added: number; removed: number; renamedFrom?: string }>,
|
|
23
|
+
): GitCommit {
|
|
24
|
+
return {
|
|
25
|
+
sha,
|
|
26
|
+
shortSha: sha.slice(0, 7),
|
|
27
|
+
author: email,
|
|
28
|
+
authorName: email,
|
|
29
|
+
authoredAt: date,
|
|
30
|
+
subject: sha,
|
|
31
|
+
changes: changes.map((c) => ({
|
|
32
|
+
path: c.path,
|
|
33
|
+
added: c.added,
|
|
34
|
+
removed: c.removed,
|
|
35
|
+
...(c.renamedFrom ? { renamedFrom: c.renamedFrom } : {}),
|
|
36
|
+
})),
|
|
37
|
+
};
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function history(commits: GitCommit[]): GitHistory {
|
|
41
|
+
return { since: '90d', until: 'now', commits, includesMerges: false };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
describe('computeChurn', () => {
|
|
45
|
+
it('counts commits, lines, authors per module', () => {
|
|
46
|
+
const modules = [mod('src/a.ts'), mod('src/b.ts')];
|
|
47
|
+
const h = history([
|
|
48
|
+
commit('1', 'alice@x.com', '2026-05-01T10:00:00Z', [
|
|
49
|
+
{ path: 'src/a.ts', added: 5, removed: 2 },
|
|
50
|
+
]),
|
|
51
|
+
commit('2', 'bob@x.com', '2026-05-03T10:00:00Z', [
|
|
52
|
+
{ path: 'src/a.ts', added: 1, removed: 0 },
|
|
53
|
+
{ path: 'src/b.ts', added: 3, removed: 1 },
|
|
54
|
+
]),
|
|
55
|
+
commit('3', 'alice@x.com', '2026-05-05T10:00:00Z', [
|
|
56
|
+
{ path: 'src/a.ts', added: 0, removed: 4 },
|
|
57
|
+
]),
|
|
58
|
+
]);
|
|
59
|
+
const churn = computeChurn({ modules, history: h });
|
|
60
|
+
expect(churn['src/a.ts']?.commits).toBe(3);
|
|
61
|
+
expect(churn['src/a.ts']?.linesChanged).toBe(5 + 2 + 1 + 0 + 4);
|
|
62
|
+
expect(churn['src/a.ts']?.authorCount).toBe(2);
|
|
63
|
+
expect(churn['src/a.ts']?.lastTouchedAt).toBe('2026-05-05T10:00:00Z');
|
|
64
|
+
expect(churn['src/b.ts']?.commits).toBe(1);
|
|
65
|
+
expect(churn['src/b.ts']?.authorCount).toBe(1);
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('sorts authors by commit count desc and collapses past top 5', () => {
|
|
69
|
+
const modules = [mod('src/a.ts')];
|
|
70
|
+
const ppl = ['a', 'b', 'c', 'd', 'e', 'f', 'g'];
|
|
71
|
+
const counts = [10, 5, 5, 3, 2, 2, 1];
|
|
72
|
+
const commits: GitCommit[] = [];
|
|
73
|
+
let n = 0;
|
|
74
|
+
for (let i = 0; i < ppl.length; i++) {
|
|
75
|
+
for (let j = 0; j < counts[i]!; j++) {
|
|
76
|
+
commits.push(
|
|
77
|
+
commit(`${++n}`.padStart(7, '0'), `${ppl[i]}@x.com`, '2026-01-01T00:00:00Z', [
|
|
78
|
+
{ path: 'src/a.ts', added: 1, removed: 0 },
|
|
79
|
+
]),
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
const churn = computeChurn({ modules, history: history(commits) });
|
|
84
|
+
const authors = churn['src/a.ts']?.authors ?? [];
|
|
85
|
+
expect(authors).toHaveLength(6); // top 5 + __other__
|
|
86
|
+
expect(authors[0]).toEqual({ author: 'a@x.com', commits: 10 });
|
|
87
|
+
expect(authors[5]).toEqual({ author: '__other__', commits: 2 + 1 }); // f+g
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('charges renames against both old and new paths', () => {
|
|
91
|
+
const modules = [mod('src/old.ts'), mod('src/new.ts')];
|
|
92
|
+
const h = history([
|
|
93
|
+
commit('1', 'a@x', '2026-05-01T10:00:00Z', [
|
|
94
|
+
{ path: 'src/new.ts', renamedFrom: 'src/old.ts', added: 0, removed: 0 },
|
|
95
|
+
]),
|
|
96
|
+
]);
|
|
97
|
+
const churn = computeChurn({ modules, history: h });
|
|
98
|
+
expect(churn['src/old.ts']?.commits).toBe(1);
|
|
99
|
+
expect(churn['src/new.ts']?.commits).toBe(1);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('skips paths that are not in the modules set', () => {
|
|
103
|
+
const modules = [mod('src/a.ts')];
|
|
104
|
+
const h = history([
|
|
105
|
+
commit('1', 'a@x', '2026-05-01T10:00:00Z', [
|
|
106
|
+
{ path: 'src/a.ts', added: 1, removed: 0 },
|
|
107
|
+
{ path: 'README.md', added: 5, removed: 0 },
|
|
108
|
+
]),
|
|
109
|
+
]);
|
|
110
|
+
const churn = computeChurn({ modules, history: h });
|
|
111
|
+
expect(Object.keys(churn)).toEqual(['src/a.ts']);
|
|
112
|
+
});
|
|
113
|
+
});
|
|
@@ -0,0 +1,125 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { computeTemporalCoupling } from '../computeTemporalCoupling';
|
|
3
|
+
import type { GitCommit, GitHistory } from '../types';
|
|
4
|
+
import type { DependencyEdge, ModuleNode } from '../../analyzer/types';
|
|
5
|
+
|
|
6
|
+
function mod(id: string): ModuleNode {
|
|
7
|
+
return {
|
|
8
|
+
id,
|
|
9
|
+
absPath: id,
|
|
10
|
+
kind: 'unknown',
|
|
11
|
+
language: 'ts',
|
|
12
|
+
loc: 10,
|
|
13
|
+
exports: [],
|
|
14
|
+
isInfra: false,
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function commit(sha: string, paths: string[]): GitCommit {
|
|
19
|
+
return {
|
|
20
|
+
sha,
|
|
21
|
+
shortSha: sha.slice(0, 7),
|
|
22
|
+
author: 'a@x.com',
|
|
23
|
+
authorName: 'a',
|
|
24
|
+
authoredAt: '2026-05-01T10:00:00Z',
|
|
25
|
+
subject: sha,
|
|
26
|
+
changes: paths.map((p) => ({ path: p, added: 1, removed: 0 })),
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function history(commits: GitCommit[]): GitHistory {
|
|
31
|
+
return { since: '90d', until: 'now', commits, includesMerges: false };
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const NO_EDGES: DependencyEdge[] = [];
|
|
35
|
+
|
|
36
|
+
describe('computeTemporalCoupling', () => {
|
|
37
|
+
it('finds tight pairs above thresholds', () => {
|
|
38
|
+
const modules = [mod('src/a.ts'), mod('src/b.ts'), mod('src/c.ts')];
|
|
39
|
+
const h = history([
|
|
40
|
+
commit('1', ['src/a.ts', 'src/b.ts']),
|
|
41
|
+
commit('2', ['src/a.ts', 'src/b.ts']),
|
|
42
|
+
commit('3', ['src/a.ts', 'src/b.ts']),
|
|
43
|
+
commit('4', ['src/a.ts']),
|
|
44
|
+
commit('5', ['src/c.ts']),
|
|
45
|
+
]);
|
|
46
|
+
const out = computeTemporalCoupling({ modules, edges: NO_EDGES, history: h });
|
|
47
|
+
expect(out).toHaveLength(1);
|
|
48
|
+
expect(out[0]?.a).toBe('src/a.ts');
|
|
49
|
+
expect(out[0]?.b).toBe('src/b.ts');
|
|
50
|
+
expect(out[0]?.coOccurrences).toBe(3);
|
|
51
|
+
// a touched 4 times, b touched 3 times -> min(3/4, 3/3) = 0.75
|
|
52
|
+
expect(out[0]?.score).toBeCloseTo(0.75);
|
|
53
|
+
expect(out[0]?.hidden).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('marks pairs with a static edge as not hidden', () => {
|
|
57
|
+
const modules = [mod('src/a.ts'), mod('src/b.ts')];
|
|
58
|
+
const edges: DependencyEdge[] = [
|
|
59
|
+
{ from: 'src/a.ts', to: 'src/b.ts', kind: 'static', specifier: './b', resolved: true },
|
|
60
|
+
];
|
|
61
|
+
const h = history([
|
|
62
|
+
commit('1', ['src/a.ts', 'src/b.ts']),
|
|
63
|
+
commit('2', ['src/a.ts', 'src/b.ts']),
|
|
64
|
+
commit('3', ['src/a.ts', 'src/b.ts']),
|
|
65
|
+
]);
|
|
66
|
+
const out = computeTemporalCoupling({ modules, edges, history: h });
|
|
67
|
+
expect(out[0]?.hidden).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it('skips fan-out commits past the cap', () => {
|
|
71
|
+
const modules = Array.from({ length: 100 }, (_, i) => mod(`src/m${i}.ts`));
|
|
72
|
+
// One mass-edit commit touching all 100 modules. Without the cap, every
|
|
73
|
+
// pair would receive a co-occurrence — 4950 false positives.
|
|
74
|
+
const c = commit(
|
|
75
|
+
'mass',
|
|
76
|
+
modules.map((m) => m.id),
|
|
77
|
+
);
|
|
78
|
+
const h = history([c, c, c]);
|
|
79
|
+
const out = computeTemporalCoupling({
|
|
80
|
+
modules,
|
|
81
|
+
edges: NO_EDGES,
|
|
82
|
+
history: h,
|
|
83
|
+
thresholds: { commitFanOutCap: 50 },
|
|
84
|
+
});
|
|
85
|
+
expect(out).toHaveLength(0);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it('honours minCoOccurrences', () => {
|
|
89
|
+
const modules = [mod('src/a.ts'), mod('src/b.ts')];
|
|
90
|
+
const h = history([
|
|
91
|
+
commit('1', ['src/a.ts', 'src/b.ts']),
|
|
92
|
+
commit('2', ['src/a.ts', 'src/b.ts']),
|
|
93
|
+
]);
|
|
94
|
+
const out = computeTemporalCoupling({
|
|
95
|
+
modules,
|
|
96
|
+
edges: NO_EDGES,
|
|
97
|
+
history: h,
|
|
98
|
+
thresholds: { minCoOccurrences: 3 },
|
|
99
|
+
});
|
|
100
|
+
expect(out).toHaveLength(0);
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it('sorts hidden pairs first then by score desc', () => {
|
|
104
|
+
const modules = ['src/a.ts', 'src/b.ts', 'src/c.ts', 'src/d.ts'].map(mod);
|
|
105
|
+
// Pair (a,b): static edge, perfect score. Pair (c,d): no edge, score 0.6.
|
|
106
|
+
const edges: DependencyEdge[] = [
|
|
107
|
+
{ from: 'src/a.ts', to: 'src/b.ts', kind: 'static', specifier: './b', resolved: true },
|
|
108
|
+
];
|
|
109
|
+
const h = history([
|
|
110
|
+
commit('1', ['src/a.ts', 'src/b.ts']),
|
|
111
|
+
commit('2', ['src/a.ts', 'src/b.ts']),
|
|
112
|
+
commit('3', ['src/a.ts', 'src/b.ts']),
|
|
113
|
+
commit('4', ['src/c.ts', 'src/d.ts']),
|
|
114
|
+
commit('5', ['src/c.ts', 'src/d.ts']),
|
|
115
|
+
commit('6', ['src/c.ts', 'src/d.ts']),
|
|
116
|
+
commit('7', ['src/c.ts']),
|
|
117
|
+
commit('8', ['src/d.ts']),
|
|
118
|
+
]);
|
|
119
|
+
const out = computeTemporalCoupling({ modules, edges, history: h });
|
|
120
|
+
expect(out).toHaveLength(2);
|
|
121
|
+
expect(out[0]?.hidden).toBe(true); // c,d shown first despite lower score
|
|
122
|
+
expect(out[0]?.a).toBe('src/c.ts');
|
|
123
|
+
expect(out[1]?.hidden).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
});
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { expandRename, parseGitLog } from '../parseGitLog';
|
|
3
|
+
|
|
4
|
+
const SOH = '\x01';
|
|
5
|
+
|
|
6
|
+
function commitHeader(
|
|
7
|
+
sha: string,
|
|
8
|
+
short: string,
|
|
9
|
+
name: string,
|
|
10
|
+
email: string,
|
|
11
|
+
date: string,
|
|
12
|
+
subject: string,
|
|
13
|
+
): string {
|
|
14
|
+
return `__FS_COMMIT__${SOH}${sha}${SOH}${short}${SOH}${name}${SOH}${email}${SOH}${date}${SOH}${subject}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
describe('parseGitLog', () => {
|
|
18
|
+
it('parses a single commit with two file changes', () => {
|
|
19
|
+
const raw =
|
|
20
|
+
commitHeader(
|
|
21
|
+
'a'.repeat(40),
|
|
22
|
+
'aaaaaaa',
|
|
23
|
+
'Alice',
|
|
24
|
+
'alice@example.com',
|
|
25
|
+
'2026-05-01T10:00:00+03:00',
|
|
26
|
+
'feat: x',
|
|
27
|
+
) + '\n10\t2\tsrc/a.ts\n3\t0\tsrc/b.ts';
|
|
28
|
+
const commits = parseGitLog(raw);
|
|
29
|
+
expect(commits).toHaveLength(1);
|
|
30
|
+
expect(commits[0]?.sha).toBe('a'.repeat(40));
|
|
31
|
+
expect(commits[0]?.author).toBe('alice@example.com');
|
|
32
|
+
expect(commits[0]?.subject).toBe('feat: x');
|
|
33
|
+
expect(commits[0]?.changes).toEqual([
|
|
34
|
+
{ path: 'src/a.ts', added: 10, removed: 2 },
|
|
35
|
+
{ path: 'src/b.ts', added: 3, removed: 0 },
|
|
36
|
+
]);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it('lowercases author email', () => {
|
|
40
|
+
const raw =
|
|
41
|
+
commitHeader(
|
|
42
|
+
'b'.repeat(40),
|
|
43
|
+
'bbbbbbb',
|
|
44
|
+
'Bob',
|
|
45
|
+
'BOB@Example.COM',
|
|
46
|
+
'2026-05-02T10:00:00+03:00',
|
|
47
|
+
'fix',
|
|
48
|
+
) + '\n1\t1\tsrc/x.ts';
|
|
49
|
+
expect(parseGitLog(raw)[0]?.author).toBe('bob@example.com');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('parses two consecutive commits', () => {
|
|
53
|
+
const raw =
|
|
54
|
+
commitHeader('a'.repeat(40), 'aaaaaaa', 'A', 'a@x', '2026-05-01T10:00:00+03:00', 's1') +
|
|
55
|
+
'\n1\t0\tsrc/a.ts' +
|
|
56
|
+
commitHeader('b'.repeat(40), 'bbbbbbb', 'B', 'b@x', '2026-05-02T10:00:00+03:00', 's2') +
|
|
57
|
+
'\n2\t3\tsrc/b.ts';
|
|
58
|
+
const commits = parseGitLog(raw);
|
|
59
|
+
expect(commits).toHaveLength(2);
|
|
60
|
+
expect(commits[0]?.subject).toBe('s1');
|
|
61
|
+
expect(commits[1]?.subject).toBe('s2');
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
it('treats binary-file diffs (-) as null lines', () => {
|
|
65
|
+
const raw =
|
|
66
|
+
commitHeader('c'.repeat(40), 'ccccccc', 'C', 'c@x', '2026-05-03T10:00:00+03:00', 'bin') +
|
|
67
|
+
'\n-\t-\tassets/logo.png';
|
|
68
|
+
expect(parseGitLog(raw)[0]?.changes).toEqual([
|
|
69
|
+
{ path: 'assets/logo.png', added: null, removed: null },
|
|
70
|
+
]);
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
it('handles whole-path rename "old => new"', () => {
|
|
74
|
+
const raw =
|
|
75
|
+
commitHeader('d'.repeat(40), 'ddddddd', 'D', 'd@x', '2026-05-04T10:00:00+03:00', 'mv') +
|
|
76
|
+
'\n5\t5\tsrc/old/x.ts => src/new/x.ts';
|
|
77
|
+
const c = parseGitLog(raw)[0]?.changes[0];
|
|
78
|
+
expect(c).toEqual({
|
|
79
|
+
path: 'src/new/x.ts',
|
|
80
|
+
renamedFrom: 'src/old/x.ts',
|
|
81
|
+
added: 5,
|
|
82
|
+
removed: 5,
|
|
83
|
+
});
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it('handles braced rename "src/{old => new}/x.ts"', () => {
|
|
87
|
+
const raw =
|
|
88
|
+
commitHeader('e'.repeat(40), 'eeeeeee', 'E', 'e@x', '2026-05-05T10:00:00+03:00', 'mv2') +
|
|
89
|
+
'\n0\t0\tsrc/{old => new}/x.ts';
|
|
90
|
+
expect(parseGitLog(raw)[0]?.changes[0]).toEqual({
|
|
91
|
+
path: 'src/new/x.ts',
|
|
92
|
+
renamedFrom: 'src/old/x.ts',
|
|
93
|
+
added: 0,
|
|
94
|
+
removed: 0,
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('returns empty array for empty input', () => {
|
|
99
|
+
expect(parseGitLog('')).toEqual([]);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('skips malformed segments without throwing', () => {
|
|
103
|
+
const raw =
|
|
104
|
+
'__FS_COMMIT__\x01garbage\nweird stuff\n' +
|
|
105
|
+
commitHeader('f'.repeat(40), 'fffffff', 'F', 'f@x', '2026-05-06T10:00:00+03:00', 'ok') +
|
|
106
|
+
'\n1\t1\tsrc/ok.ts';
|
|
107
|
+
const commits = parseGitLog(raw);
|
|
108
|
+
expect(commits).toHaveLength(1);
|
|
109
|
+
expect(commits[0]?.subject).toBe('ok');
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
describe('expandRename', () => {
|
|
114
|
+
it('handles brace expansion with empty old part', () => {
|
|
115
|
+
expect(expandRename('src/{ => new}/x.ts')).toEqual({ from: 'src/x.ts', to: 'src/new/x.ts' });
|
|
116
|
+
});
|
|
117
|
+
it('handles brace expansion with empty new part', () => {
|
|
118
|
+
expect(expandRename('src/{old => }/x.ts')).toEqual({ from: 'src/old/x.ts', to: 'src/x.ts' });
|
|
119
|
+
});
|
|
120
|
+
});
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
// Aggregate `GitHistory` into per-module churn metrics.
|
|
2
|
+
//
|
|
3
|
+
// `GitHistory` knows about repo paths, not module ids. The mapping is just
|
|
4
|
+
// "the path equals the moduleId" today (analyzer normalizes both to repo-
|
|
5
|
+
// root-relative POSIX paths), but we keep the indirection in case we ever
|
|
6
|
+
// stop using paths as ids.
|
|
7
|
+
|
|
8
|
+
import type { ModuleNode } from '../analyzer/types';
|
|
9
|
+
import type { ChurnAuthor, ChurnByModule, ChurnMetric, GitCommit, GitHistory } from './types';
|
|
10
|
+
|
|
11
|
+
export interface ComputeChurnInput {
|
|
12
|
+
modules: ModuleNode[];
|
|
13
|
+
history: GitHistory;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
const TOP_AUTHORS = 5;
|
|
17
|
+
|
|
18
|
+
export function computeChurn(input: ComputeChurnInput): ChurnByModule {
|
|
19
|
+
const moduleIds = new Set(input.modules.map((m) => m.id));
|
|
20
|
+
// path -> aggregator. We use a map so we can also collapse renames: when
|
|
21
|
+
// a commit renames `old -> new`, both paths receive the touch (the same
|
|
22
|
+
// physical change happened on both names through history).
|
|
23
|
+
const acc = new Map<string, AccPerModule>();
|
|
24
|
+
|
|
25
|
+
for (const commit of input.history.commits) {
|
|
26
|
+
const touchedPaths = pathsTouched(commit, moduleIds);
|
|
27
|
+
for (const path of touchedPaths) {
|
|
28
|
+
let entry = acc.get(path);
|
|
29
|
+
if (!entry) {
|
|
30
|
+
entry = makeEntry(path);
|
|
31
|
+
acc.set(path, entry);
|
|
32
|
+
}
|
|
33
|
+
entry.commits += 1;
|
|
34
|
+
entry.lines += linesIn(commit, path);
|
|
35
|
+
entry.authorsByEmail.set(commit.author, (entry.authorsByEmail.get(commit.author) ?? 0) + 1);
|
|
36
|
+
if (entry.lastTouchedAt < commit.authoredAt) {
|
|
37
|
+
entry.lastTouchedAt = commit.authoredAt;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const out: ChurnByModule = {};
|
|
43
|
+
for (const [path, entry] of acc) {
|
|
44
|
+
out[path] = finalize(entry);
|
|
45
|
+
}
|
|
46
|
+
return out;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
interface AccPerModule {
|
|
50
|
+
moduleId: string;
|
|
51
|
+
commits: number;
|
|
52
|
+
lines: number;
|
|
53
|
+
authorsByEmail: Map<string, number>;
|
|
54
|
+
lastTouchedAt: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function makeEntry(moduleId: string): AccPerModule {
|
|
58
|
+
return {
|
|
59
|
+
moduleId,
|
|
60
|
+
commits: 0,
|
|
61
|
+
lines: 0,
|
|
62
|
+
authorsByEmail: new Map(),
|
|
63
|
+
lastTouchedAt: '',
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function pathsTouched(commit: GitCommit, validIds: Set<string>): Set<string> {
|
|
68
|
+
const out = new Set<string>();
|
|
69
|
+
for (const change of commit.changes) {
|
|
70
|
+
if (validIds.has(change.path)) out.add(change.path);
|
|
71
|
+
// Renames: charge the old name too — its history matters for churn even
|
|
72
|
+
// if the file no longer exists at HEAD.
|
|
73
|
+
if (change.renamedFrom && validIds.has(change.renamedFrom)) {
|
|
74
|
+
out.add(change.renamedFrom);
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
return out;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function linesIn(commit: GitCommit, path: string): number {
|
|
81
|
+
let sum = 0;
|
|
82
|
+
for (const c of commit.changes) {
|
|
83
|
+
if (c.path !== path && c.renamedFrom !== path) continue;
|
|
84
|
+
if (c.added !== null) sum += c.added;
|
|
85
|
+
if (c.removed !== null) sum += c.removed;
|
|
86
|
+
}
|
|
87
|
+
return sum;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function finalize(entry: AccPerModule): ChurnMetric {
|
|
91
|
+
const authorsAll: ChurnAuthor[] = [...entry.authorsByEmail.entries()]
|
|
92
|
+
.map(([author, commits]) => ({ author, commits }))
|
|
93
|
+
.sort((a, b) => b.commits - a.commits || a.author.localeCompare(b.author));
|
|
94
|
+
let authors: ChurnAuthor[];
|
|
95
|
+
if (authorsAll.length <= TOP_AUTHORS) {
|
|
96
|
+
authors = authorsAll;
|
|
97
|
+
} else {
|
|
98
|
+
const top = authorsAll.slice(0, TOP_AUTHORS);
|
|
99
|
+
const rest = authorsAll.slice(TOP_AUTHORS);
|
|
100
|
+
const otherCommits = rest.reduce((sum, a) => sum + a.commits, 0);
|
|
101
|
+
authors = [...top, { author: '__other__', commits: otherCommits }];
|
|
102
|
+
}
|
|
103
|
+
return {
|
|
104
|
+
moduleId: entry.moduleId,
|
|
105
|
+
commits: entry.commits,
|
|
106
|
+
linesChanged: entry.lines,
|
|
107
|
+
authorCount: entry.authorsByEmail.size,
|
|
108
|
+
lastTouchedAt: entry.lastTouchedAt,
|
|
109
|
+
authors,
|
|
110
|
+
};
|
|
111
|
+
}
|