@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,372 @@
1
+ // Architectural contracts engine - unit tests at the engine boundary
2
+ // (input: pre-built modules/edges/cycles/metrics, output: violations) plus
3
+ // one integration test that drives the engine through the full `analyze()`
4
+ // pipeline using `.archora.json` to make sure config plumbing works.
5
+
6
+ import { describe, expect, it } from 'vitest';
7
+
8
+ import { analyze } from '../index';
9
+ import { checkContracts } from '../contracts';
10
+ import { createInMemoryFileSource } from '../sources/inMemoryFileSource';
11
+ import type { Cycle, DependencyEdge, ModuleId, ModuleMetrics, ModuleNode } from '../types';
12
+
13
+ // --- Test helpers ----------------------------------------------------------
14
+
15
+ function mod(id: ModuleId, overrides: Partial<ModuleNode> = {}): ModuleNode {
16
+ return {
17
+ id,
18
+ absPath: id,
19
+ kind: 'unknown',
20
+ language: 'ts',
21
+ loc: 50,
22
+ exports: [],
23
+ isInfra: false,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function edge(
29
+ from: ModuleId,
30
+ to: ModuleId,
31
+ kind: DependencyEdge['kind'] = 'static',
32
+ ): DependencyEdge {
33
+ return { from, to, kind, specifier: to, resolved: true };
34
+ }
35
+
36
+ function metrics(
37
+ values: Partial<Record<ModuleId, Partial<ModuleMetrics>>>,
38
+ ): Record<ModuleId, ModuleMetrics> {
39
+ const out: Record<ModuleId, ModuleMetrics> = {};
40
+ for (const [id, m] of Object.entries(values)) {
41
+ out[id] = {
42
+ fanIn: 0,
43
+ fanOut: 0,
44
+ instability: 0,
45
+ depth: 0,
46
+ inCycle: false,
47
+ couplingScore: 0,
48
+ hotnessScore: 0,
49
+ ...m,
50
+ };
51
+ }
52
+ return out;
53
+ }
54
+
55
+ // --- Boundary rules --------------------------------------------------------
56
+
57
+ describe('boundary: must-not', () => {
58
+ const modules = [
59
+ mod('features/auth/api.ts'),
60
+ mod('features/billing/api.ts'),
61
+ mod('features/shared/types.ts'),
62
+ mod('shared/ui/button.ts'),
63
+ ];
64
+ const edges = [
65
+ edge('features/auth/api.ts', 'features/billing/api.ts'), // violation
66
+ edge('features/auth/api.ts', 'features/shared/types.ts'), // exempted
67
+ edge('features/auth/api.ts', 'shared/ui/button.ts'), // ok
68
+ ];
69
+
70
+ it('flags forbidden cross-feature import', () => {
71
+ const v = checkContracts({
72
+ modules,
73
+ edges,
74
+ metrics: {},
75
+ cycles: [],
76
+ contracts: {
77
+ boundaries: [
78
+ {
79
+ name: 'features-isolation',
80
+ from: 'features/*/**',
81
+ to: 'features/*/**',
82
+ mode: 'must-not',
83
+ except: ['features/shared/**'],
84
+ },
85
+ ],
86
+ },
87
+ });
88
+ expect(v.length).toBe(1);
89
+ expect(v[0]?.kind).toBe('boundary');
90
+ expect(v[0]?.edge).toEqual({
91
+ from: 'features/auth/api.ts',
92
+ to: 'features/billing/api.ts',
93
+ specifier: 'features/billing/api.ts',
94
+ });
95
+ });
96
+
97
+ it('except whitelist actually exempts matched edges', () => {
98
+ const v = checkContracts({
99
+ modules,
100
+ edges: [edge('features/auth/api.ts', 'features/shared/types.ts')],
101
+ metrics: {},
102
+ cycles: [],
103
+ contracts: {
104
+ boundaries: [
105
+ {
106
+ name: 'features-isolation',
107
+ from: 'features/*/**',
108
+ to: 'features/*/**',
109
+ mode: 'must-not',
110
+ except: ['features/shared/**'],
111
+ },
112
+ ],
113
+ },
114
+ });
115
+ expect(v).toEqual([]);
116
+ });
117
+
118
+ it('ignores type-only edges (mirrors layer-violation behaviour)', () => {
119
+ const v = checkContracts({
120
+ modules,
121
+ edges: [edge('features/auth/api.ts', 'features/billing/api.ts', 'type-only')],
122
+ metrics: {},
123
+ cycles: [],
124
+ contracts: {
125
+ boundaries: [
126
+ {
127
+ name: 'features-isolation',
128
+ from: 'features/*/**',
129
+ to: 'features/*/**',
130
+ mode: 'must-not',
131
+ },
132
+ ],
133
+ },
134
+ });
135
+ expect(v).toEqual([]);
136
+ });
137
+
138
+ it('crossInstance: skips same-feature internal edges, flags cross-feature', () => {
139
+ const v = checkContracts({
140
+ modules: [
141
+ mod('features/auth/index.ts'),
142
+ mod('features/auth/lib/jwt.ts'),
143
+ mod('features/billing/index.ts'),
144
+ ],
145
+ edges: [
146
+ edge('features/auth/index.ts', 'features/auth/lib/jwt.ts'), // same-feature, OK
147
+ edge('features/auth/index.ts', 'features/billing/index.ts'), // cross-feature, violation
148
+ ],
149
+ metrics: {},
150
+ cycles: [],
151
+ contracts: {
152
+ boundaries: [
153
+ {
154
+ name: 'features-isolation',
155
+ from: 'features/*/**',
156
+ to: 'features/*/**',
157
+ mode: 'must-not',
158
+ crossInstance: true,
159
+ },
160
+ ],
161
+ },
162
+ });
163
+ expect(v.length).toBe(1);
164
+ expect(v[0]?.edge?.to).toBe('features/billing/index.ts');
165
+ });
166
+
167
+ it('respects severity from the rule (default error)', () => {
168
+ const v = checkContracts({
169
+ modules,
170
+ edges,
171
+ metrics: {},
172
+ cycles: [],
173
+ contracts: {
174
+ boundaries: [
175
+ {
176
+ name: 'soft-rule',
177
+ from: 'features/*/**',
178
+ to: 'features/*/**',
179
+ mode: 'must-not',
180
+ severity: 'warning',
181
+ },
182
+ ],
183
+ },
184
+ });
185
+ expect(v[0]?.severity).toBe('warning');
186
+ });
187
+ });
188
+
189
+ describe('boundary: can-only', () => {
190
+ const modules = [
191
+ mod('src/router/routes.ts'),
192
+ mod('src/pages/home.ts'),
193
+ mod('src/utils/helpers.ts'),
194
+ ];
195
+
196
+ it("forbids edges that don't land on the allowed glob", () => {
197
+ const edges = [
198
+ edge('src/router/routes.ts', 'src/pages/home.ts'), // ok
199
+ edge('src/router/routes.ts', 'src/utils/helpers.ts'), // violation
200
+ ];
201
+ const v = checkContracts({
202
+ modules,
203
+ edges,
204
+ metrics: {},
205
+ cycles: [],
206
+ contracts: {
207
+ boundaries: [
208
+ {
209
+ name: 'router-only-pages',
210
+ from: 'src/router/**',
211
+ to: 'src/pages/**',
212
+ mode: 'can-only',
213
+ },
214
+ ],
215
+ },
216
+ });
217
+ expect(v.length).toBe(1);
218
+ expect(v[0]?.edge?.to).toBe('src/utils/helpers.ts');
219
+ });
220
+
221
+ it('allows internal (within-from-glob) edges without violation', () => {
222
+ const edges = [edge('src/router/routes.ts', 'src/router/guards.ts')];
223
+ const v = checkContracts({
224
+ modules: [...modules, mod('src/router/guards.ts')],
225
+ edges,
226
+ metrics: {},
227
+ cycles: [],
228
+ contracts: {
229
+ boundaries: [
230
+ {
231
+ name: 'router-only-pages',
232
+ from: 'src/router/**',
233
+ to: 'src/pages/**',
234
+ mode: 'can-only',
235
+ },
236
+ ],
237
+ },
238
+ });
239
+ expect(v).toEqual([]);
240
+ });
241
+ });
242
+
243
+ // --- Budget rules ----------------------------------------------------------
244
+
245
+ describe('budget rules', () => {
246
+ it('triggers maxFanIn when exceeded', () => {
247
+ const v = checkContracts({
248
+ modules: [mod('features/auth/index.ts')],
249
+ edges: [],
250
+ metrics: metrics({ 'features/auth/index.ts': { fanIn: 50 } }),
251
+ cycles: [],
252
+ contracts: {
253
+ budgets: [{ name: 'auth-fanin', module: 'features/auth/**', maxFanIn: 30 }],
254
+ },
255
+ });
256
+ expect(v.length).toBe(1);
257
+ expect(v[0]?.detail).toEqual({ metric: 'fanIn', value: 50, limit: 30 });
258
+ });
259
+
260
+ it('does NOT trigger when under the limit', () => {
261
+ const v = checkContracts({
262
+ modules: [mod('features/auth/index.ts')],
263
+ edges: [],
264
+ metrics: metrics({ 'features/auth/index.ts': { fanIn: 5 } }),
265
+ cycles: [],
266
+ contracts: {
267
+ budgets: [{ name: 'auth-fanin', module: 'features/auth/**', maxFanIn: 30 }],
268
+ },
269
+ });
270
+ expect(v).toEqual([]);
271
+ });
272
+
273
+ it('triggers maxLoc on a per-module basis', () => {
274
+ const v = checkContracts({
275
+ modules: [mod('shared/ui/big.ts', { loc: 600 }), mod('shared/ui/small.ts', { loc: 50 })],
276
+ edges: [],
277
+ metrics: metrics({
278
+ 'shared/ui/big.ts': {},
279
+ 'shared/ui/small.ts': {},
280
+ }),
281
+ cycles: [],
282
+ contracts: {
283
+ budgets: [{ name: 'ui-loc', module: 'shared/ui/**', maxLoc: 300 }],
284
+ },
285
+ });
286
+ expect(v.length).toBe(1);
287
+ expect(v[0]?.modules).toEqual(['shared/ui/big.ts']);
288
+ });
289
+
290
+ it('triggers maxCycles when ≥ N+1 cycles touch the glob', () => {
291
+ const cycles: Cycle[] = [
292
+ { id: 'c1', modules: ['shared/ui/a.ts', 'shared/ui/b.ts'], length: 2, severity: 'direct' },
293
+ ];
294
+ const v = checkContracts({
295
+ modules: [mod('shared/ui/a.ts'), mod('shared/ui/b.ts')],
296
+ edges: [],
297
+ metrics: {},
298
+ cycles,
299
+ contracts: {
300
+ budgets: [{ name: 'ui-no-cycles', module: 'shared/ui/**', maxCycles: 0 }],
301
+ },
302
+ });
303
+ expect(v.length).toBe(1);
304
+ expect(v[0]?.detail?.metric).toBe('cycles');
305
+ expect(v[0]?.detail?.value).toBe(1);
306
+ });
307
+
308
+ it('rule with no numeric ceiling is silently dropped during config parse', () => {
309
+ // We can't go through normalizeConfig here, but we *can* check that the
310
+ // engine itself does nothing when the budget object has every threshold
311
+ // set to undefined. The normalizer (config.test.ts) covers the parse-time
312
+ // drop-on-empty case.
313
+ const v = checkContracts({
314
+ modules: [mod('features/auth/index.ts')],
315
+ edges: [],
316
+ metrics: metrics({ 'features/auth/index.ts': { fanIn: 100 } }),
317
+ cycles: [],
318
+ contracts: {
319
+ budgets: [{ name: 'no-op', module: 'features/auth/**' }],
320
+ },
321
+ });
322
+ expect(v).toEqual([]);
323
+ });
324
+ });
325
+
326
+ // --- Integration via analyze() --------------------------------------------
327
+
328
+ describe('integration: analyze() honours .archora.json contracts', () => {
329
+ it('full pipeline emits contract-violation recommendations', async () => {
330
+ const config = {
331
+ contracts: {
332
+ boundaries: [
333
+ {
334
+ name: 'features-isolation',
335
+ from: 'features/*/**',
336
+ to: 'features/*/**',
337
+ mode: 'must-not',
338
+ except: ['features/shared/**'],
339
+ },
340
+ ],
341
+ budgets: [{ name: 'auth-loc', module: 'features/auth/**', maxLoc: 5 }],
342
+ },
343
+ };
344
+ const source = createInMemoryFileSource('/proj', {
345
+ 'tsconfig.json': '{}',
346
+ '.archora.json': JSON.stringify(config),
347
+ 'features/auth/index.ts': "import { x } from '../billing/index';\nexport const a = x;\n",
348
+ 'features/billing/index.ts': 'export const x = 1;\n',
349
+ 'features/shared/util.ts': 'export const u = 0;\n',
350
+ });
351
+ const scan = await analyze(source);
352
+
353
+ expect(scan.contractViolations.length).toBeGreaterThanOrEqual(1);
354
+ const boundary = scan.contractViolations.find((v) => v.kind === 'boundary');
355
+ expect(boundary).toBeDefined();
356
+ expect(boundary?.ruleName).toBe('features-isolation');
357
+
358
+ const recs = scan.recommendations.filter((r) => r.kind === 'contract-violation');
359
+ expect(recs.length).toBeGreaterThanOrEqual(1);
360
+ expect(recs[0]?.params['rule']).toBe('features-isolation');
361
+ });
362
+
363
+ it('no contracts block → empty contractViolations', async () => {
364
+ const source = createInMemoryFileSource('/proj', {
365
+ 'tsconfig.json': '{}',
366
+ 'src/a.ts': "import { b } from './b';\nexport const a = b;\n",
367
+ 'src/b.ts': 'export const b = 1;\n',
368
+ });
369
+ const scan = await analyze(source);
370
+ expect(scan.contractViolations).toEqual([]);
371
+ });
372
+ });
@@ -0,0 +1,317 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { existsSync, readdirSync, readFileSync, statSync, type Dirent } from 'node:fs';
3
+ import { join, relative as pathRelative, sep as pathSep } from 'node:path';
4
+ import ignore from 'ignore';
5
+ import { analyze } from '../index';
6
+ import { createNodeFsFileSource } from '../sources/nodeFsFileSource';
7
+ import { createBrowserFsAccessFileSource } from '../sources/browserFsAccessFileSource';
8
+ import { createTauriFileSource } from '../sources/tauriFileSource';
9
+ import type { ScanResult } from '../types';
10
+ import { fixturePath } from './_paths';
11
+
12
+ // analyze() must produce the same ScanResult under all three FileSource impls
13
+ // (node / browser FS Access / tauri). browser+tauri use in-process mocks here.
14
+
15
+ const REFERENCE_ROOT = fixturePath('../fixtures/reference');
16
+
17
+ const SUPPORTED_EXT = new Set(['.ts', '.tsx', '.js', '.jsx', '.vue', '.svelte', '.mjs', '.cjs']);
18
+
19
+ /* ------------------------------------------------------------------ */
20
+ /* Browser File System Access mock */
21
+ /* ------------------------------------------------------------------ */
22
+
23
+ function makeFileHandle(absPath: string): unknown {
24
+ return {
25
+ kind: 'file' as const,
26
+ name: absPath.split(pathSep).pop() ?? '',
27
+ async getFile() {
28
+ return {
29
+ text: async () => readFileSync(absPath, 'utf8'),
30
+ };
31
+ },
32
+ };
33
+ }
34
+
35
+ function makeDirHandle(absPath: string, name: string): unknown {
36
+ return {
37
+ kind: 'directory' as const,
38
+ name,
39
+ async *entries(): AsyncGenerator<[string, unknown]> {
40
+ const entries: Dirent[] = readdirSync(absPath, {
41
+ withFileTypes: true,
42
+ encoding: 'utf8',
43
+ });
44
+ entries.sort((a, b) => a.name.localeCompare(b.name));
45
+ for (const entry of entries) {
46
+ const childAbs = join(absPath, entry.name);
47
+ if (entry.isDirectory()) {
48
+ yield [entry.name, makeDirHandle(childAbs, entry.name)];
49
+ } else if (entry.isFile()) {
50
+ yield [entry.name, makeFileHandle(childAbs)];
51
+ }
52
+ }
53
+ },
54
+ async getDirectoryHandle(child: string): Promise<unknown> {
55
+ const childAbs = join(absPath, child);
56
+ if (!existsSync(childAbs) || !statSync(childAbs).isDirectory()) {
57
+ throw new Error(`No such dir: ${child}`);
58
+ }
59
+ return makeDirHandle(childAbs, child);
60
+ },
61
+ async getFileHandle(child: string): Promise<unknown> {
62
+ const childAbs = join(absPath, child);
63
+ if (!existsSync(childAbs) || !statSync(childAbs).isFile()) {
64
+ throw new Error(`No such file: ${child}`);
65
+ }
66
+ return makeFileHandle(childAbs);
67
+ },
68
+ };
69
+ }
70
+
71
+ /* ------------------------------------------------------------------ */
72
+ /* Tauri invoke mock */
73
+ /* ------------------------------------------------------------------ */
74
+
75
+ function isPathEscape(rel: string): boolean {
76
+ // mirrors read_file / file_exists in src-tauri/src/commands.rs
77
+ if (/^(?:[a-zA-Z]:)?[\\/]/.test(rel)) return true;
78
+ return rel.split(/[\\/]/).some((segment) => segment === '..');
79
+ }
80
+
81
+ function listTreeLikeRust(rootAbs: string): string[] {
82
+ // mirrors read_project_tree in src-tauri/src/commands.rs
83
+ const ig = ignore();
84
+ const gitignore = join(rootAbs, '.gitignore');
85
+ if (existsSync(gitignore)) {
86
+ ig.add(readFileSync(gitignore, 'utf8'));
87
+ }
88
+ const out: string[] = [];
89
+ const walk = (absDir: string): void => {
90
+ let entries: Dirent[];
91
+ try {
92
+ entries = readdirSync(absDir, { withFileTypes: true, encoding: 'utf8' });
93
+ } catch {
94
+ return;
95
+ }
96
+ for (const entry of entries) {
97
+ const abs = join(absDir, entry.name);
98
+ const rel = pathRelative(rootAbs, abs).split(pathSep).join('/');
99
+ if (entry.isDirectory()) {
100
+ if (ig.ignores(`${rel}/`)) continue;
101
+ walk(abs);
102
+ continue;
103
+ }
104
+ if (!entry.isFile()) continue;
105
+ if (ig.ignores(rel)) continue;
106
+ const ext = entry.name.slice(entry.name.lastIndexOf('.')).toLowerCase();
107
+ if (!SUPPORTED_EXT.has(ext)) continue;
108
+ out.push(rel);
109
+ }
110
+ };
111
+ walk(rootAbs);
112
+ return out.sort();
113
+ }
114
+
115
+ function buildTauriInvoke(
116
+ rootAbs: string,
117
+ ): <T>(cmd: string, args?: Record<string, unknown>) => Promise<T> {
118
+ return (async (cmd: string, args?: Record<string, unknown>) => {
119
+ switch (cmd) {
120
+ case 'read_project_tree': {
121
+ const root = String(args?.['root'] ?? '');
122
+ if (root !== rootAbs) throw new Error(`root mismatch: ${root}`);
123
+ return listTreeLikeRust(root) as unknown;
124
+ }
125
+ case 'read_file': {
126
+ const root = String(args?.['root'] ?? '');
127
+ const rel = String(args?.['relative'] ?? '');
128
+ if (isPathEscape(rel)) throw new Error('invalid relative path');
129
+ return readFileSync(join(root, rel), 'utf8') as unknown;
130
+ }
131
+ case 'file_exists': {
132
+ const root = String(args?.['root'] ?? '');
133
+ const rel = String(args?.['relative'] ?? '');
134
+ if (isPathEscape(rel)) throw new Error('invalid relative path');
135
+ const abs = join(root, rel);
136
+ return (existsSync(abs) && statSync(abs).isFile()) as unknown;
137
+ }
138
+ default:
139
+ throw new Error(`unknown tauri command in mock: ${cmd}`);
140
+ }
141
+ }) as <T>(cmd: string, args?: Record<string, unknown>) => Promise<T>;
142
+ }
143
+
144
+ /* ------------------------------------------------------------------ */
145
+ /* Canonicalization */
146
+ /* ------------------------------------------------------------------ */
147
+
148
+ // drop wall-clock + project.id/rootPath so only scan semantics are compared
149
+ function canonicalize(r: ScanResult) {
150
+ const edges = r.edges
151
+ .map((e) => ({
152
+ from: e.from,
153
+ to: e.to,
154
+ kind: e.kind,
155
+ specifier: e.specifier,
156
+ resolved: e.resolved,
157
+ }))
158
+ .sort((a, b) => {
159
+ const k = a.from.localeCompare(b.from);
160
+ if (k !== 0) return k;
161
+ const t = a.to.localeCompare(b.to);
162
+ if (t !== 0) return t;
163
+ const kk = a.kind.localeCompare(b.kind);
164
+ if (kk !== 0) return kk;
165
+ return a.specifier.localeCompare(b.specifier);
166
+ });
167
+
168
+ const modules = r.modules
169
+ .map((m) => ({
170
+ id: m.id,
171
+ kind: m.kind,
172
+ language: m.language,
173
+ loc: m.loc,
174
+ exports: [...m.exports].sort(),
175
+ isInfra: m.isInfra,
176
+ }))
177
+ .sort((a, b) => a.id.localeCompare(b.id));
178
+
179
+ const cycles = r.cycles
180
+ .map((c) => ({
181
+ length: c.length,
182
+ severity: c.severity,
183
+ modules: [...c.modules].sort(),
184
+ }))
185
+ .sort((a, b) => a.modules[0]!.localeCompare(b.modules[0]!));
186
+
187
+ const recommendations = r.recommendations
188
+ .map((rec) => ({
189
+ id: rec.id,
190
+ kind: rec.kind,
191
+ modules: [...rec.modules].sort(),
192
+ weight: rec.weight,
193
+ }))
194
+ .sort((a, b) => a.id.localeCompare(b.id));
195
+
196
+ const layerViolations = [...r.layerViolations]
197
+ .map((v) => ({ from: v.from, to: v.to, fromLayer: v.fromLayer, toLayer: v.toLayer }))
198
+ .sort((a, b) => {
199
+ const k = a.from.localeCompare(b.from);
200
+ return k !== 0 ? k : a.to.localeCompare(b.to);
201
+ });
202
+
203
+ const hotZones = [...r.hotZones].sort();
204
+
205
+ // round metrics to 3 decimals to dodge iteration-order FP drift
206
+ type CanonMetric = {
207
+ fanIn: number;
208
+ fanOut: number;
209
+ instability: number;
210
+ depth: number;
211
+ inCycle: boolean;
212
+ couplingScore: number;
213
+ hotnessScore: number;
214
+ };
215
+ const metricEntries: Array<[string, CanonMetric]> = Object.entries(r.metrics).map(([id, m]) => [
216
+ id,
217
+ {
218
+ fanIn: m.fanIn,
219
+ fanOut: m.fanOut,
220
+ instability: Math.round(m.instability * 1000) / 1000,
221
+ depth: m.depth,
222
+ inCycle: m.inCycle,
223
+ couplingScore: Math.round(m.couplingScore * 1000) / 1000,
224
+ hotnessScore: Math.round(m.hotnessScore * 1000) / 1000,
225
+ },
226
+ ]);
227
+ metricEntries.sort((a, b) => a[0].localeCompare(b[0]));
228
+ const metrics = Object.fromEntries(metricEntries);
229
+
230
+ return {
231
+ framework: r.project.detectedFramework,
232
+ modules,
233
+ edges,
234
+ cycles,
235
+ recommendations,
236
+ layerViolations,
237
+ hotZones,
238
+ metrics,
239
+ archDebt: {
240
+ grade: r.archDebt.grade,
241
+ score: r.archDebt.score,
242
+ },
243
+ };
244
+ }
245
+
246
+ /* ------------------------------------------------------------------ */
247
+ /* Test grid */
248
+ /* ------------------------------------------------------------------ */
249
+
250
+ function listReferenceProjects(): string[] {
251
+ try {
252
+ return readdirSync(REFERENCE_ROOT)
253
+ .filter((name) => {
254
+ const full = join(REFERENCE_ROOT, name);
255
+ try {
256
+ return statSync(full).isDirectory();
257
+ } catch {
258
+ return false;
259
+ }
260
+ })
261
+ .sort();
262
+ } catch {
263
+ return [];
264
+ }
265
+ }
266
+
267
+ const projects = listReferenceProjects();
268
+
269
+ describe.skipIf(projects.length === 0)('cross-source consistency', () => {
270
+ for (const name of projects) {
271
+ it(`${name}: Node / Browser / Tauri sources agree`, async () => {
272
+ await assertSourcesAgree(join(REFERENCE_ROOT, name), name);
273
+ });
274
+ }
275
+ });
276
+
277
+ // reference fixtures don't ship a .gitignore - copy one over in a tempdir
278
+ describe('cross-source gitignore parity', () => {
279
+ it('.gitignore applies equally on a non-git fixture', async () => {
280
+ const fixture = fixturePath('../fixtures/reference/vue-spa-basic');
281
+ const { mkdtempSync, cpSync, writeFileSync, rmSync } = await import('node:fs');
282
+ const { tmpdir } = await import('node:os');
283
+ const tmp = mkdtempSync(join(tmpdir(), 'archora-gi-'));
284
+ try {
285
+ cpSync(fixture, tmp, { recursive: true });
286
+ writeFileSync(join(tmp, '.gitignore'), 'src/views/\n', 'utf8');
287
+ await assertSourcesAgree(tmp, 'vue-spa-basic+gitignore');
288
+ } finally {
289
+ rmSync(tmp, { recursive: true, force: true });
290
+ }
291
+ });
292
+ });
293
+
294
+ async function assertSourcesAgree(rootAbs: string, label: string): Promise<void> {
295
+ const nodeSource = await createNodeFsFileSource({ rootPath: rootAbs });
296
+ const browserSource = await createBrowserFsAccessFileSource({
297
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
298
+ rootHandle: makeDirHandle(rootAbs, label) as any,
299
+ });
300
+ const tauriSource = await createTauriFileSource({
301
+ rootPath: rootAbs,
302
+ invoke: buildTauriInvoke(rootAbs),
303
+ });
304
+
305
+ const [nodeResult, browserResult, tauriResult] = await Promise.all([
306
+ analyze(nodeSource),
307
+ analyze(browserSource),
308
+ analyze(tauriSource),
309
+ ]);
310
+
311
+ const nodeCanon = canonicalize(nodeResult);
312
+ const browserCanon = canonicalize(browserResult);
313
+ const tauriCanon = canonicalize(tauriResult);
314
+
315
+ expect(browserCanon, `[${label}] Browser vs Node mismatch`).toEqual(nodeCanon);
316
+ expect(tauriCanon, `[${label}] Tauri vs Node mismatch`).toEqual(nodeCanon);
317
+ }