Supported-surface manifest per release

- 302 entries generated from the diagnostics registry and lowering tables
- Non-static entries carry their SC code; compilerVersion is the release string
- Committed, shipped in the compiler package, attached to GitHub releases
- Sampling harness asserts refusal codes equal listed codes
This commit is contained in:
Chris Tate
2026-07-22 22:29:39 -05:00
11 changed files with 2728 additions and 12 deletions
+34 -7
View File
@@ -138,23 +138,46 @@ jobs:
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
# The GitHub release is a tag and notes only — scriptc has no platform
# binary assets to stage (programs compile on the user's machine) — so
# it runs AFTER a successful npm publish and never gates it. The body is
# the CHANGELOG.md block between the release:start/release:end markers,
# which RELEASING.md keeps on the latest entry only.
# The GitHub release is a tag, notes, and one asset: the surface
# manifest (packages/compiler/surface-manifest.json — the machine-
# readable listing of the surface the static tier compiles at this
# version, regenerated here and verified against the committed file).
# scriptc has no platform binary assets to stage (programs compile on
# the user's machine), so the job runs AFTER a successful npm publish
# and never gates it. The body is the CHANGELOG.md block between the
# release:start/release:end markers, which RELEASING.md keeps on the
# latest entry only.
github-release:
name: Create GitHub Release
needs: [check-release, publish]
if: needs.check-release.outputs.should_release == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
timeout-minutes: 10
permissions:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@v4
- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 11
- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "24"
# Regenerate the surface manifest from this tree and require it to
# match the committed file byte-for-byte — the same staleness guard
# the test suite runs — so the attached asset is provably the
# manifest of the code being released.
- name: Generate surface manifest
run: |
pnpm install --frozen-lockfile
pnpm manifest --check
- name: Extract changelog entry
run: |
VERSION="${{ needs.check-release.outputs.version }}"
@@ -173,7 +196,7 @@ jobs:
TAG="v$VERSION"
if gh release view "$TAG" &>/dev/null; then
echo "Release $TAG already exists, skipping"
echo "Release $TAG already exists, skipping creation"
else
echo "Creating release $TAG..."
gh release create "$TAG" \
@@ -181,5 +204,9 @@ jobs:
--title "$TAG" \
--notes-file /tmp/release-notes.md
fi
# Attach the surface manifest (idempotent: --clobber makes
# re-runs replace the asset instead of failing).
gh release upload "$TAG" packages/compiler/surface-manifest.json --clobber
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+4
View File
@@ -4,6 +4,10 @@ All notable changes to scriptc will be documented in this file.
## Unreleased
### Features
- **Surface manifest**: each release now ships a machine-readable `surface-manifest.json` — the language and stdlib surface the static tier compiles at that version, with stable per-entry ids so tooling can diff two releases mechanically. Every non-static entry carries the diagnostic code the compiler raises for it. Attached to the GitHub release and shipped inside `@scriptc/compiler` as `@scriptc/compiler/surface-manifest.json`; regenerate with `pnpm manifest`.
## 0.0.2
<!-- release:start -->
+3 -3
View File
@@ -5,14 +5,14 @@ Releases are manual, single-commit affairs. The maintainer controls the changelo
To prepare a release:
1. Bump the version in `packages/cli/package.json`
2. Run `node scripts/sync-versions.mjs` to stamp the same version into `packages/runtime` and `packages/compiler`
2. Run `node scripts/sync-versions.mjs` to stamp the same version into `packages/runtime` and `packages/compiler`, then `pnpm manifest` to restamp `packages/compiler/surface-manifest.json` with the new version, and commit both (the test suite's staleness guard fails on a version drift)
3. Fold the `## Unreleased` section of `CHANGELOG.md` into a new `## <version>` entry (newest first, below `## Unreleased`), and leave `## Unreleased` empty for the next cycle
4. Wrap the new entry in `<!-- release:start -->` and `<!-- release:end -->` markers; this marked block is also the GitHub release body
5. Remove the `<!-- release:start -->` and `<!-- release:end -->` markers from the previous release entry; only the latest release should have markers
6. Commit to `main`
CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds the workspace, verifies all three package versions match (a mismatch fails with a hint to run `scripts/sync-versions.mjs`), and publishes to npm in dependency order — `@scriptc/runtime`, then `@scriptc/compiler`, then `scriptc` — so each package's dependencies are resolvable the moment it lands. After the publish succeeds, a separate job creates the git tag `v<version>` and the GitHub release with the marked changelog entry as its body.
CI (`.github/workflows/release.yml`) compares the version in `packages/cli/package.json` to what `scriptc` has on npm. If it differs, it builds the workspace, verifies all three package versions match (a mismatch fails with a hint to run `scripts/sync-versions.mjs`), and publishes to npm in dependency order — `@scriptc/runtime`, then `@scriptc/compiler`, then `scriptc` — so each package's dependencies are resolvable the moment it lands. After the publish succeeds, a separate job creates the git tag `v<version>` and the GitHub release with the marked changelog entry as its body, and attaches `surface-manifest.json` — the machine-readable listing of the surface the static tier compiles at that version (stable per-entry ids, so two releases diff mechanically; see `packages/compiler/src/coverage/surface-manifest.ts` for the schema). The job regenerates the manifest from the tree and fails on any byte difference from the committed file before attaching, so the asset is always the manifest of the code being released. The same file ships inside the `@scriptc/compiler` package as `@scriptc/compiler/surface-manifest.json`.
Two deliberate differences from repositories that ship prebuilt binaries: there are no platform binary assets to build or stage — scriptc compiles programs on the user's machine with the local clang — so the GitHub release is a tag and release notes only, and the npm publish never waits on the GitHub release (the release job runs after the publish, not before it).
Two deliberate differences from repositories that ship prebuilt binaries: there are no platform binary assets to build or stage — scriptc compiles programs on the user's machine with the local clang — so the GitHub release is a tag, release notes, and the manifest asset only, and the npm publish never waits on the GitHub release (the release job runs after the publish, not before it).
Publishing uses npm trusted publishing (OIDC) — there is no npm token secret. The one-time setup is already done: each of the three packages is configured on npmjs.com with a GitHub Actions trusted publisher pointing at repository `vercel-labs/scriptc`, workflow `release.yml`, environment `Release`. A package missing that configuration fails with an OIDC authentication error before anything is uploaded. Re-runs are safe: any package already on the registry at the target version is skipped, so a partially published release can be resumed by re-running the workflow.
+1
View File
@@ -11,6 +11,7 @@
"test:cache-identity": "node tests/harness/cache-identity.mjs",
"test:watch": "vitest",
"lint": "eslint packages/compiler/src packages/cli/src",
"manifest": "tsx scripts/surface-manifest.mjs",
"scriptc": "tsx packages/cli/src/main.ts"
},
"devDependencies": {
+4 -2
View File
@@ -16,11 +16,13 @@
".": "./dist/index.js",
"./scriptc.d.ts": "./ambient/scriptc.d.ts",
"./scriptc-overrides.d.ts": "./ambient/scriptc-overrides.d.ts",
"./scriptc-node-fallback.d.ts": "./ambient/scriptc-node-fallback.d.ts"
"./scriptc-node-fallback.d.ts": "./ambient/scriptc-node-fallback.d.ts",
"./surface-manifest.json": "./surface-manifest.json"
},
"files": [
"dist",
"ambient"
"ambient",
"surface-manifest.json"
],
"publishConfig": {
"access": "public"
@@ -0,0 +1,341 @@
/* The machine-readable surface manifest: what the static tier compiles at
* this compiler version, projected MECHANICALLY from the tables that
* already decide support — the diagnostics registry (diagnostic.ts), the
* unsupported-syntax dispatch tables and stdlib/builtin lowering tables
* (lowering/surfaces.ts), and the supported-builtin-module list
* (frontend/shared.ts). Nothing here is hand-maintained: adding a table
* row changes the manifest on the next generation, and the staleness test
* (tests/harness/surface-manifest.test.ts) fails until the committed file
* is regenerated (`pnpm manifest`).
*
* Schema (schemaVersion 1 — additive changes only; removals or meaning
* changes bump the number):
* {
* schemaVersion: 1,
* compilerVersion: string, // the exact published release version —
* // a version pin matches this string
* coverage: string[], // what is and is not projected (the
* // manifest's own scope statement)
* entries: [{
* id: string, // STABLE diff key: dotted path, lowercase
* // kebab segments except verbatim member
* // names ("stdlib.string.charCodeAt")
* kind: "syntax" | "stdlib" | "node-builtin" | "diagnostic-fence",
* name: string, // human-readable surface name
* status: "static" | "dynamic-only" | "unsupported",
* code?: "SC____", // REQUIRED on every non-static entry:
* // the diagnostic code the compiler
* // raises where the refusal applies
* note?: string, // constraints and remediation prose
* }]
* }
*
* Two properties are contract, not incidental shape: (1) compilerVersion
* is the exact published version string, so external tooling can match a
* pinned release; (2) every entry whose status is not "static" carries
* the diagnostic code the compiler actually raises for it — the sampling
* harness asserts refusals match entry codes. renderSurfaceManifest is
* byte-deterministic: entries sort by id, keys are emitted in one fixed
* order, and the output carries no timestamps or absolute paths. */
import { FENCE_CODES, UNSUPPORTED } from "../diagnostics/diagnostic.js";
import { SUPPORTED_BUILTIN_MODULES, SUPPORTED_NODE_MODULES } from "../frontend/shared.js";
import {
ARRAY_METHODS,
BUILTIN_MODULE_CONSTS,
BUILTIN_MODULE_FENCE_HINTS,
BUILTIN_MODULE_FNS,
COMPOUND_ASSIGN_OPS,
ISLAND_SURFACE,
MAP_METHODS,
SET_COMBINE_METHODS,
SET_METHODS,
STATIC_MATH_FNS,
STR_METHODS,
UNSUPPORTED_EXPR,
UNSUPPORTED_STMT,
} from "../frontend/lowering/surfaces.js";
export type SurfaceEntryKind = "syntax" | "stdlib" | "node-builtin" | "diagnostic-fence";
export type SurfaceEntryStatus = "static" | "dynamic-only" | "unsupported";
export interface SurfaceManifestEntry {
id: string;
kind: SurfaceEntryKind;
name: string;
status: SurfaceEntryStatus;
code?: string;
note?: string;
}
export interface SurfaceManifest {
schemaVersion: 1;
compilerVersion: string;
coverage: string[];
entries: SurfaceManifestEntry[];
}
export const MANIFEST_SCHEMA_VERSION = 1;
/** The manifest's own scope statement: what the projection covers and —
* just as load-bearing — what it deliberately leaves out, so absence is
* never read as a support claim in either direction. */
const COVERAGE_NOTES: string[] = [
"Entries are projected mechanically from the compiler's own decision tables: the diagnostics registry, the unsupported-syntax dispatch tables, the stdlib and node-builtin lowering tables, and the supported-builtin-module list. Nothing is hand-maintained; the manifest regenerates byte-identically from the source tree at this version.",
"Absence from this manifest means 'not projected', never 'unsupported'. Surfaces lowered through dedicated code paths rather than tables are not yet projected: console, JSON, process, Promise/async and the timer surface, the net/http/tls/https/dgram/dns/assert/test/stream/readline module member surfaces, template literals, the regex slice, and global functions (parseInt, parseFloat, isNaN, isFinite).",
"stdlib and node-builtin member entries name surface whose LOWERED call forms are constrained (arity, argument shapes); declared call forms outside the lowered set are refused per site, with code SC2020 for standard-library and node-builtin surface.",
"Entries with status 'unsupported' or 'dynamic-only' describe where the named code is raised: forms of the construct outside the supported subset are refused with that code — not that every form of the named feature is refused. Supported forms appear as their own static entries where a table projects them.",
"Entries with status 'dynamic-only' compile when the build embeds the dynamic engine (--dynamic); without the flag each use site is refused with the entry's code.",
"Process-level diagnostic codes are not surface entries: SC0001-SC0004 are preflight gates, SC1110 is a comptime evaluation failure, SC3001 is the alternate-backend tier refusal, SC9001/SC9002 are internal errors.",
"No scheduling metadata is published; entry ids are the stable diff keys across releases.",
];
/** Stable kebab id segment from prose: quotes dropped, the parenthetical
* tail dropped, non-alphanumeric runs collapsed to one dash. */
function kebab(text: string): string {
return text
.split(" (")[0]!
.toLowerCase()
.replace(/['"‘’“”]/g, "")
.replace(/[^a-z0-9]+/g, "-")
.replace(/^-|-$/g, "");
}
/** Compound-assignment operator ids: spelled names, never punctuation. */
const COMPOUND_OP_NAMES: Record<string, string> = {
"+": "plus",
"-": "minus",
"*": "times",
"/": "divide",
"%": "remainder",
"**": "exponent",
"&": "bitwise-and",
"|": "bitwise-or",
"^": "bitwise-xor",
"<<": "shift-left",
">>": "shift-right",
">>>": "shift-right-unsigned",
};
function arityNote(min: number, max: number): string {
if (min === max) {
return min === 0
? "the lowered call form takes no arguments"
: `the lowered call form takes exactly ${min} argument${min === 1 ? "" : "s"}`;
}
return `the lowered call form takes ${min} to ${max} arguments`;
}
export function generateSurfaceManifest(compilerVersion: string): SurfaceManifest {
const entries: SurfaceManifestEntry[] = [];
const add = (e: SurfaceManifestEntry): void => {
entries.push(e);
};
// ── syntax: the unsupported-syntax dispatch tables ────────────────────
// Keys are numeric SyntaxKind values whose reverse mapping is unreliable
// (First*/Last* aliases win), so ids derive from the entry's own feature
// text — the registry feature when the row carries no override.
for (const table of [UNSUPPORTED_STMT, UNSUPPORTED_EXPR]) {
for (const entry of Object.values(table)) {
const name = entry.feature ?? UNSUPPORTED[entry.code]!.feature;
add({
id: `syntax.${kebab(name)}`,
kind: "syntax",
name,
status: "unsupported",
code: entry.code,
});
}
}
// ── syntax: compound assignment (the one positive syntax table) ───────
for (const op of Object.values(COMPOUND_ASSIGN_OPS)) {
add({
id: `syntax.compound-assignment.${COMPOUND_OP_NAMES[op]!}`,
kind: "syntax",
name: `compound assignment operator '${op}='`,
status: "static",
note: `compiles over the operand types the '${op}' operator supports`,
});
}
// ── stdlib: string / array / map / set method surfaces ────────────────
for (const [name, entry] of Object.entries(STR_METHODS)) {
add({
id: `stdlib.string.${name}`,
kind: "stdlib",
name: `string.prototype.${name}`,
status: "static",
note: arityNote(entry.minArgs, entry.maxArgs),
});
}
for (const name of [...ARRAY_METHODS]) {
add({ id: `stdlib.array.${name}`, kind: "stdlib", name: `Array.prototype.${name}`, status: "static" });
}
for (const name of [...MAP_METHODS]) {
add({ id: `stdlib.map.${name}`, kind: "stdlib", name: `Map.prototype.${name}`, status: "static" });
}
for (const name of [...SET_METHODS]) {
add({ id: `stdlib.set.${name}`, kind: "stdlib", name: `Set.prototype.${name}`, status: "static" });
}
for (const name of [...SET_COMBINE_METHODS]) {
add({
id: `stdlib.set.${name}`,
kind: "stdlib",
name: `Set.prototype.${name}`,
status: "static",
note: "compiles over Set receivers with Set arguments (the general ReadonlySetLike argument forms are refused per site)",
});
}
// ── stdlib: the Math surface — static table first, island remainder ───
const mathNames = new Set([...Object.keys(STATIC_MATH_FNS), ...Object.keys(ISLAND_SURFACE.math.fns)]);
for (const name of mathNames) {
const stat = STATIC_MATH_FNS[name];
const island = ISLAND_SURFACE.math.fns[name];
if (stat !== undefined) {
add({
id: `stdlib.math.${name}`,
kind: "stdlib",
name: `Math.${name}`,
status: "static",
// Only the island overlap earns a tail: the tables prove where the
// untabled arities go there. Elsewhere untabled shapes may have
// their own special-cased lowerings (Math.min/max over one array
// spread), so no claim is made about them.
note:
`compiles statically at arity ${stat.arity}` +
(island !== undefined
? "; other declared call shapes run only in the embedded dynamic engine (SC2012 without --dynamic)"
: ""),
});
} else {
add({ id: `stdlib.math.${name}`, kind: "stdlib", name: `Math.${name}`, status: "dynamic-only", code: "SC2012" });
}
}
for (const name of Object.keys(ISLAND_SURFACE.math.props)) {
add({ id: `stdlib.math.${name}`, kind: "stdlib", name: `Math.${name}`, status: "dynamic-only", code: "SC2012" });
}
for (const name of Object.keys(ISLAND_SURFACE.number)) {
add({
id: `stdlib.number.${name}`,
kind: "stdlib",
name: `number.prototype.${name}`,
status: "dynamic-only",
code: "SC2012",
});
}
for (const name of Object.keys(ISLAND_SURFACE.string)) {
add({
id: `stdlib.string.${name}`,
kind: "stdlib",
name: `string.prototype.${name}`,
status: "dynamic-only",
code: "SC2012",
});
}
// ISLAND_SURFACE.globals (parseFloat, isFinite) is deliberately NOT
// projected: those globals also lower statically over exactly-typed
// arguments through untabled call paths, so neither status would be
// truthful from the island table alone — the coverage notes carry the
// exclusion.
// ── node-builtin: modules, members, constants, named fences ───────────
const moduleId = (mod: string): string => `node-builtin.${mod.split("/").join(".")}`;
for (const mod of SUPPORTED_BUILTIN_MODULES) {
const prefixOnly = !SUPPORTED_NODE_MODULES.includes(mod);
add({
id: moduleId(mod),
kind: "node-builtin",
name: prefixOnly ? `node:${mod}` : mod,
status: "static",
note: prefixOnly
? "recognized module (node:-prefixed specifier only, matching Node)"
: "recognized module (bare and node:-prefixed specifiers)",
});
}
for (const [mod, members] of Object.entries(BUILTIN_MODULE_FNS)) {
for (const member of Object.keys(members!)) {
add({
id: `${moduleId(mod)}.${member}`,
kind: "node-builtin",
name: `${mod}.${member}`,
status: "static",
});
}
}
for (const [mod, members] of Object.entries(BUILTIN_MODULE_CONSTS)) {
for (const member of Object.keys(members!)) {
add({
id: `${moduleId(mod)}.${member}`,
kind: "node-builtin",
name: `${mod}.${member}`,
status: "static",
note: "constant value read",
});
}
}
for (const [mod, members] of Object.entries(BUILTIN_MODULE_FENCE_HINTS)) {
for (const [member, hint] of Object.entries(members!)) {
add({
id: `${moduleId(mod)}.${member}`,
kind: "node-builtin",
name: `${mod}.${member}`,
status: "unsupported",
code: "SC2020",
...(hint !== undefined ? { note: hint } : {}),
});
}
}
// ── diagnostic-fence: the enumerable refusal codes ─────────────────────
for (const [code, entry] of Object.entries(UNSUPPORTED)) {
add({
id: `diagnostic.${code.toLowerCase()}`,
kind: "diagnostic-fence",
name: entry.feature,
status: "unsupported",
code,
...(entry.hint !== undefined ? { note: entry.hint } : {}),
});
}
for (const [code, entry] of Object.entries(FENCE_CODES)) {
if (code in UNSUPPORTED) throw new Error(`diagnostic code ${code} is in both UNSUPPORTED and FENCE_CODES`);
add({
id: `diagnostic.${code.toLowerCase()}`,
kind: "diagnostic-fence",
name: entry.name,
status: entry.status,
code,
});
}
// ── invariants, then the stable order ──────────────────────────────────
const seen = new Set<string>();
for (const e of entries) {
if (seen.has(e.id)) throw new Error(`duplicate manifest id: ${e.id}`);
seen.add(e.id);
if (e.status !== "static" && e.code === undefined) {
throw new Error(`manifest entry ${e.id} has status ${e.status} but no diagnostic code`);
}
if (e.code !== undefined && !/^SC\d{4}$/.test(e.code)) {
throw new Error(`manifest entry ${e.id} has a malformed code: ${e.code}`);
}
}
entries.sort((a, b) => (a.id < b.id ? -1 : a.id > b.id ? 1 : 0));
return {
schemaVersion: MANIFEST_SCHEMA_VERSION,
compilerVersion,
coverage: COVERAGE_NOTES,
entries,
};
}
/** The manifest's one serialization: key order fixed by construction,
* two-space indent, trailing newline — byte-identical for the same tree
* and version on every platform. */
export function renderSurfaceManifest(manifest: SurfaceManifest): string {
return JSON.stringify(manifest, null, 2) + "\n";
}
@@ -170,6 +170,31 @@ export const UNSUPPORTED: Record<string, UnsupportedEntry> = {
},
};
/** The construct-fence codes MINTED BY FACTORY FUNCTIONS below (never keys
* of UNSUPPORTED): the other half of the enumerable code registry, indexed
* so the surface manifest (coverage/surface-manifest.ts) can catalog every
* refusal code a program's constructs can meet. `status` says what the
* refusal means for the construct: "dynamic-only" codes mark constructs
* that RUN under --dynamic (the report's dynamic-capable family —
* SC2010/SC2011/SC2012/SC2013); "unsupported" codes mark constructs with
* no lowering on either tier. Process-level codes are deliberately absent:
* preflight gates (SC0001–SC0004), comptime evaluation failures (SC1110),
* the alternate-backend tier refusal (SC3001), and internal errors
* (SC9001/SC9002) report problems or engine tiers, not language/stdlib
* surface. */
export const FENCE_CODES: Record<string, { name: string; status: "unsupported" | "dynamic-only" }> = {
SC2001: { name: "values of types outside the compilable set", status: "unsupported" },
SC2002: { name: "record width subtyping (shapes must match exactly)", status: "unsupported" },
SC2003: { name: "union-to-union conversions outside the re-tagging rule", status: "unsupported" },
SC2004: { name: "uses of a binding whose declaration did not compile (cascade marker)", status: "unsupported" },
SC2010: { name: "constructs that exist only in the embedded dynamic engine", status: "dynamic-only" },
SC2011: { name: "'any'-typed values and the operations on them", status: "dynamic-only" },
SC2012: { name: "standard-library surface that runs in the embedded dynamic engine", status: "dynamic-only" },
SC2013: { name: "npm package imports and values (the package's implementation runs in the embedded engine)", status: "dynamic-only" },
SC2020: { name: "standard-library or @types/node surface with no lowering", status: "unsupported" },
SC2030: { name: "npm package code that cannot be embedded for the dynamic engine", status: "unsupported" },
};
export function unsupportedDiag(
code: keyof typeof UNSUPPORTED & `SC${number}`,
loc: SrcLoc,
+7
View File
@@ -22,6 +22,13 @@ export { emitModule } from "./backend/emission/emitter.js";
export type { ScrDiagnostic } from "./diagnostics/diagnostic.js";
export { renderAll, renderDiagnostic } from "./diagnostics/render.js";
export { renderCoverage, type CoverageInput } from "./coverage/report.js";
export {
generateSurfaceManifest,
renderSurfaceManifest,
MANIFEST_SCHEMA_VERSION,
type SurfaceManifest,
type SurfaceManifestEntry,
} from "./coverage/surface-manifest.js";
export { validateModule } from "./ir/validate.js";
export { ISLAND_SURFACE, type IslandFnEntry } from "./frontend/lowering/surfaces.js";
export { ambientDtsPath, overridesDtsPath } from "./frontend/program.js";
File diff suppressed because it is too large Load Diff
+67
View File
@@ -0,0 +1,67 @@
#!/usr/bin/env tsx
// Generates packages/compiler/surface-manifest.json — the machine-readable
// manifest of the language and stdlib surface the static tier compiles at
// this release, with stable per-entry ids so tooling can diff two releases
// mechanically. The schema and the projection sources are documented in
// packages/compiler/src/coverage/surface-manifest.ts; this script owns the
// version spine and the file lifecycle.
//
// The version spine: compilerVersion is the EXACT published release version
// — the version release.yml keys on (packages/cli/package.json, the
// `scriptc` package) — so an external version pin matches the manifest's
// string verbatim. All three workspace packages must agree (the same check
// the release workflow runs); a drifted tree fails generation instead of
// stamping an ambiguous version.
//
// Usage:
// pnpm manifest regenerate packages/compiler/surface-manifest.json
// pnpm manifest --check verify the committed file matches a fresh
// generation (exit 1 on drift) without writing
//
// Runs under tsx (it imports the compiler's TypeScript sources directly, no
// build step) — `pnpm manifest` wires that up.
import { readFileSync, writeFileSync } from "node:fs";
import { fileURLToPath } from "node:url";
import { generateSurfaceManifest, renderSurfaceManifest } from "../packages/compiler/src/coverage/surface-manifest.ts";
const root = fileURLToPath(new URL("..", import.meta.url));
const readJson = (path) => JSON.parse(readFileSync(root + path, "utf8"));
const version = readJson("packages/cli/package.json").version;
for (const pkg of ["runtime", "compiler"]) {
const v = readJson(`packages/${pkg}/package.json`).version;
if (v !== version) {
console.error(
`version mismatch: packages/${pkg} is ${v}, packages/cli is ${version} — run 'node scripts/sync-versions.mjs' first`,
);
process.exit(1);
}
}
const outPath = root + "packages/compiler/surface-manifest.json";
const manifest = generateSurfaceManifest(version);
const rendered = renderSurfaceManifest(manifest);
const counts = new Map();
for (const e of manifest.entries) {
const key = `${e.kind}/${e.status}`;
counts.set(key, (counts.get(key) ?? 0) + 1);
}
if (process.argv.includes("--check")) {
let committed = null;
try {
committed = readFileSync(outPath, "utf8");
} catch {
// fall through to the drift report
}
if (committed !== rendered) {
console.error("surface-manifest.json is stale — run 'pnpm manifest' and commit the result");
process.exit(1);
}
console.log(`surface-manifest.json is current (${manifest.entries.length} entries, version ${version})`);
} else {
writeFileSync(outPath, rendered);
console.log(`wrote packages/compiler/surface-manifest.json (version ${version}, ${manifest.entries.length} entries)`);
for (const [key, n] of [...counts].sort()) console.log(` ${key}: ${n}`);
}
+190
View File
@@ -0,0 +1,190 @@
/* The surface manifest is a shipped artifact with two contract properties:
* the version spine is the exact published release version, and every
* non-static entry carries the diagnostic code the compiler actually
* raises for it. This suite holds the manifest to both:
*
* - staleness guard: the committed packages/compiler/surface-manifest.json
* regenerates byte-identically from the current tree (a table edit that
* changes the projection fails here until `pnpm manifest` is rerun and
* the result committed);
* - schema and determinism: unique sorted ids, valid kind/status values,
* a code on every non-static entry, identical output across generations;
* - the sampling harness: N probe programs per status class, checked
* against the compiler itself — listed-static entries COMPILE, and for
* every sampled non-static entry the refusal's diagnostic code equals
* the entry's code (dynamic-only entries additionally analyze clean
* under --dynamic). The probes don't exercise every entry; they exist
* to catch the manifest lying.
*/
import { mkdirSync, mkdtempSync, readFileSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { describe, expect, test } from "vitest";
import {
analyze,
compile,
generateSurfaceManifest,
renderAll,
renderSurfaceManifest,
type SurfaceManifest,
} from "@scriptc/compiler";
const repoRoot = join(import.meta.dirname, "../..");
const manifestPath = join(repoRoot, "packages/compiler/surface-manifest.json");
const readJson = (p: string): { version: string } =>
JSON.parse(readFileSync(join(repoRoot, p), "utf8")) as { version: string };
// The version spine: the release workflow keys on packages/cli (the
// published `scriptc` package); sync-versions.mjs stamps the same string
// into runtime and compiler.
const releaseVersion = readJson("packages/cli/package.json").version;
const committed = readFileSync(manifestPath, "utf8");
const manifest = generateSurfaceManifest(releaseVersion);
const entryById = new Map(manifest.entries.map((e) => [e.id, e]));
describe("surface manifest generation", () => {
test("the committed manifest regenerates byte-identically (staleness guard)", () => {
expect(
renderSurfaceManifest(manifest),
"packages/compiler/surface-manifest.json is stale — run 'pnpm manifest' and commit the result",
).toBe(committed);
});
test("generation is deterministic", () => {
expect(renderSurfaceManifest(generateSurfaceManifest(releaseVersion))).toBe(
renderSurfaceManifest(manifest),
);
});
test("the version spine is the exact published version string", () => {
const parsed = JSON.parse(committed) as SurfaceManifest;
expect(parsed.compilerVersion).toBe(releaseVersion);
// The three packages publish in lockstep; a drifted stamp would make
// the spine ambiguous for a version pin.
expect(readJson("packages/compiler/package.json").version).toBe(releaseVersion);
expect(readJson("packages/runtime/package.json").version).toBe(releaseVersion);
});
test("schema: ids unique and sorted, enums valid, codes on every non-static entry", () => {
expect(manifest.schemaVersion).toBe(1);
expect(manifest.entries.length).toBeGreaterThan(0);
const ids = manifest.entries.map((e) => e.id);
expect(new Set(ids).size).toBe(ids.length);
expect([...ids].sort()).toEqual(ids);
for (const e of manifest.entries) {
expect(e.id).toMatch(/^[a-z][a-z0-9-]*(\.[A-Za-z0-9$_-]+)+$/);
expect(["syntax", "stdlib", "node-builtin", "diagnostic-fence"]).toContain(e.kind);
expect(["static", "dynamic-only", "unsupported"]).toContain(e.status);
if (e.status !== "static") {
expect(e.code, `${e.id}: non-static entries must carry their diagnostic code`).toMatch(
/^SC\d{4}$/,
);
}
}
// Every kind and every status class is populated — an empty class
// means a projection source silently dropped out.
for (const kind of ["syntax", "stdlib", "node-builtin", "diagnostic-fence"]) {
expect(manifest.entries.some((e) => e.kind === kind), `no ${kind} entries`).toBe(true);
}
for (const status of ["static", "dynamic-only", "unsupported"]) {
expect(manifest.entries.some((e) => e.status === status), `no ${status} entries`).toBe(true);
}
});
});
/* ── the sampling harness ────────────────────────────────────────────────
* Each probe is one program exercising exactly one manifest entry. The
* EXPECTATION is not written here — it is read from the manifest, so a
* probe checks what the manifest CLAIMS: a status flip, a code change, or
* an id rename in the manifest fails the probe until both agree again. */
interface Probe {
id: string;
source: string;
}
const PROBES: Probe[] = [
// status static — these must compile to a binary
{ id: "syntax.compound-assignment.plus", source: 'let x = 1;\nx += 2;\nconsole.log(x);\n' },
{ id: "stdlib.string.charCodeAt", source: 'console.log("abc".charCodeAt(0));\n' },
{ id: "stdlib.array.push", source: "const xs: number[] = [1];\nxs.push(2);\nconsole.log(xs.length);\n" },
{ id: "stdlib.math.floor", source: "console.log(Math.floor(1.5));\n" },
{ id: "stdlib.map.has", source: 'const m = new Map<string, number>();\nm.set("a", 1);\nconsole.log(m.has("a"));\n' },
{ id: "node-builtin.path.join", source: 'import { join } from "node:path";\nconsole.log(join("a", "b"));\n' },
{ id: "node-builtin.os.EOL", source: 'import { EOL } from "node:os";\nconsole.log(EOL.length);\n' },
// status dynamic-only — refused with the entry's code statically,
// analyzed clean under --dynamic
{ id: "stdlib.math.sqrt", source: "console.log(Math.sqrt(2));\n" },
{ id: "stdlib.math.PI", source: "console.log(Math.PI);\n" },
{ id: "stdlib.number.toFixed", source: "const n = 1.2345;\nconsole.log(n.toFixed(2));\n" },
{ id: "stdlib.string.replace", source: 'console.log("aa".replace("a", "b"));\n' },
{ id: "diagnostic.sc2011", source: "const y: any = 1;\nconst z = y * 2;\nconsole.log(0);\n" },
// status unsupported — refused with the entry's code
{ id: "syntax.debugger-statements", source: "debugger;\nconsole.log(0);\n" },
{
id: "syntax.delete-expressions",
source:
'type Hybrid = { base: string; [k: string]: string };\nconst h: Hybrid = { base: "b", extra: "e" };\ndelete h["extra"];\nconsole.log(h.base);\n',
},
{ id: "diagnostic.sc1031", source: "class C {\n f = 1;\n}\nconst { f } = new C();\nconsole.log(0);\n" },
{ id: "diagnostic.sc1121", source: 'console.log(/ab/g.test("abab"));\n' },
{
id: "node-builtin.zlib.gzipSync",
source: 'import { gzipSync } from "node:zlib";\ngzipSync("data");\nconsole.log(0);\n',
},
];
const probeRoot = mkdtempSync(join(tmpdir(), "scr-surface-manifest-"));
function probeFile(probe: Probe): string {
const dir = join(probeRoot, probe.id.replace(/[^A-Za-z0-9]+/g, "-"));
mkdirSync(dir, { recursive: true });
const file = join(dir, "main.ts");
writeFileSync(file, probe.source);
return file;
}
describe("surface manifest sampling harness", () => {
test("the probe set covers every status class", () => {
const byStatus = new Map<string, number>();
for (const p of PROBES) {
const entry = entryById.get(p.id);
expect(entry, `probe id ${p.id} is not in the manifest`).toBeDefined();
byStatus.set(entry!.status, (byStatus.get(entry!.status) ?? 0) + 1);
}
expect(byStatus.get("static") ?? 0).toBeGreaterThanOrEqual(5);
expect(byStatus.get("dynamic-only") ?? 0).toBeGreaterThanOrEqual(4);
expect(byStatus.get("unsupported") ?? 0).toBeGreaterThanOrEqual(4);
});
test.for(PROBES.map((p) => [p.id, p] as const))("%s", async ([, probe]) => {
const entry = entryById.get(probe.id);
expect(entry, `probe id ${probe.id} is not in the manifest`).toBeDefined();
const file = probeFile(probe);
if (entry!.status === "static") {
const outDir = join(file, "..", "out");
const result = await compile(file, { outPath: join(outDir, "bin"), outDir });
if (!result.ok) {
expect.unreachable(
`${probe.id} is listed static but did not compile:\n` +
renderAll(result.diagnostics, result.sourceTexts, { color: false }),
);
}
return;
}
// Non-static: the refusal's code must equal the entry's code — the
// property external tooling depends on most.
const { coverage } = analyze(file);
expect(coverage.preflightFailed, `${probe.id}: probe must reach lowering`).toBe(false);
const codes = [...new Set(coverage.diagnostics.map((d) => d.code))];
expect(codes, `${probe.id}: expected exactly the listed refusal code`).toEqual([entry!.code]);
if (entry!.status === "dynamic-only") {
const dyn = analyze(file, { dynamic: true }).coverage;
expect(
dyn.diagnostics.map((d) => `${d.code}: ${d.message}`),
`${probe.id}: dynamic-only entries must analyze clean under --dynamic`,
).toEqual([]);
expect(dyn.stats.statementsFailed).toBe(0);
}
});
});