@erclx/canon 4.112.0 → 4.114.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/SKILL.md +3 -3
- package/claude/skills/codebase-layout/SKILL.md +1 -1
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/target-setup/references/detect.md +2 -2
- package/claude/skills/target-setup/references/verify.md +26 -10
- package/claude/skills/test-craft/SKILL.md +1 -1
- package/docs/agents/commands.md +1 -1
- package/docs/agents/counts.md +3 -3
- package/docs/agents/install-and-sync.md +2 -0
- package/docs/agents/records.md +22 -15
- package/docs/agents/teach.md +8 -4
- package/docs/target-projects.md +9 -2
- package/docs/workflow/ai-workflow.md +1 -1
- package/governance/rules/lang/130-go.md +40 -0
- package/governance/rules/lang/140-php.md +31 -0
- package/governance/rules/lib/335-testing-go.md +35 -0
- package/governance/rules/lib/336-testing-php.md +30 -0
- package/governance/rules/lib/360-security-server.md +2 -0
- package/governance/rules/lib/370-database.md +2 -0
- package/governance/rules/ui/410-a11y.md +1 -0
- package/governance/rules/ui/440-surface-capture.md +2 -0
- package/governance/stacks/nextjs.toml +2 -0
- package/governance/stacks/react.toml +1 -1
- package/package.json +3 -1
- package/src/claude/skills-audit.ts +2 -19
- package/src/commands/feedback-format.ts +24 -0
- package/src/commands/feedback.ts +2 -1
- package/src/commands/records.ts +18 -1
- package/src/commands/teach.ts +41 -1
- package/src/commands/tooling.ts +25 -2
- package/src/design/components.ts +17 -0
- package/src/design/tokens.ts +1 -1
- package/src/gate/measures.ts +13 -2
- package/src/indexes/frontmatter.ts +10 -1
- package/src/pr/evidence.ts +3 -1
- package/src/records/backup.ts +197 -27
- package/src/records/push-guard.ts +71 -0
- package/src/teach/nav.ts +75 -25
- package/src/teach/workspace.ts +14 -6
- package/src/tooling/inject.ts +43 -4
- package/src/tooling/scan.ts +35 -0
- package/src/tooling/subfolder.ts +110 -0
- package/tooling/base/configs/.husky/post-merge +5 -1
- package/tooling/base/seeds/cspell.json +1 -1
- package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
- package/tooling/nextjs/configs/vitest.config.ts +1 -1
- package/tooling/python/configs/scripts/verify.sh +32 -15
- package/tooling/python/manifest.toml +1 -1
- package/tooling/python/reference.md +3 -4
- package/tooling/vite-react/configs/vite.config.ts +1 -1
- package/tooling/web/configs/scripts/verify.sh +32 -15
package/src/tooling/inject.ts
CHANGED
|
@@ -10,19 +10,30 @@ import { ancestorsFirst, listFiles, type Manifest } from '@/tooling/manifest'
|
|
|
10
10
|
import {
|
|
11
11
|
applyScripts,
|
|
12
12
|
collectDeps,
|
|
13
|
+
collectScripts,
|
|
13
14
|
readPackage,
|
|
14
15
|
serializePackage,
|
|
15
16
|
} from '@/tooling/package'
|
|
16
|
-
import {
|
|
17
|
+
import {
|
|
18
|
+
hasSpellConfig,
|
|
19
|
+
isSubfolderTarget,
|
|
20
|
+
isWithheldInSubfolder,
|
|
21
|
+
SUBFOLDER_SPELL_CONFIG,
|
|
22
|
+
subfolderSpellConfig,
|
|
23
|
+
} from '@/tooling/subfolder'
|
|
24
|
+
import { logAdd, logRemove, logStep, logWarn } from '@/ui'
|
|
17
25
|
|
|
18
26
|
export async function injectConfigs(
|
|
19
27
|
chain: readonly Manifest[],
|
|
20
28
|
target: string,
|
|
21
29
|
): Promise<string[]> {
|
|
22
30
|
const applied: string[] = []
|
|
31
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
23
32
|
|
|
24
33
|
for (const manifest of ancestorsFirst(chain)) {
|
|
25
|
-
const files = listFiles(manifest.configsDir)
|
|
34
|
+
const files = listFiles(manifest.configsDir).filter(
|
|
35
|
+
(rel) => !(isSubfolder && isWithheldInSubfolder(rel)),
|
|
36
|
+
)
|
|
26
37
|
if (files.length === 0) continue
|
|
27
38
|
|
|
28
39
|
logStep(`Applying ${manifest.name} configs`)
|
|
@@ -100,6 +111,16 @@ export async function injectSeeds(
|
|
|
100
111
|
}
|
|
101
112
|
}
|
|
102
113
|
|
|
114
|
+
const spellConfig = isSubfolderTarget(target)
|
|
115
|
+
? subfolderSpellConfig(chain)
|
|
116
|
+
: undefined
|
|
117
|
+
if (spellConfig !== undefined && !hasSpellConfig(target)) {
|
|
118
|
+
logStep('Applying subfolder spell config')
|
|
119
|
+
await writeFile(join(target, SUBFOLDER_SPELL_CONFIG), spellConfig)
|
|
120
|
+
logAdd(SUBFOLDER_SPELL_CONFIG)
|
|
121
|
+
applied.push(SUBFOLDER_SPELL_CONFIG)
|
|
122
|
+
}
|
|
123
|
+
|
|
103
124
|
return applied
|
|
104
125
|
}
|
|
105
126
|
|
|
@@ -158,8 +179,9 @@ export async function pruneGitignore(
|
|
|
158
179
|
|
|
159
180
|
/**
|
|
160
181
|
* Installs missing dev dependencies, fills package scripts, and merges
|
|
161
|
-
* gitignore entries.
|
|
162
|
-
*
|
|
182
|
+
* gitignore entries. A target with no package.json gets the gitignore alone,
|
|
183
|
+
* plus a warning naming every install it skipped, since seeding a package.json
|
|
184
|
+
* would pick a package manager and a project name on the target's behalf.
|
|
163
185
|
*/
|
|
164
186
|
export async function injectManifest(
|
|
165
187
|
chain: readonly Manifest[],
|
|
@@ -192,4 +214,21 @@ export async function injectManifest(
|
|
|
192
214
|
}
|
|
193
215
|
|
|
194
216
|
await injectGitignore(chain, target)
|
|
217
|
+
|
|
218
|
+
if (!pkg) warnSkippedInstalls(chain, target)
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function warnSkippedInstalls(chain: readonly Manifest[], target: string): void {
|
|
222
|
+
const deps = collectDeps(chain, {}).map((dep) => dep.spec)
|
|
223
|
+
const scripts = collectScripts(chain, {}).map((script) => script.key)
|
|
224
|
+
if (deps.length === 0 && scripts.length === 0) return
|
|
225
|
+
|
|
226
|
+
logStep('Skipped installs')
|
|
227
|
+
if (deps.length > 0) {
|
|
228
|
+
logWarn(`Dev dependencies not installed: ${deps.join(', ')}`)
|
|
229
|
+
}
|
|
230
|
+
if (scripts.length > 0) {
|
|
231
|
+
logWarn(`Scripts not added: ${scripts.join(', ')}`)
|
|
232
|
+
}
|
|
233
|
+
logWarn(`No package.json: run 'bun init' in ${target}, then sync again`)
|
|
195
234
|
}
|
package/src/tooling/scan.ts
CHANGED
|
@@ -10,6 +10,13 @@ import {
|
|
|
10
10
|
readPackage,
|
|
11
11
|
type ScriptState,
|
|
12
12
|
} from '@/tooling/package'
|
|
13
|
+
import {
|
|
14
|
+
hasSpellConfig,
|
|
15
|
+
isSubfolderTarget,
|
|
16
|
+
isWithheldInSubfolder,
|
|
17
|
+
SUBFOLDER_SPELL_CONFIG,
|
|
18
|
+
subfolderSpellConfig,
|
|
19
|
+
} from '@/tooling/subfolder'
|
|
13
20
|
|
|
14
21
|
export interface ConfigState {
|
|
15
22
|
readonly rel: string
|
|
@@ -27,8 +34,15 @@ export interface EntryState {
|
|
|
27
34
|
readonly state: 'missing' | 'present'
|
|
28
35
|
}
|
|
29
36
|
|
|
37
|
+
export interface WithheldState {
|
|
38
|
+
readonly rel: string
|
|
39
|
+
readonly stack: string
|
|
40
|
+
readonly present: boolean
|
|
41
|
+
}
|
|
42
|
+
|
|
30
43
|
export interface ScanResult {
|
|
31
44
|
readonly configs: readonly ConfigState[]
|
|
45
|
+
readonly withheld: readonly WithheldState[]
|
|
32
46
|
readonly seeds: readonly SeedState[]
|
|
33
47
|
readonly scripts: readonly ScriptState[]
|
|
34
48
|
readonly deps: readonly DepState[]
|
|
@@ -48,9 +62,16 @@ function isIdentical(a: string, b: string): boolean {
|
|
|
48
62
|
* category and mirrors the bash: configs, seeds, and scripts resolve nearest
|
|
49
63
|
* stack first, while dependencies and gitignore entries resolve from the
|
|
50
64
|
* furthest ancestor inward.
|
|
65
|
+
*
|
|
66
|
+
* A target below its git toplevel is scanned as a subfolder: a path GitHub
|
|
67
|
+
* reads only at the root moves to `withheld` and counts as no change, and the
|
|
68
|
+
* nested spell config joins the seeds. The scope is read here so every caller
|
|
69
|
+
* of `scan` agrees without passing a flag.
|
|
51
70
|
*/
|
|
52
71
|
export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
72
|
+
const isSubfolder = isSubfolderTarget(target)
|
|
53
73
|
const configs: ConfigState[] = []
|
|
74
|
+
const withheld: WithheldState[] = []
|
|
54
75
|
const seenConfigs = new Set<string>()
|
|
55
76
|
|
|
56
77
|
for (const manifest of chain) {
|
|
@@ -60,6 +81,12 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
60
81
|
|
|
61
82
|
const source = join(manifest.configsDir, rel)
|
|
62
83
|
const dest = join(target, rel)
|
|
84
|
+
|
|
85
|
+
if (isSubfolder && isWithheldInSubfolder(rel)) {
|
|
86
|
+
withheld.push({ rel, stack: manifest.name, present: existsSync(dest) })
|
|
87
|
+
continue
|
|
88
|
+
}
|
|
89
|
+
|
|
63
90
|
const state = !existsSync(dest)
|
|
64
91
|
? 'new'
|
|
65
92
|
: isIdentical(source, dest)
|
|
@@ -87,6 +114,13 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
87
114
|
}
|
|
88
115
|
}
|
|
89
116
|
|
|
117
|
+
if (isSubfolder && subfolderSpellConfig(chain) !== undefined) {
|
|
118
|
+
seeds.push({
|
|
119
|
+
rel: SUBFOLDER_SPELL_CONFIG,
|
|
120
|
+
state: hasSpellConfig(target) ? 'present' : 'missing',
|
|
121
|
+
})
|
|
122
|
+
}
|
|
123
|
+
|
|
90
124
|
const packagePath = join(target, 'package.json')
|
|
91
125
|
const pkg = readPackage(packagePath)
|
|
92
126
|
const scripts = pkg ? collectScripts(chain, pkg) : []
|
|
@@ -107,6 +141,7 @@ export function scan(chain: readonly Manifest[], target: string): ScanResult {
|
|
|
107
141
|
|
|
108
142
|
return {
|
|
109
143
|
configs,
|
|
144
|
+
withheld,
|
|
110
145
|
seeds,
|
|
111
146
|
scripts,
|
|
112
147
|
deps,
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { existsSync, readdirSync, realpathSync } from 'node:fs'
|
|
2
|
+
import { basename, join, relative } from 'node:path'
|
|
3
|
+
import { gitEnv } from '@/git-env'
|
|
4
|
+
import { listFiles, type Manifest } from '@/tooling/manifest'
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The path the generated subfolder spell config is written to. Written
|
|
8
|
+
* copy-once, since a project that already configured cspell here owns it.
|
|
9
|
+
*/
|
|
10
|
+
export const SUBFOLDER_SPELL_CONFIG = 'cspell.json'
|
|
11
|
+
|
|
12
|
+
const WITHHELD_PREFIX = '.github/'
|
|
13
|
+
|
|
14
|
+
const SPELL_CONFIG_NAMES = ['cspell.json', '.cspell.json']
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Whether the target sits below the root of the git repository holding it. A
|
|
18
|
+
* target outside any repository, or one git cannot read, counts as a root, so
|
|
19
|
+
* a scratch folder with no repository keeps the behavior a root sync has.
|
|
20
|
+
*
|
|
21
|
+
* Synchronous because `scan` and `buildToolingReport` are, which is why this
|
|
22
|
+
* does not reuse the async worktree reader.
|
|
23
|
+
*/
|
|
24
|
+
export function isSubfolderTarget(target: string): boolean {
|
|
25
|
+
return subfolderPath(target) !== undefined
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* The target's path relative to its git toplevel, which is what a root CI
|
|
30
|
+
* job names as its `working-directory`. Undefined wherever
|
|
31
|
+
* `isSubfolderTarget` reads a root.
|
|
32
|
+
*/
|
|
33
|
+
export function subfolderPath(target: string): string | undefined {
|
|
34
|
+
const result = Bun.spawnSync(
|
|
35
|
+
['git', '-C', target, 'rev-parse', '--show-toplevel'],
|
|
36
|
+
{ env: gitEnv(), stdout: 'pipe', stderr: 'ignore' },
|
|
37
|
+
)
|
|
38
|
+
if (result.exitCode !== 0) return undefined
|
|
39
|
+
|
|
40
|
+
const toplevel = result.stdout.toString().trim()
|
|
41
|
+
if (toplevel === '') return undefined
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const path = relative(realpathSync(toplevel), realpathSync(target))
|
|
45
|
+
return path === '' ? undefined : path
|
|
46
|
+
} catch {
|
|
47
|
+
return undefined
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* GitHub reads workflows only at the repository root, so a `.github/` copied
|
|
53
|
+
* into a subfolder is a file nothing runs. `scan` and `injectConfigs` both
|
|
54
|
+
* read this, so the set the diff withholds and the set the sync skips cannot
|
|
55
|
+
* disagree.
|
|
56
|
+
*/
|
|
57
|
+
export function isWithheldInSubfolder(rel: string): boolean {
|
|
58
|
+
return rel.startsWith(WITHHELD_PREFIX)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Whether the target already carries a cspell config of its own, in any of
|
|
63
|
+
* the file names cspell searches for.
|
|
64
|
+
*/
|
|
65
|
+
export function hasSpellConfig(target: string): boolean {
|
|
66
|
+
if (SPELL_CONFIG_NAMES.some((name) => existsSync(join(target, name)))) {
|
|
67
|
+
return true
|
|
68
|
+
}
|
|
69
|
+
if (!existsSync(target)) return false
|
|
70
|
+
return readdirSync(target).some((name) => name.startsWith('cspell.config.'))
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Builds the nested cspell config that registers a subfolder's seeded word
|
|
75
|
+
* lists. A root `cspell '**'` merges it with the root config for files under
|
|
76
|
+
* the subfolder. Each dictionary takes a `local-` name, since reusing a root
|
|
77
|
+
* dictionary's name shadows it inside the subfolder and a root word then fails
|
|
78
|
+
* there.
|
|
79
|
+
*
|
|
80
|
+
* Returns nothing when the chain carries `base`, whose own seed already writes
|
|
81
|
+
* a config, or when no stack seeds a word list.
|
|
82
|
+
*/
|
|
83
|
+
export function subfolderSpellConfig(
|
|
84
|
+
chain: readonly Manifest[],
|
|
85
|
+
): string | undefined {
|
|
86
|
+
if (chain.some((manifest) => manifest.name === 'base')) return undefined
|
|
87
|
+
|
|
88
|
+
const lists = new Set<string>()
|
|
89
|
+
for (const manifest of chain) {
|
|
90
|
+
for (const rel of listFiles(manifest.seedsDir)) {
|
|
91
|
+
if (rel.startsWith('.cspell/') && rel.endsWith('.txt')) lists.add(rel)
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (lists.size === 0) return undefined
|
|
95
|
+
|
|
96
|
+
const definitions = [...lists].sort().map((path) => ({
|
|
97
|
+
name: `local-${basename(path, '.txt')}`,
|
|
98
|
+
path,
|
|
99
|
+
addWords: true,
|
|
100
|
+
}))
|
|
101
|
+
|
|
102
|
+
const config = {
|
|
103
|
+
version: '0.2',
|
|
104
|
+
dictionaryDefinitions: definitions,
|
|
105
|
+
dictionaries: definitions.map((definition) => definition.name),
|
|
106
|
+
ignorePaths: ['.cspell/**'],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return `${JSON.stringify(config, null, 2)}\n`
|
|
110
|
+
}
|
|
@@ -82,7 +82,11 @@ if command -v canon >/dev/null 2>&1; then
|
|
|
82
82
|
# ran the one-time setup. Nothing to report.
|
|
83
83
|
if [ "$reason" != "no-repository" ]; then
|
|
84
84
|
printf '\n🗄️ Records not backed up: %s\n' "${reason:-unknown}"
|
|
85
|
-
|
|
85
|
+
if [ "$reason" = "unsafe-payload" ]; then
|
|
86
|
+
printf 'Run canon records push to see the blocked paths.\n\n'
|
|
87
|
+
else
|
|
88
|
+
printf 'Run canon records push when the remote is reachable.\n\n'
|
|
89
|
+
fi
|
|
86
90
|
fi
|
|
87
91
|
fi
|
|
88
92
|
fi
|
|
@@ -49,19 +49,18 @@ paths=$(tail -n +"$line_start" "$transcript_path" | jq -r '
|
|
|
49
49
|
|
|
50
50
|
# Two written paths can share a basename, and a bare basename match would then
|
|
51
51
|
# read one mention as covering both. Counting first is what lets a collision
|
|
52
|
-
# fall back to the full path, which the ordinary case never needs.
|
|
53
|
-
|
|
54
|
-
while IFS= read -r path; do
|
|
52
|
+
# fall back to the full path, which the ordinary case never needs. The count
|
|
53
|
+
# avoids an associative array, since macOS ships bash 3.2.
|
|
54
|
+
colliding=$(while IFS= read -r path; do
|
|
55
55
|
[ -n "$path" ] || continue
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
done <<<"$paths"
|
|
56
|
+
printf '%s\n' "${path##*/}"
|
|
57
|
+
done <<<"$paths" | sort | uniq -d)
|
|
59
58
|
|
|
60
59
|
unmentioned=()
|
|
61
60
|
while IFS= read -r path; do
|
|
62
61
|
[ -n "$path" ] || continue
|
|
63
|
-
base=$
|
|
64
|
-
if [ "$
|
|
62
|
+
base=${path##*/}
|
|
63
|
+
if [ -n "$colliding" ] && printf '%s\n' "$colliding" | grep -Fxq -- "$base"; then
|
|
65
64
|
needle="$path"
|
|
66
65
|
else
|
|
67
66
|
needle="$base"
|
|
@@ -16,6 +16,7 @@ log_error() {
|
|
|
16
16
|
exit 1
|
|
17
17
|
}
|
|
18
18
|
log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
|
|
19
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
19
20
|
|
|
20
21
|
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
21
22
|
|
|
@@ -24,6 +25,32 @@ check_dependencies() {
|
|
|
24
25
|
command -v uv >/dev/null 2>&1 || log_error "uv is not installed"
|
|
25
26
|
}
|
|
26
27
|
|
|
28
|
+
check_package() {
|
|
29
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
has_script() {
|
|
33
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
37
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
38
|
+
# declared, so this script passes on its own in that subfolder.
|
|
39
|
+
run_base_phase() {
|
|
40
|
+
local title=$1
|
|
41
|
+
local script=$2
|
|
42
|
+
local err_msg=$3
|
|
43
|
+
local pass_msg=$4
|
|
44
|
+
|
|
45
|
+
log_step "$title"
|
|
46
|
+
if ! has_script "$script"; then
|
|
47
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
48
|
+
return
|
|
49
|
+
fi
|
|
50
|
+
run_check "bun run $script" "$err_msg"
|
|
51
|
+
log_info "$pass_msg"
|
|
52
|
+
}
|
|
53
|
+
|
|
27
54
|
run_check() {
|
|
28
55
|
local cmd=$1
|
|
29
56
|
local err_msg=$2
|
|
@@ -37,6 +64,7 @@ run_check() {
|
|
|
37
64
|
|
|
38
65
|
main() {
|
|
39
66
|
check_dependencies
|
|
67
|
+
check_package
|
|
40
68
|
|
|
41
69
|
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
42
70
|
|
|
@@ -48,21 +76,10 @@ main() {
|
|
|
48
76
|
run_check "bun run lint" "Lint failed"
|
|
49
77
|
log_info "Lint passed"
|
|
50
78
|
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
log_step "Format check"
|
|
56
|
-
run_check "bun run check:format" "Format check failed"
|
|
57
|
-
log_info "Format check passed"
|
|
58
|
-
|
|
59
|
-
log_step "Spelling"
|
|
60
|
-
run_check "bun run check:spell" "Spell check failed"
|
|
61
|
-
log_info "Spell check passed"
|
|
62
|
-
|
|
63
|
-
log_step "Shell"
|
|
64
|
-
run_check "bun run check:shell" "Shell check failed"
|
|
65
|
-
log_info "Shell check passed"
|
|
79
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
80
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
81
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
82
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
66
83
|
|
|
67
84
|
log_step "Unit tests"
|
|
68
85
|
run_check "bun run test:run" "Unit tests failed"
|
|
@@ -10,14 +10,13 @@ Configs ship as sidecar files (`ruff.toml`, `mypy.ini`, `pytest.ini`, `.coverage
|
|
|
10
10
|
|
|
11
11
|
## Scaffold checklist
|
|
12
12
|
|
|
13
|
-
1. Scaffold with `uv init --
|
|
13
|
+
1. Scaffold with `uv init --package <name>`. This creates `pyproject.toml` with a `uv_build` backend, `.python-version` pinned to 3.14, and `src/<name with underscores>/__init__.py` holding an annotated `def main() -> None:`, so source lands under `src/` where `mypy.ini`, `pytest.ini`, and `.coveragerc` look for it. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
|
|
14
14
|
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`. Without this the sync drops base configs but skips the dep install, since `resolve_missing_deps` short-circuits when `package.json` is absent.
|
|
15
15
|
3. Install base tooling: `canon tooling sync base . --write`
|
|
16
16
|
4. Install python tooling: `canon tooling sync python . --write`
|
|
17
17
|
5. Install Python tooling deps: `uv add --dev ruff mypy pytest pytest-cov`. v1 of this stack does not declare these in `[dependencies.dev]` because manifest injection hardcodes `bun add -D`, which can not install Python packages. Until the injector branches on `runtime`, this step is manual.
|
|
18
18
|
6. Sync the lockfile and create the venv: `uv sync`.
|
|
19
|
-
7.
|
|
20
|
-
8. Run `bun run lint:fix` then `bun run check`.
|
|
19
|
+
7. Run `bun run lint:fix` then `bun run check`.
|
|
21
20
|
|
|
22
21
|
The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest` collects at least one test on first run, so `bun run test:run` exits 0 instead of the empty-collection exit code 5. Delete or replace the smoke test with real tests.
|
|
23
22
|
|
|
@@ -27,7 +26,7 @@ The python stack also ships `tests/test_smoke.py` as a copy-once seed. `pytest`
|
|
|
27
26
|
- `mypy.ini`: strict mode, `python_version = 3.14`, `mypy_path = src`, excludes `tests/` and `docs/`.
|
|
28
27
|
- `pytest.ini`: tests live under `tests/`, source under `src/`.
|
|
29
28
|
- `.coveragerc`: branch coverage on `src/`, html report under `.coverage_cache/html`.
|
|
30
|
-
- `.python-version`: pinned to `3.14` to match `uv init
|
|
29
|
+
- `.python-version`: pinned to `3.14` to match `uv init` defaults.
|
|
31
30
|
- `scripts/verify.sh`: overrides base verify to add Typecheck (`mypy`), Lint (`ruff check && ruff format --check`), and Tests (`pytest -v`) phases.
|
|
32
31
|
|
|
33
32
|
## Hybrid project shape
|
|
@@ -16,6 +16,7 @@ log_error() {
|
|
|
16
16
|
exit 1
|
|
17
17
|
}
|
|
18
18
|
log_step() { echo -e "${GREY}│${NC}\n${GREY}├${NC} ${WHITE}$1${NC}"; }
|
|
19
|
+
log_skip() { echo -e "${GREY}│ - $1${NC}"; }
|
|
19
20
|
|
|
20
21
|
pipe_output() { while IFS= read -r line; do echo -e "${GREY}│${NC} $line"; done; }
|
|
21
22
|
|
|
@@ -23,6 +24,32 @@ check_dependencies() {
|
|
|
23
24
|
command -v bun >/dev/null 2>&1 || log_error "bun is not installed"
|
|
24
25
|
}
|
|
25
26
|
|
|
27
|
+
check_package() {
|
|
28
|
+
[ -f package.json ] || log_error "No package.json here. Run 'bun init', then sync the stack again"
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
has_script() {
|
|
32
|
+
bun -e 'process.exit(require(process.cwd() + "/package.json").scripts?.[process.argv[1]] ? 0 : 1)' "$1"
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
# A subfolder synced with --skip base declares none of base's scripts, since
|
|
36
|
+
# the repository root owns them. Each base phase runs only where its script is
|
|
37
|
+
# declared, so this script passes on its own in that subfolder.
|
|
38
|
+
run_base_phase() {
|
|
39
|
+
local title=$1
|
|
40
|
+
local script=$2
|
|
41
|
+
local err_msg=$3
|
|
42
|
+
local pass_msg=$4
|
|
43
|
+
|
|
44
|
+
log_step "$title"
|
|
45
|
+
if ! has_script "$script"; then
|
|
46
|
+
log_skip "Skipped: $script is not declared here, so the repository root runs it"
|
|
47
|
+
return
|
|
48
|
+
fi
|
|
49
|
+
run_check "bun run $script" "$err_msg"
|
|
50
|
+
log_info "$pass_msg"
|
|
51
|
+
}
|
|
52
|
+
|
|
26
53
|
run_check() {
|
|
27
54
|
local cmd=$1
|
|
28
55
|
local err_msg=$2
|
|
@@ -36,6 +63,7 @@ run_check() {
|
|
|
36
63
|
|
|
37
64
|
main() {
|
|
38
65
|
check_dependencies
|
|
66
|
+
check_package
|
|
39
67
|
|
|
40
68
|
if [ "$NESTED" = false ]; then echo -e "${GREY}┌${NC}"; fi
|
|
41
69
|
|
|
@@ -47,21 +75,10 @@ main() {
|
|
|
47
75
|
run_check "bun run lint" "Lint failed"
|
|
48
76
|
log_info "Lint passed"
|
|
49
77
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
log_step "Format check"
|
|
55
|
-
run_check "bun run check:format" "Format check failed"
|
|
56
|
-
log_info "Format check passed"
|
|
57
|
-
|
|
58
|
-
log_step "Spelling"
|
|
59
|
-
run_check "bun run check:spell" "Spell check failed"
|
|
60
|
-
log_info "Spell check passed"
|
|
61
|
-
|
|
62
|
-
log_step "Shell"
|
|
63
|
-
run_check "bun run check:shell" "Shell check failed"
|
|
64
|
-
log_info "Shell check passed"
|
|
78
|
+
run_base_phase "Formatting" "format" "Format failed" "Format applied"
|
|
79
|
+
run_base_phase "Format check" "check:format" "Format check failed" "Format check passed"
|
|
80
|
+
run_base_phase "Spelling" "check:spell" "Spell check failed" "Spell check passed"
|
|
81
|
+
run_base_phase "Shell" "check:shell" "Shell check failed" "Shell check passed"
|
|
65
82
|
|
|
66
83
|
log_step "Unit tests"
|
|
67
84
|
run_check "bun run test:run" "Unit tests failed"
|