@opengsd/gsd-core 1.13.0 → 1.14.0
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 +1 -1
- package/.claude-plugin/plugin.json +1 -1
- package/agents/gsd-advisor-researcher.compact.md +85 -0
- package/agents/gsd-ai-researcher.compact.md +96 -0
- package/agents/gsd-assumptions-analyzer.compact.md +81 -0
- package/agents/gsd-code-fixer.compact.md +458 -0
- package/agents/gsd-code-fixer.md +5 -5
- package/agents/gsd-code-reviewer.compact.md +269 -0
- package/agents/gsd-code-reviewer.md +15 -3
- package/agents/gsd-codebase-mapper.compact.md +760 -0
- package/agents/gsd-debug-session-manager.compact.md +345 -0
- package/agents/gsd-doc-classifier.compact.md +192 -0
- package/agents/gsd-doc-synthesizer.compact.md +200 -0
- package/agents/gsd-doc-verifier.compact.md +143 -0
- package/agents/gsd-doc-writer.compact.md +440 -0
- package/agents/gsd-dom-verifier.compact.md +138 -0
- package/agents/gsd-domain-researcher.compact.md +141 -0
- package/agents/gsd-eval-auditor.compact.md +160 -0
- package/agents/gsd-eval-planner.compact.md +137 -0
- package/agents/gsd-framework-selector.compact.md +82 -0
- package/agents/gsd-integration-checker.compact.md +245 -0
- package/agents/gsd-intel-updater.compact.md +226 -0
- package/agents/gsd-mempalace-curator.compact.md +45 -0
- package/agents/gsd-nyquist-auditor.compact.md +179 -0
- package/agents/gsd-pattern-mapper.compact.md +275 -0
- package/agents/gsd-project-researcher.compact.md +587 -0
- package/agents/gsd-research-synthesizer.compact.md +212 -0
- package/agents/gsd-roadmapper.compact.md +454 -0
- package/agents/gsd-roadmapper.md +13 -0
- package/agents/gsd-security-auditor.compact.md +162 -0
- package/agents/gsd-ui-auditor.compact.md +404 -0
- package/agents/gsd-ui-checker.compact.md +277 -0
- package/agents/gsd-ui-researcher.compact.md +282 -0
- package/agents/gsd-user-profiler.compact.md +108 -0
- package/bin/install.js +206 -68
- package/commands/gsd/cleanup.md +1 -0
- package/commands/gsd/code-review.md +2 -1
- package/commands/gsd/complete-milestone.md +1 -0
- package/commands/gsd/config.md +1 -0
- package/commands/gsd/debug.md +1 -0
- package/commands/gsd/graphify.md +1 -0
- package/commands/gsd/health.md +1 -0
- package/commands/gsd/mempalace-capture.md +1 -0
- package/commands/gsd/mempalace-recall.md +1 -0
- package/commands/gsd/new-milestone.md +1 -0
- package/commands/gsd/new-project.md +1 -0
- package/commands/gsd/next.md +1 -0
- package/commands/gsd/pause-work.md +1 -0
- package/commands/gsd/phase.md +1 -0
- package/commands/gsd/pr-branch.md +1 -0
- package/commands/gsd/resume-work.md +1 -0
- package/commands/gsd/review-backlog.md +1 -0
- package/commands/gsd/settings.md +2 -1
- package/commands/gsd/stats.md +1 -0
- package/commands/gsd/thread.md +1 -0
- package/commands/gsd/workspace.md +1 -0
- package/commands/gsd/workstreams.md +1 -0
- package/gsd-core/bin/check-latest-version.cjs +8 -3
- package/gsd-core/bin/gsd-tools.cjs +338 -125
- package/gsd-core/bin/lib/adr-parser.cjs +1 -1
- package/gsd-core/bin/lib/artifacts.cjs +2 -1
- package/gsd-core/bin/lib/audit.cjs +39 -22
- package/gsd-core/bin/lib/broken-windows.cjs +168 -49
- package/gsd-core/bin/lib/capability-lifecycle.cjs +10 -6
- package/gsd-core/bin/lib/capability-loader.cjs +135 -1
- package/gsd-core/bin/lib/capability-registry.cjs +79 -67
- package/gsd-core/bin/lib/capability-source.cjs +19 -2
- package/gsd-core/bin/lib/capability-validator.cjs +14 -1
- package/gsd-core/bin/lib/check-command-router.cjs +113 -36
- package/gsd-core/bin/lib/code-review-depth.cjs +2 -2
- package/gsd-core/bin/lib/commands.cjs +650 -72
- package/gsd-core/bin/lib/config-loader.cjs +1 -0
- package/gsd-core/bin/lib/config.cjs +153 -38
- package/gsd-core/bin/lib/coverage.cjs +1 -1
- package/gsd-core/bin/lib/decisions.cjs +137 -34
- package/gsd-core/bin/lib/external-descriptor-trust.cjs +29 -14
- package/gsd-core/bin/lib/gsd2-import.cjs +1 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +12 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +1 -1
- package/gsd-core/bin/lib/init.cjs +409 -47
- package/gsd-core/bin/lib/install-engine.cjs +16 -3
- package/gsd-core/bin/lib/install-profiles.cjs +14 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +33 -4
- package/gsd-core/bin/lib/loop-resolver.cjs +50 -31
- package/gsd-core/bin/lib/mcp-catalog.cjs +2 -2
- package/gsd-core/bin/lib/milestone.cjs +19 -8
- package/gsd-core/bin/lib/model-resolver.cjs +101 -10
- package/gsd-core/bin/lib/phase-command-router.cjs +7 -1
- package/gsd-core/bin/lib/phase-id.cjs +161 -22
- package/gsd-core/bin/lib/phase-lifecycle.cjs +61 -0
- package/gsd-core/bin/lib/phase.cjs +167 -63
- package/gsd-core/bin/lib/planning-inspect.cjs +34 -18
- package/gsd-core/bin/lib/planning-snapshot.cjs +61 -12
- package/gsd-core/bin/lib/planning-workspace.cjs +50 -1
- package/gsd-core/bin/lib/pristine-baseline.cjs +182 -0
- package/gsd-core/bin/lib/prohibition-enforcement.cjs +91 -4
- package/gsd-core/bin/lib/quick-batch.cjs +1 -1
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +61 -2
- package/gsd-core/bin/lib/research-store.cjs +11 -12
- package/gsd-core/bin/lib/review-lane-invocation.cjs +23 -0
- package/gsd-core/bin/lib/reviewer-step-dispatch.cjs +337 -0
- package/gsd-core/bin/lib/roadmap-parser.cjs +56 -15
- package/gsd-core/bin/lib/roadmap.cjs +108 -14
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +27 -10
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +12 -3
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +13 -5
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +193 -4
- package/gsd-core/bin/lib/security.cjs +126 -7
- package/gsd-core/bin/lib/state-document.cjs +130 -28
- package/gsd-core/bin/lib/state-md-schema.cjs +21 -14
- package/gsd-core/bin/lib/state-transition.cjs +142 -28
- package/gsd-core/bin/lib/state.cjs +223 -27
- package/gsd-core/bin/lib/surface.cjs +60 -2
- package/gsd-core/bin/lib/task-command-router.cjs +12 -6
- package/gsd-core/bin/lib/uat.cjs +1 -1
- package/gsd-core/bin/lib/update-context.cjs +30 -24
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +11 -3
- package/gsd-core/bin/lib/verification.cjs +47 -15
- package/gsd-core/bin/lib/verify-command-grounding.cjs +1 -1
- package/gsd-core/bin/lib/verify.cjs +188 -23
- package/gsd-core/bin/lib/workstream-inventory.cjs +1 -0
- package/gsd-core/bin/lib/worktree-safety.cjs +13 -7
- package/gsd-core/bin/shared/config-defaults.manifest.json +1 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +439 -80
- package/gsd-core/references/compact-content-gate.md +66 -0
- package/gsd-core/references/loop-hook-dispatch.md +18 -0
- package/gsd-core/references/model-profiles.md +12 -3
- package/gsd-core/references/planning-config.md +3 -0
- package/gsd-core/references/tdd.md +5 -2
- package/gsd-core/references/thinking-models-planning.md +18 -2
- package/gsd-core/references/verification-patterns.md +17 -4
- package/gsd-core/references/worktree-path-safety.md +112 -2
- package/gsd-core/templates/README.md +7 -1
- package/gsd-core/templates/state.md +6 -3
- package/gsd-core/templates/summary.compact.md +212 -0
- package/gsd-core/templates/user-setup.compact.md +199 -0
- package/gsd-core/templates/user-setup.md +0 -9
- package/gsd-core/workflows/add-todo.md +3 -2
- package/gsd-core/workflows/autonomous.md +13 -10
- package/gsd-core/workflows/check-todos.md +4 -2
- package/gsd-core/workflows/cleanup.md +3 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +7 -0
- package/gsd-core/workflows/code-review-fix.md +3 -3
- package/gsd-core/workflows/code-review.md +156 -30
- package/gsd-core/workflows/complete-milestone/detail/elaboration.md +274 -0
- package/gsd-core/workflows/complete-milestone.md +39 -262
- package/gsd-core/workflows/docs-update/detail/elaboration.md +179 -0
- package/gsd-core/workflows/docs-update.md +14 -155
- package/gsd-core/workflows/execute-phase/detail/elaboration.md +124 -0
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +18 -3
- package/gsd-core/workflows/execute-phase/steps/completion-reconciliation.md +56 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +7 -2
- package/gsd-core/workflows/execute-phase/steps/executor-progress-policy.md +43 -0
- package/gsd-core/workflows/execute-phase/steps/sequential-root-pin.md +35 -0
- package/gsd-core/workflows/execute-phase.md +53 -152
- package/gsd-core/workflows/execute-plan.md +20 -7
- package/gsd-core/workflows/help/modes/full.compact.md +398 -0
- package/gsd-core/workflows/help.md +1 -1
- package/gsd-core/workflows/map-codebase.md +50 -3
- package/gsd-core/workflows/new-milestone.md +54 -12
- package/gsd-core/workflows/new-project/detail/elaboration.md +216 -0
- package/gsd-core/workflows/new-project.md +32 -202
- package/gsd-core/workflows/plan-phase/detail/elaboration.md +209 -0
- package/gsd-core/workflows/plan-phase.md +22 -181
- package/gsd-core/workflows/pr-branch.md +19 -7
- package/gsd-core/workflows/quick.md +8 -1
- package/gsd-core/workflows/reapply-patches.md +77 -3
- package/gsd-core/workflows/settings.md +18 -5
- package/gsd-core/workflows/update.md +7 -5
- package/gsd-core/workflows/verify-work/detail/elaboration.md +230 -0
- package/gsd-core/workflows/verify-work.md +20 -180
- package/hooks/dist/gsd-agent-isolation-guard.js +42 -16
- package/hooks/dist/gsd-context-monitor.js +88 -15
- package/hooks/dist/gsd-cursor-subagent-start.js +34 -14
- package/hooks/dist/gsd-secret-read-guard.js +44 -18
- package/hooks/dist/gsd-statusline.js +11 -7
- package/hooks/dist/gsd-validate-commit.sh +34 -4
- package/hooks/dist/gsd-worktree-path-guard.js +25 -14
- package/hooks/dist/gsd-write-guard.js +46 -1
- package/hooks/dist/lib/dispatch-identity.js +187 -0
- package/hooks/dist/lib/filename-classification.js +64 -0
- package/hooks/dist/lib/isolation-deny-reason.js +53 -1
- package/hooks/dist/lib/isolation-sentinel.js +58 -19
- package/hooks/gsd-agent-isolation-guard.js +42 -16
- package/hooks/gsd-context-monitor.js +88 -15
- package/hooks/gsd-cursor-subagent-start.js +34 -14
- package/hooks/gsd-secret-read-guard.js +44 -18
- package/hooks/gsd-statusline.js +11 -7
- package/hooks/gsd-validate-commit.sh +34 -4
- package/hooks/gsd-worktree-path-guard.js +25 -14
- package/hooks/gsd-write-guard.js +46 -1
- package/hooks/lib/dispatch-identity.js +187 -0
- package/hooks/lib/filename-classification.js +64 -0
- package/hooks/lib/isolation-deny-reason.js +53 -1
- package/hooks/lib/isolation-sentinel.js +58 -19
- package/package.json +10 -6
- package/scripts/benchmark-compact-content-variants.cjs +298 -0
- package/scripts/benchmark-compact-content.cjs +368 -0
- package/scripts/check-contract-drift.cjs +4 -1
- package/scripts/check-env.cjs +36 -8
- package/scripts/check-glossary-refs.cjs +25 -21
- package/scripts/ci-next-health.cjs +271 -0
- package/scripts/ci-prepare-test-scope.cjs +7 -7
- package/scripts/ci-test-scope.cjs +126 -20
- package/scripts/ci-timeout-report.cjs +1 -1
- package/scripts/diff-touches-shipped-paths.cjs +1 -1
- package/scripts/docs-guard-registry.cjs +7 -2
- package/scripts/gen-adr-index.cjs +8 -2
- package/scripts/gen-inventory-manifest.cjs +12 -0
- package/scripts/gen-platform-conformance-tier.cjs +557 -0
- package/scripts/lib/drift-scan.cjs +1 -1
- package/scripts/lib/macos-conformance-tier.generated.cjs +210 -0
- package/scripts/lib/npm-version-check-diagnosis.cjs +59 -0
- package/scripts/lib/platform-conformance-tier.generated.cjs +276 -0
- package/scripts/lib/suite-detection.cjs +32 -0
- package/scripts/lint-allowed-tools-parity.cjs +221 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +19 -2
- package/scripts/lint-phase-id-drift.cjs +338 -13
- package/scripts/lint-response-language-coverage.cjs +9 -3
- package/scripts/lint-source-test-name-collision.cjs +1 -1
- package/scripts/lint-test-file-count.allowlist.json +1 -0
- package/scripts/lint-vendored-deps.cjs +128 -17
- package/scripts/lint-workflow-shellcheck-baseline.json +85 -0
- package/scripts/prompt-injection-scan.sh +14 -0
- package/scripts/workflow-size.cjs +139 -0
- package/skills/gsd-cleanup/SKILL.md +1 -0
- package/skills/gsd-code-review/SKILL.md +2 -1
- package/skills/gsd-complete-milestone/SKILL.md +1 -0
- package/skills/gsd-config/SKILL.md +1 -0
- package/skills/gsd-debug/SKILL.md +1 -0
- package/skills/gsd-graphify/SKILL.md +1 -0
- package/skills/gsd-health/SKILL.md +1 -0
- package/skills/gsd-mempalace-capture/SKILL.md +1 -0
- package/skills/gsd-mempalace-recall/SKILL.md +1 -0
- package/skills/gsd-new-milestone/SKILL.md +1 -0
- package/skills/gsd-new-project/SKILL.md +1 -0
- package/skills/gsd-next/SKILL.md +1 -0
- package/skills/gsd-pause-work/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -0
- package/skills/gsd-pr-branch/SKILL.md +1 -0
- package/skills/gsd-resume-work/SKILL.md +1 -0
- package/skills/gsd-review-backlog/SKILL.md +1 -0
- package/skills/gsd-settings/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +1 -0
- package/skills/gsd-thread/SKILL.md +1 -0
- package/skills/gsd-workspace/SKILL.md +1 -0
- package/skills/gsd-workstreams/SKILL.md +1 -0
- package/vscode/package.json +1 -1
- package/gsd-core/templates/claude-md.md +0 -145
- package/gsd-core/templates/codebase/concerns.md +0 -310
- package/gsd-core/templates/codebase/conventions.md +0 -307
- package/gsd-core/templates/codebase/integrations.md +0 -280
- package/gsd-core/templates/codebase/structure.md +0 -285
- package/gsd-core/templates/codebase/testing.md +0 -480
- package/gsd-core/templates/debug-subagent-prompt.md +0 -91
- package/gsd-core/templates/discovery.md +0 -146
|
@@ -94,6 +94,9 @@ const { parseMarkdownTable, matchTableSchema } = markdownTable;
|
|
|
94
94
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
95
95
|
const coreUtilsMod = require("./core-utils.cjs");
|
|
96
96
|
const { normalizeLineEndings } = coreUtilsMod;
|
|
97
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
98
|
+
const securityMod = require("./security.cjs");
|
|
99
|
+
const { tryWithinRoot, PathAcceptance, isContainedIn } = securityMod;
|
|
97
100
|
/**
|
|
98
101
|
* The wire schema version. A consumer MUST reject any value other than this
|
|
99
102
|
* one rather than best-effort-parsing an unknown shape.
|
|
@@ -184,32 +187,45 @@ function toPosix(value) {
|
|
|
184
187
|
* directory like `.planning-evil/` that merely shares a string prefix. Pure
|
|
185
188
|
* string comparison, no I/O — callers own their own `fs.realpathSync` call
|
|
186
189
|
* (and its own not-found/broken-symlink handling).
|
|
190
|
+
*
|
|
191
|
+
* NOT an independent containment implementation — it is the comparison step
|
|
192
|
+
* of one, and that comparison now comes from `security.cts`'s exported
|
|
193
|
+
* `isContainedIn` rather than being redeclared here. `readDocument` below
|
|
194
|
+
* realpaths target and root itself (to keep its own exists-vs-escaped
|
|
195
|
+
* tri-state) and calls `isContainedIn` directly; `isPathContained` gets its
|
|
196
|
+
* containment DECISION from the canonical `tryWithinRoot` predicate instead
|
|
197
|
+
* (ADR-4650 decision 6) and never called this comparison directly. Every
|
|
198
|
+
* caller owns its own resolution.
|
|
187
199
|
*/
|
|
188
|
-
function isWithinRoot(resolvedTarget, resolvedRoot) {
|
|
189
|
-
return resolvedTarget === resolvedRoot || resolvedTarget.startsWith(resolvedRoot + node_path_1.default.sep);
|
|
190
|
-
}
|
|
191
200
|
/**
|
|
192
|
-
* Containment check for a path (file OR directory)
|
|
193
|
-
*
|
|
194
|
-
*
|
|
195
|
-
*
|
|
196
|
-
*
|
|
197
|
-
*
|
|
198
|
-
* `
|
|
199
|
-
*
|
|
200
|
-
*
|
|
201
|
+
* Containment check for a path (file OR directory), used where the caller
|
|
202
|
+
* does not need to distinguish "target vanished / broken symlink" from
|
|
203
|
+
* "target resolved but escapes root" — both degrade the same way at every
|
|
204
|
+
* call site that uses this (an escaped or unresolvable phase directory is
|
|
205
|
+
* treated identically to an unreadable one). `readDocument` below needs
|
|
206
|
+
* that distinction for its own exists/readable tri-state, so it keeps its
|
|
207
|
+
* own inline `realpathSync` calls and calls `isContainedIn` directly instead
|
|
208
|
+
* of this wrapper.
|
|
209
|
+
*
|
|
210
|
+
* The containment DECISION comes from the canonical `tryWithinRoot`
|
|
211
|
+
* predicate (ADR-4650 decision 6: a wrapper may decide HOW to degrade,
|
|
212
|
+
* never WHETHER a path is contained). Must-exist stays this module's OWN
|
|
213
|
+
* degradation condition, applied after: `tryWithinRoot` deliberately accepts
|
|
214
|
+
* a not-yet-created path under the root (ancestor-walk realpath), but every
|
|
215
|
+
* caller of `isPathContained` guards an `fs` read that is about to happen
|
|
216
|
+
* against an already-existing directory, so a vanished/unresolvable path
|
|
217
|
+
* must still degrade the same as an escaped one.
|
|
201
218
|
*/
|
|
202
219
|
function isPathContained(target, root) {
|
|
203
|
-
|
|
204
|
-
|
|
220
|
+
if (tryWithinRoot(target, root, PathAcceptance.AbsoluteInsideRoot) === null)
|
|
221
|
+
return false;
|
|
205
222
|
try {
|
|
206
|
-
|
|
207
|
-
realRoot = node_fs_1.default.realpathSync(root);
|
|
223
|
+
node_fs_1.default.realpathSync(target);
|
|
208
224
|
}
|
|
209
225
|
catch {
|
|
210
226
|
return false;
|
|
211
227
|
}
|
|
212
|
-
return
|
|
228
|
+
return true;
|
|
213
229
|
}
|
|
214
230
|
function readDocument(filePath, root) {
|
|
215
231
|
let stat;
|
|
@@ -234,7 +250,7 @@ function readDocument(filePath, root) {
|
|
|
234
250
|
// here — the same non-answer `readDocument` already gives "not exists".
|
|
235
251
|
return { text: null, exists: false, readable: false };
|
|
236
252
|
}
|
|
237
|
-
if (!
|
|
253
|
+
if (!isContainedIn(realTarget, realRoot)) {
|
|
238
254
|
return { text: null, exists: true, readable: false };
|
|
239
255
|
}
|
|
240
256
|
try {
|
|
@@ -41,7 +41,15 @@ const planningWorkspace = require("./planning-workspace.cjs");
|
|
|
41
41
|
// `phase_id_convention` reader, from the same §7 owner module `planningPaths`
|
|
42
42
|
// comes from. Resolved once in `buildPlanningSnapshot` — see the
|
|
43
43
|
// `phaseIdConvention` field's comment for why one resolution point matters.
|
|
44
|
-
|
|
44
|
+
// #4257: `resolveEnvWorkstream` is that same module's ONE owner of the env
|
|
45
|
+
// workstream discriminator `planningDir` applies — the name W002's scope
|
|
46
|
+
// clause prints comes from the same resolution point that scoped the reads.
|
|
47
|
+
const { planningPaths, planningRoot, resolvePhaseIdConvention, resolveEnvWorkstream } = planningWorkspace;
|
|
48
|
+
// #4257: canonical CommonMark code strippers (markdown-sectionizer is the
|
|
49
|
+
// repo's T0 structural seam, adopted per the #2365 composition order — fenced
|
|
50
|
+
// blocks first, then inline spans) so the `statePhaseTokens` harvest sees
|
|
51
|
+
// PROSE, not quoted literals.
|
|
52
|
+
const markdown_sectionizer_cjs_1 = require("./markdown-sectionizer.cjs");
|
|
45
53
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
46
54
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
47
55
|
const frontmatterMod = require("./frontmatter.cjs");
|
|
@@ -109,9 +117,12 @@ function worstScope(...scopes) {
|
|
|
109
117
|
* uncorrelated (isPhaseComplete's readability check never re-derives or
|
|
110
118
|
* requires scanPhasePlans, and vice versa).
|
|
111
119
|
*/
|
|
112
|
-
function buildPhaseSnapshot(phasesDir, dir) {
|
|
120
|
+
function buildPhaseSnapshot(phasesDir, dir, convention) {
|
|
113
121
|
const fullPhaseDir = node_path_1.default.join(phasesDir, dir);
|
|
114
|
-
|
|
122
|
+
// #612: the snapshot's single federated convention resolution rides into
|
|
123
|
+
// completion, so a bracket phase dir resolves and scopes its verification
|
|
124
|
+
// report exactly like its legacy twin.
|
|
125
|
+
const completionResult = isPhaseComplete(fullPhaseDir, { convention });
|
|
115
126
|
const scanResult = scanPhasePlans(fullPhaseDir);
|
|
116
127
|
return {
|
|
117
128
|
dir,
|
|
@@ -160,9 +171,11 @@ function buildPhaseSnapshot(phasesDir, dir) {
|
|
|
160
171
|
* a whole-body fallback, together.
|
|
161
172
|
* - `statePhaseTokens` scans the WHOLE document (`verify.cts`'s exact
|
|
162
173
|
* `PHASE_NUMBER_TOKEN_SOURCE` regex, relocated verbatim from
|
|
163
|
-
* `verify.cts:1731-1735
|
|
164
|
-
*
|
|
165
|
-
*
|
|
174
|
+
* `verify.cts:1731-1735`; #4257 adds the left word boundary and the
|
|
175
|
+
* fenced-block/inline-span strip — see the harvest site's comment), not
|
|
176
|
+
* just the Current Position section, so it is NOT degraded to `TRUNCATED`
|
|
177
|
+
* by a missing section header — it stays `COMPLETE` whenever the file
|
|
178
|
+
* itself was read successfully.
|
|
166
179
|
*/
|
|
167
180
|
function buildStateFields(statePath) {
|
|
168
181
|
let content;
|
|
@@ -222,7 +235,30 @@ function buildStateFields(statePath) {
|
|
|
222
235
|
scope: currentPositionScope,
|
|
223
236
|
});
|
|
224
237
|
const statePhaseTokens = {
|
|
225
|
-
|
|
238
|
+
// #4257: harvest PROSE phase references, not every literal token match.
|
|
239
|
+
// Two precisions over the pre-#4257 verbatim relocation of verify.cts's
|
|
240
|
+
// scan (which was `[Pp]hase\s+(TOKEN)`, unanchored, over the raw file):
|
|
241
|
+
//
|
|
242
|
+
// 1. Strip fenced code blocks, then inline code spans (the #2365
|
|
243
|
+
// composition order, via the canonical markdown-sectionizer seam) —
|
|
244
|
+
// a token inside backticks is a QUOTED LITERAL (a ledger row quoting
|
|
245
|
+
// `` `/gsd-execute-phase 5` `` or `` `- [ ] **Phase 40:` `` from a
|
|
246
|
+
// sibling roadmap), not a reference. Pinned tradeoff: a GENUINE
|
|
247
|
+
// reference written in backticks stops counting too — a quoted
|
|
248
|
+
// literal and a reference are indistinguishable inside a code span.
|
|
249
|
+
// 2. Left word boundary `(?<![-\w])` — the `-phase 5` tail of GSD's own
|
|
250
|
+
// command names (`/gsd-execute-phase 5`, bare or in prose) is a
|
|
251
|
+
// command mention, not a reference, and word-suffixed carriers
|
|
252
|
+
// (`myphase 5`) never were references. `Phase 5` at line start,
|
|
253
|
+
// `**Phase 5:**`, `(Phase 5)`, `[Phase 5]`, and `### Phase 5:` all
|
|
254
|
+
// still harvest — the char before `Phase` is not in `[-\w]`.
|
|
255
|
+
//
|
|
256
|
+
// Still scans the WHOLE document (frontmatter included — the `Phase: 3`
|
|
257
|
+
// field syntax never matched, `phase` is followed by a colon, not `\s`),
|
|
258
|
+
// still `COMPLETE` whenever the file itself was read successfully.
|
|
259
|
+
value: [
|
|
260
|
+
...(0, markdown_sectionizer_cjs_1.stripInlineCode)((0, markdown_sectionizer_cjs_1.stripFencedCode)(content).text).matchAll(new RegExp(`(?<![-\\w])[Pp]hase\\s+(${PHASE_NUMBER_TOKEN_SOURCE})`, 'g')),
|
|
261
|
+
].map((m) => m[1]),
|
|
226
262
|
scope: SCOPE.COMPLETE,
|
|
227
263
|
};
|
|
228
264
|
return { currentPhaseLabel, statePhaseTokens, stateStatus };
|
|
@@ -632,7 +668,7 @@ function buildRoadmapBracketIncoherencesField(roadmapPath, convention) {
|
|
|
632
668
|
* deliberate fail-open match to the pre-migration behavior, not a scope
|
|
633
669
|
* degradation, since the original never surfaced these failures either.
|
|
634
670
|
*/
|
|
635
|
-
function buildResearchValidationStatusField(phasesDir, phaseDirNames, enumerationScope) {
|
|
671
|
+
function buildResearchValidationStatusField(phasesDir, phaseDirNames, enumerationScope, convention) {
|
|
636
672
|
const value = phaseDirNames.map((dir) => {
|
|
637
673
|
const fullPhaseDir = node_path_1.default.join(phasesDir, dir);
|
|
638
674
|
let files;
|
|
@@ -646,7 +682,9 @@ function buildResearchValidationStatusField(phasesDir, phaseDirNames, enumeratio
|
|
|
646
682
|
// phase-numbered-artifact predicates, so a stray cross-phase
|
|
647
683
|
// -RESEARCH.md/-VALIDATION.md sitting in the wrong directory cannot flip
|
|
648
684
|
// this phase's flags — mirrors core-utils.cts's getPhaseFileStats.
|
|
649
|
-
|
|
685
|
+
// #612: the snapshot's federated convention threaded, so a bracket dir
|
|
686
|
+
// scopes by its real token instead of the include-everything fail-safe.
|
|
687
|
+
const scopedFiles = scopeToPhase(files, dir, convention);
|
|
650
688
|
const researchFile = scopedFiles.find((f) => f.endsWith('-RESEARCH.md'));
|
|
651
689
|
const hasValidationMd = scopedFiles.some((f) => f.endsWith('-VALIDATION.md'));
|
|
652
690
|
let hasValidationArchitecture = false;
|
|
@@ -963,6 +1001,11 @@ function buildPlanningSnapshot(cwd) {
|
|
|
963
1001
|
// See the `phaseIdConvention` field's comment for why one resolution point is
|
|
964
1002
|
// load-bearing rather than a micro-optimisation.
|
|
965
1003
|
const phaseIdConvention = resolvePhaseIdConvention(cwd) ?? null;
|
|
1004
|
+
// #4257: the workstream `planningPaths(cwd)` just scoped every read to
|
|
1005
|
+
// (its `planningDir` call applies this exact discriminator when handed no
|
|
1006
|
+
// `ws`), resolved through the same owner so W002's scope clause names the
|
|
1007
|
+
// scope the valid set was ACTUALLY built from.
|
|
1008
|
+
const workstream = resolveEnvWorkstream();
|
|
966
1009
|
const milestone = getMilestoneInfo(cwd);
|
|
967
1010
|
// #612: deliberately LEFT to `listMilestonePhaseDirs`'s own lazy resolve —
|
|
968
1011
|
// this call is byte-identical to upstream's.
|
|
@@ -976,9 +1019,14 @@ function buildPlanningSnapshot(cwd) {
|
|
|
976
1019
|
// genuinely differ, and substituting one for the other would silently re-scope
|
|
977
1020
|
// `phaseDirs` — a change this PR does not need and no test covers. The
|
|
978
1021
|
// federation guarantee PR-2 exists to deliver is delivered where it is
|
|
979
|
-
// observable: in the rules that read `snapshot.phaseIdConvention`.
|
|
1022
|
+
// observable: in the rules that read `snapshot.phaseIdConvention`. Per-phase
|
|
1023
|
+
// completion and the research/validation scoping below deliberately use the
|
|
1024
|
+
// FEDERATED `phaseIdConvention` (the same value `snapshot.phaseIdConvention`
|
|
1025
|
+
// publishes), while `phaseDirs` keeps `listMilestonePhaseDirs`'s lazy
|
|
1026
|
+
// PROJECT-only resolve — the divergence documented above is unchanged by
|
|
1027
|
+
// this thread. No behavior change.
|
|
980
1028
|
const phaseDirs = listMilestonePhaseDirs(paths.phases, { cwd });
|
|
981
|
-
const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir));
|
|
1029
|
+
const phasesValue = phaseDirs.value.map((dir) => buildPhaseSnapshot(paths.phases, dir, phaseIdConvention));
|
|
982
1030
|
const stateFields = buildStateFields(paths.state);
|
|
983
1031
|
const allPhaseDirNames = buildAllPhaseDirNamesField(paths.phases);
|
|
984
1032
|
const roadmapDeclared = buildRoadmapDeclaredPhasesField(paths.roadmap, phaseIdConvention);
|
|
@@ -1001,7 +1049,7 @@ function buildPlanningSnapshot(cwd) {
|
|
|
1001
1049
|
stateStatus: stateFields.stateStatus,
|
|
1002
1050
|
roadmapDeclaredPhases: roadmapDeclared.declared,
|
|
1003
1051
|
roadmapPhaseCheckboxes: buildRoadmapPhaseCheckboxesField(paths.roadmap, phaseIdConvention),
|
|
1004
|
-
researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope),
|
|
1052
|
+
researchValidationStatus: buildResearchValidationStatusField(paths.phases, phaseDirs.value, phaseDirs.scope, phaseIdConvention),
|
|
1005
1053
|
milestoneArchiveStatus: buildMilestoneArchiveStatusField(cwd),
|
|
1006
1054
|
planningRootFiles: buildPlanningRootFilesField(cwd),
|
|
1007
1055
|
allPhaseDirNames,
|
|
@@ -1013,6 +1061,7 @@ function buildPlanningSnapshot(cwd) {
|
|
|
1013
1061
|
phaseIdConvention,
|
|
1014
1062
|
roadmapSentinelPhaseTokens: roadmapDeclared.sentinelTokens,
|
|
1015
1063
|
roadmapBracketIncoherences: buildRoadmapBracketIncoherencesField(paths.roadmap, phaseIdConvention),
|
|
1064
|
+
workstream,
|
|
1016
1065
|
};
|
|
1017
1066
|
}
|
|
1018
1067
|
module.exports = {
|
|
@@ -100,11 +100,27 @@ const PLANNING_LOCK_RETRY_ERRNOS = new Set([
|
|
|
100
100
|
'ENOENT', // Docker overlay-fs: parent dir transiently missing during race
|
|
101
101
|
'ESTALE', // NFS: stale file handle (self-resolves on retry)
|
|
102
102
|
]);
|
|
103
|
+
/**
|
|
104
|
+
* #4257: the ONE owner of the env workstream discriminator `planningDir`
|
|
105
|
+
* itself applies when handed no `ws` argument. `planningPaths(cwd)` — and
|
|
106
|
+
* therefore every workstream-scoped `PlanningSnapshot` read — resolves its
|
|
107
|
+
* base through exactly this read, and the CLI bootstrap has already folded
|
|
108
|
+
* the stored active-workstream pointer into the env by the time any
|
|
109
|
+
* diagnostic runs (`resolveActiveWorkstream` → `applyResolvedWorkstreamEnv`,
|
|
110
|
+
* `active-workstream-store.cjs`). Exposed so a consumer that needs to NAME
|
|
111
|
+
* the scope those reads used (W002's warning message, via the snapshot's
|
|
112
|
+
* `workstream` field) derives it from the same resolution point instead of
|
|
113
|
+
* growing a second env read site that can drift (the #612 PR-2
|
|
114
|
+
* two-readers-two-bases lesson).
|
|
115
|
+
*/
|
|
116
|
+
function resolveEnvWorkstream() {
|
|
117
|
+
return process.env['GSD_WORKSTREAM'] ?? null;
|
|
118
|
+
}
|
|
103
119
|
function planningDir(cwd, ws, project) {
|
|
104
120
|
if (project === undefined)
|
|
105
121
|
project = process.env['GSD_PROJECT'] ?? null;
|
|
106
122
|
if (ws === undefined)
|
|
107
|
-
ws =
|
|
123
|
+
ws = resolveEnvWorkstream();
|
|
108
124
|
// Reject path separators and traversal components in project/workstream names
|
|
109
125
|
const BAD_SEGMENT = /[/\\]|\.\./;
|
|
110
126
|
if (project && BAD_SEGMENT.test(project)) {
|
|
@@ -284,6 +300,31 @@ function listAvailableWorkstreams(cwd) {
|
|
|
284
300
|
function quickDirFrom(planningBase) {
|
|
285
301
|
return node_path_1.default.join(planningBase, 'quick');
|
|
286
302
|
}
|
|
303
|
+
// #4256: the todos directory — deliberately ROOT-SCOPED, unlike every other
|
|
304
|
+
// planningPaths key. Todos are shared project state by construction: the
|
|
305
|
+
// migrateToWorkstreams contract keeps them among the shared files that "stay
|
|
306
|
+
// in place" at .planning/todos/ (workstream.cts), and every workflow writer
|
|
307
|
+
// writes that literal cwd-relative root path. The six todos readers
|
|
308
|
+
// previously hand-composed `path.join(planningDir(cwd), 'todos', ...)`,
|
|
309
|
+
// which silently re-scoped to .planning/workstreams/<ws>/todos/ — a
|
|
310
|
+
// directory nothing creates — under a workstream, so todos went invisible
|
|
311
|
+
// and audit-open passed the milestone-close gate vacuously. Same
|
|
312
|
+
// two-composers-of-one-path shape the `debug` (#3149) and `quick` (#2142)
|
|
313
|
+
// keys were introduced to eliminate (DEFECT.GENERATIVE-FIX).
|
|
314
|
+
//
|
|
315
|
+
// Exported as its own function pair (not only as a `planningPaths` key)
|
|
316
|
+
// because `audit.cts`'s `scanTodos`/`cmdAuditAcknowledge` consume an
|
|
317
|
+
// already-resolved todos base rather than a `cwd`, mirroring how #2142
|
|
318
|
+
// exported `quickDirFrom` for `scanQuickTasks`. `todosDir` takes NO ws/project
|
|
319
|
+
// parameter — todos have no workstream- or project-scoped form anywhere, so
|
|
320
|
+
// there is no discriminator to thread. This is also the single root #4327's
|
|
321
|
+
// future filename-containment guard should enforce against.
|
|
322
|
+
function todosDirFrom(planningBase) {
|
|
323
|
+
return node_path_1.default.join(planningBase, 'todos');
|
|
324
|
+
}
|
|
325
|
+
function todosDir(cwd) {
|
|
326
|
+
return todosDirFrom(planningRoot(cwd));
|
|
327
|
+
}
|
|
287
328
|
function planningPaths(cwd, ws) {
|
|
288
329
|
const base = planningDir(cwd, ws);
|
|
289
330
|
return {
|
|
@@ -300,6 +341,11 @@ function planningPaths(cwd, ws) {
|
|
|
300
341
|
debug: node_path_1.default.join(base, 'debug'),
|
|
301
342
|
// #2142: quick-task directory, composed via the shared quickDirFrom helper.
|
|
302
343
|
quick: quickDirFrom(base),
|
|
344
|
+
// #4256: todos directory — deliberately ROOT-scoped while the rest of
|
|
345
|
+
// this record follows the active workstream/project (todos are shared
|
|
346
|
+
// project state per the migrateToWorkstreams contract), composed via the
|
|
347
|
+
// shared todosDir helper so this key and every direct caller agree.
|
|
348
|
+
todos: todosDir(cwd),
|
|
303
349
|
};
|
|
304
350
|
}
|
|
305
351
|
/**
|
|
@@ -567,10 +613,13 @@ module.exports = {
|
|
|
567
613
|
createMemoryPointerAdapter,
|
|
568
614
|
planningDir,
|
|
569
615
|
planningRoot,
|
|
616
|
+
resolveEnvWorkstream,
|
|
570
617
|
resolvePhaseIdConvention,
|
|
571
618
|
listAvailableWorkstreams,
|
|
572
619
|
planningPaths,
|
|
573
620
|
quickDirFrom,
|
|
621
|
+
todosDirFrom,
|
|
622
|
+
todosDir,
|
|
574
623
|
withPlanningLock,
|
|
575
624
|
getActiveWorkstream,
|
|
576
625
|
peekActiveWorkstream,
|
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* #4145: hash-first recovery for gsd-pristine/ baselines stored at an
|
|
4
|
+
* unexpected path.
|
|
5
|
+
*
|
|
6
|
+
* Some installs hold a pristine snapshot whose SHA-256 equals the hash recorded
|
|
7
|
+
* in backup-meta.json.pristine_hashes for a manifest-keyed file, but at a path
|
|
8
|
+
* that is not `path.join(pristineDir, relPath)` — e.g. stored without the
|
|
9
|
+
* `gsd-core/` top-level segment by an earlier release's writer. Both readers
|
|
10
|
+
* (verify-reapply-patches.cjs verifyFile and install.js saveLocalPatches)
|
|
11
|
+
* resolved strictly by that join, missed the snapshot, and reported
|
|
12
|
+
* ok_no_baseline / fell into regeneration that can never satisfy the recorded
|
|
13
|
+
* outgoing hash — a self-perpetuating gap.
|
|
14
|
+
*
|
|
15
|
+
* Hash equality with the recorded pristine_hashes entry is the same authority
|
|
16
|
+
* the #3657 drift guard already trusts, so a match cannot be the wrong
|
|
17
|
+
* baseline regardless of which release wrote it or where under gsd-pristine/
|
|
18
|
+
* it lives. This module owns the shared scan so the two readers cannot drift
|
|
19
|
+
* apart again (two private strict joins drifting is exactly the bug class).
|
|
20
|
+
*
|
|
21
|
+
* ADR-457: runtime module in src/*.cts, compiled to
|
|
22
|
+
* gsd-core/bin/lib/pristine-baseline.cjs.
|
|
23
|
+
*/
|
|
24
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
25
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
26
|
+
};
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.sha256File = sha256File;
|
|
29
|
+
exports.findPristineByHash = findPristineByHash;
|
|
30
|
+
exports.findPristineInGit = findPristineInGit;
|
|
31
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
32
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
33
|
+
const node_crypto_1 = __importDefault(require("node:crypto"));
|
|
34
|
+
const node_child_process_1 = require("node:child_process");
|
|
35
|
+
/**
|
|
36
|
+
* SHA-256 hex digest of a file's raw bytes. Byte-for-byte the same digest
|
|
37
|
+
* install.js fileHash() records into manifests and backup-meta.json.
|
|
38
|
+
*/
|
|
39
|
+
function sha256File(absPath) {
|
|
40
|
+
return node_crypto_1.default.createHash('sha256').update(node_fs_1.default.readFileSync(absPath)).digest('hex');
|
|
41
|
+
}
|
|
42
|
+
function walkSorted(dir, relPrefix, results) {
|
|
43
|
+
let entries;
|
|
44
|
+
try {
|
|
45
|
+
entries = node_fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return; // absent or unreadable — nothing to scan here
|
|
49
|
+
}
|
|
50
|
+
entries.sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
|
|
51
|
+
for (const entry of entries) {
|
|
52
|
+
// Never follow symlinks: gsd-pristine/ is installer-authored plain files;
|
|
53
|
+
// a link here is not a baseline and must not redirect the walk out of the
|
|
54
|
+
// tree (same posture as migration 004's walker).
|
|
55
|
+
if (entry.isSymbolicLink())
|
|
56
|
+
continue;
|
|
57
|
+
const rel = relPrefix ? `${relPrefix}/${entry.name}` : entry.name;
|
|
58
|
+
if (entry.isDirectory()) {
|
|
59
|
+
walkSorted(node_path_1.default.join(dir, entry.name), rel, results);
|
|
60
|
+
}
|
|
61
|
+
else if (entry.isFile()) {
|
|
62
|
+
results.push(rel);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
/**
|
|
67
|
+
* Find the first file under `pristineDir` (deterministic sorted walk) whose
|
|
68
|
+
* SHA-256 equals `recordedHash`, as a pristineDir-relative POSIX path.
|
|
69
|
+
*
|
|
70
|
+
* - `skip` is never returned — a single POSIX relPath string or a Set of them.
|
|
71
|
+
* Callers pass the canonical path(s) they (or other files in the same run)
|
|
72
|
+
* already own, so a file sitting at a canonical path is never adopted
|
|
73
|
+
* through the scan. For the installer's relocation this is what prevents a
|
|
74
|
+
* byte-identical canonical belonging to ANOTHER modified file from being
|
|
75
|
+
* "rescued" away (relocated and deleted at its home path).
|
|
76
|
+
* - Multiple matches are byte-identical by sha-256 authority; sorted order
|
|
77
|
+
* makes the choice deterministic.
|
|
78
|
+
* - Returns null when pristineDir is absent/unreadable or nothing matches.
|
|
79
|
+
*/
|
|
80
|
+
function findPristineByHash(pristineDir, recordedHash, skip) {
|
|
81
|
+
if (!pristineDir || typeof recordedHash !== 'string' || recordedHash.length === 0) {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
const skipSet = skip instanceof Set ? skip : new Set(skip !== undefined ? [skip] : []);
|
|
85
|
+
const rels = [];
|
|
86
|
+
walkSorted(pristineDir, '', rels);
|
|
87
|
+
for (const rel of rels) {
|
|
88
|
+
if (skipSet.has(rel))
|
|
89
|
+
continue;
|
|
90
|
+
try {
|
|
91
|
+
if (sha256File(node_path_1.default.join(pristineDir, rel)) === recordedHash) {
|
|
92
|
+
return rel;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// unreadable candidate — keep scanning
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
/**
|
|
102
|
+
* #4135: recover a pristine baseline from the config dir's OWN git history,
|
|
103
|
+
* anchored by the recorded pristine_hashes entry.
|
|
104
|
+
*
|
|
105
|
+
* The #3407 promotion rule keeps only regeneration candidates byte-identical
|
|
106
|
+
* across the whole version span, so a multi-version update leaves
|
|
107
|
+
* gsd-pristine/ holding exactly the files upstream did NOT change — near-zero
|
|
108
|
+
* coverage precisely where upstream churned the most. On a git-managed config
|
|
109
|
+
* dir the outgoing bytes often still exist in history (the workflow's
|
|
110
|
+
* documented Option A), and pristine_hashes is the same authority every other
|
|
111
|
+
* resolution tier trusts: a blob whose SHA-256 equals the recorded hash cannot
|
|
112
|
+
* be the wrong baseline. This is read-only recovery (git log / git show only).
|
|
113
|
+
*
|
|
114
|
+
* Guarantees:
|
|
115
|
+
* - Only an EXACT sha-256 match with the recorded hash is ever returned.
|
|
116
|
+
* - Newest-first commit order (git log default) makes multi-match resolution
|
|
117
|
+
* deterministic; byte-identical matches are interchangeable anyway.
|
|
118
|
+
* - Any failure (git absent, not a repository, empty history, unreadable
|
|
119
|
+
* blob, subprocess timeout) yields null — never a throw — so the caller's
|
|
120
|
+
* OK_NO_BASELINE posture is the universal fallback.
|
|
121
|
+
* - The walk is bounded: at most GIT_MAX_COMMITS_PER_FILE commits per file.
|
|
122
|
+
*/
|
|
123
|
+
const GIT_MAX_COMMITS_PER_FILE = 100;
|
|
124
|
+
/** Per-subprocess bound in ms — an unbounded git call is an indefinite hang. */
|
|
125
|
+
const GIT_SUBPROCESS_TIMEOUT_MS = 10_000;
|
|
126
|
+
/** git log --format=%H output cap; 100 full shas are ~4 KB, this is headroom. */
|
|
127
|
+
const GIT_MAX_BUFFER_BYTES = 16 * 1024 * 1024;
|
|
128
|
+
function isCleanRelativePosixPath(relPath) {
|
|
129
|
+
if (!relPath || relPath.startsWith('/') || relPath.includes('\\') || relPath.includes('\0')) {
|
|
130
|
+
return false;
|
|
131
|
+
}
|
|
132
|
+
const segments = relPath.split('/');
|
|
133
|
+
return segments.every((seg) => seg.length > 0 && seg !== '.' && seg !== '..');
|
|
134
|
+
}
|
|
135
|
+
function gitExec(gitDir, args) {
|
|
136
|
+
return (0, node_child_process_1.execFileSync)('git', args, {
|
|
137
|
+
cwd: gitDir,
|
|
138
|
+
encoding: 'utf8',
|
|
139
|
+
timeout: GIT_SUBPROCESS_TIMEOUT_MS,
|
|
140
|
+
maxBuffer: GIT_MAX_BUFFER_BYTES,
|
|
141
|
+
// windowsHide (#685): a console-window flash per git call would spam the
|
|
142
|
+
// user on Windows for what is a background, read-only history walk.
|
|
143
|
+
windowsHide: true,
|
|
144
|
+
// stderr is discarded: "file absent in commit" is an expected walk outcome,
|
|
145
|
+
// not operator-visible diagnostics.
|
|
146
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
147
|
+
});
|
|
148
|
+
}
|
|
149
|
+
function findPristineInGit(gitDir, relPath, recordedHash) {
|
|
150
|
+
if (!gitDir || typeof relPath !== 'string' || typeof recordedHash !== 'string'
|
|
151
|
+
|| recordedHash.length === 0 || !isCleanRelativePosixPath(relPath)) {
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
let commits;
|
|
155
|
+
try {
|
|
156
|
+
const logOutput = gitExec(gitDir, ['log', '--format=%H', '--', relPath]).trim();
|
|
157
|
+
if (!logOutput)
|
|
158
|
+
return null;
|
|
159
|
+
commits = logOutput.split('\n').slice(0, GIT_MAX_COMMITS_PER_FILE);
|
|
160
|
+
}
|
|
161
|
+
catch {
|
|
162
|
+
return null; // git absent, not a repository, or the walk failed
|
|
163
|
+
}
|
|
164
|
+
for (const commit of commits) {
|
|
165
|
+
if (!/^[0-9a-f]{40}$/i.test(commit))
|
|
166
|
+
continue;
|
|
167
|
+
try {
|
|
168
|
+
const blob = gitExec(gitDir, ['show', `${commit}:${relPath}`]);
|
|
169
|
+
if (sha256String(blob) === recordedHash) {
|
|
170
|
+
return blob;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
catch {
|
|
174
|
+
// blob absent in this commit (rename/add boundary) — keep walking
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return null;
|
|
178
|
+
}
|
|
179
|
+
/** sha256 of a utf8 string, matching how manifest hashes are recorded. */
|
|
180
|
+
function sha256String(content) {
|
|
181
|
+
return node_crypto_1.default.createHash('sha256').update(content, 'utf8').digest('hex');
|
|
182
|
+
}
|
|
@@ -362,6 +362,93 @@ function childEnv() {
|
|
|
362
362
|
const NODE_TEST_TIMEOUT_MS = 30_000;
|
|
363
363
|
const ESLINT_TIMEOUT_MS = 60_000;
|
|
364
364
|
const CHECK_MAX_BUFFER = 16 * 1024 * 1024;
|
|
365
|
+
/** Windows `taskkill` resolved by ABSOLUTE path — never a bare PATH-resolved name. A project
|
|
366
|
+
* directory used as the child's `cwd` could otherwise contain a planted `taskkill.exe`/`.bat`
|
|
367
|
+
* that Windows executable resolution picks up ahead of the real one (#3660 review, minor-9).
|
|
368
|
+
* Returns null (never a hardcoded fallback, per tests/hardcoded-paths.test.cjs) when neither env
|
|
369
|
+
* var is set -- not expected on a real Windows host (both are set by the OS itself), but a
|
|
370
|
+
* hostile/stripped env should degrade to "skip the reap" rather than guess a system path. */
|
|
371
|
+
function taskkillPath() {
|
|
372
|
+
const root = process.env.SystemRoot || process.env.windir;
|
|
373
|
+
return root ? node_path_1.default.join(root, 'System32', 'taskkill.exe') : null;
|
|
374
|
+
}
|
|
375
|
+
/**
|
|
376
|
+
* Reap the process TREE rooted at `pid` after THIS call's own bound killed it (#3660: `node --test`
|
|
377
|
+
* forks a per-file WORKER by default since Node 22 — `execFileSync`'s `timeout` signals only the
|
|
378
|
+
* direct child/runner, never the worker, which is reparented to PID 1 and can busy-loop forever).
|
|
379
|
+
*
|
|
380
|
+
* POSIX: the child was spawned `detached` (its own process group, pgid === pid), so `-pid` addresses
|
|
381
|
+
* the whole group. ESRCH (group already gone) is swallowed — the subject may have exited on its own
|
|
382
|
+
* between the timeout firing and this call.
|
|
383
|
+
*
|
|
384
|
+
* Windows has no process-group equivalent; `taskkill /PID <pid> /T /F` walks the live process tree by
|
|
385
|
+
* parent-PID instead, which does not require the parent PID to still be alive. A non-zero exit means
|
|
386
|
+
* "nothing left to kill" (already gone, or never had descendants) — not a failure, so it is never
|
|
387
|
+
* escalated; there is no portable stronger primitive to escalate TO.
|
|
388
|
+
*
|
|
389
|
+
* Never throws — this runs from a `catch`/`finally` path and must not itself become the error.
|
|
390
|
+
*/
|
|
391
|
+
function reapDescendants(pid) {
|
|
392
|
+
if (typeof pid !== 'number' || pid <= 0)
|
|
393
|
+
return; // defensive: never signal pid 0 (self) or negative
|
|
394
|
+
if (process.platform === 'win32') {
|
|
395
|
+
const exe = taskkillPath();
|
|
396
|
+
if (!exe)
|
|
397
|
+
return; // no safe absolute path available -- best-effort, skip rather than guess
|
|
398
|
+
try {
|
|
399
|
+
// 5s bound (DEFECT.UNBOUNDED-SUBPROCESS): a local OS command, not a network call -- if
|
|
400
|
+
// taskkill itself hangs, this function's own "never throws" contract already treats that
|
|
401
|
+
// identically to any other failure (swallowed below), so a bounded timeout costs nothing
|
|
402
|
+
// and just prevents a stuck taskkill from blocking the caller forever.
|
|
403
|
+
(0, node_child_process_1.spawnSync)(exe, ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 5_000 });
|
|
404
|
+
}
|
|
405
|
+
catch {
|
|
406
|
+
// best-effort: a missing taskkill.exe (or a timeout) is not this call's problem to escalate
|
|
407
|
+
}
|
|
408
|
+
return;
|
|
409
|
+
}
|
|
410
|
+
try {
|
|
411
|
+
process.kill(-pid, 'SIGKILL');
|
|
412
|
+
}
|
|
413
|
+
catch {
|
|
414
|
+
// Swallows ESRCH (group already gone -- nothing to reap) and any other errno (e.g. EPERM) --
|
|
415
|
+
// this helper never throws regardless of cause; see the function's own doc comment above.
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* `execFileSync`, with descendant reaping layered on top. Same contract (same return value, throws
|
|
420
|
+
* the identical error) EXCEPT that when — and ONLY when — this call's OWN timeout killed the child,
|
|
421
|
+
* any descendants the child forked are also reaped.
|
|
422
|
+
*
|
|
423
|
+
* Gated on `error.code === 'ETIMEDOUT'`, NOT `error.signal`. `signal` is the field Node's own docs
|
|
424
|
+
* describe for this purpose, but it is not reliably populated across platforms/Node versions: on one
|
|
425
|
+
* real Linux CI run (Node 24) a genuine timeout-kill threw `{ signal: null, code: 'ETIMEDOUT',
|
|
426
|
+
* status: 7 }` — `signal` was simply absent, `code` was the only reliable marker (confirmed empirically
|
|
427
|
+
* before landing this; a macOS/Node run separately showed `signal: 'SIGTERM'` for the identical
|
|
428
|
+
* scenario, so neither field alone is safe to rely on everywhere — `code` was the one constant).
|
|
429
|
+
* Gating strictly on the timeout code (rather than reaping on every throw) matters: an ordinary
|
|
430
|
+
* non-zero exit (a real test/lint failure) has no `ETIMEDOUT` code — the child exited on its own, so a
|
|
431
|
+
* reap there would fire on every red run for no reason and, on POSIX, risks signalling a process group
|
|
432
|
+
* whose pgid was *already* recycled by something unrelated in the time since (the #3660 review's
|
|
433
|
+
* Blocker-3 defect in the prior attempt at this fix, PR #3681). Only the timeout-kill path is targeted.
|
|
434
|
+
*
|
|
435
|
+
* Spawns `detached` on POSIX so the reap above can address the whole process group; omitted on
|
|
436
|
+
* Windows (no such flag there — `@types/node`'s `ExecFileSyncOptions` doesn't declare `detached`
|
|
437
|
+
* either, hence the cast below, though libuv honors it identically to `spawnSync`).
|
|
438
|
+
*/
|
|
439
|
+
function execFileSyncReaping(file, args, options) {
|
|
440
|
+
const spawnOptions = process.platform === 'win32' ? options : { ...options, detached: true };
|
|
441
|
+
try {
|
|
442
|
+
return (0, node_child_process_1.execFileSync)(file, args, spawnOptions);
|
|
443
|
+
}
|
|
444
|
+
catch (e) {
|
|
445
|
+
const err = e;
|
|
446
|
+
if (typeof err.pid === 'number' && err.code === 'ETIMEDOUT') {
|
|
447
|
+
reapDescendants(err.pid);
|
|
448
|
+
}
|
|
449
|
+
throw e;
|
|
450
|
+
}
|
|
451
|
+
}
|
|
365
452
|
/** Resolve the effective timeout: only a POSITIVE override is honored — `0` (which Node treats as
|
|
366
453
|
* "no timeout") or a negative value falls back to the bounded default, so the subprocess is ALWAYS
|
|
367
454
|
* bounded (a `timeoutMs: 0` injection can never disable the bound). */
|
|
@@ -378,7 +465,7 @@ function posTimeout(timeoutMs, def) {
|
|
|
378
465
|
*/
|
|
379
466
|
function runNodeTestWithSubject(check, cwd, subject, timeoutMs) {
|
|
380
467
|
try {
|
|
381
|
-
return (
|
|
468
|
+
return execFileSyncReaping(process.execPath, buildNodeTestArgs(check), {
|
|
382
469
|
cwd,
|
|
383
470
|
encoding: 'utf-8',
|
|
384
471
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -398,7 +485,7 @@ function defaultRunCheck(check, cwd, timeoutMs) {
|
|
|
398
485
|
if (check.kind === 'node-test') {
|
|
399
486
|
let out = '';
|
|
400
487
|
try {
|
|
401
|
-
out = (
|
|
488
|
+
out = execFileSyncReaping(process.execPath, buildNodeTestArgs(check), {
|
|
402
489
|
cwd,
|
|
403
490
|
encoding: 'utf-8',
|
|
404
491
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -422,7 +509,7 @@ function defaultRunCheck(check, cwd, timeoutMs) {
|
|
|
422
509
|
return { passed: false }; // eslint not installed -> fail closed, never throw
|
|
423
510
|
let json = '';
|
|
424
511
|
try {
|
|
425
|
-
json = (
|
|
512
|
+
json = execFileSyncReaping(process.execPath, [eslintCli, ...buildLintArgs(check)], {
|
|
426
513
|
cwd,
|
|
427
514
|
encoding: 'utf-8',
|
|
428
515
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -482,7 +569,7 @@ function defaultProveFailFirst(check, cwd, timeoutMs) {
|
|
|
482
569
|
return { provenFailFirst: false }; // eslint not installed -> fail closed, never throw
|
|
483
570
|
let json = '';
|
|
484
571
|
try {
|
|
485
|
-
json = (
|
|
572
|
+
json = execFileSyncReaping(process.execPath, [eslintCli, ...buildLintArgs({ ...check, target: fixture })], {
|
|
486
573
|
cwd,
|
|
487
574
|
encoding: 'utf-8',
|
|
488
575
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
@@ -210,7 +210,7 @@ function parseTaskListFromFile(cwd, filePath) {
|
|
|
210
210
|
const root = planningRoot(cwd);
|
|
211
211
|
let safePath;
|
|
212
212
|
try {
|
|
213
|
-
safePath = (0, security_cjs_1.requireSafePath)(filePath, root, 'quick-batch --file',
|
|
213
|
+
safePath = (0, security_cjs_1.requireSafePath)(filePath, root, 'quick-batch --file', security_cjs_1.PathAcceptance.AbsoluteInsideRoot);
|
|
214
214
|
}
|
|
215
215
|
catch (err) {
|
|
216
216
|
return { ok: false, reason: err instanceof Error ? err.message : String(err) };
|