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.
- package/.claude/settings.local.json +3 -23
- package/dist/api/__tests__/app-store.test.d.ts +7 -0
- package/dist/api/__tests__/app-store.test.js +60 -0
- package/dist/api/__tests__/intelligence.test.d.ts +11 -0
- package/dist/api/__tests__/intelligence.test.js +315 -0
- package/dist/api/features/__tests__/feature-utils.test.d.ts +4 -0
- package/dist/api/features/__tests__/feature-utils.test.js +370 -0
- package/dist/api/features/__tests__/status-updater.test.d.ts +4 -0
- package/dist/api/features/__tests__/status-updater.test.js +88 -0
- package/dist/commands/build/__tests__/build.test.d.ts +5 -0
- package/dist/commands/build/__tests__/build.test.js +206 -0
- package/dist/commands/build/__tests__/detect-project.test.d.ts +6 -0
- package/dist/commands/build/__tests__/detect-project.test.js +160 -0
- package/dist/commands/build/__tests__/run-build.test.d.ts +6 -0
- package/dist/commands/build/__tests__/run-build.test.js +433 -0
- package/dist/commands/intelligence/__tests__/command.test.d.ts +4 -0
- package/dist/commands/intelligence/__tests__/command.test.js +48 -0
- package/dist/commands/run-sheet/index.js +6 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.d.ts +5 -0
- package/dist/commands/workflow/core/__tests__/feature-filter.test.js +316 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/pipeline-evaluator.test.js +397 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.d.ts +4 -0
- package/dist/commands/workflow/core/__tests__/state-manager.test.js +384 -0
- package/dist/config/__tests__/config.test.d.ts +4 -0
- package/dist/config/__tests__/config.test.js +286 -0
- package/dist/config/__tests__/feature-status.test.d.ts +4 -0
- package/dist/config/__tests__/feature-status.test.js +111 -0
- package/dist/errors/__tests__/index.test.d.ts +4 -0
- package/dist/errors/__tests__/index.test.js +349 -0
- package/dist/index.js +0 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/agent.test.js +142 -0
- package/dist/phases/app-store-generation/__tests__/context.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/context.test.js +284 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/app-store-generation/__tests__/prompts.test.js +122 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.d.ts +5 -0
- package/dist/phases/app-store-generation/__tests__/screenshot-composer.test.js +826 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.d.ts +1 -0
- package/dist/phases/code-review/__tests__/diff-utils.test.js +101 -0
- package/dist/phases/intelligence-analysis/__tests__/context.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/context.test.js +192 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.d.ts +13 -0
- package/dist/phases/intelligence-analysis/__tests__/matching.test.js +154 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.d.ts +5 -0
- package/dist/phases/intelligence-analysis/__tests__/orchestration.test.js +378 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.d.ts +4 -0
- package/dist/phases/intelligence-analysis/__tests__/prompts.test.js +33 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.d.ts +1 -0
- package/dist/phases/pr-execution/__tests__/file-assigner.test.js +303 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/checklist-learner.test.js +157 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/prompts.test.js +116 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/resolve-mapping.test.js +138 -0
- package/dist/phases/pr-resolve/__tests__/types.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/types.test.js +43 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.d.ts +1 -0
- package/dist/phases/pr-resolve/__tests__/workspace.test.js +111 -0
- package/dist/phases/pr-review/__tests__/prompts.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/prompts.test.js +49 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.d.ts +1 -0
- package/dist/phases/pr-review/__tests__/review-comments.test.js +110 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/agent-utils.test.js +91 -0
- package/dist/phases/pr-shared/__tests__/context.test.d.ts +1 -0
- package/dist/phases/pr-shared/__tests__/context.test.js +94 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.d.ts +1 -0
- package/dist/phases/pr-splitting/__tests__/import-dep-validator.test.js +331 -0
- package/dist/phases/release-sync/github.d.ts +12 -0
- package/dist/phases/release-sync/github.js +39 -0
- package/dist/phases/release-sync/snapshot.js +0 -1
- package/dist/phases/run-sheet/index.d.ts +15 -0
- package/dist/phases/run-sheet/index.js +154 -22
- package/dist/phases/run-sheet/render.d.ts +23 -5
- package/dist/phases/run-sheet/render.js +193 -31
- package/dist/phases/smoke-test/__tests__/agent.test.d.ts +4 -0
- package/dist/phases/smoke-test/__tests__/agent.test.js +84 -0
- package/dist/phases/smoke-test/__tests__/github.test.d.ts +9 -0
- package/dist/phases/smoke-test/__tests__/github.test.js +120 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.d.ts +8 -0
- package/dist/phases/smoke-test/__tests__/snapshot.test.js +93 -0
- package/dist/phases/smoke-test/github.d.ts +54 -0
- package/dist/phases/smoke-test/github.js +101 -0
- package/dist/phases/smoke-test/snapshot.d.ts +27 -0
- package/dist/phases/smoke-test/snapshot.js +157 -0
- package/dist/services/coaching/__tests__/coaching-agent.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-agent.test.js +74 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/coaching-loop.test.js +59 -0
- package/dist/services/coaching/__tests__/self-rating.test.d.ts +1 -0
- package/dist/services/coaching/__tests__/self-rating.test.js +188 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/phase-criteria.test.js +133 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.d.ts +4 -0
- package/dist/services/lifecycle-agent/__tests__/transition-rules.test.js +336 -0
- package/dist/services/lifecycle-agent/index.d.ts +24 -0
- package/dist/services/lifecycle-agent/index.js +25 -0
- package/dist/services/lifecycle-agent/phase-criteria.d.ts +57 -0
- package/dist/services/lifecycle-agent/phase-criteria.js +335 -0
- package/dist/services/lifecycle-agent/transition-rules.d.ts +60 -0
- package/dist/services/lifecycle-agent/transition-rules.js +184 -0
- package/dist/services/lifecycle-agent/types.d.ts +190 -0
- package/dist/services/lifecycle-agent/types.js +12 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/bindings-fetcher.test.js +122 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-executor.test.js +321 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/hook-runner.test.js +261 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.d.ts +1 -0
- package/dist/services/phase-hooks/__tests__/plugin-loader.test.js +158 -0
- package/dist/services/video/__tests__/video-pipeline.test.d.ts +6 -0
- package/dist/services/video/__tests__/video-pipeline.test.js +249 -0
- package/dist/workspace/__tests__/workspace-manager.test.d.ts +7 -0
- package/dist/workspace/__tests__/workspace-manager.test.js +52 -0
- package/dist/workspace/workspace-manager.js +17 -4
- package/package.json +1 -1
- 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 {
|
|
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 {
|
|
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
|
|
32
|
-
const
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
-
|
|
43
|
-
|
|
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
|
-
|
|
227
|
-
logWarning(
|
|
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
|
|
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
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
|
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
|
-
|
|
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,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
|
+
});
|