@erclx/canon 4.55.0 → 4.57.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 (37) hide show
  1. package/claude/.claude-plugin/plugin.json +1 -1
  2. package/claude/skills/canon-cli/SKILL.md +7 -0
  3. package/claude/skills/claude-autoship/SKILL.md +17 -1
  4. package/claude/skills/claude-orchestrate/references/orchestrator-dispatch.md +13 -4
  5. package/claude/skills/claude-orchestrate/references/orchestrator-parked.md +2 -2
  6. package/claude/skills/claude-pr-review/SKILL.md +1 -1
  7. package/claude/skills/claude-worktree/SKILL.md +9 -3
  8. package/claude/skills/context-draft/SKILL.md +1 -1
  9. package/claude/skills/docs-draft/SKILL.md +1 -1
  10. package/claude/skills/git-pr/SKILL.md +22 -5
  11. package/claude/skills/setup-init/SKILL.md +2 -1
  12. package/claude/skills/wireframe-draft/SKILL.md +2 -2
  13. package/docs/agents/commands.md +79 -79
  14. package/docs/agents/index.md +1 -1
  15. package/docs/agents/tasks.md +27 -1
  16. package/docs/target-projects.md +1 -1
  17. package/package.json +1 -1
  18. package/src/commands/labels.ts +51 -3
  19. package/src/commands/tasks.ts +97 -0
  20. package/src/gate/measures.ts +77 -2
  21. package/src/gate/stages.ts +11 -0
  22. package/src/labels/format.ts +58 -0
  23. package/src/shipped/references.ts +74 -7
  24. package/src/tasks/answers.ts +42 -11
  25. package/src/tasks/branch.ts +78 -0
  26. package/src/web/readme-citations.ts +90 -0
  27. package/standards/branch.md +3 -1
  28. package/standards/wireframes.md +4 -2
  29. package/tooling/base/configs/commitlint.config.js +26 -0
  30. package/tooling/nextjs/configs/eslint.config.js +90 -0
  31. package/tooling/nextjs/configs/next.config.ts +10 -0
  32. package/tooling/nextjs/configs/playwright.config.ts +27 -0
  33. package/tooling/nextjs/configs/vitest.config.ts +28 -0
  34. package/tooling/nextjs/manifest.toml +19 -0
  35. package/tooling/nextjs/reference.md +47 -0
  36. package/tooling/nextjs/seeds/.cspell/tech-stack.txt +3 -0
  37. package/tooling/web/configs/scripts/screenshot.sh +2 -1
@@ -0,0 +1,78 @@
1
+ import { basename } from 'node:path'
2
+ import { type AnswersRefused, resolvePlanReference } from '@/tasks/answers'
3
+
4
+ const PLAN_PREFIX = 'feature-'
5
+ const MARKDOWN = '.md'
6
+
7
+ /**
8
+ * The type every plan-derived branch takes. It is a constant rather than a
9
+ * reading, because determinism is the whole property that makes the dispatch
10
+ * gate and the worker agree, and prose reading is the judgment that produced
11
+ * three strings for one plan. A wrong type is cheap because a branch type is
12
+ * cosmetic, since a commit's type and a pull request's title are both read off
13
+ * the diff. Nothing renames it later, whatever three shipped bodies used to say.
14
+ */
15
+ export const PLAN_BRANCH_TYPE = 'feat'
16
+
17
+ /** The description cap in `standards/branch.md`, in kebab-separated words. */
18
+ export const DESCRIPTION_WORD_CAP = 4
19
+
20
+ /** The branch length cap in `standards/branch.md`, in characters. */
21
+ export const BRANCH_LENGTH_CAP = 50
22
+
23
+ export interface PlanBranch {
24
+ readonly ok: true
25
+ readonly plan: string
26
+ readonly type: string
27
+ readonly slug: string
28
+ readonly branch: string
29
+ readonly words: number
30
+ readonly conforms: boolean
31
+ }
32
+
33
+ export type BranchOutcome = PlanBranch | AnswersRefused
34
+
35
+ /**
36
+ * Takes the slug off a plan filename. The `feature-` prefix and the extension
37
+ * are the two things every plan filename carries and no branch name does, so
38
+ * both come off and whatever is left is the description.
39
+ */
40
+ function slugOf(path: string): string {
41
+ const stem = basename(path, MARKDOWN)
42
+
43
+ return stem.startsWith(PLAN_PREFIX) ? stem.slice(PLAN_PREFIX.length) : stem
44
+ }
45
+
46
+ /**
47
+ * Derives the branch a dispatch checks and a worker takes, from the plan both
48
+ * of them name. It is the one derivation, so the collision check and the
49
+ * worktree entry it gates cannot hold two answers for one plan.
50
+ *
51
+ * Conformance covers both caps `standards/branch.md` states, being the word
52
+ * count of the description and the length of the whole branch. A slug is a
53
+ * plan's own filename rather than a name anyone chose for a branch, so a plan
54
+ * can name a branch this refuses to grade as conforming, and reporting that is
55
+ * the point: the caller hands the row to a person rather than shipping a
56
+ * rename that parts the branch slug from the plan slug.
57
+ */
58
+ export function planBranch(root: string, reference: string): BranchOutcome {
59
+ const resolved = resolvePlanReference(root, reference)
60
+ if (!resolved.ok) return resolved
61
+
62
+ const slug = slugOf(resolved.path)
63
+ const branch = `${PLAN_BRANCH_TYPE}/${slug}`
64
+ const words = slug.split('-').filter((word) => word.length > 0).length
65
+
66
+ return {
67
+ ok: true,
68
+ plan: resolved.plan,
69
+ type: PLAN_BRANCH_TYPE,
70
+ slug,
71
+ branch,
72
+ words,
73
+ conforms:
74
+ words > 0 &&
75
+ words <= DESCRIPTION_WORD_CAP &&
76
+ branch.length <= BRANCH_LENGTH_CAP,
77
+ }
78
+ }
@@ -0,0 +1,90 @@
1
+ import { isMarked } from '@/exempt-marker'
2
+
3
+ export const README_PARAPHRASE_MARKER = 'canon-allow-readme-paraphrase'
4
+
5
+ export type ReadmeCitationKind = 'quoted' | 'paraphrase' | 'bare'
6
+
7
+ export interface ReadmeCitation {
8
+ readonly file: string
9
+ /** One-based, matching the `file:line` form a reader clicks. */
10
+ readonly line: number
11
+ readonly kind: ReadmeCitationKind
12
+ /** The citation comment as written, so a report names the line to fix. */
13
+ readonly text: string
14
+ /** Verbatim phrases to check against `README.md`, set only for `kind: 'quoted'`. */
15
+ readonly phrases: readonly string[]
16
+ }
17
+
18
+ const ANCHOR = /README\.md:\s*(.*)$/
19
+ const QUOTED_PHRASE = /"([^"]+)"/g
20
+
21
+ /**
22
+ * Every `README.md:` anchor comment in one file, classified by shape.
23
+ *
24
+ * `quoted` carries one or more verbatim phrases a caller checks against the
25
+ * current `README.md` text, which is what replaces a line number that drifts
26
+ * silently the moment the cited line moves. A quote is checked whether or not
27
+ * the line also carries `README_PARAPHRASE_MARKER`, since a marker documents
28
+ * that part of a string is synthesized and asserts nothing about a phrase the
29
+ * same line puts in quotes: quoting a borrow verbatim and then never checking
30
+ * it would let the exact drift this file exists to catch survive inside its
31
+ * own escape hatch. `paraphrase` is what a marked line falls to only once it
32
+ * carries no quote of its own, muted by `README_PARAPHRASE_MARKER` the way
33
+ * `isMarked` mutes every other exemption in this repository. `bare` is the
34
+ * retired `README.md:<n>` form, reported rather than accepted so the fragile
35
+ * convention this replaces cannot come back on a later edit.
36
+ *
37
+ * Modeled on `clientCommandCitationsIn` in `src/client-commands.ts`, including
38
+ * its use of `isMarked` for the exemption.
39
+ */
40
+ export function readmeCitationsIn(
41
+ file: string,
42
+ text: string,
43
+ ): ReadmeCitation[] {
44
+ const lines = text.split('\n')
45
+ const citations: ReadmeCitation[] = []
46
+
47
+ for (const [index, line] of lines.entries()) {
48
+ const match = ANCHOR.exec(line)
49
+ if (match === null) continue
50
+
51
+ const rest = (match[1] ?? '').trim()
52
+
53
+ const phrases = [...rest.matchAll(QUOTED_PHRASE)].map(
54
+ (found) => found[1] ?? '',
55
+ )
56
+ if (phrases.length > 0) {
57
+ citations.push({
58
+ file,
59
+ line: index + 1,
60
+ kind: 'quoted',
61
+ text: line.trim(),
62
+ phrases,
63
+ })
64
+ continue
65
+ }
66
+
67
+ if (isMarked(lines, index, README_PARAPHRASE_MARKER)) {
68
+ citations.push({
69
+ file,
70
+ line: index + 1,
71
+ kind: 'paraphrase',
72
+ text: line.trim(),
73
+ phrases: [],
74
+ })
75
+ continue
76
+ }
77
+
78
+ if (/^\d/.test(rest)) {
79
+ citations.push({
80
+ file,
81
+ line: index + 1,
82
+ kind: 'bare',
83
+ text: line.trim(),
84
+ phrases: [],
85
+ })
86
+ }
87
+ }
88
+
89
+ return citations
90
+ }
@@ -21,11 +21,13 @@ Does not govern:
21
21
  - Structure: `<type>/<description>` or `<type>/<ticket>-<description>`
22
22
  - Length: 50 characters maximum
23
23
  - Casing: kebab-case only, no underscores or camelCase
24
- - Description: 2 words maximum, 3 only when genuinely needed for specificity
24
+ - Description: 2 words maximum, up to 4 only when genuinely needed for specificity
25
25
  - Capture the core change, not the commit message verbatim
26
26
  - For branches with multiple commits, use the unifying concern as the description.
27
27
  - Do not duplicate type in description (e.g., `feat/feature-login`)
28
28
 
29
+ The upper bound reads 4 rather than 3 for every branch, whoever named it. It was widened on 2026-09-06 so that a branch taking its description from a planning document's own filename stops being renamed at ship, since a rename there is a third derivation and parts the branch from the filename that later tooling reads back to find the document. Nothing can tell such a name from any other, so the wider bound holds for all of them and 2 words stays the target.
30
+
29
31
  ## Types
30
32
 
31
33
  - `feat`: new feature or capability
@@ -26,7 +26,7 @@ A wireframe works when someone can rebuild the surface from it without opening t
26
26
 
27
27
  - What is on screen, and where does it sit relative to everything else?
28
28
  - Which states can a visitor reach, and what does each one look like?
29
- - What does it say, word for word?
29
+ - What does its structural copy say, word for word, and where does its long-form content come from?
30
30
 
31
31
  A wireframe that fails these is non-conforming regardless of whether it satisfies every section rule below. The fences are the means. These three questions are the test.
32
32
 
@@ -60,7 +60,8 @@ Both fields feed `.claude/wireframes/index.md` when regenerated.
60
60
 
61
61
  ## Copy
62
62
 
63
- - Carry UI copy verbatim in the ASCII block or a short list below it. The wireframe is the source of truth for on-screen text.
63
+ - Carry short structural text verbatim, in the ASCII block or a short list below it: labels, headings, empty-state strings, and nav or footer copy. The wireframe is the source of truth for this text.
64
+ - Cite the source file for long-form or article-body content instead of duplicating it. A lede sentence or a body paragraph pulled into an ASCII figure drifts the moment its source changes, and nothing checks a fenced block against prose elsewhere. A citation elsewhere in the file does not excuse the block itself from still carrying the duplicate. Trim the block once the citation exists.
64
65
  - Mark copy that is dynamic or templated so a reader does not treat a placeholder as final text.
65
66
 
66
67
  ## Behavior
@@ -113,6 +114,7 @@ description: <when and where the surface appears>
113
114
 
114
115
  - <on-screen text, word for word>
115
116
  - <text the surface templates>: <marked so a reader does not read it as final>
117
+ - <long-form content>: cited at <its source file>, not duplicated here
116
118
 
117
119
  ## Behavior
118
120
 
@@ -1,7 +1,33 @@
1
+ const SUBJECT_STARTS_WITH_LETTER = /^[A-Za-z]/
2
+ const SUBJECT_LEADING_LETTERS = /^[A-Za-z]+/
3
+
4
+ // Checks the first word alone, unlike the built-in subject-case rule, which
5
+ // tests the whole subject and would reject a legitimate capitalized proper
6
+ // noun anywhere past the first word.
7
+ const subjectFirstWordCase = (parsed) => {
8
+ const { subject } = parsed
9
+
10
+ if (
11
+ typeof subject !== 'string' ||
12
+ !SUBJECT_STARTS_WITH_LETTER.test(subject)
13
+ ) {
14
+ return [true]
15
+ }
16
+
17
+ const leadingWord = SUBJECT_LEADING_LETTERS.exec(subject)[0]
18
+
19
+ return [
20
+ leadingWord === leadingWord.toLowerCase(),
21
+ 'subject must start with a lowercase word',
22
+ ]
23
+ }
24
+
1
25
  const config = {
2
26
  extends: ['@commitlint/config-conventional'],
27
+ plugins: [{ rules: { 'subject-first-word-case': subjectFirstWordCase } }],
3
28
  rules: {
4
29
  'subject-case': [0],
30
+ 'subject-first-word-case': [2, 'always'],
5
31
  'header-max-length': [2, 'always', 72],
6
32
  'scope-case': [2, 'always', 'lower-case'],
7
33
  'subject-full-stop': [2, 'never', '.'],
@@ -0,0 +1,90 @@
1
+ import js from '@eslint/js'
2
+ import { defineConfig, globalIgnores } from 'eslint/config'
3
+ import prettier from 'eslint-config-prettier'
4
+ import checkFile from 'eslint-plugin-check-file'
5
+ import reactHooks from 'eslint-plugin-react-hooks'
6
+ import reactRefresh from 'eslint-plugin-react-refresh'
7
+ import simpleImportSort from 'eslint-plugin-simple-import-sort'
8
+ import vitest from 'eslint-plugin-vitest'
9
+ import globals from 'globals'
10
+ import tseslint from 'typescript-eslint'
11
+
12
+ export default defineConfig([
13
+ globalIgnores([
14
+ '.next',
15
+ 'next-env.d.ts',
16
+ 'dist',
17
+ 'dist-ssr',
18
+ 'coverage',
19
+ 'release',
20
+ '.claude',
21
+ '.vscode',
22
+ '.husky',
23
+ 'test-results',
24
+ 'playwright-report',
25
+ 'blob-report',
26
+ 'playwright/.cache',
27
+ ]),
28
+ js.configs.recommended,
29
+ ...tseslint.configs.recommended,
30
+ {
31
+ files: ['**/*.{ts,tsx,js,jsx}'],
32
+ plugins: {
33
+ 'react-hooks': reactHooks,
34
+ 'react-refresh': reactRefresh,
35
+ 'simple-import-sort': simpleImportSort,
36
+ 'check-file': checkFile,
37
+ },
38
+ languageOptions: {
39
+ globals: {
40
+ ...globals.browser,
41
+ },
42
+ },
43
+ rules: {
44
+ ...reactHooks.configs.recommended.rules,
45
+ 'react-refresh/only-export-components': [
46
+ 'warn',
47
+ { allowConstantExport: true },
48
+ ],
49
+ 'simple-import-sort/imports': 'error',
50
+ 'simple-import-sort/exports': 'error',
51
+ '@typescript-eslint/no-unused-vars': [
52
+ 'error',
53
+ { varsIgnorePattern: '^_', argsIgnorePattern: '^_' },
54
+ ],
55
+ 'check-file/filename-naming-convention': [
56
+ 'error',
57
+ { '**/*.{ts,tsx}': 'KEBAB_CASE' },
58
+ { ignoreMiddleExtensions: true },
59
+ ],
60
+ 'check-file/folder-naming-convention': [
61
+ 'error',
62
+ { 'src/**/!(__tests__)': 'KEBAB_CASE' },
63
+ ],
64
+ },
65
+ },
66
+ {
67
+ // App Router route and layout files export non-component values (metadata, route handlers), which this rule flags as violations.
68
+ files: ['src/app/**/*.{ts,tsx}'],
69
+ rules: {
70
+ 'react-refresh/only-export-components': 'off',
71
+ },
72
+ },
73
+ {
74
+ files: ['**/*.test.{ts,tsx}', '**/*.spec.{ts,tsx}'],
75
+ ...vitest.configs.recommended,
76
+ },
77
+ {
78
+ files: [
79
+ '*.config.{js,mjs,cjs,ts}',
80
+ 'vitest.config.ts',
81
+ 'playwright.config.ts',
82
+ ],
83
+ languageOptions: {
84
+ globals: {
85
+ ...globals.node,
86
+ },
87
+ },
88
+ },
89
+ prettier,
90
+ ])
@@ -0,0 +1,10 @@
1
+ import type { NextConfig } from 'next'
2
+
3
+ const nextConfig: NextConfig = {
4
+ agentRules: false,
5
+ turbopack: {
6
+ root: import.meta.dirname,
7
+ },
8
+ }
9
+
10
+ export default nextConfig
@@ -0,0 +1,27 @@
1
+ import { defineConfig, devices } from '@playwright/test'
2
+
3
+ const isCI = !!process.env.CI
4
+ const baseURL = `http://localhost:${3000 + (Number(process.env.WORKTREE_PORT_OFFSET) || 0)}`
5
+
6
+ export default defineConfig({
7
+ testDir: 'e2e',
8
+ forbidOnly: isCI,
9
+ retries: isCI ? 2 : 0,
10
+ reporter: isCI ? 'list' : 'html',
11
+ use: {
12
+ trace: 'on-first-retry',
13
+ baseURL,
14
+ },
15
+ projects: [
16
+ { name: 'chromium', use: { ...devices['Desktop Chrome'] } },
17
+ { name: 'firefox', use: { ...devices['Desktop Firefox'] } },
18
+ { name: 'webkit', use: { ...devices['Desktop Safari'] } },
19
+ ],
20
+ webServer: {
21
+ command: process.env.DIST_PREBUILT
22
+ ? 'bun run preview'
23
+ : 'bun run build && bun run preview',
24
+ url: baseURL,
25
+ reuseExistingServer: false,
26
+ },
27
+ })
@@ -0,0 +1,28 @@
1
+ import path from 'node:path'
2
+
3
+ import { defineConfig } from 'vitest/config'
4
+
5
+ export default defineConfig({
6
+ resolve: {
7
+ alias: {
8
+ '@': path.resolve(__dirname, 'src'),
9
+ },
10
+ },
11
+ test: {
12
+ environment: 'jsdom',
13
+ globals: true,
14
+ setupFiles: ['src/test/setup.ts'],
15
+ passWithNoTests: true,
16
+ exclude: [
17
+ '**/node_modules/**',
18
+ '**/.next/**',
19
+ '**/e2e/**',
20
+ '**/.{idea,git,cache,output,temp}/**',
21
+ ],
22
+ coverage: {
23
+ provider: 'v8',
24
+ reporter: ['text', 'json', 'html'],
25
+ exclude: ['node_modules/', 'src/test/setup.ts', 'e2e/'],
26
+ },
27
+ },
28
+ })
@@ -0,0 +1,19 @@
1
+ [stack]
2
+ name = "nextjs"
3
+ extends = "web"
4
+ runtime = "bun"
5
+ scaffold = "bunx create-next-app@latest {{name}} --typescript --tailwind --eslint --app --src-dir --import-alias \"@/*\" --use-bun --skip-install --disable-git --no-agents-md --yes"
6
+
7
+ [dependencies.dev]
8
+ packages = [
9
+ "next",
10
+ "@tailwindcss/postcss",
11
+ ]
12
+
13
+ [scripts]
14
+ "preview" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next start --port $((3000 + WORKTREE_PORT_OFFSET))"
15
+ "typecheck" = "next typegen && tsc --noEmit"
16
+
17
+ [scripts.override]
18
+ "dev" = "WORKTREE_PORT_OFFSET=$(bash scripts/worktree-port.sh) && export WORKTREE_PORT_OFFSET && next dev --port $((3000 + WORKTREE_PORT_OFFSET))"
19
+ "screenshot" = "PREVIEW_PORT=$(bash scripts/worktree-port.sh 3000) && export PREVIEW_PORT && bash scripts/screenshot.sh"
@@ -0,0 +1,47 @@
1
+ # Tooling nextjs reference
2
+
3
+ > Extends: `web`. Apply web stack first.
4
+
5
+ ## Overview
6
+
7
+ The nextjs stack covers Next.js + TypeScript projects using the App Router. It ships golden configs for `next.config.ts`, `vitest.config.ts` (plain `defineConfig`, no bundler merge), `playwright.config.ts` (build-then-preview), and an `eslint.config.js` that extends the web layer's config with `.next` ignores and an App Router override. Shared web tooling (ESLint base, screenshot template, VS Code, CI, verify script) comes from the `web` layer.
8
+
9
+ ## Scaffold checklist
10
+
11
+ 1. Scaffold with `bunx create-next-app@latest <name> --typescript --tailwind --eslint --app --src-dir --import-alias "@/*" --use-bun --skip-install --disable-git --no-agents-md --yes`. `--no-agents-md` skips the scaffold-time `AGENTS.md`/`CLAUDE.md` write, which otherwise duplicates the root `CLAUDE.md`.
12
+ 2. Install web tooling: `canon tooling sync web . --write`
13
+ 3. Install nextjs configs: `canon tooling sync nextjs . --write`
14
+ 4. Extend the `ci` and `development` context entries under `.claude/context/` per the web reference's extend sections plus the nextjs rows below.
15
+ 5. Run `bun run lint:fix` then `bun run check`.
16
+
17
+ ## What ships as golden configs
18
+
19
+ - `next.config.ts`: `agentRules: false` stops Next from regenerating `AGENTS.md`/`CLAUDE.md` on every run, which would otherwise compete with the root `CLAUDE.md`. `turbopack.root: import.meta.dirname` pins the workspace root, silencing Next's multi-lockfile inference warning in any checkout carrying more than one lockfile above the project, a worktree included.
20
+ - `vitest.config.ts`: plain `defineConfig`, no `mergeConfig` or `getViteConfig` since Next has no Vite config to merge from. jsdom, globals, setup file, `passWithNoTests: true`, v8 coverage, `.next/**` in test excludes.
21
+ - `playwright.config.ts`: all browsers, `webServer` runs `bun run build && bun run preview` on port `3000` plus `WORKTREE_PORT_OFFSET`, `reuseExistingServer: false`. `DIST_PREBUILT` drops the `build` half, matching the astro stack's flag.
22
+ - `eslint.config.js`: overrides the web layer with `.next` and `next-env.d.ts` added to `globalIgnores`, keeping `react-hooks`/`react-refresh` since this stack still ships React components. `react-refresh/only-export-components` is `off` for `src/app/**`, since route and layout files export non-component values the rule would otherwise flag.
23
+
24
+ ## Port
25
+
26
+ Next has no config-file port hook, unlike `astro.config.mjs`'s `server.port` or `vite.config.ts`'s `server.port`. The `${base} + WORKTREE_PORT_OFFSET` formula is computed twice instead of once: in `playwright.config.ts` as a JS expression, and in `[scripts.override]` for `dev` and `preview` as shell arithmetic passed to `next`'s own `--port` flag. Default port is `3000`, Next's own default.
27
+
28
+ ## Typecheck
29
+
30
+ `typecheck` runs `next typegen && tsc --noEmit`. The App Router's route-level types (`LayoutProps`, `PageProps`) are generated into `.next/types/`, gitignored and absent from a fresh checkout, and `tsc` fails on them unresolved without the typegen step first.
31
+
32
+ ## No golden `tsconfig.json`
33
+
34
+ `create-next-app`'s own default `tsconfig.json` needs no changes beyond project-specific path aliases, so a golden copy here would ship nothing the scaffold does not already write.
35
+
36
+ ## Development docs (extend)
37
+
38
+ Append to the `## Scripts` table:
39
+
40
+ | `bun run dev` | Start the Next dev server on port 3000, plus this worktree's port offset. |
41
+ | `bun run build` | Build the production bundle. |
42
+ | `bun run preview` | Serve the built bundle locally, on port 3000 plus the worktree offset. |
43
+ | `bun run typecheck` | Run `next typegen` then `tsc --noEmit`. |
44
+
45
+ ## CI docs (extend)
46
+
47
+ In `.claude/context/ci.md`, the Typecheck row's assertion reads: `` `next typegen && tsc --noEmit` passes ``.
@@ -0,0 +1,3 @@
1
+ logomark
2
+ turbopack
3
+ vercel
@@ -11,7 +11,8 @@ fi
11
11
  bun run build
12
12
  bun run preview >/dev/null 2>&1 &
13
13
  PREVIEW_PID=$!
14
- trap 'kill "$PREVIEW_PID" 2>/dev/null || true; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
14
+ # shellcheck disable=SC2154 # pid is bound by the for loop inside the single-quoted trap body, which shellcheck does not track there
15
+ trap 'kill "$PREVIEW_PID" 2>/dev/null || true; if command -v lsof >/dev/null 2>&1; then for pid in $(lsof -ti tcp:"$PREVIEW_PORT" 2>/dev/null); do kill "$pid" 2>/dev/null || true; done; else echo "lsof not found on PATH; cannot confirm the preview port is clear of a detached grandchild." >&2; fi; wait "$PREVIEW_PID" 2>/dev/null || true' EXIT
15
16
 
16
17
  for _ in $(seq 1 40); do
17
18
  if curl -sSf "http://localhost:$PREVIEW_PORT/" >/dev/null 2>&1; then