|
| 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