From 3867dae35589d5a397592be524a085fd3336df12 Mon Sep 17 00:00:00 2001 From: Garry Tan Date: Tue, 8 Sep 2026 16:01:18 +0000 Subject: [PATCH] feat(bin): gstack-design-detect wrapper + design_detector config key MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit bin/gstack-design-detect.ts finds and runs an impeccable engine the user installed; it never installs, downloads, or executes anything that could download. `probe` reads only: config (design_detector off → DISABLED), IMPECCABLE_BIN (absolute, realpath outside the repo and cwd), a PATH walk (absolute entries outside the repo; a #! shim counts as launcher-present, never READY), the ~/.impeccable/bin// cache, and the engine installed beside a skill launcher (scripts/bin/-/impeccable, the layout a real install produced). It reports IMPECCABLE_SKILL, host-aware IMPECCABLE_HOOK (+ HOOK_OTHER), the ignore lists from .impeccable/config*.json, IMPECCABLE_ENGINE_UNTESTED for versions outside the fixture set, and a hint only when a launcher exists without its engine. `scan` re-probes, refuses URLs and anything outside the repo root or the design-report allow-list (realpath, so symlinks cannot escape), derives `--changed ` targets NUL-safely through git and lib/frontend-scope.ts, batches 100 absolute paths per engine call with stdin ignored, a SIGKILL timeout, a 50 MB stdout cap, and sanitized length-capped fields, then prints one normalized JSON document (--format gstack) or the engine's bytes (--format raw); DETECT_TOP (fenced as untrusted content), DETECT_SUMMARY, and DETECT_EXIT go to stderr; exit code passes through with 1 over 2 over 0; exit 3 is a gstack bug. `rules` prints the mapped set. Every run appends a content-free line to the local analytics file. lib/design-detect-contract.ts owns every sentinel string, the limits, and the normalized-finding shape (pure module); test/design-detect-contract.test.ts asserts every sentinel-shaped token the agent can read exists there. lib/frontend-scope.ts mirrors gstack-diff-scope's frontend arm, pinned by a parity test that runs the bash script. bin/gstack-config gains design_detector (auto | off, default auto, invalid values rejected with the file unchanged). test/fixtures/fake-impeccable.ts is the env-driven engine stand-in; test/gstack-design-detect.test.ts covers READY/NOT_CACHED/ NOT_AVAILABLE/DISABLED, env trust (.env never loaded, in-repo IMPECCABLE_BIN ignored), newest-semver cache, hook and ignore detection, refusals, exit passthrough, raw byte-identity, normalization, the display cap, timeout, parse errors, diagnostics, --changed, and analytics. The egress scanner test records the wrapper as a documented non-sink. Co-Authored-By: Claude Fable 5.1 --- bin/gstack-config | 18 +- bin/gstack-design-detect.ts | 688 ++++++++++++++++++++++++++++ lib/design-detect-contract.ts | 116 +++++ lib/frontend-scope.ts | 31 ++ test/design-detect-contract.test.ts | 86 ++++ test/egress-receipt-wiring.test.ts | 8 + test/fixtures/fake-impeccable.ts | 44 ++ test/frontend-scope.test.ts | 86 ++++ test/gstack-config-defaults.test.ts | 26 ++ test/gstack-design-detect.test.ts | 471 +++++++++++++++++++ 10 files changed, 1572 insertions(+), 2 deletions(-) create mode 100755 bin/gstack-design-detect.ts create mode 100644 lib/design-detect-contract.ts create mode 100644 lib/frontend-scope.ts create mode 100644 test/design-detect-contract.test.ts create mode 100755 test/fixtures/fake-impeccable.ts create mode 100644 test/frontend-scope.test.ts create mode 100644 test/gstack-design-detect.test.ts diff --git a/bin/gstack-config b/bin/gstack-config index d8e005753..f85cffa30 100755 --- a/bin/gstack-config +++ b/bin/gstack-config @@ -122,6 +122,13 @@ CONFIG_HEADER='# gstack configuration — edit freely, changes take effect on ne # # /document-release skip the outside-voice step entirely. # # An invalid value is REJECTED (existing value preserved) so # # a typo cannot silently turn paid Codex calls on or off. +# design_detector: auto # Deterministic design pre-pass through a user-installed +# # impeccable engine (/design-review, /review, /ship, +# # /design-html). auto = use the engine when the probe +# # finds one (gstack never installs or downloads it); +# # off = no probe, no scan, no hint, no /impeccable +# # handoff lines. An invalid value is REJECTED (existing +# # value preserved) so a typo cannot silently disable it. # gstack_contributor: false # true = file field reports when gstack misbehaves # skip_eng_review: false # true = skip eng review gate in /ship (not recommended) # @@ -149,6 +156,7 @@ lookup_default() { checkpoint_push) echo "false" ;; explain_level) echo "default" ;; codex_reviews) echo "enabled" ;; + design_detector) echo "auto" ;; # auto | off — impeccable engine pre-pass in the design skills gstack_contributor) echo "false" ;; skip_eng_review) echo "false" ;; workspace_root) echo "$HOME/conductor/workspaces" ;; @@ -416,6 +424,12 @@ case "${1:-}" in echo "Error: codex_reviews '$VALUE' not recognized. Valid values: enabled, disabled. Existing value left unchanged." >&2 exit 1 fi + # design_detector gates a third-party binary the user installed. Reject a typo + # rather than coerce it: "of" must not silently re-enable or disable the scan. + if [ "$KEY" = "design_detector" ] && [ "$VALUE" != "auto" ] && [ "$VALUE" != "off" ]; then + echo "Error: design_detector '$VALUE' not recognized. Valid values: auto, off. Existing value left unchanged." >&2 + exit 1 + fi # cross_project_learnings: empty get is the first-run prompt sentinel. # Skills enable only on the literal "true". A typo must not persist — that # keeps the feature off and suppresses the prompt. Reject, like @@ -455,7 +469,7 @@ case "${1:-}" in skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ - timeline_stop_hook; do + timeline_stop_hook design_detector; do VALUE=$(read_config_value "$KEY" || true) SOURCE="default" if [ -n "$VALUE" ]; then @@ -472,7 +486,7 @@ case "${1:-}" in skill_prefix checkpoint_mode checkpoint_push explain_level \ codex_reviews gstack_contributor skip_eng_review workspace_root \ artifacts_sync_mode artifacts_sync_mode_prompted plan_tune_hooks \ - timeline_stop_hook; do + timeline_stop_hook design_detector; do printf ' %-24s %s\n' "$KEY:" "$(lookup_default "$KEY")" done ;; diff --git a/bin/gstack-design-detect.ts b/bin/gstack-design-detect.ts new file mode 100755 index 000000000..6249aef10 --- /dev/null +++ b/bin/gstack-design-detect.ts @@ -0,0 +1,688 @@ +#!/usr/bin/env bun +/** + * gstack-design-detect — find, and run, an impeccable engine the USER installed. + * + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts probe [--host ] [--verbose] + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts scan [--format gstack|raw] [--changed ] [--host ] + * bun --no-env-file run ~/.claude/skills/gstack/bin/gstack-design-detect.ts rules + * + * Rule zero: gstack never installs, downloads, or executes anything that could + * download. The probe touches the filesystem and the environment only: file + * existence, a first-bytes sniff, JSON parsing. It never runs impeccable's + * launcher (`scripts/impeccable`) or its npm shim, because both fall through to a + * GitHub download when the engine is not cached. + * + * Probe order (first hit wins; every step is a read): + * + * design_detector config ── off ──► IMPECCABLE_DISABLED + * │ auto + * $IMPECCABLE_BIN (absolute, realpath outside repo/cwd, executable, not a script) ──► READY + * │ + * PATH walk (absolute entries only, none inside repo/cwd; `impeccable[.exe]`) ─┬─ binary ──► READY + * │ └─ #! shim ──► launcher-present + * $IMPECCABLE_HOME|~/.impeccable/bin//impeccable[.exe] ──► READY + * │ + * /{.claude,.agents,.cursor,.gemini,.github,.opencode}/skills/impeccable/scripts/ + * ├─ bin/-/impeccable[.exe] (engine installed beside the launcher) ──► READY + * └─ impeccable (launcher only) ──► IMPECCABLE_NOT_CACHED: + * │ + * nothing ──► IMPECCABLE_NOT_AVAILABLE + * + * Never probed: project-local node_modules (executing a binary that lives inside + * the repository under review is not something gstack does anywhere). + * + * Sentinel contract: lib/design-detect-contract.ts (one owner, imported here and + * by the gen-time resolvers). Scan output: stdout is one JSON document + * (--format gstack) or the engine's own bytes (--format raw); everything else + * goes to stderr, matching impeccable's own split. Exit code passes through + * (1 over 2 over 0); exit 3 is a gstack bug (DESIGN_DETECT_INTERNAL_ERROR). + * + * Scan hardening: targets must be existing files under the repo root (or cwd) + * or under ${GSTACK_HOME:-~/.gstack}/projects//designs/ (where design-review + * keeps rendered-DOM dumps); URLs are refused (the one engine path that talks to + * the network); the engine runs with stdin ignored (its ">50 files, continue?" + * prompt is gated on a TTY), a wall-clock timeout with SIGKILL on the direct + * child, a 50 MB stdout cap, and every string field sanitized and length-capped. + * The engine's file scan is a single process; if a future engine forks helpers + * they could outlive the kill (known limit). + * + * Env trust: Bun auto-loads a cwd `.env`, so every rendered invocation passes + * `--no-env-file`, and independently IMPECCABLE_BIN / IMPECCABLE_HOME values + * whose realpath lies inside the repo or cwd are ignored (IMPECCABLE_ENV_IGNORED). + * + * Observability: one content-free JSON line per probe/scan appended to + * ${GSTACK_HOME:-~/.gstack}/analytics/design-detector.jsonl (local file, no egress). + * + * Non-sink: this spawns a third-party binary the user installed over local + * paths; gstack does not audit that engine's network behavior (NOTICE.md). + */ +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { createHash } from 'crypto'; +import { spawnSync } from 'child_process'; +import { + SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS, + UNTRUSTED_BEGIN, UNTRUSTED_END, + type NormalizedFinding, type ScanResult, +} from '../lib/design-detect-contract'; +import { DESIGN_SLOP_CATALOG, entryForImpeccableId } from '../lib/design-catalog'; +import { isFrontendPath } from '../lib/frontend-scope'; + +// ── Environment ────────────────────────────────────────────────────────────── + +const WIN = process.platform === 'win32'; +const HOME = os.homedir(); +const ENV = process.env; + +function gstackHome(): string { + return ENV.GSTACK_STATE_ROOT || ENV.GSTACK_HOME || ENV.GSTACK_STATE_DIR || path.join(HOME, '.gstack'); +} + +function realpathOrNull(p: string): string | null { + try { return fs.realpathSync(p); } catch { return null; } +} + +function isInside(child: string, parent: string): boolean { + const rel = path.relative(parent, child); + return rel === '' || (!rel.startsWith('..') && !path.isAbsolute(rel)); +} + +function gitTopLevel(cwd: string): string | null { + const r = spawnSync('git', ['rev-parse', '--show-toplevel'], { cwd, encoding: 'utf-8', timeout: 10_000 }); + if (r.status !== 0) return null; + const top = r.stdout.trim(); + return top ? realpathOrNull(top) : null; +} + +/** design_detector, read the way bin/gstack-config resolves it (same STATE_DIR precedence, same default). */ +function configDesignDetector(): 'auto' | 'off' { + const file = path.join(gstackHome(), 'config.yaml'); + try { + const text = fs.readFileSync(file, 'utf-8'); + let value = ''; + for (const line of text.split('\n')) { + const m = line.match(/^design_detector:\s*(.*?)\s*$/); + if (m) value = m[1]; + } + return value === 'off' ? 'off' : 'auto'; + } catch { + return 'auto'; + } +} + +// ── Probe ──────────────────────────────────────────────────────────────────── + +const HOSTS_WITH_HOOKS: Record = { + claude: ['.claude/settings.local.json', '.claude/settings.json'], + codex: ['.codex/hooks.json'], + cursor: ['.cursor/hooks.json'], + github: ['.github/hooks/impeccable.json'], + grok: ['.grok/hooks/impeccable.json'], +}; +const SKILL_ROOTS = ['.claude', '.agents', '.cursor', '.gemini', '.github', '.opencode']; + +interface Probe { + sentinel: string; // first line + engine?: string; // resolved binary + engineVersion?: string; // semver, or sha256:<12> + launcher?: string; + skillPresent: boolean; + hook: 'present' | 'absent' | 'unknown'; + hookOther: string[]; + ignoredRules: string[]; + ignoredFiles: string[]; + notes: string[]; // extra sentinel lines (CONFIG_UNREADABLE, ENV_IGNORED, ENGINE_UNTESTED, HINT) + steps: string[]; // --verbose trail + repoRoot: string; + cwd: string; +} + +function isScript(file: string): boolean { + try { + const fd = fs.openSync(file, 'r'); + const buf = Buffer.alloc(2); + const n = fs.readSync(fd, buf, 0, 2, 0); + fs.closeSync(fd); + return n === 2 && buf[0] === 0x23 && buf[1] === 0x21; // "#!" + } catch { + return false; + } +} + +function isExecutableFile(file: string): boolean { + try { + const st = fs.statSync(file); + if (!st.isFile()) return false; + if (WIN) return /\.exe$/i.test(file); + return (st.mode & 0o111) !== 0; + } catch { + return false; + } +} + +/** + * On the PATH walk only: an executable that is not a `#!` script. A node shim + * named `impeccable` is launcher-present, never READY (running it downloads). + * Explicit install locations (IMPECCABLE_BIN, the ~/.impeccable/bin cache, the + * engine beside a skill install's launcher) accept any executable regular file. + */ +function isEngineBinary(file: string): boolean { + return isExecutableFile(file) && !isScript(file); +} + +function semverKey(v: string): number[] | null { + const m = v.match(/^v?(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/); + return m ? [Number(m[1]), Number(m[2]), Number(m[3])] : null; +} + +function newestSemverDir(dir: string): string | null { + let entries: string[]; + try { entries = fs.readdirSync(dir); } catch { return null; } + const versions = entries.map(e => ({ e, k: semverKey(e) })).filter(x => x.k) as { e: string; k: number[] }[]; + versions.sort((a, b) => (b.k[0] - a.k[0]) || (b.k[1] - a.k[1]) || (b.k[2] - a.k[2])); + return versions[0]?.e ?? null; +} + +function readJsonFile(file: string): { ok: true; value: unknown } | { ok: false; missing: boolean } { + let text: string; + try { text = fs.readFileSync(file, 'utf-8'); } catch (e) { + return { ok: false, missing: (e as NodeJS.ErrnoException).code === 'ENOENT' }; + } + try { return { ok: true, value: JSON.parse(text) }; } catch { return { ok: false, missing: false }; } +} + +function trustedEnvPath(name: string, repoRoot: string, cwd: string, notes: string[], steps: string[]): string | null { + const raw = ENV[name]; + if (!raw) return null; + if (!path.isAbsolute(raw)) { + notes.push(`${SENTINEL.ENV_IGNORED}: ${name} is not an absolute path`); + return null; + } + const real = realpathOrNull(raw); + if (!real) { + steps.push(`${name}=${raw} does not exist`); + return null; + } + if (isInside(real, repoRoot) || isInside(real, cwd)) { + notes.push(`${SENTINEL.ENV_IGNORED}: ${name} resolves inside the repository`); + return null; + } + return real; +} + +function engineSiblings(launcherDir: string): string[] { + const arch = process.arch; + const tags = new Set([ + `${process.platform}-${arch}`, + `${WIN ? 'windows' : process.platform}-${arch}`, + `${process.platform}-x64`, `${process.platform}-arm64`, + ]); + const name = WIN ? 'impeccable.exe' : 'impeccable'; + return [...tags].map(t => path.join(launcherDir, 'bin', t, name)); +} + +function probe(host: string, verbose = false): Probe { + const cwd = realpathOrNull(process.cwd()) ?? process.cwd(); + const repoRoot = gitTopLevel(cwd) ?? cwd; + const p: Probe = { + sentinel: SENTINEL.NOT_AVAILABLE, skillPresent: false, hook: 'absent', hookOther: [], + ignoredRules: [], ignoredFiles: [], notes: [], steps: [], repoRoot, cwd, + }; + const step = (s: string) => { if (verbose) p.steps.push(s); }; + + // 0. config + const cfg = configDesignDetector(); + step(`design_detector=${cfg}`); + + // Always computed: skill / launcher / hook / ignores (informational even when disabled). + const roots = [...new Set([repoRoot, cwd, HOME])]; + let siblingEngine: string | null = null; + let siblingVersion: string | null = null; + for (const root of roots) { + for (const sub of SKILL_ROOTS) { + const skillDir = path.join(root, sub, 'skills', 'impeccable'); + if (fs.existsSync(path.join(skillDir, 'SKILL.md'))) p.skillPresent = true; + const launcher = path.join(skillDir, 'scripts', 'impeccable'); + if (fs.existsSync(launcher)) { + p.launcher ??= launcher; + for (const cand of engineSiblings(path.dirname(launcher))) { + if (!siblingEngine && isExecutableFile(cand)) { + siblingEngine = cand; + try { siblingVersion = fs.readFileSync(path.join(path.dirname(launcher), 'VERSION'), 'utf-8').trim() || null; } catch { /* no VERSION file */ } + } + } + } + } + } + step(`skill=${p.skillPresent} launcher=${p.launcher ?? 'none'} sibling=${siblingEngine ?? 'none'}`); + + // Hook manifests, host-aware. + const mine = HOSTS_WITH_HOOKS[host] ?? []; + let hookEnabled = true; + for (const cfgName of ['config.json', 'config.local.json']) { + const file = path.join(repoRoot, '.impeccable', cfgName); + const r = readJsonFile(file); + if (!r.ok) { + if (!r.missing) p.notes.push(`${SENTINEL.CONFIG_UNREADABLE}: ${file}`); + continue; + } + const v = r.value as { hook?: { enabled?: unknown }; detector?: { ignoreRules?: unknown; ignoreFiles?: 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 : []; + 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)); + } + } + p.ignoredRules = [...new Set(p.ignoredRules)]; + p.ignoredFiles = [...new Set(p.ignoredFiles)]; + let unknown = false; + for (const [h, manifests] of Object.entries(HOSTS_WITH_HOOKS)) { + for (const rel of manifests) { + const file = path.join(repoRoot, rel); + const r = readJsonFile(file); + if (!r.ok) { if (!r.missing && mine.includes(rel)) unknown = true; continue; } + const text = JSON.stringify(r.value); + if (!/impeccable\s+hook/.test(text) && !/skills\/impeccable\/scripts\/impeccable/.test(text)) continue; + if (mine.includes(rel)) p.hook = 'present'; else if (!p.hookOther.includes(h)) p.hookOther.push(h); + } + } + if (p.hook !== 'present' && unknown) p.hook = 'unknown'; + if (!hookEnabled) { p.hook = 'absent'; step('hook.enabled=false in .impeccable config'); } + + if (cfg === 'off') { + p.sentinel = SENTINEL.DISABLED; + return p; + } + + // 2. IMPECCABLE_BIN + const envBin = trustedEnvPath('IMPECCABLE_BIN', repoRoot, cwd, p.notes, p.steps); + if (envBin && isExecutableFile(envBin)) { + p.sentinel = `${SENTINEL.READY}: ${envBin}`; + p.engine = envBin; + } else if (envBin) { + step(`IMPECCABLE_BIN=${envBin} is not an executable file`); + } + + // 3. PATH walk + let launcherOnPath: string | null = null; + if (!p.engine) { + const exts = WIN ? (ENV.PATHEXT || '.EXE;.CMD;.BAT').split(';').map(e => e.toLowerCase()) : ['']; + for (const entry of (ENV.PATH || '').split(path.delimiter)) { + if (!entry || !path.isAbsolute(entry)) continue; + const real = realpathOrNull(entry); + if (!real || isInside(real, repoRoot) || isInside(real, cwd)) continue; + for (const ext of exts) { + const cand = path.join(real, `impeccable${ext}`); + if (!fs.existsSync(cand)) continue; + if (isEngineBinary(cand)) { p.engine = cand; p.sentinel = `${SENTINEL.READY}: ${cand}`; break; } + launcherOnPath ??= cand; // node shim or .cmd wrapper: launcher present, engine not proven + } + if (p.engine) break; + } + step(`PATH walk: engine=${p.engine ?? 'none'} shim=${launcherOnPath ?? 'none'}`); + } + + // 4. cache + if (!p.engine) { + const homeOverride = trustedEnvPath('IMPECCABLE_HOME', repoRoot, cwd, p.notes, p.steps); + const cacheRoot = homeOverride ?? path.join(HOME, '.impeccable'); + const binDir = path.join(cacheRoot, 'bin'); + const newest = newestSemverDir(binDir); + if (newest) { + const cand = path.join(binDir, newest, WIN ? 'impeccable.exe' : 'impeccable'); + if (isExecutableFile(cand)) { p.engine = cand; p.engineVersion = newest.replace(/^v/, ''); p.sentinel = `${SENTINEL.READY}: ${cand}`; } + } + step(`cache ${binDir}: newest=${newest ?? 'none'} engine=${p.engine ?? 'none'}`); + } + + // 6. engine beside the launcher + if (!p.engine && siblingEngine) { + p.engine = siblingEngine; + p.engineVersion = siblingVersion ?? undefined; + p.sentinel = `${SENTINEL.READY}: ${siblingEngine}`; + } + + if (p.engine) { + if (!p.engineVersion) { + // Version sources, in order: the ~/.impeccable/bin// cache layout; + // the skill-install layout (/scripts/bin/-/impeccable next + // to /scripts/VERSION); else a content hash. Never a filesystem path. + const m = p.engine.match(/[\\/]bin[\\/](v?\d+\.\d+\.\d+)[\\/]/); + if (m) p.engineVersion = m[1].replace(/^v/, ''); + else { + try { + const v = fs.readFileSync(path.join(path.dirname(p.engine), '..', '..', 'VERSION'), 'utf-8').trim(); + if (semverKey(v)) p.engineVersion = v.replace(/^v/, ''); + } catch { /* no VERSION beside the binary */ } + } + p.engineVersion ??= `sha256:${sha256File(p.engine).slice(0, 12)}`; + } + if (!TESTED_ENGINE_VERSIONS.includes(p.engineVersion)) p.notes.push(`${SENTINEL.ENGINE_UNTESTED}: ${p.engineVersion}`); + return p; + } + + // 6b/7. launcher present but no engine + const launcher = p.launcher ?? launcherOnPath; + if (launcher) { + p.sentinel = `${SENTINEL.NOT_CACHED}: ${launcher}`; + const how = p.launcher + ? `run \`${p.launcher} detect --help\` once; it fetches the engine version pinned by your install` + : 'run `npx impeccable detect --help` once; it fetches the engine'; + p.notes.push(`${SENTINEL.HINT}: impeccable is installed but its engine is not cached; ${how} (gstack never downloads it). Silence this: \`gstack-config set design_detector off\`.`); + return p; + } + + p.sentinel = SENTINEL.NOT_AVAILABLE; + return p; +} + +function sha256File(file: string): string { + try { return createHash('sha256').update(fs.readFileSync(file)).digest('hex'); } catch { return 'unreadable'; } +} + +function probeLines(p: Probe): string[] { + const lines = [p.sentinel, `${SENTINEL.SKILL}: ${p.skillPresent ? 'present' : 'absent'}`, `${SENTINEL.HOOK}: ${p.hook}`]; + 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(...p.notes); + if (p.steps.length) lines.push(...p.steps.map(s => `PROBE_STEP: ${s}`)); + return lines; +} + +// ── Sanitization ───────────────────────────────────────────────────────────── + +function stripControl(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, '').replace(/[\r\n\t]+/g, ' '); +} +function clip(s: string, n: number): string { + return s.length > n ? s.slice(0, n - 1) + '…' : s; +} +function sanitizeId(raw: unknown): string | null { + if (typeof raw !== 'string') return null; + const s = raw.trim().toLowerCase(); + return /^[a-z0-9-]{1,64}$/.test(s) ? s : null; +} +function str(v: unknown): string { + return typeof v === 'string' ? v : v == null ? '' : String(v); +} + +// ── Scan ───────────────────────────────────────────────────────────────────── + +interface ScanArgs { format: 'gstack' | 'raw'; changed?: string; targets: string[]; host: string } + +function refuse(target: string, why: string) { + process.stderr.write(`${SENTINEL.DETECT_REFUSED}: ${clip(stripControl(target), 200)} (${why})\n`); +} + +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; + const projects = realpathOrNull(designsRoot()); + if (!projects || !isInside(real, projects)) return false; + const rel = path.relative(projects, real).split(path.sep); + return rel.length >= 3 && rel[1] === 'designs'; +} + +function resolveTargets(args: ScanArgs, p: Probe): string[] { + const out: string[] = []; + const seen = new Set(); + const push = (raw: string) => { + if (/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) || /^(file|data|javascript):/i.test(raw)) { refuse(raw, 'URL targets are never scanned'); return; } + const abs = path.isAbsolute(raw) ? raw : path.join(p.cwd, raw); + const real = realpathOrNull(abs); + if (!real) { refuse(raw, 'does not exist'); return; } + if (!allowedTarget(real, p)) { refuse(raw, 'outside the repository and the design-report allow-list'); return; } + if (seen.has(real)) return; + seen.add(real); + out.push(real); + }; + for (const t of args.targets) push(t); + if (args.changed !== undefined) { + const top = gitTopLevel(p.cwd); + if (!top) { refuse(args.changed, 'not a repository'); return out; } + const base = args.changed; + const files = new Set(); + const runZ = (argv: string[]) => { + const r = spawnSync('git', argv, { cwd: top, encoding: 'buffer', timeout: 30_000, maxBuffer: 64 * 1024 * 1024 }); + if (r.status !== 0) return; + for (const rel of r.stdout.toString('utf-8').split('\0')) if (rel) files.add(rel); + }; + runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', `${base}...HEAD`]); + runZ(['diff', '-z', '--name-only', '--diff-filter=ACMR', 'HEAD']); + runZ(['ls-files', '-z', '--others', '--exclude-standard']); + for (const rel of [...files].sort()) { + if (!isFrontendPath(rel)) continue; + const real = realpathOrNull(path.join(top, rel)); + if (!real) continue; // deleted or unreadable + try { if (!fs.statSync(real).isFile()) continue; } catch { continue; } + if (!seen.has(real)) { seen.add(real); out.push(real); } + } + } + return out; +} + +interface EngineRun { exit: number; stdout: string; stderr: string; timedOut: boolean; tooLarge: boolean } + +function runEngine(engine: string, batch: string[], cwd: string, timeoutMs: number): EngineRun { + const r = Bun.spawnSync([engine, 'detect', '--json', ...batch], { + cwd, stdin: 'ignore', stdout: 'pipe', stderr: 'pipe', env: ENV as Record, + timeout: timeoutMs, killSignal: 'SIGKILL', maxBuffer: DETECT_LIMITS.stdoutBytes + 1024, + }); + const out = r.stdout ?? new Uint8Array(); + const tooLarge = out.byteLength > DETECT_LIMITS.stdoutBytes; + return { + exit: r.exitCode ?? 1, + stdout: tooLarge ? '' : Buffer.from(out).toString('utf-8'), + stderr: Buffer.from(r.stderr ?? new Uint8Array()).toString('utf-8'), + timedOut: Boolean((r as { exitedDueToTimeout?: boolean }).exitedDueToTimeout), + tooLarge, + }; +} + +function normalize(raw: unknown): NormalizedFinding { + const f = (raw && typeof raw === 'object' ? raw : {}) as Record; + const idRaw = f.antipattern ?? f.rule ?? f.id ?? f.ruleId; + const id = sanitizeId(idRaw); + const entry = id ? entryForImpeccableId(id) : undefined; + const lim = DETECT_LIMITS.field; + const advisory = (id ? ADVISORY_RULE_IDS.includes(id) : false) || f.advisory === true || str(f.severity).toLowerCase() === 'advisory'; + const rawKind = str(f.category); + const base: NormalizedFinding = { + id: entry?.id ?? id ?? 'unmapped', + impeccableId: id ?? (clip(stripControl(str(idRaw)), lim.id) || 'unmapped'), + 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), + message: clip(stripControl(str(f.description ?? f.message)), lim.message), + category: entry?.category ?? 'unknown', + kind: entry?.kind ?? (rawKind === 'slop' || rawKind === 'quality' ? rawKind : 'unknown'), + impact: entry?.impact ?? 'medium', + tier: entry?.tier ?? 'ask', + advisory, + }; + if (entry?.handoff) base.handoff = entry.handoff; + if (typeof f.value === 'string' && f.value) base.value = clip(stripControl(f.value), lim.value); + if (!entry) base.unmapped = true; + return base; +} + +function scan(args: ScanArgs): number { + const p = probe(args.host); + if (!p.engine) { + process.stdout.write(probeLines(p).join('\n') + '\n'); + analytics({ verb: 'scan', sentinel: p.sentinel.split(':')[0], exit: 0 }); + return 0; + } + for (const line of probeLines(p)) process.stderr.write(line + '\n'); + + const targets = resolveTargets(args, p); + if (!targets.length) { + process.stderr.write(`${SENTINEL.DETECT_NO_TARGETS}\n`); + analytics({ verb: 'scan', sentinel: 'READY', engine: p.engineVersion, targets: 0, exit: 0 }); + return 0; + } + + const timeoutMs = Number(ENV.GSTACK_DESIGN_DETECT_TIMEOUT_MS) > 0 ? Number(ENV.GSTACK_DESIGN_DETECT_TIMEOUT_MS) : DETECT_LIMITS.timeoutMs; + const rawFindings: unknown[] = []; + const rawChunks: string[] = []; + const diagnostics: string[] = []; + let exit = 0; + const started = Date.now(); + for (let i = 0; i < targets.length; i += DETECT_LIMITS.batch) { + const batch = targets.slice(i, i + DETECT_LIMITS.batch); + const run = runEngine(p.engine, batch, p.repoRoot, timeoutMs); + for (const line of run.stderr.split('\n')) if (line.trim()) diagnostics.push(clip(stripControl(line), DETECT_LIMITS.field.diagnostic)); + if (run.timedOut) { process.stderr.write(`${SENTINEL.DETECT_TIMEOUT}: ${timeoutMs}ms\n`); exit = 1; continue; } + if (run.tooLarge) { process.stderr.write(`${SENTINEL.DETECT_OUTPUT_TOO_LARGE}: engine stdout exceeded ${DETECT_LIMITS.stdoutBytes} bytes\n`); exit = 1; continue; } + let parsed: unknown; + try { parsed = JSON.parse(run.stdout.trim() || 'null'); } catch { parsed = undefined; } + if (!Array.isArray(parsed)) { + process.stderr.write(`${SENTINEL.DETECT_PARSE_ERROR}: ${clip(stripControl(run.stdout), 80)}\n`); + exit = 1; + continue; + } + rawChunks.push(run.stdout); + rawFindings.push(...parsed); + if (run.exit === 1) exit = 1; + else if (run.exit === 2 && exit !== 1) exit = 2; + else if (run.exit !== 0 && run.exit !== 2 && exit !== 1) exit = 1; + } + + if (args.format === 'raw') { + process.stdout.write(rawChunks.length === 1 ? rawChunks[0] : JSON.stringify(rawFindings, null, 2) + '\n'); + } else { + const all = rawFindings.map(normalize); + const truncated = all.length > DETECT_LIMITS.findings; + const findings = truncated ? all.slice(0, DETECT_LIMITS.findings) : all; + const byRule: Record = {}; + let advisory = 0, high = 0, medium = 0, polish = 0, slop = 0, quality = 0; + for (const f of all) { + byRule[f.impeccableId] = (byRule[f.impeccableId] ?? 0) + 1; + if (f.advisory) { advisory++; continue; } + if (f.kind === 'slop') slop++; else if (f.kind === 'quality') quality++; + if (f.impact === 'high') high++; else if (f.impact === 'medium') medium++; else polish++; + } + const result: ScanResult = { + schemaVersion: 1, engine: p.engine, engineVersion: p.engineVersion ?? 'unknown', targets: targets.length, + exit, total: all.length, counted: all.length - advisory, advisory, ignoredRules: p.ignoredRules, byRule, findings, truncated, diagnostics, + }; + process.stdout.write(JSON.stringify(result, null, 2) + '\n'); + writeTop(all, truncated); + process.stderr.write(`${SENTINEL.DETECT_SUMMARY}: total=${all.length} slop=${slop} quality=${quality} advisory=${advisory} ignored=${p.ignoredRules.length} high=${high} medium=${medium} polish=${polish}${truncated ? ' truncated=true' : ''}\n`); + } + for (const d of diagnostics.slice(0, 20)) process.stderr.write(`ENGINE_STDERR: ${d}\n`); + process.stderr.write(`${SENTINEL.DETECT_EXIT}: ${exit}\n`); + analytics({ verb: 'scan', sentinel: 'READY', engine: p.engineVersion, targets: targets.length, total: rawFindings.length, ignored: p.ignoredRules.length, exit, ms: Date.now() - started }); + return exit; +} + +const IMPACT_ORDER = { high: 0, medium: 1, polish: 2 } as const; + +function writeTop(findings: NormalizedFinding[], truncated: boolean) { + const groups = new Map(); + for (const f of findings) { + if (f.advisory) continue; + const g = groups.get(f.impeccableId) ?? []; + g.push(f); + groups.set(f.impeccableId, g); + } + const ordered = [...groups.entries()].sort((a, b) => + (IMPACT_ORDER[a[1][0].impact] - IMPACT_ORDER[b[1][0].impact]) || (b[1].length - a[1].length) || a[0].localeCompare(b[0])); + const lines = [UNTRUSTED_BEGIN, `${SENTINEL.DETECT_TOP} total=${findings.length} rules=${groups.size}${truncated ? ' truncated=true' : ''}`]; + let shown = 0; + for (const [id, group] of ordered) { + const f0 = group[0]; + lines.push(`[${id}] impact=${f0.impact} tier=${f0.tier} count=${group.length}${f0.handoff ? ` handoff=/impeccable ${f0.handoff}` : ''}${f0.unmapped ? ' unmapped' : ''}`); + for (const f of group) { + if (shown >= DETECT_LIMITS.topLocations) break; + lines.push(` ${f.file}:${f.line} ${f.snippet}`); + shown++; + } + } + if (shown >= DETECT_LIMITS.topLocations && findings.length > shown) lines.push(` … ${findings.length - shown} more locations in the JSON`); + lines.push(UNTRUSTED_END); + process.stderr.write(lines.join('\n') + '\n'); +} + +// ── rules ──────────────────────────────────────────────────────────────────── + +function rules(): number { + const mapped = DESIGN_SLOP_CATALOG.filter(e => e.impeccableId); + process.stdout.write(`# ${mapped.length} detector rules mapped in lib/design-catalog.ts; tested engine versions: ${TESTED_ENGINE_VERSIONS.join(', ')}\n`); + process.stdout.write('id\tkind\timpact\ttier\thandoff\tname\n'); + for (const e of mapped) process.stdout.write(`${e.impeccableId}\t${e.kind}\t${e.impact}\t${e.tier}\t${e.handoff ?? '-'}\t${e.name}\n`); + return 0; +} + +// ── Analytics (local, best-effort) ─────────────────────────────────────────── + +function analytics(rec: Record) { + try { + const dir = path.join(gstackHome(), 'analytics'); + fs.mkdirSync(dir, { recursive: true }); + fs.appendFileSync(path.join(dir, 'design-detector.jsonl'), JSON.stringify({ ts: new Date().toISOString(), ...rec }) + '\n'); + } catch { /* never throws */ } +} + +// ── CLI ────────────────────────────────────────────────────────────────────── + +function parse(argv: string[]): { verb: string; host: string; verbose: boolean; scan: ScanArgs } { + const verb = argv[0] ?? ''; + let host = 'claude'; + let verbose = false; + let format: 'gstack' | 'raw' = 'gstack'; + let changed: string | undefined; + const targets: string[] = []; + for (let i = 1; i < argv.length; i++) { + const a = argv[i]; + if (a === '--host') host = argv[++i] ?? host; + else if (a === '--verbose') verbose = true; + else if (a === '--format') { const v = argv[++i]; format = v === 'raw' ? 'raw' : 'gstack'; } + else if (a === '--changed') changed = argv[++i] ?? 'main'; + else if (a === '--') { targets.push(...argv.slice(i + 1)); break; } + else if (a.startsWith('--')) process.stderr.write(`ignoring unknown flag ${a}\n`); + else targets.push(a); + } + return { verb, host, verbose, scan: { format, changed, targets, host } }; +} + +export function main(argv = process.argv.slice(2)): number { + const { verb, host, verbose, scan: scanArgs } = parse(argv); + switch (verb) { + case 'probe': { + const p = probe(host, verbose); + process.stdout.write(probeLines(p).join('\n') + '\n'); + analytics({ verb: 'probe', sentinel: p.sentinel.split(':')[0], engine: p.engineVersion, hook: p.hook, ignored: p.ignoredRules.length, exit: 0 }); + return 0; + } + case 'scan': + return scan(scanArgs); + case 'rules': + return rules(); + default: + process.stderr.write('usage: gstack-design-detect.ts probe [--host ] [--verbose] | scan [--format gstack|raw] [--changed ] [--host ] | rules\n'); + return 2; + } +} + +if (import.meta.main) { + // exitCode, not process.exit(): a pipe write over 64 KB (a big scan) is still + // in flight when process.exit() runs and would be truncated mid-JSON. + try { + process.exitCode = main(); + } catch (err) { + const e = err as Error; + process.stderr.write(`${SENTINEL.INTERNAL_ERROR}: ${e?.name ?? 'Error'}: ${clip(stripControl(String(e?.message ?? e)), 300)}\n`); + analytics({ verb: process.argv[2] ?? '', sentinel: 'INTERNAL_ERROR', exit: 3 }); + process.exitCode = 3; + } +} diff --git a/lib/design-detect-contract.ts b/lib/design-detect-contract.ts new file mode 100644 index 000000000..2cd40a279 --- /dev/null +++ b/lib/design-detect-contract.ts @@ -0,0 +1,116 @@ +// lib/design-detect-contract.ts — the one owner of the design-detector vocabulary. +// +// Pure module: no I/O, no imports from scripts/. Every sentinel the wrapper +// (bin/gstack-design-detect.ts) or the DESIGN.md tool (bin/gstack-design-md.ts) +// prints, and every one the skill prose reads, is a constant here, so the two +// sides cannot drift: gen-time resolvers import these strings into SKILL.md +// prose, the bins import them at runtime, and test/design-detect-contract.test.ts +// 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 +// ──► 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) +// ──► stderr: DETECT_TOP block, DETECT_SUMMARY, DETECT_EXIT, DETECT_REFUSED / DETECT_NO_TARGETS / +// DETECT_TIMEOUT / DETECT_PARSE_ERROR / DETECT_OUTPUT_TOO_LARGE +// any ──► exit 3 + DESIGN_DETECT_INTERNAL_ERROR: a gstack bug, never retried + +export const SENTINEL = { + READY: 'IMPECCABLE_READY', + NOT_CACHED: 'IMPECCABLE_NOT_CACHED', + NOT_AVAILABLE: 'IMPECCABLE_NOT_AVAILABLE', + DISABLED: 'IMPECCABLE_DISABLED', + SKILL: 'IMPECCABLE_SKILL', + HOOK: 'IMPECCABLE_HOOK', + HOOK_OTHER: 'IMPECCABLE_HOOK_OTHER', + IGNORED_RULES: 'IMPECCABLE_IGNORED_RULES', + IGNORED_FILES: 'IMPECCABLE_IGNORED_FILES', + CONFIG_UNREADABLE: 'IMPECCABLE_CONFIG_UNREADABLE', + ENV_IGNORED: 'IMPECCABLE_ENV_IGNORED', + ENGINE_UNTESTED: 'IMPECCABLE_ENGINE_UNTESTED', + HINT: 'DESIGN_DETECTOR_HINT', + DETECT_EXIT: 'DETECT_EXIT', + DETECT_EXIT_CODE: 'DETECT_EXIT_CODE', + DETECT_SUMMARY: 'DETECT_SUMMARY', + DETECT_TOP: 'DETECT_TOP', + DETECT_REFUSED: 'DETECT_REFUSED', + DETECT_NO_TARGETS: 'DETECT_NO_TARGETS', + DETECT_TIMEOUT: 'DETECT_TIMEOUT', + DETECT_PARSE_ERROR: 'DETECT_PARSE_ERROR', + DETECT_OUTPUT_TOO_LARGE: 'DETECT_OUTPUT_TOO_LARGE', + INTERNAL_ERROR: 'DESIGN_DETECT_INTERNAL_ERROR', + DOM_DUMP_REDACTION_BLOCKED: 'DOM_DUMP_REDACTION_BLOCKED', + DOM_DUMP_TOO_LARGE: 'DOM_DUMP_TOO_LARGE', + DESIGN_MD_FORMAT: 'DESIGN_MD_FORMAT', + DESIGN_MD_CONVERT_REFUSED: 'DESIGN_MD_CONVERT_REFUSED', + DESIGN_MD_INTERNAL_ERROR: 'DESIGN_MD_INTERNAL_ERROR', + DESIGN_MD_TOKEN_REF_INVALID: 'DESIGN_MD_TOKEN_REF_INVALID', +} as const; + +export type SentinelName = keyof typeof SENTINEL; + +/** Engine versions the committed fixtures were captured from. */ +export const TESTED_ENGINE_VERSIONS: readonly string[] = ['0.1.3']; + +/** Rules the engine reports but never counts (they never change its exit code). */ +export const ADVISORY_RULE_IDS: readonly string[] = ['em-dash-overuse']; + +export const DETECT_LIMITS = { + /** default engine wall clock; GSTACK_DESIGN_DETECT_TIMEOUT_MS overrides */ + timeoutMs: 120_000, + /** absolute paths per engine invocation */ + batch: 100, + /** engine stdout above this is DETECT_OUTPUT_TOO_LARGE */ + stdoutBytes: 50 * 1024 * 1024, + /** normalized findings kept; the rest is `truncated: true` */ + findings: 5_000, + /** locations printed in the DETECT_TOP block */ + topLocations: 50, + /** rendered-DOM dump above this is DOM_DUMP_TOO_LARGE */ + domDumpBytes: 10 * 1024 * 1024, + field: { id: 64, message: 120, snippet: 120, value: 200, file: 4096, diagnostic: 400 }, +} as const; + +/** Markers around any engine text the skill may quote (page text can echo through it). */ +export const UNTRUSTED_BEGIN = '═══ BEGIN UNTRUSTED CONTENT (design detector output) ═══'; +export const UNTRUSTED_END = '═══ END UNTRUSTED CONTENT ═══'; + +export interface NormalizedFinding { + /** catalog id (equals impeccableId when mapped; the engine's id, sanitized, when not) */ + id: string; + impeccableId: string; + file: string; + line: number; + snippet: string; + value?: string; + message: string; + category: string; + kind: 'slop' | 'quality' | 'unknown'; + impact: 'high' | 'medium' | 'polish'; + tier: 'auto-fix' | 'ask' | 'possible'; + handoff?: string; + advisory: boolean; + unmapped?: true; +} + +export interface ScanResult { + schemaVersion: 1; + engine: string; + engineVersion: string; + targets: number; + /** engine exit code after precedence (1 over 2 over 0) */ + exit: number; + total: number; + counted: number; + advisory: number; + /** rule ids the project config ignores (never present in findings) */ + ignoredRules: string[]; + byRule: Record; + findings: NormalizedFinding[]; + truncated: boolean; + diagnostics: string[]; +} + +/** 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/lib/frontend-scope.ts b/lib/frontend-scope.ts new file mode 100644 index 000000000..94c50dd67 --- /dev/null +++ b/lib/frontend-scope.ts @@ -0,0 +1,31 @@ +// lib/frontend-scope.ts — which repo paths count as frontend. +// +// Pure module: no I/O, no imports from scripts/. The patterns mirror the +// `m_frontend` arm of bin/gstack-diff-scope (the bash source of truth for +// SCOPE_FRONTEND); test/frontend-scope.test.ts pins the two against the same +// sample paths so they cannot drift. bin/gstack-design-detect.ts uses this to +// derive `scan --changed ` targets without consuming a shell-split list. + +const EXTENSIONS = new Set([ + '.css', '.scss', '.less', '.sass', '.pcss', + '.tsx', '.jsx', '.vue', '.svelte', '.astro', + '.erb', '.haml', '.slim', '.hbs', '.ejs', + '.html', +]); + +const BASENAME_PREFIXES = ['tailwind.config.', 'postcss.config.']; + +/** Repo-relative path (forward slashes) → is it a frontend file per gstack-diff-scope? */ +export function isFrontendPath(relPath: string): boolean { + const rel = relPath.replace(/\\/g, '/').replace(/^\.\//, ''); + const base = rel.slice(rel.lastIndexOf('/') + 1); + const dot = base.lastIndexOf('.'); + const ext = dot >= 0 ? base.slice(dot).toLowerCase() : ''; + if (EXTENSIONS.has(ext)) return true; + if (BASENAME_PREFIXES.some(p => base.startsWith(p))) return true; + if (rel.startsWith('app/views/')) return true; + if (rel.includes('/components/')) return true; + if (rel.startsWith('styles/') || rel.startsWith('css/')) return true; + if (rel.startsWith('app/assets/stylesheets/')) return true; + return false; +} diff --git a/test/design-detect-contract.test.ts b/test/design-detect-contract.test.ts new file mode 100644 index 000000000..739c8c9e3 --- /dev/null +++ b/test/design-detect-contract.test.ts @@ -0,0 +1,86 @@ +/** + * lib/design-detect-contract.ts is the one owner of the detector vocabulary. + * Forward direction: every sentinel-shaped token (IMPECCABLE_*, DETECT_*, + * DESIGN_MD_*, DOM_DUMP_*) that appears in something the agent reads + * (generated SKILL.md files, sections, the design checklist, the resolvers) + * must be a contract constant, so prose cannot invent a sentinel the bin never + * prints. Reverse direction (every printable sentinel is mentioned somewhere + * the agent reads) lands with the DESIGN_DETECTOR resolver wiring. + */ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { SENTINEL, TESTED_ENGINE_VERSIONS, ADVISORY_RULE_IDS, DETECT_LIMITS, DETECT_EXIT_ECHO } from '../lib/design-detect-contract'; +import { ADVISORY_RULE_IDS as _a } from '../lib/design-detect-contract'; +import { catalogEntry } from '../lib/design-catalog'; + +const ROOT = path.join(import.meta.dir, '..'); +const TOKEN = /\b(IMPECCABLE_[A-Z_]+|DETECT_[A-Z_]+|DESIGN_MD_[A-Z_]+|DOM_DUMP_[A-Z_]+|DESIGN_DETECTOR_[A-Z_]+|DESIGN_DETECT_[A-Z_]+)\b/g; +// Things that look like sentinels but are env vars / flags the prose legitimately names. +const NOT_SENTINELS = new Set(['IMPECCABLE_BIN', 'IMPECCABLE_HOME', 'IMPECCABLE_HOOK_DISABLED', 'DESIGN_DETECT_TIMEOUT_MS']); + +function* agentReadableFiles(): Generator { + const skip = new Set(['node_modules', '.git', 'dist', 'build', 'test', 'docs', '.context', '.claude', '.agents', '.factory', '.cursor', '.kiro', '.opencode', '.openclaw', '.hermes', '.slate', '.gstack', '.gbrain', '.conductor']); + const stack = [ROOT]; + while (stack.length) { + const cur = stack.pop()!; + for (const ent of fs.readdirSync(cur, { withFileTypes: true })) { + if (ent.isSymbolicLink()) continue; + const full = path.join(cur, ent.name); + if (ent.isDirectory()) { if (!skip.has(ent.name) || cur !== ROOT) { if (!skip.has(ent.name)) stack.push(full); } continue; } + if (/\.(md|tmpl|ts)$/.test(ent.name) && (full.includes(`${path.sep}scripts${path.sep}resolvers${path.sep}`) || ent.name.endsWith('.md') || ent.name.endsWith('.tmpl'))) yield full; + } + } +} + +describe('contract shape', () => { + test('sentinel values are unique, uppercase, and equal their own prefix family', () => { + const values = Object.values(SENTINEL); + expect(new Set(values).size).toBe(values.length); + for (const v of values) expect(v).toMatch(/^[A-Z][A-Z_]+$/); + }); + + test('tested engine versions and advisory ids are consistent with the fixtures and catalog', () => { + const meta = JSON.parse(fs.readFileSync(path.join(ROOT, 'test', 'fixtures', 'impeccable-captures.meta.json'), 'utf-8')); + expect(TESTED_ENGINE_VERSIONS).toContain(meta.engine.version); + for (const id of ADVISORY_RULE_IDS) { + const e = catalogEntry(id); + expect(e).toBeDefined(); + expect(e!.tier).toBe('possible'); + expect(e!.impact).toBe('polish'); + } + expect(_a).toBe(ADVISORY_RULE_IDS); + }); + + test('limits are positive and the exit echo carries the DETECT_EXIT_CODE sentinel', () => { + expect(DETECT_LIMITS.timeoutMs).toBeGreaterThan(0); + expect(DETECT_LIMITS.batch).toBeGreaterThan(0); + expect(DETECT_LIMITS.findings).toBeGreaterThan(DETECT_LIMITS.topLocations); + expect(DETECT_EXIT_ECHO).toBe(`; echo "${SENTINEL.DETECT_EXIT_CODE}=$?"`); + }); + + test('module is pure: no imports, loading prints nothing', () => { + const file = path.join(ROOT, 'lib', 'design-detect-contract.ts'); + expect(fs.readFileSync(file, 'utf-8')).not.toMatch(/^import /m); + const r = spawnSync(process.execPath, ['--no-env-file', '-e', `await import(${JSON.stringify(file)})`], { encoding: 'utf-8', timeout: 30_000 }); + expect(r.status).toBe(0); + expect(r.stdout + r.stderr).toBe(''); + }); +}); + +describe('every sentinel-shaped token the agent can read exists in the contract', () => { + test('generated docs, sections, templates, resolvers, and the checklist', () => { + const known = new Set(Object.values(SENTINEL)); + const offenders: string[] = []; + for (const file of agentReadableFiles()) { + const text = fs.readFileSync(file, 'utf-8'); + for (const m of text.matchAll(TOKEN)) { + const tok = m[1]; + if (known.has(tok) || NOT_SENTINELS.has(tok)) continue; + offenders.push(`${path.relative(ROOT, file)}: ${tok}`); + } + } + expect(offenders).toEqual([]); + }); +}); diff --git a/test/egress-receipt-wiring.test.ts b/test/egress-receipt-wiring.test.ts index 0ac0ae67e..207c5de4b 100644 --- a/test/egress-receipt-wiring.test.ts +++ b/test/egress-receipt-wiring.test.ts @@ -143,6 +143,14 @@ const SCANNER_EXEMPT: Record = { 'skill prose templates — agent-executed instructions rendered into SKILL.md, not gstack binaries', }; +// Documented non-sink (not an exemption; nothing here matches the scanner): +// bin/gstack-design-detect.ts spawns a third-party engine binary the USER +// installed (impeccable) over local file paths under the repo root or the +// design-report allow-list. URL targets are refused, so gstack never asks the +// engine to touch the network; the engine's own network behavior is not audited +// by gstack (NOTICE.md says so). This is a class the tripwire cannot see — +// a spawned binary, not curl/fetch/git — recorded here so the posture is explicit. + function isExempt(rel: string): string | undefined { for (const [key, reason] of Object.entries(SCANNER_EXEMPT)) { if (rel === key || rel.startsWith(`${key}/`)) return reason; diff --git a/test/fixtures/fake-impeccable.ts b/test/fixtures/fake-impeccable.ts new file mode 100755 index 000000000..792f93ac5 --- /dev/null +++ b/test/fixtures/fake-impeccable.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env bun +/** + * fake-impeccable — a stand-in for the impeccable engine binary in tests. + * + * Behaves like `impeccable detect --json `: prints a findings JSON + * array on stdout and exits with the engine's code. Everything is driven by env + * so tests never edit this file: + * FAKE_IMPECCABLE_OUTPUT path of the JSON (default: impeccable-detect-sample.json beside this file) + * FAKE_IMPECCABLE_EXIT exit code (default 2 = findings) + * FAKE_IMPECCABLE_LOG append one JSON line per invocation: {argv, cwd, stdinIsTTY} + * FAKE_IMPECCABLE_SLEEP_MS sleep before printing (timeout tests) + * FAKE_IMPECCABLE_STDERR text to print on stderr (diagnostics tests) + * FAKE_IMPECCABLE_RAW print this exact text instead of the JSON file (parse-error tests) + * FAKE_IMPECCABLE_REPEAT repeat the sample findings N times (display-cap tests) + * Spawned directly (shebang), so the spawn-based tests are POSIX-only. + */ +import * as fs from 'fs'; +import * as path from 'path'; + +const env = process.env; +if (env.FAKE_IMPECCABLE_LOG) { + fs.appendFileSync(env.FAKE_IMPECCABLE_LOG, JSON.stringify({ argv: process.argv.slice(2), cwd: process.cwd(), stdinIsTTY: Boolean(process.stdin.isTTY) }) + '\n'); +} +const sleep = Number(env.FAKE_IMPECCABLE_SLEEP_MS ?? 0); +if (sleep > 0) Bun.sleepSync(sleep); +if (env.FAKE_IMPECCABLE_STDERR) process.stderr.write(env.FAKE_IMPECCABLE_STDERR + '\n'); + +if (env.FAKE_IMPECCABLE_RAW !== undefined) { + process.stdout.write(env.FAKE_IMPECCABLE_RAW); +} else { + const file = env.FAKE_IMPECCABLE_OUTPUT ?? path.join(import.meta.dir, 'impeccable-detect-sample.json'); + const text = fs.readFileSync(file, 'utf-8'); + const repeat = Number(env.FAKE_IMPECCABLE_REPEAT ?? 1); + if (repeat > 1) { + const arr = JSON.parse(text) as unknown[]; + const out: unknown[] = []; + for (let i = 0; i < repeat; i++) for (const f of arr) out.push({ ...(f as object), line: i }); + process.stdout.write(JSON.stringify(out, null, 2) + '\n'); + } else { + process.stdout.write(text); + } +} +// exitCode, not process.exit(): large outputs must flush through the pipe first. +process.exitCode = Number(env.FAKE_IMPECCABLE_EXIT ?? 2); diff --git a/test/frontend-scope.test.ts b/test/frontend-scope.test.ts new file mode 100644 index 000000000..fe4eff179 --- /dev/null +++ b/test/frontend-scope.test.ts @@ -0,0 +1,86 @@ +/** + * lib/frontend-scope.ts mirrors the m_frontend arm of bin/gstack-diff-scope. + * Pure cases run everywhere; the parity case runs the bash script in a temp + * repo (POSIX only) so the two implementations cannot drift silently. + */ +import { describe, test, expect } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { isFrontendPath } from '../lib/frontend-scope'; + +const ROOT = path.join(import.meta.dir, '..'); +const POSIX = process.platform !== 'win32'; + +const SAMPLES: Array<[string, boolean]> = [ + ['src/components/Button.tsx', true], + ['src/Button.jsx', true], + ['pages/index.vue', true], + ['app/Widget.svelte', true], + ['site/page.astro', true], + ['styles/main.css', true], + ['css/a.scss', true], + ['x/y/theme.less', true], + ['x/a.sass', true], + ['x/a.pcss', true], + ['app/views/users/show.html.erb', true], + ['templates/a.haml', true], + ['templates/a.slim', true], + ['templates/a.hbs', true], + ['views/a.ejs', true], + ['public/index.html', true], + ['tailwind.config.js', true], + ['postcss.config.cjs', true], + ['app/assets/stylesheets/app.css', true], + ['lib/util/components/helper.rb', true], + ['lib/server.ts', false], + ['src/api/route.js', false], + ['README.md', false], + ['package.json', false], + ['test/foo.test.ts', false], + ['components.md', false], +]; + +describe('isFrontendPath', () => { + test.each(SAMPLES)('%s → %p', (p, expected) => { + expect(isFrontendPath(p)).toBe(expected); + }); + + test('normalizes leading ./ and backslashes', () => { + expect(isFrontendPath('./styles/a.css')).toBe(true); + expect(isFrontendPath('src\\components\\A.tsx')).toBe(true); + }); +}); + +describe.skipIf(!POSIX)('parity with bin/gstack-diff-scope', () => { + test('SCOPE_FRONTEND agrees with isFrontendPath for every sample, one file per diff', () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-scope-parity-')); + const git = (...a: string[]) => { + const r = spawnSync('git', a, { cwd: dir, encoding: 'utf-8' }); + if (r.status !== 0) throw new Error(r.stderr); + }; + try { + git('init', '-q', '-b', 'main'); + git('config', 'user.email', 't@example.com'); + git('config', 'user.name', 't'); + fs.writeFileSync(path.join(dir, 'base.txt'), 'x\n'); + git('add', '-A'); git('commit', '-q', '-m', 'base'); + const mismatches: string[] = []; + for (const [rel, expected] of SAMPLES) { + git('checkout', '-q', '-b', 'probe'); + const full = path.join(dir, rel); + fs.mkdirSync(path.dirname(full), { recursive: true }); + fs.writeFileSync(full, '/* x */\n'); + git('add', '-A'); git('commit', '-q', '-m', rel); + const r = spawnSync('bash', [path.join(ROOT, 'bin', 'gstack-diff-scope'), 'main'], { cwd: dir, encoding: 'utf-8' }); + const bashSays = /SCOPE_FRONTEND=true/.test(r.stdout); + if (bashSays !== expected) mismatches.push(`${rel}: bash=${bashSays} ts=${expected}`); + git('checkout', '-q', 'main'); git('branch', '-q', '-D', 'probe'); + } + expect(mismatches).toEqual([]); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); +}); diff --git a/test/gstack-config-defaults.test.ts b/test/gstack-config-defaults.test.ts index 781ee64b9..3b356d3f4 100644 --- a/test/gstack-config-defaults.test.ts +++ b/test/gstack-config-defaults.test.ts @@ -139,3 +139,29 @@ describe('gstack-config defaults (gate, free)', () => { expect(get('transcript_ingest_mode').out).toBe('off'); }); }); + +describe('design_detector (auto|off, rejecting validator)', () => { + test('defaults to auto', () => { + expect(get('design_detector')).toEqual({ out: 'auto', code: 0 }); + }); + + test('set to an invalid value exits 1 and leaves the file unchanged', () => { + const file = path.join(STATE, 'config.yaml'); + const before = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null; + const r = spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'maybe'], { + encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE }, + }); + expect(r.status).toBe(1); + expect(r.stderr).toContain("design_detector 'maybe' not recognized"); + const after = fs.existsSync(file) ? fs.readFileSync(file, 'utf-8') : null; + expect(after).toBe(before); + expect(get('design_detector').out).toBe('auto'); + }); + + test('set off / set auto round-trip', () => { + spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'off'], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } }); + expect(get('design_detector').out).toBe('off'); + spawnSync('bash', [CONFIG_BIN, 'set', 'design_detector', 'auto'], { encoding: 'utf-8', timeout: 30_000, env: { ...process.env, GSTACK_STATE_ROOT: STATE } }); + expect(get('design_detector').out).toBe('auto'); + }); +}); diff --git a/test/gstack-design-detect.test.ts b/test/gstack-design-detect.test.ts new file mode 100644 index 000000000..cd28e7117 --- /dev/null +++ b/test/gstack-design-detect.test.ts @@ -0,0 +1,471 @@ +/** + * bin/gstack-design-detect.ts — hermetic tests against the fake engine. + * + * Every case runs the wrapper in a temp git repo with a scrubbed env (temp + * GSTACK_HOME, temp IMPECCABLE_HOME, PATH reduced to bun + system dirs, no + * IMPECCABLE_BIN unless the case sets it). The fake engine + * (test/fixtures/fake-impeccable.ts) is spawned directly through its shebang, + * so spawn-backed cases are POSIX-only; the pure-function cases run everywhere. + */ +import { describe, test, expect, beforeAll, afterAll } from 'bun:test'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { spawnSync } from 'child_process'; +import { SENTINEL, DETECT_LIMITS, UNTRUSTED_BEGIN } from '../lib/design-detect-contract'; + +const ROOT = path.join(import.meta.dir, '..'); +const BIN = path.join(ROOT, 'bin', 'gstack-design-detect.ts'); +const FAKE_SRC = path.join(ROOT, 'test', 'fixtures', 'fake-impeccable.ts'); +const SAMPLE = path.join(ROOT, 'test', 'fixtures', 'impeccable-detect-sample.json'); +const POSIX = process.platform !== 'win32'; +const BUN_DIR = path.dirname(process.execPath); + +let SANDBOX: string; // holds everything the tests create +let REPO: string; // temp git repo (cwd for the wrapper) +let FAKE: string; // executable copy of the fake engine OUTSIDE the repo +let GSTACK_HOME: string; // temp gstack home (config.yaml, analytics) +let IMPECCABLE_HOME: string; + +function git(cwd: string, ...args: string[]) { + const r = spawnSync('git', args, { cwd, encoding: 'utf-8', timeout: 30_000 }); + if (r.status !== 0) throw new Error(`git ${args.join(' ')} failed: ${r.stderr}`); + return r.stdout.trim(); +} + +beforeAll(() => { + SANDBOX = fs.mkdtempSync(path.join(os.tmpdir(), 'gstack-design-detect-')); + REPO = path.join(SANDBOX, 'repo'); + fs.mkdirSync(REPO); + git(REPO, 'init', '-q', '-b', 'main'); + git(REPO, 'config', 'user.email', 't@example.com'); + git(REPO, 'config', 'user.name', 't'); + fs.mkdirSync(path.join(REPO, 'src', 'components'), { recursive: true }); + fs.writeFileSync(path.join(REPO, 'src', 'components', 'Card.tsx'), 'export const Card = () =>
;\n'); + fs.writeFileSync(path.join(REPO, 'src', 'styles.css'), '.hero { background: linear-gradient(135deg, #6366f1, #8b5cf6); }\n'); + fs.writeFileSync(path.join(REPO, 'src', 'server.ts'), 'export const x = 1;\n'); + fs.writeFileSync(path.join(REPO, 'README.md'), '# t\n'); + git(REPO, 'add', '-A'); + git(REPO, 'commit', '-q', '-m', 'base'); + FAKE = path.join(SANDBOX, 'engines', 'fake-impeccable'); + fs.mkdirSync(path.dirname(FAKE), { recursive: true }); + fs.copyFileSync(FAKE_SRC, FAKE); + fs.chmodSync(FAKE, 0o755); + GSTACK_HOME = path.join(SANDBOX, 'gstack-home'); + IMPECCABLE_HOME = path.join(SANDBOX, 'impeccable-home'); + fs.mkdirSync(GSTACK_HOME); + fs.mkdirSync(IMPECCABLE_HOME); +}); + +afterAll(() => { + fs.rmSync(SANDBOX, { recursive: true, force: true }); +}); + +interface RunOpts { env?: Record; cwd?: string } +function run(args: string[], opts: RunOpts = {}) { + const env: Record = { + PATH: [BUN_DIR, '/usr/bin', '/bin', '/usr/local/bin'].join(path.delimiter), + HOME: path.join(SANDBOX, 'fake-home'), + GSTACK_HOME, + IMPECCABLE_HOME, + FAKE_IMPECCABLE_OUTPUT: SAMPLE, + }; + for (const [k, v] of Object.entries(opts.env ?? {})) { + if (v === undefined) delete env[k]; else env[k] = v; + } + const r = spawnSync(process.execPath, ['--no-env-file', 'run', BIN, ...args], { + cwd: opts.cwd ?? REPO, encoding: 'utf-8', timeout: 60_000, env, + }); + return { code: r.status ?? -1, out: r.stdout ?? '', err: r.stderr ?? '' }; +} + +function lines(s: string) { return s.split('\n').filter(Boolean); } + +describe('probe', () => { + test('empty environment → NOT_AVAILABLE, skill/hook absent, no hint', () => { + const r = run(['probe', '--host', 'claude']); + expect(r.code).toBe(0); + const l = lines(r.out); + expect(l[0]).toBe(SENTINEL.NOT_AVAILABLE); + expect(l).toContain(`${SENTINEL.SKILL}: absent`); + expect(l).toContain(`${SENTINEL.HOOK}: absent`); + expect(r.out).not.toContain(SENTINEL.HINT); + expect(r.out).not.toContain('npx impeccable'); + }); + + test.skipIf(!POSIX)('IMPECCABLE_BIN pointing at an executable outside the repo → READY', () => { + const r = run(['probe'], { env: { IMPECCABLE_BIN: FAKE } }); + expect(lines(r.out)[0]).toBe(`${SENTINEL.READY}: ${fs.realpathSync(FAKE)}`); + // the fake has no version source, so it is reported untested by content hash + expect(r.out).toMatch(new RegExp(`${SENTINEL.ENGINE_UNTESTED}: sha256:[0-9a-f]{12}`)); + }); + + test('IMPECCABLE_BIN inside the repository is ignored', () => { + const inRepo = path.join(REPO, 'tools', 'impeccable'); + fs.mkdirSync(path.dirname(inRepo), { recursive: true }); + fs.writeFileSync(inRepo, '#!/bin/sh\necho MARKER > marker.txt\n'); + fs.chmodSync(inRepo, 0o755); + const r = run(['probe'], { env: { IMPECCABLE_BIN: inRepo } }); + expect(r.out).toContain(`${SENTINEL.ENV_IGNORED}: IMPECCABLE_BIN resolves inside the repository`); + expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + expect(fs.existsSync(path.join(REPO, 'marker.txt'))).toBe(false); + fs.rmSync(path.join(REPO, 'tools'), { recursive: true, force: true }); + }); + + test('a cwd .env naming IMPECCABLE_BIN is never loaded (--no-env-file) and never executed', () => { + const marker = path.join(SANDBOX, 'env-marker.txt'); + const evil = path.join(SANDBOX, 'evil-engine'); + fs.writeFileSync(evil, `#!/bin/sh\necho MARKER > ${JSON.stringify(marker)}\n`); + fs.chmodSync(evil, 0o755); + fs.writeFileSync(path.join(REPO, '.env'), `IMPECCABLE_BIN=${evil}\n`); + try { + const r = run(['probe']); + expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + expect(fs.existsSync(marker)).toBe(false); + } finally { + fs.rmSync(path.join(REPO, '.env'), { force: true }); + } + }); + + test('relative IMPECCABLE_BIN is ignored', () => { + const r = run(['probe'], { env: { IMPECCABLE_BIN: 'engines/fake-impeccable' } }); + expect(r.out).toContain(`${SENTINEL.ENV_IGNORED}: IMPECCABLE_BIN is not an absolute path`); + }); + + test.skipIf(!POSIX)('cache under IMPECCABLE_HOME picks the newest semver, skipping non-semver dirs', () => { + for (const v of ['0.1.3', '0.1.10', 'latest', '0.2.0-rc1']) { + const dir = path.join(IMPECCABLE_HOME, 'bin', v); + fs.mkdirSync(dir, { recursive: true }); + fs.copyFileSync(FAKE, path.join(dir, 'impeccable')); + fs.chmodSync(path.join(dir, 'impeccable'), 0o755); + } + try { + const r = run(['probe']); + expect(lines(r.out)[0]).toBe(`${SENTINEL.READY}: ${path.join(fs.realpathSync(IMPECCABLE_HOME), 'bin', '0.2.0-rc1', 'impeccable')}`); + expect(r.out).toContain(`${SENTINEL.ENGINE_UNTESTED}: 0.2.0-rc1`); + fs.rmSync(path.join(IMPECCABLE_HOME, 'bin', '0.2.0-rc1'), { recursive: true }); + const r2 = run(['probe']); + expect(lines(r2.out)[0]).toContain(path.join('bin', '0.1.10', 'impeccable')); + fs.rmSync(path.join(IMPECCABLE_HOME, 'bin', '0.1.10'), { recursive: true }); + const r3 = run(['probe']); + expect(lines(r3.out)[0]).toContain(path.join('bin', '0.1.3', 'impeccable')); + expect(r3.out).not.toContain(SENTINEL.ENGINE_UNTESTED); + } finally { + fs.rmSync(path.join(IMPECCABLE_HOME, 'bin'), { recursive: true, force: true }); + } + }); + + test('a #! shim named impeccable on PATH is launcher-present, not READY → NOT_CACHED with the npx hint', () => { + const shimDir = path.join(SANDBOX, 'shim-bin'); + fs.mkdirSync(shimDir, { recursive: true }); + const shim = path.join(shimDir, 'impeccable'); + fs.writeFileSync(shim, '#!/usr/bin/env node\nconsole.log("would download");\n'); + fs.chmodSync(shim, 0o755); + const r = run(['probe'], { env: { PATH: [BUN_DIR, shimDir, '/usr/bin', '/bin'].join(path.delimiter) } }); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${path.join(fs.realpathSync(shimDir), 'impeccable')}`); + expect(r.out).toContain(`${SENTINEL.HINT}: impeccable is installed but its engine is not cached`); + expect(r.out).toContain('npx impeccable detect --help'); + expect(r.out).toContain('gstack-config set design_detector off'); + expect(r.out).not.toContain('would download'); + }); + + test.skipIf(!POSIX)('skill install: launcher without engine → NOT_CACHED naming the launcher; with sibling engine → READY + VERSION', () => { + const scripts = path.join(REPO, '.claude', 'skills', 'impeccable', 'scripts'); + fs.mkdirSync(scripts, { recursive: true }); + fs.writeFileSync(path.join(REPO, '.claude', 'skills', 'impeccable', 'SKILL.md'), '# impeccable\n'); + fs.writeFileSync(path.join(scripts, 'impeccable'), '#!/bin/sh\necho "would download"\n'); + fs.chmodSync(path.join(scripts, 'impeccable'), 0o755); + fs.writeFileSync(path.join(scripts, 'VERSION'), '0.1.3\n'); + try { + const r = run(['probe']); + expect(lines(r.out)[0]).toBe(`${SENTINEL.NOT_CACHED}: ${path.join(scripts, 'impeccable')}`); + expect(r.out).toContain(`${SENTINEL.SKILL}: present`); + expect(r.out).toContain(`run \`${path.join(scripts, 'impeccable')} detect --help\` once`); + expect(r.out).not.toContain('npx impeccable'); + expect(r.out).not.toContain('would download'); + + const sib = path.join(scripts, 'bin', `${process.platform}-${process.arch}`); + fs.mkdirSync(sib, { recursive: true }); + fs.copyFileSync(FAKE, path.join(sib, 'impeccable')); + fs.chmodSync(path.join(sib, 'impeccable'), 0o755); + const r2 = run(['probe']); + expect(lines(r2.out)[0]).toBe(`${SENTINEL.READY}: ${path.join(sib, 'impeccable')}`); + expect(r2.out).not.toContain(SENTINEL.ENGINE_UNTESTED); + expect(r2.out).not.toContain(SENTINEL.HINT); + } finally { + fs.rmSync(path.join(REPO, '.claude'), { recursive: true, force: true }); + } + }); + + test('design_detector: off → DISABLED and nothing else is probed', () => { + fs.writeFileSync(path.join(GSTACK_HOME, 'config.yaml'), 'proactive: true\ndesign_detector: off\n'); + try { + const r = run(['probe'], { env: { IMPECCABLE_BIN: FAKE } }); + expect(lines(r.out)[0]).toBe(SENTINEL.DISABLED); + expect(r.out).not.toContain(SENTINEL.READY); + } finally { + fs.rmSync(path.join(GSTACK_HOME, 'config.yaml')); + } + }); + + test('hook detection is host-aware; hook.enabled=false turns it off; malformed settings → unknown', () => { + const claudeDir = path.join(REPO, '.claude'); + fs.mkdirSync(claudeDir, { recursive: true }); + fs.writeFileSync(path.join(claudeDir, 'settings.local.json'), JSON.stringify({ + hooks: { PostToolUse: [{ matcher: 'Edit|Write', hooks: [{ type: 'command', command: '.claude/skills/impeccable/scripts/impeccable hook' }] }] }, + })); + fs.mkdirSync(path.join(REPO, '.cursor'), { recursive: true }); + fs.writeFileSync(path.join(REPO, '.cursor', 'hooks.json'), JSON.stringify({ hooks: { afterFileEdit: [{ command: '.cursor/skills/impeccable/scripts/impeccable hook' }] } })); + try { + const claude = run(['probe', '--host', 'claude']); + expect(claude.out).toContain(`${SENTINEL.HOOK}: present`); + expect(claude.out).toContain(`${SENTINEL.HOOK_OTHER}: cursor`); + const codex = run(['probe', '--host', 'codex']); + expect(codex.out).toContain(`${SENTINEL.HOOK}: absent`); + expect(codex.out).toContain(`${SENTINEL.HOOK_OTHER}: claude,cursor`); + + fs.mkdirSync(path.join(REPO, '.impeccable'), { recursive: true }); + fs.writeFileSync(path.join(REPO, '.impeccable', 'config.json'), JSON.stringify({ hook: { enabled: false } })); + const off = run(['probe', '--host', 'claude']); + expect(off.out).toContain(`${SENTINEL.HOOK}: absent`); + fs.rmSync(path.join(REPO, '.impeccable'), { recursive: true }); + + fs.writeFileSync(path.join(claudeDir, 'settings.local.json'), '{ not json'); + const bad = run(['probe', '--host', 'claude']); + expect(bad.out).toContain(`${SENTINEL.HOOK}: unknown`); + } finally { + fs.rmSync(claudeDir, { recursive: true, force: true }); + fs.rmSync(path.join(REPO, '.cursor'), { recursive: true, force: true }); + } + }); + + test('ignored rules and files are the union of config.json and config.local.json; malformed config is reported', () => { + const dir = path.join(REPO, '.impeccable'); + fs.mkdirSync(dir, { recursive: true }); + fs.writeFileSync(path.join(dir, 'config.json'), JSON.stringify({ detector: { ignoreRules: ['overused-font'], ignoreFiles: ['src/legacy/**'] } })); + fs.writeFileSync(path.join(dir, 'config.local.json'), JSON.stringify({ detector: { ignoreRules: ['em-dash-overuse', 'overused-font'] } })); + try { + const r = run(['probe']); + expect(r.out).toContain(`${SENTINEL.IGNORED_RULES}: overused-font,em-dash-overuse`); + expect(r.out).toContain(`${SENTINEL.IGNORED_FILES}: src/legacy/**`); + fs.writeFileSync(path.join(dir, 'config.local.json'), '{{{'); + const bad = run(['probe']); + expect(bad.out).toContain(`${SENTINEL.CONFIG_UNREADABLE}: ${path.join(dir, 'config.local.json')}`); + expect(bad.out).toContain(`${SENTINEL.IGNORED_RULES}: overused-font`); + expect(bad.code).toBe(0); + } finally { + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + test('probe and scan append one analytics line each to the gstack home', () => { + const log = path.join(GSTACK_HOME, 'analytics', 'design-detector.jsonl'); + fs.rmSync(log, { force: true }); + run(['probe']); + run(['scan', 'src/styles.css']); + const recs = fs.readFileSync(log, 'utf-8').trim().split('\n').map(l => JSON.parse(l)); + expect(recs.length).toBe(2); + expect(recs[0].verb).toBe('probe'); + expect(recs[1].verb).toBe('scan'); + for (const r of recs) { expect(typeof r.ts).toBe('string'); expect(JSON.stringify(r)).not.toContain('styles.css'); } + }); + + test('--verbose prints probe steps', () => { + const r = run(['probe', '--verbose']); + expect(r.out).toContain('PROBE_STEP: design_detector=auto'); + expect(r.out).toContain('PROBE_STEP: PATH walk'); + }); +}); + +describe('scan', () => { + test('not READY → prints the probe lines, exit 0, engine never needed', () => { + const r = run(['scan', 'src/styles.css']); + expect(r.code).toBe(0); + expect(lines(r.out)[0]).toBe(SENTINEL.NOT_AVAILABLE); + }); + + test.skipIf(!POSIX)('URL and out-of-root targets are refused and the engine is never spawned', () => { + const log = path.join(SANDBOX, 'argv.log'); + fs.rmSync(log, { force: true }); + const outside = path.join(SANDBOX, 'outside.html'); + fs.writeFileSync(outside, ''); + const r = run(['scan', 'https://example.com', 'file:///etc/passwd', outside, '/etc/hostname'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_LOG: log } }); + expect(r.code).toBe(0); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: https://example.com (URL targets are never scanned)`); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: file:///etc/passwd`); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${outside} (outside the repository`); + expect(r.err).toContain(SENTINEL.DETECT_NO_TARGETS); + expect(fs.existsSync(log)).toBe(false); + }); + + test.skipIf(!POSIX)('a symlink under designs/ pointing outside is refused; a real file under designs/ is accepted', () => { + const designs = path.join(GSTACK_HOME, 'projects', 'x', 'designs', 'design-audit-20260908', 'dom'); + fs.mkdirSync(designs, { recursive: true }); + fs.writeFileSync(path.join(designs, 'home.dom.html'), ''); + fs.symlinkSync('/etc', path.join(designs, 'etc-link')); + const notDesigns = path.join(GSTACK_HOME, 'projects', 'x', 'other.html'); + fs.writeFileSync(notDesigns, ''); + const log = path.join(SANDBOX, 'argv2.log'); + fs.rmSync(log, { force: true }); + try { + const r = run(['scan', '--format', 'gstack', path.join(designs, 'home.dom.html'), path.join(designs, 'etc-link'), notDesigns], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_LOG: log } }); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${path.join(designs, 'etc-link')}`); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: ${notDesigns}`); + const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[]; + expect(argv.slice(0, 2)).toEqual(['detect', '--json']); + expect(argv.slice(2)).toEqual([fs.realpathSync(path.join(designs, 'home.dom.html'))]); + expect(r.code).toBe(2); + } finally { + fs.rmSync(path.join(GSTACK_HOME, 'projects'), { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('engine invoked with stdin ignored, absolute realpaths, repo cwd; exit code passes through', () => { + const log = path.join(SANDBOX, 'argv3.log'); + fs.rmSync(log, { force: true }); + for (const code of ['0', '1', '2']) { + const r = run(['scan', 'src/styles.css', './src/components/Card.tsx'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_LOG: log, FAKE_IMPECCABLE_EXIT: code } }); + expect(r.code).toBe(Number(code)); + expect(r.err).toContain(`${SENTINEL.DETECT_EXIT}: ${code}`); + } + const rec = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]); + expect(rec.stdinIsTTY).toBe(false); + expect(rec.cwd).toBe(fs.realpathSync(REPO)); + expect(rec.argv.slice(2).every((a: string) => path.isAbsolute(a))).toBe(true); + expect(rec.argv.slice(2)).toEqual([fs.realpathSync(path.join(REPO, 'src', 'styles.css')), fs.realpathSync(path.join(REPO, 'src', 'components', 'Card.tsx'))]); + }); + + test.skipIf(!POSIX)('--format raw is a byte-for-byte passthrough of the engine stdout', () => { + const r = run(['scan', '--format', 'raw', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE } }); + expect(r.out).toBe(fs.readFileSync(SAMPLE, 'utf-8')); + expect(r.code).toBe(2); + }); + + test.skipIf(!POSIX)('--format gstack normalizes by catalog: tiers, impacts, handoffs, advisory, unmapped; stdout is one JSON document', () => { + const custom = path.join(SANDBOX, 'custom.json'); + fs.writeFileSync(custom, JSON.stringify([ + { antipattern: 'overused-font', name: 'Overused font', description: 'x', severity: 'warning', category: 'slop', file: 'a.css', line: 3, snippet: 'font-family: Inter' }, + { antipattern: 'em-dash-overuse', name: 'Em dash', description: 'x', severity: 'warning', category: 'slop', file: 'a.html', line: 0, snippet: '— — —' }, + { antipattern: 'brand-new-rule', name: 'New', description: 'x'.repeat(500), severity: 'warning', category: 'slop', file: 'a.html', line: 1, snippet: 'y'.repeat(500) }, + { antipattern: 'Bad Id!!', description: 'x', category: 'weird', file: 'a.html', line: 'nope', snippet: 'ctl\x01chars\x02 here' }, + { antipattern: 'low-contrast', name: 'Low contrast text', description: 'x', severity: 'warning', category: 'quality', file: 'a.html', line: 0, snippet: '3:1' }, + ])); + const r = run(['scan', '--format', 'gstack', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_OUTPUT: custom } }); + const doc = JSON.parse(r.out); + expect(doc.schemaVersion).toBe(1); + expect(doc.total).toBe(5); + expect(doc.advisory).toBe(1); + expect(doc.counted).toBe(4); + const by = Object.fromEntries(doc.findings.map((f: any) => [f.impeccableId, f])); + expect(by['overused-font']).toMatchObject({ tier: 'ask', impact: 'medium', handoff: 'typeset', kind: 'slop', category: 'type', advisory: false }); + expect(by['em-dash-overuse']).toMatchObject({ advisory: true, tier: 'possible', impact: 'polish' }); + expect(by['brand-new-rule']).toMatchObject({ unmapped: true, impact: 'medium', tier: 'ask', kind: 'slop' }); + expect(by['brand-new-rule'].message.length).toBeLessThanOrEqual(DETECT_LIMITS.field.message); + expect(by['brand-new-rule'].snippet.length).toBeLessThanOrEqual(DETECT_LIMITS.field.snippet); + const weird = doc.findings.find((f: any) => f.id === 'unmapped'); + expect(weird.line).toBe(0); + expect(weird.snippet).toBe('ctlchars here'); + expect(weird.kind).toBe('unknown'); + expect(by['low-contrast']).toMatchObject({ impact: 'high', kind: 'quality' }); + // stderr carries the fenced DETECT_TOP block and the summary; advisory excluded from counts. + expect(r.err).toContain(UNTRUSTED_BEGIN); + expect(r.err).toContain(`${SENTINEL.DETECT_TOP} total=5 rules=4`); + expect(r.err).toMatch(/\[low-contrast\] impact=high tier=ask count=1 handoff=\/impeccable colorize/); + expect(r.err).toContain(`${SENTINEL.DETECT_SUMMARY}: total=5 slop=2 quality=1 advisory=1 ignored=0 high=1 medium=3 polish=0`); + expect(r.err).not.toMatch(/\[em-dash-overuse\]/); + }); + + test.skipIf(!POSIX)('display cap: 500+ findings → DETECT_TOP shows 50 locations and the total; JSON keeps all', () => { + const r = run(['scan', '--format', 'gstack', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_REPEAT: '84' } }); + const doc = JSON.parse(r.out); + expect(doc.total).toBe(504); + expect(doc.findings.length).toBe(504); + expect(doc.truncated).toBe(false); + const locations = r.err.split('\n').filter(l => /^ {2}\S.*:\d+ {2}/.test(l)); + expect(locations.length).toBe(DETECT_LIMITS.topLocations); + expect(r.err).toContain(`${SENTINEL.DETECT_TOP} total=504`); + expect(r.err).toContain('more locations in the JSON'); + }); + + test.skipIf(!POSIX)('ignored rules from config are reported in the summary, never re-derived from findings', () => { + fs.mkdirSync(path.join(REPO, '.impeccable'), { recursive: true }); + fs.writeFileSync(path.join(REPO, '.impeccable', 'config.json'), JSON.stringify({ detector: { ignoreRules: ['marketing-buzzword', 'side-tab'] } })); + try { + const r = run(['scan', '--format', 'gstack', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE } }); + const doc = JSON.parse(r.out); + expect(doc.ignoredRules).toEqual(['marketing-buzzword', 'side-tab']); + expect(r.err).toContain('ignored=2'); + } finally { + fs.rmSync(path.join(REPO, '.impeccable'), { recursive: true, force: true }); + } + }); + + test.skipIf(!POSIX)('engine that hangs is killed at the timeout → DETECT_TIMEOUT, exit 1, no orphan', () => { + const t0 = Date.now(); + const r = run(['scan', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_SLEEP_MS: '20000', GSTACK_DESIGN_DETECT_TIMEOUT_MS: '300' } }); + expect(Date.now() - t0).toBeLessThan(10_000); + expect(r.code).toBe(1); + expect(r.err).toContain(`${SENTINEL.DETECT_TIMEOUT}: 300ms`); + }); + + test.skipIf(!POSIX)('engine printing half a JSON document → DETECT_PARSE_ERROR, exit 1', () => { + const r = run(['scan', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_RAW: '[{"antipattern": "side-tab", "fi', FAKE_IMPECCABLE_EXIT: '2' } }); + expect(r.code).toBe(1); + expect(r.err).toContain(`${SENTINEL.DETECT_PARSE_ERROR}: [{"antipattern": "side-tab", "fi`); + }); + + test.skipIf(!POSIX)('engine stderr diagnostics are forwarded sanitized', () => { + const r = run(['scan', 'src/styles.css'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_STDERR: 'impeccable detect: could not read linked stylesheet x.css' } }); + expect(r.err).toContain('ENGINE_STDERR: impeccable detect: could not read linked stylesheet x.css'); + }); + + test.skipIf(!POSIX)('--changed derives frontend targets from git (committed, staged, untracked), never backend files', () => { + git(REPO, 'checkout', '-q', '-b', 'feature'); + fs.writeFileSync(path.join(REPO, 'src', 'components', 'Card.tsx'), 'export const Card = () =>
;\n'); + fs.writeFileSync(path.join(REPO, 'src', 'server.ts'), 'export const x = 2;\n'); + git(REPO, 'add', '-A'); + git(REPO, 'commit', '-q', '-m', 'change'); + fs.mkdirSync(path.join(REPO, 'styles'), { recursive: true }); + fs.writeFileSync(path.join(REPO, 'styles', 'new.css'), 'body { color: red }\n'); // untracked frontend + fs.writeFileSync(path.join(REPO, 'notes.md'), 'x\n'); // untracked non-frontend + const log = path.join(SANDBOX, 'argv4.log'); + fs.rmSync(log, { force: true }); + try { + const r = run(['scan', '--changed', 'main', '--format', 'gstack'], { env: { IMPECCABLE_BIN: FAKE, FAKE_IMPECCABLE_LOG: log } }); + expect(r.code).toBe(2); + const argv = JSON.parse(fs.readFileSync(log, 'utf-8').trim().split('\n')[0]).argv as string[]; + const rel = argv.slice(2).map(a => path.relative(fs.realpathSync(REPO), a)).sort(); + expect(rel).toEqual(['src/components/Card.tsx', 'styles/new.css']); + } finally { + fs.rmSync(path.join(REPO, 'styles'), { recursive: true, force: true }); + fs.rmSync(path.join(REPO, 'notes.md'), { force: true }); + git(REPO, 'checkout', '-q', 'main'); + git(REPO, 'branch', '-q', '-D', 'feature'); + } + }); + + test.skipIf(!POSIX)('--changed outside a git repository is refused', () => { + const plain = path.join(SANDBOX, 'plain'); + fs.mkdirSync(plain, { recursive: true }); + const r = run(['scan', '--changed', 'main'], { env: { IMPECCABLE_BIN: FAKE, GIT_CEILING_DIRECTORIES: SANDBOX }, cwd: plain }); + expect(r.err).toContain(`${SENTINEL.DETECT_REFUSED}: main (not a repository)`); + expect(r.code).toBe(0); + }); +}); + +describe('rules', () => { + test('prints every mapped id with kind/impact/tier/handoff and the tested engine versions', () => { + const r = run(['rules']); + expect(r.code).toBe(0); + expect(r.out).toContain('61 detector rules mapped'); + expect(r.out).toContain('tested engine versions: 0.1.3'); + expect(r.out).toMatch(/^side-tab\tslop\tmedium\task\tpolish\t/m); + expect(r.out).toMatch(/^low-contrast\tquality\thigh\task\tcolorize\t/m); + }); + + test('unknown verb prints usage and exits 2', () => { + const r = run(['bogus']); + expect(r.code).toBe(2); + expect(r.err).toContain('usage:'); + }); +});