Skip to content

Commit c2bb879

Browse files
feat(scenarios): export-gallery --check, the mechanical check a gallery PR must pass (#31748)
<!-- claude-projects-attribution: {"github_login":"zhuangjianguo"} --> _Requested by **Steedos** · [project thread](https://claude.ai/code/project/chan_018ETKaNwMZCNRYD7D8Q9Ubb?thread=cmsg_018ETKaNwMZCNRYD7D8Q9UbbTpKm2LSbAerqZvDtGXn2wb)_ Refs #31122 Clause-②: no Metadata-protocol: none ## 维护者速读 - 改了什么:场景导出器旁边加了一个只读检查 `export-gallery --check DIR`,对 gallery 仓的一棵树逐条报问题:index.json 不合导出器自己的 schema、应用目录不完整或零截图、目录未登记、`framework.version` 不是已发布的稳定版 `@objectstack/cli`、出现 `.env*`/`node_modules`/符号链接/本机绝对路径。有问题就退出码 1。 - 为什么改:定时任务每个稳定版会往公开的 gallery 仓开一个 PR,你一键合并;这一键安全的前提是 PR 先被机器检查过。检查和导出器共用同一份 schema 和目录约定,两边不会漂移。 - 风险与回滚:只读,不写任何文件,不改导出器的行为(只把三个常量挪进共享模块,导出名不变);回滚即 revert 本 PR。 - 先看:`packages/qa/scenarios/src/export/check.ts`(各条规则与本机路径正则);`src/bin.ts` 与 `tsup.config.ts`(发布命令的懒加载入口);`test/export/check.test.ts`。 - 两分钟验证:`pnpm --filter @objectstack/scenarios test`,预期全绿;或对今天的 gallery 克隆跑 `pnpm --filter @objectstack/scenarios export-gallery --check "$(realpath ../gallery)"`,预期 `gallery check: clean`。 - 需要你做的:无,席位落地。 ## What changes PR 1 of 2 for #31122. The gallery workflow (PR 2, objectstack-ai/gallery) runs this check on every PR there; it opens after this merges, pinned to this PR's merge commit. - **`src/export/check.ts`** (new): `checkGallery(dir, { publishedVersions })` answers one finding per defect, each `CODE PATH: MESSAGE`. Codes: `index-json`, `app-missing`, `app-incomplete`, `app-screenshots`, `app-unlisted`, `framework-version`, `env-file`, `node-modules`, `symbolic-link`, `local-path`. Each finding carries its code as `kind`, a kebab-case diagnostic code and never an ADR-0112 wire code, so the error-code guards do not read it as one. It reads `GalleryIndexSchema` from `manifest.ts` and the app layout from `contract.ts`, the same ones the exporter writes with, so nothing is restated. - An absent `index.json` is an empty gallery, as the exporter and the refresh read it. - A complete `apps/ID/` holds `README.md`, `run-record.json`, `scorecard.json`/`.md`, `transcript.json`/`.md`, `source/objectstack.config.ts`, at least one screenshot (seat ruling Q3), every listed screenshot on disk and nothing unlisted in `screenshots/`. - `framework.version` must match `STABLE_VERSION` and be in `@objectstack/cli`'s published version list. **Offline (tests):** the list is handed in. **Online (CLI, workflow):** `npm view @objectstack/cli versions --json` (`npmPublishedVersions`), the same `npm view` channel the refresh already uses for `latest`. npm is asked only when the index lists an app, so an empty gallery needs no network. - The tree scan skips `.git` only. It flags a name starting `.env`, a `node_modules` directory (not walked), a symbolic link (not followed), and a text file naming a local absolute path (`LOCAL_PATH`: home, temp and mount roots, `file:///`, Windows user and system paths). A file with a NUL byte in its first 8 KiB (a PNG) is checked by name only. - **`src/export/check-cli.ts`** (new): `mainCheck(argv)`. Exit 0 clean, 1 on any finding, 2 on a usage error. `--check DIR` takes no other flag. - **`src/export/cli.ts`**: `export-gallery --check DIR` hands off to `mainCheck` before any card or run is loaded. - **`src/bin.ts`, `tsup.config.ts`**: the published `objectstack-scenarios export-gallery --check DIR`. It loads the check from its own built entry, `dist/export-check.js`, the way `run` loads `dist/cli.js`, so `help` and `version` still load `dist/bin.js` alone. The export itself is not offered on the bin, since it reads a checkout's records and cards. - **`src/export/contract.ts`** (new): `APP_LAYOUT`, `FRAMEWORK_PACKAGE` and `STABLE_VERSION` move here from `exporter.ts` and `refresh.ts`. Both re-export them, so `@objectstack/scenarios/export` exports the same names. The check imports this module and not the exporter's graph. - **README**: a section "Checking a gallery PR — `export-gallery --check`". **Changeset**: `31122-scenarios-gallery-check.md`, `patch`. **Reading on today's gallery** (objectstack-ai/gallery `main` 09ce9b06: six hand-written files, no `index.json`, no app directory): `gallery check: clean`, 0 findings, exit 0, through both doors (the checkout's `pnpm … export-gallery --check` and the built bin). ## Tests - `test/export/check.test.ts` (new, 37 cases). The clean tree is the **exporter's own output** for the real crm fixture run, with a screenshot, plus the gallery's hand-written READMEs. A check that refused what the exporter writes fails there first. Each failing tree is that clean tree with one defect added, at least one case per code, all with an injected version list. The cases also cover today's empty gallery (npm never asked), the three CLI exit codes, the checkout door and the bin door. - `test/build/packed-tarball.test.ts`: the bin pin now also expects `import("./export-check.js")` and `dist/export-check.js` in the tarball, and the static imports are still only `fs`, `path` and `url`. A new case runs the packed bin under plain Node on an empty gallery (exit 0) and on one with `.env` (exit 1). `built-consumer.test.ts` does not read the bin, so it is unchanged. ## Acceptance notes - **Deviation (seat ruling on step 1):** `objectstack-scenarios export-gallery` is a new bin command, which would read as widening on a released package. `@objectstack/scenarios` has never been published (`npm view` answers E404 on 2026-11-09), so no released surface widens, and the claim's `Clause-②: no` is kept. - `symbolic-link` is one finding beyond the card's list. The content scan cannot see what a link points at, so an unflagged link would let anything through. The exporter already refuses to export one (`source.ts`). - `local-path` is a closed list of local roots, not "any leading slash", so routes (`/api/v1/…`) and URL paths are not flagged. A path outside the list (for example `/usr/…`) is not caught. - The gallery's branch protection (PR only, this check required) is a repository setting for the maintainer, not code here. ## Verification Package suites on head `afee5f75`. The merge of main (head `03dbda01`) brought 3 commits, none in `packages/qa/scenarios`, so nothing was re-run. Head `bf29bcf0` fixes the CI red on `03dbda01` (`Dispatcher error-code vocabulary guard`: the scan read the finding helper's `{ code }` object literal as an error-code stamp and `symlink` as an unregistered code). The code is now `symbolic-link` and the field `kind`. On `bf29bcf0`: `pnpm check:dispatcher-error-vocabulary` OK, `pnpm check:error-code-casing` 0, package typecheck + 32 files / 654 tests passed, eslint 0. <details><summary>Package: typecheck, 32 files / 654 tests, eslint</summary> - `pnpm --filter @objectstack/scenarios typecheck && pnpm --filter @objectstack/scenarios test` under the verify lock: VERDICT command-exit 0, Test Files 32 passed (32), Tests 654 passed (654). - `pnpm --filter @objectstack/scenarios build` then `test/build/packed-tarball.test.ts` + `test/runner/library.test.ts`: 2 files, 17 tests passed. - `npx eslint` on the ten touched source and test files: exit 0, no errors (`packed-tarball.test.ts` is in the ignore list). - Built `dist/bin.js`: static imports `fs`, `path` and `url` only; lazy `import("./cli.js")` and `import("./export-check.js")`. </details> <details><summary>Ablation: the zero-screenshot rule</summary> - `node scripts/ablation-replace.mjs` on `check.ts` replaced `if (app.screenshots.length === 0) add('app-screenshots'` with `if (false) …` (anchor 1 to 0, blob 71c32a9 to 19db493b). `check.test.ts`: 1 failed, 36 passed. The failure was the case "an entry with no screenshot fails". - Restored: blob back to 71c32a9, equal to HEAD; `git diff HEAD` empty. </details> <details><summary>Dispatch gates: 60 derived, 59 run, 1 NOT MEASURED</summary> - `node scripts/pm/dispatch-gates.mjs --commands --repo objectstack-ai/objectstack` derived 60 commands on `afee5f75`. 59 exited 0, `pnpm check:type-check-debt` among them (under the verify lock, 322 s). - NOT MEASURED: `pnpm check:dual-build-cjs-loads` exited 3 (PREREQUISITE NOT MET: the workspace is not fully built). CI's run is the evidence. - `dispatch-gates --ran` with each exit code: 60 accounted for, 59 run, 1 NOT MEASURED, 0 UNRUN. - `--tier`: no path-derived mandate. Changed lines 719, under the 3,000 threshold. </details> --- _Generated by [Claude Code](https://claude.ai/code/session_01QsR2jUtaGRLf12LBQ2uTjA)_ <!-- signed: domain:devx#2 · zhuangjianguo · session_01QsR2jTo666XXx9e1Gm21C6 --> --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent 78a8388 commit c2bb879

13 files changed

Lines changed: 703 additions & 20 deletions

File tree

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
---
2+
'@objectstack/scenarios': patch
3+
---
4+
5+
feat(scenarios): `export-gallery --check DIR`, the mechanical check a PR on objectstack-ai/gallery must pass
6+
7+
Clause-②: no
8+
Metadata-protocol: none
9+
10+
- **`export-gallery --check DIR`** reads a gallery tree and prints one `CODE PATH: MESSAGE` line per finding, exiting 1 on any and 0 on a clean tree. It writes nothing. It fails a tree when `index.json` does not match the exporter's own schema, when an entry has no complete `apps/<id>/` directory (README, run record, scorecard, transcript, `source/objectstack.config.ts`, at least one screenshot with every listed one on disk and none unlisted), when a directory under `apps/` is not listed, when a `framework.version` is not a stable `@objectstack/cli` release published on npm, and when the tree holds a `.env*` name, a `node_modules` directory, a symbolic link or a text file naming a local absolute path. An absent `index.json` is an empty gallery.
11+
- It runs from a checkout (`pnpm --filter @objectstack/scenarios export-gallery --check DIR`) and from the published command line (`npx @objectstack/scenarios export-gallery --check DIR`), which loads it lazily from its own built entry, so `help` and `version` still load nothing else.
12+
- **`@objectstack/scenarios/export`** also exports `checkGallery`, `mainCheck`, `npmPublishedVersions`, `renderFinding`, `FINDING_CODES` and `LOCAL_PATH`. The app layout, the framework package and the stable-version shape move into one shared module that the exporter, the refresh and the check all read; their exports are unchanged.

‎packages/qa/scenarios/README.md‎

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -347,6 +347,23 @@ Refresh the ObjectStack scenario gallery. Run each step in order and stop at the
347347
title and the same text. Never open a second one.
348348
```
349349

350+
### Checking a gallery PR — `export-gallery --check`
351+
352+
```bash
353+
pnpm --filter @objectstack/scenarios export-gallery --check "$(realpath ../gallery)" # from a checkout
354+
npx @objectstack/scenarios export-gallery --check . # from npm, in a gallery clone
355+
```
356+
357+
The check a PR on objectstack-ai/gallery must pass before it is merged (`src/export/check.ts`, CLI
358+
`src/export/check-cli.ts`). It writes nothing and reads the exporter's own contract (the `index.json` schema, the app
359+
layout), so the two cannot drift. It prints one `CODE PATH: MESSAGE` line per finding and exits 1 on any, 0 on a clean
360+
tree: `index.json` that does not validate (absent is an empty gallery); an entry without a complete `apps/<id>/`
361+
(README, run record, scorecard and transcript in JSON and Markdown, `source/objectstack.config.ts`, at least one
362+
screenshot, every listed screenshot on disk and none unlisted); a directory under `apps/` no entry lists; a
363+
`framework.version` that is not a stable release published for `@objectstack/cli` (read with `npm view … versions`,
364+
only when the index lists an app); and anywhere in the tree a `.env*` name, a `node_modules` directory, a symbolic link
365+
or a text file naming a local absolute path.
366+
350367
## Other agent tools — Codex and the external driver contract
351368

352369
The same card can measure another agent tool. Every record (`env.driver`) and every gallery app page (`- Driver:`)

‎packages/qa/scenarios/src/bin.ts‎

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,9 @@
1818
* file (`tsup.config.ts` turns the import into one of the built `dist/cli.js`):
1919
* its graph (the scorer's objectql and plugin-sharing among it) takes about a
2020
* second warm and several cold, which `help` and `version` must not pay.
21+
* `export-gallery --check DIR` (the gallery check, `export/check-cli.ts`) is
22+
* imported the same way, from the built `dist/export-check.js`. The export
23+
* itself is not offered here: it reads a checkout's run records and cards.
2124
*/
2225

2326
import { readFileSync } from 'node:fs';
@@ -30,6 +33,9 @@ Commands:
3033
run [flags] run the scenario cards end to end and write one run record per card
3134
(the flags of run-scenarios: --only, --modes, --driver, --cards, --out, …;
3235
see the package README, "Running from npm")
36+
export-gallery --check DIR
37+
check a gallery tree (index.json, apps/) as a PR on objectstack-ai/gallery
38+
must pass: one line per finding, exit 1 on any
3339
help print this text
3440
version print the package version
3541
@@ -52,6 +58,7 @@ function packageVersion(): string {
5258
export async function runCli(argv: readonly string[], io: CliIo = consoleIo): Promise<number> {
5359
const [command, ...rest] = argv;
5460
if (command === 'run') return (await import('./runner/cli.js')).main(rest);
61+
if (command === 'export-gallery') return (await import('./export/check-cli.js')).mainCheck(rest, { io });
5562
if (command === 'help' || command === '--help' || command === '-h') {
5663
io.out(USAGE);
5764
return 0;
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `export-gallery --check DIR` — the command line of the gallery check
5+
* (`check.ts`). Two doors reach it with the same arguments:
6+
*
7+
* pnpm --filter @objectstack/scenarios export-gallery --check DIR (a checkout; `cli.ts` hands it here)
8+
* npx @objectstack/scenarios export-gallery --check DIR (the published bin, `bin.ts`)
9+
*
10+
* It prints one line per finding (`CODE PATH: MESSAGE`) and a closing count,
11+
* and answers 1 when there is any finding, 0 on a clean tree, 2 on a usage
12+
* error. The published versions of `@objectstack/cli` come from npm unless a
13+
* test hands a list.
14+
*
15+
* Kept apart from `cli.ts` on purpose: that file loads the runner and the
16+
* browser for the export, which a check pays nothing for.
17+
*/
18+
19+
import { existsSync, statSync } from 'node:fs';
20+
import { resolve } from 'node:path';
21+
22+
import { checkGallery, npmPublishedVersions, renderFinding } from './check.js';
23+
24+
export const CHECK_USAGE = 'Usage: export-gallery --check DIR check a gallery tree (index.json, apps/) before it is merged';
25+
26+
export interface CheckCliIo {
27+
out: (line: string) => void;
28+
err: (line: string) => void;
29+
}
30+
31+
export interface CheckCliDeps {
32+
io?: CheckCliIo;
33+
/** Every published `@objectstack/cli` version; default `npm view`. */
34+
publishedVersions?: () => readonly string[];
35+
}
36+
37+
const consoleIo: CheckCliIo = { out: (l) => console.log(l), err: (l) => console.error(l) };
38+
39+
/** True when this argument list asks for the check (`--check` anywhere in it). */
40+
export const asksForCheck = (argv: readonly string[]): boolean => argv.includes('--check');
41+
42+
export function mainCheck(argv: readonly string[], deps: CheckCliDeps = {}): number {
43+
const io = deps.io ?? consoleIo;
44+
if (argv.length !== 2 || argv[0] !== '--check') {
45+
io.err(argv[0] === '--check' && argv.length < 2 ? '--check needs a value: the gallery directory' : '--check DIR takes no other flag: the export itself runs from a checkout (pnpm --filter @objectstack/scenarios export-gallery)');
46+
io.err(CHECK_USAGE);
47+
return 2;
48+
}
49+
const dir = resolve(argv[1]!);
50+
if (!existsSync(dir) || !statSync(dir).isDirectory()) {
51+
io.err(`--check ${argv[1]} is not a directory`);
52+
return 2;
53+
}
54+
const findings = checkGallery(dir, { publishedVersions: deps.publishedVersions ?? (() => npmPublishedVersions()) });
55+
for (const f of findings) io.out(renderFinding(f));
56+
io.out(findings.length === 0 ? `gallery check: clean · ${dir}` : `gallery check: ${findings.length} finding(s) · ${dir}`);
57+
return findings.length === 0 ? 0 : 1;
58+
}
Lines changed: 223 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,223 @@
1+
// Copyright (c) 2026 ObjectStack. Licensed under the Apache-2.0 license.
2+
3+
/**
4+
* `export-gallery --check DIR` — the mechanical check a PR on
5+
* objectstack-ai/gallery must pass before anyone merges it. It reads a gallery
6+
* tree and answers one finding per defect; an empty answer is a clean tree.
7+
* It writes nothing and needs no checkout of this repository.
8+
*
9+
* It lives beside the exporter and reads the exporter's own contract — the
10+
* index schema (`manifest.ts`), the app layout and the stable-version shape
11+
* (`contract.ts`) — so what the exporter writes and what the check accepts
12+
* cannot drift. The findings, each with its code:
13+
*
14+
* index-json `index.json` is not JSON, or does not match
15+
* {@link GalleryIndexSchema} (one finding per schema issue).
16+
* An absent `index.json` is an empty gallery, as the
17+
* exporter and the refresh read it.
18+
* app-missing an entry has no `apps/<id>/` directory.
19+
* app-incomplete an entry's directory lacks a file of {@link APP_LAYOUT}:
20+
* the README, the run record, the scorecard and the
21+
* transcript (JSON and Markdown each), and a `source/` tree
22+
* holding `objectstack.config.ts`.
23+
* app-screenshots an entry lists no screenshot, a listed screenshot is not
24+
* on disk, or `screenshots/` holds a file the entry does
25+
* not list.
26+
* app-unlisted a directory under `apps/` that no entry lists (a
27+
* half-written export's staging directory included).
28+
* framework-version an entry's `framework.version` is not a stable
29+
* `MAJOR.MINOR.PATCH`, or not a version of
30+
* {@link FRAMEWORK_PACKAGE} published on npm.
31+
* env-file a file or directory whose name starts `.env`, anywhere.
32+
* node-modules a `node_modules` directory, anywhere.
33+
* symbolic-link a symbolic link, anywhere: the exporter writes none, and
34+
* the content scan below cannot see what one points at.
35+
* local-path a file whose text names an absolute path on a local
36+
* machine ({@link LOCAL_PATH}); the exporter redacts every
37+
* work directory to `$WORK`, so one that survives is a leak.
38+
*
39+
* Where the published versions come from is the caller's: the command line
40+
* asks npm (`npm view @objectstack/cli versions --json`, {@link npmPublishedVersions});
41+
* a test hands it a list. It is asked only when the index lists an app, so an
42+
* empty gallery is checked with no network at all.
43+
*
44+
* The content scan reads text files only. A file with a NUL byte in its first
45+
* 8 KiB is binary (the screenshots) and is not scanned; its name still is.
46+
*/
47+
48+
import { execFileSync } from 'node:child_process';
49+
import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
50+
import { join } from 'node:path';
51+
52+
import { APP_LAYOUT, FRAMEWORK_PACKAGE, STABLE_VERSION } from './contract.js';
53+
import { GalleryIndexSchema, type GalleryApp } from './manifest.js';
54+
55+
export const FINDING_CODES = [
56+
'index-json',
57+
'app-missing',
58+
'app-incomplete',
59+
'app-screenshots',
60+
'app-unlisted',
61+
'framework-version',
62+
'env-file',
63+
'node-modules',
64+
'symbolic-link',
65+
'local-path',
66+
] as const;
67+
export type FindingCode = (typeof FINDING_CODES)[number];
68+
69+
export interface GalleryFinding {
70+
/** Which finding it is, one of {@link FINDING_CODES}: a diagnostic code, never an ADR-0112 wire code. */
71+
kind: FindingCode;
72+
/** Relative to the gallery root, forward slashes; `.` for the root itself. */
73+
path: string;
74+
message: string;
75+
}
76+
77+
/** One finding as the command line prints it: `CODE PATH: MESSAGE`, one line. */
78+
export function renderFinding(f: GalleryFinding): string {
79+
return `${f.kind} ${f.path}: ${f.message.replace(/\s*\n\s*/g, ' ')}`;
80+
}
81+
82+
export interface CheckOptions {
83+
/** Every published version of {@link FRAMEWORK_PACKAGE}; called once, and only when the index lists an app. */
84+
publishedVersions: () => readonly string[];
85+
}
86+
87+
/** Every published version of `pkg` (`npm view`, so the caller's npm configuration applies). Throws when npm cannot answer. */
88+
export function npmPublishedVersions(pkg: string = FRAMEWORK_PACKAGE): string[] {
89+
const out = execFileSync('npm', ['view', pkg, 'versions', '--json'], { encoding: 'utf8', timeout: 120_000, stdio: ['ignore', 'pipe', 'pipe'] });
90+
const v = JSON.parse(out) as unknown;
91+
// npm answers a bare string when a package has exactly one version.
92+
const list = typeof v === 'string' ? [v] : v;
93+
if (!Array.isArray(list) || !list.every((x) => typeof x === 'string')) throw new Error(`npm view ${pkg} versions answered something that is not a version list`);
94+
return list;
95+
}
96+
97+
/**
98+
* An absolute path on a local machine: a home, temp or mount root on a POSIX
99+
* system, a `file://` URL, or a Windows drive path into its user or system
100+
* directories. The POSIX roots must start a path (nothing word-like or a slash
101+
* before them), so a URL path (`https://example.com/home/…`) and a route
102+
* (`/api/v1/…`) are not read as one.
103+
*/
104+
export const LOCAL_PATH =
105+
/(?<![\w.\/-])\/(?:home|Users|root|tmp|private|var\/folders|mnt|opt|srv|workspace)\/|file:\/\/\/|(?<![A-Za-z0-9])[A-Za-z]:\\{1,2}(?:Users|Windows|ProgramData|Program Files|Temp|Documents and Settings)\\/i;
106+
107+
const ENV_NAME = /^\.env/;
108+
const BINARY_PROBE = 8192;
109+
110+
/** The app files a complete `apps/<id>/` directory holds, besides `source/` and the screenshots. */
111+
const REQUIRED_FILES = [APP_LAYOUT.readme, APP_LAYOUT.runRecord, APP_LAYOUT.scorecardJson, APP_LAYOUT.scorecardMd, APP_LAYOUT.transcriptJson, APP_LAYOUT.transcriptMd] as const;
112+
113+
/** The file `source/` must hold: the project file the exporter refuses to export without (`source.ts`). */
114+
const SOURCE_REQUIRED = 'objectstack.config.ts';
115+
116+
/** Records one finding of this kind. */
117+
type AddFinding = (kind: FindingCode, path: string, message: string) => void;
118+
119+
const isDir = (p: string) => existsSync(p) && lstatSync(p).isDirectory();
120+
const isFile = (p: string) => existsSync(p) && lstatSync(p).isFile();
121+
122+
/** Every finding on the gallery tree at `dir`, in a stable order: the index, the apps, then the tree scan. */
123+
export function checkGallery(dir: string, opts: CheckOptions): GalleryFinding[] {
124+
const findings: GalleryFinding[] = [];
125+
const add: AddFinding = (kind, path, message) => void findings.push({ kind, path, message });
126+
127+
// ── the index ────────────────────────────────────────────────────────────
128+
const indexFile = join(dir, 'index.json');
129+
let apps: GalleryApp[] | undefined = [];
130+
if (existsSync(indexFile)) {
131+
apps = undefined;
132+
let raw: unknown;
133+
try {
134+
raw = JSON.parse(readFileSync(indexFile, 'utf8'));
135+
} catch (e) {
136+
add('index-json', 'index.json', `not JSON: ${e instanceof Error ? e.message : String(e)}`);
137+
}
138+
if (raw !== undefined) {
139+
const parsed = GalleryIndexSchema.safeParse(raw);
140+
if (parsed.success) apps = parsed.data.apps;
141+
else for (const issue of parsed.error.issues) add('index-json', 'index.json', `${issue.path.length ? issue.path.join('.') : '(root)'}: ${issue.message}`);
142+
}
143+
}
144+
145+
// ── the apps the index lists, and the directories it does not ─────────────
146+
const appsDir = join(dir, 'apps');
147+
const appDirs = isDir(appsDir)
148+
? readdirSync(appsDir, { withFileTypes: true })
149+
.filter((e) => e.isDirectory())
150+
.map((e) => e.name)
151+
.sort()
152+
: [];
153+
if (apps !== undefined) {
154+
let published: ReadonlySet<string> | undefined;
155+
for (const app of apps) {
156+
const rel = `apps/${app.id}`;
157+
const root = join(dir, rel);
158+
if (!isDir(root)) {
159+
add('app-missing', rel, `index.json lists \`${app.id}\`, and there is no ${rel}/ directory`);
160+
} else {
161+
for (const f of REQUIRED_FILES) if (!isFile(join(root, f))) add('app-incomplete', `${rel}/${f}`, `a complete app directory holds ${f}, and this one has none`);
162+
if (!isFile(join(root, APP_LAYOUT.source, SOURCE_REQUIRED))) {
163+
add('app-incomplete', `${rel}/${APP_LAYOUT.source}`, `a complete app directory holds its metadata source, ${APP_LAYOUT.source}/${SOURCE_REQUIRED} at least, and this one does not`);
164+
}
165+
checkScreenshots(dir, app, add);
166+
}
167+
const v = app.framework.version;
168+
if (!STABLE_VERSION.test(v)) {
169+
add('framework-version', `index.json`, `${app.id}: framework.version \`${v}\` is not a stable MAJOR.MINOR.PATCH release of ${FRAMEWORK_PACKAGE}`);
170+
} else {
171+
published ??= new Set(opts.publishedVersions());
172+
if (!published.has(v)) add('framework-version', 'index.json', `${app.id}: framework.version \`${v}\` is not a published ${FRAMEWORK_PACKAGE} version`);
173+
}
174+
}
175+
const listed = new Set(apps.map((a) => a.id));
176+
for (const d of appDirs) if (!listed.has(d)) add('app-unlisted', `apps/${d}`, 'a directory under apps/ that index.json does not list');
177+
}
178+
179+
// ── the whole tree: stray files and local paths ──────────────────────────
180+
scanTree(dir, '', add);
181+
return findings;
182+
}
183+
184+
function checkScreenshots(dir: string, app: GalleryApp, add: AddFinding): void {
185+
const rel = `apps/${app.id}/${APP_LAYOUT.screenshots}`;
186+
if (app.screenshots.length === 0) add('app-screenshots', rel, `index.json lists no screenshot for \`${app.id}\`: an app is shown with its screenshots`);
187+
const listed = new Set(app.screenshots.map((s) => s.path));
188+
for (const s of app.screenshots) if (!isFile(join(dir, s.path))) add('app-screenshots', s.path, 'index.json lists this screenshot, and the file is not there');
189+
const shotsDir = join(dir, rel);
190+
if (!isDir(shotsDir)) return;
191+
for (const e of readdirSync(shotsDir, { withFileTypes: true })) {
192+
const p = `${rel}/${e.name}`;
193+
if (!listed.has(p)) add('app-screenshots', p, `index.json does not list this file for \`${app.id}\``);
194+
}
195+
}
196+
197+
function scanTree(root: string, rel: string, add: AddFinding): void {
198+
for (const e of readdirSync(join(root, rel), { withFileTypes: true }).sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0))) {
199+
const p = rel ? `${rel}/${e.name}` : e.name;
200+
if (!rel && e.name === '.git') continue;
201+
if (e.isSymbolicLink()) {
202+
add('symbolic-link', p, 'a symbolic link: the exporter writes none, and what it points at is not checked');
203+
continue;
204+
}
205+
if (ENV_NAME.test(e.name)) add('env-file', p, 'an environment file never belongs in the gallery');
206+
if (e.isDirectory()) {
207+
if (e.name === 'node_modules') add('node-modules', p, 'an installed dependency tree never belongs in the gallery');
208+
else scanTree(root, p, add);
209+
continue;
210+
}
211+
if (!e.isFile()) continue;
212+
const bytes = readFileSync(join(root, p));
213+
if (bytes.subarray(0, BINARY_PROBE).includes(0)) continue;
214+
const lines = bytes.toString('utf8').split('\n');
215+
for (let i = 0; i < lines.length; i++) {
216+
const m = LOCAL_PATH.exec(lines[i]!);
217+
if (m) {
218+
add('local-path', `${p}:${i + 1}`, `names a local absolute path (\`${m[0]}…\`); the exporter redacts every work directory to $WORK`);
219+
break; // one finding per file: the first line that names one
220+
}
221+
}
222+
}
223+
}

0 commit comments

Comments
 (0)