From 2b37b20d7ae3d0dac6df6fbf563091a0e0567a65 Mon Sep 17 00:00:00 2001 From: Paul Bakaus Date: Mon, 17 Aug 2026 18:11:11 -0700 Subject: [PATCH] Add docs/CLI-CONTRACT.md: observable behavior of every impeccable verb Prepared with AI assistance (Claude Code). --- docs/CLI-CONTRACT.md | 1760 ++++++++++++++++++++++++++++++++++++++++++ tests/oracle/lib.mjs | 7 + 2 files changed, 1767 insertions(+) create mode 100644 docs/CLI-CONTRACT.md diff --git a/docs/CLI-CONTRACT.md b/docs/CLI-CONTRACT.md new file mode 100644 index 000000000..d9f36c65a --- /dev/null +++ b/docs/CLI-CONTRACT.md @@ -0,0 +1,1760 @@ +# Impeccable CLI contract + +The observable behavior of every `impeccable` verb: arguments, environment, +inputs, stdout and stderr byte for byte, exit codes, files written, network. +This is the specification an alternate implementation of the scripts has to +meet; `tests/oracle/` records goldens against it and replays them. + +Verb names are the binary's subcommands. Today each verb is a script under +`skill/scripts/` or a `cli/bin` subcommand; the mapping is `JS_VERBS` in +`tests/oracle/lib.mjs`. + +Quoted strings, regexes, and JSON in this document are verbatim from the +source, including any em dashes inside user-facing messages; do not "fix" them. + +Sections: + +1. Detect CLI and misc verbs (`detect`, `ignores`, `skills`, `concept-seed`, `serve-question`, `generate-image`) +2. Context and utility verbs (`context`, `doctor`, `pin`, `surface-brief`, `critique-storage`, `palette`, `embed-prompt`, `signals`, `detect-csp`) +3. Design hook (`hook`, `hook-before-edit`, `hooks`) +4. Live mode (`live*`) + +--- + +## 1. Detect CLI and misc verbs + +Source snapshot: repo `impeccable-second` @ main (f88b2837), 2026-08-17. Node `>=22.18.0`, package `impeccable` v3.6.0, `"type": "module"`, `bin: { impeccable: cli/bin/cli.js }`, `main`/`exports["."]`: `cli/engine/detect-antipatterns.mjs`, `exports["./browser"]`: `cli/engine/detect-antipatterns-browser.js`. + +Notation: `stdout>` / `stderr>` are literal writes; `exit N`; template strings use `${}` as in the source. All regexes are quoted verbatim. + +--- + +### PART 1 — the `impeccable` binary and the detect engine + +#### `cli/bin/cli.js` -> `impeccable ` (root dispatcher) + +- **Invoked from**: README.md "CLI" section (`npx impeccable detect src/`, `npx impeccable ignores ...`), README.npm.md Quick Start, `skill/reference/hooks.md:15` ("Manual `npx impeccable detect` scans use the same project filter config..."), `hooks.md:65` ("Run `npx impeccable detect ` first to see what actually fires there"). +- **Args**: `args = process.argv.slice(2)`, `command = args[0]`. +- **Dispatch order (exact)**: + 1. `!command || command === '--help' || command === '-h'` → print (via `console.log`, stdout) then `exit 0`: + ``` + Usage: impeccable [options] + + Commands: + detect [file-or-dir-or-url...] Scan for UI anti-patterns and design quality issues + ignores Manage detector ignore rules, files, and values + help List all available skills and commands + install Install impeccable skills into your project or global harness + link Symlink skills from a local checkout or submodule + update Update skills to the latest version + check Check if skill updates are available + + Options: + --help Show this help message + --version Show version number + + Compatibility: + impeccable skills Legacy namespace; still supported. + ``` + 2. `--version` / `-v` → `console.log(pkg.version)` read from `/../../package.json`; `exit 0`. + 3. `detect` → `process.argv = [argv0, argv1, ...args.slice(1)]`; dynamic-import `../engine/detect-antipatterns.mjs`, `await detectCli()`. + 4. `ignores` or `ignore` → `./commands/ignores.mjs` `run(args.slice(1))`. + 5. `skills` → `./commands/skills.mjs` `run(args.slice(1))` (legacy namespace). + 6. `command ∈ SKILL_COMMANDS = {'help','install','link','update','check'}` → `skills.mjs run(args)` (note: whole `args`, so `run` sees the verb as `args[0]`). + 7. `looksLikeDetectTarget(command)` → detect shorthand: `process.argv = [argv0, argv1, ...args]` then `detectCli()`. Predicate: `arg.startsWith('-') || /^https?:\/\//i.test(arg) || arg.includes('/') || arg.includes('\\') || arg.includes('.') || existsSync(resolve(arg))`. + 8. `init` → `console.error('"init" is not a CLI command. Type /impeccable init in your AI coding agent\'s chat (Claude Code, Cursor, Codex, ...), not in this terminal.')`, `exit 1`. (Note: a real path literally named `init` hits rule 7 first — tested: "a real path named init still routes to detect".) + 9. otherwise → `console.error(\`Unknown command: "${command}"\n\nTo see a list of supported commands, run:\n impeccable --help\`)`, `exit 1`. +- **Top-level catch**: `main().catch(error => { if (error?.code === 'IMPECCABLE_PROMPT_ABORT') { console.log('\nAborted.'); exit 130 } console.error(error?.message || error); exit 1 })`. +- **No `live` subcommand exists in the CLI** (README/CLAUDE.md mentions of `npx impeccable live` are aspirational; live mode is driven by `skill/scripts/live*.mjs`, out of scope here). +- **Tests**: `tests/skills-cli.test.js` ("root help advertises top-level skills commands", "top-level install aliases the legacy skills install command", "#472" init tests, "a real path named init still routes to detect"). + +--- + +#### `cli/engine/detect-antipatterns.mjs` (facade) and `skill/scripts/detect.mjs` (wrapper) -> `impeccable detect` + +- **Facade**: re-exports registry (`ANTIPATTERNS`, `RULE_ENGINE_SUPPORT`, `getAntipattern`, `getRulesForCategory`, `getRuleEngineSupport`), constants (`SAFE_TAGS`, `BORDER_SAFE_TAGS`, `OVERUSED_FONTS`, `GENERIC_FONTS`, `KNOWN_SERIF_FONTS`), color helpers, `isFullPage`, check fns, `createDetectorProfile`, `summarizeDetectorProfile`, design-system fns (`parseFrontmatter as parseDesignFrontmatter`, `normalizeDesignSystem`, `loadDesignSystemForCwd`, `checkSourceDesignSystem`, `collectStaticDesignSystemFindings`), `detectHtml`, `detectUrl`, `createBrowserDetector`, `detectText`, `extractStyleBlocks`, `extractCSSinJS`, fs helpers (`walkDir`, `hasScannableExtension`, `SCANNABLE_EXTENSIONS`, `SKIP_DIRS`, `buildImportGraph`, `resolveImport`, `detectFrameworkConfig`, `isPortListening`, `FRAMEWORK_CONFIGS`), `formatFindings`, `detectCli`. Main-module guard: `process.argv[1]?.endsWith('detect-antipatterns.mjs') || endsWith('detect-antipatterns.mjs/')` → `detectCli()`. +- **`skill/scripts/detect.mjs`** (what the skill invokes as `node {{scripts_path}}/detect.mjs ...`): candidates in order `/detector/detect-antipatterns.mjs` (bundled install layout), then `/../../cli/engine/detect-antipatterns.mjs` (repo layout). If neither exists: `stderr> Error: bundled detector not found.\n`, `exit 1`. Else dynamic-imports via `pathToFileURL` and `await detectCli()`. `detectCli` strips a leading `detect` arg itself, so both `detect.mjs --json x` and `detect.mjs detect --json x` work. +- **Skill call sites (quoted)**: + - `reference/routing.md:16`: "**If `scan.targets` is non-empty and `setup.platform` is not `ios`/`android`/`adaptive`, run `node {{scripts_path}}/detect.mjs --json ` once** (the bundled detector over local files: no network, no npx; it reads HTML/CSS, so skip it for native projects)." + - `reference/critique.md:73`: `node {{scripts_path}}/detect.mjs --json [target]` with "Pass markup files/directories as `[target]`; do not pass CSS-only files. For URLs, skip CLI scan and use browser visualization. ... Exit code 0 = clean; 2 = findings. If the detector entrypoint is missing or fails to load, report deterministic scan unavailable and continue". + - `reference/layout.md:28`: `node {{scripts_path}}/detect.mjs --json --scope layout [target files or dirs]`; `reference/typeset.md:27`: `... --scope type ...`. + - `reference/audit.native.md:3`: "no browser tooling or `detect.mjs` applies" for native. + +#### `detectCli()` — `cli/engine/cli/main.mjs` + +**Arg normalization**: `args = process.argv.slice(2)` with `-json`→`--json`, `-fast`→`--fast`; if `args[0] === 'detect'` drop it. + +**Flags** (all detected with `args.includes` unless stated): + +| Flag | Effect | +|---|---| +| `--json` | JSON output to **stdout** | +| `--quiet` | text mode prints only summary line(s) to stderr | +| `--help` | print usage (stdout) and `exit 0` — evaluated *after* scope/viewport parsing (so a bad `--scope` still errors first) | +| `--no-advisory` | drop findings with `advisory === true` before output/exit-code | +| `--fast` | deprecated, ignored; `stderr> Note: --fast is deprecated and ignored. The full scan is fast now and runs every rule.\n` | +| `--gpt`, `--gemini` | deprecated, ignored; `stderr> Note: --gpt and --gemini are deprecated and ignored. Generated-UI tells now run by default.\n` | +| `--no-config` | `configEnabled=false`: detectionConfig = `{ignoreRules:[],ignoreFiles:[],ignoreValues:[]}`; also disables design system and inline ignores | +| `--no-inline-ignores` | `inlineIgnoresEnabled = configEnabled && !flag` | +| `--no-design-system` | `designSystemEnabled = configEnabled && !flag && detectionConfig.designSystem?.enabled !== false` | +| `--scope ` / `--scope=` | comma-split, trimmed, filtered; repeated occurrences accumulate; the flag+value are spliced out of `args`. Value missing or starting with `--` → `stderr> Error: --scope requires a value. Valid scopes: ${[...RULE_SCOPES].join(', ')}\n`, `exit 1`. Unknown → `stderr> Error: unknown --scope value(s): ${unknown.join(', ')}. Valid scopes: ...\n`, `exit 1`. `RULE_SCOPES` = union of rules' `scopes` = `type`, `layout` (in insertion order: `type` first from `overused-font`, then `layout` from `nested-cards`). | +| `--viewport ` / `--viewport=WxH` | regex `/^(\d{2,5})x(\d{2,5})$/i`; failure → `stderr> Error: --viewport requires a WxH value, e.g. --viewport 390x844\n`, `exit 1`. Sets `baseScanOptions.viewport = {width,height}` (browser scans only). Spliced out. | +| positional | `targets = args.filter(a => !a.startsWith('--'))` (so a bare `-x` single-dash arg is a *target*, except `-json`/`-fast` which were rewritten) | + +**Usage text** (`printUsage`, stdout, verbatim): +``` +Usage: impeccable detect [options] [file-or-dir-or-url...] + +Scan files or URLs for UI anti-patterns and design quality issues. + +Options: + --json Output results as JSON + --quiet In text mode, only print the final findings count + --scope Only report rules in the given design domain + (type, layout). Comma-separated. + --viewport Browser viewport for URL scans (default 1280x800), + e.g. --viewport 390x844 for a mobile-width pass + --no-config Do not apply project config, detector ignores, inline + ignore comments, or DESIGN.md + --no-inline-ignores Do not honor in-file impeccable-disable* ignore comments + --no-design-system Do not load local DESIGN.md / .impeccable/design.json context + --no-advisory Suppress advisory findings entirely (e.g. em-dash overuse) + --help Show this help message + +Advisory findings: + Some rules are advisory: detected and listed in a separate section, but never + counted as failures and never changing the exit code. They stay out of the + failure count so they never block automation. --no-advisory hides them. + +Project config: + Respects .impeccable/config.json and .impeccable/config.local.json detector + settings: detector.ignoreRules, detector.ignoreFiles, detector.ignoreValues, + and detector.designSystem.enabled. + +Inline ignores: + In-file comments waive a finding where it lives and travel with the file: + + .brand { font-family: Inter } /* impeccable-disable-line overused-font */ + // impeccable-disable-next-line bounce-easing: intentional bounce + impeccable-disable applies to the whole file; -line / -next-line are scoped. + List one or more rule ids (comma-separated), or omit them / use * for all. + +Detection modes: + HTML files Static HTML/CSS analysis (default, catches linked CSS) + Non-HTML files Regex pattern matching (CSS, JSX, TSX, etc.) + URLs Puppeteer full browser rendering (auto-detected; + http(s):// and file:// URLs) + +Examples: + impeccable detect src/ + impeccable detect index.html + impeccable detect https://example.com + impeccable detect --json . + impeccable detect --no-config src/ +``` + +**Config**: `readDetectionConfig(process.cwd())` (see "Config file" below). Note config is resolved from **cwd**, not from the target. + +**Design system per target**: `scanOptionsFor(localPath)`: if design system enabled and `localPath` given, `loadDesignSystemForTarget(localPath, {cache})` walks up from the target's own dir (never cwd): a dir with `DESIGN.md`/`Design.md`/`design.md` (directly, or under fallback dirs `.agents/context`, `docs`) is the design root; a dir with a project marker (`.git`, `package.json`, `.impeccable`) but no DESIGN.md is a boundary → no design system; reaching `os.homedir()` or fs root → none. Sidecar candidates: `/.impeccable/design.json`, `/DESIGN.json`, `/DESIGN.json`. Cache key `root:` or `\0none`. Options become `{...baseScanOptions, designSystem}` when found. `design-system.mjs` has **no CLI surface** (no main guard, no argv parsing); it only exports functions. + +**Input resolution**: +1. If `!process.stdin.isTTY && targets.length === 0` → **stdin mode** (`handleStdin`): read all stdin; try `JSON.parse`; if `parsed.tool_input.file_path` exists on disk → `detectLocalFile(fp, scanOptionsFor(fp))` (hook payload dispatch); else `detectText(input, '', scanOptionsFor(null))`. +2. Else `paths = targets.length ? targets : [process.cwd()]`. `urlRe = /^(?:https?|file):\/\//i`. If more than one URL target, a shared browser is created once via `createBrowserDetector()` (defaults `waitUntil:'load'`, `settleMs:100`), closed in `finally`; a single URL uses `detectUrl` directly (`waitUntil:'networkidle0'`, `settleMs:0`). +3. For each target, in order: + - URL: `file:` URLs get `scanOptionsFor(fileURLToPath(url) or null)`; http(s) get `baseScanOptions` (never cwd's design system). Errors: `stderr> Error: ${e.message}\n`, continue. + - Else `resolved = path.resolve(target)`; `fs.statSync` failure → `stderr> Warning: cannot access ${target}\n`, continue. + - **Directory**: unless `--json`/`--quiet`, `detectFrameworkConfig(resolved)` (see below) and if found probes the port, writing one of three stderr notices: + - listening & matched: `\n${name} dev server detected on localhost:${port}.\nFor more accurate results, scan the running site:\n npx impeccable detect http://localhost:${port}\n\n` + - listening & !matched: `\n${name} project detected (${basename(configPath)}).\nPort ${port} is in use by another service. Start the ${name} dev server and scan via URL for best results.\n\n` + - not listening: `\n${name} project detected (${basename(configPath)}).\nStart the dev server and scan via URL for best results:\n npx impeccable detect http://localhost:${port}\n\n` + Then `files = walkDir(resolved).filter(f => !shouldIgnoreDetectionFile(f, cwd, config))`. If `files.length > 50 && stdin.isTTY && !json && !quiet`: `stderr> \nFound ${n} files (${htmlCount} HTML) in ${target}.\nScanning may take a while${htmlCount > 10 ? ' (static HTML/CSS processes each HTML file individually)' : ''}.\nTarget a specific subdirectory to narrow scope.\n` then readline prompt `Continue? [Y/n] ` on stderr; empty or `/^y(es)?$/i` continues; otherwise `stderr> Aborted.\n`, `exit 0`. Then `buildImportGraph(files)` → reverse map; each file scanned with its own options; findings from a file that is imported get `f.importedBy = [basename(importer), ...]` (Set iteration order). + - **File**: skipped if `shouldIgnoreDetectionFile`; else `detectLocalFile`. + - `detectLocalFile(fp, opts)`: extension (lowercased) in `HTML_EXTENSIONS = {'.html','.htm'}` → `detectHtml(fp, opts)`; else `detectText(readFileSync(fp,'utf-8'), fp, opts)`. +4. Post-filter: `filterDetectionFindings(all, config)` (ignoreRules/ignoreValues), then `filterByScopes(all, scopes)` (keeps findings whose rule declares any requested scope; empty scopes = no filter), then `--no-advisory` drop. +5. Partition `{primary, advisory}` by `f.advisory === true`. + +**Output and exit codes**: +- `allFindings.length > 0`: + - json: `stdout> JSON.stringify(allFindings, null, 2) + '\n'` (all findings, advisory ones flagged). + - quiet: `stderr> ${primary.length} anti-pattern${n===1?'':'s'} found.\n`; if advisory: `stderr> dim(`${adv} advisory note${adv===1?'':'s'} (not counted).`) + '\n'`. + - text: `stderr> formatFindings(all,false) + '\n'`. + - `exit(primary.length > 0 ? 2 : 0)`. +- no findings: json → `stdout> []\n`; text/quiet → nothing. `exit 0`. +- Any other exit: `1` for arg errors above; uncaught exceptions propagate to `cli.js` catch (`exit 1`). +- `dim(text)` = `process.stderr.isTTY ? '\x1b[2m' + text + '\x1b[0m' : text`. This is the **only** ANSI styling in detect output. + +**Text format** (`formatFindings(findings, false)`): +``` +formatFindingsBody(primary): + group by f.file preserving first-seen order; + for each file: "\n${file}${importNote}" where importNote = items[0].importedBy?.length ? ` (imported by ${items[0].importedBy.join(', ')})` : '' + for each item: " ${item.line ? `line ${item.line}: ` : ''}[${item.antipattern}] ${item.snippet}" + " → ${item.description}" (U+2192 arrow, 4-space indent) +then "\n${primary.length} anti-pattern${1?'':'s'} found." +then, if advisory non-empty: + "\n" + dim('── Advisory (not counted as failures) ──') (U+2500 box-drawing dashes) + each body line individually dim()-wrapped + dim(`\n${advisory.length} advisory note${1?'':'s'}. Suppress with --no-advisory.`) +all joined with '\n'. +``` +Example (non-TTY): +``` + +/abs/a.css + line 3: [side-tab] .card — border-left: 4px solid #6366f1 + → Thick colored border on one side of a card — ... + +1 anti-pattern found. + +── Advisory (not counted as failures) ── + +/abs/a.html + [em-dash-overuse] 9 em-dashes in body text + → Em-dash saturation ... + +1 advisory note. Suppress with --no-advisory. +``` + +**Finding object** (`cli/engine/findings.mjs` `finding(id, filePath, snippet, line = 0)`), key order exactly: +```js +{ antipattern: id, name: ap.name, description: ap.description, severity: ap.severity || 'warning', category: ap.category || null, file: filePath, line, snippet } +// plus, only when registry rule has `advisory: true`: advisory: true +``` +Optional keys added later by engines (appended after the above): `ignoreValue` (design-system rules; browser findings with a value), `importedBy` (dir scans), `severity` may be overwritten by per-finding promotion (browser & html-patterns, e.g. pulsing dot in a header). Design-system findings are `{...finding(...), ...extras}` where extras = `{ ignoreValue }`. Static-HTML and browser findings have `line: 0`; regex findings have 1-based lines. `severity` values in registry: `'warning'` (default), `'advisory'` (many generated-UI tells and design-system-color/radius/font-size, numbered-section-labels, blinking-cursor, shape-assembled-illustration), `'error'` (`script-error`, `content-hidden-at-rest`). **Only `em-dash-overuse` has `advisory: true`**; `severity:'advisory'` alone does NOT make a finding advisory for exit-code/partition purposes (isAdvisory checks `finding.advisory === true`). + +**Categories**: `category` is `'slop'` (AI tells) or `'quality'`. Category has **no effect on output**, ordering, or exit codes; it is only carried in the finding and used by `getRulesForCategory`. Registry (59 ids, in order): side-tab, border-accent-on-rounded, overused-font, flat-type-hierarchy, gradient-text, ai-color-palette, cream-palette, nested-cards, monotonous-spacing, bounce-easing, pulsing-dot, blinking-cursor, shape-assembled-illustration, dark-glow, radial-halo, radial-spotlight-glow, marquee, icon-tile-stack, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, em-dash-overuse, marketing-buzzword, aphoristic-cadence, oversized-h1, extreme-negative-tracking, broken-image, script-error, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, gray-on-color, low-contrast, layout-transition, line-length, cramped-padding, body-text-viewport-edge, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, text-overflow, repeated-container-text, clipped-overflow-container, design-system-font, design-system-color, design-system-radius, design-system-font-size, gpt-thin-border-wide-shadow, repeating-stripes-gradient, codex-grid-background, theater-slop-phrase, image-hover-transform. Scopes: `type` = overused-font, flat-type-hierarchy, italic-serif-display, hero-eyebrow-chip, kicker-above-heading, numbered-section-labels, oversized-h1, extreme-negative-tracking, line-length, tight-leading, skipped-heading, heading-rhythm, justified-text, tiny-text, undersized-ui-text, all-caps-body, wide-tracking, design-system-font, design-system-font-size; `layout` = nested-cards, monotonous-spacing, icon-tile-stack, content-hidden-at-rest, edge-flush-cards, text-occlusion, first-viewport-column-overflow, line-length, cramped-padding, body-text-viewport-edge, heading-rhythm, text-overflow, clipped-overflow-container. `RULE_ENGINE_SUPPORT = { regex: Set['source','page-analyzer'], 'static-html': Set['element','page'], browser: Set['element','page','layout'], visual: Set['visual-contrast'] }`. + +**Tests**: `tests/detect-antipatterns-fixtures.test.mjs` (CLI block: `--help exits 0` and contains `Usage:`/`--quiet`, not `--gpt`; `--gpt` prints deprecation; `detect` prefix accepted; should-pass exits 0; should-flag exits 2 with `side-tab` in stderr; `--json` parses; `--quiet` stdout empty and stderr matches `/^[1-9]\d* anti-patterns? found\.$/`; `formatFindings — advisory partitioning`), `tests/detect-cli-stdin-dispatch.test.mjs`, `tests/detect-cli-design-contamination.test.mjs`, `tests/inline-ignores.test.mjs` ("detect CLI end-to-end"), `tests/detect-url-launch.test.mjs`. + +#### `cli/engine/node/file-system.mjs` + +- `SKIP_DIRS = {'node_modules','dist','build','__pycache__'}`; any directory whose name starts with `.` is skipped **except** `HIDDEN_SOURCE_DIRS = {'.vitepress','.vuepress','.storybook'}`. The root passed to `walkDir` is never name-checked (an explicit hidden dir scans). +- `SCANNABLE_EXTENSIONS = {'.html','.htm','.css','.scss','.sass','.less','.jsx','.tsx','.js','.ts','.vue','.svelte','.astro','.blade.php'}`; `hasScannableExtension` lowercases and also matches multi-dot exts by `endsWith` (`.blade.php`). +- `walkDir` returns files in `readdirSync` order, recursive, unreadable dirs → `[]`. +- **There is no generated-file detection in the CLI** (`skill/scripts/lib/is-generated.mjs` is hook-side only and not imported by `cli/`). +- Import graph: `IMPORT_SPECIFIER_PATTERNS = [/import\s+(?:[\s\S]*?from\s+)?['"]([^'"]+)['"]/g, /@import\s+(?:url\(\s*)?['"]?([^'");\s]+)['"]?\s*\)?/g, /@(?:use|forward)\s+['"]([^'"]+)['"]/g]`; `resolveImport` only for specifiers matching `/^[./]/`: exact, `base+ext` for each scannable ext, then `base/index+ext`. +- `FRAMEWORK_CONFIGS` (first match wins, in this order): Next.js (`next.config.js|mjs|ts`, 3000, `/port\s*[:=]\s*(\d+)/`, header `x-powered-by` ~ `/next/i`); SvelteKit (`svelte.config.js|ts`, 5173, header `x-sveltekit-page` any); Nuxt (`nuxt.config.js|ts`, 3000, `x-powered-by` ~ `/nuxt/i`); Vite (`vite.config.js|ts|mjs`, 5173, body `/@vite\/client/`); Astro (`astro.config.js|ts|mjs`, 4321, body `/astro/i`); Angular (`angular.json`, 4200, `/"port"\s*:\s*(\d+)/`, body `/ng-version/i`); Remix (`remix.config.js|ts`, 3000, `x-powered-by` ~ `/remix/i`). Port overridden by first regex match in the config file. +- `isPortListening(port, fingerprint)`: with fingerprint → `fetch('http://localhost:${port}/')` 2s abort, header check then body check → `{listening:true, matched:bool}`; error → `{listening:false}`. Without fingerprint → TCP connect to 127.0.0.1 with 500ms timeout. + +#### `cli/engine/shared/inline-ignores.mjs` — inline ignore comments + +- `DIRECTIVE_RE = /impeccable-(disable-next-line|disable-line|disable)\b[ \t]*([^\n\r]*)/gi` (matched anywhere on a line, any comment syntax; case-insensitive). +- `TRAILING_CLOSER_RE = /\s*(?:\*\/\}?|--+>|\*\}|#\}|%>|\}\})\s*$/` stripped from the remainder; then reason cut at first `/\s*(?:--+|:)\s*/`; tokens split on `/[\s,]+/`, lowercased; empty or containing `*` → `['*']`. +- Lines split on `'\n'` only (CRLF-safe since `\r` excluded from capture). `disable` → file set; `disable-line` on line i (1-based) → `line.get(i)`; `disable-next-line` on line i → `nextLine.get(i+1)`. +- `isInlineIgnored(finding)`: rule = lowercased `finding.antipattern`; file set match (`*` or rule) → true; if `line > 0`, `line`/`nextLine` set match → true. Line-less findings (static HTML, browser) match **only** whole-file directives. +- Applied inside `detectText` and `detectHtml` at the end unless `options.inlineIgnores === false` (set by `--no-config` or `--no-inline-ignores`). Not applied to URL scans. +- Fast path: skip unless `/impeccable-disable/i` occurs. +- **DOM-scoped ignore** (`rules/checks.mjs scopedIgnoreActive`): attribute `data-impeccable-ignore="rule-a rule-b"` (split on `/[\s,]+/`, lowercased; empty value or `*` = all) on an element waives matching findings for it and its subtree in browser, extension, and static engines. In `detectHtml`'s html-patterns pass, selector-backed findings are dropped when every element matched by the (pseudo-stripped) selector is under a waiver; unmatched selectors keep the finding. +- Tests: `tests/inline-ignores.test.mjs`; fixture `scoped-ignore.html`. + +#### Config file (`cli/lib/impeccable-config.mjs`) — `.impeccable/config.json` + `.impeccable/config.local.json` + +- Paths: `/.impeccable/config.json` (shared) and `/.impeccable/config.local.json` (per-dev; `writeDetectionConfig(...,{local:true})` also appends a block to `.git/info/exclude`: `# impeccable-config-ignore-start\n.impeccable/config.local.json\n# impeccable-config-ignore-end`, idempotent by marker regex, following `gitdir:` files for worktrees). +- Shape: + ```json + { "detector": { "ignoreRules": ["side-tab"], "ignoreFiles": ["src/legacy/**"], + "ignoreValues": [{ "rule": "overused-font", "value": "inter", "files": ["src/a.css"], "createdAt": "ISO", "reason": "..." }], + "designSystem": { "enabled": true }, "advisoryRules": "include"|"exclude" }, + "hook": { "consent": "accepted"|"declined", ... }, "updateCheck": true } + ``` +- `readDetectionConfig(root)`: start `{ignoreRules:[],ignoreFiles:[],ignoreValues:[],designSystem:{enabled:true}}`; for shared then local: apply legacy `raw.hook.*` section then `raw.detector.*`. Arrays are unioned (`uniqueStrings`, String-coerced); ignoreValues merged by key `rule\0value\0sortedFiles.join('\x1f')` (later wins); `designSystem.enabled` false only when literally `false`; `advisoryRules` copied only if `'include'|'exclude'`. Invalid JSON / non-object files are ignored silently. **No validation errors are ever raised by the CLI**; the only validation of ignore lists lives in `skill/scripts/lib/staleness-deep.mjs checkDetectorIgnores` (doctor): unknown `ignoreRules` ids vs live `ANTIPATTERNS` → finding `detector-ignore-rules-unknown` (severity `mention`); non-glob `ignoreFiles` entries that don't exist → `detector-ignore-files-missing`. +- `normalizeIgnoreValue(v)`: trim, strip one leading/trailing quote, `+`→space, collapse whitespace, lowercase. Rules lowercased/trimmed. +- `normalizeIgnoreValueEntries`: keeps `{rule, value, [files], [createdAt], [reason]}` in **that key order**; `file` (string) and `files` merged, trimmed, deduped. +- Glob → regex: `**` → `.*` (swallowing a following `/`), `*` → `[^/]*`, `?` → `[^/]`, `{a,b}` → `(?:a|b)`, regex specials escaped; anchored `^...$`. `matchesAnyGlob` tests the `/`-normalized path and its basename. +- `shouldIgnoreDetectionFile(filePath, root, config)`: raw path, absolute path, and root-relative path (if inside root) tested against `ignoreFiles`. +- `filterDetectionFindings`: drop when `ignoreRules` has the rule, or an `ignoreValues` entry matches: same rule; entry.value `*` (wildcard) OR extracted value equals (with color-key equality for `design-system-color`: rgb/hex/hsl parsed to `r,g,b,round(a*255)`); if entry has `files`, `finding.file` (or any `/`-suffix of it) must glob-match; a wildcard with no files never matches (unscoped `*` disallowed). +- `extractFindingIgnoreValue`: only for `overused-font, bounce-easing, design-system-font, design-system-color, design-system-radius, design-system-font-size`; source `finding.ignoreValue || finding.value`, else parse `detail`/`snippet`: bounce → `animate-bounce`, `cubic-bezier(...)`, or animation token matching `/bounce|elastic|wobble|jiggle|spring/i`; fonts → `Primary font:`, `Google Fonts:`, `font-family:` value, or `family=` URL param (decoded). + +#### `impeccable ignores` (`cli/bin/commands/ignores.mjs`) + +- Actions/aliases: `status|ls|list`→list (default when no action), `add-rule|ignore-rule`, `add-file|ignore-file`, `add-value|ignore-value|update-value`, `remove-rule|rm-rule`, `remove-file|rm-file`, `remove-value|rm-value`, `clear`. `--help`/`-h` prints usage (stdout). Unknown → throws `Unknown ignores action: ${a}. Run "impeccable ignores --help".` (exit 1 via cli.js). +- Scope flags: `--shared` (default), `--local`, `--all` (remove/clear only); more than one → error `Pass only one scope flag: --shared, --local, or --all` (or `--shared or --local`). +- `add-rule [--all-values] [--reason ...]`: `overused-font` without `--all-values` → error "overused-font is value-specific by default. Use add-value overused-font , or add-rule overused-font --all-values for broad suppression." Output: `Added ${rule} to ${local?'local':'shared'} detector ignoreRules (${relpath}).` +- `add-file ` → `Added ${glob} to ... detector ignoreFiles (...)`. +- `add-value [--file ]... [--reason ]`: value = normalized join of positionals after rule; `--file`/`--files`/`--file=`/`--files=` (empty or flag-like → error); unknown `--x` → `Unknown add-value flag: --x`; `*` value requires `--file`; existing entry (same key) updates reason/files, else pushes `{rule,value,[files],createdAt: ISO now,[reason]}`. Output `Added ${rule}=${value} to ... detector ignoreValues (...)`. +- `remove-*` → `Removed ${n} from shared (path), ${n} from local (path).` or `No matching detector ignore found.` `clear` → `Cleared detector ignores in ${'shared and local config'|'local config'|'shared config'}.` +- `list` output: + ``` + Impeccable detector ignores + shared file: .impeccable/config.json + local file: .impeccable/config.local.json + + Merged: + ignoreRules: (none) + ignoreFiles: ... + ignoreValues: rule=value [glob1, glob2] - reason, ... + designSystem: enabled|disabled + + Shared: + ... + + Local: + ... + ``` +- Tests: `tests/cli-ignores.test.js`. + +#### `cli/engine/engines/browser/detect-url.mjs` — URL scans + +- `detectUrl(url, options)`; options: `profile`, `waitUntil` (default `'networkidle0'`), `settleMs` (default 0), `viewport` (default `{width:1280,height:800}`), `browser` (external), `headless` (default true), `designSystem`, `scriptErrors` (default on), `contentHidden` (default on), `visualContrast`/`visualContrastBrowser`/`visualContrastPixel` (default on), `visualContrastMaxCandidates` (12), `visualContrastScrollOffscreen` (true). +- Puppeteer imported dynamically; missing → throw `puppeteer is required for URL scanning. Install: npm install puppeteer`. Browser script read from `/detect-antipatterns-browser.js`; missing → `Browser script not found at ${path}`. +- Launch: `launchArgs = process.env.CI ? ['--no-sandbox','--disable-setuid-sandbox'] : []`. On `win32` first `puppeteer.launch({channel:'chrome', headless, args})`, on failure fall back to bundled `puppeteer.launch({headless, args})` with `err.cause = channelError`. Non-Windows: bundled only. +- Flow: `newPage` → attach `pageerror` listener (message first line, trimmed, sliced to 160 chars, deduped) → `setViewport(viewport)` → `page.goto(url, {waitUntil, timeout: 30000})` → optional settle → `page.evaluate` sets `window.__IMPECCABLE_CONFIG__ = {...existing, autoScan:false, ...(designSystem ? {designSystem} : {})}` where designSystem serialized as `{present:true, hasFonts, allowedFonts:[...], hasColors, allowedColors:[{r,g,b}], hasRadii, allowedRadii:[px], hasPillRadius}` → `page.evaluate(browserScript)` (injects the bundle; defines `window.impeccableDetect/impeccableDetectAsync/impeccableScan/impeccableScanAsync/impeccableMeasureHiddenText/impeccableCollectVisualContrastCandidates/impeccableAnalyzeVisualContrast/impeccableGetLastVisualContrastAnalyses`) → `window.impeccableDetect({decorate:false, serialize:true})` returns groups `[{selector, tagName, rect, isPageLevel, isHidden, findings:[{type, category, severity, advisory, detail, ignoreValue, name, description}]}]`; flattened to `{id:type, snippet:detail, ignoreValue, severity}` → content-hidden sweep (`measureContentHiddenAfterReveal`: scroll in steps `max(200, floor(innerHeight*0.7))` with `behavior:'instant'`, 40ms rAF pauses, back to top, wait 700ms, then `impeccableMeasureHiddenText()`; `checkContentHiddenAtRest` fires when `totalChars>=200 && hiddenChars>=150 && share>0.3`, snippet `${pct}% of page text (${hidden} of ${total} chars) stays at opacity 0 / visibility hidden after reveal handlers ran (e.g. "sample")`) → up to 3 `script-error` findings `{id:'script-error', snippet:message}` → visual contrast fallback → `finally` close page (and browser if owned). +- Visual contrast fallback (`runVisualContrastFallback`): browser-side `impeccableAnalyzeVisualContrast({maxCandidates, scrollOffscreen})` findings not already low-contrast on that selector; then candidates (from analyses or `impeccableCollectVisualContrastCandidates`) not resolved pass/fail → per candidate `captureVisualContrastCandidate(page, candidate, viewport)`. +- **Screenshot flow** (`screenshot-contrast.mjs`): clip sanitized (`x,y` floored ≥0; `width` ≤ viewport width (default 1600), `height` ≤ 320, both ≥1); `page.screenshot({encoding:'base64', clip, captureBeyondViewport:true})` before; inject `; Astro: +
+

exactly ONE top-level element, same tag as original +
+
+
+``` +Param kinds: `range` `{id,kind:'range',min,max,step,default,label}` → CSS var `--p-`; `steps` `{id,kind:'steps',default,label,options:[{value,label}]}` → attribute `data-p-=""`; `toggle` `{id,kind:'toggle',default:boolean,label}` → `--p-: 1|0` and attribute `data-p-="on"` present only when on. Browser drives range/toggle vars through an injected stylesheet (`#impeccable-live-variant-state`), and hides non-visible variants there; values reset to declared defaults on variant switch. Budget/authoring rules (0-4 per variant, hard cap 4) are prose in live.md section 7. Optional readiness sentinel `--impeccable-variant-ready` (stripped on accept). + +`cssAuthoring` object returned by wrap/insert: +- scoped: `{mode:'scoped', styleTag:' + (only when paramValues non-empty) + +
+ …variant lines… +
+``` +- JSX: everything above wrapped in `
` … `
` with body indented 2 more, ``, `{/* … */}` comments, `style={{ display: 'contents' }}` on the variant div. +Result `{handled:true, file: rel, carbonize:boolean, todo?:'REQUIRED before next poll: carbonize cleanup in . See reference/live.md "Required after accept".'}`. Discard: replace range with deindented original → `{handled:true, file, carbonize:false}`. After accept with `--page-url`, buffered manual-edit ops whose original/new text appears as an exact text segment in the replaced original block are dropped from `pending-manual-edits.json`. + +Receipt: on any `handled!==false` result write `accept-receipts/.json` = `{id, operation:'accept'|'discard', variantId:'N'|null, result, completedAt}` (tmp+rename). Re-run with same op/variant → prior `result` + `{handled:true, alreadyApplied:true}`; different → `{handled:false, mode:'error', error:'accept_receipt_conflict', priorOperation, priorVariantId}`. + +Errors: thrown (e.g. `source_locked` from lock contention) → `{handled:false, mode:'error', error:'', file}`; preview-path `{handled:false, error}` without mode gets `mode:'error'` added. + +CSS helpers (pure, `accept-css.mjs`): `parseStylesheet` (rule/at/comment nodes; `media, supports, layer, container, scope` recurse), `serializeNodes` (`sel {body}` one-line if <60 chars single decl, else multi-line 2-space), `normalizeSelector` (collapse ws, strip ws around `>+~,`), `reconcileCss(existing, incoming)` → replace same-selector bodies (first replaces, later same-selector rules extend), new rules inserted before the first at-block, `{css, replaced, appended}`; `substituteParamVar(css,id,value)` paren-aware `var(--p-id[,fallback])`; `stripParamSelector(sel,id,kind,chosen)`: steps keep only `[data-p-id="chosen"]`/bare form; toggle keeps bare `[data-p-id]` or `="on"` only when on; `bakeParamValues(css, params, values)`: undeclared values bake as `range`; toggle var value `1|0`; drops rules whose every selector died or body empty; strips `--impeccable-variant-ready` declaration; `pruneUnusedSelectors(source, compile, {skipSelectors})`, `collectUnusedSelectors`, `collectAllSelectors`. + +`verifyAcceptedSource(text)` findings (marker→why): `impeccable-variants-start|end` (`variant wrapper comment left in source`), `impeccable-carbonize-start|end` (`carbonize block not rewritten into permanent form`), `impeccable-param-values` (`param-values comment not baked and removed`), `data-impeccable-` (`live-mode plumbing attribute left on markup`), `/\bdata-p-[A-Za-z0-9_-]+\s*(?:=|\])/` label `data-p-*` (`preview parameter attribute left on markup`), `/var\(\s*--p-[A-Za-z0-9_-]+\s*[,)]/` label `var(--p-*)` (`preview parameter variable not baked to a literal`), `--impeccable-variant-ready` (`preview readiness sentinel left in CSS`). Each finding `{marker, line (1-based), excerpt (≤120), why}`. + +Source lock: file `/locks/.lock` created `wx` with `{owner, token, pid, at, file}`; stale if unreadable and older than 60 s, or owner pid dead; retry every 5 ms until `waitMs` (accept: 1000) then throw `source_locked` (`code:'SOURCE_LOCKED'`); release only own token. + +#### 10. Manual edits (staged copy edits) + +10.1 Buffer `pending-manual-edits.json`: `{version:1, entries:[{id, pageUrl, element, ops:[op], stagedAt}]}`; op = `{ref, tag, elementId, classes[], originalText, newText, deleted?, leaf?, nearbyEditableTexts?, restore?, sourceHint?:{file,loc,line,column}, contextRef?, container?}`. `stageEntry` merges by (pageUrl, ref): existing op keeps its `originalText`, takes new `newText`/`deleted`, entry.element updated; else appended to entry with same (pageUrl,id) or a new entry. + +10.2 Routes: +- `POST /manual-edit-stash` body `{token, id, pageUrl, element, ops}` → validate as `manual_edits`; stage; 200 `{ok:true, pendingCount:, totalCount, perPage}`; 500 `{error:'stash_write_failed', message}`; activity `manual_edit_stashed {id,pageUrl,opCount,pendingCount,totalCount,hintedFileCount}`. +- `GET /manual-edit-stash?token&pageUrl` → `{count, totalCount, perPage, entries}`. +- `POST /manual-edit-commit?token&pageUrl&async=1&repair=1`: `repair=1` without transaction → 409 `{error:'manual_edit_repair_transaction_missing'}`; non-repair first rolls back an abandoned transaction; activity `manual_edit_commit_started`; async → 202 `{status:'started', pendingCount, totalCount, perPage}` immediately, else final result 200 `{…commitResult, totalCount, perPage}` or 500 `{error:'manual_edit_commit_failed', message}`. Provider selection: env `IMPECCABLE_LIVE_COPY_AGENT` (`chat`, `auto` (default), `codex`, `claude`, `mock`, `0|false|off|none`); `chat` when explicit or `auto` with an agent polling within 60 s (`chatAgentLikelyActive`); timeout `IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS` (120000). Chat route pushes `manual_edit_apply` events (10.3). Subprocess route runs `codex exec --cd --dangerously-bypass-approvals-and-sandbox --ephemeral --output-last-message -c model_reasoning_effort="" [--model X] -` or `claude --print --permission-mode bypassPermissions --output-format json [--model X]` with the prompt on stdin **[spawns external CLIs]**. +- `POST /manual-edit-repair-decision` `{token?, pageUrl?, action:'rollback'}` → rollback transaction, 200 `{action, pageUrl, rollback, remainingCount, totalCount, perPage}`; other action → 400 `{error:'unsupported_manual_edit_repair_decision', action}`. +- `POST /manual-edit-discard?token&pageUrl` → rollback txn, remove entries (page or all), cancel pending apply events (with file rollback), 200 `{discarded:, entries, canceledApplyEvents:[{id,pageUrl,entryCount,rolledBackFiles?,rollbackFailures?}], totalCount, perPage}`. + +10.3 `manual_edit_apply` event (server-minted id = 8 hex from uuid): `{type:'manual_edit_apply', id, pageUrl, batch:, evidencePath:'/.impeccable/live/manual-edit-evidence/.json', agentAction:{kind:'manual_edit_apply', required:'apply_source_edits_then_reply', replyCommand:"live-poll.mjs --reply done --data ''", warning:'Polling only leases this work item; it does not commit source edits.'}, schemaVersion:1, deadlineMs:120000 (IMPECCABLE_LIVE_APPLY_EVENT_SOFT_DEADLINE_MS), chunk?:{index,total,opCount,totalOpCount}, repair?:{attempt,maxAttempts,transactionId,reason,failures,files,pageUrl}}`. Batches over `IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE` (default 3, 1..20) ops are split into chunks. Hard timeout `IMPECCABLE_LIVE_APPLY_EVENT_HARD_TIMEOUT_MS` (150000): event acked, id tombstoned (late reply → 409 stale + rollback), files snapshot rolled back. Compacted batch: `{version, pageUrl, count, entries:[{id,pageUrl,stagedAt,element:{ref,tagName,id,classes,textContent≤240},ops:[{entryId,ref,contextRef,tag,elementId,classes,originalText,newText,deleted?,sourceHint,leaf,nearbyEditableTexts(≤4),container,contextHints(≤8)}]}], ops:[…flat with entryId], candidates?:[≤24 {entryId,ref,sourceHint,textMatches(≤8),objectKeyMatches(≤8),contextTextMatches(≤8),locatorMatches(≤6)} each match {file,line,column,reason,status}], context?:{bufferPath,totalEntries,totalOps,chunkIndex,chunkTotal,totalApplyOps}}`. + +10.4 Apply result (`--data`): `{status:'done'|'partial'|'error', appliedEntryIds:string[], failed:[{entryId, reason, candidates?}], files:string[], notes:string[], message?}`. Rejections (`reason`): `missing_result_data`, `summary_result_not_allowed` (has `entries`/`ops`), `invalid_status`, `_must_be_array`, `appliedEntryIds_must_contain_strings`, `files_must_contain_strings`, `notes_must_contain_strings`, `failed_must_contain_objects`, `failed_entryId_required`, `failed_reason_required`, `applied_entry_id_not_in_event`, `failed_entry_id_not_in_event`, `done_result_has_failed_entries`, `done_result_missing_applied_entry_ids`, `partial_result_has_no_entries`, `error_result_has_applied_entries`. + +10.5 Evidence (`live-manual-edit-evidence.mjs` `buildManualEditEvidence({cwd,pageUrl})`): `{version:1, pageUrl, count, entries, ops:[flattened+contextHints], context:{cwd, bufferPath, totalEntries, totalOps}, candidates:[{entryId, ref, originalText, sourceHint:{…,status:'ok'|'text_not_found_near_hint'|'file_missing'|'generated'|'outside_cwd', relativeFile, excerpt:[{line,text}]}|null, textMatches, objectKeyMatches, locatorMatches, contextTextMatches}]}`; empty buffer → `{pageUrl, count:0, entries:[], ops:[], candidates:[]}`. Search dirs `src, app, pages, components, public, views, templates, site, lib, data` + root files, depth ≤7, extensions `.html,.jsx,.tsx,.vue,.svelte,.astro,.js,.mjs,.ts,.ex,.heex,.eex`, skip `node_modules,.git,.impeccable,.astro,.next,.nuxt,.svelte-kit,dist,build,out,coverage` and generated files. Match `{kind:'text'|'object_key'|'id'|'class'|'tag'|'context', file, line, needle, excerpt≤240}`; limits 8 strong/4 weak text, 8 object-key, 4 locator, 8 context (2 per hint). + +10.6 Commit (`commitManualEdits`) result: `{applied:[{id,ref,originalText,newText}], failed:[{id, reason, candidates, failures?, checks?, files?}], files, cleared, count, pageUrl, notes?, warnings?, reason?, message?, rolledBackFiles?, rollbackFailures?, unreportedFiles?, repair?:{status:'repaired'|'needs_decision', attempts, maxAttempts, transactionId, failures?, files?}, needsManualDecision?, totalCount, perPage}`. Reasons: `manual_edit_buffer_invalid`, `no_pending_edits`, `conflicting_apply_result`, `unreported_source_changes`, `missing_applied_entry_ids`, `missing_touched_files`, `not_reported_applied`, `source_verification_failed`, `failed_entry_source_changed`, `rolled_back_due_to_failed_entry_source_changed`, `manual_edit_repair_needs_decision`. Post-apply checks: leftover impeccable markers, JSON parse for `.json`, `@babel/parser` syntax for `.jsx/.tsx/.ts` **[NODE-DEP optional; warning `syntax_parser_unavailable` if missing]**, `node --check` for `.js/.mjs/.cjs`, `package.json scripts["impeccable:manual-edit-validate"]` via shell. Repair attempts `IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS` (default 3, 1..10). + +--- + +### Per script + +Conventions: every script's "run directly" guard is `process.argv[1]` ending with `.mjs` (or `.mjs/`). Unless noted, output is one JSON object on stdout; agent-facing helpers add `_instructions` only in `live-poll.mjs`. Exit code 0 unless stated. + +#### `live.mjs` -> `impeccable live` (boot) +- Invoked from live.md step 1: "`node {{scripts_path}}/live.mjs`" or "`node {{scripts_path}}/live.mjs --target `" (monorepo). +- Args: `--target

` / `--target=

` / `-t

` (strict: missing value → stderr `--target requires a path value.` exit 1); `--help|-h` prints usage, exit 0. +- Env: none directly (children inherit `IMPECCABLE_LIVE_CONFIG`). +- Flow & outputs (all pretty-printed JSON, 2 spaces, exit 0 unless noted): + 1. Workspace monorepo selection (`resolveTargetSelection`, only when no target, cwd is a workspace/monorepo root with discoverable children): `{ok:false, error:'target_selection_required', targetPath:null, projectRoot, repoRoot, targetCandidates:[{name, path, targetExample, …context summary}], hint:'Ask the user which app Impeccable should use, then rerun live from that child app cwd. Use --target only as a fallback or explicit path diagnostic.'}`. + 2. `resolveRoots` selection → `{ok:false, error:'target_selection_required', targetCandidates:[{name,path}], hint:'Several apps with a dev-server config exist. Ask the user which one to use, then rerun with --target .'}`. + 3. Missing/unreadable/empty PRODUCT.md or DESIGN.md → `{ok:false, error:'context_missing', missing:['PRODUCT.md'?,'DESIGN.md'?], nextCommand:'init'|'document', targetPath, projectRoot, repoRoot, productPath:rel|null, designPath:rel|null}`. + 4. `writeRootsManifest(roots)`. + 5. `node live-inject.mjs --check` (cwd appRoot, 15 s): not ok → print that JSON (`{ok:false,error:'config_missing'|'config_invalid',path,message?}` or `{ok:false,error:'check_failed',raw}`) + `targetPath, projectRoot, repoRoot`, exit 0. + 6. Reuse server if `server.json` pid alive, else `node live-server.mjs --background`; failure → `{ok:false,error:'server_start_failed'}` exit 1. + 7. `node live-inject.mjs --port P --token T`; not ok → `{ok:false,error:'inject_failed',detail:,serverPort}` exit 1. + 8. Drift scan: `.html` files under `public, src, app, pages` (skipping ignored dirs/dot-dirs) not in resolved files and not user-excluded → `configDrift = {orphans:[≤20], orphanCount, hint:'N HTML file(s) exist but aren\'t in config.files. Consider adding them, or use a glob pattern like "public/**/*.html".'}` else `null`. + 9. Success: `{ok:true, serverPort, serverToken, pageFiles:[…resolved], liveConfigPath, configDrift, targetPath, projectRoot:appRoot, repoRoot, roots:{manifest}, hasProduct:true, product:, productPath:rel, hasDesign:true, design:, designPath:rel, hasSurfaceBrief, surfaceBrief:, surfaceBriefPath:rel|null, _instructions:'Open the app URL that serves a pageFiles entry (never serverPort; that is the helper). Then start the poll loop per your harness policy in live.md and re-run node /live-poll.mjs immediately after every event or reply. Every event carries _instructions: follow them; they are the authoritative next step with real ids and paths filled in. A poll that is running is a poll you are SERVICING: never announce you are waiting and idle your turn; stay on the exec session until it returns an event, and never end a turn while a poll is outstanding.'}`. Surface brief resolved from `.impeccable/surfaces` under appRoot, contextRoot, repoRoot (first hit). +- Tests: `tests/live-target-context.test.mjs`, `tests/live-roots.test.mjs`, `tests/live-e2e.test.mjs` (`session.liveBoot` for `appDir` fixtures), `tests/live-recovery-commands.test.mjs`. + +#### `live-server.mjs` -> `impeccable live-server` +- Invoked from live.md Cleanup (`node {{scripts_path}}/live-server.mjs stop`), by `live.mjs` (`--background`), by tests directly. +- Args: (none) foreground; `--background` (spawn detached child with same args minus flag, wait ≤10 s for a `server.json` whose pid ≠ own, print `{"pid","port","token"}` exit 0; else stderr `Timed out waiting for live server to start.` exit 1); `--port=N`; `stop [--keep-inject]` (fetch `/stop?token=` → stdout `Stopped live server on port P.` or `No running live server found.`; then unless keep-inject run `live-inject.mjs --remove`, print `Removed live script tag from .` when a result line has `removed:true`, else `Note: could not remove live script tag ()`; exit 0); `--help`. +- Env: `IMPECCABLE_LIVE_DEBUG_EVENTS`, `IMPECCABLE_LIVE_COPY_AGENT*`, `IMPECCABLE_LIVE_APPLY_EVENT_{HARD_TIMEOUT,SOFT_DEADLINE}_MS`, `IMPECCABLE_LIVE_MANUAL_EDIT_CHUNK_SIZE`, `IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS`. +- Behavior: shared model sections 3, 5, 6, 8, 10. Console lines: `[live] lease failed for : `, `[impeccable] Svelte component session cleanup failed: …`, `[impeccable] applied legacy deferred Svelte component accepts: {…}`. +- Tests: `tests/live-server.test.mjs` (integration: /health, /status, events, poll, gitignore), `tests/live-poll-stream.test.mjs`, `tests/live-e2e.test.mjs`, `tests/live-event-validation.test.mjs`, `tests/live-poll-lanes.test.mjs`, `tests/live-session-store.test.mjs`, `tests/live-generation-preflight.test.mjs`. + +#### `live-poll.mjs` -> `impeccable poll` +- Invoked from live.md poll loop; `--reply` forms quoted in `_instructions` (see instructions.mjs strings in 6.3/below). +- Args: `--stream`, `--timeout=MS` (one-shot total, default 600000), `--types=A,B`, `--ack-timeout=MS` (stream, default 600000), `--reply [--file PATH] [--data JSON] [message]`, `--help`. `--reply` errors (stderr, exit 1): `Usage: node "/live-poll.mjs" --reply [--file path] [--data ''] [message]` + `Missing event id after --reply.` / `The value after --reply must be the event id, not the status "done". Use --reply EVENT_ID done.` / `Missing reply status after event id "X".`; `--data must be valid JSON: `. +- Needs `server.json`; else stderr `No running live server found. Start one with: node "/live.mjs"` exit 1. +- One-shot: loops `GET /poll?token&timeout=&leaseMs=600000[&types]` until an event or total deadline; prints one JSON line (`console.log(JSON.stringify(event))`) with `_instructions` added by `instructionsForEvent` (unless already present). For `accept`/`discard`: spawns `node live-accept.mjs --id ID (--discard | --variant N) [--page-url U] [--param-values JSON]` (30 s), sets `event._acceptResult` (parse failure/throw → `{handled:false, mode:'error', error}`), then POSTs completion `{id, type: completionType, sourceEventType: event.type, message: _acceptResult.error, file: _acceptResult.file, data: {carbonize:true}?}` where completionType = discard: `discarded` if handled else `error`; accept: `agent_done` if handled&carbonize, `complete` if handled, `error` if mode error or (svelte-component unhandled), else `agent_done`; sets `event._completionAck = {ok:true, type}` (+ `final:false, requiresComplete:true, nextCommand:'live-complete.mjs --id ', message:'Carbonize cleanup must be verified, then the session must be completed explicitly before polling again.'` for carbonize) or `{ok:false, error}`. Stderr banners: manual_edit_apply → 4-line banner starting `Manual Apply action required: edit source, then reply with \`live-poll.mjs --reply done --data ''\`.`; carbonize → `⚠ Carbonize cleanup REQUIRED before next poll. After cleanup, run live-complete.mjs --id . See reference/live.md "Required after accept".` +- Stream: stderr `[impeccable-poll] stream mode: one JSON object per line on stdout; use --reply while this process stays running`; after each reply-needing event waits (poll `/status` every 400 ms) until the id leaves `pendingEvents` (else `Timed out waiting for --reply on event ` exit 1); returns on `exit`. +- Errors: 401 → `Authentication failed. The server token may have changed.` + `Try restarting: node "…/live-server.mjs" stop && node "…/live.mjs"` exit 1; ECONNREFUSED → `Live server not running. Start one with: …` exit 1; reply non-2xx → `Reply failed: \n\n\n\n<_instructions>` exit 1; other → `Poll failed: ` exit 1. +- `_instructions` templates (instructions.mjs; `` = abs scripts dir): `steer` → `Do what the message asks (page edits, navigation help, or a short answer). Then reply exactly once: node /live-poll.mjs --reply steer_done ["optional short toast"] (on failure: --reply error "Short reason"). No pickup ack; poll again immediately after.`; `prefetch`, `variant_mount_failed` (`The browser could NOT render variant N (module: URL): ERR… reply node /live-poll.mjs --reply done --file ; the browser retries on its own. Poll again after the reply.`), `discard` (`Original restored and durable completion acknowledged; nothing to do. Poll again.` or `Completion was not acknowledged: run node /live-complete.mjs --id --discarded, then poll again.`), `manual_edit_apply` (delegate to `impeccable_manual_edit_applier`; reply `--reply done --data '{"status":"done","appliedEntryIds":[...],"failed":[],"files":[...],"notes":[]}'`), `timeout` (`No event arrived; poll again immediately.`), `exit` (`Session over: kill any background poll, then node /live-server.mjs stop (removes the injected script tag). Sweep leftover impeccable-variants-start / impeccable-carbonize-start markers from source.`), `generate` (numbered steps: screenshot / scaffold branch (svelte-component: edit stubs `

/v1.svelte…`, params in `params.json`, reply `--file `; deferred wrapper: splice into `scaffold.wrapperBlock` and replace lines `replaceStartLine-replaceEndLine` in ONE edit; written wrapper: splice at `insertLine`; no scaffold: run `live-wrap.mjs --id … --count … --element-id "…" --classes "a,b" --tag "…" --text ""`) / action reference / `When all N variants are delivered: node /live-poll.mjs --reply done --file . Then poll again. If generation fails … reply --reply error "Short reason" …`), `accept` (carbonize 5-step text; `Accept was merged into source mechanically; nothing to clean up. Poll again.`; fallback; `source_locked` retry; `accept_receipt_conflict`; generic error; manual merge). Prefix when ack failed: `Completion was NOT acknowledged: run node /live-status.mjs, finish any cleanup, then node /live-complete.mjs --id . ` +- Tests: `tests/live-poll.test.mjs`, `tests/live-poll-stream.test.mjs`, `tests/live-completion.test.mjs`, `tests/live-recovery-commands.test.mjs`. + +#### `live-status.mjs` -> `impeccable status` +- Invoked from live.md Recovery: `node {{scripts_path}}/live-status.mjs`. Args: `--target`. Works with server down. +- Output (pretty JSON): `{liveServer:{status,port,connectedClients,agentPolling,pendingEvents}|null, activeSessions:[server list or local store list], render:[{id, renderState, mountedVariants, mountFailures}], recoveryHint}`. Hint: manual apply pending → `Manual Apply pending (page …, chunk i/n, N op(s), N entr(y|ies), likely files: …). If you have not already leased it, run live-poll.mjs. Apply the source edits from the manual_edit_apply batch, then reply with live-poll.mjs --reply done --data ''. Polling only leases this work item; it does not commit source edits. Do not run live-commit-manual-edits.mjs for this leased event. Do not poll again before replying.`; render failed → `The browser failed to mount variant N from URL (ERR); nothing is on screen. Fix the variant files, then reply with live-poll.mjs --reply done --file for the queued variant_mount_failed event (or republish) so the browser retries.`; server up → `Run live-poll.mjs to continue pending work, or live-complete.mjs --id after manual cleanup.`; else `Start live-server.mjs to requeue pending durable events, then run live-poll.mjs.` +- Tests: `tests/live-recovery-commands.test.mjs`. + +#### `live-resume.mjs` -> `impeccable resume` +- Args: `--id ID`/`--id=ID`, `--help` (`Usage: node live-resume.mjs [--id SESSION_ID]\n\nPrint the active durable session checkpoint and the next safe agent action.`). +- Output: no session → `{active:false, nextAction:'No active durable live session found.'}`; else `{active:true, snapshot, pendingEvent, render:{renderState,mountedVariants,mountFailures}, nextAction}` where nextAction priority: manual apply hint; mount failure text; pending → `Run live-poll.mjs, handle , then acknowledge with live-poll.mjs --reply done.`; phase `carbonize_required` → `Finish carbonize cleanup in , then run live-complete.mjs --id .`; `accept_requested` → `Run live-complete.mjs --id after verifying the accepted variant is written.`; else `Inspect ; no pending agent event is currently queued.` +- Read-only (never writes snapshot). Tests: `tests/live-recovery-commands.test.mjs`. + +#### `live-complete.mjs` -> `impeccable complete` +- Invoked from live.md carbonize step and `_completionAck.nextCommand`. Args: `--id ID` (required; missing → usage, exit 1), `--discarded|--discard`, `--error MSG`/`--error=MSG`, `--force`, `--help` (exit 0). +- Gate (status complete, no --force): snapshot.sourceFile inside project and not under `node_modules/` → `verifyAcceptedFile`; dirty → `{ok:false, error:'source_dirty', id, file, findings:[…], hint:'The accepted source still carries live-mode leftovers. Finish the carbonize cleanup (bake params, remove markers and data-p-* attributes), then run live-complete again. Use --force only if a finding is a false positive.'}` exit 1. +- Then: if server up, `POST /poll {token,id,type:'complete'|'discarded'|'error',message}`; ok → `{ok:true, id, phase, snapshot}`; else append `{type:'complete'|'discarded'|'agent_error', id, message?}` locally → same shape. +- Tests: `tests/live-recovery-commands.test.mjs`, e2e `runLiveComplete`. + +#### `live-accept.mjs` -> `impeccable accept` +- Invoked by `live-poll.mjs` automatically; live.md says re-run same command on `source_locked`. +- Args: `--id ID` (missing → stderr `Missing --id` exit 1; bad chars → `Invalid --id` exit 1), `--discard` | `--variant N` (`Need --discard or --variant N`; N must be 1-3 digits: `Invalid --variant`), `--param-values ''` (malformed → ignored), `--page-url URL`, `--defer-source-write` (deprecated no-op), `--help`. +- Output/side effects: section 9. All results exit 0 (even `handled:false`). +- Tests: `tests/live-accept.test.mjs`, `tests/live-accept-scrub.test.mjs`, `tests/live-accept-css.test.mjs`, `tests/live-svelte-component-accept.test.mjs`, `tests/live-source-lock.test.mjs`, e2e `params` scenario. + +#### `live-wrap.mjs` -> `impeccable wrap` +- Invoked from live.md Handle generate step 2 and preflight (`--defer-source-write`). +- Args: `--id ID` (req), `--count N` (default 3), `--element-id`, `--classes "a,b"` (comma or space separated), `--tag`, `--query`, `--file PATH`, `--text`, `--page-url URL`, `--defer-source-write`, `--target`, `--help`. Both `--flag value` and `--flag=value` forms accepted. Missing id → `Missing --id` exit 1; none of id/classes/query → `Need at least one of: --element-id, --classes, --query` exit 1. +- stderr JSON errors, exit 1: `{error:'element_not_in_source', fallback:'agent-driven', generatedMatch:rel, hint}`, `{error:'element_not_found', fallback:'agent-driven', hint}`, `{error:'file_is_generated', fallback:'agent-driven', file, hint}`, `{error:'Found file but could not locate element in . Searched for: q1, q2'}`, `{error:'element_ambiguous', fallback:'agent-driven', reason?:'rendered_text_not_in_source', file, candidates:[{startLine,endLine}], hint}`, `{error:'missing_page_url_with_pending_edits', pendingEntries:N, hint}`, `{error:'manual_edit_buffer_apply_failed', pendingOps:[{entryId,ref,originalText,reason:'ambiguous_or_unmatched_pending_edit'}], hint}`. +- With `--page-url`, buffered manual edits for that page whose text sits in the picked range are applied to the wrapper's "original" copy (source untouched). +- stdout success JSON: `{file, sourceFile?, previewMode?:'svelte-component', previewFallback?:{from:'svelte-component', reason}, sourceWritten?:false, wrapperBlock?, replaceStartLine?, replaceEndLine?, componentDir?, propContract?, componentStubMarkup?, sourceStartLine?, sourceEndLine?, startLine, endLine, insertLine, commentSyntax:{open,close}, styleMode:'scoped'|'astro-global-prefixed'|'svelte-component', styleTag:string|null, cssSelectorPrefixExamples:[], cssAuthoring:{…}, originalLineCount}`. Non-deferred, non-svelte: writes the wrapper into the file. Svelte: `file` = manifest path, `startLine=endLine=insertLine=1`. +- Tests: `tests/live-wrap.test.mjs`, `tests/live-wrap-buffer-aware.test.mjs`, `tests/live-source-search.test.mjs`, `tests/framework-fixtures.test.mjs` (wrapCases), `tests/live-svelte-ast.test.mjs`. + +#### `live-insert.mjs` -> `impeccable insert` +- Args as wrap plus `--position before|after` (required: `Missing --position (before | after)`, `Invalid --position: X`); no `--page-url`. Errors like wrap (`element_not_in_source`/`element_not_found` with `hint:'See "Handle fallback" in live.md.'`, `file_is_generated`, `element_ambiguous` without hint). +- Output: `{mode:'insert', position, file, sourceWritten?:false, wrapperBlock?, replaceStartLine?, replaceEndLine? (= replaceStartLine-1), insertLine, commentSyntax, styleMode, styleTag, cssSelectorPrefixExamples, cssAuthoring}`; svelte: `{mode:'insert', position, file:, sourceFile, previewMode:'svelte-component', componentDir, propContract:[], insertLine:1, sourceInsertLine, anchorStartLine, anchorEndLine, commentSyntax, styleMode:'svelte-component', styleTag:null, cssSelectorPrefixExamples:[], cssAuthoring}`. +- Tests: `tests/live-insert.test.mjs`, `tests/live-insert-ui.test.mjs`, `tests/live-e2e/agent-insert.test.mjs`, e2e insert fixtures. + +#### `live-inject.mjs` -> `impeccable inject` +- Invoked by `live.mjs` (`--check`, `--port --token`) and `live-server.mjs stop` (`--remove`); live-setup.md describes config. +- Args: `--check` (read-only: `{ok:false,error:'config_missing',path}` exit 0 / `{ok:false,error:'config_invalid',message,path}` / `{ok:true,config,path}`), `--remove`, `--port N` (+ optional `--token T`; without token adopts `server.json` token only when its port matches), `--help`. Missing config in insert/remove → stderr `{ok:false,error:'config_missing',path}` exit 1; `--port` missing/NaN → stderr `{ok:false,error:'missing_port'}` exit 1. +- Insert output: tag: `{ok:anyInserted, port, gitIgnore:{file,mode,changed,patterns}, results:[{file, inserted:true, cspPatched}|{file,error:'file_not_found'}|{file,error:'insertion_point_not_found',anchor}], healed?}` (exit 1 if none inserted); adapter: `{ok, port, adapter:'sveltekit'|'nuxt'|'tanstack-start', gitIgnore, results:[adapterResult], healed?}` (exitCode 1 on adapter error). Also writes ignore block at repoRoot when nested. Remove output: `{ok:true, results:[{file, removed, cspReverted}|{file,removed:false,note:'no tag present'}|{file,error:'file_not_found'}], healed?}` or adapter `{ok, adapter, results:[…], healed?}`. +- Tests: `tests/live-inject.test.mjs`, `tests/live-frameworks.test.mjs`, `tests/live-tanstack-adapter.test.mjs`, `tests/framework-fixtures.test.mjs`, `tests/live-server.test.mjs` (gitignore). + +#### `live-target.mjs` +- Library only (`resolveLiveTarget(cwd,args)` → `{originalCwd, projectRoot, targetPath, absoluteTargetPath, targetOptions}`); used by `live.mjs`. Tests: `tests/live-target-context.test.mjs`. + +#### `live-commit-manual-edits.mjs` -> `impeccable commit-manual-edits` +- Invoked by `/manual-edit-commit` (server) and manually (`node live-commit-manual-edits.mjs [--page-url=] [--provider=auto|codex|claude|mock]`). live.md/status hint: never run it for a leased chat Apply event. +- Env: `IMPECCABLE_LIVE_COPY_AGENT`, `IMPECCABLE_LIVE_COPY_AGENT_TIMEOUT_MS`, `IMPECCABLE_LIVE_COPY_AGENT_MODEL`, `IMPECCABLE_LIVE_COPY_AGENT_EFFORT`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_RESULT`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_WRITES`, `IMPECCABLE_LIVE_COPY_AGENT_MOCK_DELAY_MS`, `IMPECCABLE_LIVE_MANUAL_EDIT_REPAIR_ATTEMPTS`. +- Output: commit result JSON (10.6); on throw stderr `{error:'commit_failed', message}` exit 1. Does not call enterLiveRoot. +- Tests: `tests/live-commit-manual-edits.test.mjs`, `tests/live-copy-edit-agent.test.mjs`. + +#### `live-discard-manual-edits.mjs` -> `impeccable discard-manual-edits` +- Args: `--page-url=` (or `--page-url` bare = true → matches nothing meaningful), `--help` (`Usage: node live-discard-manual-edits.mjs [--page-url=]`). Output `{discarded:, entries:[…removed], totalCount:}`. No enterLiveRoot. Tests: `tests/live-discard-manual-edits.test.mjs`. + +#### `live-manual-edit-evidence.mjs` +- Library (`buildManualEditEvidence`); no CLI main. Tests: via commit tests. + +#### `live-copy-edit-agent.mjs` +- Library: `buildCopyEditBatchPrompt` (long rule list + `Final response contract` JSON shapes), `runCopyEditBatchAgent`, `runCopyEditPostApplyChecks`, `chooseCopyEditAgent`, `parseCopyEditAgentResult` (accepts raw JSON, `{result:""}` wrappers, or first `{…}` in text), `describeNoProviderError`, `extractRunnerErrorMessage`. **[NODE-DEP: spawns codex/claude CLIs, optional @babel/parser]**. Tests: `tests/live-copy-edit-agent.test.mjs`. + +#### Browser parts (`live-browser-session.js`, `live-browser-dom.js`, `live-browser.js`, `modern-screenshot.umd.js`) +- Served concatenated as `/live.js`. localStorage keys: `impeccable-live-session` (`{id, appRoot, state, action, count, expected, arrived, visible, sourceFile, previewFile, previewMode, pageUrl, paramValues, parameterState, insertPlaceholder, pickedAnchor, pickedAnchorViewportTop, pageHash, pageSearch, checkpointRevision}`), `impeccable-live-session-handled` (id), `impeccable-live-session-scroll`, plus prefs keys. Saved sessions with a different `appRoot` are discarded. States: IDLE, PICKING, CONFIGURING, EDITING, GENERATING, CYCLING, SAVING, CONFIRMED. `window.__IMPECCABLE_LIVE_INIT__===true` is the e2e handshake oracle. Polls `/status` periodically for the agent-polling indicator. `modern-screenshot.js` lazy-loaded for annotated captures. +- Tests: `tests/live-browser-session.test.mjs`, `tests/live-browser-dom.test.mjs`, `tests/live-browser-regression.test.mjs`, `tests/live-browser-source.test.mjs`, `tests/live-browser-script-parts.test.mjs`, `tests/live-ui-surfaces.test.mjs`, `tests/live-e2e.test.mjs`. + +#### Preflight (`live/generation-preflight.mjs`, run by the server on lease) +- Command: `node live-(wrap|insert).mjs --id --count --defer-source-write [--position P] [--element-id X] [--classes "a b"] [--tag T] [--text <≤80>] [--page-url U (replace only)] [--file ]`; per-target cache keyed on `{mode,position,elementId,classes,tag,pageUrl}` → resolved sourceFile; evicted on failure. Result `{ok:true, mode, durationMs, scaffold:}` or `{ok:false, mode, durationMs, error}` / `{ok:false, skipped:true, reason:'insufficient_locator'}`. Tests: `tests/live-generation-preflight.test.mjs`. + +#### E2E harness contract (`tests/live-e2e.test.mjs`, `tests/live-e2e/*`) +- Fake agent polls `GET /poll?token&timeout=5000` (no lease override → 30 s lease), replies via `POST /poll` with `{token,type:'done',sourceEventType:'generate',id,file}`, `steer_done {message,file}`, `error`, accept/discard completions with `data:{carbonize:true,_acceptResult}`/`{_acceptResult}`, manual apply via `live-poll.mjs --reply done --data `. Variant format: 3 variants (font-weights 300/900/600 for render proof), params `lightness` (range), `face` (steps), `italic` (toggle). Scenarios: core, manual, annotations, exit, missed-done, params, mount-failure, republish, storage-loss (fixtures README). Fixture `runtime` block schema is authoritative for what a reimplementation must satisfy end-to-end. diff --git a/tests/oracle/lib.mjs b/tests/oracle/lib.mjs index 9595ef13f..2c003e03f 100644 --- a/tests/oracle/lib.mjs +++ b/tests/oracle/lib.mjs @@ -95,6 +95,13 @@ export function normalize(text, { ws, home = os.homedir() }) { out = out.replace(/node '[^']*\/hook-admin\.mjs'/g, ''); out = out.replace(/node "[^"]*\/hook-admin\.mjs"/g, ''); out = out.replace(/'[^']*\/impeccable(?:\.exe)?' hooks/g, ' hooks'); + // Self-referential command lines: the JS prints "node /.mjs", the + // binary prints " ". Both collapse to " ". + out = out.replace(/node ['"]?\/skill\/scripts\/([a-z-]+)\.mjs['"]?/g, (m, v) => ` ${v === 'context-signals' ? 'signals' : v === 'hook-admin' ? 'hooks' : v}`); + if (process.env.IMPECCABLE_BIN) { + const bin = process.env.IMPECCABLE_BIN; + for (const form of [`'${bin}'`, `"${bin}"`, bin]) out = out.split(form).join(''); + } // ISO timestamps and epoch millis are run-dependent. out = out.replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z/g, ''); out = out.replace(/"(updatedAt|createdAt|checkedAt|lastCheck|lastChecked|timestamp|ts|mtimeMs|mtime|startedAt|endedAt)":\s*\d{10,}/g, '"$1": ');