feat(live): config globs + drift-heal warning for multi-page projects

Config drift was a real tripwire for projects with static generators: new
HTML files get added, never make it into config.files, silently skip
injection. Two additions.

config.files entries now accept glob patterns (**, *, ?) expanded via
fs.globSync in live-inject. Multi-page projects can write
["public/**/*.html"] once and never maintain the list again. New optional
exclude field filters out matched files (email templates, demo fixtures).
HARD_EXCLUDES of node_modules and .git are enforced regardless of user
config so vendor trees can never receive a tracking script.

live.mjs now runs a drift scan after inject: walks common page-source
roots (public/, src/, app/, pages/) and reports HTML files not covered
by the resolved inject targets. Respects user excludes so intentional
omissions aren't flagged. Output JSON carries configDrift: { orphans,
orphanCount, hint } or null. live.md documents the agent flow for
surfacing drift to users without auto-mutating the config.

Unbundle config.json from the distributable skill: it's a per-project
artifact, not skill code. readSourceFiles now skips any PER_PROJECT_ARTIFACTS
during source scan so build output to .claude/ .cursor/ etc never ships
one project's inject targets to another's install. The per-harness
copies stay gitignored via the existing **/skills/impeccable/scripts/config.json
rule; each consuming project writes its own on first /impeccable live.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Paul Bakaus
2026-04-23 09:12:56 -07:00
co-authored by Claude Opus 4.7
parent 6846a135ce
commit b0feed06c9
37 changed files with 3049 additions and 86 deletions
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
+111 -2
View File
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
+111 -2
View File
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
+111 -2
View File
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------
+13 -2
View File
@@ -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');
+40 -3
View File
@@ -351,16 +351,23 @@ Schema:
```json
{
"files": ["<path>", "<path>", ...],
"files": ["<path-or-glob>", "<path-or-glob>", ...],
"exclude": ["<optional-glob>", ...],
"insertBefore": "</body>",
"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"]` | `</body>` | `html` |
| Svelte / SvelteKit | `["src/app.html"]` | `</body>` | `html` |
| Astro | `[" <root layout .astro>"]` | `</body>` | `html` |
| Multi-page (separate HTML per route) | Every HTML file the dev server serves — glob the output dir, e.g. `public/**/*.html` | `</body>` | `html` |
| Multi-page (separate HTML per route) | `["public/**/*.html"]` — a glob covering the served directory | `</body>` | `html` |
Pick an anchor that exists in every file (`</body>` 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.
@@ -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');
}
+102 -2
View File
@@ -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
// ---------------------------------------------------------------------------