edsger 0.46.0 → 0.48.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 (121) hide show
  1. package/.claude/settings.local.json +3 -23
  2. package/dist/api/__tests__/app-store.test.d.ts +7 -0
  3. package/dist/api/__tests__/app-store.test.js +60 -0
  4. package/dist/api/__tests__/intelligence.test.d.ts +11 -0
  5. package/dist/api/__tests__/intelligence.test.js +315 -0
  6. package/dist/api/features/__tests__/feature-utils.test.d.ts +4 -0
  7. package/dist/api/features/__tests__/feature-utils.test.js +370 -0
  8. package/dist/api/features/__tests__/status-updater.test.d.ts +4 -0
  9. package/dist/api/features/__tests__/status-updater.test.js +88 -0
  10. package/dist/commands/build/__tests__/build.test.d.ts +5 -0
  11. package/dist/commands/build/__tests__/build.test.js +206 -0
  12. package/dist/commands/build/__tests__/detect-project.test.d.ts +6 -0
  13. package/dist/commands/build/__tests__/detect-project.test.js +160 -0
  14. package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
  15. package/dist/commands/build/__tests__/run-build.test.js +433 -0
  16. package/dist/commands/intelligence/__tests__/command.test.d.ts +4 -0
  17. package/dist/commands/intelligence/__tests__/command.test.js +48 -0
  18. package/dist/commands/run-sheet/index.js +6 -0
  19. package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +5 -0
  20. package/dist/commands/workflow/core/__tests__/feature-filter.test.js +316 -0
  21. package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +4 -0
  22. package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +397 -0
  23. package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +4 -0
  24. package/dist/commands/workflow/core/__tests__/state-manager.test.js +384 -0
  25. package/dist/config/__tests__/config.test.d.ts +4 -0
  26. package/dist/config/__tests__/config.test.js +286 -0
  27. package/dist/config/__tests__/feature-status.test.d.ts +4 -0
  28. package/dist/config/__tests__/feature-status.test.js +111 -0
  29. package/dist/errors/__tests__/index.test.d.ts +4 -0
  30. package/dist/errors/__tests__/index.test.js +349 -0
  31. package/dist/index.js +0 -0
  32. package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +5 -0
  33. package/dist/phases/app-store-generation/__tests__/agent.test.js +142 -0
  34. package/dist/phases/app-store-generation/__tests__/context.test.d.ts +4 -0
  35. package/dist/phases/app-store-generation/__tests__/context.test.js +284 -0
  36. package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +4 -0
  37. package/dist/phases/app-store-generation/__tests__/prompts.test.js +122 -0
  38. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +5 -0
  39. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +826 -0
  40. package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
  41. package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
  42. package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +4 -0
  43. package/dist/phases/intelligence-analysis/__tests__/context.test.js +192 -0
  44. package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +13 -0
  45. package/dist/phases/intelligence-analysis/__tests__/matching.test.js +154 -0
  46. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +5 -0
  47. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +378 -0
  48. package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +4 -0
  49. package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +33 -0
  50. package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
  51. package/dist/phases/pr-execution/__tests__/file-assigner.test.js +303 -0
  52. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +1 -0
  53. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +157 -0
  54. package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
  55. package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
  56. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
  57. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
  58. package/dist/phases/pr-resolve/__tests__/types.test.d.ts +1 -0
  59. package/dist/phases/pr-resolve/__tests__/types.test.js +43 -0
  60. package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
  61. package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
  62. package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
  63. package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
  64. package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
  65. package/dist/phases/pr-review/__tests__/review-comments.test.js +110 -0
  66. package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
  67. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
  68. package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
  69. package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
  70. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
  71. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
  72. package/dist/phases/release-sync/github.d.ts +12 -0
  73. package/dist/phases/release-sync/github.js +39 -0
  74. package/dist/phases/release-sync/snapshot.js +0 -1
  75. package/dist/phases/run-sheet/index.d.ts +15 -0
  76. package/dist/phases/run-sheet/index.js +154 -22
  77. package/dist/phases/run-sheet/render.d.ts +23 -5
  78. package/dist/phases/run-sheet/render.js +193 -31
  79. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
  80. package/dist/phases/smoke-test/__tests__/agent.test.js +84 -0
  81. package/dist/phases/smoke-test/__tests__/github.test.d.ts +9 -0
  82. package/dist/phases/smoke-test/__tests__/github.test.js +120 -0
  83. package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +8 -0
  84. package/dist/phases/smoke-test/__tests__/snapshot.test.js +93 -0
  85. package/dist/phases/smoke-test/github.d.ts +54 -0
  86. package/dist/phases/smoke-test/github.js +101 -0
  87. package/dist/phases/smoke-test/snapshot.d.ts +27 -0
  88. package/dist/phases/smoke-test/snapshot.js +157 -0
  89. package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +1 -0
  90. package/dist/services/coaching/__tests__/coaching-agent.test.js +74 -0
  91. package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +1 -0
  92. package/dist/services/coaching/__tests__/coaching-loop.test.js +59 -0
  93. package/dist/services/coaching/__tests__/self-rating.test.d.ts +1 -0
  94. package/dist/services/coaching/__tests__/self-rating.test.js +188 -0
  95. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +4 -0
  96. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +133 -0
  97. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +4 -0
  98. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +336 -0
  99. package/dist/services/lifecycle-agent/index.d.ts +24 -0
  100. package/dist/services/lifecycle-agent/index.js +25 -0
  101. package/dist/services/lifecycle-agent/phase-criteria.d.ts +57 -0
  102. package/dist/services/lifecycle-agent/phase-criteria.js +335 -0
  103. package/dist/services/lifecycle-agent/transition-rules.d.ts +60 -0
  104. package/dist/services/lifecycle-agent/transition-rules.js +184 -0
  105. package/dist/services/lifecycle-agent/types.d.ts +190 -0
  106. package/dist/services/lifecycle-agent/types.js +12 -0
  107. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +1 -0
  108. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +122 -0
  109. package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +1 -0
  110. package/dist/services/phase-hooks/__tests__/hook-executor.test.js +321 -0
  111. package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +1 -0
  112. package/dist/services/phase-hooks/__tests__/hook-runner.test.js +261 -0
  113. package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +1 -0
  114. package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +158 -0
  115. package/dist/services/video/__tests__/video-pipeline.test.d.ts +6 -0
  116. package/dist/services/video/__tests__/video-pipeline.test.js +249 -0
  117. package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
  118. package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
  119. package/dist/workspace/workspace-manager.js +17 -4
  120. package/package.json +1 -1
  121. package/.env.local +0 -12
@@ -10,7 +10,8 @@
10
10
  * template_snapshot + tag and no clone error, we short-circuit without
11
11
  * re-cloning. Pass `{ force: true }` to regenerate anyway.
12
12
  */
13
- import { closeSync, mkdirSync, openSync, readFileSync, unlinkSync, writeFileSync, } from 'fs';
13
+ import { createHash } from 'crypto';
14
+ import { closeSync, mkdirSync, openSync, readdirSync, readFileSync, rmSync, statSync, unlinkSync, writeFileSync, } from 'fs';
14
15
  import { join } from 'path';
15
16
  import { getGitHubConfigByProduct } from '../../api/github.js';
16
17
  import { getProduct } from '../../api/products.js';
@@ -18,29 +19,33 @@ import { getRelease } from '../../api/releases.js';
18
19
  import { getRunSheetByRelease, upsertRunSheet, } from '../../api/run-sheets.js';
19
20
  import { logError, logInfo, logSuccess, logWarning, } from '../../utils/logger.js';
20
21
  import { cloneFeatureRepo, ensureWorkspaceDir, getFeatureRepoPath, syncRepoToRef, } from '../../workspace/workspace-manager.js';
21
- import { fetchCompare } from '../release-sync/github.js';
22
+ import { fetchAllCompareCommits, getDefaultBranchHead, } from '../release-sync/github.js';
22
23
  import { isSafeGitRef, renderTemplate } from './render.js';
23
- // GitHub's compare endpoint is paginated; we don't page, so we warn when
24
- // we hit the first-page cap.
25
- const GITHUB_COMPARE_MAX_COMMITS = 250;
26
24
  async function fetchCommitsBetween(owner, repo, base, head, token) {
27
25
  if (!base) {
28
- return { text: '', truncated: false };
26
+ return { text: '', list: [], truncated: false };
29
27
  }
30
28
  try {
31
- const compare = await fetchCompare(owner, repo, base, head, token);
32
- const commits = compare.commits ?? [];
33
- const text = commits
34
- .map((c) => `- ${c.sha.slice(0, 7)} ${(c.commit.message || '').split('\n')[0]}`)
35
- .join('\n');
36
- return {
37
- text,
38
- truncated: commits.length >= GITHUB_COMPARE_MAX_COMMITS,
39
- };
29
+ const { commits, truncated } = await fetchAllCompareCommits(owner, repo, base, head, token);
30
+ const list = commits.map((c) => {
31
+ const message = c.commit.message ?? '';
32
+ const summary = message.split('\n')[0];
33
+ return {
34
+ sha: c.sha,
35
+ short_sha: c.sha.slice(0, 7),
36
+ summary,
37
+ message,
38
+ author: c.commit.author?.name ?? '',
39
+ url: c.html_url ?? '',
40
+ };
41
+ });
42
+ const text = list.map((c) => `- ${c.short_sha} ${c.summary}`).join('\n');
43
+ return { text, list, truncated };
40
44
  }
41
45
  catch (err) {
42
- logWarning(`Could not fetch commits for run sheet: ${err instanceof Error ? err.message : String(err)}`);
43
- return { text: '', truncated: false };
46
+ const message = err instanceof Error ? err.message : String(err);
47
+ logWarning(`Could not fetch commits for run sheet: ${message}`);
48
+ return { text: '', list: [], truncated: false, error: message };
44
49
  }
45
50
  }
46
51
  // Stale locks (e.g. left behind by a crashed or SIGKILLed CLI) are
@@ -119,6 +124,87 @@ export function releaseFileLock(lockPath) {
119
124
  // Best-effort — lock will expire via LOCK_STALE_MS anyway.
120
125
  }
121
126
  }
127
+ // A `run-sheet-<release-id>` workspace whose lock is stale and whose
128
+ // dir hasn't been touched for this long is considered abandoned. 30 days
129
+ // is long enough to keep caches warm across normal release cadences,
130
+ // short enough that disk usage doesn't grow unboundedly.
131
+ const WORKSPACE_MAX_AGE_MS = 30 * 24 * 60 * 60 * 1000;
132
+ /**
133
+ * Delete `run-sheet-*` workspaces that are both:
134
+ * - not currently locked (lock file missing or stale), AND
135
+ * - older than WORKSPACE_MAX_AGE_MS by mtime.
136
+ *
137
+ * Best-effort: any error on a single entry is logged and skipped so a
138
+ * corrupt dir doesn't block the current generation.
139
+ *
140
+ * Exported for unit tests.
141
+ */
142
+ export function pruneStaleRunSheetWorkspaces(workspaceRoot, now = Date.now(), maxAgeMs = WORKSPACE_MAX_AGE_MS) {
143
+ const removed = [];
144
+ let entries;
145
+ try {
146
+ entries = readdirSync(workspaceRoot);
147
+ }
148
+ catch {
149
+ return { removed };
150
+ }
151
+ for (const entry of entries) {
152
+ if (!entry.startsWith('run-sheet-')) {
153
+ continue;
154
+ }
155
+ const entryPath = join(workspaceRoot, entry);
156
+ const lockPath = `${entryPath}.lock`;
157
+ let ageMs;
158
+ try {
159
+ const stat = statSync(entryPath);
160
+ if (!stat.isDirectory()) {
161
+ continue;
162
+ }
163
+ ageMs = now - stat.mtimeMs;
164
+ }
165
+ catch {
166
+ continue;
167
+ }
168
+ if (ageMs < maxAgeMs) {
169
+ continue;
170
+ }
171
+ // Don't delete a dir with a live lock — another CLI is mid-run.
172
+ try {
173
+ readFileSync(lockPath, 'utf-8');
174
+ if (!isLockStale(lockPath)) {
175
+ continue;
176
+ }
177
+ }
178
+ catch {
179
+ // Lock missing — safe to delete.
180
+ }
181
+ try {
182
+ rmSync(entryPath, { recursive: true, force: true });
183
+ try {
184
+ unlinkSync(lockPath);
185
+ }
186
+ catch {
187
+ // Lock file may not exist; that's fine.
188
+ }
189
+ removed.push(entry);
190
+ }
191
+ catch (err) {
192
+ logWarning(`Could not prune stale workspace ${entry}: ${err instanceof Error ? err.message : String(err)}`);
193
+ }
194
+ }
195
+ return { removed };
196
+ }
197
+ function computeInputHash(parts) {
198
+ const h = createHash('sha256');
199
+ h.update(`template:${parts.template}\n`);
200
+ h.update(`tag:${parts.tag}\n`);
201
+ h.update(`prev:${parts.previousTag ?? ''}\n`);
202
+ for (const f of [...parts.filesRead].sort((a, b) => a.path.localeCompare(b.path))) {
203
+ h.update(`file:${f.path}:${f.bytes}\n`);
204
+ }
205
+ h.update(`commits:${parts.commitShas.join(',')}\n`);
206
+ return h.digest('hex');
207
+ }
122
208
  function runSheetIsFresh(existing, template, tag) {
123
209
  if (!existing || !existing.content) {
124
210
  return false;
@@ -192,15 +278,30 @@ export async function runRunSheet(options) {
192
278
  // whatever metadata we have).
193
279
  let repoDir = null;
194
280
  let cloneError = null;
281
+ let commitsError = null;
195
282
  let commits = '';
283
+ let commitsList = [];
196
284
  let commitsTruncated = false;
197
285
  let lockPath = null;
286
+ // Draft mode: the release's tag doesn't exist on GitHub yet (e.g. a
287
+ // Maven SNAPSHOT being planned pre-cut). We still want to produce a
288
+ // useful run sheet, so we fall back to the default branch HEAD for
289
+ // both the checkout and the commits compare.
290
+ let isDraft = false;
291
+ let draftBaseRef = null;
198
292
  if (repoConfigured && gh.owner && gh.repo && gh.token) {
199
293
  const { owner, repo, token } = gh;
200
294
  // Serialise concurrent CLI invocations for the *same release* by
201
295
  // taking a file lock next to the clone dir. Different releases get
202
296
  // different lock files (per-release workspace).
203
297
  const workspaceRoot = ensureWorkspaceDir();
298
+ // Sweep old per-release workspaces before cloning. Cheap (one
299
+ // readdir + stat per entry); silent unless something is actually
300
+ // removed.
301
+ const pruned = pruneStaleRunSheetWorkspaces(workspaceRoot);
302
+ if (pruned.removed.length > 0) {
303
+ logInfo(`Pruned ${pruned.removed.length} stale run-sheet workspace(s).`);
304
+ }
204
305
  const workspaceName = `run-sheet-${release.id}`;
205
306
  const repoPathAhead = getFeatureRepoPath(workspaceRoot, workspaceName);
206
307
  lockPath = `${repoPathAhead}.lock`;
@@ -223,22 +324,39 @@ export async function runRunSheet(options) {
223
324
  syncRepoToRef(repoPath, { tag: release.tag }, token);
224
325
  }
225
326
  catch (err) {
226
- cloneError = `Could not checkout tag ${release.tag}: ${err instanceof Error ? err.message : String(err)}`;
227
- logWarning(cloneError);
327
+ const tagErr = err instanceof Error ? err.message : String(err);
328
+ logWarning(`Tag ${release.tag} not found — generating draft run sheet from default branch.`);
329
+ try {
330
+ const { branch } = await getDefaultBranchHead(owner, repo, token);
331
+ syncRepoToRef(repoPath, { branch }, token);
332
+ isDraft = true;
333
+ draftBaseRef = branch;
334
+ cloneError = `Tag ${release.tag} not found; fell back to default branch ${branch}. (${tagErr})`;
335
+ }
336
+ catch (fallbackErr) {
337
+ cloneError = `Could not checkout tag ${release.tag} or fall back to default branch: ${fallbackErr instanceof Error ? fallbackErr.message : String(fallbackErr)}`;
338
+ logWarning(cloneError);
339
+ }
228
340
  }
229
341
  }
230
342
  catch (err) {
231
343
  cloneError = err instanceof Error ? err.message : String(err);
232
344
  logWarning(`Clone failed: ${cloneError}`);
233
345
  }
234
- const c = await fetchCommitsBetween(owner, repo, release.previous_tag, release.tag, token);
346
+ const head = isDraft && draftBaseRef ? draftBaseRef : release.tag;
347
+ const c = await fetchCommitsBetween(owner, repo, release.previous_tag, head, token);
235
348
  commits = c.text;
349
+ commitsList = c.list;
236
350
  commitsTruncated = c.truncated;
351
+ commitsError = c.error ?? null;
237
352
  }
238
353
  else {
239
354
  cloneError = 'Product is not linked to a GitHub repository';
240
355
  }
241
- const { rendered, missing } = await renderTemplate(template, {
356
+ const draftNotice = isDraft
357
+ ? `> ⚠️ **Draft run sheet** — tag \`${release.tag}\` does not exist on GitHub yet. Rendered from default branch${draftBaseRef ? ` \`${draftBaseRef}\`` : ''}; regenerate after the tag is cut for final content.`
358
+ : '';
359
+ const { rendered, missing, filesRead } = await renderTemplate(template, {
242
360
  name: product?.name ?? 'Unknown product',
243
361
  github_repository_full_name: product?.github_repository_full_name ?? null,
244
362
  }, {
@@ -251,7 +369,14 @@ export async function runRunSheet(options) {
251
369
  previous_published_at: release.previous_published_at,
252
370
  diff_summary: release.diff_summary,
253
371
  diff_stats: release.diff_stats ?? {},
254
- }, repoDir, commits);
372
+ }, repoDir, commits, draftNotice, commitsList);
373
+ const inputHash = computeInputHash({
374
+ template,
375
+ tag: release.tag,
376
+ previousTag: release.previous_tag,
377
+ filesRead,
378
+ commitShas: commitsList.map((c) => c.sha),
379
+ });
255
380
  try {
256
381
  const saved = await upsertRunSheet({
257
382
  release_id: release.id,
@@ -261,9 +386,14 @@ export async function runRunSheet(options) {
261
386
  metadata: {
262
387
  missing_placeholders: missing,
263
388
  clone_error: cloneError,
389
+ commits_error: commitsError,
264
390
  repo: product?.github_repository_full_name ?? null,
265
391
  tag: release.tag,
266
392
  commits_truncated: commitsTruncated,
393
+ is_draft: isDraft,
394
+ draft_base_ref: draftBaseRef,
395
+ input_hash: inputHash,
396
+ commits_count: commitsList.length,
267
397
  },
268
398
  generated_at: new Date().toISOString(),
269
399
  }, verbose);
@@ -276,7 +406,9 @@ export async function runRunSheet(options) {
276
406
  summary: `Rendered ${rendered.length} characters`,
277
407
  missingPlaceholders: missing,
278
408
  cloneError,
409
+ commitsError,
279
410
  commitsTruncated,
411
+ isDraft,
280
412
  };
281
413
  }
282
414
  catch (err) {
@@ -1,10 +1,9 @@
1
1
  /**
2
2
  * Pure template-rendering logic for run sheets.
3
3
  *
4
- * Intentionally identical to `web/src/services/run-sheets/render.ts`any
5
- * change here should mirror the web side (and its unit tests) so the CLI
6
- * and the web `/api/releases/[id]/generate-run-sheet` route produce the
7
- * exact same output for the same template + release.
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.
8
7
  */
9
8
  export declare const MAX_FILE_INCLUDE_BYTES: number;
10
9
  export declare const MAX_TOTAL_FILE_BYTES: number;
@@ -33,10 +32,29 @@ export declare function safeReadRepoFile(repoDir: string, relPath: string, remai
33
32
  export interface RenderResult {
34
33
  rendered: string;
35
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;
36
47
  }
37
48
  /**
38
49
  * Strip Markdown-style backslash escapes inside `{{ ... }}` spans so the
39
50
  * Rich-text editor roundtrip doesn't break placeholder matching.
40
51
  */
41
52
  export declare function normalizeTemplate(template: string): string;
42
- export declare function renderTemplate(template: string, product: TemplateProduct, release: TemplateRelease, repoDir: string | null, commits: string): Promise<RenderResult>;
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,12 +1,11 @@
1
1
  /**
2
2
  * Pure template-rendering logic for run sheets.
3
3
  *
4
- * Intentionally identical to `web/src/services/run-sheets/render.ts`any
5
- * change here should mirror the web side (and its unit tests) so the CLI
6
- * and the web `/api/releases/[id]/generate-run-sheet` route produce the
7
- * exact same output for the same template + release.
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.
8
7
  */
9
- import { statSync } from 'fs';
8
+ import { lstatSync, realpathSync, statSync } from 'fs';
10
9
  import { readFile } from 'fs/promises';
11
10
  import { join, resolve } from 'path';
12
11
  export const MAX_FILE_INCLUDE_BYTES = 256 * 1024;
@@ -32,6 +31,29 @@ export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
32
31
  if (abs !== repoDirResolved && !abs.startsWith(`${repoDirResolved}/`)) {
33
32
  return { content: null, bytes: 0, reason: 'path escape' };
34
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
+ }
35
57
  let size;
36
58
  try {
37
59
  const stat = statSync(abs);
@@ -73,32 +95,134 @@ export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
73
95
  export function normalizeTemplate(template) {
74
96
  return template.replace(/\{\{([^}]*)\}\}/g, (_match, inner) => `{{${inner.replace(/\\([_.\-\\/])/g, '$1')}}}`);
75
97
  }
76
- // eslint-disable-next-line complexity -- template expansion branches per placeholder type
77
- export async function renderTemplate(template, product, release, repoDir, commits) {
78
- const missing = [];
79
- const stats = release.diff_stats ?? {};
80
- const normalized = normalizeTemplate(template);
81
- const simpleVars = {
82
- product_name: product.name,
83
- release_tag: release.tag,
84
- release_name: release.name ?? release.tag,
85
- release_body: release.body ?? '',
86
- release_url: release.url ?? '',
87
- previous_tag: release.previous_tag ?? '',
88
- published_at: release.published_at ?? '',
89
- previous_published_at: release.previous_published_at ?? '',
90
- diff_summary: release.diff_summary ?? '',
91
- files_changed: String(stats.files_changed ?? ''),
92
- additions: String(stats.additions ?? ''),
93
- deletions: String(stats.deletions ?? ''),
94
- commits_count: String(stats.commits_count ?? stats.total_commits ?? ''),
95
- repository: product.github_repository_full_name ?? '',
96
- generated_at: new Date().toISOString(),
97
- commits,
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),
98
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 = [];
99
223
  const fileRegex = /\{\{\s*file:([^}]+?)\s*\}\}/g;
100
224
  const fileMatches = [];
101
- for (const m of normalized.matchAll(fileRegex)) {
225
+ for (const m of template.matchAll(fileRegex)) {
102
226
  fileMatches.push({ match: m[0], path: m[1].trim() });
103
227
  }
104
228
  let remainingBudget = MAX_TOTAL_FILE_BYTES;
@@ -112,7 +236,6 @@ export async function renderTemplate(template, product, release, repoDir, commit
112
236
  fileResults.set(match, `<!-- file ${path} unavailable: repo not cloned -->`);
113
237
  continue;
114
238
  }
115
- // eslint-disable-next-line no-await-in-loop -- sequential so the byte budget is enforced
116
239
  const res = await safeReadRepoFile(repoDir, path, remainingBudget);
117
240
  if (res.content === null) {
118
241
  missing.push(`file:${path} (${res.reason ?? 'unavailable'})`);
@@ -120,10 +243,49 @@ export async function renderTemplate(template, product, release, repoDir, commit
120
243
  }
121
244
  else {
122
245
  remainingBudget -= res.bytes;
246
+ filesRead.push({ path, bytes: res.bytes });
123
247
  fileResults.set(match, res.content);
124
248
  }
125
249
  }
126
- let rendered = normalized.replace(fileRegex, (match) => fileResults.get(match) ?? match);
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);
127
289
  rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
128
290
  if (key in simpleVars) {
129
291
  return simpleVars[key];
@@ -131,5 +293,5 @@ export async function renderTemplate(template, product, release, repoDir, commit
131
293
  missing.push(key);
132
294
  return match;
133
295
  });
134
- return { rendered, missing };
296
+ return { rendered, missing, filesRead: fileExpansion.filesRead };
135
297
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Unit tests for smoke-test agent response parsing.
3
+ */
4
+ export {};
@@ -0,0 +1,84 @@
1
+ /**
2
+ * Unit tests for smoke-test agent response parsing.
3
+ */
4
+ import assert from 'node:assert';
5
+ import { describe, it } from 'node:test';
6
+ import { extractJson, findBalancedJsonObject } from '../agent.js';
7
+ void describe('extractJson', () => {
8
+ void it('parses a plain JSON object', () => {
9
+ const raw = `{
10
+ "summary": "one thing changed",
11
+ "test_cases": [
12
+ { "name": "login still works", "description": "step 1", "is_critical": true }
13
+ ]
14
+ }`;
15
+ const parsed = extractJson(raw);
16
+ assert.strictEqual(parsed.summary, 'one thing changed');
17
+ assert.strictEqual(parsed.test_cases.length, 1);
18
+ assert.strictEqual(parsed.test_cases[0].is_critical, true);
19
+ });
20
+ void it('strips ```json fences', () => {
21
+ const raw = '```json\n{"summary":"x","test_cases":[{"name":"a","description":"b"}]}\n```';
22
+ const parsed = extractJson(raw);
23
+ assert.strictEqual(parsed.summary, 'x');
24
+ assert.strictEqual(parsed.test_cases[0].name, 'a');
25
+ });
26
+ void it('strips unlabeled fences', () => {
27
+ const raw = '```\n{"summary":"x","test_cases":[]}\n```';
28
+ const parsed = extractJson(raw);
29
+ assert.deepStrictEqual(parsed.test_cases, []);
30
+ });
31
+ void it('tolerates leading and trailing prose', () => {
32
+ const raw = `Here you go:
33
+ {"summary":"x","test_cases":[{"name":"a","description":"b"}]}
34
+
35
+ Hope that helps.`;
36
+ const parsed = extractJson(raw);
37
+ assert.strictEqual(parsed.summary, 'x');
38
+ });
39
+ void it('throws when test_cases is missing', () => {
40
+ assert.throws(() => extractJson('{"summary":"x"}'), /test_cases/);
41
+ });
42
+ void it('throws on invalid JSON', () => {
43
+ assert.throws(() => extractJson('not json'));
44
+ });
45
+ void it('picks the first balanced object when prose contains decoy braces', () => {
46
+ const raw = 'Note: { pseudo-json example } but the real answer is:\n' +
47
+ '{"summary":"x","test_cases":[{"name":"a","description":"b"}]}\n' +
48
+ 'Let me know if you want changes.';
49
+ const parsed = extractJson(raw);
50
+ assert.strictEqual(parsed.summary, 'x');
51
+ assert.strictEqual(parsed.test_cases[0].name, 'a');
52
+ });
53
+ void it('preserves braces inside JSON string values', () => {
54
+ const raw = '{"summary":"changes to {pricing} and {checkout}","test_cases":[{"name":"n","description":"d"}]}';
55
+ const parsed = extractJson(raw);
56
+ assert.strictEqual(parsed.summary, 'changes to {pricing} and {checkout}');
57
+ });
58
+ void it('throws when test_cases is not an array', () => {
59
+ assert.throws(() => extractJson('{"summary":"x","test_cases":"nope"}'), /test_cases/);
60
+ });
61
+ });
62
+ void describe('findBalancedJsonObject', () => {
63
+ void it('returns null when there is no opening brace', () => {
64
+ assert.strictEqual(findBalancedJsonObject('no braces here'), null);
65
+ });
66
+ void it('returns the first balanced top-level object', () => {
67
+ assert.strictEqual(findBalancedJsonObject('prefix {"a": 1} and {"b": 2} suffix'), '{"a": 1}');
68
+ });
69
+ void it('handles nested braces', () => {
70
+ const text = 'xxx {"a": {"b": {"c": 1}}} yyy';
71
+ assert.strictEqual(findBalancedJsonObject(text), '{"a": {"b": {"c": 1}}}');
72
+ });
73
+ void it('ignores braces inside strings', () => {
74
+ const text = '{"msg": "this has { and } inside", "ok": true}';
75
+ assert.strictEqual(findBalancedJsonObject(text), text);
76
+ });
77
+ void it('handles escaped quotes inside strings', () => {
78
+ const text = '{"msg": "she said \\"hi\\" {not object}"}';
79
+ assert.strictEqual(findBalancedJsonObject(text), text);
80
+ });
81
+ void it('returns null on unbalanced input', () => {
82
+ assert.strictEqual(findBalancedJsonObject('{"a": 1'), null);
83
+ });
84
+ });