From 9dade04bbf0b6e644d3df236a7c6e3c9470010b4 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 09:42:09 -0700 Subject: [PATCH 01/81] Text fallback presents surviving challengers as alternates The structured-tool channel collapsed to a single direction plus re-roll, which read as "the system only ever offers one idea" next to the multi-card decision page. Both channels now share one structure, assigned direction leading, the one or two fused challengers that survived the weighing as named alternates, re-roll with steer, and differ only in richness. The anti-lineup rule stays precise: what never appears is a ranked menu of the model's own grounded candidates; dealt challengers carry no ranking rut. Note: dist rebuild deliberately deferred; the release-gate campaign is running against the pinned dist and rebuilding mid-run aborts it. Co-Authored-By: Claude Fable 5 --- skill/reference/new-work.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index a2115c1f8..714d15a35 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -44,7 +44,7 @@ The script assigns which structure gets built: your top-ranked structure is what 2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; the audience's world is larger than that, so dig until the list spans at least three families. 3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience. 4. Run `node {{scripts_path}}/concept-seed.mjs --scope direction --mode ` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. -5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Offer re-roll with an optional one-line steer instead of a ranked menu; a lineup invites the safest card. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node {{scripts_path}}/serve-question.mjs --start --payload ` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry. +5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node {{scripts_path}}/serve-question.mjs --start --payload ` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry. Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product. From 9f5bbed8b8376c81433e180430af83e0955d250d Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 09:46:17 -0700 Subject: [PATCH 02/81] Bump astro test fixture to ^7.1.0 to clear dependabot XSS alerts The astro-vite7 live-e2e fixture pinned astro ^6.0.0, which resolves into the vulnerable range of three dependabot advisories: GHSA-4g3v-8h47-v7g6 (reflected XSS via View Transition animation properties, medium), GHSA-f48w-9m4c-m7f5 (XSS via spread attribute names in renderHTMLElement, medium), and GHSA-7pw4-f3q4-r2p2 (XSS via transition:* directive values, low). All three are patched by 7.1.0. Dev-only test fixture; the vulnerable code paths (View Transitions, transition directives, spread attributes) are not exercised by this static, non-hydrated page, so real exposure is nil. Bumped anyway as the cheap, correct fix. Also corrected the now-stale fixture label to "Astro 7 + Vite 7". Co-Authored-By: Claude Fable 5 --- tests/framework-fixtures/astro-vite7/files/package.json | 2 +- tests/framework-fixtures/astro-vite7/fixture.json | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/framework-fixtures/astro-vite7/files/package.json b/tests/framework-fixtures/astro-vite7/files/package.json index 33ba1a69d..bd826d09a 100644 --- a/tests/framework-fixtures/astro-vite7/files/package.json +++ b/tests/framework-fixtures/astro-vite7/files/package.json @@ -9,6 +9,6 @@ "preview": "astro preview" }, "devDependencies": { - "astro": "^6.0.0" + "astro": "^7.1.0" } } diff --git a/tests/framework-fixtures/astro-vite7/fixture.json b/tests/framework-fixtures/astro-vite7/fixture.json index 53ad21b50..92b409a44 100644 --- a/tests/framework-fixtures/astro-vite7/fixture.json +++ b/tests/framework-fixtures/astro-vite7/fixture.json @@ -1,5 +1,5 @@ { - "name": "Astro 6 + Vite 7", + "name": "Astro 7 + Vite 7", "config": { "files": ["src/layouts/Layout.astro"], "insertBefore": "", From 70fdc172b8c43cf949ff5acaa0554d9554ce8a91 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 09:52:31 -0700 Subject: [PATCH 03/81] Resolve detect DESIGN.md from each target's project, not cwd The detect CLI loaded DESIGN.md once from process.cwd() and applied it to every scan target. Scanning another project's files from inside a different repo therefore judged them against the wrong project's design system (cross-project contamination observed during eval work: running detect from impeccable-evals against a generated artifact elsewhere applied the evals repo's DESIGN.md). DESIGN.md now resolves by walking up from each scan target's own location to its design root: a directory carrying a DESIGN.md is the root; a directory carrying a project marker (.git / package.json / .impeccable) without a DESIGN.md is a boundary that stops the walk with no design system, so a sibling project never inherits a parent's or cwd's rules. A target with no design root above it falls back to no design system rather than cwd's. Resolution is memoized per root, so a multi-file scan reads each DESIGN.md once, and targets spanning projects each get their own. file:// URLs resolve from their path; remote http(s) URLs get no design system. Adds tests/detect-cli-design-contamination.test.mjs, which spawns the real CLI to prove B's file is not judged by A's DESIGN.md, that a project still governs its own file, that a mixed-project scan resolves per target, and that a marker-less bare file gets no design system. Co-Authored-By: Claude Fable 5 --- cli/engine/cli/main.mjs | 63 +++++++-- cli/engine/design-system.mjs | 62 ++++++++ scripts/test-suites.mjs | 3 +- .../detect-cli-design-contamination.test.mjs | 132 ++++++++++++++++++ 4 files changed, 245 insertions(+), 15 deletions(-) create mode 100644 tests/detect-cli-design-contamination.test.mjs diff --git a/cli/engine/cli/main.mjs b/cli/engine/cli/main.mjs index b3edfea8d..e52998b7e 100644 --- a/cli/engine/cli/main.mjs +++ b/cli/engine/cli/main.mjs @@ -1,7 +1,8 @@ import fs from 'node:fs'; import path from 'node:path'; +import { fileURLToPath } from 'node:url'; -import { loadDesignSystemForCwd } from '../design-system.mjs'; +import { loadDesignSystemForTarget } from '../design-system.mjs'; import { RULE_SCOPES, filterByScopes } from '../registry/antipatterns.mjs'; import { createBrowserDetector, detectUrl } from '../engines/browser/detect-url.mjs'; import { detectHtml } from '../engines/static-html/detect-html.mjs'; @@ -27,6 +28,15 @@ function formatFindingSummary(count) { return `${count} anti-pattern${count === 1 ? '' : 's'} found.`; } +// Local filesystem path behind a file:// URL, or null when it can't be mapped. +function fileUrlToLocalPath(url) { + try { + return fileURLToPath(url); + } catch { + return null; + } +} + function formatFindings(findings, jsonMode) { if (jsonMode) return JSON.stringify(findings, null, 2); @@ -52,7 +62,11 @@ function formatFindings(findings, jsonMode) { // Stdin handling // --------------------------------------------------------------------------- -async function handleStdin(options = {}) { +// `optionsFor` maps a local path to scan options carrying that path's own +// project design system (or base options when null). Falls back to a plain +// object so direct/legacy callers still work. +async function handleStdin(optionsFor = () => ({})) { + const resolve = typeof optionsFor === 'function' ? optionsFor : () => optionsFor; const chunks = []; for await (const chunk of process.stdin) chunks.push(chunk); const input = Buffer.concat(chunks).toString('utf-8'); @@ -60,11 +74,12 @@ async function handleStdin(options = {}) { const parsed = JSON.parse(input); const fp = parsed?.tool_input?.file_path; if (fp && fs.existsSync(fp)) { + const options = resolve(fp); return HTML_EXTENSIONS.has(path.extname(fp).toLowerCase()) ? detectHtml(fp, options) : detectText(fs.readFileSync(fp, 'utf-8'), fp, options); } } catch { /* not JSON */ } - return detectText(input, '', options); + return detectText(input, '', resolve(null)); } @@ -199,14 +214,23 @@ async function detectCli() { process.exit(1); } const designSystemEnabled = configEnabled && !args.includes('--no-design-system') && detectionConfig.designSystem?.enabled !== false; - const designSystem = designSystemEnabled ? loadDesignSystemForCwd(process.cwd()) : null; // Inline `impeccable-disable*` waivers are part of the scanned file, so they // apply by default. `--no-config` (raw scan) and the dedicated // `--no-inline-ignores` both turn them off. const inlineIgnoresEnabled = configEnabled && !args.includes('--no-inline-ignores'); - const scanOptions = { inlineIgnores: inlineIgnoresEnabled }; - if (designSystem) scanOptions.designSystem = designSystem; - if (viewport) scanOptions.viewport = viewport; + const baseScanOptions = { inlineIgnores: inlineIgnoresEnabled }; + if (viewport) baseScanOptions.viewport = viewport; + // DESIGN.md must resolve from EACH scan target's own project root, not from + // process.cwd(): scanning project B's files from inside project A applied A's + // design rules (cross-project contamination). Resolve per target, memoized by + // resolved project root so a multi-file scan pays the read once per project. + // A target with no project marker above it gets no design system (never cwd's). + const designSystemCache = new Map(); + const scanOptionsFor = (localPath) => { + if (!designSystemEnabled || !localPath) return baseScanOptions; + const designSystem = loadDesignSystemForTarget(localPath, { cache: designSystemCache }); + return designSystem ? { ...baseScanOptions, designSystem } : baseScanOptions; + }; const targets = args.filter(a => !a.startsWith('--')); if (helpMode) { printUsage(); process.exit(0); } @@ -214,7 +238,7 @@ async function detectCli() { let allFindings = []; if (!process.stdin.isTTY && targets.length === 0) { - allFindings = await handleStdin(scanOptions); + allFindings = await handleStdin(scanOptionsFor); } else { const paths = targets.length > 0 ? targets : [process.cwd()]; // file:// URLs get the same Puppeteer-rendered pass as http(s) — the @@ -228,10 +252,17 @@ async function detectCli() { try { for (const target of paths) { if (urlRe.test(target)) { + // A file:// URL points at a local artifact, so its design system + // resolves from that file's project. A remote http(s) URL has no + // local project — it gets base options (no design system), never + // process.cwd()'s. + const urlOptions = /^file:/i.test(target) + ? scanOptionsFor(fileUrlToLocalPath(target)) + : baseScanOptions; try { const scanner = browserDetector - ? (url) => browserDetector.detectUrl(url, scanOptions) - : (url) => detectUrl(url, scanOptions); + ? (url) => browserDetector.detectUrl(url, urlOptions) + : (url) => detectUrl(url, urlOptions); allFindings.push(...await scanner(target)); } catch (e) { process.stderr.write(`Error: ${e.message}\n`); } continue; @@ -297,11 +328,14 @@ async function detectCli() { for (const file of files) { const ext = path.extname(file).toLowerCase(); + // Each file resolves its own project design system (cached by root), + // so a scan spanning sibling projects applies the right rules per file. + const fileOptions = scanOptionsFor(file); let fileFindings; if (HTML_EXTENSIONS.has(ext)) { - fileFindings = await detectHtml(file, scanOptions); + fileFindings = await detectHtml(file, fileOptions); } else { - fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, scanOptions); + fileFindings = detectText(fs.readFileSync(file, 'utf-8'), file, fileOptions); } // Annotate findings with import context const importers = importedByMap.get(file); @@ -316,10 +350,11 @@ async function detectCli() { } else if (stat.isFile()) { if (shouldIgnoreDetectionFile(resolved, process.cwd(), detectionConfig)) continue; const ext = path.extname(resolved).toLowerCase(); + const fileOptions = scanOptionsFor(resolved); if (HTML_EXTENSIONS.has(ext)) { - allFindings.push(...await detectHtml(resolved, scanOptions)); + allFindings.push(...await detectHtml(resolved, fileOptions)); } else { - allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, scanOptions)); + allFindings.push(...detectText(fs.readFileSync(resolved, 'utf-8'), resolved, fileOptions)); } } } diff --git a/cli/engine/design-system.mjs b/cli/engine/design-system.mjs index 874b346d7..c4d31eade 100644 --- a/cli/engine/design-system.mjs +++ b/cli/engine/design-system.mjs @@ -1,4 +1,5 @@ import fs from 'node:fs'; +import os from 'node:os'; import path from 'node:path'; import { finding } from './findings.mjs'; @@ -7,6 +8,11 @@ import { parseAnyColor, resolveLengthPx } from './rules/checks.mjs'; const DESIGN_NAMES = ['DESIGN.md', 'Design.md', 'design.md']; const FALLBACK_DIRS = ['.agents/context', 'docs']; +// Files/dirs whose presence marks a directory as a project root. Mirrors the +// walk-up semantics of skill/scripts/context.mjs (`resolveProject`), which the +// CLI can't import (separate tree). `.git` and `package.json` are the common +// boundaries; `.impeccable` is our own project marker. +const PROJECT_ROOT_MARKERS = ['.git', 'package.json', '.impeccable']; const COLOR_CHANNEL_TOLERANCE = 6; const RADIUS_TOLERANCE_PX = 0.5; const FONT_SIZE_TOLERANCE_PX = 0.5; @@ -469,6 +475,62 @@ function loadDesignSystemForCwd(cwd = process.cwd()) { }); } +// Directory to begin the project-root walk from, given a scan target that may +// be a file or a directory (and may not exist yet). +function designSystemStartDir(targetPath, cwd = process.cwd()) { + const abs = path.isAbsolute(targetPath) ? targetPath : path.resolve(cwd, targetPath); + try { + return fs.statSync(abs).isDirectory() ? abs : path.dirname(abs); + } catch { + // Nonexistent path: treat an extension-bearing leaf as a file. + return path.extname(abs) ? path.dirname(abs) : abs; + } +} + +// Walk up from `startDir` to the directory that governs the target's design +// system, mirroring skill/scripts/context.mjs's project-boundary semantics: +// +// - A directory carrying a DESIGN.md (directly or in a fallback dir) IS the +// design root — that's where the rules live. +// - A directory carrying a project marker (.git / package.json / .impeccable) +// but no DESIGN.md is a project BOUNDARY: the walk stops with no design +// system, so a sibling project never inherits a parent's or cwd's rules. +// - Reaching the home directory / filesystem root with neither means no +// design system at all — never process.cwd()'s. +// +// Returns { dir, hasDesign } for the stopping directory, or null when the walk +// runs out. This is the fix for cross-project contamination. +export function findDesignRoot(startDir) { + let dir = path.resolve(startDir); + const homeDir = path.resolve(os.homedir()); + while (true) { + if (resolveDesignMdPath(dir)) return { dir, hasDesign: true }; + if (PROJECT_ROOT_MARKERS.some((marker) => fs.existsSync(path.join(dir, marker)))) { + return { dir, hasDesign: false }; + } + if (dir === homeDir) return null; + const parent = path.dirname(dir); + if (parent === dir) return null; + dir = parent; + } +} + +// Resolve the design system that governs a specific scan target, by walking up +// from the target's own location — never process.cwd(). Scanning project B's +// files from inside project A applies B's DESIGN.md (or none), not A's. +// +// Pass a `cache` Map to memoize by resolved design root across a multi-file +// scan; a target with no design root above it resolves to null. +export function loadDesignSystemForTarget(targetPath, { cache, cwd = process.cwd() } = {}) { + const startDir = designSystemStartDir(targetPath, cwd); + const found = findDesignRoot(startDir); + const key = found ? `root:${found.dir}` : '\0none'; + if (cache && cache.has(key)) return cache.get(key); + const loaded = found?.hasDesign ? loadDesignSystemForCwd(found.dir) : null; + if (cache) cache.set(key, loaded); + return loaded; +} + function isAllowedFont(font, designSystem) { if (!font || GENERIC_FONTS.has(font)) return true; if (!designSystem?.hasFonts) return true; diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index ea77cbf25..365f1dea4 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -86,7 +86,7 @@ export const SUITES = { /^scripts\/(benchmark-detector|build-browser-detector|build-extension)\.js$/, /^site\/(pages\/detector|public\/antipattern|data\/anti-patterns-catalog\.js)/, /^tests\/design-system\.test\.mjs$/, - /^tests\/(detect-antipatterns|inline-ignores|extension-build|fixtures\/antipatterns)/, + /^tests\/(detect-antipatterns|detect-cli-design-contamination|inline-ignores|extension-build|fixtures\/antipatterns)/, ], commands: [ { @@ -104,6 +104,7 @@ export const SUITES = { 'tests/design-system.test.mjs', 'tests/detect-antipatterns-fixtures.test.mjs', 'tests/detect-antipatterns-browser.test.mjs', + 'tests/detect-cli-design-contamination.test.mjs', ], }, ], diff --git a/tests/detect-cli-design-contamination.test.mjs b/tests/detect-cli-design-contamination.test.mjs new file mode 100644 index 000000000..908a44e08 --- /dev/null +++ b/tests/detect-cli-design-contamination.test.mjs @@ -0,0 +1,132 @@ +/** + * Regression: `impeccable detect ` must resolve DESIGN.md from + * EACH scan target's own project root, not from process.cwd(). + * + * The bug (found during eval work): running detect from repo A against a file + * that lives in repo B applied A's DESIGN.md to B — cross-project contamination. + * These tests spawn the real CLI so the fix is exercised end to end. + * + * Run with: node --test tests/detect-cli-design-contamination.test.mjs + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { spawnSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); +const CLI = path.resolve(__dirname, '../cli/bin/cli.js'); + +// Verdana is a plain web-safe font: it is not in OVERUSED_FONTS and trips no +// standalone rule, so the only way it becomes a `design-system-font` finding is +// if a DESIGN.md that forbids it gets applied. +const PAGE_HTML = + '' + + '
Hi
'; + +// A DESIGN.md whose typography allows only Palatino — Verdana violates it. +const DESIGN_MD = `--- +typography: + body: + fontFamily: "Palatino, Georgia, serif" +--- +# Project A Design System +`; + +const tempRoots = []; + +function mkProject({ withDesign, withMarker = true }) { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-contam-')); + tempRoots.push(dir); + if (withMarker) fs.writeFileSync(path.join(dir, 'package.json'), '{"name":"fixture"}'); + if (withDesign) fs.writeFileSync(path.join(dir, 'DESIGN.md'), DESIGN_MD); + const page = path.join(dir, 'page.html'); + fs.writeFileSync(page, PAGE_HTML); + return { dir, page }; +} + +// Run the CLI from `cwd`; force the node binary so the HTML/jsdom path never +// runs under bun (which is unusably slow). +function runDetect(cwd, targets) { + const result = spawnSync(process.execPath, [CLI, 'detect', '--json', ...targets], { + cwd, + encoding: 'utf-8', + }); + let findings = []; + try { + findings = JSON.parse(result.stdout || '[]'); + } catch { + throw new Error(`Non-JSON CLI output.\nstdout: ${result.stdout}\nstderr: ${result.stderr}`); + } + return findings; +} + +function fontFindingsFor(findings, file) { + return findings.filter( + (f) => f.antipattern === 'design-system-font' && (!file || f.file === file), + ); +} + +let projA; +let projB; + +before(() => { + projA = mkProject({ withDesign: true }); // DESIGN.md forbids Verdana + projB = mkProject({ withDesign: false }); // its own project, no DESIGN.md +}); + +after(() => { + for (const dir of tempRoots) { + try { fs.rmSync(dir, { recursive: true, force: true }); } catch { /* best effort */ } + } +}); + +describe('detect CLI DESIGN.md resolution', () => { + it('does NOT apply cwd project A\'s DESIGN.md to project B\'s file (the contamination bug)', () => { + const findings = runDetect(projA.dir, [projB.page]); + assert.deepEqual( + fontFindingsFor(findings, projB.page).map((f) => f.ignoreValue), + [], + 'project B\'s Verdana must not be flagged by project A\'s DESIGN.md', + ); + }); + + it('still applies a project\'s own DESIGN.md to its own file (positive control)', () => { + const findings = runDetect(projA.dir, [projA.page]); + assert.ok( + fontFindingsFor(findings, projA.page).some((f) => f.ignoreValue === 'verdana'), + 'project A\'s own DESIGN.md must flag Verdana in project A\'s file', + ); + }); + + it('resolves per target when one scan spans two projects', () => { + const findings = runDetect(projA.dir, [projA.page, projB.page]); + assert.ok( + fontFindingsFor(findings, projA.page).length > 0, + 'A\'s file should be judged against A\'s DESIGN.md', + ); + assert.equal( + fontFindingsFor(findings, projB.page).length, + 0, + 'B\'s file should NOT be judged against A\'s DESIGN.md', + ); + }); + + it('falls back to no design system for a bare file with no project markers above it', () => { + // A lone file whose directory has neither .git, package.json, nor .impeccable. + const bareDir = fs.mkdtempSync(path.join(os.tmpdir(), 'impeccable-detect-bare-')); + tempRoots.push(bareDir); + const barePage = path.join(bareDir, 'page.html'); + fs.writeFileSync(barePage, PAGE_HTML); + + const findings = runDetect(projA.dir, [barePage]); + assert.equal( + fontFindingsFor(findings, barePage).length, + 0, + 'a project-less file must fall back to no design system, not cwd\'s', + ); + }); +}); From e409bec7b54ba215a7f3bf341fa7e2af84a7e4e1 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 09:53:49 -0700 Subject: [PATCH 04/81] Canon standing exit, chosen-card directive, and the ambition fixes From Paul's approved UX and the eb686f36 session post-mortem: The standing exit: direction rounds carry a quiet, permanent "Play it straight" action (payload flag canon, reserved id) on the decision page and as the last structured-tool option. It is the user's door, never the model's: never recommended, never weighed against the roll, and choosing it swaps the bar rather than lowering it, two or three named reference products become the craft level, canon executed at full commitment. Safer/conventional steers resolve here, never to a stranger re-roll. Session fixes, each mechanical where possible: the ANSWER line now names the chosen card's hero and board and directs opening them before code (the session built from text alone after viewing a different world's card); generation scale joins the imagery rule (a library of centered 128px subjects foreclosed the atmospheric hero); DESIGN.md rules are checked against the world's native devices and never added to silence a hook finding (the session banned arcade lettering's own offset shadow and laundered 8px through the ramp); staging joins the FORM contract block (the axis was dropped silently at world-choice); the finishing reviewer audits the ceiling against the QUALITY BAR card after persistence (floor rigor was disguising unreached ambition); the icon-tile clause names hand-drawn icons as remedy, not target. Dist rebuild deferred: the release-gate campaign reads the pinned dist. Co-Authored-By: Claude Fable 5 --- skill/reference/new-work.md | 16 ++++++++----- skill/scripts/serve-question.mjs | 41 ++++++++++++++++++++++++++++---- 2 files changed, 47 insertions(+), 10 deletions(-) diff --git a/skill/reference/new-work.md b/skill/reference/new-work.md index 714d15a35..0b67ffd61 100644 --- a/skill/reference/new-work.md +++ b/skill/reference/new-work.md @@ -44,9 +44,11 @@ The script assigns which structure gets built: your top-ranked structure is what 2. From that cultural world, list seven concrete visual systems, artifacts, places, or rituals the audience knows by heart, each with one line on why it resonates and can carry the mechanism, ordered by resonance. The audience's world includes its graphic and screen traditions, the notation, publications, identity programs, data graphics, and interfaces it reads daily, not only its physical objects; a nameable abstract system (a school of poster, a documentation standard, a data-graphic tradition) is as concrete a candidate as any artifact. What would this thing look like as a physical object; what did its world look like before the web? Near-duplicates count once. When more than three of the seven share one material family, the derivation stopped at the subject's most obvious artifact; the audience's world is larger than that, so dig until the list spans at least three families. 3. Turn that material into complete directions: each joins a reusable visual world to a concrete first-surface experience. 4. Run `node {{scripts_path}}/concept-seed.mjs --scope direction --mode ` and follow what it prints. The script assigns which direction gets built and deals catalog challengers. Fuse each challenger before judging it: the challenger supplies the form and its system grammar, the product supplies every fact, and clarity wins conflicts. Weigh fused challengers against the assigned direction on exactly two axes, audience identification and product clarity; losing to strong grounded material is a valid outcome, and beating a thin or tool-monoculture list is the point. -5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll plus steer enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node {{scripts_path}}/serve-question.mjs --start --payload ` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry. +5. Present one direction, fully committed: its world, first viewport, visitor path, signature interaction, cross-surface reach, and honest risk. Alongside it, offer the one or two fused challengers that survived the weighing as named alternates with a one-line case each, plus re-roll with an optional one-line steer. What you never present is a ranked menu of your own grounded candidates; a lineup of those invites the safest card, while dealt challengers carry no such rut. The two channels share this structure and differ only in richness: cards and boards on the decision page, names and one-liners through the structured tool; the structured tool's option list also carries the standing exit as its last option. -Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build (when it only reads local images, download the card to a temp file first and view that): they set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product. +The standing exit: every direction round offers one quiet, permanent alternative, the category standard, played straight. It is the user's door, never yours: never recommend it, never weigh it against the roll, never take it on your own judgment, and never let its existence soften the dealt directions; the counterweights bind the unchosen default, not the chosen one. When the user takes it, or asks in a steer or in any words for the safe, familiar, or competitor-like path, convention becomes the commitment: ask once for two or three products this should sit alongside, make their craft level the quality bar, and execute the category's canon at full fidelity, conventions embraced without irony, without a smuggled quirk, and without the half-heartedness that treats the choice as a defeat. A steer asking for safer or more conventional resolves here, never to a stranger re-roll. If the user states it as a standing preference, record it as a brand commitment in PRODUCT.md so no future surface re-litigates it. Re-roll eliminates every direction already shown, grounded and challenger alike; after two consecutive re-rolls, ask what quality is missing. You may re-roll on your own only on named factual grounds, when the assigned direction cannot carry the product's truth or task; taste is never grounds. The user may re-roll freely, and a user- or brief-pinned direction beats the roll, always. Pick the channel by capability, not by habit: can you put a page in front of the user, through an in-app browser or by opening a browser window on their machine outside your harness? If yes, present the decision visually: write an options payload with the assigned direction leading, the dealt challengers as alternates carrying their QUALITY BAR cards, and re-roll, steer, plus canon enabled; a degraded roll with no challengers still uses the page, as a single text-only card with re-roll, then run `node {{scripts_path}}/serve-question.mjs --start --payload ` (run it with `--schema` first for the exact payload shape). It daemonizes, prints the page URL and a key, and exits immediately; now open that URL for the user, preferring the in-app browser when the harness has one, then the system opener, then showing the URL. Collect the choice with `--wait --key `, repeating while it exits 3; the ANSWER prints as JSON. Exit 4 means the page was closed without an answer: re-present once through the structured question tool, and with no answer there either, proceed unattended with the assigned direction and state the assumptions. A harness that can leave a shell blocked in the background may instead run the script without `--start` and let it auto-open and block. If no, because the session is headless, CI, an eval worker, or a remote shell with no display, skip the page and put the same decision through the structured question tool; the script self-detects these environments and exits 2 with that advice, so treat exit 2 as this fallback, never as an error to retry. + +Catalog worlds are working systems, not mood references. When one survives, carry its palette and material, type and composition, topology, controls and state, and responsive rules into the product. When the source is itself an interface language, commit to its native grammar across navigation, content, controls, and states. When the harness can view images, open the QUALITY BAR board and hero the seed prints for the world you build, at the moment the choice lands, and again even if you viewed a different world's card earlier in the round; a card opened for calibration before the choice tells you nothing about the world the user actually picked, and the ANSWER line names the chosen card's images for exactly this reason (when the harness only reads local images, download the card to a temp file first and view that). They set the craft level the build must reach, the finish, commitment, and art direction of a rendered reference, and never dictate the composition; your surface serves this product. Every direction the roll can land on must already be viable: every relationship and claim it visualizes true, a real palette and component family, a distinctive composition with one product-specific experience, workable at full-surface scale within the available assets, tools, and performance budget. A candidate that fails on truth is replaced before the roll, never rescued by it. Truth binds claims, not demonstrations: in greenfield work, author whatever illustrative material the concept needs at full fidelity, label it synthetic wherever a visitor could mistake it for the real thing, and hand the user the list of what to replace with real material. What stays uninventable are commercial and factual claims: prices, customers, benchmarks, endpoints, capabilities the product does not have. Refusing a bold direction because its demonstration data does not exist yet is the timidity reflex wearing honesty's clothes. @@ -66,10 +68,12 @@ Your measured rendition prior: warm, bookish, family, and child-facing subjects ## 5. Record the decision -Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, and the seed key the script printed. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract. +Before code, state the chosen direction as a contract in the artifact's opening comment, five short blocks, 150 words at most. THESIS: the one idea this surface owns and the category-default arrangement it refuses. OWN-WORLD: the palette and component language, specific enough to be recognizable with all content removed. STORY: what the visitor understands, believes, and does. FIRST VIEWPORT: the exact composition, what is where and at what scale, and where the primary action sits. FORM: the chosen form, its position on your ordered list, the staging chosen for it, and the seed key the script printed; when the seed dealt stagings, choosing the world does not silently discharge the staging decision, so name the one you commit to. If a block reads like a mood, the direction is not decided yet; the finishing review audits the render against this contract. When a new or replacement world is chosen, DESIGN.md is part of recording the decision, not an aftercare step: write it at the appropriate project or app boundary using [document.md](document.md) before the first build edit lands, in the same working stretch as the direction contract. Record only durable system rules; exact tokens may remain provisional until the first build establishes them, and you update the file when the build settles them. A new world shipped with no DESIGN.md is an incomplete run, exactly as a missing PRODUCT.md is; the finishing review checks the file exists and matches the built world. An ordinary extension does not rewrite DESIGN.md. +Two ways a DESIGN.md rule goes wrong, both observed: a rule you invent that bans a device the world itself uses natively (arcade lettering lives on its offset shadow, print on its misregistration), which reads as discipline while quietly fighting the form, so check every prohibition you write against the world's own materials; and a token added to DESIGN.md so a hook finding stops firing, which launders the value instead of judging it, so a value earns its place by the world and by legibility, never by silencing a check. + If the work establishes durable strategy for a route or artifact, read its existing surface brief, then update it: `node {{scripts_path}}/surface-brief.mjs read ` @@ -88,8 +92,8 @@ Build the assigned direction, not a safer interpretation of it. The form supplie - **The first viewport is a thesis, not a header.** Demonstrate the mechanism immediately, at the scale the form has in life; do not trap the concept inside a standard hero or card shell. The memory test: if someone left after one viewport, what would they describe an hour later? If the honest answer is a mood, the concept has not committed yet. - **Prove, don't claim.** Show the subject doing its job: the interface at work, the mechanism dramatized, specifics a competitor could not copy-paste. Sections that restate a claim in different words add length, not substance. Demonstration data is design material: author it at full fidelity and label it synthetic; never invent prices, customers, benchmarks, or capabilities. -- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, icon tiles where an authored asset belongs, is the same gap wearing chrome's. -- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs. +- **Author the assets; never substitute chrome.** Great surfaces live on carefully made content: names, entries, titles, copy, covers, thumbnails, textures. In greenfield work every blank the ask round left open is yours to author at production fidelity; content is authorable, claims are labelable, and no section is omittable. When a commercial claim stayed unanswered, ship a clearly marked placeholder value and hand the user the replacement list; a section thinned or dropped for missing truth is the asset gap wearing honesty's clothes. Decoration compensating for missing content, gradients, glass, borders, generic icon tiles where an authored asset belongs, is the same gap wearing chrome's; icons drawn by hand in the world's own grammar are authored assets, the remedy this rule protects, never its target. +- **Generate the imagery the build needs.** When any image generation is available, a harness-native tool or the API fallback context.mjs reports, generating the design's imagery is part of building: heroes, covers, demonstration thumbnails, textures, marks. Gray boxes and stock-styled placeholders are not a fallback while generation is one command away; state the cost once before the first render and batch what the surface needs. Generate at the scale the composition needs: a first viewport that wants atmosphere is generated as a scene, full-bleed and layered, with foreground, subject, and depth; a library of small centered subjects on plain grounds cannot compose into one, and standardizing every asset to icon scale for tidiness or file size forecloses the page's ambition before a line of code exists. - **Build the form's web leverage.** When the chosen world names a technique (canvas, WebGL, view transitions, generative motion), build the technique itself, not a static imitation of it; the graceful fallback serves constrained clients, it is not the default experience. - **Pace the scroll like a studio.** Vary density, scale, image, motion, and quiet inside one grammar; a dense passage earns a quiet one, and the page ends anchored by a real close. One spacing rhythm throughout, with more space above a heading than below it. - **Use real, verified imagery when the brief implies it.** Search for the subject's physical object rather than the category; one decisive photo beats five mediocre ones. Verify stock URLs resolve. @@ -101,4 +105,4 @@ Preserve semantics, accessibility, performance, responsiveness, project conventi Inspect desktop and mobile, critique the render against the user's request, the direction contract, and DESIGN.md, fix material gaps, and re-inspect. On a Persuade surface, verify the mode did its job: a first-time visitor should know what this is, why it matters, and what to do within seconds, in the form's own vocabulary. After a first implementation of a new world, update DESIGN.md with the exact tokens and behaviors that survived the build. -When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector. +When the harness can run a separate agent, this review belongs there, not in the build thread: give it the original request, confirmed answers, the artifact path, its direction contract, DESIGN.md, and existing hook findings. The reviewer's first check is persistence: on a new or replacement world, PRODUCT.md and DESIGN.md exist and DESIGN.md matches the built world; a missing file fails the review before any craft point is scored. Its second check is the ceiling, against the chosen world's QUALITY BAR card when one exists: name the world's native devices the build left unused, its frame, its depth, its lettering treatment, its ornament density, because rigor about the floor, contrast, tokens, breakpoints, is very good at disguising a build that never reached for the card's level. Then ask for a short list of material fixes, promise by promise against the contract, apply them, and finish. Do not run a second detector. diff --git a/skill/scripts/serve-question.mjs b/skill/scripts/serve-question.mjs index 05270c5f3..42c6aeaec 100644 --- a/skill/scripts/serve-question.mjs +++ b/skill/scripts/serve-question.mjs @@ -30,6 +30,8 @@ * }, ... * ], * "reroll": true, // adds a re-roll action (returns {"optionId":"reroll"}) + * "canon": true, // adds the quiet "Play it straight" standing exit + * // (returns {"optionId":"canon"}); direction rounds only * "steer": true // adds a free-text steer field returned with any answer * } * @@ -93,6 +95,24 @@ if (wantsBrowser && !process.env.IMPECCABLE_QUESTION_FORCE) { } } +// Both answer channels (blocking stdout and --wait collection) print through +// this: the ANSWER line, then a directive to open the chosen card's imagery +// when it has any. The card viewing happens at the moment of choice, in the +// working turn, because a build that never reopens the chosen world's board +// and hero calibrates on nothing. +function printAnswer(raw) { + console.log(`ANSWER: ${raw}`); + try { + const a = JSON.parse(raw); + if (a.hero || a.board) { + console.log("CHOSEN CARD: open the chosen world's board and hero images now, before any code (download local copies first when your harness only reads files); they set the craft bar the build must reach."); + } + if (a.optionId === 'canon') { + console.log('CANON CHOSEN: the user picked the category standard on purpose. Ask once for two or three products this should sit alongside; their craft level becomes the quality bar. Execute the canon at full commitment, conventions embraced without irony or smuggled quirk.'); + } + } catch { /* raw answer */ } +} + const payloadPath = arg('payload'); const timeoutSec = Number(arg('timeout', '900')); const portArg = Number(arg('port', '0')); @@ -109,9 +129,10 @@ if (hasFlag('schema')) { { id: 'challenger-teletext', label: 'Teletext Service', lineage: 'broadcast teletext magazines', body: 'Fused alternate.', hero: 'https://impeccable.style/worlds/cards/broadcast-programming-teletext-service-hero.webp' }, ], reroll: true, + canon: true, steer: true, }, null, 2)); - console.log('\nOption ids return verbatim in ANSWER; "reroll" is reserved. hero/board accept URLs or local paths.'); + console.log('\nOption ids return verbatim in ANSWER; "reroll" and "canon" are reserved. hero/board accept URLs or local paths. canon adds a quiet standing "Play it straight" action for direction decisions: the user\'s explicit door to the category standard. Include it only for visual-direction rounds; never present canon as your own recommendation.'); process.exit(0); } @@ -144,7 +165,7 @@ if (hasFlag('wait')) { } if (!answered()) { console.log(`WAITING: no answer yet after ${pollSec}s; run --wait --key ${key} again`); process.exit(3); } const collected = fs.readFileSync(answerFile(key), 'utf8').trim(); - console.log(`ANSWER: ${collected}`); + printAnswer(collected); // A re-roll keeps the table open: the server stays alive awaiting --update, // so only the answer file is consumed. Terminal choices clean up fully. let isRerollAnswer = false; @@ -354,6 +375,11 @@ function page() { #reroll { display: inline-flex; align-items: center; align-self: stretch; gap: 8px; padding: 0 16px; font-family: var(--ks-mono); font-size: .72rem; letter-spacing: .08em; text-transform: uppercase; color: var(--ks-kinpaku); background: transparent; border: 1px solid var(--ks-rule); border-radius: 6px; cursor: pointer; transition: border-color .2s ease, color .2s ease; } #reroll:hover { color: var(--ks-kinpaku-pale); border-color: var(--ks-kinpaku-deep); } #reroll svg { width: 15px; height: 15px; } + /* The quiet exit: always available, never argued with, visually subordinate + to the dealt cards and the re-roll so it reads as the user's own door, + not a recommendation. */ + #canon { align-self: center; padding: 0 4px; font-family: var(--ks-mono); font-size: .66rem; letter-spacing: .08em; text-transform: uppercase; color: inherit; opacity: .45; background: transparent; border: none; border-bottom: 1px dotted currentColor; cursor: pointer; transition: opacity .2s ease; } + #canon:hover { opacity: .85; } .card.skeleton .media { background: var(--ks-graphite); } .shimmer { width: 100%; height: 100%; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; } .card.skeleton .line { height: 11px; border-radius: 4px; background: linear-gradient(100deg, var(--ks-graphite) 35%, var(--ks-graphite-2) 50%, var(--ks-graphite) 65%); background-size: 220% 100%; animation: shimmer 1.4s linear infinite; } @@ -388,6 +414,7 @@ function page() {
${payload.steer ? '' : ''} ${payload.reroll ? '' : ''} + ${payload.canon ? '' : ''}
\n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/skill/scripts/live-server.mjs b/skill/scripts/live-server.mjs index e2eb871da..c9fc56ed4 100644 --- a/skill/scripts/live-server.mjs +++ b/skill/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.agents/skills/impeccable/scripts/live-server.mjs b/.agents/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.agents/skills/impeccable/scripts/live-server.mjs +++ b/.agents/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.claude/skills/impeccable/scripts/live-server.mjs b/.claude/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.claude/skills/impeccable/scripts/live-server.mjs +++ b/.claude/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.cursor/skills/impeccable/scripts/live-server.mjs b/.cursor/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.cursor/skills/impeccable/scripts/live-server.mjs +++ b/.cursor/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.gemini/skills/impeccable/scripts/live-server.mjs b/.gemini/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.gemini/skills/impeccable/scripts/live-server.mjs +++ b/.gemini/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.github/skills/impeccable/scripts/live-server.mjs b/.github/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.github/skills/impeccable/scripts/live-server.mjs +++ b/.github/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.grok/skills/impeccable/scripts/live-server.mjs b/.grok/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.grok/skills/impeccable/scripts/live-server.mjs +++ b/.grok/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.kiro/skills/impeccable/scripts/live-server.mjs b/.kiro/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.kiro/skills/impeccable/scripts/live-server.mjs +++ b/.kiro/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.opencode/skills/impeccable/scripts/live-server.mjs b/.opencode/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.opencode/skills/impeccable/scripts/live-server.mjs +++ b/.opencode/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.pi/skills/impeccable/scripts/live-server.mjs b/.pi/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.pi/skills/impeccable/scripts/live-server.mjs +++ b/.pi/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.qoder/skills/impeccable/scripts/live-server.mjs b/.qoder/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.qoder/skills/impeccable/scripts/live-server.mjs +++ b/.qoder/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.rovodev/skills/impeccable/scripts/live-server.mjs b/.rovodev/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.rovodev/skills/impeccable/scripts/live-server.mjs +++ b/.rovodev/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.trae-cn/skills/impeccable/scripts/live-server.mjs b/.trae-cn/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.trae-cn/skills/impeccable/scripts/live-server.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.trae/skills/impeccable/scripts/live-server.mjs b/.trae/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.trae/skills/impeccable/scripts/live-server.mjs +++ b/.trae/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/.vibe/skills/impeccable/scripts/live-server.mjs b/.vibe/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/.vibe/skills/impeccable/scripts/live-server.mjs +++ b/.vibe/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected \n' + + '\n' + open + ' ' + MARKER_CLOSE_TEXT + ' ' + close + '\n' ); } @@ -528,9 +548,9 @@ function readLineEndingAt(content, index) { return ''; } -function insertTag(content, config, port, filePath) { +function insertTag(content, config, port, filePath, token) { const lineEnding = detectLineEnding(content); - const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath), lineEnding); + const block = normalizeLineEndings(buildTagBlock(config.commentSyntax, port, filePath, token), lineEnding); // insertBefore: match the LAST occurrence. Anchors like `` naturally // belong at the end, and the same literal can appear earlier in code blocks // within rendered documentation pages. diff --git a/plugin/skills/impeccable/scripts/live-server.mjs b/plugin/skills/impeccable/scripts/live-server.mjs index c6e951086..c9fc56ed4 100644 --- a/plugin/skills/impeccable/scripts/live-server.mjs +++ b/plugin/skills/impeccable/scripts/live-server.mjs @@ -624,13 +624,37 @@ function statOrNull(filePath) { try { return fs.statSync(filePath); } catch { return null; } } +// Strict loopback-origin test for CORS. Parses the Origin as a URL (never a +// substring match, so `http://localhost.evil.com` and `http://127.0.0.1.evil.com` +// fail) and accepts only http/https on localhost, 127.0.0.1, or the IPv6 loopback. +function isLoopbackOrigin(origin) { + if (typeof origin !== 'string' || origin.length === 0) return false; + let parsed; + try { parsed = new URL(origin); } catch { return false; } + if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') return false; + const host = parsed.hostname.toLowerCase(); + return host === 'localhost' || host === '127.0.0.1' || host === '::1' || host === '[::1]'; +} + // HTTP request handler // --------------------------------------------------------------------------- function createRequestHandler({ detectScript, liveScriptParts }) { return (req, res) => { const url = new URL(req.url, `http://localhost:${state.port}`); - res.setHeader('Access-Control-Allow-Origin', '*'); + // Loopback-restricted CORS. Reflect the caller's Origin only when it is a + // loopback origin, always paired with `Vary: Origin` so an intermediary + // cache never serves a response authorized for one origin to another. A + // remote page (e.g. https://evil.example probing the port from a tab open + // on the same machine) gets no Access-Control-Allow-Origin, so its + // JS-initiated fetch cannot read any response. Requests with no Origin + // header (script tags, curl, the agent's own fetches) are not subject to + // CORS and keep working; no ACAO header is needed for them. + const origin = req.headers.origin; + if (origin && isLoopbackOrigin(origin)) { + res.setHeader('Access-Control-Allow-Origin', origin); + res.setHeader('Vary', 'Origin'); + } res.setHeader('Access-Control-Allow-Methods', 'GET, POST, OPTIONS'); res.setHeader('Access-Control-Allow-Headers', 'Content-Type'); if (req.method === 'OPTIONS') { res.writeHead(204); res.end(); return; } @@ -639,6 +663,15 @@ function createRequestHandler({ detectScript, liveScriptParts }) { // --- Scripts --- if (p === '/live.js') { + // Token-gated: the script body embeds state.token, which unlocks every + // token-guarded route. Serving it unauthenticated let any local page read + // the token and drive the session. The injected + + diff --git a/tests/framework-fixtures/tanstack-router-vite/files/package.json b/tests/framework-fixtures/tanstack-router-vite/files/package.json new file mode 100644 index 000000000..95acee9ee --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/package.json @@ -0,0 +1,20 @@ +{ + "name": "tanstack-router-vite-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite --host 127.0.0.1", + "build": "vite build", + "preview": "vite preview" + }, + "dependencies": { + "@tanstack/react-router": "^1.132.0", + "react": "^19.0.0", + "react-dom": "^19.0.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx b/tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx new file mode 100644 index 000000000..ae32f1b35 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/main.jsx @@ -0,0 +1,37 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; +import { + createRootRoute, + createRoute, + createRouter, + RouterProvider, + Outlet, + Link, +} from '@tanstack/react-router'; +import Home from './routes/Home.jsx'; +import About from './routes/About.jsx'; +import './styles.css'; + +const rootRoute = createRootRoute({ + component: () => ( + <> + + + + ), +}); + +const indexRoute = createRoute({ getParentRoute: () => rootRoute, path: '/', component: Home }); +const aboutRoute = createRoute({ getParentRoute: () => rootRoute, path: '/about', component: About }); + +const routeTree = rootRoute.addChildren([indexRoute, aboutRoute]); +const router = createRouter({ routeTree }); + +createRoot(document.getElementById('root')).render( + + + , +); diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx new file mode 100644 index 000000000..ae4c31e15 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/About.jsx @@ -0,0 +1,8 @@ +export default function About() { + return ( +
+

About Page Hero

+

Lives on the /about route — only mounts after navigation.

+
+ ); +} diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx new file mode 100644 index 000000000..3c022d03d --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/routes/Home.jsx @@ -0,0 +1,8 @@ +export default function Home() { + return ( +
+

Home

+

Welcome. The hero we'll edit lives on the About page.

+
+ ); +} diff --git a/tests/framework-fixtures/tanstack-router-vite/files/src/styles.css b/tests/framework-fixtures/tanstack-router-vite/files/src/styles.css new file mode 100644 index 000000000..7d2c6e91f --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/src/styles.css @@ -0,0 +1,6 @@ +body { margin: 0; font-family: system-ui, sans-serif; } +.nav { display: flex; gap: 1rem; padding: 1rem; border-bottom: 1px solid #eee; } +.nav a { color: #111; text-decoration: none; } +.page { padding: 2rem; } +.hero-title { font-size: 2rem; margin: 0 0 0.5rem; } +.hero-hook { color: #555; } diff --git a/tests/framework-fixtures/tanstack-router-vite/files/vite.config.js b/tests/framework-fixtures/tanstack-router-vite/files/vite.config.js new file mode 100644 index 000000000..f7ad4b565 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/files/vite.config.js @@ -0,0 +1,7 @@ +import { defineConfig } from 'vite'; +import react from '@vitejs/plugin-react'; + +export default defineConfig({ + plugins: [react()], + server: { host: '127.0.0.1', strictPort: false }, +}); diff --git a/tests/framework-fixtures/tanstack-router-vite/fixture.json b/tests/framework-fixtures/tanstack-router-vite/fixture.json new file mode 100644 index 000000000..e5f96bd86 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/fixture.json @@ -0,0 +1,49 @@ +{ + "name": "Vite 8 + TanStack Router (code-based SPA)", + "config": { + "files": ["index.html"], + "insertBefore": "", + "commentSyntax": "html" + }, + "sourceFiles": [ + "index.html", + "src/main.jsx", + "src/routes/Home.jsx", + "src/routes/About.jsx", + "src/styles.css", + "vite.config.js" + ], + "generatedFiles": [], + "wrapCases": [ + { + "name": "wraps About hero in routes/About.jsx", + "args": { "classes": "hero-title", "tag": "h1" }, + "expectedFile": "src/routes/About.jsx" + } + ], + "runtime": { + "styling": "plain-css", + "install": ["npm", "install", "--no-audit", "--no-fund", "--loglevel=error"], + "devCommand": ["npx", "vite", "--host", "127.0.0.1"], + "readyPattern": "Local:\\s+https?://[^:]+:(\\d+)", + "readyTimeoutMs": 120000, + "preActions": [ + { "type": "click", "selector": "[data-testid='nav-about']" }, + { "type": "wait", "selector": "h1.hero-title" } + ], + "reloadProbe": { + "preActions": [ + { "type": "click", "selector": "[data-testid='nav-about']" }, + { "type": "wait", "selector": "h1.hero-title" } + ], + "expectSelector": "h1.hero-title" + }, + "probe": { + "expectLiveInit": true, + "expectConsoleClean": true + }, + "steer": { + "sourceFile": "src/routes/About.jsx" + } + } +} diff --git a/tests/framework-fixtures/tanstack-router-vite/gitignore.txt b/tests/framework-fixtures/tanstack-router-vite/gitignore.txt new file mode 100644 index 000000000..8cda9ad20 --- /dev/null +++ b/tests/framework-fixtures/tanstack-router-vite/gitignore.txt @@ -0,0 +1,4 @@ +node_modules/ +dist/ +.vite/ +package-lock.json diff --git a/tests/framework-fixtures/tanstack-start/files/package.json b/tests/framework-fixtures/tanstack-start/files/package.json new file mode 100644 index 000000000..83f93d047 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/package.json @@ -0,0 +1,21 @@ +{ + "name": "tanstack-start-fixture", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite dev --host 127.0.0.1", + "build": "vite build", + "start": "node .output/server/index.mjs" + }, + "dependencies": { + "@tanstack/react-router": "^1.132.0", + "@tanstack/react-start": "^1.132.0", + "react": "^19.2.0", + "react-dom": "^19.2.0" + }, + "devDependencies": { + "@vitejs/plugin-react": "^6.0.0", + "vite": "^8.0.0" + } +} diff --git a/tests/framework-fixtures/tanstack-start/files/src/router.tsx b/tests/framework-fixtures/tanstack-start/files/src/router.tsx new file mode 100644 index 000000000..9cd826476 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/src/router.tsx @@ -0,0 +1,12 @@ +import { createRouter as createTanStackRouter } from '@tanstack/react-router'; +import { routeTree } from './routeTree.gen'; + +export function getRouter() { + return createTanStackRouter({ routeTree, scrollRestoration: true }); +} + +declare module '@tanstack/react-router' { + interface Register { + router: ReturnType; + } +} diff --git a/tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx b/tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx new file mode 100644 index 000000000..c3c0f0413 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/src/routes/__root.tsx @@ -0,0 +1,26 @@ +import { HeadContent, Scripts, createRootRoute } from '@tanstack/react-router'; + +export const Route = createRootRoute({ + head: () => ({ + meta: [ + { charSet: 'utf-8' }, + { name: 'viewport', content: 'width=device-width, initial-scale=1' }, + { title: 'TanStack Start Fixture' }, + ], + }), + shellComponent: RootDocument, +}); + +function RootDocument({ children }: { children: React.ReactNode }) { + return ( + + + + + + {children} + + + + ); +} diff --git a/tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx b/tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx new file mode 100644 index 000000000..52a49652f --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/src/routes/index.tsx @@ -0,0 +1,14 @@ +import { createFileRoute } from '@tanstack/react-router'; + +export const Route = createFileRoute('/')({ + component: Home, +}); + +function Home() { + return ( +
+

Start Home Hero

+

Server-rendered by TanStack Start.

+
+ ); +} diff --git a/tests/framework-fixtures/tanstack-start/files/vite.config.js b/tests/framework-fixtures/tanstack-start/files/vite.config.js new file mode 100644 index 000000000..6479cd32e --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/files/vite.config.js @@ -0,0 +1,8 @@ +import { defineConfig } from 'vite'; +import { tanstackStart } from '@tanstack/react-start/plugin/vite'; +import viteReact from '@vitejs/plugin-react'; + +export default defineConfig({ + server: { host: '127.0.0.1' }, + plugins: [tanstackStart(), viteReact()], +}); diff --git a/tests/framework-fixtures/tanstack-start/fixture.json b/tests/framework-fixtures/tanstack-start/fixture.json new file mode 100644 index 000000000..ca001aa30 --- /dev/null +++ b/tests/framework-fixtures/tanstack-start/fixture.json @@ -0,0 +1,40 @@ +{ + "name": "Vite 8 + TanStack Start (SSR, root-document adapter)", + "config": { + "files": ["src/routes/__root.tsx"], + "insertBefore": " + + + + + {children} + + + + ); +} +`; + +function scaffold(tmp, { ext = 'tsx', rootBody = ROOT_TSX, startPackage = '@tanstack/react-start' } = {}) { + mkdirSync(join(tmp, 'src', 'routes'), { recursive: true }); + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ + name: 'app', + dependencies: { '@tanstack/react-router': '^1', [startPackage]: '^1' }, + })); + writeFileSync(join(tmp, 'src', 'routes', `__root.${ext}`), rootBody); +} + +describe('tanstack-adapter — detection', () => { + let tmp; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('detects a TanStack Start project from package + root route', () => { + scaffold(tmp); + const project = detectTanStackStartProject(tmp); + assert.equal(project.rootRoute, 'src/routes/__root.tsx'); + assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.tsx'); + assert.equal(project.componentImport, '../impeccable/ImpeccableLiveRoot'); + }); + + it('mirrors the root-route extension for the mount component (jsx)', () => { + scaffold(tmp, { ext: 'jsx' }); + const project = detectTanStackStartProject(tmp); + assert.equal(project.rootRoute, 'src/routes/__root.jsx'); + assert.equal(project.componentFile, 'src/impeccable/ImpeccableLiveRoot.jsx'); + }); + + it('detects @tanstack/solid-start and @tanstack/start too', () => { + scaffold(tmp, { startPackage: '@tanstack/solid-start' }); + assert.ok(detectTanStackStartProject(tmp)); + }); + + it('returns null without the Start package (plain TanStack Router SPA)', () => { + mkdirSync(join(tmp, 'src', 'routes'), { recursive: true }); + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ + dependencies: { '@tanstack/react-router': '^1' }, + })); + writeFileSync(join(tmp, 'src', 'routes', '__root.tsx'), ROOT_TSX); + assert.equal(detectTanStackStartProject(tmp), null); + }); + + it('returns null without a root route file', () => { + writeFileSync(join(tmp, 'package.json'), JSON.stringify({ + dependencies: { '@tanstack/react-start': '^1' }, + })); + assert.equal(detectTanStackStartProject(tmp), null); + }); +}); + +describe('tanstack-adapter — patch/unpatch round-trip', () => { + it('inserts the import + mount component before ', () => { + const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot'); + assert.match(patched, /import ImpeccableLiveRoot from '\.\.\/impeccable\/ImpeccableLiveRoot';/); + assert.match(patched, /\{\/\* impeccable-live-tanstack-start \*\/\}/); + assert.match(patched, //); + // component renders before + assert.ok(patched.indexOf('') < patched.indexOf('')); + }); + + it('round-trips byte-for-byte (patch then unpatch)', () => { + const patched = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot'); + assert.notEqual(patched, ROOT_TSX); + assert.equal(unpatchTanStackRoot(patched), ROOT_TSX); + }); + + it('is idempotent (double patch adds one import + one mount)', () => { + const once = patchTanStackRoot(ROOT_TSX, '../impeccable/ImpeccableLiveRoot'); + const twice = patchTanStackRoot(once, '../impeccable/ImpeccableLiveRoot'); + assert.equal(twice, once); + assert.equal((twice.match(//g) || []).length, 1); + assert.equal((twice.match(/^import ImpeccableLiveRoot/gm) || []).length, 1); + }); + + it('falls back to when is absent', () => { + const noScripts = ROOT_TSX.replace(/\s*/, ''); + const patched = patchTanStackRoot(noScripts, '../impeccable/ImpeccableLiveRoot'); + assert.match(patched, //); + assert.ok(patched.indexOf('') < patched.indexOf('')); + assert.equal(unpatchTanStackRoot(patched), noScripts); + }); + + it('builds a client-only mount component carrying the token', () => { + const body = buildTanStackLiveRootComponent(8123, 'tok-xyz'); + assert.match(body, /http:\/\/localhost:8123\/live\.js\?token=tok-xyz/); + assert.match(body, /useEffect/); + assert.match(body, /typeof document === 'undefined'/); + assert.match(body, /data-impeccable-live-tanstack/); + }); +}); + +describe('tanstack-adapter — apply/remove on disk', () => { + let tmp; + beforeEach(() => { tmp = mkdtempSync(join(tmpdir(), 'impeccable-tanstack-')); }); + afterEach(() => { rmSync(tmp, { recursive: true, force: true }); }); + + it('apply writes the component + patches root, remove restores byte-for-byte', () => { + scaffold(tmp); + const original = readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'); + + const applied = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' }); + assert.equal(applied.adapter, 'tanstack-start'); + assert.equal(applied.inserted, true); + assert.ok(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'))); + assert.match(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), /ImpeccableLiveRoot/); + assert.match( + readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'), + /localhost:9100\/live\.js\?token=T1/, + ); + + const removed = removeTanStackLiveAdapter({ cwd: tmp }); + assert.equal(removed.removed, true); + assert.equal(existsSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx')), false); + assert.equal(existsSync(join(tmp, 'src/impeccable')), false, 'empty managed dir pruned'); + assert.equal(readFileSync(join(tmp, 'src/routes/__root.tsx'), 'utf-8'), original); + }); + + it('refuses to clobber an unmanaged file at the component path', () => { + scaffold(tmp); + mkdirSync(join(tmp, 'src/impeccable'), { recursive: true }); + writeFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'export const mine = 1;\n'); + const result = applyTanStackLiveAdapter({ cwd: tmp, port: 9100, token: 'T1' }); + assert.equal(result.error, 'tanstack_component_conflict'); + // unmanaged file untouched + assert.equal( + readFileSync(join(tmp, 'src/impeccable/ImpeccableLiveRoot.tsx'), 'utf-8'), + 'export const mine = 1;\n', + ); + }); +}); From dbe0c12b91ffc2595b9f0b4bab171340cafcddcc Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Wed, 22 Jul 2026 22:45:55 -0700 Subject: [PATCH 26/81] Live: stop the preflight writing source, cache the resolution The polling-rework preflight wrote the variant scaffold into source during the poll lease, before the agent acted. On source-preview targets (React/Vue/Vite, everything but the svelte-component path) that write full-reloaded the framework; a browser caught mid-reload missed the agent's variant write and the SSE done, and sat stranded at 0/N. Restore the 3.5 single-atomic-edit semantics: the preflight still resolves the element location and computes the scaffold, but --defer-source-write leaves source untouched and hands the agent the wrapper text plus the picked source range. The agent splices variants into the wrapper and replaces the range in one write, so the framework reloads exactly once. The svelte-component path is untouched (it never writes route source). The missed-completion recovery stays as defense in depth. Also cache the resolved source file per target signature (locator + route): the ~7.6s tree search re-ran on every generate for the same element; a hit now points the helper straight at the file via --file, invalidated when the target changes or a resolution fails. Co-Authored-By: Claude Fable 5 --- skill/reference/live.md | 8 +- skill/scripts/live-insert.mjs | 30 ++++-- skill/scripts/live-server.mjs | 11 ++- skill/scripts/live-wrap.mjs | 29 ++++++ skill/scripts/live/generation-preflight.mjs | 55 ++++++++++- tests/live-e2e/agent.mjs | 75 +++++++++++--- tests/live-generation-preflight.test.mjs | 102 +++++++++++++++++++- tests/live-wrap.test.mjs | 37 +++++++ 8 files changed, 314 insertions(+), 33 deletions(-) diff --git a/skill/reference/live.md b/skill/reference/live.md index 23ef907c7..9fe42cdf0 100644 --- a/skill/reference/live.md +++ b/skill/reference/live.md @@ -116,7 +116,7 @@ node {{scripts_path}}/live-insert.mjs --id EVENT_ID --count EVENT_COUNT --positi - `--position` ← `event.insert.position` (`before` | `after`) - Anchor flags ← `event.insert.anchor` (same mapping as wrap: id, classes, tag, text) -The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`. +The scaffold has **no** `data-impeccable-variant="original"`. Variants are net-new HTML+CSS inserted at `insertLine`. On source-preview targets the scaffold carries `sourceWritten: false` with `wrapperBlock`, `replaceStartLine`, and `replaceEndLine` (here `replaceEndLine < replaceStartLine`, an insertion): splice your variants into `wrapperBlock` at the marker and insert the result at `replaceStartLine` in one edit, exactly as the wrap section describes, so the framework reloads once. Decide the visitor mode from the surface and load [craft-floor.md](craft-floor.md) before writing net-new markup (freeform only, no action sub-command). Deliver using the harness policy, then `--reply done`. For Svelte/SvelteKit targets, `live-insert.mjs` returns `previewMode: "svelte-component"` with `mode: "insert"`, `file` pointing at a temporary `node_modules/.impeccable-live//manifest.json`, `componentDir` pointing at the variant component files, and `sourceFile` pointing at the real `.svelte` route. Write each inserted variant as a real Svelte component (`v1.svelte`, `v2.svelte`, …) under `componentDir`. Insert variants must be non-empty net-new content with a single top-level root, no `data-impeccable-*` attributes, and CSS in each component's ` @@ -181,6 +193,25 @@
light text over the dark panel, not the light code surface
+

Gradient-clipped text (issue #409 Case A — must not flag contrast)

+ +

Gradient Clipped Heading Text

+ +

Faint accent-glow gradient (issue #409 Case B — must not flag contrast)

+ +
+
+

Light body copy on a faint accent glow that composites to near-black

+

Muted secondary line on the same faint glow area here

+
+
+

Emoji on light backgrounds

diff --git a/tests/fixtures/antipatterns/nonrendered-text.html b/tests/fixtures/antipatterns/nonrendered-text.html new file mode 100644 index 000000000..a89d225ea --- /dev/null +++ b/tests/fixtures/antipatterns/nonrendered-text.html @@ -0,0 +1,50 @@ + + + + + + +This is a fairly long document title that easily exceeds twenty characters + + + + + + + + + + + + +
Hidden display-none block of body text long enough to exceed twenty characters.
+ + +

Invisible visibility-hidden paragraph copy that is longer than the twenty char gate.

+ + +

This is real rendered body copy at 10px that is definitely long enough to flag.

+ + + + + + diff --git a/tests/fixtures/antipatterns/shadowed-form-id.html b/tests/fixtures/antipatterns/shadowed-form-id.html new file mode 100644 index 000000000..ccc1cf473 --- /dev/null +++ b/tests/fixtures/antipatterns/shadowed-form-id.html @@ -0,0 +1,50 @@ + + + + + +Shadowed form.id regression fixture + + + +
+

Impeccable Test Product

+

$49.00

+ + +
+ + + + + + + +
+
+ + + From 501528c07f369ea5728c37377e3e83e5279c9695 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Fri, 24 Jul 2026 16:16:17 -0700 Subject: [PATCH 38/81] Register orphaned live-tanstack-adapter test in the live suite tests/live-tanstack-adapter.test.mjs (added in 4cd5ea75) was never listed in scripts/test-suites.mjs, so the test-suites registry guard failed and the file never ran in any suite. Add it to the live suite's node command list. Pre-existing housekeeping, independent of the detector fixes. Co-Authored-By: Claude Fable 5 --- scripts/test-suites.mjs | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/test-suites.mjs b/scripts/test-suites.mjs index b911c75d4..d8ef865bd 100644 --- a/scripts/test-suites.mjs +++ b/scripts/test-suites.mjs @@ -154,6 +154,7 @@ export const SUITES = { 'tests/live-session-store.test.mjs', 'tests/live-source-lock.test.mjs', 'tests/live-source-search.test.mjs', + 'tests/live-tanstack-adapter.test.mjs', 'tests/live-target-context.test.mjs', 'tests/live-wrap.test.mjs', 'tests/live-wrap-buffer-aware.test.mjs', From 94dc732d306d621346ba50e114a9153887edbc1a Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Fri, 24 Jul 2026 23:17:52 +0000 Subject: [PATCH 39/81] Sync generated provider output --- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- .../detector/browser/injected/index.mjs | 13 +- .../detector/detect-antipatterns-browser.js | 113 +++++++++++++++--- .../scripts/detector/rules/checks.mjs | 100 +++++++++++++--- 45 files changed, 2880 insertions(+), 510 deletions(-) diff --git a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs index 40bc1d155..8d18d8a3a 100644 --- a/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs +++ b/.agents/skills/impeccable/scripts/detector/browser/injected/index.mjs @@ -530,7 +530,11 @@ if (IS_BROWSER) { function generateSelector(el) { if (el === document.body) return 'body'; if (el === document.documentElement) return 'html'; - if (el.id) return '#' + CSS.escape(el.id); + // Read via getAttribute when `el.id` is not a string — a
with a + // named control (e.g. ) shadows the builtin getter and + // returns the element, producing a garbage `#[object …]` selector (#407). + const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || ''); + if (elId) return '#' + CSS.escape(elId); const parts = []; let current = el; @@ -1467,8 +1471,11 @@ if (IS_BROWSER) { for (const el of document.querySelectorAll('*')) { // Skip impeccable's own elements and any descendants (overlays, labels, banner, nav buttons) if (el.closest('.impeccable-overlay, .impeccable-label, .impeccable-banner, .impeccable-tooltip')) continue; - // Skip browser extension elements (Claude, etc.) - const elId = el.id || ''; + // Skip browser extension elements (Claude, etc.). Use getAttribute when + // `el.id` is not a string: a with a named control like + // shadows the builtin `id` getter and returns the + // element, whose `.startsWith` throws (issue #407). + const elId = typeof el.id === 'string' ? el.id : (el.getAttribute('id') || ''); if (elId.startsWith('claude-') || elId.startsWith('cic-')) continue; // Skip the impeccable live-mode overlay (highlight, tooltip, bar, picker, toast). // These are inspector chrome, not part of the user's design. diff --git a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js index 4e3e44b8e..0fdc460ff 100644 --- a/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js +++ b/.agents/skills/impeccable/scripts/detector/detect-antipatterns-browser.js @@ -902,9 +902,21 @@ function checkColors(opts) { const findings = []; if (hasDirectText && textColor && !isEmojiOnly) { + // Gradient-clipped text (`background-clip: text`, typically with a + // transparent text-fill) paints its glyphs *with* the element's own + // gradient. The `color` value the cascade still reports is never painted, + // and the gradient is the fill, not a backdrop — so measuring `color` + // against that gradient (which resolveGradientStops picks up as the + // element's own background-image) is a guaranteed false positive + // (issue #409 Case A). Skip the backdrop-contrast checks; the gradient-text + // rule below still flags the pattern itself. Skipping a rule beats a false + // positive here — the true painted contrast can't be measured from `color`. + const isGradientClippedText = bgClip === 'text'; // Run background-dependent checks against either a solid bg or, if the // ancestor is a gradient, against every gradient stop (use the worst case). - const bgs = effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null); + const bgs = isGradientClippedText + ? null + : (effectiveBg ? [effectiveBg] : (effectiveBgStops && effectiveBgStops.length ? effectiveBgStops : null)); if (bgs) { // Gray on colored background — flag if every stop is chromatic const textLum = relativeLuminance(textColor); @@ -2462,29 +2474,54 @@ function resolveBackground(el, win, customPropMap) { // Walk parents looking for a gradient background and return its color stops. // Used as a fallback when resolveBackground() returns null because the // effective background is a gradient (no single solid color to compare against). -function resolveGradientStops(el, win) { +function resolveGradientStops(el, win, customPropMap) { let current = el; while (current && current.nodeType === 1) { const style = DETECTOR_IS_BROWSER ? getComputedStyle(current) : win.getComputedStyle(current); const bgImage = style.backgroundImage || ''; + let stops = null; if (bgImage && bgImage !== 'none' && /gradient/i.test(bgImage)) { - const stops = parseGradientColors(bgImage); - if (stops.length > 0) return stops; + const parsed = parseGradientColors(bgImage); + if (parsed.length > 0) stops = parsed; } - if (!DETECTOR_IS_BROWSER) { + if (!stops && !DETECTOR_IS_BROWSER) { // jsdom doesn't decompose `background:` shorthand — peek at the raw inline style const rawStyle = current.getAttribute?.('style') || ''; const bgMatch = rawStyle.match(/background(?:-image)?\s*:\s*([^;]+)/i); if (bgMatch && /gradient/i.test(bgMatch[1])) { - const stops = parseGradientColors(bgMatch[1]); - if (stops.length > 0) return stops; + const parsed = parseGradientColors(bgMatch[1]); + if (parsed.length > 0) stops = parsed; } } + if (stops) return compositeGradientStops(stops, current, win, customPropMap); current = current.parentElement; } return null; } +// A translucent gradient stop (e.g. a faint `rgba(52,192,168,0.09)` accent +// glow) paints over whatever surface sits beneath the gradient — the browser +// composites it, so its effective color is far closer to the base than to the +// full-opacity accent. Treating the stop as opaque flags every text child of a +// softly-glowing section as low-contrast (issue #409 Case B). Composite each +// alpha stop over the resolved surface beneath the gradient element. When that +// surface isn't resolvable (another gradient above, no opaque ancestor), drop +// the translucent stop rather than guess: a dropped stop can't manufacture a +// false finding, and skipping beats a wrong ratio. +function compositeGradientStops(stops, gradientEl, win, customPropMap) { + const hasAlpha = stops.some(s => (s.a ?? 1) < 0.99); + if (!hasAlpha) return stops; + const base = resolveBackground(gradientEl.parentElement || gradientEl, win, customPropMap); + const out = []; + for (const s of stops) { + const a = s.a ?? 1; + if (a >= 0.99) { out.push(s); continue; } + if (base) out.push(compositeColorOver(s, base)); + // else: unresolvable base — drop the translucent stop (skip, don't guess). + } + return out.length ? out : null; +} + // Parse a single CSS length token to pixels. Accepts "12px", "50%", a // shorthand like "12px 4px" (uses the first value), or empty / null. // Returns the pixel value, or null when the input is unparseable. @@ -3664,6 +3701,34 @@ function isVisuallyHidden(el, style) { return false; } +// Elements whose text is never painted: document metadata and script/style +// payloads. Their JS / CSS / JSON-LD text satisfies `hasDirectText`, and on +// sites that set `html { font-size: 62.5% }` their inherited computed size is +// 10px — so the text-size floors flag them as tiny body copy even though +// nothing renders (issue #408: dozens of phantom "10px body text" findings on +// every Shopify page). Exclude them, plus anything the cascade resolves to +// display:none / visibility:hidden. The jsdom path can't lay out, so the +// tag/attribute-based exclusions carry the weight there; the display checks are +// computed-style reads that resolve without layout in both adapters. +const NON_RENDERED_TAGS = new Set([ + 'script', 'style', 'title', 'noscript', 'template', 'head', + 'meta', 'link', 'base', 'param', 'source', 'track', 'datalist', + 'col', 'colgroup', 'map', 'area', +]); +function isNonRenderedText(el, tag, style) { + const t = (tag || '').toLowerCase(); + if (NON_RENDERED_TAGS.has(t)) return true; + // Descendants of never render even when the tag itself would + // (some sites nest