@opengsd/gsd-core 1.12.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-executor.md +63 -35
- package/agents/gsd-plan-checker.md +76 -57
- package/agents/gsd-planner.md +14 -0
- package/agents/gsd-ui-checker.md +19 -3
- package/agents/gsd-ui-researcher.md +29 -0
- package/agents/gsd-verifier.md +23 -1
- package/bin/install.js +239 -67
- package/commands/gsd/execute-phase.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/surface.md +18 -8
- package/gsd-core/bin/gsd-tools.cjs +195 -50
- package/gsd-core/bin/lib/capability-activation.cjs +27 -0
- package/gsd-core/bin/lib/capability-registry.cjs +514 -114
- package/gsd-core/bin/lib/capability-state.cjs +7 -1
- package/gsd-core/bin/lib/capability-validator.cjs +120 -4
- package/gsd-core/bin/lib/capability-writer.cjs +14 -4
- package/gsd-core/bin/lib/check-command-router.cjs +85 -2
- package/gsd-core/bin/lib/claude-orchestration.cjs +10 -25
- package/gsd-core/bin/lib/clusters.cjs +1 -0
- package/gsd-core/bin/lib/command-aliases.cjs +16 -0
- package/gsd-core/bin/lib/commands.cjs +337 -13
- package/gsd-core/bin/lib/config-loader.cjs +3 -0
- package/gsd-core/bin/lib/core-utils.cjs +34 -7
- package/gsd-core/bin/lib/decisions.cjs +213 -1
- package/gsd-core/bin/lib/edge-probe.cjs +14 -1
- package/gsd-core/bin/lib/file-overlap-partitioner.cjs +74 -0
- package/gsd-core/bin/lib/frontmatter.cjs +137 -23
- package/gsd-core/bin/lib/gap-checker.cjs +22 -13
- package/gsd-core/bin/lib/git-base-branch.cjs +10 -2
- 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 +54 -11
- package/gsd-core/bin/lib/health-diagnostic-rules/state-consistency.cjs +75 -22
- package/gsd-core/bin/lib/host-integration.cjs +57 -5
- package/gsd-core/bin/lib/init-command-router.cjs +14 -0
- package/gsd-core/bin/lib/init.cjs +132 -15
- package/gsd-core/bin/lib/install-engine.cjs +184 -12
- package/gsd-core/bin/lib/install-model-override-resolver.cjs +45 -0
- package/gsd-core/bin/lib/install-profiles.cjs +22 -14
- package/gsd-core/bin/lib/installer-migration-report.cjs +1 -0
- package/gsd-core/bin/lib/io.cjs +35 -0
- package/gsd-core/bin/lib/loop-resolver.cjs +14 -8
- package/gsd-core/bin/lib/markdown-table.cjs +123 -0
- package/gsd-core/bin/lib/milestone.cjs +22 -2
- package/gsd-core/bin/lib/phase-command-router.cjs +13 -6
- package/gsd-core/bin/lib/phase-id.cjs +251 -9
- package/gsd-core/bin/lib/phase.cjs +774 -35
- package/gsd-core/bin/lib/plan-document.cjs +10 -0
- package/gsd-core/bin/lib/planning-snapshot.cjs +147 -20
- package/gsd-core/bin/lib/planning-workspace.cjs +103 -28
- 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/review-lane-descriptor.cjs +53 -5
- 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-parser.cjs +499 -26
- package/gsd-core/bin/lib/roadmap.cjs +187 -58
- package/gsd-core/bin/lib/runtime-artifact-conversion.cjs +233 -33
- package/gsd-core/bin/lib/runtime-artifact-install-plan.cjs +16 -17
- package/gsd-core/bin/lib/runtime-artifact-layout.cjs +286 -108
- package/gsd-core/bin/lib/runtime-hooks-surface.cjs +215 -43
- package/gsd-core/bin/lib/shell-command-projection.cjs +4 -0
- package/gsd-core/bin/lib/smart-entry.cjs +7 -9
- package/gsd-core/bin/lib/state-document.cjs +30 -5
- package/gsd-core/bin/lib/state-md-schema.cjs +23 -13
- package/gsd-core/bin/lib/state-transition.cjs +333 -44
- package/gsd-core/bin/lib/state.cjs +684 -125
- package/gsd-core/bin/lib/surface.cjs +23 -8
- package/gsd-core/bin/lib/tdd-red-evidence.cjs +133 -0
- package/gsd-core/bin/lib/uat.cjs +1419 -515
- package/gsd-core/bin/lib/update-context.cjs +6 -2
- package/gsd-core/bin/lib/validate.cjs +230 -12
- package/gsd-core/bin/lib/verification-command-router.cjs +2 -1
- package/gsd-core/bin/lib/verification.cjs +273 -12
- package/gsd-core/bin/lib/verify-command-router.cjs +1 -0
- package/gsd-core/bin/lib/verify.cjs +346 -16
- package/gsd-core/bin/lib/workstream-inventory.cjs +20 -2
- package/gsd-core/bin/lib/worktree-safety.cjs +8 -0
- package/gsd-core/bin/shared/config-schema.manifest.json +8 -0
- package/gsd-core/bin/verify-reapply-patches.cjs +70 -3
- package/gsd-core/references/agent-contracts.md +3 -3
- package/gsd-core/references/edge-probe.md +17 -13
- package/gsd-core/references/execute-mvp-tdd.md +18 -16
- package/gsd-core/references/execute-phase-response-language.md +6 -0
- package/gsd-core/references/executor-examples.md +42 -0
- package/gsd-core/references/few-shot-examples/plan-checker.md +15 -15
- package/gsd-core/references/mvp-concepts.md +2 -2
- 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-quick-batch.md +71 -0
- package/gsd-core/references/planner-reviews.md +47 -0
- package/gsd-core/references/planner-revision.md +75 -2
- package/gsd-core/references/planning-config.md +2 -1
- package/gsd-core/references/response-language-directive.md +9 -0
- package/gsd-core/references/revision-loop.md +118 -11
- package/gsd-core/references/tdd.md +14 -9
- package/gsd-core/references/verifier-evidence-gate.md +160 -0
- package/gsd-core/templates/phase-prompt.md +4 -0
- package/gsd-core/templates/verification-report.md +5 -0
- package/gsd-core/workflows/add-backlog.md +2 -0
- package/gsd-core/workflows/add-phase.md +2 -0
- package/gsd-core/workflows/add-tests.md +1 -1
- package/gsd-core/workflows/add-todo.md +1 -1
- package/gsd-core/workflows/ai-integration-phase.md +1 -1
- package/gsd-core/workflows/analyze-dependencies.md +2 -0
- package/gsd-core/workflows/audit-fix.md +2 -0
- package/gsd-core/workflows/audit-milestone.md +2 -0
- package/gsd-core/workflows/audit-uat.md +2 -0
- package/gsd-core/workflows/autonomous.md +2 -0
- package/gsd-core/workflows/check-todos.md +1 -1
- package/gsd-core/workflows/cleanup.md +1 -1
- package/gsd-core/workflows/code-review/steps/structural-pre-pass.md +15 -13
- package/gsd-core/workflows/code-review-fix.md +2 -0
- package/gsd-core/workflows/code-review.md +73 -31
- package/gsd-core/workflows/complete-milestone.md +13 -4
- package/gsd-core/workflows/debug.md +1 -1
- package/gsd-core/workflows/diagnose-issues.md +5 -1
- package/gsd-core/workflows/discuss-phase/modes/advisor.md +2 -0
- 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 +2 -0
- 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 +2 -0
- 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.md +1 -1
- package/gsd-core/workflows/discuss-phase-power.md +2 -0
- package/gsd-core/workflows/discuss-phase.md +1 -1
- package/gsd-core/workflows/do.md +43 -13
- package/gsd-core/workflows/docs-update.md +1 -1
- package/gsd-core/workflows/edit-phase.md +2 -0
- package/gsd-core/workflows/eval-review.md +1 -1
- package/gsd-core/workflows/execute-phase/steps/codebase-drift-gate.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/executor-isolation-dispatch.md +17 -1
- package/gsd-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +8 -2
- package/gsd-core/workflows/execute-phase/steps/regression-gate-run.md +2 -0
- package/gsd-core/workflows/execute-phase/steps/tdd-applicability-resolution.md +25 -0
- package/gsd-core/workflows/execute-phase/steps/worktree-recovery-policy.md +2 -0
- package/gsd-core/workflows/execute-phase.md +32 -14
- package/gsd-core/workflows/execute-plan.md +8 -8
- package/gsd-core/workflows/explore.md +2 -0
- package/gsd-core/workflows/extract-learnings.md +2 -0
- package/gsd-core/workflows/fast.md +6 -0
- package/gsd-core/workflows/forensics.md +2 -0
- package/gsd-core/workflows/graduation.md +1 -1
- package/gsd-core/workflows/health.md +1 -1
- 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 +3 -3
- package/gsd-core/workflows/inbox.md +1 -1
- package/gsd-core/workflows/ingest-docs.md +1 -1
- package/gsd-core/workflows/insert-phase.md +2 -0
- package/gsd-core/workflows/list-phase-assumptions.md +2 -0
- package/gsd-core/workflows/list-seeds.md +2 -0
- package/gsd-core/workflows/list-workspaces.md +2 -0
- package/gsd-core/workflows/manager.md +3 -3
- package/gsd-core/workflows/map-codebase.md +2 -0
- package/gsd-core/workflows/milestone-summary.md +2 -0
- package/gsd-core/workflows/mvp-phase.md +1 -1
- package/gsd-core/workflows/new-milestone.md +1 -1
- package/gsd-core/workflows/new-project.md +5 -3
- package/gsd-core/workflows/new-workspace.md +1 -1
- package/gsd-core/workflows/next.md +2 -0
- 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 +19 -4
- package/gsd-core/workflows/plan-phase/steps/chunked-planning-mode.md +100 -18
- package/gsd-core/workflows/plan-phase/steps/prd-express-path.md +2 -0
- package/gsd-core/workflows/plan-phase/steps/stall-detection-helpers.md +9 -0
- package/gsd-core/workflows/plan-phase.md +130 -12
- package/gsd-core/workflows/plan-review-convergence.md +102 -10
- package/gsd-core/workflows/plant-seed.md +1 -1
- package/gsd-core/workflows/pr-branch.md +11 -3
- package/gsd-core/workflows/profile-user.md +1 -1
- package/gsd-core/workflows/progress/steps/forensic-audit.md +1 -1
- package/gsd-core/workflows/progress.md +25 -3
- package/gsd-core/workflows/quick/steps/plan-checker-loop.md +37 -2
- package/gsd-core/workflows/quick/steps/research-phase.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 +13 -3
- package/gsd-core/workflows/reapply-patches.md +2 -0
- package/gsd-core/workflows/remove-phase.md +2 -0
- package/gsd-core/workflows/remove-workspace.md +1 -1
- package/gsd-core/workflows/resume-project.md +6 -2
- package/gsd-core/workflows/review.md +215 -10
- package/gsd-core/workflows/scan.md +2 -0
- package/gsd-core/workflows/section-manifest.json +12 -0
- package/gsd-core/workflows/secure-phase.md +1 -1
- package/gsd-core/workflows/session-report.md +2 -0
- package/gsd-core/workflows/settings-advanced.md +2 -0
- package/gsd-core/workflows/settings-integrations.md +9 -8
- package/gsd-core/workflows/settings.md +1 -1
- package/gsd-core/workflows/ship.md +10 -10
- package/gsd-core/workflows/sketch-wrap-up.md +2 -0
- package/gsd-core/workflows/sketch.md +1 -1
- package/gsd-core/workflows/smart-entry.md +1 -1
- package/gsd-core/workflows/spec-phase.md +24 -19
- package/gsd-core/workflows/spike-wrap-up.md +2 -0
- package/gsd-core/workflows/spike.md +1 -1
- package/gsd-core/workflows/stats.md +2 -0
- package/gsd-core/workflows/sync-skills.md +12 -4
- package/gsd-core/workflows/thread.md +2 -0
- package/gsd-core/workflows/transition.md +2 -0
- package/gsd-core/workflows/ui-phase.md +26 -5
- package/gsd-core/workflows/ui-review.md +1 -1
- package/gsd-core/workflows/ultraplan-phase.md +2 -0
- package/gsd-core/workflows/undo.md +1 -1
- package/gsd-core/workflows/update.md +41 -38
- package/gsd-core/workflows/validate-phase.md +1 -1
- package/gsd-core/workflows/verify-work.md +49 -3
- package/hooks/dist/gsd-check-update-worker.js +19 -2
- package/hooks/dist/gsd-context-monitor.js +283 -12
- package/hooks/dist/gsd-node-runner.sh +1 -0
- package/hooks/dist/gsd-prompt-guard.js +30 -5
- package/hooks/dist/gsd-read-guard.js +2 -0
- package/hooks/dist/gsd-read-injection-scanner.js +5 -5
- package/hooks/dist/gsd-secret-read-guard.js +1079 -0
- package/hooks/dist/gsd-statusline.js +7 -3
- package/hooks/dist/gsd-validate-commit.sh +444 -7
- package/hooks/dist/gsd-workflow-guard.js +2 -1
- package/hooks/dist/lib/git-cmd.js +210 -1
- package/hooks/dist/lib/injection-patterns.js +36 -6
- package/hooks/dist/managed-hooks-registry.cjs +1 -0
- package/hooks/gsd-check-update-worker.js +19 -2
- package/hooks/gsd-context-monitor.js +283 -12
- package/hooks/gsd-node-runner.sh +1 -0
- package/hooks/gsd-prompt-guard.js +30 -5
- package/hooks/gsd-read-guard.js +2 -0
- package/hooks/gsd-read-injection-scanner.js +5 -5
- package/hooks/gsd-secret-read-guard.js +1079 -0
- package/hooks/gsd-statusline.js +7 -3
- package/hooks/gsd-validate-commit.sh +444 -7
- package/hooks/gsd-workflow-guard.js +2 -1
- package/hooks/hooks.json +6 -0
- package/hooks/lib/git-cmd.js +210 -1
- package/hooks/lib/injection-patterns.js +36 -6
- package/hooks/managed-hooks-registry.cjs +1 -0
- package/package.json +5 -5
- package/scripts/build-hooks.js +11 -4
- package/scripts/ci-test-scope.cjs +7 -0
- package/scripts/docs-guard-registry.cjs +10 -0
- package/scripts/gen-loop-host-contract.cjs +67 -15
- 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.exempt-baseline.cjs +5 -0
- package/scripts/lint-phase-enumeration-drift.cjs +24 -6
- package/scripts/lint-phase-id-drift.cjs +133 -8
- package/scripts/lint-portable-grep.cjs +176 -0
- package/scripts/lint-response-language-coverage.cjs +524 -0
- package/scripts/lint-test-file-count.allowlist.json +3 -1
- package/scripts/lint-workflow-shellcheck-baseline.json +1027 -0
- package/scripts/lint-workflow-shellcheck.cjs +614 -0
- package/scripts/npm-audit-baseline.cjs +376 -0
- package/scripts/prompt-injection-scan.sh +8 -0
- package/scripts/require-issue-link-policy.cjs +16 -1
- package/skills/gsd-execute-phase/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-batch/SKILL.md +105 -0
- package/skills/gsd-surface/SKILL.md +18 -8
- package/vscode/package.json +1 -1
package/gsd-core/bin/lib/uat.cjs
CHANGED
|
@@ -31,7 +31,7 @@ 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;
|
|
@@ -278,8 +278,26 @@ function cmdAuditUat(cwd, raw) {
|
|
|
278
278
|
parse_gap_files: results.filter((r) => r.parse_gap).length,
|
|
279
279
|
by_category: {},
|
|
280
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: {} },
|
|
281
288
|
};
|
|
282
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
|
+
}
|
|
283
301
|
// Deliberate (#3707 follow-up MINOR): this seeds a `by_phase` key at 0
|
|
284
302
|
// even for a parse-gap-only phase whose `items` is empty — do NOT "tidy"
|
|
285
303
|
// this away as dead code. The 0-valued key is itself the cue that this
|
|
@@ -1706,7 +1724,7 @@ function parseGapsTableItems(sectionBody) {
|
|
|
1706
1724
|
* surfaced.
|
|
1707
1725
|
*
|
|
1708
1726
|
* #3457: when the section body contains headings, entries are delimited by
|
|
1709
|
-
* LEAF headings (see `
|
|
1727
|
+
* LEAF headings (see `splitDeferredHeadingEntriesDetailed`) rather than by bullets —
|
|
1710
1728
|
* the executor convention writes one deferred item as a heading followed by
|
|
1711
1729
|
* sibling `- **Field:** …` bullets, which the bullet-only split mis-counted as
|
|
1712
1730
|
* one item PER BULLET. A body with no headings keeps the original
|
|
@@ -1732,18 +1750,27 @@ function parseDeferredItemsWithStatus(content) {
|
|
|
1732
1750
|
// before field extraction, not just line 0 (which `extractGapEntryFields`
|
|
1733
1751
|
// does for the headless/Gaps shape, where a later `- ` line is a nested
|
|
1734
1752
|
// sub-list, not a field).
|
|
1735
|
-
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.
|
|
1736
1761
|
const entries = headingEntries !== null
|
|
1737
|
-
? headingEntries.map((
|
|
1738
|
-
lines:
|
|
1739
|
-
|
|
1762
|
+
? headingEntries.map((entry) => ({
|
|
1763
|
+
lines: entry.lines,
|
|
1764
|
+
opener: entry.opener,
|
|
1765
|
+
fields: extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener),
|
|
1740
1766
|
}))
|
|
1741
|
-
: splitGapsEntries(sectionBody).map((entryLines) => ({
|
|
1767
|
+
: splitGapsEntries(sectionBody, DEFERRED_BULLET_MARKERS).map((entryLines) => ({
|
|
1742
1768
|
lines: entryLines,
|
|
1743
|
-
|
|
1769
|
+
opener: undefined,
|
|
1770
|
+
fields: extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS),
|
|
1744
1771
|
}));
|
|
1745
|
-
for (const { lines: entryLines, fields } of entries) {
|
|
1746
|
-
const text = rawGapEntryText(entryLines);
|
|
1772
|
+
for (const { lines: entryLines, opener, fields } of entries) {
|
|
1773
|
+
const text = rawGapEntryText(entryLines, DEFERRED_BULLET_MARKERS, opener);
|
|
1747
1774
|
if (!text)
|
|
1748
1775
|
continue;
|
|
1749
1776
|
items.push({ name: text, status: fields.status || '' });
|
|
@@ -1771,6 +1798,47 @@ function parseDeferredItems(content) {
|
|
|
1771
1798
|
category: 'deferred',
|
|
1772
1799
|
}));
|
|
1773
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
|
+
}
|
|
1774
1842
|
/**
|
|
1775
1843
|
* CLI-writer half of the #3458 follow-up deferred_items suppression seam.
|
|
1776
1844
|
* Sets the ONE deferred entry whose rendered text (`rawGapEntryText`, the
|
|
@@ -1785,25 +1853,27 @@ function parseDeferredItems(content) {
|
|
|
1785
1853
|
* `status:` away from `acknowledged` (or delete the field) and it resurfaces
|
|
1786
1854
|
* with no separate cleanup step, exactly like every other category's marker.
|
|
1787
1855
|
*
|
|
1788
|
-
* #3781: the heading-delimited (#3457) entry shape is SUPPORTED
|
|
1789
|
-
*
|
|
1790
|
-
*
|
|
1791
|
-
*
|
|
1792
|
-
*
|
|
1793
|
-
*
|
|
1794
|
-
*
|
|
1795
|
-
*
|
|
1796
|
-
*
|
|
1797
|
-
*
|
|
1798
|
-
*
|
|
1799
|
-
*
|
|
1800
|
-
*
|
|
1801
|
-
*
|
|
1802
|
-
*
|
|
1803
|
-
*
|
|
1804
|
-
*
|
|
1805
|
-
*
|
|
1806
|
-
*
|
|
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.
|
|
1807
1877
|
*
|
|
1808
1878
|
* Also refuses `ambiguous` (2+ entries share the exact same text — status must
|
|
1809
1879
|
* be unique to identify one) and `not_found`, and is a no-op
|
|
@@ -1845,15 +1915,15 @@ function parseDeferredItems(content) {
|
|
|
1845
1915
|
function acknowledgeDeferredItem(content, targetText) {
|
|
1846
1916
|
const deferredSection = collectSection(content, (h) => /^deferred\s+items$/i.test(h.text) && h.level === 2, { levelBounded: true });
|
|
1847
1917
|
const sectionBody = deferredSection ? deferredSection.body : content;
|
|
1848
|
-
// #3781: the heading-delimited shape carries its own
|
|
1849
|
-
// headless path below is unchanged.
|
|
1850
|
-
const headingEntries =
|
|
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);
|
|
1851
1921
|
if (headingEntries !== null) {
|
|
1852
1922
|
return acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText });
|
|
1853
1923
|
}
|
|
1854
|
-
const entries = splitGapsEntriesWithSpans(sectionBody);
|
|
1924
|
+
const entries = splitGapsEntriesWithSpans(sectionBody, DEFERRED_BULLET_MARKERS);
|
|
1855
1925
|
const matches = entries
|
|
1856
|
-
.map((entry) => ({ entry, text: rawGapEntryText(entry.lines) }))
|
|
1926
|
+
.map((entry) => ({ entry, text: rawGapEntryText(entry.lines, DEFERRED_BULLET_MARKERS) }))
|
|
1857
1927
|
.filter((e) => e.text === targetText);
|
|
1858
1928
|
if (matches.length === 0)
|
|
1859
1929
|
return { content, status: 'not_found' };
|
|
@@ -1861,7 +1931,7 @@ function acknowledgeDeferredItem(content, targetText) {
|
|
|
1861
1931
|
return { content, status: 'ambiguous' };
|
|
1862
1932
|
const { entry } = matches[0];
|
|
1863
1933
|
const { lines: entryLines, start, end } = entry;
|
|
1864
|
-
const fields = extractGapEntryFields(entryLines);
|
|
1934
|
+
const fields = extractGapEntryFields(entryLines, DEFERRED_BULLET_MARKERS);
|
|
1865
1935
|
if (fields.status && fields.status.toLowerCase() === 'resolved') {
|
|
1866
1936
|
return { content, status: 'already_resolved' };
|
|
1867
1937
|
}
|
|
@@ -1876,69 +1946,132 @@ function acknowledgeDeferredItem(content, targetText) {
|
|
|
1876
1946
|
// comparison that selected this entry — this catches real drift between
|
|
1877
1947
|
// the two rather than a regex trivially guaranteed to agree with itself.
|
|
1878
1948
|
const strippedForVerify = matchedLines.map((l) => l.replace(/\r$/, ''));
|
|
1879
|
-
if (rawGapEntryText(strippedForVerify) !== targetText) {
|
|
1949
|
+
if (rawGapEntryText(strippedForVerify, DEFERRED_BULLET_MARKERS) !== targetText) {
|
|
1880
1950
|
return { content, status: 'match_verification_failed' };
|
|
1881
1951
|
}
|
|
1882
1952
|
const matchIndexInContent = sectionOffset + start;
|
|
1883
|
-
//
|
|
1884
|
-
//
|
|
1885
|
-
//
|
|
1886
|
-
//
|
|
1887
|
-
//
|
|
1888
|
-
// form: the reader de-bullets it, so `- status: open` as the entry line is
|
|
1889
|
-
// a real field there (and first-wins means the insert branch could not
|
|
1890
|
-
// outrank it). Everything else falls through to the insert branch below,
|
|
1891
|
-
// which the marker-free and no-status controls already round-trip.
|
|
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`.
|
|
1892
1958
|
//
|
|
1893
|
-
//
|
|
1894
|
-
//
|
|
1895
|
-
// `
|
|
1896
|
-
//
|
|
1897
|
-
//
|
|
1898
|
-
//
|
|
1899
|
-
//
|
|
1900
|
-
|
|
1901
|
-
|
|
1902
|
-
|
|
1903
|
-
|
|
1904
|
-
|
|
1905
|
-
|
|
1906
|
-
|
|
1907
|
-
|
|
1908
|
-
|
|
1909
|
-
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
//
|
|
1913
|
-
//
|
|
1914
|
-
//
|
|
1915
|
-
//
|
|
1916
|
-
//
|
|
1917
|
-
//
|
|
1918
|
-
//
|
|
1919
|
-
//
|
|
1920
|
-
//
|
|
1921
|
-
//
|
|
1922
|
-
// on-disk CRLF document before normalization), `String.prototype.replace`
|
|
1923
|
-
// consumes it as part of `.*$` and the replacement text does not
|
|
1924
|
-
// reproduce it, so it is dropped here too — consistent with the eventual
|
|
1925
|
-
// whole-file normalization rather than duplicating it.
|
|
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.)
|
|
1926
1988
|
let newMatchedLines;
|
|
1927
1989
|
if (statusLineIdx === -1) {
|
|
1928
|
-
const bulletIndentMatch = matchedLines[0].match(
|
|
1929
|
-
|
|
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));
|
|
1930
2019
|
newMatchedLines = [
|
|
1931
|
-
matchedLines[0],
|
|
1932
|
-
`${continuationIndent}status: acknowledged`,
|
|
2020
|
+
crlf ? `${matchedLines[0].replace(/\r$/, '')}\r` : matchedLines[0],
|
|
2021
|
+
`${continuationIndent}status: acknowledged${line0HadCr ? '\r' : ''}`,
|
|
1933
2022
|
...matchedLines.slice(1),
|
|
1934
2023
|
];
|
|
1935
2024
|
}
|
|
1936
2025
|
else {
|
|
1937
|
-
|
|
1938
|
-
|
|
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) ? '' : ' ';
|
|
1939
2044
|
newMatchedLines = matchedLines.slice();
|
|
1940
|
-
newMatchedLines[statusLineIdx] =
|
|
2045
|
+
newMatchedLines[statusLineIdx] = `${prefix}${sep}acknowledged${cr}`;
|
|
1941
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.
|
|
1942
2075
|
const newContent = content.slice(0, matchIndexInContent) + newMatchedLines.join('\n') + content.slice(matchIndexInContent + (end - start));
|
|
1943
2076
|
return { content: newContent, status: 'ok' };
|
|
1944
2077
|
}
|
|
@@ -1957,132 +2090,6 @@ function stripAtxPrefix(line) {
|
|
|
1957
2090
|
? ''
|
|
1958
2091
|
: m[3].replace(/^[ \t]+/, '').replace(/[ \t]+#+[ \t]*$/, '').replace(/^#+[ \t]*$/, '').trim();
|
|
1959
2092
|
}
|
|
1960
|
-
/**
|
|
1961
|
-
* #3781 — span-carrying sibling of `splitDeferredHeadingEntries`: ONE walk,
|
|
1962
|
-
* identical grouping rules (leaf = childless heading whose body carries a
|
|
1963
|
-
* bullet; container = next heading deeper; preamble/container-direct lines →
|
|
1964
|
-
* headless entries; table lines excluded), additionally recording each
|
|
1965
|
-
* entry's (start, end) character span within `sectionBody`. Returns null when
|
|
1966
|
-
* the body contains no heading at all — the caller then takes the unchanged
|
|
1967
|
-
* fully-headless path.
|
|
1968
|
-
*/
|
|
1969
|
-
function splitDeferredHeadingEntriesWithSpans(sectionBody) {
|
|
1970
|
-
const headings = tokenizeHeadings(sectionBody);
|
|
1971
|
-
if (headings.length === 0)
|
|
1972
|
-
return null;
|
|
1973
|
-
const lines = sectionBody.split('\n');
|
|
1974
|
-
const lineStarts = [];
|
|
1975
|
-
const lineEnds = [];
|
|
1976
|
-
let cursor = 0;
|
|
1977
|
-
for (const rawLine of lines) {
|
|
1978
|
-
lineStarts.push(cursor);
|
|
1979
|
-
cursor += rawLine.length;
|
|
1980
|
-
lineEnds.push(cursor);
|
|
1981
|
-
cursor += 1;
|
|
1982
|
-
}
|
|
1983
|
-
const headingByLine = new Map();
|
|
1984
|
-
for (let i = 0; i < headings.length; i++) {
|
|
1985
|
-
const isContainer = i + 1 < headings.length && headings[i + 1].level > headings[i].level;
|
|
1986
|
-
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
|
|
1987
|
-
}
|
|
1988
|
-
const isTableLine = (l) => /^\s*\|/.test(l.replace(/\r$/, ''));
|
|
1989
|
-
const isBulletLine = (l) => /^\s*-\s/.test(l.replace(/\r$/, ''));
|
|
1990
|
-
const entries = [];
|
|
1991
|
-
let current = null;
|
|
1992
|
-
let currentReaderLine0 = null;
|
|
1993
|
-
let currentStartLine = -1;
|
|
1994
|
-
let currentEndLine = -1;
|
|
1995
|
-
let currentHasBullet = false;
|
|
1996
|
-
let currentTable = false;
|
|
1997
|
-
let pendingStartLine = -1;
|
|
1998
|
-
let pendingEndLine = -1;
|
|
1999
|
-
const flushCurrent = () => {
|
|
2000
|
-
if (current !== null && currentHasBullet && currentReaderLine0 !== null) {
|
|
2001
|
-
const bodyReader = current.slice(1).map(stripLeadingBulletMarker);
|
|
2002
|
-
entries.push({
|
|
2003
|
-
kind: 'leaf',
|
|
2004
|
-
lines: current,
|
|
2005
|
-
readerLines: [currentReaderLine0, ...bodyReader],
|
|
2006
|
-
text: rawGapEntryText([currentReaderLine0, ...current.slice(1)]),
|
|
2007
|
-
fields: extractGapEntryFields([currentReaderLine0, ...bodyReader]),
|
|
2008
|
-
start: lineStarts[currentStartLine],
|
|
2009
|
-
end: lineEnds[currentEndLine],
|
|
2010
|
-
embeddedTable: currentTable,
|
|
2011
|
-
});
|
|
2012
|
-
}
|
|
2013
|
-
current = null;
|
|
2014
|
-
currentReaderLine0 = null;
|
|
2015
|
-
currentStartLine = -1;
|
|
2016
|
-
currentEndLine = -1;
|
|
2017
|
-
currentHasBullet = false;
|
|
2018
|
-
currentTable = false;
|
|
2019
|
-
};
|
|
2020
|
-
const flushPending = () => {
|
|
2021
|
-
if (pendingStartLine === -1)
|
|
2022
|
-
return;
|
|
2023
|
-
// The pending region is contiguous (a heading flushes it), but table lines
|
|
2024
|
-
// inside it were skipped by the walk: the reader's identity for this
|
|
2025
|
-
// region is computed over the table-FILTERED join, which may merge
|
|
2026
|
-
// entries across the gap, so spans cannot be translated faithfully —
|
|
2027
|
-
// mark the region's entries as refusing instead.
|
|
2028
|
-
let regionTable = false;
|
|
2029
|
-
for (let i = pendingStartLine; i <= pendingEndLine; i++) {
|
|
2030
|
-
if (isTableLine(lines[i]))
|
|
2031
|
-
regionTable = true;
|
|
2032
|
-
}
|
|
2033
|
-
const base = lineStarts[pendingStartLine];
|
|
2034
|
-
const regionText = sectionBody.slice(lineStarts[pendingStartLine], lineEnds[pendingEndLine]);
|
|
2035
|
-
for (const e of splitGapsEntriesWithSpans(regionText)) {
|
|
2036
|
-
entries.push({
|
|
2037
|
-
kind: 'pending',
|
|
2038
|
-
lines: e.lines,
|
|
2039
|
-
readerLines: e.lines,
|
|
2040
|
-
text: rawGapEntryText(e.lines),
|
|
2041
|
-
fields: extractGapEntryFields(e.lines),
|
|
2042
|
-
start: base + e.start,
|
|
2043
|
-
end: base + e.end,
|
|
2044
|
-
embeddedTable: regionTable,
|
|
2045
|
-
});
|
|
2046
|
-
}
|
|
2047
|
-
pendingStartLine = -1;
|
|
2048
|
-
pendingEndLine = -1;
|
|
2049
|
-
};
|
|
2050
|
-
for (let i = 0; i < lines.length; i++) {
|
|
2051
|
-
const heading = headingByLine.get(i + 1);
|
|
2052
|
-
if (heading !== undefined) {
|
|
2053
|
-
flushCurrent();
|
|
2054
|
-
flushPending();
|
|
2055
|
-
if (!heading.isContainer) {
|
|
2056
|
-
current = [lines[i]];
|
|
2057
|
-
currentReaderLine0 = heading.text;
|
|
2058
|
-
currentStartLine = i;
|
|
2059
|
-
currentEndLine = i;
|
|
2060
|
-
currentHasBullet = false;
|
|
2061
|
-
currentTable = false;
|
|
2062
|
-
}
|
|
2063
|
-
continue;
|
|
2064
|
-
}
|
|
2065
|
-
if (isTableLine(lines[i])) {
|
|
2066
|
-
if (current !== null)
|
|
2067
|
-
currentTable = true;
|
|
2068
|
-
continue;
|
|
2069
|
-
}
|
|
2070
|
-
if (current !== null) {
|
|
2071
|
-
current.push(lines[i]);
|
|
2072
|
-
currentEndLine = i;
|
|
2073
|
-
if (isBulletLine(lines[i]))
|
|
2074
|
-
currentHasBullet = true;
|
|
2075
|
-
}
|
|
2076
|
-
else {
|
|
2077
|
-
if (pendingStartLine === -1)
|
|
2078
|
-
pendingStartLine = i;
|
|
2079
|
-
pendingEndLine = i;
|
|
2080
|
-
}
|
|
2081
|
-
}
|
|
2082
|
-
flushCurrent();
|
|
2083
|
-
flushPending();
|
|
2084
|
-
return entries;
|
|
2085
|
-
}
|
|
2086
2093
|
/**
|
|
2087
2094
|
* #3781 — the heading-shaped half of `acknowledgeDeferredItem`, sharing the
|
|
2088
2095
|
* headless path's guards (not_found / ambiguous / already_resolved /
|
|
@@ -2090,142 +2097,408 @@ function splitDeferredHeadingEntriesWithSpans(sectionBody) {
|
|
|
2090
2097
|
* shape-specific rules documented on `acknowledgeDeferredItem` (reader-form
|
|
2091
2098
|
* status search incl. the leaf line-0 ATX corner; insert after the entry's
|
|
2092
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.
|
|
2093
2111
|
*/
|
|
2094
2112
|
function acknowledgeHeadingShapedEntry({ content, sectionBody, deferredSection, headingEntries, targetText }) {
|
|
2095
|
-
const matches = headingEntries.filter((e) => e.
|
|
2113
|
+
const matches = headingEntries.filter((e) => rawGapEntryText(e.lines, DEFERRED_BULLET_MARKERS, e.opener) === targetText);
|
|
2096
2114
|
if (matches.length === 0)
|
|
2097
2115
|
return { content, status: 'not_found' };
|
|
2098
2116
|
if (matches.length > 1)
|
|
2099
2117
|
return { content, status: 'ambiguous' };
|
|
2100
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.
|
|
2101
2121
|
if (entry.embeddedTable)
|
|
2102
2122
|
return { content, status: 'unsupported_heading_shape' };
|
|
2103
|
-
|
|
2123
|
+
const fields = extractGapEntryFields(entry.lines, DEFERRED_BULLET_MARKERS, entry.opener);
|
|
2124
|
+
if (fields.status && fields.status.toLowerCase() === 'resolved') {
|
|
2104
2125
|
return { content, status: 'already_resolved' };
|
|
2105
2126
|
}
|
|
2106
2127
|
const sectionOffset = deferredSection ? deferredSection.bodyStart : 0;
|
|
2107
|
-
const
|
|
2108
|
-
|
|
2109
|
-
//
|
|
2110
|
-
// the
|
|
2111
|
-
//
|
|
2112
|
-
//
|
|
2113
|
-
const
|
|
2114
|
-
|
|
2115
|
-
|
|
2116
|
-
|
|
2117
|
-
|
|
2118
|
-
|
|
2119
|
-
|
|
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) {
|
|
2120
2143
|
return { content, status: 'match_verification_failed' };
|
|
2121
2144
|
}
|
|
2122
|
-
|
|
2123
|
-
|
|
2124
|
-
// #3740). Reader lines are index-aligned 1:1 with the raw lines.
|
|
2125
|
-
const statusFieldBoldedRe = /^\s*\*+status:\*+/i;
|
|
2126
|
-
const statusFieldBareRe = /^\s*status:/;
|
|
2127
|
-
const statusFieldBoldedReLine0 = /^\s*(?:-\s+)?\*+status:\*+/i;
|
|
2128
|
-
const statusFieldBareReLine0 = /^\s*(?:-\s+)?status:/;
|
|
2129
|
-
const readerLines = entry.kind === 'leaf'
|
|
2130
|
-
? [
|
|
2131
|
-
stripAtxPrefix(rawSliceLines[0]) ?? rawSliceLines[0].replace(/\r$/, ''),
|
|
2132
|
-
...rawSliceLines.slice(1).map((l) => stripLeadingBulletMarker(l.replace(/\r$/, ''))),
|
|
2133
|
-
]
|
|
2134
|
-
: rawSliceLines.map((l) => l.replace(/\r$/, ''));
|
|
2135
|
-
const statusLineIdx = readerLines.findIndex((line, idx) => idx === 0
|
|
2136
|
-
? (statusFieldBoldedReLine0.test(line) || statusFieldBareReLine0.test(line))
|
|
2137
|
-
: (statusFieldBoldedRe.test(line) || statusFieldBareRe.test(line)));
|
|
2145
|
+
const statusLineIdx = entryFieldLines(readerLines, DEFERRED_BULLET_MARKERS, entry.opener)
|
|
2146
|
+
.findIndex((field) => field?.key === 'status');
|
|
2138
2147
|
let newRawLines;
|
|
2139
2148
|
if (statusLineIdx === -1) {
|
|
2140
2149
|
// Insert branch: after the entry's LAST NON-BLANK line — a heading
|
|
2141
2150
|
// entry's body is frequently a soft-wrapped sentence, and splicing after
|
|
2142
2151
|
// line 0 would split it in half (#3781's sentence trap). The headless
|
|
2143
2152
|
// (no-heading-anywhere) path keeps its own splice-after-line-0 shape.
|
|
2144
|
-
|
|
2145
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
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.
|
|
2148
2167
|
const indent = entry.kind === 'pending'
|
|
2149
|
-
? (()
|
|
2150
|
-
const bulletIndentMatch = rawSliceLines[0].match(/^(\s*)-\s+/);
|
|
2151
|
-
return ' '.repeat((bulletIndentMatch ? bulletIndentMatch[1].length : 0) + 2);
|
|
2152
|
-
})()
|
|
2168
|
+
? `${rawLines[0].replace(/\r$/, '').match(DEFERRED_BULLET_MARKERS.strip)?.[1] ?? ''} `
|
|
2153
2169
|
: ' ';
|
|
2154
|
-
|
|
2155
|
-
|
|
2156
|
-
|
|
2157
|
-
|
|
2158
|
-
|
|
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' : ''}`);
|
|
2159
2183
|
}
|
|
2160
2184
|
else {
|
|
2161
|
-
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
2168
|
-
|
|
2169
|
-
|
|
2170
|
-
|
|
2171
|
-
|
|
2172
|
-
|
|
2173
|
-
|
|
2174
|
-
|
|
2175
|
-
|
|
2176
|
-
|
|
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}`;
|
|
2177
2203
|
}
|
|
2178
2204
|
const matchIndexInContent = sectionOffset + entry.start;
|
|
2179
2205
|
const newContent = content.slice(0, matchIndexInContent) + newRawLines.join('\n') + content.slice(matchIndexInContent + (entry.end - entry.start));
|
|
2180
2206
|
return { content: newContent, status: 'ok' };
|
|
2181
2207
|
}
|
|
2182
2208
|
/**
|
|
2183
|
-
*
|
|
2184
|
-
*
|
|
2185
|
-
*
|
|
2186
|
-
*
|
|
2187
|
-
*
|
|
2188
|
-
*/
|
|
2189
|
-
|
|
2190
|
-
|
|
2191
|
-
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2195
|
-
*
|
|
2196
|
-
*
|
|
2197
|
-
*
|
|
2198
|
-
*
|
|
2199
|
-
*
|
|
2200
|
-
*
|
|
2201
|
-
*
|
|
2202
|
-
*
|
|
2203
|
-
*
|
|
2204
|
-
*
|
|
2205
|
-
*
|
|
2206
|
-
*
|
|
2207
|
-
*
|
|
2208
|
-
*
|
|
2209
|
-
*
|
|
2210
|
-
*
|
|
2211
|
-
*
|
|
2212
|
-
*
|
|
2213
|
-
*
|
|
2214
|
-
*
|
|
2215
|
-
*
|
|
2216
|
-
*
|
|
2217
|
-
*
|
|
2218
|
-
*
|
|
2219
|
-
*
|
|
2220
|
-
*
|
|
2221
|
-
*
|
|
2222
|
-
*
|
|
2223
|
-
|
|
2224
|
-
|
|
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) {
|
|
2225
2497
|
const headings = tokenizeHeadings(sectionBody);
|
|
2226
2498
|
if (headings.length === 0)
|
|
2227
2499
|
return null;
|
|
2228
2500
|
const lines = sectionBody.split('\n');
|
|
2501
|
+
const { lineStarts, lineEnds } = lineOffsets(lines);
|
|
2229
2502
|
const headingByLine = new Map();
|
|
2230
2503
|
for (let i = 0; i < headings.length; i++) {
|
|
2231
2504
|
// Container iff the next heading is deeper (see doc comment). An empty
|
|
@@ -2235,20 +2508,92 @@ function splitDeferredHeadingEntries(sectionBody) {
|
|
|
2235
2508
|
headingByLine.set(headings[i].line, { text: headings[i].text, isContainer });
|
|
2236
2509
|
}
|
|
2237
2510
|
const entries = [];
|
|
2238
|
-
|
|
2239
|
-
let
|
|
2511
|
+
// The leaf entry being accumulated, with the raw line range it spans.
|
|
2512
|
+
let current = null;
|
|
2240
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);
|
|
2241
2540
|
const flushCurrent = () => {
|
|
2242
|
-
// 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
|
|
2243
2542
|
// text line itself (element 0) never counts as one.
|
|
2244
|
-
if (current !== null && currentHasBullet)
|
|
2245
|
-
|
|
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
|
+
}
|
|
2246
2554
|
current = null;
|
|
2247
2555
|
currentHasBullet = false;
|
|
2248
2556
|
};
|
|
2249
2557
|
const flushPending = () => {
|
|
2250
|
-
|
|
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
|
+
}
|
|
2251
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
|
+
}
|
|
2252
2597
|
};
|
|
2253
2598
|
for (let i = 0; i < lines.length; i++) {
|
|
2254
2599
|
const lineNo = i + 1;
|
|
@@ -2259,23 +2604,73 @@ function splitDeferredHeadingEntries(sectionBody) {
|
|
|
2259
2604
|
// ANY heading; flushing here keeps entries in document order even when
|
|
2260
2605
|
// a container's direct bullets precede its first child entry.
|
|
2261
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);
|
|
2262
2612
|
if (!heading.isContainer) {
|
|
2263
2613
|
// Leaf heading: open an entry with the heading text as line 0.
|
|
2264
|
-
current = [heading.text];
|
|
2614
|
+
current = { lines: [heading.text], opener: [false], startLine: i, endLine: i };
|
|
2265
2615
|
currentHasBullet = false;
|
|
2266
2616
|
}
|
|
2267
2617
|
continue;
|
|
2268
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
|
+
}
|
|
2269
2653
|
// Table lines belong to parseDeferredTableItems, never to a heading entry.
|
|
2270
|
-
if (/^\s*\|/.test(
|
|
2654
|
+
if (/^\s*\|/.test(line)) {
|
|
2655
|
+
tableLines.push(i);
|
|
2271
2656
|
continue;
|
|
2657
|
+
}
|
|
2272
2658
|
if (current !== null) {
|
|
2273
|
-
|
|
2274
|
-
|
|
2659
|
+
const opener = matchListOpener(line, DEFERRED_BULLET_MARKERS, runs.at(levelOf(line)));
|
|
2660
|
+
push(line, i, opener !== null);
|
|
2661
|
+
if (opener !== null) {
|
|
2275
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() === '';
|
|
2276
2671
|
}
|
|
2277
2672
|
else {
|
|
2278
|
-
|
|
2673
|
+
push(line, i, false); // the core derives its own opener verdicts for the region
|
|
2279
2674
|
}
|
|
2280
2675
|
}
|
|
2281
2676
|
flushCurrent();
|
|
@@ -2330,70 +2725,139 @@ function parseDeferredTableItems(sectionBody) {
|
|
|
2330
2725
|
* boundary — a second, independently-written grouping pass is exactly how a
|
|
2331
2726
|
* span-carrying sibling could disagree with the plain-lines version it is
|
|
2332
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.
|
|
2333
2735
|
*/
|
|
2334
|
-
function splitGapsEntriesCore(sectionBody) {
|
|
2736
|
+
function splitGapsEntriesCore(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
|
|
2335
2737
|
const rawLines = sectionBody.split('\n');
|
|
2336
|
-
const lineStarts =
|
|
2337
|
-
const lineEnds = [];
|
|
2338
|
-
let cursor = 0;
|
|
2339
|
-
for (const rawLine of rawLines) {
|
|
2340
|
-
lineStarts.push(cursor);
|
|
2341
|
-
cursor += rawLine.length;
|
|
2342
|
-
lineEnds.push(cursor);
|
|
2343
|
-
cursor += 1; // the '\n' separator — absent after the final line, but nothing reads past it
|
|
2344
|
-
}
|
|
2738
|
+
const { lineStarts, lineEnds } = lineOffsets(rawLines);
|
|
2345
2739
|
const entries = [];
|
|
2346
2740
|
let current = null;
|
|
2347
2741
|
let currentStartLine = -1;
|
|
2348
2742
|
let currentEndLine = -1;
|
|
2349
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 = [];
|
|
2350
2753
|
const flush = () => {
|
|
2351
2754
|
if (current !== null) {
|
|
2352
|
-
entries.push({ lines: current, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
|
2755
|
+
entries.push({ lines: current, opener: currentOpeners, start: lineStarts[currentStartLine], end: lineEnds[currentEndLine] });
|
|
2353
2756
|
}
|
|
2354
2757
|
};
|
|
2355
|
-
// #3898: a spaced-hyphen thematic break (`- - -`, `- -`,
|
|
2356
|
-
// SEPARATOR, not an entry. The
|
|
2357
|
-
// whitespace), which fabricated a gap named `- -` with
|
|
2358
|
-
//
|
|
2359
|
-
//
|
|
2360
|
-
//
|
|
2361
|
-
//
|
|
2362
|
-
//
|
|
2363
|
-
//
|
|
2364
|
-
//
|
|
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.
|
|
2365
2770
|
const isSeparatorShaped = (line, bulletPrefixLen) => {
|
|
2366
2771
|
const remainder = line.slice(bulletPrefixLen);
|
|
2367
2772
|
return /^[-\s]*$/.test(remainder) && remainder.includes('-');
|
|
2368
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
|
+
};
|
|
2369
2786
|
rawLines.forEach((rawLine, idx) => {
|
|
2370
2787
|
const line = rawLine.replace(/\r$/, '');
|
|
2371
|
-
|
|
2372
|
-
//
|
|
2373
|
-
//
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
// line, so the entry's GapsEntrySpan stays byte-contiguous — the span
|
|
2377
|
-
// invariant below and the ack writer's identity re-verification both hold.
|
|
2378
|
-
if (bulletMatch && isSeparatorShaped(line, bulletMatch[0].length) &&
|
|
2379
|
-
(current === null || bulletMatch[1].length <= (baseIndent ?? 0))) {
|
|
2380
|
-
return; // separator line between entries — neither an opener nor a continuation
|
|
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);
|
|
2381
2793
|
}
|
|
2382
|
-
if (
|
|
2383
|
-
|
|
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;
|
|
2384
2837
|
if (baseIndent === null)
|
|
2385
2838
|
baseIndent = indent;
|
|
2839
|
+
runs.opened(levelOf(line));
|
|
2386
2840
|
if (indent <= baseIndent) {
|
|
2387
2841
|
flush();
|
|
2388
2842
|
current = [line];
|
|
2843
|
+
currentOpeners = [true];
|
|
2389
2844
|
currentStartLine = idx;
|
|
2390
2845
|
currentEndLine = idx;
|
|
2846
|
+
blankSeen = false; // an opener is not blank — the memory must not survive it
|
|
2391
2847
|
return;
|
|
2392
2848
|
}
|
|
2393
2849
|
}
|
|
2394
2850
|
if (current !== null) {
|
|
2395
2851
|
current.push(line);
|
|
2852
|
+
currentOpeners.push(opener !== null);
|
|
2396
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;
|
|
2397
2861
|
}
|
|
2398
2862
|
// else: pre-first-bullet content (e.g. the template's HTML comment) — discarded.
|
|
2399
2863
|
});
|
|
@@ -2402,7 +2866,8 @@ function splitGapsEntriesCore(sectionBody) {
|
|
|
2402
2866
|
}
|
|
2403
2867
|
/**
|
|
2404
2868
|
* Split a `## Gaps` section body into per-entry line groups on TOP-LEVEL
|
|
2405
|
-
* `- `
|
|
2869
|
+
* bullet openers — `- ` for Gaps, or whichever set `markers` names (#3702:
|
|
2870
|
+
* the deferred-items callers pass the widened CommonMark set).
|
|
2406
2871
|
*
|
|
2407
2872
|
* The indentation of the FIRST bullet line encountered establishes the
|
|
2408
2873
|
* "top-level" indent for the whole section; any subsequent `- `-opening line
|
|
@@ -2415,16 +2880,17 @@ function splitGapsEntriesCore(sectionBody) {
|
|
|
2415
2880
|
*
|
|
2416
2881
|
* Lines before the first bullet (e.g. the `<!-- YAML format ... -->` comment
|
|
2417
2882
|
* the template emits) are discarded. An empty/whitespace-only section body
|
|
2418
|
-
* (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).
|
|
2419
2885
|
*/
|
|
2420
|
-
function splitGapsEntries(sectionBody) {
|
|
2421
|
-
return splitGapsEntriesCore(sectionBody).map((entry) => entry.lines);
|
|
2886
|
+
function splitGapsEntries(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
|
|
2887
|
+
return splitGapsEntriesCore(sectionBody, markers).map((entry) => entry.lines);
|
|
2422
2888
|
}
|
|
2423
2889
|
/**
|
|
2424
2890
|
* Sibling of `splitGapsEntries` (F1, #3458 follow-up review) that ADDITIVELY
|
|
2425
2891
|
* carries each entry's character span — every existing `splitGapsEntries`
|
|
2426
2892
|
* caller (`parseGapsItems`, `parseDeferredItemsWithStatus`,
|
|
2427
|
-
* `
|
|
2893
|
+
* `splitDeferredHeadingEntriesDetailed`'s `flushPending`) is unaffected and keeps
|
|
2428
2894
|
* using the plain `lines`-only shape. `acknowledgeDeferredItem` is the one
|
|
2429
2895
|
* caller that needs a span: it used to select an entry via `splitGapsEntries`
|
|
2430
2896
|
* and then RE-FIND that entry's location with a fresh regex search over
|
|
@@ -2436,8 +2902,8 @@ function splitGapsEntries(sectionBody) {
|
|
|
2436
2902
|
* one. Carrying the span out of THIS same pass — the one that already knows
|
|
2437
2903
|
* exactly where the entry lives — removes the re-derivation step entirely.
|
|
2438
2904
|
*/
|
|
2439
|
-
function splitGapsEntriesWithSpans(sectionBody) {
|
|
2440
|
-
return splitGapsEntriesCore(sectionBody);
|
|
2905
|
+
function splitGapsEntriesWithSpans(sectionBody, markers = HYPHEN_BULLET_MARKERS) {
|
|
2906
|
+
return splitGapsEntriesCore(sectionBody, markers);
|
|
2441
2907
|
}
|
|
2442
2908
|
/**
|
|
2443
2909
|
* Extract `key: value` fields from one Gaps entry's lines, anchored to the
|
|
@@ -2465,182 +2931,603 @@ function splitGapsEntriesWithSpans(sectionBody) {
|
|
|
2465
2931
|
* keep their literal case, and mid-line emphasis is untouched, preserving the
|
|
2466
2932
|
* start-anchored decoy invariant above.
|
|
2467
2933
|
*/
|
|
2468
|
-
function extractGapEntryFields(entryLines) {
|
|
2934
|
+
function extractGapEntryFields(entryLines, markers = HYPHEN_BULLET_MARKERS, openerFlags) {
|
|
2469
2935
|
const fields = {};
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
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)
|
|
2483
2953
|
return;
|
|
2484
|
-
|
|
2485
|
-
|
|
2486
|
-
if (value.startsWith('"') && value.endsWith('"') && value.length >= 2) {
|
|
2487
|
-
value = value.slice(1, -1);
|
|
2488
|
-
}
|
|
2489
|
-
if (!(key in fields))
|
|
2490
|
-
fields[key] = value;
|
|
2954
|
+
if (!(field.key in fields))
|
|
2955
|
+
fields[field.key] = field.value;
|
|
2491
2956
|
});
|
|
2492
2957
|
return fields;
|
|
2493
2958
|
}
|
|
2494
|
-
/**
|
|
2495
|
-
|
|
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) {
|
|
2496
3075
|
return entryLines
|
|
2497
|
-
|
|
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()))
|
|
2498
3081
|
.join(' ')
|
|
2499
3082
|
.trim();
|
|
2500
3083
|
}
|
|
2501
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
|
+
*/
|
|
2502
3334
|
function parseVerificationItems(content, status, sourcePath) {
|
|
2503
3335
|
const items = [];
|
|
3336
|
+
if (status === 'gaps_found') {
|
|
3337
|
+
items.push(...parseHumanVerificationItems(content, sourcePath));
|
|
3338
|
+
items.push(...parseVerificationGapsItems(content));
|
|
3339
|
+
return items;
|
|
3340
|
+
}
|
|
2504
3341
|
if (status === 'human_needed') {
|
|
2505
|
-
|
|
2506
|
-
|
|
2507
|
-
|
|
2508
|
-
|
|
2509
|
-
|
|
2510
|
-
|
|
2511
|
-
|
|
2512
|
-
|
|
2513
|
-
|
|
2514
|
-
|
|
2515
|
-
|
|
2516
|
-
|
|
2517
|
-
|
|
2518
|
-
|
|
2519
|
-
|
|
2520
|
-
|
|
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',
|
|
2521
3409
|
});
|
|
2522
|
-
|
|
2523
|
-
|
|
2524
|
-
|
|
2525
|
-
|
|
2526
|
-
|
|
2527
|
-
|
|
2528
|
-
|
|
2529
|
-
|
|
2530
|
-
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
|
|
2534
|
-
|
|
2535
|
-
|
|
2536
|
-
|
|
2537
|
-
|
|
2538
|
-
|
|
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.
|
|
2539
3442
|
//
|
|
2540
|
-
// #2245
|
|
2541
|
-
//
|
|
2542
|
-
//
|
|
2543
|
-
//
|
|
2544
|
-
//
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
// against the RAW (untrimmed) line and its `\s*` would backtrack to
|
|
2558
|
-
// let `[^|]+` swallow that trailing whitespace, so OLD matched and
|
|
2559
|
-
// pushed an item with an EMPTY (`.trim()`-collapsed) name. Here,
|
|
2560
|
-
// `trimmedLine = line.trim()` strips that trailing whitespace BEFORE
|
|
2561
|
-
// `splitTableRow` ever sees it, collapsing the line to a single cell
|
|
2562
|
-
// (`candidateCells.length === 1`), which fails the `>= 2` check —
|
|
2563
|
-
// the item is silently dropped instead. A real, acceptable behaviour
|
|
2564
|
-
// change (an empty-named UAT item is not useful either way), but the
|
|
2565
|
-
// two implementations are NOT equivalent on this input.
|
|
2566
|
-
let tableCells = null;
|
|
2567
|
-
if (trimmedLine.startsWith('|')) {
|
|
2568
|
-
const candidateCells = splitTableRow(trimmedLine);
|
|
2569
|
-
if (candidateCells.length >= 2 && /^\d+$/.test(candidateCells[0])) {
|
|
2570
|
-
tableCells = candidateCells;
|
|
2571
|
-
}
|
|
2572
|
-
}
|
|
2573
|
-
// Match bullet items: - description
|
|
2574
|
-
const bulletMatch = line.match(/^[-*]\s+(.+)/);
|
|
2575
|
-
// Match numbered items: 1. description
|
|
2576
|
-
const numberedMatch = line.match(/^(\d+)\.\s+(.+)/);
|
|
2577
|
-
if (tableCells) {
|
|
2578
|
-
// Skip rows that already have a passing result (PASS, pass, resolved, etc.)
|
|
2579
|
-
// — checked over every cell AFTER the description column, mirroring
|
|
2580
|
-
// OLD's rowRemainder scan (which only ever saw cells past the
|
|
2581
|
-
// description, the description itself having already been consumed).
|
|
2582
|
-
const hasPassResult = tableCells.slice(2).some(c => /^pass$/i.test(c) || /^resolved$/i.test(c));
|
|
2583
|
-
if (hasPassResult)
|
|
2584
|
-
continue;
|
|
2585
|
-
items.push({
|
|
2586
|
-
test: parseInt(tableCells[0], 10),
|
|
2587
|
-
name: tableCells[1] ?? '',
|
|
2588
|
-
result: 'human_needed',
|
|
2589
|
-
category: 'human_uat',
|
|
2590
|
-
});
|
|
2591
|
-
}
|
|
2592
|
-
else if (numberedMatch) {
|
|
2593
|
-
items.push({
|
|
2594
|
-
test: parseInt(numberedMatch[1], 10),
|
|
2595
|
-
name: numberedMatch[2].trim(),
|
|
2596
|
-
result: 'human_needed',
|
|
2597
|
-
category: 'human_uat',
|
|
2598
|
-
});
|
|
2599
|
-
}
|
|
2600
|
-
else if (bulletMatch && bulletMatch[1].length > 10) {
|
|
2601
|
-
items.push({
|
|
2602
|
-
name: bulletMatch[1].trim(),
|
|
2603
|
-
result: 'human_needed',
|
|
2604
|
-
category: 'human_uat',
|
|
2605
|
-
});
|
|
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;
|
|
2606
3460
|
}
|
|
2607
3461
|
}
|
|
2608
|
-
//
|
|
2609
|
-
|
|
2610
|
-
//
|
|
2611
|
-
|
|
2612
|
-
|
|
2613
|
-
|
|
2614
|
-
|
|
2615
|
-
|
|
2616
|
-
|
|
2617
|
-
|
|
2618
|
-
|
|
2619
|
-
const hvSubHeadings = tokenizeHeadings(hvSection.body).filter((h) => h.level === 3 && /^\d+\.\s+/.test(h.text));
|
|
2620
|
-
for (let i = 0; i < hvSubHeadings.length; i += 1) {
|
|
2621
|
-
const current = hvSubHeadings[i];
|
|
2622
|
-
const next = hvSubHeadings[i + 1];
|
|
2623
|
-
const block = next
|
|
2624
|
-
? hvSection.body.slice(current.offset, next.offset)
|
|
2625
|
-
: hvSection.body.slice(current.offset);
|
|
2626
|
-
const bodyAfterHeading = block.slice(block.indexOf('\n') + 1);
|
|
2627
|
-
// Require a bold-led paragraph body (`**Test:** ...`) to distinguish
|
|
2628
|
-
// a genuine verification item from an unrelated numbered heading.
|
|
2629
|
-
if (!/^\s*\*\*/.test(bodyAfterHeading))
|
|
2630
|
-
continue;
|
|
2631
|
-
const headingParts = current.text.match(/^(\d+)\.\s+(.+)$/);
|
|
2632
|
-
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)
|
|
2633
3473
|
continue;
|
|
2634
3474
|
items.push({
|
|
2635
|
-
test: parseInt(
|
|
2636
|
-
name:
|
|
3475
|
+
test: parseInt(tableCells[0], 10),
|
|
3476
|
+
name: tableCells[1] ?? '',
|
|
3477
|
+
result: 'human_needed',
|
|
3478
|
+
category: 'human_uat',
|
|
3479
|
+
});
|
|
3480
|
+
}
|
|
3481
|
+
else if (numberedMatch) {
|
|
3482
|
+
items.push({
|
|
3483
|
+
test: parseInt(numberedMatch[1], 10),
|
|
3484
|
+
name: numberedMatch[2].trim(),
|
|
2637
3485
|
result: 'human_needed',
|
|
2638
3486
|
category: 'human_uat',
|
|
2639
3487
|
});
|
|
2640
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
|
+
});
|
|
2641
3529
|
}
|
|
2642
3530
|
}
|
|
2643
|
-
// gaps_found items are already handled by plan-phase --gaps pipeline
|
|
2644
3531
|
return items;
|
|
2645
3532
|
}
|
|
2646
3533
|
/**
|
|
@@ -2728,4 +3615,21 @@ module.exports = {
|
|
|
2728
3615
|
parseDeferredItems,
|
|
2729
3616
|
parseDeferredItemsWithStatus,
|
|
2730
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,
|
|
2731
3635
|
};
|