From b906b41462c26c359e452040994685ce6d8e4008 Mon Sep 17 00:00:00 2001 From: "github-actions[bot]" <41898282+github-actions[bot]@users.noreply.github.com> Date: Mon, 20 Jul 2026 17:50:07 +0000 Subject: [PATCH] Sync generated provider output --- .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .kiro/skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .kiro/skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .pi/skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .pi/skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .pi/skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .qoder/skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .../skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ .trae/skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .trae/skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ plugin/skills/impeccable/scripts/hook-lib.mjs | 53 ++----- .../scripts/lib/template-extensions.mjs | 146 ++++++++++++++++++ .../skills/impeccable/scripts/live-accept.mjs | 76 ++------- .../scripts/live-commit-manual-edits.mjs | 3 + .../scripts/live-manual-edit-evidence.mjs | 7 +- .../skills/impeccable/scripts/live-wrap.mjs | 63 ++------ .../impeccable/scripts/live/source-search.mjs | 105 +++++++++++++ 91 files changed, 3900 insertions(+), 1989 deletions(-) create mode 100644 .agents/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .agents/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .claude/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .claude/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .cursor/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .cursor/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .gemini/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .gemini/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .github/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .github/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .kiro/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .kiro/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .opencode/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .opencode/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .pi/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .pi/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .qoder/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .qoder/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .rovodev/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .rovodev/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .trae-cn/skills/impeccable/scripts/live/source-search.mjs create mode 100644 .trae/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 .trae/skills/impeccable/scripts/live/source-search.mjs create mode 100644 plugin/skills/impeccable/scripts/lib/template-extensions.mjs create mode 100644 plugin/skills/impeccable/scripts/live/source-search.mjs diff --git a/.agents/skills/impeccable/scripts/hook-lib.mjs b/.agents/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.agents/skills/impeccable/scripts/hook-lib.mjs +++ b/.agents/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.agents/skills/impeccable/scripts/lib/template-extensions.mjs b/.agents/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.agents/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.agents/skills/impeccable/scripts/live-accept.mjs b/.agents/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.agents/skills/impeccable/scripts/live-accept.mjs +++ b/.agents/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.agents/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.agents/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.agents/skills/impeccable/scripts/live-wrap.mjs b/.agents/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.agents/skills/impeccable/scripts/live-wrap.mjs +++ b/.agents/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.agents/skills/impeccable/scripts/live/source-search.mjs b/.agents/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.agents/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.claude/skills/impeccable/scripts/hook-lib.mjs b/.claude/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.claude/skills/impeccable/scripts/hook-lib.mjs +++ b/.claude/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.claude/skills/impeccable/scripts/lib/template-extensions.mjs b/.claude/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.claude/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.claude/skills/impeccable/scripts/live-accept.mjs b/.claude/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.claude/skills/impeccable/scripts/live-accept.mjs +++ b/.claude/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.claude/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.claude/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.claude/skills/impeccable/scripts/live-wrap.mjs b/.claude/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.claude/skills/impeccable/scripts/live-wrap.mjs +++ b/.claude/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.claude/skills/impeccable/scripts/live/source-search.mjs b/.claude/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.claude/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.cursor/skills/impeccable/scripts/hook-lib.mjs b/.cursor/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.cursor/skills/impeccable/scripts/hook-lib.mjs +++ b/.cursor/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.cursor/skills/impeccable/scripts/lib/template-extensions.mjs b/.cursor/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.cursor/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.cursor/skills/impeccable/scripts/live-accept.mjs b/.cursor/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.cursor/skills/impeccable/scripts/live-accept.mjs +++ b/.cursor/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.cursor/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.cursor/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.cursor/skills/impeccable/scripts/live-wrap.mjs b/.cursor/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.cursor/skills/impeccable/scripts/live-wrap.mjs +++ b/.cursor/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.cursor/skills/impeccable/scripts/live/source-search.mjs b/.cursor/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.cursor/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.gemini/skills/impeccable/scripts/hook-lib.mjs b/.gemini/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.gemini/skills/impeccable/scripts/hook-lib.mjs +++ b/.gemini/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.gemini/skills/impeccable/scripts/lib/template-extensions.mjs b/.gemini/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.gemini/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.gemini/skills/impeccable/scripts/live-accept.mjs b/.gemini/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.gemini/skills/impeccable/scripts/live-accept.mjs +++ b/.gemini/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.gemini/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.gemini/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.gemini/skills/impeccable/scripts/live-wrap.mjs b/.gemini/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.gemini/skills/impeccable/scripts/live-wrap.mjs +++ b/.gemini/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.gemini/skills/impeccable/scripts/live/source-search.mjs b/.gemini/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.gemini/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.github/skills/impeccable/scripts/hook-lib.mjs b/.github/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.github/skills/impeccable/scripts/hook-lib.mjs +++ b/.github/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.github/skills/impeccable/scripts/lib/template-extensions.mjs b/.github/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.github/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.github/skills/impeccable/scripts/live-accept.mjs b/.github/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.github/skills/impeccable/scripts/live-accept.mjs +++ b/.github/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.github/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.github/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.github/skills/impeccable/scripts/live-wrap.mjs b/.github/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.github/skills/impeccable/scripts/live-wrap.mjs +++ b/.github/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.github/skills/impeccable/scripts/live/source-search.mjs b/.github/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.github/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.kiro/skills/impeccable/scripts/hook-lib.mjs b/.kiro/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.kiro/skills/impeccable/scripts/hook-lib.mjs +++ b/.kiro/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.kiro/skills/impeccable/scripts/lib/template-extensions.mjs b/.kiro/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.kiro/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.kiro/skills/impeccable/scripts/live-accept.mjs b/.kiro/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.kiro/skills/impeccable/scripts/live-accept.mjs +++ b/.kiro/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.kiro/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.kiro/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.kiro/skills/impeccable/scripts/live-wrap.mjs b/.kiro/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.kiro/skills/impeccable/scripts/live-wrap.mjs +++ b/.kiro/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.kiro/skills/impeccable/scripts/live/source-search.mjs b/.kiro/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.kiro/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.opencode/skills/impeccable/scripts/hook-lib.mjs b/.opencode/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.opencode/skills/impeccable/scripts/hook-lib.mjs +++ b/.opencode/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.opencode/skills/impeccable/scripts/lib/template-extensions.mjs b/.opencode/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.opencode/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.opencode/skills/impeccable/scripts/live-accept.mjs b/.opencode/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.opencode/skills/impeccable/scripts/live-accept.mjs +++ b/.opencode/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.opencode/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.opencode/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.opencode/skills/impeccable/scripts/live-wrap.mjs b/.opencode/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.opencode/skills/impeccable/scripts/live-wrap.mjs +++ b/.opencode/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.opencode/skills/impeccable/scripts/live/source-search.mjs b/.opencode/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.opencode/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.pi/skills/impeccable/scripts/hook-lib.mjs b/.pi/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.pi/skills/impeccable/scripts/hook-lib.mjs +++ b/.pi/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.pi/skills/impeccable/scripts/lib/template-extensions.mjs b/.pi/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.pi/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.pi/skills/impeccable/scripts/live-accept.mjs b/.pi/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.pi/skills/impeccable/scripts/live-accept.mjs +++ b/.pi/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.pi/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.pi/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.pi/skills/impeccable/scripts/live-wrap.mjs b/.pi/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.pi/skills/impeccable/scripts/live-wrap.mjs +++ b/.pi/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.pi/skills/impeccable/scripts/live/source-search.mjs b/.pi/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.pi/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.qoder/skills/impeccable/scripts/hook-lib.mjs b/.qoder/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.qoder/skills/impeccable/scripts/hook-lib.mjs +++ b/.qoder/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.qoder/skills/impeccable/scripts/lib/template-extensions.mjs b/.qoder/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.qoder/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.qoder/skills/impeccable/scripts/live-accept.mjs b/.qoder/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.qoder/skills/impeccable/scripts/live-accept.mjs +++ b/.qoder/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.qoder/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.qoder/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.qoder/skills/impeccable/scripts/live-wrap.mjs b/.qoder/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.qoder/skills/impeccable/scripts/live-wrap.mjs +++ b/.qoder/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.qoder/skills/impeccable/scripts/live/source-search.mjs b/.qoder/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.qoder/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.rovodev/skills/impeccable/scripts/hook-lib.mjs b/.rovodev/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.rovodev/skills/impeccable/scripts/hook-lib.mjs +++ b/.rovodev/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.rovodev/skills/impeccable/scripts/lib/template-extensions.mjs b/.rovodev/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.rovodev/skills/impeccable/scripts/live-accept.mjs b/.rovodev/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.rovodev/skills/impeccable/scripts/live-accept.mjs +++ b/.rovodev/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.rovodev/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.rovodev/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.rovodev/skills/impeccable/scripts/live-wrap.mjs b/.rovodev/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.rovodev/skills/impeccable/scripts/live-wrap.mjs +++ b/.rovodev/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.rovodev/skills/impeccable/scripts/live/source-search.mjs b/.rovodev/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.rovodev/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.trae-cn/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae-cn/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.trae-cn/skills/impeccable/scripts/lib/template-extensions.mjs b/.trae-cn/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.trae-cn/skills/impeccable/scripts/live-accept.mjs b/.trae-cn/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.trae-cn/skills/impeccable/scripts/live-accept.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.trae-cn/skills/impeccable/scripts/live-wrap.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.trae-cn/skills/impeccable/scripts/live/source-search.mjs b/.trae-cn/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.trae-cn/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/.trae/skills/impeccable/scripts/hook-lib.mjs b/.trae/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/.trae/skills/impeccable/scripts/hook-lib.mjs +++ b/.trae/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/.trae/skills/impeccable/scripts/lib/template-extensions.mjs b/.trae/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/.trae/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/.trae/skills/impeccable/scripts/live-accept.mjs b/.trae/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/.trae/skills/impeccable/scripts/live-accept.mjs +++ b/.trae/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs b/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/.trae/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/.trae/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/.trae/skills/impeccable/scripts/live-wrap.mjs b/.trae/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/.trae/skills/impeccable/scripts/live-wrap.mjs +++ b/.trae/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/.trae/skills/impeccable/scripts/live/source-search.mjs b/.trae/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/.trae/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +} diff --git a/plugin/skills/impeccable/scripts/hook-lib.mjs b/plugin/skills/impeccable/scripts/hook-lib.mjs index f90c2f4a8..87343abb8 100644 --- a/plugin/skills/impeccable/scripts/hook-lib.mjs +++ b/plugin/skills/impeccable/scripts/hook-lib.mjs @@ -42,6 +42,16 @@ import path from 'node:path'; import { pathToFileURL, fileURLToPath } from 'node:url'; import { extractPlatform, loadContext } from './context.mjs'; import { IMPECCABLE_COMMAND } from './lib/provider.mjs'; +// `detector.extensions` (issue #316) is shared with Live's source search, which +// needs the same answer for `.heex` / `.blade.php` when it hunts for session +// markers. lib/template-extensions.mjs owns the shape; re-exported here because +// hook-lib has been the import site for matchConfiguredExtension since #347. +import { + matchConfiguredExtension, + mergeExtensions, +} from './lib/template-extensions.mjs'; + +export { matchConfiguredExtension }; const __filename = fileURLToPath(import.meta.url); const __dirname = path.dirname(__filename); @@ -263,49 +273,6 @@ function applyDetectorConfigSource(config, raw) { return config; } -// Extra scanned extensions from `detector.extensions` config. Entries are -// `{ ext, engine }` (engine 'html' | 'text', default 'html' — the common case -// for server-side templates) or bare strings as shorthand. Extensions are -// matched against the end of the filename, not path.extname, so double -// extensions like `.blade.php` and `.html.erb` work (issue #316). -function normalizeExtensionEntries(entries) { - if (!Array.isArray(entries)) return []; - const out = []; - for (const entry of entries) { - const raw = typeof entry === 'string' ? entry : entry?.ext; - if (typeof raw !== 'string') continue; - let ext = raw.trim().toLowerCase(); - if (!ext) continue; - if (!ext.startsWith('.')) ext = `.${ext}`; - const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; - out.push({ ext, engine }); - } - return out; -} - -function mergeExtensions(existing, incoming) { - const map = new Map(); - for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); - for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); - return Array.from(map.values()); -} - -export function matchConfiguredExtension(filePath, extensions) { - if (!Array.isArray(extensions) || extensions.length === 0) return null; - const name = path.basename(String(filePath || '')).toLowerCase(); - if (!name) return null; - // The longest matching suffix wins, so `.blade.php` beats a broader `.php` - // entry regardless of config order. - let best = null; - for (const entry of normalizeExtensionEntries(extensions)) { - if (name.length > entry.ext.length && name.endsWith(entry.ext) - && (!best || entry.ext.length > best.ext.length)) { - best = entry; - } - } - return best; -} - function applyConfigSource(config, raw) { if (!raw || typeof raw !== 'object') return config; if (Object.prototype.hasOwnProperty.call(raw, 'enabled')) { diff --git a/plugin/skills/impeccable/scripts/lib/template-extensions.mjs b/plugin/skills/impeccable/scripts/lib/template-extensions.mjs new file mode 100644 index 000000000..6a115bd7d --- /dev/null +++ b/plugin/skills/impeccable/scripts/lib/template-extensions.mjs @@ -0,0 +1,146 @@ +/** + * One owner for "which file extensions hold UI markup". + * + * Before this module the answer was spelled out separately in hook-lib.mjs + * (`detector.extensions` config, issue #316) and in live-wrap.mjs / + * live-accept.mjs (a hardcoded `EXTENSIONS` array, duplicated verbatim in both). + * The lists drifted: the hook learned configurable server-template extensions + * while Live kept its six frontend defaults, so a Phoenix project got design + * findings on `.heex` files but `Session markers not found` on Accept (#374). + * + * Extensions are matched against the END OF THE FILENAME, not `path.extname`, + * so double extensions like `.blade.php`, `.html.erb`, and `.html.heex` work. + */ + +import fs from 'node:fs'; +import path from 'node:path'; + +/** + * Built-in markup extensions for Live's wrap/accept source search. + * + * Elixir's `.ex` is here because Phoenix function components put `~H"""` + * templates directly in `lib/**\/*.ex`; `.heex` and `.eex` cover standalone + * templates. `.exs` is deliberately absent: those are Elixir *scripts* + * (`mix.exs`, `config/*.exs`, tests) and never hold markup, so including them + * only gives the wrap query a chance to match build config by accident. + */ +export const LIVE_TEMPLATE_EXTENSIONS = Object.freeze([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', + '.ex', '.heex', '.eex', +]); + +/** + * Normalize `detector.extensions` entries to `{ ext, engine }`. + * + * Accepts `{ ext, engine }` objects (engine 'html' | 'text', default 'html' — + * the common case for server-side templates) or bare strings as shorthand. + */ +export function normalizeExtensionEntries(entries) { + if (!Array.isArray(entries)) return []; + const out = []; + for (const entry of entries) { + const raw = typeof entry === 'string' ? entry : entry?.ext; + if (typeof raw !== 'string') continue; + let ext = raw.trim().toLowerCase(); + if (!ext) continue; + if (!ext.startsWith('.')) ext = `.${ext}`; + const engine = (!(typeof entry === 'string') && entry?.engine === 'text') ? 'text' : 'html'; + out.push({ ext, engine }); + } + return out; +} + +export function mergeExtensions(existing, incoming) { + const map = new Map(); + for (const entry of normalizeExtensionEntries(existing)) map.set(entry.ext, entry); + for (const entry of normalizeExtensionEntries(incoming)) map.set(entry.ext, entry); + return Array.from(map.values()); +} + +export function matchConfiguredExtension(filePath, extensions) { + if (!Array.isArray(extensions) || extensions.length === 0) return null; + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return null; + // The longest matching suffix wins, so `.blade.php` beats a broader `.php` + // entry regardless of config order. + let best = null; + for (const entry of normalizeExtensionEntries(extensions)) { + if (name.length > entry.ext.length && name.endsWith(entry.ext) + && (!best || entry.ext.length > best.ext.length)) { + best = entry; + } + } + return best; +} + +/** + * Does this filename end in one of `extensions`? + * + * Suffix matching rather than `path.extname` equality, so a configured + * `.html.erb` matches `show.html.erb` (whose extname is only `.erb`). The + * `name.length > ext.length` guard keeps a file literally named `.heex` from + * counting as a template. + */ +export function matchesTemplateExtension(filePath, extensions) { + const name = path.basename(String(filePath || '')).toLowerCase(); + if (!name) return false; + for (const ext of extensions) { + if (name.length > ext.length && name.endsWith(ext)) return true; + } + return false; +} + +/** + * Built-in Live extensions plus any the project configured for the detector. + * + * Reading `detector.extensions` here is the point: a user who taught the design + * hook about `.blade.php` should not have to teach Live separately. Config + * parsing is intentionally minimal (own the shape, not the whole hook config) + * so this module stays importable from the Live CLI without pulling in + * hook-lib.mjs. + */ +export function resolveLiveTemplateExtensions(cwd = process.cwd()) { + const cached = extensionCache.get(cwd); + if (cached) return cached; + const resolved = readLiveTemplateExtensions(cwd); + extensionCache.set(cwd, resolved); + return resolved; +} + +// live-wrap calls the resolver once per candidate query per pass (up to eight +// times in one CLI run), and every call would otherwise re-read and re-parse +// both config files. Keyed by cwd; a single CLI process never rewrites its own +// config mid-run. +const extensionCache = new Map(); + +/** Test seam: drop the memoized config so a fixture can rewrite config.json. */ +export function clearTemplateExtensionCache() { + extensionCache.clear(); +} + +function readLiveTemplateExtensions(cwd) { + const configured = []; + for (const name of ['config.json', 'config.local.json']) { + const raw = safeReadJson(path.join(cwd, '.impeccable', name)); + const detector = raw?.detector; + if (detector && typeof detector === 'object' && !Array.isArray(detector)) { + configured.push(...normalizeExtensionEntries(detector.extensions)); + } + } + const seen = new Set(LIVE_TEMPLATE_EXTENSIONS); + const out = [...LIVE_TEMPLATE_EXTENSIONS]; + for (const { ext } of configured) { + if (seen.has(ext)) continue; + seen.add(ext); + out.push(ext); + } + return out; +} + +function safeReadJson(filePath) { + try { + return JSON.parse(fs.readFileSync(filePath, 'utf-8')); + } catch { + return null; + } +} diff --git a/plugin/skills/impeccable/scripts/live-accept.mjs b/plugin/skills/impeccable/scripts/live-accept.mjs index 453ddea35..0aa6a6d2e 100644 --- a/plugin/skills/impeccable/scripts/live-accept.mjs +++ b/plugin/skills/impeccable/scripts/live-accept.mjs @@ -16,8 +16,10 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; -import { IMPECCABLE_DIR, getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { getLiveDir, safeSessionId } from './lib/impeccable-paths.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer, writeBuffer as writeManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { NEVER_SOURCE_DIRS, findSourceFile } from './live/source-search.mjs'; import { withSourceLockSync } from './live/source-lock.mjs'; import { applyDeferredSvelteComponentAccepts, @@ -26,7 +28,6 @@ import { removeSvelteComponentSession, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; const ACCEPT_LOCK_WAIT_MS = 1_000; // Mirrors VARIANT_ID_PATTERN in live/event-validation.mjs, which gates the same // value arriving over HTTP. @@ -892,66 +893,23 @@ function detectCommentSyntax(filePath) { // --------------------------------------------------------------------------- /** - * `.impeccable` is the critical entry, and it is not cosmetic. - * - * Progressive publication stages each revision as `.impeccable/live/artifacts/ - * -r.`, and those artifacts carry the very marker this search - * looks for. The walk reaches `.` for any project whose source is not under one - * of the privileged dirs above (this repo's own site lives in `site/pages/`), and - * dot-directories sort before letters, so the artifact was found *before* the - * real file. isGeneratedFile then declined the accept, and the agent fell back to - * carbonizing several hundred lines of stylesheet by hand. - * - * Impeccable's own state directory is never project source. Never search it. + * Accept also skips `dist` / `build` outright, where wrap descends into them so + * its `includeGenerated` second pass can report a `generatedMatch`. Accept has + * no such pass: a marker found in build output is only ever a stale copy of the + * marker in source. */ -const SEARCH_SKIP_DIRS = new Set(['node_modules', '.git', 'dist', 'build', IMPECCABLE_DIR]); +const SEARCH_SKIP_DIRS = [...NEVER_SOURCE_DIRS, 'dist', 'build']; function findSessionFile(id, cwd) { - const marker = 'impeccable-variants-start ' + id; - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, marker, seen, 0); - if (result) { - const content = fs.readFileSync(result, 'utf-8'); - return { file: result, content, lines: content.split('\n') }; - } - } - return null; -} - -function searchDir(dir, query, seen, depth) { - if (depth > 5) return null; - let realDir; - try { realDir = fs.realpathSync(dir); } catch { return null; } - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - for (const entry of entries) { - if (!entry.isFile()) continue; - if (!EXTENSIONS.includes(path.extname(entry.name).toLowerCase())) continue; - const filePath = path.join(dir, entry.name); - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip */ } - } - - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (SEARCH_SKIP_DIRS.has(entry.name)) continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1); - if (result) return result; - } - - return null; + const result = findSourceFile({ + query: 'impeccable-variants-start ' + id, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + skipDirs: SEARCH_SKIP_DIRS, + }); + if (!result) return null; + const content = fs.readFileSync(result, 'utf-8'); + return { file: result, content, lines: content.split('\n') }; } // --------------------------------------------------------------------------- diff --git a/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs b/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs index 8bde46b50..89572e759 100644 --- a/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs +++ b/plugin/skills/impeccable/scripts/live-commit-manual-edits.mjs @@ -29,6 +29,9 @@ const ROLLBACK_EXTENSIONS = new Set([ '.astro', '.cjs', '.css', + '.eex', + '.ex', + '.heex', '.htm', '.html', '.js', diff --git a/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs b/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs index dd10e96dc..ee8e2b778 100644 --- a/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs +++ b/plugin/skills/impeccable/scripts/live-manual-edit-evidence.mjs @@ -14,7 +14,12 @@ import { isGeneratedFile } from './lib/is-generated.mjs'; import { readBuffer, getBufferPath } from './live/manual-edits-buffer.mjs'; const EVIDENCE_VERSION = 1; -const TEXT_EXTENSIONS = new Set(['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts']); +const TEXT_EXTENSIONS = new Set([ + '.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro', '.js', '.mjs', '.ts', + // Phoenix keeps `~H"""` markup in .ex alongside standalone .heex/.eex + // templates, so copy edits land in all three. + '.ex', '.heex', '.eex', +]); const SEARCH_DIRS = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'site', 'lib', 'data']; const STRONG_LITERAL_MATCH_LIMIT = 8; const WEAK_LITERAL_MATCH_LIMIT = 4; diff --git a/plugin/skills/impeccable/scripts/live-wrap.mjs b/plugin/skills/impeccable/scripts/live-wrap.mjs index 48d7f7683..430abfeaa 100644 --- a/plugin/skills/impeccable/scripts/live-wrap.mjs +++ b/plugin/skills/impeccable/scripts/live-wrap.mjs @@ -14,15 +14,15 @@ import fs from 'node:fs'; import path from 'node:path'; import { isGeneratedFile } from './lib/is-generated.mjs'; +import { resolveLiveTemplateExtensions } from './lib/template-extensions.mjs'; import { readBuffer as readManualEditsBuffer } from './live/manual-edits-buffer.mjs'; +import { findSourceFile } from './live/source-search.mjs'; import { buildSvelteComponentCssAuthoring, scaffoldSvelteComponentSession, shouldUseSvelteComponentInjection, } from './live/svelte-component.mjs'; -const EXTENSIONS = ['.html', '.jsx', '.tsx', '.vue', '.svelte', '.astro']; - export async function wrapCli() { const args = process.argv.slice(2); @@ -672,56 +672,19 @@ function buildCssAuthoring(styleMode, count) { /** * Search project files for the query string (class name, ID, etc.) * Returns the first matching file path, or null. + * + * Only `node_modules`, `.git`, and `.impeccable` are skipped outright. + * dist/build/out are left to the isGeneratedFile guard so the + * `includeGenerated` second pass can still find the element there and report + * `generatedMatch`. */ function findFileWithQuery(query, cwd, genOpts = {}) { - const searchDirs = ['src', 'app', 'pages', 'components', 'public', 'views', 'templates', '.']; - const seen = new Set(); - - for (const dir of searchDirs) { - const absDir = path.join(cwd, dir); - if (!fs.existsSync(absDir)) continue; - const result = searchDir(absDir, query, seen, 0, genOpts); - if (result) return result; - } - return null; -} - -function searchDir(dir, query, seen, depth, genOpts) { - if (depth > 5) return null; // don't go too deep - const realDir = fs.realpathSync(dir); - if (seen.has(realDir)) return null; - seen.add(realDir); - - let entries; - try { entries = fs.readdirSync(dir, { withFileTypes: true }); } - catch { return null; } - - // Check files first - for (const entry of entries) { - if (!entry.isFile()) continue; - const ext = path.extname(entry.name).toLowerCase(); - if (!EXTENSIONS.includes(ext)) continue; - - const filePath = path.join(dir, entry.name); - if (!genOpts.includeGenerated && isGeneratedFile(filePath, genOpts)) continue; - try { - const content = fs.readFileSync(filePath, 'utf-8'); - if (content.includes(query)) return filePath; - } catch { /* skip unreadable files */ } - } - - // Then recurse into directories. Always skip node_modules and .git (never - // project content). dist/build/out are left to the isGeneratedFile guard so - // the includeGenerated second-pass can still find the element there and - // report `generatedMatch`. - for (const entry of entries) { - if (!entry.isDirectory()) continue; - if (entry.name === 'node_modules' || entry.name === '.git') continue; - const result = searchDir(path.join(dir, entry.name), query, seen, depth + 1, genOpts); - if (result) return result; - } - - return null; + return findSourceFile({ + query, + cwd, + extensions: resolveLiveTemplateExtensions(cwd), + fileFilter: (filePath) => genOpts.includeGenerated || !isGeneratedFile(filePath, genOpts), + }); } /** diff --git a/plugin/skills/impeccable/scripts/live/source-search.mjs b/plugin/skills/impeccable/scripts/live/source-search.mjs new file mode 100644 index 000000000..0b7d26b5d --- /dev/null +++ b/plugin/skills/impeccable/scripts/live/source-search.mjs @@ -0,0 +1,105 @@ +/** + * The project-source walk shared by live-wrap.mjs and live-accept.mjs. + * + * Both scripts need the same thing: find the one project file containing a + * string (wrap looks for the element's class/id/text, accept looks for the + * session's `impeccable-variants-start` marker). They had two near-identical + * copies of the walk, and the copies drifted — same `EXTENSIONS` array declared + * twice, same `searchDirs` array declared twice, one `realpathSync` guarded by + * try/catch and the other not. That drift is what #374 had to patch in two + * places at once. + * + * Callers differ only in how they reject a candidate, so that is the one thing + * this module takes as options (`skipDirs`, `fileFilter`). + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { IMPECCABLE_DIR } from '../lib/impeccable-paths.mjs'; +import { matchesTemplateExtension } from '../lib/template-extensions.mjs'; + +/** + * Privileged roots, searched in order, before the catch-all `.` walk. + * + * `lib` is here for Phoenix, whose templates live in `lib/my_app_web/`. It is + * an ordering preference rather than a reachability fix: `.` already recurses + * into `lib`, so the real #374 bug was the extension list, not this array. + */ +export const SOURCE_SEARCH_DIRS = Object.freeze([ + 'src', 'app', 'pages', 'components', 'public', 'views', 'templates', 'lib', '.', +]); + +/** + * Directories that are never project source. + * + * `.impeccable` is the critical entry, and it is not cosmetic. Progressive + * publication stages each revision as `.impeccable/live/artifacts/ + * -r.`, and those artifacts carry the very marker accept + * searches for. The walk reaches `.` for any project whose source is not under + * one of the privileged roots above (this repo's own site lives in + * `site/pages/`), and dot-directories sort before letters, so the artifact was + * found *before* the real file. isGeneratedFile then declined the accept, and + * the agent fell back to carbonizing several hundred lines of stylesheet by + * hand. + */ +export const NEVER_SOURCE_DIRS = Object.freeze(['node_modules', '.git', IMPECCABLE_DIR]); + +const MAX_DEPTH = 5; + +/** + * Walk the project for the first template file whose contents include `query`. + * + * @param {object} opts + * @param {string} opts.query substring to find in file contents + * @param {string} opts.cwd project root + * @param {string[]} opts.extensions filename suffixes that count as templates + * @param {Iterable} [opts.skipDirs] directory names never to descend into + * @param {(filePath: string) => boolean} [opts.fileFilter] return false to reject a candidate + * @returns {string|null} absolute path of the first match + */ +export function findSourceFile({ query, cwd, extensions, skipDirs = NEVER_SOURCE_DIRS, fileFilter }) { + const skip = new Set(skipDirs); + const seen = new Set(); + for (const dir of SOURCE_SEARCH_DIRS) { + const absDir = path.join(cwd, dir); + if (!fs.existsSync(absDir)) continue; + const result = walk(absDir, query, extensions, skip, fileFilter, seen, 0); + if (result) return result; + } + return null; +} + +function walk(dir, query, extensions, skip, fileFilter, seen, depth) { + if (depth > MAX_DEPTH) return null; + // A broken symlink anywhere in the tree used to throw straight out of + // live-wrap's copy of this walk, killing the whole wrap. + let realDir; + try { realDir = fs.realpathSync(dir); } catch { return null; } + if (seen.has(realDir)) return null; + seen.add(realDir); + + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return null; } + + // Files before directories: a match in the current directory beats one + // nested deeper. + for (const entry of entries) { + if (!entry.isFile()) continue; + if (!matchesTemplateExtension(entry.name, extensions)) continue; + const filePath = path.join(dir, entry.name); + if (fileFilter && !fileFilter(filePath)) continue; + try { + if (fs.readFileSync(filePath, 'utf-8').includes(query)) return filePath; + } catch { /* unreadable, skip */ } + } + + for (const entry of entries) { + if (!entry.isDirectory()) continue; + if (skip.has(entry.name)) continue; + const result = walk(path.join(dir, entry.name), query, extensions, skip, fileFilter, seen, depth + 1); + if (result) return result; + } + + return null; +}