Merge remote-tracking branch 'origin/master' into native-authoring

# Conflicts:
#	tsconfig.json
This commit is contained in:
Danila Poyarkov 2026-09-13 21:03:46 +03:00
commit 8dc80b7a33
17 changed files with 449 additions and 36 deletions

View file

@ -3,10 +3,6 @@ name: CI
on:
pull_request:
branches: [master]
paths-ignore:
- 'packages/docs/**'
- 'openspec/**'
- '*.md'
permissions:
contents: read
@ -17,8 +13,52 @@ concurrency:
cancel-in-progress: true
jobs:
changes:
name: Classify changes
runs-on: ubuntu-latest
timeout-minutes: 3
outputs:
scope: ${{ steps.classify.outputs.scope }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: Fetch comparison base
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: git fetch --no-tags --depth=1 origin "$CI_BASE_SHA"
- name: Select validation scope
id: classify
env:
CI_BASE_SHA: ${{ github.event.pull_request.base.sha }}
run: bun tools/ci/src/classify.ts
documentation:
name: Documentation
needs: changes
if: needs.changes.outputs.scope == 'docs'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- uses: ./.github/actions/setup-bun
- name: Build documentation type dependencies
run: bun run build:packages
- name: Validate documentation and generated references
run: bun run check:docs
- name: Build documentation and check examples
run: bun run docs:build
source-quality:
name: Code quality
needs: changes
if: needs.changes.outputs.scope == 'code'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
@ -45,6 +85,8 @@ jobs:
package-quality:
name: Package integrity
needs: changes
if: needs.changes.outputs.scope == 'code'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
@ -68,6 +110,8 @@ jobs:
repository-quality:
name: Repository hygiene
needs: changes
if: needs.changes.outputs.scope == 'code'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
@ -97,6 +141,8 @@ jobs:
storybook:
name: Component workshop
needs: changes
if: needs.changes.outputs.scope == 'code'
timeout-minutes: 10
runs-on: ubuntu-latest
steps:
@ -114,6 +160,8 @@ jobs:
native-test-contracts:
name: Native app contracts
needs: changes
if: needs.changes.outputs.scope == 'code'
timeout-minutes: 8
runs-on: ubuntu-24.04
container:
@ -142,6 +190,8 @@ jobs:
run: cargo check --manifest-path desktop/Cargo.toml --features native-test
unit-tests:
needs: changes
if: needs.changes.outputs.scope == 'code'
timeout-minutes: 10
runs-on: ubuntu-latest
strategy:
@ -172,3 +222,19 @@ jobs:
bun test "${test_files[@]}"
env:
BUN_HEAVY_TESTS: 'false'
result:
name: CI result
needs: [changes, documentation, source-quality, package-quality, repository-quality, storybook, native-test-contracts, unit-tests]
if: always()
runs-on: ubuntu-latest
timeout-minutes: 3
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1
with:
persist-credentials: false
- uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2.2.0
- name: Require successful checks for the selected scope
env:
CI_NEEDS: ${{ toJSON(needs) }}
run: bun tools/ci/src/gate.ts

View file

@ -86,6 +86,8 @@ For releases, update versions in the root and publishable package manifests plus
App/docs production workflows run on `v*` tags or `workflow_dispatch`, not ordinary `master` pushes. `ci.yml` and `heavy-tests.yml` define validation gates.
PR CI always classifies changed paths through `tools/ci/`. Docs-only changes run documentation integrity/reference checks and the docs build, not engine, browser, Storybook, or native suites. Runtime prompt Markdown, executable examples, configuration, and unknown paths require code validation. The aggregate `CI result` gate requires successful classification and every applicable job; failures, cancellations, and unexpected skips cannot pass. Do not restore workflow-level path filtering on required CI.
## Documentation
- `CHANGELOG.md` — curated user-facing changes by version; `Unreleased` stays first.
@ -217,6 +219,7 @@ Keep responsibilities distinct: engine tests cover state contracts, Playwright b
- Colocate `ComponentName.stories.ts` with `ComponentName.vue`. Multipart composition stories may use a descriptive family name. Preserve explicit Storybook titles and exported story names during file moves; keep default playgrounds static and give interaction flows named stories. Use deterministic fixtures and colocated Vue demos for substantial markup.
- `src/components/ui/**` is store-free app design-system code; feature controls stay in their domain.
- SDK property primitives remain controlled/editor-agnostic. Compose property rows from `PanelGrid`, `PanelFieldGroup`, `PanelItemRow`, and `PropertyItemRow`; use `BindableValue`, `FillRoot`, and `FillSwatch` rather than rebuilding binding/picker infrastructure.
- Do not add automated tests or snapshot baselines for simple CSS-only UI changes, including spacing, sizing, colors, and responsive breakpoints. Verify these visually instead. Keep automated coverage focused on behavior and contracts; the separate canvas-renderer visual coverage requirement still applies.
- Prefer accessible role/name, label, then text in tests. Use scoped `data-slot` anatomy or semantic attributes (`data-property`, `data-command`, `data-node-id`) when needed; reserve `data-test-id` for integration boundaries and never add test-hook props.
- Use Reka UI primitives and typed Tailwind Variants themes under `src/theme/**`; merge per-instance `ui` slot overrides, expose `class` for single-root components, and do not add one-off class props. Use `UI` casing in type names.
- Bind visual state through semantic `data-*` attributes; Steiger rejects template-time `use*UI()`, visual-state utility branches, and raw SVG app icons.

View file

@ -25,7 +25,7 @@
- Add local AI usage and technical diagnostics, including token telemetry, provider/model summaries, recent failures, configurable retention, export, and clear controls. (#588)
- Import, render, edit, resize, select, and export Figma text-on-path layers while preserving their curved glyph layout.
- Show temporary Figma-style distance measurements between selected and Option/Alt-hovered layers. (#491)
- Edit Design JSX and HTML/CSS previews in CodeMirror, with Tailwind viewing, completion, diagnostics, bounded execution, and session-level undo. (#130)
- Edit Design JSX and HTML/CSS previews in CodeMirror, with theme-aware highlighting, Tailwind viewing, completion, diagnostics, bounded execution, and session-level undo. (#130)
- Set provider-specific reasoning effort on supported AI model profiles. (#454)
- Show unavailable or substituted document fonts with affected-layer selection and retry actions, and expose font fidelity through the Figma API and MCP tooling. (#503)
- Add reusable remote MCP connections for ACP agents, with Streamable HTTP endpoints and credential-backed bearer tokens.
@ -37,6 +37,7 @@
### Changed
- Use compact desktop Home search actions with consistent responsive layout and control sizing.
- Keep applied and available Effect styles concise, and collapse equal independent corner fields when all four use the same variable.
- Keep pixel-grid rounding invisible while showing alignment guides only for real geometry, objects, and canvas/layout guides.
- Copy selections with embedded images into Figma while preserving typed geometry, text sizing, images, components, variables, modes, and shared styles for lossless in-app paste.

View file

@ -23,6 +23,7 @@
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3",
"@nanostores/i18n": "^1.3.3",
"@nanostores/vue": "^1.1.0",
"@open-pencil/cli": "workspace:*",

View file

@ -94,6 +94,7 @@
"@codemirror/search": "^6.7.1",
"@codemirror/state": "^6.7.1",
"@codemirror/view": "^6.43.9",
"@lezer/highlight": "^1.2.3",
"@nanostores/i18n": "^1.3.3",
"@nanostores/vue": "^1.1.0",
"@open-pencil/cli": "workspace:*",

View file

@ -95,6 +95,7 @@ export type StyleProps = {
gap?: number | DesignVariable
wrap?: boolean
rowGap?: number | DesignVariable
columnGap?: number | DesignVariable
justify?: 'start' | 'end' | 'center' | 'between'
justifyContent?: 'start' | 'end' | 'center' | 'between'
items?: 'start' | 'end' | 'center' | 'stretch'
@ -145,6 +146,8 @@ export type StyleProps = {
size?: number | DesignVariable
fontSize?: number | DesignVariable
lineHeight?: number | DesignVariable
letterSpacing?: number | DesignVariable
font?: string
fontFamily?: string
weight?: number | 'bold' | 'medium' | 'normal'

View file

@ -49,6 +49,7 @@ const variable: Variable = {
const values = new Map<string, number>([[variable.id, 16]])
const bindings = new Map<string, string>()
const getBindingId = (target: BindingTarget) => bindings.get(`${target.nodeId}:${target.path}`)
const resolve: BindingProvider<number>['resolve'] = id => values.get(id)
const provider: BindingProvider<number> = {
listVariables: () => [variable],
@ -59,9 +60,12 @@ const provider: BindingProvider<number> = {
const ids = new Set(targets.map(getBindingId))
if (ids.size === 0 || (ids.size === 1 && ids.has(undefined))) return 'unbound'
if (ids.size > 1) return 'mixed'
return ids.has(variable.id) ? 'bound' : 'unresolved'
if (!ids.has(variable.id)) return 'unresolved'
const resolved = targets.map(target => resolve(variable.id, target))
if (resolved.some(value => value === undefined)) return 'unresolved'
return new Set(resolved).size > 1 ? 'mixed' : 'bound'
},
resolve: id => values.get(id),
resolve,
bind: (target: BindingTarget, variableId) => {
bindings.set(`${target.nodeId}:${target.path}`, variableId)
},

View file

@ -3,14 +3,7 @@ import { closeBrackets, closeBracketsKeymap, completionKeymap } from '@codemirro
import { defaultKeymap, history, historyKeymap, redo, undo } from '@codemirror/commands'
import { html } from '@codemirror/lang-html'
import { javascript } from '@codemirror/lang-javascript'
import {
bracketMatching,
defaultHighlightStyle,
foldGutter,
foldKeymap,
indentOnInput,
syntaxHighlighting
} from '@codemirror/language'
import { bracketMatching, foldGutter, foldKeymap, indentOnInput } from '@codemirror/language'
import { lintKeymap } from '@codemirror/lint'
import { searchKeymap } from '@codemirror/search'
import { Compartment, EditorState, Transaction, type Extension } from '@codemirror/state'
@ -25,8 +18,10 @@ import {
} from '@codemirror/view'
import { onBeforeUnmount, onMounted, useTemplateRef, watch } from 'vue'
import { resolvedAppTheme } from '@/app/shell/theme'
import { designJSXExtensions } from '@/components/code-editor/extensions'
import type { CodeEditorLanguage } from '@/components/code-editor/types'
import { codeEditorTheme } from '@/theme/code/editor'
const {
modelValue,
@ -48,6 +43,7 @@ const host = useTemplateRef('host')
const languageCompartment = new Compartment()
const editableCompartment = new Compartment()
const labelCompartment = new Compartment()
const themeCompartment = new Compartment()
let editor: EditorView | undefined
let externalUpdate = false
@ -78,7 +74,7 @@ onMounted(() => {
drawSelection(),
EditorState.allowMultipleSelections.of(true),
indentOnInput(),
syntaxHighlighting(defaultHighlightStyle, { fallback: true }),
themeCompartment.of(codeEditorTheme(resolvedAppTheme.value === 'dark')),
bracketMatching(),
closeBrackets(),
highlightActiveLine(),
@ -97,21 +93,7 @@ onMounted(() => {
editableCompartment.of(editableExtensions(readOnly)),
labelCompartment.of(EditorView.contentAttributes.of({ 'aria-label': label })),
EditorView.lineWrapping,
EditorView.theme({
'&': { height: '100%', backgroundColor: 'transparent', color: 'var(--color-surface)' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'var(--font-mono)' },
'.cm-content': { padding: '12px 0', caretColor: 'var(--color-accent)' },
'.cm-line': { padding: '0 12px' },
'.cm-gutters': {
backgroundColor: 'transparent',
color: 'color-mix(in srgb, var(--color-muted) 45%, transparent)',
border: 'none'
},
'&.cm-focused': { outline: 'none' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 22%, transparent)'
}
}),
EditorView.updateListener.of((update) => {
if (!update.docChanged || externalUpdate) return
emit('update:modelValue', update.state.doc.toString())
@ -155,6 +137,10 @@ watch(
})
)
watch(resolvedAppTheme, (theme) =>
editor?.dispatch({ effects: themeCompartment.reconfigure(codeEditorTheme(theme === 'dark')) })
)
onBeforeUnmount(() => editor?.destroy())
</script>

View file

@ -39,27 +39,32 @@ watch(
</script>
<template>
<div class="mb-4 flex flex-col gap-3 sm:mb-6 sm:flex-row sm:items-center">
<div class="mb-4 flex flex-col gap-3 md:mb-6 md:flex-row md:items-center">
<AppInput
ref="searchInput"
v-model="query"
type="search"
name="file-search"
autocomplete="off"
class="sm:flex-1"
class="md:flex-1"
:density="isMobile ? 'comfortable' : 'compact'"
:placeholder="isMobile ? files.searchFiles : files.searchRecentAndStorageFiles"
:aria-label="files.searchFiles"
>
<template #leading><icon-lucide-search class="size-4" /></template>
</AppInput>
<div class="grid grid-cols-2 gap-2 sm:contents">
<AppButton size="lg" variant="outline" data-test-id="home-open-file" @click="openFileDialog">
<div class="grid grid-cols-2 gap-2 md:contents">
<AppButton
:size="isMobile ? 'lg' : 'md'"
variant="outline"
data-test-id="home-open-file"
@click="openFileDialog"
>
<template #leading><icon-lucide-folder-open class="size-3.5" /></template>
{{ menu.open }}
</AppButton>
<AppButton
size="lg"
:size="isMobile ? 'lg' : 'md'"
color="primary"
variant="solid"
data-test-id="home-new-document"

99
src/theme/code/editor.ts Normal file
View file

@ -0,0 +1,99 @@
import { HighlightStyle, syntaxHighlighting } from '@codemirror/language'
import { EditorView } from '@codemirror/view'
import { tags } from '@lezer/highlight'
export function codeEditorTheme(dark: boolean) {
const palette = dark
? {
keyword: '#c4a7e7',
tag: '#82aaff',
string: '#a3d9a5',
number: '#e5bc8b',
comment: '#969eaa'
}
: {
keyword: '#7947a7',
tag: '#145ca4',
string: '#276b3b',
number: '#965212',
comment: '#69727f'
}
return [
EditorView.theme(
{
'&': {
height: '100%',
backgroundColor: 'var(--color-panel)',
color: 'var(--color-surface)',
fontSize: '12px'
},
'&.cm-focused': { outline: 'none' },
'.cm-scroller': { overflow: 'auto', fontFamily: 'var(--font-mono)', lineHeight: '1.65' },
'.cm-content': { padding: '10px 0', caretColor: 'var(--color-accent)' },
'.cm-line': { padding: '0 8px' },
'.cm-gutters': {
backgroundColor: 'var(--color-panel)',
color: 'var(--color-muted)',
border: 'none'
},
'.cm-lineNumbers .cm-gutterElement': { padding: '0 4px 0 8px', minWidth: '24px' },
'.cm-foldGutter .cm-gutterElement': { padding: '0 2px' },
'.cm-activeLine': {
backgroundColor: 'color-mix(in srgb, var(--color-hover) 45%, transparent)'
},
'.cm-activeLineGutter': {
backgroundColor: 'var(--color-hover)',
color: 'var(--color-surface)'
},
'.cm-cursor, .cm-dropCursor': { borderLeftColor: 'var(--color-accent)' },
'.cm-selectionBackground, &.cm-focused .cm-selectionBackground': {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 22%, transparent)'
},
'&.cm-focused .cm-matchingBracket': {
backgroundColor: 'var(--color-hover)',
outline: '1px solid var(--color-border)'
},
'.cm-tooltip, .cm-panels': {
backgroundColor: 'var(--color-panel)',
color: 'var(--color-surface)',
border: '1px solid var(--color-border)'
},
'.cm-tooltip': { borderRadius: '6px', overflow: 'hidden' },
'.cm-tooltip-autocomplete > ul > li[aria-selected]': {
backgroundColor: 'var(--color-hover)',
color: 'var(--color-surface)'
},
'.cm-searchMatch': {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 20%, transparent)',
outline: '1px solid var(--color-accent)'
},
'.cm-searchMatch.cm-searchMatch-selected': {
backgroundColor: 'color-mix(in srgb, var(--color-accent) 35%, transparent)'
},
'.cm-textfield, .cm-button': {
background: 'var(--color-input)',
color: 'var(--color-surface)',
border: '1px solid var(--color-border)',
borderRadius: '4px',
font: 'inherit'
}
},
{ dark }
),
syntaxHighlighting(
HighlightStyle.define([
{ tag: tags.keyword, color: palette.keyword },
{ tag: [tags.tagName, tags.typeName, tags.className], color: palette.tag },
{ tag: [tags.string, tags.attributeValue], color: palette.string },
{ tag: [tags.number, tags.bool, tags.null], color: palette.number },
{ tag: tags.comment, color: palette.comment, fontStyle: 'italic' },
{
tag: [tags.attributeName, tags.propertyName, tags.variableName],
color: 'var(--color-surface)'
},
{ tag: [tags.punctuation, tags.operator], color: 'var(--color-muted)' }
])
)
]
}

11
tools/ci/package.json Normal file
View file

@ -0,0 +1,11 @@
{
"name": "@open-pencil/ci-tools",
"private": true,
"type": "module",
"imports": {
"#ci/*": "./src/*.ts"
},
"scripts": {
"test": "bun test tests"
}
}

20
tools/ci/src/classify.ts Normal file
View file

@ -0,0 +1,20 @@
import { execFileSync } from 'node:child_process'
import { appendFile } from 'node:fs/promises'
import { classifyPaths } from './policy'
const base = process.env.CI_BASE_SHA
const output = process.env.GITHUB_OUTPUT
if (!base || !/^[a-f0-9]{40}$/.test(base) || !output)
throw new Error('Missing CI base SHA or output file')
// Disable rename detection so both the old and new paths participate in routing.
const paths = execFileSync('git', ['diff', '--no-renames', '--name-only', '-z', base, 'HEAD'], {
maxBuffer: 32 * 1024 * 1024
})
.toString('utf8')
.split('\0')
.filter(Boolean)
const scope = classifyPaths(paths)
await appendFile(output, `scope=${scope}\n`)
console.log(`Selected ${scope} checks for ${paths.length} changed paths`)

10
tools/ci/src/gate.ts Normal file
View file

@ -0,0 +1,10 @@
import { gateErrors, type JobStatus } from './policy'
const input = process.env.CI_NEEDS
if (!input) throw new Error('Missing CI job results')
// GitHub serializes the needs context. Malformed data or unexpected statuses fail closed.
const needs: Record<string, JobStatus> = JSON.parse(input)
const errors = gateErrors(needs)
for (const error of errors) console.error(error)
if (errors.length > 0) process.exitCode = 1
else console.log('All checks required for this change succeeded')

53
tools/ci/src/policy.ts Normal file
View file

@ -0,0 +1,53 @@
const ROOT_DOCS = new Set(['README.md', 'CONTRIBUTING.md', 'AGENTS.md', 'CHANGELOG.md', 'LICENSE'])
const DOC_ASSET = /\.(?:md|png|jpe?g|gif|webp|svg|ico|pdf|woff2?|ttf)$/i
export type ChangeScope = 'docs' | 'code'
/** Unknown paths, executable docs, and runtime prompt Markdown require the code checks. */
export function classifyPaths(paths: readonly string[]): ChangeScope {
if (paths.length === 0) return 'code'
return paths.every((path) => {
if (ROOT_DOCS.has(path)) return true
if (path.startsWith('packages/docs/')) return DOC_ASSET.test(path)
if (path.startsWith('openspec/')) return path.endsWith('.md')
if (path.startsWith('skills/')) return path.endsWith('.md') || path.endsWith('/LICENSE.txt')
return false
})
? 'docs'
: 'code'
}
export const CODE_JOBS = [
'source-quality',
'package-quality',
'repository-quality',
'storybook',
'native-test-contracts',
'unit-tests'
] as const
export const DOCS_JOB = 'documentation'
type JobResult = 'success' | 'failure' | 'cancelled' | 'skipped'
export interface JobStatus {
result: JobResult
outputs?: Record<string, string>
}
/** The sole required gate accepts only the successful checks selected by successful detection. */
export function gateErrors(needs: Partial<Record<string, JobStatus>>): string[] {
const detection = needs.changes
if (detection?.result !== 'success') return ['Change detection did not succeed']
const scope = detection.outputs?.scope
if (scope !== 'docs' && scope !== 'code') return ['Invalid or missing change scope']
const required: readonly string[] = scope === 'docs' ? [DOCS_JOB] : CODE_JOBS
const excluded: readonly string[] = scope === 'docs' ? CODE_JOBS : [DOCS_JOB]
const errors = required
.filter((job) => needs[job]?.result !== 'success')
.map((job) => `${job} did not succeed`)
// Unexpected execution is also a policy failure: docs must not run the full suites.
for (const job of excluded) {
if (needs[job]?.result !== 'skipped')
errors.push(`${job} was not skipped for ${scope}-only routing`)
}
return errors
}

View file

@ -0,0 +1,47 @@
import { afterEach, expect, test } from 'bun:test'
import { mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
const roots: string[] = []
afterEach(async () => {
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })))
})
function git(cwd: string, ...args: string[]): string {
const result = Bun.spawnSync(
['git', '-c', 'user.name=CI Test', '-c', 'user.email=ci@example.invalid', ...args],
{ cwd }
)
if (result.exitCode !== 0) throw new Error(result.stderr.toString())
return result.stdout.toString().trim()
}
test('classifier reads real Git deletions and renames without losing the original path', async () => {
const root = await mkdtemp(join(tmpdir(), 'open-pencil-ci-'))
roots.push(root)
git(root, 'init')
await writeFile(join(root, 'runtime.ts'), 'export const version = 1\n')
git(root, 'add', '.')
git(root, 'commit', '-m', 'base')
const base = git(root, 'rev-parse', 'HEAD')
await rename(join(root, 'runtime.ts'), join(root, 'README.md'))
git(root, 'add', '.')
git(root, 'commit', '-m', 'move')
const output = join(root, 'output')
const command = fileURLToPath(import.meta.resolve('#ci/classify'))
const result = Bun.spawnSync([process.execPath, command], {
cwd: root,
env: { ...process.env, CI_BASE_SHA: base, GITHUB_OUTPUT: output }
})
expect(result.exitCode).toBe(0)
expect(await readFile(output, 'utf8')).toBe('scope=code\n')
const failed = Bun.spawnSync([process.execPath, command], {
cwd: root,
env: { ...process.env, CI_BASE_SHA: 'invalid', GITHUB_OUTPUT: output }
})
expect(failed.exitCode).not.toBe(0)
expect(await readFile(output, 'utf8')).toBe('scope=code\n')
})

View file

@ -0,0 +1,102 @@
import { expect, test } from 'bun:test'
import {
classifyPaths,
CODE_JOBS,
DOCS_JOB,
gateErrors,
type ChangeScope,
type JobStatus
} from '#ci/policy'
const documentation = [
'README.md',
'AGENTS.md',
'CHANGELOG.md',
'packages/docs/programmable/sdk/api/components/bindable-value.md',
'packages/docs/public/logo.svg',
'skills/open-pencil/SKILL.md',
'skills/open-pencil/references/design-authoring.md',
'openspec/proposal.md'
]
const code = [
'src/app/ai/chat/system-prompt.md',
'packages/core/src/design-jsx/reference/authoring.md',
'packages/docs/.vitepress/config.ts',
'packages/docs/demo.vue',
'skills/open-pencil/scripts/create.ts',
'package.json',
'bun.lock',
'.github/workflows/ci.yml',
'new-domain/instructions.md',
'src/editor.ts'
]
test.each(documentation)('docs-only path: %s', (path) => {
expect(classifyPaths([path])).toBe('docs')
})
test.each(code)('code or unknown path: %s', (path) => {
expect(classifyPaths([path])).toBe('code')
expect(classifyPaths([...documentation, path])).toBe('code')
})
test('empty diffs fail safe to code checks', () => {
expect(classifyPaths([])).toBe('code')
})
test('both sides of a rename affect classification', () => {
expect(classifyPaths(['src/prompt.md', 'packages/docs/prompt.md'])).toBe('code')
expect(classifyPaths(['packages/docs/old.md', 'packages/docs/new.md'])).toBe('docs')
})
function results(scope: ChangeScope): Record<string, JobStatus> {
return {
changes: { result: 'success', outputs: { scope } },
[DOCS_JOB]: { result: scope === 'docs' ? 'success' : 'skipped' },
...Object.fromEntries(
CODE_JOBS.map((job) => [job, { result: scope === 'code' ? 'success' : 'skipped' }])
)
}
}
test.each(['docs', 'code'] as const)(
'accepts only appropriate successful checks for %s',
(scope) => {
expect(gateErrors(results(scope))).toEqual([])
}
)
test.each(['failure', 'cancelled', 'skipped'] as const)(
'rejects %s detection and required checks',
(result) => {
for (const scope of ['docs', 'code'] as const) {
expect(gateErrors({ ...results(scope), changes: { result } })).not.toEqual([])
for (const job of scope === 'docs' ? [DOCS_JOB] : CODE_JOBS) {
expect(gateErrors({ ...results(scope), [job]: { result } })).not.toEqual([])
}
}
}
)
test('missing jobs or outputs cannot pass', () => {
expect(gateErrors({})).not.toEqual([])
expect(gateErrors({ ...results('docs'), changes: { result: 'success' } })).not.toEqual([])
expect(
gateErrors({
...results('docs'),
changes: { result: 'success', outputs: { scope: 'unknown' } }
})
).not.toEqual([])
for (const scope of ['docs', 'code'] as const) {
for (const job of scope === 'docs' ? [DOCS_JOB] : CODE_JOBS) {
const incomplete = Object.fromEntries(
Object.entries(results(scope)).filter(([name]) => name !== job)
)
expect(gateErrors(incomplete)).not.toEqual([])
}
}
})
test('docs routing rejects unexpected execution of test suites', () => {
for (const job of CODE_JOBS) {
expect(gateErrors({ ...results('docs'), [job]: { result: 'success' } })).not.toEqual([])
}
})

View file

@ -22,6 +22,7 @@
"noFallthroughCasesInSwitch": true,
"paths": {
"#authoring-reference/*": ["./tools/authoring-reference/src/*"],
"#ci/*": ["./tools/ci/src/*"],
"@/*": [
"./src/*"
],