@opengsd/gsd-core 1.11.0 → 1.13.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/.opencode/plugins/gsd-core.js +12 -0
- 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 +78 -42
- 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 +91 -112
- package/agents/gsd-planner.md +20 -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 +82 -7
- package/agents/gsd-ui-researcher.md +70 -3
- package/agents/gsd-verifier.md +24 -2
- package/bin/install.js +847 -200
- package/commands/gsd/discuss-phase.md +1 -1
- package/commands/gsd/execute-phase.md +1 -1
- package/commands/gsd/import.md +1 -1
- package/commands/gsd/ns-workflow.md +2 -1
- package/commands/gsd/phase.md +1 -1
- package/commands/gsd/quick-batch.md +105 -0
- package/commands/gsd/quick.md +8 -4
- package/commands/gsd/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +761 -100
- 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-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-lock.cjs +10 -4
- package/gsd-core/bin/lib/capability-registry.cjs +785 -144
- package/gsd-core/bin/lib/capability-state.cjs +25 -4
- package/gsd-core/bin/lib/capability-validator.cjs +321 -18
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +229 -6
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/cli-exit.cjs +496 -10
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- 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-aliases.cjs +16 -0
- 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 +877 -54
- package/gsd-core/bin/lib/complexity-trigger.cjs +26 -6
- package/gsd-core/bin/lib/config-loader.cjs +121 -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 +118 -14
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +23 -2
- 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/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +975 -326
- package/gsd-core/bin/lib/gap-checker.cjs +41 -8
- package/gsd-core/bin/lib/git-base-branch.cjs +182 -39
- package/gsd-core/bin/lib/health-diagnostic-rules/consistency.cjs +7 -3
- package/gsd-core/bin/lib/health-diagnostic-rules/phase-structure.cjs +8 -2
- package/gsd-core/bin/lib/health-diagnostic-rules/roadmap-disk-consistency.cjs +60 -14
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- 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 +96 -11
- package/gsd-core/bin/lib/init-command-router.cjs +132 -21
- package/gsd-core/bin/lib/init.cjs +252 -56
- package/gsd-core/bin/lib/install-engine.cjs +252 -15
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +78 -1
- package/gsd-core/bin/lib/install-profiles.cjs +100 -18
- package/gsd-core/bin/lib/installer-migration-report.cjs +4 -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 +195 -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/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +175 -4
- package/gsd-core/bin/lib/milestone.cjs +112 -7
- 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-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-estimation.cjs +17 -8
- package/gsd-core/bin/lib/phase-id.cjs +321 -13
- 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 +1175 -115
- package/gsd-core/bin/lib/plan-document.cjs +273 -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 +165 -34
- package/gsd-core/bin/lib/planning-workspace.cjs +159 -28
- 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/quick-batch-command-router.cjs +285 -0
- package/gsd-core/bin/lib/quick-batch-dispatch.cjs +250 -0
- package/gsd-core/bin/lib/quick-batch.cjs +840 -0
- 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 +62 -14
- package/gsd-core/bin/lib/review-lane-invocation.cjs +73 -1
- package/gsd-core/bin/lib/review-lane-runner.cjs +136 -10
- package/gsd-core/bin/lib/roadmap-command-router.cjs +45 -31
- package/gsd-core/bin/lib/roadmap-parser.cjs +577 -41
- package/gsd-core/bin/lib/roadmap.cjs +248 -64
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +329 -41
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +320 -109
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +487 -83
- 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 +75 -8
- package/gsd-core/bin/lib/smart-entry.cjs +19 -31
- 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 +216 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +231 -0
- package/gsd-core/bin/lib/state-transition.cjs +850 -145
- package/gsd-core/bin/lib/state.cjs +1629 -287
- package/gsd-core/bin/lib/surface.cjs +33 -10
- 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/tdd-red-evidence.cjs +133 -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 +2542 -387
- 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/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate-command-router.cjs +2 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- 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-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +287 -13
- package/gsd-core/bin/lib/verify-command-grounding.cjs +846 -0
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +441 -56
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- 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 +185 -21
- package/gsd-core/bin/shared/config-defaults.manifest.json +7 -1
- package/gsd-core/bin/shared/config-schema.manifest.json +13 -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/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +6 -5
- 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 +17 -5
- package/gsd-core/references/execute-mvp-tdd.md +18 -18
- package/gsd-core/references/execute-phase-between-wave-reset.md +9 -12
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/execute-phase-wave-guard.md +11 -9
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/failing-direction.md +78 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- 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/mvp-concepts.md +2 -2
- 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/plan-checker-examples.md +41 -0
- package/gsd-core/references/planner-antipatterns.md +25 -0
- package/gsd-core/references/planner-chunked.md +5 -1
- package/gsd-core/references/planner-coupling.md +42 -0
- 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-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +76 -3
- package/gsd-core/references/planner-verify-command-grounding.md +17 -0
- package/gsd-core/references/planning-config.md +39 -9
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/reviewer-instances.md +31 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/runtime-aware-dispatch.md +1 -1
- package/gsd-core/references/tdd.md +15 -12
- 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/verifier-evidence-gate.md +160 -0
- 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 +7 -0
- package/gsd-core/templates/state.md +7 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/gsd-core/workflows/add-backlog.md +3 -1
- package/gsd-core/workflows/add-phase.md +5 -3
- package/gsd-core/workflows/add-tests.md +4 -9
- package/gsd-core/workflows/add-todo.md +2 -2
- package/gsd-core/workflows/ai-integration-phase.md +5 -10
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +14 -3
- package/gsd-core/workflows/audit-milestone.md +11 -9
- package/gsd-core/workflows/audit-uat.md +19 -2
- package/gsd-core/workflows/autonomous/steps/converge-fail-fast.md +2 -2
- package/gsd-core/workflows/autonomous.md +12 -26
- package/gsd-core/workflows/check-todos.md +2 -2
- package/gsd-core/workflows/cleanup.md +3 -3
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +16 -14
- package/gsd-core/workflows/code-review-fix.md +3 -1
- package/gsd-core/workflows/code-review.md +192 -69
- package/gsd-core/workflows/complete-milestone.md +28 -14
- package/gsd-core/workflows/debug.md +6 -4
- package/gsd-core/workflows/diagnose-issues.md +17 -7
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +3 -1
- package/gsd-core/workflows/discuss-phase/modes/all.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/analyze.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/auto.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/batch.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/chain.md +5 -7
- package/gsd-core/workflows/discuss-phase/modes/default.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/power.md +2 -0
- package/gsd-core/workflows/discuss-phase/modes/text.md +3 -1
- package/gsd-core/workflows/discuss-phase/templates/context.md +2 -0
- package/gsd-core/workflows/discuss-phase/templates/discussion-log.md +2 -0
- package/gsd-core/workflows/discuss-phase-assumptions/steps/auto-advance-dispatch.md +1 -3
- package/gsd-core/workflows/discuss-phase-assumptions.md +3 -3
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +2 -2
- package/gsd-core/workflows/do.md +46 -19
- package/gsd-core/workflows/docs-update.md +6 -5
- package/gsd-core/workflows/edit-phase.md +3 -1
- package/gsd-core/workflows/eval-review.md +5 -10
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +3 -1
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +129 -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 +29 -5
- 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 +4 -2
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/wave-post-gate-hooks.md +39 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +68 -66
- package/gsd-core/workflows/execute-plan.md +25 -20
- package/gsd-core/workflows/explore.md +3 -1
- package/gsd-core/workflows/extract-learnings.md +3 -1
- package/gsd-core/workflows/fast.md +8 -2
- package/gsd-core/workflows/forensics.md +3 -1
- package/gsd-core/workflows/graduation.md +6 -6
- package/gsd-core/workflows/health.md +4 -7
- package/gsd-core/workflows/help/modes/brief.md +2 -0
- package/gsd-core/workflows/help/modes/default.md +2 -0
- package/gsd-core/workflows/help/modes/full.md +12 -0
- package/gsd-core/workflows/help/modes/topic.md +2 -0
- package/gsd-core/workflows/help.md +2 -0
- package/gsd-core/workflows/import.md +17 -14
- package/gsd-core/workflows/inbox.md +5 -6
- package/gsd-core/workflows/ingest-docs.md +45 -12
- package/gsd-core/workflows/insert-phase.md +7 -5
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +7 -3
- package/gsd-core/workflows/list-workspaces.md +3 -1
- package/gsd-core/workflows/manager.md +15 -26
- package/gsd-core/workflows/map-codebase.md +3 -1
- package/gsd-core/workflows/milestone-summary.md +3 -1
- package/gsd-core/workflows/mvp-phase.md +3 -3
- package/gsd-core/workflows/new-milestone.md +10 -22
- package/gsd-core/workflows/new-project/steps/auto-mode-config.md +1 -1
- package/gsd-core/workflows/new-project.md +17 -29
- package/gsd-core/workflows/new-workspace.md +2 -2
- package/gsd-core/workflows/next.md +4 -2
- package/gsd-core/workflows/node-repair.md +2 -0
- package/gsd-core/workflows/note.md +2 -0
- package/gsd-core/workflows/onboard.md +1 -1
- package/gsd-core/workflows/pause-work.md +20 -5
- 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 +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +4 -4
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +12 -3
- package/gsd-core/workflows/plan-phase.md +251 -54
- package/gsd-core/workflows/plan-review-convergence.md +148 -19
- package/gsd-core/workflows/plant-seed.md +3 -3
- package/gsd-core/workflows/pr-branch.md +195 -51
- package/gsd-core/workflows/profile-user.md +17 -15
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +52 -15
- package/gsd-core/workflows/quick/steps/discussion-phase.md +1 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +38 -5
- package/gsd-core/workflows/quick/steps/quick-verification.md +2 -4
- package/gsd-core/workflows/quick/steps/research-phase.md +5 -7
- package/gsd-core/workflows/quick/steps/worktree-pre-dispatch-commit.md +3 -3
- package/gsd-core/workflows/quick-batch/steps/batch-init.md +55 -0
- package/gsd-core/workflows/quick-batch/steps/completion.md +65 -0
- package/gsd-core/workflows/quick-batch/steps/merge-wave.md +100 -0
- package/gsd-core/workflows/quick-batch/steps/plan-checker-loop.md +147 -0
- package/gsd-core/workflows/quick-batch/steps/planner-wave.md +158 -0
- package/gsd-core/workflows/quick-batch/steps/research-phase.md +95 -0
- package/gsd-core/workflows/quick-batch/steps/resume-mode.md +49 -0
- package/gsd-core/workflows/quick-batch/steps/verification-wave.md +73 -0
- package/gsd-core/workflows/quick-batch/steps/worktree-dispatch.md +169 -0
- package/gsd-core/workflows/quick-batch.md +203 -0
- package/gsd-core/workflows/quick.md +33 -32
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +6 -4
- package/gsd-core/workflows/remove-workspace.md +3 -3
- package/gsd-core/workflows/resume-project.md +14 -14
- package/gsd-core/workflows/review.md +404 -21
- package/gsd-core/workflows/scan.md +3 -1
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +3 -3
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +9 -9
- package/gsd-core/workflows/settings-integrations.md +66 -32
- package/gsd-core/workflows/settings.md +4 -6
- package/gsd-core/workflows/ship.md +22 -16
- package/gsd-core/workflows/sketch-wrap-up.md +13 -17
- package/gsd-core/workflows/sketch.md +13 -19
- package/gsd-core/workflows/smart-entry.md +4 -6
- package/gsd-core/workflows/spec-phase.md +31 -4
- package/gsd-core/workflows/spike-wrap-up.md +9 -11
- package/gsd-core/workflows/spike.md +21 -32
- package/gsd-core/workflows/stats.md +4 -2
- package/gsd-core/workflows/sync-skills.md +13 -5
- package/gsd-core/workflows/thread.md +13 -7
- package/gsd-core/workflows/transition.md +7 -5
- package/gsd-core/workflows/ui-phase.md +36 -21
- package/gsd-core/workflows/ui-review.md +7 -11
- package/gsd-core/workflows/ultraplan-phase.md +7 -13
- package/gsd-core/workflows/undo.md +9 -17
- package/gsd-core/workflows/update.md +47 -48
- package/gsd-core/workflows/validate-phase.md +3 -3
- package/gsd-core/workflows/verify-work/steps/automated-ui-verification.md +25 -1
- package/gsd-core/workflows/verify-work/steps/mvp-uat-framing.md +1 -1
- package/gsd-core/workflows/verify-work.md +106 -21
- package/hooks/dist/gsd-agent-isolation-guard.js +77 -38
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-config-reload.js +18 -12
- package/hooks/dist/gsd-context-monitor.js +302 -22
- 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 +77 -0
- package/hooks/dist/gsd-phase-boundary.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +46 -12
- package/hooks/dist/gsd-read-guard.js +18 -7
- package/hooks/dist/gsd-read-injection-scanner.js +22 -13
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-session-state.sh +1 -0
- package/hooks/dist/gsd-statusline.js +222 -29
- package/hooks/dist/gsd-validate-commit.sh +523 -12
- 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 +36 -17
- package/hooks/dist/gsd-worktree-path-guard.js +36 -21
- package/hooks/dist/gsd-write-guard.js +35 -25
- package/hooks/dist/lib/cli-exit.js +560 -0
- package/hooks/dist/lib/exit-code-registry.js +98 -0
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/git-probe.js +84 -0
- package/hooks/dist/lib/hook-exit.js +81 -0
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +4 -0
- package/hooks/gsd-agent-isolation-guard.js +77 -38
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-config-reload.js +18 -12
- package/hooks/gsd-context-monitor.js +302 -22
- 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 +77 -0
- package/hooks/gsd-phase-boundary.sh +1 -0
- package/hooks/gsd-prompt-guard.js +46 -12
- package/hooks/gsd-read-guard.js +18 -7
- package/hooks/gsd-read-injection-scanner.js +22 -13
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-session-state.sh +1 -0
- package/hooks/gsd-statusline.js +222 -29
- package/hooks/gsd-validate-commit.sh +523 -12
- package/hooks/gsd-windsurf-pre-command.js +16 -11
- package/hooks/gsd-windsurf-pre-write.js +22 -13
- package/hooks/gsd-workflow-guard.js +36 -17
- package/hooks/gsd-worktree-path-guard.js +36 -21
- package/hooks/gsd-write-guard.js +35 -25
- package/hooks/hooks.json +6 -0
- package/hooks/lib/cli-exit.js +560 -0
- package/hooks/lib/exit-code-registry.js +98 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/git-probe.js +84 -0
- package/hooks/lib/hook-exit.js +81 -0
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +4 -0
- package/package.json +14 -9
- package/scripts/base64-scan.sh +74 -12
- package/scripts/build-hooks.js +12 -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 +52 -12
- package/scripts/ci-timeout-report.cjs +230 -0
- package/scripts/docs-guard-registry.cjs +406 -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 +189 -4
- 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/lib/shellcheck-fetch.cjs +247 -0
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -6
- package/scripts/lint-allow-test-rule-refs.effective-ceiling.json +1 -1
- 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 +198 -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 +45 -14
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-planning-prompt-drift.cjs +38 -1
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-removed-but-needed.cjs +184 -16
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-seam-enforcement.cjs +182 -0
- package/scripts/lint-slug-derivation-drift.cjs +921 -0
- package/scripts/lint-source-test-name-collision.cjs +241 -0
- package/scripts/lint-state-write-path-drift.cjs +337 -432
- package/scripts/lint-test-file-count.allowlist.json +124 -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/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/mutation-matrix.cjs +599 -50
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +83 -14
- package/scripts/require-issue-link-policy.cjs +16 -1
- 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-execute-phase/SKILL.md +1 -1
- package/skills/gsd-import/SKILL.md +1 -1
- package/skills/gsd-ns-workflow/SKILL.md +1 -0
- package/skills/gsd-phase/SKILL.md +1 -1
- package/skills/gsd-quick/SKILL.md +8 -4
- package/skills/gsd-quick-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- 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,30 +19,63 @@ 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;
|
|
32
32
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
33
33
|
const frontmatter = require("./frontmatter.cjs");
|
|
34
|
-
const { extractFrontmatter } = frontmatter;
|
|
34
|
+
const { extractFrontmatter, frontmatterListEntries, flattenObjectListItem } = frontmatter;
|
|
35
35
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
36
36
|
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,52 @@ 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: {},
|
|
281
|
+
// #3783: additive segmentation so a consumer reads one field instead of
|
|
282
|
+
// re-deriving the `archived_milestone` filter itself. Deliberately does
|
|
283
|
+
// NOT touch total_items/parse_gap_files — see the parse_gap_files
|
|
284
|
+
// comment above for why splitting THAT counter by archive status was
|
|
285
|
+
// tried and reverted; this is a purely additive pair of new keys.
|
|
286
|
+
current_milestone: { files: 0, items: 0 },
|
|
287
|
+
archived: { files: 0, items: 0, by_milestone: {} },
|
|
167
288
|
};
|
|
168
289
|
for (const r of results) {
|
|
290
|
+
const resultItemCount = r.items.length;
|
|
291
|
+
if (r.archived_milestone) {
|
|
292
|
+
summary.archived.files++;
|
|
293
|
+
summary.archived.items += resultItemCount;
|
|
294
|
+
summary.archived.by_milestone[r.archived_milestone] =
|
|
295
|
+
(summary.archived.by_milestone[r.archived_milestone] || 0) + resultItemCount;
|
|
296
|
+
}
|
|
297
|
+
else {
|
|
298
|
+
summary.current_milestone.files++;
|
|
299
|
+
summary.current_milestone.items += resultItemCount;
|
|
300
|
+
}
|
|
301
|
+
// Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
|
|
302
|
+
// even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
|
|
303
|
+
// this away as dead code. The 0-valued key is itself the cue that this
|
|
304
|
+
// phase was scanned and produced no COUNTABLE items, distinguishing it
|
|
305
|
+
// from a phase absent from `by_phase` entirely (never scanned / no UAT
|
|
306
|
+
// file at all). A phase with a real outstanding item overwrites it below.
|
|
169
307
|
if (!summary.by_phase[r.phase])
|
|
170
308
|
summary.by_phase[r.phase] = 0;
|
|
171
309
|
for (const item of r.items) {
|
|
@@ -174,7 +312,9 @@ function cmdAuditUat(cwd, raw) {
|
|
|
174
312
|
summary.by_category[cat] = (summary.by_category[cat] || 0) + 1;
|
|
175
313
|
}
|
|
176
314
|
}
|
|
177
|
-
|
|
315
|
+
// #3805: acknowledged files surface as a COUNT (audit-open's honesty
|
|
316
|
+
// model: the marker fired, the items are suppressed, both facts visible).
|
|
317
|
+
output({ results, summary, acknowledged_files: acknowledgedFiles }, raw, undefined);
|
|
178
318
|
}
|
|
179
319
|
// ─── cmdRenderCheckpoint ──────────────────────────────────────────────────────
|
|
180
320
|
function cmdRenderCheckpoint(cwd, options = {}, raw) {
|
|
@@ -203,6 +343,12 @@ function cmdRenderCheckpoint(cwd, options = {}, raw) {
|
|
|
203
343
|
}
|
|
204
344
|
// ─── parseCurrentTest ─────────────────────────────────────────────────────────
|
|
205
345
|
function parseCurrentTest(content) {
|
|
346
|
+
// #3707-CR: this is the render-checkpoint path's own independent ingress
|
|
347
|
+
// into `tokenizeHeadings` (via the `parseFirstPendingTest` fallback below),
|
|
348
|
+
// separate from `parseUatItemsWithStats`'s. Normalize here too, ONCE, so a
|
|
349
|
+
// lone-CR document cannot hide its first pending row from this path either
|
|
350
|
+
// — see `normalizeLineEndings` for why.
|
|
351
|
+
content = normalizeLineEndings(content);
|
|
206
352
|
// Use the seam to locate the ## Current Test section (ADR-1372 T5).
|
|
207
353
|
// HTML-comment stripping within the section body is UAT-specific, so we keep
|
|
208
354
|
// the comment removal caller-side after extracting the body.
|
|
@@ -263,24 +409,51 @@ function parseFirstPendingTest(content) {
|
|
|
263
409
|
// tokenizeHeadings operates on the section body as a standalone document,
|
|
264
410
|
// filtering to level-3 headings matching the UAT-specific "N. Name" pattern.
|
|
265
411
|
// The UAT-specific item parsing (number extraction, result parsing) stays caller-side.
|
|
266
|
-
|
|
412
|
+
//
|
|
413
|
+
// #3078 blocker (same exposure as `parseUatItemsWithStats`): only a COLUMN-0
|
|
414
|
+
// heading is a test row — see `isColumnZeroHeading`. A `### N.` line indented
|
|
415
|
+
// <= 3 spaces INSIDE an `expected: |` value is value text, and must not
|
|
416
|
+
// register as a phantom heading and steal the real row's `result:` token.
|
|
417
|
+
//
|
|
418
|
+
// #3078 follow-up: tokenize a copy with the DELIMITER LINES of every
|
|
419
|
+
// wholly-INDENTED fenced block blanked out first (bodies untouched — column
|
|
420
|
+
// 0 is structure, indentation is content) — see
|
|
421
|
+
// `blankIndentedFenceDelimiters`. Without this, an
|
|
422
|
+
// indented ` ``` ` opener inside an `expected: |` value still reads as a
|
|
423
|
+
// real fence to `tokenizeHeadings` (CommonMark tolerates 1-3 leading
|
|
424
|
+
// spaces), which then hides every heading up to the next matching closer —
|
|
425
|
+
// including a later, genuinely column-0 `### N.` row.
|
|
426
|
+
//
|
|
427
|
+
// #3078 round-5 MAJOR: the row predicate is `isTestRowHeadingText`, the ONE
|
|
428
|
+
// shared helper `parseUatItemsWithStats` uses. It previously read
|
|
429
|
+
// `/^\d+\.\s+/` here while the audit path read `/^\d+\.(?!\d)/`, so
|
|
430
|
+
// `### 3.Foo` WAS a row on one path and was NOT on the other — two parse
|
|
431
|
+
// paths in one module disagreeing about the same grammar.
|
|
432
|
+
const subHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(sectionBody)).filter((h) => h.level === 3 && isTestRowHeadingText(h.text) && isColumnZeroHeading(sectionBody, h));
|
|
267
433
|
for (let i = 0; i < subHeadings.length; i += 1) {
|
|
268
434
|
const current = subHeadings[i];
|
|
269
435
|
const next = subHeadings[i + 1];
|
|
270
|
-
// Slice the block for this sub-test from the section body text
|
|
436
|
+
// Slice the block for this sub-test from the RAW section body text
|
|
271
437
|
const block = next
|
|
272
438
|
? sectionBody.slice(current.offset, next.offset)
|
|
273
439
|
: sectionBody.slice(current.offset);
|
|
274
440
|
if (!/^result:\s*\[?pending\]?\s*$/im.test(block)) {
|
|
275
441
|
continue;
|
|
276
442
|
}
|
|
277
|
-
// Extract the UAT-specific number and name from the heading text
|
|
278
|
-
|
|
443
|
+
// Extract the UAT-specific number and name from the heading text via the
|
|
444
|
+
// SAME `parseTestRowHeadingText` seam the audit path uses (#3078 round-5
|
|
445
|
+
// MAJOR) — a name-mandatory `/^(\d+)\.\s+(.+)$/` here would have `continue`d
|
|
446
|
+
// past exactly the `### 3.` / `### 3.Foo` shapes the shared predicate just
|
|
447
|
+
// admitted, reintroducing the divergence one line below the fix.
|
|
448
|
+
const headingParts = parseTestRowHeadingText(current.text);
|
|
279
449
|
if (!headingParts)
|
|
280
450
|
continue;
|
|
281
|
-
const testNumber =
|
|
282
|
-
const testName = headingParts
|
|
283
|
-
|
|
451
|
+
const testNumber = headingParts.number;
|
|
452
|
+
const testName = headingParts.name;
|
|
453
|
+
// #3078 blocker: clip the block at its first fence opener before handing
|
|
454
|
+
// it to `parseExpectedFromTestBlock`, so a raw read cannot reach into
|
|
455
|
+
// fence-hidden content — including a LATER row's own `expected:` line.
|
|
456
|
+
const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
|
|
284
457
|
if (!expected) {
|
|
285
458
|
error(`Pending UAT test ${testNumber} is missing an expected field`);
|
|
286
459
|
}
|
|
@@ -293,20 +466,122 @@ function parseFirstPendingTest(content) {
|
|
|
293
466
|
}
|
|
294
467
|
return null;
|
|
295
468
|
}
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
469
|
+
/**
|
|
470
|
+
* CRLF (#3078, found while hardening the scalar reader): the opener pattern
|
|
471
|
+
* demanded a BARE `\n` immediately after the `|`, so on a CRLF document
|
|
472
|
+
* `expected: |\r\n` never matched the block-scalar arm at all — control fell
|
|
473
|
+
* through to the INLINE arm, which happily captured the pipe character itself
|
|
474
|
+
* and published `expected: "|"`, discarding the entire multi-line value with no
|
|
475
|
+
* trace. `\r?` on the opener plus a per-line `\r` strip on the body fixes it.
|
|
476
|
+
* `(?:[1-9][+-]?|[+-][1-9]?)?` additionally admits the `|-` / `|+` chomping
|
|
477
|
+
* indicators AND the explicit indentation indicator (`|2`, `|2-`, `|-2`, ...,
|
|
478
|
+
* in either order per the YAML header grammar), keeping this reader in step
|
|
479
|
+
* with the column-0 heading rule (an indented heading inside a scalar body is
|
|
480
|
+
* otherwise a `expected: |-` or `expected: |2` value would be structurally
|
|
481
|
+
* masked but then read as the literal string `"|-"` / `"|2"` by the same
|
|
482
|
+
* fall-through.
|
|
483
|
+
*
|
|
484
|
+
* `[|>]` (#3078 follow-up): the `>` FOLDED-scalar family (`>`, `>-`, `>+`,
|
|
485
|
+
* `>2`, `>2+`, ...) hit the exact same fall-through as the CRLF/`|-`/`|+`
|
|
486
|
+
* bugs above — the opener only ever matched `|`, so `expected: >` fell to the
|
|
487
|
+
* inline arm and published the literal `">"` as the value, discarding the
|
|
488
|
+
* whole scalar. The opener character is now captured (group 1) so the caller
|
|
489
|
+
* can apply YAML's fold semantics for `>` while leaving `|` untouched.
|
|
490
|
+
*
|
|
491
|
+
* TRAILING COMMENT (#3078 round-6 MINOR 1): YAML permits a comment after a
|
|
492
|
+
* block-scalar header — `expected: | # sample`, `reason: >- # note` are both
|
|
493
|
+
* legal and open a scalar exactly as the bare forms do. The grammar was
|
|
494
|
+
* `$`-anchored immediately after the indicator, so those headers matched
|
|
495
|
+
* NEITHER `extractScalarField`'s opener (the value silently fell through to
|
|
496
|
+
* the inline arm and published the literal `"|"`) NOR
|
|
497
|
+
* `ANY_KEY_SCALAR_HEADER_LINE_RE` (so `countUnattributedIndentedRows` treated
|
|
498
|
+
* the scalar's own indented body heading as an unattributed lost row — a FALSE
|
|
499
|
+
* parse gap). `(?:#[^\r\n]*)?` closes both at the single shared source.
|
|
500
|
+
*/
|
|
501
|
+
const SCALAR_HEADER_BODY = String.raw `[ \t]*([|>])(?:[1-9][+-]?|[+-][1-9]?)?[ \t]*(?:#[^\r\n]*)?`;
|
|
502
|
+
/**
|
|
503
|
+
* Build the block-scalar HEADER grammar for an arbitrary `key:` — the ONE
|
|
504
|
+
* source shared by `expected:`, `reason:` and `blocked_by:` (#3078 MINOR 2:
|
|
505
|
+
* `reason:`/`blocked_by:` previously had no block-scalar grammar of their own
|
|
506
|
+
* at all, and silently published the literal `"|"` / `">"` for a `|`/`>`
|
|
507
|
+
* value, discarding it). A key is always a hardcoded literal at each call
|
|
508
|
+
* site in this module (never untrusted input), so no escaping is needed.
|
|
509
|
+
*/
|
|
510
|
+
function scalarHeaderFor(key) {
|
|
511
|
+
return String.raw `${key}:${SCALAR_HEADER_BODY}`;
|
|
512
|
+
}
|
|
513
|
+
/**
|
|
514
|
+
* ANY key's block-scalar HEADER line (#3078 MINOR 1), matched against ONE
|
|
515
|
+
* already-CR-stripped source line instead of against a multi-line block.
|
|
516
|
+
* Derived from the SAME `SCALAR_HEADER_BODY` source `scalarHeaderFor` uses
|
|
517
|
+
* so the opener grammars (`|`, `|-`, `|+`, `|2`, `|-2`, `>`, `>-`, `>+`, `>2+`,
|
|
518
|
+
* ...) cannot drift between them — the generative-divergence class this repo
|
|
519
|
+
* pins elsewhere.
|
|
520
|
+
*
|
|
521
|
+
* `countUnattributedIndentedRows` walks back from an indented `### N.`-shaped
|
|
522
|
+
* line to the nearest preceding column-0 line and asks whether THAT line
|
|
523
|
+
* opened a block scalar that still owns the indented line as its body.
|
|
524
|
+
* Testing only an `expected:`-ONLY grammar there meant an indented
|
|
525
|
+
* heading-shaped line inside ANY OTHER block scalar — `reported: |`
|
|
526
|
+
* (templates/UAT.md), `reason: |`, a verbatim user response containing
|
|
527
|
+
* ` ### 9. Section Nine` — was miscounted as a lost row even though nothing
|
|
528
|
+
* is missing. YAML's indentation rule (any column-0 line terminates a scalar)
|
|
529
|
+
* does not care WHICH key opened the scalar, only that a `[|>]`-family opener
|
|
530
|
+
* did, so the walk-back only needs to recognise the opener grammar, not the
|
|
531
|
+
* specific key.
|
|
532
|
+
*/
|
|
533
|
+
const ANY_KEY_SCALAR_HEADER_LINE_RE = new RegExp(String.raw `^[A-Za-z_][\w-]*:${SCALAR_HEADER_BODY}$`);
|
|
534
|
+
/**
|
|
535
|
+
* Apply YAML FOLDED-scalar (`>`) line-joining to an already-dedented,
|
|
536
|
+
* CRLF-stripped block-scalar body: lines within a paragraph (no blank line
|
|
537
|
+
* between them) join with a single space; a blank line between paragraphs
|
|
538
|
+
* becomes a literal `\n` in the result. `|` (LITERAL) bodies are returned
|
|
539
|
+
* unchanged — folding is `>`-only.
|
|
540
|
+
*/
|
|
541
|
+
function foldScalarBody(body) {
|
|
542
|
+
const lines = body.split('\n');
|
|
543
|
+
const paragraphs = [];
|
|
544
|
+
let current = [];
|
|
545
|
+
for (const line of lines) {
|
|
546
|
+
if (line === '') {
|
|
547
|
+
paragraphs.push(current.join(' '));
|
|
548
|
+
current = [];
|
|
549
|
+
}
|
|
550
|
+
else {
|
|
551
|
+
current.push(line);
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
paragraphs.push(current.join(' '));
|
|
555
|
+
return paragraphs.join('\n');
|
|
556
|
+
}
|
|
557
|
+
/**
|
|
558
|
+
* Extract a YAML-lite `key:` field's value from `block` — block-scalar
|
|
559
|
+
* (`|`/`>` family, dedented and, for `>`, YAML-folded) OR plain inline.
|
|
560
|
+
* Generalized from the `expected:`-only reader (#3078 MINOR 2) so `reason:`
|
|
561
|
+
* and `blocked_by:` — which previously had NO block-scalar grammar at all and
|
|
562
|
+
* silently published the literal `"|"` / `">"` for a multi-line value,
|
|
563
|
+
* discarding it — go through the exact same opener grammar and fold
|
|
564
|
+
* semantics instead of a third, hand-rolled dialect.
|
|
565
|
+
*/
|
|
566
|
+
function extractScalarField(block, key) {
|
|
567
|
+
const opener = String.raw `^${scalarHeaderFor(key)}\r?\n`;
|
|
568
|
+
const blockMatch = block.match(new RegExp(`${opener}([\\s\\S]*?)(?=^\\w[\\w-]*:\\s)`, 'm'))
|
|
569
|
+
|| block.match(new RegExp(`${opener}([\\s\\S]+)`, 'm'));
|
|
570
|
+
if (blockMatch) {
|
|
571
|
+
const openerChar = blockMatch[1];
|
|
572
|
+
const dedented = blockMatch[2]
|
|
301
573
|
.split('\n')
|
|
302
|
-
.map((line) => line.replace(/^ {2}/, ''))
|
|
574
|
+
.map((line) => line.replace(/\r$/, '').replace(/^ {2}/, ''))
|
|
303
575
|
.join('\n')
|
|
304
576
|
.trim();
|
|
577
|
+
return openerChar === '>' ? foldScalarBody(dedented) : dedented;
|
|
305
578
|
}
|
|
306
|
-
const
|
|
307
|
-
return
|
|
579
|
+
const inlineMatch = block.match(new RegExp(String.raw `^${key}:\s*(.+)\s*$`, 'm'));
|
|
580
|
+
return inlineMatch ? inlineMatch[1].trim() : null;
|
|
581
|
+
}
|
|
582
|
+
function parseExpectedFromTestBlock(block) {
|
|
583
|
+
return extractScalarField(block, 'expected');
|
|
308
584
|
}
|
|
309
|
-
const CHECKPOINT_BOX_WIDTH = 64; // total column width of the ╔══...╗ border, borders stay byte-identical
|
|
310
585
|
const CHECKPOINT_FRAMES = {
|
|
311
586
|
english: {
|
|
312
587
|
banner: 'CHECKPOINT: Verification Required',
|
|
@@ -409,52 +684,6 @@ function resolveCheckpointFrame(responseLanguage) {
|
|
|
409
684
|
const key = CHECKPOINT_LANGUAGE_ALIASES[responseLanguage.trim().normalize('NFC').toLowerCase()];
|
|
410
685
|
return (key && CHECKPOINT_FRAMES[key]) || CHECKPOINT_FRAMES.english;
|
|
411
686
|
}
|
|
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
687
|
const RTL_ISOLATE = '\u2067';
|
|
459
688
|
const POP_DIRECTIONAL_ISOLATE = '\u2069';
|
|
460
689
|
function isolateCheckpointFrameText(text, frame) {
|
|
@@ -467,51 +696,777 @@ function buildCheckpoint(currentTest, responseLanguage) {
|
|
|
467
696
|
const banner = isolateCheckpointFrameText(frame.banner, frame);
|
|
468
697
|
const instruction = isolateCheckpointFrameText(frame.instruction, frame);
|
|
469
698
|
return [
|
|
470
|
-
|
|
471
|
-
checkpointBoxLine(banner),
|
|
472
|
-
'╚══════════════════════════════════════════════════════════════╝',
|
|
699
|
+
`### ${banner}`,
|
|
473
700
|
'',
|
|
474
701
|
`**Test ${currentTest.number}: ${currentTest.name}**`,
|
|
475
702
|
'',
|
|
476
703
|
currentTest.expected,
|
|
477
704
|
'',
|
|
478
|
-
'
|
|
479
|
-
|
|
480
|
-
|
|
705
|
+
'---',
|
|
706
|
+
'',
|
|
707
|
+
`**${instruction}**`,
|
|
481
708
|
].join('\n');
|
|
482
709
|
}
|
|
483
710
|
// ─── parseUatItems ────────────────────────────────────────────────────────────
|
|
484
|
-
|
|
711
|
+
/**
|
|
712
|
+
* Result tokens treated as PASSING (#3707 defect 1). Deliberately MINIMAL —
|
|
713
|
+
* that minimality is the point. Every token NOT in this set surfaces as an
|
|
714
|
+
* outstanding item, mirroring the fail-safe direction `parseGapsItems`
|
|
715
|
+
* already documents for this exact false-negative class (#2286): a project
|
|
716
|
+
* that invents a novel pass-word gets a visible, correctable false positive
|
|
717
|
+
* (an extra row an agent can dismiss) rather than today's invisible drop (a
|
|
718
|
+
* genuinely outstanding row silently vanishing with no trace). This was the
|
|
719
|
+
* issue's one open design question and was decided deliberately, here, in
|
|
720
|
+
* favor of the fail-safe direction over a larger "known synonyms" allowlist.
|
|
721
|
+
*/
|
|
722
|
+
const UAT_PASS_RESULTS = new Set(['pass', 'passed']);
|
|
723
|
+
/**
|
|
724
|
+
* A fenced-code OPENER line at COLUMN 0 (``` or ~~~).
|
|
725
|
+
*
|
|
726
|
+
* Deliberately NOT the CommonMark `{0,3}`-space form (#3078 simplification):
|
|
727
|
+
* inside this module a fence only ever means "document structure the tokenizer
|
|
728
|
+
* hid from us", and every structural fence in a UAT file starts at column 0. An
|
|
729
|
+
* INDENTED fence run is, by construction, part of an `expected: |` block-scalar
|
|
730
|
+
* value — the ordinary way a UAT row reproduces a code sample verbatim — and
|
|
731
|
+
* must stay invisible to the clipper, or the very field it exists to protect
|
|
732
|
+
* gets truncated at its own sample. Column 0 is the whole rule for every
|
|
733
|
+
* fence-aware scan THIS MODULE writes directly against raw block text (this
|
|
734
|
+
* one, `dropTopLevelFencedRegions`'s `delimRe`). It does NOT extend to
|
|
735
|
+
* `tokenizeHeadings`, which is a third-party CommonMark scanner with its own
|
|
736
|
+
* {0,3}-space fence tolerance baked in — see `blankIndentedFenceDelimiters`
|
|
737
|
+
* for how an indented delimiter is kept from reaching that scanner at all.
|
|
738
|
+
*/
|
|
739
|
+
const FENCE_OPENER_RE = /^(?:`{3,}|~{3,})/;
|
|
740
|
+
/**
|
|
741
|
+
* The CommonMark-tolerant (0-3 leading spaces) twin of `FENCE_OPENER_RE`,
|
|
742
|
+
* used ONLY by the inner delimiter-shape sweep in
|
|
743
|
+
* `blankIndentedFenceDelimiters` (#3078 round-7 MAJOR). That sweep runs
|
|
744
|
+
* strictly BETWEEN a neutralised block's own (already-blanked) delimiters,
|
|
745
|
+
* looking for a line `tokenizeHeadings` would itself read as a fence opener
|
|
746
|
+
* once those delimiters are gone — and `tokenizeHeadings` tolerates up to
|
|
747
|
+
* three leading spaces on an opener, so a column-0-anchored test here misses
|
|
748
|
+
* an INDENTED delimiter-shaped line and lets the mutation manufacture exactly
|
|
749
|
+
* the structure `scanFencedBlocks` never saw. `FENCE_OPENER_RE` itself stays
|
|
750
|
+
* column-0-anchored: every OTHER call site depends on that anchoring.
|
|
751
|
+
*/
|
|
752
|
+
const INDENT_TOLERANT_DELIM_RE = /^ {0,3}(?:`{3,}|~{3,})/;
|
|
753
|
+
/**
|
|
754
|
+
* A raw source line whose shape is a UAT `### N.` test heading — the line-level
|
|
755
|
+
* twin of the `h.level === 3 && /^\d+\.(?!\d)/` token filter in
|
|
756
|
+
* `parseUatItemsWithStats`, and anchored at COLUMN 0 to match that filter's
|
|
757
|
+
* `isColumnZeroHeading` guard exactly. Used ONLY to count headings that
|
|
758
|
+
* `tokenizeHeadings` suppressed (a fence-straddled row), never to parse one:
|
|
759
|
+
* the two counts must be derived by the SAME rule or the shortfall they
|
|
760
|
+
* bracket over- or under-reports.
|
|
761
|
+
*/
|
|
762
|
+
const TEST_HEADING_LINE_RE = /^#{3}(?!#)[ \t]+\d+\.(?!\d)/;
|
|
763
|
+
/**
|
|
764
|
+
* THE test-row grammar, in ONE place (#3078 round-5 MAJOR).
|
|
765
|
+
*
|
|
766
|
+
* `parseFirstPendingTest` (the render-checkpoint path) and
|
|
767
|
+
* `parseUatItemsWithStats` (the audit path) each filtered level-3 headings with
|
|
768
|
+
* their own literal — `/^\d+\.\s+/` vs `/^\d+\.(?!\d)/` — so the two paths in
|
|
769
|
+
* this one module DISAGREED about what a test row is: `### 3.Foo` (name squished
|
|
770
|
+
* against the dot) and `### 3.` (no name at all) were rows to the audit and were
|
|
771
|
+
* silently NOT rows to the checkpoint. That is the generative-divergence class
|
|
772
|
+
* this repo requires closed with a shared definition rather than two literals
|
|
773
|
+
* kept in sync by hand.
|
|
774
|
+
*
|
|
775
|
+
* The AUDIT rule wins, deliberately: `^\d+\.(?!\d)` admits `### 3.` and
|
|
776
|
+
* `### 3.Foo` (a heading missing or squishing its name still contributes to
|
|
777
|
+
* `headingsSeen`/items instead of vanishing from BOTH — the same silent-drop
|
|
778
|
+
* symptom the parse-gap flag exists to catch) while the `(?!\d)` lookahead keeps
|
|
779
|
+
* a DOTTED-SECTION heading like `### 1.2.3 Overview` out, since that is a
|
|
780
|
+
* document outline number, not test row 1. `TEST_HEADING_LINE_RE` /
|
|
781
|
+
* `INDENTED_TEST_HEADING_LINE_RE` are the raw-source-line twins of this same
|
|
782
|
+
* rule and carry the identical `\d+\.(?!\d)` core.
|
|
783
|
+
*/
|
|
784
|
+
const TEST_ROW_HEADING_TEXT_RE = /^\d+\.(?!\d)/;
|
|
785
|
+
/** True when a level-3 heading's TEXT is a UAT test row. See `TEST_ROW_HEADING_TEXT_RE`. */
|
|
786
|
+
function isTestRowHeadingText(text) {
|
|
787
|
+
return TEST_ROW_HEADING_TEXT_RE.test(text);
|
|
788
|
+
}
|
|
789
|
+
/**
|
|
790
|
+
* Split a test-row heading's text into its number and display name — the
|
|
791
|
+
* extraction twin of `isTestRowHeadingText`, shared by both parse paths for the
|
|
792
|
+
* same anti-divergence reason. Returns `null` for text the predicate rejects.
|
|
793
|
+
*
|
|
794
|
+
* A bare `### 3.` (no trailing name) falls back to the heading's own trimmed
|
|
795
|
+
* text (`3.`) rather than yielding an empty name.
|
|
796
|
+
*/
|
|
797
|
+
function parseTestRowHeadingText(text) {
|
|
798
|
+
if (!isTestRowHeadingText(text))
|
|
799
|
+
return null;
|
|
800
|
+
const parts = text.match(/^(\d+)\.\s*(.*)$/);
|
|
801
|
+
if (!parts)
|
|
802
|
+
return null;
|
|
803
|
+
return { number: parseInt(parts[1], 10), name: parts[2].trim() || text.trim() };
|
|
804
|
+
}
|
|
805
|
+
/**
|
|
806
|
+
* The INDENTED (1-3 leading spaces, CommonMark-legal) twin of
|
|
807
|
+
* `TEST_HEADING_LINE_RE` — used by the SHORTFALL SCAN ONLY, never by the parse
|
|
808
|
+
* gate.
|
|
809
|
+
*
|
|
810
|
+
* #3078 round-4 MAJOR 2: `isColumnZeroHeading` refusing to PARSE an indented
|
|
811
|
+
* `### N.` row is deliberate and stays (no `*UAT*.md` in the tree indents one).
|
|
812
|
+
* But the COUNTING side inherited that anchor through
|
|
813
|
+
* `TEST_HEADING_LINE_RE`, so a heading the parse gate rejected could never
|
|
814
|
+
* reach `headingsSeen` either: ` ### 1. Indented Row` with `result: pending`
|
|
815
|
+
* — which origin/next's unanchored `###\s*(\d+)\.` did surface — yielded no
|
|
816
|
+
* item, no gap, no count and no trace at all. Refusing to parse is defensible;
|
|
817
|
+
* vanishing silently is the exact defect class this issue exists to close, so
|
|
818
|
+
* the row now surfaces as a PARSE GAP instead.
|
|
819
|
+
*/
|
|
820
|
+
const INDENTED_TEST_HEADING_LINE_RE = /^[ \t]+#{3}(?!#)[ \t]+\d+\.(?!\d)/;
|
|
821
|
+
/**
|
|
822
|
+
* True when `heading` starts at COLUMN 0 of its source line in `content`.
|
|
823
|
+
*
|
|
824
|
+
* The UAT test-row contract (#3078): a `### N.` row heading is structure ONLY
|
|
825
|
+
* at column 0. `tokenizeHeadings` implements CommonMark, which tolerates up to
|
|
826
|
+
* 3 leading spaces on an ATX heading — and that single over-permissive rule is
|
|
827
|
+
* what let a `### 3. Fake Row` line sitting INSIDE an `expected: |` value
|
|
828
|
+
* register as a phantom heading, open a block, and STEAL the real row's
|
|
829
|
+
* `result:` line, dropping a genuinely outstanding row from `items`. A scalar
|
|
830
|
+
* body is indented BY CONSTRUCTION (that is what makes it a body), so requiring
|
|
831
|
+
* column 0 makes every such line inert without the parser needing any notion of
|
|
832
|
+
* YAML block scalars at all. The shipped `templates/UAT.md` writes every `### N.`
|
|
833
|
+
* heading at column 0, and no UAT document in the tree indents one.
|
|
834
|
+
*
|
|
835
|
+
* `HeadingToken.offset` is the offset of the heading LINE's first character, so
|
|
836
|
+
* a column-0 heading is exactly one whose first character is the `#` itself.
|
|
837
|
+
*/
|
|
838
|
+
function isColumnZeroHeading(content, heading) {
|
|
839
|
+
return content.charCodeAt(heading.offset) === 0x23 /* '#' */;
|
|
840
|
+
}
|
|
841
|
+
/**
|
|
842
|
+
* An INDENTED (1-3 leading spaces, never 0) fenced-code delimiter line.
|
|
843
|
+
* Column 0 is intentionally EXCLUDED — a column-0 fence is real document
|
|
844
|
+
* structure and `tokenizeHeadings` handling it is correct; only the
|
|
845
|
+
* CommonMark-legal 1-3-space tolerance is the problem this targets.
|
|
846
|
+
*/
|
|
847
|
+
const INDENTED_FENCE_DELIM_RE = /^ {1,3}(?:`{3,}|~{3,})/;
|
|
848
|
+
/**
|
|
849
|
+
* Return `content` with the two DELIMITER LINES of every wholly-INDENTED
|
|
850
|
+
* fenced block overwritten by spaces, byte-length- and line-count-preserving,
|
|
851
|
+
* so every downstream offset and line index still lines up against the
|
|
852
|
+
* original document. The block's BODY is left verbatim — see "COLUMN 0 IS
|
|
853
|
+
* STRUCTURE, INDENTATION IS CONTENT" below for why that is the point, not an
|
|
854
|
+
* oversight.
|
|
855
|
+
*
|
|
856
|
+
* Why (#3078 follow-up, escalated design call, answered as option (b)):
|
|
857
|
+
* dropping `maskBlockScalarBodies` in favor of the column-0 heading filter
|
|
858
|
+
* (`isColumnZeroHeading`) fixed the phantom-heading theft, but it silently
|
|
859
|
+
* dropped a SECOND thing masking used to do — hide an INDENTED fence
|
|
860
|
+
* delimiter from `tokenizeHeadings` itself. `tokenizeHeadings` is a
|
|
861
|
+
* CommonMark scanner with its own {0,3}-space fence tolerance; a 1-3-space
|
|
862
|
+
* ` ``` ` inside an `expected: |` scalar body still opens a fence AS FAR AS
|
|
863
|
+
* THAT SCANNER IS CONCERNED, and every heading between it and its matching
|
|
864
|
+
* (or absent) closer — including a LATER, genuinely column-0 `### N.` row —
|
|
865
|
+
* is hidden from the token stream entirely, not merely mis-filtered. The
|
|
866
|
+
* column-0 heading filter cannot recover a heading the tokenizer never
|
|
867
|
+
* returned in the first place.
|
|
868
|
+
*
|
|
869
|
+
* This is deliberately the SAME "column 0 is structure, anything else is
|
|
870
|
+
* value text" rule already applied to headings (`isColumnZeroHeading`) and to
|
|
871
|
+
* this module's own raw-text fence scans (`FENCE_OPENER_RE`,
|
|
872
|
+
* `dropTopLevelFencedRegions`'s `delimRe`) — extended to the one place that
|
|
873
|
+
* rule cannot be expressed as a post-hoc filter, because the tokenizer
|
|
874
|
+
* consumes the fence delimiter before this module ever sees a token for it.
|
|
875
|
+
* It carries no YAML knowledge whatsoever (no notion of `expected:`, `|`,
|
|
876
|
+
* indentation width, or scalar bodies) — it blanks an indented delimiter LINE
|
|
877
|
+
* unconditionally, wherever it appears, the same context-free way the other
|
|
878
|
+
* column-0 rules do.
|
|
879
|
+
*
|
|
880
|
+
* PAIRED, NOT UNCONDITIONAL (#3078 round-4 MAJOR 1). Blanking every indented
|
|
881
|
+
* delimiter LINE on sight perturbs fence PAIRING in BOTH directions, because
|
|
882
|
+
* CommonMark lets a COLUMN-0 fence be closed by a delimiter indented up to
|
|
883
|
+
* three spaces:
|
|
884
|
+
* - a column-0 opener closed by an INDENTED closer had its closer blanked,
|
|
885
|
+
* so the fence never closed for `tokenizeHeadings` and every later row —
|
|
886
|
+
* including a genuinely column-0 `### N.` with an outstanding `result:` —
|
|
887
|
+
* was swallowed;
|
|
888
|
+
* - the mirror, an INDENTED opener closed by a COLUMN-0 closer, had its
|
|
889
|
+
* opener blanked, PROMOTING that closer into an opener and swallowing
|
|
890
|
+
* everything after it instead.
|
|
891
|
+
* Both documents are legal CommonMark that renders correctly, so neither may
|
|
892
|
+
* lose content. The decision is therefore made per FENCED BLOCK, not per line:
|
|
893
|
+
* a block is neutralised only when it is indented at BOTH ends (or is an
|
|
894
|
+
* indented opener that never closes at all) — i.e. when nothing about it is
|
|
895
|
+
* column-0 document structure. That is exactly the intended case, an indented
|
|
896
|
+
* fence pair living wholly inside an `expected: |` block-scalar value, which
|
|
897
|
+
* is why the helper exists; any block with a column-0 delimiter at either end
|
|
898
|
+
* is left completely alone so its pairing reaches the tokenizer unchanged.
|
|
899
|
+
*
|
|
900
|
+
* COLUMN 0 IS STRUCTURE, INDENTATION IS CONTENT — and that rule is applied in
|
|
901
|
+
* ONE direction only, to the DELIMITERS. Only the two delimiter lines of a
|
|
902
|
+
* neutralised block are blanked; its body is left exactly as written. A
|
|
903
|
+
* column-0 `### N.` sitting between two indented delimiters therefore becomes
|
|
904
|
+
* a real heading, and a `result:` line after it belongs to that heading. That
|
|
905
|
+
* is CORRECT under this rule, not theft: by the very rule that selected the
|
|
906
|
+
* block for neutralisation, an indented delimiter is not a fence at all, so
|
|
907
|
+
* there is no fence for the column-0 line to be "inside" of. The document is
|
|
908
|
+
* malformed; reading it this way is the consistent reading, and it is PINNED
|
|
909
|
+
* by test (see "#3078 round 5: column 0 is structure" in tests/uat.test.cjs).
|
|
910
|
+
* Blanking the whole block open-to-close was tried and REVERTED: it destroys
|
|
911
|
+
* content legitimately living between the delimiters, and — for the
|
|
912
|
+
* unterminated-opener case, where the "body" runs to EOF — silently deletes
|
|
913
|
+
* the entire remainder of the document, dropping every later row.
|
|
914
|
+
*
|
|
915
|
+
* NO SECOND FENCE DIALECT: the blocks come from `scanFencedBlocks`
|
|
916
|
+
* (markdown-sectionizer.cts), the SAME exported CommonMark state machine
|
|
917
|
+
* `stripFencedCode` — and therefore `tokenizeHeadings` — runs. Backtick AND
|
|
918
|
+
* tilde runs, run length >= 3, the <= 3-space indent tolerance, a closer of
|
|
919
|
+
* the same char with run length >= the opener and no trailing text, info
|
|
920
|
+
* strings (including the "a backtick fence's info string may not contain a
|
|
921
|
+
* backtick" rule), and the unterminated-at-EOF case are all classified by that
|
|
922
|
+
* engine, not re-derived here. This module contributes only the column-0
|
|
923
|
+
* question — which delimiter lines are structure — via
|
|
924
|
+
* `INDENTED_FENCE_DELIM_RE`.
|
|
925
|
+
*
|
|
926
|
+
* LINE-BASED by construction (`content.split('\n')` / `.join('\n')`), never
|
|
927
|
+
* character-array splicing — the exact bug class (`Array.from(content)`
|
|
928
|
+
* code-point indexing against UTF-16 offsets) that made the original
|
|
929
|
+
* `maskBlockScalarBodies` corrupt astral-character documents. A line's own
|
|
930
|
+
* `.length` and `' '.repeat(line.length)` are measured in the same (UTF-16)
|
|
931
|
+
* units as the string itself, so this cannot misalign regardless of
|
|
932
|
+
* code-point framing, and CRLF survives untouched: `split('\n')` leaves any
|
|
933
|
+
* `\r` attached to the end of its line, and blanking that line replaces the
|
|
934
|
+
* `\r` with a space exactly like every other character on it — `join('\n')`
|
|
935
|
+
* then reproduces the original line count and total length exactly.
|
|
936
|
+
*/
|
|
937
|
+
function blankIndentedFenceDelimiters(content) {
|
|
938
|
+
const lines = content.split('\n');
|
|
939
|
+
const isIndentedDelimiter = (idx) => idx >= 0 && idx < lines.length && INDENTED_FENCE_DELIM_RE.test(lines[idx].replace(/\r$/, ''));
|
|
940
|
+
const blank = new Set();
|
|
941
|
+
for (const block of scanFencedBlocks(lines)) {
|
|
942
|
+
// A column-0 OPENER is real document structure: leave the whole block
|
|
943
|
+
// alone, closer included, so an indented closer still closes it.
|
|
944
|
+
if (!isIndentedDelimiter(block.openLineIdx))
|
|
945
|
+
continue;
|
|
946
|
+
// An indented opener paired with a COLUMN-0 closer is likewise real
|
|
947
|
+
// structure at its far end — blanking the opener would promote that closer
|
|
948
|
+
// into an opener and hide everything after it.
|
|
949
|
+
if (block.closeLineIdx !== -1 && !isIndentedDelimiter(block.closeLineIdx))
|
|
950
|
+
continue;
|
|
951
|
+
// DELIMITERS ONLY — never the body. THE RULE: column 0 is structure,
|
|
952
|
+
// indentation is content. An indented delimiter therefore neutralises
|
|
953
|
+
// ITSELF, but it never hides column-0 structure sitting between
|
|
954
|
+
// delimiters: a column-0 `### N.` there IS a heading, and a `result:`
|
|
955
|
+
// after it IS that heading's. Widening this to the whole block was tried
|
|
956
|
+
// (#3078 round 5) and reverted — it deletes content that legitimately
|
|
957
|
+
// lives between the delimiters, and on an UNTERMINATED indented opener it
|
|
958
|
+
// blanks to EOF, taking every later row with it. Pinned by test; do not
|
|
959
|
+
// "fix" it back.
|
|
960
|
+
blank.add(block.openLineIdx);
|
|
961
|
+
if (block.closeLineIdx !== -1)
|
|
962
|
+
blank.add(block.closeLineIdx);
|
|
963
|
+
// #3078 round-6 MAJOR: the two fence engines must not disagree about the
|
|
964
|
+
// text handed downstream. `scanFencedBlocks` classified the ORIGINAL
|
|
965
|
+
// lines, but `tokenizeHeadings` re-runs its own CommonMark state machine
|
|
966
|
+
// over this MUTATED copy. A COLUMN-0 delimiter-shaped line that was mere
|
|
967
|
+
// fence CONTENT in the original — e.g. a ```-run inside an indented
|
|
968
|
+
// ````-pair — is PROMOTED to a real opener the instant its enclosing
|
|
969
|
+
// delimiters are blanked, hiding every later heading to EOF. Blank those
|
|
970
|
+
// too, so the mutation cannot manufacture structure that the classifying
|
|
971
|
+
// engine never saw.
|
|
972
|
+
//
|
|
973
|
+
// DELIMITER-SHAPED LINES ONLY. A column-0 `### N.` heading between
|
|
974
|
+
// neutralised delimiters stays a heading (the pinned "column 0 is
|
|
975
|
+
// structure" behaviour), and the field lines of a row living between two
|
|
976
|
+
// rows' scalars survive untouched — both are pinned by test. This adds
|
|
977
|
+
// exactly one shape to the blank set: a line that would itself be read as
|
|
978
|
+
// a fence delimiter.
|
|
979
|
+
const inner = block.closeLineIdx === -1 ? lines.length : block.closeLineIdx;
|
|
980
|
+
for (let i = block.openLineIdx + 1; i < inner; i += 1) {
|
|
981
|
+
if (INDENT_TOLERANT_DELIM_RE.test(lines[i].replace(/\r$/, '')))
|
|
982
|
+
blank.add(i);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
if (blank.size === 0)
|
|
986
|
+
return content;
|
|
987
|
+
return lines.map((line, i) => (blank.has(i) ? ' '.repeat(line.length) : line)).join('\n');
|
|
988
|
+
}
|
|
989
|
+
/**
|
|
990
|
+
* Truncate `block` at its first TOP-LEVEL fenced-code opener (#3078 blocker).
|
|
991
|
+
*
|
|
992
|
+
* `parseExpectedFromTestBlock` must read the RAW block (an `expected: |` scalar
|
|
993
|
+
* may legitimately reproduce fenced-looking text verbatim, so a fence-STRIPPED
|
|
994
|
+
* copy would corrupt the field). But a raw block slice can run straight into
|
|
995
|
+
* content that `tokenizeHeadings` correctly hid inside a fence — including a
|
|
996
|
+
* LATER test row's own `expected:` line, which the earlier row then published
|
|
997
|
+
* as its own. Clipping at the fence opener bounds the raw read to the part of
|
|
998
|
+
* the block the tokenizer also considered visible.
|
|
999
|
+
*
|
|
1000
|
+
* Column-0 fences only (`FENCE_OPENER_RE`): a fenced sample nested inside a
|
|
1001
|
+
* legitimate `expected: |` value is indented by construction, so it is invisible
|
|
1002
|
+
* here and cannot clip the very field this exists to preserve.
|
|
1003
|
+
*/
|
|
1004
|
+
function clipBlockAtFirstFence(block) {
|
|
1005
|
+
const rawLines = block.split('\n');
|
|
1006
|
+
let firstFenceLine = -1;
|
|
1007
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
1008
|
+
if (FENCE_OPENER_RE.test(rawLines[i])) {
|
|
1009
|
+
firstFenceLine = i;
|
|
1010
|
+
break;
|
|
1011
|
+
}
|
|
1012
|
+
}
|
|
1013
|
+
if (firstFenceLine === -1)
|
|
1014
|
+
return block;
|
|
1015
|
+
const beforeFence = rawLines.slice(0, firstFenceLine).join('\n');
|
|
1016
|
+
if (parseExpectedFromTestBlock(beforeFence))
|
|
1017
|
+
return beforeFence;
|
|
1018
|
+
// #3078 follow-up MINOR 2: an `expected:` field appearing AFTER a fence has
|
|
1019
|
+
// CLOSED is not a theft risk — only content strictly INSIDE the fence must
|
|
1020
|
+
// stay hidden. The plain "clip at first opener" result above silently
|
|
1021
|
+
// discards a late `expected:` even when it sits outside every fence.
|
|
1022
|
+
// Reconstruct the block with every top-level FENCED REGION dropped, keeping
|
|
1023
|
+
// RAW text everywhere else. This exposes a late `expected:` living after a
|
|
1024
|
+
// fence closes, while an `expected:` living strictly inside the fence is
|
|
1025
|
+
// dropped along with it and stays unreachable — the "inside a fence" vs.
|
|
1026
|
+
// "after a closed fence" split falls straight out of whether the
|
|
1027
|
+
// fence-tracking state machine below is OPEN or CLOSED at that line, not out
|
|
1028
|
+
// of position relative to the FIRST fence opener alone.
|
|
1029
|
+
const visible = dropTopLevelFencedRegions(rawLines);
|
|
1030
|
+
if (parseExpectedFromTestBlock(visible))
|
|
1031
|
+
return visible;
|
|
1032
|
+
return beforeFence;
|
|
1033
|
+
}
|
|
1034
|
+
/**
|
|
1035
|
+
* Reconstruct `rawLines` with every TOP-LEVEL fenced region removed. Mirrors
|
|
1036
|
+
* `stripFencedCode`'s own delimiter algorithm — a fence run of the SAME
|
|
1037
|
+
* character and at least the SAME length, with no trailing content, is what
|
|
1038
|
+
* closes an open fence — so "inside a fence" here means the same thing it means
|
|
1039
|
+
* to the rest of this module's fence handling. An UNTERMINATED fence (open at
|
|
1040
|
+
* EOF) drops everything from its opener to the end, same as `stripFencedCode`.
|
|
1041
|
+
*
|
|
1042
|
+
* Delimiters are recognised at COLUMN 0 only, for the reason given on
|
|
1043
|
+
* `FENCE_OPENER_RE`: an INDENTED fence run belongs to an `expected: |` value,
|
|
1044
|
+
* not to document structure, and must not open a region here.
|
|
1045
|
+
*/
|
|
1046
|
+
function dropTopLevelFencedRegions(rawLines) {
|
|
1047
|
+
const kept = [];
|
|
1048
|
+
let openFence = null;
|
|
1049
|
+
const delimRe = /^(`{3,}|~{3,})(.*)$/;
|
|
1050
|
+
for (let i = 0; i < rawLines.length; i += 1) {
|
|
1051
|
+
const line = rawLines[i].replace(/\r$/, '');
|
|
1052
|
+
const m = delimRe.exec(line);
|
|
1053
|
+
if (m) {
|
|
1054
|
+
const char = m[1][0];
|
|
1055
|
+
const len = m[1].length;
|
|
1056
|
+
const trailing = m[2];
|
|
1057
|
+
if (openFence === null) {
|
|
1058
|
+
if (char === '`' && trailing.includes('`')) {
|
|
1059
|
+
// Not a valid fence opener (CommonMark: backtick info string must
|
|
1060
|
+
// not contain a backtick) — ordinary content.
|
|
1061
|
+
kept.push(rawLines[i]);
|
|
1062
|
+
continue;
|
|
1063
|
+
}
|
|
1064
|
+
openFence = { char, len };
|
|
1065
|
+
}
|
|
1066
|
+
else if (char === openFence.char && len >= openFence.len && /^\s*$/.test(trailing)) {
|
|
1067
|
+
openFence = null;
|
|
1068
|
+
}
|
|
1069
|
+
continue; // all delimiter lines are dropped, opener or closer
|
|
1070
|
+
}
|
|
1071
|
+
if (openFence === null)
|
|
1072
|
+
kept.push(rawLines[i]);
|
|
1073
|
+
// Lines inside an open fence are silently dropped.
|
|
1074
|
+
}
|
|
1075
|
+
return kept.join('\n');
|
|
1076
|
+
}
|
|
1077
|
+
/**
|
|
1078
|
+
* Count the INDENTED (1-3 space) `### N.` heading-shaped lines in `surface`
|
|
1079
|
+
* that are NOT the value text of a preceding `expected:` block scalar.
|
|
1080
|
+
*
|
|
1081
|
+
* Why the exclusion (#3078 round-4 MAJOR 2): the parse gate refuses BOTH
|
|
1082
|
+
* shapes for the same reason (column 0 is structure), but only one of them is
|
|
1083
|
+
* a lost ROW. A `### 3. Fake Row` line sitting inside an `expected: |` value is
|
|
1084
|
+
* the row's own published `expected:` string — already surfaced, verbatim, on
|
|
1085
|
+
* the item — so counting it would flag a parse gap against a document with
|
|
1086
|
+
* nothing missing (the pinned scalar-body behaviour). A ` ### 1. Indented
|
|
1087
|
+
* Row` that no scalar owns is a row the parser declined to read, and must be
|
|
1088
|
+
* visible as an unparsed block instead of silently clean.
|
|
1089
|
+
*
|
|
1090
|
+
* Attribution is structural and cheap: walk BACK from the indented heading to
|
|
1091
|
+
* the first non-blank line at column 0 (a block-scalar body is indented by
|
|
1092
|
+
* construction, and blank lines are legal inside one). The heading is scalar
|
|
1093
|
+
* VALUE exactly when that line is ANY `key:` scalar header — not `expected:`
|
|
1094
|
+
* only (#3078 MINOR 1: testing the `expected:`-only grammar false-positived
|
|
1095
|
+
* on an indented heading-shaped line inside a DIFFERENT block scalar, e.g. a
|
|
1096
|
+
* template-sanctioned `reported: |` holding verbatim user prose, or a
|
|
1097
|
+
* `reason: |` body) — per `ANY_KEY_SCALAR_HEADER_LINE_RE`, derived from the
|
|
1098
|
+
* SAME `[|>]`-family opener grammar the reader itself uses. No second opener
|
|
1099
|
+
* dialect, and no attempt to model YAML indentation levels.
|
|
1100
|
+
*/
|
|
1101
|
+
function countUnattributedIndentedRows(surface) {
|
|
1102
|
+
const lines = surface.split('\n');
|
|
1103
|
+
// LINEAR, not quadratic (#3078 round-6 MINOR 2). The walk-back above was
|
|
1104
|
+
// re-scanned per indented row, so a document of N rows and N lines cost
|
|
1105
|
+
// O(N^2) — measured 4x per 2x on real input (1000 rows 20ms → 16000 rows
|
|
1106
|
+
// 3.6s). The walk only ever asks ONE question of the prefix — "which is the
|
|
1107
|
+
// nearest preceding non-blank COLUMN-0 line?" — and that is a running value,
|
|
1108
|
+
// so a single forward pass computes it for every line at once. The
|
|
1109
|
+
// ATTRIBUTION RULE IS UNCHANGED: a blank line and an indented line are both
|
|
1110
|
+
// transparent (a block-scalar body is indented by construction and may
|
|
1111
|
+
// contain blank lines), and the first line that is neither terminates the
|
|
1112
|
+
// scalar; the heading is value text exactly when THAT line is any key's
|
|
1113
|
+
// block-scalar header.
|
|
1114
|
+
const stripped = lines.map((line) => line.replace(/\r$/, ''));
|
|
1115
|
+
const nearestColumnZero = new Array(lines.length);
|
|
1116
|
+
let last = -1;
|
|
1117
|
+
for (let i = 0; i < stripped.length; i += 1) {
|
|
1118
|
+
nearestColumnZero[i] = last;
|
|
1119
|
+
const line = stripped[i];
|
|
1120
|
+
if (line.trim() !== '' && !/^[ \t]/.test(line))
|
|
1121
|
+
last = i;
|
|
1122
|
+
}
|
|
1123
|
+
let count = 0;
|
|
1124
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1125
|
+
if (!INDENTED_TEST_HEADING_LINE_RE.test(lines[i]))
|
|
1126
|
+
continue;
|
|
1127
|
+
const owner = nearestColumnZero[i];
|
|
1128
|
+
const ownedByScalar = owner !== -1 && ANY_KEY_SCALAR_HEADER_LINE_RE.test(stripped[owner]);
|
|
1129
|
+
if (!ownedByScalar)
|
|
1130
|
+
count += 1;
|
|
1131
|
+
}
|
|
1132
|
+
return count;
|
|
1133
|
+
}
|
|
1134
|
+
/**
|
|
1135
|
+
* `headingsSeen` is the TOTAL parse-gap tally (every heading-shaped thing this
|
|
1136
|
+
* parser could not turn into an item). `shortfallBlocks` is the SUBSET of it
|
|
1137
|
+
* contributed by the fence-suppression shortfall scan below — the one gap class
|
|
1138
|
+
* this module documents as carrying an ACCEPTED OVER-REPORT (a closed-fence
|
|
1139
|
+
* documentation sample written with literal digits is indistinguishable from a
|
|
1140
|
+
* genuinely fence-straddled row; see the long comment at the scan itself).
|
|
1141
|
+
* Reported separately so a consumer that must decide whether to WITHHOLD a
|
|
1142
|
+
* derived number — as opposed to merely REPORT the gap — can tell "a row I
|
|
1143
|
+
* definitely could not read" from "a row I possibly mis-counted".
|
|
1144
|
+
*
|
|
1145
|
+
* #3707-CR: `src/planning-inspect.cts`'s `buildUatRows` does NOT destructure
|
|
1146
|
+
* this field (verified — it and `cmdAuditUat` both consume only `items` and
|
|
1147
|
+
* `headingsSeen`), correcting an earlier stated instruction that it did.
|
|
1148
|
+
* `shortfallBlocks` currently has NO production consumer outside this
|
|
1149
|
+
* function's own computation. It is retained on the return value anyway,
|
|
1150
|
+
* deliberately, as part of this function's published stats contract — tests
|
|
1151
|
+
* assert on the full `{ items, headingsSeen, shortfallBlocks }` shape, and
|
|
1152
|
+
* dropping a returned field is a wider, unrelated change than a line-ending
|
|
1153
|
+
* fix warrants. A future consumer that needs to distinguish an
|
|
1154
|
+
* accepted-over-report shortfall from the rest of `headingsSeen` (the
|
|
1155
|
+
* original design intent above) can still do so.
|
|
1156
|
+
*/
|
|
1157
|
+
function parseUatItemsWithStats(content) {
|
|
1158
|
+
content = normalizeLineEndings(content);
|
|
485
1159
|
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
|
-
|
|
1160
|
+
let headingsSeen = 0;
|
|
1161
|
+
let shortfallBlocks = 0;
|
|
1162
|
+
// Locate every `### N. Name` test heading across the WHOLE document (not
|
|
1163
|
+
// adjacency-matched against `result:`, #3707 defect 2) and slice each one's
|
|
1164
|
+
// own block from its heading to the next heading OF ANY LEVEL (or EOF) —
|
|
1165
|
+
// a trailing `## Gaps` section or an interleaved `### Notes` heading must
|
|
1166
|
+
// not be absorbed into the preceding test's block, else its unanchored
|
|
1167
|
+
// `reason:`/`blocked_by:` scans below bleed a Gaps entry's fields onto the
|
|
1168
|
+
// last test row.
|
|
1169
|
+
// #3078 blocker: only a COLUMN-0 heading is document structure here (see
|
|
1170
|
+
// `isColumnZeroHeading`). The filter is applied to the WHOLE token stream,
|
|
1171
|
+
// not just to the `### N.` rows, because an indented heading must not act as
|
|
1172
|
+
// a block BOUNDARY either — a `### 3. Fake Row` line inside an `expected: |`
|
|
1173
|
+
// value would otherwise truncate its own row's block just before the real
|
|
1174
|
+
// `result:` line and drop a genuinely outstanding row from `items`.
|
|
1175
|
+
//
|
|
1176
|
+
// #3078 follow-up: tokenize a copy with every indented fence delimiter
|
|
1177
|
+
// blanked out (`blankIndentedFenceDelimiters`) BEFORE the column-0 filter
|
|
1178
|
+
// ever runs. Otherwise an indented ` ``` ` opener inside an `expected: |`
|
|
1179
|
+
// value still opens a real fence as far as `tokenizeHeadings` (a
|
|
1180
|
+
// CommonMark scanner, {0,3}-space fence tolerance) is concerned, hiding
|
|
1181
|
+
// every heading up to its closer from the token stream entirely — a LATER,
|
|
1182
|
+
// genuinely column-0 `### N.` row is never returned as a token at all, so
|
|
1183
|
+
// no post-hoc filter over the token stream could recover it.
|
|
1184
|
+
const allHeadings = tokenizeHeadings(blankIndentedFenceDelimiters(content)).filter((h) => isColumnZeroHeading(content, h));
|
|
1185
|
+
// #3707 follow-up MINOR: `^\d+\.` alone — a trailing name is OPTIONAL
|
|
1186
|
+
// (`### 3.` and `### 3.Foo`, without the space the old `\s+`-anchored
|
|
1187
|
+
// pattern required, both count) so a heading missing or squishing its name
|
|
1188
|
+
// still contributes to `headingsSeen`/items rather than being silently
|
|
1189
|
+
// excluded from BOTH — the same vanishing-row symptom the parse-gap flag
|
|
1190
|
+
// exists to catch, reachable here at the heading-filter layer instead.
|
|
1191
|
+
// #3078 round-5 MAJOR: that rule now lives in `isTestRowHeadingText` and is
|
|
1192
|
+
// shared verbatim with `parseFirstPendingTest`, which used to disagree.
|
|
1193
|
+
// Carry each match's own index into `allHeadings` from the filter pass
|
|
1194
|
+
// itself (security review finding 3) rather than re-deriving it via
|
|
1195
|
+
// `allHeadings.indexOf(current)` inside the loop below — the latter is an
|
|
1196
|
+
// O(n) scan per heading, making the whole loop O(n^2) in document size.
|
|
1197
|
+
const subHeadings = [];
|
|
1198
|
+
allHeadings.forEach((h, index) => {
|
|
1199
|
+
if (h.level === 3 && isTestRowHeadingText(h.text))
|
|
1200
|
+
subHeadings.push({ heading: h, index });
|
|
1201
|
+
});
|
|
1202
|
+
// #3078 blocker: `tokenizeHeadings` is fence-aware, so a BALANCED fence pair
|
|
1203
|
+
// that opens after one test row and closes after a later one makes every
|
|
1204
|
+
// `### N.` heading between them invisible — the rows are not merely
|
|
1205
|
+
// unparseable, they are absent from the token stream, so the loop below can
|
|
1206
|
+
// never count them and the file reports as CLEAN with an outstanding
|
|
1207
|
+
// `result: blocked` inside it. (origin/next's old whole-file regex did
|
|
1208
|
+
// surface those rows, making the silent drop a regression.) Comparing the
|
|
1209
|
+
// count of heading-SHAPED source lines against the headings the tokenizer
|
|
1210
|
+
// actually returned recovers the shortfall; each suppressed row counts
|
|
1211
|
+
// toward `headingsSeen`, so the file is flagged as a parse gap rather than
|
|
1212
|
+
// silently clean. The line scan is anchored at COLUMN 0 (`TEST_HEADING_LINE_RE`)
|
|
1213
|
+
// by the same rule the token filter uses, so a `### N.`-shaped line living
|
|
1214
|
+
// inside an `expected: |` value — which is value text, not a suppressed row —
|
|
1215
|
+
// cannot inflate the tally.
|
|
1216
|
+
//
|
|
1217
|
+
// #3078 round-7 HIGH — SYMMETRY IS THE INVARIANT. BOTH SIDES OF THIS
|
|
1218
|
+
// COMPARISON ARE WHOLE-DOCUMENT. DO NOT SCOPE EITHER ONE. Read this whole
|
|
1219
|
+
// comment before "optimising" the `## Notes` noise back out; three separate
|
|
1220
|
+
// HIGH-severity silent false-cleans have been produced by three separate
|
|
1221
|
+
// attempts to be clever about scope here, and every one of them was a
|
|
1222
|
+
// regression against origin/next's plain whole-file regex.
|
|
1223
|
+
//
|
|
1224
|
+
// History of the failures, so they are not re-derived:
|
|
1225
|
+
// - round-6 HIGH: the raw line scan was SECTION-SCOPED to the `## Tests`
|
|
1226
|
+
// body while `subHeadings` stayed whole-document, so a legal
|
|
1227
|
+
// `### 9. Old / result: pass` row in a preceding `## Prior` section
|
|
1228
|
+
// decremented the shortfall by one and SILENTLY DISABLED the
|
|
1229
|
+
// fence-straddle detector.
|
|
1230
|
+
// - round-7 HIGH: "equalising" that by ALSO scoping the token side to the
|
|
1231
|
+
// section's offset span made the two counters agree with each other but
|
|
1232
|
+
// left the PARSE side whole-document — so a `### N.` row living OUTSIDE
|
|
1233
|
+
// the first `## Tests` section was parsed and surfaced normally when
|
|
1234
|
+
// visible, yet vanished with NO item AND NO parse_gap the moment a fence
|
|
1235
|
+
// straddled it: neither side of the comparison covered it. Reproduced
|
|
1236
|
+
// three ways — a straddle inside a `## Regression Tests` section, a
|
|
1237
|
+
// straddle inside a SECOND `## Tests` section (`collectSection` takes the
|
|
1238
|
+
// FIRST match only), and, as control, the identical straddle in a file
|
|
1239
|
+
// with no `## Tests` heading at all, which alone reported correctly.
|
|
1240
|
+
//
|
|
1241
|
+
// THE RULE: the parse side reads rows wherever they live in the document, so
|
|
1242
|
+
// the counting side must too. Scan shaped `### N.` lines over the ENTIRE
|
|
1243
|
+
// document and compare against ALL tokenized row headings. Any narrowing of
|
|
1244
|
+
// one side that is not matched by the other manufactures a blind spot, and a
|
|
1245
|
+
// blind spot here is a SILENT FALSE CLEAN — a file with an outstanding
|
|
1246
|
+
// `result: blocked` in it that never even enters `results`.
|
|
1247
|
+
//
|
|
1248
|
+
// ACCEPTED CONSEQUENCE, DELIBERATELY TRADED (this replaces the #3078
|
|
1249
|
+
// follow-up MINOR 1 scoping): a `### N.`-shaped line inside a properly
|
|
1250
|
+
// CLOSED fence in a `## Notes` section — a documentation sample of the row
|
|
1251
|
+
// format. NOTE the shape needs LITERAL DIGITS — the scan requires `\d+`, so the
|
|
1252
|
+
// conventional placeholder `### N. Name` does NOT trigger it; only a sample written
|
|
1253
|
+
// with real numbers (`### 1. Example Row`) does. On FREQUENCY, claim only what is
|
|
1254
|
+
// measurable here: the SHAPE is uncommon (it takes a literal-digit row inside a
|
|
1255
|
+
// CLOSED fence), and that is a claim about the shape, NOT a measurement across real
|
|
1256
|
+
// projects. The in-tree sample size for it is ZERO PHASE FILES — the only `*UAT*.md`
|
|
1257
|
+
// anywhere in this repo is the shipped template (which `selectPhaseUatFiles` never
|
|
1258
|
+
// scans, and which itself scores headingsSeen=11, six of them literal-digit example
|
|
1259
|
+
// rows), so "no phase UAT file in-tree triggers it" is vacuously true and proves
|
|
1260
|
+
// nothing about rarity in the field. Do not restate it as evidence. If you test the
|
|
1261
|
+
// placeholder form, see no over-report, and conclude this pin is stale: it is not.
|
|
1262
|
+
// The ordinary way to explain the syntax inside a UAT file — is
|
|
1263
|
+
// counted as a suppressed row and raises a parse gap on a file with nothing
|
|
1264
|
+
// actually missing. That is an OVER-report: noisy, but VISIBLE and FAIL-SAFE
|
|
1265
|
+
// (an agent reads the file and dismisses it). Fence-closedness cannot
|
|
1266
|
+
// distinguish it from a genuinely hidden row, because the fence-straddle
|
|
1267
|
+
// case this scan exists to catch is ALSO a properly closed fence — so the
|
|
1268
|
+
// only lever left is scope, and scope is exactly what produced the two
|
|
1269
|
+
// silent false-cleans above. This entire issue exists to eliminate false
|
|
1270
|
+
// cleans, so the trade goes this way ON PURPOSE: an extra noisy row beats an
|
|
1271
|
+
// invisible missing one. The behaviour is pinned by test; do not "fix" it.
|
|
1272
|
+
let shapedHeadingLines = 0;
|
|
1273
|
+
for (const line of content.split('\n')) {
|
|
1274
|
+
if (TEST_HEADING_LINE_RE.test(line))
|
|
1275
|
+
shapedHeadingLines += 1;
|
|
1276
|
+
}
|
|
1277
|
+
if (shapedHeadingLines > subHeadings.length) {
|
|
1278
|
+
shortfallBlocks = shapedHeadingLines - subHeadings.length;
|
|
1279
|
+
headingsSeen += shortfallBlocks;
|
|
1280
|
+
}
|
|
1281
|
+
// #3078 round-4 MAJOR 2: an INDENTED `### N.` row is refused by the parse
|
|
1282
|
+
// gate (`isColumnZeroHeading`) — correct — but must not therefore vanish
|
|
1283
|
+
// without a trace. See `countUnattributedIndentedRows` for why an indented
|
|
1284
|
+
// heading that is the VALUE of a preceding `expected:` block scalar is
|
|
1285
|
+
// excluded from this tally (it is value text, not a row), keeping the
|
|
1286
|
+
// scalar-body pins intact while a genuinely indented ROW surfaces as a gap.
|
|
1287
|
+
//
|
|
1288
|
+
// WHOLE-DOCUMENT, for the same reason as the shortfall scan above: this
|
|
1289
|
+
// counter has no token-side twin to disagree with, but scoping it to a
|
|
1290
|
+
// `## Tests` body would silently drop an indented row living anywhere else
|
|
1291
|
+
// in the file — the identical vanishing-row class. Its own false-positive
|
|
1292
|
+
// guard is STRUCTURAL (scalar attribution via
|
|
1293
|
+
// `ANY_KEY_SCALAR_HEADER_LINE_RE`) — with ONE positional caveat: the walk stops at the
|
|
1294
|
+
// nearest COLUMN-0 line, so a block scalar nested inside a `## Gaps` bullet (a
|
|
1295
|
+
// `- truth:` entry carrying an indented `note: |`) is transparent to it and a
|
|
1296
|
+
// heading-shaped line inside that value is counted. That is another instance of the
|
|
1297
|
+
// accepted over-report above, not a separate defect, not positional, so it needs no scope.
|
|
1298
|
+
headingsSeen += countUnattributedIndentedRows(content);
|
|
1299
|
+
// #3078: an UNTERMINATED fence swallows the entire remainder of the
|
|
1300
|
+
// document — every later test row AND a trailing `## Gaps` section — so the
|
|
1301
|
+
// file yields nothing at all and never even enters `results`: a whole-file
|
|
1302
|
+
// false clean. Mirrors the per-file malformed-markdown guard
|
|
1303
|
+
// `evaluateUatPassed` already applies via `analyzeMarkdown`
|
|
1304
|
+
// (src/uat-predicate.cts:278), which likewise gates on
|
|
1305
|
+
// `stripFencedCode(raw).unterminatedFence`. Deliberately measured on the RAW
|
|
1306
|
+
// document: a fence opened inside an `expected:` scalar is still an
|
|
1307
|
+
// unterminated fence for every downstream markdown consumer, and the masked
|
|
1308
|
+
// copy would hide it.
|
|
1309
|
+
if (stripFencedCode(content).unterminatedFence) {
|
|
1310
|
+
headingsSeen += 1;
|
|
1311
|
+
}
|
|
1312
|
+
for (let i = 0; i < subHeadings.length; i += 1) {
|
|
1313
|
+
const { heading: current, index: currentIdx } = subHeadings[i];
|
|
1314
|
+
const next = allHeadings[currentIdx + 1];
|
|
1315
|
+
const block = next ? content.slice(current.offset, next.offset) : content.slice(current.offset);
|
|
1316
|
+
// Fence-stripped copy for the `result:`/`reason:`/`blocked_by:` field
|
|
1317
|
+
// scans below (#3707 follow-up MAJOR/regression): `block` is raw slice
|
|
1318
|
+
// text, and a fenced code sample inside a test block (a legitimate way to
|
|
1319
|
+
// document expected output) can contain a line that LOOKS like a field
|
|
1320
|
+
// declaration (e.g. an example ` ```\nresult: pending\n``` `). Scanning
|
|
1321
|
+
// raw text reads that sample's `result:` as the test's real outcome —
|
|
1322
|
+
// origin/next returned null here, so an unstripped scan is a regression,
|
|
1323
|
+
// not a pre-existing behavior to preserve. `parseExpectedFromTestBlock`
|
|
1324
|
+
// below still receives the RAW `block`, not this stripped copy: an
|
|
1325
|
+
// `expected: |` block-scalar value may legitimately reproduce
|
|
1326
|
+
// fenced-looking text verbatim, and stripping it would corrupt that field.
|
|
1327
|
+
// #3707 round-3 MINOR: an UNTERMINATED fence (EOF inside a fence, or —
|
|
1328
|
+
// here, scoped per test block — the closing delimiter living in a LATER
|
|
1329
|
+
// block, so from this block's own slice the fence never closes) makes
|
|
1330
|
+
// `stripFencedCode` drop everything from the opener to the end of the
|
|
1331
|
+
// block, including a real `result:`/`reason:`/`blocked_by:` line that
|
|
1332
|
+
// follows it. Falling back to the RAW (unstripped) block in that case
|
|
1333
|
+
// means a legitimate fenced-code false-positive (a `result:`-shaped line
|
|
1334
|
+
// INSIDE a properly-closed sample) is still guarded against in the common
|
|
1335
|
+
// case, while a malformed/unterminated fence no longer silently swallows
|
|
1336
|
+
// a real field line into a false parse_gap.
|
|
1337
|
+
const stripResult = stripFencedCode(block);
|
|
1338
|
+
const fenceStrippedBlock = stripResult.unterminatedFence ? block : stripResult.text;
|
|
1339
|
+
// A block with no `result:` line at all is not a test row (e.g. still
|
|
1340
|
+
// being drafted) — no item, no false positive. It IS, however, a heading
|
|
1341
|
+
// that failed to yield an item for a reason other than a PASS token, so
|
|
1342
|
+
// it counts toward `headingsSeen` (used to detect a genuine parse gap).
|
|
1343
|
+
// Deliberately NOT end-anchored (regression fix, #3707 blocker 1): a
|
|
1344
|
+
// trailing comment/clause after the token (`result: pending (blocked on
|
|
1345
|
+
// staging)`, `result: [skipped] # no device`, `result: blocked -
|
|
1346
|
+
// waiting`) must still match and surface the row instead of being
|
|
1347
|
+
// silently dropped. The trailing text itself is matched-and-ignored
|
|
1348
|
+
// (#3707 follow-up MINOR): it is NOT synthesized into `reason` — a real
|
|
1349
|
+
// `reason:` line is the only source for that field (see below) — because
|
|
1350
|
+
// doing so previously changed `categorizeItem`'s classification for
|
|
1351
|
+
// shapes origin/next categorized differently (an unpinned behavior
|
|
1352
|
+
// change, not something the blocker required).
|
|
1353
|
+
// #3078-CR defect A fix, split-then-match scan: the previous `.match()`
|
|
1354
|
+
// against `/^result:.../im` ran a MULTILINE regex anchor directly over
|
|
1355
|
+
// unsplit block text. ECMA-262's LineTerminator set for `^`/`$` under
|
|
1356
|
+
// `/m` includes U+2028 LINE SEPARATOR and U+2029 PARAGRAPH SEPARATOR, but
|
|
1357
|
+
// `content.split('\n')` and this module's own heading tokenizer do NOT
|
|
1358
|
+
// treat either as a boundary. A `result:`-shaped line inside an
|
|
1359
|
+
// `expected: |` scalar body, sitting immediately after one of these
|
|
1360
|
+
// separators instead of an ordinary character, was therefore read as a
|
|
1361
|
+
// genuine line start by the regex engine even though it is not
|
|
1362
|
+
// `\n`-delimited from anything — it is exactly as much "one line" to
|
|
1363
|
+
// every other consumer as the ordinary-character control case.
|
|
1364
|
+
// Splitting on `\n` FIRST and testing each already-split line against a
|
|
1365
|
+
// single-line (`/im`-anchor-free) pattern fixes this: a line is never
|
|
1366
|
+
// split by U+2028/U+2029 (`String.prototype.split` matches only its
|
|
1367
|
+
// literal separator argument, never the wider ECMA-262 LineTerminator
|
|
1368
|
+
// set), so a `result:`-shaped line reachable only via one of those
|
|
1369
|
+
// separators can never register as its own split line — the split view
|
|
1370
|
+
// and the regex view are back in agreement, by construction, exactly the
|
|
1371
|
+
// way `splitLines` module is documented to be immune to the sibling `\r`
|
|
1372
|
+
// bug.
|
|
1373
|
+
//
|
|
1374
|
+
// FIRST MATCH WINS (byte-identical to origin/next otherwise): a block
|
|
1375
|
+
// with more than one column-0 `result:` line resolves to the FIRST one
|
|
1376
|
+
// encountered, same as the pre-existing `.match()` behaviour without
|
|
1377
|
+
// `/g` — this is deliberately NOT an ambiguity/parse-gap case (that
|
|
1378
|
+
// variant was tried and reverted: its boundary-truncation heuristic
|
|
1379
|
+
// mistook an indented `### N.` living inside a legitimate block scalar
|
|
1380
|
+
// for a heading boundary, corrupting every scalar/indent guard in this
|
|
1381
|
+
// module — see tests/uat.test.cjs's #3078 scalar guard family).
|
|
1382
|
+
// Trailing text is matched with `[^]*` rather than `.*` (final review
|
|
1383
|
+
// MINOR 1): `.` never matches U+2028/U+2029, so a column-0 `result:`
|
|
1384
|
+
// line whose trailing text contains one of those separators would
|
|
1385
|
+
// otherwise never reach `$`, and the whole line would fail to match —
|
|
1386
|
+
// an unpinned regression against origin/next, which parses it.
|
|
1387
|
+
const RESULT_LINE_RE = /^result:\s*\[?(\w+)\]?[^]*$/i;
|
|
1388
|
+
const resultLineMatch = fenceStrippedBlock
|
|
1389
|
+
.split('\n')
|
|
1390
|
+
.map((line) => line.match(RESULT_LINE_RE))
|
|
1391
|
+
.find((m) => m !== null);
|
|
1392
|
+
if (!resultLineMatch) {
|
|
1393
|
+
headingsSeen += 1;
|
|
1394
|
+
continue;
|
|
511
1395
|
}
|
|
1396
|
+
// Security review finding 2: store the token lower-cased so the published
|
|
1397
|
+
// `result` field agrees with `category` (which categorizeItem already
|
|
1398
|
+
// lower-cases internally, below). No consumer needs the original casing —
|
|
1399
|
+
// `uat-predicate.cts` runs its own independent parser and already
|
|
1400
|
+
// lower-cases too — so the raw-cased form is kept nowhere.
|
|
1401
|
+
const result = resultLineMatch[1].toLowerCase();
|
|
1402
|
+
// #3707 defect 1: invert the old DROP-list filter to a PASS set — see
|
|
1403
|
+
// UAT_PASS_RESULTS's doc comment for why this direction was chosen.
|
|
1404
|
+
// A recognised PASS token is the ONLY reason a heading is excluded from
|
|
1405
|
+
// `headingsSeen` without producing an item — every other non-yielding
|
|
1406
|
+
// case (missing `result:` line, above) is a genuine parse gap.
|
|
1407
|
+
// `result` is already lower-cased at its extraction above, which is the
|
|
1408
|
+
// single point of normalization for this value — re-lowercasing here was
|
|
1409
|
+
// dead work and implied a second, independent normalization that does not
|
|
1410
|
+
// exist (#3078 round-5 MINOR).
|
|
1411
|
+
if (UAT_PASS_RESULTS.has(result))
|
|
1412
|
+
continue;
|
|
1413
|
+
// #3707 follow-up MINOR: the heading filter above now admits `### 3.`
|
|
1414
|
+
// (no name at all) and `### 3.Foo` (no space before the name), so this
|
|
1415
|
+
// extraction is loosened in lockstep — a bare number with no trailing
|
|
1416
|
+
// name falls back to the heading's own trimmed text (`3.`). #3078 round-5
|
|
1417
|
+
// MAJOR: shared with `parseFirstPendingTest` via `parseTestRowHeadingText`.
|
|
1418
|
+
const headingParts = parseTestRowHeadingText(current.text);
|
|
1419
|
+
const testNumber = headingParts.number;
|
|
1420
|
+
const testName = headingParts.name;
|
|
1421
|
+
// Reuse the existing block-scalar/inline `expected:` grammar rather than
|
|
1422
|
+
// re-deriving a second one (#3707 defect 2). #3078 blocker: the block is
|
|
1423
|
+
// CLIPPED at its first top-level fence opener first — still raw text (a
|
|
1424
|
+
// legitimate `expected: |` scalar must be read verbatim, fences and all),
|
|
1425
|
+
// but bounded to what the tokenizer also treated as visible, so this row
|
|
1426
|
+
// cannot reach past a fence into a LATER row's `expected:` line and
|
|
1427
|
+
// publish it as its own. See `clipBlockAtFirstFence`.
|
|
1428
|
+
const expected = parseExpectedFromTestBlock(clipBlockAtFirstFence(block));
|
|
1429
|
+
// #3078 MINOR 2: `reason:`/`blocked_by:` previously had no block-scalar
|
|
1430
|
+
// grammar at all (only a plain `/key:\s*(.+)/` single-line match), so a
|
|
1431
|
+
// `reason: |`/`reason: >`/`blocked_by: |` value silently published as the
|
|
1432
|
+
// literal string `"|"` / `">"`, discarding the real multi-line value the
|
|
1433
|
+
// author wrote — and `categorizeItem` below reads exactly this field, so a
|
|
1434
|
+
// discarded `reason` could silently change an item's category. Routed
|
|
1435
|
+
// through the SAME `extractScalarField` machinery `expected:` already
|
|
1436
|
+
// uses rather than adding a third hand-rolled opener dialect.
|
|
1437
|
+
const reason = extractScalarField(fenceStrippedBlock, 'reason') ?? undefined;
|
|
1438
|
+
const blockedBy = extractScalarField(fenceStrippedBlock, 'blocked_by') ?? undefined;
|
|
1439
|
+
const item = {
|
|
1440
|
+
test: testNumber,
|
|
1441
|
+
name: testName,
|
|
1442
|
+
result,
|
|
1443
|
+
category: categorizeItem(result, reason, blockedBy),
|
|
1444
|
+
};
|
|
1445
|
+
if (expected)
|
|
1446
|
+
item.expected = expected;
|
|
1447
|
+
if (reason)
|
|
1448
|
+
item.reason = reason;
|
|
1449
|
+
if (blockedBy)
|
|
1450
|
+
item.blocked_by = blockedBy;
|
|
1451
|
+
items.push(item);
|
|
512
1452
|
}
|
|
513
1453
|
items.push(...parseGapsItems(content));
|
|
514
|
-
return items;
|
|
1454
|
+
return { items, headingsSeen, shortfallBlocks };
|
|
1455
|
+
}
|
|
1456
|
+
/**
|
|
1457
|
+
* ITEMS-ONLY convenience form over `parseUatItemsWithStats` — the same parse,
|
|
1458
|
+
* with the `headingsSeen` parse-gap counter dropped, for a caller that only
|
|
1459
|
+
* wants the rows.
|
|
1460
|
+
*
|
|
1461
|
+
* Deliberately RETAINED with no in-tree caller (#3078 round-5 MINOR): both
|
|
1462
|
+
* `cmdAuditUat` and `src/planning-inspect.cts` need the stats form, so this is
|
|
1463
|
+
* currently used only from outside. It is a public export of a shipped module,
|
|
1464
|
+
* and removing an exported symbol is a CONTRACT change, out of scope for a bug
|
|
1465
|
+
* fix — so it stays, as the documented thin wrapper it has always been, with a
|
|
1466
|
+
* direct test of its own rather than as untested dead weight.
|
|
1467
|
+
*/
|
|
1468
|
+
function parseUatItems(content) {
|
|
1469
|
+
return parseUatItemsWithStats(content).items;
|
|
515
1470
|
}
|
|
516
1471
|
// ─── parseGapsItems ───────────────────────────────────────────────────────────
|
|
517
1472
|
/**
|
|
@@ -769,7 +1724,7 @@ function parseGapsTableItems(sectionBody) {
|
|
|
769
1724
|
* surfaced.
|
|
770
1725
|
*
|
|
771
1726
|
* #3457: when the section body contains headings, entries are delimited by
|
|
772
|
-
* LEAF headings (see `
|
|
1727
|
+
* LEAF headings (see `splitDeferredHeadingEntriesDetailed`) rather than by bullets —
|
|
773
1728
|
* the executor convention writes one deferred item as a heading followed by
|
|
774
1729
|
* sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
|
|
775
1730
|
* one item PER BULLET. A body with no headings keeps the original
|
|
@@ -795,18 +1750,27 @@ function parseDeferredItemsWithStatus(content) {
|
|
|
795
1750
|
// before field extraction, not just line 0 (which `extractGapEntryFields`
|
|
796
1751
|
// does for the headless/Gaps shape, where a later `- ` line is a nested
|
|
797
1752
|
// sub-list, not a field).
|
|
798
|
-
const headingEntries =
|
|
1753
|
+
const headingEntries = splitDeferredHeadingEntriesDetailed(sectionBody);
|
|
1754
|
+
// The opener flags are HANDED DOWN rather than pre-applied (#3702 round 3,
|
|
1755
|
+
// m7/m8). Marker-stripping the lines here and passing the result meant the
|
|
1756
|
+
// reader's fence scan ran over text the splitter never saw, and the namer
|
|
1757
|
+
// stripped a marker off the heading TEXT. Both consumers now take the raw
|
|
1758
|
+
// lines plus the splitter's own per-line verdict — a rejected ordinal
|
|
1759
|
+
// ("3. status: resolved" as prose) still keeps its `3. ` and yields no field,
|
|
1760
|
+
// because that verdict is what carries the rejection.
|
|
799
1761
|
const entries = headingEntries !== null
|
|
800
|
-
? headingEntries.map((
|
|
801
|
-
lines:
|
|
802
|
-
|
|
1762
|
+
? headingEntries.map((entry) => ({
|
|
1763
|
+
lines: entry.lines,
|
|
1764
|
+
opener: entry.opener,
|
|
1765
|
+
fields: extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener),
|
|
803
1766
|
}))
|
|
804
|
-
: splitGapsEntries(sectionBody).map((entryLines) => ({
|
|
1767
|
+
: splitGapsEntries(sectionBody, DEFERRED_BULLET_MARKERS).map((entryLines) => ({
|
|
805
1768
|
lines: entryLines,
|
|
806
|
-
|
|
1769
|
+
opener: undefined,
|
|
1770
|
+
fields: extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS),
|
|
807
1771
|
}));
|
|
808
|
-
for (const { lines: entryLines, fields } of entries) {
|
|
809
|
-
const text = rawGapEntryText(entryLines);
|
|
1772
|
+
for (const { lines: entryLines, opener, fields } of entries) {
|
|
1773
|
+
const text = rawGapEntryText(entryLines, DEFERRED_BULLET_MARKERS, opener);
|
|
810
1774
|
if (!text)
|
|
811
1775
|
continue;
|
|
812
1776
|
items.push({ name: text, status: fields.status || '' });
|
|
@@ -834,6 +1798,47 @@ function parseDeferredItems(content) {
|
|
|
834
1798
|
category: 'deferred',
|
|
835
1799
|
}));
|
|
836
1800
|
}
|
|
1801
|
+
/**
|
|
1802
|
+
* The line ending for an entry that ends the FILE, where the entry is a single
|
|
1803
|
+
* line and therefore carries no terminator of its own to copy. No entry-local
|
|
1804
|
+
* evidence exists here — the separator before the entry terminates the
|
|
1805
|
+
* PREVIOUS line, not this one — so this asks the weaker question that CAN be
|
|
1806
|
+
* answered: does anything before the entry, within the scope the caller passes,
|
|
1807
|
+
* contradict CRLF? Uniform CRLF across that scope is the one case where
|
|
1808
|
+
* appending a `\r\n` cannot make the file more irregular. It fails CLOSED:
|
|
1809
|
+
* any bare `\n` in scope, or no scope at all, yields LF.
|
|
1810
|
+
*
|
|
1811
|
+
* Adopted from #3773 (`crlfAtEof`), whose four counterexamples fixed the scope
|
|
1812
|
+
* and are ported alongside it. Every simpler choice is refuted by a named test:
|
|
1813
|
+
* the separator immediately PRECEDING the entry propagates an isolated CRLF
|
|
1814
|
+
* into an LF-dominant list, because it terminates the previous line rather than
|
|
1815
|
+
* this one — that is the algorithm this PR shipped through round 3 and it is
|
|
1816
|
+
* withdrawn here. The whole DOCUMENT rejects CRLF over an unrelated bare `\n`
|
|
1817
|
+
* elsewhere, inside a fenced block say. The deferred-items SECTION body is
|
|
1818
|
+
* right when a heading delimits one, and becomes the whole document when it
|
|
1819
|
+
* does not.
|
|
1820
|
+
*
|
|
1821
|
+
* Scope, therefore: the section body when `## Deferred Items` delimits one (its
|
|
1822
|
+
* own preamble belongs to that section), else the entry-list region, where only
|
|
1823
|
+
* the entries can be trusted.
|
|
1824
|
+
*
|
|
1825
|
+
* WITH ONE CORRECTION to #3773, which is its B4. The entry-list region goes
|
|
1826
|
+
* EMPTY exactly when the list is undelimited AND holds a single entry, since
|
|
1827
|
+
* the region runs from the first entry's start to the insertion point and those
|
|
1828
|
+
* coincide. `crlfAtEof('')` is `false`, so a bare `\n` was inserted into a CRLF
|
|
1829
|
+
* document — `'preamble\r\n\r\n- alpha'` gained one — which is the very defect
|
|
1830
|
+
* the fallback exists to close, and it breaks the fix's own uniform-CRLF
|
|
1831
|
+
* invariant. When the preferred region is empty the caller widens to everything
|
|
1832
|
+
* preceding the insertion point rather than asserting LF from no evidence. That
|
|
1833
|
+
* can only ever loosen a scope that was carrying zero information, and the
|
|
1834
|
+
* predicate stays fail-closed over the wider one, so a contradicting bare `\n`
|
|
1835
|
+
* still yields LF. An entry at offset 0 of an undelimited document has no
|
|
1836
|
+
* evidence under either scope and stays LF, rather than inventing an ending
|
|
1837
|
+
* from nothing.
|
|
1838
|
+
*/
|
|
1839
|
+
function crlfAtEof(before) {
|
|
1840
|
+
return before.length > 0 && !/(^|[^\r])\n/.test(before);
|
|
1841
|
+
}
|
|
837
1842
|
/**
|
|
838
1843
|
* CLI-writer half of the #3458 follow-up deferred_items suppression seam.
|
|
839
1844
|
* Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
|
|
@@ -848,14 +1853,27 @@ function parseDeferredItems(content) {
|
|
|
848
1853
|
* `status:` away from `acknowledged` (or delete the field) and it resurfaces
|
|
849
1854
|
* with no separate cleanup step, exactly like every other category's marker.
|
|
850
1855
|
*
|
|
851
|
-
*
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
855
|
-
*
|
|
856
|
-
* (
|
|
857
|
-
*
|
|
858
|
-
*
|
|
1856
|
+
* #3781: the heading-delimited (#3457) entry shape is SUPPORTED. The
|
|
1857
|
+
* reader's own walk, `splitDeferredHeadingEntriesDetailed`, records each
|
|
1858
|
+
* entry's (start, end) character span in the SAME pass that groups its
|
|
1859
|
+
* lines — the technique `splitGapsEntriesWithSpans` already uses for the
|
|
1860
|
+
* headless shape — so there is no second walk for the writer to drift from
|
|
1861
|
+
* (#3702 round 5: upstream's fix shipped a hyphen-only sibling walk, and this
|
|
1862
|
+
* PR's widened grammar would have left it reading a different set of
|
|
1863
|
+
* entries than the reader; folding the spans into the one walk is what
|
|
1864
|
+
* keeps the writer and the reader on one grammar). The heading half,
|
|
1865
|
+
* `acknowledgeHeadingShapedEntry`, shares this function's guards and its
|
|
1866
|
+
* rewrite/insert machinery through the same `entryFieldLines` seam, with two
|
|
1867
|
+
* shape-specific rules: the status search runs over the READER-form lines
|
|
1868
|
+
* (the heading TEXT on a leaf's line 0 — including the corner where that
|
|
1869
|
+
* text itself parses as a status field, rewritten with its ATX prefix
|
|
1870
|
+
* preserved), and the insert branch inserts after the entry's LAST NON-BLANK
|
|
1871
|
+
* line, because a heading entry's body is frequently a soft-wrapped
|
|
1872
|
+
* sentence and splicing after line 0 would split it (#3781's sentence-split
|
|
1873
|
+
* trap). Entries whose span embeds a GFM table row are non-contiguous (table
|
|
1874
|
+
* lines are excluded from entries) and still refuse
|
|
1875
|
+
* (`unsupported_heading_shape`) rather than risk a wrong-entry write; the
|
|
1876
|
+
* fully-headless shape below is byte-for-byte the pre-#3781 path.
|
|
859
1877
|
*
|
|
860
1878
|
* Also refuses `ambiguous` (2+ entries share the exact same text — status must
|
|
861
1879
|
* be unique to identify one) and `not_found`, and is a no-op
|
|
@@ -897,12 +1915,15 @@ function parseDeferredItems(content) {
|
|
|
897
1915
|
function acknowledgeDeferredItem(content, targetText) {
|
|
898
1916
|
const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
899
1917
|
const sectionBody = deferredSection ? deferredSection.body : content;
|
|
900
|
-
|
|
901
|
-
|
|
1918
|
+
// #3781: the heading-delimited shape carries its own spans, recorded by
|
|
1919
|
+
// the reader's walk; the headless path below is unchanged.
|
|
1920
|
+
const headingEntries = splitDeferredHeadingEntriesDetailed(sectionBody);
|
|
1921
|
+
if (headingEntries !== null) {
|
|
1922
|
+
return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
|
|
902
1923
|
}
|
|
903
|
-
const entries = splitGapsEntriesWithSpans(sectionBody);
|
|
1924
|
+
const entries = splitGapsEntriesWithSpans(sectionBody, DEFERRED_BULLET_MARKERS);
|
|
904
1925
|
const matches = entries
|
|
905
|
-
.map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
|
|
1926
|
+
.map((entry) => ({ entry, text: rawGapEntryText(entry.lines, DEFERRED_BULLET_MARKERS) }))
|
|
906
1927
|
.filter((e) => e.text === targetText);
|
|
907
1928
|
if (matches.length === 0)
|
|
908
1929
|
return { content, status: 'not_found' };
|
|
@@ -910,7 +1931,7 @@ function acknowledgeDeferredItem(content, targetText) {
|
|
|
910
1931
|
return { content, status: 'ambiguous' };
|
|
911
1932
|
const { entry } = matches[0];
|
|
912
1933
|
const { lines: entryLines, start, end } = entry;
|
|
913
|
-
const fields = extractGapEntryFields(entryLines);
|
|
1934
|
+
const fields = extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS);
|
|
914
1935
|
if (fields.status && fields.status.toLowerCase() === 'resolved') {
|
|
915
1936
|
return { content, status: 'already_resolved' };
|
|
916
1937
|
}
|
|
@@ -925,94 +1946,559 @@ function acknowledgeDeferredItem(content, targetText) {
|
|
|
925
1946
|
// comparison that selected this entry — this catches real drift between
|
|
926
1947
|
// the two rather than a regex trivially guaranteed to agree with itself.
|
|
927
1948
|
const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
|
|
928
|
-
if (rawGapEntryText(strippedForVerify) !== targetText) {
|
|
1949
|
+
if (rawGapEntryText(strippedForVerify, DEFERRED_BULLET_MARKERS) !== targetText) {
|
|
929
1950
|
return { content, status: 'match_verification_failed' };
|
|
930
1951
|
}
|
|
931
1952
|
const matchIndexInContent = sectionOffset + start;
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
//
|
|
935
|
-
//
|
|
936
|
-
//
|
|
937
|
-
//
|
|
938
|
-
//
|
|
939
|
-
//
|
|
940
|
-
//
|
|
941
|
-
//
|
|
942
|
-
//
|
|
943
|
-
// `
|
|
944
|
-
//
|
|
945
|
-
//
|
|
946
|
-
//
|
|
947
|
-
//
|
|
948
|
-
//
|
|
949
|
-
//
|
|
1953
|
+
// Locate the status line with the READER'S OWN classifier, never a
|
|
1954
|
+
// writer-side regex (#3702 round 3, B1/B3 — the shape is #3773's,
|
|
1955
|
+
// parameterised here by the widened marker set per the round-3 review's
|
|
1956
|
+
// prescribed end state). The only line worth rewriting in place is one the
|
|
1957
|
+
// reader will read back as `fields.status`.
|
|
1958
|
+
//
|
|
1959
|
+
// What this closes: round 2 widened the writer's finder to the deferred
|
|
1960
|
+
// marker set while `extractGapEntryFields` still read a marker only on line
|
|
1961
|
+
// 0. A nested ` * status: pending` was therefore SELECTED by the writer and
|
|
1962
|
+
// invisible to the reader — acknowledge rewrote it, returned `ok`, and the
|
|
1963
|
+
// item stayed outstanding forever. Measured against a `next` build: `*`, `+`
|
|
1964
|
+
// and `1.` all resolved on base and stopped resolving here, so it was a
|
|
1965
|
+
// regression, not a gap in new behaviour. The hyphen form of the same shape
|
|
1966
|
+
// (` - status:`) was already broken on `next`; it is fixed here too, since
|
|
1967
|
+
// one classifier cannot be right for three markers and wrong for the fourth.
|
|
1968
|
+
//
|
|
1969
|
+
// A line the reader skips falls through to the INSERT branch, which writes a
|
|
1970
|
+
// line the reader does read — the fail-safe direction. That covers a bare
|
|
1971
|
+
// capitalised `Status:` (the reader stores it under `Status`, not `status`)
|
|
1972
|
+
// and a `status:` line inside a fenced block, both of which the writer must
|
|
1973
|
+
// NOT rewrite in place. Selecting either one produced an entry that could not
|
|
1974
|
+
// be acknowledged at all; that is why the selection goes through
|
|
1975
|
+
// `entryFieldLines` rather than the classifier directly.
|
|
1976
|
+
const statusLineIdx = entryFieldLines(matchedLines, DEFERRED_BULLET_MARKERS)
|
|
1977
|
+
.findIndex((field) => field?.key === 'status');
|
|
1978
|
+
// Per-line CRLF preservation is the honest in-memory contract. The lines
|
|
1979
|
+
// here are RAW — `audit acknowledge` hands this function the `readFileSync`
|
|
1980
|
+
// content of an on-disk `deferred-items.md`, and `_normalizeMd` runs only on
|
|
1981
|
+
// WRITE — so on a CRLF document every line but the span's last still carries
|
|
1982
|
+
// its `\r`. The write path's whole-file normalization still decides what
|
|
1983
|
+
// reaches disk; this function does not duplicate that decision, and it no
|
|
1984
|
+
// longer silently drops the `\r` either. (Round 2's comment here argued the
|
|
1985
|
+
// opposite contract. It is withdrawn: #3773 documents per-line preservation
|
|
1986
|
+
// in this same function, and two opposite contracts in one function was a
|
|
1987
|
+
// round-3 blocker in its own right.)
|
|
950
1988
|
let newMatchedLines;
|
|
951
1989
|
if (statusLineIdx === -1) {
|
|
952
|
-
const bulletIndentMatch = matchedLines[0].match(
|
|
953
|
-
|
|
1990
|
+
const bulletIndentMatch = matchedLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip);
|
|
1991
|
+
// The entry's own indent CHARACTERS, never a count of them — a tab counted
|
|
1992
|
+
// as one column and re-emitted as one space puts a 3-space continuation
|
|
1993
|
+
// under a tab-indented bullet. Identical output for all-space indents.
|
|
1994
|
+
const continuationIndent = `${bulletIndentMatch ? bulletIndentMatch[1] : ''} `;
|
|
1995
|
+
// The new line goes right after line 0, so line 0 stops being the span's
|
|
1996
|
+
// last line. Under CRLF the span's last line is the one WITHOUT a `\r`
|
|
1997
|
+
// (the file's own `\r\n` follows the span), so the ending is read from the
|
|
1998
|
+
// entry's OWN boundary and never sniffed from the whole document — a
|
|
1999
|
+
// mixed-ending file must keep its LF opener. At end-of-file there is no
|
|
2000
|
+
// following separator, so the boundary immediately PRECEDING the entry is
|
|
2001
|
+
// the remaining local evidence; an entry at offset 0 has neither and stays
|
|
2002
|
+
// LF rather than inventing an ending from nothing.
|
|
2003
|
+
const line0HadCr = matchedLines[0].endsWith('\r');
|
|
2004
|
+
// A single-line entry's line 0 IS the span's last line, so its own
|
|
2005
|
+
// terminator sits OUTSIDE the span and the separator FOLLOWING the span is
|
|
2006
|
+
// the evidence. At end-of-file there is no such separator, and reading its
|
|
2007
|
+
// absence as "not CRLF" is what joined a CRLF entry to its inserted line
|
|
2008
|
+
// with a bare `\n`. `crlfAtEof` answers the weaker question that remains,
|
|
2009
|
+
// over the entry's own SECTION when one is delimited and the entry list
|
|
2010
|
+
// alone when none is — widening to everything before the insertion point
|
|
2011
|
+
// only where that region is empty, which is #3773's B4. See its doc comment.
|
|
2012
|
+
const spanEnd = matchIndexInContent + (end - start);
|
|
2013
|
+
const eofScope = sectionBody.slice(deferredSection ? 0 : entries[0].start, start)
|
|
2014
|
+
|| sectionBody.slice(0, start);
|
|
2015
|
+
const crlf = matchedLines.length > 1
|
|
2016
|
+
? line0HadCr
|
|
2017
|
+
: content.startsWith('\r\n', spanEnd)
|
|
2018
|
+
|| (spanEnd >= content.length && crlfAtEof(eofScope));
|
|
954
2019
|
newMatchedLines = [
|
|
955
|
-
matchedLines[0],
|
|
956
|
-
`${continuationIndent}status: acknowledged`,
|
|
2020
|
+
crlf ? `${matchedLines[0].replace(/\r$/, '')}\r` : matchedLines[0],
|
|
2021
|
+
`${continuationIndent}status: acknowledged${line0HadCr ? '\r' : ''}`,
|
|
957
2022
|
...matchedLines.slice(1),
|
|
958
2023
|
];
|
|
959
2024
|
}
|
|
960
2025
|
else {
|
|
961
|
-
|
|
962
|
-
|
|
2026
|
+
// Rewrite at the offset the CLASSIFIER reported, rather than through a
|
|
2027
|
+
// second regex of the writer's own. This is what makes the selection and
|
|
2028
|
+
// the rewrite structurally incapable of disagreeing: a status line the
|
|
2029
|
+
// classifier can select is one whose value offset it has already
|
|
2030
|
+
// computed, so there is no shape it selects and then fails to rewrite.
|
|
2031
|
+
// (A widened classifier over a hyphen-only rewrite regex is exactly that
|
|
2032
|
+
// failure — it would select `* status: open` and hand back the line
|
|
2033
|
+
// untouched.) The key's own spelling and any `**bold**` wrapper survive
|
|
2034
|
+
// because only the value is replaced.
|
|
2035
|
+
const raw = matchedLines[statusLineIdx];
|
|
2036
|
+
const cr = raw.endsWith('\r') ? '\r' : '';
|
|
2037
|
+
const line = raw.slice(0, raw.length - cr.length);
|
|
2038
|
+
const field = parseGapEntryFieldLine(line, DEFERRED_BULLET_MARKERS, statusLineIdx === 0);
|
|
2039
|
+
const prefix = line.slice(0, field.valueStart);
|
|
2040
|
+
// `status:acknowledged` reads back fine, but a bare colon with no
|
|
2041
|
+
// separator is not what this file's convention looks like; supply one only
|
|
2042
|
+
// when the source had none.
|
|
2043
|
+
const sep = /[ \t]$/.test(prefix) ? '' : ' ';
|
|
963
2044
|
newMatchedLines = matchedLines.slice();
|
|
964
|
-
newMatchedLines[statusLineIdx] =
|
|
2045
|
+
newMatchedLines[statusLineIdx] = `${prefix}${sep}acknowledged${cr}`;
|
|
965
2046
|
}
|
|
2047
|
+
// NO post-write read-back guard here, deliberately (round 4, B3). Round 3
|
|
2048
|
+
// added one — `rewrite_not_readable` — after a fenced `status:` line proved
|
|
2049
|
+
// the writer could select a line the reader would not read back. Round 3
|
|
2050
|
+
// then closed that divergence STRUCTURALLY, by routing the writer's line
|
|
2051
|
+
// selection and the reader's field extraction through the one
|
|
2052
|
+
// `entryFieldLines` seam above, and the guard became unreachable from the
|
|
2053
|
+
// public API: 21 document shapes were driven against it (fence openers on
|
|
2054
|
+
// the bullet line for every marker, duplicate and triplicate `status:`
|
|
2055
|
+
// lines, bolded and nested variants, fences between duplicates) and none
|
|
2056
|
+
// reached it.
|
|
2057
|
+
//
|
|
2058
|
+
// An unreachable branch is not free here. This repo's own
|
|
2059
|
+
// `RULESET.TESTS.mutation-score` runs Stryker incrementally over changed
|
|
2060
|
+
// files at an 80% threshold and says to "treat surviving mutant as a failing
|
|
2061
|
+
// test specification"; an undriven `if` is exactly that. The only seam that
|
|
2062
|
+
// would drive it is routing this call through the module's exports so a test
|
|
2063
|
+
// could stub it — production surface reshaped for a test, which is a worse
|
|
2064
|
+
// trade than the guard is worth now that construction, not assertion,
|
|
2065
|
+
// enforces the invariant.
|
|
2066
|
+
//
|
|
2067
|
+
// What that gives up, stated plainly rather than hidden: if a future change
|
|
2068
|
+
// re-splits the writer's selection from the reader's extraction, this
|
|
2069
|
+
// function returns `ok` over an item that stays outstanding — the original
|
|
2070
|
+
// #3702 defect class. `match_verification_failed` does NOT backfill it; that
|
|
2071
|
+
// check runs BEFORE the write and compares the matched span to the target,
|
|
2072
|
+
// so it cannot see a post-write read-back failure. The protection against
|
|
2073
|
+
// re-splitting is the shared seam plus the round-3 tests that pin it, not a
|
|
2074
|
+
// runtime assertion.
|
|
966
2075
|
const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
|
|
967
2076
|
return { content: newContent, status: 'ok' };
|
|
968
2077
|
}
|
|
969
2078
|
/**
|
|
970
|
-
*
|
|
971
|
-
*
|
|
972
|
-
*
|
|
973
|
-
*
|
|
974
|
-
*
|
|
975
|
-
*/
|
|
976
|
-
function
|
|
977
|
-
|
|
978
|
-
|
|
979
|
-
|
|
980
|
-
|
|
981
|
-
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
*
|
|
986
|
-
*
|
|
987
|
-
*
|
|
988
|
-
*
|
|
989
|
-
*
|
|
990
|
-
*
|
|
991
|
-
*
|
|
992
|
-
*
|
|
993
|
-
*
|
|
994
|
-
*
|
|
995
|
-
*
|
|
996
|
-
*
|
|
997
|
-
*
|
|
998
|
-
*
|
|
999
|
-
*
|
|
1000
|
-
*
|
|
1001
|
-
*
|
|
1002
|
-
|
|
1003
|
-
|
|
1004
|
-
|
|
1005
|
-
|
|
1006
|
-
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
|
|
1011
|
-
|
|
2079
|
+
* #3781 — strip an ATX heading prefix, mirroring `tokenizeHeadings`' own ATX
|
|
2080
|
+
* regex (≤3 leading spaces, 1–6 `#`, space/tab separator, optional closing
|
|
2081
|
+
* `#` sequence) so the raw heading line reconciles byte-exactly with the
|
|
2082
|
+
* hash-stripped `text` the reader exposes. Returns null when the line is not
|
|
2083
|
+
* an ATX heading line.
|
|
2084
|
+
*/
|
|
2085
|
+
function stripAtxPrefix(line) {
|
|
2086
|
+
const m = /^( {0,3})(#{1,6})([ \t]+.*|[ \t]*)?$/.exec(line.replace(/\r$/, ''));
|
|
2087
|
+
if (!m)
|
|
2088
|
+
return null;
|
|
2089
|
+
return m[3] === undefined
|
|
2090
|
+
? ''
|
|
2091
|
+
: m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
|
|
2092
|
+
}
|
|
2093
|
+
/**
|
|
2094
|
+
* #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
|
|
2095
|
+
* headless path's guards (not_found / ambiguous / already_resolved /
|
|
2096
|
+
* match_verification_failed) and its rewrite/insert machinery, with the two
|
|
2097
|
+
* shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
|
|
2098
|
+
* status search incl. the leaf line-0 ATX corner; insert after the entry's
|
|
2099
|
+
* last non-blank line). Extracted so the headless path stays byte-identical.
|
|
2100
|
+
*
|
|
2101
|
+
* Every question about an entry is asked of the READER'S OWN answer (#3702
|
|
2102
|
+
* round 3, B1/B3 — restated here rather than re-implemented): identity is
|
|
2103
|
+
* `rawGapEntryText` over the walk's lines and opener flags, exactly as
|
|
2104
|
+
* `parseDeferredItemsWithStatus` names the entry; the status line is whichever
|
|
2105
|
+
* line `entryFieldLines` classifies as `status`, so a fenced `status:` or a
|
|
2106
|
+
* rejected-ordinal prose line is never selected; and the rewrite lands at the
|
|
2107
|
+
* offset the classifier reported. Upstream's #3781 carried its own
|
|
2108
|
+
* hyphen-only walk and its own status regexes for this shape; under the
|
|
2109
|
+
* widened marker grammar those would have read a different entry set than
|
|
2110
|
+
* the reader and re-opened the writer/reader drift this PR closes.
|
|
2111
|
+
*/
|
|
2112
|
+
function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }) {
|
|
2113
|
+
const matches = headingEntries.filter((e) => rawGapEntryText(e.lines, DEFERRED_BULLET_MARKERS, e.opener) === targetText);
|
|
2114
|
+
if (matches.length === 0)
|
|
2115
|
+
return { content, status: 'not_found' };
|
|
2116
|
+
if (matches.length > 1)
|
|
2117
|
+
return { content, status: 'ambiguous' };
|
|
2118
|
+
const entry = matches[0];
|
|
2119
|
+
// A table row inside the span: the walk skipped it, so `lines` is not 1:1
|
|
2120
|
+
// with the raw slice and no write can be anchored. Refuse, as before #3781.
|
|
2121
|
+
if (entry.embeddedTable)
|
|
2122
|
+
return { content, status: 'unsupported_heading_shape' };
|
|
2123
|
+
const fields = extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener);
|
|
2124
|
+
if (fields.status && fields.status.toLowerCase() === 'resolved') {
|
|
2125
|
+
return { content, status: 'already_resolved' };
|
|
2126
|
+
}
|
|
2127
|
+
const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
|
|
2128
|
+
const rawLines = sectionBody.slice(entry.start, entry.end).split('\n');
|
|
2129
|
+
// The reader-form of the raw slice, index-aligned with it: a leaf's line 0
|
|
2130
|
+
// is the heading TEXT (re-derived from the span's own bytes, not copied
|
|
2131
|
+
// from the walk, so the verification below is genuine), every other line
|
|
2132
|
+
// CR-stripped. Markers stay on the lines — the classifier strips them per
|
|
2133
|
+
// the opener flags, exactly as the reader does.
|
|
2134
|
+
const readerLines = rawLines.map((raw, i) => {
|
|
2135
|
+
const line = raw.replace(/\r$/, '');
|
|
2136
|
+
return i === 0 && entry.kind === 'leaf' ? (stripAtxPrefix(line) ?? line) : line;
|
|
2137
|
+
});
|
|
2138
|
+
// Genuine invariant re-verification: the identity re-derived from the
|
|
2139
|
+
// span's bytes must be the identity that selected the entry — the span was
|
|
2140
|
+
// recorded by offset bookkeeping independent of that comparison.
|
|
2141
|
+
if (readerLines.length !== entry.lines.length
|
|
2142
|
+
|| rawGapEntryText(readerLines, DEFERRED_BULLET_MARKERS, entry.opener) !== targetText) {
|
|
2143
|
+
return { content, status: 'match_verification_failed' };
|
|
2144
|
+
}
|
|
2145
|
+
const statusLineIdx = entryFieldLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener)
|
|
2146
|
+
.findIndex((field) => field?.key === 'status');
|
|
2147
|
+
let newRawLines;
|
|
2148
|
+
if (statusLineIdx === -1) {
|
|
2149
|
+
// Insert branch: after the entry's LAST NON-BLANK line — a heading
|
|
2150
|
+
// entry's body is frequently a soft-wrapped sentence, and splicing after
|
|
2151
|
+
// line 0 would split it in half (#3781's sentence trap). The headless
|
|
2152
|
+
// (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
|
|
2153
|
+
// …and never INSIDE a fence (round 5, RV6.5 review): an entry whose body
|
|
2154
|
+
// ends in a fenced block — closed or, worse, unclosed and so running to
|
|
2155
|
+
// the entry's end — would otherwise receive its marker as fence content,
|
|
2156
|
+
// a line the reader never reads: `ok` returned, item still outstanding.
|
|
2157
|
+
// Walk back over blank and fenced lines alike, classified exactly as the
|
|
2158
|
+
// reader classifies them, so the marker lands on a line the reader reads.
|
|
2159
|
+
const fencedInEntry = entryFencedLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener);
|
|
2160
|
+
let last = rawLines.length - 1;
|
|
2161
|
+
while (last > 0 && (readerLines[last].trim() === '' || fencedInEntry.has(last)))
|
|
2162
|
+
last--;
|
|
2163
|
+
// A pending entry's continuation sits two columns inside its own marker
|
|
2164
|
+
// indent (the entry's indent CHARACTERS, as the headless path does); a
|
|
2165
|
+
// leaf's body lines are sibling bullets, and an indented bare field line
|
|
2166
|
+
// among them is what the reader reads on that shape.
|
|
2167
|
+
const indent = entry.kind === 'pending'
|
|
2168
|
+
? `${rawLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip)?.[1] ?? ''} `
|
|
2169
|
+
: ' ';
|
|
2170
|
+
// The inserted line copies the ending of the line it follows. When that
|
|
2171
|
+
// line is the span's LAST, its terminator sits outside the span: the
|
|
2172
|
+
// separator following the span decides, else (end of file) the section's
|
|
2173
|
+
// own evidence — the same rule the headless path applies to line 0.
|
|
2174
|
+
const followsLast = last === rawLines.length - 1;
|
|
2175
|
+
const spanEnd = sectionOffset + entry.end;
|
|
2176
|
+
const prevCr = followsLast
|
|
2177
|
+
? content.startsWith('\r\n', spanEnd) || (spanEnd >= content.length && crlfAtEof(sectionBody.slice(0, entry.start)))
|
|
2178
|
+
: rawLines[last].endsWith('\r');
|
|
2179
|
+
newRawLines = rawLines.slice();
|
|
2180
|
+
if (followsLast && prevCr)
|
|
2181
|
+
newRawLines[last] = `${rawLines[last].replace(/\r$/, '')}\r`;
|
|
2182
|
+
newRawLines.splice(last + 1, 0, `${indent}status: acknowledged${!followsLast && prevCr ? '\r' : ''}`);
|
|
2183
|
+
}
|
|
2184
|
+
else {
|
|
2185
|
+
// Rewrite at the offset the CLASSIFIER reported, on the RAW line — the
|
|
2186
|
+
// marker, the indent, the key's spelling and any `**bold**` wrapper all
|
|
2187
|
+
// survive because only the value is replaced. A leaf's line 0 is the
|
|
2188
|
+
// heading line, so its ATX prefix is put back in front of the rewritten
|
|
2189
|
+
// text (the reader reads the heading text itself as the field there).
|
|
2190
|
+
const raw = rawLines[statusLineIdx];
|
|
2191
|
+
const cr = raw.endsWith('\r') ? '\r' : '';
|
|
2192
|
+
const line = raw.slice(0, raw.length - cr.length);
|
|
2193
|
+
const reader = readerLines[statusLineIdx];
|
|
2194
|
+
const field = parseGapEntryFieldLine(reader, DEFERRED_BULLET_MARKERS, stripsMarkerAt(statusLineIdx, entry.opener));
|
|
2195
|
+
const prefix = reader.slice(0, field.valueStart);
|
|
2196
|
+
const sep = /[ \t]$/.test(prefix) ? '' : ' ';
|
|
2197
|
+
const leafLine0 = statusLineIdx === 0 && entry.kind === 'leaf';
|
|
2198
|
+
const atx = leafLine0 ? (/^( {0,3}#{1,6}[ \t]+)/.exec(line)?.[1] ?? '') : '';
|
|
2199
|
+
// A closing `#` sequence is Markdown the reader ignores; keep it (RV6.5).
|
|
2200
|
+
const closing = leafLine0 ? (/[ \t]+#+[ \t]*$/.exec(line)?.[0] ?? '') : '';
|
|
2201
|
+
newRawLines = rawLines.slice();
|
|
2202
|
+
newRawLines[statusLineIdx] = `${atx}${prefix}${sep}acknowledged${closing}${cr}`;
|
|
2203
|
+
}
|
|
2204
|
+
const matchIndexInContent = sectionOffset + entry.start;
|
|
2205
|
+
const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
|
|
2206
|
+
return { content: newContent, status: 'ok' };
|
|
2207
|
+
}
|
|
2208
|
+
/**
|
|
2209
|
+
* Hyphen-only markers — the `## Gaps` form, unchanged by #3702. Gaps entries
|
|
2210
|
+
* come from a template that mandates the hyphen YAML-lite shape, so widening
|
|
2211
|
+
* that section's grammar is not what the deferred-items ruling required; the
|
|
2212
|
+
* shared splitting seam is parameterised rather than widened wholesale so the
|
|
2213
|
+
* Gaps path stays byte-for-byte on its existing behaviour.
|
|
2214
|
+
*/
|
|
2215
|
+
const HYPHEN_BULLET_MARKERS = {
|
|
2216
|
+
open: /^(\s*)(-)\s/,
|
|
2217
|
+
strip: /^(\s*)-\s+(.*)$/,
|
|
2218
|
+
blockStructure: false,
|
|
2219
|
+
};
|
|
2220
|
+
/**
|
|
2221
|
+
* Deferred-items markers (#3702): the standard Markdown list markers, not the
|
|
2222
|
+
* hyphen alone. `deferred-items.md` has NO template and no mandated shape —
|
|
2223
|
+
* executors write it by hand (the same premise that justified the #2766 table
|
|
2224
|
+
* union) — so an author reaching for `*`, `+` or `1.` wrote a list by every
|
|
2225
|
+
* Markdown definition while this parser contributed ZERO entries for it. The
|
|
2226
|
+
* hyphen restriction was a regex literal inherited from the Gaps seam, never a
|
|
2227
|
+
* stated decision: measured in the wild, non-empty records parsed to a clean
|
|
2228
|
+
* zero, and a MIXED file dropped its non-hyphen entries while keeping their
|
|
2229
|
+
* hyphenated siblings — under-reporting without ever looking empty.
|
|
2230
|
+
*
|
|
2231
|
+
* Deliberately NOT widened to prose: "prose is not an item" is this parser's
|
|
2232
|
+
* pre-existing, test-asserted contract (the `# Notes` case) and is untouched
|
|
2233
|
+
* here. An asterisk bullet is not prose, and a `|` row is not a list marker —
|
|
2234
|
+
* table lines are still skipped before the body-bullet flag can be set, so
|
|
2235
|
+
* `parseDeferredTableItems` keeps sole ownership of table bodies and the
|
|
2236
|
+
* #2766 anti-double-count property holds unchanged.
|
|
2237
|
+
*
|
|
2238
|
+
* The paren-terminated ordered form (`1)`) is out of scope for this fix: the
|
|
2239
|
+
* #3702 ruling scopes the widening to `*`, `+` and the dot-terminated ordered
|
|
2240
|
+
* marker.
|
|
2241
|
+
*
|
|
2242
|
+
* `DEFERRED_MARKER_ALT` is THE source every deferred-items marker regex is
|
|
2243
|
+
* built from (#3702 round 2, M3). Since round 3 that is the splitter's
|
|
2244
|
+
* `open`/`strip` pair here and nothing else: `acknowledgeDeferredItem`'s two
|
|
2245
|
+
* status-line shapes used to be derived from it too, and are now deleted in
|
|
2246
|
+
* favour of the reader's classifier. CommonMark
|
|
2247
|
+
* §5.2: bullet markers `-`, `*`, `+`; an ordered marker is 1-9 digits and a
|
|
2248
|
+
* `.` (round-1's `\d+` was uncapped). The marker is followed by a space or a
|
|
2249
|
+
* tab — `[ \t]`, where round 1 wrote `\s`, which also accepted `\r`.
|
|
2250
|
+
* `markdown-sectionizer`'s `iterateBullets` is the repo's other list-marker
|
|
2251
|
+
* grammar; the `#3702 round 2: marker-grammar parity` test pins this one to
|
|
2252
|
+
* it on the shared vocabulary and names the two points they deliberately
|
|
2253
|
+
* differ (tab after the marker, the 9-digit cap).
|
|
2254
|
+
*/
|
|
2255
|
+
const DEFERRED_MARKER_ALT = '(?:[-*+]|\\d{1,9}\\.)';
|
|
2256
|
+
const DEFERRED_BULLET_MARKERS = {
|
|
2257
|
+
open: new RegExp(`^(\\s*)(${DEFERRED_MARKER_ALT})[ \\t]`),
|
|
2258
|
+
strip: new RegExp(`^(\\s*)${DEFERRED_MARKER_ALT}[ \\t]+(.*?)\\r?$`),
|
|
2259
|
+
blockStructure: true,
|
|
2260
|
+
};
|
|
2261
|
+
// `acknowledgeDeferredItem` carries NO status-line regex of its own (#3702
|
|
2262
|
+
// round 3, B1/B3). It used to hold two — a finder and a rewrite — derived
|
|
2263
|
+
// from `DEFERRED_MARKER_ALT` so the two WRITER shapes could not drift from
|
|
2264
|
+
// each other. That kept the wrong pair in step: the finder's peer is the
|
|
2265
|
+
// READER, and widening detection without widening the read is what made a
|
|
2266
|
+
// nested ` * status:` line selectable by the writer and invisible to
|
|
2267
|
+
// `extractGapEntryFields`. Both are gone; the writer now locates its line
|
|
2268
|
+
// through `parseGapEntryFieldLine`, the reader's own classifier, and rewrites
|
|
2269
|
+
// at the offset that classifier reports. See `parseGapEntryFieldLine`.
|
|
2270
|
+
/**
|
|
2271
|
+
* CommonMark §4.1 thematic break: up to 3 spaces of indent, then three or
|
|
2272
|
+
* more of the SAME `-`, `*` or `_`, optionally space/tab-separated, and
|
|
2273
|
+
* nothing else. `- - -`, `* * *` and `+ + +` all also match a list opener —
|
|
2274
|
+
* `- - -` was a phantom `"- -"` entry on base, and #3702's widening added the
|
|
2275
|
+
* other two (#3702 round 2, M1). `+ + +` is not a CommonMark break, but it is
|
|
2276
|
+
* the same authoring gesture and no less garbage as an entry name, so the
|
|
2277
|
+
* class here is "three-or-more of one marker character, nothing else". The
|
|
2278
|
+
* indent is unbounded, not CommonMark's `{0,3}`: this parser reads a list at
|
|
2279
|
+
* any indent (see `#3702 round 2` m1), so a separator drawn at any indent is
|
|
2280
|
+
* a separator too — otherwise ` * * *` is a phantom entry named `* *`.
|
|
2281
|
+
*/
|
|
2282
|
+
const THEMATIC_BREAK_RE = /^[ \t]*([-*+_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
2283
|
+
/**
|
|
2284
|
+
* The deferred grammar's line view for fence classification: the SAME lines,
|
|
2285
|
+
* with leading whitespace removed (#3702 round 4, M2).
|
|
2286
|
+
*
|
|
2287
|
+
* `scanFencedBlocks` is CommonMark, and CommonMark caps a fence delimiter's
|
|
2288
|
+
* indent at three spaces — a fourth makes it an indented code block instead.
|
|
2289
|
+
* The deferred grammar deliberately opted out of that cliff everywhere else:
|
|
2290
|
+
* an entry opener is `[ \t]*`-indented and `THEMATIC_BREAK_RE` is
|
|
2291
|
+
* `^[ \t]*`. Leaving the fence rule at CommonMark's cap while items and
|
|
2292
|
+
* breaks are unbounded is not a conservative choice, it is an inconsistent
|
|
2293
|
+
* one, and it is REACHED BY ORDINARY DOCUMENTS: a fenced block written under a
|
|
2294
|
+
* nested bullet sits at four spaces, so its `status: resolved` line resolved
|
|
2295
|
+
* the entry containing it. That is the #3702 silent-resolution defect class in
|
|
2296
|
+
* a new place — driven, at indents 4, 5, 8 and a leading tab, before this fix.
|
|
2297
|
+
*
|
|
2298
|
+
* Still NO second fence dialect (the rule `blankIndentedFenceDelimiters`
|
|
2299
|
+
* states): the classification is done by `scanFencedBlocks`, the one exported
|
|
2300
|
+
* CommonMark state machine, over a de-indented view. Run lengths, backtick
|
|
2301
|
+
* vs tilde, closer-must-match-and-not-trail and info-string rules are all
|
|
2302
|
+
* still that engine's answers, not re-derived here; the unterminated case is
|
|
2303
|
+
* its answer too, bounded by the walk (round 5, B1 — see `scanFencesFrom`). Indent is the only dimension this hides from it, and it is
|
|
2304
|
+
* the exact dimension the deferred grammar has already declared it does not
|
|
2305
|
+
* measure. Index alignment is 1:1 by construction — `map` preserves length —
|
|
2306
|
+
* so every line index the engine returns still addresses the original line.
|
|
2307
|
+
*
|
|
2308
|
+
* Scope: the deferred grammar only. Both marker-parameterised call sites gate
|
|
2309
|
+
* on `markers.blockStructure`, which the `## Gaps` set does not set, so Gaps
|
|
2310
|
+
* reaches an empty set and is untouched by this — the same opt-out
|
|
2311
|
+
* `indentWidth` documents for the indent half.
|
|
2312
|
+
*/
|
|
2313
|
+
function deindentedForFences(lines) {
|
|
2314
|
+
return lines.map((line) => line.replace(/^[ \t]+/, ''));
|
|
2315
|
+
}
|
|
2316
|
+
/**
|
|
2317
|
+
* Indices (into `lines`) of every line that sits inside a fenced code block,
|
|
2318
|
+
* delimiters included — by the sectionizer's own fence state machine, so a
|
|
2319
|
+
* `~~~` fence, an indented fence and an unterminated fence (runs to the end)
|
|
2320
|
+
* are classified exactly as `stripFencedCode` would (#3702 round 2, M2), at
|
|
2321
|
+
* ANY indent (round 4, M2 — see `deindentedForFences`).
|
|
2322
|
+
* #3702's wild records carry reproduction blocks; `+`-prefixed diff lines and
|
|
2323
|
+
* `1.`-numbered steps are their normal content, not entries.
|
|
2324
|
+
*
|
|
2325
|
+
* ENTRY-scoped: `lines` are ONE entry's lines (`entryFieldLines`), so an
|
|
2326
|
+
* unterminated fence "running to the end" runs to the end of that entry —
|
|
2327
|
+
* exactly the bound the section-level walks give it (round 5, B1; see
|
|
2328
|
+
* `scanFencesFrom`). The two classifications agree by construction.
|
|
2329
|
+
*/
|
|
2330
|
+
function fencedLineSet(lines) {
|
|
2331
|
+
const fenced = new Set();
|
|
2332
|
+
for (const block of scanFencedBlocks(deindentedForFences(lines))) {
|
|
2333
|
+
const last = block.closeLineIdx === -1 ? lines.length - 1 : block.closeLineIdx;
|
|
2334
|
+
for (let i = block.openLineIdx; i <= last; i++)
|
|
2335
|
+
fenced.add(i);
|
|
2336
|
+
}
|
|
2337
|
+
return fenced;
|
|
2338
|
+
}
|
|
2339
|
+
function scanFencesFrom(lines, from) {
|
|
2340
|
+
const scan = { fenced: new Set(), openers: new Set(), unterminatedFrom: -1 };
|
|
2341
|
+
for (const block of scanFencedBlocks(deindentedForFences(lines.slice(from)))) {
|
|
2342
|
+
const open = block.openLineIdx + from;
|
|
2343
|
+
scan.openers.add(open);
|
|
2344
|
+
if (block.closeLineIdx === -1) {
|
|
2345
|
+
scan.unterminatedFrom = open; // always the scan's last block
|
|
2346
|
+
break;
|
|
2347
|
+
}
|
|
2348
|
+
for (let i = open; i <= block.closeLineIdx + from; i++)
|
|
2349
|
+
scan.fenced.add(i);
|
|
2350
|
+
}
|
|
2351
|
+
return scan;
|
|
2352
|
+
}
|
|
2353
|
+
/** The scan a grammar without block structure (`## Gaps`) walks under: nothing is fenced. Never mutated. */
|
|
2354
|
+
const NO_FENCES = { fenced: new Set(), openers: new Set(), unterminatedFrom: -1 };
|
|
2355
|
+
/**
|
|
2356
|
+
* Does `line` LOOK like a top-level list item under `markers` — a marker at
|
|
2357
|
+
* or above the base indent, start value ignored? The bound an unterminated
|
|
2358
|
+
* fence runs to (round 5, B1; see `scanFencesFrom`). Shape rather than the
|
|
2359
|
+
* ordered-start rule, because the list memory inside a fence is not evidence
|
|
2360
|
+
* of anything, and closing a stray fence one line early errs in the
|
|
2361
|
+
* surfacing direction.
|
|
2362
|
+
*/
|
|
2363
|
+
function topLevelItemShape(line, markers, baseIndent) {
|
|
2364
|
+
const m = line.match(markers.open);
|
|
2365
|
+
return m !== null && (baseIndent === null || indentWidth(m[1], markers) <= baseIndent);
|
|
2366
|
+
}
|
|
2367
|
+
/**
|
|
2368
|
+
* Per-indent LIST memory (#3702 round 2, round review; widened round 5, M2):
|
|
2369
|
+
* each list level remembers whether a list is OPEN there, so an ordered
|
|
2370
|
+
* marker that does not start at `0.`/`1.` is an item when it continues or
|
|
2371
|
+
* follows a list at its level, and prose otherwise. A new opener at indent
|
|
2372
|
+
* `d` resets every deeper level (a new item starts new sub-lists); a
|
|
2373
|
+
* paragraph after a blank at indent `d` ends the lists at `d` and deeper; a
|
|
2374
|
+
* thematic break or a heading clears everything.
|
|
2375
|
+
*
|
|
2376
|
+
* Round 2 keyed this on whether the previous opener was ORDERED, so a bullet
|
|
2377
|
+
* item closed the run and `1. a` / `- b` / `5. c` folded `5. c` into `b` —
|
|
2378
|
+
* the mixed-file under-report #3702 names as the shape that bites. In
|
|
2379
|
+
* CommonMark `5. c` there opens a fresh ordered list (`start=5`): a non-1
|
|
2380
|
+
* ordinal is refused only where it would INTERRUPT A PARAGRAPH (§5.3), and
|
|
2381
|
+
* after a list item it interrupts nothing. Keying on "a list is open here"
|
|
2382
|
+
* is that rule as far as this parser can state it without a paragraph model.
|
|
2383
|
+
*/
|
|
2384
|
+
class ListRuns {
|
|
2385
|
+
byIndent = new Set();
|
|
2386
|
+
at(indent) { return this.byIndent.has(indent); }
|
|
2387
|
+
opened(indent) {
|
|
2388
|
+
for (const d of [...this.byIndent])
|
|
2389
|
+
if (d > indent)
|
|
2390
|
+
this.byIndent.delete(d);
|
|
2391
|
+
this.byIndent.add(indent);
|
|
2392
|
+
}
|
|
2393
|
+
endedAt(indent) {
|
|
2394
|
+
for (const d of [...this.byIndent])
|
|
2395
|
+
if (d >= indent)
|
|
2396
|
+
this.byIndent.delete(d);
|
|
2397
|
+
}
|
|
2398
|
+
clear() { this.byIndent.clear(); }
|
|
2399
|
+
}
|
|
2400
|
+
/**
|
|
2401
|
+
* Leading-whitespace width of a line in CommonMark COLUMNS (§2.2: a tab
|
|
2402
|
+
* advances to the next multiple of 4), so `\t` and ` ` are different levels
|
|
2403
|
+
* and `\t` equals four spaces — character counting aliased them.
|
|
2404
|
+
*/
|
|
2405
|
+
/**
|
|
2406
|
+
* Indent WIDTH under a grammar (#3702 round 2, review round 6). The deferred
|
|
2407
|
+
* grammar measures CommonMark columns; the Gaps grammar keeps `next`'s raw
|
|
2408
|
+
* character count, because its `blockStructure: false` opt-out promises
|
|
2409
|
+
* byte-for-byte parity and a column measure silently breaks it — a
|
|
2410
|
+
* tab-indented Gaps item followed by a two-space one split into two entries
|
|
2411
|
+
* where `next` folded them into one, and the reverse pair folded where `next`
|
|
2412
|
+
* split. The opt-out now covers indent semantics, not only fences and breaks.
|
|
2413
|
+
*/
|
|
2414
|
+
function indentWidth(indent, markers) {
|
|
2415
|
+
return markers.blockStructure ? indentOf(indent) : indent.length;
|
|
2416
|
+
}
|
|
2417
|
+
function indentOf(line) {
|
|
2418
|
+
let col = 0;
|
|
2419
|
+
for (const ch of line) {
|
|
2420
|
+
if (ch === ' ')
|
|
2421
|
+
col += 1;
|
|
2422
|
+
else if (ch === '\t')
|
|
2423
|
+
col += 4 - (col % 4);
|
|
2424
|
+
else
|
|
2425
|
+
break;
|
|
2426
|
+
}
|
|
2427
|
+
return col;
|
|
2428
|
+
}
|
|
2429
|
+
/**
|
|
2430
|
+
* Classify `line` as a list-item opener under `markers`, applying the
|
|
2431
|
+
* ORDERED-START rule (#3702 round 2, B2; round 5, M1/M2): a dot-terminated
|
|
2432
|
+
* ordered marker opens an item when it starts at `0.` or `1.` (`01.`
|
|
2433
|
+
* included), or when a list is already open at its level (`inList` — the
|
|
2434
|
+
* caller's per-indent memory).
|
|
2435
|
+
*
|
|
2436
|
+
* Why: `\d{1,9}\.` alone reads ordinary prose as a list. "2026. was a bad
|
|
2437
|
+
* year for this module" and, under a `### Notes` heading, "3. is the number
|
|
2438
|
+
* of retries we settled on." are both sentences, and both opened an entry on
|
|
2439
|
+
* round 1 — the second one straight through the "prose is not an item"
|
|
2440
|
+
* contract that round claimed to preserve. CommonMark §5.3 faces the same
|
|
2441
|
+
* ambiguity when an ordered list would interrupt a paragraph and resolves it
|
|
2442
|
+
* the same way: the list must start with 1. This parser has no paragraph
|
|
2443
|
+
* model, so it applies that rule wherever NO list is open at the line's
|
|
2444
|
+
* level — the positions a sentence can occupy. Where a list IS open,
|
|
2445
|
+
* CommonMark accepts any start (a list item interrupts no paragraph), and so
|
|
2446
|
+
* does this. Numbers after the first are ignored, as CommonMark ignores
|
|
2447
|
+
* them, so `1. / 3. / 7.` is a three-item run.
|
|
2448
|
+
*
|
|
2449
|
+
* `0.` is accepted as a start (round 5, M1): CommonMark §5.2 permits any
|
|
2450
|
+
* 1-9-digit start number and a `0.`-numbered list is ordinary; refusing it
|
|
2451
|
+
* dropped ONLY the first item, since the run then started at `1.` — the
|
|
2452
|
+
* under-report that looks like a clean parse. A sentence opening with "0."
|
|
2453
|
+
* is not a shape anyone writes.
|
|
2454
|
+
*
|
|
2455
|
+
* Stated cost, pinned by test: a list whose first ordinal is 2 or more, at a
|
|
2456
|
+
* paragraph position, reads as prose UNTIL its first `0.`/`1.` line — the
|
|
2457
|
+
* loss is that prefix, not the whole list. Every ordered record the #3702
|
|
2458
|
+
* scan found starts at 1, and the hyphen-style `- ` alternative loses
|
|
2459
|
+
* nothing, so the trade buys the prose contract back at no measured cost.
|
|
2460
|
+
*
|
|
2461
|
+
* Bullet markers carry no rule — an asterisk bullet is not prose.
|
|
2462
|
+
*/
|
|
2463
|
+
function matchListOpener(line, markers, inList) {
|
|
2464
|
+
const m = line.match(markers.open);
|
|
2465
|
+
if (!m)
|
|
2466
|
+
return null;
|
|
2467
|
+
const token = m[2];
|
|
2468
|
+
if (/^\d/.test(token) && !inList && parseInt(token, 10) > 1)
|
|
2469
|
+
return null;
|
|
2470
|
+
return { indent: indentWidth(m[1], markers) };
|
|
2471
|
+
}
|
|
2472
|
+
/** Character offset of each line's start and end within the text they were split from (on `\n`). */
|
|
2473
|
+
function lineOffsets(lines) {
|
|
2474
|
+
const lineStarts = [];
|
|
2475
|
+
const lineEnds = [];
|
|
2476
|
+
let cursor = 0;
|
|
2477
|
+
for (const line of lines) {
|
|
2478
|
+
lineStarts.push(cursor);
|
|
2479
|
+
cursor += line.length;
|
|
2480
|
+
lineEnds.push(cursor);
|
|
2481
|
+
cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
|
|
2482
|
+
}
|
|
2483
|
+
return { lineStarts, lineEnds };
|
|
2484
|
+
}
|
|
2485
|
+
/**
|
|
2486
|
+
* The heading-delimited split, carrying the per-line opener flags the deferred
|
|
2487
|
+
* field-extraction path needs (#3702 round 2, round review): the heading path
|
|
2488
|
+
* strips the marker off EVERY body line before field extraction (#3457), and a
|
|
2489
|
+
* line whose ordinal `matchListOpener` REJECTED must not be stripped — or
|
|
2490
|
+
* "3. status: resolved" as prose loses its `3. ` and reads as a resolved field.
|
|
2491
|
+
*
|
|
2492
|
+
* Since #3781 it also records each entry's character span (see
|
|
2493
|
+
* `DeferredHeadingEntry`) in this same pass — the reader's walk IS the
|
|
2494
|
+
* writer's walk, so there is no second copy of the grouping rules to drift.
|
|
2495
|
+
*/
|
|
2496
|
+
function splitDeferredHeadingEntriesDetailed(sectionBody) {
|
|
1012
2497
|
const headings = tokenizeHeadings(sectionBody);
|
|
1013
2498
|
if (headings.length === 0)
|
|
1014
2499
|
return null;
|
|
1015
2500
|
const lines = sectionBody.split('\n');
|
|
2501
|
+
const { lineStarts, lineEnds } = lineOffsets(lines);
|
|
1016
2502
|
const headingByLine = new Map();
|
|
1017
2503
|
for (let i = 0; i < headings.length; i++) {
|
|
1018
2504
|
// Container iff the next heading is deeper (see doc comment). An empty
|
|
@@ -1022,20 +2508,92 @@ function splitDeferredHeadingEntries(sectionBody) {
|
|
|
1022
2508
|
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
|
|
1023
2509
|
}
|
|
1024
2510
|
const entries = [];
|
|
1025
|
-
|
|
1026
|
-
let
|
|
2511
|
+
// The leaf entry being accumulated, with the raw line range it spans.
|
|
2512
|
+
let current = null;
|
|
1027
2513
|
let currentHasBullet = false;
|
|
2514
|
+
// The headless-shaped region being accumulated (preamble / a container
|
|
2515
|
+
// heading's direct lines): the reader's table-filtered, CR-stripped view,
|
|
2516
|
+
// plus the raw line range it spans.
|
|
2517
|
+
let pending = [];
|
|
2518
|
+
let pendingStartLine = -1;
|
|
2519
|
+
let pendingEndLine = -1;
|
|
2520
|
+
// Table lines are never entry lines; where one sits INSIDE an entry's raw
|
|
2521
|
+
// range, that entry's span is non-contiguous (#3781, `embeddedTable`).
|
|
2522
|
+
const tableLines = [];
|
|
2523
|
+
const tableWithin = (from, to) => tableLines.some((t) => t >= from && t <= to);
|
|
2524
|
+
// List memory for the leaf body being accumulated (#3702 round 2, B2) —
|
|
2525
|
+
// reset at every heading, so `### Notes` + "3. is the number…" is prose
|
|
2526
|
+
// while `### Steps` + "1. do / 2. then" is a list. A blank line then a
|
|
2527
|
+
// non-indented non-list line is a PARAGRAPH, which ends the list
|
|
2528
|
+
// (CommonMark §5.3); a non-indented line with no blank before it is lazy
|
|
2529
|
+
// continuation and keeps it open.
|
|
2530
|
+
const runs = new ListRuns();
|
|
2531
|
+
let blankSeen = false;
|
|
2532
|
+
// Same level rule as the headless splitter: the first opener in a leaf body
|
|
2533
|
+
// sets the base, and every indent at or shallower than it is one level.
|
|
2534
|
+
let bodyBase = null;
|
|
2535
|
+
const levelOf = (line) => {
|
|
2536
|
+
const ind = indentOf(line);
|
|
2537
|
+
return bodyBase !== null && ind <= bodyBase ? bodyBase : ind;
|
|
2538
|
+
};
|
|
2539
|
+
let scan = scanFencesFrom(lines, 0);
|
|
1028
2540
|
const flushCurrent = () => {
|
|
1029
|
-
// Keep the leaf entry only when its body carries a
|
|
2541
|
+
// Keep the leaf entry only when its body carries a list item; the heading
|
|
1030
2542
|
// text line itself (element 0) never counts as one.
|
|
1031
|
-
if (current !== null && currentHasBullet)
|
|
1032
|
-
|
|
2543
|
+
if (current !== null && currentHasBullet) {
|
|
2544
|
+
const table = tableWithin(current.startLine, current.endLine);
|
|
2545
|
+
entries.push({
|
|
2546
|
+
lines: current.lines,
|
|
2547
|
+
opener: current.opener,
|
|
2548
|
+
kind: 'leaf',
|
|
2549
|
+
start: table ? -1 : lineStarts[current.startLine],
|
|
2550
|
+
end: table ? -1 : lineEnds[current.endLine],
|
|
2551
|
+
embeddedTable: table,
|
|
2552
|
+
});
|
|
2553
|
+
}
|
|
1033
2554
|
current = null;
|
|
1034
2555
|
currentHasBullet = false;
|
|
1035
2556
|
};
|
|
1036
2557
|
const flushPending = () => {
|
|
1037
|
-
|
|
2558
|
+
if (pendingStartLine !== -1) {
|
|
2559
|
+
// Headless-region entries carry the splitter's own opener flags — the
|
|
2560
|
+
// same run state (ordered start, paragraph reset) that split them. The
|
|
2561
|
+
// region is contiguous (a heading flushes it), so the core's
|
|
2562
|
+
// region-relative spans translate by the region's own offset — unless a
|
|
2563
|
+
// table row was skipped inside it, where the reader's view and the raw
|
|
2564
|
+
// region disagree and no span is claimed. Both views split identically
|
|
2565
|
+
// otherwise: the core CR-strips per line, and a table row is the only
|
|
2566
|
+
// line the reader's view omits.
|
|
2567
|
+
const table = tableWithin(pendingStartLine, pendingEndLine);
|
|
2568
|
+
const region = table ? pending.join('\n') : lines.slice(pendingStartLine, pendingEndLine + 1).join('\n');
|
|
2569
|
+
const base = lineStarts[pendingStartLine];
|
|
2570
|
+
for (const { lines: entryLines, opener, start, end } of splitGapsEntriesCore(region, DEFERRED_BULLET_MARKERS)) {
|
|
2571
|
+
entries.push({
|
|
2572
|
+
lines: entryLines,
|
|
2573
|
+
opener,
|
|
2574
|
+
kind: 'pending',
|
|
2575
|
+
start: table ? -1 : base + start,
|
|
2576
|
+
end: table ? -1 : base + end,
|
|
2577
|
+
embeddedTable: table,
|
|
2578
|
+
});
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
1038
2581
|
pending = [];
|
|
2582
|
+
pendingStartLine = -1;
|
|
2583
|
+
pendingEndLine = -1;
|
|
2584
|
+
};
|
|
2585
|
+
const push = (line, i, opener) => {
|
|
2586
|
+
if (current !== null) {
|
|
2587
|
+
current.lines.push(line);
|
|
2588
|
+
current.opener.push(opener);
|
|
2589
|
+
current.endLine = i;
|
|
2590
|
+
}
|
|
2591
|
+
else {
|
|
2592
|
+
pending.push(line);
|
|
2593
|
+
if (pendingStartLine === -1)
|
|
2594
|
+
pendingStartLine = i;
|
|
2595
|
+
pendingEndLine = i;
|
|
2596
|
+
}
|
|
1039
2597
|
};
|
|
1040
2598
|
for (let i = 0; i < lines.length; i++) {
|
|
1041
2599
|
const lineNo = i + 1;
|
|
@@ -1046,23 +2604,73 @@ function splitDeferredHeadingEntries(sectionBody) {
|
|
|
1046
2604
|
// ANY heading; flushing here keeps entries in document order even when
|
|
1047
2605
|
// a container's direct bullets precede its first child entry.
|
|
1048
2606
|
flushPending();
|
|
2607
|
+
runs.clear();
|
|
2608
|
+
blankSeen = false;
|
|
2609
|
+
bodyBase = null;
|
|
2610
|
+
// A heading ends the entry, and with it any unterminated fence (B1).
|
|
2611
|
+
scan = scanFencesFrom(lines, i + 1);
|
|
1049
2612
|
if (!heading.isContainer) {
|
|
1050
2613
|
// Leaf heading: open an entry with the heading text as line 0.
|
|
1051
|
-
current = [heading.text];
|
|
2614
|
+
current = { lines: [heading.text], opener: [false], startLine: i, endLine: i };
|
|
1052
2615
|
currentHasBullet = false;
|
|
1053
2616
|
}
|
|
1054
2617
|
continue;
|
|
1055
2618
|
}
|
|
2619
|
+
// CR-strip ONCE and carry the stripped line everywhere below — into the
|
|
2620
|
+
// entry itself included (#3702 round 2, B1). `collectSection` slices raw
|
|
2621
|
+
// `\n`-split lines, so on a CRLF file every body line but the last still
|
|
2622
|
+
// carries its `\r`; the per-line marker strip feeding field extraction is
|
|
2623
|
+
// `$`-anchored and fails on such a line, the marker survives into
|
|
2624
|
+
// `extractGapEntryFields`, and the field is silently lost — a `**Status:**`
|
|
2625
|
+
// that is not the file's final line then resurfaces its entry as open.
|
|
2626
|
+
// The headless path (`splitGapsEntriesCore`) already stores stripped lines.
|
|
2627
|
+
const line = lines[i].replace(/\r$/, '');
|
|
2628
|
+
// B1: an unterminated fence runs to the end of its entry. The next line
|
|
2629
|
+
// shaped like a top-level item ends it — rescan from there, so a later
|
|
2630
|
+
// delimiter is read on its own terms (see `scanFencesFrom`).
|
|
2631
|
+
if (scan.unterminatedFrom !== -1 && i > scan.unterminatedFrom && topLevelItemShape(line, DEFERRED_BULLET_MARKERS, bodyBase)) {
|
|
2632
|
+
scan = scanFencesFrom(lines, i);
|
|
2633
|
+
}
|
|
2634
|
+
if (scan.fenced.has(i) || (scan.unterminatedFrom !== -1 && i >= scan.unterminatedFrom)) {
|
|
2635
|
+
// Fence content is body text, never list-item evidence (M2) — and
|
|
2636
|
+
// never an opener, so it is never marker-stripped for fields either.
|
|
2637
|
+
// Its opener ends the runs at its level and deeper, as a paragraph does.
|
|
2638
|
+
if (scan.openers.has(i))
|
|
2639
|
+
runs.endedAt(levelOf(line));
|
|
2640
|
+
push(line, i, false);
|
|
2641
|
+
continue;
|
|
2642
|
+
}
|
|
2643
|
+
// A thematic break is a separator: not evidence, and it clears the list
|
|
2644
|
+
// memory (M1). It stays a BODY line — the entry's span must stay
|
|
2645
|
+
// contiguous for the writer, and the entry's name stays what `next`
|
|
2646
|
+
// reported for a body containing one (round 5, m3).
|
|
2647
|
+
if (THEMATIC_BREAK_RE.test(line)) {
|
|
2648
|
+
runs.clear();
|
|
2649
|
+
blankSeen = false;
|
|
2650
|
+
push(line, i, false);
|
|
2651
|
+
continue;
|
|
2652
|
+
}
|
|
1056
2653
|
// Table lines belong to parseDeferredTableItems, never to a heading entry.
|
|
1057
|
-
if (/^\s*\|/.test(
|
|
2654
|
+
if (/^\s*\|/.test(line)) {
|
|
2655
|
+
tableLines.push(i);
|
|
1058
2656
|
continue;
|
|
2657
|
+
}
|
|
1059
2658
|
if (current !== null) {
|
|
1060
|
-
|
|
1061
|
-
|
|
2659
|
+
const opener = matchListOpener(line, DEFERRED_BULLET_MARKERS, runs.at(levelOf(line)));
|
|
2660
|
+
push(line, i, opener !== null);
|
|
2661
|
+
if (opener !== null) {
|
|
1062
2662
|
currentHasBullet = true;
|
|
2663
|
+
if (bodyBase === null)
|
|
2664
|
+
bodyBase = opener.indent;
|
|
2665
|
+
runs.opened(levelOf(line));
|
|
2666
|
+
}
|
|
2667
|
+
else if (blankSeen && line.trim() !== '') {
|
|
2668
|
+
runs.endedAt(levelOf(line)); // a paragraph after a blank line ends the lists at its level and deeper
|
|
2669
|
+
}
|
|
2670
|
+
blankSeen = line.trim() === '';
|
|
1063
2671
|
}
|
|
1064
2672
|
else {
|
|
1065
|
-
|
|
2673
|
+
push(line, i, false); // the core derives its own opener verdicts for the region
|
|
1066
2674
|
}
|
|
1067
2675
|
}
|
|
1068
2676
|
flushCurrent();
|
|
@@ -1117,46 +2725,139 @@ function parseDeferredTableItems(sectionBody) {
|
|
|
1117
2725
|
* boundary — a second, independently-written grouping pass is exactly how a
|
|
1118
2726
|
* span-carrying sibling could disagree with the plain-lines version it is
|
|
1119
2727
|
* supposed to be span-annotating.
|
|
2728
|
+
*
|
|
2729
|
+
* `markers` selects the marker set an entry may OPEN with (#3702). It defaults
|
|
2730
|
+
* to the hyphen-only Gaps form, so every pre-existing caller is unaffected;
|
|
2731
|
+
* the deferred-items callers pass `DEFERRED_BULLET_MARKERS`. Parameterising
|
|
2732
|
+
* the shared seam — rather than widening it in place — is what keeps the
|
|
2733
|
+
* template-mandated Gaps grammar out of the deferred-items ruling's blast
|
|
2734
|
+
* radius while still leaving exactly ONE grouping pass in the module.
|
|
1120
2735
|
*/
|
|
1121
|
-
function splitGapsEntriesCore(sectionBody) {
|
|
2736
|
+
function splitGapsEntriesCore(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
|
|
1122
2737
|
const rawLines = sectionBody.split('\n');
|
|
1123
|
-
const lineStarts =
|
|
1124
|
-
const lineEnds = [];
|
|
1125
|
-
let cursor = 0;
|
|
1126
|
-
for (const rawLine of rawLines) {
|
|
1127
|
-
lineStarts.push(cursor);
|
|
1128
|
-
cursor += rawLine.length;
|
|
1129
|
-
lineEnds.push(cursor);
|
|
1130
|
-
cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
|
|
1131
|
-
}
|
|
2738
|
+
const { lineStarts, lineEnds } = lineOffsets(rawLines);
|
|
1132
2739
|
const entries = [];
|
|
1133
2740
|
let current = null;
|
|
1134
2741
|
let currentStartLine = -1;
|
|
1135
2742
|
let currentEndLine = -1;
|
|
1136
2743
|
let baseIndent = null;
|
|
2744
|
+
// List memory per indent (#3702 round 2, B2 + round review; round 5, M2) —
|
|
2745
|
+
// the top level decides entry boundaries; nested levels decide only which
|
|
2746
|
+
// continuation lines count as accepted openers for field stripping.
|
|
2747
|
+
const runs = new ListRuns();
|
|
2748
|
+
// Per-line opener flags for `current`, recorded HERE — the one place the
|
|
2749
|
+
// run state is known — so the heading path's strip-only-openers rule reads
|
|
2750
|
+
// the splitter's own verdict instead of re-deriving it (round review: a
|
|
2751
|
+
// re-derivation without the paragraph reset re-accepted a rejected ordinal).
|
|
2752
|
+
let currentOpeners = [];
|
|
1137
2753
|
const flush = () => {
|
|
1138
2754
|
if (current !== null) {
|
|
1139
|
-
entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
|
2755
|
+
entries.push({ lines: current, opener: currentOpeners, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
|
1140
2756
|
}
|
|
1141
2757
|
};
|
|
2758
|
+
// #3898 (from `next`): a spaced-hyphen thematic break (`- - -`, `- -`,
|
|
2759
|
+
// `- - -`, …) in `## Gaps` is a SEPARATOR, not an entry. The hyphen opener
|
|
2760
|
+
// matches it (hyphen + whitespace), which fabricated a gap named `- -` with
|
|
2761
|
+
// result 'unknown' — an item no edit can clear, because there is no entry,
|
|
2762
|
+
// only the separator the author wrote deliberately. A line whose content
|
|
2763
|
+
// after the opening marker is solely hyphens and spaces (with at least one
|
|
2764
|
+
// further hyphen) is skipped: it neither opens an entry nor folds into the
|
|
2765
|
+
// current one. Deliberately NOT a full thematic-break concept (option 2 in
|
|
2766
|
+
// the issue): a break does not close the Gaps list — entries after it keep
|
|
2767
|
+
// parsing. The deferred grammar (`blockStructure`) has its own, CommonMark
|
|
2768
|
+
// reading of the same line through THEMATIC_BREAK_RE below, where a break
|
|
2769
|
+
// CLOSES the list; this helper is consulted only for the Gaps set.
|
|
2770
|
+
const isSeparatorShaped = (line, bulletPrefixLen) => {
|
|
2771
|
+
const remainder = line.slice(bulletPrefixLen);
|
|
2772
|
+
return /^[-\s]*$/.test(remainder) && remainder.includes('-');
|
|
2773
|
+
};
|
|
2774
|
+
// Block structure (M1/M2 + column indents) is a property of the GRAMMAR,
|
|
2775
|
+
// not of this seam: the Gaps set opts out and stays byte-for-byte on its
|
|
2776
|
+
// `next` behaviour — see `indentWidth` for the indent half of that opt-out.
|
|
2777
|
+
let scan = markers.blockStructure ? scanFencesFrom(rawLines, 0) : NO_FENCES;
|
|
2778
|
+
let blankSeen = false;
|
|
2779
|
+
// The run LEVEL of a line: every indent at or shallower than the list's
|
|
2780
|
+
// base is the one top level (a dedenting list keeps its entry boundaries);
|
|
2781
|
+
// deeper indents are their own nested levels.
|
|
2782
|
+
const levelOf = (line) => {
|
|
2783
|
+
const ind = indentWidth(line.match(/^[ \t]*/)[0], markers);
|
|
2784
|
+
return baseIndent !== null && ind <= baseIndent ? baseIndent : ind;
|
|
2785
|
+
};
|
|
1142
2786
|
rawLines.forEach((rawLine, idx) => {
|
|
1143
2787
|
const line = rawLine.replace(/\r$/, '');
|
|
1144
|
-
|
|
1145
|
-
|
|
1146
|
-
|
|
2788
|
+
// B1: an unterminated fence runs to the end of its entry — the next line
|
|
2789
|
+
// shaped like a top-level item ends it; rescan from there so a later
|
|
2790
|
+
// delimiter is read on its own terms (see `scanFencesFrom`).
|
|
2791
|
+
if (scan.unterminatedFrom !== -1 && idx > scan.unterminatedFrom && topLevelItemShape(line, markers, baseIndent)) {
|
|
2792
|
+
scan = scanFencesFrom(rawLines, idx);
|
|
2793
|
+
}
|
|
2794
|
+
if (scan.fenced.has(idx) || (scan.unterminatedFrom !== -1 && idx >= scan.unterminatedFrom)) {
|
|
2795
|
+
// Fence content never opens an entry (M2). Inside an open entry it is
|
|
2796
|
+
// continuation — pushed, so the span invariant `acknowledgeDeferredItem`
|
|
2797
|
+
// re-verifies still holds; before the first entry it is discarded. A
|
|
2798
|
+
// fence is a non-list block: its opener ends the runs at its level and
|
|
2799
|
+
// deeper, exactly as a paragraph does.
|
|
2800
|
+
if (scan.openers.has(idx))
|
|
2801
|
+
runs.endedAt(levelOf(line));
|
|
2802
|
+
if (current !== null) {
|
|
2803
|
+
current.push(line);
|
|
2804
|
+
currentOpeners.push(false);
|
|
2805
|
+
currentEndLine = idx;
|
|
2806
|
+
}
|
|
2807
|
+
return;
|
|
2808
|
+
}
|
|
2809
|
+
if (markers.blockStructure && THEMATIC_BREAK_RE.test(line)) {
|
|
2810
|
+
// A thematic break closes the list (M1): the open entry ends here, the
|
|
2811
|
+
// break itself is neither an item nor a continuation, and nothing after
|
|
2812
|
+
// it joins the closed entry — the next opener starts fresh.
|
|
2813
|
+
flush();
|
|
2814
|
+
current = null;
|
|
2815
|
+
runs.clear();
|
|
2816
|
+
blankSeen = false;
|
|
2817
|
+
return;
|
|
2818
|
+
}
|
|
2819
|
+
// #3898 narrowed skip (review disposition a), Gaps set only: a
|
|
2820
|
+
// separator-shaped line is skipped when it sits BETWEEN entries (nothing
|
|
2821
|
+
// open yet, or it would open a top-level entry — where the phantom came
|
|
2822
|
+
// from). One landing strictly INSIDE a live entry (indent > baseIndent)
|
|
2823
|
+
// folds back as a continuation line, so the entry's GapsEntrySpan stays
|
|
2824
|
+
// byte-contiguous — the span invariant below and the ack writer's identity
|
|
2825
|
+
// re-verification both hold. The indent compare is the raw character
|
|
2826
|
+
// count, which is what `indentWidth` measures for the Gaps set.
|
|
2827
|
+
if (!markers.blockStructure) {
|
|
2828
|
+
const bulletMatch = line.match(/^(\s*)-\s/);
|
|
2829
|
+
if (bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
|
|
2830
|
+
(current === null || bulletMatch[1].length <= (baseIndent ?? 0))) {
|
|
2831
|
+
return; // separator line between entries — neither an opener nor a continuation
|
|
2832
|
+
}
|
|
2833
|
+
}
|
|
2834
|
+
const opener = matchListOpener(line, markers, runs.at(levelOf(line)));
|
|
2835
|
+
if (opener !== null) {
|
|
2836
|
+
const { indent } = opener;
|
|
1147
2837
|
if (baseIndent === null)
|
|
1148
2838
|
baseIndent = indent;
|
|
2839
|
+
runs.opened(levelOf(line));
|
|
1149
2840
|
if (indent <= baseIndent) {
|
|
1150
2841
|
flush();
|
|
1151
2842
|
current = [line];
|
|
2843
|
+
currentOpeners = [true];
|
|
1152
2844
|
currentStartLine = idx;
|
|
1153
2845
|
currentEndLine = idx;
|
|
2846
|
+
blankSeen = false; // an opener is not blank — the memory must not survive it
|
|
1154
2847
|
return;
|
|
1155
2848
|
}
|
|
1156
2849
|
}
|
|
1157
2850
|
if (current !== null) {
|
|
1158
2851
|
current.push(line);
|
|
2852
|
+
currentOpeners.push(opener !== null);
|
|
1159
2853
|
currentEndLine = idx;
|
|
2854
|
+
// A blank line then a top-level non-list line is a PARAGRAPH: the list
|
|
2855
|
+
// is over (CommonMark §5.3) and a later `5. x` is prose. Without the
|
|
2856
|
+
// blank it is lazy continuation and the run stays open.
|
|
2857
|
+
const blank = line.trim() === '';
|
|
2858
|
+
if (!blank && blankSeen && opener === null)
|
|
2859
|
+
runs.endedAt(levelOf(line));
|
|
2860
|
+
blankSeen = opener === null && blank;
|
|
1160
2861
|
}
|
|
1161
2862
|
// else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
|
|
1162
2863
|
});
|
|
@@ -1165,7 +2866,8 @@ function splitGapsEntriesCore(sectionBody) {
|
|
|
1165
2866
|
}
|
|
1166
2867
|
/**
|
|
1167
2868
|
* Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
|
|
1168
|
-
* `- `
|
|
2869
|
+
* bullet openers — `- ` for Gaps, or whichever set `markers` names (#3702:
|
|
2870
|
+
* the deferred-items callers pass the widened CommonMark set).
|
|
1169
2871
|
*
|
|
1170
2872
|
* The indentation of the FIRST bullet line encountered establishes the
|
|
1171
2873
|
* "top-level" indent for the whole section; any subsequent `- `-opening line
|
|
@@ -1178,16 +2880,17 @@ function splitGapsEntriesCore(sectionBody) {
|
|
|
1178
2880
|
*
|
|
1179
2881
|
* Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
|
|
1180
2882
|
* the template emits) are discarded. An empty/whitespace-only section body
|
|
1181
|
-
* (heading present, no bullets) returns `[]`.
|
|
2883
|
+
* (heading present, no bullets) returns `[]`. Fenced code never opens an
|
|
2884
|
+
* entry and a thematic break closes the open one (#3702 round 2, M1/M2).
|
|
1182
2885
|
*/
|
|
1183
|
-
function splitGapsEntries(sectionBody) {
|
|
1184
|
-
return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
|
|
2886
|
+
function splitGapsEntries(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
|
|
2887
|
+
return splitGapsEntriesCore(sectionBody, markers).map((entry) => entry.lines);
|
|
1185
2888
|
}
|
|
1186
2889
|
/**
|
|
1187
2890
|
* Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
|
|
1188
2891
|
* carries each entry's character span — every existing `splitGapsEntries`
|
|
1189
2892
|
* caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
|
|
1190
|
-
* `
|
|
2893
|
+
* `splitDeferredHeadingEntriesDetailed`'s `flushPending`) is unaffected and keeps
|
|
1191
2894
|
* using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
|
|
1192
2895
|
* caller that needs a span: it used to select an entry via `splitGapsEntries`
|
|
1193
2896
|
* and then RE-FIND that entry's location with a fresh regex search over
|
|
@@ -1199,8 +2902,8 @@ function splitGapsEntries(sectionBody) {
|
|
|
1199
2902
|
* one. Carrying the span out of THIS same pass — the one that already knows
|
|
1200
2903
|
* exactly where the entry lives — removes the re-derivation step entirely.
|
|
1201
2904
|
*/
|
|
1202
|
-
function splitGapsEntriesWithSpans(sectionBody) {
|
|
1203
|
-
return splitGapsEntriesCore(sectionBody);
|
|
2905
|
+
function splitGapsEntriesWithSpans(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
|
|
2906
|
+
return splitGapsEntriesCore(sectionBody, markers);
|
|
1204
2907
|
}
|
|
1205
2908
|
/**
|
|
1206
2909
|
* Extract `key: value` fields from one Gaps entry's lines, anchored to the
|
|
@@ -1228,182 +2931,603 @@ function splitGapsEntriesWithSpans(sectionBody) {
|
|
|
1228
2931
|
* keep their literal case, and mid-line emphasis is untouched, preserving the
|
|
1229
2932
|
* start-anchored decoy invariant above.
|
|
1230
2933
|
*/
|
|
1231
|
-
function extractGapEntryFields(entryLines) {
|
|
2934
|
+
function extractGapEntryFields(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
|
|
1232
2935
|
const fields = {};
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
2936
|
+
// A fenced line is content, not a field (#3702 round 2, round review): the
|
|
2937
|
+
// splitters already keep fence lines from OPENING an entry, and a
|
|
2938
|
+
// `status: resolved` quoted inside a code block must not resolve one either.
|
|
2939
|
+
// An entry is a contiguous slice and a fence never spans two entries (the
|
|
2940
|
+
// opener of the next entry would be fence content), so scanning the entry's
|
|
2941
|
+
// own lines classifies exactly what the splitter classified.
|
|
2942
|
+
//
|
|
2943
|
+
// RAW lines, and that is the fix for #3702 round 3, m7. The heading path
|
|
2944
|
+
// used to marker-strip its lines BEFORE calling this function, so the scan
|
|
2945
|
+
// below ran over text the splitter never saw: `- ```sh` is an ordinary
|
|
2946
|
+
// bullet to the splitter, but strips to ```` ```sh ````, which opens a
|
|
2947
|
+
// fence here that exists in no other pass. A `**Status:** resolved` line
|
|
2948
|
+
// after it was then suppressed as fence content and its resolved entry
|
|
2949
|
+
// resurfaced as open. The stripping now happens INSIDE this function, after
|
|
2950
|
+
// the fence scan, driven by the splitter's own per-line opener verdict.
|
|
2951
|
+
entryFieldLines(entryLines, markers, openerFlags).forEach((field) => {
|
|
2952
|
+
if (!field)
|
|
1246
2953
|
return;
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
1250
|
-
value = value.slice(1, -1);
|
|
1251
|
-
}
|
|
1252
|
-
if (!(key in fields))
|
|
1253
|
-
fields[key] = value;
|
|
2954
|
+
if (!(field.key in fields))
|
|
2955
|
+
fields[field.key] = field.value;
|
|
1254
2956
|
});
|
|
1255
2957
|
return fields;
|
|
1256
2958
|
}
|
|
1257
|
-
/**
|
|
1258
|
-
|
|
2959
|
+
/**
|
|
2960
|
+
* Per line of an entry, the field it declares — or `null` where it declares
|
|
2961
|
+
* none, INCLUDING because it is fenced.
|
|
2962
|
+
*
|
|
2963
|
+
* This is the seam, and it exists because `parseGapEntryFieldLine` alone was
|
|
2964
|
+
* not it (#3702 round 3, pre-push review). The reader applied the fence gate
|
|
2965
|
+
* before classifying and the acknowledge writer did not, so a `status:` line
|
|
2966
|
+
* inside a fenced block was selected by the writer and skipped by the reader:
|
|
2967
|
+
* the write produced a line nothing reads, the read-back guard refused it, and
|
|
2968
|
+
* the entry became impossible to acknowledge at all — `audit acknowledge`
|
|
2969
|
+
* surfaced an internal error and `complete-milestone` halted on it. That shape
|
|
2970
|
+
* acknowledged cleanly on `next`, so it was a regression introduced by the fix
|
|
2971
|
+
* for the nested-marker one, and the claim "the writer cannot select a line the
|
|
2972
|
+
* reader will not read back" was false while the fence gate lived on one side.
|
|
2973
|
+
*
|
|
2974
|
+
* Both sides call this now, so the claim is structural rather than asserted.
|
|
2975
|
+
*/
|
|
2976
|
+
function entryFieldLines(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
|
|
2977
|
+
const fenced = entryFencedLines(entryLines, markers, openerFlags);
|
|
2978
|
+
return entryLines.map((rawLine, idx) => (fenced.has(idx) ? null : parseGapEntryFieldLine(rawLine, markers, stripsMarkerAt(idx, openerFlags))));
|
|
2979
|
+
}
|
|
2980
|
+
/**
|
|
2981
|
+
* The fenced lines of ONE entry, as the reader and the writer both see them.
|
|
2982
|
+
* A leaf's line 0 is its heading TEXT, not a Markdown line: a heading that
|
|
2983
|
+
* reads ``` or ~~~ is a heading, and must not open a fence over the body
|
|
2984
|
+
* beneath it (round 5, RV6.5 — it fenced every field line, so the reader
|
|
2985
|
+
* read nothing and the writer's marker landed on a line nothing reads).
|
|
2986
|
+
* `openerFlags[0] === false` is the leaf tell: a pending or headless entry's
|
|
2987
|
+
* line 0 is an accepted opener, and a marker line is never a delimiter.
|
|
2988
|
+
*/
|
|
2989
|
+
function entryFencedLines(entryLines, markers, openerFlags) {
|
|
2990
|
+
if (!markers.blockStructure)
|
|
2991
|
+
return new Set();
|
|
2992
|
+
const leaf = openerFlags !== undefined && openerFlags[0] === false;
|
|
2993
|
+
return fencedLineSet(leaf ? ['', ...entryLines.slice(1)] : entryLines);
|
|
2994
|
+
}
|
|
2995
|
+
/**
|
|
2996
|
+
* Which lines of an entry carry an entry-opening marker to be stripped before
|
|
2997
|
+
* the line is read as a field.
|
|
2998
|
+
*
|
|
2999
|
+
* Without flags — the headless and `## Gaps` shapes — that is line 0 alone: a
|
|
3000
|
+
* marker on a later line belongs to a nested sub-list (`splitGapsEntries`
|
|
3001
|
+
* already folded it in) and is not a field line unless it independently
|
|
3002
|
+
* matches `key: value` after a plain trim.
|
|
3003
|
+
*
|
|
3004
|
+
* With flags — the heading shape — it is whichever lines the SPLITTER accepted
|
|
3005
|
+
* as list openers, because there every body line may be a sibling bullet
|
|
3006
|
+
* carrying a field (#3457) while line 0 is the heading TEXT and carries no
|
|
3007
|
+
* marker at all. Reading the splitter's verdict rather than re-deriving it is
|
|
3008
|
+
* what keeps a rejected ordinal (`3. status: resolved` as prose) from being
|
|
3009
|
+
* stripped into a field.
|
|
3010
|
+
*/
|
|
3011
|
+
function stripsMarkerAt(idx, openerFlags) {
|
|
3012
|
+
return openerFlags ? openerFlags[idx] === true : idx === 0;
|
|
3013
|
+
}
|
|
3014
|
+
/**
|
|
3015
|
+
* The ONE place an entry line is classified as a `key: value` field line.
|
|
3016
|
+
* `extractGapEntryFields` reads through it, and `acknowledgeDeferredItem`
|
|
3017
|
+
* locates the line it will rewrite through it.
|
|
3018
|
+
*
|
|
3019
|
+
* Sharing the classifier is what makes the writer structurally unable to
|
|
3020
|
+
* select a line the reader will not read back (#3702 round 3, B1; the shape
|
|
3021
|
+
* is #3773's, parameterised here by `markers` per the round-3 review's
|
|
3022
|
+
* prescribed end state). The writer used to carry its own marker-widened
|
|
3023
|
+
* status regex, so a nested ` * status: pending` was selectable by the
|
|
3024
|
+
* writer and invisible to this reader: acknowledge rewrote it in place,
|
|
3025
|
+
* returned `ok`, and the item stayed outstanding forever. A single classifier
|
|
3026
|
+
* has no second copy to drift from.
|
|
3027
|
+
*
|
|
3028
|
+
* `valueStart` is the offset, in the CR-stripped line, at which the VALUE
|
|
3029
|
+
* begins — so a rewrite can replace the value without a second regex of its
|
|
3030
|
+
* own. The bolded-key unwrap below is a PREFIX rewrite, so the tail of the
|
|
3031
|
+
* rewritten content is byte-identical to the tail of the original and the
|
|
3032
|
+
* offset maps back directly.
|
|
3033
|
+
*
|
|
3034
|
+
* Returns `null` for a non-field line.
|
|
3035
|
+
*/
|
|
3036
|
+
function parseGapEntryFieldLine(rawLine, markers = HYPHEN_BULLET_MARKERS, stripMarker = true) {
|
|
3037
|
+
const fieldLineRe = /^([A-Za-z_][A-Za-z0-9_-]*):\s*(.*)$/;
|
|
3038
|
+
const boldedKeyRe = /^\*+([A-Za-z_][A-Za-z0-9_-]*):\*+/;
|
|
3039
|
+
const line = rawLine.replace(/\r$/, '');
|
|
3040
|
+
const bulletStripped = stripMarker ? line.match(markers.strip) : null;
|
|
3041
|
+
const bare = bulletStripped ? bulletStripped[2] : line.trim();
|
|
3042
|
+
// Where `bare` begins in `line`. The two branches differ: the marker strip's
|
|
3043
|
+
// group 2 runs to end-of-line, so it is a plain suffix; `trim()` also cuts
|
|
3044
|
+
// the tail, so its offset is the LEADING run alone. Computing one from the
|
|
3045
|
+
// other's shape under-counts by the trailing whitespace.
|
|
3046
|
+
const headLen = bulletStripped ? line.length - bare.length : line.length - line.trimStart().length;
|
|
3047
|
+
const content = bare.replace(boldedKeyRe, (_m, key) => `${key.toLowerCase()}:`);
|
|
3048
|
+
const m = fieldLineRe.exec(content);
|
|
3049
|
+
if (!m)
|
|
3050
|
+
return null;
|
|
3051
|
+
let value = m[2].trim();
|
|
3052
|
+
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
3053
|
+
value = value.slice(1, -1);
|
|
3054
|
+
}
|
|
3055
|
+
return { key: m[1], value, valueStart: headLen + (bare.length - m[2].length) };
|
|
3056
|
+
}
|
|
3057
|
+
/**
|
|
3058
|
+
* Fallback display text for a Gaps entry with no parseable `truth:` field.
|
|
3059
|
+
*
|
|
3060
|
+
* `markers` selects which opening marker is stripped (#3702) — hyphen-only by
|
|
3061
|
+
* default, the widened set for deferred-items callers, so a `*`-opened entry
|
|
3062
|
+
* renders the same name its hyphen twin would. That name is the key
|
|
3063
|
+
* `acknowledgeDeferredItem` matches on, so the two MUST use the same set:
|
|
3064
|
+
* rendering `* alpha` where the parse surfaced `alpha` would make the entry
|
|
3065
|
+
* un-acknowledgeable.
|
|
3066
|
+
*
|
|
3067
|
+
* `openerFlags` decides WHICH lines are stripped, and on the heading shape
|
|
3068
|
+
* line 0 is not one of them (#3702 round 3, m8). There line 0 is the heading
|
|
3069
|
+
* TEXT, so an unconditional strip renamed `### 1. Race in the writer` to
|
|
3070
|
+
* `Race in the writer` and `### * starred title` to `starred title` — both
|
|
3071
|
+
* silent renames of the very key acknowledge matches on, and both a change
|
|
3072
|
+
* from this parser's behaviour on `next`.
|
|
3073
|
+
*/
|
|
3074
|
+
function rawGapEntryText(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
|
|
1259
3075
|
return entryLines
|
|
1260
|
-
|
|
3076
|
+
// Line 0 ONLY, and only if the splitter accepted it as an opener. The
|
|
3077
|
+
// opener flags say which lines carry a marker; the entry's NAME is a
|
|
3078
|
+
// different question, and stripping a body line's marker out of it changes
|
|
3079
|
+
// the key `acknowledgeDeferredItem` matches on.
|
|
3080
|
+
.map((l, i) => (i === 0 && stripsMarkerAt(0, openerFlags) ? l.replace(markers.strip, '$2') : l.trim()))
|
|
1261
3081
|
.join(' ')
|
|
1262
3082
|
.trim();
|
|
1263
3083
|
}
|
|
1264
3084
|
// ─── parseVerificationItems ───────────────────────────────────────────────────
|
|
3085
|
+
/**
|
|
3086
|
+
* The entry's `status:`, lowercased, or undefined when absent/blank/non-scalar.
|
|
3087
|
+
*
|
|
3088
|
+
* The entry is a PARSED OBJECT, so this reads a named field rather than
|
|
3089
|
+
* matching prose. That distinction is the whole point: against the
|
|
3090
|
+
* display-flattened string, a `truth:` whose text mentions "status: resolved"
|
|
3091
|
+
* is indistinguishable from an entry that carries the field.
|
|
3092
|
+
*/
|
|
3093
|
+
function frontmatterEntryStatus(entry) {
|
|
3094
|
+
const status = entry['status'];
|
|
3095
|
+
if (typeof status !== 'string' || status.trim() === '')
|
|
3096
|
+
return undefined;
|
|
3097
|
+
return status.trim().toLowerCase();
|
|
3098
|
+
}
|
|
3099
|
+
/**
|
|
3100
|
+
* Is this `gaps:` frontmatter entry already closed? (#3850)
|
|
3101
|
+
*
|
|
3102
|
+
* `status: resolved`, and nothing else. Byte-identical to the rule
|
|
3103
|
+
* `parseGapsItems` applies to a `## Gaps` markdown section, deliberately: the
|
|
3104
|
+
* two readers see the SAME authored vocabulary in two places, and a closure
|
|
3105
|
+
* rule that differed between them would let one entry read closed in one
|
|
3106
|
+
* reader and open in the other. `parseVerificationGapsItems`' docstring claims
|
|
3107
|
+
* it mirrors `parseGapsItems`' fail-safe status handling; this is the line
|
|
3108
|
+
* that makes that claim true rather than approximately true.
|
|
3109
|
+
*
|
|
3110
|
+
* So a `gaps:` entry carrying `resolution:` and no `status:` SURFACES, via the
|
|
3111
|
+
* same 'unknown'-status fallback `parseGapsItems` already gives it (#3879
|
|
3112
|
+
* review round 4, Major).
|
|
3113
|
+
*/
|
|
3114
|
+
function isGapsEntryResolved(entry) {
|
|
3115
|
+
if (!entry)
|
|
3116
|
+
return false;
|
|
3117
|
+
return frontmatterEntryStatus(entry) === 'resolved';
|
|
3118
|
+
}
|
|
3119
|
+
/**
|
|
3120
|
+
* Is this `human_verification:` frontmatter entry already closed? (#3850)
|
|
3121
|
+
*
|
|
3122
|
+
* Verifier-written entries record closure as a `resolution:` field with no
|
|
3123
|
+
* `status:` at all, so `resolution:` closes — but ONLY when no `status:`
|
|
3124
|
+
* contradicts it. `status:` is authoritative wherever it is readable.
|
|
3125
|
+
*
|
|
3126
|
+
* The contradiction guard is the #3879 round-4 Major fix. Without it,
|
|
3127
|
+
* `status: failed` + `resolution: "attempted retry, still failing"` — a
|
|
3128
|
+
* plausible informational note, not a closure assertion — is silently dropped
|
|
3129
|
+
* from the report, which is the exact silently-vanishing-item defect class
|
|
3130
|
+
* #3850 exists to close, reached by field COMBINATION instead of file STATUS.
|
|
3131
|
+
*
|
|
3132
|
+
* This is not a judgment call about YAML: it is the rule this codebase already
|
|
3133
|
+
* applies to the same field pair one module over. `validateResolution`
|
|
3134
|
+
* (`probe-core.cts`) rejects a populated `resolution:` on a non-resolved status
|
|
3135
|
+
* outright — "a populated payload is an authoring mistake (the author meant
|
|
3136
|
+
* resolved/dismissed) that would otherwise be silently dropped into the
|
|
3137
|
+
* unresolved count with no error pointing at it. Reject it so the mistake
|
|
3138
|
+
* surfaces." A reporter cannot throw, so the fail-safe equivalent of surfacing
|
|
3139
|
+
* the mistake is to surface the ITEM.
|
|
3140
|
+
*
|
|
3141
|
+
* #3850's suggested fix (2) states the skip unconditionally — "Skip entries
|
|
3142
|
+
* carrying a `resolution:` field" — and its named scenario (one file with 14 of
|
|
3143
|
+
* 16 entries resolved) is unaffected by the guard: those entries close either
|
|
3144
|
+
* on `resolution:` with no contradicting status, or on `status: resolved`.
|
|
3145
|
+
* Both still skip. The guard only changes entries whose own two fields
|
|
3146
|
+
* disagree, and for those the fail-safe direction on a false-NEGATIVE bug is to
|
|
3147
|
+
* report, not to drop.
|
|
3148
|
+
*/
|
|
3149
|
+
function isHumanVerificationEntryResolved(entry) {
|
|
3150
|
+
if (!entry)
|
|
3151
|
+
return false;
|
|
3152
|
+
const status = frontmatterEntryStatus(entry);
|
|
3153
|
+
if (status !== undefined)
|
|
3154
|
+
return status === 'resolved';
|
|
3155
|
+
const resolution = entry['resolution'];
|
|
3156
|
+
return typeof resolution === 'string' && resolution.trim() !== '';
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* A named string field of a parsed entry, or undefined when absent/non-scalar.
|
|
3160
|
+
*
|
|
3161
|
+
* A whitespace-only value counts as absent, but a present value is returned
|
|
3162
|
+
* VERBATIM — trimming it here would silently rewrite an author's `truth:` on
|
|
3163
|
+
* its way to becoming the item's display name, which is a different string from
|
|
3164
|
+
* the one in the file.
|
|
3165
|
+
*/
|
|
3166
|
+
function isFrontmatterObjectEntry(entry) {
|
|
3167
|
+
return !!entry && typeof entry === 'object' && !Array.isArray(entry);
|
|
3168
|
+
}
|
|
3169
|
+
/**
|
|
3170
|
+
* The PARSED object behind each element of a frontmatter array, positionally
|
|
3171
|
+
* aligned with that array's DISPLAY renderings — `null` at any index whose
|
|
3172
|
+
* entry is not an object (#3850).
|
|
3173
|
+
*
|
|
3174
|
+
* Both frontmatter readers below need the same two things about one array: the
|
|
3175
|
+
* string each entry has always displayed as, and the fields it actually
|
|
3176
|
+
* carries. `extractFrontmatter` gives the first, `frontmatterListEntries` the
|
|
3177
|
+
* second, and the ONLY safe way to use them together is by index — so the
|
|
3178
|
+
* pairing is done once, here, rather than open-coded twice.
|
|
3179
|
+
*
|
|
3180
|
+
* Alignment is checked, not assumed. Both arrays come from one parse of one
|
|
3181
|
+
* region (they share a fence parser), so they agree in practice; if they ever
|
|
3182
|
+
* did not, an index would name a DIFFERENT entry's fields and the resolved-skip
|
|
3183
|
+
* would close the wrong row. All-`null` is the correct degradation: no entry is
|
|
3184
|
+
* skipped as closed, which over-reports rather than mis-attributes.
|
|
3185
|
+
*
|
|
3186
|
+
* The length check is UNREACHABLE through content today and is kept anyway
|
|
3187
|
+
* (#3879 review round 4, Minor 2). It was verified unreachable rather than
|
|
3188
|
+
* assumed: both readers enter through `frontmatterRegion`, `extractFrontmatter`'s
|
|
3189
|
+
* only extra argument (`sourcePath`) gates a warning and nothing else, and the
|
|
3190
|
+
* display step — `normalizeParsedValue`'s `value.map(...)` — is 1:1 and drops no
|
|
3191
|
+
* element. So the guard is a drift alarm for a future edit to either parser, not
|
|
3192
|
+
* a live branch. That makes it untestable through the two readers, which is why
|
|
3193
|
+
* this helper is exported for tests: the degradation is asserted against the
|
|
3194
|
+
* function directly rather than left as the one unpinned branch in the family.
|
|
3195
|
+
*/
|
|
3196
|
+
function parsedEntriesFor(content, key, flattened) {
|
|
3197
|
+
const parsed = frontmatterListEntries(content, key);
|
|
3198
|
+
if (!parsed || parsed.length !== flattened.length)
|
|
3199
|
+
return flattened.map(() => null);
|
|
3200
|
+
return parsed.map((entry) => (isFrontmatterObjectEntry(entry) ? entry : null));
|
|
3201
|
+
}
|
|
3202
|
+
function entryField(entry, key) {
|
|
3203
|
+
const v = entry[key];
|
|
3204
|
+
if (typeof v === 'string')
|
|
3205
|
+
return v.trim() === '' ? undefined : v;
|
|
3206
|
+
if (typeof v === 'number' || typeof v === 'boolean')
|
|
3207
|
+
return String(v);
|
|
3208
|
+
return undefined;
|
|
3209
|
+
}
|
|
3210
|
+
/**
|
|
3211
|
+
* One parsed `gaps:` entry -> one `UatItem`.
|
|
3212
|
+
*
|
|
3213
|
+
* ONE call site, `parseVerificationGapsItems` (#3850 review round 3, Minor 1 —
|
|
3214
|
+
* an earlier revision's comment claimed both frontmatter readers shared this,
|
|
3215
|
+
* and a dead `forcedResult` option existed to serve the second one; neither was
|
|
3216
|
+
* ever true, and the claim made a deliberate difference read as an accident).
|
|
3217
|
+
*
|
|
3218
|
+
* WHY the two frontmatter readers derive fields differently, since they sit
|
|
3219
|
+
* side by side and it is a fair question: each mirrors its OWN established
|
|
3220
|
+
* sibling rather than each other.
|
|
3221
|
+
*
|
|
3222
|
+
* - This one mirrors `parseGapsItems`, the `## Gaps` markdown reader, field
|
|
3223
|
+
* for field: `status:` supplies `result` with the module's documented
|
|
3224
|
+
* fail-safe `'unknown'` when absent (surface a questionable entry rather
|
|
3225
|
+
* than drop a real one), `test` is taken ONLY when the entry declares one,
|
|
3226
|
+
* and `reason` passes through. A `gaps:` entry carries its own status, so
|
|
3227
|
+
* inventing one would be a lie.
|
|
3228
|
+
* - `parseHumanVerificationItems` mirrors #2286's `human_verification:`
|
|
3229
|
+
* behaviour: the array IS the outstanding list, so every surviving entry is
|
|
3230
|
+
* `human_needed` by construction and its `test` is its ROW, because those
|
|
3231
|
+
* entries carry no number of their own.
|
|
3232
|
+
*
|
|
3233
|
+
* Converging them would mean changing one of those two established contracts
|
|
3234
|
+
* for the convenience of symmetry. See `parseVerificationItems` for the one
|
|
3235
|
+
* consequence that is genuinely open (a `test` number is unique per array, not
|
|
3236
|
+
* per report).
|
|
3237
|
+
*
|
|
3238
|
+
* The display name falls back to `flattenObjectListItem` — the SAME renderer
|
|
3239
|
+
* `extractFrontmatter` applies — so an entry with no `truth:` reads exactly as
|
|
3240
|
+
* it always did, byte for byte.
|
|
3241
|
+
*/
|
|
3242
|
+
function frontmatterEntryToUatItem(entry) {
|
|
3243
|
+
const status = entryField(entry, 'status') ?? 'unknown';
|
|
3244
|
+
const reason = entryField(entry, 'reason');
|
|
3245
|
+
const item = {
|
|
3246
|
+
name: entryField(entry, 'truth') || flattenObjectListItem(entry),
|
|
3247
|
+
result: status,
|
|
3248
|
+
category: categorizeItem(status, reason, undefined),
|
|
3249
|
+
};
|
|
3250
|
+
// No `test:` read (#3879 review round 4, Minor 4). A `gaps:` entry has no
|
|
3251
|
+
// `test:` in its vocabulary — the verification template's entries carry
|
|
3252
|
+
// `truth` / `status` / `reason` / `artifacts` / `missing` — so reading one was
|
|
3253
|
+
// speculative support for a field this shape does not have. It also collided:
|
|
3254
|
+
// `parseHumanVerificationItems` numbers its items 1..N by array POSITION,
|
|
3255
|
+
// so a `gaps:` entry that did carry `test: 1` produced two items numbered 1
|
|
3256
|
+
// in one file's combined list. Not reading it makes the collision impossible
|
|
3257
|
+
// rather than unlikely, and does not renumber anything: an offset would have
|
|
3258
|
+
// rewritten an authored value, which is the opposite of `entryField`'s
|
|
3259
|
+
// verbatim contract.
|
|
3260
|
+
if (reason)
|
|
3261
|
+
item.reason = reason;
|
|
3262
|
+
return item;
|
|
3263
|
+
}
|
|
3264
|
+
/**
|
|
3265
|
+
* Surface a `gaps_found` report's frontmatter `gaps:` array (#3850).
|
|
3266
|
+
*
|
|
3267
|
+
* Mirrors `parseGapsItems`' field vocabulary and fail-safe status handling, but
|
|
3268
|
+
* reads the FRONTMATTER array rather than a `## Gaps` markdown section —
|
|
3269
|
+
* `parseGapsItems` is reached only from `parseUatItems`, and the verification
|
|
3270
|
+
* template puts gaps in frontmatter, so no existing reader covers this shape.
|
|
3271
|
+
*/
|
|
3272
|
+
function parseVerificationGapsItems(content) {
|
|
3273
|
+
const flattened = extractFrontmatter(content)['gaps'];
|
|
3274
|
+
if (!Array.isArray(flattened))
|
|
3275
|
+
return [];
|
|
3276
|
+
const parsed = parsedEntriesFor(content, 'gaps', flattened);
|
|
3277
|
+
const items = [];
|
|
3278
|
+
flattened.forEach((display, idx) => {
|
|
3279
|
+
const entry = parsed[idx];
|
|
3280
|
+
// A non-object entry (a bare scalar, a null from a `- ` with nothing after
|
|
3281
|
+
// it, a nested sequence) still surfaces, named by the SAME renderer every
|
|
3282
|
+
// other frontmatter reader names it by. Dropping it would be this module's
|
|
3283
|
+
// wrong direction on a false-NEGATIVE bug: `parseGapsItems`' own
|
|
3284
|
+
// 'unknown'-status fallback exists to surface a questionable entry rather
|
|
3285
|
+
// than lose a real one, and an entry with no readable status is exactly
|
|
3286
|
+
// that. It carries no fields, so it can never be skipped as closed.
|
|
3287
|
+
if (!entry) {
|
|
3288
|
+
items.push({
|
|
3289
|
+
name: normalizeHumanVerificationEntry(display),
|
|
3290
|
+
result: 'unknown',
|
|
3291
|
+
category: categorizeItem('unknown'),
|
|
3292
|
+
});
|
|
3293
|
+
return;
|
|
3294
|
+
}
|
|
3295
|
+
if (isGapsEntryResolved(entry))
|
|
3296
|
+
return;
|
|
3297
|
+
items.push(frontmatterEntryToUatItem(entry));
|
|
3298
|
+
});
|
|
3299
|
+
return items;
|
|
3300
|
+
}
|
|
3301
|
+
/**
|
|
3302
|
+
* #3850: `gaps_found` is as outstanding as `human_needed`.
|
|
3303
|
+
*
|
|
3304
|
+
* `cmdAuditUat` admits BOTH statuses, then this function honoured only one and
|
|
3305
|
+
* returned an empty array for the other. Because `cmdAuditUat` pushes a file
|
|
3306
|
+
* into `results` only when `items.length > 0`, a `gaps_found` report did not
|
|
3307
|
+
* merely under-report — it VANISHED, taking its phase's row out of `by_phase`
|
|
3308
|
+
* with it, so a clean-looking total gave the reader no cue that anything was
|
|
3309
|
+
* skipped. The trailing `plan-phase --gaps` note that stood in for a
|
|
3310
|
+
* `gaps_found` branch pointed at a DIFFERENT command that `audit-uat` never
|
|
3311
|
+
* reaches.
|
|
3312
|
+
*
|
|
3313
|
+
* Eligibility now has ONE owner — the caller — and this function reports what
|
|
3314
|
+
* the file says.
|
|
3315
|
+
*
|
|
3316
|
+
* Resolved entries are skipped on BOTH statuses (#3850 review m8). An earlier
|
|
3317
|
+
* revision skipped them only on `gaps_found`, citing an acceptance criterion
|
|
3318
|
+
* that the issue does not contain: #3850 has no AC section, and its suggested
|
|
3319
|
+
* fix (2) states the skip unconditionally — "Skip entries carrying a
|
|
3320
|
+
* `resolution:` field, or the fix trades one wrong number for another — one
|
|
3321
|
+
* file here has 14 of 16 entries resolved". That file is `human_needed`, so the
|
|
3322
|
+
* asymmetry left the reporter's own named scenario over-reporting by 14. The
|
|
3323
|
+
* SKIP applies on both paths.
|
|
3324
|
+
*
|
|
3325
|
+
* WHAT COUNTS AS RESOLVED is per-key, not universal (#3879 review round 4,
|
|
3326
|
+
* Major): `isGapsEntryResolved` takes `parseGapsItems`' `status: resolved` rule
|
|
3327
|
+
* verbatim so the two `gaps` readers cannot disagree, and
|
|
3328
|
+
* `isHumanVerificationEntryResolved` honours the `resolution:`-only closure the
|
|
3329
|
+
* issue names, guarded so a `status:` that contradicts it wins. The issue's
|
|
3330
|
+
* "skip entries carrying a `resolution:` field" is quoted above as written; it
|
|
3331
|
+
* holds for every entry whose fields agree, which is every entry the reporter's
|
|
3332
|
+
* own scenario contains.
|
|
3333
|
+
*/
|
|
1265
3334
|
function parseVerificationItems(content, status, sourcePath) {
|
|
1266
3335
|
const items = [];
|
|
3336
|
+
if (status === 'gaps_found') {
|
|
3337
|
+
items.push(...parseHumanVerificationItems(content, sourcePath));
|
|
3338
|
+
items.push(...parseVerificationGapsItems(content));
|
|
3339
|
+
return items;
|
|
3340
|
+
}
|
|
1267
3341
|
if (status === 'human_needed') {
|
|
1268
|
-
|
|
1269
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
3342
|
+
return parseHumanVerificationItems(content, sourcePath);
|
|
3343
|
+
}
|
|
3344
|
+
return items;
|
|
3345
|
+
}
|
|
3346
|
+
/**
|
|
3347
|
+
* The `human_verification:` reader, extracted from `parseVerificationItems` so
|
|
3348
|
+
* `gaps_found` and `human_needed` share ONE implementation rather than a second
|
|
3349
|
+
* copy that drifts (ref `DEFECT.GENERATIVE-FIX`). Both statuses now take the
|
|
3350
|
+
* identical path, resolved-entry skip included — see the dispatcher above.
|
|
3351
|
+
*/
|
|
3352
|
+
function parseHumanVerificationItems(content, sourcePath) {
|
|
3353
|
+
const items = [];
|
|
3354
|
+
// #2286: the frontmatter's structured `human_verification:` YAML array
|
|
3355
|
+
// (extractFrontmatter) is the PRIMARY source of truth when present and
|
|
3356
|
+
// non-empty — it fully bypasses the body-shape scan below, so a file
|
|
3357
|
+
// whose frontmatter declares the array doesn't require any particular
|
|
3358
|
+
// `## Human Verification` body shape at all. An absent or empty array
|
|
3359
|
+
// (length 0) falls back to the body scan unchanged.
|
|
3360
|
+
const frontmatter = extractFrontmatter(content, sourcePath);
|
|
3361
|
+
const humanVerification = frontmatter.human_verification;
|
|
3362
|
+
if (Array.isArray(humanVerification) && humanVerification.length > 0) {
|
|
3363
|
+
// #3850: ONE source for both the display name and the sibling fields.
|
|
3364
|
+
//
|
|
3365
|
+
// `extractFrontmatter` renders each object entry for humans
|
|
3366
|
+
// (`flattenObjectListItem`), which is right for printing and wrong for
|
|
3367
|
+
// branching: `resolution:` is recoverable from that string only by matching
|
|
3368
|
+
// prose, and prose cannot tell a real field from the same text quoted
|
|
3369
|
+
// inside `truth:`. `frontmatterListEntries` returns the same entries one
|
|
3370
|
+
// step earlier, off the same parse.
|
|
3371
|
+
//
|
|
3372
|
+
// The flattened array stays the #2286 GATE — a non-empty
|
|
3373
|
+
// `human_verification:` fully bypasses the body-shape scan below — but the
|
|
3374
|
+
// raw entries are the source of the items, so there is no second reader to
|
|
3375
|
+
// desynchronise against.
|
|
3376
|
+
//
|
|
3377
|
+
// WALK THE FLATTENED ARRAY, and use the parsed one only to answer "is this
|
|
3378
|
+
// entry closed?" (#3850 review round 3, Blocker).
|
|
3379
|
+
//
|
|
3380
|
+
// This is base's loop — every element, at its own index, named by the
|
|
3381
|
+
// renderer it has always been named by — plus one skip. It is deliberately
|
|
3382
|
+
// NOT "iterate the parsed entries": an earlier revision did that against an
|
|
3383
|
+
// object-FILTERED array, which compacted it, so a list mixing object and
|
|
3384
|
+
// non-object entries lost the non-object rows outright and renumbered the
|
|
3385
|
+
// survivors. That is the silently-vanishing row this issue exists to close,
|
|
3386
|
+
// reintroduced by entry SHAPE instead of file STATUS. Numbering off the
|
|
3387
|
+
// flattened array cannot drift from what the file says, because that array
|
|
3388
|
+
// is the one #2286 already gated on.
|
|
3389
|
+
//
|
|
3390
|
+
// The name therefore stays byte-identical to base for every entry shape,
|
|
3391
|
+
// including the ones with no object to read: a YAML null renders `''`, a
|
|
3392
|
+
// nested sequence renders `[nested]`. Re-deriving those from the parsed
|
|
3393
|
+
// value would have printed `["nested"]` — a rendering nobody asked this
|
|
3394
|
+
// change to alter.
|
|
3395
|
+
//
|
|
3396
|
+
// `parsedEntriesFor` owns the pairing and its alignment check.
|
|
3397
|
+
const parsed = parsedEntriesFor(content, 'human_verification', humanVerification);
|
|
3398
|
+
humanVerification.forEach((flattened, idx) => {
|
|
3399
|
+
const object = parsed[idx];
|
|
3400
|
+
if (object && isHumanVerificationEntryResolved(object))
|
|
3401
|
+
return;
|
|
3402
|
+
items.push({
|
|
3403
|
+
// The entry's ORIGINAL 1-based position, so a surfaced item still
|
|
3404
|
+
// names its row in the file when a closed sibling was skipped.
|
|
3405
|
+
test: idx + 1,
|
|
3406
|
+
name: normalizeHumanVerificationEntry(flattened),
|
|
3407
|
+
result: 'human_needed',
|
|
3408
|
+
category: 'human_uat',
|
|
1284
3409
|
});
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1289
|
-
|
|
1290
|
-
|
|
1291
|
-
|
|
1292
|
-
|
|
1293
|
-
|
|
1294
|
-
|
|
1295
|
-
|
|
1296
|
-
|
|
1297
|
-
|
|
1298
|
-
|
|
1299
|
-
|
|
1300
|
-
|
|
1301
|
-
|
|
3410
|
+
});
|
|
3411
|
+
return items;
|
|
3412
|
+
}
|
|
3413
|
+
// Use the seam to locate the ## Human Verification section (ADR-1372 T5).
|
|
3414
|
+
const hvSection = collectSection(content, (h) => /^human\s+verification/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
3415
|
+
if (hvSection) {
|
|
3416
|
+
// #2245 review Fix 3: reverted to the pre-Phase-4 (HEAD 2cbf18642)
|
|
3417
|
+
// implementation. The live Human Verification section is NOT a strict
|
|
3418
|
+
// GFM table — the planner/verifier templates mix table rows, numbered
|
|
3419
|
+
// items, and bullet items in the same section (and a `### N.` heading
|
|
3420
|
+
// format is common too), so a table-XOR-list read (parse a table, and
|
|
3421
|
+
// if it parses, suppress numbered/bullet items entirely) silently
|
|
3422
|
+
// dropped items on any mixed or malformed section: a malformed
|
|
3423
|
+
// `| N | … |` table with no valid header/delimiter yielded ZERO items
|
|
3424
|
+
// instead of reading the rows positionally. This per-line scan reads
|
|
3425
|
+
// table rows AND numbered items AND bullet items as a UNION (whichever
|
|
3426
|
+
// pattern a given line matches), exactly like OLD, and reads
|
|
3427
|
+
// `| N | desc |` rows even without a valid table header/delimiter.
|
|
3428
|
+
//
|
|
3429
|
+
// #2245 audit: the table-row branch's CELL SPLIT is name/position-
|
|
3430
|
+
// addressed via `splitTableRow` (escape-aware, canonical) instead of a
|
|
3431
|
+
// hand-rolled pipe regex — candidacy itself is decided WITHOUT a table
|
|
3432
|
+
// regex (a leading `|` plus a purely-numeric first cell), so this no
|
|
3433
|
+
// longer needs an allow-adhoc-markdown suppression at all.
|
|
3434
|
+
const lines = hvSection.body.split('\n');
|
|
3435
|
+
for (const line of lines) {
|
|
3436
|
+
const trimmedLine = line.trim();
|
|
3437
|
+
// Match table rows: | N | description | ... — candidacy requires a
|
|
3438
|
+
// leading pipe and a purely-numeric first cell (mirrors what the old
|
|
3439
|
+
// regex effectively required: a "|digit|" cell immediately followed
|
|
3440
|
+
// by more content), with at least 2 physical cells so a bare "| N |"
|
|
3441
|
+
// with nothing after it is NOT treated as a row.
|
|
1302
3442
|
//
|
|
1303
|
-
// #2245
|
|
1304
|
-
//
|
|
1305
|
-
//
|
|
1306
|
-
//
|
|
1307
|
-
//
|
|
1308
|
-
|
|
1309
|
-
|
|
1310
|
-
|
|
1311
|
-
|
|
1312
|
-
|
|
1313
|
-
|
|
1314
|
-
|
|
1315
|
-
|
|
1316
|
-
|
|
1317
|
-
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
// against the RAW (untrimmed) line and its `\s*` would backtrack to
|
|
1321
|
-
// let `[^|]+` swallow that trailing whitespace, so OLD matched and
|
|
1322
|
-
// pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
|
|
1323
|
-
// `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
|
|
1324
|
-
// `splitTableRow` ever sees it, collapsing the line to a single cell
|
|
1325
|
-
// (`candidateCells.length === 1`), which fails the `>= 2` check —
|
|
1326
|
-
// the item is silently dropped instead. A real, acceptable behaviour
|
|
1327
|
-
// change (an empty-named UAT item is not useful either way), but the
|
|
1328
|
-
// two implementations are NOT equivalent on this input.
|
|
1329
|
-
let tableCells = null;
|
|
1330
|
-
if (trimmedLine.startsWith('|')) {
|
|
1331
|
-
const candidateCells = splitTableRow(trimmedLine);
|
|
1332
|
-
if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
|
|
1333
|
-
tableCells = candidateCells;
|
|
1334
|
-
}
|
|
1335
|
-
}
|
|
1336
|
-
// Match bullet items: - description
|
|
1337
|
-
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
|
1338
|
-
// Match numbered items: 1. description
|
|
1339
|
-
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
|
1340
|
-
if (tableCells) {
|
|
1341
|
-
// Skip rows that already have a passing result (PASS, pass, resolved, etc.)
|
|
1342
|
-
// — checked over every cell AFTER the description column, mirroring
|
|
1343
|
-
// OLD's rowRemainder scan (which only ever saw cells past the
|
|
1344
|
-
// description, the description itself having already been consumed).
|
|
1345
|
-
const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
|
|
1346
|
-
if (hasPassResult)
|
|
1347
|
-
continue;
|
|
1348
|
-
items.push({
|
|
1349
|
-
test: parseInt(tableCells[0], 10),
|
|
1350
|
-
name: tableCells[1] ?? '',
|
|
1351
|
-
result: 'human_needed',
|
|
1352
|
-
category: 'human_uat',
|
|
1353
|
-
});
|
|
1354
|
-
}
|
|
1355
|
-
else if (numberedMatch) {
|
|
1356
|
-
items.push({
|
|
1357
|
-
test: parseInt(numberedMatch[1], 10),
|
|
1358
|
-
name: numberedMatch[2].trim(),
|
|
1359
|
-
result: 'human_needed',
|
|
1360
|
-
category: 'human_uat',
|
|
1361
|
-
});
|
|
1362
|
-
}
|
|
1363
|
-
else if (bulletMatch && bulletMatch[1].length > 10) {
|
|
1364
|
-
items.push({
|
|
1365
|
-
name: bulletMatch[1].trim(),
|
|
1366
|
-
result: 'human_needed',
|
|
1367
|
-
category: 'human_uat',
|
|
1368
|
-
});
|
|
3443
|
+
// #2245 review Fix 9: this is NOT the same as OLD for a row whose
|
|
3444
|
+
// ONLY content past the digit cell is trailing whitespace (e.g.
|
|
3445
|
+
// "| N | ", no second delimiting `|`). OLD's `([^|]+)` regex ran
|
|
3446
|
+
// against the RAW (untrimmed) line and its `\s*` would backtrack to
|
|
3447
|
+
// let `[^|]+` swallow that trailing whitespace, so OLD matched and
|
|
3448
|
+
// pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
|
|
3449
|
+
// `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
|
|
3450
|
+
// `splitTableRow` ever sees it, collapsing the line to a single cell
|
|
3451
|
+
// (`candidateCells.length === 1`), which fails the `>= 2` check —
|
|
3452
|
+
// the item is silently dropped instead. A real, acceptable behaviour
|
|
3453
|
+
// change (an empty-named UAT item is not useful either way), but the
|
|
3454
|
+
// two implementations are NOT equivalent on this input.
|
|
3455
|
+
let tableCells = null;
|
|
3456
|
+
if (trimmedLine.startsWith('|')) {
|
|
3457
|
+
const candidateCells = splitTableRow(trimmedLine);
|
|
3458
|
+
if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
|
|
3459
|
+
tableCells = candidateCells;
|
|
1369
3460
|
}
|
|
1370
3461
|
}
|
|
1371
|
-
//
|
|
1372
|
-
|
|
1373
|
-
//
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
|
|
1382
|
-
const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
|
|
1383
|
-
for (let i = 0; i < hvSubHeadings.length; i += 1) {
|
|
1384
|
-
const current = hvSubHeadings[i];
|
|
1385
|
-
const next = hvSubHeadings[i + 1];
|
|
1386
|
-
const block = next
|
|
1387
|
-
? hvSection.body.slice(current.offset, next.offset)
|
|
1388
|
-
: hvSection.body.slice(current.offset);
|
|
1389
|
-
const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
|
|
1390
|
-
// Require a bold-led paragraph body (`**Test:** ...`) to distinguish
|
|
1391
|
-
// a genuine verification item from an unrelated numbered heading.
|
|
1392
|
-
if (!/^\s*\*\*/.test(bodyAfterHeading))
|
|
1393
|
-
continue;
|
|
1394
|
-
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
|
|
1395
|
-
if (!headingParts)
|
|
3462
|
+
// Match bullet items: - description
|
|
3463
|
+
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
|
3464
|
+
// Match numbered items: 1. description
|
|
3465
|
+
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
|
3466
|
+
if (tableCells) {
|
|
3467
|
+
// Skip rows that already have a passing result (PASS, pass, resolved, etc.)
|
|
3468
|
+
// — checked over every cell AFTER the description column, mirroring
|
|
3469
|
+
// OLD's rowRemainder scan (which only ever saw cells past the
|
|
3470
|
+
// description, the description itself having already been consumed).
|
|
3471
|
+
const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
|
|
3472
|
+
if (hasPassResult)
|
|
1396
3473
|
continue;
|
|
1397
3474
|
items.push({
|
|
1398
|
-
test: parseInt(
|
|
1399
|
-
name:
|
|
3475
|
+
test: parseInt(tableCells[0], 10),
|
|
3476
|
+
name: tableCells[1] ?? '',
|
|
1400
3477
|
result: 'human_needed',
|
|
1401
3478
|
category: 'human_uat',
|
|
1402
3479
|
});
|
|
1403
3480
|
}
|
|
3481
|
+
else if (numberedMatch) {
|
|
3482
|
+
items.push({
|
|
3483
|
+
test: parseInt(numberedMatch[1], 10),
|
|
3484
|
+
name: numberedMatch[2].trim(),
|
|
3485
|
+
result: 'human_needed',
|
|
3486
|
+
category: 'human_uat',
|
|
3487
|
+
});
|
|
3488
|
+
}
|
|
3489
|
+
else if (bulletMatch && bulletMatch[1].length > 10) {
|
|
3490
|
+
items.push({
|
|
3491
|
+
name: bulletMatch[1].trim(),
|
|
3492
|
+
result: 'human_needed',
|
|
3493
|
+
category: 'human_uat',
|
|
3494
|
+
});
|
|
3495
|
+
}
|
|
3496
|
+
}
|
|
3497
|
+
// #2286: fall back to the `### N. <label>` heading + bold-led paragraph
|
|
3498
|
+
// shape (the canonical form emitted by `templates/verification-report.md`
|
|
3499
|
+
// — `### 1. {Test Name}` followed by `**Test:** ... **Expected:** ...
|
|
3500
|
+
// **Why human:** ...`), which the table/bullet/numbered per-line scan
|
|
3501
|
+
// above never recognises (a `###`-prefixed line matches none of those
|
|
3502
|
+
// three patterns). Uses the same `tokenizeHeadings` seam
|
|
3503
|
+
// `parseFirstPendingTest` already uses for `### N.` sub-headings,
|
|
3504
|
+
// applied here to the Human Verification section body. Runs in
|
|
3505
|
+
// addition to (a union with) the scan above — the two shapes don't
|
|
3506
|
+
// collide, so this only adds items a `###` heading page would have
|
|
3507
|
+
// silently produced zero for.
|
|
3508
|
+
const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
|
|
3509
|
+
for (let i = 0; i < hvSubHeadings.length; i += 1) {
|
|
3510
|
+
const current = hvSubHeadings[i];
|
|
3511
|
+
const next = hvSubHeadings[i + 1];
|
|
3512
|
+
const block = next
|
|
3513
|
+
? hvSection.body.slice(current.offset, next.offset)
|
|
3514
|
+
: hvSection.body.slice(current.offset);
|
|
3515
|
+
const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
|
|
3516
|
+
// Require a bold-led paragraph body (`**Test:** ...`) to distinguish
|
|
3517
|
+
// a genuine verification item from an unrelated numbered heading.
|
|
3518
|
+
if (!/^\s*\*\*/.test(bodyAfterHeading))
|
|
3519
|
+
continue;
|
|
3520
|
+
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
|
|
3521
|
+
if (!headingParts)
|
|
3522
|
+
continue;
|
|
3523
|
+
items.push({
|
|
3524
|
+
test: parseInt(headingParts[1], 10),
|
|
3525
|
+
name: headingParts[2].trim(),
|
|
3526
|
+
result: 'human_needed',
|
|
3527
|
+
category: 'human_uat',
|
|
3528
|
+
});
|
|
1404
3529
|
}
|
|
1405
3530
|
}
|
|
1406
|
-
// gaps_found items are already handled by plan-phase --gaps pipeline
|
|
1407
3531
|
return items;
|
|
1408
3532
|
}
|
|
1409
3533
|
/**
|
|
@@ -1434,7 +3558,13 @@ function normalizeHumanVerificationEntry(raw) {
|
|
|
1434
3558
|
return s || raw.trim();
|
|
1435
3559
|
}
|
|
1436
3560
|
// ─── categorizeItem ───────────────────────────────────────────────────────────
|
|
1437
|
-
function categorizeItem(
|
|
3561
|
+
function categorizeItem(rawResult, reason, blockedBy) {
|
|
3562
|
+
// Normalize once so this comparison agrees with the PASS-token check
|
|
3563
|
+
// (`UAT_PASS_RESULTS.has(result)`, over an already-lower-cased token):
|
|
3564
|
+
// `result: PENDING` and
|
|
3565
|
+
// `result: Blocked` must categorize the same as their lowercase forms,
|
|
3566
|
+
// not fall through to 'unknown'.
|
|
3567
|
+
const result = rawResult.toLowerCase();
|
|
1438
3568
|
if (result === 'blocked' || blockedBy) {
|
|
1439
3569
|
if (blockedBy) {
|
|
1440
3570
|
if (/server/i.test(blockedBy))
|
|
@@ -1463,18 +3593,43 @@ function categorizeItem(result, reason, blockedBy) {
|
|
|
1463
3593
|
return 'pending';
|
|
1464
3594
|
if (result === 'human_needed')
|
|
1465
3595
|
return 'human_uat';
|
|
3596
|
+
// #3707: the template-sanctioned `result: issue` token (templates/UAT.md)
|
|
3597
|
+
// has no UatCategory branch here, so a surfaced issue row previously fell
|
|
3598
|
+
// through to 'unknown' — placed AFTER the blocked/skipped/pending checks
|
|
3599
|
+
// above so it never shadows their more specific categorization.
|
|
3600
|
+
if (result === 'issue')
|
|
3601
|
+
return 'issue';
|
|
1466
3602
|
return 'unknown';
|
|
1467
3603
|
}
|
|
1468
3604
|
module.exports = {
|
|
1469
3605
|
cmdAuditUat,
|
|
1470
3606
|
cmdRenderCheckpoint,
|
|
1471
3607
|
parseCurrentTest,
|
|
3608
|
+
parseUatItems,
|
|
3609
|
+
parseUatItemsWithStats,
|
|
3610
|
+
selectPhaseUatFiles,
|
|
1472
3611
|
buildCheckpoint,
|
|
1473
3612
|
CHECKPOINT_FRAMES,
|
|
1474
3613
|
CHECKPOINT_LANGUAGE_ALIASES,
|
|
1475
3614
|
resolveCheckpointFrame,
|
|
1476
|
-
checkpointBoxLine,
|
|
1477
3615
|
parseDeferredItems,
|
|
1478
3616
|
parseDeferredItemsWithStatus,
|
|
1479
3617
|
acknowledgeDeferredItem,
|
|
3618
|
+
// #3702 round 2 (M3): exposed for the marker-grammar parity test only.
|
|
3619
|
+
// Narrowed in round 3 (M6): the two status-line regexes are gone from the
|
|
3620
|
+
// module, so nothing exports them, and the parity test they were widened for
|
|
3621
|
+
// could not reach the defect it was meant to guard anyway — it asserted the
|
|
3622
|
+
// four WRITER regexes shared a source string, which is true of a detect/read
|
|
3623
|
+
// asymmetry too. The pair below is what the behavioural parity test against
|
|
3624
|
+
// `iterateBullets` actually reads.
|
|
3625
|
+
DEFERRED_MARKER_ALT,
|
|
3626
|
+
DEFERRED_BULLET_MARKERS,
|
|
3627
|
+
// #3850: exported so the `gaps_found` partition invariant is asserted
|
|
3628
|
+
// against the parser itself rather than only through a CLI round-trip
|
|
3629
|
+
// (RULESET.TESTS.property-based-testing).
|
|
3630
|
+
parseVerificationItems,
|
|
3631
|
+
// #3879 review round 4, Minor 2: exported for tests so the degrade-to-all-null
|
|
3632
|
+
// branch is asserted directly. It cannot be reached through the two readers —
|
|
3633
|
+
// see the alignment note on the function.
|
|
3634
|
+
parsedEntriesFor,
|
|
1480
3635
|
};
|