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 `<!-- impeccable-disable -->` from finalized.html. Targets are
  classified as project / dom-dump (designs/<audit>/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 <noreply@anthropic.com>
This commit is contained in:
Garry Tan
2026-09-08 18:33:22 +00:00
co-authored by Claude Fable 5.1
parent 982a738663
commit 8d709c8f29
3 changed files with 158 additions and 39 deletions
+61 -28
View File
@@ -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/<slug>/designs/ (where design-
* review keeps rendered-DOM dumps); symlinks are never followed out of those roots
* review keeps rendered-DOM dumps: `designs/<audit>/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 <gstack home>/projects/<slug>/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 <gstack home>/projects/<slug>/designs/<audit>/dom/** (a page's own bytes)
* artifact any other file under <gstack home>/projects/<slug>/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<string, number> = {};
const byRule: Record<string, number> = 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);
+12 -3
View File
@@ -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}=$?"`;
+85 -8
View File
@@ -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'), '<!-- impeccable-disable --><html></html>');
test.skipIf(!POSIX)('page dumps under designs/<audit>/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'), '<!-- impeccable-disable --><html></html>');
const artifact = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'hero-20260908', 'finalized.html');
fs.mkdirSync(path.dirname(artifact), { recursive: true });
fs.writeFileSync(artifact, '<!-- impeccable-disable ai-color-palette: user agreed --><html></html>');
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'), '<html></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 });
}
});
});