fix(ruflo-adr): make ADR indexing work on Windows (#2413) (#4330)

* fix(ruflo-adr): find ADRs on Windows and report POSIX paths (#2413)

findAdrs matched literal /docs/adr/ substrings, so join()'s backslashes on Windows silently produced 0 ADRs. Match either separator and report the file field relative to the scan root with forward slashes so bySource and stored records are stable across platforms.

* fix(ruflo-adr): spawn npm npx-cli.js on Windows (#2413)

spawnSync cannot launch npx.cmd without a shell, so the no-installed-CLI fallback returned ENOENT and every store failed on Windows. Resolve npm's npx-cli.js (npm_execpath, PATH shims, or Node's bundled npm) and run it with process.execPath so argv stays shell-safe.

---------

Co-authored-by: rUv <ruv@ruv.net>
This commit is contained in:
Akash BhatandrUv authored and GitHub committed 2026-10-21 13:48:41 -04:00
1 parent ec09b65592
commit cd89db4f08
7 files changed
+133 -13

No files matched your search

@@ -3,7 +3,7 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, 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 { findAdrs, parseAdr } from '../lib/parse-adrs.mjs';
@@ -73,7 +73,7 @@ test('index and template files in docs/adr are not ADRs', () => {
'template-madr.md': '# ADR-000: template\n',
'ADR-001-real.md': adr(1, '- **Status**: accepted'),
}, (root) => {
const found = findAdrs(root).map((p) => p.split('/').pop());
const found = findAdrs(root).map((p) => basename(p));
assert.deepEqual(found, ['ADR-001-real.md']);
});
});
@@ -2,7 +2,7 @@ import assert from 'node:assert/strict';
import { spawnSync } from 'node:child_process';
import { chmodSync, mkdtempSync, mkdirSync, readFileSync, realpathSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { delimiter, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import test from 'node:test';
@@ -18,16 +18,24 @@ function fixture(t) {
mkdirSync(scanRoot, { recursive: true });
mkdirSync(bin);
writeFileSync(join(scanRoot, 'ADR-001-test.md'), '# ADR-001: Test\n\n**Status**: Accepted\n');
const shim = join(bin, 'npx');
writeFileSync(shim, `#!/usr/bin/env node
const fs = require('node:fs');
const shimBody = `const fs = require('node:fs');
fs.appendFileSync(process.env.ADR_TEST_CALLS, JSON.stringify({cwd: process.cwd(), args: process.argv.slice(2)}) + '\\n');
if (process.env.ADR_TEST_FAIL === '1') {
process.stderr.write('writer unavailable\\n');
process.exit(42);
}
`);
`;
const shim = join(bin, 'npx');
writeFileSync(shim, `#!/usr/bin/env node\n${shimBody}`);
chmodSync(shim, 0o755);
if (process.platform === 'win32') {
// spawnSync cannot launch npx.cmd on Windows; mirror the npm layout the
// fallback resolves so the same fake CLI runs on both platforms.
writeFileSync(join(bin, 'npx.cmd'), '@ECHO off\r\n');
const npxCli = join(bin, 'node_modules', 'npm', 'bin', 'npx-cli.js');
mkdirSync(join(bin, 'node_modules', 'npm', 'bin'), { recursive: true });
writeFileSync(npxCli, shimBody);
}
return { temp, project, scanRoot, bin, calls: join(temp, 'calls.jsonl') };
}
@@ -41,7 +49,7 @@ function run(fixture, format, fail = false) {
ADR_TEST_CALLS: fixture.calls,
ADR_TEST_FAIL: fail ? '1' : '0',
IMPORT_FORMAT: format,
PATH: `${fixture.bin}:${process.env.PATH}`,
PATH: [fixture.bin, process.env.PATH].filter(Boolean).join(delimiter),
// #3558: pin the npx shim — a built checkout or an installed ruflo on
// PATH would otherwise be preferred over it (lib/ruflo-cli.mjs).
RUFLO_PLUGIN_SKIP_LOCAL_CLI: '1',
@@ -81,6 +81,26 @@ test('win32: npm\'s ruflo.cmd shim maps to `node <package bin>`, never cmd.exe',
}
});
test('win32: npx fallback resolves npm\'s JS entry, not the .cmd shim', async () => {
const { resolveNpxCli } = await import(pathToFileURL(join(PLUGIN_ROOT, 'scripts', 'lib', 'ruflo-cli.mjs')).href);
const tmp = realpathSync(mkdtempSync(join(tmpdir(), 'ruflo-adr-npx-win-')));
try {
const bin = join(tmp, 'bin');
const npmBin = join(bin, 'node_modules', 'npm', 'bin');
const npxCli = join(npmBin, 'npx-cli.js');
write(join(bin, 'npx.cmd'), '@ECHO off\r\n');
write(npxCli, '');
write(join(npmBin, 'npm-cli.js'), '');
const fakeExecPath = join(tmp, 'node', 'node.exe');
const want = realpathSync(npxCli);
assert.equal(resolveNpxCli({ Path: bin }, 'win32', fakeExecPath), want);
assert.equal(resolveNpxCli({ npm_execpath: join(npmBin, 'npm-cli.js') }, 'win32', fakeExecPath), want);
assert.equal(resolveNpxCli({ Path: bin }, 'linux', fakeExecPath), null);
} finally {
rmSync(tmp, { recursive: true, force: true });
}
});
if (process.platform !== 'win32') {
test('plugin-cache install: import, reindex and verify all use the installed ruflo, never npx', () => withFixture(({ run, path, rufloBin, npxBin }) => {
const env = { PATH: path(rufloBin, npxBin) };
@@ -1,7 +1,7 @@
import assert from 'node:assert/strict';
import test from 'node:test';
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
import { join } from 'node:path';
import { basename, join } from 'node:path';
import { tmpdir } from 'node:os';
import { SKIP_DIRS, findAdrs } from '../lib/parse-adrs.mjs';
@@ -33,7 +33,7 @@ test('findAdrs does not descend into .brain', () => {
);
const found = findAdrs(root);
const names = found.map((p) => p.split('/').pop());
const names = found.map((p) => basename(p));
assert.deepEqual(names, ['ADR-001-real.md']);
} finally {
@@ -0,0 +1,51 @@
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { findAdrs, isAdrPath, parseAdr } from '../lib/parse-adrs.mjs';
// Support both the repository's Vitest collection and the standalone Node smoke.
const { test } = process.env.VITEST ? await import('vitest') : await import('node:test');
test('recognizes docs/adr paths with either separator (#2413)', () => {
assert.equal(isAdrPath('C:\\repo\\docs\\adr\\ADR-001.md'), true);
assert.equal(isAdrPath('C:\\repo\\docs\\adrs\\ADR-001.md'), true);
assert.equal(isAdrPath('/repo/docs/adr/ADR-001.md'), true);
assert.equal(isAdrPath('/repo/docs/adrs/ADR-001.md'), true);
assert.equal(isAdrPath('docs/adr/ADR-001.md'), true);
assert.equal(isAdrPath('/repo/docs/architecture/ADR-001.md'), false);
assert.equal(isAdrPath('/repo/adr/ADR-001.md'), false);
});
test('finds ADRs and reports repo-relative POSIX paths (#2413)', () => {
const root = mkdtempSync(join(tmpdir(), 'ruflo-adr-paths-'));
try {
mkdirSync(join(root, 'docs', 'adr'), { recursive: true });
mkdirSync(join(root, 'docs', 'adrs', 'nested'), { recursive: true });
mkdirSync(join(root, 'docs', 'other'), { recursive: true });
writeFileSync(
join(root, 'docs', 'adr', 'ADR-001-test.md'),
'# ADR-001: Test\n\n**Status**: accepted\n',
);
writeFileSync(
join(root, 'docs', 'adrs', 'nested', 'ADR-002-nested.md'),
'# ADR-002: Nested\n\n**Status**: accepted\n',
);
writeFileSync(
join(root, 'docs', 'other', 'ADR-003-ignored.md'),
'# ADR-003: Ignored\n\n**Status**: accepted\n',
);
const files = findAdrs(root);
assert.equal(files.length, 2);
assert.ok(files.some((file) => file.endsWith('ADR-001-test.md')));
assert.ok(files.some((file) => file.endsWith('ADR-002-nested.md')));
const parsed = parseAdr(join(root, 'docs', 'adr', 'ADR-001-test.md'), root);
assert.equal(parsed.id, 'ADR-001');
assert.equal(parsed.file, 'docs/adr/ADR-001-test.md');
} finally {
rmSync(root, { recursive: true, force: true });
}
});
+14 -3
View File
@@ -7,7 +7,7 @@
// own persistence.
import { readFileSync, readdirSync, statSync, realpathSync } from 'node:fs';
import { join, basename } from 'node:path';
import { join, basename, relative, sep } from 'node:path';
// #2474 bonus: `.claude/worktrees/*` mirrors the repo so every ADR was
// indexed 2-3x. Skip the whole `.claude` tree — it's all ruflo runtime
@@ -38,6 +38,13 @@ export function normalizeAdrId(raw) {
// #4126: an index table (README.md, index.md) or a template (template*.md) in docs/adr is not an ADR.
const NON_ADR_FILE = /^(?:readme|index|template[^/]*)\.md$/i;
// #2413: `join()` yields backslashes on Windows; match either separator so a
// Windows scan does not silently return zero ADRs.
const ADR_PATH = /(?:^|[\\/])docs[\\/]adrs?[\\/]/;
export function isAdrPath(filePath) {
return ADR_PATH.test(filePath);
}
export function findAdrs(dir, out = [], visited = new Set()) {
let canonical;
@@ -53,7 +60,7 @@ export function findAdrs(dir, out = [], visited = new Set()) {
try { st = statSync(p); } catch { continue; }
if (st.isDirectory()) {
findAdrs(p, out, visited);
} else if (e.endsWith('.md') && !NON_ADR_FILE.test(e) && (p.includes('/docs/adr/') || p.includes('/docs/adrs/'))) {
} else if (e.endsWith('.md') && !NON_ADR_FILE.test(e) && isAdrPath(p)) {
out.push(p);
}
}
@@ -69,7 +76,11 @@ export function parseAdr(path, root) {
const tags = parseTags(text);
const context = parseContextFirstParagraph(text);
const links = parseLinks(text, id);
return { id, title, status, date, tags, context, links, file: path.replace(root + '/', '') };
return {
id, title, status, date, tags, context, links,
// #2413: keep the reported path repo-relative and POSIX-shaped on Windows.
file: relative(root, path).split(sep).join('/'),
};
}
function parseId(path, text) {
@@ -122,12 +122,42 @@ export function resolveRufloCli() {
return RUFLO_CLI;
}
/**
* Path to npm's `npx-cli.js`, or null. On win32 `npx` is a .cmd shim that
* spawnSync cannot launch without a shell; running the JS entry directly keeps
* argv intact (no shell quoting) while matching what the shim would execute.
* `env` / `platform` / `execPath` are parameters so the win32 branch is
* testable on POSIX.
*/
export function resolveNpxCli(env = process.env, platform = process.platform, execPath = process.execPath) {
if (platform !== 'win32') return null;
const candidates = [];
const npmExecPath = envValue(env, 'npm_execpath');
if (npmExecPath) {
if (/[\\/]npx-cli\.js$/i.test(npmExecPath)) candidates.push(npmExecPath);
else if (/[\\/]npm-cli\.js$/i.test(npmExecPath)) candidates.push(join(dirname(npmExecPath), 'npx-cli.js'));
}
// Follow the npx.cmd shims on PATH first, then the standard Node layout.
for (const dir of (envValue(env, 'PATH') || '').split(';').filter((d) => isAbsolute(d))) {
if (existsSync(join(dir, 'npx.cmd'))) candidates.push(join(dir, 'node_modules', 'npm', 'bin', 'npx-cli.js'));
}
candidates.push(join(dirname(execPath), 'node_modules', 'npm', 'bin', 'npx-cli.js'));
for (const file of candidates) {
try { if (statSync(file).isFile()) return realpathSync(file); } catch { /* keep looking */ }
}
return null;
}
/**
* spawnSync `npx <npxArgs>` — unless npxArgs[0] is CLI_PKG and an installed
* ruflo CLI resolves, which then runs with the rest of the argv and no shell.
* On Windows the npx fallback runs npm's npx-cli.js with node instead of the
* unspawnable .cmd shim (#2413).
*/
export function spawnCliSync(npxArgs, options = {}) {
const cli = npxArgs[0] === CLI_PKG ? resolveRufloCli() : null;
if (cli) return spawnSync(cli.command, [...cli.args, ...npxArgs.slice(1)], { ...options, shell: false });
const npxCli = resolveNpxCli();
if (npxCli) return spawnSync(process.execPath, [npxCli, ...npxArgs], { ...options, shell: false });
return spawnSync('npx', npxArgs, options);
}