@erclx/canon 5.26.0 → 5.28.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/README.md +2 -2
- package/claude/.claude-plugin/plugin.json +1 -1
- package/claude/skills/api-design/SKILL.md +2 -0
- package/claude/skills/auto-ship/SKILL.md +3 -11
- package/claude/skills/auto-ship/references/review-findings.md +20 -0
- package/claude/skills/backlog-triage/SKILL.md +2 -0
- package/claude/skills/bash-cli-script/SKILL.md +2 -0
- package/claude/skills/build-in-slices/SKILL.md +2 -0
- package/claude/skills/canon-cli/SKILL.md +2 -0
- package/claude/skills/canon-feedback/SKILL.md +2 -0
- package/claude/skills/canon-feedback-triage/SKILL.md +2 -0
- package/claude/skills/canon-operator/SKILL.md +2 -0
- package/claude/skills/canon-rollout/SKILL.md +3 -11
- package/claude/skills/canon-rollout/references/failure-recovery.md +20 -0
- package/claude/skills/canvas/SKILL.md +13 -0
- package/claude/skills/ci-workflow/SKILL.md +2 -0
- package/claude/skills/code-craft/SKILL.md +2 -0
- package/claude/skills/codebase-layout/SKILL.md +2 -0
- package/claude/skills/context-fold/SKILL.md +3 -17
- package/claude/skills/context-fold/references/completion-report.md +26 -0
- package/claude/skills/create-rule/SKILL.md +2 -0
- package/claude/skills/create-skill/SKILL.md +2 -0
- package/claude/skills/create-standard/SKILL.md +2 -0
- package/claude/skills/decision-escalate/SKILL.md +2 -0
- package/claude/skills/deploy-app/SKILL.md +2 -0
- package/claude/skills/deprecation-migration/SKILL.md +2 -0
- package/claude/skills/design-extract/SKILL.md +2 -0
- package/claude/skills/design-taste/SKILL.md +2 -0
- package/claude/skills/docs-sync/SKILL.md +2 -0
- package/claude/skills/document-health/SKILL.md +2 -0
- package/claude/skills/draft-and-pick/SKILL.md +2 -0
- package/claude/skills/draft-doc/SKILL.md +2 -0
- package/claude/skills/draft-doc/references/wiki.md +1 -1
- package/claude/skills/draft-figure/SKILL.md +2 -0
- package/claude/skills/draft-identity/SKILL.md +2 -0
- package/claude/skills/draft-ready/SKILL.md +5 -1
- package/claude/skills/draft-screencast/SKILL.md +2 -0
- package/claude/skills/draft-slides/SKILL.md +2 -0
- package/claude/skills/git-branch/SKILL.md +2 -0
- package/claude/skills/git-commit/SKILL.md +2 -0
- package/claude/skills/git-followup/SKILL.md +2 -0
- package/claude/skills/git-issue/SKILL.md +2 -0
- package/claude/skills/git-pr/SKILL.md +2 -0
- package/claude/skills/git-pr/references/evidence.md +7 -7
- package/claude/skills/git-ship/SKILL.md +2 -0
- package/claude/skills/git-split/SKILL.md +2 -0
- package/claude/skills/git-stage/SKILL.md +2 -0
- package/claude/skills/git-worktree/SKILL.md +2 -0
- package/claude/skills/index-lookup/SKILL.md +2 -0
- package/claude/skills/markdown-propose/SKILL.md +2 -0
- package/claude/skills/memory-capture/SKILL.md +2 -0
- package/claude/skills/memory-review/SKILL.md +2 -0
- package/claude/skills/plan-feature/SKILL.md +2 -0
- package/claude/skills/plan-groundwork/SKILL.md +2 -0
- package/claude/skills/plan-intake/SKILL.md +2 -0
- package/claude/skills/plan-intake-answer/SKILL.md +2 -0
- package/claude/skills/project-commands/SKILL.md +2 -0
- package/claude/skills/read-frames/SKILL.md +2 -0
- package/claude/skills/record-screencast/SKILL.md +2 -0
- package/claude/skills/repo-metadata/SKILL.md +2 -0
- package/claude/skills/restate-plainly/SKILL.md +2 -0
- package/claude/skills/review-address/REQUIREMENT.md +4 -2
- package/claude/skills/review-address/SKILL.md +19 -14
- package/claude/skills/review-address/references/post-review-findings.md +16 -0
- package/claude/skills/review-address/references/rebase-conflicts.md +4 -2
- package/claude/skills/review-branch/SKILL.md +2 -0
- package/claude/skills/review-craft/SKILL.md +2 -0
- package/claude/skills/review-craft/references/ui.md +6 -6
- package/claude/skills/review-pr/SKILL.md +4 -8
- package/claude/skills/review-pr/references/body-variants.md +52 -0
- package/claude/skills/review-pr/references/key-changes.md +16 -0
- package/claude/skills/review-pr/references/post.md +15 -43
- package/claude/skills/review-ui/REQUIREMENT.md +2 -2
- package/claude/skills/review-ui/SKILL.md +16 -12
- package/claude/skills/review-ui/references/driving.md +8 -8
- package/claude/skills/review-ui/references/post.md +13 -3
- package/claude/skills/review-ui/references/tick.md +22 -0
- package/claude/skills/role-orchestrator/REQUIREMENT.md +15 -15
- package/claude/skills/role-orchestrator/SKILL.md +31 -42
- package/claude/skills/role-orchestrator/references/orchestrator-dispatch.md +18 -16
- package/claude/skills/role-orchestrator/references/orchestrator-handback.md +13 -3
- package/claude/skills/role-orchestrator/references/orchestrator-inbound.md +23 -0
- package/claude/skills/role-orchestrator/references/orchestrator-launch.md +6 -34
- package/claude/skills/role-orchestrator/references/orchestrator-output.md +4 -4
- package/claude/skills/role-orchestrator/references/orchestrator-parked.md +5 -5
- package/claude/skills/role-orchestrator/references/orchestrator-poll.md +12 -23
- package/claude/skills/role-orchestrator/references/orchestrator-refill.md +6 -6
- package/claude/skills/role-orchestrator/references/orchestrator-review-dispatch.md +1 -1
- package/claude/skills/role-orchestrator/references/orchestrator-reviewer-launch.md +38 -0
- package/claude/skills/role-orchestrator/references/orchestrator-sweep.md +2 -2
- package/claude/skills/role-orchestrator/references/orchestrator-watch.md +26 -0
- package/claude/skills/role-orchestrator/scripts/poll.ts +304 -10
- package/claude/skills/role-planner/REQUIREMENT.md +1 -1
- package/claude/skills/role-planner/SKILL.md +4 -2
- package/claude/skills/role-reviewer/REQUIREMENT.md +1 -1
- package/claude/skills/role-reviewer/SKILL.md +3 -1
- package/claude/skills/role-worker/SKILL.md +5 -2
- package/claude/skills/search-craft/SKILL.md +2 -0
- package/claude/skills/seed-sync/SKILL.md +2 -0
- package/claude/skills/session-compact/SKILL.md +2 -0
- package/claude/skills/session-map/SKILL.md +2 -0
- package/claude/skills/session-relay/SKILL.md +2 -0
- package/claude/skills/session-resume/SKILL.md +2 -0
- package/claude/skills/session-worktree/SKILL.md +3 -11
- package/claude/skills/session-worktree/references/install-command.md +20 -0
- package/claude/skills/sketch-design/SKILL.md +2 -0
- package/claude/skills/systematic-debugging/SKILL.md +2 -0
- package/claude/skills/target-check/SKILL.md +2 -0
- package/claude/skills/target-setup/SKILL.md +2 -0
- package/claude/skills/task-board/SKILL.md +2 -0
- package/claude/skills/teach-workspace/SKILL.md +5 -15
- package/claude/skills/teach-workspace/references/lesson-build.md +75 -0
- package/claude/skills/teach-workspace/references/lesson-craft.md +13 -69
- package/claude/skills/teach-workspace/references/revisit.md +22 -0
- package/claude/skills/test-craft/SKILL.md +2 -0
- package/claude/skills/test-first/SKILL.md +2 -0
- package/claude/skills/ui-checklist/SKILL.md +3 -1
- package/claude/skills/ux-audit/SKILL.md +2 -0
- package/claude/skills/ux-measure/SKILL.md +2 -0
- package/claude/skills/ux-walkthrough/SKILL.md +2 -0
- package/claude/skills/video-craft/SKILL.md +2 -0
- package/claude/skills/write-human/SKILL.md +2 -0
- package/claude/skills/youtube-transcripts/SKILL.md +2 -0
- package/docs/agents/canvas.md +24 -8
- package/docs/agents/capture.md +5 -5
- package/docs/agents/commands.md +2 -2
- package/docs/agents/gate.md +3 -1
- package/docs/agents/index.md +5 -4
- package/docs/agents/install-and-sync.md +7 -0
- package/docs/agents/key-changes.md +1 -1
- package/docs/agents/pr-evidence.md +47 -23
- package/docs/agents/pr-local.md +19 -13
- package/docs/agents/pr-preview.md +44 -21
- package/docs/agents/pr-tick.md +72 -0
- package/docs/agents/sandbox.md +30 -6
- package/docs/agents/scripting.md +8 -1
- package/docs/agents/skills-audit.md +2 -1
- package/docs/agents/tasks-plans.md +1 -1
- package/docs/agents/tasks-validate.md +18 -18
- package/docs/agents/tasks.md +1 -1
- package/docs/workflow/operating-model.md +15 -21
- package/docs/workflow/parallel-features.md +24 -3
- package/docs/workflow/skill-map.md +3 -1
- package/docs/workflow/zshrc-aliases.md +1 -1
- package/package.json +1 -1
- package/scripts/core/regen-hero.ts +1 -1
- package/scripts/lib/sandbox-path.sh +6 -162
- package/src/audits/baseline.ts +1 -1
- package/src/audits/run.ts +2 -2
- package/src/canvas/address.ts +12 -3
- package/src/canvas/capture.ts +1 -1
- package/src/canvas/client/inspector/align-grid.tsx +162 -0
- package/src/canvas/client/inspector/color-field.tsx +201 -171
- package/src/canvas/client/inspector/color-inputs.tsx +139 -0
- package/src/canvas/client/inspector/color-picker.tsx +668 -0
- package/src/canvas/client/inspector/color-space.ts +240 -0
- package/src/canvas/client/inspector/field.tsx +66 -3
- package/src/canvas/client/inspector/fields.css +473 -6
- package/src/canvas/client/inspector/size-field.tsx +69 -0
- package/src/canvas/client/inspector/token-select.tsx +109 -0
- package/src/canvas/client/inspector/values.ts +117 -3
- package/src/canvas/client/inspector.tsx +311 -15
- package/src/canvas/client/keys.ts +54 -0
- package/src/canvas/client/selection.tsx +199 -0
- package/src/canvas/client/shell.css +210 -12
- package/src/canvas/client/state.ts +165 -14
- package/src/canvas/client/surface.tsx +341 -39
- package/src/canvas/client/tools.tsx +64 -0
- package/src/canvas/content.ts +51 -2
- package/src/canvas/edit.ts +49 -6
- package/src/canvas/editing.ts +203 -0
- package/src/canvas/global.d.ts +15 -0
- package/src/canvas/server.ts +149 -12
- package/src/canvas/tokens.ts +60 -18
- package/src/census/count.ts +1 -1
- package/src/claude/seeds.ts +3 -3
- package/src/claude/skills-audit.ts +28 -0
- package/src/claude/skills-drift.ts +1 -1
- package/src/claude/skills-families.ts +36 -0
- package/src/claude/skills-list.ts +76 -15
- package/src/claude/skills-reach.ts +1 -1
- package/src/commands/audits.ts +2 -2
- package/src/commands/canvas.ts +180 -3
- package/src/commands/claude.ts +70 -12
- package/src/commands/design.ts +145 -5
- package/src/commands/docs.ts +1 -1
- package/src/commands/feedback.ts +2 -2
- package/src/commands/gate.ts +9 -2
- package/src/commands/gov.ts +3 -3
- package/src/commands/hooks.ts +1 -1
- package/src/commands/indexes.ts +1 -1
- package/src/commands/init.ts +2 -2
- package/src/commands/intake.ts +1 -1
- package/src/commands/migrate.ts +2 -2
- package/src/commands/pr.ts +288 -65
- package/src/commands/records.ts +1 -1
- package/src/commands/repo.ts +1 -1
- package/src/commands/sandbox.ts +64 -15
- package/src/commands/sessions.ts +1 -1
- package/src/commands/slides.ts +1 -1
- package/src/commands/sync.ts +2 -2
- package/src/commands/tasks.ts +21 -4
- package/src/commands/teach.ts +1 -1
- package/src/commands/tooling.ts +1 -1
- package/src/commands/transcripts.ts +2 -2
- package/src/commands/upgrade.ts +1 -1
- package/src/commands/wiki.ts +1 -1
- package/src/commands/worktrees.ts +1 -1
- package/src/comments/scan.ts +1 -1
- package/src/comments/trend.ts +1 -1
- package/src/context/architecture.ts +1 -1
- package/src/context/citations.ts +3 -3
- package/src/context/classify/extract.ts +3 -3
- package/src/context/folders.ts +2 -2
- package/src/context/requirements.ts +1 -1
- package/src/design/project-fonts.ts +392 -0
- package/src/docs/read.ts +1 -1
- package/src/exec.ts +1 -1
- package/src/feedback/github.ts +2 -2
- package/src/gate/measures.ts +48 -10
- package/src/gate/sequencer.ts +56 -4
- package/src/gate/stages.ts +17 -0
- package/src/gate/suite-lock.ts +214 -0
- package/src/{git-files.ts → git/files.ts} +2 -2
- package/src/{git-ignore.ts → git/ignore.ts} +1 -1
- package/src/{worktree.ts → git/worktree.ts} +1 -1
- package/src/gov/citations.ts +1 -1
- package/src/gov/install.ts +1 -1
- package/src/gov/superseded.ts +1 -1
- package/src/gov/test-order.ts +2 -2
- package/src/help.ts +1 -1
- package/src/hooks/post-merge.ts +1 -1
- package/src/indexes/walk.ts +1 -1
- package/src/intake/folder.ts +1 -1
- package/src/labels/audit.ts +1 -1
- package/src/labels/map.ts +1 -1
- package/src/labels/phase.ts +3 -3
- package/src/markdown/files.ts +1 -1
- package/src/migrate/apply.ts +1 -1
- package/src/migrate/record-layout.ts +1 -1
- package/src/migrate/records.ts +11 -11
- package/src/migrate/scratch-evidence.ts +1 -1
- package/src/migrate/surface-roots.ts +6 -6
- package/src/pr/bijection.ts +1 -1
- package/src/pr/evidence.ts +250 -59
- package/src/pr/local.ts +16 -10
- package/src/pr/preview.ts +78 -2
- package/src/pr/tick.ts +56 -0
- package/src/process/harness.ts +1 -1
- package/src/records/backup.ts +2 -2
- package/src/records/ordinal.ts +1 -1
- package/src/records/prune.ts +1 -1
- package/src/records/size.ts +1 -1
- package/src/records/stale-canonical.ts +2 -2
- package/src/records/stale.ts +1 -1
- package/src/records/validate.ts +2 -2
- package/src/{project-root.ts → roots/project.ts} +1 -1
- package/src/{record-root.ts → roots/record.ts} +1 -1
- package/src/sandbox/coverage.ts +11 -12
- package/src/sandbox/equivalence.ts +57 -26
- package/src/sandbox/headless/dispatch.ts +264 -0
- package/src/sandbox/headless/run.ts +653 -0
- package/src/sandbox/headless/watch.ts +145 -0
- package/src/sandbox/hooks.ts +1 -1
- package/src/sandbox/provision.ts +90 -29
- package/src/sandbox/scenario.ts +588 -0
- package/src/sandbox/tree.ts +4 -8
- package/src/secrets/scan.ts +1 -1
- package/src/sessions/claim.ts +1 -1
- package/src/sessions/live.ts +4 -1
- package/src/sessions/resolve.ts +1 -1
- package/src/sessions/transfer/bundle.ts +2 -2
- package/src/slides/decks.ts +1 -1
- package/src/slides/package/fonts.ts +6 -2
- package/src/standards/audit.ts +2 -2
- package/src/standards/read.ts +1 -1
- package/src/sync/check.ts +2 -2
- package/src/sync/engine.ts +3 -3
- package/src/sync/git.ts +1 -1
- package/src/sync/history.ts +1 -1
- package/src/sync/layout.ts +2 -2
- package/src/sync/reverse.ts +1 -1
- package/src/sync/stamp.ts +1 -1
- package/src/sync/target.ts +1 -1
- package/src/sync/workflow.ts +1 -1
- package/src/targets/pulls.ts +2 -2
- package/src/targets/sweep.ts +2 -2
- package/src/{target.ts → targets/validate.ts} +1 -1
- package/src/tasks/answers.ts +1 -1
- package/src/tasks/archive.ts +1 -1
- package/src/tasks/label.ts +1 -1
- package/src/tasks/reach.ts +2 -2
- package/src/tasks/trunk.ts +1 -1
- package/src/tasks/validate.ts +38 -14
- package/src/teach/nav.ts +1 -1
- package/src/teach/workspace.ts +1 -1
- package/src/tooling/inject.ts +2 -2
- package/src/tooling/read.ts +1 -1
- package/src/tooling/scan.ts +1 -1
- package/src/tooling/subfolder.ts +1 -1
- package/src/version/installed.ts +1 -1
- package/src/version/skew.ts +1 -1
- package/src/web/readme-citations.ts +1 -1
- package/src/worktrees/bare-flag.ts +1 -1
- package/src/worktrees/reclaim.ts +2 -2
- package/src/worktrees/remove.ts +1 -1
- package/standards/board.md +3 -3
- package/standards/canvas.md +2 -1
- package/standards/plan.md +13 -7
- package/standards/skill.md +1 -0
- package/standards/wiki.md +12 -3
- package/tooling/base/configs/.github/workflows/pr-local-preview.yml +2 -2
- package/tsconfig.json +1 -1
- package/scripts/lib/sandbox-dispatch.sh +0 -190
- /package/src/{client-commands.ts → gate/client-commands.ts} +0 -0
- /package/src/{git-base.ts → git/base.ts} +0 -0
- /package/src/{git-env.ts → git/env.ts} +0 -0
- /package/src/{legacy-path.ts → roots/legacy.ts} +0 -0
- /package/src/{surface-root.ts → roots/surface.ts} +0 -0
- /package/src/{copy.ts → sync/copy.ts} +0 -0
|
@@ -0,0 +1,392 @@
|
|
|
1
|
+
import {
|
|
2
|
+
appendFileSync,
|
|
3
|
+
copyFileSync,
|
|
4
|
+
existsSync,
|
|
5
|
+
mkdirSync,
|
|
6
|
+
readdirSync,
|
|
7
|
+
readFileSync,
|
|
8
|
+
realpathSync,
|
|
9
|
+
statSync,
|
|
10
|
+
} from 'node:fs'
|
|
11
|
+
import {
|
|
12
|
+
basename,
|
|
13
|
+
dirname,
|
|
14
|
+
extname,
|
|
15
|
+
isAbsolute,
|
|
16
|
+
join,
|
|
17
|
+
relative,
|
|
18
|
+
resolve,
|
|
19
|
+
} from 'node:path'
|
|
20
|
+
import { primaryFontFamily } from '@/capture/sources'
|
|
21
|
+
import { DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR } from '@/design/adapter'
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* A project's own faces, declared as plain `@font-face` rules in its override
|
|
25
|
+
* sheets and stored beside them under `project/`, which sync treats as
|
|
26
|
+
* project-authored by location and never writes. CSS is the declaration
|
|
27
|
+
* format because every consumer already reads it, and reading descriptors
|
|
28
|
+
* out of the font's own tables cannot open WOFF2 without a Brotli parser.
|
|
29
|
+
*
|
|
30
|
+
* A rule's `url()` is read from inside `project/` only, after `realpath`, so
|
|
31
|
+
* an override cannot carry a file from elsewhere on the machine into a frame.
|
|
32
|
+
* A hand-edited sheet reaches this module as often as `addProjectFace` does,
|
|
33
|
+
* so the check sits on the read rather than on the write.
|
|
34
|
+
*/
|
|
35
|
+
|
|
36
|
+
export const PROJECT_FONTS_DIR = 'fonts'
|
|
37
|
+
export const PROJECT_FONTS_SHEET = 'fonts.css'
|
|
38
|
+
|
|
39
|
+
export type FaceProblem = 'missing' | 'outside-folder'
|
|
40
|
+
|
|
41
|
+
export interface ProjectFace {
|
|
42
|
+
readonly family: string
|
|
43
|
+
/** The `font-weight` descriptor verbatim, `400` where the rule omits it. */
|
|
44
|
+
readonly weight: string
|
|
45
|
+
readonly style: string
|
|
46
|
+
/** Relative to the root. */
|
|
47
|
+
readonly sheet: string
|
|
48
|
+
/** The first local `url()` as the rule writes it. */
|
|
49
|
+
readonly file: string
|
|
50
|
+
readonly present: boolean
|
|
51
|
+
readonly problem?: FaceProblem
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export interface DroppedFace {
|
|
55
|
+
readonly sheet: string
|
|
56
|
+
readonly file: string
|
|
57
|
+
readonly reason: FaceProblem
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface InlinedSheet {
|
|
61
|
+
readonly css: string
|
|
62
|
+
readonly dropped: readonly DroppedFace[]
|
|
63
|
+
/** The families of the rules that survived, in sheet order. */
|
|
64
|
+
readonly declared: readonly string[]
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const FORMATS = {
|
|
68
|
+
'.woff2': { mime: 'font/woff2', format: 'woff2', magic: ['wOF2'] },
|
|
69
|
+
'.woff': { mime: 'font/woff', format: 'woff', magic: ['wOFF'] },
|
|
70
|
+
'.ttf': {
|
|
71
|
+
mime: 'font/ttf',
|
|
72
|
+
format: 'truetype',
|
|
73
|
+
magic: ['\u0000\u0001\u0000\u0000', 'true'],
|
|
74
|
+
},
|
|
75
|
+
'.otf': { mime: 'font/otf', format: 'opentype', magic: ['OTTO'] },
|
|
76
|
+
} as const
|
|
77
|
+
|
|
78
|
+
type FontExtension = keyof typeof FORMATS
|
|
79
|
+
|
|
80
|
+
function isFontExtension(ext: string): ext is FontExtension {
|
|
81
|
+
return Object.hasOwn(FORMATS, ext)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const FACE_RULE = /@font-face\s*\{[^}]*\}/g
|
|
85
|
+
const URL = /url\(\s*(['"]?)([^'")]+)\1\s*\)/g
|
|
86
|
+
const SCHEME = /^[a-z][a-z0-9+.-]*:/i
|
|
87
|
+
const DECLARATION = /([A-Za-z-]+)\s*:\s*([^;}]*)/g
|
|
88
|
+
|
|
89
|
+
/** Comments blanked to equal length, so an index into the result fits the source. */
|
|
90
|
+
function masked(css: string): string {
|
|
91
|
+
return css.replace(/\/\*[\s\S]*?\*\//g, (comment) =>
|
|
92
|
+
' '.repeat(comment.length),
|
|
93
|
+
)
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function descriptor(rule: string, name: string): string | undefined {
|
|
97
|
+
const match = rule.match(new RegExp(`(?:^|[;{\\s])${name}\\s*:\\s*([^;}]*)`))
|
|
98
|
+
return match?.[1]?.trim()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function familyOf(rule: string): string {
|
|
102
|
+
return primaryFontFamily(descriptor(rule, 'font-family') ?? '')
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Every family a declaration value lists, quoted or not, across the sheet
|
|
107
|
+
* outside its `@font-face` rules, which declare a face rather than use one.
|
|
108
|
+
* Reads every property rather than only `font-family`, since a token such as
|
|
109
|
+
* `--figure-hand` names its stack under a name that ends in neither.
|
|
110
|
+
*/
|
|
111
|
+
export function namedFamilies(css: string): Set<string> {
|
|
112
|
+
const uses = masked(css).replace(FACE_RULE, '')
|
|
113
|
+
const named = new Set<string>()
|
|
114
|
+
for (const [, , value] of uses.matchAll(DECLARATION)) {
|
|
115
|
+
for (const item of (value ?? '').split(',')) {
|
|
116
|
+
const family = primaryFontFamily(item)
|
|
117
|
+
if (family) named.add(family)
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
return named
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
function projectDir(root: string): string {
|
|
124
|
+
return join(root, DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR)
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function isWithin(folder: string, path: string): boolean {
|
|
128
|
+
const inside = relative(folder, path)
|
|
129
|
+
return inside !== '' && !inside.startsWith('..') && !isAbsolute(inside)
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
type Located =
|
|
133
|
+
| { readonly ok: true; readonly path: string }
|
|
134
|
+
| { readonly ok: false; readonly reason: FaceProblem }
|
|
135
|
+
|
|
136
|
+
/** A URL path as the name on disk, or undefined for a malformed escape. */
|
|
137
|
+
function decoded(url: string): string | undefined {
|
|
138
|
+
try {
|
|
139
|
+
return decodeURIComponent(url.replace(/[?#].*$/, ''))
|
|
140
|
+
} catch {
|
|
141
|
+
return undefined
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Confined lexically before any read, then again after resolving links. The
|
|
147
|
+
* check runs on the decoded path, so an escaped `..` cannot pass it.
|
|
148
|
+
*/
|
|
149
|
+
function locate(root: string, sheet: string, url: string): Located {
|
|
150
|
+
const folder = projectDir(root)
|
|
151
|
+
const clean = decoded(url)
|
|
152
|
+
if (clean === undefined) return { ok: false, reason: 'missing' }
|
|
153
|
+
if (isAbsolute(clean)) return { ok: false, reason: 'outside-folder' }
|
|
154
|
+
const path = resolve(join(root, dirname(sheet)), clean)
|
|
155
|
+
if (!isWithin(folder, path)) return { ok: false, reason: 'outside-folder' }
|
|
156
|
+
if (!existsSync(path) || !statSync(path).isFile()) {
|
|
157
|
+
return { ok: false, reason: 'missing' }
|
|
158
|
+
}
|
|
159
|
+
if (!isWithin(realpathSync(folder), realpathSync(path))) {
|
|
160
|
+
return { ok: false, reason: 'outside-folder' }
|
|
161
|
+
}
|
|
162
|
+
return { ok: true, path }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
function localUrls(rule: string): string[] {
|
|
166
|
+
return [...rule.matchAll(URL)]
|
|
167
|
+
.map((match) => match[2]?.trim() ?? '')
|
|
168
|
+
.filter((url) => url !== '' && !SCHEME.test(url))
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function mimeOf(path: string): string {
|
|
172
|
+
const ext = extname(path).toLowerCase()
|
|
173
|
+
return isFontExtension(ext) ? FORMATS[ext].mime : 'application/octet-stream'
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Rewrites each local `url()` in a sheet's `@font-face` rules to a `data:`
|
|
178
|
+
* URI, so a frame carries the face rather than a path the frame's origin
|
|
179
|
+
* cannot serve. A rule naming a file that is missing or leads out of
|
|
180
|
+
* `project/` is dropped whole and reported, and the sheet otherwise renders.
|
|
181
|
+
* A `data:` or remote URL is left as written.
|
|
182
|
+
*/
|
|
183
|
+
export function inlineProjectFaces(
|
|
184
|
+
root: string,
|
|
185
|
+
sheet: string,
|
|
186
|
+
css: string,
|
|
187
|
+
): InlinedSheet {
|
|
188
|
+
const dropped: DroppedFace[] = []
|
|
189
|
+
const declared: string[] = []
|
|
190
|
+
let out = ''
|
|
191
|
+
let cursor = 0
|
|
192
|
+
for (const match of masked(css).matchAll(FACE_RULE)) {
|
|
193
|
+
const start = match.index
|
|
194
|
+
const end = start + match[0].length
|
|
195
|
+
const rule = css.slice(start, end)
|
|
196
|
+
out += css.slice(cursor, start)
|
|
197
|
+
cursor = end
|
|
198
|
+
|
|
199
|
+
const urls = localUrls(match[0])
|
|
200
|
+
const located = urls.map((url) => ({ url, at: locate(root, sheet, url) }))
|
|
201
|
+
const failed = located.find(({ at }) => !at.ok)
|
|
202
|
+
if (failed && !failed.at.ok) {
|
|
203
|
+
dropped.push({ sheet, file: failed.url, reason: failed.at.reason })
|
|
204
|
+
continue
|
|
205
|
+
}
|
|
206
|
+
let inlined = rule
|
|
207
|
+
for (const { url, at } of located) {
|
|
208
|
+
if (!at.ok) continue
|
|
209
|
+
const data = `data:${mimeOf(at.path)};base64,${readFileSync(at.path).toString('base64')}`
|
|
210
|
+
inlined = inlined.replace(url, data)
|
|
211
|
+
}
|
|
212
|
+
out += inlined
|
|
213
|
+
declared.push(familyOf(match[0]))
|
|
214
|
+
}
|
|
215
|
+
return { css: out + css.slice(cursor), dropped, declared }
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/** The project's override sheets, root-relative, in the order a frame cascades them. */
|
|
219
|
+
export function projectSheets(root: string): string[] {
|
|
220
|
+
const folder = projectDir(root)
|
|
221
|
+
if (!existsSync(folder)) return []
|
|
222
|
+
return readdirSync(folder)
|
|
223
|
+
.filter((name) => name.endsWith('.css'))
|
|
224
|
+
.sort((a, b) => a.localeCompare(b))
|
|
225
|
+
.map((name) => join(DESIGN_INSTALL_DIR, DESIGN_PROJECT_SUBDIR, name))
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/** Every face the override sheets declare, without reading a font file. */
|
|
229
|
+
export function listProjectFaces(root: string): ProjectFace[] {
|
|
230
|
+
return projectSheets(root).flatMap((sheet) => {
|
|
231
|
+
const css = masked(readFileSync(join(root, sheet), 'utf8'))
|
|
232
|
+
return [...css.matchAll(FACE_RULE)].map((match): ProjectFace => {
|
|
233
|
+
const rule = match[0]
|
|
234
|
+
const local = localUrls(rule)[0]
|
|
235
|
+
const external = [...rule.matchAll(URL)][0]?.[2]?.trim() ?? ''
|
|
236
|
+
/*
|
|
237
|
+
* A rule carrying its face inline or remotely has no file to read, and
|
|
238
|
+
* the frame keeps it as written, so it lists as present. A `data:` URI
|
|
239
|
+
* is shortened to its scheme rather than echoing the whole face.
|
|
240
|
+
*/
|
|
241
|
+
const file = local ?? (external.startsWith('data:') ? 'data:' : external)
|
|
242
|
+
const at: Located = local
|
|
243
|
+
? locate(root, sheet, local)
|
|
244
|
+
: external
|
|
245
|
+
? { ok: true, path: external }
|
|
246
|
+
: { ok: false, reason: 'missing' }
|
|
247
|
+
return {
|
|
248
|
+
family: familyOf(rule),
|
|
249
|
+
weight: descriptor(rule, 'font-weight') ?? '400',
|
|
250
|
+
style: descriptor(rule, 'font-style') ?? 'normal',
|
|
251
|
+
sheet,
|
|
252
|
+
file,
|
|
253
|
+
present: at.ok,
|
|
254
|
+
...(!at.ok && { problem: at.reason }),
|
|
255
|
+
}
|
|
256
|
+
})
|
|
257
|
+
})
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export interface FaceSpec {
|
|
261
|
+
readonly family: string
|
|
262
|
+
readonly weight?: string
|
|
263
|
+
readonly style?: string
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
export type AddOutcome =
|
|
267
|
+
| {
|
|
268
|
+
readonly ok: true
|
|
269
|
+
readonly family: string
|
|
270
|
+
readonly weight: string
|
|
271
|
+
readonly style: string
|
|
272
|
+
/** Relative to the sheet's folder, as the rule writes it. */
|
|
273
|
+
readonly file: string
|
|
274
|
+
readonly sheet: string
|
|
275
|
+
}
|
|
276
|
+
| {
|
|
277
|
+
readonly ok: false
|
|
278
|
+
readonly reason: 'invalid-font' | 'invalid-face' | 'exists'
|
|
279
|
+
readonly detail: string
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/** A name CSS takes inside single quotes with no escaping. */
|
|
283
|
+
const SAFE_FAMILY = /^[A-Za-z0-9][A-Za-z0-9 _-]*$/
|
|
284
|
+
/** A file name a single-quoted `url()` takes with no escaping. */
|
|
285
|
+
const SAFE_FILE = /^[A-Za-z0-9][A-Za-z0-9._-]*$/
|
|
286
|
+
const WEIGHT = /^\d{1,4}( \d{1,4})?$/
|
|
287
|
+
|
|
288
|
+
function weightInRange(weight: string): boolean {
|
|
289
|
+
return weight.split(' ').every((n) => Number(n) >= 1 && Number(n) <= 1000)
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* Copies a face into `project/fonts/` and appends one rule for it to
|
|
294
|
+
* `project/fonts.css`. Refuses before writing anything when the extension is
|
|
295
|
+
* not a font, the bytes disagree with it, a descriptor would need escaping,
|
|
296
|
+
* or the folder already holds a file of that name.
|
|
297
|
+
*/
|
|
298
|
+
export function addProjectFace(
|
|
299
|
+
root: string,
|
|
300
|
+
source: string,
|
|
301
|
+
spec: FaceSpec,
|
|
302
|
+
): AddOutcome {
|
|
303
|
+
const name = basename(source)
|
|
304
|
+
const ext = extname(name).toLowerCase()
|
|
305
|
+
if (!isFontExtension(ext)) {
|
|
306
|
+
return {
|
|
307
|
+
ok: false,
|
|
308
|
+
reason: 'invalid-font',
|
|
309
|
+
detail: `${name} is not a font. Use woff2, woff, ttf, or otf`,
|
|
310
|
+
}
|
|
311
|
+
}
|
|
312
|
+
if (!SAFE_FILE.test(name)) {
|
|
313
|
+
return {
|
|
314
|
+
ok: false,
|
|
315
|
+
reason: 'invalid-font',
|
|
316
|
+
detail: `${name} must be letters, digits, dots, dashes, or underscores to sit in a url(). Rename it`,
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
if (!existsSync(source) || !statSync(source).isFile()) {
|
|
320
|
+
return { ok: false, reason: 'invalid-font', detail: `${source} not found` }
|
|
321
|
+
}
|
|
322
|
+
const head = readFileSync(source).subarray(0, 4).toString('binary')
|
|
323
|
+
if (!FORMATS[ext].magic.some((magic) => head === magic)) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
reason: 'invalid-font',
|
|
327
|
+
detail: `${name} does not read as ${ext.slice(1)} from its first bytes`,
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
const weight = spec.weight ?? '400'
|
|
332
|
+
const style = spec.style ?? 'normal'
|
|
333
|
+
if (!SAFE_FAMILY.test(spec.family)) {
|
|
334
|
+
return {
|
|
335
|
+
ok: false,
|
|
336
|
+
reason: 'invalid-face',
|
|
337
|
+
detail: `family ${spec.family} must be letters, digits, spaces, dashes, or underscores`,
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
if (!WEIGHT.test(weight) || !weightInRange(weight)) {
|
|
341
|
+
return {
|
|
342
|
+
ok: false,
|
|
343
|
+
reason: 'invalid-face',
|
|
344
|
+
detail: `weight ${weight} must be one number or a range of two, from 1 to 1000`,
|
|
345
|
+
}
|
|
346
|
+
}
|
|
347
|
+
if (style !== 'normal' && style !== 'italic') {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: 'invalid-face',
|
|
351
|
+
detail: `style ${style} must be normal or italic`,
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
const folder = join(projectDir(root), PROJECT_FONTS_DIR)
|
|
356
|
+
const dest = join(folder, name)
|
|
357
|
+
const file = `${PROJECT_FONTS_DIR}/${name}`
|
|
358
|
+
if (existsSync(dest)) {
|
|
359
|
+
return {
|
|
360
|
+
ok: false,
|
|
361
|
+
reason: 'exists',
|
|
362
|
+
detail: `${relative(root, dest)} already exists. Remove it or rename the source`,
|
|
363
|
+
}
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
mkdirSync(folder, { recursive: true })
|
|
367
|
+
copyFileSync(source, dest)
|
|
368
|
+
const sheetPath = join(projectDir(root), PROJECT_FONTS_SHEET)
|
|
369
|
+
const lead =
|
|
370
|
+
existsSync(sheetPath) && readFileSync(sheetPath, 'utf8').trim() !== ''
|
|
371
|
+
? '\n'
|
|
372
|
+
: ''
|
|
373
|
+
appendFileSync(
|
|
374
|
+
sheetPath,
|
|
375
|
+
`${lead}@font-face {
|
|
376
|
+
font-family: '${spec.family}';
|
|
377
|
+
font-weight: ${weight};
|
|
378
|
+
font-style: ${style};
|
|
379
|
+
font-display: swap;
|
|
380
|
+
src: url('${file}') format('${FORMATS[ext].format}');
|
|
381
|
+
}
|
|
382
|
+
`,
|
|
383
|
+
)
|
|
384
|
+
return {
|
|
385
|
+
ok: true,
|
|
386
|
+
family: spec.family,
|
|
387
|
+
weight,
|
|
388
|
+
style,
|
|
389
|
+
file,
|
|
390
|
+
sheet: relative(root, sheetPath),
|
|
391
|
+
}
|
|
392
|
+
}
|
package/src/docs/read.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { existsSync, readFileSync, readdirSync } from 'node:fs'
|
|
2
2
|
import { basename, join } from 'node:path'
|
|
3
3
|
import { stripFrontmatter } from '@/markdown/frontmatter'
|
|
4
|
-
import { SURFACE_ROOTS } from '@/surface
|
|
4
|
+
import { SURFACE_ROOTS } from '@/roots/surface'
|
|
5
5
|
|
|
6
6
|
const INDEX_TOPIC = 'index'
|
|
7
7
|
|
package/src/exec.ts
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { join } from 'node:path'
|
|
2
2
|
import { execa } from 'execa'
|
|
3
|
-
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/project
|
|
3
|
+
import { checkoutMismatchWarning, PROJECT_ROOT } from '@/roots/project'
|
|
4
4
|
import { logWarn } from '@/ui'
|
|
5
5
|
|
|
6
6
|
export async function execScript(
|
package/src/feedback/github.ts
CHANGED
package/src/gate/measures.ts
CHANGED
|
@@ -6,7 +6,7 @@ import {
|
|
|
6
6
|
CLIENT_COMMANDS,
|
|
7
7
|
type ClientCommand,
|
|
8
8
|
clientCommandCitationsIn,
|
|
9
|
-
} from '@/client-commands'
|
|
9
|
+
} from '@/gate/client-commands'
|
|
10
10
|
import {
|
|
11
11
|
isOverCount,
|
|
12
12
|
isOverLength,
|
|
@@ -23,9 +23,10 @@ import {
|
|
|
23
23
|
PRACTICE_SKILLS,
|
|
24
24
|
SHIPPED_CORPUS,
|
|
25
25
|
} from '@/claude/skills-audit'
|
|
26
|
+
import { familyKeys } from '@/claude/skills-families'
|
|
26
27
|
import { measureDesignProse } from '@/design/budget'
|
|
27
28
|
import { DESIGN_DOCUMENT } from '@/design/regen'
|
|
28
|
-
import { listRepositoryFiles } from '@/git
|
|
29
|
+
import { listRepositoryFiles } from '@/git/files'
|
|
29
30
|
import { ceilingFindings } from '@/markdown/ceiling'
|
|
30
31
|
import { resolveMarkdown } from '@/markdown/files'
|
|
31
32
|
import { CHECKPOINTS } from '@/markdown/structure'
|
|
@@ -36,7 +37,7 @@ import {
|
|
|
36
37
|
SHIPPED_CORPORA,
|
|
37
38
|
type ShippedReference,
|
|
38
39
|
} from '@/shipped/references'
|
|
39
|
-
import { surfaceDir } from '@/surface
|
|
40
|
+
import { surfaceDir } from '@/roots/surface'
|
|
40
41
|
import { seedRoots } from '@/tooling/seeds'
|
|
41
42
|
import { plural } from '@/ui'
|
|
42
43
|
import {
|
|
@@ -166,7 +167,7 @@ export function auditsBaselineRel(root: string): string {
|
|
|
166
167
|
}
|
|
167
168
|
|
|
168
169
|
export const CAPTURE_STAMP_FAILURE =
|
|
169
|
-
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/
|
|
170
|
+
'A capture set disagrees with the stamp written when its image was captured. Run canon capture assets/captures --selector .window --out assets/frames and commit each frame with its image and its stamp.'
|
|
170
171
|
|
|
171
172
|
function parseJson(payload: string): unknown {
|
|
172
173
|
try {
|
|
@@ -449,6 +450,42 @@ export const skillPracticeShape: Measure = async (ctx) => {
|
|
|
449
450
|
}
|
|
450
451
|
}
|
|
451
452
|
|
|
453
|
+
/**
|
|
454
|
+
* Every shipped skill against the family vocabulary, since the landing page
|
|
455
|
+
* groups the skill map by the field and a skill without one has no group.
|
|
456
|
+
*
|
|
457
|
+
* Read in-process for the reason `skillProvenance` is. Only the shipped corpus
|
|
458
|
+
* takes a family, so a tree without `claude/skills/` reports unmeasured rather
|
|
459
|
+
* than a vacuous pass.
|
|
460
|
+
*/
|
|
461
|
+
export const skillFamily: Measure = async (ctx) => {
|
|
462
|
+
const report = await auditSkills(ctx.root)
|
|
463
|
+
const shipped = report.corpora.find((corpus) => corpus.rel === SHIPPED_CORPUS)
|
|
464
|
+
if (shipped === undefined) {
|
|
465
|
+
return {
|
|
466
|
+
emissions: [],
|
|
467
|
+
unmeasured: `no skill corpus at ${SHIPPED_CORPUS} resolved, so no family was read.`,
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
const found = report.family
|
|
472
|
+
if (found.length > 0) {
|
|
473
|
+
const where = found
|
|
474
|
+
.map((finding) => `${finding.rel} ${finding.detail}`)
|
|
475
|
+
.join(', ')
|
|
476
|
+
return {
|
|
477
|
+
emissions: [],
|
|
478
|
+
failure: `${plural(found.length, 'skill family finding')}: ${where}. Add metadata.family to the frontmatter, one of ${familyKeys().join(', ')}, matching the skill map group its row sits under.`,
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
return {
|
|
483
|
+
emissions: [
|
|
484
|
+
info(`${plural(shipped.skills, 'shipped skill')} declare a known family`),
|
|
485
|
+
],
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
452
489
|
/**
|
|
453
490
|
* Every tracked markdown document against the whole-document ceiling.
|
|
454
491
|
*
|
|
@@ -982,7 +1019,7 @@ export const clientCommandCitations = async (
|
|
|
982
1019
|
return {
|
|
983
1020
|
emissions: [],
|
|
984
1021
|
failure:
|
|
985
|
-
'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/client-commands.ts.',
|
|
1022
|
+
'The client command table is empty, so the corpus was walked and nothing was looked for. Check src/gate/client-commands.ts.',
|
|
986
1023
|
}
|
|
987
1024
|
}
|
|
988
1025
|
|
|
@@ -1023,8 +1060,8 @@ export const clientCommandCitations = async (
|
|
|
1023
1060
|
),
|
|
1024
1061
|
failure:
|
|
1025
1062
|
found.length === 1
|
|
1026
|
-
? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
|
|
1027
|
-
: `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
|
|
1063
|
+
? `One tracked citation quotes a client command with the wrong argument. Match it against the canonical form in src/gate/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`
|
|
1064
|
+
: `${found.length} tracked citations quote a client command with the wrong argument. Match each against the canonical form in src/gate/client-commands.ts, or mark the line ${CLIENT_COMMAND_MARKER}: <reason> where the argument differs on purpose.`,
|
|
1028
1065
|
}
|
|
1029
1066
|
}
|
|
1030
1067
|
|
|
@@ -1478,11 +1515,12 @@ async function collectPluginManifests(ctx: MeasureContext): Promise<string[]> {
|
|
|
1478
1515
|
* A capture set spans two folders. The markup and the template beside it are
|
|
1479
1516
|
* the authored half and sit under `assets/captures/`, while the image a
|
|
1480
1517
|
* document points at and the stamp answering for it sit under
|
|
1481
|
-
* `assets/
|
|
1482
|
-
* `
|
|
1518
|
+
* `assets/frames/`, which is where `--out assets/frames` sends them. They sit
|
|
1519
|
+
* outside `assets/evidence/` so a changed marketing image is not read as dev
|
|
1520
|
+
* evidence.
|
|
1483
1521
|
*/
|
|
1484
1522
|
const CAPTURE_MARKUP_DIR = 'assets/captures'
|
|
1485
|
-
const CAPTURE_OUTPUT_DIR = 'assets/
|
|
1523
|
+
const CAPTURE_OUTPUT_DIR = 'assets/frames'
|
|
1486
1524
|
|
|
1487
1525
|
/**
|
|
1488
1526
|
* Every capture under `assets/captures/`, named by the base its three files
|
package/src/gate/sequencer.ts
CHANGED
|
@@ -7,8 +7,13 @@ import type {
|
|
|
7
7
|
RunCommand,
|
|
8
8
|
} from '@/gate/measures'
|
|
9
9
|
import type { Check, Stage } from '@/gate/stages'
|
|
10
|
-
import {
|
|
11
|
-
|
|
10
|
+
import {
|
|
11
|
+
acquireSuiteLock,
|
|
12
|
+
type LockHolder,
|
|
13
|
+
machineLockPath,
|
|
14
|
+
} from '@/gate/suite-lock'
|
|
15
|
+
import { gitEnv } from '@/git/env'
|
|
16
|
+
import { PROJECT_ROOT } from '@/roots/project'
|
|
12
17
|
|
|
13
18
|
export type StageStatus =
|
|
14
19
|
/** Every check ran and none found a fact. */
|
|
@@ -27,8 +32,16 @@ export interface StageResult {
|
|
|
27
32
|
readonly emissions: readonly Emission[]
|
|
28
33
|
/** The remedy line a failed stage prints, naming what to do about it. */
|
|
29
34
|
readonly failure?: string
|
|
30
|
-
/**
|
|
35
|
+
/**
|
|
36
|
+
* Wall time the stage took, including every process spawn and, on a stage
|
|
37
|
+
* holding a machine lock, the time it queued behind another worktree.
|
|
38
|
+
*/
|
|
31
39
|
readonly ms: number
|
|
40
|
+
/**
|
|
41
|
+
* The part of `ms` spent queued behind another worktree's hold on the stage's
|
|
42
|
+
* machine lock. Absent where the stage never waited.
|
|
43
|
+
*/
|
|
44
|
+
readonly queuedMs?: number
|
|
32
45
|
}
|
|
33
46
|
|
|
34
47
|
export interface GateContext extends MeasureContext {
|
|
@@ -39,6 +52,12 @@ export interface GateContext extends MeasureContext {
|
|
|
39
52
|
* scoping is off and every stage runs.
|
|
40
53
|
*/
|
|
41
54
|
readonly changed?: readonly string[]
|
|
55
|
+
/**
|
|
56
|
+
* Told when a stage starts queuing on its machine lock. A stage prints only
|
|
57
|
+
* once it returns, so without this a queued gate is silent for the whole
|
|
58
|
+
* wait and reads as a hung suite.
|
|
59
|
+
*/
|
|
60
|
+
readonly onQueue?: (label: string, holder: LockHolder) => void
|
|
42
61
|
}
|
|
43
62
|
|
|
44
63
|
export interface ChangedSet {
|
|
@@ -157,7 +176,40 @@ async function executeStage(
|
|
|
157
176
|
}
|
|
158
177
|
}
|
|
159
178
|
|
|
160
|
-
|
|
179
|
+
// CI runs one suite per runner, so a lock there queues behind nothing.
|
|
180
|
+
if (stage.lock === undefined || ctx.ci) return runChecks(stage, ctx, [])
|
|
181
|
+
|
|
182
|
+
const held = await acquireSuiteLock({
|
|
183
|
+
path: machineLockPath(stage.lock),
|
|
184
|
+
root: ctx.root,
|
|
185
|
+
onWait: (holder) => ctx.onQueue?.(stage.label, holder),
|
|
186
|
+
})
|
|
187
|
+
const queued: Emission[] =
|
|
188
|
+
held.waitedOn === undefined
|
|
189
|
+
? []
|
|
190
|
+
: [
|
|
191
|
+
{
|
|
192
|
+
kind: 'info',
|
|
193
|
+
text: `Waited ${Math.round(held.waitedMs / 1000)}s for the ${stage.label} stage another worktree held, at ${held.waitedOn.root}`,
|
|
194
|
+
},
|
|
195
|
+
]
|
|
196
|
+
|
|
197
|
+
try {
|
|
198
|
+
const outcome = await runChecks(stage, ctx, queued)
|
|
199
|
+
return held.waitedOn === undefined
|
|
200
|
+
? outcome
|
|
201
|
+
: { ...outcome, queuedMs: held.waitedMs }
|
|
202
|
+
} finally {
|
|
203
|
+
held.release()
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
async function runChecks(
|
|
208
|
+
stage: Stage,
|
|
209
|
+
ctx: GateContext,
|
|
210
|
+
leading: readonly Emission[],
|
|
211
|
+
): Promise<StageOutcome> {
|
|
212
|
+
const emissions: Emission[] = [...leading]
|
|
161
213
|
|
|
162
214
|
for (const check of stage.checks) {
|
|
163
215
|
const outcome = await runCheck(check, ctx)
|
package/src/gate/stages.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
seedStandards,
|
|
23
23
|
rawFieldFileReference,
|
|
24
24
|
shippedReferences,
|
|
25
|
+
skillFamily,
|
|
25
26
|
skillPracticeShape,
|
|
26
27
|
skillProvenance,
|
|
27
28
|
standardCriteria,
|
|
@@ -87,6 +88,11 @@ export interface Stage {
|
|
|
87
88
|
*/
|
|
88
89
|
readonly when?: (options: { readonly write: boolean }) => boolean
|
|
89
90
|
readonly checks: readonly Check[]
|
|
91
|
+
/**
|
|
92
|
+
* The machine lock this stage holds while its checks run, so one box runs it
|
|
93
|
+
* for one worktree at a time. Absent means the stage never queues.
|
|
94
|
+
*/
|
|
95
|
+
readonly lock?: string
|
|
90
96
|
/** The line a clean stage prints, where the checks emit none of their own. */
|
|
91
97
|
readonly success?: string
|
|
92
98
|
}
|
|
@@ -496,6 +502,14 @@ export const STAGES: readonly Stage[] = [
|
|
|
496
502
|
label: 'Skill practice shape',
|
|
497
503
|
checks: [{ kind: 'measure', measure: skillPracticeShape }],
|
|
498
504
|
},
|
|
505
|
+
{
|
|
506
|
+
// The audit verb only reports a shipped skill with a missing or unknown
|
|
507
|
+
// family, so a target is told without failing. The landing page groups the
|
|
508
|
+
// skill map by the field, so the stage holds this corpus at zero.
|
|
509
|
+
id: 'skill-family',
|
|
510
|
+
label: 'Skill family',
|
|
511
|
+
checks: [{ kind: 'measure', measure: skillFamily }],
|
|
512
|
+
},
|
|
499
513
|
{
|
|
500
514
|
id: 'standard-criteria',
|
|
501
515
|
label: 'Standard success criteria',
|
|
@@ -570,6 +584,9 @@ export const STAGES: readonly Stage[] = [
|
|
|
570
584
|
label: 'Tests',
|
|
571
585
|
scope: TESTS_SCOPE,
|
|
572
586
|
skipped: 'Skipped, no TypeScript or asserted-corpus changes',
|
|
587
|
+
// Concurrent suites from a wave of worktrees starve each other's spawn
|
|
588
|
+
// and browser tests past their timeouts, so the box runs one at a time.
|
|
589
|
+
lock: 'tests',
|
|
573
590
|
checks: [
|
|
574
591
|
{
|
|
575
592
|
kind: 'command',
|