@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.
Files changed (52) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-feedback/SKILL.md +3 -3
  3. package/claude/skills/codebase-layout/SKILL.md +1 -1
  4. package/claude/skills/target-setup/SKILL.md +2 -0
  5. package/claude/skills/target-setup/references/detect.md +2 -2
  6. package/claude/skills/target-setup/references/verify.md +26 -10
  7. package/claude/skills/test-craft/SKILL.md +1 -1
  8. package/docs/agents/commands.md +1 -1
  9. package/docs/agents/counts.md +3 -3
  10. package/docs/agents/install-and-sync.md +2 -0
  11. package/docs/agents/records.md +22 -15
  12. package/docs/agents/teach.md +8 -4
  13. package/docs/target-projects.md +9 -2
  14. package/docs/workflow/ai-workflow.md +1 -1
  15. package/governance/rules/lang/130-go.md +40 -0
  16. package/governance/rules/lang/140-php.md +31 -0
  17. package/governance/rules/lib/335-testing-go.md +35 -0
  18. package/governance/rules/lib/336-testing-php.md +30 -0
  19. package/governance/rules/lib/360-security-server.md +2 -0
  20. package/governance/rules/lib/370-database.md +2 -0
  21. package/governance/rules/ui/410-a11y.md +1 -0
  22. package/governance/rules/ui/440-surface-capture.md +2 -0
  23. package/governance/stacks/nextjs.toml +2 -0
  24. package/governance/stacks/react.toml +1 -1
  25. package/package.json +3 -1
  26. package/src/claude/skills-audit.ts +2 -19
  27. package/src/commands/feedback-format.ts +24 -0
  28. package/src/commands/feedback.ts +2 -1
  29. package/src/commands/records.ts +18 -1
  30. package/src/commands/teach.ts +41 -1
  31. package/src/commands/tooling.ts +25 -2
  32. package/src/design/components.ts +17 -0
  33. package/src/design/tokens.ts +1 -1
  34. package/src/gate/measures.ts +13 -2
  35. package/src/indexes/frontmatter.ts +10 -1
  36. package/src/pr/evidence.ts +3 -1
  37. package/src/records/backup.ts +197 -27
  38. package/src/records/push-guard.ts +71 -0
  39. package/src/teach/nav.ts +75 -25
  40. package/src/teach/workspace.ts +14 -6
  41. package/src/tooling/inject.ts +43 -4
  42. package/src/tooling/scan.ts +35 -0
  43. package/src/tooling/subfolder.ts +110 -0
  44. package/tooling/base/configs/.husky/post-merge +5 -1
  45. package/tooling/base/seeds/cspell.json +1 -1
  46. package/tooling/claude/seeds/.claude/hooks/silent-turn.sh +7 -8
  47. package/tooling/nextjs/configs/vitest.config.ts +1 -1
  48. package/tooling/python/configs/scripts/verify.sh +32 -15
  49. package/tooling/python/manifest.toml +1 -1
  50. package/tooling/python/reference.md +3 -4
  51. package/tooling/vite-react/configs/vite.config.ts +1 -1
  52. package/tooling/web/configs/scripts/verify.sh +32 -15
@@ -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 { logAdd, logRemove, logStep } from '@/ui'
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. This is the `inject_tooling_manifest` sequence, which
162
- * assumed a target that already has a package.json.
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
  }
@@ -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
- printf 'Run canon records push when the remote is reachable.\n\n'
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
@@ -16,5 +16,5 @@
16
16
  }
17
17
  ],
18
18
  "dictionaries": ["project-terms", "tech-stack"],
19
- "ignorePaths": [".cspell/**", ".git/**"]
19
+ "ignorePaths": [".cspell/**", ".git/**", ".claude/rules/canon/**"]
20
20
  }
@@ -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
- declare -A base_count
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
- base=$(basename "$path")
57
- base_count["$base"]=$((${base_count["$base"]:-0} + 1))
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=$(basename "$path")
64
- if [ "${base_count[$base]}" -gt 1 ]; then
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"
@@ -5,7 +5,7 @@ import { defineConfig } from 'vitest/config'
5
5
  export default defineConfig({
6
6
  resolve: {
7
7
  alias: {
8
- '@': path.resolve(__dirname, 'src'),
8
+ '@': path.resolve(import.meta.dirname, 'src'),
9
9
  },
10
10
  },
11
11
  test: {
@@ -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
- log_step "Formatting"
52
- run_check "bun run format" "Format failed"
53
- log_info "Format applied"
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"
@@ -2,7 +2,7 @@
2
2
  name = "python"
3
3
  extends = "base"
4
4
  runtime = "uv"
5
- scaffold = "uv init --app {{name}}"
5
+ scaffold = "uv init --package {{name}}"
6
6
 
7
7
  [scripts]
8
8
  "lint" = "uv run ruff check . && uv run ruff format --check ."
@@ -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 --app <name>`. This creates `pyproject.toml`, `.python-version` pinned to 3.14, `src/<name>/`, and a starter `main.py`. `uv init` defaults `requires-python` to `>=3.14`, which matches the `.python-version` pin this stack ships.
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. Annotate `main()` in the scaffold-generated `main.py` with `-> None`. `uv init --app` ships an unannotated `main()` that fails strict mypy on the first run.
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 --app` defaults.
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
@@ -9,7 +9,7 @@ export default defineConfig({
9
9
  plugins: [react(), tailwindcss()],
10
10
  resolve: {
11
11
  alias: {
12
- '@': path.resolve(__dirname, './src'),
12
+ '@': path.resolve(import.meta.dirname, './src'),
13
13
  },
14
14
  },
15
15
  base: process.env.VITE_BASE_URL ?? '/',
@@ -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
- log_step "Formatting"
51
- run_check "bun run format" "Format failed"
52
- log_info "Format applied"
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"