diff --git a/.github/workflows/docs-links.yml b/.github/workflows/docs-links.yml new file mode 100644 index 0000000..4a061ec --- /dev/null +++ b/.github/workflows/docs-links.yml @@ -0,0 +1,32 @@ +name: docs-links + +# Advisory broken-link check for the Mintlify site. Runs only on PRs that touch the +# mintlify/ folder. Non-blocking: the job reports findings but does not fail the PR +# (continue-on-error), so a transient link check never gates a merge. Mintlify deploys +# via its GitHub App, not from CI — there is intentionally no deploy workflow here. + +on: + pull_request: + paths: + - "mintlify/**" + workflow_dispatch: {} + +permissions: + contents: read + +jobs: + broken-links: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + + - name: Check internal links + working-directory: mintlify + continue-on-error: true + run: npx --yes mintlify broken-links diff --git a/CHANGELOG.md b/CHANGELOG.md index 35d7882..48d0804 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,89 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Added + +- **`forge know` — the A7 knowledge-router.** Total routing (formal-synthesis + Theorem T6) of any fact to its storage home: exemplar k-NN over a labeled bank + (`src/knowledge_router.js`) picks among claude-md / rule / skill / state / + decision / ledger-fact / recall; below-confidence facts fall back to the ledger + (provenance `fallback`) instead of being dropped. Append-only homes are written + directly (decide log, repo-ledger fact claim, personal recall store), curated + files get advice naming the right command, secrets are refused before dispatch, + and `--dry-run`/`--json` route without writing. Distilled Cortex lessons that + read like decisions or durable facts auto-route to those homes (fail-open). +- **Commit-level gate rung (`forge precommit`).** The gate lattice's middle rung + (turn ⊂ commit ⊂ PR): `src/commit_gate.js` classifies staged files with the same + registry-derived classifier as the Stop gate (code staged with no doc/state artifact + → finding) and runs the built-in secret detector over staged added lines as a + gitleaks fallback. `FORGE_COMMIT_GATE` sets the mode (`warn` default · `block` · + `0` kill switch); a detected secret blocks in every mode. `forge harden` now installs + a pre-commit hook that runs gitleaks when present, then the commit gate — and never + clobbers a user-authored hook (writes `pre-commit.forge` beside it instead). +- **Pin/downgrade via `update --to `.** New `applyUpdateTo` in + `src/update.js`: for a git checkout it fetches tags, verifies the release tag + exists (unknown version → honest miss, never a throw), and detached-checkouts + the tag with a note on how to return to latest; npm-global installs get the + exact `npm i -g @` instruction instead. +- **Multi-lens verification consensus.** `forge verify --deep` (new `src/consensus.js`) + runs a LENSES table over the diff — tests, unknown symbols, unreviewed impact + radius, docs drift, secrets in added lines, spec-lock drift, plus an optional + `--llm` majority-of-3 reviewer panel — and aggregates noisy-OR + `P(defect)=1−∏(1−wᵢsᵢ)` with the same cross-family gate as the lesson miner: a + lone structural signal (or the model reviewer alone) never blocks; a failing test + suite or a leaked secret blocks solo. Findings extend `.forge/provenance.json` + with per-lens evidence and the Theorem-D residual `∏(1−cⱼ)` over the lenses that + ran, and each run appends one `stage:"verify"` metrics record. +- **`forge radar` — dependency-currency rings (I4 verified currency).** New zero-dep + `src/radar.js` reads this repo's Node manifests, probes the registry (injectable + `fetchImpl`; metadata + bulk advisories; 4s timeout) and classifies every dependency + into an adopt/trial/assess/hold ring from _evidence_ — staleness (540-day half-life), + major-version lag, severity-weighted advisories, deprecation; atlas usage is stakes, + not risk. Deprecated or a critical advisory → `hold`; fewer than two verified evidence + kinds → `assess` (never adopt on absence). Cached at `.forge/radar.json` + (`FORGE_RADAR_TTL_H`, default 24h); `--offline` serves the stale cache or fails + honestly; `--refresh` re-probes; `--json` for tooling. A network scan records I4 + evidence into the ledger (`currency:` facts, supersede semantics) plus a metrics + line, and the pre-edit hook surfaces a cache-only advisory when a file imports a `hold` + dependency (kill switch `FORGE_RADAR=0`). +- **Cross-machine memory sync.** `forge ledger sync` push-pulls the PCM ledger through a + git ref (`refs/forge/ledger` via `hash-object`/`mktree`/`commit-tree` plumbing; + non-fast-forward races re-merge and retry ≤3 — monotone by the CRDT join, so nothing is + lost) or a shared directory (bidirectional union-merge; `FORGE_SYNC_DIR` is the default + dir target). Target precedence: `--dir` > `--remote`/`--ref` > the repo's git remote > + `FORGE_SYNC_DIR` > an honest "no target". `--personal` syncs the per-user ledger beside + the recall store, making recall facts portable across machines. Fails open (offline, + missing remote, or corrupt remote blob → an honest reason, never a throw); the git + runner is injectable so tests drive it with local bare remotes and never touch the + network. +- **Anti-repetition memory (`forge deja`).** A first-try success mints no Cortex lesson, + so its trace used to be discarded when the session ended — the root of cross-session + repetition. Now every session Stop mints one `summary` claim (an existing ledger kind) + with a secret-redacted gist of the task and the files touched, attaching a `test.run` + confirm outcome when the session's own tests passed (so verified work outranks a mere + attempt). New `src/deja.js` `dejaLookup` ranks prior summaries/lessons/diagnoses via the + same Eq. 3 retrieval as `ledger query`; `forge deja ""` surfaces them, and the + pre-action substrate shows a one-line "déjà vu" advisory when a prompt matches prior + solved work. Kill switch `FORGE_DEJA=0`. Because summaries are ordinary ledger claims, + `forge ledger merge` carries them between machines. +- **Dashboard v2 (`forge dash`).** The localhost lens gains four panels over the + durable `.forge/` stores plus live refresh: Radar (dependency-currency rings read + from the `.forge/radar.json` cache — never fetches), Trends (per-stage + `metrics.jsonl` history bucketed by day as inline-SVG sparklines, 90-day window), + Memory browser (ranked recall search over the ledger via `retrieve`, with + confidence and freshness bars), and Session timeline (durable mint/tombstone events + across sessions). New read-only endpoints `GET /api/history`, `/api/claims?q=&kind=`, + `/api/radar`, and `/api/timeline`; the page polls every 5s, paused while the tab is + hidden. The append-only write discipline (only `POST /api/ratify` and `/api/retract`) + is unchanged, and corrupt or missing stores degrade to empty sections. +- **Static HTML report (`forge report`).** New zero-dep `src/report.js` emits a + self-contained `.forge/report.html` — the offline twin of `forge dash` (no server, no + fetch, no CDN, no JS to read it). `renderReport` is pure and reuses `dashData`, buckets + `metrics.jsonl` into a 90-day activity sparkline (server-side inline SVG), reads the + `.forge/radar.json` cache directly and defensively (absent → the radar section is + omitted), and draws its palette from `rootTokensCss()` so it matches the dashboard. + `--out ` overrides the default location. + ## [0.19.0] - 2026-07-17 ### Added diff --git a/README.md b/README.md index 873574c..4b7c66e 100644 --- a/README.md +++ b/README.md @@ -173,44 +173,50 @@ Output is plain text when piped; on a TTY it adds brand-palette color and confid meters. `NO_COLOR` turns color off, `FORCE_COLOR=1` forces it on (e.g. in CI, `0` forces off), and `TERM`/`COLORTERM` follow the usual terminal conventions. -| Group | Command | Does | -| -------------------------- | ----------------- | ------------------------------------------------------------------------------------------------------------------ | -| **Config layer** | `forge init` | emit every tool's native config from one source | -| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | -| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | -| | `forge update` | self-update — `--check` reports if a newer version exists, bare applies it | -| | `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 harden` | wire gitleaks pre-commit + sandbox settings | -| | `forge catalog` | Start-Here index of every tool / crew / guard | -| | `forge brand` | print the brand token map | -| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / import | -| | `forge recall` | cross-session personal memory — list / add / consolidate | -| | `forge remember` | durable, repo-committable fact | -| | `forge brain` | portable project-memory index | -| | `forge cortex` | self-correcting lessons — `status` / `why` | -| | `forge reuse` | proof-carrying code cache — query / mint / stats | -| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | -| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | -| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | -| | `forge preflight` | assumption / info-gap check | -| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | -| | `forge impact` | predict blast radius for a symbol or file | -| | `forge scope` | cluster + surface coupled files | -| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | -| | `forge context` | budgeted context assembly + completeness gate | -| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | -| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | -| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | -| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | -| | `forge lean` | scope-minimality footprint (advisory) | -| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | -| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance | -| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | -| | `forge spec` | spec-as-contract drift — init / lock / check | -| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | -| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | -| **Observability** | `forge dash` | localhost-only read-only dashboard over ledger, metrics, blast radius (default port 4242) | +| Group | Command | Does | +| -------------------------- | ----------------- | -------------------------------------------------------------------------------------------------------------------------------------- | +| **Config layer** | `forge init` | emit every tool's native config from one source | +| | `forge sync` | recompile canonical source → each tool's native files (idempotent) | +| | `forge doctor` | pass/fail health check: tools, guards, MCP, drift, update | +| | `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 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 | +| **Memory & team** | `forge ledger` | proof-carrying memory — stats / verify / show / blame / query / ratify / retract / merge / sync / import | +| | `forge recall` | cross-session personal memory — list / add / consolidate | +| | `forge remember` | durable, repo-committable fact | +| | `forge brain` | portable project-memory index | +| | `forge cortex` | self-correcting lessons — `status` / `why` | +| | `forge deja` | anti-repetition — ranks prior solved/verified sessions for a task you're about to start (`FORGE_DEJA=0` disables) | +| | `forge reuse` | proof-carrying code cache — query / mint / stats | +| | `forge handoff` | bounded session snapshot (`.forge/state.md`) — rewritten each handoff, re-injected every session start | +| | `forge decide` | append-only decision log (`.forge/decisions.md`, D-#### ADR-lite) — future sessions read it instead of re-deciding | +| | `forge know` | route any fact to its storage home (decision / ledger / recall / …) — total routing, an unsure fact still lands | +| **Substrate (pre-action)** | `forge substrate` | the full pre-action gate in one pass | +| | `forge preflight` | assumption / info-gap check | +| | `forge route` | cheapest capable model tier (`route gateway` emits LiteLLM config) | +| | `forge impact` | predict blast radius for a symbol or file | +| | `forge scope` | cluster + surface coupled files | +| | `forge imagine` | consequence sim + minimal dry-run suite (`--run` executes it sandboxed) | +| | `forge context` | budgeted context assembly + completeness gate | +| | `forge atlas` | build / query / has (hallucinated-symbol check) the code graph | +| | `forge stack` | detect this repo's real stack (languages, frameworks, test commands) from its manifests | +| | `forge anchor` | goal-drift check (advisory) — `set`/`show`/`clear` persists the goal across sessions | +| | `forge diagnose` | doom-loop: same failure 3× → diagnosis + escalation | +| | `forge lean` | scope-minimality footprint (advisory) | +| | `forge cost` | real per-day spend · measured stage factors (`--stages`) | +| **Verification & safety** | `forge verify` | independent gate — tests + hallucinated-symbol flag + provenance; `--deep` multi-lens consensus (`--llm` reviewer panel) | +| | `forge precommit` | commit-level gate rung — staged code w/o docs + secret scan (`FORGE_COMMIT_GATE=block\|warn\|0`) | +| | `forge radar` | dependency-currency rings (adopt/trial/assess/hold) from registry evidence — cached, offline-honest | +| | `forge scan` | skill-gate: vet a SKILL.md / .mcp.json for injection / RCE / exfil | +| | `forge spec` | spec-as-contract drift — init / lock / check | +| **UI / design** | `forge taste` | pick one visual direction → DESIGN.md | +| | `forge uicheck` | contrast · fingerprint · design · visual (WCAG · slop+conformance · Playwright) | +| **Observability** | `forge dash` | localhost-only live dashboard: ledger, metrics trends, radar rings, memory browser, session timeline, blast radius (default port 4242) | +| | `forge report` | static, self-contained HTML snapshot of `.forge/` (`.forge/report.html`) — opens offline, no server | + **→ Every command with a worked example and real output: [`docs/GUIDE.md`](docs/GUIDE.md).** @@ -225,12 +231,22 @@ built to merge without conflicts: forge init # once — also emits the .gitattributes union-merge rule the ledger needs # …work normally: cortex and `forge remember` shadow claims into the ledger as you go… git pull && forge ledger merge # fold in a teammate's ledger — any order +forge ledger sync # push-pull the ledger through a git ref (refs/forge/ledger) or a shared dir — CRDT, any order ``` Identical knowledge minted independently converges to **one** claim with every author preserved in its provenance; `forge ledger blame ` shows who minted it, every oracle outcome, and per-author trust. No server, no sync service — it's just files in git. +`forge ledger sync` moves that state between machines without a merge argument: with no +flags it uses the repo's git remote, serializing the ledger to a `state.json` blob under a +dedicated ref (`refs/forge/ledger`) — a raced non-fast-forward push re-merges and retries, +monotone by the CRDT join, so nothing is ever lost. Point it at a shared folder with +`--dir ` (or set **`FORGE_SYNC_DIR`** as the default dir target when there's no +remote), and add `--personal` to sync the per-user ledger beside the recall store — the +one `forge recall add` shadows facts into — so your personal facts follow you across +machines. + ## How it compares Structural differences only — each row is checkable against the named source, and the full diff --git a/docs/GUIDE.md b/docs/GUIDE.md index cdee1cc..9ea8ce9 100644 --- a/docs/GUIDE.md +++ b/docs/GUIDE.md @@ -28,10 +28,10 @@ Every command is real and wired. Grouped by what it does: | Group | Commands | | ---------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | | **Config / cross-tool sync** | `forge init` · `forge sync` · `forge doctor` · `forge update` · `forge docs` · `forge config` · `forge harden` · `forge catalog` · `forge brand` | -| **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` | +| **Memory & ledger (PCM)** | `forge ledger` · `forge recall` · `forge remember` · `forge brain` · `forge cortex` · `forge reuse` · `forge handoff` · `forge decide` · `forge know` | | **Code graph & retrieval** | `forge atlas` · `forge stack` · `forge context` | | **Substrate / pre-action** | `forge substrate` · `forge preflight` · `forge route` · `forge impact` · `forge scope` · `forge imagine` · `forge anchor` · `forge diagnose` · `forge lean` · `forge cost` | -| **Verification & safety** | `forge verify` · `forge scan` · `forge spec` | +| **Verification & safety** | `forge verify` · `forge precommit` · `forge radar` · `forge scan` · `forge spec` | | **UI / design** | `forge taste` · `forge uicheck` | | **Dashboard** | `forge dash` | @@ -204,7 +204,7 @@ weights). `ANTHROPIC_MODEL` / `FORGE_MODEL` override the tier choice entirely. Run `forge route gateway` to emit a LiteLLM config so the routing happens automatically. -**`forge route calibrate`** is the *advisory → gated promotion* (overview §4): it fits an +**`forge route calibrate`** is the _advisory → gated promotion_ (overview §4): it fits an affine correction of the rubric's score toward a held-out labeled fixture and reports whether that calibration **measurably** beats the raw rubric (lower held-out MAE past a margin) — the same kill-criteria discipline as the risk predictor (`src/predictor.js`), @@ -225,7 +225,7 @@ Forge route calibrate — outcome-calibrated routing (measured gate) ``` Here the gate does exactly its job: the rubric already generalizes well, the affine -calibration would make held-out error *worse*, so it is **refused**. A promotion only +calibration would make held-out error _worse_, so it is **refused**. A promotion only happens when the measurement earns it — an assertion never does. ### `forge config` — provider setup @@ -355,6 +355,31 @@ nothing durable recorded the choice. `forge decide` appends one ADR-lite line to design: a decision that stops being true gets a _new_ entry, never an edit — the log is history, and `forge docs sync` exempts it for exactly that reason. +### `forge know ""` — route any fact to its storage home + +The substrate has several memory shelves (decisions, ledger facts, personal recall, +handoff state, contributor rules…) and the routing discipline used to be prose — exactly +what sessions forget, so knowledge landed nowhere and was re-learned. `forge know` makes +the routing a function, and a **total** one (formal-synthesis Theorem T6): the same +exemplar k-NN as `forge route`/intent classification picks the home, and a fact that +resembles nothing in the bank falls back to the ledger (where unverified claims decay +toward _unsure_) — it is never dropped. + +```console +$ forge know "we chose sqlite over postgres because zero ops" + home: decision (confidence 0.667) → .forge/decisions.md + ledger + nearest: "we chose sqlite over postgres because zero ops" (0.667) + stored: D-0008 +``` + +Append-only homes (`decision`, `ledger-fact`, `recall`) are written directly; curated +files (`claude-md`, `rule`, `skill`, `state`) get advice naming the right command instead +of a blind write — routing to `state`, for example, points at `forge handoff`. `--dry-run` +routes without writing anything; `--json` for tooling. Secrets are refused before any +dispatch, like every forge store. With `ENABLE_CORTEX_DISTILL=1`, distilled Cortex lessons +that read like decisions or durable facts are auto-routed to those homes too (fail-open, +best-effort). + ### `forge docs sync` — which prose did this diff make stale? `forge docs check` reconciles the registries; `docs sync` answers the diff-shaped @@ -402,6 +427,40 @@ Forge verify PASS ``` +**`forge verify --deep` — multi-lens consensus.** The deep mode runs a table of +independent lenses over the same diff — the test suite, unknown symbols, atlas +dependents the diff never touched, code-without-docs drift, secret-shaped tokens in +the added lines, spec-lock drift, and (opt-in) a reviewer panel — and aggregates them +the way the lesson miner scores mistakes: noisy-OR `P(defect) = 1 − ∏(1 − wᵢsᵢ)` with +a **cross-family gate**, so any number of correlated structural signals stays advisory +while a failing test suite or a leaked secret blocks on its own. Every run reports the +Theorem-D residual `∏(1 − cⱼ)` over the lenses that actually ran — how much +silent-miss probability a PASS still carries — and extends `.forge/provenance.json` +with the per-lens evidence plus one `stage:"verify"` metrics record. + +`--llm` (or `FORGE_LLM=1`) adds the reviewer lens: three independent model samples +over the added lines, strict-majority vote, abstaining honestly when fewer than half +the replies are usable. The panel is a proposer, never a judge — it can only block +together with a second evidence family. + +```console +$ forge verify --deep + tests outcome w=0.8 ✓ clean + symbols structural w=0.4 ✓ clean + impact structural w=0.35 ● finding + docsdrift structural w=0.3 ✓ clean + secrets security w=0.9 ✓ clean + speclock structural w=0.4 — skipped + reviewer model w=0.3 — skipped + + ! dependents of the changed code are not in this diff: src/route.js + + P(defect): █░░░░░░░░░ 0.07 (families: structural) + residual: 0.005 — Theorem-D silent-miss bound + + PASS +``` + ### `forge atlas build | query | has` — the code-graph ```console @@ -444,6 +503,45 @@ Detection reads `package.json` (deps → frameworks, lockfile → package manage `pom.xml`/`build.gradle`, and `*.csproj`. Widening it is adding a data row, not code. `--json` for tooling. Nothing detected → an honest "no known stack". +### `forge radar` — is what this repo stands on still current? + +`forge stack` says _what_ this repo is built on; `forge radar` says whether it's still +_current_. It reads the Node manifests, probes the registry for each dependency, and places +every one in a ring computed from evidence — no hardcoded package lists: + +```console +$ forge radar + hold left-pad 1.3.0→1.3.0 ██████████ marked deprecated by its maintainer + assess got 9.6.0→14.4.5 ███████░░░ high advisory: SSRF via redirect; 5 majors behind (v9→v14) + trial express 4.18.2→4.21.0 ████░░░░░░ latest published 210d ago (half-life 540d) + adopt zod 3.23.8→3.23.8 █░░░░░░░░░ + Go: currency probe is Node-first +``` + +Rings are a **formula over registry evidence** (mizan — every ring ships the evidence that +earned it): `staleness = 1 − 0.5^(daysSincePublish/540)` (a 540-day half-life), major-version +lag, open security advisories (severity-weighted), and maintainer deprecation. Repo _usage_ +(import-sites from the atlas) is **stakes, not risk** — it only sorts output, never the score. +Hard rules: **deprecated or a critical advisory → `hold`** regardless of freshness; fewer than +two verified evidence kinds → **`assess` (never `adopt` on absence)** — missing evidence never +upgrades a dep. Otherwise `score < .25 → adopt`, `< .5 → trial`, else `assess`. + +The scan is cached at `.forge/radar.json` (TTL 24h, override with `FORGE_RADAR_TTL_H`). +`--offline` serves the stale cache (flagged with its age) or fails honestly rather than +guessing; `--refresh` re-probes; `--json` for tooling. The network probe is injectable and +never runs inside a hook or test. A network scan also records **I4 verified-currency evidence** +into the ledger — one `currency:` fact per dependency, superseding the prior verification +(latest stays live, history in tombstones) — and a `radar` metrics line. + +Once a scan exists, the **pre-edit hook** surfaces a one-line advisory (cache-only — hooks +never fetch) when you open a file that imports a `hold` dep (or an `assess` dep with an open +advisory): _"radar — src/x.js imports left-pad (ring hold: marked deprecated…). Check `forge +radar` before building on it."_ Silence it with `FORGE_RADAR=0`. + +The `dev-radar` skill is the LLM _wide scan_ across the ecosystem; `forge radar` is the +deterministic _repo instrument_ — it implements `global/rules/tech-currency.md` against THIS +repo's actual manifests. + ### `forge update` — self-update No more manual "am I on the latest?". `forge update --check` reports whether a newer @@ -453,6 +551,13 @@ installs go live immediately; npm-global installs get the `npm i -g` command). ` doctor` also surfaces a one-line notice when behind (cached, never nags; silence it with `FORGE_NO_UPDATE_CHECK=1`). Fail-open: offline or a non-git install never errors. +`forge update --to ` pins or downgrades to an exact release: for a git +checkout it fetches tags, verifies the release tag exists (a version that was never +released is an honest miss, not an error dump), and does a detached checkout at that +tag — the printed note tells you how to get back to latest (`git checkout `, +then `forge update`). npm-global installs get the exact `npm i -g @` +command instead. Accepts `0.17.0` or `v0.17.0`. + ### `forge recall add | list | consolidate` — cross-session memory Durable facts, one per file, injected at the start of the next session by the @@ -482,6 +587,33 @@ Forge cortex — self-correcting project memory `forge cortex why ` shows the lessons that would be injected when you touch it. +### `forge deja ""` — have you done this before? + +Cortex only learns from _corrections_ — a first-try success mints no lesson, so its +trace is discarded when the session ends and the next session starts blind. That is the +root of the "why do I keep re-solving the same thing across sessions" complaint. So at +every session Stop the substrate mints one `summary` claim (an existing ledger kind) of +the solved task: a secret-redacted gist of the first prompt plus the files touched. If +the session's own tests passed, it attaches one `test.run` confirm outcome — so a +verified success outranks a mere attempt. + +`forge deja` ranks those prior sessions (plus lessons and diagnoses) for a task you're +about to start, using the same Eq. 3 retrieval the ledger query uses: + +```console +$ forge deja "add rate limiting to the export endpoint" +Forge déjà vu — have you done this before? + + ██████░░ 0.712 summary verified day 20641 add throttling to the export route — files: src/export.js + ███░░░░░ 0.402 lesson attempted day 20630 forgot to update the OpenAPI spec after changing a route +``` + +The same top hit surfaces as a one-line advisory in the pre-action substrate when a +prompt closely matches prior solved work. Set `FORGE_DEJA=0` to disable both the Stop-hook +summary and the advisory. Because summaries are ordinary ledger claims, `forge ledger +merge` (and a future `forge ledger sync`) carry them between machines — so the +anti-repetition works across every surface you work on, not just one checkout. + ### `forge ledger` — proof-carrying team memory The convergent store behind cortex, `recall`, `brain`, and `reuse`: every stored unit is @@ -526,6 +658,21 @@ moves confidence; `verify` recomputes every content hash (CI-friendly, exit 1 on tampering); `import` back-fills legacy lessons/facts idempotently. Add `--personal` to target the per-user ledger beside the global recall store, `--json` for scripts. +`forge ledger sync` is `merge` without a path argument — a transport that moves the CRDT +state between machines. Target precedence: `--dir ` (a shared folder, bidirectional +union-merge) beats `--remote `/`--ref `, which beat the repo's own git remote, +which beats **`FORGE_SYNC_DIR`** (the default dir target when nothing else resolves); with +none of these it prints an honest "no sync target". Ref mode is pure git plumbing: one +commit per sync on `refs/forge/ledger` whose tree is a single `state.json` blob +(`hash-object` → `mktree` → `commit-tree` → `update-ref` → `push`); pull is `fetch` the +ref → read the blob → `importState`. A non-fast-forward push (a teammate synced first) is +never a conflict — it re-fetches, re-imports (monotone by the semilattice join), rebuilds +on the new parent and retries ≤3 times, so no claim is ever lost and re-running sync with +nothing new is a byte-level no-op (the remote tree already equals ours). Everything +fails open: offline, a missing remote, or a corrupt remote blob all return an honest +reason instead of throwing. With `--personal` the transport runs over the per-user ledger +the recall store shadows into, so recall facts become portable across machines. + **Retiring the legacy stores.** Since P1 the ledger has been the convergent WRITE store (every lesson/fact dual-writes into it) and reads are the merged view (legacy ∪ ledger). Set **`FORGE_LEDGER_ONLY=1`** to finish the job: the legacy files (`.forge/lessons/*.md`, @@ -755,8 +902,12 @@ Forge uicheck interact — browser interaction checks A read-only lens over `.forge/` — stdlib `node:http`, localhost-only, one self-contained HTML page (no CDN, no build step). Panels: Ledger (claims with val bars, -contested claims, per-author trust), Cost/Cache (measured stage counters), and Impact -(blast-radius explorer). Every claim row shows its `forge ledger blame` command. +contested claims, per-author trust), Cost/Cache (measured stage counters), Impact +(blast-radius explorer), Radar (dependency-currency rings read from the `.forge/radar.json` +cache), Trends (per-stage metrics history as inline-SVG sparklines), Memory browser +(ranked recall search over the ledger with confidence + freshness bars), and Session +timeline (durable mint/tombstone events across sessions). The page live-refreshes every 5s, +paused while the tab is hidden. Every claim row shows its `forge ledger blame` command. ```console $ forge dash @@ -765,6 +916,22 @@ Forge dash — read-only lens on .forge/ http://127.0.0.1:4242 (localhost-only · Ctrl-C to stop) ``` +### `forge report [--out ]` — the static HTML report + +The offline twin of `forge dash`: instead of serving a live localhost lens, it renders +ONE self-contained HTML file — no server, no fetch, no CDN, no JavaScript needed to read +it — that you can open offline, email, or attach to a PR. It reuses the same `dashData` +payload, buckets `.forge/metrics.jsonl` into a 90-day activity sparkline (server-side +inline SVG), and folds in the tech-radar cache (`.forge/radar.json`) when present. The +palette is the brand's own token block (`rootTokensCss`), so the report matches the +dashboard exactly. Default output is `.forge/report.html`; `--out` overrides it. + +```console +$ forge report + wrote /repo/.forge/report.html + open it in a browser — fully offline, no server needed. +``` + ### `forge cost --stages` — the measured cost report Per-stage cost factors as pure arithmetic over `.forge/metrics.jsonl`. A stage with no @@ -792,22 +959,23 @@ Plain `forge cost` remains the per-day spend view via `ccusage`. ### The rest -| Command | Answers | -| -------------------------------- | ------------------------------------------------------------------------------------------------------------------------------- | -| `forge init` | Emit every tool's native config from one source. | -| `forge sync` | Recompile `source/` → each tool's files (idempotent). | -| `forge doctor` | Health check: layers, install, drift, cortex. | -| `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 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. | -| `forge harden` | Wire gitleaks pre-commit + sandbox settings. | -| `forge spec [init\|lock\|check]` | Spec-as-contract drift check. | -| `forge brand` | Print the active brand token map. | -| `forge lean ""` | Scope-minimality footprint for a task — advisory (the Lean Path as a command). | -| `forge taste [ - - -
- - forge dash - - -
- -
-
-

Ledger — proof-carrying memory .forge/ledger/

-
-
- - - - -
-
-

Contested — val ∈ [0.40, 0.60] with ≥1 contradiction

-
-

Author trust — earned from oracle outcomes, floored at 0.50

-
-
- -
-

Cost / Cache — measured, not asserted .forge/metrics.jsonl

-
-
-
-

Recent events

-
    -
    - -
    -

    Impact — blast radius .forge/atlas.json

    -
    - - -
    -
    pick a symbol to see what a change would touch
    -
    -
    - -
    lens on .forge/ — every number traces to forge ledger blame <id>. the only writes are ratify/retract (append-only; CLI twins: forge ledger ratify|retract).
    - - - + :root { + --bg: #171310; + --panel: #201a15; + --panel-2: #272019; + --line: #372c22; + --fg: #f2ede7; + --muted: #a99e90; + --faint: #7d7263; + --ember: #f26430; + --ember-wash: rgba(242, 100, 48, 0.14); + --r-s: 4px; /* radius level 1: chips, bars, inputs */ + --r-m: 10px; /* radius level 2: panels */ + --s1: 4px; + --s2: 8px; + --s3: 12px; + --s4: 16px; + --s6: 24px; + --s8: 32px; + --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace; + --sans: system-ui, -apple-system, "Segoe UI", sans-serif; + } + * { + box-sizing: border-box; + } + body { + margin: 0; + background: var(--bg); + color: var(--fg); + font: 13px/1.55 var(--sans); + -webkit-font-smoothing: antialiased; + } + .mono { + font-family: var(--mono); + } + .muted { + color: var(--muted); + } + .faint { + color: var(--faint); + } + + header { + position: sticky; + top: 0; + z-index: 5; + display: flex; + align-items: center; + gap: var(--s3); + padding: var(--s3) var(--s6); + background: rgba(23, 19, 16, 0.85); + backdrop-filter: blur(8px); + border-bottom: 1px solid var(--line); + } + .spark { + width: 8px; + height: 8px; + border-radius: var(--r-s); + background: var(--ember); + } + .brand { + font-family: var(--mono); + font-size: 13px; + letter-spacing: 0.02em; + } + .brand b { + color: var(--ember); + font-weight: 600; + } + #hdr-meta { + margin-left: auto; + font-family: var(--mono); + font-size: 11px; + color: var(--muted); + } + button { + font: 12px var(--mono); + color: var(--fg); + cursor: pointer; + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s1) var(--s3); + } + button:hover { + border-color: var(--ember); + color: var(--ember); + } + + main { + max-width: 1180px; + margin: 0 auto; + padding: var(--s6); + display: grid; + grid-template-columns: 1fr 1fr; + gap: var(--s4); + } + .panel { + background: var(--panel); + border: 1px solid var(--line); + border-radius: var(--r-m); + padding: var(--s4); + min-width: 0; + } + .panel.wide { + grid-column: 1 / -1; + } + @media (max-width: 880px) { + main { + grid-template-columns: 1fr; + } + } + .panel h2 { + margin: 0 0 var(--s3); + font: 600 11px var(--mono); + text-transform: uppercase; + letter-spacing: 0.14em; + color: var(--muted); + } + .panel h2 .cmd { + text-transform: none; + letter-spacing: 0; + color: var(--faint); + float: right; + } + h3 { + margin: var(--s6) 0 var(--s2); + font: 600 11px var(--mono); + letter-spacing: 0.08em; + color: var(--faint); + text-transform: uppercase; + } + .empty { + color: var(--faint); + font-family: var(--mono); + font-size: 12px; + padding: var(--s2) 0; + } + + /* stat tiles */ + .stats { + display: flex; + flex-wrap: wrap; + gap: var(--s2); + margin-bottom: var(--s3); + } + .stat { + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s2) var(--s3); + min-width: 84px; + } + .stat .n { + font: 600 18px var(--mono); + display: block; + } + .stat .n.ember { + color: var(--ember); + } + .stat .l { + font-size: 11px; + color: var(--faint); + } + + /* tables */ + table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + th { + text-align: left; + font: 600 10px var(--mono); + text-transform: uppercase; + letter-spacing: 0.1em; + color: var(--faint); + padding: var(--s1) var(--s2); + border-bottom: 1px solid var(--line); + } + td { + padding: var(--s1) var(--s2); + border-bottom: 1px solid var(--line); + vertical-align: top; + } + tr:last-child td { + border-bottom: 0; + } + tr.tomb td { + opacity: 0.45; + } + tr.tomb .text { + text-decoration: line-through; + } + td.num { + font-family: var(--mono); + text-align: right; + } + .tag { + font: 11px var(--mono); + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: 0 var(--s1); + display: inline-block; + white-space: nowrap; + } + .tag.contested { + color: var(--ember); + border-color: var(--ember); + background: var(--ember-wash); + } + + /* val / trust / confidence bars — width IS the number; label repeats it */ + .bar { + display: inline-block; + width: 64px; + height: 6px; + vertical-align: middle; + background: var(--panel-2); + border-radius: var(--r-s); + overflow: hidden; + margin-right: var(--s2); + } + .bar i { + display: block; + height: 100%; + background: var(--ember); + border-radius: var(--r-s); + } + .bar.dim i { + background: var(--faint); + } + .barnum { + font-family: var(--mono); + font-size: 11px; + } + + /* the provenance affordance: every number traces to a blame command */ + .blame { + font: 11px var(--mono); + color: var(--faint); + cursor: copy; + white-space: nowrap; + } + .blame:hover { + color: var(--ember); + } + .blame.copied { + color: var(--ember); + } + + /* the two writes (spec §2): small per-row actions, append-only on the server */ + .act { + font: 11px var(--mono); + color: var(--faint); + cursor: pointer; + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: 0 var(--s1); + white-space: nowrap; + display: inline-block; + } + .act:hover { + color: var(--ember); + border-color: var(--ember); + background: var(--ember-wash); + } + td.acts { + white-space: nowrap; + } + + select, + input[type="text"] { + font: 12px var(--mono); + color: var(--fg); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s1) var(--s2); + } + input[type="text"]:focus, + select:focus { + outline: none; + border-color: var(--ember); + } + .toolbar { + display: flex; + align-items: center; + gap: var(--s2); + margin-bottom: var(--s3); + flex-wrap: wrap; + } + .toolbar .grow { + flex: 1; + } + + .log { + font: 11px/1.7 var(--mono); + color: var(--muted); + margin: 0; + padding: 0; + list-style: none; + } + .log .st { + color: var(--fg); + } + .log .plus { + color: var(--ember); + } + + /* dash v2: sparklines, radar rings, timeline */ + .spark-svg { + display: block; + width: 100%; + height: 28px; + } + .trend { + display: grid; + grid-template-columns: 96px 1fr auto; + align-items: center; + gap: var(--s3); + padding: var(--s1) 0; + border-bottom: 1px solid var(--line); + } + .trend:last-child { + border-bottom: 0; + } + .trend .lbl { + font: 11px var(--mono); + color: var(--muted); + } + .trend .n { + font: 11px var(--mono); + color: var(--faint); + text-align: right; + white-space: nowrap; + } + + .rings { + display: flex; + flex-wrap: wrap; + gap: var(--s2); + margin-bottom: var(--s3); + } + .ringcount { + display: inline-flex; + align-items: center; + gap: var(--s1); + font: 11px var(--mono); + color: var(--muted); + background: var(--panel-2); + border: 1px solid var(--line); + border-radius: var(--r-s); + padding: var(--s1) var(--s2); + } + .dot { + width: 8px; + height: 8px; + border-radius: 50%; + display: inline-block; + } + .dot.adopt { + background: #46b17a; + } + .dot.trial { + background: #d9a441; + } + .dot.assess { + background: var(--muted); + } + .dot.hold { + background: var(--ember); + } + td.ring { + white-space: nowrap; + } + + .tl { + margin: 0; + padding: 0; + list-style: none; + font: 11px/1.6 var(--mono); + } + .tl li { + display: grid; + grid-template-columns: 44px 60px 1fr; + gap: var(--s2); + padding: var(--s1) 0; + border-bottom: 1px solid var(--line); + align-items: baseline; + } + .tl li:last-child { + border-bottom: 0; + } + .tl .day { + color: var(--faint); + } + .tl .ev { + color: var(--muted); + } + .tl .ev.retract { + color: var(--ember); + } + .tl .txt { + color: var(--fg); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + footer { + max-width: 1180px; + margin: 0 auto; + padding: 0 var(--s6) var(--s8); + font: 11px var(--mono); + color: var(--faint); + } + + + +
    + + forge dash + + +
    + +
    +
    +

    + Ledger — proof-carrying memory .forge/ledger/ +

    +
    +
    + + + + +
    +
    +

    Contested — val ∈ [0.40, 0.60] with ≥1 contradiction

    +
    +

    Author trust — earned from oracle outcomes, floored at 0.50

    +
    +
    + +
    +

    + Cost / Cache — measured, not asserted + .forge/metrics.jsonl +

    +
    +
    +
    +

    Recent events

    +
      +
      + +
      +

      + Impact — blast radius .forge/atlas.json +

      +
      + + +
      +
      + pick a symbol to see what a change would touch +
      +
      + +
      +

      + Radar — dependency currency forge radar +

      +
      +
      + no radar cache yet — run: forge radar +
      +
      + +
      +

      + Trends — stage history .forge/metrics.jsonl +

      +
      + no metrics yet — stages append as you work +
      +
      + +
      +

      + Memory browser — ranked recall + forge deja "<task>" +

      +
      + + + +
      +
      + type to search, or leave blank for the whole ledger by confidence +
      +
      + +
      +

      + Session timeline — durable memory events + .forge/ledger/ +

      +
        +
        +
        + +
        + lens on .forge/ — every number traces to + forge ledger blame <id>. the only writes + are ratify/retract (append-only; CLI twins: + forge ledger ratify|retract). +
        + + + diff --git a/src/dash.js b/src/dash.js index 22d2387..33385c7 100644 --- a/src/dash.js +++ b/src/dash.js @@ -13,7 +13,7 @@ import { basename, dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { impact, load as loadAtlas } from "./atlas.js"; import { estimateSpendFromLogs } from "./cost_report.js"; -import { authorTrust, claimText, val, validOutcome } from "./ledger.js"; +import { authorTrust, claimText, retrieve, val, validOutcome } from "./ledger.js"; import { getClaimByPrefix, loadClaims, @@ -23,7 +23,7 @@ import { tombstone, } from "./ledger_store.js"; import { read as readMetrics, summarize } from "./metrics.js"; -import { epochDay, gitAuthor } from "./util.js"; +import { clamp01, epochDay, gitAuthor, MS_PER_DAY } from "./util.js"; /** A claim is contested when its val sits in this band AND it carries ≥1 oracle * contradiction — genuinely disputed, not merely fresh (a fresh claim is 0.5 with @@ -35,7 +35,12 @@ const CLAIM_CAP = 200; const RECENT_CAP = 20; const emptyLedger = () => ({ - stats: { total: 0, tombstoned: 0, byKind: {}, val: { dormant: 0, uncertain: 0, trusted: 0 } }, + stats: { + total: 0, + tombstoned: 0, + byKind: {}, + val: { dormant: 0, uncertain: 0, trusted: 0 }, + }, claims: [], contested: [], trust: {}, @@ -68,7 +73,12 @@ function ledgerSection(root, nowDay) { return (c.evidence ?? []).some((e) => validOutcome(e) && e.result === "contradict"); }) .map((c) => claimRow(c, nowDay)); - return { stats: stats(dir, nowDay), claims, contested, trust: authorTrust(all) }; + return { + stats: stats(dir, nowDay), + claims, + contested, + trust: authorTrust(all), + }; } function metricsSection(root) { @@ -102,7 +112,12 @@ export function dashData(root, { nowDay = epochDay() } = {}) { let atlas = { built: false, symbols: 0, files: 0 }; try { const a = loadAtlas(root); - if (a) atlas = { built: true, symbols: a.symbols?.length ?? 0, files: a.files ?? 0 }; + if (a) + atlas = { + built: true, + symbols: a.symbols?.length ?? 0, + files: a.files ?? 0, + }; } catch {} let spend = null; try { @@ -147,6 +162,240 @@ export function dashSummary(root, { nowDay = epochDay() } = {}) { }; } +// --- dash v2: history / memory-browser / radar / timeline lenses ----------- +// Each is a pure, self-contained payload builder that NEVER throws — a corrupt or +// missing store degrades to an empty section, same discipline as dashData. + +/** Payload caps for the v2 lenses. */ +const HISTORY_CAP_DAYS = 90; +const CLAIM_BROWSE_BUDGET = 50; +const TIMELINE_CAP = 60; +const FRESH_HALF_LIFE_DAYS = 45; + +const metricDay = (e) => Math.floor((Number.isFinite(e?.t) ? e.t : 0) / MS_PER_DAY); + +/** + * Metrics history bucketed by day and by stage, capped to the last `capDays` days + * (I4 trend lens). The window anchors on the latest observed day (or nowDay), so a + * repo whose clock and nowDay disagree still yields its real trailing window. + * @param {string} root + * @param {{nowDay?: number, capDays?: number}} [opts] + */ +export function historyData(root, { nowDay = epochDay(), capDays = HISTORY_CAP_DAYS } = {}) { + let events = []; + try { + events = readMetrics(root); + } catch {} + const anchor = events.reduce((m, e) => Math.max(m, metricDay(e)), nowDay); + const floor = anchor - capDays; + const buckets = new Map(); + const stages = new Map(); + let totalEvents = 0; + let totalSaved = 0; + for (const e of events) { + const day = metricDay(e); + if (day <= floor) continue; + const saved = Number.isFinite(e.savedEstimate) ? e.savedEstimate : 0; + const outcome = e.outcome || ""; + const b = buckets.get(day) ?? { day, events: 0, saved: 0, byOutcome: {} }; + b.events++; + b.saved += saved; + if (outcome) b.byOutcome[outcome] = (b.byOutcome[outcome] ?? 0) + 1; + buckets.set(day, b); + const stage = e.stage || ""; + const s = stages.get(stage) ?? { + events: 0, + saved: 0, + byOutcome: {}, + series: new Map(), + }; + s.events++; + s.saved += saved; + if (outcome) s.byOutcome[outcome] = (s.byOutcome[outcome] ?? 0) + 1; + const sp = s.series.get(day) ?? { day, events: 0, saved: 0 }; + sp.events++; + sp.saved += saved; + s.series.set(day, sp); + stages.set(stage, s); + totalEvents++; + totalSaved += saved; + } + const bucketList = [...buckets.values()].sort((a, b) => a.day - b.day); + const stageObj = {}; + for (const [name, s] of [...stages.entries()].sort((a, b) => b[1].events - a[1].events)) + stageObj[name] = { + events: s.events, + saved: s.saved, + byOutcome: s.byOutcome, + series: [...s.series.values()].sort((a, b) => a.day - b.day), + }; + return { + window: capDays, + from: bucketList.length ? bucketList[0].day : null, + to: bucketList.length ? bucketList[bucketList.length - 1].day : null, + buckets: bucketList, + stages: stageObj, + totals: { events: totalEvents, saved: totalSaved }, + }; +} + +/** Pure recency factor (0..1) from a claim's mint day — a freshness bar to sit next + * to the val (confidence) bar in the memory browser. val already folds decay; this + * exposes age on its own so a stale-but-trusted claim reads honestly. */ +const freshness = (c, nowDay) => { + const t = c.provenance?.t; + if (!Number.isFinite(t)) return 0; + return clamp01(2 ** (-Math.max(0, nowDay - t) / FRESH_HALF_LIFE_DAYS)); +}; + +const claimBrowseRow = (c, nowDay, score) => ({ + ...claimRow(c, nowDay), + fresh: Number(freshness(c, nowDay).toFixed(3)), + score: score == null ? null : Number(score.toFixed(4)), +}); + +/** + * Memory browser: ranked claims for the given query (Eq.3 rel×rec×val via `retrieve`) + * or, with no query, the whole live ledger sorted by val. Optional kind filter. + * @param {string} root + * @param {{q?: string, kind?: string, nowDay?: number, budget?: number}} [opts] + */ +export function claimsData( + root, + { q = "", kind = "", nowDay = epochDay(), budget = CLAIM_BROWSE_BUDGET } = {}, +) { + let all = []; + try { + all = loadClaims(repoLedger(root)); + } catch {} + const query = String(q ?? "").trim(); + let ranked; + if (query) { + ranked = retrieve(query, all, { nowDay, budget: budget * 2 }); + } else { + ranked = all + .filter((c) => !c.tombstone) + .map((claim) => ({ claim, score: null })) + .sort( + (a, b) => val(b.claim, nowDay) - val(a.claim, nowDay) || (a.claim.id < b.claim.id ? -1 : 1), + ); + } + let rows = ranked.map(({ claim, score }) => claimBrowseRow(claim, nowDay, score)); + const wantKind = String(kind ?? "").trim(); + if (wantKind) rows = rows.filter((r) => r.kind === wantKind); + rows = rows.slice(0, budget); + return { + q: query, + kind: wantKind, + total: all.length, + count: rows.length, + rows, + }; +} + +const RADAR_RINGS = ["adopt", "trial", "assess", "hold"]; +const RADAR_ORDER = { hold: 0, assess: 1, trial: 2, adopt: 3 }; + +/** + * Radar rings read straight from the `.forge/radar.json` cache (hooks/dash never + * fetch — radar.js owns the network). Tolerant of shape drift; a missing or + * unparseable cache degrades to an empty, present:false panel (a clean 200). + * @param {string} root + */ +export function radarData(root) { + let raw; + try { + raw = JSON.parse(readFileSync(join(root, ".forge", "radar.json"), "utf8")); + } catch { + return { present: false, t: null, deps: [], counts: {} }; + } + const depsRaw = raw?.deps ?? {}; + const entries = Array.isArray(depsRaw) + ? depsRaw.map((d) => [d?.name, d]) + : Object.entries(depsRaw); + const counts = {}; + const deps = []; + for (const [name, d] of entries) { + if (!name || typeof d !== "object" || d == null) continue; + const ring = RADAR_RINGS.includes(d.ring) ? d.ring : "assess"; + const score = Number.isFinite(d.score) ? Number(d.score.toFixed(3)) : null; + counts[ring] = (counts[ring] ?? 0) + 1; + // radar.js writes `installed` (the pinned version) + `reasons` (evidence strings); + // tolerate the older `version`/`evidence` shape too so a stale cache still renders. + const version = + typeof d.installed === "string" + ? d.installed + : typeof d.version === "string" + ? d.version + : ""; + const reasons = Array.isArray(d.reasons) + ? d.reasons + : d.evidence && typeof d.evidence === "object" + ? d.evidence + : []; + deps.push({ + name: String(name), + ring, + score, + version, + latest: typeof d.latest === "string" ? d.latest : "", + reasons, + }); + } + deps.sort( + (a, b) => + RADAR_ORDER[a.ring] - RADAR_ORDER[b.ring] || + (b.score ?? 0) - (a.score ?? 0) || + (a.name < b.name ? -1 : 1), + ); + return { + present: true, + t: Number.isFinite(raw?.t) ? raw.t : null, + deps, + counts, + }; +} + +/** + * Session timeline drawn from the DURABLE ledger (mint + tombstone events), newest + * first — not the ephemeral session logs that clear between sessions. + * @param {string} root + * @param {{cap?: number}} [opts] + */ +export function timelineData(root, { cap = TIMELINE_CAP } = {}) { + let all = []; + try { + all = loadClaims(repoLedger(root)); + } catch {} + const events = []; + for (const c of all) { + const id8 = c.id.slice(0, 8); + const mintT = c.provenance?.t; + if (Number.isFinite(mintT)) + events.push({ + day: mintT, + type: "mint", + kind: c.kind, + id8, + author: c.provenance?.author ?? "", + text: claimText(c).slice(0, 120), + }); + if (c.tombstone && Number.isFinite(c.tombstone.t)) + events.push({ + day: c.tombstone.t, + type: "retract", + kind: c.kind, + id8, + author: c.tombstone.author ?? "", + text: c.tombstone.reason + ? String(c.tombstone.reason).slice(0, 120) + : claimText(c).slice(0, 120), + }); + } + events.sort((a, b) => b.day - a.day || (a.id8 < b.id8 ? -1 : 1)); + return { count: Math.min(events.length, cap), events: events.slice(0, cap) }; +} + const HTML_PATH = join(dirname(fileURLToPath(import.meta.url)), "dash.html"); const sendJson = (res, code, body) => { @@ -187,7 +436,9 @@ async function handleWrite(root, pathname, req, res) { } const id = typeof body?.id === "string" ? body.id.trim() : ""; if (id.length < 2) - return sendJson(res, 400, { error: 'body must be {"id": ""}' }); + return sendJson(res, 400, { + error: 'body must be {"id": ""}', + }); const dir = repoLedger(root); const author = gitAuthor(); const t = epochDay(); @@ -206,8 +457,10 @@ const WRITE_ROUTES = new Set(["/api/ratify", "/api/retract"]); /** * The dashboard server: GET / → the page, GET /api/data → dashData, GET - * /api/impact?target=X → blast radius (when an atlas exists), POST /api/ratify and - * POST /api/retract → the spec's two append-only writes. Everything else 404. + * /api/history → metrics trends, GET /api/claims?q=&kind= → memory browser, GET + * /api/radar → dependency rings (cache-only), GET /api/timeline → durable event + * stream, GET /api/impact?target=X → blast radius (when an atlas exists), POST + * /api/ratify and POST /api/retract → the spec's two append-only writes. Else 404. * Localhost-only by default — pass a host explicitly to expose it, on your own head. * @param {string} root * @param {{port?: number, host?: string}} [opts] @@ -235,18 +488,36 @@ export function serve(root, { port = 4242, host = "127.0.0.1" } = {}) { return res.end(html); } if (url.pathname === "/api/data") return sendJson(res, 200, dashData(root)); + if (url.pathname === "/api/history") return sendJson(res, 200, historyData(root)); + if (url.pathname === "/api/radar") return sendJson(res, 200, radarData(root)); + if (url.pathname === "/api/timeline") return sendJson(res, 200, timelineData(root)); + if (url.pathname === "/api/claims") + return sendJson( + res, + 200, + claimsData(root, { + q: url.searchParams.get("q") ?? "", + kind: url.searchParams.get("kind") ?? "", + }), + ); if (url.pathname === "/api/spend") { const spend = estimateSpendFromLogs(); return sendJson(res, 200, spend || { totalCost: 0, sessions: 0, byModel: [] }); } if (url.pathname === "/api/impact") { const target = url.searchParams.get("target"); - if (!target) return sendJson(res, 400, { error: "usage: /api/impact?target=" }); + if (!target) + return sendJson(res, 400, { + error: "usage: /api/impact?target=", + }); let atlas = null; try { atlas = loadAtlas(root); } catch {} - if (!atlas) return sendJson(res, 404, { error: "no atlas — run `forge atlas build` first" }); + if (!atlas) + return sendJson(res, 404, { + error: "no atlas — run `forge atlas build` first", + }); return sendJson(res, 200, impact(atlas, target)); } return sendJson(res, 404, { error: "not found" }); diff --git a/src/deja.js b/src/deja.js new file mode 100644 index 0000000..3bccd3e --- /dev/null +++ b/src/deja.js @@ -0,0 +1,177 @@ +// forge deja — anti-repetition memory. The user's "why do I keep re-doing solved +// tasks across sessions" root cause: a session's solved-task trace is thrown away at +// clearSession unless a MISTAKE fired (cortex only mints on correction episodes). So a +// clean, first-try success leaves no durable memory — the next session starts blind. +// +// Two halves, both reuse the shipped PCM machinery (no new protocol, no new kind): +// 1. recordSessionSummary — at Stop, mint ONE `summary` claim (existing KIND) whose +// body is a deterministic, secret-redacted gist of the session (first prompt + +// files touched). If the session's own tests passed, attach ONE `test.run` confirm +// outcome — so `val` (ledger.js) separates solved-AND-verified from merely-attempted. +// 2. dejaLookup — rank prior summary/lesson/diagnosis claims for a new task with the +// SAME Eq.3 retrieve() the ledger query uses (rel × rec × val). A hit above the +// noise floor surfaces as a one-line preflight advisory. With `forge ledger sync` +// the summaries travel between machines, so anti-repetition works across surfaces. +// +// Kill switch FORGE_DEJA=0 (read here and in the cortex hook). Best-effort everywhere: +// a failure to summarize or look up must never break a Stop hook or a CLI command. +import { BRAND } from "./brand.js"; +import { claimText, mintClaim, outcomeRecord, retrieve, val } from "./ledger.js"; +import { appendEvidence, loadClaims, putClaim, reindex, repoLedger } from "./ledger_store.js"; +import { redactSecrets } from "./secrets.js"; +import { epochDay, gitAuthor } from "./util.js"; + +/** Durable, task-shaped claim kinds worth checking for a repeat (summaries of solved + * work, corrected-behavior lessons, doom-loop diagnoses). Ephemeral kinds (facts, + * edges, fingerprints) are not "have I done this task before" memory. */ +export const DEJA_KINDS = ["summary", "lesson", "diagnosis"]; + +/** Retrieval score below which a hit is noise, not a déjà vu — calibrated in tests. + * The advisory stays silent under this floor so an unrelated task says nothing. */ +export const DEJA_FLOOR = 0.55; + +// Same test-command grammar cortex_hook.js keys its S1 signal on — a passing run here +// is exactly what "this session's work was verified" means. Kept local (one small +// regex) so deja is a self-contained leaf module. +const TEST_RE = /\b(npm\s+(run\s+)?test|node\s+--test|jest|vitest|pytest|go\s+test|cargo\s+test)\b/; + +/** + * Distill a session's normalized event log into a deterministic summary body, or null + * when there is nothing worth remembering (no prompt and no edits). The gist is the + * first user prompt, secret-redacted (redactSecrets — one truth, two verbs) and + * whitespace-collapsed; files are the sorted unique edit targets. `tested` reports + * whether a test command exited 0 this session (drives the confirm outcome, NOT the body + * — verification must be evidence, never a self-asserted flag). + * @param {{type?:string, text?:string, file?:string, command?:string, exitCode?:number}[]} events + * @returns {{text:string, files:string[], tested:boolean}|null} + */ +export function buildSummary(events = []) { + const files = [ + ...new Set(events.filter((e) => e.type === "edit" && e.file).map((e) => e.file)), + ].sort(); + const first = events.find( + (e) => e.type === "prompt" && typeof e.text === "string" && e.text.trim(), + ); + const gist = first ? redactSecrets(first.text).replace(/\s+/g, " ").trim().slice(0, 280) : ""; + if (!gist && !files.length) return null; + const text = gist || `touched ${files.join(", ")}`; + const tested = events.some( + (e) => e.type === "bash" && e.exitCode === 0 && TEST_RE.test(e.command || ""), + ); + return { text, files, tested }; +} + +/** + * Mint one `summary` claim for a finished session and persist it into the repo ledger. + * Best-effort: returns {ok:false, reason} instead of throwing, so a Stop hook is never + * broken by a summarization failure. Two sessions with the identical (gist, files) + * converge on ONE content-addressed claim — repeated identical work consolidates its + * evidence rather than duplicating. A passing test run attaches a single `test.run` + * confirm outcome (ref = the session id) so retrieval can rank verified work above + * merely-attempted work. + * @param {string} root repo root + * @param {string} sid session id (used as the evidence ref) + * @param {object[]} events the session's normalized events + * @param {number} [nowDay] mint day (epoch days) + * @returns {{ok:boolean, reason?:string, id?:string, tested?:boolean}} + */ +export function recordSessionSummary(root, sid, events, nowDay = epochDay()) { + try { + const s = buildSummary(events); + if (!s) return { ok: false, reason: "nothing to summarize" }; + const dir = repoLedger(root); + const minted = mintClaim({ + kind: "summary", + body: { files: s.files, text: s.text }, + scope: { level: "repo" }, + provenance: { agent: "deja", author: gitAuthor() }, + t: nowDay, + }); + if (!minted.ok) + return { + ok: false, + reason: "reason" in minted ? minted.reason : "mint failed", + }; + const put = putClaim(dir, minted.claim); + if (!put.ok) return put; + if (s.tested) { + const o = outcomeRecord({ + oracle: "test.run", + result: "confirm", + ref: `session:${sid}`, + author: gitAuthor(), + t: nowDay, + }); + if (o.ok) appendEvidence(dir, minted.claim.id, o.outcome); + } + reindex(dir, nowDay); + return { ok: true, id: minted.claim.id, tested: s.tested }; + } catch (err) { + if (process.env.FORGE_DEBUG === "1") + process.stderr.write(`forge deja: ${err?.message ?? err}\n`); + return { ok: false, reason: String(err?.message ?? err) }; + } +} + +/** + * Rank prior task-shaped claims for a new task, via the SAME Eq.3 retrieve() the ledger + * query uses (relevance × recency × validity). Pure over the supplied claim set — no fs, + * unit-testable with in-memory claims. + * @param {any[]} claims live claims (loadClaims output) + * @param {string} task the task about to be started + * @param {{nowDay?:number, budget?:number}} [opts] + * @returns {{claim:any, score:number}[]} + */ +export function dejaLookup(claims, task, { nowDay = 0, budget = 5 } = {}) { + const kinds = new Set(DEJA_KINDS); + const pool = (claims ?? []).filter((c) => kinds.has(c.kind)); + return retrieve(String(task ?? ""), pool, { nowDay, budget }); +} + +/** Load the repo ledger and rank it for a task (the fs-backed wrapper around + * dejaLookup). Best-effort: an unreadable ledger yields no hits, never a throw. */ +export function dejaFromLedger(root, task, { nowDay = epochDay(), budget = 5 } = {}) { + try { + return dejaLookup(loadClaims(repoLedger(root)), task, { nowDay, budget }); + } catch { + return []; + } +} + +/** + * The one-line advisory for the top hit, or "" when it is below the noise floor (so an + * unrelated task stays silent). "verified" appears only when the claim carries a + * confirming oracle outcome (val > 0.5 — a fresh, evidence-free summary sits exactly at + * the 0.5 prior). + * @param {{claim:any, score:number}} [top] the highest-ranked hit + * @param {number} [nowDay] + * @returns {string} + */ +export function dejaLine(top, nowDay = 0) { + if (!top || top.score < DEJA_FLOOR) return ""; + const { claim } = top; + const verified = val(claim, nowDay) > 0.5; + const day = claim.provenance?.t ?? 0; + const gist = claimText(claim).replace(/\s+/g, " ").trim().slice(0, 120); + return `${BRAND.brand} déjà vu — similar task seen day ${day}${verified ? " (verified)" : ""}: ${gist}`; +} + +/** + * Full best-effort advisory for a task: kill-switch check (FORGE_DEJA=0), load, rank, + * format. Returns "" for a disabled switch, an empty query, no hits, or any failure — + * safe to call from a hook or a preflight path. + * @param {string} root + * @param {string} task + * @param {number} [nowDay] + * @returns {string} + */ +export function dejaAdvisory(root, task, nowDay = epochDay()) { + if (process.env.FORGE_DEJA === "0") return ""; + if (!task || !String(task).trim()) return ""; + try { + const hits = dejaFromLedger(root, task, { nowDay, budget: 3 }); + return dejaLine(hits[0], nowDay); + } catch { + return ""; + } +} diff --git a/src/harden.js b/src/harden.js index ed0b197..e2931fe 100644 --- a/src/harden.js +++ b/src/harden.js @@ -1,10 +1,15 @@ // forge harden — WIRE the mature security controls Anthropic/the community already // own: enable the Claude Code sandbox (84% fewer prompts, per Anthropic) and install -// a Gitleaks pre-commit hook. We never auto-edit ~/.claude/settings.json (no clobber) -// — we write the sandbox block for the user to merge, and only touch the repo's own hooks. +// the commit-level gate rung as a pre-commit hook: gitleaks first when it's installed, +// then the built-in commit gate (` precommit` — completeness F1 + secret-scan +// fallback, src/commit_gate.js). We never auto-edit ~/.claude/settings.json (no +// clobber) — we write the sandbox block for the user to merge — and the same no-clobber +// rule protects a user-authored pre-commit hook: ours lands beside it as +// `pre-commit.` instead of overwriting it. -import { chmodSync, existsSync, mkdirSync, writeFileSync } from "node:fs"; +import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; import { join } from "node:path"; +import { BRAND } from "./brand.js"; import { hasBin as have } from "./util.js"; // The sandbox config to merge into settings — deny the credential dirs an agent should never read. @@ -13,27 +18,64 @@ const SANDBOX = { credentials: { deny: ["~/.aws", "~/.ssh", "GITHUB_TOKEN", "NPM_TOKEN"] }, }; +// The ownership marker: a hook containing this line is OURS to rewrite; anything else +// is user-authored and never clobbered. +const MARKER = `installed by ${BRAND.cli} harden`; + +/** The pre-commit script: gitleaks when present (deep scan), then the built-in commit + * gate. Fail-open by construction — a missing node or a moved package skips the gate + * instead of bricking every commit on this machine. */ +export function preCommitScript() { + const cli = join(BRAND.root, "src", "cli.js"); + return [ + "#!/usr/bin/env bash", + `# ${MARKER}`, + `# commit rung of the gate lattice: gitleaks (if installed) + \`${BRAND.cli} precommit\``, + "if command -v gitleaks >/dev/null 2>&1; then", + " gitleaks protect --staged --redact -v || exit 1", + "fi", + "command -v node >/dev/null 2>&1 || exit 0", + `[ -f "${cli}" ] || exit 0`, + `exec node "${cli}" precommit`, + "", + ].join("\n"); +} + export function harden({ targetRoot = process.cwd() } = {}) { const report = {}; - // Gitleaks pre-commit (WIRE) — only in a git repo, only if gitleaks is installed. + // Pre-commit gate (WIRE) — only in a git repo. gitleaks is now optional: when absent + // the hook still runs the built-in secret scan + completeness classifier. if (!existsSync(join(targetRoot, ".git"))) { report.gitleaks = "not a git repo — skipped"; - } else if (!have("gitleaks")) { - report.gitleaks = "gitleaks not installed — `brew install gitleaks` then re-run"; + report.precommit = "not a git repo — skipped"; } else { + report.gitleaks = have("gitleaks") + ? "installed — the pre-commit hook runs it first" + : "not installed — hook falls back to the built-in secret scan (`brew install gitleaks` for deeper coverage)"; const hooks = join(targetRoot, ".git", "hooks"); mkdirSync(hooks, { recursive: true }); - writeFileSync( - join(hooks, "pre-commit"), - "#!/usr/bin/env bash\n# installed by forge harden\nexec gitleaks protect --staged --redact -v\n", - ); + const hookPath = join(hooks, "pre-commit"); + let existing = null; + try { + existing = readFileSync(hookPath, "utf8"); + } catch {} + // No-clobber: a user-authored hook (no marker) is never overwritten — write ours + // beside it for the user to merge or exec. + const target = + existing !== null && !existing.includes(MARKER) + ? join(hooks, `pre-commit.${BRAND.cli}`) + : hookPath; + writeFileSync(target, preCommitScript()); try { - chmodSync(join(hooks, "pre-commit"), 0o755); + chmodSync(target, 0o755); } catch { /* best effort */ } - report.gitleaks = "installed pre-commit"; + report.precommit = + target === hookPath + ? `installed pre-commit (gitleaks-if-present + \`${BRAND.cli} precommit\`)` + : `existing pre-commit kept — wrote pre-commit.${BRAND.cli} beside it (merge or exec it from your hook)`; } // Sandbox settings (WIRE — Anthropic owns the sandbox; we write the block to merge). diff --git a/src/knowledge_router.js b/src/knowledge_router.js new file mode 100644 index 0000000..f8c458d --- /dev/null +++ b/src/knowledge_router.js @@ -0,0 +1,353 @@ +// forge knowledge-router — A7 of the TASK loop: route(fact) → storage home, TOTAL by +// construction (formal-synthesis Theorem T6). The discipline already existed as prose +// ("decisions go in decisions.md, facts in the ledger…") but prose routing is exactly +// what sessions forget — so knowledge landed nowhere and was re-learned. This module is +// the third routing leg beside route.js (model tiers) and intent.js (intent classes): +// the SAME exemplar k-NN math (labeled rows + overlap coefficient + confidence gate), +// tuned by adding example rows, never by editing regexes. Totality is the one place the +// shape differs from classifyIntent: a fact resembling nothing in the bank falls back to +// the ledger (`ledger-fact`, provenance:"fallback") — the ledger is the home whose decay +// semantics make an unsure placement safe — it is NEVER "none". +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { appendDecision } from "./decide.js"; +import { intentGrams } from "./intent.js"; +import { shadowFact } from "./ledger_bridge.js"; +import { repoLedger } from "./ledger_store.js"; +import { setOverlap } from "./math.js"; +import { defaultStore, add as recallAdd, reindex } from "./recall.js"; +import { hasSecret } from "./secrets.js"; +import { slug } from "./util.js"; + +/** + * The storage homes — DATA, one row per home. `write` is the dispatch mode: + * "auto" homes have an append-only store this module may write directly; + * "advise" homes are curated FILES (rewritten wholesale by a human or another + * command), so routing there returns advice, never a blind write. + * @type {Record} + */ +export const HOMES = { + "claude-md": { + write: "advise", + where: "CLAUDE.md / AGENTS.md", + advice: `project-wide instruction — add it to the canonical source (AGENTS.md, or source/ then \`${BRAND.cli} sync\`) so every agent loads it`, + }, + rule: { + write: "advise", + where: "rules / guards", + advice: `a policy is enforced, not remembered — encode it as a rule or guard (global/rules/, a PreToolUse guard) rather than prose`, + }, + skill: { + write: "advise", + where: "a skill / runbook", + advice: `a procedure belongs in replayable steps — capture it as a skill or runbook document, not a one-line memory`, + }, + state: { + write: "advise", + where: ".forge/state.md", + advice: `session state — record it with \`${BRAND.cli} handoff\` so the next session resumes instead of re-assuming`, + }, + decision: { + write: "auto", + where: ".forge/decisions.md + ledger", + advice: `a settled choice — \`${BRAND.cli} decide ""\` appends the ADR-lite line`, + }, + "ledger-fact": { + write: "auto", + where: ".forge/ledger", + advice: `a verifiable repo fact — \`${BRAND.cli} remember "" ""\` shadows it into the ledger`, + }, + recall: { + write: "auto", + where: "~/.forge/recall", + advice: `a personal preference — \`${BRAND.cli} recall add "" ""\` keeps it across repos`, + }, +}; + +/** The totality fallback (T6): unsure placements land here, never nowhere. */ +export const FALLBACK_HOME = "ledger-fact"; + +/** Labeled home bank — DATA, not decision code. Extend coverage by adding rows. */ +export const HOME_EXEMPLARS = [ + // claude-md — durable project-wide conventions every agent must follow + { + text: "always run the full test suite before committing", + home: "claude-md", + }, + { text: "this repo uses tabs not spaces for indentation", home: "claude-md" }, + { text: "all api handlers must validate their input", home: "claude-md" }, + { text: "never use default exports in this codebase", home: "claude-md" }, + { text: "commit messages follow conventional commits", home: "claude-md" }, + { text: "use the shared logger instead of console log", home: "claude-md" }, + { + text: "docs must move in the same pull request as the code", + home: "claude-md", + }, + { + text: "prefer composition over inheritance in the services layer", + home: "claude-md", + }, + { + text: "every module gets a header comment explaining why it exists", + home: "claude-md", + }, + { text: "imports are esm only never require", home: "claude-md" }, + // rule — enforced policy: block / never / deny (a guard, not a memory) + { text: "never commit directly to the main branch", home: "rule" }, + { text: "block any edit to the generated dist folder", home: "rule" }, + { text: "deny network calls from unit tests", home: "rule" }, + { text: "reject pull requests without a changelog entry", home: "rule" }, + { text: "do not delete migration files ever", home: "rule" }, + { text: "guard against force pushes to release branches", home: "rule" }, + { text: "never log user emails in production", home: "rule" }, + { text: "block writes outside the project directory", home: "rule" }, + { text: "credentials must never be committed to the repo", home: "rule" }, + { text: "forbid new runtime dependencies without an adr", home: "rule" }, + // skill — a reusable multi-step procedure / runbook + { + text: "how to cut a release bump version update changelog tag publish", + home: "skill", + }, + { text: "steps to add a new database migration", home: "skill" }, + { text: "the procedure for rotating the api keys", home: "skill" }, + { text: "how to regenerate the protobuf bindings", home: "skill" }, + { + text: "runbook for restoring the staging database from backup", + home: "skill", + }, + { + text: "how to profile a slow endpoint with the flame graph tooling", + home: "skill", + }, + { text: "checklist for onboarding a new microservice", home: "skill" }, + { text: "workflow to reproduce the flaky test locally", home: "skill" }, + { text: "how to run the app against a local postgres", home: "skill" }, + { text: "steps to debug a failing ci pipeline", home: "skill" }, + // state — in-flight session progress the NEXT session resumes from + { + text: "halfway through migrating the auth module tests still failing", + home: "state", + }, + { + text: "next step is to wire pagination into the export endpoint", + home: "state", + }, + { + text: "currently refactoring the payment service two files left", + home: "state", + }, + { + text: "paused while waiting for the api schema from the backend team", + home: "state", + }, + { + text: "todo tomorrow finish the error handling in the upload path", + home: "state", + }, + { + text: "in progress renaming the user manager across the callers", + home: "state", + }, + { + text: "the branch has uncommitted changes to the cache layer", + home: "state", + }, + { text: "still need to update the docs for the new flag", home: "state" }, + { + text: "resume from the failing integration test in checkout", + home: "state", + }, + { + text: "work remaining hook the new validator into the form", + home: "state", + }, + // decision — "we chose X over Y because Z" (settled, with a why) + { text: "we chose sqlite over postgres because zero ops", home: "decision" }, + { text: "picked vitest instead of jest for startup speed", home: "decision" }, + { + text: "decided to keep the monorepo rather than splitting packages", + home: "decision", + }, + { + text: "went with server side rendering because seo matters here", + home: "decision", + }, + { + text: "we rejected graphql because the api surface is small", + home: "decision", + }, + { + text: "chose to vendor the parser instead of adding a dependency", + home: "decision", + }, + { text: "agreed to drop support for the legacy browser", home: "decision" }, + { + text: "we opted for feature flags over long lived branches", + home: "decision", + }, + { + text: "settled on a queue in redis because it is already deployed", + home: "decision", + }, + { + text: "decision use jwt sessions because the gateway validates them", + home: "decision", + }, + // ledger-fact — a verifiable fact about THIS repo/system (also the T6 fallback) + { + text: "the api rate limit is 100 requests per minute", + home: "ledger-fact", + }, + { + text: "postgres runs on port 5433 in this environment", + home: "ledger-fact", + }, + { + text: "the payments service times out after 30 seconds", + home: "ledger-fact", + }, + { + text: "the ci pipeline takes about twelve minutes end to end", + home: "ledger-fact", + }, + { text: "the default branch is called trunk", home: "ledger-fact" }, + { text: "user ids are uuids not integers", home: "ledger-fact" }, + { text: "the mobile app pins the tls certificate", home: "ledger-fact" }, + { + text: "the staging bucket is named acme staging assets", + home: "ledger-fact", + }, + { text: "sessions expire after 24 hours", home: "ledger-fact" }, + { text: "the csv exporter caps at 50000 rows", home: "ledger-fact" }, + // recall — personal, cross-repo preference (first-person signal) + { text: "i prefer short commit messages", home: "recall" }, + { text: "my editor is neovim with lsp enabled", home: "recall" }, + { text: "i like seeing the diff before any commit", home: "recall" }, + { text: "my timezone is ist so schedule builds accordingly", home: "recall" }, + { text: "i want explanations in hindi when possible", home: "recall" }, + { text: "my default shell is fish", home: "recall" }, + { text: "i always want a summary at the end of a session", home: "recall" }, + { text: "call me by my first name in replies", home: "recall" }, + { text: "i prefer yarn over npm on my machine", home: "recall" }, + { text: "my laptop has 16gb ram so keep builds light", home: "recall" }, +]; + +// intentGrams, not a new tokenizer: task verbs and function-word stripping behave the +// same for "what kind of knowledge is this" as for "what kind of work is this". +const EXEMPLAR_GRAMS = HOME_EXEMPLARS.map((e) => ({ + ...e, + grams: intentGrams(e.text), +})); + +/** + * k-NN over the home bank: similarity-weighted vote among the top-k neighbors, gated on + * the best similarity. TOTAL (T6): below the gate — or an unparseable text — the answer + * is the FALLBACK_HOME with provenance "fallback", never "none". Neighbors ride along so + * every routing is attributable to its evidence (mizan: the confidence IS the evidence). + * @param {string} text + * @param {{k?:number, minConf?:number}} [opts] + * @returns {{home:string, confidence:number, provenance:"knn"|"fallback", write:"auto"|"advise", neighbors:{text:string, home:string, sim:number}[]}} + */ +export function routeFact(text, { k = 3, minConf = 0.25 } = {}) { + const grams = intentGrams(text); + const sims = !grams.size + ? [] + : EXEMPLAR_GRAMS.map((e) => ({ + text: e.text, + home: e.home, + sim: setOverlap(grams, e.grams), + })) + .sort((a, b) => b.sim - a.sim) + .slice(0, k); + const top = sims[0]; + if (!top || top.sim < minConf) { + return { + home: FALLBACK_HOME, + confidence: Number((top?.sim ?? 0).toFixed(3)), + provenance: "fallback", + write: HOMES[FALLBACK_HOME].write, + neighbors: sims, + }; + } + const votes = new Map(); + for (const s of sims) votes.set(s.home, (votes.get(s.home) ?? 0) + s.sim); + const [home] = [...votes.entries()].sort((a, b) => b[1] - a[1])[0]; + return { + home, + confidence: Number(top.sim.toFixed(3)), + provenance: "knn", + write: HOMES[home].write, + neighbors: sims, + }; +} + +/** A short, stable fact NAME from its text (ledger/recall stores key facts by name). */ +export function factName(text) { + const s = slug(text).split("-").slice(0, 6).join("-"); + return s || "fact"; +} + +/** + * Store a routed fact in its home. Refuses secrets (same rule as every store, checked + * HERE so no dispatch target can be reached with one). "advise"-mode homes — and every + * home when `mode:"advise"` (the CLI's --dry-run) — return advice and write NOTHING. + * Dispatch reuses the existing stores verbatim: appendDecision, shadowFact into the + * repo ledger, recall add + personal-ledger shadow (the exact `recall add` CLI path). + * Never throws — a routing must never break its caller. + * @param {string} root repo root + * @param {string} text the fact + * @param {{mode?:"auto"|"advise", route?:ReturnType}} [opts] + * @returns {{ok:boolean, home:string, stored:boolean, reason?:string, refused?:boolean, ref?:string, advice?:string}} + */ +export function storeFact(root, text, { mode = "auto", route } = {}) { + const body = String(text ?? "") + .trim() + .replace(/\s+/g, " "); + const r = route ?? routeFact(body); + if (!body) return { ok: false, home: r.home, stored: false, reason: "empty fact" }; + if (hasSecret(body)) { + return { + ok: false, + home: r.home, + stored: false, + refused: true, + reason: "refused: looks like a secret/credential — store a pointer, not the value", + }; + } + const advice = HOMES[r.home]?.advice ?? HOMES[FALLBACK_HOME].advice; + if (mode === "advise" || r.write === "advise") + return { ok: true, home: r.home, stored: false, advice }; + try { + if (r.home === "decision") { + const d = appendDecision(root, body); + return d.ok + ? { ok: true, home: r.home, stored: true, ref: d.id } + : { ok: false, home: r.home, stored: false, reason: d.reason }; + } + if (r.home === "recall") { + const store = defaultStore(); + const res = recallAdd(store, factName(body), body); + if (!res.ok) return { ok: false, home: r.home, stored: false, reason: res.reason }; + // Shadow into the PERSONAL ledger beside the global store, then re-index — the + // same best-effort pair `forge recall add` performs (repo promotion stays explicit). + try { + shadowFact(join(store, "ledger"), factName(body), body); + reindex(store); + } catch {} + return { ok: true, home: r.home, stored: true, ref: res.slug }; + } + // ledger-fact (including every T6 fallback): a claim in the repo ledger, where + // decay semantics make an unsure placement safe (unverified → fades to unsure). + const s = shadowFact(repoLedger(root), factName(body), body); + return s.ok + ? { ok: true, home: "ledger-fact", stored: true, ref: s.id } + : { ok: false, home: "ledger-fact", stored: false, reason: s.reason }; + } catch (err) { + return { + ok: false, + home: r.home, + stored: false, + reason: String(err?.message ?? err), + }; + } +} diff --git a/src/ledger_sync.js b/src/ledger_sync.js new file mode 100644 index 0000000..96b435a --- /dev/null +++ b/src/ledger_sync.js @@ -0,0 +1,365 @@ +// forge ledger sync — cross-machine convergence for the PCM ledger. The ledger is +// already a state-based CRDT (content-addressed claims + hash-deduped append-only +// logs, joined by the semilattice `mergeStates`); sync is just a transport that moves +// that state between replicas and re-runs the join. Two transports: +// +// • dir mode — a shared directory (USB, NFS, a synced folder): bidirectional +// `mergeDirs`, the same union-merge `forge ledger merge` already does, both ways. +// • ref mode — a git remote: the state is serialized to a canonical `state.json` +// blob under a dedicated ref (refs//ledger) via git plumbing +// (hash-object → mktree → commit-tree → update-ref → push). Pull = fetch the ref, +// read the blob, `importState`. A non-fast-forward push (a teammate raced us) is +// not a conflict: re-fetch, re-import (monotone — nothing is ever lost), rebuild +// on the new parent, retry. +// +// FAIL-OPEN like update.js/gate.js: no target, offline, a corrupt remote blob, or a +// lost race all return a result object with `ok:false` and an honest reason — nothing +// here throws. The git runner is injectable (`run`) so tests drive it with local +// bare remotes and never touch the network. +import { execFileSync } from "node:child_process"; +import { existsSync } from "node:fs"; +import { BRAND } from "./brand.js"; +import { canonicalize } from "./ledger.js"; +import { importState, loadState, mergeDirs } from "./ledger_store.js"; +import { gitAuthor } from "./util.js"; + +const STATE_FILE = "state.json"; + +// Contract note: syncTarget takes its environment via the injectable `env` param +// (defaulting to process.env) and reads process.env.FORGE_SYNC_DIR from it — named +// literally here so docs_check.envVarsRead collects FORGE_SYNC_DIR into the documented +// env contract (README + docs/GUIDE.md) even though the read goes through `env`. + +/** + * @typedef {object} SyncResult + * @property {boolean} ok + * @property {string} mode "dir" | "ref" | "none" + * @property {string} [reason] + * @property {string} [dir] + * @property {string} [remote] + * @property {string} [ref] + * @property {*} [pulled] import counts {claims, records} + * @property {*} [pushed] dir mode: {claims, records}; ref mode: boolean + * @property {boolean} [upToDate] + * @property {number} [retries] + * @property {string[]} [notes] + */ + +/** The default git ref the ledger state rides on. Brand-tokenized (equals + * `refs/forge/ledger` today) so a rebrand never strands a hardcoded ref, and the + * `--personal` ledger gets a sibling ref so personal facts can never land on the + * repo's shared ledger by accident. */ +export const defaultRef = (personal = false) => + `refs/${BRAND.cli}/ledger${personal ? "-personal" : ""}`; + +/** + * The real git runner. stderr is PIPED (not ignored) so a non-fast-forward rejection + * is classifiable from the thrown error's `.stderr`. Throws on non-zero exit — callers + * that expect failure (ref probes, first-sync fetch) wrap in try/catch. + * @param {string[]} args + * @param {{cwd?: string, input?: string, env?: Record}} [opts] + * @returns {string} trimmed stdout + */ +export function defaultRun(args, { cwd = process.cwd(), input, env } = {}) { + return execFileSync("git", args, { + cwd, + encoding: "utf8", + input, + env: env ? { ...process.env, ...env } : process.env, + stdio: [input !== undefined ? "pipe" : "ignore", "pipe", "pipe"], + }).trim(); +} + +/** The canonical state.json payload for a ledger dir: deterministic bytes (sorted + * keys, NFC) so the same on-disk state hashes to the same blob on every replica — + * which is what makes the idempotence check (tree-SHA equality) exact. */ +export function stateBytes(dir) { + return `${canonicalize(loadState(dir))}\n`; +} + +/** Committer/author identity for the sync commit, parsed from a `Name ` + * string (gitAuthor()). Falls back to the brand so commit-tree never fails when no + * git identity is configured. */ +function commitEnv(author) { + const m = /^(.*?)\s*<(.*)>$/.exec(String(author || "")); + const name = (m ? m[1].trim() : String(author || "").trim()) || BRAND.brand; + const email = (m ? m[2].trim() : "") || `${BRAND.cli}@localhost`; + return { + GIT_AUTHOR_NAME: name, + GIT_AUTHOR_EMAIL: email, + GIT_COMMITTER_NAME: name, + GIT_COMMITTER_EMAIL: email, + }; +} + +/** + * Resolve the sync target by the spec's precedence: an explicit `--dir` wins; then + * `--remote`/`--ref` force ref mode; then a git remote on the repo (ref mode); then + * `FORGE_SYNC_DIR` (dir mode); else an honest "no target". Pure + injectable env/run + * so it is unit-testable without a repo. + * @param {{root?: string, personal?: boolean, dirTarget?: string, remote?: string, + * ref?: string, env?: Record, run?: typeof defaultRun}} [opts] + * @returns {{mode:"dir", dir:string} | {mode:"ref", remote:string, ref:string} | + * {mode:"none", reason:string}} + */ +export function syncTarget({ + root = process.cwd(), + personal = false, + dirTarget, + remote, + ref, + env = process.env, + run = defaultRun, +} = {}) { + if (dirTarget) return { mode: "dir", dir: dirTarget }; + if (remote || ref) + return { + mode: "ref", + remote: remote || "origin", + ref: ref || defaultRef(personal), + }; + let remotes = ""; + try { + remotes = run(["remote"], { cwd: root }); + } catch { + remotes = ""; + } + const first = remotes + .split(/\r?\n/) + .map((s) => s.trim()) + .filter(Boolean)[0]; + if (first) return { mode: "ref", remote: first, ref: defaultRef(personal) }; + if (env.FORGE_SYNC_DIR) return { mode: "dir", dir: env.FORGE_SYNC_DIR }; + return { + mode: "none", + reason: "no sync target: pass --dir , --remote/--ref, or set FORGE_SYNC_DIR", + }; +} + +/** + * Dir transport: bidirectional union-merge with a shared ledger directory. Pull = + * merge the other dir into ours, push = merge ours into the other — both idempotent + * and order-independent by the CRDT property, so both dirs converge to the union. + * @param {string} localDir + * @param {string} otherDir + * @returns {SyncResult} + */ +export function syncDir(localDir, otherDir) { + if (!existsSync(otherDir)) + return { + ok: false, + mode: "dir", + dir: otherDir, + reason: `no sync directory at ${otherDir}`, + }; + const pulled = mergeDirs(localDir, otherDir); + const pushed = mergeDirs(otherDir, localDir); + return { ok: true, mode: "dir", dir: otherDir, pulled, pushed }; +} + +/** Read the remote ref's state.json blob and import it into localDir. Returns import + * counts; a missing/corrupt blob degrades to {claims:0,records:0} + a note (never + * throws) so a garbled remote can never take the local ledger down. */ +function pullRef(localDir, root, ref, run, notes) { + try { + const raw = run(["cat-file", "blob", `${ref}:${STATE_FILE}`], { + cwd: root, + }); + const remoteState = JSON.parse(raw); + return importState(localDir, remoteState); + } catch { + notes.push("remote ledger state unreadable — treated as empty"); + return { claims: 0, records: 0 }; + } +} + +/** The local commit at `ref`, or null if the ref does not exist. `--verify --quiet` + * exits non-zero (→ throw → null) when the ref is absent. */ +function refCommit(root, ref, run) { + try { + return run(["rev-parse", "--verify", "--quiet", ref], { cwd: root }) || null; + } catch { + return null; + } +} + +/** + * Ref transport: push-pull the ledger through a git ref via plumbing. + * @param {string} localDir + * @param {{root:string, remote:string, ref:string, run?: typeof defaultRun, + * author?: string, maxRetries?: number}} opts + * @returns {SyncResult} + */ +export function syncRef( + localDir, + { root, remote, ref, run = defaultRun, author = gitAuthor(), maxRetries = 3 }, +) { + const notes = []; + const base = { mode: "ref", remote, ref }; + // Preflight — an honest miss beats an exception. + try { + if (run(["rev-parse", "--is-inside-work-tree"], { cwd: root }) !== "true") + return { ok: false, ...base, reason: "not a git repository" }; + } catch { + return { ok: false, ...base, reason: "not a git repository" }; + } + try { + run(["remote", "get-url", remote], { cwd: root }); + } catch { + return { ok: false, ...base, reason: `no such git remote: ${remote}` }; + } + + // PULL: fetch the remote ledger ref. A missing ref is a first sync, not an error; + // anything else (no network, auth) fails open with an honest reason. + let pulled = { claims: 0, records: 0 }; + try { + run(["fetch", remote, `+${ref}:${ref}`], { cwd: root }); + } catch (e) { + const msg = String(e?.stderr || e?.message || ""); + if (!/couldn't find remote ref|not our ref|no matching|does not exist/i.test(msg)) + return { + ok: false, + ...base, + reason: "fetch failed (offline or no access?)", + }; + } + if (refCommit(root, ref, run)) pulled = pullRef(localDir, root, ref, run, notes); + + // PUSH: serialize local state to a blob/tree; skip entirely when the remote tree + // already equals ours (idempotence — re-running sync is a byte-level no-op). A + // non-fast-forward rejection means a teammate raced us: re-fetch, re-import + // (monotone), rebuild on the new parent, retry up to maxRetries. + for (let retries = 0; ; ) { + const bytes = stateBytes(localDir); + let blob; + let tree; + try { + blob = run(["hash-object", "-w", "--stdin"], { cwd: root, input: bytes }); + tree = run(["mktree"], { + cwd: root, + input: `100644 blob ${blob}\t${STATE_FILE}\n`, + }); + } catch { + return { + ok: false, + ...base, + reason: "could not write ledger state object", + retries, + notes, + }; + } + const parent = refCommit(root, ref, run); + if (parent) { + let parentTree = null; + try { + parentTree = run(["rev-parse", `${ref}^{tree}`], { cwd: root }); + } catch {} + if (parentTree === tree) + return { + ok: true, + ...base, + pulled, + pushed: false, + upToDate: true, + retries, + notes, + }; + } + const commitArgs = ["commit-tree", tree, "-m", `${BRAND.cli} ledger sync`]; + if (parent) commitArgs.push("-p", parent); + let commit; + try { + commit = run(commitArgs, { cwd: root, env: commitEnv(author) }); + run(["update-ref", ref, commit], { cwd: root }); + } catch { + return { + ok: false, + ...base, + reason: "could not build sync commit", + retries, + notes, + }; + } + try { + run(["push", remote, `${ref}:${ref}`], { cwd: root }); + return { + ok: true, + ...base, + pulled, + pushed: true, + upToDate: false, + retries, + notes, + }; + } catch (e) { + const msg = String(e?.stderr || e?.message || ""); + const raced = /non-fast-forward|fetch first|rejected|stale info|cannot lock ref/i.test(msg); + if (!raced || retries >= maxRetries) + return { + ok: false, + ...base, + reason: raced ? "push kept losing the race (retries exhausted)" : "push failed", + retries, + notes, + }; + retries++; + try { + run(["fetch", remote, `+${ref}:${ref}`], { cwd: root }); + } catch { + return { + ok: false, + ...base, + reason: "re-fetch after race failed", + retries, + notes, + }; + } + const more = pullRef(localDir, root, ref, run, notes); + pulled = { + claims: pulled.claims + more.claims, + records: pulled.records + more.records, + }; + } + } +} + +/** + * Resolve the target and run the matching transport. Thin dispatcher; every path + * returns a result object and nothing throws (fail-open). + * @param {{dir?:string, root?:string, personal?:boolean, dirTarget?:string, + * remote?:string, ref?:string, env?:Record, + * run?: typeof defaultRun, author?:string, maxRetries?:number}} [opts] + * @returns {SyncResult} + */ +export function ledgerSync({ + dir, + root = process.cwd(), + personal = false, + dirTarget, + remote, + ref, + env = process.env, + run = defaultRun, + author, + maxRetries, +} = {}) { + const target = syncTarget({ + root, + personal, + dirTarget, + remote, + ref, + env, + run, + }); + if (target.mode === "none") return { ok: false, ...target }; + if (target.mode === "dir") return syncDir(dir, target.dir); + return syncRef(dir, { + root, + remote: target.remote, + ref: target.ref, + run, + author, + maxRetries, + }); +} diff --git a/src/radar.js b/src/radar.js new file mode 100644 index 0000000..75035ef --- /dev/null +++ b/src/radar.js @@ -0,0 +1,615 @@ +// forge radar — dependency-currency rings (I4 verified currency; paper crosswalk GAP 4). +// +// MIZAN (evidence-proportional judgment): every ring ships with the evidence that earned +// it, and MISSING evidence NEVER upgrades a dep. A dep with too little verified evidence +// lands in "assess" — never "adopt" on the strength of what we could not check. The rings +// are a FORMULA over registry evidence (no hardcoded package lists): staleness half-life, +// major-version lag, security advisories, deprecation. Usage (atlas import-sites) is STAKES, +// not risk — it only sorts and scales priority, never the risk score itself. +// +// Fail-safe by construction: nothing here throws to a caller. Network I/O is injectable +// (fetchImpl) so tests never touch the network, and the pre-edit advisory is CACHE-ONLY — +// a hook never fetches. The `dev-radar` skill is the LLM wide scan; `forge radar` is the +// deterministic repo instrument that reads THIS repo's manifests. +import { existsSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND } from "./brand.js"; +import { clamp01, epochDay } from "./util.js"; + +const read = (root, rel) => { + try { + return readFileSync(join(root, rel), "utf8"); + } catch { + return null; + } +}; +const readJson = (root, rel) => { + const t = read(root, rel); + if (t == null) return null; + try { + return JSON.parse(t); + } catch { + return null; + } +}; + +// A range forge cannot probe against a public registry: workspace protocol, a local path, +// a git/url dependency. Listed honestly as "local" (unprobed), never silently upgraded. +const LOCAL_RANGE_RE = /^(workspace:|file:|link:|git\+|git:|https?:|github:|[./])/; + +/** + * @typedef {{range:string, source:"dependencies"|"devDependencies", installed:(string|null), + * local:boolean}} ManifestDep + */ + +/** + * Read this repo's Node manifests into a currency-probe worklist. Node-first and honest: + * other ecosystems detected by the stack detector are returned in `skipped` rather than + * pretended-about. Fail-safe — an unreadable/corrupt manifest yields an empty result. + * @param {string} [root] + * @returns {{deps:Record, skipped:{language:string, reason:string}[]}} + */ +export function depsFromManifests(root = process.cwd()) { + /** @type {Record} */ + const deps = {}; + /** @type {{language:string, reason:string}[]} */ + const skipped = []; + const pkg = readJson(root, "package.json"); + if (pkg) { + const lock = readJson(root, "package-lock.json"); + const installedOf = installedFromLock(lock); + for (const source of /** @type {const} */ (["dependencies", "devDependencies"])) { + const map = pkg[source] || {}; + for (const name of Object.keys(map)) { + if (name in deps) continue; // a prod dep shadows a dev dep of the same name + const range = String(map[name] ?? ""); + deps[name] = { + range, + source, + installed: installedOf(name), + local: LOCAL_RANGE_RE.test(range), + }; + } + } + } + // Other ecosystems: named honestly as unprobed rather than silently ignored. + for (const language of otherLanguages(root)) + skipped.push({ language, reason: "currency probe is Node-first" }); + return { deps, skipped }; +} + +/** Languages present besides JS/TS (so the CLI can say "not probed" honestly). */ +function otherLanguages(root) { + const out = []; + const has = (rel) => existsSync(join(root, rel)); + if (has("go.mod")) out.push("Go"); + if (has("Cargo.toml")) out.push("Rust"); + if (has("pyproject.toml") || has("requirements.txt") || has("Pipfile") || has("setup.py")) + out.push("Python"); + if (has("Gemfile")) out.push("Ruby"); + if (has("composer.json")) out.push("PHP"); + if (has("pom.xml") || has("build.gradle") || has("build.gradle.kts")) out.push("Java/Kotlin"); + return out; +} + +/** Installed-version lookup from a package-lock (v2/v3 `packages`, then legacy `dependencies`). */ +function installedFromLock(lock) { + if (!lock || typeof lock !== "object") return () => null; + const packages = lock.packages && typeof lock.packages === "object" ? lock.packages : null; + const legacy = + lock.dependencies && typeof lock.dependencies === "object" ? lock.dependencies : null; + return (name) => { + if (packages) { + const v = packages[`node_modules/${name}`]?.version; + if (typeof v === "string" && v) return v; + } + if (legacy) { + const v = legacy[name]?.version; + if (typeof v === "string" && v) return v; + } + return null; + }; +} + +/** Weights (mizan): the data table. Every ring states which of these earned it. */ +export const RADAR_WEIGHTS = Object.freeze({ + staleness: 0.35, + majorLag: 0.3, + advisory: 0.9, + deprecated: 1.0, +}); +export const STALENESS_HALF_LIFE_DAYS = 540; +export const ADVISORY_SEVERITY = Object.freeze({ + critical: 1, + high: 0.7, + moderate: 0.4, + low: 0.2, +}); +export const RING_THRESHOLDS = Object.freeze({ adopt: 0.25, trial: 0.5 }); +export const MIN_EVIDENCE_FOR_ADOPT = 2; + +const MS_PER_DAY = 86400000; +/** Leading integer of a semver-ish string ("^5.2.0" → 5), or null. */ +function majorOf(v) { + const m = /(\d+)/.exec(String(v ?? "")); + return m ? Number(m[1]) : null; +} + +/** + * @typedef {{installed:(string|null), latest:(string|null), publishedAt:(number|null), + * deprecated:(boolean|null), advisories:(Array<{severity?:string,title?:string,url?:string}>|null)}} DepEvidence + */ + +/** + * Classify one dependency into a ring from its evidence — a pure formula, no package lists. + * Absent evidence is never scored as zero risk (it is simply not averaged in), so a dep we + * could not verify degrades to "assess", never "adopt". Hard gates (deprecated / critical + * advisory) win over the score. Every returned ring carries its calibration `reasons`. + * @param {DepEvidence} evidence + * @param {number} nowDay epoch-day of the scan + * @returns {{ring:"adopt"|"trial"|"assess"|"hold", score:number, + * signals:Record, evidenceKinds:number, reasons:string[], hasAdvisory:boolean}} + */ +export function classifyDep(evidence, nowDay = epochDay()) { + const ev = /** @type {DepEvidence} */ (evidence || {}); + /** @type {Record} */ + const signals = {}; + const reasons = []; + let critical = false; + + // staleness — present iff we know when latest was published. + if (typeof ev.publishedAt === "number" && Number.isFinite(ev.publishedAt)) { + const days = Math.max(0, (nowDay * MS_PER_DAY - ev.publishedAt) / MS_PER_DAY); + signals.staleness = clamp01(1 - 0.5 ** (days / STALENESS_HALF_LIFE_DAYS)); + if (days >= STALENESS_HALF_LIFE_DAYS) + reasons.push( + `latest published ${Math.round(days)}d ago (half-life ${STALENESS_HALF_LIFE_DAYS}d)`, + ); + } + // major lag — present iff we know both installed and latest. + const im = majorOf(ev.installed); + const lm = majorOf(ev.latest); + if (im != null && lm != null) { + const behind = Math.max(0, lm - im); + signals.majorLag = clamp01(behind / 3); + if (behind > 0) reasons.push(`${behind} major${behind > 1 ? "s" : ""} behind (v${im}→v${lm})`); + } + // advisories — present iff the advisories probe succeeded (empty array = "none known", risk 0). + if (Array.isArray(ev.advisories)) { + let max = 0; + let top = null; + for (const a of ev.advisories) { + const sev = String(a?.severity ?? "").toLowerCase(); + const w = ADVISORY_SEVERITY[sev] ?? 0; + if (sev === "critical") critical = true; + if (w > max) { + max = w; + top = a; + } + } + signals.advisory = max; + if (top) + reasons.push( + `${String(top.severity ?? "").toLowerCase()} advisory: ${String(top.title ?? "security advisory")}`, + ); + } + // deprecation — present iff we fetched metadata (null = unknown, not "not deprecated"). + if (ev.deprecated === true || ev.deprecated === false) { + signals.deprecated = ev.deprecated ? 1 : 0; + if (ev.deprecated) reasons.push("marked deprecated by its maintainer"); + } + + const kinds = Object.keys(signals); + let num = 0; + let den = 0; + for (const k of kinds) { + const w = RADAR_WEIGHTS[k] ?? 0; + num += w * signals[k]; + den += w; + } + const score = den > 0 ? clamp01(num / den) : 0; + + // Hard gates first: a deprecated or critically-vulnerable dep is "hold" regardless of freshness. + /** @type {"adopt"|"trial"|"assess"|"hold"} */ + let ring; + if (signals.deprecated === 1 || critical) { + ring = "hold"; + } else if (kinds.length < MIN_EVIDENCE_FOR_ADOPT) { + ring = "assess"; // too little verified evidence — never adopt on absence + reasons.push(`only ${kinds.length} evidence kind${kinds.length === 1 ? "" : "s"} verified`); + } else if (score < RING_THRESHOLDS.adopt) { + ring = "adopt"; + } else if (score < RING_THRESHOLDS.trial) { + ring = "trial"; + } else { + ring = "assess"; + } + return { + ring, + score, + signals, + evidenceKinds: kinds.length, + reasons, + hasAdvisory: Array.isArray(ev.advisories) && ev.advisories.length > 0, + }; +} + +const DEFAULT_REGISTRY = "https://registry.npmjs.org"; +const ABBREVIATED = "application/vnd.npm.install-v1+json"; + +/** + * Probe a registry for currency evidence. Injectable + never throws. Per-name failures + * degrade that dep's evidence to "missing" (so it lands in assess) rather than faking a + * clean bill of health; a total network failure returns `{ok:false}` so the caller can + * serve stale cache honestly. + * @param {string[]} names + * @param {{fetchImpl?:typeof fetch, timeoutMs?:number, registry?:string, + * installed?:Record}} [opts] + * @returns {Promise<{ok:boolean, reason?:string, + * results:Record, errors:string[]}>} + */ +export async function probeRegistry(names, opts = {}) { + const fetchImpl = opts.fetchImpl ?? globalThis.fetch; + const timeoutMs = Number.isFinite(opts.timeoutMs) ? Number(opts.timeoutMs) : 4000; + const registry = (opts.registry ?? DEFAULT_REGISTRY).replace(/\/+$/, ""); + const installedMap = opts.installed ?? {}; + /** @type {Record} */ + const results = {}; + const errors = []; + if (!fetchImpl || !names?.length) return { ok: !names?.length, results, errors }; + + const getJson = async (url, headers) => { + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + try { + const res = await fetchImpl(url, { headers, signal: ac.signal }); + if (!res?.ok) throw new Error(`http ${res ? res.status : "?"}`); + return await res.json(); + } finally { + clearTimeout(timer); + } + }; + + let anyOk = false; + // Per-package metadata. + for (const name of names) { + /** @type {DepEvidence} */ + const ev = { + installed: installedMap[name] ?? null, + latest: null, + publishedAt: null, + deprecated: null, + advisories: null, + }; + try { + const doc = await getJson(`${registry}/${encodeURIComponent(name)}`, { + Accept: ABBREVIATED, + }); + const latest = doc?.["dist-tags"]?.latest ?? null; + ev.latest = typeof latest === "string" ? latest : null; + const ver = ev.latest ? doc?.versions?.[ev.latest] : null; + // null = unknown (never verified), NOT "not deprecated": if the latest version's + // entry is absent from the metadata we could not check, so classifyDep must not + // count a spurious evidence kind (mizan — missing evidence never upgrades a dep). + ev.deprecated = ver ? Boolean(ver.deprecated) : null; + const t = doc?.time || {}; + const when = (ev.latest && t[ev.latest]) || t.modified || null; + const ms = when ? Date.parse(when) : NaN; + ev.publishedAt = Number.isFinite(ms) ? ms : null; + anyOk = true; + } catch (err) { + errors.push(`${name}: metadata ${String(err?.message ?? err)}`); + } + results[name] = ev; + } + + // One bulk advisories probe. Failure ⇒ advisories evidence stays MISSING (null) — the + // documented cut-line: degrade to assess, never assert "zero advisories". + try { + const body = {}; + for (const name of names) + body[name] = [results[name]?.installed || results[name]?.latest].filter(Boolean); + const ac = new AbortController(); + const timer = setTimeout(() => ac.abort(), timeoutMs); + let bulk; + try { + const res = await fetchImpl(`${registry}/-/npm/v1/security/advisories/bulk`, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + signal: ac.signal, + }); + if (!res?.ok) throw new Error(`http ${res ? res.status : "?"}`); + bulk = await res.json(); + } finally { + clearTimeout(timer); + } + for (const name of names) { + const list = Array.isArray(bulk?.[name]) ? bulk[name] : []; + if (results[name]) + results[name].advisories = list.map((a) => ({ + severity: a?.severity, + title: a?.title, + url: a?.url, + })); + } + anyOk = true; + } catch (err) { + errors.push(`advisories: ${String(err?.message ?? err)}`); + } + + return { ok: anyOk, results, errors }; +} + +/** + * Usage as STAKES (not risk): how many `imports` edges in the cached atlas reference each + * dep. Missing atlas → {} (degrade). External deps live as unresolved edges whose raw + * target is the specifier, so we match `name`, `name/sub`, and `name.member`. + * @param {string} root + * @param {string[]} names + * @returns {Record} + */ +export function usageFromAtlas(root, names) { + /** @type {Record} */ + const counts = {}; + for (const n of names) counts[n] = 0; + let atlas; + try { + const p = join(root, ".forge", "atlas.json"); + if (!existsSync(p)) return counts; + atlas = JSON.parse(readFileSync(p, "utf8")); + } catch { + return counts; + } + const edges = Array.isArray(atlas?.edges) ? atlas.edges : []; + for (const e of edges) { + if (e?.kind !== "imports") continue; + const target = String(e.target ?? ""); + for (const n of names) { + if (target === n || target.startsWith(`${n}/`) || target.startsWith(`${n}.`)) { + counts[n]++; + break; + } + } + } + return counts; +} + +// --- cache ----------------------------------------------------------------- + +export const radarCachePath = (root = process.cwd()) => join(root, ".forge", "radar.json"); + +/** Cache TTL in hours (FORGE_RADAR_TTL_H, default 24, NaN-safe). */ +export function radarTtlHours() { + const raw = Number(process.env.FORGE_RADAR_TTL_H); + return Number.isFinite(raw) && raw > 0 ? raw : 24; +} + +/** Read the radar cache, or null (missing/corrupt). */ +export function readRadarCache(root = process.cwd()) { + try { + const p = radarCachePath(root); + if (!existsSync(p)) return null; + const c = JSON.parse(readFileSync(p, "utf8")); + return c && typeof c === "object" ? c : null; + } catch { + return null; + } +} + +function writeRadarCache(root, payload) { + try { + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(radarCachePath(root), JSON.stringify(payload)); + return true; + } catch { + return false; + } +} + +/** Deps in a scan result whose ring warrants a pre-edit warning (hold, or assess-with-advisory). */ +function riskyDeps(deps) { + const out = new Set(); + for (const [name, d] of Object.entries(deps || {})) { + if (d?.ring === "hold" || (d?.ring === "assess" && d?.hasAdvisory)) out.add(name); + } + return out; +} + +/** + * Orchestrate a currency scan. Never throws. Serves fresh cache within TTL; offline serves + * stale cache (flagged) or fails honestly; online probes, classifies, caches, and records + * I4 evidence + a metrics line (network scans only — a cache replay must not re-mint evidence). + * @param {string} root + * @param {{fetchImpl?:typeof fetch, offline?:boolean, refresh?:boolean, now?:number, + * nowDay?:number, registry?:string, timeoutMs?:number}} [opts] + * @returns {Promise<{ok:boolean, source?:string, stale?:boolean, ageH?:number, + * deps?:Record, skipped?:any[], reason?:string, errors?:string[]}>} + */ +export async function radarScan(root = process.cwd(), opts = {}) { + const nowMs = Number.isFinite(opts.now) ? Number(opts.now) : Date.now(); + const nowDay = Number.isFinite(opts.nowDay) ? Number(opts.nowDay) : epochDay(); + const cache = readRadarCache(root); + const ttlMs = radarTtlHours() * 3600000; + const ageH = cache ? (nowMs - (cache.t ?? 0)) / 3600000 : Infinity; + const fresh = cache && nowMs - (cache.t ?? 0) < ttlMs; + + if (fresh && !opts.refresh) + return { + ok: true, + source: "cache", + deps: cache.deps ?? {}, + skipped: cache.skipped ?? [], + }; + + if (opts.offline) { + if (cache) + return { + ok: true, + source: "cache", + stale: true, + ageH, + deps: cache.deps ?? {}, + skipped: cache.skipped ?? [], + }; + return { + ok: false, + reason: "no cache and --offline (run online once to build one)", + }; + } + + const { deps: manifest, skipped } = depsFromManifests(root); + const names = Object.keys(manifest).filter((n) => !manifest[n].local); + /** @type {Record} */ + const installed = {}; + for (const [n, d] of Object.entries(manifest)) installed[n] = d.installed; + + const probe = await probeRegistry(names, { + fetchImpl: opts.fetchImpl, + timeoutMs: opts.timeoutMs, + registry: opts.registry, + installed, + }); + + if (!probe.ok) { + if (cache) + return { + ok: true, + source: "cache", + stale: true, + ageH, + deps: cache.deps ?? {}, + skipped: cache.skipped ?? [], + errors: probe.errors, + }; + return { + ok: false, + reason: "registry unreachable and no cache", + errors: probe.errors, + }; + } + + const usage = usageFromAtlas(root, Object.keys(manifest)); + /** @type {Record} */ + const deps = {}; + let hold = 0; + let assess = 0; + for (const [name, d] of Object.entries(manifest)) { + if (d.local) { + deps[name] = { + ring: "assess", + score: 0, + installed: d.installed, + latest: null, + reasons: ["local/workspace range — not probed against a registry"], + evidenceKinds: 0, + hasAdvisory: false, + source: d.source, + usage: usage[name] ?? 0, + }; + assess++; + continue; + } + const cls = classifyDep(probe.results[name], nowDay); + deps[name] = { + ring: cls.ring, + score: cls.score, + installed: d.installed, + latest: probe.results[name]?.latest ?? null, + reasons: cls.reasons, + evidenceKinds: cls.evidenceKinds, + hasAdvisory: cls.hasAdvisory, + source: d.source, + usage: usage[name] ?? 0, + }; + if (cls.ring === "hold") hold++; + else if (cls.ring === "assess") assess++; + } + + writeRadarCache(root, { t: nowMs, generatedDay: nowDay, deps, skipped }); + await recordCurrency(root, deps, nowDay); + try { + const { record } = await import("./metrics.js"); + record(root, { + stage: "radar", + outcome: "scan", + deps: Object.keys(deps).length, + hold, + assess, + }); + } catch {} + + return { ok: true, source: "network", deps, skipped, errors: probe.errors }; +} + +/** + * I4 recording: mint one `fact` claim per dep ("currency:") carrying the verified ring + * and its evidence. shadowFact's supersede keeps the latest verification live and history in + * tombstones. Best-effort — a ledger failure never breaks a scan. Network scans only. + */ +async function recordCurrency(root, deps, nowDay) { + try { + const { repoLedger } = await import("./ledger_store.js"); + const { shadowFact } = await import("./ledger_bridge.js"); + const dir = repoLedger(root); + for (const [name, d] of Object.entries(deps)) { + const top = (d.reasons || []).slice(0, 2).join("; ") || "no risk signals"; + const text = `${name}@${d.installed ?? "?"} — ring ${d.ring} (latest ${d.latest ?? "?"}; ${top}) verified day ${nowDay}`; + shadowFact(dir, `currency:${name}`, text, nowDay); + } + } catch {} +} + +/** + * Pre-edit currency advisory — CACHE-ONLY (a hook never fetches). Emits one line when the + * file about to be edited imports a dep the last scan put in "hold" (or "assess" with an + * advisory). Kill switch FORGE_RADAR=0. Fail-open: any problem → "". + * @param {string} root + * @param {(string|undefined)} file + * @returns {string} + */ +export function radarAdvisory(root, file) { + try { + if (process.env.FORGE_RADAR === "0") return ""; + if (!file || file.endsWith(".md")) return ""; + const cache = readRadarCache(root); + if (!cache?.deps) return ""; + const risky = riskyDeps(cache.deps); + if (!risky.size) return ""; + let text; + try { + if (statSync(file).size > 256 * 1024) return ""; + text = readFileSync(file, "utf8"); + } catch { + return ""; + } + const imported = importedPackages(text); + const hits = [...imported].filter((p) => risky.has(p)); + if (!hits.length) return ""; + const dep = hits[0]; + const d = cache.deps[dep]; + const why = d?.ring === "hold" ? "ring hold" : "ring assess, open advisory"; + const rel = file.startsWith(`${root}/`) ? file.slice(root.length + 1) : file; + return `${BRAND.brand} radar — ${rel} imports ${dep} (${why}: ${(d?.reasons || [])[0] ?? "unverified currency"}). Check \`${BRAND.cli} radar\` before building on it.`; + } catch { + return ""; + } +} + +const IMPORT_SPEC_RE = + /(?:import\s+(?:[^"'\n]+\s+from\s+)?["']([^"']+)["']|require\(\s*["']([^"']+)["']\s*\))/g; + +/** Bare package names imported by a source file (drops relative + node: builtins). */ +function importedPackages(text) { + const out = new Set(); + IMPORT_SPEC_RE.lastIndex = 0; + let m; + while ((m = IMPORT_SPEC_RE.exec(text))) { + const spec = m[1] || m[2] || ""; + if (!spec || spec.startsWith(".") || spec.startsWith("/") || spec.startsWith("node:")) continue; + const parts = spec.split("/"); + const bare = spec.startsWith("@") ? parts.slice(0, 2).join("/") : parts[0]; + if (bare) out.add(bare); + } + return out; +} diff --git a/src/report.js b/src/report.js new file mode 100644 index 0000000..70b4a7b --- /dev/null +++ b/src/report.js @@ -0,0 +1,277 @@ +// forge report — a static, self-contained HTML snapshot of the .forge/ substrate. +// Where `dash.js` serves a live localhost lens, `report` emits ONE file you can open +// offline, mail, or attach to a PR: no server, no fetch, no CDN, no JS required to read +// it. DATA reuse is deliberate — the payload is `dashData(root)` (dash.js), history is +// `read()` from metrics.js bucketed by day, and the tech-radar section reads the +// `.forge/radar.json` cache DIRECTLY and defensively (absent/corrupt → the section is +// simply omitted; report.js never imports radar.js, so it stands alone if radar was +// never built). Visuals are server-side inline SVG sparklines. The token block comes +// from `rootTokensCss()` (brand.js) so the report's palette is the brand's single +// source, never a forked copy. `renderReport` is PURE (returns the HTML string, writes +// nothing); `writeReport` is the only side-effecting entry point. +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; +import { BRAND, rootTokensCss } from "./brand.js"; +import { dashData } from "./dash.js"; +import { read as readMetrics } from "./metrics.js"; +import { epochDay, MS_PER_DAY } from "./util.js"; + +/** How far back the history strip looks (matches the dash /api/history 90-day cap). */ +const HISTORY_DAYS = 90; + +/** Minimal HTML-attribute/text escaper — the report interpolates ledger claim text and + * radar notes that originate outside our control, so every dynamic string is escaped. */ +export function escapeHtml(s) { + return String(s).replace( + /[&<>"']/g, + (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c], + ); +} + +/** Absolute path of the emitted report for a repo root. */ +export const reportPath = (root) => join(root, ".forge", "report.html"); + +/** + * Read the radar cache without importing radar.js (built in parallel, may not exist). + * Accepts either a bare array of entries or `{ generatedAt, entries: [...] }`; coerces + * loose fields to strings. Absent, empty, or unparseable → null (section omitted). + * @param {string} root + */ +export function readRadar(root) { + try { + const p = join(root, ".forge", "radar.json"); + if (!existsSync(p)) return null; + const data = JSON.parse(readFileSync(p, "utf8")); + const raw = Array.isArray(data) ? data : Array.isArray(data?.entries) ? data.entries : []; + const entries = raw + .map((e) => ({ + name: String(e?.name ?? e?.dep ?? ""), + ring: String(e?.ring ?? e?.status ?? ""), + note: String(e?.note ?? e?.reason ?? ""), + })) + .filter((e) => e.name); + if (!entries.length) return null; + return { + generatedAt: typeof data?.generatedAt === "string" ? data.generatedAt : "", + entries, + }; + } catch { + return null; + } +} + +/** + * Bucket metrics.jsonl into a fixed [nowDay-days+1 … nowDay] window: per-day event + * counts (for the sparkline) and a per-stage rollup (events + summed savedEstimate). + * @param {string} root + * @param {{nowDay?: number, days?: number}} [opts] + */ +export function historyByDay(root, { nowDay = epochDay(), days = HISTORY_DAYS } = {}) { + const start = nowDay - days + 1; + const counts = new Array(days).fill(0); + const byStage = {}; + let total = 0; + for (const e of readMetrics(root)) { + const t = Number(e.t); + if (!Number.isFinite(t)) continue; + const day = Math.floor(t / MS_PER_DAY); + if (day < start || day > nowDay) continue; + counts[day - start] += 1; + total += 1; + const s = (byStage[e.stage || "?"] ??= { events: 0, saved: 0 }); + s.events += 1; + if (Number.isFinite(e.savedEstimate)) s.saved += e.savedEstimate; + } + return { start, days, counts, byStage, total }; +} + +/** + * Server-side inline SVG sparkline — no xmlns (the HTML parser handles inline SVG), so + * the markup carries zero external references. Flat/empty series → a baseline line. + * @param {number[]} values + * @param {{width?: number, height?: number, pad?: number}} [opts] + */ +export function sparkline(values, { width = 280, height = 34, pad = 3 } = {}) { + const n = values.length; + const max = Math.max(1, ...values); + const stepX = n > 1 ? (width - pad * 2) / (n - 1) : 0; + const y = (v) => height - pad - (v / max) * (height - pad * 2); + const pts = + n === 0 + ? `${pad},${(height - pad).toFixed(1)} ${width - pad},${(height - pad).toFixed(1)}` + : values.map((v, i) => `${(pad + i * stepX).toFixed(1)},${y(v).toFixed(1)}`).join(" "); + return ( + `` + + `` + ); +} + +const num = (x) => (Number.isFinite(x) ? x : 0); + +/** A confidence/value cell rendered as a proportional inline bar (0..1). */ +function valBar(v) { + const pct = Math.round(Math.max(0, Math.min(1, num(v))) * 100); + return ( + `` + + `${num(v).toFixed(2)}` + ); +} + +function ledgerRows(claims) { + if (!claims.length) return `no claims yet`; + return claims + .slice(0, 40) + .map( + (c) => + `` + + `${escapeHtml(c.id8)}` + + `${escapeHtml(c.kind)}` + + `${valBar(c.val)}` + + `${escapeHtml(c.text)}`, + ) + .join(""); +} + +function stageRows(byStage) { + const names = Object.keys(byStage).sort(); + if (!names.length) return `no measured stages`; + return names + .map( + (s) => + `${escapeHtml(s)}` + + `${byStage[s].events}` + + `${Math.round(byStage[s].saved)}`, + ) + .join(""); +} + +function radarSection(radar) { + if (!radar) return ""; + const rows = radar.entries + .slice(0, 60) + .map( + (e) => + `${escapeHtml(e.name)}` + + `${escapeHtml(e.ring)}` + + `${escapeHtml(e.note)}`, + ) + .join(""); + const stamp = radar.generatedAt ? ` · cached ${escapeHtml(radar.generatedAt)}` : ""; + return ( + `

        Tech radar${stamp}

        ` + + `` + + `${rows}
        dependencyringnote
        ` + ); +} + +/** + * Render the whole report as one self-contained HTML string. PURE — no filesystem + * writes, no network, no throw on corrupt stores (dashData already degrades to empty + * sections). The output references nothing external: styles come from `rootTokensCss()` + * and every chart is inline SVG. + * @param {string} root + * @param {{nowDay?: number}} [opts] + */ +export function renderReport(root, { nowDay = epochDay() } = {}) { + const data = dashData(root, { nowDay }); + const hist = historyByDay(root, { nowDay }); + const radar = readRadar(root); + const st = data.ledger.stats; + const stages = data.metrics.stages || {}; + const stageCount = Object.keys(stages).length; + const generated = new Date().toISOString(); + + const cards = [ + ["claims", st.total], + ["trusted", st.val?.trusted ?? 0], + ["contested", data.ledger.contested.length], + ["tombstoned", st.tombstoned], + ["events (90d)", hist.total], + ["stages", stageCount], + ] + .map( + ([label, value]) => + `
        ${escapeHtml(String(value))}
        ` + + `
        ${escapeHtml(label)}
        `, + ) + .join(""); + + const atlasLine = data.atlas.built + ? `atlas: ${data.atlas.symbols} symbols across ${data.atlas.files} files` + : "atlas: not built"; + + const css = ` + ${rootTokensCss()} + *{box-sizing:border-box} + body{margin:0;background:var(--bg);color:var(--text);font:13px/1.55 var(--sans); + -webkit-font-smoothing:antialiased} + .mono{font-family:var(--mono)}.muted{color:var(--muted)}.faint{color:var(--faint)} + .wrap{max-width:1000px;margin:0 auto;padding:24px 20px 64px} + header{display:flex;align-items:baseline;gap:12px;flex-wrap:wrap; + border-bottom:1px solid var(--line);padding-bottom:12px;margin-bottom:20px} + header .b{font-family:var(--mono);font-size:15px} + header .b b{color:var(--brand);font-weight:600} + header .meta{margin-left:auto;font-family:var(--mono);font-size:11px;color:var(--muted)} + h1{font-size:15px;margin:0} + h2{font-size:12px;text-transform:uppercase;letter-spacing:.06em;color:var(--muted); + margin:0 0 12px;font-weight:600} + .cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(120px,1fr));gap:10px; + margin-bottom:20px} + .card{background:var(--panel);border:1px solid var(--line);border-radius:10px;padding:12px 14px} + .card-n{font-size:22px;color:var(--brand);font-weight:600} + .card-l{font-size:11px;text-transform:uppercase;letter-spacing:.04em;margin-top:2px} + .panel{background:var(--panel);border:1px solid var(--line);border-radius:10px; + padding:16px 18px;margin-bottom:16px} + table{width:100%;border-collapse:collapse;font-size:12px} + th{text-align:left;color:var(--faint);font-weight:500;padding:4px 8px; + border-bottom:1px solid var(--line)} + td{padding:5px 8px;border-bottom:1px solid var(--line);vertical-align:top} + tr:last-child td{border-bottom:0} + tr.tomb td{opacity:.5;text-decoration:line-through} + .bar{display:inline-block;width:52px;height:6px;border-radius:4px;background:var(--bg-2); + vertical-align:middle;margin-right:6px;overflow:hidden} + .bar-fill{display:block;height:100%;background:var(--brand)} + .spark{display:block;margin:4px 0 10px} + footer{color:var(--faint);font-size:11px;margin-top:24px;text-align:center} + a{color:var(--brand)}`; + + return ( + "\n" + + '' + + '' + + `${escapeHtml(BRAND.brand)} report — ${escapeHtml(data.repo)}` + + `
        ` + + `
        ${escapeHtml(BRAND.brand)} report` + + `

        ${escapeHtml(data.repo)}

        ` + + `generated ${escapeHtml(generated)} · static · offline
        ` + + `
        ${cards}
        ` + + `

        Activity — last ${hist.days} days

        ` + + `${sparkline(hist.counts)}
        ${atlasLine}
        ` + + `

        Stages

        ` + + `` + + `${stageRows(hist.byStage)}
        stageeventssaved (est. tokens)
        ` + + radarSection(radar) + + `

        Ledger — top claims by value

        ` + + `` + + `${ledgerRows(data.ledger.claims)}
        idkindvalclaim
        ` + + `
        ${escapeHtml(BRAND.brand)} v${escapeHtml(BRAND.version)} · ` + + `${escapeHtml(BRAND.cli)} report — regenerate anytime
        ` + + "
        \n" + ); +} + +/** + * Render and write the report to `.forge/report.html`. Returns the absolute path. + * @param {string} root + * @param {{nowDay?: number}} [opts] + */ +export function writeReport(root, opts = {}) { + const html = renderReport(root, opts); + const dir = join(root, ".forge"); + mkdirSync(dir, { recursive: true }); + const path = reportPath(root); + writeFileSync(path, html); + return path; +} diff --git a/src/update.js b/src/update.js index c151cd0..adc444a 100644 --- a/src/update.js +++ b/src/update.js @@ -107,3 +107,75 @@ export function applyUpdate({ root = BRAND.root } = {}) { }; } } + +/** + * Pin or downgrade the live install to an exact released version. Git checkout: + * fetch tags (best-effort — the tag may already be local), verify the release tag + * exists, then a detached checkout with a note on how to get back to latest. + * npm/copy installs get the exact `npm i -g @` instruction instead. + * Never throws — a missing tag or a dirty tree is an honest `{ok:false, reason}`. + * @param {string} version - target version, with or without a leading "v" + * @param {{root?: string}} [opts] + * @returns {{ok:boolean, mode:string, tag?:string, changed?:boolean, before?:string, + * after?:string, note?:string, instruction?:string, reason?:string, detail?:string}} + */ +export function applyUpdateTo(version, { root = BRAND.root } = {}) { + const want = String(version ?? "") + .trim() + .replace(/^v/, ""); + // Empty or flag-shaped (`--to --json`) input: fail before touching git at all. + if (!want || want.startsWith("-")) + return { + ok: false, + mode: "none", + reason: `missing version — usage: \`${BRAND.cli} update --to \` (e.g. --to 0.17.0)`, + }; + if (!isGitCheckout(root)) + return { + ok: false, + mode: "npm-or-copy", + instruction: `npm install -g ${npmName(root)}@${want}`, + reason: "not a git checkout — pin via npm", + }; + try { + execFileSync("git", ["fetch", "--tags", "--quiet"], { + cwd: root, + stdio: "ignore", + timeout: 8000, + }); + } catch {} // offline / no remote is fine — the tag may already be local + // Release tags here are `vX.Y.Z`, but accept a bare `X.Y.Z` tag too. + const tag = [`v${want}`, want].find((t) => + git(root, ["rev-parse", "--verify", "--quiet", `refs/tags/${t}^{commit}`]), + ); + if (!tag) + return { + ok: false, + mode: "git", + reason: `no release tag v${want} found (tags fetched) — see \`git tag\` for available versions`, + }; + const before = git(root, ["rev-parse", "HEAD"]); + try { + execFileSync("git", ["checkout", "--quiet", "--detach", tag], { + cwd: root, + stdio: ["ignore", "ignore", "pipe"], + }); + } catch (e) { + return { + ok: false, + mode: "git", + reason: "checkout failed (uncommitted changes?) — stash or commit, then retry", + detail: String(e.message || e).slice(-200), + }; + } + const after = git(root, ["rev-parse", "HEAD"]); + return { + ok: true, + mode: "git", + tag, + changed: before !== after, + before: before.slice(0, 8), + after: after.slice(0, 8), + note: `detached at ${tag} — \`git checkout \` then \`${BRAND.cli} update\` returns to latest`, + }; +} diff --git a/src/verify.js b/src/verify.js index d49969a..de7c0d0 100644 --- a/src/verify.js +++ b/src/verify.js @@ -47,7 +47,12 @@ function runTests(cwd) { return { ran: false }; } catch (e) { if (e.code === "ETIMEDOUT" || e.signal === "SIGTERM") { - return { ran: true, passed: false, timedOut: true, output: `test run exceeded ${timeout}ms` }; + return { + ran: true, + passed: false, + timedOut: true, + output: `test run exceeded ${timeout}ms`, + }; } return { ran: true, @@ -113,5 +118,7 @@ export function verify({ targetRoot = process.cwd(), base = "HEAD" } = {}) { // Hard gate = the project's own tests. Unknown symbols are advisory (heuristic). const ok = tests.ran ? tests.passed === true : true; - return { ok, provenance, unknown, tests, changedFiles }; + // `added` (the raw added diff lines) rides along for the deep lenses (consensus.js: + // secrets + reviewer read the same bytes this pass already parsed — one diff, one truth). + return { ok, provenance, unknown, tests, changedFiles, added }; } diff --git a/test/commit_gate.test.js b/test/commit_gate.test.js new file mode 100644 index 0000000..2a96ce4 --- /dev/null +++ b/test/commit_gate.test.js @@ -0,0 +1,205 @@ +import assert from "node:assert/strict"; +import { execFileSync, spawnSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + commitGate, + commitGateDecision, + gateMode, + renderCommitGate, + stagedAddedLines, + stagedFiles, +} from "../src/commit_gate.js"; +import { fakeGithubPat } from "./_fixtures.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +function gitFixture() { + const root = mkdtempSync(join(tmpdir(), "forge-precommit-")); + const git = (...args) => + execFileSync("git", args, { cwd: root, stdio: ["ignore", "pipe", "pipe"] }); + git("init", "-q"); + git("config", "user.email", "forge@test.invalid"); + git("config", "user.name", "forge-test"); + writeFileSync(join(root, "a.js"), "export const one = 1;\n"); + writeFileSync(join(root, "README.md"), "# app\n"); + git("add", "-A"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "fixture"); + return { root, git }; +} + +// A clean env for every gate call: the host shell (or CI) may export +// FORGE_COMMIT_GATE and the tests must not inherit it. +const env = (extra = {}) => { + const e = { ...process.env, ...extra }; + if (!("FORGE_COMMIT_GATE" in extra)) delete e.FORGE_COMMIT_GATE; + return e; +}; + +const cli = (root, extraEnv = {}) => + spawnSync("node", [CLI, "precommit"], { + cwd: root, + encoding: "utf8", + env: env(extraEnv), + }); + +test("warn (default): staged code without docs is a finding but the commit is allowed", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "a.js"), "export const one = 2;\n"); + git("add", "a.js"); + const r = commitGate(root, { env: env() }); + assert.equal(r.mode, "warn"); + assert.equal(r.allow, true); + assert.equal(r.row, "warned"); + assert.equal(r.findings.length, 1); + assert.equal(r.findings[0].kind, "completeness"); + assert.match(r.findings[0].detail, /a\.js/); + assert.match(renderCommitGate(r), /docs sync/, "the finding carries the repair procedure"); + const run = cli(root); + assert.equal(run.status, 0, "warn mode never refuses the commit"); + assert.match(run.stdout, /completeness/); +}); + +test("code + doc staged together passes clean", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "a.js"), "export const one = 3;\n"); + writeFileSync(join(root, "README.md"), "# app\n\ndocumented the change\n"); + git("add", "-A"); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, true); + assert.equal(r.row, "clean"); + assert.equal(r.findings.length, 0); +}); + +test("FORGE_COMMIT_GATE=block refuses staged code without docs (exit 1 via CLI)", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "a.js"), "export const one = 4;\n"); + git("add", "a.js"); + const r = commitGate(root, { env: env({ FORGE_COMMIT_GATE: "block" }) }); + assert.equal(r.allow, false); + assert.equal(r.row, "blocked"); + const run = cli(root, { FORGE_COMMIT_GATE: "block" }); + assert.equal(run.status, 1, "block mode refuses via exit code"); + assert.match(run.stdout, /commit refused/); +}); + +test("a staged secret blocks even in warn mode (gitleaks fallback)", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "config.js"), `export const token = "${fakeGithubPat()}";\n`); + git("add", "config.js"); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, false, "warn mode still refuses a credential"); + const secret = r.findings.find((f) => f.kind === "secret"); + assert.ok(secret, "a secret finding is reported"); + assert.match(secret.detail, /config\.js/); + const run = cli(root); + assert.equal(run.status, 1); +}); + +test("removed lines never trigger the secret scan (added lines only)", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "cfg.js"), `export const token = "${fakeGithubPat()}";\n`); + git("add", "cfg.js"); + git("-c", "commit.gpgsign=false", "commit", "-qm", "leak (historical)"); + writeFileSync(join(root, "cfg.js"), "export const token = process.env.TOKEN;\n"); + writeFileSync(join(root, "README.md"), "# app\n\ntoken now comes from the env\n"); + git("add", "-A"); + const r = commitGate(root, { env: env() }); + assert.equal(r.allow, true, "deleting the secret must not be refused as adding one"); + assert.equal(r.findings.length, 0); +}); + +test("kill switch FORGE_COMMIT_GATE=0 disables everything — even the secret scan", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "config.js"), `export const token = "${fakeGithubPat()}";\n`); + git("add", "config.js"); + const r = commitGate(root, { env: env({ FORGE_COMMIT_GATE: "0" }) }); + assert.equal(r.allow, true); + assert.equal(r.row, "kill-switch"); + assert.equal(cli(root, { FORGE_COMMIT_GATE: "0" }).status, 0); +}); + +test("nothing staged, non-repo, and unreadable roots all fail open", () => { + const { root } = gitFixture(); + const clean = commitGate(root, { env: env({ FORGE_COMMIT_GATE: "block" }) }); + assert.equal(clean.allow, true); + assert.equal(clean.row, "nothing-staged"); + const bare = mkdtempSync(join(tmpdir(), "forge-precommit-")); + const notRepo = commitGate(bare, { + env: env({ FORGE_COMMIT_GATE: "block" }), + }); + assert.equal(notRepo.allow, true); + assert.equal(notRepo.row, "not-a-repo"); + const gone = commitGate(join(bare, "does-not-exist"), { + env: env({ FORGE_COMMIT_GATE: "block" }), + }); + assert.equal(gone.allow, true, "an unusable root can never brick a commit"); +}); + +test("test-only and docs-only commits pass (same class semantics as the Stop gate)", () => { + const { root, git } = gitFixture(); + writeFileSync( + join(root, "a.test.js"), + "import { test } from 'node:test';\ntest('x', () => {});\n", + ); + git("add", "a.test.js"); + assert.equal(commitGate(root, { env: env({ FORGE_COMMIT_GATE: "block" }) }).allow, true); + writeFileSync(join(root, "NOTES.md"), "# notes\n"); + git("add", "NOTES.md"); + assert.equal(commitGate(root, { env: env({ FORGE_COMMIT_GATE: "block" }) }).allow, true); +}); + +test("unicode/space doc paths keep their doc credit (-z parsing)", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "a.js"), "export const one = 5;\n"); + writeFileSync(join(root, "Änderungen notes.md"), "# änderungen\n\ndokumentiert\n"); + git("add", "-A"); + const staged = stagedFiles(root); + assert.ok(staged.includes("Änderungen notes.md"), "the exact unicode path survives -z"); + const r = commitGate(root, { env: env({ FORGE_COMMIT_GATE: "block" }) }); + assert.equal(r.allow, true, "the unicode-named doc satisfies the gate"); +}); + +test("stagedAddedLines maps added lines to their file, unified=0", () => { + const { root, git } = gitFixture(); + writeFileSync(join(root, "a.js"), "export const one = 1;\nexport const two = 2;\n"); + git("add", "a.js"); + const byFile = stagedAddedLines(root); + assert.deepEqual(byFile.get("a.js"), ["export const two = 2;"]); +}); + +test("gateMode is total: unrecognized values degrade to warn, never crash or block", () => { + assert.equal(gateMode(undefined), "warn"); + assert.equal(gateMode("banana"), "warn"); + assert.equal(gateMode("block"), "block"); + assert.equal(gateMode("0"), "off"); + assert.equal(gateMode("off"), "off"); +}); + +test("pure decision table: vendor-free classes, block only on the stated rows", () => { + const warn = commitGateDecision({ staged: ["src/x.js"], mode: "warn" }); + assert.equal(warn.allow, true); + assert.equal(warn.findings[0].kind, "completeness"); + const block = commitGateDecision({ staged: ["src/x.js"], mode: "block" }); + assert.equal(block.allow, false); + const withDocs = commitGateDecision({ + staged: ["src/x.js", "docs/x.md"], + mode: "block", + }); + assert.equal(withDocs.allow, true); + const secret = commitGateDecision({ + staged: ["src/x.js", "docs/x.md"], + secretFiles: ["src/x.js"], + mode: "warn", + }); + assert.equal(secret.allow, false, "a secret blocks regardless of docs credit and mode"); + const off = commitGateDecision({ + staged: ["src/x.js"], + secretFiles: ["src/x.js"], + mode: "off", + }); + assert.equal(off.allow, true); +}); diff --git a/test/consensus.test.js b/test/consensus.test.js new file mode 100644 index 0000000..9d0d710 --- /dev/null +++ b/test/consensus.test.js @@ -0,0 +1,361 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, readFileSync, rmSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + aggregate, + BLOCK_THRESHOLD, + buildReviewPrompt, + docsDriftLens, + impactLens, + LENSES, + parseReviewProposal, + reviewerLens, + secretsLens, + speclockLens, + symbolsLens, + testsLens, + verifyDeep, +} from "../src/consensus.js"; +import { fakeAnthropic } from "./_fixtures.js"; + +// --------------------------------------------------------------------------- +// LENSES table — the taxonomy the whole module hangs off. +// --------------------------------------------------------------------------- + +test("LENSES: every weight in (0,1), every family named; only tests+secrets are solo", () => { + for (const [name, l] of Object.entries(LENSES)) { + assert.ok(l.weight > 0 && l.weight < 1, `${name} weight bounded`); + assert.ok(typeof l.family === "string" && l.family, `${name} has a family`); + } + assert.equal(LENSES.tests.solo, true); + assert.equal(LENSES.secrets.solo, true); + for (const name of ["symbols", "impact", "docsdrift", "speclock", "reviewer"]) + assert.ok(!LENSES[name].solo, `${name} must never be trusted solo`); +}); + +// --------------------------------------------------------------------------- +// aggregate — noisy-OR + cross-family gate (the scoreMistake shape). +// --------------------------------------------------------------------------- + +test("aggregate: a single structural lens never blocks, however loud", () => { + const r = aggregate([{ lens: "symbols", s: 1 }]); + assert.equal(r.p, LENSES.symbols.weight); + assert.equal(r.fires, false); + assert.equal(r.block, false); +}); + +test("aggregate: many same-family structural signals still can't block (correlated evidence)", () => { + const r = aggregate([ + { lens: "symbols", s: 1 }, + { lens: "speclock", s: 1 }, + { lens: "docsdrift", s: 1 }, + { lens: "impact", s: 1 }, + ]); + assert.ok(r.p >= BLOCK_THRESHOLD, "noisy-OR piles up"); + assert.deepEqual(r.families, ["structural"]); + assert.equal(r.fires, false, "one family — the gate holds"); + assert.equal(r.block, false); +}); + +test("aggregate: a solo test failure blocks on its own", () => { + const r = aggregate([{ lens: "tests", s: 1 }]); + assert.equal(r.fires, true); + assert.ok(r.p >= BLOCK_THRESHOLD); + assert.equal(r.block, true); +}); + +test("aggregate: a leaked secret blocks on its own", () => { + const r = aggregate([{ lens: "secrets", s: 1 }]); + assert.equal(r.block, true); +}); + +test("aggregate: two families cross the gate (reviewer can block only WITH a second family)", () => { + const alone = aggregate([{ lens: "reviewer", s: 1 }]); + assert.equal(alone.block, false, "model lens never blocks alone"); + const paired = aggregate([ + { lens: "reviewer", s: 1 }, + { lens: "docsdrift", s: 1 }, + ]); + assert.equal(paired.fires, true); + assert.ok(Math.abs(paired.p - 0.51) < 1e-9, "1 − 0.7·0.7"); + assert.equal(paired.block, true); +}); + +test("aggregate: P(defect) stays bounded < 1 with every lens firing", () => { + const all = Object.keys(LENSES).map((lens) => ({ lens, s: 1 })); + const r = aggregate(all); + assert.ok(r.p < 1); +}); + +test("aggregate: residual is ∏(1−w) over lenses that RAN — clean lenses count, skipped don't", () => { + const r = aggregate([ + { lens: "tests", s: 0 }, + { lens: "symbols", s: 0 }, + { lens: "reviewer", ran: false }, + ]); + assert.equal(r.p, 0); + assert.equal(r.fires, false); + assert.ok(Math.abs(r.residual - 0.2 * 0.6) < 1e-9, "0.2·0.6 — reviewer skipped"); + const none = aggregate([{ lens: "tests", ran: false }]); + assert.equal(none.residual, 1, "nothing ran → no coverage claimed"); +}); + +test("aggregate: clean lenses contribute no family; unknown lens names are ignored", () => { + const r = aggregate([ + { lens: "tests", s: 0 }, + { lens: "docsdrift", s: 1 }, + { lens: "not-a-lens", s: 1 }, + ]); + assert.deepEqual(r.families, ["structural"]); + assert.equal(r.fires, false); +}); + +// --------------------------------------------------------------------------- +// Deterministic lenses. +// --------------------------------------------------------------------------- + +test("testsLens: abstains when no suite ran; fires on fail; clean on pass", () => { + assert.equal(testsLens({ ran: false }).ran, false); + assert.equal(testsLens(undefined).ran, false); + assert.equal(testsLens({ ran: true, passed: false }).s, 1); + assert.equal(testsLens({ ran: true, passed: true }).s, 0); +}); + +test("symbolsLens fires only on unknown symbols", () => { + assert.equal(symbolsLens([]).s, 0); + const l = symbolsLens(["ghostFn"]); + assert.equal(l.s, 1); + assert.deepEqual(l.unknown, ["ghostFn"]); +}); + +test("docsDriftLens: code without docs fires; code+docs and docs-only stay clean", () => { + assert.equal(docsDriftLens(["src/x.js"]).s, 1); + assert.equal(docsDriftLens(["src/x.js", "README.md"]).s, 0); + assert.equal(docsDriftLens(["README.md"]).s, 0); + assert.equal(docsDriftLens([]).s, 0); +}); + +test("secretsLens fires on a secret-shaped token in the added lines", () => { + assert.equal(secretsLens("const x = 1;").s, 0); + assert.equal(secretsLens(`key = "${fakeAnthropic()}"`).s, 1); +}); + +test("impactLens: null atlas abstains; dependents not in the diff fire, graded by count", () => { + assert.equal(impactLens(null, ["a.js"]).ran, false); + const atlas = { + nodes: [ + { id: "mod:a", name: "a.js", file: "a.js" }, + { id: "mod:b", name: "b.js", file: "b.js" }, + ], + edges: [{ source: "mod:b", target: "mod:a", kind: "imports" }], + symbols: [], + }; + const l = impactLens(atlas, ["a.js"]); + assert.equal(l.ran, true); + assert.deepEqual(l.dependents, ["b.js"]); + assert.ok(Math.abs(l.s - 0.2) < 1e-9, "1 dependent / 5 = 0.2"); + // dependent already IN the diff → reviewed → clean + assert.equal(impactLens(atlas, ["a.js", "b.js"]).s, 0); +}); + +test("speclockLens abstains honestly when no spec lock exists", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + assert.equal(speclockLens(dir).ran, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +// --------------------------------------------------------------------------- +// Reviewer lens — majority-of-N with an injected runner (never the network). +// --------------------------------------------------------------------------- + +// Injected reviewer runner: replays scripted replies in order, repeating the last — +// three "independent" samples without a model or the network anywhere near the test. +const scripted = (outputs) => { + let i = 0; + return () => outputs[Math.min(i++, outputs.length - 1)]; +}; + +test("reviewerLens: off unless opted in — deterministic result untouched", () => { + const r = reviewerLens({ + llm: false, + run: scripted(['{"verdict":"defect"}']), + }); + assert.equal(r.ran, false); + assert.equal(r.verdict, "off"); +}); + +test("reviewerLens: strict majority of usable votes says defect", () => { + const run = scripted([ + '{"verdict":"defect","reason":"off-by-one"}', + '{"verdict":"pass"}', + '{"verdict":"defect","reason":"same"}', + ]); + const r = reviewerLens({ llm: true, n: 3, run, added: "x" }); + assert.equal(r.ran, true); + assert.equal(r.verdict, "defect"); + assert.ok(Math.abs(r.s - 2 / 3) < 1e-9); +}); + +test("reviewerLens: all-pass panel is clean (s=0)", () => { + const r = reviewerLens({ + llm: true, + n: 3, + run: scripted(['{"verdict":"pass"}']), + }); + assert.equal(r.verdict, "pass"); + assert.equal(r.s, 0); +}); + +test("reviewerLens: abstains when fewer than ⌈n/2⌉ replies are usable", () => { + const run = scripted(["garbage", "not json either", '{"verdict":"defect"}']); + const r = reviewerLens({ llm: true, n: 3, run }); + assert.equal(r.ran, false); + assert.equal(r.verdict, "abstain"); +}); + +test("reviewerLens: a tie among usable votes passes (no strict majority)", () => { + const run = scripted(["garbage", '{"verdict":"defect"}', '{"verdict":"pass"}']); + const r = reviewerLens({ llm: true, n: 3, run }); + assert.equal(r.ran, true, "2 usable ≥ ⌈3/2⌉"); + assert.equal(r.verdict, "pass"); + assert.equal(r.s, 0); +}); + +test("parseReviewProposal: only a clear defect/pass verdict is usable", () => { + assert.equal(parseReviewProposal({ verdict: "maybe" }), null); + assert.equal(parseReviewProposal({}), null); + assert.equal(parseReviewProposal({ verdict: "DEFECT", reason: "x" }).verdict, "defect"); + assert.equal(parseReviewProposal({ verdict: " pass " }).verdict, "pass"); +}); + +test("buildReviewPrompt: names the files, caps the diff, demands strict JSON", () => { + const p = buildReviewPrompt({ files: ["src/a.js"], added: "y".repeat(9000) }); + assert.ok(p.includes("src/a.js")); + assert.ok(p.includes('{"verdict":"defect|pass"')); + assert.ok(p.length < 6000, "added lines are truncated"); +}); + +// --------------------------------------------------------------------------- +// verifyDeep — orchestration with an injected core verify (no real test-suite run). +// --------------------------------------------------------------------------- + +const fakeCore = (over = {}) => ({ + ok: true, + provenance: { + base: "HEAD", + changedFiles: [], + tests: {}, + symbolsChecked: 0, + unknownSymbols: [], + }, + unknown: [], + tests: { ran: true, passed: true, runner: "npm test" }, + changedFiles: ["src/x.js", "README.md"], + added: "const y = 2;", + ...over, +}); + +test("verifyDeep: clean diff passes, persists provenance.deep + one verify metrics record", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => fakeCore(), + }); + assert.equal(r.ok, true); + assert.deepEqual(r.findings, []); + assert.equal(r.p, 0); + assert.ok(r.residual > 0 && r.residual < 1, "some lenses ran — coverage is claimed"); + const prov = JSON.parse(readFileSync(join(dir, ".forge", "provenance.json"), "utf8")); + assert.equal(prov.deep.block, false); + assert.ok(Array.isArray(prov.deep.lenses) && prov.deep.lenses.length === 7); + assert.equal(prov.deep.residual, r.residual); + const metrics = readFileSync(join(dir, ".forge", "metrics.jsonl"), "utf8") + .trim() + .split("\n") + .map((l) => JSON.parse(l)); + assert.equal(metrics.length, 1); + assert.equal(metrics[0].stage, "verify"); + assert.equal(metrics[0].outcome, "pass"); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: a failing test suite blocks solo; findings + block land in provenance", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => fakeCore({ tests: { ran: true, passed: false, runner: "npm test" } }), + }); + assert.equal(r.ok, false); + assert.equal(r.block, true); + assert.ok(r.findings.some((f) => f.includes("tests failed"))); + const prov = JSON.parse(readFileSync(join(dir, ".forge", "provenance.json"), "utf8")); + assert.equal(prov.deep.block, true); + const metrics = readFileSync(join(dir, ".forge", "metrics.jsonl"), "utf8"); + assert.ok(metrics.includes('"outcome":"block"')); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: structural findings alone stay advisory (no block)", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => + fakeCore({ + unknown: ["ghostFn"], + changedFiles: ["src/x.js"] /* no docs → drift too */, + }), + }); + assert.equal(r.ok, true, "two structural lenses — one family, the gate holds"); + assert.ok(r.findings.length >= 2); + assert.equal(r.fires, false); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); + +test("verifyDeep: reviewer panel joins via injected runner and can tip a second family", () => { + const dir = mkdtempSync(join(tmpdir(), "forge-consensus-")); + try { + const r = verifyDeep({ + targetRoot: dir, + llm: true, + run: scripted(['{"verdict":"defect","reason":"wrong edge"}']), + verifyImpl: () => fakeCore({ changedFiles: ["src/x.js"] }), // docsdrift fires (structural) + }); + assert.equal(r.fires, true, "model + structural = two families"); + assert.equal(r.ok, false); + assert.ok(r.findings.some((f) => f.includes("reviewer panel"))); + } 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 { + const r = verifyDeep({ + targetRoot: dir, + llm: false, + verifyImpl: () => fakeCore({ added: `token = "${fakeAnthropic()}"` }), + }); + assert.equal(r.ok, false); + assert.ok(r.findings.some((f) => f.includes("secret"))); + } finally { + rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/test/dash.test.js b/test/dash.test.js index 18ae2d0..bf9db21 100644 --- a/test/dash.test.js +++ b/test/dash.test.js @@ -3,7 +3,7 @@ import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { test } from "node:test"; -import { dashData, serve } from "../src/dash.js"; +import { claimsData, dashData, historyData, radarData, serve, timelineData } from "../src/dash.js"; import { mintClaim, outcomeRecord } from "../src/ledger.js"; import { appendEvidence, @@ -18,7 +18,12 @@ const tmp = () => mkdtempSync(join(tmpdir(), "forge-dash-")); const NOW = 100; const mint = (dir, name, text, author = "alice") => { - const c = mintClaim({ kind: "fact", body: { name, text }, provenance: { author }, t: NOW }).claim; + const c = mintClaim({ + kind: "fact", + body: { name, text }, + provenance: { author }, + t: NOW, + }).claim; putClaim(dir, c); return c; }; @@ -87,9 +92,17 @@ test("dashData: atlas section reports a built atlas", () => { mkdirSync(join(root, ".forge"), { recursive: true }); writeFileSync( join(root, ".forge", "atlas.json"), - JSON.stringify({ version: 2, files: 3, symbols: [{ name: "a" }, { name: "b" }] }), + JSON.stringify({ + version: 2, + files: 3, + symbols: [{ name: "a" }, { name: "b" }], + }), ); - assert.deepEqual(dashData(root, { nowDay: NOW }).atlas, { built: true, symbols: 2, files: 3 }); + assert.deepEqual(dashData(root, { nowDay: NOW }).atlas, { + built: true, + symbols: 2, + files: 3, + }); }); test("dashData: corrupt/missing stores degrade to empty sections, never throw", () => { @@ -187,7 +200,10 @@ test("serve: POST /api/ratify and /api/retract are the two append-only writes", assert.ok(d.ledger.claims.some((c) => c.kind === "decision")); // retract → the claim reads as tombstoned in the payload, reason preserved on disk. - const r2 = await post("/api/retract", { id: contested.id.slice(0, 8), reason: "wrong port" }); + const r2 = await post("/api/retract", { + id: contested.id.slice(0, 8), + reason: "wrong port", + }); assert.equal(r2.status, 200); assert.equal((await r2.json()).ok, true); const d2 = await (await fetch(`${base}/api/data`)).json(); @@ -229,3 +245,171 @@ test("serve: /api/impact traces blast radius once an atlas exists", async () => server.close(); } }); + +// --- dash v2 lenses -------------------------------------------------------- + +const DAY_MS = 86400000; + +test("historyData: metrics bucketed by day×stage, saved summed", () => { + const { root } = fixture(); + const h = historyData(root, { nowDay: NOW }); + assert.equal(h.totals.events, 2, "two metric lines"); + assert.equal(h.stages.cache.saved, 1200); + assert.equal(h.stages.gate.byOutcome.pass, 1); + assert.ok(h.buckets.length >= 1, "at least one day bucket"); + assert.ok(Array.isArray(h.stages.cache.series), "per-stage daily series for sparklines"); + assert.equal(h.window, 90); +}); + +test("historyData: events older than the cap window are dropped", () => { + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + const now = 20000; // a day number + writeFileSync( + join(root, ".forge", "metrics.jsonl"), + `${JSON.stringify({ t: (now - 5) * DAY_MS, stage: "gate", outcome: "pass" })}\n` + + `${JSON.stringify({ t: (now - 200) * DAY_MS, stage: "gate", outcome: "fail" })}\n`, + ); + const h = historyData(root, { nowDay: now, capDays: 90 }); + assert.equal(h.totals.events, 1, "only the in-window event survives"); + assert.equal(h.stages.gate.byOutcome.pass, 1); + assert.equal(h.stages.gate.byOutcome.fail, undefined); +}); + +test("historyData: corrupt/missing metrics degrade to an empty payload", () => { + const empty = historyData(tmp(), { nowDay: NOW }); + assert.deepEqual(empty.buckets, []); + assert.deepEqual(empty.stages, {}); + assert.equal(empty.totals.events, 0); +}); + +test("claimsData: no query ranks live claims by val, with fresh + confidence", () => { + const { root } = fixture(); + const m = claimsData(root, { nowDay: NOW }); + assert.equal(m.total, 3); + assert.ok(m.rows.length >= 2, "tombstoned claim is excluded, live ones shown"); + assert.ok(!m.rows.some((r) => r.tombstoned), "no-query browse shows only live claims"); + for (const r of m.rows) { + assert.equal(typeof r.val, "number"); + assert.equal(typeof r.fresh, "number"); + assert.equal(r.score, null, "no score without a query"); + assert.equal(r.id8.length, 8); + } +}); + +test("claimsData: a query ranks via retrieve (scores present) and kind filters", () => { + const { root, contested } = fixture(); + const m = claimsData(root, { q: "dev server port 4242", nowDay: NOW }); + assert.ok( + m.rows.some((r) => r.id8 === contested.id.slice(0, 8)), + "the matching claim surfaces", + ); + assert.ok( + m.rows.every((r) => typeof r.score === "number"), + "query rows carry a retrieval score", + ); + const facts = claimsData(root, { kind: "fact", nowDay: NOW }); + assert.ok(facts.rows.every((r) => r.kind === "fact")); + const none = claimsData(root, { kind: "nonexistent", nowDay: NOW }); + assert.deepEqual(none.rows, []); +}); + +test("radarData: reads the cache, tolerates shape drift, degrades when absent", () => { + assert.deepEqual(radarData(tmp()), { + present: false, + t: null, + deps: [], + counts: {}, + }); + + const root = tmp(); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync(join(root, ".forge", "radar.json"), "{ not json"); + assert.equal(radarData(root).present, false, "unparseable cache → empty panel"); + + writeFileSync( + join(root, ".forge", "radar.json"), + JSON.stringify({ + t: 1700000000000, + deps: { + // real radar.js shape: `installed` + `reasons`, not `version`/`evidence` + left: { + ring: "hold", + score: 0.9, + installed: "1.0.0", + latest: "3.0.0", + reasons: ["marked deprecated by its maintainer"], + }, + // legacy shape still tolerated + old: { ring: "trial", score: 0.4, version: "2.0.0", latest: "2.5.0" }, + mid: { ring: "trial", score: 0.4 }, + weird: { ring: "??", score: "nope" }, // drift: bad ring, non-numeric score + }, + }), + ); + const r = radarData(root); + assert.equal(r.present, true); + assert.equal(r.deps.length, 4); + assert.equal(r.deps[0].ring, "hold", "hold sorts first"); + assert.equal(r.deps[0].version, "1.0.0", "version reads radar's `installed` field"); + assert.deepEqual( + r.deps[0].reasons, + ["marked deprecated by its maintainer"], + "reasons carry radar's evidence strings", + ); + const old = r.deps.find((d) => d.name === "old"); + assert.equal(old.version, "2.0.0", "legacy `version` field still tolerated"); + assert.equal(r.counts.hold, 1); + assert.equal(r.counts.trial, 2); + assert.equal(r.counts.assess, 1, "drifted ring falls back to assess"); + const weird = r.deps.find((d) => d.name === "weird"); + assert.equal(weird.score, null, "non-numeric score → null, never NaN"); +}); + +test("timelineData: durable mint + tombstone events, newest first", () => { + const { root, retracted } = fixture(); + const t = timelineData(root); + assert.ok(t.events.length >= 4, "3 mints + 1 tombstone"); + assert.ok( + t.events.some((e) => e.type === "retract" && e.id8 === retracted.id.slice(0, 8)), + "the tombstone shows as a retract event", + ); + for (let i = 1; i < t.events.length; i++) + assert.ok(t.events[i - 1].day >= t.events[i].day, "newest first"); +}); + +test("timelineData: broken ledger degrades to no events", () => { + assert.deepEqual(timelineData(tmp()), { count: 0, events: [] }); +}); + +test("serve: v2 endpoints answer 200 with their payloads", async () => { + const { root } = fixture(); + writeFileSync( + join(root, ".forge", "radar.json"), + JSON.stringify({ t: 1, deps: { foo: { ring: "adopt", score: 0.1 } } }), + ); + const server = serve(root, { port: 0 }); + await new Promise((resolve) => server.on("listening", resolve)); + const addr = /** @type {import("node:net").AddressInfo} */ (server.address()); + const base = `http://127.0.0.1:${addr.port}`; + try { + const hist = await (await fetch(`${base}/api/history`)).json(); + assert.equal(hist.totals.events, 2); + + const radar = await (await fetch(`${base}/api/radar`)).json(); + assert.equal(radar.present, true); + assert.equal(radar.deps[0].name, "foo"); + + const tl = await (await fetch(`${base}/api/timeline`)).json(); + assert.ok(tl.events.length >= 3); + + const mem = await (await fetch(`${base}/api/claims?q=port`)).json(); + assert.equal(mem.q, "port"); + assert.ok(mem.rows.every((r) => typeof r.score === "number")); + + const memAll = await (await fetch(`${base}/api/claims`)).json(); + assert.equal(memAll.total, 3); + } finally { + server.close(); + } +}); diff --git a/test/deja.test.js b/test/deja.test.js new file mode 100644 index 0000000..bb2a245 --- /dev/null +++ b/test/deja.test.js @@ -0,0 +1,139 @@ +import assert from "node:assert/strict"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { + buildSummary, + DEJA_FLOOR, + dejaAdvisory, + dejaFromLedger, + dejaLine, + dejaLookup, + recordSessionSummary, +} from "../src/deja.js"; +import { val } from "../src/ledger.js"; +import { loadClaims, repoLedger } from "../src/ledger_store.js"; + +const fixture = () => mkdtempSync(join(tmpdir(), "forge-deja-")); + +// A minimal live claim for the pure lookup/line tests (no fs). +const claim = (kind, text, { t = 100, evidence = [] } = {}) => ({ + v: 1, + id: `id_${kind}_${text}`.replace(/\W+/g, "").slice(0, 40), + kind, + body: { text }, + scope: { level: "repo" }, + provenance: { t }, + evidence, +}); + +test("buildSummary: sorted-unique files, redacted gist, test-pass flag", () => { + const s = buildSummary([ + { type: "prompt", text: "add rate limiting\nto the export route" }, + { type: "edit", file: "src/b.js" }, + { type: "edit", file: "src/a.js" }, + { type: "edit", file: "src/a.js" }, + { type: "bash", command: "npm test", exitCode: 0 }, + ]); + assert.deepEqual(s.files, ["src/a.js", "src/b.js"]); + assert.equal(s.text, "add rate limiting to the export route"); + assert.equal(s.tested, true); +}); + +test("buildSummary: a failing-only test run is not 'tested'; null when empty", () => { + const s = buildSummary([ + { type: "edit", file: "x.js" }, + { type: "bash", command: "npm test", exitCode: 1 }, + ]); + assert.equal(s.tested, false); + assert.equal(buildSummary([]), null); + assert.equal(buildSummary([{ type: "bash", command: "ls" }]), null); +}); + +test("buildSummary: a secret in the first prompt is redacted out of the gist", () => { + const s = buildSummary([ + { + type: "prompt", + text: "wire the client with sk-abcdEFGH1234ijklMNOP secret", + }, + { type: "edit", file: "src/c.js" }, + ]); + assert.ok(!/sk-abcdEFGH1234ijklMNOP/.test(s.text), "raw token must not survive"); + assert.ok(s.text.includes("[REDACTED]")); +}); + +test("dejaLookup only ranks task-shaped kinds (summary/lesson/diagnosis)", () => { + const claims = [ + claim("summary", "add rate limiting to the export route"), + claim("lesson", "update the openapi spec after changing a route"), + claim("fact", "the export route lives in src/export.js"), + claim("edge", "export route rate limiting"), + ]; + const hits = dejaLookup(claims, "add rate limiting to the export route", { + nowDay: 100, + }); + const kinds = new Set(hits.map((h) => h.claim.kind)); + assert.ok(!kinds.has("fact") && !kinds.has("edge"), "ephemeral kinds excluded"); + assert.equal(hits[0].claim.kind, "summary", "the matching summary ranks first"); +}); + +test("dejaLine: floor gate silences noise; verified marker rides evidence", () => { + const strong = { claim: claim("summary", "x"), score: DEJA_FLOOR + 0.1 }; + const weak = { claim: claim("summary", "x"), score: DEJA_FLOOR - 0.01 }; + assert.equal(dejaLine(weak, 100), "", "below floor → silent"); + assert.ok(dejaLine(strong, 100).includes("déjà vu")); + assert.ok(!dejaLine(strong, 100).includes("verified"), "no evidence → not verified"); + + const confirmed = claim("summary", "x", { + evidence: [ + { + oracle: "test.run", + result: "confirm", + ref: "session:1", + t: 100, + h: "abc", + }, + ], + }); + assert.ok(val(confirmed, 100) > 0.5); + assert.ok(dejaLine({ claim: confirmed, score: 0.9 }, 100).includes("(verified)")); +}); + +test("dejaAdvisory: kill switch and empty task both yield silence", () => { + const root = fixture(); + const prev = process.env.FORGE_DEJA; + process.env.FORGE_DEJA = "0"; + assert.equal(dejaAdvisory(root, "anything", 100), ""); + if (prev === undefined) delete process.env.FORGE_DEJA; + else process.env.FORGE_DEJA = prev; + assert.equal(dejaAdvisory(root, " ", 100), ""); +}); + +test("recordSessionSummary mints a retrievable summary; passing tests make it verified", () => { + const root = fixture(); + const events = [ + { type: "prompt", text: "build a paginated users endpoint" }, + { type: "edit", file: "src/users.js" }, + { type: "bash", command: "node --test", exitCode: 0 }, + ]; + const r = recordSessionSummary(root, "sess-A", events, 200); + assert.ok(r.ok && r.id, "a summary claim is minted"); + assert.equal(r.tested, true); + + const stored = loadClaims(repoLedger(root)).find((c) => c.id === r.id); + assert.equal(stored.kind, "summary"); + assert.ok(val(stored, 200) > 0.5, "the confirm outcome pushes val above the 0.5 prior"); + + const hits = dejaFromLedger(root, "paginate the users endpoint", { + nowDay: 200, + }); + assert.equal(hits[0].claim.id, r.id, "the fresh summary is retrievable next session"); +}); + +test("recordSessionSummary is best-effort and returns cleanly on an empty session", () => { + const root = fixture(); + const r = recordSessionSummary(root, "sess-empty", [], 200); + assert.equal(r.ok, false); + assert.deepEqual(dejaFromLedger(root, "anything", { nowDay: 200 }), []); +}); diff --git a/test/harden.test.js b/test/harden.test.js index 2eb264e..8631843 100644 --- a/test/harden.test.js +++ b/test/harden.test.js @@ -1,8 +1,9 @@ import assert from "node:assert/strict"; -import { mkdirSync, mkdtempSync, readFileSync } 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 { BRAND } from "../src/brand.js"; import { harden } from "../src/harden.js"; test("harden writes a mergeable sandbox block and reports gitleaks status", () => { @@ -17,5 +18,40 @@ test("harden writes a mergeable sandbox block and reports gitleaks status", () = test("harden reports 'not a git repo' outside a repo", () => { const root = mkdtempSync(join(tmpdir(), "forge-harden-")); - assert.match(harden({ targetRoot: root }).gitleaks, /not a git repo/); + const report = harden({ targetRoot: root }); + assert.match(report.gitleaks, /not a git repo/); + assert.match(report.precommit, /not a git repo/); +}); + +test("harden installs the commit-gate pre-commit hook (gitleaks optional, gate always)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-harden-")); + mkdirSync(join(root, ".git"), { recursive: true }); + const report = harden({ targetRoot: root }); + assert.match(report.precommit, /installed pre-commit/); + const hook = readFileSync(join(root, ".git", "hooks", "pre-commit"), "utf8"); + assert.match(hook, new RegExp(`installed by ${BRAND.cli} harden`), "ownership marker present"); + assert.match(hook, /gitleaks protect --staged/, "gitleaks runs first when installed"); + assert.match(hook, /precommit/, "the commit gate is the second rung"); + assert.match(hook, /command -v node .*\|\| exit 0/, "fail-open when node is missing"); +}); + +test("harden never clobbers a user-authored pre-commit hook (writes beside it)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-harden-")); + const hooks = join(root, ".git", "hooks"); + mkdirSync(hooks, { recursive: true }); + const userHook = "#!/bin/sh\necho my own checks\n"; + writeFileSync(join(hooks, "pre-commit"), userHook); + const report = harden({ targetRoot: root }); + assert.match(report.precommit, /existing pre-commit kept/); + assert.equal(readFileSync(join(hooks, "pre-commit"), "utf8"), userHook, "user hook untouched"); + assert.ok(existsSync(join(hooks, `pre-commit.${BRAND.cli}`)), "ours lands beside it"); +}); + +test("harden re-running overwrites its OWN hook (marker check, idempotent)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-harden-")); + mkdirSync(join(root, ".git"), { recursive: true }); + harden({ targetRoot: root }); + const report = harden({ targetRoot: root }); + assert.match(report.precommit, /installed pre-commit/, "second run rewrites, not sidesteps"); + assert.ok(!existsSync(join(root, ".git", "hooks", `pre-commit.${BRAND.cli}`))); }); diff --git a/test/knowledge_router.test.js b/test/knowledge_router.test.js new file mode 100644 index 0000000..162a5ad --- /dev/null +++ b/test/knowledge_router.test.js @@ -0,0 +1,179 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { existsSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { + FALLBACK_HOME, + factName, + HOME_EXEMPLARS, + HOMES, + routeFact, + storeFact, +} from "../src/knowledge_router.js"; +import { loadClaims, repoLedger } from "../src/ledger_store.js"; + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +test("routeFact: every home recognized from unseen phrasings (per-family routing)", () => { + assert.equal(routeFact("always run the linter before committing changes").home, "claude-md"); + assert.equal(routeFact("never push directly to the release branch").home, "rule"); + assert.equal(routeFact("how to restore the production database from backup").home, "skill"); + assert.equal(routeFact("next step is to finish pagination in the export endpoint").home, "state"); + assert.equal( + routeFact("we chose redis over rabbitmq because it is already deployed").home, + "decision", + ); + assert.equal(routeFact("the api gateway times out after 30 seconds").home, "ledger-fact"); + assert.equal(routeFact("i prefer detailed commit messages").home, "recall"); +}); + +test("routeFact: TOTAL (T6) — 100 arbitrary strings all land in a real home", () => { + // Deterministic LCG so the fuzz corpus is reproducible run-to-run. + let seed = 42; + const rnd = () => { + seed = (seed * 1103515245 + 12345) % 2 ** 31; + return seed / 2 ** 31; + }; + const alphabets = [ + "abcdefghijklmnopqrstuvwxyz ", + "0123456789 -_/", + "日本語テキスト漢字 ", + "🎉🚀💥🔥 ", + "{}[]()<>!?#$%^&* ", + ]; + const corpus = ["", " ", "???", "a", "\n\t"]; + while (corpus.length < 100) { + const alpha = alphabets[Math.floor(rnd() * alphabets.length)]; + const len = Math.floor(rnd() * 80); + let s = ""; + for (let i = 0; i < len; i++) s += alpha[Math.floor(rnd() * alpha.length)]; + corpus.push(s); + } + for (const s of corpus) { + const r = routeFact(s); + assert.ok(r.home in HOMES, `"${s.slice(0, 30)}" routed to unknown home ${r.home}`); + assert.ok(["knn", "fallback"].includes(r.provenance)); + assert.ok(r.confidence >= 0 && r.confidence <= 1); + } +}); + +test("routeFact: below the gate → the ledger fallback with provenance, never 'none'", () => { + const r = routeFact("zzz qqq xyzzy plugh"); + assert.equal(r.home, FALLBACK_HOME); + assert.equal(r.provenance, "fallback"); + const hi = routeFact("we chose sqlite over postgres because zero ops"); + assert.equal(hi.provenance, "knn"); + assert.ok(hi.neighbors.length > 0 && hi.neighbors[0].sim >= hi.confidence - 1e-9); +}); + +test("HOMES/exemplars: every exemplar row labels a real home; bank covers all homes", () => { + const labeled = new Set(HOME_EXEMPLARS.map((e) => e.home)); + for (const h of labeled) assert.ok(h in HOMES, `exemplar home ${h} not in HOMES`); + for (const h of Object.keys(HOMES)) assert.ok(labeled.has(h), `home ${h} has no exemplars`); + assert.ok(HOMES[FALLBACK_HOME].write === "auto", "fallback home must be writable (totality)"); +}); + +test("storeFact: dry-run (mode advise) never writes, whatever the home", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + for (const text of [ + "we chose sqlite over postgres because zero ops", + "the api rate limit is 100 requests per minute", + "zzz qqq xyzzy plugh", + ]) { + const r = storeFact(root, text, { mode: "advise" }); + assert.equal(r.ok, true); + assert.equal(r.stored, false); + assert.ok(r.advice, "advise mode explains where the fact belongs"); + } + assert.ok(!existsSync(join(root, ".forge")), "dry-run created .forge/"); +}); + +test("storeFact: advise-only homes (files) get advice, no write — even in auto mode", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const r = storeFact(root, "next step is to finish pagination in the export endpoint"); + assert.equal(r.home, "state"); + assert.equal(r.stored, false); + assert.match(r.advice ?? "", /handoff/); + assert.ok(!existsSync(join(root, ".forge"))); +}); + +test("storeFact: secret refusal — routed but never stored, nothing touches disk", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const r = storeFact(root, "we chose this api_key=sk-abcdefghijklmnopqrstuvwx for staging"); + assert.equal(r.ok, false); + assert.equal(r.refused, true); + assert.equal(r.stored, false); + assert.ok(!existsSync(join(root, ".forge")), "a refused secret still wrote something"); +}); + +test("storeFact: decision home appends the ADR-lite line", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const r = storeFact(root, "we chose sqlite over postgres because zero ops"); + assert.equal(r.ok, true); + assert.equal(r.home, "decision"); + assert.equal(r.stored, true); + assert.equal(r.ref, "D-0001"); + const log = readFileSync(join(root, ".forge", "decisions.md"), "utf8"); + assert.match(log, /\*\*D-0001\*\*.*we chose sqlite over postgres because zero ops/); +}); + +test("storeFact: ledger-fact home (and the T6 fallback) mint a fact claim", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const fact = storeFact(root, "the api rate limit is 100 requests per minute"); + assert.equal(fact.home, "ledger-fact"); + assert.equal(fact.stored, true); + const fb = storeFact(root, "zzz qqq xyzzy plugh"); + assert.equal(fb.home, "ledger-fact"); + assert.equal(fb.stored, true); + const claims = loadClaims(repoLedger(root)); + const bodies = claims.filter((c) => c.kind === "fact").map((c) => c.body?.text); + assert.ok(bodies.includes("the api rate limit is 100 requests per minute")); + assert.ok(bodies.includes("zzz qqq xyzzy plugh")); +}); + +test("storeFact: recall home writes the personal store (FORGE_HOME-scoped)", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const home = mkdtempSync(join(tmpdir(), "forge-know-home-")); + process.env.FORGE_HOME = home; + try { + const r = storeFact(root, "i prefer detailed commit messages"); + assert.equal(r.home, "recall"); + assert.equal(r.stored, true); + const slugged = factName("i prefer detailed commit messages"); + assert.ok(existsSync(join(home, "recall", "facts", `${slugged}.md`))); + assert.ok(!existsSync(join(root, ".forge")), "recall must not touch the repo ledger"); + } finally { + delete process.env.FORGE_HOME; + } +}); + +test("factName: short stable slug, never empty", () => { + assert.equal( + factName("The API rate limit is 100 requests per minute"), + "the-api-rate-limit-is-100", + ); + assert.equal(factName("???"), "fact"); +}); + +test("cli: forge know --dry-run --json routes without writing", () => { + const root = mkdtempSync(join(tmpdir(), "forge-know-")); + const p = spawnSync( + "node", + [CLI, "know", "we chose sqlite because zero dependencies", "--dry-run", "--json"], + { cwd: root, encoding: "utf8" }, + ); + assert.equal(p.status, 0, p.stderr); + const out = JSON.parse(p.stdout); + assert.equal(out.home, "decision"); + assert.equal(out.dryRun, true); + assert.equal(out.stored, false); + assert.ok(!existsSync(join(root, ".forge")), "--dry-run wrote to disk"); + const empty = spawnSync("node", [CLI, "know"], { + cwd: root, + encoding: "utf8", + }); + assert.equal(empty.status, 1, "bare know without a fact must usage-error"); +}); diff --git a/test/ledger_sync.test.js b/test/ledger_sync.test.js new file mode 100644 index 0000000..73d417e --- /dev/null +++ b/test/ledger_sync.test.js @@ -0,0 +1,300 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdtempSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { canonicalize, mintClaim } from "../src/ledger.js"; +import { loadClaims, loadState, putClaim } from "../src/ledger_store.js"; +import { defaultRun, ledgerSync, stateBytes, syncDir, syncTarget } from "../src/ledger_sync.js"; + +const tmp = (p = "forge-sync-") => mkdtempSync(join(tmpdir(), p)); +const git = (cwd, ...args) => execFileSync("git", args, { cwd, encoding: "utf8" }).trim(); + +/** A git work-tree with an identity, ready to hold a `.forge/ledger` and talk to a remote. */ +function initRepo() { + const dir = tmp("forge-sync-repo-"); + git(dir, "init", "-q"); + git(dir, "config", "user.name", "Tester"); + git(dir, "config", "user.email", "tester@example.com"); + return dir; +} +function initBare() { + const dir = tmp("forge-sync-bare-"); + git(dir, "init", "-q", "--bare"); + return dir; +} +/** The ledger dir lives inside the work-tree so ref-mode plumbing runs against it. */ +const ledgerOf = (repo) => join(repo, ".forge", "ledger"); +const fact = (name, text, t = 0) => + mintClaim({ + kind: "fact", + body: { name, text }, + provenance: { author: "tester" }, + t, + }).claim; +const mint = (dir, name, text) => putClaim(dir, fact(name, text)); + +// ── 1. target discovery ──────────────────────────────────────────────────── +test("syncTarget: --dir wins over everything", () => { + const t = syncTarget({ + dirTarget: "/some/dir", + remote: "origin", + env: { FORGE_SYNC_DIR: "/x" }, + }); + assert.deepEqual(t, { mode: "dir", dir: "/some/dir" }); +}); + +test("syncTarget: --remote/--ref force ref mode with sensible defaults", () => { + assert.deepEqual(syncTarget({ remote: "upstream" }), { + mode: "ref", + remote: "upstream", + ref: "refs/forge/ledger", + }); + assert.deepEqual(syncTarget({ ref: "refs/x" }), { + mode: "ref", + remote: "origin", + ref: "refs/x", + }); + assert.equal(syncTarget({ personal: true }).ref === "refs/forge/ledger", false); + assert.equal(syncTarget({ remote: "origin", personal: true }).ref, "refs/forge/ledger-personal"); +}); + +test("syncTarget: a repo git remote → ref mode with the first remote", () => { + const t = syncTarget({ run: () => "origin\nupstream", env: {} }); + assert.deepEqual(t, { + mode: "ref", + remote: "origin", + ref: "refs/forge/ledger", + }); +}); + +test("syncTarget: FORGE_SYNC_DIR is the fallback dir target when there is no remote", () => { + const t = syncTarget({ + run: () => "", + env: { FORGE_SYNC_DIR: "/shared/ledger" }, + }); + assert.deepEqual(t, { mode: "dir", dir: "/shared/ledger" }); +}); + +test("syncTarget: nothing resolves → honest none (no throw)", () => { + const t = syncTarget({ + run: () => { + throw new Error("not a repo"); + }, + env: {}, + }); + assert.equal(t.mode, "none"); + assert.match(t.reason, /no sync target/); +}); + +// ── 2. dir mode bidirectional convergence ────────────────────────────────── +test("syncDir: bidirectional union → both dirs byte-identical", () => { + const a = tmp(); + const b = tmp(); + mint(a, "x", "known only to A"); + mint(b, "y", "known only to B"); + const r = syncDir(a, b); + assert.equal(r.ok, true); + assert.equal(r.mode, "dir"); + const idsA = loadClaims(a) + .map((c) => c.body.name) + .sort(); + const idsB = loadClaims(b) + .map((c) => c.body.name) + .sort(); + assert.deepEqual(idsA, ["x", "y"]); + assert.deepEqual(idsB, ["x", "y"]); + assert.equal(canonicalize(loadState(a)), canonicalize(loadState(b)), "byte-identical state"); +}); + +test("syncDir: missing target degrades honestly (no throw)", () => { + const a = tmp(); + const r = syncDir(a, join(tmp(), "does-not-exist")); + assert.equal(r.ok, false); + assert.match(r.reason, /no sync directory/); +}); + +// ── 3. ref-mode two-repo convergence ─────────────────────────────────────── +test("ref sync: two repos converge to byte-identical state through a git ref", () => { + const bare = initBare(); + const repoA = initRepo(); + const repoB = initRepo(); + git(repoA, "remote", "add", "origin", bare); + git(repoB, "remote", "add", "origin", bare); + mint(ledgerOf(repoA), "x", "from A"); + mint(ledgerOf(repoB), "y", "from B"); + + const r1 = ledgerSync({ dir: ledgerOf(repoA), root: repoA }); + assert.equal(r1.ok, true); + assert.equal(r1.mode, "ref"); + assert.equal(r1.pushed, true); + + const r2 = ledgerSync({ dir: ledgerOf(repoB), root: repoB }); + assert.equal(r2.ok, true); + assert.equal(r2.pulled.claims, 1, "B pulled A's claim"); + assert.equal(r2.pushed, true); + + const r3 = ledgerSync({ dir: ledgerOf(repoA), root: repoA }); + assert.equal(r3.ok, true); + assert.equal(r3.pulled.claims, 1, "A pulled B's claim on the second round"); + + assert.equal( + canonicalize(loadState(ledgerOf(repoA))), + canonicalize(loadState(ledgerOf(repoB))), + "convergent state", + ); + const treeA = git(repoA, "rev-parse", "refs/forge/ledger^{tree}"); + const treeB = git(repoB, "rev-parse", "refs/forge/ledger^{tree}"); + assert.equal(treeA, treeB, "identical state.json tree in both clones"); +}); + +// ── 4. idempotence ───────────────────────────────────────────────────────── +test("ref sync: an immediate re-run is a byte-level no-op", () => { + const bare = initBare(); + const repo = initRepo(); + git(repo, "remote", "add", "origin", bare); + mint(ledgerOf(repo), "x", "only claim"); + assert.equal(ledgerSync({ dir: ledgerOf(repo), root: repo }).pushed, true); + const sha1 = git(repo, "rev-parse", "refs/forge/ledger"); + + const again = ledgerSync({ dir: ledgerOf(repo), root: repo }); + assert.equal(again.ok, true); + assert.equal(again.upToDate, true); + assert.equal(again.pushed, false); + assert.equal(again.pulled.claims, 0); + assert.equal(git(repo, "rev-parse", "refs/forge/ledger"), sha1, "ref SHA unchanged"); +}); + +// ── 5. race retry (monotone) ─────────────────────────────────────────────── +test("ref sync: a non-fast-forward race re-merges and retries, losing nothing", () => { + const bare = initBare(); + const repoA = initRepo(); + const repoB = initRepo(); + git(repoA, "remote", "add", "origin", bare); + git(repoB, "remote", "add", "origin", bare); + mint(ledgerOf(repoA), "x", "from A"); + mint(ledgerOf(repoB), "y", "from B"); + + let injected = false; + const racyRun = (args, opts) => { + if (!injected && args[0] === "push") { + injected = true; + // A concurrent writer (repoB) lands on the ref first → A's push must reject. + ledgerSync({ + dir: ledgerOf(repoB), + root: repoB, + remote: "origin", + run: defaultRun, + }); + } + return defaultRun(args, opts); + }; + + const r = ledgerSync({ + dir: ledgerOf(repoA), + root: repoA, + remote: "origin", + run: racyRun, + }); + assert.equal(r.ok, true, "the race is resolved, not fatal"); + assert.equal(r.retries, 1); + assert.equal(r.pushed, true); + // Nothing lost: A now holds both claims and the remote reflects the union. + const names = loadClaims(ledgerOf(repoA)) + .map((c) => c.body.name) + .sort(); + assert.deepEqual(names, ["x", "y"]); +}); + +// ── 6. honesty / fail-open ───────────────────────────────────────────────── +test("ref sync: not a git repo → honest reason (no throw)", () => { + const dir = tmp(); + const r = ledgerSync({ dir, root: dir, remote: "origin" }); + assert.equal(r.ok, false); + assert.match(r.reason, /not a git repository/); +}); + +test("ref sync: unknown remote → honest reason", () => { + const repo = initRepo(); + const r = ledgerSync({ dir: ledgerOf(repo), root: repo, remote: "nope" }); + assert.equal(r.ok, false); + assert.match(r.reason, /no such git remote/); +}); + +test("ref sync: a fetch failure (offline) fails open", () => { + const repo = initRepo(); + const bare = initBare(); + git(repo, "remote", "add", "origin", bare); + const offlineRun = (args, opts) => { + if (args[0] === "fetch") + throw Object.assign(new Error("boom"), { stderr: "network unreachable" }); + return defaultRun(args, opts); + }; + const r = ledgerSync({ + dir: ledgerOf(repo), + root: repo, + remote: "origin", + run: offlineRun, + }); + assert.equal(r.ok, false); + assert.match(r.reason, /fetch failed/); +}); + +test("ref sync: a corrupt remote state blob degrades to empty + note, push proceeds", () => { + const bare = initBare(); + const repo = initRepo(); + git(repo, "remote", "add", "origin", bare); + // Plant a garbage state.json on the ref (a concurrent writer's corruption). + const blob = execFileSync("git", ["hash-object", "-w", "--stdin"], { + cwd: repo, + input: "not json {{{", + encoding: "utf8", + }).trim(); + const tree = execFileSync("git", ["mktree"], { + cwd: repo, + input: `100644 blob ${blob}\tstate.json\n`, + encoding: "utf8", + }).trim(); + const commit = git(repo, "commit-tree", tree, "-m", "garbage"); + git(repo, "update-ref", "refs/forge/ledger", commit); + git(repo, "push", "origin", "refs/forge/ledger:refs/forge/ledger"); + + mint(ledgerOf(repo), "x", "still fine"); + const r = ledgerSync({ dir: ledgerOf(repo), root: repo, remote: "origin" }); + assert.equal(r.ok, true); + assert.equal(r.pulled.claims, 0); + assert.ok( + r.notes.some((n) => /unreadable/.test(n)), + "corruption is noted", + ); + assert.equal(loadClaims(ledgerOf(repo)).length, 1, "local claim survived"); +}); + +// ── 7. --personal recall portability ─────────────────────────────────────── +test("--personal: recall-shadowed facts ride the CRDT pipe across stores", async () => { + const { shadowFact } = await import("../src/ledger_bridge.js"); + const { ledgerFacts } = await import("../src/ledger_read.js"); + // Two "machines": each has its own personal store with a ledger sibling dir. + const storeA = tmp("forge-sync-storeA-"); + const storeB = tmp("forge-sync-storeB-"); + const ledgerA = join(storeA, "ledger"); + const ledgerB = join(storeB, "ledger"); + shadowFact(ledgerA, "laptop path", "~/dev on the laptop", 1); + shadowFact(ledgerB, "desktop path", "~/work on the desktop", 1); + + const r = syncDir(ledgerA, ledgerB); + assert.equal(r.ok, true); + const namesFromB = ledgerFacts(ledgerB) + .map((f) => f.name) + .sort(); + assert.deepEqual(namesFromB, ["desktop path", "laptop path"], "A's personal fact reached B"); +}); + +// stateBytes is the canonical payload — deterministic across an idempotent reload. +test("stateBytes: deterministic and newline-terminated", () => { + const a = tmp(); + mint(a, "x", "y"); + assert.equal(stateBytes(a), stateBytes(a)); + assert.ok(stateBytes(a).endsWith("\n")); +}); diff --git a/test/radar.test.js b/test/radar.test.js new file mode 100644 index 0000000..56e15cd --- /dev/null +++ b/test/radar.test.js @@ -0,0 +1,515 @@ +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { loadClaims, repoLedger } from "../src/ledger_store.js"; +import { read as readMetrics } from "../src/metrics.js"; +import { + classifyDep, + depsFromManifests, + probeRegistry, + radarAdvisory, + radarScan, + readRadarCache, + usageFromAtlas, +} from "../src/radar.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "forge-radar-")); +const DAY = 86400000; +const NOW_DAY = 20000; + +// A fake registry: dispatches on URL. metadata GET + bulk advisories POST. Records the +// URLs it saw so tests can prove a scan did (or did NOT) hit the network. +function fakeFetch(catalog, { advisories = {}, seen } = {}) { + return async (url) => { + if (seen) seen.push(url); + if (url.includes("/-/npm/v1/security/advisories/bulk")) { + return { + ok: true, + status: 200, + async json() { + return advisories; + }, + }; + } + // metadata: last path segment is the (encoded) package name + const name = decodeURIComponent(url.split("/").pop()); + const entry = catalog[name]; + if (!entry) + return { + ok: false, + status: 404, + async json() { + return {}; + }, + }; + return { + ok: true, + status: 200, + async json() { + return entry; + }, + }; + }; +} + +const throwingFetch = () => { + throw new Error("network touched — cache should have served"); +}; + +// --- classifyDep ----------------------------------------------------------- + +test("classifyDep: deprecated → hold regardless of freshness", () => { + const c = classifyDep( + { + installed: "5.0.0", + latest: "5.0.0", + publishedAt: NOW_DAY * DAY, + deprecated: true, + advisories: [], + }, + NOW_DAY, + ); + assert.equal(c.ring, "hold"); + assert.match(c.reasons.join(" "), /deprecated/); +}); + +test("classifyDep: critical advisory → hold even when fresh", () => { + const c = classifyDep( + { + installed: "5.0.0", + latest: "5.0.0", + publishedAt: NOW_DAY * DAY, + deprecated: false, + advisories: [{ severity: "critical", title: "RCE" }], + }, + NOW_DAY, + ); + assert.equal(c.ring, "hold"); +}); + +test("classifyDep: fresh + no advisories + ≥2 evidence kinds → adopt", () => { + const c = classifyDep( + { + installed: "3.0.0", + latest: "3.0.0", + publishedAt: NOW_DAY * DAY, + deprecated: false, + advisories: [], + }, + NOW_DAY, + ); + assert.equal(c.evidenceKinds, 4); + assert.equal(c.ring, "adopt"); + assert.ok(c.score < 0.25); +}); + +test("classifyDep: single evidence kind → assess (never adopt on absence)", () => { + const c = classifyDep( + { + installed: null, + latest: null, + publishedAt: null, + deprecated: null, + advisories: [], + }, + NOW_DAY, + ); + assert.equal(c.evidenceKinds, 1); + assert.equal(c.ring, "assess"); +}); + +test("classifyDep: score bounded [0,1] and monotone in staleness", () => { + const fresh = classifyDep( + { + installed: "1.0.0", + latest: "1.0.0", + publishedAt: NOW_DAY * DAY, + deprecated: false, + advisories: [], + }, + NOW_DAY, + ); + const stale = classifyDep( + { + installed: "1.0.0", + latest: "1.0.0", + publishedAt: (NOW_DAY - 1000) * DAY, + deprecated: false, + advisories: [], + }, + NOW_DAY, + ); + for (const c of [fresh, stale]) assert.ok(c.score >= 0 && c.score <= 1); + assert.ok(stale.score > fresh.score, "older publish → higher staleness → higher score"); +}); + +// --- depsFromManifests ----------------------------------------------------- + +test("depsFromManifests: package.json + lock v3 → names/ranges/installed", () => { + const root = tmp(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + dependencies: { got: "^14.0.0" }, + devDependencies: { vitest: "^1.0.0" }, + }), + ); + writeFileSync( + join(root, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { + "node_modules/got": { version: "14.4.5" }, + "node_modules/vitest": { version: "1.6.0" }, + }, + }), + ); + const { deps, skipped } = depsFromManifests(root); + assert.equal(deps.got.range, "^14.0.0"); + assert.equal(deps.got.source, "dependencies"); + assert.equal(deps.got.installed, "14.4.5"); + assert.equal(deps.vitest.source, "devDependencies"); + assert.equal(deps.vitest.installed, "1.6.0"); + assert.equal(skipped.length, 0); +}); + +test("depsFromManifests: workspace/file ranges flagged local (unprobed)", () => { + const root = tmp(); + writeFileSync( + join(root, "package.json"), + JSON.stringify({ + dependencies: { a: "workspace:*", b: "file:../b", c: "^1.0.0" }, + }), + ); + const { deps } = depsFromManifests(root); + assert.equal(deps.a.local, true); + assert.equal(deps.b.local, true); + assert.equal(deps.c.local, false); +}); + +test("depsFromManifests: non-Node repo → honest skipped entry; corrupt never throws", () => { + const root = tmp(); + writeFileSync(join(root, "go.mod"), "module example.com/x\n"); + const { deps, skipped } = depsFromManifests(root); + assert.equal(Object.keys(deps).length, 0); + assert.ok(skipped.some((s) => s.language === "Go")); + // corrupt package.json → no throw, empty deps + writeFileSync(join(root, "package.json"), "{ not json"); + assert.doesNotThrow(() => depsFromManifests(root)); +}); + +// --- probeRegistry --------------------------------------------------------- + +test("probeRegistry: metadata + bulk advisories parsed", async () => { + const seen = []; + const fetchImpl = fakeFetch( + { + got: { + "dist-tags": { latest: "14.4.5" }, + versions: { "14.4.5": {} }, + time: { "14.4.5": new Date((NOW_DAY - 100) * DAY).toISOString() }, + }, + }, + { + advisories: { got: [{ severity: "high", title: "SSRF", url: "u" }] }, + seen, + }, + ); + const r = await probeRegistry(["got"], { + fetchImpl, + installed: { got: "9.0.0" }, + }); + assert.equal(r.ok, true); + assert.equal(r.results.got.latest, "14.4.5"); + assert.equal(r.results.got.deprecated, false); + assert.equal(r.results.got.advisories[0].severity, "high"); + assert.ok(seen.some((u) => u.includes("advisories/bulk"))); +}); + +test("probeRegistry: advisories endpoint failure → advisory evidence MISSING (degrade to assess)", async () => { + const base = fakeFetch({ + got: { + "dist-tags": { latest: "14.0.0" }, + versions: { "14.0.0": {} }, + time: { "14.0.0": new Date((NOW_DAY - 10) * DAY).toISOString() }, + }, + }); + const fetchImpl = async (url, opts) => { + if (url.includes("advisories/bulk")) + return { + ok: false, + status: 500, + async json() { + return {}; + }, + }; + return base(url, opts); + }; + const r = await probeRegistry(["got"], { + fetchImpl, + installed: { got: "14.0.0" }, + }); + assert.equal(r.results.got.advisories, null, "failed advisories probe → null, not []"); + const c = classifyDep(r.results.got, NOW_DAY); + assert.ok(c.evidenceKinds < 4); +}); + +// --- usageFromAtlas -------------------------------------------------------- + +test("usageFromAtlas: counts imports edges by bare + subpath + member; missing atlas → 0s", () => { + const root = tmp(); + assert.deepEqual(usageFromAtlas(root, ["got"]), { got: 0 }); + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync( + join(root, ".forge", "atlas.json"), + JSON.stringify({ + edges: [ + { kind: "imports", target: "got" }, + { kind: "imports", target: "got/dist/source" }, + { kind: "imports", target: "got.default" }, + { kind: "imports", target: "gotcha" }, + { kind: "calls", target: "got" }, + ], + }), + ); + assert.equal(usageFromAtlas(root, ["got"]).got, 3); +}); + +// --- radarScan cache + I4 -------------------------------------------------- + +function seedRepo(root, { latest = "14.4.5", installed = "9.0.0" } = {}) { + writeFileSync(join(root, "package.json"), JSON.stringify({ dependencies: { got: "^9.0.0" } })); + writeFileSync( + join(root, "package-lock.json"), + JSON.stringify({ + lockfileVersion: 3, + packages: { "node_modules/got": { version: installed } }, + }), + ); + return fakeFetch({ + got: { + "dist-tags": { latest }, + versions: { [latest]: {} }, + time: { [latest]: new Date((NOW_DAY - 30) * DAY).toISOString() }, + }, + }); +} + +test("radarScan: writes cache; second scan within TTL serves cache (no fetch); refresh re-probes", async () => { + const root = tmp(); + const fetchImpl = seedRepo(root); + const first = await radarScan(root, { + fetchImpl, + now: NOW_DAY * DAY, + nowDay: NOW_DAY, + }); + assert.equal(first.ok, true); + assert.equal(first.source, "network"); + assert.ok(readRadarCache(root)); + + const cached = await radarScan(root, { + fetchImpl: throwingFetch, + now: NOW_DAY * DAY, + nowDay: NOW_DAY, + }); + assert.equal(cached.source, "cache", "within TTL → cache, no fetch"); + + const refreshed = await radarScan(root, { + fetchImpl, + now: NOW_DAY * DAY, + nowDay: NOW_DAY, + refresh: true, + }); + assert.equal(refreshed.source, "network"); +}); + +test("radarScan: offline with no cache → ok:false honest; expired TTL + dead net → stale-served", async () => { + const root = tmp(); + const off = await radarScan(root, { + offline: true, + now: NOW_DAY * DAY, + nowDay: NOW_DAY, + }); + assert.equal(off.ok, false); + assert.match(off.reason, /offline|no cache/i); + + const fetchImpl = seedRepo(root); + await radarScan(root, { fetchImpl, now: NOW_DAY * DAY, nowDay: NOW_DAY }); + // 48h later with a dead network → stale-served cache + const later = NOW_DAY * DAY + 48 * 3600000; + const stale = await radarScan(root, { + fetchImpl: async () => ({ + ok: false, + status: 500, + async json() { + return {}; + }, + }), + now: later, + nowDay: NOW_DAY + 2, + }); + assert.equal(stale.ok, true); + assert.equal(stale.stale, true); + assert.ok(stale.ageH >= 47); +}); + +test("radarScan: FORGE_RADAR_TTL_H honored", async () => { + const root = tmp(); + const fetchImpl = seedRepo(root); + await radarScan(root, { fetchImpl, now: NOW_DAY * DAY, nowDay: NOW_DAY }); + process.env.FORGE_RADAR_TTL_H = "1"; + try { + // 2h later, TTL=1h → cache is stale, so a throwing fetch would blow up unless offline. + const later = NOW_DAY * DAY + 2 * 3600000; + const r = await radarScan(root, { + offline: true, + now: later, + nowDay: NOW_DAY, + }); + assert.equal(r.stale, true, "past a 1h TTL the cache is stale"); + } finally { + delete process.env.FORGE_RADAR_TTL_H; + } +}); + +test("radarScan I4: ledger gains a live currency fact; re-scan supersedes; metrics line recorded", async () => { + const root = tmp(); + process.env.FORGE_AUTHOR = "radar-test "; + try { + const fetchImpl = seedRepo(root, { latest: "9.0.0", installed: "9.0.0" }); // fresh → adopt + await radarScan(root, { fetchImpl, now: NOW_DAY * DAY, nowDay: NOW_DAY }); + let facts = loadClaims(repoLedger(root)).filter( + (c) => c.kind === "fact" && !c.tombstone && c.body?.name === "currency:got", + ); + assert.equal(facts.length, 1, "one live currency fact"); + const firstText = facts[0].body.text; + + // re-scan with a much older latest → ring changes → supersede keeps ONE live fact + const fetch2 = seedRepo(root, { latest: "99.0.0", installed: "9.0.0" }); + await radarScan(root, { + fetchImpl: fetch2, + now: NOW_DAY * DAY, + nowDay: NOW_DAY, + refresh: true, + }); + facts = loadClaims(repoLedger(root)).filter( + (c) => c.kind === "fact" && !c.tombstone && c.body?.name === "currency:got", + ); + assert.equal(facts.length, 1, "supersede → still exactly one live currency fact"); + assert.notEqual(facts[0].body.text, firstText, "latest verification replaced the old one"); + + const radarMetrics = readMetrics(root, { stage: "radar" }); + assert.ok(radarMetrics.length >= 1, "a stage:radar metrics line was recorded"); + } finally { + delete process.env.FORGE_AUTHOR; + } +}); + +// --- radarAdvisory + hook e2e --------------------------------------------- + +function seedHoldCache(root, dep = "left-pad") { + mkdirSync(join(root, ".forge"), { recursive: true }); + writeFileSync( + join(root, ".forge", "radar.json"), + JSON.stringify({ + t: Date.now(), + generatedDay: NOW_DAY, + deps: { + [dep]: { + ring: "hold", + score: 1, + installed: "1.3.0", + latest: "1.3.0", + reasons: ["marked deprecated by its maintainer"], + evidenceKinds: 4, + hasAdvisory: false, + }, + }, + skipped: [], + }), + ); +} + +test("radarAdvisory: names dep + file for an importing file; silent otherwise", () => { + const root = tmp(); + seedHoldCache(root); + const f = join(root, "src", "x.js"); + mkdirSync(join(root, "src"), { recursive: true }); + writeFileSync(f, "import leftPad from 'left-pad';\n"); + const msg = radarAdvisory(root, f); + assert.match(msg, /left-pad/); + assert.match(msg, /x\.js/); + + // FORGE_RADAR=0 kill switch + process.env.FORGE_RADAR = "0"; + try { + assert.equal(radarAdvisory(root, f), ""); + } finally { + delete process.env.FORGE_RADAR; + } + + // non-importing file → "" (low-nag) + const g = join(root, "src", "y.js"); + writeFileSync(g, "import { readFileSync } from 'node:fs';\n"); + assert.equal(radarAdvisory(root, g), ""); + + // no cache → "" + assert.equal(radarAdvisory(tmp(), f), ""); +}); + +const HOOK = fileURLToPath(new URL("../src/cortex_hook_main.js", import.meta.url)); + +test("hook e2e: pre-edit surfaces the currency advisory; fail-safe with no cache", () => { + const root = tmp(); + seedHoldCache(root); + mkdirSync(join(root, "src"), { recursive: true }); + const f = join(root, "src", "x.js"); + writeFileSync(f, "import leftPad from 'left-pad';\n"); + const r = spawnSync("node", [HOOK, "pre-edit"], { + input: JSON.stringify({ cwd: root, tool_input: { file_path: f } }), + encoding: "utf8", + timeout: 10000, + }); + assert.equal(r.status, 0); + const out = JSON.parse(r.stdout); + assert.match(out.hookSpecificOutput.additionalContext, /left-pad/); + + // no cache → exit 0, no output + const bare = spawnSync("node", [HOOK, "pre-edit"], { + input: JSON.stringify({ cwd: tmp(), tool_input: { file_path: f } }), + encoding: "utf8", + timeout: 10000, + }); + assert.equal(bare.status, 0); +}); + +// --- CLI e2e --------------------------------------------------------------- + +const CLI = fileURLToPath(new URL("../src/cli.js", import.meta.url)); + +test("CLI: `radar --offline` with seeded cache → table with ring; --json parses", () => { + const root = tmp(); + seedHoldCache(root, "got"); + const r = spawnSync("node", [CLI, "radar", "--offline"], { + cwd: root, + encoding: "utf8", + env: { ...process.env, NO_COLOR: "1" }, + timeout: 10000, + }); + assert.equal(r.status, 0); + assert.match(r.stdout, /hold/); + assert.match(r.stdout, /got/); + + const j = spawnSync("node", [CLI, "radar", "--offline", "--json"], { + cwd: root, + encoding: "utf8", + timeout: 10000, + }); + const parsed = JSON.parse(j.stdout); + assert.equal(parsed.ok, true); + assert.ok(parsed.deps.got); +}); diff --git a/test/report.test.js b/test/report.test.js new file mode 100644 index 0000000..f6bc510 --- /dev/null +++ b/test/report.test.js @@ -0,0 +1,166 @@ +import assert from "node:assert/strict"; +import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { BRAND } from "../src/brand.js"; +import { mintClaim, outcomeRecord } from "../src/ledger.js"; +import { appendEvidence, putClaim, repoLedger, tombstone } from "../src/ledger_store.js"; +import { record } from "../src/metrics.js"; +import { + historyByDay, + readRadar, + renderReport, + reportPath, + sparkline, + writeReport, +} from "../src/report.js"; + +const tmp = () => mkdtempSync(join(tmpdir(), "forge-report-")); +const NOW = 100; + +const mint = (dir, name, text, author = "alice") => { + const c = mintClaim({ + kind: "fact", + body: { name, text }, + provenance: { author }, + t: NOW, + }).claim; + putClaim(dir, c); + return c; +}; +const ev = (dir, id, result, ref) => + appendEvidence(dir, id, outcomeRecord({ oracle: "test.run", result, ref, t: NOW }).outcome); + +/** A fixture repo: 3 claims (one contested, one tombstoned) + 2 metrics lines. */ +function fixture() { + const root = tmp(); + const dir = repoLedger(root); + const trusted = mint(dir, "style", "tabs are actually spaces here"); + ev(dir, trusted.id, "confirm", "run-1"); + const contested = mint(dir, "port", "the dev server listens on 4242"); + ev(dir, contested.id, "confirm", "run-2"); + ev(dir, contested.id, "contradict", "run-3"); + const retracted = mint(dir, "old", "we deploy from the ops box"); + tombstone(dir, retracted.id, { author: "alice", reason: "obsolete", t: NOW }); + record(root, { stage: "cache", outcome: "exact", savedEstimate: 1200 }); + record(root, { stage: "gate", outcome: "pass" }); + return { root, trusted, contested, retracted }; +} + +test("renderReport: self-contained — no external http references", () => { + const { root } = fixture(); + const html = renderReport(root); + assert.match(html, /^/); + // The core self-containment guarantee: nothing to fetch over the network. + assert.ok(!/https?:\/\//.test(html), "report must carry zero external http(s) references"); + assert.ok(!/