@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,979 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* Discovery research-track enforcement engine (discovery-v2 Phase 3,
|
|
4
|
+
* RTRK-01/RTRK-02/RTRK-03 + TRACE-05, D-33..D-48).
|
|
5
|
+
*
|
|
6
|
+
* Read-only structural validator over the three per-engagement research
|
|
7
|
+
* registers — `.planning/discovery/_research.md` (the backlog),
|
|
8
|
+
* `_risks.md` (the data-gathering register) and `_sparring.md` (the position
|
|
9
|
+
* tracker) — plus every `⚠️ CONDITIONAL —` marker in the session files and
|
|
10
|
+
* `_context.md` that cites them.
|
|
11
|
+
*
|
|
12
|
+
* Fail-loud (D-42), one bullet per fault class:
|
|
13
|
+
* - bad_id / duplicate_id — a register row whose ID is not `^(RS|DG|SP)-\d{2}$`,
|
|
14
|
+
* or a second row claiming an ID already seen
|
|
15
|
+
* - sequence_gap — a family's observed numbers are not exactly `01..max`;
|
|
16
|
+
* the reported id is the MISSING one, so a deleted row cannot lapse (D-41)
|
|
17
|
+
* - bad_enum — Priority (D-34), Affects, Source (D-37), Feeds (D-40) or
|
|
18
|
+
* Status outside its closed vocabulary, carrying the offending `field`
|
|
19
|
+
* - closed_without_evidence — a bare `Closed`, or `Closed (<evidence>)` whose
|
|
20
|
+
* evidence is neither `D-<ss>-<n>` nor a repo-relative file that exists (D-35)
|
|
21
|
+
* - missing_owner — an `Open` row with an empty Owner cell
|
|
22
|
+
* - unknown_requirement_id / gates_nothing — a `Gates what?` / `Blocks` ID
|
|
23
|
+
* absent from `_requirements.md`, or a `Gates-MVP` item gating nothing (D-36)
|
|
24
|
+
* - unknown_marker_target / stale_marker — a ⚠️ marker naming an ID with no
|
|
25
|
+
* register row, or citing an item already `Closed` (TRACE-05)
|
|
26
|
+
* - conditional_frontmatter_drift — a file's `conditional:` frontmatter and its
|
|
27
|
+
* body markers disagree; derived state is validated, never trusted (D-12)
|
|
28
|
+
* - requirements_unparseable — `_requirements.md` fails its own gate; reported
|
|
29
|
+
* ONCE, naming the owning gate, never by re-enumerating rollup's errors
|
|
30
|
+
* - artifact_unreadable — an artifact the directory listing named but that
|
|
31
|
+
* could not be read. Deliberately distinguished from the D-42 absent arm:
|
|
32
|
+
* "could not read it, so it's fine" is a fail-open on a gate whose whole
|
|
33
|
+
* purpose is structural verification
|
|
34
|
+
*
|
|
35
|
+
* Graceful arms (D-42), all three independent: no `.planning/discovery/`
|
|
36
|
+
* directory at all exits 0 with an empty `artifacts_checked`; a register absent
|
|
37
|
+
* while a sibling is present checks the one it has and stays silent about the
|
|
38
|
+
* one it does not; `_requirements.md` absent skips only the D-36 existence
|
|
39
|
+
* check and every other fault class still runs.
|
|
40
|
+
*
|
|
41
|
+
* Content-free contract (T-03-04): output names artifact paths, `<file>:<row>`
|
|
42
|
+
* locators, item IDs that have already passed their family regex, field names,
|
|
43
|
+
* BOUNDED enum-cell prefixes, BOUNDED marker literals and snake_case reason
|
|
44
|
+
* codes — and nothing else. An unvalidated cell is never echoed at all: a
|
|
45
|
+
* malformed ID is located by row number, and every remaining detail is
|
|
46
|
+
* whitespace-collapsed and capped at `ECHO_MAX_CHARS` by `sanitizeEcho`, the
|
|
47
|
+
* single chokepoint inside `formatViolation`. The bound is a confidentiality
|
|
48
|
+
* control, not a formatting preference — register prose is derived from client
|
|
49
|
+
* documents (D-24). Never-blocks contract (D-43): unfinished research
|
|
50
|
+
* is not a fault — a register whose only notable feature is an `Open`
|
|
51
|
+
* `Gates-MVP` item exits 0 and reports that item under `open_by_priority`.
|
|
52
|
+
*/
|
|
53
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
54
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
55
|
+
};
|
|
56
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
57
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
58
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
59
|
+
const io = require("./io.cjs");
|
|
60
|
+
const { output, error, ERROR_REASON } = io;
|
|
61
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
62
|
+
const fm = require("./frontmatter.cjs");
|
|
63
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
64
|
+
const rollup = require("./discovery-rollup.cjs");
|
|
65
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
66
|
+
const table = require("./discovery-table.cjs");
|
|
67
|
+
const { splitTableCells, boundedSequenceMax } = table;
|
|
68
|
+
// ─── Types ────────────────────────────────────────────────────────────────────
|
|
69
|
+
/**
|
|
70
|
+
* The closed set of reason codes this engine can emit, in declaration order.
|
|
71
|
+
*
|
|
72
|
+
* Exported as a runtime VALUE, not merely as a type, specifically so the
|
|
73
|
+
* parser-contract table in `pp-core/references/discovery-research-track.md` can
|
|
74
|
+
* be pinned against it by a test instead of by a manual diff. Verification gap 6
|
|
75
|
+
* was a documented-but-unenforced claim in that table, and what made the audit
|
|
76
|
+
* manual in the first place was that this was a compile-time-only union with
|
|
77
|
+
* nothing to enumerate at runtime. `ViolationReason` is derived from the tuple,
|
|
78
|
+
* so a code added to one is a code added to both.
|
|
79
|
+
*
|
|
80
|
+
* Append-only by convention: `artifact_unreadable` sits last so the twelve
|
|
81
|
+
* earlier entries keep their positions and the reference table extends by a row
|
|
82
|
+
* rather than being reordered.
|
|
83
|
+
*/
|
|
84
|
+
const VIOLATION_REASONS = Object.freeze([
|
|
85
|
+
'bad_id',
|
|
86
|
+
'duplicate_id',
|
|
87
|
+
'sequence_gap',
|
|
88
|
+
'bad_enum',
|
|
89
|
+
'missing_owner',
|
|
90
|
+
'gates_nothing',
|
|
91
|
+
'closed_without_evidence',
|
|
92
|
+
'unknown_requirement_id',
|
|
93
|
+
'unknown_marker_target',
|
|
94
|
+
'stale_marker',
|
|
95
|
+
'requirements_unparseable',
|
|
96
|
+
'conditional_frontmatter_drift',
|
|
97
|
+
'artifact_unreadable',
|
|
98
|
+
]);
|
|
99
|
+
// ─── Constants ────────────────────────────────────────────────────────────────
|
|
100
|
+
/** D-34: the three research priorities. `Gates-MVP` is the only blocking-shaped one. */
|
|
101
|
+
const PRIORITY = Object.freeze({
|
|
102
|
+
GATES_MVP: 'Gates-MVP',
|
|
103
|
+
BEFORE_GO_LIVE: 'Before-go-live',
|
|
104
|
+
PARALLEL: 'Parallel',
|
|
105
|
+
});
|
|
106
|
+
/** D-35: one close grammar across all three registers — `Open` or `Closed (<evidence>)`. */
|
|
107
|
+
const ITEM_STATUS = Object.freeze({ OPEN: 'Open', CLOSED: 'Closed' });
|
|
108
|
+
/** The `_risks.md` Affects vocabulary (lowercase). */
|
|
109
|
+
const AFFECTS = Object.freeze({ INPUT: 'input', PROCESS: 'process', OUTPUT: 'output' });
|
|
110
|
+
/** The marker literal — single home: references/discovery-id-conventions.md § Conditional Marker. */
|
|
111
|
+
const CONDITIONAL_MARKER = '⚠️ CONDITIONAL —';
|
|
112
|
+
/**
|
|
113
|
+
* The confidentiality bound on every echoed detail (T-03-04, D-24) — a
|
|
114
|
+
* whitespace-collapsed prefix length, not a formatting preference. Register
|
|
115
|
+
* prose is derived from client documents, so the report caps what it can
|
|
116
|
+
* repeat rather than trusting each call site to be careful; applying it at ONE
|
|
117
|
+
* place (`sanitizeEcho`, inside `formatViolation`) is what stops a future
|
|
118
|
+
* fault class from reopening the hole.
|
|
119
|
+
*
|
|
120
|
+
* The value is chosen from BOTH sides. Above it sits nothing legitimate: the
|
|
121
|
+
* longest echo the contract itself produces is the marker literal
|
|
122
|
+
* `⚠️ CONDITIONAL — RS-01`, which is 22 JS characters because `⚠️` is two code
|
|
123
|
+
* units — so 24 leaves exactly 2 characters of headroom and a well-formed
|
|
124
|
+
* value is never truncated. Below it sits the security suite's 33-character
|
|
125
|
+
* canary, which must not fit. Both sides are pinned by test rather than by
|
|
126
|
+
* this comment: 2 characters of headroom is thin enough that a longer ID
|
|
127
|
+
* family or an edit to the marker literal would silently truncate valid
|
|
128
|
+
* output, and an unpinned floor would let a later edit lower the constant into
|
|
129
|
+
* legitimate output with no test explaining the breakage.
|
|
130
|
+
*/
|
|
131
|
+
const ECHO_MAX_CHARS = 24;
|
|
132
|
+
/**
|
|
133
|
+
* The cap on how many violations the failure message RENDERS. A register with
|
|
134
|
+
* thousands of malformed rows would otherwise inline one line each into the
|
|
135
|
+
* `--json-errors` JSON `message` (03-REVIEW IN-05). The cap bounds the report,
|
|
136
|
+
* never the accounting: the count in the message prefix and the `violations`
|
|
137
|
+
* field of the failure-path summary both stay the true total, and the tail line
|
|
138
|
+
* names how many were withheld.
|
|
139
|
+
*/
|
|
140
|
+
const MAX_REPORTED_VIOLATIONS = 50;
|
|
141
|
+
const DISCOVERY_DIR = '.planning/discovery';
|
|
142
|
+
const RESEARCH_FILE = '_research.md';
|
|
143
|
+
const RISKS_FILE = '_risks.md';
|
|
144
|
+
const SPARRING_FILE = '_sparring.md';
|
|
145
|
+
const REQUIREMENTS_FILE = '_requirements.md';
|
|
146
|
+
const CONTEXT_FILE = '_context.md';
|
|
147
|
+
/** The em dash an author writes to mean "nothing here" in an ID-bearing cell. */
|
|
148
|
+
const NOT_APPLICABLE = '—';
|
|
149
|
+
// Anchored, non-backtracking regexes (threat T-03-02). Every one of them is
|
|
150
|
+
// applied either to a SINGLE already-split line or to a SINGLE already-split
|
|
151
|
+
// table cell — never unanchored across whole-file untrusted content. The
|
|
152
|
+
// register content may be derived from client documents, so a catastrophic
|
|
153
|
+
// backtracking pattern here would be a denial-of-service surface.
|
|
154
|
+
const RS_ID_RE = /^RS-\d{2}$/;
|
|
155
|
+
const DG_ID_RE = /^DG-\d{2}$/;
|
|
156
|
+
const SP_ID_RE = /^SP-\d{2}$/;
|
|
157
|
+
const SEPARATOR_ROW_RE = /^\s*\|[\s:|-]+\|\s*$/;
|
|
158
|
+
/** D-35 evidence form 1: a decision ID. Form 2 is an existing repo-relative path. */
|
|
159
|
+
const EVIDENCE_DECISION_RE = /^D-\d{2}-\d+$/;
|
|
160
|
+
const CLOSED_EVIDENCE_RE = /^Closed \((.+)\)$/;
|
|
161
|
+
/** D-37 Source vocabulary. */
|
|
162
|
+
const SOURCE_RE = /^(DG-\d{2}|SP-\d{2}|S\d+|D-\d{2}-\d+)$/;
|
|
163
|
+
/** D-40 Feeds: comma-separated session refs. */
|
|
164
|
+
const FEEDS_RE = /^S\d+$/;
|
|
165
|
+
/** A requirement ID as `_requirements.md` mints them (rollup's ROW_ID_RE shape). */
|
|
166
|
+
const REQUIREMENT_ID_RE = /^[A-Z][A-Z0-9]*-\d+$/;
|
|
167
|
+
/**
|
|
168
|
+
* Deliberately PERMISSIVE: it captures any non-whitespace token before the em
|
|
169
|
+
* dash rather than baking `SP-\d{2}` into the pattern. The strict form made
|
|
170
|
+
* `SP_ID_RE` unreachable, so a malformed heading matched nothing, fell through
|
|
171
|
+
* the `### ` flush and vanished with zero violations (03-REVIEW WR-01) — a
|
|
172
|
+
* warn-and-skip in all but name, which D-08 forbids. The family claim is what
|
|
173
|
+
* scopes the parser now, exactly as the two table parsers scope themselves on
|
|
174
|
+
* their first cell; validation is `SP_ID_RE`'s job, done in the loop.
|
|
175
|
+
*/
|
|
176
|
+
const SPARRING_HEAD_RE = /^### (\S+) — (.+)$/;
|
|
177
|
+
const SPARRING_FIELD_RE = /^- \*\*(Position|Key point|To nail down|DECISION|Residual risk accepted|Feeds|Status):\*\* (.*)$/;
|
|
178
|
+
/** D-45: the marker owns its whole line; the optional continuation is ` · <description>`. */
|
|
179
|
+
const CONDITIONAL_RE = /^⚠️ CONDITIONAL — (RS-\d{2}|DG-\d{2})( · .+)?$/;
|
|
180
|
+
/** Fixed-shape filename filter — never a regex over untrusted content. */
|
|
181
|
+
const SESSION_FILE_RE = /^session-\d{2}\.md$/;
|
|
182
|
+
const PRIORITY_LOOKUP = new Map(Object.values(PRIORITY).map(v => [v.toLowerCase(), v]));
|
|
183
|
+
const AFFECTS_LOOKUP = new Map(Object.values(AFFECTS).map(v => [v.toLowerCase(), v]));
|
|
184
|
+
// ─── Shared cell helpers ──────────────────────────────────────────────────────
|
|
185
|
+
// The escape-aware cell splitter this module used to own now lives in the
|
|
186
|
+
// `discovery-table.cjs` leaf as `splitTableCells` (discovery-v2 Phase 4, D-60), so the
|
|
187
|
+
// screen-inventory parser reads free-text cells by exactly the same rule rather
|
|
188
|
+
// than by a second copy of it. The behaviour is unchanged: `Item`
|
|
189
|
+
// (`_research.md`) and `Data to obtain` (`_risks.md`) are declared FREE TEXT by
|
|
190
|
+
// the reference, so a naive `.split('|')` would shift every subsequent column
|
|
191
|
+
// (`03-REVIEW.md` § CR-02); the correctly-escaped `\|` is the author's
|
|
192
|
+
// documented workaround, written into the Shared grammar by Plan 03-09. The
|
|
193
|
+
// regex rationale (fixed-width, quantifier-free lookbehind over a single
|
|
194
|
+
// already-split line — T-03-02) travelled with the function.
|
|
195
|
+
/**
|
|
196
|
+
* Collect the pipe-table row lines of a register. Unlike the rollup parser's
|
|
197
|
+
* `sectionRows`, a `## ` heading here is a section START, not a stop: D-32
|
|
198
|
+
* makes H2s theme groupings inside `_research.md`, so the register is the union
|
|
199
|
+
* of every themed table. Header and separator rows are skipped by SHAPE, not by
|
|
200
|
+
* header text — the positional contract (D-26 precedent).
|
|
201
|
+
*/
|
|
202
|
+
function registerRows(content) {
|
|
203
|
+
const lines = content.split(/\r?\n/);
|
|
204
|
+
const rows = [];
|
|
205
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
206
|
+
const line = lines[i];
|
|
207
|
+
const trimmed = line.trim();
|
|
208
|
+
if (!trimmed.includes('|'))
|
|
209
|
+
continue;
|
|
210
|
+
if (SEPARATOR_ROW_RE.test(line))
|
|
211
|
+
continue;
|
|
212
|
+
if (i + 1 < lines.length && SEPARATOR_ROW_RE.test(lines[i + 1]))
|
|
213
|
+
continue;
|
|
214
|
+
rows.push({ line, num: i + 1 });
|
|
215
|
+
}
|
|
216
|
+
return rows;
|
|
217
|
+
}
|
|
218
|
+
/**
|
|
219
|
+
* D-41 sequence integrity. For a family with any rows, the observed numbers
|
|
220
|
+
* must be exactly `01..max`; each hole is reported as a `sequence_gap` whose
|
|
221
|
+
* `id` is the MISSING id — that is what makes non-lapse mechanical: a deleted
|
|
222
|
+
* row leaves a hole the parser names, so history is never the enforcement
|
|
223
|
+
* mechanism. A family with zero rows has no gaps.
|
|
224
|
+
*
|
|
225
|
+
* The maximum is taken through the shared `boundedSequenceMax` fold rather than
|
|
226
|
+
* `Math.max(...seqs)`. This engine's exposure was never the screens engine's
|
|
227
|
+
* uncatchable out-of-memory abort — `RS-`/`DG-`/`SP-` are all two digits, so
|
|
228
|
+
* the value cannot exceed 99 and the loop below cannot amplify — it was a
|
|
229
|
+
* `RangeError` from the spread on a register of a few hundred thousand rows,
|
|
230
|
+
* which the CLI already catches. Symmetry and defence in depth, adopted so the
|
|
231
|
+
* two engines cannot drift again (04-SECURITY AR-04-02).
|
|
232
|
+
*/
|
|
233
|
+
function validateSequence(artifact, family, seqs) {
|
|
234
|
+
const violations = [];
|
|
235
|
+
if (seqs.length === 0)
|
|
236
|
+
return violations;
|
|
237
|
+
const seen = new Set(seqs);
|
|
238
|
+
const max = boundedSequenceMax(seqs);
|
|
239
|
+
if (max === null) {
|
|
240
|
+
// Unreachable through the two-digit family grammars, and stated as such:
|
|
241
|
+
// this is the independent floor, not the primary guard. One bounded fault
|
|
242
|
+
// instead of a gap list nobody could read.
|
|
243
|
+
return [{ artifact, reason: 'bad_id' }];
|
|
244
|
+
}
|
|
245
|
+
for (let n = 1; n <= max; n += 1) {
|
|
246
|
+
if (!seen.has(n)) {
|
|
247
|
+
violations.push({ artifact, id: `${family}-${String(n).padStart(2, '0')}`, reason: 'sequence_gap' });
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
return violations;
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Does a `Closed (<path>)` evidence cell name an existing REGULAR FILE strictly
|
|
254
|
+
* beneath the project root?
|
|
255
|
+
*
|
|
256
|
+
* That is the whole rule, and it is the only mechanism enforcing the Definition
|
|
257
|
+
* of Done: closing a research item means pointing at the decision that settled
|
|
258
|
+
* it or the artifact that answered it, so a directory is neither and is
|
|
259
|
+
* refused. The project root is refused with it — `existsSync` plus the old
|
|
260
|
+
* `target !== root` allowance made `Closed (.)` a one-character bypass of the
|
|
261
|
+
* whole rule (03-REVIEW WR-02). A symlink to a directory is refused for the
|
|
262
|
+
* same reason, since `statSync` resolves it before the type test.
|
|
263
|
+
*
|
|
264
|
+
* The T-03-07 posture is unchanged and merely narrowed from exists to
|
|
265
|
+
* is-a-regular-file: an absolute path, a null byte, or anything escaping `cwd`
|
|
266
|
+
* after resolution is refused outright rather than probed, the file is NEVER
|
|
267
|
+
* read, and nothing but resolves / does-not-resolve is ever reported — so the
|
|
268
|
+
* cell remains unusable as an exfiltration channel.
|
|
269
|
+
*/
|
|
270
|
+
function evidenceResolves(cwd, evidence) {
|
|
271
|
+
if (evidence.includes('\0') || node_path_1.default.isAbsolute(evidence))
|
|
272
|
+
return false;
|
|
273
|
+
const root = node_path_1.default.resolve(cwd);
|
|
274
|
+
const target = node_path_1.default.resolve(root, evidence);
|
|
275
|
+
// Strictly beneath: with the root allowance gone, `.` resolves to the root
|
|
276
|
+
// itself and is refused here, before any filesystem call happens at all.
|
|
277
|
+
if (!target.startsWith(root + node_path_1.default.sep))
|
|
278
|
+
return false;
|
|
279
|
+
try {
|
|
280
|
+
return node_fs_1.default.statSync(target).isFile();
|
|
281
|
+
}
|
|
282
|
+
catch {
|
|
283
|
+
return false;
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
/**
|
|
287
|
+
* D-35 status grammar, shared by all three registers. `Open` passes.
|
|
288
|
+
* `Closed (<evidence>)` passes when the evidence is a decision ID or an
|
|
289
|
+
* existing repo-relative file. A bare `Closed`, or evidence that does not
|
|
290
|
+
* resolve, is `closed_without_evidence` — "we talked about it" is not a close.
|
|
291
|
+
* Anything that is neither `Open` nor `Closed`-stemmed is `bad_enum`.
|
|
292
|
+
*/
|
|
293
|
+
function validateStatus(cwd, artifact, id, cell) {
|
|
294
|
+
const value = cell.trim();
|
|
295
|
+
if (value === ITEM_STATUS.OPEN)
|
|
296
|
+
return [];
|
|
297
|
+
if (!value.startsWith(ITEM_STATUS.CLOSED)) {
|
|
298
|
+
return [{ artifact, id, reason: 'bad_enum', field: 'status', cell: value }];
|
|
299
|
+
}
|
|
300
|
+
const match = CLOSED_EVIDENCE_RE.exec(value);
|
|
301
|
+
const evidence = match ? match[1].trim() : '';
|
|
302
|
+
if (evidence === '')
|
|
303
|
+
return [{ artifact, id, reason: 'closed_without_evidence', evidence: '' }];
|
|
304
|
+
if (EVIDENCE_DECISION_RE.test(evidence))
|
|
305
|
+
return [];
|
|
306
|
+
if (evidenceResolves(cwd, evidence))
|
|
307
|
+
return [];
|
|
308
|
+
return [{ artifact, id, reason: 'closed_without_evidence', evidence }];
|
|
309
|
+
}
|
|
310
|
+
/**
|
|
311
|
+
* D-36 existence check for an ID-bearing join cell (`Gates what?` / `Blocks`).
|
|
312
|
+
* `—` gates nothing and is handled by the caller. Every comma-separated token
|
|
313
|
+
* must both look like a requirement ID and be present in `_requirements.md`;
|
|
314
|
+
* a token that fails either test is reported with the token itself, never the
|
|
315
|
+
* surrounding cell prose.
|
|
316
|
+
*/
|
|
317
|
+
function validateRequirementRefs(artifact, id, cell, knownIds) {
|
|
318
|
+
const violations = [];
|
|
319
|
+
for (const token of cell.split(',').map(t => t.trim()).filter(Boolean)) {
|
|
320
|
+
if (!REQUIREMENT_ID_RE.test(token) || !knownIds.has(token)) {
|
|
321
|
+
violations.push({ artifact, id, reason: 'unknown_requirement_id', cell: token });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return violations;
|
|
325
|
+
}
|
|
326
|
+
/** Comma-separated `S\d+` list, or the em dash (D-40). */
|
|
327
|
+
function feedsAreValid(cell) {
|
|
328
|
+
if (cell === NOT_APPLICABLE || cell === '')
|
|
329
|
+
return true;
|
|
330
|
+
return cell.split(',').map(t => t.trim()).filter(Boolean).every(t => FEEDS_RE.test(t));
|
|
331
|
+
}
|
|
332
|
+
// ─── Register parsers ─────────────────────────────────────────────────────────
|
|
333
|
+
/**
|
|
334
|
+
* Parse `_research.md` into typed rows. Column order is positional
|
|
335
|
+
* (`ID | Item | Priority | Gates what? | Source | Owner | Status`) — header
|
|
336
|
+
* text is never matched, so a header respelling cannot silently change what
|
|
337
|
+
* is parsed. Only the shape/enum faults live here; the cross-artifact checks
|
|
338
|
+
* (status evidence, requirement existence) need `cwd` and the requirement ID
|
|
339
|
+
* set, so they run in `checkResearchTrack`.
|
|
340
|
+
*/
|
|
341
|
+
function parseResearchBacklog(content) {
|
|
342
|
+
const rows = [];
|
|
343
|
+
const violations = [];
|
|
344
|
+
if (typeof content !== 'string' || content === '')
|
|
345
|
+
return { rows, violations };
|
|
346
|
+
const seen = new Set();
|
|
347
|
+
for (const { line, num } of registerRows(content)) {
|
|
348
|
+
const cells = splitTableCells(line);
|
|
349
|
+
const id = cells[0] ?? '';
|
|
350
|
+
// A row is a register row when its first cell claims the family prefix.
|
|
351
|
+
// Rows belonging to some other table in the document are not this
|
|
352
|
+
// register's business; a claimed-but-malformed ID is (never warn-and-skip)
|
|
353
|
+
// — and it is reported by its position in the file, never by its content.
|
|
354
|
+
if (!id.startsWith('RS-'))
|
|
355
|
+
continue;
|
|
356
|
+
if (!RS_ID_RE.test(id)) {
|
|
357
|
+
// Reported by POSITION, never by content: the first cell has not passed
|
|
358
|
+
// any validation at this point, so it is arbitrary author prose that the
|
|
359
|
+
// report must not repeat (T-03-04). The row number the loop already
|
|
360
|
+
// holds locates it exactly as well.
|
|
361
|
+
violations.push({ artifact: RESEARCH_FILE, reason: 'bad_id', line: num });
|
|
362
|
+
continue;
|
|
363
|
+
}
|
|
364
|
+
if (seen.has(id)) {
|
|
365
|
+
violations.push({ artifact: RESEARCH_FILE, id, reason: 'duplicate_id' });
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
seen.add(id);
|
|
369
|
+
const priorityCell = cells[2] ?? '';
|
|
370
|
+
const gates = cells[3] ?? '';
|
|
371
|
+
const sourceCell = cells[4] ?? '';
|
|
372
|
+
const owner = cells[5] ?? '';
|
|
373
|
+
const status = cells[6] ?? '';
|
|
374
|
+
const priority = PRIORITY_LOOKUP.get(priorityCell.toLowerCase()) ?? '';
|
|
375
|
+
if (priority === '') {
|
|
376
|
+
violations.push({ artifact: RESEARCH_FILE, id, reason: 'bad_enum', field: 'priority', cell: priorityCell });
|
|
377
|
+
}
|
|
378
|
+
if (sourceCell !== NOT_APPLICABLE && sourceCell !== '' && !SOURCE_RE.test(sourceCell)) {
|
|
379
|
+
violations.push({ artifact: RESEARCH_FILE, id, reason: 'bad_enum', field: 'source', cell: sourceCell });
|
|
380
|
+
}
|
|
381
|
+
if (owner === '' && status.trim() === ITEM_STATUS.OPEN) {
|
|
382
|
+
violations.push({ artifact: RESEARCH_FILE, id, reason: 'missing_owner' });
|
|
383
|
+
}
|
|
384
|
+
// D-36: an item declared to gate the MVP that gates nothing is a
|
|
385
|
+
// contradiction, and it is a contradiction whether or not
|
|
386
|
+
// `_requirements.md` exists — so this check never depends on the ID set.
|
|
387
|
+
if (priority === PRIORITY.GATES_MVP && (gates === NOT_APPLICABLE || gates === '')) {
|
|
388
|
+
violations.push({ artifact: RESEARCH_FILE, id, reason: 'gates_nothing' });
|
|
389
|
+
}
|
|
390
|
+
rows.push({
|
|
391
|
+
id,
|
|
392
|
+
seq: parseInt(id.slice(3), 10),
|
|
393
|
+
item: cells[1] ?? '',
|
|
394
|
+
priority,
|
|
395
|
+
gates,
|
|
396
|
+
source: sourceCell,
|
|
397
|
+
owner,
|
|
398
|
+
status,
|
|
399
|
+
line: num,
|
|
400
|
+
});
|
|
401
|
+
}
|
|
402
|
+
violations.push(...validateSequence(RESEARCH_FILE, 'RS', rows.map(r => r.seq)));
|
|
403
|
+
return { rows, violations };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Parse `_risks.md` — the data-gathering register, NOT a risk log. Positional
|
|
407
|
+
* order `ID | Data to obtain | Affects | Blocks | Owner | Feeds | Status`.
|
|
408
|
+
*/
|
|
409
|
+
function parseRiskRegister(content) {
|
|
410
|
+
const rows = [];
|
|
411
|
+
const violations = [];
|
|
412
|
+
if (typeof content !== 'string' || content === '')
|
|
413
|
+
return { rows, violations };
|
|
414
|
+
const seen = new Set();
|
|
415
|
+
for (const { line, num } of registerRows(content)) {
|
|
416
|
+
const cells = splitTableCells(line);
|
|
417
|
+
const id = cells[0] ?? '';
|
|
418
|
+
if (!id.startsWith('DG-'))
|
|
419
|
+
continue;
|
|
420
|
+
if (!DG_ID_RE.test(id)) {
|
|
421
|
+
// As in `parseResearchBacklog`: a claimed-but-malformed ID is this
|
|
422
|
+
// register's business (never warn-and-skip), but the row is reported by
|
|
423
|
+
// position, never by content.
|
|
424
|
+
violations.push({ artifact: RISKS_FILE, reason: 'bad_id', line: num });
|
|
425
|
+
continue;
|
|
426
|
+
}
|
|
427
|
+
if (seen.has(id)) {
|
|
428
|
+
violations.push({ artifact: RISKS_FILE, id, reason: 'duplicate_id' });
|
|
429
|
+
continue;
|
|
430
|
+
}
|
|
431
|
+
seen.add(id);
|
|
432
|
+
const affectsCell = cells[2] ?? '';
|
|
433
|
+
const owner = cells[4] ?? '';
|
|
434
|
+
const feeds = cells[5] ?? '';
|
|
435
|
+
const status = cells[6] ?? '';
|
|
436
|
+
const affects = AFFECTS_LOOKUP.get(affectsCell.toLowerCase()) ?? '';
|
|
437
|
+
if (affects === '') {
|
|
438
|
+
violations.push({ artifact: RISKS_FILE, id, reason: 'bad_enum', field: 'affects', cell: affectsCell });
|
|
439
|
+
}
|
|
440
|
+
if (!feedsAreValid(feeds)) {
|
|
441
|
+
violations.push({ artifact: RISKS_FILE, id, reason: 'bad_enum', field: 'feeds', cell: feeds });
|
|
442
|
+
}
|
|
443
|
+
if (owner === '' && status.trim() === ITEM_STATUS.OPEN) {
|
|
444
|
+
violations.push({ artifact: RISKS_FILE, id, reason: 'missing_owner' });
|
|
445
|
+
}
|
|
446
|
+
rows.push({
|
|
447
|
+
id,
|
|
448
|
+
seq: parseInt(id.slice(3), 10),
|
|
449
|
+
data: cells[1] ?? '',
|
|
450
|
+
affects,
|
|
451
|
+
blocks: cells[3] ?? '',
|
|
452
|
+
owner,
|
|
453
|
+
feeds,
|
|
454
|
+
status,
|
|
455
|
+
line: num,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
458
|
+
violations.push(...validateSequence(RISKS_FILE, 'DG', rows.map(r => r.seq)));
|
|
459
|
+
return { rows, violations };
|
|
460
|
+
}
|
|
461
|
+
/**
|
|
462
|
+
* Parse `_sparring.md` — a BLOCK parser, not a table parser (D5). A position
|
|
463
|
+
* evolves in place, including reversals, so its fields are multi-sentence prose
|
|
464
|
+
* that a pipe table cannot hold; the H3 block form also removes `|`-in-prose as
|
|
465
|
+
* a parser-fragility class entirely. `SPARRING_HEAD_RE` proposes a position,
|
|
466
|
+
* `SPARRING_FIELD_RE` fills it, and the next `### ` or EOF closes it.
|
|
467
|
+
*
|
|
468
|
+
* Every `### ` line is handled by ONE branch, which decides in this order: the
|
|
469
|
+
* head regex is permissive, so the candidate token is taken from its capture or
|
|
470
|
+
* — when the heading is too malformed to match at all — from the first word
|
|
471
|
+
* after the prefix. A token that does not claim the `SP-` family is not this
|
|
472
|
+
* register's business and is silently ignored (an appendix H3 must never become
|
|
473
|
+
* a false fault). A token that DOES claim the family and then fails either the
|
|
474
|
+
* full heading shape or `SP_ID_RE` is a `bad_id` — never warn-and-skip (D-08).
|
|
475
|
+
* Only a token passing both opens a position.
|
|
476
|
+
*
|
|
477
|
+
* The violation reports the row position ONLY: at that point the token has
|
|
478
|
+
* passed no validation, so there is nothing about it safe to quote (T-03-04),
|
|
479
|
+
* which is the same content-free shape the two table registers emit.
|
|
480
|
+
*/
|
|
481
|
+
function parseSparringTracker(content) {
|
|
482
|
+
const rows = [];
|
|
483
|
+
const violations = [];
|
|
484
|
+
if (typeof content !== 'string' || content === '')
|
|
485
|
+
return { rows, violations };
|
|
486
|
+
const seen = new Set();
|
|
487
|
+
const lines = content.split(/\r?\n/);
|
|
488
|
+
let current = null;
|
|
489
|
+
let currentValid = false;
|
|
490
|
+
const flush = () => {
|
|
491
|
+
if (current && currentValid)
|
|
492
|
+
rows.push(current);
|
|
493
|
+
current = null;
|
|
494
|
+
currentValid = false;
|
|
495
|
+
};
|
|
496
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
497
|
+
const line = lines[i];
|
|
498
|
+
if (line.startsWith('### ')) {
|
|
499
|
+
const head = SPARRING_HEAD_RE.exec(line);
|
|
500
|
+
// When the heading is too malformed for the head regex, the candidate is
|
|
501
|
+
// still recoverable as the first word after the prefix — which is what
|
|
502
|
+
// lets `### SP-01 Retention window` be caught rather than dropped.
|
|
503
|
+
const token = head !== null ? head[1] : (line.slice(4).trim().split(/\s+/)[0] ?? '');
|
|
504
|
+
// Any `### ` closes the open block, whoever it belongs to.
|
|
505
|
+
flush();
|
|
506
|
+
if (!token.startsWith('SP-'))
|
|
507
|
+
continue;
|
|
508
|
+
if (head === null || !SP_ID_RE.test(token)) {
|
|
509
|
+
violations.push({ artifact: SPARRING_FILE, reason: 'bad_id', line: i + 1 });
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
const id = token;
|
|
513
|
+
if (seen.has(id)) {
|
|
514
|
+
violations.push({ artifact: SPARRING_FILE, id, reason: 'duplicate_id' });
|
|
515
|
+
continue;
|
|
516
|
+
}
|
|
517
|
+
seen.add(id);
|
|
518
|
+
current = {
|
|
519
|
+
id,
|
|
520
|
+
seq: parseInt(id.slice(3), 10),
|
|
521
|
+
question: head[2],
|
|
522
|
+
position: '',
|
|
523
|
+
decision: '',
|
|
524
|
+
feeds: '',
|
|
525
|
+
status: '',
|
|
526
|
+
line: i + 1,
|
|
527
|
+
};
|
|
528
|
+
currentValid = true;
|
|
529
|
+
continue;
|
|
530
|
+
}
|
|
531
|
+
if (!current)
|
|
532
|
+
continue;
|
|
533
|
+
const field = SPARRING_FIELD_RE.exec(line);
|
|
534
|
+
if (!field)
|
|
535
|
+
continue;
|
|
536
|
+
const value = field[2].trim();
|
|
537
|
+
if (field[1] === 'Position')
|
|
538
|
+
current.position = value;
|
|
539
|
+
else if (field[1] === 'DECISION')
|
|
540
|
+
current.decision = value;
|
|
541
|
+
else if (field[1] === 'Feeds')
|
|
542
|
+
current.feeds = value;
|
|
543
|
+
else if (field[1] === 'Status')
|
|
544
|
+
current.status = value;
|
|
545
|
+
}
|
|
546
|
+
flush();
|
|
547
|
+
for (const row of rows) {
|
|
548
|
+
if (!feedsAreValid(row.feeds)) {
|
|
549
|
+
violations.push({ artifact: SPARRING_FILE, id: row.id, reason: 'bad_enum', field: 'feeds', cell: row.feeds });
|
|
550
|
+
}
|
|
551
|
+
}
|
|
552
|
+
violations.push(...validateSequence(SPARRING_FILE, 'SP', rows.map(r => r.seq)));
|
|
553
|
+
return { rows, violations };
|
|
554
|
+
}
|
|
555
|
+
// ─── Conditional markers (TRACE-05) ───────────────────────────────────────────
|
|
556
|
+
/**
|
|
557
|
+
* Find every well-formed `⚠️ CONDITIONAL —` marker. Split by line FIRST, then
|
|
558
|
+
* apply the anchored regex to each single line (T-03-02). D-45 requires the
|
|
559
|
+
* marker to own its whole line, so a marker mentioned mid-sentence in prose is
|
|
560
|
+
* not a marker and is deliberately not returned.
|
|
561
|
+
*/
|
|
562
|
+
function findConditionalMarkers(content) {
|
|
563
|
+
const markers = [];
|
|
564
|
+
if (typeof content !== 'string' || content === '')
|
|
565
|
+
return markers;
|
|
566
|
+
const lines = content.split(/\r?\n/);
|
|
567
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
568
|
+
const trimmed = lines[i].trim();
|
|
569
|
+
if (!trimmed.startsWith(CONDITIONAL_MARKER))
|
|
570
|
+
continue;
|
|
571
|
+
const match = CONDITIONAL_RE.exec(trimmed);
|
|
572
|
+
if (!match)
|
|
573
|
+
continue;
|
|
574
|
+
markers.push({ id: match[1], line: i + 1, description: match[2] ? match[2].slice(3) : '' });
|
|
575
|
+
}
|
|
576
|
+
return markers;
|
|
577
|
+
}
|
|
578
|
+
/**
|
|
579
|
+
* Lines that CLAIM to be markers but fail the anchored grammar — an
|
|
580
|
+
* unrecognised ID family, a typo'd ID, a stray suffix. Returned as sanitized
|
|
581
|
+
* literals (`<marker> <bounded first token>`) so a malformed marker can never
|
|
582
|
+
* hide behind its own malformedness, while its trailing prose still cannot
|
|
583
|
+
* leak into the report (T-03-04).
|
|
584
|
+
*
|
|
585
|
+
* First-token-only is NOT sufficient on its own: the first whitespace-
|
|
586
|
+
* delimited token is itself unbounded author text, and a hyphen-joined client
|
|
587
|
+
* phrase is a single token (reproduced in `03-REVIEW.md` § CR-01). So the
|
|
588
|
+
* token is length-bounded here as well, and the assembled literal passes
|
|
589
|
+
* through `sanitizeEcho` again at the `formatViolation` chokepoint.
|
|
590
|
+
*/
|
|
591
|
+
function findMalformedMarkers(content) {
|
|
592
|
+
const malformed = [];
|
|
593
|
+
if (typeof content !== 'string' || content === '')
|
|
594
|
+
return malformed;
|
|
595
|
+
for (const line of content.split(/\r?\n/)) {
|
|
596
|
+
const trimmed = line.trim();
|
|
597
|
+
if (!trimmed.startsWith(CONDITIONAL_MARKER))
|
|
598
|
+
continue;
|
|
599
|
+
if (CONDITIONAL_RE.test(trimmed))
|
|
600
|
+
continue;
|
|
601
|
+
const token = trimmed.slice(CONDITIONAL_MARKER.length).trim().split(/\s+/)[0] ?? '';
|
|
602
|
+
malformed.push(`${CONDITIONAL_MARKER} ${sanitizeEcho(token)}`.trim());
|
|
603
|
+
}
|
|
604
|
+
return malformed;
|
|
605
|
+
}
|
|
606
|
+
/**
|
|
607
|
+
* The `conditional:` frontmatter value, normalized to a string array — read as
|
|
608
|
+
* an OWN property or not at all.
|
|
609
|
+
*
|
|
610
|
+
* D-12 inverted: frontmatter is parsed from an untrusted session file, so
|
|
611
|
+
* `conditional:` is derived state to be validated, never trusted. The concrete
|
|
612
|
+
* attack (`03-REVIEW.md` § CR-03, reproduced): `extractFrontmatter`'s key regex
|
|
613
|
+
* `[a-zA-Z0-9_-]+` admits `__proto__`, and assigning that key onto a plain
|
|
614
|
+
* object literal SETS THE PROTOTYPE rather than creating an own property — so a
|
|
615
|
+
* `__proto__:` block nesting `conditional:` satisfies an unguarded lookup,
|
|
616
|
+
* silently passing the drift gate AND writing its own value into the report's
|
|
617
|
+
* `extra` array. An inherited `conditional` must therefore never satisfy this
|
|
618
|
+
* read. This is the standing project rule in `.claude/CLAUDE.md`
|
|
619
|
+
* ("Prototype pollution guards in config access"), and the same
|
|
620
|
+
* parsed-content-never-touches-an-object-literal discipline the sibling parser
|
|
621
|
+
* documents at `src/discovery-rollup.cts:189-191`.
|
|
622
|
+
*/
|
|
623
|
+
function conditionalFrontmatter(front) {
|
|
624
|
+
if (!Object.prototype.hasOwnProperty.call(front, 'conditional'))
|
|
625
|
+
return [];
|
|
626
|
+
const value = front.conditional;
|
|
627
|
+
if (Array.isArray(value))
|
|
628
|
+
return value.map(v => String(v).trim()).filter(Boolean);
|
|
629
|
+
if (typeof value === 'string' && value.trim() !== '')
|
|
630
|
+
return [value.trim()];
|
|
631
|
+
return [];
|
|
632
|
+
}
|
|
633
|
+
// ─── Aggregation ──────────────────────────────────────────────────────────────
|
|
634
|
+
/**
|
|
635
|
+
* Aggregate the full research-track check for a project root. Collect-all-then-
|
|
636
|
+
* fail (D-08 carried forward): every fault class runs, all violations are
|
|
637
|
+
* accumulated, and the result is returned once. This layer never throws — an
|
|
638
|
+
* unreadable file degrades to "not present" rather than crashing the gate.
|
|
639
|
+
*
|
|
640
|
+
* Never-blocks (D-43): an `Open` item — including an `Open` `Gates-MVP` item —
|
|
641
|
+
* is NOT a violation. It is counted into `open_by_priority` so Session 13 can
|
|
642
|
+
* render "N sections conditional on M open research items" and sign off anyway.
|
|
643
|
+
*/
|
|
644
|
+
function checkResearchTrack(cwd) {
|
|
645
|
+
const violations = [];
|
|
646
|
+
const dir = node_path_1.default.join(cwd, DISCOVERY_DIR);
|
|
647
|
+
let names = [];
|
|
648
|
+
try {
|
|
649
|
+
names = node_fs_1.default.readdirSync(dir);
|
|
650
|
+
}
|
|
651
|
+
catch {
|
|
652
|
+
names = []; // no discovery package at all — graceful arm (D-42)
|
|
653
|
+
}
|
|
654
|
+
const present = new Set(names);
|
|
655
|
+
// The `present.has(file)` early return is what discriminates the two
|
|
656
|
+
// conditions, and it must stay that way: ABSENT is the D-42 graceful arm and
|
|
657
|
+
// is silent, while PRESENT-BUT-UNREADABLE (EACCES, EISDIR, a dangling
|
|
658
|
+
// symlink) is a fault. Re-collapsing them would let the gate exit 0 having
|
|
659
|
+
// validated nothing at all — a fail-open on a structural verifier (WR-05).
|
|
660
|
+
// The caller does not add the file to `artifactsChecked` either: an
|
|
661
|
+
// unreadable artifact was not checked, and saying it was would be a second,
|
|
662
|
+
// quieter overstatement.
|
|
663
|
+
const readArtifact = (file) => {
|
|
664
|
+
if (!present.has(file))
|
|
665
|
+
return null;
|
|
666
|
+
try {
|
|
667
|
+
return node_fs_1.default.readFileSync(node_path_1.default.join(dir, file), 'utf-8');
|
|
668
|
+
}
|
|
669
|
+
catch {
|
|
670
|
+
violations.push({ artifact: file, reason: 'artifact_unreadable' });
|
|
671
|
+
return null;
|
|
672
|
+
}
|
|
673
|
+
};
|
|
674
|
+
// Each register is read independently, so `_risks.md` present while
|
|
675
|
+
// `_research.md` is absent checks the one it has (graceful arm, per file).
|
|
676
|
+
const artifactsChecked = [];
|
|
677
|
+
const researchContent = readArtifact(RESEARCH_FILE);
|
|
678
|
+
const risksContent = readArtifact(RISKS_FILE);
|
|
679
|
+
const sparringContent = readArtifact(SPARRING_FILE);
|
|
680
|
+
if (researchContent !== null)
|
|
681
|
+
artifactsChecked.push(RESEARCH_FILE);
|
|
682
|
+
if (risksContent !== null)
|
|
683
|
+
artifactsChecked.push(RISKS_FILE);
|
|
684
|
+
if (sparringContent !== null)
|
|
685
|
+
artifactsChecked.push(SPARRING_FILE);
|
|
686
|
+
const research = parseResearchBacklog(researchContent ?? '');
|
|
687
|
+
const risks = parseRiskRegister(risksContent ?? '');
|
|
688
|
+
const sparring = parseSparringTracker(sparringContent ?? '');
|
|
689
|
+
violations.push(...research.violations, ...risks.violations, ...sparring.violations);
|
|
690
|
+
// `_requirements.md` is owned by `discovery rollup`; this gate consumes its
|
|
691
|
+
// single exported reader and NOTHING else. A grammar fault there is reported
|
|
692
|
+
// once, by name, pointing at the owning gate — never re-enumerated here.
|
|
693
|
+
let knownIds = null;
|
|
694
|
+
let requirements = 'absent';
|
|
695
|
+
const requirementsContent = readArtifact(REQUIREMENTS_FILE);
|
|
696
|
+
if (requirementsContent !== null) {
|
|
697
|
+
const parsed = rollup.parseRequirementsArtifact(requirementsContent);
|
|
698
|
+
if (parsed.ok) {
|
|
699
|
+
knownIds = new Set(parsed.rows.map(r => r.id));
|
|
700
|
+
requirements = 'ok';
|
|
701
|
+
}
|
|
702
|
+
else {
|
|
703
|
+
requirements = 'unparseable';
|
|
704
|
+
violations.push({ artifact: REQUIREMENTS_FILE, reason: 'requirements_unparseable' });
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
// Status is a cross-artifact check (evidence paths resolve against cwd), and
|
|
708
|
+
// D-36 existence needs the requirement ID set — both run here, not in the
|
|
709
|
+
// pure parsers.
|
|
710
|
+
for (const row of research.rows) {
|
|
711
|
+
violations.push(...validateStatus(cwd, RESEARCH_FILE, row.id, row.status));
|
|
712
|
+
if (knownIds !== null && row.gates !== NOT_APPLICABLE && row.gates !== '') {
|
|
713
|
+
violations.push(...validateRequirementRefs(RESEARCH_FILE, row.id, row.gates, knownIds));
|
|
714
|
+
}
|
|
715
|
+
}
|
|
716
|
+
for (const row of risks.rows) {
|
|
717
|
+
violations.push(...validateStatus(cwd, RISKS_FILE, row.id, row.status));
|
|
718
|
+
if (knownIds !== null && row.blocks !== NOT_APPLICABLE && row.blocks !== '') {
|
|
719
|
+
violations.push(...validateRequirementRefs(RISKS_FILE, row.id, row.blocks, knownIds));
|
|
720
|
+
}
|
|
721
|
+
}
|
|
722
|
+
for (const row of sparring.rows) {
|
|
723
|
+
violations.push(...validateStatus(cwd, SPARRING_FILE, row.id, row.status));
|
|
724
|
+
}
|
|
725
|
+
// Marker targets resolve against the RS/DG registers — the only two families
|
|
726
|
+
// the marker grammar admits. A Map, never an object literal keyed on parsed
|
|
727
|
+
// content (T-03-03).
|
|
728
|
+
const statuses = new Map();
|
|
729
|
+
for (const row of research.rows)
|
|
730
|
+
statuses.set(row.id, row.status.trim());
|
|
731
|
+
for (const row of risks.rows)
|
|
732
|
+
statuses.set(row.id, row.status.trim());
|
|
733
|
+
const scanned = [
|
|
734
|
+
...names.filter(n => SESSION_FILE_RE.test(n)).sort(),
|
|
735
|
+
...(present.has(CONTEXT_FILE) ? [CONTEXT_FILE] : []),
|
|
736
|
+
];
|
|
737
|
+
let conditionalSections = 0;
|
|
738
|
+
for (const file of scanned) {
|
|
739
|
+
const content = readArtifact(file);
|
|
740
|
+
if (content === null)
|
|
741
|
+
continue;
|
|
742
|
+
for (const marker of findMalformedMarkers(content)) {
|
|
743
|
+
violations.push({ file, reason: 'unknown_marker_target', marker });
|
|
744
|
+
}
|
|
745
|
+
const markers = findConditionalMarkers(content);
|
|
746
|
+
conditionalSections += markers.length;
|
|
747
|
+
const bodyIds = [];
|
|
748
|
+
for (const marker of markers) {
|
|
749
|
+
if (!bodyIds.includes(marker.id))
|
|
750
|
+
bodyIds.push(marker.id);
|
|
751
|
+
const status = statuses.get(marker.id);
|
|
752
|
+
const literal = `${CONDITIONAL_MARKER} ${marker.id}`;
|
|
753
|
+
if (status === undefined) {
|
|
754
|
+
violations.push({ file, reason: 'unknown_marker_target', marker: literal });
|
|
755
|
+
}
|
|
756
|
+
else if (status.startsWith(ITEM_STATUS.CLOSED)) {
|
|
757
|
+
violations.push({ file, reason: 'stale_marker', marker: literal });
|
|
758
|
+
}
|
|
759
|
+
}
|
|
760
|
+
// D-12 inversion: the `conditional:` frontmatter is derived state, so it is
|
|
761
|
+
// validated rather than trusted. A file with no frontmatter block at all is
|
|
762
|
+
// not drift-checked (graceful arm — it has no derived state to disagree).
|
|
763
|
+
//
|
|
764
|
+
// OWN properties only, for the same reason `conditionalFrontmatter` guards
|
|
765
|
+
// its read: a file whose only frontmatter content is a prototype-shaped key
|
|
766
|
+
// declares nothing, so it has no own derived state and takes the same
|
|
767
|
+
// graceful arm as a file with no frontmatter block at all. That is the
|
|
768
|
+
// honest outcome — the alternative is to treat an injected prototype as a
|
|
769
|
+
// declaration.
|
|
770
|
+
const front = fm.extractFrontmatter(content);
|
|
771
|
+
if (Object.getOwnPropertyNames(front).length === 0)
|
|
772
|
+
continue;
|
|
773
|
+
const declared = conditionalFrontmatter(front);
|
|
774
|
+
const missing = bodyIds.filter(id => !declared.includes(id));
|
|
775
|
+
const extra = declared.filter(id => !bodyIds.includes(id));
|
|
776
|
+
if (missing.length > 0 || extra.length > 0) {
|
|
777
|
+
violations.push({ file, reason: 'conditional_frontmatter_drift', missing, extra });
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
const openByPriority = {};
|
|
781
|
+
for (const value of Object.values(PRIORITY))
|
|
782
|
+
openByPriority[value] = 0;
|
|
783
|
+
const open = { RS: 0, DG: 0, SP: 0 };
|
|
784
|
+
const closed = { RS: 0, DG: 0, SP: 0 };
|
|
785
|
+
const tally = (family, status) => {
|
|
786
|
+
const value = status.trim();
|
|
787
|
+
if (value === ITEM_STATUS.OPEN)
|
|
788
|
+
open[family] += 1;
|
|
789
|
+
else if (value.startsWith(ITEM_STATUS.CLOSED))
|
|
790
|
+
closed[family] += 1;
|
|
791
|
+
};
|
|
792
|
+
for (const row of research.rows) {
|
|
793
|
+
tally('RS', row.status);
|
|
794
|
+
if (row.status.trim() === ITEM_STATUS.OPEN && row.priority !== '') {
|
|
795
|
+
openByPriority[row.priority] += 1;
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
for (const row of risks.rows)
|
|
799
|
+
tally('DG', row.status);
|
|
800
|
+
for (const row of sparring.rows)
|
|
801
|
+
tally('SP', row.status);
|
|
802
|
+
const summary = {
|
|
803
|
+
artifacts_checked: artifactsChecked,
|
|
804
|
+
open_by_priority: openByPriority,
|
|
805
|
+
open,
|
|
806
|
+
closed,
|
|
807
|
+
conditional_sections: conditionalSections,
|
|
808
|
+
requirements,
|
|
809
|
+
};
|
|
810
|
+
if (violations.length > 0)
|
|
811
|
+
return { ok: false, violations, ...summary };
|
|
812
|
+
return { ok: true, ...summary };
|
|
813
|
+
}
|
|
814
|
+
// ─── CLI adapter ──────────────────────────────────────────────────────────────
|
|
815
|
+
/**
|
|
816
|
+
* Every control character the whitespace collapse below does NOT handle: the C0
|
|
817
|
+
* plane minus `\t\n\v\f\r`, DEL, and the whole C1 plane.
|
|
818
|
+
*
|
|
819
|
+
* Stripped BEFORE the collapse, and this ordering is the whole point.
|
|
820
|
+
* JavaScript `\s` matches only the five C0 whitespace characters plus the
|
|
821
|
+
* Unicode space separators; it does NOT match ESC (`\x1b`), BEL (`\x07`),
|
|
822
|
+
* BS (`\x08`) or NUL. `formatViolation` output reaches `error()`, which in the
|
|
823
|
+
* default non-`--json-errors` mode writes `'Error: ' + message` to fd 2
|
|
824
|
+
* VERBATIM, which is the mode a human operator reads in a terminal. A complete
|
|
825
|
+
* OSC-52 clipboard-write sequence fits inside `ECHO_MAX_CHARS`, and an
|
|
826
|
+
* UNTERMINATED OSC string consumes the report text after it and continues into
|
|
827
|
+
* the next violation's cell. Register prose is derived from client documents:
|
|
828
|
+
* treating it as untrusted for LENGTH while treating it as trusted for BYTES
|
|
829
|
+
* was the gap. Identical to the screens engine's constant, deliberately — the
|
|
830
|
+
* two chokepoints defend the same fd-2 surface and must not drift apart.
|
|
831
|
+
*/
|
|
832
|
+
const CONTROL_CHARS_RE = /[\x00-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g;
|
|
833
|
+
/**
|
|
834
|
+
* The single chokepoint through which every author-supplied detail reaches
|
|
835
|
+
* output. Strip the control planes, collapse whitespace, trim, and cap at
|
|
836
|
+
* `ECHO_MAX_CHARS`, appending an ellipsis when the value was longer.
|
|
837
|
+
*
|
|
838
|
+
* Both the strip and the collapse are load-bearing and must run BEFORE the
|
|
839
|
+
* slice: together they guarantee a cell can never inject a newline, carriage
|
|
840
|
+
* return, tab or terminal escape sequence into the one-line-per-violation
|
|
841
|
+
* report format, regardless of where the slice happens to land.
|
|
842
|
+
*/
|
|
843
|
+
function sanitizeEcho(value) {
|
|
844
|
+
// A non-string is not author data this function can meaningfully bound, and
|
|
845
|
+
// default stringification would emit `[object Object]` — so it renders as
|
|
846
|
+
// nothing rather than as a placeholder that looks like content.
|
|
847
|
+
const raw = typeof value === 'string' ? value : '';
|
|
848
|
+
const collapsed = raw.replace(CONTROL_CHARS_RE, '').replace(/\s+/g, ' ').trim();
|
|
849
|
+
if (collapsed.length <= ECHO_MAX_CHARS)
|
|
850
|
+
return collapsed;
|
|
851
|
+
return `${collapsed.slice(0, ECHO_MAX_CHARS)}…`;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* One content-free line per violation, two-space indented (T-03-04).
|
|
855
|
+
*
|
|
856
|
+
* This is the SINGLE chokepoint for echoed detail: every author-supplied value
|
|
857
|
+
* below routes through `sanitizeEcho`, so a new reason code added to the union
|
|
858
|
+
* inherits the confidentiality bound by construction rather than by the author
|
|
859
|
+
* of that code remembering it. The reason code and the enum field name are
|
|
860
|
+
* deliberately NOT sanitized — both come from closed unions declared in this
|
|
861
|
+
* module and are not author data.
|
|
862
|
+
*
|
|
863
|
+
* `bad_id` renders as locator-plus-reason only. Its cell has passed no
|
|
864
|
+
* validation at all, so there is nothing about it that is safe to quote; the
|
|
865
|
+
* `<artifact>:<row>` locator finds the row instead.
|
|
866
|
+
*/
|
|
867
|
+
function formatViolation(v) {
|
|
868
|
+
const where = v.artifact ?? v.file ?? '';
|
|
869
|
+
const locator = v.line === undefined ? where : `${where}:${v.line}`;
|
|
870
|
+
let detail = '';
|
|
871
|
+
switch (v.reason) {
|
|
872
|
+
case 'unknown_requirement_id':
|
|
873
|
+
detail = `${sanitizeEcho(v.id)} → ${sanitizeEcho(v.cell)}`;
|
|
874
|
+
break;
|
|
875
|
+
case 'bad_enum':
|
|
876
|
+
detail = `${sanitizeEcho(v.id)} ${v.field}: ${sanitizeEcho(v.cell)}`;
|
|
877
|
+
break;
|
|
878
|
+
case 'bad_id':
|
|
879
|
+
detail = '';
|
|
880
|
+
break;
|
|
881
|
+
case 'unknown_marker_target':
|
|
882
|
+
case 'stale_marker':
|
|
883
|
+
detail = sanitizeEcho(v.marker);
|
|
884
|
+
break;
|
|
885
|
+
case 'requirements_unparseable':
|
|
886
|
+
detail = 'run: discovery rollup --check';
|
|
887
|
+
break;
|
|
888
|
+
case 'conditional_frontmatter_drift': {
|
|
889
|
+
// Each ELEMENT is bounded, not just the joined array: an injected or
|
|
890
|
+
// mistyped `conditional:` entry is author data of arbitrary length.
|
|
891
|
+
const missing = (v.missing ?? []).map(sanitizeEcho).join(', ');
|
|
892
|
+
const extra = (v.extra ?? []).map(sanitizeEcho).join(', ');
|
|
893
|
+
detail = `missing: [${missing}], extra: [${extra}]`;
|
|
894
|
+
break;
|
|
895
|
+
}
|
|
896
|
+
default:
|
|
897
|
+
detail = sanitizeEcho(v.id ?? '');
|
|
898
|
+
}
|
|
899
|
+
return detail === '' ? ` ${locator}: ${v.reason}` : ` ${locator}: ${v.reason} (${detail})`;
|
|
900
|
+
}
|
|
901
|
+
/**
|
|
902
|
+
* CLI adapter for `pp-tools discovery research-check` (dotted form
|
|
903
|
+
* `pp-tools query discovery.research-check`).
|
|
904
|
+
*
|
|
905
|
+
* Exit semantics (D-42/D-43): any structural violation exits non-zero via
|
|
906
|
+
* ERROR_REASON.DISCOVERY_RESEARCH_CHECK_FAILED, naming each violation by
|
|
907
|
+
* artifact and reason. Unfinished research is NOT a violation — an `Open`
|
|
908
|
+
* `Gates-MVP` item exits 0 and is reported under `open_by_priority`. The
|
|
909
|
+
* command accepts no positional path argument at all: the convention paths
|
|
910
|
+
* under `.planning/discovery/` resolve against cwd, which eliminates the
|
|
911
|
+
* traversal surface rather than guarding it (T-03-01).
|
|
912
|
+
*/
|
|
913
|
+
function cmdDiscoveryResearchCheck(cwd, args, raw) {
|
|
914
|
+
// args arrive shaped ['discovery', 'research-check', ...]; global output
|
|
915
|
+
// flags are consumed by the dispatcher, so anything left — a positional
|
|
916
|
+
// path or an unknown flag alike — is a usage error.
|
|
917
|
+
if (args.slice(2).length > 0) {
|
|
918
|
+
error('Usage: pp-tools discovery research-check', ERROR_REASON.USAGE);
|
|
919
|
+
return; // unreachable — error() exits
|
|
920
|
+
}
|
|
921
|
+
const result = checkResearchTrack(cwd);
|
|
922
|
+
if (!result.ok) {
|
|
923
|
+
const total = result.violations.length;
|
|
924
|
+
const rendered = result.violations.slice(0, MAX_REPORTED_VIOLATIONS).map(formatViolation);
|
|
925
|
+
if (total > MAX_REPORTED_VIOLATIONS) {
|
|
926
|
+
rendered.push(` … and ${total - MAX_REPORTED_VIOLATIONS} more`);
|
|
927
|
+
}
|
|
928
|
+
// D-43 holds on the failure path too: "the report tells you exactly what is
|
|
929
|
+
// still outstanding" (workflows/discovery-sprint/session-13-gates.md). A
|
|
930
|
+
// register with one malformed cell must not lose its outstanding-work
|
|
931
|
+
// report — that is exactly the state where an operator needs both halves.
|
|
932
|
+
// Emitted on stdout BEFORE the envelope goes to stderr, so the ordering is
|
|
933
|
+
// deterministic: `output` writes fd 1 synchronously and `error` exits.
|
|
934
|
+
//
|
|
935
|
+
// This payload is a second output surface and inherits the content-free
|
|
936
|
+
// contract (T-03-04) by construction: counts, closed-vocabulary enum keys
|
|
937
|
+
// and artifact filenames only, never a register cell.
|
|
938
|
+
output({
|
|
939
|
+
ok: false,
|
|
940
|
+
violations: total,
|
|
941
|
+
artifacts_checked: result.artifacts_checked,
|
|
942
|
+
open_by_priority: result.open_by_priority,
|
|
943
|
+
open: result.open,
|
|
944
|
+
closed: result.closed,
|
|
945
|
+
conditional_sections: result.conditional_sections,
|
|
946
|
+
requirements: result.requirements,
|
|
947
|
+
}, raw);
|
|
948
|
+
error(`discovery research-check: ${total} violation(s):\n${rendered.join('\n')}`, ERROR_REASON.DISCOVERY_RESEARCH_CHECK_FAILED);
|
|
949
|
+
return; // unreachable — error() exits
|
|
950
|
+
}
|
|
951
|
+
output({
|
|
952
|
+
ok: true,
|
|
953
|
+
artifacts_checked: result.artifacts_checked,
|
|
954
|
+
open_by_priority: result.open_by_priority,
|
|
955
|
+
open: result.open,
|
|
956
|
+
closed: result.closed,
|
|
957
|
+
conditional_sections: result.conditional_sections,
|
|
958
|
+
requirements: result.requirements,
|
|
959
|
+
}, raw);
|
|
960
|
+
}
|
|
961
|
+
module.exports = {
|
|
962
|
+
PRIORITY,
|
|
963
|
+
ITEM_STATUS,
|
|
964
|
+
AFFECTS,
|
|
965
|
+
CONDITIONAL_MARKER,
|
|
966
|
+
ECHO_MAX_CHARS,
|
|
967
|
+
VIOLATION_REASONS,
|
|
968
|
+
parseResearchBacklog,
|
|
969
|
+
parseRiskRegister,
|
|
970
|
+
parseSparringTracker,
|
|
971
|
+
// Exported so the bounded-span floor can be falsified through THIS engine
|
|
972
|
+
// rather than only through the shared leaf. All three family grammars are two
|
|
973
|
+
// digits, so the out-of-band arm is unreachable end to end and a test can only
|
|
974
|
+
// reach it by calling the validator with the numbers directly (AR-04-02).
|
|
975
|
+
validateSequence,
|
|
976
|
+
findConditionalMarkers,
|
|
977
|
+
checkResearchTrack,
|
|
978
|
+
cmdDiscoveryResearchCheck,
|
|
979
|
+
};
|