@erclx/canon 5.24.0 → 5.26.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/README.md +2 -1
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/backlog-triage/REQUIREMENT.md +2 -2
- package/claude/skills/backlog-triage/SKILL.md +19 -6
- package/claude/skills/canvas/REQUIREMENT.md +49 -0
- package/claude/skills/canvas/SKILL.md +106 -0
- package/claude/skills/context-fold/REQUIREMENT.md +6 -2
- package/claude/skills/context-fold/SKILL.md +8 -6
- package/claude/skills/context-fold/references/anchor-sweep.md +2 -2
- package/claude/skills/design-extract/REQUIREMENT.md +1 -1
- package/claude/skills/design-taste/REQUIREMENT.md +1 -1
- package/claude/skills/design-taste/SKILL.md +4 -2
- package/claude/skills/design-taste/references/adopted.md +40 -0
- package/claude/skills/design-taste/references/kinds.md +1 -1
- package/claude/skills/design-taste/references/systems.md +1 -7
- package/claude/skills/design-taste/references/tells.md +2 -24
- package/claude/skills/docs-sync/REQUIREMENT.md +2 -1
- package/claude/skills/document-health/REQUIREMENT.md +2 -0
- package/claude/skills/document-health/SKILL.md +21 -0
- package/claude/skills/document-health/references/axes.md +12 -0
- package/claude/skills/draft-and-pick/SKILL.md +1 -1
- package/claude/skills/draft-doc/REQUIREMENT.md +1 -1
- package/claude/skills/draft-figure/REQUIREMENT.md +14 -3
- package/claude/skills/draft-figure/SKILL.md +24 -51
- package/claude/skills/draft-figure/references/architecture-set.md +35 -0
- package/claude/skills/draft-figure/references/hand-drawn.md +31 -0
- package/claude/skills/draft-figure/references/mermaid.md +55 -0
- package/claude/skills/draft-identity/SKILL.md +1 -1
- package/claude/skills/draft-screencast/SKILL.md +9 -7
- package/claude/skills/draft-slides/REQUIREMENT.md +17 -12
- package/claude/skills/draft-slides/SKILL.md +31 -20
- package/claude/skills/git-followup/SKILL.md +17 -6
- package/claude/skills/git-issue/references/issue.md +10 -0
- package/claude/skills/git-pr/references/evidence.md +12 -2
- package/claude/skills/git-pr/references/labels.md +12 -0
- package/claude/skills/git-split/SKILL.md +19 -8
- package/claude/skills/index-lookup/SKILL.md +2 -2
- package/claude/skills/markdown-propose/references/format.md +9 -0
- package/claude/skills/memory-review/references/later-phases.md +7 -0
- package/claude/skills/review-address/SKILL.md +6 -13
- package/claude/skills/review-address/references/rest-reads.md +43 -0
- package/claude/skills/review-craft/references/ui.md +7 -1
- package/claude/skills/review-pr/REQUIREMENT.md +1 -0
- package/claude/skills/review-pr/SKILL.md +9 -3
- package/claude/skills/review-pr/references/close-out.md +2 -2
- package/claude/skills/review-pr/references/post.md +17 -5
- package/claude/skills/review-pr/references/scope.md +10 -8
- package/claude/skills/review-ui/SKILL.md +12 -4
- package/claude/skills/review-ui/references/post.md +4 -2
- package/claude/skills/role-orchestrator/references/orchestrator-cloud-launch.md +38 -1
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +13 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +4 -0
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +6 -0
- package/claude/skills/role-orchestrator/scripts/poll.ts +90 -27
- package/claude/skills/role-orchestrator/scripts/repo.ts +3 -5
- package/claude/skills/role-orchestrator/scripts/watch.ts +4 -7
- package/claude/skills/search-craft/REQUIREMENT.md +1 -1
- package/claude/skills/search-craft/SKILL.md +3 -1
- package/claude/skills/search-craft/references/adopted.md +30 -0
- package/claude/skills/target-setup/references/detect.md +8 -0
- package/claude/skills/target-setup/references/indexes.md +13 -0
- package/claude/skills/target-setup/references/verify.md +8 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/test-first/references/adopted.md +36 -0
- package/claude/skills/write-human/SKILL.md +1 -1
- package/claude/skills/write-human/references/machine-tells.md +13 -0
- package/docs/agents/canvas.md +4 -2
- package/docs/agents/capture.md +1 -1
- package/docs/agents/commands.md +47 -47
- package/docs/agents/context-audit-checks.md +2 -2
- package/docs/agents/index.md +2 -3
- package/docs/agents/key-changes.md +3 -3
- package/docs/agents/pr-evidence.md +11 -2
- package/docs/agents/pr-reads.md +5 -4
- package/docs/agents/records-stale.md +22 -9
- package/docs/agents/records.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/skills-audit.md +10 -3
- package/docs/agents/slides.md +15 -3
- package/docs/target/projects.md +4 -4
- package/docs/workflow/ai-workflow.md +1 -2
- package/docs/workflow/index.md +1 -0
- package/docs/workflow/parallel-features.md +1 -1
- package/docs/workflow/skill-map-on-request.md +42 -0
- package/docs/workflow/skill-map.md +3 -36
- package/docs/workflow/visual-design-workflow.md +1 -0
- package/governance/renames.toml +0 -4
- package/governance/rules/standards/markdown.md +1 -0
- package/package.json +1 -2
- package/scripts/core/install-check.sh +1 -1
- package/scripts/lib/sandbox-dispatch.sh +1 -1
- package/scripts/lib/sandbox-git.sh +1 -9
- package/scripts/lib/sandbox-path.sh +1 -1
- package/scripts/sandbox-hook.sh +99 -0
- package/src/audits/catalog.ts +2 -2
- package/src/canvas/capture.ts +1 -9
- package/src/canvas/client/inspector/color-field.tsx +368 -0
- package/src/canvas/client/inspector/field.tsx +200 -0
- package/src/canvas/client/inspector/fields.css +292 -0
- package/src/canvas/client/inspector/section.tsx +27 -0
- package/src/canvas/client/inspector/values.ts +183 -0
- package/src/canvas/client/inspector.tsx +176 -160
- package/src/canvas/client/shell.css +0 -101
- package/src/capture/render.ts +45 -7
- package/src/capture/sources.ts +43 -0
- package/src/census/count.ts +1 -1
- package/src/claude/cases/misc.ts +5 -0
- package/src/claude/cases/workflow.ts +6 -1
- package/src/claude/seeds.ts +1 -2
- package/src/claude/skills-audit.ts +68 -14
- package/src/client-commands.ts +1 -1
- package/src/commands/claude.ts +27 -2
- package/src/commands/design.ts +2 -64
- package/src/commands/feedback.ts +3 -3
- package/src/commands/pr.ts +219 -202
- package/src/commands/records.ts +88 -6
- package/src/commands/sandbox.ts +50 -10
- package/src/commands/slides.ts +31 -77
- package/src/context/audit.ts +8 -7
- package/src/design/tokens.ts +2 -18
- package/src/docs/read.ts +1 -1
- package/src/{github.ts → feedback/github.ts} +1 -1
- package/src/gate/measures.ts +6 -4
- package/src/gov/citations.ts +1 -1
- package/src/gov/payload.ts +1 -1
- package/src/gov/superseded.ts +2 -2
- package/src/help.ts +2 -2
- package/src/markdown/ceiling.ts +1 -1
- package/src/migrate/skill-names.ts +7 -1
- package/src/pr/evidence.ts +25 -5
- package/src/pr/rest.ts +137 -0
- package/src/record-root.ts +1 -0
- package/src/records/backup.ts +1 -0
- package/src/records/push-guard.ts +1 -1
- package/src/records/stale-canonical.ts +146 -0
- package/src/records/stale.ts +1 -1
- package/src/sandbox/hooks.ts +128 -0
- package/src/sandbox/provision.ts +596 -0
- package/src/sandbox/tree.ts +103 -4
- package/src/secrets/marker.ts +2 -2
- package/src/secrets/scan.ts +1 -1
- package/src/shipped/references.ts +2 -2
- package/src/slides/convert/export.ts +2 -16
- package/src/slides/decks.ts +110 -0
- package/src/standards/read.ts +1 -1
- package/src/sync/seeds-report.ts +1 -1
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/teach/nav.ts +1 -1
- package/src/tooling/inject.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/standards/architecture.md +1 -0
- package/standards/canvas.md +112 -0
- package/standards/context.md +2 -1
- package/standards/figures.md +0 -1
- package/standards/index.md +1 -1
- package/standards/markdown.md +1 -0
- package/standards/mermaid.md +1 -7
- package/standards/requirements.md +2 -0
- package/standards/skill-practice.md +2 -1
- package/standards/skill.md +1 -0
- package/tooling/claude/reference.md +0 -5
- package/claude/skills/draft-diagram/REQUIREMENT.md +0 -45
- package/claude/skills/draft-diagram/SKILL.md +0 -177
- package/docs/agents/design-board.md +0 -33
- package/governance/rules/standards/diagrams.md +0 -14
- package/governance/rules/standards/mermaid.md +0 -15
- package/scripts/core/check-gallery-exclusion.sh +0 -50
- package/scripts/manage-sandbox.sh +0 -472
- package/src/design/board.ts +0 -576
- package/src/slides/layouts.ts +0 -478
- package/src/slides/parse.ts +0 -84
- package/src/slides/render.ts +0 -110
- package/src/slides/styles.ts +0 -96
- package/standards/diagrams.md +0 -132
- package/tooling/claude/seeds/.claude/diagrams/index.md +0 -8
- /package/claude/skills/write-human/references/{source-material.md → adopted.md} +0 -0
- /package/src/{commands/feedback-format.ts → feedback/body.ts} +0 -0
- /package/src/{github-format.ts → feedback/github-format.ts} +0 -0
- /package/src/{frontmatter.ts → markdown/frontmatter.ts} +0 -0
- /package/src/{binary.ts → sweep/binary.ts} +0 -0
- /package/src/{exempt-marker.ts → sweep/exempt-marker.ts} +0 -0
- /package/src/{paths.ts → tasks/paths.ts} +0 -0
- /package/src/{seed-marker.ts → tooling/seed-marker.ts} +0 -0
package/src/pr/evidence.ts
CHANGED
|
@@ -1,6 +1,12 @@
|
|
|
1
1
|
/** The path segment that marks an image as evidence a pull request should compare. */
|
|
2
2
|
const EVIDENCE_SEGMENT = 'evidence'
|
|
3
3
|
|
|
4
|
+
/** The most images a comment carries and still opens its states, since past it the page is a wall of screenshots. */
|
|
5
|
+
const EVIDENCE_OPEN_LIMIT = 6
|
|
6
|
+
|
|
7
|
+
/** Length of the commit names the visible line shows, the width `git` abbreviates to. */
|
|
8
|
+
const SHORT_SHA_LENGTH = 7
|
|
9
|
+
|
|
4
10
|
/** Prefix of the trailing marker this module writes and reads back. */
|
|
5
11
|
const MARKER_PREFIX = '<!-- pr-evidence:'
|
|
6
12
|
|
|
@@ -120,10 +126,14 @@ export function evidenceMarker(head: string): string {
|
|
|
120
126
|
}
|
|
121
127
|
|
|
122
128
|
/**
|
|
123
|
-
* Renders the whole comment body: one
|
|
124
|
-
*
|
|
125
|
-
*
|
|
126
|
-
*
|
|
129
|
+
* Renders the whole comment body: one `<details>` block per state, a Base/Head
|
|
130
|
+
* row per case, and the trailing marker naming the head this body describes.
|
|
131
|
+
* Every state opens while the comment carries at most `EVIDENCE_OPEN_LIMIT`
|
|
132
|
+
* images, one per row for the head plus one per row not added, and every state
|
|
133
|
+
* closes past it, so a comment is never half open. A visible line under
|
|
134
|
+
* `## Evidence` names the base and head as short shas. Every image URL is
|
|
135
|
+
* pinned to a commit sha rather than a branch, so the comment keeps showing
|
|
136
|
+
* what it claimed even after the branch moves.
|
|
127
137
|
*
|
|
128
138
|
* A preview address opens the body, and with no states it is the whole body
|
|
129
139
|
* apart from the marker, so a pull request whose screenshots did not change
|
|
@@ -157,6 +167,14 @@ export function renderEvidenceBody(
|
|
|
157
167
|
return [...opening, ...closing, evidenceMarker(head)].join('\n')
|
|
158
168
|
}
|
|
159
169
|
|
|
170
|
+
const imageCount = states.reduce(
|
|
171
|
+
(total, entry) =>
|
|
172
|
+
total + entry.items.reduce((sum, item) => sum + (item.added ? 1 : 2), 0),
|
|
173
|
+
0,
|
|
174
|
+
)
|
|
175
|
+
const opener =
|
|
176
|
+
imageCount <= EVIDENCE_OPEN_LIMIT ? '<details open>' : '<details>'
|
|
177
|
+
|
|
160
178
|
const sections = states.map((entry) => {
|
|
161
179
|
const rows = entry.items.map((item) => {
|
|
162
180
|
const before = item.added
|
|
@@ -167,7 +185,7 @@ export function renderEvidenceBody(
|
|
|
167
185
|
})
|
|
168
186
|
|
|
169
187
|
return [
|
|
170
|
-
|
|
188
|
+
opener,
|
|
171
189
|
`<summary>${entry.state === '' ? 'evidence' : entry.state} (${entry.items.length})</summary>`,
|
|
172
190
|
'',
|
|
173
191
|
'| Case | Base | Head |',
|
|
@@ -182,6 +200,8 @@ export function renderEvidenceBody(
|
|
|
182
200
|
...opening,
|
|
183
201
|
'## Evidence',
|
|
184
202
|
'',
|
|
203
|
+
`**Base:** \`${base.slice(0, SHORT_SHA_LENGTH)}\` · **Head:** \`${head.slice(0, SHORT_SHA_LENGTH)}\``,
|
|
204
|
+
'',
|
|
185
205
|
...sections,
|
|
186
206
|
'',
|
|
187
207
|
...closing,
|
package/src/pr/rest.ts
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The REST shapes `canon pr` reads a pull request through, put into the shapes
|
|
3
|
+
* its readers already take.
|
|
4
|
+
*
|
|
5
|
+
* A cloud session's GitHub proxy refuses GraphQL and serves REST, and every
|
|
6
|
+
* `gh pr view` runs on GraphQL. The verbs therefore read the pulls, reviews,
|
|
7
|
+
* and issue-comments endpoints, and this module is the one boundary where
|
|
8
|
+
* those payloads meet the field names the rest of `src/pr/` was written
|
|
9
|
+
* against.
|
|
10
|
+
*/
|
|
11
|
+
import type { ReviewRow } from '@/pr/review-scope'
|
|
12
|
+
|
|
13
|
+
/** One row of `repos/{owner}/{repo}/pulls/<n>` or of the pulls listing. */
|
|
14
|
+
export interface RestPull {
|
|
15
|
+
readonly number?: number
|
|
16
|
+
readonly body?: string | null
|
|
17
|
+
readonly head?: { readonly ref?: string; readonly sha?: string }
|
|
18
|
+
readonly base?: { readonly ref?: string }
|
|
19
|
+
readonly mergeable_state?: string
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** One row of `repos/{owner}/{repo}/pulls/<n>/reviews`. */
|
|
23
|
+
export interface RestReview {
|
|
24
|
+
readonly body?: string | null
|
|
25
|
+
readonly commit_id?: string | null
|
|
26
|
+
readonly submitted_at?: string | null
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** One row of `repos/{owner}/{repo}/issues/<n>/comments`. */
|
|
30
|
+
export interface RestComment {
|
|
31
|
+
readonly html_url?: string
|
|
32
|
+
readonly body?: string | null
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/** The head branch, reported head, and base of the pull request a caller named. */
|
|
36
|
+
export interface PullIdentity {
|
|
37
|
+
readonly number: number | undefined
|
|
38
|
+
readonly branch: string
|
|
39
|
+
readonly head?: string
|
|
40
|
+
readonly base?: string
|
|
41
|
+
readonly mergeState?: string
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export type BranchPull =
|
|
45
|
+
| { readonly kind: 'found'; readonly number: number }
|
|
46
|
+
| {
|
|
47
|
+
readonly kind: 'refused'
|
|
48
|
+
readonly reason: 'gh-failed' | 'ambiguous-pull'
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Spells `mergeable_state` the way GraphQL's `mergeStateStatus` spells it.
|
|
53
|
+
*
|
|
54
|
+
* The two vocabularies hold the same values in different cases, so the
|
|
55
|
+
* mapping is a case change. `unknown` stays `UNKNOWN`, which `withMergeState`
|
|
56
|
+
* already reads as not conflicted while GitHub computes the state lazily.
|
|
57
|
+
*/
|
|
58
|
+
export function mergeStateOf(state: string | undefined): string | undefined {
|
|
59
|
+
return state === undefined || state === '' ? undefined : state.toUpperCase()
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function identityOf(row: RestPull): PullIdentity {
|
|
63
|
+
const mergeState = mergeStateOf(row.mergeable_state)
|
|
64
|
+
return {
|
|
65
|
+
number: row.number,
|
|
66
|
+
branch: row.head?.ref ?? '',
|
|
67
|
+
...(row.head?.sha !== undefined && { head: row.head.sha }),
|
|
68
|
+
...(row.base?.ref !== undefined && { base: row.base.ref }),
|
|
69
|
+
...(mergeState !== undefined && { mergeState }),
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* The one open pull request a branch carries.
|
|
75
|
+
*
|
|
76
|
+
* Two open pull requests on one head against different bases refuse rather
|
|
77
|
+
* than resolving to the first, since the listing's order says nothing about
|
|
78
|
+
* which one the caller meant.
|
|
79
|
+
*/
|
|
80
|
+
export function selectBranchPull(rows: readonly RestPull[]): BranchPull {
|
|
81
|
+
const numbers = rows
|
|
82
|
+
.map((row) => row.number)
|
|
83
|
+
.filter((number): number is number => number !== undefined)
|
|
84
|
+
if (numbers.length > 1) return { kind: 'refused', reason: 'ambiguous-pull' }
|
|
85
|
+
const [number] = numbers
|
|
86
|
+
return number === undefined
|
|
87
|
+
? { kind: 'refused', reason: 'gh-failed' }
|
|
88
|
+
: { kind: 'found', number }
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* A pending review carries no `submitted_at`, which reads as null the way the
|
|
93
|
+
* GraphQL listing reported it.
|
|
94
|
+
*/
|
|
95
|
+
export function reviewRowOf(review: RestReview): ReviewRow {
|
|
96
|
+
return {
|
|
97
|
+
body: review.body ?? '',
|
|
98
|
+
commit:
|
|
99
|
+
review.commit_id === undefined || review.commit_id === null
|
|
100
|
+
? null
|
|
101
|
+
: { oid: review.commit_id },
|
|
102
|
+
submittedAt: review.submitted_at ?? null,
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** `html_url` ends in the same `#issuecomment-<id>` anchor GraphQL's `url` did. */
|
|
107
|
+
export function commentRowOf(comment: RestComment): {
|
|
108
|
+
readonly url?: string
|
|
109
|
+
readonly body: string
|
|
110
|
+
} {
|
|
111
|
+
return {
|
|
112
|
+
...(comment.html_url !== undefined && { url: comment.html_url }),
|
|
113
|
+
body: comment.body ?? '',
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Parses the output of `gh api --paginate --jq '.[] | @json'`, one compact
|
|
119
|
+
* object per line across every page.
|
|
120
|
+
*
|
|
121
|
+
* A filter that reshapes each row makes `--paginate` emit one value per page
|
|
122
|
+
* rather than one merged array, so a line per row is the form that survives
|
|
123
|
+
* paging. A line that does not parse refuses the whole listing, since a list
|
|
124
|
+
* short by one review reads as a thread missing its newest pass.
|
|
125
|
+
*/
|
|
126
|
+
export function parseJsonLines<T>(stdout: string): T[] | undefined {
|
|
127
|
+
const rows: T[] = []
|
|
128
|
+
for (const line of stdout.split('\n')) {
|
|
129
|
+
if (line.trim() === '') continue
|
|
130
|
+
try {
|
|
131
|
+
rows.push(JSON.parse(line) as T)
|
|
132
|
+
} catch {
|
|
133
|
+
return undefined
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
return rows
|
|
137
|
+
}
|
package/src/record-root.ts
CHANGED
package/src/records/backup.ts
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { $ } from 'bun'
|
|
2
|
+
import { existsSync } from 'node:fs'
|
|
3
|
+
import { readFile } from 'node:fs/promises'
|
|
4
|
+
import { relative } from 'node:path'
|
|
5
|
+
import { gitEnv } from '@/git-env'
|
|
6
|
+
import { readReviewed } from '@/records/stale'
|
|
7
|
+
import { surfaceDir } from '@/surface-root'
|
|
8
|
+
|
|
9
|
+
/** The two records whose drift is identity rather than a stale path. */
|
|
10
|
+
const CANONICAL_DOCS = ['REQUIREMENTS.md', 'ARCHITECTURE.md'] as const
|
|
11
|
+
|
|
12
|
+
export interface CanonicalDoc {
|
|
13
|
+
/** Relative to the root, at whichever surface root carries the doc. */
|
|
14
|
+
readonly path: string
|
|
15
|
+
/** `YYYY-MM-DD`, or null when the doc carries no readable review date. */
|
|
16
|
+
readonly reviewed: string | null
|
|
17
|
+
/** The raw value of a `reviewed` field that is not a date, present only then. */
|
|
18
|
+
readonly invalidReviewed?: string
|
|
19
|
+
/** Release tags merged into HEAD after the reviewed day, or null when never reviewed. */
|
|
20
|
+
readonly releasesSince: number | null
|
|
21
|
+
/** The newest release tag merged into HEAD, or null when there is none. */
|
|
22
|
+
readonly latestRelease: string | null
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export interface CanonicalReport {
|
|
26
|
+
readonly ok: true
|
|
27
|
+
readonly root: string
|
|
28
|
+
/** False when HEAD has merged no tag, so a zero count measured nothing. */
|
|
29
|
+
readonly tagged: boolean
|
|
30
|
+
readonly docs: readonly CanonicalDoc[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface CanonicalRefused {
|
|
34
|
+
readonly ok: false
|
|
35
|
+
readonly reason: 'unreadable-tags'
|
|
36
|
+
readonly message: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export type CanonicalOutcome = CanonicalReport | CanonicalRefused
|
|
40
|
+
|
|
41
|
+
interface Release {
|
|
42
|
+
readonly tag: string
|
|
43
|
+
readonly day: string
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* A tag ending in a dotted version, bare or behind a component prefix such as
|
|
48
|
+
* `canon-v2.1.0`. An eval, deploy, or snapshot tag on the trunk names no
|
|
49
|
+
* release and would otherwise inflate the count.
|
|
50
|
+
*/
|
|
51
|
+
const RELEASE_TAG = /(?:^|[-/@_])v?\d+\.\d+(?:\.\d+)?(?:[-+][0-9A-Za-z.-]+)?$/
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* A repository before its first commit has no HEAD for `--merged` to resolve,
|
|
55
|
+
* which git reports as a failed read. It has no tags either, so it reads as an
|
|
56
|
+
* empty history rather than an unreadable one.
|
|
57
|
+
*/
|
|
58
|
+
async function isUnborn(root: string): Promise<boolean> {
|
|
59
|
+
const [gitDir, head] = await Promise.all([
|
|
60
|
+
$`git -C ${root} rev-parse --git-dir`.env(gitEnv()).quiet().nothrow(),
|
|
61
|
+
$`git -C ${root} rev-parse --verify -q HEAD`
|
|
62
|
+
.env(gitEnv())
|
|
63
|
+
.quiet()
|
|
64
|
+
.nothrow(),
|
|
65
|
+
])
|
|
66
|
+
return gitDir.exitCode === 0 && head.exitCode !== 0
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Every release tag HEAD has merged, newest first, with the day it was created.
|
|
71
|
+
*
|
|
72
|
+
* The creator date is the tagging date on an annotated tag and the commit date
|
|
73
|
+
* on a lightweight one, so a history mixing both counts by two clocks.
|
|
74
|
+
*/
|
|
75
|
+
async function mergedReleases(root: string): Promise<Release[] | undefined> {
|
|
76
|
+
// Interpolated rather than inline, since Bun's shell parses the bare
|
|
77
|
+
// parentheses in `%(refname)` as syntax of its own.
|
|
78
|
+
const args = [
|
|
79
|
+
'--merged',
|
|
80
|
+
'HEAD',
|
|
81
|
+
'--sort=-creatordate',
|
|
82
|
+
'--format=%(creatordate:short) %(refname:short)',
|
|
83
|
+
'refs/tags',
|
|
84
|
+
]
|
|
85
|
+
const result = await $`git -C ${root} for-each-ref ${args}`
|
|
86
|
+
.env(gitEnv())
|
|
87
|
+
.quiet()
|
|
88
|
+
.nothrow()
|
|
89
|
+
if (result.exitCode !== 0) return (await isUnborn(root)) ? [] : undefined
|
|
90
|
+
|
|
91
|
+
return result.stdout
|
|
92
|
+
.toString()
|
|
93
|
+
.split('\n')
|
|
94
|
+
.filter((line) => line.trim().length > 0)
|
|
95
|
+
.map((line) => {
|
|
96
|
+
const [day, tag] = line.trim().split(' ')
|
|
97
|
+
return { day, tag }
|
|
98
|
+
})
|
|
99
|
+
.filter((release) => RELEASE_TAG.test(release.tag))
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Reports when each canonical doc was last reviewed and how many releases
|
|
104
|
+
* have shipped since, read from an optional `reviewed:` frontmatter field.
|
|
105
|
+
*
|
|
106
|
+
* The review point is a date rather than a commit, since a commit written on a
|
|
107
|
+
* feature branch never reaches a trunk that squash-merges. A release tagged on
|
|
108
|
+
* the reviewed day itself is not counted, as the review is read as covering
|
|
109
|
+
* that day. It reports and never writes.
|
|
110
|
+
*/
|
|
111
|
+
export async function staleCanonical(root: string): Promise<CanonicalOutcome> {
|
|
112
|
+
const releases = await mergedReleases(root)
|
|
113
|
+
|
|
114
|
+
if (releases === undefined) {
|
|
115
|
+
return {
|
|
116
|
+
ok: false,
|
|
117
|
+
reason: 'unreadable-tags',
|
|
118
|
+
message: `Could not read the release tags at ${root}, so no count can be taken.`,
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
const latestRelease = releases[0]?.tag ?? null
|
|
123
|
+
const present = CANONICAL_DOCS.map((entry) => surfaceDir(root, entry)).filter(
|
|
124
|
+
(path) => existsSync(path),
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
const docs = await Promise.all(
|
|
128
|
+
present.map(async (path): Promise<CanonicalDoc> => {
|
|
129
|
+
const text = (await readFile(path, 'utf8')).replaceAll('\r\n', '\n')
|
|
130
|
+
const review = readReviewed(text)
|
|
131
|
+
const { reviewed } = review
|
|
132
|
+
|
|
133
|
+
return {
|
|
134
|
+
path: relative(root, path),
|
|
135
|
+
...review,
|
|
136
|
+
releasesSince:
|
|
137
|
+
reviewed === null
|
|
138
|
+
? null
|
|
139
|
+
: releases.filter((release) => release.day > reviewed).length,
|
|
140
|
+
latestRelease,
|
|
141
|
+
}
|
|
142
|
+
}),
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
return { ok: true, root, tagged: releases.length > 0, docs }
|
|
146
|
+
}
|
package/src/records/stale.ts
CHANGED
|
@@ -65,7 +65,7 @@ const RAW_FRONTMATTER = /^---\n([\s\S]*?)\n---/
|
|
|
65
65
|
* A value that is not a calendar date reads as never reviewed and is carried
|
|
66
66
|
* back so the review can see it, rather than throwing over one hand edit.
|
|
67
67
|
*/
|
|
68
|
-
function readReviewed(
|
|
68
|
+
export function readReviewed(
|
|
69
69
|
text: string,
|
|
70
70
|
): Pick<StaleEntry, 'reviewed' | 'invalidReviewed'> {
|
|
71
71
|
const block = RAW_FRONTMATTER.exec(text)?.[1]
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { spawnSync } from 'node:child_process'
|
|
2
|
+
import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs'
|
|
3
|
+
import { tmpdir } from 'node:os'
|
|
4
|
+
import { join } from 'node:path'
|
|
5
|
+
import { PROJECT_ROOT } from '@/project-root'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The bash entry a scenario's hooks run through. Scenarios and the library they
|
|
9
|
+
* call stay bash, so this contract is the whole of what the TypeScript harness
|
|
10
|
+
* knows about one: which hooks it declares and what `use_config` exports.
|
|
11
|
+
*/
|
|
12
|
+
const HOOK_SCRIPT = join(PROJECT_ROOT, 'scripts', 'sandbox-hook.sh')
|
|
13
|
+
|
|
14
|
+
const ANCHOR_RECORD = '@anchor='
|
|
15
|
+
|
|
16
|
+
export interface Probe {
|
|
17
|
+
readonly exports: Readonly<Record<string, string>>
|
|
18
|
+
readonly isAnchor: boolean
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export type ProbeResult =
|
|
22
|
+
| ({ readonly ok: true } & Probe)
|
|
23
|
+
| { readonly ok: false; readonly status: number }
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Reads the NUL-separated `NAME=value` pairs the probe prints. NUL rather than a
|
|
27
|
+
* newline, since an exported value can hold one. The anchor record closes every
|
|
28
|
+
* well-formed probe, so output missing it is a probe that did not finish.
|
|
29
|
+
*/
|
|
30
|
+
export function parseProbe(stdout: string): Probe {
|
|
31
|
+
const { exports, anchor } = parsePairs(stdout)
|
|
32
|
+
if (anchor === undefined)
|
|
33
|
+
throw new Error('the probe printed no anchor record')
|
|
34
|
+
|
|
35
|
+
return { exports, isAnchor: anchor === '1' }
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function parsePairs(text: string): {
|
|
39
|
+
exports: Record<string, string>
|
|
40
|
+
anchor: string | undefined
|
|
41
|
+
} {
|
|
42
|
+
const exports: Record<string, string> = {}
|
|
43
|
+
let anchor: string | undefined
|
|
44
|
+
|
|
45
|
+
for (const record of text.split('\0')) {
|
|
46
|
+
if (record.startsWith(ANCHOR_RECORD)) {
|
|
47
|
+
anchor = record.slice(ANCHOR_RECORD.length)
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
const split = record.indexOf('=')
|
|
51
|
+
if (split > 0) exports[record.slice(0, split)] = record.slice(split + 1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
return { exports, anchor }
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Runs `use_config` and `use_anchor` in a child and returns what they exported.
|
|
59
|
+
* Stderr is inherited by default, so a hook that fails narrates into the same
|
|
60
|
+
* frame the harness writes. A non-zero status passes through rather than being
|
|
61
|
+
* flattened, since the harness exits with it.
|
|
62
|
+
*/
|
|
63
|
+
export function probeScenario(
|
|
64
|
+
file: string,
|
|
65
|
+
env: NodeJS.ProcessEnv,
|
|
66
|
+
stderr: 'inherit' | 'pipe' = 'inherit',
|
|
67
|
+
): ProbeResult {
|
|
68
|
+
const result = spawnSync('bash', [HOOK_SCRIPT, 'probe', file], {
|
|
69
|
+
env,
|
|
70
|
+
encoding: 'utf8',
|
|
71
|
+
stdio: ['ignore', 'pipe', stderr],
|
|
72
|
+
})
|
|
73
|
+
const status = result.status ?? 1
|
|
74
|
+
if (status !== 0) return { ok: false, status }
|
|
75
|
+
|
|
76
|
+
return { ok: true, ...parseProbe(result.stdout) }
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* How `stage_setup` ended. The retired dispatcher ran it in its own shell, so
|
|
81
|
+
* an `exit` there ended provisioning through the frame-closing trap, and an
|
|
82
|
+
* `exec` replaced the harness outright, leaving its program's frame as the last
|
|
83
|
+
* one written. The harness reproduces both from this.
|
|
84
|
+
*/
|
|
85
|
+
export type StageEnding = 'returned' | 'exited' | 'replaced'
|
|
86
|
+
|
|
87
|
+
export interface StageResult {
|
|
88
|
+
readonly status: number
|
|
89
|
+
readonly ending: StageEnding
|
|
90
|
+
readonly exports: Readonly<Record<string, string>>
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const EXITED_RECORD = '@exited'
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Runs `stage_setup` inside the tree with every stream inherited, so a scenario
|
|
97
|
+
* prompting through `select_or_route_scenario` still reaches a terminal and its
|
|
98
|
+
* narration lands in order with the harness's own.
|
|
99
|
+
*
|
|
100
|
+
* The exports `stage_setup` changed come back through a file rather than a
|
|
101
|
+
* pipe. Stdout belongs to the scenario, and a background process a scenario
|
|
102
|
+
* starts would hold an extra pipe open past the child's exit.
|
|
103
|
+
*/
|
|
104
|
+
export function stageScenario(
|
|
105
|
+
file: string,
|
|
106
|
+
env: NodeJS.ProcessEnv,
|
|
107
|
+
stdio: 'inherit' | 'ignore' = 'inherit',
|
|
108
|
+
): StageResult {
|
|
109
|
+
const scratch = mkdtempSync(join(tmpdir(), 'canon-sandbox-hook-'))
|
|
110
|
+
const report = join(scratch, 'exports')
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
const result = spawnSync('bash', [HOOK_SCRIPT, 'stage', file, report], {
|
|
114
|
+
env,
|
|
115
|
+
stdio,
|
|
116
|
+
})
|
|
117
|
+
const status = result.status ?? 1
|
|
118
|
+
if (!existsSync(report)) return { status, ending: 'replaced', exports: {} }
|
|
119
|
+
|
|
120
|
+
const text = readFileSync(report, 'utf8')
|
|
121
|
+
if (text.split('\0').includes(EXITED_RECORD))
|
|
122
|
+
return { status, ending: 'exited', exports: {} }
|
|
123
|
+
|
|
124
|
+
return { status, ending: 'returned', exports: parsePairs(text).exports }
|
|
125
|
+
} finally {
|
|
126
|
+
rmSync(scratch, { recursive: true, force: true })
|
|
127
|
+
}
|
|
128
|
+
}
|