2026-10-13 16:16:31 +08:00
import { closeOwnedWebResources } from "./closeOwnedWebResources.js" ;
2026-10-07 14:59:52 +08:00
import { isAbsolute } from "node:path" ;
import { z } from "zod" ;
import type { AnalysisError } from "../domain/analysisErrorBase.js" ;
import {
AnalysisCancelledError ,
AnalysisCapabilityUnavailableError ,
AnalysisInputError ,
AnalysisOutputError ,
} from "../domain/analysisErrorCore.js" ;
import { createEvidence , type Evidence } from "../domain/evidence.js" ;
import { jsonObjectSchema } from "../domain/jsonValue.js" ;
2026-10-09 00:20:45 +08:00
import { analysisInputErrorFromIssues } from "../domain/inputIssueProjection.js" ;
2026-10-07 14:59:52 +08:00
import { err , ok , type Result } from "../domain/result.js" ;
import {
collectWebModuleImports ,
matchCapturedModules ,
} from "../domain/webModuleImports.js" ;
import {
webModuleTraceInputSchema ,
webModuleFileSchema ,
webModuleResolutionSchema ,
webModuleTraceResultSchema ,
type WebModuleResolution ,
type WebModuleTraceResult ,
} from "../domain/webModuleTrace.js" ;
2026-10-07 16:21:18 +08:00
import { selectedWebScriptArtifactsSchema } from "../domain/webScriptArtifacts.js" ;
import {
capturedWebManifestIdentity ,
validateSelectedWebScript ,
} from "./ValidateSelectedWebScript.js" ;
2026-10-07 14:59:52 +08:00
import type { ExecutionOptions } from "./AnalysisProvider.js" ;
import type {
WebModuleArtifactPort ,
WebModuleResolutionPort ,
WebModuleResolutionBatch ,
} from "./WebModulePorts.js" ;
const OPERATION = "trace_web_module_imports" ;
2026-10-07 16:21:18 +08:00
const artifactsSchema = selectedWebScriptArtifactsSchema . extend ({
2026-10-07 14:59:52 +08:00
importMap : z
. strictObject ({
file : webModuleFileSchema ,
baseUrl : z.string (). min ( 1 ),
value : jsonObjectSchema ,
})
. nullable (),
});
const resolutionBatchSchema = z . strictObject ({
engine : z.strictObject ({ id : z.string (). min ( 1 ), version : z.string (). min ( 1 ) }),
resolutions : z.array ( webModuleResolutionSchema ),
diagnostics : z.array ( z . string ()),
rawResult : jsonObjectSchema ,
});
const LIMITATIONS = [
"Resolution is derived under the selected importer URL and optional import map; it does not establish that the map was installed in the captured page." ,
"Reported source URLs do not establish an inline script's document base. Select importer_url explicitly when that context differs or is unknown." ,
"Captured candidates retain reported capture metadata; their bytes are not reverified by this trace unless selected as the source." ,
"A resolved URL or captured source does not prove loading, execution, UI causality, or module namespace identity. Execution remains unknown." ,
"Only native ES module syntax is resolved. Bundler module IDs, computed imports, workers and non-HTTP(S) importer contexts are outside this trace." ,
"Source-map declarations do not establish retained map bytes or executed original-source coverage." ,
"Selected application code is parsed as data and never executed. The owned resolver executes only REA's trusted stub; its page requests are locally fulfilled or blocked." ,
"Manifest, source and import-map limits are 32 MiB, 16 MiB and 4 MiB. Native resolution has a 20-second deadline; cleanup may extend it. One renderer is requested; no aggregate host memory limit is claimed." ,
];
/** Compose verified captures, inert syntax and a replaceable URL resolution engine. */
export class WebModuleTraceService {
constructor (
readonly artifacts : WebModuleArtifactPort ,
readonly resolver : WebModuleResolutionPort ,
) {}
2026-10-12 04:44:25 +08:00
close () : Promise < void > {
2026-10-13 16:08:41 +08:00
return closeOwnedWebResources (
[
this . artifacts . close ? . bind ( this . artifacts ),
this . resolver . close ? . bind ( this . resolver ),
],
"Web module trace cleanup failed" ,
);
2026-10-12 04:44:25 +08:00
}
2026-10-07 14:59:52 +08:00
async trace (
rawInput : unknown ,
options? : ExecutionOptions ,
) : Promise < Result < Evidence , AnalysisError >> {
if ( isAborted ( options ? . signal ))
return err ( new AnalysisCancelledError ( OPERATION ));
const parsed = webModuleTraceInputSchema . safeParse ( rawInput );
if ( ! parsed . success )
return err (
2026-10-09 00:20:45 +08:00
analysisInputErrorFromIssues ( OPERATION , parsed . error . issues , rawInput , {
cause : parsed.error ,
}),
2026-10-07 14:59:52 +08:00
);
const input = parsed . data ;
for ( const [ path , value ] of [
[ "manifest_path" , input . manifest_path ],
[ "import_map.path" , input . import_map ? . path ],
] as const )
if ( value !== undefined && ! isAbsolute ( value ))
return invalid (
path . split ( "." ),
"Expected an absolute filesystem path on this host." ,
);
for ( const [ path , value ] of [
[ "importer_url" , input . importer_url ],
[ "import_map.base_url" , input . import_map ? . base_url ],
] as const )
if ( value !== undefined ) {
const admission = admitContext ( value , path . split ( "." ));
if ( ! admission . ok ) return admission ;
}
const loaded = await this . artifacts . load ( input , options );
if ( ! loaded . ok ) return loaded ;
const verified = artifactsSchema . safeParse ( loaded . value );
if ( ! verified . success )
return err (
new AnalysisOutputError (
OPERATION ,
"Artifact port returned a malformed source/manifest report." ,
),
);
const data = verified . data ;
if (
( input . import_map ? . path ?? null ) !==
( data . importMap ? . file . path ?? null ) ||
( input . import_map ? . base_url ?? null ) !== ( data . importMap ? . baseUrl ?? null )
)
return err (
new AnalysisOutputError (
OPERATION ,
"Artifact port changed the selected import-map identity/context." ,
),
);
2026-10-07 16:21:18 +08:00
const bound = validateSelectedWebScript ( input , data , OPERATION );
if ( ! bound . ok ) return bound ;
const selected = bound . value ;
2026-10-07 14:59:52 +08:00
const importerUrl = input . importer_url ?? selected . url ;
const importerAdmission = admitContext ( importerUrl , [ "importer_url" ]);
if ( ! importerAdmission . ok ) return importerAdmission ;
const syntax = collectWebModuleImports ( data . source );
const literals = syntax . imports
. filter (( reference ) => reference . specifier !== null )
. map (( reference ) => reference . specifier ?? "" );
let resolution : WebModuleResolutionBatch | null = null ;
if ( literals . length > 0 ) {
const execution = await this . resolver . resolve (
{
importerUrl ,
importMap :
data.importMap === null
? null
: {
baseUrl : data.importMap.baseUrl ,
value : data.importMap.value ,
},
specifiers : literals ,
},
options ,
);
if ( ! execution . ok ) return execution ;
const report = resolutionBatchSchema . safeParse ( execution . value );
if ( ! report . success )
return err (
new AnalysisOutputError (
OPERATION ,
"Resolver returned malformed or incomplete native evidence." ,
),
);
resolution = report . data ;
if ( resolution . resolutions . length !== literals . length )
return err (
new AnalysisOutputError (
OPERATION ,
"Resolver did not return every selected literal specifier." ,
),
);
}
if ( isAborted ( options ? . signal ))
return err ( new AnalysisCancelledError ( OPERATION ));
let literalIndex = 0 ;
const imports : WebModuleTraceResult [ "imports" ] = [];
for ( const reference of syntax . imports ) {
let resolved :
| WebModuleResolution
| { state : "unknown" ; reason : "computed-specifier" };
if ( reference . specifier === null )
resolved = { state : "unknown" , reason : "computed-specifier" };
else {
const reported = resolution ? . resolutions [ literalIndex ++ ];
if ( reported === undefined )
return err (
new AnalysisOutputError (
OPERATION ,
"Resolver omitted a selected literal specifier." ,
),
);
resolved = reported ;
}
imports . push ({
... reference ,
resolution : resolved ,
captured_candidates :
resolved.state === "resolved"
? matchCapturedModules ( resolved . url , data . manifest . scripts )
: [],
execution : "unknown" ,
});
}
const normalized = webModuleTraceResultSchema . safeParse ({
2026-10-07 16:21:18 +08:00
manifest : capturedWebManifestIdentity ( data ),
2026-10-07 14:59:52 +08:00
source : {
... data . sourceFile ,
script_index : input.script_index ,
script : selected ,
},
importer : {
url : importerUrl ,
basis :
input.importer_url === undefined
? "reported-source-url"
: "caller-selected" ,
},
import_map :
data.importMap === null
? null
: { ... data . importMap . file , base_url : data.importMap.baseUrl },
parser : {
state : syntax.state ,
diagnostics : syntax.diagnostics ,
location_units : "UTF-16 offsets and columns; one-based lines" ,
},
engine : resolution?.engine ?? null ,
imports ,
diagnostics : resolution?.diagnostics ?? [],
limitations : LIMITATIONS ,
});
if ( ! normalized . success )
return err ( new AnalysisOutputError ( OPERATION , normalized . error . message ));
return ok (
createEvidence (
{
path : data.sourceFile.path ,
sha256 : data.sourceFile.sha256 ,
format : "file" ,
},
{
id : "rea-web-module-trace" ,
name : "REA captured module relationship trace" ,
version : "1" ,
},
{
operation : OPERATION ,
parameters : jsonObjectSchema.parse ( input ),
result : normalized.data ,
rawResult : resolution?.rawResult ?? null ,
confidence : "derived" ,
authority : "historical-reference" ,
limitations : LIMITATIONS ,
locations : [
{ kind : "artifact-path" , path : data.sourceFile.path },
{ kind : "artifact-path" , path : input.manifest_path },
],
evidenceLinks :
data.manifest.source_evidence_id === null
? []
: [ data . manifest . source_evidence_id ],
},
),
);
}
}
const admitContext = (
value : string ,
path : readonly string [],
) : Result < null , AnalysisError > => {
let url : URL ;
try {
url = new URL ( value );
} catch ( cause : unknown ) {
void cause ;
return invalid (
path ,
"Expected an absolute context URL. Select importer_url explicitly when the reported source URL is unknown." ,
);
}
if ( url . username !== "" || url . password !== "" )
return invalid (
path ,
"Module context URLs must not contain username/password userinfo." ,
);
if ( url . protocol !== "http:" && url . protocol !== "https:" )
return err (
new AnalysisCapabilityUnavailableError (
"web-module-resolution" ,
OPERATION ,
"unsupported_context_scheme" ,
{
userMessage : ` ${ path . join ( "." ) } ${ value } uses unsupported ${ url . protocol } context. This trace currently supports HTTP(S) importer/map contexts; select a supported context explicitly when appropriate.` ,
},
),
);
return ok ( null );
};
const isAborted = ( signal? : AbortSignal ) : boolean => signal ? . aborted === true ;
const invalid = ( path : readonly string [], message : string ) =>
err (
new AnalysisInputError ( OPERATION , undefined , [
{ path , reason : "invalid_format" , message },
]),
);