diff --git a/CHANGELOG.md b/CHANGELOG.md index 19994c7e..bb2ccb49 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,10 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve - **Rust IR parity sentinel packet**: Pulled the parity sentinel backlog item into design packet `0013`, defining comparator inputs, normalization, hash behavior, and failure output for the next JS/Rust parity check. +- **JS/Rust table parity sentinel**: Added `pnpm parity:ir` and the + `js-table-vs-rust-table.v0` projection so Wesley can compare legacy JS table + IR with Rust L1 over an explicit table-compatible corpus before broadening + parity coverage. - **Expanded Rust L1 fixture corpus**: Added directive-heavy, schema-extension, legacy-alias, and invalid duplicate-directive fixtures for the v0.0.6 compiler-truth lane. @@ -26,6 +30,16 @@ The format is based on Keep a Changelog, and this project adheres to Semantic Ve ### Fixed +- **Parity sentinel evidence contract**: `pnpm parity:ir --json` now records + canonical projected legacy and Rust bytes, and Rust L1 hash checks remove + top-level metadata before comparing against `wesley schema hash` or tracked + `*.l1.hash` outputs. +- **Parity projection ordering**: The `js-table-vs-rust-table.v0` projection now + sorts table names with deterministic code-point ordering instead of + locale-aware collation. +- **Parity custom fixture sidecars**: `pnpm parity:ir --fixture` now skips + tracked `*.l1.hash` checks for non-`.graphql` custom SDL paths instead of + reading the schema file as its own hash sidecar. - **Rust directive alias normalization**: Rust L1 lowering now canonicalizes the current core Wesley directive aliases to `wes_*` names and rejects duplicate canonical directives instead of allowing last-write-wins drift, diff --git a/docs/BEARING.md b/docs/BEARING.md index 3e6453f6..529d530a 100644 --- a/docs/BEARING.md +++ b/docs/BEARING.md @@ -104,22 +104,24 @@ base platform. The immediate focus is **v0.0.6 Rust IR parity and module-boundary enforcement**: -Current evidence now includes complete v0.0.5 publication proof and an expanded +Current evidence now includes complete v0.0.5 publication proof, an expanded Rust L1 corpus for directive-heavy SDL, schema extensions, legacy aliases, and -invalid duplicate-directive coverage. +invalid duplicate-directive coverage, plus `pnpm parity:ir` for the +`js-table-vs-rust-table.v0` compatibility projection over the first +table-compatible sentinel corpus. The next pulls are: -1. Implement the JS/Rust parity sentinel command from design packet - [0013-rust-ir-parity-sentinel](./design/0013-rust-ir-parity-sentinel/rust-ir-parity-sentinel.md). -2. Land the `js-table-vs-rust-table.v0` projection/crosswalk before comparing - legacy JS table IR with Rust L1 bytes. -3. Pull the domain-empty core boundary card into enforcement work so product +1. Broaden parity sentinel coverage only after naming fair projections for + non-table extension semantics and scale/performance fixtures. +2. Pull the domain-empty core boundary card into enforcement work so product and database behavior stays outside generic Wesley. -4. Continue the IR contract fixture lane for stable invalid-SDL diagnostics, +3. Continue the IR contract fixture lane for stable invalid-SDL diagnostics, including codes and spans where available. -5. Keep `wesley-postgres` visible as the database extraction home and avoid +4. Keep `wesley-postgres` visible as the database extraction home and avoid reshaping sibling work from Wesley release branches. +5. Use the parity sentinel output as compatibility evidence before retiring or + demoting legacy Node lowering. Echo and jedit do not need more Wesley feature gravity for their current work. Wesley should coordinate on compatibility only when a concrete artifact, hash, diff --git a/docs/design/0009-rust-core-and-wasm-capability-abi/phase-0-ir-truth-manifest.md b/docs/design/0009-rust-core-and-wasm-capability-abi/phase-0-ir-truth-manifest.md index ecfb43d4..112dbde5 100644 --- a/docs/design/0009-rust-core-and-wasm-capability-abi/phase-0-ir-truth-manifest.md +++ b/docs/design/0009-rust-core-and-wasm-capability-abi/phase-0-ir-truth-manifest.md @@ -81,6 +81,24 @@ tests. related core aliases to ensure Rust L1 emits canonical `@wes_*` directive names. (**COMPLETE for the current core compiler alias set**) +## JS/Rust Parity Sentinel Corpus + +`pnpm parity:ir` is the current JS/Rust compatibility sentinel. It is separate +from `pnpm fixtures:ir` and compares a named projection rather than raw legacy +table IR against raw Rust L1 IR. + +The v0 projection is `js-table-vs-rust-table.v0`. Its default corpus is the +table-compatible subset: + +- `small-schema.graphql` +- `medium-schema.graphql` +- `directive-heavy-schema.graphql` +- `legacy-alias-schema.graphql` + +`schema-extensions-schema.graphql` remains Rust L1 extension-folding coverage +until Wesley defines a fair non-table JS/Rust projection. `large-schema.graphql` +remains scale coverage outside the default v0 compatibility sentinel. + ## Baseline Performance (JS) *Captured on: May 5, 2026* @@ -107,7 +125,20 @@ This command shells through the native Wesley CLI and overwrites only the tracked `*.l1.json` and `*.l1.hash` outputs. It exits nonzero if any fixture fails to lower or hash. -### Verify Rust Parity +### Verify JS/Rust Table Parity + +```bash +pnpm parity:ir +``` + +This command lowers the explicit v0 sentinel corpus through both the legacy JS +adapter and the Rust CLI, compares the `js-table-vs-rust-table.v0` projection, +records canonical projected bytes in JSON output, verifies the Rust +`schema hash` command against current Rust semantic L1 bytes after top-level +`metadata` removal, and checks tracked Rust L1 hashes for `.graphql` fixtures +when sidecars are present. + +### Verify Rust Tests ```bash cd crates/wesley-core && cargo test diff --git a/docs/design/0013-rust-ir-parity-sentinel/rust-ir-parity-sentinel.md b/docs/design/0013-rust-ir-parity-sentinel/rust-ir-parity-sentinel.md index d90538cd..eb02f4bb 100644 --- a/docs/design/0013-rust-ir-parity-sentinel/rust-ir-parity-sentinel.md +++ b/docs/design/0013-rust-ir-parity-sentinel/rust-ir-parity-sentinel.md @@ -115,6 +115,7 @@ The normalizer removes envelope-only data and keeps semantic data intact. - Remove top-level `metadata`. - Sort object keys with Wesley canonical JSON ordering before hashing. - Preserve array order. +- Sort projection-created table arrays by deterministic code-point name order. - Preserve directive argument values exactly after each lowerer has produced semantic IR. - Require lowerers to emit canonical directive names for core Wesley aliases. @@ -149,15 +150,42 @@ Each failure should include: - the next decision: fix Rust, fix JS compatibility, update Rust goldens, or record an intentional compatibility break -## Current Slice +## Implemented Slice -This first v0.0.6 slice does not implement the sentinel command yet. +The first command slice implements the `js-table-vs-rust-table.v0` projection +and exposes it as: -It does pull the backlog card into design, expands the Rust L1 corpus, and -closes one blocker the sentinel would otherwise expose immediately: Rust L1 -lowering now canonicalizes the core Wesley directive aliases before writing -semantic IR, rejects duplicate canonical core directives, and preserves -repeated custom directives as ordered values. +```bash +pnpm parity:ir +``` + +The v0 corpus is explicit and table-compatible: + +- `test/fixtures/ir-parity/small-schema.graphql` +- `test/fixtures/ir-parity/medium-schema.graphql` +- `test/fixtures/ir-parity/directive-heavy-schema.graphql` +- `test/fixtures/ir-parity/legacy-alias-schema.graphql` + +The command lowers each fixture through the legacy JS adapter and the Rust CLI, +projects both outputs into the shared table shape, compares canonical projected +bytes and hashes, verifies `wesley schema hash` against the current Rust L1 +semantic bytes after top-level `metadata` removal, checks tracked Rust L1 hashes +for `.graphql` fixtures when sidecars are present, and reports the first +mismatch path when projection parity fails. +JSON output records the canonical projected `legacyBytes` and `rustBytes` +alongside their hashes so reviewers can archive or inspect the exact compared +bytes. + +`schema-extensions-schema.graphql` and `large-schema.graphql` remain outside +the default v0 sentinel corpus. The former still carries non-table Rust L1 +coverage that needs a separate projection before it is fair parity evidence; +the latter is scale coverage rather than the first compatibility sentinel. + +The preceding design slice also pulled the backlog card into design, expanded +the Rust L1 corpus, and closed one blocker the sentinel would otherwise expose +immediately: Rust L1 lowering now canonicalizes the core Wesley directive +aliases before writing semantic IR, rejects duplicate canonical core +directives, and preserves repeated custom directives as ordered values. ## Playback Questions @@ -169,8 +197,8 @@ repeated custom directives as ordered values. 4. Does the fixture corpus now cover directive-heavy SDL, schema extensions, legacy aliases, and at least one invalid-SDL case? 5. Does Rust L1 preserve canonical directive names for supported aliases? -6. Is the next implementation slice narrow enough to add a `pnpm parity:ir` - check without changing the Rust golden-regeneration command? +6. Does `pnpm parity:ir` compare the v0 table-compatible corpus without + changing the Rust golden-regeneration command? ## Non-Goals diff --git a/docs/method/backlog/bad-code/DX_format-check-toolchain-gap.md b/docs/method/backlog/bad-code/DX_format-check-toolchain-gap.md new file mode 100644 index 00000000..133d4237 --- /dev/null +++ b/docs/method/backlog/bad-code/DX_format-check-toolchain-gap.md @@ -0,0 +1,39 @@ +# Format Check Toolchain Gap + +- Lane: `bad-code` +- Legend: `DX` + +## Why now + +`package.json` exposes `pnpm run format` and `pnpm run format:check`, but the +workspace does not currently install a `prettier` binary. During the +`js-table-vs-rust-table.v0` parity sentinel slice, `pnpm run format:check` +failed before inspecting files: + +```text +sh: prettier: command not found +``` + +That makes the formatting gate look available when it is not actually +re-runnable from a clean local checkout. + +## Hill + +The repository either installs and pins the formatter needed by the existing +format scripts or removes/renames the scripts so local validation does not +advertise a dead command. + +## Done looks like + +- `pnpm run format:check` runs from a clean checkout without relying on a + globally installed formatter. +- The formatter version is pinned in workspace-managed package metadata. +- `pnpm run validate` no longer fails solely because the formatter binary is + unavailable. +- The chosen formatter behavior is documented in `scripts/README.md` or the + contributor-facing docs if it remains a supported gate. + +## Repo Evidence + +- `package.json` +- `scripts/README.md` diff --git a/docs/method/backlog/bad-code/README.md b/docs/method/backlog/bad-code/README.md index 54edc79e..48f0f756 100644 --- a/docs/method/backlog/bad-code/README.md +++ b/docs/method/backlog/bad-code/README.md @@ -12,3 +12,7 @@ the hill is "this no longer bothers us." ## Emitters - [Rust emitter consumer model parity](SOURCE_rust-emitter-consumer-model-parity.md) + +## Developer Experience + +- [Format Check Toolchain Gap](DX_format-check-toolchain-gap.md) diff --git a/package.json b/package.json index 02d7584a..566e36c8 100644 --- a/package.json +++ b/package.json @@ -28,7 +28,8 @@ "wesley": "node packages/wesley-host-node/bin/wesley.mjs", "meta:fix-packages": "node scripts/fix-package-metadata.mjs", "ci": "pnpm validate && pnpm generate:example && node packages/wesley-host-node/bin/wesley.mjs validate-bundle --bundle test/fixtures/examples/.wesley --schemas schemas/", - "fixtures:ir": "node scripts/generate-ir-fixtures.mjs" + "fixtures:ir": "node scripts/generate-ir-fixtures.mjs", + "parity:ir": "node scripts/check-ir-parity.mjs" }, "keywords": [ "graphql", diff --git a/scripts/README.md b/scripts/README.md index cce2e174..a4d0f626 100644 --- a/scripts/README.md +++ b/scripts/README.md @@ -5,6 +5,7 @@ This directory contains helper scripts that power development workflows. Run the | Script | Description | Usage | | --- | --- | --- | | `check-doc-links.mjs` | Scans every markdown file for relative links that point to missing targets. Fails with a non-zero exit code if any are broken. | `pnpm exec node scripts/check-doc-links.mjs` (no arguments) | +| `check-ir-parity.mjs` | Compares the legacy JS table projection with the Rust L1 projection over the explicit v0 IR parity corpus. | `pnpm parity:ir` | | `clean.mjs` | Removes generated artifacts such as `.wesley-cache/`, `out/`, and fixture outputs to return the workspace to a pristine state. | `pnpm run clean` (no arguments) | | `fix-package-metadata.mjs` | Normalises `package.json` metadata across all workspaces (author, license, repository, etc.). | `pnpm exec node scripts/fix-package-metadata.mjs` (no arguments) | | `install-hooks.sh` | Sets `core.hooksPath` to `.githooks`, ensuring local Git hooks run. Safe to rerun. | `bash scripts/install-hooks.sh` | diff --git a/scripts/check-ir-parity.mjs b/scripts/check-ir-parity.mjs new file mode 100644 index 00000000..23f80e1f --- /dev/null +++ b/scripts/check-ir-parity.mjs @@ -0,0 +1,275 @@ +#!/usr/bin/env node +import { spawnSync } from 'node:child_process'; +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, relative, resolve } from 'node:path'; +import process from 'node:process'; +import { fileURLToPath } from 'node:url'; +import { GraphQLAdapter } from '../packages/wesley-runtime-node/src/index.mjs'; +import { canonicalizeJSON } from '../packages/wesley-core/src/domain/registryHash.mjs'; +import { + DEFAULT_PARITY_FIXTURES, + PROJECTION_NAME, + PROJECTION_NORMALIZER_VERSION, + canonicalProjectionBytes, + firstMismatch, + projectLegacyTableIR, + projectRustL1IR, + projectionHash, + sha256Hex +} from './ir-parity-projection.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = resolve(__dirname, '..'); +const CARGO = process.env.CARGO || 'cargo'; +const WESLEY_CLI_ARGS = ['run', '--quiet', '-p', 'wesley-cli', '--']; +const WESLEY_CLI_BIN = process.env.WESLEY_CLI_BIN || null; + +function main() { + const options = parseArgs(process.argv.slice(2)); + + if (options.help) { + printHelp(); + return; + } + + if (options.listFixtures) { + for (const fixture of DEFAULT_PARITY_FIXTURES) { + console.log(fixture); + } + return; + } + + const report = runParity(options.fixtures); + + if (options.json) { + console.log(JSON.stringify(report, null, 2)); + } else { + printTextReport(report); + } + + if (report.summary.failed > 0) { + process.exitCode = 1; + } +} + +function parseArgs(args) { + const fixtures = []; + let json = false; + let listFixtures = false; + let help = false; + + for (let index = 0; index < args.length; index += 1) { + const arg = args[index]; + if (arg === '--') { + continue; + } else if (arg === '--json') { + json = true; + } else if (arg === '--list-fixtures') { + listFixtures = true; + } else if (arg === '--help' || arg === '-h') { + help = true; + } else if (arg === '--fixture') { + const fixture = args[index + 1]; + if (!fixture) { + throw new Error('--fixture requires a path'); + } + fixtures.push(fixture); + index += 1; + } else { + throw new Error(`Unknown argument: ${arg}`); + } + } + + return { + fixtures: fixtures.length > 0 ? fixtures : [...DEFAULT_PARITY_FIXTURES], + json, + listFixtures, + help + }; +} + +function runParity(fixtures) { + const results = fixtures.map(compareFixture); + const failed = results.filter(result => result.status !== 'pass').length; + + return { + projection: PROJECTION_NAME, + normalizerVersion: PROJECTION_NORMALIZER_VERSION, + gitHead: gitHead(), + lowerers: { + legacy: '@wesley/runtime-node GraphQLAdapter.parseSDL', + rust: WESLEY_CLI_BIN || `${CARGO} ${WESLEY_CLI_ARGS.join(' ')}` + }, + summary: { + total: results.length, + passed: results.length - failed, + failed + }, + fixtures: results + }; +} + +function compareFixture(fixture) { + const fixturePath = resolve(ROOT_DIR, fixture); + const displayPath = relative(ROOT_DIR, fixturePath); + + try { + if (!existsSync(fixturePath)) { + throw new Error(`Fixture does not exist: ${displayPath}`); + } + + const legacyProjection = lowerLegacyProjection(fixturePath); + const rustIr = lowerRustL1(fixturePath); + const rustProjection = projectRustL1IR(rustIr); + const legacyBytes = canonicalProjectionBytes(legacyProjection); + const rustBytes = canonicalProjectionBytes(rustProjection); + const mismatch = legacyBytes === rustBytes + ? null + : firstMismatch(legacyProjection, rustProjection); + const rustL1Hash = rustSemanticHash(rustIr); + const rustCommandHash = runWesley(['schema', 'hash', '--schema', fixturePath]).trim(); + const rustTrackedHash = readTrackedHash(fixturePath); + const rustCommandHashMatches = rustCommandHash === rustL1Hash; + const rustTrackedHashMatches = rustTrackedHash === null ? null : rustTrackedHash === rustL1Hash; + const failureReasons = []; + + if (mismatch) failureReasons.push('projection-mismatch'); + if (!rustCommandHashMatches) failureReasons.push('rust-command-hash-mismatch'); + if (rustTrackedHashMatches === false) failureReasons.push('tracked-rust-hash-mismatch'); + + return { + fixture: displayPath, + status: failureReasons.length > 0 ? 'fail' : 'pass', + failureReasons, + legacyBytes, + rustBytes, + legacyHash: projectionHash(legacyProjection), + rustHash: projectionHash(rustProjection), + rustL1Hash, + rustCommandHash, + rustCommandHashMatches, + rustTrackedHash, + rustTrackedHashMatches, + ...(mismatch ? { firstMismatch: mismatch } : {}) + }; + } catch (error) { + return { + fixture: displayPath, + status: 'error', + error: error?.message || String(error) + }; + } +} + +function lowerLegacyProjection(fixturePath) { + const sdl = readFileSync(fixturePath, 'utf8'); + const ir = new GraphQLAdapter().parseSDL(sdl); + return projectLegacyTableIR(ir); +} + +function lowerRustL1(fixturePath) { + const output = runWesley(['schema', 'lower', '--schema', fixturePath, '--json']); + return JSON.parse(output); +} + +function rustSemanticHash(ir) { + const semanticIr = { ...ir }; + delete semanticIr.metadata; + return sha256Hex(canonicalizeJSON(semanticIr)); +} + +function runWesley(args) { + const command = WESLEY_CLI_BIN || CARGO; + const commandArgs = WESLEY_CLI_BIN ? args : [...WESLEY_CLI_ARGS, ...args]; + const result = spawnSync(command, commandArgs, { + cwd: ROOT_DIR, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'] + }); + + if (result.error) { + throw result.error; + } + + if (result.status !== 0) { + const details = (result.stderr || result.stdout || '').trim(); + throw new Error(details || `${command} ${commandArgs.join(' ')} failed`); + } + + return result.stdout; +} + +function gitHead() { + const result = spawnSync('git', ['rev-parse', '--short=12', 'HEAD'], { + cwd: ROOT_DIR, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'] + }); + + if (result.status !== 0) return null; + return result.stdout.trim() || null; +} + +function readTrackedHash(fixturePath) { + if (!fixturePath.endsWith('.graphql')) return null; + const hashPath = fixturePath.replace(/\.graphql$/, '.l1.hash'); + if (!existsSync(hashPath)) return null; + return readFileSync(hashPath, 'utf8').trim(); +} + +function printTextReport(report) { + if (report.summary.failed === 0) { + console.log( + `IR parity passed for ${report.summary.passed}/${report.summary.total} fixtures ` + + `using ${report.projection}.` + ); + return; + } + + console.error( + `IR parity failed for ${report.summary.failed}/${report.summary.total} fixtures ` + + `using ${report.projection}.` + ); + + for (const result of report.fixtures) { + if (result.status === 'pass') continue; + console.error(`- ${result.fixture}`); + console.error(` status: ${result.status}`); + if (result.error) { + console.error(` error: ${result.error}`); + continue; + } + console.error(` legacy projection hash: ${result.legacyHash}`); + console.error(` rust projection hash: ${result.rustHash}`); + console.error(` failure reasons: ${result.failureReasons.join(', ')}`); + if (result.firstMismatch) { + console.error(` first mismatch: ${result.firstMismatch.path}`); + console.error(` reason: ${result.firstMismatch.reason}`); + console.error(` legacy: ${formatPreview(result.firstMismatch.legacy)}`); + console.error(` rust: ${formatPreview(result.firstMismatch.rust)}`); + } + console.error(` rust schema hash matches current L1: ${result.rustCommandHashMatches}`); + console.error(` tracked .l1.hash matches current Rust: ${result.rustTrackedHashMatches}`); + } +} + +function formatPreview(value) { + if (typeof value === 'string') return value; + return JSON.stringify(value); +} + +function printHelp() { + console.log(`Usage: pnpm parity:ir [--json] [--list-fixtures] [--fixture ...] + +Compares the legacy JS table IR projection against the Rust L1 projection using +${PROJECTION_NAME}. By default it runs the explicit v0 sentinel corpus: + +${DEFAULT_PARITY_FIXTURES.map(fixture => ` - ${fixture}`).join('\n')}`); +} + +try { + main(); +} catch (error) { + console.error(error?.message || String(error)); + process.exitCode = 1; +} diff --git a/scripts/ir-parity-projection.mjs b/scripts/ir-parity-projection.mjs new file mode 100644 index 00000000..52c12fb1 --- /dev/null +++ b/scripts/ir-parity-projection.mjs @@ -0,0 +1,304 @@ +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { canonicalizeJSON } from '../packages/wesley-core/src/domain/registryHash.mjs'; + +export const PROJECTION_NAME = 'js-table-vs-rust-table.v0'; +export const PROJECTION_NORMALIZER_VERSION = 'v0'; + +export const DEFAULT_PARITY_FIXTURES = Object.freeze([ + 'test/fixtures/ir-parity/small-schema.graphql', + 'test/fixtures/ir-parity/medium-schema.graphql', + 'test/fixtures/ir-parity/directive-heavy-schema.graphql', + 'test/fixtures/ir-parity/legacy-alias-schema.graphql' +]); + +const TABLE_COLUMN_SCALARS = new Set([ + 'ID', + 'UUID', + 'String', + 'Int', + 'Float', + 'Boolean', + 'DateTime', + 'Date', + 'Time', + 'JSON' +]); + +export function projectLegacyTableIR(ir) { + const tables = Array.isArray(ir?.tables) ? ir.tables : []; + + return withProjectionEnvelope({ + tables: tables.map(projectLegacyTable).sort(compareByName) + }); +} + +export function projectRustL1IR(ir) { + const types = Array.isArray(ir?.types) ? ir.types : []; + const tables = types + .filter(type => type?.kind === 'OBJECT' && type?.directives?.wes_table) + .map(projectRustTable) + .sort(compareByName); + + return withProjectionEnvelope({ tables }); +} + +export function canonicalProjectionBytes(value) { + return canonicalizeJSON(value); +} + +export function projectionHash(value) { + return sha256Hex(canonicalProjectionBytes(value)); +} + +export function sha256Hex(value) { + return createHash('sha256').update(value).digest('hex'); +} + +export function firstMismatch(left, right) { + return findFirstMismatch(left, right, []); +} + +function withProjectionEnvelope(value) { + return { + projection: PROJECTION_NAME, + normalizerVersion: PROJECTION_NORMALIZER_VERSION, + ...value + }; +} + +function projectLegacyTable(table) { + const indexByField = legacyIndexByField(table); + + return { + name: table.name, + directives: projectLegacyTableDirectives(table.directives || {}), + fields: (table.fields || []).map(field => projectLegacyField(field, indexByField)) + }; +} + +function projectLegacyTableDirectives(directives) { + const projected = { + table: directives.table === true + }; + + if (directives.rls) { + projected.rls = true; + } + + if (directives.tenant?.field) { + projected.tenant = { field: directives.tenant.field }; + } + + return projected; +} + +function projectLegacyField(field, indexByField) { + return { + name: field.name, + type: projectLegacyFieldType(field), + directives: projectLegacyFieldDirectives(field, indexByField) + }; +} + +function projectLegacyFieldType(field) { + const type = { + base: field.type?.base, + nullable: field.nullable === true, + isList: field.type?.isList === true + }; + + if (type.isList) { + type.listItemNullable = field.type?.listItemNullable !== false; + } + + return type; +} + +function projectLegacyFieldDirectives(field, indexByField) { + const source = field.directives || {}; + const projected = {}; + const index = indexByField.get(field.name); + + if (source.pk === true) projected.pk = true; + if (source.unique === true) projected.unique = true; + if (source.index === true || index) projected.index = projectIndex(index); + + if (source.default) { + projected.default = { value: source.default.value }; + } + + if (source.fk) { + projected.fk = { + targetTable: source.fk.targetTable, + targetField: source.fk.targetField + }; + } + + return projected; +} + +function legacyIndexByField(table) { + const byField = new Map(); + + for (const index of table.indexes || []) { + if (!Array.isArray(index.fields) || index.fields.length !== 1) continue; + byField.set(index.fields[0], index); + } + + return byField; +} + +function projectRustTable(type) { + const tableDirective = type.directives.wes_table; + const fields = (type.fields || []) + .filter(isRustColumnField) + .map(projectRustField); + + return { + name: tableDirective?.name || type.name, + directives: projectRustTableDirectives(type.directives || {}), + fields + }; +} + +function projectRustTableDirectives(directives) { + const projected = { + table: Boolean(directives.wes_table) + }; + + if (Object.hasOwn(directives, 'wes_rls')) { + projected.rls = true; + } + + if (directives.wes_tenant?.by) { + projected.tenant = { field: directives.wes_tenant.by }; + } + + return projected; +} + +function projectRustField(field) { + return { + name: field.name, + type: projectRustFieldType(field.type || {}), + directives: projectRustFieldDirectives(field.directives || {}) + }; +} + +function projectRustFieldType(type) { + const projected = { + base: type.base, + nullable: type.nullable === true, + isList: type.isList === true + }; + + if (projected.isList) { + projected.listItemNullable = type.listItemNullable !== false; + } + + return projected; +} + +function projectRustFieldDirectives(directives) { + const projected = {}; + + if (directives.wes_pk === true) projected.pk = true; + if (directives.wes_unique === true) projected.unique = true; + if (Object.hasOwn(directives, 'wes_index')) { + projected.index = projectIndex(directives.wes_index); + } + + if (directives.wes_default) { + projected.default = { value: directives.wes_default.value }; + } + + if (directives.wes_fk?.ref) { + const [targetTable, targetField] = directives.wes_fk.ref.split('.'); + projected.fk = { targetTable, targetField }; + } + + return projected; +} + +function projectIndex(index) { + if (!index || index === true) return true; + + const projected = {}; + if (index.name) projected.name = index.name; + if (index.using) projected.using = index.using; + + return Object.keys(projected).length > 0 ? projected : true; +} + +function isRustColumnField(field) { + if (field?.directives?.wes_fk) return true; + return TABLE_COLUMN_SCALARS.has(field?.type?.base); +} + +function compareByName(left, right) { + if (left.name < right.name) return -1; + if (left.name > right.name) return 1; + return 0; +} + +function findFirstMismatch(left, right, path) { + if (Object.is(left, right)) return null; + + if (Array.isArray(left) || Array.isArray(right)) { + if (!Array.isArray(left) || !Array.isArray(right)) { + return mismatch(path, left, right, 'type'); + } + + const max = Math.max(left.length, right.length); + for (let index = 0; index < max; index += 1) { + if (index >= left.length || index >= right.length) { + return mismatch([...path, index], left[index], right[index], 'array-length'); + } + const child = findFirstMismatch(left[index], right[index], [...path, index]); + if (child) return child; + } + return null; + } + + if (isPlainObject(left) || isPlainObject(right)) { + if (!isPlainObject(left) || !isPlainObject(right)) { + return mismatch(path, left, right, 'type'); + } + + const keys = [...new Set([...Object.keys(left), ...Object.keys(right)])].sort(); + for (const key of keys) { + if (!Object.hasOwn(left, key) || !Object.hasOwn(right, key)) { + return mismatch([...path, key], left[key], right[key], 'object-key'); + } + const child = findFirstMismatch(left[key], right[key], [...path, key]); + if (child) return child; + } + return null; + } + + return mismatch(path, left, right, 'value'); +} + +function isPlainObject(value) { + return Boolean(value) && typeof value === 'object' && !Array.isArray(value); +} + +function mismatch(path, left, right, reason) { + return { + path: toJsonPointer(path), + reason, + legacy: preview(left), + rust: preview(right) + }; +} + +function toJsonPointer(path) { + if (path.length === 0) return '/'; + return `/${path.map(part => String(part).replaceAll('~', '~0').replaceAll('/', '~1')).join('/')}`; +} + +function preview(value) { + if (value === undefined) return ''; + return value; +} diff --git a/scripts/smoke/repo-bats-prepush.sh b/scripts/smoke/repo-bats-prepush.sh index 8b8e034a..c95f8d71 100755 --- a/scripts/smoke/repo-bats-prepush.sh +++ b/scripts/smoke/repo-bats-prepush.sh @@ -25,6 +25,7 @@ files=( test/serve-static-relative-unit.bats test/progress-dry-run.bats test/ir-fixtures.bats + test/ir-parity-sentinel.bats test/progress-safety.bats test/ci-browser-smoke.bats test/ci-pkg-host-bun.bats diff --git a/test/README.md b/test/README.md index 5349c466..4a9530da 100644 --- a/test/README.md +++ b/test/README.md @@ -62,6 +62,7 @@ BATS_LIB_PATH=packages/wesley-cli/test \ bats test/serve-static*.bats \ test/progress-*.bats \ test/ir-fixtures.bats \ + test/ir-parity-sentinel.bats \ test/ci-*.bats \ test/browser-contracts-*.bats ``` diff --git a/test/ir-parity-sentinel.bats b/test/ir-parity-sentinel.bats new file mode 100644 index 00000000..93ab36fe --- /dev/null +++ b/test/ir-parity-sentinel.bats @@ -0,0 +1,272 @@ +#!/usr/bin/env bats + +load 'bats-plugins/bats-support/load' +load 'bats-plugins/bats-assert/load' + +setup() { + TEST_TEMP_DIR="$(mktemp -d -t wesley-ir-parity-sentinel-XXXXXX)" +} + +teardown() { + rm -rf "$TEST_TEMP_DIR" +} + +make_fake_wesley() { + FAKE_WESLEY="$TEST_TEMP_DIR/fake-wesley.mjs" + FAKE_WESLEY_CALL_LOG="$TEST_TEMP_DIR/fake-wesley-calls.log" + cat > "$FAKE_WESLEY" <<'NODE' +#!/usr/bin/env node +import { createHash } from 'node:crypto'; +import { appendFileSync } from 'node:fs'; + +const args = process.argv.slice(2); +if (process.env.WESLEY_FAKE_CALL_LOG) { + appendFileSync(process.env.WESLEY_FAKE_CALL_LOG, `${args.join(' ')}\n`); +} + +const nullable = process.env.WESLEY_FAKE_VARIANT === 'nullable-mismatch'; +const ir = { + version: '1.0.0', + types: [ + { + name: 'User', + kind: 'OBJECT', + directives: { + wes_table: { + name: 'users' + } + }, + fields: [ + { + name: 'id', + type: { + base: 'ID', + nullable, + isList: false + }, + directives: { + wes_pk: true + } + }, + { + name: 'username', + type: { + base: 'String', + nullable: false, + isList: false + }, + directives: { + wes_unique: true + } + }, + { + name: 'email', + type: { + base: 'String', + nullable: false, + isList: false + }, + directives: {} + }, + { + name: 'created_at', + type: { + base: 'String', + nullable: false, + isList: false + }, + directives: { + wes_default: { + value: 'now()' + } + } + } + ] + } + ] +}; + +if (process.env.WESLEY_FAKE_VARIANT === 'metadata') { + ir.metadata = { + generatedAt: '2026-05-22T00:00:00.000Z', + sourceHash: 'fake-source-hash' + }; +} + +function canonicalizeJSON(value) { + return JSON.stringify(value, (_key, jsonValue) => { + if (jsonValue && typeof jsonValue === 'object' && !Array.isArray(jsonValue)) { + const sorted = {}; + for (const key of Object.keys(jsonValue).sort()) { + sorted[key] = jsonValue[key]; + } + return sorted; + } + return jsonValue; + }); +} + +function sha256Hex(value) { + return createHash('sha256').update(value).digest('hex'); +} + +function semanticIr(value) { + const copy = structuredClone(value); + delete copy.metadata; + return copy; +} + +if (args[0] === 'schema' && args[1] === 'lower') { + console.log(JSON.stringify(ir, null, 2)); +} else if (args[0] === 'schema' && args[1] === 'hash') { + if (process.env.WESLEY_FAKE_VARIANT === 'hash-mismatch') { + console.log('deadbeef'); + process.exit(0); + } + console.log(sha256Hex(canonicalizeJSON(semanticIr(ir)))); +} else { + console.error(`unexpected fake wesley args: ${args.join(' ')}`); + process.exit(2); +} +NODE + chmod +x "$FAKE_WESLEY" +} + +@test "IR parity sentinel compares legacy and Rust table projections" { + make_fake_wesley + + run env \ + WESLEY_CLI_BIN="$FAKE_WESLEY" \ + WESLEY_FAKE_CALL_LOG="$FAKE_WESLEY_CALL_LOG" \ + node scripts/check-ir-parity.mjs \ + --fixture test/fixtures/ir-parity/small-schema.graphql \ + --json + assert_success + assert_output --partial '"projection": "js-table-vs-rust-table.v0"' + assert_output --partial '"status": "pass"' + assert_output --partial '"rustCommandHashMatches": true' + assert_output --partial '"legacyBytes": "{' + assert_output --partial '"rustBytes": "{' + + run grep -F "schema lower --schema" "$FAKE_WESLEY_CALL_LOG" + assert_success + + run grep -F "schema hash --schema" "$FAKE_WESLEY_CALL_LOG" + assert_success +} + +@test "IR parity sentinel ignores top-level Rust metadata when checking schema hash" { + make_fake_wesley + + run env \ + WESLEY_CLI_BIN="$FAKE_WESLEY" \ + WESLEY_FAKE_VARIANT="metadata" \ + node scripts/check-ir-parity.mjs \ + --fixture test/fixtures/ir-parity/small-schema.graphql \ + --json + assert_success + assert_output --partial '"status": "pass"' + assert_output --partial '"rustCommandHashMatches": true' +} + +@test "IR parity sentinel fails when the Rust hash command drifts from current L1" { + make_fake_wesley + + run env \ + WESLEY_CLI_BIN="$FAKE_WESLEY" \ + WESLEY_FAKE_VARIANT="hash-mismatch" \ + node scripts/check-ir-parity.mjs \ + --fixture test/fixtures/ir-parity/small-schema.graphql \ + --json + assert_failure + assert_output --partial '"failureReasons": [' + assert_output --partial '"rust-command-hash-mismatch"' + assert_output --partial '"rustCommandHashMatches": false' +} + +@test "IR parity sentinel skips tracked hash sidecars for custom non-graphql fixtures" { + make_fake_wesley + + cat > "$TEST_TEMP_DIR/custom.gql" <<'SDL' +type User @wes_table(name: "users") { + id: ID! @wes_pk + username: String! @wes_unique + email: String! + created_at: String! @wes_default(value: "now()") +} +SDL + + run env \ + WESLEY_CLI_BIN="$FAKE_WESLEY" \ + node scripts/check-ir-parity.mjs \ + --fixture "$TEST_TEMP_DIR/custom.gql" \ + --json + assert_success + assert_output --partial '"status": "pass"' + assert_output --partial '"rustTrackedHash": null' + assert_output --partial '"rustTrackedHashMatches": null' +} + +@test "IR parity sentinel reports the first mismatch path" { + make_fake_wesley + + run env \ + WESLEY_CLI_BIN="$FAKE_WESLEY" \ + WESLEY_FAKE_VARIANT="nullable-mismatch" \ + node scripts/check-ir-parity.mjs \ + --fixture test/fixtures/ir-parity/small-schema.graphql \ + --json + assert_failure + assert_output --partial '"status": "fail"' + assert_output --partial '"path": "/tables/0/fields/0/type/nullable"' + assert_output --partial '"legacy": false' + assert_output --partial '"rust": true' +} + +@test "IR parity projection sorts table names by code point" { + run node --input-type=module <<'NODE' +import { projectRustL1IR } from './scripts/ir-parity-projection.mjs'; + +const names = ['a', 'B', 'á', 'aa', 'A']; +const ir = { + version: '1.0.0', + types: names.map(name => ({ + name, + kind: 'OBJECT', + directives: { + wes_table: { + name + } + }, + fields: [ + { + name: 'id', + type: { + base: 'ID', + nullable: false, + isList: false + }, + directives: { + wes_pk: true + } + } + ] + })) +}; + +console.log(projectRustL1IR(ir).tables.map(table => table.name).join(',')); +NODE + assert_success + assert_output "A,B,a,aa,á" +} + +@test "IR parity sentinel lists only the v0 table-compatible corpus by default" { + run node scripts/check-ir-parity.mjs --list-fixtures + assert_success + assert_output --partial "test/fixtures/ir-parity/small-schema.graphql" + assert_output --partial "test/fixtures/ir-parity/medium-schema.graphql" + assert_output --partial "test/fixtures/ir-parity/directive-heavy-schema.graphql" + assert_output --partial "test/fixtures/ir-parity/legacy-alias-schema.graphql" + [[ "$output" != *"schema-extensions-schema.graphql"* ]] + [[ "$output" != *"large-schema.graphql"* ]] +}