edsger 0.45.1 → 0.47.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 (140) 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/commands/workflow/executors/phase-executor.js +3 -1
  26. package/dist/commands/workflow/phase-orchestrator.js +1 -2
  27. package/dist/config/__tests__/config.test.d.ts +4 -0
  28. package/dist/config/__tests__/config.test.js +286 -0
  29. package/dist/config/__tests__/feature-status.test.d.ts +4 -0
  30. package/dist/config/__tests__/feature-status.test.js +111 -0
  31. package/dist/errors/__tests__/index.test.d.ts +4 -0
  32. package/dist/errors/__tests__/index.test.js +349 -0
  33. package/dist/index.js +0 -0
  34. package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +5 -0
  35. package/dist/phases/app-store-generation/__tests__/agent.test.js +142 -0
  36. package/dist/phases/app-store-generation/__tests__/context.test.d.ts +4 -0
  37. package/dist/phases/app-store-generation/__tests__/context.test.js +284 -0
  38. package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +4 -0
  39. package/dist/phases/app-store-generation/__tests__/prompts.test.js +122 -0
  40. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +5 -0
  41. package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +826 -0
  42. package/dist/phases/app-store-generation/index.js +1 -2
  43. package/dist/phases/branch-planning/index.js +1 -2
  44. package/dist/phases/bug-fixing/analyzer.js +1 -2
  45. package/dist/phases/code-implementation/index.js +1 -2
  46. package/dist/phases/code-refine/index.js +1 -2
  47. package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
  48. package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
  49. package/dist/phases/code-review/index.js +1 -2
  50. package/dist/phases/code-testing/analyzer.js +1 -2
  51. package/dist/phases/feature-analysis/index.js +1 -2
  52. package/dist/phases/functional-testing/analyzer.js +1 -2
  53. package/dist/phases/growth-analysis/index.js +1 -2
  54. package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +4 -0
  55. package/dist/phases/intelligence-analysis/__tests__/context.test.js +192 -0
  56. package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +13 -0
  57. package/dist/phases/intelligence-analysis/__tests__/matching.test.js +154 -0
  58. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +5 -0
  59. package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +378 -0
  60. package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +4 -0
  61. package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +33 -0
  62. package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
  63. package/dist/phases/pr-execution/__tests__/file-assigner.test.js +303 -0
  64. package/dist/phases/pr-execution/index.js +1 -0
  65. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +1 -0
  66. package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +157 -0
  67. package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
  68. package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
  69. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
  70. package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
  71. package/dist/phases/pr-resolve/__tests__/types.test.d.ts +1 -0
  72. package/dist/phases/pr-resolve/__tests__/types.test.js +43 -0
  73. package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
  74. package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
  75. package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
  76. package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
  77. package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
  78. package/dist/phases/pr-review/__tests__/review-comments.test.js +110 -0
  79. package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
  80. package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
  81. package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
  82. package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
  83. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
  84. package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
  85. package/dist/phases/pr-splitting/index.js +1 -2
  86. package/dist/phases/release-sync/github.d.ts +12 -0
  87. package/dist/phases/release-sync/github.js +39 -0
  88. package/dist/phases/release-sync/snapshot.js +0 -1
  89. package/dist/phases/run-sheet/index.d.ts +15 -0
  90. package/dist/phases/run-sheet/index.js +161 -29
  91. package/dist/phases/run-sheet/render.d.ts +23 -5
  92. package/dist/phases/run-sheet/render.js +195 -31
  93. package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
  94. package/dist/phases/smoke-test/__tests__/agent.test.js +84 -0
  95. package/dist/phases/smoke-test/__tests__/github.test.d.ts +9 -0
  96. package/dist/phases/smoke-test/__tests__/github.test.js +120 -0
  97. package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +8 -0
  98. package/dist/phases/smoke-test/__tests__/snapshot.test.js +93 -0
  99. package/dist/phases/smoke-test/agent.js +2 -4
  100. package/dist/phases/smoke-test/github.d.ts +54 -0
  101. package/dist/phases/smoke-test/github.js +101 -0
  102. package/dist/phases/smoke-test/index.js +11 -6
  103. package/dist/phases/smoke-test/snapshot.d.ts +27 -0
  104. package/dist/phases/smoke-test/snapshot.js +157 -0
  105. package/dist/phases/technical-design/index.js +1 -2
  106. package/dist/phases/test-cases-analysis/index.js +1 -2
  107. package/dist/phases/user-stories-analysis/index.js +1 -2
  108. package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +1 -0
  109. package/dist/services/coaching/__tests__/coaching-agent.test.js +74 -0
  110. package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +1 -0
  111. package/dist/services/coaching/__tests__/coaching-loop.test.js +59 -0
  112. package/dist/services/coaching/__tests__/self-rating.test.d.ts +1 -0
  113. package/dist/services/coaching/__tests__/self-rating.test.js +188 -0
  114. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +4 -0
  115. package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +133 -0
  116. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +4 -0
  117. package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +336 -0
  118. package/dist/services/lifecycle-agent/index.d.ts +24 -0
  119. package/dist/services/lifecycle-agent/index.js +25 -0
  120. package/dist/services/lifecycle-agent/phase-criteria.d.ts +57 -0
  121. package/dist/services/lifecycle-agent/phase-criteria.js +335 -0
  122. package/dist/services/lifecycle-agent/transition-rules.d.ts +60 -0
  123. package/dist/services/lifecycle-agent/transition-rules.js +184 -0
  124. package/dist/services/lifecycle-agent/types.d.ts +190 -0
  125. package/dist/services/lifecycle-agent/types.js +12 -0
  126. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +1 -0
  127. package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +122 -0
  128. package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +1 -0
  129. package/dist/services/phase-hooks/__tests__/hook-executor.test.js +321 -0
  130. package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +1 -0
  131. package/dist/services/phase-hooks/__tests__/hook-runner.test.js +261 -0
  132. package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +1 -0
  133. package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +158 -0
  134. package/dist/services/video/__tests__/video-pipeline.test.d.ts +6 -0
  135. package/dist/services/video/__tests__/video-pipeline.test.js +249 -0
  136. package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
  137. package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
  138. package/dist/workspace/workspace-manager.js +17 -4
  139. package/package.json +1 -1
  140. 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
@@ -69,7 +74,8 @@ function isLockStale(lockPath) {
69
74
  const raw = readFileSync(lockPath, 'utf-8').trim();
70
75
  let ts;
71
76
  try {
72
- ts = JSON.parse(raw).ts;
77
+ ;
78
+ ({ ts } = JSON.parse(raw));
73
79
  }
74
80
  catch {
75
81
  ts = Number(raw);
@@ -118,6 +124,87 @@ export function releaseFileLock(lockPath) {
118
124
  // Best-effort — lock will expire via LOCK_STALE_MS anyway.
119
125
  }
120
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
+ }
121
208
  function runSheetIsFresh(existing, template, tag) {
122
209
  if (!existing || !existing.content) {
123
210
  return false;
@@ -134,6 +221,7 @@ function runSheetIsFresh(existing, template, tag) {
134
221
  }
135
222
  return true;
136
223
  }
224
+ // eslint-disable-next-line complexity -- orchestration with cache, clone, lock, render
137
225
  export async function runRunSheet(options) {
138
226
  const { releaseId, force, verbose } = options;
139
227
  let release;
@@ -170,7 +258,7 @@ export async function runRunSheet(options) {
170
258
  // Short-circuit cache: identical template + tag, no prior clone error.
171
259
  if (!force) {
172
260
  const existing = await getRunSheetByRelease(releaseId, verbose).catch(() => null);
173
- if (runSheetIsFresh(existing, template, release.tag)) {
261
+ if (existing && runSheetIsFresh(existing, template, release.tag)) {
174
262
  logInfo(`Run sheet for ${release.tag} is already up-to-date (template unchanged).`);
175
263
  return {
176
264
  status: 'cached',
@@ -190,17 +278,30 @@ export async function runRunSheet(options) {
190
278
  // whatever metadata we have).
191
279
  let repoDir = null;
192
280
  let cloneError = null;
281
+ let commitsError = null;
193
282
  let commits = '';
283
+ let commitsList = [];
194
284
  let commitsTruncated = false;
195
285
  let lockPath = null;
196
- if (repoConfigured) {
197
- const owner = gh.owner;
198
- const repo = gh.repo;
199
- const token = gh.token;
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;
292
+ if (repoConfigured && gh.owner && gh.repo && gh.token) {
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
  }, {
@@ -250,8 +368,15 @@ export async function runRunSheet(options) {
250
368
  previous_tag: release.previous_tag,
251
369
  previous_published_at: release.previous_published_at,
252
370
  diff_summary: release.diff_summary,
253
- diff_stats: (release.diff_stats ?? {}),
254
- }, repoDir, commits);
371
+ diff_stats: release.diff_stats ?? {},
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,13 +31,37 @@ 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);
38
60
  if (!stat.isFile()) {
39
61
  return { content: null, bytes: 0, reason: 'not a file' };
40
62
  }
41
- size = stat.size;
63
+ ;
64
+ ({ size } = stat);
42
65
  }
43
66
  catch {
44
67
  return { content: null, bytes: 0, reason: 'not found' };
@@ -72,31 +95,134 @@ export async function safeReadRepoFile(repoDir, relPath, remainingBudget) {
72
95
  export function normalizeTemplate(template) {
73
96
  return template.replace(/\{\{([^}]*)\}\}/g, (_match, inner) => `{{${inner.replace(/\\([_.\-\\/])/g, '$1')}}}`);
74
97
  }
75
- export async function renderTemplate(template, product, release, repoDir, commits) {
76
- const missing = [];
77
- const stats = release.diff_stats ?? {};
78
- const normalized = normalizeTemplate(template);
79
- const simpleVars = {
80
- product_name: product.name,
81
- release_tag: release.tag,
82
- release_name: release.name ?? release.tag,
83
- release_body: release.body ?? '',
84
- release_url: release.url ?? '',
85
- previous_tag: release.previous_tag ?? '',
86
- published_at: release.published_at ?? '',
87
- previous_published_at: release.previous_published_at ?? '',
88
- diff_summary: release.diff_summary ?? '',
89
- files_changed: String(stats.files_changed ?? ''),
90
- additions: String(stats.additions ?? ''),
91
- deletions: String(stats.deletions ?? ''),
92
- commits_count: String(stats.commits_count ?? stats.total_commits ?? ''),
93
- repository: product.github_repository_full_name ?? '',
94
- generated_at: new Date().toISOString(),
95
- 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),
96
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 = [];
97
223
  const fileRegex = /\{\{\s*file:([^}]+?)\s*\}\}/g;
98
224
  const fileMatches = [];
99
- for (const m of normalized.matchAll(fileRegex)) {
225
+ for (const m of template.matchAll(fileRegex)) {
100
226
  fileMatches.push({ match: m[0], path: m[1].trim() });
101
227
  }
102
228
  let remainingBudget = MAX_TOTAL_FILE_BYTES;
@@ -110,7 +236,6 @@ export async function renderTemplate(template, product, release, repoDir, commit
110
236
  fileResults.set(match, `<!-- file ${path} unavailable: repo not cloned -->`);
111
237
  continue;
112
238
  }
113
- // eslint-disable-next-line no-await-in-loop -- sequential so the byte budget is enforced
114
239
  const res = await safeReadRepoFile(repoDir, path, remainingBudget);
115
240
  if (res.content === null) {
116
241
  missing.push(`file:${path} (${res.reason ?? 'unavailable'})`);
@@ -118,10 +243,49 @@ export async function renderTemplate(template, product, release, repoDir, commit
118
243
  }
119
244
  else {
120
245
  remainingBudget -= res.bytes;
246
+ filesRead.push({ path, bytes: res.bytes });
121
247
  fileResults.set(match, res.content);
122
248
  }
123
249
  }
124
- 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);
125
289
  rendered = rendered.replace(/\{\{\s*([a-zA-Z_][a-zA-Z0-9_]*)\s*\}\}/g, (match, key) => {
126
290
  if (key in simpleVars) {
127
291
  return simpleVars[key];
@@ -129,5 +293,5 @@ export async function renderTemplate(template, product, release, repoDir, commit
129
293
  missing.push(key);
130
294
  return match;
131
295
  });
132
- return { rendered, missing };
296
+ return { rendered, missing, filesRead: fileExpansion.filesRead };
133
297
  }
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Unit tests for smoke-test agent response parsing.
3
+ */
4
+ export {};