@pareto4/pp-core 1.12.1 → 1.17.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/plugin.json +3 -3
- package/README.md +26 -9
- package/agents/pp-code-reviewer.md +3 -2
- package/agents/pp-debug-session-manager.md +9 -6
- package/agents/pp-executor.md +7 -21
- package/agents/pp-plan-checker.md +4 -4
- package/agents/pp-planner.md +23 -9
- package/agents/pp-vault-reviewer.md +274 -0
- package/agents/pp-verifier.md +7 -3
- package/bin/install.js +10 -3
- package/commands/pp-execute-phase.md +1 -3
- package/commands/pp-ns-review.md +2 -1
- package/commands/pp-plan-phase.md +1 -2
- package/commands/pp-vault-review.md +35 -0
- package/commands/pp-verify-work.md +1 -2
- package/gemini-extension.json +1 -1
- package/hooks/dist/pp-check-update.js +0 -0
- package/hooks/dist/pp-cursor-session-start.js +1 -1
- package/hooks/dist/pp-statusline.js +1 -1
- package/hooks/dist/pp-update-banner.js +0 -0
- package/hooks/pp-check-update.js +0 -0
- package/hooks/pp-cursor-session-start.js +1 -1
- package/hooks/pp-statusline.js +1 -1
- package/hooks/pp-update-banner.js +0 -0
- package/package.json +14 -19
- package/pareto-skills/_shared/create-project-board.sh +0 -0
- package/pareto-skills/pp-project-setup/SKILL.md +1 -1
- package/pp-core/bin/check-latest-version.cjs +0 -0
- package/pp-core/bin/detect-build-commands.sh +2 -2
- package/pp-core/bin/lib/capability-activation.cjs +83 -9
- package/pp-core/bin/lib/capability-registry.cjs +526 -22
- package/pp-core/bin/lib/capability-state.cjs +160 -4
- package/pp-core/bin/lib/capability-writer.cjs +19 -5
- package/pp-core/bin/lib/check-command-router.cjs +3867 -274
- package/pp-core/bin/lib/clusters.cjs +4 -0
- package/pp-core/bin/lib/command-aliases.cjs +0 -28
- package/pp-core/bin/lib/commands.cjs +9 -4
- package/pp-core/bin/lib/compose-bindings.cjs +253 -76
- package/pp-core/bin/lib/config-loader.cjs +7 -103
- package/pp-core/bin/lib/config-schema.cjs +1 -1
- package/pp-core/bin/lib/config.cjs +154 -62
- package/pp-core/bin/lib/decisions.cjs +104 -27
- package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
- package/pp-core/bin/lib/frontmatter.cjs +1 -1
- package/pp-core/bin/lib/git-base-branch.cjs +138 -10
- package/pp-core/bin/lib/git-provider.cjs +4 -4
- package/pp-core/bin/lib/init-command-router.cjs +5 -8
- package/pp-core/bin/lib/init-contract.cjs +653 -629
- package/pp-core/bin/lib/init-interactive.cjs +77 -21
- package/pp-core/bin/lib/init.cjs +24 -51
- package/pp-core/bin/lib/install-profiles.cjs +1 -1
- package/pp-core/bin/lib/io.cjs +8 -0
- package/pp-core/bin/lib/loop-resolver.cjs +169 -47
- package/pp-core/bin/lib/org-profile.cjs +544 -53
- package/pp-core/bin/lib/package-identity.cjs +3 -3
- package/pp-core/bin/lib/phase-command-router.cjs +1 -23
- package/pp-core/bin/lib/phase-scope.cjs +1 -1
- package/pp-core/bin/lib/phase.cjs +11 -203
- package/pp-core/bin/lib/platform-bindings.cjs +40 -19
- package/pp-core/bin/lib/practices-review-check.cjs +15 -9
- package/pp-core/bin/lib/profile-output.cjs +1 -1
- package/pp-core/bin/lib/shell-command-projection.cjs +104 -1
- package/pp-core/bin/lib/state.cjs +11 -85
- package/pp-core/bin/lib/template.cjs +1 -1
- package/pp-core/bin/lib/vault-audit.cjs +2594 -0
- package/pp-core/bin/lib/vault-command-router.cjs +2756 -60
- package/pp-core/bin/lib/vault-coordinates.cjs +13 -4
- package/pp-core/bin/lib/vault-index.cjs +699 -0
- package/pp-core/bin/lib/vault-mode.cjs +235 -0
- package/pp-core/bin/lib/vault-practices.cjs +1194 -89
- package/pp-core/bin/lib/vault-review.cjs +2102 -0
- package/pp-core/bin/lib/vault-seal.cjs +1936 -0
- package/pp-core/bin/lib/vault-standards.cjs +1505 -0
- package/pp-core/bin/lib/vault-trace.cjs +3184 -0
- package/pp-core/bin/lib/vault-update.cjs +242 -0
- package/pp-core/bin/lib/vault-write-back.cjs +78 -31
- package/pp-core/bin/lib/vault.cjs +2713 -634
- package/pp-core/bin/lib/verify.cjs +0 -11
- package/pp-core/bin/portable-timeout.sh +1 -1
- package/pp-core/bin/pp-tools.cjs +184 -37
- package/pp-core/bin/shared/config-defaults.manifest.json +0 -7
- package/pp-core/bin/shared/config-schema.manifest.json +0 -3
- package/pp-core/bin/shared/model-catalog.json +1 -0
- package/pp-core/bin/verify-reapply-patches.cjs +0 -0
- package/pp-core/org-profile.json +26 -0
- package/pp-core/references/autonomous-best-effort.md +8 -7
- package/pp-core/references/loop-hook-dispatch.md +0 -14
- package/pp-core/references/planner-gap-closure.md +0 -1
- package/pp-core/references/planner-tdd.md +0 -1
- package/pp-core/references/planner-vault-practices.md +31 -3
- package/pp-core/references/planner-vault-standards.md +320 -0
- package/pp-core/references/planning-config.md +0 -8
- package/pp-core/references/practice-arithmetic-contract.md +121 -0
- package/pp-core/references/tdd.md +0 -1
- package/pp-core/references/verifier-practice-verdicts.md +263 -0
- package/pp-core/templates/README.md +1 -0
- package/pp-core/templates/VAULT-REVIEW.md +202 -0
- package/pp-core/templates/phase-prompt.md +2 -6
- package/pp-core/templates/practices/README.md +137 -11
- package/pp-core/templates/project-charter.md +226 -0
- package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
- package/pp-core/workflows/add-tests.md +55 -0
- package/pp-core/workflows/audit-fix.md +63 -0
- package/pp-core/workflows/audit-milestone.md +1 -1
- package/pp-core/workflows/autonomous.md +85 -11
- package/pp-core/workflows/check-todos.md +13 -2
- package/pp-core/workflows/code-review-fix.md +59 -1
- package/pp-core/workflows/code-review.md +10 -1
- package/pp-core/workflows/complete-milestone.md +1 -1
- package/pp-core/workflows/debug.md +56 -2
- package/pp-core/workflows/discovery-sprint/prefill.md +21 -0
- package/pp-core/workflows/discovery-sprint/prototype.md +46 -48
- package/pp-core/workflows/discovery-sprint/session-13-gates.md +2 -0
- package/pp-core/workflows/discovery-sprint/sessions/01-kick-off.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/02-as-is.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/03-to-be.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/04-ux-design.md +20 -24
- package/pp-core/workflows/discovery-sprint/sessions/05-architecture.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/06-security.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/07-identity.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/08-integration.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/09-qa.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/10-infrastructure.md +1 -6
- package/pp-core/workflows/discovery-sprint/sessions/11-roadmap.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/12-delivery-model.md +1 -3
- package/pp-core/workflows/discovery-sprint/sessions/13-wrap-up.md +1 -3
- package/pp-core/workflows/discovery-sprint/templates/context-template.md +1 -1
- package/pp-core/workflows/discovery-sprint/vault-context.md +213 -0
- package/pp-core/workflows/discovery-sprint.md +7 -3
- package/pp-core/workflows/discovery-to-build/steps/architecture-deliverables.md +4 -4
- package/pp-core/workflows/discovery-to-build/templates/pp-handoff.md +43 -1
- package/pp-core/workflows/discovery-to-build.md +9 -1
- package/pp-core/workflows/discuss-phase.md +3 -10
- package/pp-core/workflows/execute-phase/steps/check-query-gate.md +329 -0
- package/pp-core/workflows/execute-phase/steps/client-context-gate.md +3 -2
- package/pp-core/workflows/execute-phase/steps/cross-ai-delegation.md +1 -1
- package/pp-core/workflows/execute-phase/steps/execute-pre-dispatch.md +98 -0
- package/pp-core/workflows/execute-phase/steps/per-plan-worktree-gate.md +94 -0
- package/pp-core/workflows/execute-phase/steps/post-merge-gate.md +6 -8
- package/pp-core/workflows/execute-phase/steps/vault-fetch.md +115 -14
- package/pp-core/workflows/execute-phase/steps/vault-load-gate.md +322 -18
- package/pp-core/workflows/execute-phase/steps/verifier-practice-payload.md +165 -0
- package/pp-core/workflows/execute-phase/steps/verify-post-gate.md +163 -0
- package/pp-core/workflows/execute-phase.md +154 -110
- package/pp-core/workflows/execute-plan.md +58 -4
- package/pp-core/workflows/fast.md +53 -0
- package/pp-core/workflows/help/modes/full.md +1 -0
- package/pp-core/workflows/init/steps/client-context-capture.md +101 -0
- package/pp-core/workflows/init/steps/gitlab-provisioning.md +130 -0
- package/pp-core/workflows/init/steps/project-md-deferral-narration.md +66 -0
- package/pp-core/workflows/init/steps/reachability-outcome.md +70 -0
- package/pp-core/workflows/init.md +232 -272
- package/pp-core/workflows/new-project.md +31 -23
- package/pp-core/workflows/plan-phase.md +99 -43
- package/pp-core/workflows/quick.md +120 -2
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/ship.md +57 -24
- package/pp-core/workflows/update.md +3 -3
- package/pp-core/workflows/validate-phase.md +60 -1
- package/pp-core/workflows/vault-review.md +325 -0
- package/pp-core/workflows/verify-work.md +13 -12
- package/scripts/changeset/cli.cjs +6 -5
- package/scripts/changeset/lint.cjs +0 -0
- package/scripts/changeset/new.cjs +33 -50
- package/scripts/dev-env.cjs +25 -85
- package/scripts/gen-capability-registry.cjs +199 -0
- package/scripts/gen-practice-arithmetic-contract.cjs +412 -0
- package/scripts/lib/allowlist-ratchet.cjs +1 -103
- package/scripts/lint-allow-test-rule-refs.allowlist.json +0 -2
- package/scripts/lint-docs-required.cjs +0 -0
- package/scripts/lint-pr-check-project-dir.cjs +1 -3
- package/scripts/lint-test-file-count.allowlist.json +13 -2
- package/scripts/practice-arithmetic-contract.allow.json +35 -0
- package/scripts/prompt-injection-scan.sh +22 -15
- package/scripts/record-vault-mode-golden.cjs +89 -0
- package/scripts/release-channel.cjs +0 -0
- package/scripts/sync-manifest-versions.cjs +5 -4
- package/scripts/sync-rulesets.sh +1 -1
- package/scripts/update-size-baseline.cjs +30 -274
- package/scripts/vault-calibrate.cjs +996 -0
- package/scripts/verify-opengsd-sweep.cjs +3 -5
- package/scripts/workflow-size.cjs +9 -61
- package/pp-core/bin/lib/dispatch-plan.cjs +0 -472
- package/pp-core/bin/lib/install-run.cjs +0 -536
- package/pp-core/bin/lib/phase-tier.cjs +0 -467
- package/pp-core/bin/lib/plan-graph.cjs +0 -368
- package/pp-core/references/planner-plan-graph.md +0 -66
- package/pp-core/workflows/execute-phase/steps/checkpoint-handling.md +0 -45
- package/pp-core/workflows/execute-phase/steps/install-run.md +0 -29
- package/pp-core/workflows/execute-phase/steps/phase-close-rebaseline.md +0 -23
- package/pp-core/workflows/execute-phase/steps/serial-group-dispatch.md +0 -72
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -34
- package/scripts/lib/size-budget.cjs +0 -311
- package/scripts/lint-neutrality-names.cjs +0 -694
- package/scripts/lint-org-coordinates.allowlist.json +0 -35
- package/scripts/lint-org-coordinates.cjs +0 -426
|
@@ -0,0 +1,1505 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/vault-standards.cts — the `<vault_standards>` record (Vault Assurance
|
|
4
|
+
* v1.2, Phase 15-05; D-07, D-08, D-09, D-10, D-13, D-17..D-20, D-29).
|
|
5
|
+
*
|
|
6
|
+
* ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
|
|
7
|
+
* compiles to the git-ignored `pp-core/bin/lib/vault-standards.cjs`; do NOT
|
|
8
|
+
* hand-edit the compiled output. Behaviour is locked by
|
|
9
|
+
* `tests/vault-standards.test.cjs`, which also pins the agent-facing contract
|
|
10
|
+
* `pp-core/references/planner-vault-standards.md` to this grammar in both
|
|
11
|
+
* directions (D-10).
|
|
12
|
+
*
|
|
13
|
+
* In standards mode a plan records which INDEX-listed standards documents
|
|
14
|
+
* governed it, in ONE body block the CLI emits (`vault standards block`), and
|
|
15
|
+
* the plan's SUMMARY records which of them the executor read (`## Standards
|
|
16
|
+
* Consulted`, rendered by `vault standards consulted`). This module is the ONE
|
|
17
|
+
* owner of:
|
|
18
|
+
* - the block grammar: tags, the stamp line, the document line, the closed
|
|
19
|
+
* degraded set, the CLI-only override line (`STANDARDS_GRAMMAR` is the
|
|
20
|
+
* single text the reference quotes),
|
|
21
|
+
* - the reason-clause rules (D-09),
|
|
22
|
+
* - the `## Standards Consulted` section grammar (D-13),
|
|
23
|
+
* - the serializers the verbs print through and the whole-line parsers the
|
|
24
|
+
* gate reads through,
|
|
25
|
+
* - the pure judgement of a parsed record against a measured state,
|
|
26
|
+
* - the frozen `STANDARDS_PROBLEM_KIND` table and every standards sentence,
|
|
27
|
+
* - the plan-id form `SCAFFOLD_PLAN_ID_RE` (moved here from the vault
|
|
28
|
+
* router, which re-exports it), so the verbs and the gate share one form.
|
|
29
|
+
*
|
|
30
|
+
* It imports only `./vault-index.cjs`, which imports nothing, and (16-06) the
|
|
31
|
+
* seal leaf `./vault-seal.cjs` for the one anchor grammar, whose top level
|
|
32
|
+
* imports only `fs` and `path` (D-41). This is load-bearing: the vault router
|
|
33
|
+
* (15-07) and the check router (15-08) both require this leaf, and a leaf whose
|
|
34
|
+
* dependencies are themselves import-free at load time cannot close a require
|
|
35
|
+
* cycle among them (the seal leaf requires this one back LAZILY, inside its
|
|
36
|
+
* functions). The entry head split, the display
|
|
37
|
+
* sanitiser and the fence markers are reused from there, never re-spelled
|
|
38
|
+
* (Phase 14 D-06/D-17/D-32): the grammar for a `- \`path\` — …` line is one
|
|
39
|
+
* grammar across INDEX.md, discovery's vault context and this record.
|
|
40
|
+
*
|
|
41
|
+
* Nothing here reads config, the filesystem, the environment or the clock.
|
|
42
|
+
* The measured state (the loaded pin, the resolved INDEX paths, the index
|
|
43
|
+
* status, whether `vault.required` is on) is computed by the caller and passed
|
|
44
|
+
* in as a value.
|
|
45
|
+
*
|
|
46
|
+
* Message discipline (T-15-20, D-14): every text echoed from a record passes
|
|
47
|
+
* `sanitiseVaultText`, and every path passes `displayVaultPath`, so a record
|
|
48
|
+
* cannot smuggle terminal control bytes, bidi overrides or a live fence marker
|
|
49
|
+
* into a gate message. The leaf never knows WHY a path is not a resolved INDEX
|
|
50
|
+
* entry (withheld, unsafe, unresolved and absent look the same to it), so no
|
|
51
|
+
* detail can confirm that a refused path names a credential. Every detail ends
|
|
52
|
+
* with a repair remedy and never names a destructive verb.
|
|
53
|
+
*/
|
|
54
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
55
|
+
exports.STANDARDS_PROBLEM_KIND = exports.STANDARDS_GRAMMAR = exports.STANDARDS_DETAIL_CAP = exports.SCAFFOLD_PLAN_ID_RE = exports.STANDARDS_ANCHOR_PREFIX = exports.STANDARDS_SHA_RE = exports.STANDARDS_NONE = exports.STANDARDS_CONSULTED_NOT_READ = exports.STANDARDS_CONSULTED_READ = exports.STANDARDS_OVERRIDE_GATE = exports.STANDARDS_OVERRIDE_PREFIX = exports.STANDARDS_INDEX_STATUSES = exports.STANDARDS_COULD_NOT_CHECK_CODES = exports.STANDARDS_INDEX_MISSING_CODE = exports.STANDARDS_DEGRADED_KINDS = exports.STANDARDS_REASON_MARKER = exports.STANDARDS_REASON_MAX = exports.STANDARDS_CONSULTED_HEADING = exports.STANDARDS_STAMP_SEPARATOR = exports.STANDARDS_STAMP_PREFIX = exports.VAULT_STANDARDS_TAG_CLOSE = exports.VAULT_STANDARDS_TAG_OPEN = void 0;
|
|
56
|
+
exports.validateReasonClause = validateReasonClause;
|
|
57
|
+
exports.standardsIndexRepair = standardsIndexRepair;
|
|
58
|
+
exports.standardsVaultRepairLead = standardsVaultRepairLead;
|
|
59
|
+
exports.standardsProblemDetail = standardsProblemDetail;
|
|
60
|
+
exports.standardsEchoDegraded = standardsEchoDegraded;
|
|
61
|
+
exports.serializeStandardsBlock = serializeStandardsBlock;
|
|
62
|
+
exports.formatStandardsAnchorLine = formatStandardsAnchorLine;
|
|
63
|
+
exports.parseStandardsAnchorLine = parseStandardsAnchorLine;
|
|
64
|
+
exports.serializeConsultedSection = serializeConsultedSection;
|
|
65
|
+
exports.findLiveSection = findLiveSection;
|
|
66
|
+
exports.parseStandardsBlock = parseStandardsBlock;
|
|
67
|
+
exports.parseConsultedSection = parseConsultedSection;
|
|
68
|
+
exports.standardsLoadedPin = standardsLoadedPin;
|
|
69
|
+
exports.standardsMeasuredIndexStatus = standardsMeasuredIndexStatus;
|
|
70
|
+
exports.standardsResolvedPaths = standardsResolvedPaths;
|
|
71
|
+
exports.standardsDegradedLineFor = standardsDegradedLineFor;
|
|
72
|
+
exports.judgeStandardsPlan = judgeStandardsPlan;
|
|
73
|
+
exports.judgeConsultedSummary = judgeConsultedSummary;
|
|
74
|
+
exports.compareConsulted = compareConsulted;
|
|
75
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
76
|
+
const vaultIndexLeaf = require("./vault-index.cjs");
|
|
77
|
+
const { splitVaultEntryLine, displayVaultPath, sanitiseVaultText, DISPLAY_UNSAFE_TEXT_CHAR_RE, EXTERNAL_CONTENT_FENCE_START, EXTERNAL_CONTENT_FENCE_END, VAULT_ENTRY_PREFIX, VAULT_ENTRY_SEPARATOR, } = vaultIndexLeaf;
|
|
78
|
+
// 16-06 (D-35): the anchor grammar has ONE owner, the seal leaf. Static and
|
|
79
|
+
// cycle-free: its top level imports only Node built-ins (D-41).
|
|
80
|
+
const vault_seal_cjs_1 = require("./vault-seal.cjs");
|
|
81
|
+
// ─── Vocabulary (D-07, D-08) ─────────────────────────────────────────────────
|
|
82
|
+
/** The block's opening tag; counted only as a whole line at column 0. */
|
|
83
|
+
exports.VAULT_STANDARDS_TAG_OPEN = '<vault_standards>';
|
|
84
|
+
/** The block's closing tag; counted only as a whole line at column 0. */
|
|
85
|
+
exports.VAULT_STANDARDS_TAG_CLOSE = '</vault_standards>';
|
|
86
|
+
/** The first line inside the block, and inside the consulted section, starts with this. */
|
|
87
|
+
exports.STANDARDS_STAMP_PREFIX = 'Standards record: plan ';
|
|
88
|
+
/** Between the facts of a stamp or an override line: space, middle dot (U+00B7), space. */
|
|
89
|
+
exports.STANDARDS_STAMP_SEPARATOR = ' \u00B7 ';
|
|
90
|
+
/** The SUMMARY section heading: a level-2 ATX heading at column 0, written exactly. */
|
|
91
|
+
exports.STANDARDS_CONSULTED_HEADING = '## Standards Consulted';
|
|
92
|
+
/** The bound on a reason clause, in code points (D-09). */
|
|
93
|
+
exports.STANDARDS_REASON_MAX = 200;
|
|
94
|
+
/** The marker that opens a reason slot. */
|
|
95
|
+
exports.STANDARDS_REASON_MARKER = 'reason: ';
|
|
96
|
+
/**
|
|
97
|
+
* The closed degraded set (D-08). Exactly one of these replaces the document
|
|
98
|
+
* lines; never mixed with them. `consulted-none` is the planner's judgement
|
|
99
|
+
* and carries a reason clause; the other two are emitted by the CLI only, from
|
|
100
|
+
* the measured index state, and carry a reason CODE.
|
|
101
|
+
*/
|
|
102
|
+
exports.STANDARDS_DEGRADED_KINDS = Object.freeze(['consulted-none', 'index-missing', 'could-not-check']);
|
|
103
|
+
/** The one code an `index-missing` line carries (equal to the vault-index leaf's MISSING reason). */
|
|
104
|
+
exports.STANDARDS_INDEX_MISSING_CODE = 'vault_index_missing';
|
|
105
|
+
/** The codes a `could-not-check` line may carry, closed. */
|
|
106
|
+
exports.STANDARDS_COULD_NOT_CHECK_CODES = Object.freeze([
|
|
107
|
+
'vault_index_unparseable',
|
|
108
|
+
'vault_index_empty',
|
|
109
|
+
'vault_index_unresolvable',
|
|
110
|
+
'vault_index_unchecked',
|
|
111
|
+
'vault_not_loaded',
|
|
112
|
+
]);
|
|
113
|
+
/**
|
|
114
|
+
* The INDEX.md statuses a stamp may carry: the five short forms of the
|
|
115
|
+
* vault-index leaf's reasons, `ok`, and `not-loaded` (no cached manifest at
|
|
116
|
+
* all). The leaf test pins the overlap with `vaultIndexReasonShort`.
|
|
117
|
+
*/
|
|
118
|
+
exports.STANDARDS_INDEX_STATUSES = Object.freeze([
|
|
119
|
+
'ok',
|
|
120
|
+
'missing',
|
|
121
|
+
'unparseable',
|
|
122
|
+
'empty',
|
|
123
|
+
'unresolvable',
|
|
124
|
+
'unchecked',
|
|
125
|
+
'not-loaded',
|
|
126
|
+
]);
|
|
127
|
+
/**
|
|
128
|
+
* The code each non-ok status implies on the record's degraded line. `ok`
|
|
129
|
+
* implies document lines or `consulted-none`; `missing` implies
|
|
130
|
+
* `index-missing`; every other status implies `could-not-check` with exactly
|
|
131
|
+
* this code.
|
|
132
|
+
*/
|
|
133
|
+
const STATUS_CODE = Object.freeze({
|
|
134
|
+
missing: exports.STANDARDS_INDEX_MISSING_CODE,
|
|
135
|
+
unparseable: 'vault_index_unparseable',
|
|
136
|
+
empty: 'vault_index_empty',
|
|
137
|
+
unresolvable: 'vault_index_unresolvable',
|
|
138
|
+
unchecked: 'vault_index_unchecked',
|
|
139
|
+
'not-loaded': 'vault_not_loaded',
|
|
140
|
+
});
|
|
141
|
+
/** The override line (CLI-only, the last line before the close tag) starts with this. */
|
|
142
|
+
exports.STANDARDS_OVERRIDE_PREFIX = 'Override: gate ';
|
|
143
|
+
/**
|
|
144
|
+
* The one gate a standards override line may name: the blocking load gate's
|
|
145
|
+
* `check.query`. The block verb writes it (the vault router's
|
|
146
|
+
* `TRACE_OVERRIDE_GATE` is this constant), and the judgement accepts no other
|
|
147
|
+
* (D-35).
|
|
148
|
+
*/
|
|
149
|
+
exports.STANDARDS_OVERRIDE_GATE = 'vault.status';
|
|
150
|
+
/**
|
|
151
|
+
* The timestamp form the block verb writes (`Date.prototype.toISOString`, UTC,
|
|
152
|
+
* milliseconds). An override line whose `at` slot is not this form was not
|
|
153
|
+
* written by the verb (D-35).
|
|
154
|
+
*/
|
|
155
|
+
const OVERRIDE_WHEN_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
156
|
+
/**
|
|
157
|
+
* Is `s` exactly a timestamp `toISOString` would write? The form, then a
|
|
158
|
+
* round trip: the parsed date must print back as the same text, so a date V8
|
|
159
|
+
* rolls over (`2026-02-31` reads as `2026-03-03`) is refused rather than
|
|
160
|
+
* accepted as "a real date" (15-REVIEW iter-2 IN-01).
|
|
161
|
+
*/
|
|
162
|
+
function isVerbTimestamp(s) {
|
|
163
|
+
if (typeof s !== 'string' || !OVERRIDE_WHEN_RE.test(s))
|
|
164
|
+
return false;
|
|
165
|
+
const d = new Date(s);
|
|
166
|
+
return !Number.isNaN(d.getTime()) && d.toISOString() === s;
|
|
167
|
+
}
|
|
168
|
+
/** The consulted status for a document the executor Read in full. */
|
|
169
|
+
exports.STANDARDS_CONSULTED_READ = 'read';
|
|
170
|
+
/** The consulted status for a document the executor did not read; a clause follows. */
|
|
171
|
+
exports.STANDARDS_CONSULTED_NOT_READ = 'not read: ';
|
|
172
|
+
/** The literal written in the repo, ref and sha slots when no manifest was loaded. */
|
|
173
|
+
exports.STANDARDS_NONE = 'none';
|
|
174
|
+
/** A full git object id, lowercase: the only sha form a record may carry. */
|
|
175
|
+
exports.STANDARDS_SHA_RE = /^[0-9a-f]{40}$/;
|
|
176
|
+
/**
|
|
177
|
+
* The optional anchor line of a `## Standards Consulted` section starts with
|
|
178
|
+
* this (16-06, D-14/D-31/D-35). It is in the section's own `- ` list grammar,
|
|
179
|
+
* and the CLI writes it as the section's last line: the commit the record's
|
|
180
|
+
* work started from (when the plan's directory recorded one with
|
|
181
|
+
* `vault anchor start`) and HEAD when the section was rendered. A record
|
|
182
|
+
* written before 16-06 has no such line and parses exactly as before.
|
|
183
|
+
*/
|
|
184
|
+
exports.STANDARDS_ANCHOR_PREFIX = '- anchors: ';
|
|
185
|
+
/**
|
|
186
|
+
* The plan-identifier form a record (and the practices scaffold) must carry,
|
|
187
|
+
* one of two alternatives:
|
|
188
|
+
*
|
|
189
|
+
* - a PHASE plan id: one or more digits with an optional letter, optional
|
|
190
|
+
* `.N` decimal parts, and at least one `-N` part (`15-02`, `2.1-03`,
|
|
191
|
+
* `7a-10`);
|
|
192
|
+
* - a QUICK-TASK id (16-05, D-29; research C-5): six date digits, a hyphen
|
|
193
|
+
* and three lowercase base-36 characters (`261011-8yt`), exactly what
|
|
194
|
+
* `cmdInitQuick` builds (`yymmdd` + `-` + the two-second block of the day
|
|
195
|
+
* in base 36, padded to three). Before 16-05 a quick task could not record
|
|
196
|
+
* at all, and the refusal was intermittent: an all-digit encoding such as
|
|
197
|
+
* `261011-123` happened to match the phase form, a lettered one such as
|
|
198
|
+
* `261011-8yt` did not.
|
|
199
|
+
*
|
|
200
|
+
* It rejects a bare plan number, a task label, a slugged identifier, an
|
|
201
|
+
* uppercase or wrong-length quick id and the empty string, each of which would
|
|
202
|
+
* mismatch the plan id the gate reads from a canonical filename. The phase
|
|
203
|
+
* alternative is byte-identical to the pre-16-05 pattern, and the whole is one
|
|
204
|
+
* anchored group so `filenamePlanId` can strip the anchors and embed it.
|
|
205
|
+
*
|
|
206
|
+
* Moved verbatim from `src/vault-command-router.cts` (15-05), which re-exports
|
|
207
|
+
* it under the same name. The read side's filename extractor
|
|
208
|
+
* (`filenamePlanId` in `src/check-command-router.cts`) builds its pattern from
|
|
209
|
+
* this value (15-REVIEW WR-06), so the form is read and written by one owner.
|
|
210
|
+
*/
|
|
211
|
+
exports.SCAFFOLD_PLAN_ID_RE = /^(?:\d+[A-Za-z]?(?:\.\d+)*(?:-\d+)+|\d{6}-[0-9a-z]{3})$/;
|
|
212
|
+
/** The bound on a problem detail, in code points; equal to the trace handler's detail cap. */
|
|
213
|
+
exports.STANDARDS_DETAIL_CAP = 400;
|
|
214
|
+
const SEP = exports.STANDARDS_STAMP_SEPARATOR;
|
|
215
|
+
const EM = VAULT_ENTRY_SEPARATOR; // the entry separator, owned by vault-index
|
|
216
|
+
const EM_TRIMMED = VAULT_ENTRY_SEPARATOR.trimEnd(); // ' —'
|
|
217
|
+
/**
|
|
218
|
+
* Every accepted line form, with `<placeholders>`. This is the single text the
|
|
219
|
+
* reference doc quotes; the doc pin test reads each value and finds it in the
|
|
220
|
+
* doc byte for byte.
|
|
221
|
+
*/
|
|
222
|
+
exports.STANDARDS_GRAMMAR = Object.freeze({
|
|
223
|
+
stamp: `${exports.STANDARDS_STAMP_PREFIX}<plan-id>${SEP}vault <repo> @ <ref>${SEP}sha <40-hex>${SEP}INDEX.md <status>`,
|
|
224
|
+
stampNotLoaded: `${exports.STANDARDS_STAMP_PREFIX}<plan-id>${SEP}vault ${exports.STANDARDS_NONE} @ ${exports.STANDARDS_NONE}${SEP}sha ${exports.STANDARDS_NONE}${SEP}INDEX.md not-loaded`,
|
|
225
|
+
document: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM}<ref> @ <40-hex>${EM}${exports.STANDARDS_REASON_MARKER}<one clause>`,
|
|
226
|
+
consultedNone: `- (consulted-none)${EM}${exports.STANDARDS_REASON_MARKER}<one clause>`,
|
|
227
|
+
indexMissing: `- (index-missing)${EM}${exports.STANDARDS_INDEX_MISSING_CODE}`,
|
|
228
|
+
couldNotCheck: '- (could-not-check)' + EM + '<code>',
|
|
229
|
+
override: `${exports.STANDARDS_OVERRIDE_PREFIX}<gate>${SEP}suppressed <suppressed-reason>${SEP}by <who>${SEP}at <iso-8601>${SEP}${exports.STANDARDS_REASON_MARKER}<operator text>`,
|
|
230
|
+
consultedStamp: `${exports.STANDARDS_STAMP_PREFIX}<plan-id>${SEP}vault <repo> @ <ref>${SEP}sha <40-hex>`,
|
|
231
|
+
consultedRead: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM}${exports.STANDARDS_CONSULTED_READ}`,
|
|
232
|
+
consultedNotRead: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM}${exports.STANDARDS_CONSULTED_NOT_READ}<one clause>`,
|
|
233
|
+
consultedUnfilled: `${VAULT_ENTRY_PREFIX}<index-path>\`${EM_TRIMMED}`,
|
|
234
|
+
// 16-06: the optional anchor line, in its three CLI-written forms.
|
|
235
|
+
consultedAnchors: `${exports.STANDARDS_ANCHOR_PREFIX}start <commit>${SEP}end <commit>`,
|
|
236
|
+
consultedAnchorsEndOnly: `${exports.STANDARDS_ANCHOR_PREFIX}end <commit>`,
|
|
237
|
+
consultedAnchorsBackfilled: `${exports.STANDARDS_ANCHOR_PREFIX}end <commit>${SEP}backfilled by <who> at <iso-8601>`,
|
|
238
|
+
});
|
|
239
|
+
/**
|
|
240
|
+
* The frozen standards problem kinds (D-17). Every value is `standards_` plus
|
|
241
|
+
* the lower-snake key, so none can collide with a practices trace kind or a
|
|
242
|
+
* trace handler kind (the leaf test asserts disjointness against both tables).
|
|
243
|
+
* The two pairing kinds are produced by the HANDLER (15-08); this leaf owns
|
|
244
|
+
* their texts only.
|
|
245
|
+
*/
|
|
246
|
+
exports.STANDARDS_PROBLEM_KIND = Object.freeze({
|
|
247
|
+
BLOCK_ABSENT: 'standards_block_absent',
|
|
248
|
+
BLOCK_DUPLICATE: 'standards_block_duplicate',
|
|
249
|
+
BLOCK_UNTERMINATED: 'standards_block_unterminated',
|
|
250
|
+
BLOCK_MALFORMED: 'standards_block_malformed',
|
|
251
|
+
REASON_INVALID: 'standards_reason_invalid',
|
|
252
|
+
DOCUMENT_DUPLICATE: 'standards_document_duplicate',
|
|
253
|
+
DEGRADED_MIXED: 'standards_degraded_mixed',
|
|
254
|
+
NONE_WITHOUT_REASON: 'standards_none_without_reason',
|
|
255
|
+
PATH_NOT_RESOLVED: 'standards_path_not_resolved',
|
|
256
|
+
PIN_MISMATCH: 'standards_pin_mismatch',
|
|
257
|
+
CACHE_UNREADABLE: 'standards_cache_unreadable',
|
|
258
|
+
DEGRADED_UNTRUE: 'standards_degraded_untrue',
|
|
259
|
+
CONSULTED_ABSENT: 'standards_consulted_absent',
|
|
260
|
+
CONSULTED_MISMATCH: 'standards_consulted_mismatch',
|
|
261
|
+
PLAN_ID_MISMATCH: 'standards_plan_id_mismatch',
|
|
262
|
+
PAIR_MISSING: 'standards_pair_missing',
|
|
263
|
+
ORPHAN_SUMMARY: 'standards_orphan_summary',
|
|
264
|
+
// 16-06 (D-35): a line carrying the anchor prefix that is not one of the
|
|
265
|
+
// anchor line's forms, or a second anchor line. Appended LAST. A section with
|
|
266
|
+
// NO anchor line has no problem: write points are silent on a missing anchor
|
|
267
|
+
// (D-16); the strict points' `anchor_missing` is the trace gate's (16-07).
|
|
268
|
+
ANCHOR_MALFORMED: 'standards_anchor_malformed',
|
|
269
|
+
});
|
|
270
|
+
const NEWLINE_RE = /[\r\n\u0085\u2028\u2029]/;
|
|
271
|
+
const TILDE_FENCE_RE = /~{3,}/;
|
|
272
|
+
/**
|
|
273
|
+
* Judge one reason clause: non-empty after trim; at most
|
|
274
|
+
* `STANDARDS_REASON_MAX` code points (not UTF-16 units); no newline (CR, LF,
|
|
275
|
+
* NEL, U+2028, U+2029); no backtick at all; no fence marker (either
|
|
276
|
+
* external-content marker, or a run of three tildes); no block tag; nothing
|
|
277
|
+
* the display sanitiser would rewrite (15-REVIEW WR-03: a control character
|
|
278
|
+
* such as ESC or BEL, a bidi override, a line or paragraph separator), judged
|
|
279
|
+
* by asking `sanitiseVaultText` itself, so the refused set has one owner. Runs
|
|
280
|
+
* of U+0020 and U+0020 at the ends are not judged here: the sanitiser only
|
|
281
|
+
* tidies them. Any other character the sanitiser rewrites is refused wherever
|
|
282
|
+
* it sits, the ends included (iter-2 WR-04). So is every character of the
|
|
283
|
+
* display-safety set vault-index owns (`DISPLAY_UNSAFE_TEXT_CHAR_RE`, iter-3
|
|
284
|
+
* IN-01), which adds the invisible format and default-ignorable code points
|
|
285
|
+
* the sanitiser leaves alone (zero-width space, word joiner, soft hyphen,
|
|
286
|
+
* tag characters): text a human reviewer cannot see must not reach a record
|
|
287
|
+
* an agent reads. `"` and `\` are not in that set and stay legal in prose.
|
|
288
|
+
* A document body pasted as a reason fails the bound or the newline rule.
|
|
289
|
+
* Never throws.
|
|
290
|
+
*/
|
|
291
|
+
function validateReasonClause(text) {
|
|
292
|
+
if (typeof text !== 'string' || text.trim() === '')
|
|
293
|
+
return { ok: false, why: 'empty' };
|
|
294
|
+
if (NEWLINE_RE.test(text))
|
|
295
|
+
return { ok: false, why: 'newline' };
|
|
296
|
+
if (Array.from(text.trim()).length > exports.STANDARDS_REASON_MAX)
|
|
297
|
+
return { ok: false, why: 'too_long' };
|
|
298
|
+
if (text.includes('`'))
|
|
299
|
+
return { ok: false, why: 'backtick' };
|
|
300
|
+
if (text.includes(EXTERNAL_CONTENT_FENCE_START) || text.includes(EXTERNAL_CONTENT_FENCE_END) || TILDE_FENCE_RE.test(text)) {
|
|
301
|
+
return { ok: false, why: 'fence_marker' };
|
|
302
|
+
}
|
|
303
|
+
if (text.includes(exports.VAULT_STANDARDS_TAG_OPEN) || text.includes(exports.VAULT_STANDARDS_TAG_CLOSE)) {
|
|
304
|
+
return { ok: false, why: 'block_tag' };
|
|
305
|
+
}
|
|
306
|
+
// 15-REVIEW iter-2 WR-04: the comparison side tidies U+0020 ONLY. `trim()`
|
|
307
|
+
// strips every Unicode whitespace character (tab, U+000B, U+000C, U+FEFF,
|
|
308
|
+
// U+00A0 ...), so it agreed with the sanitiser about those characters at
|
|
309
|
+
// either end and let them through; the parser trims line ends only, so one
|
|
310
|
+
// right after `reason: ` survived into the record.
|
|
311
|
+
const spacesTidied = text.replace(/ +/g, ' ').replace(/^ | $/g, '');
|
|
312
|
+
if (sanitiseVaultText(text, exports.STANDARDS_REASON_MAX * 4) !== spacesTidied || DISPLAY_UNSAFE_TEXT_CHAR_RE.test(text)) {
|
|
313
|
+
return { ok: false, why: 'control' };
|
|
314
|
+
}
|
|
315
|
+
return { ok: true };
|
|
316
|
+
}
|
|
317
|
+
const WHY_TEXT = Object.freeze({
|
|
318
|
+
empty: 'empty',
|
|
319
|
+
too_long: `longer than ${exports.STANDARDS_REASON_MAX} characters`,
|
|
320
|
+
newline: 'more than one line',
|
|
321
|
+
backtick: 'carrying a backtick',
|
|
322
|
+
fence_marker: 'carrying a fence marker',
|
|
323
|
+
block_tag: 'carrying a block tag',
|
|
324
|
+
control: 'carrying a control, bidi, separator or invisible format character',
|
|
325
|
+
});
|
|
326
|
+
const ECHO_MAX = 80;
|
|
327
|
+
const PATH_MAX = 120;
|
|
328
|
+
const PLAN_ID_ECHO_MAX = 40;
|
|
329
|
+
function echo(s, max = ECHO_MAX) {
|
|
330
|
+
return sanitiseVaultText(typeof s === 'string' ? s : '', max);
|
|
331
|
+
}
|
|
332
|
+
function echoPath(p) {
|
|
333
|
+
return displayVaultPath(typeof p === 'string' ? p : '', PATH_MAX);
|
|
334
|
+
}
|
|
335
|
+
function echoPlanId(p) {
|
|
336
|
+
const t = echo(p, PLAN_ID_ECHO_MAX);
|
|
337
|
+
return t === '' ? '<plan-id>' : t;
|
|
338
|
+
}
|
|
339
|
+
function subjectOf(ctx) {
|
|
340
|
+
const a = echo(ctx.artifact, ECHO_MAX);
|
|
341
|
+
return a === '' ? 'The artifact' : a;
|
|
342
|
+
}
|
|
343
|
+
function lineSuffix(ctx) {
|
|
344
|
+
return typeof ctx.line === 'number' && Number.isFinite(ctx.line) ? ` (line ${Math.floor(ctx.line)})` : '';
|
|
345
|
+
}
|
|
346
|
+
function blockRemedy(planId) {
|
|
347
|
+
return `Re-run \`pp-tools vault standards block --plan ${echoPlanId(planId)} <index-path...>\`, embed its output verbatim and fill only the reason slots.`;
|
|
348
|
+
}
|
|
349
|
+
const CONSULTED_REMEDY = 'Re-run `pp-tools vault standards consulted <plan-path>`, embed its section verbatim in the SUMMARY, fill each status slot with `read` or `not read: <one clause>`, and commit the SUMMARY.';
|
|
350
|
+
/** The repair for a malformed anchor line (16-06): regenerate it, or back-fill an unanchored record, attributed. */
|
|
351
|
+
const ANCHOR_REMEDY = 'Restore the anchor line `pp-tools vault standards consulted <plan-path>` printed, or, for a record that never had one, run `pp-tools vault anchor backfill <summary> --at <sha>`.';
|
|
352
|
+
/**
|
|
353
|
+
* The cause and the repair for a measured index status on which no document
|
|
354
|
+
* can be listed (15-REVIEW iter-3 WR-01), or null on `ok`. ONE owner for the
|
|
355
|
+
* four surfaces that state it: the block verb's refusal, the consulted verb's
|
|
356
|
+
* refusal, and the trace gate's details on a document record
|
|
357
|
+
* (`standards_path_not_resolved`, `standards_pin_mismatch`,
|
|
358
|
+
* `standards_cache_unreadable`) and on a degraded record
|
|
359
|
+
* (`standards_degraded_untrue`, the D-29 advisory). Before it, the gate sent a
|
|
360
|
+
* document record on a degraded INDEX back to "choose from its entries" while
|
|
361
|
+
* the verb named the INDEX repair: following the gate's remedy replaced a
|
|
362
|
+
* governed plan's document record with a degraded one. The repair is a
|
|
363
|
+
* sentence stem with no final stop, so each surface appends its own re-run.
|
|
364
|
+
* An unknown status fails closed to the INDEX repair, never to null.
|
|
365
|
+
*/
|
|
366
|
+
function standardsIndexRepair(status) {
|
|
367
|
+
return status === 'ok' ? null : indexRepairFor(status);
|
|
368
|
+
}
|
|
369
|
+
const LOAD_REPAIR = 'Run `pp-tools vault fetch` to load the vault';
|
|
370
|
+
const INDEX_REPAIR = 'Repair INDEX.md in the vault repo and run `pp-tools vault fetch`';
|
|
371
|
+
/** `standardsIndexRepair` for a status the caller already knows is not `ok`. */
|
|
372
|
+
function indexRepairFor(status) {
|
|
373
|
+
if (status === 'not-loaded')
|
|
374
|
+
return { cause: 'no vault is loaded', repair: LOAD_REPAIR };
|
|
375
|
+
return {
|
|
376
|
+
cause: isIndexStatus(status) && status !== 'ok' ? `the index is ${status}` : 'the index is in an unknown state',
|
|
377
|
+
repair: INDEX_REPAIR,
|
|
378
|
+
};
|
|
379
|
+
}
|
|
380
|
+
/**
|
|
381
|
+
* The lead the trace gate's blocking message puts before its "repair each
|
|
382
|
+
* file in place" footer when a listed standards problem names a vault repair
|
|
383
|
+
* from `standardsIndexRepair` (iter-3 WR-01), else ''. The footer points at
|
|
384
|
+
* the two record producers; while the vault is not loaded or its INDEX is
|
|
385
|
+
* degraded, regenerating a record can only write a degraded one, so the
|
|
386
|
+
* vault repair the line names comes first. Detected by the owner's own
|
|
387
|
+
* repair text, which `composeDetail` never truncates.
|
|
388
|
+
*/
|
|
389
|
+
function standardsVaultRepairLead(problems) {
|
|
390
|
+
const kinds = Object.values(exports.STANDARDS_PROBLEM_KIND);
|
|
391
|
+
const repairs = [LOAD_REPAIR, INDEX_REPAIR];
|
|
392
|
+
let names = false;
|
|
393
|
+
for (const p of problems ?? []) {
|
|
394
|
+
const kind = p?.kind;
|
|
395
|
+
const detail = p?.detail;
|
|
396
|
+
if (typeof kind === 'string' && kinds.includes(kind) && typeof detail === 'string' && repairs.some((r) => detail.includes(r)))
|
|
397
|
+
names = true;
|
|
398
|
+
}
|
|
399
|
+
return names
|
|
400
|
+
? 'A line above names a vault repair: make it first, because until the vault loads with a usable INDEX.md, regenerating a record can only write a degraded one. '
|
|
401
|
+
: '';
|
|
402
|
+
}
|
|
403
|
+
function lowerFirst(s) {
|
|
404
|
+
return `${s.charAt(0).toLowerCase()}${s.slice(1)}`;
|
|
405
|
+
}
|
|
406
|
+
/** The load repair (no manifest at gate time), the only remedy `standards_cache_unreadable` needs. */
|
|
407
|
+
const FETCH_REMEDY = `${LOAD_REPAIR}, then re-run this check.`;
|
|
408
|
+
/**
|
|
409
|
+
* Join a variable head and a constant remedy under the detail cap. The head is
|
|
410
|
+
* cut (with `…`) so the remedy is never truncated: the remedy is what makes a
|
|
411
|
+
* detail actionable.
|
|
412
|
+
*/
|
|
413
|
+
function composeDetail(head, remedy) {
|
|
414
|
+
const remedyLen = Array.from(remedy).length;
|
|
415
|
+
const room = exports.STANDARDS_DETAIL_CAP - remedyLen - 1;
|
|
416
|
+
const points = Array.from(head);
|
|
417
|
+
const cut = points.length <= room ? head : `${points.slice(0, Math.max(0, room - 1)).join('')}\u2026`;
|
|
418
|
+
return `${cut} ${remedy}`;
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* The finished detail for one kind. Every echoed value passes the sanitiser
|
|
422
|
+
* (paths through `displayVaultPath`); every detail ends with a repair remedy
|
|
423
|
+
* and is at most `STANDARDS_DETAIL_CAP` code points. The texts never name a
|
|
424
|
+
* destructive verb (INVARIANT 4's class; the leaf test sweeps all 17).
|
|
425
|
+
*/
|
|
426
|
+
function standardsProblemDetail(kind, ctx = {}) {
|
|
427
|
+
const c = ctx && typeof ctx === 'object' ? ctx : {};
|
|
428
|
+
const subject = subjectOf(c);
|
|
429
|
+
const where = c.where === 'section' ? `the ${exports.STANDARDS_CONSULTED_HEADING} section` : `the ${exports.VAULT_STANDARDS_TAG_OPEN} block`;
|
|
430
|
+
const sectionRemedy = c.where === 'section';
|
|
431
|
+
const path = echoPath(c.path);
|
|
432
|
+
switch (kind) {
|
|
433
|
+
case exports.STANDARDS_PROBLEM_KIND.BLOCK_ABSENT:
|
|
434
|
+
return composeDetail(`${subject} carries no ${exports.VAULT_STANDARDS_TAG_OPEN} block at column 0 outside code fences, comments and frontmatter; every standards-mode plan carries one, including a plan that writes no code.`, blockRemedy(c.planId));
|
|
435
|
+
case exports.STANDARDS_PROBLEM_KIND.BLOCK_DUPLICATE:
|
|
436
|
+
return composeDetail(`${subject} carries ${typeof c.count === 'number' ? c.count : 'more than one'} ${exports.VAULT_STANDARDS_TAG_OPEN} blocks; the gate reads exactly one. Replace them with a single regenerated block.`, blockRemedy(c.planId));
|
|
437
|
+
case exports.STANDARDS_PROBLEM_KIND.BLOCK_UNTERMINATED:
|
|
438
|
+
return composeDetail(`${subject} opens ${exports.VAULT_STANDARDS_TAG_OPEN}${lineSuffix(c)} and never closes it with ${exports.VAULT_STANDARDS_TAG_CLOSE} at column 0.`, blockRemedy(c.planId));
|
|
439
|
+
case exports.STANDARDS_PROBLEM_KIND.BLOCK_MALFORMED:
|
|
440
|
+
return composeDetail(`${subject}: ${where} ${echo(c.part, 160) || 'does not match the standards grammar'}${lineSuffix(c)}.`, sectionRemedy ? CONSULTED_REMEDY : blockRemedy(c.planId));
|
|
441
|
+
case exports.STANDARDS_PROBLEM_KIND.REASON_INVALID: {
|
|
442
|
+
const which = c.where === 'section' ? `the not-read clause for ${path}` : c.path ? `the reason for ${path}` : 'the consulted-none reason';
|
|
443
|
+
return composeDetail(`${subject}: ${which} is ${WHY_TEXT[c.why ?? 'empty'] ?? 'invalid'}; a reason is one clause of at most ${exports.STANDARDS_REASON_MAX} characters with no newline, backtick, fence marker, block tag, control or invisible character.`, 'Rewrite it as one short clause.');
|
|
444
|
+
}
|
|
445
|
+
case exports.STANDARDS_PROBLEM_KIND.DOCUMENT_DUPLICATE:
|
|
446
|
+
return composeDetail(`${subject}: ${where} lists ${path} more than once; each INDEX path appears once.`, sectionRemedy ? CONSULTED_REMEDY : blockRemedy(c.planId));
|
|
447
|
+
case exports.STANDARDS_PROBLEM_KIND.DEGRADED_MIXED:
|
|
448
|
+
return composeDetail(`${subject}: the ${exports.VAULT_STANDARDS_TAG_OPEN} block carries a degraded line and document lines together; a record is one or the other.`, blockRemedy(c.planId));
|
|
449
|
+
case exports.STANDARDS_PROBLEM_KIND.NONE_WITHOUT_REASON:
|
|
450
|
+
return composeDetail(`${subject} records consulted-none with an empty reason slot; it must say why no indexed standards document applies to this plan.`, 'Fill the reason slot with one clause.');
|
|
451
|
+
case exports.STANDARDS_PROBLEM_KIND.PATH_NOT_RESOLVED: {
|
|
452
|
+
// iter-3 WR-01: on a degraded INDEX there are no entries to choose
|
|
453
|
+
// from, and the only record the block verb can then write is a degraded
|
|
454
|
+
// one, so the detail names the measured status and the INDEX repair.
|
|
455
|
+
const fix = c.indexStatus === undefined ? null : standardsIndexRepair(c.indexStatus);
|
|
456
|
+
if (fix !== null) {
|
|
457
|
+
return composeDetail(`${subject} lists standards documents, but ${fix.cause}${c.indexStatus === 'not-loaded' ? '' : ' at the loaded sha'}, so no listed document can be checked against it.`, `${fix.repair}, then re-run this check.`);
|
|
458
|
+
}
|
|
459
|
+
return composeDetail(`${subject} lists ${path}, which is not a resolved INDEX entry at the loaded sha.`, `Re-read the ## INDEX section of .planning/vault/SUMMARY.md, choose from its entries, and ${lowerFirst(blockRemedy(c.planId))}`);
|
|
460
|
+
}
|
|
461
|
+
case exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH: {
|
|
462
|
+
const what = c.path ? `${path} at` : 'its stamp at';
|
|
463
|
+
const against = sectionRemedy ? `the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block records` : 'the loaded vault is';
|
|
464
|
+
const fix = sectionRemedy || c.indexStatus === undefined ? null : standardsIndexRepair(c.indexStatus);
|
|
465
|
+
return composeDetail(`${subject} records ${what} ${echo(c.recorded) || '<none>'}, but ${against} ${echo(c.loaded) || '<none>'}; a standards record must name the pin the documents were read at.`, sectionRemedy
|
|
466
|
+
? CONSULTED_REMEDY
|
|
467
|
+
: fix !== null
|
|
468
|
+
? `${fix.repair}, then re-read the documents at the loaded pin. ${blockRemedy(c.planId)}`
|
|
469
|
+
: `Re-read the documents at the loaded pin. ${blockRemedy(c.planId)}`);
|
|
470
|
+
}
|
|
471
|
+
case exports.STANDARDS_PROBLEM_KIND.CACHE_UNREADABLE:
|
|
472
|
+
return composeDetail(`${subject} names standards documents, but the vault cache could not be read at gate time, so its pins and paths cannot be checked.`, FETCH_REMEDY);
|
|
473
|
+
case exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE:
|
|
474
|
+
if (c.required === true && c.overrideUnverified === true) {
|
|
475
|
+
// D-35: an override line counts only when the load gate's own verdict,
|
|
476
|
+
// recomputed now, blocks for the reason the line records.
|
|
477
|
+
return composeDetail(`${subject} records ${echo(c.recorded) || 'a degraded line'} while vault.required is on, and its override line does not match the load gate's blocking verdict now, so it suppresses nothing.`, `Re-run \`pp-tools vault standards block --plan ${echoPlanId(c.planId)}\` with PP_VAULT_OVERRIDE_REASON set where it runs, and embed its output verbatim.`);
|
|
478
|
+
}
|
|
479
|
+
if (c.required === true) {
|
|
480
|
+
// iter-3 WR-01: the repair follows the measured status (no vault
|
|
481
|
+
// loaded needs a fetch, not an INDEX restore); an unknown or absent
|
|
482
|
+
// status keeps the INDEX repair, as before.
|
|
483
|
+
const fix = indexRepairFor(c.indexStatus ?? 'missing');
|
|
484
|
+
return composeDetail(`${subject} records ${echo(c.recorded) || 'a degraded line'} while vault.required is on: the load gate should have stopped this plan, and the block carries no override line.`, `${fix.repair}, then re-run \`pp-tools vault standards block --plan ${echoPlanId(c.planId)}\`; to pass the load gate on purpose, set PP_VAULT_OVERRIDE_REASON where the block verb runs.`);
|
|
485
|
+
}
|
|
486
|
+
return composeDetail(`${subject} records ${echo(c.recorded) || 'a degraded line'}, but the gate measures INDEX.md ${echo(c.measured) || 'in another state'}.`, blockRemedy(c.planId));
|
|
487
|
+
case exports.STANDARDS_PROBLEM_KIND.CONSULTED_ABSENT:
|
|
488
|
+
return composeDetail(`${subject} has no ${exports.STANDARDS_CONSULTED_HEADING} section at column 0 outside code fences and comments.`, CONSULTED_REMEDY);
|
|
489
|
+
case exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH: {
|
|
490
|
+
let head;
|
|
491
|
+
switch (c.form) {
|
|
492
|
+
case 'unfilled':
|
|
493
|
+
head = `${subject}: the status slot for ${path} is empty; it must say \`read\` or \`not read: <one clause>\`.`;
|
|
494
|
+
break;
|
|
495
|
+
case 'missing':
|
|
496
|
+
head = `${subject}: ${path} is in the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block but not in its ${exports.STANDARDS_CONSULTED_HEADING} section.`;
|
|
497
|
+
break;
|
|
498
|
+
case 'extra':
|
|
499
|
+
head = `${subject}: ${path} is in the ${exports.STANDARDS_CONSULTED_HEADING} section but not in the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block.`;
|
|
500
|
+
break;
|
|
501
|
+
default:
|
|
502
|
+
head = `${subject}: the ${exports.STANDARDS_CONSULTED_HEADING} section and the plan's ${exports.VAULT_STANDARDS_TAG_OPEN} block disagree: a degraded record is echoed by its exact degraded line, and a document record by its documents.`;
|
|
503
|
+
}
|
|
504
|
+
return composeDetail(head, CONSULTED_REMEDY);
|
|
505
|
+
}
|
|
506
|
+
case exports.STANDARDS_PROBLEM_KIND.PLAN_ID_MISMATCH:
|
|
507
|
+
return composeDetail(`${subject} records plan ${echoPlanId(c.planId)}, but its filename names plan ${echoPlanId(c.filenamePlanId)}.`, sectionRemedy ? CONSULTED_REMEDY : blockRemedy(c.filenamePlanId ?? c.planId));
|
|
508
|
+
case exports.STANDARDS_PROBLEM_KIND.PAIR_MISSING:
|
|
509
|
+
return composeDetail(`${subject}: plan ${echoPlanId(c.planId)} has no SUMMARY carrying its ${exports.STANDARDS_CONSULTED_HEADING} section, which this gate point requires.`, `Execute the plan, then ${CONSULTED_REMEDY.charAt(0).toLowerCase()}${CONSULTED_REMEDY.slice(1)}`);
|
|
510
|
+
case exports.STANDARDS_PROBLEM_KIND.ORPHAN_SUMMARY:
|
|
511
|
+
return composeDetail(`${subject} carries ${exports.STANDARDS_CONSULTED_HEADING} for plan ${echoPlanId(c.planId)}, but no PLAN in this phase records that plan id.`, `Render the section from the PLAN this SUMMARY belongs to. ${CONSULTED_REMEDY}`);
|
|
512
|
+
case exports.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED:
|
|
513
|
+
// 16-06: the anchor line is the CLI's; a hand-typed or edited one is
|
|
514
|
+
// dropped, never read as a commit, and this names the part, not the value.
|
|
515
|
+
return composeDetail(`${subject}: the ${exports.STANDARDS_CONSULTED_HEADING} section ${echo(c.part, 160) || 'carries an anchor line outside its grammar'}${lineSuffix(c)}; the anchor line is written by the CLI, never typed.`, ANCHOR_REMEDY);
|
|
516
|
+
default:
|
|
517
|
+
return composeDetail(`${subject}: the standards record does not match its grammar.`, blockRemedy(c.planId));
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
function problem(kind, ctx) {
|
|
521
|
+
const out = { kind, detail: standardsProblemDetail(kind, ctx) };
|
|
522
|
+
if (typeof ctx.path === 'string')
|
|
523
|
+
out.path = ctx.path;
|
|
524
|
+
if (typeof ctx.line === 'number')
|
|
525
|
+
out.line = ctx.line;
|
|
526
|
+
return out;
|
|
527
|
+
}
|
|
528
|
+
// ─── Serializers ─────────────────────────────────────────────────────────────
|
|
529
|
+
function slot(v) {
|
|
530
|
+
return typeof v === 'string' && v !== '' ? v : exports.STANDARDS_NONE;
|
|
531
|
+
}
|
|
532
|
+
function reasonSlot(reason) {
|
|
533
|
+
const r = typeof reason === 'string' ? reason : '';
|
|
534
|
+
return r === '' ? exports.STANDARDS_REASON_MARKER.trimEnd() : `${exports.STANDARDS_REASON_MARKER}${r}`;
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* A degraded line as it may be echoed (15-REVIEW WR-03, defence in depth for
|
|
538
|
+
* the `vault standards consulted` output and its `--raw` envelope): a
|
|
539
|
+
* consulted-none clause the reason rules accept is returned byte for byte, so
|
|
540
|
+
* the section still matches its block; any other clause is sanitised first.
|
|
541
|
+
* The CLI-only kinds carry a code from a closed set and are returned as they are.
|
|
542
|
+
*/
|
|
543
|
+
function standardsEchoDegraded(d) {
|
|
544
|
+
if (d.kind !== 'consulted-none' || d.reason === '' || validateReasonClause(d.reason).ok)
|
|
545
|
+
return { kind: d.kind, reason: d.reason };
|
|
546
|
+
return { kind: d.kind, reason: sanitiseVaultText(d.reason, exports.STANDARDS_REASON_MAX + 1) };
|
|
547
|
+
}
|
|
548
|
+
function degradedLine(d) {
|
|
549
|
+
if (d.kind === 'consulted-none')
|
|
550
|
+
return `- (consulted-none)${EM}${reasonSlot(standardsEchoDegraded(d).reason)}`;
|
|
551
|
+
return `- (${d.kind})${EM}${d.reason}`;
|
|
552
|
+
}
|
|
553
|
+
function stampLine(r, withStatus) {
|
|
554
|
+
const base = `${exports.STANDARDS_STAMP_PREFIX}${r.planId}${SEP}vault ${slot(r.repo)} @ ${slot(r.ref)}${SEP}sha ${slot(r.sha)}`;
|
|
555
|
+
return withStatus ? `${base}${SEP}INDEX.md ${r.indexStatus}` : base;
|
|
556
|
+
}
|
|
557
|
+
function token(s) {
|
|
558
|
+
const t = sanitiseVaultText(typeof s === 'string' ? s : '', ECHO_MAX).replace(/\s+/g, '_');
|
|
559
|
+
return t === '' ? 'unknown' : t;
|
|
560
|
+
}
|
|
561
|
+
function overrideLine(o) {
|
|
562
|
+
const who = sanitiseVaultText(o.who, ECHO_MAX).split(SEP).join(' - ') || 'unknown';
|
|
563
|
+
const reason = sanitiseVaultText(o.reason, exports.STANDARDS_REASON_MAX + 1) || 'unspecified';
|
|
564
|
+
return `${exports.STANDARDS_OVERRIDE_PREFIX}${token(o.gate)}${SEP}suppressed ${token(o.suppressedReason)}${SEP}by ${who}${SEP}at ${token(o.when)}${SEP}${exports.STANDARDS_REASON_MARKER}${reason}`;
|
|
565
|
+
}
|
|
566
|
+
/**
|
|
567
|
+
* Check a record is one the parser accepts back; throws a plain Error naming
|
|
568
|
+
* the first violation. The verbs only ever serialize records they measured,
|
|
569
|
+
* so a throw here is an engine bug, never an operator input.
|
|
570
|
+
*/
|
|
571
|
+
function assertSerializable(r) {
|
|
572
|
+
if (!r || typeof r !== 'object')
|
|
573
|
+
throw new Error('serializeStandardsBlock: record is not an object');
|
|
574
|
+
if (typeof r.planId !== 'string' || !exports.SCAFFOLD_PLAN_ID_RE.test(r.planId)) {
|
|
575
|
+
throw new Error('serializeStandardsBlock: planId does not match SCAFFOLD_PLAN_ID_RE');
|
|
576
|
+
}
|
|
577
|
+
if (!exports.STANDARDS_INDEX_STATUSES.includes(r.indexStatus)) {
|
|
578
|
+
throw new Error('serializeStandardsBlock: indexStatus is outside STANDARDS_INDEX_STATUSES');
|
|
579
|
+
}
|
|
580
|
+
const documents = Array.isArray(r.documents) ? r.documents : [];
|
|
581
|
+
if (r.degraded && documents.length > 0) {
|
|
582
|
+
throw new Error('serializeStandardsBlock: a degraded record carries no document lines');
|
|
583
|
+
}
|
|
584
|
+
if (r.degraded && !exports.STANDARDS_DEGRADED_KINDS.includes(r.degraded.kind)) {
|
|
585
|
+
throw new Error('serializeStandardsBlock: degraded kind is outside STANDARDS_DEGRADED_KINDS');
|
|
586
|
+
}
|
|
587
|
+
if (!r.degraded && documents.length === 0) {
|
|
588
|
+
throw new Error('serializeStandardsBlock: a record carries document lines or one degraded line');
|
|
589
|
+
}
|
|
590
|
+
for (const d of documents) {
|
|
591
|
+
if (!exports.STANDARDS_SHA_RE.test(String(d.sha)) || typeof d.ref !== 'string' || /\s/.test(d.ref) || d.ref === '') {
|
|
592
|
+
throw new Error('serializeStandardsBlock: a document line needs a ref and a 40-hex sha');
|
|
593
|
+
}
|
|
594
|
+
}
|
|
595
|
+
const stampProblem = checkRecordConsistency({ indexStatus: r.indexStatus, sha: slot(r.sha), documents, degraded: r.degraded });
|
|
596
|
+
if (stampProblem !== null)
|
|
597
|
+
throw new Error(`serializeStandardsBlock: ${stampProblem}`);
|
|
598
|
+
}
|
|
599
|
+
/**
|
|
600
|
+
* The block text the `vault standards block` verb prints: the open tag, the
|
|
601
|
+
* stamp, the document lines (empty reason slots by default) or the one
|
|
602
|
+
* degraded line, the override line when present, the close tag, and a
|
|
603
|
+
* trailing newline. An empty slot is written `reason:` with no trailing space,
|
|
604
|
+
* so an editor that strips trailing whitespace cannot change the record; the
|
|
605
|
+
* parser accepts `reason:` and `reason: <clause>`.
|
|
606
|
+
*/
|
|
607
|
+
function serializeStandardsBlock(record) {
|
|
608
|
+
assertSerializable(record);
|
|
609
|
+
const lines = [exports.VAULT_STANDARDS_TAG_OPEN, stampLine(record, true)];
|
|
610
|
+
if (record.degraded) {
|
|
611
|
+
lines.push(degradedLine(record.degraded));
|
|
612
|
+
}
|
|
613
|
+
else {
|
|
614
|
+
for (const d of record.documents) {
|
|
615
|
+
lines.push(`${VAULT_ENTRY_PREFIX}${d.path}\`${EM}${d.ref} @ ${d.sha}${EM}${reasonSlot(d.reason)}`);
|
|
616
|
+
}
|
|
617
|
+
}
|
|
618
|
+
if (record.override)
|
|
619
|
+
lines.push(overrideLine(record.override));
|
|
620
|
+
lines.push(exports.VAULT_STANDARDS_TAG_CLOSE);
|
|
621
|
+
return `${lines.join('\n')}\n`;
|
|
622
|
+
}
|
|
623
|
+
/**
|
|
624
|
+
* The section's anchor line (16-06, D-35): `- anchors: [start <commit> · ]end
|
|
625
|
+
* <commit>[ · backfilled by <who> at <iso-8601>]`. Throws on a value its own
|
|
626
|
+
* parser would refuse, so the writer can never emit a line its reader rejects;
|
|
627
|
+
* the verbs pass only shas they read from git and a `by` the seal leaf
|
|
628
|
+
* normalised, so a throw is an engine bug, never an operator input.
|
|
629
|
+
*/
|
|
630
|
+
function formatStandardsAnchorLine(anchors) {
|
|
631
|
+
const a = anchors && typeof anchors === 'object' ? anchors : {};
|
|
632
|
+
if (typeof a.end !== 'string' || !vault_seal_cjs_1.ANCHOR_SHA_RE.test(a.end))
|
|
633
|
+
throw new Error('formatStandardsAnchorLine: end is not a full lowercase commit id');
|
|
634
|
+
const hasStart = a.start !== undefined && a.start !== null;
|
|
635
|
+
if (hasStart && (typeof a.start !== 'string' || !vault_seal_cjs_1.ANCHOR_SHA_RE.test(a.start))) {
|
|
636
|
+
throw new Error('formatStandardsAnchorLine: start is not a full lowercase commit id');
|
|
637
|
+
}
|
|
638
|
+
const hasBackfill = a.backfill !== undefined && a.backfill !== null;
|
|
639
|
+
if (hasBackfill && !(0, vault_seal_cjs_1.isAnchorBackfill)(a.backfill))
|
|
640
|
+
throw new Error('formatStandardsAnchorLine: backfill is not { by, at }');
|
|
641
|
+
const facts = [...(hasStart ? [`start ${a.start}`] : []), `end ${a.end}`];
|
|
642
|
+
const tail = hasBackfill ? `${SEP}backfilled by ${a.backfill.by} at ${a.backfill.at}` : '';
|
|
643
|
+
return `${exports.STANDARDS_ANCHOR_PREFIX}${facts.join(SEP)}${tail}`;
|
|
644
|
+
}
|
|
645
|
+
/** `- anchors: [start H · ]end H[ · backfilled by <who> at <at>]`; H is the seal leaf's anchor shape. */
|
|
646
|
+
const ANCHOR_LINE_RE = /^- anchors: (?:start ([0-9a-f]{40}|[0-9a-f]{64}) \u00B7 )?end ([0-9a-f]{40}|[0-9a-f]{64})(?: \u00B7 backfilled by (.+) at (\S+))?$/;
|
|
647
|
+
/**
|
|
648
|
+
* Read one line as the anchor line, tri-state like `parseDegradedLine`: `null`
|
|
649
|
+
* when the line is not an anchor line at all (it does not open with the
|
|
650
|
+
* prefix), `{ ok: false, part }` when it opens with the prefix but is not one of
|
|
651
|
+
* the forms (a short or uppercase sha, a missing `end`, a malformed back-fill
|
|
652
|
+
* tail), `{ ok: true, value }` otherwise. The shas are checked against the seal
|
|
653
|
+
* leaf's `ANCHOR_SHA_RE` and the back-fill against `isAnchorBackfill`, so the
|
|
654
|
+
* grammar has one owner. Never throws.
|
|
655
|
+
*/
|
|
656
|
+
function parseStandardsAnchorLine(line) {
|
|
657
|
+
if (typeof line !== 'string' || !line.startsWith(exports.STANDARDS_ANCHOR_PREFIX.trimEnd()))
|
|
658
|
+
return null;
|
|
659
|
+
const m = ANCHOR_LINE_RE.exec(line);
|
|
660
|
+
if (!m) {
|
|
661
|
+
return { ok: false, part: `carries an anchor line that is not \`- anchors: [start <commit>${SEP}]end <commit>\` with full lowercase commit ids` };
|
|
662
|
+
}
|
|
663
|
+
const value = { end: m[2] };
|
|
664
|
+
if (m[1] !== undefined)
|
|
665
|
+
value.start = m[1];
|
|
666
|
+
if (!vault_seal_cjs_1.ANCHOR_SHA_RE.test(value.end) || (value.start !== undefined && !vault_seal_cjs_1.ANCHOR_SHA_RE.test(value.start))) {
|
|
667
|
+
return { ok: false, part: 'carries an anchor line whose commit is not a full lowercase commit id' };
|
|
668
|
+
}
|
|
669
|
+
if (m[3] !== undefined) {
|
|
670
|
+
const backfill = { by: m[3], at: m[4] };
|
|
671
|
+
if (!(0, vault_seal_cjs_1.isAnchorBackfill)(backfill)) {
|
|
672
|
+
return { ok: false, part: 'carries an anchor line whose back-fill is not `backfilled by <who> at <iso-8601>`' };
|
|
673
|
+
}
|
|
674
|
+
value.backfill = backfill;
|
|
675
|
+
}
|
|
676
|
+
return { ok: true, value };
|
|
677
|
+
}
|
|
678
|
+
/**
|
|
679
|
+
* The `## Standards Consulted` section the `vault standards consulted` verb
|
|
680
|
+
* prints: the heading, a blank line, the stamp (no INDEX.md status), then one
|
|
681
|
+
* entry per block document with an EMPTY status slot — a default of `read`
|
|
682
|
+
* would let a pasted section claim reads that never happened. A degraded
|
|
683
|
+
* record echoes its degraded line instead. `opts.entries` fills the slots (the
|
|
684
|
+
* tests and the reference examples use it); the CLI never does.
|
|
685
|
+
*
|
|
686
|
+
* 16-06: `opts.anchors` appends the anchor line LAST. Without it the output is
|
|
687
|
+
* byte-identical to the pre-16-06 serializer.
|
|
688
|
+
*/
|
|
689
|
+
function serializeConsultedSection(record, opts = {}) {
|
|
690
|
+
const lines = [exports.STANDARDS_CONSULTED_HEADING, '', stampLine(record, false)];
|
|
691
|
+
if (record.degraded) {
|
|
692
|
+
lines.push(degradedLine(record.degraded));
|
|
693
|
+
}
|
|
694
|
+
else {
|
|
695
|
+
const filled = new Map();
|
|
696
|
+
for (const e of opts.entries ?? [])
|
|
697
|
+
filled.set(e.path, e);
|
|
698
|
+
for (const d of record.documents) {
|
|
699
|
+
const e = filled.get(d.path);
|
|
700
|
+
const head = `${VAULT_ENTRY_PREFIX}${d.path}\``;
|
|
701
|
+
if (!e || e.status === 'unfilled')
|
|
702
|
+
lines.push(`${head}${EM_TRIMMED}`);
|
|
703
|
+
else if (e.status === 'read')
|
|
704
|
+
lines.push(`${head}${EM}${exports.STANDARDS_CONSULTED_READ}`);
|
|
705
|
+
else
|
|
706
|
+
lines.push(`${head}${EM}${exports.STANDARDS_CONSULTED_NOT_READ}${e.reason ?? ''}`.trimEnd());
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
if (opts.anchors)
|
|
710
|
+
lines.push(formatStandardsAnchorLine(opts.anchors));
|
|
711
|
+
return `${lines.join('\n')}\n`;
|
|
712
|
+
}
|
|
713
|
+
// ─── Whole-line scanning ─────────────────────────────────────────────────────
|
|
714
|
+
// The fence-open shape of the vault-index leaf (CommonMark: ≤3 spaces of
|
|
715
|
+
// indent, ≥3 backticks or ≥3 tildes; a backtick fence's info string may not
|
|
716
|
+
// carry a backtick). A closing fence uses the same character, at least as long.
|
|
717
|
+
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
718
|
+
// Any ATX heading at column 0 ends the consulted section.
|
|
719
|
+
const ATX_HEADING_RE = /^#{1,6}(?:[ \t]|$)/;
|
|
720
|
+
// So does a thematic break (CommonMark: up to three spaces of indent, then
|
|
721
|
+
// three or more of one of - * _, optionally spaced). The shipped SUMMARY
|
|
722
|
+
// template closes every SUMMARY with `---` and an italic footer, so a section
|
|
723
|
+
// placed last must end there, not run into the footer (15-REVIEW WR-05).
|
|
724
|
+
const THEMATIC_BREAK_RE = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
725
|
+
function splitLines(text) {
|
|
726
|
+
let src = typeof text === 'string' ? text : '';
|
|
727
|
+
if (src.charCodeAt(0) === 0xfeff)
|
|
728
|
+
src = src.slice(1);
|
|
729
|
+
return src.replace(/\r\n?/g, '\n').split('\n').map((l) => l.trimEnd());
|
|
730
|
+
}
|
|
731
|
+
/**
|
|
732
|
+
* The index of the first body line: past a YAML frontmatter that opens on
|
|
733
|
+
* line 1 with `---` and closes with a later `---` line. An unclosed
|
|
734
|
+
* frontmatter is not a frontmatter (the practices parser's rule).
|
|
735
|
+
*/
|
|
736
|
+
function bodyStart(lines) {
|
|
737
|
+
if (lines[0] !== '---')
|
|
738
|
+
return 0;
|
|
739
|
+
const close = lines.indexOf('---', 1);
|
|
740
|
+
return close > 0 ? close + 1 : 0;
|
|
741
|
+
}
|
|
742
|
+
/**
|
|
743
|
+
* A scanner over the "live" lines of a markdown body: outside the frontmatter,
|
|
744
|
+
* fenced code and HTML comments. `stop` lets a caller consume a region itself
|
|
745
|
+
* (a block's interior) by returning the index to resume after.
|
|
746
|
+
*/
|
|
747
|
+
function liveLineIndexes(lines, from, stop) {
|
|
748
|
+
const out = [];
|
|
749
|
+
let inFence = null;
|
|
750
|
+
let inComment = false;
|
|
751
|
+
for (let i = from; i < lines.length; i += 1) {
|
|
752
|
+
const line = lines[i];
|
|
753
|
+
if (inFence) {
|
|
754
|
+
const close = FENCE_OPEN_RE.exec(line);
|
|
755
|
+
if (close && close[1][0] === inFence.char && close[1].length >= inFence.len && close[2].trim() === '')
|
|
756
|
+
inFence = null;
|
|
757
|
+
continue;
|
|
758
|
+
}
|
|
759
|
+
if (inComment) {
|
|
760
|
+
if (line.includes('-->'))
|
|
761
|
+
inComment = false;
|
|
762
|
+
continue;
|
|
763
|
+
}
|
|
764
|
+
const fence = FENCE_OPEN_RE.exec(line);
|
|
765
|
+
if (fence && !(fence[1][0] === '`' && fence[2].includes('`'))) {
|
|
766
|
+
inFence = { char: fence[1][0], len: fence[1].length };
|
|
767
|
+
continue;
|
|
768
|
+
}
|
|
769
|
+
const lead = line.trimStart();
|
|
770
|
+
if (lead.startsWith('<!--')) {
|
|
771
|
+
if (!lead.slice(4).includes('-->'))
|
|
772
|
+
inComment = true;
|
|
773
|
+
continue;
|
|
774
|
+
}
|
|
775
|
+
out.push(i);
|
|
776
|
+
if (stop) {
|
|
777
|
+
const next = stop(i);
|
|
778
|
+
if (next !== null)
|
|
779
|
+
i = next;
|
|
780
|
+
}
|
|
781
|
+
}
|
|
782
|
+
return out;
|
|
783
|
+
}
|
|
784
|
+
/**
|
|
785
|
+
* Find a section by its exact heading line (16-03). The heading counts only as
|
|
786
|
+
* a whole column-0 line outside the frontmatter, fenced code and HTML comments;
|
|
787
|
+
* the section runs to the next ATX heading, the next thematic break or the end
|
|
788
|
+
* of the text. This is the rule `parseConsultedSection` applies, and it is
|
|
789
|
+
* exported so `src/vault-seal.cts` (the `## Practices Applied` presence test and
|
|
790
|
+
* the door-record parser) asks this scanner instead of keeping a second one.
|
|
791
|
+
* Never throws.
|
|
792
|
+
*/
|
|
793
|
+
function findLiveSection(text, heading) {
|
|
794
|
+
const lines = splitLines(text);
|
|
795
|
+
const live = liveLineIndexes(lines, bodyStart(lines));
|
|
796
|
+
const headings = live.filter((i) => lines[i] === heading);
|
|
797
|
+
const body = [];
|
|
798
|
+
if (headings.length > 0) {
|
|
799
|
+
for (let i = headings[0] + 1; i < lines.length; i += 1) {
|
|
800
|
+
if (ATX_HEADING_RE.test(lines[i]) || THEMATIC_BREAK_RE.test(lines[i]))
|
|
801
|
+
break;
|
|
802
|
+
if (lines[i].trim() !== '')
|
|
803
|
+
body.push(i);
|
|
804
|
+
}
|
|
805
|
+
}
|
|
806
|
+
return { lines, headings, body };
|
|
807
|
+
}
|
|
808
|
+
// ─── Line parsers ────────────────────────────────────────────────────────────
|
|
809
|
+
const DEGRADED_RE = /^- \((consulted-none|index-missing|could-not-check)\) \u2014 (.*)$/;
|
|
810
|
+
const DEGRADED_PREFIX_RE = /^- \(/;
|
|
811
|
+
const DOCUMENT_TAIL_RE = /^(\S+) @ ([0-9a-f]{40}) \u2014 reason:(?: (.*))?$/;
|
|
812
|
+
const OVERRIDE_RE = /^Override: gate (\S+) \u00B7 suppressed (\S+) \u00B7 by (.+?) \u00B7 at (\S+) \u00B7 reason: (.+)$/;
|
|
813
|
+
function parseDegradedLine(line) {
|
|
814
|
+
if (!DEGRADED_PREFIX_RE.test(line))
|
|
815
|
+
return null;
|
|
816
|
+
const m = DEGRADED_RE.exec(line);
|
|
817
|
+
if (!m)
|
|
818
|
+
return { ok: false, part: 'carries a degraded line outside the closed set (consulted-none, index-missing, could-not-check)' };
|
|
819
|
+
const kind = m[1];
|
|
820
|
+
const rest = m[2];
|
|
821
|
+
if (kind === 'consulted-none') {
|
|
822
|
+
if (rest !== 'reason:' && !rest.startsWith(exports.STANDARDS_REASON_MARKER)) {
|
|
823
|
+
return { ok: false, part: 'carries a consulted-none line without its `reason:` slot' };
|
|
824
|
+
}
|
|
825
|
+
return { ok: true, value: { kind, reason: rest === 'reason:' ? '' : rest.slice(exports.STANDARDS_REASON_MARKER.length) } };
|
|
826
|
+
}
|
|
827
|
+
if (kind === 'index-missing') {
|
|
828
|
+
if (rest !== exports.STANDARDS_INDEX_MISSING_CODE)
|
|
829
|
+
return { ok: false, part: `carries an index-missing line whose code is not ${exports.STANDARDS_INDEX_MISSING_CODE}` };
|
|
830
|
+
return { ok: true, value: { kind, reason: rest } };
|
|
831
|
+
}
|
|
832
|
+
if (!exports.STANDARDS_COULD_NOT_CHECK_CODES.includes(rest)) {
|
|
833
|
+
return { ok: false, part: 'carries a could-not-check line whose code is outside the closed set' };
|
|
834
|
+
}
|
|
835
|
+
return { ok: true, value: { kind, reason: rest } };
|
|
836
|
+
}
|
|
837
|
+
function parseStampLine(line, withStatus) {
|
|
838
|
+
if (!line.startsWith(exports.STANDARDS_STAMP_PREFIX))
|
|
839
|
+
return { ok: false, part: 'does not open with its stamp line' };
|
|
840
|
+
const facts = line.slice(exports.STANDARDS_STAMP_PREFIX.length).split(SEP);
|
|
841
|
+
const want = withStatus ? 4 : 3;
|
|
842
|
+
if (facts.length !== want) {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
part: withStatus
|
|
846
|
+
? `has a stamp with ${facts.length} of its 4 facts (plan, vault, sha, INDEX.md status)`
|
|
847
|
+
: `has a stamp with ${facts.length} of its 3 facts (plan, vault, sha) and no INDEX.md status`,
|
|
848
|
+
};
|
|
849
|
+
}
|
|
850
|
+
const [planId, vaultFact, shaFact, statusFact] = facts;
|
|
851
|
+
if (!exports.SCAFFOLD_PLAN_ID_RE.test(planId))
|
|
852
|
+
return { ok: false, part: 'has a stamp whose plan id is not a plan identifier such as 15-02' };
|
|
853
|
+
const v = /^vault (\S+) @ (\S+)$/.exec(vaultFact);
|
|
854
|
+
if (!v)
|
|
855
|
+
return { ok: false, part: 'has a stamp whose vault fact is not `vault <repo> @ <ref>`' };
|
|
856
|
+
const s = /^sha (\S+)$/.exec(shaFact);
|
|
857
|
+
if (!s || !(exports.STANDARDS_SHA_RE.test(s[1]) || s[1] === exports.STANDARDS_NONE)) {
|
|
858
|
+
return { ok: false, part: 'has a stamp whose sha is not 40 lowercase hex characters' };
|
|
859
|
+
}
|
|
860
|
+
let indexStatus = null;
|
|
861
|
+
if (withStatus) {
|
|
862
|
+
const st = /^INDEX\.md (\S+)$/.exec(statusFact);
|
|
863
|
+
if (!st || !exports.STANDARDS_INDEX_STATUSES.includes(st[1])) {
|
|
864
|
+
return { ok: false, part: `has a stamp whose INDEX.md status is not one of ${exports.STANDARDS_INDEX_STATUSES.join(', ')}` };
|
|
865
|
+
}
|
|
866
|
+
indexStatus = st[1];
|
|
867
|
+
}
|
|
868
|
+
return { ok: true, value: { planId, repo: v[1], ref: v[2], sha: s[1], indexStatus } };
|
|
869
|
+
}
|
|
870
|
+
/**
|
|
871
|
+
* The record's internal agreement: the stamp's INDEX.md status implies which
|
|
872
|
+
* line the record carries, and `none` in the sha slot is written exactly when
|
|
873
|
+
* INDEX.md is `not-loaded`, in both directions (15-REVIEW WR-02: the verb
|
|
874
|
+
* never writes another shape, so `sha none` on a missing or unchecked index
|
|
875
|
+
* is an edit, and accepting it would let that edit skip the pin comparison).
|
|
876
|
+
* Returns the violated part, or null.
|
|
877
|
+
*/
|
|
878
|
+
function checkRecordConsistency(r) {
|
|
879
|
+
const hasDocs = r.documents.length > 0;
|
|
880
|
+
if (r.indexStatus === 'ok') {
|
|
881
|
+
if (r.degraded && r.degraded.kind !== 'consulted-none')
|
|
882
|
+
return `has a stamp saying INDEX.md ok but carries the ${r.degraded.kind} line`;
|
|
883
|
+
}
|
|
884
|
+
else {
|
|
885
|
+
const code = STATUS_CODE[r.indexStatus];
|
|
886
|
+
const kind = r.indexStatus === 'missing' ? 'index-missing' : 'could-not-check';
|
|
887
|
+
if (hasDocs || !r.degraded || r.degraded.kind !== kind || r.degraded.reason !== code) {
|
|
888
|
+
return `has a stamp saying INDEX.md ${r.indexStatus}, which requires the line \`- (${kind}) \u2014 ${code}\``;
|
|
889
|
+
}
|
|
890
|
+
}
|
|
891
|
+
if (r.sha === exports.STANDARDS_NONE && r.indexStatus !== 'not-loaded') {
|
|
892
|
+
return 'has a stamp whose sha is not 40 lowercase hex characters (none is written only when INDEX.md is not-loaded)';
|
|
893
|
+
}
|
|
894
|
+
if (r.indexStatus === 'not-loaded' && r.sha !== exports.STANDARDS_NONE) {
|
|
895
|
+
return 'has a stamp saying INDEX.md not-loaded with a sha (a record with no loaded manifest writes sha none)';
|
|
896
|
+
}
|
|
897
|
+
return null;
|
|
898
|
+
}
|
|
899
|
+
/** The unfilled consulted slot: a head that ends at the separator. */
|
|
900
|
+
function splitUnfilledEntry(line) {
|
|
901
|
+
if (!line.endsWith(EM_TRIMMED))
|
|
902
|
+
return null;
|
|
903
|
+
// Reuse the one head grammar: complete the separator with a probe tail.
|
|
904
|
+
const probe = splitVaultEntryLine(`${line} _`);
|
|
905
|
+
if (probe === null || 'malformed' in probe || probe.rest !== '_')
|
|
906
|
+
return null;
|
|
907
|
+
return probe.path;
|
|
908
|
+
}
|
|
909
|
+
// ─── parseStandardsBlock ─────────────────────────────────────────────────────
|
|
910
|
+
function parseBlockInterior(lines, open, close, opts) {
|
|
911
|
+
const problems = [];
|
|
912
|
+
const base = { artifact: opts.artifact, where: 'block' };
|
|
913
|
+
const malformed = (part, idx, planId) => problems.push(problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_MALFORMED, { ...base, part, line: idx + 1, planId }));
|
|
914
|
+
const body = [];
|
|
915
|
+
for (let i = open + 1; i < close; i += 1)
|
|
916
|
+
if (lines[i].trim() !== '')
|
|
917
|
+
body.push(i);
|
|
918
|
+
if (body.length === 0 || !lines[body[0]].startsWith(exports.STANDARDS_STAMP_PREFIX)) {
|
|
919
|
+
const at = body.length === 0 ? open : body[0];
|
|
920
|
+
malformed('does not open with its stamp line', at);
|
|
921
|
+
return { record: null, problems };
|
|
922
|
+
}
|
|
923
|
+
const stamp = parseStampLine(lines[body[0]], true);
|
|
924
|
+
if (!stamp.ok) {
|
|
925
|
+
malformed(stamp.part, body[0]);
|
|
926
|
+
return { record: null, problems };
|
|
927
|
+
}
|
|
928
|
+
const planId = stamp.value.planId;
|
|
929
|
+
const documents = [];
|
|
930
|
+
const degradedLines = [];
|
|
931
|
+
let override = null;
|
|
932
|
+
let overrideAt = -1;
|
|
933
|
+
const seen = new Set();
|
|
934
|
+
let structural = false;
|
|
935
|
+
for (let k = 1; k < body.length; k += 1) {
|
|
936
|
+
const idx = body[k];
|
|
937
|
+
const line = lines[idx];
|
|
938
|
+
if (overrideAt !== -1) {
|
|
939
|
+
malformed('carries a line after its override line; the override line is last', idx, planId);
|
|
940
|
+
structural = true;
|
|
941
|
+
break;
|
|
942
|
+
}
|
|
943
|
+
if (line.startsWith(exports.STANDARDS_OVERRIDE_PREFIX)) {
|
|
944
|
+
const m = OVERRIDE_RE.exec(line);
|
|
945
|
+
if (!m) {
|
|
946
|
+
malformed('carries an override line that does not match the override grammar', idx, planId);
|
|
947
|
+
structural = true;
|
|
948
|
+
break;
|
|
949
|
+
}
|
|
950
|
+
override = { gate: m[1], suppressedReason: m[2], who: m[3], when: m[4], reason: m[5] };
|
|
951
|
+
overrideAt = idx;
|
|
952
|
+
continue;
|
|
953
|
+
}
|
|
954
|
+
if (line.startsWith(exports.STANDARDS_STAMP_PREFIX)) {
|
|
955
|
+
malformed('carries a second stamp line', idx, planId);
|
|
956
|
+
structural = true;
|
|
957
|
+
break;
|
|
958
|
+
}
|
|
959
|
+
const deg = parseDegradedLine(line);
|
|
960
|
+
if (deg !== null) {
|
|
961
|
+
if (!deg.ok) {
|
|
962
|
+
malformed(deg.part, idx, planId);
|
|
963
|
+
structural = true;
|
|
964
|
+
break;
|
|
965
|
+
}
|
|
966
|
+
degradedLines.push(deg.value);
|
|
967
|
+
continue;
|
|
968
|
+
}
|
|
969
|
+
const head = splitVaultEntryLine(line);
|
|
970
|
+
if (head === null) {
|
|
971
|
+
malformed('carries a line that is neither a document line, a degraded line nor the override line', idx, planId);
|
|
972
|
+
structural = true;
|
|
973
|
+
break;
|
|
974
|
+
}
|
|
975
|
+
if ('malformed' in head) {
|
|
976
|
+
malformed('carries a document line whose head is not - `<index-path>` followed by the separator', idx, planId);
|
|
977
|
+
structural = true;
|
|
978
|
+
break;
|
|
979
|
+
}
|
|
980
|
+
const tail = DOCUMENT_TAIL_RE.exec(head.rest);
|
|
981
|
+
if (!tail) {
|
|
982
|
+
malformed('carries a document line whose tail is not `<ref> @ <40-hex> \u2014 reason:`', idx, planId);
|
|
983
|
+
structural = true;
|
|
984
|
+
break;
|
|
985
|
+
}
|
|
986
|
+
if (seen.has(head.path)) {
|
|
987
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DOCUMENT_DUPLICATE, { ...base, path: head.path, line: idx + 1, planId }));
|
|
988
|
+
}
|
|
989
|
+
seen.add(head.path);
|
|
990
|
+
documents.push({ path: head.path, ref: tail[1], sha: tail[2], reason: tail[3] ?? '', line: idx + 1 });
|
|
991
|
+
}
|
|
992
|
+
if (structural)
|
|
993
|
+
return { record: null, problems };
|
|
994
|
+
if (degradedLines.length > 0 && documents.length > 0) {
|
|
995
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_MIXED, { ...base, planId }));
|
|
996
|
+
return { record: null, problems };
|
|
997
|
+
}
|
|
998
|
+
if (degradedLines.length > 1) {
|
|
999
|
+
malformed('carries more than one degraded line; a degraded record has exactly one', open, planId);
|
|
1000
|
+
return { record: null, problems };
|
|
1001
|
+
}
|
|
1002
|
+
if (degradedLines.length === 0 && documents.length === 0) {
|
|
1003
|
+
malformed('carries neither a document line nor a degraded line', open, planId);
|
|
1004
|
+
return { record: null, problems };
|
|
1005
|
+
}
|
|
1006
|
+
const record = {
|
|
1007
|
+
planId,
|
|
1008
|
+
repo: stamp.value.repo,
|
|
1009
|
+
ref: stamp.value.ref,
|
|
1010
|
+
sha: stamp.value.sha,
|
|
1011
|
+
indexStatus: stamp.value.indexStatus,
|
|
1012
|
+
documents,
|
|
1013
|
+
degraded: degradedLines[0] ?? null,
|
|
1014
|
+
override,
|
|
1015
|
+
};
|
|
1016
|
+
const inconsistent = checkRecordConsistency(record);
|
|
1017
|
+
if (inconsistent !== null) {
|
|
1018
|
+
malformed(inconsistent, body[0], planId);
|
|
1019
|
+
return { record: null, problems };
|
|
1020
|
+
}
|
|
1021
|
+
return { record, problems };
|
|
1022
|
+
}
|
|
1023
|
+
/**
|
|
1024
|
+
* Read the `<vault_standards>` block out of a PLAN.md text. Never throws; a
|
|
1025
|
+
* non-string is read as `''`.
|
|
1026
|
+
*
|
|
1027
|
+
* Tags count only as whole lines at column 0 (no indent: more restrictive than
|
|
1028
|
+
* CommonMark's three spaces, and where the CLI puts them), outside the YAML
|
|
1029
|
+
* frontmatter, fenced code and HTML comments — a tag quoted in prose or in an
|
|
1030
|
+
* example is not a block (Pitfall 5). The interior runs to the first column-0
|
|
1031
|
+
* close tag. Two blocks report `standards_block_duplicate` and the first is
|
|
1032
|
+
* still parsed; an open without a close reports `standards_block_unterminated`;
|
|
1033
|
+
* none reports `standards_block_absent`.
|
|
1034
|
+
*
|
|
1035
|
+
* Emptiness of a reason slot is NOT a parse problem: the verb emits empty
|
|
1036
|
+
* slots, and the judgement decides whether an empty slot is acceptable.
|
|
1037
|
+
*/
|
|
1038
|
+
function parseStandardsBlock(text, opts = {}) {
|
|
1039
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
1040
|
+
const lines = splitLines(text);
|
|
1041
|
+
const opens = [];
|
|
1042
|
+
liveLineIndexes(lines, bodyStart(lines), (i) => {
|
|
1043
|
+
if (lines[i] !== exports.VAULT_STANDARDS_TAG_OPEN)
|
|
1044
|
+
return null;
|
|
1045
|
+
let j = i + 1;
|
|
1046
|
+
while (j < lines.length && lines[j] !== exports.VAULT_STANDARDS_TAG_CLOSE)
|
|
1047
|
+
j += 1;
|
|
1048
|
+
if (j >= lines.length) {
|
|
1049
|
+
opens.push({ open: i, close: null });
|
|
1050
|
+
return lines.length;
|
|
1051
|
+
}
|
|
1052
|
+
opens.push({ open: i, close: j });
|
|
1053
|
+
return j;
|
|
1054
|
+
});
|
|
1055
|
+
if (opens.length === 0) {
|
|
1056
|
+
return {
|
|
1057
|
+
present: false,
|
|
1058
|
+
record: null,
|
|
1059
|
+
problems: [problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_ABSENT, { artifact: o.artifact })],
|
|
1060
|
+
openLine: null,
|
|
1061
|
+
closeLine: null,
|
|
1062
|
+
};
|
|
1063
|
+
}
|
|
1064
|
+
const first = opens[0];
|
|
1065
|
+
if (first.close === null) {
|
|
1066
|
+
return {
|
|
1067
|
+
present: true,
|
|
1068
|
+
record: null,
|
|
1069
|
+
problems: [problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_UNTERMINATED, { artifact: o.artifact, line: first.open + 1 })],
|
|
1070
|
+
openLine: first.open + 1,
|
|
1071
|
+
closeLine: null,
|
|
1072
|
+
};
|
|
1073
|
+
}
|
|
1074
|
+
const interior = parseBlockInterior(lines, first.open, first.close, o);
|
|
1075
|
+
const problems = [...interior.problems];
|
|
1076
|
+
if (opens.length > 1) {
|
|
1077
|
+
problems.unshift(problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_DUPLICATE, {
|
|
1078
|
+
artifact: o.artifact, count: opens.length, planId: interior.record?.planId,
|
|
1079
|
+
}));
|
|
1080
|
+
}
|
|
1081
|
+
return { present: true, record: interior.record, problems, openLine: first.open + 1, closeLine: first.close + 1 };
|
|
1082
|
+
}
|
|
1083
|
+
// ─── parseConsultedSection ───────────────────────────────────────────────────
|
|
1084
|
+
function emptySection(problems, present, headingLine) {
|
|
1085
|
+
return { present, planId: null, repo: null, ref: null, sha: null, entries: [], degraded: null, problems, headingLine };
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Read the `## Standards Consulted` section out of a SUMMARY.md text. Never
|
|
1089
|
+
* throws. The heading counts only as a whole column-0 line, written exactly,
|
|
1090
|
+
* outside the frontmatter, fenced code and HTML comments. The section runs to
|
|
1091
|
+
* the next ATX heading, the next thematic break (the SUMMARY template's `---`
|
|
1092
|
+
* footer rule) or the end of the text, and holds only the CLI's lines:
|
|
1093
|
+
* the stamp, then one entry per block document (or the one degraded line),
|
|
1094
|
+
* blank lines aside. Any other line is malformed; the reference says so.
|
|
1095
|
+
*/
|
|
1096
|
+
function parseConsultedSection(text, opts = {}) {
|
|
1097
|
+
const o = opts && typeof opts === 'object' ? opts : {};
|
|
1098
|
+
const base = { artifact: o.artifact, where: 'section' };
|
|
1099
|
+
const { lines, headings, body } = findLiveSection(text, exports.STANDARDS_CONSULTED_HEADING);
|
|
1100
|
+
if (headings.length === 0) {
|
|
1101
|
+
return emptySection([problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_ABSENT, { artifact: o.artifact })], false, null);
|
|
1102
|
+
}
|
|
1103
|
+
const h = headings[0];
|
|
1104
|
+
const malformed = (part, idx) => problem(exports.STANDARDS_PROBLEM_KIND.BLOCK_MALFORMED, { ...base, part, line: idx + 1 });
|
|
1105
|
+
if (headings.length > 1) {
|
|
1106
|
+
return emptySection([malformed('appears more than once', headings[1])], true, h + 1);
|
|
1107
|
+
}
|
|
1108
|
+
if (body.length === 0)
|
|
1109
|
+
return emptySection([malformed('has no stamp line', h)], true, h + 1);
|
|
1110
|
+
const stamp = parseStampLine(lines[body[0]], false);
|
|
1111
|
+
if (!stamp.ok)
|
|
1112
|
+
return emptySection([malformed(stamp.part, body[0])], true, h + 1);
|
|
1113
|
+
const problems = [];
|
|
1114
|
+
const entries = [];
|
|
1115
|
+
const degradedLines = [];
|
|
1116
|
+
const seen = new Set();
|
|
1117
|
+
// 16-06: at most ONE anchor line, anywhere after the stamp. A malformed or a
|
|
1118
|
+
// second one is a problem of its own kind and is DROPPED (never read as a
|
|
1119
|
+
// commit), while the rest of the record still parses, so the section's
|
|
1120
|
+
// other judgements still run.
|
|
1121
|
+
let anchors;
|
|
1122
|
+
let anchorLines = 0;
|
|
1123
|
+
for (let k = 1; k < body.length; k += 1) {
|
|
1124
|
+
const idx = body[k];
|
|
1125
|
+
const line = lines[idx];
|
|
1126
|
+
const anchor = parseStandardsAnchorLine(line);
|
|
1127
|
+
if (anchor !== null) {
|
|
1128
|
+
anchorLines += 1;
|
|
1129
|
+
if (anchorLines > 1) {
|
|
1130
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED, {
|
|
1131
|
+
...base, part: 'carries more than one anchor line; the section has at most one', line: idx + 1,
|
|
1132
|
+
}));
|
|
1133
|
+
anchors = undefined;
|
|
1134
|
+
}
|
|
1135
|
+
else if (!anchor.ok) {
|
|
1136
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED, { ...base, part: anchor.part, line: idx + 1 }));
|
|
1137
|
+
}
|
|
1138
|
+
else {
|
|
1139
|
+
anchors = anchor.value;
|
|
1140
|
+
}
|
|
1141
|
+
continue;
|
|
1142
|
+
}
|
|
1143
|
+
const deg = parseDegradedLine(line);
|
|
1144
|
+
if (deg !== null) {
|
|
1145
|
+
if (!deg.ok)
|
|
1146
|
+
return emptySection([malformed(deg.part, idx)], true, h + 1);
|
|
1147
|
+
degradedLines.push(deg.value);
|
|
1148
|
+
continue;
|
|
1149
|
+
}
|
|
1150
|
+
let entry = null;
|
|
1151
|
+
const unfilled = splitUnfilledEntry(line);
|
|
1152
|
+
if (unfilled !== null) {
|
|
1153
|
+
entry = { path: unfilled, status: 'unfilled', line: idx + 1 };
|
|
1154
|
+
}
|
|
1155
|
+
else {
|
|
1156
|
+
const head = splitVaultEntryLine(line);
|
|
1157
|
+
if (head !== null && !('malformed' in head)) {
|
|
1158
|
+
if (head.rest === exports.STANDARDS_CONSULTED_READ) {
|
|
1159
|
+
entry = { path: head.path, status: 'read', line: idx + 1 };
|
|
1160
|
+
}
|
|
1161
|
+
else if (head.rest.startsWith(exports.STANDARDS_CONSULTED_NOT_READ) || head.rest === exports.STANDARDS_CONSULTED_NOT_READ.trimEnd()) {
|
|
1162
|
+
entry = { path: head.path, status: 'not-read', reason: head.rest.slice(exports.STANDARDS_CONSULTED_NOT_READ.length), line: idx + 1 };
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
if (entry === null) {
|
|
1167
|
+
return emptySection([malformed('carries a line that is not the stamp, a degraded line, or an entry ending `read`, `not read: <one clause>` or the empty slot', idx)], true, h + 1);
|
|
1168
|
+
}
|
|
1169
|
+
if (seen.has(entry.path)) {
|
|
1170
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DOCUMENT_DUPLICATE, { ...base, path: entry.path, line: idx + 1 }));
|
|
1171
|
+
}
|
|
1172
|
+
seen.add(entry.path);
|
|
1173
|
+
entries.push(entry);
|
|
1174
|
+
}
|
|
1175
|
+
if (degradedLines.length > 0 && entries.length > 0) {
|
|
1176
|
+
return emptySection([malformed('carries a degraded line and entries together', h)], true, h + 1);
|
|
1177
|
+
}
|
|
1178
|
+
if (degradedLines.length > 1) {
|
|
1179
|
+
return emptySection([malformed('carries more than one degraded line', h)], true, h + 1);
|
|
1180
|
+
}
|
|
1181
|
+
if (degradedLines.length === 0 && entries.length === 0) {
|
|
1182
|
+
return emptySection([malformed('carries neither an entry nor a degraded line', h)], true, h + 1);
|
|
1183
|
+
}
|
|
1184
|
+
const parsed = {
|
|
1185
|
+
present: true,
|
|
1186
|
+
planId: stamp.value.planId,
|
|
1187
|
+
repo: stamp.value.repo,
|
|
1188
|
+
ref: stamp.value.ref,
|
|
1189
|
+
sha: stamp.value.sha,
|
|
1190
|
+
entries,
|
|
1191
|
+
degraded: degradedLines[0] ?? null,
|
|
1192
|
+
problems,
|
|
1193
|
+
headingLine: h + 1,
|
|
1194
|
+
};
|
|
1195
|
+
// Reconstructed only when present and well-formed, and only from a single
|
|
1196
|
+
// anchor line: a section with two keeps neither.
|
|
1197
|
+
if (anchors !== undefined && anchorLines === 1)
|
|
1198
|
+
parsed.anchors = anchors;
|
|
1199
|
+
return parsed;
|
|
1200
|
+
}
|
|
1201
|
+
function isIndexStatus(s) {
|
|
1202
|
+
return typeof s === 'string' && exports.STANDARDS_INDEX_STATUSES.includes(s);
|
|
1203
|
+
}
|
|
1204
|
+
/**
|
|
1205
|
+
* Whether a degraded line states what the gate measured (D-20): consulted-none
|
|
1206
|
+
* needs a healthy index; index-missing needs `missing`; could-not-check needs
|
|
1207
|
+
* a non-ok status whose code is exactly the recorded one.
|
|
1208
|
+
*/
|
|
1209
|
+
function degradedAgrees(d, status) {
|
|
1210
|
+
if (!isIndexStatus(status))
|
|
1211
|
+
return false;
|
|
1212
|
+
if (d.kind === 'consulted-none')
|
|
1213
|
+
return status === 'ok';
|
|
1214
|
+
if (status === 'ok')
|
|
1215
|
+
return false;
|
|
1216
|
+
if (d.kind === 'index-missing')
|
|
1217
|
+
return status === 'missing';
|
|
1218
|
+
return STATUS_CODE[status] === d.reason;
|
|
1219
|
+
}
|
|
1220
|
+
/**
|
|
1221
|
+
* Whether an override line agrees with the load gate NOW (D-35): it names the
|
|
1222
|
+
* load gate, carries the verb's timestamp form, and the load gate's own
|
|
1223
|
+
* verdict, recomputed at gate time, blocks for exactly the reason the line
|
|
1224
|
+
* says it suppressed. A line that fails this suppresses nothing.
|
|
1225
|
+
*
|
|
1226
|
+
* What this does NOT prove (15-REVIEW iter-2 WR-03): who wrote the line. It is
|
|
1227
|
+
* text in a file anyone can edit and carries no signature, and every field
|
|
1228
|
+
* checked here can be read off `check vault.status`, so a line typed by hand
|
|
1229
|
+
* whose fields match is accepted exactly as the verb's own would be. The
|
|
1230
|
+
* guarantee is agreement with the measured state, not CLI provenance; the bound
|
|
1231
|
+
* on a matching line is the execute:pre load gate (which blocks unless
|
|
1232
|
+
* `PP_VAULT_OVERRIDE_REASON` is set where it runs) and the strict points, which
|
|
1233
|
+
* block on any recorded override. `who` and the reason text are not used.
|
|
1234
|
+
*/
|
|
1235
|
+
function overrideAgrees(o, verdict) {
|
|
1236
|
+
if (!verdict || verdict.block !== true || typeof verdict.reason !== 'string' || verdict.reason === '')
|
|
1237
|
+
return false;
|
|
1238
|
+
if (o.gate !== exports.STANDARDS_OVERRIDE_GATE)
|
|
1239
|
+
return false;
|
|
1240
|
+
if (!isVerbTimestamp(o.when))
|
|
1241
|
+
return false;
|
|
1242
|
+
return o.suppressedReason === verdict.reason;
|
|
1243
|
+
}
|
|
1244
|
+
function degradedLabel(d) {
|
|
1245
|
+
return d.kind === 'consulted-none' ? 'consulted-none' : `${d.kind} (${d.reason})`;
|
|
1246
|
+
}
|
|
1247
|
+
/**
|
|
1248
|
+
* The ONE pin comparison, for the stamp and for every document line (D-18):
|
|
1249
|
+
* a recorded pin matches only when BOTH its ref and its full sha equal the
|
|
1250
|
+
* loaded manifest's.
|
|
1251
|
+
*/
|
|
1252
|
+
function pinDiffers(ref, sha, manifest) {
|
|
1253
|
+
return ref !== manifest.ref || sha !== manifest.sha;
|
|
1254
|
+
}
|
|
1255
|
+
function pinText(ref, sha) {
|
|
1256
|
+
return `${typeof ref === 'string' ? ref : exports.STANDARDS_NONE} @ ${typeof sha === 'string' ? sha : exports.STANDARDS_NONE}`;
|
|
1257
|
+
}
|
|
1258
|
+
/** The D-29 first-arm message: the measured state and the remedy; never a claim that a document was consulted. */
|
|
1259
|
+
function degradedAdvisory(record, d, status, artifact) {
|
|
1260
|
+
return composeDetail(`${echo(artifact) || 'The artifact'} records ${echo(degradedLabel(d))} and the gate measures INDEX.md ${status} too, so no standards document governed this plan; vault.required is off, so this does not block.`,
|
|
1261
|
+
// iter-3 WR-01: the repair for the measured status, from the one owner.
|
|
1262
|
+
`To govern it, ${lowerFirst(indexRepairFor(status).repair)}, then re-run \`pp-tools vault standards block --plan ${echoPlanId(record.planId)} <index-path...>\` and embed its output.`);
|
|
1263
|
+
}
|
|
1264
|
+
/**
|
|
1265
|
+
* The pin a manifest yields, or null when no vault is loaded (15-07). A
|
|
1266
|
+
* manifest with no 40-hex `resolvedSha`, or no single-token ref, is "not
|
|
1267
|
+
* loaded": a miss record (`source: miss`, every coordinate null) and a damaged
|
|
1268
|
+
* file both land here. ONE predicate for the `vault standards block` verb,
|
|
1269
|
+
* which writes the record, and the trace gate, which measures it, so the two
|
|
1270
|
+
* cannot disagree about whether a vault was loaded. Pure: the caller reads the
|
|
1271
|
+
* manifest.
|
|
1272
|
+
*/
|
|
1273
|
+
function standardsLoadedPin(manifest) {
|
|
1274
|
+
if (!manifest || typeof manifest !== 'object')
|
|
1275
|
+
return null;
|
|
1276
|
+
const m = manifest;
|
|
1277
|
+
if (typeof m.resolvedSha !== 'string' || !exports.STANDARDS_SHA_RE.test(m.resolvedSha))
|
|
1278
|
+
return null;
|
|
1279
|
+
if (typeof m.ref !== 'string' || !/^\S+$/.test(m.ref))
|
|
1280
|
+
return null;
|
|
1281
|
+
return { repo: typeof m.repo === 'string' ? m.repo : null, ref: m.ref, sha: m.resolvedSha };
|
|
1282
|
+
}
|
|
1283
|
+
/**
|
|
1284
|
+
* The stamp status for a measured state (15-07): `not-loaded` when there is no
|
|
1285
|
+
* loaded pin or no index state, whatever the index function answered (on a
|
|
1286
|
+
* miss record it answers a missing index, and the record must not say so);
|
|
1287
|
+
* `ok`; else the short form of the degraded reason. An unknown reason fails
|
|
1288
|
+
* closed to `unchecked`. Shared by the verb and the gate, like the pin above.
|
|
1289
|
+
*/
|
|
1290
|
+
function standardsMeasuredIndexStatus(pin, state) {
|
|
1291
|
+
if (pin === null || !state || typeof state !== 'object')
|
|
1292
|
+
return 'not-loaded';
|
|
1293
|
+
if (state.status === 'ok')
|
|
1294
|
+
return 'ok';
|
|
1295
|
+
for (const [status, code] of Object.entries(STATUS_CODE)) {
|
|
1296
|
+
if (status !== 'not-loaded' && code === state.reason)
|
|
1297
|
+
return status;
|
|
1298
|
+
}
|
|
1299
|
+
return 'unchecked';
|
|
1300
|
+
}
|
|
1301
|
+
/**
|
|
1302
|
+
* The INDEX paths a standards record may name (D-19): exactly the entries that
|
|
1303
|
+
* RESOLVED at the loaded sha, never a file merely present in the cache. ONE
|
|
1304
|
+
* set for the three surfaces that ask it, so they cannot disagree: the block
|
|
1305
|
+
* verb (what may be selected), the consulted verb (what an executor is told to
|
|
1306
|
+
* read, 15-REVIEW WR-04) and the trace gate (what a record may list). Pure:
|
|
1307
|
+
* the caller computes the index state, and passes null when no vault is loaded.
|
|
1308
|
+
*/
|
|
1309
|
+
function standardsResolvedPaths(state) {
|
|
1310
|
+
const entries = state && Array.isArray(state.entries) ? state.entries : [];
|
|
1311
|
+
return new Set(entries.filter((e) => e.resolution === 'resolved').map((e) => e.path));
|
|
1312
|
+
}
|
|
1313
|
+
/**
|
|
1314
|
+
* The degraded line a measured status implies when the planner selects no
|
|
1315
|
+
* document: `consulted-none` (empty reason slot) on `ok`, else the CLI-only
|
|
1316
|
+
* line carrying that status's code. The same table `checkRecordConsistency`
|
|
1317
|
+
* enforces, so the verb cannot write a record the parser refuses.
|
|
1318
|
+
*/
|
|
1319
|
+
function standardsDegradedLineFor(indexStatus) {
|
|
1320
|
+
if (indexStatus === 'ok')
|
|
1321
|
+
return { kind: 'consulted-none', reason: '' };
|
|
1322
|
+
return { kind: indexStatus === 'missing' ? 'index-missing' : 'could-not-check', reason: STATUS_CODE[indexStatus] };
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Judge one parsed PLAN record against the measured state. Pure: no reads, no
|
|
1326
|
+
* config, no clock. Order: the parse problems (a null record returns only
|
|
1327
|
+
* them); the plan id; the reason clauses; then, when the record needs the
|
|
1328
|
+
* cache (document lines or consulted-none) and no manifest was read,
|
|
1329
|
+
* `standards_cache_unreadable` and stop; then the stamp pin; then per document
|
|
1330
|
+
* its resolution and pin (on a degraded INDEX, one resolution problem naming
|
|
1331
|
+
* the measured status and `standardsIndexRepair`'s repair, iter-3 WR-01);
|
|
1332
|
+
* then the degraded arms of D-20/D-29:
|
|
1333
|
+
* - a degraded line that disagrees with the measured state: untrue;
|
|
1334
|
+
* - index-missing / could-not-check that agrees, `vault.required` off:
|
|
1335
|
+
* no problem, an advisory;
|
|
1336
|
+
* - agrees, `vault.required` on, no override line: untrue (the load gate
|
|
1337
|
+
* should have stopped the plan);
|
|
1338
|
+
* - agrees, `vault.required` on, override line present: no problem only
|
|
1339
|
+
* when the line agrees with `measured.loadVerdict` (D-35: the load gate's
|
|
1340
|
+
* verdict at gate time blocks for the reason the line records, under the
|
|
1341
|
+
* gate name and timestamp form the verb writes); otherwise untrue, so a
|
|
1342
|
+
* line that disagrees with the measured verdict removes nothing. A line
|
|
1343
|
+
* whose fields match is accepted whoever typed it: the guarantee is
|
|
1344
|
+
* agreement with measured state, not provenance (iter-2 WR-03).
|
|
1345
|
+
* A degraded record can therefore pass only where the load gate itself would
|
|
1346
|
+
* have let the build continue. The kind table stays at 17: agreement is never
|
|
1347
|
+
* a kind.
|
|
1348
|
+
*/
|
|
1349
|
+
function judgeStandardsPlan(parsed, measured, ctx) {
|
|
1350
|
+
const problems = Array.isArray(parsed?.problems) ? [...parsed.problems] : [];
|
|
1351
|
+
const record = parsed?.record ?? null;
|
|
1352
|
+
if (record === null)
|
|
1353
|
+
return { problems, advisory: null, overridden: false };
|
|
1354
|
+
const artifact = typeof ctx?.artifact === 'string' ? ctx.artifact : '';
|
|
1355
|
+
const base = { artifact, planId: record.planId };
|
|
1356
|
+
const overridden = record.override !== null && record.override !== undefined;
|
|
1357
|
+
if (typeof ctx?.filenamePlanId === 'string' && ctx.filenamePlanId !== '' && ctx.filenamePlanId !== record.planId) {
|
|
1358
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PLAN_ID_MISMATCH, { ...base, filenamePlanId: ctx.filenamePlanId }));
|
|
1359
|
+
}
|
|
1360
|
+
for (const doc of record.documents) {
|
|
1361
|
+
const v = validateReasonClause(doc.reason);
|
|
1362
|
+
if (!v.ok)
|
|
1363
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.REASON_INVALID, { ...base, path: doc.path, why: v.why, line: doc.line }));
|
|
1364
|
+
}
|
|
1365
|
+
const d = record.degraded;
|
|
1366
|
+
if (d && d.kind === 'consulted-none') {
|
|
1367
|
+
if (d.reason.trim() === '') {
|
|
1368
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.NONE_WITHOUT_REASON, base));
|
|
1369
|
+
}
|
|
1370
|
+
else {
|
|
1371
|
+
const v = validateReasonClause(d.reason);
|
|
1372
|
+
if (!v.ok)
|
|
1373
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.REASON_INVALID, { ...base, why: v.why }));
|
|
1374
|
+
}
|
|
1375
|
+
}
|
|
1376
|
+
const manifest = measured && measured.manifest ? measured.manifest : null;
|
|
1377
|
+
const needsCache = record.documents.length > 0 || (d !== null && d.kind === 'consulted-none');
|
|
1378
|
+
if (manifest === null && needsCache) {
|
|
1379
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CACHE_UNREADABLE, base));
|
|
1380
|
+
return { problems, advisory: null, overridden };
|
|
1381
|
+
}
|
|
1382
|
+
// iter-3 WR-01: a document record judged where the INDEX is degraded gets
|
|
1383
|
+
// ONE path problem naming the measured status and the INDEX repair (the
|
|
1384
|
+
// consulted verb's words, from `standardsIndexRepair`), never one "choose
|
|
1385
|
+
// from its entries" per path: there are no entries to choose from. Its pin
|
|
1386
|
+
// problems are still reported, each naming the same repair first. An
|
|
1387
|
+
// unknown status keeps the per-path arm, which still blocks.
|
|
1388
|
+
const docStatus = measured ? measured.indexStatus : undefined;
|
|
1389
|
+
const degradedIndex = record.documents.length > 0 && isIndexStatus(docStatus) && docStatus !== 'ok' ? docStatus : undefined;
|
|
1390
|
+
// A loaded pin is always compared, `none` included (15-REVIEW WR-02): a
|
|
1391
|
+
// record with no pin judged where a vault IS loaded was written against
|
|
1392
|
+
// another vault state, and D-29's pin arm applies to it.
|
|
1393
|
+
if (manifest !== null && pinDiffers(String(record.ref), String(record.sha), manifest)) {
|
|
1394
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH, {
|
|
1395
|
+
...base, recorded: pinText(record.ref, record.sha), loaded: pinText(manifest.ref, manifest.sha),
|
|
1396
|
+
...(degradedIndex !== undefined ? { indexStatus: degradedIndex } : {}),
|
|
1397
|
+
}));
|
|
1398
|
+
}
|
|
1399
|
+
const resolved = measured && measured.resolvedPaths instanceof Set ? measured.resolvedPaths : new Set();
|
|
1400
|
+
if (degradedIndex !== undefined) {
|
|
1401
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PATH_NOT_RESOLVED, { ...base, indexStatus: degradedIndex }));
|
|
1402
|
+
}
|
|
1403
|
+
for (const doc of record.documents) {
|
|
1404
|
+
if (degradedIndex === undefined && !resolved.has(doc.path)) {
|
|
1405
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PATH_NOT_RESOLVED, { ...base, path: doc.path, line: doc.line }));
|
|
1406
|
+
}
|
|
1407
|
+
if (manifest !== null && pinDiffers(doc.ref, doc.sha, manifest)) {
|
|
1408
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH, {
|
|
1409
|
+
...base, path: doc.path, line: doc.line, recorded: pinText(doc.ref, doc.sha), loaded: pinText(manifest.ref, manifest.sha),
|
|
1410
|
+
...(degradedIndex !== undefined ? { indexStatus: degradedIndex } : {}),
|
|
1411
|
+
}));
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
let advisory = null;
|
|
1415
|
+
if (d) {
|
|
1416
|
+
const status = measured ? measured.indexStatus : undefined;
|
|
1417
|
+
if (!degradedAgrees(d, status)) {
|
|
1418
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE, {
|
|
1419
|
+
...base, recorded: degradedLabel(d), measured: isIndexStatus(status) ? status : 'in an unknown state',
|
|
1420
|
+
}));
|
|
1421
|
+
}
|
|
1422
|
+
else if (d.kind !== 'consulted-none') {
|
|
1423
|
+
if (measured.required !== true) {
|
|
1424
|
+
advisory = degradedAdvisory(record, d, status, artifact);
|
|
1425
|
+
}
|
|
1426
|
+
else if (!overridden) {
|
|
1427
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE, {
|
|
1428
|
+
...base, recorded: degradedLabel(d), required: true, indexStatus: status,
|
|
1429
|
+
}));
|
|
1430
|
+
}
|
|
1431
|
+
else if (!overrideAgrees(record.override, measured.loadVerdict)) {
|
|
1432
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.DEGRADED_UNTRUE, {
|
|
1433
|
+
...base, recorded: degradedLabel(d), required: true, overrideUnverified: true,
|
|
1434
|
+
}));
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
}
|
|
1438
|
+
return { problems, advisory, overridden };
|
|
1439
|
+
}
|
|
1440
|
+
/**
|
|
1441
|
+
* Judge one parsed SUMMARY section on its own: the parse problems (absent,
|
|
1442
|
+
* malformed, duplicate entry), the plan id against the filename, every
|
|
1443
|
+
* unfilled status slot (a mismatch naming the path and both accepted forms),
|
|
1444
|
+
* and every `not read:` clause by the reason rules.
|
|
1445
|
+
*/
|
|
1446
|
+
function judgeConsultedSummary(parsed, ctx) {
|
|
1447
|
+
const problems = Array.isArray(parsed?.problems) ? [...parsed.problems] : [];
|
|
1448
|
+
if (!parsed || !parsed.present || parsed.planId === null)
|
|
1449
|
+
return problems;
|
|
1450
|
+
const artifact = typeof ctx?.artifact === 'string' ? ctx.artifact : '';
|
|
1451
|
+
const base = { artifact, planId: parsed.planId, where: 'section' };
|
|
1452
|
+
if (typeof ctx?.filenamePlanId === 'string' && ctx.filenamePlanId !== '' && ctx.filenamePlanId !== parsed.planId) {
|
|
1453
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PLAN_ID_MISMATCH, { ...base, filenamePlanId: ctx.filenamePlanId }));
|
|
1454
|
+
}
|
|
1455
|
+
for (const e of parsed.entries) {
|
|
1456
|
+
if (e.status === 'unfilled') {
|
|
1457
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, path: e.path, line: e.line, form: 'unfilled' }));
|
|
1458
|
+
}
|
|
1459
|
+
else if (e.status === 'not-read') {
|
|
1460
|
+
const v = validateReasonClause(e.reason ?? '');
|
|
1461
|
+
if (!v.ok)
|
|
1462
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.REASON_INVALID, { ...base, path: e.path, line: e.line, why: v.why }));
|
|
1463
|
+
}
|
|
1464
|
+
}
|
|
1465
|
+
return problems;
|
|
1466
|
+
}
|
|
1467
|
+
/**
|
|
1468
|
+
* Compare a PLAN's record with its paired SUMMARY section (D-13): the section
|
|
1469
|
+
* stamp's ref and sha must equal the block's; a degraded block is echoed by
|
|
1470
|
+
* exactly its degraded line; a document block is matched path for path, every
|
|
1471
|
+
* block document once and nothing extra. A null side compares to nothing:
|
|
1472
|
+
* pairing (`standards_pair_missing`, `standards_orphan_summary`) is the
|
|
1473
|
+
* handler's, by recorded plan id.
|
|
1474
|
+
*/
|
|
1475
|
+
function compareConsulted(record, section, ctx) {
|
|
1476
|
+
if (!record || !section || !section.present || section.planId === null)
|
|
1477
|
+
return [];
|
|
1478
|
+
const problems = [];
|
|
1479
|
+
const base = {
|
|
1480
|
+
artifact: typeof ctx?.artifact === 'string' ? ctx.artifact : '', planId: record.planId, where: 'section',
|
|
1481
|
+
};
|
|
1482
|
+
if (section.ref !== record.ref || section.sha !== record.sha) {
|
|
1483
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.PIN_MISMATCH, {
|
|
1484
|
+
...base, recorded: pinText(section.ref, section.sha), loaded: pinText(record.ref, record.sha),
|
|
1485
|
+
}));
|
|
1486
|
+
}
|
|
1487
|
+
if (record.degraded || section.degraded) {
|
|
1488
|
+
const same = record.degraded !== null && section.degraded !== null && section.entries.length === 0
|
|
1489
|
+
&& section.degraded.kind === record.degraded.kind && section.degraded.reason === record.degraded.reason;
|
|
1490
|
+
if (!same)
|
|
1491
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, form: 'degraded' }));
|
|
1492
|
+
return problems;
|
|
1493
|
+
}
|
|
1494
|
+
const inSection = new Set(section.entries.map((e) => e.path));
|
|
1495
|
+
const inBlock = new Set(record.documents.map((doc) => doc.path));
|
|
1496
|
+
for (const doc of record.documents) {
|
|
1497
|
+
if (!inSection.has(doc.path))
|
|
1498
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, path: doc.path, form: 'missing' }));
|
|
1499
|
+
}
|
|
1500
|
+
for (const e of section.entries) {
|
|
1501
|
+
if (!inBlock.has(e.path))
|
|
1502
|
+
problems.push(problem(exports.STANDARDS_PROBLEM_KIND.CONSULTED_MISMATCH, { ...base, path: e.path, line: e.line, form: 'extra' }));
|
|
1503
|
+
}
|
|
1504
|
+
return problems;
|
|
1505
|
+
}
|