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:
parent
c4253027ed
commit
7448cec304
4 changed files with 601 additions and 51 deletions
69
README.md
69
README.md
|
|
@ -62,6 +62,10 @@ playground-design-system/ # This repo
|
|||
│ ├── 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
|
||||
├── 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
|
||||
|
|
@ -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
|
||||
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
|
||||
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.
|
||||
### Re-sync
|
||||
|
||||
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
|
||||
`MANIFEST.json`. Answers "has anyone edited the vendored files locally?"
|
||||
- **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.
|
||||
```sh
|
||||
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
|
||||
```
|
||||
|
||||
`MANIFEST.json`'s `source` and `source_commit` fields in the existing copies point at the old
|
||||
monorepo layout. Leave them alone for now — the recorded `source_commit` is not just unreachable
|
||||
but inaccurate, and rewriting only the path would make a false pointer look plausible. See
|
||||
[docs/vendoring-and-re-sync.md](docs/vendoring-and-re-sync.md) for the full decision, the
|
||||
measurements behind it, and the recovery command.
|
||||
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
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
# Vendoring and re-sync
|
||||
|
||||
**Status:** decision recorded 2026-08-27. The re-sync script is recoverable but
|
||||
not yet restored — see [Current state](#current-state) for what a consumer can
|
||||
and cannot do today.
|
||||
**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
|
||||
|
|
@ -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
|
||||
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
|
||||
today. It survives in full in the `catalog` repo's history, which carries the
|
||||
pre-migration monorepo history (977 commits). It can be read back with:
|
||||
`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
|
||||
|
|
@ -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
|
||||
owns it.
|
||||
|
||||
**It cannot be restored unchanged, and that is why no command is published
|
||||
here yet.** The script walks its source directory and copies everything it
|
||||
finds. 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/`. A naive `--source .` would vendor all of it into both
|
||||
consumers — including `STATE.md`, which is deliberately gitignored and must
|
||||
never reach a public mirror.
|
||||
**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.
|
||||
|
||||
Restoration therefore needs an explicit source boundary — an allowlist of the
|
||||
27 delivered files, or a `dist/` directory the script reads from. Choosing
|
||||
between those is the next decision, and it is a real one; publishing a
|
||||
`--source .` command today would be a guess that leaks.
|
||||
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.
|
||||
|
||||
|
|
@ -115,20 +127,40 @@ does not hold — it was computed, and the answer is one file.
|
|||
|
||||
## 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
|
||||
in the copy's own `MANIFEST.json`. This needs nothing but the consumer's own
|
||||
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.
|
||||
```sh
|
||||
node scripts/sync-design-system.mjs <consumer-name> --target ../<consumer-repo>
|
||||
```
|
||||
|
||||
What a consumer cannot do today: re-sync with one command. That is pending the
|
||||
source-boundary decision above.
|
||||
Integrity check, which needs no source checkout and writes nothing to the
|
||||
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
|
||||
|
||||
|
|
@ -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 |
|
||||
| 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