@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,1936 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* src/vault-seal.cts — the seal leaf (Vault Assurance v1.2, Phase 16-03).
|
|
4
|
+
*
|
|
5
|
+
* ADR-457 build-at-publish: this TypeScript file is the SOURCE OF TRUTH. It
|
|
6
|
+
* compiles to the git-ignored `pp-core/bin/lib/vault-seal.cjs`; do NOT
|
|
7
|
+
* hand-edit the compiled output. Behaviour is locked by
|
|
8
|
+
* `tests/vault-seal.test.cjs`.
|
|
9
|
+
*
|
|
10
|
+
* Every defect this workstream shipped was one contract restated on N
|
|
11
|
+
* surfaces with one surface left behind. Phase 16 adds four contracts that
|
|
12
|
+
* several later plans need, so each gets ONE owner here, built and pinned
|
|
13
|
+
* before any consumer exists:
|
|
14
|
+
*
|
|
15
|
+
* - artifact presence (D-20): does a directory, or a project, hold
|
|
16
|
+
* engine-written vault records? `collectRecordedArtifacts` /
|
|
17
|
+
* `hasRecordedVaultArtifacts`;
|
|
18
|
+
* - the non-source path class (D-15, D-39): which repo paths are not
|
|
19
|
+
* governed source? `isNonSourcePath`, with `isPlanningPath` as its
|
|
20
|
+
* `.planning/` half;
|
|
21
|
+
* - the anchor grammar (D-14, D-35): the one shape a recorded commit may
|
|
22
|
+
* have before it can reach a git argv. `ANCHOR_SHA_RE`;
|
|
23
|
+
* - the door-record grammar (D-12): `formatDoorRecord` / `parseDoorRecord`.
|
|
24
|
+
*
|
|
25
|
+
* It also reads and writes the per-directory anchors file
|
|
26
|
+
* (`.vault-anchors.json`) the start anchor lives in (D-31).
|
|
27
|
+
*
|
|
28
|
+
* LAYERING (D-41). The TOP LEVEL of this file imports only Node built-ins
|
|
29
|
+
* (`fs`, `path`). That is load-bearing: `vault-trace.cts` and
|
|
30
|
+
* `vault-standards.cts` are expected to import `ANCHOR_SHA_RE` from here in
|
|
31
|
+
* 16-06, and a top-level import back into either would close a load-time
|
|
32
|
+
* require cycle. The record readers this leaf needs (`vault-trace`,
|
|
33
|
+
* `vault-standards`, `vault-audit`, `project-root`) are therefore required
|
|
34
|
+
* LAZILY, inside the functions that use them, which is the
|
|
35
|
+
* `readVaultGateActive` precedent in `src/vault.cts`. By the time any of those
|
|
36
|
+
* functions runs, every module has finished loading, so the lazy edge cannot
|
|
37
|
+
* observe a half-initialised export object.
|
|
38
|
+
*
|
|
39
|
+
* Message discipline: nothing here echoes a value it refused. A refused door
|
|
40
|
+
* record field, or a dropped anchor, is reported by naming the FIELD, never by
|
|
41
|
+
* reproducing what was found, so planning-tree text cannot inject terminal
|
|
42
|
+
* escapes or agent-directed prose through a refusal.
|
|
43
|
+
*/
|
|
44
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
45
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
46
|
+
};
|
|
47
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
48
|
+
exports.SEAL_GIT_TIMEOUT_MS = exports.SEAL_SUBJECT_CAP = exports.SEAL_EXAMINE_CAP = exports.SEAL_CANDIDATE_CAP = exports.UNCOVERED_PATHS_PER_COMMIT_CAP = exports.COVERING_RECORD_CAP = exports.UNCOVERED_COMMIT_CAP = exports.DISABLED_WITH_ARTIFACTS_REPAIR = exports.DISABLED_WITH_ARTIFACTS_LIST_CAP = exports.DISABLED_WITH_ARTIFACTS_STATUS = exports.DOOR_ARTIFACT_FILENAME_RE = exports.RECORDED_ARTIFACT_KIND = exports.RECORDED_ARTIFACT_DIR_CAP = exports.TRACE_ARTIFACT_COUNT_CAP = exports.TRACE_ARTIFACT_BYTE_CAP = exports.DOOR_RECORD_PROBLEM_KIND = exports.DOOR_NAMES = exports.DOOR_RECORD_HEADING = exports.VAULT_DOOR_STARTS_FILE = exports.CERTIFICATE_CAP = exports.VAULT_CERTIFICATES_FILE = exports.ATTESTATION_PATHS_CAP = exports.ATTESTATION_CAP = exports.VAULT_ATTESTATIONS_FILE = exports.DIR_START_HISTORY_CAP = exports.VAULT_ANCHORS_FILE = exports.NON_SOURCE_PATH_CLASSES = exports.ANCHOR_BACKFILL_WHO_MAX = exports.ANCHOR_SHA_RE = void 0;
|
|
49
|
+
exports.isAnchorSha = isAnchorSha;
|
|
50
|
+
exports.isIsoInstant = isIsoInstant;
|
|
51
|
+
exports.isAnchorBackfill = isAnchorBackfill;
|
|
52
|
+
exports.normaliseAnchorBackfillWho = normaliseAnchorBackfillWho;
|
|
53
|
+
exports.isPlanningPath = isPlanningPath;
|
|
54
|
+
exports.nonSourceClassOf = nonSourceClassOf;
|
|
55
|
+
exports.isNonSourcePath = isNonSourcePath;
|
|
56
|
+
exports.readDirAnchors = readDirAnchors;
|
|
57
|
+
exports.writeDirStartAnchor = writeDirStartAnchor;
|
|
58
|
+
exports.readDirAttestations = readDirAttestations;
|
|
59
|
+
exports.appendDirAttestations = appendDirAttestations;
|
|
60
|
+
exports.readDirCertificates = readDirCertificates;
|
|
61
|
+
exports.appendDirCertificates = appendDirCertificates;
|
|
62
|
+
exports.readDoorStart = readDoorStart;
|
|
63
|
+
exports.writeDoorStart = writeDoorStart;
|
|
64
|
+
exports.consumeDoorStart = consumeDoorStart;
|
|
65
|
+
exports.formatDoorRecord = formatDoorRecord;
|
|
66
|
+
exports.parseDoorRecord = parseDoorRecord;
|
|
67
|
+
exports.collectRecordedArtifacts = collectRecordedArtifacts;
|
|
68
|
+
exports.formatDisabledWithArtifacts = formatDisabledWithArtifacts;
|
|
69
|
+
exports.disabledWithArtifactsRepair = disabledWithArtifactsRepair;
|
|
70
|
+
exports.disabledWithArtifactsVerdict = disabledWithArtifactsVerdict;
|
|
71
|
+
exports.hasRecordedVaultArtifacts = hasRecordedVaultArtifacts;
|
|
72
|
+
exports.extractRecordAnchors = extractRecordAnchors;
|
|
73
|
+
exports.resolveRecordAnchors = resolveRecordAnchors;
|
|
74
|
+
exports.sealIsAncestorVia = sealIsAncestorVia;
|
|
75
|
+
exports.attestedFor = attestedFor;
|
|
76
|
+
exports.collectCoveringRecords = collectCoveringRecords;
|
|
77
|
+
exports.isPhaseRecordRel = isPhaseRecordRel;
|
|
78
|
+
exports.isSummaryRecordRel = isSummaryRecordRel;
|
|
79
|
+
exports.computeUncoveredSourceCommits = computeUncoveredSourceCommits;
|
|
80
|
+
const node_fs_1 = __importDefault(require("node:fs"));
|
|
81
|
+
const node_path_1 = __importDefault(require("node:path"));
|
|
82
|
+
// ─── Anchor grammar (D-14, D-35) ─────────────────────────────────────────────
|
|
83
|
+
/**
|
|
84
|
+
* A recorded anchor: a full commit id, lowercase hex, at sha1 (40) or sha256
|
|
85
|
+
* (64) length. Abbreviated ids, uppercase, and anything that could be read as
|
|
86
|
+
* an option (`--output=x`) are refused, so a value that passes can be placed
|
|
87
|
+
* in a git argv as a revision. The shape of `GIT_COMMIT_DIGEST_RE` in
|
|
88
|
+
* `src/vault-command-router.cts`, which 16-06 re-binds to this constant.
|
|
89
|
+
*/
|
|
90
|
+
exports.ANCHOR_SHA_RE = /^[0-9a-f]{40}$|^[0-9a-f]{64}$/;
|
|
91
|
+
/** True only for a string that matches `ANCHOR_SHA_RE`. Never throws. */
|
|
92
|
+
function isAnchorSha(value) {
|
|
93
|
+
return typeof value === 'string' && exports.ANCHOR_SHA_RE.test(value);
|
|
94
|
+
}
|
|
95
|
+
/** The bound on `by`, in code points (the trace's `boundedToken` width). */
|
|
96
|
+
exports.ANCHOR_BACKFILL_WHO_MAX = 80;
|
|
97
|
+
/**
|
|
98
|
+
* Characters a `by` value may not carry: C0/C1 controls, a backtick (it would
|
|
99
|
+
* open inline code on a markdown line), bidi controls, zero-width characters,
|
|
100
|
+
* line/paragraph separators and the BOM.
|
|
101
|
+
*/
|
|
102
|
+
const BACKFILL_WHO_UNSAFE_RE = /[\u0000-\u001F\u007F-\u009F`\u200B-\u200F\u2028-\u202E\u2066-\u2069\uFEFF]/;
|
|
103
|
+
/** Is `s` exactly a timestamp `toISOString` would write (form, then a round trip)? */
|
|
104
|
+
function isIsoInstant(s) {
|
|
105
|
+
if (typeof s !== 'string' || !/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/.test(s))
|
|
106
|
+
return false;
|
|
107
|
+
const d = new Date(s);
|
|
108
|
+
return !Number.isNaN(d.getTime()) && d.toISOString() === s;
|
|
109
|
+
}
|
|
110
|
+
/**
|
|
111
|
+
* Is this a well-formed back-fill attribution? `by` is a non-empty string of at
|
|
112
|
+
* most `ANCHOR_BACKFILL_WHO_MAX` code points, already trimmed, with none of the
|
|
113
|
+
* unsafe characters above; `at` round-trips through `toISOString` (a rolled-over
|
|
114
|
+
* date such as `2026-02-31` does not). Never throws.
|
|
115
|
+
*/
|
|
116
|
+
function isAnchorBackfill(value) {
|
|
117
|
+
if (!value || typeof value !== 'object' || Array.isArray(value))
|
|
118
|
+
return false;
|
|
119
|
+
const v = value;
|
|
120
|
+
const by = v.by;
|
|
121
|
+
if (typeof by !== 'string' || by === '' || by.trim() !== by)
|
|
122
|
+
return false;
|
|
123
|
+
if (Array.from(by).length > exports.ANCHOR_BACKFILL_WHO_MAX || BACKFILL_WHO_UNSAFE_RE.test(by))
|
|
124
|
+
return false;
|
|
125
|
+
return isIsoInstant(v.at);
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* Turn a raw identity (a git `user.name`, an environment value) into a `by`
|
|
129
|
+
* value `isAnchorBackfill` accepts: unsafe characters become spaces, runs of
|
|
130
|
+
* whitespace collapse, the ends are trimmed, the length is capped. An identity
|
|
131
|
+
* with nothing left is the literal `unknown`, never an empty string, so "we
|
|
132
|
+
* did not record who" stays readable as a fact.
|
|
133
|
+
*/
|
|
134
|
+
function normaliseAnchorBackfillWho(raw) {
|
|
135
|
+
const flat = String(typeof raw === 'string' ? raw : '')
|
|
136
|
+
.replace(new RegExp(BACKFILL_WHO_UNSAFE_RE.source, 'g'), ' ')
|
|
137
|
+
.replace(/\s+/g, ' ')
|
|
138
|
+
.trim();
|
|
139
|
+
const capped = Array.from(flat).slice(0, exports.ANCHOR_BACKFILL_WHO_MAX).join('').trim();
|
|
140
|
+
return capped === '' ? 'unknown' : capped;
|
|
141
|
+
}
|
|
142
|
+
// ─── Non-source path class (D-15, D-39) ──────────────────────────────────────
|
|
143
|
+
/**
|
|
144
|
+
* The seven classes of path that are NOT governed source. A commit that
|
|
145
|
+
* touches only these never needs a record (SEAL-01). D-39 adds the last three
|
|
146
|
+
* (agent instructions, not client code); that is the less restrictive reading,
|
|
147
|
+
* taken on the operator's behalf and open to reversal.
|
|
148
|
+
*
|
|
149
|
+
* `claude-md` and `agents-md` are top-level files and so already inside
|
|
150
|
+
* `top-level-markdown`; they are listed on their own so the D-39 rows stay
|
|
151
|
+
* visible and a reversal removes one row, not a rule.
|
|
152
|
+
*/
|
|
153
|
+
exports.NON_SOURCE_PATH_CLASSES = Object.freeze([
|
|
154
|
+
Object.freeze({ id: 'planning', description: 'anything under .planning/ (either separator)' }),
|
|
155
|
+
Object.freeze({ id: 'docs', description: 'anything under the top-level docs/ directory' }),
|
|
156
|
+
Object.freeze({ id: 'changeset', description: 'anything under the top-level .changeset/ directory' }),
|
|
157
|
+
Object.freeze({ id: 'claude-dir', description: 'anything under the top-level .claude/ directory (D-39)' }),
|
|
158
|
+
Object.freeze({ id: 'claude-md', description: 'the top-level CLAUDE.md (D-39)' }),
|
|
159
|
+
Object.freeze({ id: 'agents-md', description: 'the top-level AGENTS.md (D-39)' }),
|
|
160
|
+
Object.freeze({ id: 'top-level-markdown', description: 'a .md file at the repository root (no directory part)' }),
|
|
161
|
+
]);
|
|
162
|
+
/**
|
|
163
|
+
* The `.planning/` prefixes, both separators. Held as data so this file, like
|
|
164
|
+
* every other file under `src/`, carries no `.planning` prefix literal in a
|
|
165
|
+
* prefix test of its own: the one-owner pin counts those.
|
|
166
|
+
*/
|
|
167
|
+
const PLANNING_PREFIXES = Object.freeze(['.planning/', '.planning\\']);
|
|
168
|
+
/** The directory classes: prefix (always with the trailing slash) to class id. */
|
|
169
|
+
const DIRECTORY_CLASSES = Object.freeze([
|
|
170
|
+
Object.freeze(['docs/', 'docs']),
|
|
171
|
+
Object.freeze(['.changeset/', 'changeset']),
|
|
172
|
+
Object.freeze(['.claude/', 'claude-dir']),
|
|
173
|
+
]);
|
|
174
|
+
/**
|
|
175
|
+
* True for a `.planning/`-prefixed path, with either separator. The three
|
|
176
|
+
* former copies of this test (`src/vault-audit.cts`, the ship-practices filter
|
|
177
|
+
* in `src/check-command-router.cts`, and the commit_docs guard in
|
|
178
|
+
* `src/commands.cts`) call this. Case-sensitive; `..planning/x` and
|
|
179
|
+
* `.planningx/y` are not planning paths. Never throws.
|
|
180
|
+
*/
|
|
181
|
+
function isPlanningPath(p) {
|
|
182
|
+
if (typeof p !== 'string')
|
|
183
|
+
return false;
|
|
184
|
+
return PLANNING_PREFIXES.some((prefix) => p.startsWith(prefix));
|
|
185
|
+
}
|
|
186
|
+
/**
|
|
187
|
+
* The class a path belongs to, or `null` when it is governed source.
|
|
188
|
+
*
|
|
189
|
+
* Backslashes are normalised to `/` first. Directory classes are prefix
|
|
190
|
+
* matches WITH the trailing slash, so a file literally named `docs` and a
|
|
191
|
+
* directory `docsx/` are source. Top-level markdown means no `/` at all and a
|
|
192
|
+
* `.md` suffix, so a nested `packages/x/README.md` is source. Case-sensitive:
|
|
193
|
+
* `Docs/x.md` is source. Never throws.
|
|
194
|
+
*/
|
|
195
|
+
function nonSourceClassOf(p) {
|
|
196
|
+
if (typeof p !== 'string' || p === '')
|
|
197
|
+
return null;
|
|
198
|
+
const n = p.replace(/\\/g, '/');
|
|
199
|
+
if (isPlanningPath(n))
|
|
200
|
+
return 'planning';
|
|
201
|
+
for (const [prefix, id] of DIRECTORY_CLASSES) {
|
|
202
|
+
if (n.startsWith(prefix))
|
|
203
|
+
return id;
|
|
204
|
+
}
|
|
205
|
+
if (n === 'CLAUDE.md')
|
|
206
|
+
return 'claude-md';
|
|
207
|
+
if (n === 'AGENTS.md')
|
|
208
|
+
return 'agents-md';
|
|
209
|
+
if (!n.includes('/') && n.endsWith('.md'))
|
|
210
|
+
return 'top-level-markdown';
|
|
211
|
+
return null;
|
|
212
|
+
}
|
|
213
|
+
/** True when the path is in one of the seven non-source classes. Never throws. */
|
|
214
|
+
function isNonSourcePath(p) {
|
|
215
|
+
return nonSourceClassOf(p) !== null;
|
|
216
|
+
}
|
|
217
|
+
// ─── Anchors file (D-31) ─────────────────────────────────────────────────────
|
|
218
|
+
/** The per-directory file holding a phase or quick directory's start anchor. */
|
|
219
|
+
exports.VAULT_ANCHORS_FILE = '.vault-anchors.json';
|
|
220
|
+
/** The anchors file is a few dozen bytes; anything past this is not one. */
|
|
221
|
+
const ANCHORS_FILE_BYTE_CAP = 64 * 1024;
|
|
222
|
+
/** An ISO-8601 UTC instant as `Date.prototype.toISOString` writes it. */
|
|
223
|
+
const ISO_UTC_RE = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/;
|
|
224
|
+
/** How many superseded starts a directory keeps (oldest dropped first; dropping only narrows coverage). */
|
|
225
|
+
exports.DIR_START_HISTORY_CAP = 32;
|
|
226
|
+
function isDirStartEntry(v) {
|
|
227
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
228
|
+
return false;
|
|
229
|
+
const o = v;
|
|
230
|
+
return isAnchorSha(o.start) && typeof o.recordedAt === 'string' && ISO_UTC_RE.test(o.recordedAt);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Read a directory's anchors file. `null` when the file is missing, larger
|
|
234
|
+
* than a few kilobytes, not JSON, or does not carry a valid `start` anchor and
|
|
235
|
+
* an ISO `recordedAt`. Never throws: a missing anchor is a state the strict
|
|
236
|
+
* readers type, not an exception.
|
|
237
|
+
*/
|
|
238
|
+
function readDirAnchors(dirAbs) {
|
|
239
|
+
try {
|
|
240
|
+
const abs = node_path_1.default.join(dirAbs, exports.VAULT_ANCHORS_FILE);
|
|
241
|
+
const st = node_fs_1.default.statSync(abs);
|
|
242
|
+
if (!st.isFile() || st.size > ANCHORS_FILE_BYTE_CAP)
|
|
243
|
+
return null;
|
|
244
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
|
|
245
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
246
|
+
return null;
|
|
247
|
+
const o = parsed;
|
|
248
|
+
if (!isAnchorSha(o.start))
|
|
249
|
+
return null;
|
|
250
|
+
if (typeof o.recordedAt !== 'string' || !ISO_UTC_RE.test(o.recordedAt))
|
|
251
|
+
return null;
|
|
252
|
+
// A malformed history entry is dropped, never repaired: a missing earlier
|
|
253
|
+
// start can only make a record cover LESS (D-98, rule 3).
|
|
254
|
+
const earlier = Array.isArray(o.earlier)
|
|
255
|
+
? o.earlier.filter(isDirStartEntry).slice(0, exports.DIR_START_HISTORY_CAP).map((e) => ({ start: e.start, recordedAt: e.recordedAt }))
|
|
256
|
+
: [];
|
|
257
|
+
return { start: o.start, recordedAt: o.recordedAt, ...(earlier.length > 0 ? { earlier } : {}) };
|
|
258
|
+
}
|
|
259
|
+
catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Record a directory's start anchor. The start it supersedes is not lost: it
|
|
265
|
+
* moves to the front of `earlier` (newest first, `DIR_START_HISTORY_CAP`
|
|
266
|
+
* kept), where an END-only record written before the new start still finds
|
|
267
|
+
* the start that preceded its own end (16 review iteration 2 CR-02, D-98).
|
|
268
|
+
* Every record written from now on takes the new start, so nothing committed
|
|
269
|
+
* before it is claimed by them. Refuses a value that is not an anchor BEFORE
|
|
270
|
+
* anything is written (throws), because the caller is the engine and an
|
|
271
|
+
* invalid sha there is a bug, not input. The clock is injected (`now`), per
|
|
272
|
+
* the project's pure-function convention. Writes to a temporary sibling and
|
|
273
|
+
* renames, so a reader never sees half a file.
|
|
274
|
+
*/
|
|
275
|
+
function writeDirStartAnchor(dirAbs, sha, opts = {}) {
|
|
276
|
+
if (!isAnchorSha(sha)) {
|
|
277
|
+
throw new Error('writeDirStartAnchor: the start anchor is not a full lowercase commit id (40 or 64 hex)');
|
|
278
|
+
}
|
|
279
|
+
const now = typeof opts.now === 'function' ? opts.now : () => new Date();
|
|
280
|
+
const previous = readDirAnchors(dirAbs);
|
|
281
|
+
const earlier = previous === null
|
|
282
|
+
? []
|
|
283
|
+
: [{ start: previous.start, recordedAt: previous.recordedAt }, ...(previous.earlier ?? [])]
|
|
284
|
+
.filter((e) => e.start !== sha)
|
|
285
|
+
.slice(0, exports.DIR_START_HISTORY_CAP);
|
|
286
|
+
const record = { start: sha, recordedAt: now().toISOString(), ...(earlier.length > 0 ? { earlier } : {}) };
|
|
287
|
+
const abs = node_path_1.default.join(dirAbs, exports.VAULT_ANCHORS_FILE);
|
|
288
|
+
const tmp = `${abs}.${process.pid}.tmp`;
|
|
289
|
+
node_fs_1.default.writeFileSync(tmp, `${JSON.stringify(record, null, 2)}\n`, 'utf8');
|
|
290
|
+
node_fs_1.default.renameSync(tmp, abs);
|
|
291
|
+
return record;
|
|
292
|
+
}
|
|
293
|
+
// ─── Attestations file (16 review iteration 3 CR-01, D-102) ──────────────────
|
|
294
|
+
//
|
|
295
|
+
// A resume records a new start (D-98), so the phase's own commits made between
|
|
296
|
+
// its last record and the resume are covered by nothing, and the engine cannot
|
|
297
|
+
// tell them from an ungoverned commit. The operator's repair is to ATTEST each
|
|
298
|
+
// such commit, one by one, on the record that holds that work: never to widen
|
|
299
|
+
// the record's range (iteration 3 CR-01: a widened start covered every commit
|
|
300
|
+
// in between, an ungoverned one included, and the note that remained named
|
|
301
|
+
// none of them). An attestation covers exactly the commit it names, only when
|
|
302
|
+
// that commit lies on the record's own line between a start this directory
|
|
303
|
+
// recorded and the record's end, and every later strict check lists it with
|
|
304
|
+
// its subject, its paths and who attested it — a git `user.name`, recorded as
|
|
305
|
+
// given and never verified. `vault anchor backfill --from --attest` is the one
|
|
306
|
+
// writer.
|
|
307
|
+
/** The per-directory file holding the attestations made on that directory's SUMMARY records. */
|
|
308
|
+
exports.VAULT_ATTESTATIONS_FILE = '.vault-attestations.json';
|
|
309
|
+
/** At most this many attestations are kept per directory; the writer refuses more. */
|
|
310
|
+
exports.ATTESTATION_CAP = 200;
|
|
311
|
+
/** At most this many source paths are kept per attested commit. */
|
|
312
|
+
exports.ATTESTATION_PATHS_CAP = 20;
|
|
313
|
+
/** The attestations file's read cap. */
|
|
314
|
+
const ATTESTATIONS_FILE_BYTE_CAP = 256 * 1024;
|
|
315
|
+
const ATTEST_TEXT_UNSAFE_RE = /[\u0000-\u001F\u007F-\u009F]/;
|
|
316
|
+
/** `SEAL_SUBJECT_CAP` plus its ellipsis, restated because that constant is declared further down this file. */
|
|
317
|
+
const ATTEST_SUBJECT_MAX = 121;
|
|
318
|
+
function isAttestation(v) {
|
|
319
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
320
|
+
return false;
|
|
321
|
+
const o = v;
|
|
322
|
+
if (typeof o.record !== 'string' || o.record.includes('/') || o.record.includes('\\') || !/(^|-)SUMMARY\.md$/.test(o.record))
|
|
323
|
+
return false;
|
|
324
|
+
if (!isAnchorSha(o.from) || !isAnchorSha(o.sha))
|
|
325
|
+
return false;
|
|
326
|
+
if (typeof o.subject !== 'string' || o.subject.length > ATTEST_SUBJECT_MAX || ATTEST_TEXT_UNSAFE_RE.test(o.subject))
|
|
327
|
+
return false;
|
|
328
|
+
if (!Array.isArray(o.paths) || o.paths.length > exports.ATTESTATION_PATHS_CAP)
|
|
329
|
+
return false;
|
|
330
|
+
if (!o.paths.every((p) => typeof p === 'string' && p !== '' && p.length <= 1024 && !ATTEST_TEXT_UNSAFE_RE.test(p)))
|
|
331
|
+
return false;
|
|
332
|
+
return isAnchorBackfill({ by: o.by, at: o.at });
|
|
333
|
+
}
|
|
334
|
+
/**
|
|
335
|
+
* A directory's attestations, in the order they were made. A missing,
|
|
336
|
+
* oversized or unparsable file, or a malformed entry, reads as none: a lost
|
|
337
|
+
* attestation can only make a record cover LESS. Never throws.
|
|
338
|
+
*/
|
|
339
|
+
function readDirAttestations(dirAbs) {
|
|
340
|
+
try {
|
|
341
|
+
const abs = node_path_1.default.join(dirAbs, exports.VAULT_ATTESTATIONS_FILE);
|
|
342
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
343
|
+
if (!st.isFile() || st.size > ATTESTATIONS_FILE_BYTE_CAP)
|
|
344
|
+
return [];
|
|
345
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
|
|
346
|
+
const list = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.attestations : null;
|
|
347
|
+
if (!Array.isArray(list))
|
|
348
|
+
return [];
|
|
349
|
+
return list.filter(isAttestation).slice(0, exports.ATTESTATION_CAP)
|
|
350
|
+
.map((a) => ({ record: a.record, from: a.from, sha: a.sha, subject: a.subject, paths: [...a.paths], by: a.by, at: a.at }));
|
|
351
|
+
}
|
|
352
|
+
catch {
|
|
353
|
+
return [];
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* Append attestations to a directory's file. Throws, writing nothing, on an
|
|
358
|
+
* entry that is not well formed, a commit already attested for the same
|
|
359
|
+
* record, more than `ATTESTATION_CAP` in all, or a symlinked file: the caller
|
|
360
|
+
* is the engine, and each of those is a bug or a refusal it states first.
|
|
361
|
+
* Writes a temporary sibling and renames it.
|
|
362
|
+
*/
|
|
363
|
+
function appendDirAttestations(dirAbs, entries) {
|
|
364
|
+
if (!Array.isArray(entries) || entries.length === 0 || !entries.every(isAttestation)) {
|
|
365
|
+
throw new Error('appendDirAttestations: an attestation is not well formed');
|
|
366
|
+
}
|
|
367
|
+
const abs = node_path_1.default.join(dirAbs, exports.VAULT_ATTESTATIONS_FILE);
|
|
368
|
+
try {
|
|
369
|
+
if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
|
|
370
|
+
throw new Error('appendDirAttestations: the attestations file is a symlink');
|
|
371
|
+
}
|
|
372
|
+
catch (e) {
|
|
373
|
+
if (e?.code !== 'ENOENT')
|
|
374
|
+
throw e;
|
|
375
|
+
}
|
|
376
|
+
const existing = readDirAttestations(dirAbs);
|
|
377
|
+
const seen = new Set(existing.map((a) => `${a.record}\u0000${a.sha}`));
|
|
378
|
+
for (const a of entries) {
|
|
379
|
+
const key = `${a.record}\u0000${a.sha}`;
|
|
380
|
+
if (seen.has(key))
|
|
381
|
+
throw new Error('appendDirAttestations: that commit is already attested for this record');
|
|
382
|
+
seen.add(key);
|
|
383
|
+
}
|
|
384
|
+
const next = [...existing, ...entries];
|
|
385
|
+
if (next.length > exports.ATTESTATION_CAP)
|
|
386
|
+
throw new Error(`appendDirAttestations: more than ${exports.ATTESTATION_CAP} attestations in one directory`);
|
|
387
|
+
const tmp = `${abs}.${process.pid}.tmp`;
|
|
388
|
+
node_fs_1.default.writeFileSync(tmp, `${JSON.stringify({ attestations: next }, null, 2)}\n`, 'utf8');
|
|
389
|
+
node_fs_1.default.renameSync(tmp, abs);
|
|
390
|
+
return next;
|
|
391
|
+
}
|
|
392
|
+
// ─── Certificates file (16 gap closure 16-18, D-107) ─────────────────────────
|
|
393
|
+
//
|
|
394
|
+
// SEAL-01's lower bound (computeUncoveredSourceCommits (c2)) let any earlier
|
|
395
|
+
// PHASE record's end bound a later phase's reach, whether or not that phase's
|
|
396
|
+
// own strict check had passed: nothing recorded said so (D-101's reason for
|
|
397
|
+
// filing, D-106's ruling to fix it). A phase whose strict check named an
|
|
398
|
+
// ungoverned commit and was then abandoned kept its SUMMARY, and that SUMMARY's
|
|
399
|
+
// end hid the commit from every later phase. A certificate is that missing
|
|
400
|
+
// fact: an engine-written entry saying "this record's END was judged by a clean
|
|
401
|
+
// strict check of its phase". Its one writer is `vault anchor certify
|
|
402
|
+
// <phase-dir>` (plan 16-19), which runs `check vault.trace --unpaired strict`
|
|
403
|
+
// itself and certifies only on `status: checked`, `block: false` and no
|
|
404
|
+
// override (D-108), so the check stays read-only (TRACE-04). It is keyed by
|
|
405
|
+
// the record's END sha, so a SUMMARY written or re-anchored later is
|
|
406
|
+
// uncertified until the next clean strict check. A malformed entry is dropped
|
|
407
|
+
// on read and the record reads as UNCERTIFIED: the direction that bounds less.
|
|
408
|
+
/** The per-directory file holding the certificates for that directory's SUMMARY records' ends. */
|
|
409
|
+
exports.VAULT_CERTIFICATES_FILE = '.vault-certificates.json';
|
|
410
|
+
/** At most this many certificates are kept per directory; the writer refuses more. */
|
|
411
|
+
exports.CERTIFICATE_CAP = 200;
|
|
412
|
+
/** The certificates file's read cap. */
|
|
413
|
+
const CERTIFICATES_FILE_BYTE_CAP = 64 * 1024;
|
|
414
|
+
function isSealCertificate(v) {
|
|
415
|
+
if (!v || typeof v !== 'object' || Array.isArray(v))
|
|
416
|
+
return false;
|
|
417
|
+
const o = v;
|
|
418
|
+
return isAnchorSha(o.end) && isAnchorSha(o.head) && typeof o.at === 'string' && ISO_UTC_RE.test(o.at);
|
|
419
|
+
}
|
|
420
|
+
/**
|
|
421
|
+
* A directory's certificates, in the order they were written. A missing file,
|
|
422
|
+
* a file that is not a regular file (a symlink included), one over the byte
|
|
423
|
+
* cap, not JSON, not an object or without a `certified` array reads as none;
|
|
424
|
+
* a malformed entry is dropped and the rest kept, never repaired. Never
|
|
425
|
+
* throws: a lost certificate can only make a record bound LESS.
|
|
426
|
+
*/
|
|
427
|
+
function readDirCertificates(dirAbs) {
|
|
428
|
+
try {
|
|
429
|
+
const abs = node_path_1.default.join(dirAbs, exports.VAULT_CERTIFICATES_FILE);
|
|
430
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
431
|
+
if (!st.isFile() || st.size > CERTIFICATES_FILE_BYTE_CAP)
|
|
432
|
+
return [];
|
|
433
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
|
|
434
|
+
const list = parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed.certified : null;
|
|
435
|
+
if (!Array.isArray(list))
|
|
436
|
+
return [];
|
|
437
|
+
return list.filter(isSealCertificate).slice(0, exports.CERTIFICATE_CAP).map((c) => ({ end: c.end, head: c.head, at: c.at }));
|
|
438
|
+
}
|
|
439
|
+
catch {
|
|
440
|
+
return [];
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
/**
|
|
444
|
+
* Append certificates to a directory's file and return the full list. Throws,
|
|
445
|
+
* writing nothing, on an empty list, an entry that is not well formed, a
|
|
446
|
+
* symlinked file, or more than `CERTIFICATE_CAP` in all: the caller is the
|
|
447
|
+
* engine, and each of those is a bug or a refusal it states first. A repeated
|
|
448
|
+
* `end` (already certified, or twice in `entries`) keeps the FIRST entry, so
|
|
449
|
+
* certifying the same end again changes nothing. Writes a temporary sibling
|
|
450
|
+
* and renames it.
|
|
451
|
+
*/
|
|
452
|
+
function appendDirCertificates(dirAbs, entries) {
|
|
453
|
+
if (!Array.isArray(entries) || entries.length === 0 || !entries.every(isSealCertificate)) {
|
|
454
|
+
throw new Error('appendDirCertificates: a certificate is not well formed');
|
|
455
|
+
}
|
|
456
|
+
const abs = node_path_1.default.join(dirAbs, exports.VAULT_CERTIFICATES_FILE);
|
|
457
|
+
try {
|
|
458
|
+
if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
|
|
459
|
+
throw new Error('appendDirCertificates: the certificates file is a symlink');
|
|
460
|
+
}
|
|
461
|
+
catch (e) {
|
|
462
|
+
if (e?.code !== 'ENOENT')
|
|
463
|
+
throw e;
|
|
464
|
+
}
|
|
465
|
+
const next = readDirCertificates(dirAbs);
|
|
466
|
+
const seen = new Set(next.map((c) => c.end));
|
|
467
|
+
for (const c of entries) {
|
|
468
|
+
if (seen.has(c.end))
|
|
469
|
+
continue;
|
|
470
|
+
seen.add(c.end);
|
|
471
|
+
next.push({ end: c.end, head: c.head, at: c.at });
|
|
472
|
+
}
|
|
473
|
+
if (next.length > exports.CERTIFICATE_CAP)
|
|
474
|
+
throw new Error(`appendDirCertificates: more than ${exports.CERTIFICATE_CAP} certificates in one directory`);
|
|
475
|
+
const tmp = `${abs}.${process.pid}.tmp`;
|
|
476
|
+
node_fs_1.default.writeFileSync(tmp, `${JSON.stringify({ certified: next }, null, 2)}\n`, 'utf8');
|
|
477
|
+
node_fs_1.default.renameSync(tmp, abs);
|
|
478
|
+
return next;
|
|
479
|
+
}
|
|
480
|
+
// ─── Door starts file (16 review CR-01) ──────────────────────────────────────
|
|
481
|
+
//
|
|
482
|
+
// A door captures its START before its agent runs and writes its record after
|
|
483
|
+
// the door's last source commit, many steps later. A shell variable cannot
|
|
484
|
+
// carry the start between the two (each agent step runs a fresh shell), and the
|
|
485
|
+
// agent never types a sha (D-12). So `vault door-record --begin` records the
|
|
486
|
+
// start HERE, in the project's `.planning/` (never a phase directory: that
|
|
487
|
+
// directory's `.vault-anchors.json` is the phase's own start, D-59), keyed by
|
|
488
|
+
// door name, and the record step reads it back. A later `--begin` of the same
|
|
489
|
+
// door replaces its entry: every door run begins afresh, so an interrupted
|
|
490
|
+
// run's commits stay uncovered (and SEAL-01 names them) rather than a stale
|
|
491
|
+
// start widening a later run's range. A successful record CONSUMES the entry
|
|
492
|
+
// (16 review iteration 2 WR-01, D-100), so that "afresh" no longer rests on the
|
|
493
|
+
// prose step running: a record with no fresh `--begin` halts.
|
|
494
|
+
/** The project-level file holding each door's most recent start (`.planning/` root). */
|
|
495
|
+
exports.VAULT_DOOR_STARTS_FILE = '.vault-door-starts.json';
|
|
496
|
+
/** The whole file, or `{}` when it is missing, oversized, not JSON or not an object. Never throws. */
|
|
497
|
+
function readDoorStarts(planningAbs) {
|
|
498
|
+
try {
|
|
499
|
+
const abs = node_path_1.default.join(planningAbs, exports.VAULT_DOOR_STARTS_FILE);
|
|
500
|
+
const st = node_fs_1.default.lstatSync(abs);
|
|
501
|
+
if (!st.isFile() || st.size > ANCHORS_FILE_BYTE_CAP)
|
|
502
|
+
return {};
|
|
503
|
+
const parsed = JSON.parse(node_fs_1.default.readFileSync(abs, 'utf8'));
|
|
504
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed))
|
|
505
|
+
return {};
|
|
506
|
+
return parsed;
|
|
507
|
+
}
|
|
508
|
+
catch {
|
|
509
|
+
return {};
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* The start the named door's last `--begin` recorded, or `null` when there is
|
|
514
|
+
* none or it is malformed (not an anchor, no ISO `recordedAt`). Never throws.
|
|
515
|
+
*/
|
|
516
|
+
function readDoorStart(planningAbs, door) {
|
|
517
|
+
if (!exports.DOOR_NAMES.includes(door))
|
|
518
|
+
return null;
|
|
519
|
+
const all = readDoorStarts(planningAbs);
|
|
520
|
+
const entry = Object.prototype.hasOwnProperty.call(all, door) ? all[door] : undefined;
|
|
521
|
+
if (!entry || typeof entry !== 'object' || Array.isArray(entry))
|
|
522
|
+
return null;
|
|
523
|
+
const o = entry;
|
|
524
|
+
if (!isAnchorSha(o.start))
|
|
525
|
+
return null;
|
|
526
|
+
if (typeof o.recordedAt !== 'string' || !ISO_UTC_RE.test(o.recordedAt))
|
|
527
|
+
return null;
|
|
528
|
+
return { start: o.start, recordedAt: o.recordedAt };
|
|
529
|
+
}
|
|
530
|
+
/**
|
|
531
|
+
* Record `door`'s start, replacing its earlier entry and keeping the other
|
|
532
|
+
* doors' valid entries. Throws on a door name or sha the engine should never
|
|
533
|
+
* pass (a bug, not input), before anything is written; writes a temporary
|
|
534
|
+
* sibling and renames it, so a reader never sees half a file. The file must
|
|
535
|
+
* not be a symlink (a write is never redirected out of the tree).
|
|
536
|
+
*/
|
|
537
|
+
function writeDoorStart(planningAbs, door, sha, opts = {}) {
|
|
538
|
+
if (!exports.DOOR_NAMES.includes(door))
|
|
539
|
+
throw new Error('writeDoorStart: not a door that writes a record');
|
|
540
|
+
if (!isAnchorSha(sha))
|
|
541
|
+
throw new Error('writeDoorStart: the start is not a full lowercase commit id (40 or 64 hex)');
|
|
542
|
+
const abs = node_path_1.default.join(planningAbs, exports.VAULT_DOOR_STARTS_FILE);
|
|
543
|
+
try {
|
|
544
|
+
if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
|
|
545
|
+
throw new Error('writeDoorStart: the door starts file is a symlink');
|
|
546
|
+
}
|
|
547
|
+
catch (e) {
|
|
548
|
+
if (e?.code !== 'ENOENT')
|
|
549
|
+
throw e;
|
|
550
|
+
}
|
|
551
|
+
const now = typeof opts.now === 'function' ? opts.now : () => new Date();
|
|
552
|
+
const record = { start: sha, recordedAt: now().toISOString() };
|
|
553
|
+
const next = {};
|
|
554
|
+
for (const name of exports.DOOR_NAMES) {
|
|
555
|
+
if (name === door)
|
|
556
|
+
next[name] = record;
|
|
557
|
+
else {
|
|
558
|
+
const kept = readDoorStart(planningAbs, name);
|
|
559
|
+
if (kept)
|
|
560
|
+
next[name] = kept;
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
const tmp = `${abs}.${process.pid}.tmp`;
|
|
564
|
+
node_fs_1.default.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
565
|
+
node_fs_1.default.renameSync(tmp, abs);
|
|
566
|
+
return record;
|
|
567
|
+
}
|
|
568
|
+
/**
|
|
569
|
+
* Consume `door`'s start (16 review iteration 2 WR-01, D-100): remove its entry
|
|
570
|
+
* when, and only when, it still holds `start` — the value the record about to
|
|
571
|
+
* be written read — keeping every other door's valid entry. A record therefore
|
|
572
|
+
* uses a `--begin` once: a second record without a fresh `--begin` finds no
|
|
573
|
+
* start and halts naming `--begin`, instead of reusing run 1's start and
|
|
574
|
+
* covering everything committed since. An entry that changed in between (a
|
|
575
|
+
* newer `--begin`) is left alone and `false` is returned. Throws on a door name
|
|
576
|
+
* the engine should never pass, or when the file is a symlink; writes a
|
|
577
|
+
* temporary sibling and renames it.
|
|
578
|
+
*/
|
|
579
|
+
function consumeDoorStart(planningAbs, door, start) {
|
|
580
|
+
if (!exports.DOOR_NAMES.includes(door))
|
|
581
|
+
throw new Error('consumeDoorStart: not a door that writes a record');
|
|
582
|
+
const abs = node_path_1.default.join(planningAbs, exports.VAULT_DOOR_STARTS_FILE);
|
|
583
|
+
if (node_fs_1.default.lstatSync(abs).isSymbolicLink())
|
|
584
|
+
throw new Error('consumeDoorStart: the door starts file is a symlink');
|
|
585
|
+
const current = readDoorStart(planningAbs, door);
|
|
586
|
+
if (current === null || current.start !== start)
|
|
587
|
+
return false;
|
|
588
|
+
const next = {};
|
|
589
|
+
for (const name of exports.DOOR_NAMES) {
|
|
590
|
+
if (name === door)
|
|
591
|
+
continue;
|
|
592
|
+
const kept = readDoorStart(planningAbs, name);
|
|
593
|
+
if (kept)
|
|
594
|
+
next[name] = kept;
|
|
595
|
+
}
|
|
596
|
+
const tmp = `${abs}.${process.pid}.tmp`;
|
|
597
|
+
node_fs_1.default.writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`, 'utf8');
|
|
598
|
+
node_fs_1.default.renameSync(tmp, abs);
|
|
599
|
+
return true;
|
|
600
|
+
}
|
|
601
|
+
// ─── Door-record grammar (D-12) ──────────────────────────────────────────────
|
|
602
|
+
/** The heading a door record opens with, at column 0. */
|
|
603
|
+
exports.DOOR_RECORD_HEADING = '## Vault Door Record';
|
|
604
|
+
/** The doors that write a door record (D-11). */
|
|
605
|
+
exports.DOOR_NAMES = Object.freeze(['code-review-fix', 'validate-phase', 'add-tests', 'audit-fix']);
|
|
606
|
+
/** The modes a door record may name; `null` is written as `none`. */
|
|
607
|
+
const DOOR_MODES = Object.freeze(['practices', 'standards']);
|
|
608
|
+
/** How an absent value is spelled on its line. */
|
|
609
|
+
const DOOR_NONE = 'none';
|
|
610
|
+
/** Why a door record did not parse. Frozen, so a typo is a build error. */
|
|
611
|
+
exports.DOOR_RECORD_PROBLEM_KIND = Object.freeze({
|
|
612
|
+
DUPLICATE: 'door_record_duplicate',
|
|
613
|
+
MALFORMED: 'door_record_malformed',
|
|
614
|
+
FIELD_INVALID: 'door_record_field_invalid',
|
|
615
|
+
});
|
|
616
|
+
/** The line keys, in the one order a record is written and read. */
|
|
617
|
+
const DOOR_RECORD_KEYS = Object.freeze([
|
|
618
|
+
'door',
|
|
619
|
+
'mode',
|
|
620
|
+
'vault-repo',
|
|
621
|
+
'vault-ref',
|
|
622
|
+
'vault-sha',
|
|
623
|
+
'degraded',
|
|
624
|
+
'phase',
|
|
625
|
+
'start-commit',
|
|
626
|
+
'end-commit',
|
|
627
|
+
'recorded-at',
|
|
628
|
+
]);
|
|
629
|
+
/** `- key: value`, the one line shape inside the section. */
|
|
630
|
+
const DOOR_LINE_RE = /^- ([a-z-]+): (.*)$/;
|
|
631
|
+
/**
|
|
632
|
+
* Is this free-text value safe to place on a record line an agent reads?
|
|
633
|
+
* Asks the standards leaf's reason-clause judge, which owns the refused set
|
|
634
|
+
* (newline, backtick, control, bidi, separator and zero-width characters, a
|
|
635
|
+
* fence marker, a block tag, empty, over 200 code points). Required lazily;
|
|
636
|
+
* see the layering note in the header.
|
|
637
|
+
*/
|
|
638
|
+
function isDisplaySafeValue(value) {
|
|
639
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
640
|
+
const standards = require('./vault-standards.cjs');
|
|
641
|
+
return standards.validateReasonClause(value).ok && value !== DOOR_NONE && value.trim() === value;
|
|
642
|
+
}
|
|
643
|
+
/** The record's ten line values, in key order; throws naming the field on a refused value. */
|
|
644
|
+
function doorRecordValues(rec) {
|
|
645
|
+
const refuse = (field) => {
|
|
646
|
+
throw new Error(`formatDoorRecord: the ${field} field is not valid for a door record`);
|
|
647
|
+
};
|
|
648
|
+
const r = (rec && typeof rec === 'object' ? rec : {});
|
|
649
|
+
if (!exports.DOOR_NAMES.includes(r.door))
|
|
650
|
+
refuse('door');
|
|
651
|
+
if (r.mode !== null && !DOOR_MODES.includes(r.mode))
|
|
652
|
+
refuse('mode');
|
|
653
|
+
const text = (field, v) => {
|
|
654
|
+
if (v === null)
|
|
655
|
+
return DOOR_NONE;
|
|
656
|
+
if (typeof v !== 'string' || !isDisplaySafeValue(v))
|
|
657
|
+
return refuse(field);
|
|
658
|
+
return v;
|
|
659
|
+
};
|
|
660
|
+
const vault = r.vault === undefined ? refuse('vault') : r.vault;
|
|
661
|
+
if (vault !== null && (typeof vault !== 'object' || Array.isArray(vault)))
|
|
662
|
+
refuse('vault');
|
|
663
|
+
const degraded = r.degraded === undefined ? refuse('degraded') : r.degraded;
|
|
664
|
+
// Exactly one of the two: the coordinates of a loaded vault, or the
|
|
665
|
+
// degraded one-liner saying why there were none (D-12 "or").
|
|
666
|
+
if ((vault === null) === (degraded === null))
|
|
667
|
+
refuse('vault');
|
|
668
|
+
if (vault !== null && !isAnchorSha(vault.sha))
|
|
669
|
+
refuse('vault-sha');
|
|
670
|
+
if (!isAnchorSha(r.startCommit))
|
|
671
|
+
refuse('start-commit');
|
|
672
|
+
if (!isAnchorSha(r.endCommit))
|
|
673
|
+
refuse('end-commit');
|
|
674
|
+
if (typeof r.recordedAt !== 'string' || !ISO_UTC_RE.test(r.recordedAt))
|
|
675
|
+
refuse('recorded-at');
|
|
676
|
+
return {
|
|
677
|
+
door: r.door,
|
|
678
|
+
mode: r.mode === null ? DOOR_NONE : r.mode,
|
|
679
|
+
'vault-repo': vault === null ? DOOR_NONE : text('vault-repo', vault.repo),
|
|
680
|
+
'vault-ref': vault === null ? DOOR_NONE : text('vault-ref', vault.ref),
|
|
681
|
+
'vault-sha': vault === null ? DOOR_NONE : vault.sha,
|
|
682
|
+
degraded: text('degraded', degraded),
|
|
683
|
+
phase: r.phase === undefined ? refuse('phase') : text('phase', r.phase),
|
|
684
|
+
'start-commit': r.startCommit,
|
|
685
|
+
'end-commit': r.endCommit,
|
|
686
|
+
'recorded-at': r.recordedAt,
|
|
687
|
+
};
|
|
688
|
+
}
|
|
689
|
+
/**
|
|
690
|
+
* Serialise a door record: the heading line, then one `- key: value` line per
|
|
691
|
+
* field in the fixed key order, then a newline. An absent value is written
|
|
692
|
+
* `none`. Throws, naming the field, on any value the parser would refuse, so
|
|
693
|
+
* the writer can never emit a record its own reader rejects.
|
|
694
|
+
*/
|
|
695
|
+
function formatDoorRecord(rec) {
|
|
696
|
+
const values = doorRecordValues(rec);
|
|
697
|
+
const lines = [exports.DOOR_RECORD_HEADING, ...DOOR_RECORD_KEYS.map((k) => `- ${k}: ${values[k]}`)];
|
|
698
|
+
return `${lines.join('\n')}\n`;
|
|
699
|
+
}
|
|
700
|
+
/**
|
|
701
|
+
* Read a door record back out of a text (an artifact a door wrote into).
|
|
702
|
+
*
|
|
703
|
+
* The heading counts only as a whole column-0 line outside the frontmatter,
|
|
704
|
+
* fenced code and HTML comments; the section ends at the next ATX heading,
|
|
705
|
+
* thematic break or the end of the text — the `parseConsultedSection` rule,
|
|
706
|
+
* asked of the standards leaf's own scanner (`findLiveSection`). No heading →
|
|
707
|
+
* `{ ok: true, record: null }`. Two headings → a duplicate problem. Inside the
|
|
708
|
+
* section only the ten key lines, in order, each once, are legal; blank lines
|
|
709
|
+
* aside, anything else is malformed. Never throws.
|
|
710
|
+
*/
|
|
711
|
+
function parseDoorRecord(text) {
|
|
712
|
+
try {
|
|
713
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
714
|
+
const standards = require('./vault-standards.cjs');
|
|
715
|
+
const { lines, headings, body } = standards.findLiveSection(text, exports.DOOR_RECORD_HEADING);
|
|
716
|
+
if (headings.length === 0)
|
|
717
|
+
return { ok: true, record: null };
|
|
718
|
+
const fail = (kind, field, idx) => ({
|
|
719
|
+
ok: false,
|
|
720
|
+
record: null,
|
|
721
|
+
problems: [{ kind, field, line: idx === null ? null : idx + 1 }],
|
|
722
|
+
});
|
|
723
|
+
if (headings.length > 1)
|
|
724
|
+
return fail(exports.DOOR_RECORD_PROBLEM_KIND.DUPLICATE, null, headings[1]);
|
|
725
|
+
if (body.length !== DOOR_RECORD_KEYS.length) {
|
|
726
|
+
return fail(exports.DOOR_RECORD_PROBLEM_KIND.MALFORMED, null, body.length > DOOR_RECORD_KEYS.length ? body[DOOR_RECORD_KEYS.length] : headings[0]);
|
|
727
|
+
}
|
|
728
|
+
const raw = {};
|
|
729
|
+
for (let k = 0; k < DOOR_RECORD_KEYS.length; k += 1) {
|
|
730
|
+
const m = DOOR_LINE_RE.exec(lines[body[k]]);
|
|
731
|
+
if (!m || m[1] !== DOOR_RECORD_KEYS[k])
|
|
732
|
+
return fail(exports.DOOR_RECORD_PROBLEM_KIND.MALFORMED, DOOR_RECORD_KEYS[k], body[k]);
|
|
733
|
+
raw[DOOR_RECORD_KEYS[k]] = m[2];
|
|
734
|
+
}
|
|
735
|
+
const at = (key) => body[DOOR_RECORD_KEYS.indexOf(key)];
|
|
736
|
+
const invalid = (key) => fail(exports.DOOR_RECORD_PROBLEM_KIND.FIELD_INVALID, key, at(key));
|
|
737
|
+
const textOrNull = (key) => {
|
|
738
|
+
const v = raw[key];
|
|
739
|
+
if (v === DOOR_NONE)
|
|
740
|
+
return null;
|
|
741
|
+
return isDisplaySafeValue(v) ? v : undefined;
|
|
742
|
+
};
|
|
743
|
+
if (!exports.DOOR_NAMES.includes(raw.door))
|
|
744
|
+
return invalid('door');
|
|
745
|
+
if (raw.mode !== DOOR_NONE && !DOOR_MODES.includes(raw.mode))
|
|
746
|
+
return invalid('mode');
|
|
747
|
+
const repo = textOrNull('vault-repo');
|
|
748
|
+
if (repo === undefined)
|
|
749
|
+
return invalid('vault-repo');
|
|
750
|
+
const ref = textOrNull('vault-ref');
|
|
751
|
+
if (ref === undefined)
|
|
752
|
+
return invalid('vault-ref');
|
|
753
|
+
const sha = raw['vault-sha'] === DOOR_NONE ? null : raw['vault-sha'];
|
|
754
|
+
if (sha !== null && !isAnchorSha(sha))
|
|
755
|
+
return invalid('vault-sha');
|
|
756
|
+
const coords = [repo, ref, sha];
|
|
757
|
+
if (coords.some((c) => c === null) && coords.some((c) => c !== null))
|
|
758
|
+
return invalid('vault-repo');
|
|
759
|
+
const degraded = textOrNull('degraded');
|
|
760
|
+
if (degraded === undefined)
|
|
761
|
+
return invalid('degraded');
|
|
762
|
+
if ((sha === null) === (degraded === null))
|
|
763
|
+
return invalid('degraded');
|
|
764
|
+
const phase = textOrNull('phase');
|
|
765
|
+
if (phase === undefined)
|
|
766
|
+
return invalid('phase');
|
|
767
|
+
if (!isAnchorSha(raw['start-commit']))
|
|
768
|
+
return invalid('start-commit');
|
|
769
|
+
if (!isAnchorSha(raw['end-commit']))
|
|
770
|
+
return invalid('end-commit');
|
|
771
|
+
if (!ISO_UTC_RE.test(raw['recorded-at']))
|
|
772
|
+
return invalid('recorded-at');
|
|
773
|
+
return {
|
|
774
|
+
ok: true,
|
|
775
|
+
record: {
|
|
776
|
+
door: raw.door,
|
|
777
|
+
mode: raw.mode === DOOR_NONE ? null : raw.mode,
|
|
778
|
+
vault: sha === null ? null : { repo: repo, ref: ref, sha },
|
|
779
|
+
degraded,
|
|
780
|
+
phase,
|
|
781
|
+
startCommit: raw['start-commit'],
|
|
782
|
+
endCommit: raw['end-commit'],
|
|
783
|
+
recordedAt: raw['recorded-at'],
|
|
784
|
+
},
|
|
785
|
+
};
|
|
786
|
+
}
|
|
787
|
+
catch {
|
|
788
|
+
return { ok: false, record: null, problems: [{ kind: exports.DOOR_RECORD_PROBLEM_KIND.MALFORMED, field: null, line: null }] };
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
// ─── Recorded-artifact presence (D-20) ───────────────────────────────────────
|
|
792
|
+
/**
|
|
793
|
+
* A single artifact is read whole; anything larger is skipped, not streamed.
|
|
794
|
+
* MOVED here from `src/check-command-router.cts` by 16-03 and read back from
|
|
795
|
+
* there (the `TRACE_REGENERATE_COMMAND` precedent), so the trace gate's scan
|
|
796
|
+
* and this predicate's scan cannot be capped differently.
|
|
797
|
+
*/
|
|
798
|
+
exports.TRACE_ARTIFACT_BYTE_CAP = 1024 * 1024;
|
|
799
|
+
/** A directory with more candidate artifacts than this is not a phase directory. */
|
|
800
|
+
exports.TRACE_ARTIFACT_COUNT_CAP = 200;
|
|
801
|
+
/** How many planning directories the project-level predicate visits at most. */
|
|
802
|
+
exports.RECORDED_ARTIFACT_DIR_CAP = 2000;
|
|
803
|
+
/** What an engine-written record found in a directory is. Frozen; one id per D-20 record type. */
|
|
804
|
+
exports.RECORDED_ARTIFACT_KIND = Object.freeze({
|
|
805
|
+
PLAN_LEDGER: 'plan_ledger',
|
|
806
|
+
SUMMARY_LEDGER: 'summary_ledger',
|
|
807
|
+
STANDARDS_BLOCK: 'standards_block',
|
|
808
|
+
STANDARDS_CONSULTED: 'standards_consulted',
|
|
809
|
+
PRACTICES_APPLIED: 'practices_applied',
|
|
810
|
+
DOOR_RECORD: 'door_record',
|
|
811
|
+
PHASE_MANIFEST: 'phase_manifest',
|
|
812
|
+
});
|
|
813
|
+
/**
|
|
814
|
+
* The artifacts a door writes its record into (16-15): `-REVIEW-FIX.md`,
|
|
815
|
+
* `-VALIDATION.md`, `-ADD-TESTS.md`, `-AUDIT-FIX.md`, alone or after a prefix.
|
|
816
|
+
*/
|
|
817
|
+
exports.DOOR_ARTIFACT_FILENAME_RE = Object.freeze(/(^|-)(REVIEW-FIX|VALIDATION|ADD-TESTS|AUDIT-FIX)\.md$/);
|
|
818
|
+
/** The router's own filename rules for the two record-bearing artifacts. */
|
|
819
|
+
const PLAN_FILENAME_RE = /(^|-)PLAN\.md$/;
|
|
820
|
+
const SUMMARY_FILENAME_RE = /(^|-)SUMMARY\.md$/;
|
|
821
|
+
function readers() {
|
|
822
|
+
/* eslint-disable @typescript-eslint/no-require-imports */
|
|
823
|
+
return {
|
|
824
|
+
trace: require('./vault-trace.cjs'),
|
|
825
|
+
standards: require('./vault-standards.cjs'),
|
|
826
|
+
};
|
|
827
|
+
/* eslint-enable @typescript-eslint/no-require-imports */
|
|
828
|
+
}
|
|
829
|
+
/** The project root a directory belongs to (the router's rule: `findProjectRoot(x) || x`). */
|
|
830
|
+
function resolveRoot(root, from) {
|
|
831
|
+
if (typeof root === 'string' && root !== '')
|
|
832
|
+
return root;
|
|
833
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
834
|
+
const projectRoot = require('./project-root.cjs');
|
|
835
|
+
return projectRoot.findProjectRoot(from) || from;
|
|
836
|
+
}
|
|
837
|
+
/** The parsed `vault:` ledger, when the document carries one the engine wrote. */
|
|
838
|
+
function engineLedger(r, text, name) {
|
|
839
|
+
const parsed = r.trace.parseVaultTraceBlock(text, name);
|
|
840
|
+
if (!parsed.present || !parsed.statement || typeof parsed.statement !== 'object')
|
|
841
|
+
return null;
|
|
842
|
+
const st = parsed.statement;
|
|
843
|
+
// A `vault:` key is not a ledger unless it carries the statement's own two
|
|
844
|
+
// type identifiers: a hand-typed or syntactically broken key normalises to
|
|
845
|
+
// empty strings there, and is no record of anything (D-43).
|
|
846
|
+
if (st._type !== r.trace.TRACE_STATEMENT_TYPE || st.predicateType !== r.trace.TRACE_PREDICATE_TYPE)
|
|
847
|
+
return null;
|
|
848
|
+
return st;
|
|
849
|
+
}
|
|
850
|
+
/** Every record kind one candidate's text holds, each once. */
|
|
851
|
+
function classifyCandidate(r, text, name, role) {
|
|
852
|
+
const kinds = [];
|
|
853
|
+
// A parse FAILURE is not presence: a malformed record is the trace gate's
|
|
854
|
+
// business, and this predicate only answers whether the engine wrote one.
|
|
855
|
+
if (!role.doorOnly && (role.plan || role.summary) && engineLedger(r, text, name) !== null) {
|
|
856
|
+
kinds.push(role.plan ? exports.RECORDED_ARTIFACT_KIND.PLAN_LEDGER : exports.RECORDED_ARTIFACT_KIND.SUMMARY_LEDGER);
|
|
857
|
+
}
|
|
858
|
+
if (!role.doorOnly && role.plan) {
|
|
859
|
+
const block = r.standards.parseStandardsBlock(text);
|
|
860
|
+
if (block.present && block.record !== null)
|
|
861
|
+
kinds.push(exports.RECORDED_ARTIFACT_KIND.STANDARDS_BLOCK);
|
|
862
|
+
}
|
|
863
|
+
if (!role.doorOnly && role.summary) {
|
|
864
|
+
if (r.standards.findLiveSection(text, r.trace.PRACTICES_APPLIED_HEADING).headings.length > 0) {
|
|
865
|
+
kinds.push(exports.RECORDED_ARTIFACT_KIND.PRACTICES_APPLIED);
|
|
866
|
+
}
|
|
867
|
+
const section = r.standards.parseConsultedSection(text);
|
|
868
|
+
if (section.present && section.planId !== null)
|
|
869
|
+
kinds.push(exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED);
|
|
870
|
+
}
|
|
871
|
+
const door = parseDoorRecord(text);
|
|
872
|
+
if (door.ok && door.record !== null)
|
|
873
|
+
kinds.push(exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD);
|
|
874
|
+
return kinds;
|
|
875
|
+
}
|
|
876
|
+
/**
|
|
877
|
+
* The engine-written vault records one directory holds (phase-level, D-20).
|
|
878
|
+
*
|
|
879
|
+
* The scan shape of the router's `collectTraceArtifacts`: `readdir`, sort,
|
|
880
|
+
* filename rules, a `seen` counter, a cap without `break`. Candidates are
|
|
881
|
+
* `*PLAN.md`, `*SUMMARY.md` and the door artifacts. Each candidate under the
|
|
882
|
+
* byte cap is read ONCE and classified by the real parsers, never a regex:
|
|
883
|
+
* - `plan_ledger` / `summary_ledger`: a top-level frontmatter `vault:` ledger
|
|
884
|
+
* carrying the statement's type identifiers (`parseVaultTraceBlock`);
|
|
885
|
+
* - `standards_block`: a parsed `<vault_standards>` record (PLAN);
|
|
886
|
+
* - `practices_applied`: a live `## Practices Applied` heading (SUMMARY),
|
|
887
|
+
* found by the standards leaf's live-line scanner, outside fences;
|
|
888
|
+
* - `standards_consulted`: a parsed `## Standards Consulted` section (SUMMARY);
|
|
889
|
+
* - `door_record`: a parsed door record (any candidate);
|
|
890
|
+
* - `phase_manifest`: the audit's own manifest reader has a usable entry
|
|
891
|
+
* for this directory (not with `onlyFile` or `doorOnly`).
|
|
892
|
+
* A bare `<vault_practices>` body and its `vault-practices-stamp` comment are
|
|
893
|
+
* none of these (research Pitfall 4). A missing directory reads as empty.
|
|
894
|
+
*
|
|
895
|
+
* This reader does NOT check `.platform-bindings.json`. Its callers are the
|
|
896
|
+
* handlers, which are bindings-inert before they get here (D-37), and the
|
|
897
|
+
* project-level predicate below, which checks bindings first (D-42).
|
|
898
|
+
*/
|
|
899
|
+
function collectRecordedArtifacts(dirAbs, opts = {}) {
|
|
900
|
+
const fsx = opts.fs ?? node_fs_1.default;
|
|
901
|
+
const byteCap = typeof opts.byteCap === 'number' ? opts.byteCap : exports.TRACE_ARTIFACT_BYTE_CAP;
|
|
902
|
+
const countCap = typeof opts.countCap === 'number' ? opts.countCap : exports.TRACE_ARTIFACT_COUNT_CAP;
|
|
903
|
+
const doorOnly = opts.doorOnly === true;
|
|
904
|
+
let entries;
|
|
905
|
+
try {
|
|
906
|
+
entries = fsx.readdirSync(dirAbs).map(String);
|
|
907
|
+
}
|
|
908
|
+
catch {
|
|
909
|
+
return { artifacts: [], truncated: false, seen: 0, skipped: 0 };
|
|
910
|
+
}
|
|
911
|
+
const root = resolveRoot(opts.root, dirAbs);
|
|
912
|
+
const relOf = (abs) => node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/') || '.';
|
|
913
|
+
const r = readers();
|
|
914
|
+
const artifacts = [];
|
|
915
|
+
let seen = 0;
|
|
916
|
+
let admitted = 0;
|
|
917
|
+
let skipped = 0;
|
|
918
|
+
for (const name of entries.sort()) {
|
|
919
|
+
if (opts.onlyFile !== undefined && name !== opts.onlyFile)
|
|
920
|
+
continue;
|
|
921
|
+
const plan = PLAN_FILENAME_RE.test(name);
|
|
922
|
+
const summary = SUMMARY_FILENAME_RE.test(name);
|
|
923
|
+
const door = exports.DOOR_ARTIFACT_FILENAME_RE.test(name);
|
|
924
|
+
if (doorOnly ? !door : !(plan || summary || door))
|
|
925
|
+
continue;
|
|
926
|
+
// The router's 04-07 rule: count every candidate, admit no more than the cap.
|
|
927
|
+
seen += 1;
|
|
928
|
+
if (admitted >= countCap)
|
|
929
|
+
continue;
|
|
930
|
+
admitted += 1;
|
|
931
|
+
const abs = node_path_1.default.join(dirAbs, name);
|
|
932
|
+
let text;
|
|
933
|
+
try {
|
|
934
|
+
const st = fsx.statSync(abs);
|
|
935
|
+
if (!st.isFile() || st.size > byteCap) {
|
|
936
|
+
skipped += 1;
|
|
937
|
+
continue;
|
|
938
|
+
}
|
|
939
|
+
text = String(fsx.readFileSync(abs, 'utf8'));
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
skipped += 1;
|
|
943
|
+
continue;
|
|
944
|
+
}
|
|
945
|
+
for (const kind of classifyCandidate(r, text, name, { plan, summary, doorOnly })) {
|
|
946
|
+
artifacts.push({ rel: relOf(abs), abs, kind });
|
|
947
|
+
if (opts.stopAtFirst === true)
|
|
948
|
+
return { artifacts, truncated: false, seen, skipped };
|
|
949
|
+
}
|
|
950
|
+
}
|
|
951
|
+
if (!doorOnly && opts.onlyFile === undefined) {
|
|
952
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
953
|
+
const audit = require('./vault-audit.cjs');
|
|
954
|
+
if (audit.hasPhaseManifestEntry(root, dirAbs)) {
|
|
955
|
+
artifacts.push({ rel: relOf(dirAbs), abs: dirAbs, kind: exports.RECORDED_ARTIFACT_KIND.PHASE_MANIFEST });
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
return { artifacts, truncated: seen > countCap, seen, skipped };
|
|
959
|
+
}
|
|
960
|
+
// ─── disabled_with_artifacts (SEAL-02 handlers, 16-09; D-21, D-22, D-39) ─────
|
|
961
|
+
/**
|
|
962
|
+
* The status the trace and audit gates, and the `vault audit` operator verb,
|
|
963
|
+
* answer when a switch is off over a phase that holds engine-written records.
|
|
964
|
+
* The literal is restated at each emitting line (the producer-audit and FENCE 2
|
|
965
|
+
* probes read emission sites); this constant is what tests compare against.
|
|
966
|
+
*/
|
|
967
|
+
exports.DISABLED_WITH_ARTIFACTS_STATUS = 'disabled_with_artifacts';
|
|
968
|
+
/** How many artifact paths a verdict lists before it only counts. */
|
|
969
|
+
exports.DISABLED_WITH_ARTIFACTS_LIST_CAP = 20;
|
|
970
|
+
/**
|
|
971
|
+
* The repair, and only the repair (D-39, INVARIANT 4): turn the switch back on
|
|
972
|
+
* so the records can be checked. There is no override for this verdict, and
|
|
973
|
+
* the records are never named as something to get rid of.
|
|
974
|
+
*/
|
|
975
|
+
exports.DISABLED_WITH_ARTIFACTS_REPAIR = 'so the gate can check them. This verdict has no override; the records stay as they are.';
|
|
976
|
+
/** The record kinds that belong to the standards family (WR-10). */
|
|
977
|
+
const STANDARDS_RECORD_KINDS = new Set([
|
|
978
|
+
exports.RECORDED_ARTIFACT_KIND.STANDARDS_BLOCK,
|
|
979
|
+
exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED,
|
|
980
|
+
]);
|
|
981
|
+
/** The mode key, from its one owner (13-03 D-05), required lazily (D-41). */
|
|
982
|
+
function modeKey() {
|
|
983
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
984
|
+
return require('./vault-mode.cjs').VAULT_MODE_CONFIG_KEY;
|
|
985
|
+
}
|
|
986
|
+
/**
|
|
987
|
+
* The one-line message both surfaces print (D-39: an agreeing pair, one owner).
|
|
988
|
+
*
|
|
989
|
+
* Every value it interpolates is engine-derived (a switch key, a count, the
|
|
990
|
+
* resolver's repo-relative directory), never artifact text. The repair offers
|
|
991
|
+
* `vault.mode standards` as the alternative only when the practices switch is
|
|
992
|
+
* off and the records include standards records (WR-10), because only then
|
|
993
|
+
* would the standards gate be the one to check them.
|
|
994
|
+
*/
|
|
995
|
+
function formatDisabledWithArtifacts(facts) {
|
|
996
|
+
const n = facts.artifactCount;
|
|
997
|
+
const unread = typeof facts.unreadCount === 'number' && facts.unreadCount > 0 ? facts.unreadCount : 0;
|
|
998
|
+
const where = facts.phaseRel === '' ? 'this phase' : facts.phaseRel;
|
|
999
|
+
const modeNote = facts.mode === 'practices' ? ` under \`${modeKey()}\` practices` : '';
|
|
1000
|
+
const found = `${n} recorded vault artifact${n === 1 ? '' : 's'} in ${where}`;
|
|
1001
|
+
const what = unread === 0
|
|
1002
|
+
? `${found} cannot be checked`
|
|
1003
|
+
: `${found}, and ${unread} candidate file${unread === 1 ? '' : 's'} there not read (over the size or count `
|
|
1004
|
+
+ 'cap, or unreadable), so a record cannot be ruled out; none of it can be checked';
|
|
1005
|
+
return `Vault records ${n > 0 ? 'present' : 'not ruled out'} while \`${facts.switch}\` is off${modeNote} `
|
|
1006
|
+
+ `(${exports.DISABLED_WITH_ARTIFACTS_STATUS}): ${what}, so this phase cannot be recorded as governed (SEAL-02). `
|
|
1007
|
+
+ disabledWithArtifactsRepair(facts.switch, facts.kinds);
|
|
1008
|
+
}
|
|
1009
|
+
/**
|
|
1010
|
+
* The repair sentence alone: the message's tail, and the trusted tail of the
|
|
1011
|
+
* gate's problem detail, so the two can never name different repairs.
|
|
1012
|
+
*/
|
|
1013
|
+
function disabledWithArtifactsRepair(switchKey, kinds) {
|
|
1014
|
+
const standardsRecords = kinds.some((k) => STANDARDS_RECORD_KINDS.has(k));
|
|
1015
|
+
const turnOn = switchKey === 'vault.practices_enabled' && standardsRecords
|
|
1016
|
+
? `set \`vault.practices_enabled\` to true, or set \`${modeKey()}\` to standards (these are vault standards records),`
|
|
1017
|
+
: `set \`${switchKey}\` to true`;
|
|
1018
|
+
return `Repair: ${turnOn} ${exports.DISABLED_WITH_ARTIFACTS_REPAIR}`;
|
|
1019
|
+
}
|
|
1020
|
+
/**
|
|
1021
|
+
* The `disabled_with_artifacts` verdict body over one phase's scan, or `null`
|
|
1022
|
+
* when the scan found no record (the caller then answers exactly as before).
|
|
1023
|
+
*
|
|
1024
|
+
* Paths are de-duplicated (one file can hold two records) and listed in scan
|
|
1025
|
+
* order up to `DISABLED_WITH_ARTIFACTS_LIST_CAP`; `artifactCount` counts them
|
|
1026
|
+
* all.
|
|
1027
|
+
*
|
|
1028
|
+
* ABSENCE MUST BE PROVEN (16-09, D-69, amending D-44's handler sentence). A
|
|
1029
|
+
* scan that found no record but did not read every candidate (a file over the
|
|
1030
|
+
* byte cap, an unreadable one, or more candidates than the count cap) cannot
|
|
1031
|
+
* say "never recorded", so it answers this verdict with `artifactCount: 0` and
|
|
1032
|
+
* `unreadCount`, never `null`. A `null` here makes the handler answer
|
|
1033
|
+
* `inactive` / `inert`, `block: false`: a dispatched seal gate passing where it
|
|
1034
|
+
* proved nothing. Turning the switch back on lets the trace gate name the
|
|
1035
|
+
* unread file itself (`artifact_too_large`, `artifact_unreadable`,
|
|
1036
|
+
* `artifacts_truncated`).
|
|
1037
|
+
*/
|
|
1038
|
+
function disabledWithArtifactsVerdict(scan, opts) {
|
|
1039
|
+
const admitted = Math.min(scan.seen, exports.TRACE_ARTIFACT_COUNT_CAP);
|
|
1040
|
+
const unread = scan.skipped + (scan.truncated ? scan.seen - admitted : 0);
|
|
1041
|
+
if (scan.artifacts.length === 0 && unread === 0)
|
|
1042
|
+
return null;
|
|
1043
|
+
const rels = [];
|
|
1044
|
+
const kinds = [];
|
|
1045
|
+
for (const a of scan.artifacts) {
|
|
1046
|
+
if (!rels.includes(a.rel))
|
|
1047
|
+
rels.push(a.rel);
|
|
1048
|
+
if (!kinds.includes(a.kind))
|
|
1049
|
+
kinds.push(a.kind);
|
|
1050
|
+
}
|
|
1051
|
+
const mode = opts.switch === 'vault.practices_enabled' && opts.mode === 'practices' ? 'practices' : undefined;
|
|
1052
|
+
return {
|
|
1053
|
+
artifacts: rels.slice(0, exports.DISABLED_WITH_ARTIFACTS_LIST_CAP),
|
|
1054
|
+
artifactCount: rels.length,
|
|
1055
|
+
...(unread > 0 ? { unreadCount: unread } : {}),
|
|
1056
|
+
switch: opts.switch,
|
|
1057
|
+
...(mode ? { mode } : {}),
|
|
1058
|
+
kinds,
|
|
1059
|
+
message: formatDisabledWithArtifacts({
|
|
1060
|
+
switch: opts.switch,
|
|
1061
|
+
...(mode ? { mode } : {}),
|
|
1062
|
+
artifactCount: rels.length,
|
|
1063
|
+
kinds,
|
|
1064
|
+
phaseRel: opts.phaseRel,
|
|
1065
|
+
...(unread > 0 ? { unreadCount: unread } : {}),
|
|
1066
|
+
}),
|
|
1067
|
+
};
|
|
1068
|
+
}
|
|
1069
|
+
/** The sub-directories of a planning root, sorted; `null` when absence cannot be proven. */
|
|
1070
|
+
function childDirs(fsx, dirAbs) {
|
|
1071
|
+
try {
|
|
1072
|
+
return fsx.readdirSync(dirAbs, { withFileTypes: true })
|
|
1073
|
+
.filter((d) => d.isDirectory())
|
|
1074
|
+
.map((d) => node_path_1.default.join(dirAbs, d.name))
|
|
1075
|
+
.sort();
|
|
1076
|
+
}
|
|
1077
|
+
catch (e) {
|
|
1078
|
+
const code = e?.code;
|
|
1079
|
+
return code === 'ENOENT' || code === 'ENOTDIR' ? [] : null;
|
|
1080
|
+
}
|
|
1081
|
+
}
|
|
1082
|
+
/** Raised inside the enumeration when absence can no longer be proven (D-44). */
|
|
1083
|
+
class CannotProveAbsence extends Error {
|
|
1084
|
+
}
|
|
1085
|
+
/** Every directory the project-level predicate reads, in order, lazily. */
|
|
1086
|
+
function* planningDirs(fsx, planning) {
|
|
1087
|
+
const listed = (dir) => {
|
|
1088
|
+
const out = childDirs(fsx, dir);
|
|
1089
|
+
if (out === null)
|
|
1090
|
+
throw new CannotProveAbsence();
|
|
1091
|
+
return out;
|
|
1092
|
+
};
|
|
1093
|
+
for (const dir of listed(node_path_1.default.join(planning, 'phases')))
|
|
1094
|
+
yield { dir, doorOnly: false };
|
|
1095
|
+
for (const ws of listed(node_path_1.default.join(planning, 'workstreams'))) {
|
|
1096
|
+
for (const dir of listed(node_path_1.default.join(ws, 'phases')))
|
|
1097
|
+
yield { dir, doorOnly: false };
|
|
1098
|
+
}
|
|
1099
|
+
// Quick task directories are always under the ROOT planning tree, never a
|
|
1100
|
+
// workstream's (src/init.cts quick-dir resolution).
|
|
1101
|
+
for (const dir of listed(node_path_1.default.join(planning, 'quick')))
|
|
1102
|
+
yield { dir, doorOnly: false };
|
|
1103
|
+
yield { dir: node_path_1.default.join(planning, 'audits'), doorOnly: true };
|
|
1104
|
+
}
|
|
1105
|
+
/**
|
|
1106
|
+
* Does this project hold ANY engine-written vault record? (project-level, D-20)
|
|
1107
|
+
*
|
|
1108
|
+
* The question the resolver's injected seal predicate asks (16-08): a seal
|
|
1109
|
+
* gate renders for a config-inactive capability only when this is true.
|
|
1110
|
+
*
|
|
1111
|
+
* - No `.platform-bindings.json` at the project root → false, first, before
|
|
1112
|
+
* any planning file is read (D-42): a non-Pareto project never holds
|
|
1113
|
+
* recorded artifacts, even if it ran a vault verb that wrote one, because
|
|
1114
|
+
* every vault handler answers it inert first (D-37), and a seal that
|
|
1115
|
+
* rendered there would dispatch a gate whose handler cannot block.
|
|
1116
|
+
* - Otherwise every phase directory (root and workstream), every quick
|
|
1117
|
+
* directory and the door artifacts in `.planning/audits` are scanned with
|
|
1118
|
+
* `collectRecordedArtifacts`; the first record found answers true and
|
|
1119
|
+
* nothing more is read.
|
|
1120
|
+
* - When absence cannot be PROVEN — the directory cap is passed, a
|
|
1121
|
+
* directory's candidates were truncated or skipped, or a planning root
|
|
1122
|
+
* could not be listed for a reason other than not existing — the answer is
|
|
1123
|
+
* true (D-44). A false here silences the seal, so it must mean "looked
|
|
1124
|
+
* everywhere and found nothing". A true only lets the seal gate render; its
|
|
1125
|
+
* handler then reads the phase it is asked about and blocks only on the
|
|
1126
|
+
* records actually there.
|
|
1127
|
+
*/
|
|
1128
|
+
function hasRecordedVaultArtifacts(cwd, opts = {}) {
|
|
1129
|
+
const fsx = opts.fs ?? node_fs_1.default;
|
|
1130
|
+
let root;
|
|
1131
|
+
try {
|
|
1132
|
+
root = resolveRoot(undefined, cwd);
|
|
1133
|
+
// 16-05 (D-37): the one bindings predicate the five vault handlers ask
|
|
1134
|
+
// first, required lazily (D-41: this leaf imports only fs and path at load
|
|
1135
|
+
// time) and handed this function's file system, so the seam still holds.
|
|
1136
|
+
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
|
1137
|
+
const vaultMod = require('./vault.cjs');
|
|
1138
|
+
if (!vaultMod.hasPlatformBindings(root, fsx))
|
|
1139
|
+
return false;
|
|
1140
|
+
}
|
|
1141
|
+
catch {
|
|
1142
|
+
return false;
|
|
1143
|
+
}
|
|
1144
|
+
try {
|
|
1145
|
+
let visited = 0;
|
|
1146
|
+
for (const { dir, doorOnly } of planningDirs(fsx, node_path_1.default.join(root, '.planning'))) {
|
|
1147
|
+
visited += 1;
|
|
1148
|
+
if (visited > exports.RECORDED_ARTIFACT_DIR_CAP)
|
|
1149
|
+
return true;
|
|
1150
|
+
const scan = collectRecordedArtifacts(dir, { root, fs: fsx, doorOnly, stopAtFirst: true });
|
|
1151
|
+
if (scan.artifacts.length > 0)
|
|
1152
|
+
return true;
|
|
1153
|
+
if (scan.truncated || scan.skipped > 0)
|
|
1154
|
+
return true;
|
|
1155
|
+
}
|
|
1156
|
+
return false;
|
|
1157
|
+
}
|
|
1158
|
+
catch {
|
|
1159
|
+
return true;
|
|
1160
|
+
}
|
|
1161
|
+
}
|
|
1162
|
+
/**
|
|
1163
|
+
* Read a record's start, end and back-fill anchors, by record kind.
|
|
1164
|
+
*
|
|
1165
|
+
* - `summary_ledger`: the ledger's optional `predicate.startCommit`,
|
|
1166
|
+
* `predicate.endCommit` and `predicate.anchorBackfill` (`{ by, at }`, D-32);
|
|
1167
|
+
* - `standards_consulted`: the parsed section's optional anchor line
|
|
1168
|
+
* (`anchors: { start?, end, backfill? }`, 16-06);
|
|
1169
|
+
* - `door_record`: the record's `startCommit` / `endCommit` (a door record is
|
|
1170
|
+
* written by its door, so it is never back-filled).
|
|
1171
|
+
* Every other kind, and every record written before Phase 16, has none: all
|
|
1172
|
+
* three are `undefined`. A value that is present but fails its grammar is
|
|
1173
|
+
* DROPPED and reported in `problems` by field, so 16-07 can type it; it never
|
|
1174
|
+
* reaches a caller that might place it in a git argv (T-16-07). Never throws.
|
|
1175
|
+
*
|
|
1176
|
+
* 16-06 re-premised `backfill` (D-55): 16-03 read it as a sha taken from the
|
|
1177
|
+
* back-fill object's `at`. D-32 makes the back-fill an attribution — who and
|
|
1178
|
+
* when — while the sha the operator supplied with `--at` becomes the record's
|
|
1179
|
+
* END anchor, so `backfill` is now that `{ by, at }` pair.
|
|
1180
|
+
*/
|
|
1181
|
+
function extractRecordAnchors(text, kind) {
|
|
1182
|
+
const out = { start: undefined, end: undefined, backfill: undefined, problems: [] };
|
|
1183
|
+
const take = (field, value) => {
|
|
1184
|
+
if (value === undefined || value === null)
|
|
1185
|
+
return;
|
|
1186
|
+
if (isAnchorSha(value))
|
|
1187
|
+
out[field] = value;
|
|
1188
|
+
else
|
|
1189
|
+
out.problems.push({ kind: 'anchor_invalid', field });
|
|
1190
|
+
};
|
|
1191
|
+
const takeBackfill = (value) => {
|
|
1192
|
+
if (value === undefined || value === null)
|
|
1193
|
+
return;
|
|
1194
|
+
if (isAnchorBackfill(value))
|
|
1195
|
+
out.backfill = { by: value.by, at: value.at };
|
|
1196
|
+
else
|
|
1197
|
+
out.problems.push({ kind: 'anchor_invalid', field: 'backfill' });
|
|
1198
|
+
};
|
|
1199
|
+
try {
|
|
1200
|
+
const body = typeof text === 'string' ? text : '';
|
|
1201
|
+
if (kind === exports.RECORDED_ARTIFACT_KIND.SUMMARY_LEDGER) {
|
|
1202
|
+
const ledger = engineLedger(readers(), body, 'the record');
|
|
1203
|
+
if (ledger === null) {
|
|
1204
|
+
out.problems.push({ kind: 'record_unparsable', field: 'record' });
|
|
1205
|
+
return out;
|
|
1206
|
+
}
|
|
1207
|
+
const predicate = (ledger.predicate && typeof ledger.predicate === 'object' ? ledger.predicate : {});
|
|
1208
|
+
take('start', predicate.startCommit);
|
|
1209
|
+
take('end', predicate.endCommit);
|
|
1210
|
+
takeBackfill(predicate.anchorBackfill);
|
|
1211
|
+
// The parser drops a present-but-invalid anchor and lists the KEY it
|
|
1212
|
+
// dropped (never the value); a dropped anchor is still a problem here.
|
|
1213
|
+
if (Array.isArray(predicate.anchorsDropped)) {
|
|
1214
|
+
for (const key of predicate.anchorsDropped) {
|
|
1215
|
+
const field = key === 'startCommit' ? 'start' : key === 'endCommit' ? 'end' : key === 'anchorBackfill' ? 'backfill' : null;
|
|
1216
|
+
if (field !== null)
|
|
1217
|
+
out.problems.push({ kind: 'anchor_invalid', field });
|
|
1218
|
+
}
|
|
1219
|
+
}
|
|
1220
|
+
}
|
|
1221
|
+
else if (kind === exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED) {
|
|
1222
|
+
const standards = readers().standards;
|
|
1223
|
+
const section = standards.parseConsultedSection(body);
|
|
1224
|
+
if (section.present !== true || section.planId === null) {
|
|
1225
|
+
out.problems.push({ kind: 'record_unparsable', field: 'record' });
|
|
1226
|
+
return out;
|
|
1227
|
+
}
|
|
1228
|
+
const anchors = (section.anchors && typeof section.anchors === 'object' ? section.anchors : {});
|
|
1229
|
+
take('start', anchors.start);
|
|
1230
|
+
take('end', anchors.end);
|
|
1231
|
+
takeBackfill(anchors.backfill);
|
|
1232
|
+
// A prefixed anchor line that did not parse is dropped by the parser and
|
|
1233
|
+
// reported as `standards_anchor_malformed`; it is an invalid anchor here.
|
|
1234
|
+
const problems = Array.isArray(section.problems) ? section.problems : [];
|
|
1235
|
+
if (problems.some((p) => p && p.kind === standards.STANDARDS_PROBLEM_KIND.ANCHOR_MALFORMED)) {
|
|
1236
|
+
out.problems.push({ kind: 'anchor_invalid', field: 'end' });
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
else if (kind === exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD) {
|
|
1240
|
+
const parsed = parseDoorRecord(body);
|
|
1241
|
+
if (!parsed.ok || parsed.record === null) {
|
|
1242
|
+
out.problems.push({ kind: 'record_unparsable', field: 'record' });
|
|
1243
|
+
return out;
|
|
1244
|
+
}
|
|
1245
|
+
take('start', parsed.record.startCommit);
|
|
1246
|
+
take('end', parsed.record.endCommit);
|
|
1247
|
+
}
|
|
1248
|
+
}
|
|
1249
|
+
catch {
|
|
1250
|
+
out.problems.push({ kind: 'record_unparsable', field: 'record' });
|
|
1251
|
+
}
|
|
1252
|
+
return out;
|
|
1253
|
+
}
|
|
1254
|
+
// ─── SEAL-01: commits no record covers (16-07; D-15, D-16, D-31) ─────────────
|
|
1255
|
+
//
|
|
1256
|
+
// THE QUESTION. A phase's records end at their END anchors. A commit made after
|
|
1257
|
+
// them is invisible to every reader that judges records, so an unrecorded
|
|
1258
|
+
// change could ship under a phase whose records all say "governed". The strict
|
|
1259
|
+
// trace points (ship:pre, verify:post on a full-phase run, quick's
|
|
1260
|
+
// execute:post) ask instead: which commits reachable from HEAD are reachable
|
|
1261
|
+
// from none of this phase's ends, and are covered by no other record's range?
|
|
1262
|
+
//
|
|
1263
|
+
// THE RANGE RULE (D-31, research C-7). A commit c is covered iff some record r
|
|
1264
|
+
// has start_r < c <= end_r in ancestry: `rev-list start_r..end_r`, start
|
|
1265
|
+
// exclusive, end inclusive. The simpler ancestor rule ("c is an ancestor of
|
|
1266
|
+
// some recorded end") was measured to hide an ungoverned commit followed by ANY
|
|
1267
|
+
// later record (E1 -> X -> Q: X is an ancestor of Q's end), which is a record
|
|
1268
|
+
// claiming governance it never had. Per-record calls are required: the union of
|
|
1269
|
+
// ranges is not `ends --not starts`.
|
|
1270
|
+
//
|
|
1271
|
+
// ASSUMPTIONS (16-RESEARCH A1, A3), stated where they bind:
|
|
1272
|
+
// - A1: merge commits are excluded (`--no-merges`); the commits a merge brings
|
|
1273
|
+
// in are examined themselves. An "evil merge" carrying its own source edit
|
|
1274
|
+
// is not seen.
|
|
1275
|
+
// - A3: a shallow clone may not hold an old anchor; that reads as an unknown
|
|
1276
|
+
// anchor, and the router's remedy names `git fetch --unshallow`.
|
|
1277
|
+
//
|
|
1278
|
+
// SAFETY OF THE ARGV (T-16-25). Every recorded sha is checked against
|
|
1279
|
+
// `ANCHOR_SHA_RE` before any git call. Revisions for `rev-list` go on STDIN,
|
|
1280
|
+
// never in argv (research Pitfall 6); a sha placed in argv (`merge-base`,
|
|
1281
|
+
// `diff-tree`, `log`) comes after the subcommand and is hex by construction,
|
|
1282
|
+
// so it can never be read as an option.
|
|
1283
|
+
//
|
|
1284
|
+
// BOUNDS (T-16-28). Every call is the injected bounded `execGit` (timeout, no
|
|
1285
|
+
// throw; ENOBUFS and a timeout come back as a failed exit). Each count has a
|
|
1286
|
+
// cap, and passing one is a typed failure, never a silent partial answer.
|
|
1287
|
+
/** At most this many uncovered source commits are listed; the rest are counted. */
|
|
1288
|
+
exports.UNCOVERED_COMMIT_CAP = 50;
|
|
1289
|
+
/**
|
|
1290
|
+
* At most this many covering records may need their range computed. Only a
|
|
1291
|
+
* record whose END is itself one of the candidates can cover a candidate (see
|
|
1292
|
+
* `computeUncoveredSourceCommits`), so this counts the records written after
|
|
1293
|
+
* the phase being judged, not every record the project holds.
|
|
1294
|
+
*/
|
|
1295
|
+
exports.COVERING_RECORD_CAP = 200;
|
|
1296
|
+
/** At most this many source paths are named per uncovered commit. */
|
|
1297
|
+
exports.UNCOVERED_PATHS_PER_COMMIT_CAP = 20;
|
|
1298
|
+
/**
|
|
1299
|
+
* At most this many commits after the phase's ends are examined at all, and at
|
|
1300
|
+
* most this many commits a single record's range may hold. 5000 full ids are
|
|
1301
|
+
* ~205 KB of `rev-list` output (sha-1) or ~325 KB (sha-256), well inside
|
|
1302
|
+
* the 1 MiB child-process output buffer `execGit` runs with.
|
|
1303
|
+
*/
|
|
1304
|
+
exports.SEAL_CANDIDATE_CAP = 5000;
|
|
1305
|
+
/** At most this many uncovered commits have their paths read (one `diff-tree` each). */
|
|
1306
|
+
exports.SEAL_EXAMINE_CAP = 500;
|
|
1307
|
+
/** A commit subject is bounded to this many characters before it leaves the leaf. */
|
|
1308
|
+
exports.SEAL_SUBJECT_CAP = 120;
|
|
1309
|
+
/** Each git call's timeout. */
|
|
1310
|
+
exports.SEAL_GIT_TIMEOUT_MS = 10_000;
|
|
1311
|
+
/**
|
|
1312
|
+
* A record's anchors as the seal judges them: `extractRecordAnchors`, plus ONE
|
|
1313
|
+
* fallback for the start.
|
|
1314
|
+
*
|
|
1315
|
+
* A SUMMARY record (practices ledger or standards section) that carries an END
|
|
1316
|
+
* but no START takes the start its own directory's `.vault-anchors.json`
|
|
1317
|
+
* records. That file is the one `vault trace scaffold` and `vault standards
|
|
1318
|
+
* consulted` read the start from when they write the record, so the fallback
|
|
1319
|
+
* reads the same CLI-recorded value from the same place, at a later moment
|
|
1320
|
+
* (D-59). It exists for the worktree executor (deferred-items item 12): the
|
|
1321
|
+
* anchors file is untracked, a linked worktree does not have it, so a record
|
|
1322
|
+
* written there carries the end only; the strict points run in the checkout
|
|
1323
|
+
* that wrote the file, where the start is readable.
|
|
1324
|
+
*
|
|
1325
|
+
* Not applied when the record's own anchor was present and malformed (a
|
|
1326
|
+
* `problems` entry): a hand-edited start is reported as `anchor_invalid` at
|
|
1327
|
+
* every point (D-58), and must not be quietly replaced by another value. Not
|
|
1328
|
+
* applied to a door record, whose start is its own `--begin` start and whose
|
|
1329
|
+
* directory's anchors file is the phase's, not the door's. Never throws.
|
|
1330
|
+
*/
|
|
1331
|
+
function resolveRecordAnchors(text, kind, dirAbs, opts = {}) {
|
|
1332
|
+
const anchors = extractRecordAnchors(text, kind);
|
|
1333
|
+
const out = { ...anchors, startFrom: anchors.start !== undefined ? 'record' : undefined };
|
|
1334
|
+
const summaryKind = kind === exports.RECORDED_ARTIFACT_KIND.SUMMARY_LEDGER || kind === exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED;
|
|
1335
|
+
if (!summaryKind || anchors.end === undefined || anchors.start !== undefined)
|
|
1336
|
+
return out;
|
|
1337
|
+
if (anchors.problems.some((p) => p.kind === 'anchor_invalid'))
|
|
1338
|
+
return out;
|
|
1339
|
+
const dir = opts.dirAnchors !== undefined ? opts.dirAnchors : readDirAnchors(dirAbs);
|
|
1340
|
+
if (!dir || !isAnchorSha(dir.start))
|
|
1341
|
+
return out;
|
|
1342
|
+
// D-98: the LATEST recorded start that precedes this record's own end. A
|
|
1343
|
+
// start recorded after the record's work (a resume) is not its start, so a
|
|
1344
|
+
// run-1 worktree record keeps run 1's start and a later one takes the later
|
|
1345
|
+
// start. Without an ancestry check (no git), the current start alone — the
|
|
1346
|
+
// reading that covers least.
|
|
1347
|
+
const candidates = [dir.start, ...(dir.earlier ?? []).map((e) => e.start)];
|
|
1348
|
+
const end = anchors.end;
|
|
1349
|
+
const chosen = typeof opts.isAncestor === 'function'
|
|
1350
|
+
? candidates.find((c) => {
|
|
1351
|
+
try {
|
|
1352
|
+
return opts.isAncestor(c, end);
|
|
1353
|
+
}
|
|
1354
|
+
catch {
|
|
1355
|
+
return false;
|
|
1356
|
+
}
|
|
1357
|
+
})
|
|
1358
|
+
: candidates[0];
|
|
1359
|
+
if (chosen !== undefined) {
|
|
1360
|
+
out.start = chosen;
|
|
1361
|
+
out.startFrom = 'directory';
|
|
1362
|
+
}
|
|
1363
|
+
return out;
|
|
1364
|
+
}
|
|
1365
|
+
/**
|
|
1366
|
+
* The ancestry predicate the strict gate injects: one bounded `merge-base
|
|
1367
|
+
* --is-ancestor` per question, shapes checked before git is reached, any
|
|
1368
|
+
* failure read as "not an ancestor" (the reading that covers less).
|
|
1369
|
+
*/
|
|
1370
|
+
function sealIsAncestorVia(execGit, root, timeoutMs = exports.SEAL_GIT_TIMEOUT_MS) {
|
|
1371
|
+
return (ancestor, descendant) => {
|
|
1372
|
+
if (!isAnchorSha(ancestor) || !isAnchorSha(descendant))
|
|
1373
|
+
return false;
|
|
1374
|
+
try {
|
|
1375
|
+
const r = execGit(['merge-base', '--is-ancestor', ancestor, descendant], { cwd: root, timeout: timeoutMs });
|
|
1376
|
+
return r.exitCode === 0 && (r.error === undefined || r.error === null) && (r.signal === undefined || r.signal === null);
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
return false;
|
|
1380
|
+
}
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/** The attestations a directory holds for one record file, as the seal reads them. */
|
|
1384
|
+
function attestedFor(attestations, recordName) {
|
|
1385
|
+
return attestations.filter((a) => a.record === recordName).map((a) => ({ sha: a.sha, from: a.from }));
|
|
1386
|
+
}
|
|
1387
|
+
/** A cheap pre-filter: a record written before 16-06 carries neither marker and is not parsed. */
|
|
1388
|
+
const LEDGER_END_MARKER = 'endCommit:';
|
|
1389
|
+
const CONSULTED_ANCHOR_MARKER = '- anchors: ';
|
|
1390
|
+
/**
|
|
1391
|
+
* Every record in the project's planning tree that carries an END anchor.
|
|
1392
|
+
*
|
|
1393
|
+
* Walks the roots `hasRecordedVaultArtifacts` walks, in its order: phase
|
|
1394
|
+
* directories (root and every workstream), quick-task directories, and the
|
|
1395
|
+
* door artifacts in `.planning/audits`. In each directory the candidates are
|
|
1396
|
+
* `*SUMMARY.md` (a practices ledger, else a standards section) and the door
|
|
1397
|
+
* artifacts (a door record), each read once under `TRACE_ARTIFACT_BYTE_CAP`.
|
|
1398
|
+
* A record carrying an end but no start is returned with `start` undefined
|
|
1399
|
+
* (it covers nothing, D-31) unless its directory's anchors file supplies one
|
|
1400
|
+
* (`resolveRecordAnchors`, D-59).
|
|
1401
|
+
*
|
|
1402
|
+
* `exclude` names records (repo-relative) the caller judges itself: the trace
|
|
1403
|
+
* gate passes the phase's own SUMMARYs, which are its END anchors, not its
|
|
1404
|
+
* coverage. The caller is bindings-inert before it gets here (D-37). Never
|
|
1405
|
+
* throws.
|
|
1406
|
+
*/
|
|
1407
|
+
function collectCoveringRecords(root, opts = {}) {
|
|
1408
|
+
const fsx = opts.fs ?? node_fs_1.default;
|
|
1409
|
+
const exclude = new Set(opts.exclude ?? []);
|
|
1410
|
+
const records = [];
|
|
1411
|
+
let incomplete = false;
|
|
1412
|
+
let unread = 0;
|
|
1413
|
+
const relOf = (abs) => node_path_1.default.relative(root, abs).split(node_path_1.default.sep).join('/');
|
|
1414
|
+
try {
|
|
1415
|
+
let visited = 0;
|
|
1416
|
+
for (const { dir, doorOnly } of planningDirs(fsx, node_path_1.default.join(root, '.planning'))) {
|
|
1417
|
+
visited += 1;
|
|
1418
|
+
if (visited > exports.RECORDED_ARTIFACT_DIR_CAP) {
|
|
1419
|
+
incomplete = true;
|
|
1420
|
+
break;
|
|
1421
|
+
}
|
|
1422
|
+
let entries;
|
|
1423
|
+
try {
|
|
1424
|
+
entries = fsx.readdirSync(dir).map(String).sort();
|
|
1425
|
+
}
|
|
1426
|
+
catch (e) {
|
|
1427
|
+
const code = e?.code;
|
|
1428
|
+
if (code !== 'ENOENT' && code !== 'ENOTDIR')
|
|
1429
|
+
incomplete = true;
|
|
1430
|
+
continue;
|
|
1431
|
+
}
|
|
1432
|
+
let dirAnchors;
|
|
1433
|
+
let dirAttestations;
|
|
1434
|
+
let dirCertifiedEnds;
|
|
1435
|
+
let admitted = 0;
|
|
1436
|
+
for (const name of entries) {
|
|
1437
|
+
const summary = !doorOnly && SUMMARY_FILENAME_RE.test(name);
|
|
1438
|
+
const door = exports.DOOR_ARTIFACT_FILENAME_RE.test(name);
|
|
1439
|
+
if (!summary && !door)
|
|
1440
|
+
continue;
|
|
1441
|
+
const abs = node_path_1.default.join(dir, name);
|
|
1442
|
+
const rel = relOf(abs);
|
|
1443
|
+
if (exclude.has(rel))
|
|
1444
|
+
continue;
|
|
1445
|
+
if (admitted >= exports.TRACE_ARTIFACT_COUNT_CAP) {
|
|
1446
|
+
incomplete = true;
|
|
1447
|
+
break;
|
|
1448
|
+
}
|
|
1449
|
+
admitted += 1;
|
|
1450
|
+
let text;
|
|
1451
|
+
try {
|
|
1452
|
+
const st = fsx.statSync(abs);
|
|
1453
|
+
if (!st.isFile() || st.size > exports.TRACE_ARTIFACT_BYTE_CAP) {
|
|
1454
|
+
unread += 1;
|
|
1455
|
+
incomplete = true;
|
|
1456
|
+
continue;
|
|
1457
|
+
}
|
|
1458
|
+
text = String(fsx.readFileSync(abs, 'utf8'));
|
|
1459
|
+
}
|
|
1460
|
+
catch {
|
|
1461
|
+
unread += 1;
|
|
1462
|
+
incomplete = true;
|
|
1463
|
+
continue;
|
|
1464
|
+
}
|
|
1465
|
+
const tries = [];
|
|
1466
|
+
if (summary && text.includes(LEDGER_END_MARKER))
|
|
1467
|
+
tries.push(exports.RECORDED_ARTIFACT_KIND.SUMMARY_LEDGER);
|
|
1468
|
+
if (summary && text.includes(CONSULTED_ANCHOR_MARKER))
|
|
1469
|
+
tries.push(exports.RECORDED_ARTIFACT_KIND.STANDARDS_CONSULTED);
|
|
1470
|
+
if (door && text.includes(exports.DOOR_RECORD_HEADING))
|
|
1471
|
+
tries.push(exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD);
|
|
1472
|
+
for (const kind of tries) {
|
|
1473
|
+
if (dirAnchors === undefined && kind !== exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD)
|
|
1474
|
+
dirAnchors = readDirAnchors(dir);
|
|
1475
|
+
const a = resolveRecordAnchors(text, kind, dir, { dirAnchors: dirAnchors ?? null, isAncestor: opts.isAncestor });
|
|
1476
|
+
if (a.end === undefined)
|
|
1477
|
+
continue;
|
|
1478
|
+
const rec = { rel, start: a.start, end: a.end, backfill: a.backfill, startFrom: a.startFrom };
|
|
1479
|
+
if (kind !== exports.RECORDED_ARTIFACT_KIND.DOOR_RECORD) {
|
|
1480
|
+
if (dirAttestations === undefined)
|
|
1481
|
+
dirAttestations = readDirAttestations(dir);
|
|
1482
|
+
const att = attestedFor(dirAttestations, name);
|
|
1483
|
+
if (att.length > 0)
|
|
1484
|
+
rec.attested = att;
|
|
1485
|
+
// 16-18 D-107: read once per directory, for a PHASE record only (a
|
|
1486
|
+
// quick task's record is never certified: it never bounds, D-104);
|
|
1487
|
+
// set only, never `false`, so an uncertified record reads as before.
|
|
1488
|
+
if (isPhaseRecordRel(rel)) {
|
|
1489
|
+
if (dirCertifiedEnds === undefined)
|
|
1490
|
+
dirCertifiedEnds = new Set(readDirCertificates(dir).map((c) => c.end));
|
|
1491
|
+
if (dirCertifiedEnds.has(a.end))
|
|
1492
|
+
rec.certified = true;
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
records.push(rec);
|
|
1496
|
+
break;
|
|
1497
|
+
}
|
|
1498
|
+
}
|
|
1499
|
+
}
|
|
1500
|
+
}
|
|
1501
|
+
catch {
|
|
1502
|
+
incomplete = true;
|
|
1503
|
+
}
|
|
1504
|
+
return { records, incomplete, unread };
|
|
1505
|
+
}
|
|
1506
|
+
/**
|
|
1507
|
+
* Is this record a PHASE plan's (a file directly inside a `phases/<dir>/`
|
|
1508
|
+
* directory, at the root or in a workstream)? A quick task's lives in
|
|
1509
|
+
* `quick/<dir>/` and a door artifact may sit in `audits/`. Read off the
|
|
1510
|
+
* repo-relative path the walk produced (16 review iteration 3 WR-02, D-104).
|
|
1511
|
+
*/
|
|
1512
|
+
function isPhaseRecordRel(rel) {
|
|
1513
|
+
const parts = String(rel).split('/');
|
|
1514
|
+
return parts.length >= 3 && parts[parts.length - 3] === 'phases';
|
|
1515
|
+
}
|
|
1516
|
+
/**
|
|
1517
|
+
* Is this record a SUMMARY (a phase plan's or a quick task's), as opposed to
|
|
1518
|
+
* a door artifact? Read off the file name alone; whether it is a PHASE record
|
|
1519
|
+
* is `isPhaseRecordRel`'s question. The one owner of summary-ness for SEAL-01's
|
|
1520
|
+
* lower bound (c2) and for `vault anchor certify` (16 gap closure 16-18, D-107).
|
|
1521
|
+
*/
|
|
1522
|
+
function isSummaryRecordRel(rel) {
|
|
1523
|
+
return SUMMARY_FILENAME_RE.test(String(rel).split('/').pop() ?? '');
|
|
1524
|
+
}
|
|
1525
|
+
const DEFAULT_SEAL_CAPS = Object.freeze({
|
|
1526
|
+
uncovered: exports.UNCOVERED_COMMIT_CAP,
|
|
1527
|
+
coveringRecords: exports.COVERING_RECORD_CAP,
|
|
1528
|
+
pathsPerCommit: exports.UNCOVERED_PATHS_PER_COMMIT_CAP,
|
|
1529
|
+
candidates: exports.SEAL_CANDIDATE_CAP,
|
|
1530
|
+
examine: exports.SEAL_EXAMINE_CAP,
|
|
1531
|
+
});
|
|
1532
|
+
/** One line of untrusted text, controls collapsed and bounded; the router's sanitiser still runs on it. */
|
|
1533
|
+
function boundedLine(value, cap) {
|
|
1534
|
+
const flat = value.replace(/[\u0000-\u001F\u007F-\u009F]+/g, ' ').replace(/\s+/g, ' ').trim();
|
|
1535
|
+
return flat.length > cap ? `${flat.slice(0, cap)}…` : flat;
|
|
1536
|
+
}
|
|
1537
|
+
/**
|
|
1538
|
+
* Which commits after this phase's END anchors touch governed source and are
|
|
1539
|
+
* covered by no other record's range? (SEAL-01, D-15, D-16, D-31)
|
|
1540
|
+
*
|
|
1541
|
+
* In order, every step through the injected bounded `execGit` with `cwd: root`:
|
|
1542
|
+
* (a) every sha in `phaseEnds` and in `coveringRecords` must pass
|
|
1543
|
+
* `ANCHOR_SHA_RE`, or `anchor_invalid`, before any git call;
|
|
1544
|
+
* (b) every phase end must exist: `rev-list --no-walk --ignore-missing
|
|
1545
|
+
* --stdin`, the printed set compared with the input set, else
|
|
1546
|
+
* `anchor_unknown` naming the missing ones;
|
|
1547
|
+
* (c) every phase end must be an ancestor of HEAD: `merge-base --is-ancestor
|
|
1548
|
+
* <end> HEAD`, else `anchor_not_ancestor` naming them;
|
|
1549
|
+
* (c2) the lower bound (16 review CR-04): without `phaseRecords`, the
|
|
1550
|
+
* phase's ends. With them, each own record whose start is an ancestor of
|
|
1551
|
+
* its end (`merge-base --is-ancestor`) contributes its start..end range;
|
|
1552
|
+
* their common base (`merge-base --octopus` when they differ) is walked
|
|
1553
|
+
* back (`rev-list --max-count=<cap+1>`), and the ends of OTHER phase
|
|
1554
|
+
* plans' SUMMARY records found there that are CERTIFIED (`certified ===
|
|
1555
|
+
* true`: their directory's certificates file holds the end, written only
|
|
1556
|
+
* by `vault anchor certify` after a clean strict check of that phase —
|
|
1557
|
+
* 16 gap closure 16-18, D-107, closing D-106) are the lower bound, so the
|
|
1558
|
+
* commits between the latest certified phase record and this phase's
|
|
1559
|
+
* start are candidates too. An UNCERTIFIED phase record (its own strict
|
|
1560
|
+
* check blocked, never ran, was overridden, or ran before this engine)
|
|
1561
|
+
* covers its own range but never bounds, so the commit its gate named is
|
|
1562
|
+
* named again here; a quick task's record (its one strict gate records
|
|
1563
|
+
* nothing, D-104) and a door record (no gate, D-90) never bound. No
|
|
1564
|
+
* certified phase record in the window (D-109): the oldest point where a
|
|
1565
|
+
* windowed quick or uncertified phase SUMMARY record started (its end
|
|
1566
|
+
* without a usable start), via `merge-base --octopus` with the base;
|
|
1567
|
+
* with none, the base itself (nothing before it was governed). A full
|
|
1568
|
+
* window with an earlier SUMMARY record beyond it is `cap_exceeded`,
|
|
1569
|
+
* naming `vault anchor certify` as the repair. An own record with no
|
|
1570
|
+
* usable start keeps its end as a bound, as before;
|
|
1571
|
+
* (d) the candidates: `rev-list --parents --max-count=<cap+1> HEAD --stdin`
|
|
1572
|
+
* with one `^<bound>` line per bound on stdin; merge commits (two or more
|
|
1573
|
+
* parents) are reachable but never candidates (research A1). MEASURED
|
|
1574
|
+
* FORM (git 2.55.0): a `^<sha>` line is the revision syntax `rev-list
|
|
1575
|
+
* --stdin` has always read; a `--not` line on stdin also works on 2.55.0,
|
|
1576
|
+
* but pseudo-options on stdin are newer than `--stdin` itself, so the
|
|
1577
|
+
* portable spelling is used. More than the cap is `cap_exceeded`;
|
|
1578
|
+
* (e) coverage: only a record whose END is itself reachable here can cover a
|
|
1579
|
+
* candidate (an end reachable from a bound puts its whole range there
|
|
1580
|
+
* too, and an end that is not an ancestor of HEAD, or unknown, covers
|
|
1581
|
+
* nothing on this branch, so neither needs a git call); a merge commit
|
|
1582
|
+
* counts as reachable here (16 review WR-05). This phase's own ranges
|
|
1583
|
+
* cover too, and are not counted against the record cap. For each
|
|
1584
|
+
* such record: no start → skipped (`no_start`); `merge-base
|
|
1585
|
+
* --is-ancestor <start> <end>` exit 1 → skipped (`start_not_ancestor`),
|
|
1586
|
+
* 128 → skipped (`start_unknown`); otherwise `rev-list --first-parent
|
|
1587
|
+
* --max-count=<cap+1> --stdin` with `^<start>` and `<end>` on stdin, and
|
|
1588
|
+
* its commits are covered: the record's own line only, never what a merge
|
|
1589
|
+
* brought in from another branch (16 review WR-02). A skipped record covers nothing; it is never a failure. More
|
|
1590
|
+
* than `coveringRecords` such records is `cap_exceeded`;
|
|
1591
|
+
* (f) the uncovered candidates, in `rev-list` order, up to `examine`: the
|
|
1592
|
+
* paths from `diff-tree -r -z --root --no-commit-id --name-only
|
|
1593
|
+
* --no-renames <sha>` (`--no-renames`, so moving source into `docs/` still
|
|
1594
|
+
* lists the source path; `-z`, so a non-ASCII path is not C-quoted), the
|
|
1595
|
+
* commit kept when at least one path is not `isNonSourcePath`, its
|
|
1596
|
+
* subject from `log -1 --format=%s <sha>`. The scan stops after
|
|
1597
|
+
* `uncovered` source commits (`truncated`). A scan stopped by `examine`
|
|
1598
|
+
* with none found is `cap_exceeded`: the rest could not be checked.
|
|
1599
|
+
* Any non-zero exit, a timeout or ENOBUFS (`execGit` reports both as a failed
|
|
1600
|
+
* exit) is `git_failed`. Never throws.
|
|
1601
|
+
*/
|
|
1602
|
+
function computeUncoveredSourceCommits(opts) {
|
|
1603
|
+
const caps = { ...DEFAULT_SEAL_CAPS, ...(opts.caps ?? {}) };
|
|
1604
|
+
const timeout = typeof opts.timeoutMs === 'number' ? opts.timeoutMs : exports.SEAL_GIT_TIMEOUT_MS;
|
|
1605
|
+
const git = (args, input) => {
|
|
1606
|
+
try {
|
|
1607
|
+
return opts.execGit(args, { cwd: opts.root, timeout, ...(input !== undefined ? { input } : {}) });
|
|
1608
|
+
}
|
|
1609
|
+
catch (e) {
|
|
1610
|
+
// The real execGit never throws; an injected one might, and a throw here
|
|
1611
|
+
// must still become a typed failure rather than escape the gate.
|
|
1612
|
+
return { exitCode: 1, stdout: '', stderr: String(e?.message ?? e), error: e };
|
|
1613
|
+
}
|
|
1614
|
+
};
|
|
1615
|
+
const hard = (r) => (r.error !== undefined && r.error !== null)
|
|
1616
|
+
|| (r.signal !== undefined && r.signal !== null);
|
|
1617
|
+
const failed = (args, r) => ({
|
|
1618
|
+
ok: false,
|
|
1619
|
+
reason: 'git_failed',
|
|
1620
|
+
detail: `git ${args[0]} failed (exit ${r.exitCode})${r.stderr ? `: ${boundedLine(r.stderr, exports.SEAL_SUBJECT_CAP)}` : ''}`,
|
|
1621
|
+
});
|
|
1622
|
+
const lines = (out) => out.split(/\r?\n/).map((l) => l.trim()).filter((l) => l !== '');
|
|
1623
|
+
// (a) the grammar, before any git call.
|
|
1624
|
+
const ends = [...new Set(Array.isArray(opts.phaseEnds) ? opts.phaseEnds : [])];
|
|
1625
|
+
if (ends.length === 0)
|
|
1626
|
+
return { ok: false, reason: 'anchor_invalid', detail: 'no phase end anchor was given' };
|
|
1627
|
+
if (!ends.every(isAnchorSha)) {
|
|
1628
|
+
return { ok: false, reason: 'anchor_invalid', detail: 'a phase end anchor is not a full lowercase commit id' };
|
|
1629
|
+
}
|
|
1630
|
+
const covering = Array.isArray(opts.coveringRecords) ? opts.coveringRecords : [];
|
|
1631
|
+
for (const r of covering) {
|
|
1632
|
+
if (!isAnchorSha(r.end) || (r.start !== undefined && !isAnchorSha(r.start))) {
|
|
1633
|
+
return { ok: false, reason: 'anchor_invalid', detail: 'a covering record anchor is not a full lowercase commit id' };
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
const own = Array.isArray(opts.phaseRecords) ? opts.phaseRecords : null;
|
|
1637
|
+
for (const r of own ?? []) {
|
|
1638
|
+
if (!isAnchorSha(r.end) || (r.start !== undefined && !isAnchorSha(r.start))) {
|
|
1639
|
+
return { ok: false, reason: 'anchor_invalid', detail: 'a phase record anchor is not a full lowercase commit id' };
|
|
1640
|
+
}
|
|
1641
|
+
}
|
|
1642
|
+
// (b) every phase end exists.
|
|
1643
|
+
const existArgs = ['rev-list', '--no-walk', '--ignore-missing', '--stdin'];
|
|
1644
|
+
const exist = git(existArgs, `${ends.join('\n')}\n`);
|
|
1645
|
+
if (exist.exitCode !== 0 || hard(exist))
|
|
1646
|
+
return failed(existArgs, exist);
|
|
1647
|
+
const present = new Set(lines(exist.stdout));
|
|
1648
|
+
const unknown = ends.filter((e) => !present.has(e));
|
|
1649
|
+
if (unknown.length > 0) {
|
|
1650
|
+
return { ok: false, reason: 'anchor_unknown', anchors: unknown, detail: `${unknown.length} end anchor(s) are not in this repository` };
|
|
1651
|
+
}
|
|
1652
|
+
// (c) every phase end is an ancestor of HEAD.
|
|
1653
|
+
const notAncestor = [];
|
|
1654
|
+
for (const end of ends) {
|
|
1655
|
+
const args = ['merge-base', '--is-ancestor', end, 'HEAD'];
|
|
1656
|
+
const r = git(args);
|
|
1657
|
+
if (r.exitCode === 0 && !hard(r))
|
|
1658
|
+
continue;
|
|
1659
|
+
if (r.exitCode === 1 && !hard(r)) {
|
|
1660
|
+
notAncestor.push(end);
|
|
1661
|
+
continue;
|
|
1662
|
+
}
|
|
1663
|
+
return failed(args, r);
|
|
1664
|
+
}
|
|
1665
|
+
if (notAncestor.length > 0) {
|
|
1666
|
+
return { ok: false, reason: 'anchor_not_ancestor', anchors: notAncestor, detail: `${notAncestor.length} end anchor(s) are not ancestors of HEAD` };
|
|
1667
|
+
}
|
|
1668
|
+
// (c2) the lower bound (16 review CR-04). Without the phase's own records
|
|
1669
|
+
// its ends bound the candidates, as D-15 first wrote it. With them, the
|
|
1670
|
+
// phase claims only its own start..end ranges: the candidates reach back to
|
|
1671
|
+
// the latest earlier record's end, so an ungoverned commit made after that
|
|
1672
|
+
// end and before this phase's start (E1 -> X -> P, start = X) is named here,
|
|
1673
|
+
// at this phase's strict point, not only by an earlier phase whose gate may
|
|
1674
|
+
// have run before X existed.
|
|
1675
|
+
const lower = own === null ? ends : [];
|
|
1676
|
+
const ownRanges = [];
|
|
1677
|
+
if (own !== null) {
|
|
1678
|
+
const tops = [];
|
|
1679
|
+
const bounds = new Set();
|
|
1680
|
+
for (const r of own) {
|
|
1681
|
+
let valid = false;
|
|
1682
|
+
if (r.start !== undefined) {
|
|
1683
|
+
const args = ['merge-base', '--is-ancestor', r.start, r.end];
|
|
1684
|
+
const res = git(args);
|
|
1685
|
+
if (hard(res))
|
|
1686
|
+
return failed(args, res);
|
|
1687
|
+
valid = res.exitCode === 0;
|
|
1688
|
+
}
|
|
1689
|
+
if (valid) {
|
|
1690
|
+
ownRanges.push(r);
|
|
1691
|
+
if (!tops.includes(r.start))
|
|
1692
|
+
tops.push(r.start);
|
|
1693
|
+
}
|
|
1694
|
+
else {
|
|
1695
|
+
// No usable start (D-59 already reports a missing one): its end keeps
|
|
1696
|
+
// bounding the candidates, exactly as before this rule.
|
|
1697
|
+
bounds.add(r.end);
|
|
1698
|
+
}
|
|
1699
|
+
}
|
|
1700
|
+
for (const e of ends)
|
|
1701
|
+
if (!own.some((r) => r.end === e))
|
|
1702
|
+
bounds.add(e);
|
|
1703
|
+
if (tops.length === 0) {
|
|
1704
|
+
for (const e of ends)
|
|
1705
|
+
bounds.add(e);
|
|
1706
|
+
}
|
|
1707
|
+
else {
|
|
1708
|
+
let base = tops[0];
|
|
1709
|
+
if (tops.length > 1) {
|
|
1710
|
+
const mbArgs = ['merge-base', '--octopus', ...tops];
|
|
1711
|
+
const mb = git(mbArgs);
|
|
1712
|
+
if (mb.exitCode !== 0 || hard(mb))
|
|
1713
|
+
return failed(mbArgs, mb);
|
|
1714
|
+
base = lines(mb.stdout)[0] ?? '';
|
|
1715
|
+
if (!isAnchorSha(base))
|
|
1716
|
+
return { ok: false, reason: 'git_failed', detail: 'git merge-base printed a line that is not a commit id' };
|
|
1717
|
+
}
|
|
1718
|
+
const winArgs = ['rev-list', `--max-count=${caps.candidates + 1}`, '--stdin'];
|
|
1719
|
+
const win = git(winArgs, `${base}\n`);
|
|
1720
|
+
if (win.exitCode !== 0 || hard(win))
|
|
1721
|
+
return failed(winArgs, win);
|
|
1722
|
+
const window = new Set(lines(win.stdout));
|
|
1723
|
+
// Only a CERTIFIED phase plan's SUMMARY bounds (16 gap closure 16-18,
|
|
1724
|
+
// D-107, closing the residual D-104 stated and D-106 ruled must be
|
|
1725
|
+
// fixed). A bound says "every commit up to here was judged by a strict
|
|
1726
|
+
// check that passed", and only a certificate records that: it is written
|
|
1727
|
+
// by `vault anchor certify` alone, after that verb has itself run the
|
|
1728
|
+
// phase's strict check and read `status: checked`, `block: false` and no
|
|
1729
|
+
// override (D-108), and it is keyed by the record's END, so a SUMMARY
|
|
1730
|
+
// written later is uncertified until the next clean check. An
|
|
1731
|
+
// uncertified phase record — its own strict check blocked (an abandoned
|
|
1732
|
+
// phase keeps its SUMMARY), never ran, was overridden, or ran before this
|
|
1733
|
+
// engine — covers its own range but never bounds, so the commit its gate
|
|
1734
|
+
// named is named again at this phase. A quick task's record never bounds
|
|
1735
|
+
// (its one strict gate writes no record of passing, D-104), nor does a
|
|
1736
|
+
// door record (it has no gate of its own, D-90); both still COVER. No
|
|
1737
|
+
// fixpoint is needed: a certificate is only ever written after a clean
|
|
1738
|
+
// strict check whose own bound was itself a certified end or the point
|
|
1739
|
+
// where governance began, so trusting it never trusts an unjudged range.
|
|
1740
|
+
const bounding = covering.filter((r) => isSummaryRecordRel(r.rel) && isPhaseRecordRel(r.rel) && r.certified === true);
|
|
1741
|
+
const earlier = [...new Set(bounding.map((r) => r.end).filter((e) => window.has(e)))];
|
|
1742
|
+
if (earlier.length > 0) {
|
|
1743
|
+
for (const e of earlier)
|
|
1744
|
+
bounds.add(e);
|
|
1745
|
+
}
|
|
1746
|
+
else {
|
|
1747
|
+
if (window.size > caps.candidates) {
|
|
1748
|
+
// The window did not reach the first commit: an earlier record may
|
|
1749
|
+
// end further back than the cap, in which case the commits between
|
|
1750
|
+
// it and this start cannot all be examined. Any SUMMARY counts here
|
|
1751
|
+
// (a quick or an uncertified one too): it says governance had begun
|
|
1752
|
+
// further back. Both details name the repair (D-109): certifying the
|
|
1753
|
+
// earlier phases, oldest first, gives this check a trusted bound,
|
|
1754
|
+
// and each certification is bounded by its own window.
|
|
1755
|
+
const repair = "; repair: certify the earlier phases oldest first (`pp-tools vault anchor certify <phase-dir>` once its strict check passes)";
|
|
1756
|
+
const beyond = covering.filter((r) => isSummaryRecordRel(r.rel) && !window.has(r.end));
|
|
1757
|
+
if (beyond.length > caps.coveringRecords) {
|
|
1758
|
+
return { ok: false, reason: 'cap_exceeded', detail: `${beyond.length} records would need an ancestry check against this phase's start, above the ${caps.coveringRecords}-record cap${repair}` };
|
|
1759
|
+
}
|
|
1760
|
+
for (const r of beyond) {
|
|
1761
|
+
const args = ['merge-base', '--is-ancestor', r.end, base];
|
|
1762
|
+
const res = git(args);
|
|
1763
|
+
if (hard(res))
|
|
1764
|
+
return failed(args, res);
|
|
1765
|
+
if (res.exitCode === 0) {
|
|
1766
|
+
return { ok: false, reason: 'cap_exceeded', detail: `more than ${caps.candidates} commits lie between this phase's start and the latest earlier record's end${repair}` };
|
|
1767
|
+
}
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
// No certified phase record ends before this phase's start (D-109).
|
|
1771
|
+
// A quick record or an uncertified phase record that does no longer
|
|
1772
|
+
// bounds by its end, but it says governance had begun: the bound
|
|
1773
|
+
// reaches back to where the oldest such record started (its end, when
|
|
1774
|
+
// it has no usable start), so the commits between it and this start
|
|
1775
|
+
// are candidates, and each record's own commits are covered by its
|
|
1776
|
+
// range. With none, the start itself is where governance begins:
|
|
1777
|
+
// nothing before it was governed. Only a bounding record (a certified
|
|
1778
|
+
// phase SUMMARY) is skipped here, and none is in the window.
|
|
1779
|
+
const reach = [];
|
|
1780
|
+
for (const r of covering) {
|
|
1781
|
+
if (!isSummaryRecordRel(r.rel) || (isPhaseRecordRel(r.rel) && r.certified === true) || !window.has(r.end))
|
|
1782
|
+
continue;
|
|
1783
|
+
let point = r.end;
|
|
1784
|
+
if (r.start !== undefined) {
|
|
1785
|
+
const args = ['merge-base', '--is-ancestor', r.start, r.end];
|
|
1786
|
+
const res = git(args);
|
|
1787
|
+
if (hard(res))
|
|
1788
|
+
return failed(args, res);
|
|
1789
|
+
if (res.exitCode === 0)
|
|
1790
|
+
point = r.start;
|
|
1791
|
+
}
|
|
1792
|
+
if (!reach.includes(point))
|
|
1793
|
+
reach.push(point);
|
|
1794
|
+
}
|
|
1795
|
+
if (reach.length === 0) {
|
|
1796
|
+
bounds.add(base);
|
|
1797
|
+
}
|
|
1798
|
+
else {
|
|
1799
|
+
const mbArgs = ['merge-base', '--octopus', base, ...reach];
|
|
1800
|
+
const mb = git(mbArgs);
|
|
1801
|
+
if (mb.exitCode !== 0 || hard(mb))
|
|
1802
|
+
return failed(mbArgs, mb);
|
|
1803
|
+
const oldest = lines(mb.stdout)[0] ?? '';
|
|
1804
|
+
if (!isAnchorSha(oldest))
|
|
1805
|
+
return { ok: false, reason: 'git_failed', detail: 'git merge-base printed a line that is not a commit id' };
|
|
1806
|
+
bounds.add(oldest);
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
lower.push(...bounds);
|
|
1811
|
+
}
|
|
1812
|
+
// (d) the candidates: every commit reachable from HEAD and from none of the
|
|
1813
|
+
// lower bounds, read with its parents so the merge commits stay in the set
|
|
1814
|
+
// that decides which covering records are relevant (16 review WR-05) and
|
|
1815
|
+
// out of the set that is classified (research A1).
|
|
1816
|
+
const candArgs = ['rev-list', '--parents', `--max-count=${caps.candidates + 1}`, 'HEAD', '--stdin'];
|
|
1817
|
+
const cand = git(candArgs, `${lower.map((e) => `^${e}`).join('\n')}\n`);
|
|
1818
|
+
if (cand.exitCode !== 0 || hard(cand))
|
|
1819
|
+
return failed(candArgs, cand);
|
|
1820
|
+
const reachable = lines(cand.stdout).map((l) => l.split(/\s+/));
|
|
1821
|
+
if (!reachable.every((row) => row.every(isAnchorSha)))
|
|
1822
|
+
return { ok: false, reason: 'git_failed', detail: 'git rev-list printed a line that is not a commit id' };
|
|
1823
|
+
if (reachable.length > caps.candidates) {
|
|
1824
|
+
return { ok: false, reason: 'cap_exceeded', detail: `more than ${caps.candidates} commits are reachable from HEAD and from none of this phase's bounds` };
|
|
1825
|
+
}
|
|
1826
|
+
const reachableSet = new Set(reachable.map((row) => row[0]));
|
|
1827
|
+
const candidates = reachable.filter((row) => row.length <= 2).map((row) => row[0]);
|
|
1828
|
+
// (e) coverage, from the records whose end is itself reachable here: the
|
|
1829
|
+
// other records (counted against the cap, D-62) and this phase's own ranges.
|
|
1830
|
+
const relevantOthers = covering.filter((r) => reachableSet.has(r.end));
|
|
1831
|
+
if (relevantOthers.length > caps.coveringRecords) {
|
|
1832
|
+
return { ok: false, reason: 'cap_exceeded', detail: `${relevantOthers.length} records end after this phase, above the ${caps.coveringRecords}-record cap` };
|
|
1833
|
+
}
|
|
1834
|
+
const relevant = [...relevantOthers, ...ownRanges.filter((r) => reachableSet.has(r.end))];
|
|
1835
|
+
const covered = new Set();
|
|
1836
|
+
const skippedRecords = [];
|
|
1837
|
+
for (const r of relevant) {
|
|
1838
|
+
// 16 review iteration 3 CR-01 (D-102): an attested commit covers ITSELF
|
|
1839
|
+
// only, and only when it is on the record's own line between the start it
|
|
1840
|
+
// names and the record's end. Nothing between is covered by it, so an
|
|
1841
|
+
// ungoverned commit beside an attested one stays a candidate.
|
|
1842
|
+
const byFrom = new Map();
|
|
1843
|
+
for (const a of Array.isArray(r.attested) ? r.attested : []) {
|
|
1844
|
+
if (!isAnchorSha(a.sha) || !isAnchorSha(a.from))
|
|
1845
|
+
continue;
|
|
1846
|
+
if (!byFrom.has(a.from))
|
|
1847
|
+
byFrom.set(a.from, new Set());
|
|
1848
|
+
byFrom.get(a.from).add(a.sha);
|
|
1849
|
+
}
|
|
1850
|
+
for (const [from, shas] of byFrom) {
|
|
1851
|
+
const attArgs = ['rev-list', '--first-parent', `--max-count=${caps.candidates + 1}`, '--stdin'];
|
|
1852
|
+
const att = git(attArgs, `^${from}\n${r.end}\n`);
|
|
1853
|
+
if (att.exitCode !== 0 || hard(att))
|
|
1854
|
+
return failed(attArgs, att);
|
|
1855
|
+
const line = lines(att.stdout);
|
|
1856
|
+
if (line.length > caps.candidates) {
|
|
1857
|
+
return { ok: false, reason: 'cap_exceeded', detail: `an attested record's line holds more than ${caps.candidates} commits` };
|
|
1858
|
+
}
|
|
1859
|
+
for (const c of line)
|
|
1860
|
+
if (shas.has(c))
|
|
1861
|
+
covered.add(c);
|
|
1862
|
+
}
|
|
1863
|
+
if (r.start === undefined) {
|
|
1864
|
+
skippedRecords.push({ rel: r.rel, reason: 'no_start' });
|
|
1865
|
+
continue;
|
|
1866
|
+
}
|
|
1867
|
+
const ancArgs = ['merge-base', '--is-ancestor', r.start, r.end];
|
|
1868
|
+
const anc = git(ancArgs);
|
|
1869
|
+
if (hard(anc))
|
|
1870
|
+
return failed(ancArgs, anc);
|
|
1871
|
+
if (anc.exitCode === 1) {
|
|
1872
|
+
skippedRecords.push({ rel: r.rel, reason: 'start_not_ancestor' });
|
|
1873
|
+
continue;
|
|
1874
|
+
}
|
|
1875
|
+
if (anc.exitCode !== 0) {
|
|
1876
|
+
skippedRecords.push({ rel: r.rel, reason: 'start_unknown' });
|
|
1877
|
+
continue;
|
|
1878
|
+
}
|
|
1879
|
+
// 16 review WR-02: a record covers its OWN line only. `--first-parent`
|
|
1880
|
+
// walks from its end back to its start along the line the work was done
|
|
1881
|
+
// on, so commits a merge brought into the range from another branch (a
|
|
1882
|
+
// teammate's ungoverned source on `main`, merged mid-task) stay candidates
|
|
1883
|
+
// instead of being counted as governed by a record that never saw them.
|
|
1884
|
+
const rangeArgs = ['rev-list', '--first-parent', `--max-count=${caps.candidates + 1}`, '--stdin'];
|
|
1885
|
+
const range = git(rangeArgs, `^${r.start}\n${r.end}\n`);
|
|
1886
|
+
if (range.exitCode !== 0 || hard(range))
|
|
1887
|
+
return failed(rangeArgs, range);
|
|
1888
|
+
const inRange = lines(range.stdout);
|
|
1889
|
+
if (inRange.length > caps.candidates) {
|
|
1890
|
+
return { ok: false, reason: 'cap_exceeded', detail: `a covering record's range holds more than ${caps.candidates} commits` };
|
|
1891
|
+
}
|
|
1892
|
+
for (const c of inRange)
|
|
1893
|
+
covered.add(c);
|
|
1894
|
+
}
|
|
1895
|
+
// (f) classify the uncovered candidates.
|
|
1896
|
+
const uncoveredAll = candidates.filter((c) => !covered.has(c));
|
|
1897
|
+
const uncovered = [];
|
|
1898
|
+
let examined = 0;
|
|
1899
|
+
for (const sha of uncoveredAll) {
|
|
1900
|
+
if (uncovered.length >= caps.uncovered || examined >= caps.examine)
|
|
1901
|
+
break;
|
|
1902
|
+
examined += 1;
|
|
1903
|
+
const treeArgs = ['diff-tree', '-r', '-z', '--root', '--no-commit-id', '--name-only', '--no-renames', sha];
|
|
1904
|
+
const tree = git(treeArgs);
|
|
1905
|
+
if (tree.exitCode !== 0 || hard(tree))
|
|
1906
|
+
return failed(treeArgs, tree);
|
|
1907
|
+
const sourcePaths = tree.stdout.split('\0').filter((p) => p !== '' && !isNonSourcePath(p));
|
|
1908
|
+
if (sourcePaths.length === 0)
|
|
1909
|
+
continue;
|
|
1910
|
+
const subjArgs = ['log', '-1', '--format=%s', sha];
|
|
1911
|
+
const subj = git(subjArgs);
|
|
1912
|
+
if (subj.exitCode !== 0 || hard(subj))
|
|
1913
|
+
return failed(subjArgs, subj);
|
|
1914
|
+
uncovered.push({
|
|
1915
|
+
sha,
|
|
1916
|
+
shortSha: sha.slice(0, 12),
|
|
1917
|
+
subject: boundedLine(subj.stdout, exports.SEAL_SUBJECT_CAP),
|
|
1918
|
+
sourcePaths: sourcePaths.slice(0, caps.pathsPerCommit),
|
|
1919
|
+
pathsTruncated: sourcePaths.length > caps.pathsPerCommit,
|
|
1920
|
+
sourcePathCount: sourcePaths.length,
|
|
1921
|
+
});
|
|
1922
|
+
}
|
|
1923
|
+
const truncated = examined < uncoveredAll.length;
|
|
1924
|
+
if (truncated && uncovered.length === 0) {
|
|
1925
|
+
return { ok: false, reason: 'cap_exceeded', detail: `${uncoveredAll.length} commits are covered by no record and only ${examined} could be examined` };
|
|
1926
|
+
}
|
|
1927
|
+
return {
|
|
1928
|
+
ok: true,
|
|
1929
|
+
uncovered,
|
|
1930
|
+
truncated,
|
|
1931
|
+
candidateCount: candidates.length,
|
|
1932
|
+
uncoveredCount: uncoveredAll.length,
|
|
1933
|
+
examined,
|
|
1934
|
+
skippedRecords,
|
|
1935
|
+
};
|
|
1936
|
+
}
|