mirror of
https://github.com/ruvnet/ruflo.git
synced 2026-10-23 03:47:00 +09:00
* 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:
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 });
|
||||
}
|
||||
});
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user