diff --git a/CHANGELOG.md b/CHANGELOG.md index c7a43d6..dcb843b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,22 @@ All notable changes to `@fusengine/harness`. Format: [Keep a Changelog](https:// ## [Unreleased] +## [0.1.107] - 2026-10-12 + +### Added + +- **apex: the deny lists every missing prerequisite at once** (`src/policy/apex-missing.ts`, `src/runtime/gate-apex.ts`) — instead of stopping at the first failing gate, the deny now lists all missing prerequisites; the PRE-block lists only ref-free gates and never loads refs. Same block/allow verdict, same title; a single failure is byte-identical to before. Tests: `apex-deny-all-missing`. +- **solid: detect react, tanstack-start, astro, java, csharp, ruby and generic projects; the limit follows `FUSE_SOLID_MAX_LINES`** (`src/runtime/lifecycle/solid-detect.ts`, `solid-detect-extra.ts`) — only projects previously typed "unknown" get a type. `SOLID_FILE_LIMIT` = `FUSE_SOLID_MAX_LINES` when set, else the profile default; output with the variable unset is unchanged. Tests: `solid-detect-stacks`. + +### Fixed + +- **guards: the perl timeout idiom and pure reads no longer hit "perl in-place edit"** (`src/policy/guards/bash-write.ts`, `bash-write-patterns.ts`, `bash-write-inline.ts`, `bash-write-perl.ts`, `bash-write-perl-words.ts`) — verdict identical to 0.1.106 except two exemptions: pure-read programs whose `-p`/`-i` tokens are file names or program text, and `perl -e 'alarm N; exec @ARGV'` with a bare claude/codex/kimi/cursor/cursor-agent child (the existing exec CONFIRM still applies). 1387-command differential with real perl under 8 shells: 0 in-place edit loosened, 0 stricter. Tests: `bash-write-perl-inplace`, `bash-write-perl-narrow`. + +### Known limits + +- The perl rule keeps 0.1.106's existing gaps (e.g. `/usr/bin/perl -pi`, `bash -c "perl -pi …"`), deliberately unchanged. +- `test/multi-rdv-sim` scenario 01 can fail when `TMPDIR` is set (pre-existing, same on 0.1.106). + ## [0.1.106] - 2026-10-11 ### Fixed diff --git a/package.json b/package.json index 3aa6492..99cf190 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fusengine/harness", - "version": "0.1.106", + "version": "0.1.107", "description": "Harness-agnostic toolkit for AI coding agents: runtime harness detection (Claude Code, Codex, Cursor, Cline, Gemini, Aider...), pure policy core (env config, project/framework detection, SOLID/file-size limits, APEX freshness, guard patterns, portable prompts), cache, project memory, ref routing, state/locks, statusline, per-harness adapters (Claude/Cursor/Cline/Gemini) and a cli-mode harness-check binary. Bun-native, with a built dist for Node + bundlers.", "type": "module", "module": "src/index.ts", diff --git a/src/policy/apex-missing.ts b/src/policy/apex-missing.ts new file mode 100644 index 0000000..6f84299 --- /dev/null +++ b/src/policy/apex-missing.ts @@ -0,0 +1,69 @@ +import { apexAuthorizationGate } from "./apex-authorization"; +import { brainstormGate, docConsultedGate, freshnessGate, POST_AUTH_GATES, PRE_AUTH_GATES, type ApexContext, type ApexGate } from "./apex"; +import type { Prompt } from "../prompt/types"; + +/** The only gates known NOT to read the lazily loaded refs; any other gate (Check 1, SOLID refs, a future one) is treated as ref-dependent, so it is skipped when the refs are not loaded. */ +const REF_FREE: ReadonlySet = new Set([brainstormGate, freshnessGate, docConsultedGate]); + +/** Full gate chain in evaluation order, derived from the exported sub-chains (Check 1 sits between PRE and POST) so a new gate cannot be silently omitted. */ +const CHAIN: ReadonlyArray<{ gate: ApexGate; needsRefs: boolean }> = [...PRE_AUTH_GATES, apexAuthorizationGate, ...POST_AUTH_GATES].map((gate) => ({ gate, needsRefs: !REF_FREE.has(gate) })); + +/** Compare two prompts by value (gates are deterministic, so the deciding gate re-yields the same prompt). */ +const same = (a: Prompt, b: Prompt): boolean => JSON.stringify(a) === JSON.stringify(b); + +/** + * Every APEX gate that currently fails EXCEPT the deciding one (`first`), in chain + * order (brainstorm, freshness, Check 1, Check 2, SOLID refs). Pure: it only calls + * the gates, never writes state. A gate that throws is omitted, never propagated. + * @param ctx - The same context the deciding gate saw (`refs` loaded when `refsKnown`). + * @param first - The prompt that already blocks. + * @param refsKnown - False when the SOLID refs could not be loaded: the two ref-dependent gates are then skipped. + * @returns The other failing gates' prompts. + */ +export function otherMissing(ctx: ApexContext, first: Prompt, refsKnown: boolean): Prompt[] { + const hits: Prompt[] = []; + for (const { gate, needsRefs } of CHAIN) { + if (needsRefs && !refsKnown) continue; + try { + const hit = gate(ctx); + if (hit && !same(hit, first)) hits.push(hit); + } catch { + // Listing is best-effort: an unevaluable gate is simply not listed. + } + } + return hits; +} + +/** + * Fold the other missing prerequisites into the deciding prompt. The deciding + * gate's kind/title/ruleId/reason stay as-is; the reason gains one section naming + * each other gate (title + its actions) and `actions` becomes the first gate's + * actions then the others', deduplicated, order preserved. No others → `first` + * itself (byte-identical). + * @param first - The prompt of the first failing gate. + * @param others - The prompts of the other failing gates. + * @returns The merged prompt. + */ +export function mergeMissing(first: Prompt, others: Prompt[]): Prompt { + if (others.length === 0) return first; + const section = others.map((o) => [`- ${o.title}`, ...(o.actions ?? []).map((a) => ` ${a}`)].join("\n")); + const reason = [first.reason, "", `Also missing (${others.length} more):`, ...section].join("\n"); + const actions = [...new Set([...(first.actions ?? []), ...others.flatMap((o) => o.actions ?? [])])]; + return { ...first, reason, actions: actions.length ? actions : first.actions }; +} + +/** + * Block-path helper: `first` plus every other failing APEX prerequisite. Never + * throws and never writes state — on any failure it returns `first` unchanged. + * @param first - The prompt of the first failing gate. + * @param ctx - The context the gates ran on. + * @param refsKnown - False when the SOLID refs could not be loaded (see {@link otherMissing}). + * @returns The merged prompt, or `first` unchanged when nothing else fails or listing errors. + */ +export function listAllMissing(first: Prompt, ctx: ApexContext, refsKnown: boolean): Prompt { + try { + return mergeMissing(first, otherMissing(ctx, first, refsKnown)); + } catch { + return first; + } +} diff --git a/src/policy/guards/bash-write-inline.ts b/src/policy/guards/bash-write-inline.ts index f0f6852..75e6208 100644 --- a/src/policy/guards/bash-write-inline.ts +++ b/src/policy/guards/bash-write-inline.ts @@ -108,8 +108,8 @@ export const INLINE_JS_STDIN_ANCHOR: RegExp = new RegExp( * PLUS modern features (`say`, `state`, etc.) and clusters the same way * (`-nE`, `-plE`), so a bare-case check let `perl -E '...'` slip past * unanchored. Fires on script content this static check CAN inspect. The - * existing unconditional `perl -[pi]i?` CODE_MUTATORS entry (in-place edit) - * is untouched and stays a separate, always-block motif. + * CODE_MUTATORS "perl in-place edit" entry (perlCarriesInPlace, + * bash-write-perl.ts) stays a separate motif, checked before this one. */ export const PERL_E_ANCHOR: RegExp = new RegExp(`${CMD}perl\\s+-[a-zA-Z]*[eE]\\b`); diff --git a/src/policy/guards/bash-write-patterns.ts b/src/policy/guards/bash-write-patterns.ts index ef7e7d3..4b4e21f 100644 --- a/src/policy/guards/bash-write-patterns.ts +++ b/src/policy/guards/bash-write-patterns.ts @@ -1,4 +1,5 @@ import { CMD } from "./bash-command-anchor"; +import { perlCarriesInPlace } from "./bash-write-perl"; export { RUBY_WRITES, JS_RUNTIME_WRITES, INLINE_JS_ANCHOR, INLINE_JS_STDIN_ANCHOR, PERL_E_ANCHOR, PERL_WRITES, @@ -41,10 +42,14 @@ export const CODE_FILE_LITERAL: RegExp = new RegExp(`\\.(?:${CODE_EXT})\\b`, "i" * {@link CMD}-anchored (start/separator + optional wrapper chain); only the * structural heredoc-into-file motif is position-free. */ -export const CODE_MUTATORS: readonly { re: RegExp; desc: string }[] = [ +export const CODE_MUTATORS: readonly ( + | { re: RegExp; desc: string; matches?: undefined } + | { matches: (cmd: string) => boolean; desc: string; re?: undefined } +)[] = [ { re: new RegExp(`${CMD}python3?\\s+-\\s*<<`), desc: "Python heredoc input" }, { re: new RegExp(`${CMD}sed\\b[^|]*\\s-i`), desc: "sed in-place edit" }, - { re: new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`), desc: "perl in-place edit" }, + // ae977a9's verdict, except for the narrow spec in bash-write-perl.ts (which owns the legacy regex). + { matches: perlCarriesInPlace, desc: "perl in-place edit" }, { re: new RegExp(`${CMD}awk\\b[^|]*-i\\s*inplace`), desc: "awk in-place edit" }, // `patch` as a command token (start/separator + optional wrapper chain), then a // metachar/space/EOL. Never the bare word merely NAMING a path in a read-only diff --git a/src/policy/guards/bash-write-perl-words.ts b/src/policy/guards/bash-write-perl-words.ts new file mode 100644 index 0000000..9c6622f --- /dev/null +++ b/src/policy/guards/bash-write-perl-words.ts @@ -0,0 +1,87 @@ +/** + * Shell word reader for the arguments of ONE `perl` invocation (consumed by + * bash-write-perl.ts). It does NOT decode shell syntax: decoding bash, zsh and + * friends faithfully is a losing game (`$'\U2d'`, `$'\cI'`, `$'\e'`, `\M-`, + * unicode dashes …). Instead "certain" is a strict WHITELIST, and everything + * outside it makes the whole invocation uncertain — the caller then returns + * ae977a9's legacy verdict. + * + * A word is certain only if it is made solely of: + * - unquoted characters in {@link SAFE} (`A-Za-z0-9_./:=,+@%-`), + * - plain single-quoted segments free of `\`, newline and `!` (fish reads `\'` + * and `\\` inside single quotes as escapes; csh/tcsh expand `!` and reject + * newlines in quotes), + * - double-quoted segments free of `\`, `$`, backtick, `!` and newline. + * Anything else (`$'…'`, `$"…"`, any backslash anywhere, `$`, + * backtick, `{ } * ? [ ] ~ # ! ^`, redirects, non-ASCII, control characters, + * CR/FF/VT, an unbalanced quote …) is uncertain. + */ + +/** One word. */ +export interface PerlWord { + /** Value of the certain part read so far (quotes removed). */ + readonly text: string; +} + +/** Result of reading one token. */ +export interface PerlToken { + /** The word read (possibly only its certain prefix), or null at a boundary. */ + readonly word: PerlWord | null; + /** True when the invocation's argument list ends here. */ + readonly end: boolean; + /** Position to continue reading from. */ + readonly next: number; + /** True when something outside the whitelist was met. */ + readonly uncertain: boolean; +} + +/** Unquoted characters a certain word may contain. */ +const SAFE = /[A-Za-z0-9_./:=,+@%-]/; +/** Command boundaries that end an invocation with certainty. */ +const SEPARATORS = "\n;&|)"; +const REDIRECT_START = /(?:\d*|&)[<>]/y; + +/** + * Reads the next token of a perl invocation's argument text. + * @param s - Full command string. + * @param from - Index to start reading at. + * @returns The next word, or an end marker (uncertain when the end is a + * redirect, `(` or a backtick rather than a plain separator/EOF). + */ +export function readPerlToken(s: string, from: number): PerlToken { + let start = from; + while (s[start] === " " || s[start] === "\t") start++; + const c = s[start]; + if (c === undefined) return { word: null, end: true, next: start, uncertain: false }; + REDIRECT_START.lastIndex = start; + if (REDIRECT_START.test(s)) return { word: null, end: true, next: start, uncertain: true }; + if (SEPARATORS.includes(c)) return { word: null, end: true, next: start, uncertain: false }; + return readWord(s, start); +} + +/** Reads one word from its first char; stops at the first non-whitelisted construct. */ +function readWord(s: string, start: number): PerlToken { + let i = start; + let text = ""; + const stop = (): PerlToken => ({ word: { text }, end: true, next: s.length, uncertain: true }); + while (i < s.length) { + const c = s[i] as string; + if (c === " " || c === "\t" || SEPARATORS.includes(c)) break; + if (SAFE.test(c)) { text += c; i++; continue; } + if (c === "'") { + const close = s.indexOf("'", i + 1); + if (close < 0 || /[\\\n!]/.test(s.slice(i + 1, close))) return stop(); + text += s.slice(i + 1, close); + i = close + 1; + } else if (c === '"') { + const close = s.indexOf('"', i + 1); + const body = close < 0 ? "" : s.slice(i + 1, close); + if (close < 0 || /[\\$`!\n]/.test(body)) return stop(); + text += body; + i = close + 1; + } else { + return stop(); + } + } + return { word: { text }, end: false, next: i, uncertain: false }; +} diff --git a/src/policy/guards/bash-write-perl.ts b/src/policy/guards/bash-write-perl.ts new file mode 100644 index 0000000..ac99c9f --- /dev/null +++ b/src/policy/guards/bash-write-perl.ts @@ -0,0 +1,106 @@ +import { CMD } from "./bash-command-anchor"; +import { readPerlToken } from "./bash-write-perl-words"; + +/** + * "perl in-place edit" rule for CODE_MUTATORS. The verdict is ae977a9's + * ({@link LEGACY}, byte for byte) EXCEPT in exactly two cases, where the rule + * does not fire: + * + * (A) a pure-read program: one simple command (bash-write-perl-words.ts + * whitelist, no redirect/separator/expansion), first word exactly `perl`, + * switches only from `n l a F 0 w e E`, at least one `-e`/`-E` program, + * never `--` or a lone `-`, and NO program text containing `$^I`, `do`, + * `require`, `eval`, `use`, `exec`, `system`, `qx`, `readpipe`, `open`, + * `pipe`, `sysopen`, `rename`, `unlink`, `fork`, `syscall`, `IPC`, + * `INPLACE`, `print {`, a backtick or any `>`; + * (B) the owner's macOS timeout idiom: same command shape, the only program is + * exactly `[alarm N;] exec @ARGV[;]` and the child's first word is a bare + * agent CLI (claude, codex, kimi, cursor, cursor-agent). + * + * The other guards (the inline-perl `exec` ask) are untouched. Anything else — + * `-p`, `-i`, `-M`, `-x`, `-S`, `-d`, a path, another child — is LEGACY. + */ + +/** ae977a9's rule, verbatim: the single source of the legacy verdict. */ +const LEGACY: RegExp = new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`); +const FIRST_WORD_PERL = /^[ \t]*perl[ \t]/; +const FORBIDDEN = + /\^I|\b(?:do|require|eval|use|exec|system|qx|readpipe|open|pipe|sysopen|rename|unlink|fork|syscall)\b|IPC|INPLACE|print\s*\{|`|>/; +const TIMEOUT_IDIOM = /^\s*(?:alarm\s+\d+\s*;\s*)?exec\s+@ARGV\s*;?\s*$/; +const AGENT_CLIS = new Set(["claude", "codex", "kimi", "cursor", "cursor-agent"]); + +interface Cluster { ok: boolean; takesNext: boolean; isEval: boolean; attached: string } + +/** Reads a switch cluster made only of `n l a F 0 w e E`; anything else is not ok. */ +function readCluster(cluster: string): Cluster { + const res = (c: Partial): Cluster => ({ ok: true, takesNext: false, isEval: false, attached: "", ...c }); + let k = 0; + while (k < cluster.length) { + const ch = cluster[k] as string; + const rest = cluster.slice(k + 1); + if (ch === "e" || ch === "E") return res({ isEval: true, takesNext: rest === "", attached: rest }); + if (ch === "F") return res({}); + k++; + if (ch === "0" && cluster[k] === "x") { k++; while (/[0-9a-fA-F]/.test(cluster[k] ?? "")) k++; } + else if (ch === "0" || ch === "l") while (/[0-7]/.test(cluster[k] ?? "")) k++; + else if (ch !== "n" && ch !== "a" && ch !== "w") return res({ ok: false }); + } + return res({}); +} + +/** Index of the first non-blank char at or after `from`. */ +function skip(s: string, from: number): number { + let i = from; + while (s[i] === " " || s[i] === "\t") i++; + return i; +} + +/** True when every token from `from` to EOF is whitelist-certain and no word starts with `=`. */ +function certainToEnd(s: string, from: number): boolean { + let pos = from; + for (;;) { + const start = skip(s, pos); + const tok = readPerlToken(s, pos); + if (tok.uncertain || (tok.word && s[start] === "=")) return false; + if (tok.end) return s[tok.next] === undefined; + pos = tok.next; + } +} + +/** True for case (A) or (B): the in-place rule must not fire. */ +function exempt(cmd: string): boolean { + const head = FIRST_WORD_PERL.exec(cmd); + if (!head || !certainToEnd(cmd, head[0].length - 1)) return false; + let pos = head[0].length - 1; + const programs: string[] = []; + for (;;) { + const start = skip(cmd, pos); + const tok = readPerlToken(cmd, pos); + const text = tok.word?.text ?? ""; + pos = tok.next; + if (tok.end || !tok.word || !text.startsWith("-")) { + if (programs.length === 0) return false; + if (!programs.some((p) => FORBIDDEN.test(p))) return true; + const child = readPerlToken(cmd, start).word?.text ?? ""; + return programs.length === 1 && TIMEOUT_IDIOM.test(programs[0] as string) && AGENT_CLIS.has(child); + } + if (text === "--" || text === "-") return false; + const c = readCluster(text.slice(1)); + if (!c.ok) return false; + if (c.isEval) programs.push(c.attached); + if (!c.takesNext) continue; + const arg = readPerlToken(cmd, pos); + pos = arg.next; + if (!arg.word) return false; + programs[programs.length - 1] = arg.word.text; + } +} + +/** + * True when the "perl in-place edit" rule fires: ae977a9's verdict, except for + * the two exempt cases above. + * @param cmd - Raw Bash command string. + */ +export function perlCarriesInPlace(cmd: string): boolean { + return LEGACY.test(cmd) && !exempt(cmd); +} diff --git a/src/policy/guards/bash-write.ts b/src/policy/guards/bash-write.ts index 6d0a1d0..718bddd 100644 --- a/src/policy/guards/bash-write.ts +++ b/src/policy/guards/bash-write.ts @@ -80,7 +80,7 @@ export function bashWriteGuard(ctx: GuardContext): Prompt | null { const unquoted = unquotedShellText(cmd); const redirects = shellOutputRedirects(cmd).filter((redirect) => redirect.target !== "/dev/null"); - const mutator = CODE_MUTATORS.find((m) => m.re.test(cmd)); + const mutator = CODE_MUTATORS.find((m) => (m.matches ? m.matches(cmd) : m.re.test(cmd))); if (mutator) return blockCodeWrite(`${mutator.desc} — Use Edit/Write tools instead`); if (CODE_COMMAND_WRITE.test(cmd)) return blockCodeWrite("tee/dd into a code file — Use Edit/Write tools instead"); if (PYTHON_C_ANCHOR.test(cmd) && PYTHON_WRITES.test(cmd)) { diff --git a/src/runtime/gate-apex.ts b/src/runtime/gate-apex.ts index 509e5df..7922e3e 100644 --- a/src/runtime/gate-apex.ts +++ b/src/runtime/gate-apex.ts @@ -1,5 +1,6 @@ import { evaluateApex, PRE_AUTH_GATES, POST_AUTH_GATES, type ApexContext } from "../policy/apex"; import { apexAuthorizationGate } from "../policy/apex-authorization"; +import { listAllMissing } from "../policy/apex-missing"; import { isTrivialEdit, protectedPathGate, SKIP_DIRS } from "../policy/trivial-edits"; import { FAIL_CLOSED } from "../policy/guards"; import { agentsFresh, recordTarget, recordTrivialEdit, trivialCount, type SessionTrack } from "../tracking/session-state"; @@ -112,7 +113,8 @@ export async function apexScopedGate(input: GateInput, track: SessionTrack, wind try { // (4) Brainstorm + explore/research freshness (pre-existing harness gates; neither reads refs). const pre = evaluateApex(ctx, PRE_AUTH_GATES); - if (pre) return pre; + // The refs stay unloaded on a PRE block (lazy contract, src/refs/lazy.ts): only the ref-free gates are listed alongside it. Nothing is written. + if (pre) return listAllMissing(pre, ctx, false); } catch { return FAIL_CLOSED; } @@ -124,10 +126,11 @@ export async function apexScopedGate(input: GateInput, track: SessionTrack, wind if (authDeny) { const set_at = new Date(input.now).toISOString(); await withTrack(input.trackFile, (fresh) => recordTarget(fresh, { project: input.cwd ?? "", framework: input.framework, set_by: "apex-authorization", set_at })); - return authDeny; + return listAllMissing(authDeny, ctx, true); } // (6) Check 2 (doc consulted once per session) + SOLID refs. - return evaluateApex(ctx, POST_AUTH_GATES); + const post = evaluateApex(ctx, POST_AUTH_GATES); + return post && listAllMissing(post, ctx, true); } catch { return FAIL_CLOSED; } diff --git a/src/runtime/lifecycle/solid-detect-extra.ts b/src/runtime/lifecycle/solid-detect-extra.ts new file mode 100644 index 0000000..ab15e44 --- /dev/null +++ b/src/runtime/lifecycle/solid-detect-extra.ts @@ -0,0 +1,51 @@ +import { existsSync, readdirSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import type { SolidProfile } from "./solid-detect"; + +const profile = (type: string, ifaceDir: string): SolidProfile => ({ type, limit: 100, ifaceDir }); + +const TANSTACK = profile("tanstack-start", "src/interfaces"); +const ASTRO = profile("astro", "src/interfaces"); +const REACT = profile("react", "modules/cores/interfaces"); +const GENERIC = profile("generic", "modules/cores/interfaces"); +const JAVA = profile("java", "modules/core/interfaces"); +const CSHARP = profile("csharp", "Contracts"); +const RUBY = profile("ruby", "app/modules/core/contracts"); + +const GENERIC_MARKERS = ["tsconfig.json", "bun.lock", "bun.lockb", "bunfig.toml"]; +const JAVA_MARKERS = ["pom.xml", "build.gradle", "build.gradle.kts"]; + +/** Read `package.json` text, or null when absent/unreadable. */ +function readPackage(dir: string): string | null { + const path = join(dir, "package.json"); + if (!existsSync(path)) return null; + try { return readFileSync(path, "utf-8"); } catch { return null; } +} + +/** Stack for a `package.json` per solid-detection SKILL.md: tanstack-start > astro > react > generic. */ +function detectNode(dir: string): SolidProfile | null { + const pkg = readPackage(dir); + if (pkg === null) return null; + if (pkg.includes('"@tanstack/react-start"')) return TANSTACK; + if (pkg.includes('"astro"')) return ASTRO; + if (pkg.includes('"react"')) return REACT; + return GENERIC_MARKERS.some((m) => existsSync(join(dir, m))) ? GENERIC : null; +} + +/** + * Detect the stacks the base table misses (tanstack-start, astro, react, generic, java, + * csharp, ruby), in the SKILL.md priority order. Called only after the legacy chain + * found nothing, so it can never change a previously detected stack. + * @param dir - Project directory. + * @returns The matching profile, or null (caller keeps `unknown`). + */ +export function detectExtraProfile(dir: string): SolidProfile | null { + const node = detectNode(dir); + if (node) return node; + if (JAVA_MARKERS.some((f) => existsSync(join(dir, f)))) return JAVA; + try { + if (readdirSync(dir).some((e) => /\.(csproj|sln|slnx)$/.test(e))) return CSHARP; + } catch { /* unreadable dir */ } + if (existsSync(join(dir, "Gemfile")) && existsSync(join(dir, "Rakefile"))) return RUBY; + return null; +} diff --git a/src/runtime/lifecycle/solid-detect.ts b/src/runtime/lifecycle/solid-detect.ts index 4ea31c3..1b1321f 100644 --- a/src/runtime/lifecycle/solid-detect.ts +++ b/src/runtime/lifecycle/solid-detect.ts @@ -1,5 +1,8 @@ import { appendFileSync, existsSync, readdirSync, readFileSync } from "node:fs"; import { join } from "node:path"; +import { MAX_LINES_ENV_KEY } from "../../config/limits"; +import { parseEnvInt } from "../../config/env"; +import { detectExtraProfile } from "./solid-detect-extra"; /** A SOLID project profile: type label, per-file line limit, interface dir. */ export interface SolidProfile { @@ -34,7 +37,7 @@ export function detectSolidProfile(projectDir: string): SolidProfile { return { type: "swift", limit: 150, ifaceDir: "Protocols" }; } } catch { /* unreadable dir */ } - return { type: "unknown", limit: 100, ifaceDir: "" }; + return detectExtraProfile(projectDir) ?? { type: "unknown", limit: 100, ifaceDir: "" }; } /** @@ -45,7 +48,9 @@ export function detectSolidProfile(projectDir: string): SolidProfile { * @returns The plain-text stdout line (possibly empty). */ export function solidDetectStart(env: Record = process.env): string { - const profile = detectSolidProfile(env.CLAUDE_PROJECT_DIR ?? "."); + const detected = detectSolidProfile(env.CLAUDE_PROJECT_DIR ?? "."); + // Owner ceiling wins when set and valid (same parser as limits.ts); else the profile default. + const profile = { ...detected, limit: parseEnvInt(env[MAX_LINES_ENV_KEY], detected.limit) }; const envFile = env.CLAUDE_ENV_FILE ?? ""; if (envFile) { try { diff --git a/test/apex-deny-all-missing.test.ts b/test/apex-deny-all-missing.test.ts new file mode 100644 index 0000000..6ad3d42 --- /dev/null +++ b/test/apex-deny-all-missing.test.ts @@ -0,0 +1,67 @@ +/** + * An APEX deny lists the other missing prerequisites alongside the deciding one. The deciding + * gate (first failing one) keeps its kind/title/reason; the others are appended. A PRE-gate + * deny (brainstorm, explore+research) never loads the refs, so it lists only the ref-free + * gates (brainstorm, freshness, Check 2 docs); Check 1 and SOLID refs are listed once the + * deciding gate is past the refs load. 2^5 states. + */ +import { afterAll, expect, test } from "bun:test"; +import { apexScopedGate } from "../src/runtime/gate-apex"; +import { allMissing, cleanupDeny, GATE_NAMES, inputFor, REFS, targetSet, TITLES, WINDOW, type GateName, type Missing } from "./apex-deny-helpers"; +import { loadTrack } from "../src/tracking/store"; + +afterAll(cleanupDeny); + +const failing = (m: Missing) => GATE_NAMES.filter((g) => m[g]); +const NEEDS_REFS: ReadonlySet = new Set(["check1", "solid"]); +/** Gates the deny must list besides its decider: later failing ones; refs-dependent ones only when the decider is past the refs load. */ +const isPre = (g: GateName | undefined): boolean => g === "brainstorm" || g === "fresh"; +const expectedOthers = (f: GateName[]): GateName[] => f.slice(1).filter((g) => !(isPre(f[0]) && NEEDS_REFS.has(g))); +const run = async (m: Missing, over = {}) => { + const input = await inputFor(m, over); + return { input, out: await apexScopedGate(input, await loadTrack(input.trackFile), WINDOW) }; +}; + +test("one deny lists the other missing prerequisites (all 32 states, refs never loaded for a PRE deny)", async () => { + const bad: string[] = []; + for (const m of allMissing()) { + const f = failing(m); + let loads = 0; + const { input, out } = await run(m, { loadRefs: async () => { loads++; return REFS; } }); + if (f.length === 0) { if (out !== null) bad.push(`allow expected for ${JSON.stringify(m)}`); continue; } + if (!out) { bad.push(`block expected for ${f}`); continue; } + if (out.kind !== "block" || out.title !== TITLES[f[0]!]) bad.push(`decider ${f} -> ${out.title}`); + const want = expectedOthers(f); + for (const g of GATE_NAMES.filter((x) => x !== f[0])) if (out.reason.includes(TITLES[g]) !== want.includes(g)) bad.push(`${f}: ${g} listed=${out.reason.includes(TITLES[g])} want=${want.includes(g)}`); + if (new Set(out.actions).size !== out.actions?.length) bad.push(`${f}: duplicate actions`); + if (out.reason.includes("same pass")) bad.push(`${f}: wording promises a single pass`); + if (isPre(f[0]) && loads !== 0) bad.push(`${f}: PRE deny loaded the refs`); + if ((await targetSet(input)) !== (f[0] === "check1")) bad.push(`${f}: recordTarget side effect wrong`); + } + expect(bad).toEqual([]); +}); + +test("a PRE deny lists only the ref-free gates; a Check 1 deny lists Check 2 and SOLID, actions merged first-gate-first and deduplicated", async () => { + const pre = (await run({ brainstorm: true, fresh: true, check1: true, doc: true, solid: true })).out; + expect(pre?.title).toBe(TITLES.brainstorm); + expect(pre?.reason).toContain(TITLES.fresh); + expect(pre?.reason).toContain(TITLES.doc); + expect(pre?.reason).not.toContain(TITLES.check1); + expect(pre?.reason).not.toContain(TITLES.solid); + expect(pre?.actions?.[0]).toBe("Launch the brainstorming agent"); + const post = (await run({ brainstorm: false, fresh: false, check1: true, doc: true, solid: true })).out; + expect(post?.title).toBe(TITLES.check1); + expect(post?.reason).toContain(TITLES.doc); + expect(post?.reason).toContain(TITLES.solid); + const refs = REFS.map((r) => r.filePath); + expect(post?.actions?.filter((a) => refs.includes(a)).length).toBe(refs.length); +}); + +test("a throwing loadRefs never turns a PRE block into a throw (and is not even called)", async () => { + let called = false; + const boom = async () => { called = true; throw new Error("refs dir unreadable"); }; + const { out } = await run({ brainstorm: false, fresh: true, check1: true, doc: true, solid: true }, { loadRefs: boom }); + expect(out?.title).toBe(TITLES.fresh); + expect(out?.reason).toContain(TITLES.doc); + expect(called).toBe(false); +}); diff --git a/test/apex-deny-helpers.ts b/test/apex-deny-helpers.ts new file mode 100644 index 0000000..254ff54 --- /dev/null +++ b/test/apex-deny-helpers.ts @@ -0,0 +1,73 @@ +/** + * @module test/apex-deny-helpers + * State matrix for the "deny lists every missing prerequisite" tests: five + * independent APEX prerequisites (brainstorm, explore+research, Check 1 doc, + * Check 2 docs, SOLID refs) → 2^5 sessions, each with its own isolated track file. + */ +import { mkdirSync, mkdtempSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { emptyTrack, recordAgent, recordBrainstormRequired, recordDoc, recordRefRead } from "../src/tracking/session-state"; +import { loadTrack, saveTrack } from "../src/tracking/store"; +import type { GateInput } from "../src/runtime/gate-input"; +import type { RefMeta } from "../src/refs/types"; +import type { SessionTrack } from "../src/tracking/session-state"; + +export const NOW = 1_800_000_000_000; +export const WINDOW = 900_000; +export const GATE_NAMES = ["brainstorm", "fresh", "check1", "doc", "solid"] as const; +export type GateName = (typeof GATE_NAMES)[number]; +/** Which prerequisites are MISSING in a state. */ +export type Missing = Record; + +const root = mkdtempSync(join(tmpdir(), "fh-deny-all-")); +let seq = 0; + +/** Remove the module's temp dir (call from the test file's `afterAll`). */ +export function cleanupDeny(): void { + rmSync(root, { recursive: true, force: true }); +} + +/** Titles the five gates return (framework "react"). */ +export const TITLES: Record = { + brainstorm: "APEX: brainstorm first", + fresh: "APEX: explore + research required", + check1: "APEX: react documentation required", + doc: "APEX: documentation not consulted", + solid: "APEX: read SOLID references for react", +}; + +/** Routed SOLID refs (no framework name in the path, so reading them never satisfies Check 1's skill-read shortcut). */ +export const REFS: RefMeta[] = ["srp", "dry"].map((name) => ({ name, description: "", keywords: "", priority: "", related: "", appliesTo: "**/*.ts", triggerOnEdit: "", level: "principle", filePath: `/skills/solid-generic/references/${name}.md` })); + +/** The 2^5 missing-combinations, in a stable order. */ +export function allMissing(): Missing[] { + return Array.from({ length: 32 }, (_, i) => Object.fromEntries(GATE_NAMES.map((g, j) => [g, !!(i & (1 << j))])) as Missing); +} + +/** Track holding exactly the evidence for the prerequisites NOT missing. */ +export function trackFor(m: Missing): SessionTrack { + let t = recordBrainstormRequired(emptyTrack(), m.brainstorm); + if (!m.brainstorm) t = recordAgent(t, "brainstorming", NOW - 1000, "sufficient"); + if (!m.fresh) for (const a of ["explore-codebase", "research-expert"]) t = recordAgent(t, a, NOW - 1000, "sufficient"); + // Check 1 = a react stamp; Check 2 = BOTH sources in the session (any framework). + if (!m.check1) t = recordDoc(t, "react", "s1", "context7", NOW - 1000); + if (!m.doc) for (const src of ["context7", "exa"]) t = recordDoc(t, m.check1 ? "php" : "react", "s1", src, NOW - 1000); + if (!m.solid) for (const r of REFS) t = recordRefRead(t, r.filePath, NOW - 1000); + return t; +} + +/** A Write input for state `m` with a fresh isolated track file; fields are overridable (throwing `loadRefs` probes, tool, path). */ +export async function inputFor(m: Missing, over: Partial = {}): Promise { + const dir = join(root, `s${++seq}`); + mkdirSync(dir, { recursive: true }); + const trackFile = join(dir, "track-s1.json"); + await saveTrack(trackFile, trackFor(m)); + const content = Array.from({ length: 8 }, (_, i) => `export const v${i} = ${i};`).join("\n"); + return { sessionId: "s1", framework: "react", tool: "Write", filePath: join(dir, "src", "calc.ts"), content, cwd: dir, now: NOW, windowMs: WINDOW, trackFile, loadRefs: async () => REFS, ...over }; +} + +/** Whether the run persisted a Check-1 `target` into the state's track. */ +export async function targetSet(input: GateInput): Promise { + return !!(await loadTrack(input.trackFile)).target; +} diff --git a/test/bash-write-perl-inplace.test.ts b/test/bash-write-perl-inplace.test.ts new file mode 100644 index 0000000..902889f --- /dev/null +++ b/test/bash-write-perl-inplace.test.ts @@ -0,0 +1,50 @@ +import { test, expect } from "bun:test"; +import { bashWriteGuard } from "../src/policy/guards/bash-write"; +import { CMD } from "../src/policy/guards/bash-command-anchor"; + +/** + * Generated parity corpus: every perl form with an in-place switch, an expansion, + * a redirect, a shifted quote or a non-ASCII dash must get EXACTLY ae977a9's + * verdict (the legacy regex). Only the two exempt cases in + * bash-write-perl-narrow.test.ts may differ. + */ + +const LEGACY = new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`); +const inPlaceBlock = (command: string): boolean => { + const r = bashWriteGuard({ tool: "Bash", command }); + return r?.kind === "block" && r.reason.startsWith("perl in-place edit"); +}; + +const corpus: string[] = []; +const REDIRECTS = ["2>/dev/null", ">f", "&1", "&>f", ">|f", "<<&-", ">/dev/null", "> f", "2> f"]; +for (const r of REDIRECTS) { + corpus.push(`perl -pe ${r} 's/a/b/' -i src/x.ts`, `perl ${r} -pe 's/a/b/' -i src/x.ts`, `perl -p ${r} -i -e 1 src/x.ts`, + `perl -e 1 ${r} -i src/x.ts`, `perl -e 1 -pi ${r} src/x.ts`, `perl -e 1 -0777 -i ${r} src/x.ts`); +} +for (const tail of ["-i", "-pi", "-0777 -i", "-l -i", "-x -i", "-i.bak", "-w -i"]) { + corpus.push(`perl -e 's/a/b/' ${tail} src/x.ts`, `perl -pe 's/a/b/' ${tail} src/x.ts`, `perl -ne 'print' ${tail} src/x.ts`); +} +const HEADS = [ + "$'\\U2d'i -p", "$'\\x2d'pi", "$'\\055'pi", "$'\\u2d'pi", "$'\\cI'", "-w$'\\e'", "$\"-pi\"", "\\-pi", "-\\pi", "-p\\i", "‐pi", "−pi", + "-pi$BAK", "-pi${BAK}", "-pi\"$SUF\"", "-i\"$B\"", "-i${EXT:-}", "-p$X", "-p$(echo i)", "-p`echo i`", "-p$'i'", "-dpi", "-Vpi", "-Dpi", "-Ci", + "-0pi", "-ni", "-wpi", "-lpi", "'-pi'", "\"-pi\"", "-p\"i\"", "${X} -pi", "\"$@\" -pi", "$F -pi", "-{p,i}", "(echo -pi)", "\\x2dpi", +]; +for (const head of HEADS) { + for (const tail of ["", " -p", " -i", " -pi"]) { + corpus.push(`perl ${head} -e 's/a/b/' f.ts${tail}`, `perl -e 1 ${head} f.ts${tail}`, `perl -pe 's/a/b/' ${head} f.ts${tail}`); + } +} +for (const sw of ["-pi", "-i", "-0pi", "-i.bak", "-0777 -i", "-wpi"]) { + corpus.push( + `perl -e '1;#\\' ' ${sw} f.ts ' 'b\\'`, `perl -e '1;#\\\\' ' ${sw} f.ts ' 'b\\\\'`, `perl -e 'a\\'b' ${sw} f.ts ' 'c'`, + `perl -e 'one\ntwo' ${sw} f.ts`, `perl -e 'a!b' ${sw} f.ts`, `perl -e "a!b" ${sw} f.ts`, `perl -e "a\nb" ${sw} f.ts`, + `echo 'q\\' ; perl -e 1 ${sw} f.ts`, + ); +} +corpus.push("perl > > -pi src/x.ts", "perl" + " > ".repeat(100_000), "perl" + " >x".repeat(100_000) + " -pi f"); + +for (const command of corpus) { + test(`parity with ae977a9: ${JSON.stringify(command.slice(0, 120))}`, () => { + expect(inPlaceBlock(command)).toBe(LEGACY.test(command)); + }); +} diff --git a/test/bash-write-perl-narrow.test.ts b/test/bash-write-perl-narrow.test.ts new file mode 100644 index 0000000..5d9b6bf --- /dev/null +++ b/test/bash-write-perl-narrow.test.ts @@ -0,0 +1,111 @@ +import { test, expect } from "bun:test"; +import { bashWriteGuard } from "../src/policy/guards/bash-write"; +import { CMD } from "../src/policy/guards/bash-command-anchor"; + +/** + * Final minimal window: the perl in-place rule differs from ae977a9 in exactly two + * cases — (A) a pure-read `-n/-l/-a/-F/-0/-w/-e/-E` program, (B) the owner's + * `alarm N; exec @ARGV` timeout idiom with a bare agent-CLI child. Everything else + * is ae977a9's verdict (the legacy regex), byte for byte. + */ + +const LEGACY = new RegExp(`${CMD}perl\\b[^|]*\\s-[pi]i?\\b`); +const guard = (command: string) => bashWriteGuard({ tool: "Bash", command }); +const inPlaceBlock = (command: string): boolean => { + const r = guard(command); + return r?.kind === "block" && r.reason.startsWith("perl in-place edit"); +}; +const parity = (commands: readonly string[], label: string) => { + for (const command of commands) { + test(`${label}: ${JSON.stringify(command)}`, () => { + expect(inPlaceBlock(command)).toBe(LEGACY.test(command)); + }); + } +}; + +parity([ + "perl -e 1; perl5.34 -pi -e s/a/b/ x.ts", "perl -e 1 && perl5.34 -pi -e s/a/b/ x.ts", "perl -e 1 & perl5.34 -pi -e s/a/b/ x.ts", + "perl -e 1\nperl5.34 -pi -e s/a/b/ x.ts", "/usr/bin/perl -pi -e s/a/b/ x.ts", '"perl" -pi -e s/a/b/ x.ts', "'perl' -pi -e s/a/b/ x.ts", + "p''erl -pi -e s/a/b/ x.ts", "env -u FOO perl -pi -e s/a/b/ x.ts", "env -S 'perl -pi -e s/a/b/' x.ts", "xargs -I F perl -pi -e s/a/b/ F < list.txt", + "sh -c 'perl -pi -e s/a/b/ x.ts'", "{ perl -pi -e s/a/b/ x.ts; }", "! perl -pi -e s/a/b/ x.ts", "X='a b' perl -pi -e s/a/b/ x.ts", + "if true; then perl -pi -e s/a/b/ x.ts; fi", "builtin command perl -pi -e s/a/b/ x.ts", "and perl -pi -e s/a/b/ x.ts", "not perl -pi -e s/a/b/ x.ts", + "=perl -pi -e s/a/b/ x.ts", "perl script.pl -i x.ts", "perl -p -- -i x.ts", "perl -- -i", "perl - -pi", "perl -s script.pl -i", "perl -p", + "perl -le 'print 1' ; ls -p", "perl -e 1 && claude -p hi", "perl -pi -e 's/a/b/' f.ts", "perl -i.bak -pe 's/a/b/' f.ts", "perl -0pi -e 1 f.ts", + "perl -wi -e 1 f.ts", "perl -E 'say 1' -p", "perl -Ci -pe 1 f.ts", "perl -e 1 -pi f.ts", "perl -pe 2>/dev/null 's/a/b/' -i src/x.ts", + "perl $'\\U2d'i -p -e 's/a/b/' f.ts", "perl -e '1;#\\' ' -pi f.ts ' 'b\\'", "perl -pi$BAK -e 's/a/b/' src/x.ts", "perl -dpi -e 1 f", "perl -Vpi -e 1 f", + "perl -e 'alarm 120; exec @ARGV' perl -pi -e s/a/b/ x.txt", "perl -e 'exec @ARGV' perl -i -pe s/a/b/ x.txt", +], "outside A/B -> ae977a9"); + +// Round 6: programs that run commands / set $^I must keep ae977a9's verdict (block or ask), never allow. +parity([ + `perl -e 'readpipe "@ARGV"' sed -i.bak s/a/b/ f.ts`, + `perl -MIPC::Open3 -e 'open3(undef,undef,undef,@ARGV); wait' sed -i.bak s/a/b/ f.ts`, + `perl -MIPC::Open2 -e 'open2(my $o, my $i, @ARGV)' sed -i.bak s/a/b/ f.ts`, + `perl -e 'readpipe "sed -i.bak s/a/b/ f.ts"'`, + `perl -e 'qx/sed -i.bak s,a,b, f.ts/'`, + `perl -pe 'BEGIN{$^I=""} s/ -i//' f.ts`, + `perl -pe 'BEGIN{$^I=""} s/a/b/' f.ts -p`, + `perl -ne 'BEGIN{$^I=".bak"} s/a/b/; print' f.ts -i`, + `perl -e 'require "./x.pl"' f.ts -i`, + `perl -e 'do "./x.pl"' f.ts -i`, + `perl -e 'system "sed -i.bak s/a/b/ f.txt"'`, + `perl -e 'alarm 5; exec @ARGV' sed -i s/a/b/ src/x.ts`, + `perl -e 'alarm 5; exec @ARGV' true`, + `perl -e 'alarm 60; exec @ARGV' python3 -c 'print(1)'`, + `perl -e 'alarm 60; exec @ARGV' patch --dry-run -p1 x.diff`, + `perl -e 'alarm 60; exec @ARGV' patch -p1 --dry-run`, + `perl -e 'alarm 60; exec @ARGV' sh -c 'sed -i s/a/b/ f.ts'`, + `perl -e 'alarm 60; exec @ARGV' ./claude -p x`, + `perl -e 'alarm 60; exec @ARGV' gemini -p x`, + `perl -e 'alarm 60; exec @ARGV' /usr/bin/codex -p x`, + `perl -e 'alarm 60; exec @ARGV' cursor-agent2 -p x`, + `perl -e 'alarm 60; exec @ARGV; print 1' claude -p x`, +], "round-6 -> ae977a9"); + +test("round 6: never an allow for a command that runs a command or sets $^I", () => { + expect(guard(`perl -e 'readpipe "@ARGV"' sed -i.bak s/a/b/ f.ts`)?.kind).toBe("block"); + expect(guard(`perl -e 'system "sed -i.bak s/a/b/ f.txt"'`)).not.toBeNull(); + expect(guard(`perl -e 'alarm 60; exec @ARGV' patch --dry-run -p1 x.diff`)?.kind).toBe("ask"); + expect(guard(`perl -e 'alarm 60; exec @ARGV' python3 -c 'print(1)'`)?.kind).toBe("ask"); +}); + +// (B) the owner's timeout idiom with a bare agent CLI: the in-place rule does not fire. +test("(B) timeout idiom with an agent CLI is not an in-place block (exec ask stays)", () => { + for (const child of ["claude", "codex", "kimi", "cursor", "cursor-agent"]) { + for (const command of [`perl -e 'alarm 120; exec @ARGV' ${child} -p "x"`, `perl -e 'exec @ARGV' ${child} -p -i`, `perl -E 'alarm 5; exec @ARGV;' ${child} -p x`]) { + expect(inPlaceBlock(command)).toBe(false); + expect(guard(command)?.kind).toBe("ask"); + } + } +}); + +// (A) pure reads: switches only in n l a F 0 w E e, programs without any command/in-place construct. +const READS: readonly string[] = [ + "perl -ne 'print if /TODO/' src/api-items.ts", + "perl -lane 'print $F[0]' data-index.csv", + "perl -nle 'print length' my-file.ts", + "perl -0777 -ne 'print length' f.ts", + "perl -F: -lane 'print $F[1]' data.csv", + "perl -nle 'print length' x -p", + "perl -ne 'print' notes -pi.txt", + "perl -ne 'print' x -i", + "perl -lane 'print $F[0]' f.ts -p", + "perl -e 'print \"a -pi b\"' f.ts", + "perl -e -i f.ts", + "perl -wne 'print if /x/' f -pi", +]; +for (const command of READS) { + test(`(A) pure read: ${command}`, () => { + expect(inPlaceBlock(command)).toBe(false); + }); +} + +// Anything A excludes keeps ae977a9's verdict even with -p/-i-looking trailing words. +parity([ + "perl -MJSON::PP -e 'print 1' data.json -p", "perl -pe 1 f -p", + "perl -ne 'open F, \" x' f -p", "perl -ne 'eval 1' f -p", "perl -ne 'do 1' f -p", + "perl -ne 'print { $fh } 1' f -p", "perl -ne 'unlink $_' f -p", "perl -ne 'rename $_, 1' f -p", "perl -ne 'fork' f -p", + "perl -ne 'sysopen F, $_, 1' f -p", "perl -ne 'syscall 1' f -p", "perl -ne 'use English; $INPLACE_EDIT=1' f -p", + "perl -ne 'print `ls`' f -p", "perl -xne 'print' f -p", "perl -Sne 'print' f -p", "perl -dne 'print' f -p", "perl -Mstrict -ne 'print' f -p", + "perl -ne 'print' -- f -p", "perl -ne 'print' - -p", "perl -ne 'print' f > out -p", "perl -ne 'print' $X -p", "perl -ne \"print\" f; ls -p", +], "A excluded -> ae977a9"); diff --git a/test/solid-detect-stacks.test.ts b/test/solid-detect-stacks.test.ts new file mode 100644 index 0000000..97bb2a8 --- /dev/null +++ b/test/solid-detect-stacks.test.ts @@ -0,0 +1,84 @@ +import { afterAll, expect, test } from "bun:test"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { detectSolidProfile, solidDetectStart } from "../src/runtime/lifecycle/solid-detect"; + +const root = mkdtempSync(join(tmpdir(), "solid-detect-stacks-")); +afterAll(() => rmSync(root, { recursive: true, force: true })); + +/** Build a temp project dir from a `{ relPath: content }` map and detect its stack. */ +function stack(files: Record): string { + const dir = mkdtempSync(join(root, "stack-")); + for (const [name, body] of Object.entries(files)) { + mkdirSync(join(dir, name, ".."), { recursive: true }); + writeFileSync(join(dir, name), body); + } + return detectSolidProfile(dir).type; +} +const pkg = (deps: string[]): string => JSON.stringify({ dependencies: Object.fromEntries(deps.map((d) => [d, "1"])) }); + +test("new stacks: one fixture each", () => { + expect(stack({ "package.json": pkg(["react", "vite"]) })).toBe("react"); + expect(stack({ "package.json": pkg(["@tanstack/react-start", "react"]) })).toBe("tanstack-start"); + expect(stack({ "package.json": pkg(["astro"]) })).toBe("astro"); + expect(stack({ "package.json": pkg(["typescript"]), "tsconfig.json": "{}" })).toBe("generic"); + expect(stack({ "package.json": pkg([]), "bun.lock": "" })).toBe("generic"); + expect(stack({ "package.json": pkg([]), "bun.lockb": "" })).toBe("generic"); + expect(stack({ "package.json": pkg([]), "bunfig.toml": "" })).toBe("generic"); + expect(stack({ "pom.xml": "" })).toBe("java"); + expect(stack({ "build.gradle": "" })).toBe("java"); + expect(stack({ "build.gradle.kts": "" })).toBe("java"); + expect(stack({ "App.csproj": "" })).toBe("csharp"); + expect(stack({ "App.sln": "" })).toBe("csharp"); + expect(stack({ "App.slnx": "" })).toBe("csharp"); + expect(stack({ Gemfile: "", Rakefile: "" })).toBe("ruby"); +}); + +test("priority order among new stacks", () => { + expect(stack({ "package.json": pkg(["@tanstack/react-start", "astro", "react"]) })).toBe("tanstack-start"); + expect(stack({ "package.json": pkg(["astro", "react"]) })).toBe("astro"); + expect(stack({ "package.json": pkg(["react"]), "tsconfig.json": "{}" })).toBe("react"); + expect(stack({ "package.json": pkg([]), "tsconfig.json": "{}", "pom.xml": "" })).toBe("generic"); + expect(stack({ "pom.xml": "", "App.csproj": "" })).toBe("java"); + expect(stack({ "App.csproj": "", Gemfile: "", Rakefile: "" })).toBe("csharp"); +}); + +test("generic needs package.json + TS marker; Gemfile alone is not ruby; empty stays unknown", () => { + expect(stack({ "package.json": pkg([]) })).toBe("unknown"); + expect(stack({ "tsconfig.json": "{}" })).toBe("unknown"); + expect(stack({ Gemfile: "" })).toBe("unknown"); + expect(stack({})).toBe("unknown"); +}); + +/** Run solidDetectStart on a fixture; return stdout line + exported SOLID_FILE_LIMIT. */ +function start(files: Record, max?: string): { line: string; limit: string } { + const dir = mkdtempSync(join(root, "start-")); + for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body); + const envFile = join(dir, ".env-out"); + writeFileSync(envFile, ""); + const env: Record = { CLAUDE_PROJECT_DIR: dir, CLAUDE_ENV_FILE: envFile, FUSE_SOLID_MAX_LINES: max }; + const line = solidDetectStart(env); + const limit = /SOLID_FILE_LIMIT=(\S+)/.exec(readFileSync(envFile, "utf-8"))?.[1] ?? ""; + return { line, limit }; +} +const gen = { "package.json": pkg([]), "tsconfig.json": "{}" }; + +test("SOLID_FILE_LIMIT follows FUSE_SOLID_MAX_LINES (valid), else profile default", () => { + expect(start(gen, "200")).toEqual({ line: "SOLID: generic project (max 200 lines)", limit: "200" }); + expect(start(gen)).toEqual({ line: "SOLID: generic project (max 100 lines)", limit: "100" }); + expect(start(gen, "abc").limit).toBe("100"); + expect(start(gen, "0").limit).toBe("100"); + expect(start({ "go.mod": "" }, "250").limit).toBe("250"); + expect(start({ "Package.swift": "" }).limit).toBe("150"); + expect(start({ "Package.swift": "" }, "120").limit).toBe("120"); + expect(start({ "package.json": '{"next":1}' }).limit).toBe("150"); +}); + +test("old stacks keep priority over new ones", () => { + expect(stack({ "package.json": pkg(["next", "react"]), "astro.config.mjs": "" })).toBe("nextjs"); + expect(stack({ "package.json": pkg(["react"]), "go.mod": "" })).toBe("go"); + expect(stack({ "package.json": pkg([]), "tsconfig.json": "{}", "Cargo.toml": "" })).toBe("rust"); + expect(stack({ "pom.xml": "", "pyproject.toml": "" })).toBe("python"); + expect(stack({ "package.json": pkg(["react"]), "Package.swift": "" })).toBe("swift"); +});