@pareto4/pp-core 1.11.0 → 1.12.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/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-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 +128 -15
- 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 +213 -33
- package/pp-core/bin/lib/init-interactive.cjs +20 -1
- 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 +24 -0
- 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 +555 -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 +158 -8
- 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 +7 -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 +1 -1
- 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 +1 -1
- package/pp-core/workflows/code-review.md +19 -7
- 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 +9 -2
- 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 +102 -216
- 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 +3 -1
- 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 +1 -1
- 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 +25 -8
- 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 +1 -1
- 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,1995 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Discovery screen-inventory enforcement engine (discovery-v2 Phase 4,
|
|
4
|
+
* TRACE-02 / TRACE-03 / TRACE-04 + GUARD-01, D-49..D-59).
|
|
5
|
+
*
|
|
6
|
+
* Read-only structural validator over the per-engagement requirement-to-screen
|
|
7
|
+
* inventory `.planning/discovery/_screens.md` (D-49), the feedback-round log
|
|
8
|
+
* `.planning/discovery/_feedback.md` (D-56/D-57/D-58), the engine-computed MVP
|
|
9
|
+
* coverage assertion (D-51) and the build-tree instrumentation scan (D-54),
|
|
10
|
+
* aggregated by `checkScreenInventory` behind `pp-tools discovery screen-check`.
|
|
11
|
+
*
|
|
12
|
+
* The aggregator is PURE COMPOSITION: every rule it enforces is a NAMED,
|
|
13
|
+
* EXPORTED function declared below, so no fault class ever waited on the
|
|
14
|
+
* aggregator to become emittable or testable, and the aggregator adds no
|
|
15
|
+
* violation code, grammar rule or resolution of its own.
|
|
16
|
+
*
|
|
17
|
+
* Never-blocks contract (D-43): unprocessed feedback is not a fault — a round
|
|
18
|
+
* whose every Processing cell is `Open` exits 0 and reports those rows under
|
|
19
|
+
* `open_feedback`, so a still-running feedback loop can never block Session 13
|
|
20
|
+
* sign-off. Only STRUCTURAL faults set `ok` false, and the outstanding-work
|
|
21
|
+
* report is emitted on the failure path too.
|
|
22
|
+
*
|
|
23
|
+
* Why the contract lives in code (D-52): `/pp-discovery-to-build` and
|
|
24
|
+
* `/pp-ui-phase` each add a CALL plus a checklist row, not a second copy of the
|
|
25
|
+
* rule. Prose in two workflows drifts; a parser does not.
|
|
26
|
+
*
|
|
27
|
+
* Why the reason codes are a runtime VALUE (D-60): the reference
|
|
28
|
+
* `pp-core/references/discovery-traceability.md` and the sketch emitter are both
|
|
29
|
+
* pinned against `VIOLATION_REASONS` / `INSTRUMENTATION_LITERALS` by test rather
|
|
30
|
+
* than by manual diff. That is what makes the single-home claim mechanical
|
|
31
|
+
* instead of aspirational — the same repair Phase 3 made for the research track.
|
|
32
|
+
*
|
|
33
|
+
* Fail-loud (D-53), one bullet per fault class this plan emits:
|
|
34
|
+
* - missing_section — `## Screens` or `## No screen (still MVP)` absent
|
|
35
|
+
* - bad_id — a screens row whose first cell is not `SCR-\d{2}`, or a no-screen
|
|
36
|
+
* row whose first cell is not a requirement ID. Reported by artifact plus
|
|
37
|
+
* row number ONLY; the cell has passed no validation, so nothing about it is
|
|
38
|
+
* safe to quote (T-04-04)
|
|
39
|
+
* - duplicate_id — a second screens row claiming an ID already seen, or a
|
|
40
|
+
* second no-screen bucket row claiming a requirement the bucket already
|
|
41
|
+
* holds. The first row keeps the slot and the later one is REPORTED rather
|
|
42
|
+
* than dropped: its cell may contradict the one already recorded. Also a
|
|
43
|
+
* REPEATED `## Screens` / `## No screen (still MVP)` heading, which costs
|
|
44
|
+
* the gate a whole table; the heading named is this module's own literal
|
|
45
|
+
* - sequence_gap — the observed screen numbers are not exactly `01..max`; the
|
|
46
|
+
* reported id is the MISSING one, so a deleted row cannot lapse (D-41)
|
|
47
|
+
* - bad_surface_status — a surface cell that is neither the required keyword
|
|
48
|
+
* nor the deferred keyword with a parenthesised reason. A BARE deferred
|
|
49
|
+
* keyword lands here, per D-55: the reason is structured, not prose to be
|
|
50
|
+
* regexed out later
|
|
51
|
+
* - deferred_without_reason — the deferred shape with an empty or
|
|
52
|
+
* whitespace-only reason
|
|
53
|
+
* - no_screen_without_reason — a no-screen bucket row with an empty reason
|
|
54
|
+
* - unknown_requirement_id — a requirement-ID token on a screen that either
|
|
55
|
+
* does not match the family regex (the parser's arm) or matches it and
|
|
56
|
+
* names no row of `_requirements.md` (`resolveRequirementRefs`, guarded on
|
|
57
|
+
* a parseable requirements table). BOTH arms exist: for a long while only
|
|
58
|
+
* the first did, and a code named for resolution that only ever meant
|
|
59
|
+
* "malformed shape" is a trap for the next reader
|
|
60
|
+
* - requirements_unparseable — `_requirements.md` fails its own gate; reported
|
|
61
|
+
* ONCE, naming the owning gate, never by re-enumerating rollup's errors
|
|
62
|
+
* - artifact_unreadable — an artifact the directory listing named but that
|
|
63
|
+
* could not be read. Deliberately distinguished from the graceful absent
|
|
64
|
+
* arm: "could not read it, so it's fine" is a fail-open on a gate whose
|
|
65
|
+
* whole purpose is structural verification
|
|
66
|
+
* - bad_anchor — a feedback Anchor cell that is not `SCR-<nn> · <element>`
|
|
67
|
+
* (D-58). The separator is the one D-27/D-37/D-45 established; there is no
|
|
68
|
+
* second spelling
|
|
69
|
+
* - bad_verdict — a Verdict cell outside the closed four-word D-58 vocabulary
|
|
70
|
+
* - bad_processing — a Processing cell that is neither `Open`, nor
|
|
71
|
+
* `No change`, nor a closed citation of an existing decision / `DG-` / `RS-`
|
|
72
|
+
* ID (D-57). A BARE or prose-only cell lands here rather than in a code of
|
|
73
|
+
* its own: D-57 calls it one structural error, not two
|
|
74
|
+
* - unknown_screen_id — a feedback anchor naming a screen the inventory does
|
|
75
|
+
* not contain. An ABSENT `_screens.md` yields an empty map, so every anchor
|
|
76
|
+
* fails rather than passing vacuously — a feedback log without an inventory
|
|
77
|
+
* is exactly the silent-omission failure GUARD-01 exists to catch
|
|
78
|
+
* - unknown_processing_id — a `DG-`/`RS-` citation that does not resolve
|
|
79
|
+
* against a PRESENT `_risks.md` / `_research.md`
|
|
80
|
+
* - uncovered_mvp_requirement — an `MVP`-disposition requirement that appears
|
|
81
|
+
* on no screen and in no no-screen bucket row (D-51)
|
|
82
|
+
* - screens_markers — the computed-coverage marker pair is missing, doubled or
|
|
83
|
+
* out of order. A structural property of `_screens.md` alone, so it is
|
|
84
|
+
* checked even when `_requirements.md` is not there to compute against
|
|
85
|
+
* - screens_mismatch — the text between the markers differs from the machine
|
|
86
|
+
* render. Hand-writing a coverage claim is the 105-vs-106 drift D-05..D-08
|
|
87
|
+
* exist to kill, and this phase does not reintroduce it
|
|
88
|
+
* - instrumentation_leaked — a build-tree file carrying one of the pinned
|
|
89
|
+
* `INSTRUMENTATION_LITERALS`. GUARD-01 asks for the absence of discovery
|
|
90
|
+
* instrumentation to be ASSERTED, not assumed, so the fault names the
|
|
91
|
+
* repo-relative path, the 1-based line number and the matched literal — and
|
|
92
|
+
* NEVER the surrounding source line. D-54 asks for what was found and where,
|
|
93
|
+
* not for the code (T-04-15). Suppressible per exact path through
|
|
94
|
+
* `_scan-allow.txt`, so a known false positive is a recorded decision
|
|
95
|
+
* rather than a reason to disable the whole gate
|
|
96
|
+
* - scan_budget_exceeded — the walk hit its file-count or byte cap. Reported
|
|
97
|
+
* as a loud fault rather than returned as a partial pass: a bounded scan
|
|
98
|
+
* that truncates quietly is a fail-open, which is the defect class this
|
|
99
|
+
* engine exists to close
|
|
100
|
+
*
|
|
101
|
+
* Deliberately NOT validated — the non-rules, stated here because a parser
|
|
102
|
+
* contract is only honest when it says what it does NOT check:
|
|
103
|
+
* - the Role and Purpose cells of `_screens.md`. Role is a grouping column
|
|
104
|
+
* (D-50) and Purpose is free text; neither has a closed grammar, so neither
|
|
105
|
+
* is checked and neither is ever echoed
|
|
106
|
+
* - the round heading's DATE and LABEL. `ROUND_HEADING_RE` validates the round
|
|
107
|
+
* NUMBER and nothing else; the ISO date and the free-text label are matched
|
|
108
|
+
* for shape only and their content is never inspected
|
|
109
|
+
* - the feedback log's `Feedback` cell. It is verbatim client comment on a
|
|
110
|
+
* prototype — free text by definition, so it is never validated and never
|
|
111
|
+
* echoed
|
|
112
|
+
* - a DUPLICATE Anchor inside one round. This is a deliberate NON-rule, not an
|
|
113
|
+
* oversight: the same element legitimately attracts two comments in a round,
|
|
114
|
+
* and D-57 makes row identity Anchor + round precisely so no ID family has
|
|
115
|
+
* to be minted for feedback rows (D-32). A negative test pins it, so a later
|
|
116
|
+
* change that tightens it fails loudly rather than quietly
|
|
117
|
+
* - a decision-ID citation in a Processing cell, which resolves by SHAPE ONLY
|
|
118
|
+
* and is never looked up — the same posture as the research track's
|
|
119
|
+
* `Closed (D-<ss>-<n>)` evidence rule
|
|
120
|
+
* - the NO-SCREEN BUCKET's requirement cell, which resolves by SHAPE only and
|
|
121
|
+
* is never looked up in `_requirements.md`, unlike a screen's join cell.
|
|
122
|
+
* Stated rather than left implicit, and it is a decision with a reason: a
|
|
123
|
+
* bucket key can only ever make a REAL requirement look covered, and
|
|
124
|
+
* `computeCoverage` walks the requirements table asking `bucketSet.has(id)`,
|
|
125
|
+
* so a key naming no requirement matches no row, reaches no count, and
|
|
126
|
+
* cannot produce a false pass. There is no fail-open here to close
|
|
127
|
+
*
|
|
128
|
+
* Graceful arms (D-42 posture, applied to every artifact this engine reads): no
|
|
129
|
+
* `.planning/discovery/` directory at all, and `_screens.md` simply absent, both
|
|
130
|
+
* check clean. A `DG-`/`RS-` citation whose register artifact is ABSENT passes
|
|
131
|
+
* on shape alone rather than failing for the register's absence. An ABSENT or
|
|
132
|
+
* unparseable `_requirements.md` skips coverage computation entirely — no
|
|
133
|
+
* `uncovered_mvp_requirement`, no `screens_mismatch` — AND skips requirement-ID
|
|
134
|
+
* resolution, so a screen's citations still pass on shape alone: resolving
|
|
135
|
+
* against a table that could not be read would fail every citation on every
|
|
136
|
+
* screen and turn a missing artifact into an inventory full of faults. The
|
|
137
|
+
* `screens_markers` structural check still runs. An artifact that EXISTS but
|
|
138
|
+
* cannot be read is a fault, not an absence, in every one of those arms.
|
|
139
|
+
*
|
|
140
|
+
* The build-tree scan takes that same graceful arm and it is GATED, not merely
|
|
141
|
+
* quiet: `scanBuildTree` runs only when at least one of `DISCOVERY_ARTIFACTS`
|
|
142
|
+
* was actually found under `.planning/discovery/`. A project with no discovery
|
|
143
|
+
* package never walks its own tree at all, so a codebase that uses the
|
|
144
|
+
* `data-req` attribute for its own unrelated purposes is untouched by this gate.
|
|
145
|
+
* That gate is the bound on the one cost D-54 accepted explicitly (T-04-18), and
|
|
146
|
+
* it is a property of the aggregator, so the scan itself stays unconditional and
|
|
147
|
+
* drivable on its own.
|
|
148
|
+
*
|
|
149
|
+
* The gate reads ARTIFACT NAMES, never `present.size`. `present` is every entry
|
|
150
|
+
* the directory listing returned — subdirectories, editor scratch files and the
|
|
151
|
+
* `.DS_Store` macOS writes on mere Finder access — so a size test made the
|
|
152
|
+
* bound "the folder has something in it" rather than "a discovery package
|
|
153
|
+
* exists", and opening a folder was enough to promote an empty one into a
|
|
154
|
+
* whole-repo walk.
|
|
155
|
+
*
|
|
156
|
+
* The one cost D-54 accepted also has a way to DISCHARGE it, which is not the
|
|
157
|
+
* same thing as a bound on it: an optional `_scan-allow.txt` in the discovery
|
|
158
|
+
* package records repo-relative paths whose `instrumentation_leaked` hit is a
|
|
159
|
+
* known false positive. With a hard block at the handoff and no allowlist, a
|
|
160
|
+
* single unavoidable hit made the gate unpassable with no remedy but deleting
|
|
161
|
+
* the file — and an operator with no way to record "this one is fine" does not
|
|
162
|
+
* leave the gate red, they turn the gate off.
|
|
163
|
+
*
|
|
164
|
+
* Content-free contract (T-04-04): output names artifact paths, `<file>:<row>`
|
|
165
|
+
* locators, IDs that have already passed their family regex, field names and
|
|
166
|
+
* snake_case reason codes — and nothing else. Every remaining author-supplied
|
|
167
|
+
* detail is whitespace-collapsed and capped at `ECHO_MAX_CHARS` by
|
|
168
|
+
* `sanitizeEcho`, the single chokepoint inside `formatViolation`. The bound is a
|
|
169
|
+
* confidentiality control, not a formatting preference: screen names, purposes
|
|
170
|
+
* and deferral reasons are derived from client documents (D-24).
|
|
171
|
+
*
|
|
172
|
+
* COLUMN CONTRACT — decided here, in the first plan that parses each artifact,
|
|
173
|
+
* so it is not emergent. `_screens.md` carries two tables plus a computed block:
|
|
174
|
+
* `## Screens` → | ID | Role | Purpose | Surface | Requirements |
|
|
175
|
+
* `## No screen (still MVP)` → | Requirement | Reason |
|
|
176
|
+
* `## Coverage` → the marker pair, machine-owned
|
|
177
|
+
* `_feedback.md` carries one table per round:
|
|
178
|
+
* `## Round <n> — <date> — <label>` → | Anchor | Verdict | Feedback | Processing |
|
|
179
|
+
* The bootstrap template in `references/discovery-traceability.md` and Session
|
|
180
|
+
* 4's instruction block must both match these positions; the positions are
|
|
181
|
+
* exported as named indices below rather than being spelled as bare integers at
|
|
182
|
+
* their use sites.
|
|
183
|
+
*/
|
|
184
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
185
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
186
|
+
};
|
|
187
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
188
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
189
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
190
|
+
const io = require("./io.cjs");
|
|
191
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
192
|
+
const rollup = require("./discovery-rollup.cjs");
|
|
193
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
194
|
+
const table = require("./discovery-table.cjs");
|
|
195
|
+
// The `DG-`/`RS-` register grammar is owned by the research-track gate (D-42).
|
|
196
|
+
// This engine RESOLVES citations against those registers and re-implements none
|
|
197
|
+
// of their validation — a parse fault there is that gate's to report, not this
|
|
198
|
+
// one's to launder into a screens fault.
|
|
199
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
200
|
+
const researchTrack = require("./discovery-research-track.cjs");
|
|
201
|
+
const { splitTableCells, SEPARATOR_ROW_RE, boundedSequenceMax } = table;
|
|
202
|
+
const { output, error, ERROR_REASON } = io;
|
|
203
|
+
// Exported under its own name as well as through `ERROR_REASON`: the gate's
|
|
204
|
+
// identity is cited by the workflow steps that run it and by the reference, so
|
|
205
|
+
// it lives in one place rather than being re-spelled as a string literal.
|
|
206
|
+
const SCREEN_CHECK_FAILED = io.ERROR_REASON.DISCOVERY_SCREEN_CHECK_FAILED;
|
|
207
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
208
|
+
/**
|
|
209
|
+
* The closed set of reason codes this engine can emit, in declaration order.
|
|
210
|
+
*
|
|
211
|
+
* Declared IN FULL here even though this plan only emits ten of them. Later
|
|
212
|
+
* plans in this phase add the emission paths and their tests; the coverage audit
|
|
213
|
+
* asserts every code is emitted by at least one fixture. Declaration ORDER is
|
|
214
|
+
* part of the contract — the tuple is pinned with a `deepEqual`, so a code moved
|
|
215
|
+
* is a code the reference table has to move too.
|
|
216
|
+
*
|
|
217
|
+
* Exported as a runtime VALUE, not merely as a type, for the D-60 reason given
|
|
218
|
+
* in the module comment: `ViolationReason` is derived from the tuple, so a code
|
|
219
|
+
* added to one is a code added to both.
|
|
220
|
+
*/
|
|
221
|
+
const VIOLATION_REASONS = Object.freeze([
|
|
222
|
+
'bad_id',
|
|
223
|
+
'duplicate_id',
|
|
224
|
+
'sequence_gap',
|
|
225
|
+
'missing_section',
|
|
226
|
+
'bad_surface_status',
|
|
227
|
+
'deferred_without_reason',
|
|
228
|
+
'no_screen_without_reason',
|
|
229
|
+
'unknown_requirement_id',
|
|
230
|
+
'uncovered_mvp_requirement',
|
|
231
|
+
'screens_markers',
|
|
232
|
+
'screens_mismatch',
|
|
233
|
+
'bad_anchor',
|
|
234
|
+
'unknown_screen_id',
|
|
235
|
+
'bad_verdict',
|
|
236
|
+
'bad_processing',
|
|
237
|
+
'unknown_processing_id',
|
|
238
|
+
'instrumentation_leaked',
|
|
239
|
+
'scan_budget_exceeded',
|
|
240
|
+
'requirements_unparseable',
|
|
241
|
+
'artifact_unreadable',
|
|
242
|
+
]);
|
|
243
|
+
/**
|
|
244
|
+
* The two literals D-54's build-tree scan looks for, and the two the `/pp-sketch`
|
|
245
|
+
* emitter must produce.
|
|
246
|
+
*
|
|
247
|
+
* Declaring them HERE — in the engine that both consumes and enforces them — is
|
|
248
|
+
* what makes D-60's single-home claim mechanical: the sketch surface and the
|
|
249
|
+
* reference are compared against this tuple by test, so a renamed attribute or
|
|
250
|
+
* toggle id cannot drift into a scan that quietly finds nothing.
|
|
251
|
+
*/
|
|
252
|
+
const INSTRUMENTATION_LITERALS = Object.freeze([
|
|
253
|
+
'data-req=',
|
|
254
|
+
'sketch-req-overlay',
|
|
255
|
+
]);
|
|
256
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
257
|
+
const PLANNING_DIR = '.planning';
|
|
258
|
+
const DISCOVERY_SUBDIR = 'discovery';
|
|
259
|
+
const SCREENS_FILE = '_screens.md';
|
|
260
|
+
const FEEDBACK_FILE = '_feedback.md';
|
|
261
|
+
const REQUIREMENTS_FILE = '_requirements.md';
|
|
262
|
+
// Read-only, for citation resolution only. Their grammar belongs to
|
|
263
|
+
// `discovery research-check` (D-42) and is not re-implemented here.
|
|
264
|
+
const RISKS_FILE = '_risks.md';
|
|
265
|
+
const RESEARCH_FILE = '_research.md';
|
|
266
|
+
/**
|
|
267
|
+
* The optional, auditable suppression list for `instrumentation_leaked`
|
|
268
|
+
* (T-04-18). Repo-relative POSIX paths, one per line; `#` comments and blank
|
|
269
|
+
* lines are ignored, and there are no globs — an exact path is a decision a
|
|
270
|
+
* reviewer can read, a pattern is a hole nobody can size.
|
|
271
|
+
*
|
|
272
|
+
* NOT a discovery artifact this gate validates, and deliberately not a member of
|
|
273
|
+
* `DISCOVERY_ARTIFACTS` below: an allowlist alone must never be what promotes a
|
|
274
|
+
* project into a whole-repo walk. It lives under `.planning/discovery/` because
|
|
275
|
+
* that directory is committed, which is what makes the suppression a recorded
|
|
276
|
+
* decision rather than a local workaround — and because `.planning` is in
|
|
277
|
+
* `SCAN_SKIP_DIRS`, so the list can never allowlist itself into invisibility.
|
|
278
|
+
*/
|
|
279
|
+
const SCAN_ALLOW_FILE = '_scan-allow.txt';
|
|
280
|
+
/**
|
|
281
|
+
* The artifacts whose presence means "this project has a discovery package".
|
|
282
|
+
*
|
|
283
|
+
* The build-tree scan gates on THIS, not on `present.size > 0`. `present` is
|
|
284
|
+
* `new Set(fs.readdirSync(dir))` — every name in the directory, including
|
|
285
|
+
* subdirectories, editor scratch files and `.DS_Store`, which macOS creates on
|
|
286
|
+
* mere Finder access. The documented bound (see the module comment) is "a
|
|
287
|
+
* project with no discovery package never walks its own tree"; the bound as
|
|
288
|
+
* implemented was "a project whose `.planning/discovery/` directory is empty or
|
|
289
|
+
* absent", and a single metadata file silently promoted the one into the other.
|
|
290
|
+
* A gate whose reach depends on whether someone opened a folder is not a bound.
|
|
291
|
+
*/
|
|
292
|
+
const DISCOVERY_ARTIFACTS = Object.freeze([
|
|
293
|
+
SCREENS_FILE, FEEDBACK_FILE, REQUIREMENTS_FILE, RISKS_FILE, RESEARCH_FILE,
|
|
294
|
+
]);
|
|
295
|
+
const SCREENS_HEADING = '## Screens';
|
|
296
|
+
const NO_SCREEN_HEADING = '## No screen (still MVP)';
|
|
297
|
+
const COVERAGE_HEADING = '## Coverage';
|
|
298
|
+
/** D-51/D-06: the computed coverage block is replaced verbatim from command output. */
|
|
299
|
+
const COVERAGE_OPEN = '<!-- screens:auto -->';
|
|
300
|
+
const COVERAGE_CLOSE = '<!-- /screens:auto -->';
|
|
301
|
+
/**
|
|
302
|
+
* The plan for the coverage mechanism names this pair `SCREENS_OPEN` /
|
|
303
|
+
* `SCREENS_CLOSE`, by analogy with rollup's `ROLLUP_OPEN` / `ROLLUP_CLOSE`. The
|
|
304
|
+
* constants already existed under the `COVERAGE_*` spelling, so these are
|
|
305
|
+
* ALIASES of the same two strings rather than a second pair — one marker
|
|
306
|
+
* spelling, two names, and the equality is pinned by test.
|
|
307
|
+
*/
|
|
308
|
+
const SCREENS_OPEN = COVERAGE_OPEN;
|
|
309
|
+
const SCREENS_CLOSE = COVERAGE_CLOSE;
|
|
310
|
+
// Column positions of the three tables (see the COLUMN CONTRACT note in the
|
|
311
|
+
// module comment). Named rather than spelled inline so the reference and the
|
|
312
|
+
// bootstrap template have something concrete to agree with.
|
|
313
|
+
const SCREEN_COL = Object.freeze({ ID: 0, ROLE: 1, PURPOSE: 2, SURFACE: 3, REQUIREMENTS: 4 });
|
|
314
|
+
const NO_SCREEN_COL = Object.freeze({ ID: 0, REASON: 1 });
|
|
315
|
+
const FEEDBACK_COL = Object.freeze({ ANCHOR: 0, VERDICT: 1, FEEDBACK: 2, PROCESSING: 3 });
|
|
316
|
+
// Anchored, quantifier-free, applied to a SINGLE already-split cell — never
|
|
317
|
+
// unanchored across whole-file untrusted content (T-03-02 discipline carried
|
|
318
|
+
// forward as T-04-05). A backtracking pattern here would be a denial-of-service
|
|
319
|
+
// surface, and the cells are client-derived.
|
|
320
|
+
/** D-50: screen IDs are a flat, global, zero-padded 2-digit sequence. */
|
|
321
|
+
const SCR_ID_RE = /^SCR-\d{2}$/;
|
|
322
|
+
/** D-51: the join cell names requirement IDs, whose family shape is fixed by D-01. */
|
|
323
|
+
const REQ_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
324
|
+
/**
|
|
325
|
+
* D-55 surface-status grammar. The cell is EXACTLY the required keyword, or the
|
|
326
|
+
* deferred keyword with a parenthesised reason. Same trick as `Closed
|
|
327
|
+
* (<evidence>)` (D-35) and `Dropped (D-<ss>-<n>)`: the reason is structured, not
|
|
328
|
+
* prose to be regexed out later.
|
|
329
|
+
*/
|
|
330
|
+
const SURFACE_STATUS = Object.freeze({ REQUIRED: 'REQUIRED', DEFERRED: 'DEFERRED' });
|
|
331
|
+
/**
|
|
332
|
+
* The capture is `(.*)` rather than `(.+)` on purpose: an EMPTY parenthesised
|
|
333
|
+
* reason must reach `deferred_without_reason`, which says what is wrong, rather
|
|
334
|
+
* than falling through to `bad_surface_status`, which does not. A bare
|
|
335
|
+
* `DEFERRED` with no parentheses fails this pattern and IS a
|
|
336
|
+
* `bad_surface_status`, per D-55.
|
|
337
|
+
*/
|
|
338
|
+
const DEFERRED_REASON_RE = /^DEFERRED \((.*)\)$/;
|
|
339
|
+
/** The em dash alone is a permitted "no requirements on this row" marker. */
|
|
340
|
+
const EM_DASH = '—';
|
|
341
|
+
/**
|
|
342
|
+
* D-58 verdict vocabulary: four lowercase English WORDS, not emoji — the same
|
|
343
|
+
* posture D-03 and D-34 took for the sibling registers. Declaration order is the
|
|
344
|
+
* contract and is pinned by test.
|
|
345
|
+
*/
|
|
346
|
+
const VERDICTS = Object.freeze(['change', 'keep', 'drop', 'question']);
|
|
347
|
+
/**
|
|
348
|
+
* D-57 Processing grammar. The cell is EXACTLY one of these two literals, or a
|
|
349
|
+
* closed citation of an existing ID. Same trick as `Closed (<evidence>)` (D-35):
|
|
350
|
+
* "nothing came of this" is an explicit closed value, because many rows
|
|
351
|
+
* legitimately produce no decision — a `keep` verdict changes nothing, and a
|
|
352
|
+
* `question` may seed a `DG-`/`RS-` item instead. That is why an EMPTY cell is
|
|
353
|
+
* a fault rather than a shorthand for `No change`.
|
|
354
|
+
*/
|
|
355
|
+
const PROCESSING = Object.freeze({ OPEN: 'Open', NO_CHANGE: 'No change' });
|
|
356
|
+
// D-57: the three citation shapes a Processing cell may take. Anchored,
|
|
357
|
+
// quantifier-free, applied to a SINGLE already-split cell (T-04-13).
|
|
358
|
+
const DECISION_CITE_RE = /^D-\d{2}-\d+$/;
|
|
359
|
+
// D-57: a data-gathering item in `_risks.md`.
|
|
360
|
+
const DG_CITE_RE = /^DG-\d{2}$/;
|
|
361
|
+
// D-57: a research-backlog item in `_research.md`.
|
|
362
|
+
const RS_CITE_RE = /^RS-\d{2}$/;
|
|
363
|
+
/**
|
|
364
|
+
* D-56 round heading. Second-level heading, the word Round, a decimal round
|
|
365
|
+
* number, then an em-dash-separated ISO date and an em-dash-separated free-text
|
|
366
|
+
* label.
|
|
367
|
+
*
|
|
368
|
+
* ONLY the round number is validated. The date and the label are matched for
|
|
369
|
+
* SHAPE and their content is never inspected — stated here so the reference's
|
|
370
|
+
* parser-contract table can say so honestly rather than implying a date check
|
|
371
|
+
* this engine does not perform.
|
|
372
|
+
*
|
|
373
|
+
* The round number is BOUNDED to two digits, like every other discovery ID
|
|
374
|
+
* family (`SCR-\d{2}`, `DG-\d{2}`, `RS-\d{2}`). An unbounded `\d+` let a plain
|
|
375
|
+
* typo — the ISO date written into the round-number slot, `## Round 20250115` —
|
|
376
|
+
* drive `validateRoundSequence` into allocating twenty million gap violations
|
|
377
|
+
* and abort the process on a V8 heap fatal, which is not a JavaScript exception
|
|
378
|
+
* and therefore not something the CLI's try/catch can turn into `{ ok, reason }`.
|
|
379
|
+
*/
|
|
380
|
+
const ROUND_HEADING_RE = /^## Round (\d{1,2}) — [^—]+ — .+$/;
|
|
381
|
+
/** A heading that CLAIMS the round family. A claimed-but-malformed one is a fault. */
|
|
382
|
+
const ROUND_CLAIM_RE = /^## Round\b/;
|
|
383
|
+
/**
|
|
384
|
+
* D-58 anchor grammar: screen ID, the ` · ` separator D-27/D-37/D-45
|
|
385
|
+
* established, then a non-empty element anchor. One spelling of the separator
|
|
386
|
+
* across the whole discovery surface — a second would be a join that silently
|
|
387
|
+
* matches nothing.
|
|
388
|
+
*/
|
|
389
|
+
const ANCHOR_SEP = ' · ';
|
|
390
|
+
const ANCHOR_RE = /^(SCR-\d{2}) · (.+)$/;
|
|
391
|
+
/**
|
|
392
|
+
* The confidentiality bound on every echoed detail. Not a formatting preference:
|
|
393
|
+
* inventory prose is derived from client documents (D-24), so an unbounded echo
|
|
394
|
+
* is a disclosure. Same value as the research-track sibling, deliberately.
|
|
395
|
+
*/
|
|
396
|
+
const ECHO_MAX_CHARS = 24;
|
|
397
|
+
/** A pathological artifact must not produce an unbounded report. */
|
|
398
|
+
const MAX_REPORTED_VIOLATIONS = 50;
|
|
399
|
+
// ─── Parsing ──────────────────────────────────────────────────────────────────
|
|
400
|
+
/**
|
|
401
|
+
* Collect the pipe-table row lines belonging to the section opening at `start`.
|
|
402
|
+
*
|
|
403
|
+
* Modelled on the rollup parser's `sectionRows` but LOCAL to this module and
|
|
404
|
+
* deliberately not shared with it: rollup's stop condition names the rollup
|
|
405
|
+
* marker and is specific to the `_requirements.md` template, and the research
|
|
406
|
+
* track's whole-file `registerRows` treats `## ` as a section START (D-32).
|
|
407
|
+
* Neither is interchangeable with this one, which stops at the next second-level
|
|
408
|
+
* heading or at the coverage open marker. Only the SPLITTER is shared (D-60).
|
|
409
|
+
*
|
|
410
|
+
* The header row is identified POSITIONALLY — it is the row immediately before
|
|
411
|
+
* the table's FIRST separator — not by header text and not by "any row followed
|
|
412
|
+
* by a separator-shaped line". Those last two are not the same rule, and the
|
|
413
|
+
* difference was a silent-omission hole: `SEPARATOR_ROW_RE` matches the
|
|
414
|
+
* extremely common `| --- | --- | --- |` idiom ANYWHERE in the document, so a
|
|
415
|
+
* stray separator (an author appending a second table under one heading, a
|
|
416
|
+
* pasted separator, a `| --- |` placeholder row) deleted the data row above it
|
|
417
|
+
* from the entire gate — its faults were never reported, its ID never
|
|
418
|
+
* registered, and its sequence number never counted, so not even
|
|
419
|
+
* `sequence_gap` noticed. A gate whose stated purpose is that nothing is
|
|
420
|
+
* silently omitted must not have a one-line trick that omits a row.
|
|
421
|
+
*
|
|
422
|
+
* A SECOND separator therefore STOPS the collection rather than swallowing the
|
|
423
|
+
* row above it. Stopping is the conservative reading: the rows that were
|
|
424
|
+
* collected are still validated, and a second table under one heading is
|
|
425
|
+
* outside the template this parser is written against.
|
|
426
|
+
*
|
|
427
|
+
* Split from `screenSectionRows` (which resolves a heading by TEXT) because the
|
|
428
|
+
* feedback log's sections are found by PATTERN — every `## Round …` heading, in
|
|
429
|
+
* order — and two headings can carry the same text. One collector, two ways of
|
|
430
|
+
* locating the section it starts from; the alternative was a second copy of the
|
|
431
|
+
* stop condition, and a stop condition that exists twice drifts.
|
|
432
|
+
*/
|
|
433
|
+
function sectionRowsFrom(lines, start) {
|
|
434
|
+
const rows = [];
|
|
435
|
+
let seenSeparator = false;
|
|
436
|
+
for (let i = start + 1; i < lines.length; i += 1) {
|
|
437
|
+
const line = lines[i];
|
|
438
|
+
const trimmed = line.trim();
|
|
439
|
+
if (trimmed.startsWith('## ') || trimmed === COVERAGE_OPEN)
|
|
440
|
+
break;
|
|
441
|
+
if (!trimmed.includes('|'))
|
|
442
|
+
continue;
|
|
443
|
+
if (SEPARATOR_ROW_RE.test(line)) {
|
|
444
|
+
// The FIRST separator ends the header, so the row before it — and only
|
|
445
|
+
// that row — is the header. A LATER separator starts a new table in the
|
|
446
|
+
// same section: stop, rather than silently swallowing the data row above
|
|
447
|
+
// it as the previous lookahead did.
|
|
448
|
+
if (seenSeparator)
|
|
449
|
+
break;
|
|
450
|
+
seenSeparator = true;
|
|
451
|
+
if (rows.length > 0)
|
|
452
|
+
rows.pop();
|
|
453
|
+
continue;
|
|
454
|
+
}
|
|
455
|
+
rows.push({ line, num: i + 1 });
|
|
456
|
+
}
|
|
457
|
+
return rows;
|
|
458
|
+
}
|
|
459
|
+
/**
|
|
460
|
+
* Locate a section by its exact heading TEXT and collect its rows. Returns null
|
|
461
|
+
* when the heading itself is absent, which the caller turns into
|
|
462
|
+
* `missing_section`.
|
|
463
|
+
*
|
|
464
|
+
* EVERY matching heading is counted, not just the first, and that is the whole
|
|
465
|
+
* reason this returns a record rather than a bare row list. `findIndex` took the
|
|
466
|
+
* first match and `sectionRowsFrom` stops at the next `## ` — which, when the
|
|
467
|
+
* document carries the heading twice, is the SECOND copy of the same heading. So
|
|
468
|
+
* the second table's rows were never collected and never validated: no `bad_id`,
|
|
469
|
+
* no `duplicate_id`, no `bad_surface_status`, no sequence number, and no
|
|
470
|
+
* violation of any kind saying a table had been skipped. One duplicated heading
|
|
471
|
+
* deleted a whole table from a gate whose entire purpose is that nothing is
|
|
472
|
+
* silently omitted — the same silent-omission class as the stray separator that
|
|
473
|
+
* `sectionRowsFrom` above was repaired for, and not one of the module's declared
|
|
474
|
+
* non-rules.
|
|
475
|
+
*
|
|
476
|
+
* The extra headings are REPORTED and their tables are still not read. Stopping
|
|
477
|
+
* at the first section is the conservative reading, exactly as it is for a
|
|
478
|
+
* second separator: a second table under one heading is outside the template
|
|
479
|
+
* this parser is written against, and guessing at a merge would invent sequence
|
|
480
|
+
* and duplicate semantics nobody specified. What changed is that the omission
|
|
481
|
+
* now has a name.
|
|
482
|
+
*/
|
|
483
|
+
function screenSectionRows(lines, heading) {
|
|
484
|
+
const starts = [];
|
|
485
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
486
|
+
if (lines[i].trim() === heading)
|
|
487
|
+
starts.push(i);
|
|
488
|
+
}
|
|
489
|
+
if (starts.length === 0)
|
|
490
|
+
return null;
|
|
491
|
+
return {
|
|
492
|
+
rows: sectionRowsFrom(lines, starts[0]),
|
|
493
|
+
duplicateHeadings: starts.slice(1).map(i => i + 1),
|
|
494
|
+
};
|
|
495
|
+
}
|
|
496
|
+
// `boundedSequenceMax` and its `MAX_SEQUENCE_SPAN` ceiling used to live here,
|
|
497
|
+
// private to this module. They now live in the `discovery-table.cjs` leaf and
|
|
498
|
+
// are shared with `discovery-research-track.cts`, which had been left with a
|
|
499
|
+
// bare `Math.max(...seqs)` when this engine was hardened (04-SECURITY AR-04-02).
|
|
500
|
+
// Being private was also why neutering the ceiling or restoring the spread left
|
|
501
|
+
// the suite green (M4/M5): no test could reach the fold. It is exported and
|
|
502
|
+
// pinned there now.
|
|
503
|
+
/**
|
|
504
|
+
* D-41 sequence integrity. `id` is the MISSING id — that is what makes
|
|
505
|
+
* non-lapse mechanical: a deleted row leaves a hole the parser names, so history
|
|
506
|
+
* is never the enforcement mechanism. An inventory with zero rows has no gaps.
|
|
507
|
+
*/
|
|
508
|
+
function validateScreenSequence(seqs) {
|
|
509
|
+
const violations = [];
|
|
510
|
+
if (seqs.length === 0)
|
|
511
|
+
return violations;
|
|
512
|
+
const seen = new Set(seqs);
|
|
513
|
+
const max = boundedSequenceMax(seqs);
|
|
514
|
+
if (max === null) {
|
|
515
|
+
// Unreachable through `SCR_ID_RE` (two digits, so max is 99) and stated as
|
|
516
|
+
// such: this is the independent floor, not the primary guard. One bounded
|
|
517
|
+
// fault instead of a gap list nobody could read.
|
|
518
|
+
return [{ artifact: SCREENS_FILE, reason: 'bad_id' }];
|
|
519
|
+
}
|
|
520
|
+
for (let n = 1; n <= max; n += 1) {
|
|
521
|
+
if (!seen.has(n)) {
|
|
522
|
+
violations.push({
|
|
523
|
+
artifact: SCREENS_FILE,
|
|
524
|
+
id: `SCR-${String(n).padStart(2, '0')}`,
|
|
525
|
+
reason: 'sequence_gap',
|
|
526
|
+
});
|
|
527
|
+
}
|
|
528
|
+
}
|
|
529
|
+
return violations;
|
|
530
|
+
}
|
|
531
|
+
/**
|
|
532
|
+
* D-55 surface-status validation for one already-split cell.
|
|
533
|
+
*
|
|
534
|
+
* The three arms are exhaustive and ordered so the most specific fault wins:
|
|
535
|
+
* exactly the required keyword is clean; the deferred shape with an empty or
|
|
536
|
+
* whitespace-only reason is `deferred_without_reason`; anything else — including
|
|
537
|
+
* a BARE deferred keyword — is `bad_surface_status`, and only that arm echoes,
|
|
538
|
+
* through the sanitiser.
|
|
539
|
+
*/
|
|
540
|
+
function validateSurfaceStatus(cell, num) {
|
|
541
|
+
const value = cell.trim();
|
|
542
|
+
if (value === SURFACE_STATUS.REQUIRED)
|
|
543
|
+
return [];
|
|
544
|
+
const match = DEFERRED_REASON_RE.exec(value);
|
|
545
|
+
if (match === null) {
|
|
546
|
+
return [{
|
|
547
|
+
artifact: SCREENS_FILE,
|
|
548
|
+
line: num,
|
|
549
|
+
reason: 'bad_surface_status',
|
|
550
|
+
field: 'surface_status',
|
|
551
|
+
cell: value,
|
|
552
|
+
}];
|
|
553
|
+
}
|
|
554
|
+
if (match[1].trim() === '') {
|
|
555
|
+
return [{ artifact: SCREENS_FILE, line: num, reason: 'deferred_without_reason' }];
|
|
556
|
+
}
|
|
557
|
+
return [];
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* D-51 join cell: the requirement IDs a screen serves. An em dash alone is
|
|
561
|
+
* permitted and contributes nothing. Every other cell is split on commas and
|
|
562
|
+
* each token must match the family regex.
|
|
563
|
+
*
|
|
564
|
+
* A token that does not is `unknown_requirement_id` with the TOKEN echoed
|
|
565
|
+
* through the sanitiser — the token, not the surrounding cell, because the
|
|
566
|
+
* bounded echo is what lets an author find the typo without the report carrying
|
|
567
|
+
* the row's prose.
|
|
568
|
+
*
|
|
569
|
+
* RESOLUTION against `_requirements.md` (does this ID exist?) is NOT done here —
|
|
570
|
+
* that needs the other artifact, so the parser stays pure and the rule lives in
|
|
571
|
+
* `resolveRequirementRefs`, which the aggregator calls. It emits the SAME code:
|
|
572
|
+
* a token of the right shape naming nothing and a token of the wrong shape are
|
|
573
|
+
* both "this is not a requirement I can find", and splitting them would only
|
|
574
|
+
* split the fixture set that proves the rule.
|
|
575
|
+
*/
|
|
576
|
+
function parseRequirementCell(cell, num) {
|
|
577
|
+
const ids = [];
|
|
578
|
+
const violations = [];
|
|
579
|
+
const value = cell.trim();
|
|
580
|
+
if (value === '' || value === EM_DASH)
|
|
581
|
+
return { ids, violations };
|
|
582
|
+
for (const raw of value.split(',')) {
|
|
583
|
+
const token = raw.trim();
|
|
584
|
+
if (token === '')
|
|
585
|
+
continue;
|
|
586
|
+
if (!REQ_ID_RE.test(token)) {
|
|
587
|
+
violations.push({
|
|
588
|
+
artifact: SCREENS_FILE,
|
|
589
|
+
line: num,
|
|
590
|
+
reason: 'unknown_requirement_id',
|
|
591
|
+
cell: token,
|
|
592
|
+
});
|
|
593
|
+
continue;
|
|
594
|
+
}
|
|
595
|
+
ids.push(token);
|
|
596
|
+
}
|
|
597
|
+
return { ids, violations };
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* Parse `_screens.md` into the screen inventory and the no-screen bucket.
|
|
601
|
+
*
|
|
602
|
+
* Fail-loud (D-53), never warn-and-skip: every fault class runs before the
|
|
603
|
+
* return, so an author sees the whole picture in one pass rather than peeling
|
|
604
|
+
* one fault at a time.
|
|
605
|
+
*
|
|
606
|
+
* Both collections are `Map`s, never plain object literals: the keys are parsed
|
|
607
|
+
* from untrusted content, and a `__proto__`-shaped key must not be able to write
|
|
608
|
+
* into derived state (T-03-03 discipline carried forward as T-04-06). The screen
|
|
609
|
+
* key has passed `SCR_ID_RE` and the bucket key has passed `REQ_ID_RE`, so
|
|
610
|
+
* neither can actually be `__proto__` today — the `Map` is the structural
|
|
611
|
+
* guarantee that stays true if a regex is ever loosened.
|
|
612
|
+
*/
|
|
613
|
+
function parseScreenInventory(content) {
|
|
614
|
+
const screens = new Map();
|
|
615
|
+
const noScreen = new Map();
|
|
616
|
+
const violations = [];
|
|
617
|
+
if (typeof content !== 'string' || content === '') {
|
|
618
|
+
return { screens, noScreen, violations };
|
|
619
|
+
}
|
|
620
|
+
const lines = content.split(/\r?\n/);
|
|
621
|
+
const screenSection = screenSectionRows(lines, SCREENS_HEADING);
|
|
622
|
+
const bucketSection = screenSectionRows(lines, NO_SCREEN_HEADING);
|
|
623
|
+
// An absent heading is one violation naming the heading's own artifact, and
|
|
624
|
+
// that section contributes no rows. The sibling section is still parsed —
|
|
625
|
+
// reporting only the first missing heading would be warn-and-skip by another
|
|
626
|
+
// name.
|
|
627
|
+
if (screenSection === null) {
|
|
628
|
+
violations.push({ artifact: SCREENS_FILE, reason: 'missing_section', cell: SCREENS_HEADING });
|
|
629
|
+
}
|
|
630
|
+
if (bucketSection === null) {
|
|
631
|
+
violations.push({ artifact: SCREENS_FILE, reason: 'missing_section', cell: NO_SCREEN_HEADING });
|
|
632
|
+
}
|
|
633
|
+
// A REPEATED heading is the mirror of an absent one and is reported for the
|
|
634
|
+
// same reason: it costs the gate a whole table. One violation per extra
|
|
635
|
+
// heading, located by its own source row so the author can find the copy.
|
|
636
|
+
//
|
|
637
|
+
// The heading is carried in `id` rather than in `cell`, and that is safe
|
|
638
|
+
// BECAUSE it is not author data at all: both values are closed literals
|
|
639
|
+
// declared in this module — `SCREENS_HEADING` and `NO_SCREEN_HEADING`, never
|
|
640
|
+
// the text as it was written in the document — so naming one discloses
|
|
641
|
+
// nothing and cannot smuggle prose past the echo bound. It still routes
|
|
642
|
+
// through `sanitizeEcho` at the chokepoint, because the invariant that every
|
|
643
|
+
// rendered detail passes it stays literally true or it is not an invariant.
|
|
644
|
+
for (const [section, heading] of [
|
|
645
|
+
[screenSection, SCREENS_HEADING],
|
|
646
|
+
[bucketSection, NO_SCREEN_HEADING],
|
|
647
|
+
]) {
|
|
648
|
+
for (const num of section?.duplicateHeadings ?? []) {
|
|
649
|
+
violations.push({ artifact: SCREENS_FILE, id: heading, reason: 'duplicate_id', line: num });
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
const seqs = [];
|
|
653
|
+
for (const { line, num } of screenSection?.rows ?? []) {
|
|
654
|
+
const cells = splitTableCells(line);
|
|
655
|
+
const id = cells[SCREEN_COL.ID] ?? '';
|
|
656
|
+
const idOk = SCR_ID_RE.test(id);
|
|
657
|
+
const duplicate = idOk && screens.has(id);
|
|
658
|
+
if (!idOk) {
|
|
659
|
+
// Reported by POSITION, never by content: the first cell has passed no
|
|
660
|
+
// validation at all, so there is nothing about it that is safe to quote.
|
|
661
|
+
violations.push({ artifact: SCREENS_FILE, line: num, reason: 'bad_id' });
|
|
662
|
+
}
|
|
663
|
+
else if (duplicate) {
|
|
664
|
+
// The ID is validated, so naming it is safe — and naming it is the only
|
|
665
|
+
// way an author can find the second row.
|
|
666
|
+
violations.push({ artifact: SCREENS_FILE, id, reason: 'duplicate_id', line: num });
|
|
667
|
+
}
|
|
668
|
+
// The cell validators run REGARDLESS of the ID's fate, and that is the
|
|
669
|
+
// whole point. Both ID arms used to `continue` past this block, so a row
|
|
670
|
+
// with a typo'd ID and a malformed Surface cell reported one fault; the
|
|
671
|
+
// author fixed the ID, re-ran, and only then met the second. That is
|
|
672
|
+
// exactly the peeling-one-fault-at-a-time this parser says it does not do
|
|
673
|
+
// (D-53). Surface status and the requirements join are properties of their
|
|
674
|
+
// OWN cells — neither is derived from the ID, and neither becomes
|
|
675
|
+
// meaningless because the ID is wrong — so there is nothing to defer.
|
|
676
|
+
violations.push(...validateSurfaceStatus(cells[SCREEN_COL.SURFACE] ?? '', num));
|
|
677
|
+
const requirements = parseRequirementCell(cells[SCREEN_COL.REQUIREMENTS] ?? '', num);
|
|
678
|
+
violations.push(...requirements.violations);
|
|
679
|
+
// Only a row with a valid, unseen ID joins the inventory and the sequence.
|
|
680
|
+
// That much is genuinely ID-dependent: a malformed ID has no sequence
|
|
681
|
+
// number to contribute, and a duplicate must not overwrite the row that
|
|
682
|
+
// claimed the slot first. Reporting more is not the same as recording more.
|
|
683
|
+
if (!idOk || duplicate)
|
|
684
|
+
continue;
|
|
685
|
+
seqs.push(Number(id.slice('SCR-'.length)));
|
|
686
|
+
screens.set(id, {
|
|
687
|
+
id,
|
|
688
|
+
seq: Number(id.slice('SCR-'.length)),
|
|
689
|
+
role: cells[SCREEN_COL.ROLE] ?? '',
|
|
690
|
+
purpose: cells[SCREEN_COL.PURPOSE] ?? '',
|
|
691
|
+
surface: (cells[SCREEN_COL.SURFACE] ?? '').trim(),
|
|
692
|
+
requirementIds: requirements.ids,
|
|
693
|
+
line: num,
|
|
694
|
+
});
|
|
695
|
+
}
|
|
696
|
+
violations.push(...validateScreenSequence(seqs));
|
|
697
|
+
for (const { line, num } of bucketSection?.rows ?? []) {
|
|
698
|
+
const cells = splitTableCells(line);
|
|
699
|
+
const id = cells[NO_SCREEN_COL.ID] ?? '';
|
|
700
|
+
if (!REQ_ID_RE.test(id)) {
|
|
701
|
+
violations.push({ artifact: SCREENS_FILE, line: num, reason: 'bad_id' });
|
|
702
|
+
continue;
|
|
703
|
+
}
|
|
704
|
+
// Ordered BEFORE the empty-reason check so the MORE SPECIFIC fault is the
|
|
705
|
+
// first thing an author reads. A second row claiming a requirement the
|
|
706
|
+
// bucket already holds used to be dropped in silence — first row kept,
|
|
707
|
+
// second row's reason (potentially contradicting it) discarded with no
|
|
708
|
+
// violation at all. That was an oversight rather than a decision: the
|
|
709
|
+
// screens table above and the feedback log's rounds both raise
|
|
710
|
+
// `duplicate_id`, and the non-rules block never claimed this one as open.
|
|
711
|
+
const duplicate = noScreen.has(id);
|
|
712
|
+
if (duplicate) {
|
|
713
|
+
// The ID has passed its family regex, so naming it is safe — and naming
|
|
714
|
+
// it is the only way an author can find the second row.
|
|
715
|
+
violations.push({ artifact: SCREENS_FILE, id, reason: 'duplicate_id', line: num });
|
|
716
|
+
}
|
|
717
|
+
const reason = (cells[NO_SCREEN_COL.REASON] ?? '').trim();
|
|
718
|
+
if (reason === '') {
|
|
719
|
+
// The ID has passed its family regex, so naming it is safe; the empty
|
|
720
|
+
// cell is what is being reported, and there is nothing in it to echo.
|
|
721
|
+
// Still checked on a DUPLICATE row, for the same D-53 reason the screens
|
|
722
|
+
// table validates the cells of a duplicate row: the two faults are
|
|
723
|
+
// independent, and reporting only one makes the author run the gate twice.
|
|
724
|
+
violations.push({ artifact: SCREENS_FILE, id, reason: 'no_screen_without_reason', line: num });
|
|
725
|
+
}
|
|
726
|
+
if (!duplicate)
|
|
727
|
+
noScreen.set(id, { id, reason, line: num });
|
|
728
|
+
}
|
|
729
|
+
return { screens, noScreen, violations };
|
|
730
|
+
}
|
|
731
|
+
// ─── Feedback log (TRACE-02, D-56 / D-57 / D-58) ──────────────────────────────
|
|
732
|
+
/**
|
|
733
|
+
* D-41 sequence integrity for feedback rounds. Rows are never deleted (D-56), so
|
|
734
|
+
* a dropped round leaves a hole the parser names. Round numbers are NEVER
|
|
735
|
+
* renumbered: renumbering is what turns a deleted round into a round that never
|
|
736
|
+
* existed, and the round history is the audit trail.
|
|
737
|
+
*/
|
|
738
|
+
function validateRoundSequence(seqs) {
|
|
739
|
+
const violations = [];
|
|
740
|
+
if (seqs.length === 0)
|
|
741
|
+
return violations;
|
|
742
|
+
const seen = new Set(seqs);
|
|
743
|
+
const max = boundedSequenceMax(seqs);
|
|
744
|
+
if (max === null) {
|
|
745
|
+
// Unreachable through `ROUND_HEADING_RE` (two digits, so max is 99). Kept
|
|
746
|
+
// because the grammar is one edit away from being widened again, and the
|
|
747
|
+
// failure mode this floor prevents is a process abort rather than a report.
|
|
748
|
+
return [{ artifact: FEEDBACK_FILE, reason: 'bad_id' }];
|
|
749
|
+
}
|
|
750
|
+
for (let n = 1; n <= max; n += 1) {
|
|
751
|
+
if (!seen.has(n)) {
|
|
752
|
+
violations.push({ artifact: FEEDBACK_FILE, id: `Round ${n}`, reason: 'sequence_gap' });
|
|
753
|
+
}
|
|
754
|
+
}
|
|
755
|
+
return violations;
|
|
756
|
+
}
|
|
757
|
+
/**
|
|
758
|
+
* D-57 Processing-cell grammar for one already-split cell.
|
|
759
|
+
*
|
|
760
|
+
* Clean when the cell is exactly `Open`, exactly `No change`, or a single
|
|
761
|
+
* citation matching one of the three closed shapes. Everything else — including
|
|
762
|
+
* an EMPTY or whitespace-only cell — is one `bad_processing`. D-57 calls a bare
|
|
763
|
+
* or prose-only Processing cell a structural error, singular, so there is no
|
|
764
|
+
* separate empty-cell code: an author who left the cell blank and an author who
|
|
765
|
+
* wrote a sentence in it have made the same mistake, and inventing a second code
|
|
766
|
+
* would only split the fixture set that proves the rule.
|
|
767
|
+
*
|
|
768
|
+
* RESOLUTION of a `DG-`/`RS-` citation against its register is NOT done here —
|
|
769
|
+
* that needs the other artifacts, so it lives in `resolveCrossArtifact` and the
|
|
770
|
+
* parser stays pure.
|
|
771
|
+
*/
|
|
772
|
+
function validateProcessing(cell, num) {
|
|
773
|
+
const value = cell.trim();
|
|
774
|
+
if (value === PROCESSING.OPEN || value === PROCESSING.NO_CHANGE)
|
|
775
|
+
return [];
|
|
776
|
+
if (DECISION_CITE_RE.test(value) || DG_CITE_RE.test(value) || RS_CITE_RE.test(value))
|
|
777
|
+
return [];
|
|
778
|
+
return [{
|
|
779
|
+
artifact: FEEDBACK_FILE,
|
|
780
|
+
line: num,
|
|
781
|
+
reason: 'bad_processing',
|
|
782
|
+
field: 'processing',
|
|
783
|
+
cell: value,
|
|
784
|
+
}];
|
|
785
|
+
}
|
|
786
|
+
/**
|
|
787
|
+
* Parse `_feedback.md` into its rounds and rows.
|
|
788
|
+
*
|
|
789
|
+
* Fail-loud (D-53) and never warn-and-skip, including at the HEADING level: a
|
|
790
|
+
* heading that claims the round family but does not match the grammar is
|
|
791
|
+
* reported and its section skipped, rather than silently ignored. That widening
|
|
792
|
+
* is the same repair 03-08 made to the `_sparring.md` heading grammar — a
|
|
793
|
+
* malformed heading that a parser passes over is a whole section of the audit
|
|
794
|
+
* trail the gate never looked at.
|
|
795
|
+
*
|
|
796
|
+
* The heading fault is `bad_id` located by artifact plus row number ONLY. The
|
|
797
|
+
* heading text has passed no validation, so there is nothing about it that is
|
|
798
|
+
* safe to quote (T-04-10).
|
|
799
|
+
*
|
|
800
|
+
* A heading that never claims the Round family is ignored outright: other
|
|
801
|
+
* second-level sections in the document are not this parser's business.
|
|
802
|
+
*
|
|
803
|
+
* `rounds` is a `Map` keyed by the validated integer round number (T-04-06
|
|
804
|
+
* discipline: derived state is never keyed into a plain object literal).
|
|
805
|
+
*/
|
|
806
|
+
function parseFeedbackLog(content) {
|
|
807
|
+
const rounds = new Map();
|
|
808
|
+
const rows = [];
|
|
809
|
+
const violations = [];
|
|
810
|
+
if (typeof content !== 'string' || content === '')
|
|
811
|
+
return { rounds, rows, violations };
|
|
812
|
+
const lines = content.split(/\r?\n/);
|
|
813
|
+
const seqs = [];
|
|
814
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
815
|
+
const heading = lines[i].trim();
|
|
816
|
+
if (!ROUND_CLAIM_RE.test(heading))
|
|
817
|
+
continue;
|
|
818
|
+
const num = i + 1;
|
|
819
|
+
const match = ROUND_HEADING_RE.exec(heading);
|
|
820
|
+
if (match === null) {
|
|
821
|
+
violations.push({ artifact: FEEDBACK_FILE, line: num, reason: 'bad_id' });
|
|
822
|
+
continue;
|
|
823
|
+
}
|
|
824
|
+
const round = Number(match[1]);
|
|
825
|
+
if (rounds.has(round)) {
|
|
826
|
+
// The round number has passed the grammar, so naming it is safe — and
|
|
827
|
+
// naming it is the only way an author finds the second section.
|
|
828
|
+
violations.push({ artifact: FEEDBACK_FILE, id: `Round ${round}`, reason: 'duplicate_id', line: num });
|
|
829
|
+
continue;
|
|
830
|
+
}
|
|
831
|
+
rounds.set(round, { round, line: num });
|
|
832
|
+
seqs.push(round);
|
|
833
|
+
for (const row of sectionRowsFrom(lines, i)) {
|
|
834
|
+
const cells = splitTableCells(row.line);
|
|
835
|
+
const anchor = (cells[FEEDBACK_COL.ANCHOR] ?? '').trim();
|
|
836
|
+
const anchorMatch = ANCHOR_RE.exec(anchor);
|
|
837
|
+
if (anchorMatch === null) {
|
|
838
|
+
violations.push({
|
|
839
|
+
artifact: FEEDBACK_FILE,
|
|
840
|
+
line: row.num,
|
|
841
|
+
reason: 'bad_anchor',
|
|
842
|
+
field: 'anchor',
|
|
843
|
+
cell: anchor,
|
|
844
|
+
});
|
|
845
|
+
}
|
|
846
|
+
const verdict = (cells[FEEDBACK_COL.VERDICT] ?? '').trim();
|
|
847
|
+
if (!VERDICTS.includes(verdict)) {
|
|
848
|
+
violations.push({
|
|
849
|
+
artifact: FEEDBACK_FILE,
|
|
850
|
+
line: row.num,
|
|
851
|
+
reason: 'bad_verdict',
|
|
852
|
+
field: 'verdict',
|
|
853
|
+
cell: verdict,
|
|
854
|
+
});
|
|
855
|
+
}
|
|
856
|
+
const processing = (cells[FEEDBACK_COL.PROCESSING] ?? '').trim();
|
|
857
|
+
violations.push(...validateProcessing(processing, row.num));
|
|
858
|
+
// Every row is kept, including one whose anchor failed: the cross-artifact
|
|
859
|
+
// resolver skips rows with no validated screen ID, and dropping the row
|
|
860
|
+
// here would make a second fault on the same row invisible.
|
|
861
|
+
rows.push({
|
|
862
|
+
screenId: anchorMatch === null ? '' : anchorMatch[1],
|
|
863
|
+
elementAnchor: anchorMatch === null ? '' : anchorMatch[2],
|
|
864
|
+
verdict,
|
|
865
|
+
feedback: cells[FEEDBACK_COL.FEEDBACK] ?? '',
|
|
866
|
+
processing,
|
|
867
|
+
round,
|
|
868
|
+
line: row.num,
|
|
869
|
+
});
|
|
870
|
+
}
|
|
871
|
+
}
|
|
872
|
+
violations.push(...validateRoundSequence(seqs));
|
|
873
|
+
return { rounds, rows, violations };
|
|
874
|
+
}
|
|
875
|
+
// ─── Artifact loading ─────────────────────────────────────────────────────────
|
|
876
|
+
/**
|
|
877
|
+
* Resolve the discovery package for a project root and hand back a reader.
|
|
878
|
+
*
|
|
879
|
+
* The `present.has(file)` early return is what discriminates the two conditions,
|
|
880
|
+
* and it must stay that way: ABSENT is the graceful arm and is silent, while
|
|
881
|
+
* PRESENT-BUT-UNREADABLE (EACCES, EISDIR, a dangling symlink) is a fault.
|
|
882
|
+
* Re-collapsing them would let the gate exit 0 having validated nothing at all —
|
|
883
|
+
* a fail-open on a structural verifier, and the failure mode GUARD-01 exists to
|
|
884
|
+
* close. An unreadable artifact must not be counted as checked either: saying it
|
|
885
|
+
* was checked would be a second, quieter overstatement.
|
|
886
|
+
*
|
|
887
|
+
* This is a NAMED, EXPORTED function rather than a closure buried inside an
|
|
888
|
+
* aggregator because later plans in this phase compose it — they need a name to
|
|
889
|
+
* call and a test needs a name to drive.
|
|
890
|
+
*/
|
|
891
|
+
function loadDiscoveryArtifacts(cwd) {
|
|
892
|
+
const violations = [];
|
|
893
|
+
const dir = node_path_1.default.join(cwd, PLANNING_DIR, DISCOVERY_SUBDIR);
|
|
894
|
+
let names = [];
|
|
895
|
+
try {
|
|
896
|
+
names = node_fs_1.default.readdirSync(dir);
|
|
897
|
+
}
|
|
898
|
+
catch {
|
|
899
|
+
names = []; // no discovery package at all — graceful arm
|
|
900
|
+
}
|
|
901
|
+
const present = new Set(names);
|
|
902
|
+
const readArtifact = (file) => {
|
|
903
|
+
if (!present.has(file))
|
|
904
|
+
return null;
|
|
905
|
+
try {
|
|
906
|
+
return node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), 'utf-8');
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
violations.push({ artifact: file, reason: 'artifact_unreadable' });
|
|
910
|
+
return null;
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
return { dir, present, readArtifact, violations };
|
|
914
|
+
}
|
|
915
|
+
/**
|
|
916
|
+
* Read `_requirements.md` through the loaded package and hand back its rows.
|
|
917
|
+
*
|
|
918
|
+
* `_requirements.md` is owned by `discovery rollup`; this gate consumes that
|
|
919
|
+
* single exported reader and NOTHING else. A grammar fault there is reported
|
|
920
|
+
* ONCE, by name, pointing at the owning gate — the underlying errors are NOT
|
|
921
|
+
* re-enumerated here, because two gates reporting the same fault in two
|
|
922
|
+
* vocabularies is how an author learns to ignore both.
|
|
923
|
+
*
|
|
924
|
+
* An ABSENT `_requirements.md` is silent, yields no rows, and leaves `status`
|
|
925
|
+
* absent; the requirement-resolution checks that need it are skipped by the
|
|
926
|
+
* aggregator rather than failing. Not having the artifact is not a fault of this
|
|
927
|
+
* gate.
|
|
928
|
+
*/
|
|
929
|
+
function resolveRequirements(loaded) {
|
|
930
|
+
const violations = [];
|
|
931
|
+
const content = loaded.readArtifact(REQUIREMENTS_FILE);
|
|
932
|
+
if (content === null)
|
|
933
|
+
return { rows: [], status: 'absent', violations };
|
|
934
|
+
const parsed = rollup.parseRequirementsArtifact(content);
|
|
935
|
+
if (!parsed.ok) {
|
|
936
|
+
violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
|
|
937
|
+
return { rows: [], status: 'unparseable', violations };
|
|
938
|
+
}
|
|
939
|
+
const rows = parsed.rows.map((r) => ({
|
|
940
|
+
id: r.id,
|
|
941
|
+
disposition: r.disposition,
|
|
942
|
+
}));
|
|
943
|
+
return { rows, status: 'ok', violations };
|
|
944
|
+
}
|
|
945
|
+
// ─── Cross-artifact resolution (D-58 anchors, D-57 citations) ─────────────────
|
|
946
|
+
/**
|
|
947
|
+
* Collect the register IDs of a citation register, or null when the artifact is
|
|
948
|
+
* absent.
|
|
949
|
+
*
|
|
950
|
+
* The read goes through `loaded.readArtifact`, so the absent-versus-unreadable
|
|
951
|
+
* discrimination is INHERITED rather than re-derived — an unreadable register
|
|
952
|
+
* pushes `artifact_unreadable` onto the loader's own violation list and returns
|
|
953
|
+
* null here, which is the correct outcome: this gate must not resolve citations
|
|
954
|
+
* against a register it could not read, and it must not pretend it did.
|
|
955
|
+
*
|
|
956
|
+
* Parsing goes through the research track's exported register parsers. Their
|
|
957
|
+
* violations are DISCARDED on purpose: a grammar fault in `_risks.md` belongs to
|
|
958
|
+
* `discovery research-check` (D-42), and laundering it into a screens fault is
|
|
959
|
+
* the two-gates-two-vocabularies failure `requirements_unparseable` already
|
|
960
|
+
* avoids for `_requirements.md`.
|
|
961
|
+
*/
|
|
962
|
+
function registerIds(loaded, file, parse) {
|
|
963
|
+
const content = loaded.readArtifact(file);
|
|
964
|
+
if (content === null)
|
|
965
|
+
return null;
|
|
966
|
+
return new Set(parse(content).rows.map(r => r.id));
|
|
967
|
+
}
|
|
968
|
+
/**
|
|
969
|
+
* Resolve a parsed feedback log against the screen inventory and the citation
|
|
970
|
+
* registers. Returns a violation array and nothing else.
|
|
971
|
+
*
|
|
972
|
+
* A NAMED, EXPORTED function rather than a step inside `parseFeedbackLog`, for
|
|
973
|
+
* two reasons that pull the same way: the parser stays pure (it is handed a
|
|
974
|
+
* string and reads no filesystem), and both cross-artifact fault classes are
|
|
975
|
+
* emittable and testable in the wave that introduces them rather than waiting on
|
|
976
|
+
* the aggregator. The aggregator CALLS this; it adds no resolution rule of its
|
|
977
|
+
* own.
|
|
978
|
+
*
|
|
979
|
+
* `unknown_screen_id` (D-58, T-04-11): the screen ID left of the ` · ` separator
|
|
980
|
+
* must be a key of the inventory map. When `_feedback.md` is present while
|
|
981
|
+
* `_screens.md` is ABSENT the map is empty and EVERY anchor fails — that is the
|
|
982
|
+
* correct fail-loud reading, not an over-report: a feedback log without an
|
|
983
|
+
* inventory is precisely the silent-omission failure GUARD-01 exists to catch,
|
|
984
|
+
* and a gate that passed it would be verifying nothing.
|
|
985
|
+
*
|
|
986
|
+
* `unknown_processing_id` (D-57): a `DG-`/`RS-` citation resolves against
|
|
987
|
+
* `_risks.md` / `_research.md`. When the register artifact is ABSENT, resolution
|
|
988
|
+
* is SKIPPED and the citation passes on shape alone — the D-42 graceful arm. A
|
|
989
|
+
* decision-ID citation is NEVER looked up and always passes on shape, matching
|
|
990
|
+
* the research track's `Closed (D-<ss>-<n>)` evidence rule. Both of those are
|
|
991
|
+
* "not mechanically checked beyond shape", and the reference says so.
|
|
992
|
+
*
|
|
993
|
+
* Each register is read at most once, whatever the row count.
|
|
994
|
+
*/
|
|
995
|
+
function resolveCrossArtifact(screens, rows, loaded) {
|
|
996
|
+
const violations = [];
|
|
997
|
+
let risks;
|
|
998
|
+
let research;
|
|
999
|
+
for (const row of rows) {
|
|
1000
|
+
// A row whose anchor failed the grammar already carries `bad_anchor`; it has
|
|
1001
|
+
// no validated screen ID, so there is nothing here to resolve.
|
|
1002
|
+
if (row.screenId !== '' && !screens.has(row.screenId)) {
|
|
1003
|
+
violations.push({
|
|
1004
|
+
artifact: FEEDBACK_FILE,
|
|
1005
|
+
line: row.line,
|
|
1006
|
+
reason: 'unknown_screen_id',
|
|
1007
|
+
id: row.screenId,
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
if (DG_CITE_RE.test(row.processing)) {
|
|
1011
|
+
if (risks === undefined)
|
|
1012
|
+
risks = registerIds(loaded, RISKS_FILE, researchTrack.parseRiskRegister);
|
|
1013
|
+
if (risks !== null && !risks.has(row.processing)) {
|
|
1014
|
+
violations.push({
|
|
1015
|
+
artifact: FEEDBACK_FILE,
|
|
1016
|
+
line: row.line,
|
|
1017
|
+
reason: 'unknown_processing_id',
|
|
1018
|
+
id: row.processing,
|
|
1019
|
+
});
|
|
1020
|
+
}
|
|
1021
|
+
}
|
|
1022
|
+
else if (RS_CITE_RE.test(row.processing)) {
|
|
1023
|
+
if (research === undefined) {
|
|
1024
|
+
research = registerIds(loaded, RESEARCH_FILE, researchTrack.parseResearchBacklog);
|
|
1025
|
+
}
|
|
1026
|
+
if (research !== null && !research.has(row.processing)) {
|
|
1027
|
+
violations.push({
|
|
1028
|
+
artifact: FEEDBACK_FILE,
|
|
1029
|
+
line: row.line,
|
|
1030
|
+
reason: 'unknown_processing_id',
|
|
1031
|
+
id: row.processing,
|
|
1032
|
+
});
|
|
1033
|
+
}
|
|
1034
|
+
}
|
|
1035
|
+
}
|
|
1036
|
+
return violations;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Resolve every requirement ID a screen cites against `_requirements.md` (D-51).
|
|
1040
|
+
*
|
|
1041
|
+
* This is the OTHER half of `unknown_requirement_id`, and until it existed the
|
|
1042
|
+
* code did not do what its name said. `parseRequirementCell` checks a token's
|
|
1043
|
+
* SHAPE and nothing else, and two comments in this module used to promise that
|
|
1044
|
+
* resolution was "the aggregator's job (a later plan)". No later plan did it:
|
|
1045
|
+
* `requirementIds` was read in exactly one place — `computeCoverage`, to build
|
|
1046
|
+
* the placed set — and was never compared against the requirements table at all.
|
|
1047
|
+
* A screen citing `REQ-999` where no such requirement existed checked clean, so
|
|
1048
|
+
* a reason code named `unknown_requirement_id` only ever meant "malformed
|
|
1049
|
+
* shape". Coverage compensated partly, and only for MVP rows, by resurfacing the
|
|
1050
|
+
* real requirement as `uncovered_mvp_requirement`; a `Later` or `Dropped`
|
|
1051
|
+
* requirement mistyped on a screen was invisible in every arm. A reason code
|
|
1052
|
+
* that does not do what its name says is a trap for the next reader.
|
|
1053
|
+
*
|
|
1054
|
+
* A NAMED, EXPORTED function rather than a block inside `checkScreenInventory`,
|
|
1055
|
+
* for the reason `resolveCrossArtifact` is one: the aggregator stays pure
|
|
1056
|
+
* composition and adds no resolution of its own, and this rule is drivable and
|
|
1057
|
+
* testable without going through it.
|
|
1058
|
+
*
|
|
1059
|
+
* GUARDED on `status === 'ok'`, which preserves the documented graceful arm
|
|
1060
|
+
* exactly: an ABSENT or UNPARSEABLE `_requirements.md` yields no known set, and
|
|
1061
|
+
* resolving against an empty one would fail every citation on every screen and
|
|
1062
|
+
* turn a missing artifact into an inventory full of faults. `absent` is silent
|
|
1063
|
+
* by design and `unparseable` is already reported once, by name, against the
|
|
1064
|
+
* gate that owns it.
|
|
1065
|
+
*
|
|
1066
|
+
* The NO-SCREEN BUCKET's requirement cell is deliberately NOT resolved here, and
|
|
1067
|
+
* that is a stated non-rule rather than an omission. A bucket key can only ever
|
|
1068
|
+
* make a REAL requirement look covered, and `computeCoverage` iterates the
|
|
1069
|
+
* requirements table and asks `bucketSet.has(row.id)` — so an ID that is in no
|
|
1070
|
+
* requirements table matches no row, contributes to no count, and cannot produce
|
|
1071
|
+
* a false pass of the coverage assertion. There is no fail-open to close.
|
|
1072
|
+
*/
|
|
1073
|
+
function resolveRequirementRefs(screens, requirements) {
|
|
1074
|
+
const violations = [];
|
|
1075
|
+
if (requirements.status !== 'ok')
|
|
1076
|
+
return violations;
|
|
1077
|
+
const known = new Set(requirements.rows.map(r => r.id));
|
|
1078
|
+
for (const row of screens.values()) {
|
|
1079
|
+
for (const id of row.requirementIds) {
|
|
1080
|
+
if (known.has(id))
|
|
1081
|
+
continue;
|
|
1082
|
+
violations.push({
|
|
1083
|
+
artifact: SCREENS_FILE,
|
|
1084
|
+
line: row.line,
|
|
1085
|
+
reason: 'unknown_requirement_id',
|
|
1086
|
+
// The token has already passed `REQ_ID_RE`, so naming it is safe — and
|
|
1087
|
+
// naming it is the only way an author finds which citation is wrong.
|
|
1088
|
+
cell: id,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return violations;
|
|
1093
|
+
}
|
|
1094
|
+
// ─── MVP coverage (TRACE-03, D-51) ────────────────────────────────────────────
|
|
1095
|
+
/** The fixed metric labels of the computed block. A closed vocabulary, like the reason codes. */
|
|
1096
|
+
const COVERAGE_LABELS = Object.freeze({
|
|
1097
|
+
MVP: 'MVP requirements',
|
|
1098
|
+
PLACED: 'Placed on a screen',
|
|
1099
|
+
BUCKETED: 'In the no-screen bucket',
|
|
1100
|
+
UNCOVERED: 'Uncovered',
|
|
1101
|
+
UNCOVERED_IDS: 'Uncovered IDs',
|
|
1102
|
+
});
|
|
1103
|
+
/**
|
|
1104
|
+
* Compute "no requirement without a place" (D-51).
|
|
1105
|
+
*
|
|
1106
|
+
* COMPUTED, never sampled. The motivating failure for this whole phase is a
|
|
1107
|
+
* percentage visual gate that passed over a CURATED baseline list while an
|
|
1108
|
+
* entire prototype surface had been silently dropped: a percentage over a
|
|
1109
|
+
* curated list cannot detect an omission, because the omitted thing is missing
|
|
1110
|
+
* from the denominator too. So the denominator here is `_requirements.md` — the
|
|
1111
|
+
* artifact the author does not get to curate for this check — and the union is
|
|
1112
|
+
* taken FROM the screens inventory INTO it (D-46's join direction; the
|
|
1113
|
+
* requirements grammar is not widened and no Screen column is added to it).
|
|
1114
|
+
*
|
|
1115
|
+
* Only `MVP`-disposition rows participate. `Later` and `Dropped` requirements
|
|
1116
|
+
* need no place, so they appear in no count — a negative test pins that, because
|
|
1117
|
+
* quietly widening the denominator is how this check would start reporting
|
|
1118
|
+
* uncoverable work.
|
|
1119
|
+
*
|
|
1120
|
+
* The placed and bucket sets are `Set`s, never plain objects: the members come
|
|
1121
|
+
* from parsed content (T-04-06).
|
|
1122
|
+
*/
|
|
1123
|
+
function computeCoverage(screens, noScreen, requirementRows) {
|
|
1124
|
+
const placedSet = new Set();
|
|
1125
|
+
for (const row of screens.values()) {
|
|
1126
|
+
for (const id of row.requirementIds)
|
|
1127
|
+
placedSet.add(id);
|
|
1128
|
+
}
|
|
1129
|
+
const bucketSet = new Set(noScreen.keys());
|
|
1130
|
+
const uncovered = [];
|
|
1131
|
+
let mvp = 0;
|
|
1132
|
+
let placed = 0;
|
|
1133
|
+
let bucketed = 0;
|
|
1134
|
+
for (const row of requirementRows) {
|
|
1135
|
+
if (row.disposition !== rollup.DISPOSITIONS.MVP)
|
|
1136
|
+
continue;
|
|
1137
|
+
mvp += 1;
|
|
1138
|
+
const onScreen = placedSet.has(row.id);
|
|
1139
|
+
const inBucket = bucketSet.has(row.id);
|
|
1140
|
+
if (onScreen)
|
|
1141
|
+
placed += 1;
|
|
1142
|
+
if (inBucket)
|
|
1143
|
+
bucketed += 1;
|
|
1144
|
+
if (!onScreen && !inBucket)
|
|
1145
|
+
uncovered.push(row.id);
|
|
1146
|
+
}
|
|
1147
|
+
uncovered.sort();
|
|
1148
|
+
return { mvp, placed, bucketed, uncovered };
|
|
1149
|
+
}
|
|
1150
|
+
/**
|
|
1151
|
+
* Render the machine-owned coverage block (D-51/D-06) — the exact text that
|
|
1152
|
+
* belongs between the markers. The workflow replaces the block with this
|
|
1153
|
+
* VERBATIM, exactly as it does with the rollup totals.
|
|
1154
|
+
*
|
|
1155
|
+
* CONTENT-FREE BY CONSTRUCTION (T-04-09): the output is integers, requirement
|
|
1156
|
+
* IDs that have already passed `REQ_ID_RE`, the fixed labels above, table pipes
|
|
1157
|
+
* and an em dash — and nothing else. No Requirement text cell, no screen name,
|
|
1158
|
+
* no purpose ever reaches it, which is what lets the whole block be carried in a
|
|
1159
|
+
* `screens_mismatch` violation's `expected` field without breaching the
|
|
1160
|
+
* content-free contract. A test asserts the property against a whitelist rather
|
|
1161
|
+
* than trusting this comment.
|
|
1162
|
+
*/
|
|
1163
|
+
function renderCoverageBlock(summary) {
|
|
1164
|
+
return [
|
|
1165
|
+
'| Metric | Count |',
|
|
1166
|
+
'|--------|-------|',
|
|
1167
|
+
`| ${COVERAGE_LABELS.MVP} | ${summary.mvp} |`,
|
|
1168
|
+
`| ${COVERAGE_LABELS.PLACED} | ${summary.placed} |`,
|
|
1169
|
+
`| ${COVERAGE_LABELS.BUCKETED} | ${summary.bucketed} |`,
|
|
1170
|
+
`| ${COVERAGE_LABELS.UNCOVERED} | ${summary.uncovered.length} |`,
|
|
1171
|
+
'',
|
|
1172
|
+
`${COVERAGE_LABELS.UNCOVERED_IDS}: ${summary.uncovered.length > 0 ? summary.uncovered.join(', ') : EM_DASH}`,
|
|
1173
|
+
].join('\n');
|
|
1174
|
+
}
|
|
1175
|
+
/** Whitespace-normalize for block comparison: trim lines, collapse runs, drop blanks. */
|
|
1176
|
+
function normalizeBlock(text) {
|
|
1177
|
+
return text
|
|
1178
|
+
.split(/\r?\n/)
|
|
1179
|
+
.map(l => l.trim().replace(/\s+/g, ' '))
|
|
1180
|
+
.filter(l => l.length > 0)
|
|
1181
|
+
.join('\n');
|
|
1182
|
+
}
|
|
1183
|
+
function countOccurrences(haystack, needle) {
|
|
1184
|
+
return haystack.split(needle).length - 1;
|
|
1185
|
+
}
|
|
1186
|
+
/**
|
|
1187
|
+
* Enforce the coverage assertion against what is actually written in
|
|
1188
|
+
* `_screens.md`. Returns a violation array and nothing else.
|
|
1189
|
+
*
|
|
1190
|
+
* A NAMED, EXPORTED function for the same reason `resolveCrossArtifact` is: all
|
|
1191
|
+
* three of its fault classes are emittable and testable in the wave that
|
|
1192
|
+
* introduces them, and the aggregator CALLS this rather than reimplementing it.
|
|
1193
|
+
*
|
|
1194
|
+
* `requirementsStatus` is passed EXPLICITLY rather than inferred, so the
|
|
1195
|
+
* graceful arm is a decision this function makes rather than one every caller
|
|
1196
|
+
* has to remember to make. Order of the three rules is load-bearing:
|
|
1197
|
+
*
|
|
1198
|
+
* 1. MARKER STRUCTURE, first and unconditionally. Exactly one open marker,
|
|
1199
|
+
* exactly one close marker, close after open. This is a structural property
|
|
1200
|
+
* of `_screens.md` ALONE, so it is checked even when `_requirements.md` is
|
|
1201
|
+
* absent — a document with no computed block is a document whose coverage
|
|
1202
|
+
* claim was never verified, whatever the other artifacts say. (The `## Coverage`
|
|
1203
|
+
* heading itself is deliberately not separately reported: `parseScreenInventory`
|
|
1204
|
+
* does not check it, so there is no second report to collide with.)
|
|
1205
|
+
* 2. UNCOVERED MVP REQUIREMENTS, one violation per ID. The ID has passed
|
|
1206
|
+
* `REQ_ID_RE`, so naming it is safe — and naming it is the whole point.
|
|
1207
|
+
* 3. NORMALIZED DRIFT COMPARISON. A hand-edited block is `screens_mismatch`,
|
|
1208
|
+
* exactly as a hand-edited rollup total is `rollup_mismatch`. This is the
|
|
1209
|
+
* 105-vs-106 drift class D-05..D-08 exist to kill, closed for coverage.
|
|
1210
|
+
*
|
|
1211
|
+
* D-42 GRACEFUL ARM: when `_requirements.md` is ABSENT or `unparseable`, rules 2
|
|
1212
|
+
* and 3 are skipped entirely — there is nothing to compute against, and a gate
|
|
1213
|
+
* that reported drift against an empty computation would be dressing up a
|
|
1214
|
+
* missing sibling artifact to look like a coverage fault. Rule 1 still runs.
|
|
1215
|
+
*/
|
|
1216
|
+
function enforceCoverage(screensContent, summary, requirementsStatus) {
|
|
1217
|
+
const violations = [];
|
|
1218
|
+
const content = typeof screensContent === 'string' ? screensContent : '';
|
|
1219
|
+
const openCount = countOccurrences(content, SCREENS_OPEN);
|
|
1220
|
+
const closeCount = countOccurrences(content, SCREENS_CLOSE);
|
|
1221
|
+
if (openCount !== 1 || closeCount !== 1) {
|
|
1222
|
+
violations.push({
|
|
1223
|
+
artifact: SCREENS_FILE,
|
|
1224
|
+
reason: 'screens_markers',
|
|
1225
|
+
detail: `expected exactly one ${SCREENS_OPEN} and one ${SCREENS_CLOSE} (found ${openCount}/${closeCount})`,
|
|
1226
|
+
});
|
|
1227
|
+
// Without exactly one of each there is no block to compare; a drift report
|
|
1228
|
+
// on top of a marker report would be the same fault in two vocabularies.
|
|
1229
|
+
return violations;
|
|
1230
|
+
}
|
|
1231
|
+
const openIdx = content.indexOf(SCREENS_OPEN);
|
|
1232
|
+
const closeIdx = content.indexOf(SCREENS_CLOSE);
|
|
1233
|
+
if (closeIdx < openIdx) {
|
|
1234
|
+
violations.push({
|
|
1235
|
+
artifact: SCREENS_FILE,
|
|
1236
|
+
reason: 'screens_markers',
|
|
1237
|
+
detail: 'close marker precedes open marker',
|
|
1238
|
+
});
|
|
1239
|
+
return violations;
|
|
1240
|
+
}
|
|
1241
|
+
if (requirementsStatus !== 'ok')
|
|
1242
|
+
return violations;
|
|
1243
|
+
for (const id of summary.uncovered) {
|
|
1244
|
+
violations.push({ artifact: SCREENS_FILE, id, reason: 'uncovered_mvp_requirement' });
|
|
1245
|
+
}
|
|
1246
|
+
const expected = normalizeBlock(renderCoverageBlock(summary));
|
|
1247
|
+
const actual = normalizeBlock(content.slice(openIdx + SCREENS_OPEN.length, closeIdx));
|
|
1248
|
+
if (expected !== actual) {
|
|
1249
|
+
violations.push({
|
|
1250
|
+
artifact: SCREENS_FILE,
|
|
1251
|
+
reason: 'screens_mismatch',
|
|
1252
|
+
expected,
|
|
1253
|
+
// Arbitrary author text between two markers. Bounded HERE, at
|
|
1254
|
+
// construction, so an inspector of the violation object cannot reach an
|
|
1255
|
+
// unbounded echo either (T-04-09).
|
|
1256
|
+
actual: sanitizeEcho(actual),
|
|
1257
|
+
});
|
|
1258
|
+
}
|
|
1259
|
+
return violations;
|
|
1260
|
+
}
|
|
1261
|
+
// ─── Build-tree instrumentation scan (GUARD-01, D-54) ─────────────────────────
|
|
1262
|
+
/**
|
|
1263
|
+
* Directory basenames the walk never enters.
|
|
1264
|
+
*
|
|
1265
|
+
* The OMISSIONS carry as much weight as the entries. A build output directory is
|
|
1266
|
+
* deliberately NOT skipped — a build tree is precisely the thing GUARD-01
|
|
1267
|
+
* asserts about, so skipping `dist`/`build`/`out` would make the whole scan a
|
|
1268
|
+
* ceremony. What is skipped is the three trees whose contents this project did
|
|
1269
|
+
* not author for this engagement: the dependency tree, the git object store, and
|
|
1270
|
+
* `.planning` itself, whose discovery artifacts and templates legitimately spell
|
|
1271
|
+
* the literals out — walking those would report the specification as a leak.
|
|
1272
|
+
*
|
|
1273
|
+
* `Object.freeze` seals the object's own properties; a `Set`'s membership lives
|
|
1274
|
+
* in an internal slot, so this is a declaration of intent backed by the ordinary
|
|
1275
|
+
* no-mutation convention, with the membership pinned by test rather than by the
|
|
1276
|
+
* freeze.
|
|
1277
|
+
*/
|
|
1278
|
+
const SCAN_SKIP_DIRS = Object.freeze(new Set([
|
|
1279
|
+
'node_modules', '.git', PLANNING_DIR,
|
|
1280
|
+
// Caches and vendored trees. NOT build output: these inflate the file count
|
|
1281
|
+
// without ever shipping, and every visited file counts against
|
|
1282
|
+
// `SCAN_MAX_FILES` regardless of extension (the count precedes the
|
|
1283
|
+
// extension filter). A locally-built Next.js tree alone puts thousands of
|
|
1284
|
+
// files into `.next/cache`, none of which a browser will ever honour a
|
|
1285
|
+
// `data-req` attribute from. `.gitignore` is deliberately NOT consulted —
|
|
1286
|
+
// that would make the scan's reach depend on a file the engagement edits.
|
|
1287
|
+
'.next', '.nuxt', '.turbo', '.cache', 'coverage',
|
|
1288
|
+
'.venv', 'venv', '__pycache__', 'vendor', 'target',
|
|
1289
|
+
]));
|
|
1290
|
+
/**
|
|
1291
|
+
* The file extensions the scan actually READS. An allowlist rather than a
|
|
1292
|
+
* denylist, because the bound has to hold for file types nobody has thought of
|
|
1293
|
+
* yet: a lockfile, a source map or a binary asset cannot carry a discovery
|
|
1294
|
+
* annotation that a browser would honour, and reading them is pure cost.
|
|
1295
|
+
*
|
|
1296
|
+
* A file outside this list still counts against the file cap — it was visited,
|
|
1297
|
+
* and pretending otherwise would let a tree of a million images walk forever.
|
|
1298
|
+
*/
|
|
1299
|
+
const SCAN_EXTENSIONS = Object.freeze(new Set([
|
|
1300
|
+
'.html', '.htm',
|
|
1301
|
+
'.js', '.jsx', '.mjs', '.cjs',
|
|
1302
|
+
'.ts', '.tsx',
|
|
1303
|
+
'.vue', '.svelte', '.astro',
|
|
1304
|
+
'.css',
|
|
1305
|
+
]));
|
|
1306
|
+
/**
|
|
1307
|
+
* The two axes of the walk's budget (T-04-14).
|
|
1308
|
+
*
|
|
1309
|
+
* No analog exists in this codebase — no other discovery engine walks outside
|
|
1310
|
+
* `.planning/discovery/` — so both numbers are invented here and are deliberately
|
|
1311
|
+
* explicit rather than emergent. They are generous enough that a real front-end
|
|
1312
|
+
* build tree passes, and finite enough that a pathological or hostile tree
|
|
1313
|
+
* cannot turn a structural gate into an unbounded read.
|
|
1314
|
+
*
|
|
1315
|
+
* SIZED AGAINST A REAL REPOSITORY, not against a fixture. The first numbers
|
|
1316
|
+
* (5,000 files / 20 MB) were guesses, and measured against THIS repository —
|
|
1317
|
+
* counting exactly as `scanBuildTree` counts, with `SCAN_SKIP_DIRS` applied —
|
|
1318
|
+
* they came to 3,022 files and 18,487,659 bytes: 60% of the file cap and 92%
|
|
1319
|
+
* of the byte cap. The repository that ships this engine was within 1.5 MB of
|
|
1320
|
+
* tripping its own gate, and `scan_budget_exceeded` sets `ok` false at the
|
|
1321
|
+
* handoff, which is the workstream's single deliberate HARD BLOCK. An operator
|
|
1322
|
+
* who hit it had no remedy but to delete files. A cap an operator cannot raise
|
|
1323
|
+
* turns an honest truncation into an unpassable gate, so `SCAN_FLOOR` below
|
|
1324
|
+
* pins these against a stated realistic floor and the CLI accepts a bounded
|
|
1325
|
+
* override.
|
|
1326
|
+
*/
|
|
1327
|
+
const SCAN_MAX_FILES = 50000;
|
|
1328
|
+
const SCAN_MAX_BYTES = 200000000;
|
|
1329
|
+
/**
|
|
1330
|
+
* The realistic floor the shipped caps must clear, exported so a test can pin
|
|
1331
|
+
* it. Stated as its own constant rather than as a magic number in an assertion:
|
|
1332
|
+
* the point is that a future edit cannot quietly re-lower the caps back under
|
|
1333
|
+
* what an ordinary repository needs, and a floor nobody can read is a floor
|
|
1334
|
+
* nobody will respect.
|
|
1335
|
+
*/
|
|
1336
|
+
const SCAN_FLOOR = Object.freeze({ files: 20000, bytes: 100000000 });
|
|
1337
|
+
/**
|
|
1338
|
+
* The locator a whole-tree fault is reported against. The repo root has no
|
|
1339
|
+
* filename, and an empty locator would render as a bare `: scan_budget_exceeded`.
|
|
1340
|
+
*/
|
|
1341
|
+
const SCAN_ROOT_LABEL = '.';
|
|
1342
|
+
/**
|
|
1343
|
+
* The ceiling an operator-supplied bound may not exceed, per axis. The override
|
|
1344
|
+
* exists to RAISE a cap that was set too low, never to remove the bound: a walk
|
|
1345
|
+
* with no finite ceiling on either axis is the unbounded read T-04-14 exists to
|
|
1346
|
+
* prevent, and `--max-files 1e21` would be exactly that wearing a number. Both
|
|
1347
|
+
* ceilings sit two orders of magnitude above the shipped defaults, which is
|
|
1348
|
+
* headroom for a repository nobody has built yet and still a hard stop.
|
|
1349
|
+
*/
|
|
1350
|
+
const SCAN_ABSOLUTE_MAX = Object.freeze({ files: 5000000, bytes: 20000000000 });
|
|
1351
|
+
/**
|
|
1352
|
+
* One operator-supplied budget bound, or `null` when it is not a plain positive
|
|
1353
|
+
* integer at or below `ceiling`.
|
|
1354
|
+
*
|
|
1355
|
+
* Deliberately stricter than `Number()`: `parseInt` would accept `50000abc`,
|
|
1356
|
+
* and `Number` accepts `0x10`, `1e9`, `Infinity` and whitespace-padded forms.
|
|
1357
|
+
* An anchored digit run is the whole grammar, so there is nothing to reason
|
|
1358
|
+
* about. Zero is rejected too — a zero cap makes the very first file trip the
|
|
1359
|
+
* budget, which reads as a misunderstanding rather than as an intent.
|
|
1360
|
+
*/
|
|
1361
|
+
function parseScanBound(value, ceiling) {
|
|
1362
|
+
if (typeof value !== 'string' || !/^\d{1,11}$/.test(value))
|
|
1363
|
+
return null;
|
|
1364
|
+
const parsed = Number(value);
|
|
1365
|
+
if (parsed < 1 || parsed > ceiling)
|
|
1366
|
+
return null;
|
|
1367
|
+
return parsed;
|
|
1368
|
+
}
|
|
1369
|
+
/**
|
|
1370
|
+
* The single spelling of the usage line. Declared once because it is emitted
|
|
1371
|
+
* from two arms below and the cross-surface literal audit compares the command
|
|
1372
|
+
* name against the workflows — two copies would be two things to drift.
|
|
1373
|
+
*/
|
|
1374
|
+
const SCREEN_CHECK_USAGE = 'Usage: pp-tools discovery screen-check [--max-files <n>] [--max-bytes <n>] [--require-processed]';
|
|
1375
|
+
/**
|
|
1376
|
+
* The truncation fault. Names WHICH axis was crossed, the count reached and the
|
|
1377
|
+
* cap, because "the scan gave up" without saying where is not actionable.
|
|
1378
|
+
*
|
|
1379
|
+
* `detail` is machine-rendered from this module's own integers and fixed
|
|
1380
|
+
* phrases, never from author content, which is what makes it emittable verbatim.
|
|
1381
|
+
*/
|
|
1382
|
+
function scanBudgetViolation(axis, reached, cap) {
|
|
1383
|
+
return {
|
|
1384
|
+
artifact: SCAN_ROOT_LABEL,
|
|
1385
|
+
reason: 'scan_budget_exceeded',
|
|
1386
|
+
detail: `${axis} cap ${cap} crossed at ${reached}; the scan stopped and nothing beyond that point was checked`,
|
|
1387
|
+
};
|
|
1388
|
+
}
|
|
1389
|
+
/**
|
|
1390
|
+
* The FIRST pinned literal in a file, or null.
|
|
1391
|
+
*
|
|
1392
|
+
* First, not all: one generated bundle can carry thousands of annotations, and a
|
|
1393
|
+
* report that listed every one would bury the other files under a single build
|
|
1394
|
+
* artifact. The per-file cap is one hit; the report's total stays the number of
|
|
1395
|
+
* FILES with hits, which is the number an operator acts on.
|
|
1396
|
+
*
|
|
1397
|
+
* The matched literal is returned so the caller can name it. It is a
|
|
1398
|
+
* closed-vocabulary token from the frozen `INSTRUMENTATION_LITERALS` tuple, so
|
|
1399
|
+
* naming it is safe — unlike the line it was found on, which is application
|
|
1400
|
+
* source and is never carried anywhere (T-04-15).
|
|
1401
|
+
*/
|
|
1402
|
+
function firstInstrumentationHit(content) {
|
|
1403
|
+
const lines = content.split(/\r?\n/);
|
|
1404
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
1405
|
+
for (const literal of INSTRUMENTATION_LITERALS) {
|
|
1406
|
+
if (lines[i].includes(literal))
|
|
1407
|
+
return { line: i + 1, literal };
|
|
1408
|
+
}
|
|
1409
|
+
}
|
|
1410
|
+
return null;
|
|
1411
|
+
}
|
|
1412
|
+
/** Repo-relative, POSIX-separated, so a report reads the same on every platform. */
|
|
1413
|
+
function relativePosix(root, full) {
|
|
1414
|
+
return node_path_1.default.relative(root, full).split(node_path_1.default.sep).join('/');
|
|
1415
|
+
}
|
|
1416
|
+
/**
|
|
1417
|
+
* Read the optional `_scan-allow.txt` and hand back the repo-relative paths a
|
|
1418
|
+
* known `instrumentation_leaked` false positive has been recorded against.
|
|
1419
|
+
*
|
|
1420
|
+
* WHY THIS EXISTS. D-54 accepted a false-positive cost explicitly, but accepting
|
|
1421
|
+
* a cost is not the same as providing a way to discharge it. With no suppression
|
|
1422
|
+
* mechanism and a hard block at the handoff, ONE unavoidable hit made the gate
|
|
1423
|
+
* unpassable with no remedy but deleting the file — and the hits are not
|
|
1424
|
+
* hypothetical: the repository that ships this engine matches `data-req=` in its
|
|
1425
|
+
* own compiled output and in its own test fixtures, and in a consumer repo the
|
|
1426
|
+
* equivalents are documentation, a config file or a test that merely mentions
|
|
1427
|
+
* the attribute. An operator with no way to record "this one is fine" does not
|
|
1428
|
+
* leave the gate red; they turn the gate off, which costs every other finding.
|
|
1429
|
+
*
|
|
1430
|
+
* WHY IT IS SHAPED THIS WAY. Exact repo-relative paths only, no globs: an exact
|
|
1431
|
+
* path is a decision a reviewer can read and a pattern is a hole nobody can
|
|
1432
|
+
* size. `#` comments are supported precisely so the WHY can sit next to the
|
|
1433
|
+
* path. Split on `/\r?\n/` rather than on `'\n'`, so a list edited on Windows
|
|
1434
|
+
* does not leave a trailing carriage return welded to every path and silently
|
|
1435
|
+
* match nothing.
|
|
1436
|
+
*
|
|
1437
|
+
* The read goes through `loaded.readArtifact`, so the absent-versus-unreadable
|
|
1438
|
+
* discrimination is INHERITED: an absent list is the ordinary case and is
|
|
1439
|
+
* silent, while a list that exists and cannot be read is `artifact_unreadable`
|
|
1440
|
+
* and suppresses nothing. Failing loud with everything reported is the correct
|
|
1441
|
+
* direction for an unreadable SUPPRESSION file.
|
|
1442
|
+
*
|
|
1443
|
+
* A STALE entry — a path that is allowlisted but no longer matches anything — is
|
|
1444
|
+
* a deliberate non-rule. Reporting it would need a reason code of its own, and
|
|
1445
|
+
* an unused allowlist entry cannot cause the gate to miss a leak; it is untidy,
|
|
1446
|
+
* not unsound.
|
|
1447
|
+
*/
|
|
1448
|
+
function loadScanAllowlist(loaded) {
|
|
1449
|
+
const content = loaded.readArtifact(SCAN_ALLOW_FILE);
|
|
1450
|
+
if (content === null)
|
|
1451
|
+
return new Set();
|
|
1452
|
+
const allowed = new Set();
|
|
1453
|
+
for (const raw of content.split(/\r?\n/)) {
|
|
1454
|
+
const line = raw.trim();
|
|
1455
|
+
if (line === '' || line.startsWith('#'))
|
|
1456
|
+
continue;
|
|
1457
|
+
allowed.add(line);
|
|
1458
|
+
}
|
|
1459
|
+
return allowed;
|
|
1460
|
+
}
|
|
1461
|
+
/**
|
|
1462
|
+
* Assert that no discovery instrumentation survives into the build tree (D-54,
|
|
1463
|
+
* GUARD-01). Returns a violation array and nothing else.
|
|
1464
|
+
*
|
|
1465
|
+
* GUARD-01 asks for the absence to be ASSERTED rather than assumed, and D-54
|
|
1466
|
+
* makes that a deterministic scan for two pinned literals — no Playwright, no
|
|
1467
|
+
* generated per-project code, consistent with the ROADMAP fence.
|
|
1468
|
+
*
|
|
1469
|
+
* Every bound is explicit:
|
|
1470
|
+
* - the walk is ITERATIVE over a stack, never recursive, so tree depth cannot
|
|
1471
|
+
* exhaust the call stack;
|
|
1472
|
+
* - entries are read with `lstatSync` and a SYMLINK is skipped whatever it
|
|
1473
|
+
* points at, which bounds the walk and removes the directory-cycle class
|
|
1474
|
+
* outright rather than defending against it with a visited-set;
|
|
1475
|
+
* - every entry is resolved and confirmed strictly beneath the resolved
|
|
1476
|
+
* project root before any stat or read, the same posture the roll-up
|
|
1477
|
+
* traversal guard takes (T-04-16);
|
|
1478
|
+
* - `SCAN_SKIP_DIRS` keeps the walk out of the dependency tree;
|
|
1479
|
+
* - `SCAN_EXTENSIONS` bounds what is read;
|
|
1480
|
+
* - the file and byte caps stop the walk.
|
|
1481
|
+
*
|
|
1482
|
+
* ON CROSSING A CAP the return is the budget violation ALONE. Whatever hits had
|
|
1483
|
+
* accumulated are dropped, deliberately: a truncated scan has not established
|
|
1484
|
+
* anything about the tree, and a result that mixed real findings into a partial
|
|
1485
|
+
* pass would invite exactly the reading "it found two, so it looked" — which is
|
|
1486
|
+
* the silent fail-open this phase exists to close. One loud fault that says the
|
|
1487
|
+
* scan did not complete is the honest report, and the gate exits non-zero on it
|
|
1488
|
+
* either way.
|
|
1489
|
+
*
|
|
1490
|
+
* GATING is the CALLER's job and is documented in the module comment: this
|
|
1491
|
+
* function is unconditional so it stays drivable on its own, and the aggregator
|
|
1492
|
+
* runs it only when a discovery artifact was found.
|
|
1493
|
+
*/
|
|
1494
|
+
function scanBuildTree(cwd, options = {}) {
|
|
1495
|
+
const maxFiles = options.maxFiles ?? SCAN_MAX_FILES;
|
|
1496
|
+
const maxBytes = options.maxBytes ?? SCAN_MAX_BYTES;
|
|
1497
|
+
const allow = options.allow ?? new Set();
|
|
1498
|
+
const violations = [];
|
|
1499
|
+
const root = node_path_1.default.resolve(cwd);
|
|
1500
|
+
const stack = [root];
|
|
1501
|
+
let files = 0;
|
|
1502
|
+
let bytes = 0;
|
|
1503
|
+
while (stack.length > 0) {
|
|
1504
|
+
const dir = stack.pop();
|
|
1505
|
+
if (dir === undefined)
|
|
1506
|
+
break;
|
|
1507
|
+
let names;
|
|
1508
|
+
try {
|
|
1509
|
+
names = node_fs_1.default.readdirSync(dir);
|
|
1510
|
+
}
|
|
1511
|
+
catch {
|
|
1512
|
+
// A directory that cannot be listed bounds the walk here. It is not a
|
|
1513
|
+
// discovery artifact, so it is not `artifact_unreadable`: that code names
|
|
1514
|
+
// a file the gate was asked to validate and could not, and inventing a
|
|
1515
|
+
// structural fault out of an unreadable build directory would report the
|
|
1516
|
+
// operator's filesystem permissions as a traceability failure.
|
|
1517
|
+
continue;
|
|
1518
|
+
}
|
|
1519
|
+
// Sorted so the walk is deterministic. Which file a CAPPED scan stops at
|
|
1520
|
+
// must not depend on filesystem enumeration order, or the same tree would
|
|
1521
|
+
// produce different reports on different machines.
|
|
1522
|
+
names.sort();
|
|
1523
|
+
for (const name of names) {
|
|
1524
|
+
const full = node_path_1.default.resolve(dir, name);
|
|
1525
|
+
// Containment guard (T-04-16): refuse anything that does not resolve
|
|
1526
|
+
// strictly beneath the project root, BEFORE any stat or read.
|
|
1527
|
+
if (!full.startsWith(root + node_path_1.default.sep))
|
|
1528
|
+
continue;
|
|
1529
|
+
let stat;
|
|
1530
|
+
try {
|
|
1531
|
+
stat = node_fs_1.default.lstatSync(full);
|
|
1532
|
+
}
|
|
1533
|
+
catch {
|
|
1534
|
+
continue;
|
|
1535
|
+
}
|
|
1536
|
+
if (stat.isSymbolicLink())
|
|
1537
|
+
continue;
|
|
1538
|
+
if (stat.isDirectory()) {
|
|
1539
|
+
if (!SCAN_SKIP_DIRS.has(name))
|
|
1540
|
+
stack.push(full);
|
|
1541
|
+
continue;
|
|
1542
|
+
}
|
|
1543
|
+
// Sockets, FIFOs and devices are neither walked nor counted: they are not
|
|
1544
|
+
// files a build emits, and reading one can block.
|
|
1545
|
+
if (!stat.isFile())
|
|
1546
|
+
continue;
|
|
1547
|
+
files += 1;
|
|
1548
|
+
if (files > maxFiles)
|
|
1549
|
+
return [scanBudgetViolation('file', files, maxFiles)];
|
|
1550
|
+
if (!SCAN_EXTENSIONS.has(node_path_1.default.extname(name).toLowerCase()))
|
|
1551
|
+
continue;
|
|
1552
|
+
// An allowlisted path is still VISITED and still counted against the file
|
|
1553
|
+
// cap — suppressing a finding must not also shrink the walk, or the
|
|
1554
|
+
// budget an operator raised would silently mean something else. Only the
|
|
1555
|
+
// read and the report are skipped.
|
|
1556
|
+
if (allow.has(relativePosix(root, full)))
|
|
1557
|
+
continue;
|
|
1558
|
+
// Checked BEFORE the read, so a single enormous file cannot cross the cap
|
|
1559
|
+
// by being read first and counted afterwards.
|
|
1560
|
+
if (bytes + stat.size > maxBytes) {
|
|
1561
|
+
return [scanBudgetViolation('byte', bytes + stat.size, maxBytes)];
|
|
1562
|
+
}
|
|
1563
|
+
bytes += stat.size;
|
|
1564
|
+
let content;
|
|
1565
|
+
try {
|
|
1566
|
+
content = node_fs_1.default.readFileSync(full, 'utf-8');
|
|
1567
|
+
}
|
|
1568
|
+
catch {
|
|
1569
|
+
continue;
|
|
1570
|
+
}
|
|
1571
|
+
const hit = firstInstrumentationHit(content);
|
|
1572
|
+
if (hit === null)
|
|
1573
|
+
continue;
|
|
1574
|
+
violations.push({
|
|
1575
|
+
// The repo-relative path IS the report. D-54 accepted the
|
|
1576
|
+
// false-positive cost on the explicit condition that the fault says
|
|
1577
|
+
// plainly what was found and where, so the path is carried in full — a
|
|
1578
|
+
// path truncated to the echo bound would locate nothing. It is a
|
|
1579
|
+
// locator, in the same class as the `<artifact>:<row>` locators the
|
|
1580
|
+
// content-free contract already emits, not client prose.
|
|
1581
|
+
artifact: relativePosix(root, full),
|
|
1582
|
+
line: hit.line,
|
|
1583
|
+
reason: 'instrumentation_leaked',
|
|
1584
|
+
// The matched literal, from this module's own frozen tuple. The SOURCE
|
|
1585
|
+
// LINE it sat on is never carried (T-04-15).
|
|
1586
|
+
detail: hit.literal,
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
return violations;
|
|
1591
|
+
}
|
|
1592
|
+
// ─── Output ───────────────────────────────────────────────────────────────────
|
|
1593
|
+
/**
|
|
1594
|
+
* Every control character the whitespace collapse below does NOT handle: the C0
|
|
1595
|
+
* plane minus `\t\n\v\f\r`, DEL, and the whole C1 plane.
|
|
1596
|
+
*
|
|
1597
|
+
* Stripped BEFORE the collapse, and this ordering is the whole point.
|
|
1598
|
+
* JavaScript `\s` matches only the five C0 whitespace characters plus the
|
|
1599
|
+
* Unicode space separators; it does NOT match ESC (`\x1b`), BEL (`\x07`),
|
|
1600
|
+
* BS (`\x08`) or NUL. `formatViolation` output reaches `error()`, which in the
|
|
1601
|
+
* default non-`--json-errors` mode writes `'Error: ' + message` to fd 2
|
|
1602
|
+
* VERBATIM, which is the mode a human operator reads in a terminal. A complete
|
|
1603
|
+
* OSC-52 clipboard-write sequence fits inside `ECHO_MAX_CHARS`, and an
|
|
1604
|
+
* UNTERMINATED OSC string consumes the report text after it and continues into
|
|
1605
|
+
* the next violation's cell. `_screens.md` and `_feedback.md` are derived from
|
|
1606
|
+
* client documents (D-24): treating them as untrusted for LENGTH while
|
|
1607
|
+
* treating them as trusted for BYTES was the gap.
|
|
1608
|
+
*/
|
|
1609
|
+
const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
1610
|
+
/**
|
|
1611
|
+
* Bound and flatten an author-supplied value before it reaches violation output.
|
|
1612
|
+
* Strip the control planes, collapse whitespace, trim, and cap at
|
|
1613
|
+
* `ECHO_MAX_CHARS`, appending an ellipsis when the value was longer.
|
|
1614
|
+
*
|
|
1615
|
+
* Both the strip and the collapse are load-bearing and must run BEFORE the
|
|
1616
|
+
* slice: together they guarantee a cell can never inject a newline, carriage
|
|
1617
|
+
* return, tab or terminal escape sequence into the one-line-per-violation
|
|
1618
|
+
* report format, regardless of where the slice happens to land.
|
|
1619
|
+
*/
|
|
1620
|
+
function sanitizeEcho(value) {
|
|
1621
|
+
// A non-string is not author data this function can meaningfully bound, and
|
|
1622
|
+
// default stringification would emit `[object Object]` — so it renders as
|
|
1623
|
+
// nothing rather than as a placeholder that looks like content.
|
|
1624
|
+
const raw = typeof value === 'string' ? value : '';
|
|
1625
|
+
const collapsed = raw.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim();
|
|
1626
|
+
if (collapsed.length <= ECHO_MAX_CHARS)
|
|
1627
|
+
return collapsed;
|
|
1628
|
+
return `${collapsed.slice(0, ECHO_MAX_CHARS)}…`;
|
|
1629
|
+
}
|
|
1630
|
+
/**
|
|
1631
|
+
* One content-free line per violation, two-space indented (T-04-04).
|
|
1632
|
+
*
|
|
1633
|
+
* This is the SINGLE chokepoint for echoed detail: every author-supplied value
|
|
1634
|
+
* below routes through `sanitizeEcho`, so a reason code added to the union by a
|
|
1635
|
+
* later plan inherits the confidentiality bound by construction rather than by
|
|
1636
|
+
* the author of that code remembering it. Reason codes, field names and IDs that
|
|
1637
|
+
* have already passed their family regex are deliberately NOT sanitised — all
|
|
1638
|
+
* three are closed sets declared in this module, not author data.
|
|
1639
|
+
*
|
|
1640
|
+
* `bad_id` renders as locator-plus-reason only. Its cell has passed no
|
|
1641
|
+
* validation at all, so there is nothing about it that is safe to quote; the
|
|
1642
|
+
* `<artifact>:<row>` locator finds the row instead.
|
|
1643
|
+
*/
|
|
1644
|
+
function formatViolation(v) {
|
|
1645
|
+
const where = v.artifact ?? '';
|
|
1646
|
+
const locator = v.line === undefined ? where : `${where}:${v.line}`;
|
|
1647
|
+
let detail = '';
|
|
1648
|
+
switch (v.reason) {
|
|
1649
|
+
case 'bad_id':
|
|
1650
|
+
detail = '';
|
|
1651
|
+
break;
|
|
1652
|
+
case 'missing_section':
|
|
1653
|
+
detail = sanitizeEcho(v.cell);
|
|
1654
|
+
break;
|
|
1655
|
+
case 'bad_surface_status':
|
|
1656
|
+
case 'bad_anchor':
|
|
1657
|
+
case 'bad_verdict':
|
|
1658
|
+
case 'bad_processing':
|
|
1659
|
+
// One arm for every closed-enum fault: the value IS echoed, because a
|
|
1660
|
+
// closed-vocabulary fault an author cannot see the value of is not
|
|
1661
|
+
// actionable — but it is echoed BOUNDED, through the chokepoint.
|
|
1662
|
+
detail = `${v.field}: ${sanitizeEcho(v.cell)}`;
|
|
1663
|
+
break;
|
|
1664
|
+
case 'unknown_requirement_id':
|
|
1665
|
+
detail = sanitizeEcho(v.cell);
|
|
1666
|
+
break;
|
|
1667
|
+
case 'screens_markers':
|
|
1668
|
+
case 'instrumentation_leaked':
|
|
1669
|
+
case 'scan_budget_exceeded':
|
|
1670
|
+
// `detail` is machine-rendered from this module's own marker literals, the
|
|
1671
|
+
// frozen instrumentation tuple and integers — never author content — so it
|
|
1672
|
+
// is emitted verbatim. For `instrumentation_leaked` that is the matched
|
|
1673
|
+
// literal and nothing else: the file and line are already in the locator,
|
|
1674
|
+
// and the source line they name is never carried at all (T-04-15).
|
|
1675
|
+
detail = v.detail ?? '';
|
|
1676
|
+
break;
|
|
1677
|
+
case 'screens_mismatch':
|
|
1678
|
+
// `actual` was already bounded at construction; routing it through the
|
|
1679
|
+
// chokepoint again is idempotent and keeps the invariant "every
|
|
1680
|
+
// author-supplied value in this function passes sanitizeEcho" literally
|
|
1681
|
+
// true, so a later reader cannot mistake the pre-sanitised field for an
|
|
1682
|
+
// exemption. `expected` is deliberately NOT rendered here: it is a
|
|
1683
|
+
// multi-line block, and this format is one line per violation.
|
|
1684
|
+
detail = `block drift; actual: ${sanitizeEcho(v.actual)}`;
|
|
1685
|
+
break;
|
|
1686
|
+
case 'requirements_unparseable':
|
|
1687
|
+
detail = 'run: discovery rollup --check';
|
|
1688
|
+
break;
|
|
1689
|
+
default:
|
|
1690
|
+
detail = sanitizeEcho(v.id ?? '');
|
|
1691
|
+
}
|
|
1692
|
+
return detail === '' ? ` ${locator}: ${v.reason}` : ` ${locator}: ${v.reason} (${detail})`;
|
|
1693
|
+
}
|
|
1694
|
+
/**
|
|
1695
|
+
* Render a violation list, capped, with the TRUE total.
|
|
1696
|
+
*
|
|
1697
|
+
* A pathological artifact must not produce an unbounded report, but a capped
|
|
1698
|
+
* report that also capped the COUNT would understate the problem — and
|
|
1699
|
+
* understating is the failure mode this whole phase exists to close. So the cap
|
|
1700
|
+
* applies to the rendered lines only; the tail line carries how many were
|
|
1701
|
+
* suppressed and the caller keeps `violations.length` as the true total.
|
|
1702
|
+
*
|
|
1703
|
+
* The research track applies this same idiom inline in its CLI adapter. It is a
|
|
1704
|
+
* named function here because the screens CLI adapter arrives in a later plan
|
|
1705
|
+
* and the cap is a contract that has to be testable before its only caller
|
|
1706
|
+
* exists — the same reason `formatViolation` is exported.
|
|
1707
|
+
*/
|
|
1708
|
+
function renderViolations(violations) {
|
|
1709
|
+
const total = violations.length;
|
|
1710
|
+
const rendered = violations.slice(0, MAX_REPORTED_VIOLATIONS).map(formatViolation);
|
|
1711
|
+
if (total > MAX_REPORTED_VIOLATIONS) {
|
|
1712
|
+
rendered.push(` … and ${total - MAX_REPORTED_VIOLATIONS} more`);
|
|
1713
|
+
}
|
|
1714
|
+
return rendered;
|
|
1715
|
+
}
|
|
1716
|
+
/**
|
|
1717
|
+
* Run every fault class this engine owns, in one pass, for a project root
|
|
1718
|
+
* (D-52). Collect-all-then-fail: every check runs, violations accumulate, the
|
|
1719
|
+
* result returns once. There is no warn-and-skip anywhere in this function.
|
|
1720
|
+
*
|
|
1721
|
+
* PURE COMPOSITION. Every fault class reported here is emitted by a function
|
|
1722
|
+
* declared and tested above; this aggregator introduces no violation code, no
|
|
1723
|
+
* grammar rule and no resolution of its own. That is deliberate and it is the
|
|
1724
|
+
* single-home doctrine applied to itself — a second copy of a resolution is
|
|
1725
|
+
* exactly the drift this phase exists to prevent, and the earlier waves' tests
|
|
1726
|
+
* pin the first copy.
|
|
1727
|
+
*
|
|
1728
|
+
* NEVER-BLOCKS (D-43), carried forward from the research track: unprocessed
|
|
1729
|
+
* feedback is not a fault — a round whose every Processing cell is `Open` exits
|
|
1730
|
+
* 0 and reports those rows under `open_feedback`, so a still-running feedback
|
|
1731
|
+
* loop can never block sign-off. Only STRUCTURAL faults set `ok` false.
|
|
1732
|
+
*
|
|
1733
|
+
* The `_screens.md` ABSENT arm skips coverage enforcement entirely. A document
|
|
1734
|
+
* that does not exist has no markers that could be missing, and reporting
|
|
1735
|
+
* `screens_markers` against it would collapse the absent arm into a fault — the
|
|
1736
|
+
* conflation `loadDiscoveryArtifacts` exists to keep apart. The anchor join is
|
|
1737
|
+
* NOT skipped in that arm, because an empty inventory map failing every anchor
|
|
1738
|
+
* is the correct GUARD-01 reading rather than an over-report.
|
|
1739
|
+
*
|
|
1740
|
+
* `artifacts_checked` names the three artifacts this gate VALIDATES, and only
|
|
1741
|
+
* those. `_risks.md` and `_research.md` are consulted for citation resolution,
|
|
1742
|
+
* never checked — their grammar belongs to `discovery research-check` (D-42) —
|
|
1743
|
+
* so listing them here would be the same overstatement that keeping an
|
|
1744
|
+
* unreadable artifact out of the list avoids.
|
|
1745
|
+
*/
|
|
1746
|
+
function checkScreenInventory(cwd, budget = {}) {
|
|
1747
|
+
const violations = [];
|
|
1748
|
+
const loaded = loadDiscoveryArtifacts(cwd);
|
|
1749
|
+
const artifactsChecked = [];
|
|
1750
|
+
// 1–2. The screen inventory.
|
|
1751
|
+
const screensContent = loaded.readArtifact(SCREENS_FILE);
|
|
1752
|
+
if (screensContent !== null)
|
|
1753
|
+
artifactsChecked.push(SCREENS_FILE);
|
|
1754
|
+
const inventory = parseScreenInventory(screensContent);
|
|
1755
|
+
violations.push(...inventory.violations);
|
|
1756
|
+
// 3. The requirements spine, through rollup's single exported reader.
|
|
1757
|
+
const requirements = resolveRequirements(loaded);
|
|
1758
|
+
if (requirements.status !== 'absent')
|
|
1759
|
+
artifactsChecked.push(REQUIREMENTS_FILE);
|
|
1760
|
+
violations.push(...requirements.violations);
|
|
1761
|
+
// 4. The feedback log.
|
|
1762
|
+
const feedbackContent = loaded.readArtifact(FEEDBACK_FILE);
|
|
1763
|
+
if (feedbackContent !== null)
|
|
1764
|
+
artifactsChecked.push(FEEDBACK_FILE);
|
|
1765
|
+
const feedback = parseFeedbackLog(feedbackContent);
|
|
1766
|
+
violations.push(...feedback.violations);
|
|
1767
|
+
// 5. The cross-artifact resolutions: anchors and Processing citations against
|
|
1768
|
+
// the inventory and the registers, and every screen's requirement
|
|
1769
|
+
// citations against the requirements spine. Each is a named function this
|
|
1770
|
+
// aggregator CALLS, never a rule it declares.
|
|
1771
|
+
violations.push(...resolveCrossArtifact(inventory.screens, feedback.rows, loaded));
|
|
1772
|
+
violations.push(...resolveRequirementRefs(inventory.screens, requirements));
|
|
1773
|
+
// 6. The computed coverage assertion. Computed unconditionally so the payload
|
|
1774
|
+
// can report it, ENFORCED only against a `_screens.md` that exists.
|
|
1775
|
+
const coverage = computeCoverage(inventory.screens, inventory.noScreen, requirements.rows);
|
|
1776
|
+
let expectedBlock;
|
|
1777
|
+
if (screensContent !== null) {
|
|
1778
|
+
const coverageViolations = enforceCoverage(screensContent, coverage, requirements.status);
|
|
1779
|
+
violations.push(...coverageViolations);
|
|
1780
|
+
if (coverageViolations.some(v => v.reason === 'screens_mismatch')) {
|
|
1781
|
+
// The VERBATIM render, not the violation's normalised `expected`: the
|
|
1782
|
+
// operator is going to paste this between the markers, and the normalised
|
|
1783
|
+
// form has had its blank line collapsed out of it.
|
|
1784
|
+
expectedBlock = renderCoverageBlock(coverage);
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
// 7. The anti-leak scan, GATED ON ARTIFACTS. A project with no discovery
|
|
1788
|
+
// package never walks its own tree, so a codebase that uses `data-req` for
|
|
1789
|
+
// its own unrelated purposes is untouched (D-52 graceful arm applied to
|
|
1790
|
+
// D-54's false-positive surface, T-04-18).
|
|
1791
|
+
//
|
|
1792
|
+
// The gate reads DISCOVERY_ARTIFACTS rather than `present.size > 0`: the
|
|
1793
|
+
// latter is every directory entry, so a `.DS_Store` that macOS writes on
|
|
1794
|
+
// mere Finder access — or a subdirectory, or an editor scratch file —
|
|
1795
|
+
// promoted an empty discovery folder into a whole-repo walk. The bound has
|
|
1796
|
+
// to be "a discovery package exists", not "the folder has something in it".
|
|
1797
|
+
//
|
|
1798
|
+
// The allowlist is loaded here, in the layer that owns the discovery
|
|
1799
|
+
// package, and handed to the walk. Loading it unconditionally rather than
|
|
1800
|
+
// inside the gate keeps `artifact_unreadable` reachable for an unreadable
|
|
1801
|
+
// list even in a project that never gets scanned; the read is a no-op when
|
|
1802
|
+
// the file is absent, which is the ordinary case.
|
|
1803
|
+
const scanAllow = loadScanAllowlist(loaded);
|
|
1804
|
+
if (DISCOVERY_ARTIFACTS.some(file => loaded.present.has(file))) {
|
|
1805
|
+
violations.push(...scanBuildTree(cwd, { ...budget, allow: scanAllow }));
|
|
1806
|
+
}
|
|
1807
|
+
// LAST, not first: `readArtifact` pushes onto this list as reads happen, and
|
|
1808
|
+
// the citation registers are read lazily inside `resolveCrossArtifact`. Taking
|
|
1809
|
+
// a copy any earlier would silently drop an `artifact_unreadable` fault.
|
|
1810
|
+
violations.push(...loaded.violations);
|
|
1811
|
+
const byVerdict = {};
|
|
1812
|
+
for (const verdict of VERDICTS)
|
|
1813
|
+
byVerdict[verdict] = 0;
|
|
1814
|
+
let openTotal = 0;
|
|
1815
|
+
for (const row of feedback.rows) {
|
|
1816
|
+
if (row.processing !== PROCESSING.OPEN)
|
|
1817
|
+
continue;
|
|
1818
|
+
openTotal += 1;
|
|
1819
|
+
// Guarded membership: a malformed verdict is already a `bad_verdict` fault
|
|
1820
|
+
// and must not be able to mint a key on a derived object.
|
|
1821
|
+
if (VERDICTS.includes(row.verdict))
|
|
1822
|
+
byVerdict[row.verdict] += 1;
|
|
1823
|
+
}
|
|
1824
|
+
const result = {
|
|
1825
|
+
ok: violations.length === 0,
|
|
1826
|
+
artifacts_checked: artifactsChecked,
|
|
1827
|
+
screens: inventory.screens.size,
|
|
1828
|
+
rounds: feedback.rounds.size,
|
|
1829
|
+
open_feedback: { total: openTotal, by_verdict: byVerdict },
|
|
1830
|
+
coverage: {
|
|
1831
|
+
mvp: coverage.mvp,
|
|
1832
|
+
placed: coverage.placed,
|
|
1833
|
+
bucketed: coverage.bucketed,
|
|
1834
|
+
uncovered: coverage.uncovered.length,
|
|
1835
|
+
},
|
|
1836
|
+
violations,
|
|
1837
|
+
};
|
|
1838
|
+
if (expectedBlock !== undefined)
|
|
1839
|
+
result.expected_coverage_block = expectedBlock;
|
|
1840
|
+
return result;
|
|
1841
|
+
}
|
|
1842
|
+
// ─── CLI adapter ──────────────────────────────────────────────────────────────
|
|
1843
|
+
/**
|
|
1844
|
+
* CLI adapter for `pp-tools discovery screen-check` (dotted form
|
|
1845
|
+
* `pp-tools query discovery.screen-check`).
|
|
1846
|
+
*
|
|
1847
|
+
* Exit semantics: any structural violation exits non-zero via
|
|
1848
|
+
* `ERROR_REASON.DISCOVERY_SCREEN_CHECK_FAILED`, naming each violation by
|
|
1849
|
+
* locator and reason. Unprocessed feedback is NOT a violation (D-43) — a round
|
|
1850
|
+
* whose every Processing cell is `Open` exits 0 and is reported under
|
|
1851
|
+
* `open_feedback`.
|
|
1852
|
+
*
|
|
1853
|
+
* The command accepts no positional path argument at all: the convention paths
|
|
1854
|
+
* under `.planning/discovery/` resolve against cwd, which ELIMINATES the
|
|
1855
|
+
* traversal surface rather than guarding it (T-04-17, the T-03-01 precedent).
|
|
1856
|
+
* `--max-files <n>` and `--max-bytes <n>` are the ONLY arguments, and they
|
|
1857
|
+
* adjust the build-tree scan's caps and nothing else. The traversal surface
|
|
1858
|
+
* stays eliminated; only the CAP is operator-adjustable, because
|
|
1859
|
+
* `scan_budget_exceeded` sets `ok` false and the handoff arm is a hard block
|
|
1860
|
+
* with no option to proceed — a cap an operator cannot raise turns an honest
|
|
1861
|
+
* truncation into an unpassable gate whose only remedy is deleting files.
|
|
1862
|
+
*
|
|
1863
|
+
* Each value must be a plain positive integer within `SCAN_ABSOLUTE_MAX`. The
|
|
1864
|
+
* override RAISES or lowers a bound; it can never remove one, so the walk stays
|
|
1865
|
+
* finite on both axes exactly as T-04-14 requires.
|
|
1866
|
+
*
|
|
1867
|
+
* The outstanding-work payload is emitted on the FAILURE path too, and first.
|
|
1868
|
+
* An inventory with one malformed cell must not lose the report that makes an
|
|
1869
|
+
* honest partial sign-off possible — that is precisely the state in which an
|
|
1870
|
+
* operator needs both halves. The payload inherits the content-free contract by
|
|
1871
|
+
* construction: counts, closed-vocabulary enum keys, artifact filenames and the
|
|
1872
|
+
* machine-rendered coverage block, never a screen name or a feedback comment.
|
|
1873
|
+
*/
|
|
1874
|
+
function cmdDiscoveryScreenCheck(cwd, args, raw) {
|
|
1875
|
+
// args arrive shaped ['discovery', 'screen-check', ...]; global output flags
|
|
1876
|
+
// are consumed by the dispatcher, so anything left that is not one of the two
|
|
1877
|
+
// budget flags or `--require-processed` — a positional path or an unknown flag
|
|
1878
|
+
// alike — is a usage error. Parsed by hand so the accepted set stays visibly closed.
|
|
1879
|
+
const rest = args.slice(2);
|
|
1880
|
+
const budget = {};
|
|
1881
|
+
let requireProcessed = false;
|
|
1882
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
1883
|
+
const flag = rest[i];
|
|
1884
|
+
if (flag === '--require-processed') {
|
|
1885
|
+
requireProcessed = true;
|
|
1886
|
+
continue;
|
|
1887
|
+
}
|
|
1888
|
+
if (flag !== '--max-files' && flag !== '--max-bytes') {
|
|
1889
|
+
error(SCREEN_CHECK_USAGE, ERROR_REASON.USAGE);
|
|
1890
|
+
return; // unreachable — error() exits
|
|
1891
|
+
}
|
|
1892
|
+
const ceiling = flag === '--max-files' ? SCAN_ABSOLUTE_MAX.files : SCAN_ABSOLUTE_MAX.bytes;
|
|
1893
|
+
const parsed = parseScanBound(rest[i + 1], ceiling);
|
|
1894
|
+
if (parsed === null) {
|
|
1895
|
+
error(SCREEN_CHECK_USAGE, ERROR_REASON.USAGE);
|
|
1896
|
+
return; // unreachable — error() exits
|
|
1897
|
+
}
|
|
1898
|
+
if (flag === '--max-files')
|
|
1899
|
+
budget.maxFiles = parsed;
|
|
1900
|
+
else
|
|
1901
|
+
budget.maxBytes = parsed;
|
|
1902
|
+
i += 1; // the consumed value
|
|
1903
|
+
}
|
|
1904
|
+
const result = checkScreenInventory(cwd, budget);
|
|
1905
|
+
const payload = {
|
|
1906
|
+
ok: result.ok,
|
|
1907
|
+
artifacts_checked: result.artifacts_checked,
|
|
1908
|
+
screens: result.screens,
|
|
1909
|
+
rounds: result.rounds,
|
|
1910
|
+
open_feedback: result.open_feedback,
|
|
1911
|
+
coverage: result.coverage,
|
|
1912
|
+
};
|
|
1913
|
+
// Present when and only when the block actually drifted, so its presence is
|
|
1914
|
+
// itself the signal that there is something to paste back (D-06). There is no
|
|
1915
|
+
// compute-and-write mode and no second command: this is how the
|
|
1916
|
+
// replace-verbatim workflow is served by a pure validator.
|
|
1917
|
+
if (result.expected_coverage_block !== undefined) {
|
|
1918
|
+
payload.expected_coverage_block = result.expected_coverage_block;
|
|
1919
|
+
}
|
|
1920
|
+
if (!result.ok) {
|
|
1921
|
+
const total = result.violations.length;
|
|
1922
|
+
payload.violations = total;
|
|
1923
|
+
// Emitted on stdout BEFORE the envelope goes to stderr, so the ordering is
|
|
1924
|
+
// deterministic: `output` writes fd 1 synchronously and `error` exits.
|
|
1925
|
+
output(payload, raw);
|
|
1926
|
+
error(`discovery screen-check: ${total} violation(s):\n${renderViolations(result.violations).join('\n')}`, ERROR_REASON.DISCOVERY_SCREEN_CHECK_FAILED);
|
|
1927
|
+
return; // unreachable — error() exits
|
|
1928
|
+
}
|
|
1929
|
+
// Session close and the build handoff: an Open row is feedback nobody has processed yet.
|
|
1930
|
+
if (requireProcessed && result.open_feedback.total > 0) {
|
|
1931
|
+
const open = result.open_feedback;
|
|
1932
|
+
const split = VERDICTS.map((v) => `${v}: ${open.by_verdict[v] ?? 0}`).join(', ');
|
|
1933
|
+
output(payload, raw);
|
|
1934
|
+
error(`discovery screen-check: ${open.total} feedback row(s) still Open (${split}) in ${FEEDBACK_FILE} — process each before continuing`, ERROR_REASON.DISCOVERY_FEEDBACK_OPEN);
|
|
1935
|
+
return; // unreachable — error() exits
|
|
1936
|
+
}
|
|
1937
|
+
output(payload, raw);
|
|
1938
|
+
}
|
|
1939
|
+
module.exports = {
|
|
1940
|
+
SURFACE_STATUS,
|
|
1941
|
+
VIOLATION_REASONS,
|
|
1942
|
+
INSTRUMENTATION_LITERALS,
|
|
1943
|
+
ECHO_MAX_CHARS,
|
|
1944
|
+
MAX_REPORTED_VIOLATIONS,
|
|
1945
|
+
SCREENS_FILE,
|
|
1946
|
+
FEEDBACK_FILE,
|
|
1947
|
+
REQUIREMENTS_FILE,
|
|
1948
|
+
SCREENS_HEADING,
|
|
1949
|
+
NO_SCREEN_HEADING,
|
|
1950
|
+
COVERAGE_HEADING,
|
|
1951
|
+
COVERAGE_OPEN,
|
|
1952
|
+
COVERAGE_CLOSE,
|
|
1953
|
+
SCREENS_OPEN,
|
|
1954
|
+
SCREENS_CLOSE,
|
|
1955
|
+
COVERAGE_LABELS,
|
|
1956
|
+
RISKS_FILE,
|
|
1957
|
+
RESEARCH_FILE,
|
|
1958
|
+
VERDICTS,
|
|
1959
|
+
PROCESSING,
|
|
1960
|
+
ANCHOR_SEP,
|
|
1961
|
+
SCREEN_COL,
|
|
1962
|
+
NO_SCREEN_COL,
|
|
1963
|
+
FEEDBACK_COL,
|
|
1964
|
+
SCREEN_CHECK_FAILED,
|
|
1965
|
+
SCAN_ALLOW_FILE,
|
|
1966
|
+
DISCOVERY_ARTIFACTS,
|
|
1967
|
+
loadScanAllowlist,
|
|
1968
|
+
SCAN_SKIP_DIRS,
|
|
1969
|
+
SCAN_EXTENSIONS,
|
|
1970
|
+
SCAN_MAX_FILES,
|
|
1971
|
+
SCAN_MAX_BYTES,
|
|
1972
|
+
SCAN_FLOOR,
|
|
1973
|
+
SCAN_ABSOLUTE_MAX,
|
|
1974
|
+
SCREEN_CHECK_USAGE,
|
|
1975
|
+
scanBuildTree,
|
|
1976
|
+
loadDiscoveryArtifacts,
|
|
1977
|
+
resolveRequirements,
|
|
1978
|
+
parseScreenInventory,
|
|
1979
|
+
parseFeedbackLog,
|
|
1980
|
+
// Exported so the bounded-span floor can be falsified through THIS engine
|
|
1981
|
+
// rather than only through the shared leaf. Both grammars are two digits, so
|
|
1982
|
+
// the out-of-band arm is unreachable end to end and a test can only reach it
|
|
1983
|
+
// by calling the validator with the numbers directly (04-SECURITY AR-04-01).
|
|
1984
|
+
validateScreenSequence,
|
|
1985
|
+
validateRoundSequence,
|
|
1986
|
+
resolveCrossArtifact,
|
|
1987
|
+
resolveRequirementRefs,
|
|
1988
|
+
computeCoverage,
|
|
1989
|
+
renderCoverageBlock,
|
|
1990
|
+
enforceCoverage,
|
|
1991
|
+
formatViolation,
|
|
1992
|
+
renderViolations,
|
|
1993
|
+
checkScreenInventory,
|
|
1994
|
+
cmdDiscoveryScreenCheck,
|
|
1995
|
+
};
|