feat(#1726): G1-G6 portability AST rules; fix all offenders; delete the ratchet (Phase 4) (#1731)

Phase 4 of epic #1702. Closes #1726.
This commit is contained in:
Tom Boucher
2026-06-25 18:24:39 -04:00
committed by GitHub
parent 2990305f78
commit f08b177215
88 changed files with 2038 additions and 377 deletions

View File

@@ -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`

View File

@@ -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

View 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;

View 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;

View 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;

View 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;

View File

@@ -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',

View File

@@ -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",

View File

@@ -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() {

View File

@@ -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

View File

@@ -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])) {

View File

@@ -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());

View File

@@ -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/

View File

@@ -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];

View File

@@ -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)'
);
});

View File

@@ -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,

View File

@@ -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

View File

@@ -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)}`);

View File

@@ -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());
}

View File

@@ -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);

View File

@@ -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}`);

View File

@@ -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');

View File

@@ -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,
[],

View File

@@ -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];

View File

@@ -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 });

View File

@@ -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). ' +

View File

@@ -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) {

View File

@@ -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

View File

@@ -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

View File

@@ -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', () => {

View File

@@ -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,

View File

@@ -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;

View File

@@ -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)),

View File

@@ -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, []);
});

View File

@@ -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');
});
});

View File

@@ -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];

View File

@@ -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));

View File

@@ -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);
}
});

View File

@@ -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);

View File

@@ -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

View File

@@ -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)'
);
});

View File

@@ -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 ─────────────────────────────────────────

View File

@@ -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];

View File

@@ -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(

View File

@@ -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');

View File

@@ -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 = [];

View File

@@ -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

View File

@@ -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;

View File

@@ -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)', () => {

View File

@@ -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]);

View File

@@ -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()}`);

View File

@@ -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, [],

View File

@@ -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');
});

View File

@@ -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');

View File

@@ -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,

View File

@@ -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(

View File

@@ -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;

View File

@@ -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

View 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: [],
});
});
});

View 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: [],
});
});
});

View 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: [],
});
});
});

View File

@@ -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);

View File

@@ -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');

View File

@@ -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];

View File

@@ -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', () => {

View File

@@ -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.`);
});

View File

@@ -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'

View File

@@ -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] };

View File

@@ -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));

View File

@@ -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 ───────────────────────────

View File

@@ -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];

View File

@@ -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];

View File

@@ -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)) {

View File

@@ -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:'));

View File

@@ -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);

View File

@@ -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,

View 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: [],
});
});
});

View File

@@ -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']);

View File

@@ -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,

View File

@@ -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}`

View File

@@ -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(

View File

@@ -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('${{'),

View File

@@ -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++) {

View File

@@ -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];

View File

@@ -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*$/),

View File

@@ -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,
});
});
});

View File

@@ -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

View File

@@ -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();