@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,481 @@
1
+ import type { ModuleId, ModuleNode, Recommendation, ScanResult } from '../analyzer/types';
2
+
3
+ export interface FixPlanFinding {
4
+ type:
5
+ | 'cycle'
6
+ | 'barrel-cycle'
7
+ | 'move-module'
8
+ | 'unreachable-from-entries'
9
+ | 'layer-violation'
10
+ | 'contract-violation'
11
+ | 'hot-zone'
12
+ | 'recommendation';
13
+ id: string;
14
+ title: string;
15
+ weight: number;
16
+ targets: ModuleId[];
17
+ reason: string;
18
+ action: string;
19
+ verify: string;
20
+ /** True when every target module is marked `isGenerated`. */
21
+ generated?: boolean;
22
+ params?: Record<string, unknown>;
23
+ }
24
+
25
+ export interface FixPlanReport {
26
+ kind: 'archora-fix-plan';
27
+ version: 1;
28
+ exportedAt: string;
29
+ appVersion: string;
30
+ project: ScanResult['project'];
31
+ scannedAt: string;
32
+ architectureDebt: ScanResult['archDebt'];
33
+ summary: {
34
+ cycles: number;
35
+ layerViolations: number;
36
+ contractViolations: number;
37
+ hotZones: number;
38
+ generatedModules: number;
39
+ };
40
+ priorityFindings: FixPlanFinding[];
41
+ repairGroups: FixPlanRepairGroup[];
42
+ evidence: {
43
+ cycles: ScanResult['cycles'];
44
+ layerViolations: ScanResult['layerViolations'];
45
+ contractViolations: ScanResult['contractViolations'];
46
+ hotZones: ScanResult['hotZones'];
47
+ generatedModules: ModuleId[];
48
+ };
49
+ verificationOrder: string[];
50
+ }
51
+
52
+ export interface FixPlanRepairGroup {
53
+ id: 'safe-first' | 'high-impact' | 'review-before-change';
54
+ title: string;
55
+ description: string;
56
+ findings: string[];
57
+ }
58
+
59
+ export interface BuildFixPlanOptions {
60
+ appVersion?: string;
61
+ exportedAt?: string;
62
+ }
63
+
64
+ export function buildFixPlan(scan: ScanResult, options: BuildFixPlanOptions = {}): FixPlanReport {
65
+ const generated = collectGenerated(scan.modules);
66
+ const priorityFindings = buildPriorityFindings(scan, generated);
67
+ return {
68
+ kind: 'archora-fix-plan',
69
+ version: 1,
70
+ exportedAt: options.exportedAt ?? new Date().toISOString(),
71
+ appVersion: options.appVersion ?? 'archora',
72
+ project: scan.project,
73
+ scannedAt: scan.scannedAt,
74
+ architectureDebt: scan.archDebt,
75
+ summary: {
76
+ cycles: scan.cycles.length,
77
+ layerViolations: scan.layerViolations.length,
78
+ contractViolations: scan.contractViolations.length,
79
+ hotZones: scan.hotZones.length,
80
+ generatedModules: generated.size,
81
+ },
82
+ priorityFindings,
83
+ repairGroups: buildRepairGroups(priorityFindings),
84
+ evidence: {
85
+ cycles: scan.cycles.slice(0, 20),
86
+ layerViolations: scan.layerViolations.slice(0, 50),
87
+ contractViolations: scan.contractViolations.slice(0, 50),
88
+ hotZones: scan.hotZones.slice(0, 50),
89
+ generatedModules: [...generated].slice(0, 100),
90
+ },
91
+ verificationOrder: buildVerificationOrder(scan),
92
+ };
93
+ }
94
+
95
+ function buildRepairGroups(findings: readonly FixPlanFinding[]): FixPlanRepairGroup[] {
96
+ const safeFirst = findings
97
+ .filter((finding) => ['unreachable-from-entries', 'barrel-cycle'].includes(finding.type))
98
+ .slice(0, 8)
99
+ .map((finding) => finding.id);
100
+ const highImpact = findings
101
+ .filter((finding) => finding.weight >= 80 && !finding.generated)
102
+ .slice(0, 8)
103
+ .map((finding) => finding.id);
104
+ const reviewBeforeChange = findings
105
+ .filter((finding) =>
106
+ ['cycle', 'layer-violation', 'contract-violation', 'hot-zone'].includes(finding.type),
107
+ )
108
+ .slice(0, 8)
109
+ .map((finding) => finding.id);
110
+
111
+ return [
112
+ {
113
+ id: 'safe-first',
114
+ title: 'Safe first',
115
+ description: 'Small repair candidates with concrete targets and low blast radius.',
116
+ findings: safeFirst,
117
+ },
118
+ {
119
+ id: 'high-impact',
120
+ title: 'High impact',
121
+ description: 'Highest-weight user-code findings to review before release or broad refactor.',
122
+ findings: highImpact,
123
+ },
124
+ {
125
+ id: 'review-before-change',
126
+ title: 'Review before change',
127
+ description: 'Findings that need Impact, Rules or Cycles evidence before editing code.',
128
+ findings: reviewBeforeChange,
129
+ },
130
+ ];
131
+ }
132
+
133
+ function collectGenerated(modules: readonly ModuleNode[]): Set<ModuleId> {
134
+ const set = new Set<ModuleId>();
135
+ for (const m of modules) if (m.isGenerated || isLikelyGeneratedPath(m.id)) set.add(m.id);
136
+ return set;
137
+ }
138
+
139
+ // Generated-only findings are evidence, not the first repair target.
140
+ const GENERATED_WEIGHT_MULTIPLIER = 0.1;
141
+
142
+ function buildPriorityFindings(scan: ScanResult, generated: Set<ModuleId>): FixPlanFinding[] {
143
+ const findings: FixPlanFinding[] = [];
144
+ const tagAndDownweight = (f: FixPlanFinding): FixPlanFinding => {
145
+ if (f.targets.length === 0) return f;
146
+ const allGenerated = f.targets.every((id) => generated.has(id));
147
+ if (!allGenerated) return f;
148
+ return { ...f, generated: true, weight: f.weight * GENERATED_WEIGHT_MULTIPLIER };
149
+ };
150
+
151
+ for (const cycle of scan.cycles.slice(0, 20)) {
152
+ if (!cycle.modules.some(isReviewModule)) continue;
153
+ const bp = cycle.suggestedBreakpoint;
154
+ const breakLabel = bp
155
+ ? `${bp.from} -> ${bp.to}`
156
+ : cycle.modules[0] && cycle.modules[1]
157
+ ? `${cycle.modules[0]} -> ${cycle.modules[1]}`
158
+ : cycle.id;
159
+ findings.push({
160
+ type: 'cycle',
161
+ id: cycle.id,
162
+ title: cycle.severity === 'direct' ? 'Direct dependency cycle' : 'Indirect dependency cycle',
163
+ weight: cycle.severity === 'direct' ? 100 : 82,
164
+ targets: cycle.modules,
165
+ reason: `${cycle.length} modules close a dependency cycle.`,
166
+ action: `Break the import from ${breakLabel}.`,
167
+ verify: 'Open Cycles and confirm this cycle disappeared or reduced after re-scan.',
168
+ params: { severity: cycle.severity, suggestedBreakpoint: bp ?? breakLabel },
169
+ });
170
+
171
+ const barrel = cycle.modules.find((id) => /(^|\/)index\.[cm]?[jt]sx?$/u.test(id));
172
+ if (barrel) {
173
+ const repair = barrelCycleRepair(scan, cycle.modules, barrel);
174
+ findings.push({
175
+ type: 'barrel-cycle',
176
+ id: `${cycle.id}:barrel`,
177
+ title: 'Barrel cycle candidate',
178
+ weight: cycle.severity === 'direct' ? 92 : 74,
179
+ targets: cycle.modules,
180
+ reason: `${barrel} participates in a dependency cycle and may be re-exporting back into its consumers.`,
181
+ action: repair.action,
182
+ verify: repair.verify,
183
+ params: { cycleId: cycle.id, barrel, ...repair.params },
184
+ });
185
+ }
186
+ }
187
+
188
+ for (const violation of scan.layerViolations.slice(0, 50)) {
189
+ if (!isReviewModule(violation.from) || !isReviewModule(violation.to)) continue;
190
+ findings.push({
191
+ type: 'layer-violation',
192
+ id: violation.edgeId,
193
+ title: `${violation.fromLayer} imports ${violation.toLayer}`,
194
+ weight: violation.severity === 'error' ? 88 : 66,
195
+ targets: [violation.from, violation.to],
196
+ reason: 'Import direction crosses a configured or inferred layer boundary.',
197
+ action:
198
+ 'Move the dependency to an allowed layer, introduce an adapter, or make the policy explicit.',
199
+ verify: 'Open Rules and confirm the violated boundary is no longer present after re-scan.',
200
+ params: {
201
+ severity: violation.severity,
202
+ fromLayer: violation.fromLayer,
203
+ toLayer: violation.toLayer,
204
+ },
205
+ });
206
+ const moveRepair = layerViolationMoveRepair(violation);
207
+ findings.push({
208
+ type: 'move-module',
209
+ id: `${violation.edgeId}:move`,
210
+ title: `Move boundary for ${violation.fromLayer} -> ${violation.toLayer}`,
211
+ weight: violation.severity === 'error' ? 84 : 58,
212
+ targets: [violation.from, violation.to],
213
+ reason: 'This import crosses a layer boundary and has a deterministic source/target pair.',
214
+ action: moveRepair.action,
215
+ verify: moveRepair.verify,
216
+ params: {
217
+ severity: violation.severity,
218
+ from: violation.from,
219
+ to: violation.to,
220
+ fromLayer: violation.fromLayer,
221
+ toLayer: violation.toLayer,
222
+ },
223
+ });
224
+ }
225
+
226
+ for (const module of scan.modules) {
227
+ if (!isReviewModule(module.id)) continue;
228
+ const metrics = scan.metrics[module.id];
229
+ const fanIn = metrics?.fanIn ?? 0;
230
+ const fanOut = metrics?.fanOut ?? 0;
231
+ if (
232
+ fanIn === 0 &&
233
+ fanOut === 0 &&
234
+ module.exports.length === 0 &&
235
+ module.kind !== 'entry' &&
236
+ module.kind !== 'test' &&
237
+ !module.isGenerated &&
238
+ !module.isInfra
239
+ ) {
240
+ const repair = unreachableRepair(module.id);
241
+ findings.push({
242
+ type: 'unreachable-from-entries',
243
+ id: `${module.id}:unreachable`,
244
+ title: 'Unreachable module candidate',
245
+ weight: Math.min(70, 35 + module.loc / 10),
246
+ targets: [module.id],
247
+ reason: 'No resolved imports connect this module to the analyzed dependency model.',
248
+ action: repair.action,
249
+ verify: repair.verify,
250
+ params: { loc: module.loc, ...repair.params },
251
+ });
252
+ }
253
+ }
254
+
255
+ for (const violation of scan.contractViolations.slice(0, 50)) {
256
+ if (!violation.modules.some(isReviewModule)) continue;
257
+ const repair = contractViolationRepair(violation);
258
+ findings.push({
259
+ type: 'contract-violation',
260
+ id: violation.id,
261
+ title: violation.ruleName,
262
+ weight: violation.severity === 'error' ? 86 : 64,
263
+ targets: violation.modules,
264
+ reason: violation.message,
265
+ action: repair.action,
266
+ verify: repair.verify,
267
+ params: { severity: violation.severity },
268
+ });
269
+ }
270
+
271
+ for (const id of scan.hotZones.slice(0, 50)) {
272
+ if (!isReviewModule(id)) continue;
273
+ const metrics = scan.metrics[id];
274
+ const churn = scan.churn?.[id];
275
+ const repair = hotspotRepair(id, metrics, churn);
276
+ const finding: FixPlanFinding = {
277
+ type: 'hot-zone',
278
+ id,
279
+ title: 'High change-risk module',
280
+ weight: metrics ? Math.min(80, metrics.hotnessScore * 10) : 40,
281
+ targets: [id],
282
+ reason: hotspotReason(metrics, churn),
283
+ action: repair.action,
284
+ verify: repair.verify,
285
+ };
286
+ if (metrics) {
287
+ finding.params = { fanIn: metrics.fanIn, fanOut: metrics.fanOut, inCycle: metrics.inCycle };
288
+ }
289
+ if (churn) {
290
+ finding.params = {
291
+ ...finding.params,
292
+ commits: churn.commits,
293
+ authorCount: churn.authorCount,
294
+ linesChanged: churn.linesChanged,
295
+ };
296
+ }
297
+ findings.push(finding);
298
+ }
299
+
300
+ for (const item of scan.recommendations) {
301
+ if (!item.modules.some(isReviewModule)) continue;
302
+ findings.push({
303
+ type: 'recommendation',
304
+ id: item.id,
305
+ title: item.kind,
306
+ weight: item.weight,
307
+ targets: item.modules,
308
+ reason: 'Analyzer recommendation derived from structural findings.',
309
+ action: 'Review the recommendation in Archora before applying the change.',
310
+ verify: 'Re-scan the project and compare the priority queue.',
311
+ params: recommendationParams(item),
312
+ });
313
+ }
314
+
315
+ return findings
316
+ .map(tagAndDownweight)
317
+ .sort((a, b) => b.weight - a.weight)
318
+ .slice(0, 100);
319
+ }
320
+
321
+ function isReviewModule(id: ModuleId): boolean {
322
+ return !/(^|\/)(fixtures|test\/fixtures|__fixtures__|__tests__|__mocks__)(\/|$)|\.(test|spec)\./u.test(
323
+ id,
324
+ );
325
+ }
326
+
327
+ function isLikelyGeneratedPath(id: ModuleId): boolean {
328
+ return /(^|\/)(generated|__generated__|openapi|swagger|graphql-codegen)(\/|$)/iu.test(id);
329
+ }
330
+
331
+ function unreachableRepair(id: ModuleId): {
332
+ action: string;
333
+ verify: string;
334
+ params: Record<string, unknown>;
335
+ } {
336
+ if (isScriptEntryCandidate(id)) {
337
+ return {
338
+ action: `Treat ${id} as a script entry: add it to architecture entry configuration or exclude it from review scope; delete only after confirming no package script or CI job calls it.`,
339
+ verify:
340
+ 'Run archora report . --format fix-plan after entry/exclude config and confirm this script is no longer a priority finding.',
341
+ params: { entryCandidate: 'script' },
342
+ };
343
+ }
344
+
345
+ return {
346
+ action:
347
+ 'Check whether this file is dead code, dynamically loaded outside analyzer reach, or should be declared as an entry point.',
348
+ verify:
349
+ 'Re-scan after deletion, ignore, or entry-point configuration and confirm the candidate is gone.',
350
+ params: {},
351
+ };
352
+ }
353
+
354
+ function isScriptEntryCandidate(id: ModuleId): boolean {
355
+ const normalized = id.replace(/\\/gu, '/');
356
+ let scriptsIndex = 0;
357
+ if (!normalized.startsWith('scripts/')) {
358
+ const nestedIndex = normalized.indexOf('/scripts/');
359
+ if (nestedIndex < 0) return false;
360
+ scriptsIndex = nestedIndex + 1;
361
+ }
362
+ const file = normalized.slice(scriptsIndex + 'scripts/'.length);
363
+ if (!file) return false;
364
+ return file.endsWith('.js') || file.endsWith('.cjs') || file.endsWith('.mjs');
365
+ }
366
+
367
+ function barrelCycleRepair(
368
+ scan: ScanResult,
369
+ modules: readonly ModuleId[],
370
+ barrel: ModuleId,
371
+ ): { action: string; verify: string; params: Record<string, unknown> } {
372
+ const inCycle = new Set(modules);
373
+ const importer = scan.edges.find(
374
+ (edge) =>
375
+ edge.kind !== 'type-only' &&
376
+ edge.to === barrel &&
377
+ edge.from !== barrel &&
378
+ inCycle.has(edge.from),
379
+ );
380
+
381
+ if (!importer) {
382
+ return {
383
+ action:
384
+ 'Split the barrel export or import the concrete module directly at the suggested cycle boundary.',
385
+ verify:
386
+ 'Open Cycles and confirm the cycle no longer contains this barrel module after re-scan.',
387
+ params: {},
388
+ };
389
+ }
390
+
391
+ return {
392
+ action: `Replace imports through ${barrel} in ${importer.from} with concrete module paths, then keep ${barrel} as export-only.`,
393
+ verify: `Run archora check . --fail-on new-cycles:0 and confirm ${barrel} no longer participates in this cycle.`,
394
+ params: {
395
+ importer: importer.from,
396
+ importSpecifier: importer.specifier,
397
+ },
398
+ };
399
+ }
400
+
401
+ function layerViolationMoveRepair(violation: ScanResult['layerViolations'][number]): {
402
+ action: string;
403
+ verify: string;
404
+ } {
405
+ return {
406
+ action: `Remove the forbidden import ${violation.from} -> ${violation.to}. Move the dependency behind a ${violation.fromLayer}-facing adapter or invert the dependency through an allowed ${violation.toLayer} entry.`,
407
+ verify:
408
+ 'Run archora check . --fail-on layer-violations:0 and confirm the boundary no longer appears in Rules.',
409
+ };
410
+ }
411
+
412
+ function hotspotRepair(
413
+ id: ModuleId,
414
+ metrics: ScanResult['metrics'][ModuleId] | undefined,
415
+ churn: NonNullable<ScanResult['churn']>[ModuleId] | undefined,
416
+ ): { action: string; verify: string } {
417
+ const churnPrefix =
418
+ churn && churn.commits >= 5
419
+ ? `Coordinate the change with recent owners first: ${id} changed ${churn.commits} times across ${churn.authorCount} author(s). `
420
+ : '';
421
+
422
+ if (!metrics) {
423
+ return {
424
+ action: `${churnPrefix}Inspect Impact before changing this module and keep the public surface stable.`,
425
+ verify: `Run archora impact --module ${id} and focused tests around affected consumers.`,
426
+ };
427
+ }
428
+
429
+ if (metrics.fanIn >= metrics.fanOut) {
430
+ return {
431
+ action: `${churnPrefix}Freeze the public surface of ${id} first, then inspect its ${metrics.fanIn} consumers before editing internals.`,
432
+ verify: `Run archora impact --module ${id} and focused tests around affected consumers.`,
433
+ };
434
+ }
435
+
436
+ return {
437
+ action: `${churnPrefix}Reduce outgoing dependencies from ${id} first, then split stable behavior away from volatile integrations.`,
438
+ verify: `Run archora impact --module ${id} and confirm outbound impact is smaller after re-scan.`,
439
+ };
440
+ }
441
+
442
+ function hotspotReason(
443
+ metrics: ScanResult['metrics'][ModuleId] | undefined,
444
+ churn: NonNullable<ScanResult['churn']>[ModuleId] | undefined,
445
+ ): string {
446
+ const structural = metrics
447
+ ? `${metrics.fanIn} incoming and ${metrics.fanOut} outgoing dependencies.`
448
+ : 'Module was ranked as a hotspot by the analyzer.';
449
+ if (!churn) return structural;
450
+ return `${structural} Git history: ${churn.commits} commits, ${churn.linesChanged} changed lines, ${churn.authorCount} author(s).`;
451
+ }
452
+
453
+ function contractViolationRepair(violation: ScanResult['contractViolations'][number]): {
454
+ action: string;
455
+ verify: string;
456
+ } {
457
+ const target = violation.modules.find(isReviewModule) ?? violation.modules[0] ?? violation.id;
458
+ const fallback =
459
+ violation.severity === 'error'
460
+ ? 'archora check . --fail-on contract-errors:0'
461
+ : 'archora check . --fail-on contract-violations:0';
462
+
463
+ return {
464
+ action: `Fix ${violation.ruleName} in ${target}: narrow the exported surface or add an explicit policy exception with owner approval.`,
465
+ verify: `Run ${fallback} and confirm the contract violation is removed or intentionally suppressed.`,
466
+ };
467
+ }
468
+
469
+ function recommendationParams(item: Recommendation): Record<string, unknown> {
470
+ return item.params as Record<string, unknown>;
471
+ }
472
+
473
+ function buildVerificationOrder(scan: ScanResult): string[] {
474
+ const steps: string[] = [];
475
+ if (scan.cycles.length > 0) steps.push('Verify cycle breakpoints first');
476
+ if (scan.layerViolations.length > 0) steps.push('Verify layer boundary fixes');
477
+ if (scan.contractViolations.length > 0) steps.push('Verify contract exceptions or fixes');
478
+ if (scan.hotZones.length > 0) steps.push('Run focused smoke tests around hot zones');
479
+ if (steps.length === 0) steps.push('Run project test suite after changes');
480
+ return steps;
481
+ }
@@ -0,0 +1,27 @@
1
+ import type { ScanResult } from '../analyzer/types';
2
+
3
+ export interface ReportEnvelope {
4
+ /** Schema/format version for forward-compat. Bump if shape changes. */
5
+ schema: 1;
6
+ /** Application version that produced the report. */
7
+ app: string;
8
+ /** ISO timestamp of export. */
9
+ exportedAt: string;
10
+ scan: ScanResult;
11
+ }
12
+
13
+ export interface BuildReportOptions {
14
+ appVersion?: string;
15
+ exportedAt?: string;
16
+ pretty?: boolean;
17
+ }
18
+
19
+ export function buildJsonReport(scan: ScanResult, options: BuildReportOptions = {}): string {
20
+ const envelope: ReportEnvelope = {
21
+ schema: 1,
22
+ app: options.appVersion ?? 'archora',
23
+ exportedAt: options.exportedAt ?? new Date().toISOString(),
24
+ scan,
25
+ };
26
+ return options.pretty === false ? JSON.stringify(envelope) : JSON.stringify(envelope, null, 2);
27
+ }
@@ -0,0 +1,67 @@
1
+ import { describe, expect, it } from 'vitest';
2
+ import { parseQuery, isEmpty } from '../parseQuery';
3
+
4
+ describe('parseQuery', () => {
5
+ it('пустая строка → пустой query', () => {
6
+ const q = parseQuery('');
7
+ expect(q.free).toEqual([]);
8
+ expect(q.prefixes).toEqual({});
9
+ expect(isEmpty(q)).toBe(true);
10
+ });
11
+
12
+ it('один free-token без префиксов', () => {
13
+ expect(parseQuery('useAuth')).toEqual({ free: ['useAuth'], prefixes: {} });
14
+ });
15
+
16
+ it('один префикс path:', () => {
17
+ expect(parseQuery('path:src/features')).toEqual({
18
+ free: [],
19
+ prefixes: { path: ['src/features'] },
20
+ });
21
+ });
22
+
23
+ it('несколько префиксов разных ключей = AND-пересечение', () => {
24
+ const q = parseQuery('kind:component path:auth');
25
+ expect(q).toEqual({ free: [], prefixes: { kind: ['component'], path: ['auth'] } });
26
+ });
27
+
28
+ it('несколько одинаковых префиксов = OR внутри ключа', () => {
29
+ const q = parseQuery('kind:component kind:composable');
30
+ expect(q.prefixes.kind).toEqual(['component', 'composable']);
31
+ });
32
+
33
+ it('префикс + free-text смешаны', () => {
34
+ expect(parseQuery('useAuth kind:composable')).toEqual({
35
+ free: ['useAuth'],
36
+ prefixes: { kind: ['composable'] },
37
+ });
38
+ });
39
+
40
+ it('значение префикса в кавычках допускает пробелы', () => {
41
+ const q = parseQuery('path:"src/feature x"');
42
+ expect(q.prefixes.path).toEqual(['src/feature x']);
43
+ });
44
+
45
+ it('неизвестный префикс — фолбэк в free-text целиком', () => {
46
+ // `foo:bar` не известный prefix → токен целиком уходит в free
47
+ expect(parseQuery('foo:bar')).toEqual({ free: ['foo:bar'], prefixes: {} });
48
+ });
49
+
50
+ it('пустое значение префикса — игнорируется', () => {
51
+ // `kind:` без значения — не добавляет ключа
52
+ const q = parseQuery('kind: hello');
53
+ expect(q.prefixes.kind).toBeUndefined();
54
+ expect(q.free).toContain('hello');
55
+ });
56
+
57
+ it('case-insensitive ключи, case-sensitive значения', () => {
58
+ const q = parseQuery('KIND:Component PATH:Auth');
59
+ expect(q.prefixes.kind).toEqual(['Component']);
60
+ expect(q.prefixes.path).toEqual(['Auth']);
61
+ });
62
+
63
+ it('export / import префиксы работают', () => {
64
+ const q = parseQuery('export:useAuth import:react-query');
65
+ expect(q.prefixes).toEqual({ export: ['useAuth'], import: ['react-query'] });
66
+ });
67
+ });