baodan/.agents/skills/impeccable/scripts/live-accept.mjs
wsb1224 e3479f0546 上线阻断问题全部修复
#	问题	修复	文件
1	前端构建失败(引号错误)	size="small type=" → size="small" type="	PosterHistoryPage.vue
2	migrate_014 ORM vs 缺失列	全部改为原始 SQL,不再引用 ORM 模型	migrate_014.py
3	cleanup 字段名错误	output_path → ppt_path	cleanup.py
4	文案生成 case 越权	添加 case.user_id != user_id 校验	poster/service.py
5	存储路径未接通持久化卷	全部改用 get_storage_root()(默认 /app/api/storage/insurance)	config.py, ppt/routes.py, poster/service.py, poster/tasks.py
高风险问题修复
#	问题	修复	文件
6	migrate_019 rollback 撤销成功字段	每个 ALTER 后立即 commit,失败只回滚当前语句	migrate_019.py
7	迁移锁 Windows 不兼容 + 句柄未持久化	全局变量保存锁句柄,支持 Windows msvcrt	api/insurance/db/__init__.py
8	PDF 校验异常时放行	异常返回 False(文件损坏)	security.py
9	健康检查始终返回成功	缺少关键资源时返回 503 + missing 列表	poster/routes.py
10	短密钥掩码泄露原值	≤4 字符返回 ****	ppt_admin_service.py
11	设置无键名白名单	添加 _ALLOWED_SETTING_KEYS 白名单	ppt_admin_service.py
12	容器重启任务永久 stuck	添加 recover_stale_tasks() 启动恢复函数	poster/tasks.py, ppt/parse_worker.py
2026-07-27 13:52:09 +08:00

953 lines
35 KiB
JavaScript

/**
* CLI helper: deterministic accept/discard of variant sessions.
*
* Usage:
* node live-accept.mjs --id SESSION_ID --discard
* node live-accept.mjs --id SESSION_ID --variant N
*
* For discard: removes the entire variant wrapper and restores the original.
* For accept: replaces the wrapper with the chosen variant's content. If the
* session had a colocated <style> block, it's preserved with carbonize markers
* for a background agent to integrate into the project's CSS.
*
* Output: JSON to stdout.
*/
import fs from 'node:fs';
import path from 'node:path';
import { isGeneratedFile } from './lib/is-generated.mjs';
import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs';
import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs';
import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs';
import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs';
import { withSourceLockSync } from './live/source-lock.mjs';
import {
applyDeferredSvelteComponentAccepts,
findSvelteComponentManifest,
inlineSvelteComponentAccept,
removeSvelteComponentSession,
} from './live/svelte-component.mjs';
const ACCEPT_LOCK_WAIT_MS = 1_000;
// Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same
// value arriving over HTTP.
const VARIANT_NUM_PATTERN = /^[0-9]{1,3}$/;
/**
* A thrown accept/discard is a real failure, not a manual handoff.
*
* live/completion.mjs only classifies a result as `error` when it carries
* `mode: 'error'`; anything else unhandled falls through to `agent_done` with a
* successful ack, and reference/live.md then tells the agent to finish the edit
* by hand. That is right for the documented fallback paths and wrong here: a
* `source_locked` contention needs a retry (hand-editing races the publisher
* holding the lock), and a crash needs surfacing, not a hand-applied guess.
*/
function operationFailure(err, extra = {}) {
return { handled: false, mode: 'error', error: err.message, ...extra };
}
/**
* Mark an unhandled preview-path result as a real failure.
*
* operationFailure only covers results built from a *thrown* error. The accept
* implementations also return `{handled: false, error}` for their own checks
* (variant missing, template empty, original text ambiguous), and those arrived
* without `mode`, so completion.mjs classified them as agent_done and
* reference/live.md routed the agent to "read file, find markers, edit".
*
* That handoff only makes sense for a plain wrapper session, which is the one
* shape with markers in the user's source to edit. Component and isolated
* artifact previews keep the source clean until Accept, so there is nothing to
* hand-edit and an unhandled result is always a failure. `previewMode` is
* exactly that discriminator: only the preview branches set it.
*/
function markPreviewFailure(result) {
if (result?.handled === false && !result.mode && result.previewMode) {
return { ...result, mode: 'error' };
}
return result;
}
// ---------------------------------------------------------------------------
// CLI
// ---------------------------------------------------------------------------
export async function acceptCli() {
const args = process.argv.slice(2);
if (args.includes('--help') || args.includes('-h')) {
console.log(`Usage: node live-accept.mjs [options]
Deterministic accept/discard for live variant sessions.
Modes:
--discard Remove variants, restore original
--variant N Accept variant N, discard the rest
Required:
--id SESSION_ID Session ID of the variant wrapper
Options:
--page-url URL Current browser page URL; scopes staged copy-edit cleanup
--defer-source-write
Deprecated compatibility flag. Svelte component accepts
now write the real source immediately.
Output (JSON):
{ handled, file, carbonize }`);
process.exit(0);
}
const id = argVal(args, '--id');
const variantNum = argVal(args, '--variant');
const paramValuesRaw = argVal(args, '--param-values');
const pageUrl = argVal(args, '--page-url');
const isDiscard = args.includes('--discard');
if (!id) { console.error('Missing --id'); process.exit(1); }
// `id` becomes a path segment (accept receipts, preview manifests, generated
// component dirs). Reject separators and traversal here so one check covers
// every downstream sink.
try { safeSessionId(id); } catch { console.error('Invalid --id'); process.exit(1); }
if (!isDiscard && !variantNum) { console.error('Need --discard or --variant N'); process.exit(1); }
// `variantNum` is interpolated into a RegExp and into the markup written back
// to source. The browser and the /events schema both constrain it to digits;
// enforce the same here, or `--variant '.*'` matches the `original` block
// first and silently accepts the original while reporting success.
if (!isDiscard && !VARIANT_NUM_PATTERN.test(variantNum)) {
console.error('Invalid --variant');
process.exit(1);
}
const requestedOperation = isDiscard ? 'discard' : 'accept';
const priorReceipt = readAcceptReceipt(process.cwd(), id);
if (priorReceipt) {
const sameOperation = priorReceipt.operation === requestedOperation
&& (isDiscard || String(priorReceipt.variantId) === String(variantNum));
console.log(JSON.stringify(sameOperation
? { ...priorReceipt.result, handled: true, alreadyApplied: true }
: {
// mode: 'error' is what marks this a real failure rather than a manual
// handoff. Without it, live/completion.mjs classifies the reply as
// agent_done and reference/live.md tells the agent to "read file, find
// markers, edit" by hand — which would apply a second, conflicting
// accept on top of the one the receipt already recorded.
handled: false,
mode: 'error',
error: 'accept_receipt_conflict',
priorOperation: priorReceipt.operation,
priorVariantId: priorReceipt.variantId ?? null,
}));
return;
}
const emitResult = (rawResult) => {
const result = markPreviewFailure(rawResult);
if (result?.handled !== false) {
writeAcceptReceipt(process.cwd(), id, {
operation: requestedOperation,
variantId: isDiscard ? null : String(variantNum),
result,
});
}
console.log(JSON.stringify(result));
};
let paramValues = null;
if (paramValuesRaw) {
try { paramValues = JSON.parse(paramValuesRaw); }
catch { paramValues = null; } // malformed blob: skip the comment rather than failing the accept
}
// Find the file containing this session's markers
const found = findSessionFile(id, process.cwd());
const svelteComponentManifest = found ? null : findSvelteComponentManifest(id, process.cwd());
if (!found && !svelteComponentManifest) {
console.log(JSON.stringify({ handled: false, error: 'Session markers not found for id: ' + id }));
process.exit(0);
}
if (svelteComponentManifest) {
if (isDiscard) {
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'discard:' + id,
() => {
removeSvelteComponentSession(id, process.cwd());
return { handled: true };
},
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = operationFailure(err);
}
emitResult({
...result,
file: svelteComponentManifest.sourceFile,
carbonize: false,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
});
return;
}
let result;
try {
result = withSourceLockSync(
path.resolve(process.cwd(), svelteComponentManifest.sourceFile),
'accept:' + id,
() => inlineSvelteComponentAccept(
svelteComponentManifest,
variantNum,
paramValues,
process.cwd(),
),
{ waitMs: ACCEPT_LOCK_WAIT_MS },
);
} catch (err) {
result = operationFailure(err, {
file: svelteComponentManifest.sourceFile,
sourceFile: svelteComponentManifest.sourceFile,
previewMode: 'svelte-component',
componentDir: svelteComponentManifest.componentDir,
});
}
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + result.file + '. See reference/live.md "Required after accept".';
}
emitResult({ handled: result.handled !== false, ...result });
return;
}
const { file: targetFile, content, lines } = found;
const relFile = path.relative(process.cwd(), targetFile);
const previewBlock = findMarkerBlock(id, lines);
const sourceShadowPreview = previewBlock
? readSourceShadowPreviewMeta(content, id)
: null;
if (sourceShadowPreview) {
console.log(JSON.stringify({
handled: false,
error: 'source_shadow_preview_deprecated',
hint: 'Svelte live mode now uses svelte-component injection. Re-wrap the element and regenerate variants.',
}));
process.exit(0);
}
if (isGeneratedFile(targetFile, { cwd: process.cwd() })) {
console.log(JSON.stringify({
handled: false,
mode: 'fallback',
file: relFile,
hint: 'Session is in a generated file. Persist the accepted variant in source; do not rely on this script.',
}));
process.exit(0);
}
if (isDiscard) {
let result;
// handleDiscard takes the source lock, which throws SOURCE_LOCKED under
// contention. Without this catch the CLI exits non-zero with empty stdout
// and the agent gets no JSON to act on.
try {
result = handleDiscard(id, lines, targetFile);
} catch (err) {
emitResult(operationFailure(err, { file: relFile }));
return;
}
emitResult({ handled: true, file: relFile, carbonize: false, ...result });
} else {
let result;
try {
result = handleAccept(id, variantNum, lines, targetFile, paramValues);
} catch (err) {
emitResult(operationFailure(err, { file: relFile }));
return;
}
const acceptedOriginalText = result.acceptedOriginalText || '';
delete result.acceptedOriginalText;
// Single-line attention-grabber when cleanup is required. The full
// five-step checklist lives in reference/live.md (loaded once per
// session); repeating it per-event would waste tokens.
if (result.carbonize) {
result.todo = 'REQUIRED before next poll: carbonize cleanup in ' + relFile + '. See reference/live.md "Required after accept".';
}
// Scrub stash entries whose text appeared inside the just-replaced
// original wrap block. The accept embodies those manual edits (wrap was
// buffer-aware), so only those scoped ops are redundant.
if (result.handled !== false) {
try {
scrubManualEditsAgainstOriginalBlock(acceptedOriginalText, process.cwd(), pageUrl);
} catch {
// Non-fatal; the buffer stays as-is and the user can discard later.
}
}
emitResult({ handled: true, file: relFile, ...result });
}
}
/**
* After a variant accept rewrites one wrapper, drop only buffer ops whose
* text appeared inside that wrapper's original block. The previous file-wide
* scrub dropped unrelated staged edits from other components/files whenever
* their originalText wasn't present in the just-accepted file.
*
* Match both originalText and newText because live-wrap rewrites the original
* preview block to reflect pending manual edits before variants are generated.
*/
function scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd = process.cwd(), pageUrl = null) {
const originalBlock = String(originalBlockText || '');
if (!originalBlock) return;
if (!pageUrl) return;
const buffer = readManualEditsBuffer(cwd);
if (buffer.entries.length === 0) return;
let mutated = false;
for (const entry of buffer.entries) {
if (entry.pageUrl !== pageUrl) continue;
const before = entry.ops.length;
entry.ops = entry.ops.filter((op) => {
return !manualEditOpAppearsInBlock(op, originalBlock);
});
if (entry.ops.length !== before) mutated = true;
}
buffer.entries = buffer.entries.filter((entry) => entry.ops.length > 0);
if (mutated) writeManualEditsBuffer(cwd, buffer);
}
function manualEditOpAppearsInBlock(op, originalBlock) {
const candidates = [op?.newText, op?.originalText]
.filter((text) => typeof text === 'string' && text.length > 0);
return candidates.some((text) => originalBlockHasExactManualText(originalBlock, text));
}
function originalBlockHasExactManualText(originalBlock, text) {
const needle = normalizeManualEditText(text);
if (!needle) return false;
return manualEditTextSegments(originalBlock).some((segment) => segment === needle);
}
function manualEditTextSegments(source) {
return String(source || '')
.replace(/<[^>]*>/g, '\n')
.replace(/\{\/\*[\s\S]*?\*\/\}/g, '\n')
.replace(/<!--[\s\S]*?-->/g, '\n')
.split(/\n+/)
.map(normalizeManualEditText)
.filter(Boolean);
}
function normalizeManualEditText(text) {
return String(text || '').replace(/\s+/g, ' ').trim();
}
// Compatibility export for older tests/callers. The unsafe file-wide scrub was
// removed; callers must pass accepted original-block text for scoped cleanup.
function scrubManualEditsAgainstFile(_targetFile, cwd = process.cwd(), originalBlockText = '', pageUrl = null) {
return scrubManualEditsAgainstOriginalBlock(originalBlockText, cwd, pageUrl);
}
// ---------------------------------------------------------------------------
// Discard
// ---------------------------------------------------------------------------
function handleDiscard(id, _lines, targetFile) {
return withSourceLockSync(targetFile, 'discard:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleDiscardUnlocked(id, lines, targetFile);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleDiscardUnlocked(id, lines, targetFile) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const original = extractOriginal(lines, block);
const isJsx = detectCommentSyntax(targetFile).open === '{/*';
const replaceRange = expandReplaceRange(block, lines, isJsx);
// Restore at the line we're actually replacing FROM, not the marker line.
// For JSX wrappers the marker comments live INSIDE the outer `<div>`, so
// `block.start` sits 2 spaces deeper than the original element. Using that
// as the deindent base would push the restored content 2 spaces too far
// right on every JSX/TSX session. `replaceRange.start` is the outer wrapper
// line, which is at the original element's indent for both HTML and JSX.
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
const restored = deindentContent(original, indent);
const newLines = [
...lines.slice(0, replaceRange.start),
...restored,
...lines.slice(replaceRange.end + 1),
];
fs.writeFileSync(targetFile, newLines.join('\n'), 'utf-8');
return {};
}
// ---------------------------------------------------------------------------
// Accept
// ---------------------------------------------------------------------------
/**
* Build carbonize stitch-in lines. JSX targets occupy a single child slot
* (ternary branch, return value, etc.) — the same constraint as live-wrap.
* When isJsx, tuck markers + <style> + variant wrapper inside one outer
* <div data-impeccable-carbonize> so the slot keeps a single root node.
*/
function buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
}) {
const lines = [];
if (!cssContent) {
lines.push(...restored);
return lines;
}
const variantStyleAttr = isJsx
? "style={{ display: 'contents' }}"
: 'style="display: contents"';
const pushCarbonizeBody = (bodyIndent) => {
const bodyRestored = reindentContent(restored, indent, bodyIndent + ' ');
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-start ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<style data-impeccable-css="' + id + '">' + (isJsx ? '{`' : ''));
for (const cssLine of cssContent) {
lines.push(bodyIndent + cssLine.trimStart());
}
lines.push(bodyIndent + (isJsx ? '`}</style>' : '</style>'));
if (paramValues && Object.keys(paramValues).length > 0) {
lines.push(
bodyIndent + commentSyntax.open + ' impeccable-param-values ' + id + ': ' + JSON.stringify(paramValues) + ' ' + commentSyntax.close,
);
}
lines.push(bodyIndent + commentSyntax.open + ' impeccable-carbonize-end ' + id + ' ' + commentSyntax.close);
lines.push(bodyIndent + '<div data-impeccable-variant="' + variantNum + '" ' + variantStyleAttr + '>');
lines.push(...bodyRestored);
lines.push(bodyIndent + '</div>');
};
if (isJsx) {
const wrapperStyle = 'style={{ display: "contents" }}';
lines.push(indent + '<div data-impeccable-carbonize="' + id + '" ' + wrapperStyle + '>');
pushCarbonizeBody(indent + ' ');
lines.push(indent + '</div>');
} else {
pushCarbonizeBody(indent);
}
return lines;
}
function reindentContent(contentLines, fromIndent, toIndent) {
return contentLines.map((line) => {
if (line.trim() === '') return '';
if (line.startsWith(fromIndent)) return toIndent + line.slice(fromIndent.length);
return toIndent + line.trimStart();
});
}
function handleAccept(id, variantNum, _lines, targetFile, paramValues) {
return withSourceLockSync(targetFile, 'accept:' + id, () => {
const lines = fs.readFileSync(targetFile, 'utf-8').split('\n');
return handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues);
}, { waitMs: ACCEPT_LOCK_WAIT_MS });
}
function handleAcceptUnlocked(id, variantNum, lines, targetFile, paramValues) {
const built = buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues);
if (built.handled === false) return built;
fs.writeFileSync(targetFile, built.content, 'utf-8');
return {
carbonize: built.carbonize,
acceptedOriginalText: built.acceptedOriginalText,
};
}
function buildAcceptedWrappedSource(id, variantNum, lines, targetFile, paramValues) {
const block = findMarkerBlock(id, lines);
if (!block) return { handled: false, error: 'Markers not found' };
const commentSyntax = detectCommentSyntax(targetFile);
const isJsx = commentSyntax.open === '{/*';
// Anchor indent on the line we're replacing FROM (the outer wrapper),
// not on `block.start` — for JSX that's the marker comment 2 spaces
// deeper than the original element. See handleDiscard for the full
// rationale.
const replaceRange = expandReplaceRange(block, lines, isJsx);
const indent = lines[replaceRange.start].match(/^(\s*)/)[1];
// Extract the chosen variant's inner content
const variantContent = extractVariant(lines, block, variantNum);
if (!variantContent) return { handled: false, error: 'Variant ' + variantNum + ' not found' };
const originalContent = extractOriginal(lines, block);
// Extract CSS block if present
const cssContent = extractCss(lines, block, id);
// Check if carbonizing is needed:
// - CSS block exists, OR
// - variant HTML contains helper classes/attributes that need cleanup
const variantText = variantContent.join('\n');
const hasHelperAttrs = variantText.includes('data-impeccable-variant');
const needsCarbonize = !!(cssContent || hasHelperAttrs);
const restored = deindentContent(variantContent, indent);
const replacement = buildCarbonizeReplacement({
indent,
commentSyntax,
isJsx,
id,
variantNum,
cssContent,
paramValues,
restored,
});
const newLines = [
...lines.slice(0, replaceRange.start),
...replacement,
...lines.slice(replaceRange.end + 1),
];
return {
content: newLines.join('\n'),
carbonize: needsCarbonize,
acceptedOriginalText: originalContent.join('\n'),
};
}
function readSourceShadowPreviewMeta(content, id) {
const escaped = escapeRegExp(id);
const wrapperRe = new RegExp('<[^>]+data-impeccable-variants=(["\'])' + escaped + '\\1[^>]*>');
const match = String(content || '').match(wrapperRe);
if (!match) return null;
const tag = match[0];
if (readHtmlAttr(tag, 'data-impeccable-preview') !== 'source-shadow') return null;
const sourceFile = readHtmlAttr(tag, 'data-impeccable-source-file');
const sourceStartLine = Number(readHtmlAttr(tag, 'data-impeccable-source-start'));
const sourceEndLine = Number(readHtmlAttr(tag, 'data-impeccable-source-end'));
if (!sourceFile || !Number.isFinite(sourceStartLine) || !Number.isFinite(sourceEndLine)) return null;
return { sourceFile, sourceStartLine, sourceEndLine };
}
function readHtmlAttr(tag, name) {
const match = String(tag || '').match(new RegExp('\\s' + escapeRegExp(name) + '\\s*=\\s*(["\'])(.*?)\\1'));
if (!match) return null;
return decodeHtmlAttr(match[2]);
}
function decodeHtmlAttr(value) {
return String(value || '')
.replace(/&quot;/g, '"')
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
// ---------------------------------------------------------------------------
// Parsing helpers
// ---------------------------------------------------------------------------
/**
* Find the start/end marker lines for a session.
* Returns { start, end } (0-indexed line numbers) or null.
*/
function findMarkerBlock(id, lines) {
let start = -1;
let end = -1;
const startPattern = 'impeccable-variants-start ' + id;
const endPattern = 'impeccable-variants-end ' + id;
for (let i = 0; i < lines.length; i++) {
if (start === -1 && lines[i].includes(startPattern)) start = i;
if (lines[i].includes(endPattern)) { end = i; break; }
}
return (start !== -1 && end !== -1) ? { start, end, id } : null;
}
/**
* Compute the line range to REPLACE (vs. just the marker range to extract
* from). For JSX/TSX wrappers, live-wrap places the marker comments INSIDE
* the `<div data-impeccable-variants="ID">` outer wrapper so the picked
* element's JSX slot keeps a single child — a Fragment `<></>` would have
* solved the multi-sibling case but failed inside `asChild` / cloneElement
* parents with "Invalid prop supplied to React.Fragment".
*
* That means the marker block is enclosed by the wrapper `<div>` opener
* (with `data-impeccable-variants="ID"`) and its matching `</div>`. We
* walk back to the opener and forward to the closer so accept/discard
* remove the entire scaffold, not just the inner markers.
*
* Marker lines themselves stay where they were so extractOriginal /
* extractVariant / extractCss continue to walk the same range.
*/
function expandReplaceRange(block, lines, isJsx) {
if (!isJsx) return { start: block.start, end: block.end };
let { start, end } = block;
// Walk back for the wrapper `<div data-impeccable-variants="..."` opener.
// The attr may sit on a continuation line of a multi-line opening tag, so
// also walk to the line that actually contains `<div`.
for (let i = start - 1; i >= 0; i--) {
if (isVariantEndMarkerLine(lines[i], block.id)) break;
if (hasVariantWrapperAttr(lines[i], block.id)) {
let opener = i;
while (opener > 0 && !/<div\b/.test(lines[opener]) && !isVariantEndMarkerLine(lines[opener], block.id)) {
opener--;
}
if (/<div\b/.test(lines[opener])) start = opener;
break;
}
}
// Walk forward to the matching `</div>` by div-depth tracking from the
// wrapper opener. Operate on JOINED text instead of per-line: a
// multi-line self-closing JSX `<div\n className="spacer"\n/>` would
// fool per-line regex tracking (the `<div` line matches openRe but the
// `/>` line never matches selfCloseRe since it needs `<div` on the same
// line). That left depth permanently over-counted and the wrapper's
// outer `</div>` orphaned after accept/discard. Single regex with
// `[^>]*?` (which spans newlines in JS) handles either form correctly.
const joined = lines.slice(start).join('\n');
// Match either `<div … />` (self-close, group 1 is `/`), `<div … >`
// (open, group 1 is empty), or `</div>`.
const tagRe = /<div\b[^>]*?(\/?)>|<\/div\s*>/g;
let depth = 0;
let m;
while ((m = tagRe.exec(joined)) !== null) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && m[1] === '/';
if (isClose) depth--;
else if (!isSelfClose) depth++;
if (depth <= 0) {
// m.index is offset within `joined`; convert back to a file line.
const linesBefore = joined.slice(0, m.index + m[0].length).split('\n').length - 1;
const candidateEnd = start + linesBefore;
if (candidateEnd >= end) {
end = candidateEnd;
break;
}
}
}
return { start, end };
}
function escapeRegExp(value) {
return String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}
function isVariantEndMarkerLine(line, id) {
return new RegExp('impeccable-variants-end\\s+' + escapeRegExp(id) + '(?:\\s|--|\\*/|$)').test(line);
}
function hasVariantWrapperAttr(line, id) {
const escaped = escapeRegExp(id);
return new RegExp(`data-impeccable-variants\\s*=\\s*(?:"${escaped}"|'${escaped}'|\\{["']${escaped}["']\\})`).test(line);
}
/**
* Join wrapper lines into a single string with `<style>` elements removed so
* marker matching and div-depth tracking aren't confused by:
* - CSS `@scope ([data-impeccable-variant="N"])` strings that look like the
* HTML marker we're searching for
* - JSX self-closing `<style ... />` (no separate `</style>` to close on)
* - Same-line `<style>…</style>` blocks
* - Multi-line `<style>\n…\n</style>` blocks
*/
function stripStyleAndJoin(lines, block) {
const out = [];
let inStyle = false;
for (let i = block.start; i <= block.end; i++) {
let line = lines[i];
if (!inStyle) {
// Strip any complete <style> elements on this line (self-closed or
// same-line-closed), including their body content.
line = line
.replace(/<style\b[^>]*>[\s\S]*?<\/style\s*>/g, '')
.replace(/<style\b[^>]*\/\s*>/g, '');
// If a <style> opener remains (multi-line body starts here), strip from
// the opener to end-of-line and flip into skip mode.
const openerIdx = line.search(/<style\b/);
if (openerIdx !== -1) {
line = line.slice(0, openerIdx);
inStyle = true;
}
out.push(line);
} else {
// In multi-line style body; drop everything until we see </style>.
const closeIdx = line.search(/<\/style\s*>/);
if (closeIdx !== -1) {
inStyle = false;
out.push(line.slice(closeIdx).replace(/<\/style\s*>/, ''));
}
// else: skip line entirely
}
}
return out.join('\n');
}
/**
* Find the inner content of `<TAG ...attrMatch...>…</TAG>` inside `text`,
* handling nested same-tag elements via depth counting. `attrMatch` is a
* regex source fragment that must appear inside the opener tag.
* Returns the inner string (may be empty), or null if not found.
*/
function extractInnerByAttr(text, attrMatch) {
const openerRe = new RegExp('<([A-Za-z][A-Za-z0-9]*)\\b[^>]*' + attrMatch + '[^>]*>');
const openMatch = text.match(openerRe);
if (!openMatch) return null;
const tagName = openMatch[1];
const innerStart = openMatch.index + openMatch[0].length;
// Match any opener or closer of this tag name after innerStart.
// (Does not match self-closing <TAG … />, which doesn't contribute to depth.)
const tagRe = new RegExp('<(?:/)?' + tagName + '\\b[^>]*>', 'g');
tagRe.lastIndex = innerStart;
let depth = 1;
let m;
while ((m = tagRe.exec(text))) {
const isClose = m[0].startsWith('</');
const isSelfClose = !isClose && /\/\s*>$/.test(m[0]);
if (isClose) {
depth--;
if (depth === 0) return text.slice(innerStart, m.index);
} else if (!isSelfClose) {
depth++;
}
}
return null;
}
/**
* Extract the original element content from within the variant wrapper.
* Returns an array of lines.
*/
function extractOriginal(lines, block) {
const text = stripStyleAndJoin(lines, block);
const inner = extractInnerByAttr(text, 'data-impeccable-variant="original"');
if (inner === null) return [];
return inner.split('\n');
}
/**
* Extract a specific variant's inner content (stripping the wrapper div).
* Returns an array of lines, or null if not found.
*/
function extractVariant(lines, block, variantNum) {
const text = stripStyleAndJoin(lines, block);
const inner = extractInnerByAttr(text, 'data-impeccable-variant="' + variantNum + '"');
if (inner === null) return null;
const result = inner.split('\n');
// Collapse a lone empty leading/trailing line (common after string splice).
while (result.length > 1 && result[0].trim() === '') result.shift();
while (result.length > 1 && result[result.length - 1].trim() === '') result.pop();
return result.length > 0 ? result : null;
}
/**
* Extract the colocated <style> block content (between the style tags).
* Returns an array of CSS lines, or null if no style block found.
*
* Handles three shapes of `<style data-impeccable-css="ID" ...>`:
* 1. Self-closing: `<style ... />` — no body; return null (nothing to carbonize).
* 2. Same-line open+close: `<style>...</style>` — return the inner content.
* 3. Multi-line: `<style>` on one line, `</style>` on a later line — return
* the lines between them.
*/
function extractCss(lines, block, id) {
const styleAttr = 'data-impeccable-css="' + id + '"';
let inStyle = false;
const content = [];
for (let i = block.start; i <= block.end; i++) {
const line = lines[i];
if (!inStyle && line.includes(styleAttr)) {
// Self-closing: nothing to carbonize.
if (/<style\b[^>]*\/\s*>/.test(line)) return null;
// Same-line open + close: extract inner text.
const sameLine = line.match(/<style\b[^>]*>([\s\S]*?)<\/style\s*>/);
if (sameLine) {
const inner = stripJsxTemplateWrap(sameLine[1]);
return inner.length > 0 ? inner.split('\n') : null;
}
inStyle = true;
continue; // skip the <style> opening tag
}
if (inStyle) {
// Detect </style> anywhere on the line — JSX template-literal closes
// (`}</style>`) put the close mid-line, and we don't want to absorb the
// template-literal punctuation as CSS content.
const closeIdx = line.indexOf('</style>');
if (closeIdx !== -1) break;
content.push(line);
}
}
if (content.length === 0) return null;
return stripJsxTemplateLines(content);
}
/**
* Strip a JSX template-literal wrap (`{` … `}`) from CSS extracted out of a
* `<style>` element in a JSX/TSX file. The agent may write the wrap with
* `{` and `}` directly attached to the `<style>` tags, on their own lines,
* or attached to the first/last CSS lines — all three are JSX-legal.
*
* Stripping is required because handleAccept re-wraps the CSS itself when
* carbonizing. Without this, two consecutive accepts (or a previously-
* accepted variants block being carbonized) would produce nested
* `{` `{` … `}` `}`, which oxc rejects with "Expected `}` but found `@`".
*/
function stripJsxTemplateLines(content) {
const out = content.slice();
// Drop any leading blank lines so we don't miss a `{` line buried below
// them; same for trailing.
while (out.length > 0 && out[0].trim() === '') out.shift();
while (out.length > 0 && out[out.length - 1].trim() === '') out.pop();
if (out.length === 0) return null;
// Leading `{`: own line, or attached to the first CSS line.
const firstTrim = out[0].trimStart();
if (firstTrim === '{`') {
out.shift();
} else if (firstTrim.startsWith('{`')) {
const idx = out[0].indexOf('{`');
out[0] = out[0].slice(0, idx) + out[0].slice(idx + 2);
if (out[0].trim() === '') out.shift();
}
if (out.length === 0) return null;
// Trailing `` ` `` `}`: own line, or attached to the last CSS line.
const lastIdx = out.length - 1;
const lastTrim = out[lastIdx].trimEnd();
if (lastTrim === '`}') {
out.pop();
} else if (lastTrim.endsWith('`}')) {
const text = out[lastIdx];
const idx = text.lastIndexOf('`}');
out[lastIdx] = text.slice(0, idx) + text.slice(idx + 2);
if (out[lastIdx].trim() === '') out.pop();
}
return out.length > 0 ? out : null;
}
function stripJsxTemplateWrap(text) {
const lines = text.split('\n');
const stripped = stripJsxTemplateLines(lines);
return stripped ? stripped.join('\n') : '';
}
/**
* De-indent content that was indented by live-wrap.mjs.
* The wrap script adds `indent + ' '` (4 extra spaces) to each line.
* We restore to just `indent` level.
*/
function deindentContent(contentLines, baseIndent) {
// Find the minimum indentation in the content to determine how much was added
let minIndent = Infinity;
for (const line of contentLines) {
if (line.trim() === '') continue;
const leadingSpaces = line.match(/^(\s*)/)[1].length;
minIndent = Math.min(minIndent, leadingSpaces);
}
if (minIndent === Infinity) minIndent = 0;
// Strip the extra indentation and re-add base indent
return contentLines.map(line => {
if (line.trim() === '') return '';
return baseIndent + line.slice(minIndent);
});
}
function detectCommentSyntax(filePath) {
const ext = path.extname(filePath).toLowerCase();
if (ext === '.jsx' || ext === '.tsx') {
return { open: '{/*', close: '*/}' };
}
return { open: '<!--', close: '-->' };
}
// ---------------------------------------------------------------------------
// File search (find the file containing session markers)
// ---------------------------------------------------------------------------
/**
* Accept also skips `dist` / `build` outright, where wrap descends into them so
* its `includeGenerated` second pass can report a `generatedMatch`. Accept has
* no such pass: a marker found in build output is only ever a stale copy of the
* marker in source.
*/
const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build'];
function findSessionFile(id, cwd) {
const result = findSourceFile({
query: 'impeccable-variants-start ' + id,
cwd,
extensions: resolveLiveTemplateExtensions(cwd),
skipDirs: SEARCH_SKIP_DIRS,
});
if (!result) return null;
const content = fs.readFileSync(result, 'utf-8');
return { file: result, content, lines: content.split('\n') };
}
// ---------------------------------------------------------------------------
// Utilities
// ---------------------------------------------------------------------------
function acceptReceiptPath(cwd, id) {
return path.join(getLiveDir(cwd), 'accept-receipts', `${safeSessionId(id)}.json`);
}
function readAcceptReceipt(cwd, id) {
try { return JSON.parse(fs.readFileSync(acceptReceiptPath(cwd, id), 'utf-8')); } catch { return null; }
}
function writeAcceptReceipt(cwd, id, receipt) {
const file = acceptReceiptPath(cwd, id);
fs.mkdirSync(path.dirname(file), { recursive: true });
const value = {
id,
...receipt,
completedAt: new Date().toISOString(),
};
const temporary = `${file}.${process.pid}.${Date.now()}.tmp`;
fs.writeFileSync(temporary, JSON.stringify(value, null, 2) + '\n', 'utf-8');
fs.renameSync(temporary, file);
return value;
}
function argVal(args, flag) {
const idx = args.indexOf(flag);
return idx !== -1 && idx + 1 < args.length ? args[idx + 1] : null;
}
// Auto-execute when run directly
const _running = process.argv[1];
if (_running?.endsWith('live-accept.mjs') || _running?.endsWith('live-accept.mjs/')) {
acceptCli();
}
export { findMarkerBlock, extractOriginal, extractVariant, extractCss, deindentContent, detectCommentSyntax, scrubManualEditsAgainstFile, scrubManualEditsAgainstOriginalBlock, applyDeferredSvelteComponentAccepts };