From 8d709c8f295b87e22236f3a301cec93a63036518 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 18:33:22 +0000 Subject: [PATCH] fix(design-detect): project means below HOME; only page dumps drop inline ignores; a whole-scan budget; prototype-safe rule counts Third review cycle + Red Team, all reproduced before the fix: - With no repository, the wrapper adopted cwd as the repo root, so a review launched from HOME (URL mode can run from anywhere) rejected every HOME-rooted install as "repository-local", reported the user's own skill install with the wrong hint, and, for targets, accepted all of HOME (~/.ssh/id_rsa scanned). A project directory is now one strictly below HOME: `git init ~` never turns the user's installs into repository files, and from HOME only the designs allow-list qualifies as a target. - --no-inline-ignores keyed on "not inside the repo", which misclassified dumps when GSTACK_HOME sits under the repo and stripped the design-html gate's own `` from finalized.html. Targets are classified as project / dom-dump (designs//dom/**, the page's bytes) / artifact (other designs/ files, gstack-authored); only dumps drop inline ignores. - A repository's .impeccable/config.json can hide rules from the review; detector.ignoreValues was never surfaced. The probe prints IMPECCABLE_IGNORED_VALUES beside the rules, and the prose stops calling repo-config ignores "a decision the user made". - An engine id named `constructor` corrupted byRule through Object.prototype and `__proto__` counts vanished; byRule is a null- prototype object and an id that fails the shape check is `unmapped` as a key too. - Batches ran with no total budget (10,000 un-ignored files: hours). The scan stops at 5x the per-batch timeout with DETECT_TIMEOUT and exit 1. - The scan JSON carries an `untrusted` list of the engine- and page-derived fields, so the agent reading past the fenced DETECT_TOP block is told what is evidence. - The PATH walk keeps launcher-present for a .cmd wrapper or a differently named real file (the name gate applies to READY only). Tests: probe and scan from a fake HOME (cache READY, HOME file refused, dump scanned without inline ignores), artifact vs dump batches, prototype-member ids, the whole-scan budget over 11 batches, ignoreValues surfaced, the `untrusted` field. Co-Authored-By: Claude Fable 5.1 --- bin/gstack-design-detect.ts | 89 +++++++++++++++++++---------- lib/design-detect-contract.ts | 15 ++++- test/gstack-design-detect.test.ts | 93 ++++++++++++++++++++++++++++--- 3 files changed, 158 insertions(+), 39 deletions(-) diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts index df475f407..d2c6052aa 100755 --- a/bin/gstack-design-detect.ts +++ b/bin/gstack-design-detect.ts @@ -36,8 +36,10 @@ * cache, and PATH entries outside the repo qualify, all by realpath of the FILE, * and every engine is named impeccable[.exe]: an env override pointing at an * interpreter (/bin/sh, node) would otherwise run the repository's own `detect` - * file from cwd. "cwd" means a project directory: when cwd is HOME or above it, - * only the repository rule applies (a URL-mode review can run from anywhere). + * file from cwd. "Repository" and "cwd" count only when they are project + * directories, strictly below HOME: from HOME itself (a URL-mode review can run + * from anywhere) only the designs allow-list qualifies as a target and every + * HOME-rooted install stays trusted. * * Sentinel contract: lib/design-detect-contract.ts (one owner, imported here and * by the gen-time resolvers). Scan output: stdout is one JSON document @@ -48,7 +50,10 @@ * Scan hardening: every target, explicit or derived from `--changed`, must be an * existing regular file or directory whose realpath lies under the repo root (or * cwd) or under ${GSTACK_HOME:-~/.gstack}/projects//designs/ (where design- - * review keeps rendered-DOM dumps); symlinks are never followed out of those roots + * review keeps rendered-DOM dumps: `designs//dom/**` are page dumps and + * scan with --no-inline-ignores, because an `impeccable-disable` comment there is + * page-controlled; other designs/ files are gstack-authored artifacts and keep + * them); symlinks are never followed out of those roots * and are skipped when git names them (a directory target is handed to the engine * as-is: its own walk decides what inside it is read); URLs are refused (the one engine path that * talks to the network); the engine runs with a minimal environment (PATH, HOME, @@ -77,7 +82,7 @@ import { spawnSync } from 'child_process'; import { SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS, UNTRUSTED_BEGIN, UNTRUSTED_END, neutralizeSentinels, - type NormalizedFinding, type ScanResult, + type NormalizedFinding, type ScanResult, SCAN_UNTRUSTED_PATHS, } from '../lib/design-detect-contract'; import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog'; import { isFrontendPath } from '../lib/frontend-scope'; @@ -153,6 +158,7 @@ interface Probe { hookOther: string[]; ignoredRules: string[]; ignoredFiles: string[]; + ignoredValues: string[]; notes: string[]; // extra sentinel lines (CONFIG_UNREADABLE, ENV_IGNORED, ENGINE_UNTESTED, HINT) steps: string[]; // --verbose trail repoRoot: string; @@ -200,14 +206,18 @@ function isEngineName(realFile: string): boolean { } /** - * Under the project the agent is reviewing: inside the repository, or inside cwd - * when cwd is itself a project directory. HOME and its ancestors are not projects - * (a URL-mode review can run from HOME, where every HOME-rooted install lives). + * A project directory is one strictly below HOME. HOME itself and its ancestors + * are never projects: a URL-mode review can run from HOME, where every + * HOME-rooted install lives, and `git init ~` (a dotfiles repo) must not turn + * the user's own installs into "repository-controlled" files. */ +function isProjectDir(dir: string): boolean { + return !isInside(HOME, dir); +} + +/** Under the project the agent is reviewing: the repository, or cwd, when each is a project directory. */ function underProject(real: string, repoRoot: string, cwd: string): boolean { - if (isInside(real, repoRoot)) return true; - if (isInside(HOME, cwd)) return false; - return isInside(real, cwd); + return (isProjectDir(repoRoot) && isInside(real, repoRoot)) || (isProjectDir(cwd) && isInside(real, cwd)); } function semverKey(v: string): number[] | null { @@ -266,7 +276,7 @@ function probe(host: string, verbose = false): Probe { const repoRoot = gitTopLevel(cwd) ?? cwd; const p: Probe = { sentinel: SENTINEL.NOT_AVAILABLE, skillPresent: false, hook: 'absent', hookOther: [], - ignoredRules: [], ignoredFiles: [], notes: [], steps: [], repoRoot, cwd, + ignoredRules: [], ignoredFiles: [], ignoredValues: [], notes: [], steps: [], repoRoot, cwd, }; const step = (s: string) => { if (verbose) p.steps.push(s); }; @@ -316,17 +326,20 @@ function probe(host: string, verbose = false): Probe { if (!r.missing) p.notes.push(`${SENTINEL.CONFIG_UNREADABLE}: ${file}`); continue; } - const v = r.value as { hook?: { enabled?: unknown }; detector?: { ignoreRules?: unknown; ignoreFiles?: unknown } }; + const v = r.value as { hook?: { enabled?: unknown }; detector?: { ignoreRules?: unknown; ignoreFiles?: unknown; ignoreValues?: unknown } }; if (v && typeof v === 'object') { if (v.hook && typeof v.hook === 'object' && 'enabled' in v.hook) hookEnabled = v.hook.enabled !== false; const rules = Array.isArray(v.detector?.ignoreRules) ? v.detector!.ignoreRules : []; const files = Array.isArray(v.detector?.ignoreFiles) ? v.detector!.ignoreFiles : []; + const values = Array.isArray(v.detector?.ignoreValues) ? v.detector!.ignoreValues : []; for (const x of rules) if (typeof x === 'string') p.ignoredRules.push(sanitizeId(x) ?? 'unmapped'); for (const x of files) if (typeof x === 'string') p.ignoredFiles.push(clip(stripControl(x), DETECT_LIMITS.field.file)); + for (const x of values) if (typeof x === 'string') p.ignoredValues.push(clip(stripControl(x), DETECT_LIMITS.field.value)); } } p.ignoredRules = [...new Set(p.ignoredRules)]; p.ignoredFiles = [...new Set(p.ignoredFiles)]; + p.ignoredValues = [...new Set(p.ignoredValues)]; let unknown = false; for (const [h, manifests] of Object.entries(HOSTS_WITH_HOOKS)) { for (const rel of manifests) { @@ -370,9 +383,9 @@ function probe(host: string, verbose = false): Probe { const cand = path.join(real, `impeccable${ext}`); if (!fs.existsSync(cand)) continue; const realCand = realpathOrNull(cand); - if (!realCand || underProject(realCand, repoRoot, cwd) || !isEngineName(realCand)) { step(`PATH ${cand} resolves to ${realCand ?? 'nothing'}: not an engine`); continue; } - if (isEngineBinary(realCand)) { p.engine = realCand; p.sentinel = `${SENTINEL.READY}: ${realCand}`; break; } - launcherOnPath ??= cand; // node shim or .cmd wrapper: launcher present, engine not proven + if (!realCand || underProject(realCand, repoRoot, cwd)) { step(`PATH ${cand} resolves to ${realCand ?? 'nothing'}: inside the project, never run`); continue; } + if (isEngineName(realCand) && isEngineBinary(realCand)) { p.engine = realCand; p.sentinel = `${SENTINEL.READY}: ${realCand}`; break; } + launcherOnPath ??= cand; // node shim, .cmd wrapper, or a differently named real file: launcher present, engine not proven } if (p.engine) break; } @@ -461,6 +474,7 @@ function probeLines(p: Probe): string[] { if (p.hookOther.length) lines.push(`${SENTINEL.HOOK_OTHER}: ${p.hookOther.join(',')}`); lines.push(`${SENTINEL.IGNORED_RULES}: ${p.ignoredRules.join(',')}`); lines.push(`${SENTINEL.IGNORED_FILES}: ${p.ignoredFiles.join(',')}`); + lines.push(`${SENTINEL.IGNORED_VALUES}: ${p.ignoredValues.join(',')}`); lines.push(...p.notes); if (p.steps.length) lines.push(...p.steps.map(s => `${SENTINEL.PROBE_STEP}: ${s}`)); return lines; @@ -496,13 +510,25 @@ function designsRoot(): string { return path.join(gstackHome(), 'projects'); } -/** realpath under the repo root / cwd, or under /projects//designs/. */ -function allowedTarget(real: string, p: Probe): boolean { - if (isInside(real, p.repoRoot) || isInside(real, p.cwd)) return true; +type TargetClass = 'project' | 'artifact' | 'dom-dump'; + +/** + * Where a target lives, or null when it is outside every allowed root: + * project under the repository or cwd (project directories only, see isProjectDir) + * dom-dump /projects//designs//dom/** (a page's own bytes) + * artifact any other file under /projects//designs/ (gstack-authored: finalized.html, previews) + */ +function targetClass(real: string, p: Probe): TargetClass | null { + if (underProject(real, p.repoRoot, p.cwd)) return 'project'; const projects = realpathOrNull(designsRoot()); - if (!projects || !isInside(real, projects)) return false; + if (!projects || !isInside(real, projects)) return null; const rel = path.relative(projects, real).split(path.sep); - return rel.length >= 3 && rel[1] === 'designs'; + if (rel.length < 3 || rel[1] !== 'designs') return null; + return rel[3] === 'dom' ? 'dom-dump' : 'artifact'; +} + +function allowedTarget(real: string, p: Probe): boolean { + return targetClass(real, p) !== null; } function resolveTargets(args: ScanArgs, p: Probe): { targets: string[]; refusedBase: boolean } { @@ -597,7 +623,7 @@ function normalize(raw: unknown): NormalizedFinding { const rawKind = str(f.category); const base: NormalizedFinding = { id: entry?.id ?? id ?? 'unmapped', - impeccableId: id ?? (clip(stripControl(str(idRaw)), lim.id) || 'unmapped'), + impeccableId: id ?? 'unmapped', // an id that fails the shape check is never used as a key or printed file: clip(stripControl(str(f.file ?? f.path)), lim.file), line: Number.isFinite(Number(f.line)) ? Number(f.line) : 0, snippet: clip(stripControl(str(f.snippet)), lim.snippet), @@ -638,15 +664,21 @@ function scan(args: ScanArgs): number { let diagnosticsTotal = 0; let exit = 0; const started = Date.now(); - // Repo files honor the project's own inline `impeccable-disable` comments. DOM dumps - // under the designs root are the audited page's bytes: an inline ignore there is - // page-controlled, never a decision the user made, so those batches disable them. - const inProject = (t: string) => isInside(t, p.repoRoot) || isInside(t, p.cwd); + // Repository files and gstack-authored artifacts honor inline `impeccable-disable` + // comments (the user's or the agent's). DOM dumps are the audited page's bytes: + // an inline ignore there is page-controlled, so those batches disable them. + const isDump = (t: string) => targetClass(t, p) === 'dom-dump'; const batches: Array<{ files: string[]; extra: string[] }> = []; - for (const [files, extra] of [[targets.filter(inProject), []], [targets.filter(t => !inProject(t)), ['--no-inline-ignores']]] as Array<[string[], string[]]>) { + for (const [files, extra] of [[targets.filter(t => !isDump(t)), []], [targets.filter(isDump), ['--no-inline-ignores']]] as Array<[string[], string[]]>) { for (let i = 0; i < files.length; i += DETECT_LIMITS.batch) batches.push({ files: files.slice(i, i + DETECT_LIMITS.batch), extra }); } - for (const { files: batch, extra } of batches) { + const totalBudgetMs = timeoutMs * DETECT_LIMITS.totalTimeoutFactor; + for (const [k, { files: batch, extra }] of batches.entries()) { + if (Date.now() - started > totalBudgetMs) { + process.stderr.write(`${SENTINEL.DETECT_TIMEOUT}: whole-scan budget ${totalBudgetMs}ms exceeded, ${batches.length - k} of ${batches.length} batches not run\n`); + exit = 1; + break; + } const run = runEngine(p.engine, batch, p.repoRoot, timeoutMs, extra); for (const line of run.stderr.split('\n')) { if (!line.trim()) continue; @@ -676,7 +708,7 @@ function scan(args: ScanArgs): number { const truncated = rawFindings.length > DETECT_LIMITS.findings; const findings = (truncated ? rawFindings.slice(0, DETECT_LIMITS.findings) : rawFindings).map(normalize); const total = rawFindings.length; - const byRule: Record = {}; + const byRule: Record = Object.create(null); // engine ids are untrusted keys: no prototype members to collide with let advisory = 0, high = 0, medium = 0, polish = 0, slop = 0, quality = 0; for (const f of findings) { byRule[f.impeccableId] = (byRule[f.impeccableId] ?? 0) + 1; @@ -688,6 +720,7 @@ function scan(args: ScanArgs): number { schemaVersion: 1, engine: p.engine, engineVersion: p.engineVersion ?? 'unknown', targets: targets.length, exit, total, counted: findings.length - advisory, advisory, ignoredRules: p.ignoredRules, byRule, findings, truncated, diagnostics: diagnosticsTotal > diagnostics.length ? [...diagnostics, `… ${diagnosticsTotal - diagnostics.length} more engine stderr lines not kept`] : diagnostics, + untrusted: SCAN_UNTRUSTED_PATHS, }; process.stdout.write(JSON.stringify(result, null, 2) + '\n'); writeTop(findings, total, truncated); diff --git a/lib/design-detect-contract.ts b/lib/design-detect-contract.ts index 25e093d4b..d4b93c522 100644 --- a/lib/design-detect-contract.ts +++ b/lib/design-detect-contract.ts @@ -8,7 +8,8 @@ // asserts that every sentinel-shaped token in generated docs exists here. // // probe ──► one of: IMPECCABLE_READY | IMPECCABLE_NOT_CACHED | IMPECCABLE_NOT_AVAILABLE | IMPECCABLE_DISABLED -// ──► always: IMPECCABLE_SKILL, IMPECCABLE_HOOK, IMPECCABLE_IGNORED_RULES, IMPECCABLE_IGNORED_FILES +// ──► always: IMPECCABLE_SKILL, IMPECCABLE_HOOK, IMPECCABLE_IGNORED_RULES, IMPECCABLE_IGNORED_FILES, +// IMPECCABLE_IGNORED_VALUES // ──► maybe: IMPECCABLE_HOOK_OTHER, IMPECCABLE_CONFIG_UNREADABLE, IMPECCABLE_ENV_IGNORED, // IMPECCABLE_ENGINE_UNTESTED, DESIGN_DETECTOR_HINT // scan ──► stdout: one JSON document (--format gstack) or engine bytes (--format raw) @@ -26,6 +27,7 @@ export const SENTINEL = { HOOK_OTHER: 'IMPECCABLE_HOOK_OTHER', IGNORED_RULES: 'IMPECCABLE_IGNORED_RULES', IGNORED_FILES: 'IMPECCABLE_IGNORED_FILES', + IGNORED_VALUES: 'IMPECCABLE_IGNORED_VALUES', CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE', ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED', ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED', @@ -55,6 +57,7 @@ export const SENTINEL = { DESIGN_MD_REASON: 'DESIGN_MD_REASON', DESIGN_MD_WRITTEN: 'DESIGN_MD_WRITTEN', DESIGN_MD_BACKUP: 'DESIGN_MD_BACKUP', + DESIGN_MD_EDIT_REFUSED: 'DESIGN_MD_EDIT_REFUSED', /** printed by the wrapper: --verbose probe trail, forwarded engine stderr */ PROBE_STEP: 'PROBE_STEP', ENGINE_STDERR: 'ENGINE_STDERR', @@ -68,10 +71,10 @@ export const SENTINEL = { * reads. */ export const SELF_DESCRIBING_SENTINELS: readonly string[] = [ - SENTINEL.HOOK_OTHER, SENTINEL.IGNORED_FILES, SENTINEL.CONFIG_UNREADABLE, SENTINEL.ENV_IGNORED, + SENTINEL.HOOK_OTHER, SENTINEL.IGNORED_FILES, SENTINEL.IGNORED_VALUES, SENTINEL.CONFIG_UNREADABLE, SENTINEL.ENV_IGNORED, SENTINEL.ENGINE_UNTESTED, SENTINEL.DETECT_EXIT, SENTINEL.DETECT_REFUSED, SENTINEL.DETECT_NO_TARGETS, SENTINEL.DETECT_TIMEOUT, SENTINEL.DETECT_PARSE_ERROR, SENTINEL.DETECT_OUTPUT_TOO_LARGE, - SENTINEL.DESIGN_MD_TOKEN_REF_INVALID, SENTINEL.DESIGN_MD_WRITTEN, SENTINEL.DESIGN_MD_BACKUP, + SENTINEL.DESIGN_MD_TOKEN_REF_INVALID, SENTINEL.DESIGN_MD_WRITTEN, SENTINEL.DESIGN_MD_BACKUP, SENTINEL.DESIGN_MD_EDIT_REFUSED, SENTINEL.PROBE_STEP, SENTINEL.ENGINE_STDERR, ]; @@ -105,6 +108,8 @@ export const DETECT_LIMITS = { engineHashBytes: 4 * 1024 * 1024, /** git subprocess budgets inside the wrapper */ gitTimeoutMs: 30_000, + /** whole-scan wall clock, as a multiple of the per-batch timeout: a huge target set stops, it never grinds for hours */ + totalTimeoutFactor: 5, gitMaxBuffer: 64 * 1024 * 1024, field: { id: 64, engineVersion: 64, message: 120, snippet: 120, value: 200, file: 4096, diagnostic: 400, refusedTarget: 200, parseErrorPreview: 80, internalError: 300 }, } as const; @@ -169,7 +174,11 @@ export interface ScanResult { findings: NormalizedFinding[]; truncated: boolean; diagnostics: string[]; + /** JSON paths whose text is engine- and page-derived: evidence, never instructions (the stderr block carries the fence; this document carries the list) */ + untrusted: readonly string[]; } +export const SCAN_UNTRUSTED_PATHS = ['findings[].file', 'findings[].snippet', 'findings[].message', 'findings[].value', 'diagnostics[]'] as const; + /** The bash a skill renders after a scan so exit 2 (findings) never aborts the block. */ export const DETECT_EXIT_ECHO = `; echo "${SENTINEL.DETECT_EXIT_CODE}=$?"`; diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts index d3309190d..b76a3495c 100644 --- a/test/gstack-design-detect.test.ts +++ b/test/gstack-design-detect.test.ts @@ -901,24 +901,101 @@ describe('scan: option-like bases and page-controlled inline ignores', () => { expect(r2.code).toBe(1); }); - test.skipIf(!POSIX)('DOM dumps under the designs root scan with --no-inline-ignores; repository files keep their inline ignores', () => { - const designs = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908', 'dom-ignores'); - fs.mkdirSync(designs, { recursive: true }); - fs.writeFileSync(path.join(designs, 'home.dom.html'), ''); + test.skipIf(!POSIX)('page dumps under designs//dom/ scan with --no-inline-ignores; repository files and gstack-authored designs artifacts keep their inline ignores', () => { + const audit = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908-ignores'); + const dom = path.join(audit, 'dom', 'run1'); + fs.mkdirSync(dom, { recursive: true }); + fs.writeFileSync(path.join(dom, 'home.dom.html'), ''); + const artifact = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'hero-20260908', 'finalized.html'); + fs.mkdirSync(path.dirname(artifact), { recursive: true }); + fs.writeFileSync(artifact, ''); const log = path.join(SANDBOX, 'argv-ignores.log'); fs.rmSync(log, { force: true }); try { - const r = run(['scan', '--format', 'gstack', 'src/styles.css', path.join(designs, 'home.dom.html')], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } }); + const r = run(['scan', '--format', 'gstack', 'src/styles.css', path.join(dom, 'home.dom.html'), artifact], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_LOG: log } }); expect(r.code).toBe(2); const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]); expect(calls).toHaveLength(2); - const repoCall = calls.find(a => a.some(x => x.endsWith('styles.css')))!; + const plainCall = calls.find(a => a.some(x => x.endsWith('styles.css')))!; const domCall = calls.find(a => a.some(x => x.endsWith('home.dom.html')))!; - expect(repoCall).not.toContain('--no-inline-ignores'); + expect(plainCall).not.toContain('--no-inline-ignores'); + expect(plainCall.some(x => x.endsWith('finalized.html'))).toBe(true); // the design-html gate's inline disable keeps working expect(domCall).toContain('--no-inline-ignores'); expect(domCall.indexOf('--no-inline-ignores')).toBeLessThan(domCall.findIndex(x => x.endsWith('home.dom.html'))); + const doc = JSON.parse(r.out); + expect(doc.untrusted).toEqual(['findings[].file', 'findings[].snippet', 'findings[].message', 'findings[].value', 'diagnostics[]']); } finally { - fs.rmSync(designs, { recursive: true, force: true }); + fs.rmSync(audit, { recursive: true, force: true }); + fs.rmSync(path.dirname(artifact), { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('from HOME (no repository) HOME-rooted installs are READY, HOME files are refused as targets, and a dump still scans without inline ignores', () => { + const home = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-fake-home-')); + const cache = path.join(home, '.impeccable', 'bin', '0.1.3'); + fs.mkdirSync(cache, { recursive: true }); + fs.copyFileSync(FAKE, path.join(cache, 'impeccable')); + fs.chmodSync(path.join(cache, 'impeccable'), 0o755); + fs.writeFileSync(path.join(home, 'secret.css'), 'a{}'); + const dom = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908-home', 'dom', 'run1'); + fs.mkdirSync(dom, { recursive: true }); + fs.writeFileSync(path.join(dom, 'home.dom.html'), ''); + const log = path.join(SANDBOX, 'argv-home.log'); + fs.rmSync(log, { force: true }); + try { + const probe = run(['probe'], { cwd: home, env: { HOME: home, IMPECCABLE_HOME: path.join(home, '.impeccable'), IMPECCABLE_BIN: '' } }); + expect(lines(probe.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(path.join(cache, 'impeccable'))}`); + const r = run(['scan', '--format', 'gstack', path.join(home, 'secret.css'), path.join(dom, 'home.dom.html')], { cwd: home, env: { HOME: home, IMPECCABLE_HOME: path.join(home, '.impeccable'), IMPECCABLE_BIN: '', IMPECCABLE_FAKE_LOG: log } }); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${path.join(home, 'secret.css')}`); + const calls = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l).argv as string[]); + expect(calls).toHaveLength(1); + expect(calls[0]).toContain('--no-inline-ignores'); + expect(calls[0].some(x => x.endsWith('secret.css'))).toBe(false); + } finally { + fs.rmSync(home, { recursive: true, force: true }); + fs.rmSync(path.dirname(path.dirname(dom)), { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('engine ids that are prototype members or fail the shape check count as unmapped, never as object keys', () => { + const out = path.join(SANDBOX, 'proto-ids.json'); + fs.writeFileSync(out, JSON.stringify([ + { antipattern: 'constructor', file: 'a.css', line: 1 }, { antipattern: '__proto__', file: 'a.css', line: 2 }, + { antipattern: '__proto__', file: 'a.css', line: 3 }, { antipattern: 'low-contrast', file: 'a.css', line: 4 }, { antipattern: 'Bad Id!!', file: 'a.css', line: 5 }, + ])); + const r = run(['scan', '--format', 'gstack', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_OUTPUT: out } }); + const doc = JSON.parse(r.out); + expect(doc.total).toBe(5); + // `__proto__` and `Bad Id!!` fail the id shape and count as unmapped; `constructor` passes it and must be an own key, never Object.prototype's. + expect(Object.entries(doc.byRule).sort()).toEqual([['constructor', 1], ['low-contrast', 1], ['unmapped', 3]]); + expect(doc.findings.map((f: { impeccableId: string }) => f.impeccableId)).toEqual(['constructor', 'unmapped', 'unmapped', 'low-contrast', 'unmapped']); + }); + + test.skipIf(!POSIX)('the whole-scan budget stops a huge target set instead of grinding batch after batch', () => { + const many = path.join(REPO, 'src', 'many'); + fs.mkdirSync(many, { recursive: true }); + for (let i = 0; i < 1100; i++) fs.writeFileSync(path.join(many, `f${i}.css`), 'a{}'); + try { + const r = run(['scan', '--format', 'gstack', 'src/many'], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '250', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '400' } }); + expect(r.code).toBe(2); // a directory target is one batch (the fake reports findings): the budget test needs files + const files = fs.readdirSync(many).map(f => path.join('src', 'many', f)); + const r2 = run(['scan', '--format', 'gstack', ...files], { env: { IMPECCABLE_BIN: FAKE, IMPECCABLE_FAKE_SLEEP_MS: '250', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '400' } }); + expect(r2.err).toMatch(/DETECT_TIMEOUT: whole-scan budget 2000ms exceeded, \d+ of 11 batches not run/); + expect(r2.code).toBe(1); + } finally { + fs.rmSync(many, { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('detector.ignoreValues from the project config are surfaced on their own line', () => { + const dir = path.join(REPO, '.impeccable'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ detector: { ignoreValues: ['#8b5cf6', 'Inter'] } })); + try { + const r = run(['probe'], { env: { IMPECCABLE_BIN: FAKE } }); + expect(r.out).toContain(`${SENTINEL.IGNORED_VALUES}: #8b5cf6,Inter`); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); } }); });