@erclx/canon 4.10.0 → 4.12.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-feedback-file/SKILL.md +41 -11
- package/docs/agents/capture.md +2 -2
- package/docs/agents/commands.md +6 -1
- package/docs/ai-workflow.md +1 -1
- package/docs/target-projects.md +11 -0
- package/package.json +1 -1
- package/scripts/core/regen-hero.sh +76 -23
- package/src/commands/feedback-format.ts +74 -1
- package/src/commands/feedback.ts +23 -9
- package/src/commands/migrate.ts +173 -0
- package/src/design/base.css +3 -1
- package/src/design/tokens.ts +18 -4
- package/src/gate/measures.ts +50 -28
- package/src/gate/stages.ts +8 -4
- package/src/github-format.ts +62 -0
- package/src/github.ts +17 -6
- package/src/migrate/record-tree.ts +343 -0
- package/src/migrate/records.ts +6 -1
|
@@ -17,28 +17,49 @@ Format a `## Toolkit feedback` block from the current session, then ship it to t
|
|
|
17
17
|
|
|
18
18
|
From the conversation so far, identify:
|
|
19
19
|
|
|
20
|
-
- Target project name or path
|
|
20
|
+
- Target project name or kind, never its full path. The path names a folder on one machine and says nothing a triage session can route on, where the project's own name does. Name a private project by its kind instead, such as `a Next.js app`, since the report leaves the machine.
|
|
21
21
|
- Toolkit surface and its type (plugin skill, snippet, tooling config, governance rule, seed, or CLI)
|
|
22
22
|
- Specific toolkit file or name when the session cites one
|
|
23
23
|
- Observed behavior
|
|
24
24
|
- Expected behavior, or `unclear`
|
|
25
25
|
- Repro details already in context (commands run, files touched), or `none`
|
|
26
|
-
- Proposed fix when the
|
|
26
|
+
- Proposed fix, or `open` when the session settled on no direction
|
|
27
27
|
|
|
28
|
-
Format as a single fenced markdown block:
|
|
28
|
+
Format as a single fenced markdown block, one `###` heading per field:
|
|
29
29
|
|
|
30
30
|
```markdown
|
|
31
31
|
## Toolkit feedback
|
|
32
32
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
33
|
+
### From project
|
|
34
|
+
|
|
35
|
+
<name or kind>
|
|
36
|
+
|
|
37
|
+
### Surface
|
|
38
|
+
|
|
39
|
+
<type>, <file path or name>
|
|
40
|
+
|
|
41
|
+
### Observed
|
|
42
|
+
|
|
43
|
+
<one or two lines>
|
|
44
|
+
|
|
45
|
+
### Expected
|
|
46
|
+
|
|
47
|
+
<one or two lines, or "unclear">
|
|
48
|
+
|
|
49
|
+
### Repro
|
|
50
|
+
|
|
51
|
+
<commands or steps, or "none">
|
|
52
|
+
|
|
53
|
+
### Proposed fix
|
|
54
|
+
|
|
55
|
+
<one line, or "open">
|
|
39
56
|
```
|
|
40
57
|
|
|
41
|
-
|
|
58
|
+
`canon feedback` refuses a report missing `### Surface`, `### Observed`, or `### Proposed fix`, naming the one it did not find. The other three are optional, so write the literal fallback shown above rather than dropping the heading, which is what keeps a field the session cannot fill reading as absent instead of unreported.
|
|
59
|
+
|
|
60
|
+
Headings rather than bold labels, because a GitHub issue form renders a submitted field as `### <label>`. One shape reaches the toolkit whichever route a report takes, and the CLI parses both with one parser. The retired `**Surface:**` form is refused rather than accepted, so a block carrying it has to be rewritten.
|
|
61
|
+
|
|
62
|
+
Keep each field to one or two lines.
|
|
42
63
|
|
|
43
64
|
## Step 2: ship to the toolkit
|
|
44
65
|
|
|
@@ -60,7 +81,7 @@ EOF
|
|
|
60
81
|
|
|
61
82
|
Report the printed path back to the user on its own line, in the form the project's instruction file sets under `## Output`.
|
|
62
83
|
|
|
63
|
-
For a durable, cross-machine report instead of local scratch, add `--github`. The CLI opens a GitHub issue on the toolkit repo and prints the issue URL. It needs `gh` authenticated, and falls back to local scratch with
|
|
84
|
+
For a durable, cross-machine report instead of local scratch, add `--github`. The CLI opens a GitHub issue on the toolkit repo and prints the issue URL. It needs `gh` authenticated, and falls back to local scratch with the reason on stderr when the call produces no URL.
|
|
64
85
|
|
|
65
86
|
```bash
|
|
66
87
|
cat <<'EOF' | canon feedback --github
|
|
@@ -74,6 +95,15 @@ Default to local scratch for a quick note. Use `--github` for a report worth tra
|
|
|
74
95
|
|
|
75
96
|
If `canon` is not on PATH, fall back: print the block in chat and tell the user `📋 Copy the block above into a toolkit-repo session.`
|
|
76
97
|
|
|
98
|
+
## Step 3: read what the CLI said back
|
|
99
|
+
|
|
100
|
+
The command exits 1 and writes nothing on a report it refuses, so a non-zero run is a block to repair rather than a report that shipped. Two refusals reach this step:
|
|
101
|
+
|
|
102
|
+
- A named missing field. Add that heading with a real value or its stated fallback, then re-run. Do not report the defect as filed.
|
|
103
|
+
- `gh` absent or its call failed, on `--github` from a machine holding no toolkit source. The stderr names which, since installing `gh` and fixing an authenticated call are different repairs. Fall back to printing the block in chat.
|
|
104
|
+
|
|
105
|
+
A `--github` run from a toolkit checkout warns and writes local scratch instead, which is a report filed on the other route rather than a failure.
|
|
106
|
+
|
|
77
107
|
## Notes
|
|
78
108
|
|
|
79
109
|
- `canon feedback` resolves the toolkit root from the running `canon` binary's source location. If multiple toolkit clones exist on the machine, the first `canon` on PATH wins.
|
package/docs/agents/capture.md
CHANGED
|
@@ -15,9 +15,9 @@ canon capture assets --selector .window --out .canon/review/captures
|
|
|
15
15
|
|
|
16
16
|
`--selector` is required and every example above passes it. The element a capture crops to belongs to the page, not to the command, so there is no value that could be right for an arbitrary project's markup. `.window` is what this repository's own two sources declare, and a project renders its own pages by naming whatever theirs declare.
|
|
17
17
|
|
|
18
|
-
`assets/` here holds two sources, so one run over the folder rebuilds both.
|
|
18
|
+
`assets/` here holds two sources, so one run over the folder rebuilds both. Neither is edited by hand. `scripts/core/regen-hero.sh` writes both from a template beside it, filling `hero.html.tmpl` from the CLI catalogs and both templates from the design source, and `bun run check` regenerates them and fails on the difference. The terminal text on `install.html` still comes from a real run, and it is the template that holds it.
|
|
19
19
|
|
|
20
|
-
Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets
|
|
20
|
+
Only the HTML is asserted for drift. The PNG is a chromium render whose bytes move with the browser version, so rebuild it with `canon capture assets --selector .window` when the check reports the HTML changed.
|
|
21
21
|
|
|
22
22
|
Every render writes a stamp beside its PNG, `hero.png` next to `hero.stamp`, holding the source filename, a `source-sha256` over the markup bytes it read, and an `image-sha256` over the image bytes it wrote. Both digests are what `bun run check` compares, so a markup edit committed without a capture and a PNG swapped under unchanged markup each fail. The stamp is tracked and commits alongside the pair. Nothing hand-edits it, and a capture that cannot write it reports that source as failed and exits 1, so an image whose stamp never landed is reported rather than passed over.
|
|
23
23
|
|
package/docs/agents/commands.md
CHANGED
|
@@ -28,7 +28,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
28
28
|
| `canon design sync` | Update a base stylesheet already installed under `.claude/design/` |
|
|
29
29
|
| `canon slides render` | Render a `.claude/SLIDES.md` source into a PowerPoint deck, reporting any unrecognized layout name on stderr |
|
|
30
30
|
| `canon slides list` | List the available slide layouts (`--json` for the catalog) |
|
|
31
|
-
| `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github
|
|
31
|
+
| `canon feedback` | Write toolkit feedback from stdin to `.canon/review/feedback/`, or open a GitHub issue with `--github`, refusing either when a required field is absent |
|
|
32
32
|
| `canon transcripts <url>` | Fetch a YouTube transcript with metadata frontmatter (needs `yt-dlp`) |
|
|
33
33
|
| `canon tasks archive` | Move a shipped task and its plan off the board, clear its ordering row, and regenerate the index |
|
|
34
34
|
| `canon tasks pull-request` | Record a pull request number on the task a branch closes, by stem or `--plan` (`--json`) |
|
|
@@ -48,6 +48,7 @@ Full help: `canon <command> --help`. Behavior notes for the install and sync ver
|
|
|
48
48
|
| `canon records pull` | Fetch the records remote and write it back, refusing rather than discarding unpushed records (`--json`) |
|
|
49
49
|
| `canon migrate rename` | Rewrite every unprotected `aitk` token to `canon` and move the paths that carry the name, reporting the plan without `--write` (`--scope`, `--json`) |
|
|
50
50
|
| `canon migrate records` | Move the gitignored session records to `.canon/` and repoint every tracked citation, reporting the plan without `--write` (`--root`, `--json`) |
|
|
51
|
+
| `canon migrate record-tree` | Repoint the old-root citations inside the records themselves, scoped to the live folders and reporting every line without `--write` (`--root`, `--json`) |
|
|
51
52
|
| `canon sessions list` | Resolve live sessions to the worktree and branch each holds, filtered by `--branch` (`--json`) |
|
|
52
53
|
| `canon worktrees list` | Report which worktrees are reclaimable, keyed on the pull request having merged, with every refusal and the removal route named (`--json`) |
|
|
53
54
|
| `canon comments scan` | Measure comment density by language and comment kind, with a trend recomputed from git |
|
|
@@ -146,6 +147,10 @@ Common patterns:
|
|
|
146
147
|
|
|
147
148
|
A record folder already present at the destination is a refusal rather than a merge, and a line carrying `canon-keep-record-root`, or the line below it, keeps the old spelling for prose that dates a decision. The records themselves are never swept: everything under `.canon/` and every `.claude/` record folder is passed over and reported as a count on its own line, which is what keeps the run that follows the ignore collapse touching the same files as one before it. Running it twice rewrites nothing, which is the check that the exclusions, the markers, and that skip all fired.
|
|
148
149
|
|
|
150
|
+
`migrate record-tree` is what reaches the records the sweep above passes over, and the two share no scope. That one enumerates through git, so it sees every tracked file and none of the records, which are gitignored by construction. This one walks `.canon/` itself and is scoped to the folders a session still follows a path into: `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, each minus its own `archive/` subtree. A closed groundwork or intake trail, the scratch folder, and the backup history are reported as counts and never rewritten, because a path inside a closed trail sits in a sentence about work that already ended. Run it after the move, since there is no new root to walk before one.
|
|
151
|
+
|
|
152
|
+
Every citation in scope is reported with its file, its line number, and the line text. The record tree is untracked, so a wrong rewrite has no git undo, and that report is what a reader judges before passing `--write`. Marking a line that has to keep the old spelling uses the same `canon-keep-record-root` comment, and a second run rewriting nothing is the idempotence check.
|
|
153
|
+
|
|
149
154
|
## Version skew
|
|
150
155
|
|
|
151
156
|
`canon sync --check` and `canon claude skills drift` are the two moments a target
|
package/docs/ai-workflow.md
CHANGED
|
@@ -31,7 +31,7 @@ Project docs split across two roots at the project root, on one mechanical line:
|
|
|
31
31
|
└── tmp/ ← deletable scratch, safe to remove without loss
|
|
32
32
|
```
|
|
33
33
|
|
|
34
|
-
A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. `canon migrate records` moves one project across and repoints what cites it.
|
|
34
|
+
A project scaffolded before the move keeps its records under `.claude/`, and every command reads either root. `canon migrate records` moves one project across and repoints what cites it, and `canon migrate record-tree` follows it to reach the citations inside the records themselves, which the first verb passes over because it enumerates through git.
|
|
35
35
|
|
|
36
36
|
Three tiers of context load with different cost: always-loaded (root `CLAUDE.md`, `.claude/REQUIREMENTS.md`, `.claude/ARCHITECTURE.md`), path-scoped lazy (`.claude/rules/<scope>.md` with `paths:` glob), and on-demand lookup (`.claude/context/<domain>.md`, or `.claude/context/<domain>/` once a domain outgrows one file, discovered via `.claude/context/index.md`). See [the context model](../.claude/context/context-model.md) for the full picture.
|
|
37
37
|
|
package/docs/target-projects.md
CHANGED
|
@@ -146,6 +146,17 @@ A tracked file that names an old record path on purpose, such as prose dating a
|
|
|
146
146
|
|
|
147
147
|
Until the move runs, the project is exposed. The shipped ignore set no longer names the old record paths, so a project holding records at `.claude/` stops ignoring them on its next `canon tooling sync`, and the first sign is a memory file or a task board appearing in a commit. Every command reads either root, so nothing else breaks in the meantime, and running the move is what closes it.
|
|
148
148
|
|
|
149
|
+
Then sweep the records themselves, which the three lines above never reach. They enumerate through git, and the records are gitignored by construction, so a task still naming its plan at the old root resolves nothing once the folders move.
|
|
150
|
+
|
|
151
|
+
```bash
|
|
152
|
+
canon migrate record-tree --json
|
|
153
|
+
canon migrate record-tree --write --json
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
This is a separate step rather than a fourth line in the block above because the move has to land before there is a new root to walk. The scope is the folders a session still follows a path into, being `diagrams`, `memory`, `plans`, `proposals`, `review`, `tasks`, and `teach`, each minus its own `archive/` subtree. A closed groundwork or intake trail, the scratch folder, and the backup history are counted and left alone, since a path inside one of those sits in a sentence about work that already ended.
|
|
157
|
+
|
|
158
|
+
Read the report before `--write` here more carefully than above. The record tree is untracked, so a wrong rewrite has no git undo, and the report names every citation with its line number and the line text for exactly that reason. The same `canon-keep-record-root` marker protects a line that has to keep the old spelling.
|
|
159
|
+
|
|
149
160
|
### Check first
|
|
150
161
|
|
|
151
162
|
`canon sync --check <path>` reports what has drifted without writing anything. It splits each difference by cause, which is the question that decides what to do next.
|
package/package.json
CHANGED
|
@@ -1,16 +1,30 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Fills assets
|
|
2
|
+
# Fills every assets/*.html.tmpl from the CLI catalogs and the design source,
|
|
3
|
+
# writing the .html beside each one.
|
|
4
|
+
#
|
|
5
|
+
# The name says hero because the hero was the only template when it was written
|
|
6
|
+
# and every citation of it across docs/, .claude/context/, and src/ spells that
|
|
7
|
+
# name. Renaming the file is a sweep across roughly twenty surfaces for a
|
|
8
|
+
# cosmetic gain, so the file keeps its name and the loop below covers whatever
|
|
9
|
+
# templates the folder holds.
|
|
10
|
+
#
|
|
11
|
+
# The palette arrives the same way the counts do. `canon design css
|
|
12
|
+
# --no-components` emits the custom properties from src/design/tokens.ts and
|
|
13
|
+
# they land on the {{TOKENS}} placeholder, so neither frame carries its own copy
|
|
14
|
+
# of a hex value and a token moved at the source moves both captures. The
|
|
15
|
+
# component half is left out on purpose: it is a scrollbar and a status marker,
|
|
16
|
+
# and a static capture frame renders neither.
|
|
3
17
|
#
|
|
4
18
|
# Only the HTML regenerates here. The PNG beside it is a chromium render whose
|
|
5
19
|
# bytes move with the browser version, so asserting it in verify.sh would fail
|
|
6
20
|
# on a machine whose chromium differs rather than on a stale count. Rebuild the
|
|
7
|
-
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# That capture also writes
|
|
12
|
-
# markup it rendered and is what the Hero stage compares, so
|
|
13
|
-
# commit together. The frame carries no version. `package.json` is bumped on main by the release
|
|
21
|
+
# images with `canon capture assets --selector .window` after this script
|
|
22
|
+
# reports a change. The selector has no default, since the element a capture
|
|
23
|
+
# crops to belongs to the page rather than to the command, and `.window` is the
|
|
24
|
+
# class this repository's own two sources declare.
|
|
25
|
+
# That capture also writes a .stamp beside each PNG, which records the digest of
|
|
26
|
+
# the markup it rendered and is what the Hero stage compares, so a frame's three
|
|
27
|
+
# files commit together. The frame carries no version. `package.json` is bumped on main by the release
|
|
14
28
|
# tooling, so embedding it drifts every open branch on the next release and the
|
|
15
29
|
# stage then fails for work that touched nothing.
|
|
16
30
|
#
|
|
@@ -22,8 +36,7 @@ set -o pipefail
|
|
|
22
36
|
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
23
37
|
PROJECT_ROOT="${PROJECT_ROOT:-$(cd "$SCRIPT_DIR/../.." && pwd)}"
|
|
24
38
|
|
|
25
|
-
|
|
26
|
-
OUTPUT="$PROJECT_ROOT/assets/hero.html"
|
|
39
|
+
ASSET_DIR="$PROJECT_ROOT/assets"
|
|
27
40
|
LISTED=10
|
|
28
41
|
|
|
29
42
|
# `bun src/cli.ts` rather than `canon`, since a globally linked binary resolves to
|
|
@@ -32,8 +45,17 @@ catalog() {
|
|
|
32
45
|
(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts "$@" --json 2>/dev/null)
|
|
33
46
|
}
|
|
34
47
|
|
|
35
|
-
|
|
36
|
-
|
|
48
|
+
TEMPLATES=("$ASSET_DIR"/*.html.tmpl)
|
|
49
|
+
if [ ! -f "${TEMPLATES[0]}" ]; then
|
|
50
|
+
echo "regen-hero: no templates under $ASSET_DIR" >&2
|
|
51
|
+
exit 1
|
|
52
|
+
fi
|
|
53
|
+
|
|
54
|
+
# Not routed through `catalog`, since the design emitter writes CSS on stdout
|
|
55
|
+
# rather than a `--json` record.
|
|
56
|
+
TOKEN_CSS="$(cd "$PROJECT_ROOT" && CANON_NON_INTERACTIVE=1 bun src/cli.ts design css --no-components)"
|
|
57
|
+
if [ -z "$TOKEN_CSS" ]; then
|
|
58
|
+
echo "regen-hero: the design source emitted nothing, refusing to write an unstyled frame" >&2
|
|
37
59
|
exit 1
|
|
38
60
|
fi
|
|
39
61
|
|
|
@@ -73,17 +95,26 @@ printf '%s' "$GOV_JSON" >"$PAYLOAD_DIR/gov.json"
|
|
|
73
95
|
printf '%s' "$STANDARDS_JSON" >"$PAYLOAD_DIR/standards.json"
|
|
74
96
|
printf '%s' "$SNIPPETS_JSON" >"$PAYLOAD_DIR/snippets.json"
|
|
75
97
|
printf '%s' "$TOOLING_JSON" >"$PAYLOAD_DIR/tooling.json"
|
|
98
|
+
printf '%s' "$TOKEN_CSS" >"$PAYLOAD_DIR/tokens.css"
|
|
76
99
|
|
|
77
100
|
export PAYLOAD_DIR
|
|
78
|
-
export
|
|
101
|
+
export ASSET_DIR LISTED PROJECT_ROOT
|
|
79
102
|
|
|
80
103
|
bun --eval '
|
|
81
|
-
const { readFileSync } = require("node:fs")
|
|
104
|
+
const { readFileSync, readdirSync } = require("node:fs")
|
|
82
105
|
|
|
83
|
-
const { PAYLOAD_DIR,
|
|
106
|
+
const { PAYLOAD_DIR, ASSET_DIR, LISTED, PROJECT_ROOT } = process.env
|
|
84
107
|
|
|
85
108
|
const payload = (name) => readFileSync(PAYLOAD_DIR + "/" + name + ".json", "utf8")
|
|
86
109
|
|
|
110
|
+
// Indented to the depth a rule inside the `<style>` block sits at, so the
|
|
111
|
+
// emitted file passes the same formatter every other committed asset does.
|
|
112
|
+
const tokenCss = readFileSync(PAYLOAD_DIR + "/tokens.css", "utf8")
|
|
113
|
+
.trimEnd()
|
|
114
|
+
.split("\n")
|
|
115
|
+
.map((line) => (line === "" ? "" : " " + line))
|
|
116
|
+
.join("\n")
|
|
117
|
+
|
|
87
118
|
const COUNTS_JSON = payload("counts")
|
|
88
119
|
const SKILLS_JSON = payload("skills")
|
|
89
120
|
const GOV_JSON = payload("gov")
|
|
@@ -114,6 +145,12 @@ if (!commandCount) {
|
|
|
114
145
|
process.exit(1)
|
|
115
146
|
}
|
|
116
147
|
|
|
148
|
+
const MARK_SVG = (await Bun.file(`${PROJECT_ROOT}/assets/brand/mark.svg`).text()).trim()
|
|
149
|
+
if (!MARK_SVG) {
|
|
150
|
+
console.error("regen-hero: assets/brand/mark.svg read empty, refusing to write a hero with no mark")
|
|
151
|
+
process.exit(1)
|
|
152
|
+
}
|
|
153
|
+
|
|
117
154
|
const escape = (value) =>
|
|
118
155
|
value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")
|
|
119
156
|
|
|
@@ -190,6 +227,7 @@ for (const [label, list] of [
|
|
|
190
227
|
}
|
|
191
228
|
|
|
192
229
|
const values = {
|
|
230
|
+
TOKENS: tokenCss,
|
|
193
231
|
SKILL_COUNT: String(skills.length),
|
|
194
232
|
RULE_COUNT: String(rules.length),
|
|
195
233
|
STANDARD_COUNT: String(standards.length),
|
|
@@ -197,6 +235,7 @@ const values = {
|
|
|
197
235
|
GOV_STACK_COUNT: String(gov.stacks.length),
|
|
198
236
|
TOOLING_STACK_COUNT: String(toolingStacks.length),
|
|
199
237
|
COMMAND_COUNT: String(commandCount),
|
|
238
|
+
MARK_SVG,
|
|
200
239
|
SKILL_ENTRIES: markup(featured(skills, FEATURED_SKILLS)),
|
|
201
240
|
RULE_ENTRIES: entries(deliveredRules),
|
|
202
241
|
STANDARD_ENTRIES: entries(standards),
|
|
@@ -205,16 +244,30 @@ const values = {
|
|
|
205
244
|
STANDARD_MORE: remaining(standards),
|
|
206
245
|
}
|
|
207
246
|
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
247
|
+
// Every template takes the same value map, so one carrying no count placeholder
|
|
248
|
+
// simply resolves none of them. What a template must not do is name a
|
|
249
|
+
// placeholder nobody fills, which the unresolved check below catches per file.
|
|
250
|
+
const templates = readdirSync(ASSET_DIR)
|
|
251
|
+
.filter((name) => name.endsWith(".html.tmpl"))
|
|
252
|
+
.sort()
|
|
212
253
|
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
console.error(`regen-hero: unresolved placeholders ${[...new Set(unresolved)].join(", ")}`)
|
|
254
|
+
if (templates.length === 0) {
|
|
255
|
+
console.error(`regen-hero: no templates under ${ASSET_DIR}`)
|
|
216
256
|
process.exit(1)
|
|
217
257
|
}
|
|
218
258
|
|
|
219
|
-
|
|
259
|
+
for (const template of templates) {
|
|
260
|
+
let html = await Bun.file(ASSET_DIR + "/" + template).text()
|
|
261
|
+
for (const [key, value] of Object.entries(values)) {
|
|
262
|
+
html = html.replaceAll(`{{${key}}}`, value)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const unresolved = html.match(/{{[A-Z_]+}}/g)
|
|
266
|
+
if (unresolved) {
|
|
267
|
+
console.error(`regen-hero: ${template} carries unresolved placeholders ${[...new Set(unresolved)].join(", ")}`)
|
|
268
|
+
process.exit(1)
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
await Bun.write(ASSET_DIR + "/" + template.replace(/\.tmpl$/, ""), html)
|
|
272
|
+
}
|
|
220
273
|
'
|
|
@@ -2,8 +2,81 @@ const SLUG_FALLBACK = 'general'
|
|
|
2
2
|
const SLUG_MAX_LENGTH = 40
|
|
3
3
|
const TITLE_MAX_LENGTH = 72
|
|
4
4
|
|
|
5
|
+
/**
|
|
6
|
+
* The one place the report's field list is spelled. The GitHub issue form is
|
|
7
|
+
* the other writer of this shape and enforces nothing a producer passes
|
|
8
|
+
* through, so validation sits here, on the path every filed report crosses.
|
|
9
|
+
*/
|
|
10
|
+
export const REQUIRED_FIELDS = ['Surface', 'Observed', 'Proposed fix'] as const
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Fields are `###` headings under the report's own `## Toolkit feedback` title,
|
|
14
|
+
* because that is the level a GitHub issue form emits for a field label. One
|
|
15
|
+
* parser therefore reads a report the CLI wrote and one a person submitted
|
|
16
|
+
* through the web form.
|
|
17
|
+
*
|
|
18
|
+
* The retired `**Surface:**` bold form is not accepted. Reading both would
|
|
19
|
+
* leave a producer free to keep emitting the shape this parser exists to
|
|
20
|
+
* retire, and the validator would then enforce nothing.
|
|
21
|
+
*/
|
|
22
|
+
const FIELD_HEADING = /^###\s+(.+?)\s*$/
|
|
23
|
+
const FENCE = /^\s*(```|~~~)/
|
|
24
|
+
|
|
25
|
+
export function parseSections(body: string): Map<string, string> {
|
|
26
|
+
const sections = new Map<string, string>()
|
|
27
|
+
let heading: string | undefined
|
|
28
|
+
let lines: string[] = []
|
|
29
|
+
let fenced = false
|
|
30
|
+
|
|
31
|
+
const flush = (): void => {
|
|
32
|
+
if (heading === undefined) return
|
|
33
|
+
sections.set(heading.toLowerCase(), lines.join('\n').trim())
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
for (const line of body.split('\n')) {
|
|
37
|
+
// A Repro field routinely carries a fenced block, and a `###` comment
|
|
38
|
+
// inside one is shell rather than the next field. Reading it as a heading
|
|
39
|
+
// splits the section and drops everything under it.
|
|
40
|
+
if (FENCE.test(line)) fenced = !fenced
|
|
41
|
+
const match = fenced ? null : line.match(FIELD_HEADING)
|
|
42
|
+
if (match?.[1] === undefined) {
|
|
43
|
+
lines.push(line)
|
|
44
|
+
continue
|
|
45
|
+
}
|
|
46
|
+
flush()
|
|
47
|
+
heading = match[1]
|
|
48
|
+
lines = []
|
|
49
|
+
}
|
|
50
|
+
flush()
|
|
51
|
+
|
|
52
|
+
return sections
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function readField(body: string, field: string): string | undefined {
|
|
56
|
+
const value = parseSections(body).get(field.toLowerCase())
|
|
57
|
+
return value ? value : undefined
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* The first required field the report does not carry, or `undefined` when it
|
|
62
|
+
* carries them all. One name rather than a list, because a reporter repairs one
|
|
63
|
+
* field at a time and the next run names the next gap.
|
|
64
|
+
*/
|
|
65
|
+
export function missingField(body: string): string | undefined {
|
|
66
|
+
const sections = parseSections(body)
|
|
67
|
+
for (const field of REQUIRED_FIELDS) {
|
|
68
|
+
const value = sections.get(field.toLowerCase())
|
|
69
|
+
if (!value) return field
|
|
70
|
+
}
|
|
71
|
+
return undefined
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function missingFieldMessage(field: string): string {
|
|
75
|
+
return `Feedback report is missing its "### ${field}" section. Every report needs ${REQUIRED_FIELDS.map((name) => `### ${name}`).join(', ')}.`
|
|
76
|
+
}
|
|
77
|
+
|
|
5
78
|
function surfaceField(body: string): string | undefined {
|
|
6
|
-
return body
|
|
79
|
+
return readField(body, 'Surface')?.split('\n')[0]?.trim()
|
|
7
80
|
}
|
|
8
81
|
|
|
9
82
|
export function deriveSlug(body: string): string {
|
package/src/commands/feedback.ts
CHANGED
|
@@ -1,10 +1,16 @@
|
|
|
1
1
|
import { existsSync, mkdirSync, writeFileSync } from 'node:fs'
|
|
2
2
|
import { join } from 'node:path'
|
|
3
3
|
import type { Command } from 'commander'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
deriveSlug,
|
|
6
|
+
deriveTitle,
|
|
7
|
+
missingField,
|
|
8
|
+
missingFieldMessage,
|
|
9
|
+
} from '@/commands/feedback-format'
|
|
5
10
|
import { PROJECT_ROOT } from '@/project-root'
|
|
6
11
|
import { creationRel } from '@/record-root'
|
|
7
12
|
import { createGithubIssue } from '@/github'
|
|
13
|
+
import { issueFailureMessage } from '@/github-format'
|
|
8
14
|
import { frameError, frameSuccess, palette } from '@/ui'
|
|
9
15
|
|
|
10
16
|
function readStdin(): Promise<string> {
|
|
@@ -78,27 +84,35 @@ export function register(program: Command): void {
|
|
|
78
84
|
return
|
|
79
85
|
}
|
|
80
86
|
|
|
87
|
+
// Both write paths, not `--github` alone. A report missing its surface is
|
|
88
|
+
// no more useful sitting in the triage queue on disk than in the tracker.
|
|
89
|
+
const absent = missingField(body)
|
|
90
|
+
if (absent) {
|
|
91
|
+
frameError(missingFieldMessage(absent))
|
|
92
|
+
process.exitCode = 1
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
|
|
81
96
|
if (opts.github) {
|
|
82
|
-
const
|
|
97
|
+
const result = await createGithubIssue({
|
|
83
98
|
title: deriveTitle(body),
|
|
84
99
|
body,
|
|
85
100
|
labels: ['feedback'],
|
|
86
101
|
})
|
|
87
|
-
if (
|
|
88
|
-
frameSuccess('canon feedback', url)
|
|
89
|
-
process.stdout.write(`${url}\n`)
|
|
102
|
+
if (result.ok) {
|
|
103
|
+
frameSuccess('canon feedback', result.url)
|
|
104
|
+
process.stdout.write(`${result.url}\n`)
|
|
90
105
|
return
|
|
91
106
|
}
|
|
107
|
+
const reason = issueFailureMessage(result)
|
|
92
108
|
if (!isToolkitSource()) {
|
|
93
|
-
frameError(
|
|
94
|
-
'gh unavailable and no toolkit source to fall back to. Install gh, or file it at https://github.com/erclx/canon/issues/new',
|
|
95
|
-
)
|
|
109
|
+
frameError(`${reason} No toolkit source to fall back to.`)
|
|
96
110
|
process.exitCode = 1
|
|
97
111
|
return
|
|
98
112
|
}
|
|
99
113
|
const { NC, YELLOW } = palette(process.stderr)
|
|
100
114
|
process.stderr.write(
|
|
101
|
-
`${YELLOW}!
|
|
115
|
+
`${YELLOW}! ${reason} Wrote local scratch instead.${NC}\n`,
|
|
102
116
|
)
|
|
103
117
|
}
|
|
104
118
|
|