@nomos-arc/arc 0.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 (160) hide show
  1. package/.claude/settings.local.json +10 -0
  2. package/.nomos-config.json +5 -0
  3. package/CLAUDE.md +108 -0
  4. package/LICENSE +190 -0
  5. package/README.md +569 -0
  6. package/dist/cli.js +21120 -0
  7. package/docs/auth/googel_plan.yaml +1093 -0
  8. package/docs/auth/google_task.md +235 -0
  9. package/docs/auth/hardened_blueprint.yaml +1658 -0
  10. package/docs/auth/red_team_report.yaml +336 -0
  11. package/docs/auth/session_state.yaml +162 -0
  12. package/docs/certificate/cer_enhance_plan.md +605 -0
  13. package/docs/certificate/certificate_report.md +338 -0
  14. package/docs/dev_overview.md +419 -0
  15. package/docs/feature_assessment.md +156 -0
  16. package/docs/how_it_works.md +78 -0
  17. package/docs/infrastructure/map.md +867 -0
  18. package/docs/init/master_plan.md +3581 -0
  19. package/docs/init/red_team_report.md +215 -0
  20. package/docs/init/report_phase_1a.md +304 -0
  21. package/docs/integrity-gate/enhance_drift.md +703 -0
  22. package/docs/integrity-gate/overview.md +108 -0
  23. package/docs/management/manger-task.md +99 -0
  24. package/docs/management/scafffold.md +76 -0
  25. package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
  26. package/docs/map/RED_TEAM_REPORT.md +159 -0
  27. package/docs/map/map_task.md +147 -0
  28. package/docs/map/semantic_graph_task.md +792 -0
  29. package/docs/map/semantic_master_plan.md +705 -0
  30. package/docs/phase7/TEAM_RED.md +249 -0
  31. package/docs/phase7/plan.md +1682 -0
  32. package/docs/phase7/task.md +275 -0
  33. package/docs/prompts/USAGE.md +312 -0
  34. package/docs/prompts/architect.md +165 -0
  35. package/docs/prompts/executer.md +190 -0
  36. package/docs/prompts/hardener.md +190 -0
  37. package/docs/prompts/red_team.md +146 -0
  38. package/docs/verification/goveranance-overview.md +396 -0
  39. package/docs/verification/governance-overview.md +245 -0
  40. package/docs/verification/verification-arc-ar.md +560 -0
  41. package/docs/verification/verification-architecture.md +560 -0
  42. package/docs/very_next.md +52 -0
  43. package/docs/whitepaper.md +89 -0
  44. package/overview.md +1469 -0
  45. package/package.json +63 -0
  46. package/src/adapters/__tests__/git.test.ts +296 -0
  47. package/src/adapters/__tests__/stdio.test.ts +70 -0
  48. package/src/adapters/git.ts +226 -0
  49. package/src/adapters/pty.ts +159 -0
  50. package/src/adapters/stdio.ts +113 -0
  51. package/src/cli.ts +83 -0
  52. package/src/commands/apply.ts +47 -0
  53. package/src/commands/auth.ts +301 -0
  54. package/src/commands/certificate.ts +89 -0
  55. package/src/commands/discard.ts +24 -0
  56. package/src/commands/drift.ts +116 -0
  57. package/src/commands/index.ts +78 -0
  58. package/src/commands/init.ts +121 -0
  59. package/src/commands/list.ts +75 -0
  60. package/src/commands/map.ts +55 -0
  61. package/src/commands/plan.ts +30 -0
  62. package/src/commands/review.ts +58 -0
  63. package/src/commands/run.ts +63 -0
  64. package/src/commands/search.ts +147 -0
  65. package/src/commands/show.ts +63 -0
  66. package/src/commands/status.ts +59 -0
  67. package/src/core/__tests__/budget.test.ts +213 -0
  68. package/src/core/__tests__/certificate.test.ts +385 -0
  69. package/src/core/__tests__/config.test.ts +191 -0
  70. package/src/core/__tests__/preflight.test.ts +24 -0
  71. package/src/core/__tests__/prompt.test.ts +358 -0
  72. package/src/core/__tests__/review.test.ts +161 -0
  73. package/src/core/__tests__/state.test.ts +362 -0
  74. package/src/core/auth/__tests__/manager.test.ts +166 -0
  75. package/src/core/auth/__tests__/server.test.ts +220 -0
  76. package/src/core/auth/gcp-projects.ts +160 -0
  77. package/src/core/auth/manager.ts +114 -0
  78. package/src/core/auth/server.ts +141 -0
  79. package/src/core/budget.ts +119 -0
  80. package/src/core/certificate.ts +502 -0
  81. package/src/core/config.ts +212 -0
  82. package/src/core/errors.ts +54 -0
  83. package/src/core/factory.ts +49 -0
  84. package/src/core/graph/__tests__/builder.test.ts +272 -0
  85. package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
  86. package/src/core/graph/__tests__/enricher.test.ts +299 -0
  87. package/src/core/graph/__tests__/parser.test.ts +200 -0
  88. package/src/core/graph/__tests__/pipeline.test.ts +202 -0
  89. package/src/core/graph/__tests__/renderer.test.ts +128 -0
  90. package/src/core/graph/__tests__/resolver.test.ts +185 -0
  91. package/src/core/graph/__tests__/scanner.test.ts +231 -0
  92. package/src/core/graph/__tests__/show.test.ts +134 -0
  93. package/src/core/graph/builder.ts +303 -0
  94. package/src/core/graph/constraints.ts +94 -0
  95. package/src/core/graph/contract-writer.ts +93 -0
  96. package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
  97. package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
  98. package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
  99. package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
  100. package/src/core/graph/drift/classifier.ts +165 -0
  101. package/src/core/graph/drift/comparator.ts +205 -0
  102. package/src/core/graph/drift/reporter.ts +77 -0
  103. package/src/core/graph/enricher.ts +251 -0
  104. package/src/core/graph/grammar-paths.ts +30 -0
  105. package/src/core/graph/html-template.ts +493 -0
  106. package/src/core/graph/map-schema.ts +137 -0
  107. package/src/core/graph/parser.ts +336 -0
  108. package/src/core/graph/pipeline.ts +209 -0
  109. package/src/core/graph/renderer.ts +92 -0
  110. package/src/core/graph/resolver.ts +195 -0
  111. package/src/core/graph/scanner.ts +145 -0
  112. package/src/core/logger.ts +46 -0
  113. package/src/core/orchestrator.ts +792 -0
  114. package/src/core/plan-file-manager.ts +66 -0
  115. package/src/core/preflight.ts +64 -0
  116. package/src/core/prompt.ts +173 -0
  117. package/src/core/review.ts +95 -0
  118. package/src/core/state.ts +294 -0
  119. package/src/core/worktree-coordinator.ts +77 -0
  120. package/src/search/__tests__/chunk-extractor.test.ts +339 -0
  121. package/src/search/__tests__/embedder-auth.test.ts +124 -0
  122. package/src/search/__tests__/embedder.test.ts +267 -0
  123. package/src/search/__tests__/graph-enricher.test.ts +178 -0
  124. package/src/search/__tests__/indexer.test.ts +518 -0
  125. package/src/search/__tests__/integration.test.ts +649 -0
  126. package/src/search/__tests__/query-engine.test.ts +334 -0
  127. package/src/search/__tests__/similarity.test.ts +78 -0
  128. package/src/search/__tests__/vector-store.test.ts +281 -0
  129. package/src/search/chunk-extractor.ts +167 -0
  130. package/src/search/embedder.ts +209 -0
  131. package/src/search/graph-enricher.ts +95 -0
  132. package/src/search/indexer.ts +483 -0
  133. package/src/search/lexical-searcher.ts +190 -0
  134. package/src/search/query-engine.ts +225 -0
  135. package/src/search/vector-store.ts +311 -0
  136. package/src/types/index.ts +572 -0
  137. package/src/utils/__tests__/ansi.test.ts +54 -0
  138. package/src/utils/__tests__/frontmatter.test.ts +79 -0
  139. package/src/utils/__tests__/sanitize.test.ts +229 -0
  140. package/src/utils/ansi.ts +19 -0
  141. package/src/utils/context.ts +44 -0
  142. package/src/utils/frontmatter.ts +27 -0
  143. package/src/utils/sanitize.ts +78 -0
  144. package/test/e2e/lifecycle.test.ts +330 -0
  145. package/test/fixtures/mock-planner-hang.ts +5 -0
  146. package/test/fixtures/mock-planner.ts +26 -0
  147. package/test/fixtures/mock-reviewer-bad.ts +8 -0
  148. package/test/fixtures/mock-reviewer-retry.ts +34 -0
  149. package/test/fixtures/mock-reviewer.ts +18 -0
  150. package/test/fixtures/sample-project/src/circular-a.ts +6 -0
  151. package/test/fixtures/sample-project/src/circular-b.ts +6 -0
  152. package/test/fixtures/sample-project/src/config.ts +15 -0
  153. package/test/fixtures/sample-project/src/main.ts +19 -0
  154. package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
  155. package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
  156. package/test/fixtures/sample-project/src/types.ts +14 -0
  157. package/test/fixtures/sample-project/src/utils/index.ts +14 -0
  158. package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
  159. package/tsconfig.json +20 -0
  160. package/vitest.config.ts +12 -0
@@ -0,0 +1,453 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { compare } from '../comparator.js';
3
+ import { classify } from '../classifier.js';
4
+ import { render } from '../reporter.js';
5
+ import type {
6
+ FileNode,
7
+ ProjectMap,
8
+ SymbolEntry,
9
+ ImportEntry,
10
+ SemanticInfo,
11
+ } from '../../../../types/index.js';
12
+
13
+ // ─── Factory helpers (mirrored from comparator.test.ts pattern) ───────────────
14
+
15
+ function makeSymbol(
16
+ overrides: Pick<SymbolEntry, 'name'> & Partial<SymbolEntry>,
17
+ ): SymbolEntry {
18
+ return {
19
+ kind: 'function',
20
+ line: 1,
21
+ end_line: null,
22
+ signature: null,
23
+ exported: false,
24
+ ...overrides,
25
+ };
26
+ }
27
+
28
+ function makeImport(
29
+ overrides: Pick<ImportEntry, 'source'> & Partial<ImportEntry>,
30
+ ): ImportEntry {
31
+ return {
32
+ resolved: null,
33
+ symbols: [],
34
+ is_external: false,
35
+ ...overrides,
36
+ };
37
+ }
38
+
39
+ function makeSemanticInfo(sourceHash: string): SemanticInfo {
40
+ return {
41
+ overview: 'test overview',
42
+ purpose: 'test purpose',
43
+ key_logic: [],
44
+ usage_context: [],
45
+ source_hash: sourceHash,
46
+ enriched_at: '2026-01-01T00:00:00.000Z',
47
+ model: 'gemini-1.5-pro',
48
+ };
49
+ }
50
+
51
+ function makeFileNode(
52
+ file: string,
53
+ hash: string,
54
+ overrides: Partial<Omit<FileNode, 'file' | 'hash'>> = {},
55
+ ): FileNode {
56
+ return {
57
+ file,
58
+ hash,
59
+ language: 'typescript',
60
+ symbols: [],
61
+ imports: [],
62
+ dependents: [],
63
+ dependencies: [],
64
+ depth: 0,
65
+ last_parsed_at: null,
66
+ semantic: null,
67
+ enrichment_status: 'structural',
68
+ ...overrides,
69
+ };
70
+ }
71
+
72
+ function makeMap(
73
+ files: Record<string, FileNode>,
74
+ statsOverrides: Partial<ProjectMap['stats']> = {},
75
+ generatedAt = '2026-01-01T00:00:00.000Z',
76
+ ): ProjectMap {
77
+ return {
78
+ schema_version: 1,
79
+ generated_at: generatedAt,
80
+ root: '/project',
81
+ files,
82
+ stats: {
83
+ total_files: Object.keys(files).length,
84
+ total_symbols: 0,
85
+ total_edges: 0,
86
+ core_modules: [],
87
+ structural_only: Object.keys(files).length,
88
+ semantically_enriched: 0,
89
+ indexed: 0,
90
+ ...statsOverrides,
91
+ },
92
+ };
93
+ }
94
+
95
+ // ─── Integration Tests ────────────────────────────────────────────────────────
96
+
97
+ describe('arc drift — full pipeline integration (compare → classify → render)', () => {
98
+ it('1. full happy path — mixed changes produce correct DriftReport, ClassifiedDrift, and terminal output', () => {
99
+ // Baseline: 3 files
100
+ const baseline = makeMap(
101
+ {
102
+ 'src/auth.ts': makeFileNode('src/auth.ts', 'hash-auth-v1', {
103
+ dependents: ['src/cli.ts'],
104
+ depth: 1,
105
+ symbols: [
106
+ makeSymbol({ name: 'login', exported: true, signature: '(): void' }),
107
+ makeSymbol({ name: 'logout', exported: true, signature: '(): void' }),
108
+ ],
109
+ imports: [
110
+ makeImport({ source: 'src/config.ts', resolved: 'src/config.ts', is_external: false }),
111
+ ],
112
+ }),
113
+ 'src/config.ts': makeFileNode('src/config.ts', 'hash-config-v1', {
114
+ depth: 0,
115
+ symbols: [makeSymbol({ name: 'loadConfig', exported: true })],
116
+ }),
117
+ 'src/old-utils.ts': makeFileNode('src/old-utils.ts', 'hash-old', {
118
+ dependents: ['src/auth.ts'],
119
+ symbols: [makeSymbol({ name: 'helper', exported: false })],
120
+ }),
121
+ },
122
+ { core_modules: ['src/config.ts'] },
123
+ '2026-01-01T00:00:00.000Z',
124
+ );
125
+
126
+ // Current: auth.ts modified (symbol signature changed, import added),
127
+ // config.ts unchanged, old-utils.ts removed, new-logger.ts added,
128
+ // depth change on auth.ts
129
+ const current = makeMap(
130
+ {
131
+ 'src/auth.ts': makeFileNode('src/auth.ts', 'hash-auth-v2', {
132
+ dependents: ['src/cli.ts'],
133
+ depth: 3, // depth change of 2
134
+ symbols: [
135
+ makeSymbol({ name: 'login', exported: true, signature: '(opts: LoginOpts): void' }), // signature_changed
136
+ makeSymbol({ name: 'logout', exported: true, signature: '(): void' }),
137
+ makeSymbol({ name: 'refresh', exported: false, signature: '(): Promise<void>' }), // added
138
+ ],
139
+ imports: [
140
+ makeImport({ source: 'src/config.ts', resolved: 'src/config.ts', is_external: false }),
141
+ makeImport({ source: 'src/logger.ts', resolved: 'src/logger.ts', is_external: false }), // added
142
+ ],
143
+ }),
144
+ 'src/config.ts': makeFileNode('src/config.ts', 'hash-config-v1', {
145
+ depth: 0,
146
+ symbols: [makeSymbol({ name: 'loadConfig', exported: true })],
147
+ }),
148
+ 'src/new-logger.ts': makeFileNode('src/new-logger.ts', 'hash-logger', {
149
+ depth: 0,
150
+ symbols: [makeSymbol({ name: 'createLogger', exported: true })],
151
+ }),
152
+ },
153
+ { core_modules: ['src/config.ts', 'src/auth.ts'] }, // auth.ts promoted to core
154
+ '2026-04-11T00:00:00.000Z',
155
+ );
156
+
157
+ // ── compare ───────────────────────────────────────────────────────────────
158
+ const report = compare(baseline, current);
159
+
160
+ // Files: auth.ts modified, old-utils.ts removed, new-logger.ts added
161
+ expect(report.files).toHaveLength(3);
162
+ const authFileDiff = report.files.find((f) => f.file === 'src/auth.ts');
163
+ expect(authFileDiff?.status).toBe('modified');
164
+ const removedFileDiff = report.files.find((f) => f.file === 'src/old-utils.ts');
165
+ expect(removedFileDiff?.status).toBe('removed');
166
+ const addedFileDiff = report.files.find((f) => f.file === 'src/new-logger.ts');
167
+ expect(addedFileDiff?.status).toBe('added');
168
+
169
+ // Symbols: login signature_changed (exported, has dependents → breaking)
170
+ // refresh added (info), helper removed (from old-utils.ts removal)
171
+ const loginDiff = report.symbols.find((s) => s.name === 'login');
172
+ expect(loginDiff?.status).toBe('signature_changed');
173
+ expect(loginDiff?.exported).toBe(true);
174
+ expect(loginDiff?.dependents_affected).toContain('src/cli.ts');
175
+
176
+ const refreshDiff = report.symbols.find((s) => s.name === 'refresh');
177
+ expect(refreshDiff?.status).toBe('added');
178
+
179
+ const helperDiff = report.symbols.find((s) => s.name === 'helper');
180
+ expect(helperDiff?.status).toBe('removed');
181
+ expect(helperDiff?.exported).toBe(false);
182
+
183
+ // Imports: src/logger.ts added to auth.ts
184
+ expect(report.imports).toHaveLength(1);
185
+ expect(report.imports[0].source).toBe('src/logger.ts');
186
+ expect(report.imports[0].status).toBe('added');
187
+
188
+ // Graph: depth change on auth.ts (1 → 3), core_modules_added: auth.ts
189
+ expect(report.graph.depth_changes).toHaveLength(1);
190
+ expect(report.graph.depth_changes[0].file).toBe('src/auth.ts');
191
+ expect(report.graph.depth_changes[0].before).toBe(1);
192
+ expect(report.graph.depth_changes[0].after).toBe(3);
193
+ expect(report.graph.core_modules_added).toContain('src/auth.ts');
194
+ expect(report.graph.core_modules_removed).toHaveLength(0);
195
+
196
+ // Summary
197
+ expect(report.summary.files_added).toBe(1);
198
+ expect(report.summary.files_removed).toBe(1);
199
+ expect(report.summary.files_modified).toBe(1);
200
+ expect(report.summary.symbols_changed).toBe(1);
201
+ expect(report.summary.symbols_added).toBe(1);
202
+ expect(report.summary.imports_added).toBe(1);
203
+ expect(report.summary.depth_changes).toBe(1);
204
+
205
+ // ── classify ──────────────────────────────────────────────────────────────
206
+ const classified = classify(report);
207
+
208
+ // login signature_changed, exported, has dependents → breaking
209
+ const breakingChange = classified.changes.find(
210
+ (c) => c.severity === 'breaking' && c.message.includes('login'),
211
+ );
212
+ expect(breakingChange).toBeDefined();
213
+ expect(classified.has_breaking).toBe(true);
214
+
215
+ // old-utils.ts removed → warning (file)
216
+ const fileWarning = classified.changes.find(
217
+ (c) => c.severity === 'warning' && c.message.includes('old-utils.ts'),
218
+ );
219
+ expect(fileWarning).toBeDefined();
220
+
221
+ // auth.ts promoted to core → info (graph)
222
+ const corePromoted = classified.changes.find(
223
+ (c) => c.severity === 'info' && c.message.includes('promoted to core'),
224
+ );
225
+ expect(corePromoted).toBeDefined();
226
+
227
+ // depth change on auth.ts (delta 2) → warning
228
+ const depthWarning = classified.changes.find(
229
+ (c) => c.severity === 'warning' && c.message.includes('depth change'),
230
+ );
231
+ expect(depthWarning).toBeDefined();
232
+
233
+ // Severity counts
234
+ const breakingCount = classified.changes.filter((c) => c.severity === 'breaking').length;
235
+ expect(breakingCount).toBeGreaterThanOrEqual(1);
236
+
237
+ // ── render (terminal) ─────────────────────────────────────────────────────
238
+ const output = render(classified, baseline.generated_at, current.generated_at, {
239
+ json: false,
240
+ breakingOnly: false,
241
+ });
242
+
243
+ expect(output).toContain('Drift Report: baseline');
244
+ expect(output).toContain('2026-01-01T00:00:00.000Z');
245
+ expect(output).toContain('2026-04-11T00:00:00.000Z');
246
+ expect(output).toContain('BREAKING');
247
+ expect(output).toContain('→');
248
+ expect(output).toContain('Summary:');
249
+ });
250
+
251
+ it('2. no changes — same map as baseline and current produces zero-count DriftReport', () => {
252
+ const map = makeMap({
253
+ 'src/foo.ts': makeFileNode('src/foo.ts', 'hash-foo', {
254
+ symbols: [makeSymbol({ name: 'doFoo', exported: true })],
255
+ }),
256
+ 'src/bar.ts': makeFileNode('src/bar.ts', 'hash-bar'),
257
+ });
258
+
259
+ const report = compare(map, map);
260
+
261
+ // All counters zero
262
+ expect(report.files).toHaveLength(0);
263
+ expect(report.symbols).toHaveLength(0);
264
+ expect(report.imports).toHaveLength(0);
265
+ expect(report.graph.depth_changes).toHaveLength(0);
266
+ expect(report.graph.core_modules_added).toHaveLength(0);
267
+ expect(report.graph.core_modules_removed).toHaveLength(0);
268
+ expect(report.stale_enrichments).toHaveLength(0);
269
+ Object.values(report.summary).forEach((v) => expect(v).toBe(0));
270
+
271
+ const classified = classify(report);
272
+ expect(classified.changes).toHaveLength(0);
273
+ expect(classified.has_breaking).toBe(false);
274
+
275
+ const output = render(classified, map.generated_at, map.generated_at, {
276
+ json: false,
277
+ breakingOnly: false,
278
+ });
279
+ expect(output).toContain('No drift detected');
280
+ });
281
+
282
+ it('3. breakingOnly — render() with breakingOnly=true only includes breaking changes', () => {
283
+ const baseline = makeMap({
284
+ 'src/api.ts': makeFileNode('src/api.ts', 'hash-v1', {
285
+ dependents: ['src/consumer.ts'],
286
+ symbols: [
287
+ makeSymbol({ name: 'fetchUser', exported: true, signature: '(id: string): User' }),
288
+ makeSymbol({ name: 'fetchList', exported: false, signature: '(): User[]' }),
289
+ ],
290
+ }),
291
+ });
292
+
293
+ const current = makeMap({
294
+ 'src/api.ts': makeFileNode('src/api.ts', 'hash-v2', {
295
+ dependents: ['src/consumer.ts'],
296
+ symbols: [
297
+ makeSymbol({
298
+ name: 'fetchUser',
299
+ exported: true,
300
+ signature: '(id: string, opts: Options): User', // signature_changed
301
+ }),
302
+ // fetchList removed (not exported → info)
303
+ ],
304
+ }),
305
+ 'src/helpers.ts': makeFileNode('src/helpers.ts', 'hash-helpers'), // added → info
306
+ });
307
+
308
+ const report = compare(baseline, current);
309
+ const classified = classify(report);
310
+
311
+ // Confirm there are both breaking and non-breaking changes
312
+ const severities = new Set(classified.changes.map((c) => c.severity));
313
+ expect(severities.has('breaking')).toBe(true);
314
+
315
+ const breakingOnlyOutput = render(classified, baseline.generated_at, current.generated_at, {
316
+ json: false,
317
+ breakingOnly: true,
318
+ });
319
+
320
+ expect(breakingOnlyOutput).toContain('BREAKING');
321
+ // WARNING and INFO sections should not appear
322
+ expect(breakingOnlyOutput).not.toContain('WARNING (');
323
+ expect(breakingOnlyOutput).not.toContain('INFO (');
324
+ });
325
+
326
+ it('4. --json — render() with json=true produces valid JSON matching ClassifiedDrift schema', () => {
327
+ const baseline = makeMap({
328
+ 'src/mod.ts': makeFileNode('src/mod.ts', 'hash-a', {
329
+ symbols: [makeSymbol({ name: 'go', exported: true })],
330
+ }),
331
+ });
332
+ const current = makeMap({
333
+ 'src/mod.ts': makeFileNode('src/mod.ts', 'hash-b', {
334
+ symbols: [makeSymbol({ name: 'go', exported: true, signature: '(): void' })],
335
+ }),
336
+ 'src/new.ts': makeFileNode('src/new.ts', 'hash-new'),
337
+ });
338
+
339
+ const report = compare(baseline, current);
340
+ const classified = classify(report);
341
+
342
+ const jsonOutput = render(classified, baseline.generated_at, current.generated_at, {
343
+ json: true,
344
+ breakingOnly: false,
345
+ });
346
+
347
+ // Must be valid JSON
348
+ let parsed: ReturnType<JSON['parse']>;
349
+ expect(() => {
350
+ parsed = JSON.parse(jsonOutput);
351
+ }).not.toThrow();
352
+
353
+ // Must match the expected schema
354
+ expect(parsed).toHaveProperty('baseline_generated_at', baseline.generated_at);
355
+ expect(parsed).toHaveProperty('current_generated_at', current.generated_at);
356
+ expect(parsed).toHaveProperty('has_breaking');
357
+ expect(Array.isArray(parsed.changes)).toBe(true);
358
+ expect(parsed).toHaveProperty('summary');
359
+
360
+ // Each change entry must have the required shape
361
+ for (const change of parsed.changes) {
362
+ expect(change).toHaveProperty('severity');
363
+ expect(change).toHaveProperty('category');
364
+ expect(change).toHaveProperty('message');
365
+ expect(change).toHaveProperty('file');
366
+ expect(change).toHaveProperty('detail');
367
+ expect(change).toHaveProperty('suggestion');
368
+ }
369
+ });
370
+
371
+ it('4b. --json with breakingOnly filters changes array to breaking severity only', () => {
372
+ const baseline = makeMap({
373
+ 'src/svc.ts': makeFileNode('src/svc.ts', 'hash-v1', {
374
+ dependents: ['src/consumer.ts'],
375
+ symbols: [
376
+ makeSymbol({ name: 'publicFn', exported: true, signature: '(): void' }),
377
+ makeSymbol({ name: 'internalFn', exported: false }),
378
+ ],
379
+ }),
380
+ });
381
+
382
+ const current = makeMap({
383
+ 'src/svc.ts': makeFileNode('src/svc.ts', 'hash-v2', {
384
+ dependents: ['src/consumer.ts'],
385
+ symbols: [
386
+ makeSymbol({ name: 'publicFn', exported: true, signature: '(opts: Opts): void' }), // breaking
387
+ // internalFn removed → info
388
+ ],
389
+ }),
390
+ 'src/extra.ts': makeFileNode('src/extra.ts', 'hash-extra'), // added → info
391
+ });
392
+
393
+ const report = compare(baseline, current);
394
+ const classified = classify(report);
395
+
396
+ const jsonOutput = render(classified, baseline.generated_at, current.generated_at, {
397
+ json: true,
398
+ breakingOnly: true,
399
+ });
400
+
401
+ const parsed = JSON.parse(jsonOutput);
402
+ expect(parsed.changes.every((c: { severity: string }) => c.severity === 'breaking')).toBe(true);
403
+ });
404
+
405
+ it('5. backward compat — baseline without enrichment_status (defaults to "structural") is handled gracefully', () => {
406
+ // Simulate a baseline FileNode without enrichment_status (as if loaded from an older schema).
407
+ // Zod should default enrichment_status to 'structural' during migration.
408
+ // Here we verify compare() does not crash and stale_enrichments works correctly.
409
+ const baselineFile = makeFileNode('src/legacy.ts', 'hash-leg-v1', {
410
+ semantic: makeSemanticInfo('hash-leg-v1'),
411
+ // enrichment_status defaults to 'structural' — simulates pre-enrichment_status schema
412
+ enrichment_status: 'structural',
413
+ });
414
+
415
+ const baseline = makeMap({ 'src/legacy.ts': baselineFile });
416
+
417
+ // Current: file modified, semantic data is now stale (source_hash doesn't match current hash)
418
+ const currentFile = makeFileNode('src/legacy.ts', 'hash-leg-v2', {
419
+ semantic: makeSemanticInfo('hash-leg-v1'), // stale — still points to old hash
420
+ enrichment_status: 'semantic',
421
+ });
422
+ const current = makeMap({ 'src/legacy.ts': currentFile });
423
+
424
+ // compare() must not throw
425
+ let report;
426
+ expect(() => {
427
+ report = compare(baseline, current);
428
+ }).not.toThrow();
429
+
430
+ // The file was modified (different hashes)
431
+ expect(report!.files).toHaveLength(1);
432
+ expect(report!.files[0].status).toBe('modified');
433
+
434
+ // Stale enrichment detected: source_hash ('hash-leg-v1') !== current hash ('hash-leg-v2')
435
+ expect(report!.stale_enrichments).toHaveLength(1);
436
+ expect(report!.stale_enrichments[0].file).toBe('src/legacy.ts');
437
+ expect(report!.stale_enrichments[0].hash_changed).toBe(true);
438
+
439
+ // classify() must not throw and must return stale severity
440
+ const classified = classify(report!);
441
+ const staleChange = classified.changes.find((c) => c.severity === 'stale');
442
+ expect(staleChange).toBeDefined();
443
+ expect(staleChange?.file).toBe('src/legacy.ts');
444
+
445
+ // render() must not throw
446
+ expect(() =>
447
+ render(classified, baseline.generated_at, current.generated_at, {
448
+ json: false,
449
+ breakingOnly: false,
450
+ }),
451
+ ).not.toThrow();
452
+ });
453
+ });
@@ -0,0 +1,203 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { render } from '../reporter.js';
3
+ import type { ClassifiedDrift, DriftReport } from '../../../../types/index.js';
4
+
5
+ // ─── Fixture helpers ──────────────────────────────────────────────────────────
6
+
7
+ function emptySummary(): DriftReport['summary'] {
8
+ return {
9
+ files_added: 0,
10
+ files_removed: 0,
11
+ files_modified: 0,
12
+ symbols_added: 0,
13
+ symbols_removed: 0,
14
+ symbols_changed: 0,
15
+ imports_added: 0,
16
+ imports_removed: 0,
17
+ depth_changes: 0,
18
+ stale_enrichments: 0,
19
+ };
20
+ }
21
+
22
+ function emptyDrift(): ClassifiedDrift {
23
+ return { changes: [], has_breaking: false, summary: emptySummary() };
24
+ }
25
+
26
+ function makeDrift(overrides: Partial<ClassifiedDrift> = {}): ClassifiedDrift {
27
+ return { ...emptyDrift(), ...overrides };
28
+ }
29
+
30
+ const BASELINE_DATE = '2026-01-01T00:00:00.000Z';
31
+ const CURRENT_DATE = '2026-04-11T00:00:00.000Z';
32
+
33
+ // ─── Tests ────────────────────────────────────────────────────────────────────
34
+
35
+ describe('render()', () => {
36
+ it('1. terminal output includes "Drift Report:" header with both dates', () => {
37
+ const output = render(emptyDrift(), BASELINE_DATE, CURRENT_DATE, {
38
+ json: false,
39
+ breakingOnly: false,
40
+ });
41
+ expect(output).toContain('Drift Report:');
42
+ expect(output).toContain(BASELINE_DATE);
43
+ expect(output).toContain(CURRENT_DATE);
44
+ });
45
+
46
+ it('2. terminal output groups changes with BREAKING before INFO', () => {
47
+ const drift = makeDrift({
48
+ has_breaking: true,
49
+ changes: [
50
+ {
51
+ severity: 'info',
52
+ category: 'file',
53
+ message: 'info change',
54
+ file: 'src/foo.ts',
55
+ detail: '',
56
+ suggestion: 'no action',
57
+ },
58
+ {
59
+ severity: 'breaking',
60
+ category: 'symbol',
61
+ message: 'breaking change',
62
+ file: 'src/bar.ts',
63
+ detail: '',
64
+ suggestion: 'fix it',
65
+ },
66
+ ],
67
+ });
68
+ const output = render(drift, BASELINE_DATE, CURRENT_DATE, { json: false, breakingOnly: false });
69
+ const breakingIndex = output.indexOf('BREAKING');
70
+ const infoIndex = output.indexOf('INFO');
71
+ expect(breakingIndex).toBeGreaterThanOrEqual(0);
72
+ expect(infoIndex).toBeGreaterThanOrEqual(0);
73
+ expect(breakingIndex).toBeLessThan(infoIndex);
74
+ });
75
+
76
+ it('3. terminal output includes "→" suggestion lines', () => {
77
+ const drift = makeDrift({
78
+ changes: [
79
+ {
80
+ severity: 'warning',
81
+ category: 'symbol',
82
+ message: 'some warning',
83
+ file: 'src/foo.ts',
84
+ detail: 'some detail',
85
+ suggestion: 'do something',
86
+ },
87
+ ],
88
+ });
89
+ const output = render(drift, BASELINE_DATE, CURRENT_DATE, { json: false, breakingOnly: false });
90
+ expect(output).toContain('→ do something');
91
+ });
92
+
93
+ it('4. terminal output includes summary footer line with counts', () => {
94
+ const summary = {
95
+ ...emptySummary(),
96
+ files_added: 2,
97
+ files_removed: 1,
98
+ files_modified: 3,
99
+ };
100
+ const drift = makeDrift({
101
+ summary,
102
+ changes: [
103
+ {
104
+ severity: 'info',
105
+ category: 'file',
106
+ message: 'File added: src/new.ts',
107
+ file: 'src/new.ts',
108
+ detail: '',
109
+ suggestion: 'no action',
110
+ },
111
+ ],
112
+ });
113
+ const output = render(drift, BASELINE_DATE, CURRENT_DATE, { json: false, breakingOnly: false });
114
+ expect(output).toContain('Summary:');
115
+ expect(output).toContain('2 added');
116
+ expect(output).toContain('1 removed');
117
+ expect(output).toContain('3 modified');
118
+ });
119
+
120
+ it('5. breakingOnly=true filters to only BREAKING section in terminal output', () => {
121
+ const drift = makeDrift({
122
+ has_breaking: true,
123
+ changes: [
124
+ {
125
+ severity: 'breaking',
126
+ category: 'symbol',
127
+ message: 'breaking change message',
128
+ file: 'src/bar.ts',
129
+ detail: '',
130
+ suggestion: 'fix it',
131
+ },
132
+ {
133
+ severity: 'info',
134
+ category: 'file',
135
+ message: 'info change message',
136
+ file: 'src/foo.ts',
137
+ detail: '',
138
+ suggestion: 'no action',
139
+ },
140
+ ],
141
+ });
142
+ const output = render(drift, BASELINE_DATE, CURRENT_DATE, { json: false, breakingOnly: true });
143
+ expect(output).toContain('breaking change message');
144
+ expect(output).not.toContain('info change message');
145
+ expect(output).not.toContain('INFO');
146
+ });
147
+
148
+ it('6. JSON output is valid JSON', () => {
149
+ const output = render(emptyDrift(), BASELINE_DATE, CURRENT_DATE, {
150
+ json: true,
151
+ breakingOnly: false,
152
+ });
153
+ expect(() => JSON.parse(output)).not.toThrow();
154
+ });
155
+
156
+ it('7. JSON output includes has_breaking, changes[], summary, and date fields', () => {
157
+ const drift = makeDrift({ has_breaking: false });
158
+ const output = render(drift, BASELINE_DATE, CURRENT_DATE, { json: true, breakingOnly: false });
159
+ const parsed = JSON.parse(output) as Record<string, unknown>;
160
+ expect(parsed).toHaveProperty('has_breaking', false);
161
+ expect(parsed).toHaveProperty('changes');
162
+ expect(Array.isArray(parsed['changes'])).toBe(true);
163
+ expect(parsed).toHaveProperty('summary');
164
+ expect(parsed).toHaveProperty('baseline_generated_at', BASELINE_DATE);
165
+ expect(parsed).toHaveProperty('current_generated_at', CURRENT_DATE);
166
+ });
167
+
168
+ it('8. JSON breakingOnly=true filters changes array to breaking only', () => {
169
+ const drift = makeDrift({
170
+ has_breaking: true,
171
+ changes: [
172
+ {
173
+ severity: 'breaking',
174
+ category: 'symbol',
175
+ message: 'breaking',
176
+ file: 'src/a.ts',
177
+ detail: '',
178
+ suggestion: '',
179
+ },
180
+ {
181
+ severity: 'warning',
182
+ category: 'file',
183
+ message: 'warning',
184
+ file: 'src/b.ts',
185
+ detail: '',
186
+ suggestion: '',
187
+ },
188
+ ],
189
+ });
190
+ const output = render(drift, BASELINE_DATE, CURRENT_DATE, { json: true, breakingOnly: true });
191
+ const parsed = JSON.parse(output) as { changes: Array<{ severity: string }> };
192
+ expect(parsed.changes).toHaveLength(1);
193
+ expect(parsed.changes[0].severity).toBe('breaking');
194
+ });
195
+
196
+ it('9. empty drift (no changes) produces "No drift detected." in terminal output', () => {
197
+ const output = render(emptyDrift(), BASELINE_DATE, CURRENT_DATE, {
198
+ json: false,
199
+ breakingOnly: false,
200
+ });
201
+ expect(output).toContain('No drift detected.');
202
+ });
203
+ });