Merge remote-tracking branch 'origin/master' into native-authoring
# Conflicts: # tsconfig.json
This commit is contained in:
commit
8dc80b7a33
74
.github/workflows/ci.yml
vendored
74
.github/workflows/ci.yml
vendored
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
1
bun.lock
1
bun.lock
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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
99
src/theme/code/editor.ts
Normal 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
11
tools/ci/package.json
Normal 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
20
tools/ci/src/classify.ts
Normal 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
10
tools/ci/src/gate.ts
Normal 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
53
tools/ci/src/policy.ts
Normal 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
|
||||
}
|
||||
47
tools/ci/tests/classify.test.ts
Normal file
47
tools/ci/tests/classify.test.ts
Normal 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')
|
||||
})
|
||||
102
tools/ci/tests/policy.test.ts
Normal file
102
tools/ci/tests/policy.test.ts
Normal 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([])
|
||||
}
|
||||
})
|
||||
|
|
@ -22,6 +22,7 @@
|
|||
"noFallthroughCasesInSwitch": true,
|
||||
"paths": {
|
||||
"#authoring-reference/*": ["./tools/authoring-reference/src/*"],
|
||||
"#ci/*": ["./tools/ci/src/*"],
|
||||
"@/*": [
|
||||
"./src/*"
|
||||
],
|
||||
|
|
|
|||
Loading…
Reference in a new issue