@erclx/canon 5.32.0 → 5.34.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/REQUIREMENT.md +1 -2
- package/claude/skills/api-design/SKILL.md +2 -3
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/SKILL.md +1 -1
- package/claude/skills/auto-ship/references/worktree-name.md +1 -1
- package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
- package/claude/skills/backlog-triage/SKILL.md +1 -1
- package/claude/skills/canon-cli/REQUIREMENT.md +16 -13
- package/claude/skills/canon-cli/SKILL.md +75 -32
- package/claude/skills/canon-feedback/REQUIREMENT.md +1 -0
- package/claude/skills/canon-feedback/SKILL.md +11 -29
- package/claude/skills/canon-feedback/references/template.md +53 -0
- package/claude/skills/{canvas → canvas-board}/REQUIREMENT.md +2 -2
- package/claude/skills/{canvas → canvas-board}/SKILL.md +6 -3
- package/claude/skills/{canvas → canvas-board}/references/canvas.md +26 -3
- package/{standards → claude/skills/canvas-board/references}/design.md +11 -2
- package/claude/skills/code-craft/REQUIREMENT.md +1 -1
- package/claude/skills/code-craft/SKILL.md +0 -1
- package/claude/skills/context-fold/REQUIREMENT.md +25 -33
- package/claude/skills/context-fold/SKILL.md +25 -70
- package/claude/skills/context-fold/references/classify.md +3 -3
- package/claude/skills/context-fold/references/completion-report.md +4 -4
- package/claude/skills/context-fold/references/context-refresh.md +5 -5
- package/{standards → claude/skills/context-fold/references}/context.md +16 -5
- package/claude/skills/context-fold/references/unusable-baseline.md +3 -5
- package/claude/skills/create-rule/SKILL.md +3 -3
- package/{standards → claude/skills/create-rule/references}/rule.md +15 -6
- package/claude/skills/create-skill/SKILL.md +2 -2
- package/{standards → claude/skills/create-skill/references}/skill.md +18 -15
- package/claude/skills/design-taste/REQUIREMENT.md +3 -3
- package/claude/skills/design-taste/SKILL.md +1 -1
- package/claude/skills/docs-sync/REQUIREMENT.md +1 -1
- package/claude/skills/docs-sync/SKILL.md +3 -3
- package/{standards → claude/skills/docs-sync/references}/docs.md +11 -0
- package/{standards → claude/skills/docs-sync/references}/readme.md +9 -2
- package/claude/skills/draft-and-pick/SKILL.md +5 -5
- package/claude/skills/draft-doc/SKILL.md +6 -6
- package/claude/skills/draft-figure/REQUIREMENT.md +2 -2
- package/claude/skills/draft-figure/SKILL.md +2 -2
- package/claude/skills/draft-figure/references/hand-drawn.md +1 -1
- package/claude/skills/draft-figure/references/mermaid-path.md +1 -1
- package/claude/skills/draft-identity/REQUIREMENT.md +2 -2
- package/claude/skills/draft-identity/SKILL.md +2 -2
- package/claude/skills/draft-ready/SKILL.md +1 -1
- package/claude/skills/git-branch/SKILL.md +1 -1
- package/{standards → claude/skills/git-branch/references}/branch.md +1 -1
- package/claude/skills/git-commit/SKILL.md +2 -2
- package/claude/skills/git-followup/REQUIREMENT.md +1 -1
- package/claude/skills/git-followup/SKILL.md +1 -1
- package/claude/skills/git-issue/SKILL.md +1 -1
- package/claude/skills/git-issue/references/issue.md +4 -1
- package/claude/skills/git-pr/SKILL.md +13 -13
- package/claude/skills/git-pr/references/evidence.md +2 -2
- package/claude/skills/git-pr/references/final-command.md +38 -14
- package/claude/skills/git-pr/references/labels.md +5 -1
- package/{standards → claude/skills/git-pr/references}/pr.md +19 -9
- package/{standards → claude/skills/git-pr/references}/publish.md +4 -2
- package/claude/skills/git-pr/references/task-number.md +1 -1
- package/claude/skills/git-ship/SKILL.md +15 -14
- package/claude/skills/git-split/SKILL.md +3 -3
- package/claude/skills/git-stage/SKILL.md +1 -1
- package/claude/skills/plan-feature/REQUIREMENT.md +1 -1
- package/claude/skills/review-address/SKILL.md +1 -1
- package/claude/skills/review-address/references/post-review-findings.md +1 -1
- package/claude/skills/review-craft/references/ui.md +1 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -1
- package/claude/skills/review-pr/SKILL.md +3 -3
- package/claude/skills/review-pr/references/body-variants.md +2 -2
- package/claude/skills/review-pr/references/key-changes.md +3 -3
- package/claude/skills/review-pr/references/post.md +4 -2
- package/claude/skills/review-ui/REQUIREMENT.md +1 -1
- package/claude/skills/review-ui/SKILL.md +8 -6
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +53 -17
- package/claude/skills/role-orchestrator/REQUIREMENT.md +3 -0
- package/claude/skills/role-orchestrator/SKILL.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-handoff.md +8 -5
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +32 -1
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-resume.md +3 -0
- package/claude/skills/role-planner/REQUIREMENT.md +2 -1
- package/claude/skills/role-planner/SKILL.md +13 -2
- package/claude/skills/role-reviewer/SKILL.md +1 -1
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +9 -1
- package/claude/skills/session-map/SKILL.md +1 -1
- package/claude/skills/session-resume/REQUIREMENT.md +2 -2
- package/claude/skills/session-worktree/SKILL.md +5 -5
- package/claude/skills/target-setup/SKILL.md +10 -4
- package/claude/skills/target-setup/references/detect.md +20 -3
- package/claude/skills/task-board/REQUIREMENT.md +7 -12
- package/claude/skills/task-board/SKILL.md +5 -17
- package/claude/skills/task-board/references/tasks.md +2 -2
- package/claude/skills/task-complete/REQUIREMENT.md +47 -0
- package/claude/skills/task-complete/SKILL.md +86 -0
- package/claude/skills/{task-board → task-complete}/references/archive.md +4 -4
- package/claude/skills/{context-fold → task-complete}/references/receipt-sweep.md +5 -5
- package/claude/skills/task-complete/references/unusable-baseline.md +19 -0
- package/claude/skills/teach-workspace/REQUIREMENT.md +6 -2
- package/claude/skills/teach-workspace/SKILL.md +8 -4
- package/claude/skills/teach-workspace/references/lesson-craft.md +1 -1
- package/claude/skills/{context-fold/references/promoted-pages.md → teach-workspace/references/promotion-fold.md} +3 -3
- package/claude/skills/teach-workspace/references/promotion.md +3 -3
- package/claude/skills/test-craft/REQUIREMENT.md +2 -3
- package/claude/skills/test-craft/SKILL.md +15 -3
- package/claude/skills/ui-checklist/SKILL.md +1 -1
- package/claude/skills/ux-audit/SKILL.md +1 -1
- package/claude/skills/ux-measure/SKILL.md +1 -1
- package/docs/agents/canvas.md +6 -2
- package/docs/agents/commands.md +4 -3
- package/docs/agents/context-audit-checks.md +4 -4
- package/docs/agents/gov-detect.md +47 -0
- package/docs/agents/index.md +7 -4
- package/docs/agents/indexes.md +1 -1
- package/docs/agents/install-and-sync.md +10 -11
- package/docs/agents/key-changes.md +5 -5
- package/docs/agents/label-coverage.md +11 -0
- package/docs/agents/migrate.md +1 -1
- package/docs/agents/pr-evidence-layout.md +70 -0
- package/docs/agents/pr-evidence.md +8 -9
- package/docs/agents/pr-frames.md +7 -2
- package/docs/agents/pr-lift.md +132 -0
- package/docs/agents/records-push.md +2 -2
- package/docs/agents/records-stale.md +12 -47
- package/docs/agents/records-validate.md +3 -7
- package/docs/agents/records.md +5 -27
- package/docs/agents/rule-citations.md +1 -1
- package/docs/agents/sandbox.md +4 -4
- package/docs/agents/scripting.md +2 -1
- package/docs/agents/skills-audit.md +1 -1
- package/docs/agents/tasks-plans.md +2 -2
- package/docs/agents/tasks.md +2 -2
- package/docs/target/migrations.md +10 -2
- package/docs/target/projects.md +4 -4
- package/docs/target/sync.md +1 -1
- package/docs/workflow/operating-model.md +2 -2
- package/docs/workflow/parallel-features.md +7 -7
- package/docs/workflow/skill-map-on-request.md +9 -9
- package/docs/workflow/skill-map.md +12 -11
- package/docs/workflow/visual-design-workflow.md +2 -2
- package/governance/detect.toml +201 -0
- package/governance/renames.toml +0 -11
- package/governance/rules/canon/context.md +13 -0
- package/governance/rules/claude/skill.md +17 -0
- package/governance/rules/code/000-code.md +1 -13
- package/governance/rules/code/010-testing.md +2 -30
- package/governance/rules/ui/400-ui.md +1 -1
- package/governance/rules/ui/480-canvas.md +13 -0
- package/governance/stacks/base.toml +2 -4
- package/package.json +4 -4
- package/scripts/core/install-check.sh +3 -3
- package/scripts/core/regen-hero.ts +0 -6
- package/scripts/lib/ui.sh +0 -168
- package/src/audits/catalog.ts +0 -1
- package/src/canvas/arrange.ts +66 -0
- package/src/canvas/capture.ts +20 -6
- package/src/canvas/content.ts +39 -0
- package/src/canvas/fit.ts +238 -0
- package/src/canvas/tokens.ts +28 -10
- package/src/capture/frame-values.ts +2 -32
- package/src/claude/cases/misc.ts +1 -1
- package/src/claude/cases/setup.ts +4 -0
- package/src/claude/cases/workflow.ts +6 -1
- package/src/claude/seeds.ts +1 -1
- package/src/claude/skills-audit.ts +2 -2
- package/src/cli/help.ts +3 -1
- package/src/cli/ui.ts +2 -3
- package/src/commands/canvas.ts +179 -1
- package/src/commands/claude.ts +1 -1
- package/src/commands/gov.ts +146 -16
- package/src/commands/labels.ts +326 -7
- package/src/commands/migrate.ts +6 -1
- package/src/commands/pr.ts +377 -2
- package/src/commands/records.ts +13 -399
- package/src/commands/sandbox.ts +3 -6
- package/src/commands/tasks.ts +3 -3
- package/src/context/audit.ts +5 -5
- package/src/context/folders.ts +1 -1
- package/src/design/budget.ts +1 -1
- package/src/design/components.ts +1 -1
- package/src/design/document.ts +1 -1
- package/src/design/regen.ts +1 -1
- package/src/design/render.ts +1 -1
- package/src/feedback/body.ts +26 -5
- package/src/gate/measures.ts +59 -45
- package/src/gate/sequencer.ts +56 -1
- package/src/gate/stages.ts +4 -11
- package/src/gov/detect.ts +301 -0
- package/src/gov/install.ts +2 -3
- package/src/gov/list.ts +23 -2
- package/src/gov/stacks.ts +10 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/labels/apply.ts +39 -0
- package/src/labels/format.ts +3 -3
- package/src/labels/phase.ts +3 -3
- package/src/labels/sections.ts +43 -0
- package/src/migrate/skill-names.ts +20 -1
- package/src/pr/evidence.ts +218 -45
- package/src/pr/frames.ts +23 -3
- package/src/pr/lift.ts +418 -0
- package/src/pr/paths.ts +4 -4
- package/src/pr/rest.ts +3 -0
- package/src/pr/review-scope.ts +76 -0
- package/src/records/backup.ts +16 -6
- package/src/records/stale-canonical.ts +31 -1
- package/src/records/validate.ts +2 -188
- package/src/sandbox/census.ts +1 -1
- package/src/sandbox/coverage.ts +2 -6
- package/src/sandbox/equivalence.ts +19 -51
- package/src/sandbox/expect.ts +4 -4
- package/src/sandbox/provision.ts +13 -36
- package/src/sandbox/scenario.ts +26 -48
- package/src/sandbox/tree.ts +2 -6
- package/src/standards/read.ts +14 -1
- package/src/sync/git.ts +1 -1
- package/src/tasks/archive.ts +5 -5
- package/src/tasks/branch.ts +3 -3
- package/src/tasks/label.ts +1 -1
- package/src/tasks/record.ts +2 -2
- package/src/tasks/validate.ts +1 -1
- package/standards/index.md +0 -13
- package/standards/standard.md +1 -7
- package/tooling/base/reference.md +1 -1
- package/tooling/base/seeds/.github/pull_request_template.md +2 -2
- package/tooling/claude/reference.md +1 -1
- package/tooling/claude/seeds/.claude/hooks/pr-create-log.sh +9 -1
- package/tooling/claude/seeds/.claude/hooks/review-post-scan.sh +171 -0
- package/tooling/claude/seeds/.claude/settings.json +9 -0
- package/governance/rules/standards/context.md +0 -14
- package/governance/rules/standards/design.md +0 -12
- package/governance/rules/standards/readme.md +0 -12
- package/governance/rules/standards/rule.md +0 -13
- package/governance/rules/standards/skill.md +0 -21
- package/governance/rules/standards/standard.md +0 -13
- package/scripts/config.sh +0 -11
- package/scripts/lib/gov.sh +0 -14
- package/scripts/lib/sandbox-fixtures.sh +0 -191
- package/scripts/lib/sandbox-git.sh +0 -125
- package/scripts/lib/sandbox-path.sh +0 -50
- package/scripts/sandbox-hook.sh +0 -99
- package/src/gov/standard-rules.ts +0 -179
- package/src/records/migrate.ts +0 -78
- package/src/records/stale.ts +0 -243
- package/src/sandbox/hooks.ts +0 -128
- /package/{standards → claude/skills/draft-figure/references}/figures.md +0 -0
- /package/{standards → claude/skills/git-branch/references}/slug.md +0 -0
- /package/{standards → claude/skills/git-commit/references}/commit.md +0 -0
- /package/{standards → claude/skills/git-commit/references}/versioning.md +0 -0
package/src/pr/lift.ts
ADDED
|
@@ -0,0 +1,418 @@
|
|
|
1
|
+
import type { ChecksState } from '@/targets/pulls'
|
|
2
|
+
import {
|
|
3
|
+
coversHead,
|
|
4
|
+
type ReviewRow,
|
|
5
|
+
resolveReviewScope,
|
|
6
|
+
resolveUiScope,
|
|
7
|
+
} from '@/pr/review-scope'
|
|
8
|
+
|
|
9
|
+
/** How long `--wait` holds for a pending CI run, in seconds, when none is named. */
|
|
10
|
+
export const DEFAULT_WAIT_SECONDS = 1200
|
|
11
|
+
|
|
12
|
+
/** The gap between two reads of the check runs while the verb waits on them. */
|
|
13
|
+
export const CHECKS_POLL_MS = 30_000
|
|
14
|
+
|
|
15
|
+
/** Why the verb did not lift, in the order the gates are read. */
|
|
16
|
+
export type LiftRefusal =
|
|
17
|
+
| 'pull-closed'
|
|
18
|
+
| 'review-missing'
|
|
19
|
+
| 'review-open'
|
|
20
|
+
| 'review-unmarked'
|
|
21
|
+
| 'review-behind'
|
|
22
|
+
| 'ui-missing'
|
|
23
|
+
| 'ui-open'
|
|
24
|
+
| 'ui-unmarked'
|
|
25
|
+
| 'ui-behind'
|
|
26
|
+
| 'evidence-owed'
|
|
27
|
+
| 'standing-stale'
|
|
28
|
+
| 'standing-conflict'
|
|
29
|
+
| 'standing-unknown'
|
|
30
|
+
| 'ci-pending'
|
|
31
|
+
| 'ci-failing'
|
|
32
|
+
| 'ci-conflicted'
|
|
33
|
+
| 'ci-timeout'
|
|
34
|
+
| 'head-moved'
|
|
35
|
+
| 'gh-failed'
|
|
36
|
+
|
|
37
|
+
/** The gates a pull request clears, named so a record can report each one. */
|
|
38
|
+
export type GateName = 'review' | 'ui' | 'evidence' | 'standing' | 'ci'
|
|
39
|
+
|
|
40
|
+
export interface GateResult {
|
|
41
|
+
readonly gate: GateName
|
|
42
|
+
readonly isOk: boolean
|
|
43
|
+
/** Absent when the gate passed. */
|
|
44
|
+
readonly reason?: LiftRefusal
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** How the head sits against the base the pull request targets. */
|
|
48
|
+
export type Standing = 'fresh' | 'stale' | 'conflict' | 'unknown'
|
|
49
|
+
|
|
50
|
+
export interface PullState {
|
|
51
|
+
readonly isDraft: boolean
|
|
52
|
+
readonly isOpen: boolean
|
|
53
|
+
/** The id the ready-for-review mutation takes. */
|
|
54
|
+
readonly nodeId: string
|
|
55
|
+
readonly branch: string
|
|
56
|
+
/** The head the pull request object reports, which lags the tip. */
|
|
57
|
+
readonly head: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** What the evidence check says about the comment the ship chain posts. */
|
|
61
|
+
export interface EvidenceGate {
|
|
62
|
+
readonly isSettled: boolean
|
|
63
|
+
readonly owed: readonly string[]
|
|
64
|
+
/** Whether the marked comment carries a visual checklist, which binds the UI gate. */
|
|
65
|
+
readonly hasChecklist: boolean
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export interface ChecksGate {
|
|
69
|
+
readonly state: ChecksState
|
|
70
|
+
readonly isConflicted: boolean
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Everything the verb reads or writes, injected so the gates and the wait run
|
|
75
|
+
* without a network. A read that failed returns undefined, and the verb
|
|
76
|
+
* refuses `gh-failed` rather than reading absence as a pass.
|
|
77
|
+
*/
|
|
78
|
+
export interface LiftApi {
|
|
79
|
+
readPull(): Promise<PullState | undefined>
|
|
80
|
+
/** The branch tip from the remote, which is the authority for the head. */
|
|
81
|
+
readTip(branch: string): Promise<string | undefined>
|
|
82
|
+
readReviews(): Promise<readonly ReviewRow[] | undefined>
|
|
83
|
+
readEvidence(): Promise<EvidenceGate | undefined>
|
|
84
|
+
readStanding(head: string): Promise<Standing>
|
|
85
|
+
readChecks(tip: string): Promise<ChecksGate | undefined>
|
|
86
|
+
/** The one GraphQL call the verb makes. */
|
|
87
|
+
markReady(nodeId: string): Promise<boolean>
|
|
88
|
+
sleep(ms: number): Promise<void>
|
|
89
|
+
now(): number
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export interface LiftOptions {
|
|
93
|
+
readonly isCheck: boolean
|
|
94
|
+
readonly waitSeconds: number
|
|
95
|
+
readonly pollMs?: number
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export interface LiftRecord {
|
|
99
|
+
readonly reason: 'lifted' | 'already-ready' | 'clear' | LiftRefusal
|
|
100
|
+
readonly isLifted: boolean
|
|
101
|
+
/** Whether the caller can treat the pull request as ready afterwards. */
|
|
102
|
+
readonly isOk: boolean
|
|
103
|
+
readonly head?: string
|
|
104
|
+
readonly gates: readonly GateResult[]
|
|
105
|
+
readonly waitedSeconds: number
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function pass(gate: GateName): GateResult {
|
|
109
|
+
return { gate, isOk: true }
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function fail(gate: GateName, reason: LiftRefusal): GateResult {
|
|
113
|
+
return { gate, isOk: false, reason }
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* The code review gate. A pass with no marker is refused rather than lifted,
|
|
118
|
+
* since the commit it covers is a stamp GitHub wrote at submission, which the
|
|
119
|
+
* marker exists to replace.
|
|
120
|
+
*/
|
|
121
|
+
function reviewGate(reviews: readonly ReviewRow[], head: string): GateResult {
|
|
122
|
+
const scope = resolveReviewScope({ reviews })
|
|
123
|
+
if (scope.state === 'none') return fail('review', 'review-missing')
|
|
124
|
+
if (scope.state === 'open') return fail('review', 'review-open')
|
|
125
|
+
if (scope.source !== 'marker') return fail('review', 'review-unmarked')
|
|
126
|
+
if (scope.commit === undefined || !coversHead(scope.commit, head)) {
|
|
127
|
+
return fail('review', 'review-behind')
|
|
128
|
+
}
|
|
129
|
+
return pass('review')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Binds only where the evidence record carries a checklist to drive. */
|
|
133
|
+
function uiGate(
|
|
134
|
+
reviews: readonly ReviewRow[],
|
|
135
|
+
head: string,
|
|
136
|
+
hasChecklist: boolean,
|
|
137
|
+
): GateResult {
|
|
138
|
+
if (!hasChecklist) return pass('ui')
|
|
139
|
+
|
|
140
|
+
const scope = resolveUiScope({ reviews })
|
|
141
|
+
if (scope.state === 'none') return fail('ui', 'ui-missing')
|
|
142
|
+
if (scope.state === 'open') return fail('ui', 'ui-open')
|
|
143
|
+
if (scope.source !== 'marker') return fail('ui', 'ui-unmarked')
|
|
144
|
+
if (scope.commit === undefined || !coversHead(scope.commit, head)) {
|
|
145
|
+
return fail('ui', 'ui-behind')
|
|
146
|
+
}
|
|
147
|
+
return pass('ui')
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function standingGate(standing: Standing): GateResult {
|
|
151
|
+
if (standing === 'fresh') return pass('standing')
|
|
152
|
+
return fail('standing', `standing-${standing}`)
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
type CiOutcome =
|
|
156
|
+
| { readonly kind: 'passing'; readonly waited: number }
|
|
157
|
+
| {
|
|
158
|
+
readonly kind: 'refused'
|
|
159
|
+
readonly reason: LiftRefusal
|
|
160
|
+
readonly waited: number
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Reads the check runs until they conclude, the deadline passes, or a run can
|
|
165
|
+
* no longer start. A conflicted branch gets no merge ref, so no run ever
|
|
166
|
+
* starts and waiting on it is the one wait that cannot end.
|
|
167
|
+
*/
|
|
168
|
+
async function waitForChecks(
|
|
169
|
+
api: LiftApi,
|
|
170
|
+
tip: string,
|
|
171
|
+
waitSeconds: number,
|
|
172
|
+
pollMs: number,
|
|
173
|
+
): Promise<CiOutcome> {
|
|
174
|
+
const started = api.now()
|
|
175
|
+
const deadline = started + waitSeconds * 1000
|
|
176
|
+
|
|
177
|
+
for (;;) {
|
|
178
|
+
const reading = await api.readChecks(tip)
|
|
179
|
+
const waited = Math.round((api.now() - started) / 1000)
|
|
180
|
+
if (reading === undefined) {
|
|
181
|
+
return { kind: 'refused', reason: 'gh-failed', waited }
|
|
182
|
+
}
|
|
183
|
+
if (reading.state === 'passing') return { kind: 'passing', waited }
|
|
184
|
+
if (reading.state === 'failing') {
|
|
185
|
+
return { kind: 'refused', reason: 'ci-failing', waited }
|
|
186
|
+
}
|
|
187
|
+
if (reading.isConflicted) {
|
|
188
|
+
return { kind: 'refused', reason: 'ci-conflicted', waited }
|
|
189
|
+
}
|
|
190
|
+
if (api.now() + pollMs > deadline) {
|
|
191
|
+
return { kind: 'refused', reason: 'ci-timeout', waited }
|
|
192
|
+
}
|
|
193
|
+
await api.sleep(pollMs)
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function refused(
|
|
198
|
+
reason: LiftRefusal,
|
|
199
|
+
gates: readonly GateResult[],
|
|
200
|
+
head: string | undefined,
|
|
201
|
+
waitedSeconds = 0,
|
|
202
|
+
): LiftRecord {
|
|
203
|
+
return {
|
|
204
|
+
reason,
|
|
205
|
+
isLifted: false,
|
|
206
|
+
isOk: false,
|
|
207
|
+
...(head !== undefined && { head }),
|
|
208
|
+
gates,
|
|
209
|
+
waitedSeconds,
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/**
|
|
214
|
+
* Lifts the draft mark when every gate reads clear at the head.
|
|
215
|
+
*
|
|
216
|
+
* The reads that need no wait all run before the CI wait, so a reviewer whose
|
|
217
|
+
* partner is still driving refuses at once rather than holding a turn through
|
|
218
|
+
* a CI run. The head and the reviews are read again after the wait and before
|
|
219
|
+
* the mutation, since a push during the wait moves the head off the commit
|
|
220
|
+
* every earlier gate cleared.
|
|
221
|
+
*/
|
|
222
|
+
export async function lift(
|
|
223
|
+
api: LiftApi,
|
|
224
|
+
opts: LiftOptions,
|
|
225
|
+
): Promise<LiftRecord> {
|
|
226
|
+
const pollMs = opts.pollMs ?? CHECKS_POLL_MS
|
|
227
|
+
|
|
228
|
+
const pull = await api.readPull()
|
|
229
|
+
if (pull === undefined) return refused('gh-failed', [], undefined)
|
|
230
|
+
if (!pull.isOpen) return refused('pull-closed', [], pull.head)
|
|
231
|
+
if (!pull.isDraft) {
|
|
232
|
+
return {
|
|
233
|
+
reason: 'already-ready',
|
|
234
|
+
isLifted: false,
|
|
235
|
+
isOk: true,
|
|
236
|
+
head: pull.head,
|
|
237
|
+
gates: [],
|
|
238
|
+
waitedSeconds: 0,
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// The tip, not the object's head, since the object lags the ref by up to a
|
|
243
|
+
// minute after a push and a review marker names the commit it read.
|
|
244
|
+
const tip = await api.readTip(pull.branch)
|
|
245
|
+
if (tip === undefined) return refused('gh-failed', [], pull.head)
|
|
246
|
+
|
|
247
|
+
const [reviews, evidence, standing] = await Promise.all([
|
|
248
|
+
api.readReviews(),
|
|
249
|
+
api.readEvidence(),
|
|
250
|
+
api.readStanding(tip),
|
|
251
|
+
])
|
|
252
|
+
if (reviews === undefined || evidence === undefined) {
|
|
253
|
+
return refused('gh-failed', [], tip)
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const gates: GateResult[] = [
|
|
257
|
+
reviewGate(reviews, tip),
|
|
258
|
+
uiGate(reviews, tip, evidence.hasChecklist),
|
|
259
|
+
evidence.isSettled ? pass('evidence') : fail('evidence', 'evidence-owed'),
|
|
260
|
+
standingGate(standing),
|
|
261
|
+
]
|
|
262
|
+
const blocked = gates.find((gate) => !gate.isOk)
|
|
263
|
+
|
|
264
|
+
if (opts.isCheck) {
|
|
265
|
+
const reading = await api.readChecks(tip)
|
|
266
|
+
if (reading === undefined) {
|
|
267
|
+
return refused('gh-failed', gates, tip)
|
|
268
|
+
}
|
|
269
|
+
const ci =
|
|
270
|
+
reading.state === 'passing'
|
|
271
|
+
? pass('ci')
|
|
272
|
+
: reading.state === 'failing'
|
|
273
|
+
? fail('ci', 'ci-failing')
|
|
274
|
+
: reading.isConflicted
|
|
275
|
+
? fail('ci', 'ci-conflicted')
|
|
276
|
+
: fail('ci', 'ci-pending')
|
|
277
|
+
const all = [...gates, ci]
|
|
278
|
+
const first = all.find((gate) => !gate.isOk)
|
|
279
|
+
if (first?.reason !== undefined) return refused(first.reason, all, tip)
|
|
280
|
+
return {
|
|
281
|
+
reason: 'clear',
|
|
282
|
+
isLifted: false,
|
|
283
|
+
isOk: true,
|
|
284
|
+
head: tip,
|
|
285
|
+
gates: all,
|
|
286
|
+
waitedSeconds: 0,
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (blocked?.reason !== undefined) return refused(blocked.reason, gates, tip)
|
|
291
|
+
|
|
292
|
+
const ci = await waitForChecks(api, tip, opts.waitSeconds, pollMs)
|
|
293
|
+
if (ci.kind === 'refused') {
|
|
294
|
+
return refused(ci.reason, [...gates, fail('ci', ci.reason)], tip, ci.waited)
|
|
295
|
+
}
|
|
296
|
+
const cleared = [...gates, pass('ci')]
|
|
297
|
+
|
|
298
|
+
// The re-read: whatever the gates cleared above was cleared at `tip`.
|
|
299
|
+
const [nowPull, nowTip, nowReviews] = await Promise.all([
|
|
300
|
+
api.readPull(),
|
|
301
|
+
api.readTip(pull.branch),
|
|
302
|
+
api.readReviews(),
|
|
303
|
+
])
|
|
304
|
+
if (
|
|
305
|
+
nowPull === undefined ||
|
|
306
|
+
nowTip === undefined ||
|
|
307
|
+
nowReviews === undefined
|
|
308
|
+
) {
|
|
309
|
+
return refused('gh-failed', cleared, tip, ci.waited)
|
|
310
|
+
}
|
|
311
|
+
if (!nowPull.isDraft) {
|
|
312
|
+
return {
|
|
313
|
+
reason: 'already-ready',
|
|
314
|
+
isLifted: false,
|
|
315
|
+
isOk: true,
|
|
316
|
+
head: nowTip,
|
|
317
|
+
gates: cleared,
|
|
318
|
+
waitedSeconds: ci.waited,
|
|
319
|
+
}
|
|
320
|
+
}
|
|
321
|
+
if (nowTip !== tip) return refused('head-moved', cleared, nowTip, ci.waited)
|
|
322
|
+
|
|
323
|
+
const reread = [
|
|
324
|
+
reviewGate(nowReviews, nowTip),
|
|
325
|
+
uiGate(nowReviews, nowTip, evidence.hasChecklist),
|
|
326
|
+
].find((gate) => !gate.isOk)
|
|
327
|
+
if (reread?.reason !== undefined) {
|
|
328
|
+
return refused(reread.reason, cleared, nowTip, ci.waited)
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
// Standing is read again too, since a sibling merging during the wait can
|
|
332
|
+
// leave a branch that read fresh before it stale.
|
|
333
|
+
const laterStanding = standingGate(await api.readStanding(nowTip))
|
|
334
|
+
if (laterStanding.reason !== undefined) {
|
|
335
|
+
return refused(laterStanding.reason, cleared, nowTip, ci.waited)
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
const isMarked = await api.markReady(pull.nodeId)
|
|
339
|
+
if (!isMarked) return refused('gh-failed', cleared, nowTip, ci.waited)
|
|
340
|
+
|
|
341
|
+
return {
|
|
342
|
+
reason: 'lifted',
|
|
343
|
+
isLifted: true,
|
|
344
|
+
isOk: true,
|
|
345
|
+
head: nowTip,
|
|
346
|
+
gates: cleared,
|
|
347
|
+
waitedSeconds: ci.waited,
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** One git invocation, answering its stdout or undefined when it failed. */
|
|
352
|
+
export type GitRunner = (args: readonly string[]) => Promise<string | undefined>
|
|
353
|
+
|
|
354
|
+
function nonEmptyLines(output: string): string[] {
|
|
355
|
+
return output.split('\n').filter((line) => line !== '')
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Reads where a head stands against the base, ported from the orchestrator
|
|
360
|
+
* poll's own read so the lift and the poll answer one question the same way.
|
|
361
|
+
*
|
|
362
|
+
* merge-tree answers locally, because `mergeable_state` reads unknown while
|
|
363
|
+
* GitHub computes it, which is exactly when a verb asks. Only a change to a
|
|
364
|
+
* path the branch writes makes it stale, since re-testing after every merge to
|
|
365
|
+
* the base is a merge queue's cost without its batching. A head or base that
|
|
366
|
+
* does not resolve, which a fork or a deleted head leaves, reads `unknown`.
|
|
367
|
+
*/
|
|
368
|
+
export async function readBaseStanding(
|
|
369
|
+
git: GitRunner,
|
|
370
|
+
number: number,
|
|
371
|
+
head: string,
|
|
372
|
+
ref: string,
|
|
373
|
+
): Promise<Standing> {
|
|
374
|
+
// The base is fetched as well as the head. A tracking ref is only as current
|
|
375
|
+
// as the last fetch, so a sibling merging since then would read as fresh.
|
|
376
|
+
const baseBranch = ref.startsWith('origin/')
|
|
377
|
+
? ref.slice('origin/'.length)
|
|
378
|
+
: ref
|
|
379
|
+
await git(['fetch', '-q', 'origin', baseBranch])
|
|
380
|
+
await git(['fetch', '-q', 'origin', `pull/${number}/head`])
|
|
381
|
+
|
|
382
|
+
const hasHead =
|
|
383
|
+
(await git(['cat-file', '-e', `${head}^{commit}`])) !== undefined
|
|
384
|
+
const hasRef = (await git(['rev-parse', '--verify', '-q', ref])) !== undefined
|
|
385
|
+
if (!hasHead || !hasRef) return 'unknown'
|
|
386
|
+
|
|
387
|
+
const merged = await git(['merge-tree', '--write-tree', ref, head])
|
|
388
|
+
if (merged === undefined) return 'conflict'
|
|
389
|
+
|
|
390
|
+
const forkPoint = (await git(['merge-base', ref, head]))?.trim()
|
|
391
|
+
if (forkPoint === undefined || forkPoint === '') return 'unknown'
|
|
392
|
+
|
|
393
|
+
const written = await git([
|
|
394
|
+
'diff',
|
|
395
|
+
'--name-only',
|
|
396
|
+
'--no-renames',
|
|
397
|
+
forkPoint,
|
|
398
|
+
head,
|
|
399
|
+
])
|
|
400
|
+
if (written === undefined) return 'unknown'
|
|
401
|
+
|
|
402
|
+
const paths = nonEmptyLines(written)
|
|
403
|
+
if (paths.length === 0) return 'fresh'
|
|
404
|
+
|
|
405
|
+
const moved = await git([
|
|
406
|
+
'--literal-pathspecs',
|
|
407
|
+
'diff',
|
|
408
|
+
'--name-only',
|
|
409
|
+
'--no-renames',
|
|
410
|
+
forkPoint,
|
|
411
|
+
ref,
|
|
412
|
+
'--',
|
|
413
|
+
...paths,
|
|
414
|
+
])
|
|
415
|
+
if (moved === undefined) return 'unknown'
|
|
416
|
+
|
|
417
|
+
return nonEmptyLines(moved).length > 0 ? 'stale' : 'fresh'
|
|
418
|
+
}
|
package/src/pr/paths.ts
CHANGED
|
@@ -3,12 +3,12 @@ import { findBacktickSpans } from '@/markdown/backticks'
|
|
|
3
3
|
/**
|
|
4
4
|
* The heading whose bullets state what a branch changed.
|
|
5
5
|
*
|
|
6
|
-
* Read alone, and never widened to a sibling section. `## Technical
|
|
6
|
+
* Read alone, and never widened to a sibling section. `## Technical context`
|
|
7
7
|
* legitimately names files a branch never touched, such as an install stamp
|
|
8
8
|
* inside a target, so a reader that took the whole body would manufacture a
|
|
9
9
|
* finding out of every argument the author made for the change.
|
|
10
10
|
*/
|
|
11
|
-
export const KEY_CHANGES = 'Key
|
|
11
|
+
export const KEY_CHANGES = 'Key changes'
|
|
12
12
|
|
|
13
13
|
/** What one bullet claimed, kept with its bullet so a finding can quote it. */
|
|
14
14
|
export interface PathClaim {
|
|
@@ -86,7 +86,7 @@ const BARE_LINE = /^:\d+(?:-\d+)?$/
|
|
|
86
86
|
* `canon markdown audit .claude/rules --json` carries a slash and names no file.
|
|
87
87
|
* Angle brackets answer a second, since `.canon/plans/feature-<slug>.md`
|
|
88
88
|
* describes a shape rather than naming a file. A glob and a caret describe a
|
|
89
|
-
* shape too, `^src/` being a grep pattern one body spelled in Key
|
|
89
|
+
* shape too, `^src/` being a grep pattern one body spelled in Key changes, and
|
|
90
90
|
* a leading anchor names something outside this repository.
|
|
91
91
|
*
|
|
92
92
|
* Deliberately not shared with `classifySpan` in `@/gov/citations`, which asks
|
|
@@ -307,7 +307,7 @@ function resolveSpan(span: string): ResolvedSpan | undefined {
|
|
|
307
307
|
}
|
|
308
308
|
|
|
309
309
|
/**
|
|
310
|
-
* Every path the `## Key
|
|
310
|
+
* Every path the `## Key changes` section claims a change to.
|
|
311
311
|
*
|
|
312
312
|
* `roots` names the entries the tree holds at its top level, which is what
|
|
313
313
|
* separates a whole path from one written partially. It is passed in rather
|
package/src/pr/rest.ts
CHANGED
|
@@ -17,6 +17,9 @@ export interface RestPull {
|
|
|
17
17
|
readonly head?: { readonly ref?: string; readonly sha?: string }
|
|
18
18
|
readonly base?: { readonly ref?: string }
|
|
19
19
|
readonly mergeable_state?: string
|
|
20
|
+
readonly state?: string
|
|
21
|
+
readonly draft?: boolean
|
|
22
|
+
readonly node_id?: string
|
|
20
23
|
}
|
|
21
24
|
|
|
22
25
|
/** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
|
package/src/pr/review-scope.ts
CHANGED
|
@@ -131,6 +131,82 @@ function markerOf(
|
|
|
131
131
|
return { commit, readAt }
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** The two headings `review-ui` posts a verdict under. */
|
|
135
|
+
export const UI_HEADINGS = ['## UI review', '## UI review closed'] as const
|
|
136
|
+
|
|
137
|
+
export type UiHeading = (typeof UI_HEADINGS)[number]
|
|
138
|
+
|
|
139
|
+
export interface UiScope {
|
|
140
|
+
readonly heading?: UiHeading
|
|
141
|
+
readonly state: ReviewState
|
|
142
|
+
/** The head the verdict covered, a full or abbreviated sha. */
|
|
143
|
+
readonly commit?: string
|
|
144
|
+
/** `marker` is the verdict's own record, `fallback` is GitHub's stamp. */
|
|
145
|
+
readonly source: ScopeSource
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* The marker `review-ui` writes as the last line of every verdict. It names the
|
|
150
|
+
* head the pass drove, since the submission stamp moves onto a commit pushed
|
|
151
|
+
* while the pass was driving.
|
|
152
|
+
*/
|
|
153
|
+
const UI_MARKER = /^<!--\s*review-ui:\s*head=([0-9a-f]{7,40})\s*-->$/
|
|
154
|
+
|
|
155
|
+
function lastLine(body: string): string | undefined {
|
|
156
|
+
const lines = body.split('\n')
|
|
157
|
+
|
|
158
|
+
let index = lines.length - 1
|
|
159
|
+
while (index >= 0 && (lines[index] ?? '').trim() === '') index -= 1
|
|
160
|
+
if (index < 0) return undefined
|
|
161
|
+
|
|
162
|
+
return (lines[index] ?? '').trim()
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
/**
|
|
166
|
+
* Resolves the newest UI verdict and the head it covered.
|
|
167
|
+
*
|
|
168
|
+
* Read off `reviews` like the code review family, since `review-ui` posts each
|
|
169
|
+
* verdict as a pull request review. The first line is matched for equality so
|
|
170
|
+
* `## UI review closed` is never taken for the open heading by prefix.
|
|
171
|
+
*/
|
|
172
|
+
export function resolveUiScope(listing: ReviewListing): UiScope {
|
|
173
|
+
let newest: { heading: UiHeading; row: ReviewRow } | undefined
|
|
174
|
+
|
|
175
|
+
for (const row of listing.reviews ?? []) {
|
|
176
|
+
const first = ((row.body ?? '').split('\n')[0] ?? '').replace(/\r$/, '')
|
|
177
|
+
const heading = UI_HEADINGS.find((candidate) => candidate === first)
|
|
178
|
+
if (heading !== undefined) newest = { heading, row }
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (newest === undefined) return { state: 'none', source: 'none' }
|
|
182
|
+
|
|
183
|
+
const { heading, row } = newest
|
|
184
|
+
const state: ReviewState = heading === '## UI review' ? 'open' : 'closed'
|
|
185
|
+
const line = lastLine(row.body ?? '')
|
|
186
|
+
const match = line === undefined ? null : UI_MARKER.exec(line)
|
|
187
|
+
const marked = match?.[1]
|
|
188
|
+
|
|
189
|
+
if (marked !== undefined) {
|
|
190
|
+
return { heading, state, commit: marked, source: 'marker' }
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const oid = row.commit?.oid
|
|
194
|
+
return {
|
|
195
|
+
heading,
|
|
196
|
+
state,
|
|
197
|
+
...(oid === undefined || oid === '' ? {} : { commit: oid }),
|
|
198
|
+
source: 'fallback',
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* Whether a covered commit names the head. A marker may carry a short sha, so
|
|
204
|
+
* coverage is a prefix match on the head.
|
|
205
|
+
*/
|
|
206
|
+
export function coversHead(commit: string, head: string): boolean {
|
|
207
|
+
return commit !== '' && head !== '' && head.startsWith(commit)
|
|
208
|
+
}
|
|
209
|
+
|
|
134
210
|
/**
|
|
135
211
|
* Resolves what the newest review pass covered.
|
|
136
212
|
*
|
package/src/records/backup.ts
CHANGED
|
@@ -30,7 +30,7 @@ import { type BlockedPath, guardPayload } from '@/records/push-guard'
|
|
|
30
30
|
* rather than named here. That is what keeps this list at one line per surface
|
|
31
31
|
* as archives spread, which a sibling-per-archive layout could not.
|
|
32
32
|
*
|
|
33
|
-
* `RECORD_KINDS` in `validate.ts` overlaps this on
|
|
33
|
+
* `RECORD_KINDS` in `validate.ts` overlaps this on four names and carries one
|
|
34
34
|
* more that no backup reaches. The two lists differ on purpose: one is what a
|
|
35
35
|
* standard governs, this is what a disk loss would take, and `standards` is
|
|
36
36
|
* tracked so a backup would carry a second copy of committed files.
|
|
@@ -39,7 +39,6 @@ export const BACKED_FOLDERS = [
|
|
|
39
39
|
'diagrams',
|
|
40
40
|
'groundwork',
|
|
41
41
|
'intake',
|
|
42
|
-
'memory',
|
|
43
42
|
'plans',
|
|
44
43
|
'proposals',
|
|
45
44
|
'review',
|
|
@@ -51,6 +50,15 @@ export const BACKED_FOLDERS = [
|
|
|
51
50
|
'walkthroughs',
|
|
52
51
|
] as const
|
|
53
52
|
|
|
53
|
+
/**
|
|
54
|
+
* Folders a retired kind left at a legacy `.claude` root that the allowlist
|
|
55
|
+
* must still read, so a half-finished move that strands one still trips
|
|
56
|
+
* `split-roots`. Absent from `BACKED_FOLDERS` because nothing writes them, and
|
|
57
|
+
* absent from `RETIRED_FOLDERS` because that list stages a deletion and this
|
|
58
|
+
* folder is an archive to keep.
|
|
59
|
+
*/
|
|
60
|
+
const LEGACY_RECORD_FOLDERS = ['memory'] as const
|
|
61
|
+
|
|
54
62
|
/**
|
|
55
63
|
* Names that have left `BACKED_FOLDERS` and whose removal still has to reach a
|
|
56
64
|
* records history once.
|
|
@@ -124,7 +132,9 @@ function isExcluded(name: string): boolean {
|
|
|
124
132
|
*/
|
|
125
133
|
function foldersAt(dir: string): string[] {
|
|
126
134
|
if (basename(dir) === '.claude') {
|
|
127
|
-
return BACKED_FOLDERS.filter((folder) =>
|
|
135
|
+
return [...BACKED_FOLDERS, ...LEGACY_RECORD_FOLDERS].filter((folder) =>
|
|
136
|
+
existsSync(join(dir, folder)),
|
|
137
|
+
)
|
|
128
138
|
}
|
|
129
139
|
|
|
130
140
|
const entries = existsSync(dir)
|
|
@@ -378,7 +388,7 @@ async function enclosingRemotes(
|
|
|
378
388
|
* Clears the four gates both verbs share and returns the records remote URL.
|
|
379
389
|
*
|
|
380
390
|
* The last two are the ones the payload depends on. This repository is public,
|
|
381
|
-
* so a records branch on any of its remotes serves the
|
|
391
|
+
* so a records branch on any of its remotes serves the plans and the
|
|
382
392
|
* groundwork trails to anyone who fetches all refs. Comparing the configured
|
|
383
393
|
* URL against every remote of the enclosing repository is what keeps a
|
|
384
394
|
* misconfigured `origin` from publishing them, and refusing when that list
|
|
@@ -416,7 +426,7 @@ async function resolveRemote(
|
|
|
416
426
|
if (!enclosing) {
|
|
417
427
|
return refuse(
|
|
418
428
|
'remote-unreadable',
|
|
419
|
-
`Cannot read the remotes of the project at ${root}, so the records origin cannot be checked against them. Records carry the
|
|
429
|
+
`Cannot read the remotes of the project at ${root}, so the records origin cannot be checked against them. Records carry the plans and the groundwork trails, and an unchecked origin risks publishing them.`,
|
|
420
430
|
)
|
|
421
431
|
}
|
|
422
432
|
|
|
@@ -424,7 +434,7 @@ async function resolveRemote(
|
|
|
424
434
|
if (enclosing.identities.includes(url)) {
|
|
425
435
|
return refuse(
|
|
426
436
|
'remote-shared',
|
|
427
|
-
`The records origin ${remote.text} is a remote of this project. Records carry the
|
|
437
|
+
`The records origin ${remote.text} is a remote of this project. Records carry the plans and the groundwork trails, so they need a repository of their own.`,
|
|
428
438
|
)
|
|
429
439
|
}
|
|
430
440
|
|
|
@@ -3,7 +3,6 @@ import { existsSync } from 'node:fs'
|
|
|
3
3
|
import { readFile } from 'node:fs/promises'
|
|
4
4
|
import { relative } from 'node:path'
|
|
5
5
|
import { gitEnv } from '@/git/env'
|
|
6
|
-
import { readReviewed } from '@/records/stale'
|
|
7
6
|
import { surfaceDir } from '@/roots/surface'
|
|
8
7
|
|
|
9
8
|
/** The record whose drift is identity rather than a stale path. */
|
|
@@ -22,6 +21,37 @@ export interface CanonicalDoc {
|
|
|
22
21
|
readonly latestRelease: string | null
|
|
23
22
|
}
|
|
24
23
|
|
|
24
|
+
const REVIEWED_FIELD = /^reviewed:[ \t]*(.*?)[ \t]*$/m
|
|
25
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
26
|
+
const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Reads `reviewed` off the raw block rather than the parsed fields, for the
|
|
30
|
+
* reason `hasOpeningDate` in the validator gives: a YAML parser resolves a
|
|
31
|
+
* bare `YYYY-MM-DD` to a date on one schema and a string on another.
|
|
32
|
+
*
|
|
33
|
+
* A value that is not a calendar date reads as never reviewed and is carried
|
|
34
|
+
* back so the review can see it, rather than throwing over one hand edit.
|
|
35
|
+
*/
|
|
36
|
+
function readReviewed(
|
|
37
|
+
text: string,
|
|
38
|
+
): Pick<CanonicalDoc, 'reviewed' | 'invalidReviewed'> {
|
|
39
|
+
const block = RAW_FRONTMATTER.exec(text)?.[1]
|
|
40
|
+
const match = block ? REVIEWED_FIELD.exec(block) : null
|
|
41
|
+
if (!match) return { reviewed: null }
|
|
42
|
+
|
|
43
|
+
const value = match[1].replace(/^(['"])(.*)\1$/, '$2')
|
|
44
|
+
const parsed = Date.parse(`${value}T00:00:00Z`)
|
|
45
|
+
const isDate =
|
|
46
|
+
ISO_DATE.test(value) &&
|
|
47
|
+
Number.isFinite(parsed) &&
|
|
48
|
+
new Date(parsed).toISOString().slice(0, 10) === value
|
|
49
|
+
|
|
50
|
+
return isDate
|
|
51
|
+
? { reviewed: value }
|
|
52
|
+
: { reviewed: null, invalidReviewed: value }
|
|
53
|
+
}
|
|
54
|
+
|
|
25
55
|
export interface CanonicalReport {
|
|
26
56
|
readonly ok: true
|
|
27
57
|
readonly root: string
|