diff --git a/.gitattributes b/.gitattributes index 20c2eec..bfa9acb 100644 --- a/.gitattributes +++ b/.gitattributes @@ -3,3 +3,7 @@ # by construction (docs/plans/substrate-v2/02-team-memory.md §1). `forge init` emits # this same rule into consumer repos. .forge/ledger/*/*.log merge=union + +# Shell scripts and the Node guard must stay LF even on Windows checkouts — bash chokes on +# CRLF (\r), which breaks the guard/install tests under Git Bash on windows-latest. +*.sh text eol=lf diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index dfed152..04a476b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,6 +29,35 @@ jobs: - run: npm ci - run: npm test + # Windows-git-bash coverage: the guards are bash and the suite does a lot of path work, + # so run the unit suite on windows-latest under Git Bash. Invoke `node --test` directly + # (not `npm test`) so Git Bash expands the test glob — npm's Windows script-shell is cmd, + # which would pass the literal `test/*.test.js`. Genuinely-Unix-only tests (bash guard + # hooks) self-skip via `process.platform === 'win32'` guards in the tests themselves. + test-windows: + name: Test (Windows / Git Bash) + runs-on: windows-latest + steps: + - uses: actions/checkout@v7 + - uses: actions/setup-node@v6 + with: + node-version: 22 + cache: npm + - run: npm ci + - name: Unit suite (Git Bash) + shell: bash + run: | + set +e + node --test test/*.test.js 2>&1 | tee /tmp/win-test.log + ec=${PIPESTATUS[0]} + echo "===== FAILING TESTS (name + file) =====" + grep -nE '^not ok ' /tmp/win-test.log | head -80 + echo "===== FAILURE DIAGNOSTICS (TAP YAML blocks) =====" + # Print each failing test's `not ok` line through its YAML `...` terminator so the + # assertion detail (expected/actual/location) is visible in the CI log. + awk '/^not ok /{p=1} p{print} p&&/^ \.\.\.[[:space:]]*$/{p=0}' /tmp/win-test.log | head -200 + exit "$ec" + quality-gate: name: Quality gate uses: ./.github/workflows/reusable-quality-gate.yml diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml index 9af0901..6c08fc9 100644 --- a/.github/workflows/security.yml +++ b/.github/workflows/security.yml @@ -2,10 +2,18 @@ # Deliberately blocking — a real credential in the tree or history should fail the build, # not warn. (Rotate first, then rewrite/land; a red X is the cheap part of a leak.) # -# Why this passes on our own redaction tests: test/_fixtures.js assembles secret-LOOKING -# strings at RUNTIME via .join() precisely so no secret-format literal exists in any blob -# for a scanner to match. Verified clean with gitleaks 8.16 across the whole history and -# working tree before this workflow was added — no .gitleaks.toml allowlist is needed. +# We run the gitleaks BINARY directly rather than gitleaks/gitleaks-action@v3: that action's +# breaking update requires a paid GITLEAKS_LICENSE for org-associated accounts and hard-fails +# the job when its license-validation server is unreachable ("License key validation will be +# enforced"), turning a green gate red for reasons unrelated to the code. The pinned binary +# performs the identical scan (`gitleaks detect` walks every commit) with no network license +# dependency, so the gate stays deterministic and blocking. +# +# Why this passes on our own redaction tests: most secret-LOOKING fixtures are assembled at +# RUNTIME (test/_fixtures.js .join()) so no secret-format literal exists in a blob to match. +# The few committed synthetic fixtures that a scanner can't tell from the real thing — the +# PEM/private-key literals in test/secrets.test.js — are allowlisted by path in .gitleaks.toml +# (which also carries the bibliography-key exceptions). Every other path is fully scanned. name: Security on: @@ -24,7 +32,13 @@ jobs: - uses: actions/checkout@v7 with: fetch-depth: 0 # gitleaks scans commit history, not just the checkout - - uses: gitleaks/gitleaks-action@v3 + - name: Secret scan (gitleaks binary — no license, scans full history) env: - GITHUB_TOKEN: ${{ github.token }} # PR comments / job summary - # GITLEAKS_LICENSE is only required for ORG-owned repos; this repo is user-owned. + GITLEAKS_VERSION: "8.21.2" + run: | + set -euo pipefail + curl -sSfL \ + "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" \ + | tar -xz -C /tmp gitleaks + # `detect` walks the whole git log (fetch-depth:0 above); non-zero exit on any find. + /tmp/gitleaks detect --source . --no-banner --redact --exit-code 1 diff --git a/.gitleaks.toml b/.gitleaks.toml index ca1c7e7..56391d6 100644 --- a/.gitleaks.toml +++ b/.gitleaks.toml @@ -8,10 +8,14 @@ useDefault = true # The literal field name "key" next to a quoted alphanumeric value trips the default # generic-api-key rule even though there is no secret here — just citation metadata. [allowlist] -description = "Bibliography citation-key fields, not secrets" +description = "Bibliography citation-key fields and synthetic redaction-test fixtures, not secrets" paths = [ '''^research/formal-synthesis/graded_reference_set\.json$''', '''^research/formal-synthesis/merged_references\.json$''', + # test/secrets.test.js is the redaction test suite: it holds synthetic PEM private-key and + # high-entropy-token fixtures on purpose and asserts forge redacts them. Those literals are + # committed, so a full-history scan always matches them — they are NOT real credentials. + '''^test/secrets\.test\.js$''', ] # Obvious placeholder credentials used as test fixtures to exercise the secret # redactor/scanner — sequential-hex tokens that are unmistakably fake, not real secrets. diff --git a/CHANGELOG.md b/CHANGELOG.md index 4277082..668ce47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **Windows path portability (Git Bash CI).** Several subsystems compared or emitted paths + with the OS-native separator or as raw filesystem paths, which broke on Windows where + `path.relative`/`join` yield `\` and drive letters look like URL schemes: + - `atlas` and `scope` now normalize every repo-relative path to POSIX (`/`) before using + it as a graph node id, cache key, or comparison target, so impact/decompose/graph + results match on Windows (a no-op on Linux/macOS). A shared `toPosix` helper lives in + `src/util.js`. + - `uicheck` visual target resolution no longer mistakes a Windows drive-letter path + (`C:\…`) for an unsupported URL scheme, so local files render and the playwright-absent + path still degrades to a clean skip. + - `init` writes hook/statusline commands into `settings.json` in POSIX form. `bash` + (Git Bash) treats `\` as an escape, so a native Windows path would corrupt the command; + ownership/dedup matching normalizes separators too, keeping merges idempotent. + - The `secret-redact` guard imports `src/secrets.js` via a `file://` URL instead of a raw + path, so the Node redactor no longer degrades to a silent no-op on Windows. + - `imagine`'s sandboxed dry-run attributes per-file pass/fail by comparing paths in POSIX + form, and `build-pages` tolerates CRLF when parsing the changelog for the status page. + ## [0.23.1] - 2026-07-19 ### Fixed diff --git a/global/guards/secret-redact.mjs b/global/guards/secret-redact.mjs index 2a69c92..93ba481 100755 --- a/global/guards/secret-redact.mjs +++ b/global/guards/secret-redact.mjs @@ -11,8 +11,8 @@ // recursive and type-preserving: strings are redacted in place, arrays/objects keep their // exact keys and structure, and every non-string leaf (numbers, booleans, null) passes // through untouched. A string response stays a string; an object response stays an object. -import { fileURLToPath } from "node:url"; import { dirname, join } from "node:path"; +import { fileURLToPath, pathToFileURL } from "node:url"; /** Recursively redact every string leaf while preserving the value's exact structure. */ function redactValue(value, redactSecrets) { @@ -44,8 +44,11 @@ async function main() { if (val == null) return; const here = dirname(fileURLToPath(import.meta.url)); + // A file:// URL, not a bare path: on Windows join() yields `C:\…\secrets.js`, which is not + // a valid dynamic-import specifier (backslashes / drive-letter) and throws — degrading the + // redactor to a no-op. pathToFileURL makes the import portable (no-op difference on POSIX). const { redactSecrets } = await import( - join(here, "..", "..", "src", "secrets.js") + pathToFileURL(join(here, "..", "..", "src", "secrets.js")).href ); const red = redactValue(val, redactSecrets); // Emit a rewrite only when something actually changed. Compare canonically so an diff --git a/scripts/build-pages.mjs b/scripts/build-pages.mjs index b66d351..253ef54 100644 --- a/scripts/build-pages.mjs +++ b/scripts/build-pages.mjs @@ -57,7 +57,10 @@ function latestChanges() { // continuation lines so the status page shows the whole item, not a truncated fragment. const items = []; let cur = null; - for (const line of section.split("\n")) { + // Split on \r?\n: a Windows checkout (autocrlf) leaves a trailing \r that a `.`-based + // `/^- (.*)$/` won't cross (JS `.` and `$` treat \r as a line terminator), which silently + // emptied the changes list. Tolerating CRLF here keeps the parse identical on both OSes. + for (const line of section.split(/\r?\n/)) { const m = /^- (.*)$/.exec(line); if (m) { if (cur !== null) items.push(cur); diff --git a/src/atlas.js b/src/atlas.js index a4b6498..e2feb4e 100644 --- a/src/atlas.js +++ b/src/atlas.js @@ -5,7 +5,7 @@ import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSy import { extname, join, relative } from "node:path"; import { adjudicate, asText, buildRunner, llmEnabled } from "./adjudicate.js"; import { CALL_RE } from "./extract.js"; -import { contentHash, IGNORE_DIRS } from "./util.js"; +import { contentHash, IGNORE_DIRS, toPosix } from "./util.js"; const JS_RULES = [ { @@ -297,7 +297,9 @@ function extractConfig(rel, text) { function extractFile(path, root, preRead) { const ext = extname(path); const rules = RULES[ext]; - const rel = relative(root, path); + // POSIX-normalize: node/config/module ids and `file` fields are compared to `/`-joined + // paths everywhere (impact(), docs, tests). Windows `\` would break every such lookup. + const rel = toPosix(relative(root, path)); let text = preRead; if (text == null) { try { @@ -494,7 +496,7 @@ export function build({ root = process.cwd(), cap = 20000 } = {}) { const rawEdges = []; const fileHashes = {}; for (const f of files) { - const rel = relative(root, f); + const rel = toPosix(relative(root, f)); let text; try { text = readFileSync(f, "utf8"); @@ -552,7 +554,7 @@ export function isStale(root, atlas) { const current = []; walk(root, current, 20000); if (current.length !== indexed.size) return true; // a file was added or removed - for (const p of current) if (!indexed.has(relative(root, p))) return true; + for (const p of current) if (!indexed.has(toPosix(relative(root, p)))) return true; } return false; } diff --git a/src/imagine.js b/src/imagine.js index 10f8e33..2f787a6 100644 --- a/src/imagine.js +++ b/src/imagine.js @@ -8,13 +8,13 @@ // TAP summary, and always discards the sandbox. Selection stays useful on its own as // "run these, in this order"; dryRun turns the prediction into measured evidence. import { execFileSync, spawnSync } from "node:child_process"; -import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync, statSync } from "node:fs"; +import { existsSync, mkdtempSync, readFileSync, rmSync, statSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { build as buildAtlas, impact, load as loadAtlas } from "./atlas.js"; import { referencedEntities } from "./preflight.js"; import { isTestFile, predictFailingTests } from "./substrate.js"; -import { hasBin } from "./util.js"; +import { hasBin, toPosix } from "./util.js"; /** * Weighted greedy set cover over the covers(test, source) relation: candidates are @@ -110,9 +110,16 @@ const locFile = (line) => { export function dryRun(root, { tests, timeoutMs = 120000 } = {}) { if (!Array.isArray(tests) || tests.length === 0) return { ok: false, reason: "no tests selected — nothing to dry-run" }; - if (!hasBin("git")) return { ok: false, reason: "git not found — the sandbox is a git worktree" }; + if (!hasBin("git")) + return { + ok: false, + reason: "git not found — the sandbox is a git worktree", + }; try { - execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["rev-parse", "--is-inside-work-tree"], { + cwd: root, + stdio: "ignore", + }); } catch { return { ok: false, reason: `not a git repository: ${root}` }; } @@ -134,12 +141,6 @@ export function dryRun(root, { tests, timeoutMs = 120000 } = {}) { const msg = /** @type {{stderr?: Buffer}} */ (e).stderr?.toString().trim() || String(e); return { ok: false, reason: `git worktree add failed: ${msg}` }; } - // node --test reports each failure's `location:` as a canonical (realpath'd) path. - // On platforms where tmpdir() itself is a symlink (macOS: /var → /private/var), the - // raw `wt` path won't string-match those locations, so per-file attribution below - // must compare against the canonicalized worktree root. On Linux (no symlink) this - // is a no-op. - const wtReal = realpathSync(wt); // Runner policy: always `node --test ` — a custom package test script // (jest, vitest, …) is a WHOLE-SUITE command that can't be scoped per-file safely, // which would defeat minimal selection. We still run node --test and say so, so a @@ -195,21 +196,42 @@ export function dryRun(root, { tests, timeoutMs = 120000 } = {}) { } // perFile is best-effort: node ≥20 TAP flattens per-TEST (no per-file points), but // every failure block carries a `location: ':l:c'` diagnostic — map those - // back to the requested files; anything never implicated passed. Omitted when a - // failure can't be attributed (partial attribution would misassign blame). + // back to the requested files; anything never implicated passed. /** @type {Record} */ const perFile = Object.fromEntries(tests.map((t) => [String(t), "pass"])); - let attributable = true; + // Precompute POSIX + basename forms of each requested test so the match survives every + // OS's location format: TAP's `location:` uses `\` on Windows and canonicalizes its root + // differently per-OS (macOS /var→/private/var; Windows drive-letter case / 8.3 short + // names), so an absolute-path compare is fragile. Match by POSIX suffix, then basename. + const wanted = tests.map((c) => { + const posix = toPosix(String(c)); + return { orig: String(c), posix, base: posix.split("/").pop() }; + }); + let failMarks = 0; + // Split on CRLF *or* LF: Windows `node --test` emits `\r\n`, and a stray `\r` left on the + // `location:` line used to break the parse and drop the whole map to undefined. for (const block of (run.stdout ?? "").split(/^not ok /m).slice(1)) { - const file = block.split("\n").map(locFile).find(Boolean); - const t = file && tests.find((c) => file === join(wtReal, String(c))); - if (t) perFile[String(t)] = "fail"; - else attributable = false; + const file = block.split(/\r?\n/).map(locFile).find(Boolean); + if (!file) continue; // a location-less block (summary/parent) implicates no file — skip + const f = toPosix(file); + const hit = wanted.find( + (w) => f === w.posix || f.endsWith(`/${w.posix}`) || f.endsWith(`/${w.base}`), + ); + if (hit) { + perFile[hit.orig] = "fail"; + failMarks++; + } } + // We ran `node --test` on EXACTLY these files, so every real failure lives in one of + // them; a block that matches none is TAP noise, safely skipped above. Trust the map when + // it accounts for the run's failures, and abstain only if the suite reported failures we + // could pin to no file at all — there, partial blame would mislead more than no blame. + const failed = Number(mFail[1]); + const attributable = failed === 0 || failMarks > 0; return { ok: true, passed: Number(mPass[1]), - failed: Number(mFail[1]), + failed, ...(attributable ? { perFile } : {}), durationMs, runner, @@ -221,16 +243,26 @@ export function dryRun(root, { tests, timeoutMs = 120000 } = {}) { result = body(); } finally { // ALWAYS discard the sandbox — a leaked worktree pins refs and litters - // `git worktree list` forever. remove --force, prune the bookkeeping, then - // belt-and-braces rm of the parent; the verdict below verifies, never assumes. + // `git worktree list` forever. Order matters on Windows: `git worktree remove` + // can fail there when a just-exited `node --test` worker still holds a handle, so + // we then rm the directory ourselves and prune LAST — pruning only reconciles + // git's bookkeeping against the on-disk state, so it must run AFTER the directory + // is gone or it re-registers the still-present worktree and `git worktree list` + // reports a phantom entry. The verdict below verifies removal, never assumes it. try { - execFileSync("git", ["worktree", "remove", "--force", wt], { cwd: root, stdio: "ignore" }); + execFileSync("git", ["worktree", "remove", "--force", wt], { + cwd: root, + stdio: "ignore", + }); } catch {} try { - execFileSync("git", ["worktree", "prune"], { cwd: root, stdio: "ignore" }); + rmSync(parent, { recursive: true, force: true }); } catch {} try { - rmSync(parent, { recursive: true, force: true }); + execFileSync("git", ["worktree", "prune"], { + cwd: root, + stdio: "ignore", + }); } catch {} } result.worktree = existsSync(wt) ? "leaked" : "removed"; diff --git a/src/init.js b/src/init.js index 22593ff..c1d2c8c 100644 --- a/src/init.js +++ b/src/init.js @@ -18,6 +18,7 @@ import { autoDetectProvider } from "./providers.js"; import { validateProfile, writeForgeConfig } from "./repo_config.js"; import { sync } from "./sync.js"; import { list as tasteList } from "./taste.js"; +import { toPosix } from "./util.js"; /** Without the union merge driver, two teammates appending to the same ledger log get * a git conflict — the exact thing the ledger's design promises can't happen @@ -65,7 +66,11 @@ const FORGE_OWNED_KEY = "_forgeOwned"; // tell a Forge-owned command (some spelling of a path under here, or the plugin root) apart // from a user's OWN hook that merely shares a basename+args — those must never collide for // ownership/removal purposes (HI-05). -const FORGE_GLOBAL = join(BRAND.root, "global"); +// POSIX form on purpose: hook paths written into settings.json are consumed by `bash` +// (Git Bash on Windows), where backslashes are escape characters — a native `C:\…` path +// would corrupt the command. Every resolved path below is forward-slash, and ownership +// matching normalizes to the same form, so comparisons hold on every OS. +const FORGE_GLOBAL = toPosix(join(BRAND.root, "global")); /** True iff `hook` is a Forge-managed hook/statusline entry — i.e. it resolves to a path * under the installed assets root (`~/.forge/…`, the resolved `/global/…`, quoted or @@ -98,13 +103,17 @@ export function shellQuote(path) { * with no quoting. A legacy shell-string `command` (should no longer appear in the template, * but handled defensively) keeps the single-quoting a real shell would still need. */ function resolveManagedPaths(template) { - const base = join(BRAND.root, "global"); + // POSIX base: settings.json hook paths must be forward-slash (bash on Windows treats + // `\` as an escape). toPosix is a no-op on Linux/macOS. + const base = toPosix(join(BRAND.root, "global")); const fixStr = (cmd) => typeof cmd === "string" - ? cmd.replace(/~\/\.forge\/(\S+)/g, (_, rest) => shellQuote(join(base, rest))) + ? cmd.replace(/~\/\.forge\/(\S+)/g, (_, rest) => shellQuote(toPosix(join(base, rest)))) : cmd; const fixArg = (a) => - typeof a === "string" ? a.replace(/^~\/\.forge\/(.+)$/, (_, rest) => join(base, rest)) : a; + typeof a === "string" + ? a.replace(/^~\/\.forge\/(.+)$/, (_, rest) => toPosix(join(base, rest))) + : a; const fixEntry = (h) => { if (h && Array.isArray(h.args)) h.args = h.args.map(fixArg); else if (h && typeof h.command === "string") h.command = fixStr(h.command); @@ -124,10 +133,15 @@ function resolveManagedPaths(template) { * Also normalizes the legacy `~/.forge/` prefix to the resolved install base so * entries merged by very old installs still match. */ function normalizeCommand(command) { - return String(command ?? "") - .replaceAll("'\\''", "'") - .replace(/["']/g, "") - .replaceAll("~/.forge/", `${join(BRAND.root, "global")}/`); + return ( + String(command ?? "") + .replaceAll("'\\''", "'") + .replace(/["']/g, "") + .replaceAll("~/.forge/", `${toPosix(join(BRAND.root, "global"))}/`) + // Normalize path separators AFTER quote-stripping (so escape backslashes are already + // gone): a legacy install written with native `\` paths matches the posix template. + .replaceAll("\\", "/") + ); } /** Reduce ANY hook/statusLine entry to one comparable command string, quote-normalized and diff --git a/src/scope.js b/src/scope.js index 17508bc..c160281 100644 --- a/src/scope.js +++ b/src/scope.js @@ -5,7 +5,7 @@ // approximate (dynamic/DI edges missed) — a real call-graph MCP is the upgrade seam. import { readdirSync, readFileSync } from "node:fs"; import { dirname, join, relative, resolve } from "node:path"; -import { IGNORE_DIRS, SRC_EXT } from "./util.js"; +import { IGNORE_DIRS, SRC_EXT, toPosix } from "./util.js"; const IMPORT_RES = [ /import\s+[^'"]*from\s+['"]([^'"]+)['"]/g, // import x from "y" @@ -21,7 +21,7 @@ function walk(dir, root, out) { if (IGNORE_DIRS.has(entry.name)) continue; const p = join(dir, entry.name); if (entry.isDirectory()) walk(p, root, out); - else if (SRC_EXT.test(entry.name)) out.push(relative(root, p)); + else if (SRC_EXT.test(entry.name)) out.push(toPosix(relative(root, p))); } } @@ -34,7 +34,7 @@ function resolveSpec(fromRel, spec, root, fileSet) { ...["index.js", "index.ts"].map((idx) => join(raw, idx)), ]; for (const c of cands) { - const rel = relative(root, c); + const rel = toPosix(relative(root, c)); if (fileSet.has(rel)) return rel; } return null; @@ -97,7 +97,7 @@ export function components(graph) { export function decompose(root, touched) { // Normalize to repo-relative (the graph's key form) so `./src/a.js` or an absolute path // still matches — otherwise a coupled file is missed and reported as an independent solo. - const norm = touched.map((t) => relative(root, resolve(root, t))); + const norm = touched.map((t) => toPosix(relative(root, resolve(root, t)))); const normSet = new Set(norm); const comps = components(importGraph(root)); const compOf = new Map(); diff --git a/src/uivisual.js b/src/uivisual.js index 4565683..b87d0ec 100644 --- a/src/uivisual.js +++ b/src/uivisual.js @@ -101,8 +101,14 @@ export function resolveTarget(target, { remote = false, cwd = process.cwd() } = return { ok: true, url: u.href }; } if (/^file:\/\//i.test(t)) return { ok: true, url: t }; - if (/^[a-z][a-z0-9+.-]*:/i.test(t)) - return { ok: false, reason: `unsupported URL scheme: ${t} (use a file path or http(s))` }; + // A Windows drive-letter absolute path (`C:\…` / `C:/…`) is NOT a URL scheme — the + // single-letter-plus-separator shape distinguishes it from real schemes like ftp:. + const isWinDrive = /^[a-z]:[\\/]/i.test(t); + if (!isWinDrive && /^[a-z][a-z0-9+.-]*:/i.test(t)) + return { + ok: false, + reason: `unsupported URL scheme: ${t} (use a file path or http(s))`, + }; const p = isAbsolute(t) ? t : join(cwd, t); if (!existsSync(p)) return { ok: false, reason: `no such file: ${t}` }; return { ok: true, url: pathToFileURL(p).href }; @@ -253,7 +259,11 @@ export async function renderedFingerprint(target, opts = {}) { browser = await playwright.chromium.launch({ headless: true }); } catch (err) { // Installed module but no usable browser binary — same graceful-absence class. - return { ok: false, skipped: true, reason: `browser launch failed: ${err.message}` }; + return { + ok: false, + skipped: true, + reason: `browser launch failed: ${err.message}`, + }; } try { const outDir = join(root, ".forge", "ui"); @@ -272,7 +282,13 @@ export async function renderedFingerprint(target, opts = {}) { await page.close(); } const fingerprint = fingerprintText(computedStylesToCss(records)); - return { ok: true, url: t.url, fingerprint, screenshots, elements: records.length }; + return { + ok: true, + url: t.url, + fingerprint, + screenshots, + elements: records.length, + }; } catch (err) { return { ok: false, reason: `render failed: ${err.message}` }; } finally { diff --git a/src/util.js b/src/util.js index a06aaae..4e8fbc7 100644 --- a/src/util.js +++ b/src/util.js @@ -13,6 +13,13 @@ export const slug = (s) => export const clamp01 = (x) => Math.max(0, Math.min(1, x)); +// Normalize a path to POSIX separators. Node's path.relative()/join() emit `\` on Windows, +// but the graph/atlas/scope layers use repo-relative paths as MAP KEYS, NODE IDS, and values +// compared against `/`-joined strings (in code and tests). Backslash keys silently miss those +// lookups on Windows. Canonicalizing to `/` makes every comparison portable; it's a no-op on +// POSIX (no `\` in the path) and safe on Windows (fs/join accept `/`). +export const toPosix = (p) => String(p).replaceAll("\\", "/"); + export const MS_PER_DAY = 86400000; export const epochDay = () => Math.floor(Date.now() / MS_PER_DAY); @@ -46,7 +53,9 @@ export function gitAuthor() { if (cachedAuthor !== undefined) return cachedAuthor; try { const get = (k) => - execFileSync("git", ["config", "--get", k], { stdio: ["ignore", "pipe", "ignore"] }) + execFileSync("git", ["config", "--get", k], { + stdio: ["ignore", "pipe", "ignore"], + }) .toString() .trim(); const name = get("user.name"); diff --git a/test/dash.test.js b/test/dash.test.js index cb066ac..045cb19 100644 --- a/test/dash.test.js +++ b/test/dash.test.js @@ -1,7 +1,7 @@ import assert from "node:assert/strict"; import { mkdirSync, mkdtempSync, writeFileSync } from "node:fs"; import { tmpdir } from "node:os"; -import { join } from "node:path"; +import { basename, join } from "node:path"; import { test } from "node:test"; import { claimsData, dashData, historyData, radarData, serve, timelineData } from "../src/dash.js"; import { mintClaim, outcomeRecord } from "../src/ledger.js"; @@ -50,7 +50,7 @@ test("dashData: one payload with ledger, metrics, and atlas sections in shape", const { root, contested, retracted } = fixture(); const d = dashData(root, { nowDay: NOW }); - assert.equal(d.repo, root.split("/").pop()); + assert.equal(d.repo, basename(root)); assert.equal(d.nowDay, NOW); assert.equal(d.ledger.stats.total, 3); assert.equal(d.ledger.stats.tombstoned, 1); diff --git a/test/decide.test.js b/test/decide.test.js index 621b77e..1dae37a 100644 --- a/test/decide.test.js +++ b/test/decide.test.js @@ -65,13 +65,17 @@ test("a corrupted decisions file is tolerated — numbering restarts from parsea test("concurrent appends never duplicate ids or headers (mkdir lock)", async () => { const root = mkdtempSync(join(tmpdir(), "forge-decide-")); const { spawn } = await import("node:child_process"); + // A file:// URL (not a raw path): a Windows cwd is `C:\…`, which is neither a valid import + // specifier nor a safe JS string literal (backslash escapes). Derive the module URL from + // import.meta.url so the child imports it identically on both OSes. + const decideUrl = new URL("../src/decide.js", import.meta.url).href; const one = (i) => new Promise((resolve) => { const p = spawn( "node", [ "-e", - `import("${process.cwd()}/src/decide.js").then(m => m.appendDecision(process.argv[1], "parallel decision ${"n"}${i} — race test"))`, + `import(${JSON.stringify(decideUrl)}).then(m => m.appendDecision(process.argv[1], "parallel decision ${"n"}${i} — race test"))`, root, ], { stdio: "ignore" }, diff --git a/test/init.test.js b/test/init.test.js index 75ffe13..1908669 100644 --- a/test/init.test.js +++ b/test/init.test.js @@ -24,6 +24,7 @@ import { validateProfile, } from "../src/init.js"; import { GITATTRIBUTES_RULE } from "../src/ledger_store.js"; +import { toPosix } from "../src/util.js"; /** The effective shell command a hook/statusLine entry runs, across BOTH forms: exec form * (`command`+`args`, ME-23) joins the args onto the command; a legacy shell string is returned @@ -299,7 +300,7 @@ test("shellQuote single-quotes paths (spaces safe) and escapes embedded quotes", }); test("guardKey derives ONE identity from legacy shell-string AND exec-form spellings (ME-23)", () => { - const base = join(BRAND.root, "global"); + const base = toPosix(join(BRAND.root, "global")); // hook paths are POSIX (see init.js) const key = "cortex.sh prompt"; // Legacy shell-string spellings (quoted, unquoted, tilde, plugin-root). assert.equal(guardKey(`bash ~/.forge/guards/cortex.sh prompt`), key); @@ -341,7 +342,7 @@ test("mergeSettings writes hooks in EXEC form — path in args[], resolved, UNQU const settingsPath = join(tmp, "settings.json"); mergeSettings({ settingsPath }); const merged = JSON.parse(readFileSync(settingsPath, "utf8")); - const base = join(BRAND.root, "global"); + const base = toPosix(join(BRAND.root, "global")); // hook paths are POSIX (see init.js) const hooks = Object.values(merged.hooks) .flat() .flatMap((e) => e.hooks || []); @@ -358,7 +359,7 @@ test("mergeSettings writes hooks in EXEC form — path in args[], resolved, UNQU } // The statusLine migrated to exec form too. assert.equal(merged.statusLine.command, "bash"); - assert.equal(merged.statusLine.args[0], join(base, "statusline.sh")); + assert.equal(merged.statusLine.args[0], `${base}/statusline.sh`); assert.doesNotMatch(merged.statusLine.args[0], /['"]/, "statusline args path unquoted"); }); @@ -370,14 +371,14 @@ test("resolveManagedPaths: a base path WITH A SPACE lands literally in args, no const settingsPath = join(tmp, "settings.json"); mergeSettings({ settingsPath }); const merged = JSON.parse(readFileSync(settingsPath, "utf8")); - const base = join(BRAND.root, "global"); + const base = toPosix(join(BRAND.root, "global")); // hook paths are POSIX (see init.js) const cortexPrompt = merged.hooks.UserPromptSubmit.flatMap((e) => e.hooks || []).find( (h) => guardKey(h) === "cortex.sh prompt", ); assert.ok(cortexPrompt, "cortex prompt hook present"); assert.deepEqual( cortexPrompt.args, - [join(base, "guards", "cortex.sh"), "prompt"], + [`${base}/guards/cortex.sh`, "prompt"], "args are the literal resolved path element + trailing arg, verbatim and unquoted", ); }); @@ -385,7 +386,7 @@ test("resolveManagedPaths: a base path WITH A SPACE lands literally in args, no test("an OLD shell-string install dedupes against the exec-form template and is upgraded (ME-23)", () => { const tmp = mkdtempSync(join(tmpdir(), "forge-legacy-dedup-")); const settingsPath = join(tmp, "settings.json"); - const base = join(BRAND.root, "global"); + const base = toPosix(join(BRAND.root, "global")); // hook paths are POSIX (see init.js) // What a pre-ME-23 install wrote: one resolved shell-string command (RA-12 quoted form). writeFileSync( settingsPath, @@ -499,7 +500,7 @@ test("removeForgeSettings strips a merge into an EMPTY file completely (fresh-in test("removeForgeSettings removes an OLD unquoted install's entries too (quote-normalized match)", () => { const tmp = mkdtempSync(join(tmpdir(), "forge-remove-old-")); const settingsPath = join(tmp, "settings.json"); - const base = join(BRAND.root, "global"); + const base = toPosix(join(BRAND.root, "global")); // hook paths are POSIX (see init.js) writeFileSync( settingsPath, JSON.stringify({ @@ -553,13 +554,13 @@ const SETTINGS_SCHEMA = "https://json.schemastore.org/claude-code-settings.json" test("HI-05 ownership round-trip: user-owned entries that collide with the template survive uninstall byte-identical", () => { const tmp = mkdtempSync(join(tmpdir(), "forge-own-")); const settingsPath = join(tmp, "settings.json"); - const base = join(BRAND.root, "global"); + const base = toPosix(join(BRAND.root, "global")); // hook paths are POSIX (see init.js) // A statusLine byte-identical to the template's resolved command, a $schema identical to the // template's, an allow string the template also ships, and a cortex.sh hook at a DIFFERENT // absolute path than Forge's (same basename+args). None of these were added by Forge. const userStatusLine = { type: "command", - command: `bash ${shellQuote(join(base, "statusline.sh"))}`, + command: `bash ${shellQuote(`${base}/statusline.sh`)}`, }; const userCortex = { type: "command", diff --git a/test/skills.test.js b/test/skills.test.js index 28bd6a2..8484112 100644 --- a/test/skills.test.js +++ b/test/skills.test.js @@ -8,10 +8,12 @@ const root = join(dirname(fileURLToPath(import.meta.url)), ".."); const read = (p) => readFileSync(join(root, p), "utf8"); function frontmatter(md) { - const m = md.match(/^---\n([\s\S]*?)\n---/); + // \r?\n throughout: a Windows checkout (autocrlf) delivers `---\r\n…`, which a bare `\n` + // anchor won't match — the parser would return null and every frontmatter assertion fail. + const m = md.match(/^---\r?\n([\s\S]*?)\r?\n---/); if (!m) return null; const fm = {}; - for (const line of m[1].split("\n")) { + for (const line of m[1].split(/\r?\n/)) { const kv = line.match(/^(\w+):\s*(.*)$/); if (kv) fm[kv[1]] = kv[2]; } diff --git a/test/verify.test.js b/test/verify.test.js index 36a639f..2078986 100644 --- a/test/verify.test.js +++ b/test/verify.test.js @@ -199,7 +199,15 @@ test("verify: an untracked source file appears in provenance (changedFiles + unt // second, unexecuted/failing one. // --------------------------------------------------------------------------- -test("verify: polyglot — passing Node suite + non-executable go suite ⇒ INCOMPLETE, not PASS", () => { +// win32 gate: the fake runners are `#!/bin/sh` scripts made runnable via the exec bit and a +// `:`-delimited PATH — a Unix construct. Windows resolves `npm`/`pytest` through PATHEXT to +// `.cmd`/`.exe`, so an extensionless shell script can never stand in for them. verify.js itself +// is portable (shell-free execFileSync); only this fake-executable injection is Unix-only. +test("verify: polyglot — passing Node suite + non-executable go suite ⇒ INCOMPLETE, not PASS", { + skip: + process.platform === "win32" && + "fake #!/bin/sh runners can't be invoked as npm/pytest on Windows", +}, () => { const root = gitRepo(); writeFileSync( join(root, "package.json"), @@ -225,7 +233,12 @@ test("verify: polyglot — passing Node suite + non-executable go suite ⇒ INCO ); }); -test("verify: two executable suites both pass ⇒ PASS (every suite ran)", () => { +// win32 gate: same Unix fake-runner mechanism as above (see comment). +test("verify: two executable suites both pass ⇒ PASS (every suite ran)", { + skip: + process.platform === "win32" && + "fake #!/bin/sh runners can't be invoked as npm/pytest on Windows", +}, () => { const root = gitRepo(); writeFileSync( join(root, "package.json"), @@ -243,7 +256,12 @@ test("verify: two executable suites both pass ⇒ PASS (every suite ran)", () => assert.deepEqual(r.tests.notExecuted, []); }); -test("verify: one of two executable suites fails ⇒ FAIL (failure is not hidden)", () => { +// win32 gate: same Unix fake-runner mechanism as above (see comment). +test("verify: one of two executable suites fails ⇒ FAIL (failure is not hidden)", { + skip: + process.platform === "win32" && + "fake #!/bin/sh runners can't be invoked as npm/pytest on Windows", +}, () => { const root = gitRepo(); writeFileSync( join(root, "package.json"),