Files
pbakaus_impeccable/tests/live-svelte-ast.test.mjs
T
Paul BakausandClaude Code 24d69675e0 fix: mixed loop/outer expressions fall back; globals are neither free nor bound
cursor[bot]: an expression mixing loop bindings with outer free names
(fmt(r.label) where fmt lives in the route script) was left verbatim, so
the detached preview referenced an undeclared identifier and failed at
mount, past the compile gate, because globals make it legal to the
compiler. Such expressions now mark the analysis unsupported and the
session takes source-preview mode. A globals allowlist makes Math/JSON
and friends count as neither free nor bound, which also fixes a latent
bug where a pure-global expression minted a nonsense prop.

Won't-fix on the same pass: the live-setup.md filename cross-reference
matches the repo's established reference-link convention.

This work was produced with AI assistance (Claude Code).

Co-Authored-By: Claude Code <noreply@anthropic.com>
2026-07-28 14:22:57 -07:00

261 lines
12 KiB
JavaScript

import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import { parse, compile } from 'svelte/compiler';
import {
analyzeSvelteMarkup,
buildPropsScriptV2,
collectRootIdentifiers,
derivePropName,
restoreSvelteMarkup,
} from '../skill/scripts/live/svelte-ast.mjs';
const PIT_BOARD = `<ol class="stages" data-active={activeId}>
{#each stages as stage, i}
<li class="stage">
<span class="label">{stage.label}</span>
<p class="detail">{stage.detail}</p>
{#if stage.active}<span class="dot"></span>{/if}
</li>
{/each}
<p class="footer">{footerNote}</p>
</ol>`;
describe('svelte AST scaffolding', () => {
it('turns a free each collection into one structured prop and keeps the loop intact', () => {
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
assert.equal(res.ok, true, res.reason);
const collection = res.contract.find((c) => c.kind === 'collection');
assert.equal(collection.prop, 'stages');
assert.equal(collection.expr, 'stages');
// The loop body survives verbatim: bound expressions are not props.
assert.match(res.markupWithProps, /\{#each stages as stage, i\}/);
assert.match(res.markupWithProps, /\{stage\.label\}/);
assert.match(res.markupWithProps, /\{#if stage\.active\}/);
// No prop entries for bound expressions.
assert.equal(res.contract.some((c) => c.expr.includes('stage.')), false);
});
it('records the item hydration description for each blocks', () => {
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
const collection = res.contract.find((c) => c.kind === 'collection');
assert.equal(collection.item.rootTag, 'li');
assert.deepEqual(collection.item.rootClasses, ['stage']);
assert.deepEqual(collection.item.textSlots.map((s) => s.key), ['label', 'detail']);
});
it('extracts free text and attribute expressions as props', () => {
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
const props = Object.fromEntries(res.contract.map((c) => [c.expr, c]));
assert.equal(props['footerNote'].kind, 'text');
assert.equal(props['activeId'].kind, 'text');
assert.match(res.markupWithProps, /data-active=\{activeId\}/);
});
it('names collection props from member tails and restores them precisely', () => {
const src = `<ul>{#each data.stages as s}<li>{s.name}</li>{/each}</ul>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
assert.match(res.markupWithProps, /\{#each stages as s\}/);
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
assert.equal(restored.ok, true);
assert.equal(restored.markup, src);
});
it('classifies event handler expressions as handler props', () => {
const src = `<button onclick={handleGo} class="go">{label}</button>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
const handler = res.contract.find((c) => c.expr === 'handleGo');
assert.equal(handler.kind, 'handler');
});
it('deduplicates repeated expressions and disambiguates name collisions', () => {
const src = `<div><h1>{a.title}</h1><h2>{b.title}</h2><p>{a.title}</p></div>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
const names = res.contract.map((c) => c.prop);
assert.deepEqual(names, ['title', 'title2']);
});
it('supports independent nested each blocks but rejects bound nested ones', () => {
const independent = `<div>{#each rows as r}<p>{r.x}</p>{/each}{#each cols as c}<p>{c.y}</p>{/each}</div>`;
const okRes = analyzeSvelteMarkup(independent, parse);
assert.equal(okRes.ok, true, okRes.reason);
assert.equal(okRes.contract.filter((c) => c.kind === 'collection').length, 2);
const nested = `<ul>{#each groups as g}<li>{#each g.items as item}<span>{item.n}</span>{/each}</li>{/each}</ul>`;
const badRes = analyzeSvelteMarkup(nested, parse);
assert.equal(badRes.ok, false);
assert.match(badRes.reason, /nested/);
});
it('falls back for constructs a detached preview cannot support', () => {
const cases = [
[`<div><Card title={t} /></div>`, /component tag/],
[`<input bind:value={query} />`, /bind:/],
[`<div use:tooltip>{t}</div>`, /use:/],
[`<div>{#await p}<p>wait</p>{:then v}<p>{v}</p>{/await}</div>`, /await/],
[`<div><script>let x = 1;<\/script><p>{t}</p></div>`, /script/],
[`<div {...rest}>{t}</div>`, /spread/],
];
for (const [src, reason] of cases) {
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, false, `expected fallback for: ${src}`);
assert.match(res.reason, reason, src);
}
});
it('treats class directives as condition props', () => {
const src = `<div class:active={isActive}><span>{label}</span></div>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
const cond = res.contract.find((c) => c.expr === 'isActive');
assert.equal(cond.kind, 'condition');
});
it('round-trips every supported corpus snippet', () => {
const corpus = [
PIT_BOARD,
`<ul>{#each data.stages as s, i (s.id)}<li>{s.name}</li>{/each}</ul>`,
`<section>{#if user.loggedIn}<p>{user.name}</p>{:else}<p>guest</p>{/if}</section>`,
`<div>{@html bodyHtml}</div>`,
`<article>{#each posts as post}<h2>{post.title}</h2>{#if post.pinned}<em>pinned</em>{/if}{/each}</article>`,
`<p title={hint}>{copy} and {copy}</p>`,
`<div>{@const label = row.name}<span>{label}</span></div>`,
`<nav>{#each links as link}<a href={link.href}>{link.text}</a>{/each}</nav>`,
];
for (const src of corpus) {
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, `${res.reason} in: ${src}`);
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
assert.equal(restored.ok, true, src);
assert.equal(restored.markup, src, `round trip failed for: ${src}`);
}
});
it('generates a props script that compiles with real svelte', () => {
const res = analyzeSvelteMarkup(PIT_BOARD, parse);
const component = `${buildPropsScriptV2(res.contract)}\n${res.markupWithProps}\n<style>\n .stage { display: flex; }\n</style>\n`;
const compiled = compile(component, { generate: 'client' });
assert.ok(compiled.js.code.length > 0);
const fatal = (compiled.warnings || []).filter((w) => /error/i.test(w.code || ''));
assert.deepEqual(fatal, []);
});
it('collectRootIdentifiers skips member properties and object keys', () => {
const ast = parse(`<p>{fmt(user.name, { width: cols })}</p>`, { modern: true });
const tag = ast.fragment.nodes[0].fragment.nodes[0];
const roots = collectRootIdentifiers(tag.expression);
assert.deepEqual([...roots].sort(), ['cols', 'fmt', 'user']);
});
it('derivePropName picks stable tails', () => {
assert.equal(derivePropName('stages'), 'stages');
assert.equal(derivePropName('data.stages'), 'stages');
assert.equal(derivePropName('rows[0].label'), 'label');
assert.equal(derivePropName('a + b'), 'value');
});
});
describe('keyed each blocks', () => {
it('records a keyField for member keys and keeps the key in the scaffold', () => {
const src = `<ul>{#each expenses as expense, i (expense.id)}<li class="row"><strong>{expense.name}</strong></li>{/each}</ul>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
const collection = res.contract.find((c) => c.kind === 'collection');
assert.equal(collection.item.keyField, 'id');
assert.match(res.markupWithProps, /\(expense\.id\)/);
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
assert.equal(restored.markup, src);
});
it('needs no keyField when the key is the item or the index', () => {
for (const src of [
`<ul>{#each rows as r (r)}<li>{r.x}</li>{/each}</ul>`,
`<ul>{#each rows as r, i (i)}<li>{r.x}</li>{/each}</ul>`,
]) {
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, `${res.reason} in ${src}`);
const collection = res.contract.find((c) => c.kind === 'collection');
assert.equal(collection.item.keyField, undefined, src);
}
});
it('falls back for keys a detached preview cannot hydrate distinctly', () => {
const cases = [
[`<ul>{#each rows as r (globalThing)}<li>{r.x}</li>{/each}</ul>`, /not derived from the loop item/],
[`<ul>{#each rows as r (makeKey(r))}<li>{r.x}</li>{/each}</ul>`, /complex each key/],
[`<ul>{#each rows as r (r.name)}<li>{r.name}</li>{/each}</ul>`, /also a displayed field/],
];
for (const [src, reason] of cases) {
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, false, `expected fallback for ${src}`);
assert.match(res.reason, reason, src);
}
});
});
describe('review regressions: reserved prop names', () => {
it('never derives a JS reserved word as a prop name (M5)', () => {
for (const [src, expected] of [
[`<div>{item.class}</div>`, 'classValue'],
[`<div>{cfg.default}</div>`, 'defaultValue'],
[`<div>{a.for}</div>`, 'forValue'],
]) {
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
assert.equal(res.contract[0].prop, expected, src);
// The generated stub must actually compile.
const stub = `${buildPropsScriptV2(res.contract)}\n${res.markupWithProps}\n`;
const compiled = compile(stub, { generate: 'client' });
assert.ok(compiled.js.code.length > 0, src);
// And restore back to the original expression.
const restored = restoreSvelteMarkup(res.markupWithProps, res.contract, parse);
assert.equal(restored.markup, src);
}
});
});
describe('review regressions: directives', () => {
it('class directives carry a className probe for live hydration', () => {
const src = `<div class:active={isActive}><span>{label}</span></div>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, true, res.reason);
const cond = res.contract.find((c) => c.expr === 'isActive');
assert.deepEqual(cond.probe, { className: 'active' });
});
it('style directives with dynamic values fall back to source-preview', () => {
const res = analyzeSvelteMarkup(`<div style:opacity={fade}>x</div>`, parse);
assert.equal(res.ok, false);
assert.match(res.reason, /style:opacity/);
});
it('style directives with static values stay supported', () => {
const res = analyzeSvelteMarkup(`<div style:color="red">{note}</div>`, parse);
assert.equal(res.ok, true, res.reason);
});
});
describe('review regressions: mixed and global identifiers', () => {
it('falls back for expressions mixing loop bindings with outer names', () => {
const src = `<ul>{#each rows as r}<li>{fmt(r.label)}</li>{/each}</ul>`;
const res = analyzeSvelteMarkup(src, parse);
assert.equal(res.ok, false);
assert.match(res.reason, /mixing loop and outer identifiers/);
});
it('treats known globals as neither free nor bound', () => {
// Global + bound: stays verbatim, no prop, no fallback.
const okRes = analyzeSvelteMarkup(`<ul>{#each rows as r}<li>{Math.round(r.score)}</li>{/each}</ul>`, parse);
assert.equal(okRes.ok, true, okRes.reason);
assert.equal(okRes.contract.some((c) => c.prop === 'round'), false);
assert.match(okRes.markupWithProps, /\{Math\.round\(r\.score\)\}/);
// Pure-global expression at top level: no prop minted either.
const topRes = analyzeSvelteMarkup(`<p>{JSON.stringify(navigator.language)}</p>`, parse);
assert.equal(topRes.ok, true, topRes.reason);
assert.equal(topRes.contract.length, 0);
});
});