@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.
- package/.claude/settings.local.json +10 -0
- package/.nomos-config.json +5 -0
- package/CLAUDE.md +108 -0
- package/LICENSE +190 -0
- package/README.md +569 -0
- package/dist/cli.js +21120 -0
- package/docs/auth/googel_plan.yaml +1093 -0
- package/docs/auth/google_task.md +235 -0
- package/docs/auth/hardened_blueprint.yaml +1658 -0
- package/docs/auth/red_team_report.yaml +336 -0
- package/docs/auth/session_state.yaml +162 -0
- package/docs/certificate/cer_enhance_plan.md +605 -0
- package/docs/certificate/certificate_report.md +338 -0
- package/docs/dev_overview.md +419 -0
- package/docs/feature_assessment.md +156 -0
- package/docs/how_it_works.md +78 -0
- package/docs/infrastructure/map.md +867 -0
- package/docs/init/master_plan.md +3581 -0
- package/docs/init/red_team_report.md +215 -0
- package/docs/init/report_phase_1a.md +304 -0
- package/docs/integrity-gate/enhance_drift.md +703 -0
- package/docs/integrity-gate/overview.md +108 -0
- package/docs/management/manger-task.md +99 -0
- package/docs/management/scafffold.md +76 -0
- package/docs/map/ATOMIC_BLUEPRINT.md +1349 -0
- package/docs/map/RED_TEAM_REPORT.md +159 -0
- package/docs/map/map_task.md +147 -0
- package/docs/map/semantic_graph_task.md +792 -0
- package/docs/map/semantic_master_plan.md +705 -0
- package/docs/phase7/TEAM_RED.md +249 -0
- package/docs/phase7/plan.md +1682 -0
- package/docs/phase7/task.md +275 -0
- package/docs/prompts/USAGE.md +312 -0
- package/docs/prompts/architect.md +165 -0
- package/docs/prompts/executer.md +190 -0
- package/docs/prompts/hardener.md +190 -0
- package/docs/prompts/red_team.md +146 -0
- package/docs/verification/goveranance-overview.md +396 -0
- package/docs/verification/governance-overview.md +245 -0
- package/docs/verification/verification-arc-ar.md +560 -0
- package/docs/verification/verification-architecture.md +560 -0
- package/docs/very_next.md +52 -0
- package/docs/whitepaper.md +89 -0
- package/overview.md +1469 -0
- package/package.json +63 -0
- package/src/adapters/__tests__/git.test.ts +296 -0
- package/src/adapters/__tests__/stdio.test.ts +70 -0
- package/src/adapters/git.ts +226 -0
- package/src/adapters/pty.ts +159 -0
- package/src/adapters/stdio.ts +113 -0
- package/src/cli.ts +83 -0
- package/src/commands/apply.ts +47 -0
- package/src/commands/auth.ts +301 -0
- package/src/commands/certificate.ts +89 -0
- package/src/commands/discard.ts +24 -0
- package/src/commands/drift.ts +116 -0
- package/src/commands/index.ts +78 -0
- package/src/commands/init.ts +121 -0
- package/src/commands/list.ts +75 -0
- package/src/commands/map.ts +55 -0
- package/src/commands/plan.ts +30 -0
- package/src/commands/review.ts +58 -0
- package/src/commands/run.ts +63 -0
- package/src/commands/search.ts +147 -0
- package/src/commands/show.ts +63 -0
- package/src/commands/status.ts +59 -0
- package/src/core/__tests__/budget.test.ts +213 -0
- package/src/core/__tests__/certificate.test.ts +385 -0
- package/src/core/__tests__/config.test.ts +191 -0
- package/src/core/__tests__/preflight.test.ts +24 -0
- package/src/core/__tests__/prompt.test.ts +358 -0
- package/src/core/__tests__/review.test.ts +161 -0
- package/src/core/__tests__/state.test.ts +362 -0
- package/src/core/auth/__tests__/manager.test.ts +166 -0
- package/src/core/auth/__tests__/server.test.ts +220 -0
- package/src/core/auth/gcp-projects.ts +160 -0
- package/src/core/auth/manager.ts +114 -0
- package/src/core/auth/server.ts +141 -0
- package/src/core/budget.ts +119 -0
- package/src/core/certificate.ts +502 -0
- package/src/core/config.ts +212 -0
- package/src/core/errors.ts +54 -0
- package/src/core/factory.ts +49 -0
- package/src/core/graph/__tests__/builder.test.ts +272 -0
- package/src/core/graph/__tests__/contract-writer.test.ts +175 -0
- package/src/core/graph/__tests__/enricher.test.ts +299 -0
- package/src/core/graph/__tests__/parser.test.ts +200 -0
- package/src/core/graph/__tests__/pipeline.test.ts +202 -0
- package/src/core/graph/__tests__/renderer.test.ts +128 -0
- package/src/core/graph/__tests__/resolver.test.ts +185 -0
- package/src/core/graph/__tests__/scanner.test.ts +231 -0
- package/src/core/graph/__tests__/show.test.ts +134 -0
- package/src/core/graph/builder.ts +303 -0
- package/src/core/graph/constraints.ts +94 -0
- package/src/core/graph/contract-writer.ts +93 -0
- package/src/core/graph/drift/__tests__/classifier.test.ts +215 -0
- package/src/core/graph/drift/__tests__/comparator.test.ts +335 -0
- package/src/core/graph/drift/__tests__/drift.test.ts +453 -0
- package/src/core/graph/drift/__tests__/reporter.test.ts +203 -0
- package/src/core/graph/drift/classifier.ts +165 -0
- package/src/core/graph/drift/comparator.ts +205 -0
- package/src/core/graph/drift/reporter.ts +77 -0
- package/src/core/graph/enricher.ts +251 -0
- package/src/core/graph/grammar-paths.ts +30 -0
- package/src/core/graph/html-template.ts +493 -0
- package/src/core/graph/map-schema.ts +137 -0
- package/src/core/graph/parser.ts +336 -0
- package/src/core/graph/pipeline.ts +209 -0
- package/src/core/graph/renderer.ts +92 -0
- package/src/core/graph/resolver.ts +195 -0
- package/src/core/graph/scanner.ts +145 -0
- package/src/core/logger.ts +46 -0
- package/src/core/orchestrator.ts +792 -0
- package/src/core/plan-file-manager.ts +66 -0
- package/src/core/preflight.ts +64 -0
- package/src/core/prompt.ts +173 -0
- package/src/core/review.ts +95 -0
- package/src/core/state.ts +294 -0
- package/src/core/worktree-coordinator.ts +77 -0
- package/src/search/__tests__/chunk-extractor.test.ts +339 -0
- package/src/search/__tests__/embedder-auth.test.ts +124 -0
- package/src/search/__tests__/embedder.test.ts +267 -0
- package/src/search/__tests__/graph-enricher.test.ts +178 -0
- package/src/search/__tests__/indexer.test.ts +518 -0
- package/src/search/__tests__/integration.test.ts +649 -0
- package/src/search/__tests__/query-engine.test.ts +334 -0
- package/src/search/__tests__/similarity.test.ts +78 -0
- package/src/search/__tests__/vector-store.test.ts +281 -0
- package/src/search/chunk-extractor.ts +167 -0
- package/src/search/embedder.ts +209 -0
- package/src/search/graph-enricher.ts +95 -0
- package/src/search/indexer.ts +483 -0
- package/src/search/lexical-searcher.ts +190 -0
- package/src/search/query-engine.ts +225 -0
- package/src/search/vector-store.ts +311 -0
- package/src/types/index.ts +572 -0
- package/src/utils/__tests__/ansi.test.ts +54 -0
- package/src/utils/__tests__/frontmatter.test.ts +79 -0
- package/src/utils/__tests__/sanitize.test.ts +229 -0
- package/src/utils/ansi.ts +19 -0
- package/src/utils/context.ts +44 -0
- package/src/utils/frontmatter.ts +27 -0
- package/src/utils/sanitize.ts +78 -0
- package/test/e2e/lifecycle.test.ts +330 -0
- package/test/fixtures/mock-planner-hang.ts +5 -0
- package/test/fixtures/mock-planner.ts +26 -0
- package/test/fixtures/mock-reviewer-bad.ts +8 -0
- package/test/fixtures/mock-reviewer-retry.ts +34 -0
- package/test/fixtures/mock-reviewer.ts +18 -0
- package/test/fixtures/sample-project/src/circular-a.ts +6 -0
- package/test/fixtures/sample-project/src/circular-b.ts +6 -0
- package/test/fixtures/sample-project/src/config.ts +15 -0
- package/test/fixtures/sample-project/src/main.ts +19 -0
- package/test/fixtures/sample-project/src/services/product-service.ts +20 -0
- package/test/fixtures/sample-project/src/services/user-service.ts +18 -0
- package/test/fixtures/sample-project/src/types.ts +14 -0
- package/test/fixtures/sample-project/src/utils/index.ts +14 -0
- package/test/fixtures/sample-project/src/utils/validate.ts +12 -0
- package/tsconfig.json +20 -0
- 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
|
+
});
|