feat(#1707): no-path-literal-in-assert AST rule + portability foundation (#1710)

Phase 1 of epic #1702. Closes #1707.
This commit is contained in:
Tom Boucher
2026-06-25 14:37:10 -04:00
committed by GitHub
parent fb5f89db10
commit a72dbfa58c
15 changed files with 2602 additions and 32 deletions

View File

@@ -93,18 +93,18 @@ describe('bug #3126: runtime-homes getGlobalConfigDir — defaults', () => {
describe('bug #3126: runtime-homes env-var overrides', () => {
test('claude respects CLAUDE_CONFIG_DIR (was missing in old code)', () => {
withEnv('CLAUDE_CONFIG_DIR', '/custom/claude', () => {
assert.strictEqual(getGlobalConfigDir('claude'), '/custom/claude');
assert.strictEqual(String(getGlobalConfigDir('claude')).replace(/\\/g, '/'), '/custom/claude');
});
});
test('cursor respects CURSOR_CONFIG_DIR', () => {
withEnv('CURSOR_CONFIG_DIR', '/custom/cursor', () => {
assert.strictEqual(getGlobalConfigDir('cursor'), '/custom/cursor');
assert.strictEqual(String(getGlobalConfigDir('cursor')).replace(/\\/g, '/'), '/custom/cursor');
});
});
test('opencode respects OPENCODE_CONFIG_DIR', () => {
withEnv('OPENCODE_CONFIG_DIR', '/custom/opencode', () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/custom/opencode');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/custom/opencode');
});
});
});
@@ -208,7 +208,7 @@ describe('bug #3126: runtime-homes getGlobalSkillDir', () => {
describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-path precedence', () => {
// ── explicitDir override ──────────────────────────────────────────────────
test('explicitDir absolute path is returned as-is (claude)', () => {
assert.strictEqual(getGlobalConfigDir('claude', '/tmp/x'), '/tmp/x');
assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/x')).replace(/\\/g, '/'), '/tmp/x');
});
test('explicitDir with tilde is expanded (opencode)', () => {
@@ -220,7 +220,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
test('explicitDir wins even when OPENCODE_CONFIG_DIR is also set', () => {
withEnv('OPENCODE_CONFIG_DIR', '/should/not/win', () => {
assert.strictEqual(getGlobalConfigDir('opencode', '/explicit/wins'), '/explicit/wins');
assert.strictEqual(String(getGlobalConfigDir('opencode', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins');
});
});
@@ -229,7 +229,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
withEnv('OPENCODE_CONFIG', '/home/u/cfg/opencode.json', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg');
});
});
});
@@ -238,7 +238,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
test('opencode: OPENCODE_CONFIG_DIR takes precedence over OPENCODE_CONFIG', () => {
withEnv('OPENCODE_CONFIG_DIR', '/dir/wins', () => {
withEnv('OPENCODE_CONFIG', '/file/loses.json', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins');
});
});
});
@@ -247,7 +247,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
withEnv('OPENCODE_CONFIG_DIR', undefined, () => {
withEnv('OPENCODE_CONFIG', '/cfg/opencode.json', () => {
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg');
});
});
});
@@ -271,7 +271,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('XDG_CONFIG_HOME', undefined, () => {
withEnv('KILO_CONFIG', '/home/u/cfg/kilo.json', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg');
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg');
});
});
});
@@ -280,7 +280,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
test('kilo: KILO_CONFIG_DIR takes precedence over KILO_CONFIG', () => {
withEnv('KILO_CONFIG_DIR', '/dir/wins', () => {
withEnv('KILO_CONFIG', '/file/loses.json', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins');
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins');
});
});
});
@@ -289,7 +289,7 @@ describe('getGlobalConfigDir — explicitDir override and opencode/kilo file-pat
withEnv('KILO_CONFIG_DIR', undefined, () => {
withEnv('KILO_CONFIG', '/cfg/kilo.json', () => {
withEnv('XDG_CONFIG_HOME', '/xdg/should/lose', () => {
assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg');
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg');
});
});
});

View File

@@ -102,12 +102,12 @@ describe('Kimi runtime homes', () => {
test('KIMI_CONFIG_DIR can select the brand-specific ~/.kimi-code root', () => {
withEnv({ KIMI_CONFIG_DIR: '/tmp/custom-kimi-code', XDG_CONFIG_HOME: undefined }, () => {
assert.strictEqual(getGlobalConfigDir('kimi'), '/tmp/custom-kimi-code');
assert.strictEqual(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), '/tmp/custom-kimi-code');
assert.strictEqual(
getGlobalSkillsBase('kimi'),
path.join('/tmp/custom-kimi-code', 'skills'),
);
assert.strictEqual(getGlobalDir('kimi'), '/tmp/custom-kimi-code');
assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), '/tmp/custom-kimi-code');
});
});

View File

@@ -207,7 +207,7 @@ describe('getGlobalConfigDir — explicit configDir overrides env for all runtim
const savedHome = process.env.HERMES_HOME;
process.env.HERMES_HOME = '~/from-env';
try {
assert.strictEqual(getGlobalConfigDir('hermes', '/explicit/hermes'), '/explicit/hermes');
assert.strictEqual(String(getGlobalConfigDir('hermes', '/explicit/hermes')).replace(/\\/g, '/'), '/explicit/hermes');
} finally {
if (savedHome !== undefined) process.env.HERMES_HOME = savedHome;
else delete process.env.HERMES_HOME;
@@ -218,7 +218,7 @@ describe('getGlobalConfigDir — explicit configDir overrides env for all runtim
const saved = process.env.KILO_CONFIG_DIR;
process.env.KILO_CONFIG_DIR = '~/from-env';
try {
assert.strictEqual(getGlobalConfigDir('kilo', '/explicit/kilo'), '/explicit/kilo');
assert.strictEqual(String(getGlobalConfigDir('kilo', '/explicit/kilo')).replace(/\\/g, '/'), '/explicit/kilo');
} finally {
if (saved !== undefined) process.env.KILO_CONFIG_DIR = saved;
else delete process.env.KILO_CONFIG_DIR;

View File

@@ -0,0 +1,402 @@
'use strict';
/**
* no-path-literal-in-assert.rule.test.cjs
*
* RuleTester unit tests for the local/no-path-literal-in-assert ESLint rule.
* Mirrors the style of tests/eslint-rules.test.cjs.
*
* Rule: report when a path-returning call (path.join, getGlobalConfigDir, …)
* is compared to a hardcoded POSIX-slash literal in an assert.*() or
* expect(…).<matcher>() assertion — a DEFECT that fails on Windows.
*
* VALID (no report) when:
* - the path operand is wrapped by a POSIX normalizer (.replace, .replaceAll, toPosixPath, etc.)
* - the assertion is inside a Windows-excluded block (process.platform !== 'win32' guard,
* early-return guard, hoisted isWindows guard)
* - both operands are path calls (no slash literal involved)
* - the string literal has no slash (file-name only)
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const { RuleTester } = require('eslint');
const noPathLiteralInAssert = require('../eslint-rules/no-path-literal-in-assert.cjs');
const ruleTester = new RuleTester({
languageOptions: {
ecmaVersion: 2022,
sourceType: 'commonjs',
},
});
// ─── module shape ─────────────────────────────────────────────────────────────
describe('no-path-literal-in-assert rule module', () => {
test('exports meta and create', () => {
assert.strictEqual(typeof noPathLiteralInAssert.meta, 'object');
assert.strictEqual(typeof noPathLiteralInAssert.create, 'function');
assert.strictEqual(noPathLiteralInAssert.meta.type, 'problem');
assert.ok(noPathLiteralInAssert.meta.messages.pathLiteral);
});
});
// ─── INVALID cases (violation expected) ───────────────────────────────────────
describe('no-path-literal-in-assert invalid cases', () => {
test('invalid: assert.strictEqual(path.join(a,b), "/x/y")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.strictEqual(path.join(a, b), '/x/y');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: assert.equal(getGlobalConfigDir("claude"), "/custom/claude")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.equal(getGlobalConfigDir('claude'), '/custom/claude');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: assert.deepStrictEqual(path.resolve(x), "/a/b")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.deepStrictEqual(path.resolve(x), '/a/b');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: reversed operands — assert.equal("/x/y", path.join(a,b))', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.equal('/x/y', path.join(a, b));`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: expect(path.resolve(x)).toBe("/a/b")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `expect(path.resolve(x)).toBe('/a/b');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: multi-line assert.strictEqual', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `
assert.strictEqual(
path.join(base, 'dir'),
'/home/user/dir'
);
`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: assert.equal(os.homedir(), "/home/user")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.equal(os.homedir(), '/home/user');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: assert.equal(os.tmpdir(), "/tmp")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.equal(os.tmpdir(), '/tmp');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: expect(getGlobalConfigDir("claude")).toEqual("/custom/claude")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `expect(getGlobalConfigDir('claude')).toEqual('/custom/claude');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('invalid: assert.deepEqual(path.normalize(x), "/a/b/c")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.deepEqual(path.normalize(x), '/a/b/c');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
});
// ─── VALID cases (no violation expected) ─────────────────────────────────────
describe('no-path-literal-in-assert valid cases', () => {
test('valid: normalized with String(path.join).replace(/\\\\/g, "/")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(String(path.join(a, b)).replace(/\\\\/g, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: normalized with String(path.join).replace(/[\\\\/]/g, "/")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(String(path.join(a, b)).replace(/[\\\\/]/g, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: normalized with path.join().replaceAll(path.sep, "/")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(path.join(a, b).replaceAll(path.sep, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: assert.equal("foo", "foo") — no path call, no slash difference', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal('foo', 'foo');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: assert.equal(path.basename(p), "file.txt") — string has no slash', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(path.basename(p), 'file.txt');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: both operands are path calls — no slash literal', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(path.join(a, b), path.join(c, d));`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: guarded by if (process.platform !== "win32") { ... }', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `
if (process.platform !== 'win32') {
assert.equal(path.join(a, b), '/x/y');
}
`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: early-return guard — if (process.platform === "win32") return; assert.equal(path.join(a,b), "/x/y")', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `
if (process.platform === 'win32') return;
assert.equal(path.join(a, b), '/x/y');
`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: hoisted isWindows guard — const isWindows = process.platform === "win32"; if (!isWindows) assert.equal(...)', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `
const isWindows = process.platform === 'win32';
if (!isWindows) assert.equal(path.join(a, b), '/x/y');
`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: assert.ok(path.join(a,b)) — not an equality assert', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.ok(path.join(a, b));`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: URL string starting with https:// is not flagged', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(getUrl(), 'https://example.com/path');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('valid: normalized with toPosixPath(path.join(a,b))', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(toPosixPath(path.join(a, b)), '/x/y');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
});
// ─── C1: isPosixNormalizerCall must require backslash-targeting regex ─────────
// The fix: .replace(/foo/g,'/') and .replace(/\//g,'/') must NOT suppress the rule.
// Before the fix, ANY <x>.replace(/<anything>/g, '/') was accepted as a normalizer
// and would suppress the violation even when the regex did not target backslashes.
describe('C1 — isPosixNormalizerCall backslash-targeting requirement', () => {
test('C1 INVALID: path.join().replace(/foo/g, "/") is NOT a POSIX normalizer — flagged', () => {
// Before C1 fix: was NOT flagged (any regex with g flag was accepted as normalizer).
// After C1 fix: IS flagged (/foo/g does not target backslashes → not a normalizer;
// rule now peels the non-normalizer method chain and finds path.join inside).
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.equal(path.join(a,b).replace(/foo/g, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('C1 INVALID: path.join().replace(/\\//g, "/") targets forward-slash only — flagged', () => {
// Before C1 fix: was NOT flagged.
// After C1 fix: IS flagged (/\//g matches forward-slash only, not backslash → not a normalizer).
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [],
invalid: [
{
code: `assert.equal(path.join(a,b).replace(/\\//g, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
errors: [{ messageId: 'pathLiteral' }],
},
],
});
});
test('C1 VALID: path.join().replace(/\\\\/g, "/") IS a proper POSIX normalizer — NOT flagged', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(path.join(a,b).replace(/\\\\/g, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
test('C1 VALID: path.join().replace(/[\\\\/]/g, "/") IS a proper POSIX normalizer — NOT flagged', () => {
ruleTester.run('no-path-literal-in-assert', noPathLiteralInAssert, {
valid: [
{
code: `assert.equal(path.join(a,b).replace(/[\\\\/]/g, '/'), '/x/y');`,
filename: 'tests/foo.test.cjs',
},
],
invalid: [],
});
});
});

View File

@@ -0,0 +1,381 @@
'use strict';
/**
* platform-guard.unit.test.cjs
*
* Unit tests for eslint-rules/lib/platform-guard.cjs.
* Verifies classifyPlatformTest and isWindowsExcludedNode shapes
* using espree to parse code snippets into ASTs.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const espree = require('espree');
const { Linter } = require('eslint');
const { classifyPlatformTest, isWindowsExcludedNode } = require('../eslint-rules/lib/platform-guard.cjs');
const PARSE_OPTIONS = {
ecmaVersion: 2022,
sourceType: 'script',
range: true,
loc: true,
tokens: true,
comment: true,
};
function parse(code) {
return espree.parse(code, PARSE_OPTIONS);
}
// ─── classifyPlatformTest ─────────────────────────────────────────────────────
describe('classifyPlatformTest', () => {
test('process.platform === "win32" → windows', () => {
const ast = parse(`process.platform === 'win32'`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('"win32" === process.platform (reversed) → windows', () => {
const ast = parse(`'win32' === process.platform`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('process.platform !== "win32" → not-windows', () => {
const ast = parse(`process.platform !== 'win32'`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'not-windows');
});
test('os.platform() === "win32" → windows', () => {
const ast = parse(`os.platform() === 'win32'`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('os.platform() !== "win32" → not-windows', () => {
const ast = parse(`os.platform() !== 'win32'`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'not-windows');
});
test('isWindows identifier → windows', () => {
const ast = parse(`isWindows`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('IS_WINDOWS identifier → windows', () => {
const ast = parse(`IS_WINDOWS`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('isWin identifier → windows', () => {
const ast = parse(`isWin`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('onWindows identifier → windows', () => {
const ast = parse(`onWindows`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'windows');
});
test('!isWindows → not-windows', () => {
const ast = parse(`!isWindows`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), 'not-windows');
});
test('unrelated expression → null', () => {
const ast = parse(`x === 'linux'`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), null);
});
test('bare identifier "result" → null', () => {
const ast = parse(`result`);
const node = ast.body[0].expression;
assert.strictEqual(classifyPlatformTest(node), null);
});
});
// ─── isWindowsExcludedNode via Linter ────────────────────────────────────────
// We use the Linter + a custom rule to get real sourceCode with ancestors.
/**
* Build a mini rule that collects data about CallExpression nodes named
* "assert.equal" and checks isWindowsExcludedNode on them.
*/
function buildCollectorRule() {
return {
create(context) {
const sourceCode = context.sourceCode;
const results = [];
return {
CallExpression(node) {
if (
node.callee.type === 'MemberExpression' &&
node.callee.object.name === 'assert' &&
node.callee.property.name === 'equal'
) {
results.push(isWindowsExcludedNode(node, sourceCode));
}
},
'Program:exit'() {
context.report({ node: sourceCode.ast, messageId: 'result', data: { results: JSON.stringify(results) } });
},
};
},
meta: { type: 'suggestion', schema: [], messages: { result: '{{results}}' } },
};
}
function runCollector(code) {
const linter = new Linter({ configType: 'flat' });
const messages = linter.verify(
code,
[{ plugins: { t: { rules: { collector: buildCollectorRule() } } }, rules: { 't/collector': 'warn' }, languageOptions: { ecmaVersion: 2022, sourceType: 'script' } }],
{ filename: 'tests/x.test.cjs' }
);
// The rule emits exactly one message (Program:exit) with results as JSON
const msg = messages.find(m => m.ruleId === 't/collector');
if (!msg) return [];
return JSON.parse(msg.message);
}
describe('isWindowsExcludedNode — if (!windows) { assert } shape', () => {
test('assert inside if (process.platform !== "win32") block → excluded=true', () => {
const code = `
if (process.platform !== 'win32') {
assert.equal(path.join(a, b), '/x/y');
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('assert inside else of if (process.platform === "win32") block → excluded=true', () => {
const code = `
if (process.platform === 'win32') {
doWindows();
} else {
assert.equal(path.join(a, b), '/x/y');
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('assert NOT inside any guard → excluded=false', () => {
const code = `assert.equal(path.join(a, b), '/x/y');`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false]);
});
});
describe('isWindowsExcludedNode — early-return guard shape', () => {
test('if (process.platform === "win32") return; assert.equal(...) → excluded=true', () => {
const code = `
function test() {
if (process.platform === 'win32') return;
assert.equal(path.join(a, b), '/x/y');
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('if (process.platform === "win32") return t.skip(); assert.equal → excluded=true', () => {
const code = `
function test(t) {
if (process.platform === 'win32') return t.skip('no windows');
assert.equal(path.join(a, b), '/x/y');
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('guard appears AFTER the assert → excluded=false', () => {
const code = `
function test() {
assert.equal(path.join(a, b), '/x/y');
if (process.platform === 'win32') return;
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false]);
});
});
describe('isWindowsExcludedNode — hoisted isWindows guard shape', () => {
test('const isWindows = process.platform === "win32"; if (!isWindows) assert.equal → excluded=true', () => {
const code = `
const isWindows = process.platform === 'win32';
if (!isWindows) assert.equal(path.join(a, b), '/x/y');
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('const isWindows = …; if (isWindows) {} else { assert.equal } → excluded=true', () => {
const code = `
const isWindows = process.platform === 'win32';
if (isWindows) { doWindowsThing(); } else { assert.equal(path.join(a,b), '/x/y'); }
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
});
describe('isWindowsExcludedNode — arbitrary-named hoisted boolean (real hoisting)', () => {
test('const winFlag = process.platform === "win32"; if (!winFlag) assert.equal → excluded=true', () => {
const code = `
function test() {
const winFlag = process.platform === 'win32';
if (!winFlag) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('const winFlag = process.platform === "win32"; if (winFlag) return; assert.equal → excluded=true', () => {
const code = `
function test() {
const winFlag = process.platform === 'win32';
if (winFlag) return;
assert.equal(path.join(a, b), '/x/y');
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('unrelated variable used as guard is NOT excluded', () => {
const code = `
function test() {
const debugMode = true;
if (!debugMode) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false]);
});
test('winFlag declared AFTER the assert is NOT a guard → excluded=false', () => {
const code = `
function test() {
assert.equal(path.join(a, b), '/x/y');
const winFlag = process.platform === 'win32';
if (!winFlag) { doSomething(); }
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false]);
});
});
// ─── C2: early-return guard must climb ancestor blocks ────────────────────────
describe('C2 — early-return guard climbs ancestor blocks', () => {
test('C2: early-return in outer block before nested if-block → excluded=true', () => {
// The guard is in the function body; assert.equal is inside a nested if-block.
// Before the fix, _hasEarlyWindowsReturnBefore only checked the innermost block.
const code = `
function test() {
if (process.platform === 'win32') return;
if (cond) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true]);
});
test('C2: no early-return at all → excluded=false', () => {
const code = `
function test() {
if (cond) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false]);
});
});
// ─── C3: binding-aware identifier classification ──────────────────────────────
describe('C3 — binding-aware identifier classification', () => {
test('C3 case 1: const isWindows = false; if (!isWindows) assert.equal → excluded=false (FLAGGED)', () => {
// isWindows is in WINDOWS_BOOL_NAMES but its binding is `false`, not a platform test.
const code = `
function test() {
const isWindows = false;
if (!isWindows) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false], 'const isWindows = false should not be treated as a platform guard');
});
test('C3 case 2: let w = platform===win32; w = false; if (!w) assert.equal → excluded=false (FLAGGED)', () => {
// w starts as a platform test but is reassigned — should NOT be trusted.
const code = `
function test() {
let w = process.platform === 'win32';
w = false;
if (!w) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false], 'reassigned variable should not be treated as a platform guard');
});
test('C3 case 3: inner shadow const w = false wins over outer const w = platform test → excluded=false (FLAGGED)', () => {
// The inner const w = false shadows the outer const w = platform test.
const code = `
function test() {
const w = process.platform === 'win32';
{
const w = false;
if (!w) {
assert.equal(path.join(a, b), '/x/y');
}
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [false], 'inner shadow (w=false) should win over outer platform test');
});
test('C3 case 4: const w = platform test; if (!w) assert.equal → excluded=true (genuine guard)', () => {
// The canonical case — should still work.
const code = `
function test() {
const w = process.platform === 'win32';
if (!w) {
assert.equal(path.join(a, b), '/x/y');
}
}
`;
const results = runCollector(code);
assert.deepStrictEqual(results, [true], 'genuine platform guard should suppress the report');
});
});

View File

@@ -0,0 +1,199 @@
'use strict';
/**
* portability-rule-disable-ban.test.cjs
*
* Out-of-band disable-ban scan (ADR-1703).
*
* ESLint inline suppression of portability rules is banned. This test runs
* OUTSIDE ESLint so it cannot itself be eslint-disabled.
*
* PROTECTED_RULES grows as later phases add rules. Each new portability rule
* in the `local/` namespace should be appended to this list.
*
* Hard-fails on:
* (a) Any `eslint-disable*` comment that NAMES a protected portability rule.
* (b) Any BLANKET `eslint-disable*` comment (no rule list) — these suppress
* every rule including the protected ones.
*
* NOTE: This file itself is excluded from the scan by absolute path. It
* references the disable keyword only inside regex/string data structures to
* avoid being detected as a real directive.
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const espree = require('espree');
const { globSync } = require('glob');
// ── Protected portability rules (grows with each ADR-1703 phase) ──────────────
const PROTECTED_RULES = [
'no-path-literal-in-assert',
// Future phases: add new local/ portability rules here.
];
// ── Detect disable directives via the comment text ───────────────────────────
// The three directive forms ESLint recognises (built as concatenated strings so
// this source file contains NO real disable directive of its own).
const D = 'eslint-' + 'disable';
const DN = 'eslint-' + 'disable-next-line';
const DL = 'eslint-' + 'disable-line';
const DISABLE_PREFIXES = [DN, DL, D]; // longest first so prefix-match is greedy
/**
* Classify a comment node. Returns:
* 'blanket' — a disable with NO rule list (suppresses everything)
* 'named' — a disable that lists at least one protected portability rule
* null — not a disable directive, or a non-portability named disable
*/
function classifyComment(commentValue) {
const txt = commentValue.trim();
for (const prefix of DISABLE_PREFIXES) {
if (txt.startsWith(prefix)) {
// Text after the directive keyword
const rest = txt.slice(prefix.length).trim();
// Blanket: nothing after the keyword, or only a prose comment (starts with --)
if (!rest || rest.startsWith('--')) {
return 'blanket';
}
// Named: rest is a comma-separated rule list (possibly with -- prose)
const ruleList = rest.split('--')[0]; // strip trailing prose
const rules = ruleList.split(',').map(r => r.trim()).filter(Boolean);
for (const rule of rules) {
for (const protected_ of PROTECTED_RULES) {
if (rule === 'local/' + protected_ || rule === protected_) {
return 'named';
}
}
}
return null; // named disable but not for a protected rule
}
}
return null;
}
// ── Collect test files ────────────────────────────────────────────────────────
const SELF_ABS = __filename;
function collectTestFiles() {
return globSync('tests/**/*.test.cjs', { cwd: path.join(__dirname, '..') })
.map(rel => path.join(__dirname, '..', rel))
.filter(absPath => absPath !== SELF_ABS);
}
// ── Scan ──────────────────────────────────────────────────────────────────────
function scanFile(absPath) {
let src;
try {
src = fs.readFileSync(absPath, 'utf-8');
} catch (err) {
throw new Error(`Could not read ${absPath}: ${err.message}`);
}
let ast;
try {
ast = espree.parse(src, {
comment: true,
ecmaVersion: 2022,
loc: true,
range: true,
tolerant: true,
});
} catch (parseErr) {
// C5: fail CLOSED on parse error — a file that fails to parse must FAIL the
// test with its path, not be silently skipped. Silent skip is a false-green:
// an unparseable test file could contain a real disable directive.
throw new Error(`Parse error in ${absPath}: ${parseErr.message}`);
}
const blanket = [];
const named = [];
for (const cmt of ast.comments || []) {
const kind = classifyComment(cmt.value);
if (!kind) continue;
const line = cmt.loc ? cmt.loc.start.line : '?';
const entry = { file: absPath, line, text: cmt.value.trim() };
if (kind === 'blanket') blanket.push(entry);
else if (kind === 'named') named.push(entry);
}
return { blanket, named };
}
// ── C5: parse-error fail-closed ───────────────────────────────────────────────
describe('C5 — scanFile fails closed on parse error', () => {
test('C5: scanFile throws on parse error instead of silently returning empty result', () => {
// Inject a parse error deterministically by monkeypatching espree.parse.
// This is the cross-platform approach (works under root/Docker too).
const origParse = espree.parse;
try {
espree.parse = () => { throw new SyntaxError('injected parse error for C5 test'); };
// Create a minimal real file to scan (use this test file itself, which exists).
assert.throws(
() => scanFile(__filename),
(err) => {
return err instanceof Error &&
err.message.includes('injected parse error for C5 test');
},
'scanFile must throw on parse error, not silently return empty result'
);
} finally {
espree.parse = origParse;
}
});
});
// ── Tests ─────────────────────────────────────────────────────────────────────
describe('portability-rule disable-ban (ADR-1703)', () => {
const testFiles = collectTestFiles();
test('test file enumeration finds at least 10 test files', () => {
assert.ok(
testFiles.length >= 10,
`Expected at least 10 test files, got ${testFiles.length}`,
);
});
test('no test file contains a named eslint-disable for a portability rule (category a)', () => {
const offenders = [];
for (const absPath of testFiles) {
const { named } = scanFile(absPath);
for (const o of named) {
offenders.push(`${path.relative(path.join(__dirname, '..'), o.file)}:${o.line} — ${o.text}`);
}
}
assert.deepStrictEqual(
offenders,
[],
'Found inline disable directives suppressing protected portability rules.\n' +
'These MUST be removed — the rule exists to enforce cross-platform safety:\n\n' +
offenders.map(s => ' ' + s).join('\n'),
);
});
test('no test file contains a blanket eslint-disable (category b — suppresses all rules including portability)', () => {
const offenders = [];
for (const absPath of testFiles) {
const { blanket } = scanFile(absPath);
for (const o of blanket) {
offenders.push(`${path.relative(path.join(__dirname, '..'), o.file)}:${o.line} — ${o.text}`);
}
}
assert.deepStrictEqual(
offenders,
[],
'Found blanket eslint-disable directives in test files.\n' +
'Blanket disables suppress ALL rules including portability rules and are banned.\n' +
'Replace with targeted per-rule disables for non-portability rules, or remove:\n\n' +
offenders.map(s => ' ' + s).join('\n'),
);
});
});

View File

@@ -0,0 +1,339 @@
'use strict';
/**
* portability-vocab-drift.test.cjs
*
* Drift-guard: ensure that every exported function from src/runtime-homes.cts
* that returns a filesystem path is listed in PATH_RETURNING_FNS
* (eslint-rules/lib/portability-vocab.cjs).
*
* Method:
* 1. Parse src/runtime-homes.cts with @typescript-eslint/parser.
* 2. Collect `export function <name>` declarations where the body contains
* a path-building expression (path.join, path.dirname, os.homedir,
* expandTilde, or returns something with "Dir" / "Path" / "Base" in its name).
* 3. Assert each collected name is in PATH_RETURNING_FNS or is listed in
* IGNORED_NON_PATH_EXPORTS (with a reason comment per entry).
*/
const { test, describe } = require('node:test');
const assert = require('node:assert/strict');
const fs = require('fs');
const path = require('path');
const tsParser = require('@typescript-eslint/parser');
const { PATH_RETURNING_FNS } = require('../eslint-rules/lib/portability-vocab.cjs');
// Functions exported from runtime-homes.cts that do NOT return a filesystem
// path and therefore are intentionally excluded from PATH_RETURNING_FNS.
const IGNORED_NON_PATH_EXPORTS = new Set([
// resolveConfigHomeFromDescriptor: internal/exported but delegates to path-returning helpers;
// it IS included in PATH_RETURNING_FNS under its bare name (no object prefix needed).
// detectAntigravityDirAmbiguity: returns an object (AntigravityAmbiguity), not a path string.
'detectAntigravityDirAmbiguity',
]);
describe('portability-vocab drift guard', () => {
test('PATH_RETURNING_FNS is a non-empty array', () => {
assert.ok(Array.isArray(PATH_RETURNING_FNS));
assert.ok(PATH_RETURNING_FNS.length > 0);
});
test('PATH_RETURNING_FNS includes the Node builtins', () => {
const builtins = ['path.join', 'path.resolve', 'path.dirname', 'path.basename', 'path.normalize', 'path.relative', 'os.homedir', 'os.tmpdir'];
for (const fn of builtins) {
assert.ok(PATH_RETURNING_FNS.includes(fn), `Expected PATH_RETURNING_FNS to include builtin "${fn}"`);
}
});
test('every path-returning export from runtime-homes.cts is in PATH_RETURNING_FNS or IGNORED', () => {
const srcPath = path.join(__dirname, '..', 'src', 'runtime-homes.cts');
const src = fs.readFileSync(srcPath, 'utf-8');
// Parse with @typescript-eslint/parser (handles TypeScript syntax)
const ast = tsParser.parse(src, {
jsx: false,
loc: true,
range: true,
comment: true,
tokens: false,
});
// Collect exported function names whose body looks path-returning:
// - body contains a call to path.join / path.dirname / os.homedir / expandTilde
// - OR function name ends with Dir, Path, Base, Home, or starts with resolve/get
const pathReturningExports = [];
function bodyText(node) {
// Slice the source for the function body
if (node.range) return src.slice(node.range[0], node.range[1]);
return '';
}
function looksPathReturning(funcNode, name) {
const body = bodyText(funcNode.body ?? funcNode);
const pathBuilders = [
'path.join', 'path.resolve', 'path.dirname', 'path.basename',
'path.normalize', 'path.relative', 'os.homedir', 'os.tmpdir',
'expandTilde', 'expandTildeWithHome', 'resolveConfigHome',
];
if (pathBuilders.some(p => body.includes(p))) return true;
// Name heuristic: resolveXxx / getXxxDir / getXxxPath / getXxxBase
if (/^(resolve|get)[A-Z]/.test(name) && /Dir|Path|Base|Home|Skills/.test(name)) return true;
return false;
}
// Build a lookup from top-level declaration names to their function nodes,
// to resolve `export { name }` specifier exports (C4).
const topLevelFunctionNodes = new Map(); // name → funcNode
for (const node of ast.body) {
// function <name>(...) { ... } (non-exported declaration)
if (
node.type === 'FunctionDeclaration' &&
node.id
) {
topLevelFunctionNodes.set(node.id.name, node);
}
// const <name> = () => ... (non-exported const arrow/function)
if (node.type === 'VariableDeclaration') {
for (const decl of node.declarations) {
if (
decl.type === 'VariableDeclarator' &&
decl.id &&
decl.id.type === 'Identifier' &&
decl.init &&
(decl.init.type === 'ArrowFunctionExpression' ||
decl.init.type === 'FunctionExpression')
) {
topLevelFunctionNodes.set(decl.id.name, decl.init);
}
}
}
// export function / export const — also register in the map
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'FunctionDeclaration' &&
node.declaration.id
) {
topLevelFunctionNodes.set(node.declaration.id.name, node.declaration);
}
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'VariableDeclaration'
) {
for (const decl of node.declaration.declarations) {
if (
decl.type === 'VariableDeclarator' &&
decl.id &&
decl.id.type === 'Identifier' &&
decl.init &&
(decl.init.type === 'ArrowFunctionExpression' ||
decl.init.type === 'FunctionExpression')
) {
topLevelFunctionNodes.set(decl.id.name, decl.init);
}
}
}
}
for (const node of ast.body) {
// export function <name>(...) { ... }
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'TSDeclareFunction' === false &&
(node.declaration.type === 'FunctionDeclaration') &&
node.declaration.id
) {
const name = node.declaration.id.name;
if (looksPathReturning(node.declaration, name)) {
pathReturningExports.push(name);
}
}
// export const <name> = (<ArrowFunctionExpression> | <FunctionExpression>)
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'VariableDeclaration'
) {
for (const decl of node.declaration.declarations) {
if (
decl.type === 'VariableDeclarator' &&
decl.id &&
decl.id.type === 'Identifier' &&
decl.init &&
(decl.init.type === 'ArrowFunctionExpression' ||
decl.init.type === 'FunctionExpression')
) {
const name = decl.id.name;
if (looksPathReturning(decl.init, name)) {
pathReturningExports.push(name);
}
}
}
}
// export { name1, name2 } — specifier exports (C4)
// Resolve each specifier to its in-file function/const declaration.
if (
node.type === 'ExportNamedDeclaration' &&
!node.declaration &&
node.source == null && // not a re-export from another module
Array.isArray(node.specifiers)
) {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ExportSpecifier' &&
specifier.local &&
specifier.local.type === 'Identifier'
) {
const name = specifier.local.name;
const exportedName =
specifier.exported && specifier.exported.type === 'Identifier'
? specifier.exported.name
: name;
const funcNode = topLevelFunctionNodes.get(name);
if (funcNode && looksPathReturning(funcNode, exportedName)) {
pathReturningExports.push(exportedName);
}
}
}
}
}
// Verify we found at least a few (guards against parser silently failing)
assert.ok(
pathReturningExports.length >= 3,
`Expected at least 3 path-returning exports, got ${pathReturningExports.length}: [${pathReturningExports.join(', ')}]`
);
const vocabSet = new Set(PATH_RETURNING_FNS);
const missing = [];
for (const name of pathReturningExports) {
if (!vocabSet.has(name) && !IGNORED_NON_PATH_EXPORTS.has(name)) {
missing.push(name);
}
}
assert.deepStrictEqual(
missing,
[],
`These path-returning exports from runtime-homes.cts are missing from PATH_RETURNING_FNS:\n ${missing.join('\n ')}\n\nEither add them to PATH_RETURNING_FNS in eslint-rules/lib/portability-vocab.cjs or add them to IGNORED_NON_PATH_EXPORTS with a reason.`
);
});
test('export const arrow-function returning path.join would be required in PATH_RETURNING_FNS', () => {
// Simulate parsing a snippet with `export const myArrowResolver = (x) => path.join(home, x)`
// and verify the drift-guard collector would pick it up (i.e. it's NOT silently bypassed).
const snippetSrc = `
export const myArrowResolver = (x) => path.join('/home', x);
`;
const ast = tsParser.parse(snippetSrc, {
jsx: false,
loc: true,
range: true,
comment: true,
tokens: false,
});
const collected = [];
for (const node of ast.body) {
if (
node.type === 'ExportNamedDeclaration' &&
node.declaration &&
node.declaration.type === 'VariableDeclaration'
) {
for (const decl of node.declaration.declarations) {
if (
decl.type === 'VariableDeclarator' &&
decl.id &&
decl.id.type === 'Identifier' &&
decl.init &&
(decl.init.type === 'ArrowFunctionExpression' ||
decl.init.type === 'FunctionExpression')
) {
const name = decl.id.name;
const bodyTxt = snippetSrc.slice(decl.init.range[0], decl.init.range[1]);
if (['path.join', 'path.resolve', 'path.dirname'].some(p => bodyTxt.includes(p))) {
collected.push(name);
}
}
}
}
}
assert.deepStrictEqual(collected, ['myArrowResolver'],
'Arrow-function path export should be collected by the drift guard, requiring it in PATH_RETURNING_FNS');
// Confirm PATH_RETURNING_FNS does NOT already contain this fictional name
// (so the test demonstrates a missing entry would be caught, not silently pass).
assert.ok(
!PATH_RETURNING_FNS.includes('myArrowResolver'),
'myArrowResolver should not be in PATH_RETURNING_FNS (it is a test fixture name)',
);
});
test('C4: export { name } specifier form — path resolver would be required in PATH_RETURNING_FNS', () => {
// Demonstrate that the drift guard now handles `export { mySpecifierResolver }` where
// the function is declared separately (not inline in the export statement).
const snippetSrc = `
function mySpecifierResolver(x) {
return path.join('/home', x);
}
export { mySpecifierResolver };
`;
const ast = tsParser.parse(snippetSrc, {
jsx: false,
loc: true,
range: true,
comment: true,
tokens: false,
});
// Replicate the drift-guard's specifier-resolution logic (C4 addition).
const topLevelFns = new Map();
for (const node of ast.body) {
if (node.type === 'FunctionDeclaration' && node.id) {
topLevelFns.set(node.id.name, node);
}
}
const collected = [];
for (const node of ast.body) {
if (
node.type === 'ExportNamedDeclaration' &&
!node.declaration &&
node.source == null &&
Array.isArray(node.specifiers)
) {
for (const specifier of node.specifiers) {
if (
specifier.type === 'ExportSpecifier' &&
specifier.local &&
specifier.local.type === 'Identifier'
) {
const localName = specifier.local.name;
const funcNode = topLevelFns.get(localName);
if (funcNode) {
const bodyTxt = snippetSrc.slice(funcNode.range[0], funcNode.range[1]);
if (['path.join', 'path.resolve', 'path.dirname'].some(p => bodyTxt.includes(p))) {
collected.push(localName);
}
}
}
}
}
}
assert.deepStrictEqual(collected, ['mySpecifierResolver'],
'export { name } specifier form should be detected by drift guard, requiring entry in PATH_RETURNING_FNS');
assert.ok(
!PATH_RETURNING_FNS.includes('mySpecifierResolver'),
'mySpecifierResolver should not be in PATH_RETURNING_FNS (it is a test fixture name)',
);
});
});

View File

@@ -178,7 +178,7 @@ describe('descriptor-driven equivalence: env-var overrides', () => {
process.env['COPILOT_CONFIG_DIR'] = '/custom/copilot-dir';
process.env['COPILOT_HOME'] = '/should/not/win';
try {
assert.strictEqual(getGlobalConfigDir('copilot'), '/custom/copilot-dir');
assert.strictEqual(String(getGlobalConfigDir('copilot')).replace(/\\/g, '/'), '/custom/copilot-dir');
} finally {
restoreEnvKeys(saved);
}
@@ -188,7 +188,7 @@ describe('descriptor-driven equivalence: env-var overrides', () => {
const saved = clearAllEnvKeys();
process.env['COPILOT_HOME'] = '/custom/copilot-home';
try {
assert.strictEqual(getGlobalConfigDir('copilot'), '/custom/copilot-home');
assert.strictEqual(String(getGlobalConfigDir('copilot')).replace(/\\/g, '/'), '/custom/copilot-home');
} finally {
restoreEnvKeys(saved);
}
@@ -219,7 +219,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => {
const saved = clearAllEnvKeys();
process.env['OPENCODE_CONFIG'] = '/home/u/cfg/opencode.json';
try {
assert.strictEqual(getGlobalConfigDir('opencode'), '/home/u/cfg');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/home/u/cfg');
} finally {
restoreEnvKeys(saved);
}
@@ -230,7 +230,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => {
process.env['OPENCODE_CONFIG_DIR'] = '/dir/wins';
process.env['OPENCODE_CONFIG'] = '/file/loses.json';
try {
assert.strictEqual(getGlobalConfigDir('opencode'), '/dir/wins');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/dir/wins');
} finally {
restoreEnvKeys(saved);
}
@@ -241,7 +241,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => {
process.env['OPENCODE_CONFIG'] = '/cfg/opencode.json';
process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose';
try {
assert.strictEqual(getGlobalConfigDir('opencode'), '/cfg');
assert.strictEqual(String(getGlobalConfigDir('opencode')).replace(/\\/g, '/'), '/cfg');
} finally {
restoreEnvKeys(saved);
}
@@ -272,7 +272,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => {
const saved = clearAllEnvKeys();
process.env['KILO_CONFIG'] = '/home/u/cfg/kilo.json';
try {
assert.strictEqual(getGlobalConfigDir('kilo'), '/home/u/cfg');
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/home/u/cfg');
} finally {
restoreEnvKeys(saved);
}
@@ -283,7 +283,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => {
process.env['KILO_CONFIG_DIR'] = '/dir/wins';
process.env['KILO_CONFIG'] = '/file/loses.json';
try {
assert.strictEqual(getGlobalConfigDir('kilo'), '/dir/wins');
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/dir/wins');
} finally {
restoreEnvKeys(saved);
}
@@ -294,7 +294,7 @@ describe('descriptor-driven equivalence: xdg runtimes (opencode, kilo)', () => {
process.env['KILO_CONFIG'] = '/cfg/kilo.json';
process.env['XDG_CONFIG_HOME'] = '/xdg/should/lose';
try {
assert.strictEqual(getGlobalConfigDir('kilo'), '/cfg');
assert.strictEqual(String(getGlobalConfigDir('kilo')).replace(/\\/g, '/'), '/cfg');
} finally {
restoreEnvKeys(saved);
}
@@ -735,10 +735,10 @@ describe('descriptor-driven equivalence: generic-agents-root kimi probe hit/miss
describe('descriptor-driven equivalence: explicitDir short-circuit', () => {
test('explicitDir absolute path returned as-is (any runtime)', () => {
assert.strictEqual(getGlobalConfigDir('claude', '/tmp/explicit'), '/tmp/explicit');
assert.strictEqual(getGlobalConfigDir('opencode', '/tmp/explicit'), '/tmp/explicit');
assert.strictEqual(getGlobalConfigDir('kimi', '/tmp/explicit'), '/tmp/explicit');
assert.strictEqual(getGlobalConfigDir('grok', '/tmp/explicit'), '/tmp/explicit');
assert.strictEqual(String(getGlobalConfigDir('claude', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
assert.strictEqual(String(getGlobalConfigDir('opencode', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
assert.strictEqual(String(getGlobalConfigDir('kimi', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
assert.strictEqual(String(getGlobalConfigDir('grok', '/tmp/explicit')).replace(/\\/g, '/'), '/tmp/explicit');
});
test('explicitDir with ~ is expanded', () => {
@@ -750,7 +750,7 @@ describe('descriptor-driven equivalence: explicitDir short-circuit', () => {
test('explicitDir wins even when env var is set', () => {
withEnv({ CLAUDE_CONFIG_DIR: '/should/not/win' }, () => {
assert.strictEqual(getGlobalConfigDir('claude', '/explicit/wins'), '/explicit/wins');
assert.strictEqual(String(getGlobalConfigDir('claude', '/explicit/wins')).replace(/\\/g, '/'), '/explicit/wins');
});
});
});
@@ -769,7 +769,7 @@ describe('descriptor-driven equivalence: grok (not in registry)', () => {
test('grok: GROK_AGENTS_HOME override', () => {
withEnv({ GROK_AGENTS_HOME: '/custom/grok-agents' }, () => {
assert.strictEqual(getGlobalConfigDir('grok'), '/custom/grok-agents');
assert.strictEqual(String(getGlobalConfigDir('grok')).replace(/\\/g, '/'), '/custom/grok-agents');
});
});
@@ -794,7 +794,7 @@ describe('descriptor-driven equivalence: unknown runtime fallback', () => {
test('unknown runtime → CLAUDE_CONFIG_DIR if set', () => {
withEnv({ CLAUDE_CONFIG_DIR: '/custom/claude-for-unknown' }, () => {
assert.strictEqual(getGlobalConfigDir('no-such-runtime'), '/custom/claude-for-unknown');
assert.strictEqual(String(getGlobalConfigDir('no-such-runtime')).replace(/\\/g, '/'), '/custom/claude-for-unknown');
});
});
});