@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
|
@@ -75,6 +75,215 @@ const bulletTitledColonRe = /^\s*-\s+\*\*D-([A-Za-z0-9][A-Za-z0-9_-]*)(?:\s*\[([
|
|
|
75
75
|
* shape without hardcoding the `D`.
|
|
76
76
|
*/
|
|
77
77
|
const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
|
|
78
|
+
/**
|
|
79
|
+
* #3939: a decision bullet's DECLARATION line — the `- **D-NN … **` bold lead-in
|
|
80
|
+
* the three grammars above anchor on — may wrap across a line break. Physical
|
|
81
|
+
* line breaks inside a bullet are markdown-insignificant, and GSD's own
|
|
82
|
+
* discuss-phase writer emits the wrapped shape whenever a decision title runs
|
|
83
|
+
* past the wrap column. All three grammars require the closing `**` in the same
|
|
84
|
+
* string as the `- **D-` anchor, so a wrapped declaration matched none of them
|
|
85
|
+
* and fell to the #1365 parse-miss guard, forcing `could-not-parse` (which
|
|
86
|
+
* hard-blocks `check.decision-coverage-plan`) on a well-formed CONTEXT.md.
|
|
87
|
+
*
|
|
88
|
+
* The repair is confined to how the LOGICAL bullet is assembled — the grammars
|
|
89
|
+
* themselves are untouched, so every single-line form parses exactly as before.
|
|
90
|
+
*/
|
|
91
|
+
const decisionBulletStartRe = /^\s*-\s+\*\*D-/;
|
|
92
|
+
/**
|
|
93
|
+
* A line that opens a new BLOCK-LEVEL construct, and therefore terminates the
|
|
94
|
+
* bullet above it: a list marker of any family (`-`, `*`, `+`, `1.`, `1)`), an
|
|
95
|
+
* ATX heading, a blockquote, or a table row. Joining never reaches across one of
|
|
96
|
+
* these (nor across a blank/whitespace-only line, checked separately), so a
|
|
97
|
+
* declaration whose bold run genuinely never closes cannot absorb the block
|
|
98
|
+
* below it and get "closed" by an unrelated inline `**` — it stays a parse-miss
|
|
99
|
+
* and still fails loud, which #1365's contract requires.
|
|
100
|
+
*
|
|
101
|
+
* The four MARKER families demand trailing whitespace so that a continuation
|
|
102
|
+
* line opening with emphasis (`*in* the header.** …`) is text, not a bullet.
|
|
103
|
+
* The table-row alternative deliberately does not: CommonMark tables may open
|
|
104
|
+
* flush (`|Col1|Col2|`), and a leading `|` is never ordinary decision prose.
|
|
105
|
+
* A `- ` line at ANY indent stops the join: a deeper one is #3169 nested
|
|
106
|
+
* elaboration, which the main loop folds into the open decision itself.
|
|
107
|
+
*
|
|
108
|
+
* DELIBERATE DIVERGENCE from the sectionizer seam (ADR-1372): `iterateBullets`
|
|
109
|
+
* recognises only the `N. ` ordered-list form (`numberedRe`,
|
|
110
|
+
* src/markdown-sectionizer.cts), while this set also stops at the `N) ` form.
|
|
111
|
+
* That is intentional and one-directional — this regex answers "may the join
|
|
112
|
+
* cross this line?", where recognising MORE block openers is the conservative
|
|
113
|
+
* answer (a missed terminator can manufacture a decision; a spare one can only
|
|
114
|
+
* make a malformed bullet fail loud, which #1365 already wants). `N)` is a
|
|
115
|
+
* CommonMark ordered-list marker, so a join must not reach across it whether or
|
|
116
|
+
* not the seam's own bullet iterator yields it. Both forms are pinned by tests,
|
|
117
|
+
* and a drift test asserts the seam still does NOT treat `N)` as a bullet, so
|
|
118
|
+
* this divergence stays visible if either side moves.
|
|
119
|
+
*
|
|
120
|
+
* Accepted over-termination: continuation prose that happens to open with
|
|
121
|
+
* digits-then-`.`/`)` ("10. really keeps going") or a literal `|` stops the join
|
|
122
|
+
* early, so such a bullet fails loud rather than parsing. That is the same
|
|
123
|
+
* markdown ambiguity every line-oriented reader carries, and this direction of
|
|
124
|
+
* the trade is the one #1365 asks for — fail loud, never guess.
|
|
125
|
+
*/
|
|
126
|
+
const blockConstructRe = /^(?:[-*+]\s|\d+[.)]\s|#{1,6}\s|>\s|\|)/;
|
|
127
|
+
/**
|
|
128
|
+
* The id-adjacent `[tags]` REGION of a logical bullet, as far as it has been
|
|
129
|
+
* assembled. Matching means the region is still unsettled, in one of two ways:
|
|
130
|
+
* capture group 1 is present when the bracket is open (group 1 is the content
|
|
131
|
+
* seen so far), and absent when the id has been read but no `[` has followed
|
|
132
|
+
* yet — so a bracket may still open on the next absorbed line.
|
|
133
|
+
*
|
|
134
|
+
* A NON-match means the region is settled for good: the bracket closed, or
|
|
135
|
+
* something other than `[` followed the id. Either way the join no longer has
|
|
136
|
+
* to watch for a splice.
|
|
137
|
+
*
|
|
138
|
+
* The id character class is deliberately looser than the grammars' (it admits
|
|
139
|
+
* an empty id, so a bare `- **D-` still counts as unsettled). This regex only
|
|
140
|
+
* answers "may an id-adjacent bracket still open here?", where recognising MORE
|
|
141
|
+
* shapes is the conservative direction: an over-broad match can only make a
|
|
142
|
+
* malformed bullet fail loud, while a missed one silently re-classifies.
|
|
143
|
+
*
|
|
144
|
+
* Only the ID-ADJACENT bracket matters: that is the one the three grammars turn
|
|
145
|
+
* into `tags` (and therefore into `trackable`). A `[` further along the title is
|
|
146
|
+
* ordinary text and does not restrict the join.
|
|
147
|
+
*/
|
|
148
|
+
const tagRegionRe = /^\s*-\s+\*\*D-[A-Za-z0-9_-]*\s*(?:\[([^\]]*))?$/;
|
|
149
|
+
/**
|
|
150
|
+
* #3939 (review): would folding `next` onto a lead-in whose `[tags]` bracket is
|
|
151
|
+
* still open splice the inserted space INTO a tag token?
|
|
152
|
+
*
|
|
153
|
+
* Tags are comma-split and trimmed, so a space landing next to a delimiter
|
|
154
|
+
* (`[`, `,`, `]`) changes nothing — `[informational,` + `deferred]` is still
|
|
155
|
+
* exactly `[informational, deferred]`. A space landing anywhere else splits one
|
|
156
|
+
* token into two (`[defer` + `red]` → `defer red`), which would not fail; it
|
|
157
|
+
* would parse to a DIFFERENT tag, silently flipping `trackable` on a gate that
|
|
158
|
+
* decides whether a decision must be covered. Refusing to join there leaves the
|
|
159
|
+
* bullet unchanged, so it reaches the #1365 parse-miss guard and fails loud —
|
|
160
|
+
* a wrong answer about coverage is worse than a blocked gate.
|
|
161
|
+
*
|
|
162
|
+
* `tail` is the bracket content accumulated so far, `next` the trimmed
|
|
163
|
+
* continuation line.
|
|
164
|
+
*/
|
|
165
|
+
function wouldSpliceTagToken(tail, next) {
|
|
166
|
+
const before = tail.trimEnd();
|
|
167
|
+
if (before === '' || before.endsWith(',') || before.endsWith('['))
|
|
168
|
+
return false;
|
|
169
|
+
return !(next.startsWith(',') || next.startsWith(']'));
|
|
170
|
+
}
|
|
171
|
+
/**
|
|
172
|
+
* True when the bullet's own bold lead-in — the FIRST bold run on the line —
|
|
173
|
+
* is still open at end-of-line. Deliberately asks only about that first run
|
|
174
|
+
* (not `**`-parity over the whole string), because that is the run the three
|
|
175
|
+
* grammars anchor on: a balanced inline `**bold**` later in the body must not
|
|
176
|
+
* make a terminated lead-in look open.
|
|
177
|
+
*/
|
|
178
|
+
function boldLeadInIsUnterminated(text) {
|
|
179
|
+
const open = text.indexOf('**');
|
|
180
|
+
if (open === -1)
|
|
181
|
+
return false;
|
|
182
|
+
return text.indexOf('**', open + 2) === -1;
|
|
183
|
+
}
|
|
184
|
+
/**
|
|
185
|
+
* Fold a decision bullet whose bold lead-in wraps into ONE logical line, so the
|
|
186
|
+
* declaration grammars see the whole lead-in (#3939).
|
|
187
|
+
*
|
|
188
|
+
* Bounded and fail-loud-preserving: a wrapped declaration absorbs following
|
|
189
|
+
* lines only until its lead-in closes, and a blank/whitespace-only line, a new
|
|
190
|
+
* block-level construct (`blockConstructRe`), or the end of the block stops it.
|
|
191
|
+
* If the lead-in never closes, the original line is emitted UNCHANGED — a
|
|
192
|
+
* genuinely malformed bullet (e.g. an unterminated bold run) still reaches the
|
|
193
|
+
* parse-miss guard and still fails loud, exactly as #1365 requires. Non-decision
|
|
194
|
+
* lines pass through untouched, so continuation lines (#1372 FIX) and nested
|
|
195
|
+
* cross-reference bullets (#3169) are handled by the main loop as before.
|
|
196
|
+
*
|
|
197
|
+
* The joined line keeps the FIRST physical line's leading whitespace, so the
|
|
198
|
+
* `indentWidth` signal #3169 depends on is unchanged. Absorbed lines are
|
|
199
|
+
* trimmed and re-joined with a single space, which is what a soft line break
|
|
200
|
+
* means in markdown — so the join reproduces the rendered one-line text rather
|
|
201
|
+
* than concatenating the raw bytes.
|
|
202
|
+
*
|
|
203
|
+
* One place that equivalence does not hold is inside the id-adjacent `[tags]`
|
|
204
|
+
* bracket, where an inserted space can split a tag token and silently flip
|
|
205
|
+
* `trackable`. The join stops there instead (`wouldSpliceTagToken`), leaving the
|
|
206
|
+
* bullet to fail loud.
|
|
207
|
+
*
|
|
208
|
+
* Absorption stops at the first `**` on a continuation line, so an inline
|
|
209
|
+
* `**bold**` INSIDE a wrapped title closes the run early. That is deliberate:
|
|
210
|
+
* the result is byte-identical to what the same bullet written on one physical
|
|
211
|
+
* line parses to (the text past the early close re-attaches through the main
|
|
212
|
+
* loop's continuation folding), which is the whole contract here — wrapping is
|
|
213
|
+
* markdown-insignificant, never a second grammar.
|
|
214
|
+
*/
|
|
215
|
+
function joinWrappedBoldLeadIns(lines) {
|
|
216
|
+
const joined = [];
|
|
217
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
218
|
+
const line = lines[i];
|
|
219
|
+
if (!decisionBulletStartRe.test(line) || !boldLeadInIsUnterminated(line)) {
|
|
220
|
+
joined.push(line);
|
|
221
|
+
continue;
|
|
222
|
+
}
|
|
223
|
+
// Absorbed lines accumulate as SEGMENTS joined by a single space, and each
|
|
224
|
+
// new segment is searched on its own: the lead-in is known to be open at the
|
|
225
|
+
// end of the declaration line, and the inserted space means a closing `**`
|
|
226
|
+
// can never straddle a segment boundary, so the first `**` in any later
|
|
227
|
+
// segment is the close. Scanning per segment (rather than re-searching the
|
|
228
|
+
// accumulated string, which forces a rope flatten every iteration) keeps a
|
|
229
|
+
// long unterminated run linear on the plan gate's hot path.
|
|
230
|
+
const segments = [line];
|
|
231
|
+
// Bracket content accumulated while the id-adjacent `[tags]` bracket is
|
|
232
|
+
// still open; null when it is not open. O(1) per segment.
|
|
233
|
+
let tagTail = null;
|
|
234
|
+
// The logical text assembled so far, kept ONLY while the id-adjacent
|
|
235
|
+
// bracket has yet to open, so a bracket that opens on ANY absorbed line
|
|
236
|
+
// arms the splice guard — not just one that opens on the declaration line.
|
|
237
|
+
// Null once the region settles (the bracket opened and `tagTail` took over,
|
|
238
|
+
// or the id was followed by something else), so this never re-walks a long
|
|
239
|
+
// absorption: a non-empty segment that is not a bracket-open settles the
|
|
240
|
+
// region immediately, which bounds the string to a single extra join.
|
|
241
|
+
let tagRegion = null;
|
|
242
|
+
const declRegion = tagRegionRe.exec(line);
|
|
243
|
+
if (declRegion !== null) {
|
|
244
|
+
if (declRegion[1] === undefined)
|
|
245
|
+
tagRegion = line;
|
|
246
|
+
else
|
|
247
|
+
tagTail = declRegion[1];
|
|
248
|
+
}
|
|
249
|
+
let scan = i + 1;
|
|
250
|
+
let closed = false;
|
|
251
|
+
while (scan < lines.length) {
|
|
252
|
+
const trimmed = lines[scan].trim();
|
|
253
|
+
if (trimmed === '' || blockConstructRe.test(trimmed))
|
|
254
|
+
break;
|
|
255
|
+
if (tagTail !== null && wouldSpliceTagToken(tagTail, trimmed))
|
|
256
|
+
break;
|
|
257
|
+
segments.push(trimmed);
|
|
258
|
+
scan += 1;
|
|
259
|
+
if (tagTail !== null) {
|
|
260
|
+
tagTail = trimmed.indexOf(']') === -1 ? trimmed : null;
|
|
261
|
+
}
|
|
262
|
+
else if (tagRegion !== null) {
|
|
263
|
+
tagRegion = `${tagRegion} ${trimmed}`;
|
|
264
|
+
const opened = tagRegionRe.exec(tagRegion);
|
|
265
|
+
if (opened === null)
|
|
266
|
+
tagRegion = null;
|
|
267
|
+
else if (opened[1] !== undefined) {
|
|
268
|
+
tagTail = opened[1];
|
|
269
|
+
tagRegion = null;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
if (trimmed.indexOf('**') !== -1) {
|
|
273
|
+
closed = true;
|
|
274
|
+
break;
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
if (closed) {
|
|
278
|
+
joined.push(segments.join(' '));
|
|
279
|
+
i = scan - 1;
|
|
280
|
+
}
|
|
281
|
+
else {
|
|
282
|
+
joined.push(line);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
return joined;
|
|
286
|
+
}
|
|
78
287
|
/**
|
|
79
288
|
* Parse decision lines from a block of text (the inner text of a <decisions>
|
|
80
289
|
* or markdown-header section body). Returns the extracted decisions and a count
|
|
@@ -83,9 +292,12 @@ const boldLeadInBulletRe = /^\s*-\s+\*\*[A-Z]+[0-9]*-[A-Za-z0-9]/m;
|
|
|
83
292
|
* FIX B (#1365): parseMisses > 0 means the caller must treat the result as
|
|
84
293
|
* could-not-parse even when some decisions were extracted — a silent drop is
|
|
85
294
|
* worse than a fail-loud signal.
|
|
295
|
+
*
|
|
296
|
+
* #3939: physical lines are folded into logical bullets first, so a declaration
|
|
297
|
+
* whose bold lead-in wraps is matched as the one bullet it is.
|
|
86
298
|
*/
|
|
87
299
|
function parseDecisionLines(block) {
|
|
88
|
-
const lines = block.split(/\r?\n/);
|
|
300
|
+
const lines = joinWrappedBoldLeadIns(block.split(/\r?\n/));
|
|
89
301
|
const out = [];
|
|
90
302
|
let category = '';
|
|
91
303
|
let inDiscretion = false;
|
|
@@ -110,6 +110,15 @@ function validateRequirement(requirement) {
|
|
|
110
110
|
if (r.shapes == null && !(typeof r.text === 'string' && r.text.trim())) {
|
|
111
111
|
throw new Error(`requirement ${requirement.id} text must be a non-empty string when no shapes override is provided`);
|
|
112
112
|
}
|
|
113
|
+
// text_en (#3717) is optional, but when present it must be a non-empty string. An empty
|
|
114
|
+
// string is NOT caught by `??` (only null/undefined are), so an unvalidated `text_en: ''`
|
|
115
|
+
// would silently win `text_en ?? text` and classify against '' — the same fail-open shape
|
|
116
|
+
// #1110/#2773 already exist to eliminate, just moved one field over. Validated
|
|
117
|
+
// unconditionally (not gated on whether `shapes` will make it unused) so bad data fails
|
|
118
|
+
// closed even when it happens to be dead for this particular call.
|
|
119
|
+
if (r.text_en != null && !(typeof r.text_en === 'string' && r.text_en.trim())) {
|
|
120
|
+
throw new Error(`requirement ${requirement.id} text_en must be a non-empty string when present`);
|
|
121
|
+
}
|
|
113
122
|
}
|
|
114
123
|
/** Validate an edge resolution against the edge verification vocabulary. */
|
|
115
124
|
function validateResolution(resolution) {
|
|
@@ -135,7 +144,11 @@ function proposeEdges(requirement) {
|
|
|
135
144
|
shapes = requirement.shapes;
|
|
136
145
|
}
|
|
137
146
|
else {
|
|
138
|
-
|
|
147
|
+
// #3717: prefer the English translation when present — SHAPE_CUES are English-only
|
|
148
|
+
// word-boundary patterns, so a non-English `text` (e.g. response_language projects)
|
|
149
|
+
// would otherwise classify to zero shapes. validateRequirement (called above) has
|
|
150
|
+
// already guaranteed text_en, if present, is a non-empty string.
|
|
151
|
+
shapes = classifyShape(requirement.text_en ?? requirement.text);
|
|
139
152
|
if (shapes.length === 0) {
|
|
140
153
|
// Prose present but no shape cue matched. Do NOT silently drop it (#1110): an
|
|
141
154
|
// edge-relevant requirement whose phrasing missed every cue would otherwise vanish from
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* File-overlap partitioner — shared greedy first-fit stage assignment (#3674).
|
|
4
|
+
*
|
|
5
|
+
* Extracted from `claude-orchestration.cts`'s `partitionStages` (#1143), which
|
|
6
|
+
* emits sequential Workflow `parallel()` stage barriers so that no two plans
|
|
7
|
+
* sharing a `files_modified` entry ever cohabit a stage. This module is the
|
|
8
|
+
* SAME algorithm, generalized: it depends on no `Plan`/`Wave` interface from
|
|
9
|
+
* `claude-orchestration.cts` (or any other caller-specific shape), so a future
|
|
10
|
+
* consumer (quick-batch, #3675 / ADR-1239 "Quick-batch binding") can partition
|
|
11
|
+
* its own planned-path items without pulling in orchestration internals.
|
|
12
|
+
*
|
|
13
|
+
* This is a pure, behavior-preserving extraction (#3674's acceptance bar is
|
|
14
|
+
* byte-identical output for `claude-orchestration.cts`'s existing callers —
|
|
15
|
+
* not an improvement). Explicitly OUT of scope, per the #3674 design lock:
|
|
16
|
+
* - dependency-DAG ordering — this module only ever sees a flat item list
|
|
17
|
+
* and file-overlap; a caller resolves dependency order before calling in
|
|
18
|
+
* (same contract `partitionStages` already had);
|
|
19
|
+
* - path normalization — file entries are compared by exact string equality
|
|
20
|
+
* only. `Foo.ts` vs `foo.ts`, or `src/a.ts` vs `src\a.ts`, are treated as
|
|
21
|
+
* DISTINCT files. This is deliberate, not a gap to "fix" during extraction;
|
|
22
|
+
* - filesystem access — this module never reads a path off disk.
|
|
23
|
+
*
|
|
24
|
+
* Zero external dependencies. Pure function. Never throws on well-typed input.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Partition `items` into a near-minimal number of sequential stages (via
|
|
28
|
+
* greedy first-fit — not guaranteed optimal for arbitrary overlap graphs, but
|
|
29
|
+
* correct: no two items sharing a file ever cohabit a stage) such that no two
|
|
30
|
+
* items in the same stage share a file. Each item goes into the earliest
|
|
31
|
+
* stage where it does not overlap any item already there, in input order.
|
|
32
|
+
*
|
|
33
|
+
* An item with an EMPTY `files` array declares no files; it overlaps nothing
|
|
34
|
+
* and coalesces into stage 0 (mirrors `partitionStages`' original behavior —
|
|
35
|
+
* this module cannot guard against undeclared concurrent writes; a caller
|
|
36
|
+
* must declare `files` accurately).
|
|
37
|
+
*
|
|
38
|
+
* File comparison is EXACT STRING EQUALITY — no path normalization, no
|
|
39
|
+
* case-folding, no separator canonicalization. Duplicate `id`s in the input
|
|
40
|
+
* are NOT deduplicated; each item is placed independently, in input order.
|
|
41
|
+
*
|
|
42
|
+
* Deterministic: identical input (including input order) always yields an
|
|
43
|
+
* identical partition.
|
|
44
|
+
*/
|
|
45
|
+
function partitionByFileOverlap(items) {
|
|
46
|
+
const stages = [];
|
|
47
|
+
for (const item of items) {
|
|
48
|
+
const fileSet = new Set(item.files);
|
|
49
|
+
let placed = false;
|
|
50
|
+
for (const stage of stages) {
|
|
51
|
+
let overlap = false;
|
|
52
|
+
for (const f of fileSet) {
|
|
53
|
+
if (stage.files.has(f)) {
|
|
54
|
+
overlap = true;
|
|
55
|
+
break;
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (!overlap) {
|
|
59
|
+
stage.items.push(item);
|
|
60
|
+
for (const f of fileSet)
|
|
61
|
+
stage.files.add(f);
|
|
62
|
+
placed = true;
|
|
63
|
+
break;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
if (!placed) {
|
|
67
|
+
stages.push({ items: [item], files: new Set(fileSet) });
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return stages.map((s) => s.items.map((i) => i.id));
|
|
71
|
+
}
|
|
72
|
+
module.exports = {
|
|
73
|
+
partitionByFileOverlap,
|
|
74
|
+
};
|
|
@@ -652,46 +652,137 @@ function countTopLevelKeyShapedLines(region) {
|
|
|
652
652
|
* key its deduplication. Optional because this function has 50-odd call sites and several
|
|
653
653
|
* hold only an in-memory string; those dedup on a content digest instead.
|
|
654
654
|
*/
|
|
655
|
-
|
|
655
|
+
/**
|
|
656
|
+
* The frontmatter REGION of a document: the text between the opening `---`
|
|
657
|
+
* fence at byte 0 and its closing fence.
|
|
658
|
+
*
|
|
659
|
+
* One fence parser, not two (#3850 review round 2, B1). `extractFrontmatter`
|
|
660
|
+
* and `frontmatterListEntries` need the identical answer to "where does the
|
|
661
|
+
* frontmatter start and stop" — same BOM tolerance (#2977), same byte-0-only
|
|
662
|
+
* fence rule, same CR handling before the closing fence — and differ only in
|
|
663
|
+
* what they do with the region text afterwards. A second copy of this logic is
|
|
664
|
+
* the `DEFECT.GENERATIVE-FIX` shape: it silently stops agreeing the first time
|
|
665
|
+
* either is taught something.
|
|
666
|
+
*
|
|
667
|
+
* `terminated: false` is the fence-opened-but-never-closed case. It is not an
|
|
668
|
+
* error here because the two callers disagree about it: `extractFrontmatter`
|
|
669
|
+
* runs the #1882 truncation probe over the region and may warn, while
|
|
670
|
+
* `frontmatterListEntries` has no array to return and gives up. So the shape
|
|
671
|
+
* is reported and the decision is left to them.
|
|
672
|
+
*
|
|
673
|
+
* `content` is returned alongside because it is the BOM-STRIPPED text, and a
|
|
674
|
+
* caller reporting on the document (the truncation diagnostic) must describe
|
|
675
|
+
* the same bytes the offsets were computed against.
|
|
676
|
+
*/
|
|
677
|
+
function frontmatterRegion(content) {
|
|
656
678
|
// #2977: tolerate a single leading UTF-8 BOM (U+FEFF), which Windows tooling
|
|
657
|
-
// (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default.
|
|
658
|
-
// strip, the byte-0 `startsWith('---')` fence check below fails
|
|
659
|
-
// parse collapses
|
|
660
|
-
// proceeds as though the file had no frontmatter
|
|
661
|
-
//
|
|
662
|
-
// Scope: BOM only. Arbitrary non-BOM content before the fence
|
|
663
|
-
// line/comment) is a separate product-intent
|
|
664
|
-
//
|
|
665
|
-
if (content.charCodeAt(0) === 0xFEFF)
|
|
679
|
+
// (PowerShell `>`/`Out-File` on PS 5.1, several editors) writes by default.
|
|
680
|
+
// Without this strip, the byte-0 `startsWith('---')` fence check below fails
|
|
681
|
+
// on the BOM and the whole parse collapses — every frontmatter field silently
|
|
682
|
+
// disappears, and the engine proceeds as though the file had no frontmatter
|
|
683
|
+
// at all. The BOM is a single codepoint; stripping it here restores byte-0
|
|
684
|
+
// alignment. Scope: BOM only. Arbitrary non-BOM content before the fence
|
|
685
|
+
// (leading whitespace/blank line/comment) is a separate product-intent
|
|
686
|
+
// decision (tolerate vs diagnose) left to a future change.
|
|
687
|
+
if (content.charCodeAt(0) === 0xFEFF)
|
|
666
688
|
content = content.slice(1);
|
|
667
|
-
|
|
668
|
-
//
|
|
669
|
-
// body (YAML examples, horizontal rules) must never be treated as frontmatter.
|
|
689
|
+
// Match frontmatter only at byte 0 — a `---` block later in the document body
|
|
690
|
+
// (YAML examples, horizontal rules) must never be treated as frontmatter.
|
|
670
691
|
const headerEnd = content.startsWith('---\r\n') ? 5 : content.startsWith('---\n') ? 4 : -1;
|
|
671
692
|
if (headerEnd === -1)
|
|
672
|
-
return
|
|
693
|
+
return null;
|
|
673
694
|
const closingLineStart = content.indexOf('\n---', headerEnd);
|
|
674
695
|
if (closingLineStart === -1) {
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
696
|
+
return { region: content.slice(headerEnd), terminated: false, content };
|
|
697
|
+
}
|
|
698
|
+
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
|
699
|
+
return { region: content.slice(headerEnd, yamlEnd), terminated: true, content };
|
|
700
|
+
}
|
|
701
|
+
function extractFrontmatter(content, sourcePath) {
|
|
702
|
+
// Fence location (BOM strip, byte-0 rule, CR handling) lives in
|
|
703
|
+
// `frontmatterRegion` so this and `frontmatterListEntries` cannot drift
|
|
704
|
+
// apart on where the frontmatter is.
|
|
705
|
+
const found = frontmatterRegion(content);
|
|
706
|
+
if (!found)
|
|
707
|
+
return {};
|
|
708
|
+
if (!found.terminated) {
|
|
709
|
+
const keyCount = countKeysBeforeTruncation(found.region);
|
|
710
|
+
if (keyCount >= UNTERMINATED_KEY_THRESHOLD && isFrontmatterShaped(found.region)) {
|
|
678
711
|
warnUnusableInput({
|
|
679
712
|
reason: UNUSABLE_REASON.FRONTMATTER_UNTERMINATED,
|
|
680
713
|
source: sourcePath,
|
|
681
|
-
content,
|
|
714
|
+
content: found.content,
|
|
682
715
|
});
|
|
683
716
|
}
|
|
684
717
|
return {};
|
|
685
718
|
}
|
|
686
|
-
const yamlEnd = content[closingLineStart - 1] === '\r' ? closingLineStart - 1 : closingLineStart;
|
|
687
|
-
const region = content.slice(headerEnd, yamlEnd);
|
|
688
719
|
try {
|
|
689
|
-
return parseGuardedYamlRegion(region);
|
|
720
|
+
return parseGuardedYamlRegion(found.region);
|
|
690
721
|
}
|
|
691
722
|
catch {
|
|
692
723
|
return unparseableResult();
|
|
693
724
|
}
|
|
694
725
|
}
|
|
726
|
+
/**
|
|
727
|
+
* The entries of a top-level frontmatter ARRAY key, VERBATIM — as the values
|
|
728
|
+
* YAML actually describes rather than the display-flattened strings
|
|
729
|
+
* `extractFrontmatter` returns (#3850).
|
|
730
|
+
*
|
|
731
|
+
* `extractFrontmatter` renders each object entry for HUMANS —
|
|
732
|
+
* `normalizeParsedValue` maps `{test, resolution}` to `"test: …, resolution: …"`
|
|
733
|
+
* via `flattenObjectListItem`. That is the right contract for a reader printing
|
|
734
|
+
* a list, and the wrong one for a reader that needs to branch on a specific
|
|
735
|
+
* field: `status`, `resolution` and `reason` are recoverable from that string
|
|
736
|
+
* only by re-parsing prose, which cannot distinguish a real `resolution:` field
|
|
737
|
+
* from the same text inside a quoted `truth:`.
|
|
738
|
+
*
|
|
739
|
+
* This returns the same entries BEFORE that display step, off the same
|
|
740
|
+
* `extractFrontmatter` parse path — same BOM strip (#2977), same byte-0 fence
|
|
741
|
+
* rule (shared via `frontmatterRegion`), same anchor/alias and sentinel guards,
|
|
742
|
+
* same ambiguous-colon repair. It is deliberately NOT a second parser: a
|
|
743
|
+
* hand-rolled fence regex or entry slicer re-loses whatever the real one
|
|
744
|
+
* learned, which is the `DEFECT.GENERATIVE-FIX` shape.
|
|
745
|
+
*
|
|
746
|
+
* EVERY element is returned, at its own index, whatever its type (#3850 review
|
|
747
|
+
* round 3, Blocker). An earlier revision filtered to objects, which compacted
|
|
748
|
+
* the array: a caller numbering entries by array position then numbered the
|
|
749
|
+
* SURVIVORS, so a list mixing object and non-object entries lost rows outright
|
|
750
|
+
* and mis-numbered the rest — the same silently-vanishing-row defect this whole
|
|
751
|
+
* issue exists to close, triggered by entry shape instead of file status.
|
|
752
|
+
* Deciding what a non-object entry MEANS is a caller's judgement (the two
|
|
753
|
+
* readers in `uat.cts` answer it differently and both are right for their own
|
|
754
|
+
* vocabulary); dropping it is nobody's.
|
|
755
|
+
*
|
|
756
|
+
* Returns `null` when the document has no frontmatter, the frontmatter is
|
|
757
|
+
* unterminated or unparseable, the key is absent, or the key is not an array —
|
|
758
|
+
* "nothing to iterate" cases the caller should not have to tell apart.
|
|
759
|
+
*/
|
|
760
|
+
function frontmatterListEntries(content, key) {
|
|
761
|
+
const found = frontmatterRegion(content);
|
|
762
|
+
if (!found || !found.terminated)
|
|
763
|
+
return null;
|
|
764
|
+
let raw;
|
|
765
|
+
try {
|
|
766
|
+
refuseAnchorsAndAliases(found.region);
|
|
767
|
+
refuseIfSentinelPresent(found.region);
|
|
768
|
+
raw = restoreNullBytesDeep(loadWithAmbiguousColonRepair(escapeNullBytesForParse(found.region)));
|
|
769
|
+
}
|
|
770
|
+
catch {
|
|
771
|
+
// Same posture as `extractFrontmatter`: an unparseable region is "no
|
|
772
|
+
// frontmatter", never a throw into a caller that was only reading a field.
|
|
773
|
+
return null;
|
|
774
|
+
}
|
|
775
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw))
|
|
776
|
+
return null;
|
|
777
|
+
const value = raw[key];
|
|
778
|
+
// `Array.isArray` narrows an `unknown` to `any[]`, and returning that
|
|
779
|
+
// unchecked is how `any` escapes a guarded parser into every caller. The
|
|
780
|
+
// element type genuinely IS unknown here — that is the point of this
|
|
781
|
+
// function — so say so.
|
|
782
|
+
if (!Array.isArray(value))
|
|
783
|
+
return null;
|
|
784
|
+
return value;
|
|
785
|
+
}
|
|
695
786
|
/**
|
|
696
787
|
* Escape a string for emission inside a YAML double-quoted scalar (#1779). ADR-3473 §8.1
|
|
697
788
|
* (#3881): routed through the vendored js-yaml's `dump()` (forced double-quoted style) rather
|
|
@@ -816,6 +907,22 @@ function agentScalarNeedsDoubleQuoting(s) {
|
|
|
816
907
|
return true;
|
|
817
908
|
return false;
|
|
818
909
|
}
|
|
910
|
+
/**
|
|
911
|
+
* #4053 — Quote a numeric-looking scalar that is not all-digit (`22.10`,
|
|
912
|
+
* `1.0`, `1e3`, `0x1F`) so a spec YAML reader keeps it a string: bare `22.10`
|
|
913
|
+
* reloads as the float 22.1 and collides with `22.1`, a different phase.
|
|
914
|
+
*
|
|
915
|
+
* All-digit strings stay bare as a deliberate, scoped trade-off — not a
|
|
916
|
+
* safety guarantee. A leading-zero value (`02`, `017`) also mis-parses under
|
|
917
|
+
* a spec reader (to 2, 17). It is left unquoted because zero-padded ids
|
|
918
|
+
* (`plan: 01`, `phase: 02`) are the pervasive GSD convention, quoting them
|
|
919
|
+
* all is the blanket quoting #4053 asked to avoid, and the loss is padding
|
|
920
|
+
* rather than identity: `02` and `2` normalize to the same phase; `22.1` and
|
|
921
|
+
* `22.10` do not.
|
|
922
|
+
*/
|
|
923
|
+
function generalScalarNeedsNumericQuoting(s) {
|
|
924
|
+
return YAML_NUMERIC_RE.test(s) && !/^\d+$/.test(s);
|
|
925
|
+
}
|
|
819
926
|
function reconstructFrontmatter(obj) {
|
|
820
927
|
const lines = [];
|
|
821
928
|
// #3257: read the full-line-comment channel (set by parseGuardedYamlRegion when comments
|
|
@@ -899,13 +1006,13 @@ function reconstructFrontmatter(obj) {
|
|
|
899
1006
|
else {
|
|
900
1007
|
// eslint-disable-next-line @typescript-eslint/no-base-to-string
|
|
901
1008
|
const sv = String(subval);
|
|
902
|
-
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
|
|
1009
|
+
lines.push(` ${subkey}: ${sv.includes(':') || sv.includes('#') || scalarNeedsDoubleQuoting(sv) || generalScalarNeedsNumericQuoting(sv) ? `"${escapeDoubleQuotedScalar(sv)}"` : sv}`);
|
|
903
1010
|
}
|
|
904
1011
|
}
|
|
905
1012
|
}
|
|
906
1013
|
else {
|
|
907
1014
|
const sv = String(value);
|
|
908
|
-
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv)) {
|
|
1015
|
+
if (sv.includes(':') || sv.includes('#') || sv.startsWith('[') || sv.startsWith('{') || scalarNeedsDoubleQuoting(sv) || generalScalarNeedsNumericQuoting(sv)) {
|
|
909
1016
|
lines.push(`${key}: "${escapeDoubleQuotedScalar(sv)}"`);
|
|
910
1017
|
}
|
|
911
1018
|
else {
|
|
@@ -1529,6 +1636,13 @@ module.exports = {
|
|
|
1529
1636
|
stripFrontmatter,
|
|
1530
1637
|
noOpObjectListSetError,
|
|
1531
1638
|
parseMustHavesBlock,
|
|
1639
|
+
// #3850: an array key's entries as parsed OBJECTS, for a caller that must
|
|
1640
|
+
// branch on an entry's `status:`/`resolution:` rather than print it. Off the
|
|
1641
|
+
// same parse path as `extractFrontmatter`, minus only the display flattening.
|
|
1642
|
+
frontmatterListEntries,
|
|
1643
|
+
// #3850: the display rendering itself, so a caller deriving a name from those
|
|
1644
|
+
// objects produces the byte-identical string `extractFrontmatter` would have.
|
|
1645
|
+
flattenObjectListItem,
|
|
1532
1646
|
FRONTMATTER_SCHEMAS,
|
|
1533
1647
|
cmdFrontmatterGet,
|
|
1534
1648
|
cmdFrontmatterSet,
|
|
@@ -36,6 +36,9 @@ const { scanPhasePlans } = planScanMod;
|
|
|
36
36
|
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
37
37
|
const phaseIdMod = require("./phase-id.cjs");
|
|
38
38
|
const { scopeToPhase } = phaseIdMod;
|
|
39
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
40
|
+
const planningScopeMod = require("./planning-scope.cjs");
|
|
41
|
+
const { SCOPE } = planningScopeMod;
|
|
39
42
|
/**
|
|
40
43
|
* Parse REQ-IDs from REQUIREMENTS.md content.
|
|
41
44
|
*
|
|
@@ -303,6 +306,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
303
306
|
summary: 'workflow.post_planning_gaps disabled — skipping post-planning gap analysis',
|
|
304
307
|
counts: { total: 0, covered: 0, uncovered: 0 },
|
|
305
308
|
phase_dir_read_error: null,
|
|
309
|
+
phase_dir_scope: SCOPE.COMPLETE,
|
|
306
310
|
};
|
|
307
311
|
}
|
|
308
312
|
const absPhaseDir = node_path_1.default.isAbsolute(phaseDir) ? phaseDir : node_path_1.default.join(cwd, phaseDir);
|
|
@@ -325,19 +329,20 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
325
329
|
}
|
|
326
330
|
// Read the phase directory once; reuse the listing for both context detection
|
|
327
331
|
// and plan-file enumeration (avoids redundant readdirSync calls).
|
|
328
|
-
|
|
329
|
-
// #
|
|
330
|
-
//
|
|
331
|
-
//
|
|
332
|
-
//
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
332
|
+
//
|
|
333
|
+
// #4014 (epic #3473 B4): findContextMdIn's directory-string form now owns
|
|
334
|
+
// the listing + ENOENT-vs-other discrimination, retiring the local
|
|
335
|
+
// existsSync-guarded readdirSync try/catch — ENOENT (genuinely absent)
|
|
336
|
+
// and a successful-but-empty read both resolve to SCOPE.COMPLETE with an
|
|
337
|
+
// empty listing, exactly like the existsSync guard's short-circuit did.
|
|
338
|
+
const { files: phaseDirFiles, scope: phaseDirScope } = findContextMdIn(absPhaseDir);
|
|
339
|
+
// #3885 (ADR-3473 §8.5): `phaseDirReadError` stays additive for the
|
|
340
|
+
// shipped `phase_dir_read_error` JSON field — derived from `phaseDirScope`
|
|
341
|
+
// rather than from its own caught error, since findContextMdIn's
|
|
342
|
+
// directory-string form reports SCOPE, not the raw errno message.
|
|
343
|
+
const phaseDirReadError = phaseDirScope === SCOPE.UNREADABLE
|
|
344
|
+
? `Could not read phase directory ${formatDiagnosticToken(absPhaseDir)}`
|
|
345
|
+
: null;
|
|
341
346
|
// #3511-class: scope the raw listing to this phase dir before the
|
|
342
347
|
// phase-numbered -CONTEXT.md predicate. `phaseDirFiles` itself stays raw —
|
|
343
348
|
// it is also reused below only as a `.length > 0` guard ahead of
|
|
@@ -405,6 +410,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
405
410
|
summary: coverageSummary + '; extracted 0 of N — possible format mismatch',
|
|
406
411
|
counts: { total: rows.length, covered, uncovered },
|
|
407
412
|
phase_dir_read_error: phaseDirReadError,
|
|
413
|
+
phase_dir_scope: phaseDirScope,
|
|
408
414
|
};
|
|
409
415
|
}
|
|
410
416
|
return {
|
|
@@ -414,6 +420,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
414
420
|
summary: 'extracted 0 of N — possible format mismatch',
|
|
415
421
|
counts: { total: 0, covered: 0, uncovered: 0 },
|
|
416
422
|
phase_dir_read_error: phaseDirReadError,
|
|
423
|
+
phase_dir_scope: phaseDirScope,
|
|
417
424
|
};
|
|
418
425
|
}
|
|
419
426
|
// #1365: if no items at all, surface a clean no-check message.
|
|
@@ -431,6 +438,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
431
438
|
summary: 'no requirements or decisions to check',
|
|
432
439
|
counts: { total: 0, covered: 0, uncovered: 0 },
|
|
433
440
|
phase_dir_read_error: phaseDirReadError,
|
|
441
|
+
phase_dir_scope: phaseDirScope,
|
|
434
442
|
};
|
|
435
443
|
}
|
|
436
444
|
const rows = sortRows([
|
|
@@ -449,6 +457,7 @@ function runGapAnalysis(cwd, phaseDir, options = {}) {
|
|
|
449
457
|
summary,
|
|
450
458
|
counts: { total: rows.length, covered, uncovered },
|
|
451
459
|
phase_dir_read_error: phaseDirReadError,
|
|
460
|
+
phase_dir_scope: phaseDirScope,
|
|
452
461
|
};
|
|
453
462
|
}
|
|
454
463
|
function cmdGapAnalysis(cwd, args, raw) {
|