diff --git a/.agents/skills/impeccable/reference/live.md b/.agents/skills/impeccable/reference/live.md index 4fd031421..aab6fa008 100644 --- a/.agents/skills/impeccable/reference/live.md +++ b/.agents/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.agents/skills/impeccable/scripts/live-inject.mjs b/.agents/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.agents/skills/impeccable/scripts/live-inject.mjs +++ b/.agents/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.agents/skills/impeccable/scripts/live.mjs b/.agents/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.agents/skills/impeccable/scripts/live.mjs +++ b/.agents/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.claude/skills/impeccable/reference/live.md b/.claude/skills/impeccable/reference/live.md index a0e69f315..c6ff0fa02 100644 --- a/.claude/skills/impeccable/reference/live.md +++ b/.claude/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.claude/skills/impeccable/scripts/live-inject.mjs b/.claude/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.claude/skills/impeccable/scripts/live-inject.mjs +++ b/.claude/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.claude/skills/impeccable/scripts/live.mjs b/.claude/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.claude/skills/impeccable/scripts/live.mjs +++ b/.claude/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.cursor/skills/impeccable/reference/live.md b/.cursor/skills/impeccable/reference/live.md index 84bfd823b..123777f49 100644 --- a/.cursor/skills/impeccable/reference/live.md +++ b/.cursor/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.cursor/skills/impeccable/scripts/live-inject.mjs b/.cursor/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.cursor/skills/impeccable/scripts/live-inject.mjs +++ b/.cursor/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.cursor/skills/impeccable/scripts/live.mjs b/.cursor/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.cursor/skills/impeccable/scripts/live.mjs +++ b/.cursor/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.gemini/skills/impeccable/reference/live.md b/.gemini/skills/impeccable/reference/live.md index 54fb1ad43..99fef6802 100644 --- a/.gemini/skills/impeccable/reference/live.md +++ b/.gemini/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.gemini/skills/impeccable/scripts/live-inject.mjs b/.gemini/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.gemini/skills/impeccable/scripts/live-inject.mjs +++ b/.gemini/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.gemini/skills/impeccable/scripts/live.mjs b/.gemini/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.gemini/skills/impeccable/scripts/live.mjs +++ b/.gemini/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.github/skills/impeccable/reference/live.md b/.github/skills/impeccable/reference/live.md index 1794cad05..2ed9258e8 100644 --- a/.github/skills/impeccable/reference/live.md +++ b/.github/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.github/skills/impeccable/scripts/live-inject.mjs b/.github/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.github/skills/impeccable/scripts/live-inject.mjs +++ b/.github/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.github/skills/impeccable/scripts/live.mjs b/.github/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.github/skills/impeccable/scripts/live.mjs +++ b/.github/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.kiro/skills/impeccable/reference/live.md b/.kiro/skills/impeccable/reference/live.md index c67d15b00..0d4c79d7f 100644 --- a/.kiro/skills/impeccable/reference/live.md +++ b/.kiro/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.kiro/skills/impeccable/scripts/live-inject.mjs b/.kiro/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.kiro/skills/impeccable/scripts/live-inject.mjs +++ b/.kiro/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.kiro/skills/impeccable/scripts/live.mjs b/.kiro/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.kiro/skills/impeccable/scripts/live.mjs +++ b/.kiro/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.opencode/skills/impeccable/reference/live.md b/.opencode/skills/impeccable/reference/live.md index 88aba8379..b3c387ecc 100644 --- a/.opencode/skills/impeccable/reference/live.md +++ b/.opencode/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.opencode/skills/impeccable/scripts/live-inject.mjs b/.opencode/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.opencode/skills/impeccable/scripts/live-inject.mjs +++ b/.opencode/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.opencode/skills/impeccable/scripts/live.mjs b/.opencode/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.opencode/skills/impeccable/scripts/live.mjs +++ b/.opencode/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.pi/skills/impeccable/reference/live.md b/.pi/skills/impeccable/reference/live.md index 70c3cfd2b..9d6add218 100644 --- a/.pi/skills/impeccable/reference/live.md +++ b/.pi/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.pi/skills/impeccable/scripts/live-inject.mjs b/.pi/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.pi/skills/impeccable/scripts/live-inject.mjs +++ b/.pi/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.pi/skills/impeccable/scripts/live.mjs b/.pi/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.pi/skills/impeccable/scripts/live.mjs +++ b/.pi/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.rovodev/skills/impeccable/reference/live.md b/.rovodev/skills/impeccable/reference/live.md index ddafdfd44..06a9160a1 100644 --- a/.rovodev/skills/impeccable/reference/live.md +++ b/.rovodev/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.rovodev/skills/impeccable/scripts/live-inject.mjs b/.rovodev/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.rovodev/skills/impeccable/scripts/live-inject.mjs +++ b/.rovodev/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.rovodev/skills/impeccable/scripts/live.mjs b/.rovodev/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.rovodev/skills/impeccable/scripts/live.mjs +++ b/.rovodev/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.trae-cn/skills/impeccable/reference/live.md b/.trae-cn/skills/impeccable/reference/live.md index a4ad290c4..d5639e2bf 100644 --- a/.trae-cn/skills/impeccable/reference/live.md +++ b/.trae-cn/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.trae-cn/skills/impeccable/scripts/live-inject.mjs b/.trae-cn/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.trae-cn/skills/impeccable/scripts/live-inject.mjs +++ b/.trae-cn/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.trae-cn/skills/impeccable/scripts/live.mjs b/.trae-cn/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.trae-cn/skills/impeccable/scripts/live.mjs +++ b/.trae-cn/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/.trae/skills/impeccable/reference/live.md b/.trae/skills/impeccable/reference/live.md index 970b528c7..77fe5580e 100644 --- a/.trae/skills/impeccable/reference/live.md +++ b/.trae/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/.trae/skills/impeccable/scripts/live-inject.mjs b/.trae/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/.trae/skills/impeccable/scripts/live-inject.mjs +++ b/.trae/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/.trae/skills/impeccable/scripts/live.mjs b/.trae/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/.trae/skills/impeccable/scripts/live.mjs +++ b/.trae/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // --------------------------------------------------------------------------- diff --git a/scripts/lib/utils.js b/scripts/lib/utils.js index 9c060894a..c8f8267f9 100644 --- a/scripts/lib/utils.js +++ b/scripts/lib/utils.js @@ -147,11 +147,22 @@ export function readSourceFiles(rootDir) { } } - // Read script files if they exist + // Read script files if they exist. + // + // Per-project artifacts (state files that belong to the consuming + // project, not the distributable skill) must be excluded here so + // the build never bundles them into the skill that ships to users. + // - config.json: the live-mode inject-target list for the current + // project. Written by the agent at first /impeccable live; tied + // to the project's filesystem layout. + const PER_PROJECT_ARTIFACTS = new Set(['config.json']); const scripts = []; const scriptsDir = path.join(entryPath, 'scripts'); if (fs.existsSync(scriptsDir)) { - const scriptFiles = fs.readdirSync(scriptsDir).filter(f => fs.statSync(path.join(scriptsDir, f)).isFile()); + const scriptFiles = fs.readdirSync(scriptsDir).filter(f => { + if (PER_PROJECT_ARTIFACTS.has(f)) return false; + return fs.statSync(path.join(scriptsDir, f)).isFile(); + }); for (const scriptFile of scriptFiles) { const scriptPath = path.join(scriptsDir, scriptFile); const scriptContent = fs.readFileSync(scriptPath, 'utf-8'); diff --git a/source/skills/impeccable/reference/live.md b/source/skills/impeccable/reference/live.md index 69240cfb6..10e13851b 100644 --- a/source/skills/impeccable/reference/live.md +++ b/source/skills/impeccable/reference/live.md @@ -351,16 +351,23 @@ Schema: ```json { - "files": ["", "", ...], + "files": ["", "", ...], + "exclude": ["", ...], "insertBefore": "", "commentSyntax": "html", "cspChecked": true } ``` +`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Each entry is either a literal path (`"public/index.html"`) or a glob pattern (`"public/**/*.html"`). Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. + +`exclude` (optional) is a list of glob patterns matching files to skip, even if a `files` glob would have included them. Use for email templates, demo fixtures, or any HTML that isn't a live page. + `cspChecked` tracks whether the CSP detection step below has already run. Absent on first setup; set to `true` after CSP is checked (whether patched, declined, or not needed). -`files` is the inject target — **the HTML files the browser actually loads**, not necessarily source. Tracked or generated doesn't matter here; wrap has its own generated-file guard and routes accepts through the fallback flow. +**Hard-excluded paths (cannot be overridden).** `**/node_modules/**` and `**/.git/**` are never matched regardless of what the user writes. These are vendor/metadata directories and injecting into them would silently instrument third-party code. + +**Glob syntax.** `**` matches any number of path segments (including zero), `*` matches any characters except `/`, `?` matches a single character except `/`. Paths are always relative to the project root with forward slashes. | Framework | `files` | `insertBefore` | `commentSyntax` | |-----------|---------|----------------|-----------------| @@ -370,12 +377,42 @@ Schema: | Nuxt | `["app.vue"]` | `` | `html` | | Svelte / SvelteKit | `["src/app.html"]` | `` | `html` | | Astro | `[" "]` | `` | `html` | -| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `` | `html` | +| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `` | `html` | Pick an anchor that exists in every file (`` almost always works). Use `insertAfter` if the anchor should match **after** a specific line. +For multi-page sites, **prefer a glob over a literal file list**. New pages added later are picked up automatically on the next `live-inject.mjs` run; no config maintenance needed. + For multi-page sites whose pages are *rebuilt* by a generator (Astro, static-site generators, custom scripts like `build-sub-pages.js`), the inject survives only until the next regeneration. Re-run `live.mjs` after each build. Accept is unaffected — it writes to true source via the fallback flow. +### Drift-heal warning + +On every `live.mjs` boot, after inject, the project is scanned for HTML files under common page-source roots (`public/`, `src/`, `app/`, `pages/`). If any exist that aren't covered by the resolved `files` list, the output includes a `configDrift` field: + +```json +{ + "ok": true, + "serverPort": 8400, + "pageFiles": [ "..." ], + "configDrift": { + "orphans": ["public/new-section/index.html", "public/docs/new-command.html"], + "orphanCount": 2, + "hint": "2 HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like \"public/**/*.html\"." + } +} +``` + +When `configDrift` is present, surface it to the user once per session before entering the poll loop: + +> Noticed N HTML file(s) in the project that aren't in `config.files`: +> +> - `public/new-section/index.html` +> - `public/docs/new-command.html` +> +> Add them, or switch `files` to a glob like `["public/**/*.html"]` and let it track new pages automatically? + +Don't auto-update the config — let the user decide. `configDrift` is `null` when there's no drift. + ### CSP detection (first-time only) If `config.cspChecked === true`, skip this entire section. You already asked this user once; the answer sticks. diff --git a/source/skills/impeccable/scripts/live-inject.mjs b/source/skills/impeccable/scripts/live-inject.mjs index 9bbc345ac..61614efec 100644 --- a/source/skills/impeccable/scripts/live-inject.mjs +++ b/source/skills/impeccable/scripts/live-inject.mjs @@ -22,6 +22,16 @@ const CONFIG_PATH = process.env.IMPECCABLE_LIVE_CONFIG || path.join(__dirname, ' const MARKER_OPEN_TEXT = 'impeccable-live-start'; const MARKER_CLOSE_TEXT = 'impeccable-live-end'; +/** + * Hard-excluded directory patterns. These are NEVER user-facing pages and + * matching them would silently inject tracking scripts into third-party + * code. The user cannot turn these off via config — they are the floor. + */ +const HARD_EXCLUDES = [ + '**/node_modules/**', + '**/.git/**', +]; + export async function injectCli() { const args = process.argv.slice(2); @@ -71,8 +81,10 @@ Output (JSON): const config = JSON.parse(fs.readFileSync(CONFIG_PATH, 'utf-8')); validateConfig(config); + const resolvedFiles = resolveFiles(process.cwd(), config); + if (args.includes('--remove')) { - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -93,7 +105,7 @@ Output (JSON): process.exit(1); } - const results = config.files.map((relFile) => { + const results = resolvedFiles.map((relFile) => { const absFile = path.resolve(process.cwd(), relFile); if (!fs.existsSync(absFile)) return { file: relFile, error: 'file_not_found' }; const content = fs.readFileSync(absFile, 'utf-8'); @@ -108,6 +120,95 @@ Output (JSON): if (!anyInserted) process.exit(1); } +/** + * Expand config.files (which may contain glob patterns) into a literal list + * of existing file paths relative to rootDir. Literal entries pass through; + * glob patterns are expanded via fs.globSync. HARD_EXCLUDES and config.exclude + * are applied as filters. Duplicates are removed. Order is preserved by + * first appearance. + */ +export function resolveFiles(rootDir, config) { + const patterns = config.files; + const userExcludes = Array.isArray(config.exclude) ? config.exclude : []; + const allExcludes = [...HARD_EXCLUDES, ...userExcludes]; + const excludeRegexes = allExcludes.map(globToRegex); + + const isExcluded = (relPath) => excludeRegexes.some((re) => re.test(relPath)); + const isGlob = (s) => /[*?[]/.test(s); + + const seen = new Set(); + const out = []; + for (const pat of patterns) { + if (!isGlob(pat)) { + // Literal path — include even if it doesn't exist yet; the caller + // reports file_not_found per-entry. Exclude list doesn't apply to + // explicit literal entries (user named it on purpose). + if (!seen.has(pat)) { + seen.add(pat); + out.push(pat); + } + continue; + } + let matches; + try { + matches = fs.globSync(pat, { cwd: rootDir, withFileTypes: true }); + } catch { + continue; + } + for (const ent of matches) { + if (!ent.isFile || !ent.isFile()) continue; + const abs = path.join(ent.parentPath || ent.path || rootDir, ent.name); + const rel = path.relative(rootDir, abs).split(path.sep).join('/'); + if (isExcluded(rel)) continue; + if (seen.has(rel)) continue; + seen.add(rel); + out.push(rel); + } + } + return out; +} + +/** + * Convert a glob pattern to a RegExp. Supports: + * ** → any number of path segments (including zero) + * * → any chars except `/` + * ? → any single char except `/` + * Paths are normalized to forward slashes before matching. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + // ** — any number of segments, including zero. Handle the common + // **/ and /** forms so `a/**/b` matches `a/b` as well as `a/x/y/b`. + if (pattern[i + 2] === '/') { + re += '(?:.*/)?'; + i += 3; + } else { + re += '.*'; + i += 2; + } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Core operations // --------------------------------------------------------------------------- @@ -120,6 +221,14 @@ function validateConfig(cfg) { if (!cfg.files.every((f) => typeof f === 'string' && f.length > 0)) { throw new Error('config.files must contain only non-empty strings'); } + if (cfg.exclude !== undefined) { + if (!Array.isArray(cfg.exclude)) { + throw new Error('config.exclude, if present, must be a string array'); + } + if (!cfg.exclude.every((f) => typeof f === 'string' && f.length > 0)) { + throw new Error('config.exclude must contain only non-empty strings'); + } + } if (typeof cfg.insertBefore !== 'string' && typeof cfg.insertAfter !== 'string') { throw new Error('config.insertBefore or config.insertAfter (string) required'); } diff --git a/source/skills/impeccable/scripts/live.mjs b/source/skills/impeccable/scripts/live.mjs index aefacfba3..befbdb8ed 100644 --- a/source/skills/impeccable/scripts/live.mjs +++ b/source/skills/impeccable/scripts/live.mjs @@ -22,6 +22,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { fileURLToPath } from 'node:url'; import { loadContext } from './load-context.mjs'; +import { resolveFiles } from './live-inject.mjs'; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const PID_FILE = path.join(process.cwd(), '.impeccable-live.json'); @@ -82,12 +83,19 @@ The agent should then: // 4. Load PRODUCT.md + DESIGN.md context (auto-migrates legacy .impeccable.md) const ctx = loadContext(process.cwd()); - // 5. Emit everything the agent needs + // 5. Compute drift-heal: compare resolved inject targets against the + // project's HTML files. Orphans are HTML files not covered by config. + // Warning only — the agent decides whether to act. + const resolvedFiles = resolveFiles(process.cwd(), checkResult.config); + const drift = scanForDrift(process.cwd(), resolvedFiles, checkResult.config); + + // 6. Emit everything the agent needs console.log(JSON.stringify({ ok: true, serverPort: serverInfo.port, serverToken: serverInfo.token, - pageFiles: checkResult.config.files, + pageFiles: resolvedFiles, + configDrift: drift, hasProduct: ctx.hasProduct, product: ctx.product, productPath: ctx.productPath, @@ -98,6 +106,98 @@ The agent should then: }, null, 2)); } +/** + * Drift-heal scan. Walks the project for HTML files under common + * page-source directories (public/, src/, app/, pages/) and reports any + * that aren't covered by the resolved inject targets. This is purely + * advisory — the agent can ignore it, or suggest the user add the + * orphans to config.files. + * + * Skipped if config.files already contains at least one glob pattern + * covering everything in practice (signaled by the orphan count being 0). + */ +function scanForDrift(rootDir, resolvedFiles, config) { + const SCAN_ROOTS = ['public', 'src', 'app', 'pages']; + const IGNORE_DIRS = new Set([ + 'node_modules', '.git', '.next', '.nuxt', '.svelte-kit', '.astro', + '.turbo', '.vercel', '.cache', 'coverage', 'dist', 'build', + ]); + + const resolvedSet = new Set(resolvedFiles.map((f) => f.split(path.sep).join('/'))); + + // Files matching the user's `exclude` globs are intentional omissions, + // not drift. Compile them to regexes so the orphan list stays signal. + const userExcludeRegexes = (Array.isArray(config.exclude) ? config.exclude : []) + .map((p) => globToRegex(p)); + const isUserExcluded = (rel) => userExcludeRegexes.some((re) => re.test(rel)); + + const orphans = []; + + const walk = (dir, relBase) => { + let entries; + try { entries = fs.readdirSync(dir, { withFileTypes: true }); } + catch { return; } + for (const e of entries) { + const rel = relBase ? `${relBase}/${e.name}` : e.name; + if (e.isDirectory()) { + if (IGNORE_DIRS.has(e.name) || e.name.startsWith('.')) continue; + walk(path.join(dir, e.name), rel); + } else if (e.isFile() && e.name.endsWith('.html')) { + if (resolvedSet.has(rel)) continue; + if (isUserExcluded(rel)) continue; + orphans.push(rel); + } + } + }; + + for (const root of SCAN_ROOTS) { + const abs = path.join(rootDir, root); + if (fs.existsSync(abs) && fs.statSync(abs).isDirectory()) { + walk(abs, root); + } + } + + if (orphans.length === 0) return null; + const capped = orphans.slice(0, 20); + return { + orphans: capped, + orphanCount: orphans.length, + hint: `${orphans.length} HTML file(s) exist but aren't in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".`, + }; +} + +/** + * Same glob-to-regex mapping used by live-inject.mjs. Kept inline here + * to avoid a circular import (live-inject.mjs already imports nothing + * from live.mjs). The two must stay in sync. + */ +function globToRegex(pattern) { + let re = ''; + let i = 0; + while (i < pattern.length) { + const c = pattern[i]; + if (c === '*') { + if (pattern[i + 1] === '*') { + if (pattern[i + 2] === '/') { re += '(?:.*/)?'; i += 3; } + else { re += '.*'; i += 2; } + } else { + re += '[^/]*'; + i += 1; + } + } else if (c === '?') { + re += '[^/]'; + i += 1; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + re += '\\' + c; + i += 1; + } else { + re += c; + i += 1; + } + } + return new RegExp('^' + re + '$'); +} + // --------------------------------------------------------------------------- // Helpers // ---------------------------------------------------------------------------