2026-09-10 18:27:41 -07:00
import fs from "node:fs/promises" ;
import path from "node:path" ;
import os from "node:os" ;
import { execFile as execFileCallback } from "node:child_process" ;
import { promisify } from "node:util" ;
import { createHash } from "node:crypto" ;
import { compileWorkspaceRepository , compileCapabilityResourceRepository } from "./assembly.js" ;
import { createGitCapabilityResolver } from "./git-resolver.js" ;
import { contentDigest , planEvolution , type EvolutionReview , type WorkspaceRevision } from "../capability-model/index.js" ;
2026-09-13 22:07:18 -07:00
import { bindingSchema } from "../bindings/index.js" ;
import { snapshotCommit , checkoutCommit , buildCheckedPackage } from "./checked-build.js" ;
2026-09-10 18:27:41 -07:00
const execFile = promisify ( execFileCallback );
const bytesDigest = ( value : Uint8Array ) => `sha256: ${ createHash ( "sha256" ). update ( value ). digest ( "hex" ) } ` ;
export const localResourceSnapshots = async ( root : string , filename? : string ) : Promise < { resources : { kind : string ; repository : string ; commit : string ; directory : string }[]} > => {
if ( filename ) {
const document = JSON . parse ( await fs . readFile ( filename , "utf8" ));
return { resources : document.resources.map (( entry : { directory : string }) => ({... entry , directory : path.resolve ( path . dirname ( filename ), entry . directory )}))};
}
let directory = await fs . realpath ( root );
for (;;) {
let graphText : string | undefined ;
try { graphText = await fs . readFile ( path . join ( directory , ".quixos/resource-graph.json" ), "utf8" );}
catch ( error ) { if (( error as NodeJS . ErrnoException ). code !== "ENOENT" ) throw error ;}
if ( graphText !== undefined ) {
const graph = JSON . parse ( graphText );
const resources = [];
for ( const entry of graph . resources ) {
const location = await fs . realpath ( path . resolve ( directory , entry . directory ));
if ( ! location . startsWith ( ` ${ directory } /resources/` )) throw new Error ( "Workbench resource escapes managed directory" );
resources . push ({ kind : entry.kind , ... entry . source , directory : location });
}
return { resources };
}
const parent = path . dirname ( directory );
if ( parent === directory ) return { resources : []};
directory = parent ;
}
};
/** Copy actual authoring files without snapshotting jj or creating a Git commit. */
export const snapshotRepository = async ( source : string , destination : string ) => {
const root = await fs . realpath ( source );
const files = async () => ( await execFile ( "git" , [ "-C" , root , "ls-files" , "--cached" , "--others" , "--exclude-standard" , "-z" ], { maxBuffer : 16 * 1024 * 1024 })). stdout . split ( "\0" ). filter ( Boolean ). sort ();
const names = [... new Set ( await files ())];
if ( names . length > 50 _000 ) throw new Error ( "Candidate source exceeds 50000 files" );
const contents : { name : string ; digest : string ; mode : number }[] = [];
let bytes = 0 ;
await fs . mkdir ( destination , { recursive : true , mode : 0o700 });
for ( const name of names ) {
if ( path . isAbsolute ( name ) || name . split ( /[\\/]/ ). some (( part ) => part === ".." || part === ".git" || part === ".jj" )) throw new Error ( "Invalid candidate source path" );
const file = path . join ( root , name );
let metadata ;
try { metadata = await fs . lstat ( file ); } catch ( error ) { if (( error as NodeJS . ErrnoException ). code === "ENOENT" ) continue ; throw error ; }
if ( ! metadata . isFile () || metadata . isSymbolicLink () || ! ( await fs . realpath ( file )). startsWith ( ` ${ root }${ path . sep } ` )) throw new Error ( `Candidate source must be a regular file: ${ name } ` );
const data = await fs . readFile ( file );
bytes += data . length ;
if ( bytes > 128 * 1024 * 1024 ) throw new Error ( "Candidate source exceeds 128 MiB" );
contents . push ({ name , digest : bytesDigest ( data ), mode : metadata.mode & 0 o777 });
await fs . mkdir ( path . dirname ( path . join ( destination , name )), { recursive : true });
await fs . writeFile ( path . join ( destination , name ), data , { flag : "wx" , mode : metadata.mode & 0 o777 });
}
if ( JSON . stringify ([... new Set ( await files ())]) !== JSON . stringify ( names )) throw new Error ( "Source files changed during candidate snapshot" );
for ( const entry of contents ) if ( bytesDigest ( await fs . readFile ( path . join ( root , entry . name ))) !== entry . digest ) throw new Error ( `Source changed during candidate snapshot: ${ entry . name } ` );
return { source : root , directory : destination , treeDigest : contentDigest ( contents ), files : contents };
};
2026-09-13 22:07:18 -07:00
// Local mirrors accelerate resolution, but only their committed locked trees
// may stand in for published dependencies. Never relabel dirty files as a pin.
async function committedResolver ( root : string , temporary : string , filename? : string , publishedOnly = false ) {
const map = publishedOnly ? { resources : []} : await localResourceSnapshots ( root , filename );
const resources = [];
for ( const [ index , entry ] of map . resources . entries ()) {
const directory = path . join ( temporary , `dependency- ${ index } ` );
await checkoutCommit ( entry . directory , entry . commit , directory );
resources . push ({... entry , directory });
}
const snapshotMap = path . join ( temporary , "snapshots.json" );
await fs . writeFile ( snapshotMap , JSON . stringify ({ resources }));
return createGitCapabilityResolver ({ checkoutRoot : path.join ( temporary , "resolved" ), snapshotMap });
}
2026-09-10 18:27:41 -07:00
export const checkResourceCandidate = async ( options : { root : string ; output : string ; kind : "package" | "interface" ; source : { repository : string ; commit : string }; snapshotMap? : string ; publishedOnly? : boolean }) => {
await fs . mkdir ( options . output , { mode : 0o700 });
const temporary = await fs . mkdtemp ( path . join ( os . tmpdir (), "qx-resource-check-" ));
const blockers : string [] = [];
2026-09-13 22:07:18 -07:00
let treeDigest : string | undefined , commit : string | undefined , artifactPath : string | undefined ;
2026-09-10 18:27:41 -07:00
try {
2026-09-13 22:07:18 -07:00
commit = await snapshotCommit ( options . root );
treeDigest = ( await snapshotRepository ( options . root , path . join ( temporary , "observed" ))). treeDigest ;
const root = path . join ( temporary , "source" );
await checkoutCommit ( options . root , commit , root );
const resolveResource = await committedResolver ( options . root , temporary , options . snapshotMap , options . publishedOnly );
const compiled = await compileCapabilityResourceRepository ({ rootDirectory : root , kind : options.kind , source : { resolver : "git" , repository : options.source.repository , commit }, resolveResource });
2026-09-10 18:27:41 -07:00
if ( compiled . resource . kind === "package" ) {
2026-09-13 22:07:18 -07:00
const schema = path . join ( temporary , "bindings.json" );
await fs . writeFile ( schema , JSON . stringify ( bindingSchema ( compiled )));
artifactPath = await buildCheckedPackage ( root , schema , compiled . resource . revision . revisionId );
2026-09-10 18:27:41 -07:00
}
2026-09-13 22:07:18 -07:00
if ( await snapshotCommit ( options . root ) !== commit ) throw new Error ( "Source changed during verification; run the check again" );
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "candidate.json" ), JSON . stringify ( compiled . resource , null , 2 ));
} catch ( error ) {
blockers . push ( error instanceof Error ? error.message : String ( error ));
} finally { await fs . rm ( temporary , { recursive : true , force : true });}
2026-09-13 22:07:18 -07:00
const result = { candidateOnly : true , activationEvidence : false , commit , treeDigest , artifactPath , blockers ,
note : "Checked immutable candidate; cutover independently checks current migration/review requirements. No publication or activation performed." };
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "report.json" ), JSON . stringify ( result , null , 2 ));
return result ;
};
2026-09-13 22:07:18 -07:00
export const checkWorkspaceCandidate = async ( options : { root : string ; output : string ; snapshotMap? : string ; baseline? : string ; reviews? : string }) => {
await fs . mkdir ( options . output , { mode : 0o700 });
const temporary = await fs . mkdtemp ( path . join ( os . tmpdir (), "qx-workspace-check-" ));
const blockers : string [] = [], checks : { packageRevisionId : string ; artifactPath : string }[] = [];
let commit : string | undefined ;
2026-09-10 18:27:41 -07:00
try {
2026-09-13 22:07:18 -07:00
commit = await snapshotCommit ( options . root );
for ( const entry of ( await localResourceSnapshots ( options . root , options . snapshotMap )). resources ) {
const current = await snapshotCommit ( entry . directory );
const tree = async ( revision : string ) => ( await execFile ( "git" , [ "rev-parse" , ` ${ revision } ^{tree}` ], { cwd : entry.directory })). stdout . trim ();
if ( await tree ( current ) !== await tree ( entry . commit )) throw new Error ( `Edited resource is not in the root's locked candidate: ${ entry . directory } . Check that resource, then run qx-workspace resource upgrade --publish to propagate its revision.` );
2026-09-10 18:27:41 -07:00
}
2026-09-13 22:07:18 -07:00
const root = path . join ( temporary , "source" );
await checkoutCommit ( options . root , commit , root );
const resolveResource = await committedResolver ( options . root , temporary , options . snapshotMap );
const compiled = await compileWorkspaceRepository ({ rootDirectory : root , sourceRootCommit : commit , resolveResource });
2026-09-10 18:27:41 -07:00
const baseline = options . baseline ? JSON . parse ( await fs . readFile ( options . baseline , "utf8" )) as WorkspaceRevision : null ;
const reviews = options . reviews ? JSON . parse ( await fs . readFile ( options . reviews , "utf8" )) as EvolutionReview [] : [];
2026-09-13 22:07:18 -07:00
const evolution = planEvolution ( baseline , compiled . workspace , { reviews });
2026-09-10 18:27:41 -07:00
blockers . push (... evolution . blockers );
2026-09-13 22:07:18 -07:00
for ( const resource of compiled . resources . filter ( entry => entry . kind === "package" )) {
// Per-package recursive schema, identical to host activation, not unrelated
// workspace declarations that would unnecessarily invalidate build caches.
const candidate = await compileCapabilityResourceRepository ({ rootDirectory : resource.directory , kind : "package" , source : resource.source , resolveResource });
const schema = path . join ( temporary , "bindings.json" );
await fs . writeFile ( schema , JSON . stringify ( bindingSchema ( candidate )));
const artifactPath = await buildCheckedPackage ( resource . directory , schema , candidate . resource . revision . revisionId );
checks . push ({ packageRevisionId : candidate.resource.revision.revisionId , artifactPath });
2026-09-10 18:27:41 -07:00
}
2026-09-13 22:07:18 -07:00
if ( await snapshotCommit ( options . root ) !== commit ) throw new Error ( "Source changed during verification; run the check again" );
const result = { schemaVersion : 1 , candidateOnly : true , activationEvidence : false , commit , evolution , checks , blockers ,
note : "Checks the committed root and its exact locked dependencies. Resource edits must be verified and repinned before they enter this candidate. No publication or activation performed." };
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "candidate.json" ), JSON . stringify ( compiled . workspace , null , 2 ));
await fs . writeFile ( path . join ( options . output , "report.json" ), JSON . stringify ( result , null , 2 ));
return result ;
} catch ( error ) {
2026-09-13 22:07:18 -07:00
const result = { schemaVersion : 1 , candidateOnly : true , activationEvidence : false , commit , checks , blockers : [... blockers , error instanceof Error ? error.message : String ( error )]};
2026-09-10 18:27:41 -07:00
await fs . writeFile ( path . join ( options . output , "report.json" ), JSON . stringify ( result , null , 2 ));
return result ;
2026-09-13 22:07:18 -07:00
} finally { await fs . rm ( temporary , { recursive : true , force : true });}
2026-09-10 18:27:41 -07:00
};