@opengsd/gsd-core 1.10.0 → 1.12.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-code-fixer.md +1 -1
- package/agents/gsd-debug-session-manager.md +12 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-doc-synthesizer.md +2 -4
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +22 -14
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +5 -3
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +23 -2
- package/agents/gsd-plan-checker.md +50 -53
- package/agents/gsd-planner.md +50 -50
- package/agents/gsd-project-researcher.md +1 -1
- package/agents/gsd-research-synthesizer.md +2 -2
- package/agents/gsd-roadmapper.md +15 -11
- package/agents/gsd-ui-checker.md +63 -4
- package/agents/gsd-ui-researcher.md +41 -3
- package/agents/gsd-user-profiler.md +3 -0
- package/agents/gsd-verifier.md +13 -4
- package/bin/install.js +1448 -1103
- package/commands/gsd/code-review.md +1 -1
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/map-codebase.md +1 -1
- package/commands/gsd/mempalace-capture.md +1 -1
- package/commands/gsd/mempalace-recall.md +1 -1
- package/commands/gsd/new-milestone.md +1 -1
- package/commands/gsd/quick.md +9 -5
- package/commands/gsd/review-backlog.md +2 -1
- package/commands/gsd/verify-work.md +1 -1
- package/gsd-core/bin/gsd-tools.cjs +1035 -138
- package/gsd-core/bin/lib/active-workstream-store.cjs +146 -22
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +392 -32
- package/gsd-core/bin/lib/api-coverage.cjs +33 -14
- package/gsd-core/bin/lib/artifacts.cjs +5 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +32 -15
- package/gsd-core/bin/lib/audit-command-router.cjs +9 -2
- package/gsd-core/bin/lib/audit.cjs +1026 -268
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-consent.cjs +149 -15
- package/gsd-core/bin/lib/capability-lifecycle.cjs +45 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +845 -130
- package/gsd-core/bin/lib/capability-source.cjs +92 -0
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-trust.cjs +444 -25
- package/gsd-core/bin/lib/capability-validator.cjs +700 -40
- package/gsd-core/bin/lib/capability-writer.cjs +3 -2
- package/gsd-core/bin/lib/check-command-router.cjs +216 -42
- package/gsd-core/bin/lib/claude-orchestration.cjs +56 -3
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/code-review-depth.cjs +288 -0
- package/gsd-core/bin/lib/codex-agent-toml.cjs +735 -0
- package/gsd-core/bin/lib/command-aliases.cjs +22 -0
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-roster.cjs +44 -1
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +1172 -108
- package/gsd-core/bin/lib/commonjs-marker.cjs +12 -6
- package/gsd-core/bin/lib/complexity-trigger.cjs +1192 -0
- package/gsd-core/bin/lib/config-loader.cjs +187 -23
- package/gsd-core/bin/lib/config.cjs +102 -3
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +208 -33
- package/gsd-core/bin/lib/decisions.cjs +23 -0
- package/gsd-core/bin/lib/edge-probe.cjs +9 -1
- package/gsd-core/bin/lib/estimate-cli.cjs +55 -11
- package/gsd-core/bin/lib/exit-code-registry.cjs +98 -0
- package/gsd-core/bin/lib/fallow-runner.cjs +20 -44
- package/gsd-core/bin/lib/frontmatter.cjs +899 -229
- package/gsd-core/bin/lib/gap-checker.cjs +95 -10
- package/gsd-core/bin/lib/git-base-branch.cjs +276 -39
- package/gsd-core/bin/lib/gsd2-import.cjs +10 -1
- package/gsd-core/bin/lib/health-diagnostic-rules/agent-install.cjs +101 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/config-validation.cjs +348 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +149 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/install-surface-shadowing.cjs +98 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/milestone-archive-hygiene.cjs +100 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +222 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +268 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/root-existence.cjs +161 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +303 -0
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +187 -0
- package/gsd-core/bin/lib/health-diagnostic-types.cjs +68 -0
- package/gsd-core/bin/lib/health-diagnostic.cjs +451 -0
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/host-runtime-detection.cjs +134 -0
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +439 -168
- package/gsd-core/bin/lib/install-effort-resolver.cjs +73 -30
- package/gsd-core/bin/lib/install-engine.cjs +811 -259
- package/gsd-core/bin/lib/install-fs-adapter.cjs +262 -0
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +235 -0
- package/gsd-core/bin/lib/install-profiles.cjs +212 -61
- package/gsd-core/bin/lib/install-scope.cjs +270 -0
- package/gsd-core/bin/lib/install-shadow-report.cjs +385 -0
- package/gsd-core/bin/lib/installed-surface-resolver.cjs +381 -0
- package/gsd-core/bin/lib/installer-migration-report.cjs +3 -0
- package/gsd-core/bin/lib/installer-migrations/010-antigravity-retire-confighome-artifacts.cjs +169 -0
- package/gsd-core/bin/lib/installer-migrations.cjs +148 -38
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +170 -15
- package/gsd-core/bin/lib/learnings.cjs +85 -14
- package/gsd-core/bin/lib/legacy-cleanup.cjs +8 -2
- package/gsd-core/bin/lib/markdown-sectionizer.cjs +2 -1
- package/gsd-core/bin/lib/markdown-table.cjs +183 -22
- package/gsd-core/bin/lib/milestone-lock.cjs +248 -0
- package/gsd-core/bin/lib/milestone.cjs +842 -73
- package/gsd-core/bin/lib/model-catalog.cjs +232 -16
- package/gsd-core/bin/lib/model-resolver.cjs +193 -68
- package/gsd-core/bin/lib/normalize-test-command.cjs +1 -1
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/pattern.cjs +122 -0
- package/gsd-core/bin/lib/phase-estimation.cjs +18 -9
- package/gsd-core/bin/lib/phase-id.cjs +514 -40
- package/gsd-core/bin/lib/phase-lifecycle.cjs +52 -19
- package/gsd-core/bin/lib/phase-locator.cjs +262 -34
- package/gsd-core/bin/lib/phase.cjs +1038 -214
- package/gsd-core/bin/lib/plan-dependency-graph.cjs +72 -1
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-drift-guard.cjs +120 -0
- package/gsd-core/bin/lib/plan-scan.cjs +98 -3
- package/gsd-core/bin/lib/planning-command-router.cjs +61 -0
- package/gsd-core/bin/lib/planning-inspect.cjs +1168 -0
- package/gsd-core/bin/lib/planning-scope.cjs +31 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +894 -0
- package/gsd-core/bin/lib/planning-workspace.cjs +112 -6
- package/gsd-core/bin/lib/probe-core.cjs +5 -2
- package/gsd-core/bin/lib/profile-output.cjs +1 -1
- package/gsd-core/bin/lib/profile-pipeline-command-router.cjs +50 -7
- package/gsd-core/bin/lib/profile-pipeline.cjs +6 -3
- package/gsd-core/bin/lib/real-home-guard.cjs +419 -0
- package/gsd-core/bin/lib/refactor-trigger-command-router.cjs +766 -0
- package/gsd-core/bin/lib/retired-artifact-cleanup.cjs +11 -6
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +22 -13
- package/gsd-core/bin/lib/review-lane-invocation.cjs +30 -0
- package/gsd-core/bin/lib/review-lane-runner.cjs +421 -66
- package/gsd-core/bin/lib/review-reviewer-selection.cjs +13 -18
- package/gsd-core/bin/lib/roadmap-command-router.cjs +59 -11
- package/gsd-core/bin/lib/roadmap-parser.cjs +1006 -184
- package/gsd-core/bin/lib/roadmap-upgrade.cjs +37 -10
- package/gsd-core/bin/lib/roadmap.cjs +442 -96
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +702 -52
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +14 -2
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +459 -55
- package/gsd-core/bin/lib/runtime-config-adapter-registry.cjs +3 -2
- package/gsd-core/bin/lib/runtime-homes.cjs +69 -3
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +402 -58
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-name-policy.cjs +3 -1
- package/gsd-core/bin/lib/runtime-slash.cjs +96 -8
- package/gsd-core/bin/lib/security.cjs +104 -5
- package/gsd-core/bin/lib/shell-command-projection.cjs +342 -7
- package/gsd-core/bin/lib/smart-entry.cjs +133 -23
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +52 -19
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +338 -8
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +846 -176
- package/gsd-core/bin/lib/state.cjs +2589 -369
- package/gsd-core/bin/lib/surface.cjs +33 -11
- package/gsd-core/bin/lib/task-command-router.cjs +111 -1
- package/gsd-core/bin/lib/task-content-resolution.cjs +368 -0
- package/gsd-core/bin/lib/teams-status.cjs +4 -1
- package/gsd-core/bin/lib/text-lines.cjs +80 -0
- package/gsd-core/bin/lib/token-scanner.cjs +76 -0
- package/gsd-core/bin/lib/uat-predicate.cjs +67 -23
- package/gsd-core/bin/lib/uat.cjs +1761 -167
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-frontend-evidence.cjs +157 -0
- package/gsd-core/bin/lib/ui-safety-gate.cjs +51 -12
- package/gsd-core/bin/lib/unusable-input.cjs +37 -0
- package/gsd-core/bin/lib/update-context.cjs +8 -2
- package/gsd-core/bin/lib/user-artifact-staging.cjs +705 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +20 -6
- package/gsd-core/bin/lib/vendor/README.md +75 -0
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/vendor/re2js.cjs +6480 -0
- package/gsd-core/bin/lib/vendor/re2js.d.cts +938 -0
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +272 -9
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +453 -918
- package/gsd-core/bin/lib/workstream-inventory-builder.cjs +53 -32
- package/gsd-core/bin/lib/workstream-inventory.cjs +63 -10
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/workstream.cjs +2 -2
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +341 -18
- package/gsd-core/bin/shared/config-defaults.manifest.json +8 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +12 -1
- package/gsd-core/bin/shared/exit-codes.json +8 -0
- package/gsd-core/bin/shared/exit-codes.sh +20 -0
- package/gsd-core/bin/shared/model-catalog.json +8 -1
- package/gsd-core/references/agent-contracts.md +44 -26
- package/gsd-core/references/api-coverage.md +24 -2
- package/gsd-core/references/autonomous-smart-discuss.md +3 -3
- package/gsd-core/references/checkpoints.md +39 -21
- package/gsd-core/references/context-budget.md +1 -1
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/dispatch-isolation-gate.md +138 -0
- package/gsd-core/references/doc-conflict-engine.md +1 -1
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +4 -6
- package/gsd-core/references/execute-phase-between-wave-reset.md +15 -14
- package/gsd-core/references/execute-phase-context-guard.md +1 -1
- package/gsd-core/references/execute-phase-response-language.md +1 -1
- package/gsd-core/references/execute-phase-wave-guard.md +17 -11
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/gate-prompts.md +1 -1
- package/gsd-core/references/git-integration.md +5 -5
- package/gsd-core/references/git-planning-commit.md +5 -4
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +61 -2
- package/gsd-core/references/model-profiles.md +12 -4
- package/gsd-core/references/mvp-concepts.md +9 -9
- package/gsd-core/references/nyquist-compliance.md +74 -0
- package/gsd-core/references/offer-next.md +3 -5
- package/gsd-core/references/phase-argument-parsing.md +3 -3
- package/gsd-core/references/planner-failing-direction.md +53 -0
- package/gsd-core/references/planner-guidance.md +3 -9
- package/gsd-core/references/planner-human-verify-mode.md +15 -1
- package/gsd-core/references/planner-preconditions.md +1 -1
- package/gsd-core/references/planner-reviews.md +1 -1
- package/gsd-core/references/planner-revision.md +1 -1
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +44 -13
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +1 -1
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/specless-probe-fallback.md +1 -1
- package/gsd-core/references/tdd.md +1 -3
- package/gsd-core/references/ui-brand.md +65 -21
- package/gsd-core/references/ui-consideration-probe.md +1 -1
- package/gsd-core/references/universal-anti-patterns.md +5 -5
- package/gsd-core/references/verifier-phase-gates.md +192 -0
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +2 -2
- package/gsd-core/references/workstream-flag.md +33 -17
- package/gsd-core/templates/README.md +1 -1
- package/gsd-core/templates/SECURITY.md +3 -3
- package/gsd-core/templates/UI-SPEC.md +25 -3
- package/gsd-core/templates/VALIDATION.md +3 -3
- package/gsd-core/templates/discussion-log.md +1 -1
- package/gsd-core/templates/phase-prompt.md +5 -4
- package/gsd-core/templates/state.md +11 -4
- package/gsd-core/templates/verification-report.md +9 -1
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +1 -1
- package/gsd-core/workflows/add-phase.md +3 -3
- package/gsd-core/workflows/add-tests.md +3 -8
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +13 -20
- package/gsd-core/workflows/audit-fix.md +12 -3
- package/gsd-core/workflows/audit-milestone.md +9 -9
- package/gsd-core/workflows/audit-uat.md +17 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +11 -27
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +64 -5
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +14 -4
- package/gsd-core/workflows/code-review-fix.md +38 -11
- package/gsd-core/workflows/code-review.md +159 -52
- package/gsd-core/workflows/complete-milestone.md +151 -23
- package/gsd-core/workflows/debug.md +12 -8
- package/gsd-core/workflows/diagnose-issues.md +47 -15
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -8
- package/gsd-core/workflows/discuss-phase/modes/default.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/text.md +1 -1
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +4 -3
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +3 -6
- package/gsd-core/workflows/docs-update.md +5 -4
- package/gsd-core/workflows/edit-phase.md +27 -2
- package/gsd-core/workflows/eval-review.md +7 -14
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +142 -15
- package/gsd-core/workflows/execute-phase/steps/gap-closure-artifacts.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/partial-wave.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-executor-routing.md +77 -0
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +24 -4
- package/gsd-core/workflows/execute-phase/steps/post-merge-gate.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/protected-branch.md +21 -0
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -2
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase.md +72 -100
- package/gsd-core/workflows/execute-plan.md +52 -15
- package/gsd-core/workflows/explore.md +131 -4
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +10 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +76 -10
- package/gsd-core/workflows/import.md +18 -15
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +49 -16
- package/gsd-core/workflows/insert-phase.md +5 -5
- package/gsd-core/workflows/list-seeds.md +5 -3
- package/gsd-core/workflows/list-workspaces.md +1 -1
- package/gsd-core/workflows/manager.md +12 -23
- package/gsd-core/workflows/map-codebase.md +1 -1
- package/gsd-core/workflows/milestone-summary.md +1 -1
- package/gsd-core/workflows/mvp-phase.md +8 -5
- package/gsd-core/workflows/new-milestone.md +22 -29
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +26 -40
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +14 -2
- package/gsd-core/workflows/pause-work.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/adr-ingest-express-path.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +1 -1
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +3 -3
- package/gsd-core/workflows/plan-phase.md +162 -59
- package/gsd-core/workflows/plan-review-convergence.md +96 -11
- package/gsd-core/workflows/plant-seed.md +2 -2
- package/gsd-core/workflows/pr-branch.md +187 -51
- package/gsd-core/workflows/profile-user.md +16 -14
- package/gsd-core/workflows/progress.md +61 -18
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +5 -7
- package/gsd-core/workflows/quick/steps/quick-verification.md +28 -9
- package/gsd-core/workflows/quick/steps/research-phase.md +4 -6
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +55 -44
- package/gsd-core/workflows/remove-phase.md +4 -4
- package/gsd-core/workflows/remove-workspace.md +2 -2
- package/gsd-core/workflows/resume-project.md +8 -12
- package/gsd-core/workflows/review.md +219 -20
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -1
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +69 -7
- package/gsd-core/workflows/ship.md +116 -50
- package/gsd-core/workflows/sketch-wrap-up.md +11 -17
- package/gsd-core/workflows/sketch.md +12 -18
- package/gsd-core/workflows/smart-entry.md +3 -5
- package/gsd-core/workflows/spec-phase.md +53 -13
- package/gsd-core/workflows/spike-wrap-up.md +7 -11
- package/gsd-core/workflows/spike.md +20 -31
- package/gsd-core/workflows/stats.md +2 -2
- package/gsd-core/workflows/sync-skills.md +64 -9
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +49 -14
- package/gsd-core/workflows/ui-phase.md +15 -21
- package/gsd-core/workflows/ui-review.md +8 -12
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +7 -11
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +66 -25
- package/hooks/dist/gsd-agent-isolation-guard.js +158 -30
- package/hooks/dist/gsd-check-update-worker.js +56 -13
- package/hooks/dist/gsd-check-update.js +19 -1
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +19 -10
- package/hooks/dist/gsd-cursor-post-tool.js +3 -1
- package/hooks/dist/gsd-cursor-pre-tool.js +2 -3
- package/hooks/dist/gsd-cursor-session-start.js +2 -1
- package/hooks/dist/gsd-cursor-stop.js +2 -1
- package/hooks/dist/gsd-cursor-subagent-start.js +83 -3
- package/hooks/dist/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/dist/gsd-ensure-canonical-path.js +2 -1
- package/hooks/dist/gsd-graphify-update.sh +22 -18
- package/hooks/dist/gsd-node-runner.sh +76 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +37 -27
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +55 -32
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +231 -24
- package/hooks/dist/gsd-update-banner.js +22 -1
- package/hooks/dist/gsd-validate-commit.sh +80 -6
- package/hooks/dist/gsd-windsurf-pre-command.js +16 -11
- package/hooks/dist/gsd-windsurf-pre-write.js +22 -13
- package/hooks/dist/gsd-workflow-guard.js +162 -46
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +92 -59
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +45 -0
- package/hooks/dist/lib/isolation-deny-reason.js +39 -0
- package/hooks/dist/lib/isolation-sentinel.js +9 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +158 -30
- package/hooks/gsd-check-update-worker.js +56 -13
- package/hooks/gsd-check-update.js +19 -1
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +19 -10
- package/hooks/gsd-cursor-post-tool.js +3 -1
- package/hooks/gsd-cursor-pre-tool.js +2 -3
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +83 -3
- package/hooks/gsd-cursor-subagent-stop.js +6 -3
- package/hooks/gsd-ensure-canonical-path.js +2 -1
- package/hooks/gsd-graphify-update.sh +22 -18
- package/hooks/gsd-node-runner.sh +76 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +37 -27
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +55 -32
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +231 -24
- package/hooks/gsd-update-banner.js +22 -1
- package/hooks/gsd-validate-commit.sh +80 -6
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +162 -46
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +92 -59
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +45 -0
- package/hooks/lib/isolation-deny-reason.js +39 -0
- package/hooks/lib/isolation-sentinel.js +9 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +28 -11
- package/pi/gsd.cjs +19 -5
- package/scripts/base64-scan.sh +74 -12
- package/scripts/baselines/planning-prompt-drift-baseline.json +4 -0
- package/scripts/baselines/planning-snapshot-bypass-baseline.json +12 -0
- package/scripts/baselines/unreachable-guard-drift-baseline.json +4 -0
- package/scripts/build-hooks.js +5 -0
- package/scripts/changeset/lint.cjs +60 -5
- package/scripts/check-alias-drift.cjs +7 -43
- package/scripts/check-contract-drift.cjs +297 -0
- package/scripts/check-glossary-refs.cjs +77 -15
- package/scripts/check-mutation-score-ratchet.cjs +156 -0
- package/scripts/ci-check-job-near-cap.cjs +49 -0
- package/scripts/ci-pr-mergeability.cjs +262 -0
- package/scripts/ci-test-scope.cjs +64 -14
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/command-contract-helpers.cjs +903 -1
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-adr-index.cjs +728 -38
- package/scripts/gen-capability-registry.cjs +11 -21
- package/scripts/gen-context-index.cjs +2 -11
- package/scripts/gen-exit-code-docs.cjs +318 -0
- package/scripts/gen-exit-code-registry.cjs +891 -0
- package/scripts/gen-features.cjs +836 -0
- package/scripts/gen-health-docs.cjs +390 -0
- package/scripts/gen-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-inventory-manifest.cjs +50 -4
- package/scripts/gen-loop-host-contract.cjs +138 -25
- package/scripts/gen-registry.cjs +3 -14
- package/scripts/gen-scripts-cli-exit.cjs +185 -0
- package/scripts/gen-state-md-docs.cjs +727 -0
- package/scripts/{test-failure-reasons.cjs → gsd-test-gate-reasons.cjs} +6 -0
- package/scripts/lib/alias-drift-families.cjs +46 -0
- package/scripts/lib/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +308 -0
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +1 -26
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +4 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +3 -0
- package/scripts/lint-canary-version-leak.cjs +73 -0
- package/scripts/lint-command-contract.cjs +96 -13
- package/scripts/lint-completion-predicate-drift.cjs +933 -0
- package/scripts/lint-completion-ratio-drift.cjs +214 -0
- package/scripts/lint-default-flip-documentation.cjs +193 -0
- package/scripts/lint-docs-guard-registration.cjs +495 -0
- package/scripts/lint-docs-guard-registration.exempt-baseline.cjs +193 -0
- package/scripts/lint-eslint-glob-coverage.allowlist.json +38 -0
- package/scripts/lint-eslint-glob-coverage.cjs +340 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-frontmatter-scalar-broad-grep.cjs +237 -0
- package/scripts/lint-health-diagnostic-rule-table.cjs +461 -0
- package/scripts/lint-hooks-runtime-build-seam.cjs +262 -0
- package/scripts/lint-milestone-window-drift.cjs +468 -0
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +492 -0
- package/scripts/lint-plan-count-drift.cjs +318 -0
- package/scripts/lint-planning-artifact-writer-drift.cjs +398 -0
- package/scripts/lint-planning-prompt-drift.cjs +471 -0
- package/scripts/lint-planning-snapshot-bypass-drift.cjs +544 -0
- package/scripts/lint-regression-test-names.cjs +15 -13
- package/scripts/lint-removed-but-needed.cjs +488 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-field-drift.cjs +805 -0
- package/scripts/lint-state-write-path-drift.cjs +950 -0
- package/scripts/lint-test-file-count.allowlist.json +137 -8
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +830 -0
- package/scripts/lint-vendored-deps.cjs +297 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/pr-changed-files.cjs +63 -0
- package/scripts/pr-template-policy.cjs +14 -4
- package/scripts/prompt-injection-scan.sh +100 -14
- package/scripts/require-issue-link-policy.cjs +192 -0
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +24 -7
- package/skills/gsd-autonomous/SKILL.md +0 -1
- package/skills/gsd-code-review/SKILL.md +1 -1
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-execute-phase/SKILL.md +1 -2
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-map-codebase/SKILL.md +1 -1
- package/skills/gsd-mempalace-capture/SKILL.md +1 -1
- package/skills/gsd-mempalace-recall/SKILL.md +1 -1
- package/skills/gsd-new-milestone/SKILL.md +1 -1
- package/skills/gsd-next/SKILL.md +0 -1
- package/skills/gsd-plan-phase/SKILL.md +0 -1
- package/skills/gsd-progress/SKILL.md +0 -1
- package/skills/gsd-quick/SKILL.md +9 -5
- package/skills/gsd-review-backlog/SKILL.md +2 -1
- package/skills/gsd-stats/SKILL.md +0 -1
- package/skills/gsd-verify-work/SKILL.md +1 -1
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -107
- package/gsd-core/workflows/discovery-phase.md +0 -298
- package/gsd-core/workflows/plan-milestone-gaps.md +0 -281
- package/gsd-core/workflows/verify-phase.md +0 -574
- package/scripts/affected-tests-lib.cjs +0 -554
- package/scripts/lint-allow-test-rule-refs.cjs +0 -162
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/run-affected-tests.cjs +0 -7
- package/scripts/run-tests.cjs +0 -1051
|
@@ -5,6 +5,16 @@
|
|
|
5
5
|
* ADR-457 build-at-publish: the hand-written bin/lib/frontmatter.cjs collapsed
|
|
6
6
|
* to a TypeScript source of truth. Behaviour is preserved byte-for-behaviour
|
|
7
7
|
* from the prior hand-written .cjs; only strict types are added.
|
|
8
|
+
*
|
|
9
|
+
* ADR-3473 §8.1 (#3881): the read path is no longer a hand-rolled line
|
|
10
|
+
* scanner. `parseGuardedYamlRegion` now parses through the vendored `js-yaml`
|
|
11
|
+
* (`./vendor/js-yaml.cjs`, verbatim `node_modules/js-yaml/dist/js-yaml.js`)
|
|
12
|
+
* under `FAILSAFE_SCHEMA` + `json: true` — every scalar comes back a string
|
|
13
|
+
* (today's contract, no adapter needed) and duplicate keys overwrite
|
|
14
|
+
* (last-wins, the documented invariant). What js-yaml does NOT do —
|
|
15
|
+
* anchors/alias refusal, the #3257 comment channel, the #1882 truncation
|
|
16
|
+
* probe, null-byte preservation and object-list flattening for the existing
|
|
17
|
+
* string-shaped value contract — is layered on top, in one place, below.
|
|
8
18
|
*/
|
|
9
19
|
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
10
20
|
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
@@ -16,46 +26,21 @@ const ioMod = require("./io.cjs");
|
|
|
16
26
|
const { output, error } = ioMod;
|
|
17
27
|
const shell_command_projection_cjs_1 = require("./shell-command-projection.cjs");
|
|
18
28
|
const validate_cjs_1 = require("./validate.cjs");
|
|
29
|
+
const text_lines_cjs_1 = require("./text-lines.cjs");
|
|
19
30
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
20
31
|
const unusableInputMod = require("./unusable-input.cjs");
|
|
21
32
|
const { UNUSABLE_REASON, warnUnusableInput } = unusableInputMod;
|
|
33
|
+
const js_yaml_cjs_1 = require("./vendor/js-yaml.cjs");
|
|
22
34
|
// ─── Parsing engine ───────────────────────────────────────────────────────────
|
|
23
35
|
/**
|
|
24
|
-
*
|
|
25
|
-
*
|
|
36
|
+
* Base js-yaml options for every parse in this module (ADR-3473 §8.1 §3.1/§3.3).
|
|
37
|
+
* `FAILSAFE_SCHEMA` resolves only `!!str`/`!!seq`/`!!map` — every scalar comes
|
|
38
|
+
* back a string, which is today's contract and needs no coercion layer.
|
|
39
|
+
* `json: true` makes duplicate keys overwrite (last-wins) instead of throwing,
|
|
40
|
+
* which is the documented behavior `tests/fixtures/adversarial/frontmatter/
|
|
41
|
+
* duplicate-keys.md` pins.
|
|
26
42
|
*/
|
|
27
|
-
|
|
28
|
-
const items = [];
|
|
29
|
-
let current = '';
|
|
30
|
-
let inQuote = null;
|
|
31
|
-
for (let i = 0; i < body.length; i++) {
|
|
32
|
-
const ch = body[i];
|
|
33
|
-
if (inQuote) {
|
|
34
|
-
if (ch === inQuote) {
|
|
35
|
-
inQuote = null;
|
|
36
|
-
}
|
|
37
|
-
else {
|
|
38
|
-
current += ch;
|
|
39
|
-
}
|
|
40
|
-
}
|
|
41
|
-
else if (ch === '"' || ch === "'") {
|
|
42
|
-
inQuote = ch;
|
|
43
|
-
}
|
|
44
|
-
else if (ch === ',') {
|
|
45
|
-
const trimmed = current.trim();
|
|
46
|
-
if (trimmed)
|
|
47
|
-
items.push(trimmed);
|
|
48
|
-
current = '';
|
|
49
|
-
}
|
|
50
|
-
else {
|
|
51
|
-
current += ch;
|
|
52
|
-
}
|
|
53
|
-
}
|
|
54
|
-
const trimmed = current.trim();
|
|
55
|
-
if (trimmed)
|
|
56
|
-
items.push(trimmed);
|
|
57
|
-
return items;
|
|
58
|
-
}
|
|
43
|
+
const YAML_LOAD_OPTS = { schema: js_yaml_cjs_1.FAILSAFE_SCHEMA, json: true };
|
|
59
44
|
/**
|
|
60
45
|
* How many parsed keys an unterminated region must yield before it is reported as a
|
|
61
46
|
* truncated frontmatter rather than left alone as ordinary Markdown. See the rationale on
|
|
@@ -80,10 +65,12 @@ const UNTERMINATED_KEY_THRESHOLD = 2;
|
|
|
80
65
|
* a frontmatter block ends mid-block, so *every* line in the region is still frontmatter-shaped,
|
|
81
66
|
* whereas a document merely opening with a rule goes on to prose. So the region must be
|
|
82
67
|
* uniformly frontmatter-shaped AND carry enough keys to be worth reporting; either test alone
|
|
83
|
-
* has a false-positive class the other closes.
|
|
68
|
+
* has a false-positive class the other closes. This heuristic is deliberately a raw-text scan,
|
|
69
|
+
* independent of whichever parser counts the keys (see `countKeysBeforeTruncation`) — it is the
|
|
70
|
+
* guard that keeps a stricter parser from turning "opens with a rule" into a false positive.
|
|
84
71
|
*/
|
|
85
72
|
function isFrontmatterShaped(region) {
|
|
86
|
-
const lines =
|
|
73
|
+
const lines = (0, text_lines_cjs_1.splitLines)(region).filter((line) => line.trim() !== '');
|
|
87
74
|
if (lines.length === 0)
|
|
88
75
|
return false;
|
|
89
76
|
return lines.every((line) => (/^\s*[a-zA-Z0-9_-]+:/.test(line) // key: value
|
|
@@ -92,75 +79,541 @@ function isFrontmatterShaped(region) {
|
|
|
92
79
|
));
|
|
93
80
|
}
|
|
94
81
|
/**
|
|
95
|
-
*
|
|
82
|
+
* #3257: a Symbol-keyed channel that carries full-line (column-0 `#`) YAML
|
|
83
|
+
* comments through a parse → reconstruct round-trip. Comments are otherwise
|
|
84
|
+
* unrepresentable on the Frontmatter object (Record<string, ...>) and were
|
|
85
|
+
* silently dropped by reconstructFrontmatter. The Symbol is invisible to
|
|
86
|
+
* Object.entries / Object.keys / JSON.stringify / for-in, so every existing
|
|
87
|
+
* reader is unchanged; only reconstructFrontmatter reads it. Leading comments
|
|
88
|
+
* are attached to the top-level key that follows them; comments after the last
|
|
89
|
+
* key go to `trailing`. Only set when a comment is actually seen, so comment-less
|
|
90
|
+
* frontmatter parses byte-identically to before.
|
|
91
|
+
*/
|
|
92
|
+
const FULL_LINE_COMMENTS = Symbol('fullLineComments');
|
|
93
|
+
/**
|
|
94
|
+
* ADR-3473 §8.1 §0.3 (#3881, consequence 2): a Symbol-keyed marker carried on the `{}`
|
|
95
|
+
* `extractFrontmatter` returns when the region failed to parse (malformed YAML, or a refused
|
|
96
|
+
* anchor/alias/merge key — consequence 6). Mirrors `FULL_LINE_COMMENTS` exactly: invisible to
|
|
97
|
+
* `Object.keys`/`Object.entries`/`JSON.stringify`/`for-in`, so the 70 call sites that never
|
|
98
|
+
* inspect it are unaffected, while the 8 `hasFrontmatter = Object.keys(...).length > 0` sites
|
|
99
|
+
* consult it to tell "genuinely empty" apart from "unparseable" and avoid reassembling the
|
|
100
|
+
* document without its (unparsed but still present) frontmatter block. Those 8 call sites (7 in
|
|
101
|
+
* `state-transition.cts` behind `isUnparseableFrontmatter`/`rawFrontmatterPrefix`, plus 1 more in
|
|
102
|
+
* `state.cts`'s `cmdStateCompletePhase`) are wired on this branch — this module sets and exports
|
|
103
|
+
* the marker; the callers consume it.
|
|
104
|
+
*/
|
|
105
|
+
const FRONTMATTER_UNPARSEABLE = Symbol('frontmatterUnparseable');
|
|
106
|
+
function unparseableResult() {
|
|
107
|
+
// Plain-prototype (post-remote-runner-fix, #3881): the PUBLIC parse surface must keep
|
|
108
|
+
// handing callers ordinary `{}`-shaped objects — `assert.deepStrictEqual` compares
|
|
109
|
+
// prototypes, and 50+ existing call sites/tests compare against object literals. The
|
|
110
|
+
// Symbol marker is still attached via `Object.defineProperty` rather than bracket
|
|
111
|
+
// assignment, which is what actually matters for prototype-pollution safety: a data
|
|
112
|
+
// property named e.g. `__proto__` set through `defineProperty` never invokes the
|
|
113
|
+
// inherited `Object.prototype.__proto__` accessor setter the way `fm[k] = v` would.
|
|
114
|
+
const fm = {};
|
|
115
|
+
Object.defineProperty(fm, FRONTMATTER_UNPARSEABLE, {
|
|
116
|
+
value: true, writable: true, enumerable: true, configurable: true,
|
|
117
|
+
});
|
|
118
|
+
return fm;
|
|
119
|
+
}
|
|
120
|
+
/**
|
|
121
|
+
* Convert an internal, possibly null-prototype, YAML-derived value tree into an ordinary
|
|
122
|
+
* plain-prototype tree for the public parse surface (post-remote-runner-fix, #3881).
|
|
96
123
|
*
|
|
97
|
-
*
|
|
98
|
-
*
|
|
99
|
-
*
|
|
124
|
+
* The internal construction (`normalizeParsedValue`, `restoreNullBytesDeep`,
|
|
125
|
+
* `extractCommentChannel`) deliberately builds with `Object.create(null)` so a hostile key
|
|
126
|
+
* like `__proto__`/`constructor`/`toString` is always a genuine own data property and never
|
|
127
|
+
* resolves to (or overwrites) an inherited `Object.prototype` member WHILE THE TREE IS BEING
|
|
128
|
+
* BUILT. That safety property has nothing to do with what prototype the FINAL object callers
|
|
129
|
+
* receive — `assert.deepStrictEqual` compares prototypes, so handing back a null-prototype
|
|
130
|
+
* object silently broke every caller comparing against `{}` object literals (57+ tests). This
|
|
131
|
+
* walks the tree exactly once at the return boundary and re-homes every string/number-keyed
|
|
132
|
+
* own property onto an ordinary `{}` via `Object.defineProperty` (never `out[k] = v`), which
|
|
133
|
+
* is what keeps the copy itself safe: `defineProperty` always creates a real own data
|
|
134
|
+
* property, even for a key literally named `__proto__`, and never triggers the inherited
|
|
135
|
+
* setter the way bracket assignment would.
|
|
136
|
+
*
|
|
137
|
+
* The `FULL_LINE_COMMENTS` Symbol channel is copied across by reference, NOT recursed into —
|
|
138
|
+
* it stays null-prototype. It is purely internal plumbing (only `reconstructFrontmatter` /
|
|
139
|
+
* `propagateCommentChannel`, both in this module, ever read `channel.leading[key]` with an
|
|
140
|
+
* arbitrary user-authored key), invisible to every external reader (`Object.keys` /
|
|
141
|
+
* `Object.entries` / `JSON.stringify` / `for-in` all skip symbols), and re-plaining it would
|
|
142
|
+
* reopen the exact `leading[key]` inherited-member bug the null prototype exists to close.
|
|
100
143
|
*/
|
|
101
|
-
function
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
144
|
+
function toPlainValueTree(value) {
|
|
145
|
+
if (Array.isArray(value))
|
|
146
|
+
return value.map(toPlainValueTree);
|
|
147
|
+
if (value !== null && typeof value === 'object') {
|
|
148
|
+
const out = {};
|
|
149
|
+
for (const k of Object.keys(value)) {
|
|
150
|
+
Object.defineProperty(out, k, {
|
|
151
|
+
value: toPlainValueTree(value[k]),
|
|
152
|
+
writable: true, enumerable: true, configurable: true,
|
|
153
|
+
});
|
|
154
|
+
}
|
|
155
|
+
for (const s of Object.getOwnPropertySymbols(value)) {
|
|
156
|
+
Object.defineProperty(out, s, {
|
|
157
|
+
value: value[s], // internal channel: copied raw, not recursed
|
|
158
|
+
writable: true, enumerable: true, configurable: true,
|
|
159
|
+
});
|
|
160
|
+
}
|
|
161
|
+
return out;
|
|
162
|
+
}
|
|
163
|
+
return value;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* ADR-3473 §8.1 (consequence 6, corrected post-#3881-review): `FAILSAFE_SCHEMA` still resolves
|
|
167
|
+
* anchors, aliases and merge keys — that is core YAML mechanics, not tag resolution, so no
|
|
168
|
+
* schema choice disables it. A hostile 7-line frontmatter (`&a [...]` fanned out through nested
|
|
169
|
+
* aliases) expands to tens of megabytes in a few milliseconds, and `.planning/` documents are
|
|
170
|
+
* user-authored, untrusted input. Corpus occurrences of anchors/aliases/merge keys today: zero,
|
|
171
|
+
* so refusing them costs nothing.
|
|
172
|
+
*
|
|
173
|
+
* This was originally a raw-text line regex, and it was bypassable: a quoted key (`"a": &x 1`),
|
|
174
|
+
* a flow mapping (`{b: &x 1, c: *x}`) or a flow sequence (`[&x "q", *x]`) all define/use an
|
|
175
|
+
* anchor while never matching the "bareword key, then `&`/`*`" line shape the regex checked —
|
|
176
|
+
* so the exact expansion this guard exists to stop went straight through unrefused. Detecting a
|
|
177
|
+
* YAML anchor with a regex is re-implementing a YAML parser in order to guard a YAML parser; the
|
|
178
|
+
* fix is to let the real parser report it instead of re-deriving anchor syntax by hand. js-yaml's
|
|
179
|
+
* `load` accepts a `listener` invoked once per parse event with the parser's internal `State`;
|
|
180
|
+
* `state.anchor` is non-null on every event belonging to an anchored node, in every spelling
|
|
181
|
+
* above (verified by execution against all four), so throwing the instant it is set aborts the
|
|
182
|
+
* parse before any alias expansion happens — the 303-byte quoted-key bomb refuses in ~1ms rather
|
|
183
|
+
* than expanding to ~35MB. A `<<: *base` merge key is refused too, because it can only ever
|
|
184
|
+
* reference a previously anchored node — the alias itself trips `state.anchor`. A merge key with
|
|
185
|
+
* NO alias (`<<: {b: 1}`) carries no anchor and is not separately refused: under
|
|
186
|
+
* `FAILSAFE_SCHEMA` (no `!!merge` type resolution) it never actually merges — it parses as an
|
|
187
|
+
* ordinary literal `"<<"` string key with a normal, non-expanding nested map — so it carries none
|
|
188
|
+
* of the resource-exhaustion risk this guard exists for.
|
|
189
|
+
*/
|
|
190
|
+
/** Thrown from inside the `listener` callback below; never surfaced past `refuseAnchorsAndAliases`. */
|
|
191
|
+
class AnchorDetectedSignal extends Error {
|
|
192
|
+
}
|
|
193
|
+
function refuseAnchorsAndAliases(yaml) {
|
|
194
|
+
try {
|
|
195
|
+
(0, js_yaml_cjs_1.load)(yaml, {
|
|
196
|
+
...YAML_LOAD_OPTS,
|
|
197
|
+
listener: (_event, state) => {
|
|
198
|
+
// Thrown FROM INSIDE the listener, not merely recorded and checked after `load`
|
|
199
|
+
// returns: js-yaml keeps parsing (and, for an alias, keeps EXPANDING) past a listener
|
|
200
|
+
// that only sets a flag, which reintroduces the exact resource-exhaustion window this
|
|
201
|
+
// guard exists to close. Throwing here aborts the parse immediately, before any
|
|
202
|
+
// expansion — the billion-laughs fixture refuses in ~1-2ms rather than building the
|
|
203
|
+
// ~35MB tree first and discarding it.
|
|
204
|
+
if (state.anchor !== null && state.anchor !== undefined)
|
|
205
|
+
throw new AnchorDetectedSignal();
|
|
206
|
+
},
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
catch (e) {
|
|
210
|
+
if (e instanceof AnchorDetectedSignal) {
|
|
211
|
+
throw new js_yaml_cjs_1.YAMLException('frontmatter: anchors, aliases and merge keys are refused (ADR-3473 §8.1)');
|
|
212
|
+
}
|
|
213
|
+
// Any other failure (malformed YAML unrelated to anchors) is reported by the real parse
|
|
214
|
+
// in parseGuardedYamlRegion; this pre-pass only exists to refuse anchors/aliases early.
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* ADR-3473 §8.1 (consequence 7): js-yaml rejects a literal U+0000 unconditionally, under every
|
|
219
|
+
* schema. The fixture invariant (`null-byte-value.md`) is "preserve or normalize; never truncate
|
|
220
|
+
* silently", so the byte is swapped for a private-use sentinel before the parse and restored in
|
|
221
|
+
* every resulting string afterward — preserving the exact byte rather than normalizing it away.
|
|
222
|
+
*
|
|
223
|
+
* CORRECTED (post-#3881-review, finding 3): the round-trip was non-injective. `restoreNullBytesDeep`
|
|
224
|
+
* rewrites EVERY U+E000 in the parsed tree back to U+0000 — including one the document author
|
|
225
|
+
* legitimately wrote — so a document containing a literal U+E000 (with or without an actual NUL
|
|
226
|
+
* elsewhere) came back corrupted: its own U+E000 silently became a NUL. Rather than pick a
|
|
227
|
+
* "provably absent" sentinel (unprovable in general — any fixed codepoint can itself appear in
|
|
228
|
+
* user-authored input), `refuseIfSentinelPresent` makes the substitution provably reversible by
|
|
229
|
+
* refusing outright whenever the RAW region already contains U+E000, before any substitution
|
|
230
|
+
* happens — consistent with this module's existing refusal path (anchors/aliases/merge keys) for
|
|
231
|
+
* "cannot faithfully round-trip this input." Once refused, the sentinel is guaranteed absent from
|
|
232
|
+
* the input the escape/restore pair actually operates on, and the substitution is injective by
|
|
233
|
+
* construction.
|
|
234
|
+
*/
|
|
235
|
+
const NULL_BYTE_SENTINEL = String.fromCharCode(0xE000);
|
|
236
|
+
function refuseIfSentinelPresent(yaml) {
|
|
237
|
+
if (yaml.includes(NULL_BYTE_SENTINEL)) {
|
|
238
|
+
throw new js_yaml_cjs_1.YAMLException('frontmatter: contains the reserved null-byte-escape sentinel U+E000 — refused rather than ' +
|
|
239
|
+
'silently corrupted on restore (ADR-3473 §8.1)');
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
function escapeNullBytesForParse(yaml) {
|
|
243
|
+
return yaml.indexOf('\u0000') === -1 ? yaml : yaml.split('\u0000').join(NULL_BYTE_SENTINEL);
|
|
244
|
+
}
|
|
245
|
+
function restoreNullBytesDeep(value) {
|
|
246
|
+
if (typeof value === 'string') {
|
|
247
|
+
return value.includes(NULL_BYTE_SENTINEL) ? value.split(NULL_BYTE_SENTINEL).join('\u0000') : value;
|
|
248
|
+
}
|
|
249
|
+
if (Array.isArray(value))
|
|
250
|
+
return value.map(restoreNullBytesDeep);
|
|
251
|
+
if (value && typeof value === 'object') {
|
|
252
|
+
// Null-prototype (post-#3881-review, finding 3): an ordinary {} here silently DROPS a
|
|
253
|
+
// top-level key literally named __proto__ -- out['__proto__'] = v on a normal object
|
|
254
|
+
// invokes the inherited Object.prototype.__proto__ SETTER (reassigning the object's own
|
|
255
|
+
// prototype) instead of creating a data property, so key: __proto__ in a document
|
|
256
|
+
// vanishes from the parsed result with no error. Confirmed by execution: ---\n__proto__:
|
|
257
|
+
// hello\nz: 1\n---\n parsed to {z: "1"}, silently dropping the __proto__ key entirely.
|
|
258
|
+
// Object.create(null) has no such setter, so the assignment below is always a genuine
|
|
259
|
+
// own data property, for every key including __proto__ itself.
|
|
260
|
+
const out = Object.create(null);
|
|
261
|
+
for (const [k, v] of Object.entries(value)) {
|
|
262
|
+
const restoredKey = k.includes(NULL_BYTE_SENTINEL) ? k.split(NULL_BYTE_SENTINEL).join(String.fromCharCode(0)) : k;
|
|
263
|
+
out[restoredKey] = restoreNullBytesDeep(v);
|
|
264
|
+
}
|
|
265
|
+
return out;
|
|
266
|
+
}
|
|
267
|
+
return value;
|
|
268
|
+
}
|
|
269
|
+
/**
|
|
270
|
+
* ADR-3473 §8.1 (consequence 3): js-yaml resolves `- test: a b` (and the three other spellings
|
|
271
|
+
* of the same value — `"a b"`, `'a b'`, `{test: a b}`) to ONE tree shape, `[{test: "a b"}]` —
|
|
272
|
+
* unlike the legacy scanner, whose output was a function of the raw source line and therefore
|
|
273
|
+
* produced four different strings for those four spellings (ADR-3473 40-design.md §0.1). No
|
|
274
|
+
* adapter over a tree can recover a distinction the tree does not carry, so this renders a single
|
|
275
|
+
* canonical string per object-list item instead, keeping the existing value SHAPE (an array of
|
|
276
|
+
* strings) that `sliceTopLevelFrontmatterSegments`, the `[object Object]` guard and
|
|
277
|
+
* `noOpObjectListSetError` all depend on. Choosing structured (non-string) values is fork (b) —
|
|
278
|
+
* out of scope for this phase.
|
|
279
|
+
*/
|
|
280
|
+
function flattenScalarForDisplay(value) {
|
|
281
|
+
if (value === null || value === undefined)
|
|
282
|
+
return '';
|
|
283
|
+
if (Array.isArray(value))
|
|
284
|
+
return `[${value.map(flattenScalarForDisplay).join(', ')}]`;
|
|
285
|
+
if (typeof value === 'object')
|
|
286
|
+
return flattenObjectListItem(value);
|
|
287
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
288
|
+
return String(value);
|
|
289
|
+
}
|
|
290
|
+
function flattenObjectListItem(item) {
|
|
291
|
+
return Object.entries(item)
|
|
292
|
+
.map(([k, v]) => `${k}: ${flattenScalarForDisplay(v)}`)
|
|
293
|
+
.join(', ');
|
|
294
|
+
}
|
|
295
|
+
/**
|
|
296
|
+
* Recursively normalize a parsed js-yaml tree to this module's historical contract:
|
|
297
|
+
* - `null`/`undefined` in an object-value slot becomes `{}` (consequence 1 — matches the
|
|
298
|
+
* legacy scanner's empty-value handling exactly, so `reconstructFrontmatter` — which omits
|
|
299
|
+
* null-valued keys — still round-trips a bare `key:` line instead of deleting it);
|
|
300
|
+
* - `null`/`undefined` inside an array becomes `''` (arrays are always string[] in this
|
|
301
|
+
* module's contract);
|
|
302
|
+
* - a map or nested array found as an array ITEM is flattened to a canonical string
|
|
303
|
+
* (consequence 3);
|
|
304
|
+
* - every other scalar is already a string under `FAILSAFE_SCHEMA` and passes through.
|
|
305
|
+
*/
|
|
306
|
+
function normalizeParsedValue(value, inArray) {
|
|
307
|
+
if (value === null || value === undefined)
|
|
308
|
+
return inArray ? '' : {};
|
|
309
|
+
if (Array.isArray(value)) {
|
|
310
|
+
return value.map((item) => {
|
|
311
|
+
if (item !== null && typeof item === 'object') {
|
|
312
|
+
return Array.isArray(item) ? flattenScalarForDisplay(item) : flattenObjectListItem(item);
|
|
313
|
+
}
|
|
314
|
+
return normalizeParsedValue(item, true);
|
|
315
|
+
});
|
|
316
|
+
}
|
|
317
|
+
if (typeof value === 'object') {
|
|
318
|
+
// Null-prototype (post-#3881-review, finding 3): a top-level YAML key named `constructor`,
|
|
319
|
+
// `__proto__`, `toString`, `valueOf` or `hasOwnProperty` is ordinary user-authored input
|
|
320
|
+
// (`.planning/` frontmatter), not an attack — but on an ordinary `{}` it resolves to the
|
|
321
|
+
// inherited Object.prototype member instead of `undefined`, which crashes downstream
|
|
322
|
+
// bracket reads (`commentChannel?.leading[key]`) and silently mis-answers `fm[field]`
|
|
323
|
+
// lookups in `cmdFrontmatterGet`. `Object.create(null)` severs the prototype chain so every
|
|
324
|
+
// reader of a parsed Frontmatter object gets a real bracket-read contract: present or
|
|
325
|
+
// `undefined`, never an inherited function.
|
|
326
|
+
const out = Object.create(null);
|
|
327
|
+
for (const [k, v] of Object.entries(value)) {
|
|
328
|
+
out[k] = normalizeParsedValue(v, false);
|
|
329
|
+
}
|
|
330
|
+
return out;
|
|
331
|
+
}
|
|
332
|
+
return value;
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* ADR-3473 §8.1 (consequence 5): the #3257 comment scan used to key off the legacy parser's own
|
|
336
|
+
* `[a-zA-Z0-9_-]+:` key regex — a Unicode key (e.g. `相:`) never matched it, so a comment above
|
|
337
|
+
* one silently attached to the WRONG key once js-yaml owns the real (Unicode-inclusive) key set.
|
|
338
|
+
* This attributes each pending column-0 comment block against js-yaml's own parsed top-level key
|
|
339
|
+
* list, in document order, by matching the literal key text at column 0 rather than re-deriving a
|
|
340
|
+
* key shape independently — so it can never disagree with what was actually parsed.
|
|
341
|
+
*/
|
|
342
|
+
function extractCommentChannel(yaml, orderedKeys) {
|
|
343
|
+
const lines = (0, text_lines_cjs_1.splitLines)(yaml);
|
|
344
|
+
// #3742: pending full-line comments carry their indentation so an INDENTED
|
|
345
|
+
// comment (` # note` above a nested key) can attach to the nested key that
|
|
346
|
+
// follows it — recorded under a dotted path key (`progress.total_phases`)
|
|
347
|
+
// that reconstructFrontmatter re-emits at the same nesting depth. Column-0
|
|
348
|
+
// comments keep the exact pre-#3742 behavior (top-level key attachment).
|
|
349
|
+
let pending = [];
|
|
350
|
+
let channel;
|
|
351
|
+
let keyIdx = 0;
|
|
352
|
+
// Stack of enclosing mapping keys with their indentation, for dotted-path
|
|
353
|
+
// construction on nested key lines. Only indented keys push here.
|
|
354
|
+
const pathStack = [];
|
|
355
|
+
const attach = (pathKey, comments) => {
|
|
356
|
+
if (!channel)
|
|
357
|
+
channel = { leading: Object.create(null), trailing: [] };
|
|
358
|
+
// Null-prototype `leading` (post-#3881-review, finding 3): the path key is
|
|
359
|
+
// derived from arbitrary user-authored YAML keys — `constructor`,
|
|
360
|
+
// `__proto__`, `toString`, `valueOf`, `hasOwnProperty` all round-trip
|
|
361
|
+
// through here. On an ordinary `{}` those resolve to inherited
|
|
362
|
+
// Object.prototype members; the null prototype makes every lookup an
|
|
363
|
+
// own-property-or-undefined read.
|
|
364
|
+
channel.leading[pathKey] = comments.map((c) => c.line);
|
|
365
|
+
};
|
|
105
366
|
for (const line of lines) {
|
|
106
|
-
// Skip empty lines
|
|
107
367
|
if (line.trim() === '')
|
|
108
368
|
continue;
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
while (stack.length > 1 && indent <= stack[stack.length - 1].indent) {
|
|
114
|
-
stack.pop();
|
|
369
|
+
const commentMatch = /^(\s*)#/.exec(line);
|
|
370
|
+
if (commentMatch) {
|
|
371
|
+
pending.push({ indent: commentMatch[1].length, line });
|
|
372
|
+
continue;
|
|
115
373
|
}
|
|
116
|
-
|
|
117
|
-
//
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
//
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
374
|
+
// A list item (`- foo: bar`) is not a mapping key: its `- ` prefix would
|
|
375
|
+
// otherwise register as a key named `- foo` and corrupt the path stack
|
|
376
|
+
// (#3742 review). List items fall through to the pending-drop below.
|
|
377
|
+
const isListItem = /^\s*-\s/.test(line);
|
|
378
|
+
const keyLineMatch = isListItem
|
|
379
|
+
? null
|
|
380
|
+
: /^(\s*)(?:"([^"]+)"|'([^']+)'|([^:\s][^:]*)):(?:\s|$)/.exec(line);
|
|
381
|
+
if (keyLineMatch) {
|
|
382
|
+
const indent = keyLineMatch[1].length;
|
|
383
|
+
const key = keyLineMatch[2] ?? keyLineMatch[3] ?? keyLineMatch[4];
|
|
384
|
+
if (indent === 0) {
|
|
385
|
+
// Top-level: keep the pre-#3742 orderedKeys walk — the comment
|
|
386
|
+
// attaches only to the next EXPECTED top-level key.
|
|
387
|
+
if (keyIdx < orderedKeys.length && key === orderedKeys[keyIdx]) {
|
|
388
|
+
const col0 = pending.filter((c) => c.indent === 0);
|
|
389
|
+
if (col0.length)
|
|
390
|
+
attach(key, col0);
|
|
391
|
+
keyIdx++;
|
|
392
|
+
// A top-level mapping key opens a nesting context for the indented
|
|
393
|
+
// keys that follow it (#3742 dotted-path attachment).
|
|
394
|
+
pathStack.length = 0;
|
|
395
|
+
pathStack.push({ indent: 0, key });
|
|
396
|
+
pending = [];
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
134
399
|
}
|
|
135
400
|
else {
|
|
136
|
-
//
|
|
137
|
-
|
|
138
|
-
|
|
401
|
+
// Nested key line: a pending comment at the SAME indentation attaches
|
|
402
|
+
// to this key under its dotted path. Deeper/misaligned pending
|
|
403
|
+
// comments were not leading this key — drop them, matching the
|
|
404
|
+
// top-level rule's "attach only when a key follows" discipline.
|
|
405
|
+
while (pathStack.length > 0 && pathStack[pathStack.length - 1].indent >= indent)
|
|
406
|
+
pathStack.pop();
|
|
407
|
+
const sameIndent = pending.filter((c) => c.indent === indent);
|
|
408
|
+
if (sameIndent.length && key.length > 0) {
|
|
409
|
+
attach([...pathStack.map((e) => e.key), key].join('.'), sameIndent);
|
|
410
|
+
}
|
|
411
|
+
pathStack.push({ indent, key });
|
|
412
|
+
pending = [];
|
|
413
|
+
continue;
|
|
139
414
|
}
|
|
140
415
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
416
|
+
// A non-comment line that is not the next expected top-level key start: any comments
|
|
417
|
+
// pending before it were not actually leading a key (malformed/unusual input) — drop
|
|
418
|
+
// rather than misattach, matching the prior scan's "attach only when a key follows" shape.
|
|
419
|
+
pending = [];
|
|
420
|
+
}
|
|
421
|
+
const col0Trailing = pending.filter((c) => c.indent === 0);
|
|
422
|
+
if (col0Trailing.length) {
|
|
423
|
+
if (!channel)
|
|
424
|
+
channel = { leading: Object.create(null), trailing: [] };
|
|
425
|
+
channel.trailing = col0Trailing.map((c) => c.line);
|
|
426
|
+
}
|
|
427
|
+
return channel;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Parse one already-delimited YAML region into a Frontmatter object, via the vendored js-yaml
|
|
431
|
+
* (ADR-3473 §8.1). Throws (a `YAMLException`, or a plain `Error` from `refuseAnchorsAndAliases`)
|
|
432
|
+
* on anything js-yaml itself cannot parse or that this module refuses outright; callers decide
|
|
433
|
+
* whether to surface that as `unparseableResult()` or use it as a truncation signal.
|
|
434
|
+
*
|
|
435
|
+
* Renamed from `parseYamlRegion` (post-#3881-review, finding 2): §8.1 says `parseYamlRegion` is
|
|
436
|
+
* "deleted, not patched" — the hand-rolled line scanner that name identified IS gone, but the
|
|
437
|
+
* name itself survived on a new function with two callers (`extractFrontmatter` and
|
|
438
|
+
* `countKeysBeforeTruncation`) that could not be inlined without duplicating the
|
|
439
|
+
* refusal/null-byte/comment-channel glue below. Renaming closes that gap literally: nothing in
|
|
440
|
+
* this module still answers to the old hand-rolled scanner's name.
|
|
441
|
+
*
|
|
442
|
+
* RESTORED (fix #3881/#3881-followup-2, closes the #3705-shaped regression reported against
|
|
443
|
+
* `tests/smart-entry.unit.test.cjs:867`/`tests/smart-entry.property.test.cjs`): a prior revision
|
|
444
|
+
* of this function fell back, on a throw, to TWO hand-rolled re-implementations of YAML dialect —
|
|
445
|
+
* `repairAmbiguousColonValues` (below) for `key: value: extra`-shaped ambiguous colons, and
|
|
446
|
+
* `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` for a malformed/unclosed `[...]`.
|
|
447
|
+
* Both were deleted in 810e5e508 after a sweep of every tracked `*.md` file in this repo (910
|
|
448
|
+
* files) showed disabling each repair independently changed the parse result for zero documents.
|
|
449
|
+
*
|
|
450
|
+
* THAT SWEEP MEASURED THE WRONG POPULATION. `repairAmbiguousColonValues`'s one real dependent is
|
|
451
|
+
* not a document committed anywhere in this repo — it is user hand-edited STATE.md content that
|
|
452
|
+
* exists only on end users' machines and is pinned here by `tests/smart-entry.unit.test.cjs` (see
|
|
453
|
+
* its own in-file comment: "silently re-opened #2571/#2570 for hand-edited STATE.md that omits
|
|
454
|
+
* the template em dash"). The exact shape: `last_activity: 2026-06-08: reviewed the PR queue` — a
|
|
455
|
+
* colon-separated date+description a user typed by hand instead of the template's ` — ` (em dash)
|
|
456
|
+
* separator. js-yaml correctly refuses this as genuinely ambiguous YAML (a colon+space inside an
|
|
457
|
+
* unquoted scalar opens a nested mapping key); the old hand-rolled scanner tolerated it by taking
|
|
458
|
+
* everything after the first `key:` verbatim. A future sweep of tracked `.md` files will AGAIN
|
|
459
|
+
* show zero dependents for this exact reason — the dependent never lives in this repo's tree, it
|
|
460
|
+
* lives in a user's own `.planning/STATE.md`. Do not delete this again on that evidence alone;
|
|
461
|
+
* `tests/smart-entry.unit.test.cjs` and the frontmatter-level row in
|
|
462
|
+
* `tests/feat-3881-yaml-parser-consequences.test.cjs` are the actual proof the dependent exists.
|
|
463
|
+
*
|
|
464
|
+
* `repairMalformedInlineArrays`/`splitLegacyInlineArrayItems` stay deleted: their zero-dependents
|
|
465
|
+
* finding was reverified directly (frontmatter/smart-entry/verify/roadmap suites all pass without
|
|
466
|
+
* them) and, unlike the colon repair, nothing in the test suite or #2570/#2571 documents a
|
|
467
|
+
* hand-edited-STATE.md shape that depends on inline-array leniency.
|
|
468
|
+
*/
|
|
469
|
+
function loadWithAmbiguousColonRepair(yaml) {
|
|
470
|
+
try {
|
|
471
|
+
return (0, js_yaml_cjs_1.load)(yaml, YAML_LOAD_OPTS);
|
|
472
|
+
}
|
|
473
|
+
catch (e) {
|
|
474
|
+
const repaired = repairAmbiguousColonValues(yaml);
|
|
475
|
+
if (repaired === yaml)
|
|
476
|
+
throw e; // nothing to repair — surface the original error
|
|
477
|
+
try {
|
|
478
|
+
return (0, js_yaml_cjs_1.load)(repaired, YAML_LOAD_OPTS);
|
|
479
|
+
}
|
|
480
|
+
catch {
|
|
481
|
+
throw e; // repair didn't help (still invalid, possibly for another reason) — surface the original
|
|
482
|
+
}
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
/**
|
|
486
|
+
* Double-quote (and escape) any column-0 `key: value` line whose (single-line) value contains an
|
|
487
|
+
* unquoted colon+whitespace or a trailing bare colon — the exact shape that reads as an ambiguous
|
|
488
|
+
* nested mapping key to a real YAML parser (`key: value: extra`, `key: value:`). Lines that are
|
|
489
|
+
* already safely quoted or open a flow/block collection (`"`, `'`, `[`, `{`) are left untouched
|
|
490
|
+
* (js-yaml already handles those); an empty value (`key:` alone, opening a nested block) is left
|
|
491
|
+
* untouched too, since repairing it would change a legitimate nested-map opener into a scalar.
|
|
492
|
+
* Only column-0 lines are considered — an indented line is either already-valid nested content or
|
|
493
|
+
* a genuinely different malformation this repair does not claim to fix.
|
|
494
|
+
*
|
|
495
|
+
* Restored (fix #3881/#3881-followup-2) — see `loadWithAmbiguousColonRepair`'s docblock for why a
|
|
496
|
+
* tracked-document sweep cannot see this function's one real dependent (hand-edited STATE.md,
|
|
497
|
+
* #2571/#2570, pinned by `tests/smart-entry.unit.test.cjs`).
|
|
498
|
+
*/
|
|
499
|
+
function repairAmbiguousColonValues(yaml) {
|
|
500
|
+
return (0, text_lines_cjs_1.splitLines)(yaml)
|
|
501
|
+
.map((line) => {
|
|
502
|
+
const m = /^([A-Za-z0-9_][A-Za-z0-9_-]*):[ \t](.+)$/.exec(line);
|
|
503
|
+
if (!m)
|
|
504
|
+
return line;
|
|
505
|
+
const [, key, value] = m;
|
|
506
|
+
if (/^["'[{]/.test(value))
|
|
507
|
+
return line; // already safely quoted/collection-opened
|
|
508
|
+
if (!/:(?:[ \t]|$)/.test(value))
|
|
509
|
+
return line; // no ambiguous colon in the value
|
|
510
|
+
const escaped = value.replace(/\\/g, '\\\\').replace(/"/g, '\\"');
|
|
511
|
+
return `${key}: "${escaped}"`;
|
|
512
|
+
})
|
|
513
|
+
.join('\n');
|
|
514
|
+
}
|
|
515
|
+
function parseGuardedYamlRegion(yaml) {
|
|
516
|
+
refuseAnchorsAndAliases(yaml);
|
|
517
|
+
refuseIfSentinelPresent(yaml);
|
|
518
|
+
const escaped = escapeNullBytesForParse(yaml);
|
|
519
|
+
const raw = loadWithAmbiguousColonRepair(escaped);
|
|
520
|
+
const normalized = normalizeParsedValue(raw, false);
|
|
521
|
+
const root = normalized && typeof normalized === 'object' && !Array.isArray(normalized)
|
|
522
|
+
? normalized
|
|
523
|
+
: Object.create(null);
|
|
524
|
+
const restored = restoreNullBytesDeep(root);
|
|
525
|
+
const commentChannel = extractCommentChannel(yaml, Object.keys(restored));
|
|
526
|
+
if (commentChannel) {
|
|
527
|
+
restored[FULL_LINE_COMMENTS] = commentChannel;
|
|
528
|
+
}
|
|
529
|
+
// Plain-prototype at the public-surface boundary (post-remote-runner-fix, #3881): see
|
|
530
|
+
// `toPlainValueTree`'s docblock. Everything above this line stays null-prototype internally.
|
|
531
|
+
return toPlainValueTree(restored);
|
|
532
|
+
}
|
|
533
|
+
/**
|
|
534
|
+
* ADR-3473 §8.1 (consequence 4): the #1882 truncation probe used to run the SAME parser
|
|
535
|
+
* (`parseGuardedYamlRegion`) over an unterminated region and count its keys — deliberately, so a second
|
|
536
|
+
* "does this look like YAML?" matcher could never drift from the real parser. js-yaml is stricter
|
|
537
|
+
* than the old scanner, though: the dominant real truncation shape (fence opened, well-formed
|
|
538
|
+
* keys, then the document body follows with no closing fence) is *invalid* YAML — a plain-text
|
|
539
|
+
* paragraph at column 0 right after a block mapping raises `bad indentation of a mapping entry`
|
|
540
|
+
* — so a naive "parse the whole region, count keys on success" port yields 0 keys and goes
|
|
541
|
+
* silent on exactly the case #1882 exists for.
|
|
542
|
+
*
|
|
543
|
+
* CORRECTED (post-#3881-review, finding 5): the original recovery — re-parse ONLY the exact
|
|
544
|
+
* prefix named by `e.mark.line` — regressed on every realistic truncation shape actually
|
|
545
|
+
* checked by execution: an unquoted-colon value (`title: a: b`) and a mis-indented sibling
|
|
546
|
+
* key (` plan: 2`) both raise "bad indentation of a mapping entry" ON the offending line
|
|
547
|
+
* itself, so `mark.line` names that SAME line and slicing BEFORE it drops the offending
|
|
548
|
+
* line's own key entirely — undercounting by exactly the key the probe most needs to see. An
|
|
549
|
+
* open flow collection (`list: [a, b`) raises its error on the (nonexistent) line AFTER the
|
|
550
|
+
* region's end, so the "marked prefix" still contains the same unterminated `[` and the retry
|
|
551
|
+
* parse fails too, falling through to a hard `0`. And a refused anchor/alias/merge key throws
|
|
552
|
+
* a mark-LESS `YAMLException` (`refuseAnchorsAndAliases`, thrown from inside the parse
|
|
553
|
+
* `listener` before js-yaml attaches position info) — the `e.mark` guard was never entered at
|
|
554
|
+
* all, hard `0` again, even though every other key in the region is perfectly valid.
|
|
555
|
+
*
|
|
556
|
+
* A first fix attempt tried shrinking the region line-by-line and re-parsing through the SAME
|
|
557
|
+
* real parser only — still no second matcher. It did NOT recover any of the three shapes above:
|
|
558
|
+
* the offending line in the first two IS the malformed token, at every possible prefix boundary
|
|
559
|
+
* that includes it, so no amount of shrinking ever makes it parse; the only prefix that ever
|
|
560
|
+
* succeeds is the one line BEFORE it, i.e. exactly the original bug's undercount. A parser-only
|
|
561
|
+
* strategy cannot report a key whose own line is genuinely invalid YAML — the same limitation
|
|
562
|
+
* that made the mark-based recovery fail in the first place. This means "the one real parser,
|
|
563
|
+
* never a hand-rolled matcher" is unreachable for the truncation-probe's actual job (a lower-
|
|
564
|
+
* bound COUNT of what looks like a key line, not a validity judgment) — this file already
|
|
565
|
+
* accepts an independent raw-text matcher for the adjacent question of "is this shaped like
|
|
566
|
+
* frontmatter" (`isFrontmatterShaped`, used by this probe's only caller), so `countKeysBeforeTruncation`
|
|
567
|
+
* takes the MAX of two lower bounds: how many keys the real parser can recover from the longest
|
|
568
|
+
* parseable line-prefix (still the primary signal — correct on the dominant fence-then-prose
|
|
569
|
+
* shape, and on any prefix boundary that genuinely IS the truncation point), and how many
|
|
570
|
+
* column-0 `key:`-shaped lines the raw text contains (recovers the three regressed shapes,
|
|
571
|
+
* whose offending key line the parser can never count). Neither alone is sufficient; together
|
|
572
|
+
* they never under-report a key that either signal can see.
|
|
573
|
+
*/
|
|
574
|
+
function countKeysBeforeTruncation(region) {
|
|
575
|
+
const parsed = parsedKeyCount(region);
|
|
576
|
+
const textual = countTopLevelKeyShapedLines(region);
|
|
577
|
+
return Math.max(parsed, textual);
|
|
578
|
+
}
|
|
579
|
+
/** How many keys the real parser recovers from the longest line-prefix of `region` that parses
|
|
580
|
+
* cleanly (the whole region itself, when it parses outright). Bounded to at most `region`'s own
|
|
581
|
+
* line count re-parses — no worse than the whole-region parse already paid for on the caller's
|
|
582
|
+
* unterminated-region path, which is itself bounded by ordinary `.planning/` document sizes (the
|
|
583
|
+
* huge-bounded fixture parses successfully on the FIRST try and never reaches the shrink loop).
|
|
584
|
+
*/
|
|
585
|
+
function parsedKeyCount(region) {
|
|
586
|
+
try {
|
|
587
|
+
return Object.keys(parseGuardedYamlRegion(region)).length;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
const lines = (0, text_lines_cjs_1.splitLines)(region);
|
|
591
|
+
for (let n = lines.length - 1; n >= 1; n--) {
|
|
592
|
+
const prefix = lines.slice(0, n).join('\n');
|
|
593
|
+
if (prefix.trim() === '')
|
|
594
|
+
continue;
|
|
595
|
+
try {
|
|
596
|
+
return Object.keys(parseGuardedYamlRegion(prefix)).length;
|
|
157
597
|
}
|
|
158
|
-
|
|
159
|
-
|
|
598
|
+
catch {
|
|
599
|
+
continue;
|
|
160
600
|
}
|
|
161
601
|
}
|
|
602
|
+
return 0;
|
|
162
603
|
}
|
|
163
|
-
|
|
604
|
+
}
|
|
605
|
+
/** How many `key:`-shaped lines `region` textually contains — the raw-text lower bound that
|
|
606
|
+
* recovers a key whose OWN line is malformed YAML (an unquoted colon in the value, or a
|
|
607
|
+
* mis-indented sibling that reads as an "indented continuation" to the real parser), which no
|
|
608
|
+
* re-parse of any prefix can ever count (see `countKeysBeforeTruncation`'s docblock). Matches
|
|
609
|
+
* ANY indentation, not only column 0 — the mis-indented-sibling shape is, by construction, a key
|
|
610
|
+
* the author intended as top-level but indented by mistake; requiring column 0 here would just
|
|
611
|
+
* relocate the exact undercount finding 5 reports. Deliberately the SAME key-shape pattern this
|
|
612
|
+
* file already uses for the sibling shape check (`isFrontmatterShaped`'s first branch) — ASCII-
|
|
613
|
+
* only is an accepted, precedented scope limit for this raw-text heuristic, not a new one.
|
|
614
|
+
*/
|
|
615
|
+
function countTopLevelKeyShapedLines(region) {
|
|
616
|
+
return (0, text_lines_cjs_1.splitLines)(region).filter((line) => /^\s*[A-Za-z0-9_-]+:/.test(line)).length;
|
|
164
617
|
}
|
|
165
618
|
/**
|
|
166
619
|
* Extract frontmatter from a document.
|
|
@@ -174,8 +627,9 @@ function parseYamlRegion(yaml) {
|
|
|
174
627
|
* The discriminator is the reason this is not simply "opened but never closed". A Markdown
|
|
175
628
|
* document whose first line is a thematic break (`---`) takes that exact branch, so flagging
|
|
176
629
|
* on the missing fence alone reports corruption on perfectly good Markdown. Instead the
|
|
177
|
-
* unterminated region
|
|
178
|
-
* yields **two or more** keys
|
|
630
|
+
* unterminated region's key count (see `countKeysBeforeTruncation`) is reported only when it
|
|
631
|
+
* yields **two or more** keys AND the region is uniformly frontmatter-shaped raw text
|
|
632
|
+
* (`isFrontmatterShaped`).
|
|
179
633
|
*
|
|
180
634
|
* Two, not one, and the extra key is doing real work. A single `key: value` line is genuinely
|
|
181
635
|
* ambiguous: `---` followed by `Note: this is a paragraph.` — or `Author:`, `TODO:`, `See:` —
|
|
@@ -189,13 +643,17 @@ function parseYamlRegion(yaml) {
|
|
|
189
643
|
* (STATE.md, PLAN.md, ROADMAP.md, SUMMARY.md, agent/command docs) carries two or more
|
|
190
644
|
* frontmatter keys, so the realistic interruption window stays covered.
|
|
191
645
|
*
|
|
646
|
+
* A closed region that js-yaml itself cannot parse (malformed YAML, or a refused
|
|
647
|
+
* anchor/alias/merge key — ADR-3473 §8.1 consequence 6) returns `{}` carrying the
|
|
648
|
+
* `FRONTMATTER_UNPARSEABLE` Symbol (consequence 2) rather than a bare, indistinguishable `{}`.
|
|
649
|
+
*
|
|
192
650
|
* @param content Raw document text.
|
|
193
651
|
* @param sourcePath Optional resolved path, used to name the file in the diagnostic and to
|
|
194
652
|
* key its deduplication. Optional because this function has 50-odd call sites and several
|
|
195
653
|
* hold only an in-memory string; those dedup on a content digest instead.
|
|
196
654
|
*/
|
|
197
655
|
function extractFrontmatter(content, sourcePath) {
|
|
198
|
-
// #2977: tolerate a single leading UTF-8 BOM (
|
|
656
|
+
// #2977: tolerate a single leading UTF-8 BOM (U+FEFF), which Windows tooling
|
|
199
657
|
// (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default. Without this
|
|
200
658
|
// strip, the byte-0 `startsWith('---')` fence check below fails on the BOM and the whole
|
|
201
659
|
// parse collapses to {} — every frontmatter field silently disappears, and the engine
|
|
@@ -215,8 +673,8 @@ function extractFrontmatter(content, sourcePath) {
|
|
|
215
673
|
const closingLineStart = content.indexOf('\n---', headerEnd);
|
|
216
674
|
if (closingLineStart === -1) {
|
|
217
675
|
const region = content.slice(headerEnd);
|
|
218
|
-
const
|
|
219
|
-
if (
|
|
676
|
+
const keyCount = countKeysBeforeTruncation(region);
|
|
677
|
+
if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(region)) {
|
|
220
678
|
warnUnusableInput({
|
|
221
679
|
reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
|
|
222
680
|
source: sourcePath,
|
|
@@ -226,26 +684,47 @@ function extractFrontmatter(content, sourcePath) {
|
|
|
226
684
|
return {};
|
|
227
685
|
}
|
|
228
686
|
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
|
229
|
-
|
|
687
|
+
const region = content.slice(headerEnd, yamlEnd);
|
|
688
|
+
try {
|
|
689
|
+
return parseGuardedYamlRegion(region);
|
|
690
|
+
}
|
|
691
|
+
catch {
|
|
692
|
+
return unparseableResult();
|
|
693
|
+
}
|
|
230
694
|
}
|
|
231
695
|
/**
|
|
232
|
-
* Escape a string for emission inside a YAML double-quoted scalar (#1779).
|
|
233
|
-
*
|
|
234
|
-
*
|
|
235
|
-
*
|
|
236
|
-
*
|
|
237
|
-
*
|
|
238
|
-
*
|
|
696
|
+
* Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
|
|
697
|
+
* (#3881): routed through the vendored js-yaml's `dump()` (forced double-quoted style) rather
|
|
698
|
+
* than a hand-rolled character-class replace chain, so the writer shares the same escaping
|
|
699
|
+
* engine the reader now uses. js-yaml emits control-char escapes as uppercase hex (`\x1F`);
|
|
700
|
+
* this repo has emitted lowercase (`\x1f`) since #1779, so the hex digits are lowercased after
|
|
701
|
+
* dump to keep serialized output byte-stable across the migration FOR THE CASES the old
|
|
702
|
+
* hand-rolled chain actually covered (backslash/quote/newline/tab/CR, and every C0 control
|
|
703
|
+
* plus DEL via \xHH). It is NOT byte-stable end-to-end (post-#3881-review, finding 4,
|
|
704
|
+
* verified by execution): the old chain left BEL/NUL unescaped-as-hex (`\x07`/`\x00`) and
|
|
705
|
+
* left NEL/NBSP/LINE SEPARATOR/PARAGRAPH SEPARATOR/BOM as raw literal bytes entirely (they
|
|
706
|
+
* fall outside its `\u0000-\u001f\u007f` class); js-yaml's dump instead emits the YAML-named
|
|
707
|
+
* escapes `\a`/`\0`/`\N`/`\_`/`\L`/`\P` for those six, and a `\uXXXX` escape for the BOM and
|
|
708
|
+
* any lone UTF-16 surrogate. The serialized TEXT differs from pre-migration output for these
|
|
709
|
+
* codepoints, but the round-trip is equivalence-preserving, not merely byte-preserving: every
|
|
710
|
+
* one of `\a`/`\0`/`\N`/`\_`/`\L`/`\P`/`\uXXXX` is a YAML double-quoted-scalar escape that
|
|
711
|
+
* resolves back to the EXACT source codepoint on re-parse (confirmed by execution — see
|
|
712
|
+
* `tests/frontmatter.unit.test.cjs`'s pinned cases). scalarNeedsDoubleQuoting was extended in
|
|
713
|
+
* the same review round to also route lone surrogates through this quoted+escaped path,
|
|
714
|
+
* because they were previously emitted bare and produced genuinely UNPARSEABLE YAML.
|
|
715
|
+
*
|
|
716
|
+
* Renamed from `escapeDoubleQuoted` (post-#3881-review, finding 2): §8.1 says this function is
|
|
717
|
+
* "deleted, not patched" — like `parseGuardedYamlRegion`, the hand-rolled character-class chain
|
|
718
|
+
* is gone, but unlike that function this one HAS no other caller inside this module to hide the
|
|
719
|
+
* old name's survival behind, so the rename is a straight mechanical propagation to its three
|
|
720
|
+
* call sites (`reconstructFrontmatter` here, plus `commands.cts` and
|
|
721
|
+
* `runtime-artifact-conversion.cts`, both updated in this change — no ADR amendment needed).
|
|
239
722
|
*/
|
|
240
|
-
function
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
.replace(/\t/g, '\\t')
|
|
246
|
-
.replace(/\r/g, '\\r')
|
|
247
|
-
// Remaining C0 controls + DEL → \xHH (a valid YAML double-quoted escape).
|
|
248
|
-
.replace(/[\u0000-\u001f\u007f]/g, (c) => `\\x${c.charCodeAt(0).toString(16).padStart(2, '0')}`);
|
|
723
|
+
function escapeDoubleQuotedScalar(s) {
|
|
724
|
+
const dumped = (0, js_yaml_cjs_1.dump)(s, { schema: js_yaml_cjs_1.FAILSAFE_SCHEMA, forceQuotes: true, quotingType: '"', lineWidth: -1 });
|
|
725
|
+
const withoutTrailingNewline = dumped.endsWith('\n') ? dumped.slice(0, -1) : dumped;
|
|
726
|
+
const interior = withoutTrailingNewline.slice(1, -1); // strip the outer double-quote pair
|
|
727
|
+
return interior.replace(/\\x([0-9A-Fa-f]{2})/g, (_m, hex) => `\\x${hex.toLowerCase()}`);
|
|
249
728
|
}
|
|
250
729
|
/**
|
|
251
730
|
* A plain (unquoted) scalar that would mis-parse or round-trip lossily when
|
|
@@ -254,7 +733,7 @@ function escapeDoubleQuoted(s) {
|
|
|
254
733
|
* or control char, a leading YAML indicator (quote, `&`/`*`/`!` anchor/alias/
|
|
255
734
|
* tag, `|`/`>` block scalar, flow `[]{},`, `#`, reserved `%`/`@`/backtick, or
|
|
256
735
|
* `-`/`?`/`:` before a space), or leading/trailing whitespace. This helper is
|
|
257
|
-
* the correctness complement of `
|
|
736
|
+
* the correctness complement of `escapeDoubleQuotedScalar`: it broadens the *trigger*
|
|
258
737
|
* for quoting without broadening the lossy object-list handling deferred to
|
|
259
738
|
* #1572/#1660.
|
|
260
739
|
*/
|
|
@@ -269,13 +748,87 @@ function scalarNeedsDoubleQuoting(s) {
|
|
|
269
748
|
// `-` `?` `:` only start a plain scalar safely when NOT followed by a space.
|
|
270
749
|
if (/^[-?:](\s|$)/.test(s))
|
|
271
750
|
return true;
|
|
751
|
+
// Post-#3881-review, finding 4 (found while verifying escapeDoubleQuotedScalar's byte-
|
|
752
|
+
// stability claim): an unpaired UTF-16 surrogate (U+D800-U+DFFF) is outside YAML's
|
|
753
|
+
// printable-character set, so js-yaml's loader refuses it ("the stream contains
|
|
754
|
+
// non-printable characters") the instant it is emitted bare. No other trigger above
|
|
755
|
+
// catches it -- not whitespace, not a C0/C1 control, not a leading indicator -- so a bare
|
|
756
|
+
// emission was genuinely invalid YAML: reconstructFrontmatter produced text
|
|
757
|
+
// extractFrontmatter could not re-parse, silently collapsing to {} via
|
|
758
|
+
// unparseableResult(). Confirmed by execution: reconstructFrontmatter({weird: '\uD800'})
|
|
759
|
+
// round-tripped to undefined before this fix. Routing it through the quoted +
|
|
760
|
+
// escapeDoubleQuotedScalar path (which already emits the \uD800 escape) fixes the
|
|
761
|
+
// round-trip.
|
|
762
|
+
if (/[\uD800-\uDFFF]/.test(s))
|
|
763
|
+
return true;
|
|
764
|
+
return false;
|
|
765
|
+
}
|
|
766
|
+
/**
|
|
767
|
+
* #3706 — Does this value need double-quoting when written as an AGENT
|
|
768
|
+
* frontmatter scalar (`model:`, `variant:`)?
|
|
769
|
+
*
|
|
770
|
+
* Deliberately a superset of `scalarNeedsDoubleQuoting` rather than a second,
|
|
771
|
+
* competing predicate: that one answers "can this open a plain scalar safely",
|
|
772
|
+
* which is necessary but not sufficient for a value that must ROUND-TRIP as the
|
|
773
|
+
* exact string it went in as. Keeping both here is the point — they are two
|
|
774
|
+
* answers to one question and drift the moment they live apart.
|
|
775
|
+
*
|
|
776
|
+
* The extra clauses, each an observed mis-parse rather than a precaution:
|
|
777
|
+
*
|
|
778
|
+
* - a non-alphanumeric first character. `scalarNeedsDoubleQuoting` rejects the
|
|
779
|
+
* indicators that cannot OPEN a scalar, but YAML still resolves plenty of
|
|
780
|
+
* values that open legally: `~` and `.inf`/`.nan` become null and floats,
|
|
781
|
+
* and a leading sign or dot (`+1`, `-0`, `.5`) becomes a number. Requiring
|
|
782
|
+
* alphanumeric-first covers that whole family at once, and costs nothing:
|
|
783
|
+
* every real model ID and effort level starts alphanumeric.
|
|
784
|
+
* - a trailing `:` — `model: foo:` is read as a nested mapping key and fails
|
|
785
|
+
* the whole frontmatter with "bad indentation of a mapping entry".
|
|
786
|
+
* - a boolean/null word — YAML 1.1 readers resolve `no`/`y`/`off`/`null` to
|
|
787
|
+
* non-strings, so a variant named `no` arrives as `false`.
|
|
788
|
+
* - a numeric-looking value, including the YAML 1.1 sexagesimal form: `12:30`
|
|
789
|
+
* resolves to the integer 750, and `:` is legal mid-identifier here.
|
|
790
|
+
* - a date. `2026-08-25` starts alphanumeric and survives every clause above,
|
|
791
|
+
* yet YAML resolves it to a Date object rather than a string.
|
|
792
|
+
*/
|
|
793
|
+
const YAML_WORD_SCALAR_RE = /^(?:y|n|yes|no|true|false|on|off|null)$/i;
|
|
794
|
+
const YAML_NUMERIC_RE = /^(?:\d[\d_]*(?:\.[\d_]*)?(?:[eE][-+]?\d+)?|0[xXbBoO][0-9a-fA-F_]+|\d[\d_]*(?::[0-5]?\d)+(?:\.[\d_]*)?)$/;
|
|
795
|
+
// YAML 1.1 timestamp: a bare ymd, optionally followed by a time part.
|
|
796
|
+
const YAML_TIMESTAMP_RE = /^\d{4}-\d{1,2}-\d{1,2}(?:[Tt ].*)?$/;
|
|
797
|
+
function agentScalarNeedsDoubleQuoting(s) {
|
|
798
|
+
if (scalarNeedsDoubleQuoting(s))
|
|
799
|
+
return true;
|
|
800
|
+
if (!/^[A-Za-z0-9]/.test(s))
|
|
801
|
+
return true;
|
|
802
|
+
// A plain scalar ENDS at `: ` or ` #` wherever they appear — the base
|
|
803
|
+
// predicate only inspects the first character, because its question is
|
|
804
|
+
// whether the scalar can legally open. `a: b` makes the line a nested
|
|
805
|
+
// mapping (a parse error at this indent) and `a #b` silently truncates to
|
|
806
|
+
// `a`. Both were found by the round-trip property, not by inspection.
|
|
807
|
+
if (/:\s/.test(s) || /\s#/.test(s))
|
|
808
|
+
return true;
|
|
809
|
+
if (s.endsWith(':'))
|
|
810
|
+
return true;
|
|
811
|
+
if (YAML_WORD_SCALAR_RE.test(s))
|
|
812
|
+
return true;
|
|
813
|
+
if (YAML_NUMERIC_RE.test(s))
|
|
814
|
+
return true;
|
|
815
|
+
if (YAML_TIMESTAMP_RE.test(s))
|
|
816
|
+
return true;
|
|
272
817
|
return false;
|
|
273
818
|
}
|
|
274
819
|
function reconstructFrontmatter(obj) {
|
|
275
820
|
const lines = [];
|
|
821
|
+
// #3257: read the full-line-comment channel (set by parseGuardedYamlRegion when comments
|
|
822
|
+
// were present). Object.entries skips the Symbol key, so the data loop is unchanged.
|
|
823
|
+
const commentChannel = obj[FULL_LINE_COMMENTS];
|
|
276
824
|
for (const [key, value] of Object.entries(obj)) {
|
|
277
825
|
if (value === null || value === undefined)
|
|
278
826
|
continue;
|
|
827
|
+
// #3257: re-emit this key's leading full-line comments before the key itself.
|
|
828
|
+
const leading = commentChannel?.leading[key];
|
|
829
|
+
if (leading)
|
|
830
|
+
for (const c of leading)
|
|
831
|
+
lines.push(c);
|
|
279
832
|
if (Array.isArray(value)) {
|
|
280
833
|
if (value.length === 0) {
|
|
281
834
|
lines.push(`${key}: []`);
|
|
@@ -286,7 +839,7 @@ function reconstructFrontmatter(obj) {
|
|
|
286
839
|
else {
|
|
287
840
|
lines.push(`${key}:`);
|
|
288
841
|
for (const item of value) {
|
|
289
|
-
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${
|
|
842
|
+
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuotedScalar(item)}"` : item}`);
|
|
290
843
|
}
|
|
291
844
|
}
|
|
292
845
|
}
|
|
@@ -295,6 +848,12 @@ function reconstructFrontmatter(obj) {
|
|
|
295
848
|
for (const [subkey, subval] of Object.entries(value)) {
|
|
296
849
|
if (subval === null || subval === undefined)
|
|
297
850
|
continue;
|
|
851
|
+
// #3742: re-emit a nested key's leading full-line comments (channel
|
|
852
|
+
// path key `parent.subkey`) at the subkey's own indentation.
|
|
853
|
+
const nestedLeading = commentChannel?.leading[`${key}.${subkey}`];
|
|
854
|
+
if (nestedLeading)
|
|
855
|
+
for (const c of nestedLeading)
|
|
856
|
+
lines.push(` ${c.trimStart()}`);
|
|
298
857
|
if (Array.isArray(subval)) {
|
|
299
858
|
if (subval.length === 0) {
|
|
300
859
|
lines.push(` ${subkey}: []`);
|
|
@@ -305,7 +864,7 @@ function reconstructFrontmatter(obj) {
|
|
|
305
864
|
else {
|
|
306
865
|
lines.push(` ${subkey}:`);
|
|
307
866
|
for (const item of subval) {
|
|
308
|
-
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${
|
|
867
|
+
lines.push(` - ${typeof item === 'string' && (item.includes(':') || item.includes('#') || scalarNeedsDoubleQuoting(item)) ? `"${escapeDoubleQuotedScalar(item)}"` : item}`);
|
|
309
868
|
}
|
|
310
869
|
}
|
|
311
870
|
}
|
|
@@ -314,6 +873,12 @@ function reconstructFrontmatter(obj) {
|
|
|
314
873
|
for (const [subsubkey, subsubval] of Object.entries(subval)) {
|
|
315
874
|
if (subsubval === null || subsubval === undefined)
|
|
316
875
|
continue;
|
|
876
|
+
// #3742: same nested-comment re-emission one level deeper
|
|
877
|
+
// (`parent.sub.subsub`).
|
|
878
|
+
const deepLeading = commentChannel?.leading[`${key}.${subkey}.${subsubkey}`];
|
|
879
|
+
if (deepLeading)
|
|
880
|
+
for (const c of deepLeading)
|
|
881
|
+
lines.push(` ${c.trimStart()}`);
|
|
317
882
|
if (Array.isArray(subsubval)) {
|
|
318
883
|
if (subsubval.length === 0) {
|
|
319
884
|
lines.push(` ${subsubkey}: []`);
|
|
@@ -334,22 +899,86 @@ function reconstructFrontmatter(obj) {
|
|
|
334
899
|
else {
|
|
335
900
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
336
901
|
const sv = String(subval);
|
|
337
|
-
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${
|
|
902
|
+
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
|
|
338
903
|
}
|
|
339
904
|
}
|
|
340
905
|
}
|
|
341
906
|
else {
|
|
342
907
|
const sv = String(value);
|
|
343
908
|
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) {
|
|
344
|
-
lines.push(`${key}: "${
|
|
909
|
+
lines.push(`${key}: "${escapeDoubleQuotedScalar(sv)}"`);
|
|
345
910
|
}
|
|
346
911
|
else {
|
|
347
912
|
lines.push(`${key}: ${sv}`);
|
|
348
913
|
}
|
|
349
914
|
}
|
|
350
915
|
}
|
|
916
|
+
// #3257: re-emit any trailing full-line comments (those after the last key).
|
|
917
|
+
if (commentChannel?.trailing?.length) {
|
|
918
|
+
for (const c of commentChannel.trailing)
|
|
919
|
+
lines.push(c);
|
|
920
|
+
}
|
|
351
921
|
return lines.join('\n');
|
|
352
922
|
}
|
|
923
|
+
/**
|
|
924
|
+
* #3257: copy the full-line-comment channel from `source` onto `target`, filtering
|
|
925
|
+
* `leading` to keys still present in `target` (a deleted key's annotation goes with
|
|
926
|
+
* it — AC5). No-op when `source` carries no channel. Consumers that rebuild their
|
|
927
|
+
* target object fresh (syncStateFrontmatter builds derivedFm via buildStateFrontmatter
|
|
928
|
+
* and copies keys with Object.keys, which skips the Symbol) MUST call this before
|
|
929
|
+
* reconstructFrontmatter, or the channel parseGuardedYamlRegion attached to the extracted
|
|
930
|
+
* source is lost.
|
|
931
|
+
*/
|
|
932
|
+
function propagateCommentChannel(source, target) {
|
|
933
|
+
const channel = source[FULL_LINE_COMMENTS];
|
|
934
|
+
if (!channel)
|
|
935
|
+
return;
|
|
936
|
+
// #3742: two changes, both about a target that is a PARTIAL rebuild.
|
|
937
|
+
//
|
|
938
|
+
// (a) Root-segment membership: a comment keyed by a dotted path
|
|
939
|
+
// (`progress.total_plans`) survives while its root section survives —
|
|
940
|
+
// requiring the full path to resolve inside `target` would drop every
|
|
941
|
+
// nested comment the moment the rebuild reconstructed the section
|
|
942
|
+
// object (a fresh object with the same leaf keys still matches at
|
|
943
|
+
// EMIT time; membership is about the section existing at all).
|
|
944
|
+
// (b) Merge, not clobber: `target` may already carry its own channel
|
|
945
|
+
// (extracted from content that kept some comments). Target entries win
|
|
946
|
+
// for the same key; source entries fill the gaps; trailing lists
|
|
947
|
+
// concatenate (source first, mirroring document order when the source
|
|
948
|
+
// is the earlier snapshot).
|
|
949
|
+
const hasOwn = (o, k) => Object.prototype.hasOwnProperty.call(o, k);
|
|
950
|
+
const rootAlive = (k) => {
|
|
951
|
+
const root = k.split('.')[0];
|
|
952
|
+
return hasOwn(target, root);
|
|
953
|
+
};
|
|
954
|
+
// Null-prototype `leading` (post-#3881-review, finding 3) — same rationale as
|
|
955
|
+
// `extractCommentChannel`. `target` may be a plain `{}` built by a caller outside this
|
|
956
|
+
// module (e.g. `buildStateFrontmatter`), so membership is checked via
|
|
957
|
+
// `hasOwnProperty`, not the `in` operator: `in` walks target's OWN prototype chain too,
|
|
958
|
+
// and a target key named `constructor`/`toString`/etc. would otherwise read as "present"
|
|
959
|
+
// even when it was never actually set.
|
|
960
|
+
const existingChannel = target[FULL_LINE_COMMENTS];
|
|
961
|
+
// Trailing: when the target already carries a channel, its trailing list is
|
|
962
|
+
// the SAME comments re-parsed from content this lineage already emitted —
|
|
963
|
+
// concatenating would duplicate them on every write (unbounded growth,
|
|
964
|
+
// #3742 review). Take the target's list; only a channel-less target
|
|
965
|
+
// (a fresh rebuild, e.g. buildStateFrontmatter output) inherits the
|
|
966
|
+
// source's trailing comments.
|
|
967
|
+
const merged = {
|
|
968
|
+
leading: Object.create(null),
|
|
969
|
+
trailing: existingChannel ? existingChannel.trailing : channel.trailing,
|
|
970
|
+
};
|
|
971
|
+
for (const [key, comments] of Object.entries(existingChannel?.leading ?? {})) {
|
|
972
|
+
merged.leading[key] = comments;
|
|
973
|
+
}
|
|
974
|
+
for (const [key, comments] of Object.entries(channel.leading)) {
|
|
975
|
+
if (!hasOwn(merged.leading, key) && rootAlive(key))
|
|
976
|
+
merged.leading[key] = comments;
|
|
977
|
+
}
|
|
978
|
+
if (merged.trailing.length || Object.keys(merged.leading).length) {
|
|
979
|
+
target[FULL_LINE_COMMENTS] = merged;
|
|
980
|
+
}
|
|
981
|
+
}
|
|
353
982
|
/**
|
|
354
983
|
* Slice a frontmatter YAML body into per-top-level-key raw text segments. Each segment
|
|
355
984
|
* runs from a column-0 `key:` line through the line before the next column-0 key (or the
|
|
@@ -359,7 +988,7 @@ function reconstructFrontmatter(obj) {
|
|
|
359
988
|
* not modify (e.g. must_haves.artifacts / .prohibitions).
|
|
360
989
|
*/
|
|
361
990
|
function sliceTopLevelFrontmatterSegments(yaml) {
|
|
362
|
-
const lines =
|
|
991
|
+
const lines = (0, text_lines_cjs_1.splitLines)(yaml);
|
|
363
992
|
const segments = [];
|
|
364
993
|
let current = null;
|
|
365
994
|
for (const line of lines) {
|
|
@@ -423,7 +1052,7 @@ function spliceFrontmatter(content, newObj) {
|
|
|
423
1052
|
// unrelated `must_haves` block. Keys absent from the original (genuinely new) are
|
|
424
1053
|
// regenerated and appended; keys absent from `newObj` are preserved (never silently
|
|
425
1054
|
// deleted by a set/merge).
|
|
426
|
-
const fmLines =
|
|
1055
|
+
const fmLines = (0, text_lines_cjs_1.splitLines)(fmBlock);
|
|
427
1056
|
const inner = fmLines.slice(1, -1).join('\n'); // drop the opening `---` and closing `---`
|
|
428
1057
|
let originalParsed;
|
|
429
1058
|
try {
|
|
@@ -497,126 +1126,87 @@ function frontmatterDeepEqual(a, b) {
|
|
|
497
1126
|
}
|
|
498
1127
|
return false;
|
|
499
1128
|
}
|
|
1129
|
+
/**
|
|
1130
|
+
* ADR-3473 §8.1 (#3881): the legacy `- key: value` same-line-with-dash capture never trimmed
|
|
1131
|
+
* or number-coerced its value (`current[kvMatch[1]] = kvMatch[2]` verbatim), while every
|
|
1132
|
+
* CONTINUATION line (a further-indented sibling key under the same list item) both trimmed
|
|
1133
|
+
* (`kvMatch[2].trim()` — #1905/#1154, a quoted `"backstop "` must not silently stop matching
|
|
1134
|
+
* the literal `backstop` marker) and number-coerced (`/^\d+$/.test(val) ? parseInt(val, 10) :
|
|
1135
|
+
* val`). That distinction was purely a byproduct of the hand-rolled line scanner's own
|
|
1136
|
+
* position tracking — real YAML has no such notion; `path: x` on the dash's own line and
|
|
1137
|
+
* `count: 1` one line below it are the same kind of mapping entry. `tests/frontmatter.test.cjs`
|
|
1138
|
+
* ("trims a continuation-KV value…") pins the trimming behavior, so it is reproduced here by
|
|
1139
|
+
* treating an object item's FIRST own key (source order, matching the dash line) as untouched
|
|
1140
|
+
* and every subsequent key as "continuation": trimmed, and coerced to a number when (after
|
|
1141
|
+
* trimming) it is all-digits — the exact `/^\d+$/` shape the legacy scanner recognized, never a
|
|
1142
|
+
* broader YAML-native numeric resolution (which would also promote floats/octal/booleans the
|
|
1143
|
+
* legacy scanner left as strings).
|
|
1144
|
+
*/
|
|
1145
|
+
function coerceMustHavesValue(value, isContinuation) {
|
|
1146
|
+
if (typeof value !== 'string')
|
|
1147
|
+
return value; // arrays/nested maps pass through untouched
|
|
1148
|
+
if (!isContinuation)
|
|
1149
|
+
return value;
|
|
1150
|
+
const trimmed = value.trim();
|
|
1151
|
+
return /^\d+$/.test(trimmed) ? parseInt(trimmed, 10) : trimmed;
|
|
1152
|
+
}
|
|
1153
|
+
/** Normalize one must_haves list item to the legacy contract: a plain scalar item stays a
|
|
1154
|
+
* string; an object item gets `coerceMustHavesValue`'s same-line/continuation treatment
|
|
1155
|
+
* (see that function's docblock).
|
|
1156
|
+
*/
|
|
1157
|
+
function normalizeMustHavesItem(item) {
|
|
1158
|
+
if (item === null || typeof item !== 'object' || Array.isArray(item))
|
|
1159
|
+
return item;
|
|
1160
|
+
const out = {};
|
|
1161
|
+
Object.entries(item).forEach(([k, v], idx) => {
|
|
1162
|
+
out[k] = coerceMustHavesValue(v, idx > 0);
|
|
1163
|
+
});
|
|
1164
|
+
return out;
|
|
1165
|
+
}
|
|
1166
|
+
/**
|
|
1167
|
+
* Extract a specific block from `must_haves` in frontmatter YAML (e.g. `must_haves.truths`,
|
|
1168
|
+
* `must_haves.artifacts`, `must_haves.key_links`) — via the same vendored js-yaml parser the
|
|
1169
|
+
* rest of this module uses (ADR-3473 §8.1 / #3881), rather than the hand-rolled indentation
|
|
1170
|
+
* scanner this replaces.
|
|
1171
|
+
*
|
|
1172
|
+
* Deliberately NOT routed through `parseGuardedYamlRegion`: that function flattens an
|
|
1173
|
+
* object-shaped list ITEM to a single canonical string (consequence 3), which is the correct
|
|
1174
|
+
* contract for the top-level Frontmatter value shape but would collapse `must_haves.artifacts`'s
|
|
1175
|
+
* `{path, provides, ...}` items into unusable strings. This parses the region independently,
|
|
1176
|
+
* under the same `FAILSAFE_SCHEMA` + `json: true` options (every scalar a string, duplicate
|
|
1177
|
+
* keys last-wins) and the same anchor/alias/merge-key refusal (`refuseAnchorsAndAliases`) —
|
|
1178
|
+
* `.planning/` must_haves blocks are untrusted input exactly like the rest of frontmatter.
|
|
1179
|
+
*/
|
|
500
1180
|
function parseMustHavesBlock(content, blockName) {
|
|
501
|
-
// Extract a specific block from must_haves in raw frontmatter YAML
|
|
502
|
-
// Handles 3-level nesting: must_haves > artifacts/key_links > [{path, provides, ...}]
|
|
503
1181
|
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
504
1182
|
if (!fmMatch)
|
|
505
1183
|
return [];
|
|
506
1184
|
const yaml = fmMatch[1];
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
// It must be indented more than must_haves but we detect the actual indent dynamically
|
|
514
|
-
const blockPattern = new RegExp(`^(\\s+)${blockName}:\\s*$`, 'm');
|
|
515
|
-
const blockMatch = yaml.match(blockPattern);
|
|
516
|
-
if (!blockMatch)
|
|
1185
|
+
let parsed;
|
|
1186
|
+
try {
|
|
1187
|
+
refuseAnchorsAndAliases(yaml);
|
|
1188
|
+
parsed = (0, js_yaml_cjs_1.load)(yaml, YAML_LOAD_OPTS);
|
|
1189
|
+
}
|
|
1190
|
+
catch {
|
|
517
1191
|
return [];
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
if (blockIndent <= mustHavesIndent)
|
|
1192
|
+
}
|
|
1193
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
521
1194
|
return [];
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
if (blockStart === -1)
|
|
1195
|
+
const mustHaves = parsed.must_haves;
|
|
1196
|
+
if (!mustHaves || typeof mustHaves !== 'object' || Array.isArray(mustHaves))
|
|
525
1197
|
return [];
|
|
526
|
-
const
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
for (const line of blockLines) {
|
|
534
|
-
// Skip empty lines
|
|
535
|
-
if (line.trim() === '')
|
|
536
|
-
continue;
|
|
537
|
-
const indentMatch = line.match(/^(\s*)/);
|
|
538
|
-
const indent = indentMatch ? indentMatch[1].length : 0;
|
|
539
|
-
// Stop at same or lower indent level than the block header
|
|
540
|
-
if (indent <= blockIndent && line.trim() !== '')
|
|
541
|
-
break;
|
|
542
|
-
const trimmed = line.trim();
|
|
543
|
-
if (trimmed.startsWith('- ')) {
|
|
544
|
-
// Detect list item indent from the first occurrence
|
|
545
|
-
if (listItemIndent === -1)
|
|
546
|
-
listItemIndent = indent;
|
|
547
|
-
// Only treat as a top-level list item if at the expected indent
|
|
548
|
-
if (indent === listItemIndent) {
|
|
549
|
-
if (current)
|
|
550
|
-
items.push(current);
|
|
551
|
-
const afterDash = trimmed.slice(2);
|
|
552
|
-
const trimmedAfterDash = afterDash.trim();
|
|
553
|
-
// Check if it's a fully-quoted string (may contain ':' inside the quotes)
|
|
554
|
-
if ((trimmedAfterDash.startsWith('"') && trimmedAfterDash.endsWith('"')) ||
|
|
555
|
-
(trimmedAfterDash.startsWith("'") && trimmedAfterDash.endsWith("'"))) {
|
|
556
|
-
current = trimmedAfterDash.slice(1, -1);
|
|
557
|
-
// Check if it's a simple string item (no colon means not a key-value)
|
|
558
|
-
}
|
|
559
|
-
else if (!afterDash.includes(':')) {
|
|
560
|
-
current = afterDash.replace(/^["']|["']$/g, '');
|
|
561
|
-
}
|
|
562
|
-
else {
|
|
563
|
-
// Key-value on same line as dash: "- path: value"
|
|
564
|
-
// YAML KV always has at least one space after the colon: "key: value"
|
|
565
|
-
// Requiring \s+ rejects "Class::Method" and "db:seed" (no space after colon)
|
|
566
|
-
const kvMatch = afterDash.match(/^(\w+):\s+"?([^"]*)"?\s*$/);
|
|
567
|
-
if (kvMatch) {
|
|
568
|
-
current = {};
|
|
569
|
-
(current)[kvMatch[1]] = kvMatch[2];
|
|
570
|
-
}
|
|
571
|
-
else {
|
|
572
|
-
// Looks like KV but doesn't match — treat as plain string (#2757)
|
|
573
|
-
current = afterDash.replace(/^["']|["']$/g, '');
|
|
574
|
-
}
|
|
575
|
-
}
|
|
576
|
-
continue;
|
|
577
|
-
}
|
|
578
|
-
}
|
|
579
|
-
if (current && typeof current === 'object' && indent > listItemIndent) {
|
|
580
|
-
// Continuation key-value or nested array item
|
|
581
|
-
if (trimmed.startsWith('- ')) {
|
|
582
|
-
// Array item under a key
|
|
583
|
-
const arrVal = trimmed.slice(2).replace(/^["']|["']$/g, '');
|
|
584
|
-
const keys = Object.keys(current);
|
|
585
|
-
const lastKey = keys[keys.length - 1];
|
|
586
|
-
if (lastKey && !Array.isArray((current)[lastKey])) {
|
|
587
|
-
const existing = (current)[lastKey];
|
|
588
|
-
(current)[lastKey] = existing ? [existing] : [];
|
|
589
|
-
}
|
|
590
|
-
if (lastKey)
|
|
591
|
-
(current)[lastKey].push(arrVal);
|
|
592
|
-
}
|
|
593
|
-
else {
|
|
594
|
-
const kvMatch = trimmed.match(/^(\w+):\s*"?([^"]*)"?\s*$/);
|
|
595
|
-
if (kvMatch) {
|
|
596
|
-
// Trim: a quoted value like `"backstop "` captures the inner trailing space in group 2.
|
|
597
|
-
// Left untrimmed, a hand-authored `must_haves` marker degrades (a `backstop` truth silently
|
|
598
|
-
// grades green instead of abstaining — #1905, the #1154 false-pass; also the sibling
|
|
599
|
-
// check_target/violationFixture path). Whitespace is never semantic in a scalar KV value.
|
|
600
|
-
const val = kvMatch[2].trim();
|
|
601
|
-
// Try to parse as number
|
|
602
|
-
(current)[kvMatch[1]] = /^\d+$/.test(val) ? parseInt(val, 10) : val;
|
|
603
|
-
}
|
|
604
|
-
}
|
|
605
|
-
}
|
|
606
|
-
}
|
|
607
|
-
if (current)
|
|
608
|
-
items.push(current);
|
|
609
|
-
// Warn when must_haves block exists but parsed as empty -- likely YAML formatting issue.
|
|
610
|
-
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
|
|
611
|
-
// to Option C (LLM-derived truths) instead of checking documented contracts.
|
|
612
|
-
if (items.length === 0 && blockLines.length > 0) {
|
|
613
|
-
const nonEmptyLines = blockLines.filter(l => l.trim() !== '').length;
|
|
614
|
-
if (nonEmptyLines > 0) {
|
|
615
|
-
process.stderr.write(`[gsd-tools] WARNING: must_haves.${blockName} block has ${nonEmptyLines} content lines but parsed 0 items. ` +
|
|
1198
|
+
const block = mustHaves[blockName];
|
|
1199
|
+
if (!Array.isArray(block)) {
|
|
1200
|
+
// Warn when the block exists but isn't a usable list — likely a YAML formatting issue.
|
|
1201
|
+
// This is a critical diagnostic: empty must_haves causes verification to silently degrade
|
|
1202
|
+
// to Option C (LLM-derived truths) instead of checking documented contracts.
|
|
1203
|
+
if (block !== undefined && block !== null) {
|
|
1204
|
+
process.stderr.write(`[gsd-tools] WARNING: must_haves.${blockName} block has content but parsed 0 items. ` +
|
|
616
1205
|
`Possible YAML formatting issue — verification will fall back to LLM-derived truths.\n`);
|
|
617
1206
|
}
|
|
1207
|
+
return [];
|
|
618
1208
|
}
|
|
619
|
-
return
|
|
1209
|
+
return block.map(normalizeMustHavesItem);
|
|
620
1210
|
}
|
|
621
1211
|
// ─── Frontmatter CRUD commands ────────────────────────────────────────────────
|
|
622
1212
|
// Shared base for 'plan' and 'plan-gap-closure' below — a plain array reference (not
|
|
@@ -733,6 +1323,14 @@ function cmdFrontmatterSet(cwd, filePath, field, value, raw) {
|
|
|
733
1323
|
catch {
|
|
734
1324
|
parsedValue = value;
|
|
735
1325
|
}
|
|
1326
|
+
// #1660 (broadened): a lossy object-list field being genuinely CHANGED must fail closed
|
|
1327
|
+
// before it is regenerated, not just when the regenerated result happens to be byte-identical
|
|
1328
|
+
// to the original (see objectListFieldWouldLoseData's docblock).
|
|
1329
|
+
const lossyErr = objectListFieldWouldLoseData(content, field, parsedValue);
|
|
1330
|
+
if (lossyErr) {
|
|
1331
|
+
output({ error: lossyErr, field }, raw, undefined);
|
|
1332
|
+
return;
|
|
1333
|
+
}
|
|
736
1334
|
fm[field] = parsedValue;
|
|
737
1335
|
const newContent = spliceFrontmatter(content, fm);
|
|
738
1336
|
// #1660: a no-op set (newContent unchanged) with a dict-valued field means the lossy
|
|
@@ -764,6 +1362,69 @@ function noOpObjectListSetError(originalContent, newContent, parsedValue) {
|
|
|
764
1362
|
return null;
|
|
765
1363
|
return 'frontmatter set had no effect — the supplied value is equivalent to the existing field under the frontmatter parser, which cannot faithfully round-trip object-list fields like must_haves. Edit the file directly.';
|
|
766
1364
|
}
|
|
1365
|
+
/**
|
|
1366
|
+
* #1660 (broadened, ADR-3473 §8.1 / #3881): `noOpObjectListSetError` only catches the
|
|
1367
|
+
* BYTE-IDENTICAL no-op case. Under the js-yaml migration, `flattenObjectListItem` correctly
|
|
1368
|
+
* joins EVERY sub-key of an object-list item (`path: X, provides: Y`) instead of the legacy
|
|
1369
|
+
* hand-rolled scanner's accidental behavior of silently discarding every field but the one on
|
|
1370
|
+
* the dash line itself. That fixes a real data-loss bug on READ, but it also means a `set` that
|
|
1371
|
+
* replaces such a field with a plainly-flattened string (e.g. `{artifacts: ["path: X"]}`,
|
|
1372
|
+
* omitting `provides`) is no longer byte-identical to the original — so it no longer trips the
|
|
1373
|
+
* no-op guard, sails through `regenerateFrontmatterKey` (which only refuses when the NEW value
|
|
1374
|
+
* itself contains a live JS object), and silently writes a version with `provides` gone.
|
|
1375
|
+
*
|
|
1376
|
+
* This is the general form of the same "cannot faithfully round-trip" contract: a field is
|
|
1377
|
+
* lossy exactly when regenerating its OWN already-parsed value fails to reproduce its own raw
|
|
1378
|
+
* source text byte-for-byte (proof, not a guess, that this key's original shape does not
|
|
1379
|
+
* survive parse → reconstruct). When that is true AND the caller is genuinely changing the
|
|
1380
|
+
* field (not merely re-supplying an equal value, which `frontmatterDeepEqual` already lets
|
|
1381
|
+
* through), the set is refused — matching `regenerateFrontmatterKey`'s own fail-closed
|
|
1382
|
+
* philosophy for the mirror-image case (new value carries a nested object outright).
|
|
1383
|
+
*/
|
|
1384
|
+
function objectListFieldWouldLoseData(content, field, newValue) {
|
|
1385
|
+
// A NEW value that itself carries a live nested object (rather than an already-flattened
|
|
1386
|
+
// string) is the mirror-image case `regenerateFrontmatterKey` already refuses on its own
|
|
1387
|
+
// (the "[object Object]" guard, via spliceFrontmatter) — leave that path's existing throw
|
|
1388
|
+
// behavior alone rather than intercepting it here with a different (non-throwing) contract.
|
|
1389
|
+
try {
|
|
1390
|
+
regenerateFrontmatterKey(field, newValue);
|
|
1391
|
+
}
|
|
1392
|
+
catch {
|
|
1393
|
+
return null;
|
|
1394
|
+
}
|
|
1395
|
+
let originalParsed;
|
|
1396
|
+
try {
|
|
1397
|
+
originalParsed = extractFrontmatter(content);
|
|
1398
|
+
}
|
|
1399
|
+
catch {
|
|
1400
|
+
return null;
|
|
1401
|
+
}
|
|
1402
|
+
if (!Object.prototype.hasOwnProperty.call(originalParsed, field))
|
|
1403
|
+
return null;
|
|
1404
|
+
const originalValue = originalParsed[field];
|
|
1405
|
+
if (frontmatterDeepEqual(newValue, originalValue))
|
|
1406
|
+
return null;
|
|
1407
|
+
const fmMatch = content.match(/^---\r?\n([\s\S]+?)\r?\n---/);
|
|
1408
|
+
if (!fmMatch)
|
|
1409
|
+
return null;
|
|
1410
|
+
const original = sliceTopLevelFrontmatterSegments(fmMatch[1]).find((s) => s.key === field);
|
|
1411
|
+
if (!original)
|
|
1412
|
+
return null;
|
|
1413
|
+
let regeneratedOriginal;
|
|
1414
|
+
try {
|
|
1415
|
+
regeneratedOriginal = regenerateFrontmatterKey(field, originalValue);
|
|
1416
|
+
}
|
|
1417
|
+
catch {
|
|
1418
|
+
return `frontmatter set refused — the existing "${field}" field contains a nested object-list ` +
|
|
1419
|
+
`(e.g. must_haves.artifacts) the frontmatter writer cannot faithfully represent, and this change ` +
|
|
1420
|
+
`would silently discard data. Edit the file directly instead of using frontmatter set/merge.`;
|
|
1421
|
+
}
|
|
1422
|
+
if (regeneratedOriginal.trim() === original.raw.trim())
|
|
1423
|
+
return null;
|
|
1424
|
+
return `frontmatter set refused — the existing "${field}" field cannot be faithfully round-tripped by ` +
|
|
1425
|
+
`the frontmatter writer (its structure would be flattened and data, such as a nested object-list ` +
|
|
1426
|
+
`field, silently dropped). Edit the file directly instead of using frontmatter set/merge.`;
|
|
1427
|
+
}
|
|
767
1428
|
function cmdFrontmatterMerge(cwd, filePath, data, raw) {
|
|
768
1429
|
if (!filePath || !data) {
|
|
769
1430
|
error('file and data required');
|
|
@@ -848,8 +1509,16 @@ function cmdFrontmatterValidate(cwd, filePath, schemaName, raw) {
|
|
|
848
1509
|
output({ valid: missing.length === 0, missing, present, invalidValue, schema: schemaName }, raw, missing.length === 0 ? 'valid' : 'invalid');
|
|
849
1510
|
}
|
|
850
1511
|
module.exports = {
|
|
1512
|
+
// #3706: shared with the agent-frontmatter writers so a config-supplied
|
|
1513
|
+
// `model:`/`variant:` value cannot break out of its scalar. Previously private
|
|
1514
|
+
// here while those writers interpolated raw — one escaper, three call sites.
|
|
1515
|
+
escapeDoubleQuotedScalar,
|
|
1516
|
+
agentScalarNeedsDoubleQuoting,
|
|
851
1517
|
extractFrontmatter,
|
|
852
1518
|
UNTERMINATED_KEY_THRESHOLD,
|
|
1519
|
+
// ADR-3473 §8.1 (#3881, consequence 2): the unparseable-vs-empty marker Symbol. Exported so
|
|
1520
|
+
// the 8 `hasFrontmatter` call sites named in the design can consult it in a follow-up change.
|
|
1521
|
+
FRONTMATTER_UNPARSEABLE,
|
|
853
1522
|
// Additive alias (#644 prohibition-probe schema contract): the probe round-trip seam reads a
|
|
854
1523
|
// frontmatter object via `parseFrontmatter` (the name the contract test pins). It is the SAME
|
|
855
1524
|
// function as `extractFrontmatter` — a bare-object parse with no behavior change — exposed under
|
|
@@ -865,4 +1534,5 @@ module.exports = {
|
|
|
865
1534
|
cmdFrontmatterSet,
|
|
866
1535
|
cmdFrontmatterMerge,
|
|
867
1536
|
cmdFrontmatterValidate,
|
|
1537
|
+
propagateCommentChannel,
|
|
868
1538
|
};
|