"""Bounded staged input discovery from pinned inventory and inert build declarations.""" import json import posixpath from build_module_overlay import parse_module_source from chameleon_core.adaptation_contracts import Intake, Residual from chameleon_core.contracts import digest from chameleon_core.entry_capture import capture_entry from chameleon_core.module_resolution import resolve_relative_import from chameleon_core.repository_capture import fetch_bytes, tree_files, verify_package from plan_saved_build import plan_saved_build def capture_build_inputs(source, entry, importer, *, additional_paths=(), fetch=fetch_bytes): """Runs in isolated capture worker; cache frozen GETs and cap the entire chain.""" cache, trees, plans = {}, {}, [] wire = reads = 0 def bounded_fetch(url, limit): nonlocal wire, reads if url not in cache: if reads >= 64: raise Residual('BUILD_DISCOVERY_REQUEST_BUDGET') reads += 1 body = fetch(url, limit) wire += len(body) if len(body) > limit or wire > 6*1024*1024: raise Residual('BUILD_DISCOVERY_WIRE_BUDGET') cache[url] = body if '?recursive=1' in url: tree = json.loads(body) trees[tree['sha']] = tree if len(cache[url]) > limit: raise Residual('BUILD_DISCOVERY_WIRE_BUDGET') return cache[url] paths = sorted(set(additional_paths) | {importer}) previous = None for _ in range(8): package = capture_entry(source, entry, bounded_fetch, additional_paths=paths) repo = package['repository'] if previous and (any(repo[k] != previous[k] for k in ('source', 'commit_sha', 'tree_sha', 'entry')) or any(repo['files'].get(p) != r for p, r in previous['files'].items())): raise Residual('BUILD_DISCOVERY_RECAPTURE_MISMATCH') tree = trees.get(repo['tree_sha']) if tree is None: raise Residual('BUILD_COMPLETE_INVENTORY_REQUIRED') try: plan = discover_build_inputs(package, importer, tree) except ValueError as exc: raise Residual(str(exc)) from exc plans.append(plan) if plan['status'] == 'READY': repo['projection'].update(additional_local_paths=list(additional_paths), build_importer=importer, build_discovered_paths=paths, build_discovery=plans) repo['acquisition'].update(external_reads=reads, wire_bytes=wire) package['digest'] = digest(repo) verify_package(package) return package new_paths = sorted(set(paths) | set(plan['missing_paths'])) if new_paths == paths: raise Residual('BUILD_DISCOVERY_STALLED') paths, previous = new_paths, repo raise Residual('BUILD_DISCOVERY_PASS_BUDGET') def discover_build_inputs(package, importer, tree): files = verify_package(package) repo = package['repository'] if tree.get('sha') != repo['tree_sha']: raise ValueError('BUILD_TREE_BINDING_MISMATCH') inventory = tree_files(tree, metadata_only=True) for name, record in repo['files'].items(): if (name not in inventory or inventory[name]['sha'] != record['git_blob_sha'] or inventory[name]['size'] != record['bytes']): raise ValueError('BUILD_HELD_TREE_MISMATCH') if importer not in files or not importer.endswith(('.js', '.mjs')): raise ValueError('BUILD_CAPTURED_IMPORTER_REQUIRED') metadata = {'package.json', 'rollup.config.js'} if not metadata <= inventory.keys(): raise ValueError('BUILD_DECLARATION_FILES_ABSENT') missing = metadata - files.keys() visited, edges = set(), [] if not missing: plan = plan_saved_build(package)['plan'] aliases = {} for row in plan['mappings']: if row['format'] != 'es': continue destination = row.get('destination', row['output']) if destination in aliases or destination in inventory: raise ValueError('BUILD_ALIAS_AMBIGUOUS') aliases[destination] = row['input'] if not aliases: raise ValueError('BUILD_ES_MAPPING_REQUIRED') pending = [importer] while pending: current = pending.pop() if current in visited: continue visited.add(current) if len(visited) > 64: raise ValueError('BUILD_GRAPH_BUDGET') if current not in inventory: raise ValueError('BUILD_DECLARED_INPUT_ABSENT') if current not in files: missing.add(current) continue syntax = parse_module_source(files[current]) if syntax['dynamic_imports']: raise ValueError('BUILD_DYNAMIC_IMPORT_UNSUPPORTED') for ref in syntax['references']: # Resolver enforces relative-only paths and ambiguity refusal. virtual = {**inventory, **{p: {'sha': '0'*40, 'size': 0} for p in aliases if p not in inventory}} resolution = resolve_relative_import(current, ref['specifier'], virtual) if resolution['status'] != 'RESOLVED': raise ValueError('BUILD_MODULE_' + resolution['status']) destination = resolution['matches'][0]['path'] source = destination if destination in inventory else aliases[destination] if not posixpath.splitext(source)[1] in ('.js', '.mjs'): raise ValueError('BUILD_SOURCE_TYPE_UNSUPPORTED') edges.append({'importer': current, 'specifier': ref['specifier'], 'destination': destination, 'source': source}) pending.append(source) paths = sorted(set(repo.get('projection', {}).get('additional_local_paths', [])) | missing) if len(paths) > 16: raise ValueError('BUILD_CAPTURE_PATH_BUDGET') intake = Intake(source=repo['source']+'/tree/'+repo['commit_sha'], source_type='repository', repository_scope='entry', repository_entry=repo['entry'], repository_assets=paths, build_counterpart=True) return {'version': 'build-input-discovery-v1', 'source_digest': package['digest'], 'inventory_digest': digest(tree), 'status': 'CAPTURE_REQUIRED' if missing else 'READY', 'missing_paths': sorted(missing), 'visited': sorted(visited), 'edges': edges, 'intake': intake.model_dump(mode='json'), 'verified_application': False}