mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-14 15:16:35 +03:00
The first real Claude Code Live run failed, and the subagent was not the cause. Root cause: progressive publication stages each revision as `.impeccable/live/artifacts/<id>-r<n>.<source-ext>`, nothing ever deleted them, and findSessionFile's walker skipped only node_modules/.git/dist/build. It searches src, app, pages, ... then `.`; a project whose source is not under one of those (this repo's own site lives in site/pages/) falls through to the `.` walk, where dot-directories sort before letters. So accept found the artifact instead of the real file. Two outcomes, both reproduced: where isGeneratedFile returns true it declines with mode: 'fallback' (what the run hit, after which the agent hand-carbonized several hundred lines across three stylesheets, including unrequested drive-by edits); where it returns false, accept writes the variant into the throwaway artifact and reports handled: true while real source never changes. The E2E suite could not have caught this. Every fixture puts source under `src/`, which is searched before the `.` walk can reach `.impeccable`. Five framework fixtures and three progressive scenarios pass because of fixture layout, not because the path works. I read that as evidence and shouldn't have. - Never search `.impeccable`: it is Impeccable's own state, never project source. - Retire a session's staged artifacts on accept/discard, so they cannot outlive the session and become a decoy for anything else that walks the tree. - Regression tests use a site/pages layout with artifacts present. All three fail against the previous code. Generator subagent removed, on both harnesses: The parent must hand-compress the design system into the handoff, and compression is lossy. Measured on the real run: a 6,826-char handoff carrying exactly one token reference, after the parent had itself read kinpaku-tokens.css. The subagent then spent 3 of its first 9 turns hunting DESIGN.md, gave up, and emitted 0 var(--token) uses and 22 raw oklch literals — violating its own spec's "Never invent raw colors when tokens exist" — including a 1:1 gold-on-gold contrast bug. Isolation is not a benefit here; knowing the design system is the job. Generation stays in the main thread, which already holds the tokens and writes them from the first byte, so carbonize is a move rather than a translation. Copy edits keep their subagent: applying a known set of ops to a named file is self-contained, so an isolated context costs nothing. That is the line. Progressive delivery stays for Codex and Claude Code, main-thread driven. Claude Code keeps the full benefit because its poll is a background task. Codex's poll blocks the foreground, so with no subagent the user sees variant 1 early via HMR but cannot accept it until the trio finishes; that is the cost of the simplification and it is worth naming. Prepared with AI assistance under maintainer direction. Co-Authored-By: Claude <noreply@anthropic.com>
638 lines
23 KiB
JavaScript
638 lines
23 KiB
JavaScript
import fs from 'node:fs';
|
|
import path from 'node:path';
|
|
import { createHash } from 'node:crypto';
|
|
import { createLiveSessionStore } from './session-store.mjs';
|
|
import { withSourceLockSync } from './source-lock.mjs';
|
|
import { getLiveDir, safeSessionId } from '../lib/impeccable-paths.mjs';
|
|
import {
|
|
SOURCE_ARTIFACT_PREVIEW_MODE,
|
|
findSourceArtifactManifest,
|
|
} from './source-artifact.mjs';
|
|
|
|
export function sha256(value) {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
/**
|
|
* Delete a session's staged revision artifacts.
|
|
*
|
|
* Nothing used to remove these, and they are the reason a Live accept could
|
|
* resolve to the wrong file: `<id>-r<n>.<source-ext>` carries the session marker,
|
|
* so it is a decoy for any marker search that walks the project. live-accept no
|
|
* longer searches `.impeccable`, but the artifacts should not outlive the session
|
|
* they belong to either. Called on accept and discard.
|
|
*/
|
|
export function removeGenerationArtifacts(id, cwd = process.cwd()) {
|
|
let removed = 0;
|
|
try { safeSessionId(id); } catch { return removed; }
|
|
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
|
|
let entries;
|
|
try { entries = fs.readdirSync(artifactDir); } catch { return removed; }
|
|
for (const name of entries) {
|
|
if (!name.startsWith(id + '-r')) continue;
|
|
try { fs.rmSync(path.join(artifactDir, name), { force: true }); removed += 1; } catch { /* best effort */ }
|
|
}
|
|
return removed;
|
|
}
|
|
|
|
export function prepareGenerationArtifact({ id, sourceFile, cwd = process.cwd() } = {}) {
|
|
if (!id) return failure('missing_session_id');
|
|
if (!sourceFile) return failure('missing_file');
|
|
const requestedPath = resolveInside(cwd, sourceFile);
|
|
if (!requestedPath || !fs.existsSync(requestedPath)) return failure(requestedPath ? 'source_missing' : 'path_outside_project');
|
|
|
|
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
|
|
if (componentTarget?.error) return componentTarget;
|
|
const sourceArtifactTarget = componentTarget ? null : readSourceArtifactPublicationTarget(requestedPath, cwd, id);
|
|
if (sourceArtifactTarget?.error) return sourceArtifactTarget;
|
|
const sourcePath = componentTarget?.sourcePath || sourceArtifactTarget?.sourcePath || requestedPath;
|
|
|
|
try {
|
|
return withSourceLockSync(sourcePath, 'generation-prepare:' + id, () => {
|
|
const store = createLiveSessionStore({ cwd, sessionId: id });
|
|
const snapshot = store.getSnapshot(id, { includeCompleted: true });
|
|
if (!snapshot?.updatedAt) return failure('session_missing');
|
|
if (snapshot.generationCanceled === true) {
|
|
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
|
|
}
|
|
const source = fs.readFileSync(sourcePath, 'utf-8');
|
|
const artifactBase = sourceArtifactTarget
|
|
? fs.readFileSync(sourceArtifactTarget.previewPath, 'utf-8')
|
|
: source;
|
|
const revision = Number(snapshot.publishedRevision || 0) + 1;
|
|
const artifactDir = path.join(getLiveDir(cwd), 'artifacts');
|
|
if (componentTarget) {
|
|
return prepareComponentArtifact({
|
|
id,
|
|
revision,
|
|
snapshot,
|
|
source,
|
|
sourcePath,
|
|
requestedPath,
|
|
target: componentTarget,
|
|
artifactDir,
|
|
cwd,
|
|
});
|
|
}
|
|
const extension = path.extname(sourcePath) || '.html';
|
|
const artifactPath = path.join(artifactDir, id + '-r' + revision + extension);
|
|
fs.mkdirSync(artifactDir, { recursive: true });
|
|
fs.writeFileSync(artifactPath, artifactBase, 'utf-8');
|
|
return {
|
|
ok: true,
|
|
id,
|
|
epoch: Number(snapshot.generationEpoch || 1),
|
|
revision,
|
|
sourceFile: relative(cwd, sourcePath),
|
|
...(sourceArtifactTarget ? {
|
|
previewFile: relative(cwd, sourceArtifactTarget.previewPath),
|
|
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
|
} : {}),
|
|
artifactFile: relative(cwd, artifactPath),
|
|
expectedSourceHash: sha256(source),
|
|
};
|
|
}, { cwd });
|
|
} catch (error) {
|
|
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
|
|
return failure('prepare_failed', { message: error?.message || String(error) });
|
|
}
|
|
}
|
|
|
|
export function publishGenerationArtifact({
|
|
id,
|
|
epoch,
|
|
sourceFile,
|
|
artifactFile,
|
|
expectedSourceHash,
|
|
arrivedVariants,
|
|
expectedVariants,
|
|
publicationKind,
|
|
cwd = process.cwd(),
|
|
} = {}) {
|
|
if (!id) return failure('missing_session_id');
|
|
if (!Number.isInteger(epoch) || epoch < 1) return failure('invalid_generation_epoch');
|
|
if (!sourceFile || !artifactFile) return failure('missing_file');
|
|
if (publicationKind && !['variants', 'params'].includes(publicationKind)) {
|
|
return failure('invalid_publication_kind');
|
|
}
|
|
|
|
const requestedPath = resolveInside(cwd, sourceFile);
|
|
const artifactPath = resolveInside(cwd, artifactFile);
|
|
if (!requestedPath || !artifactPath) return failure('path_outside_project');
|
|
if (!fs.existsSync(requestedPath)) return failure('source_missing');
|
|
if (!fs.existsSync(artifactPath)) return failure('artifact_missing');
|
|
|
|
const componentTarget = readComponentPublicationTarget(requestedPath, cwd, id);
|
|
if (componentTarget?.error) return componentTarget;
|
|
const sourceArtifactTarget = componentTarget ? null : readSourceArtifactPublicationTarget(requestedPath, cwd, id);
|
|
if (sourceArtifactTarget?.error) return sourceArtifactTarget;
|
|
const artifactManifest = readJson(artifactPath);
|
|
const isComponentArtifact = isComponentPreviewMode(artifactManifest?.previewMode);
|
|
if (Boolean(componentTarget) !== isComponentArtifact) {
|
|
return failure('artifact_preview_mode_mismatch');
|
|
}
|
|
if (componentTarget && componentTarget.manifest.previewMode !== artifactManifest?.previewMode) {
|
|
return failure('artifact_preview_mode_mismatch');
|
|
}
|
|
const sourcePath = componentTarget?.sourcePath || sourceArtifactTarget?.sourcePath || requestedPath;
|
|
|
|
try {
|
|
return withSourceLockSync(sourcePath, 'generation:' + id + ':' + epoch, () => {
|
|
const store = createLiveSessionStore({ cwd, sessionId: id });
|
|
const snapshot = store.getSnapshot(id, { includeCompleted: true });
|
|
if (!snapshot?.updatedAt) return failure('session_missing');
|
|
const stale = staleGenerationFailure(snapshot, epoch);
|
|
if (stale) return stale;
|
|
|
|
const current = fs.readFileSync(sourcePath, 'utf-8');
|
|
const currentHash = sha256(current);
|
|
if (!expectedSourceHash || currentHash !== expectedSourceHash) {
|
|
return failure('source_hash_mismatch', { actualSourceHash: currentHash });
|
|
}
|
|
|
|
if (componentTarget) {
|
|
return publishComponentArtifact({
|
|
id,
|
|
epoch,
|
|
snapshot,
|
|
target: componentTarget,
|
|
artifactManifest,
|
|
artifactPath,
|
|
sourcePath,
|
|
arrivedVariants,
|
|
expectedVariants,
|
|
publicationKind,
|
|
store,
|
|
cwd,
|
|
});
|
|
}
|
|
|
|
const stablePreview = sourceArtifactTarget
|
|
? fs.readFileSync(sourceArtifactTarget.previewPath, 'utf-8')
|
|
: current;
|
|
const artifact = fs.readFileSync(artifactPath, 'utf-8');
|
|
if (!artifact.includes('data-impeccable-variants="' + id + '"')) {
|
|
return failure('artifact_missing_session_wrapper');
|
|
}
|
|
const delivered = countDeliveredVariants(artifact);
|
|
if (delivered < 1) return failure('artifact_has_no_variants');
|
|
if (Number.isInteger(arrivedVariants) && delivered < arrivedVariants) {
|
|
return failure('artifact_variant_count_mismatch', { delivered });
|
|
}
|
|
const priorArrived = Math.max(0, Number(snapshot.arrivedVariants || 0));
|
|
for (let variant = 1; variant <= priorArrived; variant++) {
|
|
const currentVariant = extractVariantBlock(stablePreview, variant);
|
|
const artifactVariant = extractVariantBlock(artifact, variant);
|
|
if (!currentVariant || !artifactVariant) {
|
|
return failure('published_variant_missing', { variant });
|
|
}
|
|
if (sha256(withoutVariantParams(currentVariant)) !== sha256(withoutVariantParams(artifactVariant))) {
|
|
return failure('published_variant_changed', { variant });
|
|
}
|
|
}
|
|
const currentPreviewCss = extractPreviewCss(stablePreview, id);
|
|
const artifactPreviewCss = extractPreviewCss(artifact, id);
|
|
if (priorArrived > 0 && currentPreviewCss && !artifactPreviewCss.startsWith(currentPreviewCss)) {
|
|
return failure('published_variant_css_changed');
|
|
}
|
|
|
|
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
|
|
const commitStale = staleGenerationFailure(commitSnapshot, epoch);
|
|
if (commitStale) return commitStale;
|
|
const artifactHash = sha256(artifact);
|
|
const publishPath = sourceArtifactTarget?.previewPath || sourcePath;
|
|
atomicReplace(publishPath, artifact);
|
|
const revision = Number(commitSnapshot.publishedRevision || 0) + 1;
|
|
store.appendEvent({
|
|
type: 'variant_published',
|
|
id,
|
|
generationEpoch: epoch,
|
|
revision,
|
|
digest: artifactHash,
|
|
sourceFile: relative(cwd, sourcePath),
|
|
...(sourceArtifactTarget ? {
|
|
previewFile: relative(cwd, publishPath),
|
|
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
|
} : {}),
|
|
arrivedVariants: delivered,
|
|
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
|
|
publicationKind: publicationKind || 'variants',
|
|
at: Date.now(),
|
|
});
|
|
return {
|
|
ok: true,
|
|
id,
|
|
epoch,
|
|
revision,
|
|
digest: artifactHash,
|
|
sourceFile: relative(cwd, sourcePath),
|
|
...(sourceArtifactTarget ? {
|
|
previewFile: relative(cwd, publishPath),
|
|
previewMode: SOURCE_ARTIFACT_PREVIEW_MODE,
|
|
} : {}),
|
|
arrivedVariants: delivered,
|
|
expectedVariants: Number(expectedVariants || snapshot.expectedVariants || delivered),
|
|
publicationKind: publicationKind || 'variants',
|
|
};
|
|
}, { cwd });
|
|
} catch (error) {
|
|
if (error?.code === 'SOURCE_LOCKED') return failure('source_locked');
|
|
return failure('publish_failed', { message: error?.message || String(error) });
|
|
}
|
|
}
|
|
|
|
function prepareComponentArtifact({
|
|
id,
|
|
revision,
|
|
snapshot,
|
|
source,
|
|
sourcePath,
|
|
requestedPath,
|
|
target,
|
|
artifactDir,
|
|
cwd,
|
|
}) {
|
|
const artifactComponentDir = path.join(
|
|
artifactDir,
|
|
id + '-r' + revision + '-' + target.manifest.previewMode + '-' + process.pid + '-' + Date.now(),
|
|
);
|
|
fs.mkdirSync(artifactComponentDir, { recursive: true });
|
|
copyDirectoryFiles(target.componentPath, artifactComponentDir);
|
|
const artifactPath = path.join(artifactComponentDir, 'manifest.json');
|
|
const artifactManifest = {
|
|
...target.manifest,
|
|
componentDir: relative(cwd, artifactComponentDir),
|
|
};
|
|
fs.writeFileSync(artifactPath, JSON.stringify(artifactManifest, null, 2) + '\n', 'utf-8');
|
|
return {
|
|
ok: true,
|
|
id,
|
|
epoch: Number(snapshot.generationEpoch || 1),
|
|
revision,
|
|
sourceFile: relative(cwd, requestedPath),
|
|
targetSourceFile: relative(cwd, sourcePath),
|
|
artifactFile: relative(cwd, artifactPath),
|
|
componentDir: relative(cwd, artifactComponentDir),
|
|
previewMode: target.manifest.previewMode,
|
|
expectedSourceHash: sha256(source),
|
|
};
|
|
}
|
|
|
|
function publishComponentArtifact({
|
|
id,
|
|
epoch,
|
|
snapshot,
|
|
target,
|
|
artifactManifest,
|
|
artifactPath,
|
|
sourcePath,
|
|
arrivedVariants,
|
|
expectedVariants,
|
|
publicationKind,
|
|
store,
|
|
cwd,
|
|
}) {
|
|
if (!artifactManifest || typeof artifactManifest !== 'object') {
|
|
return failure('artifact_manifest_invalid');
|
|
}
|
|
if (artifactManifest.id !== id || target.manifest.id !== id) {
|
|
return failure('artifact_session_mismatch');
|
|
}
|
|
const artifactComponentPath = resolveInside(cwd, artifactManifest.componentDir);
|
|
if (!artifactComponentPath || path.resolve(artifactComponentPath) !== path.dirname(artifactPath)) {
|
|
return failure('artifact_component_dir_mismatch');
|
|
}
|
|
if (!isDescendant(path.join(getLiveDir(cwd), 'artifacts'), artifactComponentPath)) {
|
|
return failure('artifact_not_staged');
|
|
}
|
|
const immutableMismatch = componentManifestMismatch(target.manifest, artifactManifest);
|
|
if (immutableMismatch) {
|
|
return failure('artifact_manifest_changed', { field: immutableMismatch });
|
|
}
|
|
|
|
const expected = Number(expectedVariants || target.manifest.count || snapshot.expectedVariants || 0);
|
|
const declared = optionalPositiveInteger(artifactManifest.arrivedVariants);
|
|
const delivered = Number.isInteger(arrivedVariants) ? arrivedVariants : declared;
|
|
if (!Number.isInteger(delivered) || delivered < 1) return failure('artifact_has_no_variants');
|
|
if (expected > 0 && delivered > expected) {
|
|
return failure('artifact_variant_count_mismatch', { delivered, expected });
|
|
}
|
|
if (declared !== null && declared !== delivered) {
|
|
return failure('artifact_variant_count_mismatch', { delivered: declared, expected: delivered });
|
|
}
|
|
|
|
const priorArrived = Math.max(
|
|
optionalPositiveInteger(target.manifest.arrivedVariants) || 0,
|
|
Number(snapshot.arrivedVariants || 0),
|
|
);
|
|
if (delivered < priorArrived) {
|
|
return failure('artifact_variant_count_regressed', { delivered, priorArrived });
|
|
}
|
|
|
|
const componentExtension = target.manifest.componentExtension
|
|
|| (target.manifest.previewMode === 'vue-component' ? 'vue' : 'svelte');
|
|
const variantContents = [];
|
|
for (let variant = 1; variant <= delivered; variant++) {
|
|
const artifactVariantPath = path.join(artifactComponentPath, 'v' + variant + '.' + componentExtension);
|
|
if (!regularFileInside(artifactComponentPath, artifactVariantPath)) {
|
|
return failure('artifact_variant_missing', { variant });
|
|
}
|
|
const content = fs.readFileSync(artifactVariantPath, 'utf-8');
|
|
if (!content.trim()) return failure('artifact_variant_empty', { variant });
|
|
const targetVariantPath = path.join(target.componentPath, 'v' + variant + '.' + componentExtension);
|
|
if (variant <= priorArrived && !regularFileInside(target.componentPath, targetVariantPath)) {
|
|
return failure('published_variant_missing', { variant });
|
|
}
|
|
if (variant <= priorArrived) {
|
|
const prior = fs.readFileSync(targetVariantPath, 'utf-8');
|
|
if (sha256(prior) !== sha256(content)) {
|
|
return failure('published_variant_changed', { variant });
|
|
}
|
|
}
|
|
variantContents.push({ variant, content, targetPath: targetVariantPath });
|
|
}
|
|
|
|
const artifactParamsPath = path.join(artifactComponentPath, 'params.json');
|
|
let paramsContent = null;
|
|
if (fs.existsSync(artifactParamsPath)) {
|
|
if (!regularFileInside(artifactComponentPath, artifactParamsPath)) {
|
|
return failure('artifact_params_invalid');
|
|
}
|
|
paramsContent = fs.readFileSync(artifactParamsPath, 'utf-8');
|
|
const params = parseJson(paramsContent);
|
|
if (!params || typeof params !== 'object' || Array.isArray(params)) {
|
|
return failure('artifact_params_invalid');
|
|
}
|
|
}
|
|
|
|
// Check the fence before writing anything. The prepare→publish gap is exactly
|
|
// where an Accept lands, and the source-artifact path above rechecks before
|
|
// its only write. Without the same check here, a canceled generation still
|
|
// scattered variant files across the generated component dir and left them
|
|
// there — the `stale_generation_epoch` returns below have no rollback.
|
|
const preWriteStale = staleGenerationFailure(store.getSnapshot(id, { includeCompleted: true }), epoch);
|
|
if (preWriteStale) return preWriteStale;
|
|
|
|
// Components and optional params become reachable before the manifest
|
|
// advertises them. Committing the manifest last makes publication atomic
|
|
// from the browser's point of view while the source lock excludes Accept.
|
|
fs.mkdirSync(target.componentPath, { recursive: true });
|
|
for (const variant of variantContents) {
|
|
if (variant.variant > priorArrived) atomicReplace(variant.targetPath, variant.content);
|
|
}
|
|
if (paramsContent !== null) {
|
|
atomicReplace(path.join(target.componentPath, 'params.json'), paramsContent);
|
|
}
|
|
// Re-check immediately before the manifest: the manifest is what makes the
|
|
// variants visible to the browser, so this is the gate that actually matters.
|
|
const commitSnapshot = store.getSnapshot(id, { includeCompleted: true });
|
|
const commitStale = staleGenerationFailure(commitSnapshot, epoch);
|
|
if (commitStale) return commitStale;
|
|
const publishedManifest = {
|
|
...target.manifest,
|
|
componentDir: relative(cwd, target.componentPath),
|
|
arrivedVariants: delivered,
|
|
};
|
|
delete publishedManifest.manifestPath;
|
|
const manifestContent = JSON.stringify(publishedManifest, null, 2) + '\n';
|
|
atomicReplace(target.manifestPath, manifestContent);
|
|
|
|
const digest = digestComponentPublication(manifestContent, variantContents, paramsContent);
|
|
const revision = Number(snapshot.publishedRevision || 0) + 1;
|
|
const sourceFile = relative(cwd, sourcePath);
|
|
const previewFile = relative(cwd, target.manifestPath);
|
|
store.appendEvent({
|
|
type: 'variant_published',
|
|
id,
|
|
generationEpoch: epoch,
|
|
revision,
|
|
digest,
|
|
sourceFile,
|
|
previewFile,
|
|
previewMode: target.manifest.previewMode,
|
|
arrivedVariants: delivered,
|
|
expectedVariants: expected || delivered,
|
|
publicationKind: publicationKind || 'variants',
|
|
at: Date.now(),
|
|
});
|
|
return {
|
|
ok: true,
|
|
id,
|
|
epoch,
|
|
revision,
|
|
digest,
|
|
sourceFile,
|
|
previewFile,
|
|
previewMode: target.manifest.previewMode,
|
|
componentDir: relative(cwd, target.componentPath),
|
|
arrivedVariants: delivered,
|
|
expectedVariants: expected || delivered,
|
|
publicationKind: publicationKind || 'variants',
|
|
};
|
|
}
|
|
|
|
const COMPONENT_MANIFEST_FIELDS = [
|
|
'id',
|
|
'mode',
|
|
'previewMode',
|
|
'sourceFile',
|
|
'sourceStartLine',
|
|
'sourceEndLine',
|
|
'insertLine',
|
|
'position',
|
|
'anchorStartLine',
|
|
'anchorEndLine',
|
|
'count',
|
|
'propContract',
|
|
'originalMarkup',
|
|
'anchorMarkup',
|
|
'runtimeModule',
|
|
'componentModuleBase',
|
|
'framework',
|
|
'componentExtension',
|
|
];
|
|
|
|
function readComponentPublicationTarget(manifestPath, cwd, id) {
|
|
if (path.basename(manifestPath) !== 'manifest.json') return null;
|
|
const manifest = readJson(manifestPath);
|
|
if (!manifest || !isComponentPreviewMode(manifest.previewMode)) return null;
|
|
if (manifest.id !== id) return failure('artifact_session_mismatch');
|
|
const sourcePath = resolveInside(cwd, manifest.sourceFile);
|
|
const componentPath = resolveInside(cwd, manifest.componentDir);
|
|
if (!sourcePath || !componentPath) return failure('path_outside_project');
|
|
if (!fs.existsSync(sourcePath)) return failure('source_missing');
|
|
if (path.resolve(componentPath) !== path.dirname(manifestPath)) {
|
|
return failure('manifest_component_dir_mismatch');
|
|
}
|
|
return { manifest, manifestPath, sourcePath, componentPath };
|
|
}
|
|
|
|
function readSourceArtifactPublicationTarget(requestedPath, cwd, id) {
|
|
const manifest = findSourceArtifactManifest(id, cwd);
|
|
if (!manifest) return null;
|
|
if (path.resolve(requestedPath) !== path.resolve(manifest.previewPath)) {
|
|
return failure('source_artifact_preview_mismatch');
|
|
}
|
|
return manifest;
|
|
}
|
|
|
|
function componentManifestMismatch(target, artifact) {
|
|
for (const field of COMPONENT_MANIFEST_FIELDS) {
|
|
if (JSON.stringify(target[field] ?? null) !== JSON.stringify(artifact[field] ?? null)) return field;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function isComponentPreviewMode(value) {
|
|
return value === 'svelte-component' || value === 'vue-component';
|
|
}
|
|
|
|
function copyDirectoryFiles(sourceDir, targetDir) {
|
|
for (const entry of fs.readdirSync(sourceDir, { withFileTypes: true })) {
|
|
if (!entry.isFile() || entry.isSymbolicLink()) continue;
|
|
fs.copyFileSync(path.join(sourceDir, entry.name), path.join(targetDir, entry.name));
|
|
}
|
|
}
|
|
|
|
function regularFileInside(root, file) {
|
|
const rel = path.relative(root, file);
|
|
if (!rel || rel.startsWith('..') || path.isAbsolute(rel)) return false;
|
|
try {
|
|
return fs.lstatSync(file).isFile();
|
|
} catch {
|
|
return false;
|
|
}
|
|
}
|
|
|
|
function isDescendant(root, candidate) {
|
|
const rel = path.relative(root, candidate);
|
|
return Boolean(rel) && !rel.startsWith('..') && !path.isAbsolute(rel);
|
|
}
|
|
|
|
function digestComponentPublication(manifestContent, variants, paramsContent) {
|
|
const hash = createHash('sha256');
|
|
hash.update(manifestContent);
|
|
for (const variant of variants) {
|
|
hash.update('\0v' + variant.variant + '\0');
|
|
hash.update(variant.content);
|
|
}
|
|
if (paramsContent !== null) hash.update('\0params\0' + paramsContent);
|
|
return hash.digest('hex');
|
|
}
|
|
|
|
function readJson(file) {
|
|
try {
|
|
return JSON.parse(fs.readFileSync(file, 'utf-8'));
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function parseJson(value) {
|
|
try {
|
|
return JSON.parse(value);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
function optionalPositiveInteger(value) {
|
|
const number = Number(value);
|
|
return Number.isInteger(number) && number > 0 ? number : null;
|
|
}
|
|
|
|
function countDeliveredVariants(source) {
|
|
const matches = source.match(/<div\b[^>]*\bdata-impeccable-variant=(?:"|')(?!original(?:"|'))[^"']+(?:"|')[^>]*>/g);
|
|
return matches?.length || 0;
|
|
}
|
|
|
|
function extractVariantBlock(source, variant) {
|
|
const open = /<div\b[^>]*>/gi;
|
|
let match;
|
|
let start = -1;
|
|
const attr = new RegExp("\\bdata-impeccable-variant=(?:\"" + variant + "\"|'" + variant + "')");
|
|
while ((match = open.exec(source))) {
|
|
if (attr.test(match[0])) {
|
|
start = match.index;
|
|
break;
|
|
}
|
|
}
|
|
if (start < 0) return null;
|
|
|
|
const token = /<div\b[^>]*\/\s*>|<div\b[^>]*>|<\/div\s*>/gi;
|
|
token.lastIndex = start;
|
|
let depth = 0;
|
|
while ((match = token.exec(source))) {
|
|
if (/^<\/div/i.test(match[0])) {
|
|
depth -= 1;
|
|
if (depth === 0) return source.slice(start, token.lastIndex);
|
|
} else if (!/\/\s*>$/.test(match[0])) {
|
|
depth += 1;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function withoutVariantParams(block) {
|
|
return String(block || '').replace(
|
|
/\sdata-impeccable-params=(?:"[^"]*"|'[^']*')/i,
|
|
'',
|
|
);
|
|
}
|
|
|
|
function extractPreviewCss(source, id) {
|
|
const escapedId = String(id).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
const open = new RegExp("<style\\b[^>]*\\bdata-impeccable-css=(?:\"" + escapedId + "\"|'" + escapedId + "')[^>]*>", 'i');
|
|
const match = open.exec(source);
|
|
if (!match) return '';
|
|
const start = match.index + match[0].length;
|
|
const end = source.indexOf('</style>', start);
|
|
if (end < 0) return '';
|
|
return source.slice(start, end)
|
|
.replace(/^\s*\{\s*`\s*/, '')
|
|
.replace(/\s*`\s*\}\s*$/, '')
|
|
.trim();
|
|
}
|
|
|
|
function atomicReplace(target, content) {
|
|
let mode = 0o666;
|
|
try { mode = fs.statSync(target).mode; } catch {}
|
|
const temp = target + '.impeccable-publish-' + process.pid + '-' + Date.now();
|
|
try {
|
|
fs.writeFileSync(temp, content, { encoding: 'utf-8', mode });
|
|
fs.renameSync(temp, target);
|
|
} finally {
|
|
try { fs.unlinkSync(temp); } catch {}
|
|
}
|
|
}
|
|
|
|
function resolveInside(cwd, value) {
|
|
const resolved = path.resolve(cwd, value);
|
|
const rel = path.relative(cwd, resolved);
|
|
if (rel.startsWith('..') || path.isAbsolute(rel)) return null;
|
|
return resolved;
|
|
}
|
|
|
|
function relative(cwd, value) {
|
|
return path.relative(cwd, value).split(path.sep).join('/');
|
|
}
|
|
|
|
function failure(error, details = {}) {
|
|
return { ok: false, error, ...details };
|
|
}
|
|
|
|
/**
|
|
* The generation fence: has this session been canceled (Accept/Discard landed),
|
|
* or has a newer generation superseded this epoch? Returns a failure result to
|
|
* propagate, or null when the caller may proceed.
|
|
*/
|
|
function staleGenerationFailure(snapshot, epoch) {
|
|
if (snapshot?.generationCanceled === true) {
|
|
return failure('stale_generation_epoch', { canceled: true, phase: snapshot.phase });
|
|
}
|
|
if (Number(snapshot?.generationEpoch || 1) !== epoch) {
|
|
return failure('stale_generation_epoch', { expectedEpoch: snapshot?.generationEpoch || 1 });
|
|
}
|
|
return null;
|
|
}
|