* test(#3664): failing-first rows for the config-dir foreign-agent warning * fix(#3664): warn when config-dir targets a foreign-agent destination * test(#3664): fold the foreign-agent warning rows into the install-regressions suite * fix(#3664): close review findings — kimi-agents kind, gsd.md ownership, e2e gate * test(#3664): sync boolean call sites and the path-vocab registries * chore(#3664): backfill changeset pr number * test(#3663): skip the posix case-pin on win32 where folding is the fix --------- Co-authored-by: sim <sim@local>
491 lines
20 KiB
JavaScript
491 lines
20 KiB
JavaScript
'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 espree = require('espree');
|
|
|
|
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',
|
|
]);
|
|
|
|
// bin/install.js path-returning helpers that are registered in PATH_RETURNING_FNS.
|
|
// This is the curated set named by ADR-1703 L114-119 ("the relevant bin/install.js
|
|
// exports"). The companion test below locks it two ways: each must still be DEFINED
|
|
// in the generated installer (catches a rename/removal making the vocab entry stale),
|
|
// AND this list must equal the PATH_RETURNING_FNS bare-names that are defined in
|
|
// bin/install.js (so the curation cannot drift silently out of sync with the vocab).
|
|
const INSTALL_JS_PATH_HELPERS = [
|
|
'getConfigDirFromHome',
|
|
'getGlobalDir',
|
|
'resolveKiloConfigPath',
|
|
'resolveOpencodeConfigPath',
|
|
'computePathPrefix',
|
|
'normalizeInstallRelativePath',
|
|
// #2088 (ADR-1239 upgrade 3): resolves the skills-install dir honoring a
|
|
// skills-kind `home` override (e.g. Codex → $HOME/.agents/skills).
|
|
'_resolveSkillsRootDir',
|
|
// #3664: shared kind-destination resolver (skills/agents/kimi-agents kinds).
|
|
'_kindDestDir',
|
|
];
|
|
|
|
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)',
|
|
);
|
|
});
|
|
|
|
// ─── ADR-1703 L114-119: the drift guard also covers "the relevant bin/install.js
|
|
// exports". The generated installer is a path-heavy 12k-line CommonJS file where
|
|
// the loose body-contains heuristic (used for runtime-homes.cts) is UNSOUND — it
|
|
// produces ~33 false positives because nearly every function uses path.join. The
|
|
// two checks below use SOUND shapes only, and document the residual boundary.
|
|
test('bin/install.js: every function that DIRECTLY returns a path.* call is in PATH_RETURNING_FNS', () => {
|
|
const srcPath = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const raw = fs.readFileSync(srcPath, 'utf8');
|
|
// bin/install.js starts with a shebang espree cannot parse — rewrite #! -> //.
|
|
const src = raw.startsWith('#!') ? '//' + raw.slice(2) : raw;
|
|
const ast = espree.parse(src, { ecmaVersion: 2022, loc: true, range: true, tolerant: true });
|
|
|
|
// SOUND shape: a top-level function whose body contains a ReturnStatement
|
|
// whose argument is a CallExpression to path.join/resolve/dirname/normalize/
|
|
// relative. This is tight (0 false positives on the current installer) and
|
|
// catches the canonical resolver shape (resolveOpencodeConfigPath,
|
|
// resolveKiloConfigPath). It does NOT catch a resolver that builds a path
|
|
// into a temp variable then returns the temp — see the boundary note below.
|
|
function returnsPathCall(funcBody) {
|
|
let found = false;
|
|
function walk(n) {
|
|
if (found || !n || typeof n !== 'object') return;
|
|
if (n.type === 'ReturnStatement' && n.argument && n.argument.type === 'CallExpression') {
|
|
const callee = n.argument.callee;
|
|
if (
|
|
callee.type === 'MemberExpression' &&
|
|
!callee.computed &&
|
|
callee.object.type === 'Identifier' &&
|
|
callee.object.name === 'path' &&
|
|
callee.property.type === 'Identifier' &&
|
|
['join', 'resolve', 'dirname', 'normalize', 'relative', 'basename'].includes(callee.property.name)
|
|
) {
|
|
found = true;
|
|
return;
|
|
}
|
|
}
|
|
// Do NOT descend into nested function expressions/declarations — a path
|
|
// return inside a nested callback does not make the outer fn path-returning.
|
|
if (n.type === 'FunctionExpression' || n.type === 'ArrowFunctionExpression' || n.type === 'FunctionDeclaration') return;
|
|
for (const key of Object.keys(n)) {
|
|
if (key === 'parent' || key === 'tokens' || key === 'comments') continue;
|
|
const child = n[key];
|
|
if (Array.isArray(child)) {
|
|
for (const item of child) {
|
|
if (item && typeof item === 'object' && item.type) walk(item);
|
|
}
|
|
} else if (child && typeof child === 'object' && child.type) {
|
|
walk(child);
|
|
}
|
|
}
|
|
}
|
|
walk(funcBody);
|
|
return found;
|
|
}
|
|
|
|
const pathReturning = [];
|
|
for (const node of ast.body) {
|
|
if (node.type === 'FunctionDeclaration' && node.id && node.body) {
|
|
if (returnsPathCall(node.body)) pathReturning.push(node.id.name);
|
|
}
|
|
}
|
|
|
|
const vocabSet = new Set(PATH_RETURNING_FNS);
|
|
const missing = pathReturning.filter((n) => !vocabSet.has(n));
|
|
assert.deepStrictEqual(
|
|
missing,
|
|
[],
|
|
`These bin/install.js functions return a path.* call but are missing from PATH_RETURNING_FNS:\n ${missing.join('\n ')}\n\nRegister them in eslint-rules/lib/portability-vocab.cjs (or, if they do not return a string path that flows into content/assertions, document why).`,
|
|
);
|
|
});
|
|
|
|
test('bin/install.js: the curated INSTALL_JS_PATH_HELPERS still exist (no stale vocab entries after a rename)', () => {
|
|
const srcPath = path.join(__dirname, '..', 'bin', 'install.js');
|
|
const raw = fs.readFileSync(srcPath, 'utf8');
|
|
const src = raw.startsWith('#!') ? '//' + raw.slice(2) : raw;
|
|
const ast = espree.parse(src, { ecmaVersion: 2022, loc: true, range: true, tolerant: true });
|
|
|
|
// Collect every top-level function-declaration AND const/let/var name defined
|
|
// in the installer (path helpers may be `function foo(){}` OR a const import
|
|
// like `const computePathPrefix = runtimeArtifactConversion._computePathPrefix`).
|
|
const defined = new Set();
|
|
for (const node of ast.body) {
|
|
if (node.type === 'FunctionDeclaration' && node.id) defined.add(node.id.name);
|
|
if (node.type === 'VariableDeclaration') {
|
|
for (const decl of node.declarations) {
|
|
if (decl.type === 'VariableDeclarator' && decl.id && decl.id.type === 'Identifier') {
|
|
defined.add(decl.id.name);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// (a) Each curated helper must still be defined — catches a rename/removal
|
|
// that would leave a stale entry in PATH_RETURNING_FNS (the rule would
|
|
// silently stop matching the renamed resolver).
|
|
const missing = INSTALL_JS_PATH_HELPERS.filter((n) => !defined.has(n));
|
|
assert.deepStrictEqual(
|
|
missing,
|
|
[],
|
|
`These curated bin/install.js path helpers are no longer defined in bin/install.js — their PATH_RETURNING_FNS entries are now stale:\n ${missing.join('\n ')}\n\nRename them in eslint-rules/lib/portability-vocab.cjs PATH_RETURNING_FNS and in INSTALL_JS_PATH_HELPERS here.`,
|
|
);
|
|
|
|
// (b) The curated list must equal the PATH_RETURNING_FNS bare-names that are
|
|
// defined in bin/install.js — so the curation cannot drift out of sync
|
|
// with the vocab. If a new install.js helper is added to PATH_RETURNING_FNS,
|
|
// it must also be added to INSTALL_JS_PATH_HELPERS (and vice versa).
|
|
const vocabSet = new Set(PATH_RETURNING_FNS);
|
|
const vocabHelpersDefinedInInstallJs = [...vocabSet].filter((n) => defined.has(n) && !n.includes('.')).sort();
|
|
assert.deepStrictEqual(
|
|
[...INSTALL_JS_PATH_HELPERS].sort(),
|
|
vocabHelpersDefinedInInstallJs,
|
|
`INSTALL_JS_PATH_HELPERS is out of sync with PATH_RETURNING_FNS entries defined in bin/install.js.\n` +
|
|
` curated list: [${[...INSTALL_JS_PATH_HELPERS].sort().join(', ')}]\n` +
|
|
` vocab ∩ install.js: [${vocabHelpersDefinedInInstallJs.join(', ')}]\n` +
|
|
`Reconcile the two lists.`,
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Known boundary (documented, not enforced) ─────────────────────────────────
|
|
//
|
|
// A NEW path-returning resolver added to bin/install.js that builds its path via
|
|
// a temp variable (`const p = path.join(...); return p;`) or by delegating to
|
|
// another helper (`return getGlobalDir(...)`) is NOT caught by the tight
|
|
// return-path.* check above (which requires `return path.join(...)` directly).
|
|
// The looser body-contains heuristic is unsound here (33 FPs on the current
|
|
// installer). The installer's path API is a small, stable, curated set; a new
|
|
// resolver there is caught at code review (the active resolver module,
|
|
// src/runtime-homes.cts, IS fully drift-guarded by the looser heuristic above,
|
|
// which is sound for that focused module).
|