@pareto4/pp-core 1.13.0 → 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 +2 -1
- package/agents/pp-debug-session-manager.md +9 -6
- package/agents/pp-executor.md +10 -13
- package/agents/pp-plan-checker.md +4 -4
- package/agents/pp-planner.md +23 -9
- package/agents/pp-vault-reviewer.md +244 -21
- 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 +1 -1
- package/commands/pp-plan-phase.md +1 -2
- package/commands/pp-vault-review.md +5 -3
- 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 +396 -82
- 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 +3334 -946
- package/pp-core/bin/lib/clusters.cjs +3 -0
- package/pp-core/bin/lib/command-aliases.cjs +0 -35
- package/pp-core/bin/lib/commands.cjs +9 -4
- package/pp-core/bin/lib/compose-bindings.cjs +251 -88
- 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 +4 -4
- package/pp-core/bin/lib/discovery-vault-check.cjs +246 -0
- package/pp-core/bin/lib/frontmatter.cjs +1 -38
- package/pp-core/bin/lib/git-base-branch.cjs +13 -11
- 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 +648 -738
- package/pp-core/bin/lib/init-interactive.cjs +76 -4
- 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 +189 -129
- package/pp-core/bin/lib/org-profile.cjs +469 -152
- package/pp-core/bin/lib/package-identity.cjs +3 -3
- package/pp-core/bin/lib/phase-command-router.cjs +1 -29
- package/pp-core/bin/lib/phase-scope.cjs +1 -1
- package/pp-core/bin/lib/phase.cjs +11 -253
- 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 +650 -126
- package/pp-core/bin/lib/vault-command-router.cjs +2604 -367
- 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 +139 -72
- package/pp-core/bin/lib/vault-review.cjs +194 -451
- 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 +607 -254
- package/pp-core/bin/lib/vault-update.cjs +9 -33
- package/pp-core/bin/lib/vault-write-back.cjs +70 -42
- package/pp-core/bin/lib/vault.cjs +2734 -920
- 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 -54
- 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 +2 -2
- 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 -25
- 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 +17 -34
- 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 +20 -23
- package/pp-core/workflows/_runtime-launcher.snippet.sh +0 -0
- package/pp-core/workflows/add-tests.md +55 -4
- package/pp-core/workflows/audit-fix.md +63 -2
- 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 -3
- package/pp-core/workflows/code-review.md +10 -1
- package/pp-core/workflows/complete-milestone.md +1 -1
- package/pp-core/workflows/debug.md +54 -4
- package/pp-core/workflows/discovery-sprint/prefill.md +19 -7
- package/pp-core/workflows/discovery-sprint/prototype.md +46 -49
- 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 +10 -2
- 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 -20
- 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 +155 -128
- package/pp-core/workflows/execute-plan.md +56 -6
- package/pp-core/workflows/fast.md +53 -4
- package/pp-core/workflows/help/modes/full.md +3 -3
- 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 +98 -45
- package/pp-core/workflows/quick.md +117 -3
- package/pp-core/workflows/secure-phase.md +1 -1
- package/pp-core/workflows/ship.md +57 -28
- package/pp-core/workflows/undo.md +1 -1
- package/pp-core/workflows/update.md +3 -3
- package/pp-core/workflows/validate-phase.md +58 -3
- package/pp-core/workflows/vault-review.md +282 -22
- package/pp-core/workflows/verify-work.md +12 -14
- 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 -11
- 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 +12 -10
- package/scripts/practice-arithmetic-contract.allow.json +35 -0
- package/scripts/prompt-injection-scan.sh +22 -17
- 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 -483
- package/pp-core/bin/lib/install-run.cjs +0 -536
- package/pp-core/bin/lib/loop-gates.cjs +0 -213
- package/pp-core/bin/lib/phase-manifest.cjs +0 -527
- package/pp-core/bin/lib/phase-tier.cjs +0 -480
- package/pp-core/bin/lib/plan-graph.cjs +0 -368
- package/pp-core/references/executor-vault-trace.md +0 -47
- package/pp-core/references/planner-plan-graph.md +0 -66
- package/pp-core/workflows/discovery-sprint/vault-fetch.md +0 -37
- 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 -73
- package/pp-core/workflows/execute-phase/steps/vault-door-gate.md +0 -19
- package/pp-core/workflows/plan-phase/steps/phase-tier.md +0 -43
- 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,699 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/vault-index.cts — the vault INDEX.md machine layer (Vault Assurance v1.2,
|
|
4
|
+
* Phase 14-02, D-06).
|
|
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-index.cjs`; do NOT
|
|
8
|
+
* hand-edit the compiled output. Behaviour is locked by
|
|
9
|
+
* `tests/vault-index.test.cjs`.
|
|
10
|
+
*
|
|
11
|
+
* In standards mode (`vault.mode: standards`) the client vault's own INDEX.md
|
|
12
|
+
* is the machine layer: one `- \`path\` — what it is; used when …` line per
|
|
13
|
+
* document, grouped by `##` section. This module is the ONE owner of:
|
|
14
|
+
* - the grammar constants (the single text the REL-02 one-pager quotes),
|
|
15
|
+
* - the entry head split shared with `discovery-vault-check`,
|
|
16
|
+
* - the vault-path classifier and its typed reasons,
|
|
17
|
+
* - the tolerant INDEX parser,
|
|
18
|
+
* - the membership resolver,
|
|
19
|
+
* - the frozen index reason codes and every index sentence,
|
|
20
|
+
* - the display sanitiser for vault-derived text,
|
|
21
|
+
* - the external-content fence-marker constants and `defangFenceMarkers`,
|
|
22
|
+
* - the standards card-line text.
|
|
23
|
+
* A surface that needs any of these quotes it from here rather than spelling
|
|
24
|
+
* its own copy, so the grammar the engine parses, the path rule discovery
|
|
25
|
+
* applies and the sentence an operator reads cannot drift apart.
|
|
26
|
+
*
|
|
27
|
+
* It imports NOTHING project-internal (no node builtins either). This is
|
|
28
|
+
* load-bearing: `src/vault.cts`, `src/discovery-vault-check.cts`,
|
|
29
|
+
* `src/check-command-router.cts`, `src/vault-command-router.cts`,
|
|
30
|
+
* `src/vault-practices.cts` and `src/practices-review-check.cts` all require
|
|
31
|
+
* it, and a leaf with no dependencies cannot close a require cycle among them
|
|
32
|
+
* (the `src/vault-mode.cts` precedent). The leaf therefore never touches the
|
|
33
|
+
* filesystem: resolution is pure string membership in a set the caller built
|
|
34
|
+
* from the manifest, and the bounded read of INDEX.md lives in `vault.cts`.
|
|
35
|
+
*
|
|
36
|
+
* Message discipline (T-13-03, T-14-04): no text here takes or echoes a
|
|
37
|
+
* configured value. Vault-derived text (a path, a section title, a case
|
|
38
|
+
* variant of the index filename) is quoted only after `sanitiseVaultText`,
|
|
39
|
+
* which strips terminal control and bidi characters, bounds the length and
|
|
40
|
+
* defangs the fence markers. The card line is built from fields its caller
|
|
41
|
+
* has already sanitised.
|
|
42
|
+
*/
|
|
43
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
44
|
+
exports.VAULT_INDEX_WITHHELD_KINDS = exports.VAULT_INDEX_UNCHECKED_CAUSES = exports.VAULT_INDEX_UNPARSEABLE_CAUSES = exports.VAULT_INDEX_REASON = exports.DISPLAY_UNSAFE_TEXT_CHAR_RE = exports.ZERO_WIDTH_SPACE = exports.EXTERNAL_CONTENT_FENCE_END = exports.EXTERNAL_CONTENT_FENCE_START = exports.VAULT_PATH_REASONS = exports.VAULT_INDEX_LIST_BOUND = exports.VAULT_TEXT_MAX = exports.VAULT_INDEX_GRAMMAR = exports.VAULT_SECTION_HEADING_PREFIX = exports.VAULT_USED_WHEN_RE = exports.VAULT_USED_WHEN_MARKER = exports.VAULT_ENTRY_SEPARATOR = exports.VAULT_ENTRY_PREFIX = exports.VAULT_INDEX_BYTE_CAP = exports.VAULT_INDEX_FILENAME = void 0;
|
|
45
|
+
exports.classifyVaultPath = classifyVaultPath;
|
|
46
|
+
exports.isSafeVaultPath = isSafeVaultPath;
|
|
47
|
+
exports.splitVaultEntryLine = splitVaultEntryLine;
|
|
48
|
+
exports.parseVaultIndex = parseVaultIndex;
|
|
49
|
+
exports.defangFenceMarkers = defangFenceMarkers;
|
|
50
|
+
exports.sanitiseVaultText = sanitiseVaultText;
|
|
51
|
+
exports.isDisplaySafeVaultPath = isDisplaySafeVaultPath;
|
|
52
|
+
exports.displayVaultPath = displayVaultPath;
|
|
53
|
+
exports.resolveVaultIndexEntries = resolveVaultIndexEntries;
|
|
54
|
+
exports.vaultIndexReasonShort = vaultIndexReasonShort;
|
|
55
|
+
exports.vaultIndexReasonText = vaultIndexReasonText;
|
|
56
|
+
exports.vaultIndexWarningMessage = vaultIndexWarningMessage;
|
|
57
|
+
exports.vaultIndexBlockMessage = vaultIndexBlockMessage;
|
|
58
|
+
exports.vaultIndexResolutionText = vaultIndexResolutionText;
|
|
59
|
+
exports.vaultStandardsCardLine = vaultStandardsCardLine;
|
|
60
|
+
// ─── Grammar (D-01, D-02, D-05) ──────────────────────────────────────────────
|
|
61
|
+
/** The index file, named exactly (case-sensitive) at the vault root. */
|
|
62
|
+
exports.VAULT_INDEX_FILENAME = 'INDEX.md';
|
|
63
|
+
/** The bounded read cap for INDEX.md: 256 KiB. The reader reads cap+1 bytes to detect oversize. */
|
|
64
|
+
exports.VAULT_INDEX_BYTE_CAP = 262144;
|
|
65
|
+
/** An entry line starts at column 0 with a hyphen, a space and a backtick. */
|
|
66
|
+
exports.VAULT_ENTRY_PREFIX = '- `';
|
|
67
|
+
/** Between the closing backtick and the remainder: space, em dash (U+2014), space. */
|
|
68
|
+
exports.VAULT_ENTRY_SEPARATOR = ' \u2014 ';
|
|
69
|
+
/** The canonical spelling of the used-when clause marker. */
|
|
70
|
+
exports.VAULT_USED_WHEN_MARKER = '; used when ';
|
|
71
|
+
/**
|
|
72
|
+
* The matcher for the used-when clause: `;`, whitespace, `used when`,
|
|
73
|
+
* whitespace, case-insensitive. The FIRST match splits (D-02). The split is
|
|
74
|
+
* never at a bare `;`: the real `practices/` entry carries `; used
|
|
75
|
+
* automatically …`, and splitting there would invent a clause (D-22).
|
|
76
|
+
*/
|
|
77
|
+
exports.VAULT_USED_WHEN_RE = /;\s+used when\s+/i;
|
|
78
|
+
/** A section is opened by a level-2 ATX heading at column 0. */
|
|
79
|
+
exports.VAULT_SECTION_HEADING_PREFIX = '## ';
|
|
80
|
+
/** The six grammar values bundled, frozen, for a consumer that quotes the grammar whole. */
|
|
81
|
+
exports.VAULT_INDEX_GRAMMAR = Object.freeze({
|
|
82
|
+
filename: exports.VAULT_INDEX_FILENAME,
|
|
83
|
+
byteCap: exports.VAULT_INDEX_BYTE_CAP,
|
|
84
|
+
entryPrefix: exports.VAULT_ENTRY_PREFIX,
|
|
85
|
+
separator: exports.VAULT_ENTRY_SEPARATOR,
|
|
86
|
+
usedWhenMarker: exports.VAULT_USED_WHEN_MARKER,
|
|
87
|
+
sectionHeadingPrefix: exports.VAULT_SECTION_HEADING_PREFIX,
|
|
88
|
+
});
|
|
89
|
+
/** The bound on a sanitised vault-derived string (equal to vault.cts SUMMARY_HEADING_MAX). */
|
|
90
|
+
exports.VAULT_TEXT_MAX = 500;
|
|
91
|
+
/** The bound on an envelope list of index paths; the counts beside it stay exact (D-14). */
|
|
92
|
+
exports.VAULT_INDEX_LIST_BOUND = 20;
|
|
93
|
+
// ─── Path safety (D-08) ──────────────────────────────────────────────────────
|
|
94
|
+
/**
|
|
95
|
+
* Why a vault path is unsafe, in classification order. Every reason maps to
|
|
96
|
+
* discovery's single `path_unsafe`; INDEX keeps the typed reason.
|
|
97
|
+
*/
|
|
98
|
+
exports.VAULT_PATH_REASONS = Object.freeze([
|
|
99
|
+
'empty',
|
|
100
|
+
'nul',
|
|
101
|
+
'control_char',
|
|
102
|
+
'backslash',
|
|
103
|
+
'absolute',
|
|
104
|
+
'traversal',
|
|
105
|
+
'leading_dot',
|
|
106
|
+
'dot_segment',
|
|
107
|
+
'empty_segment',
|
|
108
|
+
// 14-REVIEW WR-02: a path that cannot be displayed as written (a bidi,
|
|
109
|
+
// line-separator, format or zero-width character, any whitespace other than
|
|
110
|
+
// a single interior U+0020, a `"`, a fence marker, or over VAULT_TEXT_MAX
|
|
111
|
+
// code points — `isDisplaySafeVaultPath`).
|
|
112
|
+
// Refusing it is what makes `resolved ⇒ the displayed path is the checked
|
|
113
|
+
// path` hold: nothing selectable can render under a name that is not the file.
|
|
114
|
+
'display_unsafe',
|
|
115
|
+
]);
|
|
116
|
+
// C0 controls other than NUL (NUL has its own reason), DEL, and the C1 block.
|
|
117
|
+
const CONTROL_CHAR_RE = /[\u0001-\u001F\u007F-\u009F]/;
|
|
118
|
+
// A Windows drive prefix (`C:/x`, `c:x`) is absolute (D-08 tightening 1).
|
|
119
|
+
const DRIVE_PREFIX_RE = /^[A-Za-z]:/;
|
|
120
|
+
/**
|
|
121
|
+
* Classify a vault-relative path. The base is discovery's rule moved verbatim
|
|
122
|
+
* (non-empty; no backslash, no NUL; not starting with `/` or `.`; no empty,
|
|
123
|
+
* `.` or `..` segment), plus D-08's two tightenings: a Windows drive prefix is
|
|
124
|
+
* absolute, and any C0 or C1 control character is unsafe — a path the engine
|
|
125
|
+
* echoes must not carry terminal control bytes.
|
|
126
|
+
*
|
|
127
|
+
* Percent-encoding and unicode (look-alike dots included) are LITERAL: never
|
|
128
|
+
* decoded, never normalised. Resolution is exact membership, so they cannot
|
|
129
|
+
* traverse. A trailing slash yields an empty last segment: `empty_segment`.
|
|
130
|
+
*
|
|
131
|
+
* Order is pinned by the leaf test: traversal is checked before leading_dot so
|
|
132
|
+
* `../x` reports `traversal`. `display_unsafe` is checked last (14-REVIEW
|
|
133
|
+
* WR-02): a path that passes every structural rule but cannot be displayed
|
|
134
|
+
* exactly as written (`isDisplaySafeVaultPath`) is refused, so a path that
|
|
135
|
+
* resolves is always echoed byte-for-byte.
|
|
136
|
+
*/
|
|
137
|
+
function classifyVaultPath(p) {
|
|
138
|
+
if (typeof p !== 'string' || p === '')
|
|
139
|
+
return { ok: false, reason: 'empty' };
|
|
140
|
+
if (p.includes('\0'))
|
|
141
|
+
return { ok: false, reason: 'nul' };
|
|
142
|
+
if (CONTROL_CHAR_RE.test(p))
|
|
143
|
+
return { ok: false, reason: 'control_char' };
|
|
144
|
+
if (p.includes('\\'))
|
|
145
|
+
return { ok: false, reason: 'backslash' };
|
|
146
|
+
if (p.startsWith('/') || DRIVE_PREFIX_RE.test(p))
|
|
147
|
+
return { ok: false, reason: 'absolute' };
|
|
148
|
+
const segs = p.split('/');
|
|
149
|
+
if (segs.includes('..'))
|
|
150
|
+
return { ok: false, reason: 'traversal' };
|
|
151
|
+
if (p.startsWith('.'))
|
|
152
|
+
return { ok: false, reason: 'leading_dot' };
|
|
153
|
+
if (segs.includes('.'))
|
|
154
|
+
return { ok: false, reason: 'dot_segment' };
|
|
155
|
+
if (segs.includes(''))
|
|
156
|
+
return { ok: false, reason: 'empty_segment' };
|
|
157
|
+
// Last, so every structural reason keeps its precedence (14-REVIEW WR-02).
|
|
158
|
+
// The same predicate decides displayVaultPath's quoting (iteration 2 WR-01).
|
|
159
|
+
if (!isDisplaySafeVaultPath(p, exports.VAULT_TEXT_MAX))
|
|
160
|
+
return { ok: false, reason: 'display_unsafe' };
|
|
161
|
+
return { ok: true };
|
|
162
|
+
}
|
|
163
|
+
/** The boolean form of `classifyVaultPath`. */
|
|
164
|
+
function isSafeVaultPath(p) {
|
|
165
|
+
return classifyVaultPath(p).ok;
|
|
166
|
+
}
|
|
167
|
+
/**
|
|
168
|
+
* Split one already-`trimEnd()`-ed line into its entry head. The order is
|
|
169
|
+
* discovery's (`discovery-vault-check.cts` parseEntry): prefix → closing
|
|
170
|
+
* backtick → separator. Path safety is NOT judged here; each consumer applies
|
|
171
|
+
* `classifyVaultPath` after the split, so an unsafe path with a bad separator
|
|
172
|
+
* stays malformed rather than unsafe.
|
|
173
|
+
*
|
|
174
|
+
* The path is LITERAL: no trim inside the backticks (discovery extracts
|
|
175
|
+
* `" a.md "` as written), and empty backticks yield path `''`, which the
|
|
176
|
+
* classifier reports `empty`.
|
|
177
|
+
*/
|
|
178
|
+
function splitVaultEntryLine(line) {
|
|
179
|
+
if (typeof line !== 'string' || !line.startsWith(exports.VAULT_ENTRY_PREFIX))
|
|
180
|
+
return null;
|
|
181
|
+
const close = line.indexOf('`', exports.VAULT_ENTRY_PREFIX.length);
|
|
182
|
+
if (close === -1)
|
|
183
|
+
return { malformed: true };
|
|
184
|
+
const after = line.slice(close + 1);
|
|
185
|
+
if (!after.startsWith(exports.VAULT_ENTRY_SEPARATOR))
|
|
186
|
+
return { malformed: true };
|
|
187
|
+
const rest = after.slice(exports.VAULT_ENTRY_SEPARATOR.length);
|
|
188
|
+
if (rest.trim() === '')
|
|
189
|
+
return { malformed: true };
|
|
190
|
+
return { path: line.slice(exports.VAULT_ENTRY_PREFIX.length, close), rest };
|
|
191
|
+
}
|
|
192
|
+
// CommonMark 0.31.2 fenced code blocks: ≤3 spaces of indent, then ≥3 backticks
|
|
193
|
+
// or ≥3 tildes (never mixed). A backtick fence's info string may not contain a
|
|
194
|
+
// backtick, or the line is not a fence opener.
|
|
195
|
+
const FENCE_OPEN_RE = /^ {0,3}(`{3,}|~{3,})(.*)$/;
|
|
196
|
+
// A level-2 heading at column 0; `##` alone is an empty heading. `###` never
|
|
197
|
+
// matches because its third character is neither whitespace nor end of line.
|
|
198
|
+
const SECTION_HEADING_RE = /^##(?:[ \t]+(.*))?$/;
|
|
199
|
+
/**
|
|
200
|
+
* Strip an optional ATX closing sequence (`## Title ##`): a trailing run of
|
|
201
|
+
* `#` that is the whole title or follows a space/tab, together with that
|
|
202
|
+
* space/tab run. Same result as `title.replace(/(?:^|[ \t]+)#+$/, '')`, but a
|
|
203
|
+
* backwards scan rather than a regex: the regex retried `[ \t]+` from every
|
|
204
|
+
* position of a whitespace run, so one heading padded to the 256 KiB cap
|
|
205
|
+
* parsed in ~26 s (14-SECURITY T-14-05).
|
|
206
|
+
*/
|
|
207
|
+
function stripAtxClosing(title) {
|
|
208
|
+
let hashes = title.length;
|
|
209
|
+
while (hashes > 0 && title[hashes - 1] === '#')
|
|
210
|
+
hashes -= 1;
|
|
211
|
+
if (hashes === title.length)
|
|
212
|
+
return title;
|
|
213
|
+
if (hashes === 0)
|
|
214
|
+
return '';
|
|
215
|
+
let ws = hashes;
|
|
216
|
+
while (ws > 0 && (title[ws - 1] === ' ' || title[ws - 1] === '\t'))
|
|
217
|
+
ws -= 1;
|
|
218
|
+
return ws === hashes ? title : title.slice(0, ws);
|
|
219
|
+
}
|
|
220
|
+
// A level-1 heading at column 0 resets the section.
|
|
221
|
+
const LEVEL1_HEADING_RE = /^#(?:[ \t].*)?$/;
|
|
222
|
+
// D-28: an entry prefix indented 1-3 spaces is a near-miss, not a nested list.
|
|
223
|
+
const INDENTED_ENTRY_RE = /^ {1,3}- `/;
|
|
224
|
+
function buildEntry(section, path, rest, line) {
|
|
225
|
+
const m = exports.VAULT_USED_WHEN_RE.exec(rest);
|
|
226
|
+
if (m) {
|
|
227
|
+
const usedWhen = rest.slice(m.index + m[0].length).trim();
|
|
228
|
+
if (usedWhen !== '') {
|
|
229
|
+
return { section, path, description: rest.slice(0, m.index).trim(), usedWhen, line };
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
// No clause, or an empty one: the whole remainder is the description and the
|
|
233
|
+
// key is omitted — a clause is never invented and text is never dropped.
|
|
234
|
+
return { section, path, description: rest.trim(), line };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Parse an INDEX.md text tolerantly. Never throws; a non-string is read as
|
|
238
|
+
* `''`. One leading BOM is stripped and CRLF is tolerated; each line is
|
|
239
|
+
* `trimEnd()`-ed before matching.
|
|
240
|
+
*
|
|
241
|
+
* Entries are column-0 `- \`path\` — remainder` lines outside fenced code
|
|
242
|
+
* blocks and HTML comments. Blank lines, paragraphs, headings other than level
|
|
243
|
+
* 2, other bullets and deeper-indented items are prose and ignored silently. A
|
|
244
|
+
* column-0 line that starts like an entry but fails the head split, or an entry
|
|
245
|
+
* prefix indented 1-3 spaces, is a `malformed_entry` diagnostic — never an
|
|
246
|
+
* error. A fence or comment still open at end of text is an `unclosed_block`
|
|
247
|
+
* diagnostic naming its opening line, so the entries it swallowed cannot
|
|
248
|
+
* vanish silently.
|
|
249
|
+
*/
|
|
250
|
+
function parseVaultIndex(text) {
|
|
251
|
+
let src = typeof text === 'string' ? text : '';
|
|
252
|
+
if (src.charCodeAt(0) === 0xfeff)
|
|
253
|
+
src = src.slice(1);
|
|
254
|
+
const lines = src.split(/\r?\n/);
|
|
255
|
+
const sections = [];
|
|
256
|
+
// A Set beside the ordered list: `sections.includes` was O(S²) over a
|
|
257
|
+
// cap-sized index of distinct headings (14-SECURITY T-14-05).
|
|
258
|
+
const seenSections = new Set();
|
|
259
|
+
const entries = [];
|
|
260
|
+
const diagnostics = [];
|
|
261
|
+
let section = null;
|
|
262
|
+
let inFence = null;
|
|
263
|
+
let inComment = null;
|
|
264
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
265
|
+
const lineNo = i + 1;
|
|
266
|
+
const line = lines[i].trimEnd();
|
|
267
|
+
if (inFence) {
|
|
268
|
+
const close = FENCE_OPEN_RE.exec(line);
|
|
269
|
+
if (close && close[1][0] === inFence.char && close[1].length >= inFence.len && close[2].trim() === '') {
|
|
270
|
+
inFence = null;
|
|
271
|
+
}
|
|
272
|
+
continue;
|
|
273
|
+
}
|
|
274
|
+
if (inComment) {
|
|
275
|
+
if (line.includes('-->'))
|
|
276
|
+
inComment = null;
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
const fence = FENCE_OPEN_RE.exec(line);
|
|
280
|
+
if (fence && !(fence[1][0] === '`' && fence[2].includes('`'))) {
|
|
281
|
+
inFence = { char: fence[1][0], len: fence[1].length, openLine: lineNo };
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
const lead = line.trimStart();
|
|
285
|
+
if (lead.startsWith('<!--')) {
|
|
286
|
+
if (!lead.slice(4).includes('-->'))
|
|
287
|
+
inComment = { openLine: lineNo };
|
|
288
|
+
continue;
|
|
289
|
+
}
|
|
290
|
+
const h2 = SECTION_HEADING_RE.exec(line);
|
|
291
|
+
if (h2) {
|
|
292
|
+
const title = stripAtxClosing(h2[1] ?? '').trim();
|
|
293
|
+
section = title === '' ? null : title;
|
|
294
|
+
if (section !== null && !seenSections.has(section)) {
|
|
295
|
+
seenSections.add(section);
|
|
296
|
+
sections.push(section);
|
|
297
|
+
}
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
if (LEVEL1_HEADING_RE.test(line)) {
|
|
301
|
+
section = null;
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (line.startsWith(exports.VAULT_ENTRY_PREFIX)) {
|
|
305
|
+
const head = splitVaultEntryLine(line);
|
|
306
|
+
if (head === null || 'malformed' in head) {
|
|
307
|
+
diagnostics.push({ kind: 'malformed_entry', line: lineNo });
|
|
308
|
+
}
|
|
309
|
+
else {
|
|
310
|
+
entries.push(buildEntry(section, head.path, head.rest, lineNo));
|
|
311
|
+
}
|
|
312
|
+
continue;
|
|
313
|
+
}
|
|
314
|
+
if (INDENTED_ENTRY_RE.test(line)) {
|
|
315
|
+
diagnostics.push({ kind: 'malformed_entry', line: lineNo });
|
|
316
|
+
}
|
|
317
|
+
}
|
|
318
|
+
if (inFence)
|
|
319
|
+
diagnostics.push({ kind: 'unclosed_block', line: inFence.openLine });
|
|
320
|
+
if (inComment)
|
|
321
|
+
diagnostics.push({ kind: 'unclosed_block', line: inComment.openLine });
|
|
322
|
+
return { sections, entries, diagnostics };
|
|
323
|
+
}
|
|
324
|
+
// ─── Fence-marker owner (D-17, D-26) ─────────────────────────────────────────
|
|
325
|
+
// External-content fence markers (D-15 / T-24-05). The SUMMARY writer, the
|
|
326
|
+
// practice excerpt and the review check all fence untrusted vault text between
|
|
327
|
+
// these two literals; this leaf is their one cycle-free owner (14-04 re-points
|
|
328
|
+
// the three source copies here).
|
|
329
|
+
exports.EXTERNAL_CONTENT_FENCE_START = '<!-- vault:external-content-start -->';
|
|
330
|
+
exports.EXTERNAL_CONTENT_FENCE_END = '<!-- vault:external-content-end -->';
|
|
331
|
+
// Zero-width space (U+200B) — invisible when rendered, but breaks an exact
|
|
332
|
+
// string match against the literal fence marker (CR-01 / T-24-05). Used by
|
|
333
|
+
// `defangFenceMarkers` to neutralize a spoofed marker planted inside untrusted
|
|
334
|
+
// body text without visibly mangling the text for a human/agent reader.
|
|
335
|
+
exports.ZERO_WIDTH_SPACE = '\u200B';
|
|
336
|
+
/**
|
|
337
|
+
* Neutralize any occurrence of the external-content fence marker strings
|
|
338
|
+
* inside untrusted body text BEFORE it is wrapped between the real markers
|
|
339
|
+
* (CR-01 / T-24-05 fence-spoof escape). Without this, a practice body
|
|
340
|
+
* containing the literal end-fence marker (`<!-- vault:external-content-end
|
|
341
|
+
* -->`) could make a downstream consumer that naively splits on the exact
|
|
342
|
+
* marker treat the injected text as being OUTSIDE the untrusted-content
|
|
343
|
+
* fence — the classic "premature fence close" prompt-injection escape.
|
|
344
|
+
*
|
|
345
|
+
* Replaces each literal marker occurrence with a defanged variant (a
|
|
346
|
+
* zero-width space inserted immediately after the opening `<!--`) that is
|
|
347
|
+
* visually indistinguishable but no longer matches the real marker string —
|
|
348
|
+
* so only the two GENUINE markers `boundedFencedExcerpt` appends afterward
|
|
349
|
+
* remain real fence boundaries.
|
|
350
|
+
*/
|
|
351
|
+
function defangFenceMarkers(body) {
|
|
352
|
+
let result = body;
|
|
353
|
+
for (const marker of [exports.EXTERNAL_CONTENT_FENCE_START, exports.EXTERNAL_CONTENT_FENCE_END]) {
|
|
354
|
+
if (result.includes(marker)) {
|
|
355
|
+
const defanged = `${marker.slice(0, 4)}${exports.ZERO_WIDTH_SPACE}${marker.slice(4)}`;
|
|
356
|
+
result = result.split(marker).join(defanged);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
return result;
|
|
360
|
+
}
|
|
361
|
+
// ─── Display sanitiser (D-15, D-27) ──────────────────────────────────────────
|
|
362
|
+
// C0, DEL, C1, the line/paragraph separators, and the bidi formatting controls
|
|
363
|
+
// (embeddings/overrides U+202A-202E, isolates U+2066-2069, plus the marks
|
|
364
|
+
// U+200E/200F and U+061C — the Trojan Source set). JSON.stringify passes all
|
|
365
|
+
// of these through unescaped except C0, so --raw would carry them too.
|
|
366
|
+
const UNSAFE_DISPLAY_RE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]/g;
|
|
367
|
+
/**
|
|
368
|
+
* Make one vault-derived string safe to echo: every control, line-separator
|
|
369
|
+
* and bidi character becomes a space, whitespace runs collapse to one space,
|
|
370
|
+
* the ends are trimmed, the fence markers are defanged, and the result is cut
|
|
371
|
+
* to `max` code points with a trailing `…` when longer. Pure; never throws; a
|
|
372
|
+
* non-string becomes `''`.
|
|
373
|
+
*
|
|
374
|
+
* Defang runs AFTER the collapse, because a control byte inside a marker
|
|
375
|
+
* would otherwise become a space and reassemble a live marker.
|
|
376
|
+
*/
|
|
377
|
+
function sanitiseVaultText(s, max = exports.VAULT_TEXT_MAX) {
|
|
378
|
+
if (typeof s !== 'string')
|
|
379
|
+
return '';
|
|
380
|
+
const flat = s.replace(UNSAFE_DISPLAY_RE, ' ').replace(/\s+/g, ' ').trim();
|
|
381
|
+
const defanged = defangFenceMarkers(flat);
|
|
382
|
+
const points = Array.from(defanged);
|
|
383
|
+
const bound = Number.isFinite(max) && max >= 0 ? Math.floor(max) : exports.VAULT_TEXT_MAX;
|
|
384
|
+
if (points.length <= bound)
|
|
385
|
+
return defanged;
|
|
386
|
+
return `${points.slice(0, bound).join('')}\u2026`;
|
|
387
|
+
}
|
|
388
|
+
// The ONE character predicate for path display (14-REVIEW iteration 2, WR-01).
|
|
389
|
+
// A path carrying any of these is display-unsafe, and `displayVaultPath`
|
|
390
|
+
// escapes exactly these — so the set that decides "needs quoting" and the set
|
|
391
|
+
// that gets escaped cannot drift apart again. Members: everything
|
|
392
|
+
// UNSAFE_DISPLAY_RE strips; every whitespace character other than U+0020
|
|
393
|
+
// (no-break space, U+2000-200A, ideographic space, U+FEFF …), which the
|
|
394
|
+
// sanitiser would collapse; every format or default-ignorable code point
|
|
395
|
+
// (zero-width space and joiners, soft hyphen, word joiner, variation
|
|
396
|
+
// selectors, tag characters), which renders as nothing; and `"` and `\`, so a
|
|
397
|
+
// display that starts with `"` is always the quoted form and every `\` inside
|
|
398
|
+
// it starts an escape.
|
|
399
|
+
//
|
|
400
|
+
// The set is held in two parts so prose has one owner too (15-REVIEW iter-3
|
|
401
|
+
// IN-01): `DISPLAY_UNSAFE_TEXT_CHAR_RE` is every member except `"` and `\`,
|
|
402
|
+
// which are legal in prose and unsafe only in a path display, and a reason
|
|
403
|
+
// clause (`validateReasonClause` in vault-standards) refuses exactly it. The
|
|
404
|
+
// path predicate below is that set plus the two escape characters, never a
|
|
405
|
+
// second copy of it.
|
|
406
|
+
exports.DISPLAY_UNSAFE_TEXT_CHAR_RE = /[\u0000-\u001F\u007F-\u009F\u2028\u2029\u202A-\u202E\u2066-\u2069\u200E\u200F\u061C]|[^\S ]|\p{Cf}|\p{Default_Ignorable_Code_Point}/u;
|
|
407
|
+
const DISPLAY_UNSAFE_CHAR_RE = new RegExp(`${exports.DISPLAY_UNSAFE_TEXT_CHAR_RE.source}|["\\\\]`, 'u');
|
|
408
|
+
const DISPLAY_UNSAFE_CHAR_RE_G = new RegExp(DISPLAY_UNSAFE_CHAR_RE.source, 'gu');
|
|
409
|
+
/**
|
|
410
|
+
* Whether a vault path can be echoed exactly as written. False for a path
|
|
411
|
+
* that carries a `DISPLAY_UNSAFE_CHAR_RE` character, a run of two spaces,
|
|
412
|
+
* leading or trailing space, a fence marker, or more than `max` code points.
|
|
413
|
+
* Everything `sanitiseVaultText` would rewrite is covered, so this is never
|
|
414
|
+
* looser than the sanitiser. The classifier's `display_unsafe` reason and
|
|
415
|
+
* `displayVaultPath`'s quoting both ask this one question.
|
|
416
|
+
*/
|
|
417
|
+
function isDisplaySafeVaultPath(p, max = exports.VAULT_TEXT_MAX) {
|
|
418
|
+
if (typeof p !== 'string')
|
|
419
|
+
return false;
|
|
420
|
+
if (DISPLAY_UNSAFE_CHAR_RE.test(p))
|
|
421
|
+
return false;
|
|
422
|
+
if (p.startsWith(' ') || p.endsWith(' ') || p.includes(' '))
|
|
423
|
+
return false;
|
|
424
|
+
if (defangFenceMarkers(p) !== p)
|
|
425
|
+
return false;
|
|
426
|
+
const bound = Number.isFinite(max) && max >= 0 ? Math.floor(max) : exports.VAULT_TEXT_MAX;
|
|
427
|
+
return Array.from(p).length <= bound;
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* Echo a vault path WITHOUT rewriting it into a plausible different name
|
|
431
|
+
* (14-REVIEW WR-02, and iteration 2 WR-01). A display-safe path is returned
|
|
432
|
+
* as written. Any other path is returned double-quoted, with every
|
|
433
|
+
* `DISPLAY_UNSAFE_CHAR_RE` character escaped — `"` and `\` as `\"` and `\\`,
|
|
434
|
+
* the rest as `\uXXXX` per UTF-16 unit — and U+0020 kept as written, so
|
|
435
|
+
* padding and runs stay visible. A fence marker is broken by spelling its
|
|
436
|
+
* first space `\u0020`. So the quoted body holds no character that renders as
|
|
437
|
+
* nothing or as a different character, and removing the quotes never yields a
|
|
438
|
+
* display-safe path. Bounded at `max` code points inside the quotes, plus
|
|
439
|
+
* at most the rest of one escape and the `…`.
|
|
440
|
+
*/
|
|
441
|
+
function displayVaultPath(p, max = exports.VAULT_TEXT_MAX) {
|
|
442
|
+
if (typeof p !== 'string')
|
|
443
|
+
return '';
|
|
444
|
+
if (isDisplaySafeVaultPath(p, max))
|
|
445
|
+
return p;
|
|
446
|
+
let escaped = p.replace(DISPLAY_UNSAFE_CHAR_RE_G, (c) => (c === '"' || c === '\\'
|
|
447
|
+
? `\\${c}`
|
|
448
|
+
: Array.from({ length: c.length }, (_, i) => `\\u${c.charCodeAt(i).toString(16).toUpperCase().padStart(4, '0')}`).join('')));
|
|
449
|
+
for (const marker of [exports.EXTERNAL_CONTENT_FENCE_START, exports.EXTERNAL_CONTENT_FENCE_END]) {
|
|
450
|
+
escaped = escaped.split(marker).join(`${marker.slice(0, 4)}\\u0020${marker.slice(5)}`);
|
|
451
|
+
}
|
|
452
|
+
const bound = Number.isFinite(max) && max >= 0 ? Math.floor(max) : exports.VAULT_TEXT_MAX;
|
|
453
|
+
if (Array.from(escaped).length <= bound)
|
|
454
|
+
return `"${escaped}"`;
|
|
455
|
+
// Truncate on token boundaries, so the cut never leaves half an escape (a
|
|
456
|
+
// dangling `\` or `\u00`); every `\` in `escaped` starts an escape, because
|
|
457
|
+
// a raw `\` was escaped above. The token that crosses the bound is kept, so
|
|
458
|
+
// the body before the `…` is at least `bound` code points: with the `…` it is
|
|
459
|
+
// over-length, and removing the quotes can never yield a display-safe name.
|
|
460
|
+
let body = '';
|
|
461
|
+
let used = 0;
|
|
462
|
+
for (const token of escaped.match(/\\(?:["\\]|u[0-9A-F]{4})|[\s\S]/gu) ?? []) {
|
|
463
|
+
if (used >= bound)
|
|
464
|
+
break;
|
|
465
|
+
body += token;
|
|
466
|
+
used += Array.from(token).length;
|
|
467
|
+
}
|
|
468
|
+
return `"${body}\u2026"`;
|
|
469
|
+
}
|
|
470
|
+
/**
|
|
471
|
+
* Resolve parsed entries by exact string membership. The caller builds both
|
|
472
|
+
* sets from the manifest's recorded `files[]` (`resolvable` = what the SUMMARY
|
|
473
|
+
* tree may list; `withheld` = the credential-pattern files it omits). No path
|
|
474
|
+
* is ever joined, stat'ed or opened — the leaf has no filesystem — and an
|
|
475
|
+
* unsafe path is classified before the resolvable set is consulted, so it can
|
|
476
|
+
* never resolve.
|
|
477
|
+
*
|
|
478
|
+
* Order per entry: withheld → withheld · classify → unsafe · resolvable →
|
|
479
|
+
* resolved · else unresolved. Withheld comes FIRST (D-35, 14-SECURITY
|
|
480
|
+
* T-14-02): a credential file whose name is display-unsafe (`keys/a b.pem`,
|
|
481
|
+
* `keys/"prod".pem`) is in `files[]`, and classifying it first named it on
|
|
482
|
+
* the unsafe list. Withheld membership is an exact string compare that can
|
|
483
|
+
* only withhold more, never make an entry usable.
|
|
484
|
+
*/
|
|
485
|
+
function resolveVaultIndexEntries(entries, resolvable, withheld) {
|
|
486
|
+
const out = [];
|
|
487
|
+
const counts = { entries: entries.length, resolved: 0, unresolved: 0, unsafe: 0, withheld: 0 };
|
|
488
|
+
for (const entry of entries) {
|
|
489
|
+
if (withheld.has(entry.path)) {
|
|
490
|
+
out.push({ ...entry, resolution: 'withheld' });
|
|
491
|
+
counts.withheld += 1;
|
|
492
|
+
continue;
|
|
493
|
+
}
|
|
494
|
+
const verdict = classifyVaultPath(entry.path);
|
|
495
|
+
if (!verdict.ok) {
|
|
496
|
+
out.push({ ...entry, resolution: 'unsafe', unsafeReason: verdict.reason });
|
|
497
|
+
counts.unsafe += 1;
|
|
498
|
+
}
|
|
499
|
+
else if (resolvable.has(entry.path)) {
|
|
500
|
+
out.push({ ...entry, resolution: 'resolved' });
|
|
501
|
+
counts.resolved += 1;
|
|
502
|
+
}
|
|
503
|
+
else {
|
|
504
|
+
out.push({ ...entry, resolution: 'unresolved' });
|
|
505
|
+
counts.unresolved += 1;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
return { entries: out, counts };
|
|
509
|
+
}
|
|
510
|
+
// ─── Reasons (D-12) ──────────────────────────────────────────────────────────
|
|
511
|
+
/**
|
|
512
|
+
* The five degraded-index reasons. Frozen so a typo is a build error rather
|
|
513
|
+
* than a verdict that silently reports a different code. `UNRESOLVABLE` exists
|
|
514
|
+
* so a record cannot claim N governing entries when none of them names a file
|
|
515
|
+
* in the fetched tree (T-14-12). `UNCHECKED` (14-REVIEW iteration 2, WR-02) is
|
|
516
|
+
* the fail-closed arm: the engine could not run the check at all, so the record
|
|
517
|
+
* states that and claims nothing about INDEX.md itself — reusing
|
|
518
|
+
* `vault_index_unparseable` would record a fault in a file that was never read.
|
|
519
|
+
*/
|
|
520
|
+
exports.VAULT_INDEX_REASON = Object.freeze({
|
|
521
|
+
MISSING: 'vault_index_missing',
|
|
522
|
+
UNPARSEABLE: 'vault_index_unparseable',
|
|
523
|
+
EMPTY: 'vault_index_empty',
|
|
524
|
+
UNRESOLVABLE: 'vault_index_unresolvable',
|
|
525
|
+
UNCHECKED: 'vault_index_unchecked',
|
|
526
|
+
});
|
|
527
|
+
/** The measured causes behind `vault_index_unparseable`: each one is a fact about the bytes read. */
|
|
528
|
+
exports.VAULT_INDEX_UNPARSEABLE_CAUSES = Object.freeze(['oversize', 'nul', 'invalid_utf8', 'unreadable']);
|
|
529
|
+
/**
|
|
530
|
+
* The measured causes behind `vault_index_unchecked` (14-REVIEW WR-01, and
|
|
531
|
+
* iteration 2 WR-02): the cached manifest records no file list to check
|
|
532
|
+
* against, or the check faulted after the manifest was read. Neither says
|
|
533
|
+
* anything about INDEX.md, so neither sentence does.
|
|
534
|
+
*/
|
|
535
|
+
exports.VAULT_INDEX_UNCHECKED_CAUSES = Object.freeze(['no_file_list', 'check_faulted']);
|
|
536
|
+
const REASON_SHORT = Object.freeze({
|
|
537
|
+
[exports.VAULT_INDEX_REASON.MISSING]: 'missing',
|
|
538
|
+
[exports.VAULT_INDEX_REASON.UNPARSEABLE]: 'unparseable',
|
|
539
|
+
[exports.VAULT_INDEX_REASON.EMPTY]: 'empty',
|
|
540
|
+
[exports.VAULT_INDEX_REASON.UNRESOLVABLE]: 'unresolvable',
|
|
541
|
+
[exports.VAULT_INDEX_REASON.UNCHECKED]: 'unchecked',
|
|
542
|
+
});
|
|
543
|
+
const CAUSE_PHRASE = Object.freeze({
|
|
544
|
+
oversize: 'it is larger than the 256 KiB cap',
|
|
545
|
+
nul: 'it contains a NUL byte',
|
|
546
|
+
invalid_utf8: 'it is not valid UTF-8',
|
|
547
|
+
unreadable: 'it could not be opened as a regular file',
|
|
548
|
+
});
|
|
549
|
+
const UNCHECKED_CAUSE_PHRASE = Object.freeze({
|
|
550
|
+
no_file_list: 'the cached manifest records no file list to check it against',
|
|
551
|
+
check_faulted: 'the check faulted after the cached manifest was read',
|
|
552
|
+
});
|
|
553
|
+
// A case variant is a vault filename: bounded hard, it only needs to be recognisable.
|
|
554
|
+
const CASE_VARIANT_MAX = 80;
|
|
555
|
+
/** The short form of a reason (the envelope's `indexStatus` value). */
|
|
556
|
+
function vaultIndexReasonShort(reason) {
|
|
557
|
+
return REASON_SHORT[reason];
|
|
558
|
+
}
|
|
559
|
+
/**
|
|
560
|
+
* One sentence per reason, each naming INDEX.md, with no trailing period (the
|
|
561
|
+
* messages that embed it add their own). Only the measured cause is stated.
|
|
562
|
+
* An unknown code yields a generic sentence; the code itself is never echoed.
|
|
563
|
+
*/
|
|
564
|
+
function vaultIndexReasonText(reason, detail = {}) {
|
|
565
|
+
const d = detail && typeof detail === 'object' ? detail : {};
|
|
566
|
+
switch (reason) {
|
|
567
|
+
case exports.VAULT_INDEX_REASON.MISSING: {
|
|
568
|
+
const base = 'no file named exactly INDEX.md at the vault root is listed in the fetched tree';
|
|
569
|
+
const variant = typeof d.caseVariant === 'string' ? sanitiseVaultText(d.caseVariant, CASE_VARIANT_MAX) : '';
|
|
570
|
+
return variant === '' ? base : `${base} (found \`${variant}\`; the engine reads INDEX.md exactly)`;
|
|
571
|
+
}
|
|
572
|
+
case exports.VAULT_INDEX_REASON.UNPARSEABLE: {
|
|
573
|
+
const base = 'INDEX.md could not be read as text';
|
|
574
|
+
const cause = typeof d.cause === 'string' && Object.prototype.hasOwnProperty.call(CAUSE_PHRASE, d.cause)
|
|
575
|
+
? CAUSE_PHRASE[d.cause]
|
|
576
|
+
: '';
|
|
577
|
+
return cause === '' ? base : `${base}: ${cause}`;
|
|
578
|
+
}
|
|
579
|
+
case exports.VAULT_INDEX_REASON.EMPTY:
|
|
580
|
+
return 'INDEX.md parsed with zero entries — no line of the form - `path` — description';
|
|
581
|
+
case exports.VAULT_INDEX_REASON.UNRESOLVABLE:
|
|
582
|
+
return 'INDEX.md lists entries but none of them names a file in the fetched tree';
|
|
583
|
+
case exports.VAULT_INDEX_REASON.UNCHECKED: {
|
|
584
|
+
// States only what was measured: the check did not run. INDEX.md may be
|
|
585
|
+
// perfectly valid, so no sentence here says it was read or is at fault.
|
|
586
|
+
const base = 'the engine could not check INDEX.md against the cached file list';
|
|
587
|
+
const cause = typeof d.cause === 'string' && Object.prototype.hasOwnProperty.call(UNCHECKED_CAUSE_PHRASE, d.cause)
|
|
588
|
+
? UNCHECKED_CAUSE_PHRASE[d.cause]
|
|
589
|
+
: '';
|
|
590
|
+
return cause === '' ? base : `${base}: ${cause}`;
|
|
591
|
+
}
|
|
592
|
+
default:
|
|
593
|
+
return 'INDEX.md is degraded';
|
|
594
|
+
}
|
|
595
|
+
}
|
|
596
|
+
/**
|
|
597
|
+
* The degraded-index WARNING (policy off, D-13): two lines, the second naming
|
|
598
|
+
* the repair. Planning continues; nothing here is a block. The unchecked arm
|
|
599
|
+
* (14-REVIEW iteration 2, WR-02) names the local repair — re-fetch, then
|
|
600
|
+
* report a persisting fault — and never sends the vault owner to edit a file
|
|
601
|
+
* the check did not read.
|
|
602
|
+
*/
|
|
603
|
+
function vaultIndexWarningMessage(reason, detail) {
|
|
604
|
+
if (reason === exports.VAULT_INDEX_REASON.UNCHECKED) {
|
|
605
|
+
return [
|
|
606
|
+
`⚠ Vault (standards mode): the index check did not run — ${vaultIndexReasonText(reason, detail)}.`,
|
|
607
|
+
'Planning continues without a checked index; with vault.required off this is a warning. '
|
|
608
|
+
+ 'Remedy: re-run pp-tools vault fetch, then pp-tools check vault.status; if it persists, report it as an engine fault.',
|
|
609
|
+
].join('\n');
|
|
610
|
+
}
|
|
611
|
+
return [
|
|
612
|
+
`⚠ Vault (standards mode): the index is degraded — ${vaultIndexReasonText(reason, detail)}.`,
|
|
613
|
+
'Planning continues without a usable INDEX.md; with vault.required off this is a warning. '
|
|
614
|
+
+ "Remedy: fix INDEX.md in the vault repo (the vault owner's), then pp-tools vault fetch "
|
|
615
|
+
+ 'and re-run pp-tools check vault.status.',
|
|
616
|
+
].join('\n');
|
|
617
|
+
}
|
|
618
|
+
/**
|
|
619
|
+
* The degraded-index BLOCK message (policy on, D-13), in the register of
|
|
620
|
+
* `buildVaultBlockMessage`: pure-read diagnosis first, then repair-only
|
|
621
|
+
* remedies. No remedy names deleting a file or a cache — a deletion remedy
|
|
622
|
+
* would let an operator clear the evidence instead of fixing the index
|
|
623
|
+
* (memory: gate-message-remedy-hazard).
|
|
624
|
+
*/
|
|
625
|
+
function vaultIndexBlockMessage(reason, detail) {
|
|
626
|
+
if (reason === exports.VAULT_INDEX_REASON.UNCHECKED) {
|
|
627
|
+
return [
|
|
628
|
+
`vault.required is on and the client vault is loaded but the engine could not check its index: ${vaultIndexReasonText(reason, detail)}.`,
|
|
629
|
+
'Diagnose (pure reads — none of these can change this verdict):',
|
|
630
|
+
' pp-tools check vault.status --raw # this verdict, with the index fields',
|
|
631
|
+
"Remedy (repair only — re-fetching rewrites the cached manifest the check reads):",
|
|
632
|
+
' pp-tools vault fetch, then re-run the gate; if it persists, report it as an engine fault',
|
|
633
|
+
' or pp-tools config set vault.mode practices # if this project is not on standards mode yet',
|
|
634
|
+
' or set PP_VAULT_OVERRIDE_REASON to a substantive explanation for an attributed, recorded override',
|
|
635
|
+
].join('\n');
|
|
636
|
+
}
|
|
637
|
+
return [
|
|
638
|
+
`vault.required is on and the client vault is loaded but its INDEX.md is degraded: ${vaultIndexReasonText(reason, detail)}.`,
|
|
639
|
+
'Diagnose (pure reads — none of these can change this verdict):',
|
|
640
|
+
' pp-tools check vault.status --raw # this verdict, with the index fields',
|
|
641
|
+
' pp-tools vault summary # the INDEX section names what did and did not resolve',
|
|
642
|
+
"Remedy (repair only — the index is the vault owner's):",
|
|
643
|
+
' fix INDEX.md in the vault repo, then pp-tools vault fetch and re-run the gate',
|
|
644
|
+
' or pp-tools config set vault.mode practices # if this project is not on standards mode yet',
|
|
645
|
+
' or set PP_VAULT_OVERRIDE_REASON to a substantive explanation for an attributed, recorded override',
|
|
646
|
+
].join('\n');
|
|
647
|
+
}
|
|
648
|
+
/**
|
|
649
|
+
* What a withheld entry names, ONE text for the per-entry sentence and the
|
|
650
|
+
* SUMMARY aggregate line (14-REVIEW IN-01). The withheld set is not only
|
|
651
|
+
* credential-pattern files: the engine-written `SUMMARY.md` and `INDEX.md`
|
|
652
|
+
* itself (14-REVIEW WR-03) are withheld too, and the old "(credential
|
|
653
|
+
* pattern)" label was untrue for them.
|
|
654
|
+
*/
|
|
655
|
+
exports.VAULT_INDEX_WITHHELD_KINDS = '(INDEX.md itself, SUMMARY.md or a credential-pattern file)';
|
|
656
|
+
/**
|
|
657
|
+
* Why an entry did or did not resolve — the "why" SUMMARY's Not resolvable
|
|
658
|
+
* list prints. A withheld entry's sentence names no path (VSEC-03 count-only
|
|
659
|
+
* discipline). A trailing-slash path is a directory, not a document (D-08).
|
|
660
|
+
*/
|
|
661
|
+
function vaultIndexResolutionText(entry) {
|
|
662
|
+
const e = entry && typeof entry === 'object' ? entry : {};
|
|
663
|
+
switch (e.resolution) {
|
|
664
|
+
case 'resolved':
|
|
665
|
+
return 'resolved';
|
|
666
|
+
case 'unresolved':
|
|
667
|
+
return 'not in the fetched tree';
|
|
668
|
+
case 'withheld':
|
|
669
|
+
return `names a file withheld from this listing ${exports.VAULT_INDEX_WITHHELD_KINDS}`;
|
|
670
|
+
case 'unsafe': {
|
|
671
|
+
const reason = exports.VAULT_PATH_REASONS.includes(String(e.unsafeReason))
|
|
672
|
+
? String(e.unsafeReason)
|
|
673
|
+
: 'unclassified';
|
|
674
|
+
if (reason === 'empty_segment' && typeof e.path === 'string' && e.path.endsWith('/')) {
|
|
675
|
+
return 'names a directory, not a file — not readable as a document';
|
|
676
|
+
}
|
|
677
|
+
if (reason === 'display_unsafe') {
|
|
678
|
+
return 'cannot be displayed as written (a format, zero-width or non-space whitespace character, a double quote, a space run or padding, or over-length) — shown quoted and escaped, not readable as a document';
|
|
679
|
+
}
|
|
680
|
+
return `unsafe path (${reason})`;
|
|
681
|
+
}
|
|
682
|
+
default:
|
|
683
|
+
return 'not resolved';
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
// ─── Card line (D-15, D-28) ──────────────────────────────────────────────────
|
|
687
|
+
/**
|
|
688
|
+
* The one-line standards card shown on a healthy standards verdict. Every
|
|
689
|
+
* field arrives already sanitised (repo and ref through the router's manifest
|
|
690
|
+
* sanitiser); this function neither sanitises nor reads config. The suffix
|
|
691
|
+
* appears only when at least one entry is not usable. It says "not usable",
|
|
692
|
+
* not "unresolved" (14-REVIEW iteration 2, IN-01): the count is every entry
|
|
693
|
+
* that did not resolve — absent, unsafe or withheld — while the envelope's
|
|
694
|
+
* `indexUnresolved` lists the absent ones only, so one word must not mean both.
|
|
695
|
+
*/
|
|
696
|
+
function vaultStandardsCardLine(f) {
|
|
697
|
+
const line = `Vault (standards mode): ${f.repo}@${f.ref} sha ${f.sha8} — ${f.fileCount} files; INDEX.md: ${f.indexEntries} entries`;
|
|
698
|
+
return f.unusable > 0 ? `${line} (${f.unusable} not usable — see .planning/vault/SUMMARY.md)` : line;
|
|
699
|
+
}
|