@erclx/canon 5.12.0 → 5.13.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 -1
- package/claude/skills/api-design/SKILL.md +1 -1
- package/claude/skills/api-design/references/adopted.md +1 -1
- package/claude/skills/auto-ship/REQUIREMENT.md +1 -0
- package/claude/skills/auto-ship/SKILL.md +2 -2
- package/claude/skills/build-in-slices/REQUIREMENT.md +45 -0
- package/claude/skills/build-in-slices/SKILL.md +94 -0
- package/claude/skills/build-in-slices/references/adopted.md +41 -0
- package/claude/skills/code-craft/REQUIREMENT.md +48 -0
- package/claude/skills/code-craft/SKILL.md +107 -0
- package/claude/skills/code-craft/references/adopted.md +52 -0
- package/claude/skills/codebase-layout/REQUIREMENT.md +1 -1
- package/claude/skills/git-ship/REQUIREMENT.md +1 -0
- package/claude/skills/git-ship/SKILL.md +1 -1
- package/claude/skills/review-craft/REQUIREMENT.md +3 -0
- package/claude/skills/review-craft/SKILL.md +1 -1
- package/claude/skills/review-craft/references/adopted.md +8 -0
- package/claude/skills/review-craft/references/security.md +5 -0
- package/claude/skills/review-ui/REQUIREMENT.md +1 -0
- package/claude/skills/review-ui/SKILL.md +11 -0
- package/claude/skills/role-worker/REQUIREMENT.md +1 -0
- package/claude/skills/role-worker/SKILL.md +4 -0
- package/claude/skills/test-first/REQUIREMENT.md +3 -0
- package/claude/skills/test-first/SKILL.md +2 -1
- package/docs/agents/pr-preview.md +40 -12
- package/docs/workflow/skill-map.md +2 -0
- package/governance/rules/code/000-code.md +5 -1
- package/package.json +1 -1
- package/src/claude/cases/misc.ts +10 -0
- package/src/claude/skills-audit.ts +2 -0
- package/src/commands/pr.ts +107 -7
- package/src/commands/tooling.ts +39 -16
- package/src/pr/preview.ts +66 -0
- package/src/tooling/manifest.ts +16 -0
- package/src/tooling/verify-stack.ts +249 -0
- package/tooling/base/seeds/.cspell/tech-stack.txt +2 -0
- package/tooling/go/manifest.toml +3 -0
- package/tooling/go/reference.md +1 -0
- package/tooling/php/manifest.toml +3 -0
- package/tooling/php/reference.md +1 -0
- package/tooling/python/manifest.toml +3 -0
- package/tooling/python/reference.md +1 -1
- package/tooling/python/seeds/.cspell/tech-stack.txt +0 -1
- package/scripts/tooling/verify.sh +0 -184
|
@@ -21,6 +21,7 @@ rather than inside it, so the two passes finish on their own clocks.
|
|
|
21
21
|
- No address resolves under Step 3: stop and post nothing, `❌ No address to drive. PR #<number> carries no preview and no local preview, and the launch named none.`
|
|
22
22
|
- The wrapper refuses to open the browser because its revision is absent: stop and post nothing, passing its install command to whoever launched the pass. Never install mid-pass.
|
|
23
23
|
- The newest UI verdict is `## UI review closed` at the current head: stop, `The UI review already covers <short-sha>.` An open verdict at the head stops the same way only when nothing has arrived since it: drive again when a `## Review response` or `## Post-review findings` comment is newer than the verdict, or the launch names an address.
|
|
24
|
+
- The hosted preview was not built from the head: stop and post nothing, naming `built`, `tip`, and `canon pr preview <number> --json` for the dispatcher. A verdict posted against an older build carries a marker naming a head nobody drove.
|
|
24
25
|
- Post and stop. Never merge, never tick a box on the evidence comment, and never lift the draft mark.
|
|
25
26
|
|
|
26
27
|
## Step 1: resolve the pull request
|
|
@@ -55,6 +56,16 @@ Take the first rung that answers, testing each with `curl -s -o /dev/null -w '%{
|
|
|
55
56
|
2. The `local` address off the record.
|
|
56
57
|
3. An address the launch names.
|
|
57
58
|
|
|
59
|
+
When the hosted rung answers, check that it was built from the head before driving it:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
canon pr preview <number> --check --json
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
Branch on the record's `reason`. Only `fresh` continues. Any other stops with nothing posted, naming `built`, `tip`, and the next act: `stale` and `no-build` ask the dispatcher to run `canon pr preview <number> --json`, and `building` asks it to wait, since a mint is already running for the tip. This pass never mints. A `canon` that rejects `--check` predates the verb. Report that this pass needs a `canon` whose `pr preview` takes `--check`, naming the installed `canon --version`, and stop rather than reading the missing flag as `fresh`, the way Step 2 does for its own older binaries. A `reason` of `no-deploy`, `unfenced`, or `no-alias` means the project has no fenced deploy to read, so the hosted build cannot be verified. Stop on the hosted address then too, and say so.
|
|
66
|
+
|
|
67
|
+
The check covers the hosted address only. The local rung and an address the launch names have no deploy run to read, so say in the comment that they were driven unverified rather than implying the same coverage.
|
|
68
|
+
|
|
58
69
|
An address that exists and does not answer is a different outcome from no address at all. When every rung that exists fails to answer, skip the drive, record each box as not driven with the addresses tried and what each returned, and go to Step 7. A local address on another machine is the usual cause, and it says nothing about the change.
|
|
59
70
|
|
|
60
71
|
## Step 4: open the browser
|
|
@@ -52,5 +52,6 @@ The draft mark fails from both ends. On 2026-08-31 four sessions re-drafted a pu
|
|
|
52
52
|
|
|
53
53
|
- Entering the worktree, which `session-worktree` owns
|
|
54
54
|
- The chain from implement through pull request, which `auto-ship` owns
|
|
55
|
+
- How the build moves through the plan, slice by slice with a commit each, which `build-in-slices` owns
|
|
55
56
|
- Answering a posted review, which `review-address` owns
|
|
56
57
|
- The controlling session's half of the channel, the review poll, and the watch beside it, which `role-orchestrator` and its runbooks hold
|
|
@@ -20,6 +20,10 @@ from the skill that owns it and invoke none of the three from here. The ordinary
|
|
|
20
20
|
path arrives through `auto-ship` Step 0, which means that chain is already
|
|
21
21
|
running and re-invoking it would restart the build.
|
|
22
22
|
|
|
23
|
+
This body holds what the session may write. How it builds, in committed slices
|
|
24
|
+
with an out-of-scope finding noted rather than fixed, is `build-in-slices`,
|
|
25
|
+
which `auto-ship` Step 2 loads.
|
|
26
|
+
|
|
23
27
|
## Where the session stands
|
|
24
28
|
|
|
25
29
|
- Resolve `.canon/plans/`, `.canon/tasks/`, `.canon/review/`, `.canon/memory/`, and `.canon/ready/` at the main worktree root, never against the linked worktree this session builds in. Those folders are gitignored, so `git worktree add` never creates them and the copy beside the build is absent rather than empty.
|
|
@@ -14,6 +14,7 @@ Without this skill, a session implementing a planned change writes the test afte
|
|
|
14
14
|
- Write or extend the test before the implementation it covers, for any behavior whose shape is already known
|
|
15
15
|
- Run the test before implementing and confirm it fails for the missing behavior, not for an unrelated mistake in the test itself
|
|
16
16
|
- Implement the minimum the current test demands, and start a new test before extending past it
|
|
17
|
+
- Load `canon:code-craft` before writing the implementation when its shape is still open and the session has not loaded the skill, so the shape of the code is settled before the body rather than during refactor
|
|
17
18
|
- Re-run the test after implementing and confirm the pass is the one the test was written to prove
|
|
18
19
|
- Refactor the implementation and the test once the suite is green, re-running it after each change, since the smallest change that passes leaves duplication and shortcuts the loop exists to remove before they reach history
|
|
19
20
|
|
|
@@ -33,4 +34,6 @@ Without this skill, a session implementing a planned change writes the test afte
|
|
|
33
34
|
- Choosing the layer a test belongs at and judging whether it is a good test: `canon:test-craft`, loaded at step 1 rather than restated here
|
|
34
35
|
- Finding the cause of an unexplained failure: `canon:systematic-debugging`
|
|
35
36
|
- Confirming visual output after a change lands: `567-planning.md` states the order and `ui-checklist` writes the list to confirm against
|
|
37
|
+
- Shaping the function, class, or module the implementation writes: `canon:code-craft`, loaded at step 4 rather than restated here
|
|
38
|
+
- Committing the behavior as a slice and ordering the slices of a larger build: `canon:build-in-slices`
|
|
36
39
|
- The mechanical audit of whether an implementation reached history ahead of its test: `canon gov test-order`, invoked from `auto-ship`
|
|
@@ -10,7 +10,7 @@ description: Write the failing test for a behavior before writing the code that
|
|
|
10
10
|
1. Load `canon:test-craft` and pick the layer the test belongs at before writing it. Then find or write the test at that layer before touching the code it covers. Follow the pairing convention the project already uses, which is commonly a test sitting beside its subject under one name, so that `foo.ts` takes `foo.test.ts` beside it. Report it rather than proceeding silently when `canon:test-craft` does not resolve, and write the test at the smallest layer that can observe the behavior.
|
|
11
11
|
2. Write the test against the behavior as it should exist once the change lands, naming the case for what it proves rather than for the function it calls.
|
|
12
12
|
3. Run only that test and read the failure. Confirm it fails because the behavior is missing, not because of a typo, a missing import, or a signature the test itself got wrong. A test failing for the wrong reason still reads green the moment any code exists to satisfy that wrong reason, so treat any other failure as a defect in the test and fix the test before moving on.
|
|
13
|
-
4.
|
|
13
|
+
4. Load `canon:code-craft` and settle the shape of the code before writing it, unless the shape is already fixed or this session already loaded the skill. Report it rather than proceeding silently when `canon:code-craft` does not resolve, and write the plainest code the test asks for. Then implement the smallest change that turns the failure into a pass. Do not add behavior the test does not ask for. A second behavior wants its own test written first, not a shortcut folded into the change already open.
|
|
14
14
|
5. Run the test again and confirm it passes for the reason it was written for, not by accident. Then run the surrounding suite so a change made to satisfy this test has not broken another.
|
|
15
15
|
6. Refactor with the suite green. Remove the duplication and the shortcuts step 4 left, in the implementation and in the test, without changing what either does. Run the suite after each change, and undo a change that turns anything red rather than fixing forward.
|
|
16
16
|
|
|
@@ -23,4 +23,5 @@ description: Write the failing test for a behavior before writing the code that
|
|
|
23
23
|
|
|
24
24
|
- Which layer a test belongs at and what makes it a good one. `canon:test-craft` owns that judgment, and step 1 loads it.
|
|
25
25
|
- A failure with no known cause. Route through `canon:systematic-debugging` first, whose own fix phase already writes the reproducing test as part of finding the cause. This skill starts once the shape of the fix or the feature is already decided.
|
|
26
|
+
- Committing the green, refactored behavior as one slice of a larger build. `canon:build-in-slices` owns the commit and the order slices land in.
|
|
26
27
|
- Confirming a change looks right once it has landed, which is a separate concern from whether the test passed and runs after implementation rather than before it.
|
|
@@ -14,6 +14,7 @@ rather than screenshots and a checklist alone.
|
|
|
14
14
|
```bash
|
|
15
15
|
canon pr preview --json
|
|
16
16
|
canon pr preview 1341 --json --timeout 20
|
|
17
|
+
canon pr preview 1341 --check --json
|
|
17
18
|
```
|
|
18
19
|
|
|
19
20
|
## Which workflow it dispatches
|
|
@@ -61,22 +62,49 @@ keeps the match independent of the local clock.
|
|
|
61
62
|
|
|
62
63
|
`reason` on the record is what a caller branches on, not the exit code:
|
|
63
64
|
|
|
64
|
-
| Reason
|
|
65
|
-
|
|
|
66
|
-
| `ok`
|
|
67
|
-
| `no-deploy`
|
|
68
|
-
| `unfenced`
|
|
69
|
-
| `no-alias`
|
|
70
|
-
| `run-failed`
|
|
71
|
-
| `timeout`
|
|
72
|
-
| `bad-timeout`
|
|
73
|
-
| `gh-missing`
|
|
74
|
-
| `gh-failed`
|
|
75
|
-
| `no-branch`
|
|
65
|
+
| Reason | What it means |
|
|
66
|
+
| --------------- | -------------------------------------------------------------------------- |
|
|
67
|
+
| `ok` | The preview was published. `url` carries the address and `runId` the run. |
|
|
68
|
+
| `no-deploy` | No dispatchable workflow runs `pages deploy`. |
|
|
69
|
+
| `unfenced` | The deploy passes no `--branch`, so nothing was dispatched. |
|
|
70
|
+
| `no-alias` | The workflow prints no alias line, or the finished run's log carried none. |
|
|
71
|
+
| `run-failed` | The deploy run finished without succeeding. `runId` names it. |
|
|
72
|
+
| `timeout` | The run did not finish inside `--timeout` minutes, 15 by default. |
|
|
73
|
+
| `bad-timeout` | `--timeout` was not a positive number. |
|
|
74
|
+
| `gh-missing` | `gh` is not on the path. |
|
|
75
|
+
| `gh-failed` | `gh` could not read the pull request, or list or dispatch the workflow. |
|
|
76
|
+
| `no-branch` | The pull request carries no head branch name. |
|
|
77
|
+
| `check-timeout` | `--check` was combined with `--timeout`. |
|
|
76
78
|
|
|
77
79
|
The exit is 0 on `ok` and 1 on every refusal. A `timeout` leaves the run
|
|
78
80
|
going, so the preview may still land after the verb has given up on it.
|
|
79
81
|
|
|
82
|
+
## Checking which head a preview was built from
|
|
83
|
+
|
|
84
|
+
`--check` dispatches nothing and waits on nothing. It lists the branch's
|
|
85
|
+
`workflow_dispatch` runs, takes the newest successful one by creation time, and
|
|
86
|
+
compares the head it ran at with the branch tip read from the remote. The tip
|
|
87
|
+
comes from the remote rather than the pull request object's `headRefOid`, which
|
|
88
|
+
lags a push. `--check` takes no `--timeout` and refuses one as `check-timeout`.
|
|
89
|
+
|
|
90
|
+
The alias serves the newest successful deployment for the branch and names no
|
|
91
|
+
sha, so the run listing is the only record of the built head. The
|
|
92
|
+
`workflow_dispatch` filter also drops the cleanup run a closed pull request
|
|
93
|
+
fires, which concludes `success` with its deploy skipped. A project that deploys
|
|
94
|
+
on `push` to a feature branch would need that filter widened.
|
|
95
|
+
|
|
96
|
+
| Reason | What it means |
|
|
97
|
+
| ---------- | --------------------------------------------------------------------- |
|
|
98
|
+
| `fresh` | The newest successful deploy built the tip. Exit 0. |
|
|
99
|
+
| `stale` | It built an earlier head. `built` and `tip` name both. Mint again. |
|
|
100
|
+
| `building` | A deploy of the tip is still running. Wait rather than minting again. |
|
|
101
|
+
| `no-build` | No deploy of the branch has succeeded. |
|
|
102
|
+
|
|
103
|
+
The record also carries `runId` for the deploy it read. The check covers the
|
|
104
|
+
hosted address only, so a local address has no run to read and stays unverified.
|
|
105
|
+
`review-ui` runs it before driving a hosted preview and posts nothing on any
|
|
106
|
+
reason but `fresh`.
|
|
107
|
+
|
|
80
108
|
## How the address reaches the pull request
|
|
81
109
|
|
|
82
110
|
`canon pr evidence --preview <url>` puts `**Preview:** <url>` on the first line
|
|
@@ -45,7 +45,9 @@ This page is the corpus the coverage claim is measured against: every name `cano
|
|
|
45
45
|
| `canon:project-commands` | When the project's own command needs running |
|
|
46
46
|
| `canon:test-first` | Before implementing a planned change, to run its test red, green, then refactor |
|
|
47
47
|
| `canon:test-craft` | When writing or changing any test, to pick its layer and filter what it asserts |
|
|
48
|
+
| `canon:build-in-slices` | While building a plan, to commit each tested slice and note out-of-scope defects rather than fix them |
|
|
48
49
|
| `canon:api-design` | When changing what a caller sees, being an export, a flag, an output shape, or a retried write |
|
|
50
|
+
| `canon:code-craft` | Before writing a function, class, or module, to pick the shape the next change needs |
|
|
49
51
|
| `canon:review-craft` | When reviewing a change, for what to look for and how much evidence a finding needs |
|
|
50
52
|
| `canon:systematic-debugging` | When a test fails or a bug surfaces, to force root cause first |
|
|
51
53
|
| `canon:design-taste` | When drafting or judging an interface, or when output reads generic, to settle which decision comes first |
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
---
|
|
2
|
-
description: Code placement, dependency, error, and naming choices a model does not make by default
|
|
2
|
+
description: Code placement, shape, dependency, error, and naming choices a model does not make by default
|
|
3
3
|
---
|
|
4
4
|
|
|
5
5
|
# Code standards
|
|
@@ -10,6 +10,10 @@ description: Code placement, dependency, error, and naming choices a model does
|
|
|
10
10
|
- Prioritize native platform capabilities over third-party libraries.
|
|
11
11
|
- Ensure data and configuration reside in designated Single Source of Truth locations.
|
|
12
12
|
|
|
13
|
+
## Shape
|
|
14
|
+
|
|
15
|
+
- Load the `canon:code-craft` skill before deciding the shape of a new function, class, or module, and report it rather than proceeding silently when it does not resolve.
|
|
16
|
+
|
|
13
17
|
## Errors
|
|
14
18
|
|
|
15
19
|
- Return structured error types for recoverable failures.
|
package/package.json
CHANGED
package/src/claude/cases/misc.ts
CHANGED
|
@@ -74,6 +74,16 @@ export const MISC_CASES: readonly SkillCase[] = [
|
|
|
74
74
|
"I'm adding a field to the JSON this command prints and renaming one flag, anything to watch?",
|
|
75
75
|
expect: 'api-design',
|
|
76
76
|
},
|
|
77
|
+
{
|
|
78
|
+
prompt:
|
|
79
|
+
'The same discount rule sits in two files and I need a third variant. Should I extract it, or add a strategy class for the two cases?',
|
|
80
|
+
expect: 'code-craft',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
prompt:
|
|
84
|
+
"I've got the plan approved and it touches the parser, the command, and the docs. How should I build it so I don't end up with one giant diff?",
|
|
85
|
+
expect: 'build-in-slices',
|
|
86
|
+
},
|
|
77
87
|
{
|
|
78
88
|
prompt: 'Does our github about text still match what the readme says?',
|
|
79
89
|
expect: 'repo-metadata',
|
|
@@ -37,6 +37,8 @@ export const PRACTICE_SKILLS: readonly string[] = [
|
|
|
37
37
|
join(SHIPPED_CORPUS, 'review-craft'),
|
|
38
38
|
join(SHIPPED_CORPUS, 'systematic-debugging'),
|
|
39
39
|
join(SHIPPED_CORPUS, 'api-design'),
|
|
40
|
+
join(SHIPPED_CORPUS, 'code-craft'),
|
|
41
|
+
join(SHIPPED_CORPUS, 'build-in-slices'),
|
|
40
42
|
]
|
|
41
43
|
|
|
42
44
|
/** The closing H2s a practice skill carries, matched exactly outside fences. */
|
package/src/commands/pr.ts
CHANGED
|
@@ -50,6 +50,8 @@ import { KEY_CHANGES } from '@/pr/paths'
|
|
|
50
50
|
import {
|
|
51
51
|
findDeployWorkflow,
|
|
52
52
|
mintPreview,
|
|
53
|
+
type PreviewHeadReason,
|
|
54
|
+
readPreviewHead,
|
|
53
55
|
type PreviewRefusal,
|
|
54
56
|
type PreviewRunner,
|
|
55
57
|
type RunRow,
|
|
@@ -116,6 +118,7 @@ const LOCAL_REFUSALS: Record<LocalRefusal | 'gh-failed', string> = {
|
|
|
116
118
|
|
|
117
119
|
interface PreviewOptions extends ReadOptions {
|
|
118
120
|
readonly timeout?: string
|
|
121
|
+
readonly check?: boolean
|
|
119
122
|
}
|
|
120
123
|
|
|
121
124
|
/** How long `canon pr preview` waits on the deploy run by default, in minutes. */
|
|
@@ -124,8 +127,13 @@ const PREVIEW_TIMEOUT_MINUTES = 15
|
|
|
124
127
|
/** How often the deploy run is re-read while waiting on it. */
|
|
125
128
|
const PREVIEW_POLL_MS = 15_000
|
|
126
129
|
|
|
127
|
-
const PREVIEW_REFUSALS: Record<
|
|
130
|
+
const PREVIEW_REFUSALS: Record<
|
|
131
|
+
PreviewRefusal | 'bad-timeout' | 'check-timeout',
|
|
132
|
+
string
|
|
133
|
+
> = {
|
|
128
134
|
'bad-timeout': '--timeout takes a positive number of minutes.',
|
|
135
|
+
'check-timeout':
|
|
136
|
+
'--check only reads the deploy runs and waits on nothing, so it takes no --timeout.',
|
|
129
137
|
'no-deploy':
|
|
130
138
|
'No workflow under .github/workflows/ runs pages deploy and carries a workflow_dispatch trigger, so there is nothing to dispatch.',
|
|
131
139
|
// biome-ignore lint/suspicious/noTemplateCurlyInString: a workflow expression, not a template
|
|
@@ -141,6 +149,17 @@ const PREVIEW_REFUSALS: Record<PreviewRefusal | 'bad-timeout', string> = {
|
|
|
141
149
|
'The deploy run did not finish inside the bound. The preview may still land. Re-run with a longer --timeout or read the run.',
|
|
142
150
|
}
|
|
143
151
|
|
|
152
|
+
/** What a reader does about each way `canon pr preview --check` answers. */
|
|
153
|
+
const PREVIEW_HEAD_NOTES: Record<PreviewHeadReason, string> = {
|
|
154
|
+
fresh: 'The newest successful deploy built the branch tip.',
|
|
155
|
+
stale:
|
|
156
|
+
'The newest successful deploy built an earlier head than the branch tip. Mint again with canon pr preview before driving the preview.',
|
|
157
|
+
building:
|
|
158
|
+
'A deploy of the branch tip is still running. Wait for it rather than minting again.',
|
|
159
|
+
'no-build':
|
|
160
|
+
'No successful deploy exists for this branch. Mint one with canon pr preview.',
|
|
161
|
+
}
|
|
162
|
+
|
|
144
163
|
/** Why a head-sensitive read produced no answer about a commit. */
|
|
145
164
|
type PullRefusal =
|
|
146
165
|
| 'gh-missing'
|
|
@@ -528,8 +547,11 @@ export function register(program: Command): void {
|
|
|
528
547
|
.option('--json', 'Add a machine-readable record on stdout')
|
|
529
548
|
.option(
|
|
530
549
|
'--timeout <minutes>',
|
|
531
|
-
|
|
532
|
-
|
|
550
|
+
`How long to wait on the deploy run (default ${PREVIEW_TIMEOUT_MINUTES})`,
|
|
551
|
+
)
|
|
552
|
+
.option(
|
|
553
|
+
'--check',
|
|
554
|
+
'Read whether the newest successful deploy built the branch tip, dispatching nothing',
|
|
533
555
|
)
|
|
534
556
|
.addHelpText(
|
|
535
557
|
'after',
|
|
@@ -554,13 +576,22 @@ export function register(program: Command): void {
|
|
|
554
576
|
' run-failed the deploy run finished without succeeding',
|
|
555
577
|
' timeout the run did not finish inside --timeout minutes',
|
|
556
578
|
'',
|
|
579
|
+
'With --check nothing is dispatched or waited on. The newest successful',
|
|
580
|
+
'workflow_dispatch run is compared with the branch tip read from the remote.',
|
|
581
|
+
'Read `reason` on the JSON record, with `built`, `tip`, and `runId`:',
|
|
582
|
+
' fresh the newest successful deploy built the tip',
|
|
583
|
+
' stale the newest successful deploy built an earlier head',
|
|
584
|
+
' building a deploy of the tip is still running, so wait',
|
|
585
|
+
' no-build no deploy of this branch has succeeded',
|
|
586
|
+
'',
|
|
557
587
|
'Exit codes:',
|
|
558
|
-
' 0 a preview was published',
|
|
559
|
-
' 1 refused,
|
|
588
|
+
' 0 a preview was published, or --check read fresh',
|
|
589
|
+
' 1 refused, or --check read anything but fresh',
|
|
560
590
|
'',
|
|
561
591
|
'Examples:',
|
|
562
592
|
' canon pr preview --json',
|
|
563
593
|
' canon pr preview 1341 --json --timeout 20',
|
|
594
|
+
' canon pr preview 1341 --check --json',
|
|
564
595
|
'',
|
|
565
596
|
].join('\n'),
|
|
566
597
|
)
|
|
@@ -1773,7 +1804,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
|
|
|
1773
1804
|
'--limit',
|
|
1774
1805
|
'20',
|
|
1775
1806
|
'--json',
|
|
1776
|
-
'databaseId,status,conclusion',
|
|
1807
|
+
'databaseId,status,conclusion,headSha,createdAt',
|
|
1777
1808
|
])
|
|
1778
1809
|
if (out === null) return undefined
|
|
1779
1810
|
try {
|
|
@@ -1789,7 +1820,7 @@ function ghPreviewRunner(root: string): PreviewRunner {
|
|
|
1789
1820
|
'view',
|
|
1790
1821
|
String(id),
|
|
1791
1822
|
'--json',
|
|
1792
|
-
'databaseId,status,conclusion',
|
|
1823
|
+
'databaseId,status,conclusion,headSha,createdAt',
|
|
1793
1824
|
]),
|
|
1794
1825
|
)
|
|
1795
1826
|
},
|
|
@@ -1810,6 +1841,15 @@ async function runPreview(
|
|
|
1810
1841
|
|
|
1811
1842
|
intro('canon pr preview')
|
|
1812
1843
|
|
|
1844
|
+
if (opts.check === true && opts.timeout !== undefined) {
|
|
1845
|
+
return refuseWith(
|
|
1846
|
+
'check-timeout',
|
|
1847
|
+
PREVIEW_REFUSALS['check-timeout'],
|
|
1848
|
+
emitJson,
|
|
1849
|
+
root,
|
|
1850
|
+
)
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1813
1853
|
const minutes = Number(opts.timeout ?? PREVIEW_TIMEOUT_MINUTES)
|
|
1814
1854
|
if (!Number.isFinite(minutes) || minutes <= 0) {
|
|
1815
1855
|
return refuseWith(
|
|
@@ -1838,6 +1878,10 @@ async function runPreview(
|
|
|
1838
1878
|
}
|
|
1839
1879
|
const { identity } = read
|
|
1840
1880
|
|
|
1881
|
+
if (opts.check === true) {
|
|
1882
|
+
return runPreviewCheck(root, emitJson, pick.path, identity)
|
|
1883
|
+
}
|
|
1884
|
+
|
|
1841
1885
|
logStep('Deploy')
|
|
1842
1886
|
logInfo(`${pick.path} on ${identity.branch}`)
|
|
1843
1887
|
|
|
@@ -1887,6 +1931,62 @@ async function runPreview(
|
|
|
1887
1931
|
return 0
|
|
1888
1932
|
}
|
|
1889
1933
|
|
|
1934
|
+
/**
|
|
1935
|
+
* Reads the built head against the tip. The tip comes from the remote rather
|
|
1936
|
+
* than `headRefOid`, which lags a push, so a build of the last head reads stale
|
|
1937
|
+
* the moment a newer commit lands.
|
|
1938
|
+
*/
|
|
1939
|
+
async function runPreviewCheck(
|
|
1940
|
+
root: string,
|
|
1941
|
+
emitJson: boolean,
|
|
1942
|
+
workflow: string,
|
|
1943
|
+
identity: { readonly branch: string; readonly number?: number },
|
|
1944
|
+
): Promise<number> {
|
|
1945
|
+
const resolved = await resolveTip(identity.branch, refReader(root))
|
|
1946
|
+
if (resolved.kind === 'refused') {
|
|
1947
|
+
return refuseWith(
|
|
1948
|
+
resolved.reason,
|
|
1949
|
+
PULL_REFUSALS[resolved.reason],
|
|
1950
|
+
emitJson,
|
|
1951
|
+
root,
|
|
1952
|
+
)
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1955
|
+
const reading = await readPreviewHead(ghPreviewRunner(root), {
|
|
1956
|
+
workflow,
|
|
1957
|
+
branch: identity.branch,
|
|
1958
|
+
tip: resolved.tip,
|
|
1959
|
+
})
|
|
1960
|
+
if (reading.reason === 'gh-failed') {
|
|
1961
|
+
return refuseWith(
|
|
1962
|
+
'gh-failed',
|
|
1963
|
+
PREVIEW_REFUSALS['gh-failed'],
|
|
1964
|
+
emitJson,
|
|
1965
|
+
root,
|
|
1966
|
+
)
|
|
1967
|
+
}
|
|
1968
|
+
|
|
1969
|
+
logStep('Preview head')
|
|
1970
|
+
logInfo(PREVIEW_HEAD_NOTES[reading.reason])
|
|
1971
|
+
outro()
|
|
1972
|
+
|
|
1973
|
+
if (emitJson) {
|
|
1974
|
+
process.stdout.write(
|
|
1975
|
+
`${JSON.stringify({
|
|
1976
|
+
root,
|
|
1977
|
+
...(identity.number !== undefined && { number: identity.number }),
|
|
1978
|
+
reason: reading.reason,
|
|
1979
|
+
message: PREVIEW_HEAD_NOTES[reading.reason],
|
|
1980
|
+
workflow,
|
|
1981
|
+
tip: reading.tip,
|
|
1982
|
+
...(reading.built !== undefined && { built: reading.built }),
|
|
1983
|
+
...(reading.runId !== undefined && { runId: reading.runId }),
|
|
1984
|
+
})}\n`,
|
|
1985
|
+
)
|
|
1986
|
+
}
|
|
1987
|
+
return reading.reason === 'fresh' ? 0 : 1
|
|
1988
|
+
}
|
|
1989
|
+
|
|
1890
1990
|
async function readText(path: string): Promise<string | undefined> {
|
|
1891
1991
|
return readFile(path, 'utf8').catch(() => undefined)
|
|
1892
1992
|
}
|
package/src/commands/tooling.ts
CHANGED
|
@@ -1,6 +1,5 @@
|
|
|
1
1
|
import { resolve } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
|
-
import { execScript } from '@/exec'
|
|
4
3
|
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project-root'
|
|
5
4
|
import {
|
|
6
5
|
injectConfigs,
|
|
@@ -21,6 +20,7 @@ import { readReference, resolveReference } from '@/tooling/read'
|
|
|
21
20
|
import { scan, type ScanResult } from '@/tooling/scan'
|
|
22
21
|
import { recordedStacks, recordToolingChain } from '@/tooling/stamp'
|
|
23
22
|
import { subfolderPath } from '@/tooling/subfolder'
|
|
23
|
+
import { verifyStack } from '@/tooling/verify-stack'
|
|
24
24
|
import {
|
|
25
25
|
intro,
|
|
26
26
|
isNonInteractive,
|
|
@@ -34,8 +34,6 @@ import {
|
|
|
34
34
|
select,
|
|
35
35
|
} from '@/ui'
|
|
36
36
|
|
|
37
|
-
const PASS_THROUGH_VERBS = ['verify'] as const
|
|
38
|
-
|
|
39
37
|
interface SyncOptions {
|
|
40
38
|
readonly skip?: string
|
|
41
39
|
readonly check?: boolean
|
|
@@ -60,6 +58,10 @@ interface PruneOptions {
|
|
|
60
58
|
readonly nested?: boolean
|
|
61
59
|
}
|
|
62
60
|
|
|
61
|
+
interface VerifyOptions {
|
|
62
|
+
readonly keep?: boolean
|
|
63
|
+
}
|
|
64
|
+
|
|
63
65
|
interface ListOptions {
|
|
64
66
|
readonly json?: boolean
|
|
65
67
|
}
|
|
@@ -207,18 +209,39 @@ export function register(program: Command): void {
|
|
|
207
209
|
process.exitCode = printReference(stack)
|
|
208
210
|
})
|
|
209
211
|
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
212
|
+
tooling
|
|
213
|
+
.command('verify')
|
|
214
|
+
.description('Scaffold a stack fresh, sync it, and run its checks')
|
|
215
|
+
.argument('<stack>', 'Tooling stack name (e.g. vite-react, astro)')
|
|
216
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
217
|
+
.option('--keep', 'Keep the tmp dir after a clean run for inspection')
|
|
218
|
+
.addHelpText(
|
|
219
|
+
'after',
|
|
220
|
+
[
|
|
221
|
+
'',
|
|
222
|
+
'Scaffolds <stack> into .canon/tmp/runs/verify-<stack>/, runs the',
|
|
223
|
+
"manifest's [verify] prepare, syncs the chain from this checkout, then",
|
|
224
|
+
'runs lint:fix, check, test:e2e, and screenshot where the scaffold',
|
|
225
|
+
'declares them. A scaffold with no package.json after Sync fails.',
|
|
226
|
+
'',
|
|
227
|
+
'Exit codes:',
|
|
228
|
+
' 0 every phase passed',
|
|
229
|
+
' 1 a phase failed, or the stack cannot be verified',
|
|
230
|
+
'',
|
|
231
|
+
'Examples:',
|
|
232
|
+
' canon tooling verify vite-react',
|
|
233
|
+
' canon tooling verify astro --keep',
|
|
234
|
+
'',
|
|
235
|
+
].join('\n'),
|
|
236
|
+
)
|
|
237
|
+
.action(async (stack: string, opts: VerifyOptions) => {
|
|
238
|
+
const outcome = await verifyStack({
|
|
239
|
+
root: PROJECT_ROOT,
|
|
240
|
+
stack,
|
|
241
|
+
keep: opts.keep === true,
|
|
220
242
|
})
|
|
221
|
-
|
|
243
|
+
process.exitCode = outcome.exitCode
|
|
244
|
+
})
|
|
222
245
|
}
|
|
223
246
|
|
|
224
247
|
/**
|
|
@@ -227,8 +250,8 @@ export function register(program: Command): void {
|
|
|
227
250
|
* quote emitted output a consuming skill could not parse.
|
|
228
251
|
*
|
|
229
252
|
* The frame opens after the `--json` return. The bash emitted a closing `└`
|
|
230
|
-
* from its EXIT trap with no `┌` above it, because the
|
|
231
|
-
*
|
|
253
|
+
* from its EXIT trap with no `┌` above it, because the pass-through loop that
|
|
254
|
+
* ran it opened no frame first.
|
|
232
255
|
*/
|
|
233
256
|
function runList(opts: ListOptions): number {
|
|
234
257
|
// The warning is a frame-interior line, so it goes out after `intro` on the
|
package/src/pr/preview.ts
CHANGED
|
@@ -95,6 +95,8 @@ export interface RunRow {
|
|
|
95
95
|
readonly databaseId: number
|
|
96
96
|
readonly status: string
|
|
97
97
|
readonly conclusion: string
|
|
98
|
+
readonly headSha: string
|
|
99
|
+
readonly createdAt: string
|
|
98
100
|
}
|
|
99
101
|
|
|
100
102
|
/** The `gh` calls a preview makes, injected so the wait can be driven without a network. */
|
|
@@ -176,3 +178,67 @@ export async function mintPreview(
|
|
|
176
178
|
|
|
177
179
|
return { kind: 'minted', runId, url }
|
|
178
180
|
}
|
|
181
|
+
|
|
182
|
+
export type PreviewHeadReason = 'fresh' | 'stale' | 'building' | 'no-build'
|
|
183
|
+
|
|
184
|
+
export type PreviewHeadReading =
|
|
185
|
+
| {
|
|
186
|
+
readonly reason: PreviewHeadReason
|
|
187
|
+
readonly tip: string
|
|
188
|
+
readonly built?: string
|
|
189
|
+
readonly runId?: number
|
|
190
|
+
}
|
|
191
|
+
| { readonly reason: 'gh-failed' }
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Compares the head the newest successful dispatch built with the branch tip.
|
|
195
|
+
*
|
|
196
|
+
* The run listing is the only record of the built head, since the alias serves
|
|
197
|
+
* the newest successful deployment per branch and names no sha. Runs are
|
|
198
|
+
* ordered by creation rather than list position, so the verdict follows the
|
|
199
|
+
* most recent deploy. A run still going at the tip reads `building` rather
|
|
200
|
+
* than `stale`, so a mint already under way is not reported as a failure.
|
|
201
|
+
*/
|
|
202
|
+
export async function readPreviewHead(
|
|
203
|
+
runner: PreviewRunner,
|
|
204
|
+
target: {
|
|
205
|
+
readonly workflow: string
|
|
206
|
+
readonly branch: string
|
|
207
|
+
readonly tip: string
|
|
208
|
+
},
|
|
209
|
+
): Promise<PreviewHeadReading> {
|
|
210
|
+
const rows = await runner.listRuns(target.workflow, target.branch)
|
|
211
|
+
if (rows === undefined) return { reason: 'gh-failed' }
|
|
212
|
+
|
|
213
|
+
const { tip } = target
|
|
214
|
+
const newest = [...rows].sort((a, b) =>
|
|
215
|
+
b.createdAt.localeCompare(a.createdAt),
|
|
216
|
+
)
|
|
217
|
+
const built = newest.find(
|
|
218
|
+
(row) => row.status === 'completed' && row.conclusion === 'success',
|
|
219
|
+
)
|
|
220
|
+
|
|
221
|
+
if (built?.headSha === tip) {
|
|
222
|
+
return {
|
|
223
|
+
reason: 'fresh',
|
|
224
|
+
built: built.headSha,
|
|
225
|
+
tip,
|
|
226
|
+
runId: built.databaseId,
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const pending = newest.find(
|
|
231
|
+
(row) => row.status !== 'completed' && row.headSha === tip,
|
|
232
|
+
)
|
|
233
|
+
if (pending !== undefined) {
|
|
234
|
+
return {
|
|
235
|
+
reason: 'building',
|
|
236
|
+
tip,
|
|
237
|
+
runId: pending.databaseId,
|
|
238
|
+
...(built !== undefined && { built: built.headSha }),
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
if (built === undefined) return { reason: 'no-build', tip }
|
|
243
|
+
return { reason: 'stale', built: built.headSha, tip, runId: built.databaseId }
|
|
244
|
+
}
|
package/src/tooling/manifest.ts
CHANGED
|
@@ -30,6 +30,14 @@ export interface Manifest {
|
|
|
30
30
|
* gitignore entries.
|
|
31
31
|
*/
|
|
32
32
|
readonly onlyConfigs?: readonly string[]
|
|
33
|
+
/**
|
|
34
|
+
* The command that creates a fresh project for this stack, from `[stack]
|
|
35
|
+
* scaffold`, with `{{name}}` standing for the project folder. Unset on a
|
|
36
|
+
* layer stack, which scaffolds nothing of its own.
|
|
37
|
+
*/
|
|
38
|
+
readonly scaffold?: string
|
|
39
|
+
/** Post-scaffold setup `canon tooling verify` runs ahead of Sync. */
|
|
40
|
+
readonly prepare?: string
|
|
33
41
|
}
|
|
34
42
|
|
|
35
43
|
export interface ChainOptions {
|
|
@@ -134,6 +142,8 @@ export function loadManifest(
|
|
|
134
142
|
typeof stackTable.extends === 'string' ? stackTable.extends : ''
|
|
135
143
|
const scriptsTable = asTable(parsed.scripts)
|
|
136
144
|
const perRoot = readStrings(asTable(parsed.sync).per_root)
|
|
145
|
+
const scaffold = readString(stackTable.scaffold)
|
|
146
|
+
const prepare = readString(asTable(parsed.verify).prepare)
|
|
137
147
|
const dir = join(toolingDir(root), stack)
|
|
138
148
|
|
|
139
149
|
return {
|
|
@@ -147,6 +157,8 @@ export function loadManifest(
|
|
|
147
157
|
gitignore: readGitignoreSections(asTable(parsed.gitignore)),
|
|
148
158
|
devPackages: readDevPackages(parsed.dependencies),
|
|
149
159
|
...(perRoot.length > 0 ? { perRoot } : {}),
|
|
160
|
+
...(scaffold !== undefined ? { scaffold } : {}),
|
|
161
|
+
...(prepare !== undefined ? { prepare } : {}),
|
|
150
162
|
}
|
|
151
163
|
}
|
|
152
164
|
|
|
@@ -231,6 +243,10 @@ function readDevPackages(dependencies: unknown): string[] {
|
|
|
231
243
|
return readStrings(asTable(asTable(dependencies).dev).packages)
|
|
232
244
|
}
|
|
233
245
|
|
|
246
|
+
function readString(value: unknown): string | undefined {
|
|
247
|
+
return typeof value === 'string' && value !== '' ? value : undefined
|
|
248
|
+
}
|
|
249
|
+
|
|
234
250
|
function readStrings(value: unknown): string[] {
|
|
235
251
|
if (!Array.isArray(value)) return []
|
|
236
252
|
return value.filter(
|