mirror of
https://github.com/pbakaus/impeccable.git
synced 2026-09-12 22:26:38 +03:00
Live: polling rework, source locks, preflight scaffolding, Vue previews
Carved out of #371, minus progressive publication. Everything here works against real project source the way main's Live already does: the agent writes variants into the file the browser loaded, HMR fires, Accept promotes and carbonizes. Nothing is staged anywhere. Poll lanes. Events now carry an explicit priority: accept/discard/exit ahead of manual_edit_apply/steer/carbonize_cleanup ahead of generate. A long generate can no longer sit in front of the Accept the user just clicked. leaseEvent claims its lease before awaiting, so a slow prepare cannot hand the same event to two pollers. Source locks. A per-file mutex around every accept and discard path, keyed on a digest of the absolute path. Staleness is decided by owner-pid liveness rather than mtime, so a wedged lock clears when its owner dies instead of after an arbitrary timeout, and a slow-but-live accept is never stolen from. Only the owning process can release a lock. Preflight scaffolding. The server runs live-wrap (or live-insert) before the poll returns and hands the result back as event.scaffold. That walk is measured at ~7.6s on a large repo; moving it off the agent's critical path removes a deterministic tool round trip without touching the generated design. Falls back cleanly to the agent running the helper itself. Vue previews. previewMode: "vue-component" for Nuxt/Vue targets, matching the existing Svelte component path: variants compile as real SFCs from a dev-only directory so the route is never rewritten during generation, and Vite mounts them without invalidating page state. Accept is the only route write. Includes a Vue attr tokenizer that normalizes shorthand bindings (@x, :x, #x) to their canonical forms. Accept hardening. Every thrown failure now returns mode: 'error' rather than an ambiguous unhandled result, so a real failure is never classified as a deliberate manual handoff and silently dropped. The marker search skips node_modules/.git/dist/build/.impeccable. Shared CLI arg parsing extracted to scripts/lib/cli-args.mjs. Assisted-by: Claude Code
This commit is contained in:
@@ -13,8 +13,6 @@
|
||||
* node hook-admin.mjs ignore-file <glob> # append to ignoreFiles
|
||||
* node hook-admin.mjs ignore-value <rule> <value> # append to shared ignoreValues
|
||||
* node hook-admin.mjs ignore-value <rule> <value> --local
|
||||
* node hook-admin.mjs ignore-value <rule> "*" --file <glob> # rule off in <glob> only
|
||||
* node hook-admin.mjs ignore-value <rule> "*" # refused: scope it or use ignore-rule
|
||||
* node hook-admin.mjs reset # remove all config + cache
|
||||
*
|
||||
* Designed to be invoked by the LLM from the reference/hooks.md flow.
|
||||
@@ -536,13 +534,12 @@ function addIgnoreFile(cwd, glob) {
|
||||
|
||||
function parseIgnoreValueArgs(args) {
|
||||
const positionals = [];
|
||||
const files = [];
|
||||
let shared = false;
|
||||
let local = false;
|
||||
let reason = '';
|
||||
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = String(args[i] || '');
|
||||
const arg = args[i];
|
||||
if (arg === '--shared') {
|
||||
shared = true;
|
||||
} else if (arg === '--local') {
|
||||
@@ -553,20 +550,8 @@ function parseIgnoreValueArgs(args) {
|
||||
chunks.push(args[++i]);
|
||||
}
|
||||
reason = chunks.join(' ').trim();
|
||||
} else if (arg.startsWith('--reason=')) {
|
||||
reason = arg.slice('--reason='.length).trim();
|
||||
} else if (arg === '--file' || arg === '--files') {
|
||||
if (i + 1 >= args.length) throw new Error(`${arg} requires a glob`);
|
||||
files.push(String(args[++i]).trim());
|
||||
} else if (arg.startsWith('--file=')) {
|
||||
files.push(arg.slice('--file='.length).trim());
|
||||
} else if (arg.startsWith('--files=')) {
|
||||
files.push(arg.slice('--files='.length).trim());
|
||||
} else if (arg.startsWith('--')) {
|
||||
// Otherwise a typo folds into the value: `ignore-value overused-font Inter
|
||||
// --shard` stored the value "inter --shard", which matches no finding, and
|
||||
// reported success. Matches `impeccable ignores add-value`.
|
||||
throw new Error(`Unknown ignore-value flag: ${arg}`);
|
||||
} else if (String(arg).startsWith('--reason=')) {
|
||||
reason = String(arg).slice('--reason='.length).trim();
|
||||
} else {
|
||||
positionals.push(arg);
|
||||
}
|
||||
@@ -576,7 +561,6 @@ function parseIgnoreValueArgs(args) {
|
||||
return {
|
||||
rule: String(rule || '').trim().toLowerCase(),
|
||||
value: normalizeIgnoreValue(valueParts.join(' ')),
|
||||
files: Array.from(new Set(files.filter(Boolean))),
|
||||
shared,
|
||||
local,
|
||||
reason,
|
||||
@@ -593,19 +577,10 @@ function addIgnoreValue(cwd, args) {
|
||||
throw new Error('Pass only one scope flag: --shared or --local');
|
||||
}
|
||||
|
||||
// A bare `*` would suppress the rule everywhere, which is ignore-rule's job and
|
||||
// not what a finding in one file justifies. detector.ignoreValues honours a
|
||||
// `files` scope, so require one — matching `impeccable ignores add-value`.
|
||||
if (parsed.value === '*' && parsed.files.length === 0) {
|
||||
throw new Error(`Wildcard value ignores must be scoped with --file <glob>, e.g. ${IMPECCABLE_COMMAND} hooks ignore-value design-system-font-size "*" --file "src/widget.js". To suppress the rule project-wide use ${IMPECCABLE_COMMAND} hooks ignore-rule ${parsed.rule}.`);
|
||||
}
|
||||
|
||||
const local = parsed.local;
|
||||
const config = mergeDetectorConfig(readRawDetectorConfig(cwd, { local }));
|
||||
// Key on the file scope too: the same rule/value legitimately appears more than
|
||||
// once with different scopes, and a rule+value-only key overwrote them.
|
||||
const key = ignoreValueEntryKey({ rule: parsed.rule, value: parsed.value, files: parsed.files });
|
||||
const existing = config.ignoreValues.find((entry) => ignoreValueEntryKey(entry) === key);
|
||||
const key = `${parsed.rule}\0${parsed.value}`;
|
||||
const existing = config.ignoreValues.find((entry) => `${entry.rule}\0${entry.value}` === key);
|
||||
|
||||
if (existing) {
|
||||
if (parsed.reason) existing.reason = parsed.reason;
|
||||
@@ -613,17 +588,15 @@ function addIgnoreValue(cwd, args) {
|
||||
const entry = {
|
||||
rule: parsed.rule,
|
||||
value: parsed.value,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
if (parsed.files.length) entry.files = parsed.files;
|
||||
entry.createdAt = new Date().toISOString();
|
||||
if (parsed.reason) entry.reason = parsed.reason;
|
||||
config.ignoreValues.push(entry);
|
||||
}
|
||||
|
||||
const target = writeDetectorConfig(cwd, config, { local });
|
||||
const scope = local ? 'local detector.ignoreValues' : 'shared detector.ignoreValues';
|
||||
const scopeSuffix = parsed.files.length ? ` scoped to ${parsed.files.join(', ')}` : '';
|
||||
return `Added ${parsed.rule}=${parsed.value}${scopeSuffix} to ${scope} (${path.relative(cwd, target) || target}).`;
|
||||
return `Added ${parsed.rule}=${parsed.value} to ${scope} (${path.relative(cwd, target) || target}).`;
|
||||
}
|
||||
|
||||
function reset(cwd) {
|
||||
|
||||
@@ -502,15 +502,12 @@ export function normalizeIgnoreValueEntries(entries) {
|
||||
...(Array.isArray(entry.files) ? entry.files.filter(v => typeof v === 'string' && v.trim()).map(v => v.trim()) : []),
|
||||
]);
|
||||
if (files.length > 0) normalized.files = files;
|
||||
// Key order is rule, value, files, createdAt, reason and must stay that way:
|
||||
// normalizing runs on every write, so emitting a different order than the one
|
||||
// already on disk rewrites every untouched entry and churns the diff.
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
if (typeof entry.reason === 'string' && entry.reason.trim()) {
|
||||
normalized.reason = entry.reason.trim();
|
||||
}
|
||||
if (typeof entry.createdAt === 'string' && entry.createdAt.trim()) {
|
||||
normalized.createdAt = entry.createdAt.trim();
|
||||
}
|
||||
out.push(normalized);
|
||||
}
|
||||
return out;
|
||||
@@ -1468,17 +1465,16 @@ export function appendDesignSystemNote(text, scanOptions) {
|
||||
// raw envelope. Asking the model to surface the resolution in its
|
||||
// reply is the cheapest way to make the feedback loop visible.
|
||||
function directiveFooter(display, opts = {}) {
|
||||
// Offer the rule-scoped-to-file form first. `ignore-file` silences every rule
|
||||
// for the path forever, which is far more than one noisy rule on a real UI
|
||||
// surface justifies, and it was previously the only option named here.
|
||||
const target = opts.grouped ? '<path>' : quoteCommandArg(display);
|
||||
const fileIgnoreGuidance = `run \`${IMPECCABLE_COMMAND} hooks ignore-value <id> "*" --file ${target}\` to scope just that rule to the file, or \`${IMPECCABLE_COMMAND} hooks ignore-file ${target}\` only when the whole file is out of scope for design review (a fixture, a generated artifact, a deliberate demo)`;
|
||||
const ignoreFileCommand = `${IMPECCABLE_COMMAND} hooks ignore-file ${quoteCommandArg(display)}`;
|
||||
const fileIgnoreGuidance = opts.grouped
|
||||
? `run \`${IMPECCABLE_COMMAND} hooks ignore-file <path>\` for the specific file`
|
||||
: `run \`${ignoreFileCommand}\``;
|
||||
return [
|
||||
'Handle these before finalizing: fix findings that are real design problems, or explicitly classify contextually intentional findings as false positives. Acknowledge what you changed or why you are leaving a finding unchanged.',
|
||||
'',
|
||||
'Use context judgment before editing. A finding is not automatically a defect; literal or domain-appropriate motion, intentional demos or fixtures, documentation of bad design, and user-confirmed choices can be valid as-is.',
|
||||
'',
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For a finding whose line shows no exact ignore-value command, such as \`side-tab\`, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
`Do not change intentional design just to satisfy the hook, and do not silence a real finding with an inline ignore comment to skip fixing it. Suppress a finding only after the user explicitly confirms it is intentional. Prefer a config ignore (one reviewable place, the commands below); reach for an inline \`impeccable-disable <rule>\` comment only when the waiver must travel with a file that leaves the repo, such as an exported or standalone document. Prefer the narrowest persisted exception: run the exact \`${IMPECCABLE_COMMAND} hooks ignore-value ... --shared\` command shown next to a value-specific finding. For \`overused-font\`, use \`ignore-value\` for a specific font and use \`${IMPECCABLE_COMMAND} hooks ignore-rule overused-font --all-values\` only when the user asks to ignore overused fonts generally. For file-specific findings without an ignore-value command, ${fileIgnoreGuidance}; use \`${IMPECCABLE_COMMAND} hooks ignore-rule <id>\` only when the user asks to suppress the whole non-value-specific rule. Run ${IMPECCABLE_COMMAND} audit for the full pass.`,
|
||||
].join('\n');
|
||||
}
|
||||
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
inlineVueComponentAccept,
|
||||
retireVueComponentSession,
|
||||
} from './live/vue-component.mjs';
|
||||
import { removeGenerationArtifacts } from './live/generation-publisher.mjs';
|
||||
|
||||
const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro'];
|
||||
const ACCEPT_LOCK_WAIT_MS = 1_000;
|
||||
@@ -154,10 +153,6 @@ Output (JSON):
|
||||
variantId: isDiscard ? null : String(variantNum),
|
||||
result,
|
||||
});
|
||||
// The session is over: drop its staged revision artifacts. Leaving them
|
||||
// behind is what let a later marker search find a decoy instead of real
|
||||
// source. Only on success, so a failed accept can still be retried.
|
||||
removeGenerationArtifacts(id, process.cwd());
|
||||
}
|
||||
console.log(JSON.stringify(result));
|
||||
};
|
||||
|
||||
@@ -8038,7 +8038,6 @@ void main() {
|
||||
paramsCurrentValues = { ...saved.paramValues };
|
||||
}
|
||||
if (saved.parameterState) parameterGenerationState = saved.parameterState;
|
||||
else if (saved.paramsPublished === true && parameterGenerationState !== 'ready') parameterGenerationState = 'loading';
|
||||
if (saved.generationPhase) generationPhase = saved.generationPhase;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
#!/usr/bin/env node
|
||||
|
||||
import {
|
||||
prepareGenerationArtifact,
|
||||
publishGenerationArtifact,
|
||||
} from './live/generation-publisher.mjs';
|
||||
|
||||
const args = process.argv.slice(2);
|
||||
const result = args.includes('--prepare')
|
||||
? prepareGenerationArtifact({
|
||||
id: arg(args, '--id'),
|
||||
sourceFile: arg(args, '--file'),
|
||||
})
|
||||
: publishGenerationArtifact({
|
||||
id: arg(args, '--id'),
|
||||
epoch: Number(arg(args, '--epoch')),
|
||||
sourceFile: arg(args, '--file'),
|
||||
artifactFile: arg(args, '--artifact'),
|
||||
expectedSourceHash: arg(args, '--expected-source-hash'),
|
||||
arrivedVariants: optionalNumber(arg(args, '--arrived')),
|
||||
expectedVariants: optionalNumber(arg(args, '--expected')),
|
||||
publicationKind: arg(args, '--kind'),
|
||||
});
|
||||
|
||||
console.log(JSON.stringify(result));
|
||||
if (!result.ok) process.exitCode = 2;
|
||||
|
||||
function arg(values, name) {
|
||||
const index = values.indexOf(name);
|
||||
return index >= 0 ? values[index + 1] : undefined;
|
||||
}
|
||||
|
||||
function optionalNumber(value) {
|
||||
if (value === undefined) return undefined;
|
||||
const number = Number(value);
|
||||
return Number.isInteger(number) ? number : undefined;
|
||||
}
|
||||
@@ -383,7 +383,6 @@ function summarizeActiveSessionForClient(snapshot = {}) {
|
||||
browserCheckpointRevision: snapshot.browserCheckpointRevision ?? snapshot.checkpointRevision ?? 0,
|
||||
publicationCheckpointRevision: snapshot.publicationCheckpointRevision ?? 0,
|
||||
paramValues: snapshot.paramValues || {},
|
||||
paramsPublished: snapshot.paramsPublished === true,
|
||||
generationPhase: snapshot.generationPhase ?? null,
|
||||
generationCanceled: snapshot.generationCanceled === true,
|
||||
cancelReason: snapshot.cancelReason ?? null,
|
||||
|
||||
@@ -1,604 +0,0 @@
|
||||
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';
|
||||
|
||||
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 sourcePath = componentTarget?.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 = 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),
|
||||
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 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 || 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 = 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 = 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),
|
||||
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),
|
||||
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 non-component 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 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;
|
||||
}
|
||||
@@ -121,11 +121,7 @@ function baseSnapshot(id) {
|
||||
fallbackMode: null,
|
||||
generationPhase: null,
|
||||
generationTimings: {},
|
||||
generationEpoch: 1,
|
||||
publishedRevision: 0,
|
||||
deliveredVariants: {},
|
||||
variantPlan: null,
|
||||
paramsPublished: false,
|
||||
generationCanceled: false,
|
||||
generationCanceledAt: null,
|
||||
cancelReason: null,
|
||||
@@ -169,7 +165,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
paramValues: { ...(snapshot.paramValues || {}) },
|
||||
sourceMarkers: { ...(snapshot.sourceMarkers || {}) },
|
||||
generationTimings: { ...(snapshot.generationTimings || {}) },
|
||||
deliveredVariants: { ...(snapshot.deliveredVariants || {}) },
|
||||
variantPlan: snapshot.variantPlan || null,
|
||||
annotationArtifacts: [...(snapshot.annotationArtifacts || [])],
|
||||
diagnostics: [...(snapshot.diagnostics || [])],
|
||||
@@ -183,7 +178,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
switch (event.type) {
|
||||
case 'generate':
|
||||
next.phase = 'generate_requested';
|
||||
next.generationEpoch = Number(event.generationEpoch || next.generationEpoch || 1);
|
||||
next.pageUrl = event.pageUrl ?? next.pageUrl;
|
||||
next.expectedVariants = event.count ?? next.expectedVariants;
|
||||
next.pendingEventSeq = entry.seq ?? next.pendingEventSeq;
|
||||
@@ -204,40 +198,6 @@ function applyEvent(snapshot, entry, inheritedDiagnostics = []) {
|
||||
];
|
||||
}
|
||||
break;
|
||||
case 'variant_published':
|
||||
if (next.generationCanceled || GENERATION_FENCED_PHASES.has(next.phase)) {
|
||||
next.diagnostics.push({
|
||||
error: 'late_generation_event_ignored',
|
||||
type: event.type,
|
||||
phase: next.phase,
|
||||
revision: event.revision ?? null,
|
||||
});
|
||||
break;
|
||||
}
|
||||
if (Number(event.generationEpoch || 0) !== Number(next.generationEpoch || 1)) {
|
||||
next.diagnostics.push({
|
||||
error: 'stale_generation_epoch_ignored',
|
||||
epoch: event.generationEpoch ?? null,
|
||||
expectedEpoch: next.generationEpoch || 1,
|
||||
});
|
||||
break;
|
||||
}
|
||||
next.phase = 'variants_progress';
|
||||
next.publishedRevision = Math.max(next.publishedRevision || 0, Number(event.revision || 0));
|
||||
next.arrivedVariants = Math.max(next.arrivedVariants || 0, Number(event.arrivedVariants || 0));
|
||||
next.expectedVariants = Number(event.expectedVariants || next.expectedVariants || 0);
|
||||
if (event.publicationKind === 'params') next.paramsPublished = true;
|
||||
next.sourceFile = event.sourceFile ?? next.sourceFile;
|
||||
next.previewFile = event.previewFile ?? next.previewFile;
|
||||
next.previewMode = event.previewMode ?? next.previewMode;
|
||||
if (event.revision) {
|
||||
next.deliveredVariants[String(event.revision)] = {
|
||||
digest: event.digest || null,
|
||||
arrivedVariants: Number(event.arrivedVariants || 0),
|
||||
publishedAt: event.at || null,
|
||||
};
|
||||
}
|
||||
break;
|
||||
case 'agent_phase':
|
||||
next.generationPhase = event.phase ?? next.generationPhase;
|
||||
if (event.phase) {
|
||||
|
||||
Reference in New Issue
Block a user