@opengsd/gsd-core 1.11.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 +1 -1
- package/agents/gsd-debugger.md +1 -1
- package/agents/gsd-dom-verifier.md +169 -0
- package/agents/gsd-eval-auditor.md +1 -1
- package/agents/gsd-executor.md +17 -9
- package/agents/gsd-framework-selector.md +1 -3
- package/agents/gsd-intel-updater.md +1 -1
- package/agents/gsd-mempalace-curator.md +0 -1
- package/agents/gsd-pattern-mapper.md +11 -0
- package/agents/gsd-phase-researcher.md +3 -1
- package/agents/gsd-plan-checker.md +15 -55
- package/agents/gsd-planner.md +6 -4
- 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-verifier.md +1 -1
- package/bin/install.js +609 -134
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/quick.md +8 -4
- package/gsd-core/bin/gsd-tools.cjs +567 -51
- package/gsd-core/bin/lib/active-workstream-store.cjs +8 -0
- package/gsd-core/bin/lib/adr-parser.cjs +13 -7
- package/gsd-core/bin/lib/agent-install-check.cjs +162 -0
- package/gsd-core/bin/lib/api-coverage.cjs +30 -9
- package/gsd-core/bin/lib/artifacts.cjs +2 -0
- package/gsd-core/bin/lib/assumption-delta.cjs +30 -11
- package/gsd-core/bin/lib/audit.cjs +163 -41
- package/gsd-core/bin/lib/broken-windows.cjs +306 -28
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +336 -95
- package/gsd-core/bin/lib/capability-state.cjs +18 -3
- package/gsd-core/bin/lib/capability-validator.cjs +205 -18
- package/gsd-core/bin/lib/check-command-router.cjs +145 -5
- 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 +410 -4
- package/gsd-core/bin/lib/command-arg-projection.cjs +144 -14
- package/gsd-core/bin/lib/command-routing-hub.cjs +31 -2
- package/gsd-core/bin/lib/commands.cjs +543 -44
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +118 -29
- package/gsd-core/bin/lib/config.cjs +92 -2
- package/gsd-core/bin/lib/configuration.cjs +129 -37
- package/gsd-core/bin/lib/core-utils.cjs +84 -7
- 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/frontmatter.cjs +840 -305
- package/gsd-core/bin/lib/gap-checker.cjs +27 -3
- package/gsd-core/bin/lib/git-base-branch.cjs +174 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +6 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/worktree-health.cjs +22 -8
- package/gsd-core/bin/lib/health-diagnostic.cjs +23 -3
- package/gsd-core/bin/lib/host-integration.cjs +39 -6
- package/gsd-core/bin/lib/init-command-router.cjs +118 -21
- package/gsd-core/bin/lib/init.cjs +120 -41
- package/gsd-core/bin/lib/install-engine.cjs +68 -3
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +33 -1
- package/gsd-core/bin/lib/install-profiles.cjs +78 -4
- 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 +10 -7
- package/gsd-core/bin/lib/intel.cjs +101 -26
- package/gsd-core/bin/lib/io.cjs +160 -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-table.cjs +52 -4
- package/gsd-core/bin/lib/milestone.cjs +90 -5
- package/gsd-core/bin/lib/model-catalog.cjs +177 -19
- package/gsd-core/bin/lib/model-resolver.cjs +10 -28
- package/gsd-core/bin/lib/onboard-projection.cjs +5 -1
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +70 -4
- package/gsd-core/bin/lib/phase-lifecycle.cjs +24 -16
- package/gsd-core/bin/lib/phase-locator.cjs +138 -17
- package/gsd-core/bin/lib/phase.cjs +405 -84
- package/gsd-core/bin/lib/plan-document.cjs +263 -0
- package/gsd-core/bin/lib/plan-scan.cjs +13 -2
- 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-snapshot.cjs +18 -14
- package/gsd-core/bin/lib/planning-workspace.cjs +56 -0
- package/gsd-core/bin/lib/probe-core.cjs +4 -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 +71 -45
- package/gsd-core/bin/lib/review-lane-descriptor.cjs +9 -9
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +79 -16
- package/gsd-core/bin/lib/roadmap.cjs +74 -19
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +96 -8
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +34 -1
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +287 -55
- package/gsd-core/bin/lib/runtime-identity.cjs +234 -0
- package/gsd-core/bin/lib/runtime-slash.cjs +72 -2
- package/gsd-core/bin/lib/shell-command-projection.cjs +71 -8
- package/gsd-core/bin/lib/smart-entry.cjs +12 -22
- package/gsd-core/bin/lib/spec-section.cjs +12 -7
- package/gsd-core/bin/lib/state-command-router.cjs +47 -18
- package/gsd-core/bin/lib/state-contract.cjs +359 -0
- package/gsd-core/bin/lib/state-document.cjs +186 -0
- package/gsd-core/bin/lib/state-md-schema.cjs +221 -0
- package/gsd-core/bin/lib/state-transition.cjs +517 -101
- package/gsd-core/bin/lib/state.cjs +946 -163
- package/gsd-core/bin/lib/surface.cjs +10 -2
- 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/uat-predicate.cjs +58 -20
- package/gsd-core/bin/lib/uat.cjs +1376 -125
- package/gsd-core/bin/lib/ui-consideration-probe.cjs +9 -1
- package/gsd-core/bin/lib/ui-safety-gate.cjs +37 -7
- package/gsd-core/bin/lib/unusable-input.cjs +13 -0
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/vendor/README.md +43 -5
- package/gsd-core/bin/lib/vendor/js-yaml.cjs +3014 -0
- package/gsd-core/bin/lib/verification.cjs +14 -1
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify.cjs +95 -40
- package/gsd-core/bin/lib/workstream-name-policy.cjs +25 -4
- package/gsd-core/bin/lib/worktree-base-ref.cjs +66 -12
- package/gsd-core/bin/lib/worktree-safety.cjs +177 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +5 -0
- 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 +3 -2
- 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 +37 -19
- package/gsd-core/references/decimal-phase-calculation.md +5 -5
- package/gsd-core/references/edge-probe.md +8 -0
- package/gsd-core/references/execute-mvp-tdd.md +1 -3
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- 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 +3 -3
- package/gsd-core/references/gsd-run-resolver.md +1 -1
- package/gsd-core/references/loop-hook-dispatch.md +22 -0
- package/gsd-core/references/model-profiles.md +1 -1
- 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-human-verify-mode.md +15 -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 +37 -8
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/runtime-aware-dispatch.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 +2 -2
- package/gsd-core/references/verify-command-path-resolvability.md +42 -0
- package/gsd-core/references/verify-mvp-mode.md +1 -1
- package/gsd-core/references/workstream-flag.md +11 -11
- 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/phase-prompt.md +3 -0
- package/gsd-core/templates/state.md +7 -0
- 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 +4 -9
- 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 +10 -26
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +2 -2
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +1 -1
- package/gsd-core/workflows/code-review-fix.md +1 -1
- package/gsd-core/workflows/code-review.md +121 -40
- package/gsd-core/workflows/complete-milestone.md +15 -10
- package/gsd-core/workflows/debug.md +5 -3
- package/gsd-core/workflows/diagnose-issues.md +12 -6
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/gsd-core/workflows/discuss-phase/modes/chain.md +3 -7
- 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 +2 -2
- 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 +1 -1
- package/gsd-core/workflows/eval-review.md +4 -9
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +113 -11
- 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 +1 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +22 -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 +38 -54
- package/gsd-core/workflows/execute-plan.md +17 -12
- package/gsd-core/workflows/explore.md +1 -1
- package/gsd-core/workflows/extract-learnings.md +1 -1
- package/gsd-core/workflows/fast.md +2 -2
- package/gsd-core/workflows/forensics.md +1 -1
- package/gsd-core/workflows/graduation.md +5 -5
- package/gsd-core/workflows/health.md +3 -6
- package/gsd-core/workflows/import.md +14 -11
- package/gsd-core/workflows/inbox.md +4 -5
- package/gsd-core/workflows/ingest-docs.md +44 -11
- 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 +2 -2
- package/gsd-core/workflows/new-milestone.md +9 -21
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +12 -26
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -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 +121 -42
- package/gsd-core/workflows/plan-review-convergence.md +46 -9
- 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 +27 -12
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +1 -3
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +2 -4
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick.md +20 -29
- 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 +193 -15
- package/gsd-core/workflows/scan.md +1 -1
- package/gsd-core/workflows/secure-phase.md +2 -2
- package/gsd-core/workflows/settings-advanced.md +7 -9
- package/gsd-core/workflows/settings-integrations.md +64 -31
- package/gsd-core/workflows/settings.md +3 -5
- package/gsd-core/workflows/ship.md +12 -6
- 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 +23 -1
- 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 +1 -1
- package/gsd-core/workflows/thread.md +11 -7
- package/gsd-core/workflows/transition.md +5 -5
- package/gsd-core/workflows/ui-phase.md +10 -16
- package/gsd-core/workflows/ui-review.md +6 -10
- package/gsd-core/workflows/ultraplan-phase.md +5 -13
- package/gsd-core/workflows/undo.md +8 -16
- package/gsd-core/workflows/update.md +6 -10
- package/gsd-core/workflows/validate-phase.md +2 -2
- 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 +57 -18
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- 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 +3 -1
- 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 +28 -23
- package/hooks/dist/gsd-cursor-subagent-stop.js +3 -1
- 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 +16 -7
- package/hooks/dist/gsd-read-guard.js +16 -7
- package/hooks/dist/gsd-read-injection-scanner.js +17 -8
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +215 -26
- 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 +34 -16
- 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-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/managed-hooks-registry.cjs +3 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- 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 +3 -1
- package/hooks/gsd-cursor-session-start.js +2 -1
- package/hooks/gsd-cursor-stop.js +2 -1
- package/hooks/gsd-cursor-subagent-start.js +28 -23
- package/hooks/gsd-cursor-subagent-stop.js +3 -1
- 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 +16 -7
- package/hooks/gsd-read-guard.js +16 -7
- package/hooks/gsd-read-injection-scanner.js +17 -8
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +215 -26
- 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 +34 -16
- 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-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/managed-hooks-registry.cjs +3 -0
- package/package.json +12 -7
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +5 -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 +45 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +396 -0
- package/scripts/gen-capability-registry.cjs +8 -6
- 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-hooks-cli-exit.cjs +239 -0
- package/scripts/gen-install-tree-fixtures.cjs +2 -2
- package/scripts/gen-loop-host-contract.cjs +134 -1
- 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/ci-job-timing.cjs +72 -0
- package/scripts/lib/cli-exit.cjs +546 -44
- package/scripts/lib/drift-scan.cjs +32 -2
- package/scripts/lib/exit-code-registry.cjs +98 -0
- package/scripts/lib/ndjson-reporter.cjs +119 -0
- package/scripts/lint-allow-test-rule-refs.unverified-ceiling.json +1 -1
- 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 +4 -0
- package/scripts/{lint-fix-has-regression-test.cjs → lint-fix-has-regression-tests.cjs} +12 -6
- package/scripts/lint-health-diagnostic-rule-table.cjs +65 -8
- package/scripts/lint-mutation-test-derivation-drift.cjs +86 -0
- package/scripts/lint-phase-enumeration-drift.cjs +21 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-removed-but-needed.cjs +184 -16
- 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-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +122 -4
- package/scripts/lint-test-file-count.cjs +25 -3
- package/scripts/lint-unreachable-guard-drift.cjs +51 -64
- package/scripts/lint-vendored-deps.cjs +208 -35
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/prompt-injection-scan.sh +75 -14
- package/scripts/secret-scan.sh +75 -13
- package/scripts/select-docs-guards.cjs +56 -0
- package/scripts/sync-runtime-launcher.cjs +22 -3
- package/skills/gsd-discuss-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/vscode/package.json +1 -1
- package/bin/lib/ui-safety-gate.cjs +0 -109
- package/scripts/lint-emitted-drift-ack.cjs +0 -344
- package/scripts/state-write-path-drift-baseline.json +0 -19
package/gsd-core/bin/lib/uat.cjs
CHANGED
|
@@ -19,13 +19,13 @@ const io = require("./io.cjs");
|
|
|
19
19
|
const { output, error } = io;
|
|
20
20
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
21
21
|
const markdownSectionizer = require("./markdown-sectionizer.cjs");
|
|
22
|
-
const { collectSection, tokenizeHeadings } = markdownSectionizer;
|
|
22
|
+
const { collectSection, tokenizeHeadings, stripFencedCode, scanFencedBlocks } = markdownSectionizer;
|
|
23
23
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
24
24
|
const markdownTable = require("./markdown-table.cjs");
|
|
25
25
|
const { splitTableRow, isDelimiterRow } = markdownTable;
|
|
26
26
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
27
27
|
const coreUtils = require("./core-utils.cjs");
|
|
28
|
-
const { toPosixPath } = coreUtils;
|
|
28
|
+
const { toPosixPath, normalizeLineEndings } = coreUtils;
|
|
29
29
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
30
30
|
const planningWorkspace = require("./planning-workspace.cjs");
|
|
31
31
|
const { planningDir } = planningWorkspace;
|
|
@@ -37,12 +37,45 @@ const phaseIdMod = require("./phase-id.cjs");
|
|
|
37
37
|
const { PHASE_NUMBER_TOKEN_SOURCE, scopeToPhase } = phaseIdMod;
|
|
38
38
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
39
39
|
const phaseLocator = require("./phase-locator.cjs");
|
|
40
|
-
const {
|
|
40
|
+
const { listMilestonePhaseDirs, getAllArchivedPhaseDirs } = phaseLocator;
|
|
41
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
42
|
+
const auditMod = require("./audit.cjs");
|
|
43
|
+
const { isAuditItemAcknowledged, deriveUatGapSnapshotValue } = auditMod;
|
|
41
44
|
const security_cjs_1 = require("./security.cjs");
|
|
42
45
|
// eslint-disable-next-line @typescript-eslint/no-require-imports -- config-loader.cjs is an export= CommonJS module
|
|
43
46
|
const configLoader = require("./config-loader.cjs");
|
|
44
47
|
const { loadConfig } = configLoader;
|
|
45
48
|
// ─── cmdAuditUat ─────────────────────────────────────────────────────────────
|
|
49
|
+
/**
|
|
50
|
+
* Select the UAT documents belonging to ONE phase directory.
|
|
51
|
+
*
|
|
52
|
+
* Extracted (#2790) so `cmdAuditUat` and the read-only `planning.inspect` query
|
|
53
|
+
* cannot drift on which files count as this phase's UAT. `scopeToPhase` has no
|
|
54
|
+
* unfiltered fallback on purpose: a phase whose own UAT file is genuinely absent
|
|
55
|
+
* scopes to empty and contributes nothing, rather than picking up a stray
|
|
56
|
+
* cross-phase file (#3511).
|
|
57
|
+
*/
|
|
58
|
+
function selectPhaseUatFiles(files, phaseDirName) {
|
|
59
|
+
return scopeToPhase(files.filter((f) => f.includes('-UAT') && f.endsWith('.md')), phaseDirName);
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* The ONE read boundary for every document `cmdAuditUat` scans off disk
|
|
63
|
+
* (#3707-CR follow-up MAJOR). Wraps `fs.readFileSync` +
|
|
64
|
+
* `normalizeLineEndings` in a single seam so a lone-CR-separated
|
|
65
|
+
* `*-UAT.md`, `*-VERIFICATION.md`, or `deferred-items.md` is normalized BY
|
|
66
|
+
* CONSTRUCTION before it reaches ANY downstream parser — current
|
|
67
|
+
* (`parseUatItemsWithStats`, `parseVerificationItems`, `parseDeferredItems`)
|
|
68
|
+
* or future. Fixing this per-parser was the original (#3707-CR) MEDIUM fix's
|
|
69
|
+
* mistake: two of the four ingresses in this function were normalized by
|
|
70
|
+
* editing their own parsers directly, and the other two (VERIFICATION,
|
|
71
|
+
* deferred-items.md) were missed precisely because nothing forced a new call
|
|
72
|
+
* site to remember the step. Routing every read through this function
|
|
73
|
+
* removes that failure mode: a parser added later needs no line-ending logic
|
|
74
|
+
* of its own, because the text it receives is already normalized.
|
|
75
|
+
*/
|
|
76
|
+
function readNormalizedDocument(filePath) {
|
|
77
|
+
return normalizeLineEndings(node_fs_1.default.readFileSync(filePath, 'utf-8'));
|
|
78
|
+
}
|
|
46
79
|
function cmdAuditUat(cwd, raw) {
|
|
47
80
|
const phasesDir = node_path_1.default.join(planningDir(cwd), 'phases');
|
|
48
81
|
const hasActivePhases = node_fs_1.default.existsSync(phasesDir);
|
|
@@ -55,14 +88,18 @@ function cmdAuditUat(cwd, raw) {
|
|
|
55
88
|
// mattering when a milestone closes: a deferred human-UAT scenario or a
|
|
56
89
|
// `skipped` live-stack test is exactly what gets archived still-open.
|
|
57
90
|
//
|
|
58
|
-
// Reuses the canonical `
|
|
91
|
+
// Reuses the canonical `getAllArchivedPhaseDirs` seam (phase-locator.cts), which
|
|
59
92
|
// `findPhaseInternal` already uses for this same fallback, so the archive
|
|
60
93
|
// layout convention stays owned by one module.
|
|
61
|
-
|
|
94
|
+
// #3804: the guard AND the scan use the cross-workstream enumeration —
|
|
95
|
+
// a project whose only phases live in workstream milestone trees is a
|
|
96
|
+
// fully-populated audit, not a broken install.
|
|
97
|
+
const archivedDirs = getAllArchivedPhaseDirs(cwd);
|
|
62
98
|
if (!hasActivePhases && archivedDirs.length === 0) {
|
|
63
99
|
error('No phases directory found in planning directory');
|
|
64
100
|
}
|
|
65
101
|
const results = [];
|
|
102
|
+
let acknowledgedFiles = 0;
|
|
66
103
|
// Active dirs are milestone-filtered; archived dirs deliberately are NOT.
|
|
67
104
|
// listMilestonePhaseDirs derives the CURRENT milestone's phase directories
|
|
68
105
|
// (window + sentinel filtered) from ROADMAP.md, and archived phases belong
|
|
@@ -94,30 +131,89 @@ function cmdAuditUat(cwd, raw) {
|
|
|
94
131
|
// under this phase's audit-uat entry. A phase whose own UAT file is
|
|
95
132
|
// genuinely absent scopes to empty and contributes nothing — correct, and
|
|
96
133
|
// the reason scopeToPhase has no unfiltered fallback.
|
|
97
|
-
for (const file of
|
|
134
|
+
for (const file of selectPhaseUatFiles(files, dir)) {
|
|
98
135
|
const uatFilePath = node_path_1.default.join(phaseDir, file);
|
|
99
|
-
const content =
|
|
100
|
-
const items =
|
|
101
|
-
|
|
102
|
-
|
|
136
|
+
const content = readNormalizedDocument(uatFilePath);
|
|
137
|
+
const { items, headingsSeen } = parseUatItemsWithStats(content);
|
|
138
|
+
const uatFm = extractFrontmatter(content, uatFilePath);
|
|
139
|
+
const status = (uatFm.status || 'unknown').toLowerCase();
|
|
140
|
+
// #3805: honour the audit_acknowledged marker with the SAME snapshot
|
|
141
|
+
// key audit.cts's scanUatGaps uses ('gap_snapshot', derived value
|
|
142
|
+
// composed by the shared derivation) — one acknowledgement means the
|
|
143
|
+
// same thing to both commands.
|
|
144
|
+
if (isAuditItemAcknowledged(uatFm, { snapshotKey: 'gap_snapshot', currentValue: deriveUatGapSnapshotValue(status, content) })) {
|
|
145
|
+
acknowledgedFiles++;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
// `parse_gap` means the file contained `### N.` test blocks that
|
|
149
|
+
// yielded no items — NOT merely "zero items and not complete" (#3707
|
|
150
|
+
// MAJOR: that broader signal false-positived on an all-pass file and on
|
|
151
|
+
// a Gaps-only file with everything resolved). A file whose blocks all
|
|
152
|
+
// passed, or that has no test blocks at all, never sets `headingsSeen`,
|
|
153
|
+
// so it never sets the flag regardless of status.
|
|
154
|
+
//
|
|
155
|
+
// `status` deliberately does NOT gate this (#3078 security review). A
|
|
156
|
+
// terminal `status: complete` is an ASSERTION BY THE AUTHOR that the
|
|
157
|
+
// work is finished — and an assertion is exactly the thing that must
|
|
158
|
+
// not be allowed to switch off the detector that would contradict it.
|
|
159
|
+
// The earlier `status !== 'complete'` guard did precisely that: a file
|
|
160
|
+
// could declare itself complete and thereby suppress the report of the
|
|
161
|
+
// rows this tool could not read, which is a self-declared kill switch
|
|
162
|
+
// over the very detector this issue built. The distinction that
|
|
163
|
+
// actually matters is not "is it complete" but "is there anything the
|
|
164
|
+
// tool failed to parse":
|
|
165
|
+
// - complete + `headingsSeen === 0` — nothing unread, so nothing to
|
|
166
|
+
// contradict the claim. Still omitted entirely, exactly as before;
|
|
167
|
+
// that is the whole point of a terminal status and must not
|
|
168
|
+
// regress. (Same for a file whose blocks all parsed and passed.)
|
|
169
|
+
// - complete + `headingsSeen > 0` — the author's claim of
|
|
170
|
+
// completeness CANNOT BE VERIFIED against rows the parser could not
|
|
171
|
+
// read, so the file is surfaced with `parse_gap` and the
|
|
172
|
+
// `unparsed_blocks` count. The audit reports what it could not see
|
|
173
|
+
// rather than trusting the frontmatter over the file body.
|
|
174
|
+
//
|
|
175
|
+
// This check is deliberately UNCONDITIONAL on `items.length` (#3707
|
|
176
|
+
// follow-up BLOCKER): a MIXED file — some parseable rows plus some
|
|
177
|
+
// unparseable blocks — must report BOTH the real items AND the parse
|
|
178
|
+
// gap, quantified via `unparsed_blocks`. The previous `else if` only
|
|
179
|
+
// ever flagged a file with ZERO items, silently discarding
|
|
180
|
+
// `headingsSeen` (and every unparseable row it counted) the instant any
|
|
181
|
+
// single item existed anywhere in the file, including via the Gaps
|
|
182
|
+
// union.
|
|
183
|
+
if (items.length > 0 || headingsSeen > 0) {
|
|
184
|
+
const entry = {
|
|
103
185
|
phase: phaseNum,
|
|
104
186
|
phase_dir: dir,
|
|
105
187
|
file,
|
|
106
188
|
file_path: toPosixPath(node_path_1.default.relative(cwd, node_path_1.default.join(phaseDir, file))),
|
|
107
189
|
type: 'uat',
|
|
108
|
-
status
|
|
190
|
+
status,
|
|
109
191
|
archived_milestone: milestone,
|
|
110
192
|
items,
|
|
111
|
-
}
|
|
193
|
+
};
|
|
194
|
+
if (headingsSeen > 0) {
|
|
195
|
+
entry.parse_gap = true;
|
|
196
|
+
entry.unparsed_blocks = headingsSeen;
|
|
197
|
+
}
|
|
198
|
+
results.push(entry);
|
|
112
199
|
}
|
|
113
200
|
}
|
|
114
201
|
// Process VERIFICATION files — scoped to THIS phase's own token (#3511)
|
|
115
202
|
// for the same reason as the UAT loop above.
|
|
116
203
|
for (const file of scopeToPhase(files.filter(f => f.includes('-VERIFICATION') && f.endsWith('.md')), dir)) {
|
|
117
204
|
const verificationFilePath = node_path_1.default.join(phaseDir, file);
|
|
118
|
-
const content =
|
|
119
|
-
const
|
|
205
|
+
const content = readNormalizedDocument(verificationFilePath);
|
|
206
|
+
const verFm = extractFrontmatter(content, verificationFilePath);
|
|
207
|
+
const status = (verFm.status || 'unknown').toLowerCase();
|
|
208
|
+
// #3805: same marker, same 'status' snapshot key as scanVerificationGaps,
|
|
209
|
+
// and the same ORDERING — the open-status gate runs FIRST (a marker on
|
|
210
|
+
// a file that would never surface is not a suppressed item), then the
|
|
211
|
+
// acknowledgement suppresses what the gate surfaced.
|
|
120
212
|
if (status === 'human_needed' || status === 'gaps_found') {
|
|
213
|
+
if (isAuditItemAcknowledged(verFm, { snapshotKey: 'status', currentValue: status })) {
|
|
214
|
+
acknowledgedFiles++;
|
|
215
|
+
continue;
|
|
216
|
+
}
|
|
121
217
|
const items = parseVerificationItems(content, status, verificationFilePath);
|
|
122
218
|
if (items.length > 0) {
|
|
123
219
|
results.push({
|
|
@@ -142,7 +238,7 @@ function cmdAuditUat(cwd, raw) {
|
|
|
142
238
|
// required.
|
|
143
239
|
const deferredFile = 'deferred-items.md';
|
|
144
240
|
if (files.includes(deferredFile)) {
|
|
145
|
-
const content =
|
|
241
|
+
const content = readNormalizedDocument(node_path_1.default.join(phaseDir, deferredFile));
|
|
146
242
|
const items = parseDeferredItems(content);
|
|
147
243
|
if (items.length > 0) {
|
|
148
244
|
results.push({
|
|
@@ -162,10 +258,34 @@ function cmdAuditUat(cwd, raw) {
|
|
|
162
258
|
const summary = {
|
|
163
259
|
total_files: results.length,
|
|
164
260
|
total_items: results.reduce((sum, r) => sum + r.items.length, 0),
|
|
261
|
+
// #3707 blocker 2: a distinct counter so a file whose test blocks
|
|
262
|
+
// yielded no items (structurally unparseable, not "all clear") stays
|
|
263
|
+
// visible even though it contributes zero to total_items. Consumers
|
|
264
|
+
// (audit-uat.md, progress.md) must gate their all-clear / debt checks on
|
|
265
|
+
// BOTH total_items === 0 AND parse_gap_files === 0.
|
|
266
|
+
//
|
|
267
|
+
// Counts EVERY entry with `parse_gap: true`, archived or not — same as
|
|
268
|
+
// `total_items`, which has no archived split. An outstanding item does
|
|
269
|
+
// not stop mattering because its phase was archived on milestone close
|
|
270
|
+
// (#2766): a deferred human-UAT scenario or a `skipped` live-stack test
|
|
271
|
+
// is exactly what gets archived still-open, so a parse gap on that same
|
|
272
|
+
// file is still an unread outstanding row, not closed history. Splitting
|
|
273
|
+
// this counter by `archived_milestone` (tried in this branch, reverted)
|
|
274
|
+
// demoted an in-progress phase filed under an archived dir out of the
|
|
275
|
+
// gate, and buried an archived outstanding row's parse failure relative
|
|
276
|
+
// to the identical row when it happened to parse — the exact bug class
|
|
277
|
+
// this issue exists to fix.
|
|
278
|
+
parse_gap_files: results.filter((r) => r.parse_gap).length,
|
|
165
279
|
by_category: {},
|
|
166
280
|
by_phase: {},
|
|
167
281
|
};
|
|
168
282
|
for (const r of results) {
|
|
283
|
+
// Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
|
|
284
|
+
// even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
|
|
285
|
+
// this away as dead code. The 0-valued key is itself the cue that this
|
|
286
|
+
// phase was scanned and produced no COUNTABLE items, distinguishing it
|
|
287
|
+
// from a phase absent from `by_phase` entirely (never scanned / no UAT
|
|
288
|
+
// file at all). A phase with a real outstanding item overwrites it below.
|
|
169
289
|
if (!summary.by_phase[r.phase])
|
|
170
290
|
summary.by_phase[r.phase] = 0;
|
|
171
291
|
for (const item of r.items) {
|
|
@@ -174,7 +294,9 @@ function cmdAuditUat(cwd, raw) {
|
|
|
174
294
|
summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
|
|
175
295
|
}
|
|
176
296
|
}
|
|
177
|
-
|
|
297
|
+
// #3805: acknowledged files surface as a COUNT (audit-open's honesty
|
|
298
|
+
// model: the marker fired, the items are suppressed, both facts visible).
|
|
299
|
+
output({ results, summary, acknowledged_files: acknowledgedFiles }, raw, undefined);
|
|
178
300
|
}
|
|
179
301
|
// ─── cmdRenderCheckpoint ──────────────────────────────────────────────────────
|
|
180
302
|
function cmdRenderCheckpoint(cwd, options = {}, raw) {
|
|
@@ -203,6 +325,12 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
|
|
|
203
325
|
}
|
|
204
326
|
// ─── parseCurrentTest ─────────────────────────────────────────────────────────
|
|
205
327
|
function parseCurrentTest(content) {
|
|
328
|
+
// #3707-CR: this is the render-checkpoint path's own independent ingress
|
|
329
|
+
// into `tokenizeHeadings` (via the `parseFirstPendingTest` fallback below),
|
|
330
|
+
// separate from `parseUatItemsWithStats`'s. Normalize here too, ONCE, so a
|
|
331
|
+
// lone-CR document cannot hide its first pending row from this path either
|
|
332
|
+
// — see `normalizeLineEndings` for why.
|
|
333
|
+
content = normalizeLineEndings(content);
|
|
206
334
|
// Use the seam to locate the ## Current Test section (ADR-1372 T5).
|
|
207
335
|
// HTML-comment stripping within the section body is UAT-specific, so we keep
|
|
208
336
|
// the comment removal caller-side after extracting the body.
|
|
@@ -263,24 +391,51 @@ function parseFirstPendingTest(content) {
|
|
|
263
391
|
// tokenizeHeadings operates on the section body as a standalone document,
|
|
264
392
|
// filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
|
|
265
393
|
// The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
|
|
266
|
-
|
|
394
|
+
//
|
|
395
|
+
// #3078 blocker (same exposure as `parseUatItemsWithStats`): only a COLUMN-0
|
|
396
|
+
// heading is a test row — see `isColumnZeroHeading`. A `### N.` line indented
|
|
397
|
+
// <= 3 spaces INSIDE an `expected: |` value is value text, and must not
|
|
398
|
+
// register as a phantom heading and steal the real row's `result:` token.
|
|
399
|
+
//
|
|
400
|
+
// #3078 follow-up: tokenize a copy with the DELIMITER LINES of every
|
|
401
|
+
// wholly-INDENTED fenced block blanked out first (bodies untouched — column
|
|
402
|
+
// 0 is structure, indentation is content) — see
|
|
403
|
+
// `blankIndentedFenceDelimiters`. Without this, an
|
|
404
|
+
// indented ` ``` ` opener inside an `expected: |` value still reads as a
|
|
405
|
+
// real fence to `tokenizeHeadings` (CommonMark tolerates 1-3 leading
|
|
406
|
+
// spaces), which then hides every heading up to the next matching closer —
|
|
407
|
+
// including a later, genuinely column-0 `### N.` row.
|
|
408
|
+
//
|
|
409
|
+
// #3078 round-5 MAJOR: the row predicate is `isTestRowHeadingText`, the ONE
|
|
410
|
+
// shared helper `parseUatItemsWithStats` uses. It previously read
|
|
411
|
+
// `/^\d+\.\s+/` here while the audit path read `/^\d+\.(?!\d)/`, so
|
|
412
|
+
// `### 3.Foo` WAS a row on one path and was NOT on the other — two parse
|
|
413
|
+
// paths in one module disagreeing about the same grammar.
|
|
414
|
+
const subHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(sectionBody)).filter((h) => h.level === 3 && isTestRowHeadingText(h.text) && isColumnZeroHeading(sectionBody, h));
|
|
267
415
|
for (let i = 0; i < subHeadings.length; i += 1) {
|
|
268
416
|
const current = subHeadings[i];
|
|
269
417
|
const next = subHeadings[i + 1];
|
|
270
|
-
// Slice the block for this sub-test from the section body text
|
|
418
|
+
// Slice the block for this sub-test from the RAW section body text
|
|
271
419
|
const block = next
|
|
272
420
|
? sectionBody.slice(current.offset, next.offset)
|
|
273
421
|
: sectionBody.slice(current.offset);
|
|
274
422
|
if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
|
|
275
423
|
continue;
|
|
276
424
|
}
|
|
277
|
-
// Extract the UAT-specific number and name from the heading text
|
|
278
|
-
|
|
425
|
+
// Extract the UAT-specific number and name from the heading text via the
|
|
426
|
+
// SAME `parseTestRowHeadingText` seam the audit path uses (#3078 round-5
|
|
427
|
+
// MAJOR) — a name-mandatory `/^(\d+)\.\s+(.+)$/` here would have `continue`d
|
|
428
|
+
// past exactly the `### 3.` / `### 3.Foo` shapes the shared predicate just
|
|
429
|
+
// admitted, reintroducing the divergence one line below the fix.
|
|
430
|
+
const headingParts = parseTestRowHeadingText(current.text);
|
|
279
431
|
if (!headingParts)
|
|
280
432
|
continue;
|
|
281
|
-
const testNumber =
|
|
282
|
-
const testName = headingParts
|
|
283
|
-
|
|
433
|
+
const testNumber = headingParts.number;
|
|
434
|
+
const testName = headingParts.name;
|
|
435
|
+
// #3078 blocker: clip the block at its first fence opener before handing
|
|
436
|
+
// it to `parseExpectedFromTestBlock`, so a raw read cannot reach into
|
|
437
|
+
// fence-hidden content — including a LATER row's own `expected:` line.
|
|
438
|
+
const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
|
|
284
439
|
if (!expected) {
|
|
285
440
|
error(`Pending UAT test ${testNumber} is missing an expected field`);
|
|
286
441
|
}
|
|
@@ -293,20 +448,122 @@ function parseFirstPendingTest(content) {
|
|
|
293
448
|
}
|
|
294
449
|
return null;
|
|
295
450
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
451
|
+
/**
|
|
452
|
+
* CRLF (#3078, found while hardening the scalar reader): the opener pattern
|
|
453
|
+
* demanded a BARE `\n` immediately after the `|`, so on a CRLF document
|
|
454
|
+
* `expected: |\r\n` never matched the block-scalar arm at all — control fell
|
|
455
|
+
* through to the INLINE arm, which happily captured the pipe character itself
|
|
456
|
+
* and published `expected: "|"`, discarding the entire multi-line value with no
|
|
457
|
+
* trace. `\r?` on the opener plus a per-line `\r` strip on the body fixes it.
|
|
458
|
+
* `(?:[1-9][+-]?|[+-][1-9]?)?` additionally admits the `|-` / `|+` chomping
|
|
459
|
+
* indicators AND the explicit indentation indicator (`|2`, `|2-`, `|-2`, ...,
|
|
460
|
+
* in either order per the YAML header grammar), keeping this reader in step
|
|
461
|
+
* with the column-0 heading rule (an indented heading inside a scalar body is
|
|
462
|
+
* otherwise a `expected: |-` or `expected: |2` value would be structurally
|
|
463
|
+
* masked but then read as the literal string `"|-"` / `"|2"` by the same
|
|
464
|
+
* fall-through.
|
|
465
|
+
*
|
|
466
|
+
* `[|>]` (#3078 follow-up): the `>` FOLDED-scalar family (`>`, `>-`, `>+`,
|
|
467
|
+
* `>2`, `>2+`, ...) hit the exact same fall-through as the CRLF/`|-`/`|+`
|
|
468
|
+
* bugs above — the opener only ever matched `|`, so `expected: >` fell to the
|
|
469
|
+
* inline arm and published the literal `">"` as the value, discarding the
|
|
470
|
+
* whole scalar. The opener character is now captured (group 1) so the caller
|
|
471
|
+
* can apply YAML's fold semantics for `>` while leaving `|` untouched.
|
|
472
|
+
*
|
|
473
|
+
* TRAILING COMMENT (#3078 round-6 MINOR 1): YAML permits a comment after a
|
|
474
|
+
* block-scalar header — `expected: | # sample`, `reason: >- # note` are both
|
|
475
|
+
* legal and open a scalar exactly as the bare forms do. The grammar was
|
|
476
|
+
* `$`-anchored immediately after the indicator, so those headers matched
|
|
477
|
+
* NEITHER `extractScalarField`'s opener (the value silently fell through to
|
|
478
|
+
* the inline arm and published the literal `"|"`) NOR
|
|
479
|
+
* `ANY_KEY_SCALAR_HEADER_LINE_RE` (so `countUnattributedIndentedRows` treated
|
|
480
|
+
* the scalar's own indented body heading as an unattributed lost row — a FALSE
|
|
481
|
+
* parse gap). `(?:#[^\r\n]*)?` closes both at the single shared source.
|
|
482
|
+
*/
|
|
483
|
+
const SCALAR_HEADER_BODY = String.raw `[ \t]*([|>])(?:[1-9][+-]?|[+-][1-9]?)?[ \t]*(?:#[^\r\n]*)?`;
|
|
484
|
+
/**
|
|
485
|
+
* Build the block-scalar HEADER grammar for an arbitrary `key:` — the ONE
|
|
486
|
+
* source shared by `expected:`, `reason:` and `blocked_by:` (#3078 MINOR 2:
|
|
487
|
+
* `reason:`/`blocked_by:` previously had no block-scalar grammar of their own
|
|
488
|
+
* at all, and silently published the literal `"|"` / `">"` for a `|`/`>`
|
|
489
|
+
* value, discarding it). A key is always a hardcoded literal at each call
|
|
490
|
+
* site in this module (never untrusted input), so no escaping is needed.
|
|
491
|
+
*/
|
|
492
|
+
function scalarHeaderFor(key) {
|
|
493
|
+
return String.raw `${key}:${SCALAR_HEADER_BODY}`;
|
|
494
|
+
}
|
|
495
|
+
/**
|
|
496
|
+
* ANY key's block-scalar HEADER line (#3078 MINOR 1), matched against ONE
|
|
497
|
+
* already-CR-stripped source line instead of against a multi-line block.
|
|
498
|
+
* Derived from the SAME `SCALAR_HEADER_BODY` source `scalarHeaderFor` uses
|
|
499
|
+
* so the opener grammars (`|`, `|-`, `|+`, `|2`, `|-2`, `>`, `>-`, `>+`, `>2+`,
|
|
500
|
+
* ...) cannot drift between them — the generative-divergence class this repo
|
|
501
|
+
* pins elsewhere.
|
|
502
|
+
*
|
|
503
|
+
* `countUnattributedIndentedRows` walks back from an indented `### N.`-shaped
|
|
504
|
+
* line to the nearest preceding column-0 line and asks whether THAT line
|
|
505
|
+
* opened a block scalar that still owns the indented line as its body.
|
|
506
|
+
* Testing only an `expected:`-ONLY grammar there meant an indented
|
|
507
|
+
* heading-shaped line inside ANY OTHER block scalar — `reported: |`
|
|
508
|
+
* (templates/UAT.md), `reason: |`, a verbatim user response containing
|
|
509
|
+
* ` ### 9. Section Nine` — was miscounted as a lost row even though nothing
|
|
510
|
+
* is missing. YAML's indentation rule (any column-0 line terminates a scalar)
|
|
511
|
+
* does not care WHICH key opened the scalar, only that a `[|>]`-family opener
|
|
512
|
+
* did, so the walk-back only needs to recognise the opener grammar, not the
|
|
513
|
+
* specific key.
|
|
514
|
+
*/
|
|
515
|
+
const ANY_KEY_SCALAR_HEADER_LINE_RE = new RegExp(String.raw `^[A-Za-z_][\w-]*:${SCALAR_HEADER_BODY}$`);
|
|
516
|
+
/**
|
|
517
|
+
* Apply YAML FOLDED-scalar (`>`) line-joining to an already-dedented,
|
|
518
|
+
* CRLF-stripped block-scalar body: lines within a paragraph (no blank line
|
|
519
|
+
* between them) join with a single space; a blank line between paragraphs
|
|
520
|
+
* becomes a literal `\n` in the result. `|` (LITERAL) bodies are returned
|
|
521
|
+
* unchanged — folding is `>`-only.
|
|
522
|
+
*/
|
|
523
|
+
function foldScalarBody(body) {
|
|
524
|
+
const lines = body.split('\n');
|
|
525
|
+
const paragraphs = [];
|
|
526
|
+
let current = [];
|
|
527
|
+
for (const line of lines) {
|
|
528
|
+
if (line === '') {
|
|
529
|
+
paragraphs.push(current.join(' '));
|
|
530
|
+
current = [];
|
|
531
|
+
}
|
|
532
|
+
else {
|
|
533
|
+
current.push(line);
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
paragraphs.push(current.join(' '));
|
|
537
|
+
return paragraphs.join('\n');
|
|
538
|
+
}
|
|
539
|
+
/**
|
|
540
|
+
* Extract a YAML-lite `key:` field's value from `block` — block-scalar
|
|
541
|
+
* (`|`/`>` family, dedented and, for `>`, YAML-folded) OR plain inline.
|
|
542
|
+
* Generalized from the `expected:`-only reader (#3078 MINOR 2) so `reason:`
|
|
543
|
+
* and `blocked_by:` — which previously had NO block-scalar grammar at all and
|
|
544
|
+
* silently published the literal `"|"` / `">"` for a multi-line value,
|
|
545
|
+
* discarding it — go through the exact same opener grammar and fold
|
|
546
|
+
* semantics instead of a third, hand-rolled dialect.
|
|
547
|
+
*/
|
|
548
|
+
function extractScalarField(block, key) {
|
|
549
|
+
const opener = String.raw `^${scalarHeaderFor(key)}\r?\n`;
|
|
550
|
+
const blockMatch = block.match(new RegExp(`${opener}([\\s\\S]*?)(?=^\\w[\\w-]*:\\s)`, 'm'))
|
|
551
|
+
|| block.match(new RegExp(`${opener}([\\s\\S]+)`, 'm'));
|
|
552
|
+
if (blockMatch) {
|
|
553
|
+
const openerChar = blockMatch[1];
|
|
554
|
+
const dedented = blockMatch[2]
|
|
301
555
|
.split('\n')
|
|
302
|
-
.map((line) => line.replace(/^ {2}/, ''))
|
|
556
|
+
.map((line) => line.replace(/\r$/, '').replace(/^ {2}/, ''))
|
|
303
557
|
.join('\n')
|
|
304
558
|
.trim();
|
|
559
|
+
return openerChar === '>' ? foldScalarBody(dedented) : dedented;
|
|
305
560
|
}
|
|
306
|
-
const
|
|
307
|
-
return
|
|
561
|
+
const inlineMatch = block.match(new RegExp(String.raw `^${key}:\s*(.+)\s*$`, 'm'));
|
|
562
|
+
return inlineMatch ? inlineMatch[1].trim() : null;
|
|
563
|
+
}
|
|
564
|
+
function parseExpectedFromTestBlock(block) {
|
|
565
|
+
return extractScalarField(block, 'expected');
|
|
308
566
|
}
|
|
309
|
-
const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
|
|
310
567
|
const CHECKPOINT_FRAMES = {
|
|
311
568
|
english: {
|
|
312
569
|
banner: 'CHECKPOINT: Verification Required',
|
|
@@ -409,52 +666,6 @@ function resolveCheckpointFrame(responseLanguage) {
|
|
|
409
666
|
const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().normalize('NFC').toLowerCase()];
|
|
410
667
|
return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
|
|
411
668
|
}
|
|
412
|
-
// Approximate terminal-cell width. East Asian Width W/F code points occupy two
|
|
413
|
-
// cells, while Unicode combining marks occupy no additional cell beyond their
|
|
414
|
-
// base character. Counting only W/F ranges is insufficient for scripts such as
|
|
415
|
-
// Devanagari: Hindi vowel signs and viramas are combining marks, and treating
|
|
416
|
-
// each as a full cell visibly shifts the checkpoint box's right border.
|
|
417
|
-
function isWideCodePoint(codePoint) {
|
|
418
|
-
return ((codePoint >= 0x1100 && codePoint <= 0x115f) || // Hangul Jamo
|
|
419
|
-
codePoint === 0x2329 || codePoint === 0x232a ||
|
|
420
|
-
(codePoint >= 0x2e80 && codePoint <= 0x303e) || // CJK Radicals .. CJK Symbols and Punctuation
|
|
421
|
-
(codePoint >= 0x3041 && codePoint <= 0x33ff) || // Hiragana .. CJK Compatibility
|
|
422
|
-
(codePoint >= 0x3400 && codePoint <= 0x4dbf) || // CJK Unified Ideographs Extension A
|
|
423
|
-
(codePoint >= 0x4e00 && codePoint <= 0x9fff) || // CJK Unified Ideographs
|
|
424
|
-
(codePoint >= 0xa000 && codePoint <= 0xa4cf) || // Yi Syllables
|
|
425
|
-
(codePoint >= 0xac00 && codePoint <= 0xd7a3) || // Hangul Syllables
|
|
426
|
-
(codePoint >= 0xf900 && codePoint <= 0xfaff) || // CJK Compatibility Ideographs
|
|
427
|
-
(codePoint >= 0xfe30 && codePoint <= 0xfe4f) || // CJK Compatibility Forms
|
|
428
|
-
(codePoint >= 0xff00 && codePoint <= 0xff60) || // Fullwidth Forms
|
|
429
|
-
(codePoint >= 0xffe0 && codePoint <= 0xffe6) ||
|
|
430
|
-
(codePoint >= 0x20000 && codePoint <= 0x3fffd) // CJK Unified Ideographs Extension B+ / supplementary
|
|
431
|
-
);
|
|
432
|
-
}
|
|
433
|
-
// Non-spacing/enclosing marks and format controls occupy zero terminal cells.
|
|
434
|
-
// Spacing combining marks (General_Category=Mc), such as Devanagari vowel
|
|
435
|
-
// signs, still advance the cursor and must contribute one column.
|
|
436
|
-
const ZERO_WIDTH_MARK_RE = /\p{gc=Mn}|\p{gc=Me}|\p{gc=Cf}/u;
|
|
437
|
-
// Iterates by Unicode code point (not UTF-16 code unit) so astral characters
|
|
438
|
-
// are measured once, not as two surrogate units.
|
|
439
|
-
function displayWidth(text) {
|
|
440
|
-
let width = 0;
|
|
441
|
-
for (const ch of text) {
|
|
442
|
-
if (ZERO_WIDTH_MARK_RE.test(ch))
|
|
443
|
-
continue;
|
|
444
|
-
width += isWideCodePoint(ch.codePointAt(0)) ? 2 : 1;
|
|
445
|
-
}
|
|
446
|
-
return width;
|
|
447
|
-
}
|
|
448
|
-
// Pads `text` into a `║ text… ║` line matching CHECKPOINT_BOX_WIDTH. Content
|
|
449
|
-
// that overflows the box (a longer translated string) is left unpadded rather
|
|
450
|
-
// than truncated — a slightly ragged border beats losing text.
|
|
451
|
-
function checkpointBoxLine(text) {
|
|
452
|
-
const innerWidth = CHECKPOINT_BOX_WIDTH - 2;
|
|
453
|
-
const content = ` ${text}`;
|
|
454
|
-
const padLength = innerWidth - displayWidth(content);
|
|
455
|
-
const padded = padLength > 0 ? content + ' '.repeat(padLength) : content;
|
|
456
|
-
return `║${padded}║`;
|
|
457
|
-
}
|
|
458
669
|
const RTL_ISOLATE = '\u2067';
|
|
459
670
|
const POP_DIRECTIONAL_ISOLATE = '\u2069';
|
|
460
671
|
function isolateCheckpointFrameText(text, frame) {
|
|
@@ -467,51 +678,777 @@ function buildCheckpoint(currentTest, responseLanguage) {
|
|
|
467
678
|
const banner = isolateCheckpointFrameText(frame.banner, frame);
|
|
468
679
|
const instruction = isolateCheckpointFrameText(frame.instruction, frame);
|
|
469
680
|
return [
|
|
470
|
-
|
|
471
|
-
checkpointBoxLine(banner),
|
|
472
|
-
'╚══════════════════════════════════════════════════════════════╝',
|
|
681
|
+
`### ${banner}`,
|
|
473
682
|
'',
|
|
474
683
|
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
|
475
684
|
'',
|
|
476
685
|
currentTest.expected,
|
|
477
686
|
'',
|
|
478
|
-
'
|
|
479
|
-
|
|
480
|
-
|
|
687
|
+
'---',
|
|
688
|
+
'',
|
|
689
|
+
`**${instruction}**`,
|
|
481
690
|
].join('\n');
|
|
482
691
|
}
|
|
483
692
|
// ─── parseUatItems ────────────────────────────────────────────────────────────
|
|
484
|
-
|
|
693
|
+
/**
|
|
694
|
+
* Result tokens treated as PASSING (#3707 defect 1). Deliberately MINIMAL —
|
|
695
|
+
* that minimality is the point. Every token NOT in this set surfaces as an
|
|
696
|
+
* outstanding item, mirroring the fail-safe direction `parseGapsItems`
|
|
697
|
+
* already documents for this exact false-negative class (#2286): a project
|
|
698
|
+
* that invents a novel pass-word gets a visible, correctable false positive
|
|
699
|
+
* (an extra row an agent can dismiss) rather than today's invisible drop (a
|
|
700
|
+
* genuinely outstanding row silently vanishing with no trace). This was the
|
|
701
|
+
* issue's one open design question and was decided deliberately, here, in
|
|
702
|
+
* favor of the fail-safe direction over a larger "known synonyms" allowlist.
|
|
703
|
+
*/
|
|
704
|
+
const UAT_PASS_RESULTS = new Set(['pass', 'passed']);
|
|
705
|
+
/**
|
|
706
|
+
* A fenced-code OPENER line at COLUMN 0 (``` or ~~~).
|
|
707
|
+
*
|
|
708
|
+
* Deliberately NOT the CommonMark `{0,3}`-space form (#3078 simplification):
|
|
709
|
+
* inside this module a fence only ever means "document structure the tokenizer
|
|
710
|
+
* hid from us", and every structural fence in a UAT file starts at column 0. An
|
|
711
|
+
* INDENTED fence run is, by construction, part of an `expected: |` block-scalar
|
|
712
|
+
* value — the ordinary way a UAT row reproduces a code sample verbatim — and
|
|
713
|
+
* must stay invisible to the clipper, or the very field it exists to protect
|
|
714
|
+
* gets truncated at its own sample. Column 0 is the whole rule for every
|
|
715
|
+
* fence-aware scan THIS MODULE writes directly against raw block text (this
|
|
716
|
+
* one, `dropTopLevelFencedRegions`'s `delimRe`). It does NOT extend to
|
|
717
|
+
* `tokenizeHeadings`, which is a third-party CommonMark scanner with its own
|
|
718
|
+
* {0,3}-space fence tolerance baked in — see `blankIndentedFenceDelimiters`
|
|
719
|
+
* for how an indented delimiter is kept from reaching that scanner at all.
|
|
720
|
+
*/
|
|
721
|
+
const FENCE_OPENER_RE = /^(?:`{3,}|~{3,})/;
|
|
722
|
+
/**
|
|
723
|
+
* The CommonMark-tolerant (0-3 leading spaces) twin of `FENCE_OPENER_RE`,
|
|
724
|
+
* used ONLY by the inner delimiter-shape sweep in
|
|
725
|
+
* `blankIndentedFenceDelimiters` (#3078 round-7 MAJOR). That sweep runs
|
|
726
|
+
* strictly BETWEEN a neutralised block's own (already-blanked) delimiters,
|
|
727
|
+
* looking for a line `tokenizeHeadings` would itself read as a fence opener
|
|
728
|
+
* once those delimiters are gone — and `tokenizeHeadings` tolerates up to
|
|
729
|
+
* three leading spaces on an opener, so a column-0-anchored test here misses
|
|
730
|
+
* an INDENTED delimiter-shaped line and lets the mutation manufacture exactly
|
|
731
|
+
* the structure `scanFencedBlocks` never saw. `FENCE_OPENER_RE` itself stays
|
|
732
|
+
* column-0-anchored: every OTHER call site depends on that anchoring.
|
|
733
|
+
*/
|
|
734
|
+
const INDENT_TOLERANT_DELIM_RE = /^ {0,3}(?:`{3,}|~{3,})/;
|
|
735
|
+
/**
|
|
736
|
+
* A raw source line whose shape is a UAT `### N.` test heading — the line-level
|
|
737
|
+
* twin of the `h.level === 3 && /^\d+\.(?!\d)/` token filter in
|
|
738
|
+
* `parseUatItemsWithStats`, and anchored at COLUMN 0 to match that filter's
|
|
739
|
+
* `isColumnZeroHeading` guard exactly. Used ONLY to count headings that
|
|
740
|
+
* `tokenizeHeadings` suppressed (a fence-straddled row), never to parse one:
|
|
741
|
+
* the two counts must be derived by the SAME rule or the shortfall they
|
|
742
|
+
* bracket over- or under-reports.
|
|
743
|
+
*/
|
|
744
|
+
const TEST_HEADING_LINE_RE = /^#{3}(?!#)[ \t]+\d+\.(?!\d)/;
|
|
745
|
+
/**
|
|
746
|
+
* THE test-row grammar, in ONE place (#3078 round-5 MAJOR).
|
|
747
|
+
*
|
|
748
|
+
* `parseFirstPendingTest` (the render-checkpoint path) and
|
|
749
|
+
* `parseUatItemsWithStats` (the audit path) each filtered level-3 headings with
|
|
750
|
+
* their own literal — `/^\d+\.\s+/` vs `/^\d+\.(?!\d)/` — so the two paths in
|
|
751
|
+
* this one module DISAGREED about what a test row is: `### 3.Foo` (name squished
|
|
752
|
+
* against the dot) and `### 3.` (no name at all) were rows to the audit and were
|
|
753
|
+
* silently NOT rows to the checkpoint. That is the generative-divergence class
|
|
754
|
+
* this repo requires closed with a shared definition rather than two literals
|
|
755
|
+
* kept in sync by hand.
|
|
756
|
+
*
|
|
757
|
+
* The AUDIT rule wins, deliberately: `^\d+\.(?!\d)` admits `### 3.` and
|
|
758
|
+
* `### 3.Foo` (a heading missing or squishing its name still contributes to
|
|
759
|
+
* `headingsSeen`/items instead of vanishing from BOTH — the same silent-drop
|
|
760
|
+
* symptom the parse-gap flag exists to catch) while the `(?!\d)` lookahead keeps
|
|
761
|
+
* a DOTTED-SECTION heading like `### 1.2.3 Overview` out, since that is a
|
|
762
|
+
* document outline number, not test row 1. `TEST_HEADING_LINE_RE` /
|
|
763
|
+
* `INDENTED_TEST_HEADING_LINE_RE` are the raw-source-line twins of this same
|
|
764
|
+
* rule and carry the identical `\d+\.(?!\d)` core.
|
|
765
|
+
*/
|
|
766
|
+
const TEST_ROW_HEADING_TEXT_RE = /^\d+\.(?!\d)/;
|
|
767
|
+
/** True when a level-3 heading's TEXT is a UAT test row. See `TEST_ROW_HEADING_TEXT_RE`. */
|
|
768
|
+
function isTestRowHeadingText(text) {
|
|
769
|
+
return TEST_ROW_HEADING_TEXT_RE.test(text);
|
|
770
|
+
}
|
|
771
|
+
/**
|
|
772
|
+
* Split a test-row heading's text into its number and display name — the
|
|
773
|
+
* extraction twin of `isTestRowHeadingText`, shared by both parse paths for the
|
|
774
|
+
* same anti-divergence reason. Returns `null` for text the predicate rejects.
|
|
775
|
+
*
|
|
776
|
+
* A bare `### 3.` (no trailing name) falls back to the heading's own trimmed
|
|
777
|
+
* text (`3.`) rather than yielding an empty name.
|
|
778
|
+
*/
|
|
779
|
+
function parseTestRowHeadingText(text) {
|
|
780
|
+
if (!isTestRowHeadingText(text))
|
|
781
|
+
return null;
|
|
782
|
+
const parts = text.match(/^(\d+)\.\s*(.*)$/);
|
|
783
|
+
if (!parts)
|
|
784
|
+
return null;
|
|
785
|
+
return { number: parseInt(parts[1], 10), name: parts[2].trim() || text.trim() };
|
|
786
|
+
}
|
|
787
|
+
/**
|
|
788
|
+
* The INDENTED (1-3 leading spaces, CommonMark-legal) twin of
|
|
789
|
+
* `TEST_HEADING_LINE_RE` — used by the SHORTFALL SCAN ONLY, never by the parse
|
|
790
|
+
* gate.
|
|
791
|
+
*
|
|
792
|
+
* #3078 round-4 MAJOR 2: `isColumnZeroHeading` refusing to PARSE an indented
|
|
793
|
+
* `### N.` row is deliberate and stays (no `*UAT*.md` in the tree indents one).
|
|
794
|
+
* But the COUNTING side inherited that anchor through
|
|
795
|
+
* `TEST_HEADING_LINE_RE`, so a heading the parse gate rejected could never
|
|
796
|
+
* reach `headingsSeen` either: ` ### 1. Indented Row` with `result: pending`
|
|
797
|
+
* — which origin/next's unanchored `###\s*(\d+)\.` did surface — yielded no
|
|
798
|
+
* item, no gap, no count and no trace at all. Refusing to parse is defensible;
|
|
799
|
+
* vanishing silently is the exact defect class this issue exists to close, so
|
|
800
|
+
* the row now surfaces as a PARSE GAP instead.
|
|
801
|
+
*/
|
|
802
|
+
const INDENTED_TEST_HEADING_LINE_RE = /^[ \t]+#{3}(?!#)[ \t]+\d+\.(?!\d)/;
|
|
803
|
+
/**
|
|
804
|
+
* True when `heading` starts at COLUMN 0 of its source line in `content`.
|
|
805
|
+
*
|
|
806
|
+
* The UAT test-row contract (#3078): a `### N.` row heading is structure ONLY
|
|
807
|
+
* at column 0. `tokenizeHeadings` implements CommonMark, which tolerates up to
|
|
808
|
+
* 3 leading spaces on an ATX heading — and that single over-permissive rule is
|
|
809
|
+
* what let a `### 3. Fake Row` line sitting INSIDE an `expected: |` value
|
|
810
|
+
* register as a phantom heading, open a block, and STEAL the real row's
|
|
811
|
+
* `result:` line, dropping a genuinely outstanding row from `items`. A scalar
|
|
812
|
+
* body is indented BY CONSTRUCTION (that is what makes it a body), so requiring
|
|
813
|
+
* column 0 makes every such line inert without the parser needing any notion of
|
|
814
|
+
* YAML block scalars at all. The shipped `templates/UAT.md` writes every `### N.`
|
|
815
|
+
* heading at column 0, and no UAT document in the tree indents one.
|
|
816
|
+
*
|
|
817
|
+
* `HeadingToken.offset` is the offset of the heading LINE's first character, so
|
|
818
|
+
* a column-0 heading is exactly one whose first character is the `#` itself.
|
|
819
|
+
*/
|
|
820
|
+
function isColumnZeroHeading(content, heading) {
|
|
821
|
+
return content.charCodeAt(heading.offset) === 0x23 /* '#' */;
|
|
822
|
+
}
|
|
823
|
+
/**
|
|
824
|
+
* An INDENTED (1-3 leading spaces, never 0) fenced-code delimiter line.
|
|
825
|
+
* Column 0 is intentionally EXCLUDED — a column-0 fence is real document
|
|
826
|
+
* structure and `tokenizeHeadings` handling it is correct; only the
|
|
827
|
+
* CommonMark-legal 1-3-space tolerance is the problem this targets.
|
|
828
|
+
*/
|
|
829
|
+
const INDENTED_FENCE_DELIM_RE = /^ {1,3}(?:`{3,}|~{3,})/;
|
|
830
|
+
/**
|
|
831
|
+
* Return `content` with the two DELIMITER LINES of every wholly-INDENTED
|
|
832
|
+
* fenced block overwritten by spaces, byte-length- and line-count-preserving,
|
|
833
|
+
* so every downstream offset and line index still lines up against the
|
|
834
|
+
* original document. The block's BODY is left verbatim — see "COLUMN 0 IS
|
|
835
|
+
* STRUCTURE, INDENTATION IS CONTENT" below for why that is the point, not an
|
|
836
|
+
* oversight.
|
|
837
|
+
*
|
|
838
|
+
* Why (#3078 follow-up, escalated design call, answered as option (b)):
|
|
839
|
+
* dropping `maskBlockScalarBodies` in favor of the column-0 heading filter
|
|
840
|
+
* (`isColumnZeroHeading`) fixed the phantom-heading theft, but it silently
|
|
841
|
+
* dropped a SECOND thing masking used to do — hide an INDENTED fence
|
|
842
|
+
* delimiter from `tokenizeHeadings` itself. `tokenizeHeadings` is a
|
|
843
|
+
* CommonMark scanner with its own {0,3}-space fence tolerance; a 1-3-space
|
|
844
|
+
* ` ``` ` inside an `expected: |` scalar body still opens a fence AS FAR AS
|
|
845
|
+
* THAT SCANNER IS CONCERNED, and every heading between it and its matching
|
|
846
|
+
* (or absent) closer — including a LATER, genuinely column-0 `### N.` row —
|
|
847
|
+
* is hidden from the token stream entirely, not merely mis-filtered. The
|
|
848
|
+
* column-0 heading filter cannot recover a heading the tokenizer never
|
|
849
|
+
* returned in the first place.
|
|
850
|
+
*
|
|
851
|
+
* This is deliberately the SAME "column 0 is structure, anything else is
|
|
852
|
+
* value text" rule already applied to headings (`isColumnZeroHeading`) and to
|
|
853
|
+
* this module's own raw-text fence scans (`FENCE_OPENER_RE`,
|
|
854
|
+
* `dropTopLevelFencedRegions`'s `delimRe`) — extended to the one place that
|
|
855
|
+
* rule cannot be expressed as a post-hoc filter, because the tokenizer
|
|
856
|
+
* consumes the fence delimiter before this module ever sees a token for it.
|
|
857
|
+
* It carries no YAML knowledge whatsoever (no notion of `expected:`, `|`,
|
|
858
|
+
* indentation width, or scalar bodies) — it blanks an indented delimiter LINE
|
|
859
|
+
* unconditionally, wherever it appears, the same context-free way the other
|
|
860
|
+
* column-0 rules do.
|
|
861
|
+
*
|
|
862
|
+
* PAIRED, NOT UNCONDITIONAL (#3078 round-4 MAJOR 1). Blanking every indented
|
|
863
|
+
* delimiter LINE on sight perturbs fence PAIRING in BOTH directions, because
|
|
864
|
+
* CommonMark lets a COLUMN-0 fence be closed by a delimiter indented up to
|
|
865
|
+
* three spaces:
|
|
866
|
+
* - a column-0 opener closed by an INDENTED closer had its closer blanked,
|
|
867
|
+
* so the fence never closed for `tokenizeHeadings` and every later row —
|
|
868
|
+
* including a genuinely column-0 `### N.` with an outstanding `result:` —
|
|
869
|
+
* was swallowed;
|
|
870
|
+
* - the mirror, an INDENTED opener closed by a COLUMN-0 closer, had its
|
|
871
|
+
* opener blanked, PROMOTING that closer into an opener and swallowing
|
|
872
|
+
* everything after it instead.
|
|
873
|
+
* Both documents are legal CommonMark that renders correctly, so neither may
|
|
874
|
+
* lose content. The decision is therefore made per FENCED BLOCK, not per line:
|
|
875
|
+
* a block is neutralised only when it is indented at BOTH ends (or is an
|
|
876
|
+
* indented opener that never closes at all) — i.e. when nothing about it is
|
|
877
|
+
* column-0 document structure. That is exactly the intended case, an indented
|
|
878
|
+
* fence pair living wholly inside an `expected: |` block-scalar value, which
|
|
879
|
+
* is why the helper exists; any block with a column-0 delimiter at either end
|
|
880
|
+
* is left completely alone so its pairing reaches the tokenizer unchanged.
|
|
881
|
+
*
|
|
882
|
+
* COLUMN 0 IS STRUCTURE, INDENTATION IS CONTENT — and that rule is applied in
|
|
883
|
+
* ONE direction only, to the DELIMITERS. Only the two delimiter lines of a
|
|
884
|
+
* neutralised block are blanked; its body is left exactly as written. A
|
|
885
|
+
* column-0 `### N.` sitting between two indented delimiters therefore becomes
|
|
886
|
+
* a real heading, and a `result:` line after it belongs to that heading. That
|
|
887
|
+
* is CORRECT under this rule, not theft: by the very rule that selected the
|
|
888
|
+
* block for neutralisation, an indented delimiter is not a fence at all, so
|
|
889
|
+
* there is no fence for the column-0 line to be "inside" of. The document is
|
|
890
|
+
* malformed; reading it this way is the consistent reading, and it is PINNED
|
|
891
|
+
* by test (see "#3078 round 5: column 0 is structure" in tests/uat.test.cjs).
|
|
892
|
+
* Blanking the whole block open-to-close was tried and REVERTED: it destroys
|
|
893
|
+
* content legitimately living between the delimiters, and — for the
|
|
894
|
+
* unterminated-opener case, where the "body" runs to EOF — silently deletes
|
|
895
|
+
* the entire remainder of the document, dropping every later row.
|
|
896
|
+
*
|
|
897
|
+
* NO SECOND FENCE DIALECT: the blocks come from `scanFencedBlocks`
|
|
898
|
+
* (markdown-sectionizer.cts), the SAME exported CommonMark state machine
|
|
899
|
+
* `stripFencedCode` — and therefore `tokenizeHeadings` — runs. Backtick AND
|
|
900
|
+
* tilde runs, run length >= 3, the <= 3-space indent tolerance, a closer of
|
|
901
|
+
* the same char with run length >= the opener and no trailing text, info
|
|
902
|
+
* strings (including the "a backtick fence's info string may not contain a
|
|
903
|
+
* backtick" rule), and the unterminated-at-EOF case are all classified by that
|
|
904
|
+
* engine, not re-derived here. This module contributes only the column-0
|
|
905
|
+
* question — which delimiter lines are structure — via
|
|
906
|
+
* `INDENTED_FENCE_DELIM_RE`.
|
|
907
|
+
*
|
|
908
|
+
* LINE-BASED by construction (`content.split('\n')` / `.join('\n')`), never
|
|
909
|
+
* character-array splicing — the exact bug class (`Array.from(content)`
|
|
910
|
+
* code-point indexing against UTF-16 offsets) that made the original
|
|
911
|
+
* `maskBlockScalarBodies` corrupt astral-character documents. A line's own
|
|
912
|
+
* `.length` and `' '.repeat(line.length)` are measured in the same (UTF-16)
|
|
913
|
+
* units as the string itself, so this cannot misalign regardless of
|
|
914
|
+
* code-point framing, and CRLF survives untouched: `split('\n')` leaves any
|
|
915
|
+
* `\r` attached to the end of its line, and blanking that line replaces the
|
|
916
|
+
* `\r` with a space exactly like every other character on it — `join('\n')`
|
|
917
|
+
* then reproduces the original line count and total length exactly.
|
|
918
|
+
*/
|
|
919
|
+
function blankIndentedFenceDelimiters(content) {
|
|
920
|
+
const lines = content.split('\n');
|
|
921
|
+
const isIndentedDelimiter = (idx) => idx >= 0 && idx < lines.length && INDENTED_FENCE_DELIM_RE.test(lines[idx].replace(/\r$/, ''));
|
|
922
|
+
const blank = new Set();
|
|
923
|
+
for (const block of scanFencedBlocks(lines)) {
|
|
924
|
+
// A column-0 OPENER is real document structure: leave the whole block
|
|
925
|
+
// alone, closer included, so an indented closer still closes it.
|
|
926
|
+
if (!isIndentedDelimiter(block.openLineIdx))
|
|
927
|
+
continue;
|
|
928
|
+
// An indented opener paired with a COLUMN-0 closer is likewise real
|
|
929
|
+
// structure at its far end — blanking the opener would promote that closer
|
|
930
|
+
// into an opener and hide everything after it.
|
|
931
|
+
if (block.closeLineIdx !== -1 && !isIndentedDelimiter(block.closeLineIdx))
|
|
932
|
+
continue;
|
|
933
|
+
// DELIMITERS ONLY — never the body. THE RULE: column 0 is structure,
|
|
934
|
+
// indentation is content. An indented delimiter therefore neutralises
|
|
935
|
+
// ITSELF, but it never hides column-0 structure sitting between
|
|
936
|
+
// delimiters: a column-0 `### N.` there IS a heading, and a `result:`
|
|
937
|
+
// after it IS that heading's. Widening this to the whole block was tried
|
|
938
|
+
// (#3078 round 5) and reverted — it deletes content that legitimately
|
|
939
|
+
// lives between the delimiters, and on an UNTERMINATED indented opener it
|
|
940
|
+
// blanks to EOF, taking every later row with it. Pinned by test; do not
|
|
941
|
+
// "fix" it back.
|
|
942
|
+
blank.add(block.openLineIdx);
|
|
943
|
+
if (block.closeLineIdx !== -1)
|
|
944
|
+
blank.add(block.closeLineIdx);
|
|
945
|
+
// #3078 round-6 MAJOR: the two fence engines must not disagree about the
|
|
946
|
+
// text handed downstream. `scanFencedBlocks` classified the ORIGINAL
|
|
947
|
+
// lines, but `tokenizeHeadings` re-runs its own CommonMark state machine
|
|
948
|
+
// over this MUTATED copy. A COLUMN-0 delimiter-shaped line that was mere
|
|
949
|
+
// fence CONTENT in the original — e.g. a ```-run inside an indented
|
|
950
|
+
// ````-pair — is PROMOTED to a real opener the instant its enclosing
|
|
951
|
+
// delimiters are blanked, hiding every later heading to EOF. Blank those
|
|
952
|
+
// too, so the mutation cannot manufacture structure that the classifying
|
|
953
|
+
// engine never saw.
|
|
954
|
+
//
|
|
955
|
+
// DELIMITER-SHAPED LINES ONLY. A column-0 `### N.` heading between
|
|
956
|
+
// neutralised delimiters stays a heading (the pinned "column 0 is
|
|
957
|
+
// structure" behaviour), and the field lines of a row living between two
|
|
958
|
+
// rows' scalars survive untouched — both are pinned by test. This adds
|
|
959
|
+
// exactly one shape to the blank set: a line that would itself be read as
|
|
960
|
+
// a fence delimiter.
|
|
961
|
+
const inner = block.closeLineIdx === -1 ? lines.length : block.closeLineIdx;
|
|
962
|
+
for (let i = block.openLineIdx + 1; i < inner; i += 1) {
|
|
963
|
+
if (INDENT_TOLERANT_DELIM_RE.test(lines[i].replace(/\r$/, '')))
|
|
964
|
+
blank.add(i);
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
if (blank.size === 0)
|
|
968
|
+
return content;
|
|
969
|
+
return lines.map((line, i) => (blank.has(i) ? ' '.repeat(line.length) : line)).join('\n');
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Truncate `block` at its first TOP-LEVEL fenced-code opener (#3078 blocker).
|
|
973
|
+
*
|
|
974
|
+
* `parseExpectedFromTestBlock` must read the RAW block (an `expected: |` scalar
|
|
975
|
+
* may legitimately reproduce fenced-looking text verbatim, so a fence-STRIPPED
|
|
976
|
+
* copy would corrupt the field). But a raw block slice can run straight into
|
|
977
|
+
* content that `tokenizeHeadings` correctly hid inside a fence — including a
|
|
978
|
+
* LATER test row's own `expected:` line, which the earlier row then published
|
|
979
|
+
* as its own. Clipping at the fence opener bounds the raw read to the part of
|
|
980
|
+
* the block the tokenizer also considered visible.
|
|
981
|
+
*
|
|
982
|
+
* Column-0 fences only (`FENCE_OPENER_RE`): a fenced sample nested inside a
|
|
983
|
+
* legitimate `expected: |` value is indented by construction, so it is invisible
|
|
984
|
+
* here and cannot clip the very field this exists to preserve.
|
|
985
|
+
*/
|
|
986
|
+
function clipBlockAtFirstFence(block) {
|
|
987
|
+
const rawLines = block.split('\n');
|
|
988
|
+
let firstFenceLine = -1;
|
|
989
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
990
|
+
if (FENCE_OPENER_RE.test(rawLines[i])) {
|
|
991
|
+
firstFenceLine = i;
|
|
992
|
+
break;
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
if (firstFenceLine === -1)
|
|
996
|
+
return block;
|
|
997
|
+
const beforeFence = rawLines.slice(0, firstFenceLine).join('\n');
|
|
998
|
+
if (parseExpectedFromTestBlock(beforeFence))
|
|
999
|
+
return beforeFence;
|
|
1000
|
+
// #3078 follow-up MINOR 2: an `expected:` field appearing AFTER a fence has
|
|
1001
|
+
// CLOSED is not a theft risk — only content strictly INSIDE the fence must
|
|
1002
|
+
// stay hidden. The plain "clip at first opener" result above silently
|
|
1003
|
+
// discards a late `expected:` even when it sits outside every fence.
|
|
1004
|
+
// Reconstruct the block with every top-level FENCED REGION dropped, keeping
|
|
1005
|
+
// RAW text everywhere else. This exposes a late `expected:` living after a
|
|
1006
|
+
// fence closes, while an `expected:` living strictly inside the fence is
|
|
1007
|
+
// dropped along with it and stays unreachable — the "inside a fence" vs.
|
|
1008
|
+
// "after a closed fence" split falls straight out of whether the
|
|
1009
|
+
// fence-tracking state machine below is OPEN or CLOSED at that line, not out
|
|
1010
|
+
// of position relative to the FIRST fence opener alone.
|
|
1011
|
+
const visible = dropTopLevelFencedRegions(rawLines);
|
|
1012
|
+
if (parseExpectedFromTestBlock(visible))
|
|
1013
|
+
return visible;
|
|
1014
|
+
return beforeFence;
|
|
1015
|
+
}
|
|
1016
|
+
/**
|
|
1017
|
+
* Reconstruct `rawLines` with every TOP-LEVEL fenced region removed. Mirrors
|
|
1018
|
+
* `stripFencedCode`'s own delimiter algorithm — a fence run of the SAME
|
|
1019
|
+
* character and at least the SAME length, with no trailing content, is what
|
|
1020
|
+
* closes an open fence — so "inside a fence" here means the same thing it means
|
|
1021
|
+
* to the rest of this module's fence handling. An UNTERMINATED fence (open at
|
|
1022
|
+
* EOF) drops everything from its opener to the end, same as `stripFencedCode`.
|
|
1023
|
+
*
|
|
1024
|
+
* Delimiters are recognised at COLUMN 0 only, for the reason given on
|
|
1025
|
+
* `FENCE_OPENER_RE`: an INDENTED fence run belongs to an `expected: |` value,
|
|
1026
|
+
* not to document structure, and must not open a region here.
|
|
1027
|
+
*/
|
|
1028
|
+
function dropTopLevelFencedRegions(rawLines) {
|
|
1029
|
+
const kept = [];
|
|
1030
|
+
let openFence = null;
|
|
1031
|
+
const delimRe = /^(`{3,}|~{3,})(.*)$/;
|
|
1032
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
1033
|
+
const line = rawLines[i].replace(/\r$/, '');
|
|
1034
|
+
const m = delimRe.exec(line);
|
|
1035
|
+
if (m) {
|
|
1036
|
+
const char = m[1][0];
|
|
1037
|
+
const len = m[1].length;
|
|
1038
|
+
const trailing = m[2];
|
|
1039
|
+
if (openFence === null) {
|
|
1040
|
+
if (char === '`' && trailing.includes('`')) {
|
|
1041
|
+
// Not a valid fence opener (CommonMark: backtick info string must
|
|
1042
|
+
// not contain a backtick) — ordinary content.
|
|
1043
|
+
kept.push(rawLines[i]);
|
|
1044
|
+
continue;
|
|
1045
|
+
}
|
|
1046
|
+
openFence = { char, len };
|
|
1047
|
+
}
|
|
1048
|
+
else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
|
|
1049
|
+
openFence = null;
|
|
1050
|
+
}
|
|
1051
|
+
continue; // all delimiter lines are dropped, opener or closer
|
|
1052
|
+
}
|
|
1053
|
+
if (openFence === null)
|
|
1054
|
+
kept.push(rawLines[i]);
|
|
1055
|
+
// Lines inside an open fence are silently dropped.
|
|
1056
|
+
}
|
|
1057
|
+
return kept.join('\n');
|
|
1058
|
+
}
|
|
1059
|
+
/**
|
|
1060
|
+
* Count the INDENTED (1-3 space) `### N.` heading-shaped lines in `surface`
|
|
1061
|
+
* that are NOT the value text of a preceding `expected:` block scalar.
|
|
1062
|
+
*
|
|
1063
|
+
* Why the exclusion (#3078 round-4 MAJOR 2): the parse gate refuses BOTH
|
|
1064
|
+
* shapes for the same reason (column 0 is structure), but only one of them is
|
|
1065
|
+
* a lost ROW. A `### 3. Fake Row` line sitting inside an `expected: |` value is
|
|
1066
|
+
* the row's own published `expected:` string — already surfaced, verbatim, on
|
|
1067
|
+
* the item — so counting it would flag a parse gap against a document with
|
|
1068
|
+
* nothing missing (the pinned scalar-body behaviour). A ` ### 1. Indented
|
|
1069
|
+
* Row` that no scalar owns is a row the parser declined to read, and must be
|
|
1070
|
+
* visible as an unparsed block instead of silently clean.
|
|
1071
|
+
*
|
|
1072
|
+
* Attribution is structural and cheap: walk BACK from the indented heading to
|
|
1073
|
+
* the first non-blank line at column 0 (a block-scalar body is indented by
|
|
1074
|
+
* construction, and blank lines are legal inside one). The heading is scalar
|
|
1075
|
+
* VALUE exactly when that line is ANY `key:` scalar header — not `expected:`
|
|
1076
|
+
* only (#3078 MINOR 1: testing the `expected:`-only grammar false-positived
|
|
1077
|
+
* on an indented heading-shaped line inside a DIFFERENT block scalar, e.g. a
|
|
1078
|
+
* template-sanctioned `reported: |` holding verbatim user prose, or a
|
|
1079
|
+
* `reason: |` body) — per `ANY_KEY_SCALAR_HEADER_LINE_RE`, derived from the
|
|
1080
|
+
* SAME `[|>]`-family opener grammar the reader itself uses. No second opener
|
|
1081
|
+
* dialect, and no attempt to model YAML indentation levels.
|
|
1082
|
+
*/
|
|
1083
|
+
function countUnattributedIndentedRows(surface) {
|
|
1084
|
+
const lines = surface.split('\n');
|
|
1085
|
+
// LINEAR, not quadratic (#3078 round-6 MINOR 2). The walk-back above was
|
|
1086
|
+
// re-scanned per indented row, so a document of N rows and N lines cost
|
|
1087
|
+
// O(N^2) — measured 4x per 2x on real input (1000 rows 20ms → 16000 rows
|
|
1088
|
+
// 3.6s). The walk only ever asks ONE question of the prefix — "which is the
|
|
1089
|
+
// nearest preceding non-blank COLUMN-0 line?" — and that is a running value,
|
|
1090
|
+
// so a single forward pass computes it for every line at once. The
|
|
1091
|
+
// ATTRIBUTION RULE IS UNCHANGED: a blank line and an indented line are both
|
|
1092
|
+
// transparent (a block-scalar body is indented by construction and may
|
|
1093
|
+
// contain blank lines), and the first line that is neither terminates the
|
|
1094
|
+
// scalar; the heading is value text exactly when THAT line is any key's
|
|
1095
|
+
// block-scalar header.
|
|
1096
|
+
const stripped = lines.map((line) => line.replace(/\r$/, ''));
|
|
1097
|
+
const nearestColumnZero = new Array(lines.length);
|
|
1098
|
+
let last = -1;
|
|
1099
|
+
for (let i = 0; i < stripped.length; i += 1) {
|
|
1100
|
+
nearestColumnZero[i] = last;
|
|
1101
|
+
const line = stripped[i];
|
|
1102
|
+
if (line.trim() !== '' && !/^[ \t]/.test(line))
|
|
1103
|
+
last = i;
|
|
1104
|
+
}
|
|
1105
|
+
let count = 0;
|
|
1106
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1107
|
+
if (!INDENTED_TEST_HEADING_LINE_RE.test(lines[i]))
|
|
1108
|
+
continue;
|
|
1109
|
+
const owner = nearestColumnZero[i];
|
|
1110
|
+
const ownedByScalar = owner !== -1 && ANY_KEY_SCALAR_HEADER_LINE_RE.test(stripped[owner]);
|
|
1111
|
+
if (!ownedByScalar)
|
|
1112
|
+
count += 1;
|
|
1113
|
+
}
|
|
1114
|
+
return count;
|
|
1115
|
+
}
|
|
1116
|
+
/**
|
|
1117
|
+
* `headingsSeen` is the TOTAL parse-gap tally (every heading-shaped thing this
|
|
1118
|
+
* parser could not turn into an item). `shortfallBlocks` is the SUBSET of it
|
|
1119
|
+
* contributed by the fence-suppression shortfall scan below — the one gap class
|
|
1120
|
+
* this module documents as carrying an ACCEPTED OVER-REPORT (a closed-fence
|
|
1121
|
+
* documentation sample written with literal digits is indistinguishable from a
|
|
1122
|
+
* genuinely fence-straddled row; see the long comment at the scan itself).
|
|
1123
|
+
* Reported separately so a consumer that must decide whether to WITHHOLD a
|
|
1124
|
+
* derived number — as opposed to merely REPORT the gap — can tell "a row I
|
|
1125
|
+
* definitely could not read" from "a row I possibly mis-counted".
|
|
1126
|
+
*
|
|
1127
|
+
* #3707-CR: `src/planning-inspect.cts`'s `buildUatRows` does NOT destructure
|
|
1128
|
+
* this field (verified — it and `cmdAuditUat` both consume only `items` and
|
|
1129
|
+
* `headingsSeen`), correcting an earlier stated instruction that it did.
|
|
1130
|
+
* `shortfallBlocks` currently has NO production consumer outside this
|
|
1131
|
+
* function's own computation. It is retained on the return value anyway,
|
|
1132
|
+
* deliberately, as part of this function's published stats contract — tests
|
|
1133
|
+
* assert on the full `{ items, headingsSeen, shortfallBlocks }` shape, and
|
|
1134
|
+
* dropping a returned field is a wider, unrelated change than a line-ending
|
|
1135
|
+
* fix warrants. A future consumer that needs to distinguish an
|
|
1136
|
+
* accepted-over-report shortfall from the rest of `headingsSeen` (the
|
|
1137
|
+
* original design intent above) can still do so.
|
|
1138
|
+
*/
|
|
1139
|
+
function parseUatItemsWithStats(content) {
|
|
1140
|
+
content = normalizeLineEndings(content);
|
|
485
1141
|
const items = [];
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
1142
|
+
let headingsSeen = 0;
|
|
1143
|
+
let shortfallBlocks = 0;
|
|
1144
|
+
// Locate every `### N. Name` test heading across the WHOLE document (not
|
|
1145
|
+
// adjacency-matched against `result:`, #3707 defect 2) and slice each one's
|
|
1146
|
+
// own block from its heading to the next heading OF ANY LEVEL (or EOF) —
|
|
1147
|
+
// a trailing `## Gaps` section or an interleaved `### Notes` heading must
|
|
1148
|
+
// not be absorbed into the preceding test's block, else its unanchored
|
|
1149
|
+
// `reason:`/`blocked_by:` scans below bleed a Gaps entry's fields onto the
|
|
1150
|
+
// last test row.
|
|
1151
|
+
// #3078 blocker: only a COLUMN-0 heading is document structure here (see
|
|
1152
|
+
// `isColumnZeroHeading`). The filter is applied to the WHOLE token stream,
|
|
1153
|
+
// not just to the `### N.` rows, because an indented heading must not act as
|
|
1154
|
+
// a block BOUNDARY either — a `### 3. Fake Row` line inside an `expected: |`
|
|
1155
|
+
// value would otherwise truncate its own row's block just before the real
|
|
1156
|
+
// `result:` line and drop a genuinely outstanding row from `items`.
|
|
1157
|
+
//
|
|
1158
|
+
// #3078 follow-up: tokenize a copy with every indented fence delimiter
|
|
1159
|
+
// blanked out (`blankIndentedFenceDelimiters`) BEFORE the column-0 filter
|
|
1160
|
+
// ever runs. Otherwise an indented ` ``` ` opener inside an `expected: |`
|
|
1161
|
+
// value still opens a real fence as far as `tokenizeHeadings` (a
|
|
1162
|
+
// CommonMark scanner, {0,3}-space fence tolerance) is concerned, hiding
|
|
1163
|
+
// every heading up to its closer from the token stream entirely — a LATER,
|
|
1164
|
+
// genuinely column-0 `### N.` row is never returned as a token at all, so
|
|
1165
|
+
// no post-hoc filter over the token stream could recover it.
|
|
1166
|
+
const allHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(content)).filter((h) => isColumnZeroHeading(content, h));
|
|
1167
|
+
// #3707 follow-up MINOR: `^\d+\.` alone — a trailing name is OPTIONAL
|
|
1168
|
+
// (`### 3.` and `### 3.Foo`, without the space the old `\s+`-anchored
|
|
1169
|
+
// pattern required, both count) so a heading missing or squishing its name
|
|
1170
|
+
// still contributes to `headingsSeen`/items rather than being silently
|
|
1171
|
+
// excluded from BOTH — the same vanishing-row symptom the parse-gap flag
|
|
1172
|
+
// exists to catch, reachable here at the heading-filter layer instead.
|
|
1173
|
+
// #3078 round-5 MAJOR: that rule now lives in `isTestRowHeadingText` and is
|
|
1174
|
+
// shared verbatim with `parseFirstPendingTest`, which used to disagree.
|
|
1175
|
+
// Carry each match's own index into `allHeadings` from the filter pass
|
|
1176
|
+
// itself (security review finding 3) rather than re-deriving it via
|
|
1177
|
+
// `allHeadings.indexOf(current)` inside the loop below — the latter is an
|
|
1178
|
+
// O(n) scan per heading, making the whole loop O(n^2) in document size.
|
|
1179
|
+
const subHeadings = [];
|
|
1180
|
+
allHeadings.forEach((h, index) => {
|
|
1181
|
+
if (h.level === 3 && isTestRowHeadingText(h.text))
|
|
1182
|
+
subHeadings.push({ heading: h, index });
|
|
1183
|
+
});
|
|
1184
|
+
// #3078 blocker: `tokenizeHeadings` is fence-aware, so a BALANCED fence pair
|
|
1185
|
+
// that opens after one test row and closes after a later one makes every
|
|
1186
|
+
// `### N.` heading between them invisible — the rows are not merely
|
|
1187
|
+
// unparseable, they are absent from the token stream, so the loop below can
|
|
1188
|
+
// never count them and the file reports as CLEAN with an outstanding
|
|
1189
|
+
// `result: blocked` inside it. (origin/next's old whole-file regex did
|
|
1190
|
+
// surface those rows, making the silent drop a regression.) Comparing the
|
|
1191
|
+
// count of heading-SHAPED source lines against the headings the tokenizer
|
|
1192
|
+
// actually returned recovers the shortfall; each suppressed row counts
|
|
1193
|
+
// toward `headingsSeen`, so the file is flagged as a parse gap rather than
|
|
1194
|
+
// silently clean. The line scan is anchored at COLUMN 0 (`TEST_HEADING_LINE_RE`)
|
|
1195
|
+
// by the same rule the token filter uses, so a `### N.`-shaped line living
|
|
1196
|
+
// inside an `expected: |` value — which is value text, not a suppressed row —
|
|
1197
|
+
// cannot inflate the tally.
|
|
1198
|
+
//
|
|
1199
|
+
// #3078 round-7 HIGH — SYMMETRY IS THE INVARIANT. BOTH SIDES OF THIS
|
|
1200
|
+
// COMPARISON ARE WHOLE-DOCUMENT. DO NOT SCOPE EITHER ONE. Read this whole
|
|
1201
|
+
// comment before "optimising" the `## Notes` noise back out; three separate
|
|
1202
|
+
// HIGH-severity silent false-cleans have been produced by three separate
|
|
1203
|
+
// attempts to be clever about scope here, and every one of them was a
|
|
1204
|
+
// regression against origin/next's plain whole-file regex.
|
|
1205
|
+
//
|
|
1206
|
+
// History of the failures, so they are not re-derived:
|
|
1207
|
+
// - round-6 HIGH: the raw line scan was SECTION-SCOPED to the `## Tests`
|
|
1208
|
+
// body while `subHeadings` stayed whole-document, so a legal
|
|
1209
|
+
// `### 9. Old / result: pass` row in a preceding `## Prior` section
|
|
1210
|
+
// decremented the shortfall by one and SILENTLY DISABLED the
|
|
1211
|
+
// fence-straddle detector.
|
|
1212
|
+
// - round-7 HIGH: "equalising" that by ALSO scoping the token side to the
|
|
1213
|
+
// section's offset span made the two counters agree with each other but
|
|
1214
|
+
// left the PARSE side whole-document — so a `### N.` row living OUTSIDE
|
|
1215
|
+
// the first `## Tests` section was parsed and surfaced normally when
|
|
1216
|
+
// visible, yet vanished with NO item AND NO parse_gap the moment a fence
|
|
1217
|
+
// straddled it: neither side of the comparison covered it. Reproduced
|
|
1218
|
+
// three ways — a straddle inside a `## Regression Tests` section, a
|
|
1219
|
+
// straddle inside a SECOND `## Tests` section (`collectSection` takes the
|
|
1220
|
+
// FIRST match only), and, as control, the identical straddle in a file
|
|
1221
|
+
// with no `## Tests` heading at all, which alone reported correctly.
|
|
1222
|
+
//
|
|
1223
|
+
// THE RULE: the parse side reads rows wherever they live in the document, so
|
|
1224
|
+
// the counting side must too. Scan shaped `### N.` lines over the ENTIRE
|
|
1225
|
+
// document and compare against ALL tokenized row headings. Any narrowing of
|
|
1226
|
+
// one side that is not matched by the other manufactures a blind spot, and a
|
|
1227
|
+
// blind spot here is a SILENT FALSE CLEAN — a file with an outstanding
|
|
1228
|
+
// `result: blocked` in it that never even enters `results`.
|
|
1229
|
+
//
|
|
1230
|
+
// ACCEPTED CONSEQUENCE, DELIBERATELY TRADED (this replaces the #3078
|
|
1231
|
+
// follow-up MINOR 1 scoping): a `### N.`-shaped line inside a properly
|
|
1232
|
+
// CLOSED fence in a `## Notes` section — a documentation sample of the row
|
|
1233
|
+
// format. NOTE the shape needs LITERAL DIGITS — the scan requires `\d+`, so the
|
|
1234
|
+
// conventional placeholder `### N. Name` does NOT trigger it; only a sample written
|
|
1235
|
+
// with real numbers (`### 1. Example Row`) does. On FREQUENCY, claim only what is
|
|
1236
|
+
// measurable here: the SHAPE is uncommon (it takes a literal-digit row inside a
|
|
1237
|
+
// CLOSED fence), and that is a claim about the shape, NOT a measurement across real
|
|
1238
|
+
// projects. The in-tree sample size for it is ZERO PHASE FILES — the only `*UAT*.md`
|
|
1239
|
+
// anywhere in this repo is the shipped template (which `selectPhaseUatFiles` never
|
|
1240
|
+
// scans, and which itself scores headingsSeen=11, six of them literal-digit example
|
|
1241
|
+
// rows), so "no phase UAT file in-tree triggers it" is vacuously true and proves
|
|
1242
|
+
// nothing about rarity in the field. Do not restate it as evidence. If you test the
|
|
1243
|
+
// placeholder form, see no over-report, and conclude this pin is stale: it is not.
|
|
1244
|
+
// The ordinary way to explain the syntax inside a UAT file — is
|
|
1245
|
+
// counted as a suppressed row and raises a parse gap on a file with nothing
|
|
1246
|
+
// actually missing. That is an OVER-report: noisy, but VISIBLE and FAIL-SAFE
|
|
1247
|
+
// (an agent reads the file and dismisses it). Fence-closedness cannot
|
|
1248
|
+
// distinguish it from a genuinely hidden row, because the fence-straddle
|
|
1249
|
+
// case this scan exists to catch is ALSO a properly closed fence — so the
|
|
1250
|
+
// only lever left is scope, and scope is exactly what produced the two
|
|
1251
|
+
// silent false-cleans above. This entire issue exists to eliminate false
|
|
1252
|
+
// cleans, so the trade goes this way ON PURPOSE: an extra noisy row beats an
|
|
1253
|
+
// invisible missing one. The behaviour is pinned by test; do not "fix" it.
|
|
1254
|
+
let shapedHeadingLines = 0;
|
|
1255
|
+
for (const line of content.split('\n')) {
|
|
1256
|
+
if (TEST_HEADING_LINE_RE.test(line))
|
|
1257
|
+
shapedHeadingLines += 1;
|
|
1258
|
+
}
|
|
1259
|
+
if (shapedHeadingLines > subHeadings.length) {
|
|
1260
|
+
shortfallBlocks = shapedHeadingLines - subHeadings.length;
|
|
1261
|
+
headingsSeen += shortfallBlocks;
|
|
1262
|
+
}
|
|
1263
|
+
// #3078 round-4 MAJOR 2: an INDENTED `### N.` row is refused by the parse
|
|
1264
|
+
// gate (`isColumnZeroHeading`) — correct — but must not therefore vanish
|
|
1265
|
+
// without a trace. See `countUnattributedIndentedRows` for why an indented
|
|
1266
|
+
// heading that is the VALUE of a preceding `expected:` block scalar is
|
|
1267
|
+
// excluded from this tally (it is value text, not a row), keeping the
|
|
1268
|
+
// scalar-body pins intact while a genuinely indented ROW surfaces as a gap.
|
|
1269
|
+
//
|
|
1270
|
+
// WHOLE-DOCUMENT, for the same reason as the shortfall scan above: this
|
|
1271
|
+
// counter has no token-side twin to disagree with, but scoping it to a
|
|
1272
|
+
// `## Tests` body would silently drop an indented row living anywhere else
|
|
1273
|
+
// in the file — the identical vanishing-row class. Its own false-positive
|
|
1274
|
+
// guard is STRUCTURAL (scalar attribution via
|
|
1275
|
+
// `ANY_KEY_SCALAR_HEADER_LINE_RE`) — with ONE positional caveat: the walk stops at the
|
|
1276
|
+
// nearest COLUMN-0 line, so a block scalar nested inside a `## Gaps` bullet (a
|
|
1277
|
+
// `- truth:` entry carrying an indented `note: |`) is transparent to it and a
|
|
1278
|
+
// heading-shaped line inside that value is counted. That is another instance of the
|
|
1279
|
+
// accepted over-report above, not a separate defect, not positional, so it needs no scope.
|
|
1280
|
+
headingsSeen += countUnattributedIndentedRows(content);
|
|
1281
|
+
// #3078: an UNTERMINATED fence swallows the entire remainder of the
|
|
1282
|
+
// document — every later test row AND a trailing `## Gaps` section — so the
|
|
1283
|
+
// file yields nothing at all and never even enters `results`: a whole-file
|
|
1284
|
+
// false clean. Mirrors the per-file malformed-markdown guard
|
|
1285
|
+
// `evaluateUatPassed` already applies via `analyzeMarkdown`
|
|
1286
|
+
// (src/uat-predicate.cts:278), which likewise gates on
|
|
1287
|
+
// `stripFencedCode(raw).unterminatedFence`. Deliberately measured on the RAW
|
|
1288
|
+
// document: a fence opened inside an `expected:` scalar is still an
|
|
1289
|
+
// unterminated fence for every downstream markdown consumer, and the masked
|
|
1290
|
+
// copy would hide it.
|
|
1291
|
+
if (stripFencedCode(content).unterminatedFence) {
|
|
1292
|
+
headingsSeen += 1;
|
|
1293
|
+
}
|
|
1294
|
+
for (let i = 0; i < subHeadings.length; i += 1) {
|
|
1295
|
+
const { heading: current, index: currentIdx } = subHeadings[i];
|
|
1296
|
+
const next = allHeadings[currentIdx + 1];
|
|
1297
|
+
const block = next ? content.slice(current.offset, next.offset) : content.slice(current.offset);
|
|
1298
|
+
// Fence-stripped copy for the `result:`/`reason:`/`blocked_by:` field
|
|
1299
|
+
// scans below (#3707 follow-up MAJOR/regression): `block` is raw slice
|
|
1300
|
+
// text, and a fenced code sample inside a test block (a legitimate way to
|
|
1301
|
+
// document expected output) can contain a line that LOOKS like a field
|
|
1302
|
+
// declaration (e.g. an example ` ```\nresult: pending\n``` `). Scanning
|
|
1303
|
+
// raw text reads that sample's `result:` as the test's real outcome —
|
|
1304
|
+
// origin/next returned null here, so an unstripped scan is a regression,
|
|
1305
|
+
// not a pre-existing behavior to preserve. `parseExpectedFromTestBlock`
|
|
1306
|
+
// below still receives the RAW `block`, not this stripped copy: an
|
|
1307
|
+
// `expected: |` block-scalar value may legitimately reproduce
|
|
1308
|
+
// fenced-looking text verbatim, and stripping it would corrupt that field.
|
|
1309
|
+
// #3707 round-3 MINOR: an UNTERMINATED fence (EOF inside a fence, or —
|
|
1310
|
+
// here, scoped per test block — the closing delimiter living in a LATER
|
|
1311
|
+
// block, so from this block's own slice the fence never closes) makes
|
|
1312
|
+
// `stripFencedCode` drop everything from the opener to the end of the
|
|
1313
|
+
// block, including a real `result:`/`reason:`/`blocked_by:` line that
|
|
1314
|
+
// follows it. Falling back to the RAW (unstripped) block in that case
|
|
1315
|
+
// means a legitimate fenced-code false-positive (a `result:`-shaped line
|
|
1316
|
+
// INSIDE a properly-closed sample) is still guarded against in the common
|
|
1317
|
+
// case, while a malformed/unterminated fence no longer silently swallows
|
|
1318
|
+
// a real field line into a false parse_gap.
|
|
1319
|
+
const stripResult = stripFencedCode(block);
|
|
1320
|
+
const fenceStrippedBlock = stripResult.unterminatedFence ? block : stripResult.text;
|
|
1321
|
+
// A block with no `result:` line at all is not a test row (e.g. still
|
|
1322
|
+
// being drafted) — no item, no false positive. It IS, however, a heading
|
|
1323
|
+
// that failed to yield an item for a reason other than a PASS token, so
|
|
1324
|
+
// it counts toward `headingsSeen` (used to detect a genuine parse gap).
|
|
1325
|
+
// Deliberately NOT end-anchored (regression fix, #3707 blocker 1): a
|
|
1326
|
+
// trailing comment/clause after the token (`result: pending (blocked on
|
|
1327
|
+
// staging)`, `result: [skipped] # no device`, `result: blocked -
|
|
1328
|
+
// waiting`) must still match and surface the row instead of being
|
|
1329
|
+
// silently dropped. The trailing text itself is matched-and-ignored
|
|
1330
|
+
// (#3707 follow-up MINOR): it is NOT synthesized into `reason` — a real
|
|
1331
|
+
// `reason:` line is the only source for that field (see below) — because
|
|
1332
|
+
// doing so previously changed `categorizeItem`'s classification for
|
|
1333
|
+
// shapes origin/next categorized differently (an unpinned behavior
|
|
1334
|
+
// change, not something the blocker required).
|
|
1335
|
+
// #3078-CR defect A fix, split-then-match scan: the previous `.match()`
|
|
1336
|
+
// against `/^result:.../im` ran a MULTILINE regex anchor directly over
|
|
1337
|
+
// unsplit block text. ECMA-262's LineTerminator set for `^`/`$` under
|
|
1338
|
+
// `/m` includes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, but
|
|
1339
|
+
// `content.split('\n')` and this module's own heading tokenizer do NOT
|
|
1340
|
+
// treat either as a boundary. A `result:`-shaped line inside an
|
|
1341
|
+
// `expected: |` scalar body, sitting immediately after one of these
|
|
1342
|
+
// separators instead of an ordinary character, was therefore read as a
|
|
1343
|
+
// genuine line start by the regex engine even though it is not
|
|
1344
|
+
// `\n`-delimited from anything — it is exactly as much "one line" to
|
|
1345
|
+
// every other consumer as the ordinary-character control case.
|
|
1346
|
+
// Splitting on `\n` FIRST and testing each already-split line against a
|
|
1347
|
+
// single-line (`/im`-anchor-free) pattern fixes this: a line is never
|
|
1348
|
+
// split by U+2028/U+2029 (`String.prototype.split` matches only its
|
|
1349
|
+
// literal separator argument, never the wider ECMA-262 LineTerminator
|
|
1350
|
+
// set), so a `result:`-shaped line reachable only via one of those
|
|
1351
|
+
// separators can never register as its own split line — the split view
|
|
1352
|
+
// and the regex view are back in agreement, by construction, exactly the
|
|
1353
|
+
// way `splitLines` module is documented to be immune to the sibling `\r`
|
|
1354
|
+
// bug.
|
|
1355
|
+
//
|
|
1356
|
+
// FIRST MATCH WINS (byte-identical to origin/next otherwise): a block
|
|
1357
|
+
// with more than one column-0 `result:` line resolves to the FIRST one
|
|
1358
|
+
// encountered, same as the pre-existing `.match()` behaviour without
|
|
1359
|
+
// `/g` — this is deliberately NOT an ambiguity/parse-gap case (that
|
|
1360
|
+
// variant was tried and reverted: its boundary-truncation heuristic
|
|
1361
|
+
// mistook an indented `### N.` living inside a legitimate block scalar
|
|
1362
|
+
// for a heading boundary, corrupting every scalar/indent guard in this
|
|
1363
|
+
// module — see tests/uat.test.cjs's #3078 scalar guard family).
|
|
1364
|
+
// Trailing text is matched with `[^]*` rather than `.*` (final review
|
|
1365
|
+
// MINOR 1): `.` never matches U+2028/U+2029, so a column-0 `result:`
|
|
1366
|
+
// line whose trailing text contains one of those separators would
|
|
1367
|
+
// otherwise never reach `$`, and the whole line would fail to match —
|
|
1368
|
+
// an unpinned regression against origin/next, which parses it.
|
|
1369
|
+
const RESULT_LINE_RE = /^result:\s*\[?(\w+)\]?[^]*$/i;
|
|
1370
|
+
const resultLineMatch = fenceStrippedBlock
|
|
1371
|
+
.split('\n')
|
|
1372
|
+
.map((line) => line.match(RESULT_LINE_RE))
|
|
1373
|
+
.find((m) => m !== null);
|
|
1374
|
+
if (!resultLineMatch) {
|
|
1375
|
+
headingsSeen += 1;
|
|
1376
|
+
continue;
|
|
511
1377
|
}
|
|
1378
|
+
// Security review finding 2: store the token lower-cased so the published
|
|
1379
|
+
// `result` field agrees with `category` (which categorizeItem already
|
|
1380
|
+
// lower-cases internally, below). No consumer needs the original casing —
|
|
1381
|
+
// `uat-predicate.cts` runs its own independent parser and already
|
|
1382
|
+
// lower-cases too — so the raw-cased form is kept nowhere.
|
|
1383
|
+
const result = resultLineMatch[1].toLowerCase();
|
|
1384
|
+
// #3707 defect 1: invert the old DROP-list filter to a PASS set — see
|
|
1385
|
+
// UAT_PASS_RESULTS's doc comment for why this direction was chosen.
|
|
1386
|
+
// A recognised PASS token is the ONLY reason a heading is excluded from
|
|
1387
|
+
// `headingsSeen` without producing an item — every other non-yielding
|
|
1388
|
+
// case (missing `result:` line, above) is a genuine parse gap.
|
|
1389
|
+
// `result` is already lower-cased at its extraction above, which is the
|
|
1390
|
+
// single point of normalization for this value — re-lowercasing here was
|
|
1391
|
+
// dead work and implied a second, independent normalization that does not
|
|
1392
|
+
// exist (#3078 round-5 MINOR).
|
|
1393
|
+
if (UAT_PASS_RESULTS.has(result))
|
|
1394
|
+
continue;
|
|
1395
|
+
// #3707 follow-up MINOR: the heading filter above now admits `### 3.`
|
|
1396
|
+
// (no name at all) and `### 3.Foo` (no space before the name), so this
|
|
1397
|
+
// extraction is loosened in lockstep — a bare number with no trailing
|
|
1398
|
+
// name falls back to the heading's own trimmed text (`3.`). #3078 round-5
|
|
1399
|
+
// MAJOR: shared with `parseFirstPendingTest` via `parseTestRowHeadingText`.
|
|
1400
|
+
const headingParts = parseTestRowHeadingText(current.text);
|
|
1401
|
+
const testNumber = headingParts.number;
|
|
1402
|
+
const testName = headingParts.name;
|
|
1403
|
+
// Reuse the existing block-scalar/inline `expected:` grammar rather than
|
|
1404
|
+
// re-deriving a second one (#3707 defect 2). #3078 blocker: the block is
|
|
1405
|
+
// CLIPPED at its first top-level fence opener first — still raw text (a
|
|
1406
|
+
// legitimate `expected: |` scalar must be read verbatim, fences and all),
|
|
1407
|
+
// but bounded to what the tokenizer also treated as visible, so this row
|
|
1408
|
+
// cannot reach past a fence into a LATER row's `expected:` line and
|
|
1409
|
+
// publish it as its own. See `clipBlockAtFirstFence`.
|
|
1410
|
+
const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
|
|
1411
|
+
// #3078 MINOR 2: `reason:`/`blocked_by:` previously had no block-scalar
|
|
1412
|
+
// grammar at all (only a plain `/key:\s*(.+)/` single-line match), so a
|
|
1413
|
+
// `reason: |`/`reason: >`/`blocked_by: |` value silently published as the
|
|
1414
|
+
// literal string `"|"` / `">"`, discarding the real multi-line value the
|
|
1415
|
+
// author wrote — and `categorizeItem` below reads exactly this field, so a
|
|
1416
|
+
// discarded `reason` could silently change an item's category. Routed
|
|
1417
|
+
// through the SAME `extractScalarField` machinery `expected:` already
|
|
1418
|
+
// uses rather than adding a third hand-rolled opener dialect.
|
|
1419
|
+
const reason = extractScalarField(fenceStrippedBlock, 'reason') ?? undefined;
|
|
1420
|
+
const blockedBy = extractScalarField(fenceStrippedBlock, 'blocked_by') ?? undefined;
|
|
1421
|
+
const item = {
|
|
1422
|
+
test: testNumber,
|
|
1423
|
+
name: testName,
|
|
1424
|
+
result,
|
|
1425
|
+
category: categorizeItem(result, reason, blockedBy),
|
|
1426
|
+
};
|
|
1427
|
+
if (expected)
|
|
1428
|
+
item.expected = expected;
|
|
1429
|
+
if (reason)
|
|
1430
|
+
item.reason = reason;
|
|
1431
|
+
if (blockedBy)
|
|
1432
|
+
item.blocked_by = blockedBy;
|
|
1433
|
+
items.push(item);
|
|
512
1434
|
}
|
|
513
1435
|
items.push(...parseGapsItems(content));
|
|
514
|
-
return items;
|
|
1436
|
+
return { items, headingsSeen, shortfallBlocks };
|
|
1437
|
+
}
|
|
1438
|
+
/**
|
|
1439
|
+
* ITEMS-ONLY convenience form over `parseUatItemsWithStats` — the same parse,
|
|
1440
|
+
* with the `headingsSeen` parse-gap counter dropped, for a caller that only
|
|
1441
|
+
* wants the rows.
|
|
1442
|
+
*
|
|
1443
|
+
* Deliberately RETAINED with no in-tree caller (#3078 round-5 MINOR): both
|
|
1444
|
+
* `cmdAuditUat` and `src/planning-inspect.cts` need the stats form, so this is
|
|
1445
|
+
* currently used only from outside. It is a public export of a shipped module,
|
|
1446
|
+
* and removing an exported symbol is a CONTRACT change, out of scope for a bug
|
|
1447
|
+
* fix — so it stays, as the documented thin wrapper it has always been, with a
|
|
1448
|
+
* direct test of its own rather than as untested dead weight.
|
|
1449
|
+
*/
|
|
1450
|
+
function parseUatItems(content) {
|
|
1451
|
+
return parseUatItemsWithStats(content).items;
|
|
515
1452
|
}
|
|
516
1453
|
// ─── parseGapsItems ───────────────────────────────────────────────────────────
|
|
517
1454
|
/**
|
|
@@ -848,14 +1785,25 @@ function parseDeferredItems(content) {
|
|
|
848
1785
|
* `status:` away from `acknowledged` (or delete the field) and it resurfaces
|
|
849
1786
|
* with no separate cleanup step, exactly like every other category's marker.
|
|
850
1787
|
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
*
|
|
857
|
-
*
|
|
858
|
-
*
|
|
1788
|
+
* #3781: the heading-delimited (#3457) entry shape is SUPPORTED, via
|
|
1789
|
+
* `splitDeferredHeadingEntriesWithSpans` — a span-carrying sibling of the
|
|
1790
|
+
* reader's walk that records each entry's (start, end) character span in the
|
|
1791
|
+
* SAME pass that groups its lines (the identical technique
|
|
1792
|
+
* `splitGapsEntriesWithSpans` uses for the headless shape). Leaf entries keep
|
|
1793
|
+
* their RAW heading line as `lines[0]` so `sectionBody.slice(start, end)` is
|
|
1794
|
+
* byte-verbatim; pending (preamble / container-direct) regions are contiguous
|
|
1795
|
+
* slices handed to `splitGapsEntriesWithSpans` with a baseOffset translation.
|
|
1796
|
+
* Two write rules differ from the headless path on this shape: the status
|
|
1797
|
+
* search runs over the READER-form lines (what the reader actually parses —
|
|
1798
|
+
* including the leaf line-0 corner where the heading text itself parses as a
|
|
1799
|
+
* status field, whose raw line is rewritten with its ATX prefix preserved),
|
|
1800
|
+
* and the insert branch inserts after the entry's LAST NON-BLANK line — a
|
|
1801
|
+
* heading entry's body is frequently a soft-wrapped sentence, and splicing
|
|
1802
|
+
* after line 0 would split it in half (#3781's sentence-split trap).
|
|
1803
|
+
* Entries whose span embeds a GFM table row are non-contiguous (table lines
|
|
1804
|
+
* are excluded from entries) and still refuse (`unsupported_heading_shape`)
|
|
1805
|
+
* rather than risk a wrong-entry write; the fully-headless shape below is
|
|
1806
|
+
* byte-for-byte the pre-#3781 path.
|
|
859
1807
|
*
|
|
860
1808
|
* Also refuses `ambiguous` (2+ entries share the exact same text — status must
|
|
861
1809
|
* be unique to identify one) and `not_found`, and is a no-op
|
|
@@ -897,8 +1845,11 @@ function parseDeferredItems(content) {
|
|
|
897
1845
|
function acknowledgeDeferredItem(content, targetText) {
|
|
898
1846
|
const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
899
1847
|
const sectionBody = deferredSection ? deferredSection.body : content;
|
|
900
|
-
|
|
901
|
-
|
|
1848
|
+
// #3781: the heading-delimited shape carries its own span walk; the
|
|
1849
|
+
// headless path below is unchanged.
|
|
1850
|
+
const headingEntries = splitDeferredHeadingEntriesWithSpans(sectionBody);
|
|
1851
|
+
if (headingEntries !== null) {
|
|
1852
|
+
return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
|
|
902
1853
|
}
|
|
903
1854
|
const entries = splitGapsEntriesWithSpans(sectionBody);
|
|
904
1855
|
const matches = entries
|
|
@@ -929,8 +1880,33 @@ function acknowledgeDeferredItem(content, targetText) {
|
|
|
929
1880
|
return { content, status: 'match_verification_failed' };
|
|
930
1881
|
}
|
|
931
1882
|
const matchIndexInContent = sectionOffset + start;
|
|
932
|
-
|
|
933
|
-
|
|
1883
|
+
// #3740: the search must mirror the reader exactly. extractGapEntryFields
|
|
1884
|
+
// strips a bullet marker on line 0 ALONE — a later `- ` line is a nested
|
|
1885
|
+
// sub-list, never a field line — so a marker-prefixed match on any
|
|
1886
|
+
// continuation line would rewrite a line no reader reads and report `ok`
|
|
1887
|
+
// while the entry stays outstanding. Line 0 KEEPS the marker-optional
|
|
1888
|
+
// form: the reader de-bullets it, so `- status: open` as the entry line is
|
|
1889
|
+
// a real field there (and first-wins means the insert branch could not
|
|
1890
|
+
// outrank it). Everything else falls through to the insert branch below,
|
|
1891
|
+
// which the marker-free and no-status controls already round-trip.
|
|
1892
|
+
//
|
|
1893
|
+
// #3775: the CASE axis of the same rule. The reader lowercases BOLDED
|
|
1894
|
+
// keys only; bare keys keep their literal case (#3457 design), so a bare
|
|
1895
|
+
// `Status:`/`STATUS:` line is stored under key `Status` and never read as
|
|
1896
|
+
// fields.status. The search therefore matches a bolded key in ANY case
|
|
1897
|
+
// and a bare key in LOWERCASE only — never a bare Title-case/UPPER line,
|
|
1898
|
+
// which must fall through to the insert branch whose lowercase output the
|
|
1899
|
+
// reader consumes (leaving any human `Status: resolved` untouched).
|
|
1900
|
+
const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
|
|
1901
|
+
const statusFieldBareRe = /^\s*status:/;
|
|
1902
|
+
const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
|
|
1903
|
+
const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
|
|
1904
|
+
const statusLineIdx = matchedLines.findIndex((rawLine, idx) => {
|
|
1905
|
+
const line = rawLine.replace(/\r$/, '');
|
|
1906
|
+
return idx === 0
|
|
1907
|
+
? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
|
|
1908
|
+
: (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line));
|
|
1909
|
+
});
|
|
934
1910
|
// No CRLF-preservation branch here (WARNING 1, #3458 follow-up review):
|
|
935
1911
|
// every write goes through `platformWriteSync` → `normalizeContent`, which
|
|
936
1912
|
// for a `.md` path unconditionally runs `_normalizeMd` — whole-file
|
|
@@ -966,6 +1942,243 @@ function acknowledgeDeferredItem(content, targetText) {
|
|
|
966
1942
|
const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
|
|
967
1943
|
return { content: newContent, status: 'ok' };
|
|
968
1944
|
}
|
|
1945
|
+
/**
|
|
1946
|
+
* #3781 — strip an ATX heading prefix, mirroring `tokenizeHeadings`' own ATX
|
|
1947
|
+
* regex (≤3 leading spaces, 1–6 `#`, space/tab separator, optional closing
|
|
1948
|
+
* `#` sequence) so the raw heading line reconciles byte-exactly with the
|
|
1949
|
+
* hash-stripped `text` the reader exposes. Returns null when the line is not
|
|
1950
|
+
* an ATX heading line.
|
|
1951
|
+
*/
|
|
1952
|
+
function stripAtxPrefix(line) {
|
|
1953
|
+
const m = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line.replace(/\r$/, ''));
|
|
1954
|
+
if (!m)
|
|
1955
|
+
return null;
|
|
1956
|
+
return m[3] === undefined
|
|
1957
|
+
? ''
|
|
1958
|
+
: m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
|
|
1959
|
+
}
|
|
1960
|
+
/**
|
|
1961
|
+
* #3781 — span-carrying sibling of `splitDeferredHeadingEntries`: ONE walk,
|
|
1962
|
+
* identical grouping rules (leaf = childless heading whose body carries a
|
|
1963
|
+
* bullet; container = next heading deeper; preamble/container-direct lines →
|
|
1964
|
+
* headless entries; table lines excluded), additionally recording each
|
|
1965
|
+
* entry's (start, end) character span within `sectionBody`. Returns null when
|
|
1966
|
+
* the body contains no heading at all — the caller then takes the unchanged
|
|
1967
|
+
* fully-headless path.
|
|
1968
|
+
*/
|
|
1969
|
+
function splitDeferredHeadingEntriesWithSpans(sectionBody) {
|
|
1970
|
+
const headings = tokenizeHeadings(sectionBody);
|
|
1971
|
+
if (headings.length === 0)
|
|
1972
|
+
return null;
|
|
1973
|
+
const lines = sectionBody.split('\n');
|
|
1974
|
+
const lineStarts = [];
|
|
1975
|
+
const lineEnds = [];
|
|
1976
|
+
let cursor = 0;
|
|
1977
|
+
for (const rawLine of lines) {
|
|
1978
|
+
lineStarts.push(cursor);
|
|
1979
|
+
cursor += rawLine.length;
|
|
1980
|
+
lineEnds.push(cursor);
|
|
1981
|
+
cursor += 1;
|
|
1982
|
+
}
|
|
1983
|
+
const headingByLine = new Map();
|
|
1984
|
+
for (let i = 0; i < headings.length; i++) {
|
|
1985
|
+
const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
|
|
1986
|
+
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
|
|
1987
|
+
}
|
|
1988
|
+
const isTableLine = (l) => /^\s*\|/.test(l.replace(/\r$/, ''));
|
|
1989
|
+
const isBulletLine = (l) => /^\s*-\s/.test(l.replace(/\r$/, ''));
|
|
1990
|
+
const entries = [];
|
|
1991
|
+
let current = null;
|
|
1992
|
+
let currentReaderLine0 = null;
|
|
1993
|
+
let currentStartLine = -1;
|
|
1994
|
+
let currentEndLine = -1;
|
|
1995
|
+
let currentHasBullet = false;
|
|
1996
|
+
let currentTable = false;
|
|
1997
|
+
let pendingStartLine = -1;
|
|
1998
|
+
let pendingEndLine = -1;
|
|
1999
|
+
const flushCurrent = () => {
|
|
2000
|
+
if (current !== null && currentHasBullet && currentReaderLine0 !== null) {
|
|
2001
|
+
const bodyReader = current.slice(1).map(stripLeadingBulletMarker);
|
|
2002
|
+
entries.push({
|
|
2003
|
+
kind: 'leaf',
|
|
2004
|
+
lines: current,
|
|
2005
|
+
readerLines: [currentReaderLine0, ...bodyReader],
|
|
2006
|
+
text: rawGapEntryText([currentReaderLine0, ...current.slice(1)]),
|
|
2007
|
+
fields: extractGapEntryFields([currentReaderLine0, ...bodyReader]),
|
|
2008
|
+
start: lineStarts[currentStartLine],
|
|
2009
|
+
end: lineEnds[currentEndLine],
|
|
2010
|
+
embeddedTable: currentTable,
|
|
2011
|
+
});
|
|
2012
|
+
}
|
|
2013
|
+
current = null;
|
|
2014
|
+
currentReaderLine0 = null;
|
|
2015
|
+
currentStartLine = -1;
|
|
2016
|
+
currentEndLine = -1;
|
|
2017
|
+
currentHasBullet = false;
|
|
2018
|
+
currentTable = false;
|
|
2019
|
+
};
|
|
2020
|
+
const flushPending = () => {
|
|
2021
|
+
if (pendingStartLine === -1)
|
|
2022
|
+
return;
|
|
2023
|
+
// The pending region is contiguous (a heading flushes it), but table lines
|
|
2024
|
+
// inside it were skipped by the walk: the reader's identity for this
|
|
2025
|
+
// region is computed over the table-FILTERED join, which may merge
|
|
2026
|
+
// entries across the gap, so spans cannot be translated faithfully —
|
|
2027
|
+
// mark the region's entries as refusing instead.
|
|
2028
|
+
let regionTable = false;
|
|
2029
|
+
for (let i = pendingStartLine; i <= pendingEndLine; i++) {
|
|
2030
|
+
if (isTableLine(lines[i]))
|
|
2031
|
+
regionTable = true;
|
|
2032
|
+
}
|
|
2033
|
+
const base = lineStarts[pendingStartLine];
|
|
2034
|
+
const regionText = sectionBody.slice(lineStarts[pendingStartLine], lineEnds[pendingEndLine]);
|
|
2035
|
+
for (const e of splitGapsEntriesWithSpans(regionText)) {
|
|
2036
|
+
entries.push({
|
|
2037
|
+
kind: 'pending',
|
|
2038
|
+
lines: e.lines,
|
|
2039
|
+
readerLines: e.lines,
|
|
2040
|
+
text: rawGapEntryText(e.lines),
|
|
2041
|
+
fields: extractGapEntryFields(e.lines),
|
|
2042
|
+
start: base + e.start,
|
|
2043
|
+
end: base + e.end,
|
|
2044
|
+
embeddedTable: regionTable,
|
|
2045
|
+
});
|
|
2046
|
+
}
|
|
2047
|
+
pendingStartLine = -1;
|
|
2048
|
+
pendingEndLine = -1;
|
|
2049
|
+
};
|
|
2050
|
+
for (let i = 0; i < lines.length; i++) {
|
|
2051
|
+
const heading = headingByLine.get(i + 1);
|
|
2052
|
+
if (heading !== undefined) {
|
|
2053
|
+
flushCurrent();
|
|
2054
|
+
flushPending();
|
|
2055
|
+
if (!heading.isContainer) {
|
|
2056
|
+
current = [lines[i]];
|
|
2057
|
+
currentReaderLine0 = heading.text;
|
|
2058
|
+
currentStartLine = i;
|
|
2059
|
+
currentEndLine = i;
|
|
2060
|
+
currentHasBullet = false;
|
|
2061
|
+
currentTable = false;
|
|
2062
|
+
}
|
|
2063
|
+
continue;
|
|
2064
|
+
}
|
|
2065
|
+
if (isTableLine(lines[i])) {
|
|
2066
|
+
if (current !== null)
|
|
2067
|
+
currentTable = true;
|
|
2068
|
+
continue;
|
|
2069
|
+
}
|
|
2070
|
+
if (current !== null) {
|
|
2071
|
+
current.push(lines[i]);
|
|
2072
|
+
currentEndLine = i;
|
|
2073
|
+
if (isBulletLine(lines[i]))
|
|
2074
|
+
currentHasBullet = true;
|
|
2075
|
+
}
|
|
2076
|
+
else {
|
|
2077
|
+
if (pendingStartLine === -1)
|
|
2078
|
+
pendingStartLine = i;
|
|
2079
|
+
pendingEndLine = i;
|
|
2080
|
+
}
|
|
2081
|
+
}
|
|
2082
|
+
flushCurrent();
|
|
2083
|
+
flushPending();
|
|
2084
|
+
return entries;
|
|
2085
|
+
}
|
|
2086
|
+
/**
|
|
2087
|
+
* #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
|
|
2088
|
+
* headless path's guards (not_found / ambiguous / already_resolved /
|
|
2089
|
+
* match_verification_failed) and its rewrite/insert machinery, with the two
|
|
2090
|
+
* shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
|
|
2091
|
+
* status search incl. the leaf line-0 ATX corner; insert after the entry's
|
|
2092
|
+
* last non-blank line). Extracted so the headless path stays byte-identical.
|
|
2093
|
+
*/
|
|
2094
|
+
function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }) {
|
|
2095
|
+
const matches = headingEntries.filter((e) => e.text === targetText);
|
|
2096
|
+
if (matches.length === 0)
|
|
2097
|
+
return { content, status: 'not_found' };
|
|
2098
|
+
if (matches.length > 1)
|
|
2099
|
+
return { content, status: 'ambiguous' };
|
|
2100
|
+
const entry = matches[0];
|
|
2101
|
+
if (entry.embeddedTable)
|
|
2102
|
+
return { content, status: 'unsupported_heading_shape' };
|
|
2103
|
+
if (entry.fields.status && entry.fields.status.toLowerCase() === 'resolved') {
|
|
2104
|
+
return { content, status: 'already_resolved' };
|
|
2105
|
+
}
|
|
2106
|
+
const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
|
|
2107
|
+
const rawSlice = sectionBody.slice(entry.start, entry.end);
|
|
2108
|
+
const rawSliceLines = rawSlice.split('\n');
|
|
2109
|
+
// Genuine invariant re-verification: re-derive the entry's identity from
|
|
2110
|
+
// the span's own bytes and compare against the targetText that selected it
|
|
2111
|
+
// — the span was recorded by an offset bookkeeping independent of the
|
|
2112
|
+
// identity comparison above.
|
|
2113
|
+
const verifyText = entry.kind === 'leaf'
|
|
2114
|
+
? (() => {
|
|
2115
|
+
const stripped = stripAtxPrefix(rawSliceLines[0]);
|
|
2116
|
+
return stripped === null ? null : rawGapEntryText([stripped, ...rawSliceLines.slice(1)]);
|
|
2117
|
+
})()
|
|
2118
|
+
: rawGapEntryText(rawSliceLines.map((l) => l.replace(/\r$/, '')));
|
|
2119
|
+
if (verifyText !== targetText) {
|
|
2120
|
+
return { content, status: 'match_verification_failed' };
|
|
2121
|
+
}
|
|
2122
|
+
// Status search over the READER-form lines — the exact set the reader
|
|
2123
|
+
// parses (bolded any case + bare lowercase, per #3775; line-0 forms per
|
|
2124
|
+
// #3740). Reader lines are index-aligned 1:1 with the raw lines.
|
|
2125
|
+
const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
|
|
2126
|
+
const statusFieldBareRe = /^\s*status:/;
|
|
2127
|
+
const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
|
|
2128
|
+
const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
|
|
2129
|
+
const readerLines = entry.kind === 'leaf'
|
|
2130
|
+
? [
|
|
2131
|
+
stripAtxPrefix(rawSliceLines[0]) ?? rawSliceLines[0].replace(/\r$/, ''),
|
|
2132
|
+
...rawSliceLines.slice(1).map((l) => stripLeadingBulletMarker(l.replace(/\r$/, ''))),
|
|
2133
|
+
]
|
|
2134
|
+
: rawSliceLines.map((l) => l.replace(/\r$/, ''));
|
|
2135
|
+
const statusLineIdx = readerLines.findIndex((line, idx) => idx === 0
|
|
2136
|
+
? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
|
|
2137
|
+
: (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line)));
|
|
2138
|
+
let newRawLines;
|
|
2139
|
+
if (statusLineIdx === -1) {
|
|
2140
|
+
// Insert branch: after the entry's LAST NON-BLANK line — a heading
|
|
2141
|
+
// entry's body is frequently a soft-wrapped sentence, and splicing after
|
|
2142
|
+
// line 0 would split it in half (#3781's sentence trap). The headless
|
|
2143
|
+
// (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
|
|
2144
|
+
let lastNonBlank = rawSliceLines.length - 1;
|
|
2145
|
+
while (lastNonBlank > 0 && rawSliceLines[lastNonBlank].replace(/\r$/, '').trim() === '') {
|
|
2146
|
+
lastNonBlank--;
|
|
2147
|
+
}
|
|
2148
|
+
const indent = entry.kind === 'pending'
|
|
2149
|
+
? (() => {
|
|
2150
|
+
const bulletIndentMatch = rawSliceLines[0].match(/^(\s*)-\s+/);
|
|
2151
|
+
return ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
|
|
2152
|
+
})()
|
|
2153
|
+
: ' ';
|
|
2154
|
+
newRawLines = [
|
|
2155
|
+
...rawSliceLines.slice(0, lastNonBlank + 1),
|
|
2156
|
+
`${indent}status: acknowledged`,
|
|
2157
|
+
...rawSliceLines.slice(lastNonBlank + 1),
|
|
2158
|
+
];
|
|
2159
|
+
}
|
|
2160
|
+
else {
|
|
2161
|
+
newRawLines = rawSliceLines.slice();
|
|
2162
|
+
if (entry.kind === 'leaf' && statusLineIdx === 0) {
|
|
2163
|
+
// Leaf line 0 is the RAW heading line — rewrite the heading-text portion
|
|
2164
|
+
// the reader treats as a field, with the ATX prefix preserved.
|
|
2165
|
+
const replacedReader = readerLines[statusLineIdx].replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
|
|
2166
|
+
const atxMatch = /^(\s*#+[ \t]*)(.*)$/.exec(rawSliceLines[0].replace(/\r$/, ''));
|
|
2167
|
+
newRawLines[0] = atxMatch ? atxMatch[1] + replacedReader : replacedReader;
|
|
2168
|
+
}
|
|
2169
|
+
else {
|
|
2170
|
+
// Every other status line is rewritten on its RAW line, so the bullet
|
|
2171
|
+
// marker and indent survive the write (the reader-form line has the
|
|
2172
|
+
// marker stripped — writing it back would mangle the markdown shape and
|
|
2173
|
+
// change the entry's identity text). Same replacement regex as the
|
|
2174
|
+
// headless path.
|
|
2175
|
+
newRawLines[statusLineIdx] = rawSliceLines[statusLineIdx].replace(/^(\s*(?:-\s+)?)(\*+status:\*+|status:)(\s*).*$/i, (_m, indent, key, ws) => `${indent}${key}${ws}acknowledged`);
|
|
2176
|
+
}
|
|
2177
|
+
}
|
|
2178
|
+
const matchIndexInContent = sectionOffset + entry.start;
|
|
2179
|
+
const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
|
|
2180
|
+
return { content: newContent, status: 'ok' };
|
|
2181
|
+
}
|
|
969
2182
|
/**
|
|
970
2183
|
* Strip one leading `- ` bullet marker (#3457). Heading-delimited deferred
|
|
971
2184
|
* entries carry their fields as sibling bullets; `extractGapEntryFields` only
|
|
@@ -1139,9 +2352,33 @@ function splitGapsEntriesCore(sectionBody) {
|
|
|
1139
2352
|
entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
|
1140
2353
|
}
|
|
1141
2354
|
};
|
|
2355
|
+
// #3898: a spaced-hyphen thematic break (`- - -`, `- -`, `- - -`, …) is a
|
|
2356
|
+
// SEPARATOR, not an entry. The opener regex below matches it (hyphen +
|
|
2357
|
+
// whitespace), which fabricated a gap named `- -` with result 'unknown' —
|
|
2358
|
+
// an item that cannot be cleared by editing any entry, because there is no
|
|
2359
|
+
// entry, only the separator the author wrote deliberately. A line whose
|
|
2360
|
+
// content after the opening marker consists solely of hyphens and spaces
|
|
2361
|
+
// (with at least one further hyphen) is skipped entirely: it neither opens
|
|
2362
|
+
// an entry nor is folded into the current one. This is deliberately NOT a
|
|
2363
|
+
// full thematic-break concept (option 2 in the issue): a break does not
|
|
2364
|
+
// close the Gaps list — entries after it keep parsing.
|
|
2365
|
+
const isSeparatorShaped = (line, bulletPrefixLen) => {
|
|
2366
|
+
const remainder = line.slice(bulletPrefixLen);
|
|
2367
|
+
return /^[-\s]*$/.test(remainder) && remainder.includes('-');
|
|
2368
|
+
};
|
|
1142
2369
|
rawLines.forEach((rawLine, idx) => {
|
|
1143
2370
|
const line = rawLine.replace(/\r$/, '');
|
|
1144
2371
|
const bulletMatch = line.match(/^(\s*)-\s/);
|
|
2372
|
+
// Narrowed skip (review disposition a): a separator-shaped line is skipped
|
|
2373
|
+
// only when it sits BETWEEN entries (nothing open yet, or it would open a
|
|
2374
|
+
// top-level entry — where the phantom came from). One landing strictly
|
|
2375
|
+
// INSIDE a live entry (indent > baseIndent) folds back as a continuation
|
|
2376
|
+
// line, so the entry's GapsEntrySpan stays byte-contiguous — the span
|
|
2377
|
+
// invariant below and the ack writer's identity re-verification both hold.
|
|
2378
|
+
if (bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
|
|
2379
|
+
(current === null || bulletMatch[1].length <= (baseIndent ?? 0))) {
|
|
2380
|
+
return; // separator line between entries — neither an opener nor a continuation
|
|
2381
|
+
}
|
|
1145
2382
|
if (bulletMatch) {
|
|
1146
2383
|
const indent = bulletMatch[1].length;
|
|
1147
2384
|
if (baseIndent === null)
|
|
@@ -1434,7 +2671,13 @@ function normalizeHumanVerificationEntry(raw) {
|
|
|
1434
2671
|
return s || raw.trim();
|
|
1435
2672
|
}
|
|
1436
2673
|
// ─── categorizeItem ───────────────────────────────────────────────────────────
|
|
1437
|
-
function categorizeItem(
|
|
2674
|
+
function categorizeItem(rawResult, reason, blockedBy) {
|
|
2675
|
+
// Normalize once so this comparison agrees with the PASS-token check
|
|
2676
|
+
// (`UAT_PASS_RESULTS.has(result)`, over an already-lower-cased token):
|
|
2677
|
+
// `result: PENDING` and
|
|
2678
|
+
// `result: Blocked` must categorize the same as their lowercase forms,
|
|
2679
|
+
// not fall through to 'unknown'.
|
|
2680
|
+
const result = rawResult.toLowerCase();
|
|
1438
2681
|
if (result === 'blocked' || blockedBy) {
|
|
1439
2682
|
if (blockedBy) {
|
|
1440
2683
|
if (/server/i.test(blockedBy))
|
|
@@ -1463,17 +2706,25 @@ function categorizeItem(result, reason, blockedBy) {
|
|
|
1463
2706
|
return 'pending';
|
|
1464
2707
|
if (result === 'human_needed')
|
|
1465
2708
|
return 'human_uat';
|
|
2709
|
+
// #3707: the template-sanctioned `result: issue` token (templates/UAT.md)
|
|
2710
|
+
// has no UatCategory branch here, so a surfaced issue row previously fell
|
|
2711
|
+
// through to 'unknown' — placed AFTER the blocked/skipped/pending checks
|
|
2712
|
+
// above so it never shadows their more specific categorization.
|
|
2713
|
+
if (result === 'issue')
|
|
2714
|
+
return 'issue';
|
|
1466
2715
|
return 'unknown';
|
|
1467
2716
|
}
|
|
1468
2717
|
module.exports = {
|
|
1469
2718
|
cmdAuditUat,
|
|
1470
2719
|
cmdRenderCheckpoint,
|
|
1471
2720
|
parseCurrentTest,
|
|
2721
|
+
parseUatItems,
|
|
2722
|
+
parseUatItemsWithStats,
|
|
2723
|
+
selectPhaseUatFiles,
|
|
1472
2724
|
buildCheckpoint,
|
|
1473
2725
|
CHECKPOINT_FRAMES,
|
|
1474
2726
|
CHECKPOINT_LANGUAGE_ALIASES,
|
|
1475
2727
|
resolveCheckpointFrame,
|
|
1476
|
-
checkpointBoxLine,
|
|
1477
2728
|
parseDeferredItems,
|
|
1478
2729
|
parseDeferredItemsWithStatus,
|
|
1479
2730
|
acknowledgeDeferredItem,
|