@codeyam/codeyam-cli 0.1.0-staging.ae0de75 → 0.1.0-staging.b8f4f94

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 (81) hide show
  1. package/analyzer-template/.build-info.json +7 -7
  2. package/analyzer-template/log.txt +3 -3
  3. package/analyzer-template/package.json +3 -3
  4. package/analyzer-template/packages/analyze/src/lib/ProjectAnalyzer.ts +13 -7
  5. package/analyzer-template/packages/analyze/src/lib/asts/index.ts +7 -2
  6. package/codeyam-cli/src/__tests__/memory-scripts/filter-session.test.js +196 -0
  7. package/codeyam-cli/src/__tests__/memory-scripts/filter-session.test.js.map +1 -0
  8. package/codeyam-cli/src/__tests__/memory-scripts/read-json-field.test.js +114 -0
  9. package/codeyam-cli/src/__tests__/memory-scripts/read-json-field.test.js.map +1 -0
  10. package/codeyam-cli/src/__tests__/memory-scripts/ripgrep-fallback.test.js +149 -0
  11. package/codeyam-cli/src/__tests__/memory-scripts/ripgrep-fallback.test.js.map +1 -0
  12. package/codeyam-cli/src/commands/default.js +3 -46
  13. package/codeyam-cli/src/commands/default.js.map +1 -1
  14. package/codeyam-cli/src/commands/editor.js +254 -66
  15. package/codeyam-cli/src/commands/editor.js.map +1 -1
  16. package/codeyam-cli/src/utils/__tests__/editorAudit.test.js +234 -0
  17. package/codeyam-cli/src/utils/__tests__/editorAudit.test.js.map +1 -1
  18. package/codeyam-cli/src/utils/__tests__/editorJournal.test.js +12 -1
  19. package/codeyam-cli/src/utils/__tests__/editorJournal.test.js.map +1 -1
  20. package/codeyam-cli/src/utils/__tests__/editorScenarios.test.js +29 -0
  21. package/codeyam-cli/src/utils/__tests__/editorScenarios.test.js.map +1 -1
  22. package/codeyam-cli/src/utils/__tests__/entityChangeStatus.test.js +1217 -0
  23. package/codeyam-cli/src/utils/__tests__/entityChangeStatus.test.js.map +1 -0
  24. package/codeyam-cli/src/utils/backgroundServer.js +2 -2
  25. package/codeyam-cli/src/utils/backgroundServer.js.map +1 -1
  26. package/codeyam-cli/src/utils/editorAudit.js +45 -5
  27. package/codeyam-cli/src/utils/editorAudit.js.map +1 -1
  28. package/codeyam-cli/src/utils/editorJournal.js +7 -0
  29. package/codeyam-cli/src/utils/editorJournal.js.map +1 -1
  30. package/codeyam-cli/src/utils/entityChangeStatus.js +255 -0
  31. package/codeyam-cli/src/utils/entityChangeStatus.js.map +1 -0
  32. package/codeyam-cli/src/utils/install-skills.js +1 -1
  33. package/codeyam-cli/src/utils/install-skills.js.map +1 -1
  34. package/codeyam-cli/src/utils/setupClaudeCodeSettings.js +5 -1
  35. package/codeyam-cli/src/utils/setupClaudeCodeSettings.js.map +1 -1
  36. package/codeyam-cli/src/webserver/build/client/assets/Terminal-nZNBALox.js +41 -0
  37. package/codeyam-cli/src/webserver/build/client/assets/api.editor-file-diff-l0sNRNKZ.js +1 -0
  38. package/codeyam-cli/src/webserver/build/client/assets/api.editor-file-l0sNRNKZ.js +1 -0
  39. package/codeyam-cli/src/webserver/build/client/assets/editor-DTwKl1Xu.js +10 -0
  40. package/codeyam-cli/src/webserver/build/client/assets/{entity._sha.scenarios._scenarioId.dev-D8ILZMR0.js → entity._sha.scenarios._scenarioId.dev-DjACbfdI.js} +1 -1
  41. package/codeyam-cli/src/webserver/build/client/assets/git-CdN8sCqs.js +1 -0
  42. package/codeyam-cli/src/webserver/build/client/assets/globals-h1-1oFYI.css +1 -0
  43. package/codeyam-cli/src/webserver/build/client/assets/index-yHOVb4rc.js +15 -0
  44. package/codeyam-cli/src/webserver/build/client/assets/manifest-9422aeab.js +1 -0
  45. package/codeyam-cli/src/webserver/build/client/assets/{memory-FweZHj5U.js → memory-Dg0mvYrI.js} +4 -1
  46. package/codeyam-cli/src/webserver/build/client/assets/{root-DUKqhFlb.js → root-BzQgN2ff.js} +1 -1
  47. package/codeyam-cli/src/webserver/build/server/assets/{index-BLhjL9Xi.js → index-Bh_pNxNA.js} +1 -1
  48. package/codeyam-cli/src/webserver/build/server/assets/server-build-Bqr22tlO.js +367 -0
  49. package/codeyam-cli/src/webserver/build/server/index.js +1 -1
  50. package/codeyam-cli/src/webserver/build-info.json +5 -5
  51. package/codeyam-cli/src/webserver/server.js +32 -6
  52. package/codeyam-cli/src/webserver/server.js.map +1 -1
  53. package/codeyam-cli/src/webserver/terminalServer.js +23 -4
  54. package/codeyam-cli/src/webserver/terminalServer.js.map +1 -1
  55. package/codeyam-cli/templates/editor-step-hook.py +53 -6
  56. package/codeyam-cli/templates/skills/codeyam-editor/SKILL.md +10 -5
  57. package/codeyam-cli/templates/skills/codeyam-memory/SKILL.md +10 -10
  58. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/detect-deprecated-patterns.mjs +139 -0
  59. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/find-exports.mjs +52 -0
  60. package/codeyam-cli/templates/skills/codeyam-memory/scripts/lib/read-json-field.mjs +61 -0
  61. package/codeyam-cli/templates/skills/codeyam-memory/scripts/lib/ripgrep-fallback.mjs +155 -0
  62. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/cleanup.mjs +13 -0
  63. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/filter-session.mjs +95 -0
  64. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/preprocess.mjs +160 -0
  65. package/package.json +10 -10
  66. package/packages/analyze/src/lib/ProjectAnalyzer.js +10 -4
  67. package/packages/analyze/src/lib/ProjectAnalyzer.js.map +1 -1
  68. package/packages/analyze/src/lib/asts/index.js +4 -2
  69. package/packages/analyze/src/lib/asts/index.js.map +1 -1
  70. package/scripts/npm-post-install.cjs +22 -0
  71. package/codeyam-cli/src/webserver/build/client/assets/Terminal-wkqC0AQk.js +0 -41
  72. package/codeyam-cli/src/webserver/build/client/assets/editor-CdjF_fX6.js +0 -8
  73. package/codeyam-cli/src/webserver/build/client/assets/git-CFCTYk9I.js +0 -15
  74. package/codeyam-cli/src/webserver/build/client/assets/globals-B17TBSS6.css +0 -1
  75. package/codeyam-cli/src/webserver/build/client/assets/manifest-b8fd6b07.js +0 -1
  76. package/codeyam-cli/src/webserver/build/server/assets/server-build-DyMuI5mU.js +0 -363
  77. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/detect-deprecated-patterns.sh +0 -108
  78. package/codeyam-cli/templates/skills/codeyam-memory/scripts/holistic-analysis/find-exports.sh +0 -69
  79. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/cleanup.sh +0 -12
  80. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/filter.jq +0 -45
  81. package/codeyam-cli/templates/skills/codeyam-memory/scripts/session-mining/preprocess.sh +0 -139
@@ -0,0 +1,1217 @@
1
+ import { buildReverseDependencyGraph, classifyDirectChanges, computeEntityChangeStatus, buildChangedFilesMap, pageNameFromUrl, buildEntityInfosFromScenarios, filterGroupsByChangeStatus, } from "../entityChangeStatus.js";
2
+ // ── Helpers ──────────────────────────────────────────────────────────────
3
+ /** Shorthand to build an EntityInfo with importedBy metadata */
4
+ function entity(name, filePath, importedBy) {
5
+ if (!importedBy)
6
+ return { name, filePath };
7
+ // Convert simplified { filePath: [importerName, ...] } to DB format
8
+ const dbFormat = {};
9
+ for (const [fp, importers] of Object.entries(importedBy)) {
10
+ dbFormat[fp] = {};
11
+ for (const imp of importers) {
12
+ dbFormat[fp][imp] = { shas: ['test-sha'] };
13
+ }
14
+ }
15
+ return { name, filePath, importedBy: dbFormat };
16
+ }
17
+ /** Helper: get sorted impactedBy names from a result */
18
+ function impactNames(result, entityName) {
19
+ return (result[entityName]?.impactedBy || []).map((d) => d.name).sort();
20
+ }
21
+ // ── Tests ────────────────────────────────────────────────────────────────
22
+ describe('entityChangeStatus', () => {
23
+ // ── buildChangedFilesMap ───────────────────────────────────────────────
24
+ describe('buildChangedFilesMap', () => {
25
+ it('should map "added" files to "new"', () => {
26
+ const files = [
27
+ { path: 'components/New.tsx', status: 'added' },
28
+ ];
29
+ const result = buildChangedFilesMap(files, false);
30
+ expect(result.get('components/New.tsx')).toBe('new');
31
+ });
32
+ it('should map "untracked" files to "new"', () => {
33
+ const files = [
34
+ { path: 'components/Untracked.tsx', status: 'untracked' },
35
+ ];
36
+ const result = buildChangedFilesMap(files, false);
37
+ expect(result.get('components/Untracked.tsx')).toBe('new');
38
+ });
39
+ it('should map "modified" files to "edited"', () => {
40
+ const files = [
41
+ { path: 'components/Modified.tsx', status: 'modified' },
42
+ ];
43
+ const result = buildChangedFilesMap(files, false);
44
+ expect(result.get('components/Modified.tsx')).toBe('edited');
45
+ });
46
+ it('should ignore "deleted" files', () => {
47
+ const files = [
48
+ { path: 'components/Deleted.tsx', status: 'deleted' },
49
+ ];
50
+ const result = buildChangedFilesMap(files, false);
51
+ expect(result.size).toBe(0);
52
+ });
53
+ it('should ignore "renamed" files', () => {
54
+ const files = [
55
+ { path: 'components/Renamed.tsx', status: 'renamed' },
56
+ ];
57
+ const result = buildChangedFilesMap(files, false);
58
+ expect(result.size).toBe(0);
59
+ });
60
+ it('should treat ALL non-deleted files as "new" when isFirstFeature is true', () => {
61
+ const files = [
62
+ { path: 'components/Modified.tsx', status: 'modified' },
63
+ { path: 'components/Added.tsx', status: 'added' },
64
+ { path: 'components/Untracked.tsx', status: 'untracked' },
65
+ { path: 'components/Renamed.tsx', status: 'renamed' },
66
+ { path: 'components/Deleted.tsx', status: 'deleted' },
67
+ ];
68
+ const result = buildChangedFilesMap(files, true);
69
+ // Everything except deleted should be 'new'
70
+ expect(result.get('components/Modified.tsx')).toBe('new');
71
+ expect(result.get('components/Added.tsx')).toBe('new');
72
+ expect(result.get('components/Untracked.tsx')).toBe('new');
73
+ expect(result.get('components/Renamed.tsx')).toBe('new');
74
+ expect(result.has('components/Deleted.tsx')).toBe(false);
75
+ });
76
+ it('should return empty map for empty input', () => {
77
+ expect(buildChangedFilesMap([], false).size).toBe(0);
78
+ expect(buildChangedFilesMap([], true).size).toBe(0);
79
+ });
80
+ it('should handle mix of statuses correctly', () => {
81
+ const files = [
82
+ { path: 'a.tsx', status: 'added' },
83
+ { path: 'b.tsx', status: 'modified' },
84
+ { path: 'c.tsx', status: 'untracked' },
85
+ { path: 'd.tsx', status: 'deleted' },
86
+ ];
87
+ const result = buildChangedFilesMap(files, false);
88
+ expect(result.get('a.tsx')).toBe('new');
89
+ expect(result.get('b.tsx')).toBe('edited');
90
+ expect(result.get('c.tsx')).toBe('new');
91
+ expect(result.has('d.tsx')).toBe(false);
92
+ expect(result.size).toBe(3);
93
+ });
94
+ });
95
+ // ── pageNameFromUrl ───────────────────────────────────────────────────
96
+ describe('pageNameFromUrl', () => {
97
+ it('should return "Home" for null', () => {
98
+ expect(pageNameFromUrl(null)).toBe('Home');
99
+ });
100
+ it('should return "Home" for undefined', () => {
101
+ expect(pageNameFromUrl(undefined)).toBe('Home');
102
+ });
103
+ it('should return "Home" for root path "/"', () => {
104
+ expect(pageNameFromUrl('/')).toBe('Home');
105
+ });
106
+ it('should capitalize first segment for simple paths', () => {
107
+ expect(pageNameFromUrl('/about')).toBe('About');
108
+ expect(pageNameFromUrl('/drinks')).toBe('Drinks');
109
+ expect(pageNameFromUrl('/settings')).toBe('Settings');
110
+ });
111
+ it('should use only the first segment for nested paths', () => {
112
+ expect(pageNameFromUrl('/drinks/123')).toBe('Drinks');
113
+ expect(pageNameFromUrl('/blog/posts/latest')).toBe('Blog');
114
+ });
115
+ it('should strip query parameters', () => {
116
+ expect(pageNameFromUrl('/settings?tab=profile')).toBe('Settings');
117
+ expect(pageNameFromUrl('/drinks?sort=name&filter=ipa')).toBe('Drinks');
118
+ });
119
+ it('should handle paths without leading slash', () => {
120
+ expect(pageNameFromUrl('about')).toBe('About');
121
+ });
122
+ it('should handle empty string as Home', () => {
123
+ // Empty string: after stripping '/', split gives [''], charAt(0) is ''
124
+ // This is an edge case — empty string is falsy so returns Home
125
+ expect(pageNameFromUrl('')).toBe('Home');
126
+ });
127
+ it('should handle dynamic route segments', () => {
128
+ expect(pageNameFromUrl('/drinks/[id]')).toBe('Drinks');
129
+ expect(pageNameFromUrl('/users/[userId]/posts')).toBe('Users');
130
+ });
131
+ });
132
+ // ── buildEntityInfosFromScenarios ─────────────────────────────────────
133
+ describe('buildEntityInfosFromScenarios', () => {
134
+ it('should extract component entity from scenario with componentName/componentPath', () => {
135
+ const scenarios = [
136
+ {
137
+ componentName: 'DrinkCard',
138
+ componentPath: 'components/DrinkCard.tsx',
139
+ },
140
+ ];
141
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
142
+ expect(result).toEqual([
143
+ { name: 'DrinkCard', filePath: 'components/DrinkCard.tsx' },
144
+ ]);
145
+ });
146
+ it('should extract page entity from scenario with URL', () => {
147
+ const scenarios = [
148
+ { componentName: null, componentPath: null, url: '/' },
149
+ ];
150
+ const pageFilePaths = { Home: 'app/page.tsx' };
151
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
152
+ expect(result).toEqual([{ name: 'Home', filePath: 'app/page.tsx' }]);
153
+ });
154
+ it('should derive page name from URL using pageNameFromUrl', () => {
155
+ const scenarios = [
156
+ { componentName: null, componentPath: null, url: '/drinks/123' },
157
+ ];
158
+ const pageFilePaths = { Drinks: 'app/drinks/page.tsx' };
159
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
160
+ expect(result).toEqual([
161
+ { name: 'Drinks', filePath: 'app/drinks/page.tsx' },
162
+ ]);
163
+ });
164
+ it('should skip page scenarios when page file path is not found', () => {
165
+ const scenarios = [
166
+ { componentName: null, componentPath: null, url: '/nonexistent' },
167
+ ];
168
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
169
+ expect(result).toEqual([]);
170
+ });
171
+ it('should deduplicate by entity name (first occurrence wins)', () => {
172
+ const scenarios = [
173
+ {
174
+ componentName: 'DrinkCard',
175
+ componentPath: 'components/DrinkCard.tsx',
176
+ },
177
+ {
178
+ componentName: 'DrinkCard',
179
+ componentPath: 'components/DrinkCard.tsx',
180
+ },
181
+ {
182
+ componentName: 'DrinkCard',
183
+ componentPath: 'components/DrinkCard2.tsx',
184
+ },
185
+ ];
186
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
187
+ expect(result).toHaveLength(1);
188
+ expect(result[0].filePath).toBe('components/DrinkCard.tsx');
189
+ });
190
+ it('should enrich entities with importedBy metadata from entity DB', () => {
191
+ const scenarios = [
192
+ {
193
+ componentName: 'DrinkCard',
194
+ componentPath: 'components/DrinkCard.tsx',
195
+ },
196
+ ];
197
+ const entitiesWithMetadata = [
198
+ {
199
+ name: 'DrinkCard',
200
+ metadata: {
201
+ importedBy: {
202
+ 'components/DrinkCard.tsx': {
203
+ DrinkList: { shas: ['abc'] },
204
+ },
205
+ },
206
+ },
207
+ },
208
+ ];
209
+ const result = buildEntityInfosFromScenarios(scenarios, {}, entitiesWithMetadata);
210
+ expect(result[0].importedBy).toEqual({
211
+ 'components/DrinkCard.tsx': {
212
+ DrinkList: { shas: ['abc'] },
213
+ },
214
+ });
215
+ });
216
+ it('should handle entities without matching metadata (no importedBy)', () => {
217
+ const scenarios = [
218
+ {
219
+ componentName: 'DrinkCard',
220
+ componentPath: 'components/DrinkCard.tsx',
221
+ },
222
+ ];
223
+ const entitiesWithMetadata = [
224
+ { name: 'UnrelatedEntity', metadata: null },
225
+ ];
226
+ const result = buildEntityInfosFromScenarios(scenarios, {}, entitiesWithMetadata);
227
+ expect(result[0].importedBy).toBeUndefined();
228
+ });
229
+ it('should handle mixed component and page scenarios', () => {
230
+ const scenarios = [
231
+ {
232
+ componentName: 'DrinkCard',
233
+ componentPath: 'components/DrinkCard.tsx',
234
+ },
235
+ { componentName: null, componentPath: null, url: '/' },
236
+ { componentName: 'Header', componentPath: 'components/Header.tsx' },
237
+ { componentName: null, componentPath: null, url: '/drinks' },
238
+ ];
239
+ const pageFilePaths = {
240
+ Home: 'app/page.tsx',
241
+ Drinks: 'app/drinks/page.tsx',
242
+ };
243
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
244
+ expect(result.map((e) => e.name)).toEqual([
245
+ 'DrinkCard',
246
+ 'Home',
247
+ 'Header',
248
+ 'Drinks',
249
+ ]);
250
+ });
251
+ it('should skip scenarios with componentName but no componentPath', () => {
252
+ const scenarios = [
253
+ { componentName: 'DrinkCard', componentPath: null },
254
+ ];
255
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
256
+ expect(result).toEqual([]);
257
+ });
258
+ it('should handle empty inputs', () => {
259
+ expect(buildEntityInfosFromScenarios([], {}, [])).toEqual([]);
260
+ });
261
+ it('should handle scenarios where url is undefined (not a page scenario)', () => {
262
+ // A scenario with no componentName and no url — should be skipped
263
+ const scenarios = [
264
+ { componentName: null, componentPath: null },
265
+ ];
266
+ const result = buildEntityInfosFromScenarios(scenarios, {}, []);
267
+ expect(result).toEqual([]);
268
+ });
269
+ it('should handle entity metadata with null metadata field', () => {
270
+ const scenarios = [
271
+ { componentName: 'Card', componentPath: 'components/Card.tsx' },
272
+ ];
273
+ const metadata = [
274
+ { name: 'Card', metadata: null },
275
+ ];
276
+ const result = buildEntityInfosFromScenarios(scenarios, {}, metadata);
277
+ expect(result[0].importedBy).toBeUndefined();
278
+ });
279
+ });
280
+ // ── buildReverseDependencyGraph ──────────────────────────────────────
281
+ describe('buildReverseDependencyGraph', () => {
282
+ it('should return empty map for empty entities', () => {
283
+ expect(buildReverseDependencyGraph([]).size).toBe(0);
284
+ });
285
+ it('should build reverse graph from a single importer', () => {
286
+ const result = buildReverseDependencyGraph([
287
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
288
+ 'components/DrinkCard.tsx': ['DrinkList'],
289
+ }),
290
+ ]);
291
+ expect(result.get('DrinkCard')).toEqual(new Set(['DrinkList']));
292
+ });
293
+ it('should collect multiple importers across file paths', () => {
294
+ const result = buildReverseDependencyGraph([
295
+ entity('Button', 'components/Button.tsx', {
296
+ 'components/Button.tsx': ['LoginForm'],
297
+ 'lib/ui/Button.tsx': ['SignupForm'],
298
+ }),
299
+ ]);
300
+ expect(result.get('Button')).toEqual(new Set(['LoginForm', 'SignupForm']));
301
+ });
302
+ it('should deduplicate importers appearing under multiple file paths', () => {
303
+ const result = buildReverseDependencyGraph([
304
+ entity('Icon', 'components/Icon.tsx', {
305
+ 'components/Icon.tsx': ['Header'],
306
+ 'lib/Icon.tsx': ['Header'],
307
+ }),
308
+ ]);
309
+ expect(result.get('Icon')).toEqual(new Set(['Header']));
310
+ });
311
+ it('should skip entities with no importedBy metadata', () => {
312
+ const result = buildReverseDependencyGraph([
313
+ entity('Orphan', 'components/Orphan.tsx'),
314
+ entity('Used', 'components/Used.tsx', {
315
+ 'components/Used.tsx': ['Parent'],
316
+ }),
317
+ ]);
318
+ expect(result.has('Orphan')).toBe(false);
319
+ expect(result.get('Used')).toEqual(new Set(['Parent']));
320
+ });
321
+ it('should handle empty importedBy object gracefully', () => {
322
+ const entities = [
323
+ { name: 'Empty', filePath: 'components/Empty.tsx', importedBy: {} },
324
+ ];
325
+ const result = buildReverseDependencyGraph(entities);
326
+ expect(result.has('Empty')).toBe(false);
327
+ });
328
+ it('should handle multiple importers under a single file path', () => {
329
+ const result = buildReverseDependencyGraph([
330
+ entity('utils', 'lib/utils.ts', {
331
+ 'lib/utils.ts': ['DrinkCard', 'DrinkList', 'Header'],
332
+ }),
333
+ ]);
334
+ expect(result.get('utils')).toEqual(new Set(['DrinkCard', 'DrinkList', 'Header']));
335
+ });
336
+ });
337
+ // ── classifyDirectChanges ────────────────────────────────────────────
338
+ describe('classifyDirectChanges', () => {
339
+ it('should classify entities whose file paths match changed files', () => {
340
+ const changedFiles = new Map([
341
+ ['components/DrinkCard.tsx', 'edited'],
342
+ ['app/page.tsx', 'new'],
343
+ ]);
344
+ const entities = [
345
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
346
+ entity('Home', 'app/page.tsx'),
347
+ entity('Header', 'components/Header.tsx'),
348
+ ];
349
+ const result = classifyDirectChanges(changedFiles, entities);
350
+ expect(result.get('DrinkCard')).toBe('edited');
351
+ expect(result.get('Home')).toBe('new');
352
+ expect(result.has('Header')).toBe(false);
353
+ });
354
+ it('should return empty map when no files match', () => {
355
+ const changedFiles = new Map([
356
+ ['unrelated/file.ts', 'edited'],
357
+ ]);
358
+ const result = classifyDirectChanges(changedFiles, [
359
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
360
+ ]);
361
+ expect(result.size).toBe(0);
362
+ });
363
+ it('should return empty map for empty inputs', () => {
364
+ expect(classifyDirectChanges(new Map(), [])).toEqual(new Map());
365
+ });
366
+ it('should match file paths exactly (no normalization)', () => {
367
+ const changedFiles = new Map([
368
+ ['components/DrinkCard.tsx', 'edited'],
369
+ ]);
370
+ // Slightly different path should NOT match
371
+ const result = classifyDirectChanges(changedFiles, [
372
+ entity('DrinkCard', './components/DrinkCard.tsx'),
373
+ ]);
374
+ expect(result.size).toBe(0);
375
+ });
376
+ });
377
+ // ── computeEntityChangeStatus ────────────────────────────────────────
378
+ describe('computeEntityChangeStatus', () => {
379
+ // ── Basic classification ───────────────────────────────────────────
380
+ it('should mark entity as "new" when its file is added', () => {
381
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'new']]), [entity('DrinkCard', 'components/DrinkCard.tsx')]);
382
+ expect(result['DrinkCard']).toEqual({ status: 'new' });
383
+ });
384
+ it('should mark entity as "edited" when its file is modified', () => {
385
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [entity('DrinkCard', 'components/DrinkCard.tsx')]);
386
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
387
+ });
388
+ it('should omit unchanged entities with no impacted deps', () => {
389
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
390
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
391
+ entity('Header', 'components/Header.tsx'),
392
+ ]);
393
+ expect(result['DrinkCard']).toBeDefined();
394
+ expect(result['Header']).toBeUndefined();
395
+ });
396
+ it('should return empty map for empty inputs', () => {
397
+ expect(computeEntityChangeStatus(new Map(), [])).toEqual({});
398
+ });
399
+ it('should return empty map when changedFiles has entries but no entity matches', () => {
400
+ const result = computeEntityChangeStatus(new Map([['unrelated/file.ts', 'edited']]), [entity('DrinkCard', 'components/DrinkCard.tsx')]);
401
+ expect(result).toEqual({});
402
+ });
403
+ // ── Single-level impact ────────────────────────────────────────────
404
+ it('should mark single-level impact: A imports B, B edited → A impacted by B', () => {
405
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
406
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
407
+ 'components/DrinkCard.tsx': ['DrinkList'],
408
+ }),
409
+ entity('DrinkList', 'components/DrinkList.tsx'),
410
+ ]);
411
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
412
+ expect(result['DrinkList']).toEqual({
413
+ status: 'impacted',
414
+ impactedBy: [
415
+ {
416
+ name: 'DrinkCard',
417
+ filePath: 'components/DrinkCard.tsx',
418
+ changeType: 'edited',
419
+ },
420
+ ],
421
+ });
422
+ });
423
+ it('should preserve changeType correctly: "new" root cause shows as "new" in impactedBy', () => {
424
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'new']]), [
425
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
426
+ 'components/DrinkCard.tsx': ['DrinkList'],
427
+ }),
428
+ entity('DrinkList', 'components/DrinkList.tsx'),
429
+ ]);
430
+ expect(result['DrinkList']?.impactedBy?.[0]?.changeType).toBe('new');
431
+ });
432
+ // ── Multi-level transitive ─────────────────────────────────────────
433
+ it('should trace multi-level transitive: Icon→DrinkCard→DrinkList, Icon edited → all impacted by Icon', () => {
434
+ const result = computeEntityChangeStatus(new Map([['components/Icon.tsx', 'edited']]), [
435
+ entity('Icon', 'components/Icon.tsx', {
436
+ 'components/Icon.tsx': ['DrinkCard'],
437
+ }),
438
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
439
+ 'components/DrinkCard.tsx': ['DrinkList'],
440
+ }),
441
+ entity('DrinkList', 'components/DrinkList.tsx'),
442
+ ]);
443
+ expect(result['Icon']).toEqual({ status: 'edited' });
444
+ // Both should trace back to Icon as root cause (not intermediate DrinkCard)
445
+ expect(result['DrinkCard']?.impactedBy).toEqual([
446
+ { name: 'Icon', filePath: 'components/Icon.tsx', changeType: 'edited' },
447
+ ]);
448
+ expect(result['DrinkList']?.impactedBy).toEqual([
449
+ { name: 'Icon', filePath: 'components/Icon.tsx', changeType: 'edited' },
450
+ ]);
451
+ });
452
+ // ── Multiple root causes ───────────────────────────────────────────
453
+ it('should track multiple root causes: DrinkCard imports Icon and utils (both changed)', () => {
454
+ const result = computeEntityChangeStatus(new Map([
455
+ ['components/Icon.tsx', 'edited'],
456
+ ['lib/utils.ts', 'new'],
457
+ ]), [
458
+ entity('Icon', 'components/Icon.tsx', {
459
+ 'components/Icon.tsx': ['DrinkCard'],
460
+ }),
461
+ entity('utils', 'lib/utils.ts', {
462
+ 'lib/utils.ts': ['DrinkCard'],
463
+ }),
464
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
465
+ ]);
466
+ expect(result['DrinkCard']?.status).toBe('impacted');
467
+ expect(impactNames(result, 'DrinkCard')).toEqual(['Icon', 'utils']);
468
+ // Verify changeTypes are preserved
469
+ const iconDep = result['DrinkCard']?.impactedBy?.find((d) => d.name === 'Icon');
470
+ const utilsDep = result['DrinkCard']?.impactedBy?.find((d) => d.name === 'utils');
471
+ expect(iconDep?.changeType).toBe('edited');
472
+ expect(utilsDep?.changeType).toBe('new');
473
+ });
474
+ // ── Diamond dependency (BFS root cause propagation) ────────────────
475
+ it('should propagate ALL root causes through diamond: D1→A→C→E, D2→B→C→E', () => {
476
+ // This tests that when two paths converge at C and C has downstream
477
+ // importers (E), ALL root causes propagate through — not just the
478
+ // ones from whichever path was processed first.
479
+ //
480
+ // D1(edited) ──importedBy──→ A ──importedBy──→ C ──importedBy──→ E
481
+ // D2(new) ──importedBy──→ B ──importedBy──→ C
482
+ //
483
+ const result = computeEntityChangeStatus(new Map([
484
+ ['components/D1.tsx', 'edited'],
485
+ ['components/D2.tsx', 'new'],
486
+ ]), [
487
+ entity('D1', 'components/D1.tsx', {
488
+ 'components/D1.tsx': ['A'],
489
+ }),
490
+ entity('D2', 'components/D2.tsx', {
491
+ 'components/D2.tsx': ['B'],
492
+ }),
493
+ entity('A', 'components/A.tsx', {
494
+ 'components/A.tsx': ['C'],
495
+ }),
496
+ entity('B', 'components/B.tsx', {
497
+ 'components/B.tsx': ['C'],
498
+ }),
499
+ entity('C', 'components/C.tsx', {
500
+ 'components/C.tsx': ['E'],
501
+ }),
502
+ entity('E', 'components/E.tsx'),
503
+ ]);
504
+ // C should have both root causes
505
+ expect(result['C']?.status).toBe('impacted');
506
+ expect(impactNames(result, 'C')).toEqual(['D1', 'D2']);
507
+ // E (downstream of C) must also have both root causes
508
+ expect(result['E']?.status).toBe('impacted');
509
+ expect(impactNames(result, 'E')).toEqual(['D1', 'D2']);
510
+ });
511
+ it('should propagate root causes through diamond with unequal path lengths', () => {
512
+ // D1 reaches C directly (depth 1), D2 reaches C via B (depth 2).
513
+ // C has downstream E. E must get both root causes.
514
+ //
515
+ // D1(edited) ──importedBy──→ C ──importedBy──→ E
516
+ // D2(new) ──importedBy──→ B ──importedBy──→ C
517
+ //
518
+ const result = computeEntityChangeStatus(new Map([
519
+ ['components/D1.tsx', 'edited'],
520
+ ['components/D2.tsx', 'new'],
521
+ ]), [
522
+ entity('D1', 'components/D1.tsx', {
523
+ 'components/D1.tsx': ['C'],
524
+ }),
525
+ entity('D2', 'components/D2.tsx', {
526
+ 'components/D2.tsx': ['B'],
527
+ }),
528
+ entity('B', 'components/B.tsx', {
529
+ 'components/B.tsx': ['C'],
530
+ }),
531
+ entity('C', 'components/C.tsx', {
532
+ 'components/C.tsx': ['E'],
533
+ }),
534
+ entity('E', 'components/E.tsx'),
535
+ ]);
536
+ expect(impactNames(result, 'C')).toEqual(['D1', 'D2']);
537
+ expect(impactNames(result, 'E')).toEqual(['D1', 'D2']);
538
+ });
539
+ // ── Cycles ─────────────────────────────────────────────────────────
540
+ it('should handle simple cycle without infinite loop: A↔B, A edited', () => {
541
+ const result = computeEntityChangeStatus(new Map([['components/A.tsx', 'edited']]), [
542
+ entity('A', 'components/A.tsx', {
543
+ 'components/A.tsx': ['B'],
544
+ }),
545
+ entity('B', 'components/B.tsx', {
546
+ 'components/B.tsx': ['A'],
547
+ }),
548
+ ]);
549
+ expect(result['A']).toEqual({ status: 'edited' });
550
+ expect(result['B']).toEqual({
551
+ status: 'impacted',
552
+ impactedBy: [
553
+ { name: 'A', filePath: 'components/A.tsx', changeType: 'edited' },
554
+ ],
555
+ });
556
+ });
557
+ it('should handle three-node cycle: A→B→C→A, A edited', () => {
558
+ const result = computeEntityChangeStatus(new Map([['components/A.tsx', 'edited']]), [
559
+ entity('A', 'components/A.tsx', {
560
+ 'components/A.tsx': ['B'],
561
+ }),
562
+ entity('B', 'components/B.tsx', {
563
+ 'components/B.tsx': ['C'],
564
+ }),
565
+ entity('C', 'components/C.tsx', {
566
+ 'components/C.tsx': ['A'],
567
+ }),
568
+ ]);
569
+ expect(result['A']).toEqual({ status: 'edited' });
570
+ expect(result['B']?.status).toBe('impacted');
571
+ expect(result['C']?.status).toBe('impacted');
572
+ expect(impactNames(result, 'B')).toEqual(['A']);
573
+ expect(impactNames(result, 'C')).toEqual(['A']);
574
+ });
575
+ it('should handle cycle with multiple seeds: A→B→C→A, A and C both edited', () => {
576
+ const result = computeEntityChangeStatus(new Map([
577
+ ['components/A.tsx', 'edited'],
578
+ ['components/C.tsx', 'new'],
579
+ ]), [
580
+ entity('A', 'components/A.tsx', {
581
+ 'components/A.tsx': ['B'],
582
+ }),
583
+ entity('B', 'components/B.tsx', {
584
+ 'components/B.tsx': ['C'],
585
+ }),
586
+ entity('C', 'components/C.tsx', {
587
+ 'components/C.tsx': ['A'],
588
+ }),
589
+ ]);
590
+ // A and C directly changed
591
+ expect(result['A']).toEqual({ status: 'edited' });
592
+ expect(result['C']).toEqual({ status: 'new' });
593
+ // B is impacted by both (A importedBy B via A→B, C importedBy B via C→...→B)
594
+ expect(result['B']?.status).toBe('impacted');
595
+ expect(impactNames(result, 'B')).toEqual(['A', 'C']);
596
+ });
597
+ it('should handle self-import cycle gracefully', () => {
598
+ // Entity lists itself as its own importer
599
+ const result = computeEntityChangeStatus(new Map([['components/A.tsx', 'edited']]), [
600
+ entity('A', 'components/A.tsx', {
601
+ 'components/A.tsx': ['A'],
602
+ }),
603
+ ]);
604
+ // A is directly changed — self-loop should not create an impacted entry
605
+ expect(result['A']).toEqual({ status: 'edited' });
606
+ });
607
+ // ── Direct change priority ─────────────────────────────────────────
608
+ it('should NOT mark directly-changed entities as impacted even if they import other changed entities', () => {
609
+ const result = computeEntityChangeStatus(new Map([
610
+ ['components/A.tsx', 'edited'],
611
+ ['components/B.tsx', 'new'],
612
+ ]), [
613
+ entity('A', 'components/A.tsx', {
614
+ 'components/A.tsx': ['B'],
615
+ }),
616
+ entity('B', 'components/B.tsx'),
617
+ ]);
618
+ expect(result['A']).toEqual({ status: 'edited' });
619
+ expect(result['B']).toEqual({ status: 'new' });
620
+ });
621
+ it('should not produce impacted entries when ALL entities are directly changed', () => {
622
+ const result = computeEntityChangeStatus(new Map([
623
+ ['components/A.tsx', 'edited'],
624
+ ['components/B.tsx', 'new'],
625
+ ['components/C.tsx', 'edited'],
626
+ ]), [
627
+ entity('A', 'components/A.tsx', {
628
+ 'components/A.tsx': ['B'],
629
+ }),
630
+ entity('B', 'components/B.tsx', {
631
+ 'components/B.tsx': ['C'],
632
+ }),
633
+ entity('C', 'components/C.tsx'),
634
+ ]);
635
+ expect(Object.values(result).every((s) => s.status !== 'impacted')).toBe(true);
636
+ expect(result['A']?.status).toBe('edited');
637
+ expect(result['B']?.status).toBe('new');
638
+ expect(result['C']?.status).toBe('edited');
639
+ });
640
+ // ── Missing metadata / graceful degradation ────────────────────────
641
+ it('should handle missing importedBy metadata gracefully (no impact propagation)', () => {
642
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
643
+ entity('DrinkCard', 'components/DrinkCard.tsx'),
644
+ entity('DrinkList', 'components/DrinkList.tsx'),
645
+ ]);
646
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
647
+ expect(result['DrinkList']).toBeUndefined();
648
+ });
649
+ it('should skip phantom importers (importedBy references entity not in entities list)', () => {
650
+ // DrinkCard's importedBy references "PhantomPage" which is not in entities
651
+ const result = computeEntityChangeStatus(new Map([['components/DrinkCard.tsx', 'edited']]), [
652
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
653
+ 'components/DrinkCard.tsx': ['PhantomPage', 'DrinkList'],
654
+ }),
655
+ entity('DrinkList', 'components/DrinkList.tsx'),
656
+ ]);
657
+ // PhantomPage should not appear in results (not in entities list)
658
+ expect(result['PhantomPage']).toBeUndefined();
659
+ // DrinkList should still be impacted
660
+ expect(result['DrinkList']?.status).toBe('impacted');
661
+ });
662
+ // ── maxDepth ───────────────────────────────────────────────────────
663
+ it('should enforce maxDepth and stop propagation beyond limit', () => {
664
+ // Chain: D → C → B → A
665
+ const result = computeEntityChangeStatus(new Map([['components/D.tsx', 'edited']]), [
666
+ entity('D', 'components/D.tsx', { 'components/D.tsx': ['C'] }),
667
+ entity('C', 'components/C.tsx', { 'components/C.tsx': ['B'] }),
668
+ entity('B', 'components/B.tsx', { 'components/B.tsx': ['A'] }),
669
+ entity('A', 'components/A.tsx'),
670
+ ], 2);
671
+ expect(result['D']).toEqual({ status: 'edited' });
672
+ expect(result['C']?.status).toBe('impacted');
673
+ expect(result['B']?.status).toBe('impacted');
674
+ // A at depth 3 is cut off
675
+ expect(result['A']).toBeUndefined();
676
+ });
677
+ it('should use default maxDepth of 20 when not specified', () => {
678
+ // Build a chain of depth 15 — should all be reached with default maxDepth
679
+ const entities = [];
680
+ for (let i = 0; i < 16; i++) {
681
+ const name = `E${i}`;
682
+ const filePath = `components/${name}.tsx`;
683
+ if (i < 15) {
684
+ entities.push(entity(name, filePath, { [filePath]: [`E${i + 1}`] }));
685
+ }
686
+ else {
687
+ entities.push(entity(name, filePath));
688
+ }
689
+ }
690
+ const result = computeEntityChangeStatus(new Map([['components/E0.tsx', 'edited']]), entities);
691
+ // E15 at depth 15 should still be reached (within default maxDepth of 20)
692
+ expect(result['E15']?.status).toBe('impacted');
693
+ });
694
+ // ── Fan-out ────────────────────────────────────────────────────────
695
+ it('should handle large fan-out: one changed entity imported by many', () => {
696
+ const importers = Array.from({ length: 10 }, (_, i) => `Comp${i}`);
697
+ const entities = [
698
+ entity('SharedUtil', 'lib/shared.ts', {
699
+ 'lib/shared.ts': importers,
700
+ }),
701
+ ...importers.map((name) => entity(name, `components/${name}.tsx`)),
702
+ ];
703
+ const result = computeEntityChangeStatus(new Map([['lib/shared.ts', 'edited']]), entities);
704
+ expect(result['SharedUtil']).toEqual({ status: 'edited' });
705
+ for (const name of importers) {
706
+ expect(result[name]?.status).toBe('impacted');
707
+ expect(result[name]?.impactedBy).toEqual([
708
+ {
709
+ name: 'SharedUtil',
710
+ filePath: 'lib/shared.ts',
711
+ changeType: 'edited',
712
+ },
713
+ ]);
714
+ }
715
+ });
716
+ // ── Deterministic output ───────────────────────────────────────────
717
+ it('should sort impactedBy array by name for deterministic output', () => {
718
+ const result = computeEntityChangeStatus(new Map([
719
+ ['components/Zebra.tsx', 'edited'],
720
+ ['components/Apple.tsx', 'new'],
721
+ ['components/Mango.tsx', 'edited'],
722
+ ]), [
723
+ entity('Zebra', 'components/Zebra.tsx', {
724
+ 'components/Zebra.tsx': ['Target'],
725
+ }),
726
+ entity('Apple', 'components/Apple.tsx', {
727
+ 'components/Apple.tsx': ['Target'],
728
+ }),
729
+ entity('Mango', 'components/Mango.tsx', {
730
+ 'components/Mango.tsx': ['Target'],
731
+ }),
732
+ entity('Target', 'components/Target.tsx'),
733
+ ]);
734
+ expect(impactNames(result, 'Target')).toEqual([
735
+ 'Apple',
736
+ 'Mango',
737
+ 'Zebra',
738
+ ]);
739
+ });
740
+ // ── Realistic integration-style scenarios ──────────────────────────
741
+ it('should compute correct status for a realistic Next.js app scenario', () => {
742
+ // Simulates a real editor session:
743
+ // - User edited DrinkCard component and added a new formatPrice utility
744
+ // - DrinkCard is imported by DrinkList component and Home page
745
+ // - formatPrice is imported by DrinkCard
746
+ // - Header is unchanged and imports nothing changed
747
+ //
748
+ // Expected:
749
+ // - DrinkCard: edited (directly changed)
750
+ // - formatPrice: new (directly changed)
751
+ // - DrinkList: impacted by [DrinkCard, formatPrice] (DrinkCard is edited,
752
+ // and formatPrice flows through DrinkCard)
753
+ // - Home: impacted by [DrinkCard, formatPrice] (same reasoning)
754
+ // - Header: not in results (unchanged, no changed deps)
755
+ const result = computeEntityChangeStatus(new Map([
756
+ ['components/DrinkCard.tsx', 'edited'],
757
+ ['lib/formatPrice.ts', 'new'],
758
+ ]), [
759
+ entity('formatPrice', 'lib/formatPrice.ts', {
760
+ 'lib/formatPrice.ts': ['DrinkCard'],
761
+ }),
762
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
763
+ 'components/DrinkCard.tsx': ['DrinkList', 'Home'],
764
+ }),
765
+ entity('DrinkList', 'components/DrinkList.tsx'),
766
+ entity('Home', 'app/page.tsx'),
767
+ entity('Header', 'components/Header.tsx'),
768
+ ]);
769
+ expect(result['formatPrice']).toEqual({ status: 'new' });
770
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
771
+ expect(result['Header']).toBeUndefined();
772
+ // DrinkList and Home are impacted by DrinkCard (directly, its an importer)
773
+ // AND by formatPrice (transitively via DrinkCard)
774
+ expect(result['DrinkList']?.status).toBe('impacted');
775
+ expect(impactNames(result, 'DrinkList')).toEqual([
776
+ 'DrinkCard',
777
+ 'formatPrice',
778
+ ]);
779
+ expect(result['Home']?.status).toBe('impacted');
780
+ expect(impactNames(result, 'Home')).toEqual(['DrinkCard', 'formatPrice']);
781
+ });
782
+ it('should handle a realistic scenario where component is both directly changed AND an intermediate', () => {
783
+ // DrinkCard is edited AND it imports formatPrice (also changed).
784
+ // DrinkCard should be marked 'edited' (direct), not 'impacted'.
785
+ // DrinkList imports DrinkCard → impacted by both DrinkCard and formatPrice.
786
+ const result = computeEntityChangeStatus(new Map([
787
+ ['components/DrinkCard.tsx', 'edited'],
788
+ ['lib/formatPrice.ts', 'new'],
789
+ ]), [
790
+ entity('formatPrice', 'lib/formatPrice.ts', {
791
+ 'lib/formatPrice.ts': ['DrinkCard'],
792
+ }),
793
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
794
+ 'components/DrinkCard.tsx': ['DrinkList'],
795
+ }),
796
+ entity('DrinkList', 'components/DrinkList.tsx'),
797
+ ]);
798
+ // DrinkCard is directly changed — status should be 'edited', no impactedBy
799
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
800
+ // DrinkList is impacted by BOTH root causes
801
+ expect(result['DrinkList']?.status).toBe('impacted');
802
+ expect(impactNames(result, 'DrinkList')).toEqual([
803
+ 'DrinkCard',
804
+ 'formatPrice',
805
+ ]);
806
+ });
807
+ // ── End-to-end pipeline test ───────────────────────────────────────
808
+ it('should produce correct results through the full pipeline: git files → scenarios → entity infos → status', () => {
809
+ // This tests the complete data flow that both route handlers execute:
810
+ // 1. buildChangedFilesMap from git status
811
+ // 2. buildEntityInfosFromScenarios from scenario data + metadata
812
+ // 3. computeEntityChangeStatus from the above
813
+ // Step 1: Git status
814
+ const gitFiles = [
815
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
816
+ { path: 'lib/formatPrice.ts', status: 'added' },
817
+ { path: 'components/Header.tsx', status: 'deleted' },
818
+ ];
819
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
820
+ expect(changedFiles.get('components/DrinkCard.tsx')).toBe('edited');
821
+ expect(changedFiles.get('lib/formatPrice.ts')).toBe('new');
822
+ expect(changedFiles.has('components/Header.tsx')).toBe(false);
823
+ // Step 2: Build entity infos from scenarios
824
+ const scenarios = [
825
+ {
826
+ componentName: 'DrinkCard',
827
+ componentPath: 'components/DrinkCard.tsx',
828
+ },
829
+ { componentName: null, componentPath: null, url: '/' },
830
+ {
831
+ componentName: 'DrinkList',
832
+ componentPath: 'components/DrinkList.tsx',
833
+ },
834
+ ];
835
+ const pageFilePaths = { Home: 'app/page.tsx' };
836
+ const metadata = [
837
+ {
838
+ name: 'DrinkCard',
839
+ metadata: {
840
+ importedBy: {
841
+ 'components/DrinkCard.tsx': {
842
+ DrinkList: { shas: ['sha1'] },
843
+ Home: { shas: ['sha2'] },
844
+ },
845
+ },
846
+ },
847
+ },
848
+ { name: 'DrinkList', metadata: null },
849
+ ];
850
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, pageFilePaths, metadata);
851
+ expect(entityInfos).toHaveLength(3);
852
+ expect(entityInfos.find((e) => e.name === 'DrinkCard')?.importedBy).toBeDefined();
853
+ // Step 3: Compute status
854
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
855
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
856
+ expect(result['DrinkList']?.status).toBe('impacted');
857
+ expect(result['Home']?.status).toBe('impacted');
858
+ expect(impactNames(result, 'DrinkList')).toEqual(['DrinkCard']);
859
+ expect(impactNames(result, 'Home')).toEqual(['DrinkCard']);
860
+ });
861
+ it('should produce correct results for first-feature pipeline (all files new)', () => {
862
+ const gitFiles = [
863
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
864
+ { path: 'app/page.tsx', status: 'modified' },
865
+ { path: 'components/Header.tsx', status: 'untracked' },
866
+ ];
867
+ // isFirstFeature = true → all become 'new'
868
+ const changedFiles = buildChangedFilesMap(gitFiles, true);
869
+ expect(changedFiles.get('components/DrinkCard.tsx')).toBe('new');
870
+ expect(changedFiles.get('app/page.tsx')).toBe('new');
871
+ expect(changedFiles.get('components/Header.tsx')).toBe('new');
872
+ const scenarios = [
873
+ {
874
+ componentName: 'DrinkCard',
875
+ componentPath: 'components/DrinkCard.tsx',
876
+ },
877
+ { componentName: null, componentPath: null, url: '/' },
878
+ { componentName: 'Header', componentPath: 'components/Header.tsx' },
879
+ ];
880
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, { Home: 'app/page.tsx' }, []);
881
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
882
+ // All should be 'new' — none impacted
883
+ expect(result['DrinkCard']).toEqual({ status: 'new' });
884
+ expect(result['Home']).toEqual({ status: 'new' });
885
+ expect(result['Header']).toEqual({ status: 'new' });
886
+ expect(Object.values(result).some((s) => s.status === 'impacted')).toBe(false);
887
+ });
888
+ // ── Pass-through propagation ─────────────────────────────────────
889
+ it('should propagate root causes THROUGH a directly-changed intermediate entity', () => {
890
+ // D is edited, A is also edited, A importedBy B.
891
+ // D importedBy A (so D flows through A).
892
+ // B should be impacted by BOTH D (transitive through A) and A (direct importer).
893
+ //
894
+ // D(edited) ──importedBy──→ A(edited) ──importedBy──→ B
895
+ //
896
+ const result = computeEntityChangeStatus(new Map([
897
+ ['components/D.tsx', 'edited'],
898
+ ['components/A.tsx', 'edited'],
899
+ ]), [
900
+ entity('D', 'components/D.tsx', {
901
+ 'components/D.tsx': ['A'],
902
+ }),
903
+ entity('A', 'components/A.tsx', {
904
+ 'components/A.tsx': ['B'],
905
+ }),
906
+ entity('B', 'components/B.tsx'),
907
+ ]);
908
+ expect(result['D']).toEqual({ status: 'edited' });
909
+ expect(result['A']).toEqual({ status: 'edited' });
910
+ // B must see BOTH root causes — D propagated through A
911
+ expect(result['B']?.status).toBe('impacted');
912
+ expect(impactNames(result, 'B')).toEqual(['A', 'D']);
913
+ });
914
+ it('should propagate through a chain of directly-changed entities: D→C→B→A all changed except A', () => {
915
+ // D, C, B all directly changed. A imports B.
916
+ // A should see D, C, B as root causes.
917
+ const result = computeEntityChangeStatus(new Map([
918
+ ['components/D.tsx', 'edited'],
919
+ ['components/C.tsx', 'new'],
920
+ ['components/B.tsx', 'edited'],
921
+ ]), [
922
+ entity('D', 'components/D.tsx', { 'components/D.tsx': ['C'] }),
923
+ entity('C', 'components/C.tsx', { 'components/C.tsx': ['B'] }),
924
+ entity('B', 'components/B.tsx', { 'components/B.tsx': ['A'] }),
925
+ entity('A', 'components/A.tsx'),
926
+ ]);
927
+ expect(result['A']?.status).toBe('impacted');
928
+ expect(impactNames(result, 'A')).toEqual(['B', 'C', 'D']);
929
+ });
930
+ // ── Wide diamond ─────────────────────────────────────────────────
931
+ it('should handle wide diamond: 3+ paths converging then fanning out', () => {
932
+ // D1, D2, D3 all edited, all importedBy Merge, Merge importedBy [Out1, Out2]
933
+ //
934
+ // D1 ─┐
935
+ // D2 ─┼──importedBy──→ Merge ──importedBy──→ Out1
936
+ // D3 ─┘ └──importedBy──→ Out2
937
+ //
938
+ const result = computeEntityChangeStatus(new Map([
939
+ ['components/D1.tsx', 'edited'],
940
+ ['components/D2.tsx', 'new'],
941
+ ['components/D3.tsx', 'edited'],
942
+ ]), [
943
+ entity('D1', 'components/D1.tsx', { 'components/D1.tsx': ['Merge'] }),
944
+ entity('D2', 'components/D2.tsx', { 'components/D2.tsx': ['Merge'] }),
945
+ entity('D3', 'components/D3.tsx', { 'components/D3.tsx': ['Merge'] }),
946
+ entity('Merge', 'components/Merge.tsx', {
947
+ 'components/Merge.tsx': ['Out1', 'Out2'],
948
+ }),
949
+ entity('Out1', 'components/Out1.tsx'),
950
+ entity('Out2', 'components/Out2.tsx'),
951
+ ]);
952
+ // Merge gets all 3 root causes
953
+ expect(impactNames(result, 'Merge')).toEqual(['D1', 'D2', 'D3']);
954
+ // Out1 and Out2 also get all 3 root causes (propagated through Merge)
955
+ expect(impactNames(result, 'Out1')).toEqual(['D1', 'D2', 'D3']);
956
+ expect(impactNames(result, 'Out2')).toEqual(['D1', 'D2', 'D3']);
957
+ });
958
+ // ── changedFiles with non-entity entries ─────────────────────────
959
+ it('should ignore changedFiles entries that do not match any entity file path', () => {
960
+ // Git shows many changed files, but only some are entities
961
+ const result = computeEntityChangeStatus(new Map([
962
+ ['components/DrinkCard.tsx', 'edited'],
963
+ ['package.json', 'edited'],
964
+ ['README.md', 'new'],
965
+ ['.env', 'edited'],
966
+ ['tsconfig.json', 'edited'],
967
+ ]), [
968
+ entity('DrinkCard', 'components/DrinkCard.tsx', {
969
+ 'components/DrinkCard.tsx': ['DrinkList'],
970
+ }),
971
+ entity('DrinkList', 'components/DrinkList.tsx'),
972
+ ]);
973
+ expect(result['DrinkCard']).toEqual({ status: 'edited' });
974
+ expect(result['DrinkList']?.status).toBe('impacted');
975
+ // Only entities should appear in results
976
+ expect(Object.keys(result)).toEqual(['DrinkCard', 'DrinkList']);
977
+ });
978
+ });
979
+ // ── buildEntityInfosFromScenarios — additional edge cases ────────
980
+ describe('buildEntityInfosFromScenarios (edge cases)', () => {
981
+ it('should deduplicate page scenarios with different URLs resolving to same name', () => {
982
+ // /drinks and /drinks/123 both resolve to "Drinks"
983
+ const scenarios = [
984
+ { componentName: null, componentPath: null, url: '/drinks' },
985
+ { componentName: null, componentPath: null, url: '/drinks/123' },
986
+ {
987
+ componentName: null,
988
+ componentPath: null,
989
+ url: '/drinks/456?tab=reviews',
990
+ },
991
+ ];
992
+ const pageFilePaths = { Drinks: 'app/drinks/page.tsx' };
993
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
994
+ // Should deduplicate to a single "Drinks" entity
995
+ expect(result).toHaveLength(1);
996
+ expect(result[0]).toEqual({
997
+ name: 'Drinks',
998
+ filePath: 'app/drinks/page.tsx',
999
+ });
1000
+ });
1001
+ it('should handle component and page scenario with same conceptual name (component wins)', () => {
1002
+ // A component named "Home" AND a page URL "/" both produce name "Home"
1003
+ // Component scenario comes first — it should win
1004
+ const scenarios = [
1005
+ { componentName: 'Home', componentPath: 'components/Home.tsx' },
1006
+ { componentName: null, componentPath: null, url: '/' },
1007
+ ];
1008
+ const pageFilePaths = { Home: 'app/page.tsx' };
1009
+ const result = buildEntityInfosFromScenarios(scenarios, pageFilePaths, []);
1010
+ expect(result).toHaveLength(1);
1011
+ // Component path should win (first occurrence)
1012
+ expect(result[0].filePath).toBe('components/Home.tsx');
1013
+ });
1014
+ it('should handle multiple scenarios for the same component (only first is included)', () => {
1015
+ const scenarios = [
1016
+ {
1017
+ componentName: 'DrinkCard',
1018
+ componentPath: 'components/DrinkCard.tsx',
1019
+ },
1020
+ {
1021
+ componentName: 'DrinkCard',
1022
+ componentPath: 'components/DrinkCard.tsx',
1023
+ },
1024
+ {
1025
+ componentName: 'DrinkCard',
1026
+ componentPath: 'components/DrinkCard.tsx',
1027
+ },
1028
+ ];
1029
+ const metadata = [
1030
+ {
1031
+ name: 'DrinkCard',
1032
+ metadata: {
1033
+ importedBy: {
1034
+ 'components/DrinkCard.tsx': { DrinkList: { shas: ['abc'] } },
1035
+ },
1036
+ },
1037
+ },
1038
+ ];
1039
+ const result = buildEntityInfosFromScenarios(scenarios, {}, metadata);
1040
+ expect(result).toHaveLength(1);
1041
+ expect(result[0].importedBy).toBeDefined();
1042
+ });
1043
+ });
1044
+ // ── Full pipeline regression tests ───────────────────────────────
1045
+ describe('full pipeline regression tests', () => {
1046
+ it('regression: entity with changed file AND imported by other unchanged entities should propagate', () => {
1047
+ // This catches the old regex-based bug: previously, "impacted" was marked
1048
+ // for every entity not directly changed, even if no dependency was actually changed.
1049
+ // With the new system, only entities that transitively import a changed entity get impacted.
1050
+ const gitFiles = [
1051
+ { path: 'lib/formatPrice.ts', status: 'added' },
1052
+ ];
1053
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
1054
+ const scenarios = [
1055
+ {
1056
+ componentName: 'DrinkCard',
1057
+ componentPath: 'components/DrinkCard.tsx',
1058
+ },
1059
+ { componentName: 'Header', componentPath: 'components/Header.tsx' },
1060
+ { componentName: null, componentPath: null, url: '/' },
1061
+ ];
1062
+ const pageFilePaths = { Home: 'app/page.tsx' };
1063
+ const metadata = [
1064
+ {
1065
+ name: 'formatPrice',
1066
+ metadata: {
1067
+ importedBy: {
1068
+ 'lib/formatPrice.ts': { DrinkCard: { shas: ['sha1'] } },
1069
+ },
1070
+ },
1071
+ },
1072
+ {
1073
+ name: 'DrinkCard',
1074
+ metadata: {
1075
+ importedBy: {
1076
+ 'components/DrinkCard.tsx': { Home: { shas: ['sha2'] } },
1077
+ },
1078
+ },
1079
+ },
1080
+ ];
1081
+ // formatPrice is not a scenario entity — only DrinkCard, Header, Home are.
1082
+ // But formatPrice IS in the metadata as importedBy DrinkCard.
1083
+ // We need to include formatPrice in entityInfos for the graph to work.
1084
+ // However, buildEntityInfosFromScenarios only builds from scenarios,
1085
+ // so formatPrice won't be in the entities list → DrinkCard won't be impacted.
1086
+ //
1087
+ // This test documents the current behavior: only entities from scenarios
1088
+ // participate in the change status graph. Changed files that aren't entities
1089
+ // don't propagate impact (they'd need to be in the entities list).
1090
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, pageFilePaths, metadata);
1091
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
1092
+ // formatPrice is not in scenarios, so not in entityInfos
1093
+ expect(entityInfos.find((e) => e.name === 'formatPrice')).toBeUndefined();
1094
+ // No entity files match changed files → empty result
1095
+ // DrinkCard and Home are NOT impacted because formatPrice is not in entities
1096
+ expect(result['DrinkCard']).toBeUndefined();
1097
+ expect(result['Header']).toBeUndefined();
1098
+ expect(result['Home']).toBeUndefined();
1099
+ });
1100
+ it('pipeline: changed utility file that IS an entity propagates impact correctly', () => {
1101
+ // When the changed utility IS registered as a scenario entity,
1102
+ // the full chain works end-to-end.
1103
+ const gitFiles = [
1104
+ { path: 'lib/formatPrice.ts', status: 'added' },
1105
+ ];
1106
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
1107
+ const scenarios = [
1108
+ { componentName: 'formatPrice', componentPath: 'lib/formatPrice.ts' },
1109
+ {
1110
+ componentName: 'DrinkCard',
1111
+ componentPath: 'components/DrinkCard.tsx',
1112
+ },
1113
+ { componentName: null, componentPath: null, url: '/' },
1114
+ ];
1115
+ const pageFilePaths = { Home: 'app/page.tsx' };
1116
+ const metadata = [
1117
+ {
1118
+ name: 'formatPrice',
1119
+ metadata: {
1120
+ importedBy: {
1121
+ 'lib/formatPrice.ts': { DrinkCard: { shas: ['sha1'] } },
1122
+ },
1123
+ },
1124
+ },
1125
+ {
1126
+ name: 'DrinkCard',
1127
+ metadata: {
1128
+ importedBy: {
1129
+ 'components/DrinkCard.tsx': { Home: { shas: ['sha2'] } },
1130
+ },
1131
+ },
1132
+ },
1133
+ ];
1134
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, pageFilePaths, metadata);
1135
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
1136
+ expect(result['formatPrice']).toEqual({ status: 'new' });
1137
+ expect(result['DrinkCard']?.status).toBe('impacted');
1138
+ expect(impactNames(result, 'DrinkCard')).toEqual(['formatPrice']);
1139
+ expect(result['Home']?.status).toBe('impacted');
1140
+ expect(impactNames(result, 'Home')).toEqual(['formatPrice']);
1141
+ });
1142
+ it('pipeline: renamed file status is correctly ignored', () => {
1143
+ const gitFiles = [
1144
+ { path: 'components/OldName.tsx', status: 'renamed' },
1145
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
1146
+ ];
1147
+ const changedFiles = buildChangedFilesMap(gitFiles, false);
1148
+ // Renamed file should not appear
1149
+ expect(changedFiles.has('components/OldName.tsx')).toBe(false);
1150
+ expect(changedFiles.get('components/DrinkCard.tsx')).toBe('edited');
1151
+ });
1152
+ it('pipeline: first feature mode with import graph still marks everything new', () => {
1153
+ // Even with importedBy metadata, first-feature mode should mark
1154
+ // all entities as 'new' with no 'impacted' entries.
1155
+ const gitFiles = [
1156
+ { path: 'components/DrinkCard.tsx', status: 'modified' },
1157
+ { path: 'app/page.tsx', status: 'modified' },
1158
+ ];
1159
+ const changedFiles = buildChangedFilesMap(gitFiles, true); // first feature!
1160
+ const scenarios = [
1161
+ {
1162
+ componentName: 'DrinkCard',
1163
+ componentPath: 'components/DrinkCard.tsx',
1164
+ },
1165
+ { componentName: null, componentPath: null, url: '/' },
1166
+ ];
1167
+ const metadata = [
1168
+ {
1169
+ name: 'DrinkCard',
1170
+ metadata: {
1171
+ importedBy: {
1172
+ 'components/DrinkCard.tsx': { Home: { shas: ['sha1'] } },
1173
+ },
1174
+ },
1175
+ },
1176
+ ];
1177
+ const entityInfos = buildEntityInfosFromScenarios(scenarios, { Home: 'app/page.tsx' }, metadata);
1178
+ const result = computeEntityChangeStatus(changedFiles, entityInfos);
1179
+ // Both are directly changed (new) → no impact propagation
1180
+ expect(result['DrinkCard']).toEqual({ status: 'new' });
1181
+ expect(result['Home']).toEqual({ status: 'new' });
1182
+ });
1183
+ });
1184
+ // ── filterGroupsByChangeStatus ──────────────────────────────────────────
1185
+ describe('filterGroupsByChangeStatus', () => {
1186
+ const groups = [
1187
+ ['Home', ['s1', 's2']],
1188
+ ['About', ['s3']],
1189
+ ['Header', ['s4']],
1190
+ ];
1191
+ it('should return all groups when entityChangeStatus is undefined', () => {
1192
+ expect(filterGroupsByChangeStatus(groups, undefined)).toEqual(groups);
1193
+ });
1194
+ it('should return all groups when entityChangeStatus is empty', () => {
1195
+ expect(filterGroupsByChangeStatus(groups, {})).toEqual(groups);
1196
+ });
1197
+ it('should filter to only groups with a change status', () => {
1198
+ const status = {
1199
+ Home: { status: 'new' },
1200
+ Header: { status: 'edited' },
1201
+ };
1202
+ const result = filterGroupsByChangeStatus(groups, status);
1203
+ expect(result).toEqual([
1204
+ ['Home', ['s1', 's2']],
1205
+ ['Header', ['s4']],
1206
+ ]);
1207
+ });
1208
+ it('should return empty array when no groups match', () => {
1209
+ const status = {
1210
+ Footer: { status: 'new' },
1211
+ };
1212
+ const result = filterGroupsByChangeStatus(groups, status);
1213
+ expect(result).toEqual([]);
1214
+ });
1215
+ });
1216
+ });
1217
+ //# sourceMappingURL=entityChangeStatus.test.js.map