@pareto4/pp-core 1.11.1 → 1.12.1
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/plugin.json +1 -1
- package/README.md +31 -0
- package/agents/pp-code-fixer.md +1 -1
- package/agents/pp-debug-session-manager.md +1 -1
- package/agents/pp-debugger.md +1 -1
- package/agents/pp-executor.md +20 -10
- package/agents/pp-intel-updater.md +1 -1
- package/agents/pp-phase-researcher.md +1 -1
- package/agents/pp-plan-checker.md +5 -5
- package/agents/pp-planner.md +10 -24
- package/agents/pp-project-researcher.md +1 -1
- package/agents/pp-research-synthesizer.md +1 -1
- package/agents/pp-ui-researcher.md +1 -1
- package/agents/pp-verifier.md +8 -4
- package/bin/install.js +429 -82
- package/commands/pp-autonomous.md +8 -6
- package/commands/pp-discovery-to-build.md +1 -1
- package/commands/pp-discovery.md +3 -2
- package/commands/pp-discuss-phase.md +2 -2
- package/commands/pp-execute-phase.md +3 -1
- package/commands/pp-graphify.md +5 -5
- package/commands/pp-import.md +1 -1
- package/commands/pp-map-codebase.md +1 -1
- package/commands/pp-ns-project.md +2 -3
- package/commands/pp-ns-workflow.md +1 -1
- package/commands/pp-plan-phase.md +2 -1
- package/commands/pp-verify-work.md +2 -1
- package/gemini-extension.json +1 -1
- package/hooks/dist/pp-cursor-session-start.js +2 -2
- package/hooks/dist/pp-session-state.sh +1 -1
- package/hooks/dist/pp-statusline.js +21 -2
- package/hooks/pp-cursor-session-start.js +2 -2
- package/hooks/pp-session-state.sh +1 -1
- package/hooks/pp-statusline.js +21 -2
- package/package.json +18 -5
- package/pareto-skills/pp-project-setup/SKILL.md +5 -63
- package/pp-core/bin/detect-build-commands.sh +182 -0
- package/pp-core/bin/lib/capability-activation.cjs +19 -9
- package/pp-core/bin/lib/capability-registry.cjs +65 -7
- package/pp-core/bin/lib/check-command-router.cjs +79 -38
- package/pp-core/bin/lib/clusters.cjs +2 -3
- package/pp-core/bin/lib/command-aliases.cjs +36 -0
- package/pp-core/bin/lib/command-arg-projection.cjs +99 -1
- package/pp-core/bin/lib/commands.cjs +21 -6
- package/pp-core/bin/lib/compose-bindings.cjs +70 -6
- package/pp-core/bin/lib/config-loader.cjs +151 -8
- package/pp-core/bin/lib/config-schema.cjs +16 -0
- package/pp-core/bin/lib/config.cjs +142 -27
- package/pp-core/bin/lib/core-utils.cjs +43 -7
- package/pp-core/bin/lib/discovery-input-check.cjs +218 -0
- package/pp-core/bin/lib/discovery-rebaseline.cjs +1372 -0
- package/pp-core/bin/lib/discovery-research-track.cjs +979 -0
- package/pp-core/bin/lib/discovery-rollup.cjs +476 -0
- package/pp-core/bin/lib/discovery-screens.cjs +1995 -0
- package/pp-core/bin/lib/discovery-sweep.cjs +390 -0
- package/pp-core/bin/lib/discovery-table.cjs +98 -0
- package/pp-core/bin/lib/dispatch-plan.cjs +472 -0
- package/pp-core/bin/lib/frontmatter.cjs +17 -2
- package/pp-core/bin/lib/init-command-router.cjs +8 -5
- package/pp-core/bin/lib/init-contract.cjs +93 -4
- package/pp-core/bin/lib/init.cjs +124 -134
- package/pp-core/bin/lib/install-profiles.cjs +5 -0
- package/pp-core/bin/lib/install-run.cjs +536 -0
- package/pp-core/bin/lib/io.cjs +15 -0
- package/pp-core/bin/lib/loop-resolver.cjs +40 -2
- package/pp-core/bin/lib/milestone.cjs +345 -48
- package/pp-core/bin/lib/phase-command-router.cjs +23 -1
- package/pp-core/bin/lib/phase-locator.cjs +15 -6
- package/pp-core/bin/lib/phase-scope.cjs +91 -0
- package/pp-core/bin/lib/phase-tier.cjs +467 -0
- package/pp-core/bin/lib/phase.cjs +247 -21
- package/pp-core/bin/lib/phases-command-router.cjs +2 -0
- package/pp-core/bin/lib/plan-graph.cjs +368 -0
- package/pp-core/bin/lib/planning-workspace.cjs +53 -0
- package/pp-core/bin/lib/platform-bindings.cjs +32 -2
- package/pp-core/bin/lib/profile-output.cjs +11 -3
- package/pp-core/bin/lib/prohibition-enforcement.cjs +112 -0
- package/pp-core/bin/lib/roadmap-parser.cjs +366 -99
- package/pp-core/bin/lib/roadmap.cjs +141 -39
- package/pp-core/bin/lib/runtime-hooks-surface.cjs +1 -1
- package/pp-core/bin/lib/shell-command-projection.cjs +24 -0
- package/pp-core/bin/lib/state-command-router.cjs +82 -13
- package/pp-core/bin/lib/state-document.cjs +124 -7
- package/pp-core/bin/lib/state.cjs +784 -257
- package/pp-core/bin/lib/template.cjs +22 -6
- package/pp-core/bin/lib/vault-command-router.cjs +57 -6
- package/pp-core/bin/lib/vault-practices.cjs +6 -36
- package/pp-core/bin/lib/vault-write-back.cjs +20 -4
- package/pp-core/bin/lib/vault.cjs +592 -23
- package/pp-core/bin/lib/verify.cjs +53 -31
- package/pp-core/bin/lib/workstream.cjs +1 -1
- package/pp-core/bin/lib/worktree-safety.cjs +26 -15
- package/pp-core/bin/portable-timeout.sh +89 -0
- package/pp-core/bin/pp-tools.cjs +149 -9
- package/pp-core/bin/shared/config-defaults.manifest.json +7 -0
- package/pp-core/bin/shared/config-schema.manifest.json +3 -0
- package/pp-core/references/autonomous-best-effort.md +461 -0
- package/pp-core/references/autonomous-lifecycle.md +146 -0
- package/pp-core/references/autonomous-smart-discuss.md +16 -12
- package/pp-core/references/checkpoints.md +1 -1
- package/pp-core/references/discovery-id-conventions.md +202 -0
- package/pp-core/references/discovery-rebaseline.md +254 -0
- package/pp-core/references/discovery-research-track.md +283 -0
- package/pp-core/references/discovery-traceability.md +400 -0
- package/pp-core/references/loop-hook-dispatch.md +14 -0
- package/pp-core/references/mandatory-initial-read.md +1 -1
- package/pp-core/references/planner-gap-closure.md +1 -0
- package/pp-core/references/planner-human-verify-mode.md +1 -1
- package/pp-core/references/planner-load-graph-context.md +1 -1
- package/pp-core/references/planner-plan-graph.md +66 -0
- package/pp-core/references/planner-tdd.md +1 -0
- package/pp-core/references/planning-config.md +8 -0
- package/pp-core/references/sketch-tooling.md +4 -0
- package/pp-core/references/tdd.md +1 -0
- package/pp-core/templates/claude-md.md +1 -1
- package/pp-core/templates/phase-prompt.md +6 -2
- package/pp-core/templates/prototype-review/review.js +303 -0
- package/pp-core/templates/state.md +6 -0
- package/pp-core/workflows/_runtime-launcher.snippet.sh +1 -1
- package/pp-core/workflows/add-backlog.md +1 -1
- package/pp-core/workflows/add-phase.md +1 -1
- package/pp-core/workflows/add-tests.md +1 -1
- package/pp-core/workflows/add-todo.md +1 -1
- package/pp-core/workflows/ai-integration-phase.md +2 -2
- package/pp-core/workflows/audit-fix.md +12 -16
- package/pp-core/workflows/audit-milestone.md +2 -2
- package/pp-core/workflows/audit-uat.md +1 -1
- package/pp-core/workflows/autonomous.md +68 -306
- package/pp-core/workflows/check-todos.md +1 -1
- package/pp-core/workflows/cleanup.md +1 -1
- package/pp-core/workflows/code-review-fix.md +2 -2
- package/pp-core/workflows/code-review.md +20 -8
- package/pp-core/workflows/complete-milestone.md +41 -60
- package/pp-core/workflows/debug.md +1 -1
- package/pp-core/workflows/diagnose-issues.md +1 -1
- package/pp-core/workflows/discovery-sprint/gap-analysis.md +140 -0
- package/pp-core/workflows/discovery-sprint/input-package.md +140 -0
- package/pp-core/workflows/discovery-sprint/prefill.md +92 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +257 -0
- package/pp-core/workflows/discovery-sprint/rebaseline.md +276 -0
- package/pp-core/workflows/discovery-sprint/session-13-gates.md +24 -0
- package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +92 -0
- package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +215 -0
- package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +146 -0
- package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/06-security.md +107 -0
- package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +85 -0
- package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +109 -0
- package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +119 -0
- package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +92 -0
- package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +98 -0
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +205 -0
- package/pp-core/workflows/discovery-sprint/templates/context-template.md +266 -0
- package/pp-core/workflows/discovery-sprint.md +79 -1714
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +5 -5
- package/pp-core/workflows/discovery-to-build/steps/screen-inventory-gate.md +70 -0
- package/pp-core/workflows/discovery-to-build/templates/discovery-references.md +54 -0
- package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +184 -0
- package/pp-core/workflows/discovery-to-build.md +21 -238
- package/pp-core/workflows/discuss-phase/modes/advisor.md +1 -1
- package/pp-core/workflows/discuss-phase/modes/auto.md +1 -1
- package/pp-core/workflows/discuss-phase/modes/chain.md +1 -1
- package/pp-core/workflows/discuss-phase-assumptions.md +1 -1
- package/pp-core/workflows/discuss-phase.md +11 -4
- package/pp-core/workflows/do.md +4 -4
- package/pp-core/workflows/docs-update.md +1 -1
- package/pp-core/workflows/edit-phase.md +1 -1
- package/pp-core/workflows/eval-review.md +1 -1
- package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +45 -0
- package/pp-core/workflows/execute-phase/steps/client-context-gate.md +1 -1
- package/pp-core/workflows/execute-phase/steps/codebase-drift-gate.md +1 -1
- package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +90 -0
- package/pp-core/workflows/execute-phase/steps/install-run.md +29 -0
- package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +23 -0
- package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +76 -77
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +72 -0
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +5 -5
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +19 -112
- package/pp-core/workflows/execute-phase.md +104 -218
- package/pp-core/workflows/execute-plan.md +6 -2
- package/pp-core/workflows/explore.md +1 -1
- package/pp-core/workflows/extract-learnings.md +1 -1
- package/pp-core/workflows/forensics.md +4 -4
- package/pp-core/workflows/graduation.md +1 -1
- package/pp-core/workflows/health.md +1 -1
- package/pp-core/workflows/help/modes/brief.md +1 -0
- package/pp-core/workflows/help/modes/default.md +2 -1
- package/pp-core/workflows/help/modes/full.md +12 -5
- package/pp-core/workflows/import.md +1 -1
- package/pp-core/workflows/ingest-docs.md +1 -1
- package/pp-core/workflows/init.md +18 -2
- package/pp-core/workflows/insert-phase.md +1 -1
- package/pp-core/workflows/list-workspaces.md +1 -1
- package/pp-core/workflows/manager.md +3 -3
- package/pp-core/workflows/map-codebase.md +2 -2
- package/pp-core/workflows/milestone-summary.md +4 -4
- package/pp-core/workflows/mvp-phase.md +1 -1
- package/pp-core/workflows/new-milestone.md +7 -8
- package/pp-core/workflows/new-project.md +6 -1
- package/pp-core/workflows/new-workspace.md +1 -1
- package/pp-core/workflows/next.md +2 -2
- package/pp-core/workflows/note.md +2 -2
- package/pp-core/workflows/pause-work.md +1 -1
- package/pp-core/workflows/plan-milestone-gaps.md +1 -1
- package/pp-core/workflows/plan-phase/steps/chunked-planning.md +111 -0
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +34 -0
- package/pp-core/workflows/plan-phase.md +43 -167
- package/pp-core/workflows/plan-review-convergence.md +2 -2
- package/pp-core/workflows/plant-seed.md +1 -1
- package/pp-core/workflows/pr-branch.md +1 -1
- package/pp-core/workflows/profile-user.md +1 -1
- package/pp-core/workflows/progress.md +3 -3
- package/pp-core/workflows/quick.md +4 -2
- package/pp-core/workflows/remove-phase.md +1 -1
- package/pp-core/workflows/remove-workspace.md +1 -1
- package/pp-core/workflows/resume-project.md +6 -12
- package/pp-core/workflows/review.md +1 -1
- package/pp-core/workflows/scan.md +1 -1
- package/pp-core/workflows/secure-phase.md +2 -2
- package/pp-core/workflows/settings-advanced.md +1 -1
- package/pp-core/workflows/settings-integrations.md +1 -1
- package/pp-core/workflows/settings.md +1 -1
- package/pp-core/workflows/ship.md +26 -9
- package/pp-core/workflows/sketch-wrap-up.md +1 -1
- package/pp-core/workflows/sketch.md +2 -1
- package/pp-core/workflows/spec-phase.md +1 -1
- package/pp-core/workflows/spike-wrap-up.md +1 -1
- package/pp-core/workflows/spike.md +1 -1
- package/pp-core/workflows/stats.md +2 -2
- package/pp-core/workflows/thread.md +1 -1
- package/pp-core/workflows/transition.md +5 -9
- package/pp-core/workflows/ui-phase.md +23 -2
- package/pp-core/workflows/ui-review.md +1 -1
- package/pp-core/workflows/ultraplan-phase.md +2 -2
- package/pp-core/workflows/validate-phase.md +2 -2
- package/pp-core/workflows/vault-write-back.md +1 -1
- package/pp-core/workflows/verify-phase.md +29 -26
- package/pp-core/workflows/verify-work.md +14 -10
- package/scripts/base64-scan.sh +7 -41
- package/scripts/changeset/cli.cjs +25 -5
- package/scripts/dev-env.cjs +462 -0
- package/scripts/lib/allowlist-ratchet.cjs +103 -1
- package/scripts/lib/size-budget.cjs +311 -0
- package/scripts/lint-neutrality-names.cjs +694 -0
- package/scripts/lint-org-coordinates.allowlist.json +35 -0
- package/scripts/lint-org-coordinates.cjs +426 -0
- package/scripts/lint-skill-deps.cjs +48 -9
- package/scripts/lint-test-file-count.cjs +17 -2
- package/scripts/prompt-injection-scan.sh +41 -20
- package/scripts/sync-manifest-versions.cjs +41 -7
- package/scripts/sync-runtime-launcher.cjs +106 -1
- package/scripts/update-size-baseline.cjs +274 -30
- package/scripts/workflow-size.cjs +61 -9
- package/commands/pp-build-handoff.md +0 -46
- package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +0 -94
|
@@ -0,0 +1,1372 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Discovery scope re-baselining enforcement engine (discovery-v2 Phase 5,
|
|
4
|
+
* VAR-02, D-65..D-77).
|
|
5
|
+
*
|
|
6
|
+
* Read-only structural validator over `.planning/discovery/_rebaseline.md` —
|
|
7
|
+
* the client-doc ↔ requirement ↔ built-phase matrix (D-66). It exists for one
|
|
8
|
+
* situation: a client scope document arrives AFTER build has started, and the
|
|
9
|
+
* functionality already built has to be reconciled against it, phase by phase,
|
|
10
|
+
* with a disposition on each and an argued reason attached to every disposition
|
|
11
|
+
* that is not "carry on".
|
|
12
|
+
*
|
|
13
|
+
* The spine is one row per BUILT unit (D-68), because the thing being
|
|
14
|
+
* dispositioned is built functionality — the only spine that gives a row to
|
|
15
|
+
* functionality the new document simply stops mentioning, which is the whole
|
|
16
|
+
* `lapsed` case. Row identity is the round number plus the built unit's
|
|
17
|
+
* existing roadmap phase number; this engine mints NO ID family at all (D-67).
|
|
18
|
+
*
|
|
19
|
+
* Fail-loud (D-72/D-73), one bullet per fault class this module can emit:
|
|
20
|
+
* - bad_id — a `## Round` heading that CLAIMS the round family but does not
|
|
21
|
+
* match the grammar (a three-digit round number lands here), and the matrix
|
|
22
|
+
* row arity fault: a pipe line inside a round that is neither the header
|
|
23
|
+
* nor the separator and does not carry exactly six cells
|
|
24
|
+
* - duplicate_id — the same round number heading two sections, or the same
|
|
25
|
+
* phase dispositioned twice inside one round. The first row keeps the slot;
|
|
26
|
+
* the later row is reported rather than dropped, because its disposition may
|
|
27
|
+
* contradict the one already recorded
|
|
28
|
+
* - sequence_gap — round numbers that are not contiguous from 1 (D-41/D-66);
|
|
29
|
+
* the locator names the MISSING number, so a deleted round cannot lapse
|
|
30
|
+
* - missing_section — a PRESENT artifact holding no round at all, empty or
|
|
31
|
+
* not: a bootstrap that never got its rows, not a project with nothing to
|
|
32
|
+
* say. A zero-byte or whitespace-only file is the purest instance of that,
|
|
33
|
+
* and exempting it would let the gate exit 0 while the report claimed the
|
|
34
|
+
* artifact had been checked
|
|
35
|
+
* - missing_round_metadata — a round that records no phase denominator. A
|
|
36
|
+
* fault rather than a graceful skip on purpose (D-76): without a denominator
|
|
37
|
+
* the phase-coverage rule can never fire, and a check that exits 0 having
|
|
38
|
+
* verified nothing is precisely the fail-open this engine exists to close
|
|
39
|
+
* - bad_phase_token — a token outside the phase grammar, in the denominator
|
|
40
|
+
* line or in the `Phase` cell alike. ONE grammar for both, deliberately, so
|
|
41
|
+
* coverage is a token-for-token comparison with no normalisation step
|
|
42
|
+
* - bad_built_status — a `Built` cell outside `executed` / `in-flight` /
|
|
43
|
+
* `not started` (D-69)
|
|
44
|
+
* - bad_disposition — a `Disposition` value outside the four scope states
|
|
45
|
+
* (D-70)
|
|
46
|
+
* - disposition_without_reason — a bare `validate`, `dormant` or `lapsed`
|
|
47
|
+
* (D-71). This is the mechanism that stops an agent proposing a lapse
|
|
48
|
+
* without arguing it; a bare `active` is clean, and that polarity is
|
|
49
|
+
* load-bearing rather than incidental
|
|
50
|
+
* - bad_clause_citation — a malformed token in the `Clauses` cell, or a
|
|
51
|
+
* `validate` whose parenthesised reason is prose rather than a D-23 citation
|
|
52
|
+
* - unknown_requirement_id — a `Discovery requirements` token of the wrong
|
|
53
|
+
* shape. The resolves-to-nothing arm of the same code, together with
|
|
54
|
+
* missing_phase_row, requirements_unparseable and artifact_unreadable, is
|
|
55
|
+
* emitted by the aggregator this module's sibling plan adds; all fifteen
|
|
56
|
+
* codes are declared here so the tuple is complete from the start and the
|
|
57
|
+
* reference agrees with it in both directions
|
|
58
|
+
* - unterminated_fence — a code fence opened inside the artifact and never
|
|
59
|
+
* closed. A fenced block is data and its contents are skipped, so an
|
|
60
|
+
* UNBALANCED fence would otherwise skip every remaining round heading,
|
|
61
|
+
* metadata bullet and matrix row and let the gate exit 0 having verified
|
|
62
|
+
* nothing — the same fail-open `missing_round_metadata` exists to close,
|
|
63
|
+
* with the whole rest of the file as its blast radius
|
|
64
|
+
*
|
|
65
|
+
* Graceful arms (D-42/D-66, wired into the aggregator): an ABSENT
|
|
66
|
+
* `_rebaseline.md` is a clean pass — most engagements never re-baseline, and a
|
|
67
|
+
* check that reddened on the absence would turn a variation nobody triggered
|
|
68
|
+
* into a red gate for everybody — while a PRESENT-BUT-UNREADABLE one is
|
|
69
|
+
* `artifact_unreadable`. Collapsing the two would let the gate exit 0 having
|
|
70
|
+
* validated nothing.
|
|
71
|
+
*
|
|
72
|
+
* Reads `.planning/discovery/` and NOTHING else (D-76). The phase denominator
|
|
73
|
+
* every coverage judgement is made against is recorded by the round itself, so
|
|
74
|
+
* this engine performs no roadmap access at all. That keeps each round the
|
|
75
|
+
* self-contained snapshot D-66 already says it is, keeps the dependency
|
|
76
|
+
* footprint identical to the four sibling engines, and dissolves the asymmetry
|
|
77
|
+
* that `.planning/discovery/` is hard-coded non-workstream-scoped while a
|
|
78
|
+
* roadmap path would follow `--ws`.
|
|
79
|
+
*
|
|
80
|
+
* Content-free contract (T-05-01): a violation carries the artifact name, a
|
|
81
|
+
* 1-based source row, a validated round number, a phase token that has already
|
|
82
|
+
* passed its grammar, a validated requirement ID, a closed-list field name and a
|
|
83
|
+
* BOUNDED closed-vocabulary cell prefix — and nothing else. The `Violation`
|
|
84
|
+
* interface has NO free-text member: the `Roadmap requirements` provenance cell
|
|
85
|
+
* (D-77a), the parenthesised disposition reason (D-71) and the round label are
|
|
86
|
+
* parsed and stored on the parse RESULT, and no code path can place any of them
|
|
87
|
+
* on a violation. `bad_phase_token` and the wrong-shape arm of
|
|
88
|
+
* `unknown_requirement_id` carry no echo at all — a cell that failed its grammar
|
|
89
|
+
* has passed no validation, so there is nothing about it that is safe to quote.
|
|
90
|
+
* The `ECHO_MAX_CHARS` bound is a confidentiality control, not a formatting
|
|
91
|
+
* preference: matrix prose is written out of client documents (D-24).
|
|
92
|
+
*
|
|
93
|
+
* Never-judges-a-disposition contract (D-72): this check tests STRUCTURE only —
|
|
94
|
+
* whether the value is in the enum, whether a non-`active` value carries a
|
|
95
|
+
* reason, whether a cited discovery requirement ID resolves, whether every
|
|
96
|
+
* recorded phase has a row, and whether the round headings and their metadata
|
|
97
|
+
* are well-formed. Whether a given `lapsed` is a GOOD call is not
|
|
98
|
+
* machine-checkable, and settling that is the job of the targeted-review walk,
|
|
99
|
+
* with a human in it. There is likewise no round-to-round monotonicity rule,
|
|
100
|
+
* and that is settled rather than pending (D-76): rounds are independent
|
|
101
|
+
* snapshots, a later round may reverse an earlier verdict, and both records
|
|
102
|
+
* survive.
|
|
103
|
+
*
|
|
104
|
+
* The single home for the matrix template, the vocabularies and the
|
|
105
|
+
* parser-contract table this module implements is
|
|
106
|
+
* `pp-core/references/discovery-rebaseline.md` (D-65).
|
|
107
|
+
*/
|
|
108
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
109
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
110
|
+
};
|
|
111
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
112
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
113
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
114
|
+
const io = require("./io.cjs");
|
|
115
|
+
const { output, error, ERROR_REASON } = io;
|
|
116
|
+
// `_requirements.md` is owned by `discovery rollup`; the D-77b resolution arm
|
|
117
|
+
// consumes its single exported reader and nothing else, so a grammar fault
|
|
118
|
+
// there is reported once by name rather than re-enumerated here.
|
|
119
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
120
|
+
const rollup = require("./discovery-rollup.cjs");
|
|
121
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
122
|
+
const table = require("./discovery-table.cjs");
|
|
123
|
+
const { splitTableCells, SEPARATOR_ROW_RE, boundedSequenceMax } = table;
|
|
124
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
125
|
+
/**
|
|
126
|
+
* The closed set of reason codes this engine can emit, in declaration order.
|
|
127
|
+
*
|
|
128
|
+
* Exported as a runtime VALUE, not merely as a type, specifically so the
|
|
129
|
+
* parser-contract table in `pp-core/references/discovery-rebaseline.md` can be
|
|
130
|
+
* pinned against it by a test instead of by a manual diff — in BOTH directions:
|
|
131
|
+
* a code here with no row there fails, and a row there naming a code this engine
|
|
132
|
+
* cannot emit fails too. `ViolationReason` is derived from the tuple, so a code
|
|
133
|
+
* added to one is a code added to both.
|
|
134
|
+
*
|
|
135
|
+
* Declared IN FULL here even though this module emits only the first ten and
|
|
136
|
+
* the fifteenth. Codes eleven through fourteen are raised by the aggregator
|
|
137
|
+
* added alongside the CLI arm; the declaration ORDER is part of the contract,
|
|
138
|
+
* so a code moved here is a row the reference table has to move too.
|
|
139
|
+
*
|
|
140
|
+
* Append-only by convention: `unterminated_fence` sits last so the fourteen
|
|
141
|
+
* earlier entries keep their positions and the reference table extends by a row
|
|
142
|
+
* rather than being reordered — which is what keeps a fault code's spelling
|
|
143
|
+
* stable for the readers and scripts that have already learned it. That is also
|
|
144
|
+
* why the unbalanced fence got a code of its OWN rather than reusing `bad_id`:
|
|
145
|
+
* a fault code that names the wrong condition is the defect this gap set exists
|
|
146
|
+
* to remove, not a saving.
|
|
147
|
+
*/
|
|
148
|
+
const VIOLATION_REASONS = Object.freeze([
|
|
149
|
+
'bad_id',
|
|
150
|
+
'duplicate_id',
|
|
151
|
+
'sequence_gap',
|
|
152
|
+
'missing_section',
|
|
153
|
+
'missing_round_metadata',
|
|
154
|
+
'bad_phase_token',
|
|
155
|
+
'bad_built_status',
|
|
156
|
+
'bad_disposition',
|
|
157
|
+
'disposition_without_reason',
|
|
158
|
+
'bad_clause_citation',
|
|
159
|
+
'unknown_requirement_id',
|
|
160
|
+
'missing_phase_row',
|
|
161
|
+
'requirements_unparseable',
|
|
162
|
+
'artifact_unreadable',
|
|
163
|
+
'unterminated_fence',
|
|
164
|
+
]);
|
|
165
|
+
// ─── Closed vocabularies ──────────────────────────────────────────────────────
|
|
166
|
+
/**
|
|
167
|
+
* The four SCOPE-STATE values a row may be dispositioned with (D-70).
|
|
168
|
+
*
|
|
169
|
+
* `active` — the new scope still wants it as built, nothing to do.
|
|
170
|
+
* `validate` — still in scope, but the new document changes its expected
|
|
171
|
+
* behaviour; it must be re-verified against the named clause.
|
|
172
|
+
* `dormant` — out of scope now but plausibly returns; the code stays,
|
|
173
|
+
* unadvertised and unmaintained, with a named re-entry condition.
|
|
174
|
+
* `lapsed` — the new scope removes it outright; the code stays in the tree but
|
|
175
|
+
* is no longer claimed as delivered.
|
|
176
|
+
*
|
|
177
|
+
* The cut between the last two is "may return" versus "won't". Nothing in this
|
|
178
|
+
* vocabulary demands deletion.
|
|
179
|
+
*
|
|
180
|
+
* NAMED `SCOPE_STATES` RATHER THAN A DISPOSITION-SHAPED NAME ON PURPOSE.
|
|
181
|
+
* `_requirements.md` already owns a vocabulary called dispositions, with the
|
|
182
|
+
* values MVP / Later / Dropped, exported from `discovery-rollup.cts` as
|
|
183
|
+
* `DISPOSITIONS`. D-03/D-70/D-74 make this a THIRD, orthogonal axis living in a
|
|
184
|
+
* different artifact on a different spine, and re-baselining writes no
|
|
185
|
+
* `_requirements.md` cell at all. Two frozen objects sharing one name across two
|
|
186
|
+
* engines is how a reader conflates two axes the design deliberately separated.
|
|
187
|
+
*/
|
|
188
|
+
const SCOPE_STATES = Object.freeze({
|
|
189
|
+
ACTIVE: 'active',
|
|
190
|
+
VALIDATE: 'validate',
|
|
191
|
+
DORMANT: 'dormant',
|
|
192
|
+
LAPSED: 'lapsed',
|
|
193
|
+
});
|
|
194
|
+
const SCOPE_VALUES = Object.values(SCOPE_STATES);
|
|
195
|
+
/**
|
|
196
|
+
* The closed three-value `Built` enum (D-69), mapped by the workflow from the
|
|
197
|
+
* `disk_status` field of `pp-tools query roadmap analyze`: `complete` →
|
|
198
|
+
* `executed`; `partial` / `planned` → `in-flight`; everything else → `not
|
|
199
|
+
* started`.
|
|
200
|
+
*
|
|
201
|
+
* Every phase in the denominator gets a row, INCLUDING phases nobody has
|
|
202
|
+
* started: the cheapest scope kill is a phase nobody has started, and excluding
|
|
203
|
+
* queued work would hide precisely the decisions that cost nothing to make.
|
|
204
|
+
*/
|
|
205
|
+
const BUILT_STATES = Object.freeze({
|
|
206
|
+
EXECUTED: 'executed',
|
|
207
|
+
IN_FLIGHT: 'in-flight',
|
|
208
|
+
NOT_STARTED: 'not started',
|
|
209
|
+
});
|
|
210
|
+
const BUILT_VALUES = Object.values(BUILT_STATES);
|
|
211
|
+
// ─── Module constants ─────────────────────────────────────────────────────────
|
|
212
|
+
/**
|
|
213
|
+
* The confidentiality bound on every echoed detail. Not a formatting
|
|
214
|
+
* preference: matrix prose is derived from client documents (D-24), so an
|
|
215
|
+
* unbounded echo is a disclosure. Same value as all four sibling engines,
|
|
216
|
+
* deliberately — a bound that differs per engine is a bound somebody has to
|
|
217
|
+
* remember, and the one they misremember is the one that leaks.
|
|
218
|
+
*/
|
|
219
|
+
const ECHO_MAX_CHARS = 24;
|
|
220
|
+
/**
|
|
221
|
+
* The cap on how many violations a failure message RENDERS. A pathological
|
|
222
|
+
* artifact would otherwise inline one line each into the `--json-errors` JSON
|
|
223
|
+
* `message`. The cap bounds the report, never the accounting: the count in the
|
|
224
|
+
* message prefix and the `violations` field of the failure-path summary both
|
|
225
|
+
* stay the true total.
|
|
226
|
+
*/
|
|
227
|
+
const MAX_REPORTED_VIOLATIONS = 50;
|
|
228
|
+
/**
|
|
229
|
+
* Hard-coded exactly as all four sibling engines hard-code it, and deliberately
|
|
230
|
+
* NOT resolved through the workspace path helper. `.planning/discovery/` is not
|
|
231
|
+
* workstream-scoped, so a `--ws`-following roadmap path beside it would be an
|
|
232
|
+
* asymmetry the engine has to reconcile at every read. D-76 dissolves that
|
|
233
|
+
* asymmetry instead of papering over it, by recording the phase denominator in
|
|
234
|
+
* the round itself — an engine that never opens a roadmap never has to
|
|
235
|
+
* reconcile the two.
|
|
236
|
+
*/
|
|
237
|
+
const DISCOVERY_DIR = '.planning/discovery';
|
|
238
|
+
const REBASELINE_FILE = '_rebaseline.md';
|
|
239
|
+
const REQUIREMENTS_FILE = '_requirements.md';
|
|
240
|
+
/** The em dash an author writes to mean "nothing here" in a list-bearing cell. */
|
|
241
|
+
const NOT_APPLICABLE = '—';
|
|
242
|
+
/**
|
|
243
|
+
* The gate's identity, exported under its own name as well as through
|
|
244
|
+
* `ERROR_REASON`: it is cited by the workflow steps that run it, by the
|
|
245
|
+
* reference, and by the derived-command audit that regexes the subcommand
|
|
246
|
+
* spelling straight out of this module rather than re-spelling it as a string
|
|
247
|
+
* literal in a second place.
|
|
248
|
+
*/
|
|
249
|
+
const REBASELINE_CHECK_FAILED = ERROR_REASON.DISCOVERY_REBASELINE_CHECK_FAILED;
|
|
250
|
+
const REBASELINE_CHECK_USAGE = ERROR_REASON.USAGE;
|
|
251
|
+
// ─── Grammar ──────────────────────────────────────────────────────────────────
|
|
252
|
+
// Anchored, non-backtracking regexes (threat T-05-07). Every one of them is
|
|
253
|
+
// applied either to a SINGLE already-split line or to a SINGLE already-split
|
|
254
|
+
// table cell — never unanchored across whole-file untrusted content — and every
|
|
255
|
+
// quantifier is bounded. The matrix is written out of client documents, so a
|
|
256
|
+
// catastrophic backtracking pattern here would be a denial-of-service surface.
|
|
257
|
+
/**
|
|
258
|
+
* D-66 round heading, byte-identical in shape to the `_feedback.md` one: a
|
|
259
|
+
* second-level heading, the word Round, a decimal round number, then an
|
|
260
|
+
* em-dash-separated ISO date and an em-dash-separated free-text label. The
|
|
261
|
+
* triggering accession lives in a metadata BULLET rather than in the heading
|
|
262
|
+
* precisely so this shape could transfer unchanged.
|
|
263
|
+
*
|
|
264
|
+
* ONLY the round number is validated. The date and the label are matched for
|
|
265
|
+
* SHAPE and their content is never inspected — stated here so the reference's
|
|
266
|
+
* parser-contract table can say so honestly rather than implying a date check
|
|
267
|
+
* this engine does not perform.
|
|
268
|
+
*
|
|
269
|
+
* The round number is BOUNDED to two digits, like every other discovery ID
|
|
270
|
+
* family. An unbounded `\d+` let a plain typo — the ISO date written into the
|
|
271
|
+
* round-number slot, `## Round 20250115` — drive `validateRoundSequence` into
|
|
272
|
+
* allocating twenty million gap violations and abort the process on a V8 heap
|
|
273
|
+
* fatal, which is not a JavaScript exception and therefore not something a
|
|
274
|
+
* try/catch can turn into a structured result.
|
|
275
|
+
*/
|
|
276
|
+
const ROUND_HEADING_RE = /^## Round (\d{1,2}) — ([^—]+) — (.+)$/;
|
|
277
|
+
/**
|
|
278
|
+
* A heading that CLAIMS the round family. A claimed-but-malformed one is a
|
|
279
|
+
* reported fault, not a skipped line (T-05-04): a malformed heading a parser
|
|
280
|
+
* passes over is a whole section of the audit trail the gate never looked at.
|
|
281
|
+
* A heading that never claims the family is ignored outright — other
|
|
282
|
+
* second-level sections in the document are not this parser's business.
|
|
283
|
+
*/
|
|
284
|
+
const ROUND_CLAIM_RE = /^## Round\b/;
|
|
285
|
+
/**
|
|
286
|
+
* The bound on the phase-denominator payload, in characters after the colon.
|
|
287
|
+
*
|
|
288
|
+
* Derived from the shape the rebaseline WORKFLOW actually instructs rather than
|
|
289
|
+
* chosen for roundness. A partitioned project prefixes every phase token with
|
|
290
|
+
* its workstream name — `discovery-v2:04, ` — which runs about seventeen
|
|
291
|
+
* characters, so the previous 400 was exhausted at roughly twenty-four phases:
|
|
292
|
+
* an ordinary project size, and a line the author had every reason to believe
|
|
293
|
+
* was correct (WR-03). 2000 admits well over a hundred such tokens, which no
|
|
294
|
+
* real roadmap reaches.
|
|
295
|
+
*
|
|
296
|
+
* The larger bound adds NO backtracking surface (T-05-07, T-05-31). The
|
|
297
|
+
* quantifier is still single, still non-nested, still anchored at both ends, and
|
|
298
|
+
* still applied to ONE already-split line, so the match is linear in the line
|
|
299
|
+
* length — and the split list it feeds is bounded by the same number.
|
|
300
|
+
*
|
|
301
|
+
* EXPORTED, and stated by number in the reference, because the whole of WR-03
|
|
302
|
+
* was a bound nobody could see reported under a code that named a different
|
|
303
|
+
* condition. A bound an author can neither read nor derive is a bound they can
|
|
304
|
+
* only discover by failing.
|
|
305
|
+
*/
|
|
306
|
+
const DENOMINATOR_MAX_CHARS = 2000;
|
|
307
|
+
/**
|
|
308
|
+
* The per-round phase-denominator metadata bullet (D-76). Its capture is the
|
|
309
|
+
* comma-separated phase-token list every coverage judgement is made against.
|
|
310
|
+
*
|
|
311
|
+
* Built from `DENOMINATOR_MAX_CHARS` rather than restating it, so the bound the
|
|
312
|
+
* reference documents, the bound the tests derive their payloads from and the
|
|
313
|
+
* bound the grammar enforces are one number. The pattern is a fixed string
|
|
314
|
+
* composed at module load from a module constant — no input reaches it.
|
|
315
|
+
*/
|
|
316
|
+
const DENOMINATOR_LINE_RE = new RegExp(`^- Phases at this round:(.{0,${DENOMINATOR_MAX_CHARS}})$`);
|
|
317
|
+
/**
|
|
318
|
+
* A line that CLAIMS the phase-denominator family. Same claim-versus-match
|
|
319
|
+
* idiom as `ROUND_CLAIM_RE` versus `ROUND_HEADING_RE`, and for the same reason:
|
|
320
|
+
* a claimed-but-malformed line a parser passes over is a line the author
|
|
321
|
+
* believes is recording a denominator that the gate never read.
|
|
322
|
+
*
|
|
323
|
+
* Before WR-03 an over-long bullet matched nothing at all and fell through the
|
|
324
|
+
* accession check, the separator check and the leading-pipe check to be dropped
|
|
325
|
+
* in silence — leaving the round to close with no denominator and report
|
|
326
|
+
* `missing_round_metadata`, a fault whose text describes a round that records no
|
|
327
|
+
* denominator about a round that records one perfectly well.
|
|
328
|
+
*/
|
|
329
|
+
const DENOMINATOR_CLAIM_RE = /^- Phases at this round:/;
|
|
330
|
+
/**
|
|
331
|
+
* The per-round accession metadata bullet, matched for PRESENCE only. The
|
|
332
|
+
* accession is recorded for provenance and is never resolved against the
|
|
333
|
+
* input-package README — do not read a resolution guarantee into a passing run.
|
|
334
|
+
*/
|
|
335
|
+
const ACCESSION_LINE_RE = /^- Triggering document:(.{0,80})$/;
|
|
336
|
+
/**
|
|
337
|
+
* A roadmap phase number, optionally prefixed by a lower-case workstream name
|
|
338
|
+
* and a colon when a project is partitioned across several roadmaps — `04`, or
|
|
339
|
+
* `discovery-v2:04`. An optional dotted sub-number covers a split phase.
|
|
340
|
+
*
|
|
341
|
+
* The SAME grammar governs the denominator line and the `Phase` cell,
|
|
342
|
+
* deliberately, so the two can be compared token for token without a
|
|
343
|
+
* normalisation step — the cheapest possible implementation of the coverage
|
|
344
|
+
* rule and the one least likely to drift.
|
|
345
|
+
*/
|
|
346
|
+
const PHASE_TOKEN_RE = /^(?:[a-z][a-z0-9-]{0,39}:)?\d{1,3}(?:\.\d{1,2})?$/;
|
|
347
|
+
/**
|
|
348
|
+
* The D-23 bracketed accession-plus-locator citation, the same shape
|
|
349
|
+
* `discovery-id-conventions.md` defines and `discovery-rollup.cts` already
|
|
350
|
+
* enforces — `[03]`, `[03 §2.1]`. The eighty-character locator bound is a
|
|
351
|
+
* confidentiality control (D-24), not a formatting preference.
|
|
352
|
+
*/
|
|
353
|
+
const CLAUSE_CITE_RE = /^\[(\d{2})( §[^\]]{1,80})?\]$/;
|
|
354
|
+
/**
|
|
355
|
+
* A discovery requirement ID as `_requirements.md` mints them. Bounded rather
|
|
356
|
+
* than the siblings' open `[A-Z0-9]*-\d+`, per the T-05-07 discipline; the
|
|
357
|
+
* bounds are a generous superset of every reserved discovery prefix, so nothing
|
|
358
|
+
* the owning gate accepts is rejected here.
|
|
359
|
+
*/
|
|
360
|
+
const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]{0,15}-\d{1,6}$/;
|
|
361
|
+
/**
|
|
362
|
+
* The D-71 value-plus-structured-reason `Disposition` cell — the same trick as
|
|
363
|
+
* `Closed (<evidence>)`, `Dropped (D-<ss>-<n>)` and `DEFERRED (<reason>)`.
|
|
364
|
+
* Capture 1 is the bounded lower-case value token, capture 2 the optional
|
|
365
|
+
* bounded parenthesised reason.
|
|
366
|
+
*
|
|
367
|
+
* A separate Reason COLUMN was rejected because a separate column lets the
|
|
368
|
+
* reason be empty without breaking the enum's grammar — decoupling precisely
|
|
369
|
+
* what this shape couples. The reason text is free text and is never echoed.
|
|
370
|
+
*/
|
|
371
|
+
const DISPOSITION_CELL_RE = /^([a-z][a-z-]{0,23})(?: \((.{0,400})\))?$/;
|
|
372
|
+
/** A fenced block opener or closer. Contents are data, never parsed as rows. */
|
|
373
|
+
const FENCE_RE = /^(?:```|~~~)/;
|
|
374
|
+
/**
|
|
375
|
+
* The six column names in the fixed order the reference sets. The header row is
|
|
376
|
+
* recognised by COMPARISON against this array rather than by a regex, so the
|
|
377
|
+
* order is a value the reference and the engine can be pinned against rather
|
|
378
|
+
* than a pattern buried in a match.
|
|
379
|
+
*/
|
|
380
|
+
const MATRIX_HEADER_CELLS = Object.freeze([
|
|
381
|
+
'Phase',
|
|
382
|
+
'Built',
|
|
383
|
+
'Roadmap requirements',
|
|
384
|
+
'Discovery requirements',
|
|
385
|
+
'Clauses',
|
|
386
|
+
'Disposition',
|
|
387
|
+
]);
|
|
388
|
+
/** Column indices into a split matrix row, in the fixed order above. */
|
|
389
|
+
const MATRIX_COL = Object.freeze({
|
|
390
|
+
PHASE: 0,
|
|
391
|
+
BUILT: 1,
|
|
392
|
+
ROADMAP_REQUIREMENTS: 2,
|
|
393
|
+
DISCOVERY_REQUIREMENTS: 3,
|
|
394
|
+
CLAUSES: 4,
|
|
395
|
+
DISPOSITION: 5,
|
|
396
|
+
});
|
|
397
|
+
// ─── Helpers ──────────────────────────────────────────────────────────────────
|
|
398
|
+
/**
|
|
399
|
+
* Every control character the whitespace collapse below does NOT handle: the C0
|
|
400
|
+
* plane minus `\t\n\v\f\r`, DEL, and the whole C1 plane.
|
|
401
|
+
*
|
|
402
|
+
* Stripped BEFORE the collapse, and the ordering is the whole point. JavaScript
|
|
403
|
+
* `\s` matches only the five C0 whitespace characters plus the Unicode space
|
|
404
|
+
* separators; it does NOT match ESC (`\x1b`), BEL (`\x07`), BS (`\x08`) or NUL.
|
|
405
|
+
* `formatViolation` output reaches `error()`, which in the default
|
|
406
|
+
* non-`--json-errors` mode writes `'Error: ' + message` to fd 2 VERBATIM — the
|
|
407
|
+
* mode a human operator reads in a terminal. A complete OSC-52 clipboard-write
|
|
408
|
+
* sequence fits inside `ECHO_MAX_CHARS`, and an UNTERMINATED OSC string consumes
|
|
409
|
+
* the report text after it and continues into the next violation's line. The
|
|
410
|
+
* `Built` cell is echoed on the `bad_built_status` path and has passed no
|
|
411
|
+
* grammar at that point, so it is exactly this surface. Identical to the
|
|
412
|
+
* research-track and screens constants, deliberately — the three chokepoints
|
|
413
|
+
* defend the same fd-2 surface and must not drift apart.
|
|
414
|
+
*/
|
|
415
|
+
const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
416
|
+
/**
|
|
417
|
+
* The single echo chokepoint. Strip the control planes, collapse whitespace,
|
|
418
|
+
* trim, then cap at `ECHO_MAX_CHARS`.
|
|
419
|
+
*
|
|
420
|
+
* Both the strip and the collapse run BEFORE the slice, and together they
|
|
421
|
+
* guarantee a cell can never inject a newline, carriage return, tab or terminal
|
|
422
|
+
* escape sequence into the one-line-per-violation report format, regardless of
|
|
423
|
+
* where the slice happens to land.
|
|
424
|
+
*
|
|
425
|
+
* A confidentiality control, not a formatting preference (D-24): matrix prose is
|
|
426
|
+
* written out of client documents, so an unbounded echo is a disclosure.
|
|
427
|
+
*/
|
|
428
|
+
function sanitizeEcho(value) {
|
|
429
|
+
// A non-string is not author data this function can meaningfully bound, and
|
|
430
|
+
// default stringification would emit `[object Object]` — so it renders as
|
|
431
|
+
// nothing rather than as a placeholder that looks like content.
|
|
432
|
+
if (typeof value !== 'string')
|
|
433
|
+
return '';
|
|
434
|
+
const collapsed = value.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim();
|
|
435
|
+
if (collapsed.length <= ECHO_MAX_CHARS)
|
|
436
|
+
return collapsed;
|
|
437
|
+
return `${collapsed.slice(0, ECHO_MAX_CHARS)}…`;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Split a comma-separated in-cell list into non-empty trimmed tokens. Lists
|
|
441
|
+
* WITHIN one cell are comma-separated across this whole surface; the ` · `
|
|
442
|
+
* middle dot stays reserved for compound-field separation (D-27/D-37/D-45). A
|
|
443
|
+
* second spelling of either would be a join that silently matches nothing.
|
|
444
|
+
*/
|
|
445
|
+
function splitList(value) {
|
|
446
|
+
return value.split(',').map(t => t.trim()).filter(t => t !== '');
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* The content-free locator every fault raised INSIDE a round section is built
|
|
450
|
+
* from: the artifact, the 1-based source row, and the round number — but only
|
|
451
|
+
* when there is a round number that passed the grammar.
|
|
452
|
+
*
|
|
453
|
+
* The single place the round field is attached, so an anonymous section
|
|
454
|
+
* (WR-05) cannot leak a number into a report by one call site forgetting. The
|
|
455
|
+
* field is OMITTED rather than set to a sentinel: a sentinel is a value that
|
|
456
|
+
* can travel, and the content-free contract (T-05-01) turns on a violation
|
|
457
|
+
* never carrying anything that passed no validation.
|
|
458
|
+
*/
|
|
459
|
+
function roundLocator(round, line) {
|
|
460
|
+
if (round === null)
|
|
461
|
+
return { artifact: REBASELINE_FILE, line };
|
|
462
|
+
return { artifact: REBASELINE_FILE, line, round };
|
|
463
|
+
}
|
|
464
|
+
/**
|
|
465
|
+
* Open a round for a section whose heading claimed the family and failed it
|
|
466
|
+
* (WR-05), located at that heading's row.
|
|
467
|
+
*
|
|
468
|
+
* `date` and `label` are empty strings rather than anything taken from the
|
|
469
|
+
* line: the heading matched no grammar, so it has no date and no label — it has
|
|
470
|
+
* only a row number. The caller is responsible for the three exclusions the
|
|
471
|
+
* `AnonymousRound` docblock states; this factory deliberately does NOT touch
|
|
472
|
+
* the rounds map or the sequence list, so neither can happen by accident here.
|
|
473
|
+
*/
|
|
474
|
+
function openAnonymousRound(line) {
|
|
475
|
+
return {
|
|
476
|
+
round: null,
|
|
477
|
+
anonymous: true,
|
|
478
|
+
line,
|
|
479
|
+
date: '',
|
|
480
|
+
label: '',
|
|
481
|
+
accession: null,
|
|
482
|
+
denominator: null,
|
|
483
|
+
denominatorLine: null,
|
|
484
|
+
denominatorClaimLine: null,
|
|
485
|
+
metadataFaultReported: false,
|
|
486
|
+
rows: [],
|
|
487
|
+
};
|
|
488
|
+
}
|
|
489
|
+
/** True when a split row is the fixed six-column header. */
|
|
490
|
+
function isHeaderRow(cells) {
|
|
491
|
+
return (cells.length === MATRIX_HEADER_CELLS.length &&
|
|
492
|
+
MATRIX_HEADER_CELLS.every((name, i) => cells[i] === name));
|
|
493
|
+
}
|
|
494
|
+
// ─── Sequence ─────────────────────────────────────────────────────────────────
|
|
495
|
+
/**
|
|
496
|
+
* Report the round numbers missing from a contiguous `1..max` run (D-41/D-66).
|
|
497
|
+
* The locator names the MISSING number, so a deleted round cannot lapse
|
|
498
|
+
* unnoticed — a hole is information, it says a round was dropped.
|
|
499
|
+
*
|
|
500
|
+
* The maximum folds through `boundedSequenceMax` from the shared leaf and NEVER
|
|
501
|
+
* through a spread over the parsed numbers. Two hazards close there in one
|
|
502
|
+
* place: the spread throws `RangeError` on a large array, and an unbounded
|
|
503
|
+
* maximum turns
|
|
504
|
+
* one mistyped heading into one allocated violation per missing number and a
|
|
505
|
+
* heap abort that no try/catch can convert into a structured result. Past the
|
|
506
|
+
* bound this reports a SINGLE `sequence_gap` rather than enumerating.
|
|
507
|
+
*
|
|
508
|
+
* EXPORTED so the bounded-span floor can be falsified through THIS engine and
|
|
509
|
+
* not only through the shared leaf. `ROUND_HEADING_RE` is two digits, so the
|
|
510
|
+
* out-of-band arm is unreachable end to end and a test can only reach it by
|
|
511
|
+
* calling this validator with the numbers directly.
|
|
512
|
+
*
|
|
513
|
+
* The below-1 arm is an INDEPENDENT floor, not the primary guard: the round
|
|
514
|
+
* heading now rejects a number below 1 at the grammar, so nothing this parser
|
|
515
|
+
* produces can reach here out of range (WR-04). It is kept for the same two
|
|
516
|
+
* reasons the out-of-band arm above it is kept — the grammar is one edit away
|
|
517
|
+
* from being widened again, and the tests call this validator directly, so an
|
|
518
|
+
* exported validator that returned a CLEAN list for `[0, 1]` would be dishonest
|
|
519
|
+
* about a rule the family states in three places.
|
|
520
|
+
*
|
|
521
|
+
* The floor lives HERE and not in `boundedSequenceMax`. That leaf is shared with
|
|
522
|
+
* two sibling engines, it computes a MAXIMUM, and a family's low bound is not
|
|
523
|
+
* its business: widening it for one caller would reintroduce exactly the drift
|
|
524
|
+
* its extraction closed (ADR/04-PATTERNS, AR-04-02).
|
|
525
|
+
*
|
|
526
|
+
* Noted rather than silently changed: the out-of-band arm below returns
|
|
527
|
+
* `sequence_gap` where this engine's three siblings return `bad_id` for the same
|
|
528
|
+
* shape. That divergence is real and is recorded as an observation for plan
|
|
529
|
+
* 05-12; it is NOT changed here, because the case is unreachable through the
|
|
530
|
+
* grammar and moving its code would move a documented parser-contract row for a
|
|
531
|
+
* condition no artifact can produce.
|
|
532
|
+
*/
|
|
533
|
+
function validateRoundSequence(numbers) {
|
|
534
|
+
const violations = [];
|
|
535
|
+
if (numbers.length === 0)
|
|
536
|
+
return violations;
|
|
537
|
+
if (numbers.some(n => n < 1)) {
|
|
538
|
+
return [{ artifact: REBASELINE_FILE, reason: 'bad_id' }];
|
|
539
|
+
}
|
|
540
|
+
const seen = new Set(numbers);
|
|
541
|
+
const max = boundedSequenceMax(numbers);
|
|
542
|
+
if (max === null) {
|
|
543
|
+
return [{ artifact: REBASELINE_FILE, reason: 'sequence_gap' }];
|
|
544
|
+
}
|
|
545
|
+
for (let n = 1; n <= max; n += 1) {
|
|
546
|
+
if (!seen.has(n)) {
|
|
547
|
+
violations.push({ artifact: REBASELINE_FILE, round: n, reason: 'sequence_gap' });
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
return violations;
|
|
551
|
+
}
|
|
552
|
+
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
|
553
|
+
/**
|
|
554
|
+
* Parse `_rebaseline.md` into its rounds, its rows and the faults found WITHIN
|
|
555
|
+
* the artifact. Never throws; a non-string input yields an empty result.
|
|
556
|
+
*
|
|
557
|
+
* Lines are split on a carriage-return-optional newline so a Windows checkout
|
|
558
|
+
* parses identically. Fenced blocks are tracked and their contents skipped, so
|
|
559
|
+
* the bootstrap template pasted into the artifact — or any other fenced
|
|
560
|
+
* example — cannot be parsed as data. The contract that follows from those two
|
|
561
|
+
* halves in one sentence: a BALANCED fence is still data and its contents are
|
|
562
|
+
* still skipped, while an UNBALANCED fence is a fault, so the remainder it
|
|
563
|
+
* swallowed can never be skipped in silence.
|
|
564
|
+
*
|
|
565
|
+
* A FAULTED ROUND HEADING is reported AND its rows are still checked (WR-05),
|
|
566
|
+
* so an author sees every fault in the file on the first run rather than
|
|
567
|
+
* discovering a second wave after fixing the heading. A duplicated heading
|
|
568
|
+
* re-opens the round already registered under that number; a malformed or
|
|
569
|
+
* out-of-range one opens an anonymous round, which reaches the row walk and
|
|
570
|
+
* nothing else. Both arms used to leave no round open, so every line until the
|
|
571
|
+
* next heading fell through the `open === null` guard and the whole section's
|
|
572
|
+
* cells escaped every per-row check — the two-pass experience the
|
|
573
|
+
* collect-all-then-fail design (D-08) exists to avoid.
|
|
574
|
+
*
|
|
575
|
+
* `rounds` is a `Map` keyed by the validated integer round number and the
|
|
576
|
+
* per-round phase index is a `Set` (T-05-06 discipline: derived state is never
|
|
577
|
+
* keyed into a plain object literal, so a crafted key cannot reach it).
|
|
578
|
+
*
|
|
579
|
+
* Cross-artifact resolution — the resolves-to-nothing arm of
|
|
580
|
+
* `unknown_requirement_id` and the whole of `missing_phase_row` — is NOT done
|
|
581
|
+
* here. This function is handed a string and reads no filesystem, which is what
|
|
582
|
+
* lets every within-artifact fault be provoked by an in-memory fixture.
|
|
583
|
+
*/
|
|
584
|
+
function parseRebaselineMatrix(content) {
|
|
585
|
+
const rounds = new Map();
|
|
586
|
+
const rows = [];
|
|
587
|
+
const violations = [];
|
|
588
|
+
if (typeof content !== 'string')
|
|
589
|
+
return { rounds, rows, violations };
|
|
590
|
+
const lines = content.split(/\r?\n/);
|
|
591
|
+
const seqs = [];
|
|
592
|
+
let open = null;
|
|
593
|
+
let seenPhases = new Set();
|
|
594
|
+
let sawRoundClaim = false;
|
|
595
|
+
let inFence = false;
|
|
596
|
+
// The 1-based row of the fence that OPENED the block currently being skipped,
|
|
597
|
+
// held so an unbalanced fence can be located at the line that opened it. The
|
|
598
|
+
// missing closer has no row of its own to name, and the opening line is the
|
|
599
|
+
// one the author has to go and look at.
|
|
600
|
+
let fenceOpenLine = null;
|
|
601
|
+
// A round that reaches its end — the next round heading, or end of file —
|
|
602
|
+
// without a denominator line is the D-76 fault, located at its HEADING row
|
|
603
|
+
// because the missing line has no row of its own.
|
|
604
|
+
//
|
|
605
|
+
// A round whose bullet CLAIMED the family and failed its grammar is exempt: it
|
|
606
|
+
// already carries a `bad_id` naming the condition it actually violated, and
|
|
607
|
+
// adding `missing_round_metadata` on top would restate one malformed line as
|
|
608
|
+
// two faults, the second of them the misreport WR-03 exists to remove. A round
|
|
609
|
+
// whose bullet PARSED but recorded no usable token is not exempt — that round
|
|
610
|
+
// genuinely records no denominator (WR-01).
|
|
611
|
+
//
|
|
612
|
+
// An ANONYMOUS round is exempt too, and for the same shape of reason (WR-05):
|
|
613
|
+
// its heading already faulted, so a second derived fault about a section
|
|
614
|
+
// nobody can name would have to be located by row alone and would say nothing
|
|
615
|
+
// the `bad_id` above it does not — noise the author cannot act on.
|
|
616
|
+
//
|
|
617
|
+
// `metadataFaultReported` is what keeps this one fault per ROUND rather than
|
|
618
|
+
// one per SECTION. A round whose number heads two sections is re-opened for
|
|
619
|
+
// the second one (WR-05), so this close runs twice over the same round object;
|
|
620
|
+
// the count of sections is not the count of faults.
|
|
621
|
+
const closeRound = () => {
|
|
622
|
+
if (open !== null &&
|
|
623
|
+
!open.anonymous &&
|
|
624
|
+
!open.metadataFaultReported &&
|
|
625
|
+
open.denominator === null &&
|
|
626
|
+
open.denominatorClaimLine === null) {
|
|
627
|
+
violations.push({
|
|
628
|
+
artifact: REBASELINE_FILE,
|
|
629
|
+
line: open.line,
|
|
630
|
+
round: open.round,
|
|
631
|
+
reason: 'missing_round_metadata',
|
|
632
|
+
});
|
|
633
|
+
open.metadataFaultReported = true;
|
|
634
|
+
}
|
|
635
|
+
};
|
|
636
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
637
|
+
const raw = lines[i];
|
|
638
|
+
const line = raw.trim();
|
|
639
|
+
const num = i + 1;
|
|
640
|
+
if (FENCE_RE.test(line)) {
|
|
641
|
+
inFence = !inFence;
|
|
642
|
+
fenceOpenLine = inFence ? num : null;
|
|
643
|
+
continue;
|
|
644
|
+
}
|
|
645
|
+
// The skip stays EXACTLY as it was. A round heading is not tested ahead of
|
|
646
|
+
// it, deliberately: a heading inside a BALANCED fence is the pasted
|
|
647
|
+
// bootstrap template the module header promises to tolerate, and parsing it
|
|
648
|
+
// unconditionally would fault that documented shape with `duplicate_id` —
|
|
649
|
+
// trading a fail-open for a false positive. The unbalanced fence is
|
|
650
|
+
// reported after the loop instead, so the silent-pass path closes without
|
|
651
|
+
// the balanced shape becoming a fault.
|
|
652
|
+
if (inFence)
|
|
653
|
+
continue;
|
|
654
|
+
if (ROUND_CLAIM_RE.test(line)) {
|
|
655
|
+
sawRoundClaim = true;
|
|
656
|
+
closeRound();
|
|
657
|
+
open = null;
|
|
658
|
+
seenPhases = new Set();
|
|
659
|
+
const match = ROUND_HEADING_RE.exec(line);
|
|
660
|
+
if (match === null) {
|
|
661
|
+
// Located by row number ONLY, carrying no echo: a heading that failed
|
|
662
|
+
// its grammar has passed no validation, so nothing about it is safe to
|
|
663
|
+
// quote (T-05-01).
|
|
664
|
+
violations.push({ artifact: REBASELINE_FILE, line: num, reason: 'bad_id' });
|
|
665
|
+
// …and the section beneath it is STILL parsed, under an anonymous round
|
|
666
|
+
// (WR-05). The heading fault used to cost the gate every cell below it.
|
|
667
|
+
// `seenPhases` was reset above and stays reset: this is a distinct
|
|
668
|
+
// section, not the second half of an existing round.
|
|
669
|
+
open = openAnonymousRound(num);
|
|
670
|
+
continue;
|
|
671
|
+
}
|
|
672
|
+
const round = Number(match[1]);
|
|
673
|
+
// The three heading outcomes read as one group: malformed above, OUT OF
|
|
674
|
+
// RANGE here, duplicate below.
|
|
675
|
+
//
|
|
676
|
+
// Below 1 is the SAME condition the high end already rejects through the
|
|
677
|
+
// two-digit capture — a round number outside the family's range — so it is
|
|
678
|
+
// rejected in the same place, at the grammar. `ROUND_HEADING_RE` captures
|
|
679
|
+
// one or two digits and therefore matches `0` and `00`; before WR-04 a
|
|
680
|
+
// lone `## Round 0` parsed successfully, yielded a maximum of zero from
|
|
681
|
+
// `boundedSequenceMax` (which seeds at 0), ran the contiguity loop zero
|
|
682
|
+
// times and reported nothing — while the module header, the reference and
|
|
683
|
+
// the bootstrap template's own preamble all promise "contiguous from 1".
|
|
684
|
+
// It also left `latest_round` as the falsy 0, which a downstream
|
|
685
|
+
// `if (payload.latest_round)` reads as "no rounds" for an artifact with
|
|
686
|
+
// one. Carrying no echo: the heading did not pass its grammar (T-05-01).
|
|
687
|
+
if (round < 1) {
|
|
688
|
+
violations.push({ artifact: REBASELINE_FILE, line: num, reason: 'bad_id' });
|
|
689
|
+
// Anonymous for the same reason the malformed arm is (WR-05): the number
|
|
690
|
+
// failed a range rule, so there is no validated number to name, but the
|
|
691
|
+
// rows below are still the author's audit trail and are still checked.
|
|
692
|
+
open = openAnonymousRound(num);
|
|
693
|
+
continue;
|
|
694
|
+
}
|
|
695
|
+
const registered = rounds.get(round);
|
|
696
|
+
if (registered !== undefined) {
|
|
697
|
+
// The round number has passed the grammar, so naming it is safe — and
|
|
698
|
+
// naming it is the only way an author finds the second section.
|
|
699
|
+
violations.push({
|
|
700
|
+
artifact: REBASELINE_FILE,
|
|
701
|
+
line: num,
|
|
702
|
+
round,
|
|
703
|
+
reason: 'duplicate_id',
|
|
704
|
+
});
|
|
705
|
+
// RE-OPEN the round already registered under that number rather than
|
|
706
|
+
// leaving none open (WR-05), so the second section's rows land on the
|
|
707
|
+
// round they CLAIM and are cell-checked normally. Its denominator bullet
|
|
708
|
+
// then reaches the WR-02 duplicate guard as well, which is correct: the
|
|
709
|
+
// round really does carry two denominator bullets.
|
|
710
|
+
open = registered;
|
|
711
|
+
// Rebuilt from the rows already collected, NOT started empty. A phase
|
|
712
|
+
// dispositioned in both halves of a duplicated round IS dispositioned
|
|
713
|
+
// twice inside one round, and a fresh index would hide exactly that —
|
|
714
|
+
// which is the shortcut that would keep this fixture's old violation
|
|
715
|
+
// count and lose the finding.
|
|
716
|
+
seenPhases = new Set(registered.rows.map(r => r.phase).filter(p => p !== ''));
|
|
717
|
+
continue;
|
|
718
|
+
}
|
|
719
|
+
const opened = {
|
|
720
|
+
round,
|
|
721
|
+
anonymous: false,
|
|
722
|
+
line: num,
|
|
723
|
+
date: match[2].trim(),
|
|
724
|
+
label: match[3].trim(),
|
|
725
|
+
accession: null,
|
|
726
|
+
denominator: null,
|
|
727
|
+
denominatorLine: null,
|
|
728
|
+
denominatorClaimLine: null,
|
|
729
|
+
metadataFaultReported: false,
|
|
730
|
+
rows: [],
|
|
731
|
+
};
|
|
732
|
+
open = opened;
|
|
733
|
+
rounds.set(round, opened);
|
|
734
|
+
seqs.push(round);
|
|
735
|
+
continue;
|
|
736
|
+
}
|
|
737
|
+
if (open === null)
|
|
738
|
+
continue;
|
|
739
|
+
// The claim arm comes FIRST, and it continues, so a bullet that claims the
|
|
740
|
+
// denominator family can no longer fall through the accession check, the
|
|
741
|
+
// separator check and the leading-pipe check to be dropped without comment
|
|
742
|
+
// (WR-03). Same shape as the round heading's `ROUND_CLAIM_RE` arm above.
|
|
743
|
+
if (DENOMINATOR_CLAIM_RE.test(line)) {
|
|
744
|
+
const denominator = DENOMINATOR_LINE_RE.exec(line);
|
|
745
|
+
if (denominator === null) {
|
|
746
|
+
// Located by row number ONLY, carrying no echo: a line that claimed a
|
|
747
|
+
// family and failed its grammar has passed no validation, so nothing
|
|
748
|
+
// about it is safe to quote (T-05-01). The round number IS safe when
|
|
749
|
+
// there is one — it passed the heading grammar — and it is how an author
|
|
750
|
+
// finds the round; under an anonymous section (WR-05) there is none, and
|
|
751
|
+
// `roundLocator` omits the field rather than inventing one.
|
|
752
|
+
violations.push({ ...roundLocator(open.round, num), reason: 'bad_id' });
|
|
753
|
+
open.denominatorClaimLine = num;
|
|
754
|
+
continue;
|
|
755
|
+
}
|
|
756
|
+
// A REPEATED bullet is the same duplication the round heading above and
|
|
757
|
+
// the phase cell below already report, and it gets the same policy: the
|
|
758
|
+
// first record keeps the slot and the later one is reported rather than
|
|
759
|
+
// dropped in silence, because it may contradict what is already recorded.
|
|
760
|
+
//
|
|
761
|
+
// The FIRST bullet wins rather than the last, and that direction is the
|
|
762
|
+
// whole point. A reader scanning the artifact sees the first line under
|
|
763
|
+
// the heading and takes it as the denominator; a last-write-wins parser
|
|
764
|
+
// would judge coverage against a line the reader never read. Before WR-02
|
|
765
|
+
// every match overwrote unconditionally, so `- Phases at this round: 02,
|
|
766
|
+
// 03, 04` followed by `- Phases at this round: 02` narrowed the coverage
|
|
767
|
+
// denominator from three phases to one and raised NOTHING anywhere — the
|
|
768
|
+
// fully-silent instance of the no-op this engine exists to close. D-73
|
|
769
|
+
// accepts a hand-edited denominator understating the phase list as a
|
|
770
|
+
// cost, but the accepted cost is ONE line an operator can read and check;
|
|
771
|
+
// this keeps it there rather than extending it to a second line that
|
|
772
|
+
// overrides the first while the first still sits above it.
|
|
773
|
+
//
|
|
774
|
+
// Keyed on "has RECORDED a denominator", not on "has SEEN a bullet". A
|
|
775
|
+
// bullet that already faulted and recorded nothing — the WR-01 all-invalid
|
|
776
|
+
// case, or the WR-03 claimed-and-malformed case — has reported its own
|
|
777
|
+
// condition at its own row; turning the round's only usable bullet into a
|
|
778
|
+
// second fault on top of that would be the derived misreport WR-03
|
|
779
|
+
// removed. Located through `roundLocator`: a named round's number came out
|
|
780
|
+
// of the heading grammar so naming it is safe and is how an author finds
|
|
781
|
+
// the round, while an anonymous section (WR-05) carries no number at all.
|
|
782
|
+
if (open.denominatorLine !== null) {
|
|
783
|
+
violations.push({ ...roundLocator(open.round, num), reason: 'duplicate_id' });
|
|
784
|
+
continue;
|
|
785
|
+
}
|
|
786
|
+
const tokens = splitList(denominator[1]);
|
|
787
|
+
const valid = [];
|
|
788
|
+
for (const token of tokens) {
|
|
789
|
+
if (PHASE_TOKEN_RE.test(token))
|
|
790
|
+
valid.push(token);
|
|
791
|
+
else {
|
|
792
|
+
violations.push({
|
|
793
|
+
...roundLocator(open.round, num),
|
|
794
|
+
reason: 'bad_phase_token',
|
|
795
|
+
field: 'phase',
|
|
796
|
+
});
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
// A bullet that records no USABLE token records no denominator at all, so
|
|
800
|
+
// it stays the D-76 fault rather than becoming a coverage rule that
|
|
801
|
+
// trivially passes — that would be exactly the fail-open the code exists
|
|
802
|
+
// to close. The test is `valid`, not `tokens` (WR-01): keying it on the
|
|
803
|
+
// SPLIT count let an all-invalid bullet leave `denominator === []`, which
|
|
804
|
+
// is non-null, which is the only thing `enforcePhaseCoverage` tests — so
|
|
805
|
+
// the round reported `bad_phase_token`, skipped `missing_round_metadata`,
|
|
806
|
+
// and handed the coverage rule an empty list to iterate.
|
|
807
|
+
//
|
|
808
|
+
// The PARTIAL case is deliberately unchanged: a bullet holding one good
|
|
809
|
+
// token and one bad one still records the good one and still faults the
|
|
810
|
+
// bad one. Refusing the whole bullet there would trade a fail-open for a
|
|
811
|
+
// second, wrong fault on every partially-mistyped round.
|
|
812
|
+
if (valid.length > 0) {
|
|
813
|
+
open.denominator = valid;
|
|
814
|
+
open.denominatorLine = num;
|
|
815
|
+
}
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
const accession = ACCESSION_LINE_RE.exec(line);
|
|
819
|
+
if (accession !== null) {
|
|
820
|
+
open.accession = accession[1].trim();
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
if (SEPARATOR_ROW_RE.test(raw))
|
|
824
|
+
continue;
|
|
825
|
+
if (!line.startsWith('|'))
|
|
826
|
+
continue;
|
|
827
|
+
const cells = splitTableCells(raw);
|
|
828
|
+
if (isHeaderRow(cells))
|
|
829
|
+
continue;
|
|
830
|
+
if (cells.length !== MATRIX_HEADER_CELLS.length) {
|
|
831
|
+
// A pipe line inside a round that is neither header nor separator CLAIMS
|
|
832
|
+
// the matrix family without matching it. Reported, never skipped past.
|
|
833
|
+
violations.push({ ...roundLocator(open.round, num), reason: 'bad_id' });
|
|
834
|
+
continue;
|
|
835
|
+
}
|
|
836
|
+
const at = roundLocator(open.round, num);
|
|
837
|
+
const phaseCell = cells[MATRIX_COL.PHASE];
|
|
838
|
+
let phase = '';
|
|
839
|
+
if (!PHASE_TOKEN_RE.test(phaseCell)) {
|
|
840
|
+
violations.push({ ...at, reason: 'bad_phase_token', field: 'phase' });
|
|
841
|
+
}
|
|
842
|
+
else if (seenPhases.has(phaseCell)) {
|
|
843
|
+
// Echoing the phase is safe: it has passed its grammar. The FIRST row
|
|
844
|
+
// keeps the slot and this one is reported rather than dropped in silence,
|
|
845
|
+
// because its disposition may contradict the one already recorded.
|
|
846
|
+
violations.push({ ...at, phase: sanitizeEcho(phaseCell), reason: 'duplicate_id' });
|
|
847
|
+
phase = phaseCell;
|
|
848
|
+
}
|
|
849
|
+
else {
|
|
850
|
+
seenPhases.add(phaseCell);
|
|
851
|
+
phase = phaseCell;
|
|
852
|
+
}
|
|
853
|
+
const built = cells[MATRIX_COL.BUILT];
|
|
854
|
+
if (!BUILT_VALUES.includes(built)) {
|
|
855
|
+
violations.push({
|
|
856
|
+
...at,
|
|
857
|
+
reason: 'bad_built_status',
|
|
858
|
+
field: 'built',
|
|
859
|
+
cell: sanitizeEcho(built),
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
// D-77a: provenance for the built unit, carrying MILESTONE requirement IDs
|
|
863
|
+
// from a different ID plane. Stored and never inspected again — it matches
|
|
864
|
+
// the same ID shape as the cell beside it, which is exactly why the two are
|
|
865
|
+
// separate columns rather than one.
|
|
866
|
+
const roadmapRequirements = cells[MATRIX_COL.ROADMAP_REQUIREMENTS];
|
|
867
|
+
const discoveryCell = cells[MATRIX_COL.DISCOVERY_REQUIREMENTS];
|
|
868
|
+
const discoveryRequirements = [];
|
|
869
|
+
if (discoveryCell !== NOT_APPLICABLE) {
|
|
870
|
+
const tokens = splitList(discoveryCell);
|
|
871
|
+
if (tokens.length === 0) {
|
|
872
|
+
// An empty cell is not in the grammar: "one or more IDs, or the em
|
|
873
|
+
// dash". No echo — an empty token has passed nothing.
|
|
874
|
+
violations.push({ ...at, reason: 'unknown_requirement_id', field: 'discovery_requirements' });
|
|
875
|
+
}
|
|
876
|
+
for (const token of tokens) {
|
|
877
|
+
if (REQUIREMENT_ID_RE.test(token))
|
|
878
|
+
discoveryRequirements.push(token);
|
|
879
|
+
// Wrong shape and resolves-to-nothing are the SAME code — both mean
|
|
880
|
+
// "this is not a requirement I can find" — but the wrong-shape arm
|
|
881
|
+
// carries no echo, because the token passed no grammar.
|
|
882
|
+
else
|
|
883
|
+
violations.push({ ...at, reason: 'unknown_requirement_id', field: 'discovery_requirements' });
|
|
884
|
+
}
|
|
885
|
+
}
|
|
886
|
+
const clausesCell = cells[MATRIX_COL.CLAUSES];
|
|
887
|
+
const clauses = [];
|
|
888
|
+
if (clausesCell !== NOT_APPLICABLE) {
|
|
889
|
+
const tokens = splitList(clausesCell);
|
|
890
|
+
if (tokens.length === 0) {
|
|
891
|
+
// The em dash is how "no clause bearing on this phase" is spelled, and
|
|
892
|
+
// it is exactly the `lapsed` shape stated in the cell. A blank cell is
|
|
893
|
+
// silence, which is the thing this artifact exists to remove.
|
|
894
|
+
violations.push({ ...at, reason: 'bad_clause_citation', field: 'clauses' });
|
|
895
|
+
}
|
|
896
|
+
for (const token of tokens) {
|
|
897
|
+
if (CLAUSE_CITE_RE.test(token))
|
|
898
|
+
clauses.push(token);
|
|
899
|
+
else
|
|
900
|
+
violations.push({ ...at, reason: 'bad_clause_citation', field: 'clauses' });
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
const dispositionCell = cells[MATRIX_COL.DISPOSITION];
|
|
904
|
+
const dm = DISPOSITION_CELL_RE.exec(dispositionCell);
|
|
905
|
+
let disposition = '';
|
|
906
|
+
let dispositionReason = null;
|
|
907
|
+
if (dm === null) {
|
|
908
|
+
violations.push({ ...at, reason: 'bad_disposition', field: 'disposition' });
|
|
909
|
+
}
|
|
910
|
+
else {
|
|
911
|
+
disposition = dm[1];
|
|
912
|
+
dispositionReason = dm[2] === undefined ? null : dm[2];
|
|
913
|
+
if (!SCOPE_VALUES.includes(disposition)) {
|
|
914
|
+
violations.push({
|
|
915
|
+
...at,
|
|
916
|
+
reason: 'bad_disposition',
|
|
917
|
+
field: 'disposition',
|
|
918
|
+
cell: sanitizeEcho(disposition),
|
|
919
|
+
});
|
|
920
|
+
}
|
|
921
|
+
else if (disposition !== SCOPE_STATES.ACTIVE) {
|
|
922
|
+
if (dispositionReason === null || dispositionReason.trim() === '') {
|
|
923
|
+
// The mechanism that stops an agent proposing a lapse without
|
|
924
|
+
// arguing it (D-71). `disposition` is a validated enum value here.
|
|
925
|
+
violations.push({
|
|
926
|
+
...at,
|
|
927
|
+
reason: 'disposition_without_reason',
|
|
928
|
+
field: 'disposition',
|
|
929
|
+
cell: disposition,
|
|
930
|
+
});
|
|
931
|
+
}
|
|
932
|
+
else if (disposition === SCOPE_STATES.VALIDATE &&
|
|
933
|
+
!CLAUSE_CITE_RE.test(dispositionReason.trim())) {
|
|
934
|
+
// A `validate` must name the clause that changed the expected
|
|
935
|
+
// behaviour; prose there is a citation fault, not a missing reason.
|
|
936
|
+
violations.push({ ...at, reason: 'bad_clause_citation', field: 'disposition' });
|
|
937
|
+
}
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
// Every row is kept, including one whose phase failed: dropping it here
|
|
941
|
+
// would make a second fault on the same row invisible to a later pass.
|
|
942
|
+
const row = {
|
|
943
|
+
round: open.round,
|
|
944
|
+
line: num,
|
|
945
|
+
phase,
|
|
946
|
+
built,
|
|
947
|
+
roadmapRequirements,
|
|
948
|
+
discoveryRequirements,
|
|
949
|
+
clauses,
|
|
950
|
+
disposition,
|
|
951
|
+
dispositionReason,
|
|
952
|
+
};
|
|
953
|
+
open.rows.push(row);
|
|
954
|
+
rows.push(row);
|
|
955
|
+
}
|
|
956
|
+
closeRound();
|
|
957
|
+
// A fence that opened and never closed swallowed every line after it. That is
|
|
958
|
+
// the largest blast radius any structural fault in this artifact has: the
|
|
959
|
+
// remaining round headings, metadata bullets and matrix rows are all skipped,
|
|
960
|
+
// and because an earlier round already set `sawRoundClaim` and the swallowed
|
|
961
|
+
// rounds never reach `seqs`, neither `missing_section` nor `sequence_gap`
|
|
962
|
+
// fires either — the parse returns clean and the gate exits 0 having verified
|
|
963
|
+
// nothing. Reported at the OPENING row, carrying no cell and no round: the
|
|
964
|
+
// fence belongs to the artifact rather than to a section, and nothing inside a
|
|
965
|
+
// block the parser could not delimit has passed any grammar.
|
|
966
|
+
if (inFence) {
|
|
967
|
+
violations.push({
|
|
968
|
+
artifact: REBASELINE_FILE,
|
|
969
|
+
line: fenceOpenLine ?? undefined,
|
|
970
|
+
reason: 'unterminated_fence',
|
|
971
|
+
});
|
|
972
|
+
}
|
|
973
|
+
// A file that exists with no round at all is a bootstrap that never got its
|
|
974
|
+
// rows, not a project with nothing to disposition. Keyed off whether a round
|
|
975
|
+
// was ever CLAIMED, so a malformed heading reports its own `bad_id` without
|
|
976
|
+
// also collecting a spurious `missing_section`.
|
|
977
|
+
//
|
|
978
|
+
// The distinction is THREE-way and not two-way, and each arm is a different
|
|
979
|
+
// statement about what the gate actually verified:
|
|
980
|
+
// - ABSENT is the D-42/D-66 graceful arm and is silent. Most engagements
|
|
981
|
+
// never re-baseline, and reddening on the absence would turn a variation
|
|
982
|
+
// nobody triggered into a red gate for everybody.
|
|
983
|
+
// - PRESENT-AND-EMPTY is `missing_section`. A file that exists with no round
|
|
984
|
+
// at all is a bootstrap that never got its rows, and a zero-byte or
|
|
985
|
+
// whitespace-only file is the purest instance of that description.
|
|
986
|
+
// - PRESENT-AND-UNREADABLE is `artifact_unreadable`, raised by the
|
|
987
|
+
// aggregator, and the parser's non-string early return keeps it standing
|
|
988
|
+
// alone with no derived cascade.
|
|
989
|
+
//
|
|
990
|
+
// There is deliberately NO emptiness exemption here. The `content.trim() !==
|
|
991
|
+
// ''` clause this replaces collapsed the middle arm into the first, and it did
|
|
992
|
+
// so while the aggregator was pushing the file into `artifacts_checked` — so
|
|
993
|
+
// the report claimed the artifact had been checked when nothing in it had
|
|
994
|
+
// been. That is exactly the second, quieter overstatement the unreadable arm
|
|
995
|
+
// one branch over was written to avoid.
|
|
996
|
+
if (!sawRoundClaim) {
|
|
997
|
+
violations.push({ artifact: REBASELINE_FILE, reason: 'missing_section' });
|
|
998
|
+
}
|
|
999
|
+
violations.push(...validateRoundSequence(seqs));
|
|
1000
|
+
return { rounds, rows, violations };
|
|
1001
|
+
}
|
|
1002
|
+
// ─── Cross-artifact resolution ────────────────────────────────────────────────
|
|
1003
|
+
/**
|
|
1004
|
+
* Resolve the cited discovery requirement IDs against the set `_requirements.md`
|
|
1005
|
+
* actually mints (D-77b).
|
|
1006
|
+
*
|
|
1007
|
+
* `knownIds === null` means resolution is SKIPPED — `_requirements.md` is absent
|
|
1008
|
+
* or the owning gate cannot parse it — and skipping returns an empty list rather
|
|
1009
|
+
* than a fault. A citation then passes on shape alone, which is the same
|
|
1010
|
+
* graceful arm the sibling gates take: a project that has not minted a
|
|
1011
|
+
* requirement set yet must not be reddened for citing into one, and a gate that
|
|
1012
|
+
* is already red elsewhere must not be reported twice here.
|
|
1013
|
+
*
|
|
1014
|
+
* ONLY the discovery-requirement column is resolved. The roadmap-provenance cell
|
|
1015
|
+
* beside it (D-77a) holds MILESTONE IDs from a different ID plane and matches the
|
|
1016
|
+
* same `[A-Z][A-Z0-9]*-\d+` shape, so resolving it would fault every
|
|
1017
|
+
* roadmap-pre-filled row with a false `unknown_requirement_id` — which is
|
|
1018
|
+
* precisely why the two planes are two columns rather than one.
|
|
1019
|
+
*
|
|
1020
|
+
* `row.discoveryRequirements` holds only tokens that already passed
|
|
1021
|
+
* `REQUIREMENT_ID_RE`; the wrong-SHAPE arm of this same code was raised by the
|
|
1022
|
+
* parser and carries no echo. Here the ID is safe to name precisely because it
|
|
1023
|
+
* passed its family regex — and naming it is the only way an author finds the
|
|
1024
|
+
* citation among six columns.
|
|
1025
|
+
*/
|
|
1026
|
+
function resolveRequirements(rows, knownIds) {
|
|
1027
|
+
const violations = [];
|
|
1028
|
+
if (knownIds === null)
|
|
1029
|
+
return violations;
|
|
1030
|
+
for (const row of rows) {
|
|
1031
|
+
for (const id of row.discoveryRequirements) {
|
|
1032
|
+
if (!knownIds.has(id)) {
|
|
1033
|
+
// Through the same locator the parser uses, so a row that survived a
|
|
1034
|
+
// faulted heading (WR-05) reaches this rule — it is still a citation
|
|
1035
|
+
// somebody wrote — without the report inventing a round for it.
|
|
1036
|
+
violations.push({
|
|
1037
|
+
...roundLocator(row.round, row.line),
|
|
1038
|
+
reason: 'unknown_requirement_id',
|
|
1039
|
+
field: 'discovery_requirements',
|
|
1040
|
+
id: sanitizeEcho(id),
|
|
1041
|
+
});
|
|
1042
|
+
}
|
|
1043
|
+
}
|
|
1044
|
+
}
|
|
1045
|
+
return violations;
|
|
1046
|
+
}
|
|
1047
|
+
/**
|
|
1048
|
+
* Every phase the LATEST round recorded in its own denominator must have a row
|
|
1049
|
+
* in that round (D-76). A phase with no row is functionality nobody
|
|
1050
|
+
* dispositioned, and silence is the thing this artifact exists to remove.
|
|
1051
|
+
*
|
|
1052
|
+
* THE DENOMINATOR COMES FROM THE ROUND'S OWN RECORDED METADATA. This engine
|
|
1053
|
+
* reads no roadmap — the rebaseline WORKFLOW enumerates the roadmap and writes
|
|
1054
|
+
* the phase list as it stood when the round was taken into the round's metadata
|
|
1055
|
+
* bullet. That is what keeps this engine's dependency footprint identical to its
|
|
1056
|
+
* four siblings (`.planning/discovery/` and nothing else) and what dissolves the
|
|
1057
|
+
* asymmetry that `.planning/discovery/` is hard-coded non-workstream-scoped while
|
|
1058
|
+
* a roadmap path would follow `--ws`. An engine that never opens a roadmap never
|
|
1059
|
+
* has to reconcile the two.
|
|
1060
|
+
*
|
|
1061
|
+
* The LATEST round only, and that is settled rather than pending (D-76): rounds
|
|
1062
|
+
* are independent snapshots, a later round may reverse an earlier verdict, and
|
|
1063
|
+
* there is no monotonicity rule. Re-evaluating an earlier round would make a
|
|
1064
|
+
* roadmap that grew between rounds retroactively redden a round that was closed
|
|
1065
|
+
* correctly at the time it was taken.
|
|
1066
|
+
*
|
|
1067
|
+
* `denominator` is non-null iff at least one token was recorded — an empty
|
|
1068
|
+
* bullet, and a bullet whose every token fails the phase grammar, both leave it
|
|
1069
|
+
* null and still fire `missing_round_metadata` — so there is no empty-list
|
|
1070
|
+
* branch here, and no path by which a hand-edited bullet silently turns this
|
|
1071
|
+
* rule into a no-op.
|
|
1072
|
+
*
|
|
1073
|
+
* That invariant is true BECAUSE of the `valid.length > 0` guard on the
|
|
1074
|
+
* denominator arm of `parseRebaselineMatrix`, not by luck. It was false until
|
|
1075
|
+
* WR-01: the arm keyed on the SPLIT token count, so an all-invalid bullet
|
|
1076
|
+
* recorded `[]` — non-null, and non-null is the only thing the guard below
|
|
1077
|
+
* tests. Anything that widens that arm back to "any token at all" breaks the
|
|
1078
|
+
* contract this comment states, and is a change to the coverage rule rather
|
|
1079
|
+
* than a change to the parser.
|
|
1080
|
+
*
|
|
1081
|
+
* The denominator tokens and the `Phase` cells
|
|
1082
|
+
* share ONE grammar, so coverage is a plain set difference with no normalisation
|
|
1083
|
+
* step to drift.
|
|
1084
|
+
*/
|
|
1085
|
+
function enforcePhaseCoverage(rounds) {
|
|
1086
|
+
const violations = [];
|
|
1087
|
+
let latest = null;
|
|
1088
|
+
for (const round of rounds.values()) {
|
|
1089
|
+
if (latest === null || round.round > latest.round)
|
|
1090
|
+
latest = round;
|
|
1091
|
+
}
|
|
1092
|
+
if (latest === null || latest.denominator === null)
|
|
1093
|
+
return violations;
|
|
1094
|
+
// A `Set`, never an object literal keyed on parsed content (T-05-06).
|
|
1095
|
+
const covered = new Set();
|
|
1096
|
+
for (const row of latest.rows) {
|
|
1097
|
+
if (row.phase !== '')
|
|
1098
|
+
covered.add(row.phase);
|
|
1099
|
+
}
|
|
1100
|
+
for (const token of latest.denominator) {
|
|
1101
|
+
if (covered.has(token))
|
|
1102
|
+
continue;
|
|
1103
|
+
violations.push({
|
|
1104
|
+
artifact: REBASELINE_FILE,
|
|
1105
|
+
line: latest.denominatorLine ?? latest.line,
|
|
1106
|
+
round: latest.round,
|
|
1107
|
+
reason: 'missing_phase_row',
|
|
1108
|
+
phase: sanitizeEcho(token),
|
|
1109
|
+
});
|
|
1110
|
+
}
|
|
1111
|
+
return violations;
|
|
1112
|
+
}
|
|
1113
|
+
// ─── Aggregation ──────────────────────────────────────────────────────────────
|
|
1114
|
+
/**
|
|
1115
|
+
* Aggregate the full re-baselining check for a project root. Collect-all-then-
|
|
1116
|
+
* fail (D-08 carried forward): every fault class runs, all violations are
|
|
1117
|
+
* accumulated, and the result is returned once. This layer NEVER throws.
|
|
1118
|
+
*
|
|
1119
|
+
* Pure aggregation: the parser's within-artifact violations, then the
|
|
1120
|
+
* requirement resolution, then the phase coverage. It adds no rule of its own,
|
|
1121
|
+
* so every fault this gate can report is owned by exactly one function.
|
|
1122
|
+
*/
|
|
1123
|
+
function checkRebaseline(cwd) {
|
|
1124
|
+
const violations = [];
|
|
1125
|
+
const dir = node_path_1.default.join(cwd, DISCOVERY_DIR);
|
|
1126
|
+
let names = [];
|
|
1127
|
+
try {
|
|
1128
|
+
names = node_fs_1.default.readdirSync(dir);
|
|
1129
|
+
}
|
|
1130
|
+
catch {
|
|
1131
|
+
names = []; // no discovery package at all — graceful arm (D-42)
|
|
1132
|
+
}
|
|
1133
|
+
const present = new Set(names);
|
|
1134
|
+
// The `present.has(file)` early return is what discriminates the two
|
|
1135
|
+
// conditions, and it must stay that way: ABSENT is the D-42/D-66 graceful arm
|
|
1136
|
+
// and is silent, while PRESENT-BUT-UNREADABLE (EACCES, EISDIR, a dangling
|
|
1137
|
+
// symlink) is a fault. Re-collapsing them would let the gate exit 0 having
|
|
1138
|
+
// validated nothing at all — a fail-open on a structural verifier.
|
|
1139
|
+
// The caller does not add the file to `artifactsChecked` either: an unreadable
|
|
1140
|
+
// artifact was not checked, and saying it was would be a second, quieter
|
|
1141
|
+
// overstatement.
|
|
1142
|
+
const readArtifact = (file) => {
|
|
1143
|
+
if (!present.has(file))
|
|
1144
|
+
return null;
|
|
1145
|
+
try {
|
|
1146
|
+
return node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), 'utf-8');
|
|
1147
|
+
}
|
|
1148
|
+
catch {
|
|
1149
|
+
violations.push({ artifact: file, reason: 'artifact_unreadable' });
|
|
1150
|
+
return null;
|
|
1151
|
+
}
|
|
1152
|
+
};
|
|
1153
|
+
const artifactsChecked = [];
|
|
1154
|
+
const rebaselineContent = readArtifact(REBASELINE_FILE);
|
|
1155
|
+
// Absent — and ONLY absent, which is why the unreadable fault is tested for
|
|
1156
|
+
// rather than assumed away. A project that has never re-baselined is wholly
|
|
1157
|
+
// unaffected by this gate: most engagements never re-baseline, and a check
|
|
1158
|
+
// that reddened on the absence would turn a variation nobody triggered into a
|
|
1159
|
+
// red gate for everybody (D-42/D-66).
|
|
1160
|
+
if (rebaselineContent === null && violations.length === 0) {
|
|
1161
|
+
return {
|
|
1162
|
+
ok: true,
|
|
1163
|
+
artifacts_checked: artifactsChecked,
|
|
1164
|
+
rounds: 0,
|
|
1165
|
+
latest_round: null,
|
|
1166
|
+
requirements: 'absent',
|
|
1167
|
+
};
|
|
1168
|
+
}
|
|
1169
|
+
if (rebaselineContent !== null)
|
|
1170
|
+
artifactsChecked.push(REBASELINE_FILE);
|
|
1171
|
+
// A null here is the unreadable arm; the parser guards non-string input and
|
|
1172
|
+
// yields an empty result, so the `artifact_unreadable` fault stands alone
|
|
1173
|
+
// rather than collecting a cascade of derived faults about a file nobody read.
|
|
1174
|
+
const parsed = parseRebaselineMatrix(rebaselineContent);
|
|
1175
|
+
violations.push(...parsed.violations);
|
|
1176
|
+
// `_requirements.md` is owned by `discovery rollup`; this gate consumes its
|
|
1177
|
+
// single exported reader and NOTHING else. A grammar fault there is reported
|
|
1178
|
+
// ONCE, by name, pointing at the owning gate — never re-enumerated here.
|
|
1179
|
+
let knownIds = null;
|
|
1180
|
+
let requirements = 'absent';
|
|
1181
|
+
const requirementsContent = readArtifact(REQUIREMENTS_FILE);
|
|
1182
|
+
if (requirementsContent !== null) {
|
|
1183
|
+
artifactsChecked.push(REQUIREMENTS_FILE);
|
|
1184
|
+
const parsedRequirements = rollup.parseRequirementsArtifact(requirementsContent);
|
|
1185
|
+
if (parsedRequirements.ok) {
|
|
1186
|
+
knownIds = new Set(parsedRequirements.rows.map(r => r.id));
|
|
1187
|
+
requirements = 'ok';
|
|
1188
|
+
}
|
|
1189
|
+
else {
|
|
1190
|
+
requirements = 'unparseable';
|
|
1191
|
+
violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
|
|
1192
|
+
}
|
|
1193
|
+
}
|
|
1194
|
+
violations.push(...resolveRequirements(parsed.rows, knownIds));
|
|
1195
|
+
violations.push(...enforcePhaseCoverage(parsed.rounds));
|
|
1196
|
+
// Folded, never spread: `Math.max(...keys)` throws `RangeError` on a large
|
|
1197
|
+
// array, and the round map is keyed off artifact content.
|
|
1198
|
+
let latestRound = null;
|
|
1199
|
+
for (const round of parsed.rounds.keys()) {
|
|
1200
|
+
if (latestRound === null || round > latestRound)
|
|
1201
|
+
latestRound = round;
|
|
1202
|
+
}
|
|
1203
|
+
const summary = {
|
|
1204
|
+
artifacts_checked: artifactsChecked,
|
|
1205
|
+
rounds: parsed.rounds.size,
|
|
1206
|
+
latest_round: latestRound,
|
|
1207
|
+
requirements,
|
|
1208
|
+
};
|
|
1209
|
+
if (violations.length > 0)
|
|
1210
|
+
return { ok: false, violations, ...summary };
|
|
1211
|
+
return { ok: true, ...summary };
|
|
1212
|
+
}
|
|
1213
|
+
// ─── CLI adapter ──────────────────────────────────────────────────────────────
|
|
1214
|
+
/**
|
|
1215
|
+
* One content-free line per violation, two-space indented (T-05-01).
|
|
1216
|
+
*
|
|
1217
|
+
* This is the SINGLE chokepoint at which a detail string is BUILT: every
|
|
1218
|
+
* author-supplied value below routes through `sanitizeEcho`, so a reason code
|
|
1219
|
+
* added to the tuple inherits the confidentiality bound by construction rather
|
|
1220
|
+
* than by the author of that code remembering it. The reason code and the enum
|
|
1221
|
+
* field name are deliberately NOT sanitized — both come from closed unions
|
|
1222
|
+
* declared in this module and are not author data.
|
|
1223
|
+
*
|
|
1224
|
+
* Eight of the fifteen codes emit NO detail at all. That is deliberate and it
|
|
1225
|
+
* is the interesting half: the cell behind each of them has passed no
|
|
1226
|
+
* validation, so there is nothing about it that is safe to quote, and the
|
|
1227
|
+
* `<artifact>:<row>` locator finds the row instead. The `default` arm emits
|
|
1228
|
+
* nothing for the same reason — a new code must opt IN to an echo rather than
|
|
1229
|
+
* inherit an unbounded one by falling through.
|
|
1230
|
+
*/
|
|
1231
|
+
function formatViolation(v) {
|
|
1232
|
+
const where = v.artifact ?? '';
|
|
1233
|
+
const locator = v.line === undefined ? where : `${where}:${v.line}`;
|
|
1234
|
+
let detail = '';
|
|
1235
|
+
switch (v.reason) {
|
|
1236
|
+
case 'unknown_requirement_id':
|
|
1237
|
+
// Populated only on the resolves-to-nothing arm; the wrong-shape arm
|
|
1238
|
+
// leaves it undefined and therefore renders as locator-plus-reason.
|
|
1239
|
+
detail = sanitizeEcho(v.id);
|
|
1240
|
+
break;
|
|
1241
|
+
case 'missing_phase_row':
|
|
1242
|
+
// Both halves have passed their grammars: the round number came out of
|
|
1243
|
+
// `ROUND_HEADING_RE` and the phase token out of `PHASE_TOKEN_RE`.
|
|
1244
|
+
detail = `round ${v.round}: ${sanitizeEcho(v.phase)}`;
|
|
1245
|
+
break;
|
|
1246
|
+
case 'duplicate_id':
|
|
1247
|
+
// A duplicated phase names the phase; a duplicated round heading names the
|
|
1248
|
+
// round. Both are validated tokens.
|
|
1249
|
+
detail = v.phase === undefined ? `round ${v.round}` : sanitizeEcho(v.phase);
|
|
1250
|
+
break;
|
|
1251
|
+
case 'sequence_gap':
|
|
1252
|
+
// The locator names the MISSING round number, so a deleted round cannot
|
|
1253
|
+
// lapse unnoticed (D-41/D-66). Past the shared bound there is no single
|
|
1254
|
+
// missing number to name and the detail is correctly empty.
|
|
1255
|
+
detail = v.round === undefined ? '' : `round ${v.round}`;
|
|
1256
|
+
break;
|
|
1257
|
+
case 'bad_disposition':
|
|
1258
|
+
case 'bad_built_status':
|
|
1259
|
+
// The bounded value token of a CLOSED-ENUM cell: the operator needs to see
|
|
1260
|
+
// which value was rejected, and the alternative — naming the column and
|
|
1261
|
+
// nothing else — makes a typo unfindable in a six-column row.
|
|
1262
|
+
detail = sanitizeEcho(v.cell);
|
|
1263
|
+
break;
|
|
1264
|
+
case 'requirements_unparseable':
|
|
1265
|
+
// Names the owning gate and stops. This gate never re-enumerates that
|
|
1266
|
+
// gate's errors — one artifact, one owner, one report.
|
|
1267
|
+
detail = 'run: discovery rollup --check';
|
|
1268
|
+
break;
|
|
1269
|
+
case 'bad_id':
|
|
1270
|
+
case 'bad_phase_token':
|
|
1271
|
+
case 'bad_clause_citation':
|
|
1272
|
+
case 'disposition_without_reason':
|
|
1273
|
+
case 'missing_round_metadata':
|
|
1274
|
+
case 'missing_section':
|
|
1275
|
+
case 'artifact_unreadable':
|
|
1276
|
+
// The fence line is a LOCATOR and nothing more. Nothing inside or around a
|
|
1277
|
+
// block the parser could not delimit has passed a grammar, so there is
|
|
1278
|
+
// nothing about it that is safe to echo (T-05-01) — and echoing the fence
|
|
1279
|
+
// line itself would quote the first line of a block whose whole content is
|
|
1280
|
+
// unvalidated author text.
|
|
1281
|
+
case 'unterminated_fence':
|
|
1282
|
+
detail = '';
|
|
1283
|
+
break;
|
|
1284
|
+
default:
|
|
1285
|
+
detail = '';
|
|
1286
|
+
}
|
|
1287
|
+
return detail === '' ? ` ${locator}: ${v.reason}` : ` ${locator}: ${v.reason} (${detail})`;
|
|
1288
|
+
}
|
|
1289
|
+
/**
|
|
1290
|
+
* CLI adapter for `pp-tools discovery rebaseline-check` (dotted form
|
|
1291
|
+
* `pp-tools query discovery.rebaseline-check`).
|
|
1292
|
+
*
|
|
1293
|
+
* Exit semantics (D-72/D-73): any structural violation exits non-zero via
|
|
1294
|
+
* `DISCOVERY_REBASELINE_CHECK_FAILED`, naming each violation by artifact, row
|
|
1295
|
+
* and reason. An ABSENT `_rebaseline.md` exits 0 — the graceful arm — while a
|
|
1296
|
+
* present-but-unreadable one is a fault.
|
|
1297
|
+
*
|
|
1298
|
+
* The command accepts NO positional path argument at all: the convention paths
|
|
1299
|
+
* under `.planning/discovery/` resolve against cwd, which ELIMINATES the
|
|
1300
|
+
* traversal surface rather than guarding it (T-05-14). There is no path to
|
|
1301
|
+
* normalise, no symlink to resolve and no allow-list to keep in step with the
|
|
1302
|
+
* filesystem, because there is no caller-supplied path.
|
|
1303
|
+
*/
|
|
1304
|
+
function cmdDiscoveryRebaselineCheck(cwd, args, raw) {
|
|
1305
|
+
// args arrive shaped ['discovery', 'rebaseline-check', ...]; global output and
|
|
1306
|
+
// workstream flags are consumed by the dispatcher before dispatch, so anything
|
|
1307
|
+
// left — a positional path or an unknown flag alike — is a usage error.
|
|
1308
|
+
if (args.slice(2).length > 0) {
|
|
1309
|
+
error('Usage: pp-tools discovery rebaseline-check', REBASELINE_CHECK_USAGE);
|
|
1310
|
+
return; // unreachable — error() exits
|
|
1311
|
+
}
|
|
1312
|
+
const result = checkRebaseline(cwd);
|
|
1313
|
+
if (!result.ok) {
|
|
1314
|
+
const total = result.violations.length;
|
|
1315
|
+
const rendered = result.violations.slice(0, MAX_REPORTED_VIOLATIONS).map(formatViolation);
|
|
1316
|
+
if (total > MAX_REPORTED_VIOLATIONS) {
|
|
1317
|
+
rendered.push(` … and ${total - MAX_REPORTED_VIOLATIONS} more`);
|
|
1318
|
+
}
|
|
1319
|
+
// Emitted on stdout BEFORE the envelope goes to stderr, so the ordering is
|
|
1320
|
+
// deterministic: `output` writes fd 1 synchronously and `error` exits. The
|
|
1321
|
+
// payload is a second output surface and inherits the content-free contract
|
|
1322
|
+
// (T-05-01) by construction: counts, a closed-vocabulary value and artifact
|
|
1323
|
+
// filenames only, never a matrix cell.
|
|
1324
|
+
output({
|
|
1325
|
+
ok: false,
|
|
1326
|
+
violations: total,
|
|
1327
|
+
artifacts_checked: result.artifacts_checked,
|
|
1328
|
+
rounds: result.rounds,
|
|
1329
|
+
latest_round: result.latest_round,
|
|
1330
|
+
requirements: result.requirements,
|
|
1331
|
+
}, raw);
|
|
1332
|
+
error(`discovery rebaseline-check: ${total} violation(s):\n${rendered.join('\n')}`, REBASELINE_CHECK_FAILED);
|
|
1333
|
+
return; // unreachable — error() exits
|
|
1334
|
+
}
|
|
1335
|
+
output({
|
|
1336
|
+
ok: true,
|
|
1337
|
+
artifacts_checked: result.artifacts_checked,
|
|
1338
|
+
rounds: result.rounds,
|
|
1339
|
+
latest_round: result.latest_round,
|
|
1340
|
+
requirements: result.requirements,
|
|
1341
|
+
}, raw);
|
|
1342
|
+
}
|
|
1343
|
+
module.exports = {
|
|
1344
|
+
SCOPE_STATES,
|
|
1345
|
+
BUILT_STATES,
|
|
1346
|
+
VIOLATION_REASONS,
|
|
1347
|
+
MATRIX_HEADER_CELLS,
|
|
1348
|
+
MATRIX_COL,
|
|
1349
|
+
ECHO_MAX_CHARS,
|
|
1350
|
+
// Exported so the tests derive their over-bound payload from the bound under
|
|
1351
|
+
// test rather than from a second copy of the number, and so the value the
|
|
1352
|
+
// reference states by number is pinnable against the engine (WR-03).
|
|
1353
|
+
DENOMINATOR_MAX_CHARS,
|
|
1354
|
+
MAX_REPORTED_VIOLATIONS,
|
|
1355
|
+
DISCOVERY_DIR,
|
|
1356
|
+
REBASELINE_FILE,
|
|
1357
|
+
REQUIREMENTS_FILE,
|
|
1358
|
+
NOT_APPLICABLE,
|
|
1359
|
+
REBASELINE_CHECK_FAILED,
|
|
1360
|
+
REBASELINE_CHECK_USAGE,
|
|
1361
|
+
sanitizeEcho,
|
|
1362
|
+
parseRebaselineMatrix,
|
|
1363
|
+
// Exported so the bounded-span floor can be falsified through THIS engine
|
|
1364
|
+
// rather than only through the shared leaf. The round grammar is two digits,
|
|
1365
|
+
// so the out-of-band arm is unreachable end to end and a test can only reach
|
|
1366
|
+
// it by calling the validator with the numbers directly.
|
|
1367
|
+
validateRoundSequence,
|
|
1368
|
+
resolveRequirements,
|
|
1369
|
+
enforcePhaseCoverage,
|
|
1370
|
+
checkRebaseline,
|
|
1371
|
+
cmdDiscoveryRebaselineCheck,
|
|
1372
|
+
};
|