mirror of
https://github.com/garrytan/gstack.git
synced 2026-09-21 04:10:47 +02:00
408 lines
29 KiB
TypeScript
408 lines
29 KiB
TypeScript
#!/usr/bin/env bun
|
|
import { createHash } from 'node:crypto';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import { BUG_FIX_OVERLAYS, evaluateBugFixRegression, overlaysForSource } from './bug-fix-overlays';
|
|
import { contractFor, DISPATCHERS, SOURCE_ASSIGNMENTS } from './assignments';
|
|
import { ROOT, blobShaForPath, legacySections, renderLegacyBody, renderPortedAssetBytes, renderPortedLegacyBody, renderPortedLegacySection, retiredInvocationPattern, sourceBlobSha } from './render-legacy';
|
|
import { routeStructured } from './route';
|
|
import { SCENARIOS } from './scenarios';
|
|
import { GSTACK2_BASE_SHA, TREE_NAMES } from './types';
|
|
|
|
const CONTRACT_KEYS = ['question_order', 'pressure', 'smart_skips', 'stop_approval_gates', 'evidence', 'artifacts', 'mutation', 'exit', 'voice'];
|
|
const PROVENANCE_KEYS = ['original_source_file', 'original_line_range', 'purpose', 'invocation_conditions', 'modes', 'question_sequence', 'follow_up_behavior', 'smart_skip_rules', 'pushback_rules', 'stop_gates', 'approval_gates', 'rubrics_and_scoring', 'cognitive_frameworks', 'evidence_requirements', 'artifacts_produced', 'mutation_authority', 'exit_states', 'voice', 'response_posture', 'new_location', 'parity_test'];
|
|
const ALLOWED_DISPOSITIONS = new Set(['VERBATIM_PORT', 'MECHANICAL_PORT', 'JUDGMENT_PRESERVING_CARVE', 'SHARED_MODULE', 'BUG_FIX', 'DUPLICATE_INFRASTRUCTURE', 'REMOVE_WITH_USER_APPROVAL']);
|
|
// Inventory history: the componentized-runtime parity expansion added 152
|
|
// checks to the previously verified 4,681-check corpus. The first update only
|
|
// accounted for the 16 lazy-section checks; the remaining 136 cover runtime
|
|
// contracts, retired-invocation guards, and generated package closure. Porting
|
|
// six more upstream judgment overlays (16 -> 22) added 30 checks.
|
|
export const EXPECTED_PARITY_CHECKS = 4866;
|
|
|
|
function sha256(value: string | Uint8Array): string {
|
|
return createHash('sha256').update(value).digest('hex');
|
|
}
|
|
|
|
function json(file: string): any {
|
|
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
}
|
|
|
|
function files(directory: string, suffix = ''): string[] {
|
|
if (!fs.existsSync(directory)) return [];
|
|
return fs.readdirSync(directory).filter((file) => file.endsWith(suffix)).sort();
|
|
}
|
|
|
|
export function normalizeGolden(value: string): string {
|
|
return `${value.replace(/\r\n/g, '\n').trim()}\n`;
|
|
}
|
|
|
|
export function extractLegacyBody(module: string, source: string): string {
|
|
const startMarker = `<!-- GSTACK2_LEGACY_BODY_START source=${source} -->`;
|
|
const endMarker = `<!-- GSTACK2_LEGACY_BODY_END source=${source} -->`;
|
|
const start = module.indexOf(startMarker);
|
|
const end = module.indexOf(endMarker);
|
|
if (start === -1 || end === -1 || end <= start) throw new Error(`Missing legacy body markers for ${source}`);
|
|
return normalizeGolden(module.slice(start + startMarker.length, end));
|
|
}
|
|
|
|
export interface ParityResult {
|
|
checks: number;
|
|
sources: number;
|
|
sections: number;
|
|
scenarios: number;
|
|
regressions: number;
|
|
assets: number;
|
|
}
|
|
|
|
export function runParity(): ParityResult {
|
|
const failures: string[] = [];
|
|
let checks = 0;
|
|
const check = (condition: unknown, message: string): void => {
|
|
checks += 1;
|
|
if (!condition) failures.push(message);
|
|
};
|
|
const retiredInvocation = retiredInvocationPattern();
|
|
|
|
const publicSkills = fs.readdirSync(path.join(ROOT, 'skills'), { withFileTypes: true })
|
|
.filter((entry) => entry.isDirectory() && fs.existsSync(path.join(ROOT, 'skills', entry.name, 'SKILL.md')))
|
|
.map((entry) => entry.name)
|
|
.sort();
|
|
check(JSON.stringify(publicSkills) === JSON.stringify([...TREE_NAMES].sort()), `Public skills differ: ${publicSkills.join(', ')}`);
|
|
check(SOURCE_ASSIGNMENTS.length === 55, `Expected 55 source assignments; got ${SOURCE_ASSIGNMENTS.length}`);
|
|
check(SOURCE_ASSIGNMENTS.filter((entry) => entry.mandatory).length === 31, 'Mandatory specialist count is not 31');
|
|
const exactModes: Record<string, string[]> = {
|
|
plan: ['Discovery', 'Product', 'Engineering', 'DX', 'Specification', 'Full chain'],
|
|
design: ['Explore', 'Generate', 'Critique', 'Implement'],
|
|
qa: ['Report', 'Fix'],
|
|
debug: ['Diagnose-only', 'Fix'],
|
|
review: ['Normal', 'Security', 'Performance', 'Deep'],
|
|
ship: ['Prepare', 'Land', 'Deploy', 'Monitor', 'Resume'],
|
|
};
|
|
for (const [tree, modes] of Object.entries(exactModes)) {
|
|
const actual = DISPATCHERS.find((entry) => entry.name === tree)?.modes.map((entry) => entry.mode);
|
|
check(JSON.stringify(actual) === JSON.stringify(modes), `${tree} top-level modes differ: ${actual?.join(', ')}`);
|
|
}
|
|
|
|
for (const tree of TREE_NAMES) {
|
|
const skillPath = path.join(ROOT, 'skills', tree, 'SKILL.md');
|
|
const skill = fs.readFileSync(skillPath, 'utf8');
|
|
const fmEnd = skill.indexOf('\n---', 4);
|
|
const fm = skill.slice(4, fmEnd);
|
|
const keys = fm.split('\n').map((line) => line.match(/^([a-z][a-z0-9_-]*):/)?.[1]).filter(Boolean).sort();
|
|
check(JSON.stringify(keys) === JSON.stringify(['description', 'name']), `${tree} frontmatter must contain only name and description`);
|
|
check(new RegExp(`^name: ${tree}$`, 'm').test(fm), `${tree} frontmatter name mismatch`);
|
|
check(skill.split('\n').length < 500, `${tree}/SKILL.md exceeds 500 lines`);
|
|
let prior = -1;
|
|
for (const label of ['Target:', 'Mode:', 'Depth:', 'Mutation:', 'Active modules:', 'Skipped modules:', 'Web context:']) {
|
|
const position = skill.indexOf(label);
|
|
check(position > prior, `${tree} required execution header is missing or out of order at ${label}`);
|
|
prior = position;
|
|
}
|
|
const metadata = fs.readFileSync(path.join(ROOT, 'skills', tree, 'agents', 'openai.yaml'), 'utf8');
|
|
check(/^interface:\n display_name: .+\n short_description: .+\n default_prompt: .+\n$/.test(metadata), `${tree} openai.yaml schema mismatch`);
|
|
check(metadata.includes(`$${tree}`), `${tree} default prompt does not mention $${tree}`);
|
|
const dispatcher = DISPATCHERS.find((entry) => entry.name === tree)!;
|
|
for (const mode of dispatcher.modes) {
|
|
for (const source of mode.modules) {
|
|
const localModule = path.join(ROOT, 'skills', tree, 'references', 'legacy', `${source}.md`);
|
|
const owner = SOURCE_ASSIGNMENTS.find((entry) => entry.source === source)!;
|
|
const canonicalModule = path.join(ROOT, 'skills', owner.tree, 'references', 'legacy', `${source}.md`);
|
|
check(fs.existsSync(localModule), `${tree}:${mode.mode} is not package-closed; missing ${source}`);
|
|
check(skill.includes(`references/legacy/${source}.md`), `${tree}:${mode.mode} does not use its package-local ${source} module`);
|
|
if (fs.existsSync(localModule) && fs.existsSync(canonicalModule)) {
|
|
check(sha256(fs.readFileSync(localModule)) === sha256(fs.readFileSync(canonicalModule)), `${tree}:${mode.mode} dependency copy drifted for ${source}`);
|
|
}
|
|
}
|
|
}
|
|
const packagedModules = files(path.join(ROOT, 'skills', tree, 'references', 'legacy'), '.md');
|
|
for (const moduleName of packagedModules) {
|
|
const modulePath = path.join(ROOT, 'skills', tree, 'references', 'legacy', moduleName);
|
|
const module = fs.readFileSync(modulePath, 'utf8');
|
|
check(
|
|
!/(?:~\/\.claude\/skills\/gstack|\$GSTACK_ROOT)\/[a-z0-9-]+\/SKILL\.md|\$\{?CLAUDE_SKILL_DIR\}?\/\.\.\/[a-z0-9-]+\/SKILL\.md/.test(module),
|
|
`${tree}/${moduleName} still reaches another host or source checkout for a skill`,
|
|
);
|
|
check(!/GSTACK_ROOT="\$HOME\/\.(?:claude|codex)\/skills\/gstack"/.test(module), `${tree}/${moduleName} still binds runtime state to a host skill directory`);
|
|
check(!/(?:\$HOME\/|\$_ROOT\/|^)\.agents\/skills\/gstack\/(?:bin|browse|design|make-pdf|lib|extension)/m.test(module), `${tree}/${moduleName} still resolves a runtime capability through host placement`);
|
|
check(
|
|
!/(?:\$\{GSTACK_HOME:-\$HOME\/\.gstack\}|\$GSTACK_STATE_ROOT|\$\{GSTACK_STATE_ROOT\}|\$GSTACK_HOME)[^\n]{0,16}\/projects\/(?:\$\{SLUG|\$SLUG\b|\$_PLAN_SLUG\b|<slug>|\{slug\})/.test(module),
|
|
`${tree}/${moduleName} still keys worktree-local state by repository slug`,
|
|
);
|
|
check(!/~\/\.gstack(?:\/|(?=[\s`'"),.;:\]}]))/.test(module), `${tree}/${moduleName} bypasses GSTACK_HOME with a literal state path`);
|
|
check(!/"\$HOME\/\.gstack(?:\/|")/.test(module), `${tree}/${moduleName} bypasses GSTACK_HOME with a quoted HOME state path`);
|
|
check(!/\$\{HOME\}\/\.gstack(?:\/|(?=[\s`'"),.;:\]}]))/.test(module), `${tree}/${moduleName} bypasses GSTACK_HOME with a braced HOME state path`);
|
|
check(!/(?<![-"{])\$HOME\/\.gstack(?:\/|(?=[\s`'"),.;:\]}]))/.test(module), `${tree}/${moduleName} bypasses GSTACK_HOME with an unquoted HOME state path`);
|
|
if (module.includes('GSTACK_BIN=')) check(module.includes('GSTACK_HOME="${GSTACK_HOME:-$HOME/.gstack}"'), `${tree}/${moduleName} lacks the host-neutral runtime root`);
|
|
for (const match of module.matchAll(/(?:^|[\s`(])((?:references\/(?:legacy|sections|support|artifacts)|assets)\/[A-Za-z0-9_.\/-]+)/g)) {
|
|
const relative = match[1].replace(/[.,;:]+$/, '');
|
|
check(fs.existsSync(path.join(ROOT, 'skills', tree, relative)), `${tree}/${moduleName} has an unpackaged local reference: ${relative}`);
|
|
}
|
|
}
|
|
}
|
|
const systemFunctionalPath = path.join(ROOT, 'skills', 'qa', 'references', 'SYSTEM-FUNCTIONAL.md');
|
|
check(fs.existsSync(systemFunctionalPath), 'QA lacks the system-functional execution adapter');
|
|
if (fs.existsSync(systemFunctionalPath)) {
|
|
const adapter = fs.readFileSync(systemFunctionalPath, 'utf8');
|
|
check(
|
|
adapter.includes('Report mode reads `qa-only` and never changes product code')
|
|
&& adapter.includes('`investigate` proves root cause')
|
|
&& adapter.includes('rerun the exact failing probe'),
|
|
'System-functional adapter lost preserved report/fix, root-cause, or exact re-verification behavior',
|
|
);
|
|
for (const source of ['devex-review', 'qa-only', 'qa', 'investigate']) {
|
|
check(fs.existsSync(path.join(ROOT, 'skills', 'qa', 'references', 'legacy', `${source}.md`)), `System-functional QA package lacks preserved ${source}`);
|
|
}
|
|
check(adapter.includes('API, CLI command, backend job, worker, queue consumer, webhook'), 'System-functional adapter does not cover the required non-browser surfaces');
|
|
}
|
|
check(fs.readFileSync(path.join(ROOT, 'skills', 'qa', 'SKILL.md'), 'utf8').includes('references/SYSTEM-FUNCTIONAL.md'), 'QA dispatcher does not load system-functional when activated');
|
|
for (const tree of TREE_NAMES) {
|
|
const dispatcher = fs.readFileSync(path.join(ROOT, 'skills', tree, 'SKILL.md'), 'utf8');
|
|
const authority = path.join(ROOT, 'skills', tree, 'references', 'AUTHORITY-POLICY.md');
|
|
check(fs.existsSync(authority), `${tree} lacks the executable authority/evidence policy`);
|
|
check(dispatcher.includes('references/AUTHORITY-POLICY.md'), `${tree} dispatcher does not load the authority/evidence policy`);
|
|
}
|
|
const effectsPath = path.join(ROOT, 'skills', 'ship', 'references', 'EXTERNAL-EFFECTS.md');
|
|
check(fs.existsSync(effectsPath), 'Ship lacks the durable external-effect protocol');
|
|
if (fs.existsSync(effectsPath)) {
|
|
const effects = fs.readFileSync(effectsPath, 'utf8');
|
|
check(effects.includes('gstack state effect') && effects.includes('Never retry automatically'), 'Ship external-effect protocol lost durable claim or no-repeat behavior');
|
|
}
|
|
check(fs.readFileSync(path.join(ROOT, 'skills', 'ship', 'SKILL.md'), 'utf8').includes('references/EXTERNAL-EFFECTS.md'), 'Ship dispatcher does not bind external actions to durable state');
|
|
|
|
check(files(path.join(ROOT, 'evals', 'parity', 'contracts'), '.json').length === 55, 'Contract fixture count is not 55');
|
|
const baselineRenders = json(path.join(ROOT, 'evals', 'parity', 'baseline-render-hashes.json'));
|
|
check(baselineRenders.base_sha === GSTACK2_BASE_SHA, 'Immutable rendered-baseline SHA mismatch');
|
|
for (const assignment of SOURCE_ASSIGNMENTS) {
|
|
const baseBlob = sourceBlobSha(assignment.source);
|
|
const baselineBody = normalizeGolden(renderLegacyBody(assignment.source));
|
|
const expectedBody = normalizeGolden(renderPortedLegacyBody(assignment.source));
|
|
const immutableRenderHash = baselineRenders.sources[assignment.source];
|
|
if (immutableRenderHash == null) {
|
|
check(assignment.source === 'codex', `${assignment.source} unexpectedly lacks an immutable Codex render`);
|
|
} else {
|
|
check(sha256(baselineBody) === immutableRenderHash, `${assignment.source} current resolver output drifted from the immutable base render`);
|
|
}
|
|
const modulePath = path.join(ROOT, 'skills', assignment.tree, 'references', 'legacy', `${assignment.source}.md`);
|
|
const module = fs.readFileSync(modulePath, 'utf8');
|
|
const generatedBody = extractLegacyBody(module, assignment.source);
|
|
const legacyMarker = `<!-- GSTACK2_LEGACY_BODY_START source=${assignment.source} -->`;
|
|
const prelude = module.slice(0, module.indexOf(legacyMarker));
|
|
check(!/^\s*#{1,6}\s|^\s*[-*]\s|^\s*\d+\.\s/m.test(prelude), `${assignment.source} has visible generated prose before preserved judgment`);
|
|
check(prelude.split('\n').length <= 5, `${assignment.source} generated prelude is not thin`);
|
|
check(generatedBody === expectedBody, `${assignment.source} normalized legacy body differs`);
|
|
check(!generatedBody.includes('## Preamble (run first)'), `${assignment.source} still executes the retired shared onboarding preamble`);
|
|
check(!/MODEL_OVERLAY: claude|CLAUDE_PLAN_FILE|Add routing rules to CLAUDE\.md|Boil the Ocean principle|TEL_PROMPTED|PROACTIVE_PROMPTED/.test(generatedBody), `${assignment.source} retains host-specific onboarding, engagement, or telemetry machinery`);
|
|
check(!/cd <SKILL_DIR> && \.\/setup/.test(generatedBody), `${assignment.source} tells a standard-installed skill to run a nonexistent local setup script`);
|
|
check(!retiredInvocation.test(generatedBody), `${assignment.source} recommends a retired public invocation instead of a six-skill route`);
|
|
const usesRuntimeBinding = /\$(?:GSTACK_BIN|GSTACK_ROOT|GSTACK_STATE_ROOT)\b|\$(?:B|D|P)\b/.test(generatedBody);
|
|
check(!usesRuntimeBinding || generatedBody.includes('## Host-neutral runtime bindings'), `${assignment.source} uses an unbound retained runtime helper variable`);
|
|
check(!/bun\.sh\/install|bun run \$GSTACK_BIN|command -v bun/.test(generatedBody), `${assignment.source} retains GStack-owned host Bun onboarding or invocation`);
|
|
const visiblePointOfUse = ['open-gstack-browser', 'pair-agent', 'setup-browser-cookies'].includes(assignment.source);
|
|
check(
|
|
generatedBody.includes('## Visible-browser point-of-use gate') === visiblePointOfUse,
|
|
`${assignment.source} visible-browser point-of-use gate classification is wrong`,
|
|
);
|
|
check(module.includes(`blob=${baseBlob}`), `${assignment.source} module lacks source blob provenance`);
|
|
check(module.includes(`baseline_render_sha256=${sha256(baselineBody)}`), `${assignment.source} module lacks immutable baseline render hash`);
|
|
check(module.includes(`ported_render_sha256=${sha256(expectedBody)}`), `${assignment.source} module lacks installable port render hash`);
|
|
const contract = json(path.join(ROOT, 'evals', 'parity', 'contracts', `${assignment.source}.json`));
|
|
check(contract.base_sha === GSTACK2_BASE_SHA && contract.blob_sha === baseBlob, `${assignment.source} contract provenance mismatch`);
|
|
check(JSON.stringify(Object.keys(contract.contract).sort()) === JSON.stringify([...CONTRACT_KEYS].sort()), `${assignment.source} contract dimensions mismatch`);
|
|
check(JSON.stringify(contract.contract) === JSON.stringify(contractFor(assignment)), `${assignment.source} contract content mismatch`);
|
|
for (const overlay of overlaysForSource(assignment.source)) {
|
|
check(module.includes(`anchor=${overlay.anchor}`), `${assignment.source} is missing PR #${overlay.pr} anchor`);
|
|
check(module.includes(overlay.body), `${assignment.source} is missing PR #${overlay.pr} judgment body`);
|
|
}
|
|
}
|
|
|
|
const baselinePromptBytes = SOURCE_ASSIGNMENTS.reduce((total, assignment) => total + Buffer.byteLength(renderLegacyBody(assignment.source)), 0);
|
|
const canonicalPromptBytes = SOURCE_ASSIGNMENTS.reduce((total, assignment) => total + Buffer.byteLength(renderPortedLegacyBody(assignment.source)), 0);
|
|
check(canonicalPromptBytes < baselinePromptBytes * 0.5, `Canonical specialist corpus did not cut prompt bytes by at least 50% (${canonicalPromptBytes}/${baselinePromptBytes})`);
|
|
|
|
const sections = legacySections();
|
|
check(sections.length === 16, `Expected 16 section templates; got ${sections.length}`);
|
|
for (const section of sections) {
|
|
check(blobShaForPath(section.relativePath) === json(path.join(ROOT, 'docs', 'gstack-2', 'JUDGMENT-PROVENANCE.json')).sections.find((item: any) => item.source_path === section.relativePath)?.blob_sha, `${section.relativePath} blob provenance mismatch`);
|
|
const assignment = SOURCE_ASSIGNMENTS.find((entry) => entry.source === section.source)!;
|
|
const module = fs.readFileSync(path.join(ROOT, 'skills', assignment.tree, 'references', 'legacy', `${assignment.source}.md`), 'utf8');
|
|
const portedSection = renderPortedLegacySection(section);
|
|
const sectionName = path.basename(section.relativePath).replace(/\.tmpl$/, '');
|
|
check(module.includes(`references/sections/${section.source}/${sectionName}`), `${section.relativePath} lacks its package-local lazy reference`);
|
|
check(!module.includes(portedSection.trim()), `${section.relativePath} is still duplicated inline instead of lazy-loaded`);
|
|
const packaged = path.join(ROOT, 'skills', assignment.tree, 'references', 'sections', section.source, sectionName);
|
|
check(fs.existsSync(packaged), `${section.relativePath} is referenced but not packaged`);
|
|
if (fs.existsSync(packaged)) check(normalizeGolden(fs.readFileSync(packaged, 'utf8')) === normalizeGolden(portedSection), `${section.relativePath} packaged content drifted`);
|
|
}
|
|
|
|
check(SCENARIOS.length === 25, `Expected 25 scenarios; got ${SCENARIOS.length}`);
|
|
check(files(path.join(ROOT, 'evals', 'parity', 'scenarios'), '.json').length === 25, 'Generated scenario fixture count is not 25');
|
|
for (const scenario of SCENARIOS) {
|
|
const routed = routeStructured(scenario.signals);
|
|
const expectedRoute = {
|
|
tree: scenario.expected.tree,
|
|
mode: scenario.expected.mode,
|
|
depth: scenario.expected.depth,
|
|
mutation: scenario.expected.mutation,
|
|
active_modules: scenario.expected.active_modules,
|
|
skipped_modules: scenario.expected.skipped_modules,
|
|
web_context: scenario.expected.web_context,
|
|
};
|
|
check(JSON.stringify(routed) === JSON.stringify(expectedRoute), `${scenario.id} structured route mismatch`);
|
|
check(scenario.expected.decision_basis.length > 0, `${scenario.id} lacks routing evidence`);
|
|
check(JSON.stringify(json(path.join(ROOT, 'evals', 'parity', 'scenarios', `${scenario.id}.json`))) === JSON.stringify(scenario), `${scenario.id} generated fixture drift`);
|
|
}
|
|
|
|
check(BUG_FIX_OVERLAYS.length === 22, `Expected 22 regression definitions; got ${BUG_FIX_OVERLAYS.length}`);
|
|
check(files(path.join(ROOT, 'evals', 'parity', 'regressions'), '.json').length === 22, 'Generated regression fixture count is not 22');
|
|
for (const overlay of BUG_FIX_OVERLAYS) {
|
|
const fixture = json(path.join(ROOT, 'evals', 'parity', 'regressions', `pr-${overlay.pr}.json`));
|
|
check(JSON.stringify(fixture) === JSON.stringify(overlay), `PR #${overlay.pr} regression fixture drift`);
|
|
check(Object.keys(overlay.regression.input).length > 0 && Object.keys(overlay.regression.expected).length > 0, `PR #${overlay.pr} regression is empty`);
|
|
check(
|
|
JSON.stringify(evaluateBugFixRegression(overlay.pr, fixture.regression.input)) === JSON.stringify(fixture.regression.expected),
|
|
`PR #${overlay.pr} executable replacement regression failed`,
|
|
);
|
|
}
|
|
|
|
const manifest = json(path.join(ROOT, 'evals', 'parity', 'manifest.json'));
|
|
const provenance = json(path.join(ROOT, 'docs', 'gstack-2', 'JUDGMENT-PROVENANCE.json'));
|
|
check(JSON.stringify(manifest) === JSON.stringify(provenance), 'Eval manifest and judgment provenance differ');
|
|
check(manifest.base_sha === GSTACK2_BASE_SHA, 'Provenance base SHA mismatch');
|
|
const helperClosure = json(path.join(ROOT, 'evals', 'parity', 'runtime-helper-closure.json'));
|
|
check(JSON.stringify(helperClosure.helpers) === JSON.stringify(manifest.runtime_helpers), 'Runtime helper closure and provenance differ');
|
|
for (const helper of helperClosure.helpers) {
|
|
if (helper.delivery === 'managed-runtime-artifact') {
|
|
check(
|
|
helper.name === 'bun'
|
|
&& helper.component === 'core'
|
|
&& helper.build_step === 'managed-bun'
|
|
&& helper.source_path === '.gstack-runtime-tools/bun'
|
|
&& helper.stable_path.endsWith('/bin/bun'),
|
|
`Managed runtime helper ${helper.name} lacks core-component provenance`,
|
|
);
|
|
} else {
|
|
const sourcePath = helper.platform_source_paths?.[process.platform === 'win32' ? 'win32' : 'posix']
|
|
?? helper.source_path;
|
|
check(fs.existsSync(path.join(ROOT, sourcePath)), `Preserved helper ${helper.name} has no source payload at ${sourcePath}`);
|
|
}
|
|
check(Array.isArray(helper.consumer_modules) && helper.consumer_modules.length > 0, `Preserved helper ${helper.name} has no consumer provenance`);
|
|
}
|
|
for (const record of [...manifest.sources, ...manifest.sections]) {
|
|
check(ALLOWED_DISPOSITIONS.has(record.disposition), `${record.source_path} uses invalid disposition ${record.disposition}`);
|
|
for (const key of PROVENANCE_KEYS) check(record[key] !== undefined, `${record.source_path} lacks provenance field ${key}`);
|
|
}
|
|
for (const asset of manifest.assets) {
|
|
const target = path.join(ROOT, asset.target_path);
|
|
check(fs.existsSync(target), `Missing relocated asset ${asset.target_path}`);
|
|
const baseline = Bun.spawnSync({
|
|
cmd: ['git', 'show', `${GSTACK2_BASE_SHA}:${asset.source_path}`],
|
|
cwd: ROOT,
|
|
stdout: 'pipe',
|
|
stderr: 'pipe',
|
|
});
|
|
check(baseline.exitCode === 0, `Unable to read pinned asset ${asset.source_path}`);
|
|
if (baseline.exitCode === 0) {
|
|
const expected = renderPortedAssetBytes(asset.source_path, baseline.stdout);
|
|
const expectedDisposition = sha256(expected) === sha256(baseline.stdout) ? 'VERBATIM_PORT' : 'MECHANICAL_PORT';
|
|
check(asset.blob_sha === blobShaForPath(asset.source_path), `Relocated asset blob provenance mismatch: ${asset.target_path}`);
|
|
check(asset.baseline_sha256 === sha256(baseline.stdout), `Relocated asset baseline hash mismatch: ${asset.target_path}`);
|
|
check(asset.sha256 === sha256(expected), `Relocated asset port hash mismatch: ${asset.target_path}`);
|
|
check(asset.disposition === expectedDisposition, `Relocated asset disposition mismatch: ${asset.target_path}`);
|
|
if (fs.existsSync(target)) {
|
|
const installed = fs.readFileSync(target);
|
|
check(sha256(installed) === sha256(expected), `Relocated asset hash mismatch: ${asset.target_path}`);
|
|
if (asset.target_path.endsWith('.md')) {
|
|
check(!/~\/.claude\/skills\/gstack|browse\/bin\/remote-slug/.test(installed.toString()), `Relocated asset retains a host-specific runtime path: ${asset.target_path}`);
|
|
check(!retiredInvocationPattern().test(installed.toString()), `Relocated executable asset recommends a retired public invocation: ${asset.target_path}`);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
for (const sectionCopy of manifest.section_copies ?? []) {
|
|
const target = path.join(ROOT, sectionCopy.target_path);
|
|
check(fs.existsSync(target), `Missing packaged section ${sectionCopy.target_path}`);
|
|
if (fs.existsSync(target)) {
|
|
const content = fs.readFileSync(target);
|
|
check(sha256(content) === sectionCopy.sha256, `Packaged section hash mismatch: ${sectionCopy.target_path}`);
|
|
check(!retiredInvocationPattern().test(content.toString()), `Packaged section recommends a retired public invocation: ${sectionCopy.target_path}`);
|
|
}
|
|
}
|
|
for (const dependency of manifest.dependency_copies ?? []) {
|
|
const target = path.join(ROOT, dependency.target);
|
|
check(fs.existsSync(target), `Missing transitive module copy ${dependency.target}`);
|
|
if (fs.existsSync(target)) check(sha256(fs.readFileSync(target)) === dependency.sha256, `Transitive module copy drift: ${dependency.target}`);
|
|
}
|
|
|
|
check(files(path.join(ROOT, 'compat'), '.md').length === 56, 'Compatibility alias file count is not 55 + README');
|
|
const migrationMap = json(path.join(ROOT, 'compat', 'migration-map.json'));
|
|
check(migrationMap.schema_version === 1, 'Compatibility migration map schema mismatch');
|
|
check(migrationMap.aliases.length === 55, 'Compatibility migration map must contain 55 aliases');
|
|
check(migrationMap.policy.default_discoverable === false, 'Compatibility aliases must be opt-in');
|
|
check(
|
|
migrationMap.policy.context_choice_migrated_implicitly === false &&
|
|
migrationMap.policy.context_consent_migrated_implicitly === false,
|
|
'Compatibility migration must not infer Context choice or consent',
|
|
);
|
|
for (const assignment of SOURCE_ASSIGNMENTS) {
|
|
const aliasPath = path.join(ROOT, 'skills', '.compat', assignment.source, 'SKILL.md');
|
|
const needsAlias = !(TREE_NAMES as readonly string[]).includes(assignment.source);
|
|
check(fs.existsSync(aliasPath) === needsAlias, needsAlias
|
|
? `Missing opt-in compatibility alias for ${assignment.source}`
|
|
: `Redundant compatibility alias collides with canonical ${assignment.source}`);
|
|
if (fs.existsSync(aliasPath)) {
|
|
const alias = fs.readFileSync(aliasPath, 'utf8');
|
|
check(alias.includes(assignment.replacement), `${assignment.source} alias lacks exact replacement invocation`);
|
|
check(alias.includes('internal: true'), `${assignment.source} alias must stay out of default discovery`);
|
|
check(!alias.includes('GSTACK2_LEGACY_BODY_START'), `${assignment.source} alias copied specialist judgment`);
|
|
check(alias.split('\n').length < 30, `${assignment.source} alias is not thin`);
|
|
}
|
|
}
|
|
for (const tree of TREE_NAMES) {
|
|
const compatibility = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'COMPATIBILITY.md'), 'utf8');
|
|
check(!compatibility.includes('../../../') && !compatibility.includes('compat/README.md'), `${tree} compatibility map escapes the selected package`);
|
|
for (const reference of ['SHARED-JUDGMENT.md', 'WEB-CONTEXT.md', 'RUNTIME.md']) {
|
|
const referencePath = path.join(ROOT, 'skills', tree, 'references', reference);
|
|
check(fs.existsSync(referencePath), `${tree} lacks ${reference}`);
|
|
if (fs.existsSync(referencePath)) {
|
|
check(
|
|
/^<!-- GENERATED[^\n]* -->\n# /.test(fs.readFileSync(referencePath, 'utf8')),
|
|
`${tree}/${reference} must separate the generated marker from its Markdown heading`,
|
|
);
|
|
}
|
|
}
|
|
const runtimeContract = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'RUNTIME.md'), 'utf8');
|
|
const packagedBootstrap = fs.readFileSync(path.join(ROOT, 'skills', tree, 'references', 'support', 'runtime-bootstrap.mjs'));
|
|
check(packagedBootstrap.equals(fs.readFileSync(path.join(ROOT, 'runtime', 'runtime-bootstrap.mjs'))), `${tree} packaged runtime bootstrap drifted from its source`);
|
|
check(runtimeContract.includes('preview --capability <name>') && runtimeContract.includes('It never downloads components or mutates runtime state.'), `${tree} runtime contract lacks non-mutating exact-byte preview`);
|
|
check(runtimeContract.includes('matching `install` command with the same capabilities and browser flags plus `--yes`'), `${tree} runtime contract lacks explicit approved install invocation`);
|
|
check(runtimeContract.includes('With managed Chromium, logical `browser` expands to `browser-code + browser-headless`') && runtimeContract.includes('Internal `browser-visible` expands to `browser-code + browser-visible` and is managed-only') && runtimeContract.includes('`pdf` depends on `diagram`'), `${tree} runtime contract omits provider-aware component dependency closure`);
|
|
check(runtimeContract.includes('`all` means those five and intentionally excludes visible Chromium'), `${tree} runtime contract lets eager setup install visible Chromium`);
|
|
check(runtimeContract.includes('B=$GSTACK_BIN/browse') && runtimeContract.includes('P=$GSTACK_BIN/make-pdf'), `${tree} runtime contract omits stable launcher bindings`);
|
|
check(runtimeContract.includes('BUN_CMD=$GSTACK_BIN/bun'), `${tree} runtime contract omits the managed Bun binding`);
|
|
check(runtimeContract.includes('discovers Git for Windows Bash') && runtimeContract.includes('Python is not a global GStack prerequisite'), `${tree} runtime contract omits retained shell/Python prerequisite disclosure`);
|
|
}
|
|
for (const required of ['SKILL-MIGRATION.md', 'JUDGMENT-PROVENANCE.json', 'JUDGMENT-PARITY.md', 'SCENARIOS.md']) {
|
|
check(fs.existsSync(path.join(ROOT, 'docs', 'gstack-2', required)), `Missing docs/gstack-2/${required}`);
|
|
}
|
|
|
|
if (checks !== EXPECTED_PARITY_CHECKS) {
|
|
failures.push(`Parity check inventory changed: expected ${EXPECTED_PARITY_CHECKS}, observed ${checks}`);
|
|
}
|
|
if (failures.length) {
|
|
throw new Error(`GStack 2 parity failed (${failures.length}/${checks} checks):\n- ${failures.join('\n- ')}`);
|
|
}
|
|
return {
|
|
checks,
|
|
sources: SOURCE_ASSIGNMENTS.length,
|
|
sections: sections.length,
|
|
scenarios: SCENARIOS.length,
|
|
regressions: BUG_FIX_OVERLAYS.length,
|
|
assets: manifest.assets.length,
|
|
};
|
|
}
|
|
|
|
if (import.meta.main) {
|
|
const result = runParity();
|
|
process.stdout.write(`GStack 2 parity passed: ${result.checks} checks; ${result.sources} sources, ${result.sections} sections, ${result.scenarios} scenarios, ${result.regressions} regressions, ${result.assets} assets.\n`);
|
|
}
|