@opengsd/gsd-core 1.6.0 → 1.7.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/agents/gsd-verifier.md +1 -0
  3. package/bin/gsd-mcp-server.js +31 -0
  4. package/bin/install.js +293 -1145
  5. package/commands/gsd/review.md +6 -0
  6. package/gemini-extension.json +1 -1
  7. package/gsd-core/bin/gsd-tools.cjs +116 -1
  8. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  9. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  10. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  11. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  12. package/gsd-core/bin/lib/capability-loader.cjs +18 -0
  13. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  14. package/gsd-core/bin/lib/capability-registry.cjs +889 -82
  15. package/gsd-core/bin/lib/capability-source.cjs +4 -4
  16. package/gsd-core/bin/lib/capability-validator.cjs +198 -0
  17. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  18. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  19. package/gsd-core/bin/lib/config.cjs +27 -0
  20. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  21. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  22. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  23. package/gsd-core/bin/lib/host-integration.cjs +408 -0
  24. package/gsd-core/bin/lib/init.cjs +1 -1
  25. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  26. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  27. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  28. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  29. package/gsd-core/bin/lib/milestone.cjs +27 -30
  30. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  31. package/gsd-core/bin/lib/phase.cjs +41 -72
  32. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  33. package/gsd-core/bin/lib/probe-core.cjs +91 -1
  34. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  35. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  36. package/gsd-core/bin/lib/roadmap.cjs +17 -3
  37. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  38. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  39. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  40. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  41. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  42. package/gsd-core/bin/lib/shell-command-projection.cjs +37 -1
  43. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  44. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  45. package/gsd-core/bin/lib/state-io.cjs +55 -0
  46. package/gsd-core/bin/lib/state-transition.cjs +1588 -0
  47. package/gsd-core/bin/lib/state.cjs +306 -681
  48. package/gsd-core/bin/lib/surface.cjs +4 -1
  49. package/gsd-core/bin/lib/workstream.cjs +4 -4
  50. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  51. package/gsd-core/references/honest-verifier.md +105 -0
  52. package/gsd-core/references/reviewer-instances.md +99 -0
  53. package/gsd-core/workflows/autonomous.md +9 -9
  54. package/gsd-core/workflows/manager.md +15 -15
  55. package/gsd-core/workflows/plan-phase.md +1 -1
  56. package/gsd-core/workflows/review.md +26 -0
  57. package/gsd-core/workflows/thread.md +4 -4
  58. package/gsd-core/workflows/verify-phase.md +11 -4
  59. package/hooks/dist/gsd-graphify-update.sh +7 -1
  60. package/hooks/gsd-graphify-update.sh +7 -1
  61. package/package.json +4 -4
  62. package/scripts/ci-test-scope.cjs +38 -9
  63. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  64. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  65. package/scripts/lint-test-file-count.allowlist.json +19 -5
  66. package/scripts/mutation-matrix.cjs +45 -3
  67. package/scripts/prompt-injection-scan.sh +8 -0
  68. package/scripts/lint-windows-test-portability.cjs +0 -178
package/package.json CHANGED
@@ -1,11 +1,12 @@
1
1
  {
2
2
  "name": "@opengsd/gsd-core",
3
- "version": "1.6.0",
3
+ "version": "1.7.0-rc.1",
4
4
  "description": "GSD Core is a meta-prompting, context engineering, and spec-driven development system for AI coding agents.",
5
5
  "bin": {
6
6
  "gsd-core": "bin/install.js",
7
7
  "gsd-tools": "gsd-core/bin/gsd-tools.cjs",
8
- "gsd_run": "gsd-core/bin/gsd_run"
8
+ "gsd_run": "gsd-core/bin/gsd_run",
9
+ "gsd-mcp-server": "bin/gsd-mcp-server.js"
9
10
  },
10
11
  "files": [
11
12
  "bin",
@@ -94,9 +95,8 @@
94
95
  "pretest:coverage": "npm run build:lib && npm run lint:skill-deps",
95
96
  "lint": "eslint . --cache --cache-location node_modules/.cache/eslint/",
96
97
  "lint:fix": "eslint . --fix",
97
- "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-windows-test-portability.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
98
+ "lint:ci": "npm run lint && npm run lint:skill-deps && node scripts/lint-test-file-count.cjs && node scripts/lint-command-contract.cjs && node scripts/lint-pr-check-project-dir.cjs && npm run lint:legacy-name && node scripts/lint-regression-test-names.cjs && node scripts/lint-allow-test-rule-refs.cjs && node scripts/lint-resolution-provenance.cjs",
98
99
  "lint:allow-test-rule-refs": "node scripts/lint-allow-test-rule-refs.cjs",
99
- "lint:windows-test-portability": "node scripts/lint-windows-test-portability.cjs",
100
100
  "lint:regression-names": "node scripts/lint-regression-test-names.cjs",
101
101
  "lint:descriptions": "node scripts/lint-descriptions.cjs",
102
102
  "lint:skill-deps": "node scripts/lint-skill-deps.cjs",
@@ -118,6 +118,7 @@ const RULES = [
118
118
  tests: [
119
119
  'tests/semver-compare.test.cjs',
120
120
  'tests/bug-10-semver-policy-consolidation.test.cjs',
121
+ 'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
121
122
  ],
122
123
  },
123
124
  {
@@ -134,6 +135,7 @@ const RULES = [
134
135
  'tests/install-path-detection.test.cjs',
135
136
  'tests/release-tarball-smoke.install.test.cjs',
136
137
  'tests/runtime-artifact-layout.test.cjs',
138
+ 'tests/golden-install-parity.test.cjs', // any src/installer change can alter emitted install artifacts → re-verify golden install parity (drift guard)
137
139
  ],
138
140
  },
139
141
  {
@@ -212,17 +214,44 @@ const RULES = [
212
214
  ],
213
215
  },
214
216
  {
215
- name: 'configuration',
216
- match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
217
+ name: 'configuration',
218
+ match: path => ['config', 'configuration', 'model-catalog', 'model-profile'].some(k => path.includes(k)),
219
+ tests: [
220
+ 'tests/config.test.cjs',
221
+ 'tests/config-get-default.test.cjs',
222
+ 'tests/configuration-migrate-config.test.cjs',
223
+ 'tests/model-catalog-runtime-defaults.test.cjs',
224
+ 'tests/model-profiles.test.cjs',
225
+ ],
226
+ },
227
+ {
228
+ // ADR-1703 portability lint surface. Editing a rule, the shared vocab/guard
229
+ // helpers, or the eslint config that wires them must re-run the rule suites
230
+ // + the disable-ban. The disable-ban also scans bin/install.js and
231
+ // scripts/build-hooks.js (the Phase 6 glob-expansion surface), so changes
232
+ // to those files re-run it too.
233
+ name: 'portability lint rules (ADR-1703)',
234
+ match: path => path.startsWith('eslint-rules/') ||
235
+ path === 'eslint.config.mjs' ||
236
+ path === 'bin/install.js' ||
237
+ path === 'scripts/build-hooks.js',
217
238
  tests: [
218
- 'tests/config.test.cjs',
219
- 'tests/config-get-default.test.cjs',
220
- 'tests/configuration-migrate-config.test.cjs',
221
- 'tests/model-catalog-runtime-defaults.test.cjs',
222
- 'tests/model-profiles.test.cjs',
239
+ 'tests/portability-rule-disable-ban.test.cjs',
240
+ 'tests/portability-vocab-drift.test.cjs',
241
+ // All nine RuleTester suites (P1–P6) — editing any rule / the shared
242
+ // vocab+guard helpers / the eslint config re-runs the full rule family.
243
+ 'tests/no-path-literal-in-assert.rule.test.cjs',
244
+ 'tests/no-posix-mode-bit-assert.rule.test.cjs',
245
+ 'tests/no-unguarded-nonportable-exec.rule.test.cjs',
246
+ 'tests/no-crlf-fragile-split.rule.test.cjs',
247
+ 'tests/no-hardcoded-tmp.rule.test.cjs',
248
+ 'tests/no-bare-npm-exec.rule.test.cjs',
249
+ 'tests/require-userprofile-with-home.rule.test.cjs',
250
+ 'tests/normalize-path-in-content.rule.test.cjs',
251
+ 'tests/require-fs-op-fallback.rule.test.cjs',
223
252
  ],
224
253
  },
225
- ];
254
+ ];
226
255
 
227
256
  function usage() {
228
257
  return [
@@ -329,7 +358,7 @@ function classify(files) {
329
358
  // Determine if this file is product/pipeline code.
330
359
  // docs/ and root-level .md files are intentionally excluded.
331
360
  if (
332
- ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/'].some(p => file.startsWith(p)) ||
361
+ ['bin/', 'src/', 'gsd-core/', 'agents/', 'commands/', 'hooks/', 'tests/', 'scripts/', 'eslint-rules/'].some(p => file.startsWith(p)) ||
333
362
  file === 'package.json' || file === 'package-lock.json' ||
334
363
  (file.startsWith('tsconfig') && file.endsWith('.json')) ||
335
364
  file.startsWith('.github/rulesets/')
@@ -313,7 +313,6 @@
313
313
  "tests/verify-test-quality.test.cjs :: source-text-is-the-product",
314
314
  "tests/verify-work-auto-transition.test.cjs :: source-text-is-the-product",
315
315
  "tests/windows-robustness.test.cjs :: source-text-is-the-product",
316
- "tests/windows-test-parity-guard.test.cjs :: structural-regression-guard",
317
316
  "tests/workflow-compat.test.cjs :: source-text-is-the-product",
318
317
  "tests/workflow-guard-registration.test.cjs :: structural-regression-guard",
319
318
  "tests/workflow-maintainer-skip.test.cjs :: source-text-is-the-product",
@@ -5,10 +5,13 @@
5
5
  "bug-1367-claude-local-flat-command-layout.test.cjs",
6
6
  "bug-14-progress-auto-flag-dropped.test.cjs",
7
7
  "bug-167-query-meta-command.test.cjs",
8
+ "bug-1695-state-patch-clobbers-phase-name.test.cjs",
8
9
  "bug-17-askuserquestion-option-cap.test.cjs",
9
10
  "bug-170-workflow-fallback-install-hint.test.cjs",
10
11
  "bug-1736-local-install-commands.test.cjs",
11
12
  "bug-1754-js-hook-guard.test.cjs",
13
+ "bug-1760-state-prune-noop-template-field.test.cjs",
14
+ "bug-1761-state-sync-wrong-progress.test.cjs",
12
15
  "bug-1817-sh-hook-guard.test.cjs",
13
16
  "bug-1818-unknown-flags.test.cjs",
14
17
  "bug-1826-phases-clear-confirm.test.cjs",
@@ -99,6 +99,9 @@
99
99
  },
100
100
  "state": {
101
101
  "files": [
102
+ "bug-1695-state-patch-clobbers-phase-name.test.cjs",
103
+ "bug-1760-state-prune-noop-template-field.test.cjs",
104
+ "bug-1761-state-sync-wrong-progress.test.cjs",
102
105
  "bug-21-state-md-template-frontmatter.test.cjs",
103
106
  "bug-2630-state-frontmatter-milestone-switch.test.cjs",
104
107
  "bug-3127-state-begin-phase-idempotent.test.cjs",
@@ -107,10 +110,12 @@
107
110
  "bug-3454-state-dollar-backreference-growth.test.cjs",
108
111
  "bug-397-state-preserve-executor-authored.test.cjs",
109
112
  "bug-905-state-syncstatefrontmatter-preserve-scalars.test.cjs",
110
- "bug-948-state-noop-write-guard.test.cjs",
111
- "state-acquirestatelock-non-eexist.test.cjs",
112
- "state-prune.test.cjs",
113
- "state.test.cjs"
113
+ "bug-948-state-noop-write-guard.test.cjs",
114
+ "state-acquirestatelock-non-eexist.test.cjs",
115
+ "state-prune.test.cjs",
116
+ "state-rebuild-cli.test.cjs",
117
+ "state-rebuild.test.cjs",
118
+ "state.test.cjs"
114
119
  ],
115
120
  "issue": "180"
116
121
  },
@@ -142,9 +147,10 @@
142
147
  "install-regressions.test.cjs",
143
148
  "install-runtime-artifacts.test.cjs",
144
149
  "install-update-marker.test.cjs",
150
+ "install-write-confinement.test.cjs",
145
151
  "install.test.cjs"
146
152
  ],
147
- "issue": "TBD"
153
+ "issue": "1679"
148
154
  },
149
155
  "validate": {
150
156
  "files": [
@@ -184,6 +190,14 @@
184
190
  "fix-1464-docs-manifest-validation.test.cjs"
185
191
  ],
186
192
  "issue": "1496"
193
+ },
194
+ "host-integration": {
195
+ "files": [
196
+ "host-integration.test.cjs",
197
+ "host-integration-validator-parity.test.cjs",
198
+ "host-integration-descriptors.test.cjs"
199
+ ],
200
+ "issue": "1684"
187
201
  }
188
202
  }
189
203
  }
@@ -30,10 +30,52 @@
30
30
  */
31
31
 
32
32
  const { execFileSync } = require('child_process');
33
- const { readFileSync } = require('fs');
33
+ const fs = require('fs');
34
34
 
35
35
  const { ExitError, runMain } = require('./lib/cli-exit.cjs');
36
36
 
37
+ // ── Resilient stdin reader ────────────────────────────────────────────────────
38
+ // On macOS, libuv sets the stdin pipe fd to non-blocking mode. A synchronous
39
+ // readFileSync(process.stdin.fd) can therefore throw EAGAIN ("resource
40
+ // temporarily unavailable") when the writer hasn't yet filled the pipe — this
41
+ // is intermittent under heavy CI shard load and causes a spurious status 2
42
+ // exit. We work around it by calling fs.readSync in a loop and retrying on
43
+ // EAGAIN with a 1 ms synchronous pause (Atomics.wait on a fresh SharedArrayBuffer
44
+ // — no hot spin, no real-clock dependency, works under --experimental-vm-modules).
45
+ /**
46
+ * Read all of stdin synchronously, retrying on EAGAIN.
47
+ *
48
+ * @returns {string} UTF-8 decoded full stdin content.
49
+ */
50
+ function readStdinSync() {
51
+ const BUF_SIZE = 64 * 1024; // 64 KB chunks
52
+ const buf = Buffer.allocUnsafe(BUF_SIZE);
53
+ const chunks = [];
54
+
55
+ for (;;) {
56
+ let bytesRead;
57
+ try {
58
+ bytesRead = fs.readSync(process.stdin.fd, buf, 0, BUF_SIZE, null);
59
+ } catch (err) {
60
+ if (err.code === 'EAGAIN') {
61
+ // Non-blocking pipe not yet ready — yield for ~1 ms then retry.
62
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, 1);
63
+ continue;
64
+ }
65
+ if (err.code === 'EOF') {
66
+ break;
67
+ }
68
+ throw err;
69
+ }
70
+ if (bytesRead === 0) {
71
+ break; // Clean EOF
72
+ }
73
+ chunks.push(Buffer.from(buf.slice(0, bytesRead)));
74
+ }
75
+
76
+ return Buffer.concat(chunks).toString('utf8');
77
+ }
78
+
37
79
  // ── Per-module mutation score ratchet ─────────────────────────────────────────
38
80
  // ADR-456 / issue #1187: every covered module declares a minScore floor.
39
81
  //
@@ -195,7 +237,7 @@ function resolveChangedFiles(args) {
195
237
  // When --base is absent AND stdin is not a TTY (isTTY is falsy / undefined),
196
238
  // read a newline-delimited file list from stdin.
197
239
  if (!args.base && process.stdin.isTTY !== true) {
198
- const raw = readFileSync(process.stdin.fd, 'utf8');
240
+ const raw = readStdinSync();
199
241
  return raw.split('\n').map(l => l.trim()).filter(Boolean);
200
242
  }
201
243
 
@@ -318,6 +360,6 @@ function resolveMutationBreak(raw) {
318
360
 
319
361
  // Export internals for programmatic use (tests/mutation-matrix-ratchet.test.cjs).
320
362
  // The require.main guard prevents main() from running when this file is require()d.
321
- module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak };
363
+ module.exports = { COVERED, TARGET_MUTATION_SCORE, resolveMutationBreak, readStdinSync };
322
364
 
323
365
  if (require.main === module) runMain(main);
@@ -94,6 +94,14 @@ ALLOWLIST=(
94
94
  # real injection payloads to prove the validator rejects them. See
95
95
  # DEFECT.PROMPT-INJECTION-SCAN-COLLISION in CONTEXT.md.
96
96
  'tests/windsurf-conversion.test.cjs'
97
+ # RuleTester fixtures for the local/no-unguarded-nonportable-exec ESLint rule
98
+ # contain shell-exec command strings (exec("sh -c …"), execFileSync('bash',['-c',…]))
99
+ # as test DATA the rule must lint — not attack vectors. ADR-1703 Phase 3 (#1720).
100
+ 'tests/no-unguarded-nonportable-exec.rule.test.cjs'
101
+ # RuleTester fixtures for the local/no-bare-npm-exec ESLint rule contain npm
102
+ # exec command strings (execFileSync('npm', ['install'])) as test DATA the rule
103
+ # must lint — not attack vectors. ADR-1703 Phase 4 (#1726).
104
+ 'tests/no-bare-npm-exec.rule.test.cjs'
97
105
  )
98
106
 
99
107
  is_allowlisted() {
@@ -1,178 +0,0 @@
1
- 'use strict';
2
-
3
- /**
4
- * lint-windows-test-portability.cjs — flag tests that combine chmod exec-bit
5
- * with bare sh/bash -c without a platform guard.
6
- *
7
- * ## Why
8
- *
9
- * Windows Git Bash (msys2) does not honour Node's chmod exec bit for
10
- * PATH-executing extension-less scripts. A test that (a) makes a fixture
11
- * executable via chmodSync and (b) runs it with `sh -c`/`bash -c` will pass
12
- * on Mac/Linux but fail only in the CI `test (windows-latest, *)` /
13
- * `full test (windows-latest, *)` lanes, producing a hard-to-diagnose
14
- * false-negative gate. See CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY.
15
- *
16
- * ## What this enforces
17
- *
18
- * For every file in tests/**\/*.test.cjs (recursive, excluding node_modules):
19
- * - makesExecutable: contains chmodSync?( with an exec-bit octal literal
20
- * - shellDashC: contains a sh/bash -c invocation (array form or string literal)
21
- * - guarded: contains a process.platform / os.platform() / win32 / isWindows guard
22
- * - optOut: contains the literal `windows-portability-ok`
23
- * VIOLATION = makesExecutable && shellDashC && !guarded && !optOut
24
- *
25
- * ## Remediation
26
- *
27
- * Gate the bare-command execution with `if (process.platform !== 'win32')`,
28
- * or invoke via an explicit interpreter (`sh <path>`), or annotate
29
- * `// windows-portability-ok: <reason>`.
30
- *
31
- * ## Export contract (for unit tests)
32
- *
33
- * When required as a module (`require.main !== module`) this file exports
34
- * `scanContent(source)` → { makesExecutable, shellDashC, guarded, optOut, violation }.
35
- */
36
-
37
- const fs = require('fs');
38
- const path = require('path');
39
-
40
- // ─── Detection regexes ──────────────────────────────────────────────────────
41
-
42
- /**
43
- * Match chmod/chmodSync( calls with an octal mode literal whose exec bits are
44
- * set, e.g. `fs.chmodSync(p, 0o755)` or `chmod(file, 0o111)`.
45
- */
46
- const CHMOD_RE = /chmod(?:Sync)?\s*\([^,;]+,\s*0o([0-7]{3})\b/g;
47
-
48
- /**
49
- * Array form: execFileSync/spawnSync/exec* with 'sh' or 'bash' (optionally
50
- * prefixed) as the first arg and '-c' as an element of the args array.
51
- * e.g. execFileSync('bash', ['-c', ...]) or spawnSync('/bin/sh', ['-c', ...])
52
- */
53
- const SHELL_ARRAY_RE =
54
- /(?:execFile(?:Sync)?|spawnSync|spawn|exec)\s*\(\s*['"`](?:\/(?:usr\/)?bin\/)?(?:bash|sh)['"`]\s*,\s*\[[^\]]*['"]-c['"]/;
55
-
56
- /**
57
- * String-literal form: any string containing `bash -c` or `sh -c`.
58
- */
59
- const SHELL_STRING_RE = /['"`][^'"`\n]*(?:bash|sh)\s+-c[^'"`\n]*['"`]/;
60
-
61
- /** Platform guard presence. */
62
- const GUARD_RE = /process\.platform|os\.platform\s*\(|\bwin32\b|\bisWindows\b/;
63
-
64
- /** Opt-out annotation. */
65
- const OPT_OUT_RE = /windows-portability-ok/;
66
-
67
- // ─── Pure scanning function (exported for unit tests) ────────────────────────
68
-
69
- /**
70
- * Scan a single file's source text and return detection flags.
71
- *
72
- * @param {string} source - The file contents as a string.
73
- * @returns {{ makesExecutable: boolean, shellDashC: boolean, guarded: boolean, optOut: boolean, violation: boolean }}
74
- */
75
- function scanContent(source) {
76
- // Reset stateful regex before use.
77
- CHMOD_RE.lastIndex = 0;
78
-
79
- let makesExecutable = false;
80
- let match;
81
- while ((match = CHMOD_RE.exec(source)) !== null) {
82
- const oct = match[1];
83
- if ((parseInt(oct, 8) & 0o111) !== 0) {
84
- makesExecutable = true;
85
- break;
86
- }
87
- }
88
-
89
- const shellDashC = SHELL_ARRAY_RE.test(source) || SHELL_STRING_RE.test(source);
90
- const guarded = GUARD_RE.test(source);
91
- const optOut = OPT_OUT_RE.test(source);
92
- const violation = makesExecutable && shellDashC && !guarded && !optOut;
93
-
94
- return { makesExecutable, shellDashC, guarded, optOut, violation };
95
- }
96
-
97
- // ─── Filesystem walker ───────────────────────────────────────────────────────
98
-
99
- /**
100
- * Recursively collect all *.test.cjs files under `dir`, excluding node_modules.
101
- *
102
- * @param {string} dir
103
- * @param {string[]} [acc]
104
- * @returns {string[]}
105
- */
106
- function collectTestFiles(dir, acc) {
107
- acc = acc || [];
108
- let entries;
109
- try {
110
- entries = fs.readdirSync(dir, { withFileTypes: true });
111
- } catch {
112
- return acc;
113
- }
114
- for (const entry of entries) {
115
- if (entry.name === 'node_modules') continue;
116
- const full = path.join(dir, entry.name);
117
- if (entry.isDirectory()) {
118
- collectTestFiles(full, acc);
119
- } else if (entry.isFile() && entry.name.endsWith('.test.cjs')) {
120
- acc.push(full);
121
- }
122
- }
123
- return acc;
124
- }
125
-
126
- // ─── Main ────────────────────────────────────────────────────────────────────
127
-
128
- function main() {
129
- const ROOT = path.join(__dirname, '..');
130
- const TESTS_DIR = path.join(ROOT, 'tests');
131
-
132
- const files = collectTestFiles(TESTS_DIR);
133
- const violations = [];
134
-
135
- for (const file of files) {
136
- let source;
137
- try {
138
- source = fs.readFileSync(file, 'utf8');
139
- } catch {
140
- continue;
141
- }
142
- const { violation } = scanContent(source);
143
- if (violation) {
144
- const rel = path.relative(ROOT, file).replace(/\\/g, '/');
145
- violations.push(rel);
146
- }
147
- }
148
-
149
- if (violations.length > 0) {
150
- for (const rel of violations) {
151
- process.stderr.write(
152
- `${rel}: chmod-executable + sh/bash -c with no platform guard\n`,
153
- );
154
- }
155
- process.stderr.write(
156
- '\nWindows Git Bash does not honor Node\'s chmod exec bit for ' +
157
- 'PATH-executing extension-less scripts ' +
158
- '(CONTEXT.md → DEFECT.WINDOWS-TEST-PORTABILITY). ' +
159
- 'Gate the bare-command execution with ' +
160
- '`if (process.platform !== \'win32\')`, or invoke via an explicit ' +
161
- 'interpreter (`sh <path>`), or annotate ' +
162
- '`// windows-portability-ok: <reason>`.\n',
163
- );
164
- process.exitCode = 1;
165
- } else {
166
- console.log(
167
- `ok lint-windows-test-portability: ${files.length} file(s) scanned, no violations`,
168
- );
169
- }
170
- }
171
-
172
- // ─── Module boundary ─────────────────────────────────────────────────────────
173
-
174
- if (require.main === module) {
175
- main();
176
- } else {
177
- module.exports = { scanContent };
178
- }