mirror of
https://github.com/nexu-io/open-design.git
synced 2026-10-27 09:51:12 +01:00
* Add Website Clone home entry with bundled web-clone skill and root-relative asset preview fix
* Retrigger CI
* Open the site entry after website-clone turns
* Harden web-clone skill asset and color fidelity
Recon now scrolls the full page and captures @font-face rules, real font/image
resource URLs, computed section palette, and resolved root variables. Asset
harvest is rebuilt on the browser network stack so cross-origin CDN images,
fonts, and media (hotlink-protected included) all land locally, with a
self-hosted fonts.css and an originalUrl→localPath manifest. Clone audit gains
--recon --strict fidelity gates (fonts self-hosted, images downloaded, key
section colors exact) that exit 2 on violations, and SKILL.md makes all three
a mandatory delivery gate.
* Default web-clone mode to faithful reproduction when unconfirmed
A real nike.com run showed the agent silently downgrading to visual-homage
mode (placeholder brand, typography blocks instead of photography, approximated
palette) because the user skipped the turn-1 confirmations and the site was a
copyright-heavy commercial SPA. Fidelity is the point of this scenario: default
to faithful reproduction, land real assets first, record trademark/copyright
replacements as a pre-deploy checklist for the user to decide in Step 6, and
never skip script recon just because the target looks bot-sensitive.
* Streamline web-clone runs: skip discovery with URL, exempt brand constraints, actionable playwright setup
Three run-path optimizations for the Website Clone scenario:
1. POST /api/projects derives metadata.skipDiscoveryBrief for web-clone
projects whose pending prompt already carries the target URL — the URL is
the brief, so the turn-1 question form no longer stalls the run (an
explicit client flag still wins). Covered by
project-create-web-clone-discovery.test.ts.
2. Prompt composition skips the design-system authoritative block and craft
rules for intent=web-clone runs: the fidelity target is the original
site, not the project brand; brand application belongs to the skill's
Step 6 replacement phase.
3. The plugin's playwright-loader now also resolves from the process cwd
(so 'npm i -D playwright' in the project root works for scripts staged
under .od-skills/) and fails with exact fix commands; SKILL.md documents
the staged-path resolution and forbids downgrading to eyeballing because
of missing deps.
* Swap the copyright guardrail for faithful reproduction on web-clone runs
The official designer prompt's 'don't recreate copyrighted designs — help
the user build something original instead' bullet made web-clone agents
silently rebrand clones (Nike → placeholder brand, original SVGs instead of
real photography) even after the skill mandated faithful defaults: a system
prompt guardrail outranks skill blocks. For intent=web-clone runs the bullet
is swapped for a faithful-reproduction rule that routes trademark/copyright
caution through a pre-deploy replacement checklist (NOTES.md) the user owns.
Prompt tests guard both directions of the substitution.
Also: recon now captures scroll-feel signals (scroll-behavior, scroll-snap
rule count, sticky/fixed census) and SKILL.md makes matching the original's
scroll behavior (smooth-scroll libs, snap, sticky) a fidelity requirement.
* Extract web-clone into a shared global skill referenced by ref
The Website Clone workflow (SKILL.md + 14 recon/harvest/audit scripts +
references) moves from the example-web-clone plugin into the global skill
registry at skills/web-clone/. example-web-clone now references it via
od.context.skills:[{ref:'web-clone'}] instead of shipping its own copy, so the
upcoming per-site clone example plugins can all share one skill rather than
each duplicating a 16KB SKILL.md and its scripts.
Pinned-plugin skill resolution (server.ts) previously only loaded a plugin's
LOCAL ./SKILL.md; a {ref} skill loaded nothing, so the body never entered the
prompt and the scripts never staged. It now falls back to resolving the ref
against the global registry, loading the body and registering the skill dir for
cwd staging. Verified end-to-end in a live run: skills/web-clone stages to
.od-skills/web-clone-<hash>/ with SKILL.md + all scripts.
guard.ts residual-JS allowlist follows the scripts to their new home; a
structural test locks the manifest<->skill contract.
* Add 7 Website Clone example-prompt cards for the Home clone rail
nexu.io, open-design.ai, anthropic.com, vercel.com, resend.com, linear.app,
raycast.com — each a thin example plugin that references the shared web-clone
global skill (od.context.skills:[{ref:'web-clone'}]) so none duplicate the
workflow. Each seeds the composer with a faithful-clone prompt for its URL and
ships a self-contained example.html preview built from a real recon capture of
the target homepage, so the gallery card shows what the card clones. They
surface under the Website clone chip's example-prompt rail (verified in a live
desktop run) and inherit the chip's intent:'web-clone' metadata, so clicking one
runs through the full fidelity path (skip discovery, brand exemption, faithful
reproduction, script recon/harvest/audit).
* Polish clone example-card previews with a framed hero capture
The first pass inlined a downscaled full-page screenshot cropped mid-content —
it read as a raw screenshot dump. Each preview is now a crisp above-the-fold
hero capture (1440x900 DPR2, 16:10) presented inside a browser-window chrome
(traffic lights + host URL pill) on a brand-tinted gradient, so the gallery
card reads as an intentional site preview. Verified by rendering the previews
headless.
* Cache the plugin list so Home remounts don't grey the create rail
HomeView re-fetched + parsed the large /api/plugins payload on every remount,
leaving the create-rail chips disabled for 1-2s each time. A module-level cache
(listPluginsFresh, 10s TTL) serves warm mounts without the network round trip so
pluginsLoading clears within a frame; the first render stays cheap (empty list)
to keep the click off the critical path. Also hide the base example-web-clone
plugin from the example rail (the per-site clone-* cards are the real examples).
* Ship real self-contained clones as example-card previews + align seed prompt
Each clone-* example.html is now a real, self-contained reproduction of its
target site (inlined CSS/fonts/images via document.styleSheets serialization so
CSS-in-JS/Next.js SPAs like Vercel/Raycast capture correctly, oversized icon
sprites dropped, rasters downscaled to stay under the 5MB preview cap, plus a
scroll-reveal motion layer). Preview renders the real HTML; Remix copies it into
a project as index.html. Manifests reuse the base web-clone useCase.query with a
{{targetUrl}} default per site, so picking a card seeds the same preset prompt
with the URL filled in instead of the marketing description.
* Shrink clone example previews ~33% (dedup + drop EOT + WebP)
Three lossless-to-near-lossless passes on the self-contained clones:
- Dedup repeated data-URIs (same image used in <img> + css url() N times): store
each once as a :root CSS var, point url() at var(), hydrate img[src] from the
var via a tiny script. Resend alone shed 1.8MB.
- Drop leftover EOT/fontobject font data-URIs (IE-only, redundant with woff2).
- Re-encode inlined raster images to WebP (~50% smaller than JPEG at equivalent
quality) via cwebp q72.
Total across the 7 cards: ~23MB → ~15.5MB, every card now under 3MB (was up to
5MB). Image hydration + fidelity verified by headless render.
* Lead create rail with Website clone; seed example cards with just the URL
- Move 'web-clone' to the front of CREATE_RAIL_ORDER so Website clone is the
first create-rail card, ahead of Slides (deck).
- Website-clone example cards (Clone Nexu, Clone Vercel, …) now seed the
composer with the same short 'clone this site: <url>' scaffold the chip uses
(localized webClonePromptSeed prefix + the card's targetUrl), e.g.
'Website URL to clone: https://nexu.io', instead of the multi-paragraph
build spec. Picking the chip with an empty composer already seeds the bare
prefix for the user to paste a URL after.
- Fix a test-only regression from the Home plugin-list cache: the module-level
visible-plugins cache leaked a case's mocked /api/plugins payload into later
cases via listPluginsFresh, greying the rail / stranding scenarios on
'Template None'. Add resetPluginsCache() and clear it in a global afterEach.
* Track web-clone example-card picks (example_prompt / example_open_project)
The Website-clone example cards (Clone Nexu / Vercel / …) had no analytics on
pick or remix, so the site-clone funnel could not be broken down per example.
Wire the two clicks the contracts already type (埋点文档 row 116):
- useExamplePlugin → ui_click area=chat_composer element=example_prompt
- duplicateExamplePlugin (Remix) → element=example_open_project
Both carry chip_id (web-clone) + plugin_id (example-clone-<site>) + plugin_type
for per-site breakdown. The created project's own project_kind=web_clone (already
derived from metadata.intent via projectKindToTracking) still rides
project_create_result / run_created, so 'how many projects are website clones' is
answerable without extra work. No contracts change — the element enum and the
web_clone project_kind already existed. Adds a behavioral test asserting both
events fire with the right triplet + attribution.
* Mirror web-clone copyright-guardrail swap into contracts/BYOK prompt
PR #5178 blocking review (nettee/looper): the daemon prompt special-cases
intent='web-clone' (faithful-reproduction rule) but the contracts prompt stack —
which apps/web uses for API/BYOK runs — imported OFFICIAL_DESIGNER_PROMPT directly
and never routed web-clone, so BYOK website-clone runs kept the original 'build
something original' guardrail. Daemon and BYOK now behave identically:
- contracts/prompts/official-system.ts: add COPYRIGHT_GUARDRAIL_BULLET,
WEB_CLONE_COPYRIGHT_GUARDRAIL_BULLET, renderOfficialDesignerPrompt (mirrors the
daemon renderer; contracts base has no execution placeholders so it only swaps
the bullet).
- contracts/prompts/system.ts: composeSystemPrompt routes the charter through
renderOfficialDesignerPrompt({ webCloneFidelity: metadata.intent==='web-clone' }).
- Add system-prompt-web-clone.test.ts covering both modes + the byte-identical
bullet guard.
* Complete web-clone BYOK prompt parity + rebase relative JS asset refs
Two remaining blocking issues from PR #5178 looper re-review:
1. contracts composeSystemPrompt still appended the active-design-system
'authoritative' block + visual-direction override unconditionally, while the
daemon suppresses both for intent==='web-clone'. A BYOK web-clone run with an
active design system was still told the design system wins, fighting faithful
reproduction. Gate both sections behind !isWebCloneRun; add contracts tests
asserting web-clone BYOK prompts drop the design-system guidance.
2. rewriteInlinedScriptAssetRefs only handled root-relative literals. When
js/app.js is inlined into the HTML entry, a sibling lookup like
fetch('./data.json') / new Worker('./worker.js') / import('./chunk.js')
resolved against the HTML root instead of js/. Take the source script path and
rebase confirmed relative refs against it (mirrors the CSS pass), membership-
confirmed; add unit cases for ./sibling, ../parent, and escaping/missing.
* Website-clone examples: plain text prompt cards, drop embedded HTML + Remix
The 7 site example cards embedded a full self-contained clone of each target site
(anthropic.com, vercel.com, …) as example.html and offered a one-click Remix that
copied that markup into a project. Shipping verbatim copies of other companies'
sites is an IP risk, so replace them with plain text prompt cards:
- Delete plugins/_official/examples/clone-* (7 dirs, incl. the example.html
reproductions and the Remix-enabling preview.entry).
- Add a 'web-clone' entry to HOME_PROMPT_EXAMPLES (en + zh-CN, others fall back to
en): 7 text cards that fill the composer with 'Website URL to clone: <url>'.
With the site plugins gone, the web-clone chip's preset rail is empty so these
static text cards render instead. Clicking one fills the URL prompt and (via
the existing usePromptExample) still fires ui_click element=example_prompt
chip_id=web-clone — no plugin_id / no Remix / no example_open_project.
- Remove the now-dead webCloneCardTargetUrl seed override; refresh stale comments.
- Rewrite HomeView.web-clone-tracking test for the text-card path (asserts text
cards render, no plugin-preset / no remix affordance, and the pick still tracks).
The base example-web-clone skill + web-clone run behavior (intent, fidelity
prompt, discovery skip) are unchanged.
* Give web-clone example cards a site logo + domain, not raw prompt text
The 7 Website-clone examples rendered as near-identical white boxes each showing
'Website URL to clone: https://<site>' — visually flat and hard to scan. Render
them as a logo gallery instead: each card shows the site's own favicon (resolved
at render time via a public favicon service — no third-party brand assets are
bundled) in a rounded tile above the bare domain (nexu.io, vercel.com, …). A
blocked/failed icon fetch falls back to a lettered monogram tile. Click behavior
is unchanged: the full 'Website URL to clone: <url>' prompt is still what fills
the composer and fires ui_click element=example_prompt. Non-URL prompt examples
(other chips) keep the plain text card.
* Web-clone examples: keep only open-design, add kimi
Legal review flagged brand-copy risk in the example rail. Drop the
nexu / anthropic / vercel / resend / linear / raycast site prompts and
keep just open-design.ai plus kimi.com (official Kimi partnership).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---------
Co-authored-by: free666799 <293857035+free666799@users.noreply.github.com>
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1347 lines
48 KiB
TypeScript
1347 lines
48 KiB
TypeScript
import { readFile, readdir } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { pathToFileURL } from "node:url";
|
|
import ts from "typescript";
|
|
|
|
import { checkCrossAppImports } from "./check-cross-app-imports.ts";
|
|
import { checkDesignSystemManifests } from "./check-design-system-manifests.ts";
|
|
import { checkDesignSystemPackageQuality } from "./check-design-system-package-quality.ts";
|
|
import { checkDesignSystemComponentFixtureReport } from "./check-components-fixtures.ts";
|
|
import { checkDesignSystemFlagParity } from "./check-design-system-flag-parity.ts";
|
|
import { checkComponentsManifestExtraction } from "./check-components-manifest-extraction.ts";
|
|
import { checkPluginPreviewManifest } from "./check-plugin-preview-manifest.ts";
|
|
import { validatePlaywrightSuiteTopology } from "../e2e/lib/playwright/suites.ts";
|
|
import {
|
|
checkDesignSystemA1RequiredTokens,
|
|
checkDesignSystemA2DefaultsParity,
|
|
checkDesignSystemA2RequiredTokens,
|
|
checkDesignSystemBSlotRequiredTokens,
|
|
checkDesignSystemTokenFixtureSync,
|
|
checkDesignSystemUnknownTokens,
|
|
} from "./check-tokens-fixture-sync.ts";
|
|
import { checkCraftReferences } from "./lint-craft-references.ts";
|
|
import { collectCssHardcodedColorMatches, cssWideAndSpecialColorKeywords, realNamedColors } from "./style-policy.ts";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
const allowedE2eScripts = new Set([
|
|
"e2e/scripts/playwright.ts",
|
|
"e2e/scripts/release-smoke.ts",
|
|
"e2e/scripts/visual-report.ts",
|
|
]);
|
|
|
|
type GuardCheck = {
|
|
name: string;
|
|
run: () => Promise<boolean>;
|
|
};
|
|
|
|
function toRepositoryPath(filePath: string): string {
|
|
return path.relative(repoRoot, filePath).split(path.sep).join("/");
|
|
}
|
|
|
|
const residualExtensions = new Set([".js", ".mjs", ".cjs"]);
|
|
|
|
const residualSkippedDirectories = new Set([
|
|
".agents",
|
|
".astro",
|
|
".claude",
|
|
".claude-sessions",
|
|
".codex",
|
|
".cursor",
|
|
".git",
|
|
".od",
|
|
".od-e2e",
|
|
".opencode",
|
|
".task",
|
|
".tmp",
|
|
".vite",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
]);
|
|
|
|
const residualAllowedExactPaths = new Set([
|
|
// esbuild config entrypoints are executed directly by Node before package
|
|
// dist output exists.
|
|
"packages/agui-adapter/esbuild.config.mjs",
|
|
"packages/contracts/esbuild.config.mjs",
|
|
"packages/diagnostics/esbuild.config.mjs",
|
|
"packages/download/esbuild.config.mjs",
|
|
"packages/host/esbuild.config.mjs",
|
|
"packages/launcher-proto/esbuild.config.mjs",
|
|
"packages/metatool/esbuild.config.mjs",
|
|
"packages/platform/esbuild.config.mjs",
|
|
"packages/plugin-runtime/esbuild.config.mjs",
|
|
"packages/registry-protocol/esbuild.config.mjs",
|
|
"packages/sidecar/esbuild.config.mjs",
|
|
"packages/sidecar-proto/esbuild.config.mjs",
|
|
// Maintainer utility scripts ported from the media branch. They are
|
|
// executed directly by Node and are not loaded by the app runtime.
|
|
"scripts/import-prompt-templates.mjs",
|
|
"scripts/postinstall.mjs",
|
|
// Checked-in bin shim so pnpm can link `od` before daemon dist output exists.
|
|
"apps/daemon/bin/od.mjs",
|
|
"apps/packaged/esbuild.config.mjs",
|
|
// Browser service workers must be served as JavaScript files.
|
|
"apps/web/public/od-notifications-sw.js",
|
|
// Vendored dom-to-pptx browser bundle used by the packaged desktop renderer
|
|
// for editable PPTX export. It is loaded into the off-screen Chromium page as
|
|
// an upstream browser asset, not compiled as project-owned TypeScript.
|
|
"apps/desktop/vendor/dom-to-pptx/dom-to-pptx.bundle.js",
|
|
// Shared nav enhancer for the landing-page static `/community/` pages,
|
|
// which are verbatim HTML served straight from `public/` (not Astro-
|
|
// compiled). It must ship as a browser-loadable `.js` asset, same as the
|
|
// web notifications service worker above.
|
|
"apps/landing-page/public/community/_site-nav.js",
|
|
// PostCSS loads Tailwind through a web-local .mjs compatibility config entry.
|
|
"apps/web/postcss.config.mjs",
|
|
"scripts/bake-html-ppt-examples.mjs",
|
|
// CI-only plugin-preview renderer. Kept .mjs and run directly by Node so its
|
|
// runtime deps (puppeteer-core + a headless Chrome + ffmpeg) are provided by
|
|
// the CI environment and never pulled into the daemon/web TS build or bundle.
|
|
"scripts/bake-plugin-previews.mjs",
|
|
// Manifest diff guard + its node:test coverage. Run directly by Node from the
|
|
// bake workflows (no TS build step there) to decide whether a `previews` entry
|
|
// actually changed, ignoring the per-run `generatedAt` timestamp.
|
|
"scripts/plugin-previews-diff.mjs",
|
|
"scripts/plugin-previews-diff.test.mjs",
|
|
// CI-only R2 garbage collector for orphaned preview clips + its node:test.
|
|
"scripts/plugin-previews-gc.mjs",
|
|
"scripts/plugin-previews-gc.test.mjs",
|
|
"scripts/scaffold-html-ppt-skills.mjs",
|
|
"scripts/sync-hyperframes-skill.mjs",
|
|
"scripts/verify-media-models.mjs",
|
|
// AMR (vela) verifier: ad-hoc dev runner that imports the daemon's compiled
|
|
// `dist/acp.js` and drives a real `vela agent run` against a live model.
|
|
// Kept as .mjs so it can be invoked directly via Node without any transform.
|
|
"apps/daemon/scripts/verify-amr-real-vela.mjs",
|
|
// Fake `vela agent run --runtime opencode` ACP stdio stub used by the AMR
|
|
// integration tests. The Vitest test spawns it via `child_process.spawn`,
|
|
// which needs a directly-executable file (shebang + .mjs).
|
|
"apps/daemon/tests/fixtures/fake-vela.mjs",
|
|
"tools/dev/bin/tools-dev.mjs",
|
|
"tools/dev/esbuild.config.mjs",
|
|
"tools/pack/bin/tools-pack.mjs",
|
|
"tools/pack/esbuild.config.mjs",
|
|
// Checked-in bin shim so pnpm can link `tools-release` before dist output exists.
|
|
"tools/release/bin/tools-release.mjs",
|
|
"tools/release/esbuild.config.mjs",
|
|
"tools/serve/bin/tools-serve.mjs",
|
|
"tools/serve/esbuild.config.mjs",
|
|
"tools/pack/resources/mac/notarize.cjs",
|
|
// electron-builder hook path; CJS compatibility entry used by tools-pack desktop builds.
|
|
"tools/pack/resources/web-standalone-after-pack.cjs",
|
|
]);
|
|
|
|
const residualAllowedPathPrefixes = [
|
|
"apps/daemon/dist/",
|
|
"apps/web/.next/",
|
|
"apps/web/out/",
|
|
"generated/",
|
|
"e2e/playwright-report/",
|
|
"e2e/reports/html/",
|
|
"e2e/reports/playwright-html-report/",
|
|
"e2e/reports/test-results/",
|
|
"e2e/ui/.od-data/",
|
|
"e2e/ui/reports/playwright-html-report/",
|
|
"e2e/ui/reports/test-results/",
|
|
"e2e/ui/test-results/",
|
|
// Vendored upstream HyperFrames helper scripts (design template).
|
|
"design-templates/hyperframes/scripts/",
|
|
// Vendored upstream Web Clone skill helper scripts. These are portable
|
|
// Node-run skill utilities executed from user workspaces via explicit script
|
|
// paths, and stay as `.mjs` to preserve the upstream skill packaging.
|
|
"skills/web-clone/scripts/",
|
|
// Vendored upstream Last30Days runtime helper used by the engine (design template).
|
|
"design-templates/last30days/scripts/lib/vendor/",
|
|
// Vendored upstream html-ppt runtime assets (lewislulu/html-ppt-skill, design template).
|
|
"design-templates/html-ppt/assets/",
|
|
// Vendored upstream website-clone recon/mirror/audit helpers
|
|
// (Jane-xiaoer/claude-skill-web-clone). Global skill assets staged into the
|
|
// project cwd for direct `node scripts/...` execution by the agent.
|
|
"skills/web-clone/scripts/",
|
|
// Replay-based mock CLIs that impersonate the agent CLIs OD spawns
|
|
// (opencode/claude/codex/gemini/cursor-agent + ACP family). Need to
|
|
// be directly executable via Node so `child_process.spawn` from test
|
|
// harnesses and PATH-overlay shells work without any transform step.
|
|
// `mocks/scripts/` holds the maintainer-facing helpers (manifest math,
|
|
// fetch from R2) which are also pure-node single-file modules — same
|
|
// precedent as `apps/daemon/tests/fixtures/fake-vela.mjs` (an ACP
|
|
// stdio stub, allowlisted individually above). See `mocks/README.md`.
|
|
"mocks/lib/",
|
|
"mocks/mock-agent.mjs",
|
|
"mocks/scripts/",
|
|
// OD Clipper - a standalone Chrome MV3 extension subproject (not a pnpm
|
|
// workspace package, no build step). It ships hand-written browser-loadable
|
|
// JavaScript (service worker, content script, popup) the same way as the
|
|
// web notifications service worker; it must not be retypecast to TypeScript.
|
|
"clipper/",
|
|
// OD Figma Import - a standalone Figma plugin subproject (no build step,
|
|
// not a pnpm workspace package). Figma plugins load hand-written
|
|
// browser-loadable JavaScript (`code.js` sandbox + `ui.html`); same
|
|
// precedent as the clipper, and it must not be retypecast to TypeScript.
|
|
"figma-plugin/",
|
|
"test-results/",
|
|
"vendor/",
|
|
];
|
|
|
|
const residualAllowedPathPatterns: RegExp[] = [
|
|
// Vendored upstream Zara template runtimes — one design template per template,
|
|
// name prefix `html-ppt-zhangzara-` (zarazhangrui/beautiful-html-templates).
|
|
// Only the vendored deck-stage runtime asset is allowlisted; any other
|
|
// JavaScript under these design-template directories must still be converted
|
|
// to TypeScript or explicitly listed in `residualAllowedExactPaths`.
|
|
/^design-templates\/html-ppt-zhangzara-[^/]+\/assets\/deck-stage\.js$/,
|
|
// Bundled example/skill plugins copy the upstream skill's `assets/`
|
|
// and `references/` directories verbatim so the daemon's preview
|
|
// surface can render the baked HTML without staging detours. Those
|
|
// assets are vendored runtime, never project-owned code, and must
|
|
// not be retypecasted to TypeScript.
|
|
/^plugins\/_official\/examples\/[^/]+\/(assets|references)\/.+$/,
|
|
];
|
|
|
|
function isResidualAllowedPath(repositoryPath: string): boolean {
|
|
if (residualAllowedExactPaths.has(repositoryPath)) return true;
|
|
if (residualAllowedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix))) return true;
|
|
return residualAllowedPathPatterns.some((pattern) => pattern.test(repositoryPath));
|
|
}
|
|
|
|
function isResidualSkippedDirectoryName(directoryName: string): boolean {
|
|
return (
|
|
residualSkippedDirectories.has(directoryName) || directoryName === ".next" || directoryName.startsWith(".next-")
|
|
);
|
|
}
|
|
|
|
async function collectResidualJavaScript(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const residualFiles: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
const repositoryPath = toRepositoryPath(fullPath);
|
|
|
|
if (entry.isDirectory()) {
|
|
if (isResidualSkippedDirectoryName(entry.name) || isResidualAllowedPath(`${repositoryPath}/`)) {
|
|
continue;
|
|
}
|
|
|
|
residualFiles.push(...(await collectResidualJavaScript(fullPath)));
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile() || !residualExtensions.has(path.extname(entry.name))) {
|
|
continue;
|
|
}
|
|
|
|
if (isResidualAllowedPath(repositoryPath)) {
|
|
continue;
|
|
}
|
|
|
|
residualFiles.push(repositoryPath);
|
|
}
|
|
|
|
return residualFiles;
|
|
}
|
|
|
|
async function checkResidualJavaScript(): Promise<boolean> {
|
|
const residualFiles = await collectResidualJavaScript(repoRoot);
|
|
|
|
if (residualFiles.length > 0) {
|
|
console.error("Residual project-owned JavaScript files found:");
|
|
for (const filePath of residualFiles) {
|
|
console.error(`- ${filePath}`);
|
|
}
|
|
console.error("Convert these files to TypeScript or add a documented generated/vendor/output allowlist entry.");
|
|
return false;
|
|
}
|
|
|
|
console.log("Residual JavaScript check passed: project-owned code is TypeScript-only.");
|
|
return true;
|
|
}
|
|
|
|
const sourcePackageManifestRootPaths = ["package.json", "e2e/package.json"];
|
|
const sourcePackageManifestScopedDirectories = ["apps", "packages", "tools"];
|
|
const packageDependencySections = [
|
|
"dependencies",
|
|
"devDependencies",
|
|
"peerDependencies",
|
|
"optionalDependencies",
|
|
];
|
|
const packageManagerOverridePaths = ["pnpm.overrides", "overrides", "resolutions"];
|
|
const exactVersionPattern = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
const exactNpmAliasPattern = /^npm:(?:@[^/]+\/)?[^@]+@\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?(?:\+[0-9A-Za-z.-]+)?$/;
|
|
|
|
type DependencySpecViolation = {
|
|
filePath: string;
|
|
fieldPath: string;
|
|
name: string;
|
|
spec: unknown;
|
|
reason: string;
|
|
};
|
|
|
|
type DependencySpecStats = {
|
|
exact: number;
|
|
manifests: number;
|
|
total: number;
|
|
workspace: number;
|
|
};
|
|
|
|
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
}
|
|
|
|
function isAllowedDependencySpec(spec: string): boolean {
|
|
return spec === "workspace:*" || exactVersionPattern.test(spec) || exactNpmAliasPattern.test(spec);
|
|
}
|
|
|
|
function dependencySpecReason(spec: string): string {
|
|
if (spec.startsWith("workspace:") && spec !== "workspace:*") {
|
|
return "workspace dependencies must use exactly workspace:*";
|
|
}
|
|
|
|
return "dependency specs must be exact versions like 1.2.3 or workspace:*";
|
|
}
|
|
|
|
function dependencySpecFieldValue(value: unknown): string {
|
|
return typeof value === "string" ? value : JSON.stringify(value);
|
|
}
|
|
|
|
async function collectScopedPackageManifestPaths(scopeDirectory: string): Promise<string[]> {
|
|
const scopeRoot = path.join(repoRoot, scopeDirectory);
|
|
const entries = await readdir(scopeRoot, { withFileTypes: true });
|
|
const manifestPaths: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
if (!entry.isDirectory()) continue;
|
|
|
|
const packageDirectory = path.join(scopeRoot, entry.name);
|
|
const packageEntries = await readdir(packageDirectory, { withFileTypes: true });
|
|
if (packageEntries.some((packageEntry) => packageEntry.isFile() && packageEntry.name === "package.json")) {
|
|
manifestPaths.push(`${scopeDirectory}/${entry.name}/package.json`);
|
|
}
|
|
}
|
|
|
|
return manifestPaths;
|
|
}
|
|
|
|
async function collectSourcePackageManifestPaths(): Promise<string[]> {
|
|
const scopedManifestPaths = (
|
|
await Promise.all(sourcePackageManifestScopedDirectories.map((scope) => collectScopedPackageManifestPaths(scope)))
|
|
).flat();
|
|
|
|
return [...sourcePackageManifestRootPaths, ...scopedManifestPaths].sort();
|
|
}
|
|
|
|
function getPackageJsonField(packageJson: Record<string, unknown>, fieldPath: string): unknown {
|
|
let current: unknown = packageJson;
|
|
for (const part of fieldPath.split(".")) {
|
|
if (!isRecord(current)) return undefined;
|
|
current = current[part];
|
|
}
|
|
return current;
|
|
}
|
|
|
|
function checkDependencySpecRecord(
|
|
record: Record<string, unknown>,
|
|
filePath: string,
|
|
fieldPath: string,
|
|
violations: DependencySpecViolation[],
|
|
stats: DependencySpecStats,
|
|
): void {
|
|
for (const [name, spec] of Object.entries(record).sort(([left], [right]) => left.localeCompare(right))) {
|
|
if (isRecord(spec)) {
|
|
checkDependencySpecRecord(spec, filePath, `${fieldPath}.${name}`, violations, stats);
|
|
continue;
|
|
}
|
|
|
|
stats.total += 1;
|
|
if (typeof spec !== "string") {
|
|
violations.push({
|
|
filePath,
|
|
fieldPath,
|
|
name,
|
|
spec,
|
|
reason: "dependency specs must be strings",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
if (spec === "workspace:*") {
|
|
stats.workspace += 1;
|
|
continue;
|
|
}
|
|
|
|
if (isAllowedDependencySpec(spec)) {
|
|
stats.exact += 1;
|
|
continue;
|
|
}
|
|
|
|
violations.push({
|
|
filePath,
|
|
fieldPath,
|
|
name,
|
|
spec,
|
|
reason: dependencySpecReason(spec),
|
|
});
|
|
}
|
|
}
|
|
|
|
async function checkPackageDependencySpecs(): Promise<boolean> {
|
|
const manifestPaths = await collectSourcePackageManifestPaths();
|
|
const violations: DependencySpecViolation[] = [];
|
|
const stats: DependencySpecStats = {
|
|
exact: 0,
|
|
manifests: manifestPaths.length,
|
|
total: 0,
|
|
workspace: 0,
|
|
};
|
|
|
|
for (const manifestPath of manifestPaths) {
|
|
const packageJson = JSON.parse(await readFile(path.join(repoRoot, manifestPath), "utf8")) as Record<string, unknown>;
|
|
|
|
for (const section of packageDependencySections) {
|
|
const value = packageJson[section];
|
|
if (value === undefined) continue;
|
|
if (!isRecord(value)) {
|
|
violations.push({
|
|
filePath: manifestPath,
|
|
fieldPath: section,
|
|
name: section,
|
|
spec: value,
|
|
reason: "dependency sections must be objects",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
checkDependencySpecRecord(value, manifestPath, section, violations, stats);
|
|
}
|
|
|
|
for (const overridePath of packageManagerOverridePaths) {
|
|
const value = getPackageJsonField(packageJson, overridePath);
|
|
if (value === undefined) continue;
|
|
if (!isRecord(value)) {
|
|
violations.push({
|
|
filePath: manifestPath,
|
|
fieldPath: overridePath,
|
|
name: overridePath,
|
|
spec: value,
|
|
reason: "package-manager override sections must be objects",
|
|
});
|
|
continue;
|
|
}
|
|
|
|
checkDependencySpecRecord(value, manifestPath, overridePath, violations, stats);
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Package dependency spec violations found:");
|
|
for (const violation of violations) {
|
|
console.error(
|
|
`- ${violation.filePath} ${violation.fieldPath}.${violation.name}=${dependencySpecFieldValue(violation.spec)} -> ${violation.reason}`,
|
|
);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log(
|
|
`Package dependency spec check passed: ${stats.manifests} package.json files, ${stats.exact} exact specs, ${stats.workspace} workspace:* specs.`,
|
|
);
|
|
return true;
|
|
}
|
|
|
|
const testLayoutScopedDirectories = ["apps", "packages", "tools"];
|
|
const testLayoutSkippedDirectories = new Set([".next", ".od-data", "dist", "node_modules", "out", "reports", "test-results"]);
|
|
|
|
function isTestFile(fileName: string): boolean {
|
|
return /\.test\.tsx?$/.test(fileName);
|
|
}
|
|
|
|
function expectedTestPath(repositoryPath: string): string {
|
|
const [scope, project, ...relativeParts] = repositoryPath.split("/");
|
|
if (!testLayoutScopedDirectories.includes(scope ?? "") || project == null || relativeParts.length === 0) {
|
|
return repositoryPath;
|
|
}
|
|
|
|
const normalizedRelativeParts = relativeParts[0] === "src" ? relativeParts.slice(1) : relativeParts;
|
|
return [scope, project, "tests", ...normalizedRelativeParts].join("/");
|
|
}
|
|
|
|
function isAllowedScopedTestPath(repositoryPath: string): boolean {
|
|
const [scope, project, directory] = repositoryPath.split("/");
|
|
return testLayoutScopedDirectories.includes(scope ?? "") && project != null && directory === "tests";
|
|
}
|
|
|
|
async function collectTestLayoutViolations(directory: string): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const violations: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
|
|
if (entry.isDirectory()) {
|
|
if (testLayoutSkippedDirectories.has(entry.name)) {
|
|
continue;
|
|
}
|
|
|
|
violations.push(...(await collectTestLayoutViolations(fullPath)));
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile() || !isTestFile(entry.name)) {
|
|
continue;
|
|
}
|
|
|
|
const repositoryPath = toRepositoryPath(fullPath);
|
|
if (!isAllowedScopedTestPath(repositoryPath)) {
|
|
violations.push(repositoryPath);
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function checkTestLayout(): Promise<boolean> {
|
|
const violations = (
|
|
await Promise.all(
|
|
testLayoutScopedDirectories.map((directory) => collectTestLayoutViolations(path.join(repoRoot, directory))),
|
|
)
|
|
).flat();
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Test files under apps/, packages/, and tools/ must live in tests/ sibling to src/:");
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation} -> ${expectedTestPath(violation)}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log("Test layout check passed: apps/packages/tools tests live in sibling tests directories.");
|
|
return true;
|
|
}
|
|
|
|
const e2ePackageJsonPath = path.join(repoRoot, "e2e", "package.json");
|
|
const e2eSkippedDirectories = new Set([".od-data", "node_modules", "reports", "test-results"]);
|
|
const e2eAllowedScripts = [
|
|
"test",
|
|
"test:p0",
|
|
"test:p0p1",
|
|
"test:p1",
|
|
"test:p2",
|
|
"test:ui",
|
|
"test:ui:critical",
|
|
"test:ui:extended",
|
|
"test:ui:p0",
|
|
"test:ui:p0p1",
|
|
"test:ui:p1",
|
|
"test:ui:p2",
|
|
"typecheck",
|
|
];
|
|
|
|
async function collectRepositoryFiles(directory: string, skippedDirectoryNames = new Set<string>()): Promise<string[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const files: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (skippedDirectoryNames.has(entry.name)) continue;
|
|
files.push(...(await collectRepositoryFiles(fullPath, skippedDirectoryNames)));
|
|
continue;
|
|
}
|
|
if (entry.isFile()) files.push(toRepositoryPath(fullPath));
|
|
}
|
|
|
|
return files;
|
|
}
|
|
|
|
const productNeutralitySkippedDirectories = new Set([
|
|
".git",
|
|
".od",
|
|
".tmp",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"test-results",
|
|
]);
|
|
// Public contracts, help/prompt strings, docs, and shipped content should
|
|
// describe the integration role, not name a private deployment. The default
|
|
// check blocks named "orchestrator such as ..." examples; private forks can
|
|
// add stricter local terms through OD_PRODUCT_NEUTRALITY_FORBIDDEN_TERMS.
|
|
const productNeutralityCheckedPathPrefixes = [
|
|
"apps/daemon/src/",
|
|
"apps/web/app/",
|
|
"apps/web/src/",
|
|
"craft/",
|
|
"design-systems/",
|
|
"design-templates/",
|
|
"docs/",
|
|
"packages/contracts/src/",
|
|
"skills/",
|
|
];
|
|
const productNeutralityTextExtensions = new Set([".md", ".mdx", ".ts", ".tsx"]);
|
|
const productNeutralityDocFilePattern =
|
|
/(?:^|\/)(?:AGENTS|CLAUDE|CONTRIBUTING(?:\.[^.]+)?|QUICKSTART|README(?:\.[^.]+)?)\.md$/;
|
|
const namedOrchestratorExamplePattern =
|
|
/\borchestrator\s+(?:such as|like|for example,?)\s+[`"']?[A-Z][A-Za-z0-9_-]+/gi;
|
|
|
|
type ProductNeutralityViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
reason: string;
|
|
};
|
|
|
|
export function isProductNeutralityCheckedPath(repositoryPath: string): boolean {
|
|
return (
|
|
productNeutralityCheckedPathPrefixes.some((prefix) => repositoryPath.startsWith(prefix)) ||
|
|
productNeutralityDocFilePattern.test(repositoryPath)
|
|
);
|
|
}
|
|
|
|
function isProductNeutralityTextFile(repositoryPath: string): boolean {
|
|
return productNeutralityTextExtensions.has(path.extname(repositoryPath));
|
|
}
|
|
|
|
function productNeutralityForbiddenTerms(): string[] {
|
|
return String(process.env.OD_PRODUCT_NEUTRALITY_FORBIDDEN_TERMS ?? "")
|
|
.split(",")
|
|
.map((term) => term.trim())
|
|
.filter((term) => term.length > 0);
|
|
}
|
|
|
|
export function collectProductNeutralityViolationsFromSource(
|
|
repositoryPath: string,
|
|
source: string,
|
|
forbiddenTerms = productNeutralityForbiddenTerms(),
|
|
): ProductNeutralityViolation[] {
|
|
if (!isProductNeutralityCheckedPath(repositoryPath) || !isProductNeutralityTextFile(repositoryPath)) {
|
|
return [];
|
|
}
|
|
|
|
const lowerSource = source.toLowerCase();
|
|
const violations: ProductNeutralityViolation[] = [];
|
|
|
|
for (const match of source.matchAll(namedOrchestratorExamplePattern)) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, match.index ?? 0),
|
|
reason: "use generic \"external orchestrator\" phrasing instead of named orchestrator examples",
|
|
});
|
|
}
|
|
|
|
for (const term of forbiddenTerms) {
|
|
const lowerTerm = term.toLowerCase();
|
|
let index = lowerSource.indexOf(lowerTerm);
|
|
|
|
while (index !== -1) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, index),
|
|
reason: "use generic \"external orchestrator\" phrasing instead of private deployment names",
|
|
});
|
|
index = lowerSource.indexOf(lowerTerm, index + lowerTerm.length);
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function checkProductNeutrality(): Promise<boolean> {
|
|
const violations: ProductNeutralityViolation[] = [];
|
|
|
|
for (const repositoryPath of await collectRepositoryFiles(repoRoot, productNeutralitySkippedDirectories)) {
|
|
if (!isProductNeutralityCheckedPath(repositoryPath) || !isProductNeutralityTextFile(repositoryPath)) {
|
|
continue;
|
|
}
|
|
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
|
violations.push(...collectProductNeutralityViolationsFromSource(repositoryPath, source));
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Product-neutrality violations found:");
|
|
for (const violation of violations) {
|
|
console.error(`${violation.filePath}:${violation.lineNumber} -> ${violation.reason}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log("Product-neutrality check passed: public docs, contracts, and prompts use generic orchestrator naming.");
|
|
return true;
|
|
}
|
|
|
|
async function checkE2eLayout(): Promise<boolean> {
|
|
const violations: string[] = [];
|
|
const packageJson = JSON.parse(await readFile(e2ePackageJsonPath, "utf8")) as {
|
|
scripts?: Record<string, unknown>;
|
|
};
|
|
const scriptNames = Object.keys(packageJson.scripts ?? {}).sort();
|
|
if (scriptNames.join("\0") !== e2eAllowedScripts.join("\0")) {
|
|
violations.push(
|
|
`e2e/package.json scripts must be exactly ${e2eAllowedScripts.join(", ")} (found: ${scriptNames.join(", ")})`,
|
|
);
|
|
}
|
|
|
|
const e2eRoot = path.join(repoRoot, "e2e");
|
|
for (const repositoryPath of await collectRepositoryFiles(e2eRoot, e2eSkippedDirectories)) {
|
|
if (
|
|
repositoryPath === "e2e/package.json" ||
|
|
repositoryPath === "e2e/tsconfig.json" ||
|
|
repositoryPath === "e2e/vitest.config.ts" ||
|
|
repositoryPath === "e2e/playwright.config.ts" ||
|
|
repositoryPath === "e2e/playwright.visual.config.ts" ||
|
|
repositoryPath === "e2e/AGENTS.md"
|
|
) {
|
|
continue;
|
|
}
|
|
|
|
if (repositoryPath.startsWith("e2e/specs/")) {
|
|
if (!/\.spec\.ts$/.test(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> e2e specs must be *.spec.ts`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (repositoryPath.startsWith("e2e/tests/")) {
|
|
if (!/\.test\.ts$/.test(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> e2e tests must be *.test.ts`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (repositoryPath.startsWith("e2e/ui/")) {
|
|
const relativePath = repositoryPath.slice("e2e/ui/".length);
|
|
if (relativePath.includes("/") || !/\.test\.ts$/.test(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> e2e UI files must be flat Playwright *.test.ts files under ui/`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (repositoryPath.startsWith("e2e/resources/")) {
|
|
const relativePath = repositoryPath.slice("e2e/resources/".length);
|
|
if (relativePath.includes("/") || !/\.ts$/.test(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> e2e resources must be flat TypeScript files under resources/`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (repositoryPath.startsWith("e2e/lib/")) {
|
|
if (!/\.ts$/.test(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> e2e lib files must be TypeScript`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (repositoryPath.startsWith("e2e/scripts/")) {
|
|
if (!allowedE2eScripts.has(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> e2e scripts must be an approved package-owned entrypoint`);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
violations.push(`${repositoryPath} -> e2e source files must live in specs/, tests/, ui/, resources/, lib/, or approved scripts`);
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("E2E package layout violations found:");
|
|
for (const violation of violations) console.error(`- ${violation}`);
|
|
return false;
|
|
}
|
|
|
|
console.log("E2E layout check passed: Vitest, Playwright UI, resources, lib, and scripts stay in their lanes.");
|
|
return true;
|
|
}
|
|
|
|
const webTestSkippedDirectories = new Set([".od-data", "reports", "test-results"]);
|
|
|
|
async function checkWebTestLayout(): Promise<boolean> {
|
|
const violations: string[] = [];
|
|
const webTestsRoot = path.join(repoRoot, "apps", "web", "tests");
|
|
|
|
for (const repositoryPath of await collectRepositoryFiles(webTestsRoot, webTestSkippedDirectories)) {
|
|
if (repositoryPath.startsWith("apps/web/tests/vitest/") || repositoryPath.startsWith("apps/web/tests/playwright/")) {
|
|
violations.push(`${repositoryPath} -> web tests should stay lightweight under apps/web/tests/ without vitest/playwright nesting`);
|
|
continue;
|
|
}
|
|
|
|
if (/\.(spec|test)\.tsx?$/.test(repositoryPath) && !/\.test\.tsx?$/.test(repositoryPath)) {
|
|
violations.push(`${repositoryPath} -> web Vitest test files must be *.test.ts or *.test.tsx`);
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Web test layout violations found:");
|
|
for (const violation of violations) console.error(`- ${violation}`);
|
|
return false;
|
|
}
|
|
|
|
console.log("Web test layout check passed: web tests stay lightweight and Vitest-only.");
|
|
return true;
|
|
}
|
|
|
|
const webImportIsolationSourcePrefixes = ["apps/web/app/", "apps/web/src/"];
|
|
const webImportIsolationExtensions = new Set([".ts", ".tsx"]);
|
|
const webImportIsolationSkippedDirectories = new Set([
|
|
".next",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"reports",
|
|
"test-results",
|
|
]);
|
|
const webImportIsolationForbiddenPackages = [
|
|
"@open-design/platform",
|
|
"@open-design/sidecar",
|
|
"@open-design/sidecar-proto",
|
|
];
|
|
const webImportIsolationForbiddenDaemonRoots = [
|
|
"apps/daemon/src",
|
|
"apps/daemon/tests",
|
|
];
|
|
const webImportIsolationForbiddenPackageRoots = [
|
|
"packages/platform",
|
|
"packages/sidecar",
|
|
"packages/sidecar-proto",
|
|
];
|
|
|
|
type WebImportIsolationViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
specifier: string;
|
|
reason: string;
|
|
};
|
|
|
|
type SourceImportSpecifier = {
|
|
lineNumber: number;
|
|
specifier: string;
|
|
};
|
|
|
|
export function isWebImportIsolationSourcePath(repositoryPath: string): boolean {
|
|
return (
|
|
webImportIsolationSourcePrefixes.some((prefix) => repositoryPath.startsWith(prefix)) &&
|
|
webImportIsolationExtensions.has(path.extname(repositoryPath))
|
|
);
|
|
}
|
|
|
|
function pushStringSpecifier(
|
|
imports: SourceImportSpecifier[],
|
|
sourceFile: ts.SourceFile,
|
|
node: ts.Node | undefined,
|
|
): void {
|
|
if (!node) return;
|
|
if (!ts.isStringLiteral(node) && !ts.isNoSubstitutionTemplateLiteral(node)) return;
|
|
|
|
imports.push({
|
|
lineNumber: sourceFile.getLineAndCharacterOfPosition(node.getStart(sourceFile)).line + 1,
|
|
specifier: node.text,
|
|
});
|
|
}
|
|
|
|
function collectImportSpecifiersFromSource(repositoryPath: string, source: string): SourceImportSpecifier[] {
|
|
const sourceFile = ts.createSourceFile(
|
|
repositoryPath,
|
|
source,
|
|
ts.ScriptTarget.Latest,
|
|
true,
|
|
repositoryPath.endsWith(".tsx") ? ts.ScriptKind.TSX : ts.ScriptKind.TS,
|
|
);
|
|
const imports: SourceImportSpecifier[] = [];
|
|
|
|
const visit = (node: ts.Node): void => {
|
|
if (ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) {
|
|
pushStringSpecifier(imports, sourceFile, node.moduleSpecifier);
|
|
} else if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument)) {
|
|
pushStringSpecifier(imports, sourceFile, node.argument.literal);
|
|
} else if (
|
|
ts.isCallExpression(node) &&
|
|
(node.expression.kind === ts.SyntaxKind.ImportKeyword ||
|
|
(ts.isIdentifier(node.expression) && node.expression.text === "require"))
|
|
) {
|
|
pushStringSpecifier(imports, sourceFile, node.arguments[0]);
|
|
}
|
|
|
|
ts.forEachChild(node, visit);
|
|
};
|
|
|
|
visit(sourceFile);
|
|
return imports;
|
|
}
|
|
|
|
function isPackageOrSubpath(specifier: string, packageName: string): boolean {
|
|
return specifier === packageName || specifier.startsWith(`${packageName}/`);
|
|
}
|
|
|
|
function isPathOrDescendant(repositoryPath: string, root: string): boolean {
|
|
return repositoryPath === root || repositoryPath.startsWith(`${root}/`);
|
|
}
|
|
|
|
function resolveWebImportRepositoryPath(fromRepositoryPath: string, specifier: string): string | null {
|
|
const pathOnly = specifier.split(/[?#]/, 1)[0];
|
|
if (!pathOnly) return null;
|
|
|
|
if (pathOnly.startsWith("@/")) {
|
|
return path.posix.normalize(path.posix.join("apps/web", pathOnly.slice("@/".length)));
|
|
}
|
|
|
|
if (!pathOnly.startsWith(".")) return null;
|
|
return path.posix.normalize(path.posix.join(path.posix.dirname(fromRepositoryPath), pathOnly));
|
|
}
|
|
|
|
function webImportIsolationViolationReason(fromRepositoryPath: string, specifier: string): string | null {
|
|
if (webImportIsolationForbiddenPackages.some((packageName) => isPackageOrSubpath(specifier, packageName))) {
|
|
return "apps/web must not import sidecar or platform control-plane packages directly";
|
|
}
|
|
|
|
const resolvedPath = resolveWebImportRepositoryPath(fromRepositoryPath, specifier);
|
|
if (!resolvedPath) return null;
|
|
|
|
if (webImportIsolationForbiddenDaemonRoots.some((root) => isPathOrDescendant(resolvedPath, root))) {
|
|
return "apps/web must use daemon HTTP APIs or @open-design/contracts instead of daemon private source";
|
|
}
|
|
|
|
if (webImportIsolationForbiddenPackageRoots.some((root) => isPathOrDescendant(resolvedPath, root))) {
|
|
return "apps/web must not import sidecar or platform control-plane source directly";
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function collectWebImportIsolationViolationsFromSource(
|
|
repositoryPath: string,
|
|
source: string,
|
|
): WebImportIsolationViolation[] {
|
|
if (!isWebImportIsolationSourcePath(repositoryPath)) return [];
|
|
|
|
return collectImportSpecifiersFromSource(repositoryPath, source).flatMap((sourceImport) => {
|
|
const reason = webImportIsolationViolationReason(repositoryPath, sourceImport.specifier);
|
|
if (!reason) return [];
|
|
return [{
|
|
filePath: repositoryPath,
|
|
lineNumber: sourceImport.lineNumber,
|
|
specifier: sourceImport.specifier,
|
|
reason,
|
|
}];
|
|
});
|
|
}
|
|
|
|
async function checkWebImportIsolation(): Promise<boolean> {
|
|
const violations: WebImportIsolationViolation[] = [];
|
|
|
|
for (const repositoryPrefix of webImportIsolationSourcePrefixes) {
|
|
const repositoryDirectory = repositoryPrefix.replace(/\/$/, "");
|
|
if (!(await repositoryDirectoryExists(repositoryDirectory))) continue;
|
|
|
|
for (const repositoryPath of await collectRepositoryFiles(
|
|
path.join(repoRoot, repositoryDirectory),
|
|
webImportIsolationSkippedDirectories,
|
|
)) {
|
|
if (!isWebImportIsolationSourcePath(repositoryPath)) continue;
|
|
const source = await readFile(path.join(repoRoot, repositoryPath), "utf8");
|
|
violations.push(...collectWebImportIsolationViolationsFromSource(repositoryPath, source));
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Web import isolation violations found:");
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.specifier}\` -> ${violation.reason}`);
|
|
}
|
|
return false;
|
|
}
|
|
|
|
console.log("Web import isolation check passed: web runtime imports stay behind contracts and daemon HTTP APIs.");
|
|
return true;
|
|
}
|
|
|
|
const toolsRootAllowlist = new Map<string, "directory" | "file">([
|
|
// Keep top-level tools intentionally small. `tools/launcher` was an incoming
|
|
// Windows shim experiment from PR #683 and is not an active repo boundary.
|
|
["AGENTS.md", "file"],
|
|
["dev", "directory"],
|
|
["pack", "directory"],
|
|
["release", "directory"],
|
|
["serve", "directory"],
|
|
]);
|
|
|
|
async function checkToolsLayout(): Promise<boolean> {
|
|
const toolsRoot = path.join(repoRoot, "tools");
|
|
const entries = await readdir(toolsRoot, { withFileTypes: true });
|
|
const seen = new Set<string>();
|
|
const violations: string[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const expected = toolsRootAllowlist.get(entry.name);
|
|
const repositoryPath = `tools/${entry.name}${entry.isDirectory() ? "/" : ""}`;
|
|
|
|
if (expected == null) {
|
|
violations.push(`${repositoryPath} -> tools/ top-level entries are allowlisted; expected only AGENTS.md, dev/, pack/, release/, and serve/`);
|
|
continue;
|
|
}
|
|
|
|
seen.add(entry.name);
|
|
if (expected === "directory" && !entry.isDirectory()) {
|
|
violations.push(`${repositoryPath} -> expected tools/${entry.name}/ to be a directory`);
|
|
}
|
|
if (expected === "file" && !entry.isFile()) {
|
|
violations.push(`${repositoryPath} -> expected tools/${entry.name} to be a file`);
|
|
}
|
|
}
|
|
|
|
for (const [entryName, expected] of toolsRootAllowlist) {
|
|
if (!seen.has(entryName)) {
|
|
violations.push(`tools/${entryName}${expected === "directory" ? "/" : ""} -> required tools boundary is missing`);
|
|
}
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Tools layout violations found:");
|
|
for (const violation of violations) console.error(`- ${violation}`);
|
|
return false;
|
|
}
|
|
|
|
console.log("Tools layout check passed: tools/ top-level entries match the active boundary allowlist.");
|
|
return true;
|
|
}
|
|
|
|
const stylePolicySkippedDirectories = new Set([
|
|
".next",
|
|
".od-data",
|
|
"dist",
|
|
"node_modules",
|
|
"out",
|
|
"reports",
|
|
"test-results",
|
|
]);
|
|
|
|
const stylePolicySourcePrefixes = ["apps/web/app/", "apps/web/src/"];
|
|
const stylePolicyHardcodedColorEnforcedPrefixes = ["scripts/guard-style-policy-fixtures/"];
|
|
const stylePolicyCheckedDirectoryPrefixes = [
|
|
...new Set([...stylePolicySourcePrefixes, ...stylePolicyHardcodedColorEnforcedPrefixes]),
|
|
];
|
|
const stylePolicyExtensions = new Set([".css", ".ts", ".tsx"]);
|
|
const tailwindDefaultColorNames = [
|
|
"slate",
|
|
"gray",
|
|
"zinc",
|
|
"neutral",
|
|
"stone",
|
|
"red",
|
|
"orange",
|
|
"amber",
|
|
"yellow",
|
|
"lime",
|
|
"green",
|
|
"emerald",
|
|
"teal",
|
|
"cyan",
|
|
"sky",
|
|
"blue",
|
|
"indigo",
|
|
"violet",
|
|
"purple",
|
|
"fuchsia",
|
|
"pink",
|
|
"rose",
|
|
"white",
|
|
"black",
|
|
].join("|");
|
|
const tailwindDefaultPaletteClassPrefixes = [
|
|
"bg",
|
|
"text",
|
|
"border(?:-(?:x|y|s|e|t|r|b|l))?",
|
|
"divide",
|
|
"placeholder",
|
|
"marker",
|
|
"from",
|
|
"via",
|
|
"to",
|
|
"ring(?:-offset)?",
|
|
"outline",
|
|
"decoration",
|
|
"(?:inset-|text-|drop-)?shadow",
|
|
"accent",
|
|
"caret",
|
|
"fill",
|
|
"stroke",
|
|
].join("|");
|
|
const defaultTailwindPaletteClassPattern = new RegExp(
|
|
`\\b(?:${tailwindDefaultPaletteClassPrefixes})-(?:${tailwindDefaultColorNames})(?:-\\d{2,3})?\\b`,
|
|
"g",
|
|
);
|
|
|
|
const hardcodedColorPattern = new RegExp(
|
|
`#[0-9a-fA-F]{3,8}\\b|rgba?\\([^)]*\\)|hsla?\\([^)]*\\)|(?<quote>['"])\\s*(?<named>${realNamedColors.join("|")}|transparent|currentColor|currentcolor|inherit|initial|unset|revert)\\s*\\k<quote>`,
|
|
"g",
|
|
);
|
|
|
|
type StylePolicyAllowlistEntry = {
|
|
pathPattern: RegExp;
|
|
valuePattern: RegExp;
|
|
reason: string;
|
|
};
|
|
|
|
const hardcodedColorAllowlist: StylePolicyAllowlistEntry[] = [
|
|
{
|
|
pathPattern: /^apps\/web\/src\/index\.css$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "global token definitions, shadows, overlays, and retained migration inventory live in the CSS source of truth",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:AgentIcon|PaletteTweaks|PetSettings|SettingsDialog)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "brand accents, user accent choices, and legacy token fallbacks are classified as Phase 1 migration inventory",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:SketchEditor|SketchPreview|NewProjectPanel)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\)|['\"](?:none|currentColor|currentcolor|transparent)['\"])$/,
|
|
reason: "sketch/canvas data and SVG illustrations keep narrow hardcoded color exceptions until their migration slice",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:FileViewer|ManualEditPanel)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "user-authored file, inspect, and editable style colors are handled by the file/viewer migration slice",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/src\/components\/(?:MemorySection|MemoryModelInline|MemoryToast)\.tsx$/,
|
|
valuePattern: /^(?:#[0-9a-fA-F]{3,8}\b|rgba?\([^)]*\)|hsla?\([^)]*\))$/,
|
|
reason: "memory UI legacy color fallbacks are classified as Phase 1 migration inventory",
|
|
},
|
|
{
|
|
pathPattern: /^apps\/web\/tests\//,
|
|
valuePattern: /.*/,
|
|
reason: "tests and fixtures may assert rejected colors explicitly",
|
|
},
|
|
];
|
|
|
|
type StylePolicyViolation = {
|
|
filePath: string;
|
|
lineNumber: number;
|
|
match: string;
|
|
reason: string;
|
|
};
|
|
|
|
function lineNumberForIndex(source: string, index: number): number {
|
|
return source.slice(0, index).split("\n").length;
|
|
}
|
|
|
|
function isStylePolicySource(repositoryPath: string): boolean {
|
|
return stylePolicySourcePrefixes.some((prefix) => repositoryPath.startsWith(prefix));
|
|
}
|
|
|
|
function isHardcodedColorEnforcedPath(repositoryPath: string): boolean {
|
|
return stylePolicyHardcodedColorEnforcedPrefixes.some((prefix) => repositoryPath.startsWith(prefix));
|
|
}
|
|
|
|
function isHardcodedColorAllowlisted(repositoryPath: string, match: string): boolean {
|
|
const normalizedMatch = match.trim();
|
|
const unquotedMatch = normalizedMatch.replace(/^['"]|['"]$/g, "");
|
|
if (cssWideAndSpecialColorKeywords.has(unquotedMatch.toLowerCase())) return true;
|
|
|
|
return hardcodedColorAllowlist.some(
|
|
(entry) => entry.pathPattern.test(repositoryPath) && entry.valuePattern.test(normalizedMatch),
|
|
);
|
|
}
|
|
|
|
function addStylePolicyViolation(
|
|
violations: StylePolicyViolation[],
|
|
repositoryPath: string,
|
|
source: string,
|
|
index: number,
|
|
match: string,
|
|
reason: string,
|
|
): void {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, index),
|
|
match,
|
|
reason,
|
|
});
|
|
}
|
|
|
|
function collectStylePolicyViolationsFromSource(repositoryPath: string, source: string): StylePolicyViolation[] {
|
|
const violations: StylePolicyViolation[] = [];
|
|
|
|
if (isStylePolicySource(repositoryPath)) {
|
|
for (const match of source.matchAll(defaultTailwindPaletteClassPattern)) {
|
|
violations.push({
|
|
filePath: repositoryPath,
|
|
lineNumber: lineNumberForIndex(source, match.index ?? 0),
|
|
match: match[0],
|
|
reason: "default Tailwind palette classes must use Open Design token utilities instead",
|
|
});
|
|
}
|
|
}
|
|
|
|
if (isStylePolicySource(repositoryPath) || isHardcodedColorEnforcedPath(repositoryPath)) {
|
|
if (repositoryPath.endsWith(".css") && isHardcodedColorEnforcedPath(repositoryPath)) {
|
|
for (const match of collectCssHardcodedColorMatches(source)) {
|
|
const value = match.value;
|
|
if (value === undefined || isHardcodedColorAllowlisted(repositoryPath, value)) continue;
|
|
|
|
addStylePolicyViolation(
|
|
violations,
|
|
repositoryPath,
|
|
source,
|
|
match.index,
|
|
value,
|
|
"unregistered hardcoded UI colors must use Open Design tokens or an explicit allowlist entry",
|
|
);
|
|
}
|
|
} else {
|
|
for (const match of source.matchAll(hardcodedColorPattern)) {
|
|
const value = match[0];
|
|
if (isHardcodedColorAllowlisted(repositoryPath, value)) continue;
|
|
if (!isHardcodedColorEnforcedPath(repositoryPath)) continue;
|
|
|
|
addStylePolicyViolation(
|
|
violations,
|
|
repositoryPath,
|
|
source,
|
|
match.index ?? 0,
|
|
value,
|
|
"unregistered hardcoded UI colors must use Open Design tokens or an explicit allowlist entry",
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function collectStylePolicyViolations(directory: string): Promise<StylePolicyViolation[]> {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
const violations: StylePolicyViolation[] = [];
|
|
|
|
for (const entry of entries) {
|
|
const fullPath = path.join(directory, entry.name);
|
|
if (entry.isDirectory()) {
|
|
if (stylePolicySkippedDirectories.has(entry.name)) continue;
|
|
violations.push(...(await collectStylePolicyViolations(fullPath)));
|
|
continue;
|
|
}
|
|
|
|
if (!entry.isFile() || !stylePolicyExtensions.has(path.extname(entry.name))) continue;
|
|
|
|
const repositoryPath = toRepositoryPath(fullPath);
|
|
if (!isStylePolicySource(repositoryPath) && !isHardcodedColorEnforcedPath(repositoryPath)) continue;
|
|
|
|
violations.push(...collectStylePolicyViolationsFromSource(repositoryPath, await readFile(fullPath, "utf8")));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function repositoryDirectoryExists(repositoryPath: string): Promise<boolean> {
|
|
const parentPath = path.join(repoRoot, path.dirname(repositoryPath));
|
|
const directoryName = path.basename(repositoryPath);
|
|
const entries = await readdir(parentPath, { withFileTypes: true });
|
|
|
|
return entries.some((entry) => entry.name === directoryName && entry.isDirectory());
|
|
}
|
|
|
|
async function collectStylePolicyViolationsFromCheckedPaths(): Promise<StylePolicyViolation[]> {
|
|
const violations: StylePolicyViolation[] = [];
|
|
|
|
for (const repositoryPrefix of stylePolicyCheckedDirectoryPrefixes) {
|
|
const repositoryDirectory = repositoryPrefix.replace(/\/$/, "");
|
|
if (!(await repositoryDirectoryExists(repositoryDirectory))) continue;
|
|
|
|
violations.push(...(await collectStylePolicyViolations(path.join(repoRoot, repositoryDirectory))));
|
|
}
|
|
|
|
return violations;
|
|
}
|
|
|
|
async function checkStylePolicy(): Promise<boolean> {
|
|
const violations = await collectStylePolicyViolationsFromCheckedPaths();
|
|
|
|
if (violations.length > 0) {
|
|
console.error("Style policy violations found:");
|
|
for (const violation of violations) {
|
|
console.error(`- ${violation.filePath}:${violation.lineNumber} \`${violation.match}\` -> ${violation.reason}`);
|
|
}
|
|
console.error("Use Open Design token utilities/CSS variables or add a narrow allowlist entry with a reason.");
|
|
return false;
|
|
}
|
|
|
|
console.log("Style policy check passed: Tailwind palette classes and enforced hardcoded UI colors stay token-first.");
|
|
return true;
|
|
}
|
|
|
|
async function checkCiTopology(): Promise<boolean> {
|
|
const ciWorkflow = await readFile(path.join(repoRoot, ".github/workflows/ci.yml"), "utf8");
|
|
const errors = [
|
|
...validatePlaywrightSuiteTopology(),
|
|
...[
|
|
"run: node --experimental-strip-types scripts/scopes.ts github-output",
|
|
"ci_mode: ${{ steps.detect.outputs.ci_mode }}",
|
|
"ui_p0_validation_required: ${{ steps.detect.outputs.ui_p0_validation_required }}",
|
|
"run_ui_p0: ${{ steps.detect.outputs.run_ui_p0 }}",
|
|
"run_nix_validation: ${{ steps.detect.outputs.run_nix_validation }}",
|
|
"ui_p0_matrix: ${{ steps.detect.outputs.ui_p0_matrix }}",
|
|
"visual_matrix: ${{ steps.detect.outputs.visual_matrix }}",
|
|
"include: ${{ fromJSON(needs.scopes.outputs.ui_p0_matrix) }}",
|
|
"include: ${{ fromJSON(needs.scopes.outputs.visual_matrix) }}",
|
|
"needs.scopes.outputs.run_ui_p0 == 'true'",
|
|
"pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group smoke",
|
|
"pnpm -C e2e exec tsx scripts/playwright.ts run-ui-group ${{ matrix.shard }}",
|
|
]
|
|
.filter((needle) => !ciWorkflow.includes(needle))
|
|
.map((needle) => `.github/workflows/ci.yml is missing ${needle}`),
|
|
];
|
|
|
|
if (errors.length > 0) {
|
|
console.error("CI topology check failed:");
|
|
for (const error of errors) console.error(`- ${error}`);
|
|
return false;
|
|
}
|
|
|
|
console.log("CI topology check passed: scopes, Playwright suites, and workflow matrices stay aligned.");
|
|
return true;
|
|
}
|
|
|
|
const checks: GuardCheck[] = [
|
|
{ name: "residual JavaScript", run: checkResidualJavaScript },
|
|
{ name: "package dependency specs", run: checkPackageDependencySpecs },
|
|
{ name: "product neutrality", run: checkProductNeutrality },
|
|
{ name: "cross-app imports", run: checkCrossAppImports },
|
|
{ name: "test layout", run: checkTestLayout },
|
|
{ name: "e2e layout", run: checkE2eLayout },
|
|
{ name: "web test layout", run: checkWebTestLayout },
|
|
{ name: "web import isolation", run: checkWebImportIsolation },
|
|
{ name: "tools layout", run: checkToolsLayout },
|
|
{ name: "style policy", run: checkStylePolicy },
|
|
{ name: "CI topology", run: checkCiTopology },
|
|
{ name: "craft references", run: checkCraftReferences },
|
|
{ name: "plugin preview manifest", run: checkPluginPreviewManifest },
|
|
{ name: "design system manifests", run: checkDesignSystemManifests },
|
|
{ name: "design system package quality", run: checkDesignSystemPackageQuality },
|
|
{ name: "design system component fixture report", run: checkDesignSystemComponentFixtureReport },
|
|
{ name: "design system token-fixture sync", run: checkDesignSystemTokenFixtureSync },
|
|
{ name: "design system A1 required tokens", run: checkDesignSystemA1RequiredTokens },
|
|
{ name: "design system A2 required tokens", run: checkDesignSystemA2RequiredTokens },
|
|
{ name: "design system B-slot required tokens", run: checkDesignSystemBSlotRequiredTokens },
|
|
{ name: "design system unknown token allowlist", run: checkDesignSystemUnknownTokens },
|
|
{ name: "design system A2 defaults parity", run: checkDesignSystemA2DefaultsParity },
|
|
{ name: "design system flag parity", run: checkDesignSystemFlagParity },
|
|
{ name: "design system component manifest extraction", run: checkComponentsManifestExtraction },
|
|
];
|
|
|
|
async function runChecks(): Promise<boolean> {
|
|
const results: boolean[] = [];
|
|
for (const check of checks) {
|
|
try {
|
|
results.push(await check.run());
|
|
} catch (error) {
|
|
console.error(`Guard check failed unexpectedly: ${check.name}`);
|
|
console.error(error);
|
|
results.push(false);
|
|
}
|
|
}
|
|
|
|
return results.every(Boolean);
|
|
}
|
|
|
|
const isMain = process.argv[1] ? import.meta.url === pathToFileURL(process.argv[1]).href : false;
|
|
if (isMain && !(await runChecks())) {
|
|
process.exitCode = 1;
|
|
}
|