diff --git a/CHANGELOG.md b/CHANGELOG.md index 140e7e9..2a63ead 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,115 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Changed + +- **Only real profiles (RA-14).** `forge init --profile` now accepts `minimal` and + `standard` only — the former `web-app`/`backend-service`/`library`/`regulated` names + were always aliases of the full pack (sync only ever branched on `minimal`) and are now + deprecated aliases of `standard`: accepted with a deprecation warning, stored as + `standard`, and a legacy name already stored in an existing config still syncs as + standard (with a one-time warning). New exported `validateProfile()` maps legacy names + before any side effect. +- **Honest wording (RA-22, RA-23, RA-24).** package.json's description now says what + ships — shared memory, impact analysis, and guardrail hooks emitted as native config — + instead of "the cognitive substrate every frozen model is missing". README's loop intro + now states that the automatic pre-action check is Claude Code hooks (advisory by + default, enforcement opt-in via `FORGE_ENFORCE=1`) while other tools receive + instructions and MCP tools to invoke; the comparison table now claims what the ledger + does today — evidence must name a known oracle and a typed, format-checked reference at + append time — rather than implying stored evidence is re-verified on load. + +### Security + +- **Git read-command bypasses closed (RA-05).** The `protect-paths` guard now classifies + `git diff`, `git stash`, `git cat-file`, `git archive`, and `git grep` as content + readers, closing the secret-file read path via git plumbing (`git diff -- .env`, + `git cat-file -p HEAD:.env`, `git archive HEAD .env`). The settings template moves + `Bash(git stash:*)` from allow to ask: `git stash show -p` can dump stashed secret + content with no path token for the guard to match, so the permission prompt is the + only defense there. +- **Secret redactor no longer fails silently (RA-06).** Any redaction failure prints a + visible `secret redaction DEGRADED` warning to stderr, and new `FORGE_GUARD_STRICT=1` + escalates degraded redaction to a blocking exit instead of passing unredacted output + through. + +### Fixed + +- **`forge verify --deep` can no longer pass when nothing ran (RA-01).** Deep `ok` now + requires the core tests status to be PASS in addition to the lens consensus; the + result, provenance, and metrics carry an additive four-state `status` + (PASS/FAIL/INCOMPLETE/NOT_CONFIGURED), and a repo with no configured verifier exits 1 + with `NOT VERIFIED` instead of printing PASS. +- **`forge verify` executes the detected runner (RA-08).** pnpm/yarn/bun repos spawn + their own package manager instead of a hardcoded `npm test`; pytest runs directly; + non-executable detections (go/cargo/mvn/gradle/dotnet/rspec/phpunit) or a missing + binary report an honest INCOMPLETE naming the real command. `detectStack()` gains a + structured `testRunners` field (`{bin, args, label}`) alongside the unchanged + `testCommands` strings; npx-based runners are report-only and never executed. +- **CLI verify output distinguishes all four states (RA-09).** `BLOCKED` is reserved for + a runner that actually failed; anything that never completed prints `NOT VERIFIED` + (still exit 1) instead of the misleading "tests failing". +- **Ledger log lines are hash-verified at read time (RA-02).** `readLog()` recomputes + every record's content hash and drops forged/corrupt lines (evidence lines must also + be valid outcomes), so a hand-edited log line can no longer move `val()`. Imports and + merges no longer bypass validation: imported evidence goes through the full append + gate (oracle/ref checks including `git:` resolution against the destination repo), and + rejected records are quarantined as sealed audit lines under `quarantine/.log` + with a `quarantined` count surfaced by `forge ledger merge`. `validOutcome()` rejects + typed-but-empty refs (e.g. `git:`), matching append-time validation. +- **Stale ambient atlas is no longer authoritative (RA-07).** When the atlas is stale and + a hook can't rebuild it, impact, impacted files, and predicted tests are dropped rather + than silently computed from stale data; renderers say "impact unavailable" without a + contradicting test list, and `FORGE_ENFORCE=1` never hard-blocks on a stale blast + radius. +- **Completion gate enforces real obligations (RA-10).** Code changes now owe test + evidence — a test file moved with the change or a fresh passing `forge verify` run — in + addition to docs/state; a handoff alone no longer satisfies the gate for code + (config-only changes keep the lighter docs-or-handoff bar). Kill switch, block-once, + and fail-open behavior are unchanged. +- **Doctor honesty (RA-19, RA-20).** A missing atlas reports subsystem health + `UNAVAILABLE` (new neutral `na` status) instead of `ACTIVE`, and `doctor --fix` + records a repair that returns an error object (e.g. `mergeSettings` refusing a corrupt + file) as failed with the reason instead of successful. +- **Settings failures propagate (RA-04).** `forge init` fails (exit 1, stderr) when the + settings merge is refused or errors, and `install.sh` reports `Install INCOMPLETE` and + exits 1 instead of printing `Done.` when hooks weren't wired. +- **Hook paths are shell-quoted (RA-12).** Hook and statusline commands merged into + `~/.claude/settings.json` single-quote the package path, so installs under a path + containing spaces work; old unquoted entries dedupe against and are upgraded to the + quoted form on re-merge. +- **Invalid profile aborts before side effects (RA-13).** An invalid + `forge init --profile` value no longer leaves a partial repo scaffold, `.forge/` write, + or settings merge behind. +- **Reversible settings uninstall (RA-11, RA-17, RA-18).** New + `forge init --remove-settings` (also run by `install.sh --uninstall`) reverses the + settings merge — template-shaped hooks/permissions/statusline and the `_forge` marker + are removed with a backup, user entries untouched. The installer header now tells the + truth about the global, reversible merge, announces it before merging, and gains + `--no-settings` to skip it. +- **MCP emitters are no longer destructive (RA-03).** Installed integrations are recorded + in `.forge/forge.config.json` (`mcp.integrations`), and `forge sync` and + `forge integrations add` both emit the same full managed set — the sync/add/sync + oscillation that deleted each other's servers is gone. +- **MCP configs respect ownership (RA-21).** Continue gets one forge-marked YAML per + managed server (the legacy combined file is migrated away only when provably forge's), + Codex servers live in `# forge:managed:` blocks refreshed by byte-compare, and a + same-name JSON entry the user configured is never overwritten — it is reported with an + `--adopt` hint instead. New `forge integrations remove ` reverses an add, + deleting only forge-owned entries, blocks, and files; running it twice is a no-op. +- **Stop-hook auto-sync detects body drift (RA-16).** Auto-sync now byte-compares + `AGENTS.md` against the exact content sync would write instead of trusting the embedded + marker hash, so a hand-edited body with an intact marker is repaired. +- **Repo config consolidated; malformed JSON fails loudly (RA-15).** `src/repo_config.js` + is now the single per-repo config module: `readForgeConfig`/`writeForgeConfig` operate + on the unified `.forge/forge.config.json` (unknown keys round-trip through writes), + migration-read the legacy `.forge/config.json` (`primaryTool`/`tools`; the unified file + wins on key conflicts, the legacy file is left in place), and never silently discard + corrupt JSON — reads warn once per process on stderr and report the corrupt path, and + writers (`forge init --profile`, `forge tools `) refuse to overwrite an + unparseable config instead of replacing it with defaults. `forge sync` still fail-opens + to default rules on a corrupt config but surfaces a warning in its report. + ## [0.22.0] - 2026-07-17 ### Fixed (audit remediation) diff --git a/README.md b/README.md index 12cdd5e..b1e7dfe 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,15 @@ Then, in your project: forge init # emit every AI tool's native config from one shared source forge doctor # pass/fail health check: tools, guards, MCP, config drift forge doctor --fix # auto-repair the safely fixable findings, then re-check +``` + +`forge init` also merges Forge's hooks + permissions into `~/.claude/settings.json` — +that file is **global** (it affects all your repos), so init says so before reporting the +merge. Opt out with `forge init --no-settings`; reverse a past merge any time with +`forge init --remove-settings` (your own entries are preserved, and a timestamped backup +is written first). + +```bash # pre-action check before you (or your agent) edit anything: forge substrate "Change verifyToken in src/auth.js to require length > 20; update tests" @@ -193,7 +202,8 @@ The first time you run a real command before `~/.claude/settings.json` is forge- one tip line points at `forge init` (or `forge doctor --fix`) to wire hooks + permissions; it self-silences once init runs and `FORGE_NO_HINT=1` mutes it entirely. `install.sh` does this wiring for you via `forge init --settings-only` — an idempotent, marker-guarded merge -that never clobbers your existing settings. +that never clobbers your existing settings (skip it with `install.sh --no-settings`; +`install.sh --uninstall` or `forge init --remove-settings` reverses it). | Group | Command | Does | | -------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | @@ -204,7 +214,7 @@ that never clobbers your existing settings. | | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it, `--to ` pins/downgrades | | | `forge docs` | docs↔code drift — `check` reconciles commands/env/MCP/CHANGELOG; `sync` sweeps the diff for stale doc mentions | | | `forge config` | provider setup — show / switch / add providers, set the default model | -| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — shows package/network, writes only with `--yes` | +| | `forge integrations` | opt-in third-party MCP servers (e.g. context7) — `add` records the managed set and writes only with `--yes` (`--adopt` claims a same-name entry you already had); `remove` reverses it | | | `forge harden` | wire the pre-commit gate (gitleaks + commit gate) + sandbox settings | | | `forge catalog` | Start-Here index of every tool / crew / guard | | | `forge brand` | print the brand token map | @@ -276,14 +286,14 @@ Structural differences only — each row is checkable against the named source, tables (including what each adjacent tool does _better_) are in [`reports/benchmarks.md` → Uniqueness](reports/benchmarks.md#uniqueness--structural-contrasts-with-adjacent-tools): -| Property | Forge | Note stores / gateways / RAG | -| -------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Memory confidence moved **only by independent oracles** (tests, CI, human) | yes — closed `ORACLES` table; unverifiable evidence rejected (`src/ledger.js`) | note stores keep notes as written | -| Unreviewed knowledge decays toward _uncertainty_, not deletion | yes — confidence fades over time toward _unsure_; dormant claims kept for audit, never deleted | notes persist unchanged until deleted | -| Conflict-free team merge over plain git | yes — two teammates' memories combine by set-union, so they never conflict (property-tested) | per-machine SQLite or a hosted store | -| Routing decision visible and diffable **before** dispatch | yes — a deterministic rubric you can read in the repo (`src/model_tiers.json`) | gateways decide inside the proxy at request time | -| Cached code served **only with verification evidence**, revalidated against the current code graph | yes — a cache hit is served only if its evidence clears a confidence floor and still matches today's code | plain RAG serves on similarity alone | -| **What they do better** | — | hosted sync, web UIs, embedding search that catches paraphrase; gateways actually _move traffic_ (failover, quotas). Forge is a transparency layer, not a replacement | +| Property | Forge | Note stores / gateways / RAG | +| -------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Memory confidence moved **only by independent oracles** (tests, CI, human) | yes — closed `ORACLES` table; unverifiable evidence rejected at append, forged log lines fail their content-hash recheck at read and imports are quarantined (`src/ledger.js`) | note stores keep notes as written | +| Unreviewed knowledge decays toward _uncertainty_, not deletion | yes — confidence fades over time toward _unsure_; dormant claims kept for audit, never deleted | notes persist unchanged until deleted | +| Conflict-free team merge over plain git | yes — two teammates' memories combine by set-union, so they never conflict (property-tested) | per-machine SQLite or a hosted store | +| Routing decision visible and diffable **before** dispatch | yes — a deterministic rubric you can read in the repo (`src/model_tiers.json`) | gateways decide inside the proxy at request time | +| Cached code served **only with verification evidence**, revalidated against the current code graph | yes — a cache hit is served only if its evidence clears a confidence floor and still matches today's code | plain RAG serves on similarity alone | +| **What they do better** | — | hosted sync, web UIs, embedding search that catches paraphrase; gateways actually _move traffic_ (failover, quotas). Forge is a transparency layer, not a replacement | ## Honest limits diff --git a/docs/GUIDE.md b/docs/GUIDE.md index 1c6ef15..74a5567 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -997,7 +997,7 @@ Plain `forge cost` remains the per-day spend view via `ccusage`. | `forge docs check` | Docs↔code drift: commands, env vars, MCP tools, CHANGELOG reconciled against the code (CI-gated on the forge repo itself). | | `forge docs sync` | Diff-driven stale-docs sweep: UPDATED / STALE (file:line hits) / VERIFIED-UNAFFECTED per artifact (see the full section above). | | `forge catalog` | Start-Here index of every tool / crew / guard. | -| `forge integrations` | Opt-in third-party MCP servers (e.g. `context7`, no longer installed by default). `integrations add ` prints the package, its network behaviour, and the files it touches, then writes only with `--yes`. | +| `forge integrations` | Opt-in third-party MCP servers (e.g. `context7`, no longer installed by default). `integrations add ` prints the package, its network behaviour, and the files it touches, then writes only with `--yes`; the install is recorded in `.forge/forge.config.json` (`mcp.integrations`), so every later sync keeps emitting it. A same-name server you configured yourself is never overwritten unless you pass `--adopt` (recorded under `mcp.adopted`). `integrations remove ` reverses the add — it deletes only forge-owned entries/blocks/files and is a no-op when nothing is installed. | | `forge brain` / `forge remember` | Portable project memory inlined into `AGENTS.md`. | | `forge cost` | Real per-day spend (via `ccusage`) + the cost ceiling; `--stages` for the measured report. | | `forge scan ` | Vet a skill/MCP (SKILL.md/.mcp.json) for injection/RCE/exfil before install. | @@ -1266,6 +1266,7 @@ code reads but this table misses fails CI on the forge repo): | `FORGE_ROOT` | repo root override for the MCP server | | `FORGE_AUTHOR` | identity stamped on ledger provenance (defaults to git identity) | | `FORGE_COST_CEILING` | daily spend (USD) the cost-budget guard warns at (default 10) | +| `FORGE_GUARD_STRICT` | `1` makes secret-redaction failure blocking — a broken/missing redactor exits 2 (block) instead of warning `DEGRADED` and passing output through | | `FORGE_LOOP_THRESHOLD` | identical tool calls before the doom-loop guard speaks (default 4) | | `FORGE_LEAN_THRESHOLD` | lines-per-task-word ratio the lean guard nudges at | | `FORGE_VERIFY_TIMEOUT_MS` | verify test-run timeout (default 600000) | diff --git a/global/guards/protect-paths.sh b/global/guards/protect-paths.sh index fc4f359..48f0be3 100755 --- a/global/guards/protect-paths.sh +++ b/global/guards/protect-paths.sh @@ -45,7 +45,10 @@ if [ -n "${cmd:-}" ]; then # message mentioning ".env") isn't a false positive, AND require a protected path token. # Best-effort defence in depth — a content scan like `rg TOKEN .` with no named path can't # be caught here; that's what secret-redact.sh is for. - reader='(^|[;&|])[[:space:]]*((cat|less|more|head|tail|nl|xxd|od|strings|base64|rg|grep|ag)[[:space:]]|git[[:space:]]+(show|log)[[:space:]])' + # Git subcommands that can print file/history content (RA-05): show, log, diff, + # stash (show -p), cat-file, archive, grep. Subcommand may be followed by a space + # or end the command string. + reader='(^|[;&|])[[:space:]]*((cat|less|more|head|tail|nl|xxd|od|strings|base64|rg|grep|ag)[[:space:]]|git[[:space:]]+(show|log|diff|stash|cat-file|archive|grep)([[:space:]]|$))' # \b anchors the extensions so `.key` matches a real key file but NOT `Object.keys`, # and `.env` matches `.env`/`.env.prod` but NOT `.environment`. secret='(\.env(\.[A-Za-z0-9_-]+)?\b|id_rsa\b|id_ed25519\b|\.pem\b|\.key\b|/secrets/|/\.ssh/)' diff --git a/global/guards/secret-redact.mjs b/global/guards/secret-redact.mjs index 9634fe2..48dd39d 100755 --- a/global/guards/secret-redact.mjs +++ b/global/guards/secret-redact.mjs @@ -42,4 +42,13 @@ async function main() { } } -main().catch(() => process.exit(0)); +// RA-06: never fail silently — a broken redactor means the ORIGINAL, unredacted +// output passes through, so say so loudly. FORGE_GUARD_STRICT=1 makes the +// degradation blocking (exit 2 = block per the PreToolUse/PostToolUse hook +// convention, see protect-paths.sh); the default stays advisory (exit 0). +main().catch((err) => { + process.stderr.write( + `forge: secret redaction DEGRADED (${err?.message ?? err})\n`, + ); + process.exit(process.env.FORGE_GUARD_STRICT === "1" ? 2 : 0); +}); diff --git a/global/guards/secret-redact.sh b/global/guards/secret-redact.sh index b7dbc31..0e52619 100755 --- a/global/guards/secret-redact.sh +++ b/global/guards/secret-redact.sh @@ -22,5 +22,9 @@ if command -v node >/dev/null 2>&1 && [ -f "$MJS" ]; then fi # A secret-shaped candidate is present but we cannot redact — do NOT silently drop it. +# FORGE_GUARD_STRICT=1 turns this degradation into a block (exit 2); default is advisory. echo "forge secret-redact: DEGRADED — node unavailable or redactor missing; tool output was NOT scanned for secrets. Install Node 20+ to restore redaction." >&2 +if [ "${FORGE_GUARD_STRICT:-0}" = "1" ]; then + exit 2 +fi exit 0 diff --git a/global/settings.template.json b/global/settings.template.json index 2fae3d7..504b4e2 100644 --- a/global/settings.template.json +++ b/global/settings.template.json @@ -19,7 +19,6 @@ "Bash(git diff:*)", "Bash(git branch:*)", "Bash(git add:*)", - "Bash(git stash:*)", "Bash(ls:*)", "Bash(fd:*)", "Bash(tree:*)", @@ -47,6 +46,7 @@ "ask": [ "Bash(git push:*)", "Bash(git commit:*)", + "Bash(git stash:*)", "Bash(gh pr create:*)", "Bash(gh pr merge:*)", "Bash(npm ci)", diff --git a/install.sh b/install.sh index 90432e1..b329f99 100644 --- a/install.sh +++ b/install.sh @@ -1,10 +1,13 @@ #!/usr/bin/env bash -# Forge installer — idempotent, reversible, offline. -# bash install.sh install (symlink global/ into ~/.forge and ~/.claude, put `forge` on PATH) -# bash install.sh --dry-run print what it would do, change nothing -# bash install.sh --uninstall remove Forge's own symlinks (never touches your other files) -# It never downloads anything and never edits settings.json for you — it prints the -# hook/statusline block to merge by hand, so your existing config is untouched. +# Forge installer — idempotent, reversible, offline. It never downloads anything. +# bash install.sh install (symlink global/ into ~/.forge and ~/.claude, put `forge` on PATH) +# bash install.sh --dry-run print what it would do, change nothing +# bash install.sh --no-settings install, but skip the settings.json merge +# bash install.sh --uninstall remove Forge's symlinks AND the merged settings entries +# Installing MERGES Forge hooks/permissions/statusline into ~/.claude/settings.json +# (GLOBAL — affects all repos) via an idempotent, marker-guarded merge: your file is +# backed up first and existing entries are never clobbered. Skip it with --no-settings; +# reverse it any time with `install.sh --uninstall` or `forge init --remove-settings`. set -euo pipefail REPO="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" @@ -17,12 +20,13 @@ CLAUDE_DIR="$HOME/.claude" BIN_DIR="$HOME/.local/bin" STAMP="$(date +%Y%m%d-%H%M%S)" -DRY=0; MODE=install +DRY=0; MODE=install; NO_SETTINGS=0 for arg in "$@"; do case "$arg" in --dry-run) DRY=1 ;; + --no-settings) NO_SETTINGS=1 ;; --uninstall) MODE=uninstall ;; - -h|--help) sed -n '2,9p' "$0"; exit 0 ;; + -h|--help) sed -n '2,10p' "$0"; exit 0 ;; *) echo "unknown option: $arg" >&2; exit 2 ;; esac done @@ -80,15 +84,35 @@ install_forge() { # Wire guards + statusline into settings.json via the idempotent, marker-guarded merge # (`forge init --settings-only`) instead of printing a block to paste by hand. mergeSettings # never clobbers existing entries — it unions hooks/permissions and stamps a _forge marker, - # so re-running install.sh is a no-op. Under --dry-run we print the call for transparency. - if [ "$DRY" = 1 ]; then - say "[dry-run] would merge guards + statusline into $CLAUDE_DIR/settings.json via:" + # so re-running install.sh is a no-op. A FAILED merge (e.g. corrupt settings.json) must + # fail the install loudly (RA-04) — capture output first so the exit status is the node + # command's, not sed's, then report INCOMPLETE instead of Done. + SETTINGS_FAILED=0 + if [ "$NO_SETTINGS" = 1 ]; then + say "settings merge skipped (--no-settings) — wire hooks later with: forge init --settings-only" + elif [ "$DRY" = 1 ]; then + say "[dry-run] would merge guards + statusline into $CLAUDE_DIR/settings.json (GLOBAL — affects all repos) via:" say "[dry-run] node \"$REPO/src/cli.js\" init --settings-only" say "[dry-run] (idempotent + _forge-marker-guarded — existing settings are preserved)" else - say "Merging guards + statusline into $CLAUDE_DIR/settings.json (idempotent, never clobbers)" - node "$REPO/src/cli.js" init --settings-only 2>&1 | sed 's/^/ /' \ - || say "note: settings merge skipped — run \`forge init --settings-only\` once node is available" + say "Merging guards + statusline into $CLAUDE_DIR/settings.json (GLOBAL — affects all repos; idempotent, backed up, never clobbers)" + say " skip with --no-settings · reverse with \`forge init --remove-settings\` or \`install.sh --uninstall\`" + MERGE_OUT="" + if MERGE_OUT="$(node "$REPO/src/cli.js" init --settings-only 2>&1)"; then + printf '%s\n' "$MERGE_OUT" | sed 's/^/ /' + else + [ -n "$MERGE_OUT" ] && printf '%s\n' "$MERGE_OUT" | sed 's/^/ /' + SETTINGS_FAILED=1 + echo " WARNING: settings merge FAILED — hooks/guards are NOT wired" >&2 + fi + fi + + if [ "$SETTINGS_FAILED" = 1 ]; then + cat >&2 </dev/null 2>&1; then + REMOVE_OUT="" + if REMOVE_OUT="$(node "$REPO/src/cli.js" init --remove-settings 2>&1)"; then + printf '%s\n' "$REMOVE_OUT" | sed 's/^/ /' + else + [ -n "$REMOVE_OUT" ] && printf '%s\n' "$REMOVE_OUT" | sed 's/^/ /' + say "note: automatic settings cleanup failed — remove the Forge hook/permission/statusline entries from $CLAUDE_DIR/settings.json by hand, or fix the file and run: forge init --remove-settings" + fi + else + say "note: node not found — remove the Forge hook/permission/statusline entries from $CLAUDE_DIR/settings.json by hand, or run \`forge init --remove-settings\` once node is available" + fi for d in "$REPO"/global/tools/*/; do [ -d "$d" ] && unlink_ours "$CLAUDE_DIR/skills/$(basename "$d")"; done for f in "$REPO"/global/crew/*.md; do [ -e "$f" ] && unlink_ours "$CLAUDE_DIR/agents/$(basename "$f")"; done unlink_ours "$BIN_DIR/forge" diff --git a/package.json b/package.json index c60ec1d..6853833 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@codewithjuber/forgekit", "version": "0.22.0", - "description": "One brain for every AI coding agent — the cognitive substrate every frozen model is missing (proof-carrying memory, impact foresight, enforced guardrails), authored once and delivered as native config to Claude Code, Codex, Cursor, Gemini, Aider, and more.", + "description": "Shared memory, impact analysis, and guardrail hooks for AI coding agents — authored once, emitted as native config for Claude Code, Codex, Cursor, Gemini, Aider, and more.", "type": "module", "bin": { "forge": "src/cli.js" diff --git a/src/cli.js b/src/cli.js index 3693476..e5194d1 100755 --- a/src/cli.js +++ b/src/cli.js @@ -95,17 +95,59 @@ async function run(argv) { if (cmd === "init") { const { init } = await import("./init.js"); const noSettings = argv.includes("--no-settings"); + // Test/plumbing override of the merge target — not user-facing surface. + const settingsPath = process.env.FORGE_SETTINGS_PATH || undefined; + // RA-11: the settings merge touches GLOBAL state — say so before reporting it, + // on merged AND unchanged runs, with the opt-out and the reversal path. + const consentLine = (path) => + console.log( + ` settings: merging ${BRAND.brand} hooks into ${path} (GLOBAL — affects all repos). ` + + `Skip with --no-settings; reverse with \`${BRAND.cli} init --remove-settings\`.`, + ); + // --remove-settings: reverse the merge (RA-17) — strip every template-shaped hook, + // permission, statusline, and the _forge marker; user-owned entries stay untouched. + if (argv.includes("--remove-settings")) { + const { removeForgeSettings } = await import("./init.js"); + const r = removeForgeSettings({ settingsPath }); + heading(`${BRAND.brand} init — remove managed settings\n`); + if (r.action === "removed") { + console.log(` settings: removed ${r.removed.join(", ")} from ${r.path}`); + console.log(` backup: ${r.backup}`); + } else if (r.action === "noop") { + console.log(` settings: nothing to remove (${r.reason})`); + } else { + console.error(` settings: NOT modified — ${r.path}: ${r.reason}`); + process.exitCode = 1; + } + return; + } // --settings-only: wire hooks + permissions into ~/.claude/settings.json ONLY (the // idempotent, marker-guarded merge install.sh calls) — no repo emit, no AGENTS.md. if (argv.includes("--settings-only")) { - const { settings } = init({ settingsOnly: true, noSettings }); + const { settings } = init({ + settingsOnly: true, + noSettings, + settingsPath, + }); heading(`${BRAND.brand} init — settings merge only\n`); - if (settings?.action === "merged" && "added" in settings) { - console.log(` settings: merged ${settings.added.join(", ")} into ${settings.path}`); + if ( + (settings?.action === "merged" || settings?.action === "created") && + "added" in settings + ) { + consentLine(settings.path); + const verb = settings.action === "created" ? "created" : "merged"; + const what = settings.added.length ? settings.added.join(", ") : "defaults"; + console.log(` settings: ${verb} ${what} into ${settings.path}`); } else if (settings?.action === "unchanged" && "path" in settings) { + consentLine(settings.path); console.log(` settings: already up to date (${settings.path})`); } else if (settings?.action === "skipped") { console.log(" settings: skipped (--no-settings)"); + } else if (settings?.action === "error") { + // RA-04: a refused/failed merge must FAIL the command, not silently exit 0 — + // install.sh keys its "install incomplete" path off this exit code. + console.error(` settings: FAILED — ${settings.path}: ${settings.reason}`); + process.exitCode = 1; } return; } @@ -122,6 +164,7 @@ async function run(argv) { targetRoot: process.cwd(), noSettings, profile, + settingsPath, }) ); if (profileResult?.error) { @@ -137,19 +180,29 @@ async function run(argv) { ); if (profileResult?.profile) { console.log(` profile: ${profileResult.profile} → .forge/forge.config.json`); + if (profileResult.deprecated) { + console.error( + ` warning: profile "${profileResult.deprecated}" is deprecated — treated as "${profileResult.profile}"`, + ); + } } if ((settings?.action === "merged" || settings?.action === "created") && "added" in settings) { + consentLine(settings.path); const verb = settings.action === "created" ? "created" : "merged"; const what = settings.added.length ? settings.added.join(", ") : "defaults"; console.log(` settings: ${verb} ${what} into ${settings.path}`); if ("backup" in settings && settings.backup) console.log(` backup: ${settings.backup}`); } else if (settings?.action === "unchanged" && "path" in settings) { + consentLine(settings.path); console.log(` settings: already up to date (${settings.path})`); } else if (settings?.action === "skipped") { console.log(" settings: skipped (--no-settings)"); } else if (settings?.action === "error") { - console.log(` settings: NOT written — ${settings.reason}`); + // RA-04: repo files above were still emitted, but the settings merge FAILED — + // surface it on stderr and fail the command instead of a quiet exit 0. + console.error(` settings: FAILED — ${settings.reason} (repo files were still emitted)`); + process.exitCode = 1; } if (detected) { console.log(` provider: auto-detected ${detected.name} from ${detected.source}`); @@ -352,6 +405,7 @@ async function run(argv) { ok: paint("✓", "ok"), warn: paint("!", "warn"), fail: paint("✗", "err"), + na: paint("–", "dim"), // not built/applicable — neutral, never a failure }; heading(`${BRAND.brand} doctor\n`); if (fix) { @@ -414,7 +468,9 @@ async function run(argv) { return; } if (cmd === "integrations") { - const { listIntegrations, planIntegration, addIntegration } = await import("./integrations.js"); + const { listIntegrations, planIntegration, addIntegration, removeIntegration } = await import( + "./integrations.js" + ); const sub = argv[1]; if (sub === "add") { const name = argv[2]; @@ -431,15 +487,48 @@ async function run(argv) { console.log(` network: ${plan.network}`); console.log(` purpose: ${plan.why}`); console.log(` writes: .mcp.json, .cursor/mcp.json, .gemini/…, .codex/…, .continue/…`); + console.log(` records: .forge/forge.config.json (mcp.integrations — the managed set)`); console.log( `\n Not installed. Re-run with --yes to apply: ${BRAND.cli} integrations add ${name} --yes`, ); + console.log( + ` A same-name server you configured yourself is never overwritten unless you also pass --adopt.`, + ); return; } - const res = addIntegration(name, { targetRoot: process.cwd() }); - const wrote = res.rows.filter((x) => x.action === "written").map((x) => x.target); + const res = addIntegration(name, { + targetRoot: process.cwd(), + adopt: argv.includes("--adopt"), + }); heading(`${BRAND.brand} integrations — add ${name}\n`); + if (res.ok === false) { + console.error(` ${res.reason}`); + process.exitCode = 1; + return; + } + const wrote = res.rows.filter((x) => x.action === "written").map((x) => x.target); console.log(` added ${name} → ${wrote.length ? wrote.join(", ") : "(all up to date)"}`); + for (const r of res.rows.filter((x) => x.action === "skipped")) + console.log(` ! ${r.target}: ${r.note}`); + return; + } + if (sub === "remove") { + const name = argv[2]; + const res = removeIntegration(name, { targetRoot: process.cwd() }); + heading(`${BRAND.brand} integrations — remove ${name}\n`); + if (res.ok === false) { + console.error(` ${res.reason}`); + process.exitCode = 1; + return; + } + if (!res.removed) { + console.log(` ${name} is not installed — nothing to remove`); + return; + } + const wrote = res.rows.filter((x) => x.action === "written").map((x) => x.target); + console.log(` removed ${name} → ${wrote.length ? wrote.join(", ") : "(nothing on disk)"}`); + for (const r of res.rows.filter((x) => x.action === "skipped")) + console.log(` ! ${r.target}: ${r.note}`); return; } // Default: list what's available. @@ -447,7 +536,8 @@ async function run(argv) { for (const it of listIntegrations()) { console.log(` ${it.name.padEnd(12)} ${it.why} (${it.pkg})`); } - console.log(`\n Add one with: ${BRAND.cli} integrations add `); + console.log(`\n Add one with: ${BRAND.cli} integrations add `); + console.log(` Remove one with: ${BRAND.cli} integrations remove `); return; } if (cmd === "recall") { @@ -562,6 +652,10 @@ async function run(argv) { const r = ls.mergeDirs(dir, src); if (json) return console.log(JSON.stringify(r, null, 2)); console.log(` merged: ${r.claims} new claim(s), ${r.records} new record(s) — conflict-free`); + if (r.quarantined) + console.log( + ` quarantined: ${r.quarantined} invalid record(s) (forged hash or unresolvable ref — see quarantine/ in the ledger dir)`, + ); return; } if (sub === "blame") { @@ -1014,11 +1108,24 @@ async function run(argv) { console.log( ` remainingUncheckedWeight: ${r.residual.toFixed(3)} — Theorem-D silent-miss bound (heuristic)`, ); + // The core tests state, spelled out — deep ok REQUIRES a passing core, so the + // reader must see whether a verifier actually ran (RA-01). + const t = r.tests ?? /** @type {import("./verify.js").VerifyTests} */ ({ ran: false }); console.log( - `\n ${ - r.ok ? paint("PASS", "ok") : paint("BLOCKED — cross-family consensus says defect", "err") + ` tests: ${t.status ?? "unknown"}${t.runner ? ` (${t.runner})` : ""}${ + !t.runner && t.detected?.length ? ` (detected: ${t.detected.join(", ")})` : "" }`, ); + const detail = t.output || (t.detected ?? []).join(", "); + const verdictLine = + r.status === "PASS" + ? paint("PASS", "ok") + : r.status === "NOT_CONFIGURED" + ? paint("NOT VERIFIED — no test runner configured (NOT_CONFIGURED)", "warn") + : r.status === "INCOMPLETE" + ? paint(`NOT VERIFIED — tests incomplete${detail ? ` (${detail})` : ""}`, "warn") + : paint("BLOCKED — cross-family consensus says defect", "err"); + console.log(`\n ${verdictLine}`); if (!r.ok) process.exitCode = 1; return; } @@ -1031,16 +1138,32 @@ async function run(argv) { } heading(`${BRAND.brand} verify\n`); console.log(` changed files: ${r.changedFiles.length}`); - console.log( - ` tests: ${r.tests.ran ? (r.tests.passed ? "✓ pass" : "✗ FAIL") : "— none detected"}`, - ); + // Honest four-state tests line (RA-09): "nothing ran" is never dressed up as a pass, + // and a real run names the runner that actually executed. + const t = r.tests; + const testsLine = + t.status === "PASS" + ? `✓ pass (${t.runner ?? "project suite"})` + : t.status === "FAIL" + ? `✗ FAIL (${t.runner ?? "project suite"})` + : t.status === "INCOMPLETE" + ? `— INCOMPLETE: ${t.output || (t.detected ?? []).join(", ") || "test run did not complete"}` + : "— NOT CONFIGURED (no test runner detected)"; + console.log(` tests: ${testsLine}`); console.log(` symbols checked: ${r.provenance.symbolsChecked}`); if (r.unknown.length) console.log( ` ! not in codebase (possible hallucination): ${r.unknown.slice(0, 12).join(", ")}`, ); console.log(` provenance: .forge/provenance.json`); - console.log(`\n ${r.ok ? "PASS" : "BLOCKED — tests failing"}`); + // BLOCKED is reserved for a runner that actually FAILED; anything that never ran + // to completion is NOT VERIFIED (still exit 1 — unverified is not a pass). + const verdict = r.ok + ? "PASS" + : t.status === "FAIL" + ? "BLOCKED — tests failing" + : `NOT VERIFIED — ${t.status}`; + console.log(`\n ${verdict}`); if (!r.ok) process.exitCode = 1; return; } diff --git a/src/commands.js b/src/commands.js index 05d93a6..859b66f 100644 --- a/src/commands.js +++ b/src/commands.js @@ -11,18 +11,27 @@ export const COMMANDS = { init: { summary: "scaffold this repo's config — emits every tool from one shared source", - usage: "forge init [--no-settings | --settings-only]", + usage: + "forge init [--profile minimal|standard] [--no-settings | --settings-only | --remove-settings]", flags: [ + { + flag: "--profile ", + desc: "policy profile: minimal = the five core-safety rules only; standard = the full pack (default). Legacy names (web-app, backend-service, library, regulated) are deprecated aliases of standard.", + }, { flag: "--no-settings", - desc: "emit tool configs but don't touch ~/.claude/settings.json", + desc: "emit tool configs but don't touch ~/.claude/settings.json (the merge is GLOBAL — it affects all repos)", }, { flag: "--settings-only", desc: "only merge hooks/permissions into settings (skip repo emit)", }, + { + flag: "--remove-settings", + desc: "reverse the merge — remove forge-managed hooks/permissions/statusline from ~/.claude/settings.json (backed up first)", + }, ], - examples: ["forge init", "forge init --no-settings"], + examples: ["forge init", "forge init --profile minimal", "forge init --remove-settings"], }, sync: { summary: "recompile the canonical source into each tool's native config files", @@ -102,7 +111,7 @@ export const COMMANDS = { brand: "print the active brand token map", docs: "docs↔code drift — check (registry reconcile) / sync (diff-driven stale-docs sweep)", integrations: - "opt-in third-party MCP servers (e.g. context7) — shows package/network, writes only with --yes", + "opt-in third-party MCP servers (e.g. context7) — add records the managed set and writes only with --yes (--adopt claims a same-name entry); remove reverses it", }; // Groups order the --help surface from the stable reliability loop down to experiments. diff --git a/src/consensus.js b/src/consensus.js index 6b6c536..c043329 100644 --- a/src/consensus.js +++ b/src/consensus.js @@ -6,6 +6,11 @@ // solo-trusted lens) so a pile of correlated structural signals can never block on its // own. `p` is a calibrated heuristic, NOT a proof or a measured defect probability. // +// Deep `ok` is a CONJUNCTION: the core verify's tests must be PASS **and** the lens +// consensus must not block. A core that never ran a verifier (NOT_CONFIGURED / +// INCOMPLETE) can never yield ok:true — abstaining lenses widen `residual`, they do +// not manufacture a green light. +// // Mizan (weighed judgment — a philosophical/ethical framing, not a technical guarantee): // the verdict ships WITH its evidence. Every lens reports whether it ran and what it saw, // and the remaining-unchecked-weight bound ∏ⱼ(1 − cⱼ) over the lenses that actually ran @@ -269,8 +274,12 @@ const round4 = (x) => Number(Number(x).toFixed(4)); * Multi-lens verification: run plain `verify()` (tests + unknown symbols + base * provenance), add the structural/security lenses and the optional reviewer panel, * aggregate, and persist — findings extend `.forge/provenance.json` and one - * `stage:"verify"` metrics record is appended. Blocks (ok:false) only on - * cross-family consensus or a solo-trusted lens at P(defect) ≥ BLOCK_THRESHOLD. + * `stage:"verify"` metrics record is appended. Blocks on cross-family consensus or + * a solo-trusted lens at P(defect) ≥ BLOCK_THRESHOLD, and `ok` additionally + * REQUIRES the core tests status to be PASS: a core that never ran a verifier + * (NOT_CONFIGURED / INCOMPLETE) is never ok:true, however clean the lenses look. + * `status` mirrors the four-state core verdict (non-PASS core wins; else the + * consensus decides PASS/FAIL). * @param {object} [opts] * @param {string} [opts.targetRoot] * @param {string} [opts.base] @@ -305,6 +314,15 @@ export function verifyDeep({ ]; const verdict = aggregate(lenses); const findings = findingsOf(lenses); + // Four-state core verdict (RA-01). Older/injected cores without `status` fall back to + // the ran/passed pair; a core whose suite never ran is NOT_CONFIGURED, never PASS. + const testsStatus = + core.tests?.status ?? + (core.tests?.ran ? (core.tests.passed ? "PASS" : "FAIL") : "NOT_CONFIGURED"); + // Deep status: a non-PASS core is the verdict (nothing to consense over); a PASSing + // core hands the call to the lens consensus. + const status = testsStatus !== "PASS" ? testsStatus : verdict.block ? "FAIL" : "PASS"; + const ok = testsStatus === "PASS" && !verdict.block; const deep = { lenses: lenses.map((l) => ({ lens: l.lens, @@ -319,6 +337,7 @@ export function verifyDeep({ fires: verdict.fires, residual: round4(verdict.residual), block: verdict.block, + status, // additive: the four-state deep verdict (RA-01) }; const provenance = { ...core.provenance, deep }; try { @@ -330,14 +349,18 @@ export function verifyDeep({ } catch {} recordMetric(targetRoot, { stage: "verify", + // `outcome` keeps its historical block|pass vocabulary; the four-state verdict + // rides along additively as `status`. outcome: verdict.block ? "block" : "pass", + status, mode: "deep", lenses: deep.lenses.filter((l) => l.ran).length, p: deep.p, residual: deep.residual, }); return { - ok: !verdict.block, + ok, + status, block: verdict.block, p: deep.p, fires: verdict.fires, diff --git a/src/docs_check.js b/src/docs_check.js index 6d3c8c8..ef80e5b 100644 --- a/src/docs_check.js +++ b/src/docs_check.js @@ -20,6 +20,9 @@ const DOC_FILES = ["README.md", "docs/GUIDE.md", "ARCHITECTURE.md", "ROADMAP.md" const INTERNAL_ENV = new Set([ "_FORGE_LLM_KEY", "FORGE_EMBED_KEY", + // Test-only override of the settings.json path `forge init` targets — plumbing for + // exercising merge/remove/exit-code behavior without touching the real ~/.claude. + "FORGE_SETTINGS_PATH", "CLAUDE_PLUGIN_ROOT", // Standard XDG base-dir var forge honors for its state home — not forge's own surface. "XDG_STATE_HOME", diff --git a/src/doctor.js b/src/doctor.js index c4a480d..dfea165 100644 --- a/src/doctor.js +++ b/src/doctor.js @@ -27,6 +27,9 @@ import { updateStatus } from "./update.js"; const ok = (label, note = "") => ({ status: "ok", label, note }); const warn = (label, note = "") => ({ status: "warn", label, note }); const fail = (label, note = "") => ({ status: "fail", label, note }); +// Not applicable / not built: a subsystem that simply is not there. Neither healthy nor +// failing — it must not render as ACTIVE (RA-19) and never counts toward failed totals. +const na = (label, note = "") => ({ status: "na", label, note }); import { hasBin } from "./util.js"; @@ -150,7 +153,9 @@ function checkPricing(out) { function checkAtlas(out, targetRoot) { const atlas = loadAtlas(targetRoot); if (!atlas) { - out.push(ok("atlas", "not built — run `forge atlas build` for impact/verify")); + // "na", not "ok": a missing atlas means impact/verify are UNAVAILABLE, and reporting + // it green would surface as an ACTIVE subsystem in the health line (RA-19). + out.push(na("atlas", "not built — run `forge atlas build` for impact/verify")); return; } out.push( @@ -492,6 +497,7 @@ export function subsystemHealth(results) { if (!r) return "UNAVAILABLE"; if (r.status === "fail") return "FAILED"; if (r.status === "warn") return "DEGRADED"; + if (r.status === "na") return "UNAVAILABLE"; // not built/applicable ≠ ACTIVE (RA-19) return "ACTIVE"; }; const health = {}; @@ -540,7 +546,16 @@ export function doctor({ targetRoot = process.cwd(), fix = false, settingsPath } if ((r.status === "warn" || r.status === "fail") && r.fix) { try { const detail = r.fix.run(); - repairs.push({ id: r.fix.id, label: r.fix.label, ok: true, detail }); + // Repairs like mergeSettings report failure as a RETURNED {action:"error", reason} + // instead of throwing — an errored result must not be recorded as ok (RA-20). + const errored = detail && typeof detail === "object" && detail.action === "error"; + repairs.push({ + id: r.fix.id, + label: r.fix.label, + ok: !errored, + error: errored ? detail.reason : undefined, + detail: errored ? undefined : detail, + }); } catch (err) { repairs.push({ id: r.fix.id, diff --git a/src/emit/_shared.js b/src/emit/_shared.js index 659845b..e98110e 100644 --- a/src/emit/_shared.js +++ b/src/emit/_shared.js @@ -37,5 +37,11 @@ export function writeIfChanged(absPath, content) { return "written"; } +// The exact on-disk bytes of a managed file. BOTH the writer (writeManaged) and any +// drift check (autoSyncIfDrifted) must build the expectation through this one helper — +// comparing anything less than these full bytes (e.g. only the embedded marker hash) +// lets a hand-edited body with an intact marker pass as "in sync" (RA-16). +export const managedContent = (header, body) => `${header}\n${body}\n`; + export const writeManaged = (absPath, header, body) => - writeIfChanged(absPath, `${header}\n${body}\n`); + writeIfChanged(absPath, managedContent(header, body)); diff --git a/src/emit/mcp.js b/src/emit/mcp.js index 8e56249..7830371 100644 --- a/src/emit/mcp.js +++ b/src/emit/mcp.js @@ -1,9 +1,14 @@ -// Emit the canonical MCP server set (source/mcp.json) into each tool's MCP config, -// in that tool's REAL format (all verified 2026-07). Always MERGE — never clobber a -// user's own servers. JSON tools differ only by their top-level key; Codex is TOML and -// Continue is a YAML block file. (Windsurf is global-only — no per-repo file to emit.) -import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +// Emit the managed MCP server set into each tool's MCP config, in that tool's REAL +// format (all verified 2026-07). Non-destructive by construction (RA-03, RA-21): +// every write is scoped to an entry/block/file forge OWNS — a name in the persistent +// managed∪adopted record (see integrations.js) or a region carrying a forge marker. +// Anything else is user-owned and is reported, never rewritten. JSON tools differ only +// by their top-level key; Codex is TOML (forge-marked blocks) and Continue gets one +// forge-marked YAML file per managed server. (Windsurf is global-only — nothing to emit.) +import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { BRAND } from "../brand.js"; +import { hashContent, isManaged, readIfExists, writeManaged, yamlHeader } from "./_shared.js"; const JSON_TARGETS = [ { tool: "Claude Code", file: ".mcp.json", key: "mcpServers" }, @@ -14,7 +19,21 @@ const JSON_TARGETS = [ { tool: "VS Code / Copilot", file: ".vscode/mcp.json", key: "servers" }, ]; -function mergeJson(path, key, servers) { +const CONTINUE_DIR = join(".continue", "mcpServers"); +const LEGACY_CONTINUE_FILE = "forge-mcp.yaml"; // pre-RA-03 combined file +const CODEX_FILE = join(".codex", "config.toml"); + +const adoptHint = (name) => `adopt with: ${BRAND.cli} integrations add ${name} --adopt`; + +/** Per-server Continue file name. Servers already namespaced `forge-*` keep their name. */ +export const continueFileFor = (name) => + name.startsWith("forge-") ? `${name}.yaml` : `forge-${name}.yaml`; + +// --------------------------------------------------------------------------- +// JSON targets — entry-level ownership (RA-21). +// --------------------------------------------------------------------------- + +function mergeJson(path, key, servers, owned) { let obj = {}; if (existsSync(path)) { try { @@ -24,54 +43,231 @@ function mergeJson(path, key, servers) { } } const bucket = obj[key] || (obj[key] = {}); - // Managed-entry ownership (P0-06): every server WE emit is forge-managed, so add it when - // missing AND refresh it when a prior emission has drifted (stale command/args). Servers - // the user added under other names are never touched — we only iterate our own set. + // Ownership rules: absent → write; present + owned → refresh on drift; present + + // NOT owned → leave byte-identical and say so (the entry is the user's — a same-name + // server they configured themselves must never be silently replaced). let changed = 0; + const skipped = []; for (const [name, def] of Object.entries(servers)) { - if (!bucket[name] || JSON.stringify(bucket[name]) !== JSON.stringify(def)) { + const cur = bucket[name]; + if (cur === undefined) { bucket[name] = def; changed += 1; + } else if (JSON.stringify(cur) !== JSON.stringify(def)) { + if (owned.has(name)) { + bucket[name] = def; + changed += 1; + } else { + skipped.push(name); + } } } - if (!changed) return { action: "unchanged", note: "present" }; + const skipNote = skipped.length + ? `skipped ${skipped.join(", ")}: user-owned — ${adoptHint(skipped[0])}` + : ""; + if (!changed) { + return skipped.length + ? { action: "skipped", note: skipNote } + : { action: "unchanged", note: "present" }; + } mkdirSync(dirname(path), { recursive: true }); writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`); - return { action: "written", note: `${changed} server(s) written/updated` }; + const note = `${changed} server(s) written/updated${skipNote ? `; ${skipNote}` : ""}`; + return { action: "written", note }; } -function emitCodexToml(path, servers) { - const existing = existsSync(path) ? readFileSync(path, "utf8") : ""; - let blocks = ""; - for (const [name, def] of Object.entries(servers)) { - if (existing.includes(`[mcp_servers.${name}]`)) continue; - const args = (def.args || []).map((a) => JSON.stringify(a)).join(", "); - blocks += `\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(def.command)}\nargs = [${args}]\n`; +/** Delete one server entry from a JSON target. Never touches anything else in the file. */ +function removeFromJson(path, key, name) { + if (!existsSync(path)) return { action: "unchanged", note: "absent" }; + let obj; + try { + obj = JSON.parse(readFileSync(path, "utf8")); + } catch { + return { action: "skipped", note: "invalid JSON — left as-is" }; } - if (!blocks) return { action: "unchanged", note: "present" }; - mkdirSync(dirname(path), { recursive: true }); - writeFileSync(path, existing + blocks); - return { action: "written", note: "TOML block appended" }; + const bucket = obj?.[key]; + if (!bucket || bucket[name] === undefined) return { action: "unchanged", note: "absent" }; + delete bucket[name]; + writeFileSync(path, `${JSON.stringify(obj, null, 2)}\n`); + return { action: "written", note: `${name} removed` }; } -function emitContinueYaml(dir, servers) { - const path = join(dir, "forge-mcp.yaml"); - const lines = ["name: Forge MCP", "version: 0.0.1", "schema: v1", "mcpServers:"]; +// --------------------------------------------------------------------------- +// Codex TOML — forge-marked managed blocks. +// --------------------------------------------------------------------------- + +const codexBegin = (name) => `# forge:managed:${name} begin`; +const codexEnd = (name) => `# forge:managed:${name} end`; + +function codexBlock(name, def) { + const args = (def.args || []).map((a) => JSON.stringify(a)).join(", "); + return `${codexBegin(name)}\n[mcp_servers.${name}]\ncommand = ${JSON.stringify(def.command)}\nargs = [${args}]\n${codexEnd(name)}\n`; +} + +/** What the pre-marker emitter wrote for this server — the migration fingerprint. A + * byte-identical unmarked block is provably a past forge emission, safe to claim. */ +function legacyCodexBlock(name, def) { + const args = (def.args || []).map((a) => JSON.stringify(a)).join(", "); + return `[mcp_servers.${name}]\ncommand = ${JSON.stringify(def.command)}\nargs = [${args}]\n`; +} + +/** The extent [start, end) of a forge-marked region for `name`, or null. */ +function markedRegion(text, name) { + const start = text.indexOf(codexBegin(name)); + if (start < 0) return null; + const endMarker = codexEnd(name); + const endAt = text.indexOf(endMarker, start); + if (endAt < 0) return { start, end: -1 }; // damaged: begin without end + let end = endAt + endMarker.length; + if (text[end] === "\n") end += 1; + return { start, end }; +} + +/** The extent [start, end) of an UNMARKED `[mcp_servers.]` block: from its header + * line to the next `[`-table or forge marker at line start (or EOF), or null. */ +function unmarkedRegion(text, name) { + const header = `[mcp_servers.${name}]`; + let idx = -1; + for (const m of text.matchAll(/^\[mcp_servers\.([^\]]+)\]/gm)) { + if (m[1] === name) { + idx = m.index; + break; + } + } + if (idx < 0) return null; + if (text.slice(0, idx).trimEnd().endsWith(codexBegin(name))) return null; // it's marked + const rest = text.slice(idx + header.length); + const next = rest.search(/^\[|^# forge:managed:/m); + const end = next < 0 ? text.length : idx + header.length + next; + return { start: idx, end }; +} + +function emitCodexToml(path, servers, owned) { + let text = existsSync(path) ? readFileSync(path, "utf8") : ""; + let changed = 0; + const notes = []; for (const [name, def] of Object.entries(servers)) { - lines.push(` - name: ${name}`, " type: stdio", ` command: ${def.command}`, " args:"); - for (const a of def.args || []) lines.push(` - ${JSON.stringify(a)}`); + const block = codexBlock(name, def); + const marked = markedRegion(text, name); + if (marked) { + if (marked.end < 0) { + notes.push(`${name}: begin marker without end — left as-is`); + continue; + } + const cur = text.slice(marked.start, marked.end); + if (cur !== block) { + text = text.slice(0, marked.start) + block + text.slice(marked.end); + changed += 1; + } + continue; + } + const unmarked = unmarkedRegion(text, name); + if (unmarked) { + const cur = text.slice(unmarked.start, unmarked.end); + // Migration/ownership for a pre-existing plain block: claim it only when the name + // is in the owned record OR the block byte-matches a past forge emission. Anything + // else is the user's TOML — leave it byte-identical. + if (owned.has(name) || cur.trimEnd() === legacyCodexBlock(name, def).trimEnd()) { + text = text.slice(0, unmarked.start) + block + text.slice(unmarked.end); + changed += 1; + notes.push(`${name}: adopted unmarked block (forge markers added)`); + } else { + notes.push(`skipped ${name}: user-owned — ${adoptHint(name)}`); + } + continue; + } + text += `${text && !text.endsWith("\n\n") ? "\n" : ""}${block}`; + changed += 1; + } + if (!changed) { + const skippedOnly = notes.length && notes.every((n) => n.startsWith("skipped")); + return { + action: skippedOnly ? "skipped" : "unchanged", + note: notes.join("; ") || "present", + }; } - const content = `${lines.join("\n")}\n`; - if (existsSync(path) && readFileSync(path, "utf8") === content) - return { action: "unchanged", note: "present" }; - mkdirSync(dir, { recursive: true }); - writeFileSync(path, content); - return { action: "written", note: "YAML block file" }; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, text); + return { + action: "written", + note: [`${changed} managed block(s)`, ...notes].join("; "), + }; +} + +/** Remove the forge-marked Codex block for `name`. Unmarked blocks are never touched. */ +function removeCodexBlock(path, name) { + if (!existsSync(path)) return { action: "unchanged", note: "absent" }; + const text = readFileSync(path, "utf8"); + const marked = markedRegion(text, name); + if (!marked || marked.end < 0) return { action: "unchanged", note: "no forge-marked block" }; + writeFileSync(path, text.slice(0, marked.start) + text.slice(marked.end)); + return { action: "written", note: `${name} block removed` }; +} + +// --------------------------------------------------------------------------- +// Continue — one forge-marked YAML file per managed server (never a combined file). +// --------------------------------------------------------------------------- + +function continueBody(name, def) { + const lines = [ + `name: ${continueFileFor(name).replace(/\.yaml$/, "")}`, + "version: 0.0.1", + "schema: v1", + "mcpServers:", + ` - name: ${name}`, + " type: stdio", + ` command: ${def.command}`, + " args:", + ]; + for (const a of def.args || []) lines.push(` - ${JSON.stringify(a)}`); + return lines.join("\n"); +} + +function emitContinueServer(dir, name, def) { + const path = join(dir, continueFileFor(name)); + const existing = readIfExists(path); + if (existing !== null && !isManaged(existing)) + return { action: "skipped", note: "existing unmanaged file" }; + const body = continueBody(name, def); + const action = writeManaged(path, yamlHeader(hashContent(body)), body); + return { action, note: "per-server YAML" }; +} + +/** Migrate away the pre-RA-03 combined forge-mcp.yaml once per-server files exist. + * Deleted only when it is provably forge's: it carries the forge marker, or it is the + * old emitter's exact fingerprint (`name: Forge MCP` header + `schema: v1`, which the + * old generator always wrote and never marked). A file that matches neither is the + * user's and stays. */ +function migrateLegacyContinue(dir) { + const path = join(dir, LEGACY_CONTINUE_FILE); + const existing = readIfExists(path); + if (existing === null) return null; + const oldFingerprint = + existing.startsWith("name: Forge MCP\n") && existing.includes("schema: v1"); + if (!isManaged(existing) && !oldFingerprint) + return { action: "skipped", note: "unmanaged legacy file left in place" }; + rmSync(path, { force: true }); + return { + action: "written", + note: "legacy combined file removed (now per-server files)", + }; } -export function emitMcp({ targetRoot, servers }) { +// --------------------------------------------------------------------------- +// Entry points. +// --------------------------------------------------------------------------- + +/** + * Emit `servers` (the full managed set) into every target. `owned` is the set of names + * forge may OVERWRITE when a same-name entry already exists and drifted — the persistent + * managed∪adopted record plus the built-in registry names. Absent entries are always + * written; entries outside `owned` are preserved and reported. + * @param {{targetRoot:string, servers:Record, + * owned?:Set}} opts + */ +export function emitMcp({ targetRoot, servers, owned = new Set(Object.keys(servers)) }) { const rows = JSON_TARGETS.map((t) => { - const r = mergeJson(join(targetRoot, t.file), t.key, servers); + const r = mergeJson(join(targetRoot, t.file), t.key, servers, owned); return { tool: `${t.tool} MCP`, target: t.file, @@ -79,19 +275,115 @@ export function emitMcp({ targetRoot, servers }) { note: r.note, }; }); - const codex = emitCodexToml(join(targetRoot, ".codex", "config.toml"), servers); + const codex = emitCodexToml(join(targetRoot, CODEX_FILE), servers, owned); rows.push({ tool: "Codex MCP", target: ".codex/config.toml", action: codex.action, note: codex.note, }); - const cont = emitContinueYaml(join(targetRoot, ".continue", "mcpServers"), servers); + const dir = join(targetRoot, CONTINUE_DIR); + for (const [name, def] of Object.entries(servers)) { + const r = emitContinueServer(dir, name, def); + rows.push({ + tool: "Continue MCP", + target: `.continue/mcpServers/${continueFileFor(name)}`, + action: r.action, + note: r.note, + }); + } + const legacy = migrateLegacyContinue(dir); + if (legacy) { + rows.push({ + tool: "Continue MCP", + target: `.continue/mcpServers/${LEGACY_CONTINUE_FILE}`, + action: legacy.action, + note: legacy.note, + }); + } + return rows; +} + +/** + * Reverse one server's emission. JSON entries are deleted only when `removeJsonEntry` + * says forge owns them (adopted, or byte-identical to forge's own spec); the Codex block + * only when forge-marked; the Continue file only when forge-managed. Idempotent. + * @param {{targetRoot:string, name:string, + * removeJsonEntry:(current:any)=>boolean}} opts + */ +export function removeMcp({ targetRoot, name, removeJsonEntry }) { + const rows = []; + for (const t of JSON_TARGETS) { + const path = join(targetRoot, t.file); + let r; + let current; + if (existsSync(path)) { + try { + current = JSON.parse(readFileSync(path, "utf8"))?.[t.key]?.[name]; + } catch { + current = undefined; + } + } + if (current !== undefined && !removeJsonEntry(current)) { + r = { + action: "skipped", + note: `${name} left in place: user-owned entry`, + }; + } else { + r = removeFromJson(path, t.key, name); + } + rows.push({ + tool: `${t.tool} MCP`, + target: t.file, + action: r.action, + note: r.note, + }); + } + const codex = removeCodexBlock(join(targetRoot, CODEX_FILE), name); + rows.push({ + tool: "Codex MCP", + target: ".codex/config.toml", + action: codex.action, + note: codex.note, + }); + const contPath = join(targetRoot, CONTINUE_DIR, continueFileFor(name)); + const existing = readIfExists(contPath); + let cont; + if (existing === null) cont = { action: "unchanged", note: "absent" }; + else if (!isManaged(existing)) cont = { action: "skipped", note: "unmanaged file left in place" }; + else { + rmSync(contPath, { force: true }); + cont = { action: "written", note: "per-server file removed" }; + } rows.push({ tool: "Continue MCP", - target: ".continue/mcpServers/forge-mcp.yaml", + target: `.continue/mcpServers/${continueFileFor(name)}`, action: cont.action, note: cont.note, }); return rows; } + +/** True when any JSON target or the Codex TOML already holds a same-name entry that + * differs from forge's spec — i.e. an entry forge did NOT just create and must not + * claim without --adopt. Drives add-time auto-ownership in integrations.js. */ +export function hasForeignEntry(targetRoot, name, def) { + for (const t of JSON_TARGETS) { + const path = join(targetRoot, t.file); + if (!existsSync(path)) continue; + try { + const cur = JSON.parse(readFileSync(path, "utf8"))?.[t.key]?.[name]; + if (cur !== undefined && JSON.stringify(cur) !== JSON.stringify(def)) return true; + } catch { + // invalid JSON: mergeJson will skip the file entirely — not a claimable entry + } + } + const codexPath = join(targetRoot, CODEX_FILE); + const text = existsSync(codexPath) ? readFileSync(codexPath, "utf8") : ""; + const unmarked = unmarkedRegion(text, name); + if (unmarked) { + const cur = text.slice(unmarked.start, unmarked.end); + if (cur.trimEnd() !== legacyCodexBlock(name, def).trimEnd()) return true; + } + return false; +} diff --git a/src/gate.js b/src/gate.js index b120fe6..bc74abc 100644 --- a/src/gate.js +++ b/src/gate.js @@ -1,7 +1,8 @@ // forge gate — the completion gate: a deterministic floor under "done". Instructions and // lessons raise the PROBABILITY that code ships with its docs/state; this Stop hook -// guarantees a floor: a session that changed code but moved no doc/state artifact is -// blocked ONCE, with the exact repair procedure as the reason. P(silent miss) = +// guarantees a floor: a session that changed code but produced no TEST EVIDENCE (a test +// file moved, or a fresh passing `verify` provenance stamp) or moved no doc/state +// artifact is blocked ONCE, with the exact repair procedure as the reason. P(silent miss) = // (1−p)·∏(1−cⱼ) — the gate is the cⱼ≈1 layer for the structural signal "code moved, // nothing followed". Loop-safe (stop_hook_active + once-per-session marker), fail-open // on every error path, kill switch FORGE_STOPGATE=0. @@ -13,7 +14,7 @@ // to git because .forge/ is gitignored — counts as the doc signal via its mtime against // the session baseline (the baseline file's mtime IS the session-start timestamp). import { execFileSync } from "node:child_process"; -import { existsSync, mkdirSync, statSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; import { extname, join } from "node:path"; import { cusum } from "./anchor.js"; import { CODE_EXTS, DOC_EXTS, impact, isConfigFile, load as loadAtlas } from "./atlas.js"; @@ -122,8 +123,15 @@ export function changedSet(root, baseHead, { sinceMs, preDirty } = {}) { } /** - * PURE decision table (the kit's ten rows, minus the impure guards the orchestrator - * handles). First match wins; returns {allow, row, classes}. + * PURE decision table (first match wins; returns {allow, row, classes}). The teeth + * (RA-10): a code change owes TEST EVIDENCE — a test-class file moved with it, or a + * fresh passing `verify` run (provenance stamp newer than session start) — AND a + * doc/state artifact. A handoff/state touch alone still counts as the continuity + * (docs) leg, but it can no longer satisfy the gate by itself when code moved; only + * a config-only change keeps that lighter bar. + * @param {{stopHookActive?: boolean, isRepo?: boolean, markerExists?: boolean, + * killSwitch?: boolean, changed?: string[], stateTouched?: boolean, + * verifyEvidence?: {fresh: boolean, status: string} | null}} [input] */ export function gateDecision({ stopHookActive = false, @@ -132,6 +140,7 @@ export function gateDecision({ killSwitch = false, changed = [], stateTouched = false, + verifyEvidence = null, } = {}) { if (stopHookActive) return { allow: true, row: "stop-hook-active" }; if (!isRepo) return { allow: true, row: "not-a-repo" }; @@ -141,9 +150,26 @@ export function gateDecision({ for (const f of changed) classes[classifyPath(f)].push(f); const external = changed.length - classes.internal.length; if (!external && !stateTouched) return { allow: true, row: "no-changes", classes }; - if (classes.docs.length || stateTouched) return { allow: true, row: "docs-touched", classes }; - if (classes.code.length) return { allow: false, row: "code-without-docs", classes }; - return { allow: true, row: "no-code-class", classes }; + const testEvidence = + classes.test.length > 0 || + (verifyEvidence?.fresh === true && verifyEvidence?.status === "PASS"); + const docEvidence = classes.docs.length > 0 || stateTouched; + if (classes.code.length) { + if (!testEvidence) return { allow: false, row: "code-without-test-evidence", classes }; + if (!docEvidence) return { allow: false, row: "code-without-docs", classes }; + return { allow: true, row: "code-with-evidence", classes }; + } + // Test-only sessions (a regression test owes no prose) pass; config-only still owes + // at least the lighter continuity bar (docs or a state/handoff touch). + if (classes.test.length && !classes.config.length) + return { allow: true, row: "test-only", classes }; + if (classes.config.length && !docEvidence) + return { allow: false, row: "config-without-docs", classes }; + return { + allow: true, + row: docEvidence ? "docs-touched" : "no-code-class", + classes, + }; } /** The change-type obligation matrix (P1-05): what evidence each kind of change owes, so @@ -162,9 +188,18 @@ export function obligationsFor(classes = {}) { } /** The block reason IS the repair procedure — its consumer is the agent itself, and a - * checklist converts a failure into a same-turn fix. Stale-doc candidates come from the - * CACHED atlas only (a hook never builds). */ -export function repairReason(root, { codeFiles = [], driftAlarm = false, classes = {} } = {}) { + * checklist converts a failure into a same-turn fix. Parameterized by the blocked row + * so it leads with the MISSING leg (test evidence vs docs vs config docs); the old + * "handoff alone satisfies the gate" claim survives only on the config-only row, where + * that lighter bar is real. Stale-doc candidates come from the CACHED atlas only (a + * hook never builds). + * @param {string} root + * @param {{codeFiles?: string[], driftAlarm?: boolean, + * classes?: {code?: string[], config?: string[], test?: string[]}, row?: string}} [opts] */ +export function repairReason( + root, + { codeFiles = [], driftAlarm = false, classes = {}, row = "code-without-docs" } = {}, +) { let likelyDocs = []; try { const atlas = loadAtlas(root); @@ -176,30 +211,54 @@ export function repairReason(root, { codeFiles = [], driftAlarm = false, classes likelyDocs = [...docs].slice(0, 5); } } catch {} - const shown = codeFiles.slice(0, 10).join(", "); - const more = codeFiles.length > 10 ? ` (+${codeFiles.length - 10} more)` : ""; + const cited = codeFiles.length ? codeFiles : (classes.config ?? []); + const shown = cited.slice(0, 10).join(", "); + const more = cited.length > 10 ? ` (+${cited.length - 10} more)` : ""; const obligations = obligationsFor(classes); + const docsSyncStep = `\`${BRAND.cli} docs sync\` — sweep the diff for stale doc mentions${ + likelyDocs.length ? ` (graph suggests: ${likelyDocs.join(", ")})` : "" + } and update every hit.`; + const handoffStep = (suffix = "") => + `\`${BRAND.cli} handoff "" --next ""\` — rewrite the session snapshot the next session resumes from${suffix}.`; + const decideStep = `\`${BRAND.cli} decide ""\` if a non-obvious decision was made.`; + let headline; + const steps = []; + if (row === "code-without-test-evidence") { + headline = `END-TO-END COMPLETENESS: code changed this session with NO test evidence — no test file moved with it and no fresh passing \`${BRAND.cli} verify\` run backs the change.`; + steps.push( + `\`${BRAND.cli} verify\` — run the project's own tests against this change (or add/adjust a test that exercises the new behaviour).`, + docsSyncStep, + handoffStep(), + decideStep, + ); + } else if (row === "config-without-docs") { + headline = + "END-TO-END COMPLETENESS: config changed this session but no doc or state artifact moved with it."; + steps.push( + docsSyncStep, + handoffStep(" (this alone satisfies the gate for a config-only change)"), + decideStep, + ); + } else { + headline = + "END-TO-END COMPLETENESS: code changed this session but no doc or state artifact moved with it."; + steps.push(docsSyncStep, handoffStep(), decideStep); + } + if (driftAlarm) + steps.push( + `Sustained goal drift this session (CUSUM alarm) — re-read the goal: \`${BRAND.cli} anchor\`.`, + ); const lines = [ - "END-TO-END COMPLETENESS: code changed this session but no doc or state artifact moved with it.", - `Changed code: ${shown}${more}`, + headline, + ...(shown ? [`Changed ${codeFiles.length ? "code" : "config"}: ${shown}${more}`] : []), ...(obligations.length ? ["Obligations for this change:", ...obligations.map((o) => `- ${o}`)] : []), "Do what applies before finishing:", - `1. \`${BRAND.cli} docs sync\` — sweep the diff for stale doc mentions${ - likelyDocs.length ? ` (graph suggests: ${likelyDocs.join(", ")})` : "" - } and update every hit.`, - `2. \`${BRAND.cli} handoff "" --next ""\` — rewrite the session snapshot the next session resumes from (this alone satisfies the gate).`, - `3. \`${BRAND.cli} decide ""\` if a non-obvious decision was made.`, - ]; - if (driftAlarm) - lines.push( - `4. Sustained goal drift this session (CUSUM alarm) — re-read the goal: \`${BRAND.cli} anchor\`.`, - ); - lines.push( + ...steps.map((s, i) => `${i + 1}. ${s}`), `If genuinely no doc is affected, tell the user why in one line and still run \`${BRAND.cli} handoff\`.`, "(Blocks once per session — stopping again proceeds. Kill switch: FORGE_STOPGATE=0.)", - ); + ]; return lines.join("\n"); } @@ -242,7 +301,22 @@ export function stopGate(root, sid, hook = {}) { sinceMs: startedAt ?? undefined, preDirty: readDirtySnapshot(root, sid) ?? undefined, }); - const decision = gateDecision({ changed, stateTouched }); + // Test evidence for the RA-10 rows: a `verify` provenance stamp written THIS session + // (mtime after session start) whose tests verdict is PASS — the exact field verify.js + // writes. Parse-guarded: any trouble → null → the evidence leg simply fails, and the + // block-once marker still caps the cost (second stop always proceeds — cannot brick). + let verifyEvidence = null; + try { + const provPath = join(root, ".forge", "provenance.json"); + const mtime = statSync(provPath).mtimeMs; + const status = JSON.parse(readFileSync(provPath, "utf8"))?.tests?.status; + if (typeof status === "string") + verifyEvidence = { + fresh: startedAt != null && mtime > startedAt, + status, + }; + } catch {} + const decision = gateDecision({ changed, stateTouched, verifyEvidence }); if (decision.allow) return decision; // Marker FIRST: if it can't be persisted, the block-once promise can't be kept — // on a read-only checkout that would mean an unsatisfiable block every turn, so @@ -265,6 +339,7 @@ export function stopGate(root, sid, hook = {}) { codeFiles: decision.classes.code, driftAlarm, classes: decision.classes, + row: decision.row, }); return { allow: false, diff --git a/src/init.js b/src/init.js index 1828317..0636cc6 100644 --- a/src/init.js +++ b/src/init.js @@ -15,6 +15,7 @@ import { dirname, join } from "node:path"; import { BRAND } from "./brand.js"; import { GITATTRIBUTES_RULE } from "./ledger_store.js"; import { autoDetectProvider } from "./providers.js"; +import { validateProfile, writeForgeConfig } from "./repo_config.js"; import { sync } from "./sync.js"; import { list as tasteList } from "./taste.js"; @@ -39,13 +40,25 @@ export function ensureLedgerGitattributes(targetRoot = process.cwd()) { const FORGE_SETTINGS_MARKER = "forge-managed"; +/** Single-quote a path for safe embedding in a shell command (RA-12): an install + * prefix containing spaces (or any shell metacharacter) must not split into words. + * Embedded single quotes use the standard `'\''` escape. */ +export function shellQuote(path) { + return `'${String(path).replaceAll("'", `'\\''`)}'`; +} + /** Rewrite the template's `~/.forge/…` hook + statusline commands to the ACTUAL installed * package location. The npm global-install path never creates `~/.forge`, so a literal * `~/.forge/guards/*.sh` reference points at nothing (P0-02). `~/.forge` is the `global/` - * dir (that's what install.sh symlinks), so `~/.forge/X` resolves to `/global/X`. */ + * dir (that's what install.sh symlinks), so `~/.forge/X` resolves to `/global/X` — + * single-quoted, so an install path with spaces still runs (RA-12). Trailing args stay + * outside the quotes: `bash '/guards/cortex.sh' prompt`. */ function resolveManagedPaths(template) { const base = join(BRAND.root, "global"); - const fix = (cmd) => (typeof cmd === "string" ? cmd.replaceAll("~/.forge/", `${base}/`) : cmd); + const fix = (cmd) => + typeof cmd === "string" + ? cmd.replace(/~\/\.forge\/(\S+)/g, (_, rest) => shellQuote(join(base, rest))) + : cmd; if (template.statusLine?.command) template.statusLine.command = fix(template.statusLine.command); for (const entries of Object.values(template.hooks || {})) { for (const entry of entries) { @@ -55,6 +68,18 @@ function resolveManagedPaths(template) { return template; } +/** Strip shell quoting from a command string so quoted and unquoted spellings of the + * same command compare equal: `bash '/x/a.sh' p` ≡ `bash /x/a.sh p`. Undoes the + * `'\''` embedded-quote escape first, then drops the remaining quote characters. + * Also normalizes the legacy `~/.forge/` prefix to the resolved install base so + * entries merged by very old installs still match. */ +function normalizeCommand(command) { + return String(command ?? "") + .replaceAll("'\\''", "'") + .replace(/["']/g, "") + .replaceAll("~/.forge/", `${join(BRAND.root, "global")}/`); +} + function loadTemplate() { const path = join(BRAND.root, "global", "settings.template.json"); return resolveManagedPaths(JSON.parse(readFileSync(path, "utf8"))); @@ -90,18 +115,40 @@ function unionStrings(a = [], b = []) { } /** Extract guard identity (basename + trailing args) from a hook command for dedup. - * `bash ~/.forge/guards/cortex.sh prompt` and - * `"${CLAUDE_PLUGIN_ROOT}"/global/guards/cortex.sh prompt` both → `cortex.sh prompt`. */ -function guardKey(command) { - const m = command.match(/([^/\\"]+\.sh)\s*(.*)/); - return m ? `${m[1]} ${m[2]}`.trim() : command; + * Quote-normalized (RA-12), so all of `bash ~/.forge/guards/cortex.sh prompt`, + * `bash '/install path/global/guards/cortex.sh' prompt`, and + * `"${CLAUDE_PLUGIN_ROOT}"/global/guards/cortex.sh prompt` → `cortex.sh prompt` — + * old unquoted installed entries and new quoted template entries must dedupe, or + * every existing install would grow duplicate hooks on re-merge. Exported for tests. */ +export function guardKey(command) { + const cmd = normalizeCommand(command); + const m = cmd.match(/([^/\\]+\.sh)\s*(.*)/); + return m ? `${m[1]} ${m[2]}`.trim() : cmd; } -/** Merge Forge hook entries into existing hook arrays, matching by guard identity to avoid duplicates. */ +/** Merge Forge hook entries into existing hook arrays, matching by guard identity to avoid + * duplicates. An existing entry that is the SAME resolved command as the template's — just + * spelled without quotes (a pre-RA-12 install) — is upgraded in place to the quoted form. */ function mergeHooks(existing = {}, template = {}) { const merged = { ...existing }; for (const [event, entries] of Object.entries(template)) { const existingEntries = merged[event] || []; + // guardKey → template command, to heal old unquoted spellings of the same command. + const templateByKey = new Map(); + for (const entry of entries) { + for (const h of entry.hooks || []) { + if (h.command) templateByKey.set(guardKey(h.command), h.command); + } + } + for (const entry of existingEntries) { + for (const h of entry.hooks || []) { + if (!h.command) continue; + const tpl = templateByKey.get(guardKey(h.command)); + if (tpl && tpl !== h.command && normalizeCommand(h.command) === normalizeCommand(tpl)) { + h.command = tpl; // same command, pre-quoting spelling → upgrade to the quoted form + } + } + } const existingKeys = new Set( existingEntries .flatMap((e) => (e.hooks || []).map((h) => h.command)) @@ -211,36 +258,151 @@ export function mergeSettings({ settingsPath, noSettings } = {}) { }; } -/** Valid policy profiles (P1-02). `standard` is the full engineering pack (default). */ -export const PROFILES = [ - "minimal", - "standard", - "web-app", - "backend-service", - "library", - "regulated", -]; +// Policy profiles (P1-02, RA-14) live in repo_config.js — the single config module — +// and are re-exported here so `import { PROFILES } from "./init.js"` keeps working. +export { LEGACY_PROFILES, PROFILES, validateProfile } from "./repo_config.js"; + +/** + * Remove every Forge-managed entry that `mergeSettings` added from the user's + * ~/.claude/settings.json, using the settings template as the authoritative shape + * (RA-17): hook entries whose guardKey matches a template guard (quote-normalized), + * permission strings appearing verbatim in the template's allow/ask/deny (removed only + * from the same list they are in), the statusLine iff its command matches the template's + * resolved command (quote-normalized), the `$schema` iff it is the template's, and the + * `_forge` marker. Everything user-owned is preserved unchanged; empty containers left + * behind are pruned. Timestamped backup + atomic tmp-file+rename write, same as + * `mergeSettings`. Corrupt file → refuses; missing file → noop. + * @param {{settingsPath?: string}} [opts] + * @returns {{action:"removed", path:string, removed:string[], backup:string} + * | {action:"noop", path:string, reason:string} + * | {action:"error", path:string, reason:string}} + */ +export function removeForgeSettings({ settingsPath } = {}) { + const target = settingsPath || join(homedir(), ".claude", "settings.json"); + const { status, data } = readExistingSettings(target); + if (status === "missing") return { action: "noop", path: target, reason: "no settings file" }; + if (status === "corrupt") { + return { + action: "error", + path: target, + reason: + "existing settings file is present but not valid JSON — refusing to modify. " + + "Fix or remove it by hand, then re-run.", + }; + } + const template = loadTemplate(); + const settings = data; + /** @type {string[]} */ + const removed = []; + + // Hooks: drop every hook whose guard identity is one the template installs. + const templateKeys = new Set(); + for (const entries of Object.values(template.hooks || {})) { + for (const entry of entries) { + for (const h of entry.hooks || []) if (h.command) templateKeys.add(guardKey(h.command)); + } + } + if (settings.hooks && typeof settings.hooks === "object") { + for (const [event, entries] of Object.entries(settings.hooks)) { + if (!Array.isArray(entries)) continue; + let changed = false; + const kept = []; + for (const entry of entries) { + if (!entry || !Array.isArray(entry.hooks)) { + kept.push(entry); + continue; + } + const hooks = entry.hooks.filter( + (h) => !(typeof h?.command === "string" && templateKeys.has(guardKey(h.command))), + ); + if (hooks.length !== entry.hooks.length) changed = true; + if (hooks.length) kept.push({ ...entry, hooks }); + } + if (changed) { + removed.push(`hooks.${event}`); + if (kept.length) settings.hooks[event] = kept; + else delete settings.hooks[event]; + } + } + if (Object.keys(settings.hooks).length === 0) delete settings.hooks; + } + + // Permissions: remove template strings verbatim, only from the SAME list they sit in. + if (settings.permissions && template.permissions) { + for (const level of ["allow", "ask", "deny"]) { + const tpl = template.permissions[level]; + const cur = settings.permissions[level]; + if (!Array.isArray(tpl) || !Array.isArray(cur)) continue; + const tplSet = new Set(tpl); + const kept = cur.filter((s) => !tplSet.has(s)); + if (kept.length !== cur.length) { + removed.push(`permissions.${level}`); + if (kept.length) settings.permissions[level] = kept; + else delete settings.permissions[level]; + } + } + // Merge residue only: a permissions object reduced to the template's own defaultMode + // is what mergeSettings itself left behind — prune it. A user-set custom mode stays. + if ( + settings.permissions && + Object.keys(settings.permissions).length === 1 && + settings.permissions.defaultMode === (template.permissions.defaultMode || "default") + ) { + delete settings.permissions; + } else if (settings.permissions && Object.keys(settings.permissions).length === 0) { + delete settings.permissions; + } + } + + // Statusline: only if it IS the template's (quote-normalized) — a user's own stays. + if ( + settings.statusLine?.command && + template.statusLine?.command && + normalizeCommand(settings.statusLine.command) === normalizeCommand(template.statusLine.command) + ) { + delete settings.statusLine; + removed.push("statusLine"); + } + + // Schema: mergeSettings sets it only when absent — remove only the template's own value. + if (settings.$schema && settings.$schema === template.$schema) delete settings.$schema; + + if ("_forge" in settings) { + delete settings._forge; + removed.push("_forge"); + } + + if (!removed.length) + return { + action: "noop", + path: target, + reason: "no forge-managed entries found", + }; + + const backup = `${target}.forge-bak-${stamp()}`; + copyFileSync(target, backup); + const tmp = `${target}.forge-tmp-${process.pid}`; + writeFileSync(tmp, `${JSON.stringify(settings, null, 2)}\n`); + renameSync(tmp, target); + return { action: "removed", path: target, removed, backup }; +} /** Persist a chosen profile to `.forge/forge.config.json` so `sync` applies it. Merges into - * any existing config rather than clobbering it. Returns the resolved profile or null. */ + * any existing config rather than clobbering it (unknown keys round-trip); a corrupt + * config file makes the write refuse loudly instead of discarding the bytes (RA-15). + * Legacy profile names are accepted, stored as their mapped profile, and surfaced via + * `deprecated` so the CLI can warn (RA-14). Returns the resolved profile or null. */ function writeProfile(targetRoot, profile) { if (!profile) return null; - if (!PROFILES.includes(profile)) return { error: `unknown profile: ${profile}` }; - const dir = join(targetRoot, ".forge"); - const path = join(dir, "forge.config.json"); - /** @type {Record} */ - let cfg = {}; - if (existsSync(path)) { - try { - cfg = JSON.parse(readFileSync(path, "utf8")) || {}; - } catch { - cfg = {}; - } - } - cfg.profile = profile; - mkdirSync(dir, { recursive: true }); - writeFileSync(path, `${JSON.stringify(cfg, null, 2)}\n`); - return { profile }; + const v = validateProfile(profile); + // `=== false` (not `!v.ok`): tsc only narrows the discriminated union this way here. + if (v.ok === false) return { error: v.error }; + const res = writeForgeConfig(targetRoot, (cfg) => { + cfg.profile = v.profile; + return cfg; + }); + if (res.ok === false) return { error: res.reason }; + return v.deprecated ? { profile: v.profile, deprecated: v.deprecated } : { profile: v.profile }; } /** @@ -264,6 +426,11 @@ export function init({ settingsOnly: true, }; } + // RA-13: an invalid profile aborts BEFORE any filesystem/settings side effect — + // no AGENTS.md, no .forge/, no .gitattributes append, no settings merge. + const valid = validateProfile(profile); + // `=== false` (not `!valid.ok`): tsc only narrows the discriminated union this way here. + if (valid.ok === false) return { profile: { error: valid.error }, aborted: true }; const profileResult = writeProfile(targetRoot, profile); const r = sync({ targetRoot }); ensureLedgerGitattributes(targetRoot); diff --git a/src/integrations.js b/src/integrations.js index ba3b7ec..6f2c24d 100644 --- a/src/integrations.js +++ b/src/integrations.js @@ -2,7 +2,19 @@ // (P0-06): `forge init` only wires forge's own server. Each entry here is added explicitly // via `forge integrations add `, which first shows the package, its network behaviour, // and the files it will touch, then requires confirmation (--yes) before writing anything. -import { emitMcp } from "./emit/mcp.js"; +// +// Installed integrations are RECORDED in `.forge/forge.config.json` under +// `mcp.integrations` (RA-03): every emit — sync or add — computes the same full managed +// set (registry ∪ recorded integrations), so one command can no longer delete another's +// servers. `mcp.adopted` records the names forge may OVERWRITE when a same-name entry +// already exists (RA-21): adding over a user's own entry requires --adopt; a fresh add +// that forge itself creates is auto-adopted (forge owns what it wrote, so later catalog +// updates can refresh it). +import { existsSync, readFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { emitMcp, hasForeignEntry, removeMcp } from "./emit/mcp.js"; +import { readForgeConfig, writeForgeConfig } from "./repo_config.js"; /** The catalog of known optional integrations. Keep each entry honest about what running it * actually does (network, third-party code execution). */ @@ -31,10 +43,114 @@ export function planIntegration(name) { return { ok: true, name, pkg: m.pkg, network: m.network, why: m.why }; } -/** Write the integration's server into each tool's MCP config (via the same emitter as sync). */ -export function addIntegration(name, { targetRoot = process.cwd() } = {}) { +/** The `mcp` record from the repo config, shape-validated. */ +function mcpRecord(cfg) { + const raw = cfg.mcp && typeof cfg.mcp === "object" && !Array.isArray(cfg.mcp) ? cfg.mcp : {}; + const names = (v) => (Array.isArray(v) ? v.filter((n) => typeof n === "string") : []); + return { integrations: names(raw.integrations), adopted: names(raw.adopted) }; +} + +/** The built-in server registry (source/mcp.json) — implicitly managed, always emitted. */ +function registryServers() { + const p = join(BRAND.root, "source", "mcp.json"); + if (!existsSync(p)) return {}; + return JSON.parse(readFileSync(p, "utf8")); +} + +/** + * The full managed MCP state for a repo — the ONE computation both `sync` and + * `integrations add` share, so they can never oscillate (RA-03): + * servers = registry ∪ (recorded integrations that exist in the catalog) + * owned = registry names ∪ recorded adopted names (what forge may overwrite) + * A corrupt repo config is NEVER treated as "no integrations installed": the result + * falls back to registry-only and says so, so no recorded server gets clobbered. + * @param {string} targetRoot + * @returns {{servers:Record, owned:Set, corrupt:boolean, warning?:string}} + */ +export function managedMcpState(targetRoot) { + const registry = registryServers(); + const servers = { ...registry }; + const owned = new Set(Object.keys(registry)); + const cfg = readForgeConfig(targetRoot); + if (cfg.corrupt) { + return { + servers, + owned, + corrupt: true, + warning: `${cfg.path} is not valid JSON — MCP emitted registry-only; recorded integrations left untouched (fix or delete it)`, + }; + } + const rec = mcpRecord(cfg); + for (const name of rec.integrations) { + if (INTEGRATIONS[name]) servers[name] = INTEGRATIONS[name].server; + } + for (const name of rec.adopted) owned.add(name); + return { servers, owned, corrupt: false }; +} + +/** + * Install an integration: record it in `.forge/forge.config.json` FIRST (the persistent + * managed set), then emit the FULL set so nothing previously installed is dropped. + * `adopt: true` (--adopt) additionally claims a pre-existing same-name entry as + * forge-owned; without it such an entry is preserved and reported. + */ +export function addIntegration(name, { targetRoot = process.cwd(), adopt = false } = {}) { const m = INTEGRATIONS[name]; if (!m) return { ok: false, reason: `unknown integration: ${name}` }; - const rows = emitMcp({ targetRoot, servers: { [name]: m.server } }); - return { ok: true, name, rows }; + // Fresh create → forge owns it (auto-adopt). A pre-existing DIVERGENT same-name entry + // is someone else's config: claiming it requires the explicit --adopt. + const foreign = hasForeignEntry(targetRoot, name, m.server); + const own = adopt || !foreign; + const rec = writeForgeConfig(targetRoot, (cfg) => { + const cur = mcpRecord(cfg); + if (!cur.integrations.includes(name)) cur.integrations.push(name); + if (own && !cur.adopted.includes(name)) cur.adopted.push(name); + cfg.mcp = { + ...(typeof cfg.mcp === "object" && cfg.mcp ? cfg.mcp : {}), + ...cur, + }; + return cfg; + }); + // Corrupt config: refuse rather than emit an unrecorded (and therefore un-removable, + // oscillation-prone) server. The config module already warned on stderr. + if (rec.ok === false) return { ok: false, reason: rec.reason }; + const { servers, owned } = managedMcpState(targetRoot); + const rows = emitMcp({ targetRoot, servers, owned }); + return { ok: true, name, adopted: own, rows }; +} + +/** + * Reverse `add `: drop it from the persistent record, then remove only what forge + * owns — its per-server Continue file, its forge-marked Codex block, and JSON entries + * that are adopted or byte-identical to forge's spec. A user's own divergent entry is + * left in place and reported. Second remove is a no-op. + */ +export function removeIntegration(name, { targetRoot = process.cwd() } = {}) { + const m = INTEGRATIONS[name]; + if (!m) return { ok: false, reason: `unknown integration: ${name}` }; + const cfg = readForgeConfig(targetRoot); + if (cfg.corrupt) + return { + ok: false, + reason: `${cfg.path} is not valid JSON — cannot update the managed-set record (fix or delete it)`, + }; + const before = mcpRecord(cfg); + const wasRecorded = before.integrations.includes(name) || before.adopted.includes(name); + if (!wasRecorded) return { ok: true, name, removed: false, rows: [] }; + const rec = writeForgeConfig(targetRoot, (c) => { + const cur = mcpRecord(c); + cur.integrations = cur.integrations.filter((n) => n !== name); + cur.adopted = cur.adopted.filter((n) => n !== name); + c.mcp = { ...(typeof c.mcp === "object" && c.mcp ? c.mcp : {}), ...cur }; + return c; + }); + if (rec.ok === false) return { ok: false, reason: rec.reason }; + const wasAdopted = before.adopted.includes(name); + const rows = removeMcp({ + targetRoot, + name, + removeJsonEntry: (current) => + wasAdopted || JSON.stringify(current) === JSON.stringify(m.server), + }); + return { ok: true, name, removed: true, rows }; } diff --git a/src/ledger.js b/src/ledger.js index ef48eee..6bd7b7d 100644 --- a/src/ledger.js +++ b/src/ledger.js @@ -201,10 +201,19 @@ export function outcomeRecord({ oracle, result, ref, author = "", t = 0, resolve }; } -/** An evidence record val() will count: known oracle, valid result, a ref, a hash. */ +/** An evidence record val() will count: known oracle, valid result, a well-formed ref, + * a hash. Ref checking is the PURE half of validateRef only (untyped/legacy accepted — + * read-path parity with what append accepts; typed-but-empty like `git:` rejected); + * git resolution stays on the append/import/verify paths, never on a read. */ export function validOutcome(e) { return Boolean( - e && ORACLES[e.oracle] && (e.result === "confirm" || e.result === "contradict") && e.ref && e.h, + e && + ORACLES[e.oracle] && + (e.result === "confirm" || e.result === "contradict") && + typeof e.ref === "string" && + e.ref && + validateRef(e.ref).ok && + e.h, ); } diff --git a/src/ledger_store.js b/src/ledger_store.js index 5d4335a..32886fd 100644 --- a/src/ledger_store.js +++ b/src/ledger_store.js @@ -80,25 +80,46 @@ const readJson = (path) => { }; /** Parse an append-only log: one canonical-JSON record per line, deduped by content - * hash, corrupt lines skipped. The single reader every log goes through. */ -function readLog(dir, log, id) { + * hash, corrupt lines skipped. The single reader every log goes through — and the + * single choke point where every line must PROVE its content hash (re-sealing the + * h-less rest must reproduce `h`) before it can reach any view, dedupe set, or val(). + * Evidence lines must additionally be valid outcomes. A forged/hand-edited line is + * simply invisible at read time; verify() is where it gets NAMED. The internal + * `verifyHashes:false` escape hatch exists ONLY so imports can read a source raw and + * QUARANTINE bad records instead of silently dropping them. */ +function readLog(dir, log, id, { verifyHashes = true } = {}) { const path = logPath(dir, log, id); if (!existsSync(path)) return []; const records = []; for (const line of readFileSync(path, "utf8").split(/\r?\n/)) { if (!line.trim()) continue; + let rec = null; try { - records.push(JSON.parse(line)); + rec = JSON.parse(line); } catch {} + if (!rec?.h) continue; + if (verifyHashes) { + const { h, ...rest } = rec; + if (sealRecord(rest).h !== h) continue; // forged/corrupt — cannot buy confidence + if (log === "evidence" && !validOutcome(rec)) continue; + } + records.push(rec); } // sortRecords, not file order: after a git union merge the two replicas' logs hold // the same set in different line orders — views must not depend on that. return sortRecords(records); } -/** Append one sealed record to a log iff its hash isn't already present. */ +/** Append one sealed record to a log iff its hash isn't already present. The seal is + * RECHECKED here — a record whose `h` does not match its own content never lands. */ function appendRecord(dir, log, id, record) { if (!record?.h) return { ok: false, reason: "record missing content hash" }; + const { h, ...rest } = record; + if (sealRecord(rest).h !== h) + return { + ok: false, + reason: "record content hash mismatch (forged/corrupt)", + }; if (!existsSync(claimPath(dir, id))) return { ok: false, reason: `no such claim in ledger: ${id}` }; if (readLog(dir, log, id).some((e) => e.h === record.h)) return { ok: true, deduped: true }; @@ -223,13 +244,17 @@ export function ratify(dir, idPrefix, { author = "", t = 0 } = {}) { }; } -/** Load the full ledger state {claims, evidence, provenance, tombstones}. */ -export function loadState(dir) { +/** Load the full ledger state {claims, evidence, provenance, tombstones}. Log lines + * are hash-verified on read (see readLog); `verifyHashes:false` is internal-only — + * mergeDirs reads its SOURCE raw so bad records get quarantined, not silently lost. + * @param {string} dir + * @param {{verifyHashes?: boolean}} [opts] */ +export function loadState(dir, { verifyHashes = true } = {}) { const state = emptyState(); for (const { id, claim } of walkClaimFiles(dir)) { if (!claim) continue; state.claims[id] = claim; - for (const log of LOGS) state[log][id] = readLog(dir, log, id); + for (const log of LOGS) state[log][id] = readLog(dir, log, id, { verifyHashes }); } return state; } @@ -262,7 +287,10 @@ export function getClaimByPrefix(dir, prefix) { * this one. Idempotent and order-independent by the CRDT property, so merging a * teammate's checkout, a backup, or a branch worktree is always safe. */ export function mergeDirs(dstDir, srcDir) { - return importState(dstDir, loadState(srcDir)); + // The SOURCE is read raw (hashes unverified) on purpose: importState re-validates + // every record against THIS ledger, so forged/invalid lines end up quarantined with + // a named reason instead of being silently dropped at read time. + return importState(dstDir, loadState(srcDir, { verifyHashes: false })); } /** @@ -295,24 +323,45 @@ export function blame(dir, prefix, nowDay = 0) { }; } +/** Quarantine one rejected import record — an append-only audit line ({reason, rec, t}, + * sealed like every other log record) under quarantine/.log, deduped by the + * OFFENDING record's own hash so re-merges stay idempotent. Returns 1 when newly + * quarantined, 0 on a dupe. The timestamp is the record's own day (records never + * read the clock here — same rule as mintClaim/outcomeRecord). */ +function quarantineRecord(dir, id, rec, reason) { + if (readLog(dir, "quarantine", id).some((q) => q.rec?.h === rec?.h)) return 0; + mkdirSync(join(dir, "quarantine"), { recursive: true }); + appendFileSync( + logPath(dir, "quarantine", id), + `${canonicalize(sealRecord({ reason, rec, t: rec?.t ?? 0 }))}\n`, + ); + return 1; +} + /** Semilattice import: merge another ledger state into this directory (the mergeDirs - * core). Idempotent; safe to re-run. */ + * core). Idempotent; safe to re-run. Imported records get NO validation bypass: + * evidence goes through the full appendEvidence gate (validOutcome + ref resolution + * against THIS repo) and every record must prove its content hash in appendRecord — + * rejects land in quarantine/ for audit and are counted in `quarantined`. */ export function importState(dir, other) { const merged = mergeStates(loadState(dir), other); let claims = 0; let records = 0; + let quarantined = 0; for (const c of Object.values(merged.claims)) { const r = putClaim(dir, c); if (r.ok && !r.existed) claims++; for (const log of LOGS) { for (const rec of merged[log][c.id] ?? []) { - const a = appendRecord(dir, log, c.id, rec); + const a = + log === "evidence" ? appendEvidence(dir, c.id, rec) : appendRecord(dir, log, c.id, rec); if (a.ok && !a.deduped) records++; + else if (!a.ok) quarantined += quarantineRecord(dir, c.id, rec, a.reason ?? "rejected"); } } } reindex(dir); - return { claims, records }; + return { claims, records, quarantined }; } /** Regenerate LEDGER.md — the human index (like recall's MEMORY.md). */ diff --git a/src/ledger_sync.js b/src/ledger_sync.js index 477d827..b8d8caf 100644 --- a/src/ledger_sync.js +++ b/src/ledger_sync.js @@ -38,7 +38,7 @@ const STATE_FILE = "state.json"; * @property {string} [dir] * @property {string} [remote] * @property {string} [ref] - * @property {*} [pulled] import counts {claims, records} + * @property {*} [pulled] import counts {claims, records, quarantined} * @property {*} [pushed] dir mode: {claims, records}; ref mode: boolean * @property {boolean} [upToDate] * @property {number} [retries] @@ -169,7 +169,7 @@ function pullRef(localDir, root, ref, run, notes) { return importState(localDir, remoteState); } catch { notes.push("remote ledger state unreadable — treated as empty"); - return { claims: 0, records: 0 }; + return { claims: 0, records: 0, quarantined: 0 }; } } @@ -216,7 +216,7 @@ export function syncRef( // fetch actually found the ref on this remote, and only trust the idempotence // short-circuit when it did (else a new/pruned remote is silently never pushed to). let remoteHasRef = false; - let pulled = { claims: 0, records: 0 }; + let pulled = { claims: 0, records: 0, quarantined: 0 }; try { run(["fetch", remote, `+${ref}:${ref}`], { cwd: root }); remoteHasRef = true; @@ -325,6 +325,7 @@ export function syncRef( pulled = { claims: pulled.claims + more.claims, records: pulled.records + more.records, + quarantined: (pulled.quarantined ?? 0) + (more.quarantined ?? 0), }; } } diff --git a/src/repo_config.js b/src/repo_config.js index 273e276..4e969f9 100644 --- a/src/repo_config.js +++ b/src/repo_config.js @@ -1,17 +1,22 @@ -// forge repo config — reads/writes /.forge/config.json, a small per-repo file -// that records which agent tool this repo actually uses (`primaryTool`). Reading is a -// pure, throw-free probe; when no config exists the primary tool is auto-detected from -// which agent folders/files are present (mirrors autoDetectProvider in providers.js). +// forge repo config — THE single per-repo config module (RA-15). The unified file is +// /.forge/forge.config.json: profile / disableSections / rules (read by sync) +// plus primaryTool / tools (read by `forge tools`), and any future keys — unknown keys +// always round-trip through writeForgeConfig. The legacy /.forge/config.json +// (primaryTool/tools only) is still migration-read; on key conflicts the unified +// forge.config.json wins, and the legacy file is left in place. // -// The config drives ONE thing: which emitted targets `forge tools` hides in .gitignore. -// Default behaviour is unchanged — `forge sync` still emits every tool. Only the -// secondary-tool artifacts (the ones for tools this repo does NOT use) get gitignored, -// and only when the user opts in via `forge tools `. +// Malformed JSON is never silently discarded: reads warn ONCE per process on stderr and +// report `{corrupt: true, path}` alongside whatever valid data the other file held; +// writes REFUSE (`{ok:false, reason}`) rather than replace bytes a human may still want +// to fix. When no config exists the primary tool is auto-detected from which agent +// folders/files are present (mirrors autoDetectProvider in providers.js). import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs"; import { dirname, join } from "node:path"; +import { BRAND } from "./brand.js"; import { ensureGitignoreBlock } from "./gitignore.js"; -const CONFIG_REL = ".forge/config.json"; +const FORGE_CONFIG_REL = ".forge/forge.config.json"; +const LEGACY_CONFIG_REL = ".forge/config.json"; // Canonical primary-tool names Forge understands, and the on-disk marker that reveals // a tool is in use. Order = auto-detect precedence. @@ -53,7 +58,125 @@ const TOOL_KEYS = [ ["roo", /^Roo/], ]; -const configPath = (root) => join(root, CONFIG_REL); +const forgeConfigPath = (root) => join(root, FORGE_CONFIG_REL); +const legacyConfigPath = (root) => join(root, LEGACY_CONFIG_REL); + +// One loud warning per process (not per read) — the cortex hooks re-read config on every +// event, and repeating the same corruption warning on each hook fire would be spam. +let warnedCorruptConfig = false; +function warnCorrupt(path) { + if (warnedCorruptConfig) return; + warnedCorruptConfig = true; + process.stderr.write( + `${BRAND.cli}: ${path} is not valid JSON — ignoring it (fix or delete it); refusing to overwrite\n`, + ); +} + +/** + * Parse one config file without ever throwing. + * @param {string} path + * @returns {{status:"missing"|"ok"|"corrupt", data:Record}} + */ +function readConfigFile(path) { + if (!existsSync(path)) return { status: "missing", data: {} }; + try { + const parsed = JSON.parse(readFileSync(path, "utf8")); + const ok = parsed && typeof parsed === "object" && !Array.isArray(parsed); + return { status: "ok", data: ok ? parsed : {} }; + } catch { + return { status: "corrupt", data: {} }; + } +} + +/** + * Read the unified repo config: `.forge/forge.config.json` merged over the legacy + * `.forge/config.json` (migration read — the unified file wins on key conflicts). + * Never throws. A corrupt file is reported (`corrupt: true`, `path` = the corrupt + * file) alongside whatever valid data the other file held, and warned once per + * process on stderr — never silently treated as absent. + * @param {string} [root] + * @returns {Record & {corrupt?: true, path?: string}} + */ +export function readForgeConfig(root = process.cwd()) { + const unified = readConfigFile(forgeConfigPath(root)); + const legacy = readConfigFile(legacyConfigPath(root)); + const out = { ...legacy.data, ...unified.data }; + const corruptPath = + unified.status === "corrupt" + ? forgeConfigPath(root) + : legacy.status === "corrupt" + ? legacyConfigPath(root) + : null; + if (!corruptPath) return out; + warnCorrupt(corruptPath); + return { ...out, corrupt: true, path: corruptPath }; +} + +/** + * Read-modify-write of `.forge/forge.config.json` via `mutator(cfg)`. Unknown keys + * round-trip untouched (later features store their own keys here). Legacy + * `.forge/config.json` keys are folded in on write — the unified file wins on + * conflicts and the legacy file itself is left in place. If forge.config.json exists + * but is corrupt JSON the write REFUSES and the bytes on disk are preserved. + * @param {string} root + * @param {(cfg: Record) => Record|undefined} mutator mutate the + * draft in place (and return it) or return a replacement object + * @returns {{ok:true, path:string, config:Record}|{ok:false, path:string, reason:string}} + */ +export function writeForgeConfig(root, mutator) { + const path = forgeConfigPath(root); + const unified = readConfigFile(path); + if (unified.status === "corrupt") { + warnCorrupt(path); + return { + ok: false, + path, + reason: `${path} is not valid JSON — refusing to overwrite (fix or delete it)`, + }; + } + const legacy = readConfigFile(legacyConfigPath(root)); + if (legacy.status === "corrupt") warnCorrupt(legacyConfigPath(root)); + const draft = { ...legacy.data, ...unified.data }; + const next = mutator(draft) ?? draft; + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, `${JSON.stringify(next, null, 2)}\n`); + return { ok: true, path, config: next }; +} + +// --------------------------------------------------------------------------- +// Policy profiles (P1-02, RA-14). Defined here — the config module — so sync.js can +// read them without a static import cycle through init.js (init.js re-exports them). +// --------------------------------------------------------------------------- + +/** Valid policy profiles. `standard` is the full engineering pack (default). */ +export const PROFILES = ["minimal", "standard"]; + +/** Pre-RA-14 profile names. They were always aliases of the full pack (`sync` only ever + * branched on `minimal`), so they are now accepted as deprecated aliases of `standard`. */ +export const LEGACY_PROFILES = { + "web-app": "standard", + "backend-service": "standard", + library: "standard", + regulated: "standard", +}; + +/** + * Validate a profile name. Pure. Legacy names map to their real profile with + * `deprecated` set to the old name so callers can warn BEFORE any side effect. + * Absence (`undefined`/empty) is valid: no profile was requested (RA-13). + * @param {string} [p] + * @returns {{ok:true, profile?:string, deprecated?:string}|{ok:false, error:string}} + */ +export function validateProfile(p) { + if (!p) return { ok: true }; + if (PROFILES.includes(p)) return { ok: true, profile: p }; + if (Object.hasOwn(LEGACY_PROFILES, p)) + return { ok: true, profile: LEGACY_PROFILES[p], deprecated: p }; + return { + ok: false, + error: `unknown profile: ${p} (valid: ${PROFILES.join(", ")}; deprecated aliases of standard: ${Object.keys(LEGACY_PROFILES).join(", ")})`, + }; +} /** * The primary tool inferred from on-disk agent markers, or null if none present. @@ -66,21 +189,13 @@ export function detectPrimaryTool(root = process.cwd()) { } /** - * Read /.forge/config.json. Never throws — a missing or malformed file yields {}. + * Read the primary-tool view of the repo config (unified file, with legacy migration + * read). Never throws — missing or malformed files yield {}. * @param {string} [root] * @returns {{primaryTool?:string, tools?:string[]}} */ export function readRepoConfig(root = process.cwd()) { - const file = configPath(root); - let raw = {}; - if (existsSync(file)) { - try { - const parsed = JSON.parse(readFileSync(file, "utf8")); - if (parsed && typeof parsed === "object") raw = parsed; - } catch { - // malformed → treat as absent - } - } + const raw = readForgeConfig(root); const out = {}; if (typeof raw.primaryTool === "string" && raw.primaryTool) out.primaryTool = raw.primaryTool; if (Array.isArray(raw.tools)) out.tools = raw.tools.filter((t) => typeof t === "string"); @@ -100,45 +215,43 @@ export function resolvePrimaryTool(root = process.cwd()) { return { tool: null, source: "none" }; } -/** Persist primaryTool into /.forge/config.json, preserving other keys. */ +/** Persist primaryTool into /.forge/forge.config.json, preserving other keys. + * Throws (fail loudly) when the existing file is corrupt JSON — it is never replaced. */ export function setPrimaryTool(root, tool) { - const file = configPath(root); - /** @type {Record} */ - let cfg = {}; - if (existsSync(file)) { - try { - cfg = JSON.parse(readFileSync(file, "utf8")) || {}; - } catch { - cfg = {}; - } - } - cfg.primaryTool = tool; - mkdirSync(dirname(file), { recursive: true }); - writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`); - return file; + const res = writeForgeConfig(root, (cfg) => { + cfg.primaryTool = tool; + return cfg; + }); + // `=== false` (not `!res.ok`): tsc only narrows the discriminated union this way here. + if (res.ok === false) throw new Error(res.reason); + return res.path; } /** - * Clear the primary-tool config. Removes only the `primaryTool` key; if the file is - * left empty it is deleted. Never throws. + * Clear the primary-tool config: removes the `primaryTool`/`tools` keys from BOTH the + * unified forge.config.json and the legacy config.json (else a legacy value would + * resurface on the next migration read), preserving every other key. A file left empty + * is deleted; a corrupt file is left untouched (never rewritten). Never throws. * @param {string} root * @returns {{cleared:boolean, path:string}} */ export function clearRepoConfig(root) { - const file = configPath(root); - if (!existsSync(file)) return { cleared: false, path: file }; - /** @type {Record} */ - let cfg = {}; - try { - cfg = JSON.parse(readFileSync(file, "utf8")) || {}; - } catch { - cfg = {}; + let had = false; + for (const file of [forgeConfigPath(root), legacyConfigPath(root)]) { + const { status, data } = readConfigFile(file); + if (status === "missing") continue; + if (status === "corrupt") { + warnCorrupt(file); + continue; + } + if (!("primaryTool" in data) && !("tools" in data)) continue; + had = true; + delete data.primaryTool; + delete data.tools; + if (Object.keys(data).length === 0) rmSync(file, { force: true }); + else writeFileSync(file, `${JSON.stringify(data, null, 2)}\n`); } - const had = "primaryTool" in cfg; - delete cfg.primaryTool; - if (Object.keys(cfg).length === 0) rmSync(file, { force: true }); - else writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`); - return { cleared: had, path: file }; + return { cleared: had, path: forgeConfigPath(root) }; } /** Canonical tool key for a sync-report row's tool label, or null when shared/unmapped. */ diff --git a/src/stack.js b/src/stack.js index e3dd464..45fa9c2 100644 --- a/src/stack.js +++ b/src/stack.js @@ -92,14 +92,21 @@ function detectNode(root, add) { if (existsSync(join(root, "tsconfig.json")) || names.some((n) => n === "typescript")) add.language("TypeScript"); for (const f of hasAnyDep(names, NODE_FRAMEWORKS)) add.framework(f); - for (const t of hasAnyDep(names, NODE_TEST)) add.testCmd(runnerCmd(root, t)); + // npx-based runner detections stay label-only: forge must never EXECUTE npx (it can + // download arbitrary packages), so no bin/args descriptor is emitted for them. + for (const t of hasAnyDep(names, NODE_TEST)) add.runner({ label: runnerCmd(root, t) }); // package manager from the lockfile present if (existsSync(join(root, "pnpm-lock.yaml"))) add.pm("pnpm"); else if (existsSync(join(root, "yarn.lock"))) add.pm("yarn"); else if (existsSync(join(root, "bun.lockb"))) add.pm("bun"); else if (existsSync(join(root, "package-lock.json"))) add.pm("npm"); - // an explicit test script beats guessing - if (pkg.scripts?.test) add.testCmd(`${pmRun(root)} test`); + // an explicit test script beats guessing — executable via the DETECTED package manager + if (pkg.scripts?.test) + add.runner({ + bin: pmRun(root), + args: ["test"], + label: `${pmRun(root)} test`, + }); } const pmRun = (root) => @@ -124,8 +131,9 @@ function detectPython(root, add) { else if (reqs) add.evidence("requirements.txt"); else if (pipfile) add.evidence("Pipfile"); for (const f of hasAny(blob, PY_FRAMEWORKS)) add.framework(f); - if (blob.includes("pytest") || existsSync(join(root, "pytest.ini"))) add.testCmd("pytest -q"); - else add.testCmd("python -m unittest"); + if (blob.includes("pytest") || existsSync(join(root, "pytest.ini"))) + add.runner({ bin: "pytest", args: ["-q"], label: "pytest -q" }); + else add.runner({ label: "python -m unittest" }); if (blob.includes("ruff")) add.tool("ruff"); if (blob.includes("[tool.uv]") || existsSync(join(root, "uv.lock"))) add.pm("uv"); else if (pipfile) add.pm("pipenv"); @@ -137,7 +145,7 @@ function detectGo(root, add) { if (mod == null) return; add.language("Go"); add.evidence("go.mod"); - add.testCmd("go test ./..."); + add.runner({ bin: "go", args: ["test", "./..."], label: "go test ./..." }); const m = /^module\s+(\S+)/m.exec(mod); if (m) add.note(`module ${m[1]}`); if (/gin-gonic\/gin/.test(mod)) add.framework("Gin"); @@ -151,7 +159,7 @@ function detectRust(root, add) { add.language("Rust"); add.evidence("Cargo.toml"); add.pm("cargo"); - add.testCmd("cargo test"); + add.runner({ bin: "cargo", args: ["test"], label: "cargo test" }); if (/\bactix-web\b/.test(cargo)) add.framework("Actix"); if (/\baxum\b/.test(cargo)) add.framework("Axum"); if (/\brocket\b/.test(cargo)) add.framework("Rocket"); @@ -167,8 +175,18 @@ function detectRuby(root, add) { const g = (gemfile || "").toLowerCase(); if (g.includes("rails")) add.framework("Rails"); if (g.includes("sinatra")) add.framework("Sinatra"); - if (g.includes("rspec")) add.testCmd("bundle exec rspec"); - else add.testCmd("bundle exec rake test"); + if (g.includes("rspec")) + add.runner({ + bin: "bundle", + args: ["exec", "rspec"], + label: "bundle exec rspec", + }); + else + add.runner({ + bin: "bundle", + args: ["exec", "rake", "test"], + label: "bundle exec rake test", + }); } function detectPhp(root, add) { @@ -183,7 +201,12 @@ function detectPhp(root, add) { }); if (deps.some((d) => d.startsWith("laravel/"))) add.framework("Laravel"); if (deps.some((d) => d.startsWith("symfony/"))) add.framework("Symfony"); - if (deps.some((d) => d.includes("phpunit"))) add.testCmd("./vendor/bin/phpunit"); + if (deps.some((d) => d.includes("phpunit"))) + add.runner({ + bin: "./vendor/bin/phpunit", + args: [], + label: "./vendor/bin/phpunit", + }); } function detectJvm(root, add) { @@ -197,11 +220,11 @@ function detectJvm(root, add) { if (pom) { add.evidence("pom.xml"); add.pm("Maven"); - add.testCmd("mvn test"); + add.runner({ bin: "mvn", args: ["test"], label: "mvn test" }); } else { add.evidence(existsSync(join(root, "build.gradle.kts")) ? "build.gradle.kts" : "build.gradle"); add.pm("Gradle"); - add.testCmd("./gradlew test"); + add.runner({ bin: "./gradlew", args: ["test"], label: "./gradlew test" }); } if (blob.includes("springframework") || blob.includes("spring-boot")) add.framework("Spring"); } @@ -218,7 +241,7 @@ function detectDotnet(root, add) { add.language(proj.endsWith(".fsproj") ? "F#" : "C#"); add.evidence(proj); add.pm("dotnet"); - add.testCmd("dotnet test"); + add.runner({ bin: "dotnet", args: ["test"], label: "dotnet test" }); } const DETECTORS = [ @@ -232,12 +255,26 @@ const DETECTORS = [ detectDotnet, ]; +/** + * One detected test runner. `label` is the human-readable command string (always + * mirrored into `testCommands` for back-compat). `bin`/`args` are the structured, + * shell-free spawn descriptor — present only when the command is safe to execute + * verbatim (label-only entries, e.g. `npx vitest` or `python -m unittest`, are + * report-only: forge never executes them). + * @typedef {object} TestRunner + * @property {string} label + * @property {string} [bin] + * @property {string[]} [args] + */ + /** * Detect the repo's real stack by reading its manifests. Pure aside from fs reads; - * every detector is fail-safe. Returns deduped, deterministic (sorted) arrays. + * every detector is fail-safe. Returns deduped, deterministic (sorted) arrays; + * `testRunners` is deduped by label and sorted by label. * @param {string} [root] * @returns {{languages:string[], frameworks:string[], packageManagers:string[], - * testCommands:string[], tools:string[], notes:string[], evidence:string[]}} + * testCommands:string[], testRunners:TestRunner[], tools:string[], notes:string[], + * evidence:string[]}} */ export function detectStack(root = process.cwd()) { const sets = { @@ -249,11 +286,19 @@ export function detectStack(root = process.cwd()) { notes: new Set(), evidence: new Set(), }; + /** @type {Map} */ + const runners = new Map(); const add = { language: (v) => v && sets.languages.add(v), framework: (v) => v && sets.frameworks.add(v), pm: (v) => v && sets.packageManagers.add(v), testCmd: (v) => v && sets.testCommands.add(v), + /** @param {TestRunner} r structured descriptor — the label also lands in testCommands */ + runner: (r) => { + if (!r?.label) return; + sets.testCommands.add(r.label); + if (!runners.has(r.label)) runners.set(r.label, r); + }, tool: (v) => v && sets.tools.add(v), note: (v) => v && sets.notes.add(v), evidence: (v) => v && sets.evidence.add(v), @@ -269,6 +314,7 @@ export function detectStack(root = process.cwd()) { frameworks: sort(sets.frameworks), packageManagers: sort(sets.packageManagers), testCommands: sort(sets.testCommands), + testRunners: [...runners.values()].sort((a, b) => a.label.localeCompare(b.label)), tools: sort(sets.tools), notes: sort(sets.notes), evidence: sort(sets.evidence), diff --git a/src/substrate.js b/src/substrate.js index 4418f30..6bb8ee7 100644 --- a/src/substrate.js +++ b/src/substrate.js @@ -234,8 +234,15 @@ export function substrateCheck( let atlasFresh = true; if (atlas) { if (atlasIsStale(root, atlas)) { - if (allowBuild) atlas = buildAtlas({ root }); - else atlasFresh = false; // stale cache, can't rebuild from a hook + if (allowBuild) { + atlas = buildAtlas({ root }); + } else { + // Stale cache, can't rebuild from a hook. A stale graph is NOT evidence — drop it + // entirely so impacts/impactedFiles/predictedTests all derive from nothing instead + // of from a snapshot of a repo that no longer exists (RA-07). + atlas = null; + atlasFresh = false; + } } } else if (allowBuild) { atlas = buildAtlas({ root }); @@ -259,7 +266,9 @@ export function substrateCheck( // Consequence simulation (Eq 4), class "failing tests": which tests likely break if the // impacted files change — the impacted files that ARE tests, plus each impacted source file's // sibling test. Cheap, exact-ish, and surfaced BEFORE the edit (not after, like verify). - const predictedTests = predictFailingTests(root, impactedFiles); + // Gated on atlas freshness (belt and braces with the null atlas above): predictions from a + // stale graph are not trustworthy and must not be presented as consequence evidence. + const predictedTests = atlasFresh ? predictFailingTests(root, impactedFiles) : []; // P3 reuse stage: has this team already built (and verified) this? The explicit gate // meters + writes evidence (reuseQuery); the ambient hook path stays read-only // (reusePeek) so a per-prompt hook never appends to the ledger or metrics. @@ -429,12 +438,17 @@ export function enforceDecision(result, { enforce, blastThreshold = 25 } = {}) { reason: `Forge gate (enforcing): the required context can't be assembled from this repo — resolve before I edit:\n${qs}${tail}`, }; } - const blast = result.impact?.impactedFiles?.length ?? 0; - if (blast >= blastThreshold) { - return { - block: true, - reason: `Forge gate (enforcing): this touches a large blast radius (${blast} files predicted). Review the impacted files (or narrow the change) before editing.${tail}`, - }; + // Blast-radius block ONLY on a fresh atlas: a stale/missing graph yields an empty (or + // untrustworthy) impacted set, and stale predictions must never hard-block an edit — + // the explicit guard documents the intent even though a stale atlas now yields blast 0. + if (result.impact?.atlasFresh !== false) { + const blast = result.impact?.impactedFiles?.length ?? 0; + if (blast >= blastThreshold) { + return { + block: true, + reason: `Forge gate (enforcing): this touches a large blast radius (${blast} files predicted). Review the impacted files (or narrow the change) before editing.${tail}`, + }; + } } return { block: false }; } @@ -476,7 +490,9 @@ export function renderSubstrate(result) { if (result.impact.impactedFiles.length > 10) lines.push(` … ${result.impact.impactedFiles.length - 10} more`); } - const tests = result.impact.predictedTests || []; + // Predicted tests only speak for a FRESH atlas — right after an "impact: unavailable" + // notice, a likely-affected-tests list would contradict it with stale data (RA-07). + const tests = result.impact.atlasFresh === false ? [] : result.impact.predictedTests || []; if (tests.length) { lines.push("", ` likely-affected tests (${tests.length}) — run these first:`); for (const t of tests.slice(0, 8)) lines.push(` - ${t}`); @@ -530,7 +546,8 @@ export function substrateContext(result) { `- Predicted blast radius (${files.length}): ${files.slice(0, 8).join(", ")}${files.length > 8 ? " …" : ""}. Review these before editing.`, ); } - const predTests = result.impact.predictedTests || []; + // Same freshness rule as the renderer: never advise stale test predictions (RA-07). + const predTests = result.impact.atlasFresh === false ? [] : result.impact.predictedTests || []; if (predTests.length) lines.push( `- Likely-affected tests (${predTests.length}): ${predTests.slice(0, 6).join(", ")}${predTests.length > 6 ? " …" : ""}. Run these first.`, diff --git a/src/sync.js b/src/sync.js index 7042402..1f7bbda 100644 --- a/src/sync.js +++ b/src/sync.js @@ -16,6 +16,8 @@ import gemini from "./emit/gemini.js"; import { emitMcp } from "./emit/mcp.js"; import windsurf from "./emit/windsurf.js"; import zed from "./emit/zed.js"; +import { managedMcpState } from "./integrations.js"; +import { LEGACY_PROFILES, readForgeConfig } from "./repo_config.js"; const MODULES = [codex, cursor, copilot, windsurf, zed, claude, gemini, aider, continueTool]; @@ -48,29 +50,40 @@ const MINIMAL_SECTION = { ], }; -/** Read the optional per-repo config (`.forge/forge.config.json`). Invalid JSON is ignored - * (fail-open) so a typo can't break `forge sync`. */ +/** Read the optional per-repo config — the unified `.forge/forge.config.json`, with + * legacy `.forge/config.json` keys folded in (repo_config.js is the single config + * module, RA-15). Rule loading stays fail-open so a typo can't break `forge sync`, + * but corrupt JSON is no longer silent: readForgeConfig warns once on stderr and + * marks the result (`corrupt`/`path`) so sync() can surface a warning row. */ export function loadConfig(targetRoot) { - const p = join(targetRoot, ".forge/forge.config.json"); - if (!existsSync(p)) return {}; - try { - const cfg = JSON.parse(readFileSync(p, "utf8")); - return cfg && typeof cfg === "object" ? cfg : {}; - } catch { - return {}; - } + return readForgeConfig(targetRoot); } +// Warn once per process when a stored legacy profile name is read (RA-14) — loadRules +// runs on every sync/drift check and the hooks would otherwise repeat the warning. +let warnedLegacyProfile = false; + /** * Resolve the rule set for a repo with explicit, deterministic override semantics (P1-03): - * 1. profile — `minimal` replaces the pack with the core-safety section; otherwise the - * full source pack. + * 1. profile — `minimal` replaces the pack with the core-safety section; anything else + * (including the deprecated legacy names web-app/backend-service/library/regulated, + * which warn once per process) behaves as `standard`, the full source pack (RA-14). * 2. disableSections — drop sections by id or title. * 3. appends — legacy `.forge/rules.json` sections, then `config.rules` sections. */ function loadRules(targetRoot) { const cfg = loadConfig(targetRoot); const base = JSON.parse(readFileSync(join(BRAND.root, "source/rules.json"), "utf8")); + if ( + typeof cfg.profile === "string" && + Object.hasOwn(LEGACY_PROFILES, cfg.profile) && + !warnedLegacyProfile + ) { + warnedLegacyProfile = true; + process.stderr.write( + `${BRAND.cli}: profile "${cfg.profile}" is deprecated — treated as "standard"\n`, + ); + } if (cfg.profile === "minimal") { base.sections = [MINIMAL_SECTION]; } else if (Array.isArray(cfg.disableSections) && cfg.disableSections.length) { @@ -142,12 +155,18 @@ export function sync({ targetRoot = process.cwd() } = {}) { } } - // MCP servers — emit the canonical set into each tool's MCP config (real formats). + // MCP servers — emit the FULL managed set (registry ∪ recorded integrations) into each + // tool's MCP config (real formats). Sharing managedMcpState with `integrations add` + // means sync can never drop a server that add installed, and vice versa (RA-03). A + // corrupt repo config falls back to registry-only with a warning — never treated as + // "no integrations installed, overwrite everything". + const warnings = []; const mcpFile = join(BRAND.root, "source", "mcp.json"); if (existsSync(mcpFile)) { try { - const servers = JSON.parse(readFileSync(mcpFile, "utf8")); - for (const row of emitMcp({ targetRoot, servers })) report.push(row); + const { servers, owned, warning } = managedMcpState(targetRoot); + if (warning) warnings.push(warning); + for (const row of emitMcp({ targetRoot, servers, owned })) report.push(row); } catch (err) { report.push({ tool: "MCP", @@ -157,8 +176,13 @@ export function sync({ targetRoot = process.cwd() } = {}) { }); } } - - const warnings = []; + // Corrupt repo config: rules were built from defaults (fail-open), but say so in the + // report instead of only on stderr — a typo'd config must not vanish silently (RA-15). + const cfg = loadConfig(targetRoot); + if (cfg.corrupt) + warnings.push( + `${cfg.path} is not valid JSON — config ignored, default rules used (fix or delete it)`, + ); if (backedUp) warnings.push( "existing AGENTS.md was not Forge-managed — backed up to AGENTS.md.forge-bak; move any custom rules into source/rules.json or a per-repo .forge/rules.json", @@ -189,8 +213,13 @@ export function autoSyncIfDrifted(targetRoot = process.cwd()) { const existing = shared.readIfExists(join(targetRoot, "AGENTS.md")); if (existing === null || !shared.isManaged(existing)) return { synced: false, reason: "no managed AGENTS.md here" }; - if (shared.extractHash(existing) === shared.hashContent(buildCanonical(targetRoot))) - return { synced: false, reason: "in sync" }; + // Full-byte comparison against the exact content sync would write (RA-16): the embedded + // marker hash alone proves nothing — a hand-edited body with an intact marker must + // still count as drift. managedContent is the same helper writeManaged writes through, + // so the two paths cannot diverge again. + const body = buildCanonical(targetRoot); + const expected = shared.managedContent(shared.mdHeader(shared.hashContent(body)), body); + if (existing === expected) return { synced: false, reason: "in sync" }; sync({ targetRoot }); return { synced: true, reason: "drifted — resynced" }; } diff --git a/src/verify.js b/src/verify.js index 808ea4a..7e82a30 100644 --- a/src/verify.js +++ b/src/verify.js @@ -34,8 +34,11 @@ function git(args, cwd) { // PASS — a real verifier ran and passed // FAIL — a real verifier ran and failed // NOT_CONFIGURED — no test runner exists for this repo (nothing ran → NEVER ok) -// INCOMPLETE — a runner was expected but couldn't complete (timeout, or no concrete -// executor for the detected command) +// INCOMPLETE — a runner was expected but couldn't complete (timeout, executor binary +// missing, or no built-in executor for the detected command) +// The DETECTED runner is what actually executes (a pnpm/yarn/bun repo runs its own package +// manager, never a hardcoded `npm`), via the executor whitelist below — shell-free spawn of +// a known bin only, never npx (it can download arbitrary packages). // `ran`/`passed` are kept for back-compat (consensus.js reads them). Bounded by a timeout // (FORGE_VERIFY_TIMEOUT_MS, default 10 min) so a hanging test can't hang the gate. /** @@ -48,6 +51,20 @@ function git(args, cwd) { * @property {string[]} [detected] * @property {string} [output] */ +// Bins forge is willing to execute directly. Everything else stays report-only. +const EXECUTORS = new Set(["npm", "pnpm", "yarn", "bun", "pytest"]); +// Fallback when a detectStack result has no `testRunners` field (older shape): +// rebuild descriptors from the command strings. +/** @param {string[]} cmds @returns {import("./stack.js").TestRunner[]} */ +function parseRunnerStrings(cmds) { + return cmds.map((c) => { + const cmd = c.trim(); + const pm = /(^|\s)(npm|pnpm|yarn|bun)\s+test\b/.exec(cmd); + if (pm) return { bin: pm[2], args: ["test"], label: `${pm[2]} test` }; + if (/\bpytest\b/.test(cmd)) return { bin: "pytest", args: ["-q"], label: "pytest -q" }; + return { label: cmd }; + }); +} /** * @param {string} cwd * @returns {VerifyTests} @@ -56,43 +73,63 @@ function runTests(cwd) { const timeout = Number(process.env.FORGE_VERIFY_TIMEOUT_MS) || 600000; const run = (cmd, args) => execFileSync(cmd, args, { cwd, encoding: "utf8", stdio: "pipe", timeout }); - // Detect the repo's real test commands (no test script → none → NOT_CONFIGURED, not a + // Detect the repo's real test runners (no test script → none → NOT_CONFIGURED, not a // forced npm-test failure). - let detected = []; + let stack = null; try { - detected = detectStack(cwd).testCommands; + stack = detectStack(cwd); } catch {} + const detected = stack?.testCommands ?? []; if (!detected.length) return { ran: false, status: "NOT_CONFIGURED" }; - // Concrete runners forge can actually execute. npm test is only real if a package.json exists. - const npm = - detected.some((c) => /(^|\s)(npm|pnpm|yarn|bun)\s+test\b/.test(c)) && - existsSync(join(cwd, "package.json")); - const pytest = detected.some((c) => /\bpytest\b/.test(c)); + const runners = stack?.testRunners?.length ? stack.testRunners : parseRunnerStrings(detected); + // First whitelisted descriptor wins. A package-manager runner is only real when a + // package.json exists (the guard the old npm-only path had). + const candidate = runners.find( + (r) => + r?.bin && + EXECUTORS.has(r.bin) && + (r.bin === "pytest" || existsSync(join(cwd, "package.json"))), + ); + if (!candidate) { + // Runners were detected but forge has no built-in executor for any of them — + // expected, didn't complete. Never guess a different runner. + const labels = runners.map((r) => r?.label).filter(Boolean); + return { + ran: false, + status: "INCOMPLETE", + detected, + output: `detected "${labels.join('", "')}" — no built-in executor; run it yourself and re-verify`, + }; + } try { - if (npm) { - run("npm", ["test"]); - return { ran: true, passed: true, status: "PASS", runner: "npm test" }; - } - if (pytest) { - run("pytest", ["-q"]); - return { ran: true, passed: true, status: "PASS", runner: "pytest" }; - } - // A runner was detected but forge has no concrete executor for it — expected, didn't complete. - return { ran: false, status: "INCOMPLETE", detected }; + run(candidate.bin, candidate.args ?? []); + return { ran: true, passed: true, status: "PASS", runner: candidate.label }; } catch (e) { + if (e.code === "ENOENT") { + // The detected runner's binary isn't installed here — nothing ran, and silently + // substituting another package manager would verify the wrong thing. + return { + ran: false, + status: "INCOMPLETE", + detected, + output: `detected "${candidate.label}", executor unavailable (${candidate.bin} not on PATH)`, + }; + } if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") { return { ran: true, passed: false, timedOut: true, status: "INCOMPLETE", - output: `test run exceeded ${timeout}ms`, + runner: candidate.label, + output: `test run (${candidate.label}) exceeded ${timeout}ms`, }; } return { ran: true, passed: false, status: "FAIL", + runner: candidate.label, output: String(e.stdout || e.message || "").slice(-600), }; } diff --git a/test/cli_init.test.js b/test/cli_init.test.js new file mode 100644 index 0000000..a94c3b9 --- /dev/null +++ b/test/cli_init.test.js @@ -0,0 +1,115 @@ +// CLI-level init contract (RA-04, RA-11, RA-13, RA-17, RA-18): exit codes, stderr +// routing, and the install.sh wording that the docs/help print. The settings target is +// injected via FORGE_SETTINGS_PATH (test-only plumbing read by the init block) so no +// test ever touches the real ~/.claude/settings.json. +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); +const INSTALL_SH = fileURLToPath(new URL("../install.sh", import.meta.url)); +const runCli = (args, { cwd, settingsPath } = {}) => + spawnSync("node", [CLI, ...args], { + cwd, + encoding: "utf8", + env: { + ...process.env, + FORGE_NO_HINT: "1", + ...(settingsPath ? { FORGE_SETTINGS_PATH: settingsPath } : {}), + }, + }); + +test("init --settings-only against a corrupt settings file exits 1 with the reason on stderr", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-cli-corrupt-")); + const settingsPath = join(tmp, "settings.json"); + const garbage = "{ not json "; + writeFileSync(settingsPath, garbage); + const r = runCli(["init", "--settings-only"], { cwd: tmp, settingsPath }); + assert.equal(r.status, 1, "a refused merge must FAIL, not exit 0 (RA-04)"); + assert.match(r.stderr, /settings: FAILED/, "failure goes to stderr"); + assert.match(r.stderr, /not valid JSON/); + assert.equal(readFileSync(settingsPath, "utf8"), garbage, "file untouched"); +}); + +test("init --settings-only announces the GLOBAL merge (informed consent) and exits 0", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-cli-consent-")); + const settingsPath = join(tmp, "settings.json"); + const first = runCli(["init", "--settings-only"], { cwd: tmp, settingsPath }); + assert.equal(first.status, 0); + assert.match(first.stdout, /GLOBAL — affects all repos/, "consent line on merge"); + assert.match(first.stdout, /--no-settings/, "names the opt-out"); + assert.match(first.stdout, /--remove-settings/, "names the reversal"); + // RA-11: the notice prints on unchanged runs too, not only the first merge. + const second = runCli(["init", "--settings-only"], { + cwd: tmp, + settingsPath, + }); + assert.equal(second.status, 0); + assert.match(second.stdout, /GLOBAL — affects all repos/, "consent line on unchanged run"); + assert.match(second.stdout, /already up to date/); +}); + +test("init --remove-settings reverses a merge (exit 0) and fails loudly on a corrupt file", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-cli-remove-")); + const settingsPath = join(tmp, "settings.json"); + assert.equal(runCli(["init", "--settings-only"], { cwd: tmp, settingsPath }).status, 0); + const removed = runCli(["init", "--remove-settings"], { + cwd: tmp, + settingsPath, + }); + assert.equal(removed.status, 0); + assert.match(removed.stdout, /settings: removed .*_forge/, "reports what was removed"); + assert.match(removed.stdout, /backup:/, "reports the backup"); + const after = JSON.parse(readFileSync(settingsPath, "utf8")); + assert.ok(!after._forge && !after.hooks, "forge entries gone"); + // Corrupt file → refuse with exit 1 on stderr. + const garbage = "{ nope "; + writeFileSync(settingsPath, garbage); + const bad = runCli(["init", "--remove-settings"], { cwd: tmp, settingsPath }); + assert.equal(bad.status, 1); + assert.match(bad.stderr, /NOT modified/); + assert.equal(readFileSync(settingsPath, "utf8"), garbage, "corrupt file untouched"); +}); + +test("full init with an invalid profile exits 1 BEFORE any side effect (RA-13)", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-cli-badprofile-")); + const settingsPath = join(tmp, "home-settings.json"); + const r = runCli(["init", "--profile", "bogus"], { cwd: tmp, settingsPath }); + assert.equal(r.status, 1); + assert.match(r.stderr, /unknown profile: bogus/); + assert.ok(!existsSync(join(tmp, "AGENTS.md")), "no AGENTS.md emitted"); + assert.ok(!existsSync(join(tmp, ".forge")), "no .forge/ created"); + assert.ok(!existsSync(join(tmp, ".gitattributes")), "no .gitattributes appended"); + assert.ok(!existsSync(settingsPath), "settings never touched"); +}); + +// --------------------------------------------------------------------------- +// install.sh — static checks (RA-04, RA-17, RA-18): syntax + truthful wording. +// --------------------------------------------------------------------------- + +test("install.sh parses (bash -n) and its header tells the truth about the settings merge", () => { + const syntax = spawnSync("bash", ["-n", INSTALL_SH], { encoding: "utf8" }); + assert.equal(syntax.status, 0, `bash -n failed: ${syntax.stderr}`); + const sh = readFileSync(INSTALL_SH, "utf8"); + assert.ok(!sh.includes("never edits settings.json"), "the RA-18 lie is gone"); + assert.match(sh, /GLOBAL — affects all repos/, "header/consent names the global scope"); + assert.match(sh, /--no-settings/, "documents the skip flag"); + assert.match(sh, /--remove-settings/, "documents the reversal path"); +}); + +test("install.sh propagates a failed settings merge as an INCOMPLETE install, and uninstall cleans settings", () => { + const sh = readFileSync(INSTALL_SH, "utf8"); + assert.match(sh, /SETTINGS_FAILED=1/, "captures the merge exit status"); + assert.match(sh, /WARNING: settings merge FAILED/, "loud stderr warning"); + assert.match(sh, /Install INCOMPLETE — settings\.json was not updated/, "no false Done."); + assert.match(sh, /init --remove-settings/, "uninstall attempts the settings reversal"); + // The failure path must not be swallowed by a `|| say` on the merge pipeline (RA-04). + assert.ok( + !/init --settings-only 2>&1 \| sed[^\n]*\\\n\s*\|\| say/.test(sh), + "merge exit status is no longer discarded by an || fallback on the pipeline", + ); +}); diff --git a/test/consensus.test.js b/test/consensus.test.js index 9d0d710..5abf792 100644 --- a/test/consensus.test.js +++ b/test/consensus.test.js @@ -345,6 +345,108 @@ test("verifyDeep: reviewer panel joins via injected runner and can tip a second } }); +// --------------------------------------------------------------------------- +// RA-01 — deep ok is a conjunction: core tests PASS AND no consensus block. +// --------------------------------------------------------------------------- + +test("verifyDeep: NOT_CONFIGURED core is never ok, even with every lens clean (RA-01)", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => fakeCore({ tests: { ran: false, status: "NOT_CONFIGURED" } }), + }); + assert.equal(r.ok, false, "nothing ran must never be deep-ok"); + assert.equal(r.status, "NOT_CONFIGURED"); + assert.equal(r.block, false, "the lenses did not block — the CORE verdict did"); + const prov = JSON.parse(readFileSync(join(dir, ".forge", "provenance.json"), "utf8")); + assert.equal(prov.deep.status, "NOT_CONFIGURED", "deep status persisted additively"); + assert.equal(prov.deep.block, false, "existing provenance fields untouched"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: INCOMPLETE core (detected but unexecutable runner) is not ok (RA-01)", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => + fakeCore({ + tests: { + ran: false, + status: "INCOMPLETE", + detected: ["go test ./..."], + }, + }), + }); + assert.equal(r.ok, false); + assert.equal(r.status, "INCOMPLETE"); + assert.deepEqual(r.tests.detected, ["go test ./..."], "the detected runner rides along"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: explicit four-state FAIL core blocks with status FAIL", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => fakeCore({ tests: { ran: true, passed: false, status: "FAIL" } }), + }); + assert.equal(r.ok, false); + assert.equal(r.status, "FAIL"); + assert.equal(r.block, true, "a failing suite still blocks solo"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: passing core + clean lenses → ok:true with status PASS", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => fakeCore(), + }); + assert.equal(r.ok, true, "ran/passed fallback: a status-less passing core still derives PASS"); + assert.equal(r.status, "PASS"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: passing core + blocking finding → ok:false with status FAIL", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => + fakeCore({ + tests: { + ran: true, + passed: true, + status: "PASS", + runner: "npm test", + }, + added: `token = "${fakeAnthropic()}"`, // secrets lens blocks solo + }), + }); + assert.equal(r.ok, false); + assert.equal(r.status, "FAIL", "consensus block on a PASSing core reads as FAIL"); + assert.equal(r.block, true); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + test("verifyDeep: a secret in the added lines blocks solo", () => { const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); try { diff --git a/test/doctor.test.js b/test/doctor.test.js index 4d6489b..d4afa7d 100644 --- a/test/doctor.test.js +++ b/test/doctor.test.js @@ -148,3 +148,31 @@ test("doctor --fix is idempotent — a second run repairs nothing", () => { const second = doctor({ targetRoot: root, settingsPath, fix: true }); assert.equal(second.repairs.length, 0, "second --fix run is a no-op"); }); + +test("doctor: a missing atlas is UNAVAILABLE, not ACTIVE; a fresh one is ACTIVE (RA-19)", async () => { + const bare = fixture(); + const r = doctor({ targetRoot: bare }); + const atlasRow = r.results.find((x) => x.label === "atlas"); + assert.equal(atlasRow.status, "na", "not built is neither ok nor a failure"); + assert.equal(r.health.atlas, "UNAVAILABLE"); + assert.equal(r.failed, 0, "na never counts toward failed totals"); + + const built = fixture(); + writeFileSync(join(built, "a.js"), "export const one = 1;\n"); + const { build } = await import("../src/atlas.js"); + build({ root: built }); + const r2 = doctor({ targetRoot: built }); + assert.equal(r2.results.find((x) => x.label === "atlas").status, "ok"); + assert.equal(r2.health.atlas, "ACTIVE"); +}); + +test("doctor --fix records a returned {action:'error'} repair as ok:false (RA-20)", () => { + const root = fixture(); + const settingsPath = join(fixture(), "settings.json"); + writeFileSync(settingsPath, "{ not: valid json"); // present but unparseable + const r = doctor({ targetRoot: root, settingsPath, fix: true }); + const rep = r.repairs.find((x) => x.id === "settings"); + assert.ok(rep, "a settings repair was attempted"); + assert.equal(rep.ok, false, "a returned error object is not success"); + assert.match(rep.error, /not valid JSON/, "the returned reason is surfaced"); +}); diff --git a/test/gate.test.js b/test/gate.test.js index 00b819c..6901330 100644 --- a/test/gate.test.js +++ b/test/gate.test.js @@ -56,31 +56,100 @@ test("gate table: clean and internal-only sessions owe nothing", () => { assert.equal(internal.allow, true); }); -test("gate table: THE row — code moved, no doc/state followed → block", () => { +test("gate table: THE row — code moved with no test evidence → block (RA-10)", () => { const r = gateDecision({ changed: ["src/route.js", "src/gate.js"] }); assert.equal(r.allow, false); - assert.equal(r.row, "code-without-docs"); + assert.equal(r.row, "code-without-test-evidence"); assert.deepEqual(r.classes.code, ["src/route.js", "src/gate.js"]); }); -test("gate table: any doc-class artifact (or a state touch) satisfies the floor", () => { - assert.equal(gateDecision({ changed: ["src/route.js", "README.md"] }).row, "docs-touched"); - assert.equal(gateDecision({ changed: ["src/route.js"], stateTouched: true }).row, "docs-touched"); - assert.equal(gateDecision({ changed: ["docs/GUIDE.md"] }).row, "docs-touched"); +test("gate table: docs/handoff alone no longer satisfy a code change (RA-10)", () => { + assert.equal( + gateDecision({ changed: ["src/route.js", "README.md"] }).allow, + false, + "code + docs but NO test evidence blocks — ceremony is not evidence", + ); + assert.equal( + gateDecision({ changed: ["src/route.js"], stateTouched: true }).row, + "code-without-test-evidence", + "a handoff alone can no longer pass a code change", + ); assert.equal( gateDecision({ changed: ["src/x.js", ".forge/state.md"] }).row, - "docs-touched", - "the gitignore-invisible snapshot counts via the changed set too", + "code-without-test-evidence", ); }); -test("gate table: test-only, config-only, and other-only changes pass (precision rule)", () => { - assert.equal(gateDecision({ changed: ["test/gate.test.js"] }).row, "no-code-class"); - assert.equal(gateDecision({ changed: [".github/workflows/ci.yml"] }).row, "no-code-class"); - assert.equal(gateDecision({ changed: ["assets/logo.png"] }).row, "no-code-class"); +test("gate table: code + test evidence but NO docs/state → code-without-docs", () => { + const r = gateDecision({ changed: ["src/x.js", "test/x.test.js"] }); + assert.equal(r.allow, false); + assert.equal(r.row, "code-without-docs", "reachable only WITH test evidence now"); +}); + +test("gate table: code + test evidence + docs (or state) → allow code-with-evidence", () => { + const tested = gateDecision({ + changed: ["src/x.js", "test/x.test.js", "README.md"], + }); + assert.equal(tested.allow, true); + assert.equal(tested.row, "code-with-evidence"); + const handoff = gateDecision({ + changed: ["src/x.js", "test/x.test.js"], + stateTouched: true, + }); + assert.equal(handoff.allow, true, "state/handoff still counts as the continuity leg"); + assert.equal(handoff.row, "code-with-evidence"); +}); + +test("gate table: a fresh passing verify run is test evidence; stale or FAIL is not", () => { + const base = { changed: ["src/x.js", "README.md"] }; + const fresh = gateDecision({ + ...base, + verifyEvidence: { fresh: true, status: "PASS" }, + }); + assert.equal(fresh.allow, true); + assert.equal(fresh.row, "code-with-evidence"); assert.equal( - gateDecision({ changed: ["src/x.js", "test/x.test.js"] }).allow, - false, - "code + tests but NO docs still blocks — tests are not prose", + gateDecision({ ...base, verifyEvidence: { fresh: false, status: "PASS" } }).row, + "code-without-test-evidence", + "a stale provenance stamp proves nothing about THIS session's change", ); + assert.equal( + gateDecision({ ...base, verifyEvidence: { fresh: true, status: "FAIL" } }).row, + "code-without-test-evidence", + "a fresh FAIL is not evidence of completion", + ); + assert.equal( + gateDecision({ + changed: ["src/x.js"], + verifyEvidence: { fresh: true, status: "PASS" }, + }).row, + "code-without-docs", + "verify evidence covers the test leg only — docs are still owed", + ); +}); + +test("gate table: test-only sessions pass (a regression test owes no prose)", () => { + const r = gateDecision({ changed: ["test/gate.test.js"] }); + assert.equal(r.allow, true); + assert.equal(r.row, "test-only"); +}); + +test("gate table: config-only owes the lighter continuity bar", () => { + const bare = gateDecision({ changed: [".github/workflows/ci.yml"] }); + assert.equal(bare.allow, false); + assert.equal(bare.row, "config-without-docs"); + assert.equal( + gateDecision({ changed: [".github/workflows/ci.yml"], stateTouched: true }).allow, + true, + "a handoff alone satisfies THIS row (config-only)", + ); + assert.equal( + gateDecision({ changed: [".github/workflows/ci.yml", "docs/DEPLOY.md"] }).row, + "docs-touched", + ); +}); + +test("gate table: docs-only and other-only changes still pass", () => { + assert.equal(gateDecision({ changed: ["docs/GUIDE.md"] }).row, "docs-touched"); + assert.equal(gateDecision({ changed: ["assets/logo.png"] }).row, "no-code-class"); }); diff --git a/test/guards.test.js b/test/guards.test.js index 8bd9cc5..46de847 100644 --- a/test/guards.test.js +++ b/test/guards.test.js @@ -94,6 +94,43 @@ test("protect-paths does not false-positive on prose mentioning secrets in a quo assert.equal(r.code, 0); }); +test("protect-paths blocks content-dumping git subcommands on secret paths (RA-05)", () => { + for (const command of [ + "git diff -- .env", + "git diff HEAD~1 .env.production", + "git stash show -p stash@{0} -- .env", + "git cat-file -p HEAD:.env", + "git archive HEAD .env", + "git grep -h . -- .env", + "git show HEAD:.env", // regression: already a reader before RA-05 + 'git diff -- ".env"', + "cat './.env'", + ]) { + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { command }, + }); + assert.equal(r.code, 2, `must block: ${command}`); + assert.match(r.err, /protected secret path/, `deny reason for: ${command}`); + } +}); + +test("protect-paths does not false-positive on benign git commands (RA-05)", () => { + for (const command of [ + "git diff src/verify.js", + "git log --oneline", + "git status", + "git stash list", // no secret token — the permission ask covers stash + 'git commit -m "update .env docs"', // secret token only inside prose, commit is not a reader + ]) { + const r = runGuard("protect-paths.sh", { + tool_name: "Bash", + tool_input: { command }, + }); + assert.equal(r.code, 0, `must not block: ${command}`); + } +}); + test("secret-redact redacts a token without jq (Node path)", () => { const r = runGuard("secret-redact.sh", { tool_name: "Bash", diff --git a/test/init.test.js b/test/init.test.js index c79a113..166f12b 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -1,9 +1,27 @@ import assert from "node:assert/strict"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readdirSync, + readFileSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; -import { catalog, init, mergeSettings } from "../src/init.js"; +import { BRAND } from "../src/brand.js"; +import { + catalog, + guardKey, + init, + LEGACY_PROFILES, + mergeSettings, + PROFILES, + removeForgeSettings, + shellQuote, + validateProfile, +} from "../src/init.js"; test("init emits the shared config for a fresh repo in one call", () => { const root = mkdtempSync(join(tmpdir(), "forge-init-")); @@ -120,6 +138,48 @@ test("init({settingsOnly}) is idempotent — a second run reports unchanged", () assert.equal(second.settings.action, "unchanged", "re-run never clobbers"); }); +test("PROFILES lists only real profiles; validateProfile maps legacy names (RA-14)", () => { + assert.deepEqual(PROFILES, ["minimal", "standard"]); + assert.deepEqual(validateProfile("minimal"), { + ok: true, + profile: "minimal", + }); + assert.deepEqual(validateProfile("standard"), { + ok: true, + profile: "standard", + }); + for (const legacy of Object.keys(LEGACY_PROFILES)) { + assert.deepEqual(validateProfile(legacy), { + ok: true, + profile: "standard", + deprecated: legacy, + }); + } + const bogus = validateProfile("bogus"); + assert.equal(bogus.ok, false); + assert.match(bogus.error, /unknown profile: bogus/); +}); + +test("init --profile with a legacy name stores the mapped profile and reports the deprecation", () => { + const root = mkdtempSync(join(tmpdir(), "forge-profile-")); + const r = init({ targetRoot: root, profile: "web-app", noSettings: true }); + assert.equal(r.profile.profile, "standard"); + assert.equal(r.profile.deprecated, "web-app"); + const cfg = JSON.parse(readFileSync(join(root, ".forge/forge.config.json"), "utf8")); + assert.equal(cfg.profile, "standard", "the mapped profile is stored, never the legacy name"); +}); + +test("init --profile refuses to overwrite a corrupt forge.config.json (RA-15)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-profile-corrupt-")); + mkdirSync(join(root, ".forge"), { recursive: true }); + const file = join(root, ".forge/forge.config.json"); + const original = "{ nope"; + writeFileSync(file, original); + const r = init({ targetRoot: root, profile: "minimal", noSettings: true }); + assert.match(r.profile.error, /not valid JSON/); + assert.equal(readFileSync(file, "utf8"), original, "corrupt bytes preserved"); +}); + test("catalog indexes tools (with a why), crew, and guards", () => { const c = catalog(); assert.ok( @@ -138,3 +198,201 @@ test("catalog indexes tools (with a why), crew, and guards", () => { assert.ok(c.guards.includes("cost-budget"), "has cost-budget guard"); assert.ok(!c.guards.includes("_guardlib"), "excludes the sourced lib"); }); + +// --------------------------------------------------------------------------- +// RA-12 — quoted hook paths +// --------------------------------------------------------------------------- + +test("shellQuote single-quotes paths (spaces safe) and escapes embedded quotes", () => { + assert.equal(shellQuote("/a b/guards/x.sh"), "'/a b/guards/x.sh'"); + assert.equal(shellQuote("/o'brien/x.sh"), "'/o'\\''brien/x.sh'"); +}); + +test("guardKey normalizes quoting: quoted, unquoted, tilde, and plugin-root spellings all match", () => { + const base = join(BRAND.root, "global"); + const key = guardKey(`bash ~/.forge/guards/cortex.sh prompt`); + assert.equal(key, "cortex.sh prompt"); + assert.equal(guardKey(`bash '${base}/guards/cortex.sh' prompt`), key); + assert.equal(guardKey(`bash ${base}/guards/cortex.sh prompt`), key); + assert.equal(guardKey(`bash '/a b/with space/guards/cortex.sh' prompt`), key); + assert.equal(guardKey('"${CLAUDE_PLUGIN_ROOT}"/global/guards/cortex.sh prompt'), key); + // Args are part of identity: `cortex.sh prompt` and `cortex.sh stop` differ. + assert.notEqual(guardKey("bash ~/.forge/guards/cortex.sh stop"), key); +}); + +test("mergeSettings resolves hook paths QUOTED so an install path with spaces survives", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-quoted-")); + const settingsPath = join(tmp, "settings.json"); + mergeSettings({ settingsPath }); + const merged = JSON.parse(readFileSync(settingsPath, "utf8")); + const base = join(BRAND.root, "global"); + const cmds = Object.values(merged.hooks) + .flat() + .flatMap((e) => (e.hooks || []).map((h) => h.command)); + assert.ok(cmds.length > 0, "hooks merged"); + for (const c of cmds) { + assert.match(c, /'[^']*\.sh'/, `hook path is single-quoted: ${c}`); + assert.ok(c.includes(`'${base}/`), `resolved to the quoted install base: ${c}`); + } + assert.match(merged.statusLine.command, /'[^']*\.sh'/, "statusline path quoted too"); +}); + +test("old UNQUOTED installed hook entries dedupe against the new quoted template and are healed", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-requote-")); + const settingsPath = join(tmp, "settings.json"); + const base = join(BRAND.root, "global"); + // What a pre-RA-12 install wrote: resolved absolute path, no quotes. + writeFileSync( + settingsPath, + JSON.stringify({ + hooks: { + UserPromptSubmit: [ + { + hooks: [ + { + type: "command", + command: `bash ${base}/guards/cortex.sh prompt`, + }, + ], + }, + ], + }, + }), + ); + mergeSettings({ settingsPath }); + const merged = JSON.parse(readFileSync(settingsPath, "utf8")); + const promptCmds = merged.hooks.UserPromptSubmit.flatMap((e) => + (e.hooks || []).map((h) => h.command), + ).filter((c) => c.includes("cortex.sh") && c.endsWith("prompt")); + assert.equal(promptCmds.length, 1, "no duplicate on re-merge with the quoted template"); + assert.equal( + promptCmds[0], + `bash ${shellQuote(`${base}/guards/cortex.sh`)} prompt`, + "the old unquoted entry is upgraded to the quoted spelling", + ); +}); + +// --------------------------------------------------------------------------- +// RA-13 — invalid profile aborts before ANY side effect +// --------------------------------------------------------------------------- + +test("validateProfile accepts known profiles and absence, rejects garbage", () => { + assert.deepEqual(validateProfile(undefined), { ok: true }); + assert.deepEqual(validateProfile("standard"), { + ok: true, + profile: "standard", + }); + const bad = validateProfile("bogus"); + assert.equal(bad.ok, false); + assert.match(bad.error, /unknown profile: bogus/); +}); + +test("init with an invalid profile mutates NOTHING — no emit, no .forge, no gitattributes, no settings", () => { + const root = mkdtempSync(join(tmpdir(), "forge-badprofile-")); + const settingsPath = join(root, "home-settings.json"); + const r = init({ targetRoot: root, profile: "bogus", settingsPath }); + assert.equal(r.aborted, true, "init reports the abort"); + assert.match(r.profile.error, /unknown profile: bogus/); + assert.ok(!existsSync(join(root, "AGENTS.md")), "no AGENTS.md"); + assert.ok(!existsSync(join(root, ".forge")), "no .forge/"); + assert.ok(!existsSync(join(root, ".gitattributes")), "no .gitattributes"); + assert.ok(!existsSync(settingsPath), "settings untouched"); + assert.deepEqual( + readdirSync(root), + [], + "target root is byte-for-byte pristine — zero side effects", + ); +}); + +// --------------------------------------------------------------------------- +// RA-17 — removeForgeSettings reverses the merge +// --------------------------------------------------------------------------- + +test("removeForgeSettings round-trips: merge then remove restores the user's file exactly", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-remove-")); + const settingsPath = join(tmp, "settings.json"); + const original = { + model: "opus", + statusLine: { type: "command", command: "bash ~/my-own-statusline.sh" }, + hooks: { + PreToolUse: [ + { + matcher: "Bash", + hooks: [{ type: "command", command: "bash ~/my-hook.sh" }], + }, + ], + }, + permissions: { allow: ["Bash(mycmd:*)"], defaultMode: "plan" }, + custom: { keep: true }, + }; + writeFileSync(settingsPath, JSON.stringify(original, null, 2)); + const merged = mergeSettings({ settingsPath }); + assert.equal(merged.action, "merged"); + const r = removeForgeSettings({ settingsPath }); + assert.equal(r.action, "removed"); + assert.ok(r.removed.includes("_forge"), "marker removed"); + assert.ok(r.backup && existsSync(r.backup), "timestamped backup written"); + const after = JSON.parse(readFileSync(settingsPath, "utf8")); + assert.deepEqual(after, original, "user-owned content is restored exactly"); +}); + +test("removeForgeSettings strips a merge into an EMPTY file completely (fresh-install teardown)", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-remove-fresh-")); + const settingsPath = join(tmp, "settings.json"); + mergeSettings({ settingsPath }); // creates the file from the template + const r = removeForgeSettings({ settingsPath }); + assert.equal(r.action, "removed"); + const after = JSON.parse(readFileSync(settingsPath, "utf8")); + assert.ok(!after._forge, "marker gone"); + assert.ok(!after.hooks, "no forge hooks remain"); + assert.ok(!after.permissions, "no forge permissions remain"); + assert.ok(!after.statusLine, "template statusline removed"); +}); + +test("removeForgeSettings removes an OLD unquoted install's entries too (quote-normalized match)", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-remove-old-")); + const settingsPath = join(tmp, "settings.json"); + const base = join(BRAND.root, "global"); + writeFileSync( + settingsPath, + JSON.stringify({ + _forge: "forge-managed", + statusLine: { type: "command", command: `bash ${base}/statusline.sh` }, + hooks: { + Stop: [ + { + hooks: [ + { + type: "command", + command: `bash ${base}/guards/completion-gate.sh`, + }, + ], + }, + ], + }, + }), + ); + const r = removeForgeSettings({ settingsPath }); + assert.equal(r.action, "removed"); + assert.ok(r.removed.includes("statusLine"), "unquoted statusline matched and removed"); + const after = JSON.parse(readFileSync(settingsPath, "utf8")); + assert.ok(!after.hooks, "unquoted guard hook removed"); + assert.ok(!after.statusLine, "statusline removed"); +}); + +test("removeForgeSettings refuses a corrupt file and noops on a missing or unmanaged one", () => { + const tmp = mkdtempSync(join(tmpdir(), "forge-remove-edge-")); + const corrupt = join(tmp, "corrupt.json"); + const garbage = "{ not json "; + writeFileSync(corrupt, garbage); + const bad = removeForgeSettings({ settingsPath: corrupt }); + assert.equal(bad.action, "error", "corrupt → refuse"); + assert.equal(readFileSync(corrupt, "utf8"), garbage, "original bytes preserved"); + const missing = removeForgeSettings({ settingsPath: join(tmp, "nope.json") }); + assert.equal(missing.action, "noop", "missing → noop"); + const clean = join(tmp, "clean.json"); + writeFileSync(clean, JSON.stringify({ model: "opus" })); + const noop = removeForgeSettings({ settingsPath: clean }); + assert.equal(noop.action, "noop", "nothing forge-managed → noop"); + assert.deepEqual(JSON.parse(readFileSync(clean, "utf8")), { model: "opus" }); +}); diff --git a/test/ledger_store.test.js b/test/ledger_store.test.js index 3d4e11b..6603093 100644 --- a/test/ledger_store.test.js +++ b/test/ledger_store.test.js @@ -1,6 +1,6 @@ import assert from "node:assert/strict"; import { execFileSync } from "node:child_process"; -import { mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; @@ -152,6 +152,7 @@ test("verify: catches forged evidence — wrong content hash, unknown oracle, in const c = fact("target", "forgery magnet"); putClaim(dir, c); appendEvidence(dir, c.id, ev("confirm", "run:legit")); + const before = val(loadClaims(dir)[0], 0); // one real confirm — the honest baseline const logPath = join(dir, "evidence", `${c.id}.log`); const forged = [ // real-looking record whose h doesn't match its content @@ -174,9 +175,8 @@ test("verify: catches forged evidence — wrong content hash, unknown oracle, in assert.ok(v.issues.some((i) => /content hash mismatch/.test(i))); assert.ok(v.issues.some((i) => /invalid outcome/.test(i))); assert.equal(v.outcomes, 1, "only the legit outcome counts"); - // And the forged lines can't move confidence either (val ignores them): - const honest = val(loadClaims(dir)[0], 0); - assert.ok(honest > 0.5 && honest < 0.75, `val=${honest} reflects one real confirm only`); + // And the forged lines can't move confidence AT ALL — val is exactly what it was: + assert.equal(val(loadClaims(dir)[0], 0), before, "forged lines are invisible to val()"); }); test("tombstone: append-only records; concurrent retractions coexist; stats reflects it", () => { @@ -357,14 +357,21 @@ test("appendEvidence: a git: ref is rejected unless the object exists in the rep writeFileSync(join(root, "f.txt"), "hello\n"); g("add", "-A"); g("commit", "-m", "init"); - const head = execFileSync("git", ["rev-parse", "HEAD"], { cwd: root, encoding: "utf8" }).trim(); + const head = execFileSync("git", ["rev-parse", "HEAD"], { + cwd: root, + encoding: "utf8", + }).trim(); const dir = repoLedger(root); // /.forge/ledger — repoRootOf() resolves back to root const c = fact("build", "the git ref is real"); putClaim(dir, c); // real HEAD sha resolves via `git cat-file -e` → accepted - const good = outcomeRecord({ oracle: "ci.run", result: "confirm", ref: `git:${head}` }).outcome; + const good = outcomeRecord({ + oracle: "ci.run", + result: "confirm", + ref: `git:${head}`, + }).outcome; assert.equal(appendEvidence(dir, c.id, good).ok, true, "resolvable HEAD sha accepted"); // a bogus sha does not resolve → rejected before it can affect confidence @@ -381,3 +388,99 @@ test("appendEvidence: a git: ref is rejected unless the object exists in the rep const store = verify(dir); assert.equal(store.outcomes, 1, "only the resolvable outcome counts"); }); + +// --------------------------------------------------------------------------- +// RA-02 — imported/forged ledger evidence must never move confidence: log lines are +// hash-verified at read time, and imports get the full append gate + quarantine. +// --------------------------------------------------------------------------- + +test("readEvidence/loadState: forged or invalid log lines are invisible at read time", () => { + const dir = tmp(); + const c = fact("readpath", "load-time verification"); + putClaim(dir, c); + appendEvidence(dir, c.id, ev("confirm", "run:1", 2)); + const legit = readEvidence(dir, c.id)[0]; + const path = join(dir, "evidence", `${c.id}.log`); + const hostile = [ + // real-looking record whose h is a lie (would ride the top oracle weight) + '{"author":"","h":"deadbeef","oracle":"human.revert","ref":"x","result":"confirm","t":0,"w":1}', + // correctly sealed but not a valid outcome (unknown oracle) + canonicalize( + sealRecord({ + author: "", + oracle: "made.up", + ref: "x", + result: "confirm", + t: 0, + w: 1, + }), + ), + // correctly sealed but a typed-and-empty ref + canonicalize( + sealRecord({ + author: "", + oracle: "test.run", + ref: "git:", + result: "confirm", + t: 0, + w: 0.8, + }), + ), + ].join("\n"); + writeFileSync(path, `${readFileSync(path, "utf8")}${hostile}\n`); + assert.deepEqual(readEvidence(dir, c.id), [legit], "only the sealed, valid record survives"); + assert.deepEqual(loadState(dir).evidence[c.id], [legit], "loadState sees the same single record"); + assert.equal(loadClaims(dir)[0].evidence.length, 1); +}); + +test("mergeDirs: imported forged/unresolvable evidence is quarantined and cannot move val", () => { + const dst = tmp(); + const src = tmp(); + const c = fact("target", "imports get no bypass"); + putClaim(dst, c); + appendEvidence(dst, c.id, ev("confirm", "run:legit", 1)); + const before = val(loadClaims(dst)[0], 5); + const beforeBytes = readFileSync(join(dst, "evidence", `${c.id}.log`), "utf8"); + + // Source replica: the same claim plus two hostile evidence lines written straight + // to disk — (a) well-formed but its h is a lie, (b) correctly sealed but its git: + // ref resolves to nothing in the destination repo. + putClaim(src, c); + mkdirSync(join(src, "evidence"), { recursive: true }); + writeFileSync( + join(src, "evidence", `${c.id}.log`), + `${[ + '{"author":"","h":"deadbeef","oracle":"test.run","ref":"x","result":"confirm","t":0,"w":0.8}', + canonicalize( + sealRecord({ + author: "", + oracle: "ci.run", + ref: "git:deadbeefdeadbeef", + result: "confirm", + t: 0, + w: 0.8, + }), + ), + ].join("\n")}\n`, + ); + + const r = mergeDirs(dst, src); + assert.equal(r.quarantined, 2, "both hostile records quarantined"); + assert.equal( + readFileSync(join(dst, "evidence", `${c.id}.log`), "utf8"), + beforeBytes, + "destination evidence log is byte-identical", + ); + assert.equal(val(loadClaims(dst)[0], 5), before, "val is exactly what it was before the merge"); + const qPath = join(dst, "quarantine", `${c.id}.log`); + assert.ok(existsSync(qPath), "quarantine log exists"); + const qLog = readFileSync(qPath, "utf8"); + assert.match(qLog, /hash mismatch/, "the forged-hash record carries its reason"); + assert.match(qLog, /unresolvable/, "the unresolvable-ref record carries its reason"); + + // Re-merge is idempotent: no new quarantine lines, nothing else changes. + const again = mergeDirs(dst, src); + assert.equal(again.quarantined, 0, "re-merge quarantines nothing new"); + assert.equal(readFileSync(qPath, "utf8"), qLog, "no duplicate quarantine lines"); + assert.equal(val(loadClaims(dst)[0], 5), before, "val still untouched after re-merge"); +}); diff --git a/test/mcp.test.js b/test/mcp.test.js index 82dd2f0..c830bba 100644 --- a/test/mcp.test.js +++ b/test/mcp.test.js @@ -1,12 +1,37 @@ import assert from "node:assert/strict"; -import { existsSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; +import { addIntegration, removeIntegration } from "../src/integrations.js"; import { sync } from "../src/sync.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-mcp-")); +// Every MCP target emitMcp writes, with its top-level key (mirror of src/emit/mcp.js). +const JSON_TARGETS = [ + [".mcp.json", "mcpServers"], + [".cursor/mcp.json", "mcpServers"], + [".gemini/settings.json", "mcpServers"], + [".roo/mcp.json", "mcpServers"], + [".zed/settings.json", "context_servers"], + [".vscode/mcp.json", "servers"], +]; + +function assertServersEverywhere(root, names, when) { + for (const [file, key] of JSON_TARGETS) { + const j = JSON.parse(readFileSync(join(root, file), "utf8")); + for (const n of names) assert.ok(j[key]?.[n], `${when}: ${n} present in ${file}`); + } + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + for (const n of names) + assert.match(toml, new RegExp(`\\[mcp_servers\\.${n}\\]`), `${when}: ${n} in codex toml`); + for (const n of names) { + const f = n.startsWith("forge-") ? `${n}.yaml` : `forge-${n}.yaml`; + assert.ok(existsSync(join(root, ".continue", "mcpServers", f)), `${when}: ${f} exists`); + } +} + test("sync emits MCP config into each tool's real format (forge server only, no third-party)", () => { const root = fixture(); sync({ targetRoot: root }); @@ -28,7 +53,7 @@ test("sync emits MCP config into each tool's real format (forge server only, no /\[mcp_servers\.forge-cortex\]/, ); assert.match( - readFileSync(join(root, ".continue", "mcpServers", "forge-mcp.yaml"), "utf8"), + readFileSync(join(root, ".continue", "mcpServers", "forge-cortex.yaml"), "utf8"), /mcpServers:/, ); }); @@ -116,3 +141,179 @@ test("cortex MCP exposes substrate tools", async () => { }); assert.match(called.result.content[0].text, /shouldAsk/); }); + +// --------------------------------------------------------------------------- +// RA-03 / RA-21 regression matrix — non-destructive emitters, persistent managed set. +// --------------------------------------------------------------------------- + +test("RA-03: sync → integrations add → sync keeps BOTH servers in every target (no oscillation)", () => { + const root = fixture(); + sync({ targetRoot: root }); + assertServersEverywhere(root, ["forge-cortex"], "after first sync"); + const res = addIntegration("context7", { targetRoot: root }); + assert.ok(res.ok); + assertServersEverywhere(root, ["forge-cortex", "context7"], "after add"); + sync({ targetRoot: root }); + assertServersEverywhere(root, ["forge-cortex", "context7"], "after second sync"); + const cfg = JSON.parse(readFileSync(join(root, ".forge", "forge.config.json"), "utf8")); + assert.deepEqual(cfg.mcp.integrations, ["context7"], "install recorded in the managed set"); + assert.ok(cfg.mcp.adopted.includes("context7"), "fresh create is forge-owned (auto-adopted)"); +}); + +test("Codex: a stale forge-marked block is refreshed on the next emit", () => { + const root = fixture(); + mkdirSync(join(root, ".codex"), { recursive: true }); + writeFileSync( + join(root, ".codex", "config.toml"), + '# forge:managed:forge-cortex begin\n[mcp_servers.forge-cortex]\ncommand = "forge"\nargs = ["OLD-STALE-ARG"]\n# forge:managed:forge-cortex end\n', + ); + sync({ targetRoot: root }); + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + assert.match(toml, /args = \["cortex-mcp"\]/, "marked block refreshed"); + assert.doesNotMatch(toml, /OLD-STALE-ARG/); + assert.equal(toml.match(/mcp_servers\.forge-cortex/g).length, 1, "no duplicate block"); +}); + +test("Codex: unmarked user blocks (own name AND same-name) survive byte-identical, reported", () => { + const root = fixture(); + const myTool = '[mcp_servers.mytool]\ncommand = "mine"\nargs = ["a"]\n'; + const myContext7 = '[mcp_servers.context7]\ncommand = "custom"\nargs = ["b"]\n'; + mkdirSync(join(root, ".codex"), { recursive: true }); + writeFileSync(join(root, ".codex", "config.toml"), `${myTool}\n${myContext7}`); + const res = addIntegration("context7", { targetRoot: root }); + assert.ok(res.ok); + assert.equal(res.adopted, false, "divergent pre-existing entry is NOT auto-adopted"); + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + assert.ok(toml.includes(myTool), "user's own tool block byte-identical"); + assert.ok(toml.includes(myContext7), "user's same-name context7 block byte-identical"); + const row = res.rows.find((r) => r.target === ".codex/config.toml"); + assert.match(row.note, /user-owned/); + assert.match(row.note, /--adopt/); +}); + +test("Codex: an unmarked block byte-matching the old forge emission is migrated to markers", () => { + const root = fixture(); + // Exactly what the pre-marker emitter wrote for the registry server. + mkdirSync(join(root, ".codex"), { recursive: true }); + writeFileSync( + join(root, ".codex", "config.toml"), + '\n[mcp_servers.forge-cortex]\ncommand = "forge"\nargs = ["cortex-mcp"]\n', + ); + sync({ targetRoot: root }); + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + assert.match(toml, /# forge:managed:forge-cortex begin/, "wrapped with markers"); + assert.equal(toml.match(/mcp_servers\.forge-cortex/g).length, 1, "no duplicate block"); +}); + +test("RA-21 JSON: user-owned same-name entry preserved + reported; --adopt refreshes it", () => { + const root = fixture(); + writeFileSync( + join(root, ".mcp.json"), + JSON.stringify({ + mcpServers: { + context7: { command: "my-custom-context7" }, + other: { command: "keep-me" }, + }, + }), + ); + let res = addIntegration("context7", { targetRoot: root }); + assert.ok(res.ok); + let j = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); + assert.equal(j.mcpServers.context7.command, "my-custom-context7", "user entry preserved"); + assert.ok(j.mcpServers.other, "unrelated user entry survives"); + const row = res.rows.find((r) => r.target === ".mcp.json"); + assert.match(row.note, /user-owned/); + assert.match(row.note, /--adopt/); + const cursor = JSON.parse(readFileSync(join(root, ".cursor", "mcp.json"), "utf8")); + assert.ok(cursor.mcpServers.context7, "absent targets still get the server"); + res = addIntegration("context7", { targetRoot: root, adopt: true }); + assert.ok(res.ok && res.adopted); + j = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); + assert.equal(j.mcpServers.context7.command, "npx", "adopted entry refreshed to catalog spec"); + assert.ok(j.mcpServers.other, "unrelated user entry still survives"); +}); + +test("Continue: per-server marked files; forge legacy combined file migrated; user file kept", () => { + const root = fixture(); + const dir = join(root, ".continue", "mcpServers"); + mkdirSync(dir, { recursive: true }); + // The pre-RA-03 emitter's exact header fingerprint (it never wrote a marker). + writeFileSync( + join(dir, "forge-mcp.yaml"), + "name: Forge MCP\nversion: 0.0.1\nschema: v1\nmcpServers:\n - name: forge-cortex\n", + ); + sync({ targetRoot: root }); + assert.ok(existsSync(join(dir, "forge-cortex.yaml")), "per-server file written"); + assert.match(readFileSync(join(dir, "forge-cortex.yaml"), "utf8"), /forge:sync:/); + assert.ok(!existsSync(join(dir, "forge-mcp.yaml")), "legacy combined file migrated away"); + + // A forge-MARKED legacy file is also migrated away. + writeFileSync(join(dir, "forge-mcp.yaml"), "# forge:sync:abc123\nmcpServers: []\n"); + sync({ targetRoot: root }); + assert.ok(!existsSync(join(dir, "forge-mcp.yaml")), "marked legacy file migrated away"); + + // A marker-less file at the legacy path that is NOT forge's fingerprint is user-owned. + const userYaml = "name: my own servers\nmcpServers: []\n"; + writeFileSync(join(dir, "forge-mcp.yaml"), userYaml); + sync({ targetRoot: root }); + assert.equal(readFileSync(join(dir, "forge-mcp.yaml"), "utf8"), userYaml, "user file preserved"); +}); + +test("integrations remove reverses add everywhere; second remove is a no-op", () => { + const root = fixture(); + sync({ targetRoot: root }); + addIntegration("context7", { targetRoot: root }); + let res = removeIntegration("context7", { targetRoot: root }); + assert.ok(res.ok); + assert.equal(res.removed, true); + for (const [file, key] of JSON_TARGETS) { + const j = JSON.parse(readFileSync(join(root, file), "utf8")); + assert.equal(j[key].context7, undefined, `context7 removed from ${file}`); + assert.ok(j[key]["forge-cortex"], `forge-cortex kept in ${file}`); + } + const toml = readFileSync(join(root, ".codex", "config.toml"), "utf8"); + assert.doesNotMatch(toml, /context7/, "codex block removed"); + assert.match(toml, /\[mcp_servers\.forge-cortex\]/, "registry block kept"); + assert.ok(!existsSync(join(root, ".continue", "mcpServers", "forge-context7.yaml"))); + const cfg = JSON.parse(readFileSync(join(root, ".forge", "forge.config.json"), "utf8")); + assert.deepEqual(cfg.mcp.integrations, [], "record cleared"); + assert.deepEqual(cfg.mcp.adopted, [], "adoption cleared"); + res = removeIntegration("context7", { targetRoot: root }); + assert.ok(res.ok); + assert.equal(res.removed, false, "second remove is a no-op"); +}); + +test("integrations remove never deletes a user-owned same-name entry", () => { + const root = fixture(); + writeFileSync( + join(root, ".mcp.json"), + JSON.stringify({ mcpServers: { context7: { command: "my-custom-context7" } } }), + ); + addIntegration("context7", { targetRoot: root }); // not adopted — user entry preserved + const res = removeIntegration("context7", { targetRoot: root }); + assert.ok(res.ok && res.removed); + const j = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); + assert.equal(j.mcpServers.context7.command, "my-custom-context7", "user entry left in place"); + const cursor = JSON.parse(readFileSync(join(root, ".cursor", "mcp.json"), "utf8")); + assert.equal(cursor.mcpServers.context7, undefined, "forge-written entry removed"); +}); + +test("corrupt forge.config.json: sync emits registry-only + warns; add refuses; bytes kept", () => { + const root = fixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge", "forge.config.json"), "{ not json"); + const r = sync({ targetRoot: root }); + assert.ok( + r.warnings.some((w) => /registry-only/.test(w)), + "sync warns about registry-only fallback", + ); + const j = JSON.parse(readFileSync(join(root, ".mcp.json"), "utf8")); + assert.ok(j.mcpServers["forge-cortex"], "registry server still emitted"); + const res = addIntegration("context7", { targetRoot: root }); + assert.equal(res.ok, false, "add refuses when the record cannot be updated"); + assert.equal( + readFileSync(join(root, ".forge", "forge.config.json"), "utf8"), + "{ not json", + "corrupt config bytes never overwritten", + ); +}); diff --git a/test/secretredact.test.js b/test/secretredact.test.js index 770afa3..160de93 100644 --- a/test/secretredact.test.js +++ b/test/secretredact.test.js @@ -1,17 +1,14 @@ import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; +import { copyFileSync, mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; import { fakeAnthropic } from "./_fixtures.js"; -const guard = join( - dirname(fileURLToPath(import.meta.url)), - "..", - "global", - "guards", - "secret-redact.sh", -); +const guardsDir = join(dirname(fileURLToPath(import.meta.url)), "..", "global", "guards"); +const guard = join(guardsDir, "secret-redact.sh"); function run(input) { const r = spawnSync("bash", [guard], { @@ -48,6 +45,35 @@ test("secret-redact matches redactSecrets byte-for-byte (shared implementation)" assert.equal(emitted, redactSecrets(input)); }); +// RA-06: a broken redactor must never fail silently — the unredacted output would +// pass straight through. Copying the .mjs to a dir with no ../../src/secrets.js makes +// its dynamic import throw, exercising the degradation path. +function runBrokenRedactor(env = {}) { + const tmp = mkdtempSync(join(tmpdir(), "redact-degraded-")); + const broken = join(tmp, "secret-redact.mjs"); + copyFileSync(join(guardsDir, "secret-redact.mjs"), broken); + const r = spawnSync("node", [broken], { + input: JSON.stringify({ + tool_response: "possible token ghp_but_module_is_missing", + }), + encoding: "utf8", + env: { ...process.env, ...env }, + }); + return { code: r.status ?? 1, out: r.stdout || "", err: r.stderr || "" }; +} + +test("secret-redact failure is loud, not silent (DEGRADED warning, exit 0)", () => { + const r = runBrokenRedactor({ FORGE_GUARD_STRICT: "" }); + assert.equal(r.code, 0, "default stays non-blocking"); + assert.match(r.err, /DEGRADED/); +}); + +test("secret-redact failure blocks under FORGE_GUARD_STRICT=1 (exit 2)", () => { + const r = runBrokenRedactor({ FORGE_GUARD_STRICT: "1" }); + assert.equal(r.code, 2, "strict mode blocks per the hook convention"); + assert.match(r.err, /DEGRADED/); +}); + test("secret-redact catches an unknown-vendor high-entropy token (beyond the old sed list)", () => { const tok = ["Zq7Rt2", "Xk9Lp4", "Vm1Nc8", "Yb5Ws3", "Hd6Fg0"].join(""); const r = run({ tool_response: `issued credential ${tok} ok` }); diff --git a/test/settings_template.test.js b/test/settings_template.test.js index 6a6f3d9..2d2ebe8 100644 --- a/test/settings_template.test.js +++ b/test/settings_template.test.js @@ -43,6 +43,16 @@ test("`:*` appears only at the end of a Bash specifier", () => { } }); +test("git stash requires confirmation (ask), never pre-allowed (RA-05)", () => { + // `git stash show -p` dumps stashed secret-file content with NO path token in + // the command string, so the path-token guard can never catch it — the + // permission layer is the only defense (and stash mutates state). + const allow = template.permissions?.allow ?? []; + const ask = template.permissions?.ask ?? []; + assert.ok(ask.includes("Bash(git stash:*)"), "git stash:* must be in ask"); + assert.ok(!allow.includes("Bash(git stash:*)"), "git stash:* must NOT be in allow"); +}); + test("the former inert curl-pipe deny rules are absent", () => { const deny = template.permissions?.deny ?? []; assert.ok(!deny.includes("Bash(curl:* | sh)"), "inert curl|sh rule must stay removed"); diff --git a/test/stack.test.js b/test/stack.test.js index c20917a..ea2e5e5 100644 --- a/test/stack.test.js +++ b/test/stack.test.js @@ -96,6 +96,36 @@ test("empty repo → empty but safe; corrupt manifest never throws", () => { assert.doesNotThrow(() => detectStack(root)); }); +test("testRunners: structured descriptors alongside UNCHANGED testCommands strings", () => { + const root = tmp(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + scripts: { test: "vitest run" }, + devDependencies: { vitest: "2" }, + }), + ); + writeFileSync(join(root, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); + const s = detectStack(root); + // strings: back-compat surface, byte-identical to before + assert.deepEqual(s.testCommands, ["npx vitest", "pnpm test"]); + // descriptors: the DETECTED package manager, structured for a shell-free spawn + assert.deepEqual( + s.testRunners.find((r) => r.label === "pnpm test"), + { bin: "pnpm", args: ["test"], label: "pnpm test" }, + ); + const npx = s.testRunners.find((r) => r.label === "npx vitest"); + assert.ok(npx && !npx.bin, "npx detections stay label-only — forge never executes npx"); + + const go = tmp(); + writeFileSync(join(go, "go.mod"), "module x\n\ngo 1.22\n"); + const gs = detectStack(go); + assert.deepEqual(gs.testCommands, ["go test ./..."]); + assert.deepEqual(gs.testRunners, [ + { bin: "go", args: ["test", "./..."], label: "go test ./..." }, + ]); +}); + test("CLI: forge stack --json emits the detected stack", () => { const root = tmp(); writeFileSync(join(root, "go.mod"), "module x\n\ngo 1.22\n"); diff --git a/test/stop_gate.test.js b/test/stop_gate.test.js index fdf80d2..547b072 100644 --- a/test/stop_gate.test.js +++ b/test/stop_gate.test.js @@ -1,6 +1,13 @@ import assert from "node:assert/strict"; import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { + existsSync, + mkdirSync, + mkdtempSync, + readFileSync, + utimesSync, + writeFileSync, +} from "node:fs"; import { tmpdir } from "node:os"; import { dirname, join } from "node:path"; import { test } from "node:test"; @@ -40,6 +47,17 @@ const start = (root, sid) => feed("session-start", { session_id: sid, cwd: root const stopGate = (root, sid, extra = {}, env = {}) => feed("stop-gate", { session_id: sid, cwd: root, ...extra }, env); +// A `verify` provenance stamp (the exact shape verify.js writes) with an explicit +// mtime offset relative to now, so fresh-vs-stale is deterministic regardless of +// filesystem timestamp granularity. Positive offsetMs = future = after session start. +function writeProvenance(root, status, { offsetMs = 5000 } = {}) { + mkdirSync(join(root, ".forge"), { recursive: true }); + const p = join(root, ".forge", "provenance.json"); + writeFileSync(p, JSON.stringify({ tests: { status } })); + const t = new Date(Date.now() + offsetMs); + utimesSync(p, t, t); +} + test("code-only session blocks ONCE with the repair checklist, then the marker allows", () => { const { root } = gitFixture(); start(root, "g1"); @@ -66,23 +84,80 @@ test("stop_hook_active and the kill switch never block", () => { assert.equal(killed.stdout.trim(), "", "FORGE_STOPGATE=0 disables the gate"); }); -test("code+doc together, docs-only, and clean sessions all pass silently", () => { +test("code + docs + test evidence and clean sessions pass silently", () => { const { root } = gitFixture(); start(root, "g3"); assert.equal(stopGate(root, "g3").stdout.trim(), "", "clean session owes nothing"); writeFileSync(join(root, "a.js"), "export const one = 4;\n"); writeFileSync(join(root, "README.md"), "# app\n\nupdated with the change\n"); - assert.equal(stopGate(root, "g3").stdout.trim(), "", "docs moved with the code"); + writeFileSync(join(root, "a.test.js"), "import './a.js';\n"); + assert.equal(stopGate(root, "g3").stdout.trim(), "", "docs AND a test moved with the code"); }); -test("a handoff (state.md mtime after session start) satisfies the gate", () => { +test("a handoff ALONE no longer satisfies a code change — but with fresh verify PASS it does (RA-10)", () => { const { root } = gitFixture(); start(root, "g4"); writeFileSync(join(root, "a.js"), "export const one = 5;\n"); mkdirSync(join(root, ".forge"), { recursive: true }); writeFileSync(join(root, ".forge", "state.md"), "# Session state\n\n## Done\n- changed a.js\n"); - const r = stopGate(root, "g4"); - assert.equal(r.stdout.trim(), "", "the gitignored snapshot counts via mtime-vs-baseline"); + const bare = stopGate(root, "g4"); + const out = JSON.parse(bare.stdout); + assert.equal(out.decision, "block", "handoff alone is ceremony, not evidence"); + assert.match(out.reason, /test evidence/i, "the reason leads with the missing leg"); + assert.match(out.reason, /verify/, "the checklist names the verify step"); + assert.doesNotMatch( + out.reason, + /this alone satisfies the gate\)/, + "the handoff-suffices claim is gone from code rows", + ); + // Same session shape, fresh evidence: state touch (docs leg) + fresh verify PASS passes. + const { root: root2 } = gitFixture(); + start(root2, "g4b"); + writeFileSync(join(root2, "a.js"), "export const one = 5;\n"); + writeFileSync(join(root2, ".forge", "state.md"), "# Session state\n\n## Done\n- changed a.js\n"); + writeProvenance(root2, "PASS"); + const r = stopGate(root2, "g4b"); + assert.equal( + r.stdout.trim(), + "", + "the gitignored snapshot still counts as the docs leg once test evidence exists", + ); +}); + +test("stale or failing verify provenance is NOT evidence; unreadable provenance fails toward block-once (RA-10)", () => { + // Stale stamp (mtime before session start) → block on first stop. + const { root } = gitFixture(); + start(root, "pv1"); + writeFileSync(join(root, "a.js"), "export const one = 51;\n"); + writeFileSync(join(root, ".forge", "state.md"), "# state\n"); + writeProvenance(root, "PASS", { offsetMs: -3_600_000 }); // an hour-old run + const stale = stopGate(root, "pv1"); + assert.equal(JSON.parse(stale.stdout).decision, "block", "stale PASS proves nothing"); + + // Fresh but FAIL → block. + const { root: rootF } = gitFixture(); + start(rootF, "pv2"); + writeFileSync(join(rootF, "a.js"), "export const one = 52;\n"); + writeFileSync(join(rootF, ".forge", "state.md"), "# state\n"); + writeProvenance(rootF, "FAIL"); + assert.equal(JSON.parse(stopGate(rootF, "pv2").stdout).decision, "block"); + + // Unreadable provenance → treated as absent → block once, second stop proceeds. + const { root: rootU } = gitFixture(); + start(rootU, "pv3"); + writeFileSync(join(rootU, "a.js"), "export const one = 53;\n"); + writeFileSync(join(rootU, ".forge", "state.md"), "# state\n"); + mkdirSync(join(rootU, ".forge"), { recursive: true }); + writeFileSync(join(rootU, ".forge", "provenance.json"), "not json{"); + utimesSync( + join(rootU, ".forge", "provenance.json"), + new Date(Date.now() + 5000), + new Date(Date.now() + 5000), + ); + const first = stopGate(rootU, "pv3"); + assert.equal(JSON.parse(first.stdout).decision, "block", "corrupt stamp → no evidence"); + const second = stopGate(rootU, "pv3"); + assert.equal(second.stdout.trim(), "", "block-once marker: second stop always proceeds"); }); test("missing baseline degrades to worktree-only detection and still blocks", () => { @@ -99,7 +174,10 @@ test("non-git root and garbage stdin are silent no-ops (fail-open)", () => { const r = stopGate(bare, "g5"); assert.equal(r.status, 0); assert.equal(r.stdout.trim(), ""); - const garbage = spawnSync("node", [ENTRY, "stop-gate"], { input: "not json", encoding: "utf8" }); + const garbage = spawnSync("node", [ENTRY, "stop-gate"], { + input: "not json", + encoding: "utf8", + }); assert.equal(garbage.status, 0); assert.equal(garbage.stdout.trim(), ""); }); @@ -115,7 +193,10 @@ test("guard shim: synchronous passthrough of the block decision, always exit 0", assert.equal(r.status, 0); assert.equal(JSON.parse(r.stdout).decision, "block", "the shim relays the gate's answer"); const bare = spawnSync("bash", [GUARD], { - input: JSON.stringify({ session_id: "x", cwd: mkdtempSync(join(tmpdir(), "forge-gate-")) }), + input: JSON.stringify({ + session_id: "x", + cwd: mkdtempSync(join(tmpdir(), "forge-gate-")), + }), encoding: "utf8", }); assert.equal(bare.status, 0); @@ -174,8 +255,9 @@ test("unicode/space doc paths keep their doc credit (-z parsing)", () => { writeFileSync(join(root, "Änderungen notes.md"), "# änderungen\n\ndocumented the change\n"); git("add", "-A"); git("-c", "commit.gpgsign=false", "commit", "-qm", "code + unicode doc"); + writeProvenance(root, "PASS"); // test-evidence leg, so the doc leg is what's under test const r = stopGate(root, "uni1"); - assert.equal(r.stdout.trim(), "", "the unicode-named doc satisfies the gate"); + assert.equal(r.stdout.trim(), "", "the unicode-named doc satisfies the docs leg"); }); test("missing session_id disables gating (no shared 'default' state)", () => { diff --git a/test/substrate.test.js b/test/substrate.test.js index ffd0a6e..4fe7877 100644 --- a/test/substrate.test.js +++ b/test/substrate.test.js @@ -187,3 +187,44 @@ test("enforceDecision (enforcing): blocks an edit into a large blast radius", as assert.equal(g.block, true); assert.match(g.reason, /large blast radius/); }); + +test("stale ambient atlas is dropped: no impacts, no predicted tests, honest render (RA-07)", async () => { + const { renderSubstrate, substrateContext } = await import("../src/substrate.js"); + const root = repo(); + writeFileSync(join(root, "math.test.js"), "import './math.js'\n"); + // Build a real atlas, then invalidate it by adding a brand-new file (inventory drift). + build({ root }); + writeFileSync(join(root, "brand_new.js"), "export const later = 1;\n"); + const r = substrateCheck(root, "Update `computeTax` in `math.js`", { allowBuild: false }); + assert.equal(r.impact.atlasFresh, false, "staleness is reported truthfully"); + assert.deepEqual(r.impact.impactedFiles, [], "no impact derived from a stale graph"); + assert.deepEqual(r.impact.reports, [], "no per-target reports from a stale graph"); + assert.deepEqual(r.impact.predictedTests, [], "no test predictions from a stale graph"); + assert.match(r.impact.note, /unavailable/); + const rendered = renderSubstrate(r); + assert.match(rendered, /impact: unavailable/); + assert.doesNotMatch( + rendered, + /likely-affected tests/, + "no test list right after the unavailable notice", + ); + const ambient = substrateContext(r); + assert.match(ambient, /Impact unavailable/); + assert.doesNotMatch(ambient, /Likely-affected tests/); +}); + +test("enforceDecision: a stale atlas never hard-blocks on blast radius (RA-07)", async () => { + const { enforceDecision } = await import("../src/substrate.js"); + const staleBig = { + assumption: { hardUnderspecified: false, questions: [] }, + impact: { + atlasFresh: false, + impactedFiles: Array.from({ length: 30 }, (_, i) => `f${i}.js`), + }, + }; + assert.equal( + enforceDecision(staleBig, { enforce: true, blastThreshold: 25 }).block, + false, + "stale predictions are not authority to block", + ); +}); diff --git a/test/sync.test.js b/test/sync.test.js index 169afa8..090434f 100644 --- a/test/sync.test.js +++ b/test/sync.test.js @@ -3,7 +3,7 @@ import { existsSync, mkdirSync, mkdtempSync, readFileSync, writeFileSync } from import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { assemble, sync } from "../src/sync.js"; +import { assemble, autoSyncIfDrifted, sync } from "../src/sync.js"; const fixture = () => mkdtempSync(join(tmpdir(), "forge-sync-")); @@ -99,6 +99,42 @@ test("minimal profile emits only the core-safety section (P1-02)", () => { assert.doesNotMatch(md, /## AI interfaces & design quality/, "full pack sections dropped"); }); +test("a stored legacy profile name behaves as standard — the full pack (RA-14)", () => { + const root = fixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge/forge.config.json"), JSON.stringify({ profile: "web-app" })); + sync({ targetRoot: root }); + const md = readFileSync(join(root, "AGENTS.md"), "utf8"); + assert.match(md, /## Workflow/, "full (standard) pack emitted"); + assert.match(md, /## AI interfaces & design quality/, "no section dropped"); +}); + +test("corrupt forge.config.json: sync fail-opens to default rules but surfaces a warning (RA-15)", () => { + const root = fixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge/forge.config.json"), "{ not json"); + const res = sync({ targetRoot: root }); + assert.match( + readFileSync(join(root, "AGENTS.md"), "utf8"), + /## Workflow/, + "default rules still emitted (fail-open)", + ); + assert.ok( + res.warnings.some((w) => w.includes("not valid JSON")), + `warnings must mention the corrupt config: ${JSON.stringify(res.warnings)}`, + ); +}); + +test("legacy .forge/config.json keys are migration-read into sync's config", () => { + const root = fixture(); + mkdirSync(join(root, ".forge"), { recursive: true }); + // Only the LEGACY file exists — profile stored there must still reach loadRules. + writeFileSync(join(root, ".forge/config.json"), JSON.stringify({ profile: "minimal" })); + sync({ targetRoot: root }); + const md = readFileSync(join(root, "AGENTS.md"), "utf8"); + assert.match(md, /## Core safety/, "minimal profile honored from the legacy file"); +}); + test("config disableSections drops a named section; config.rules appends (P1-03)", () => { const root = fixture(); mkdirSync(join(root, ".forge"), { recursive: true }); @@ -115,3 +151,20 @@ test("config disableSections drops a named section; config.rules appends (P1-03) assert.match(md, /## ProjectY/, "config.rules appended"); assert.match(md, /## Workflow/, "other sections preserved"); }); + +test("RA-16: a hand-edited AGENTS.md body with an INTACT marker still counts as drift", () => { + const root = fixture(); + sync({ targetRoot: root }); + const p = join(root, "AGENTS.md"); + const lines = readFileSync(p, "utf8").split("\n"); + // Keep line 0 (the GENERATED header with the forge:sync: marker), tamper the body. + writeFileSync( + p, + [lines[0], "# AGENTS.md — quietly rewritten by hand", ...lines.slice(2)].join("\n"), + ); + const r = autoSyncIfDrifted(root); + assert.equal(r.synced, true, "marker-only agreement must not pass as in-sync"); + const restored = readFileSync(p, "utf8"); + assert.doesNotMatch(restored, /quietly rewritten by hand/, "body restored from canonical"); + assert.equal(autoSyncIfDrifted(root).synced, false, "after repair: full bytes match again"); +}); diff --git a/test/tools.test.js b/test/tools.test.js index 97746f7..9e98f19 100644 --- a/test/tools.test.js +++ b/test/tools.test.js @@ -12,9 +12,11 @@ import { detectPrimaryTool, KNOWN_TOOLS, nonPrimaryTargets, + readForgeConfig, readRepoConfig, resolvePrimaryTool, rowToolKey, + writeForgeConfig, } from "../src/repo_config.js"; const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); @@ -75,6 +77,104 @@ test("malformed config never throws — treated as absent", () => { assert.deepEqual(readRepoConfig(root), {}); }); +test("readForgeConfig: forge.config.json wins over legacy config.json on key conflicts", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge/config.json"), JSON.stringify({ primaryTool: "cursor" })); + writeFileSync( + join(root, ".forge/forge.config.json"), + JSON.stringify({ primaryTool: "claude", profile: "minimal" }), + ); + const cfg = readForgeConfig(root); + assert.equal(cfg.primaryTool, "claude", "unified file wins"); + assert.equal(cfg.profile, "minimal"); + assert.deepEqual(readRepoConfig(root), { primaryTool: "claude" }); +}); + +test("readForgeConfig: corrupt forge.config.json is reported, valid legacy data still read (RA-15)", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge/forge.config.json"), "{ not json"); + writeFileSync(join(root, ".forge/config.json"), JSON.stringify({ primaryTool: "cursor" })); + const cfg = readForgeConfig(root); + assert.equal(cfg.corrupt, true, "corruption is reported, not swallowed"); + assert.equal(cfg.path, join(root, ".forge/forge.config.json")); + assert.equal(cfg.primaryTool, "cursor", "valid data from the other file still read"); + // The filtered primary-tool view stays throw-free and back-compat shaped. + assert.deepEqual(readRepoConfig(root), { primaryTool: "cursor" }); +}); + +test("writeForgeConfig refuses to overwrite a corrupt forge.config.json (RA-15)", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + const file = join(root, ".forge/forge.config.json"); + const original = "{ definitely not json"; + writeFileSync(file, original); + const res = writeForgeConfig(root, (cfg) => { + cfg.profile = "minimal"; + }); + assert.equal(res.ok, false); + assert.match(res.reason, /not valid JSON/); + assert.equal( + readFileSync(file, "utf8"), + original, + "corrupt bytes preserved for the human to fix", + ); +}); + +test("writeForgeConfig round-trips unknown keys and folds legacy config.json keys", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync( + join(root, ".forge/forge.config.json"), + JSON.stringify({ + profile: "minimal", + mcp: { integrations: ["context7"], adopted: ["x"] }, + }), + ); + writeFileSync(join(root, ".forge/config.json"), JSON.stringify({ primaryTool: "cursor" })); + const res = writeForgeConfig(root, (cfg) => { + cfg.primaryTool = "claude"; + }); + assert.equal(res.ok, true); + const written = JSON.parse(readFileSync(join(root, ".forge/forge.config.json"), "utf8")); + assert.deepEqual(written, { + profile: "minimal", + mcp: { integrations: ["context7"], adopted: ["x"] }, // unknown keys round-trip untouched + primaryTool: "claude", // legacy key folded in, then mutated + }); + // The legacy file is left in place (documented precedence: forge.config.json wins). + assert.ok(existsSync(join(root, ".forge/config.json"))); +}); + +test("applyPrimaryTool fails loudly on a corrupt forge.config.json — bytes preserved", async () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + const file = join(root, ".forge/forge.config.json"); + writeFileSync(file, "{ not json"); + await assert.rejects( + () => applyPrimaryTool(root, "claude", { syncFn: () => FAKE_REPORT }), + /not valid JSON/, + ); + assert.equal(readFileSync(file, "utf8"), "{ not json"); +}); + +test("clearRepoConfig scrubs primaryTool from both files but preserves other keys", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge/config.json"), JSON.stringify({ primaryTool: "cursor" })); + writeFileSync( + join(root, ".forge/forge.config.json"), + JSON.stringify({ primaryTool: "claude", profile: "minimal" }), + ); + const r = clearRepoConfig(root); + assert.equal(r.cleared, true); + assert.deepEqual(readRepoConfig(root), {}, "no primary tool resurfaces from the legacy file"); + const kept = JSON.parse(readFileSync(join(root, ".forge/forge.config.json"), "utf8")); + assert.deepEqual(kept, { profile: "minimal" }, "non-primary-tool keys preserved"); + assert.ok(!existsSync(join(root, ".forge/config.json")), "emptied legacy file deleted"); +}); + test("rowToolKey maps labels; shared source is never mapped", () => { assert.equal(rowToolKey("shared source"), null); assert.equal(rowToolKey("Claude Code MCP"), "claude"); @@ -135,7 +235,7 @@ test("`forge tools --reset` clears config and removes ONLY the block", () => { const root = tmp(); writeFileSync(join(root, ".gitignore"), "node_modules\n"); runCli(["tools", "claude"], root); - assert.ok(existsSync(join(root, ".forge/config.json"))); + assert.ok(existsSync(join(root, ".forge/forge.config.json"))); assert.ok(readGitignoreBlock(root).length > 0); const res = runCli(["tools", "--reset", "--json"], root); diff --git a/test/verify.test.js b/test/verify.test.js index 5346880..2e69f79 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -116,6 +116,50 @@ test("verify: a repo with no test runner is NOT_CONFIGURED and NOT ok (nothing r assert.equal(r.ok, false, "nothing ran must never be ok:true"); }); +// --------------------------------------------------------------------------- +// RA-08 — the DETECTED runner is what executes (or is honestly reported), never +// a hardcoded `npm test`. +// --------------------------------------------------------------------------- + +test("verify: pnpm repo targets pnpm — with pnpm off PATH it is INCOMPLETE, never a silent npm run", () => { + const root = gitRepo(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + name: "t", + scripts: { test: "node -e 'process.exit(0)'" }, + }), + ); + writeFileSync(join(root, "pnpm-lock.yaml"), "lockfileVersion: 9\n"); + // Empty PATH → spawning pnpm deterministically ENOENTs, whatever this machine has + // installed. git/atlas inside verify are fail-safe against the same PATH. + const oldPath = process.env.PATH; + process.env.PATH = join(root, "no-binaries-here"); + let r; + try { + r = verify({ targetRoot: root }); + } finally { + process.env.PATH = oldPath; + } + assert.equal(r.tests.status, "INCOMPLETE", "nothing ran — and npm was NOT substituted"); + assert.equal(r.tests.ran, false); + assert.equal(r.ok, false); + assert.ok(r.tests.output.includes("pnpm"), r.tests.output); + assert.ok(r.tests.output.includes("executor unavailable"), r.tests.output); +}); + +test("verify: go-only repo is INCOMPLETE with the real label — no built-in executor", () => { + const root = gitRepo(); + writeFileSync(join(root, "go.mod"), "module example.com/app\n\ngo 1.22\n"); + const r = verify({ targetRoot: root }); + assert.equal(r.tests.status, "INCOMPLETE"); + assert.equal(r.tests.ran, false, "go test is detected but never executed by forge"); + assert.equal(r.ok, false); + assert.ok(r.tests.output.includes("go test ./..."), r.tests.output); + assert.ok(r.tests.output.includes("no built-in executor"), r.tests.output); + assert.deepEqual(r.tests.detected, ["go test ./..."]); +}); + test("verify: an untracked source file appears in provenance (changedFiles + untracked)", () => { const root = gitRepo(); // untracked (never `git add`ed) — invisible to `git diff`, but part of the change.