@opengsd/gsd-core 1.6.1 → 1.7.0-rc.2

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 (119) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +1 -1
  3. package/.opencode/plugins/gsd-core.js +711 -0
  4. package/agents/gsd-advisor-researcher.md +2 -0
  5. package/agents/gsd-ai-researcher.md +1 -1
  6. package/agents/gsd-assumptions-analyzer.md +2 -0
  7. package/agents/gsd-code-fixer.md +2 -0
  8. package/agents/gsd-code-reviewer.md +2 -0
  9. package/agents/gsd-codebase-mapper.md +2 -0
  10. package/agents/gsd-debugger.md +2 -0
  11. package/agents/gsd-doc-writer.md +2 -0
  12. package/agents/gsd-eval-auditor.md +2 -0
  13. package/agents/gsd-executor.md +9 -6
  14. package/agents/gsd-integration-checker.md +2 -0
  15. package/agents/gsd-nyquist-auditor.md +2 -0
  16. package/agents/gsd-phase-researcher.md +2 -0
  17. package/agents/gsd-plan-checker.md +2 -0
  18. package/agents/gsd-planner.md +2 -0
  19. package/agents/gsd-project-researcher.md +2 -0
  20. package/agents/gsd-research-synthesizer.md +2 -0
  21. package/agents/gsd-roadmapper.md +2 -0
  22. package/agents/gsd-security-auditor.md +2 -0
  23. package/agents/gsd-ui-auditor.md +2 -0
  24. package/agents/gsd-ui-checker.md +2 -0
  25. package/agents/gsd-ui-researcher.md +2 -0
  26. package/agents/gsd-verifier.md +5 -2
  27. package/bin/gsd-mcp-server.js +31 -0
  28. package/bin/install.js +411 -1146
  29. package/commands/gsd/review.md +6 -0
  30. package/gemini-extension.json +1 -1
  31. package/gsd-core/bin/gsd-tools.cjs +134 -8
  32. package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
  33. package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
  34. package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
  35. package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
  36. package/gsd-core/bin/lib/capability-loader.cjs +45 -9
  37. package/gsd-core/bin/lib/capability-lock.cjs +2 -2
  38. package/gsd-core/bin/lib/capability-registry.cjs +891 -82
  39. package/gsd-core/bin/lib/capability-source.cjs +26 -11
  40. package/gsd-core/bin/lib/capability-validator.cjs +222 -2
  41. package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
  42. package/gsd-core/bin/lib/command-aliases.cjs +8 -0
  43. package/gsd-core/bin/lib/commands.cjs +2 -1
  44. package/gsd-core/bin/lib/config.cjs +27 -0
  45. package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
  46. package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
  47. package/gsd-core/bin/lib/frontmatter.cjs +53 -6
  48. package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
  49. package/gsd-core/bin/lib/hook-bus.cjs +81 -0
  50. package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
  51. package/gsd-core/bin/lib/host-integration.cjs +469 -0
  52. package/gsd-core/bin/lib/init.cjs +35 -7
  53. package/gsd-core/bin/lib/install-engine.cjs +755 -0
  54. package/gsd-core/bin/lib/install-profiles.cjs +35 -4
  55. package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
  56. package/gsd-core/bin/lib/mcp-server.cjs +194 -0
  57. package/gsd-core/bin/lib/milestone.cjs +68 -40
  58. package/gsd-core/bin/lib/model-adapter.cjs +50 -0
  59. package/gsd-core/bin/lib/phase-id.cjs +18 -0
  60. package/gsd-core/bin/lib/phase.cjs +57 -90
  61. package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
  62. package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
  63. package/gsd-core/bin/lib/probe-core.cjs +132 -2
  64. package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
  65. package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
  66. package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
  67. package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
  68. package/gsd-core/bin/lib/roadmap.cjs +33 -22
  69. package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
  70. package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
  71. package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
  72. package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
  73. package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
  74. package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
  75. package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
  76. package/gsd-core/bin/lib/state-command-router.cjs +4 -0
  77. package/gsd-core/bin/lib/state-io.cjs +55 -0
  78. package/gsd-core/bin/lib/state-transition.cjs +1603 -0
  79. package/gsd-core/bin/lib/state.cjs +327 -683
  80. package/gsd-core/bin/lib/surface.cjs +4 -1
  81. package/gsd-core/bin/lib/validate.cjs +2 -1
  82. package/gsd-core/bin/lib/verify.cjs +6 -4
  83. package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
  84. package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
  85. package/gsd-core/bin/lib/workstream.cjs +4 -4
  86. package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
  87. package/gsd-core/references/agent-skills-bootstrap.md +60 -0
  88. package/gsd-core/references/honest-verifier.md +105 -0
  89. package/gsd-core/references/model-profiles.md +27 -0
  90. package/gsd-core/references/reviewer-instances.md +99 -0
  91. package/gsd-core/workflows/autonomous.md +30 -32
  92. package/gsd-core/workflows/complete-milestone.md +6 -10
  93. package/gsd-core/workflows/execute-phase.md +1 -1
  94. package/gsd-core/workflows/forensics.md +3 -3
  95. package/gsd-core/workflows/help/modes/full.md +1 -1
  96. package/gsd-core/workflows/manager.md +15 -15
  97. package/gsd-core/workflows/milestone-summary.md +3 -3
  98. package/gsd-core/workflows/new-milestone.md +6 -0
  99. package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
  100. package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
  101. package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
  102. package/gsd-core/workflows/plan-phase.md +4 -159
  103. package/gsd-core/workflows/review.md +33 -2
  104. package/gsd-core/workflows/thread.md +4 -4
  105. package/gsd-core/workflows/verify-phase.md +11 -4
  106. package/gsd-core/workflows/verify-work.md +1 -2
  107. package/hooks/dist/gsd-graphify-update.sh +7 -1
  108. package/hooks/gsd-graphify-update.sh +7 -1
  109. package/package.json +6 -4
  110. package/scripts/ci-test-scope.cjs +38 -9
  111. package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
  112. package/scripts/lint-regression-test-names.allowlist.json +3 -0
  113. package/scripts/lint-test-file-count.allowlist.json +19 -5
  114. package/scripts/mutation-matrix.cjs +45 -3
  115. package/scripts/prompt-injection-scan.sh +8 -0
  116. package/scripts/run-tests.cjs +51 -1
  117. package/scripts/sync-manifest-versions.cjs +66 -14
  118. package/skills/gsd-review/SKILL.md +6 -0
  119. package/scripts/lint-windows-test-portability.cjs +0 -178
@@ -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() {
@@ -119,6 +119,51 @@ function ensureBuiltArtifacts(overrides = {}) {
119
119
  }
120
120
  }
121
121
  }
122
+
123
+ // hooks/dist/ is gitignored (.gitignore) and NOT built by `prepare`
124
+ // (npm run build:lib only) — only the full `build`/`prepublishOnly` scripts run
125
+ // build:hooks. So on a clean checkout + `npm ci` (fresh CI, incl. the scoped
126
+ // test lane) hooks/dist starts absent. Install tests (e.g.
127
+ // bug-3683-workflow-colon-namespace-leak) spawn `install.js --<runtime> --local`
128
+ // which copies hooks from hooks/dist/ and then verifyInstalled() hard-fails if
129
+ // the target hooks dir is empty. build-hooks.js `build()` creates DIST_DIR
130
+ // empty and fills it file-by-file, so the FIRST on-demand build (triggered by
131
+ // whichever concurrent install test's before() hook runs first) exposes a
132
+ // window where hooks/dist exists but is empty/partial. A concurrently-spawned
133
+ // install reader observes zero hooks -> "Failed to install hooks: directory is
134
+ // empty" -> intermittent scoped-lane failure (full lanes dodge it only by luck
135
+ // of a hooks-builder finishing early). Building hooks/dist ONCE here — the same
136
+ // upfront chokepoint as ensureBuiltArtifacts, single-process with no concurrent
137
+ // readers — fully populates dist before any test runs, closing the first-build
138
+ // empty window everywhere (CI scoped/unit shards + local). Subsequent on-demand
139
+ // rebuilds only atomically replace individual files (per-file rename in
140
+ // build-hooks.js) and never re-empty the dir, so they stay safe.
141
+ function ensureBuiltHooks(overrides = {}) {
142
+ const { existsSync, statSync } = require('fs');
143
+ const root = overrides.root || join(__dirname, '..');
144
+ const distDir = overrides.distDir || join(root, 'hooks', 'dist');
145
+ const hookNames = overrides.hookNames || require('./build-hooks.js').HOOKS_TO_COPY;
146
+ const runBuild = overrides.runBuild || (() => {
147
+ execFileSync(process.execPath, [join(root, 'scripts', 'build-hooks.js')], {
148
+ cwd: root,
149
+ stdio: 'inherit',
150
+ });
151
+ });
152
+
153
+ // dist is "complete" only if every expected hook exists as a non-empty file.
154
+ // Absent dir, empty dir, or a missing/zero-byte hook all trigger a rebuild.
155
+ const complete = existsSync(distDir) && hookNames.every((hook) => {
156
+ const p = join(distDir, hook);
157
+ try {
158
+ return existsSync(p) && statSync(p).size > 0;
159
+ } catch {
160
+ return false;
161
+ }
162
+ });
163
+ if (!complete) {
164
+ runBuild();
165
+ }
166
+ }
122
167
  const MARKED_SUITES = ['integration', 'install', 'security', 'slow'];
123
168
 
124
169
  // Recursively collect *.test.cjs files under dir, returning paths relative to dir.
@@ -454,6 +499,11 @@ function main() {
454
499
  // Build the gitignored bin/lib artifact if absent, before any test requires it.
455
500
  ensureBuiltArtifacts();
456
501
 
502
+ // Build the gitignored hooks/dist artifact once, before any concurrent install
503
+ // test spawns install.js and reads it — closes the first-build empty-dir race
504
+ // that intermittently failed the scoped CI lane (see ensureBuiltHooks above).
505
+ ensureBuiltHooks();
506
+
457
507
  // Hermeticity: in-process tests resolve `.planning` via planningDir(cwd), which
458
508
  // honours GSD_PROJECT/GSD_WORKSTREAM. A developer shell inside a GSD workstream
459
509
  // exports GSD_WORKSTREAM, which would redirect fixture STATE.md reads away from
@@ -600,4 +650,4 @@ if (require.main === module) {
600
650
  runMain(main);
601
651
  }
602
652
 
603
- module.exports = { suiteOf, ensureBuiltArtifacts, parseShardArg, selectShard };
653
+ module.exports = { suiteOf, ensureBuiltArtifacts, ensureBuiltHooks, parseShardArg, selectShard };
@@ -21,18 +21,66 @@ const { execFileSync } = require('child_process');
21
21
 
22
22
  const ROOT = path.resolve(__dirname, '..');
23
23
 
24
- // Single source of truth: runtime-integration manifests whose top-level `version`
25
- // MUST track package.json. Add a new manifest here so `npm version` keeps it in
26
- // sync — the regression guard test (issue 844) fails if you forget.
24
+ // Single source of truth: runtime-integration manifests whose `version` field
25
+ // MUST track package.json. Each entry names the manifest path plus the dotted
26
+ // path to the version field inside that JSON document. Top-level `version` is
27
+ // the default (plugin.json, gemini-extension.json); the Claude plugin
28
+ // marketplace manifest carries its canonical version at plugins[0].version
29
+ // (the schema-canonical location runtimes read — issue #1855).
30
+ //
31
+ // Add a new manifest here so `npm version` keeps it in sync — the regression
32
+ // guard test (issue 844) fails if you forget.
27
33
  const VERSIONED_MANIFESTS = [
28
- '.claude-plugin/plugin.json',
29
- 'gemini-extension.json',
34
+ { path: '.claude-plugin/plugin.json', versionKey: 'version' },
35
+ { path: 'gemini-extension.json', versionKey: 'version' },
36
+ { path: '.claude-plugin/marketplace.json', versionKey: 'plugins.0.version' },
37
+ { path: 'vscode/package.json', versionKey: 'version' },
30
38
  ];
31
39
 
40
+ // Convenience: just the registered paths, for consumers that only need to
41
+ // iterate files (e.g. the issue-844 regression-guard ALLOWED set).
42
+ const VERSIONED_MANIFEST_PATHS = VERSIONED_MANIFESTS.map((e) => e.path);
43
+
32
44
  function readJson(p) {
33
45
  return JSON.parse(fs.readFileSync(p, 'utf8'));
34
46
  }
35
47
 
48
+ // Property names that must never be traversed/assigned through a dotted path —
49
+ // the prototype-pollution triple. Mirrors the _isSafePropKey CodeQL barrier used
50
+ // elsewhere in the repo. Versions are hand-authored descriptors, but the helpers
51
+ // are exported, so guard them before reuse across a trust boundary.
52
+ const UNSAFE_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
53
+ function assertSafePath(dotPath) {
54
+ for (const seg of String(dotPath).split('.')) {
55
+ if (UNSAFE_KEYS.has(seg)) {
56
+ throw new Error(`refusing to traverse reserved property "${seg}" in version path "${dotPath}"`);
57
+ }
58
+ }
59
+ }
60
+
61
+ // Read a dotted path ('plugins.0.version') from a parsed JSON document.
62
+ // Returns undefined if any intermediate is missing. Array indices are plain
63
+ // numeric keys, so 'plugins.0.version' resolves obj.plugins[0].version.
64
+ // Reserved properties (__proto__/constructor/prototype) are rejected.
65
+ function getByPath(obj, dotPath) {
66
+ assertSafePath(dotPath);
67
+ return String(dotPath).split('.').reduce((cur, key) => (cur == null ? cur : cur[key]), obj);
68
+ }
69
+
70
+ // Write a value at a dotted path. Intermediate nodes must already exist (every
71
+ // registered manifest is hand-authored with its version slot present, so this
72
+ // never needs to materialize a path). Reserved properties are rejected. Kept
73
+ // trivial — no eval, no creation.
74
+ function setByPath(obj, dotPath, value) {
75
+ assertSafePath(dotPath);
76
+ const parts = String(dotPath).split('.');
77
+ let cur = obj;
78
+ for (let i = 0; i < parts.length - 1; i++) {
79
+ cur = cur[parts[i]];
80
+ }
81
+ cur[parts[parts.length - 1]] = value;
82
+ }
83
+
36
84
  function getPackageVersion(root) {
37
85
  const r = root || ROOT;
38
86
  return readJson(path.join(r, 'package.json')).version;
@@ -44,13 +92,13 @@ function syncManifestVersions(opts) {
44
92
  const root = (opts && opts.root) || ROOT;
45
93
  const v = (opts && opts.version) != null ? opts.version : getPackageVersion(root);
46
94
  const changed = [];
47
- for (const rel of VERSIONED_MANIFESTS) {
48
- const abs = path.join(root, rel);
95
+ for (const entry of VERSIONED_MANIFESTS) {
96
+ const abs = path.join(root, entry.path);
49
97
  const manifest = readJson(abs);
50
- if (manifest.version !== v) {
51
- manifest.version = v;
98
+ if (getByPath(manifest, entry.versionKey) !== v) {
99
+ setByPath(manifest, entry.versionKey, v);
52
100
  fs.writeFileSync(abs, JSON.stringify(manifest, null, 2) + '\n');
53
- changed.push(rel);
101
+ changed.push(entry.path);
54
102
  }
55
103
  }
56
104
  return changed;
@@ -61,9 +109,10 @@ function findDrift(opts) {
61
109
  const root = (opts && opts.root) || ROOT;
62
110
  const v = (opts && opts.version) != null ? opts.version : getPackageVersion(root);
63
111
  const drift = [];
64
- for (const rel of VERSIONED_MANIFESTS) {
65
- const found = readJson(path.join(root, rel)).version;
66
- if (found !== v) drift.push({ manifest: rel, found, expected: v });
112
+ for (const entry of VERSIONED_MANIFESTS) {
113
+ const manifest = readJson(path.join(root, entry.path));
114
+ const found = getByPath(manifest, entry.versionKey);
115
+ if (found !== v) drift.push({ manifest: entry.path, found, expected: v });
67
116
  }
68
117
  return drift;
69
118
  }
@@ -143,7 +192,7 @@ function stageManifests(opts) {
143
192
  console.warn('sync-manifest-versions: not a git work tree; skipping staging.');
144
193
  return;
145
194
  }
146
- const toStage = [...VERSIONED_MANIFESTS, ...listCapabilityManifests({ root })];
195
+ const toStage = [...VERSIONED_MANIFEST_PATHS, ...listCapabilityManifests({ root })];
147
196
  try {
148
197
  execFileSync('git', ['add', '--', ...toStage], { cwd: root, stdio: ['ignore', 'ignore', 'pipe'] });
149
198
  } catch (err) {
@@ -154,6 +203,9 @@ function stageManifests(opts) {
154
203
 
155
204
  module.exports = {
156
205
  VERSIONED_MANIFESTS,
206
+ VERSIONED_MANIFEST_PATHS,
207
+ getByPath,
208
+ setByPath,
157
209
  syncManifestVersions,
158
210
  findDrift,
159
211
  getPackageVersion,
@@ -35,6 +35,12 @@ Phase number: extracted from $ARGUMENTS (required)
35
35
  - `--cursor` — Include Cursor agent review
36
36
  - `--agy` / `--antigravity` — Include Antigravity CLI review
37
37
  - `--all` — Include all available CLIs
38
+
39
+ **No flags** — if `review.default_reviewers` is set, review with only those configured
40
+ reviewers that are detected; otherwise review with all available CLIs. Configured
41
+ `review.reviewer_instances` names may appear in `review.default_reviewers`; each runs as an
42
+ independent reviewer identity backed by its configured adapter+model (see
43
+ `docs/CONFIGURATION.md`). Instance names are not valid as flags.
38
44
  </context>
39
45
 
40
46
  <process>
@@ -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
- }