feat(sync): restore sync-design-system.mjs with an explicit source boundary

The script that produced the existing vendored copies was deleted with the
monorepo layout in 2026-06 and recovered from catalog's history
(e84dffd^:scripts/sync-design-system.mjs). It could not be restored unchanged:
it walks its source directory and copies everything, and this repo's root is no
longer a directory holding only the design system. Run verbatim it vendored 109
files instead of 27, STATE.md and the whole of .git/ among them - and STATE.md
is gitignored precisely because this repo's remote is public.

The boundary is an explicit allowlist (DELIVERED_FILES) rather than a dist/
directory: one definition, read directly by the tests, with no copy step to go
stale. The allowlist's own failure mode - a new design-system file forgotten
from the list ships nothing, and --check stays green because it hashes a
consumer's tree against that consumer's own MANIFEST - is closed by scanning
the source for unlisted root-level *.css, fonts/* and schemas/* and refusing to
run.

tests/sync-source-boundary.test.mjs pins both directions. It was red on the
verbatim script for the right reason (STATE.md present in the target, with
tokens.css as the known-positive control) before the boundary was written.

--target is the consumer's repo root; the script appends
playground/vendor/playground-design-system/ itself. --check verified read-only
against both consumers: MANIFEST OK (27 files) for ms-ai-architect and
llm-security alike. No re-sync was run, so both MANIFEST.json files are
untouched and the stale source_commit is still theirs to inherit at the first
real re-sync.

README and docs/vendoring-and-re-sync.md now publish the command, and warn that
a plain diff/cmp against this repo reports all nine stylesheets as drifted
because the sync injects a generated-header line the source does not have -
17/27 raw, 26/27 with line 1 stripped. Found by ms-ai-architect, re-measured
here.
This commit is contained in:
Kjell Tore Guttormsen 2026-08-27 00:45:58 +02:00
commit 7448cec304
4 changed files with 601 additions and 51 deletions

View file

@ -62,6 +62,10 @@ playground-design-system/ # This repo
│ ├── finding.schema.json # Used by llm-security, config-audit, ultraplan-review, ms-ai-review │ ├── finding.schema.json # Used by llm-security, config-audit, ultraplan-review, ms-ai-review
│ ├── okr-set.schema.json # Used by OKR plugin │ ├── okr-set.schema.json # Used by OKR plugin
│ └── ros-threat.schema.json # Used by ms-ai-architect ROS workflow │ └── ros-threat.schema.json # Used by ms-ai-architect ROS workflow
├── 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) └── playground-examples/ # Showcase + reference scenarios (this repo's own demos)
├── index.html # System showcase (browse all components) ├── index.html # System showcase (browse all components)
├── ros-lier-kommune.html # Scenario A — ms-ai-architect ROS report ├── ros-lier-kommune.html # Scenario A — ms-ai-architect ROS report
@ -170,26 +174,57 @@ Vendoring remains the recommended consumer model. Measured 2026-08-27 across bot
current content three and a half months after their last sync, and neither copy has been edited 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. locally. The only file that differs is this README.
**There is no re-sync command today.** The `sync-design-system.mjs` script that produced the ### Re-sync
existing vendored copies was deleted with the monorepo layout in 2026-06. It is recoverable in
full from the `catalog` repo's history and belongs in this repo once restored, but restoring it
unchanged would copy this repo's root wholesale — including files that are not part of the
delivered system. That boundary is an open decision, so no command is published here rather than
one that would be wrong.
Until then, a consumer can still check its own copy without any script: From a checkout of this repo, with the consumer repo checked out alongside it:
- **Integrity** — re-hash the vendored files against the SHA-256 values in that copy's own ```sh
`MANIFEST.json`. Answers "has anyone edited the vendored files locally?" node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
- **Drift** — hash each vendored file, stripping the ```
`/* Code generated by sync-design-system.mjs; DO NOT EDIT. */` header line the sync injects into
every `.css`, and compare against the same filename at this repo's root.
`MANIFEST.json`'s `source` and `source_commit` fields in the existing copies point at the old That rewrites `<consumer-repo>/playground/vendor/playground-design-system/` — the 27 delivered
monorepo layout. Leave them alone for now — the recorded `source_commit` is not just unreachable files plus a regenerated `MANIFEST.json`. `--target` is the consumer's **repo root**; the script
but inaccurate, and rewriting only the path would make a false pointer look plausible. See appends `playground/vendor/playground-design-system/` itself. `--source <dir>` selects a
[docs/vendoring-and-re-sync.md](docs/vendoring-and-re-sync.md) for the full decision, the different checkout of this repo to read from (default: the one the script lives in). Node 16.7+,
measurements behind it, and the recovery command. 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 ## Design principles

View file

@ -1,8 +1,8 @@
# Vendoring and re-sync # Vendoring and re-sync
**Status:** decision recorded 2026-08-27. The re-sync script is recoverable but **Status:** decision recorded 2026-08-27; the re-sync script was restored the
not yet restored — see [Current state](#current-state) for what a consumer can same day with an explicit source boundary. See
and cannot do today. [Current state](#current-state) for the published command.
This document answers three questions raised by a consumer repo on 2026-08-26: 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 whether a current re-sync path exists, whether vendoring is still the
@ -47,11 +47,11 @@ 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 registry, and a Playground that opens from `file://` with only the CSS files
alongside it. alongside it.
### 2. Is there a current re-sync path? Not yet — but the script is not lost. ### 2. Is there a current re-sync path? Yes, since 2026-08-27.
`scripts/sync-design-system.mjs` is absent from the entire marketplace tree `scripts/sync-design-system.mjs` was absent from the entire marketplace tree.
today. It survives in full in the `catalog` repo's history, which carries the It survived in full in the `catalog` repo's history, which carries the
pre-migration monorepo history (977 commits). It can be read back with: pre-migration monorepo history (977 commits), and was read back with:
```sh ```sh
git -C <marketplace>/catalog show e84dffd^:scripts/sync-design-system.mjs git -C <marketplace>/catalog show e84dffd^:scripts/sync-design-system.mjs
@ -70,19 +70,31 @@ 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 repo** — the mechanism vendors *from* the design system, so the design system
owns it. owns it.
**It cannot be restored unchanged, and that is why no command is published **It could not be restored unchanged.** The script walked its source directory
here yet.** The script walks its source directory and copies everything it and copied everything it found. In the monorepo, `shared/playground-design-system/`
finds. In the monorepo, `shared/playground-design-system/` contained only the contained only the design system. This repo's root contains the design system
design system. This repo's root contains the design system *plus* the repo *plus* the repo apparatus: `STATE.md`, `LICENSE`, `SECURITY.md`, `.git/`,
apparatus: `STATE.md`, `LICENSE`, `SECURITY.md`, `.git/`, `docs/`, and `docs/`, and `playground-examples/`. That is not a hypothetical: run verbatim
`playground-examples/`. A naive `--source .` would vendor all of it into both against this repo's root, the recovered script vendored **109 files instead of
consumers — including `STATE.md`, which is deliberately gitignored and must 27**, `STATE.md` and the whole of `.git/` among them — and `STATE.md` is
never reach a public mirror. deliberately gitignored because this repo's remote is public.
Restoration therefore needs an explicit source boundary — an allowlist of the The boundary chosen is an **explicit allowlist** (`DELIVERED_FILES` in the
27 delivered files, or a `dist/` directory the script reads from. Choosing script) rather than a `dist/` directory. A `dist/` would duplicate every
between those is the next decision, and it is a real one; publishing a stylesheet and insert a copy step that can silently go stale; the allowlist
`--source .` command today would be a guess that leaks. 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. ### 3. Where should `MANIFEST.json`'s `source` point? Here — but not yet.
@ -115,20 +127,40 @@ does not hold — it was computed, and the answer is one file.
## Current state ## Current state
What a consumer can do today, with no script: Re-sync, run from a checkout of this repo with the consumer checked out
alongside it:
- **Verify integrity** — re-hash the vendored files against the SHA-256 values ```sh
in the copy's own `MANIFEST.json`. This needs nothing but the consumer's own node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
checkout and answers "has anyone edited the vendored copy locally?" ```
- **Measure drift against this repo** — hash each vendored file (stripping the
`/* Code generated by sync-design-system.mjs; DO NOT EDIT. */` header line
that the sync injects into every `.css`) and compare against the file of the
same name at this repo's root.
What a consumer cannot do today: re-sync with one command. That is pending the Integrity check, which needs no source checkout and writes nothing to the
source-boundary decision above. consumer:
`MANIFEST.json` should be left alone in the meantime. ```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 ## Verification log
@ -147,6 +179,11 @@ Measured 2026-08-27 against ground truth. `<marketplace>` is the polyrepo root.
| No local drift in either consumer | SHA-256 of each vendored file vs its own `MANIFEST.json` | both `MANIFEST OK`, 27/27 | | 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 | | 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 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 Not verified: whether `catalog`'s history is itself mirrored anywhere off this
machine. The recovery path above assumes a local `catalog` checkout. machine. The recovery path above assumes a local `catalog` checkout.

View 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);
});
}

View 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',
);
});