@@ -723,14 +723,14 @@ The prompt-level data/instruction isolation seam for untrusted web/document ingr
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.examples=#586/PR #650 ship.md verification gate — grep "^status:" also matched body status: lines, yielding passed+gaps_found+human_needed instead of passed and blocking a passed phase; the same broad-grep still lives in execute-phase.md (consolidation tracked by #651)`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.detect=grep "^<key>:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning <key>: is enough to break it`
|
||||
`DEFECT.FRONTMATTER-SCALAR-BROAD-GREP.fix-forward=scope to the leading frontmatter block and take the first match: sed -n '/^---$/,/^---$/p' "$f" | grep -m1 "^<key>:" | cut -d: -f2 | tr -d ' '; fix every parallel copy in the same change or consolidate behind one queryable seam (#651)`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and trips windows-test-parity-guard (fenceRegexLiteralNewline); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.symptom=a test that parses a workflow bash block out of a *.md and runs it via execFileSync('bash',...) breaks on Windows two ways: the fence regex uses a literal \n after the bash fence that will not match CRLF and is flagged by local/no-crlf-fragile-split (the windows-test-parity-guard ratchet it formerly tripped was deleted in ADR-1703 Phase 4 #1726); and git-bash exists so a bash-presence probe is true, but an os.tmpdir() Windows path (C:\...) is un-globbable in bash so the pipeline returns empty and assertions fail`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.examples=#586/PR #650 tests/ship-586-verification-routing.test.cjs — the fence \n offender failed ubuntu-24/macos/coverage, then the Windows tmpdir-path glob failed full test (windows-latest,22) at fail 3; both were invisible to file-scoped gsd-test-both runs because the parity guard is only scanned by the full suite`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.detect=test does readFileSync(md).match for a bash fence with literal \n, OR execFileSync('bash',...) gated only on a bash-presence probe; also verifying a new test with a file-scoped run instead of the full suite hides repo-wide static guards; now enforced at write-time + CI by local/no-crlf-fragile-split (CRLF fence/frontmatter regex + readFileSync split-on-\n) and local/no-unguarded-nonportable-exec (bash+chmod), eslint, ADR-1703`
|
||||
`DEFECT.TEST-SHELL-PIPELINE-NONPORTABLE.fix-forward=match the fence with \r?\n and normalize the captured block to LF; gate pipeline execution on process.platform !== 'win32' && hasBash since the extraction LOGIC is platform-independent and POSIX coverage suffices; run the full suite (or the parity/lint guards) before push when adding a test file`
|
||||
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.symptom=local gsd-test runs Mac+Linux only (no Windows host); Windows-only test failures (chmod exec-bit not honored for PATH-executing extension-less scripts in Git Bash msys2; / vs \ path-separator in assertions; Git Bash msys2 shell semantics) surface ONLY in CI test (windows-latest,*) / full test (windows-latest,*) lanes, never locally`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.examples=PR #1084 (chmod 0o755 + bare-command execution failed on windows lane); PR #1692 tests/stale-bake-guard.test.cjs resolveAgentDir assertions hardcoded '/H/.config/opencode/agent' forward-slash literals against a path.join return — passed macOS/linux/ubuntu CI (incl. gsd-test docker mirror), failed windows-latest,24 + full test windows-latest,22 shard 2/3; test files that assert path.join result without normalizing to forward slashes`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.detect=npm run lint (eslint) runs the local/* AST portability rules (ADR-1703): local/no-unguarded-nonportable-exec flags a test that chmods an exec bit AND runs it via sh/bash -c without a process.platform !== 'win32' guard (the retired scripts/lint-windows-test-portability.cjs tripwire, migrated to AST in #1720); local/no-path-literal-in-assert + local/no-posix-mode-bit-assert cover the assertion shapes; local/no-crlf-fragile-split (CRLF file-content split/regex), local/no-hardcoded-tmp (/tmp literal → os.tmpdir()), local/no-bare-npm-exec (npm needs shell:true on Windows) and local/require-userprofile-with-home (set USERPROFILE alongside HOME) replace the deleted windows-test-parity-guard ratchet (#1726); all are platform-guard-aware with zero opt-out (tests/portability-rule-disable-ban.test.cjs); watch CI windows matrix green before declaring a PR done`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.fix-forward=gate platform-specific execution with if (process.platform !== 'win32'); normalize path expectations to forward slashes with .replace(/\\/g, '/'); invoke scripts via explicit interpreter (sh <path>) rather than relying on exec-bit; there is NO opt-out for the local/* portability rules — structure platform-specific code behind a recognized process.platform !== 'win32' guard (ADR-1703 zero escape hatch)`
|
||||
`DEFECT.WINDOWS-TEST-PORTABILITY.prevention=run npm run lint (the local/* AST portability rules, ADR-1703) before opening a PR; treat the CI windows lane as the only true Windows signal — gsd-test (Mac/Linux only) cannot substitute for it`
|
||||
|
||||
|
||||
@@ -19,8 +19,12 @@ running outside ESLint, fails the build if you try). Legitimately platform-speci
|
||||
| `local/no-path-literal-in-assert` | An `assert.equal`/`strictEqual`/`deepEqual`/`deepStrictEqual` or `expect(...).toBe`/`toEqual`/`toStrictEqual` where one operand is a **path-returning function call** and the other is a **hardcoded `/`-string literal** not normalized to POSIX. | `tests/**/*.test.cjs` |
|
||||
| `local/no-posix-mode-bit-assert` | An equality assertion comparing a file **`.mode`** (e.g. `statSync(p).mode & 0o777`) to an **octal literal** — Windows reports `0o666`/`0o444`, never the requested mode. | `tests/**/*.test.cjs` |
|
||||
| `local/no-unguarded-nonportable-exec` | A file that **both** sets a chmod exec-bit (`chmod`/`chmodSync` with `0oNNN & 0o111 !== 0`) **and** invokes `sh`/`bash` with a `-c` flag (`execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync`) without a Windows platform guard — Windows Git Bash ignores the exec bit for extension-less PATH-executed scripts. | `tests/**/*.test.cjs` |
|
||||
| `local/no-crlf-fragile-split` | A `.split('\n')` or `.split("\n")` call on `readFileSync` content, **or** a regex literal containing a bare `\n` used against `readFileSync` content — Windows `git-autocrlf` yields `\r\n` line endings so a literal `\n` split or regex will mismatch. | `tests/**/*.test.cjs` |
|
||||
| `local/no-hardcoded-tmp` | A hardcoded `/tmp/` string passed as the first argument to an `fs.*` function or `path.join` — `/tmp` does not exist on Windows. Use `os.tmpdir()` instead. | `tests/**/*.test.cjs` |
|
||||
| `local/no-bare-npm-exec` | An `execFileSync`/`spawnSync`/`spawn`/`exec`/`execSync` call with `"npm"` as the command and no `{ shell: true }` option (or a platform-guarded equivalent) — `npm` is a `.cmd` batch wrapper on Windows and will not be found without a shell. | `tests/**/*.test.cjs` |
|
||||
| `local/require-userprofile-with-home` | A `process.env.HOME = <x>` assignment in a test file with no corresponding `process.env.USERPROFILE` reference anywhere in the file — Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. | `tests/**/*.test.cjs` |
|
||||
|
||||
(More rules land per the epic — see ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702).)
|
||||
(See ADR-1703's catalog and [epic #1702](https://github.com/open-gsd/gsd-core/issues/1702) for the full phase history.)
|
||||
|
||||
The set of path-returning functions is single-sourced in
|
||||
[`eslint-rules/lib/portability-vocab.cjs`](../../eslint-rules/lib/portability-vocab.cjs) as
|
||||
@@ -126,6 +130,97 @@ binding-aware (a reassigned or `false`-initialized variable is not trusted), and
|
||||
> NOT recognized as a platform guard. To scope a POSIX-only assertion use an
|
||||
> `if (process.platform !== 'win32')` guard (or early-return) **inside** the callback.
|
||||
|
||||
## How-to — fix a `no-crlf-fragile-split` violation
|
||||
|
||||
Windows `git-autocrlf=true` (the default on Windows) rewrites `\n` to `\r\n` in checked-out files.
|
||||
A test that reads a file with `readFileSync` and then splits on `'\n'` (or uses a regex with a bare
|
||||
`\n`) will silently miscalculate line counts on Windows.
|
||||
|
||||
**Fix: use `/\r?\n/` everywhere you split or match lines in file content:**
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
const lines = fs.readFileSync(p, 'utf8').split('\n');
|
||||
assert.match(content, /^---\n/m);
|
||||
assert.match(content, /```bash\n/);
|
||||
|
||||
// ✅ CRLF-safe
|
||||
const lines = fs.readFileSync(p, 'utf8').split(/\r?\n/);
|
||||
assert.match(content, /^---\r?\n/m);
|
||||
assert.match(content, /```bash\r?\n/);
|
||||
```
|
||||
|
||||
The `/\r?\n/` form is a no-op on POSIX (matches only `\n`) and correct on Windows (matches `\r\n`).
|
||||
|
||||
## How-to — fix a `no-hardcoded-tmp` violation
|
||||
|
||||
`/tmp` does not exist on Windows. Use `os.tmpdir()` to get the platform-appropriate temp directory:
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
const dir = path.join('/tmp/my-test-dir', 'sub');
|
||||
env.MY_VAR = '/tmp/custom-dir';
|
||||
|
||||
// ✅ portable
|
||||
const dir = path.join(os.tmpdir(), 'my-test-dir', 'sub');
|
||||
const customDir = path.join(os.tmpdir(), 'custom-dir');
|
||||
env.MY_VAR = customDir;
|
||||
```
|
||||
|
||||
When the same `/tmp/...` value is used both as a fixture env var and in an assertion, update both
|
||||
sides consistently so they still match:
|
||||
|
||||
```js
|
||||
// ❌ fragile — assertion tied to /tmp/ literal
|
||||
const customDir = path.join(os.tmpdir(), 'custom-dir');
|
||||
env.MY_VAR = customDir;
|
||||
assert.strictEqual(String(fn()).replace(/\\/g, '/'), '/tmp/custom-dir'); // ← still wrong
|
||||
|
||||
// ✅ assertion uses the same derived constant
|
||||
assert.strictEqual(String(fn()).replace(/\\/g, '/'), customDir.replace(/\\/g, '/'));
|
||||
```
|
||||
|
||||
## How-to — fix a `no-bare-npm-exec` violation
|
||||
|
||||
On Windows, `npm` is installed as `npm.cmd` (a CMD batch script). Without `{ shell: true }`,
|
||||
`execFileSync('npm', ...)` fails because the OS cannot find an executable named `npm` (no `.cmd`
|
||||
extension). Add `shell: true` or gate the call behind a platform check:
|
||||
|
||||
```js
|
||||
// ❌ flagged
|
||||
execFileSync('npm', ['ci'], { cwd: dir });
|
||||
|
||||
// ✅ shell: true — works on all platforms
|
||||
execFileSync('npm', ['ci'], { cwd: dir, shell: true });
|
||||
|
||||
// ✅ platform-guarded alternative
|
||||
execFileSync('npm', ['ci'], { cwd: dir, shell: process.platform === 'win32' });
|
||||
```
|
||||
|
||||
## How-to — fix a `require-userprofile-with-home` violation
|
||||
|
||||
Windows uses `USERPROFILE` as the home directory environment variable, not `HOME`. Whenever a test
|
||||
sets `process.env.HOME`, it must also set `process.env.USERPROFILE` to the same value (so that
|
||||
code under test that calls `os.homedir()` or reads `process.env.USERPROFILE` gets the isolated
|
||||
directory on Windows too). Mirror the teardown as well:
|
||||
|
||||
```js
|
||||
// ❌ flagged — Windows code-under-test reads USERPROFILE, not HOME
|
||||
const origHome = process.env.HOME;
|
||||
process.env.HOME = isolatedDir;
|
||||
// …
|
||||
process.env.HOME = origHome; // restore
|
||||
|
||||
// ✅ set and restore both
|
||||
const origHome = process.env.HOME;
|
||||
const origUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = isolatedDir;
|
||||
process.env.USERPROFILE = isolatedDir;
|
||||
// …
|
||||
if (origHome === undefined) delete process.env.HOME; else process.env.HOME = origHome;
|
||||
if (origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = origUserProfile;
|
||||
```
|
||||
|
||||
## How-to — add a new path resolver
|
||||
|
||||
When you add a function that returns a filesystem path (e.g. in `src/runtime-homes.cts`), add its
|
||||
|
||||
154
eslint-rules/no-bare-npm-exec.cjs
Normal file
154
eslint-rules/no-bare-npm-exec.cjs
Normal file
@@ -0,0 +1,154 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-bare-npm-exec
|
||||
*
|
||||
* Flag bare 'npm' invocations via execFileSync/spawnSync/spawn without
|
||||
* `shell: true`. On Windows, `npm` is `npm.cmd` — a CMD batch file — and
|
||||
* cannot be launched without a shell.
|
||||
*
|
||||
* ## What this enforces (G5)
|
||||
*
|
||||
* - `execFileSync('npm', ...)` / `spawnSync('npm', ...)` / `spawn('npm', ...)`
|
||||
* whose options object (last arg, if ObjectExpression) does NOT set
|
||||
* `shell: true`, `shell: isWindows`, or `shell: process.platform === 'win32'`.
|
||||
*
|
||||
* ## What this does NOT flag
|
||||
*
|
||||
* - `execSync('npm install', ...)` — execSync always runs through a shell
|
||||
* (cmd.exe on Windows automatically resolves npm.cmd), so it is safe without
|
||||
* `shell: true`. Only direct binary exec functions (execFileSync, spawnSync,
|
||||
* spawn) bypass the shell and require explicit `{ shell: true }`.
|
||||
*
|
||||
* Message: Windows needs `npm.cmd` — pass `{ shell: true }`.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow bare "npm" execFileSync/spawnSync/spawn/execSync without shell:true (fails on Windows)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
bareNpmExec:
|
||||
'Bare "npm" invocation without { shell: true } is not portable ' +
|
||||
'(DEFECT.WINDOWS-TEST-PORTABILITY): On Windows, npm is a CMD batch file ' +
|
||||
'(npm.cmd) and requires a shell to execute. Pass { shell: true } as the ' +
|
||||
'options argument.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
/** Functions that take (command, args, options) — direct binary exec, no shell */
|
||||
const EXEC_FILE_FNS = new Set(['execFileSync', 'spawnSync', 'spawn']);
|
||||
|
||||
/**
|
||||
* Returns the string value of a Literal node, or null.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the function name for a CallExpression callee (Identifier or
|
||||
* MemberExpression), or null if not recognized.
|
||||
* @param {import('eslint').Rule.Node} callee
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function getFnName(callee) {
|
||||
if (callee.type === 'Identifier') return callee.name;
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier'
|
||||
) {
|
||||
return callee.property.name;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if an ObjectExpression has `shell: true`, `shell: isWindows`,
|
||||
* or `shell: process.platform === 'win32'`.
|
||||
* @param {import('eslint').Rule.Node} optionsNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasShellTrue(optionsNode) {
|
||||
if (!optionsNode || optionsNode.type !== 'ObjectExpression') return false;
|
||||
for (const prop of optionsNode.properties) {
|
||||
if (prop.type !== 'Property') continue;
|
||||
const keyName =
|
||||
prop.key.type === 'Identifier'
|
||||
? prop.key.name
|
||||
: stringValue(prop.key);
|
||||
if (keyName !== 'shell') continue;
|
||||
const val = prop.value;
|
||||
// shell: true
|
||||
if (val.type === 'Literal' && val.value === true) return true;
|
||||
// shell: isWindows / shell: IS_WINDOWS / shell: isWin / shell: onWindows
|
||||
if (val.type === 'Identifier') {
|
||||
const name = val.name;
|
||||
if (
|
||||
name === 'isWindows' ||
|
||||
name === 'IS_WINDOWS' ||
|
||||
name === 'isWin' ||
|
||||
name === 'onWindows'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
// shell: process.platform === 'win32'
|
||||
if (
|
||||
val.type === 'BinaryExpression' &&
|
||||
(val.operator === '===' || val.operator === '==') &&
|
||||
val.left.type === 'MemberExpression' &&
|
||||
val.left.object.type === 'Identifier' &&
|
||||
val.left.object.name === 'process' &&
|
||||
val.left.property.type === 'Identifier' &&
|
||||
val.left.property.name === 'platform' &&
|
||||
val.right.type === 'Literal' &&
|
||||
val.right.value === 'win32'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
const fnName = getFnName(node.callee);
|
||||
if (!fnName) return;
|
||||
|
||||
const args = node.arguments;
|
||||
if (!args || args.length === 0) return;
|
||||
|
||||
// Pattern A: execFileSync/spawnSync/spawn('npm', ...)
|
||||
if (EXEC_FILE_FNS.has(fnName)) {
|
||||
const firstArg = stringValue(args[0]);
|
||||
if (firstArg !== 'npm') return;
|
||||
|
||||
// Find last ObjectExpression argument as the options
|
||||
const lastArg = args[args.length - 1];
|
||||
if (hasShellTrue(lastArg)) return;
|
||||
|
||||
// No shell:true — report
|
||||
context.report({ node, messageId: 'bareNpmExec' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
418
eslint-rules/no-crlf-fragile-split.cjs
Normal file
418
eslint-rules/no-crlf-fragile-split.cjs
Normal file
@@ -0,0 +1,418 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-crlf-fragile-split
|
||||
*
|
||||
* Flag CRLF-fragile file-content splitting and regex patterns in test files.
|
||||
* Windows git-autocrlf causes readFileSync to return \r\n line endings; code
|
||||
* that splits on bare `\n` or uses regexes with bare `\n` will silently
|
||||
* mismatch on Windows.
|
||||
*
|
||||
* ## What this enforces
|
||||
*
|
||||
* G1 — a `.split('\n')` / `.split("\n")` CallExpression whose receiver is
|
||||
* (transitively) a `readFileSync`/`fs.readFileSync` result — directly,
|
||||
* via a chain, or via an Identifier that scope-resolves to a variable
|
||||
* initialized from readFileSync.
|
||||
* Message: use `.split(/\r?\n/)`.
|
||||
*
|
||||
* G2/G3 — a RegExpLiteral whose pattern contains a bare `\n` (a `\n` not
|
||||
* part of `\r?\n` / `\r\n` / `[\r\n]` etc.) used as the pattern of a
|
||||
* `.match`/`.test`/`.exec`/`.replace`/`.replaceAll`/`.split`/`.matchAll`
|
||||
* call on a readFileSync-derived receiver. ALSO flags a RegExpLiteral
|
||||
* with a bare `\n` whose source contains a markdown fence (```) or a
|
||||
* frontmatter anchor (`^---`), since those shapes target file content.
|
||||
* Message: use `\r?\n` (Windows git-autocrlf yields `\r\n`).
|
||||
*
|
||||
* ## Known boundaries
|
||||
*
|
||||
* The data-flow is scope-based: a readFileSync result is tracked via the
|
||||
* immediate call-chain or a single variable binding initialized from
|
||||
* readFileSync in the same file scope. A regex stored far from its use, or
|
||||
* content obtained via a non-readFileSync read (e.g. fs.readFile callback,
|
||||
* streams), may not be caught. G2/G3 additionally fires on fence/frontmatter
|
||||
* regex shapes even when data-flow is indirect, to catch the most common
|
||||
* markdown parsing patterns.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-CRLF-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
const { isWindowsExcludedNode } = require('./lib/platform-guard.cjs');
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow CRLF-fragile file-content split and regex patterns in tests (fails on Windows with git-autocrlf)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
crlfFragileSplit:
|
||||
'Splitting on literal "\\n" on readFileSync content is CRLF-fragile ' +
|
||||
'(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY): Windows git-autocrlf yields "\\r\\n" ' +
|
||||
'line endings. Use .split(/\\r?\\n/) instead.',
|
||||
crlfFragileRegex:
|
||||
'RegExp with a bare "\\n" on readFileSync content is CRLF-fragile ' +
|
||||
'(DEFECT.WINDOWS-CRLF-TEST-PORTABILITY): Windows git-autocrlf yields "\\r\\n" ' +
|
||||
'line endings. Use \\r?\\n (or [\\r\\n]) instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Returns the string value of a Literal node, or null.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {string|null}
|
||||
*/
|
||||
function stringValue(node) {
|
||||
if (node && node.type === 'Literal' && typeof node.value === 'string') {
|
||||
return node.value;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the node is a call to `readFileSync` or `fs.readFileSync`.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isReadFileSyncCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
// readFileSync(...)
|
||||
if (callee.type === 'Identifier' && callee.name === 'readFileSync') return true;
|
||||
// fs.readFileSync(...)
|
||||
if (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'readFileSync'
|
||||
) {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if `node` is (transitively) derived from a readFileSync call.
|
||||
*
|
||||
* Handles:
|
||||
* - Direct: readFileSync(...) -- the node itself IS the readFileSync call
|
||||
* - Chain: readFileSync(...).toString() etc.
|
||||
* - Identifier resolved via scope to a variable initialized from readFileSync
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isReadFileSyncDerived(node) {
|
||||
if (!node) return false;
|
||||
|
||||
// Direct readFileSync call
|
||||
if (isReadFileSyncCall(node)) return true;
|
||||
|
||||
// MemberExpression: x.something — check the object
|
||||
if (node.type === 'MemberExpression') {
|
||||
return isReadFileSyncDerived(node.object);
|
||||
}
|
||||
|
||||
// CallExpression: x.something() — check object of the callee
|
||||
if (node.type === 'CallExpression') {
|
||||
if (isReadFileSyncCall(node)) return true;
|
||||
if (node.callee.type === 'MemberExpression') {
|
||||
return isReadFileSyncDerived(node.callee.object);
|
||||
}
|
||||
}
|
||||
|
||||
// Identifier: resolve to its variable initializer via scope
|
||||
if (node.type === 'Identifier') {
|
||||
return resolveIdentifierToReadFileSync(node);
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given an Identifier node, walk the scope chain to find its binding,
|
||||
* then check if the initializer is derived from readFileSync.
|
||||
* @param {import('eslint').Rule.Node} identNode
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function resolveIdentifierToReadFileSync(identNode) {
|
||||
if (typeof sourceCode.getScope !== 'function') return false;
|
||||
|
||||
let scope;
|
||||
try {
|
||||
scope = sourceCode.getScope(identNode);
|
||||
} catch (_) {
|
||||
// If scope resolution fails (e.g. due to unsupported node type or
|
||||
// parser version mismatch), conservatively return false (not flagged).
|
||||
// This is an intentional boundary: an unresolvable scope produces a
|
||||
// false negative rather than a spurious error.
|
||||
return false;
|
||||
}
|
||||
if (!scope) return false;
|
||||
|
||||
let s = scope;
|
||||
while (s) {
|
||||
const variable = s.variables.find(v => v.name === identNode.name);
|
||||
if (variable) {
|
||||
const defs = variable.defs;
|
||||
if (!defs || defs.length === 0) return false;
|
||||
const decl = defs[0].node; // VariableDeclarator
|
||||
if (!decl || !decl.init) return false;
|
||||
// Check the init is readFileSync-derived
|
||||
return isReadFileSyncDerived(decl.init);
|
||||
}
|
||||
s = s.upper;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a RegExpLiteral has at least one FRAGILE bare \n — a \n
|
||||
* that is not adequately protected against CRLF.
|
||||
*
|
||||
* Per-occurrence classification: every \n in the pattern is inspected
|
||||
* individually. A \n is SAFE when ANY of these hold:
|
||||
* 1. Immediately preceded by \r? (part of \r?\n)
|
||||
* 2. Immediately preceded by \r (part of \r\n)
|
||||
* 3. Inside a character class [...] that also contains \r
|
||||
* (e.g. [\r\n], [^\r\n], [\n\r])
|
||||
*
|
||||
* Everything else is FRAGILE: [^\n], [\n], or a bare \n in the main pattern.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — Literal with regex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function hasBareLiteralNewline(node) {
|
||||
if (!node || node.type !== 'Literal' || !node.regex) return false;
|
||||
const pattern = node.regex.pattern;
|
||||
if (!pattern.includes('\\n')) return false;
|
||||
|
||||
// Walk the pattern, find every \n occurrence and classify it.
|
||||
let i = 0;
|
||||
// Track whether we are inside a [...] character class and whether
|
||||
// the current class contains \r.
|
||||
let inClass = false;
|
||||
let classHasCarriageReturn = false;
|
||||
let foundFragile = false;
|
||||
|
||||
while (i < pattern.length) {
|
||||
// Entering a character class
|
||||
if (pattern[i] === '[' && !inClass) {
|
||||
inClass = true;
|
||||
classHasCarriageReturn = false;
|
||||
i++;
|
||||
// Skip optional ^ negation
|
||||
if (i < pattern.length && pattern[i] === '^') i++;
|
||||
// Skip ] if it appears immediately after [ or [^, where it is literal
|
||||
if (i < pattern.length && pattern[i] === ']') i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Exiting a character class
|
||||
if (pattern[i] === ']' && inClass) {
|
||||
inClass = false;
|
||||
i++;
|
||||
continue;
|
||||
}
|
||||
|
||||
// Escape sequences inside the pattern
|
||||
if (pattern[i] === '\\' && i + 1 < pattern.length) {
|
||||
const next = pattern[i + 1];
|
||||
if (next === 'r') {
|
||||
// \r — if inside a class, note it contains \r
|
||||
if (inClass) classHasCarriageReturn = true;
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
if (next === 'n') {
|
||||
// \n found — classify it
|
||||
// Check if preceded by \r? or \r (look back in the raw pattern string)
|
||||
// "preceded by" means the two chars before the current \\ are \r or \r?
|
||||
const before2 = pattern.slice(Math.max(0, i - 2), i); // up to 2 chars before \\
|
||||
const safeByPrefix =
|
||||
before2.endsWith('\\r?') || // \r?\n (but \r? is 3 chars, before is 2 — need to check before3)
|
||||
before2.endsWith('\\r'); // \r\n
|
||||
|
||||
// Re-check with a wider window for \r?\n (pattern chars: \r?\n = 5 chars)
|
||||
const before3 = pattern.slice(Math.max(0, i - 3), i);
|
||||
const safeByPrefixFull =
|
||||
before3 === '\\r?' || // \r?\n
|
||||
before2 === '\\r'; // \r\n
|
||||
|
||||
if (inClass) {
|
||||
// Inside a class: safe only if the class itself contains \r
|
||||
if (!classHasCarriageReturn) {
|
||||
foundFragile = true;
|
||||
}
|
||||
} else if (!safeByPrefixFull) {
|
||||
foundFragile = true;
|
||||
}
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
// Any other escape: skip both chars
|
||||
i += 2;
|
||||
continue;
|
||||
}
|
||||
|
||||
i++;
|
||||
}
|
||||
|
||||
return foundFragile;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a RegExpLiteral with a bare \n is used on a readFileSync-
|
||||
* derived receiver via .match/.test/.exec/.replace/.replaceAll/.split/.matchAll.
|
||||
*
|
||||
* Two AST shapes:
|
||||
* Shape A: str.match(/regex/) — regex is an ARG to the call.
|
||||
* regex.parent = CallExpression (arg), callee.object = str
|
||||
* Shape B: /regex/.test(str) — regex is the callee object.
|
||||
* regex.parent = MemberExpression (the .test callee)
|
||||
* regex.parent.parent = CallExpression, first arg = str
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} regexNode — the RegExpLiteral
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isRegexUsedOnFileContent(regexNode) {
|
||||
const FILE_METHODS = new Set(['match', 'test', 'exec', 'replace', 'replaceAll', 'split', 'matchAll']);
|
||||
const parent = regexNode.parent;
|
||||
if (!parent) return false;
|
||||
|
||||
// Shape A: str.match(regex) — regex is an argument; parent is CallExpression
|
||||
if (parent.type === 'CallExpression') {
|
||||
const callee = parent.callee;
|
||||
if (
|
||||
callee &&
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
FILE_METHODS.has(callee.property.name)
|
||||
) {
|
||||
// regex must actually be one of the arguments (not the callee)
|
||||
if (parent.arguments.includes(regexNode)) {
|
||||
return isReadFileSyncDerived(callee.object);
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Shape B: /regex/.test(str) — regex is the callee object.
|
||||
// In this case, regexNode.parent is the MemberExpression (/regex/.test)
|
||||
if (parent.type === 'MemberExpression' && !parent.computed) {
|
||||
if (
|
||||
parent.object === regexNode &&
|
||||
parent.property.type === 'Identifier' &&
|
||||
FILE_METHODS.has(parent.property.name)
|
||||
) {
|
||||
// parent.parent should be the CallExpression
|
||||
const callExpr = parent.parent;
|
||||
if (callExpr && callExpr.type === 'CallExpression' && callExpr.callee === parent) {
|
||||
const args = callExpr.arguments;
|
||||
if (args && args.length > 0) {
|
||||
return isReadFileSyncDerived(args[0]);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a RegExpLiteral pattern:
|
||||
* - has a bare \n, AND
|
||||
* - contains a markdown fence (```) or frontmatter anchor (^---)
|
||||
*
|
||||
* These shapes target file content by convention even without direct
|
||||
* data-flow tracking.
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} node — Literal with regex
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isMarkdownOrFrontmatterRegex(node) {
|
||||
if (!node || node.type !== 'Literal' || !node.regex) return false;
|
||||
if (!hasBareLiteralNewline(node)) return false;
|
||||
const pattern = node.regex.pattern;
|
||||
// Markdown fence: ```
|
||||
if (pattern.includes('```')) return true;
|
||||
// Frontmatter anchor: ^---
|
||||
if (/\^---/.test(pattern)) return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
// ── Per-file state ──────────────────────────────────────────────────────
|
||||
|
||||
/** Collected G1 violations: {node} */
|
||||
const g1Violations = [];
|
||||
/** Collected G2/G3 violations: {node} */
|
||||
const g2g3Violations = [];
|
||||
|
||||
return {
|
||||
// G1: .split('\n') on readFileSync-derived content
|
||||
CallExpression(node) {
|
||||
const callee = node.callee;
|
||||
if (
|
||||
callee &&
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'split'
|
||||
) {
|
||||
const args = node.arguments;
|
||||
if (args && args.length >= 1) {
|
||||
const argVal = stringValue(args[0]);
|
||||
if (argVal === '\n') {
|
||||
// Is the receiver derived from readFileSync?
|
||||
if (isReadFileSyncDerived(callee.object)) {
|
||||
g1Violations.push(node);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
// G2/G3: RegExpLiteral with bare \n
|
||||
Literal(node) {
|
||||
if (!node.regex) return;
|
||||
if (!hasBareLiteralNewline(node)) return;
|
||||
|
||||
// Check G2/G3 via data-flow (receiver is readFileSync-derived)
|
||||
if (isRegexUsedOnFileContent(node)) {
|
||||
g2g3Violations.push(node);
|
||||
return;
|
||||
}
|
||||
|
||||
// Also check G2/G3 via content shape (markdown fence or frontmatter)
|
||||
if (isMarkdownOrFrontmatterRegex(node)) {
|
||||
g2g3Violations.push(node);
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
for (const node of g1Violations) {
|
||||
if (!isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'crlfFragileSplit' });
|
||||
}
|
||||
}
|
||||
for (const node of g2g3Violations) {
|
||||
if (!isWindowsExcludedNode(node, sourceCode)) {
|
||||
context.report({ node, messageId: 'crlfFragileRegex' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
110
eslint-rules/no-hardcoded-tmp.cjs
Normal file
110
eslint-rules/no-hardcoded-tmp.cjs
Normal file
@@ -0,0 +1,110 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-hardcoded-tmp
|
||||
*
|
||||
* Flag hardcoded `/tmp/` paths passed to `fs.*` calls or `path.join()`.
|
||||
* On Windows, `/tmp/` does not exist — use `os.tmpdir()` instead.
|
||||
*
|
||||
* ## What this enforces (G4)
|
||||
*
|
||||
* A string Literal whose value starts with `/tmp/` (or is exactly `/tmp`)
|
||||
* passed as an argument to:
|
||||
* - An `fs.<method>(...)` call
|
||||
* - A `path.join('/tmp/...', …)` call
|
||||
*
|
||||
* Message: use `os.tmpdir()`.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Disallow hardcoded /tmp/ paths in fs.* calls or path.join() (not portable to Windows)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
hardcodedTmp:
|
||||
'Hardcoded "/tmp/" path is not portable (DEFECT.WINDOWS-TEST-PORTABILITY): ' +
|
||||
'Windows does not have /tmp/. Use os.tmpdir() to get the platform-appropriate ' +
|
||||
'temp directory instead.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
/**
|
||||
* Returns true if `node` is a string Literal starting with /tmp/ or equal to /tmp.
|
||||
* @param {import('eslint').Rule.Node} node
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isTmpLiteral(node) {
|
||||
if (!node || node.type !== 'Literal') return false;
|
||||
if (typeof node.value !== 'string') return false;
|
||||
return node.value === '/tmp' || node.value.startsWith('/tmp/');
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this CallExpression is an `fs.<method>(...)` call.
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isFsMethodCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'fs' &&
|
||||
callee.property.type === 'Identifier'
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this CallExpression is a `path.join(...)` call.
|
||||
* @param {import('eslint').Rule.Node} node — CallExpression
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isPathJoinCall(node) {
|
||||
if (!node || node.type !== 'CallExpression') return false;
|
||||
const callee = node.callee;
|
||||
return (
|
||||
callee.type === 'MemberExpression' &&
|
||||
!callee.computed &&
|
||||
callee.object.type === 'Identifier' &&
|
||||
callee.object.name === 'path' &&
|
||||
callee.property.type === 'Identifier' &&
|
||||
callee.property.name === 'join'
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
// Check fs.<method>(...) calls
|
||||
if (isFsMethodCall(node)) {
|
||||
for (const arg of node.arguments) {
|
||||
if (isTmpLiteral(arg)) {
|
||||
context.report({ node: arg, messageId: 'hardcodedTmp' });
|
||||
}
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Check path.join('/tmp/...', ...) calls
|
||||
if (isPathJoinCall(node)) {
|
||||
const args = node.arguments;
|
||||
if (args && args.length > 0 && isTmpLiteral(args[0])) {
|
||||
context.report({ node: args[0], messageId: 'hardcodedTmp' });
|
||||
}
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
114
eslint-rules/require-userprofile-with-home.cjs
Normal file
114
eslint-rules/require-userprofile-with-home.cjs
Normal file
@@ -0,0 +1,114 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* require-userprofile-with-home
|
||||
*
|
||||
* Flag test files that assign `process.env.HOME` without also referencing
|
||||
* `USERPROFILE` anywhere in the file.
|
||||
*
|
||||
* ## What this enforces (G6)
|
||||
*
|
||||
* Program-level: collect assignments to `process.env.HOME`
|
||||
* (`process.env.HOME = …` / `process.env['HOME'] = …`); track whether
|
||||
* `USERPROFILE` appears anywhere in the file (any reference). At
|
||||
* `Program:exit`, if HOME is assigned and `USERPROFILE` never appears, report
|
||||
* each HOME assignment.
|
||||
*
|
||||
* Message: Windows uses `USERPROFILE`, not `HOME` — set
|
||||
* `process.env.USERPROFILE` alongside.
|
||||
*
|
||||
* DEFECT category: DEFECT.WINDOWS-TEST-PORTABILITY
|
||||
*/
|
||||
|
||||
/** @type {import('eslint').Rule.RuleModule} */
|
||||
const rule = {
|
||||
meta: {
|
||||
type: 'problem',
|
||||
docs: {
|
||||
description:
|
||||
'Require process.env.USERPROFILE to be set alongside process.env.HOME (Windows portability)',
|
||||
category: 'Portability',
|
||||
},
|
||||
schema: [],
|
||||
messages: {
|
||||
missingUserProfile:
|
||||
'Assigning process.env.HOME without process.env.USERPROFILE is not portable ' +
|
||||
'(DEFECT.WINDOWS-TEST-PORTABILITY): Windows uses USERPROFILE as the home ' +
|
||||
'directory environment variable, not HOME. Set process.env.USERPROFILE ' +
|
||||
'alongside process.env.HOME.',
|
||||
},
|
||||
},
|
||||
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode ?? context.getSourceCode();
|
||||
|
||||
/** Collected HOME assignment nodes */
|
||||
const homeAssignments = [];
|
||||
|
||||
/** Whether a real process.env.USERPROFILE = … assignment exists in the file */
|
||||
let userProfileAssigned = false;
|
||||
|
||||
/**
|
||||
* Returns true if node is an assignment to process.env[key] or
|
||||
* process.env.key for the given key name.
|
||||
*
|
||||
* Recognized shapes (as the left-hand side of AssignmentExpression):
|
||||
* process.env.KEY — MemberExpression(MemberExpression, Identifier)
|
||||
* process.env['KEY'] — MemberExpression(MemberExpression, Literal, computed=true)
|
||||
*
|
||||
* @param {import('eslint').Rule.Node} lhs — left side of AssignmentExpression
|
||||
* @param {string} key — the env var name to check
|
||||
* @returns {boolean}
|
||||
*/
|
||||
function isProcessEnvAssignment(lhs, key) {
|
||||
if (!lhs || lhs.type !== 'MemberExpression') return false;
|
||||
const obj = lhs.object;
|
||||
if (!obj || obj.type !== 'MemberExpression') return false;
|
||||
|
||||
// obj must be process.env
|
||||
if (
|
||||
obj.computed ||
|
||||
obj.object.type !== 'Identifier' ||
|
||||
obj.object.name !== 'process' ||
|
||||
obj.property.type !== 'Identifier' ||
|
||||
obj.property.name !== 'env'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Property must be key (identifier or string literal)
|
||||
if (!lhs.computed) {
|
||||
return lhs.property.type === 'Identifier' && lhs.property.name === key;
|
||||
} else {
|
||||
return (
|
||||
lhs.property.type === 'Literal' && lhs.property.value === key
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
AssignmentExpression(node) {
|
||||
if (isProcessEnvAssignment(node.left, 'HOME')) {
|
||||
homeAssignments.push(node);
|
||||
}
|
||||
// Track actual USERPROFILE assignments (not mere text/comment mentions)
|
||||
if (isProcessEnvAssignment(node.left, 'USERPROFILE')) {
|
||||
userProfileAssigned = true;
|
||||
}
|
||||
},
|
||||
|
||||
'Program:exit'() {
|
||||
if (homeAssignments.length === 0) return;
|
||||
|
||||
// Only suppress if USERPROFILE is actually ASSIGNED (not just mentioned in a comment)
|
||||
if (userProfileAssigned) return;
|
||||
|
||||
for (const node of homeAssignments) {
|
||||
context.report({ node, messageId: 'missingUserProfile' });
|
||||
}
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
module.exports = rule;
|
||||
@@ -18,6 +18,10 @@ import noAdhocMarkdownParsing from './eslint-rules/no-adhoc-markdown-parsing.cjs
|
||||
import noPathLiteralInAssert from './eslint-rules/no-path-literal-in-assert.cjs';
|
||||
import noPosixModeBitAssert from './eslint-rules/no-posix-mode-bit-assert.cjs';
|
||||
import noUnguardedNonportableExec from './eslint-rules/no-unguarded-nonportable-exec.cjs';
|
||||
import noCrlfFragileSplit from './eslint-rules/no-crlf-fragile-split.cjs';
|
||||
import noHardcodedTmp from './eslint-rules/no-hardcoded-tmp.cjs';
|
||||
import noBareNpmExec from './eslint-rules/no-bare-npm-exec.cjs';
|
||||
import requireUserprofileWithHome from './eslint-rules/require-userprofile-with-home.cjs';
|
||||
|
||||
const localPlugin = {
|
||||
rules: {
|
||||
@@ -30,6 +34,10 @@ const localPlugin = {
|
||||
'no-path-literal-in-assert': noPathLiteralInAssert,
|
||||
'no-posix-mode-bit-assert': noPosixModeBitAssert,
|
||||
'no-unguarded-nonportable-exec': noUnguardedNonportableExec,
|
||||
'no-crlf-fragile-split': noCrlfFragileSplit,
|
||||
'no-hardcoded-tmp': noHardcodedTmp,
|
||||
'no-bare-npm-exec': noBareNpmExec,
|
||||
'require-userprofile-with-home': requireUserprofileWithHome,
|
||||
},
|
||||
};
|
||||
|
||||
@@ -277,6 +285,14 @@ export default tseslint.config(
|
||||
'local/no-posix-mode-bit-assert': 'error',
|
||||
// Ban unguarded chmod exec-bit + sh/bash -c combos (fails on Windows Git Bash)
|
||||
'local/no-unguarded-nonportable-exec': 'error',
|
||||
// Ban CRLF-fragile file-content splits and regex patterns (ADR-1703 Phase 4)
|
||||
'local/no-crlf-fragile-split': 'error',
|
||||
// Ban hardcoded /tmp/ paths in fs.* calls (ADR-1703 Phase 4)
|
||||
'local/no-hardcoded-tmp': 'error',
|
||||
// Ban bare npm exec without shell:true (ADR-1703 Phase 4)
|
||||
'local/no-bare-npm-exec': 'error',
|
||||
// Require USERPROFILE alongside HOME assignments (ADR-1703 Phase 4)
|
||||
'local/require-userprofile-with-home': 'error',
|
||||
// Ban raw setTimeout sync + elapsed/duration-style assertions via no-restricted-syntax
|
||||
'no-restricted-syntax': [
|
||||
'error',
|
||||
|
||||
@@ -313,7 +313,6 @@
|
||||
"tests/verify-test-quality.test.cjs :: source-text-is-the-product",
|
||||
"tests/verify-work-auto-transition.test.cjs :: source-text-is-the-product",
|
||||
"tests/windows-robustness.test.cjs :: source-text-is-the-product",
|
||||
"tests/windows-test-parity-guard.test.cjs :: structural-regression-guard",
|
||||
"tests/workflow-compat.test.cjs :: source-text-is-the-product",
|
||||
"tests/workflow-guard-registration.test.cjs :: structural-regression-guard",
|
||||
"tests/workflow-maintainer-skip.test.cjs :: source-text-is-the-product",
|
||||
|
||||
@@ -98,6 +98,10 @@ ALLOWLIST=(
|
||||
# contain shell-exec command strings (exec("sh -c …"), execFileSync('bash',['-c',…]))
|
||||
# as test DATA the rule must lint — not attack vectors. ADR-1703 Phase 3 (#1720).
|
||||
'tests/no-unguarded-nonportable-exec.rule.test.cjs'
|
||||
# RuleTester fixtures for the local/no-bare-npm-exec ESLint rule contain npm
|
||||
# exec command strings (execFileSync('npm', ['install'])) as test DATA the rule
|
||||
# must lint — not attack vectors. ADR-1703 Phase 4 (#1726).
|
||||
'tests/no-bare-npm-exec.rule.test.cjs'
|
||||
)
|
||||
|
||||
is_allowlisted() {
|
||||
|
||||
@@ -50,7 +50,7 @@ describe('HDOC: anti-heredoc instruction', () => {
|
||||
for (const agent of ALL_AGENTS) {
|
||||
const content = fs.readFileSync(path.join(AGENTS_DIR, agent + '.md'), 'utf-8');
|
||||
// Match actual heredoc commands (not references in anti-heredoc instruction)
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
// Skip lines that are part of the anti-heredoc instruction or markdown code fences
|
||||
|
||||
@@ -32,7 +32,7 @@ const libDir = path.resolve(__dirname, '..', 'gsd-core', 'bin', 'lib');
|
||||
*/
|
||||
function findBareWrites(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const hits = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/\bfs\.writeFileSync\s*\(/.test(lines[i])) {
|
||||
|
||||
@@ -28,7 +28,7 @@ describe('commands/gsd/autonomous.md allowed-tools', () => {
|
||||
|
||||
// Parse the allowed-tools list items (lines starting with " - ")
|
||||
const toolLines = frontmatter
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^\s+-\s+/.test(line))
|
||||
.map((line) => line.replace(/^\s+-\s+/, '').trim());
|
||||
|
||||
|
||||
@@ -23,6 +23,7 @@ const ROOT = path.join(__dirname, '..');
|
||||
// the real ~/.config/opencode/ even if the guard is missing.
|
||||
const FAKE_HOME = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-130-test-'));
|
||||
process.env.HOME = FAKE_HOME;
|
||||
process.env.USERPROFILE = FAKE_HOME;
|
||||
|
||||
// The opencode config dir that configureOpencodePermissions would use for a
|
||||
// global install when configDir=null: <HOME>/.config/opencode/
|
||||
|
||||
@@ -81,7 +81,7 @@ describe('buildStateFrontmatter cache invalidation (#1967)', () => {
|
||||
|
||||
// Read back and parse frontmatter to verify it reflects 2 phases, not 1
|
||||
const result = fs.readFileSync(statePath, 'utf-8');
|
||||
const fmMatch = result.match(/^---\n([\s\S]*?)\n---/);
|
||||
const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'STATE.md should have frontmatter after writeStateMd');
|
||||
|
||||
const fm = fmMatch[1];
|
||||
|
||||
@@ -50,39 +50,39 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
|
||||
test('workflow emits a wave-start heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*wave \{N\}\/\{M\} starting/.test(workflow),
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} starting/.test(workflow),
|
||||
'workflow should emit a wave-start [checkpoint] marker before spawning agents'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a wave-complete heartbeat (A: wave-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*wave \{N\}\/\{M\} complete/.test(workflow),
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(workflow),
|
||||
'workflow should emit a wave-complete [checkpoint] marker after spot-checks'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-start heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*plan \{plan_id\} starting/.test(workflow),
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(workflow),
|
||||
'workflow should emit a plan-start [checkpoint] marker before each Task() dispatch'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow emits a plan-complete heartbeat (B: plan-boundary checkpoint)', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*plan \{plan_id\} complete/.test(workflow),
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(workflow),
|
||||
'workflow should emit a plan-complete [checkpoint] marker after executor returns'
|
||||
);
|
||||
});
|
||||
|
||||
test('workflow handles plan failure and checkpoint-gate heartbeats too', () => {
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*plan \{plan_id\} failed/.test(workflow),
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} failed/.test(workflow),
|
||||
'workflow should emit a plan-failed [checkpoint] marker on executor error'
|
||||
);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*plan \{plan_id\} checkpoint/.test(workflow),
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} checkpoint/.test(workflow),
|
||||
'workflow should emit a heartbeat when a plan returns a human-gate checkpoint'
|
||||
);
|
||||
});
|
||||
@@ -129,7 +129,7 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
assert.ok(spawnIdx !== -1 && waitIdx !== -1, 'spawn and wait steps must exist');
|
||||
const step3 = workflow.slice(spawnIdx, waitIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*plan \{plan_id\} starting/.test(step3),
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} starting/.test(step3),
|
||||
'plan-start heartbeat should be emitted inside step 3 (spawn executor agents)'
|
||||
);
|
||||
});
|
||||
@@ -140,7 +140,7 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
assert.ok(waitIdx !== -1 && hookIdx !== -1, 'wait + hook steps must exist');
|
||||
const step4 = workflow.slice(waitIdx, hookIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*plan \{plan_id\} complete/.test(step4),
|
||||
/\[checkpoint\][^\r\n]*plan \{plan_id\} complete/.test(step4),
|
||||
'plan-complete heartbeat should be emitted in step 4 (wait for agents)'
|
||||
);
|
||||
|
||||
@@ -149,7 +149,7 @@ describe('bug #2410: execute-phase emits checkpoint heartbeats', () => {
|
||||
assert.ok(reportIdx !== -1 && failureIdx !== -1, 'report + failure steps must exist');
|
||||
const step6 = workflow.slice(reportIdx, failureIdx);
|
||||
assert.ok(
|
||||
/\[checkpoint\][^\n]*wave \{N\}\/\{M\} complete/.test(step6),
|
||||
/\[checkpoint\][^\r\n]*wave \{N\}\/\{M\} complete/.test(step6),
|
||||
'wave-complete heartbeat should be emitted in step 6 (report completion)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -95,8 +95,8 @@ describe('plan-phase decision-coverage gate (#2492)', () => {
|
||||
const snippet = md.slice(gateIdx, gateIdx + 800);
|
||||
// Accept either an inline `|| exit 1` or a `|| { ...; exit 1; }` group.
|
||||
const hasJqGuard =
|
||||
/jq[^\n]*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/jq[^\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet);
|
||||
/jq[^\r\n]*\.data\.passed\s*==\s*true/.test(snippet) ||
|
||||
/jq[^\r\n]*\(\.passed\s*\/\/\s*\.data\.passed\)\s*==\s*true/.test(snippet);
|
||||
const hasExitOne = /\|\|\s*(?:exit\s+1|\{[\s\S]{0,200}?exit\s+1)/.test(snippet);
|
||||
assert.ok(
|
||||
hasJqGuard && hasExitOne,
|
||||
|
||||
@@ -69,7 +69,7 @@ describe('bug #2516: executor_model "inherit" must not be passed literally to Ta
|
||||
content.includes('omit `model=`') ||
|
||||
content.includes('omit model=')
|
||||
);
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const hasLiteralInheritInTask = lines.some(line => {
|
||||
if (!/model\s*=\s*["']inherit["']/.test(line)) return false;
|
||||
// Exclude instructional/explanatory lines that document what NOT to do
|
||||
|
||||
@@ -119,7 +119,7 @@ describe('slash-command namespace invariant (#3443)', () => {
|
||||
const violations = [];
|
||||
for (const file of allUserFacingFiles) {
|
||||
const src = fs.readFileSync(file, 'utf-8');
|
||||
const lines = src.split('\n');
|
||||
const lines = src.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (retiredPattern.test(lines[i])) {
|
||||
violations.push(`${path.relative(ROOT, file)}:${i + 1}: ${lines[i].trim().slice(0, 80)}`);
|
||||
|
||||
@@ -123,9 +123,9 @@ describe('skill frontmatter name parity (#2643 / #2808)', () => {
|
||||
const input = '---\nname: old\ndescription: test\n---\n\nBody.';
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-execute-phase');
|
||||
// Parse the frontmatter block structurally: extract the name: field value.
|
||||
const frontmatterMatch = result.match(/^---\n([\s\S]*?)\n---/);
|
||||
const frontmatterMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(frontmatterMatch, 'output must have a frontmatter block delimited by ---');
|
||||
const frontmatterLines = frontmatterMatch[1].split('\n');
|
||||
const frontmatterLines = frontmatterMatch[1].split(/\r?\n/);
|
||||
const nameEntry = frontmatterLines.find((l) => l.startsWith('name:'));
|
||||
assert.ok(nameEntry, 'frontmatter must contain a name: field');
|
||||
const nameValue = nameEntry.replace(/^name:\s*/, '').trim();
|
||||
@@ -185,7 +185,7 @@ describe('skill frontmatter name parity (#2643 / #2808)', () => {
|
||||
const skillDirName = 'gsd-' + base;
|
||||
const src = fs.readFileSync(path.join(COMMANDS_DIR, cmd), 'utf-8');
|
||||
const out = convertClaudeCommandToClaudeSkill(src, skillDirName);
|
||||
const m = out.match(/^---\nname:\s*(.+)$/m);
|
||||
const m = out.match(/^---\r?\nname:\s*(.+)$/m);
|
||||
if (m) emitted.add(m[1].trim());
|
||||
}
|
||||
|
||||
|
||||
@@ -44,17 +44,22 @@ describe('bug-2794: readGsdRuntimeProfileResolver resolves opencode tier overrid
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUP;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
origUP = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origUP === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUP;
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
});
|
||||
@@ -105,20 +110,25 @@ describe('bug-2794: OpenCode agent install embeds model_profile_overrides model'
|
||||
let projectDir;
|
||||
let homeDir;
|
||||
let origHome;
|
||||
let origUP;
|
||||
let origCwd;
|
||||
|
||||
beforeEach(() => {
|
||||
projectDir = makeTmp('proj');
|
||||
homeDir = makeTmp('home');
|
||||
origHome = process.env.HOME;
|
||||
origUP = process.env.USERPROFILE;
|
||||
origCwd = process.cwd();
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
process.chdir(projectDir);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
if (origHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = origHome;
|
||||
if (origUP === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = origUP;
|
||||
process.chdir(origCwd);
|
||||
cleanup(projectDir);
|
||||
cleanup(homeDir);
|
||||
|
||||
@@ -81,9 +81,9 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
const skillContent = convertClaudeCommandToClaudeSkill(src, skillDirName);
|
||||
|
||||
// Parse frontmatter structurally: extract name: line from the --- block.
|
||||
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, `${cmd}: generated skill content must have a frontmatter block`);
|
||||
const fmLines = fmMatch[1].split('\n');
|
||||
const fmLines = fmMatch[1].split(/\r?\n/);
|
||||
const nameEntry = fmLines.find((l) => l.startsWith('name:'));
|
||||
assert.ok(nameEntry, `${cmd}: generated SKILL.md is missing required name: field`);
|
||||
|
||||
@@ -108,7 +108,7 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
// gsd:sdk and gsd:tools are intentionally excluded: they are not slash commands
|
||||
// (no commands/gsd/sdk.md or tools.md exist), so the transformer correctly leaves
|
||||
// them alone. They are benign and should not trigger this assertion.
|
||||
const bodyContent = skillContent.replace(/^---\n[\s\S]*?\n---\n?/, '');
|
||||
const bodyContent = skillContent.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/, '');
|
||||
const colonRefs = (bodyContent.match(/\bgsd:[a-z][a-z0-9-]*\b/g) || [])
|
||||
.filter(r => !/gsd:(sdk|tools)/.test(r));
|
||||
assert.strictEqual(
|
||||
@@ -144,7 +144,7 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
// Scan each line for Skill() calls using the colon form.
|
||||
// Parsing line-by-line is more precise than a multi-line regex
|
||||
// and avoids false positives from incidental matches in prose.
|
||||
for (const line of stripped.split('\n')) {
|
||||
for (const line of stripped.split(/\r?\n/)) {
|
||||
// Tolerate whitespace around the parenthesis, the `skill` keyword,
|
||||
// and the `=` so variants like `Skill( skill = "gsd:foo" )` are still
|
||||
// flagged. Without the `\s*` allowances, drift slips through this guard.
|
||||
@@ -213,9 +213,9 @@ describe('bug-2808: SKILL.md name: uses hyphen form', () => {
|
||||
const skillContent = fs.readFileSync(skillMdPath, 'utf-8');
|
||||
// Scope the name: lookup to the YAML frontmatter block so a stray
|
||||
// `name:` line in the body cannot satisfy the assertion.
|
||||
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
||||
const fmMatch = skillContent.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, `${relPath}: generated SKILL.md must include frontmatter`);
|
||||
const nameLine = fmMatch[1].split('\n').find((l) => /^name:\s*/.test(l));
|
||||
const nameLine = fmMatch[1].split(/\r?\n/).find((l) => /^name:\s*/.test(l));
|
||||
assert.ok(nameLine, `${relPath}: generated SKILL.md is missing name: frontmatter`);
|
||||
const name = nameLine.replace(/^name:\s*/, '').trim();
|
||||
assert.ok(name.startsWith('gsd-'), `${relPath}: autocomplete name must start with gsd-, got ${name}`);
|
||||
|
||||
@@ -166,7 +166,7 @@ describe('bug #2836: workflows/help.md one-liner reconciliation', () => {
|
||||
// Locate the documented "Result: Creates ..." quick-task one-liner and
|
||||
// assert it references the per-task SUMMARY filename pattern, not bare
|
||||
// SUMMARY.md. We parse by line to avoid false positives elsewhere.
|
||||
const resultLines = content.split('\n').filter(l =>
|
||||
const resultLines = content.split(/\r?\n/).filter(l =>
|
||||
l.includes('Result: Creates') && l.includes('.planning/quick/')
|
||||
);
|
||||
assert.ok(resultLines.length > 0, 'expected a quick-task Result line in help.md');
|
||||
|
||||
@@ -32,7 +32,7 @@ const src = fs.readFileSync(UPDATE_WF, 'utf8');
|
||||
test('bug #3130: update.md contains no bare npx invocations (cache-stale form)', () => {
|
||||
// Any occurrence of `npx -y @opengsd/gsd-core@<something>` without `--package=`
|
||||
// is the stale form that triggers the two failure modes.
|
||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\n]*/g) || []);
|
||||
const stale = (src.match(/npx -y @opengsd\/gsd-core@\S+[^\r\n]*/g) || []);
|
||||
assert.deepEqual(
|
||||
stale,
|
||||
[],
|
||||
|
||||
@@ -33,9 +33,9 @@ function readMdFiles(dir, prefix) {
|
||||
}
|
||||
|
||||
function extractFrontmatterTools(content) {
|
||||
const fm = content.match(/^---\n([\s\S]*?)\n---/);
|
||||
const fm = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
if (!fm) return [];
|
||||
const toolsMatch = fm[1].match(/^allowed-tools:\s*\n((?:[ \t]+-[^\n]*\n?)*)/m) ||
|
||||
const toolsMatch = fm[1].match(/^allowed-tools:\s*\r?\n((?:[ \t]+-[^\n]*\n?)*)/m) ||
|
||||
fm[1].match(/^tools:\s*(.+)$/m);
|
||||
if (!toolsMatch) return [];
|
||||
const toolsBlock = toolsMatch[1];
|
||||
@@ -79,7 +79,7 @@ describe('#3168 — workflows: prose must use Agent( not Task( for dispatcher ca
|
||||
|
||||
for (const wf of workflows) {
|
||||
test(`${wf.name}: must not contain dispatcher Task( calls`, () => {
|
||||
const lines = wf.content.split('\n');
|
||||
const lines = wf.content.split(/\r?\n/);
|
||||
const violations = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
@@ -137,7 +137,7 @@ describe('bug #3290 — Group B: layout-detection verdict has no downstream cons
|
||||
const src = fs.readFileSync(file, 'utf-8');
|
||||
if (src.includes('Layout detection returned')) {
|
||||
// Collect matching lines for the error message
|
||||
const lines = src.split('\n')
|
||||
const lines = src.split(/\r?\n/)
|
||||
.map((l, i) => ({ line: l, n: i + 1 }))
|
||||
.filter(({ line }) => line.includes('Layout detection returned'));
|
||||
matches.push({ rel, lines });
|
||||
|
||||
@@ -168,7 +168,7 @@ test('bug-3491: new-project.md gates `git init` on in_nested_subdir, not just ha
|
||||
// either gate the init on `in_nested_subdir`/worktree-root semantics or
|
||||
// drop the unconditional `git init` block entirely.
|
||||
const unconditionalInitPattern =
|
||||
/\*\*If `has_git` is false:\*\* Initialize git:\s*\n+```bash\s*\ngit init\s*\n```/;
|
||||
/\*\*If `has_git` is false:\*\* Initialize git:\s*\r?\n+```bash\s*\r?\ngit init\s*\r?\n```/;
|
||||
assert.ok(
|
||||
!unconditionalInitPattern.test(content),
|
||||
'new-project.md must not run `git init` unconditionally on has_git=false (#3491). ' +
|
||||
|
||||
@@ -44,7 +44,7 @@ function listAgentFiles() {
|
||||
|
||||
function scanForRetired(filePath) {
|
||||
const text = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = text.split('\n');
|
||||
const lines = text.split(/\r?\n/);
|
||||
const hits = [];
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
for (const cmd of RETIRED_COMMANDS) {
|
||||
|
||||
@@ -150,7 +150,7 @@ describe('bug #3678 — executor must respect commit_docs:false', () => {
|
||||
);
|
||||
const stagedAll = git(['diff', '--cached', '--name-only'], tmpDir);
|
||||
const stagedPlanning = stagedAll
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.map(s => s.trim())
|
||||
.filter(s => s.startsWith('.planning/'));
|
||||
assert.deepStrictEqual(
|
||||
@@ -178,7 +178,7 @@ describe('bug #3678 — executor must respect commit_docs:false', () => {
|
||||
test('checklist carve-out preserved for intentional skip', () => {
|
||||
const body = fs.readFileSync(EXECUTOR_AGENT, 'utf-8');
|
||||
const checklistLine = body
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.find(line => /Final metadata commit made/.test(line));
|
||||
assert.ok(
|
||||
checklistLine,
|
||||
@@ -205,7 +205,7 @@ describe('bug #3678 — executor must respect commit_docs:false', () => {
|
||||
if (entry.isDirectory()) { walk(full); continue; }
|
||||
if (!entry.isFile() || !entry.name.endsWith('.md')) continue;
|
||||
const body = fs.readFileSync(full, 'utf-8');
|
||||
const lines = body.split('\n');
|
||||
const lines = body.split(/\r?\n/);
|
||||
const danger = lines.filter((line) => {
|
||||
if (!/git\s+add\s+(-f|--force)\b/.test(line)) return false;
|
||||
// Allow prohibition / warning sentences and code-fence prose that
|
||||
|
||||
@@ -19,7 +19,7 @@ function readKnownTargets() {
|
||||
}
|
||||
|
||||
function stripFrontmatter(src) {
|
||||
return src.replace(/^---\r?\n[\s\S]*?\n---\r?\n/, '');
|
||||
return src.replace(/^---\r?\n[\s\S]*?\r?\n---\r?\n/, '');
|
||||
}
|
||||
|
||||
// Word-boundary lookbehind matching fix-slash-commands.cjs buildColonPattern / buildPattern
|
||||
|
||||
@@ -55,7 +55,7 @@ function workerCodeOnly() {
|
||||
const src = fs.readFileSync(WORKER_PATH, 'utf8');
|
||||
return src
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||||
.replace(/(^|[^:])\/\/[^\r\n]*/g, '$1');
|
||||
}
|
||||
|
||||
describe('bug #378 / #498: update worker queries the scoped name via the seam', () => {
|
||||
|
||||
@@ -88,13 +88,13 @@ describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#50
|
||||
|
||||
test('execution_context classifier maps /.agents/ and /.agent/ paths to antigravity (update.md)', () => {
|
||||
const hasAgentsClassifierRule =
|
||||
/\/\.agents\/[^\n]*->[^\n]*antigravity/.test(UPDATE_MD);
|
||||
/\/\.agents\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD);
|
||||
assert.ok(
|
||||
hasAgentsClassifierRule,
|
||||
'update.md classifier must map a `/.agents/` path to the `antigravity` runtime',
|
||||
);
|
||||
const hasAgentClassifierRule =
|
||||
/\/\.agent\/[^\n]*->[^\n]*antigravity/.test(UPDATE_MD);
|
||||
/\/\.agent\/[^\r\n]*->[^\r\n]*antigravity/.test(UPDATE_MD);
|
||||
assert.ok(
|
||||
hasAgentClassifierRule,
|
||||
'update.md classifier must still map a `/.agent/` path to the `antigravity` runtime (backward-compat)',
|
||||
@@ -108,7 +108,7 @@ describe('/gsd:update detects local Antigravity (.agent / .agents) installs (#50
|
||||
// include both .agents (canonical, #791) and .agent (legacy, #503) or
|
||||
// stale indicators could linger.
|
||||
const runtimeDirLoops = UPDATE_MD
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => /for dir in .*\.claude.*\.codex/.test(l));
|
||||
assert.ok(
|
||||
runtimeDirLoops.length >= 1,
|
||||
|
||||
@@ -73,7 +73,7 @@ describe('bug #619 — codebase-drift-gate resolves gsd-tools via the runtime sh
|
||||
|
||||
test('exactly one canonical launcher preamble, in the drift-check block, before any launcher call (#619)', () => {
|
||||
const content = readGate();
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\n$/, '');
|
||||
const snippet = fs.readFileSync(SNIPPET_FILE, 'utf8').replace(/\r?\n$/, '');
|
||||
|
||||
// Count canonical preamble occurrences across the whole file (parity: exactly one).
|
||||
let count = 0;
|
||||
|
||||
@@ -237,7 +237,7 @@ describe('bug #1329 — ci-prepare-test-scope fallback never emits a deleted fil
|
||||
assert.strictEqual(prep.status, 0, `prepare step failed: ${prep.stderr}`);
|
||||
|
||||
const selected = fs.readFileSync(path.join(tmpDir, '.ci-selected-tests.txt'), 'utf8');
|
||||
for (const line of selected.split('\n').filter(Boolean)) {
|
||||
for (const line of selected.split(/\r?\n/).filter(Boolean)) {
|
||||
const isSentinel = SUITE_SENTINELS.includes(line);
|
||||
assert.ok(
|
||||
isSentinel || fs.existsSync(path.join(tmpDir, line)),
|
||||
|
||||
@@ -13,6 +13,10 @@ const { cleanup } = require('./helpers.cjs');
|
||||
const { installerEnv } = require('./helpers/install-shared.cjs');
|
||||
|
||||
const ROOT = path.join(__dirname, '..');
|
||||
|
||||
// Cross-platform temp paths for test fixtures (avoids hardcoded /tmp)
|
||||
const KIMI_CFG = path.join(os.tmpdir(), 'gsd-kimi-config-test').replace(/\\/g, '/');
|
||||
const XDG_HOME = path.join(os.tmpdir(), 'gsd-xdg-home-test');
|
||||
const INSTALL_SCRIPT = path.join(ROOT, 'bin', 'install.js');
|
||||
|
||||
const {
|
||||
@@ -101,20 +105,21 @@ 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(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), '/tmp/custom-kimi-code');
|
||||
const customKimiDir = path.join(os.tmpdir(), 'custom-kimi-code');
|
||||
withEnv({ KIMI_CONFIG_DIR: customKimiDir, XDG_CONFIG_HOME: undefined }, () => {
|
||||
assert.strictEqual(String(getGlobalConfigDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/'));
|
||||
assert.strictEqual(
|
||||
getGlobalSkillsBase('kimi'),
|
||||
path.join('/tmp/custom-kimi-code', 'skills'),
|
||||
path.join(customKimiDir, 'skills'),
|
||||
);
|
||||
assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), '/tmp/custom-kimi-code');
|
||||
assert.strictEqual(String(getGlobalDir('kimi')).replace(/\\/g, '/'), customKimiDir.replace(/\\/g, '/'));
|
||||
});
|
||||
});
|
||||
|
||||
test('XDG_CONFIG_HOME does not change Kimi default root', () => {
|
||||
const tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-kimi-home-xdg-'));
|
||||
try {
|
||||
withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: '/tmp/xdg-home', HOME: tmpHome, USERPROFILE: tmpHome }, () => {
|
||||
withEnv({ KIMI_CONFIG_DIR: undefined, XDG_CONFIG_HOME: XDG_HOME, HOME: tmpHome, USERPROFILE: tmpHome }, () => {
|
||||
assert.strictEqual(
|
||||
getGlobalConfigDir('kimi'),
|
||||
path.join(tmpHome, '.config', 'agents'),
|
||||
@@ -166,9 +171,9 @@ describe('Kimi runtime homes', () => {
|
||||
|
||||
describe('Kimi runtime artifact layout', () => {
|
||||
test('global layout stages Kimi skills and agents while local layout remains guarded', () => {
|
||||
const globalLayout = resolveRuntimeArtifactLayout('kimi', '/tmp/kimi-config', 'global');
|
||||
const globalLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'global');
|
||||
assert.strictEqual(globalLayout.runtime, 'kimi');
|
||||
assert.strictEqual(globalLayout.configDir, '/tmp/kimi-config');
|
||||
assert.strictEqual(String(globalLayout.configDir).replace(/\\/g, '/'), KIMI_CFG);
|
||||
assert.strictEqual(globalLayout.kinds.length, 2);
|
||||
assert.strictEqual(globalLayout.kinds[0].kind, 'skills');
|
||||
assert.strictEqual(globalLayout.kinds[0].destSubpath, 'skills');
|
||||
@@ -179,7 +184,7 @@ describe('Kimi runtime artifact layout', () => {
|
||||
assert.strictEqual(globalLayout.kinds[1].prefix, 'gsd');
|
||||
assert.strictEqual(typeof globalLayout.kinds[1].stage, 'function');
|
||||
|
||||
const localLayout = resolveRuntimeArtifactLayout('kimi', '/tmp/kimi-config', 'local');
|
||||
const localLayout = resolveRuntimeArtifactLayout('kimi', KIMI_CFG, 'local');
|
||||
assert.strictEqual(localLayout.runtime, 'kimi');
|
||||
assert.deepStrictEqual(localLayout.kinds, []);
|
||||
});
|
||||
|
||||
@@ -31,8 +31,8 @@ describe('capability-matrix drift guard (ADR-1244 Phase 6)', () => {
|
||||
});
|
||||
|
||||
test('buildMatrix(registry) equals the committed file byte-for-byte (modulo line endings)', () => {
|
||||
const generated = buildMatrix(registry).replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
|
||||
const committed = fs.readFileSync(MATRIX, 'utf8').replace(/\r\n/g, '\n').replace(/\n+$/, '\n');
|
||||
const generated = buildMatrix(registry).replace(/\r\r?\n/g, '\n').replace(/\r?\n+$/, '\n');
|
||||
const committed = fs.readFileSync(MATRIX, 'utf8').replace(/\r\r?\n/g, '\n').replace(/\r?\n+$/, '\n');
|
||||
assert.equal(committed, generated);
|
||||
});
|
||||
|
||||
@@ -53,7 +53,7 @@ describe('capability-matrix drift guard (ADR-1244 Phase 6)', () => {
|
||||
// rendered row reflects it.
|
||||
const shipPreGates = (registry.byLoopPoint['ship:pre'] && registry.byLoopPoint['ship:pre'].gates) || [];
|
||||
assert.ok(shipPreGates.some((g) => g.capId === 'security'), 'precondition: security registers a ship:pre gate in the registry');
|
||||
const securityRow = md.split('\n').find((l) => l.includes('`security`') && l.includes('|'));
|
||||
const securityRow = md.split(/\r?\n/).find((l) => l.includes('`security`') && l.includes('|'));
|
||||
assert.ok(securityRow && securityRow.includes('`ship:pre`'), 'security row must list its real ship:pre extension point');
|
||||
});
|
||||
});
|
||||
|
||||
@@ -87,7 +87,7 @@ describe('detectConfigDir runtime behavior (#1860)', () => {
|
||||
const hookSource = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8');
|
||||
|
||||
// Extract detectConfigDir function body (from 'function detectConfigDir' to the closing brace)
|
||||
const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\n\})/);
|
||||
const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\r?\n\})/);
|
||||
assert.ok(fnMatch, 'should be able to extract detectConfigDir function from hook source');
|
||||
const fnSource = fnMatch[1];
|
||||
|
||||
@@ -124,7 +124,7 @@ describe('detectConfigDir runtime behavior (#1860)', () => {
|
||||
fs.writeFileSync(path.join(openCodeVersionDir, 'VERSION'), '1.0.0\n');
|
||||
|
||||
const hookSource = fs.readFileSync(CHECK_UPDATE_PATH, 'utf8');
|
||||
const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\n\})/);
|
||||
const fnMatch = hookSource.match(/(function detectConfigDir\(baseDir\)\s*\{[\s\S]*?\r?\n\})/);
|
||||
assert.ok(fnMatch, 'should be able to extract detectConfigDir function from hook source');
|
||||
const fnSource = fnMatch[1];
|
||||
|
||||
|
||||
@@ -431,7 +431,7 @@ describe('test.yml changes job contract (#837)', () => {
|
||||
test('changes job checkout step sets fetch-depth: 0 (required for three-dot diff merge-base)', () => {
|
||||
const workflowPath = path.join(WORKFLOWS_DIR, 'test.yml');
|
||||
const text = fs.readFileSync(workflowPath, 'utf8');
|
||||
const lines = text.split('\n');
|
||||
const lines = text.split(/\r?\n/);
|
||||
|
||||
// Locate the `changes:` job (two-space-indented top-level job key).
|
||||
const jobStart = lines.findIndex(l => /^ {2}changes:\s*$/.test(l));
|
||||
|
||||
@@ -230,7 +230,9 @@ describe('generate-claude-md skills section', () => {
|
||||
);
|
||||
|
||||
const originalHome = process.env.HOME;
|
||||
const originalUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = homeDir;
|
||||
process.env.USERPROFILE = homeDir;
|
||||
|
||||
try {
|
||||
const result = runGsdTools('generate-claude-md', tmpDir);
|
||||
@@ -241,7 +243,10 @@ describe('generate-claude-md skills section', () => {
|
||||
assert.ok(content.includes('Project Codex skill'));
|
||||
assert.ok(!content.includes('import-only'));
|
||||
} finally {
|
||||
process.env.HOME = originalHome;
|
||||
if (originalHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = originalHome;
|
||||
if (originalUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = originalUserProfile;
|
||||
cleanup(homeDir);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -116,7 +116,7 @@ function assertNoDraftRootKeys(content) {
|
||||
function assertUsesOnlyEol(content, eol) {
|
||||
if (eol === '\r\n') {
|
||||
assert.ok(content.includes('\r\n'), 'contains CRLF line endings');
|
||||
assert.ok(!content.replace(/\r\n/g, '').includes('\n'), 'does not contain bare LF line endings');
|
||||
assert.ok(!content.replace(/\r\r?\n/g, '').includes('\n'), 'does not contain bare LF line endings');
|
||||
return;
|
||||
}
|
||||
assert.ok(!content.includes('\r\n'), 'does not contain CRLF line endings');
|
||||
@@ -124,7 +124,7 @@ function assertUsesOnlyEol(content, eol) {
|
||||
|
||||
function assertNoCodexBareGsdToolsInvocation(content, label) {
|
||||
const patterns = [
|
||||
/(^|\n)[ \t]*gsd-tools\s/,
|
||||
/(^|\r?\n)[ \t]*gsd-tools\s/,
|
||||
/\$\(\s*gsd-tools\s/,
|
||||
/`\s*gsd-tools\s/,
|
||||
/(?:&&|\|\||[;|])\s*gsd-tools\s/,
|
||||
@@ -1379,7 +1379,7 @@ describe('mergeCodexConfig', () => {
|
||||
assert.ok(content.includes('[agents.custom-agent]'), 'preserves non-GSD agent section');
|
||||
assert.strictEqual(gsdStructCount, 1, 'keeps exactly one [agents.gsd-executor] struct entry');
|
||||
assert.strictEqual(markerCount, 1, 'adds exactly one marker block');
|
||||
assert.ok(!/\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block');
|
||||
assert.ok(!/\r?\n{3,}# GSD Agent Configuration/.test(content), 'does not leave extra blank lines before marker block');
|
||||
});
|
||||
|
||||
test('idempotent: re-merge produces same result', () => {
|
||||
@@ -1693,11 +1693,11 @@ describe('codex features section safety', () => {
|
||||
// causes "invalid type: string, expected a boolean in features"
|
||||
const configContent = `[features]\ncodex_hooks = true\n\nmodel = "gpt-5.4"\nmodel_reasoning_effort = "medium"\n\n[agents.gsd-executor]\ndescription = "test"\n`;
|
||||
|
||||
const featuresMatch = configContent.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/);
|
||||
const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||||
assert.ok(featuresMatch, 'features section found');
|
||||
|
||||
const featuresBody = featuresMatch[1];
|
||||
const nonBooleanKeys = featuresBody.split('\n')
|
||||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/))
|
||||
.map(line => line.trim());
|
||||
|
||||
@@ -1709,9 +1709,9 @@ describe('codex features section safety', () => {
|
||||
test('boolean keys under [features] are NOT flagged', () => {
|
||||
const configContent = `[features]\ncodex_hooks = true\nmulti_agent = false\n`;
|
||||
|
||||
const featuresMatch = configContent.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/);
|
||||
const featuresMatch = configContent.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||||
const featuresBody = featuresMatch[1];
|
||||
const nonBooleanKeys = featuresBody.split('\n')
|
||||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/))
|
||||
.map(line => line.trim());
|
||||
|
||||
@@ -1793,7 +1793,7 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
const content = readCodexConfig(codexHome);
|
||||
const agentsDir = path.join(codexHome, 'agents').replace(/\\/g, '/');
|
||||
// All config_file values should use absolute paths
|
||||
const configFileLines = content.split('\n').filter(l => l.startsWith('config_file = '));
|
||||
const configFileLines = content.split(/\r?\n/).filter(l => l.startsWith('config_file = '));
|
||||
assert.ok(configFileLines.length > 0, 'has config_file entries');
|
||||
for (const line of configFileLines) {
|
||||
assert.ok(line.includes(agentsDir), `absolute path in: ${line}`);
|
||||
@@ -1831,10 +1831,10 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= relocated before [features]');
|
||||
|
||||
// [features] should only contain boolean keys
|
||||
const featuresMatch = content.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/);
|
||||
const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||||
assert.ok(featuresMatch, 'features section found');
|
||||
const featuresBody = featuresMatch[1];
|
||||
const nonBooleanKeys = featuresBody.split('\n')
|
||||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/));
|
||||
assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]');
|
||||
|
||||
@@ -1895,10 +1895,10 @@ describe('Codex install hook configuration (e2e)', () => {
|
||||
assert.ok(reasoningIndex < featuresIndex, 'model_reasoning_effort= stays before [features]');
|
||||
|
||||
// [features] should only contain boolean keys
|
||||
const featuresMatch = content.match(/\[features\]\n([\s\S]*?)(?=\n\[|$)/);
|
||||
const featuresMatch = content.match(/\[features\]\r?\n([\s\S]*?)(?=\n\[|$)/);
|
||||
assert.ok(featuresMatch, 'features section found');
|
||||
const featuresBody = featuresMatch[1];
|
||||
const nonBooleanKeys = featuresBody.split('\n')
|
||||
const nonBooleanKeys = featuresBody.split(/\r?\n/)
|
||||
.filter(line => line.match(/^\s*\w+\s*=/) && !line.match(/=\s*(true|false)\s*(#.*)?$/));
|
||||
assert.strictEqual(nonBooleanKeys.length, 0, 'no non-boolean keys under [features]');
|
||||
|
||||
@@ -2572,7 +2572,7 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => {
|
||||
runCodexInstall(codexHome);
|
||||
|
||||
const cleaned = stripGsdFromCodexConfig(readCodexConfig(codexHome));
|
||||
assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split('\n')[0]}`);
|
||||
assert.strictEqual(cleaned, initialContent, `preserves short-circuited root features assignment: ${initialContent.split(/\r?\n/)[0]}`);
|
||||
|
||||
cleanup(codexHome);
|
||||
fs.mkdirSync(codexHome, { recursive: true });
|
||||
@@ -2588,7 +2588,7 @@ describe('Codex uninstall symmetry for hook-enabled configs', () => {
|
||||
'[model]',
|
||||
'name = "o3"',
|
||||
'',
|
||||
].join('\r\n').replace(/^# first line wins\r\n/, '# first line wins\n');
|
||||
].join('\r\n').replace(/^# first line wins\r\r?\n/, '# first line wins\n');
|
||||
|
||||
writeCodexConfig(codexHome, initialContent);
|
||||
runCodexInstall(codexHome);
|
||||
|
||||
@@ -22,7 +22,7 @@ describe('commit_docs bypass guard (#1783)', () => {
|
||||
|
||||
test('execute-phase.md: every git add .planning/ has a commit_docs guard', () => {
|
||||
const content = fs.readFileSync(EXECUTE_PHASE_PATH, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/git add\b.*\.planning\//.test(lines[i])) {
|
||||
@@ -39,7 +39,7 @@ describe('commit_docs bypass guard (#1783)', () => {
|
||||
|
||||
test('quick.md: every git add .planning/ has a commit_docs guard', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/git add\b.*\.planning\//.test(lines[i])) {
|
||||
@@ -55,7 +55,7 @@ describe('commit_docs bypass guard (#1783)', () => {
|
||||
|
||||
test('quick.md: git add ${file_list} has a commit_docs guard for .planning/ filtering', () => {
|
||||
const content = fs.readFileSync(QUICK_PATH, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
// Find the line(s) that do `git add ${file_list}` — this variable
|
||||
// includes .planning/STATE.md so it needs a commit_docs guard too
|
||||
@@ -82,7 +82,7 @@ describe('commit_docs bypass guard (#1783)', () => {
|
||||
const content = fs.readFileSync(wf.path, 'utf-8');
|
||||
|
||||
// Find all occurrences of git add that reference .planning/
|
||||
const regex = /git add\b[^\n]*\.planning\//g;
|
||||
const regex = /git add\b[^\r\n]*\.planning\//g;
|
||||
let match;
|
||||
while ((match = regex.exec(content)) !== null) {
|
||||
// Get the 500-char window before this match
|
||||
|
||||
@@ -64,7 +64,7 @@ describe('config-field-docs', () => {
|
||||
// Extract CONFIG_DEFAULTS keys from config-loader.cjs source (moved from core.cjs by ADR-857 phase 2e)
|
||||
const coreSource = fs.readFileSync(CORE_PATH, 'utf-8');
|
||||
const defaultsMatch = coreSource.match(
|
||||
/const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\n\};/
|
||||
/const CONFIG_DEFAULTS\s*=\s*\{([\s\S]*?)\r?\n\};/
|
||||
);
|
||||
assert.ok(defaultsMatch, 'Could not find CONFIG_DEFAULTS in config-loader.cjs');
|
||||
|
||||
@@ -302,7 +302,7 @@ describe('CONFIGURATION.md parity (#1216)', () => {
|
||||
test('settings-advanced.md parse-default list must NOT show subagent_timeout default 600 (#1216)', () => {
|
||||
// Line 53 regression: the parse-default list item must use 300000, not 600
|
||||
assert.ok(
|
||||
!(/`workflow\.subagent_timeout`[^\n]*default:[^\n]*`?600`?/.test(settingsAdvancedContent)),
|
||||
!(/`workflow\.subagent_timeout`[^\r\n]*default:[^\n]*`?600`?/.test(settingsAdvancedContent)),
|
||||
'settings-advanced.md must NOT list subagent_timeout default as 600 (stale seconds default)'
|
||||
);
|
||||
});
|
||||
|
||||
@@ -47,7 +47,7 @@ function makeManifest() {
|
||||
function readJsonl(filePath) {
|
||||
const raw = fs.readFileSync(filePath, 'utf8').trim();
|
||||
if (!raw) return [];
|
||||
return raw.split('\n').map(line => JSON.parse(line));
|
||||
return raw.split(/\r?\n/).map(line => JSON.parse(line));
|
||||
}
|
||||
|
||||
// ─── Shared state for the test group ─────────────────────────────────────────
|
||||
|
||||
@@ -24,7 +24,7 @@ const specTemplatePath = path.join(__dirname, '..', 'gsd-core', 'templates', 'sp
|
||||
// The \n? before the closing fence allows blocks whose closing fence has no preceding newline
|
||||
// (fixes the silent-skip bug where a trailing-fence-with-no-newline was not matched).
|
||||
function taggedJsonBlocks(md) {
|
||||
const re = /```json edge-probe:([^\n]+)\n([\s\S]*?)\n?```/g;
|
||||
const re = /```json edge-probe:([^\r\n]+)\r?\n([\s\S]*?)\r?\n?```/g;
|
||||
const out = {};
|
||||
let m;
|
||||
while ((m = re.exec(md))) out[m[1].trim()] = m[2];
|
||||
|
||||
@@ -19,7 +19,7 @@ describe('enh-773: automated codex exec invocations include --ephemeral and --da
|
||||
// probe (`codex exec --help | grep …`) is not an automation invocation, so it
|
||||
// is excluded from the per-invocation flag assertions below.
|
||||
const codexExecLines = workflow
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes('codex exec') && !line.includes('codex exec --help'));
|
||||
|
||||
test('review.md contains at least one codex exec invocation', () => {
|
||||
@@ -43,7 +43,7 @@ describe('enh-773: automated codex exec invocations include --ephemeral and --da
|
||||
// be probed (`codex exec --help | grep`) and applied via $CODEX_BYPASS_FLAG so
|
||||
// older installs do not fail with "unexpected argument" (a silent empty review).
|
||||
assert.ok(
|
||||
/codex exec --help[^\n]*grep[^\n]*--dangerously-bypass-hook-trust/.test(workflow),
|
||||
/codex exec --help[^\r\n]*grep[^\r\n]*--dangerously-bypass-hook-trust/.test(workflow),
|
||||
'review.md must capability-probe --dangerously-bypass-hook-trust via `codex exec --help | grep`'
|
||||
);
|
||||
assert.ok(
|
||||
|
||||
@@ -31,7 +31,7 @@ describe('execute-phase command: --wave flag', () => {
|
||||
|
||||
test('argument-hint includes --wave, --gaps-only, and --interactive', () => {
|
||||
const content = fs.readFileSync(COMMAND_PATH, 'utf-8');
|
||||
const hintLine = content.split('\n').find(l => l.includes('argument-hint'));
|
||||
const hintLine = content.split(/\r?\n/).find(l => l.includes('argument-hint'));
|
||||
assert.ok(hintLine, 'should have argument-hint line');
|
||||
assert.ok(hintLine.includes('--wave N'), 'argument-hint should include --wave N');
|
||||
assert.ok(hintLine.includes('--gaps-only'), 'argument-hint should keep --gaps-only');
|
||||
|
||||
@@ -40,7 +40,7 @@ const USER_GUIDE_MD = path.join(ROOT, 'docs', 'USER-GUIDE.md');
|
||||
*/
|
||||
function extractSection(filePath, headerSubstring) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inSection = false;
|
||||
let startDepth = 0;
|
||||
const collected = [];
|
||||
@@ -275,7 +275,7 @@ describe('#3025 markdownlint pre-flight: MD040 + MD056', () => {
|
||||
const section = extractSection(CONTEXT_BUDGET_MD, 'mcp');
|
||||
// Guard: same null-section concern as MD040 above (CR follow-up).
|
||||
assert.ok(section, 'MCP section not found in context-budget.md — cannot check MD056');
|
||||
const lines = section.split('\n');
|
||||
const lines = section.split(/\r?\n/);
|
||||
// Walk through and detect tables: header row followed by a separator
|
||||
// (--- pattern) followed by data rows. Count `|` per line.
|
||||
const issues = [];
|
||||
|
||||
@@ -90,10 +90,12 @@ function runGlobalInstall(runtime, tmpHome) {
|
||||
const prev = process.env[envVar];
|
||||
const prevCwd = process.cwd();
|
||||
const prevHome = process.env.HOME;
|
||||
const prevUserProfile = process.env.USERPROFILE;
|
||||
const prevSkipStale = process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
|
||||
process.env[envVar] = tmpHome;
|
||||
process.env.HOME = isolatedHome;
|
||||
process.env.USERPROFILE = isolatedHome;
|
||||
process.env.GSD_SKIP_STALE_SDK_CHECK = '1';
|
||||
process.chdir(REPO_ROOT);
|
||||
|
||||
@@ -105,6 +107,8 @@ function runGlobalInstall(runtime, tmpHome) {
|
||||
else process.env[envVar] = prev;
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
if (prevUserProfile === undefined) delete process.env.USERPROFILE;
|
||||
else process.env.USERPROFILE = prevUserProfile;
|
||||
if (prevSkipStale === undefined) delete process.env.GSD_SKIP_STALE_SDK_CHECK;
|
||||
else process.env.GSD_SKIP_STALE_SDK_CHECK = prevSkipStale;
|
||||
// Clean up the isolated HOME dir
|
||||
|
||||
@@ -32,7 +32,7 @@ const MANIFEST_REQUIRED_KEYS = new Set([
|
||||
*/
|
||||
function extractManifests(mdContent) {
|
||||
const manifests = [];
|
||||
const fenceRe = /```json\s*\n([\s\S]*?)```/g;
|
||||
const fenceRe = /```json\s*\r?\n([\s\S]*?)```/g;
|
||||
let match;
|
||||
while ((match = fenceRe.exec(mdContent)) !== null) {
|
||||
let parsed;
|
||||
|
||||
@@ -41,7 +41,7 @@ const PROJECTION_PATH = path.join(
|
||||
function codeOnly(file) {
|
||||
return fs.readFileSync(file, 'utf8')
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||||
.replace(/(^|[^:])\/\/[^\r\n]*/g, '$1');
|
||||
}
|
||||
|
||||
describe('execNpm: Windows npm spawn platform gate (PR #3102, relocated #498)', () => {
|
||||
|
||||
@@ -55,7 +55,7 @@ describe('phase-researcher: Architectural Responsibility Mapping', () => {
|
||||
|
||||
test('step is a pure reasoning step with no tool calls', () => {
|
||||
// Extract the ARM section content (between the ARM heading and the next ## Step heading)
|
||||
const armHeadingMatch = content.match(/## Step 1\.5[^\n]*Architectural Responsibility Map/);
|
||||
const armHeadingMatch = content.match(/## Step 1\.5[^\r\n]*Architectural Responsibility Map/);
|
||||
assert.ok(armHeadingMatch, 'Must have a Step 1.5 heading for Architectural Responsibility Mapping');
|
||||
|
||||
const armStart = content.indexOf(armHeadingMatch[0]);
|
||||
|
||||
@@ -30,7 +30,7 @@ describe('command files: gsd-tools path references (#1766)', () => {
|
||||
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (/\bgsd-sdk\s+query\b|\$GSD_SDK\s+query/.test(lines[i])) {
|
||||
violations.push(`${rel(file)}:${i + 1}: ${lines[i].trim()}`);
|
||||
|
||||
@@ -59,7 +59,7 @@ function scanFiles(files, pattern, _description) {
|
||||
const failures = [];
|
||||
for (const file of files) {
|
||||
const content = fs.readFileSync(file, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
const trimmed = line.trimStart();
|
||||
@@ -105,7 +105,7 @@ describe('no hardcoded /home/ absolute paths', () => {
|
||||
test('no /home/<username>/ paths in string literals', () => {
|
||||
// Requires: quote + /home/ + non-slash chars (the username) + /
|
||||
// This avoids matching things like regex patterns /^home/
|
||||
const homePath = /['"`]\/home\/[^/\s'"` \n]+\//;
|
||||
const homePath = /['"`]\/home\/[^/\s'"` \r\n]+\//;
|
||||
const failures = scanFiles(sourceFiles, homePath);
|
||||
assert.deepStrictEqual(
|
||||
failures, [],
|
||||
@@ -122,7 +122,7 @@ describe('no hardcoded /home/ absolute paths', () => {
|
||||
describe('no hardcoded /Users/ absolute paths', () => {
|
||||
test('no /Users/<username>/ paths in string literals', () => {
|
||||
// Requires: quote + /Users/ + username chars + /
|
||||
const usersPath = /['"`]\/Users\/[^/\s'"` \n]+\//;
|
||||
const usersPath = /['"`]\/Users\/[^/\s'"` \r\n]+\//;
|
||||
const failures = scanFiles(sourceFiles, usersPath);
|
||||
assert.deepStrictEqual(
|
||||
failures, [],
|
||||
|
||||
@@ -176,7 +176,7 @@ describe('Hermes Agent: installRuntimeArtifacts', () => {
|
||||
assert.ok(fm.description && fm.description.length > 0, 'description present and non-empty');
|
||||
assert.strictEqual(fm.version, pkg.version,
|
||||
`Hermes SKILL.md must declare version (got ${JSON.stringify(fm.version)})`);
|
||||
assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(content),
|
||||
assert.ok(/^allowed-tools:\s*\r?\n(?:\s+-\s+\S+\r?\n?)+/m.test(content),
|
||||
'allowed-tools rendered as YAML block list');
|
||||
assert.ok(content.includes('<objective>'), 'body content preserved');
|
||||
});
|
||||
@@ -316,7 +316,7 @@ describe('Hermes Agent: SKILL.md format validation', () => {
|
||||
assert.strictEqual(fm.version, pkg.version, 'version matches package.json');
|
||||
assert.strictEqual(fm.agent, 'gsd-code-reviewer', 'agent preserved');
|
||||
assert.strictEqual(fm['argument-hint'], '[PR number or branch]', 'argument-hint preserved and unquoted');
|
||||
assert.ok(/^allowed-tools:\s*\n(?:\s+-\s+\S+\n?)+/m.test(result),
|
||||
assert.ok(/^allowed-tools:\s*\r?\n(?:\s+-\s+\S+\r?\n?)+/m.test(result),
|
||||
'allowed-tools rendered as YAML block list');
|
||||
});
|
||||
|
||||
|
||||
@@ -1406,7 +1406,7 @@ describe('cmdInitMapCodebase', () => {
|
||||
path.join(__dirname, '..', 'gsd-core', 'workflows', 'map-codebase.md'), 'utf8'
|
||||
);
|
||||
// OpenCode must NOT appear in the "WITHOUT Task tool" / "NOT available" condition
|
||||
const withoutLine = workflow.split('\n').find(l =>
|
||||
const withoutLine = workflow.split(/\r?\n/).find(l =>
|
||||
l.includes('NOT available') || l.includes('WITHOUT Task tool')
|
||||
);
|
||||
assert.ok(withoutLine, 'workflow should have a line about Task tool NOT being available');
|
||||
|
||||
@@ -1134,7 +1134,7 @@ describe('#1000 regression: gsd-intel-updater emits canonical intel filenames',
|
||||
// Guard against substring false-positives (e.g. 'files.json' inside 'file-roles.json'):
|
||||
// canonical long names never contain these short tokens, verified by the canonical set.
|
||||
const offendingLines = agentPrompt
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => line.includes(shortName));
|
||||
assert.strictEqual(
|
||||
offendingLines.length,
|
||||
|
||||
@@ -21,7 +21,7 @@ const INVENTORY_PATH = path.join(ROOT, 'docs', 'INVENTORY.md');
|
||||
test('docs/INVENTORY.md has no "(N shipped)" count scalars in headings', () => {
|
||||
const content = fs.readFileSync(INVENTORY_PATH, 'utf8');
|
||||
const offenders = content
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((line) => /^##\s+.+\(\d+\s+shipped\)/.test(line));
|
||||
|
||||
assert.ok(
|
||||
|
||||
@@ -57,17 +57,21 @@ function writeConfig(tmpDir, obj) {
|
||||
// behavior. Capture HOME, point it at an isolated tmpdir for the duration of
|
||||
// each test, restore on teardown.
|
||||
let _origHome;
|
||||
let _origUserProfile;
|
||||
let _origGsdHome;
|
||||
let _isolatedHome;
|
||||
function isolateHome() {
|
||||
_origHome = process.env.HOME;
|
||||
_origUserProfile = process.env.USERPROFILE;
|
||||
_origGsdHome = process.env.GSD_HOME;
|
||||
_isolatedHome = fs.mkdtempSync(path.join(os.tmpdir(), 'gsd-home-iso-'));
|
||||
process.env.HOME = _isolatedHome;
|
||||
process.env.USERPROFILE = _isolatedHome;
|
||||
process.env.GSD_HOME = _isolatedHome;
|
||||
}
|
||||
function restoreHome() {
|
||||
if (_origHome === undefined) delete process.env.HOME; else process.env.HOME = _origHome;
|
||||
if (_origUserProfile === undefined) delete process.env.USERPROFILE; else process.env.USERPROFILE = _origUserProfile;
|
||||
if (_origGsdHome === undefined) delete process.env.GSD_HOME; else process.env.GSD_HOME = _origGsdHome;
|
||||
cleanup(_isolatedHome);
|
||||
_isolatedHome = null;
|
||||
|
||||
@@ -191,7 +191,7 @@ describe('milestone-summary artifact path resolution', () => {
|
||||
test('current milestone paths point to .planning/ root', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Current milestone should read from .planning/ root
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const currentSection = lines.slice(
|
||||
lines.findIndex(l => l.includes('Current/in-progress')),
|
||||
lines.findIndex(l => l.includes('Current/in-progress')) + 10
|
||||
|
||||
201
tests/no-bare-npm-exec.rule.test.cjs
Normal file
201
tests/no-bare-npm-exec.rule.test.cjs
Normal file
@@ -0,0 +1,201 @@
|
||||
'use strict';
|
||||
|
||||
// This file is an eslint-rule RuleTester fixture. It contains npm exec
|
||||
// command strings as TEST DATA (fixtures the rule must lint) — not real
|
||||
// invocations. See ALLOWLIST in scripts/prompt-injection-scan.sh.
|
||||
|
||||
/**
|
||||
* no-bare-npm-exec.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-bare-npm-exec ESLint rule.
|
||||
*
|
||||
* Rule (G5): flag execFileSync/spawnSync/spawn('npm', ...) without
|
||||
* { shell: true } — Windows needs npm.cmd via a shell.
|
||||
*
|
||||
* execSync('npm ...') is explicitly NOT flagged: execSync always runs through
|
||||
* a shell (cmd.exe on Windows resolves npm.cmd automatically), so it is safe
|
||||
* without shell: true.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-bare-npm-exec.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-bare-npm-exec 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.bareNpmExec, 'bareNpmExec message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-bare-npm-exec: invalid cases', () => {
|
||||
test('invalid: execFileSync("npm", ["install"]) with no options', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `execFileSync('npm', ['install']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'bareNpmExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: execFileSync("npm", ["ci"], { cwd }) without shell', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `execFileSync('npm', ['ci'], { cwd: '/some/dir' });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'bareNpmExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: spawnSync("npm", ["run", "build"]) with no options', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawnSync('npm', ['run', 'build']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'bareNpmExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: spawn("npm", ["install"]) with no options', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `spawn('npm', ['install']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'bareNpmExec' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
});
|
||||
|
||||
// ─── VALID cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-bare-npm-exec: valid cases', () => {
|
||||
test('valid: execFileSync("npm", ["install"], { shell: true })', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('npm', ['install'], { shell: true });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: execFileSync("npm", ["ci"], { shell: true, cwd: dir })', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('npm', ['ci'], { shell: true, cwd: dir });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: execFileSync("npm", ...) with shell: isWindows', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('npm', ['install'], { shell: isWindows });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: execFileSync("npm", ...) with shell: process.platform === "win32"', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('npm', ['install'], { shell: process.platform === 'win32' });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: spawnSync("npm", ["run", "test"], { shell: true })', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `spawnSync('npm', ['run', 'test'], { shell: true });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: execFileSync("node", ["script.js"]) — not npm, no flag needed', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('node', ['script.js']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: execFileSync("npx", ["mocha"]) — not npm, different command', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `execFileSync('npx', ['mocha']);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: execSync("npm install") — execSync uses a shell by default, safe without shell:true', () => {
|
||||
ruleTester.run('no-bare-npm-exec', rule, {
|
||||
valid: [
|
||||
{
|
||||
// execSync always invokes a shell (cmd.exe on Windows resolves npm.cmd),
|
||||
// so it does NOT need shell: true. Rule only flags execFileSync/spawnSync/spawn.
|
||||
code: `execSync('npm install');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
338
tests/no-crlf-fragile-split.rule.test.cjs
Normal file
338
tests/no-crlf-fragile-split.rule.test.cjs
Normal file
@@ -0,0 +1,338 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-crlf-fragile-split.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-crlf-fragile-split ESLint rule.
|
||||
*
|
||||
* Rule covers three sub-patterns:
|
||||
* G1 — .split('\n') on readFileSync-derived content (crlfFragileSplit)
|
||||
* G2 — RegExp with bare \n on readFileSync-derived content (crlfFragileRegex)
|
||||
* G3 — RegExp with bare \n containing markdown fence or frontmatter anchor
|
||||
* (crlfFragileRegex) — caught even without direct data-flow
|
||||
*
|
||||
* NOTE: Fixture code strings must encode actual \n characters as \\n inside
|
||||
* the JavaScript string literals used for RuleTester `code` fields, so that
|
||||
* the ESLint parser receives the intended source text.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-crlf-fragile-split.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-crlf-fragile-split 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.crlfFragileSplit, 'crlfFragileSplit message must exist');
|
||||
assert.ok(rule.meta.messages.crlfFragileRegex, 'crlfFragileRegex message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── G1: INVALID cases ────────────────────────────────────────────────────────
|
||||
|
||||
describe('G1 — no-crlf-fragile-split: invalid (crlfFragileSplit)', () => {
|
||||
test('G1-invalid: readFileSync(p).split("\\n") — direct chain', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// code that ESLint will parse: fs.readFileSync(p, 'utf8').split('\n')
|
||||
code: "const lines = fs.readFileSync(p, 'utf8').split('\\n');",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileSplit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G1-invalid: readFileSync(p).toString().split("\\n") — chained call', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: "const lines = readFileSync(p).toString().split('\\n');",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileSplit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G1-invalid: content = readFileSync(...); content.split("\\n") — via variable', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: [
|
||||
"const content = fs.readFileSync(filePath, 'utf8');",
|
||||
"const lines = content.split('\\n');",
|
||||
].join('\n'),
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileSplit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G1-invalid: double-quoted "\\n" in split', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: 'const lines = fs.readFileSync(\'file.txt\', \'utf8\').split("\\n");',
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileSplit' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── G1: VALID cases ──────────────────────────────────────────────────────────
|
||||
|
||||
describe('G1 — no-crlf-fragile-split: valid cases', () => {
|
||||
test('G1-valid: .split(/\\r?\\n/) — correct regex', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
// /\r?\n/ in source — no bare \n in a string argument
|
||||
code: "const lines = fs.readFileSync(p, 'utf8').split(/\\r?\\n/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('G1-valid: non-file content split — "\\n" on a plain string literal', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "const lines = someString.split('\\n');",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('G1-valid: non-file content split — "\\n" on variable not from readFileSync', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: [
|
||||
"const content = 'hello\\\\nworld';",
|
||||
"const lines = content.split('\\n');",
|
||||
].join('\n'),
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('G1-valid: .split("\\n") on a fetch/HTTP response (not readFileSync)', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "const lines = response.text.split('\\n');",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── G2/G3: INVALID cases ─────────────────────────────────────────────────────
|
||||
|
||||
describe('G2/G3 — no-crlf-fragile-split: invalid (crlfFragileRegex)', () => {
|
||||
test('G2-invalid: bare \\n in regex on readFileSync content via .match()', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// /foo\nbar/ — regex with bare \n; .match() on readFileSync result
|
||||
code: "const m = fs.readFileSync(p, 'utf8').match(/foo\\nbar/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G2-invalid: bare \\n in regex on readFileSync content via .test()', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// /hello\nworld/.test(readFileSync(...))
|
||||
code: "const ok = /hello\\nworld/.test(fs.readFileSync(p, 'utf8'));",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G2-invalid: bare \\n in regex on readFileSync content via .replace()', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: "const out = fs.readFileSync(p, 'utf8').replace(/foo\\nbar/, 'x');",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G3-invalid: markdown fence regex with bare \\n (```bash\\n)', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// content.match(/```bash\nsome/) — fence regex with bare \n
|
||||
code: "const m = content.match(/```bash\\nsome/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('G3-invalid: frontmatter anchor regex with bare \\n (/^---\\n/)', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// /^---\ntitle/.test(content) — frontmatter with bare \n
|
||||
code: "const hasFM = /^---\\ntitle/.test(content);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── G2/G3: VALID cases ───────────────────────────────────────────────────────
|
||||
|
||||
describe('G2/G3 — no-crlf-fragile-split: valid cases', () => {
|
||||
test('G2-valid: regex with \\r?\\n (already CRLF-safe) on readFileSync content', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
// /foo\r?\nbar/ — has \r?\n so it's safe
|
||||
code: "const m = fs.readFileSync(p, 'utf8').match(/foo\\r?\\nbar/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('G2-valid: regex with bare \\n but used on a non-file string (ok per known boundaries)', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
// /hello\nworld/.test(someRuntimeString) — not file content
|
||||
code: "const ok = /hello\\nworld/.test(someRuntimeString);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('G3-valid: markdown fence regex but with \\r?\\n (already CRLF-safe)', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "const m = content.match(/```bash\\r?\\nsome/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('G3-valid: frontmatter regex with \\r\\n (explicitly CRLF-safe)', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "const hasFM = /^---\\r\\ntitle/.test(content);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
// C3 per-occurrence classification cases
|
||||
test('C3-valid: /\\r?\\n/ — single safe occurrence, not flagged', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "const m = fs.readFileSync(p, 'utf8').match(/\\r?\\n/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('C3-invalid: regex with both safe \\r?\\n AND a separate bare \\n — flagged', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// /\r?\nfoo|\nbar/ — the second \n (after |) is bare and fragile
|
||||
code: "const m = fs.readFileSync(p, 'utf8').match(/\\r?\\nfoo|\\nbar/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C3-invalid: [^\\n] — \\n in class without \\r is fragile', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// /[^\n]+/ — class has \n but no \r
|
||||
code: "const m = fs.readFileSync(p, 'utf8').match(/[^\\n]+/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'crlfFragileRegex' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('C3-valid: [^\\r\\n] — \\n in class with \\r is safe', () => {
|
||||
ruleTester.run('no-crlf-fragile-split', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: "const m = fs.readFileSync(p, 'utf8').match(/[^\\r\\n]+/);",
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
184
tests/no-hardcoded-tmp.rule.test.cjs
Normal file
184
tests/no-hardcoded-tmp.rule.test.cjs
Normal file
@@ -0,0 +1,184 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* no-hardcoded-tmp.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/no-hardcoded-tmp ESLint rule.
|
||||
*
|
||||
* Rule (G4): flag a string Literal starting with `/tmp/` (or exactly `/tmp`)
|
||||
* passed to an `fs.<method>(...)` call or `path.join('/tmp/...', …)`.
|
||||
* Message: use `os.tmpdir()`.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/no-hardcoded-tmp.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-hardcoded-tmp 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.hardcodedTmp, 'hardcodedTmp message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-hardcoded-tmp: invalid cases', () => {
|
||||
test('invalid: fs.writeFileSync("/tmp/x", data)', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `fs.writeFileSync('/tmp/x', data);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'hardcodedTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: fs.readFileSync("/tmp/file.txt", "utf8")', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const c = fs.readFileSync('/tmp/file.txt', 'utf8');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'hardcodedTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: fs.mkdirSync("/tmp/mydir", { recursive: true })', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `fs.mkdirSync('/tmp/mydir', { recursive: true });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'hardcodedTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: path.join("/tmp/dir", "sub")', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `const p = path.join('/tmp/dir', 'sub');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'hardcodedTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: fs.existsSync("/tmp")', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `fs.existsSync('/tmp');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'hardcodedTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: fs.rmSync("/tmp/x", { recursive: true })', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `fs.rmSync('/tmp/x', { recursive: true });`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'hardcodedTmp' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('no-hardcoded-tmp: valid cases', () => {
|
||||
test('valid: os.tmpdir() — portable temp directory', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
const tmpDir = os.tmpdir();
|
||||
fs.writeFileSync(path.join(tmpDir, 'x'), data);
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: fs.writeFileSync with a variable (not hardcoded /tmp/)', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `fs.writeFileSync(tmpFile, data);`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: path.join with non-tmp first arg', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = path.join(__dirname, 'fixtures', 'test.txt');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: /tmp/ string not passed to fs or path.join (assignment)', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const note = 'uses /tmp/ on POSIX';`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: /tmp/ as a non-first arg to path.join', () => {
|
||||
ruleTester.run('no-hardcoded-tmp', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const p = path.join(os.tmpdir(), '/tmp/subdir');`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -150,7 +150,7 @@ describe('createDefaultLogger — stderr on error', () => {
|
||||
const stderrOutput = captureStderr(() => logger.onEvent(errEvent));
|
||||
|
||||
// Must be exactly one non-empty line
|
||||
const lines = stderrOutput.split('\n').filter(l => l.trim().length > 0);
|
||||
const lines = stderrOutput.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
assert.equal(lines.length, 1, `expected 1 line, got ${lines.length}: ${stderrOutput}`);
|
||||
});
|
||||
|
||||
@@ -259,7 +259,7 @@ describe('createDefaultLogger — audit file', () => {
|
||||
|
||||
const auditPath = path.join(tmpDir, '.planning', '.gsd-trace.jsonl');
|
||||
const content = fs.readFileSync(auditPath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
||||
const lines = content.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
assert.equal(lines.length, 2, `expected 2 lines, got ${lines.length}`);
|
||||
|
||||
const parsed0 = JSON.parse(lines[0]);
|
||||
@@ -279,7 +279,7 @@ describe('createDefaultLogger — audit file', () => {
|
||||
logger2.onEvent(makeOkEvent({ traceId: 'second' }));
|
||||
|
||||
const content = fs.readFileSync(auditPath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
||||
const lines = content.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
assert.equal(lines.length, 2, 'both events must appear (append-only)');
|
||||
assert.equal(JSON.parse(lines[0]).traceId, 'first');
|
||||
assert.equal(JSON.parse(lines[1]).traceId, 'second');
|
||||
@@ -293,7 +293,7 @@ describe('createDefaultLogger — audit file', () => {
|
||||
|
||||
const auditPath = path.join(tmpDir, '.planning', '.gsd-trace.jsonl');
|
||||
const content = fs.readFileSync(auditPath, 'utf8');
|
||||
const lines = content.split('\n').filter(l => l.trim().length > 0);
|
||||
const lines = content.split(/\r?\n/).filter(l => l.trim().length > 0);
|
||||
assert.equal(lines.length, 2);
|
||||
|
||||
const traceIds = lines.map(l => JSON.parse(l).traceId);
|
||||
|
||||
@@ -18,7 +18,7 @@ const PLANNER = path.join(AGENTS, 'gsd-planner.md');
|
||||
const EXECUTOR = path.join(AGENTS, 'gsd-executor.md');
|
||||
|
||||
function parseSections(md) {
|
||||
const lines = md.split('\n');
|
||||
const lines = md.split(/\r?\n/);
|
||||
const sections = [];
|
||||
let current = { heading: '__preamble__', body: [] };
|
||||
let inFence = false;
|
||||
@@ -39,7 +39,7 @@ function parseSections(md) {
|
||||
|
||||
function extractCodeBlocks(text) {
|
||||
const blocks = [];
|
||||
const lines = text.split('\n');
|
||||
const lines = text.split(/\r?\n/);
|
||||
let inside = false;
|
||||
let buf = [];
|
||||
|
||||
@@ -59,7 +59,7 @@ function extractCodeBlocks(text) {
|
||||
}
|
||||
|
||||
function extractResearchTemplate(content) {
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
let inside = false;
|
||||
let isMarkdownFence = false;
|
||||
let buf = [];
|
||||
@@ -182,7 +182,7 @@ function readModel(filePath) {
|
||||
const text = fs.readFileSync(filePath, 'utf-8');
|
||||
return {
|
||||
text,
|
||||
lines: text.split('\n'),
|
||||
lines: text.split(/\r?\n/),
|
||||
sections: parseSections(text),
|
||||
codeBlocks: extractCodeBlocks(text),
|
||||
};
|
||||
@@ -388,7 +388,7 @@ describe('gsd-planner.md — supply-chain row in threat_model template', () => {
|
||||
});
|
||||
|
||||
test('threat_model template includes supply-chain row with mitigate disposition', () => {
|
||||
const tables = parseMarkdownTables(threatModelBlock.split('\n'));
|
||||
const tables = parseMarkdownTables(threatModelBlock.split(/\r?\n/));
|
||||
const strideTable = tables.find((table) => table.headers.includes('Threat ID'));
|
||||
assert.ok(strideTable, 'threat_model must include STRIDE threat register table');
|
||||
|
||||
|
||||
@@ -83,7 +83,7 @@ test('no hardcoded @opengsd/gsd-core literals in runtime non-comment code lines
|
||||
if (path.resolve(file) === path.resolve(IDENTITY_MODULE)) continue;
|
||||
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
@@ -2826,7 +2826,7 @@ describe('phase complete command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
||||
const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m);
|
||||
assert.ok(rowMatch, 'table row should exist');
|
||||
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
||||
assert.strictEqual(cells.length, 5, 'should have 5 columns');
|
||||
@@ -2897,7 +2897,7 @@ describe('phase complete command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
||||
const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m);
|
||||
assert.ok(rowMatch, 'table row should exist');
|
||||
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
||||
assert.strictEqual(cells.length, 4, 'should have 4 columns');
|
||||
@@ -2937,7 +2937,7 @@ describe('phase complete command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const rowMatch = roadmap.match(/^\|[^\n]*1\. Foundation[^\n]*$/m);
|
||||
const rowMatch = roadmap.match(/^\|[^\r\n]*1\. Foundation[^\r\n]*$/m);
|
||||
assert.ok(rowMatch, 'table row should exist');
|
||||
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
||||
assert.strictEqual(cells.length, 5, 'should have 5 columns');
|
||||
@@ -4223,12 +4223,12 @@ describe('bug-3287 — init plan-phase exposes expected_phase_dir with project_c
|
||||
}
|
||||
|
||||
function containsBareTemplateMkdir(content) {
|
||||
return /mkdir[^`\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content);
|
||||
return /mkdir[^`\r\n]*\.planning\/phases\/\{[A-Z0-9]+\}-\{/.test(content);
|
||||
}
|
||||
|
||||
function containsBareShellVarMkdir(content) {
|
||||
return /mkdir[^`\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content)
|
||||
|| /mkdir[^`\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content);
|
||||
return /mkdir[^`\r\n]*\.planning\/phases\/"\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content)
|
||||
|| /mkdir[^`\r\n]*\.planning\/phases\/\$\{(?:NEXT|NN|PHASE)[^}]*\}-/.test(content);
|
||||
}
|
||||
|
||||
describe('bug-3298 — plan-milestone-gaps.md must not construct bare {NN}-{name} phase dirs', () => {
|
||||
|
||||
@@ -244,7 +244,7 @@ describe('ADR-857 phase 6 — capabilities must not bake install paths into the
|
||||
|
||||
test('generated capability-registry.cjs contains no ~/.claude install path', () => {
|
||||
const reg = fs.readFileSync(path.join(__dirname, '..', 'gsd-core', 'bin', 'lib', 'capability-registry.cjs'), 'utf8');
|
||||
const leakLines = reg.split('\n').map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n);
|
||||
const leakLines = reg.split(/\r?\n/).map((l, i) => [i + 1, l]).filter(([, l]) => LEAK.test(l)).map(([n]) => n);
|
||||
assert.deepEqual(leakLines, [],
|
||||
`capability-registry.cjs leaks ~/.claude install paths at line(s) ${leakLines.join(', ')} — the registry is copied verbatim to non-Claude runtimes (only workflow .md files are path-converted at install). Make the source capability fragment path-free.`);
|
||||
});
|
||||
|
||||
@@ -177,7 +177,7 @@ describe('plan-review-convergence workflow: config gate (#2306-v2)', () => {
|
||||
|
||||
test('workflow defaults config key to false (opt-in, not opt-out)', () => {
|
||||
// The config-get call must default to false, not true
|
||||
const configGetMatch = workflow.match(/config-get\s+workflow\.plan_review_convergence[^\n]*/);
|
||||
const configGetMatch = workflow.match(/config-get\s+workflow\.plan_review_convergence[^\r\n]*/);
|
||||
assert.ok(
|
||||
configGetMatch,
|
||||
'workflow must read workflow.plan_review_convergence via config-get'
|
||||
@@ -546,7 +546,7 @@ describe('plan-review-convergence CONFIGURATION.md documentation (#2306-v2)', ()
|
||||
});
|
||||
|
||||
test('CONFIGURATION.md entry documents disabled-by-default behavior', () => {
|
||||
const row = configDoc.match(/workflow\.plan_review_convergence[^\n]*/);
|
||||
const row = configDoc.match(/workflow\.plan_review_convergence[^\r\n]*/);
|
||||
assert.ok(row, 'workflow.plan_review_convergence row must exist in CONFIGURATION.md');
|
||||
assert.ok(
|
||||
row[0].includes('false') || row[0].includes('disabled'),
|
||||
@@ -724,7 +724,7 @@ describe('plan-review-convergence workflow: source-grounding reviewer pass (#22)
|
||||
|
||||
// ── Severity mappings: AMBIGUOUS→MEDIUM and UNCHECKABLE→INFO must appear
|
||||
// on the SAME line inside the section, not just anywhere in the file ────
|
||||
const severityLine = section.split('\n').find((line) =>
|
||||
const severityLine = section.split(/\r?\n/).find((line) =>
|
||||
line.includes('AMBIGUOUS') && line.includes('MEDIUM') &&
|
||||
line.includes('UNCHECKABLE') && line.includes('INFO')
|
||||
);
|
||||
@@ -856,7 +856,7 @@ describe('plan-review-convergence workflow: inline plan-phase dispatch (#936)',
|
||||
/Skill\(\s*skill=['"]gsd-plan-phase['"]/.test(b.blockText)
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
wrapping.map((b) => b.blockText.slice(0, 80).replace(/\n/g, '\\n')),
|
||||
wrapping.map((b) => b.blockText.slice(0, 80).replace(/\r?\n/g, '\\n')),
|
||||
[],
|
||||
'Initial planning must NOT wrap gsd-plan-phase inside Agent() — run it inline so ' +
|
||||
'it can spawn gsd-planner/gsd-plan-checker at depth 1. See: bug #936'
|
||||
@@ -871,7 +871,7 @@ describe('plan-review-convergence workflow: inline plan-phase dispatch (#936)',
|
||||
/--reviews/.test(b.blockText)
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
wrapping.map((b) => b.blockText.slice(0, 80).replace(/\n/g, '\\n')),
|
||||
wrapping.map((b) => b.blockText.slice(0, 80).replace(/\r?\n/g, '\\n')),
|
||||
[],
|
||||
'Replan step must NOT wrap gsd-plan-phase inside Agent() — the replan loop can ' +
|
||||
'never produce a plan on Claude Code when plan-phase is at depth 1. See: bug #936'
|
||||
|
||||
@@ -35,7 +35,7 @@ function assertNyquistCapabilityGate(name) {
|
||||
|
||||
function findNyquistConfigLine(filePath) {
|
||||
const content = fs.readFileSync(filePath, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
if (lines[i].includes('config-get workflow.nyquist_validation')) {
|
||||
return { lineNumber: i + 1, line: lines[i] };
|
||||
|
||||
@@ -14,14 +14,14 @@ const WORKFLOWS_DIR = path.join(REPO_ROOT, '.github', 'workflows');
|
||||
|
||||
// Matches: npm install -g npm@..., npm i -g npm, npm install --global npm@11, etc.
|
||||
// Does NOT match: npm ci, npm install (no -g / --global followed by npm)
|
||||
const NPM_SELF_UPGRADE_RE = /\bnpm\s+(install|i)\s+(-g|--global)\b[^\n]*\bnpm(@|\b)/;
|
||||
const NPM_SELF_UPGRADE_RE = /\bnpm\s+(install|i)\s+(-g|--global)\b[^\r\n]*\bnpm(@|\b)/;
|
||||
|
||||
describe('policy: no runtime npm self-upgrade in release lanes (#318)', () => {
|
||||
const releaseFile = path.join(WORKFLOWS_DIR, 'release.yml');
|
||||
|
||||
test('release.yml must not contain a runtime global npm self-upgrade step', () => {
|
||||
const content = fs.readFileSync(releaseFile, 'utf8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const violations = lines
|
||||
.map((line, idx) => ({ line, lineNo: idx + 1 }))
|
||||
.filter(({ line }) => NPM_SELF_UPGRADE_RE.test(line));
|
||||
|
||||
@@ -33,6 +33,11 @@ const PROTECTED_RULES = [
|
||||
'no-path-literal-in-assert',
|
||||
'no-posix-mode-bit-assert',
|
||||
'no-unguarded-nonportable-exec',
|
||||
// ADR-1703 Phase 4 rules (issue #1726)
|
||||
'no-crlf-fragile-split',
|
||||
'no-hardcoded-tmp',
|
||||
'no-bare-npm-exec',
|
||||
'require-userprofile-with-home',
|
||||
];
|
||||
|
||||
// ── Detect disable directives via the comment text ───────────────────────────
|
||||
|
||||
@@ -84,7 +84,7 @@ describe('product name purity (#1777)', () => {
|
||||
|
||||
for (const file of README_FILES) {
|
||||
const content = fs.readFileSync(path.join(ROOT, file), 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
const line = lines[i];
|
||||
|
||||
@@ -24,7 +24,7 @@ const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'prohi
|
||||
// Extract fenced blocks tagged ```json prohibition-probe:<dir>/<file> from the doc, keyed by ref.
|
||||
// The \n? before the closing fence allows blocks whose closing fence has no preceding newline.
|
||||
function taggedJsonBlocks(md) {
|
||||
const re = /```json prohibition-probe:([^\n]+)\n([\s\S]*?)\n?```/g;
|
||||
const re = /```json prohibition-probe:([^\r\n]+)\r?\n([\s\S]*?)\r?\n?```/g;
|
||||
const out = {};
|
||||
let m;
|
||||
while ((m = re.exec(md))) out[m[1].trim()] = m[2];
|
||||
|
||||
@@ -286,7 +286,7 @@ describe('codebase prompt injection scan', () => {
|
||||
const content = fs.readFileSync(file, 'utf-8');
|
||||
if (invisiblePattern.test(content)) {
|
||||
// Find the line numbers with invisible chars
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const badLines = [];
|
||||
lines.forEach((line, i) => {
|
||||
if (invisiblePattern.test(line)) {
|
||||
|
||||
@@ -97,7 +97,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => {
|
||||
});
|
||||
|
||||
test('preserves body content unchanged', () => {
|
||||
const body = '\n<objective>\nDo the thing.\n</objective>\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
|
||||
const body = '\n<objective>\nDo the thing.\n</objective>\r?\n\n<process>\nStep 1.\nStep 2.\n</process>\n';
|
||||
const input = [
|
||||
'---',
|
||||
'name: gsd:test',
|
||||
@@ -299,10 +299,10 @@ describe('Qwen Code: SKILL.md format validation', () => {
|
||||
const result = convertClaudeCommandToClaudeSkill(input, 'gsd-review');
|
||||
|
||||
// Parse the frontmatter
|
||||
const fmMatch = result.match(/^---\n([\s\S]*?)\n---/);
|
||||
const fmMatch = result.match(/^---\r?\n([\s\S]*?)\r?\n---/);
|
||||
assert.ok(fmMatch, 'has frontmatter block');
|
||||
|
||||
const fmLines = fmMatch[1].split('\n');
|
||||
const fmLines = fmMatch[1].split(/\r?\n/);
|
||||
const hasName = fmLines.some(l => l.startsWith('name: gsd-review'));
|
||||
const hasDesc = fmLines.some(l => l.startsWith('description:'));
|
||||
const hasAgent = fmLines.some(l => l.startsWith('agent:'));
|
||||
|
||||
@@ -269,7 +269,7 @@ function parseFrontmatterField(content, field) {
|
||||
* against the Hunk Verification Table without raw substring matching.
|
||||
*/
|
||||
function parsePipeTable(content, expectedHeaderTokens) {
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let i = 0; i < lines.length - 1; i++) {
|
||||
const headerLine = lines[i].trim();
|
||||
const sepLine = (lines[i + 1] || '').trim();
|
||||
@@ -332,7 +332,7 @@ describe('reapply-patches workflow contract (#1469)', () => {
|
||||
].map((m) => m[1]);
|
||||
assert.ok(blocks.length > 0, 'update.md must define at least one <execution_context> block');
|
||||
const includes = blocks
|
||||
.flatMap((blk) => blk.split('\n'))
|
||||
.flatMap((blk) => blk.split(/\r?\n/))
|
||||
.map((l) => l.trim())
|
||||
.filter((l) => l.startsWith('@'))
|
||||
.map((l) => l.replace(/^@/, ''));
|
||||
@@ -381,7 +381,7 @@ describe('reapply-patches gated hunk verification (#1999)', () => {
|
||||
// assert it both names the table and defines an explicit gate
|
||||
// condition tied to the `verified` column.
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|Z)/m);
|
||||
const step5Match = content.match(/^##\s+Step 5[^\r\n]*\r?\n([\s\S]*?)(?=^##\s|Z)/m);
|
||||
assert.ok(step5Match, 'reapply-patches workflow must contain a "## Step 5" section');
|
||||
const step5 = step5Match[1];
|
||||
assert.ok(
|
||||
@@ -405,7 +405,7 @@ describe('reapply-patches gated hunk verification (#1999)', () => {
|
||||
test('Step 5 also halts when the Hunk Verification Table is absent (Step 4 produced nothing)', () => {
|
||||
// Independent gate: missing-table is a separate halt path from any-no-row.
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
const step5Match = content.match(/^##\s+Step 5[^\n]*\n([\s\S]*?)(?=^##\s|Z)/m);
|
||||
const step5Match = content.match(/^##\s+Step 5[^\r\n]*\r?\n([\s\S]*?)(?=^##\s|Z)/m);
|
||||
assert.ok(step5Match, 'Step 5 section must exist');
|
||||
const step5 = step5Match[1];
|
||||
const handlesAbsent = /(table is absent|table is missing|missing.*table|absent.*table)/i.test(step5);
|
||||
|
||||
@@ -13,7 +13,7 @@ const RELEASE_WORKFLOW = path.join(__dirname, '..', '.github', 'workflows', 'rel
|
||||
|
||||
describe('release-coverage-scope', () => {
|
||||
test('release.yml uses test:coverage:unit (not full suite) in both rc and finalize gates', () => {
|
||||
const lines = fs.readFileSync(RELEASE_WORKFLOW, 'utf8').split('\n').map(l => l.trim());
|
||||
const lines = fs.readFileSync(RELEASE_WORKFLOW, 'utf8').split(/\r?\n/).map(l => l.trim());
|
||||
const bareCount = lines.filter(l => l === 'npm run test:coverage').length;
|
||||
const unitCount = lines.filter(l => l === 'npm run test:coverage:unit').length;
|
||||
assert.strictEqual(bareCount, 0,
|
||||
|
||||
197
tests/require-userprofile-with-home.rule.test.cjs
Normal file
197
tests/require-userprofile-with-home.rule.test.cjs
Normal file
@@ -0,0 +1,197 @@
|
||||
'use strict';
|
||||
|
||||
/**
|
||||
* require-userprofile-with-home.rule.test.cjs
|
||||
*
|
||||
* RuleTester unit tests for the local/require-userprofile-with-home ESLint rule.
|
||||
*
|
||||
* Rule (G6): at Program:exit, if the file assigns process.env.HOME and
|
||||
* never references USERPROFILE, report each HOME assignment.
|
||||
*/
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const { RuleTester } = require('eslint');
|
||||
|
||||
const rule = require('../eslint-rules/require-userprofile-with-home.cjs');
|
||||
|
||||
const ruleTester = new RuleTester({
|
||||
languageOptions: {
|
||||
ecmaVersion: 2022,
|
||||
sourceType: 'commonjs',
|
||||
},
|
||||
});
|
||||
|
||||
// ─── module shape ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('require-userprofile-with-home 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.missingUserProfile, 'missingUserProfile message must exist');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── INVALID cases ────────────────────────────────────────────────────────────
|
||||
|
||||
describe('require-userprofile-with-home: invalid cases', () => {
|
||||
test('invalid: process.env.HOME = "/home/user" with no USERPROFILE reference', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `process.env.HOME = '/home/user';`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingUserProfile' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: process.env["HOME"] = dir with no USERPROFILE reference', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `process.env['HOME'] = tmpDir;`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingUserProfile' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: beforeEach sets HOME with no USERPROFILE anywhere', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
beforeEach(() => {
|
||||
process.env.HOME = '/tmp/test-home';
|
||||
});
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingUserProfile' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: multiple HOME assignments — all reported when USERPROFILE absent', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
code: `
|
||||
process.env.HOME = orig;
|
||||
process.env.HOME = tmpDir;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [
|
||||
{ messageId: 'missingUserProfile' },
|
||||
{ messageId: 'missingUserProfile' },
|
||||
],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// ─── VALID cases ──────────────────────────────────────────────────────────────
|
||||
|
||||
describe('require-userprofile-with-home: valid cases', () => {
|
||||
test('valid: process.env.HOME assigned AND process.env.USERPROFILE assigned', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
process.env.HOME = tmpDir;
|
||||
process.env.USERPROFILE = tmpDir;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: process.env.HOME assigned AND USERPROFILE only read (not assigned) — read is insufficient', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// Reading process.env.USERPROFILE is not enough — the rule requires
|
||||
// an actual assignment so Windows test environments are set up correctly.
|
||||
code: `
|
||||
process.env.HOME = tmpDir;
|
||||
const up = process.env.USERPROFILE;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingUserProfile' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: process.env.HOME assigned AND process.env["USERPROFILE"] assigned', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
process.env.HOME = tmpDir;
|
||||
process.env['USERPROFILE'] = tmpDir;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: no HOME assignment at all', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `const home = process.env.HOME;`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
|
||||
test('invalid: USERPROFILE only in a comment does NOT satisfy the rule (comment is not an assignment)', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [],
|
||||
invalid: [
|
||||
{
|
||||
// A comment mentioning USERPROFILE is insufficient — the rule requires
|
||||
// an actual process.env.USERPROFILE = … assignment.
|
||||
code: `
|
||||
// also set USERPROFILE on Windows
|
||||
process.env.HOME = tmpDir;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
errors: [{ messageId: 'missingUserProfile' }],
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
test('valid: process.env.HOME assigned AND process.env.USERPROFILE actually assigned', () => {
|
||||
ruleTester.run('require-userprofile-with-home', rule, {
|
||||
valid: [
|
||||
{
|
||||
code: `
|
||||
process.env.HOME = tmpDir;
|
||||
process.env.USERPROFILE = tmpDir;
|
||||
`,
|
||||
filename: 'tests/foo.test.cjs',
|
||||
},
|
||||
],
|
||||
invalid: [],
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -915,7 +915,7 @@ describe('roadmap update-plan-progress command', () => {
|
||||
assert.ok(result.success, `Command failed: ${result.error}`);
|
||||
|
||||
const roadmap = fs.readFileSync(path.join(tmpDir, '.planning', 'ROADMAP.md'), 'utf-8');
|
||||
const rowMatch = roadmap.match(/^\|[^\n]*50\. Build[^\n]*$/m);
|
||||
const rowMatch = roadmap.match(/^\|[^\r\n]*50\. Build[^\r\n]*$/m);
|
||||
assert.ok(rowMatch, 'table row should exist');
|
||||
const cells = rowMatch[0].split('|').slice(1, -1).map(c => c.trim());
|
||||
assert.strictEqual(cells.length, 5, 'should have 5 columns');
|
||||
@@ -1252,7 +1252,7 @@ describe('regressions: insert missing plan rows (#1163)', () => {
|
||||
// ── Adversarial: CRLF in ROADMAP.md ──────────────────────────────────────
|
||||
|
||||
test('CRLF line endings in ROADMAP.md are handled without corruption', () => {
|
||||
const content = buildRoadmapBoldPlans('5').replace(/\n/g, '\r\n');
|
||||
const content = buildRoadmapBoldPlans('5').replace(/\r?\n/g, '\r\n');
|
||||
fs.writeFileSync(roadmapPath, content);
|
||||
createPhaseWithPlans(tmpDir, '5', ['5-01-PLAN.md', '5-02-PLAN.md']);
|
||||
|
||||
|
||||
@@ -40,7 +40,7 @@ const SNIPPET_FILE = path.join(WORKFLOWS_DIR, '_runtime-launcher.snippet.sh');
|
||||
*/
|
||||
function expectedPreamble() {
|
||||
const raw = fs.readFileSync(SNIPPET_FILE, 'utf8');
|
||||
const lines = raw.split('\n');
|
||||
const lines = raw.split(/\r?\n/);
|
||||
// Strip trailing empty element produced by a trailing newline.
|
||||
const content = lines[lines.length - 1] === '' ? lines.slice(0, -1) : lines;
|
||||
assert.ok(content.length >= 1, `_runtime-launcher.snippet.sh must not be empty`);
|
||||
@@ -55,7 +55,7 @@ function expectedPreamble() {
|
||||
* Handles both column-0 fences (```bash) and indented fences ( ```bash).
|
||||
*/
|
||||
function extractShellBlocks(content) {
|
||||
const allLines = content.split('\n');
|
||||
const allLines = content.split(/\r?\n/);
|
||||
const blocks = [];
|
||||
let inBlock = false;
|
||||
let blockLang = null;
|
||||
@@ -521,7 +521,7 @@ describe('runtime-launcher-parity (#373)', () => {
|
||||
`_runtime-launcher.snippet.sh must not contain the literal "/gsd-tools" substring. ` +
|
||||
`Use bin/\${_GSD_SHIM_NAME} indirection to keep the /gsd[:-] scanner from ` +
|
||||
`misreading it as a slash-command stub. Found in snippet:\n` +
|
||||
snippetContent.split('\n').filter((l) => l.includes('/gsd-tools')).join('\n'),
|
||||
snippetContent.split(/\r?\n/).filter((l) => l.includes('/gsd-tools')).join('\n'),
|
||||
);
|
||||
|
||||
// (F2) workflows/do.md must not contain the literal substring /gsd-tools
|
||||
@@ -533,7 +533,7 @@ describe('runtime-launcher-parity (#373)', () => {
|
||||
const doMdPath = path.join(WORKFLOWS_DIR, 'do.md');
|
||||
const doMdContent = fs.readFileSync(doMdPath, 'utf8');
|
||||
const offendingLines = doMdContent
|
||||
.split('\n')
|
||||
.split(/\r?\n/)
|
||||
.filter((l) => /\/gsd-tools/.test(l));
|
||||
assert.deepStrictEqual(
|
||||
offendingLines,
|
||||
|
||||
@@ -113,7 +113,7 @@ describe('secret-scan-lint.sh script exists and is executable', { skip: IS_WINDO
|
||||
});
|
||||
|
||||
test('lint script has bash shebang', () => {
|
||||
const firstLine = fs.readFileSync(LINT_SCRIPT, 'utf-8').split('\n')[0];
|
||||
const firstLine = fs.readFileSync(LINT_SCRIPT, 'utf-8').split(/\r?\n/)[0];
|
||||
assert.ok(
|
||||
firstLine.startsWith('#!/usr/bin/env bash') || firstLine.startsWith('#!/bin/bash'),
|
||||
`${LINT_SCRIPT} missing bash shebang: ${firstLine}`
|
||||
|
||||
@@ -380,7 +380,7 @@ describe('SECURE: VALIDATION.md security columns', () => {
|
||||
test('both columns appear in the Per-Task Verification Map table', () => {
|
||||
const content = fs.readFileSync(valPath, 'utf-8');
|
||||
// Find the table header row containing both columns
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const headerLine = lines.find(
|
||||
line => line.includes('Threat Ref') && line.includes('Secure Behavior')
|
||||
);
|
||||
@@ -516,7 +516,7 @@ describe('SECURE: per-threat severity gate (#1626)', () => {
|
||||
// The old unconditional language said "phase must not ship" without a severity qualifier.
|
||||
// After the fix, every "phase must not ship" must be paired with a severity condition.
|
||||
// Find all occurrences of "must not ship" and verify none appear without "severity" nearby.
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (const line of lines) {
|
||||
if (line.includes('must not ship') && !line.includes('severity')) {
|
||||
assert.fail(
|
||||
|
||||
@@ -92,7 +92,7 @@ describe('security scan scripts exist and are executable', () => {
|
||||
});
|
||||
|
||||
test(`${name} script has bash shebang`, () => {
|
||||
const firstLine = fs.readFileSync(scriptPath, 'utf-8').split('\n')[0];
|
||||
const firstLine = fs.readFileSync(scriptPath, 'utf-8').split(/\r?\n/)[0];
|
||||
assert.ok(
|
||||
firstLine.startsWith('#!/usr/bin/env bash') || firstLine.startsWith('#!/bin/bash'),
|
||||
`${scriptPath} missing bash shebang: ${firstLine}`
|
||||
@@ -563,7 +563,7 @@ describe('security-scan.yml workflow', () => {
|
||||
test('workflow does not use direct github context in run commands', () => {
|
||||
const content = fs.readFileSync(workflowPath, 'utf-8');
|
||||
// Extract only run: blocks and check they don't contain ${{ }}
|
||||
const runBlocks = content.match(/run:\s*\|?\s*\n([\s\S]*?)(?=\n\s*-|\n\s*\w+:|Z)/g) || [];
|
||||
const runBlocks = content.match(/run:\s*\|?\s*\r?\n([\s\S]*?)(?=\r?\n\s*-|\r?\n\s*\w+:|Z)/g) || [];
|
||||
for (const block of runBlocks) {
|
||||
assert.ok(
|
||||
!block.includes('${{'),
|
||||
|
||||
@@ -37,7 +37,7 @@ const LIVENESS_PHRASE = 'runs in a subagent';
|
||||
// But NOT:
|
||||
// "◆ Planner wrote N plan(s)..." → not matched (no "spawn" word)
|
||||
// "◆ Research phase enabled" → not matched (no "spawn" word)
|
||||
const SPAWN_BANNER_RE = /◆[^\n]*\bspawning?\b/i;
|
||||
const SPAWN_BANNER_RE = /◆[^\r\n]*\bspawning?\b/i;
|
||||
|
||||
function findMdFiles(dir) {
|
||||
const entries = fs.readdirSync(dir, { withFileTypes: true });
|
||||
@@ -60,7 +60,7 @@ describe('spawn-liveness-banner', () => {
|
||||
|
||||
for (const filePath of mdFiles) {
|
||||
const content = fs.readFileSync(filePath, 'utf-8');
|
||||
const lines = content.split('\n');
|
||||
const lines = content.split(/\r?\n/);
|
||||
const rel = path.relative(WORKFLOWS_DIR, filePath);
|
||||
|
||||
for (let i = 0; i < lines.length; i++) {
|
||||
|
||||
@@ -1366,7 +1366,7 @@ describe('cmdStateResolveBlocker (state resolve-blocker)', () => {
|
||||
assert.ok(!updated.includes('- Single blocker'), 'resolved blocker should be removed');
|
||||
|
||||
// Section should contain "None" placeholder, not be empty
|
||||
const sectionMatch = updated.match(/## Blockers\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const sectionMatch = updated.match(/## Blockers\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(sectionMatch, 'Blockers section should still exist');
|
||||
assert.ok(sectionMatch[1].includes('None'), 'Blockers section should contain None placeholder');
|
||||
});
|
||||
@@ -1680,7 +1680,7 @@ Progress: [..........] 0%
|
||||
);
|
||||
|
||||
// Extract the Current Position section
|
||||
const posMatch = content.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(posMatch, 'Current Position section should exist');
|
||||
const posSection = posMatch[1];
|
||||
|
||||
@@ -1742,7 +1742,7 @@ Progress: [..........] 0%
|
||||
const content = fs.readFileSync(
|
||||
path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8'
|
||||
);
|
||||
const posMatch = content.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const posMatch = content.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(posMatch, 'Current Position section should exist after advance-plan');
|
||||
const posSection = posMatch[1];
|
||||
|
||||
@@ -2240,7 +2240,7 @@ describe('updatePerformanceMetricsSection', () => {
|
||||
].join('\n');
|
||||
const statePath = path.join(tmpDir, '.planning', 'STATE.md');
|
||||
// Force CRLF line endings across the whole STATE.md (Windows / hand-edited).
|
||||
fs.writeFileSync(statePath, content.replace(/\n/g, '\r\n'), 'utf8');
|
||||
fs.writeFileSync(statePath, content.replace(/\r?\n/g, '\r\n'), 'utf8');
|
||||
|
||||
const phaseDir = path.join(tmpDir, '.planning', 'phases', '07-crlf');
|
||||
fs.mkdirSync(phaseDir, { recursive: true });
|
||||
@@ -2464,7 +2464,7 @@ Progress: [##########] 20%
|
||||
);
|
||||
|
||||
// Current Position Status: line must also be "Ready to execute"
|
||||
const posMatch = stateContent.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(posMatch, 'Current Position section not found');
|
||||
const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m);
|
||||
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
|
||||
@@ -2519,7 +2519,7 @@ Progress: [##########] 20%
|
||||
const stateContent = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
// Locate the Current Position section and verify the Status line there.
|
||||
const posMatch = stateContent.match(/## Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const posMatch = stateContent.match(/## Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(posMatch, 'Current Position section not found');
|
||||
const posStatusMatch = posMatch[1].match(/^Status:\s*(.+)/m);
|
||||
assert.ok(posStatusMatch, 'Status field not found in Current Position section');
|
||||
@@ -2678,7 +2678,7 @@ describe('state sync command', () => {
|
||||
const afterSecond = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
|
||||
|
||||
// Strip frontmatter timestamps which will differ
|
||||
const stripTimestamps = (s) => s.replace(/last_updated:.*\n/g, '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'TS');
|
||||
const stripTimestamps = (s) => s.replace(/last_updated:.*\r?\n/g, '').replace(/\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/g, 'TS');
|
||||
assert.strictEqual(stripTimestamps(afterFirst), stripTimestamps(afterSecond), 'Two syncs should produce same result');
|
||||
});
|
||||
|
||||
@@ -3119,7 +3119,7 @@ describe('state add-roadmap-evolution (bug #1140)', () => {
|
||||
// Body of `## Accumulated Context` bounded by the next h2 (or EOF), so
|
||||
// placement assertions prove a subsection sits INSIDE that section.
|
||||
const accumulatedContextBody = (state) => {
|
||||
const m = state.match(/##\s*Accumulated Context\s*\n([\s\S]*?)(?=\n##[^#]|$)/);
|
||||
const m = state.match(/##\s*Accumulated Context\s*\r?\n([\s\S]*?)(?=\n##[^#]|$)/);
|
||||
return m ? m[1] : null;
|
||||
};
|
||||
|
||||
@@ -3284,7 +3284,7 @@ describe('state add-roadmap-evolution (bug #1140)', () => {
|
||||
|
||||
const state = readState(tmpDir);
|
||||
assert.ok(state.includes('- Phase 9 edited: line one line two line three'), `note not flattened:\n${state}`);
|
||||
assert.ok(!/\n\s*line two/.test(state), 'continuation lines must not spill outside the bullet');
|
||||
assert.ok(!/\r?\n\s*line two/.test(state), 'continuation lines must not spill outside the bullet');
|
||||
|
||||
const second = runGsdTools(
|
||||
['state', 'add-roadmap-evolution', '--phase', '9', '--action', 'edited', '--note-file', notePath],
|
||||
@@ -3722,7 +3722,7 @@ describe('regressions: table-format STATE.md (#1162)', () => {
|
||||
});
|
||||
|
||||
test('CRLF line endings in table format are handled', () => {
|
||||
const content = buildTableFormatState({ status: 'Ready to plan' }).replace(/\n/g, '\r\n');
|
||||
const content = buildTableFormatState({ status: 'Ready to plan' }).replace(/\r?\n/g, '\r\n');
|
||||
fs.writeFileSync(statePath, content);
|
||||
|
||||
const result = runGsdTools(['state', 'update', 'Status', 'Ready to execute'], tmpDir);
|
||||
@@ -4021,7 +4021,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md'
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
|
||||
// Extract the ## Current Position section only, to avoid matching Configuration rows
|
||||
const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(cpMatch, '## Current Position section must exist');
|
||||
const cpSection = cpMatch[1];
|
||||
|
||||
@@ -4095,7 +4095,7 @@ describe('#1255 — begin/complete-phase advance status for pipe-table STATE.md'
|
||||
const after = fs.readFileSync(path.join(dir, '.planning', 'STATE.md'), 'utf8');
|
||||
|
||||
// Extract the ## Current Position section only, to avoid matching Configuration rows
|
||||
const cpMatch = after.match(/##\s*Current Position\s*\n([\s\S]*?)(?=\n##|$)/i);
|
||||
const cpMatch = after.match(/##\s*Current Position\s*\r?\n([\s\S]*?)(?=\r?\n##|$)/i);
|
||||
assert.ok(cpMatch, '## Current Position section must exist');
|
||||
const cpSection = cpMatch[1];
|
||||
|
||||
|
||||
@@ -103,7 +103,7 @@ describe('map-codebase workflow references configurable timeout (#1472)', () =>
|
||||
const content = fs.readFileSync(workflowPath, 'utf8');
|
||||
|
||||
// The timeout line should reference the config variable, not a hardcoded value
|
||||
const timeoutLines = content.split('\n').filter(l => l.includes('timeout:'));
|
||||
const timeoutLines = content.split(/\r?\n/).filter(l => l.includes('timeout:'));
|
||||
for (const line of timeoutLines) {
|
||||
assert.ok(
|
||||
!line.match(/timeout:\s*300000\s*$/),
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
'use strict';
|
||||
|
||||
process.env.GSD_TEST_MODE = '1';
|
||||
|
||||
/**
|
||||
* Named-set allowlist guard against Windows-test-parity regressions.
|
||||
*
|
||||
* PR #3649 cleared ~270 Windows-only test failures from the chunking fix
|
||||
* in #3597 surfaced. Each cluster reduced to a handful of repeating
|
||||
* patterns. This guard prevents the patterns from being re-introduced.
|
||||
*
|
||||
* Strategy (updated from integer-count ratchet): each rule's known offenders
|
||||
* are enumerated by filename in a frozen KNOWN_OFFENDERS set. The guard uses
|
||||
* the shared assertWithinAllowlist primitive (scripts/lib/allowlist-ratchet.cjs)
|
||||
* which enforces BOTH directions:
|
||||
* - Novel offenders (current \ known) → fail immediately.
|
||||
* - Stale allowlist entries (known \ current) → also fail, forcing the
|
||||
* allowlist to shrink as defects are fixed (ratchet-DOWN enforcement).
|
||||
*
|
||||
* When you fix an existing offender, you MUST remove its entry from
|
||||
* KNOWN_OFFENDERS — the guard will fail on stale entries to enforce progress.
|
||||
* When CI breaks because a new file introduced an anti-pattern, fix the
|
||||
* anti-pattern — do not just add the filename to the set to silence the guard.
|
||||
*
|
||||
* rmSync teardown safety is now enforced at write-time by the ESLint rule
|
||||
* local/no-raw-rmsync-in-tests (see issue #597); it is no longer ratcheted here.
|
||||
*
|
||||
* Scope: tests/ only. Production-code Windows-compat is enforced via
|
||||
* behavioural tests (see no-unconditional-win32-skip.test.cjs).
|
||||
*/
|
||||
|
||||
// allow-test-rule: structural-regression-guard
|
||||
|
||||
const { test, describe } = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const { assertWithinAllowlist } = require('../scripts/lib/allowlist-ratchet.cjs');
|
||||
|
||||
const TESTS_DIR = path.join(__dirname);
|
||||
const SELF = path.basename(__filename);
|
||||
|
||||
// ── Known offenders after PR #3649 batch (named-set allowlist) ───────────
|
||||
// These are the files that matched each anti-pattern at the time of writing.
|
||||
// A test fails when a file NOT in the set starts matching (novel regression),
|
||||
// OR when a file in the set stops matching (stale entry — must be pruned).
|
||||
// Edit this object to update the allowlists:
|
||||
// edit KNOWN_OFFENDERS in tests/windows-test-parity-guard.test.cjs
|
||||
const KNOWN_OFFENDERS = Object.freeze({
|
||||
splitNewlineOnFileContent: new Set([
|
||||
'release-coverage-scope.test.cjs',
|
||||
'secret-scan-lint.security.test.cjs',
|
||||
'security-scan.security.test.cjs',
|
||||
]),
|
||||
fenceRegexLiteralNewline: new Set([
|
||||
'bug-2995-post-install-script-paths.test.cjs',
|
||||
'security-scan.security.test.cjs',
|
||||
]),
|
||||
frontmatterAnchorLiteralNewline: new Set([
|
||||
'bug-1967-cache-invalidation.test.cjs',
|
||||
'bug-2643-skill-frontmatter-name.test.cjs',
|
||||
'bug-2808-skill-hyphen-name.test.cjs',
|
||||
'bug-3168-task-to-agent-rename.test.cjs',
|
||||
'qwen-skills-migration.test.cjs',
|
||||
]),
|
||||
hardcodedTmpToFsCall: new Set([
|
||||
// (none at time of writing)
|
||||
]),
|
||||
bareNpmExecWithoutShell: new Set([
|
||||
// (none at time of writing)
|
||||
]),
|
||||
stubsHomeNoUserProfile: new Set([
|
||||
'bug-130-finishinstall-opencode-testmode.test.cjs',
|
||||
'bug-2794-opencode-model-profile-overrides.test.cjs',
|
||||
'claude-md.test.cjs',
|
||||
'feat-443-effort-install-wiring.install.test.cjs',
|
||||
'issue-2517-runtime-aware-profiles.test.cjs',
|
||||
]),
|
||||
});
|
||||
|
||||
function listTestFiles() {
|
||||
return fs.readdirSync(TESTS_DIR)
|
||||
.filter((f) => /\.(test|spec)\.cjs$/.test(f))
|
||||
.filter((f) => f !== SELF)
|
||||
.map((f) => path.join(TESTS_DIR, f));
|
||||
}
|
||||
|
||||
function readFileText(filePath) {
|
||||
return fs.readFileSync(filePath, 'utf8');
|
||||
}
|
||||
|
||||
// Strip line comments and block comments before pattern matching to avoid
|
||||
// false-positives in commentary describing the very pattern we forbid.
|
||||
function stripComments(text) {
|
||||
return text
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/(^|[^:])\/\/[^\n]*/g, '$1');
|
||||
}
|
||||
|
||||
function countMatchingFiles(predicate) {
|
||||
let count = 0;
|
||||
const offenders = [];
|
||||
for (const file of listTestFiles()) {
|
||||
const text = stripComments(readFileText(file));
|
||||
if (predicate(text, file)) {
|
||||
count += 1;
|
||||
offenders.push(path.basename(file));
|
||||
}
|
||||
}
|
||||
return { count, offenders };
|
||||
}
|
||||
|
||||
const PRUNE_HINT = 'edit KNOWN_OFFENDERS in tests/windows-test-parity-guard.test.cjs';
|
||||
|
||||
describe('Windows test-parity lint guards (named-set allowlist: PR #3649)', () => {
|
||||
// ── G1 — CRLF: file-content split on literal '\n' ─────────────────────
|
||||
test('split-on-newline after readFileSync (use /\\r?\\n/)', () => {
|
||||
const { offenders } = countMatchingFiles((text) => {
|
||||
return /\.readFileSync\s*\([^)]*\)[^;]*\.split\(\s*['"]\\n['"]\s*\)/.test(text);
|
||||
});
|
||||
assertWithinAllowlist({
|
||||
label: 'splitNewlineOnFileContent',
|
||||
current: offenders,
|
||||
known: KNOWN_OFFENDERS.splitNewlineOnFileContent,
|
||||
fail: assert.fail,
|
||||
pruneHint: PRUNE_HINT,
|
||||
});
|
||||
});
|
||||
|
||||
// ── G2 — CRLF: ```bash|sh\n fence regex on file content ──────────────
|
||||
test('markdown-fence regex with literal \\n after ```bash/sh', () => {
|
||||
const { offenders } = countMatchingFiles((text) => {
|
||||
return /\/[^/]*```(?:bash|sh)\\n[^/]*\//.test(text);
|
||||
});
|
||||
assertWithinAllowlist({
|
||||
label: 'fenceRegexLiteralNewline',
|
||||
current: offenders,
|
||||
known: KNOWN_OFFENDERS.fenceRegexLiteralNewline,
|
||||
fail: assert.fail,
|
||||
pruneHint: PRUNE_HINT,
|
||||
});
|
||||
});
|
||||
|
||||
// ── G3 — CRLF: frontmatter regex with literal '\n' ────────────────────
|
||||
test('frontmatter regex anchors on /^---\\n/', () => {
|
||||
const { offenders } = countMatchingFiles((text) => {
|
||||
return /\/\^---\\n/.test(text);
|
||||
});
|
||||
assertWithinAllowlist({
|
||||
label: 'frontmatterAnchorLiteralNewline',
|
||||
current: offenders,
|
||||
known: KNOWN_OFFENDERS.frontmatterAnchorLiteralNewline,
|
||||
fail: assert.fail,
|
||||
pruneHint: PRUNE_HINT,
|
||||
});
|
||||
});
|
||||
|
||||
// ── G4 — POSIX-tmp: hardcoded '/tmp/' literal passed to fs.* ─────────
|
||||
test('fs.* call receives a hardcoded "/tmp/..." literal', () => {
|
||||
const { offenders } = countMatchingFiles((text) => {
|
||||
return /\bfs\.[A-Za-z]+\s*\([^)]*['"]\/tmp\/[^'"]+['"][^)]*\)/.test(text);
|
||||
});
|
||||
assertWithinAllowlist({
|
||||
label: 'hardcodedTmpToFsCall',
|
||||
current: offenders,
|
||||
known: KNOWN_OFFENDERS.hardcodedTmpToFsCall,
|
||||
fail: assert.fail,
|
||||
pruneHint: PRUNE_HINT,
|
||||
});
|
||||
});
|
||||
|
||||
// ── G5 — npm.cmd: bare 'npm' to exec*Sync without shell:true ─────────
|
||||
test('bare npm exec without shell-true Windows fallback', () => {
|
||||
const { offenders } = countMatchingFiles((text) => {
|
||||
const re = /\b(?:execFileSync|spawnSync)\s*\(\s*['"]npm['"]\s*,[^)]*\)/g;
|
||||
const matches = text.match(re) || [];
|
||||
return matches.some((m) =>
|
||||
!/shell\s*:\s*true/.test(m) && !/shell\s*:\s*isWindows/.test(m),
|
||||
);
|
||||
});
|
||||
assertWithinAllowlist({
|
||||
label: 'bareNpmExecWithoutShell',
|
||||
current: offenders,
|
||||
known: KNOWN_OFFENDERS.bareNpmExecWithoutShell,
|
||||
fail: assert.fail,
|
||||
pruneHint: PRUNE_HINT,
|
||||
});
|
||||
});
|
||||
|
||||
// ── G6 — Test stubs HOME without USERPROFILE ─────────────────────────
|
||||
test('test stubs process.env.HOME but never references USERPROFILE', () => {
|
||||
const { offenders } = countMatchingFiles((text) => {
|
||||
return /process\.env\.HOME\s*=\s*/.test(text) && !/USERPROFILE/.test(text);
|
||||
});
|
||||
assertWithinAllowlist({
|
||||
label: 'stubsHomeNoUserProfile',
|
||||
current: offenders,
|
||||
known: KNOWN_OFFENDERS.stubsHomeNoUserProfile,
|
||||
fail: assert.fail,
|
||||
pruneHint: PRUNE_HINT,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -331,7 +331,7 @@ describe('workspace command files', () => {
|
||||
const fmMatch = raw.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n([\s\S]*)$/);
|
||||
assert.ok(fmMatch, `${path.basename(filePath)} must start with a YAML frontmatter block`);
|
||||
const fm = {};
|
||||
for (const rawLine of fmMatch[1].split('\n')) {
|
||||
for (const rawLine of fmMatch[1].split(/\r?\n/)) {
|
||||
// Explicit \r strip: split('\n') on CRLF content leaves a trailing
|
||||
// \r on every line, which the value regex pulls into `kv[2]` and trim
|
||||
// is enough for most values — but be defensive so future keys with
|
||||
@@ -358,7 +358,7 @@ describe('workspace command files', () => {
|
||||
.map((m) => m[1]);
|
||||
const targets = [];
|
||||
for (const blk of blocks) {
|
||||
for (const line of blk.split('\n')) {
|
||||
for (const line of blk.split(/\r?\n/)) {
|
||||
const t = line.trim();
|
||||
if (!t.startsWith('@')) continue;
|
||||
// Normalize away the home-prefix and the `.claude/gsd-core/` root
|
||||
|
||||
@@ -53,7 +53,7 @@ function extractNamedBlock(markdown, blockName) {
|
||||
*/
|
||||
function extractFencedCodeBlocks(markdown) {
|
||||
const blocks = [];
|
||||
const lines = markdown.split('\n');
|
||||
const lines = markdown.split(/\r?\n/);
|
||||
let inFence = false;
|
||||
let fenceLang = '';
|
||||
let buffer = [];
|
||||
@@ -83,7 +83,7 @@ function extractFencedCodeBlocks(markdown) {
|
||||
*/
|
||||
function shellStatements(script) {
|
||||
const statements = [];
|
||||
const lines = script.split('\n');
|
||||
const lines = script.split(/\r?\n/);
|
||||
for (let raw of lines) {
|
||||
const line = raw.replace(/#.*$/, '').trim();
|
||||
if (!line) continue;
|
||||
@@ -219,7 +219,7 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
// negated/opt-out context (e.g. "Do NOT pass --no-verify"); reject
|
||||
// any sentence whose first verb is "Use --no-verify".
|
||||
const sentences = block
|
||||
.replace(/\n+/g, ' ')
|
||||
.replace(/\r?\n+/g, ' ')
|
||||
.split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
@@ -253,7 +253,7 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
assert.notStrictEqual(endIdx, -1, 'parallel-executor sub-section terminator must exist');
|
||||
const subBlock = block.slice(headingIdx, endIdx);
|
||||
assert.ok(subBlock.length > 0, 'sub-section must have content');
|
||||
const sentences = subBlock.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
const sentences = subBlock.replace(/\r?\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
@@ -448,10 +448,10 @@ describe('bug #2924: worktree HEAD attachment + destructive recovery', () => {
|
||||
const idx = content.indexOf('Parallel agents');
|
||||
assert.notStrictEqual(idx, -1, 'must contain a "Parallel agents" callout');
|
||||
const section = content.slice(idx);
|
||||
const endMatch = section.slice(1).match(/\n#{1,6}\s/);
|
||||
const endMatch = section.slice(1).match(/\r?\n#{1,6}\s/);
|
||||
assert.ok(endMatch, 'Parallel agents section must terminate at the next heading');
|
||||
const tail = section.slice(0, 1 + endMatch.index);
|
||||
const sentences = tail.replace(/\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
const sentences = tail.replace(/\r?\n+/g, ' ').split(/(?<=[.!?])\s+/);
|
||||
for (const sentence of sentences) {
|
||||
if (!sentence.includes('--no-verify')) continue;
|
||||
const lower = sentence.toLowerCase();
|
||||
|
||||
Reference in New Issue
Block a user