From f08b1772153644fc446be30467be6c4cfbd1f737 Mon Sep 17 00:00:00 2001 From: Tom Boucher Date: Thu, 25 Jun 2026 18:24:39 -0400 Subject: [PATCH] feat(#1726): G1-G6 portability AST rules; fix all offenders; delete the ratchet (Phase 4) (#1731) Phase 4 of epic #1702. Closes #1726. --- CONTEXT.md | 6 +- .../cross-platform-portability-rules.md | 97 +++- eslint-rules/no-bare-npm-exec.cjs | 154 +++++++ eslint-rules/no-crlf-fragile-split.cjs | 418 ++++++++++++++++++ eslint-rules/no-hardcoded-tmp.cjs | 110 +++++ .../require-userprofile-with-home.cjs | 114 +++++ eslint.config.mjs | 16 + .../lint-allow-test-rule-refs.allowlist.json | 1 - scripts/prompt-injection-scan.sh | 4 + tests/agent-frontmatter.test.cjs | 2 +- tests/atomic-write-coverage.test.cjs | 2 +- tests/autonomous-allowed-tools.test.cjs | 2 +- ...0-finishinstall-opencode-testmode.test.cjs | 1 + tests/bug-1967-cache-invalidation.test.cjs | 2 +- ...2410-stream-checkpoint-heartbeats.test.cjs | 18 +- tests/bug-2492-context-coverage-gate.test.cjs | 4 +- ...-2516-inherit-model-execute-phase.test.cjs | 2 +- tests/bug-2543-gsd-slash-namespace.test.cjs | 2 +- .../bug-2643-skill-frontmatter-name.test.cjs | 6 +- ...-opencode-model-profile-overrides.test.cjs | 10 + tests/bug-2808-skill-hyphen-name.test.cjs | 12 +- ...2836-audit-open-summary-uat-drift.test.cjs | 2 +- ...3130-update-npx-robust-invocation.test.cjs | 2 +- tests/bug-3168-task-to-agent-rename.test.cjs | 6 +- ...g-3290-intel-updater-layout-block.test.cjs | 2 +- tests/bug-3491-nested-git-worktree.test.cjs | 2 +- ...-research-insert-phase-agent-refs.test.cjs | 2 +- ...3678-executor-commit-docs-respect.test.cjs | 6 +- ...command-cross-reference-invariant.test.cjs | 2 +- .../bug-378-update-check-scoped-name.test.cjs | 2 +- ...pdate-agent-antigravity-detection.test.cjs | 6 +- .../bug-619-codebase-drift-gate-shim.test.cjs | 2 +- tests/bug-641-files-from-suite-token.test.cjs | 2 +- .../bug-kimi-path-layout-local-guard.test.cjs | 21 +- tests/capability-matrix-sync.test.cjs | 6 +- tests/check-update-config-dir.test.cjs | 4 +- tests/ci-test-scope.test.cjs | 2 +- tests/claude-md.test.cjs | 7 +- tests/codex-config.test.cjs | 28 +- tests/commit-docs-bypass.test.cjs | 8 +- tests/config-field-docs.test.cjs | 4 +- tests/dispatch/trace-correlation.test.cjs | 2 +- tests/edge-probe-docs-fixtures.test.cjs | 2 +- ...h-773-codex-exec-automation-flags.test.cjs | 4 +- tests/execute-phase-wave.test.cjs | 2 +- .../feat-3025-mcp-token-budget-docs.test.cjs | 4 +- ...443-effort-install-wiring.install.test.cjs | 4 + ...fix-1464-docs-manifest-validation.test.cjs | 2 +- ...check-update-worker-platform-gate.test.cjs | 2 +- tests/gsd-researcher-app-aware.test.cjs | 2 +- tests/gsd-tools-path-refs.test.cjs | 2 +- tests/hardcoded-paths.test.cjs | 6 +- tests/hermes-skills-migration.test.cjs | 4 +- tests/init.test.cjs | 2 +- tests/intel.test.cjs | 2 +- tests/inventory-headings-countfree.test.cjs | 2 +- ...issue-2517-runtime-aware-profiles.test.cjs | 4 + tests/milestone-summary.test.cjs | 2 +- tests/no-bare-npm-exec.rule.test.cjs | 201 +++++++++ tests/no-crlf-fragile-split.rule.test.cjs | 338 ++++++++++++++ tests/no-hardcoded-tmp.rule.test.cjs | 184 ++++++++ tests/observability/logger.test.cjs | 8 +- tests/package-legitimacy-gate.test.cjs | 10 +- tests/package-name-single-source.test.cjs | 2 +- tests/phase.test.cjs | 12 +- tests/phase6-capstone-conformance.test.cjs | 2 +- tests/plan-review-convergence.test.cjs | 10 +- ...policy-138-nyquist-config-default.test.cjs | 2 +- ...olicy-release-no-npm-self-upgrade.test.cjs | 4 +- tests/portability-rule-disable-ban.test.cjs | 5 + tests/product-name-purity.test.cjs | 2 +- .../prohibition-probe.docs-fixtures.test.cjs | 2 +- tests/prompt-injection-scan.security.test.cjs | 2 +- tests/qwen-skills-migration.test.cjs | 6 +- tests/reapply-patches.test.cjs | 8 +- tests/release-coverage-scope.test.cjs | 2 +- ...equire-userprofile-with-home.rule.test.cjs | 197 +++++++++ tests/roadmap.test.cjs | 4 +- tests/runtime-launcher-parity.test.cjs | 8 +- tests/secret-scan-lint.security.test.cjs | 2 +- tests/secure-phase.test.cjs | 4 +- tests/security-scan.security.test.cjs | 4 +- tests/spawn-liveness-banner.test.cjs | 4 +- tests/state.test.cjs | 24 +- tests/subagent-timeout.test.cjs | 2 +- tests/windows-test-parity-guard.test.cjs | 203 --------- tests/workspace.test.cjs | 4 +- tests/worktree-cleanup.test.cjs | 12 +- 88 files changed, 2038 insertions(+), 377 deletions(-) create mode 100644 eslint-rules/no-bare-npm-exec.cjs create mode 100644 eslint-rules/no-crlf-fragile-split.cjs create mode 100644 eslint-rules/no-hardcoded-tmp.cjs create mode 100644 eslint-rules/require-userprofile-with-home.cjs create mode 100644 tests/no-bare-npm-exec.rule.test.cjs create mode 100644 tests/no-crlf-fragile-split.rule.test.cjs create mode 100644 tests/no-hardcoded-tmp.rule.test.cjs create mode 100644 tests/require-userprofile-with-home.rule.test.cjs delete mode 100644 tests/windows-test-parity-guard.test.cjs diff --git a/CONTEXT.md b/CONTEXT.md index e931cfc67..40bbdd620 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -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 "^:" on a *.md whose result is compared to exact tokens, with no frontmatter scoping and no -m1; one body line beginning : 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 "^:" | 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 ) 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` diff --git a/docs/contributing/cross-platform-portability-rules.md b/docs/contributing/cross-platform-portability-rules.md index 4e18cbb18..80290c134 100644 --- a/docs/contributing/cross-platform-portability-rules.md +++ b/docs/contributing/cross-platform-portability-rules.md @@ -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 = ` 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 diff --git a/eslint-rules/no-bare-npm-exec.cjs b/eslint-rules/no-bare-npm-exec.cjs new file mode 100644 index 000000000..0640b5a0b --- /dev/null +++ b/eslint-rules/no-bare-npm-exec.cjs @@ -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; diff --git a/eslint-rules/no-crlf-fragile-split.cjs b/eslint-rules/no-crlf-fragile-split.cjs new file mode 100644 index 000000000..a55a07eb8 --- /dev/null +++ b/eslint-rules/no-crlf-fragile-split.cjs @@ -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; diff --git a/eslint-rules/no-hardcoded-tmp.cjs b/eslint-rules/no-hardcoded-tmp.cjs new file mode 100644 index 000000000..1440df943 --- /dev/null +++ b/eslint-rules/no-hardcoded-tmp.cjs @@ -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.(...)` 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.(...)` 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.(...) 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; diff --git a/eslint-rules/require-userprofile-with-home.cjs b/eslint-rules/require-userprofile-with-home.cjs new file mode 100644 index 000000000..0852daaf2 --- /dev/null +++ b/eslint-rules/require-userprofile-with-home.cjs @@ -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; diff --git a/eslint.config.mjs b/eslint.config.mjs index 26f7f26d6..22ddd2234 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -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', diff --git a/scripts/lint-allow-test-rule-refs.allowlist.json b/scripts/lint-allow-test-rule-refs.allowlist.json index 55e98eaac..4bee3ccdd 100644 --- a/scripts/lint-allow-test-rule-refs.allowlist.json +++ b/scripts/lint-allow-test-rule-refs.allowlist.json @@ -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", diff --git a/scripts/prompt-injection-scan.sh b/scripts/prompt-injection-scan.sh index 3440507e7..b94460efe 100755 --- a/scripts/prompt-injection-scan.sh +++ b/scripts/prompt-injection-scan.sh @@ -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() { diff --git a/tests/agent-frontmatter.test.cjs b/tests/agent-frontmatter.test.cjs index 97ecd6754..d1b60e0d7 100644 --- a/tests/agent-frontmatter.test.cjs +++ b/tests/agent-frontmatter.test.cjs @@ -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 diff --git a/tests/atomic-write-coverage.test.cjs b/tests/atomic-write-coverage.test.cjs index 8bfb0bb7a..e5901f8ff 100644 --- a/tests/atomic-write-coverage.test.cjs +++ b/tests/atomic-write-coverage.test.cjs @@ -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])) { diff --git a/tests/autonomous-allowed-tools.test.cjs b/tests/autonomous-allowed-tools.test.cjs index 2116d7a9e..8cfd1404c 100644 --- a/tests/autonomous-allowed-tools.test.cjs +++ b/tests/autonomous-allowed-tools.test.cjs @@ -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()); diff --git a/tests/bug-130-finishinstall-opencode-testmode.test.cjs b/tests/bug-130-finishinstall-opencode-testmode.test.cjs index f0d698bad..353105bd8 100644 --- a/tests/bug-130-finishinstall-opencode-testmode.test.cjs +++ b/tests/bug-130-finishinstall-opencode-testmode.test.cjs @@ -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: /.config/opencode/ diff --git a/tests/bug-1967-cache-invalidation.test.cjs b/tests/bug-1967-cache-invalidation.test.cjs index 389ade42d..fbda4cda4 100644 --- a/tests/bug-1967-cache-invalidation.test.cjs +++ b/tests/bug-1967-cache-invalidation.test.cjs @@ -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]; diff --git a/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs b/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs index dfae569c1..c43ed238d 100644 --- a/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs +++ b/tests/bug-2410-stream-checkpoint-heartbeats.test.cjs @@ -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)' ); }); diff --git a/tests/bug-2492-context-coverage-gate.test.cjs b/tests/bug-2492-context-coverage-gate.test.cjs index 7edc1b596..58dff07a1 100644 --- a/tests/bug-2492-context-coverage-gate.test.cjs +++ b/tests/bug-2492-context-coverage-gate.test.cjs @@ -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, diff --git a/tests/bug-2516-inherit-model-execute-phase.test.cjs b/tests/bug-2516-inherit-model-execute-phase.test.cjs index aa963d6b3..699358976 100644 --- a/tests/bug-2516-inherit-model-execute-phase.test.cjs +++ b/tests/bug-2516-inherit-model-execute-phase.test.cjs @@ -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 diff --git a/tests/bug-2543-gsd-slash-namespace.test.cjs b/tests/bug-2543-gsd-slash-namespace.test.cjs index e4b7cba81..bd313bf9f 100644 --- a/tests/bug-2543-gsd-slash-namespace.test.cjs +++ b/tests/bug-2543-gsd-slash-namespace.test.cjs @@ -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)}`); diff --git a/tests/bug-2643-skill-frontmatter-name.test.cjs b/tests/bug-2643-skill-frontmatter-name.test.cjs index 0ed5cbb6f..6b4b62e28 100644 --- a/tests/bug-2643-skill-frontmatter-name.test.cjs +++ b/tests/bug-2643-skill-frontmatter-name.test.cjs @@ -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()); } diff --git a/tests/bug-2794-opencode-model-profile-overrides.test.cjs b/tests/bug-2794-opencode-model-profile-overrides.test.cjs index 97b53f648..d9e533e3f 100644 --- a/tests/bug-2794-opencode-model-profile-overrides.test.cjs +++ b/tests/bug-2794-opencode-model-profile-overrides.test.cjs @@ -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); diff --git a/tests/bug-2808-skill-hyphen-name.test.cjs b/tests/bug-2808-skill-hyphen-name.test.cjs index db95ef051..a6375fecb 100644 --- a/tests/bug-2808-skill-hyphen-name.test.cjs +++ b/tests/bug-2808-skill-hyphen-name.test.cjs @@ -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}`); diff --git a/tests/bug-2836-audit-open-summary-uat-drift.test.cjs b/tests/bug-2836-audit-open-summary-uat-drift.test.cjs index 600f9a97b..8d4487800 100644 --- a/tests/bug-2836-audit-open-summary-uat-drift.test.cjs +++ b/tests/bug-2836-audit-open-summary-uat-drift.test.cjs @@ -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'); diff --git a/tests/bug-3130-update-npx-robust-invocation.test.cjs b/tests/bug-3130-update-npx-robust-invocation.test.cjs index 3ae34d453..6eea373db 100644 --- a/tests/bug-3130-update-npx-robust-invocation.test.cjs +++ b/tests/bug-3130-update-npx-robust-invocation.test.cjs @@ -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@` 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, [], diff --git a/tests/bug-3168-task-to-agent-rename.test.cjs b/tests/bug-3168-task-to-agent-rename.test.cjs index a99405d81..644be5ec1 100644 --- a/tests/bug-3168-task-to-agent-rename.test.cjs +++ b/tests/bug-3168-task-to-agent-rename.test.cjs @@ -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]; diff --git a/tests/bug-3290-intel-updater-layout-block.test.cjs b/tests/bug-3290-intel-updater-layout-block.test.cjs index 95c3d21d0..4496c2bf5 100644 --- a/tests/bug-3290-intel-updater-layout-block.test.cjs +++ b/tests/bug-3290-intel-updater-layout-block.test.cjs @@ -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 }); diff --git a/tests/bug-3491-nested-git-worktree.test.cjs b/tests/bug-3491-nested-git-worktree.test.cjs index 2afbb6dbd..d31754f60 100644 --- a/tests/bug-3491-nested-git-worktree.test.cjs +++ b/tests/bug-3491-nested-git-worktree.test.cjs @@ -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). ' + diff --git a/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs b/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs index 6f3c46929..5c9f19e35 100644 --- a/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs +++ b/tests/bug-3605-stale-research-insert-phase-agent-refs.test.cjs @@ -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) { diff --git a/tests/bug-3678-executor-commit-docs-respect.test.cjs b/tests/bug-3678-executor-commit-docs-respect.test.cjs index b70527457..f863a9300 100644 --- a/tests/bug-3678-executor-commit-docs-respect.test.cjs +++ b/tests/bug-3678-executor-commit-docs-respect.test.cjs @@ -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 diff --git a/tests/bug-3683-command-cross-reference-invariant.test.cjs b/tests/bug-3683-command-cross-reference-invariant.test.cjs index 79496ddd7..1c361b7ff 100644 --- a/tests/bug-3683-command-cross-reference-invariant.test.cjs +++ b/tests/bug-3683-command-cross-reference-invariant.test.cjs @@ -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 diff --git a/tests/bug-378-update-check-scoped-name.test.cjs b/tests/bug-378-update-check-scoped-name.test.cjs index 9baf23e4f..d73c6ba05 100644 --- a/tests/bug-378-update-check-scoped-name.test.cjs +++ b/tests/bug-378-update-check-scoped-name.test.cjs @@ -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', () => { diff --git a/tests/bug-503-update-agent-antigravity-detection.test.cjs b/tests/bug-503-update-agent-antigravity-detection.test.cjs index fd4a64587..b5ce266fd 100644 --- a/tests/bug-503-update-agent-antigravity-detection.test.cjs +++ b/tests/bug-503-update-agent-antigravity-detection.test.cjs @@ -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, diff --git a/tests/bug-619-codebase-drift-gate-shim.test.cjs b/tests/bug-619-codebase-drift-gate-shim.test.cjs index 55f9a33f8..919e95540 100644 --- a/tests/bug-619-codebase-drift-gate-shim.test.cjs +++ b/tests/bug-619-codebase-drift-gate-shim.test.cjs @@ -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; diff --git a/tests/bug-641-files-from-suite-token.test.cjs b/tests/bug-641-files-from-suite-token.test.cjs index a427fc527..327266de3 100644 --- a/tests/bug-641-files-from-suite-token.test.cjs +++ b/tests/bug-641-files-from-suite-token.test.cjs @@ -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)), diff --git a/tests/bug-kimi-path-layout-local-guard.test.cjs b/tests/bug-kimi-path-layout-local-guard.test.cjs index fd9c33ebe..8396358b9 100644 --- a/tests/bug-kimi-path-layout-local-guard.test.cjs +++ b/tests/bug-kimi-path-layout-local-guard.test.cjs @@ -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, []); }); diff --git a/tests/capability-matrix-sync.test.cjs b/tests/capability-matrix-sync.test.cjs index f35e1f9dc..0c25087fe 100644 --- a/tests/capability-matrix-sync.test.cjs +++ b/tests/capability-matrix-sync.test.cjs @@ -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'); }); }); diff --git a/tests/check-update-config-dir.test.cjs b/tests/check-update-config-dir.test.cjs index dc7792f5c..0406723d2 100644 --- a/tests/check-update-config-dir.test.cjs +++ b/tests/check-update-config-dir.test.cjs @@ -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]; diff --git a/tests/ci-test-scope.test.cjs b/tests/ci-test-scope.test.cjs index 47e8eb803..a41dd3318 100644 --- a/tests/ci-test-scope.test.cjs +++ b/tests/ci-test-scope.test.cjs @@ -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)); diff --git a/tests/claude-md.test.cjs b/tests/claude-md.test.cjs index 94683d8c3..9ecd947fe 100644 --- a/tests/claude-md.test.cjs +++ b/tests/claude-md.test.cjs @@ -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); } }); diff --git a/tests/codex-config.test.cjs b/tests/codex-config.test.cjs index b08b3a1c2..f87290bba 100644 --- a/tests/codex-config.test.cjs +++ b/tests/codex-config.test.cjs @@ -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); diff --git a/tests/commit-docs-bypass.test.cjs b/tests/commit-docs-bypass.test.cjs index aef33410e..2ac5328bc 100644 --- a/tests/commit-docs-bypass.test.cjs +++ b/tests/commit-docs-bypass.test.cjs @@ -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 diff --git a/tests/config-field-docs.test.cjs b/tests/config-field-docs.test.cjs index 490c8af64..ad9fdc72a 100644 --- a/tests/config-field-docs.test.cjs +++ b/tests/config-field-docs.test.cjs @@ -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)' ); }); diff --git a/tests/dispatch/trace-correlation.test.cjs b/tests/dispatch/trace-correlation.test.cjs index 6c8a8ee1c..45d1e0aa7 100644 --- a/tests/dispatch/trace-correlation.test.cjs +++ b/tests/dispatch/trace-correlation.test.cjs @@ -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 ───────────────────────────────────────── diff --git a/tests/edge-probe-docs-fixtures.test.cjs b/tests/edge-probe-docs-fixtures.test.cjs index 3b848bca6..61654e665 100644 --- a/tests/edge-probe-docs-fixtures.test.cjs +++ b/tests/edge-probe-docs-fixtures.test.cjs @@ -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]; diff --git a/tests/enh-773-codex-exec-automation-flags.test.cjs b/tests/enh-773-codex-exec-automation-flags.test.cjs index 68779f187..ec187951d 100644 --- a/tests/enh-773-codex-exec-automation-flags.test.cjs +++ b/tests/enh-773-codex-exec-automation-flags.test.cjs @@ -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( diff --git a/tests/execute-phase-wave.test.cjs b/tests/execute-phase-wave.test.cjs index 8aaf9f58c..d4ae1cbba 100644 --- a/tests/execute-phase-wave.test.cjs +++ b/tests/execute-phase-wave.test.cjs @@ -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'); diff --git a/tests/feat-3025-mcp-token-budget-docs.test.cjs b/tests/feat-3025-mcp-token-budget-docs.test.cjs index efd8471fa..a9fe19630 100644 --- a/tests/feat-3025-mcp-token-budget-docs.test.cjs +++ b/tests/feat-3025-mcp-token-budget-docs.test.cjs @@ -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 = []; diff --git a/tests/feat-443-effort-install-wiring.install.test.cjs b/tests/feat-443-effort-install-wiring.install.test.cjs index 1dfac84ef..b8b9675d2 100644 --- a/tests/feat-443-effort-install-wiring.install.test.cjs +++ b/tests/feat-443-effort-install-wiring.install.test.cjs @@ -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 diff --git a/tests/fix-1464-docs-manifest-validation.test.cjs b/tests/fix-1464-docs-manifest-validation.test.cjs index 6ff7a1f80..78cd25df9 100644 --- a/tests/fix-1464-docs-manifest-validation.test.cjs +++ b/tests/fix-1464-docs-manifest-validation.test.cjs @@ -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; diff --git a/tests/gsd-check-update-worker-platform-gate.test.cjs b/tests/gsd-check-update-worker-platform-gate.test.cjs index f307c24fd..7547ba6df 100644 --- a/tests/gsd-check-update-worker-platform-gate.test.cjs +++ b/tests/gsd-check-update-worker-platform-gate.test.cjs @@ -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)', () => { diff --git a/tests/gsd-researcher-app-aware.test.cjs b/tests/gsd-researcher-app-aware.test.cjs index 18f898ae0..5c8bc501f 100644 --- a/tests/gsd-researcher-app-aware.test.cjs +++ b/tests/gsd-researcher-app-aware.test.cjs @@ -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]); diff --git a/tests/gsd-tools-path-refs.test.cjs b/tests/gsd-tools-path-refs.test.cjs index 1079d59d3..a3c2922c2 100644 --- a/tests/gsd-tools-path-refs.test.cjs +++ b/tests/gsd-tools-path-refs.test.cjs @@ -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()}`); diff --git a/tests/hardcoded-paths.test.cjs b/tests/hardcoded-paths.test.cjs index ed7b84533..c7e286dd0 100644 --- a/tests/hardcoded-paths.test.cjs +++ b/tests/hardcoded-paths.test.cjs @@ -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// 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// 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, [], diff --git a/tests/hermes-skills-migration.test.cjs b/tests/hermes-skills-migration.test.cjs index 92b0a9fa1..597aff31d 100644 --- a/tests/hermes-skills-migration.test.cjs +++ b/tests/hermes-skills-migration.test.cjs @@ -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(''), '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'); }); diff --git a/tests/init.test.cjs b/tests/init.test.cjs index bd9613c51..f0c1ad693 100644 --- a/tests/init.test.cjs +++ b/tests/init.test.cjs @@ -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'); diff --git a/tests/intel.test.cjs b/tests/intel.test.cjs index 104769332..235441afd 100644 --- a/tests/intel.test.cjs +++ b/tests/intel.test.cjs @@ -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, diff --git a/tests/inventory-headings-countfree.test.cjs b/tests/inventory-headings-countfree.test.cjs index 53335e567..722bd676f 100644 --- a/tests/inventory-headings-countfree.test.cjs +++ b/tests/inventory-headings-countfree.test.cjs @@ -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( diff --git a/tests/issue-2517-runtime-aware-profiles.test.cjs b/tests/issue-2517-runtime-aware-profiles.test.cjs index 84b3d43d4..dbca0dcc4 100644 --- a/tests/issue-2517-runtime-aware-profiles.test.cjs +++ b/tests/issue-2517-runtime-aware-profiles.test.cjs @@ -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; diff --git a/tests/milestone-summary.test.cjs b/tests/milestone-summary.test.cjs index 53fd4a459..bc358c7f3 100644 --- a/tests/milestone-summary.test.cjs +++ b/tests/milestone-summary.test.cjs @@ -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 diff --git a/tests/no-bare-npm-exec.rule.test.cjs b/tests/no-bare-npm-exec.rule.test.cjs new file mode 100644 index 000000000..d9c1ee46f --- /dev/null +++ b/tests/no-bare-npm-exec.rule.test.cjs @@ -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: [], + }); + }); +}); diff --git a/tests/no-crlf-fragile-split.rule.test.cjs b/tests/no-crlf-fragile-split.rule.test.cjs new file mode 100644 index 000000000..bfc73c712 --- /dev/null +++ b/tests/no-crlf-fragile-split.rule.test.cjs @@ -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: [], + }); + }); +}); diff --git a/tests/no-hardcoded-tmp.rule.test.cjs b/tests/no-hardcoded-tmp.rule.test.cjs new file mode 100644 index 000000000..0946176d7 --- /dev/null +++ b/tests/no-hardcoded-tmp.rule.test.cjs @@ -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.(...)` 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: [], + }); + }); +}); diff --git a/tests/observability/logger.test.cjs b/tests/observability/logger.test.cjs index 2abd79b70..74e6c075c 100644 --- a/tests/observability/logger.test.cjs +++ b/tests/observability/logger.test.cjs @@ -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); diff --git a/tests/package-legitimacy-gate.test.cjs b/tests/package-legitimacy-gate.test.cjs index a6af9a3a5..5d0a3e051 100644 --- a/tests/package-legitimacy-gate.test.cjs +++ b/tests/package-legitimacy-gate.test.cjs @@ -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'); diff --git a/tests/package-name-single-source.test.cjs b/tests/package-name-single-source.test.cjs index 8d3f5ba21..59b6b1714 100644 --- a/tests/package-name-single-source.test.cjs +++ b/tests/package-name-single-source.test.cjs @@ -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]; diff --git a/tests/phase.test.cjs b/tests/phase.test.cjs index fc70420fc..62c4c90c7 100644 --- a/tests/phase.test.cjs +++ b/tests/phase.test.cjs @@ -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', () => { diff --git a/tests/phase6-capstone-conformance.test.cjs b/tests/phase6-capstone-conformance.test.cjs index 4f3f580d1..0e5b32bdf 100644 --- a/tests/phase6-capstone-conformance.test.cjs +++ b/tests/phase6-capstone-conformance.test.cjs @@ -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.`); }); diff --git a/tests/plan-review-convergence.test.cjs b/tests/plan-review-convergence.test.cjs index d10fd6b14..1260a3016 100644 --- a/tests/plan-review-convergence.test.cjs +++ b/tests/plan-review-convergence.test.cjs @@ -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' diff --git a/tests/policy-138-nyquist-config-default.test.cjs b/tests/policy-138-nyquist-config-default.test.cjs index 827cd8f7e..0665e1d04 100644 --- a/tests/policy-138-nyquist-config-default.test.cjs +++ b/tests/policy-138-nyquist-config-default.test.cjs @@ -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] }; diff --git a/tests/policy-release-no-npm-self-upgrade.test.cjs b/tests/policy-release-no-npm-self-upgrade.test.cjs index 02794b0b8..b08a49801 100644 --- a/tests/policy-release-no-npm-self-upgrade.test.cjs +++ b/tests/policy-release-no-npm-self-upgrade.test.cjs @@ -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)); diff --git a/tests/portability-rule-disable-ban.test.cjs b/tests/portability-rule-disable-ban.test.cjs index 66a40798a..aee17ba98 100644 --- a/tests/portability-rule-disable-ban.test.cjs +++ b/tests/portability-rule-disable-ban.test.cjs @@ -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 ─────────────────────────── diff --git a/tests/product-name-purity.test.cjs b/tests/product-name-purity.test.cjs index 44b4ce53d..d834d1541 100644 --- a/tests/product-name-purity.test.cjs +++ b/tests/product-name-purity.test.cjs @@ -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]; diff --git a/tests/prohibition-probe.docs-fixtures.test.cjs b/tests/prohibition-probe.docs-fixtures.test.cjs index 43ffdbc76..3481a1c4a 100644 --- a/tests/prohibition-probe.docs-fixtures.test.cjs +++ b/tests/prohibition-probe.docs-fixtures.test.cjs @@ -24,7 +24,7 @@ const fixturesRoot = path.join(__dirname, '..', 'gsd-core', 'references', 'prohi // Extract fenced blocks tagged ```json prohibition-probe:/ 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]; diff --git a/tests/prompt-injection-scan.security.test.cjs b/tests/prompt-injection-scan.security.test.cjs index c32d98c97..751098e90 100644 --- a/tests/prompt-injection-scan.security.test.cjs +++ b/tests/prompt-injection-scan.security.test.cjs @@ -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)) { diff --git a/tests/qwen-skills-migration.test.cjs b/tests/qwen-skills-migration.test.cjs index c2ee81d80..77554761a 100644 --- a/tests/qwen-skills-migration.test.cjs +++ b/tests/qwen-skills-migration.test.cjs @@ -97,7 +97,7 @@ describe('Qwen Code: convertClaudeCommandToClaudeSkill', () => { }); test('preserves body content unchanged', () => { - const body = '\n\nDo the thing.\n\n\n\nStep 1.\nStep 2.\n\n'; + const body = '\n\nDo the thing.\n\r?\n\n\nStep 1.\nStep 2.\n\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:')); diff --git a/tests/reapply-patches.test.cjs b/tests/reapply-patches.test.cjs index db7832e3f..2b85c4bae 100644 --- a/tests/reapply-patches.test.cjs +++ b/tests/reapply-patches.test.cjs @@ -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 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); diff --git a/tests/release-coverage-scope.test.cjs b/tests/release-coverage-scope.test.cjs index f795a855a..438c8887c 100644 --- a/tests/release-coverage-scope.test.cjs +++ b/tests/release-coverage-scope.test.cjs @@ -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, diff --git a/tests/require-userprofile-with-home.rule.test.cjs b/tests/require-userprofile-with-home.rule.test.cjs new file mode 100644 index 000000000..231c76d40 --- /dev/null +++ b/tests/require-userprofile-with-home.rule.test.cjs @@ -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: [], + }); + }); +}); diff --git a/tests/roadmap.test.cjs b/tests/roadmap.test.cjs index 7061e9d4a..29102f5d8 100644 --- a/tests/roadmap.test.cjs +++ b/tests/roadmap.test.cjs @@ -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']); diff --git a/tests/runtime-launcher-parity.test.cjs b/tests/runtime-launcher-parity.test.cjs index 6aa0b4b8e..bc40ceabb 100644 --- a/tests/runtime-launcher-parity.test.cjs +++ b/tests/runtime-launcher-parity.test.cjs @@ -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, diff --git a/tests/secret-scan-lint.security.test.cjs b/tests/secret-scan-lint.security.test.cjs index 8dd6a4ff6..49f551567 100644 --- a/tests/secret-scan-lint.security.test.cjs +++ b/tests/secret-scan-lint.security.test.cjs @@ -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}` diff --git a/tests/secure-phase.test.cjs b/tests/secure-phase.test.cjs index 01e0826df..109faa40a 100644 --- a/tests/secure-phase.test.cjs +++ b/tests/secure-phase.test.cjs @@ -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( diff --git a/tests/security-scan.security.test.cjs b/tests/security-scan.security.test.cjs index ac93714b0..e1dec6d03 100644 --- a/tests/security-scan.security.test.cjs +++ b/tests/security-scan.security.test.cjs @@ -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('${{'), diff --git a/tests/spawn-liveness-banner.test.cjs b/tests/spawn-liveness-banner.test.cjs index fa027c096..cf212f8d9 100644 --- a/tests/spawn-liveness-banner.test.cjs +++ b/tests/spawn-liveness-banner.test.cjs @@ -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++) { diff --git a/tests/state.test.cjs b/tests/state.test.cjs index 5025075ec..c39fbfd92 100644 --- a/tests/state.test.cjs +++ b/tests/state.test.cjs @@ -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]; diff --git a/tests/subagent-timeout.test.cjs b/tests/subagent-timeout.test.cjs index 9db81a8f3..5e4198bee 100644 --- a/tests/subagent-timeout.test.cjs +++ b/tests/subagent-timeout.test.cjs @@ -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*$/), diff --git a/tests/windows-test-parity-guard.test.cjs b/tests/windows-test-parity-guard.test.cjs deleted file mode 100644 index fd8eb30f0..000000000 --- a/tests/windows-test-parity-guard.test.cjs +++ /dev/null @@ -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, - }); - }); -}); diff --git a/tests/workspace.test.cjs b/tests/workspace.test.cjs index a9319853e..1e1a57af9 100644 --- a/tests/workspace.test.cjs +++ b/tests/workspace.test.cjs @@ -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 diff --git a/tests/worktree-cleanup.test.cjs b/tests/worktree-cleanup.test.cjs index 467540e41..fd60b60cb 100644 --- a/tests/worktree-cleanup.test.cjs +++ b/tests/worktree-cleanup.test.cjs @@ -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();