enhance(#3624): local/no-exact-case-env-access — ratchet ADR-1703 onto production env reads (epic #3411 Phase 4) (#3976)
* enhance(#3624): local/no-exact-case-env-access — ratchet ADR-1703 onto production env reads (epic #3411 Phase 4) Extends ADR-1703's portability rule catalog with a second production-runtime rule: it flags an exact-case read of a Windows case-varying environment variable (PATH, PATHEXT, ComSpec, USERPROFILE, TEMP, TMP, APPDATA) off any receiver that is not process.env itself, matched via an env-shaped-receiver check to avoid colliding with ordinary `.path`-named properties elsewhere in the tree. Exports the seam's private `_envGet` as `envGet` so the rule's remediation message names a real helper, and fixes the one pre-existing violation the tightened rule found (`src/runtime-hooks-surface.cts`'s `env.APPDATA` read). Closes #3624 * fix(#3624): extractStaticName recognizes non-computed Literal destructuring keys; add missing accessor-call test case Review findings from the code-review + isolated-adversarial passes: - extractStaticName only matched non-computed Identifier keys, so a destructuring like `const { 'PATH': v } = opts.env;` (the issue's own I8 acceptance case) silently evaded the rule. Widened to accept a Literal key regardless of computed, which is safe for MemberExpression too (its non-computed property is always an Identifier by grammar). - Added the missing RuleTester valid case for "a case-insensitive accessor call" (envGet(env, 'PATH')) from the issue's Done-when checklist. * docs: backfill changeset PR number for #3624 (PR #3976) --------- Co-authored-by: sim <sim@local>
This commit is contained in:
333
tests/no-exact-case-env-access.rule.test.cjs
Normal file
333
tests/no-exact-case-env-access.rule.test.cjs
Normal file
@@ -0,0 +1,333 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-exact-case-env-access.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-exact-case-env-access ESLint rule.
|
||||
* Ids (V1-V14, I1-I8) map to the case list in the #3624 dispatch brief.
|
||||
*
|
||||
* RuleTester feeds fixtures to the rule directly and does not scan this test
|
||||
* file's own source, so the self-flagging problem that forced eslint.config.mjs
|
||||
* to carve the eslint-rules directory out of the scripts .cjs block does not
|
||||
* arise here (ADR-1703 rule 5).
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-exact-case-env-access.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
const OUTSIDE_SEAM_FILE = 'src/some-other-module.cts';
|
||||
const SEAM_FILE = 'src/shell-command-projection.cts';
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-exact-case-env-access rule module', () => {
|
||||
test('exports meta and create', () => {
|
||||
assert.strictEqual(typeof rule.meta, 'object');
|
||||
assert.strictEqual(typeof rule.create, 'function');
|
||||
assert.strictEqual(rule.meta.type, 'problem');
|
||||
assert.ok(rule.meta.messages.exactCaseEnvRead, 'exactCaseEnvRead message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID cases (V1-V14) ───────────────────────────────────────────────────
|
||||
|
||||
describe('no-exact-case-env-access: valid cases', () => {
|
||||
test('V1: process.env.PATH', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = process.env.PATH;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V2: process.env['PATH']", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = process.env['PATH'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V3: process['env'].PATH", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = process['env'].PATH;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V4: process['env']['PATH']", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = process['env']['PATH'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V5: const { PATH } = process.env;', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const { PATH } = process.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V6: config.path — unrelated object, dot, lowercase path (real false-positive class)', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = config.path;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V7: artifact['path'] — unrelated object, bracket, lowercase path (real false-positive class)", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = artifact['path'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V8: const { path } = someConfig; — destructuring an unrelated field from a non-env-shaped source', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const { path } = someConfig;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V9: env[someVar] — computed non-literal key, not statically decidable', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const v = env[someVar];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V10: opts.env.NODE_ENV — env-shaped receiver but name outside the vocab', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const v = opts.env.NODE_ENV;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V11: process.env.PATHEXT in src/shell-command-projection.cts — seam exemption', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const ext = process.env.PATHEXT;`,
|
||||
filename: SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V12: opts.env['PATH'] in src/shell-command-projection.cts — seam exemption applies even to a real violation shape", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = opts.env['PATH'];`,
|
||||
filename: SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test("V13: merged['PATH'] where merged is a bare identifier not named env — documented accepted false-negative", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `merged['PATH'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V14: const { [key]: v } = opts.env; — computed non-literal destructuring key', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const { [key]: v } = opts.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('V15: envGet(env, "PATH") — the case-insensitive accessor call itself is valid', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = envGet(env, 'PATH');`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases (I1-I8) ──────────────────────────────────────────────────
|
||||
|
||||
describe('no-exact-case-env-access: invalid cases', () => {
|
||||
test("I1: opts.env['PATH'] — 1 error", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const p = opts.env['PATH'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'exactCaseEnvRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I2: opts.env.pathext — lowercase dot form — 1 error', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = opts.env.pathext;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'exactCaseEnvRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I3: env['Pathext'] — bare env-named identifier, mixed case — 1 error", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const ext = env['Pathext'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'exactCaseEnvRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I4: const env = { ...process.env, ...opts.env }; env['PATH']; — spread-derived, read via env-shaped identifier — 1 error total", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const env = { ...process.env, ...opts.env }; env['PATH'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: 1,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I5: const { PATHEXT } = opts.env; — 1 error', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { PATHEXT } = opts.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'exactCaseEnvRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I6: const { PATHEXT: exts } = env; — renamed destructuring from a bare env identifier — 1 error', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { PATHEXT: exts } = env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'exactCaseEnvRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('I7: dot-form AND bracket-form violation in one fixture — 2 errors', () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const a = opts.env.PATH; const b = opts.env['PATHEXT'];`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: 2,
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test("I8: const { 'PATH': v } = opts.env; — non-computed string-literal destructuring key — 1 error", () => {
|
||||
ruleTester.run('no-exact-case-env-access', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const { 'PATH': v } = opts.env;`,
|
||||
filename: OUTSIDE_SEAM_FILE,
|
||||
errors: [{ messageId: 'exactCaseEnvRead' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -23,7 +23,7 @@ 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');
|
||||
const { PATH_RETURNING_FNS, WINDOWS_CASE_VARYING_ENV_VARS } = 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.
|
||||
@@ -477,6 +477,73 @@ describe('portability-vocab drift guard', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('exact-case-env-access vocab drift guard', () => {
|
||||
test('WINDOWS_CASE_VARYING_ENV_VARS is a non-empty array', () => {
|
||||
assert.ok(Array.isArray(WINDOWS_CASE_VARYING_ENV_VARS));
|
||||
assert.ok(WINDOWS_CASE_VARYING_ENV_VARS.length > 0);
|
||||
});
|
||||
|
||||
test('every literal env-var name passed to envGet(...) in the seam is registered in WINDOWS_CASE_VARYING_ENV_VARS', () => {
|
||||
const srcPath = path.join(__dirname, '..', 'src', 'shell-command-projection.cts');
|
||||
const src = fs.readFileSync(srcPath, 'utf-8');
|
||||
|
||||
const ast = tsParser.parse(src, {
|
||||
jsx: false,
|
||||
loc: true,
|
||||
range: true,
|
||||
comment: true,
|
||||
tokens: false,
|
||||
});
|
||||
|
||||
// Collect the second-argument string literal of every `envGet(...)` call
|
||||
// site anywhere in the file (no special-casing of nesting — walk everything).
|
||||
const collected = [];
|
||||
function walk(n) {
|
||||
if (!n || typeof n !== 'object') return;
|
||||
if (
|
||||
n.type === 'CallExpression' &&
|
||||
n.callee &&
|
||||
n.callee.type === 'Identifier' &&
|
||||
n.callee.name === 'envGet' &&
|
||||
Array.isArray(n.arguments) &&
|
||||
n.arguments[1] &&
|
||||
n.arguments[1].type === 'Literal' &&
|
||||
typeof n.arguments[1].value === 'string'
|
||||
) {
|
||||
collected.push(n.arguments[1].value);
|
||||
}
|
||||
for (const key of Object.keys(n)) {
|
||||
if (key === 'parent' || key === 'tokens' || key === 'comments' || key === 'loc' || key === 'range') 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(ast);
|
||||
|
||||
// Guard against the walker itself being broken (a silent 0-hit pass would
|
||||
// make this test vacuously true).
|
||||
assert.ok(
|
||||
collected.length > 0,
|
||||
`Expected to find at least one envGet(...) call site with a literal name in ${srcPath}, found none — the AST walker may be broken.`,
|
||||
);
|
||||
|
||||
const vocabLower = new Set(WINDOWS_CASE_VARYING_ENV_VARS.map((v) => v.toLowerCase()));
|
||||
const missing = collected.filter((name) => !vocabLower.has(name.toLowerCase()));
|
||||
|
||||
assert.deepStrictEqual(
|
||||
missing,
|
||||
[],
|
||||
`These envGet(...) literal env-var names in src/shell-command-projection.cts are missing from WINDOWS_CASE_VARYING_ENV_VARS:\n ${missing.join('\n ')}\n\nAdd them to WINDOWS_CASE_VARYING_ENV_VARS in eslint-rules/lib/portability-vocab.cjs.`,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
// ─── Known boundary (documented, not enforced) ─────────────────────────────────
|
||||
//
|
||||
// A NEW path-returning resolver added to bin/install.js that builds its path via
|
||||
|
||||
Reference in New Issue
Block a user