@erclx/canon 4.114.0 → 4.116.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/document-health/references/axes.md +1 -1
- package/claude/skills/target-setup/SKILL.md +1 -1
- package/claude/skills/target-setup/references/detect.md +43 -9
- package/claude/skills/teach-workspace/SKILL.md +10 -6
- package/claude/skills/teach-workspace/references/lesson-craft.md +7 -3
- package/docs/agents/audits.md +4 -2
- package/docs/agents/commands.md +4 -2
- package/docs/agents/context-audit-checks.md +6 -6
- package/docs/agents/context-audit.md +1 -1
- package/docs/agents/context-classify.md +1 -1
- package/docs/agents/index.md +1 -1
- package/docs/agents/sandbox.md +1 -1
- package/docs/agents/sessions.md +84 -1
- package/docs/agents/teach.md +26 -4
- package/docs/target-projects.md +4 -2
- package/package.json +1 -1
- package/src/audits/catalog.ts +52 -0
- package/src/commands/context.ts +30 -10
- package/src/commands/sessions.ts +222 -0
- package/src/commands/teach.ts +22 -4
- package/src/context/audit.ts +54 -18
- package/src/records/backup.ts +1 -1
- package/src/sandbox/census.ts +1 -1
- package/src/sessions/registry.ts +11 -7
- package/src/sessions/transfer/bundle.ts +546 -0
- package/src/sessions/transfer/transcript.ts +148 -0
- package/src/teach/components/paragraph.tsx +19 -2
- package/src/teach/components/refs.tsx +28 -0
- package/src/teach/cross-references.ts +78 -0
- package/src/teach/lesson.ts +1 -1
- package/src/teach/nav.ts +278 -16
- package/src/teach/render.ts +94 -6
- package/standards/teach.md +13 -12
- package/tooling/go/configs/.golangci.yml +25 -0
- package/tooling/go/configs/scripts/verify.sh +95 -0
- package/tooling/go/manifest.toml +13 -0
- package/tooling/go/reference.md +60 -0
- package/tooling/go/seeds/.cspell/tech-stack.txt +16 -0
- package/tooling/go/seeds/smoke_test.go +9 -0
- package/tooling/php/configs/.php-cs-fixer.dist.php +18 -0
- package/tooling/php/configs/phpstan.neon +5 -0
- package/tooling/php/configs/phpunit.xml +18 -0
- package/tooling/php/configs/scripts/verify.sh +100 -0
- package/tooling/php/manifest.toml +15 -0
- package/tooling/php/reference.md +74 -0
- package/tooling/php/seeds/.cspell/tech-stack.txt +5 -0
- package/tooling/php/seeds/tests/SmokeTest.php +13 -0
package/src/teach/render.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { Heading } from '@/teach/components/heading'
|
|
2
2
|
import { List } from '@/teach/components/list'
|
|
3
3
|
import { Paragraph } from '@/teach/components/paragraph'
|
|
4
|
+
import { type Reference, Refs } from '@/teach/components/refs'
|
|
4
5
|
import { render } from '@/teach/html/jsx-runtime'
|
|
5
6
|
import type { TeachRefused } from '@/teach/workspace'
|
|
6
7
|
|
|
@@ -10,12 +11,14 @@ export type LessonBlock =
|
|
|
10
11
|
readonly type: 'paragraph'
|
|
11
12
|
readonly text: string
|
|
12
13
|
readonly lede?: boolean
|
|
14
|
+
readonly cites?: readonly number[]
|
|
13
15
|
}
|
|
14
16
|
| {
|
|
15
17
|
readonly type: 'list'
|
|
16
18
|
readonly items: readonly string[]
|
|
17
19
|
readonly ordered?: boolean
|
|
18
20
|
}
|
|
21
|
+
| { readonly type: 'refs'; readonly items: readonly Reference[] }
|
|
19
22
|
| { readonly type: 'raw'; readonly html: string }
|
|
20
23
|
|
|
21
24
|
export interface RenderRendered {
|
|
@@ -25,7 +28,9 @@ export interface RenderRendered {
|
|
|
25
28
|
|
|
26
29
|
export type RenderOutcome = RenderRendered | TeachRefused
|
|
27
30
|
|
|
28
|
-
const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'raw'] as const
|
|
31
|
+
const BLOCK_TYPES = ['heading', 'paragraph', 'list', 'refs', 'raw'] as const
|
|
32
|
+
|
|
33
|
+
const LINK_PROTOCOLS = new Set(['http:', 'https:'])
|
|
29
34
|
|
|
30
35
|
function isBlockType(value: unknown): value is LessonBlock['type'] {
|
|
31
36
|
return (BLOCK_TYPES as readonly string[]).includes(value as string)
|
|
@@ -48,10 +53,46 @@ function isStringArray(value: unknown): value is readonly string[] {
|
|
|
48
53
|
return Array.isArray(value) && value.every(isString)
|
|
49
54
|
}
|
|
50
55
|
|
|
56
|
+
function isCiteArray(value: unknown): value is readonly number[] {
|
|
57
|
+
return (
|
|
58
|
+
Array.isArray(value) &&
|
|
59
|
+
value.length > 0 &&
|
|
60
|
+
value.every((cite) => Number.isInteger(cite) && cite > 0)
|
|
61
|
+
)
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function isLinkUrl(value: string): boolean {
|
|
65
|
+
if (!URL.canParse(value)) return false
|
|
66
|
+
return LINK_PROTOCOLS.has(new URL(value).protocol)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function readReference(item: unknown, position: number): Reference | string {
|
|
70
|
+
if (typeof item !== 'object' || item === null) {
|
|
71
|
+
return `reference ${position} is not an object`
|
|
72
|
+
}
|
|
73
|
+
const { title, url, note } = item as Record<string, unknown>
|
|
74
|
+
if (!isString(title) || title === '') {
|
|
75
|
+
return `reference ${position} needs a non-empty string title`
|
|
76
|
+
}
|
|
77
|
+
if (note !== undefined && !isString(note)) {
|
|
78
|
+
return `reference ${position} note must be a string`
|
|
79
|
+
}
|
|
80
|
+
if (url !== undefined && !(isString(url) && isLinkUrl(url))) {
|
|
81
|
+
return `reference ${position} url must be an http or https URL`
|
|
82
|
+
}
|
|
83
|
+
return { title, url, note }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
interface RenderedBlock {
|
|
87
|
+
readonly html: string
|
|
88
|
+
readonly cites?: readonly number[]
|
|
89
|
+
readonly referenceCount?: number
|
|
90
|
+
}
|
|
91
|
+
|
|
51
92
|
function renderBlock(
|
|
52
93
|
block: unknown,
|
|
53
94
|
index: number,
|
|
54
|
-
):
|
|
95
|
+
): RenderedBlock | TeachRefused {
|
|
55
96
|
if (typeof block !== 'object' || block === null || !('type' in block)) {
|
|
56
97
|
return badBlock(index, 'not an object carrying a type')
|
|
57
98
|
}
|
|
@@ -83,8 +124,24 @@ function renderBlock(
|
|
|
83
124
|
if (fields.lede !== undefined && typeof fields.lede !== 'boolean') {
|
|
84
125
|
return badBlock(index, 'paragraph lede must be a boolean')
|
|
85
126
|
}
|
|
127
|
+
if (fields.cites !== undefined && !isCiteArray(fields.cites)) {
|
|
128
|
+
return badBlock(
|
|
129
|
+
index,
|
|
130
|
+
'paragraph cites must be a non-empty array of positive integers',
|
|
131
|
+
)
|
|
132
|
+
}
|
|
133
|
+
if (fields.lede === true && fields.cites !== undefined) {
|
|
134
|
+
return badBlock(index, 'a lede paragraph cannot carry cites')
|
|
135
|
+
}
|
|
86
136
|
return {
|
|
87
|
-
html: render(
|
|
137
|
+
html: render(
|
|
138
|
+
Paragraph({
|
|
139
|
+
lede: fields.lede,
|
|
140
|
+
cites: fields.cites,
|
|
141
|
+
children: fields.text,
|
|
142
|
+
}),
|
|
143
|
+
),
|
|
144
|
+
cites: fields.cites,
|
|
88
145
|
}
|
|
89
146
|
}
|
|
90
147
|
case 'list': {
|
|
@@ -98,6 +155,21 @@ function renderBlock(
|
|
|
98
155
|
html: render(List({ ordered: fields.ordered, items: fields.items })),
|
|
99
156
|
}
|
|
100
157
|
}
|
|
158
|
+
case 'refs': {
|
|
159
|
+
if (!Array.isArray(fields.items) || fields.items.length === 0) {
|
|
160
|
+
return badBlock(index, 'refs needs a non-empty items array')
|
|
161
|
+
}
|
|
162
|
+
const items: Reference[] = []
|
|
163
|
+
for (const [position, item] of fields.items.entries()) {
|
|
164
|
+
const reference = readReference(item, position + 1)
|
|
165
|
+
if (isString(reference)) return badBlock(index, reference)
|
|
166
|
+
items.push(reference)
|
|
167
|
+
}
|
|
168
|
+
return {
|
|
169
|
+
html: render(Refs({ items })),
|
|
170
|
+
referenceCount: items.length,
|
|
171
|
+
}
|
|
172
|
+
}
|
|
101
173
|
case 'raw': {
|
|
102
174
|
if (!isString(fields.html)) {
|
|
103
175
|
return badBlock(index, 'raw needs a string html')
|
|
@@ -112,15 +184,31 @@ function renderBlock(
|
|
|
112
184
|
* `lesson.test.tsx` composes as JSX, so a real lesson and the component test
|
|
113
185
|
* share one rendering mechanism. Every block past the first refusal
|
|
114
186
|
* goes unread, matching how a malformed JSON parse refuses the whole call.
|
|
187
|
+
* Citations resolve across blocks once every block has rendered, so a marker
|
|
188
|
+
* with no reference refuses rather than leaving a dead anchor.
|
|
115
189
|
*/
|
|
116
190
|
export function renderLessonBody(blocks: readonly unknown[]): RenderOutcome {
|
|
117
|
-
const rendered:
|
|
191
|
+
const rendered: RenderedBlock[] = []
|
|
192
|
+
let referenceCount = 0
|
|
118
193
|
|
|
119
194
|
for (const [index, block] of blocks.entries()) {
|
|
120
195
|
const result = renderBlock(block, index)
|
|
121
196
|
if ('ok' in result) return result
|
|
122
|
-
|
|
197
|
+
if (result.referenceCount !== undefined) {
|
|
198
|
+
if (referenceCount > 0) {
|
|
199
|
+
return badBlock(index, 'a lesson carries one refs block')
|
|
200
|
+
}
|
|
201
|
+
referenceCount = result.referenceCount
|
|
202
|
+
}
|
|
203
|
+
rendered.push(result)
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
for (const [index, block] of rendered.entries()) {
|
|
207
|
+
const dangling = block.cites?.find((cite) => cite > referenceCount)
|
|
208
|
+
if (dangling !== undefined) {
|
|
209
|
+
return badBlock(index, `cite ${dangling} resolves to no reference`)
|
|
210
|
+
}
|
|
123
211
|
}
|
|
124
212
|
|
|
125
|
-
return { ok: true, html: rendered.join('\n') }
|
|
213
|
+
return { ok: true, html: rendered.map((block) => block.html).join('\n') }
|
|
126
214
|
}
|
package/standards/teach.md
CHANGED
|
@@ -44,18 +44,19 @@ A workspace failing these is non-conforming even when it satisfies every shape r
|
|
|
44
44
|
|
|
45
45
|
## Layout
|
|
46
46
|
|
|
47
|
-
| Path
|
|
48
|
-
|
|
|
49
|
-
| `MISSION.md`
|
|
50
|
-
| `RESOURCES.md`
|
|
51
|
-
| `GLOSSARY.md`
|
|
52
|
-
| `NOTES.md`
|
|
53
|
-
| `reference/<slug>.md`
|
|
54
|
-
| `
|
|
55
|
-
| `
|
|
56
|
-
| `
|
|
57
|
-
|
|
58
|
-
|
|
47
|
+
| Path | Holds | Required |
|
|
48
|
+
| ----------------------- | ----------------------------------------------------------------------- | -------- |
|
|
49
|
+
| `MISSION.md` | The subject, the learner's starting point, and success | Always |
|
|
50
|
+
| `RESOURCES.md` | Sources read and sources found but not opened | Always |
|
|
51
|
+
| `GLOSSARY.md` | Terms the subject defines, one entry each | Always |
|
|
52
|
+
| `NOTES.md` | Session scratch that belongs to no other file | Optional |
|
|
53
|
+
| `reference/<slug>.md` | Durable reference pages, the promotable half | Optional |
|
|
54
|
+
| `reference/<slug>.html` | Each reference page rendered by `canon teach nav`, never edited by hand | Optional |
|
|
55
|
+
| `learning-records/` | Numbered records of what the learner was taken through | Optional |
|
|
56
|
+
| `lessons/` | Numbered lessons, generated and disposable | Optional |
|
|
57
|
+
| `assets/` | Files several lessons share, including the stylesheet | Optional |
|
|
58
|
+
|
|
59
|
+
Only the markdown half answers to a conformance check: `MISSION.md`, `RESOURCES.md`, `GLOSSARY.md`, `NOTES.md`, the markdown under `reference/`, and `learning-records/`. A lesson is generated markup that nothing downstream cites, so this standard fixes its filename and its location and says nothing about what it contains. A rendered reference page is the same kind of output, rewritten from its markdown on every `nav` run.
|
|
59
60
|
|
|
60
61
|
## Frontmatter
|
|
61
62
|
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
version: '2'
|
|
2
|
+
|
|
3
|
+
linters:
|
|
4
|
+
default: standard
|
|
5
|
+
enable:
|
|
6
|
+
- bodyclose
|
|
7
|
+
- errorlint
|
|
8
|
+
- gosec
|
|
9
|
+
- misspell
|
|
10
|
+
- noctx
|
|
11
|
+
- revive
|
|
12
|
+
- sqlclosecheck
|
|
13
|
+
- rowserrcheck
|
|
14
|
+
- unconvert
|
|
15
|
+
exclusions:
|
|
16
|
+
paths:
|
|
17
|
+
- node_modules
|
|
18
|
+
|
|
19
|
+
formatters:
|
|
20
|
+
enable:
|
|
21
|
+
- gofmt
|
|
22
|
+
- goimports
|
|
23
|
+
exclusions:
|
|
24
|
+
paths:
|
|
25
|
+
- node_modules
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
set -o pipefail
|
|
4
|
+
|
|
5
|
+
GREEN='\033[0;32m'
|
|
6
|
+
RED='\033[0;31m'
|
|
7
|
+
WHITE='\033[1;37m'
|
|
8
|
+
GREY='\033[0;90m'
|
|
9
|
+
NC='\033[0m'
|
|
10
|
+
|
|
11
|
+
NESTED="${VERIFY_NESTED:-false}"
|
|
12
|
+
|
|
13
|
+
log_info() { echo -e "${GREY}│${NC} ${GREEN}✓${NC} $1"; }
|
|
14
|
+
log_error() {
|
|
15
|
+
echo -e "${GREY}│${NC} ${RED}✗${NC} $1"
|
|
16
|
+
exit 1
|
|
17
|
+
}
|
|
18
|
+
log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
|
|
19
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
20
|
+
|
|
21
|
+
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
22
|
+
|
|
23
|
+
check_dependencies() {
|
|
24
|
+
command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
|
|
25
|
+
command -v go >/dev/null 2>&1 || log_error "go is not installed"
|
|
26
|
+
command -v golangci-lint >/dev/null 2>&1 || log_error "golangci-lint is not installed. Run 'go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest'"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
check_package() {
|
|
30
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
has_script() {
|
|
34
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
38
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
39
|
+
# declared, so this script passes on its own in that subfolder.
|
|
40
|
+
run_base_phase() {
|
|
41
|
+
local title=$1
|
|
42
|
+
local script=$2
|
|
43
|
+
local err_msg=$3
|
|
44
|
+
local pass_msg=$4
|
|
45
|
+
|
|
46
|
+
log_step "$title"
|
|
47
|
+
if ! has_script "$script"; then
|
|
48
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
49
|
+
return
|
|
50
|
+
fi
|
|
51
|
+
run_check "bun run $script" "$err_msg"
|
|
52
|
+
log_info "$pass_msg"
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
run_check() {
|
|
56
|
+
local cmd=$1
|
|
57
|
+
local err_msg=$2
|
|
58
|
+
local output
|
|
59
|
+
if ! output=$(eval "$cmd" 2>&1); then
|
|
60
|
+
echo "$output" | pipe_output
|
|
61
|
+
log_error "$err_msg"
|
|
62
|
+
fi
|
|
63
|
+
echo "$output" | pipe_output
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
main() {
|
|
67
|
+
check_dependencies
|
|
68
|
+
check_package
|
|
69
|
+
|
|
70
|
+
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
71
|
+
|
|
72
|
+
echo -e "${GREY}├${NC} ${WHITE}Typecheck${NC}"
|
|
73
|
+
run_check "bun run typecheck" "Typecheck failed"
|
|
74
|
+
log_info "Typecheck passed"
|
|
75
|
+
|
|
76
|
+
log_step "Lint"
|
|
77
|
+
run_check "bun run lint" "Lint failed"
|
|
78
|
+
log_info "Lint passed"
|
|
79
|
+
|
|
80
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
81
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
82
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
83
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
84
|
+
|
|
85
|
+
log_step "Unit tests"
|
|
86
|
+
run_check "bun run test:run" "Unit tests failed"
|
|
87
|
+
log_info "Unit tests passed"
|
|
88
|
+
|
|
89
|
+
if [ "$NESTED" = false ]; then
|
|
90
|
+
echo -e "${GREY}└${NC}\n"
|
|
91
|
+
echo -e "${GREEN}✓ Verification passed${NC}"
|
|
92
|
+
fi
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
main "$@"
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
[stack]
|
|
2
|
+
name = "go"
|
|
3
|
+
extends = "base"
|
|
4
|
+
runtime = "go"
|
|
5
|
+
scaffold = "mkdir {{name}} && cd {{name}} && go mod init example.com/{{name}} && go mod edit -ignore=./node_modules"
|
|
6
|
+
|
|
7
|
+
[scripts]
|
|
8
|
+
"lint" = "golangci-lint run ./..."
|
|
9
|
+
"lint:fix" = "golangci-lint fmt ./... && golangci-lint run --fix ./..."
|
|
10
|
+
"typecheck" = "go vet ./..."
|
|
11
|
+
"test" = "go test ./..."
|
|
12
|
+
"test:run" = "go test -v ./..."
|
|
13
|
+
"build" = "go build ./..."
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
# Tooling Go reference
|
|
2
|
+
|
|
3
|
+
> Extends: `base`. Apply base stack first.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The go stack covers Go modules linted and formatted by `golangci-lint` v2, type-checked by `go vet`, and tested by `go test`. It ships a golden `.golangci.yml`, a Go-aware `verify.sh`, and a copy-once smoke test.
|
|
8
|
+
|
|
9
|
+
## Scaffold checklist
|
|
10
|
+
|
|
11
|
+
1. Scaffold with `go mod init <module-path>`. The module path is the project's own identity, so this stack never ships a `go.mod`.
|
|
12
|
+
2. Keep `./...` out of `node_modules`: `go mod edit -ignore=./node_modules`. At a root sync the base layer installs `node_modules/` inside the module, and a package there can ship a `.go` file that `go vet`, `go test`, and `golangci-lint` would otherwise walk. The directive needs Go 1.25 or later.
|
|
13
|
+
3. Seed `package.json` so the base layer's bun-side tools (husky, prettier, cspell, commitlint) have a target to install into: `bun init -y`.
|
|
14
|
+
4. Install base tooling: `canon tooling sync base . --write`
|
|
15
|
+
5. Install go tooling: `canon tooling sync go . --write`
|
|
16
|
+
6. Install the linter: `go install github.com/golangci/golangci-lint/v2/cmd/golangci-lint@latest`. The manifest declares no `[dependencies.dev]`, because manifest injection runs `bun add -D`, which cannot install a Go tool. This step stays manual.
|
|
17
|
+
7. Run `bun run lint:fix` then `bun run check`.
|
|
18
|
+
|
|
19
|
+
The go stack also ships `smoke_test.go` at the module root as a copy-once seed. A module with no `.go` file fails `golangci-lint run`, `go vet`, and `go test` on an empty package list, so the seed is what lets `bun run check` pass on a fresh scaffold. It declares `package main`, so delete or rename it once the root package takes another name, or the build reports two packages in one folder.
|
|
20
|
+
|
|
21
|
+
## golangci-lint
|
|
22
|
+
|
|
23
|
+
- Use config `version: '2'`.
|
|
24
|
+
- Start from `default: standard`.
|
|
25
|
+
- Enable `bodyclose`, `errorlint`, `gosec`, `misspell`, `noctx`, `revive`, `sqlclosecheck`, `rowserrcheck`, and `unconvert`. The two SQL linters report only on `database/sql` call sites, so they cost nothing in a module without a database.
|
|
26
|
+
- Enable formatters `gofmt` and `goimports`. `lint:fix` runs `golangci-lint fmt` ahead of `golangci-lint run --fix`.
|
|
27
|
+
- Exclude `node_modules` from both linters and formatters. The `go.mod` `ignore` directive in the checklist covers every Go tool, but `go.mod` belongs to the project and sync cannot write it, so the config keeps `golangci-lint run` from reporting and `golangci-lint fmt` from rewriting a vendored `.go` file in a module that never added the directive.
|
|
28
|
+
|
|
29
|
+
## verify.sh
|
|
30
|
+
|
|
31
|
+
- Run Typecheck (`go vet`), Lint (`golangci-lint run`), and Unit tests (`go test -v`), plus the four base phases wherever the folder declares their scripts.
|
|
32
|
+
- Keep `build` out of `verify.sh`. `go build ./...` exits 1 with `no packages to build` on a module holding only a test file, and a seeded `main.go` would assume every module is a binary rather than a library.
|
|
33
|
+
- Keep `-race` out of `test:run`. The race detector needs cgo and a C toolchain, so it belongs in CI rather than in a local `bun run check`.
|
|
34
|
+
|
|
35
|
+
## CI
|
|
36
|
+
|
|
37
|
+
- Install Go with `actions/setup-go`, reading the version from `go.mod` through `go-version-file`.
|
|
38
|
+
- Install the linter with the same `go install` line as the scaffold checklist.
|
|
39
|
+
- Run `go test -race ./...` in CI.
|
|
40
|
+
|
|
41
|
+
## Anti-patterns
|
|
42
|
+
|
|
43
|
+
- Do not ship `go.mod` or `go.sum` from tooling. Both belong to the project, and a golden copy would overwrite its module path on every sync.
|
|
44
|
+
- Do not pin the linter through a `tools.go` file or a `tool` directive in `go.mod`. The module is project-owned, so the install stays in the checklist.
|
|
45
|
+
- Do not ship a `Dockerfile`, `.dockerignore`, or `compose.yaml` under tooling. Those are deployment choices and belong in the project itself.
|
|
46
|
+
|
|
47
|
+
## Development docs (extend)
|
|
48
|
+
|
|
49
|
+
Append to the `## Scripts` table:
|
|
50
|
+
|
|
51
|
+
| `bun run lint` | Run `golangci-lint run`. |
|
|
52
|
+
| `bun run lint:fix` | Format with `golangci-lint fmt`, then auto-fix with `golangci-lint run --fix`. |
|
|
53
|
+
| `bun run typecheck` | Run `go vet`. |
|
|
54
|
+
| `bun run test` | Run `go test`. |
|
|
55
|
+
| `bun run test:run` | Run `go test -v` (used by `verify`). |
|
|
56
|
+
| `bun run build` | Run `go build`. |
|
|
57
|
+
|
|
58
|
+
## CI docs (extend)
|
|
59
|
+
|
|
60
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `go vet ./...` passes ``. Add a Lint row whose assertion reads: `` `golangci-lint run` passes ``. Add a Tests row whose assertion reads: `` `go test -race ./...` exits 0 ``.
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<?php
|
|
2
|
+
|
|
3
|
+
declare(strict_types=1);
|
|
4
|
+
|
|
5
|
+
$finder = (new PhpCsFixer\Finder())
|
|
6
|
+
->in([__DIR__ . '/src', __DIR__ . '/tests']);
|
|
7
|
+
|
|
8
|
+
return (new PhpCsFixer\Config())
|
|
9
|
+
->setRiskyAllowed(true)
|
|
10
|
+
->setRules([
|
|
11
|
+
'@PER-CS' => true,
|
|
12
|
+
'declare_strict_types' => true,
|
|
13
|
+
'strict_comparison' => true,
|
|
14
|
+
'no_unused_imports' => true,
|
|
15
|
+
'ordered_imports' => true,
|
|
16
|
+
])
|
|
17
|
+
->setFinder($finder)
|
|
18
|
+
->setCacheFile(__DIR__ . '/.php-cs-fixer.cache');
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<phpunit xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
|
3
|
+
xsi:noNamespaceSchemaLocation="vendor/phpunit/phpunit/phpunit.xsd"
|
|
4
|
+
bootstrap="vendor/autoload.php"
|
|
5
|
+
colors="true"
|
|
6
|
+
failOnWarning="true"
|
|
7
|
+
failOnRisky="true">
|
|
8
|
+
<testsuites>
|
|
9
|
+
<testsuite name="unit">
|
|
10
|
+
<directory>tests</directory>
|
|
11
|
+
</testsuite>
|
|
12
|
+
</testsuites>
|
|
13
|
+
<source>
|
|
14
|
+
<include>
|
|
15
|
+
<directory>src</directory>
|
|
16
|
+
</include>
|
|
17
|
+
</source>
|
|
18
|
+
</phpunit>
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
set -e
|
|
3
|
+
set -o pipefail
|
|
4
|
+
|
|
5
|
+
GREEN='\033[0;32m'
|
|
6
|
+
RED='\033[0;31m'
|
|
7
|
+
WHITE='\033[1;37m'
|
|
8
|
+
GREY='\033[0;90m'
|
|
9
|
+
NC='\033[0m'
|
|
10
|
+
|
|
11
|
+
NESTED="${VERIFY_NESTED:-false}"
|
|
12
|
+
|
|
13
|
+
log_info() { echo -e "${GREY}│${NC} ${GREEN}✓${NC} $1"; }
|
|
14
|
+
log_error() {
|
|
15
|
+
echo -e "${GREY}│${NC} ${RED}✗${NC} $1"
|
|
16
|
+
exit 1
|
|
17
|
+
}
|
|
18
|
+
log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
|
|
19
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
20
|
+
|
|
21
|
+
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
22
|
+
|
|
23
|
+
check_dependencies() {
|
|
24
|
+
command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
|
|
25
|
+
command -v php >/dev/null 2>&1 || log_error "php is not installed"
|
|
26
|
+
command -v composer >/dev/null 2>&1 || log_error "composer is not installed"
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
check_package() {
|
|
30
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
check_vendor() {
|
|
34
|
+
[ -x vendor/bin/phpunit ] || log_error "No vendor/bin/phpunit here. Run 'composer install'"
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
has_script() {
|
|
38
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
42
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
43
|
+
# declared, so this script passes on its own in that subfolder.
|
|
44
|
+
run_base_phase() {
|
|
45
|
+
local title=$1
|
|
46
|
+
local script=$2
|
|
47
|
+
local err_msg=$3
|
|
48
|
+
local pass_msg=$4
|
|
49
|
+
|
|
50
|
+
log_step "$title"
|
|
51
|
+
if ! has_script "$script"; then
|
|
52
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
53
|
+
return
|
|
54
|
+
fi
|
|
55
|
+
run_check "bun run $script" "$err_msg"
|
|
56
|
+
log_info "$pass_msg"
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
run_check() {
|
|
60
|
+
local cmd=$1
|
|
61
|
+
local err_msg=$2
|
|
62
|
+
local output
|
|
63
|
+
if ! output=$(eval "$cmd" 2>&1); then
|
|
64
|
+
echo "$output" | pipe_output
|
|
65
|
+
log_error "$err_msg"
|
|
66
|
+
fi
|
|
67
|
+
echo "$output" | pipe_output
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
main() {
|
|
71
|
+
check_dependencies
|
|
72
|
+
check_package
|
|
73
|
+
check_vendor
|
|
74
|
+
|
|
75
|
+
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
76
|
+
|
|
77
|
+
echo -e "${GREY}├${NC} ${WHITE}Typecheck${NC}"
|
|
78
|
+
run_check "bun run typecheck" "Typecheck failed"
|
|
79
|
+
log_info "Typecheck passed"
|
|
80
|
+
|
|
81
|
+
log_step "Lint"
|
|
82
|
+
run_check "bun run lint" "Lint failed"
|
|
83
|
+
log_info "Lint passed"
|
|
84
|
+
|
|
85
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
86
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
87
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
88
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
89
|
+
|
|
90
|
+
log_step "Unit tests"
|
|
91
|
+
run_check "bun run test:run" "Unit tests failed"
|
|
92
|
+
log_info "Unit tests passed"
|
|
93
|
+
|
|
94
|
+
if [ "$NESTED" = false ]; then
|
|
95
|
+
echo -e "${GREY}└${NC}\n"
|
|
96
|
+
echo -e "${GREEN}✓ Verification passed${NC}"
|
|
97
|
+
fi
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
main "$@"
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[stack]
|
|
2
|
+
name = "php"
|
|
3
|
+
extends = "base"
|
|
4
|
+
runtime = "composer"
|
|
5
|
+
scaffold = "mkdir {{name}} && cd {{name}} && composer init -n --name example/{{name}} --autoload src/"
|
|
6
|
+
|
|
7
|
+
[scripts]
|
|
8
|
+
"lint" = "vendor/bin/php-cs-fixer check --diff"
|
|
9
|
+
"lint:fix" = "vendor/bin/php-cs-fixer fix"
|
|
10
|
+
"typecheck" = "vendor/bin/phpstan analyse --no-progress"
|
|
11
|
+
"test" = "vendor/bin/phpunit"
|
|
12
|
+
"test:run" = "vendor/bin/phpunit --testdox"
|
|
13
|
+
|
|
14
|
+
[gitignore]
|
|
15
|
+
"# PHP" = ["vendor/", ".php-cs-fixer.cache", ".phpunit.cache/", ".phpunit.result.cache"]
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
# Tooling PHP reference
|
|
2
|
+
|
|
3
|
+
> Extends: `base`. Apply base stack first.
|
|
4
|
+
|
|
5
|
+
## Overview
|
|
6
|
+
|
|
7
|
+
The php stack covers Composer projects on PHP 8.3 or later, formatted by PHP CS Fixer, analysed by PHPStan, and tested by PHPUnit. It ships the three tools' configs, a PHP-aware `verify.sh`, and a copy-once smoke test.
|
|
8
|
+
|
|
9
|
+
## Scaffold checklist
|
|
10
|
+
|
|
11
|
+
1. Scaffold with `composer init -n --name <vendor>/<name> --autoload src/`. The package name and namespace are the project's own identity, so this stack never ships a `composer.json`.
|
|
12
|
+
2. Seed `package.json` so the base layer's bun-side tools (husky, prettier, cspell, commitlint) have a target to install into: `bun init -y`.
|
|
13
|
+
3. Install base tooling: `canon tooling sync base . --write`
|
|
14
|
+
4. Install php tooling: `canon tooling sync php . --write`
|
|
15
|
+
5. Install the dev tools: `composer require --dev friendsofphp/php-cs-fixer phpstan/phpstan phpunit/phpunit`. The manifest declares no `[dependencies.dev]`, because manifest injection runs `bun add -D`, which cannot install a Composer package. This step stays manual.
|
|
16
|
+
6. Run `bun run lint:fix` then `bun run check`.
|
|
17
|
+
|
|
18
|
+
Use PHP 8.3 or later, since PHPUnit 12 requires it.
|
|
19
|
+
|
|
20
|
+
Keep both `src/` and `tests/` in place. All three tools read both paths and fail when either is missing. The scaffold creates `src/`, but git does not commit an empty folder, so a fresh clone with no source yet fails `lint` and `typecheck` until the first class lands there.
|
|
21
|
+
|
|
22
|
+
The php stack also ships `tests/SmokeTest.php` as a copy-once seed, so `bun run check` passes on a fresh scaffold. Delete or replace it with real tests.
|
|
23
|
+
|
|
24
|
+
## PHP CS Fixer
|
|
25
|
+
|
|
26
|
+
- Scan `src` and `tests`.
|
|
27
|
+
- Allow risky rules.
|
|
28
|
+
- Apply `@PER-CS`, plus `declare_strict_types`, `strict_comparison`, `no_unused_imports`, and `ordered_imports`.
|
|
29
|
+
- Write the cache to `.php-cs-fixer.cache` beside the config.
|
|
30
|
+
|
|
31
|
+
## PHPStan
|
|
32
|
+
|
|
33
|
+
- Set `level: max`.
|
|
34
|
+
- Analyse `src` and `tests`.
|
|
35
|
+
|
|
36
|
+
## PHPUnit
|
|
37
|
+
|
|
38
|
+
- Bootstrap from `vendor/autoload.php` and validate against the vendored schema.
|
|
39
|
+
- Set `colors`, `failOnWarning`, and `failOnRisky`.
|
|
40
|
+
- Run one `unit` suite over `tests`, with `src` as the source.
|
|
41
|
+
|
|
42
|
+
## verify.sh
|
|
43
|
+
|
|
44
|
+
- Stop and name `composer install` when `vendor/bin/phpunit` is missing.
|
|
45
|
+
- Run Typecheck (`phpstan analyse`), Lint (`php-cs-fixer check`), and Unit tests (`phpunit --testdox`), plus the four base phases wherever the folder declares their scripts.
|
|
46
|
+
|
|
47
|
+
## Gitignore
|
|
48
|
+
|
|
49
|
+
- `# PHP`: `vendor/`, `.php-cs-fixer.cache`, `.phpunit.cache/`, `.phpunit.result.cache`
|
|
50
|
+
|
|
51
|
+
## CI
|
|
52
|
+
|
|
53
|
+
- Install PHP with `shivammathur/setup-php` and `tools: composer`.
|
|
54
|
+
- Run `composer install`, then `bun run typecheck`, `bun run lint`, and `bun run test:run`.
|
|
55
|
+
|
|
56
|
+
## Anti-patterns
|
|
57
|
+
|
|
58
|
+
- Do not ship `composer.json` or `composer.lock` from tooling. Both belong to the project, and a golden copy would overwrite its package name on every sync.
|
|
59
|
+
- Do not add a `phpstan-baseline.neon` to reach `level: max`. Fix the finding or lower the level in the project.
|
|
60
|
+
- Do not ship a `Dockerfile`, `.dockerignore`, or `compose.yaml` under tooling. Those are deployment choices and belong in the project itself.
|
|
61
|
+
|
|
62
|
+
## Development docs (extend)
|
|
63
|
+
|
|
64
|
+
Append to the `## Scripts` table:
|
|
65
|
+
|
|
66
|
+
| `bun run lint` | Run `php-cs-fixer check --diff`. |
|
|
67
|
+
| `bun run lint:fix` | Auto-fix with `php-cs-fixer fix`. |
|
|
68
|
+
| `bun run typecheck` | Run `phpstan analyse`. |
|
|
69
|
+
| `bun run test` | Run `phpunit`. |
|
|
70
|
+
| `bun run test:run` | Run `phpunit --testdox` (used by `verify`). |
|
|
71
|
+
|
|
72
|
+
## CI docs (extend)
|
|
73
|
+
|
|
74
|
+
In `canon/context/ci.md`, the Typecheck row's assertion reads: `` `phpstan analyse` passes ``. Add a Lint row whose assertion reads: `` `php-cs-fixer check` passes ``. Add a Tests row whose assertion reads: `` `phpunit` exits 0 ``.
|