@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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +1 -1
- package/.opencode/plugins/gsd-core.js +711 -0
- package/agents/gsd-advisor-researcher.md +2 -0
- package/agents/gsd-ai-researcher.md +1 -1
- package/agents/gsd-assumptions-analyzer.md +2 -0
- package/agents/gsd-code-fixer.md +2 -0
- package/agents/gsd-code-reviewer.md +2 -0
- package/agents/gsd-codebase-mapper.md +2 -0
- package/agents/gsd-debugger.md +2 -0
- package/agents/gsd-doc-writer.md +2 -0
- package/agents/gsd-eval-auditor.md +2 -0
- package/agents/gsd-executor.md +9 -6
- package/agents/gsd-integration-checker.md +2 -0
- package/agents/gsd-nyquist-auditor.md +2 -0
- package/agents/gsd-phase-researcher.md +2 -0
- package/agents/gsd-plan-checker.md +2 -0
- package/agents/gsd-planner.md +2 -0
- package/agents/gsd-project-researcher.md +2 -0
- package/agents/gsd-research-synthesizer.md +2 -0
- package/agents/gsd-roadmapper.md +2 -0
- package/agents/gsd-security-auditor.md +2 -0
- package/agents/gsd-ui-auditor.md +2 -0
- package/agents/gsd-ui-checker.md +2 -0
- package/agents/gsd-ui-researcher.md +2 -0
- package/agents/gsd-verifier.md +5 -2
- package/bin/gsd-mcp-server.js +31 -0
- package/bin/install.js +411 -1146
- package/commands/gsd/review.md +6 -0
- package/gemini-extension.json +1 -1
- package/gsd-core/bin/gsd-tools.cjs +134 -8
- package/gsd-core/bin/lib/adapter-declarative.cjs +35 -0
- package/gsd-core/bin/lib/adapter-imperative.cjs +52 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +231 -0
- package/gsd-core/bin/lib/capability-lifecycle.cjs +7 -7
- package/gsd-core/bin/lib/capability-loader.cjs +45 -9
- package/gsd-core/bin/lib/capability-lock.cjs +2 -2
- package/gsd-core/bin/lib/capability-registry.cjs +891 -82
- package/gsd-core/bin/lib/capability-source.cjs +26 -11
- package/gsd-core/bin/lib/capability-validator.cjs +222 -2
- package/gsd-core/bin/lib/cli-skew-check.cjs +44 -0
- package/gsd-core/bin/lib/command-aliases.cjs +8 -0
- package/gsd-core/bin/lib/commands.cjs +2 -1
- package/gsd-core/bin/lib/config.cjs +27 -0
- package/gsd-core/bin/lib/embedding-adapter.cjs +27 -0
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +70 -0
- package/gsd-core/bin/lib/frontmatter.cjs +53 -6
- package/gsd-core/bin/lib/handshake-serialized.cjs +70 -0
- package/gsd-core/bin/lib/hook-bus.cjs +81 -0
- package/gsd-core/bin/lib/host-integration-sdk.cjs +53 -0
- package/gsd-core/bin/lib/host-integration.cjs +469 -0
- package/gsd-core/bin/lib/init.cjs +35 -7
- package/gsd-core/bin/lib/install-engine.cjs +755 -0
- package/gsd-core/bin/lib/install-profiles.cjs +35 -4
- package/gsd-core/bin/lib/installer-migrations.cjs +1 -1
- package/gsd-core/bin/lib/mcp-server.cjs +194 -0
- package/gsd-core/bin/lib/milestone.cjs +68 -40
- package/gsd-core/bin/lib/model-adapter.cjs +50 -0
- package/gsd-core/bin/lib/phase-id.cjs +18 -0
- package/gsd-core/bin/lib/phase.cjs +57 -90
- package/gsd-core/bin/lib/phases-command-router.cjs +4 -3
- package/gsd-core/bin/lib/planning-workspace.cjs +1 -1
- package/gsd-core/bin/lib/probe-core.cjs +132 -2
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +129 -13
- package/gsd-core/bin/lib/roadmap-command-router.cjs +3 -2
- package/gsd-core/bin/lib/roadmap-parser.cjs +21 -11
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +3 -2
- package/gsd-core/bin/lib/roadmap.cjs +33 -22
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +65 -9
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +54 -4
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +5 -2
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/gsd-core/bin/lib/runtime-name-policy.cjs +160 -30
- package/gsd-core/bin/lib/shell-command-projection.cjs +16 -0
- package/gsd-core/bin/lib/stale-bake-guard.cjs +254 -0
- package/gsd-core/bin/lib/state-command-router.cjs +4 -0
- package/gsd-core/bin/lib/state-io.cjs +55 -0
- package/gsd-core/bin/lib/state-transition.cjs +1603 -0
- package/gsd-core/bin/lib/state.cjs +327 -683
- package/gsd-core/bin/lib/surface.cjs +4 -1
- package/gsd-core/bin/lib/validate.cjs +2 -1
- package/gsd-core/bin/lib/verify.cjs +6 -4
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +12 -2
- package/gsd-core/bin/lib/workstream-inventory.cjs +28 -0
- package/gsd-core/bin/lib/workstream.cjs +4 -4
- package/gsd-core/bin/shared/config-schema.manifest.json +9 -0
- package/gsd-core/references/agent-skills-bootstrap.md +60 -0
- package/gsd-core/references/honest-verifier.md +105 -0
- package/gsd-core/references/model-profiles.md +27 -0
- package/gsd-core/references/reviewer-instances.md +99 -0
- package/gsd-core/workflows/autonomous.md +30 -32
- package/gsd-core/workflows/complete-milestone.md +6 -10
- package/gsd-core/workflows/execute-phase.md +1 -1
- package/gsd-core/workflows/forensics.md +3 -3
- package/gsd-core/workflows/help/modes/full.md +1 -1
- package/gsd-core/workflows/manager.md +15 -15
- package/gsd-core/workflows/milestone-summary.md +3 -3
- package/gsd-core/workflows/new-milestone.md +6 -0
- package/gsd-core/workflows/plan-phase/steps/closed-phase-gate.md +42 -0
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +102 -0
- package/gsd-core/workflows/plan-phase/steps/windows-troubleshooting.md +23 -0
- package/gsd-core/workflows/plan-phase.md +4 -159
- package/gsd-core/workflows/review.md +33 -2
- package/gsd-core/workflows/thread.md +4 -4
- package/gsd-core/workflows/verify-phase.md +11 -4
- package/gsd-core/workflows/verify-work.md +1 -2
- package/hooks/dist/gsd-graphify-update.sh +7 -1
- package/hooks/gsd-graphify-update.sh +7 -1
- package/package.json +6 -4
- package/scripts/ci-test-scope.cjs +38 -9
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -1
- package/scripts/lint-regression-test-names.allowlist.json +3 -0
- package/scripts/lint-test-file-count.allowlist.json +19 -5
- package/scripts/mutation-matrix.cjs +45 -3
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/run-tests.cjs +51 -1
- package/scripts/sync-manifest-versions.cjs +66 -14
- package/skills/gsd-review/SKILL.md +6 -0
- 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() {
|
package/scripts/run-tests.cjs
CHANGED
|
@@ -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
|
|
25
|
-
// MUST track package.json.
|
|
26
|
-
//
|
|
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
|
|
48
|
-
const abs = path.join(root,
|
|
95
|
+
for (const entry of VERSIONED_MANIFESTS) {
|
|
96
|
+
const abs = path.join(root, entry.path);
|
|
49
97
|
const manifest = readJson(abs);
|
|
50
|
-
if (manifest.
|
|
51
|
-
manifest.
|
|
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(
|
|
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
|
|
65
|
-
const
|
|
66
|
-
|
|
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 = [...
|
|
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
|
-
}
|