@erclx/canon 4.55.0 → 4.56.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/canon-cli/SKILL.md +7 -0
- package/claude/skills/claude-autoship/SKILL.md +17 -1
- package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -4
- package/claude/skills/claude-worktree/SKILL.md +9 -3
- package/claude/skills/setup-init/SKILL.md +2 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/tasks.md +27 -1
- package/docs/target-projects.md +1 -1
- package/package.json +1 -1
- package/src/commands/tasks.ts +97 -0
- package/src/gate/measures.ts +73 -0
- package/src/gate/stages.ts +11 -0
- package/src/tasks/answers.ts +42 -11
- package/src/tasks/branch.ts +78 -0
- package/src/web/readme-citations.ts +90 -0
- package/standards/branch.md +3 -1
- package/tooling/nextjs/configs/eslint.config.js +90 -0
- package/tooling/nextjs/configs/next.config.ts +10 -0
- package/tooling/nextjs/configs/playwright.config.ts +27 -0
- package/tooling/nextjs/configs/vitest.config.ts +28 -0
- package/tooling/nextjs/manifest.toml +19 -0
- package/tooling/nextjs/reference.md +47 -0
- package/tooling/nextjs/seeds/.cspell/tech-stack.txt +3 -0
|
@@ -56,6 +56,13 @@ Run `canon tooling sync <stack> <target> --check` for the list resolved against
|
|
|
56
56
|
- `commitlint.config.js`
|
|
57
57
|
- `scripts/verify.sh`
|
|
58
58
|
|
|
59
|
+
### nextjs
|
|
60
|
+
|
|
61
|
+
- `eslint.config.js`
|
|
62
|
+
- `next.config.ts`
|
|
63
|
+
- `playwright.config.ts`
|
|
64
|
+
- `vitest.config.ts`
|
|
65
|
+
|
|
59
66
|
### python
|
|
60
67
|
|
|
61
68
|
- `.coveragerc`
|
|
@@ -35,12 +35,28 @@ Invoke `canon:claude-worker` first, whatever the worktree state. This session is
|
|
|
35
35
|
|
|
36
36
|
Report it rather than proceeding silently when `canon:claude-worker` does not resolve. It ships with the plugin, so a session running this chain from a project holding the CLI alone builds with no role asserted.
|
|
37
37
|
|
|
38
|
-
If `git rev-parse --git-dir` equals `git rev-parse --git-common-dir`, the session is in the main worktree. Invoke `canon:claude-worktree` before continuing. The wrapper handles
|
|
38
|
+
If `git rev-parse --git-dir` equals `git rev-parse --git-common-dir`, the session is in the main worktree. Invoke `canon:claude-worktree` before continuing, carrying the argument the subsection below derives. The wrapper handles branch alignment. Do not call `EnterWorktree` directly.
|
|
39
39
|
|
|
40
40
|
If neither command resolves, stop: `❌ Not a git repository. Autoship needs git or a WorktreeCreate hook.`
|
|
41
41
|
|
|
42
42
|
If the two commands differ, the session is already in a linked worktree. Continue.
|
|
43
43
|
|
|
44
|
+
### Name the worktree from the plan rather than leaving it to be derived
|
|
45
|
+
|
|
46
|
+
This step runs ahead of Step 1, so what it holds is the raw invocation argument rather than a resolved plan. When that argument is a plan path or a bare slug, run the verb on it and hand the result to `canon:claude-worktree` as its tier 0 argument:
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
canon tasks plan-branch <argument> --json
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
- `conforms: true`: pass the record's `branch`, which is already `<type>/<slug>`, and invoke nothing else to derive a name.
|
|
53
|
+
- `conforms: false`: the plan's own filename breaks a cap in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. Pass `branch` anyway and say the cap it broke, since the alternative is a name this session shortened by hand, which is a second derivation and the thing this call exists to prevent. `git-branch` decides the rename at ship.
|
|
54
|
+
- Anything else, including a refusal, a record carrying no `branch` key, and an installed binary carrying no `plan-branch` subcommand: invoke `canon:claude-worktree` bare and let its ladder derive the name. Say the verb did not answer, so a reader can tell a derived name from a fallback one.
|
|
55
|
+
|
|
56
|
+
The dispatch runbook runs the same verb on the same plan to pick the branch its collision check clears, so calling it here is what makes the checked branch and the taken branch one string. Deriving a name from `<plan>` by reading it was the alternative, and it is what produced three strings for one plan across four dispatches on 2026-09-05.
|
|
57
|
+
|
|
58
|
+
A caller that supplied a task path, or supplied nothing at all, has no plan to hand the verb here, since resolving either is Step 1's work and Step 1 has not run. Invoke `canon:claude-worktree` bare in both cases. That is the ladder unchanged rather than a regression, and it leaves the hole open: a dispatched worker reaching this step through a task path derives its name from a tier rather than from the plan the dispatcher checked.
|
|
59
|
+
|
|
44
60
|
## Step 1: read the plan
|
|
45
61
|
|
|
46
62
|
Resolve `<plan>` in this order, stopping at the first match:
|
|
@@ -7,9 +7,18 @@ Run this at loop step 4, for a `## Run now` row whose plan is verified, in place
|
|
|
7
7
|
|
|
8
8
|
## Derive the candidate
|
|
9
9
|
|
|
10
|
-
|
|
10
|
+
Run `canon tasks plan-branch <plan> --json` against the row's plan file and read `branch`, `type`, `slug`, and `conforms` off the record.
|
|
11
11
|
|
|
12
|
-
|
|
12
|
+
- `conforms: true`: take `branch` as the candidate, and take `type` and `slug` from the same record for the check below.
|
|
13
|
+
- `conforms: false`: the plan's own filename breaks a cap in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. Report which, and hand the row to the human-launch line below rather than shortening the slug here. A rename parts the branch from the plan filename that `claude-worktree` tier 1 and `git-pr`'s plan lookup both read back.
|
|
14
|
+
- `reason: archived`, `no-plan`, or `bad-input`: the row does not cite a live plan. Repoint the row or fix the citation rather than dispatching, since `claude-autoship` Step 1 refuses the same file and the worker would meet that refusal after the launch spent.
|
|
15
|
+
- Anything else, including a record carrying no `branch` key and an installed binary carrying no `plan-branch` subcommand: treat the candidate as unverified rather than clear, name which reading could not be taken, and fall back to the human-launch line below. Re-deriving by prose here rebuilds the defect the verb closes, and does it quietly.
|
|
16
|
+
|
|
17
|
+
Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
|
|
18
|
+
|
|
19
|
+
The worker calls the same verb on the same plan at `claude-autoship` Step 0, so the branch this gate checks and the branch that session takes are one string by construction rather than two readings of one paragraph. They were two readings until 2026-09-06. One run checked `docs/remaining-skill-verdicts` against a worker that took `docs/skill-verdicts-decide`, another checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`, and four dispatches on 2026-09-05 produced three strings for one plan. A check against a branch nobody uses verifies nothing.
|
|
20
|
+
|
|
21
|
+
The type the verb reports is fixed at `feat` whatever the row does, which is the half of the derivation that disagreed most. What makes that safe is that a branch type is cosmetic: `git-stage` reads a commit's type off the staged diff and `git-pr` reads a title off the diff, so nothing a release reads passes through the branch name. What it costs is a worktree listing where every dispatched branch reads `feat/`, which a person scanning one loses. Nothing renames it later, and this paragraph said `git-branch` did until 2026-09-06, when its conventions guard turned out to fire on a conforming `feat/` before reaching any type judgment.
|
|
13
22
|
|
|
14
23
|
## Check the plan waits on nobody
|
|
15
24
|
|
|
@@ -93,9 +102,9 @@ The worker resolves that id back to a name through `canon sessions list --json`,
|
|
|
93
102
|
|
|
94
103
|
The template carries no worktree call. `claude-autoship` Step 0 invokes `canon:claude-worker` and then `canon:claude-worktree` itself, and neither carries the flag, so both are reachable through the `Skill` tool regardless of where a call to them would sit in a prompt. The autoship call carries `<plan>`, the same file this runbook already read to derive the branch, so its Step 1 takes it as the caller-supplied plan rather than re-deriving one from the slug the worker's branch happens to carry.
|
|
95
104
|
|
|
96
|
-
|
|
105
|
+
The template names no branch, and it does not need to. `claude-autoship` Step 0 runs `canon tasks plan-branch <plan>` on the same file this runbook derived the candidate from, and hands the `<type>/<slug>` it reports to `claude-worktree` as its tier 0 argument, so the two sides agree by calling one derivation rather than by a string copied between them.
|
|
97
106
|
|
|
98
|
-
|
|
107
|
+
That retires the inference four workers took before the verb existed, which was to derive the name from `<plan>` by their own reading of it. Nothing has to reach `claude-worktree`'s ladder now, which mattered because a worker launched onto `main` cannot match tier 1, a board carrying more than one plan puts tier 2 out of reach, and tier 3 tells it to ask a person who is not there. What still travels on judgment is the fallback: a worker whose installed binary carries no `plan-branch` derives by prose, which is where both live disagreements came from.
|
|
99
108
|
|
|
100
109
|
### Expansion needs position zero and a clean delimiter, not leading order alone
|
|
101
110
|
|
|
@@ -49,11 +49,17 @@ Tier 0 sits ahead of the inference because every tier below it answers from stat
|
|
|
49
49
|
|
|
50
50
|
Validate the result: letters, digits, dots, underscores, dashes only, max 64 chars (`/` separators are also allowed). If the derived name violates the rule, sanitize by replacing invalid chars with `-` and truncating. Show the sanitized name in the preview before invoking.
|
|
51
51
|
|
|
52
|
-
Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it, drawing the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. A type the caller spelled in tier 0 wins outright and no reading overrides it. A name from a plan takes
|
|
52
|
+
Resolve `<type>` here as well, since Step 3 previews it and Step 5 renames onto it, drawing the value from the type vocabulary in `${CLAUDE_SKILL_DIR}/../../standards/branch.md`. A type the caller spelled in tier 0 wins outright and no reading overrides it. A name that came from a plan, through tier 1 or tier 2, takes its type from `canon tasks plan-branch <plan> --json`, read off the record's `type` field. Every other case takes `feat`, which covers a name from a branch, a bare name from the user, and a plan the verb could not answer for.
|
|
53
53
|
|
|
54
|
-
The
|
|
54
|
+
The verb is the reading rather than this body, because a type read off a plan's `## Summary` and `**Files to touch:**` lines is a judgment, and it has disagreed with itself in production. One dispatch checked `fix/path-form-hook` and the worker took `feat/path-form-hook`, both sides reading the same plan and grading it differently. The verb answers `feat` for every plan, so two sides calling it cannot part.
|
|
55
55
|
|
|
56
|
-
|
|
56
|
+
The caller's type still wins over the verb's, because tier 0 is the one source that knows something no file states. The ordinary caller is `claude-autoship` Step 0, which ran the verb itself and is handing over the answer it got, so nothing is overridden in that case either.
|
|
57
|
+
|
|
58
|
+
Branch on the record rather than on the exit code, which a shell function wrapping `canon` can flatten to zero. Take `feat` and say the verb did not answer where it refuses, where the record carries no `type` key, or where the installed binary carries no `plan-branch` subcommand.
|
|
59
|
+
|
|
60
|
+
A wrong type is cheap because the branch type is cosmetic rather than because anything corrects it. `git-stage` reads a commit's type off the staged diff and `git-pr` reads a title off the diff too, so the semantics a release reads never pass through the branch name at all. Nothing downstream is wrong when a `feat/` sits over a fix, and a person scanning a worktree listing loses a signal.
|
|
61
|
+
|
|
62
|
+
`git-branch` does not correct it, which three surfaces used to say it did. Its second guard reads `If branch name already follows conventions, stop`, and `${CLAUDE_SKILL_DIR}/../../standards/branch.md` makes the type vocabulary an axis without making the choice within that vocabulary one, so `feat/` over a fix conforms and the guard fires ahead of the Analysis line that would have re-derived the type.
|
|
57
63
|
|
|
58
64
|
Then test both names the entry is about to claim. Neither read needs a worktree, and a stop after Step 4 leaves one built with the session sitting inside it, so both belong here rather than beside the rename:
|
|
59
65
|
|
|
@@ -45,7 +45,8 @@ Read these from the project root in parallel, skipping any that do not exist:
|
|
|
45
45
|
## Resolve arguments
|
|
46
46
|
|
|
47
47
|
- **Stack:** pick the closest governance stack by matching detected runtime or framework against stack names in the catalog. If nothing matches, fall back to `base` and carry the fallback into the preview.
|
|
48
|
-
- **Tooling stack:** pick the closest tooling stack from `canon tooling list --json` (e.g. `vite-react`, `astro`). Distinct from the governance stack. Fall back to `base` if no framework match, and carry that fallback into the preview too.
|
|
48
|
+
- **Tooling stack:** pick the closest tooling stack from `canon tooling list --json` (e.g. `vite-react`, `astro`, `nextjs`). Distinct from the governance stack. Fall back to `base` if no framework match, and carry that fallback into the preview too.
|
|
49
|
+
- **Next.js:** map `next` in `package.json` dependencies, or a root `next.config.ts`/`.js`/`.mjs` file, to the `nextjs` tooling stack. The dependency name does not match the stack name by itself.
|
|
49
50
|
- **Extras:** identify technologies not already covered by the picked stack. For each, find a rule whose `description` or `paths` points at that technology and pass it via `--add`. Do not add a rule the stack already pulls in.
|
|
50
51
|
- **Skip (`--skip`):** `wiki` installs by default. Add `--skip wiki` only when the user explicitly wants it left out.
|
|
51
52
|
|
package/docs/agents/index.md
CHANGED
|
@@ -43,7 +43,7 @@ CLI catalog and invocation rules for agents, split by command domain. Start with
|
|
|
43
43
|
- [State-scoped risk](state-scoped-risk.md): Reading committed state rather than an arriving change, the shipped-tree corpus the secret scan reads, what it keys on and how a deliberate value is exempted, the advisory check and its network failure mode, and why one gates while the other reports
|
|
44
44
|
- [Superseded values](superseded.md): Reading where the tree still asserts a value a changed convention no longer produces, why the sweep keys on the value rather than the file, the family stem behind a templated citation, the exemption marker, the blind spots it cannot reach, and why it reports rather than gates
|
|
45
45
|
- [Targets](targets.md): The projects this toolkit installed into, the record the install writes against the sweep that backs it, what bounds each answer, and the cross-target pull request read
|
|
46
|
-
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
46
|
+
- [Tasks](tasks.md): Selecting a shipped task by stem or pull request, recording a number and closing an outcome, deriving the branch a dispatch and a worker both take, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
47
47
|
- [Teach](teach.md): Listing learning workspaces with what their records schedule next, opening one with its required files, recording sources and glossary terms, resolving what the next lesson needs before it is written, rewriting the root listing, a contents page, and each lesson's chrome and quiz stepper from its marker regions, the refusal reasons, and why every write here runs through a verb
|
|
48
48
|
- [Test order](test-order.md): Reading where an implementation reached history ahead of its test, how a pair is decided, the three verdicts, the coverage the pairing cannot reach, and why the check reports rather than gates
|
|
49
49
|
- [Worktrees](worktrees.md): Reporting which worktrees are reclaimable, removing the ones that are, the record a hook reads back, why the reading keys on the pull request rather than on git ancestry, the refusals it names, and the two removal shapes
|
package/docs/agents/tasks.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
title: Tasks
|
|
3
|
-
description: Selecting a shipped task by stem or pull request, recording a number and closing an outcome, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
3
|
+
description: Selecting a shipped task by stem or pull request, recording a number and closing an outcome, deriving the branch a dispatch and a worker both take, the refusal reasons, the board and backlog checks validate runs, and why the board root defaults to the main worktree
|
|
4
4
|
---
|
|
5
5
|
|
|
6
6
|
# Tasks
|
|
@@ -104,6 +104,32 @@ The orchestrator dispatch runbook calls this before it checks the branch or the
|
|
|
104
104
|
canon tasks plan-answers dispatch-answer-gate --json | jq -r '.launchable'
|
|
105
105
|
```
|
|
106
106
|
|
|
107
|
+
## Plan branch
|
|
108
|
+
|
|
109
|
+
`canon tasks plan-branch <plan>` derives the branch name from a plan file. It reports and never writes, and it names the plan the same two ways `canon tasks plan-answers` does, by path or by slug, against the same two bases.
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
canon tasks plan-branch dispatch-answer-gate
|
|
113
|
+
canon tasks plan-branch .canon/plans/feature-dispatch-answer-gate.md --json
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
| Option | Effect |
|
|
117
|
+
| --------------- | ------------------------------------------- |
|
|
118
|
+
| `--json` | Emit a machine-readable record on stdout |
|
|
119
|
+
| `--root <path>` | Board root, defaulting to the main worktree |
|
|
120
|
+
|
|
121
|
+
The record carries `type`, `slug`, `branch`, `words`, and `conforms`. Exit codes: `0` derived and conforming, `1` refused with `no-plan`, `archived`, or `bad-input`, `2` derived with `conforms` false. Branch on `conforms` rather than on the exit code, which a shell function wrapping `canon` can flatten to zero.
|
|
122
|
+
|
|
123
|
+
`slug` is the plan filename with its `feature-` prefix and its extension taken off, and `type` is the constant `feat`. Reading a type off the plan's prose was the alternative, and it is the half of the derivation that has already disagreed with itself: one dispatch checked `fix/path-form-hook` against a worker that took `feat/path-form-hook`, both sides reading one plan. What makes the constant safe is that a branch type is cosmetic. `git-stage` reads a commit's type off the staged diff and `git-pr` reads a title off the diff, so the semantics a release reads never pass through the branch name. What it costs is a worktree listing where every plan-derived branch reads `feat/`, and nothing renames it later.
|
|
124
|
+
|
|
125
|
+
`conforms` reads both caps `standards/branch.md` states, being 4 words on the description and 50 characters on the whole branch. A false reading is a row for a person rather than a name to shorten here, since a rename parts the branch slug from the plan slug that `claude-worktree` tier 1 and `git-pr`'s plan lookup both read back.
|
|
126
|
+
|
|
127
|
+
Both sides of a dispatch call it. The orchestrator's collision check derives its candidate here, and `claude-autoship` Step 0 derives the worktree it enters from the same plan, so the branch a gate clears and the branch a session takes are one string by construction. They were two readings of one paragraph until 2026-09-06, when four dispatches on one plan produced three different strings.
|
|
128
|
+
|
|
129
|
+
```bash
|
|
130
|
+
canon tasks plan-branch dispatch-answer-gate --json | jq -r '.branch'
|
|
131
|
+
```
|
|
132
|
+
|
|
107
133
|
## Plan link
|
|
108
134
|
|
|
109
135
|
`canon tasks plan-link <task> <plan>` writes or corrects a task's `Plan:` line, as `Plan: [<label>](<target>)` right after the H1. `claude-feature` calls it right after a plan file lands, when Step 1 resolved an existing task for the feature, so the line is a mechanical write rather than hand-edited markdown.
|
package/docs/target-projects.md
CHANGED
|
@@ -83,7 +83,7 @@ Each diagram entry records the commit and date it was last verified against, and
|
|
|
83
83
|
|
|
84
84
|
The default path is `base`. `canon init` on `base` installs base tooling configs, Claude seeds, and governance core rules, and scaffolds an empty `.claude/wiki/`. Most projects need nothing more.
|
|
85
85
|
|
|
86
|
-
Escalate only for real web apps. The `setup-init` skill reads `package.json` and root configs, then picks the matching tooling stack (`
|
|
86
|
+
Escalate only for real web apps. The `setup-init` skill reads `package.json` and root configs, then picks the matching tooling stack (`canon tooling list --json` names the current set) and the matching governance stack (`react`, `astro`, `node`).
|
|
87
87
|
|
|
88
88
|
`node-server` is named rather than detected. It carries the server-side security and persistence rules for a project writing request handlers or a persistence layer in TypeScript, and the detect step matches a runtime or a framework against stack names, so nothing there marks a project as a backend. Pass it deliberately with `canon init --stack node-server` or `canon gov install node-server <target>`.
|
|
89
89
|
|
package/package.json
CHANGED
package/src/commands/tasks.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { relative } from 'node:path'
|
|
2
2
|
import type { Command } from 'commander'
|
|
3
3
|
import { type AnswersOutcome, planAnswers } from '@/tasks/answers'
|
|
4
|
+
import { type BranchOutcome, planBranch } from '@/tasks/branch'
|
|
4
5
|
import {
|
|
5
6
|
type ArchiveOutcome,
|
|
6
7
|
archiveTask,
|
|
@@ -62,6 +63,11 @@ interface AnswersCommandOptions {
|
|
|
62
63
|
readonly root?: string
|
|
63
64
|
}
|
|
64
65
|
|
|
66
|
+
interface BranchCommandOptions {
|
|
67
|
+
readonly json?: boolean
|
|
68
|
+
readonly root?: string
|
|
69
|
+
}
|
|
70
|
+
|
|
65
71
|
interface PullRequestCommandOptions {
|
|
66
72
|
readonly json?: boolean
|
|
67
73
|
readonly plan?: string
|
|
@@ -230,6 +236,42 @@ export function register(program: Command): void {
|
|
|
230
236
|
process.exitCode = await runAnswers(plan, opts)
|
|
231
237
|
})
|
|
232
238
|
|
|
239
|
+
tasks
|
|
240
|
+
.command('plan-branch')
|
|
241
|
+
.description('Derive the branch name a dispatch and a worker both take')
|
|
242
|
+
.argument('<plan>', 'Plan path or its slug, as in dispatch-answer-gate')
|
|
243
|
+
.helpOption('-h, --help', 'Show this help message')
|
|
244
|
+
.option('--json', 'Emit a machine-readable record on stdout')
|
|
245
|
+
.option('--root <path>', 'Board root, defaulting to the main worktree')
|
|
246
|
+
.addHelpText(
|
|
247
|
+
'after',
|
|
248
|
+
[
|
|
249
|
+
'',
|
|
250
|
+
'Exit codes:',
|
|
251
|
+
' 0 the branch is derived and conforms to standards/branch.md',
|
|
252
|
+
' 1 refused as no-plan, archived, or bad-input',
|
|
253
|
+
' 2 derived, and conforms is false because the slug breaks a cap',
|
|
254
|
+
'',
|
|
255
|
+
'It reports type, slug, branch, words, and conforms. The type is fixed',
|
|
256
|
+
'at feat, since a derivation two sides grade differently is the defect',
|
|
257
|
+
'this closes, and git-branch renames a wrong type later in the chain.',
|
|
258
|
+
'Branch on conforms rather than on the exit code, which a shell',
|
|
259
|
+
'function wrapping canon can flatten to zero.',
|
|
260
|
+
'',
|
|
261
|
+
'The dispatch collision check and the worker worktree entry both call',
|
|
262
|
+
'it, so the branch a gate clears is the branch a session takes.',
|
|
263
|
+
'',
|
|
264
|
+
'Examples:',
|
|
265
|
+
' canon tasks plan-branch dispatch-answer-gate',
|
|
266
|
+
' canon tasks plan-branch .canon/plans/feature-dispatch-answer-gate.md',
|
|
267
|
+
' canon tasks plan-branch dispatch-answer-gate --json',
|
|
268
|
+
'',
|
|
269
|
+
].join('\n'),
|
|
270
|
+
)
|
|
271
|
+
.action(async (plan: string, opts: BranchCommandOptions) => {
|
|
272
|
+
process.exitCode = await runBranch(plan, opts)
|
|
273
|
+
})
|
|
274
|
+
|
|
233
275
|
tasks
|
|
234
276
|
.command('pull-request')
|
|
235
277
|
.description('Record a pull request number on the task a branch closes')
|
|
@@ -732,6 +774,61 @@ function reportAnswers(
|
|
|
732
774
|
return EXIT_FINDINGS
|
|
733
775
|
}
|
|
734
776
|
|
|
777
|
+
async function runBranch(
|
|
778
|
+
plan: string,
|
|
779
|
+
opts: BranchCommandOptions,
|
|
780
|
+
): Promise<number> {
|
|
781
|
+
const root = opts.root ?? (await mainWorktreeRoot())
|
|
782
|
+
const outcome = planBranch(root, plan)
|
|
783
|
+
|
|
784
|
+
return reportBranch(outcome, opts.json ?? false, root)
|
|
785
|
+
}
|
|
786
|
+
|
|
787
|
+
function reportBranch(
|
|
788
|
+
outcome: BranchOutcome,
|
|
789
|
+
emitJson: boolean,
|
|
790
|
+
root: string,
|
|
791
|
+
): number {
|
|
792
|
+
if (!outcome.ok) {
|
|
793
|
+
if (emitJson) {
|
|
794
|
+
process.stdout.write(
|
|
795
|
+
`${JSON.stringify({ ok: false, reason: outcome.reason, message: outcome.message })}\n`,
|
|
796
|
+
)
|
|
797
|
+
return 1
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
intro('canon tasks plan-branch')
|
|
801
|
+
logStep('Refused')
|
|
802
|
+
logError(outcome.message)
|
|
803
|
+
outro()
|
|
804
|
+
return 1
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
if (emitJson) {
|
|
808
|
+
process.stdout.write(`${JSON.stringify({ ...outcome, root })}\n`)
|
|
809
|
+
return outcome.conforms ? 0 : EXIT_FINDINGS
|
|
810
|
+
}
|
|
811
|
+
|
|
812
|
+
intro('canon tasks plan-branch')
|
|
813
|
+
logStep(outcome.branch)
|
|
814
|
+
|
|
815
|
+
if (outcome.conforms) {
|
|
816
|
+
logInfo(`derived from ${outcome.plan}, ${outcome.words} words.`)
|
|
817
|
+
outro()
|
|
818
|
+
return 0
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
logWarn(
|
|
822
|
+
`derived from ${outcome.plan}, ${outcome.words} words and ${outcome.branch.length} characters.`,
|
|
823
|
+
)
|
|
824
|
+
logError(
|
|
825
|
+
'The slug breaks a cap in standards/branch.md, so hand the row to a person rather than renaming it.',
|
|
826
|
+
)
|
|
827
|
+
outro()
|
|
828
|
+
|
|
829
|
+
return EXIT_FINDINGS
|
|
830
|
+
}
|
|
831
|
+
|
|
735
832
|
function reportValidation(
|
|
736
833
|
outcome: ValidateOutcome,
|
|
737
834
|
emitJson: boolean,
|
package/src/gate/measures.ts
CHANGED
|
@@ -15,6 +15,10 @@ import {
|
|
|
15
15
|
SHIPPED_CORPORA,
|
|
16
16
|
type ShippedReference,
|
|
17
17
|
} from '@/shipped/references'
|
|
18
|
+
import {
|
|
19
|
+
README_PARAPHRASE_MARKER,
|
|
20
|
+
readmeCitationsIn,
|
|
21
|
+
} from '@/web/readme-citations'
|
|
18
22
|
|
|
19
23
|
export interface CommandResult {
|
|
20
24
|
readonly exitCode: number
|
|
@@ -622,6 +626,75 @@ export const clientCommandCitations = async (
|
|
|
622
626
|
}
|
|
623
627
|
}
|
|
624
628
|
|
|
629
|
+
/**
|
|
630
|
+
* Every `README.md:` anchor in `web/src/content/copy.ts` whose quoted phrase no
|
|
631
|
+
* longer appears in the current `README.md`, plus any leftover bare
|
|
632
|
+
* `README.md:<n>` citation the retired line-number convention would leave
|
|
633
|
+
* behind.
|
|
634
|
+
*
|
|
635
|
+
* Scoped to the one file carrying the anchors rather than walked across the
|
|
636
|
+
* tree, since `web/src/content/copy.ts` is the only place this repository
|
|
637
|
+
* writes one. A quoted phrase is checked with a plain substring test against
|
|
638
|
+
* the whole `README.md` text rather than against the cited line, which is the
|
|
639
|
+
* property that lets the anchor survive `README.md` growing or shrinking
|
|
640
|
+
* above it: the phrase fails only when the content itself moved or changed,
|
|
641
|
+
* never when a line number did.
|
|
642
|
+
*/
|
|
643
|
+
export const readmeCitations: Measure = async (ctx) => {
|
|
644
|
+
const copyPath = 'web/src/content/copy.ts'
|
|
645
|
+
const readmePath = 'README.md'
|
|
646
|
+
const copyFile = join(ctx.root, copyPath)
|
|
647
|
+
const readmeFile = join(ctx.root, readmePath)
|
|
648
|
+
|
|
649
|
+
if (!existsSync(copyFile) || !existsSync(readmeFile)) {
|
|
650
|
+
return {
|
|
651
|
+
emissions: [],
|
|
652
|
+
unmeasured: `${copyPath} or ${readmePath} is absent, so no citation was checked.`,
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
const readmeText = readFileSync(readmeFile, 'utf8')
|
|
657
|
+
const citations = readmeCitationsIn(copyPath, readFileSync(copyFile, 'utf8'))
|
|
658
|
+
|
|
659
|
+
if (citations.length === 0) {
|
|
660
|
+
return {
|
|
661
|
+
emissions: [],
|
|
662
|
+
unmeasured: `${copyPath} carries no README.md citation, so nothing was checked.`,
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
const bad = citations.filter(
|
|
667
|
+
(citation) =>
|
|
668
|
+
citation.kind === 'bare' ||
|
|
669
|
+
(citation.kind === 'quoted' &&
|
|
670
|
+
citation.phrases.some((phrase) => !readmeText.includes(phrase))),
|
|
671
|
+
)
|
|
672
|
+
|
|
673
|
+
if (bad.length === 0) {
|
|
674
|
+
return {
|
|
675
|
+
emissions: [
|
|
676
|
+
info(
|
|
677
|
+
`${citations.length} README.md citation(s) in ${copyPath} verified against the current text`,
|
|
678
|
+
),
|
|
679
|
+
],
|
|
680
|
+
}
|
|
681
|
+
}
|
|
682
|
+
|
|
683
|
+
return {
|
|
684
|
+
emissions: bad.map((citation) =>
|
|
685
|
+
warn(
|
|
686
|
+
citation.kind === 'bare'
|
|
687
|
+
? `${citation.file}:${citation.line} carries ${citation.text}, a bare line number that cannot detect a shifted line`
|
|
688
|
+
: `${citation.file}:${citation.line} carries ${citation.text}, whose quoted phrase no longer appears in README.md`,
|
|
689
|
+
),
|
|
690
|
+
),
|
|
691
|
+
failure:
|
|
692
|
+
bad.length === 1
|
|
693
|
+
? `One README.md citation in ${copyPath} failed. Quote a verbatim phrase from the current README.md, or mark the line ${README_PARAPHRASE_MARKER}: <reason> where the string condenses rather than quotes.`
|
|
694
|
+
: `${bad.length} README.md citations in ${copyPath} failed. Quote a verbatim phrase from the current README.md, or mark each line ${README_PARAPHRASE_MARKER}: <reason> where the string condenses rather than quotes.`,
|
|
695
|
+
}
|
|
696
|
+
}
|
|
697
|
+
|
|
625
698
|
/**
|
|
626
699
|
* `canon sandbox coverage` moves only when a person runs it, so a scenario added
|
|
627
700
|
* with no expectation ships unnoticed.
|
package/src/gate/stages.ts
CHANGED
|
@@ -5,6 +5,7 @@ import {
|
|
|
5
5
|
markdownBans,
|
|
6
6
|
type Measure,
|
|
7
7
|
pluginManifests,
|
|
8
|
+
readmeCitations,
|
|
8
9
|
recordIdempotence,
|
|
9
10
|
sandboxCoverage,
|
|
10
11
|
seedStandards,
|
|
@@ -390,6 +391,16 @@ export const STAGES: readonly Stage[] = [
|
|
|
390
391
|
skipped: 'No shipped corpus changed, so no reference was read',
|
|
391
392
|
checks: [{ kind: 'measure', measure: shippedReferences }],
|
|
392
393
|
},
|
|
394
|
+
{
|
|
395
|
+
// Scoped to the two files a citation can drift between, so an edit to
|
|
396
|
+
// either one runs the check. web/src/content/copy.ts carries the anchors
|
|
397
|
+
// and README.md is the text they quote from.
|
|
398
|
+
id: 'readme-citations',
|
|
399
|
+
label: 'README citations',
|
|
400
|
+
scope: /^(web\/src\/content\/copy\.ts|README\.md)$/,
|
|
401
|
+
skipped: 'Neither copy.ts nor README.md changed, so no citation was read',
|
|
402
|
+
checks: [{ kind: 'measure', measure: readmeCitations }],
|
|
403
|
+
},
|
|
393
404
|
{
|
|
394
405
|
id: 'seed-standards',
|
|
395
406
|
label: 'Seed standards',
|
package/src/tasks/answers.ts
CHANGED
|
@@ -47,6 +47,18 @@ export interface PlanAnswers {
|
|
|
47
47
|
|
|
48
48
|
export type AnswersOutcome = PlanAnswers | AnswersRefused
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A reference that named a live plan, carrying the absolute path a reader opens
|
|
52
|
+
* and the root-relative spelling a record reports.
|
|
53
|
+
*/
|
|
54
|
+
export interface PlanResolved {
|
|
55
|
+
readonly ok: true
|
|
56
|
+
readonly path: string
|
|
57
|
+
readonly plan: string
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export type ResolveOutcome = PlanResolved | AnswersRefused
|
|
61
|
+
|
|
50
62
|
/**
|
|
51
63
|
* The spellings a caller reaches a plan by, in the order they are tried. A bare
|
|
52
64
|
* slug names the live folder outright, and a path is resolved against the
|
|
@@ -146,19 +158,18 @@ function openQuestions(lines: readonly string[]): OpenQuestion[] {
|
|
|
146
158
|
}
|
|
147
159
|
|
|
148
160
|
/**
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
* the same `readQuestions` the plan validator runs, so the gate and the
|
|
152
|
-
* conformance check cannot drift into disagreeing about what a question is.
|
|
161
|
+
* Resolves a caller's reference to a live plan, refusing an empty reference, a
|
|
162
|
+
* reference that names no file, and one that lands in the plans archive.
|
|
153
163
|
*
|
|
154
|
-
*
|
|
155
|
-
* the
|
|
156
|
-
*
|
|
164
|
+
* Both `planAnswers` and `planBranch` answer the same reference, so the
|
|
165
|
+
* resolution and the three refusals sit here rather than in each of them. Two
|
|
166
|
+
* verbs reading one reference through two copies of this ladder is the defect
|
|
167
|
+
* the branch derivation was filed against, one layer down.
|
|
157
168
|
*/
|
|
158
|
-
export
|
|
169
|
+
export function resolvePlanReference(
|
|
159
170
|
root: string,
|
|
160
171
|
reference: string,
|
|
161
|
-
):
|
|
172
|
+
): ResolveOutcome {
|
|
162
173
|
if (reference.trim().length === 0) {
|
|
163
174
|
return refuse('bad-input', 'No plan named. Pass a plan path or its slug.')
|
|
164
175
|
}
|
|
@@ -188,12 +199,32 @@ export async function planAnswers(
|
|
|
188
199
|
)
|
|
189
200
|
}
|
|
190
201
|
|
|
191
|
-
|
|
202
|
+
return { ok: true, path, plan: relative(root, path) }
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Answers whether a plan is launchable, which is whether it still waits on the
|
|
207
|
+
* operator for a call only they can make. It reads the question block through
|
|
208
|
+
* the same `readQuestions` the plan validator runs, so the gate and the
|
|
209
|
+
* conformance check cannot drift into disagreeing about what a question is.
|
|
210
|
+
*
|
|
211
|
+
* It reports and never writes. Holding the row, naming the slot, and reaching
|
|
212
|
+
* the operator belong to the dispatcher, which is where the decision already
|
|
213
|
+
* sits.
|
|
214
|
+
*/
|
|
215
|
+
export async function planAnswers(
|
|
216
|
+
root: string,
|
|
217
|
+
reference: string,
|
|
218
|
+
): Promise<AnswersOutcome> {
|
|
219
|
+
const resolved = resolvePlanReference(root, reference)
|
|
220
|
+
if (!resolved.ok) return resolved
|
|
221
|
+
|
|
222
|
+
const sections = splitPlanSections(await readFile(resolved.path, 'utf8'))
|
|
192
223
|
const open = openQuestions(sections.get('Questions') ?? [])
|
|
193
224
|
|
|
194
225
|
return {
|
|
195
226
|
ok: true,
|
|
196
|
-
plan:
|
|
227
|
+
plan: resolved.plan,
|
|
197
228
|
launchable: open.length === 0,
|
|
198
229
|
open,
|
|
199
230
|
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { basename } from 'node:path'
|
|
2
|
+
import { type AnswersRefused, resolvePlanReference } from '@/tasks/answers'
|
|
3
|
+
|
|
4
|
+
const PLAN_PREFIX = 'feature-'
|
|
5
|
+
const MARKDOWN = '.md'
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* The type every plan-derived branch takes. It is a constant rather than a
|
|
9
|
+
* reading, because determinism is the whole property that makes the dispatch
|
|
10
|
+
* gate and the worker agree, and prose reading is the judgment that produced
|
|
11
|
+
* three strings for one plan. A wrong type is cheap because a branch type is
|
|
12
|
+
* cosmetic, since a commit's type and a pull request's title are both read off
|
|
13
|
+
* the diff. Nothing renames it later, whatever three shipped bodies used to say.
|
|
14
|
+
*/
|
|
15
|
+
export const PLAN_BRANCH_TYPE = 'feat'
|
|
16
|
+
|
|
17
|
+
/** The description cap in `standards/branch.md`, in kebab-separated words. */
|
|
18
|
+
export const DESCRIPTION_WORD_CAP = 4
|
|
19
|
+
|
|
20
|
+
/** The branch length cap in `standards/branch.md`, in characters. */
|
|
21
|
+
export const BRANCH_LENGTH_CAP = 50
|
|
22
|
+
|
|
23
|
+
export interface PlanBranch {
|
|
24
|
+
readonly ok: true
|
|
25
|
+
readonly plan: string
|
|
26
|
+
readonly type: string
|
|
27
|
+
readonly slug: string
|
|
28
|
+
readonly branch: string
|
|
29
|
+
readonly words: number
|
|
30
|
+
readonly conforms: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export type BranchOutcome = PlanBranch | AnswersRefused
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Takes the slug off a plan filename. The `feature-` prefix and the extension
|
|
37
|
+
* are the two things every plan filename carries and no branch name does, so
|
|
38
|
+
* both come off and whatever is left is the description.
|
|
39
|
+
*/
|
|
40
|
+
function slugOf(path: string): string {
|
|
41
|
+
const stem = basename(path, MARKDOWN)
|
|
42
|
+
|
|
43
|
+
return stem.startsWith(PLAN_PREFIX) ? stem.slice(PLAN_PREFIX.length) : stem
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Derives the branch a dispatch checks and a worker takes, from the plan both
|
|
48
|
+
* of them name. It is the one derivation, so the collision check and the
|
|
49
|
+
* worktree entry it gates cannot hold two answers for one plan.
|
|
50
|
+
*
|
|
51
|
+
* Conformance covers both caps `standards/branch.md` states, being the word
|
|
52
|
+
* count of the description and the length of the whole branch. A slug is a
|
|
53
|
+
* plan's own filename rather than a name anyone chose for a branch, so a plan
|
|
54
|
+
* can name a branch this refuses to grade as conforming, and reporting that is
|
|
55
|
+
* the point: the caller hands the row to a person rather than shipping a
|
|
56
|
+
* rename that parts the branch slug from the plan slug.
|
|
57
|
+
*/
|
|
58
|
+
export function planBranch(root: string, reference: string): BranchOutcome {
|
|
59
|
+
const resolved = resolvePlanReference(root, reference)
|
|
60
|
+
if (!resolved.ok) return resolved
|
|
61
|
+
|
|
62
|
+
const slug = slugOf(resolved.path)
|
|
63
|
+
const branch = `${PLAN_BRANCH_TYPE}/${slug}`
|
|
64
|
+
const words = slug.split('-').filter((word) => word.length > 0).length
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
ok: true,
|
|
68
|
+
plan: resolved.plan,
|
|
69
|
+
type: PLAN_BRANCH_TYPE,
|
|
70
|
+
slug,
|
|
71
|
+
branch,
|
|
72
|
+
words,
|
|
73
|
+
conforms:
|
|
74
|
+
words > 0 &&
|
|
75
|
+
words <= DESCRIPTION_WORD_CAP &&
|
|
76
|
+
branch.length <= BRANCH_LENGTH_CAP,
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { isMarked } from '@/exempt-marker'
|
|
2
|
+
|
|
3
|
+
export const README_PARAPHRASE_MARKER = 'canon-allow-readme-paraphrase'
|
|
4
|
+
|
|
5
|
+
export type ReadmeCitationKind = 'quoted' | 'paraphrase' | 'bare'
|
|
6
|
+
|
|
7
|
+
export interface ReadmeCitation {
|
|
8
|
+
readonly file: string
|
|
9
|
+
/** One-based, matching the `file:line` form a reader clicks. */
|
|
10
|
+
readonly line: number
|
|
11
|
+
readonly kind: ReadmeCitationKind
|
|
12
|
+
/** The citation comment as written, so a report names the line to fix. */
|
|
13
|
+
readonly text: string
|
|
14
|
+
/** Verbatim phrases to check against `README.md`, set only for `kind: 'quoted'`. */
|
|
15
|
+
readonly phrases: readonly string[]
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
const ANCHOR = /README\.md:\s*(.*)$/
|
|
19
|
+
const QUOTED_PHRASE = /"([^"]+)"/g
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Every `README.md:` anchor comment in one file, classified by shape.
|
|
23
|
+
*
|
|
24
|
+
* `quoted` carries one or more verbatim phrases a caller checks against the
|
|
25
|
+
* current `README.md` text, which is what replaces a line number that drifts
|
|
26
|
+
* silently the moment the cited line moves. A quote is checked whether or not
|
|
27
|
+
* the line also carries `README_PARAPHRASE_MARKER`, since a marker documents
|
|
28
|
+
* that part of a string is synthesized and asserts nothing about a phrase the
|
|
29
|
+
* same line puts in quotes: quoting a borrow verbatim and then never checking
|
|
30
|
+
* it would let the exact drift this file exists to catch survive inside its
|
|
31
|
+
* own escape hatch. `paraphrase` is what a marked line falls to only once it
|
|
32
|
+
* carries no quote of its own, muted by `README_PARAPHRASE_MARKER` the way
|
|
33
|
+
* `isMarked` mutes every other exemption in this repository. `bare` is the
|
|
34
|
+
* retired `README.md:<n>` form, reported rather than accepted so the fragile
|
|
35
|
+
* convention this replaces cannot come back on a later edit.
|
|
36
|
+
*
|
|
37
|
+
* Modeled on `clientCommandCitationsIn` in `src/client-commands.ts`, including
|
|
38
|
+
* its use of `isMarked` for the exemption.
|
|
39
|
+
*/
|
|
40
|
+
export function readmeCitationsIn(
|
|
41
|
+
file: string,
|
|
42
|
+
text: string,
|
|
43
|
+
): ReadmeCitation[] {
|
|
44
|
+
const lines = text.split('\n')
|
|
45
|
+
const citations: ReadmeCitation[] = []
|
|
46
|
+
|
|
47
|
+
for (const [index, line] of lines.entries()) {
|
|
48
|
+
const match = ANCHOR.exec(line)
|
|
49
|
+
if (match === null) continue
|
|
50
|
+
|
|
51
|
+
const rest = (match[1] ?? '').trim()
|
|
52
|
+
|
|
53
|
+
const phrases = [...rest.matchAll(QUOTED_PHRASE)].map(
|
|
54
|
+
(found) => found[1] ?? '',
|
|
55
|
+
)
|
|
56
|
+
if (phrases.length > 0) {
|
|
57
|
+
citations.push({
|
|
58
|
+
file,
|
|
59
|
+
line: index + 1,
|
|
60
|
+
kind: 'quoted',
|
|
61
|
+
text: line.trim(),
|
|
62
|
+
phrases,
|
|
63
|
+
})
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
if (isMarked(lines, index, README_PARAPHRASE_MARKER)) {
|
|
68
|
+
citations.push({
|
|
69
|
+
file,
|
|
70
|
+
line: index + 1,
|
|
71
|
+
kind: 'paraphrase',
|
|
72
|
+
text: line.trim(),
|
|
73
|
+
phrases: [],
|
|
74
|
+
})
|
|
75
|
+
continue
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
if (/^\d/.test(rest)) {
|
|
79
|
+
citations.push({
|
|
80
|
+
file,
|
|
81
|
+
line: index + 1,
|
|
82
|
+
kind: 'bare',
|
|
83
|
+
text: line.trim(),
|
|
84
|
+
phrases: [],
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return citations
|
|
90
|
+
}
|
package/standards/branch.md
CHANGED
|
@@ -21,11 +21,13 @@ Does not govern:
|
|
|
21
21
|
- Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
|
|
22
22
|
- Length: 50 characters maximum
|
|
23
23
|
- Casing: kebab-case only, no underscores or camelCase
|
|
24
|
-
- Description: 2 words maximum,
|
|
24
|
+
- Description: 2 words maximum, up to 4 only when genuinely needed for specificity
|
|
25
25
|
- Capture the core change, not the commit message verbatim
|
|
26
26
|
- For branches with multiple commits, use the unifying concern as the description.
|
|
27
27
|
- Do not duplicate type in description (e.g., `feat/feature-login`)
|
|
28
28
|
|
|
29
|
+
The upper bound reads 4 rather than 3 for every branch, whoever named it. It was widened on 2026-09-06 so that a branch taking its description from a planning document's own filename stops being renamed at ship, since a rename there is a third derivation and parts the branch from the filename that later tooling reads back to find the document. Nothing can tell such a name from any other, so the wider bound holds for all of them and 2 words stays the target.
|
|
30
|
+
|
|
29
31
|
## Types
|
|
30
32
|
|
|
31
33
|
- `feat`: new feature or capability
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import js from '@eslint/js'
|
|
2
|
+
import { defineConfig, globalIgnores } from 'eslint/config'
|
|
3
|
+
import prettier from 'eslint-config-prettier'
|
|
4
|
+
import checkFile from 'eslint-plugin-check-file'
|
|
5
|
+
import reactHooks from 'eslint-plugin-react-hooks'
|
|
6
|
+
import reactRefresh from 'eslint-plugin-react-refresh'
|
|
7
|
+
import simpleImportSort from 'eslint-plugin-simple-import-sort'
|
|
8
|
+
import vitest from 'eslint-plugin-vitest'
|
|
9
|
+
import globals from 'globals'
|
|
10
|
+
import tseslint from 'typescript-eslint'
|
|
11
|
+
|
|
12
|
+
export default defineConfig([
|
|
13
|
+
globalIgnores([
|
|
14
|
+
'.next',
|
|
15
|
+
'next-env.d.ts',
|
|
16
|
+
'dist',
|
|
17
|
+
'dist-ssr',
|
|
18
|
+
'coverage',
|
|
19
|
+
'release',
|
|
20
|
+
'.claude',
|
|
21
|
+
'.vscode',
|
|
22
|
+
'.husky',
|
|
23
|
+
'test-results',
|
|
24
|
+
'playwright-report',
|
|
25
|
+
'blob-report',
|
|
26
|
+
'playwright/.cache',
|
|
27
|
+
]),
|
|
28
|
+
js.configs.recommended,
|
|
29
|
+
...tseslint.configs.recommended,
|
|
30
|
+
{
|
|
31
|
+
files: ['**/*.{ts,tsx,js,jsx}'],
|
|
32
|
+
plugins: {
|
|
33
|
+
'react-hooks': reactHooks,
|
|
34
|
+
'react-refresh': reactRefresh,
|
|
35
|
+
'simple-import-sort': simpleImportSort,
|
|
36
|
+
'check-file': checkFile,
|
|
37
|
+
},
|
|
38
|
+
languageOptions: {
|
|
39
|
+
globals: {
|
|
40
|
+
...globals.browser,
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
rules: {
|
|
44
|
+
...reactHooks.configs.recommended.rules,
|
|
45
|
+
'react-refresh/only-export-components': [
|
|
46
|
+
'warn',
|
|
47
|
+
{ allowConstantExport: true },
|
|
48
|
+
],
|
|
49
|
+
'simple-import-sort/imports': 'error',
|
|
50
|
+
'simple-import-sort/exports': 'error',
|
|
51
|
+
'@typescript-eslint/no-unused-vars': [
|
|
52
|
+
'error',
|
|
53
|
+
{ varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
|
|
54
|
+
],
|
|
55
|
+
'check-file/filename-naming-convention': [
|
|
56
|
+
'error',
|
|
57
|
+
{ '**/*.{ts,tsx}': 'KEBAB_CASE' },
|
|
58
|
+
{ ignoreMiddleExtensions: true },
|
|
59
|
+
],
|
|
60
|
+
'check-file/folder-naming-convention': [
|
|
61
|
+
'error',
|
|
62
|
+
{ 'src/**/!(__tests__)': 'KEBAB_CASE' },
|
|
63
|
+
],
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
{
|
|
67
|
+
// App Router route and layout files export non-component values (metadata, route handlers), which this rule flags as violations.
|
|
68
|
+
files: ['src/app/**/*.{ts,tsx}'],
|
|
69
|
+
rules: {
|
|
70
|
+
'react-refresh/only-export-components': 'off',
|
|
71
|
+
},
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
|
|
75
|
+
...vitest.configs.recommended,
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
files: [
|
|
79
|
+
'*.config.{js,mjs,cjs,ts}',
|
|
80
|
+
'vitest.config.ts',
|
|
81
|
+
'playwright.config.ts',
|
|
82
|
+
],
|
|
83
|
+
languageOptions: {
|
|
84
|
+
globals: {
|
|
85
|
+
...globals.node,
|
|
86
|
+
},
|
|
87
|
+
},
|
|
88
|
+
},
|
|
89
|
+
prettier,
|
|
90
|
+
])
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import { defineConfig, devices } from '@playwright/test'
|
|
2
|
+
|
|
3
|
+
const isCI = !!process.env.CI
|
|
4
|
+
const baseURL = `http://localhost:${3000 + (Number(process.env.WORKTREE_PORT_OFFSET) || 0)}`
|
|
5
|
+
|
|
6
|
+
export default defineConfig({
|
|
7
|
+
testDir: 'e2e',
|
|
8
|
+
forbidOnly: isCI,
|
|
9
|
+
retries: isCI ? 2 : 0,
|
|
10
|
+
reporter: isCI ? 'list' : 'html',
|
|
11
|
+
use: {
|
|
12
|
+
trace: 'on-first-retry',
|
|
13
|
+
baseURL,
|
|
14
|
+
},
|
|
15
|
+
projects: [
|
|
16
|
+
{ name: 'chromium', use: { ...devices['Desktop Chrome'] } },
|
|
17
|
+
{ name: 'firefox', use: { ...devices['Desktop Firefox'] } },
|
|
18
|
+
{ name: 'webkit', use: { ...devices['Desktop Safari'] } },
|
|
19
|
+
],
|
|
20
|
+
webServer: {
|
|
21
|
+
command: process.env.DIST_PREBUILT
|
|
22
|
+
? 'bun run preview'
|
|
23
|
+
: 'bun run build && bun run preview',
|
|
24
|
+
url: baseURL,
|
|
25
|
+
reuseExistingServer: false,
|
|
26
|
+
},
|
|
27
|
+
})
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
|
|
3
|
+
import { defineConfig } from 'vitest/config'
|
|
4
|
+
|
|
5
|
+
export default defineConfig({
|
|
6
|
+
resolve: {
|
|
7
|
+
alias: {
|
|
8
|
+
'@': path.resolve(__dirname, 'src'),
|
|
9
|
+
},
|
|
10
|
+
},
|
|
11
|
+
test: {
|
|
12
|
+
environment: 'jsdom',
|
|
13
|
+
globals: true,
|
|
14
|
+
setupFiles: ['src/test/setup.ts'],
|
|
15
|
+
passWithNoTests: true,
|
|
16
|
+
exclude: [
|
|
17
|
+
'**/node_modules/**',
|
|
18
|
+
'**/.next/**',
|
|
19
|
+
'**/e2e/**',
|
|
20
|
+
'**/.{idea,git,cache,output,temp}/**',
|
|
21
|
+
],
|
|
22
|
+
coverage: {
|
|
23
|
+
provider: 'v8',
|
|
24
|
+
reporter: ['text', 'json', 'html'],
|
|
25
|
+
exclude: ['node_modules/', 'src/test/setup.ts', 'e2e/'],
|
|
26
|
+
},
|
|
27
|
+
},
|
|
28
|
+
})
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
[stack]
|
|
2
|
+
name = "nextjs"
|
|
3
|
+
extends = "web"
|
|
4
|
+
runtime = "bun"
|
|
5
|
+
scaffold = "bunx create-next-app@latest {{name}} --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" --use-bun --skip-install --disable-git --no-agents-md --yes"
|
|
6
|
+
|
|
7
|
+
[dependencies.dev]
|
|
8
|
+
packages = [
|
|
9
|
+
"next",
|
|
10
|
+
"@tailwindcss/postcss",
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
[scripts]
|
|
14
|
+
"preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next start --port $((3000 + WORKTREE_PORT_OFFSET))"
|
|
15
|
+
"typecheck" = "next typegen && tsc --noEmit"
|
|
16
|
+
|
|
17
|
+
[scripts.override]
|
|
18
|
+
"dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
|
|
19
|
+
"screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# Tooling nextjs reference
|
|
2
|
+
|
|
3
|
+
> Extends: `web`. Apply web stack first.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The nextjs stack covers Next.js + TypeScript projects using the App Router. It ships golden configs for `next.config.ts`, `vitest.config.ts` (plain `defineConfig`, no bundler merge), `playwright.config.ts` (build-then-preview), and an `eslint.config.js` that extends the web layer's config with `.next` ignores and an App Router override. Shared web tooling (ESLint base, screenshot template, VS Code, CI, verify script) comes from the `web` layer.
|
|
8
|
+
|
|
9
|
+
## Scaffold checklist
|
|
10
|
+
|
|
11
|
+
1. Scaffold with `bunx create-next-app@latest <name> --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-bun --skip-install --disable-git --no-agents-md --yes`. `--no-agents-md` skips the scaffold-time `AGENTS.md`/`CLAUDE.md` write, which otherwise duplicates the root `CLAUDE.md`.
|
|
12
|
+
2. Install web tooling: `canon tooling sync web . --write`
|
|
13
|
+
3. Install nextjs configs: `canon tooling sync nextjs . --write`
|
|
14
|
+
4. Extend the `ci` and `development` context entries under `.claude/context/` per the web reference's extend sections plus the nextjs rows below.
|
|
15
|
+
5. Run `bun run lint:fix` then `bun run check`.
|
|
16
|
+
|
|
17
|
+
## What ships as golden configs
|
|
18
|
+
|
|
19
|
+
- `next.config.ts`: `agentRules: false` stops Next from regenerating `AGENTS.md`/`CLAUDE.md` on every run, which would otherwise compete with the root `CLAUDE.md`. `turbopack.root: import.meta.dirname` pins the workspace root, silencing Next's multi-lockfile inference warning in any checkout carrying more than one lockfile above the project, a worktree included.
|
|
20
|
+
- `vitest.config.ts`: plain `defineConfig`, no `mergeConfig` or `getViteConfig` since Next has no Vite config to merge from. jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `.next/**` in test excludes.
|
|
21
|
+
- `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `3000` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. `DIST_PREBUILT` drops the `build` half, matching the astro stack's flag.
|
|
22
|
+
- `eslint.config.js`: overrides the web layer with `.next` and `next-env.d.ts` added to `globalIgnores`, keeping `react-hooks`/`react-refresh` since this stack still ships React components. `react-refresh/only-export-components` is `off` for `src/app/**`, since route and layout files export non-component values the rule would otherwise flag.
|
|
23
|
+
|
|
24
|
+
## Port
|
|
25
|
+
|
|
26
|
+
Next has no config-file port hook, unlike `astro.config.mjs`'s `server.port` or `vite.config.ts`'s `server.port`. The `${base} + WORKTREE_PORT_OFFSET` formula is computed twice instead of once: in `playwright.config.ts` as a JS expression, and in `[scripts.override]` for `dev` and `preview` as shell arithmetic passed to `next`'s own `--port` flag. Default port is `3000`, Next's own default.
|
|
27
|
+
|
|
28
|
+
## Typecheck
|
|
29
|
+
|
|
30
|
+
`typecheck` runs `next typegen && tsc --noEmit`. The App Router's route-level types (`LayoutProps`, `PageProps`) are generated into `.next/types/`, gitignored and absent from a fresh checkout, and `tsc` fails on them unresolved without the typegen step first.
|
|
31
|
+
|
|
32
|
+
## No golden `tsconfig.json`
|
|
33
|
+
|
|
34
|
+
`create-next-app`'s own default `tsconfig.json` needs no changes beyond project-specific path aliases, so a golden copy here would ship nothing the scaffold does not already write.
|
|
35
|
+
|
|
36
|
+
## Development docs (extend)
|
|
37
|
+
|
|
38
|
+
Append to the `## Scripts` table:
|
|
39
|
+
|
|
40
|
+
| `bun run dev` | Start the Next dev server on port 3000, plus this worktree's port offset. |
|
|
41
|
+
| `bun run build` | Build the production bundle. |
|
|
42
|
+
| `bun run preview` | Serve the built bundle locally, on port 3000 plus the worktree offset. |
|
|
43
|
+
| `bun run typecheck` | Run `next typegen` then `tsc --noEmit`. |
|
|
44
|
+
|
|
45
|
+
## CI docs (extend)
|
|
46
|
+
|
|
47
|
+
In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `next typegen && tsc --noEmit` passes ``.
|