@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.
Files changed (112) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +62 -0
  3. package/package.json +36 -0
  4. package/src/README.md +4 -0
  5. package/src/analyzer/__tests__/__snapshots__/referenceSnapshot.test.ts.snap +145 -0
  6. package/src/analyzer/__tests__/_paths.ts +8 -0
  7. package/src/analyzer/__tests__/analyze.test.ts +522 -0
  8. package/src/analyzer/__tests__/archDebt.test.ts +111 -0
  9. package/src/analyzer/__tests__/asyncLifecycleRisk.test.ts +122 -0
  10. package/src/analyzer/__tests__/browserFsAccessFileSource.test.ts +97 -0
  11. package/src/analyzer/__tests__/bundle.test.ts +191 -0
  12. package/src/analyzer/__tests__/classify.test.ts +99 -0
  13. package/src/analyzer/__tests__/contracts.test.ts +372 -0
  14. package/src/analyzer/__tests__/crossSourceConsistency.test.ts +317 -0
  15. package/src/analyzer/__tests__/cyclePatterns.test.ts +132 -0
  16. package/src/analyzer/__tests__/cycles.test.ts +74 -0
  17. package/src/analyzer/__tests__/detect.test.ts +62 -0
  18. package/src/analyzer/__tests__/discover.test.ts +68 -0
  19. package/src/analyzer/__tests__/displayId.test.ts +30 -0
  20. package/src/analyzer/__tests__/feedbackArcSet.test.ts +168 -0
  21. package/src/analyzer/__tests__/inMemoryFileSource.test.ts +34 -0
  22. package/src/analyzer/__tests__/incremental.test.ts +154 -0
  23. package/src/analyzer/__tests__/layers.test.ts +87 -0
  24. package/src/analyzer/__tests__/layersOverrides.test.ts +120 -0
  25. package/src/analyzer/__tests__/memoryRisk.test.ts +132 -0
  26. package/src/analyzer/__tests__/metrics.test.ts +59 -0
  27. package/src/analyzer/__tests__/parserRegistry.test.ts +54 -0
  28. package/src/analyzer/__tests__/parsers.test.ts +187 -0
  29. package/src/analyzer/__tests__/reactParser.test.ts +93 -0
  30. package/src/analyzer/__tests__/recommendations.test.ts +171 -0
  31. package/src/analyzer/__tests__/referenceSnapshot.test.ts +63 -0
  32. package/src/analyzer/__tests__/resolve.test.ts +294 -0
  33. package/src/analyzer/__tests__/rsc.test.ts +130 -0
  34. package/src/analyzer/__tests__/signals.test.ts +316 -0
  35. package/src/analyzer/__tests__/suggestContracts.test.ts +108 -0
  36. package/src/analyzer/__tests__/svelteParser.test.ts +108 -0
  37. package/src/analyzer/__tests__/typeOnlyCandidates.test.ts +163 -0
  38. package/src/analyzer/__tests__/vueAutoImport.test.ts +177 -0
  39. package/src/analyzer/archDebt.ts +68 -0
  40. package/src/analyzer/asyncLifecycleRisk.ts +234 -0
  41. package/src/analyzer/buildGraph.ts +683 -0
  42. package/src/analyzer/bundle/analyzeBundle.ts +147 -0
  43. package/src/analyzer/bundle/index.ts +12 -0
  44. package/src/analyzer/bundle/parseStats.ts +152 -0
  45. package/src/analyzer/bundle/types.ts +85 -0
  46. package/src/analyzer/classify.ts +54 -0
  47. package/src/analyzer/contracts.ts +265 -0
  48. package/src/analyzer/cyclePatterns.ts +138 -0
  49. package/src/analyzer/cycles.ts +98 -0
  50. package/src/analyzer/detect.ts +34 -0
  51. package/src/analyzer/discover.ts +131 -0
  52. package/src/analyzer/displayId.ts +21 -0
  53. package/src/analyzer/entryPoints.ts +136 -0
  54. package/src/analyzer/feedbackArcSet.ts +332 -0
  55. package/src/analyzer/fileSource.ts +8 -0
  56. package/src/analyzer/hotZones.ts +17 -0
  57. package/src/analyzer/incremental.ts +455 -0
  58. package/src/analyzer/index.ts +444 -0
  59. package/src/analyzer/layers.ts +183 -0
  60. package/src/analyzer/loadAliases.ts +288 -0
  61. package/src/analyzer/memoryRisk.ts +345 -0
  62. package/src/analyzer/metrics.ts +156 -0
  63. package/src/analyzer/parsers/index.ts +62 -0
  64. package/src/analyzer/parsers/reactParser.ts +24 -0
  65. package/src/analyzer/parsers/svelteParser.ts +46 -0
  66. package/src/analyzer/parsers/tsParser.ts +364 -0
  67. package/src/analyzer/parsers/vueParser.ts +109 -0
  68. package/src/analyzer/recommendations.ts +432 -0
  69. package/src/analyzer/resolve.ts +315 -0
  70. package/src/analyzer/rsc.ts +120 -0
  71. package/src/analyzer/signals.ts +684 -0
  72. package/src/analyzer/sources/browserFsAccessFileSource.ts +132 -0
  73. package/src/analyzer/sources/inMemoryFileSource.ts +24 -0
  74. package/src/analyzer/sources/nodeFsFileSource.ts +93 -0
  75. package/src/analyzer/sources/tauriFileSource.ts +68 -0
  76. package/src/analyzer/suggestContracts.ts +214 -0
  77. package/src/analyzer/typeOnlyCandidates.ts +233 -0
  78. package/src/analyzer/types.ts +537 -0
  79. package/src/cache/__tests__/cache.test.ts +316 -0
  80. package/src/cache/index.ts +432 -0
  81. package/src/codegen/__tests__/applyTypeOnlyFix.integration.test.ts +62 -0
  82. package/src/codegen/__tests__/applyTypeOnlyFix.test.ts +176 -0
  83. package/src/codegen/__tests__/configSnippets.test.ts +230 -0
  84. package/src/codegen/applyTypeOnlyFix.ts +344 -0
  85. package/src/codegen/configSnippets.ts +172 -0
  86. package/src/codegen/initConfig.ts +223 -0
  87. package/src/config/__tests__/frontScopeConfig.test.ts +187 -0
  88. package/src/config/frontScopeConfig.ts +830 -0
  89. package/src/diff/__tests__/diffScans.test.ts +103 -0
  90. package/src/diff/diffScans.ts +61 -0
  91. package/src/diff/index.ts +2 -0
  92. package/src/diff/types.ts +39 -0
  93. package/src/git/__tests__/computeChurn.test.ts +113 -0
  94. package/src/git/__tests__/computeTemporalCoupling.test.ts +125 -0
  95. package/src/git/__tests__/parseGitLog.test.ts +120 -0
  96. package/src/git/computeChurn.ts +111 -0
  97. package/src/git/computeTemporalCoupling.ts +114 -0
  98. package/src/git/index.ts +24 -0
  99. package/src/git/parseGitLog.ts +124 -0
  100. package/src/git/readGitHistory.ts +130 -0
  101. package/src/git/types.ts +119 -0
  102. package/src/index.ts +137 -0
  103. package/src/report/__tests__/buildFixPlan.test.ts +357 -0
  104. package/src/report/__tests__/buildJsonReport.test.ts +34 -0
  105. package/src/report/buildFixPlan.ts +481 -0
  106. package/src/report/buildJsonReport.ts +27 -0
  107. package/src/search/__tests__/parseQuery.test.ts +67 -0
  108. package/src/search/__tests__/search.test.ts +172 -0
  109. package/src/search/index.ts +281 -0
  110. package/src/search/parseQuery.ts +75 -0
  111. package/src/views/__tests__/analyzerViews.test.ts +558 -0
  112. package/src/views/analyzerViews.ts +1294 -0
@@ -0,0 +1,114 @@
1
+ // Temporal coupling detector.
2
+ //
3
+ // Two modules are "temporally coupled" when they consistently change in the
4
+ // same commit. We compute a symmetric score:
5
+ //
6
+ // coupling(a, b) = min( cooccurrences / commits(a),
7
+ // cooccurrences / commits(b) )
8
+ //
9
+ // Using `min` instead of `max` (or asymmetric scores) keeps the metric
10
+ // conservative: if `a` changes 100 times and `b` only 5 times, and they
11
+ // always co-change, the score isn't 100% (which the asymmetric view would
12
+ // suggest) — it's 0.05 in the `a` direction. We surface only pairs where
13
+ // BOTH directions agree, which is what `min` captures.
14
+ //
15
+ // We skip mass-edit commits (lockfile bumps, format-everything PRs) because
16
+ // every pair in such a commit gets a free co-occurrence which dilutes the
17
+ // signal.
18
+
19
+ import type { GitHistory, TemporalCoupling, TemporalCouplingThresholds } from './types';
20
+ import type { DependencyEdge, ModuleNode } from '../analyzer/types';
21
+
22
+ export interface ComputeTemporalCouplingInput {
23
+ modules: ModuleNode[];
24
+ edges: DependencyEdge[];
25
+ history: GitHistory;
26
+ thresholds?: TemporalCouplingThresholds;
27
+ }
28
+
29
+ const DEFAULTS: Required<TemporalCouplingThresholds> = {
30
+ minCoOccurrences: 3,
31
+ minScore: 0.5,
32
+ maxPairs: 100,
33
+ commitFanOutCap: 50,
34
+ };
35
+
36
+ export function computeTemporalCoupling(input: ComputeTemporalCouplingInput): TemporalCoupling[] {
37
+ const t = { ...DEFAULTS, ...(input.thresholds ?? {}) };
38
+ const moduleIds = new Set(input.modules.map((m) => m.id));
39
+
40
+ const commitsByModule = new Map<string, number>();
41
+ // Pair key = `${a}\x00${b}` with a < b lexically.
42
+ const coOccurrences = new Map<string, number>();
43
+
44
+ for (const commit of input.history.commits) {
45
+ const touched = collectTouchedModules(commit, moduleIds);
46
+ if (touched.length === 0) continue;
47
+ if (touched.length > t.commitFanOutCap) continue;
48
+ for (const m of touched) {
49
+ commitsByModule.set(m, (commitsByModule.get(m) ?? 0) + 1);
50
+ }
51
+ // Pair contribution. `touched` is sorted in `collectTouchedModules`.
52
+ for (let i = 0; i < touched.length; i++) {
53
+ for (let j = i + 1; j < touched.length; j++) {
54
+ const key = `${touched[i]}\x00${touched[j]}`;
55
+ coOccurrences.set(key, (coOccurrences.get(key) ?? 0) + 1);
56
+ }
57
+ }
58
+ }
59
+
60
+ const staticPairs = collectStaticPairs(input.edges);
61
+ const out: TemporalCoupling[] = [];
62
+ for (const [key, n] of coOccurrences) {
63
+ if (n < t.minCoOccurrences) continue;
64
+ const sep = key.indexOf('\x00');
65
+ const a = key.slice(0, sep);
66
+ const b = key.slice(sep + 1);
67
+ const ca = commitsByModule.get(a);
68
+ const cb = commitsByModule.get(b);
69
+ if (!ca || !cb) continue;
70
+ const scoreA = n / ca;
71
+ const scoreB = n / cb;
72
+ const score = Math.min(scoreA, scoreB);
73
+ if (score < t.minScore) continue;
74
+ const hidden = !staticPairs.has(key);
75
+ out.push({ a, b, coOccurrences: n, scoreA, scoreB, score, hidden });
76
+ }
77
+
78
+ // Hidden couplings first (most informative), then by score, then by
79
+ // co-occurrences, then alphabetically — stable across runs.
80
+ out.sort((x, y) => {
81
+ if (x.hidden !== y.hidden) return x.hidden ? -1 : 1;
82
+ if (y.score !== x.score) return y.score - x.score;
83
+ if (y.coOccurrences !== x.coOccurrences) return y.coOccurrences - x.coOccurrences;
84
+ return x.a.localeCompare(y.a) || x.b.localeCompare(y.b);
85
+ });
86
+
87
+ if (out.length > t.maxPairs) out.length = t.maxPairs;
88
+ return out;
89
+ }
90
+
91
+ function collectTouchedModules(
92
+ commit: GitHistory['commits'][number],
93
+ validIds: Set<string>,
94
+ ): string[] {
95
+ const touched = new Set<string>();
96
+ for (const change of commit.changes) {
97
+ if (validIds.has(change.path)) touched.add(change.path);
98
+ if (change.renamedFrom && validIds.has(change.renamedFrom)) {
99
+ touched.add(change.renamedFrom);
100
+ }
101
+ }
102
+ return [...touched].sort();
103
+ }
104
+
105
+ function collectStaticPairs(edges: DependencyEdge[]): Set<string> {
106
+ const out = new Set<string>();
107
+ for (const e of edges) {
108
+ if (e.from === e.to) continue;
109
+ const a = e.from < e.to ? e.from : e.to;
110
+ const b = e.from < e.to ? e.to : e.from;
111
+ out.add(`${a}\x00${b}`);
112
+ }
113
+ return out;
114
+ }
@@ -0,0 +1,24 @@
1
+ // Git barrel. Node-only consumers (CLI, scripts) can import the
2
+ // entire module; browser bundles only ever pull `parseGitLog` / `computeChurn`
3
+ // (pure, no `node:*`) via deep imports if needed.
4
+
5
+ export type {
6
+ ChurnAuthor,
7
+ ChurnByModule,
8
+ ChurnMetric,
9
+ GitCommit,
10
+ GitFileChange,
11
+ GitHistory,
12
+ } from './types';
13
+ export { parseGitLog, expandRename } from './parseGitLog';
14
+ export { computeChurn, type ComputeChurnInput } from './computeChurn';
15
+ export {
16
+ computeTemporalCoupling,
17
+ type ComputeTemporalCouplingInput,
18
+ } from './computeTemporalCoupling';
19
+ export {
20
+ readGitHistory,
21
+ expandSince,
22
+ GitNotAvailableError,
23
+ type ReadGitHistoryOptions,
24
+ } from './readGitHistory';
@@ -0,0 +1,124 @@
1
+ // Pure parser for the output of:
2
+ //
3
+ // git log --no-merges --numstat --date=iso-strict \
4
+ // --pretty=format:'__FS_COMMIT__%x01%H%x01%h%x01%aN%x01%aE%x01%aI%x01%s'
5
+ //
6
+ // We use a sentinel (`__FS_COMMIT__`) at the start of each commit's header
7
+ // line so we can split the output without worrying about newlines in
8
+ // subjects (`%s` is single-line by definition, but renames in --numstat
9
+ // without `-z` produce path strings of the form "{old => new}" which we
10
+ // handle directly).
11
+ //
12
+ // Each commit yields:
13
+ //
14
+ // __FS_COMMIT__<SOH>sha<SOH>short<SOH>name<SOH>email<SOH>date<SOH>subject\n
15
+ // <numstat>\t<numstat>\t<path>\n (zero or more)
16
+ // ...
17
+
18
+ import type { GitCommit, GitFileChange } from './types';
19
+
20
+ const COMMIT_SENTINEL = '__FS_COMMIT__';
21
+ const HEADER_SEP = '\x01';
22
+ const NUMSTAT_RE = /^(\d+|-)\t(\d+|-)\t(.+)$/u;
23
+
24
+ export function parseGitLog(raw: string): GitCommit[] {
25
+ if (!raw) return [];
26
+ const out: GitCommit[] = [];
27
+ // Split on the sentinel; the first segment is empty (raw starts with it).
28
+ const segments = raw.split(COMMIT_SENTINEL);
29
+ for (const segment of segments) {
30
+ if (!segment) continue;
31
+ const newlineIdx = segment.indexOf('\n');
32
+ const headerLine = newlineIdx === -1 ? segment : segment.slice(0, newlineIdx);
33
+ const header = parseHeader(headerLine);
34
+ if (!header) continue;
35
+ const changes: GitFileChange[] = [];
36
+ if (newlineIdx !== -1) {
37
+ const tail = segment.slice(newlineIdx + 1);
38
+ for (const line of tail.split('\n')) {
39
+ if (!line) continue;
40
+ const change = parseNumstatLine(line);
41
+ if (change) changes.push(change);
42
+ }
43
+ }
44
+ out.push({ ...header, changes });
45
+ }
46
+ return out;
47
+ }
48
+
49
+ interface ParsedHeader {
50
+ sha: string;
51
+ shortSha: string;
52
+ authorName: string;
53
+ author: string;
54
+ authoredAt: string;
55
+ subject: string;
56
+ }
57
+
58
+ function parseHeader(line: string): ParsedHeader | null {
59
+ // Leading HEADER_SEP comes from the `%x01` in the format string after the
60
+ // sentinel. Trim it and split.
61
+ const trimmed = line.startsWith(HEADER_SEP) ? line.slice(1) : line;
62
+ const parts = trimmed.split(HEADER_SEP);
63
+ if (parts.length < 6) return null;
64
+ const [sha, shortSha, authorName, email, authoredAt, ...rest] = parts;
65
+ if (!sha || sha.length < 7) return null;
66
+ return {
67
+ sha,
68
+ shortSha: shortSha ?? sha.slice(0, 7),
69
+ authorName: authorName ?? '',
70
+ author: (email ?? '').toLowerCase(),
71
+ authoredAt: authoredAt ?? '',
72
+ subject: rest.join(HEADER_SEP),
73
+ };
74
+ }
75
+
76
+ const RENAME_PATH_RE = /^(.*)\{([^{}]*?)\s*=>\s*([^{}]*?)\}(.*)$/u;
77
+
78
+ function parseNumstatLine(line: string): GitFileChange | null {
79
+ const m = NUMSTAT_RE.exec(line);
80
+ if (!m) return null;
81
+ const rawPath = m[3]!;
82
+ const added = m[1] === '-' ? null : Number(m[1]);
83
+ const removed = m[2] === '-' ? null : Number(m[2]);
84
+ // Rename: "src/old/file.ts => src/new/file.ts" or "src/{old => new}/file.ts".
85
+ if (rawPath.includes(' => ')) {
86
+ const expanded = expandRename(rawPath);
87
+ if (expanded) {
88
+ return { path: expanded.to, renamedFrom: expanded.from, added, removed };
89
+ }
90
+ }
91
+ return { path: rawPath, added, removed };
92
+ }
93
+
94
+ interface ExpandedRename {
95
+ from: string;
96
+ to: string;
97
+ }
98
+
99
+ export function expandRename(spec: string): ExpandedRename | null {
100
+ const braced = RENAME_PATH_RE.exec(spec);
101
+ if (braced) {
102
+ const [, prefix = '', oldPart = '', newPart = '', suffix = ''] = braced;
103
+ const from = collapseSlashes(`${prefix}${oldPart}${suffix}`);
104
+ const to = collapseSlashes(`${prefix}${newPart}${suffix}`);
105
+ if (!from || !to) return null;
106
+ return { from, to };
107
+ }
108
+ // Whole-path rename: "old/path => new/path".
109
+ const arrowIdx = spec.indexOf(' => ');
110
+ if (arrowIdx !== -1) {
111
+ return {
112
+ from: spec.slice(0, arrowIdx),
113
+ to: spec.slice(arrowIdx + ' => '.length),
114
+ };
115
+ }
116
+ return null;
117
+ }
118
+
119
+ function collapseSlashes(p: string): string {
120
+ // Brace expansions like `src/{ => new}/foo.ts` produce `src//foo.ts` — fold
121
+ // doubled slashes that result from empty segments. We preserve a single
122
+ // leading slash if it was present.
123
+ return p.replace(/\/+/gu, '/');
124
+ }
@@ -0,0 +1,130 @@
1
+ // Node-only thin wrapper around `git log`. Browsers can't shell out, so this
2
+ // module is imported lazily by Node consumers (CLI, optional Tauri bridge).
3
+ // Keeping the spawn isolated here means `parseGitLog` and `computeChurn`
4
+ // stay pure and trivially testable.
5
+
6
+ import { spawn } from 'node:child_process';
7
+ import { existsSync } from 'node:fs';
8
+ import { join } from 'node:path';
9
+ import { parseGitLog } from './parseGitLog';
10
+ import type { GitHistory } from './types';
11
+
12
+ export interface ReadGitHistoryOptions {
13
+ /** Repo root. We require `<root>/.git` to exist. */
14
+ rootPath: string;
15
+ /** Lookback window. Either a parseable git `--since` string ("90 days ago",
16
+ * ISO date) or a relative shorthand we expand: "90d", "12w", "6m", "1y". */
17
+ since?: string;
18
+ /**
19
+ * Hard cap on commits, applied by git via `--max-count`. 0/undefined means
20
+ * no cap. Useful on monorepos where 90 days = 50k commits and parsing eats
21
+ * memory.
22
+ */
23
+ maxCommits?: number;
24
+ /** Include merge commits? Default: false. Merges inflate temporal coupling
25
+ * noise without adding signal for Archora. */
26
+ includeMerges?: boolean;
27
+ /** Override the git executable (mostly for tests/CI). */
28
+ gitBin?: string;
29
+ }
30
+
31
+ const DEFAULT_SINCE = '90d';
32
+
33
+ const FORMAT = '__FS_COMMIT__\x01%H\x01%h\x01%aN\x01%aE\x01%aI\x01%s';
34
+
35
+ export class GitNotAvailableError extends Error {
36
+ constructor(message: string) {
37
+ super(message);
38
+ this.name = 'GitNotAvailableError';
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Run `git log` against `rootPath` and parse the output. Throws
44
+ * `GitNotAvailableError` when the path is not a git repo or git is missing.
45
+ * All other errors propagate as-is.
46
+ */
47
+ export async function readGitHistory(options: ReadGitHistoryOptions): Promise<GitHistory> {
48
+ const { rootPath } = options;
49
+ if (!existsSync(join(rootPath, '.git'))) {
50
+ throw new GitNotAvailableError(`Not a git repository: ${rootPath}`);
51
+ }
52
+ const since = expandSince(options.since ?? DEFAULT_SINCE);
53
+ const args: string[] = [
54
+ 'log',
55
+ '--no-merges',
56
+ '--numstat',
57
+ '--date=iso-strict',
58
+ `--pretty=format:${FORMAT}`,
59
+ `--since=${since}`,
60
+ ];
61
+ if (options.includeMerges) {
62
+ // Replace `--no-merges` with nothing.
63
+ const idx = args.indexOf('--no-merges');
64
+ if (idx !== -1) args.splice(idx, 1);
65
+ }
66
+ if (options.maxCommits && options.maxCommits > 0) {
67
+ args.push(`--max-count=${options.maxCommits}`);
68
+ }
69
+
70
+ const stdout = await runGit(options.gitBin ?? 'git', args, rootPath);
71
+ const commits = parseGitLog(stdout);
72
+ const until = new Date().toISOString();
73
+ return {
74
+ since,
75
+ until,
76
+ commits,
77
+ includesMerges: !!options.includeMerges,
78
+ };
79
+ }
80
+
81
+ const SHORTHAND_RE = /^(\d+)([dwmy])$/u;
82
+
83
+ export function expandSince(input: string): string {
84
+ const m = SHORTHAND_RE.exec(input.trim());
85
+ if (!m) return input;
86
+ const n = Number(m[1]);
87
+ switch (m[2]) {
88
+ case 'd':
89
+ return `${n} days ago`;
90
+ case 'w':
91
+ return `${n} weeks ago`;
92
+ case 'm':
93
+ return `${n} months ago`;
94
+ case 'y':
95
+ return `${n} years ago`;
96
+ default:
97
+ return input;
98
+ }
99
+ }
100
+
101
+ function runGit(gitBin: string, args: string[], cwd: string): Promise<string> {
102
+ return new Promise((resolve, reject) => {
103
+ const child = spawn(gitBin, args, { cwd, stdio: ['ignore', 'pipe', 'pipe'] });
104
+ let stdout = '';
105
+ let stderr = '';
106
+ child.stdout.setEncoding('utf8');
107
+ child.stdout.on('data', (chunk) => {
108
+ stdout += chunk;
109
+ });
110
+ child.stderr.setEncoding('utf8');
111
+ child.stderr.on('data', (chunk) => {
112
+ stderr += chunk;
113
+ });
114
+ child.on('error', (err) => {
115
+ const code = (err as NodeJS.ErrnoException).code;
116
+ if (code === 'ENOENT') {
117
+ reject(new GitNotAvailableError(`git executable not found (tried "${gitBin}").`));
118
+ return;
119
+ }
120
+ reject(err);
121
+ });
122
+ child.on('close', (exit) => {
123
+ if (exit !== 0) {
124
+ reject(new Error(`git log failed (exit ${exit}): ${stderr.trim() || 'unknown error'}`));
125
+ return;
126
+ }
127
+ resolve(stdout);
128
+ });
129
+ });
130
+ }
@@ -0,0 +1,119 @@
1
+ // Git history types.
2
+ //
3
+ // Archora reads `git log` once per scan and feeds the result into the
4
+ // analyzer as `AnalyzeOptions.gitHistory`. Everything in this file is plain
5
+ // data: no IO, no Node dependencies. The reader (`readGitHistory.ts`) and
6
+ // the parser (`parseGitLog.ts`) hydrate these shapes.
7
+
8
+ import type { ModuleId } from '../analyzer/types';
9
+
10
+ export interface GitCommit {
11
+ /** Full SHA. */
12
+ sha: string;
13
+ /** Short SHA, first 7 chars by convention. Stored alongside `sha` so callers
14
+ * don't have to recompute. */
15
+ shortSha: string;
16
+ /** Author email (lowercased on read for stable grouping). */
17
+ author: string;
18
+ /** Author display name as written in the commit. */
19
+ authorName: string;
20
+ /** ISO-8601 timestamp of the author date (commit date is ignored — author
21
+ * date survives rebase/cherry-pick which is what we want for churn). */
22
+ authoredAt: string;
23
+ subject: string;
24
+ /**
25
+ * Files changed by this commit, normalized to repo-root-relative paths
26
+ * with `/` separators. Renames are recorded as the new path; the old path
27
+ * is in `renamedFrom`.
28
+ */
29
+ changes: GitFileChange[];
30
+ }
31
+
32
+ export interface GitFileChange {
33
+ path: string;
34
+ /** Renames record the previous path here. Otherwise undefined. */
35
+ renamedFrom?: string;
36
+ /** Lines added in this file in this commit. `null` for binary changes. */
37
+ added: number | null;
38
+ /** Lines removed in this file in this commit. `null` for binary. */
39
+ removed: number | null;
40
+ }
41
+
42
+ export interface GitHistory {
43
+ /** Range covered by this history. */
44
+ since: string; // ISO date string
45
+ until: string; // ISO date string
46
+ /** Commits in reverse-chronological order. */
47
+ commits: GitCommit[];
48
+ /** Inclusive of merges? Reader filters merges by default; surface here so
49
+ * consumers know what they're looking at. */
50
+ includesMerges: boolean;
51
+ }
52
+
53
+ /**
54
+ * Per-module churn aggregate. Computed by `computeChurn` from `GitHistory`
55
+ * once we know which paths map to which `ModuleId`s.
56
+ */
57
+ export interface ChurnMetric {
58
+ moduleId: ModuleId;
59
+ /** Number of commits that touched this module in the window. */
60
+ commits: number;
61
+ /** Sum of `added + removed`, ignoring binary files. */
62
+ linesChanged: number;
63
+ /** Number of distinct authors. */
64
+ authorCount: number;
65
+ /** ISO timestamp of the most recent commit touching this module. */
66
+ lastTouchedAt: string;
67
+ /** Author distribution sorted by commit count desc, then alphabetic.
68
+ * Capped at top 5; remaining authors collapsed into a single
69
+ * `{ author: '__other__', commits: N }` entry when present. */
70
+ authors: ChurnAuthor[];
71
+ }
72
+
73
+ export interface ChurnAuthor {
74
+ author: string;
75
+ commits: number;
76
+ }
77
+
78
+ /** Aggregate keyed by `ModuleId`. Modules with zero touches are omitted. */
79
+ export type ChurnByModule = Record<ModuleId, ChurnMetric>;
80
+
81
+ /**
82
+ * One pair of modules that change together more often than baseline.
83
+ * The pair is ordered (`a < b` lexically) so a coupling is unique
84
+ * by `${a}\0${b}`.
85
+ */
86
+ export interface TemporalCoupling {
87
+ a: ModuleId;
88
+ b: ModuleId;
89
+ /** Commits that touched both `a` and `b`. */
90
+ coOccurrences: number;
91
+ /** `coOccurrences / commits(a)`. */
92
+ scoreA: number;
93
+ /** `coOccurrences / commits(b)`. */
94
+ scoreB: number;
95
+ /** `min(scoreA, scoreB)` — the symmetric, conservative score. Sorting key. */
96
+ score: number;
97
+ /**
98
+ * True when there is no static import edge between `a` and `b` in either
99
+ * direction. These are the "surprising" couplings — the actually
100
+ * informative ones, because the static graph already exposes the rest.
101
+ */
102
+ hidden: boolean;
103
+ }
104
+
105
+ export interface TemporalCouplingThresholds {
106
+ /** Minimum co-occurrences required to consider a pair. Default 3. */
107
+ minCoOccurrences?: number;
108
+ /** Minimum `score` (symmetric) to surface. Default 0.5 — at least 50% of
109
+ * the rarer module's commits also touched the other. */
110
+ minScore?: number;
111
+ /** Hard cap on emitted pairs. Default 100. */
112
+ maxPairs?: number;
113
+ /**
114
+ * Per-commit fan-out cap: commits that touch more than this many files
115
+ * are skipped (mass renames, lockfile bumps, format-everything passes).
116
+ * Default 50.
117
+ */
118
+ commitFanOutCap?: number;
119
+ }
package/src/index.ts ADDED
@@ -0,0 +1,137 @@
1
+ export { analyze, type AnalyzeOptions, type FileSource } from './analyzer/index';
2
+ export {
3
+ buildArchitectureSignals,
4
+ applySignalSuppressions,
5
+ canSignalFailCi,
6
+ projectSignalsToRecommendations,
7
+ reconcileSignalLifecycle,
8
+ type ApplySignalSuppressionsResult,
9
+ type BuildArchitectureSignalsInput,
10
+ type BuildArchitectureSignalsResult,
11
+ type CanSignalFailCiOptions,
12
+ type ReconcileSignalLifecycleResult,
13
+ } from './analyzer/index';
14
+ // NOTE: cache module is intentionally NOT re-exported from this barrel.
15
+ // `cache/index.ts` imports `node:crypto` / `node:fs` / `node:v8` / `node:os`
16
+ // at the top level - including it here would force Vite to externalize those
17
+ // for browser builds and throw at runtime as soon as anything in the front-end
18
+ // touches `@/core` (barrel imports cascade into every re-export, even unused
19
+ // ones). Node-side consumers (CLI, scripts) import directly from
20
+ // `@archora/core/cache` via the package's `exports` map.
21
+ export type * from './analyzer/types';
22
+ export {
23
+ buildJsonReport,
24
+ type ReportEnvelope,
25
+ type BuildReportOptions,
26
+ } from './report/buildJsonReport';
27
+ export {
28
+ buildFixPlan,
29
+ type FixPlanReport,
30
+ type FixPlanFinding,
31
+ type FixPlanRepairGroup,
32
+ type BuildFixPlanOptions,
33
+ } from './report/buildFixPlan';
34
+ export { diffScans, type ScanDiff, type ChangedModule, type ScanDiffSummary } from './diff/index';
35
+ export { compileGlob } from './analyzer/discover';
36
+ export {
37
+ loadArchoraConfig,
38
+ loadArchoraConfigWithDiagnostics,
39
+ resolveGeneratedPatterns,
40
+ GENERATED_PRESETS,
41
+ type ArchoraConfig,
42
+ type ConfigDiagnostic,
43
+ type ConfigDiagnosticSeverity,
44
+ type LoadArchoraConfigResult,
45
+ type DynamicLoaderConfig,
46
+ type AnalysisConfig,
47
+ type ArchitectureBudgetConfig,
48
+ type GeneratedPolicy,
49
+ type GeneratedPreset,
50
+ } from './config/frontScopeConfig';
51
+ export {
52
+ detectLayer,
53
+ detectLayerViolations,
54
+ recomputeLayers,
55
+ validateLayerOverride,
56
+ LAYER_ORDER,
57
+ type Layer,
58
+ type LayerOverrides,
59
+ type LayerOverrideError,
60
+ type RecomputeLayersInput,
61
+ type RecomputeLayersOutput,
62
+ } from './analyzer/layers';
63
+ export {
64
+ search,
65
+ parseQuery,
66
+ isEmpty as isEmptyQuery,
67
+ type SearchResult,
68
+ type SearchOptions,
69
+ type MatchKind,
70
+ type SearchPrefix,
71
+ type ParsedQuery,
72
+ } from './search/index';
73
+ export {
74
+ suggestContracts,
75
+ type SuggestContractsInput,
76
+ type SuggestedContracts,
77
+ } from './analyzer/suggestContracts';
78
+ // Git history. Same pattern as `cache`: the Node-only
79
+ // `readGitHistory` lives in `./git/readGitHistory` and must NOT be
80
+ // re-exported from this barrel (it imports `node:child_process` and would
81
+ // break browser bundles). Pure helpers are safe — they import only types.
82
+ export { parseGitLog, expandRename } from './git/parseGitLog';
83
+ export { computeChurn, type ComputeChurnInput } from './git/computeChurn';
84
+ export {
85
+ computeTemporalCoupling,
86
+ type ComputeTemporalCouplingInput,
87
+ } from './git/computeTemporalCoupling';
88
+ export {
89
+ buildGeneratedConfigSnippet,
90
+ buildIgnoreSnippet,
91
+ buildLayerOverrideSnippet,
92
+ buildDynamicLoaderSnippet,
93
+ buildProjectPolicyPresetSnippet,
94
+ type ProjectPolicyPreset,
95
+ } from './codegen/configSnippets';
96
+ export {
97
+ buildInitialArchoraConfig,
98
+ buildInitialArchoraConfigJson,
99
+ type BuildInitialArchoraConfigInput,
100
+ type InitialArchoraConfigResult,
101
+ type InitialArchoraDetection,
102
+ } from './codegen/initConfig';
103
+ export {
104
+ buildExplainView,
105
+ buildImpactView,
106
+ buildLifecycleHygieneView,
107
+ buildMatrixView,
108
+ buildOwnershipView,
109
+ buildReviewRiskView,
110
+ buildSemanticSurfaceView,
111
+ buildSignalBaselineView,
112
+ buildTrendView,
113
+ countBaselineSignals,
114
+ findModuleMatches,
115
+ resolveImpactTarget,
116
+ type BuildMatrixViewOptions,
117
+ type GuidedReviewAction,
118
+ type ExplainCycleDetails,
119
+ type ExplainCycleEdge,
120
+ type ExplainView,
121
+ type ImpactView,
122
+ type LifecycleHygieneItem,
123
+ type LifecycleHygieneView,
124
+ type LifecycleRiskModule,
125
+ type MatrixCell,
126
+ type MatrixEdge,
127
+ type MatrixGrouping,
128
+ type MatrixView,
129
+ type OwnershipArea,
130
+ type OwnershipView,
131
+ type ReviewRiskView,
132
+ type SemanticSurfaceModule,
133
+ type SemanticSurfaceView,
134
+ type SideEffectOwner,
135
+ type SignalBaselineView,
136
+ type TrendView,
137
+ } from './views/analyzerViews';