Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
29 changes: 29 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
28 changes: 21 additions & 7 deletions .github/workflows/security.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
6 changes: 5 additions & 1 deletion .gitleaks.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
20 changes: 20 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 5 additions & 2 deletions global/guards/secret-redact.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
5 changes: 4 additions & 1 deletion scripts/build-pages.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
10 changes: 6 additions & 4 deletions src/atlas.js
Original file line number Diff line number Diff line change
Expand Up @@ -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 = [
{
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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");
Expand Down Expand Up @@ -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;
}
Expand Down
78 changes: 55 additions & 23 deletions src/imagine.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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}` };
}
Expand All @@ -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 <files...>` — 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
Expand Down Expand Up @@ -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: '<abs path>: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<string, "pass"|"fail">} */
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,
Expand All @@ -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";
Expand Down
Loading
Loading