Compare commits
9 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| efaa3d0e79 | |||
| 7448cec304 | |||
| c4253027ed | |||
| 0fa22b9f83 | |||
| 2b67a93ce3 | |||
| dcd7b41209 | |||
| 92b55e0155 | |||
| 4464f12144 | |||
| 3838d55990 |
7 changed files with 914 additions and 41 deletions
12
.gitignore
vendored
Normal file
12
.gitignore
vendored
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
|
||||
# --- session/local state (gitignored per ~/.claude polyrepo-konvensjon) ---
|
||||
STATE.md
|
||||
REMEMBER.md
|
||||
ROADMAP.md
|
||||
TODO.md
|
||||
NEXT-SESSION-PROMPT*.local.md
|
||||
*.local.md
|
||||
*.local.json
|
||||
*.local.sh
|
||||
.DS_Store
|
||||
.claude/
|
||||
21
LICENSE
Normal file
21
LICENSE
Normal file
|
|
@ -0,0 +1,21 @@
|
|||
MIT License
|
||||
|
||||
Copyright (c) 2026 Kjell Tore Guttormsen
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
in the Software without restriction, including without limitation the rights
|
||||
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
||||
copies of the Software, and to permit persons to whom the Software is
|
||||
furnished to do so, subject to the following conditions:
|
||||
|
||||
The above copyright notice and this permission notice shall be included in all
|
||||
copies or substantial portions of the Software.
|
||||
|
||||
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
||||
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
||||
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
||||
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
||||
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
||||
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
||||
SOFTWARE.
|
||||
189
README.md
189
README.md
|
|
@ -1,8 +1,37 @@
|
|||
# Playground Design System
|
||||
# playground-design-system
|
||||
|
||||
A shared design system for plugin Playgrounds — visual self-service UIs that complement terminal slash-commands. Built for Norwegian public sector with WCAG 2.1 AA compliance, Aksel/Digdir-aligned aesthetics, and self-contained HTML deployment.
|
||||
Vendored, framework-free CSS design system for plugin playgrounds — design tokens, four component tiers, self-hosted fonts, and file:// operation with no build step.
|
||||
|
||||
**Version:** 0.1 (Phase 1 — 2026-05-02)
|
||||
Playgrounds are the visual self-service UIs that complement a plugin's terminal slash-commands. This system gives them one shared visual language: Aksel/Digdir-aligned aesthetics for Norwegian public-sector users, WCAG 2.1 AA contrast and focus handling, and pages that open straight from disk with no server.
|
||||
|
||||
**Version:** 0.6.0 — see [CHANGELOG.md](CHANGELOG.md).
|
||||
|
||||
## Table of Contents
|
||||
|
||||
- [Non-goals](#non-goals)
|
||||
- [Provenance](#provenance)
|
||||
- [Directory layout](#directory-layout)
|
||||
- [Quick start](#quick-start)
|
||||
- [Vendoring and re-sync](#vendoring-and-re-sync)
|
||||
- [Design principles](#design-principles)
|
||||
- [Token system](#token-system)
|
||||
- [Component reference](#component-reference)
|
||||
- [Schemas](#schemas)
|
||||
- [Theming](#theming)
|
||||
- [Print mode](#print-mode)
|
||||
- [Known limitations](#known-limitations)
|
||||
- [Self-hosted fonts](#self-hosted-fonts)
|
||||
- [Versioning](#versioning)
|
||||
- [License](#license)
|
||||
- [Contributing](#contributing)
|
||||
|
||||
## Non-goals
|
||||
|
||||
- **Not a JavaScript component library.** The system is CSS-only. Interactivity (tab state, sidepanel toggling, live-meter updates) is wired by each Playground in vanilla JS.
|
||||
- **Not a general-purpose web UI kit.** Scope is the Playgrounds in this marketplace; Aksel/Digdir conventions and domain visuals (NS 5814 matrices, EU AI Act tiers) are baked in rather than configurable.
|
||||
- **No build step, bundler or npm package.** Files are vendored and linked with plain `<link>` tags. Nothing is compiled, and nothing is published to a package registry.
|
||||
- **No icon set.** Iconography is left to each Playground so the shared system stays small.
|
||||
- **Not open to pull requests.** Solo project — see [Contributing](#contributing).
|
||||
|
||||
## Provenance
|
||||
|
||||
|
|
@ -13,28 +42,31 @@ Integration into the marketplace (file organization, path normalization, README
|
|||
## Directory layout
|
||||
|
||||
```
|
||||
shared/
|
||||
├── playground-design-system/ # The design system (this directory)
|
||||
│ ├── README.md # This file
|
||||
│ ├── tokens.css # CSS custom properties (Aksel/Digdir-aligned)
|
||||
│ ├── base.css # Reset, typography, primitives, focus, print
|
||||
│ ├── components.css # Tier 1: radar, matrix, findings-browser, critique-card, wizard, live-meter
|
||||
│ ├── components-tier2.css # Tier 2: decision-tree, traffic-lights, diff-review, treemap, distribution, command-pipeline, pyramide, pipeline-cockpit, verdict-pill+risk-meter, codepoint-reveal, small-multiples, OWASP badges
|
||||
│ ├── components-tier3.css # Tier 3 wave 1: pair-before-after, AI Act timeline, 3-track entry, FRIA rights-matrix, capability-matrix, parallel-agent-status, ErrorSummary, GuidePanel
|
||||
│ ├── components-tier3-supplement.css # Tier 3 wave 2 (12): toxic-flow, fleet-overview, kanban Keep/Review/Remove, maturity-ladder, classify-and-transform, cycle-ribbon, persistent-antipattern, suppressed-signals, ExpansionCard, ReadMore, FormProgress, Aspirational-vs-Committed
|
||||
│ ├── fonts.css # @font-face declarations for self-hosted fonts
|
||||
│ ├── fonts/ # Self-hosted woff2 + license attribution
|
||||
│ │ ├── Inter-{Regular,Medium,SemiBold,Bold}.woff2
|
||||
│ │ ├── JetBrainsMono-{Regular,Medium,SemiBold}.woff2
|
||||
│ │ ├── SourceSerif4-{Regular,Semibold}.woff2
|
||||
│ │ └── LICENSES.md # All three are SIL OFL 1.1
|
||||
│ ├── print.css # A4 print stylesheet with B/W severity patterns
|
||||
│ └── schemas/ # Cross-plugin JSON schemas
|
||||
playground-design-system/ # This repo
|
||||
├── README.md # This file
|
||||
├── tokens.css # CSS custom properties (Aksel/Digdir-aligned)
|
||||
├── base.css # Reset, typography, primitives, focus, print
|
||||
├── components.css # Tier 1: radar, matrix, findings-browser, critique-card, wizard, live-meter
|
||||
├── components-tier2.css # Tier 2: decision-tree, traffic-lights, diff-review, treemap, distribution, command-pipeline, pyramide, pipeline-cockpit, verdict-pill+risk-meter, codepoint-reveal, small-multiples, OWASP badges
|
||||
├── components-tier3.css # Tier 3 wave 1: pair-before-after, AI Act timeline, 3-track entry, FRIA rights-matrix, capability-matrix, parallel-agent-status, ErrorSummary, GuidePanel
|
||||
├── components-tier3-supplement.css # Tier 3 wave 2 (12): toxic-flow, fleet-overview, kanban Keep/Review/Remove, maturity-ladder, classify-and-transform, cycle-ribbon, persistent-antipattern, suppressed-signals, ExpansionCard, ReadMore, FormProgress, Aspirational-vs-Committed
|
||||
├── components-tier4-project-view.css # Tier 4: project-view arketype
|
||||
├── fonts.css # @font-face declarations for self-hosted fonts
|
||||
├── fonts/ # Self-hosted woff2 + license attribution
|
||||
│ ├── Inter-{Regular,Medium,SemiBold,Bold}.woff2
|
||||
│ ├── JetBrainsMono-{Regular,Medium,SemiBold}.woff2
|
||||
│ ├── SourceSerif4-{Regular,Semibold}.woff2
|
||||
│ └── LICENSES.md # All three are SIL OFL 1.1
|
||||
├── print.css # A4 print stylesheet with B/W severity patterns
|
||||
├── schemas/ # Cross-plugin JSON schemas
|
||||
│ ├── finding.schema.json # Used by llm-security, config-audit, ultraplan-review, ms-ai-review
|
||||
│ ├── okr-set.schema.json # Used by OKR plugin
|
||||
│ └── ros-threat.schema.json # Used by ms-ai-architect ROS workflow
|
||||
│
|
||||
└── playground-examples/ # Showcase + reference scenarios
|
||||
├── scripts/
|
||||
│ └── sync-design-system.mjs # Vendors the delivered files into a consumer (see Vendoring and re-sync)
|
||||
├── tests/
|
||||
│ └── sync-source-boundary.test.mjs # Pins what the sync may and may not copy
|
||||
└── playground-examples/ # Showcase + reference scenarios (this repo's own demos)
|
||||
├── index.html # System showcase (browse all components)
|
||||
├── ros-lier-kommune.html # Scenario A — ms-ai-architect ROS report
|
||||
├── okr-baerum.html # Scenario B — OKR live writer
|
||||
|
|
@ -58,6 +90,10 @@ shared/
|
|||
└── ros-data.js # Scenario A mock data
|
||||
```
|
||||
|
||||
Consumers vendor this repo (its CSS/fonts/schemas, not `playground-examples/`) into their own
|
||||
Playground at `<plugin>/playground/vendor/playground-design-system/` — copied in, not a git
|
||||
submodule or shared symlink. See [Quick start](#quick-start).
|
||||
|
||||
## Quick start
|
||||
|
||||
To use the design system from a plugin's Playground:
|
||||
|
|
@ -67,16 +103,41 @@ To use the design system from a plugin's Playground:
|
|||
<html lang="nb" data-theme="light">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<link rel="stylesheet" href="../../shared/playground-design-system/tokens.css">
|
||||
<link rel="stylesheet" href="../../shared/playground-design-system/base.css">
|
||||
<link rel="stylesheet" href="../../shared/playground-design-system/components.css">
|
||||
<link rel="stylesheet" href="../../shared/playground-design-system/components-tier2.css">
|
||||
|
||||
<!-- Theme bootstrap. Must run before the stylesheets parse to avoid a
|
||||
flash-of-wrong-theme (FOUC). Priority order:
|
||||
1) saved choice (localStorage '<plugin-name>-theme')
|
||||
2) OS preference via matchMedia('(prefers-color-scheme: dark)')
|
||||
3) the HTML attribute default ('light' above)
|
||||
Sets both data-theme + colorScheme for native form controls and scrollbars.
|
||||
Wrapped in try/catch — file:// + private mode can block localStorage. -->
|
||||
<script>
|
||||
(function () {
|
||||
var theme = null;
|
||||
try {
|
||||
var saved = localStorage.getItem('<plugin-name>-theme');
|
||||
if (saved === 'light' || saved === 'dark') theme = saved;
|
||||
} catch (e) { /* localStorage unavailable */ }
|
||||
if (!theme && window.matchMedia) {
|
||||
theme = window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light';
|
||||
}
|
||||
if (!theme) theme = document.documentElement.getAttribute('data-theme') || 'light';
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
document.documentElement.style.colorScheme = theme;
|
||||
})();
|
||||
</script>
|
||||
|
||||
<link rel="stylesheet" href="vendor/playground-design-system/tokens.css">
|
||||
<link rel="stylesheet" href="vendor/playground-design-system/base.css">
|
||||
<link rel="stylesheet" href="vendor/playground-design-system/components.css">
|
||||
<link rel="stylesheet" href="vendor/playground-design-system/components-tier2.css">
|
||||
<!-- Optional: include components-tier3.css for Tier 3 wave 1 components -->
|
||||
<!-- Optional: include components-tier3-supplement.css for Tier 3 wave 2 (12 additional components) -->
|
||||
<!-- Optional: include components-tier4-project-view.css for the Tier 4 project-view arketype -->
|
||||
<!-- Optional: only include print.css if scenario produces a printable A4 report -->
|
||||
<link rel="stylesheet" href="../../shared/playground-design-system/print.css">
|
||||
<link rel="stylesheet" href="vendor/playground-design-system/print.css">
|
||||
<!-- Self-hosted fonts (no external requests) -->
|
||||
<link rel="stylesheet" href="../../shared/playground-design-system/fonts.css">
|
||||
<link rel="stylesheet" href="vendor/playground-design-system/fonts.css">
|
||||
</head>
|
||||
<body>
|
||||
<header class="app-header">
|
||||
|
|
@ -93,7 +154,77 @@ To use the design system from a plugin's Playground:
|
|||
</html>
|
||||
```
|
||||
|
||||
The relative path `../../shared/playground-design-system/` assumes the plugin's Playground lives at `plugins/{plugin-name}/playground/index.html`. Adjust the prefix to match your plugin's structure.
|
||||
The relative path `vendor/playground-design-system/` assumes the plugin's Playground HTML lives at
|
||||
`<plugin-name>/playground/<plugin-name>-playground.html`, with the design system vendored (copied
|
||||
in) at `<plugin-name>/playground/vendor/playground-design-system/`. Adjust the prefix to match your
|
||||
plugin's structure.
|
||||
|
||||
The theme-bootstrap script is not part of the vendored CSS — copy it into each Playground's own
|
||||
`<head>`, replacing `<plugin-name>-theme` with a key unique to that plugin (both `ms-ai-architect`
|
||||
and `llm-security` follow this pattern today). The `data-theme-toggle` button's own click handler
|
||||
(written per Playground, not vendored) is what writes that key via `localStorage.setItem` when the
|
||||
reader switches theme — the bootstrap script only reads it back on the next load. Without the
|
||||
bootstrap script, the page renders in the HTML attribute's default theme for one frame before
|
||||
JavaScript applies the saved/OS preference — the FOUC it exists to prevent.
|
||||
|
||||
## Vendoring and re-sync
|
||||
|
||||
Vendoring remains the recommended consumer model. Measured 2026-08-27 across both consumers
|
||||
(`ms-ai-architect`, `llm-security`): 26 of 27 vendored files are byte-identical to this repo's
|
||||
current content three and a half months after their last sync, and neither copy has been edited
|
||||
locally. The only file that differs is this README.
|
||||
|
||||
### Re-sync
|
||||
|
||||
From a checkout of this repo, with the consumer repo checked out alongside it:
|
||||
|
||||
```sh
|
||||
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
|
||||
```
|
||||
|
||||
That rewrites `<consumer-repo>/playground/vendor/playground-design-system/` — the 27 delivered
|
||||
files plus a regenerated `MANIFEST.json`. `--target` is the consumer's **repo root**; the script
|
||||
appends `playground/vendor/playground-design-system/` itself. `--source <dir>` selects a
|
||||
different checkout of this repo to read from (default: the one the script lives in). Node 16.7+,
|
||||
no npm dependencies, no install step.
|
||||
|
||||
The script refuses to overwrite a vendored file that was edited locally since the last sync;
|
||||
`--force` overrides. It copies an explicit allowlist (`DELIVERED_FILES` in the script), not the
|
||||
repo root — `STATE.md`, `.git/`, `docs/`, `playground-examples/`, `LICENSE` and `SECURITY.md`
|
||||
stay here. `tests/sync-source-boundary.test.mjs` pins that boundary in both directions: nothing
|
||||
outside the allowlist reaches a consumer, and a new design-system file that is missing *from* the
|
||||
allowlist fails the sync loudly instead of silently not shipping.
|
||||
|
||||
```sh
|
||||
node --test tests/sync-source-boundary.test.mjs
|
||||
```
|
||||
|
||||
### Checking a vendored copy
|
||||
|
||||
Integrity, from the consumer side, needs no source checkout:
|
||||
|
||||
```sh
|
||||
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo> --check
|
||||
```
|
||||
|
||||
It re-hashes the vendored tree against that copy's own `MANIFEST.json`, then prints
|
||||
`MANIFEST OK (27 files, source_commit ...)` and exits 0, or lists the differing files and exits 2.
|
||||
It reads only — `--check` writes nothing to the consumer.
|
||||
|
||||
**Without the script, do not compare a vendored copy to this repo with a plain `diff` or `cmp`.**
|
||||
The sync injects `/* Code generated by sync-design-system.mjs; DO NOT EDIT. */` as line 1 of every
|
||||
`.css`, and the source has no such line, so a raw comparison reports **all nine stylesheets as
|
||||
drifted** when none of them is. Measured 2026-08-27 against `ms-ai-architect`: raw `cmp` matches
|
||||
17 of 27 files; strip line 1 from the vendored `.css` files and it is 26 of 27, the only real
|
||||
difference being this README. `--check` is immune, because it hashes the vendored tree against
|
||||
`MANIFEST.json` — which was built after the header was injected — and never against the source.
|
||||
|
||||
`MANIFEST.json`'s `source` and `source_commit` fields in the existing copies still describe the
|
||||
old monorepo layout, and the recorded commit is not merely unreachable but inaccurate. Leave them
|
||||
alone: the next real re-sync rewrites both truthfully. Do not repair them by hand — rewriting only
|
||||
the path would turn an obviously stale pointer into a plausible false one. See
|
||||
[docs/vendoring-and-re-sync.md](docs/vendoring-and-re-sync.md) for the full decision and the
|
||||
measurements behind it.
|
||||
|
||||
## Design principles
|
||||
|
||||
|
|
|
|||
31
SECURITY.md
Normal file
31
SECURITY.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# Security policy
|
||||
|
||||
## Reporting a vulnerability
|
||||
|
||||
Report privately to <security@fromaitochitta.com> - do not open a
|
||||
public issue.
|
||||
Canonical repository: https://git.fromaitochitta.com/open/playground-design-system
|
||||
|
||||
Please include the affected version or commit, a minimal reproduction,
|
||||
and the impact you see. We acknowledge every report within 5 working
|
||||
days, agree a fix and disclosure timeline with the reporter, and aim to
|
||||
disclose within 90 days of the initial report.
|
||||
|
||||
## Response process
|
||||
|
||||
1. Acknowledge within 5 working days.
|
||||
2. Triage and confirm severity within 10 working days.
|
||||
3. Develop and test a fix.
|
||||
4. Publish an advisory and credit the reporter unless they prefer
|
||||
to remain anonymous.
|
||||
|
||||
## Supported versions
|
||||
|
||||
This repository has not yet reached a 1.0 release. Only the latest
|
||||
tagged 0.x version (currently 0.6.0, see [CHANGELOG.md](CHANGELOG.md))
|
||||
is supported; security fixes land on `main` and are released as the
|
||||
next 0.x tag.
|
||||
|
||||
## Advisories
|
||||
|
||||
No advisories have been published for this repository to date.
|
||||
200
docs/vendoring-and-re-sync.md
Normal file
200
docs/vendoring-and-re-sync.md
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
# Vendoring and re-sync
|
||||
|
||||
**Status:** decision recorded 2026-08-27; the re-sync script was restored the
|
||||
same day with an explicit source boundary. See
|
||||
[Current state](#current-state) for the published command.
|
||||
|
||||
This document answers three questions raised by a consumer repo on 2026-08-26:
|
||||
whether a current re-sync path exists, whether vendoring is still the
|
||||
recommended consumer model, and where `MANIFEST.json`'s `source` field should
|
||||
point now that the marketplace is a polyrepo.
|
||||
|
||||
Every number below was measured against ground truth on 2026-08-27, not
|
||||
recalled. The measurements are listed in [Verification log](#verification-log)
|
||||
so a reader can re-run them.
|
||||
|
||||
## Background
|
||||
|
||||
Until 2026-06-18 the marketplace was a monorepo. This design system lived at
|
||||
`shared/playground-design-system/`, and `scripts/sync-design-system.mjs`
|
||||
vendored it into each plugin's `playground/vendor/playground-design-system/`.
|
||||
Commit `e84dffd` ("thin catalog to manifest + docs (polyrepo migration
|
||||
complete)") deleted both paths in the same change. This repo was extracted as a
|
||||
sibling with the CSS files at its root and a fresh 17-commit history.
|
||||
|
||||
Consumer documentation kept pointing at the monorepo layout, so the instructions
|
||||
it gave named a directory and a script that no longer existed anywhere.
|
||||
|
||||
## Decision
|
||||
|
||||
### 1. Is vendoring still the recommended model? Yes.
|
||||
|
||||
This is settled first, because the other two answers depend on it.
|
||||
|
||||
The measurement supports keeping it. Three and a half months after the last
|
||||
sync, in both consumers, **26 of 27 vendored files are byte-identical to this
|
||||
repo's current content**, and each `MANIFEST.json` still matches its own files
|
||||
exactly — no consumer has edited a vendored file locally. The one file that
|
||||
differs is this repo's own `README.md`, which documents the system rather than
|
||||
being part of what it delivers.
|
||||
|
||||
Vendoring did what it promised: the consumers stayed standalone and kept
|
||||
rendering correctly when the source moved out from under them. What broke was
|
||||
the *documentation pointing at the mechanism*, not the mechanism.
|
||||
|
||||
Nothing replaces it. A git submodule or an npm package would contradict the
|
||||
Non-goals in [README.md](../README.md): no build step, no bundler, no package
|
||||
registry, and a Playground that opens from `file://` with only the CSS files
|
||||
alongside it.
|
||||
|
||||
### 2. Is there a current re-sync path? Yes, since 2026-08-27.
|
||||
|
||||
`scripts/sync-design-system.mjs` was absent from the entire marketplace tree.
|
||||
It survived in full in the `catalog` repo's history, which carries the
|
||||
pre-migration monorepo history (977 commits), and was read back with:
|
||||
|
||||
```sh
|
||||
git -C <marketplace>/catalog show e84dffd^:scripts/sync-design-system.mjs
|
||||
```
|
||||
|
||||
It is 225 lines, has no npm dependencies, and — this matters — it was **already
|
||||
prepared for the polyrepo split before the split happened**. It accepts
|
||||
`--source <dir>` and `--target <dir>`, both of which override the hardcoded
|
||||
monorepo paths, and its own comment says the `--target` flag exists "so the
|
||||
script still works once `plugins/<name>` no longer exists in-repo
|
||||
(post-migration / extracted repo)". It also has a `--check` mode that re-hashes
|
||||
a consumer's vendored tree against its committed `MANIFEST.json` and exits
|
||||
non-zero on drift, needing no source at all.
|
||||
|
||||
So restoring it is recovery, not new development, and it belongs in **this
|
||||
repo** — the mechanism vendors *from* the design system, so the design system
|
||||
owns it.
|
||||
|
||||
**It could not be restored unchanged.** The script walked its source directory
|
||||
and copied everything it found. In the monorepo, `shared/playground-design-system/`
|
||||
contained only the design system. This repo's root contains the design system
|
||||
*plus* the repo apparatus: `STATE.md`, `LICENSE`, `SECURITY.md`, `.git/`,
|
||||
`docs/`, and `playground-examples/`. That is not a hypothetical: run verbatim
|
||||
against this repo's root, the recovered script vendored **109 files instead of
|
||||
27**, `STATE.md` and the whole of `.git/` among them — and `STATE.md` is
|
||||
deliberately gitignored because this repo's remote is public.
|
||||
|
||||
The boundary chosen is an **explicit allowlist** (`DELIVERED_FILES` in the
|
||||
script) rather than a `dist/` directory. A `dist/` would duplicate every
|
||||
stylesheet and insert a copy step that can silently go stale; the allowlist
|
||||
names the delivered set in one place that the tests read directly, so there is
|
||||
no second definition to drift from.
|
||||
|
||||
An allowlist has one failure mode of its own: a file added to the design system
|
||||
but not to the list would silently stop shipping, and `--check` would never
|
||||
notice, because it hashes a consumer's tree against that consumer's own
|
||||
`MANIFEST.json` and never against the source. This repo has already lived
|
||||
through that shape once — `components-tier4-project-view.css` was added in
|
||||
v0.6.0 and was missing from the README's own directory tree until 2026-08-18.
|
||||
So the script scans the source for design-system files the allowlist does not
|
||||
name (root-level `*.css`, plus everything under `fonts/` and `schemas/`) and
|
||||
refuses to run, naming the file. Both directions are covered by
|
||||
`tests/sync-source-boundary.test.mjs`.
|
||||
|
||||
### 3. Where should `MANIFEST.json`'s `source` point? Here — but not yet.
|
||||
|
||||
This is not the consumer's business alone: the field is written by this repo's
|
||||
script and describes this repo's source, so the value is ours to define.
|
||||
|
||||
The important finding is that `source_commit` is not merely unreachable — **it
|
||||
is false**. Both consumers record
|
||||
`source_commit: c1b7bad3899c5cfe9ff90663003609b018aa79a0`, but the vendored tree
|
||||
contains `components-tier4-project-view.css`, and that file demonstrably did not
|
||||
exist in `c1b7bad`. The sync labelled its output with the marketplace's `HEAD`
|
||||
while copying the working tree, which at that moment held a v0.6.0 file that had
|
||||
not been committed yet.
|
||||
|
||||
The consequence for repair: **do not fix the field by translating the path.**
|
||||
Rewriting `shared/playground-design-system/` to this repo's name while keeping
|
||||
`c1b7bad` would turn an obviously stale pointer into a plausible false one, and
|
||||
a plausible false pointer is worse — it stops inviting the check that would
|
||||
catch it.
|
||||
|
||||
The correct order is: restore the script with a source boundary, run one
|
||||
re-sync, and let the script write a truthful manifest in which `source_commit`
|
||||
is this repo's `HEAD` — reachable from this repo, unlike `c1b7bad`. Until then,
|
||||
a visibly stale field is the more honest state.
|
||||
|
||||
Concretely, the restored script writes `source: "playground-design-system"` —
|
||||
the repo name, not a path inside it and not the absolute path of whatever
|
||||
checkout happened to be read, since either would be a machine-local string in a
|
||||
committed file. `source_commit` comes from `git rev-parse HEAD` in the source
|
||||
checkout. **That makes the push order load-bearing: run the first re-sync only
|
||||
after this repo's commit is on the public mirror**, or the manifest gets a hash
|
||||
that exists on one machine — the same unreachable-pointer defect as `c1b7bad`,
|
||||
with a fresher date on it. Check with
|
||||
`git -C <this repo> rev-parse HEAD` against `git -C <this repo> rev-parse origin/main`
|
||||
before syncing.
|
||||
|
||||
One correction to the premise this question arrived with: the lineage is **not**
|
||||
severed. `c1b7bad` is unreachable from this repo and from either consumer, but
|
||||
it is alive in `catalog`. "Nobody can compute how far behind a vendored copy is"
|
||||
does not hold — it was computed, and the answer is one file.
|
||||
|
||||
## Current state
|
||||
|
||||
Re-sync, run from a checkout of this repo with the consumer checked out
|
||||
alongside it:
|
||||
|
||||
```sh
|
||||
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
|
||||
```
|
||||
|
||||
Integrity check, which needs no source checkout and writes nothing to the
|
||||
consumer:
|
||||
|
||||
```sh
|
||||
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo> --check
|
||||
```
|
||||
|
||||
`--target` is the consumer's **repo root**; the script appends
|
||||
`playground/vendor/playground-design-system/` itself. Pointing `--target` at
|
||||
the vendor directory instead makes `--check` look one level too deep, find no
|
||||
`MANIFEST.json`, and report drift that does not exist.
|
||||
|
||||
One trap worth naming for anyone checking by hand instead. The sync injects
|
||||
`/* Code generated by sync-design-system.mjs; DO NOT EDIT. */` as line 1 of
|
||||
every `.css`, and the source has no such line, so a plain `diff` or `cmp`
|
||||
against this repo reports **all nine stylesheets as drifted** when none of them
|
||||
is: raw `cmp` matches 17 of 27 files, and stripping line 1 from the vendored
|
||||
`.css` files puts it back at 26 of 27 with this repo's README the only real
|
||||
difference. `--check` is immune, because it hashes the vendored tree against
|
||||
`MANIFEST.json`, which was built after the header was injected. This was found
|
||||
by `ms-ai-architect` on their own copy and re-measured here (see the
|
||||
[Verification log](#verification-log)).
|
||||
|
||||
`MANIFEST.json` should still be left alone. This session restored the script
|
||||
but ran no re-sync, so the fields described in § 3 above are unchanged and the
|
||||
reasoning there still holds: the first real re-sync is what rewrites them
|
||||
truthfully.
|
||||
|
||||
## Verification log
|
||||
|
||||
Measured 2026-08-27 against ground truth. `<marketplace>` is the polyrepo root.
|
||||
|
||||
| Claim | How it was checked | Result |
|
||||
|---|---|---|
|
||||
| `sync-design-system.mjs` absent from the marketplace | `find` over the tree, maxdepth 5, `.git`/`node_modules` pruned | 0 hits. Denominator: the same query returned 836 `.mjs` files; known-positive control found `catalog/scripts/check-versions.mjs`, so the query can find. |
|
||||
| `shared/` absent | `find -type d -name 'shared*'`, maxdepth 3 | 0 hits |
|
||||
| Two consumers vendor the system | `find -type d -name playground-design-system`, maxdepth 4 | `ms-ai-architect` and `llm-security`, 27 files + `MANIFEST.json` each |
|
||||
| `c1b7bad` unreachable from this repo | `git cat-file -t` | fatal: could not get object info. Known-positive control on this repo's `HEAD` returned `commit`. |
|
||||
| `c1b7bad` reachable from `catalog` | `git -C catalog cat-file -t` | `commit` — 2026-05-15, monorepo history, 977 commits |
|
||||
| The monorepo commit held both deleted paths | `git -C catalog ls-tree -r c1b7bad` | `shared/playground-design-system/` (26 files) and `scripts/sync-design-system.mjs` both present |
|
||||
| Both paths died in the migration | `git -C catalog log --diff-filter=D` | `e84dffd`, 2026-06-18, deleted both |
|
||||
| `source_commit` is false | `git -C catalog cat-file -e c1b7bad:shared/playground-design-system/components-tier4-project-view.css` | path does not exist in `c1b7bad`. Known-positive control on `components-tier3.css` in the same commit succeeded. |
|
||||
| No local drift in either consumer | SHA-256 of each vendored file vs its own `MANIFEST.json` | both `MANIFEST OK`, 27/27 |
|
||||
| Drift vs this repo | SHA-256 vs this repo's root, generated-header stripped from `.css` | 26/27 identical in both consumers; `README.md` the only difference |
|
||||
| The recovered script supports polyrepo | read of `git show e84dffd^:scripts/sync-design-system.mjs` | `--source`, `--target`, `--check` all present and honoured in `main()` |
|
||||
| The verbatim script leaks this repo's apparatus | ran the recovered script unchanged, `--source <repo root>`, into a temp target | 109 files vendored instead of 27; `STATE.md`, `.git`, `.gitignore`, `docs`, `playground-examples`, `LICENSE`, `SECURITY.md` all present. `tests/sync-source-boundary.test.mjs` red on this build, with `tokens.css` present as the known-positive control proving the assertions read the right path. |
|
||||
| The allowlisted script does not leak | `node --test tests/sync-source-boundary.test.mjs` after the boundary was added | 2/2 pass: 27 files + `MANIFEST.json`, none of the seven apparatus entries present |
|
||||
| An unlisted design-system file fails the sync | test adds `components-tier5.css` to a complete synthetic source | non-zero exit, filename named in stderr. Known-positive control: the same source without the extra file syncs cleanly. |
|
||||
| The published `--check` command works against both consumers | `node scripts/sync-design-system.mjs <name> --target ../<name> --check` | `MANIFEST OK (27 files, source_commit c1b7bad3899c5cfe9ff90663003609b018aa79a0)`, exit 0, for `ms-ai-architect` and `llm-security` alike |
|
||||
| A raw `cmp` misreports the CSS as drifted | `cmp` per file vs this repo's root, then `cmp` with line 1 stripped | raw: 17/27 match, the 10 differing being 9 `.css` + `README.md`; line-1-stripped: 26/27, `README.md` alone. Independently re-measured here; matches `ms-ai-architect`'s finding exactly. |
|
||||
|
||||
Not verified: whether `catalog`'s history is itself mirrored anywhere off this
|
||||
machine. The recovery path above assumes a local `catalog` checkout.
|
||||
342
scripts/sync-design-system.mjs
Normal file
342
scripts/sync-design-system.mjs
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
* sync-design-system.mjs
|
||||
*
|
||||
* Vendors this design system into a consumer's
|
||||
* playground/vendor/playground-design-system/ tree.
|
||||
*
|
||||
* Usage:
|
||||
* node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> [--source <dir>] [--force]
|
||||
* node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> --check
|
||||
*
|
||||
* Each consumer keeps its own pinned copy so it stays standalone.
|
||||
* MANIFEST.json records SHA-256 per file + source commit + sync date.
|
||||
* Drift detection refuses overwrite if a vendored file was modified
|
||||
* locally after sync; pass --force to overwrite anyway.
|
||||
*
|
||||
* Source boundary: only the files named in DELIVERED_FILES are vendored.
|
||||
* This repo's root holds the design system *and* the repo apparatus
|
||||
* (STATE.md, .git/, docs/, playground-examples/, LICENSE, SECURITY.md).
|
||||
* STATE.md is gitignored because this repo's remote is public; copying it
|
||||
* into a consumer would publish it. Walking the source tree, which is what
|
||||
* this script did while the system lived in its own directory inside the
|
||||
* marketplace monorepo, is therefore no longer safe.
|
||||
*
|
||||
* No npm dependencies. Node 16.7+.
|
||||
*/
|
||||
|
||||
import { createHash } from 'node:crypto';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { execSync } from 'node:child_process';
|
||||
import { fileURLToPath, pathToFileURL } from 'node:url';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const REPO_ROOT = path.resolve(__dirname, '..');
|
||||
const SOURCE_LABEL = 'playground-design-system';
|
||||
const GENERATED_HEADER = '/* Code generated by sync-design-system.mjs; DO NOT EDIT. */\n';
|
||||
|
||||
/**
|
||||
* The delivered system: exactly what a consumer vendors. Everything else in
|
||||
* this repo is apparatus and stays here.
|
||||
*
|
||||
* Adding a file to the design system means adding it here. Forgetting to is
|
||||
* caught by assertSourceBoundary() below rather than shipping silently, since
|
||||
* --check hashes a consumer's tree against its own MANIFEST and would stay
|
||||
* green forever on a file that was never copied.
|
||||
*/
|
||||
export const DELIVERED_FILES = [
|
||||
'CHANGELOG.md',
|
||||
'README.md',
|
||||
'base.css',
|
||||
'components-tier2.css',
|
||||
'components-tier3-supplement.css',
|
||||
'components-tier3.css',
|
||||
'components-tier4-project-view.css',
|
||||
'components.css',
|
||||
'fonts.css',
|
||||
'fonts/Inter-Bold.woff2',
|
||||
'fonts/Inter-Medium.woff2',
|
||||
'fonts/Inter-Regular.woff2',
|
||||
'fonts/Inter-SemiBold.woff2',
|
||||
'fonts/JetBrainsMono-Medium.woff2',
|
||||
'fonts/JetBrainsMono-Regular.woff2',
|
||||
'fonts/JetBrainsMono-SemiBold.woff2',
|
||||
'fonts/LICENSE-Inter.txt',
|
||||
'fonts/LICENSE-JetBrainsMono.txt',
|
||||
'fonts/LICENSE-SourceSerif4.md',
|
||||
'fonts/LICENSES.md',
|
||||
'fonts/SourceSerif4-Regular.woff2',
|
||||
'fonts/SourceSerif4-Semibold.woff2',
|
||||
'print.css',
|
||||
'schemas/finding.schema.json',
|
||||
'schemas/okr-set.schema.json',
|
||||
'schemas/ros-threat.schema.json',
|
||||
'tokens.css',
|
||||
];
|
||||
|
||||
// Where an added design-system file would plausibly land. Scanned against the
|
||||
// allowlist so an omission is loud. Deliberately narrow: root-level *.md and
|
||||
// the repo apparatus are excluded by design, not by oversight.
|
||||
const SCANNED_DIRS = ['fonts', 'schemas'];
|
||||
|
||||
function parseArgs(argv) {
|
||||
const args = { plugin: null, force: false, source: null, target: null, check: false };
|
||||
const rest = argv.slice(2);
|
||||
for (let i = 0; i < rest.length; i++) {
|
||||
const a = rest[i];
|
||||
if (a === '--force') args.force = true;
|
||||
else if (a === '--check') args.check = true;
|
||||
else if (a === '--source' || a === '--target') {
|
||||
const v = rest[++i];
|
||||
if (!v) throw new Error(`${a} requires a directory argument`);
|
||||
args[a === '--source' ? 'source' : 'target'] = v;
|
||||
} else if (a.startsWith('--source=')) args.source = a.slice('--source='.length);
|
||||
else if (a.startsWith('--target=')) args.target = a.slice('--target='.length);
|
||||
else if (a.startsWith('--')) {
|
||||
throw new Error(`Unknown flag: ${a}`);
|
||||
} else if (!args.plugin) {
|
||||
args.plugin = a;
|
||||
} else {
|
||||
throw new Error(`Unexpected positional arg: ${a}`);
|
||||
}
|
||||
}
|
||||
if (!args.plugin) {
|
||||
throw new Error('Missing consumer name. Usage: node scripts/sync-design-system.mjs <consumer-name> --target <consumer-repo> [--source <dir>] [--check] [--force]');
|
||||
}
|
||||
if (!args.target) {
|
||||
throw new Error('Missing --target. The marketplace no longer holds plugins/<name>, so the consumer repo root must be given explicitly.');
|
||||
}
|
||||
return args;
|
||||
}
|
||||
|
||||
// The consumer (vendor target) repo root. The vendored tree goes below it at
|
||||
// playground/vendor/playground-design-system/.
|
||||
function resolvePluginDir(args) {
|
||||
return path.resolve(args.target);
|
||||
}
|
||||
|
||||
async function sha256(filePath) {
|
||||
const buf = await fs.readFile(filePath);
|
||||
return createHash('sha256').update(buf).digest('hex');
|
||||
}
|
||||
|
||||
async function walk(dir, base = dir) {
|
||||
const entries = await fs.readdir(dir, { withFileTypes: true });
|
||||
const out = [];
|
||||
for (const e of entries) {
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
out.push(...(await walk(full, base)));
|
||||
} else if (e.isFile()) {
|
||||
out.push(path.relative(base, full));
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
async function readJsonIfExists(p) {
|
||||
try {
|
||||
return JSON.parse(await fs.readFile(p, 'utf8'));
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') return null;
|
||||
throw e;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Both directions of the boundary: every delivered file must exist in the
|
||||
* source, and no design-system file may exist in the source without being
|
||||
* delivered.
|
||||
*/
|
||||
async function assertSourceBoundary(sourceDir) {
|
||||
const delivered = new Set(DELIVERED_FILES);
|
||||
|
||||
const missing = [];
|
||||
for (const rel of DELIVERED_FILES) {
|
||||
try {
|
||||
await fs.access(path.join(sourceDir, rel));
|
||||
} catch {
|
||||
missing.push(rel);
|
||||
}
|
||||
}
|
||||
if (missing.length) {
|
||||
throw new Error(
|
||||
`Source is missing ${missing.length} delivered file(s):\n` +
|
||||
missing.map(f => ` - ${f}`).join('\n'),
|
||||
);
|
||||
}
|
||||
|
||||
const candidates = [];
|
||||
for (const e of await fs.readdir(sourceDir, { withFileTypes: true })) {
|
||||
if (e.isFile() && e.name.endsWith('.css')) candidates.push(e.name);
|
||||
}
|
||||
for (const dir of SCANNED_DIRS) {
|
||||
const full = path.join(sourceDir, dir);
|
||||
try {
|
||||
for (const rel of await walk(full)) candidates.push(path.join(dir, rel));
|
||||
} catch (e) {
|
||||
if (e.code !== 'ENOENT') throw e;
|
||||
}
|
||||
}
|
||||
|
||||
const unlisted = candidates.filter(rel => !delivered.has(rel)).sort();
|
||||
if (unlisted.length) {
|
||||
throw new Error(
|
||||
`Source holds ${unlisted.length} design-system file(s) the allowlist does not name:\n` +
|
||||
unlisted.map(f => ` - ${f}`).join('\n') +
|
||||
'\nAdd them to DELIVERED_FILES, or move them out of the delivered tree.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async function copyDelivered(sourceDir, targetDir) {
|
||||
for (const rel of DELIVERED_FILES) {
|
||||
const dest = path.join(targetDir, rel);
|
||||
await fs.mkdir(path.dirname(dest), { recursive: true });
|
||||
await fs.copyFile(path.join(sourceDir, rel), dest);
|
||||
}
|
||||
}
|
||||
|
||||
async function detectDrift(targetDir, prevManifest) {
|
||||
if (!prevManifest || !prevManifest.files) return [];
|
||||
const drifted = [];
|
||||
for (const [rel, prevHash] of Object.entries(prevManifest.files)) {
|
||||
const tgt = path.join(targetDir, rel);
|
||||
try {
|
||||
const cur = await sha256(tgt);
|
||||
if (cur !== prevHash) drifted.push(rel);
|
||||
} catch (e) {
|
||||
if (e.code === 'ENOENT') drifted.push(`${rel} (missing)`);
|
||||
else throw e;
|
||||
}
|
||||
}
|
||||
return drifted;
|
||||
}
|
||||
|
||||
async function injectGeneratedHeader(targetDir, files) {
|
||||
for (const rel of files) {
|
||||
if (!rel.endsWith('.css')) continue;
|
||||
const p = path.join(targetDir, rel);
|
||||
const content = await fs.readFile(p, 'utf8');
|
||||
if (content.startsWith(GENERATED_HEADER)) continue;
|
||||
await fs.writeFile(p, GENERATED_HEADER + content, 'utf8');
|
||||
}
|
||||
}
|
||||
|
||||
async function buildManifest(targetDir, files, sourceCommit, sourceLabel) {
|
||||
const fileHashes = {};
|
||||
for (const rel of files.sort()) {
|
||||
fileHashes[rel] = await sha256(path.join(targetDir, rel));
|
||||
}
|
||||
return {
|
||||
generated_by: 'scripts/sync-design-system.mjs',
|
||||
do_not_edit: true,
|
||||
source: sourceLabel,
|
||||
source_commit: sourceCommit,
|
||||
sync_date: new Date().toISOString(),
|
||||
file_count: files.length,
|
||||
files: fileHashes,
|
||||
};
|
||||
}
|
||||
|
||||
function getCurrentCommit(cwd) {
|
||||
try {
|
||||
return execSync('git rev-parse HEAD', {
|
||||
cwd: cwd || REPO_ROOT,
|
||||
encoding: 'utf8',
|
||||
}).trim();
|
||||
} catch {
|
||||
return 'unknown';
|
||||
}
|
||||
}
|
||||
|
||||
// --check: re-hash a consumer's vendored tree against its committed
|
||||
// MANIFEST.json and exit non-zero on drift. No source needed — one command in
|
||||
// a clean clone. Reads only; it never writes to the consumer.
|
||||
async function runCheck(args) {
|
||||
const pluginDir = resolvePluginDir(args);
|
||||
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
|
||||
const manifestPath = path.join(targetDir, 'MANIFEST.json');
|
||||
const manifest = await readJsonIfExists(manifestPath);
|
||||
if (!manifest) {
|
||||
console.error(`MANIFEST DRIFT: no MANIFEST.json at ${manifestPath}`);
|
||||
process.exit(2);
|
||||
}
|
||||
const drifted = await detectDrift(targetDir, manifest);
|
||||
if (drifted.length) {
|
||||
console.error(`MANIFEST DRIFT: ${drifted.length} vendored file(s) differ from MANIFEST.json:`);
|
||||
for (const f of drifted) console.error(` - ${f}`);
|
||||
process.exit(2);
|
||||
}
|
||||
console.log(`MANIFEST OK (${manifest.file_count} files, source_commit ${manifest.source_commit})`);
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const args = parseArgs(process.argv);
|
||||
|
||||
if (args.check) {
|
||||
await runCheck(args);
|
||||
return;
|
||||
}
|
||||
|
||||
const pluginDir = resolvePluginDir(args);
|
||||
const sourceDir = args.source ? path.resolve(args.source) : REPO_ROOT;
|
||||
|
||||
try {
|
||||
const stat = await fs.stat(pluginDir);
|
||||
if (!stat.isDirectory()) throw new Error('not a directory');
|
||||
} catch {
|
||||
throw new Error(`Consumer directory not found: ${pluginDir}`);
|
||||
}
|
||||
|
||||
try {
|
||||
await fs.access(sourceDir);
|
||||
} catch {
|
||||
throw new Error(`Source directory missing: ${sourceDir}`);
|
||||
}
|
||||
|
||||
await assertSourceBoundary(sourceDir);
|
||||
|
||||
const targetDir = path.join(pluginDir, 'playground', 'vendor', 'playground-design-system');
|
||||
const manifestPath = path.join(targetDir, 'MANIFEST.json');
|
||||
|
||||
const prevManifest = await readJsonIfExists(manifestPath);
|
||||
const drifted = await detectDrift(targetDir, prevManifest);
|
||||
if (drifted.length && !args.force) {
|
||||
console.error(`Refusing sync: ${drifted.length} vendored file(s) drifted from previous MANIFEST:`);
|
||||
for (const f of drifted) console.error(` - ${f}`);
|
||||
console.error('Pass --force to overwrite local changes.');
|
||||
process.exit(2);
|
||||
}
|
||||
if (drifted.length && args.force) {
|
||||
console.warn(`--force: overwriting ${drifted.length} drifted file(s).`);
|
||||
}
|
||||
|
||||
await fs.mkdir(path.dirname(targetDir), { recursive: true });
|
||||
await fs.rm(targetDir, { recursive: true, force: true });
|
||||
await fs.mkdir(targetDir, { recursive: true });
|
||||
await copyDelivered(sourceDir, targetDir);
|
||||
|
||||
const files = await walk(targetDir);
|
||||
await injectGeneratedHeader(targetDir, files);
|
||||
|
||||
const sourceCommit = getCurrentCommit(sourceDir);
|
||||
const finalFiles = await walk(targetDir);
|
||||
const manifest = await buildManifest(targetDir, finalFiles, sourceCommit, SOURCE_LABEL);
|
||||
await fs.writeFile(manifestPath, JSON.stringify(manifest, null, 2) + '\n', 'utf8');
|
||||
|
||||
console.log(`Synced ${SOURCE_LABEL} → ${targetDir}`);
|
||||
console.log(` Files: ${manifest.file_count + 1} (incl. MANIFEST.json)`);
|
||||
console.log(` Source commit: ${sourceCommit}`);
|
||||
console.log(` Sync date: ${manifest.sync_date}`);
|
||||
}
|
||||
|
||||
// Importable: the tests read DELIVERED_FILES from here so the allowlist has
|
||||
// exactly one definition.
|
||||
if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) {
|
||||
main().catch(err => {
|
||||
console.error(`Error: ${err.message}`);
|
||||
process.exit(1);
|
||||
});
|
||||
}
|
||||
136
tests/sync-source-boundary.test.mjs
Normal file
136
tests/sync-source-boundary.test.mjs
Normal file
|
|
@ -0,0 +1,136 @@
|
|||
/**
|
||||
* Source-boundary tests for scripts/sync-design-system.mjs.
|
||||
*
|
||||
* The design system used to live in its own directory inside the marketplace
|
||||
* monorepo, so the sync script could copy its whole source tree. After the
|
||||
* polyrepo split the source tree is this repo's root, which also holds
|
||||
* STATE.md (gitignored, must never reach a public mirror), .git/, docs/ and
|
||||
* playground-examples/. These tests pin the boundary: only the delivered
|
||||
* files may be vendored.
|
||||
*
|
||||
* Run with: node --test tests/
|
||||
*/
|
||||
|
||||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import { execFile } from 'node:child_process';
|
||||
import { promisify } from 'node:util';
|
||||
import { pathToFileURL } from 'node:url';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
|
||||
const REPO_ROOT = path.resolve(import.meta.dirname, '..');
|
||||
const SCRIPT = path.join(REPO_ROOT, 'scripts', 'sync-design-system.mjs');
|
||||
const VENDOR_SUBPATH = path.join('playground', 'vendor', 'playground-design-system');
|
||||
|
||||
const DELIVERED_COUNT = 27;
|
||||
|
||||
// --target is the PLUGIN root; the script appends playground/vendor/... itself,
|
||||
// and refuses to run if the plugin root does not already exist.
|
||||
async function makePluginDir() {
|
||||
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'pds-sync-test-'));
|
||||
return dir;
|
||||
}
|
||||
|
||||
async function listFiles(dir) {
|
||||
const out = [];
|
||||
async function walk(current) {
|
||||
const entries = await fs.readdir(current, { withFileTypes: true });
|
||||
for (const e of entries) {
|
||||
const full = path.join(current, e.name);
|
||||
if (e.isDirectory()) await walk(full);
|
||||
else out.push(path.relative(dir, full));
|
||||
}
|
||||
}
|
||||
await walk(dir);
|
||||
return out.sort();
|
||||
}
|
||||
|
||||
async function exists(p) {
|
||||
try {
|
||||
await fs.stat(p);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
test('sync copies only the delivered files, never the repo apparatus', async (t) => {
|
||||
const pluginDir = await makePluginDir();
|
||||
t.after(() => fs.rm(pluginDir, { recursive: true, force: true }));
|
||||
|
||||
await execFileAsync('node', [
|
||||
SCRIPT, 'test-plugin',
|
||||
'--source', REPO_ROOT,
|
||||
'--target', pluginDir,
|
||||
]);
|
||||
|
||||
const vendorDir = path.join(pluginDir, VENDOR_SUBPATH);
|
||||
|
||||
// Known-positive control: the assertions below are worthless unless this
|
||||
// proves the sync actually wrote to the path being inspected.
|
||||
assert.ok(
|
||||
await exists(path.join(vendorDir, 'tokens.css')),
|
||||
'tokens.css must be vendored — without it the leak assertions prove nothing',
|
||||
);
|
||||
|
||||
// The leak this test exists for. STATE.md is gitignored precisely because
|
||||
// this repo's remote is public; vendoring it into a consumer publishes it.
|
||||
for (const leak of ['STATE.md', '.git', 'playground-examples', 'docs', 'LICENSE', 'SECURITY.md', '.gitignore']) {
|
||||
assert.equal(
|
||||
await exists(path.join(vendorDir, leak)),
|
||||
false,
|
||||
`${leak} must not be vendored`,
|
||||
);
|
||||
}
|
||||
|
||||
const files = await listFiles(vendorDir);
|
||||
assert.equal(
|
||||
files.length,
|
||||
DELIVERED_COUNT + 1,
|
||||
`expected ${DELIVERED_COUNT} delivered files + MANIFEST.json, got ${files.length}`,
|
||||
);
|
||||
|
||||
const manifest = JSON.parse(await fs.readFile(path.join(vendorDir, 'MANIFEST.json'), 'utf8'));
|
||||
assert.equal(manifest.file_count, DELIVERED_COUNT);
|
||||
});
|
||||
|
||||
test('sync refuses to run when the source holds a design-system file the allowlist does not name', async (t) => {
|
||||
const pluginDir = await makePluginDir();
|
||||
const sourceDir = await fs.mkdtemp(path.join(os.tmpdir(), 'pds-sync-src-'));
|
||||
t.after(() => Promise.all([
|
||||
fs.rm(pluginDir, { recursive: true, force: true }),
|
||||
fs.rm(sourceDir, { recursive: true, force: true }),
|
||||
]));
|
||||
|
||||
// A minimal but complete source: every delivered file present, empty.
|
||||
const { DELIVERED_FILES } = await import(pathToFileURL(SCRIPT).href);
|
||||
for (const rel of DELIVERED_FILES) {
|
||||
const p = path.join(sourceDir, rel);
|
||||
await fs.mkdir(path.dirname(p), { recursive: true });
|
||||
await fs.writeFile(p, '', 'utf8');
|
||||
}
|
||||
|
||||
// Baseline: the complete source syncs cleanly. Known-positive control for
|
||||
// the failure asserted below.
|
||||
await execFileAsync('node', [SCRIPT, 'test-plugin', '--source', sourceDir, '--target', pluginDir]);
|
||||
|
||||
// A new stylesheet added to the design system but not to the allowlist is
|
||||
// the failure mode this repo already lived through once
|
||||
// (components-tier4-project-view.css, added v0.6.0, missing from docs until
|
||||
// 2026-08-18). It must be loud, not silent: --check hashes target against
|
||||
// MANIFEST and would stay green forever.
|
||||
await fs.writeFile(path.join(sourceDir, 'components-tier5.css'), '', 'utf8');
|
||||
|
||||
await assert.rejects(
|
||||
execFileAsync('node', [SCRIPT, 'test-plugin', '--source', sourceDir, '--target', pluginDir]),
|
||||
(err) => {
|
||||
assert.match(err.stderr, /components-tier5\.css/);
|
||||
return true;
|
||||
},
|
||||
'an unlisted stylesheet in the source must fail the sync',
|
||||
);
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue