From f253b5ae399a79db3a3ebf73be19ccce747762db Mon Sep 17 00:00:00 2001 From: egallmann Date: Thu, 13 Aug 2026 16:24:11 -0400 Subject: [PATCH 1/3] feat: add public runtime workspace foundation --- ...onal-execution-for-project-level-sema.yaml | 9 +- ...-L-0009-unified-workspace-scope-model.yaml | 13 +- ...17-recon-workspace-execution-contract.yaml | 5 +- ...deterministic-workspace-graph-queries.yaml | 7 +- package.json | 8 + scripts/prove-public-tarball.mjs | 94 +++ src/index.ts | 208 +------ src/public/canonical.ts | 75 +++ src/public/errors.ts | 25 + src/public/graph.ts | 84 +++ src/public/index.ts | 34 ++ src/public/runtime.test.ts | 333 ++++++++++ src/public/runtime.ts | 573 ++++++++++++++++++ src/public/types.ts | 169 ++++++ src/workspace/manifest.ts | 9 +- src/workspace/workspace-merge.ts | 69 ++- src/workspace/workspace-recon.ts | 6 + test/fixtures/public-consumer/bootstrap.mjs | 4 + test/fixtures/public-consumer/consumer.ts | 31 + test/fixtures/public-consumer/package.json | 8 + test/fixtures/public-consumer/tsconfig.json | 11 + vitest.config.ts | 3 +- 22 files changed, 1565 insertions(+), 213 deletions(-) create mode 100644 scripts/prove-public-tarball.mjs create mode 100644 src/public/canonical.ts create mode 100644 src/public/errors.ts create mode 100644 src/public/graph.ts create mode 100644 src/public/index.ts create mode 100644 src/public/runtime.test.ts create mode 100644 src/public/runtime.ts create mode 100644 src/public/types.ts create mode 100644 test/fixtures/public-consumer/bootstrap.mjs create mode 100644 test/fixtures/public-consumer/consumer.ts create mode 100644 test/fixtures/public-consumer/package.json create mode 100644 test/fixtures/public-consumer/tsconfig.json diff --git a/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml b/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml index c4c126a..17b34cc 100644 --- a/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml +++ b/adrs/logical/ADR-L-0001-recon-provisional-execution-for-project-level-sema.yaml @@ -47,13 +47,16 @@ interaction_contracts: [] constraints: [] invariants: - id: 019ff84e-4ece-7387-b33f-3d203e3c968c - statement: 'Single repository only: RECON discovers files within the current repository. - Cross-repository reconciliation is out of scope.' + statement: 'RECON extraction is repository-local: each repository observation discovers + files within its registered repository source. Workspace orchestration may compose + multiple repository observations into one derived workspace projection. Cross-workspace + reconciliation remains out of scope unless explicitly federated.' scope: global enforcement_level: must enforcement_mechanism: design verification_method: manual - rationale: Extracted from ADR-L-0001 specification + rationale: Repository source remains the provenance boundary while workspace orchestration + provides the bounded multi-repository graph shell. compliance_frameworks: [] exceptions: [] alias_id: INV-0001 diff --git a/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml b/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml index e5084e6..5eac08a 100644 --- a/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml +++ b/adrs/logical/ADR-L-0009-unified-workspace-scope-model.yaml @@ -20,7 +20,10 @@ context: 'Reconnaissance tools traditionally assume a single repository as the u level so that cross-repo relationships, shared configuration, and - aggregate evidence can be captured correctly. + aggregate evidence can be captured correctly. A workspace is a durably identified + engineering scope composed of one or more repositories. Repositories are the only + workspace membership units; graph entities remain derived projections with repository + provenance. ' capabilities: @@ -68,7 +71,13 @@ decision: 'Scope is a workspace. A workspace contains one or more repositories. is --workspace where the workspace contains one repo. No - special-case code exists for single-repo mode. + special-case code exists for single-repo mode. Workspace identity is an immutable + UUIDv7 minted by explicit registration creation. Registration-scoped repository + UUIDv7 identities preserve provenance across local materialization path changes. + Definition revisions are runtime-generated canonical digests and do not change + workspace identity. Repository boundaries preserve source provenance; the workspace + boundary governs graph traversal. Cross-workspace traversal is fail-closed unless + future federation is explicitly declared. ' consequences: diff --git a/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml b/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml index 74d313d..eaae3f8 100644 --- a/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml +++ b/adrs/logical/ADR-L-0017-recon-workspace-execution-contract.yaml @@ -31,7 +31,10 @@ context: 'ADR-L-0009 fixes workspace as the universal scope unit. This ADR recor phase-level extraction behavior; workspace orchestration layered here only - coordinates repos and aggregates outcomes. + coordinates repos and aggregates outcomes. The public runtime adapter treats each + refresh as an immutable observation event: observed repositories alone contribute + current graph content, partial observations contain no stale failed-repository + projection, and zero observed repositories produce a typed refresh failure. ' implements_physical_system_ref: diff --git a/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml b/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml index 908f76f..cc1cebf 100644 --- a/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml +++ b/adrs/logical/ADR-L-0018-deterministic-workspace-graph-queries.yaml @@ -59,7 +59,12 @@ context: 'The workspace semantic graph (verb-typed edges in slices/*.yaml, produ API. loadAidocGraph() only consumes per-repo RECON YAML with _slice blocks. A - new loader and query layer was required. + new loader and query layer was required. Public graph references are scoped by + workspace and immutable snapshot identity; legacy node IDs are opaque projection + keys rather than durable source/entity identity. Repository identity is carried as + separate node and relationship provenance. Traversal is bounded by one workspace + and one snapshot projection and rejects foreign workspace or snapshot endpoints. + The runtime performs no semantic or probabilistic reasoning. ' capabilities: diff --git a/package.json b/package.json index acc398a..00ebcb3 100644 --- a/package.json +++ b/package.json @@ -6,6 +6,13 @@ "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "import": "./dist/index.js", + "default": "./dist/index.js" + } + }, "files": [ "dist", "README.md", @@ -27,6 +34,7 @@ "test:watch": "vitest", "test:unit": "vitest run", "test:integration": "node scripts/prove-architecture-compile.mjs", + "test:public-tarball": "node scripts/prove-public-tarball.mjs", "release:check": "node scripts/check-release-policy.mjs", "test:coverage": "vitest run --coverage", "lint": "eslint src vitest.config.ts", diff --git a/scripts/prove-public-tarball.mjs b/scripts/prove-public-tarball.mjs new file mode 100644 index 0000000..07ada56 --- /dev/null +++ b/scripts/prove-public-tarball.mjs @@ -0,0 +1,94 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { execFile } from 'node:child_process'; +import { promisify } from 'node:util'; + +const execFileAsync = promisify(execFile); +const repositoryRoot = path.resolve(fileURLToPath(new URL('../', import.meta.url))); +const fixtureRoot = path.join(repositoryRoot, 'test', 'fixtures', 'public-consumer'); +const tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-public-tarball-')); +const consumerRoot = path.join(tempRoot, 'consumer'); +const packRoot = path.join(tempRoot, 'pack'); +const sourceFixtureRoot = path.join(consumerRoot, 'source-fixture'); +const packNpmCache = path.join(tempRoot, 'npm-pack-cache'); +const installNpmCache = process.env.npm_config_cache + ?? (process.env.LOCALAPPDATA ? path.join(process.env.LOCALAPPDATA, 'npm-cache') : undefined); +const npmCommand = process.platform === 'win32' ? 'npm.cmd' : 'npm'; +const tscCommand = process.platform === 'win32' + ? path.join(consumerRoot, 'node_modules', '.bin', 'tsc.cmd') + : path.join(consumerRoot, 'node_modules', '.bin', 'tsc'); + +try { + await fs.mkdir(consumerRoot, { recursive: true }); + await fs.mkdir(packRoot, { recursive: true }); + await fs.copyFile(path.join(fixtureRoot, 'package.json'), path.join(consumerRoot, 'package.json')); + await fs.copyFile(path.join(fixtureRoot, 'consumer.ts'), path.join(consumerRoot, 'consumer.ts')); + await fs.copyFile(path.join(fixtureRoot, 'bootstrap.mjs'), path.join(consumerRoot, 'bootstrap.mjs')); + await fs.copyFile(path.join(fixtureRoot, 'tsconfig.json'), path.join(consumerRoot, 'tsconfig.json')); + await fs.mkdir(path.join(sourceFixtureRoot, 'src'), { recursive: true }); + await fs.writeFile(path.join(sourceFixtureRoot, 'package.json'), '{"name":"public-consumer-source-fixture","version":"1.0.0"}\n'); + await fs.writeFile(path.join(sourceFixtureRoot, 'src', 'index.ts'), 'export const fixture = 1;\n'); + + await execFileAsync(npmCommand, ['run', 'build'], { + cwd: repositoryRoot, + maxBuffer: 4 * 1024 * 1024, + shell: true, + env: { ...process.env, npm_config_cache: packNpmCache }, + }); + + const { stdout } = await execFileAsync(npmCommand, ['pack', '--ignore-scripts', '--json', '--pack-destination', packRoot], { + cwd: repositoryRoot, + maxBuffer: 1024 * 1024, + shell: true, + env: { + ...process.env, + npm_config_cache: packNpmCache, + }, + }); + const packed = JSON.parse(stdout.slice(stdout.indexOf('['))); + const tarball = path.resolve(packRoot, packed[0].filename); + + await execFileAsync(npmCommand, [ + 'install', tarball, '--ignore-scripts', '--no-audit', '--no-fund', '--no-package-lock', + ], { + cwd: consumerRoot, + maxBuffer: 4 * 1024 * 1024, + shell: true, + env: { + ...process.env, + ...(installNpmCache ? { npm_config_cache: installNpmCache } : {}), + npm_config_fetch_retries: '0', + npm_config_fetch_timeout: '10000', + }, + }); + + await execFileAsync(tscCommand, ['--project', 'tsconfig.json'], { + cwd: consumerRoot, + maxBuffer: 4 * 1024 * 1024, + shell: true, + }); + + const result = await execFileAsync(process.execPath, ['bootstrap.mjs', sourceFixtureRoot], { + cwd: consumerRoot, + maxBuffer: 1024 * 1024, + }); + for (const forbidden of ['.ste', '.ste-self', '.workspace-graph']) { + const candidate = path.join(sourceFixtureRoot, forbidden); + await expectMissing(candidate); + } + process.stdout.write(result.stdout); +} finally { + await fs.rm(tempRoot, { recursive: true, force: true }); +} + +async function expectMissing(candidate) { + try { + await fs.access(candidate); + throw new Error(`Packed consumer created forbidden state path: ${candidate}`); + } catch (error) { + if (error && typeof error === 'object' && 'code' in error && error.code === 'ENOENT') return; + throw error; + } +} diff --git a/src/index.ts b/src/index.ts index 672d4f0..07ee9bc 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,206 +1,8 @@ /** - * ste-runtime + * Supported ste-runtime Public Runtime SDK. * - * Portable RECON, RSS, workspace-graph, and runtime-evidence implementation - * for supervised AI-assisted development workflows. - * - * ## For AI Coding Assistants (Cursor, Copilot, etc.) - * - * A built source checkout provides programmatic access to the semantic graph. - * Instead of using the CLI, local consumers can import and call the functions - * directly: - * - * ```typescript - * import { initRssContext, search, blastRadius } from './dist/index.js'; - * - * const ctx = await initRssContext('.ste/state'); - * const results = search(ctx, 'user authentication'); - * const impact = blastRadius(ctx, results.nodes[0].key); - * ``` - * - * See instructions/RSS-PROGRAMMATIC-API.md for current documentation. These - * exports are documented for source-checkout use and do not create an npm - * compatibility commitment while the package remains private and unpublished. - * - * @module ste-runtime + * Legacy RECON, RSS, CLI, MCP, and workspace implementation modules remain + * available through their repository-internal paths. The package root exposes + * only the narrow production foundation contract. */ - -// ============================================================================ -// RSS - Runtime State Slicing (semantic graph traversal) -// ============================================================================ - -export { - // Core initialization - initRssContext, - - // Direct retrieval - lookup, - lookupByKey, - - // Graph traversal - dependencies, - dependents, - blastRadius, - - // Discovery - search, - byTag, - findEntryPoints, - - // Context assembly - assembleContext, - - // Statistics - getGraphStats, - - // Hybrid workflow helpers (RSS + Grep) - extractFilePaths, - getRelevantFiles, - - // Graph validation and health - validateBidirectionalEdges, - findOrphanedNodes, - findAllBrokenEdges, - validateGraphHealth, - - // Types - type RssContext, - type RssQueryResult, - type BrokenEdge, - type BidirectionalInconsistency, -} from './rss/rss-operations.js'; - -export { - // Graph data types - type AidocNode, - type AidocGraph, - type AidocEdge, - loadAidocGraph, -} from './rss/graph-loader.js'; - -// ============================================================================ -// RECON - Semantic Extraction (optional - typically run via CLI) -// ============================================================================ - -// Note: RECON is primarily invoked via CLI (npm run recon:full) -// but the engine can be imported for programmatic use if needed. - -export { executeRecon, type ReconOptions, type ReconResult } from './recon/index.js'; - -// ============================================================================ -// CQI - Conversational Query Interface -// ============================================================================ - -export { - // Engine for session-based queries (caches context) - ConversationalQueryEngine, - - // Convenience function for one-off queries - ask, - - // Output formatters - formatForHuman, - formatForAgent, - - // Types - type ConversationalResponse, - type QueryIntent, - type NodeSummary, -} from './rss/conversational-query.js'; - -// ============================================================================ -// Architecture Bundle Discovery -// ============================================================================ - -export { - loadArchitectureBundle, - type ArchitectureBundleArtifact, - type ArchitectureBundleIndexSummary, - type ArchitectureBundleManifestSummary, - type ArchitectureBundleResult, - type ArchitectureBundleStatus, -} from './discovery/architecture-bundle.js'; - -export { - buildArchitectureEvidence, - runArchitectureEvidenceCommand, - deriveSubjectsFromBundle, - type ArchitectureEvidence, - type ArchitectureEvidenceFreshnessStatus, - type ArchitectureEvidenceVersion, - type EvidenceSubject, - type EvidenceSubjectKind, - type EvidenceSubjectEffect, -} from './cli/evidence-command.js'; - -// ============================================================================ -// Workspace Graph Queries (non-LLM canned traversals) -// ============================================================================ - -export { - loadWorkspaceGraph, - systemDependencies, - componentIntegration, - blastRadiusWorkspace, - toMermaid, - toTable, - toAdjacencyMatrix, - type WorkspaceGraph, - type WorkspaceNode, - type WorkspaceEdge, - type SystemDependencyResult, - type RepoDependency, - type ComponentIntegrationResult, - type IntegrationGroup, - type WorkspaceBlastRadiusResult, - type BlastTier, - type CannedQueryResult, - type AdjacencyMatrixResult, -} from './workspace/index.js'; - -export { - assertMvcDefinitionContract, - assertMvcFederatedIdentity, - assertMvcSnapshotCandidateOnly, - buildMvcSnapshotCandidate, - canonicalMvcFingerprintInput, - recommendMvcDepthFromTopology, - traverseMvcSFromLinkageSurface, - traverseMvcSCandidates, - type BuildMvcSnapshotInput, - type MvcLinkageSurface, - type MvcLinkageSurfaceRelationshipRecord, - type MvcDepthRecommendation, - type MvcDepthRecommendationInput, - type MvcDefinition, - type MvcNegativeSpace, - type MvcRationale, - type MvcRef, - type MvcRefWithHash, - type MvcSnapshot, - type MvcTopologyMetrics, - type MvcTraversalRelationshipRecord, - type TraverseMvcSFromLinkageSurfaceInput, - type TraverseMvcSCandidatesInput, -} from './workspace/mvc-evolution.js'; - -// ============================================================================ -// Architecture compilation and runtime-owned evidence artifacts -// ============================================================================ - -export { - compileArchitecture, - runArchitecturePipeline, - architectureMerge, - emptyReconSnapshot, - buildAdrGraph, - assembleDiscoveryBundle, - type CompileArchitectureOptions, - type CompileArchitectureResult, - type PipelineRunOptions, - type ArchModelState, - type AdrGraph, - type ReconArchitectureSnapshot, - type CompileDiagnostic, - type DiscoveryBundle, -} from './architecture/index.js'; +export * from './public/index.js'; diff --git a/src/public/canonical.ts b/src/public/canonical.ts new file mode 100644 index 0000000..4276910 --- /dev/null +++ b/src/public/canonical.ts @@ -0,0 +1,75 @@ +import crypto from 'node:crypto'; +import path from 'node:path'; + +import type { + DefinitionRevision, + RepositoryDefinition, + RepositoryId, + WorkspaceDefinition, +} from './types.js'; + +export const PUBLIC_RUNTIME_CONTRACT_VERSION = '1.0.0'; + +export function normalizeLocalSourcePath(input: string): string { + const resolved = path.resolve(input.trim()); + const normalized = path.normalize(resolved); + return process.platform === 'win32' ? normalized.replace(/\\/g, '/').toLowerCase() : normalized; +} + +export function canonicalize(value: unknown): string { + if (value === undefined) return 'null'; + if (value === null || typeof value === 'string' || typeof value === 'number' || typeof value === 'boolean') { + return JSON.stringify(value); + } + if (Array.isArray(value)) { + return `[${value.map(canonicalize).join(',')}]`; + } + if (typeof value === 'object') { + const record = value as Record; + return `{${Object.keys(record) + .sort() + .filter(key => record[key] !== undefined) + .map(key => `${JSON.stringify(key)}:${canonicalize(record[key])}`) + .join(',')}}`; + } + throw new TypeError(`Unsupported value in canonical representation: ${typeof value}`); +} + +export function canonicalDefinition(definition: WorkspaceDefinition): string { + const repositories = [...definition.repositories] + .map(repository => ({ + repositoryId: repository.repositoryId, + source: { + kind: repository.source.kind, + path: normalizeLocalSourcePath(repository.source.path), + }, + })) + .sort((a, b) => a.repositoryId.localeCompare(b.repositoryId)); + + return canonicalize({ + contractVersion: PUBLIC_RUNTIME_CONTRACT_VERSION, + repositories, + }); +} + +export function definitionRevision(definition: WorkspaceDefinition): DefinitionRevision { + return `sha256:${crypto.createHash('sha256').update(canonicalDefinition(definition), 'utf8').digest('hex')}` as DefinitionRevision; +} + +export function canonicalObservation(value: unknown): string { + return canonicalize(value); +} + +export function observationFingerprint(value: unknown): string { + return `sha256:${crypto.createHash('sha256').update(canonicalObservation(value), 'utf8').digest('hex')}`; +} + +export function canonicalRepositoryDefinition( + repositoryId: RepositoryId, + sourcePath: string, +): RepositoryDefinition { + return { + repositoryId, + source: { kind: 'local', path: normalizeLocalSourcePath(sourcePath) }, + }; +} diff --git a/src/public/errors.ts b/src/public/errors.ts new file mode 100644 index 0000000..f4f3758 --- /dev/null +++ b/src/public/errors.ts @@ -0,0 +1,25 @@ +import type { RepositoryId, RuntimeDiagnostic } from './types.js'; + +export class RuntimeContractError extends Error { + readonly code: string; + readonly diagnostic: RuntimeDiagnostic; + + constructor(code: string, message: string, repositoryIds?: readonly RepositoryId[]) { + super(`${code}: ${message}`); + this.name = 'RuntimeContractError'; + this.code = code; + this.diagnostic = { code, message, repositoryIds }; + } +} + +export class RefreshError extends Error { + readonly code: string; + readonly diagnostics: readonly RuntimeDiagnostic[]; + + constructor(code: string, message: string, diagnostics: readonly RuntimeDiagnostic[] = []) { + super(message); + this.name = 'RefreshError'; + this.code = code; + this.diagnostics = diagnostics; + } +} diff --git a/src/public/graph.ts b/src/public/graph.ts new file mode 100644 index 0000000..3e22884 --- /dev/null +++ b/src/public/graph.ts @@ -0,0 +1,84 @@ +import { RuntimeContractError } from './errors.js'; +import type { + GraphNodeRef, + GraphNode, + GraphProjection, + GraphRelationship, + SnapshotId, + TraversalOptions, + WorkspaceId, +} from './types.js'; + +function refKey(ref: GraphNodeRef): string { + return `${ref.workspaceId}:${ref.snapshotId}:${ref.nodeId}`; +} + +export function createGraphProjection( + workspaceId: WorkspaceId, + snapshotId: SnapshotId, + nodes: readonly GraphNode[], + relationships: readonly GraphRelationship[], +): GraphProjection { + const nodeMap = new Map(nodes.map(node => [refKey(node.ref), node])); + const outgoing = new Map(); + for (const relationship of relationships) { + const key = refKey(relationship.source); + const existing = outgoing.get(key) ?? []; + existing.push(relationship); + outgoing.set(key, existing); + } + + const getNode = (ref: GraphNodeRef): GraphNode | undefined => { + if (ref.workspaceId !== workspaceId || ref.snapshotId !== snapshotId) { + throw new RuntimeContractError( + 'FOREIGN_GRAPH_PROJECTION', + `Node ${ref.nodeId} does not belong to graph projection ${workspaceId}/${snapshotId}`, + ); + } + return nodeMap.get(refKey(ref)); + }; + + const traverse = (start: GraphNodeRef, options: TraversalOptions = {}): readonly GraphNodeRef[] => { + if (start.workspaceId !== workspaceId || start.snapshotId !== snapshotId) { + throw new RuntimeContractError( + start.workspaceId !== workspaceId ? 'FOREIGN_WORKSPACE_TRAVERSAL' : 'FOREIGN_GRAPH_PROJECTION', + `Node ${start.nodeId} does not belong to graph projection ${workspaceId}/${snapshotId}`, + ); + } + if (!nodeMap.has(refKey(start))) return []; + + const maxDepth = options.maxDepth ?? Number.POSITIVE_INFINITY; + const maxNodes = options.maxNodes ?? Number.POSITIVE_INFINITY; + const result: GraphNodeRef[] = []; + const seen = new Set(); + const queue: Array<{ ref: GraphNodeRef; depth: number }> = [{ ref: start, depth: 0 }]; + + while (queue.length > 0 && result.length < maxNodes) { + const current = queue.shift()!; + const key = refKey(current.ref); + if (seen.has(key)) continue; + seen.add(key); + result.push(current.ref); + if (current.depth >= maxDepth) continue; + + for (const relationship of outgoing.get(key) ?? []) { + if (relationship.target.workspaceId !== workspaceId || relationship.target.snapshotId !== snapshotId) { + throw new RuntimeContractError( + relationship.target.workspaceId !== workspaceId ? 'FOREIGN_WORKSPACE_TRAVERSAL' : 'FOREIGN_GRAPH_PROJECTION', + `Relationship target ${relationship.target.nodeId} crosses the graph projection boundary`, + ); + } + queue.push({ ref: relationship.target, depth: current.depth + 1 }); + } + } + + return result; + }; + + return Object.freeze({ + nodes: Object.freeze([...nodes]), + relationships: Object.freeze([...relationships]), + getNode, + traverse, + }); +} diff --git a/src/public/index.ts b/src/public/index.ts new file mode 100644 index 0000000..0db6ccb --- /dev/null +++ b/src/public/index.ts @@ -0,0 +1,34 @@ +export { createRuntime } from './runtime.js'; +export { RuntimeContractError, RefreshError } from './errors.js'; +export type { + CreateRegistrationInput, + CreateRepositoryInput, + DefinitionRevision, + EntityProvenance, + GraphNodeRef, + GraphNode, + GraphProjection, + GraphRelationship, + LocalRepositorySource, + RegisteredRepositoryMetadata, + RepositoryDefinition, + RepositoryDisplayMetadata, + RepositoryId, + RepositoryObservation, + RepositoryObservationStatus, + RetainedRepositoryInput, + ReviseRegistrationInput, + RelationshipProvenance, + Runtime, + RuntimeCapabilityManifest, + RuntimeDiagnostic, + RuntimeSnapshot, + SnapshotId, + SourceProvenance, + TraversalOptions, + WorkspaceDefinition, + WorkspaceDisplayMetadata, + WorkspaceHandle, + WorkspaceId, + WorkspaceRegistration, +} from './types.js'; diff --git a/src/public/runtime.test.ts b/src/public/runtime.test.ts new file mode 100644 index 0000000..aeb6fe6 --- /dev/null +++ b/src/public/runtime.test.ts @@ -0,0 +1,333 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { describe, expect, it } from 'vitest'; + +import { mergeWorkspaceGraph, preflightWorkspaceGraphIdentity, WorkspaceIdentityCollisionError } from '../workspace/workspace-merge.js'; +import { canonicalize, definitionRevision } from './canonical.js'; +import { RefreshError, RuntimeContractError } from './errors.js'; +import { createGraphProjection } from './graph.js'; +import { createRuntime } from './runtime.js'; +import type { GraphNodeRef, SnapshotId, WorkspaceId } from './types.js'; + +const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +async function createFixtureRepository(prefix: string): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), `${prefix}-`)); + await fs.mkdir(path.join(root, 'src'), { recursive: true }); + await fs.writeFile(path.join(root, 'package.json'), '{"name":"public-runtime-fixture","version":"1.0.0"}\n'); + await fs.writeFile(path.join(root, 'src', 'index.ts'), 'export const fixture = 1;\n'); + return root; +} + +describe('public runtime registration contract', () => { + it('canonicalizes definition property and repository ordering while excluding undefined values', () => { + const repositoryA = '019ffc3c-0000-7000-8000-000000000001' as never; + const repositoryB = '019ffc3c-0000-7000-8000-000000000002' as never; + const first = { + repositories: [ + { repositoryId: repositoryB, source: { kind: 'local' as const, path: process.cwd() } }, + { repositoryId: repositoryA, source: { kind: 'local' as const, path: process.cwd() } }, + ], + }; + const second = { + repositories: [ + { source: { path: process.cwd(), kind: 'local' as const }, repositoryId: repositoryA }, + { source: { path: process.cwd(), kind: 'local' as const }, repositoryId: repositoryB }, + ], + }; + + expect(definitionRevision(first)).toBe(definitionRevision(second)); + expect(canonicalize({ alias: 'ignored', value: undefined })).toBe('{"alias":"ignored"}'); + }); + + it('mints UUIDv7 workspace and repository identities only during registration creation', async () => { + const runtime = createRuntime(); + const registration = await runtime.createRegistration({ + repositories: [ + { source: { kind: 'local', path: process.cwd() }, display: { alias: 'runtime' } }, + ], + }); + + expect(registration.workspaceId).toMatch(UUID_V7); + expect(registration.definition.repositories[0]?.repositoryId).toMatch(UUID_V7); + expect(registration.repositories[0]?.repositoryId).toBe(registration.definition.repositories[0]?.repositoryId); + await runtime.close(); + }); + + it('preserves workspace and retained repository identities across explicit revision', async () => { + const runtime = createRuntime(); + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: process.cwd() }, display: { alias: 'before' } }], + }); + const repositoryId = registration.definition.repositories[0]!.repositoryId; + const revised = await runtime.reviseRegistration(registration, { + retain: [{ + repositoryId, + source: { kind: 'local', path: process.cwd() }, + display: { alias: 'after' }, + }], + add: [], + remove: [], + }); + + expect(revised.workspaceId).toBe(registration.workspaceId); + expect(revised.definition.repositories[0]?.repositoryId).toBe(repositoryId); + expect(revised.definitionRevision).toBe(registration.definitionRevision); + expect(revised.repositories[0]?.display?.alias).toBe('after'); + await runtime.close(); + }); + + it('rejects a registration whose caller-supplied definition revision is fabricated', async () => { + const runtime = createRuntime(); + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: process.cwd() } }], + }); + + await expect(runtime.open({ ...registration, definitionRevision: 'sha256:fabricated' as never })) + .rejects.toMatchObject({ code: 'DEFINITION_REVISION_MISMATCH' }); + await runtime.close(); + }); + + it('rejects empty workspaces and does not persist discarded registrations', async () => { + const runtime = createRuntime(); + await expect(runtime.createRegistration({ repositories: [] })).rejects.toMatchObject({ code: 'EMPTY_WORKSPACE' }); + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: process.cwd() } }], + }); + expect(registration).toBeDefined(); + await runtime.close(); + }); +}); + +describe('snapshot-bound graph projection', () => { + it('traverses a deterministic cross-repository relationship inside one workspace snapshot', () => { + const workspaceId = '019ffc3c-0000-7000-8000-000000000000' as WorkspaceId; + const snapshotId = '019ffc3c-0000-7002-8000-000000000000' as SnapshotId; + const source: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'Service:repo-a' }; + const target: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'Endpoint:repo-b:get:health' }; + const graph = createGraphProjection( + workspaceId, + snapshotId, + [ + { ref: source, type: 'Service', name: 'A', provenance: { snapshotId, sources: [{ repositoryId: 'repo-a' as never }] } }, + { ref: target, type: 'Endpoint', name: 'B', provenance: { snapshotId, sources: [{ repositoryId: 'repo-b' as never }] } }, + ], + [{ + source, + target, + verb: 'calls', + provenance: { + snapshotId, + sources: [{ repositoryId: 'repo-a' as never }, { repositoryId: 'repo-b' as never }], + evidence: 'deterministic fixture evidence', + }, + }], + ); + + expect(graph.traverse(source)).toEqual([source, target]); + expect(graph.getNode(source)?.provenance.sources[0]?.repositoryId).toBe('repo-a'); + expect(graph.getNode(target)?.provenance.sources[0]?.repositoryId).toBe('repo-b'); + }); + + it('treats legacy node keys as opaque snapshot-scoped projection identity', () => { + const workspaceId = '019ffc3c-0000-7000-8000-000000000000' as WorkspaceId; + const snapshotId = '019ffc3c-0000-7002-8000-000000000000' as SnapshotId; + const otherSnapshotId = '019ffc3c-0000-7003-8000-000000000000' as SnapshotId; + const ref: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'Service:legacy-execution-key' }; + const graph = createGraphProjection( + workspaceId, + snapshotId, + [{ ref, type: 'Service', name: 'Example', provenance: { snapshotId, sources: [] } }], + [], + ); + + expect(ref).toEqual({ workspaceId, snapshotId, nodeId: 'Service:legacy-execution-key' }); + expect(() => graph.getNode({ workspaceId, snapshotId: otherSnapshotId, nodeId: ref.nodeId })) + .toThrowError('FOREIGN_GRAPH_PROJECTION'); + }); + + it('fails closed when traversal crosses the workspace boundary', () => { + const workspaceId = '019ffc3c-0000-7000-8000-000000000000' as WorkspaceId; + const foreign = '019ffc3c-0000-7001-8000-000000000000' as WorkspaceId; + const snapshotId = '019ffc3c-0000-7002-8000-000000000000' as SnapshotId; + const start: GraphNodeRef = { workspaceId, snapshotId, nodeId: 'a' }; + const graph = createGraphProjection( + workspaceId, + snapshotId, + [{ ref: start, type: 'Service', name: 'A', provenance: { snapshotId, sources: [] } }], + [{ + source: start, + target: { workspaceId: foreign, snapshotId, nodeId: 'b' }, + verb: 'calls', + provenance: { snapshotId, sources: [] }, + }], + ); + + expect(() => graph.traverse(start)).toThrowError(RuntimeContractError); + expect(() => graph.traverse({ workspaceId: foreign, snapshotId, nodeId: 'b' })).toThrowError('FOREIGN_WORKSPACE_TRAVERSAL'); + expect(() => graph.traverse({ workspaceId, snapshotId: '019ffc3c-0000-7003-8000-000000000000' as SnapshotId, nodeId: 'a' })) + .toThrowError('FOREIGN_GRAPH_PROJECTION'); + }); +}); + +describe('pre-merge workspace identity guard', () => { + async function createSlices(): Promise { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-collision-')); + await fs.mkdir(path.join(root, 'slices')); + await fs.writeFile(path.join(root, 'slices', 'repo-a.yaml'), `schema_version: '1.0'\nrepo: repo-a\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:shared\n type: Service\n name: repo-a\n provenance:\n source_path: src/index.ts\n source_ref: x\n repo: repo-a\nedges: []\n`); + await fs.writeFile(path.join(root, 'slices', 'repo-b.yaml'), `schema_version: '1.0'\nrepo: repo-b\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:shared\n type: Service\n name: repo-b\n provenance:\n source_path: src/index.ts\n source_ref: x\n repo: repo-b\nedges: []\n`); + return root; + } + + it('fails before legacy first-wins merge and identifies both repositories', async () => { + const root = await createSlices(); + await expect(preflightWorkspaceGraphIdentity(root)).rejects.toSatisfy(error => { + expect(error).toBeInstanceOf(WorkspaceIdentityCollisionError); + expect((error as WorkspaceIdentityCollisionError).collisions[0]?.repositories).toEqual(['repo-a', 'repo-b']); + return true; + }); + await fs.rm(root, { recursive: true, force: true }); + }); + + it('allows unique workspace entity IDs across repositories', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-unique-')); + await fs.mkdir(path.join(root, 'slices')); + await fs.writeFile(path.join(root, 'slices', 'repo-a.yaml'), `schema_version: '1.0'\nrepo: repo-a\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:a\n type: Service\n name: repo-a\n provenance:\n source_path: src/a.ts\n source_ref: a\n repo: repo-a\nedges: []\n`); + await fs.writeFile(path.join(root, 'slices', 'repo-b.yaml'), `schema_version: '1.0'\nrepo: repo-b\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: Service:b\n type: Service\n name: repo-b\n provenance:\n source_path: src/b.ts\n source_ref: b\n repo: repo-b\nedges: []\n`); + await expect(preflightWorkspaceGraphIdentity(root)).resolves.toBeUndefined(); + await fs.rm(root, { recursive: true, force: true }); + }); +}); + +describe('cross-repository relationship provenance', () => { + it('retains deterministic source and target repository evidence through merge', async () => { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-edge-provenance-')); + await fs.mkdir(path.join(root, 'slices')); + const slice = (repo: string, nodeId: string) => `schema_version: '1.0'\nrepo: ${repo}\ngenerated_by: test\ngenerated_at: now\nnodes:\n - id: ${nodeId}\n type: Service\n name: ${repo}\n provenance:\n source_path: src/index.ts\n source_ref: service\n repo: ${repo}\nedges: []\n`; + await fs.writeFile(path.join(root, 'slices', 'repo-a.yaml'), slice('repo-a', 'Service:a')); + await fs.writeFile(path.join(root, 'slices', 'repo-b.yaml'), slice('repo-b', 'Service:b')); + await fs.writeFile(path.join(root, 'workspace-edges.yaml'), `cross_repo_edges:\n - from: Service:a\n to: Service:b\n verb: calls\n confidence: high\n provenance:\n source_repo: repo-a\n target_repo: repo-b\n evidence: bilateral test evidence\n`); + + const result = await mergeWorkspaceGraph(root); + expect(result.graph.edges[0]?.provenance).toEqual({ + source_repo: 'repo-a', + target_repo: 'repo-b', + evidence: 'bilateral test evidence', + }); + await fs.rm(root, { recursive: true, force: true }); + }); +}); + +describe('no-source current projection', () => { + it('returns a typed failure for an orphaned materialization and never fabricates a snapshot', async () => { + const runtime = createRuntime(); + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: path.join(os.tmpdir(), 'missing-ste-runtime-repository') } }], + }); + const workspace = await runtime.open(registration); + await expect(workspace.refresh()).rejects.toSatisfy(error => { + expect(error).toBeInstanceOf(RefreshError); + expect((error as RefreshError).code).toBe('NO_SOURCE_OBSERVED'); + expect((error as RefreshError).diagnostics[0]?.code).toBe('REPOSITORY_ORPHANED'); + return true; + }); + await runtime.close(); + }); + + it('returns a partial current projection without stale content for an orphaned member', async () => { + const observedRoot = await createFixtureRepository('ste-runtime-observed'); + const orphanedRoot = path.join(os.tmpdir(), `missing-ste-runtime-${Date.now()}`); + const runtime = createRuntime(); + try { + const registration = await runtime.createRegistration({ + repositories: [ + { source: { kind: 'local', path: observedRoot } }, + { source: { kind: 'local', path: orphanedRoot } }, + ], + }); + const workspace = await runtime.open(registration); + const snapshot = await workspace.refresh(); + const observedId = snapshot.repositoryObservations.find(observation => observation.status === 'observed')!.repositoryId; + const orphanedId = snapshot.repositoryObservations.find(observation => observation.status === 'orphaned')!.repositoryId; + + expect(snapshot.status).toBe('partial'); + expect(snapshot.repositoryObservations).toEqual(expect.arrayContaining([ + expect.objectContaining({ repositoryId: observedId, status: 'observed' }), + expect.objectContaining({ repositoryId: orphanedId, status: 'orphaned' }), + ])); + expect(snapshot.graph.nodes.every(node => node.provenance.sources.every(source => source.repositoryId === observedId))).toBe(true); + expect(snapshot.graph.relationships.every(edge => edge.provenance.sources.every(source => source.repositoryId === observedId))).toBe(true); + } finally { + await runtime.close(); + await fs.rm(observedRoot, { recursive: true, force: true }); + } + }); + + it('does not substitute a prior snapshot after a failed refresh', async () => { + const sourceRoot = await createFixtureRepository('ste-runtime-failed-refresh'); + const runtime = createRuntime(); + try { + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: sourceRoot } }], + }); + const workspace = await runtime.open(registration); + const first = await workspace.refresh(); + const start = first.graph.nodes[0]!.ref; + await fs.rm(sourceRoot, { recursive: true, force: true }); + + await expect(workspace.refresh()).rejects.toMatchObject({ code: 'NO_SOURCE_OBSERVED' }); + expect(first.graph.traverse(start, { maxDepth: 0, maxNodes: 1 })).toEqual([start]); + expect('currentSnapshot' in workspace).toBe(false); + expect('graph' in workspace).toBe(false); + } finally { + await runtime.close(); + await fs.rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it('assigns a new snapshot ID while retaining an equal fingerprint for identical observations', async () => { + const sourceRoot = await createFixtureRepository('ste-runtime-repeatable-refresh'); + const runtime = createRuntime(); + try { + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: sourceRoot } }], + }); + const workspace = await runtime.open(registration); + const first = await workspace.refresh(); + const second = await workspace.refresh(); + expect(second.snapshotId).not.toBe(first.snapshotId); + expect(second.observationFingerprint).toBe(first.observationFingerprint); + } finally { + await runtime.close(); + await fs.rm(sourceRoot, { recursive: true, force: true }); + } + }); + + it('observes multiple repositories in one workspace with separate provenance', async () => { + const repositoryA = await createFixtureRepository('ste-runtime-multi-a'); + const repositoryB = await createFixtureRepository('ste-runtime-multi-b'); + const runtime = createRuntime(); + try { + const registration = await runtime.createRegistration({ + repositories: [ + { source: { kind: 'local', path: repositoryA } }, + { source: { kind: 'local', path: repositoryB } }, + ], + }); + const snapshot = await (await runtime.open(registration)).refresh(); + const repositoryIds = new Set(registration.definition.repositories.map(repository => repository.repositoryId)); + const observedIds = new Set(snapshot.repositoryObservations + .filter(observation => observation.status === 'observed') + .map(observation => observation.repositoryId)); + expect(snapshot.status).toBe('complete'); + expect(observedIds).toEqual(repositoryIds); + expect(new Set(snapshot.graph.nodes.flatMap(node => node.provenance.sources.map(source => source.repositoryId)))).toEqual(repositoryIds); + } finally { + await runtime.close(); + await fs.rm(repositoryA, { recursive: true, force: true }); + await fs.rm(repositoryB, { recursive: true, force: true }); + } + }); +}); diff --git a/src/public/runtime.ts b/src/public/runtime.ts new file mode 100644 index 0000000..8aae5d5 --- /dev/null +++ b/src/public/runtime.ts @@ -0,0 +1,573 @@ +import crypto from 'node:crypto'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; + +import type { WorkspaceReconResult } from '../workspace/workspace-recon.js'; +import { + canonicalDefinition, + definitionRevision, + normalizeLocalSourcePath, + observationFingerprint, + PUBLIC_RUNTIME_CONTRACT_VERSION, +} from './canonical.js'; +import { RefreshError, RuntimeContractError } from './errors.js'; +import { createGraphProjection } from './graph.js'; +import type { + CreateRegistrationInput, + EntityProvenance, + GraphNode, + GraphRelationship, + LocalRepositorySource, + RegisteredRepositoryMetadata, + RepositoryDefinition, + RepositoryDisplayMetadata, + RepositoryId, + RepositoryObservation, + ReviseRegistrationInput, + Runtime, + RuntimeCapabilityManifest, + RuntimeDiagnostic, + RuntimeSnapshot, + SnapshotId, + SourceProvenance, + WorkspaceDefinition, + WorkspaceHandle, + WorkspaceId, + WorkspaceRegistration, +} from './types.js'; + +interface LegacyNode { + id: string; + type: string; + name: string; + repo?: string; + attributes?: Record; + provenance?: { + source_path?: string; + source_ref?: string; + repo?: string; + }; +} + +interface LegacyEdge { + from: string; + to: string; + verb: string; + provenance?: { + source_path?: string; + source_ref?: string; + repo?: string; + source_repo?: string; + target_repo?: string; + evidence?: string; + }; +} + +interface LegacyGraph { + nodes?: LegacyNode[]; + edges?: LegacyEdge[]; +} + +const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +function uuidv7(): string { + const bytes = crypto.randomBytes(16); + const timestamp = BigInt(Date.now()); + for (let index = 5; index >= 0; index -= 1) { + bytes[index] = Number(timestamp >> BigInt((5 - index) * 8)) & 0xff; + } + bytes[6] = (bytes[6] & 0x0f) | 0x70; + bytes[8] = (bytes[8] & 0x3f) | 0x80; + const hex = bytes.toString('hex'); + return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`; +} + +function isUuidV7(value: string): boolean { + return UUID_V7.test(value); +} + +function deepFreeze(value: T): T { + if (value && typeof value === 'object' && !Object.isFrozen(value)) { + Object.freeze(value); + for (const child of Object.values(value as Record)) { + deepFreeze(child); + } + } + return value; +} + +function cloneDisplay(value: RepositoryDisplayMetadata | undefined): RepositoryDisplayMetadata | undefined { + return value ? { ...value } : undefined; +} + +function normalizeSource(source: LocalRepositorySource): LocalRepositorySource { + if (source.kind !== 'local' || typeof source.path !== 'string' || source.path.trim().length === 0) { + throw new RuntimeContractError('INVALID_REPOSITORY_SOURCE', 'Only non-empty local repository paths are supported'); + } + return { kind: 'local', path: normalizeLocalSourcePath(source.path) }; +} + +function validateRepositoryId(value: string): asserts value is RepositoryId { + if (!isUuidV7(value)) { + throw new RuntimeContractError('INVALID_REPOSITORY_ID', `RepositoryId is not a UUIDv7: ${value}`); + } +} + +function validateWorkspaceId(value: string): asserts value is WorkspaceId { + if (!isUuidV7(value)) { + throw new RuntimeContractError('INVALID_WORKSPACE_ID', `WorkspaceId is not a UUIDv7: ${value}`); + } +} + +function validateSnapshotId(value: string): asserts value is SnapshotId { + if (!isUuidV7(value)) { + throw new RuntimeContractError('INVALID_SNAPSHOT_ID', `SnapshotId is not a UUIDv7: ${value}`); + } +} + +function sortedByRepositoryId(values: readonly T[]): T[] { + return [...values].sort((a, b) => a.repositoryId.localeCompare(b.repositoryId)); +} + +function createDefinition(repositories: readonly RepositoryDefinition[]): WorkspaceDefinition { + if (repositories.length === 0) { + throw new RuntimeContractError('EMPTY_WORKSPACE', 'A workspace must contain at least one repository'); + } + const ids = new Set(); + for (const repository of repositories) { + validateRepositoryId(repository.repositoryId); + if (ids.has(repository.repositoryId)) { + throw new RuntimeContractError('DUPLICATE_REPOSITORY_ID', `RepositoryId is duplicated: ${repository.repositoryId}`); + } + ids.add(repository.repositoryId); + } + return { repositories: sortedByRepositoryId(repositories).map(repository => ({ + repositoryId: repository.repositoryId, + source: normalizeSource(repository.source), + })) }; +} + +function buildRegistration( + workspaceId: WorkspaceId, + definition: WorkspaceDefinition, + display: WorkspaceRegistration['display'], + metadata: readonly RegisteredRepositoryMetadata[], +): WorkspaceRegistration { + const normalizedDefinition = createDefinition(definition.repositories); + const registration: WorkspaceRegistration = { + workspaceId, + definition: normalizedDefinition, + definitionRevision: definitionRevision(normalizedDefinition), + display: display ? { ...display } : undefined, + repositories: sortedByRepositoryId(metadata).map(entry => ({ + repositoryId: entry.repositoryId, + display: cloneDisplay(entry.display), + })), + }; + return deepFreeze(registration); +} + +function validateRegistration(registration: WorkspaceRegistration): WorkspaceRegistration { + if (!registration || typeof registration !== 'object') { + throw new RuntimeContractError('INVALID_REGISTRATION', 'A complete WorkspaceRegistration is required'); + } + validateWorkspaceId(registration.workspaceId); + const normalizedDefinition = createDefinition(registration.definition.repositories); + const expected = definitionRevision(normalizedDefinition); + if (registration.definitionRevision !== expected) { + throw new RuntimeContractError( + 'DEFINITION_REVISION_MISMATCH', + `Workspace definition revision does not match canonical definition (expected ${expected})`, + ); + } + const ids = new Set(normalizedDefinition.repositories.map(repository => repository.repositoryId)); + for (const metadata of registration.repositories) { + validateRepositoryId(metadata.repositoryId); + if (!ids.has(metadata.repositoryId)) { + throw new RuntimeContractError( + 'REGISTRATION_METADATA_MISMATCH', + `Repository metadata references a repository outside the definition: ${metadata.repositoryId}`, + ); + } + } + return buildRegistration(registration.workspaceId, normalizedDefinition, registration.display, registration.repositories); +} + +function executionKey(index: number): string { + return `repo-${index + 1}`; +} + +function sourceLocator(sourcePath?: string, sourceRef?: string): string | undefined { + if (!sourcePath && !sourceRef) return undefined; + if (!sourceRef) return sourcePath; + if (!sourcePath) return sourceRef; + return `${sourcePath}#${sourceRef}`; +} + +function repositoryIdFor( + key: string | undefined, + executionToRepository: ReadonlyMap, +): RepositoryId | undefined { + return key ? executionToRepository.get(key) : undefined; +} + +async function readLegacyGraph(raw: string): Promise { + const { default: yaml } = await import('js-yaml'); + const parsed = yaml.load(raw) as LegacyGraph | null; + return parsed ?? {}; +} + +async function pathStatus(sourcePath: string): Promise<'present' | 'orphaned' | 'unavailable'> { + try { + const stat = await fs.stat(sourcePath); + return stat.isDirectory() ? 'present' : 'unavailable'; + } catch (error) { + const code = error && typeof error === 'object' && 'code' in error ? String(error.code) : ''; + return code === 'ENOENT' || code === 'ENOTDIR' ? 'orphaned' : 'unavailable'; + } +} + +function toDiagnostic(code: string, message: string, repositoryIds?: readonly RepositoryId[]): RuntimeDiagnostic { + return { code, message, repositoryIds }; +} + +function graphFromLegacy( + workspaceId: WorkspaceId, + snapshotId: SnapshotId, + graph: LegacyGraph, + executionToRepository: ReadonlyMap, +): ReturnType { + const nodes: GraphNode[] = []; + const nodeById = new Map(); + + for (const node of graph.nodes ?? []) { + const repoKey = node.provenance?.repo ?? node.repo; + const repositoryId = repositoryIdFor(repoKey, executionToRepository); + if (!repositoryId || nodeById.has(node.id)) continue; + const provenance: EntityProvenance = { + snapshotId, + sources: [{ + repositoryId, + sourceLocator: sourceLocator(node.provenance?.source_path, node.provenance?.source_ref), + }], + }; + const projected: GraphNode = { + ref: { workspaceId, snapshotId, nodeId: node.id }, + type: node.type, + name: node.name, + provenance, + attributes: node.attributes, + }; + nodes.push(projected); + nodeById.set(node.id, projected); + } + + const relationships: GraphRelationship[] = []; + const relationshipKeys = new Set(); + for (const edge of graph.edges ?? []) { + const source = nodeById.get(edge.from); + const target = nodeById.get(edge.to); + if (!source || !target) continue; + + const sourceRepository = repositoryIdFor(edge.provenance?.source_repo, executionToRepository) + ?? source.provenance.sources[0]?.repositoryId; + const targetRepository = repositoryIdFor(edge.provenance?.target_repo, executionToRepository) + ?? target.provenance.sources[0]?.repositoryId; + const sourceRecords: SourceProvenance[] = []; + if (sourceRepository) sourceRecords.push({ repositoryId: sourceRepository }); + if (targetRepository && targetRepository !== sourceRepository) sourceRecords.push({ repositoryId: targetRepository }); + for (const record of source.provenance.sources) { + if (!sourceRecords.some(existing => existing.repositoryId === record.repositoryId)) sourceRecords.push(record); + } + for (const record of target.provenance.sources) { + if (!sourceRecords.some(existing => existing.repositoryId === record.repositoryId)) sourceRecords.push(record); + } + + const key = `${edge.from}|${edge.to}|${edge.verb}`; + if (relationshipKeys.has(key)) continue; + relationshipKeys.add(key); + relationships.push({ + source: source.ref, + target: target.ref, + verb: edge.verb, + provenance: { + snapshotId, + sources: sourceRecords, + evidence: edge.provenance?.evidence ?? sourceLocator(edge.provenance?.source_path, edge.provenance?.source_ref), + }, + }); + } + + return createGraphProjection(workspaceId, snapshotId, nodes, relationships); +} + +function currentObservationValue( + registration: WorkspaceRegistration, + observations: readonly RepositoryObservation[], + graph: ReturnType, +): unknown { + return { + definitionRevision: registration.definitionRevision, + repositories: observations.map(observation => ({ + repositoryId: observation.repositoryId, + status: observation.status, + diagnostic: observation.diagnostic, + })), + nodes: graph.nodes.map(node => ({ + ref: { workspaceId: node.ref.workspaceId, nodeId: node.ref.nodeId }, + type: node.type, + name: node.name, + provenance: { sources: node.provenance.sources }, + attributes: node.attributes, + })), + relationships: graph.relationships.map(relationship => ({ + source: { workspaceId: relationship.source.workspaceId, nodeId: relationship.source.nodeId }, + target: { workspaceId: relationship.target.workspaceId, nodeId: relationship.target.nodeId }, + verb: relationship.verb, + provenance: { sources: relationship.provenance.sources, evidence: relationship.provenance.evidence }, + })), + }; +} + +async function writeWorkspaceManifest( + workspaceRoot: string, + registration: WorkspaceRegistration, +): Promise<{ manifestPath: string; executionToRepository: Map }> { + const repositories = sortedByRepositoryId(registration.definition.repositories); + const executionToRepository = new Map(); + const manifestRepos = repositories.map((repository, index) => { + const key = executionKey(index); + executionToRepository.set(key, repository.repositoryId); + return { + name: key, + path: repository.source.path, + kind: 'service', + lang: 'unknown', + }; + }); + const manifestPath = path.join(workspaceRoot, 'workspace.yaml'); + const { default: yaml } = await import('js-yaml'); + await fs.writeFile( + manifestPath, + yaml.dump({ schema_version: '1.0', output_dir: '.workspace-graph', repos: manifestRepos }), + 'utf8', + ); + return { manifestPath, executionToRepository }; +} + +function observationsForResult( + registration: WorkspaceRegistration, + result: WorkspaceReconResult, + executionToRepository: ReadonlyMap, + initialStatuses: ReadonlyMap, +): { observations: RepositoryObservation[]; observedCount: number; diagnostics: RuntimeDiagnostic[] } { + const observations: RepositoryObservation[] = []; + const diagnostics: RuntimeDiagnostic[] = []; + for (const repository of sortedByRepositoryId(registration.definition.repositories)) { + const key = [...executionToRepository.entries()].find(([, id]) => id === repository.repositoryId)?.[0]; + const repoResult = result.repos.find(entry => entry.name === key); + const initial = initialStatuses.get(repository.repositoryId) ?? 'unavailable'; + if (repoResult?.status === 'success') { + observations.push({ + repositoryId: repository.repositoryId, + status: 'observed', + sourceFingerprint: repoResult.contentHash, + }); + continue; + } + const status = initial === 'orphaned' ? 'orphaned' : 'unavailable'; + const message = repoResult?.error?.message ?? `Repository ${repository.repositoryId} was not observed`; + observations.push({ repositoryId: repository.repositoryId, status, diagnostic: message }); + diagnostics.push(toDiagnostic(status === 'orphaned' ? 'REPOSITORY_ORPHANED' : 'REPOSITORY_UNAVAILABLE', message, [repository.repositoryId])); + } + return { + observations, + observedCount: observations.filter(observation => observation.status === 'observed').length, + diagnostics, + }; +} + +export function createRuntime(): Runtime { + const temporaryRoots = new Set(); + let closed = false; + + const capabilities: RuntimeCapabilityManifest = Object.freeze({ + contractVersion: PUBLIC_RUNTIME_CONTRACT_VERSION, + mechanical: true, + supportedSourceKinds: ['local'] as const, + federation: false, + }); + + const ensureOpen = (): void => { + if (closed) throw new RuntimeContractError('RUNTIME_CLOSED', 'Runtime has been closed'); + }; + + const createRegistration = async (input: CreateRegistrationInput): Promise => { + ensureOpen(); + if (!input || !Array.isArray(input.repositories) || input.repositories.length === 0) { + throw new RuntimeContractError('EMPTY_WORKSPACE', 'A workspace must contain at least one repository'); + } + const ordered = [...input.repositories].sort((a, b) => normalizeLocalSourcePath(a.source.path).localeCompare(normalizeLocalSourcePath(b.source.path))); + const definitions: RepositoryDefinition[] = []; + const metadata: RegisteredRepositoryMetadata[] = []; + for (const repository of ordered) { + const repositoryId = uuidv7() as RepositoryId; + definitions.push({ repositoryId, source: normalizeSource(repository.source) }); + metadata.push({ repositoryId, display: cloneDisplay(repository.display) }); + } + return buildRegistration(uuidv7() as WorkspaceId, { repositories: definitions }, input.display, metadata); + }; + + const reviseRegistration = async ( + registrationInput: WorkspaceRegistration, + input: ReviseRegistrationInput, + ): Promise => { + ensureOpen(); + const registration = validateRegistration(registrationInput); + const existingIds = new Set(registration.definition.repositories.map(repository => repository.repositoryId)); + const retainedIds = new Set(); + const removedIds = new Set(); + for (const retained of input.retain) { + validateRepositoryId(retained.repositoryId); + if (!existingIds.has(retained.repositoryId)) { + throw new RuntimeContractError('UNKNOWN_REPOSITORY_ID', `Cannot retain unknown repository ${retained.repositoryId}`); + } + if (!retainedIds.add(retained.repositoryId)) { + throw new RuntimeContractError('DUPLICATE_REPOSITORY_ID', `Repository is retained twice: ${retained.repositoryId}`); + } + } + for (const removed of input.remove) { + validateRepositoryId(removed); + if (!existingIds.has(removed) || retainedIds.has(removed) || !removedIds.add(removed)) { + throw new RuntimeContractError('INVALID_REPOSITORY_REVISION', `Invalid repository removal: ${removed}`); + } + } + if (retainedIds.size + removedIds.size !== existingIds.size) { + throw new RuntimeContractError('INCOMPLETE_REPOSITORY_REVISION', 'Every existing repository must be retained or explicitly removed'); + } + + const retainedDefinitions = input.retain.map(repository => ({ + repositoryId: repository.repositoryId, + source: normalizeSource(repository.source), + })); + const orderedAdds = [...input.add].sort((a, b) => normalizeLocalSourcePath(a.source.path).localeCompare(normalizeLocalSourcePath(b.source.path))); + const addedDefinitions: RepositoryDefinition[] = []; + const addedMetadata: RegisteredRepositoryMetadata[] = []; + for (const repository of orderedAdds) { + const repositoryId = uuidv7() as RepositoryId; + addedDefinitions.push({ repositoryId, source: normalizeSource(repository.source) }); + addedMetadata.push({ repositoryId, display: cloneDisplay(repository.display) }); + } + const currentMetadata = new Map(registration.repositories.map(entry => [entry.repositoryId, entry.display])); + const metadata: RegisteredRepositoryMetadata[] = [ + ...retainedDefinitions.map(repository => ({ + repositoryId: repository.repositoryId, + display: cloneDisplay(input.retain.find(entry => entry.repositoryId === repository.repositoryId)?.display ?? currentMetadata.get(repository.repositoryId)), + })), + ...addedMetadata, + ]; + const revised = buildRegistration( + registration.workspaceId, + { repositories: [...retainedDefinitions, ...addedDefinitions] }, + input.display ?? registration.display, + metadata, + ); + return revised; + }; + + const open = async (registrationInput: WorkspaceRegistration): Promise => { + ensureOpen(); + const registration = validateRegistration(registrationInput); + return { + registration, + refresh: async (): Promise => { + ensureOpen(); + const refreshRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'ste-runtime-public-refresh-')); + temporaryRoots.add(refreshRoot); + const { manifestPath, executionToRepository } = await writeWorkspaceManifest(refreshRoot, registration); + const initialStatuses = new Map(); + for (const repository of registration.definition.repositories) { + initialStatuses.set(repository.repositoryId, await pathStatus(repository.source.path)); + } + + try { + const [{ executeWorkspaceRecon }, { preflightWorkspaceGraphIdentity }] = await Promise.all([ + import('../workspace/workspace-recon.js'), + import('../workspace/workspace-merge.js'), + ]); + const result = await executeWorkspaceRecon({ + workspacePath: manifestPath, + mode: 'full', + runtimeDir: path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../..'), + failOnAnyError: false, + skipUnchanged: false, + beforeMerge: outputDir => preflightWorkspaceGraphIdentity(outputDir), + }); + const { observations, observedCount, diagnostics } = observationsForResult( + registration, + result, + executionToRepository, + initialStatuses, + ); + if (observedCount === 0) { + throw new RefreshError('NO_SOURCE_OBSERVED', 'No registered repository could be observed', diagnostics); + } + + const graphRaw = await fs.readFile(path.join(refreshRoot, '.workspace-graph', 'graph.yaml'), 'utf8'); + const legacyGraph = await readLegacyGraph(graphRaw); + const snapshotId = uuidv7() as SnapshotId; + validateSnapshotId(snapshotId); + const graph = graphFromLegacy(registration.workspaceId, snapshotId, legacyGraph, executionToRepository); + const snapshot: RuntimeSnapshot = { + snapshotId, + workspaceId: registration.workspaceId, + definitionRevision: registration.definitionRevision, + status: observedCount === registration.definition.repositories.length ? 'complete' : 'partial', + observationFingerprint: observationFingerprint(currentObservationValue(registration, observations, graph)), + observedAt: new Date().toISOString(), + runtimeContractVersion: PUBLIC_RUNTIME_CONTRACT_VERSION, + repositoryObservations: observations, + graph, + diagnostics, + }; + return deepFreeze(snapshot); + } catch (error) { + if (error instanceof RefreshError) throw error; + if (error instanceof Error && error.name === 'WorkspaceIdentityCollisionError' && 'collisions' in error) { + const collisions = (error as Error & { collisions: Array<{ id: string; repositories: string[] }> }).collisions; + const diagnostics = collisions.map(collision => toDiagnostic( + 'ENTITY_ID_COLLISION', + `${collision.id} was declared by ${collision.repositories.join(', ')}`, + collision.repositories + .map(repository => executionToRepository.get(repository)) + .filter((repositoryId): repositoryId is RepositoryId => repositoryId !== undefined), + )); + throw new RefreshError('ENTITY_ID_COLLISION', error.message, diagnostics); + } + const message = error instanceof Error ? error.message : String(error); + throw new RefreshError('REFRESH_FAILED', message, [toDiagnostic('REFRESH_FAILED', message)]); + } finally { + await fs.rm(refreshRoot, { recursive: true, force: true }); + temporaryRoots.delete(refreshRoot); + } + }, + }; + }; + + return { + createRegistration, + reviseRegistration, + open, + capabilities: () => capabilities, + close: async () => { + if (closed) return; + closed = true; + await Promise.all([...temporaryRoots].map(root => fs.rm(root, { recursive: true, force: true }))); + temporaryRoots.clear(); + }, + }; +} + +export { canonicalDefinition, definitionRevision, normalizeLocalSourcePath }; diff --git a/src/public/types.ts b/src/public/types.ts new file mode 100644 index 0000000..b401d1a --- /dev/null +++ b/src/public/types.ts @@ -0,0 +1,169 @@ +export type WorkspaceId = string & { readonly __workspaceId: unique symbol }; +export type RepositoryId = string & { readonly __repositoryId: unique symbol }; +export type SnapshotId = string & { readonly __snapshotId: unique symbol }; +export type DefinitionRevision = string & { readonly __definitionRevision: unique symbol }; + +export interface LocalRepositorySource { + readonly kind: 'local'; + readonly path: string; +} + +export interface RepositoryDisplayMetadata { + readonly name?: string; + readonly alias?: string; +} + +export interface WorkspaceDisplayMetadata { + readonly name?: string; + readonly alias?: string; +} + +export interface RepositoryDefinition { + readonly repositoryId: RepositoryId; + readonly source: LocalRepositorySource; +} + +export interface WorkspaceDefinition { + readonly repositories: readonly RepositoryDefinition[]; +} + +export interface RegisteredRepositoryMetadata { + readonly repositoryId: RepositoryId; + readonly display?: RepositoryDisplayMetadata; +} + +export interface WorkspaceRegistration { + readonly workspaceId: WorkspaceId; + readonly definition: WorkspaceDefinition; + readonly definitionRevision: DefinitionRevision; + readonly display?: WorkspaceDisplayMetadata; + readonly repositories: readonly RegisteredRepositoryMetadata[]; +} + +export interface CreateRepositoryInput { + readonly source: LocalRepositorySource; + readonly display?: RepositoryDisplayMetadata; +} + +export interface CreateRegistrationInput { + readonly repositories: readonly CreateRepositoryInput[]; + readonly display?: WorkspaceDisplayMetadata; +} + +export interface RetainedRepositoryInput { + readonly repositoryId: RepositoryId; + readonly source: LocalRepositorySource; + readonly display?: RepositoryDisplayMetadata; +} + +export interface ReviseRegistrationInput { + readonly retain: readonly RetainedRepositoryInput[]; + readonly add: readonly CreateRepositoryInput[]; + readonly remove: readonly RepositoryId[]; + readonly display?: WorkspaceDisplayMetadata; +} + +/** + * Opaque identity for a node in one immutable graph projection. + * Legacy node IDs are projection keys, not durable source/entity identity. + */ +export interface GraphNodeRef { + readonly workspaceId: WorkspaceId; + readonly snapshotId: SnapshotId; + readonly nodeId: string; +} + +export interface SourceProvenance { + readonly repositoryId: RepositoryId; + readonly sourceLocator?: string; +} + +export interface EntityProvenance { + readonly snapshotId: SnapshotId; + readonly sources: readonly SourceProvenance[]; +} + +export interface RelationshipProvenance { + readonly snapshotId: SnapshotId; + readonly sources: readonly SourceProvenance[]; + readonly evidence?: string; +} + +export interface GraphNode { + readonly ref: GraphNodeRef; + readonly type: string; + readonly name: string; + readonly provenance: EntityProvenance; + readonly attributes?: Readonly>; +} + +export interface GraphRelationship { + readonly source: GraphNodeRef; + readonly target: GraphNodeRef; + readonly verb: string; + readonly provenance: RelationshipProvenance; +} + +export interface TraversalOptions { + readonly maxDepth?: number; + readonly maxNodes?: number; +} + +export interface GraphProjection { + readonly nodes: readonly GraphNode[]; + readonly relationships: readonly GraphRelationship[]; + readonly getNode: (ref: GraphNodeRef) => GraphNode | undefined; + readonly traverse: (start: GraphNodeRef, options?: TraversalOptions) => readonly GraphNodeRef[]; +} + +export type RepositoryObservationStatus = 'observed' | 'orphaned' | 'unavailable'; + +export interface RepositoryObservation { + readonly repositoryId: RepositoryId; + readonly status: RepositoryObservationStatus; + readonly sourceFingerprint?: string; + readonly diagnostic?: string; +} + +export interface RuntimeDiagnostic { + readonly code: string; + readonly message: string; + readonly repositoryIds?: readonly RepositoryId[]; +} + +export interface RuntimeSnapshot { + readonly snapshotId: SnapshotId; + readonly workspaceId: WorkspaceId; + readonly definitionRevision: DefinitionRevision; + readonly status: 'complete' | 'partial'; + readonly observationFingerprint: string; + readonly observedAt: string; + readonly runtimeContractVersion: string; + readonly extractorVersions?: readonly string[]; + readonly repositoryObservations: readonly RepositoryObservation[]; + readonly graph: GraphProjection; + readonly diagnostics: readonly RuntimeDiagnostic[]; +} + +export interface WorkspaceHandle { + readonly registration: WorkspaceRegistration; + refresh(): Promise; +} + +export interface RuntimeCapabilityManifest { + readonly contractVersion: string; + readonly mechanical: true; + readonly supportedSourceKinds: readonly ['local']; + readonly federation: false; +} + +export interface Runtime { + createRegistration(input: CreateRegistrationInput): Promise; + reviseRegistration( + registration: WorkspaceRegistration, + input: ReviseRegistrationInput, + ): Promise; + open(registration: WorkspaceRegistration): Promise; + capabilities(): RuntimeCapabilityManifest; + close(): Promise; +} diff --git a/src/workspace/manifest.ts b/src/workspace/manifest.ts index cda79c6..1fbf727 100644 --- a/src/workspace/manifest.ts +++ b/src/workspace/manifest.ts @@ -111,6 +111,13 @@ function dedupeLanguages(langs: SupportedLanguage[]): SupportedLanguage[] { return [...new Set(langs)]; } +function equivalentLocalPath(left: string, right: string): boolean { + const normalize = (value: string) => path.normalize(value).replace(/[\\/]+$/, ''); + const a = normalize(left); + const b = normalize(right); + return process.platform === 'win32' ? a.toLowerCase() === b.toLowerCase() : a === b; +} + /** * Map manifest `lang` labels to RECON {@link SupportedLanguage} values. * Unknown labels fall back to {@link detectLanguages} against the repository root. @@ -319,7 +326,7 @@ export const buildPerRepoConfig: ( const relativeStateDir = toPosixPath(path.relative(repoAbsPath, stateAbsPath)); const resolvedViaJoin = path.resolve(repoAbsPath, relativeStateDir); - if (resolvedViaJoin !== stateAbsPath) { + if (!equivalentLocalPath(resolvedViaJoin, stateAbsPath)) { throw new Error( `Invariant 2 check failed: state path resolution mismatch (expected ${stateAbsPath}, got ${resolvedViaJoin})`, ); diff --git a/src/workspace/workspace-merge.ts b/src/workspace/workspace-merge.ts index 03e283f..4bfeacf 100644 --- a/src/workspace/workspace-merge.ts +++ b/src/workspace/workspace-merge.ts @@ -34,7 +34,13 @@ export interface MergedEdge { to: string; verb: string; confidence?: string; - provenance?: { source_path: string; source_ref: string; repo?: string }; + provenance?: + | { source_path: string; source_ref: string; repo?: string } + | { + source_repo: string; + target_repo: string; + evidence: string; + }; } export interface UnifiedGraphDoc { @@ -77,6 +83,66 @@ interface SliceDoc { }>; } +export interface WorkspaceIdentityCollision { + id: string; + repositories: string[]; + declarations: Array<{ repository: string; type: string; name: string }>; +} + +export class WorkspaceIdentityCollisionError extends Error { + readonly collisions: readonly WorkspaceIdentityCollision[]; + + constructor(collisions: readonly WorkspaceIdentityCollision[]) { + super( + `Workspace entity identity collision before merge: ${collisions + .map(c => `${c.id} [${c.repositories.join(', ')}]`) + .join('; ')}`, + ); + this.name = 'WorkspaceIdentityCollisionError'; + this.collisions = collisions; + } +} + +/** + * Validate workspace-scoped entity IDs before the legacy first-wins merger can + * discard a declaration. This is opt-in so existing CLI behavior remains + * compatible; the public runtime adapter always enables it. + */ +export async function preflightWorkspaceGraphIdentity(outputDir: string): Promise { + const slicesDir = path.join(outputDir, 'slices'); + const declarations = new Map>(); + let sliceFiles: string[] = []; + try { + const entries = await fs.readdir(slicesDir); + sliceFiles = entries.filter(file => file.endsWith('.yaml')).sort().map(file => path.join(slicesDir, file)); + } catch { + return; + } + + for (const slicePath of sliceFiles) { + const repository = path.basename(slicePath, '.yaml'); + const parsed = yaml.load(await fs.readFile(slicePath, 'utf8')) as SliceDoc | null; + for (const node of parsed?.nodes ?? []) { + if (!node.id || !node.type) continue; + const list = declarations.get(node.id) ?? []; + list.push({ repository: node.provenance?.repo ?? repository, type: node.type, name: node.name ?? node.id }); + declarations.set(node.id, list); + } + } + + const collisions = [...declarations.entries()] + .filter(([, entries]) => new Set(entries.map(entry => entry.repository)).size > 1) + .map(([id, entries]) => ({ + id, + repositories: [...new Set(entries.map(entry => entry.repository))].sort(), + declarations: entries, + })); + + if (collisions.length > 0) { + throw new WorkspaceIdentityCollisionError(collisions); + } +} + async function loadCrossRepoEdges(outputDir: string): Promise { const edgesPath = path.join(outputDir, 'workspace-edges.yaml'); try { @@ -190,6 +256,7 @@ export async function mergeWorkspaceGraph( to: cre.to, verb: cre.verb, confidence: cre.confidence, + provenance: cre.provenance, }); } if (crossRepoEdges.length > 0) { diff --git a/src/workspace/workspace-recon.ts b/src/workspace/workspace-recon.ts index 4cc6569..a1158a9 100644 --- a/src/workspace/workspace-recon.ts +++ b/src/workspace/workspace-recon.ts @@ -32,6 +32,8 @@ export interface WorkspaceReconOptions { failOnAnyError?: boolean; skipUnchanged?: boolean; timeoutPerRepoMs?: number; + /** Public SDK seam; legacy CLI leaves this unset to preserve first-wins behavior. */ + beforeMerge?: (outputDir: string) => Promise; } export interface RepoResult { @@ -323,6 +325,10 @@ export const executeWorkspaceRecon: ( log(`[workspace-recon] Cross-repo edge analysis failed (non-fatal): ${msg}`); } + if (options.beforeMerge) { + await options.beforeMerge(outputRoot); + } + try { const mergeResult = await mergeWorkspaceGraph(outputRoot); log( diff --git a/test/fixtures/public-consumer/bootstrap.mjs b/test/fixtures/public-consumer/bootstrap.mjs new file mode 100644 index 0000000..2b4b752 --- /dev/null +++ b/test/fixtures/public-consumer/bootstrap.mjs @@ -0,0 +1,4 @@ +import { run } from './dist/consumer.js'; + +await run(process.argv[2]); +console.log('public tarball TypeScript consumer proof passed'); diff --git a/test/fixtures/public-consumer/consumer.ts b/test/fixtures/public-consumer/consumer.ts new file mode 100644 index 0000000..507277b --- /dev/null +++ b/test/fixtures/public-consumer/consumer.ts @@ -0,0 +1,31 @@ +import { createRuntime } from 'ste-runtime'; +import type { GraphNodeRef } from 'ste-runtime'; + +const UUID_V7 = /^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i; + +export async function run(sourcePath: string): Promise { + const runtime = createRuntime(); + try { + const registration = await runtime.createRegistration({ + repositories: [{ source: { kind: 'local', path: sourcePath } }], + }); + if (!UUID_V7.test(registration.workspaceId)) throw new Error('WorkspaceId is not UUIDv7'); + if (runtime.capabilities().mechanical !== true || runtime.capabilities().federation !== false) { + throw new Error('Invalid public capability manifest'); + } + + const workspace = await runtime.open(registration); + const snapshot = await workspace.refresh(); + if (snapshot.workspaceId !== registration.workspaceId || !UUID_V7.test(snapshot.snapshotId)) { + throw new Error('Snapshot identity did not bind to the registration'); + } + const start: GraphNodeRef | undefined = snapshot.graph.nodes[0]?.ref; + if (!start) throw new Error('Packed consumer received no graph node'); + const traversed = snapshot.graph.traverse(start, { maxDepth: 0, maxNodes: 1 }); + if (traversed.length !== 1 || traversed[0]?.snapshotId !== snapshot.snapshotId) { + throw new Error('Snapshot-bound graph traversal failed'); + } + } finally { + await runtime.close(); + } +} diff --git a/test/fixtures/public-consumer/package.json b/test/fixtures/public-consumer/package.json new file mode 100644 index 0000000..7569438 --- /dev/null +++ b/test/fixtures/public-consumer/package.json @@ -0,0 +1,8 @@ +{ + "name": "ste-runtime-public-consumer-fixture", + "private": true, + "type": "module", + "scripts": { + "build": "tsc" + } +} diff --git a/test/fixtures/public-consumer/tsconfig.json b/test/fixtures/public-consumer/tsconfig.json new file mode 100644 index 0000000..3b5ef7d --- /dev/null +++ b/test/fixtures/public-consumer/tsconfig.json @@ -0,0 +1,11 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "strict": true, + "skipLibCheck": true, + "outDir": "dist" + }, + "include": ["consumer.ts"] +} diff --git a/vitest.config.ts b/vitest.config.ts index c6f2257..d19d389 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -1,4 +1,5 @@ import { defineConfig } from 'vitest/config'; +import { fileURLToPath } from 'node:url'; export default defineConfig({ test: { @@ -39,7 +40,7 @@ export default defineConfig({ // Resolve aliases for cleaner imports alias: { - '@/': new URL('./src/', import.meta.url).pathname, + '@/': fileURLToPath(new URL('./src/', import.meta.url)), }, // Pool configuration: use 'forks' for reliable ESM mocking on Linux CI From 44abc7776e241097df9ebd078043f5091d61dd2d Mon Sep 17 00:00:00 2001 From: egallmann Date: Thu, 13 Aug 2026 17:00:01 -0400 Subject: [PATCH 2/3] chore: update Node runtime baseline --- .github/workflows/test.yml | 6 +++--- CONTRIBUTING.md | 2 +- README.md | 4 ++-- documentation/guides/workspace-initialization.md | 2 +- package-lock.json | 10 +++++----- package.json | 4 ++-- scripts/init.cjs | 4 ++-- 7 files changed, 16 insertions(+), 16 deletions(-) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index cb0af01..40ca80e 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -15,7 +15,7 @@ jobs: strategy: matrix: - node-version: [18.x, 20.x, 22.x] + node-version: [22.x, 24.x] steps: - name: Checkout repository @@ -56,7 +56,7 @@ jobs: echo "RECON self-documentation completed successfully" portability: - name: OS portability / ${{ matrix.os }} / Node 20 + name: OS portability / ${{ matrix.os }} / Node 24 runs-on: ${{ matrix.os }} strategy: fail-fast: false @@ -72,7 +72,7 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: 20.x + node-version: 24.x cache: npm cache-dependency-path: package-lock.json diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7eaf3eb..ff65470 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -15,7 +15,7 @@ linking are the supported ways to experiment with it today. Prerequisites: -- Node.js 18 or later; +- Node.js 22 or later; - npm; - Python 3 for Python extractor development. diff --git a/README.md b/README.md index 04e409d..df5e5e5 100644 --- a/README.md +++ b/README.md @@ -10,7 +10,7 @@ repository exploration, task-scoped context, workspace queries, and evidence production without replacing canonical architecture or governance authority. [![License](https://img.shields.io/badge/License-Apache%202.0-blue.svg)](LICENSE) -[![Node](https://img.shields.io/badge/node-%3E%3D18.0.0-brightgreen.svg)](https://nodejs.org/) +[![Node](https://img.shields.io/badge/node-%3E%3D22.0.0-brightgreen.svg)](https://nodejs.org/) ## Runtime boundary and AI orientation @@ -26,7 +26,7 @@ human-in-the-loop; the runtime is not an autonomous engineering agent. ## Quick Start -The supported path is a source checkout. Requires Node.js 18 or later and npm. +The supported path is a source checkout. Requires Node.js 22 or later and npm. Python 3 is also required by some extractors. ```bash diff --git a/documentation/guides/workspace-initialization.md b/documentation/guides/workspace-initialization.md index 5359a1c..5a9d965 100644 --- a/documentation/guides/workspace-initialization.md +++ b/documentation/guides/workspace-initialization.md @@ -12,7 +12,7 @@ For single-repo usage, see [RECON-README.md](../../instructions/RECON-README.md) ## Prerequisites -- Node.js 18+ +- Node.js 22+ - npm - ste-runtime cloned and built: diff --git a/package-lock.json b/package-lock.json index 62c9ae2..8d127c6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,7 @@ "@eslint/js": "^9.22.0", "@mermaid-js/mermaid-cli": "^11.4.0", "@types/js-yaml": "^4.0.9", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.0", "ajv": "^8.18.0", "eslint": "^9.22.0", @@ -38,7 +38,7 @@ "vitest": "^3.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" } }, "node_modules/@alloc/quick-lru": { @@ -2126,9 +2126,9 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "20.19.30", - "resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.30.tgz", - "integrity": "sha512-WJtwWJu7UdlvzEAUm484QNg5eAoq5QR08KDNx7g45Usrs2NtOPiX8ugDqmKdXkyL03rBqU5dYNYVQetEpBHq2g==", + "version": "22.20.1", + "resolved": "https://registry.npmjs.org/@types/node/-/node-22.20.1.tgz", + "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "dev": true, "license": "MIT", "dependencies": { diff --git a/package.json b/package.json index 00ebcb3..0180333 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "@eslint/js": "^9.22.0", "@mermaid-js/mermaid-cli": "^11.4.0", "@types/js-yaml": "^4.0.9", - "@types/node": "^20.0.0", + "@types/node": "^22.0.0", "@vitest/coverage-v8": "^3.2.0", "ajv": "^8.18.0", "eslint": "^9.22.0", @@ -78,7 +78,7 @@ "vitest": "^3.2.0" }, "engines": { - "node": ">=18.0.0" + "node": ">=22.0.0" }, "packageManager": "npm@10.9.4", "keywords": [ diff --git a/scripts/init.cjs b/scripts/init.cjs index d4e1f5a..bb24af1 100644 --- a/scripts/init.cjs +++ b/scripts/init.cjs @@ -98,7 +98,7 @@ ${c.cyan}EXAMPLES:${c.reset} node scripts/init.cjs --mcp ${c.cyan}WHAT THIS DOES:${c.reset} - 1. Validates prerequisites (Node.js 18+, npm) + 1. Validates prerequisites (Node.js 22+, npm) 2. Installs dependencies (npm install) 3. Builds the project (npm run build) 4. Runs initial RECON to create semantic graph @@ -405,7 +405,7 @@ function printSummary(success, options) { log(`${c.red}${c.bold}Bootstrap failed. See errors above.${c.reset}`); log(''); log('Common fixes:'); - log(' 1. Ensure Node.js 18+ is installed'); + log(' 1. Ensure Node.js 22+ is installed'); log(' 2. Run from the ste-runtime-private directory'); log(' 3. Check network connectivity for npm install'); log(' 4. Try: rm -rf node_modules && npm install'); From c9aa5f5133ae3c61498e75749c8cd7a43b1d3291 Mon Sep 17 00:00:00 2001 From: egallmann Date: Thu, 13 Aug 2026 20:27:23 -0400 Subject: [PATCH 3/3] ci: add stable required test gate --- .github/workflows/test.yml | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 40ca80e..c24f0eb 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -99,3 +99,21 @@ jobs: - name: Verify public-source release policy run: npm run release:check + required: + name: required + if: ${{ always() }} + needs: + - test + - portability + runs-on: ubuntu-latest + steps: + - name: Verify mandatory test jobs succeeded + env: + TEST_RESULT: ${{ needs.test.result }} + PORTABILITY_RESULT: ${{ needs.portability.result }} + run: | + if [ "$TEST_RESULT" != "success" ] || [ "$PORTABILITY_RESULT" != "success" ]; then + echo "Required test gate failed: test=$TEST_RESULT portability=$PORTABILITY_RESULT" + exit 1 + fi +