edsger 0.51.0 → 0.52.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (184) hide show
  1. package/.claude/settings.local.json +23 -3
  2. package/.env.local +12 -0
  3. package/dist/commands/find-smells/index.d.ts +21 -0
  4. package/dist/commands/find-smells/index.js +65 -0
  5. package/dist/index.js +29 -0
  6. package/dist/phases/find-bugs/index.js +7 -92
  7. package/dist/phases/find-bugs/state.d.ts +10 -35
  8. package/dist/phases/find-bugs/state.js +12 -120
  9. package/dist/phases/find-features/index.js +16 -83
  10. package/dist/phases/find-features/prompts.d.ts +7 -1
  11. package/dist/phases/find-features/prompts.js +31 -11
  12. package/dist/phases/find-features/state.d.ts +15 -19
  13. package/dist/phases/find-features/state.js +17 -89
  14. package/dist/phases/find-features/types.d.ts +1 -1
  15. package/dist/phases/find-shared/git.d.ts +24 -0
  16. package/dist/phases/find-shared/git.js +60 -0
  17. package/dist/phases/find-shared/mcp.d.ts +33 -0
  18. package/dist/phases/find-shared/mcp.js +69 -0
  19. package/dist/phases/find-shared/scan-state.d.ts +33 -0
  20. package/dist/phases/find-shared/scan-state.js +112 -0
  21. package/dist/phases/find-smells/index.d.ts +47 -0
  22. package/dist/phases/find-smells/index.js +278 -0
  23. package/dist/phases/find-smells/prompts.d.ts +30 -0
  24. package/dist/phases/find-smells/prompts.js +129 -0
  25. package/dist/phases/find-smells/state.d.ts +21 -0
  26. package/dist/phases/find-smells/state.js +17 -0
  27. package/dist/phases/find-smells/types.d.ts +51 -0
  28. package/dist/phases/find-smells/types.js +64 -0
  29. package/package.json +1 -1
  30. package/vitest.config.ts +2 -0
  31. package/dist/api/__tests__/app-store.test.d.ts +0 -7
  32. package/dist/api/__tests__/app-store.test.js +0 -60
  33. package/dist/api/__tests__/intelligence.test.d.ts +0 -11
  34. package/dist/api/__tests__/intelligence.test.js +0 -315
  35. package/dist/api/features/__tests__/feature-utils.test.d.ts +0 -4
  36. package/dist/api/features/__tests__/feature-utils.test.js +0 -370
  37. package/dist/api/features/__tests__/status-updater.test.d.ts +0 -4
  38. package/dist/api/features/__tests__/status-updater.test.js +0 -88
  39. package/dist/api/features/approval-checker.d.ts +0 -20
  40. package/dist/api/features/approval-checker.js +0 -152
  41. package/dist/api/features/batch-operations.d.ts +0 -17
  42. package/dist/api/features/batch-operations.js +0 -100
  43. package/dist/api/features/feature-utils.d.ts +0 -23
  44. package/dist/api/features/feature-utils.js +0 -80
  45. package/dist/api/features/get-feature.d.ts +0 -5
  46. package/dist/api/features/get-feature.js +0 -21
  47. package/dist/api/features/index.d.ts +0 -8
  48. package/dist/api/features/index.js +0 -10
  49. package/dist/api/features/status-updater.d.ts +0 -41
  50. package/dist/api/features/status-updater.js +0 -122
  51. package/dist/api/features/test-cases.d.ts +0 -29
  52. package/dist/api/features/test-cases.js +0 -110
  53. package/dist/api/features/update-feature.d.ts +0 -20
  54. package/dist/api/features/update-feature.js +0 -83
  55. package/dist/api/features/user-stories.d.ts +0 -21
  56. package/dist/api/features/user-stories.js +0 -88
  57. package/dist/commands/agent-workflow/feature-worker.d.ts +0 -14
  58. package/dist/commands/agent-workflow/feature-worker.js +0 -65
  59. package/dist/commands/build/__tests__/build.test.d.ts +0 -5
  60. package/dist/commands/build/__tests__/build.test.js +0 -206
  61. package/dist/commands/build/__tests__/detect-project.test.d.ts +0 -6
  62. package/dist/commands/build/__tests__/detect-project.test.js +0 -160
  63. package/dist/commands/build/__tests__/run-build.test.d.ts +0 -6
  64. package/dist/commands/build/__tests__/run-build.test.js +0 -433
  65. package/dist/commands/intelligence/__tests__/command.test.d.ts +0 -4
  66. package/dist/commands/intelligence/__tests__/command.test.js +0 -48
  67. package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +0 -5
  68. package/dist/commands/workflow/core/__tests__/feature-filter.test.js +0 -316
  69. package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +0 -4
  70. package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +0 -397
  71. package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +0 -4
  72. package/dist/commands/workflow/core/__tests__/state-manager.test.js +0 -384
  73. package/dist/commands/workflow/core/feature-filter.d.ts +0 -16
  74. package/dist/commands/workflow/core/feature-filter.js +0 -47
  75. package/dist/commands/workflow/feature-coordinator.d.ts +0 -18
  76. package/dist/commands/workflow/feature-coordinator.js +0 -161
  77. package/dist/config/__tests__/config.test.d.ts +0 -4
  78. package/dist/config/__tests__/config.test.js +0 -286
  79. package/dist/config/__tests__/feature-status.test.d.ts +0 -4
  80. package/dist/config/__tests__/feature-status.test.js +0 -111
  81. package/dist/config/feature-status.d.ts +0 -56
  82. package/dist/config/feature-status.js +0 -130
  83. package/dist/errors/__tests__/index.test.d.ts +0 -4
  84. package/dist/errors/__tests__/index.test.js +0 -349
  85. package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +0 -5
  86. package/dist/phases/app-store-generation/__tests__/agent.test.js +0 -142
  87. package/dist/phases/app-store-generation/__tests__/context.test.d.ts +0 -4
  88. package/dist/phases/app-store-generation/__tests__/context.test.js +0 -284
  89. package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +0 -4
  90. package/dist/phases/app-store-generation/__tests__/prompts.test.js +0 -122
  91. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +0 -5
  92. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +0 -826
  93. package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +0 -1
  94. package/dist/phases/code-review/__tests__/diff-utils.test.js +0 -101
  95. package/dist/phases/feature-analysis/agent.d.ts +0 -13
  96. package/dist/phases/feature-analysis/agent.js +0 -112
  97. package/dist/phases/feature-analysis/context.d.ts +0 -24
  98. package/dist/phases/feature-analysis/context.js +0 -138
  99. package/dist/phases/feature-analysis/index.d.ts +0 -8
  100. package/dist/phases/feature-analysis/index.js +0 -199
  101. package/dist/phases/feature-analysis/outcome.d.ts +0 -40
  102. package/dist/phases/feature-analysis/outcome.js +0 -280
  103. package/dist/phases/feature-analysis/prompts.d.ts +0 -10
  104. package/dist/phases/feature-analysis/prompts.js +0 -212
  105. package/dist/phases/feature-analysis-verification/agent.d.ts +0 -33
  106. package/dist/phases/feature-analysis-verification/agent.js +0 -124
  107. package/dist/phases/feature-analysis-verification/index.d.ts +0 -25
  108. package/dist/phases/feature-analysis-verification/index.js +0 -92
  109. package/dist/phases/feature-analysis-verification/prompts.d.ts +0 -10
  110. package/dist/phases/feature-analysis-verification/prompts.js +0 -100
  111. package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +0 -4
  112. package/dist/phases/intelligence-analysis/__tests__/context.test.js +0 -192
  113. package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +0 -13
  114. package/dist/phases/intelligence-analysis/__tests__/matching.test.js +0 -154
  115. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +0 -5
  116. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +0 -378
  117. package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +0 -4
  118. package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +0 -33
  119. package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +0 -1
  120. package/dist/phases/pr-execution/__tests__/file-assigner.test.js +0 -303
  121. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +0 -1
  122. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +0 -157
  123. package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +0 -1
  124. package/dist/phases/pr-resolve/__tests__/prompts.test.js +0 -116
  125. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +0 -1
  126. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +0 -138
  127. package/dist/phases/pr-resolve/__tests__/types.test.d.ts +0 -1
  128. package/dist/phases/pr-resolve/__tests__/types.test.js +0 -43
  129. package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +0 -1
  130. package/dist/phases/pr-resolve/__tests__/workspace.test.js +0 -111
  131. package/dist/phases/pr-review/__tests__/prompts.test.d.ts +0 -1
  132. package/dist/phases/pr-review/__tests__/prompts.test.js +0 -49
  133. package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +0 -1
  134. package/dist/phases/pr-review/__tests__/review-comments.test.js +0 -110
  135. package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +0 -1
  136. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +0 -91
  137. package/dist/phases/pr-shared/__tests__/context.test.d.ts +0 -1
  138. package/dist/phases/pr-shared/__tests__/context.test.js +0 -94
  139. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +0 -1
  140. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +0 -331
  141. package/dist/phases/run-sheet/render.d.ts +0 -60
  142. package/dist/phases/run-sheet/render.js +0 -297
  143. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +0 -4
  144. package/dist/phases/smoke-test/__tests__/agent.test.js +0 -84
  145. package/dist/phases/smoke-test/__tests__/github.test.d.ts +0 -9
  146. package/dist/phases/smoke-test/__tests__/github.test.js +0 -120
  147. package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +0 -8
  148. package/dist/phases/smoke-test/__tests__/snapshot.test.js +0 -93
  149. package/dist/phases/smoke-test/github.d.ts +0 -54
  150. package/dist/phases/smoke-test/github.js +0 -101
  151. package/dist/phases/smoke-test/snapshot.d.ts +0 -27
  152. package/dist/phases/smoke-test/snapshot.js +0 -157
  153. package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +0 -1
  154. package/dist/services/coaching/__tests__/coaching-agent.test.js +0 -74
  155. package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +0 -1
  156. package/dist/services/coaching/__tests__/coaching-loop.test.js +0 -59
  157. package/dist/services/coaching/__tests__/self-rating.test.d.ts +0 -1
  158. package/dist/services/coaching/__tests__/self-rating.test.js +0 -188
  159. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +0 -4
  160. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +0 -133
  161. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +0 -4
  162. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +0 -336
  163. package/dist/services/lifecycle-agent/index.d.ts +0 -24
  164. package/dist/services/lifecycle-agent/index.js +0 -25
  165. package/dist/services/lifecycle-agent/phase-criteria.d.ts +0 -57
  166. package/dist/services/lifecycle-agent/phase-criteria.js +0 -335
  167. package/dist/services/lifecycle-agent/transition-rules.d.ts +0 -60
  168. package/dist/services/lifecycle-agent/transition-rules.js +0 -184
  169. package/dist/services/lifecycle-agent/types.d.ts +0 -190
  170. package/dist/services/lifecycle-agent/types.js +0 -12
  171. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +0 -1
  172. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +0 -122
  173. package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +0 -1
  174. package/dist/services/phase-hooks/__tests__/hook-executor.test.js +0 -321
  175. package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +0 -1
  176. package/dist/services/phase-hooks/__tests__/hook-runner.test.js +0 -261
  177. package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +0 -1
  178. package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +0 -158
  179. package/dist/services/video/__tests__/video-pipeline.test.d.ts +0 -6
  180. package/dist/services/video/__tests__/video-pipeline.test.js +0 -249
  181. package/dist/types/features.d.ts +0 -35
  182. package/dist/types/features.js +0 -1
  183. package/dist/workspace/__tests__/workspace-manager.test.d.ts +0 -7
  184. package/dist/workspace/__tests__/workspace-manager.test.js +0 -52
@@ -1,331 +0,0 @@
1
- import assert from 'node:assert';
2
- import { describe, it } from 'node:test';
3
- import { autoFixPROrdering, getTransitiveDependencies, parseImportSpecifiers, resolveImportToChangedFile, } from '../import-dep-validator.js';
4
- // ============================================================
5
- // parseImportSpecifiers
6
- // ============================================================
7
- void describe('parseImportSpecifiers', () => {
8
- void it('parses named imports', () => {
9
- const result = parseImportSpecifiers(`import { foo } from './utils'`);
10
- assert.deepStrictEqual(result, ['./utils']);
11
- });
12
- void it('parses default imports', () => {
13
- const result = parseImportSpecifiers(`import foo from './utils'`);
14
- assert.deepStrictEqual(result, ['./utils']);
15
- });
16
- void it('parses namespace imports', () => {
17
- const result = parseImportSpecifiers(`import * as foo from './utils'`);
18
- assert.deepStrictEqual(result, ['./utils']);
19
- });
20
- void it('parses side-effect imports', () => {
21
- const result = parseImportSpecifiers(`import './polyfill'`);
22
- assert.deepStrictEqual(result, ['./polyfill']);
23
- });
24
- void it('parses type-only imports', () => {
25
- const result = parseImportSpecifiers(`import type { Foo } from './types'`);
26
- assert.deepStrictEqual(result, ['./types']);
27
- });
28
- void it('parses re-exports', () => {
29
- const result = parseImportSpecifiers(`export { foo } from './utils'`);
30
- assert.deepStrictEqual(result, ['./utils']);
31
- });
32
- void it('parses star re-exports', () => {
33
- const result = parseImportSpecifiers(`export * from './utils'`);
34
- assert.deepStrictEqual(result, ['./utils']);
35
- });
36
- void it('parses dynamic imports', () => {
37
- const result = parseImportSpecifiers(`const m = import('./lazy')`);
38
- assert.deepStrictEqual(result, ['./lazy']);
39
- });
40
- void it('parses multiple imports', () => {
41
- const source = `
42
- import { foo } from './utils'
43
- import Bar from '../components/Bar'
44
- import type { Baz } from './types'
45
- `;
46
- const result = parseImportSpecifiers(source);
47
- assert.deepStrictEqual(result, ['./utils', '../components/Bar', './types']);
48
- });
49
- void it('filters out non-relative imports', () => {
50
- const source = `
51
- import { X } from 'lodash'
52
- import React from 'react'
53
- import { Y } from './local'
54
- import { Z } from '@scope/package'
55
- `;
56
- const result = parseImportSpecifiers(source);
57
- assert.deepStrictEqual(result, ['./local']);
58
- });
59
- void it('parses multiline imports', () => {
60
- const source = `import {
61
- foo,
62
- bar,
63
- baz
64
- } from './utils'`;
65
- const result = parseImportSpecifiers(source);
66
- assert.deepStrictEqual(result, ['./utils']);
67
- });
68
- void it('deduplicates specifiers', () => {
69
- const source = `
70
- import { foo } from './utils'
71
- import { bar } from './utils'
72
- `;
73
- const result = parseImportSpecifiers(source);
74
- assert.deepStrictEqual(result, ['./utils']);
75
- });
76
- void it('handles .js extension in specifier', () => {
77
- const result = parseImportSpecifiers(`import { X } from './utils.js'`);
78
- assert.deepStrictEqual(result, ['./utils.js']);
79
- });
80
- void it('returns empty for empty source', () => {
81
- assert.deepStrictEqual(parseImportSpecifiers(''), []);
82
- });
83
- void it('returns empty for non-relative only', () => {
84
- const source = `import express from 'express'`;
85
- assert.deepStrictEqual(parseImportSpecifiers(source), []);
86
- });
87
- void it('parses export default from', () => {
88
- const result = parseImportSpecifiers(`export { default as X } from './module'`);
89
- assert.deepStrictEqual(result, ['./module']);
90
- });
91
- });
92
- // ============================================================
93
- // resolveImportToChangedFile
94
- // ============================================================
95
- void describe('resolveImportToChangedFile', () => {
96
- void it('resolves exact match with extension', () => {
97
- const changed = new Set(['src/utils.ts']);
98
- const result = resolveImportToChangedFile('./utils.ts', 'src/app.ts', changed);
99
- assert.strictEqual(result, 'src/utils.ts');
100
- });
101
- void it('infers .ts extension', () => {
102
- const changed = new Set(['src/utils.ts']);
103
- const result = resolveImportToChangedFile('./utils', 'src/app.ts', changed);
104
- assert.strictEqual(result, 'src/utils.ts');
105
- });
106
- void it('infers .tsx extension', () => {
107
- const changed = new Set(['src/Component.tsx']);
108
- const result = resolveImportToChangedFile('./Component', 'src/app.ts', changed);
109
- assert.strictEqual(result, 'src/Component.tsx');
110
- });
111
- void it('resolves index file', () => {
112
- const changed = new Set(['src/types/index.ts']);
113
- const result = resolveImportToChangedFile('./types', 'src/app.ts', changed);
114
- assert.strictEqual(result, 'src/types/index.ts');
115
- });
116
- void it('resolves .js to .ts (ESM convention)', () => {
117
- const changed = new Set(['src/utils.ts']);
118
- const result = resolveImportToChangedFile('./utils.js', 'src/app.ts', changed);
119
- assert.strictEqual(result, 'src/utils.ts');
120
- });
121
- void it('resolves parent directory traversal', () => {
122
- const changed = new Set(['src/shared/helpers.ts']);
123
- const result = resolveImportToChangedFile('../shared/helpers', 'src/components/Button.ts', changed);
124
- assert.strictEqual(result, 'src/shared/helpers.ts');
125
- });
126
- void it('resolves double parent traversal', () => {
127
- const changed = new Set(['src/utils.ts']);
128
- const result = resolveImportToChangedFile('../../utils', 'src/features/explore/App.ts', changed);
129
- assert.strictEqual(result, 'src/utils.ts');
130
- });
131
- void it('returns null for non-changed file', () => {
132
- const changed = new Set(['src/other.ts']);
133
- const result = resolveImportToChangedFile('./utils', 'src/app.ts', changed);
134
- assert.strictEqual(result, null);
135
- });
136
- void it('returns null for external package import path', () => {
137
- // This shouldn't happen since parseImportSpecifiers filters these,
138
- // but test defensive behavior
139
- const changed = new Set(['src/utils.ts']);
140
- const result = resolveImportToChangedFile('./nonexistent', 'src/app.ts', changed);
141
- assert.strictEqual(result, null);
142
- });
143
- void it('prefers .ts over .tsx when both exist', () => {
144
- const changed = new Set(['src/utils.ts', 'src/utils.tsx']);
145
- const result = resolveImportToChangedFile('./utils', 'src/app.ts', changed);
146
- assert.strictEqual(result, 'src/utils.ts');
147
- });
148
- });
149
- // ============================================================
150
- // getTransitiveDependencies
151
- // ============================================================
152
- void describe('getTransitiveDependencies', () => {
153
- void it('returns direct dependency', () => {
154
- const graph = new Map([
155
- ['A', new Set(['B'])],
156
- ['B', new Set()],
157
- ]);
158
- const result = getTransitiveDependencies('A', graph);
159
- assert.deepStrictEqual(result, new Set(['B']));
160
- });
161
- void it('returns transitive dependencies', () => {
162
- const graph = new Map([
163
- ['A', new Set(['B'])],
164
- ['B', new Set(['C'])],
165
- ['C', new Set()],
166
- ]);
167
- const result = getTransitiveDependencies('A', graph);
168
- assert.deepStrictEqual(result, new Set(['B', 'C']));
169
- });
170
- void it('handles diamond dependency', () => {
171
- const graph = new Map([
172
- ['A', new Set(['B', 'C'])],
173
- ['B', new Set(['D'])],
174
- ['C', new Set(['D'])],
175
- ['D', new Set()],
176
- ]);
177
- const result = getTransitiveDependencies('A', graph);
178
- assert.deepStrictEqual(result, new Set(['B', 'C', 'D']));
179
- });
180
- void it('handles cycles without infinite loop', () => {
181
- const graph = new Map([
182
- ['A', new Set(['B'])],
183
- ['B', new Set(['A'])],
184
- ]);
185
- const result = getTransitiveDependencies('A', graph);
186
- assert.deepStrictEqual(result, new Set(['B', 'A']));
187
- });
188
- void it('returns empty set for no dependencies', () => {
189
- const graph = new Map([['A', new Set()]]);
190
- const result = getTransitiveDependencies('A', graph);
191
- assert.deepStrictEqual(result, new Set());
192
- });
193
- void it('returns empty set for unknown file', () => {
194
- const graph = new Map();
195
- const result = getTransitiveDependencies('unknown', graph);
196
- assert.deepStrictEqual(result, new Set());
197
- });
198
- });
199
- // ============================================================
200
- // autoFixPROrdering
201
- // ============================================================
202
- function makePR(sequence, name, files, dependsOn) {
203
- return {
204
- sequence,
205
- name,
206
- description: name,
207
- branch_name: `pr/feat/${sequence}-${name.toLowerCase().replace(/\s/g, '-')}`,
208
- depends_on_branch_name: dependsOn ?? null,
209
- files: files.map((f) => ({ path: f, change_type: 'modified' })),
210
- };
211
- }
212
- void describe('autoFixPROrdering', () => {
213
- void it('returns unchanged when no violations', () => {
214
- const prs = [
215
- makePR(1, 'Foundation', ['src/utils.ts']),
216
- makePR(2, 'Components', ['src/app.ts'], 'pr/feat/1-foundation'),
217
- ];
218
- // app.ts depends on utils.ts, and utils.ts is in PR 1 (earlier) — OK
219
- const graph = new Map([
220
- ['src/app.ts', new Set(['src/utils.ts'])],
221
- ['src/utils.ts', new Set()],
222
- ]);
223
- const result = autoFixPROrdering(prs, graph);
224
- assert.strictEqual(result.movedFiles.length, 0);
225
- assert.strictEqual(result.pullRequests[0].files?.length, 1);
226
- assert.strictEqual(result.pullRequests[1].files?.length, 1);
227
- });
228
- void it('moves dependency from later PR to earlier PR', () => {
229
- const prs = [
230
- makePR(1, 'Components', ['src/app.ts']),
231
- makePR(2, 'Utils', ['src/utils.ts'], 'pr/feat/1-components'),
232
- ];
233
- // app.ts (PR 1) depends on utils.ts (PR 2) — violation!
234
- const graph = new Map([
235
- ['src/app.ts', new Set(['src/utils.ts'])],
236
- ['src/utils.ts', new Set()],
237
- ]);
238
- const result = autoFixPROrdering(prs, graph);
239
- assert.strictEqual(result.movedFiles.length, 1);
240
- assert.strictEqual(result.movedFiles[0].file, 'src/utils.ts');
241
- // utils.ts should now be in PR 1
242
- const pr1Files = result.pullRequests[0].files?.map((f) => f.path) ?? [];
243
- assert.ok(pr1Files.includes('src/utils.ts'));
244
- assert.ok(pr1Files.includes('src/app.ts'));
245
- // PR 2 should be empty
246
- assert.strictEqual(result.pullRequests[1].files?.length, 0);
247
- });
248
- void it('handles transitive dependency moves', () => {
249
- const prs = [
250
- makePR(1, 'App', ['src/app.ts']),
251
- makePR(2, 'Service', ['src/service.ts'], 'pr/feat/1-app'),
252
- makePR(3, 'Utils', ['src/utils.ts'], 'pr/feat/2-service'),
253
- ];
254
- // app.ts → service.ts → utils.ts (chain across 3 PRs)
255
- const graph = new Map([
256
- ['src/app.ts', new Set(['src/service.ts'])],
257
- ['src/service.ts', new Set(['src/utils.ts'])],
258
- ['src/utils.ts', new Set()],
259
- ]);
260
- const result = autoFixPROrdering(prs, graph);
261
- // Both service.ts and utils.ts should move to PR 1
262
- const pr1Files = result.pullRequests[0].files?.map((f) => f.path) ?? [];
263
- assert.ok(pr1Files.includes('src/app.ts'));
264
- assert.ok(pr1Files.includes('src/service.ts'));
265
- assert.ok(pr1Files.includes('src/utils.ts'));
266
- });
267
- void it('moves dep to earliest PR that needs it', () => {
268
- const prs = [
269
- makePR(1, 'PR1', ['src/a.ts']),
270
- makePR(2, 'PR2', ['src/b.ts'], 'pr/feat/1-pr1'),
271
- makePR(3, 'PR3', ['src/utils.ts'], 'pr/feat/2-pr2'),
272
- ];
273
- // Both a.ts (PR 1) and b.ts (PR 2) import utils.ts (PR 3)
274
- const graph = new Map([
275
- ['src/a.ts', new Set(['src/utils.ts'])],
276
- ['src/b.ts', new Set(['src/utils.ts'])],
277
- ['src/utils.ts', new Set()],
278
- ]);
279
- const result = autoFixPROrdering(prs, graph);
280
- // utils.ts should move to PR 1 (earliest needer)
281
- const pr1Files = result.pullRequests[0].files?.map((f) => f.path) ?? [];
282
- assert.ok(pr1Files.includes('src/utils.ts'));
283
- });
284
- void it('handles no dependencies at all', () => {
285
- const prs = [
286
- makePR(1, 'PR1', ['src/a.ts']),
287
- makePR(2, 'PR2', ['src/b.ts'], 'pr/feat/1-pr1'),
288
- ];
289
- const graph = new Map([
290
- ['src/a.ts', new Set()],
291
- ['src/b.ts', new Set()],
292
- ]);
293
- const result = autoFixPROrdering(prs, graph);
294
- assert.strictEqual(result.movedFiles.length, 0);
295
- });
296
- void it('handles single PR with all files', () => {
297
- const prs = [makePR(1, 'Everything', ['src/a.ts', 'src/b.ts', 'src/c.ts'])];
298
- const graph = new Map([
299
- ['src/a.ts', new Set(['src/b.ts'])],
300
- ['src/b.ts', new Set(['src/c.ts'])],
301
- ['src/c.ts', new Set()],
302
- ]);
303
- const result = autoFixPROrdering(prs, graph);
304
- assert.strictEqual(result.movedFiles.length, 0);
305
- });
306
- void it('does not mutate original PR files arrays', () => {
307
- const originalFiles = [{ path: 'src/app.ts', change_type: 'modified' }];
308
- const prs = [
309
- {
310
- sequence: 1,
311
- name: 'PR1',
312
- description: 'PR1',
313
- files: originalFiles,
314
- },
315
- {
316
- sequence: 2,
317
- name: 'PR2',
318
- description: 'PR2',
319
- files: [{ path: 'src/utils.ts', change_type: 'modified' }],
320
- },
321
- ];
322
- const graph = new Map([
323
- ['src/app.ts', new Set(['src/utils.ts'])],
324
- ['src/utils.ts', new Set()],
325
- ]);
326
- autoFixPROrdering(prs, graph);
327
- // Original files array should not be modified
328
- assert.strictEqual(originalFiles.length, 1);
329
- assert.strictEqual(originalFiles[0].path, 'src/app.ts');
330
- });
331
- });
@@ -1,60 +0,0 @@
1
- /**
2
- * Pure template-rendering logic for run sheets.
3
- *
4
- * The CLI is the sole authoritative renderer today — if the web app
5
- * ever grows its own run-sheet generation path, it should call into
6
- * this module (or a published copy of it) rather than forking.
7
- */
8
- export declare const MAX_FILE_INCLUDE_BYTES: number;
9
- export declare const MAX_TOTAL_FILE_BYTES: number;
10
- export interface TemplateProduct {
11
- name: string;
12
- github_repository_full_name: string | null;
13
- }
14
- export interface TemplateRelease {
15
- tag: string;
16
- name: string | null;
17
- body: string | null;
18
- url: string | null;
19
- published_at: string | null;
20
- previous_tag: string | null;
21
- previous_published_at: string | null;
22
- diff_summary: string | null;
23
- diff_stats: Record<string, unknown>;
24
- }
25
- export declare function isSafeGitRef(ref: string): boolean;
26
- export interface FileReadResult {
27
- content: string | null;
28
- bytes: number;
29
- reason?: string;
30
- }
31
- export declare function safeReadRepoFile(repoDir: string, relPath: string, remainingBudget: number): Promise<FileReadResult>;
32
- export interface RenderResult {
33
- rendered: string;
34
- missing: string[];
35
- filesRead: {
36
- path: string;
37
- bytes: number;
38
- }[];
39
- }
40
- export interface TemplateCommit {
41
- sha: string;
42
- short_sha: string;
43
- summary: string;
44
- message: string;
45
- author: string;
46
- url: string;
47
- }
48
- /**
49
- * Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
50
- * Rich-text editor roundtrip doesn't break placeholder matching.
51
- */
52
- export declare function normalizeTemplate(template: string): string;
53
- /**
54
- * Escape CommonMark-significant characters so untrusted strings
55
- * (commit messages, author names, release bodies) can be safely
56
- * dropped into a markdown run sheet without injecting headings,
57
- * blockquotes, links, images, or HTML.
58
- */
59
- export declare function escapeMarkdown(raw: string): string;
60
- export declare function renderTemplate(template: string, product: TemplateProduct, release: TemplateRelease, repoDir: string | null, commits: string, draftNotice?: string, commitsList?: TemplateCommit[]): Promise<RenderResult>;
@@ -1,297 +0,0 @@
1
- /**
2
- * Pure template-rendering logic for run sheets.
3
- *
4
- * The CLI is the sole authoritative renderer today — if the web app
5
- * ever grows its own run-sheet generation path, it should call into
6
- * this module (or a published copy of it) rather than forking.
7
- */
8
- import { lstatSync, realpathSync, statSync } from 'fs';
9
- import { readFile } from 'fs/promises';
10
- import { join, resolve } from 'path';
11
- export const MAX_FILE_INCLUDE_BYTES = 256 * 1024;
12
- export const MAX_TOTAL_FILE_BYTES = 1024 * 1024;
13
- export function isSafeGitRef(ref) {
14
- if (typeof ref !== 'string' || ref.length === 0 || ref.length > 200) {
15
- return false;
16
- }
17
- if (/\s/.test(ref) || /^[-.]/.test(ref)) {
18
- return false;
19
- }
20
- if (ref.includes('..') || ref.includes('@{')) {
21
- return false;
22
- }
23
- return /^[A-Za-z0-9._\-+/@]+$/.test(ref);
24
- }
25
- export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
26
- if (!relPath || relPath.startsWith('/') || relPath.includes('\0')) {
27
- return { content: null, bytes: 0, reason: 'invalid path' };
28
- }
29
- const repoDirResolved = resolve(repoDir);
30
- const abs = resolve(join(repoDirResolved, relPath));
31
- if (abs !== repoDirResolved && !abs.startsWith(`${repoDirResolved}/`)) {
32
- return { content: null, bytes: 0, reason: 'path escape' };
33
- }
34
- // Refuse symlinks at the leaf. `resolve()` only collapses `..`; it does
35
- // not follow symlinks, so a symlink *inside* the repo pointing at
36
- // `/etc/passwd` would otherwise be readable.
37
- try {
38
- if (lstatSync(abs).isSymbolicLink()) {
39
- return { content: null, bytes: 0, reason: 'symlink refused' };
40
- }
41
- }
42
- catch {
43
- return { content: null, bytes: 0, reason: 'not found' };
44
- }
45
- // Also verify the fully-resolved real path still lives inside the repo
46
- // root — catches symlinked parent directories.
47
- try {
48
- const realAbs = realpathSync(abs);
49
- const realRoot = realpathSync(repoDirResolved);
50
- if (realAbs !== realRoot && !realAbs.startsWith(`${realRoot}/`)) {
51
- return { content: null, bytes: 0, reason: 'symlink escapes repo' };
52
- }
53
- }
54
- catch {
55
- return { content: null, bytes: 0, reason: 'not found' };
56
- }
57
- let size;
58
- try {
59
- const stat = statSync(abs);
60
- if (!stat.isFile()) {
61
- return { content: null, bytes: 0, reason: 'not a file' };
62
- }
63
- ;
64
- ({ size } = stat);
65
- }
66
- catch {
67
- return { content: null, bytes: 0, reason: 'not found' };
68
- }
69
- if (size > MAX_FILE_INCLUDE_BYTES) {
70
- return {
71
- content: null,
72
- bytes: 0,
73
- reason: `too large (${size} > ${MAX_FILE_INCLUDE_BYTES} bytes)`,
74
- };
75
- }
76
- if (size > remainingBudget) {
77
- return {
78
- content: null,
79
- bytes: 0,
80
- reason: 'total file-inclusion budget exhausted',
81
- };
82
- }
83
- try {
84
- const content = await readFile(abs, 'utf-8');
85
- return { content, bytes: size };
86
- }
87
- catch {
88
- return { content: null, bytes: 0, reason: 'read failed' };
89
- }
90
- }
91
- /**
92
- * Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
93
- * Rich-text editor roundtrip doesn't break placeholder matching.
94
- */
95
- export function normalizeTemplate(template) {
96
- return template.replace(/\{\{([^}]*)\}\}/g, (_match, inner) => `{{${inner.replace(/\\([_.\-\\/])/g, '$1')}}}`);
97
- }
98
- function isTruthy(value) {
99
- if (!value) {
100
- return false;
101
- }
102
- const v = value.trim();
103
- return v !== '' && v !== 'false' && v !== '0';
104
- }
105
- /**
106
- * Split a block body on a top-level `{{else}}` so `{{#if}}A{{else}}B{{/if}}`
107
- * resolves. Because we don't support nested same-keyword blocks, a naive
108
- * first-occurrence split is safe enough.
109
- */
110
- function splitOnElse(body) {
111
- const m = body.match(/\{\{\s*else\s*\}\}/);
112
- if (!m || m.index === undefined) {
113
- return { main: body, alt: '' };
114
- }
115
- return {
116
- main: body.slice(0, m.index),
117
- alt: body.slice(m.index + m[0].length),
118
- };
119
- }
120
- /**
121
- * Replace `{{#if key}}...{{else}}...{{/if}}` and
122
- * `{{#unless key}}...{{else}}...{{/unless}}` blocks. Runs repeatedly
123
- * until a fixed point so two sibling (non-nested) blocks both resolve.
124
- * Nested blocks of the *same* keyword aren't supported; that's an
125
- * intentional simplicity/safety choice.
126
- */
127
- function substituteConditionals(template, ctx) {
128
- const IF_RE = /\{\{\s*#if\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}([\s\S]*?)\{\{\s*\/if\s*\}\}/g;
129
- const UNLESS_RE = /\{\{\s*#unless\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}([\s\S]*?)\{\{\s*\/unless\s*\}\}/g;
130
- let out = template;
131
- for (let i = 0; i < 5; i++) {
132
- const before = out;
133
- out = out.replace(IF_RE, (_, key, body) => {
134
- const { main, alt } = splitOnElse(body);
135
- return isTruthy(ctx[key] ?? '') ? main : alt;
136
- });
137
- out = out.replace(UNLESS_RE, (_, key, body) => {
138
- const { main, alt } = splitOnElse(body);
139
- return isTruthy(ctx[key] ?? '') ? alt : main;
140
- });
141
- if (out === before) {
142
- break;
143
- }
144
- }
145
- return out;
146
- }
147
- /**
148
- * Expand `{{#each commits}}...{{/each}}`. Each iteration renders the body
149
- * with a merged ctx of (outer vars + commit fields), running the full
150
- * conditional + variable substitution on the body so `{{#if url}}` /
151
- * `{{formatDate ...}}` / outer vars all work per-commit.
152
- */
153
- function substituteEachCommits(template, commits, outerCtx) {
154
- const EACH_RE = /\{\{\s*#each\s+commits\s*\}\}([\s\S]*?)\{\{\s*\/each\s*\}\}/g;
155
- return template.replace(EACH_RE, (_match, body) => commits
156
- .map((c) => {
157
- const localCtx = {
158
- ...outerCtx,
159
- sha: c.sha,
160
- short_sha: c.short_sha,
161
- summary: c.summary,
162
- message: c.message,
163
- author: c.author,
164
- url: c.url,
165
- };
166
- let rendered = substituteConditionals(body, localCtx);
167
- rendered = substituteDateHelper(rendered, localCtx);
168
- rendered = substituteEscapeHelper(rendered, localCtx);
169
- rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => (key in localCtx ? localCtx[key] : match));
170
- return rendered;
171
- })
172
- .join(''));
173
- }
174
- /**
175
- * Escape CommonMark-significant characters so untrusted strings
176
- * (commit messages, author names, release bodies) can be safely
177
- * dropped into a markdown run sheet without injecting headings,
178
- * blockquotes, links, images, or HTML.
179
- */
180
- export function escapeMarkdown(raw) {
181
- if (!raw) {
182
- return '';
183
- }
184
- // Backslash-escape every CommonMark-significant punctuation character.
185
- // This also neutralises line-start markers (`#`, `>`, `-`, `+`, `*`,
186
- // `1.`) because their first char always ends up escaped.
187
- return raw.replace(/([\\`*_{}\[\]()#+\-.!|<>~])/g, '\\$1');
188
- }
189
- function substituteEscapeHelper(template, ctx) {
190
- const RE = /\{\{\s*escape\s+([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g;
191
- return template.replace(RE, (_match, key) => escapeMarkdown(ctx[key] ?? ''));
192
- }
193
- /**
194
- * `{{formatDate key 'YYYY-MM-DD HH:mm'}}` — resolves `key` from ctx (must
195
- * be a parseable date), formats with a tiny token set. Invalid dates or
196
- * empty values render as empty string (matches the "degrade gracefully"
197
- * convention the rest of the template follows).
198
- */
199
- function substituteDateHelper(template, ctx) {
200
- const RE = /\{\{\s*formatDate\s+([a-zA-Z_][a-zA-Z0-9_]*)\s+['"]([^'"]+)['"]\s*\}\}/g;
201
- return template.replace(RE, (_match, key, format) => {
202
- const raw = ctx[key];
203
- if (!raw) {
204
- return '';
205
- }
206
- const d = new Date(raw);
207
- if (Number.isNaN(d.getTime())) {
208
- return '';
209
- }
210
- const pad = (n) => n.toString().padStart(2, '0');
211
- return format
212
- .replace(/YYYY/g, String(d.getUTCFullYear()))
213
- .replace(/MM/g, pad(d.getUTCMonth() + 1))
214
- .replace(/DD/g, pad(d.getUTCDate()))
215
- .replace(/HH/g, pad(d.getUTCHours()))
216
- .replace(/mm/g, pad(d.getUTCMinutes()))
217
- .replace(/ss/g, pad(d.getUTCSeconds()));
218
- });
219
- }
220
- async function expandFileIncludes(template, repoDir) {
221
- const missing = [];
222
- const filesRead = [];
223
- const fileRegex = /\{\{\s*file:([^}]+?)\s*\}\}/g;
224
- const fileMatches = [];
225
- for (const m of template.matchAll(fileRegex)) {
226
- fileMatches.push({ match: m[0], path: m[1].trim() });
227
- }
228
- let remainingBudget = MAX_TOTAL_FILE_BYTES;
229
- const fileResults = new Map();
230
- for (const { match, path } of fileMatches) {
231
- if (fileResults.has(match)) {
232
- continue;
233
- }
234
- if (!repoDir) {
235
- missing.push(`file:${path} (no repo)`);
236
- fileResults.set(match, `<!-- file ${path} unavailable: repo not cloned -->`);
237
- continue;
238
- }
239
- const res = await safeReadRepoFile(repoDir, path, remainingBudget);
240
- if (res.content === null) {
241
- missing.push(`file:${path} (${res.reason ?? 'unavailable'})`);
242
- fileResults.set(match, `<!-- file ${path} unavailable: ${res.reason ?? 'unknown'} -->`);
243
- }
244
- else {
245
- remainingBudget -= res.bytes;
246
- filesRead.push({ path, bytes: res.bytes });
247
- fileResults.set(match, res.content);
248
- }
249
- }
250
- const rendered = template.replace(fileRegex, (match) => fileResults.get(match) ?? match);
251
- return { rendered, missing, filesRead };
252
- }
253
- function buildSimpleVars(product, release, commits, draftNotice) {
254
- const stats = release.diff_stats ?? {};
255
- return {
256
- product_name: product.name,
257
- release_tag: release.tag,
258
- release_name: release.name ?? release.tag,
259
- release_body: release.body ?? '',
260
- release_url: release.url ?? '',
261
- previous_tag: release.previous_tag ?? '',
262
- published_at: release.published_at ?? '',
263
- previous_published_at: release.previous_published_at ?? '',
264
- diff_summary: release.diff_summary ?? '',
265
- files_changed: String(stats.files_changed ?? ''),
266
- additions: String(stats.additions ?? ''),
267
- deletions: String(stats.deletions ?? ''),
268
- commits_count: String(stats.commits_count ?? stats.total_commits ?? ''),
269
- repository: product.github_repository_full_name ?? '',
270
- generated_at: new Date().toISOString(),
271
- commits,
272
- draft_notice: draftNotice,
273
- };
274
- }
275
- export async function renderTemplate(template, product, release, repoDir, commits, draftNotice = '', commitsList = []) {
276
- const missing = [];
277
- const simpleVars = buildSimpleVars(product, release, commits, draftNotice);
278
- // Pipeline: normalize → file includes → #each (self-contained, renders
279
- // its body with per-commit ctx) → outer #if/#unless → date helper →
280
- // variable substitution. Running `#each` first lets it fully resolve
281
- // per-commit `{{#if url}}` without bleeding into outer-scope `{{#if}}`.
282
- const normalized = normalizeTemplate(template);
283
- const fileExpansion = await expandFileIncludes(normalized, repoDir);
284
- missing.push(...fileExpansion.missing);
285
- let rendered = substituteEachCommits(fileExpansion.rendered, commitsList, simpleVars);
286
- rendered = substituteConditionals(rendered, simpleVars);
287
- rendered = substituteDateHelper(rendered, simpleVars);
288
- rendered = substituteEscapeHelper(rendered, simpleVars);
289
- rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
290
- if (key in simpleVars) {
291
- return simpleVars[key];
292
- }
293
- missing.push(key);
294
- return match;
295
- });
296
- return { rendered, missing, filesRead: fileExpansion.filesRead };
297
- }