diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 00000000..33def02a --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,5 @@ +- Behavior owner and resulting change: +- Contracts/invariants affected; local/remote implications: +- Validation results; remaining risks: +- Intentional architecture/design changes and rationale (if any): +- UI evidence: affected themes/layouts, keyboard use, Pierre geometry (if applicable): diff --git a/AGENTS.md b/AGENTS.md index 9c859c9f..40a54933 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -7,9 +7,27 @@ Go CLI/server in `cmd/` and `internal/`; embedded React/Vite app in `apps/web/`. - Use pnpm. Install dependencies with `pnpm install --frozen-lockfile`. - Use mise tasks or pnpm scripts for project commands; toolchain and orchestration live in `mise.toml`. - Run relevant checks for changes; `mise run check` runs all checks. +- Keep full suites and architecture/design guards local through the pre-push hook; keep CI/release verification lightweight. - After frontend changes, run `mise run web:stage` and commit `internal/webui/dist`. - See `docs/development.md` and `docs/release.md` for workflows. +## Design and architecture + +- Before editing, read `docs/architecture.md` and relevant `docs/system.md` sections. +- For frontend changes, also read `DESIGN.md` and frontend conventions in `docs/development.md`. +- Identify the behavior owner, affected contracts, and applicable invariants before choosing an implementation. +- Extend the owning module; encapsulate behavior behind its public contract. +- Share semantic rules, not merely similar code. Avoid speculative abstractions. +- Compose local/remote policies at entry points; keep mode branches out of domain operations. +- Define interfaces beside consumers; expose only required operations. +- Keep transports thin. Services own application rules; storage owns atomic persistence. +- Preserve ownership, authorization, identity, retry, cancellation, and durability contracts. +- Reuse UI tokens and primitives; preserve Pierre's rendering and measured geometry. +- Verify affected behavior at boundaries, including both modes when shared behavior changes. +- Update canonical docs and checks with intentional design changes; explain the changed requirement. +- Never weaken a check solely to accommodate a violating implementation. +- Before completion, review the diff against applicable design rules; report validation and remaining risks. + ## Git etiquette - Before pushing to any remote, run all CI checks locally with `mise run check`; resolve failures. diff --git a/DESIGN.md b/DESIGN.md index 7138b344..c1244a82 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -463,6 +463,32 @@ Before adding a value or visual pattern: 7. Are keyboard focus, contrast, labels, and hit areas correct? 8. Does this follow `DESIGN.md`? If a new role is necessary, update it here. +## Enforcement + +`mise run check:design` scans CSS, TypeScript and TSX under `apps/web/src`, +including new nested files, static utilities, inline style objects, SVG colors +and Pierre's `unsafeCSS`. Local `mise run check` and pre-push run the guard +and its contracts. It rejects literal palettes outside `tokens.css` definitions, +independent typography, literal radii/elevation, and arbitrary padding/margin/gap +lengths. Existing ordinary Tailwind numeric spacing utilities remain allowed; +their relationship to the spacing scale needs review. Compose authored values +from the canonical tokens. Zero, automatic spacing +and 1px optical insets remain valid; dimensions, breakpoints and dynamic measured +geometry require review rather than a blanket literal-value ban. + +The guard has exact exceptions for status circles, SVG monogram units, icon line +boxes, compact comment counts, the overlapping sidebar resizer, and Pierre's +line-surface mixes/inset strokes. Existing generated tabs' 3px inset and active +shadow, and the dialog title's unit line-height, are bounded legacy exceptions; +do not copy them into new controls. Changes to exceptions need a concrete role, +rationale and contract test in the same PR. Do not exempt entire components. + +Static checks cannot prove responsive behavior, accessibility, visual hierarchy +or correctness of styles assembled at runtime. Use existing browser tests and +review affected themes, narrow layouts and keyboard interaction. Changes to +Pierre geometry also require selection, annotation, expansion, wrapping and +scrolling checks. Record this evidence in the PR template. + ## Audit baseline and consolidation This audit records the starting points, not permission to reuse legacy values. diff --git a/apps/web/test/browser/app.spec.ts b/apps/web/test/browser/app.spec.ts index e2390a7e..99daa895 100644 --- a/apps/web/test/browser/app.spec.ts +++ b/apps/web/test/browser/app.spec.ts @@ -216,6 +216,7 @@ test("renders and filters a piped diff", async ({ page }) => { test("file navigation scrolls to and retriggers a destination cue", async ({ page, }) => { + await page.clock.install({ time: new Date("2026-01-01T00:00:00Z") }); await page.setViewportSize({ width: 1280, height: 420 }); await page.goto("/"); @@ -227,8 +228,11 @@ test("file navigation scrolls to and retriggers a destination cue", async ({ .filter({ hasText: "src/components/Badge.tsx" }); const header = target.locator("[data-diffs-header]"); await expect(header).toContainText("src/components/Badge.tsx"); + // Host round trips must not consume the cue's one-second lifetime. + await page.clock.pauseAt(new Date("2026-01-01T01:00:00Z")); await file.click(); + await page.clock.runFor(100); await expect(file).toHaveAttribute("aria-current", "true"); await expect( target.locator("[data-diffs-header][data-navigation-target]"), @@ -244,12 +248,13 @@ test("file navigation scrolls to and retriggers a destination cue", async ({ }) .toBeLessThanOrEqual(1); - await page.waitForTimeout(700); + await page.clock.runFor(700); await file.click(); - await page.waitForTimeout(400); + await page.clock.runFor(400); const cue = target.locator("[data-diffs-header][data-navigation-target]"); await expect(cue).toHaveCount(1); - await expect(cue).toHaveCount(0, { timeout: 1_000 }); + await page.clock.runFor(700); + await expect(cue).toHaveCount(0); }); test("file frames preserve measured gutters, gaps, and virtual geometry", async ({ diff --git a/docs/architecture.md b/docs/architecture.md index 4cd6962b..e53d0b58 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,9 @@ personal lifecycle discovery. - `httpapi`, `mcpapi`: transport parsing, projection and error mapping. - `serverapp`: transport/task composition; `daemon`: local lifecycle; `remoteserver`: remote authentication and worker composition. +- `config`, `processlock`, `browser`, `version`: configuration, process ownership, + browser launch and build metadata; `processmetrics`: server process metrics; + `webui`: embedded assets. `testsupport` is test-only. Interfaces live beside consumers and require the needed operation. Optional interface assertions must not change application behavior. Storage contracts @@ -76,9 +79,39 @@ describe atomic application operations, not independently committed CRUD steps. ## Contracts and evolution `mise run test:contracts` runs production dependency checks, storage/lifecycle/ -queue/transport boundaries, shared Go/TypeScript comment examples and real HTTP -schema validation. Run locally or wherever useful; pre-push invokes it. -CI configuration does not enforce this suite. +queue/transport boundaries, frontend token guards, shared Go/TypeScript comment +examples and real HTTP schema validation. Local `mise run check` and pre-push +run these checks alongside the web unit/browser and plugin/shared suites. +`test:contracts:rules` reuses built binaries so the full local check does not +rebuild or repeat the distribution smoke test and JavaScript suites. CI and +release verification retain static checks, builds, generated-file consistency +and the distribution API smoke test; full suites and guards stay local. + +`mise run check:boundaries` classifies every package under `cmd/` and `internal/` +using `tools/check-boundaries`. Unclassified packages, forbidden dependencies and +production imports of `testsupport` fail. Process execution and SQL access stay +with their explicit owners. The checker inspects production imports for the +current build platform; tests may import adapters to exercise their contracts. + +## Making changes + +Before implementation, identify the behavior's owner, consuming contract and +invariants from this document and [system.md](system.md). Put shared rules in +their existing owner and compose delivery/lifecycle differences at entry points. +For shared changes, verify both compositions through existing boundary tests; +add a regression test when coverage is missing. Check semantic outcomes rather +than private implementation shape. + +The PR template records ownership, contract/mode implications, validation and +intentional design changes. Change a boundary policy only for a concrete new +responsibility, updating its rationale and tests in the same PR. Passing checks +does not establish cohesive modules, correct DRY abstractions or visual quality; +those remain review responsibilities. `main` requires the GitHub Actions `checks` +status through the `ci-required` ruleset. That status covers lightweight CI; +architecture/design guards and full-suite validation are enforced locally by +pre-push and recorded in the PR evidence. + +## Adapter and schema evolution New adapters must preserve ownership, atomic publication, retention, replay, lease fencing and commit-before-ack recovery. External brokers require atomic diff --git a/docs/development.md b/docs/development.md index 9e6537ab..d0e0244e 100644 --- a/docs/development.md +++ b/docs/development.md @@ -48,6 +48,8 @@ List available tasks with `mise tasks`. Keep personal overrides in | Run web unit and browser tests | `pnpm test:web` | | Run Go tests | `mise run test:go` | | Run boundary and shared-rule contracts | `mise run test:contracts` | +| Check production package dependencies | `mise run check:boundaries` | +| Check frontend design-token use | `mise run check:design` | | Run distribution API conformance tests | `mise run test:conformance` | | Run TypeScript checks | `pnpm typecheck` | | Run JavaScript linting | `pnpm lint` | @@ -67,8 +69,13 @@ local platform. Both CI and release verification run `mise run check:ci`: static checks, web and CLI builds, generated-file consistency, and the distribution API smoke test. -Unit, browser, release-tool, and Go -race suites run locally before pushing. CI does not run a native OS test matrix. +Local `mise run check` adds dependency/design guards, behavioral contracts, +JavaScript unit/browser suites, release-tool tests and all Go tests; pre-push +adds Go race tests. Keep these full suites and guards local. Chromium is installed +by local setup, not CI/release workflows. CI does not run a native OS test matrix. +GitHub's `ci-required` ruleset requires the lightweight `checks` status on an +up-to-date `main` change, including administrators; it is configured in GitHub, +not by mise. PR evidence records full local validation. `mise run install` embeds the current web build and installs `servediff` into `$GOBIN`, or `$GOPATH/bin` when `GOBIN` is unset. Ensure that directory is on diff --git a/mise.toml b/mise.toml index f9085b63..01908fbf 100644 --- a/mise.toml +++ b/mise.toml @@ -126,29 +126,39 @@ run = [ description = "Run all repository checks and test suites" run = [ { task = "check:ci" }, + { task = "test:contracts:rules" }, "pnpm test", "pnpm test:release", { task = "test:go" }, ] [tasks."test:contracts"] -description = "Run architecture, boundary, shared-rule and real HTTP contracts locally" +description = "Build and run architecture, boundary, shared-rule and real HTTP contracts" depends = ["build"] +run = [{ task = "test:contracts:rules" }, "pnpm test:conformance"] + +[tasks."check:boundaries"] +description = "Reject unclassified packages and forbidden production dependencies" +run = "go run ./tools/check-boundaries" + +[tasks."check:design"] +description = "Check frontend design-token use" +run = "node tools/check-design.ts" + +[tasks."test:contracts:rules"] +description = "Run dependency, design-token, behavioral and shared-rule contracts without rebuilding" run = [ - "go run ./tools/check-boundaries", - "go test ./internal/review ./internal/session ./internal/contextservice ./internal/reviewservice ./internal/reviewstore ./internal/controlapi ./internal/ingestion ./internal/ingestionqueue ./internal/submission ./internal/serverapp ./internal/remoteserver ./test/conformance", + { tasks = [ + "check:boundaries", + "check:design", + ] }, + "go test ./tools/check-boundaries ./internal/review ./internal/session ./internal/contextservice ./internal/reviewservice ./internal/reviewstore ./internal/controlapi ./internal/ingestion ./internal/ingestionqueue ./internal/submission ./internal/serverapp ./internal/remoteserver ./test/conformance", "node --test test/contracts/*.test.ts", - "pnpm test:conformance", ] [tasks."check:push"] description = "Full pre-push checks, race tests, and generated-file consistency" -run = [ - { task = "check" }, - { task = "test:contracts" }, - { task = "test:race" }, - { task = "check:generated" }, -] +run = [{ task = "check" }, { task = "test:race" }, { task = "check:generated" }] [tasks."release:publish"] description = "Publish the tagged GitHub release with GoReleaser" diff --git a/package.json b/package.json index 5fa48b47..ea613eb7 100644 --- a/package.json +++ b/package.json @@ -26,6 +26,7 @@ "check": "mise run check" }, "devDependencies": { + "@babel/parser": "^7.29.8", "@earendil-works/pi-coding-agent": "catalog:", "@opencode/plugin": "catalog:", "@types/node": "catalog:", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index c339b451..351b1959 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -83,6 +83,9 @@ importers: .: devDependencies: + '@babel/parser': + specifier: ^7.29.8 + version: 7.29.8 '@earendil-works/pi-coding-agent': specifier: 'catalog:' version: 1.0.0(@aws-sdk/credential-provider-node@3.972.84)(@modelcontextprotocol/sdk@1.30.0(supports-color@10.2.2)(zod@4.1.8))(@smithy/signature-v4@5.7.4)(supports-color@10.2.2)(ws@8.22.0)(zod@4.1.8) diff --git a/test/conformance/tsconfig.json b/test/conformance/tsconfig.json index 4c6ee960..e6de6f72 100644 --- a/test/conformance/tsconfig.json +++ b/test/conformance/tsconfig.json @@ -3,5 +3,9 @@ "compilerOptions": { "paths": { "@servediff/shared": ["../../packages/shared/src/index.ts"] } }, - "include": ["server.test.ts", "../contracts/*.test.ts"] + "include": [ + "server.test.ts", + "../contracts/*.test.ts", + "../../tools/check-design.ts" + ] } diff --git a/test/contracts/design.test.ts b/test/contracts/design.test.ts new file mode 100644 index 00000000..47cdbb88 --- /dev/null +++ b/test/contracts/design.test.ts @@ -0,0 +1,242 @@ +import assert from "node:assert/strict"; +import { mkdtempSync, mkdirSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { test } from "node:test"; +import { fileURLToPath } from "node:url"; +import { checkDesign, readDesignSources } from "../../tools/check-design.ts"; + +function check(text: string, path = "new.css") { + return checkDesign([{ path, text }]); +} + +test("semantic tokens compose colors, type, geometry and spacing", () => { + assert.deepEqual( + check(`.control { + color: var(--fg); background: color-mix(in srgb, var(--accent) 10%, transparent); + font: var(--weight-medium) var(--text-sm) var(--font-sans); + border: 1px solid currentColor; border-radius: var(--radius-control) 0; + box-shadow: var(--shadow-overlay); padding: 0 var(--space-3); margin-left: auto; + }`), + [], + ); +}); + +test("literal palette cannot escape through custom properties, fallbacks or shorthand", () => { + for (const declaration of [ + "color: red", + "background: #abc", + "border: 1px solid rebeccapurple", + "--new-color: hsl(1 2% 3%)", + "color: var(--unknown, red)", + "--alias: blue", + "background: color-mix(in srgb, var(--bg), white)", + ]) { + assert.equal( + check(`.new { ${declaration} !important }`).length, + 1, + declaration, + ); + } +}); + +test("canonical source ownership does not exempt unrelated files", () => { + assert.deepEqual(check(":root { --fg: #fff }", "tokens.css"), []); + assert.equal(check(".new { color: #fff }", "tokens.css").length, 1); + assert.equal(check(":root { --fg: #fff }", "nested/tokens.css").length, 1); + assert.deepEqual( + check(":root { --text-xs: .75rem; font-size: 100% }", "typography.css"), + [], + ); + assert.equal(check(":root { --text-xs: .75rem }", "tokens.css").length, 1); +}); + +test("CSS parser handles nesting, comments, strings and multiline values", () => { + const result = check(`/* color: red; */ +@media (width > 100px) { + .new { content: "color: red; }"; + background-image: url("data:image/svg+xml;unused"); + color: rgb( + 1 2 3 + ); + } +}`); + assert.equal(result.length, 1); + assert.equal(result[0]?.line, 5); + assert.equal(result[0]?.property, "color"); + assert.deepEqual( + check(`/* @apply text-xl; */ .new { content: "/* @apply text-xl; */" }`), + [], + ); +}); + +test("type, radius, shadows and layout lengths require tokens", () => { + for (const declaration of [ + "font-size: 15px", + "font-family: Arial", + "font-family: var(--font-sans), Arial", + "font-weight: 550", + "line-height: 1.7", + "letter-spacing: .2em", + "border-radius: 50%", + "border-radius: calc(var(--radius-sm) + .5rem)", + "--local-radius: .5rem", + "--space-custom: 13px", + "--shadow-custom: 0 2px var(--border)", + "box-shadow: 0 2px 8px var(--border)", + "padding: 10px", + "gap: calc(var(--space-2) + 2px)", + ]) { + assert.equal(check(`.new { ${declaration} }`).length, 1, declaration); + } +}); + +test("optical exceptions remain exact in selector, value and source", () => { + assert.deepEqual( + check(".staging-dot { border-radius: 50% }", "review.css"), + [], + ); + assert.equal(check(".new { border-radius: 50% }", "review.css").length, 1); + assert.equal( + check(".staging-dot { border-radius: 25% }", "review.css").length, + 1, + ); + assert.equal( + check(".staging-dot { border-radius: 50% }", "new.css").length, + 1, + ); + assert.deepEqual(check(".new { padding: 1px var(--space-2) }"), []); +}); + +test("inline styles and named style objects cannot bypass rules", () => { + for (const text of [ + `const View = () =>
`, + `const rowStyle = { fontSize: 15 };`, + `const View = () => `, + `const rowStyle = { "border-radius": "12px" };`, + `const s = {color: "red"}; const View = () => ;`, + `const base = {color: "red"}; const s = {...base}; const View = () => ;`, + `const s: React.CSSProperties = {color: "red"};`, + `const View = () => ;`, + 'const View = () => ;', + 'const View = () => ;', + "const View = () => ;", + "const View = () => ;", + "const View = () => ;", + "const View = () => ;", + ]) + assert.equal(check(text, "new.tsx").length, 1, text); + assert.deepEqual( + check( + `const rowStyle = { color: "var(--fg)", paddingLeft: \`calc(var(--space-1) + \${depth} * var(--space-3))\` };`, + "new.tsx", + ), + [], + ); + assert.deepEqual( + check(`const itemMetrics = { lineHeight: measuredHeight };`, "new.tsx"), + [], + ); + assert.deepEqual(check(".control { border-radius: 0px }"), []); +}); + +test("unsafeCSS is checked as CSS and dynamic injection is rejected", () => { + assert.equal( + check("const options = { unsafeCSS: `.new { color: #fff }` };", "new.ts") + .length, + 1, + ); + assert.equal( + check("const options = { unsafeCSS: injectedRules };", "new.ts").length, + 1, + ); + assert.equal( + check( + "const options = { unsafeCSS: `.new { ${injectedRules} }` };", + "new.ts", + ).length, + 1, + ); + assert.deepEqual( + check( + "const options = { unsafeCSS: `.new { color: var(--fg) }` };", + "new.ts", + ), + [], + ); +}); + +test("utility styling covers variants, arbitrary properties and escaped JS strings", () => { + for (const token of [ + "hover:bg-red-500", + "text-white", + "text-sm", + "font-bold", + "rounded-md", + "shadow-lg", + "text-[15px]", + "bg-[#fff]", + "[color:red]", + "focus:[font-size:15px]", + "[&_svg]:text-[length:12px]", + "gap-[15px]", + ]) { + assert.equal( + check(`const classes = ${JSON.stringify(token)};`, "new.tsx").length, + 1, + token, + ); + } + assert.equal( + check(`const classes = "bg-\\u0072ed-500";`, "new.tsx").length, + 1, + ); + assert.equal(check(`.new { @apply hover:bg-red-500; }`).length, 1); + assert.deepEqual( + check( + `const classes = "hover:bg-[var(--hover)] text-[length:var(--text-xs)] rounded-[var(--radius-sm)] gap-[var(--space-2)]";`, + "new.tsx", + ), + [], + ); +}); + +test("utility guards distinguish styling from displayed data and follow aliases", () => { + assert.deepEqual( + check( + 'const example = "text-sm font-bold"; const View = () =>{example};',
+ "new.tsx",
+ ),
+ [],
+ );
+ for (const text of [
+ 'const s = "rounded-md"; const View = () => ;',
+ 'const base = "text-sm"; const s = base; const View = () => ;',
+ 'const base = "shadow-lg"; const View = () => ;',
+ 'const variants = cva("text-sm", {variants: {size: {large: "text-xl"}}});',
+ ])
+ assert.ok(check(text, "new.tsx").length > 0, text);
+});
+
+test("new nested frontend files enter the check automatically", () => {
+ const directory = mkdtempSync(join(tmpdir(), "servediff-design-"));
+ try {
+ mkdirSync(join(directory, "new"));
+ writeFileSync(join(directory, "new", "control.css"), ".new { color: red }");
+ writeFileSync(
+ join(directory, "new", "Control.tsx"),
+ 'const classes = "text-xl";',
+ );
+ writeFileSync(join(directory, "README.md"), "color: red");
+ const sources = readDesignSources(directory);
+ assert.equal(sources.length, 2);
+ assert.equal(checkDesign(sources).length, 2);
+ } finally {
+ rmSync(directory, { recursive: true, force: true });
+ }
+});
+
+test("current frontend satisfies the guard", () => {
+ const root = fileURLToPath(new URL("../../apps/web/src/", import.meta.url));
+ assert.deepEqual(checkDesign(readDesignSources(root)), []);
+});
diff --git a/tools/check-boundaries/main.go b/tools/check-boundaries/main.go
index 3085b8ca..5c6ae798 100644
--- a/tools/check-boundaries/main.go
+++ b/tools/check-boundaries/main.go
@@ -1,78 +1,136 @@
-// Command check-boundaries validates production dependencies without imposing CI policy.
+// Command check-boundaries validates production dependencies for the active Go build target.
package main
import (
+ "bytes"
"encoding/json"
"fmt"
"io"
"os"
"os/exec"
+ "slices"
"strings"
)
-func main() {
- command := exec.Command("go", "list", "-json", "./internal/...")
- output, err := command.StdoutPipe()
- if err != nil {
- panic(err)
- }
- command.Stderr = os.Stderr
- if err := command.Start(); err != nil {
- panic(err)
- }
- allowed := map[string][]string{
- "review": {}, "ingestion": {"review"}, "reviewdata": {"review", "ingestion"},
- "session": {"review"}, "contextservice": {"review", "reviewdata", "session", "ingestion"},
- "reviewservice": {"review", "reviewdata", "session", "contextservice", "ingestion"},
- "ingestionqueue": {"review", "ingestion"},
- "reviewstore": {"review", "reviewdata", "ingestion", "ingestionqueue", "processlock"},
- "submission": {"ingestion", "processlock"},
- "httpapi": {"review", "reviewdata", "session", "contextservice", "reviewservice", "ingestion", "processmetrics"},
- "mcpapi": {"review", "contextservice", "reviewservice", "ingestion"},
+const modulePrefix = "github.com/flexdinesh/servediff/"
+
+// Exact package paths make new packages, including nested ones, require an explicit
+// architectural role. Command/composition roots wire concrete adapters.
+var allowed = map[string][]string{
+ "cmd/servediff": {
+ "internal/browser", "internal/collector", "internal/config", "internal/contextservice",
+ "internal/daemon", "internal/diffsource", "internal/hooks", "internal/ingestion",
+ "internal/review", "internal/reviewstore", "internal/submission", "internal/version",
+ },
+ "cmd/servediff-server": {"internal/config", "internal/remoteserver", "internal/reviewstore", "internal/version"},
+ "internal/browser": {},
+ "internal/collector": {"internal/diffsource", "internal/ingestion", "internal/review"},
+ "internal/config": {"internal/processlock", "internal/reviewstore"},
+ "internal/contextservice": {"internal/review", "internal/reviewdata", "internal/session", "internal/ingestion"},
+ "internal/controlapi": {"internal/config", "internal/review"},
+ "internal/daemon": {
+ "internal/contextservice", "internal/controlapi", "internal/ingestion", "internal/processlock",
+ "internal/reviewstore", "internal/serverapp", "internal/version", "internal/webui",
+ },
+ "internal/diffsource": {"internal/processlock", "internal/review"},
+ "internal/hooks": {"internal/ingestion", "internal/processlock", "internal/submission"},
+ "internal/httpapi": {
+ "internal/review", "internal/reviewdata", "internal/session", "internal/contextservice",
+ "internal/reviewservice", "internal/ingestion", "internal/processmetrics", "packages/api",
+ },
+ "internal/ingestion": {"internal/review"},
+ "internal/ingestionqueue": {"internal/review", "internal/ingestion"},
+ "internal/mcpapi": {"internal/review", "internal/contextservice", "internal/reviewservice", "internal/ingestion"},
+ "internal/processlock": {},
+ "internal/processmetrics": {},
+ "internal/remoteserver": {
+ "internal/contextservice", "internal/ingestion", "internal/ingestionqueue", "internal/review",
+ "internal/reviewstore", "internal/serverapp", "internal/webui",
+ },
+ "internal/review": {},
+ "internal/reviewdata": {"internal/review", "internal/ingestion"},
+ "internal/reviewservice": {"internal/review", "internal/reviewdata", "internal/session", "internal/contextservice", "internal/ingestion"},
+ "internal/reviewstore": {"internal/review", "internal/reviewdata", "internal/ingestion", "internal/ingestionqueue", "internal/processlock"},
+ "internal/serverapp": {
+ "internal/contextservice", "internal/httpapi", "internal/ingestion", "internal/ingestionqueue",
+ "internal/mcpapi", "internal/review", "internal/reviewdata", "internal/reviewservice", "internal/version",
+ },
+ "internal/session": {"internal/review"},
+ "internal/submission": {"internal/ingestion", "internal/processlock"},
+ // Test helpers are classified, but production packages cannot import them.
+ "internal/testsupport": {
+ "internal/contextservice", "internal/ingestion", "internal/ingestionqueue", "internal/review",
+ "internal/reviewstore", "internal/serverapp", "internal/webui",
+ },
+ "internal/version": {},
+ "internal/webui": {},
+}
+
+type listedPackage struct {
+ ImportPath string
+ Imports []string
+}
+
+func checkPackage(pkg listedPackage) []string {
+ name := strings.TrimPrefix(pkg.ImportPath, modulePrefix)
+ permitted, checked := allowed[name]
+ if !checked {
+ return []string{fmt.Sprintf("unclassified package: %s; assign an architecture boundary", name)}
}
- const prefix = "github.com/flexdinesh/servediff/internal/"
- decoder := json.NewDecoder(output)
- failed := false
- for {
- var pkg struct {
- ImportPath string
- Imports []string
+ var violations []string
+ for _, dependency := range pkg.Imports {
+ if dependency == "os/exec" && !slices.Contains([]string{
+ "internal/browser", "internal/daemon", "internal/diffsource", "internal/hooks",
+ }, name) {
+ violations = append(violations, fmt.Sprintf("%s must not execute processes", name))
}
- err := decoder.Decode(&pkg)
- if err == io.EOF {
- break
+ if (dependency == "database/sql" || dependency == "modernc.org/sqlite" || strings.HasPrefix(dependency, "modernc.org/sqlite/")) && name != "internal/reviewstore" {
+ violations = append(violations, fmt.Sprintf("%s must not access SQL storage directly: %s", name, dependency))
}
- if err != nil {
- panic(err)
- }
- name := strings.TrimPrefix(pkg.ImportPath, prefix)
- permitted, checked := allowed[name]
- if !checked {
+ if !strings.HasPrefix(dependency, modulePrefix) {
continue
}
- for _, dependency := range pkg.Imports {
- if dependency == "os/exec" {
- fmt.Fprintf(os.Stderr, "%s must not execute producers\n", name)
- failed = true
- }
- if !strings.HasPrefix(dependency, prefix) {
- continue
- }
- dependency = strings.TrimPrefix(dependency, prefix)
- ok := false
- for _, candidate := range permitted {
- ok = ok || dependency == candidate
- }
- if !ok {
- fmt.Fprintf(os.Stderr, "forbidden dependency: %s -> %s\n", name, dependency)
- failed = true
- }
+ dependency = strings.TrimPrefix(dependency, modulePrefix)
+ if dependency == "internal/testsupport" || strings.HasPrefix(dependency, "internal/testsupport/") {
+ violations = append(violations, fmt.Sprintf("production dependency on test helpers: %s -> %s", name, dependency))
+ } else if !slices.Contains(permitted, dependency) {
+ violations = append(violations, fmt.Sprintf("forbidden dependency: %s -> %s", name, dependency))
}
}
- if err := command.Wait(); err != nil {
- panic(err)
+ return violations
+}
+
+func checkPackages(input io.Reader, diagnostics io.Writer) (bool, error) {
+ decoder := json.NewDecoder(input)
+ passed := true
+ for {
+ var pkg listedPackage
+ if err := decoder.Decode(&pkg); err == io.EOF {
+ return passed, nil
+ } else if err != nil {
+ return false, err
+ }
+ for _, violation := range checkPackage(pkg) {
+ fmt.Fprintln(diagnostics, violation)
+ passed = false
+ }
+ }
+}
+
+func main() {
+ command := exec.Command("go", "list", "-json", "./cmd/...", "./internal/...")
+ command.Stderr = os.Stderr
+ output, err := command.Output()
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
+ }
+ passed, err := checkPackages(bytes.NewReader(output), os.Stderr)
+ if err != nil {
+ fmt.Fprintln(os.Stderr, err)
+ os.Exit(1)
}
- if failed {
+ if !passed {
os.Exit(1)
}
fmt.Println("Architecture boundaries passed")
diff --git a/tools/check-boundaries/main_test.go b/tools/check-boundaries/main_test.go
new file mode 100644
index 00000000..067ba90d
--- /dev/null
+++ b/tools/check-boundaries/main_test.go
@@ -0,0 +1,92 @@
+package main
+
+import (
+ "bytes"
+ "encoding/json"
+ "strings"
+ "testing"
+)
+
+func TestPackageBoundaries(t *testing.T) {
+ tests := []struct {
+ name string
+ pkg string
+ imports []string
+ want string
+ }{
+ {name: "unknown module cannot silently pass", pkg: "internal/newservice", want: "unclassified package: internal/newservice"},
+ {name: "new command needs a role", pkg: "cmd/other", want: "unclassified package: cmd/other"},
+ {name: "nested service needs its own role", pkg: "internal/reviewservice/adapter", want: "unclassified package: internal/reviewservice/adapter"},
+ {name: "domain cannot import transport", pkg: "internal/reviewservice", imports: []string{modulePrefix + "internal/httpapi"}, want: "forbidden dependency: internal/reviewservice -> internal/httpapi"},
+ {name: "domain cannot import concrete storage", pkg: "internal/contextservice", imports: []string{modulePrefix + "internal/reviewstore"}, want: "forbidden dependency: internal/contextservice -> internal/reviewstore"},
+ {name: "submission cannot import hook scheduling", pkg: "internal/submission", imports: []string{modulePrefix + "internal/hooks"}, want: "forbidden dependency: internal/submission -> internal/hooks"},
+ {name: "nested import cannot inherit permission", pkg: "internal/reviewservice", imports: []string{modulePrefix + "internal/review/helpers"}, want: "forbidden dependency: internal/reviewservice -> internal/review/helpers"},
+ {name: "domain cannot import generated transport contract", pkg: "internal/review", imports: []string{modulePrefix + "packages/api"}, want: "forbidden dependency: internal/review -> packages/api"},
+ {name: "local root composes storage and producers", pkg: "cmd/servediff", imports: []string{modulePrefix + "internal/reviewstore", modulePrefix + "internal/collector", modulePrefix + "internal/daemon"}},
+ {name: "remote root composes storage and server", pkg: "cmd/servediff-server", imports: []string{modulePrefix + "internal/reviewstore", modulePrefix + "internal/remoteserver"}},
+ {name: "remote composition wires worker", pkg: "internal/remoteserver", imports: []string{modulePrefix + "internal/ingestionqueue", modulePrefix + "internal/serverapp", modulePrefix + "internal/reviewstore"}},
+ {name: "service uses shared contracts", pkg: "internal/reviewservice", imports: []string{modulePrefix + "internal/review", modulePrefix + "internal/reviewdata", modulePrefix + "internal/contextservice"}},
+ {name: "transport can project generated contract", pkg: "internal/httpapi", imports: []string{modulePrefix + "packages/api", modulePrefix + "internal/session"}},
+ {name: "standard and external libraries unaffected", pkg: "internal/review", imports: []string{"context", "encoding/json", "example.com/library"}},
+ {name: "shared reads cannot execute producers", pkg: "internal/reviewservice", imports: []string{"os/exec"}, want: "internal/reviewservice must not execute processes"},
+ {name: "command delegates process execution", pkg: "cmd/servediff", imports: []string{"os/exec"}, want: "cmd/servediff must not execute processes"},
+ {name: "storage cannot execute producers", pkg: "internal/reviewstore", imports: []string{"os/exec"}, want: "internal/reviewstore must not execute processes"},
+ {name: "git adapter executes git", pkg: "internal/diffsource", imports: []string{"os/exec"}},
+ {name: "hook scheduler executes finite producer", pkg: "internal/hooks", imports: []string{"os/exec"}},
+ {name: "lifecycle inspects processes", pkg: "internal/daemon", imports: []string{"os/exec"}},
+ {name: "browser launches platform opener", pkg: "internal/browser", imports: []string{"os/exec"}},
+ {name: "service cannot bypass atomic storage contract", pkg: "internal/contextservice", imports: []string{"database/sql"}, want: "internal/contextservice must not access SQL storage directly"},
+ {name: "composition cannot query sqlite directly", pkg: "internal/remoteserver", imports: []string{"modernc.org/sqlite"}, want: "internal/remoteserver must not access SQL storage directly"},
+ {name: "sqlite subpackages preserve storage boundary", pkg: "internal/review", imports: []string{"modernc.org/sqlite/lib"}, want: "internal/review must not access SQL storage directly"},
+ {name: "storage owns sql and sqlite", pkg: "internal/reviewstore", imports: []string{"database/sql", "modernc.org/sqlite"}},
+ {name: "production cannot use test helpers", pkg: "internal/contextservice", imports: []string{modulePrefix + "internal/testsupport"}, want: "production dependency on test helpers: internal/contextservice -> internal/testsupport"},
+ {name: "command cannot use nested test helpers", pkg: "cmd/servediff", imports: []string{modulePrefix + "internal/testsupport/fixtures"}, want: "production dependency on test helpers: cmd/servediff -> internal/testsupport/fixtures"},
+ {name: "test helper can compose real boundary", pkg: "internal/testsupport", imports: []string{modulePrefix + "internal/reviewstore", modulePrefix + "internal/serverapp", "testing", "net/http/httptest"}},
+ }
+ for _, test := range tests {
+ t.Run(test.name, func(t *testing.T) {
+ got := checkPackage(listedPackage{ImportPath: modulePrefix + test.pkg, Imports: test.imports})
+ if test.want == "" {
+ if len(got) != 0 {
+ t.Fatalf("permitted dependencies rejected: %v", got)
+ }
+ } else if len(got) != 1 || !strings.Contains(got[0], test.want) {
+ t.Fatalf("violations = %v; want one containing %q", got, test.want)
+ }
+ })
+ }
+}
+
+func TestCheckPackagesReportsAllViolations(t *testing.T) {
+ var input, diagnostics bytes.Buffer
+ encoder := json.NewEncoder(&input)
+ for _, pkg := range []listedPackage{
+ {ImportPath: modulePrefix + "internal/review"},
+ {ImportPath: modulePrefix + "internal/newservice"},
+ {ImportPath: modulePrefix + "internal/reviewservice", Imports: []string{"os/exec", modulePrefix + "internal/reviewstore"}},
+ } {
+ if err := encoder.Encode(pkg); err != nil {
+ t.Fatal(err)
+ }
+ }
+ passed, err := checkPackages(&input, &diagnostics)
+ if err != nil || passed {
+ t.Fatalf("passed = %v, error = %v; want rejected stream without decoding error", passed, err)
+ }
+ for _, want := range []string{
+ "unclassified package: internal/newservice",
+ "internal/reviewservice must not execute processes",
+ "forbidden dependency: internal/reviewservice -> internal/reviewstore",
+ } {
+ if !strings.Contains(diagnostics.String(), want) {
+ t.Errorf("diagnostics missing %q: %s", want, &diagnostics)
+ }
+ }
+}
+
+func TestCheckPackagesRejectsMalformedInventory(t *testing.T) {
+ passed, err := checkPackages(strings.NewReader("{"), &bytes.Buffer{})
+ if passed || err == nil {
+ t.Fatalf("passed = %v, error = %v; want decoding failure", passed, err)
+ }
+}
diff --git a/tools/check-design.ts b/tools/check-design.ts
new file mode 100644
index 00000000..d96c4ae1
--- /dev/null
+++ b/tools/check-design.ts
@@ -0,0 +1,616 @@
+import { readdirSync, readFileSync } from "node:fs";
+import { join, relative, resolve, sep } from "node:path";
+import { fileURLToPath } from "node:url";
+import { parse } from "@babel/parser";
+
+export interface DesignSource {
+ path: string;
+ text: string;
+}
+
+export interface DesignViolation {
+ path: string;
+ line: number;
+ property: string;
+ value: string;
+}
+
+const colors = new Set(
+ "aliceblue antiquewhite aqua aquamarine azure beige bisque black blanchedalmond blue blueviolet brown burlywood cadetblue chartreuse chocolate coral cornflowerblue cornsilk crimson cyan darkblue darkcyan darkgoldenrod darkgray darkgreen darkgrey darkkhaki darkmagenta darkolivegreen darkorange darkorchid darkred darksalmon darkseagreen darkslateblue darkslategray darkslategrey darkturquoise darkviolet deeppink deepskyblue dimgray dimgrey dodgerblue firebrick floralwhite forestgreen fuchsia gainsboro ghostwhite gold goldenrod gray green greenyellow grey honeydew hotpink indianred indigo ivory khaki lavender lavenderblush lawngreen lemonchiffon lightblue lightcoral lightcyan lightgoldenrodyellow lightgray lightgreen lightgrey lightpink lightsalmon lightseagreen lightskyblue lightslategray lightslategrey lightsteelblue lightyellow lime limegreen linen magenta maroon mediumaquamarine mediumblue mediumorchid mediumpurple mediumseagreen mediumslateblue mediumspringgreen mediumturquoise mediumvioletred midnightblue mintcream mistyrose moccasin navajowhite navy oldlace olive olivedrab orange orangered orchid palegoldenrod palegreen paleturquoise palevioletred papayawhip peachpuff peru pink plum powderblue purple rebeccapurple red rosybrown royalblue saddlebrown salmon sandybrown seagreen seashell sienna silver skyblue slateblue slategray slategrey snow springgreen steelblue tan teal thistle tomato turquoise violet wheat white whitesmoke yellow yellowgreen".split(
+ " ",
+ ),
+);
+const typeProperty =
+ /^(?:(?:font(?:-family|-size|-weight)?|line-height|letter-spacing)$|--(?:font|text|weight|leading|tracking|icon)-)/;
+const spacingProperty =
+ /^(?:(?:padding|margin)(?:-(?:top|right|bottom|left|inline|block)(?:-start|-end)?)?|(?:row-|column-)?gap)$/;
+const radiusProperty = /^border(?:-[\w-]+)?-radius$/;
+const colorProperty =
+ /(?:color|background|border|outline|shadow|fill|stroke)|^--/;
+
+// Exact optical/renderer allowances, not file-wide exclusions. DESIGN.md owns
+// the reasons; token rules still apply to every other declaration in these files.
+const exceptions = new Set([
+ "style.css|.live-dot|border-radius|50%",
+ "review.css|.file-reviewed-check|border-radius|50%",
+ "review.css|.staging-dot|border-radius|50%",
+ "review.css|.file-row .file-icon|line-height|1",
+ "review.css|.monogram-icon text|font|700 9px ui-monospace, monospace",
+ "review.css|.comment-filter-count|font-size|calc(var(--text-xs) - 1px)",
+ "review.css|.comment-filter-count|line-height|1",
+ "review.css|#sidebar-resizer|margin|0 -2px 0 -3px",
+ "style.css|#viewer|--diff-gutter-divider-width|2px",
+]);
+
+function normalize(value: string): string {
+ return value
+ .replace(/\s+/g, " ")
+ .trim()
+ .replace(/\s*!important$/i, "")
+ .replace(/\(\s+/g, "(")
+ .replace(/\s+\)/g, ")");
+}
+
+function withoutVariables(value: string): string {
+ // Keep fallbacks visible: var(--role, red) must still fail the palette rule.
+ return value.replace(/var\(\s*--[\w-]+\s*(?:,\s*([^()]*))?\)/gi, "$1");
+}
+
+function invalidValue(
+ path: string,
+ selector: string,
+ property: string,
+ raw: string,
+): boolean {
+ const value = normalize(raw);
+ if (exceptions.has(`${path}|${selector}|${property}|${value}`)) return false;
+ const paletteOwner = path === "tokens.css" && property.startsWith("--");
+ const typeOwner = path === "typography.css";
+ const remainder = withoutVariables(value).replace(
+ /url\([^)]*\)|"[^"\\]*(?:\\.[^"\\]*)*"|'[^'\\]*(?:\\.[^'\\]*)*'/gi,
+ "",
+ );
+ const literalColor =
+ /#[\da-f]{3,8}\b|\b(?:rgba?|hsla?|hwb|lab|lch|oklab|oklch|color)\(/i.test(
+ remainder,
+ );
+ const namedColor =
+ colorProperty.test(property) &&
+ (remainder.toLowerCase().match(/[a-z]+/g) ?? []).some((word) =>
+ colors.has(word),
+ );
+ // Pierre mixes black into its own addition/deletion backgrounds for comments.
+ const pierreMix =
+ path === "DiffWorkspace.tsx" &&
+ property === "--diffs-line-bg" &&
+ /^color-mix\(in srgb, var\(--diffs-computed-diff-line-bg\) 76%, color-mix\(in srgb, var\(--diffs-(?:addition|deletion)-base\) 72%, black\)\)$/.test(
+ value,
+ );
+ if (!paletteOwner && (literalColor || (namedColor && !pierreMix)))
+ return true;
+ if (
+ property.startsWith("--") &&
+ !paletteOwner &&
+ !typeOwner &&
+ /(?:\d*\.)?\d+(?:px|rem|em|%|vh|vw|dvh|dvw)/.test(remainder)
+ ) {
+ const dimensions =
+ remainder.match(/(?:\d*\.)?\d+(?:px|rem|em|%|vh|vw|dvh|dvw)/g) ?? [];
+ if (
+ dimensions.some(
+ (dimension) => !/^0(?:px|rem|em|%|vh|vw|dvh|dvw)$/.test(dimension),
+ ) &&
+ !property.startsWith("--diffs-line-bg")
+ )
+ return true;
+ }
+ if (
+ typeProperty.test(property) &&
+ !typeOwner &&
+ !(paletteOwner && literalColor)
+ ) {
+ if (/^(?:inherit|initial|unset|revert|revert-layer)$/.test(value))
+ return false;
+ if (!value.includes("var(")) return true;
+ if (/\b\d/.test(remainder)) return true;
+ if (/[a-z]/i.test(remainder.replace(/\b(?:calc|min|max|clamp)\b/g, "")))
+ return true;
+ }
+ if (
+ radiusProperty.test(property) &&
+ !/^(?:0(?:px|rem|em|%)?|none|inherit|initial|unset)$/.test(value)
+ ) {
+ if (
+ !value.includes("var(") ||
+ /(?:\d*\.)?\d+(?:px|rem|em|%)/.test(remainder)
+ )
+ return true;
+ }
+ if (
+ /^(?:box|text)-shadow$/.test(property) &&
+ !/^(?:none|inherit|initial|unset|var\(--[\w-]+\))$/.test(value)
+ ) {
+ // These inset strokes carry header separators/navigation feedback, no elevation.
+ if (
+ !(
+ path === "DiffWorkspace.tsx" &&
+ property === "box-shadow" &&
+ /^(?:inset 0 -1px var\(--border\)|inset 3px 0 var\(--accent\)(?:, inset 0 -1px var\(--border\))?|inset 0 0 transparent(?:, inset 0 -1px var\(--border\))?)$/.test(
+ value,
+ )
+ )
+ )
+ return true;
+ }
+ if (spacingProperty.test(property) && !paletteOwner) {
+ // Zero, auto, calc multipliers and 1px optical insets are allowed; layout
+ // lengths use tokens. The overlapping resizer has an exact allowance above.
+ const lengths =
+ remainder.match(/-?(?:\d*\.)?\d+(?:px|rem|em|%|vh|vw|dvh|dvw)/g) ?? [];
+ if (
+ lengths.some(
+ (length) => !/^-?(?:0(?:px|rem|em|%|vh|vw|dvh|dvw)|1px)$/.test(length),
+ )
+ )
+ return true;
+ if (/^[-+]?\d+(?:\.\d+)?$/.test(value) && Number(value) !== 0) return true;
+ }
+ return false;
+}
+
+// Lex declarations rather than grep lines: comments, strings, nested rules,
+// multiline functions and semicolons inside URLs do not split declarations.
+function withoutComments(text: string): string {
+ let output = "";
+ let quote = "";
+ for (let index = 0; index < text.length; index++) {
+ const character = text[index] ?? "";
+ if (quote) {
+ output += character;
+ if (character === "\\") output += text[++index] ?? "";
+ else if (character === quote) quote = "";
+ } else if (character === '"' || character === "'") {
+ quote = character;
+ output += character;
+ } else if (character === "/" && text[index + 1] === "*") {
+ const end = text.indexOf("*/", index + 2);
+ const finish = end < 0 ? text.length : end + 2;
+ output += text.slice(index, finish).replace(/[^\n]/g, " ");
+ index = finish - 1;
+ } else output += character;
+ }
+ return output;
+}
+
+function cssDeclarations(
+ text: string,
+ visit: (
+ selector: string,
+ property: string,
+ value: string,
+ offset: number,
+ ) => void,
+): void {
+ const clean = withoutComments(text);
+ const selectors: string[] = [];
+ let start = 0;
+ let parentheses = 0;
+ let quote = "";
+ function declaration(end: number) {
+ const segment = clean.slice(start, end);
+ const match = /^\s*([\w-]+)\s*:\s*([\s\S]*)$/.exec(segment);
+ if (match?.[1] && match[2] !== undefined) {
+ visit(
+ selectors.at(-1) ?? "",
+ match[1].toLowerCase(),
+ match[2],
+ start + segment.search(/\S/),
+ );
+ }
+ const apply = /^\s*@apply\s+([\s\S]+)$/.exec(segment);
+ if (apply?.[1])
+ visit(
+ selectors.at(-1) ?? "",
+ "@apply",
+ apply[1],
+ start + segment.search(/\S/),
+ );
+ }
+ for (let index = 0; index < clean.length; index++) {
+ const character = clean[index];
+ if (quote) {
+ if (character === "\\") index++;
+ else if (character === quote) quote = "";
+ continue;
+ }
+ if (character === '"' || character === "'") {
+ quote = character;
+ continue;
+ }
+ if (character === "(") parentheses++;
+ if (character === ")") parentheses--;
+ if (parentheses !== 0) continue;
+ if (character === "{") {
+ selectors.push(normalize(clean.slice(start, index)));
+ start = index + 1;
+ } else if (character === ";") {
+ declaration(index);
+ start = index + 1;
+ } else if (character === "}") {
+ declaration(index);
+ selectors.pop();
+ start = index + 1;
+ }
+ }
+}
+
+function utilityTokens(text: string): string[] {
+ return text.split(/\s+/);
+}
+
+function baseUtility(token: string): string {
+ let depth = 0;
+ let lastVariant = -1;
+ for (let index = 0; index < token.length; index++) {
+ if (token[index] === "[" || token[index] === "(") depth++;
+ if (token[index] === "]" || token[index] === ")") depth--;
+ if (token[index] === ":" && depth === 0) lastVariant = index;
+ }
+ return token.slice(lastVariant + 1).replace(/^!|!$/g, "");
+}
+
+function utilityInvalid(path: string, original: string, context = ""): boolean {
+ const token = baseUtility(original);
+ const property = /^\[([\w-]+):(.+)\]$/.exec(token);
+ if (property?.[1] && property[2])
+ return invalidValue(
+ path,
+ "",
+ property[1],
+ property[2].replaceAll("_", " "),
+ );
+ const arbitrary =
+ /^(rounded(?:-[trblse]{1,2})?|font|text|leading|tracking|shadow|bg|border|outline|ring|fill|stroke|p[xytrblse]?|m[xytrblse]?|gap(?:-[xy])?)-\[(.+)\]$/.exec(
+ token,
+ );
+ if (arbitrary?.[1] && arbitrary[2]) {
+ const prefix = arbitrary[1];
+ const value = arbitrary[2]
+ .replace(/^(?:length|color):/, "")
+ .replaceAll("_", " ");
+ if (
+ path === "components/ui/tabs.tsx" &&
+ token === "p-[3px]" &&
+ context ===
+ "group/tabs-list inline-flex w-fit items-center justify-center rounded-[var(--radius-control)] p-[3px] text-muted-foreground group-data-horizontal/tabs:h-[var(--control-height)] group-data-vertical/tabs:h-fit group-data-vertical/tabs:flex-col data-[variant=line]:rounded-none"
+ )
+ return false;
+ const cssProperty = prefix.startsWith("rounded")
+ ? "border-radius"
+ : prefix === "font"
+ ? "font"
+ : prefix === "leading"
+ ? "line-height"
+ : prefix === "tracking"
+ ? "letter-spacing"
+ : prefix === "shadow"
+ ? "box-shadow"
+ : /^(?:p|m|gap)/.test(prefix)
+ ? "padding"
+ : prefix === "text" &&
+ (arbitrary[2].startsWith("length:") ||
+ /\d(?:px|rem|em)/.test(value))
+ ? "font-size"
+ : "color";
+ return invalidValue(path, "", cssProperty, value);
+ }
+ if (
+ /^(?:bg|text|border|outline|ring|fill|stroke|shadow)-(?:black|white|(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d+)(?:\/.*)?$/.test(
+ token,
+ )
+ )
+ return true;
+ if (
+ /^text-(?:xs|sm|base|lg|xl|\d+xl)$|^font-(?:thin|extralight|light|normal|medium|semibold|bold|extrabold|black|sans|serif|mono)$|^leading-(?:none|tight|snug|normal|relaxed|loose|\d+)$|^tracking-(?:tighter|tight|normal|wide|wider|widest)$|^rounded(?:-[trblse]{1,2})?(?:-(?:xs|sm|md|lg|xl|\d+xl|full))?$|^(?:text-)?shadow(?:-(?:xs|sm|md|lg|xl|\d+xl))?$/.test(
+ token,
+ )
+ ) {
+ return (
+ !(
+ path === "components/ui/tabs.tsx" &&
+ original ===
+ "group-data-[variant=default]/tabs-list:data-active:shadow-sm"
+ ) &&
+ !(
+ path === "components/ui/dialog.tsx" &&
+ token === "leading-none" &&
+ context ===
+ "font-heading text-[length:var(--text-base)] leading-none font-[var(--weight-medium)]"
+ )
+ );
+ }
+ return false;
+}
+
+export function checkDesign(
+ sources: readonly DesignSource[],
+): DesignViolation[] {
+ const violations: DesignViolation[] = [];
+ for (const source of sources) {
+ const add = (property: string, value: string, offset: number) =>
+ violations.push({
+ path: source.path,
+ line: source.text.slice(0, offset).split("\n").length,
+ property,
+ value: normalize(value),
+ });
+ const css = (text: string, offset: number) =>
+ cssDeclarations(text, (selector, property, value, at) => {
+ if (property === "@apply") {
+ for (const token of utilityTokens(value))
+ if (utilityInvalid(source.path, token))
+ add("utility", token, offset + at);
+ } else if (invalidValue(source.path, selector, property, value))
+ add(property, value, offset + at);
+ });
+ if (source.path.endsWith(".css")) {
+ css(source.text, 0);
+ continue;
+ }
+ const ast = parse(source.text, {
+ sourceType: "module",
+ plugins: ["typescript", "jsx"],
+ });
+ function record(node: unknown): node is Record