diff --git a/.harness/scripts/ci/14-rag-index-sync.mjs b/.harness/scripts/ci/14-rag-index-sync.mjs index b4a5ae361..d213d0317 100644 --- a/.harness/scripts/ci/14-rag-index-sync.mjs +++ b/.harness/scripts/ci/14-rag-index-sync.mjs @@ -21,6 +21,10 @@ import { readFileSync, writeFileSync, existsSync } from 'node:fs'; import { resolve } from 'node:path'; import { createRagAdapter } from './rag-port.mjs'; import { syncIndex, chunkIds } from './rag-sync.mjs'; +// Side-effect import: registers the durable `pgvector` adapter (GT-538 / ADR-0112) +// so EVOLITH_RAG_PROVIDER=pgvector resolves instead of failing closed. The port +// itself stays vendor-neutral; the vendor is wired only here, at the CI seam. +import './rag-pgvector.mjs'; const RAG_SYNC_ENABLED = process.env.EVOLITH_RAG_SYNC === 'true'; diff --git a/.harness/scripts/ci/rag-embed-integration.test.mjs b/.harness/scripts/ci/rag-embed-integration.test.mjs new file mode 100644 index 000000000..ea7f2cf4b --- /dev/null +++ b/.harness/scripts/ci/rag-embed-integration.test.mjs @@ -0,0 +1,121 @@ +/** + * GT-539 — Integration of the real embedding model into the durable pgvector + * adapter and the write-side sync. STUBBED DB client + STUBBED sidecar fetch; + * the live sidecar (running Qwen3) is deploy-gated and NOT exercised here. + * + * Covers: configured ⇒ real-model path (dim 1024 + model id in corpus_version); + * unconfigured ⇒ hashEmbed offline default; wrong-dim sidecar ⇒ fail closed. + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { syncIndex } from './rag-sync.mjs'; +import { pgvectorAdapter, RAG_EMBEDDING_DIM, HASH_EMBED_MODEL_ID } from './rag-pgvector.mjs'; +import { RagPortError } from './rag-port.mjs'; + +const DOC = `# Title\n\nintro\n\n## Alpha\n\nalpha body\n\n## Beta\n\nbeta body\n`; +const URL = 'http://localhost:8085/embed'; +const vec = (n, f = 0.1) => Array.from({ length: n }, () => f); + +function dbStub() { + const calls = []; + return { + calls, + async query(text, params) { + calls.push({ text, params }); + return { rowCount: Array.isArray(params?.[0]) ? params[0].length : 1 }; + }, + }; +} + +/** A sidecar fetch stub returning `dim`-length vectors for every input. */ +function sidecarStub(dim = RAG_EMBEDDING_DIM) { + const calls = []; + const fn = async (url, opts) => { + const { input } = JSON.parse(opts.body); + calls.push({ url, input }); + return { ok: true, status: 200, async json() { return { data: input.map(() => ({ embedding: vec(dim) })) }; } }; + }; + fn.calls = calls; + return fn; +} + +/** Run a block with EVOLITH_RAG_EMBED_URL unset (isolated offline default). */ +async function withoutSidecarEnv(fn) { + const saved = process.env.EVOLITH_RAG_EMBED_URL; + delete process.env.EVOLITH_RAG_EMBED_URL; + try { + return await fn(); + } finally { + if (saved !== undefined) process.env.EVOLITH_RAG_EMBED_URL = saved; + } +} + +test('unconfigured adapter uses the hashEmbed offline default (model id = hash-sha256@1024)', async () => { + await withoutSidecarEnv(async () => { + const a = pgvectorAdapter({ client: dbStub() }); + assert.equal(a.embeddingModelId, HASH_EMBED_MODEL_ID); + const [v] = await a.embed(['hello']); + assert.equal(v.length, RAG_EMBEDDING_DIM); + }); +}); + +test('configured adapter uses the real model path (sidecar called, dim 1024)', async () => { + const sidecar = sidecarStub(); + const a = pgvectorAdapter({ client: dbStub(), url: URL, fetch: sidecar }); + assert.equal(a.embeddingModelId, 'qwen3-embedding-0.6b'); + const out = await a.embed(['x', 'y']); + assert.equal(out.length, 2); + assert.equal(out[0].length, RAG_EMBEDDING_DIM); + assert.ok(sidecar.calls.length >= 1, 'the sidecar must be called on the real-model path'); +}); + +test('configured sync records the model id in each chunk corpus_version (ADR-0090 §3)', async () => { + const db = dbStub(); + const a = pgvectorAdapter({ client: db, url: URL, fetch: sidecarStub() }); + const receipt = await syncIndex({ + adapter: a, + changed: [{ sourceFile: 'reference/x.md', content: DOC }], + corpusVersion: 'gitsha123', + }); + assert.equal(receipt.corpusVersion, 'gitsha123+qwen3-embedding-0.6b'); + + const upsertCalls = db.calls.filter((c) => /INSERT INTO rag_chunks/.test(c.text)); + assert.ok(upsertCalls.length >= 2, 'expected multiple chunks upserted'); + for (const c of upsertCalls) { + // param[8] is the corpus_version column; param[9] is the 1024-dim vector literal. + assert.equal(c.params[8], 'gitsha123+qwen3-embedding-0.6b'); + assert.match(c.params[9], /^\[/); + } +}); + +test('offline sync still declares the (hash) model id in corpus_version', async () => { + await withoutSidecarEnv(async () => { + const a = pgvectorAdapter({ client: dbStub() }); + const receipt = await syncIndex({ + adapter: a, + changed: [{ sourceFile: 'reference/x.md', content: DOC }], + corpusVersion: 'gitsha123', + }); + assert.equal(receipt.corpusVersion, `gitsha123+${HASH_EMBED_MODEL_ID}`); + }); +}); + +test('configured adapter fails closed on a wrong-dimension sidecar response', async () => { + const a = pgvectorAdapter({ client: dbStub(), url: URL, fetch: sidecarStub(768) }); + await assert.rejects(() => a.embed(['x']), RagPortError); +}); + +test('an injected embedder with the wrong dim is rejected at construction (fail closed)', () => { + assert.throws( + () => pgvectorAdapter({ client: dbStub(), embedder: { modelId: 'x', dim: 512, embed: async () => [] } }), + RagPortError, + ); +}); + +test('adapter embed() re-checks vector dimension even from an injected embedder', async () => { + const a = pgvectorAdapter({ + client: dbStub(), + embedder: { modelId: 'liar', dim: RAG_EMBEDDING_DIM, embed: async (t) => t.map(() => vec(999)) }, + }); + await assert.rejects(() => a.embed(['x']), RagPortError); +}); diff --git a/.harness/scripts/ci/rag-embed-qwen3.mjs b/.harness/scripts/ci/rag-embed-qwen3.mjs new file mode 100644 index 000000000..8b0925a82 --- /dev/null +++ b/.harness/scripts/ci/rag-embed-qwen3.mjs @@ -0,0 +1,130 @@ +/** + * GT-539 / ADR-0112 §1,§2,§4,§5 — Real embedding model behind the RAG port. + * + * A model-agnostic embedder that computes vectors by calling a LOCAL inference + * SIDECAR over HTTP. Per ADR-0112 §4 the Node process never runs the model + * in-process — the sidecar is the platform boundary and embeddings are computed + * on-perimeter, so there is NO corpus egress. The default model is + * `Qwen3-Embedding-0.6B` at dimension 1024 (ADR-0112 §1/§2), but the endpoint, + * model id and dimension are all env/config-driven so switching model is a + * re-embed, not a code change (the port stays model-agnostic — ADR-0090 §3). + * + * The network client is an INJECTED seam (`config.fetch`) so unit tests never + * touch the network. This module NEVER imports a network library at load time. + * + * Fails closed on: a missing endpoint, a transport error, a non-OK response, an + * unparseable body, a wrong vector count, or ANY vector whose length != the + * expected dimension. Dimension consistency is load-bearing — vectors of a + * different dimension are not cosine-comparable with the store (ADR-0112 §2/§5). + */ + +import { RagPortError } from './rag-port.mjs'; + +/** ADR-0112 §1 — default self-hosted OSS model (Apache-2.0), lean footprint. */ +export const DEFAULT_EMBED_MODEL = 'qwen3-embedding-0.6b'; + +/** ADR-0112 §2 — Qwen3 Matryoshka maximum; the pgvector column is vector(1024). */ +export const DEFAULT_EMBED_DIM = 1024; + +/** Resolve the configured sidecar URL (injected config wins over env). */ +function resolveUrl(config = {}) { + return config.url || config.embedUrl || process.env.EVOLITH_RAG_EMBED_URL || null; +} + +/** + * True when an on-perimeter inference sidecar is configured (injected config or + * env). When false, callers keep the offline `hashEmbed` default — the sidecar + * is opt-in so dry-run/tests never require it. + */ +export function isQwen3Configured(config = {}) { + return Boolean(resolveUrl(config)); +} + +/** Accept OpenAI-style {data:[{embedding}]}, TEI-style [[...]], or {embeddings}. */ +function extractVectors(body) { + const pick = (e) => (Array.isArray(e) ? e : e && e.embedding); + if (Array.isArray(body)) return body.map(pick); + if (body && Array.isArray(body.data)) return body.data.map(pick); + if (body && Array.isArray(body.embeddings)) return body.embeddings.map(pick); + return null; +} + +/** + * Build a Qwen3 sidecar embedder. + * + * @param {object} [config] + * @param {string} [config.url] sidecar endpoint (else EVOLITH_RAG_EMBED_URL) + * @param {string} [config.model] model id (else EVOLITH_RAG_EMBED_MODEL / default) + * @param {number} [config.dim] expected output dimension (default 1024) + * @param {Function} [config.fetch] injected fetch seam (else global fetch) + * @returns {{ modelId: string, dim: number, embed(texts: string[]): Promise }} + */ +export function makeQwen3Embedder(config = {}) { + const url = resolveUrl(config); + if (!url) { + throw new RagPortError( + 'Qwen3 embedder requires a sidecar URL (config.url or EVOLITH_RAG_EMBED_URL)', + ); + } + const modelId = config.model || process.env.EVOLITH_RAG_EMBED_MODEL || DEFAULT_EMBED_MODEL; + const dim = Number(config.dim || DEFAULT_EMBED_DIM); + // Injected client seam — tests pass a stub; real runs use global fetch. + const doFetch = config.fetch || globalThis.fetch; + if (typeof doFetch !== 'function') { + throw new RagPortError( + 'Qwen3 embedder requires a fetch implementation (config.fetch or a global fetch)', + ); + } + + return { + modelId, + dim, + async embed(texts) { + if (!Array.isArray(texts)) throw new RagPortError('embed() expects an array of texts'); + if (texts.length === 0) return []; + + let res; + try { + res = await doFetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ model: modelId, input: texts }), + }); + } catch (err) { + // Fail closed — never silently fall back to a non-semantic embedding. + throw new RagPortError(`Qwen3 sidecar request failed (${url}): ${err.message}`); + } + + if (!res || res.ok === false) { + const status = res && res.status != null ? res.status : 'no-response'; + throw new RagPortError(`Qwen3 sidecar returned a non-OK response (status ${status})`); + } + + let body; + try { + body = await res.json(); + } catch (err) { + throw new RagPortError(`Qwen3 sidecar returned an unparseable body: ${err.message}`); + } + + const vectors = extractVectors(body); + if (!Array.isArray(vectors) || vectors.length !== texts.length) { + throw new RagPortError( + `Qwen3 sidecar returned ${Array.isArray(vectors) ? vectors.length : typeof vectors} ` + + `vector(s) for ${texts.length} input(s)`, + ); + } + for (let i = 0; i < vectors.length; i++) { + const v = vectors[i]; + if (!Array.isArray(v) || v.length !== dim) { + // Fail closed — a mismatched dimension is not cosine-comparable. + throw new RagPortError( + `Qwen3 sidecar vector ${i} has dimension ` + + `${Array.isArray(v) ? v.length : typeof v}, expected ${dim} (ADR-0112 §2/§5)`, + ); + } + } + return vectors; + }, + }; +} diff --git a/.harness/scripts/ci/rag-embed-qwen3.test.mjs b/.harness/scripts/ci/rag-embed-qwen3.test.mjs new file mode 100644 index 000000000..9dfea0236 --- /dev/null +++ b/.harness/scripts/ci/rag-embed-qwen3.test.mjs @@ -0,0 +1,122 @@ +/** + * GT-539 — Unit tests for the Qwen3 sidecar embedder (rag-embed-qwen3.mjs). + * + * Every case uses a STUBBED fetch — the network is never touched. The live + * sidecar (an actually-running Qwen3 model) is deploy-gated and NOT exercised + * here (ADR-0112 §4). + */ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { + makeQwen3Embedder, + isQwen3Configured, + DEFAULT_EMBED_MODEL, + DEFAULT_EMBED_DIM, +} from './rag-embed-qwen3.mjs'; +import { RagPortError } from './rag-port.mjs'; + +const URL = 'http://localhost:8085/embed'; +const vec = (n, fill = 0.1) => Array.from({ length: n }, () => fill); + +/** Build an injectable fetch stub that records calls and shapes its response. */ +function fetchStub({ vectors = [], ok = true, status = 200, shape = 'openai', throwErr = null, badJson = false } = {}) { + const calls = []; + const fn = async (url, opts) => { + calls.push({ url, opts, body: opts && JSON.parse(opts.body) }); + if (throwErr) throw new Error(throwErr); + return { + ok, + status, + async json() { + if (badJson) throw new Error('invalid json'); + if (shape === 'openai') return { data: vectors.map((v) => ({ embedding: v })) }; + if (shape === 'tei') return vectors; // bare array of vectors + if (shape === 'embeddings') return { embeddings: vectors }; + return {}; + }, + }; + }; + fn.calls = calls; + return fn; +} + +test('isQwen3Configured reflects config.url', () => { + assert.equal(isQwen3Configured({}), false); + assert.equal(isQwen3Configured({ url: URL }), true); +}); + +test('defaults: model qwen3-embedding-0.6b, dim 1024 (ADR-0112 §1/§2)', () => { + const e = makeQwen3Embedder({ url: URL, fetch: fetchStub() }); + assert.equal(e.modelId, DEFAULT_EMBED_MODEL); + assert.equal(e.modelId, 'qwen3-embedding-0.6b'); + assert.equal(e.dim, 1024); + assert.equal(DEFAULT_EMBED_DIM, 1024); +}); + +test('embed posts {model,input} and returns dim-1024 vectors (openai shape)', async () => { + const stub = fetchStub({ vectors: [vec(1024), vec(1024, 0.2)] }); + const e = makeQwen3Embedder({ url: URL, fetch: stub }); + const out = await e.embed(['a', 'b']); + assert.equal(out.length, 2); + assert.equal(out[0].length, 1024); + assert.equal(stub.calls.length, 1); + assert.equal(stub.calls[0].url, URL); + assert.equal(stub.calls[0].opts.method, 'POST'); + assert.deepEqual(stub.calls[0].body, { model: 'qwen3-embedding-0.6b', input: ['a', 'b'] }); +}); + +test('embed accepts TEI bare-array and {embeddings} response shapes', async () => { + const e1 = makeQwen3Embedder({ url: URL, fetch: fetchStub({ vectors: [vec(1024)], shape: 'tei' }) }); + assert.equal((await e1.embed(['x']))[0].length, 1024); + const e2 = makeQwen3Embedder({ url: URL, fetch: fetchStub({ vectors: [vec(1024)], shape: 'embeddings' }) }); + assert.equal((await e2.embed(['x']))[0].length, 1024); +}); + +test('empty input short-circuits without a network call (no egress)', async () => { + const stub = fetchStub(); + const e = makeQwen3Embedder({ url: URL, fetch: stub }); + assert.deepEqual(await e.embed([]), []); + assert.equal(stub.calls.length, 0); +}); + +test('custom model + Matryoshka dim are honoured', async () => { + const e = makeQwen3Embedder({ url: URL, model: 'qwen3-embedding-4b', dim: 512, fetch: fetchStub({ vectors: [vec(512)] }) }); + assert.equal(e.modelId, 'qwen3-embedding-4b'); + assert.equal(e.dim, 512); + assert.equal((await e.embed(['x']))[0].length, 512); +}); + +test('wrong-dimension response fails closed (768 != 1024)', async () => { + const e = makeQwen3Embedder({ url: URL, fetch: fetchStub({ vectors: [vec(768)] }) }); + await assert.rejects(() => e.embed(['x']), RagPortError); +}); + +test('vector-count mismatch fails closed', async () => { + const e = makeQwen3Embedder({ url: URL, fetch: fetchStub({ vectors: [vec(1024)] }) }); + await assert.rejects(() => e.embed(['x', 'y']), RagPortError); +}); + +test('non-OK sidecar response fails closed', async () => { + const e = makeQwen3Embedder({ url: URL, fetch: fetchStub({ ok: false, status: 503 }) }); + await assert.rejects(() => e.embed(['x']), RagPortError); +}); + +test('transport error fails closed (never silently degrades)', async () => { + const e = makeQwen3Embedder({ url: URL, fetch: fetchStub({ throwErr: 'ECONNREFUSED' }) }); + await assert.rejects(() => e.embed(['x']), RagPortError); +}); + +test('unparseable body fails closed', async () => { + const e = makeQwen3Embedder({ url: URL, fetch: fetchStub({ badJson: true }) }); + await assert.rejects(() => e.embed(['x']), RagPortError); +}); + +test('missing url throws (fail closed at construction)', () => { + const saved = process.env.EVOLITH_RAG_EMBED_URL; + delete process.env.EVOLITH_RAG_EMBED_URL; + try { + assert.throws(() => makeQwen3Embedder({ fetch: fetchStub() }), RagPortError); + } finally { + if (saved !== undefined) process.env.EVOLITH_RAG_EMBED_URL = saved; + } +}); diff --git a/.harness/scripts/ci/rag-pgvector.mjs b/.harness/scripts/ci/rag-pgvector.mjs new file mode 100644 index 000000000..9f7c50a24 --- /dev/null +++ b/.harness/scripts/ci/rag-pgvector.mjs @@ -0,0 +1,205 @@ +/** + * GT-538 / ADR-0112 — Durable pgvector adapter behind the RAG port. + * + * Registers a `durable: true` adapter with the provider-neutral port + * (`rag-port.mjs`) so `createRagAdapter({ provider: 'pgvector' })` returns a + * real vector-store writer and a live `14-rag-index-sync.mjs` run persists + * embeddings instead of failing closed (GT-145 contract). + * + * Design constraints honoured here: + * - `pg` (node-postgres) is NOT a build dependency. This module never imports + * it at load time. Tests inject a minimal `{ query(text, params) }` client; + * only a real run with no injected client lazy-imports `pg` and builds a Pool + * from `config.connectionString` / env. If neither is available at run time + * the adapter fails closed. + * - Dimension is fixed at 1024 and distance is cosine (ADR-0112 §2/§3); the DDL + * lives in `rag-pgvector.schema.sql` and is re-exported here as PGVECTOR_DDL. + * - `embed()` uses the REAL Qwen3-Embedding model (via the on-perimeter + * inference sidecar) WHEN configured (GT-539 · `rag-embed-qwen3.mjs`), and + * falls back to the deterministic `hashEmbed(t, 1024)` offline default when + * no sidecar is configured (dry-run / tests). The port stays model-agnostic: + * the concrete model is never hard-coded, only defaulted (ADR-0090 §3). The + * effective model id is exposed as `embeddingModelId` so the sync can fold it + * into `corpus_version` for cache invalidation (ADR-0090 §3 / ADR-0112 §1). + */ + +import { readFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { dirname, resolve } from 'node:path'; +import { registerRagAdapter, hashEmbed, RagPortError } from './rag-port.mjs'; +import { makeQwen3Embedder, isQwen3Configured } from './rag-embed-qwen3.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); + +/** ADR-0112 §2 — Qwen3 Matryoshka maximum; the pgvector column is vector(1024). */ +export const RAG_EMBEDDING_DIM = 1024; + +/** Offline/test default model id — deterministic sha256 pseudo-embedding at the store dim. */ +export const HASH_EMBED_MODEL_ID = `hash-sha256@${RAG_EMBEDDING_DIM}`; + +/** Chunk table name (matches the DDL). */ +export const RAG_PGVECTOR_TABLE = 'rag_chunks'; + +/** Path to the canonical DDL file (single source of truth). */ +export const PGVECTOR_SCHEMA_PATH = resolve(__dirname, 'rag-pgvector.schema.sql'); + +/** + * The DDL, read from the canonical .sql file so the exported constant and the + * file can never drift. A unit test asserts they agree. + */ +export const PGVECTOR_DDL = readFileSync(PGVECTOR_SCHEMA_PATH, 'utf8'); + +/** Parameterized upsert — id, metadata columns, and the vector (cast ::vector). */ +export const UPSERT_SQL = `INSERT INTO ${RAG_PGVECTOR_TABLE} + (id, content, section_heading, char_start, char_end, source_file, adr_id, language, corpus_version, embedding) +VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10::vector) +ON CONFLICT (id) DO UPDATE SET + content = EXCLUDED.content, + section_heading = EXCLUDED.section_heading, + char_start = EXCLUDED.char_start, + char_end = EXCLUDED.char_end, + source_file = EXCLUDED.source_file, + adr_id = EXCLUDED.adr_id, + language = EXCLUDED.language, + corpus_version = EXCLUDED.corpus_version, + embedding = EXCLUDED.embedding`; + +/** Parameterized bulk delete by id. */ +export const DELETE_SQL = `DELETE FROM ${RAG_PGVECTOR_TABLE} WHERE id = ANY($1)`; + +/** pgvector text literal for a numeric vector: [0.1,0.2,...]. */ +function toVectorLiteral(vec) { + return `[${vec.join(',')}]`; +} + +/** + * Resolve the embedding function behind the port. When an on-perimeter inference + * sidecar is configured (GT-539) — or an explicit `config.embedder` is injected — + * use the REAL model; otherwise fall back to the deterministic offline default. + * Asserts the model's output dimension equals the store dimension (fail closed: + * a mismatched dimension is not cosine-comparable — ADR-0112 §2/§5). + */ +function resolveEmbedder(config) { + if (config.embedder || isQwen3Configured(config)) { + const embedder = config.embedder || makeQwen3Embedder({ ...config, dim: RAG_EMBEDDING_DIM }); + if (embedder.dim !== RAG_EMBEDDING_DIM) { + throw new RagPortError( + `configured embedding model dim ${embedder.dim} != store dim ${RAG_EMBEDDING_DIM} ` + + `(ADR-0112 §2 — fail closed)`, + ); + } + return embedder; + } + // Offline default: deterministic hashEmbed at the store dimension. Non-semantic + // but stable — used for dry-run and tests when no sidecar is on-perimeter. + return { + modelId: HASH_EMBED_MODEL_ID, + dim: RAG_EMBEDDING_DIM, + async embed(texts) { + return texts.map((t) => hashEmbed(t, RAG_EMBEDDING_DIM)); + }, + }; +} + +/** Factory: `config -> durable pgvector adapter`. */ +export function pgvectorAdapter(config = {}) { + let clientPromise = null; + const getClient = () => { + if (!clientPromise) clientPromise = resolveClient(config); + return clientPromise; + }; + const embedder = resolveEmbedder(config); + + return { + name: 'pgvector', + durable: true, + dim: RAG_EMBEDDING_DIM, + ddl: PGVECTOR_DDL, + // Effective embedding model id — the sync folds this into corpus_version so + // a model swap invalidates the cache (ADR-0090 §3 / ADR-0112 §1). + embeddingModelId: embedder.modelId, + + async embed(texts) { + const vectors = await embedder.embed(texts); + // Defense in depth: the store column is vector(1024) — refuse anything else, + // even from an injected embedder (fail closed on dimension drift). + for (const v of vectors) { + if (!Array.isArray(v) || v.length !== RAG_EMBEDDING_DIM) { + throw new RagPortError( + `embedding dimension ${Array.isArray(v) ? v.length : typeof v} != ` + + `store dim ${RAG_EMBEDDING_DIM} (ADR-0112 §2 — fail closed)`, + ); + } + } + return vectors; + }, + + async upsert(records) { + const client = await getClient(); + let upserted = 0; + for (const r of records) { + if (!r || typeof r.id !== 'string') { + throw new RagPortError('pgvector upsert record requires a string id'); + } + const vec = r.vector; + if (!Array.isArray(vec) || vec.length !== RAG_EMBEDDING_DIM) { + throw new RagPortError( + `pgvector upsert expects a ${RAG_EMBEDDING_DIM}-dim vector for id "${r.id}" ` + + `(got ${Array.isArray(vec) ? vec.length : typeof vec})`, + ); + } + const m = r.metadata || {}; + await client.query(UPSERT_SQL, [ + r.id, + m.text ?? m.text_preview ?? null, // full chunk body for retrieval (fallback to preview) + m.section_heading ?? null, + m.char_start ?? null, + m.char_end ?? null, + m.source_file ?? null, + m.adr_id ?? null, + m.language ?? null, + m.corpus_version ?? null, + toVectorLiteral(vec), + ]); + upserted += 1; + } + return { upserted }; + }, + + async delete(ids) { + if (!Array.isArray(ids) || ids.length === 0) return { deleted: 0 }; + const client = await getClient(); + const res = await client.query(DELETE_SQL, [ids]); + return { deleted: typeof res?.rowCount === 'number' ? res.rowCount : ids.length }; + }, + }; +} + +/** + * Resolve a DB client. Prefers the injected `config.client` seam. Only when + * none is injected does it lazy-import `pg` and build a Pool — so module load + * never requires the package. Fails closed if neither is available. + */ +async function resolveClient(config) { + if (config.client && typeof config.client.query === 'function') return config.client; + + const connectionString = + config.connectionString || process.env.EVOLITH_RAG_PG_URL || process.env.DATABASE_URL; + + let pg; + try { + pg = await import('pg'); + } catch { + throw new RagPortError( + 'pgvector adapter requires an injected client (config.client) or the optional "pg" package at run time', + ); + } + const Pool = pg.default?.Pool || pg.Pool; + if (typeof Pool !== 'function') { + throw new RagPortError('pgvector adapter could not resolve a pg Pool constructor'); + } + return new Pool(connectionString ? { connectionString } : {}); +} + +// Register on import so `createRagAdapter({ provider: 'pgvector' })` resolves it. +registerRagAdapter('pgvector', pgvectorAdapter); diff --git a/.harness/scripts/ci/rag-pgvector.schema.sql b/.harness/scripts/ci/rag-pgvector.schema.sql new file mode 100644 index 000000000..1f1f5b04b --- /dev/null +++ b/.harness/scripts/ci/rag-pgvector.schema.sql @@ -0,0 +1,35 @@ +-- GT-538 / ADR-0112 — Durable pgvector schema for the RAG chunk index. +-- +-- Realizes the ADR-0090 §5 "preferred self-hosted" target on the PostgreSQL +-- instance Evolith already runs (:5432). Dimension is 1024 and the ANN index +-- is cosine-distance HNSW, both fixed by ADR-0112 §2/§3. The four ADR-0090 §2 +-- filter fields (source_file, adr_id, language, corpus_version) are first-class +-- columns with btree indexes so metadata filtering is a plain WHERE clause. +-- +-- This file is the single source of truth for the DDL; rag-pgvector.mjs exports +-- the identical text as PGVECTOR_DDL and a unit test asserts they never drift. + +CREATE EXTENSION IF NOT EXISTS vector; + +CREATE TABLE IF NOT EXISTS rag_chunks ( + id TEXT PRIMARY KEY, + content TEXT, + section_heading TEXT, + char_start INTEGER, + char_end INTEGER, + source_file TEXT NOT NULL, + adr_id TEXT, + language TEXT NOT NULL, + corpus_version TEXT NOT NULL, + embedding vector(1024) NOT NULL +); + +-- Approximate nearest-neighbour search — cosine distance (ADR-0112 §3). +CREATE INDEX IF NOT EXISTS rag_chunks_embedding_hnsw + ON rag_chunks USING hnsw (embedding vector_cosine_ops); + +-- Metadata filter indexes (ADR-0090 §2 filter fields). +CREATE INDEX IF NOT EXISTS rag_chunks_source_file_idx ON rag_chunks (source_file); +CREATE INDEX IF NOT EXISTS rag_chunks_adr_id_idx ON rag_chunks (adr_id); +CREATE INDEX IF NOT EXISTS rag_chunks_language_idx ON rag_chunks (language); +CREATE INDEX IF NOT EXISTS rag_chunks_corpus_version_idx ON rag_chunks (corpus_version); diff --git a/.harness/scripts/ci/rag-pgvector.test.mjs b/.harness/scripts/ci/rag-pgvector.test.mjs new file mode 100644 index 000000000..363cbe66d --- /dev/null +++ b/.harness/scripts/ci/rag-pgvector.test.mjs @@ -0,0 +1,133 @@ +import assert from 'node:assert/strict'; +import test from 'node:test'; +import { readFileSync, existsSync } from 'node:fs'; +import { createRagAdapter, availableRagProviders, RagPortError } from './rag-port.mjs'; +import { + pgvectorAdapter, + PGVECTOR_DDL, + PGVECTOR_SCHEMA_PATH, + UPSERT_SQL, + DELETE_SQL, + RAG_EMBEDDING_DIM, + RAG_PGVECTOR_TABLE, +} from './rag-pgvector.mjs'; + +/** Minimal `{ query(text, params) }` stub that records every call. */ +function makeStub() { + const calls = []; + return { + calls, + async query(text, params) { + calls.push({ text, params }); + return { rowCount: Array.isArray(params?.[0]) ? params[0].length : 1 }; + }, + }; +} + +const META = { + chunk_id: 'abc123', + source_file: 'reference/core/architecture/adrs/core/0090-rag-knowledge-governance.md', + section_heading: 'Decision', + adr_id: '0090', + language: 'en', + corpus_version: 'deadbeefcafe', + text_preview: 'a preview of the chunk body', +}; + +test('pgvector adapter registers as a durable provider', () => { + assert.ok(availableRagProviders().includes('pgvector')); + const a = createRagAdapter({ provider: 'pgvector', client: makeStub() }); + assert.equal(a.name, 'pgvector'); + assert.equal(a.durable, true); + assert.equal(a.dim, 1024); +}); + +test('createRagAdapter({provider:"pgvector", client}) works with an injected stub', () => { + const a = createRagAdapter({ provider: 'pgvector', client: makeStub() }); + for (const m of ['embed', 'upsert', 'delete']) assert.equal(typeof a[m], 'function'); +}); + +test('embed returns dimension-1024 vectors', async () => { + const a = pgvectorAdapter({ client: makeStub() }); + const vectors = await a.embed(['hello', 'world']); + assert.equal(vectors.length, 2); + assert.equal(vectors[0].length, RAG_EMBEDDING_DIM); + assert.equal(vectors[1].length, 1024); +}); + +test('upsert issues parameterized SQL with id, vector, and all four metadata columns', async () => { + const stub = makeStub(); + const a = pgvectorAdapter({ client: stub }); + const vector = await a.embed(['x']).then((v) => v[0]); + const res = await a.upsert([{ id: 'chunk-1', vector, metadata: META }]); + + assert.equal(res.upserted, 1); + assert.equal(stub.calls.length, 1); + + const { text, params } = stub.calls[0]; + // parameterized (no interpolated values), targets the chunk table, upserts on conflict + assert.match(text, /INSERT INTO rag_chunks/); + assert.match(text, /ON CONFLICT \(id\) DO UPDATE/); + assert.match(text, /\$10::vector/); + assert.equal(text, UPSERT_SQL); + + // id first, vector last (as a pgvector literal), all four filter columns present + assert.equal(params[0], 'chunk-1'); + assert.equal(params[5], META.source_file); + assert.equal(params[6], META.adr_id); + assert.equal(params[7], META.language); + assert.equal(params[8], META.corpus_version); + assert.equal(params[9], `[${vector.join(',')}]`); + assert.equal(params.length, 10); +}); + +test('upsert rejects a wrong-dimension vector (fail closed)', async () => { + const a = pgvectorAdapter({ client: makeStub() }); + await assert.rejects( + () => a.upsert([{ id: 'bad', vector: [0.1, 0.2, 0.3], metadata: META }]), + RagPortError, + ); +}); + +test('upsert rejects a record without a string id', async () => { + const a = pgvectorAdapter({ client: makeStub() }); + const vector = await a.embed(['x']).then((v) => v[0]); + await assert.rejects(() => a.upsert([{ vector, metadata: META }]), RagPortError); +}); + +test('delete issues DELETE ... WHERE id = ANY($1) with an id array', async () => { + const stub = makeStub(); + const a = pgvectorAdapter({ client: stub }); + const res = await a.delete(['id-a', 'id-b']); + + assert.equal(stub.calls.length, 1); + const { text, params } = stub.calls[0]; + assert.equal(text, DELETE_SQL); + assert.match(text, /DELETE FROM rag_chunks WHERE id = ANY\(\$1\)/); + assert.deepEqual(params, [['id-a', 'id-b']]); + assert.equal(res.deleted, 2); +}); + +test('delete on an empty id list is a no-op (no query)', async () => { + const stub = makeStub(); + const a = pgvectorAdapter({ client: stub }); + const res = await a.delete([]); + assert.equal(stub.calls.length, 0); + assert.equal(res.deleted, 0); +}); + +test('DDL: exported constant matches the on-disk schema file (no drift)', () => { + assert.ok(existsSync(PGVECTOR_SCHEMA_PATH), 'rag-pgvector.schema.sql must exist'); + const onDisk = readFileSync(PGVECTOR_SCHEMA_PATH, 'utf8'); + assert.equal(PGVECTOR_DDL, onDisk); +}); + +test('DDL: dimension 1024, cosine HNSW, vector extension, and metadata columns (ADR-0112)', () => { + assert.match(PGVECTOR_DDL, /CREATE EXTENSION IF NOT EXISTS vector/); + assert.match(PGVECTOR_DDL, /embedding\s+vector\(1024\)/); + assert.match(PGVECTOR_DDL, /USING hnsw \(embedding vector_cosine_ops\)/); + for (const col of ['source_file', 'adr_id', 'language', 'corpus_version']) { + assert.match(PGVECTOR_DDL, new RegExp(`${col}`), `DDL missing metadata column ${col}`); + } + assert.equal(RAG_PGVECTOR_TABLE, 'rag_chunks'); +}); diff --git a/.harness/scripts/ci/rag-sync.mjs b/.harness/scripts/ci/rag-sync.mjs index c0737d6a7..4c4c7ac1b 100644 --- a/.harness/scripts/ci/rag-sync.mjs +++ b/.harness/scripts/ci/rag-sync.mjs @@ -83,8 +83,10 @@ export function chunkIds(content, sourceFile, corpusVersion) { } function metadataOf(chunk) { - const { text, ...meta } = chunk; // store metadata + preview, not the full text body - return meta; + // Keep the full `text` so DURABLE stores (pgvector) persist the retrievable + // chunk body — retrieval that returns only a 120-char preview cannot ground + // an agent. The non-durable `memory` adapter simply ignores unused fields. + return { ...chunk }; } /** @@ -102,9 +104,16 @@ export async function syncIndex({ adapter, changed = [], deleted = [], corpusVer if (!adapter || typeof adapter.embed !== 'function') { throw new Error('syncIndex requires a valid RAG adapter'); } + // ADR-0090 §3 / ADR-0112 §1 — the embedding model id is part of corpus + // identity so cache invalidation tracks a model swap (a re-embed). Adapters + // that expose their effective model id fold it into corpus_version; adapters + // that do not (e.g. the memory stand-in) leave it unchanged. + const effectiveCorpusVersion = adapter.embeddingModelId + ? `${corpusVersion}+${adapter.embeddingModelId}` + : corpusVersion; const receipt = { schemaVersion: RECEIPT_SCHEMA_VERSION, - corpusVersion, + corpusVersion: effectiveCorpusVersion, provider: adapter.name, durable: !!adapter.durable, upserted: [], @@ -114,7 +123,7 @@ export async function syncIndex({ adapter, changed = [], deleted = [], corpusVer }; for (const file of changed) { - const chunks = chunkMarkdown(file.content, file.sourceFile, corpusVersion); + const chunks = chunkMarkdown(file.content, file.sourceFile, effectiveCorpusVersion); const liveIds = new Set(chunks.map((c) => c.chunk_id)); // Prune stale chunks that no longer exist in the re-indexed file (no orphans). diff --git a/package-lock.json b/package-lock.json index 0afbb1f0a..14c68c4f2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -567,6 +567,10 @@ "resolved": "src/sdk/cli", "link": true }, + "node_modules/@beyondnet/evolith-contracts": { + "resolved": "src/packages/contracts", + "link": true + }, "node_modules/@beyondnet/evolith-core": { "resolved": "src/packages/core", "link": true @@ -11853,6 +11857,19 @@ "typescript": "6.0.3" } }, + "src/packages/contracts": { + "name": "@beyondnet/evolith-contracts", + "version": "1.0.0", + "license": "MIT", + "devDependencies": { + "@beyondnet/evolith-core-domain": "^1.0.0", + "@types/jest": "30.0.0", + "@types/node": "26.0.0", + "jest": "30.4.2", + "ts-jest": "29.4.11", + "typescript": "6.0.3" + } + }, "src/packages/core": { "name": "@beyondnet/evolith-core", "version": "1.0.0", @@ -12008,7 +12025,7 @@ }, "src/sdk/cli": { "name": "@beyondnet/evolith-cli", - "version": "1.0.0", + "version": "1.0.1", "license": "MIT", "dependencies": { "@beyondnet/evolith-agent-runtime": "^1.0.0", diff --git a/product/infra/validated-tool-catalog.md b/product/infra/validated-tool-catalog.md index 7cb4d190a..899922a6a 100644 --- a/product/infra/validated-tool-catalog.md +++ b/product/infra/validated-tool-catalog.md @@ -111,15 +111,15 @@ Select architecture pattern: ### 4.3 Architecture Enforcement (Boundary Analyzers) -Static analyzers that enforce module/layer boundaries and cycles per runtime. Core routes `enforce.engine === 'enforcer'` rules to these via the `EnforcerEvaluator` (GT-514); the machine-readable mirror is `src/rulesets/enforcement/enforcer-catalog.json`. Exact version pinning is tracked in GT-519. The **Adapter** column reflects the state of each tool's Core adapter (`src/packages/core-domain/.../enforcement/adapters/`); a catalog entry can exist ahead of its adapter. +Static analyzers that enforce module/layer boundaries and cycles per runtime. Core routes `enforce.engine === 'enforcer'` rules to these via the `EnforcerEvaluator` (GT-514); the machine-readable mirror is `src/rulesets/enforcement/enforcer-catalog.json`. **GT-519:** versions below are pinned EXACT (`x.y.z`, no ranges/wildcards) so per-runtime CI images are reproducible; `enforcer-catalog-doc-parity.spec.ts` fails on any drift between this table and `enforcer-catalog.json`. Renovate keeps the pins current (deploy-gated). The **Adapter** column reflects the state of each tool's Core adapter (`src/packages/core-domain/.../enforcement/adapters/`); a catalog entry can exist ahead of its adapter. | Tool | Version | Runtime | Purpose | Adapter | ADR | |------|---------|---------|---------|---------|-----| -| **dependency-cruiser** | 16.x | node | Module/layer boundary + cycle analysis (TS/JS) | Implemented (GT-515, in progress) | ADR-0002 | -| **NetArchTest** | 1.3.x | dotnet | Layer & dependency-direction rules (.NET) | Implemented (GT-524, in progress; real execution blocked by GT-512) | ADR-0002 | -| **Deptrac** | 2.x | php | Layer boundary enforcement (PHP) | Catalogued, adapter pending (GT-521) | ADR-0002 | -| **import-linter** | 2.x | python | Import contracts, grimp-backed (Python) | Catalogued, adapter pending (GT-521) | ADR-0002 | -| **Conftest** | 0.56.x | iac | OPA/Rego policy checks for IaC/config manifests | Catalogued, adapter pending (GT-521) | ADR-0002 | +| **dependency-cruiser** | 16.10.4 | node | Module/layer boundary + cycle analysis (TS/JS) | Implemented (GT-515, in progress) | ADR-0002 | +| **NetArchTest** | 1.3.2 | dotnet | Layer & dependency-direction rules (.NET) | Implemented (GT-524, in progress; real execution blocked by GT-512) | ADR-0002 | +| **Deptrac** | 2.0.4 | php | Layer boundary enforcement (PHP) | Catalogued, adapter pending (GT-521) | ADR-0002 | +| **import-linter** | 2.1.0 | python | Import contracts, grimp-backed (Python) | Catalogued, adapter pending (GT-521) | ADR-0002 | +| **Conftest** | 0.56.0 | iac | OPA/Rego policy checks for IaC/config manifests | Catalogued, adapter pending (GT-521) | ADR-0002 | --- diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 07db3c2fb..59a074956 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -7354,6 +7354,66 @@ "cd src/sdk/cli && npm run build && npx jest (71 suites, 967/967; edit-hook+enforce 45/45) — `evolith enforce edit` blocks a boundary-violating edit (exit 2) with canonical Violation; conforming edit exit 0; cross-agent VendorHookAdapter registry" ], "dependencyDisposition": "none" + }, + { + "id": "GT-533", + "closedAt": "2026-07-13", + "closureCommit": "baf570f4", + "evidence": [ + "src/packages/core-domain/src/evaluation/evaluation-orchestrator.service.ts", + "src/packages/core-domain/src/evaluation/contracts/quality-evidence.ts", + "src/packages/core-domain/src/evaluation/contracts/evaluation-result.ts", + "src/packages/core-domain/src/evaluation/evaluation-orchestrator.spec.ts" + ], + "validationCommands": [ + "cd src/packages/core-domain && npx tsc && npx jest (95 suites, 966/966) — evaluate() folds ctx.qualitySignals via resolveEvidenceSignals onto EvaluationResult.qualitySignals; WITH evidence ⇒ present signal surfaced, verdict PASS; WITHOUT ⇒ no-evidence signal, evaluation still succeeds. Core stays provider-free (grep-clean)." + ], + "dependencyDisposition": "none" + }, + { + "id": "GT-513", + "closedAt": "2026-07-13", + "closureCommit": "9f027797", + "evidence": [ + "src/packages/contracts/src/index.ts", + "src/packages/contracts/src/schemas/machine-contract-set.ts", + "src/packages/contracts/src/capabilities/capability-contract.parity.spec.ts", + "src/apps/core-api/src/presentation/controllers/capabilities.controller.ts" + ], + "validationCommands": [ + "cd src/packages/contracts && npx tsc -p tsconfig.json && npx jest (2 suites, 13/13) — @beyondnet/evolith-contracts SemVer + CONTRACT_SET_SHA256; parity spec binds package to live buildCapabilityManifest and FAILS on added-engine / single-consumer drift. GET /api/v1/capabilities served by CapabilitiesController (on develop)." + ], + "dependencyDisposition": "none" + }, + { + "id": "GT-535", + "closedAt": "2026-07-13", + "closureCommit": "d450d969", + "evidence": [ + "src/packages/agent-runtime/src/domain/rubrics/structural-review-rubric.ts", + "src/packages/agent-runtime/src/application/structural-review-provider.ts", + "src/packages/agent-runtime/src/application/structural-quality-gate.ts", + "src/packages/agent-runtime/src/adapters/skills/default-skills.ts" + ], + "validationCommands": [ + "cd src/packages/agent-runtime && npx tsc && npx jest (16 suites, 110/110) — rubric ranked by severity; StructuralReviewProvider emits probabilistic Evidence + provenance and registers in the GT-533 registry; evaluateStructuralGate blocks high/critical; GT-424 skill-registry parity green" + ], + "dependencyDisposition": "none" + }, + { + "id": "GT-540", + "closedAt": "2026-07-13", + "closureCommit": "40464149", + "evidence": [ + "src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.ts", + "src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.spec.ts", + "src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts", + "reference/core/control-center/maturity-reports/maturity-assessment.md" + ], + "validationCommands": [ + "cd src/packages/agent-runtime && npx tsc && npx jest (17 suites, 118/118) + cd src/apps/agent-runtime-api && npx nest build && npx jest (5 suites, 67/67) — PgVectorKnowledgeAdapter cosine top-k over rag_chunks with injected embedder+pg seams, ranked KnowledgeChunk + citation; runtime.factory selects pgvector via env, in-memory default preserved" + ], + "dependencyDisposition": "none" } ] } diff --git a/reference/core/control-center/gaps/gap-reference-catalog.es.md b/reference/core/control-center/gaps/gap-reference-catalog.es.md index 7bdeaaf2b..10985b456 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -65,11 +65,12 @@ Este catálogo explica cada gap: problema, propósito, evidencia, criterios de c - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Publicar un paquete versionado `@beyondnet/evolith-contracts` (SemVer + sha256 del set de schemas); agregar `GET /api/v1/capabilities` junto a `ReferenceController`, solo-REST según ADR-0074 (sin GraphQL aquí); agregar tests de paridad de contrato que enlacen el paquete a los endpoints vivos. - **Acceptance criteria:** - - [ ] `GET /api/v1/capabilities` retorna el manifiesto de capabilities versionado. - - [ ] `@beyondnet/evolith-contracts` está versionado (SemVer + sha256) y es consumible por un consumidor externo. - - [ ] Los tests de paridad de contrato fallan ante drift entre el paquete y los endpoints. + - [x] `GET /api/v1/capabilities` retorna el manifiesto de capabilities versionado. _(`CapabilitiesController` + `buildCapabilityManifest` en develop)_ + - [x] `@beyondnet/evolith-contracts` está versionado (SemVer + sha256) y es consumible por un consumidor externo. _(nuevo paquete `src/packages/contracts`; `MACHINE_CONTRACT_SET` + `CONTRACT_SET_SHA256` + `EXPECTED_CAPABILITY_MANIFEST` congelado; agrega el consumidor `external`, cerrando el gap de consumidor único)_ + - [x] Los tests de paridad de contrato fallan ante drift entre el paquete y los endpoints. _(el spec de paridad enlaza el paquete al productor vivo `buildCapabilityManifest`; casos dedicados prueban que FALLA ante un engine añadido y ante regresión a un solo consumidor + guard sha256 por schema)_ - **Dependencies:** GT-511. -- **Status:** `PENDING` +- **Cierre (2026-07-13, Ola 3, commit `9f027797`):** Solo-REST según ADR-0074. El endpoint `/api/v1/capabilities` + manifiesto de dominio ya estaban en develop (ola previa); esto cierra el paquete frontera SemVer + el guard de paridad que falla ante drift. contracts 13/13; límite hexagonal intacto (el runtime de contracts no importa core-domain; solo su test enlaza el productor). +- **Status:** `DONE` #### GT-514 @@ -160,11 +161,12 @@ Este catálogo explica cada gap: problema, propósito, evidencia, criterios de c - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Agregar un exportador SARIF de `EvaluationResult` (`evolith evaluate --format sarif`); agregar un gate de drift en CI sobre la Checks API de GitHub/GitLab (GitHub App con `checks:write` + GHAS en repos privados; fallback = comentario de PR + exit code); emitir el manifiesto de enforcer-evidence (EVD-01..03 vía el `EvidenceNormalizer` unificado); agregar un flujo de waiver (request/approve/version/expire) para `waiverRef`; enriquecer owner vía CODEOWNERS. - **Acceptance criteria:** - - [ ] Un PR que viola un ADR es bloqueado con un comentario que cita el ADR + owner. - - [ ] `evolith evaluate --format sarif` emite SARIF válido; el manifiesto de evidencia lleva EVD-01..03. - - [ ] Existe una ruta de waiver (request/approve/version/expire) para `waiverRef`. + - [~] Un PR que viola un ADR es bloqueado con un comentario que cita el ADR + owner. _(Ola 6 `41135566`: `evaluateDriftGate` bloquea por violaciones error, cita ADR id + owner desde CODEOWNERS, arma cuerpo de PR-comment + exit code vía `evolith evaluate --format drift`. **El publish live a la Checks API (GitHub App + `checks:write`/GHAS) es deploy-gated** tras `IChecksPublisher`; el `PrCommentFallbackPublisher` mandatorio está cableado)_ + - [x] `evolith evaluate --format sarif` emite SARIF válido; el manifiesto de evidencia lleva EVD-01..03. _(reusa el `sarif-exporter` existente; `evolith evaluate --evidence ` emite el manifiesto vía `buildEnforcerEvidence`)_ + - [~] Existe una ruta de waiver (request/approve/version/expire) para `waiverRef`. _(máquina de estados determinista `domain/waiver.ts` + `applyWaivers` con auditoría; **falta:** store durable + subcomando CLI `waiver`)_ - **Dependencies:** GT-514, GT-516. -- **Status:** `PENDING` +- **Progreso (2026-07-13, Ola 6, commit `41135566`):** En el seam de core-domain (reusa `sarif-exporter`/`EvidenceNormalizer`, el Core queda puro). core-domain 1018/1018 + CLI evaluate 6/6. Queda `IN-PROGRESS`: publish live Checks API + store durable de waivers + subcomando CLI. +- **Status:** `IN-PROGRESS` #### GT-519 @@ -420,11 +422,12 @@ Este catálogo explica cada gap: problema, propósito, evidencia, criterios de c - **Criticality:** P1 · **Complexity:** L - **Proposed fix:** Puerto de salida propiedad de la orquestación; el Core importa solo `Evidence` (inline, como `OverlayFileSystem`, ADR-0080); el Core nunca ejecuta proveedores; registro declarativo opt-in por tenant; `provenance` + `determinism` obligatorios. - **Acceptance criteria:** - - [ ] `core-domain` importa solo `Evidence` (grep limpio de imports de proveedor/adaptador). - - [ ] Los proveedores corren en orquestación; el Core evalúa `Evidence[]` recibida; evidencia ausente ⇒ `no-evidence`, no un fallo. - - [ ] El registro por tenant habilita/deshabilita proveedores de forma declarativa. + - [x] `core-domain` importa solo `Evidence` (grep limpio de imports de proveedor/adaptador). _(`quality-evidence.ts` canónico, cero imports; inline vía `EvaluationContext.qualitySignals?`)_ + - [x] Los proveedores corren en orquestación; el Core evalúa `Evidence[]` recibida; evidencia ausente ⇒ `no-evidence`, no un fallo. _(Ola 3 `baf570f4`: el orquestador pliega `ctx.qualitySignals` vía `foldQualitySignals`→`resolveEvidenceSignals` sobre `EvaluationResult.qualitySignals`; ausente ⇒ señal `no-evidence` advisory, el verdict nunca falla por evidencia faltante)_ + - [x] El registro por tenant habilita/deshabilita proveedores de forma declarativa. _(`TenantQualitySignalRegistry` en agent-runtime; aislado de fallos, re-normalizado por el ACL canónico)_ - **Dependencies:** ADR-0111; compone con GT-530. -- **Status:** `PENDING` +- **Cierre (2026-07-13, Olas 1+3):** Ola 1 (`d56ba32c`) fundó la costura (Evidence/Provenance canónicos + puerto `IQualitySignalProvider` en orquestación + registro por tenant); Ola 3 (`baf570f4`) la cableó VIVA en el pipeline de evaluación, cerrando el loop ADR-0104. Grep limpio de acoplamiento Core→proveedor; core-domain 966/966 + agent-runtime 98/98. +- **Status:** `DONE` #### GT-534 @@ -458,10 +461,11 @@ Este catálogo explica cada gap: problema, propósito, evidencia, criterios de c - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Codificar los siete estándares + jerarquía de severidad como skill y rúbrica de gate; emitir `Evidence` con `determinism: 'probabilistic'`. - **Acceptance criteria:** - - [ ] El agente produce hallazgos estructurales ordenados por la jerarquía de severidad de la rúbrica. - - [ ] El gate puede tratar las regresiones estructurales como bloqueantes; atribución respetada. + - [x] El agente produce hallazgos estructurales ordenados por la jerarquía de severidad de la rúbrica. _(`StructuralReviewProvider` corre el puerto probabilístico `IStructuralReviewer`, `rankStructuralFindings` ordena por severidad, emite una `Evidence` canónica con `determinism:'probabilistic'` + provenance)_ + - [x] El gate puede tratar las regresiones estructurales como bloqueantes; atribución respetada. _(`evaluateStructuralGate` — mapeo determinista severidad→decisión; `DEFAULT_STRUCTURAL_GATE_POLICY` bloquea high/critical; `RUBRIC_ATTRIBUTION` acredita la metodología comunitaria, reexpresada en nuestras palabras)_ - **Dependencies:** GT-533. -- **Status:** `PENDING` +- **Cierre (2026-07-13, Ola 4, commit `d450d969`):** Rúbrica como datos de dominio puros (siete estándares sobre una jerarquía `info` (`score = 1 - distance`), mapea filas → `KnowledgeChunk` rankeado con citación; filtros de metadata en WHERE parametrizado)_ + - [x] La fila Knowledge/RAG de `maturity-assessment.md` se actualiza de "Not implemented" al adaptador entregado. - **Dependencies:** GT-538; GT-539; ADR-0090; ADR-0112 (plataforma: mismo modelo+dim que el write-side). -- **Status:** `PENDING` +- **Cierre (2026-07-13, Ola 6, commit `40464149`):** El read-side RAG es real — adaptador `IKnowledgePort` de producción que fundamenta las recomendaciones cosine-rankeando el corpus GT-538 vía el embedder GT-539, totalmente hexagonal (cliente pg y embedder inyectados; sin `pg` en build; elección de modelo en el borde de wiring). `runtime.factory.ts` lo selecciona vía `AGENT_RUNTIME_KNOWLEDGE_MODE=pgvector` / `EVOLITH_RAG_PG_URL` (falla-fuerte ante misconfig; `InMemoryKnowledgeAdapter` sigue siendo el default). agent-runtime 118/118 + agent-runtime-api 67/67. _Deploy-gated:_ la corrida live contra Postgres + sidecar Qwen3 (no es criterio de aceptación). +- **Status:** `DONE` #### GT-541 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index d92b5ce0d..128188747 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -46,11 +46,12 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P0 · **Complexity:** L - **Proposed fix:** PA-01 restore (`npm ci` / `dotnet restore+build` / `pip install`+grimp / `composer install`); PA-02 per-project Nx scoping; PA-03 EvaluationResult cache keyed by commit-SHA + changed-files-only; PA-04 shell-out sandbox (no egress, no secrets, ulimits/cgroups, binary allowlist); PA-05 toolchain resolved from the `evolith.yaml` manifest. - **Acceptance criteria:** - - [ ] Analyzers run against a **restored**, project-scoped checkout inside the sandbox. _(PA-06 done: `executeRestorePlan` runs the plan fail-fast + `provisionEvaluationEnvironment` composes scope→cache→restore, and the real `NodeProcessRunner` (infra-providers) executes it; only the real repo fetch/checkout integration remains)_ - - [ ] Sandbox denies egress + secret access and enforces ulimits/cgroups + a binary allowlist. _(done: allowlist + secret denial + fail-closed wrapper (policy) and now the `NodeProcessRunner` does NOT inherit parent-env secrets —curated passthrough— at runtime; OS-level egress/cgroup/namespace enforcement is deploy-gated in a locked-down container)_ + - [~] Analyzers run against a **restored**, project-scoped checkout inside the sandbox. _(Wave 4 `20f704b6` PA-07: `materializeAndProvisionEnvironment` composes fetch→resolve-toolchain-from-evolith.yaml→materialize-to-workdir→`executeRestorePlan` (npm ci / dotnet restore+build / pip install) via the sandbox-wrapped `NodeProcessRunner`→exposes Nx-project-scoped `analysisPaths`; ports `IRepositorySourceReader` + `IWorkspaceMaterializer` with `NodeWorkspaceMaterializer`; unit-tested with stubs. **Deploy-gated:** the real GitHubRepositorySourceReader network fetch needs `tar` + network in the runtime image)_ + - [~] Sandbox denies egress + secret access and enforces ulimits/cgroups + a binary allowlist. _(app-level DONE: allowlist + secret denial + fail-closed `SandboxPolicy`/`enforceSandboxPolicy`/`SandboxedProcessRunner`, curated env passthrough. **Deploy-gated:** OS-level egress/cgroup/namespace/seccomp isolation needs a locked-down container)_ - [x] Re-evaluating an unchanged commit hits the cache (SHA + changed-files scope). - **Dependencies:** GT-511. -- **Status:** `PENDING` +- **Progress (2026-07-13, Wave 4, commit `20f704b6`):** The full domain-wiring seam (PA-03/05/06/07) is now in place — fetch→materialize→restore→scoped-analysis, cache-keyed by SHA+changed-files, toolchain resolved from `evolith.yaml`. Unblocks GT-515/GT-524 at the **code** level (their 0-FP gates still need a real toolchain execution). core-domain 986/986 + infra-providers 109/109 green. Remaining is uniformly **deploy-gated** (real network fetch adapter + OS-level sandbox container) — kept `IN-PROGRESS`. +- **Status:** `IN-PROGRESS` #### GT-513 @@ -65,11 +66,12 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Publish a versioned `@beyondnet/evolith-contracts` package (SemVer + sha256 of the schema set); add `GET /api/v1/capabilities` next to `ReferenceController`, REST-only per ADR-0074 (no GraphQL here); add contract-parity tests binding the package to the live endpoints. - **Acceptance criteria:** - - [ ] `GET /api/v1/capabilities` returns the versioned capability manifest. - - [ ] `@beyondnet/evolith-contracts` is versioned (SemVer + sha256) and consumable by an external consumer. - - [ ] Contract-parity tests fail on drift between the package and the endpoints. + - [x] `GET /api/v1/capabilities` returns the versioned capability manifest. _(`CapabilitiesController` + `buildCapabilityManifest` on develop)_ + - [x] `@beyondnet/evolith-contracts` is versioned (SemVer + sha256) and consumable by an external consumer. _(new `src/packages/contracts` package; `MACHINE_CONTRACT_SET` + `CONTRACT_SET_SHA256` + frozen `EXPECTED_CAPABILITY_MANIFEST`; adds a first-class `external` consumer, closing the single-consumer gap)_ + - [x] Contract-parity tests fail on drift between the package and the endpoints. _(parity spec binds the package to the live `buildCapabilityManifest` producer; dedicated cases prove it FAILS on an added engine and on a single-consumer regression + per-schema sha256 guard)_ - **Dependencies:** GT-511. -- **Status:** `PENDING` +- **Closure (2026-07-13, Wave 3, commit `9f027797`):** REST-only per ADR-0074. The `/api/v1/capabilities` endpoint + domain manifest were delivered by prior-wave work already on develop; this closes the SemVer-boundary package + the drift-failing parity guard. contracts 13/13 green; hexagonal boundary intact (the contracts runtime does not import core-domain; only its test binds the producer). +- **Status:** `DONE` #### GT-514 @@ -122,11 +124,12 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** L - **Proposed fix:** Add `enforce:` to `ruleset-standard.schema.json` (`engine, tool, toolRuleId, config|configRef, severityMap, runtime, mode`); implement PolicyCompiler + `evolith enforce compile` (nest-commander, `src/sdk/cli/src/commands/enforce/`) with a per-rule fallback for uncompilable rules; populate the `enforce` block in ADR-0002; add a round-trip test with 0 FP. - **Acceptance criteria:** - - [ ] ADR-0002 rules compile, run, and normalize to `Violation`. + - [~] ADR-0002 rules compile, run, and normalize to `Violation`. _(Wave 5 `806e3337`: all seven HXA rules are now enforcer-routed — HXA-01/02/04/05/07 compile to dependency-cruiser checks, HXA-03/06 take the documented per-rule native fallback; compile→normalize→`Violation` runs end-to-end through the GT-515 DependencyCruiserAdapter over a StubProcessRunner. **The real cross-runtime tool spawn on a restored workspace is GT-512-gated**)_ - [x] Uncompilable rules take a documented per-rule fallback (no wholesale failure). - - [ ] Round-trip test passes with 0 false positives. + - [~] Round-trip test passes with 0 false positives. _(round-trip spec green on FIXTURE corpus: 0 FP on a clean corpus, full round-trip of every compiled tool-rule-id on a dirty corpus, no spurious findings on malformed reports. The 0-FP gate on a REAL .NET/TS corpus needs a live tool run — GT-512-gated)_ - **Dependencies:** GT-514. -- **Status:** `PENDING` +- **Progress (2026-07-13, Wave 5, commit `806e3337`):** The `enforce:` schema block, PolicyCompiler (per-rule fallback), and `evolith enforce compile` were already on develop; this pilots ADR-0002 (HXA-01..07 enforce blocks: 5 compiled / 2 fallback) and adds the compile→normalize→`Violation` round-trip on fixtures (0 FP). Output feeds GT-518's gate. core-domain 990/990 + CLI enforce 20/20 green. Kept `IN-PROGRESS`: the real cross-runtime execution + real-corpus 0-FP is GT-512-gated (the same sandbox that unblocks GT-515/524). +- **Status:** `IN-PROGRESS` #### GT-517 @@ -160,11 +163,12 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Add a SARIF exporter of `EvaluationResult` (`evolith evaluate --format sarif`); add a CI drift-gate on the GitHub/GitLab Checks API (GitHub App with `checks:write` + GHAS in private repos; fallback = PR comment + exit code); emit the enforcer-evidence manifest (EVD-01..03 via the unified `EvidenceNormalizer`); add a waiver flow (request/approve/version/expire) for `waiverRef`; enrich owner via CODEOWNERS. - **Acceptance criteria:** - - [ ] A PR that violates an ADR is blocked with a comment citing the ADR + owner. - - [ ] `evolith evaluate --format sarif` emits valid SARIF; the evidence manifest carries EVD-01..03. - - [ ] A waiver path (request/approve/version/expire) exists for `waiverRef`. + - [~] A PR that violates an ADR is blocked with a comment citing the ADR + owner. _(Wave 6 `41135566`: `evaluateDriftGate` blocks on retained error violations, cites the ADR id + owner resolved from CODEOWNERS (`domain/codeowners.ts`), renders a PR-comment body + non-zero exit code via `evolith evaluate --format drift`. **The live GitHub/GitLab Checks-API publish (GitHub App + `checks:write`/GHAS) is deploy-gated** behind `IChecksPublisher`; the mandated `PrCommentFallbackPublisher` is wired)_ + - [x] `evolith evaluate --format sarif` emits valid SARIF; the evidence manifest carries EVD-01..03. _(reuses the existing core-domain `sarif-exporter` via a shared `evaluationResultToViolations`; `evolith evaluate --evidence ` emits the enforcer-evidence manifest via `buildEnforcerEvidence`)_ + - [~] A waiver path (request/approve/version/expire) exists for `waiverRef`. _(deterministic `domain/waiver.ts` state machine + `applyWaivers` suppression with audit trail — valid approved waiver suppresses a finding until expiry, expired does not; `IWaiverStore` seam. **Remaining:** durable (fs/db) store + dedicated CLI `waiver` subcommand)_ - **Dependencies:** GT-514, GT-516. -- **Status:** `PENDING` +- **Progress (2026-07-13, Wave 6, commit `41135566`):** Landed at the core-domain seam (reuses `sarif-exporter`/`EvidenceNormalizer`, Core stays pure — live Checks API behind a port). core-domain 1018/1018 + CLI evaluate 6/6 green. Kept `IN-PROGRESS`: the live Checks-API publish, a durable waiver store, and the CLI waiver subcommand are the deploy/polish remainders. +- **Status:** `IN-PROGRESS` #### GT-519 @@ -179,11 +183,12 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P2 · **Complexity:** M - **Proposed fix:** Register the Composite in `evaluate` / the MCP `architecture` tool / `POST /api/v1/evaluate`; pin exact tool versions (`validated-tool-catalog.md` ↔ `enforcer-catalog.json`); build per-runtime composable CI images (not one monolith) with vuln scan + Renovate; emit enforcer OTel metrics (duration, failure rate, timeouts, violation counts). - **Acceptance criteria:** - - [ ] Parity tests are green across CLI/MCP/REST for the enforcer path. - - [ ] Tool versions are pinned and reproducible; CI images are per-runtime and vuln-scanned. + - [x] Parity tests are green across CLI/MCP/REST for the enforcer path. _(Wave 4 `f8310fac`: **latent bug fixed** — no surface DI factory injected a `processRunner`, so `RulesetValidatorService` never wrapped its strategy with the Composite and the enforcer path was unreachable on all three surfaces. `NodeProcessRunner` is now injected in core-api `core-domain.module.ts`, cli `app.module.ts`, mcp-server `domain.module.ts`; `enforcer-surface-parity.spec.ts` asserts byte-identical results + a divergence guard + a source-level anti-drift guard)_ + - [~] Tool versions are pinned and reproducible; CI images are per-runtime and vuln-scanned. _(code part DONE: exact x.y.z pins in `enforcer-catalog.json` ↔ `validated-tool-catalog.md` §4.3 + `enforcer-catalog-doc-parity.spec.ts` fails on any drift/non-exact pin. **Deploy-gated:** per-runtime composable CI images + vuln-scan + Renovate are ops/pipeline concerns)_ - [x] Enforcer runs emit OTel metrics (duration, failure rate, timeouts, violation counts). - **Dependencies:** GT-514. -- **Status:** `PENDING` +- **Progress (2026-07-13, Wave 4, commit `f8310fac`):** Criteria 1 & 3 met; criterion 2 code-complete, CI-image build deploy-gated. Kept `IN-PROGRESS` until the per-runtime vuln-scanned CI images + Renovate pin-maintenance land (ops). core-domain enforcement 144/144 + mcp-server 324/324 green. +- **Status:** `IN-PROGRESS` #### GT-520 @@ -198,11 +203,12 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Add Streamable HTTP + OAuth bearer in `mcp-server/main.ts`; add per-consumer ABAC in `tool-registry` (`abac-mcp-tool-access.rego`) and audit every `tools/call`; expose resources `evolith://capabilities` and `evolith://contracts`. - **Acceptance criteria:** - - [ ] Remote MCP requires OAuth (Streamable HTTP bearer). + - [x] Remote MCP requires OAuth (Streamable HTTP bearer). _(Wave 3 `87645d26`: IdP-agnostic OAuth 2.1 resource-server validator — JWKS RS/PS/ES or shared HS, iss/aud/exp/nbf + clock-skew; wired into the Streamable HTTP auth path; missing/invalid/expired/spoofed ⇒ 401; the cryptographically-verified identity feeds per-identity ABAC. mcp-server 324/324)_ - [x] Every `tools/call` is ABAC-checked per identity and audited. - [x] `evolith://capabilities` and `evolith://contracts` resources are served. -- **Dependencies:** GT-513, and the identity decision (tracked as EAG-01 in the Tracker board). -- **Status:** `PENDING` +- **Dependencies:** GT-513 (DONE), and the identity decision (tracked as EAG-01 in the Tracker board). +- **Progress (2026-07-13, Wave 3, commit `87645d26`):** All three acceptance criteria are met in code (OAuth mechanism implemented, wired, and tested). **Kept `IN-PROGRESS`** only because closure depends on **EAG-01** — the org's concrete IdP selection (which OIDC provider, shared vs per-tenant, audience model). The code is IdP-agnostic and needs no further change: it activates via `EVOLITH_MCP_OAUTH_ISSUER` / `_JWKS_URI` / `_SECRET` / `_AUDIENCE`. Flip to DONE once EAG-01 is decided and the issuer/JWKS/audience are wired in a deployment. +- **Status:** `IN-PROGRESS` #### GT-521 @@ -421,11 +427,11 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Proposed fix:** Driven port owned by orchestration; Core imports only `Evidence` (inline, like `OverlayFileSystem`, ADR-0080); Core never executes providers; declarative opt-in registry per tenant; mandatory `provenance` + `determinism`. - **Acceptance criteria:** - [x] `core-domain` imports only `Evidence` (grep-clean of provider/adapter imports). _(canonical `quality-evidence.ts` — zero imports; consumed inline via `EvaluationContext.qualitySignals?`)_ - - [~] Providers run in orchestration; Core evaluates received `Evidence[]`; missing evidence ⇒ `no-evidence`, not a failure. _(the Core-side rule `resolveEvidenceSignals` + `no-evidence` semantics exist and are tested, but are **not yet wired into the evaluation pipeline** — follow-on below)_ + - [x] Providers run in orchestration; Core evaluates received `Evidence[]`; missing evidence ⇒ `no-evidence`, not a failure. _(Wave 3 `baf570f4`: the orchestrator's `evaluate()` folds `ctx.qualitySignals` via `foldQualitySignals`→`resolveEvidenceSignals`; received `Evidence[]` surfaces on `EvaluationResult.qualitySignals`; empty/absent ⇒ a `no-evidence` signal, advisory only — verdict never fails on missing evidence)_ - [x] Per-tenant registry enables/disables providers declaratively. _(`TenantQualitySignalRegistry` in agent-runtime; fault-isolated, re-normalized through the canonical ACL)_ - **Dependencies:** ADR-0111; composes with GT-530. -- **Progress (2026-07-13, Wave 1, commit `d56ba32c`):** Seam foundation landed — canonical `Evidence`/`Provenance`/`EvidenceFinding` + `Determinism` in `core-domain` (mandatory provenance enforced by `normalizeEvidence`), driven `IQualitySignalProvider` port owned by the orchestration layer (Core never executes providers), and the per-tenant registry. Grep-clean of Core→provider coupling; core-domain 960/960 + agent-runtime 98/98 green. **Follow-on to close (→ DONE):** (1) invoke `resolveEvidenceSignals` inside the evaluation pipeline so received `Evidence[]` actually influences signals/verdict; (2) a runtime adapter that runs `TenantQualitySignalRegistry.collect()` and populates `EvaluationContext.qualitySignals` inline — only then is the ADR-0104 conformance loop closed end-to-end. GT-534 (Lighthouse) is the first concrete provider behind this port. -- **Status:** `IN-PROGRESS` +- **Closure (2026-07-13, Waves 1+3):** _Wave 1 (`d56ba32c`)_ landed the seam foundation — canonical `Evidence`/`Provenance`/`EvidenceFinding` + `Determinism` in `core-domain` (provenance enforced by `normalizeEvidence`), the driven `IQualitySignalProvider` port owned by orchestration (Core never executes providers), and the per-tenant `TenantQualitySignalRegistry`. _Wave 3 (`baf570f4`)_ wired it LIVE: the evaluation orchestrator folds inline `EvaluationContext.qualitySignals` through `resolveEvidenceSignals` onto `EvaluationResult.qualitySignals`, closing the ADR-0104 loop (Core reads the received `Evidence[]`; missing ⇒ advisory `no-evidence`, verdict unaffected). Grep-clean of Core→provider coupling throughout; core-domain 966/966 + agent-runtime 98/98 green. GT-534 (Lighthouse) is the first concrete provider behind the port. _Optional consumer-side follow-on (not required for this gap):_ a runtime service that calls `TenantQualitySignalRegistry.collect()` to populate the context when a live provider is deployed. +- **Status:** `DONE` #### GT-534 @@ -459,10 +465,11 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P1 · **Complexity:** M - **Proposed fix:** Encode the seven standards + severity hierarchy as a skill and gate rubric; emit `Evidence` with `determinism: 'probabilistic'`. - **Acceptance criteria:** - - [ ] The agent produces structural findings ranked by the rubric's severity hierarchy. - - [ ] The gate can treat structural regressions as blocking; attribution respected. + - [x] The agent produces structural findings ranked by the rubric's severity hierarchy. _(`StructuralReviewProvider` runs the probabilistic `IStructuralReviewer` port, `rankStructuralFindings` orders by the severity hierarchy, emits one canonical Evidence with `determinism:'probabilistic'` + provenance)_ + - [x] The gate can treat structural regressions as blocking; attribution respected. _(`evaluateStructuralGate` — deterministic severity→decision; `DEFAULT_STRUCTURAL_GATE_POLICY` blocks high/critical; `RUBRIC_ATTRIBUTION` credits the community structural-review methodology, re-expressed in our own words)_ - **Dependencies:** GT-533. -- **Status:** `PENDING` +- **Closure (2026-07-13, Wave 4, commit `d450d969`):** Rubric encoded as PURE domain data (seven structural standards over one `info` (`score = 1 - distance`), maps rows → ranked `KnowledgeChunk` with citation metadata; metadata filters compiled to parameterized WHERE)_ + - [x] The `maturity-assessment.md` Knowledge/RAG row is updated from "Not implemented" to the delivered adapter. - **Dependencies:** GT-538; GT-539; ADR-0090; ADR-0112 (platform: same model+dim as write-side). -- **Status:** `PENDING` +- **Closure (2026-07-13, Wave 6, commit `40464149`):** The RAG read-side is real — a production `IKnowledgePort` adapter that grounds agent recommendations by cosine-ranking the GT-538 corpus through the GT-539 embedder, fully hexagonal (both the pg client and the embedder are injected seams; no compile-time `pg`; model choice at the wiring edge). `runtime.factory.ts` selects it via `AGENT_RUNTIME_KNOWLEDGE_MODE=pgvector` / `EVOLITH_RAG_PG_URL` (fails loud on misconfig; `InMemoryKnowledgeAdapter` stays the explicit default). agent-runtime 118/118 + agent-runtime-api 67/67 green. _Deploy-gated:_ the live retrieval run against a running Postgres + Qwen3 sidecar (not an acceptance criterion). +- **Status:** `DONE` #### GT-541 diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index 5defd56d2..79562a60b 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -16,7 +16,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-510`](./gap-reference-catalog.es.md#gt-510) | **16 gaps DONE no tienen registro de cierre en `gap-closure-evidence.json`.** El registro tiene 417 registros de cierre pero se requieren 433 (425 `GT-*` DONE + 8 `MT-*`), así que `08-validate-tracking.mjs` y `09-reconcile-maturity.mjs --check` fallan. Destapado cuando [`GT-476`](./gap-reference-catalog.es.md#gt-476) re-apuntó los guards de tracking/madurez a las rutas reales — las rutas obsoletas lo ocultaban. Afectados: GT-424, GT-436, GT-440, GT-449, GT-450, GT-452, GT-466, GT-467, GT-468, GT-469, GT-470, GT-471, GT-472, GT-473, GT-474, GT-484. Fix: añadir el registro de cierre real (commit + verificación) de cada uno, o revertir los que no estén realmente DONE — no fabricar evidencia. **COMPLETADO:** el registro ya tiene 439 records (≥ requerido) y AMBOS guards pasan — `08-validate-tracking.mjs` "Validated 523 gaps and 439 closure records" y `09-reconcile-maturity.mjs --check` "✅ Maturity reconciliation matches" (este último completado por el sync de `asOf` de madurez `0d45a08e`). Cada gap DONE tiene registro (08 lo exige exactamente). | `Governance` | Cross | P2 | M | `COMPLETADO` | | [`GT-511`](./gap-reference-catalog.es.md#gt-511) | (EAG-02 · integración A/B) **Modelo único normalizado de evidencia/Violation.** Un modelo canónico `Violation` (`ruleId, tool, file, line?, column?, severity, message, adrRef, owner?, fingerprint, frozen`) + un contrato de evidencia contra `src/rulesets/evidence/evidence-manifest.rules.json` (EVD-01..04); unifica el normalizador OSS (A) y el `EvidenceNormalizer` (B). Hoy el único motor es OPA-WASM + 12 handlers nativos (`native-evaluator.ts`), no hay adaptador de ingesta OSS, y `RuleExecutionRef.engine` es un enum Ajv estricto. Fix: `violation.ts` en core-domain (fingerprint normalizado SIN message + path normalizado), mapear a `GapFinding`/`RiskFinding`, `violation.schema.json` + `enforcer-evidence.schema.json`, agregar el engine `'enforcer'` con una estrategia de versión/tolerancia del enum. **COMPLETADO (`d769c97b`):** `violation.ts` (modelo Violation + mapas de severidad invertibles + fingerprint sobre path normalizado excluyendo `message` + Violation⇄GapFinding/RiskFinding + `EnforcerEvidenceManifest`/`buildEnforcerEvidence` cubriendo EVD-01..04); `violation.schema.json` + `enforcer-evidence.schema.json` (ajv-válidos, incl. caso negativo EVD-02); `RuleExecutionRef.engine` ampliado a un vocabulario abierto `RuleEngine` + `'enforcer'` (`isKnownEngine`/`normalizeEngine`). Verificado: core-domain 752/752 (+17), tsc limpio, ajv válido. | `core-domain` | Cross | P0 | M | `COMPLETADO` | | [`GT-512`](./gap-reference-catalog.es.md#gt-512) | (EAG-04 · integración A/B) **Aprovisionamiento del entorno de evaluación (restore/scoping/cache/sandbox).** Hacer los analizadores de fuente ejecutables y seguros. `GitHubRepositorySourceReader` entrega un tarball de TEXTO sin dependencias → dependency-cruiser/import-linter dan falsos negativos; Core y satélites son monorepos Nx. Fix: PA-01 restore (`npm ci`/`dotnet restore+build`/`pip install`+grimp/`composer install`); PA-02 scoping por proyecto en Nx; PA-03 cache de EvaluationResult por commit-SHA + solo-archivos-cambiados; PA-04 sandbox de shell-out (sin egress, sin secretos, ulimits/cgroups, allowlist de binarios); PA-05 toolchain según el manifiesto `evolith.yaml`. **EN-PROGRESO (`970a0da6`):** aterrizó la porción pura verificable en `provisioning.ts` — PA-01 `buildRestorePlan`, PA-02 `resolveProjectScope` (Nx afectados), PA-03 `computeEvaluationCacheKey`+`IEvaluationCache` (AC3 hecho — commit+scope sin cambios pega al cache), PA-04 `SandboxPolicy`+`enforceSandboxPolicy`+`SandboxedProcessRunner` (fail-closed: binario no-allowlisted / env-secreto rechazado antes del runner interno; endurece el `IProcessRunner` de GT-514), PA-05 `resolveRuntimeFromManifest`. Verificado: core-domain 838/838 (+16), tsc limpio. **+2026-07-12 (PA-06 + runner real):** `executeRestorePlan` (corre el plan fail-fast en el cwd del checkout) + `provisionEvaluationEnvironment` (compone scope→cache→restore) en core-domain; y el **`NodeProcessRunner`** real en `@beyondnet/evolith-infra-providers` (`execFile` sin shell, sin heredar secretos —passthrough curado PATH/HOME/locale, nunca TOKEN/SECRET—, timeout+kill, confinado a cwd) — el inner-runner que envuelve `SandboxedProcessRunner`; puerto expuesto en la API pública de core-domain. Verificado: core-domain 893/893, infra-providers 67/67, tsc limpio. **Bloqueado (solo infra-OS):** el enforcement OS-level de egress/cgroups/namespaces (requiere contenedor aislado en el deploy) + la integración de traer el checkout real del repo. | `Evolith Core` | Cross | P0 | L | `EN-PROGRESO` | -| [`GT-513`](./gap-reference-catalog.es.md#gt-513) | (EAG-06 · integración A/B) **API estable + manifiesto de capabilities.** La puerta de entrada para consumidores externos. `evolith-machine-contracts.json` lista solo `evolith_tracker` en `supportedConsumers` y no existe `/capabilities`. Fix: un paquete versionado `@beyondnet/evolith-contracts` (SemVer + sha256); `GET /api/v1/capabilities` junto a `ReferenceController` (solo-REST según ADR-0074, sin GraphQL aquí); tests de paridad de contrato. **EN-PROGRESO (`6b4bebf4`, ola paralela):** aterrizó el manifiesto versionado + guard de drift en core-domain — `capabilities/capabilities-manifest.ts` `buildCapabilityManifest` (kinds desde `EvaluationResult.results`, engines desde `KNOWN_RULE_ENGINES` incl. `enforcer`, `supportedConsumers` agrega `external`, `version` SemVer, `sha256` del JSON canónico) + guard de paridad `capabilityManifestFingerprint` + guards de exhaustividad en compile-time. Verificado: core-domain 822/822 (+15), tsc limpio. **Bloqueado:** el endpoint vivo `GET /api/v1/capabilities` en core-api + el paquete publicado `@beyondnet/evolith-contracts` (wiring de ficheros/módulos compartidos). **+ola paralela (`d144736e`):** aterrizó el endpoint vivo `GET /api/v1/capabilities` (`CapabilitiesController` → `buildCapabilityManifest`, envelope ADR-0073). Verificado 2/2 + reference sin regresión. Resta el paquete publicado `@beyondnet/evolith-contracts`. | `Core API` | Cross | P1 | M | `EN-PROGRESO` | +| [`GT-513`](./gap-reference-catalog.es.md#gt-513) | (EAG-06 · integración A/B) **API estable + manifiesto de capabilities.** La puerta de entrada para consumidores externos. `evolith-machine-contracts.json` lista solo `evolith_tracker` en `supportedConsumers` y no existe `/capabilities`. Fix: un paquete versionado `@beyondnet/evolith-contracts` (SemVer + sha256); `GET /api/v1/capabilities` junto a `ReferenceController` (solo-REST según ADR-0074, sin GraphQL aquí); tests de paridad de contrato. **EN-PROGRESO (`6b4bebf4`, ola paralela):** aterrizó el manifiesto versionado + guard de drift en core-domain — `capabilities/capabilities-manifest.ts` `buildCapabilityManifest` (kinds desde `EvaluationResult.results`, engines desde `KNOWN_RULE_ENGINES` incl. `enforcer`, `supportedConsumers` agrega `external`, `version` SemVer, `sha256` del JSON canónico) + guard de paridad `capabilityManifestFingerprint` + guards de exhaustividad en compile-time. Verificado: core-domain 822/822 (+15), tsc limpio. **Bloqueado:** el endpoint vivo `GET /api/v1/capabilities` en core-api + el paquete publicado `@beyondnet/evolith-contracts` (wiring de ficheros/módulos compartidos). **+ola paralela (`d144736e`):** aterrizó el endpoint vivo `GET /api/v1/capabilities` (`CapabilitiesController` → `buildCapabilityManifest`, envelope ADR-0073). Verificado 2/2 + reference sin regresión. Resta el paquete publicado `@beyondnet/evolith-contracts`. | `Core API` | Cross | P1 | M | `COMPLETADO` | | [`GT-514`](./gap-reference-catalog.es.md#gt-514) | (EAG-08 · integración A/B) **`IEnforcerAdapter` + `EnforcerEvaluator` + Composite + catálogo.** La costura de orquestación de enforcers. `rule-evaluation-engine.ts`/`RulesetValidatorService` usan un único `this.strategy` (Native). Fix: `IEnforcerAdapter.analyze(ctx)→Violation[]` con una unión `runtime`; `EnforcerEvaluator` (un `IRuleEvaluatorStrategy`) filtrando `enforce.engine==='enforcer'`; `CompositeRuleEvaluator` preservando el default Native; `ShellEnforcerAdapter` + `IProcessRunner`; `enforcer-catalog.json` alineado con `product/infra/validated-tool-catalog.md`. **COMPLETADO (`30aee332`):** costura `enforcement/` — `IEnforcerAdapter.analyze→Violation[]` + puerto `IProcessRunner` (endurecido por GT-512) + `StubProcessRunner` (no se envía runner sin sandbox por defecto); `ShellEnforcerAdapter` reutilizable (buildSpec+parse); `EnforcerEvaluator` (filtra `enforce.engine==='enforcer'`, correlaciona por `toolRuleId`, frozen⇒pass, sin-adapter/crash⇒skip); `CompositeRuleEvaluator` entra en el slot `strategy` inyectable del engine sin cambiarlo; `NormalizedRule.enforce?`; `enforcer-catalog.json` espeja el nuevo §4.3 del catálogo (EN+ES). Verificado: core-domain 762/762 (+10), tsc limpio. | `core-domain` | Cross | P1 | M | `COMPLETADO` | | [`GT-515`](./gap-reference-catalog.es.md#gt-515) | (EAG-09 · integración A/B) **Adaptador dependency-cruiser + ingester SARIF.** Primer adaptador de analizador de fuente (Node/TS, donde vive el código de Core). depcruise no tiene SARIF nativo ≤v16 y la resolución de tsconfig puede dar falsos "not resolvable". Fix: `DependencyCruiserAdapter` (`depcruise -T json`, parsear `summary.violations[]`) + un ingester SARIF 2.1.0 genérico reutilizable. Gate: 0 falsos positivos en un corpus real antes de cualquier bloqueo. **EN-PROGRESO (`a1bc3ea1`):** AC1+AC2 hechos — `DependencyCruiserAdapter` (`parseDependencyCruiserReport` puro: `summary.violations[]`→`Violation` file:line, error/warn/info, path del ciclo, malformado⇒`[]`) sobre la costura `ShellEnforcerAdapter`/`IProcessRunner` de GT-514; ingester **SARIF 2.1.0 genérico** reutilizable (tool desde `driver.name`, no específico de depcruise — reusado por GT-521). Verificado: core-domain 773/773 (+11), tsc limpio. **AC3 (0 FP en corpus REAL) bloqueado por GT-512** (requiere una corrida de `depcruise` en un entorno restaurado). | `Evolith Core` | Cross | P1 | M | `EN-PROGRESO` | | [`GT-516`](./gap-reference-catalog.es.md#gt-516) | (EAG-10 · integración A/B) **Bloque `enforce:` + PolicyCompiler + `evolith enforce compile` + piloto ADR-0002.** Política-única → checks nativos. HXA-01..07 en `adr-0002-hexagonal-architecture.rules.json` son solo texto en `validationQuery`. Fix: `enforce:` en `ruleset-standard.schema.json` (`engine, tool, toolRuleId, config\|configRef, severityMap, runtime, mode`); PolicyCompiler + `evolith enforce compile` (nest-commander, `src/sdk/cli/src/commands/enforce/`) con fallback por regla para reglas no compilables (NetArchTest sin ciclos; Deptrac solo-PHP; Conftest solo-IaC); poblar `enforce` en ADR-0002; test round-trip 0 FP. | `Evolith CLI` | Cross | P1 | L | `EN-PROGRESO` | @@ -37,14 +37,14 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-530`](./gap-reference-catalog.es.md#gt-530) | **[Surround · eje 1] Adaptador Langfuse → evidencia canónica.** Mapear traces/evaluaciones/costo/latencia/versión-de-prompt/tool-calls de Langfuse al modelo de evidencia de Evolith, para no reconstruir una plataforma de telemetría LLM. §8.1 / §12. Fix: `LangfuseEvidenceAdapter` vía puerto de observabilidad. **EN-PROGRESO (`domain/observability-evidence.ts`):** aterrizó el mapper puro + forma portable — `ObservabilityEvidence` (traceId/model/promptVersion/costUsd/latencyMs/totalTokens/toolCalls/evaluations/url, provider-neutral §9-5), `mapLangfuseTrace` (agrega costo/tokens/latencia de las observaciones, toma model/prompt de la 1ª GENERATION, colecta tool-calls distintos, mapea scores→evaluations; trace sin id⇒null) y el puerto `IObservabilityEvidenceSource`. Verificado: core-domain 945/945 (+6), tsc limpio. **Pendiente (conector/infra):** el fetch read-only de la Langfuse API detrás del puerto. **COMPLETADO (`wave`):** `LangfuseEvidenceAdapter implements IObservabilityEvidenceSource` sobre un `LangfuseHttpClient` inyectado, delegando en `mapLangfuseTrace`. Verificado: infra-providers 78/78. | `Evolith Core` | Cross | P2 | L | `COMPLETADO` | | [`GT-531`](./gap-reference-catalog.es.md#gt-531) | **[Surround · eje 1] Adaptador Cowork/Claude como ejecutor gobernado acotado.** Ejecutor de actividades con permisos/planes/aprobaciones/captura de evidencia, tratando a Claude como uno de varios ejecutores reemplazables (§8.2, §9). Extiende el épico agent-runtime GT-383…394 (HITL GT-441 / adapters GT-438). **EN-PROGRESO (`cowork-agent.adapter.ts`):** aterrizó el `CoworkAgentEngineAdapter` — implementa el mismo `IAgentEnginePort` que stub/hermes/swarms (Claude Cowork como ejecutor **reemplazable**), y es **acotado**: nunca propone una herramienta fuera del catálogo de skills gobernado (una propuesta de Cowork/LLM a una capacidad inexistente se rechaza en vez de inventarse). El envelope del runtime (approval GT-441 / policy / trace) ya lo gobierna; la llamada viva a Claude va detrás de `CoworkClient` inyectable (sin cliente = determinista, como el stub). Verificado: agent-runtime 92/92, tsc limpio, surface-freeze GT-388 actualizado. **Pendiente (conector/infra):** el `CoworkClient` real contra la Claude/Cowork API. | `agent-runtime` | Cross | P2 | M | `EN-PROGRESO` | | [`GT-532`](./gap-reference-catalog.es.md#gt-532) | **[Surround · eje 1] Vistas ejecutivas de portafolio + adaptadores marketplace + paquetes de gobernanza por tenant.** Mejora de adopción empresarial y escala de ecosistema (§12 P2); mayormente Tracker (plano de captura de valor enterprise). | `Tracker` | Cross | P3 | XL | `PENDIENTE` | -| [`GT-533`](./gap-reference-catalog.es.md#gt-533) | **[Evidencia · ADR-0111] Puerto de Proveedores de Señales de Calidad + modelo canónico `Evidence` + registro por tenant.** La costura por la que cualquier herramienta externa de calidad/evidencia enriquece al Core sin volverse dependencia: puerto de salida `IQualitySignalProvider` propiedad de la orquestación (nunca `core-domain`); el Core importa solo `Evidence` y la recibe inline (como los archivos fuente vía `OverlayFileSystem`, ADR-0080); el Core nunca ejecuta proveedores; registro declarativo opt-in por tenant; `provenance` + flag `determinism` obligatorios. Generaliza el adaptador `ObservabilityEvidence` de GT-530 en una única costura. **Prototipar primero.** | `Evolith Core` | Cross | P1 | L | `EN-PROGRESO` | +| [`GT-533`](./gap-reference-catalog.es.md#gt-533) | **[Evidencia · ADR-0111] Puerto de Proveedores de Señales de Calidad + modelo canónico `Evidence` + registro por tenant.** La costura por la que cualquier herramienta externa de calidad/evidencia enriquece al Core sin volverse dependencia: puerto de salida `IQualitySignalProvider` propiedad de la orquestación (nunca `core-domain`); el Core importa solo `Evidence` y la recibe inline (como los archivos fuente vía `OverlayFileSystem`, ADR-0080); el Core nunca ejecuta proveedores; registro declarativo opt-in por tenant; `provenance` + flag `determinism` obligatorios. Generaliza el adaptador `ObservabilityEvidence` de GT-530 en una única costura. **Prototipar primero.** | `Evolith Core` | Cross | P1 | L | `COMPLETADO` | | [`GT-534`](./gap-reference-catalog.es.md#gt-534) | **[Adaptador de evidencia · ALTA] Adaptador de referencia Lighthouse (Apache-2.0).** Evidencia de runtime (performance/a11y/SEO, determinista) detrás de `IQualitySignalProvider`; la prueba prototype-first del puerto. OSS, Node module embebible con salida JSON, sin lock-in. Requiere un ADR de Plataforma Node.js acompañante para la elección concreta de proveedor/runtime. | `infra-providers` | Cross | P1 | M | `COMPLETADO` | -| [`GT-535`](./gap-reference-catalog.es.md#gt-535) | **[Quality gate · ALTA] Rúbrica de revisión estructural thermo-nuclear → agente de calidad de código + Quality Gate.** Adoptar la metodología de revisión estructural estricta (code-judo, disciplina de tamaño de archivo, chequeos de spaghetti/abstracción/capas, jerarquía de severidad) como skill del agente code-quality-review y como criterio de regresión estructural del Quality Gate. Metodología de referencia, sin dependencia de runtime; respetar atribución/licencia de la fuente. | `agent-runtime` | Cross | P1 | M | `PENDIENTE` | +| [`GT-535`](./gap-reference-catalog.es.md#gt-535) | **[Quality gate · ALTA] Rúbrica de revisión estructural thermo-nuclear → agente de calidad de código + Quality Gate.** Adoptar la metodología de revisión estructural estricta (code-judo, disciplina de tamaño de archivo, chequeos de spaghetti/abstracción/capas, jerarquía de severidad) como skill del agente code-quality-review y como criterio de regresión estructural del Quality Gate. Metodología de referencia, sin dependencia de runtime; respetar atribución/licencia de la fuente. | `agent-runtime` | Cross | P1 | M | `COMPLETADO` | | [`GT-536`](./gap-reference-catalog.es.md#gt-536) | **[Adaptador de evidencia · opt-in] Adaptador de test-evidence TestSprite — OFF por defecto.** Evidencia opcional de la dimensión testing detrás del puerto; nube propietaria + coste por crédito + egress de código aislado en la frontera del adaptador; **nunca dependencia dura** (opt-in, deshabilitado por defecto). Referenciar el pipeline discover→plan→generate→execute→heal para el agente autónomo de remediación, sin depender de su nube. | `infra-providers` | Cross | P2 | M | `DIFERIDO` | | [`GT-537`](./gap-reference-catalog.es.md#gt-537) | **[Pack de Scorecards] Dimensión GEO / AI-discoverability (patrón Claude SEO).** Pack opcional de Scorecards inspirado en la auditoría multi-agente de Claude SEO (score + plan por severidad); valida que el patrón multi-agente→scorecard escala. No es capacidad del Core — un pack de producto en el plano Portal/Scorecards. | `Tracker` | Cross | P3 | L | `DIFERIDO` | -| [`GT-538`](./gap-reference-catalog.es.md#gt-538) | **[RAG · operacionalizar] Adaptador durable de vector store (pgvector) detrás de `rag-port.mjs`.** Registrar un adaptador real `durable: true` vía `registerRagAdapter('pgvector', …)` para que `14-rag-index-sync.mjs` deje de fallar cerrado y persista embeddings de verdad. El ADR-0090 §5 nombra pgvector como target self-hosted preferido y Postgres ya corre en :5432. Hoy el único adaptador es el stand-in `memory` no durable — nada llega a un store real. | `Operations` | Cross | P1 | M | `PENDIENTE` | -| [`GT-539`](./gap-reference-catalog.es.md#gt-539) | **[RAG · operacionalizar] Modelo de embedding real detrás del `embed()` del puerto RAG.** Reemplazar el pseudo-embedding sha256 determinista `hashEmbed` por el modelo real fijado por el **ADR-0112 — Qwen3-Embedding (Apache-2.0, OSS/self-hosted)** detrás de un sidecar de inferencia local, declarado en la metadata `corpus_version` según ADR-0090 §3 y gobernado por la selección de modelos del ADR-0003. El puerto ya es model-agnostic — un solo swap de adaptador. | `Operations` | Cross | P1 | S | `PENDIENTE` | -| [`GT-540`](./gap-reference-catalog.es.md#gt-540) | **[RAG · retrieval] Adaptador `IKnowledgePort` de producción (vector store, semántico).** El read-side de runtime es un stub: `InMemoryKnowledgeAdapter` puntúa por coincidencia substring/tokens con "No vector embeddings" en su propio docstring. Construir el adaptador de producción que consulta el store de GT-538 por similitud coseno, devolviendo chunks rankeados con la metadata de citación ya modelada. Es el `maturity-assessment.md:147` "Knowledge / RAG — Not implemented" (ALTA). | `agent-runtime` | Cross | P1 | M | `PENDIENTE` | +| [`GT-538`](./gap-reference-catalog.es.md#gt-538) | **[RAG · operacionalizar] Adaptador durable de vector store (pgvector) detrás de `rag-port.mjs`.** Registrar un adaptador real `durable: true` vía `registerRagAdapter('pgvector', …)` para que `14-rag-index-sync.mjs` deje de fallar cerrado y persista embeddings de verdad. El ADR-0090 §5 nombra pgvector como target self-hosted preferido y Postgres ya corre en :5432. Hoy el único adaptador es el stand-in `memory` no durable — nada llega a un store real. | `Operations` | Cross | P1 | M | `EN-PROGRESO` | +| [`GT-539`](./gap-reference-catalog.es.md#gt-539) | **[RAG · operacionalizar] Modelo de embedding real detrás del `embed()` del puerto RAG.** Reemplazar el pseudo-embedding sha256 determinista `hashEmbed` por el modelo real fijado por el **ADR-0112 — Qwen3-Embedding (Apache-2.0, OSS/self-hosted)** detrás de un sidecar de inferencia local, declarado en la metadata `corpus_version` según ADR-0090 §3 y gobernado por la selección de modelos del ADR-0003. El puerto ya es model-agnostic — un solo swap de adaptador. | `Operations` | Cross | P1 | S | `EN-PROGRESO` | +| [`GT-540`](./gap-reference-catalog.es.md#gt-540) | **[RAG · retrieval] Adaptador `IKnowledgePort` de producción (vector store, semántico).** El read-side de runtime es un stub: `InMemoryKnowledgeAdapter` puntúa por coincidencia substring/tokens con "No vector embeddings" en su propio docstring. Construir el adaptador de producción que consulta el store de GT-538 por similitud coseno, devolviendo chunks rankeados con la metadata de citación ya modelada. Es el `maturity-assessment.md:147` "Knowledge / RAG — Not implemented" (ALTA). | `agent-runtime` | Cross | P1 | M | `COMPLETADO` | | [`GT-541`](./gap-reference-catalog.es.md#gt-541) | **[RAG · cableado] Disparador del delta-sync + grounding de agentes (cerrar el lazo).** Cablear un CI/workflow que corra `14-rag-index-sync.mjs` con `EVOLITH_RAG_SYNC=true` + `EVOLITH_RAG_PROVIDER` en commits a `reference/` (re-embed delta según ADR-0090 §4), y que al menos un agente (Winston) consulte `IKnowledgePort` antes de recomendar. Solo entonces el RAG queda operativo de punta a punta. | `Operations` | Cross | P2 | M | `PENDIENTE` | | [`GT-486`](./gap-reference-catalog.es.md#gt-486) | **el CLI no emitió un envelope ADR-0073 parseable para sdlc-status.** La salida no pudo parsearse en un envelope { success, data\|error, meta }. (Auto-detectado por el agente de pruebas exploratorias en la operación `sdlc-status`.) | `Evolith CLI` | Cross | P2 | S | `COMPLETADO` | | [`GT-487`](./gap-reference-catalog.es.md#gt-487) | **el CLI no emitió un envelope ADR-0073 parseable para sdlc-handoff.** La salida no pudo parsearse en un envelope { success, data\|error, meta }. (Auto-detectado por el agente de pruebas exploratorias en la operación `sdlc-handoff`.) | `Evolith CLI` | Cross | P2 | S | `COMPLETADO` | @@ -556,7 +556,7 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-246`](./gap-reference-catalog.es.md#gt-246) | Implementar experimentos Chaos Mesh/Litmus | `QA` | Cross | P3 | L | `COMPLETADO` | -**Progreso:** 506 / 541 completados · 17 en progreso · 14 pendientes · 4 diferidos +**Progreso:** 510 / 541 completados · 17 en progreso · 10 pendientes · 4 diferidos **Oleada 2026-06-23 (auditoría profunda de Winston III):** Añadidos 14 gaps nuevos `GT-212`…`GT-225` del Winston Audit Playbook que cubren: higiene de estado ADR (GT-212), metadata + presupuestos operativos + corpus de guías por topología (GT-213, GT-217, GT-219), observabilidad + OpenAPI en controladores REST (GT-214, GT-215), paridad de input-schemas OPA + densidad de tests por topología (GT-216, GT-222), plantillas de rollback + on-call de Fase 05 (GT-218), cobertura de ramas CLI + paridad de envelope --format + limpieza de skip-list (GT-220, GT-224, GT-225), audit logging HTTP de MCP (GT-221), y tests e2e de paridad cross-surface (GT-223). diff --git a/reference/core/control-center/gaps/gap-tracking.md b/reference/core/control-center/gaps/gap-tracking.md index 9fb0e5df6..c9f28986c 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -16,7 +16,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-510`](./gap-reference-catalog.md#gt-510) | **16 DONE gaps lack a closure-evidence record in `gap-closure-evidence.json`.** The registry holds 417 closure records but 433 are required (425 `GT-*` DONE + 8 `MT-*`), so `08-validate-tracking.mjs` and `09-reconcile-maturity.mjs --check` fail. Surfaced once [`GT-476`](./gap-reference-catalog.md#gt-476) re-pointed the tracking/maturity guards to the real paths — the stale paths had hidden it. Affected: GT-424, GT-436, GT-440, GT-449, GT-450, GT-452, GT-466, GT-467, GT-468, GT-469, GT-470, GT-471, GT-472, GT-473, GT-474, GT-484. Fix: add the real closure record (commit + verification) for each, or revert any not actually DONE — do not fabricate evidence. **DONE:** the registry now holds 439 records (≥ required) and BOTH guards pass — `08-validate-tracking.mjs` "Validated 523 gaps and 439 closure records" and `09-reconcile-maturity.mjs --check` "✅ Maturity reconciliation matches" (the latter completed by the maturity `asOf` sync `0d45a08e`). Every DONE gap has a record (08 enforces exactly that). | `Governance` | Cross | P2 | M | `DONE` | | [`GT-511`](./gap-reference-catalog.md#gt-511) | (EAG-02 · A/B integration) **Single normalized evidence/Violation model.** One canonical `Violation` model (`ruleId, tool, file, line?, column?, severity, message, adrRef, owner?, fingerprint, frozen`) + an evidence contract against `src/rulesets/evidence/evidence-manifest.rules.json` (EVD-01..04); unifies the OSS normalizer (A) and the `EvidenceNormalizer` (B). Today the only engine is OPA-WASM + 12 native handlers (`native-evaluator.ts`), there is no OSS-ingestion adapter, and `RuleExecutionRef.engine` is a strict Ajv enum. Fix: `violation.ts` in core-domain (fingerprint normalized WITHOUT message + normalized path), map to `GapFinding`/`RiskFinding`, `violation.schema.json` + `enforcer-evidence.schema.json`, add engine `'enforcer'` with an enum version/tolerance strategy. **DONE (`d769c97b`):** `violation.ts` (Violation model + invertible severity maps + fingerprint over normalized path excluding `message` + Violation⇄GapFinding/RiskFinding + `EnforcerEvidenceManifest`/`buildEnforcerEvidence` discharging EVD-01..04); `violation.schema.json` + `enforcer-evidence.schema.json` (ajv-valid, incl. negative EVD-02); `RuleExecutionRef.engine` widened to an open `RuleEngine` vocab + `'enforcer'` (`isKnownEngine`/`normalizeEngine`). Verified: core-domain 752/752 (+17), tsc clean, ajv valid. | `core-domain` | Cross | P0 | M | `DONE` | | [`GT-512`](./gap-reference-catalog.md#gt-512) | (EAG-04 · A/B integration) **Evaluation-environment provisioning (restore/scoping/cache/sandbox).** Make source-analyzers runnable and safe. `GitHubRepositorySourceReader` delivers a TEXT tarball with no dependencies → dependency-cruiser/import-linter give false negatives; Core and satellites are Nx monorepos. Fix: PA-01 restore (`npm ci`/`dotnet restore+build`/`pip install`+grimp/`composer install`); PA-02 per-project Nx scoping; PA-03 EvaluationResult cache by commit-SHA + changed-files-only; PA-04 shell-out sandbox (no egress, no secrets, ulimits/cgroups, binary allowlist); PA-05 toolchain per `evolith.yaml` manifest. **IN-PROGRESS (`970a0da6`):** the verifiable pure slice landed in `provisioning.ts` — PA-01 `buildRestorePlan`, PA-02 `resolveProjectScope` (Nx affected), PA-03 `computeEvaluationCacheKey`+`IEvaluationCache` (AC3 done — unchanged commit+scope hits cache), PA-04 `SandboxPolicy`+`enforceSandboxPolicy`+`SandboxedProcessRunner` (fail-closed: non-allowlisted binary / secret-env rejected before the inner runner; hardens the GT-514 `IProcessRunner`), PA-05 `resolveRuntimeFromManifest`. Verified: core-domain 838/838 (+16), tsc clean. **+2026-07-12 (PA-06 + real runner):** `executeRestorePlan` (runs the plan fail-fast in the checkout cwd) + `provisionEvaluationEnvironment` (composes scope→cache→restore) in core-domain; and the real **`NodeProcessRunner`** in `@beyondnet/evolith-infra-providers` (`execFile`, no shell, no secret inheritance —curated PATH/HOME/locale passthrough, never TOKEN/SECRET—, timeout+kill, cwd-confined) — the inner runner that `SandboxedProcessRunner` wraps; port exposed in core-domain's public API. Verified: core-domain 893/893, infra-providers 67/67, tsc clean. **Gated (OS-infra only):** OS-level egress/cgroup/namespace enforcement (needs a locked-down deploy container) + the real repo fetch/checkout integration. | `Evolith Core` | Cross | P0 | L | `IN-PROGRESS` | -| [`GT-513`](./gap-reference-catalog.md#gt-513) | (EAG-06 · A/B integration) **Stable API + capabilities manifest.** The front door for external consumers. `evolith-machine-contracts.json` lists only `evolith_tracker` in `supportedConsumers` and there is no `/capabilities`. Fix: a versioned `@beyondnet/evolith-contracts` package (SemVer + sha256); `GET /api/v1/capabilities` next to `ReferenceController` (REST-only per ADR-0074, no GraphQL here); contract-parity tests. **IN-PROGRESS (`6b4bebf4`, parallel wave):** the versioned manifest + drift guard landed in core-domain — `capabilities/capabilities-manifest.ts` `buildCapabilityManifest` (kinds from `EvaluationResult.results`, engines from `KNOWN_RULE_ENGINES` incl. `enforcer`, `supportedConsumers` adds `external`, SemVer `version`, `sha256` of canonical JSON) + `capabilityManifestFingerprint` parity guard + compile-time exhaustiveness guards. Verified: core-domain 822/822 (+15), tsc clean. **Gated:** the live `GET /api/v1/capabilities` core-api endpoint + the published `@beyondnet/evolith-contracts` package (shared-file/module wiring). **+parallel wave (`d144736e`):** landed the live `GET /api/v1/capabilities` endpoint (`CapabilitiesController` → `buildCapabilityManifest`, ADR-0073 envelope). Verified 2/2 + reference no regression. Remaining: the published `@beyondnet/evolith-contracts` package. | `Core API` | Cross | P1 | M | `IN-PROGRESS` | +| [`GT-513`](./gap-reference-catalog.md#gt-513) | (EAG-06 · A/B integration) **Stable API + capabilities manifest.** The front door for external consumers. `evolith-machine-contracts.json` lists only `evolith_tracker` in `supportedConsumers` and there is no `/capabilities`. Fix: a versioned `@beyondnet/evolith-contracts` package (SemVer + sha256); `GET /api/v1/capabilities` next to `ReferenceController` (REST-only per ADR-0074, no GraphQL here); contract-parity tests. **IN-PROGRESS (`6b4bebf4`, parallel wave):** the versioned manifest + drift guard landed in core-domain — `capabilities/capabilities-manifest.ts` `buildCapabilityManifest` (kinds from `EvaluationResult.results`, engines from `KNOWN_RULE_ENGINES` incl. `enforcer`, `supportedConsumers` adds `external`, SemVer `version`, `sha256` of canonical JSON) + `capabilityManifestFingerprint` parity guard + compile-time exhaustiveness guards. Verified: core-domain 822/822 (+15), tsc clean. **Gated:** the live `GET /api/v1/capabilities` core-api endpoint + the published `@beyondnet/evolith-contracts` package (shared-file/module wiring). **+parallel wave (`d144736e`):** landed the live `GET /api/v1/capabilities` endpoint (`CapabilitiesController` → `buildCapabilityManifest`, ADR-0073 envelope). Verified 2/2 + reference no regression. Remaining: the published `@beyondnet/evolith-contracts` package. | `Core API` | Cross | P1 | M | `DONE` | | [`GT-514`](./gap-reference-catalog.md#gt-514) | (EAG-08 · A/B integration) **`IEnforcerAdapter` + `EnforcerEvaluator` + Composite + catalog.** The enforcer orchestration seam. `rule-evaluation-engine.ts`/`RulesetValidatorService` use a single `this.strategy` (Native). Fix: `IEnforcerAdapter.analyze(ctx)→Violation[]` with a `runtime` union; `EnforcerEvaluator` (an `IRuleEvaluatorStrategy`) filtering `enforce.engine==='enforcer'`; `CompositeRuleEvaluator` preserving the Native default; `ShellEnforcerAdapter` + `IProcessRunner`; `enforcer-catalog.json` aligned with `product/infra/validated-tool-catalog.md`. **DONE (`30aee332`):** `enforcement/` seam — `IEnforcerAdapter.analyze→Violation[]` + `IProcessRunner` port (hardened by GT-512) + `StubProcessRunner` (no unsandboxed default shipped); reusable `ShellEnforcerAdapter` (buildSpec+parse); `EnforcerEvaluator` (filters `enforce.engine==='enforcer'`, correlates by `toolRuleId`, frozen⇒pass, missing-adapter/crash⇒skip); `CompositeRuleEvaluator` drops into the engine's injectable `strategy` unchanged; `NormalizedRule.enforce?`; `enforcer-catalog.json` mirrors new §4.3 of the tool catalog (EN+ES). Verified: core-domain 762/762 (+10), tsc clean. | `core-domain` | Cross | P1 | M | `DONE` | | [`GT-515`](./gap-reference-catalog.md#gt-515) | (EAG-09 · A/B integration) **dependency-cruiser adapter + SARIF ingester.** First source-analyzer adapter (Node/TS, where Core code lives). depcruise has no native SARIF ≤v16 and tsconfig resolution can yield false "not resolvable". Fix: `DependencyCruiserAdapter` (`depcruise -T json`, parse `summary.violations[]`) + a generic reusable SARIF 2.1.0 ingester. Gate: 0 false positives on a real corpus before any block. **IN-PROGRESS (`a1bc3ea1`):** AC1+AC2 done — `DependencyCruiserAdapter` (pure `parseDependencyCruiserReport`: `summary.violations[]`→`Violation` file:line, error/warn/info, cycle path, malformed⇒`[]`) over the GT-514 `ShellEnforcerAdapter`/`IProcessRunner` seam; generic reusable **SARIF 2.1.0 ingester** (tool from `driver.name`, not depcruise-specific — reused by GT-521). Verified: core-domain 773/773 (+11), tsc clean. **AC3 (0 FP on a REAL corpus) gated by GT-512** (needs a `depcruise` run in a restored env). | `Evolith Core` | Cross | P1 | M | `IN-PROGRESS` | | [`GT-516`](./gap-reference-catalog.md#gt-516) | (EAG-10 · A/B integration) **`enforce:` block + PolicyCompiler + `evolith enforce compile` + ADR-0002 pilot.** Single-policy → native checks. HXA-01..07 in `adr-0002-hexagonal-architecture.rules.json` are only text in `validationQuery`. Fix: `enforce:` in `ruleset-standard.schema.json` (`engine, tool, toolRuleId, config\|configRef, severityMap, runtime, mode`); PolicyCompiler + `evolith enforce compile` (nest-commander, `src/sdk/cli/src/commands/enforce/`) with per-rule fallback for uncompilable rules (NetArchTest no cycles; Deptrac PHP-only; Conftest IaC-only); populate `enforce` in ADR-0002; round-trip test 0 FP. | `Evolith CLI` | Cross | P1 | L | `IN-PROGRESS` | @@ -37,14 +37,14 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-530`](./gap-reference-catalog.md#gt-530) | **[Surround · axis 1] Langfuse adapter → canonical evidence.** Map Langfuse traces/evaluations/cost/latency/prompt-version/tool-calls to Evolith's evidence model, to avoid rebuilding an LLM telemetry platform. §8.1 / §12. Fix: `LangfuseEvidenceAdapter` via the observability port. **IN-PROGRESS (`domain/observability-evidence.ts`):** landed the pure mapper + portable shape — `ObservabilityEvidence` (traceId/model/promptVersion/costUsd/latencyMs/totalTokens/toolCalls/evaluations/url, provider-neutral §9-5), `mapLangfuseTrace` (aggregates cost/tokens/latency across observations, takes model/prompt from the first GENERATION, collects distinct tool-calls, maps scores→evaluations; a trace with no id⇒null) and the `IObservabilityEvidenceSource` port. Verified: core-domain 945/945 (+6), tsc clean. **Remaining (connector/infra):** the read-only Langfuse API fetch behind the port. **DONE (`wave`):** `LangfuseEvidenceAdapter implements IObservabilityEvidenceSource` over an injected `LangfuseHttpClient`, delegating to `mapLangfuseTrace`. Verified: infra-providers 78/78. | `Evolith Core` | Cross | P2 | L | `DONE` | | [`GT-531`](./gap-reference-catalog.md#gt-531) | **[Surround · axis 1] Cowork/Claude adapter as a bounded governed executor.** Activity executor with permissions/plans/approvals/evidence capture, treating Claude as one of several replaceable executors (§8.2, §9). Extends the agent-runtime epic GT-383…394 (HITL GT-441 / adapters GT-438). **IN-PROGRESS (`cowork-agent.adapter.ts`):** landed `CoworkAgentEngineAdapter` — implements the same `IAgentEnginePort` as stub/hermes/swarms (Claude Cowork as a **replaceable** executor), and is **bounded**: it never proposes a tool outside the governed skill catalog (a Cowork/LLM proposal for a non-existent capability is rejected, not invented). The runtime envelope (approval GT-441 / policy / trace) already governs it; the live Claude call sits behind an injectable `CoworkClient` (no client = deterministic, like the stub). Verified: agent-runtime 92/92, tsc clean, GT-388 surface-freeze updated. **Remaining (connector/infra):** the real `CoworkClient` against the Claude/Cowork API. | `agent-runtime` | Cross | P2 | M | `IN-PROGRESS` | | [`GT-532`](./gap-reference-catalog.md#gt-532) | **[Surround · axis 1] Executive portfolio views + marketplace adapters + per-tenant governance packages.** Enterprise-adoption and ecosystem-scale improvement (§12 P2); mostly Tracker (enterprise value-capture plane). | `Tracker` | Cross | P3 | XL | `PENDING` | -| [`GT-533`](./gap-reference-catalog.md#gt-533) | **[Evidence · ADR-0111] Quality Signal Provider port + canonical `Evidence` model + per-tenant registry.** The seam through which any external quality/evidence tool enriches the Core without becoming a dependency: driven port `IQualitySignalProvider` owned by orchestration (never `core-domain`); Core imports only `Evidence` and receives it inline (like source files via `OverlayFileSystem`, ADR-0080); Core never executes providers; declarative opt-in registry per tenant; mandatory `provenance` + `determinism` flag. Generalizes the GT-530 `ObservabilityEvidence` adapter into one uniform seam. **Prototype first.** | `Evolith Core` | Cross | P1 | L | `IN-PROGRESS` | +| [`GT-533`](./gap-reference-catalog.md#gt-533) | **[Evidence · ADR-0111] Quality Signal Provider port + canonical `Evidence` model + per-tenant registry.** The seam through which any external quality/evidence tool enriches the Core without becoming a dependency: driven port `IQualitySignalProvider` owned by orchestration (never `core-domain`); Core imports only `Evidence` and receives it inline (like source files via `OverlayFileSystem`, ADR-0080); Core never executes providers; declarative opt-in registry per tenant; mandatory `provenance` + `determinism` flag. Generalizes the GT-530 `ObservabilityEvidence` adapter into one uniform seam. **Prototype first.** | `Evolith Core` | Cross | P1 | L | `DONE` | | [`GT-534`](./gap-reference-catalog.md#gt-534) | **[Evidence adapter · HIGH] Lighthouse reference adapter (Apache-2.0).** Runtime evidence (performance/a11y/SEO, deterministic) behind `IQualitySignalProvider`; the prototype-first proof of the port. OSS, embeddable Node module with JSON output, no lock-in. Needs a companion Node.js Platform ADR for the concrete vendor/runtime choice. | `infra-providers` | Cross | P1 | M | `DONE` | -| [`GT-535`](./gap-reference-catalog.md#gt-535) | **[Quality gate · HIGH] Thermo-nuclear structural-review rubric → code-quality agent + Quality Gate.** Adopt the strict structural-review methodology (code-judo, file-size discipline, spaghetti/abstraction/layering checks, severity hierarchy) as a skill for the code-quality-review agent and as the structural-regression criteria of the Quality Gate. Reference methodology, no runtime dependency; respect source attribution/license. | `agent-runtime` | Cross | P1 | M | `PENDING` | +| [`GT-535`](./gap-reference-catalog.md#gt-535) | **[Quality gate · HIGH] Thermo-nuclear structural-review rubric → code-quality agent + Quality Gate.** Adopt the strict structural-review methodology (code-judo, file-size discipline, spaghetti/abstraction/layering checks, severity hierarchy) as a skill for the code-quality-review agent and as the structural-regression criteria of the Quality Gate. Reference methodology, no runtime dependency; respect source attribution/license. | `agent-runtime` | Cross | P1 | M | `DONE` | | [`GT-536`](./gap-reference-catalog.md#gt-536) | **[Evidence adapter · opt-in] TestSprite test-evidence adapter — default OFF.** Optional testing-dimension evidence behind the port; proprietary cloud + credit cost + code egress isolated at the adapter boundary; **never a hard dependency** (opt-in, disabled by default). Reference the discover→plan→generate→execute→heal pipeline for the autonomous remediation agent, without depending on their cloud. | `infra-providers` | Cross | P2 | M | `DEFERRED` | | [`GT-537`](./gap-reference-catalog.md#gt-537) | **[Scorecards pack] GEO / AI-discoverability dimension (Claude SEO pattern).** Optional Scorecards pack inspired by the Claude SEO multi-agent audit (score + severity plan); validates that the multi-agent→scorecard pattern scales. Not a Core capability — a product pack in the Portal/Scorecards plane. | `Tracker` | Cross | P3 | L | `DEFERRED` | -| [`GT-538`](./gap-reference-catalog.md#gt-538) | **[RAG · operationalize] Durable vector-store adapter (pgvector) behind `rag-port.mjs`.** Register a real `durable: true` adapter via `registerRagAdapter('pgvector', …)` so `14-rag-index-sync.mjs` stops failing closed and actually persists embeddings. ADR-0090 §5 names pgvector the preferred self-hosted target and Postgres already runs on :5432. Today the only adapter is the non-durable `memory` stand-in — nothing reaches a real store. | `Operations` | Cross | P1 | M | `PENDING` | -| [`GT-539`](./gap-reference-catalog.md#gt-539) | **[RAG · operationalize] Real embedding model behind the RAG port `embed()`.** Replace the deterministic `hashEmbed` sha256 pseudo-embedding with the real model fixed by **ADR-0112 — Qwen3-Embedding (Apache-2.0, OSS/self-hosted)** behind a local inference sidecar, declared in `corpus_version` metadata per ADR-0090 §3 and governed by ADR-0003 model selection. The port is already model-agnostic — a single adapter swap. | `Operations` | Cross | P1 | S | `PENDING` | -| [`GT-540`](./gap-reference-catalog.md#gt-540) | **[RAG · retrieval] Production `IKnowledgePort` adapter (vector-store, semantic).** The runtime read-side is a stub: `InMemoryKnowledgeAdapter` scores by substring/token-overlap with "No vector embeddings" in its own docstring. Build the production adapter querying the GT-538 store by cosine similarity, returning ranked chunks with the citation metadata already modeled. This is `maturity-assessment.md:147` "Knowledge / RAG — Not implemented" (HIGH). | `agent-runtime` | Cross | P1 | M | `PENDING` | +| [`GT-538`](./gap-reference-catalog.md#gt-538) | **[RAG · operationalize] Durable vector-store adapter (pgvector) behind `rag-port.mjs`.** Register a real `durable: true` adapter via `registerRagAdapter('pgvector', …)` so `14-rag-index-sync.mjs` stops failing closed and actually persists embeddings. ADR-0090 §5 names pgvector the preferred self-hosted target and Postgres already runs on :5432. Today the only adapter is the non-durable `memory` stand-in — nothing reaches a real store. | `Operations` | Cross | P1 | M | `IN-PROGRESS` | +| [`GT-539`](./gap-reference-catalog.md#gt-539) | **[RAG · operationalize] Real embedding model behind the RAG port `embed()`.** Replace the deterministic `hashEmbed` sha256 pseudo-embedding with the real model fixed by **ADR-0112 — Qwen3-Embedding (Apache-2.0, OSS/self-hosted)** behind a local inference sidecar, declared in `corpus_version` metadata per ADR-0090 §3 and governed by ADR-0003 model selection. The port is already model-agnostic — a single adapter swap. | `Operations` | Cross | P1 | S | `IN-PROGRESS` | +| [`GT-540`](./gap-reference-catalog.md#gt-540) | **[RAG · retrieval] Production `IKnowledgePort` adapter (vector-store, semantic).** The runtime read-side is a stub: `InMemoryKnowledgeAdapter` scores by substring/token-overlap with "No vector embeddings" in its own docstring. Build the production adapter querying the GT-538 store by cosine similarity, returning ranked chunks with the citation metadata already modeled. This is `maturity-assessment.md:147` "Knowledge / RAG — Not implemented" (HIGH). | `agent-runtime` | Cross | P1 | M | `DONE` | | [`GT-541`](./gap-reference-catalog.md#gt-541) | **[RAG · wiring] Delta-sync workflow trigger + agent grounding (close the loop).** Wire a CI/workflow that runs `14-rag-index-sync.mjs` with `EVOLITH_RAG_SYNC=true` + `EVOLITH_RAG_PROVIDER` on `reference/` commits (delta re-embed per ADR-0090 §4), and have at least one agent (Winston) query `IKnowledgePort` before recommending. Only then is RAG operational end-to-end. | `Operations` | Cross | P2 | M | `PENDING` | | [`GT-486`](./gap-reference-catalog.md#gt-486) | **cli did not emit a parseable ADR-0073 envelope for sdlc-status.** Output could not be parsed into an { success, data\|error, meta } envelope. (Auto-detected by the exploratory test agent on operation `sdlc-status`.) | `Evolith CLI` | Cross | P2 | S | `DONE` | | [`GT-487`](./gap-reference-catalog.md#gt-487) | **cli did not emit a parseable ADR-0073 envelope for sdlc-handoff.** Output could not be parsed into an { success, data\|error, meta } envelope. (Auto-detected by the exploratory test agent on operation `sdlc-handoff`.) | `Evolith CLI` | Cross | P2 | S | `DONE` | @@ -556,7 +556,7 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-246`](./gap-reference-catalog.md#gt-246) | Implement Chaos Mesh/Litmus experiments | `QA` | Cross | P3 | L | `DONE` | -**Progress:** 506 / 541 done · 17 in progress · 14 pending · 4 deferred +**Progress:** 510 / 541 done · 17 in progress · 10 pending · 4 deferred **Wave 2026-06-23 (Winston deep audit III):** Added 14 new gaps `GT-212`…`GT-225` from the Winston Audit Playbook covering: ADR status hygiene (GT-212), topology manifest metadata + operational budgets + guidance corpus (GT-213, GT-217, GT-219), REST controller observability + OpenAPI (GT-214, GT-215), OPA input-schema parity + per-topology test density (GT-216, GT-222), SDLC Phase 05 rollback + on-call templates (GT-218), CLI branch coverage + envelope format coverage + skip-list cleanup (GT-220, GT-224, GT-225), MCP HTTP audit logging (GT-221), and cross-surface parity e2e tests (GT-223). diff --git a/reference/core/control-center/maturity-reports/maturity-assessment.md b/reference/core/control-center/maturity-reports/maturity-assessment.md index 7fcbe3168..2be93c142 100644 --- a/reference/core/control-center/maturity-reports/maturity-assessment.md +++ b/reference/core/control-center/maturity-reports/maturity-assessment.md @@ -144,7 +144,7 @@ This dimension measures the maturity of the interaction surfaces and internal or | **GitHub Automation** | Create satellite repos, issues, PRs, CI from governed flows. | Not implemented as direct runtime adapter. | `Not implemented` | `GitHubRepositoryAdapter`, `GitHubIssueAdapter`, `GitHubPullRequestAdapter`, `GitHubActionsAdapter` | Governed SDLC automation over GitHub. | Medium | | **Notifications / Collaboration** | Notify blocked gates, pending approvals, and results. | Not implemented as direct runtime adapter. | `Not implemented` | `SlackAdapter`, `TeamsAdapter`, `EmailNotificationAdapter`, `DiscordAdapter` | Improves collaboration, alerts, and approvals. | Medium | | **Observability** | Observe runtime, engines, latency, errors, and blocks. | Partial via Tracker Trace. | `Partial` | `OpenTelemetryAdapter`, `PrometheusMetricsAdapter`, `StructuredAuditAdapter` | Enterprise monitoring and technical auditing. | Medium | -| **Knowledge / RAG** | Query ADRs, blueprints, rulesets before suggesting actions. | Not implemented as consolidated adapter. | `Not implemented` | `RagKnowledgeAdapter`, `DocsSearchAdapter`, `VectorStoreAdapter`, `GitDocsAdapter`, `ObsidianAdapter` | Enhances agentic recommendation quality using internal evidence. | High | +| **Knowledge / RAG** | Query ADRs, blueprints, rulesets before suggesting actions. | Production read-side delivered (GT-540): `PgVectorKnowledgeAdapter` runs cosine top-k over the GT-538 pgvector `rag_chunks` store via an injected GT-539 Qwen3 embedder (dim 1024, fail-closed) and returns ranked, cited `KnowledgeChunk[]`; selected over the token-overlap `InMemoryKnowledgeAdapter` default by `runtime.factory` (`AGENT_RUNTIME_KNOWLEDGE_MODE` / store URL). Live pgvector + sidecar run is deploy-gated. | `Implemented (deploy-gated run)` | `PgVectorKnowledgeAdapter` (GT-540), `InMemoryKnowledgeAdapter` (GT-408 default) | Enhances agentic recommendation quality using internal evidence. | High | | **Secrets / Config** | Manage credentials, endpoints, engine selection, and config. | Partial via bootstrap/overrides. | `Partial` | `VaultSecretAdapter`, `EnvConfigAdapter`, `RemoteConfigAdapter`, `PolicyBundleConfigAdapter` | Avoids hardcoding, improves per-environment configuration security. | High | --- diff --git a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts index 45b20c6e2..0cc8d45cf 100644 --- a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts +++ b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.spec.ts @@ -10,8 +10,10 @@ import { FileMemoryAdapter, StubPolicyValidationAdapter, OpaCliPolicyValidationAdapter, + InMemoryKnowledgeAdapter, + PgVectorKnowledgeAdapter, } from '@beyondnet/evolith-agent-runtime'; -import { createRuntimeFromEnv, resolveProfile } from './runtime.factory'; +import { createRuntimeFromEnv, resolveProfile, resolveKnowledgeAdapter } from './runtime.factory'; /** * GT-438 — the factory's profile selection matrix. A single switch @@ -166,4 +168,57 @@ describe('createRuntimeFromEnv — profile selection matrix (GT-438)', () => { expect(deps.engine).toBeInstanceOf(StubAgentEngineAdapter); }); }); + + /** + * GT-540 — read-side knowledge/RAG adapter selection. Token-overlap in-memory + * is the explicit default in EVERY profile; the pgvector production adapter is + * selected explicitly or when a store URL is configured, and fails loud when + * selected without its store connection + embedder sidecar. + */ + describe('knowledge/RAG adapter selection (GT-540)', () => { + const PG = { EVOLITH_RAG_PG_URL: 'postgres://db/evolith', EVOLITH_RAG_EMBED_URL: 'http://sidecar:8080/embed' } as const; + + it('defaults to the in-memory token adapter when nothing is configured', () => { + expect(resolveKnowledgeAdapter({})).toBeInstanceOf(InMemoryKnowledgeAdapter); + }); + + it('auto-selects pgvector when a store URL is configured with an embedder sidecar', () => { + expect(resolveKnowledgeAdapter({ ...PG })).toBeInstanceOf(PgVectorKnowledgeAdapter); + }); + + it('selects pgvector explicitly via AGENT_RUNTIME_KNOWLEDGE_MODE=pgvector', () => { + expect( + resolveKnowledgeAdapter({ AGENT_RUNTIME_KNOWLEDGE_MODE: 'pgvector', ...PG }), + ).toBeInstanceOf(PgVectorKnowledgeAdapter); + }); + + it('keeps in-memory explicitly even when a store URL is present', () => { + expect( + resolveKnowledgeAdapter({ AGENT_RUNTIME_KNOWLEDGE_MODE: 'in-memory', ...PG }), + ).toBeInstanceOf(InMemoryKnowledgeAdapter); + }); + + it('fails loud when pgvector is selected without a store connection', () => { + expect(() => + resolveKnowledgeAdapter({ AGENT_RUNTIME_KNOWLEDGE_MODE: 'pgvector', EVOLITH_RAG_EMBED_URL: 'http://sidecar/embed' }), + ).toThrow(/requires a store connection/); + }); + + it('fails loud when pgvector is selected without an embedder sidecar', () => { + expect(() => + resolveKnowledgeAdapter({ AGENT_RUNTIME_KNOWLEDGE_MODE: 'pgvector', EVOLITH_RAG_PG_URL: 'postgres://db/evolith' }), + ).toThrow(/requires an embedder sidecar/); + }); + + it('throws on an unknown knowledge mode', () => { + expect(() => resolveKnowledgeAdapter({ AGENT_RUNTIME_KNOWLEDGE_MODE: 'faiss' })).toThrow( + /unknown AGENT_RUNTIME_KNOWLEDGE_MODE/, + ); + }); + + it('is wired onto the bundle by createRuntimeFromEnv (in-memory by default)', () => { + const { deps } = createRuntimeFromEnv({}); + expect(deps.knowledge).toBeInstanceOf(InMemoryKnowledgeAdapter); + }); + }); }); diff --git a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts index 1338c89a7..b67cf2f83 100644 --- a/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts +++ b/src/apps/agent-runtime-api/src/agent-runtime/runtime.factory.ts @@ -41,9 +41,14 @@ import { HermesAgentAdapter, SwarmsAgentAdapter, CoworkAgentEngineAdapter, + InMemoryKnowledgeAdapter, + PgVectorKnowledgeAdapter, + PGVECTOR_KNOWLEDGE_DIM, + type EmbedQuery, type AgentRuntimeBundle, type AgentRuntimeOverrides, type EngineRouterConfig, + type IKnowledgePort, } from '@beyondnet/evolith-agent-runtime'; import * as path from 'node:path'; @@ -81,6 +86,113 @@ export function resolveProfile(env: NodeJS.ProcessEnv = process.env): RuntimePro } } +/** + * Build an `EmbedQuery` seam that calls the GT-539 on-perimeter Qwen3 inference + * sidecar over HTTP. Model-agnostic: the endpoint and model id are env-driven so + * a model swap is config, not code (ADR-0090 §3 / ADR-0112 §1,§4). Accepts the + * OpenAI-style `{data:[{embedding}]}`, TEI-style `[[...]]`, or `{embeddings}` + * response shapes — the same shapes the write-side embedder tolerates. + */ +export function makeSidecarEmbedder(url: string, model?: string): EmbedQuery { + return async (text: string): Promise => { + const res = await fetch(url, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ input: [text], model }), + }); + if (!res.ok) { + throw new Error( + `[agent-runtime] RAG embed sidecar ${url} returned ${res.status} ${res.statusText}`, + ); + } + const body: any = await res.json(); + const pick = (e: any) => (Array.isArray(e) ? e : e && e.embedding); + let vec: unknown; + if (Array.isArray(body)) vec = pick(body[0]); + else if (Array.isArray(body?.data)) vec = pick(body.data[0]); + else if (Array.isArray(body?.embeddings)) vec = pick(body.embeddings[0]); + if (!Array.isArray(vec)) { + throw new Error( + `[agent-runtime] RAG embed sidecar ${url} returned an unparseable embedding body`, + ); + } + return vec as number[]; + }; +} + +/** + * Select the read-side knowledge/RAG adapter (GT-540). + * + * The token-overlap `InMemoryKnowledgeAdapter` (GT-408) is the explicit default + * in EVERY profile. The production pgvector adapter is selected when + * `AGENT_RUNTIME_KNOWLEDGE_MODE=pgvector` — or implicitly when a store URL + * (`EVOLITH_RAG_PG_URL` / `AGENT_RUNTIME_KNOWLEDGE_PG_URL`) is configured. When + * pgvector is selected, the store connection AND the embedder sidecar are + * MANDATORY: the factory FAILS LOUD rather than silently degrading grounded + * retrieval to token-overlap. Mirrors the read-side of the GT-538 store env + * conventions (`EVOLITH_RAG_PG_URL`, `EVOLITH_RAG_EMBED_URL`). + */ +export function resolveKnowledgeAdapter(env: NodeJS.ProcessEnv = process.env): IKnowledgePort { + // Only an EXPLICIT RAG store URL enables pgvector — never the generic + // DATABASE_URL, which commonly points at an unrelated app DB and would + // silently (and wrongly) mis-target grounded retrieval / fail loud. + const pgUrl = env.AGENT_RUNTIME_KNOWLEDGE_PG_URL ?? env.EVOLITH_RAG_PG_URL; + const rawMode = (env.AGENT_RUNTIME_KNOWLEDGE_MODE ?? '').trim().toLowerCase(); + + let mode: 'pgvector' | 'in-memory'; + switch (rawMode) { + case 'pgvector': + mode = 'pgvector'; + break; + case 'in-memory': + case 'inmemory': + case 'memory': + mode = 'in-memory'; + break; + case '': + // Auto: graduate to pgvector only when a store URL is actually configured. + mode = pgUrl ? 'pgvector' : 'in-memory'; + break; + default: + throw new Error( + `[agent-runtime] unknown AGENT_RUNTIME_KNOWLEDGE_MODE '${env.AGENT_RUNTIME_KNOWLEDGE_MODE}'. ` + + "Use 'pgvector' or 'in-memory'.", + ); + } + + if (mode === 'in-memory') { + return new InMemoryKnowledgeAdapter(); + } + + // pgvector selected — connection + embedder are mandatory, fail loud. + if (!pgUrl) { + throw new Error( + '[agent-runtime] AGENT_RUNTIME_KNOWLEDGE_MODE=pgvector requires a store connection ' + + '(AGENT_RUNTIME_KNOWLEDGE_PG_URL or EVOLITH_RAG_PG_URL — the GT-538 pgvector store).', + ); + } + const embedUrl = env.AGENT_RUNTIME_KNOWLEDGE_EMBED_URL ?? env.EVOLITH_RAG_EMBED_URL; + if (!embedUrl) { + throw new Error( + '[agent-runtime] AGENT_RUNTIME_KNOWLEDGE_MODE=pgvector requires an embedder sidecar ' + + '(AGENT_RUNTIME_KNOWLEDGE_EMBED_URL or EVOLITH_RAG_EMBED_URL — the GT-539 Qwen3 sidecar). ' + + 'Refusing to run semantic retrieval without a query embedder.', + ); + } + const embedModel = env.AGENT_RUNTIME_KNOWLEDGE_EMBED_MODEL ?? env.EVOLITH_RAG_EMBED_MODEL; + const corpusVersion = env.AGENT_RUNTIME_KNOWLEDGE_CORPUS_VERSION ?? env.EVOLITH_RAG_CORPUS_VERSION; + + // Dimension is fixed by ADR-0112 §2 (vector(1024)); the adapter asserts it per + // query and fails closed on any mismatch. Referenced here for provenance. + void PGVECTOR_KNOWLEDGE_DIM; + + return new PgVectorKnowledgeAdapter({ + embed: makeSidecarEmbedder(embedUrl, embedModel), + connectionString: pgUrl, + corpusVersion: corpusVersion || undefined, + }); +} + /** Read process.env and assemble the runtime bundle. */ export function createRuntimeFromEnv(env: NodeJS.ProcessEnv = process.env): AgentRuntimeBundle { let overrides: AgentRuntimeOverrides = {}; @@ -250,5 +362,14 @@ export function createRuntimeFromEnv(env: NodeJS.ProcessEnv = process.env): Agen ); } + // Knowledge / RAG read-side (GT-540) — token-overlap in-memory by default in + // every profile; the pgvector production adapter is selected via + // AGENT_RUNTIME_KNOWLEDGE_MODE / a configured store URL. Selecting pgvector + // without a store connection + embedder sidecar fails loud (see selector). + overrides = { + ...overrides, + knowledge: resolveKnowledgeAdapter(env), + }; + return createAgentRuntime(overrides); } diff --git a/src/apps/core-api/src/core-domain.module.ts b/src/apps/core-api/src/core-domain.module.ts index 8e949bfe9..6d5365ef5 100644 --- a/src/apps/core-api/src/core-domain.module.ts +++ b/src/apps/core-api/src/core-domain.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { NodeFileSystemProvider } from '@beyondnet/evolith-infra-providers'; import { NestLoggerProvider } from '@beyondnet/evolith-infra-providers'; import { YamlConfigParserProvider } from '@beyondnet/evolith-infra-providers'; +import { NodeProcessRunner } from '@beyondnet/evolith-infra-providers'; import { EvaluateGateUseCase, @@ -98,7 +99,14 @@ const CoreDomainProviders = [ { provide: RulesetValidatorService, useFactory: (fs: IFileSystem, logger: ILogger, configParser: IConfigParser, rulesetRepo: any, topologyCatalog: TopologyCatalogService) => { - return new RulesetValidatorService({ fileSystem: fs, logger, configParser, rulesetRepo, topologyCatalog }); + // GT-519 parity: register the enforcer subsystem on the REST surface identically to + // CLI/MCP by injecting the real process runner, so `enforce:`-routed rules run their + // external analyzers. Non-forking — without enforcer rules the composite delegates to + // the native/opa strategy, so existing behaviour is preserved. + return new RulesetValidatorService({ + fileSystem: fs, logger, configParser, rulesetRepo, topologyCatalog, + processRunner: new NodeProcessRunner(), + }); }, inject: ['IFileSystem', 'ILogger', 'IConfigParser', 'IRulesetRepository', TopologyCatalogService], }, diff --git a/src/packages/agent-runtime/src/__tests__/public-surface.spec.ts b/src/packages/agent-runtime/src/__tests__/public-surface.spec.ts index 7746939da..ebbed27d5 100644 --- a/src/packages/agent-runtime/src/__tests__/public-surface.spec.ts +++ b/src/packages/agent-runtime/src/__tests__/public-surface.spec.ts @@ -50,8 +50,11 @@ const ADAPTERS_SURFACE = [ 'OpaCliPolicyValidationAdapter', 'OpenCodeInteractionAdapter', 'OpenTelemetryTrackerTraceAdapter', + 'PGVECTOR_KNOWLEDGE_DIM', 'PendingApprovalAdapter', + 'PgVectorKnowledgeAdapter', 'PolicyBasedEngineRouter', + 'RAG_CHUNKS_TABLE', 'RoutingAgentAdapter', 'SlackApprovalAdapter', 'SmartCliChatInteractionAdapter', diff --git a/src/packages/agent-runtime/src/__tests__/structural-review.spec.ts b/src/packages/agent-runtime/src/__tests__/structural-review.spec.ts new file mode 100644 index 000000000..af50dbb21 --- /dev/null +++ b/src/packages/agent-runtime/src/__tests__/structural-review.spec.ts @@ -0,0 +1,210 @@ +/** + * GT-535 — Structural-review rubric → code-quality agent skill + Quality Gate. + * + * Verifies the three acceptance criteria: + * - findings are RANKED by the rubric's severity hierarchy; + * - the emitted Evidence carries `determinism: 'probabilistic'` + provenance/attribution; + * - the deterministic gate maps a critical structural regression to BLOCKING. + */ + +import type { + RawStructuralFinding, + IStructuralReviewer, + StructuralReviewInput, +} from '../domain/ports/structural-reviewer.port'; +import { + StructuralReviewProvider, + STRUCTURAL_REVIEW_PROVIDER_ID, + rankStructuralFindings, + summarizeSeverities, +} from '../application/structural-review-provider'; +import { + evaluateStructuralGate, + decideForSeverity, + DEFAULT_STRUCTURAL_GATE_POLICY, + type StructuralGatePolicy, +} from '../application/structural-quality-gate'; +import { + RUBRIC_ATTRIBUTION, + STRUCTURAL_RUBRIC_VERSION, + STRUCTURAL_STANDARDS, + STRUCTURAL_SEVERITY_RANK, + compareSeverity, +} from '../domain/rubrics/structural-review-rubric'; +import type { CollectionContext, CollectionTarget } from '../domain/ports/quality-signal-provider.port'; + +/** A deterministic stub reviewer standing in for the probabilistic LLM/agent. */ +function stubReviewer(findings: readonly RawStructuralFinding[]): IStructuralReviewer { + return { + review: async (_input: StructuralReviewInput) => findings, + }; +} + +const FIXED_NOW = () => '2026-07-13T00:00:00.000Z'; +const ctx: CollectionContext = { tenantId: 't-1', dimension: 'code-quality' }; +const target: CollectionTarget = { repositoryRef: 'repo@abc123' }; + +describe('structural-review rubric (GT-535)', () => { + it('encodes seven distinct structural standards with a total severity hierarchy', () => { + expect(STRUCTURAL_STANDARDS).toHaveLength(7); + const ids = new Set(STRUCTURAL_STANDARDS.map((s) => s.id)); + expect(ids.size).toBe(7); + // The hierarchy is total and strictly ordered. + expect(STRUCTURAL_SEVERITY_RANK).toEqual({ info: 0, low: 1, medium: 2, high: 3, critical: 4 }); + expect(compareSeverity('critical', 'high')).toBeGreaterThan(0); + expect(compareSeverity('info', 'low')).toBeLessThan(0); + }); + + it('cites the source methodology (attribution respected, not copied)', () => { + expect(RUBRIC_ATTRIBUTION.methodology).toMatch(/structural/i); + expect(RUBRIC_ATTRIBUTION.note).toMatch(/own words/i); + }); +}); + +describe('rankStructuralFindings', () => { + it('ranks findings most-severe first, stable within a severity', () => { + const findings: RawStructuralFinding[] = [ + { standardId: 'file-size-discipline', severity: 'low', message: 'big file' }, + { standardId: 'layering-and-boundaries', severity: 'critical', message: 'domain imports infra' }, + { standardId: 'code-judo', severity: 'medium', message: 'over-engineered' }, + { standardId: 'spaghetti-detection', severity: 'high', message: 'deep nesting' }, + { standardId: 'dead-code-and-scope', severity: 'low', message: 'unused export' }, + ]; + + const ranked = rankStructuralFindings(findings); + + expect(ranked.map((f) => f.severity)).toEqual(['critical', 'high', 'medium', 'low', 'low']); + // Stable tie-break: 'big file' (input index 0) precedes 'unused export' (index 4). + expect(ranked[3].message).toBe('big file'); + expect(ranked[4].message).toBe('unused export'); + // Pure: input not mutated. + expect(findings[0].severity).toBe('low'); + }); +}); + +describe('StructuralReviewProvider.collect', () => { + const findings: RawStructuralFinding[] = [ + { standardId: 'file-size-discipline', severity: 'low', message: 'big file', location: 'a.ts' }, + { standardId: 'layering-and-boundaries', severity: 'critical', message: 'domain imports infra', location: 'b.ts:12' }, + { standardId: 'code-judo', severity: 'medium', message: 'over-engineered', location: 'c.ts' }, + ]; + + it('emits probabilistic Evidence with ranked findings and full provenance/attribution', async () => { + const provider = new StructuralReviewProvider(stubReviewer(findings), { now: FIXED_NOW }); + + const evidence = await provider.collect(target, ctx); + + // Determinism class is probabilistic (LLM/agent-derived) — never deterministic. + expect(evidence.determinism).toBe('probabilistic'); + expect(evidence.dimension).toBe('code-quality'); + expect(evidence.source).toBe('structural-review'); + + // Findings ranked by severity, most-severe first, and coded to the rubric standard. + expect(evidence.findings.map((f) => f.severity)).toEqual(['critical', 'medium', 'low']); + expect(evidence.findings[0].code).toBe('structural-review.layering-and-boundaries'); + + // Mandatory provenance stamped + attributed to this provider and the rubric version. + expect(evidence.provenance.collectedBy).toBe(STRUCTURAL_REVIEW_PROVIDER_ID); + expect(evidence.provenance.adapterVersion).toBe(STRUCTURAL_RUBRIC_VERSION); + expect(evidence.provenance.artifactHash).toMatch(/^[0-9a-f]{64}$/); + expect(evidence.provenance.timestamp).toBe('2026-07-13T00:00:00.000Z'); + + // Metrics summarize the severity distribution + peak rank. + expect(evidence.metrics.findings).toBe(3); + expect(evidence.metrics['severity.critical']).toBe(1); + expect(evidence.metrics.peakSeverityRank).toBe(STRUCTURAL_SEVERITY_RANK.critical); + }); + + it('supports the code-quality dimension and an undeclared dimension', () => { + const provider = new StructuralReviewProvider(stubReviewer([])); + expect(provider.supports({ tenantId: 't-1' })).toBe(true); + expect(provider.supports({ tenantId: 't-1', dimension: 'code-quality' })).toBe(true); + expect(provider.supports({ tenantId: 't-1', dimension: 'performance' })).toBe(false); + }); + + it('emits probabilistic no-finding Evidence when the reviewer is clean', async () => { + const provider = new StructuralReviewProvider(stubReviewer([]), { now: FIXED_NOW }); + const evidence = await provider.collect(target, ctx); + expect(evidence.determinism).toBe('probabilistic'); + expect(evidence.findings).toEqual([]); + expect(evidence.metrics.peakSeverityRank).toBe(0); + }); +}); + +describe('summarizeSeverities', () => { + it('counts per severity and reports the peak rank', () => { + const metrics = summarizeSeverities([ + { standardId: 'code-judo', severity: 'medium', message: 'm' }, + { standardId: 'spaghetti-detection', severity: 'high', message: 'h' }, + { standardId: 'abstraction-quality', severity: 'high', message: 'h2' }, + ]); + expect(metrics['severity.high']).toBe(2); + expect(metrics['severity.medium']).toBe(1); + expect(metrics.peakSeverityRank).toBe(STRUCTURAL_SEVERITY_RANK.high); + }); +}); + +describe('Structural Quality Gate (deterministic severity → decision)', () => { + it('maps a critical structural regression to BLOCKING', async () => { + const provider = new StructuralReviewProvider( + stubReviewer([ + { standardId: 'layering-and-boundaries', severity: 'critical', message: 'domain imports infra' }, + { standardId: 'file-size-discipline', severity: 'low', message: 'big file' }, + ]), + { now: FIXED_NOW }, + ); + const evidence = await provider.collect(target, ctx); + + const result = evaluateStructuralGate([evidence]); + + expect(result.decision).toBe('block'); + expect(result.blocking).toBe(true); + expect(result.peakSeverity).toBe('critical'); + expect(result.triggeringFindings.map((f) => f.code)).toContain('structural-review.layering-and-boundaries'); + }); + + it('is deterministic: same evidence → same decision', () => { + const evidence = { + source: 'structural-review', + dimension: 'code-quality', + metrics: {}, + findings: [{ code: 'structural-review.spaghetti-detection', severity: 'high' as const, message: 'x' }], + determinism: 'probabilistic' as const, + provenance: { collectedBy: 'structural-review', adapterVersion: '1.0.0', artifactHash: 'h', timestamp: 't' }, + }; + const a = evaluateStructuralGate([evidence]); + const b = evaluateStructuralGate([evidence]); + expect(a).toEqual(b); + expect(a.decision).toBe('block'); // high blocks under the default policy. + }); + + it('warns on a medium peak and passes when only low/info findings exist', () => { + const mk = (severity: 'medium' | 'low') => ({ + source: 'structural-review', + dimension: 'code-quality', + metrics: {}, + findings: [{ code: 'structural-review.code-judo', severity, message: 'x' }], + determinism: 'probabilistic' as const, + provenance: { collectedBy: 'structural-review', adapterVersion: '1.0.0', artifactHash: 'h', timestamp: 't' }, + }); + expect(evaluateStructuralGate([mk('medium')]).decision).toBe('warn'); + expect(evaluateStructuralGate([mk('medium')]).blocking).toBe(false); + expect(evaluateStructuralGate([mk('low')]).decision).toBe('pass'); + }); + + it('passes on empty evidence (no findings)', () => { + const result = evaluateStructuralGate([]); + expect(result.decision).toBe('pass'); + expect(result.blocking).toBe(false); + expect(result.triggeringFindings).toEqual([]); + }); + + it('honors a custom policy (stricter thresholds)', () => { + const strict: StructuralGatePolicy = { blockAtOrAbove: 'medium', warnAtOrAbove: 'low' }; + expect(decideForSeverity('medium', strict)).toBe('block'); + expect(decideForSeverity('low', strict)).toBe('warn'); + expect(decideForSeverity('info', strict)).toBe('pass'); + // Default policy is more lenient than the strict one for 'medium'. + expect(decideForSeverity('medium', DEFAULT_STRUCTURAL_GATE_POLICY)).toBe('warn'); + }); +}); diff --git a/src/packages/agent-runtime/src/adapters/index.ts b/src/packages/agent-runtime/src/adapters/index.ts index 455526e02..9d08d33a6 100644 --- a/src/packages/agent-runtime/src/adapters/index.ts +++ b/src/packages/agent-runtime/src/adapters/index.ts @@ -37,6 +37,17 @@ export type { FileMemoryOptions } from './memory/file-memory.adapter'; // Knowledge / RAG (GT-408) export { InMemoryKnowledgeAdapter } from './knowledge/in-memory-knowledge.adapter'; +// Knowledge / RAG production read-side (GT-540 · ADR-0090 / ADR-0112) +export { + PgVectorKnowledgeAdapter, + EXPECTED_DIM as PGVECTOR_KNOWLEDGE_DIM, + RAG_CHUNKS_TABLE, +} from './knowledge/pgvector-knowledge.adapter'; +export type { + EmbedQuery, + PgClientLike, + PgVectorKnowledgeConfig, +} from './knowledge/pgvector-knowledge.adapter'; // Skills export { LocalSkillRegistryAdapter } from './skills/local-skill-registry.adapter'; diff --git a/src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.spec.ts b/src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.spec.ts new file mode 100644 index 000000000..f3779fac1 --- /dev/null +++ b/src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.spec.ts @@ -0,0 +1,178 @@ +import { + PgVectorKnowledgeAdapter, + EXPECTED_DIM, + type PgClientLike, + type EmbedQuery, +} from './pgvector-knowledge.adapter'; + +/** Deterministic 1024-dim stub embedder — no sidecar, no network. */ +const stubEmbed: EmbedQuery = async (text: string) => { + const vec = new Array(EXPECTED_DIM).fill(0); + // Cheap, deterministic fill so different queries differ (not semantic). + for (let i = 0; i < text.length; i++) { + vec[i % EXPECTED_DIM] += text.charCodeAt(i) / 255; + } + return vec; +}; + +/** Records every SQL + params, returns a scripted set of rows. */ +function makeClient(rows: any[]): { + client: PgClientLike; + calls: Array<{ text: string; params: readonly unknown[] }>; +} { + const calls: Array<{ text: string; params: readonly unknown[] }> = []; + const client: PgClientLike = { + async query(text: string, params: readonly unknown[] = []) { + calls.push({ text, params }); + // corpusSize() issues a count(*) query — answer it distinctly. + if (/count\(\*\)/i.test(text)) { + return { rows: [{ n: rows.length }] as any }; + } + return { rows: rows as any }; + }, + }; + return { client, calls }; +} + +const ROW_A = { + id: 'chunk-a', + content: 'ADR-0112 fixes the RAG embedding model to Qwen3 at dimension 1024.', + section_heading: 'ADR-0112 Embedding Platform', + char_start: 40, + char_end: 210, + source_file: 'reference/core/architecture/adrs/core/0112-rag-embedding.md', + adr_id: 'ADR-0112', + language: 'en', + corpus_version: 'c-2026-07-13', + score: 0.91, +}; +const ROW_B = { + id: 'chunk-b', + content: 'The pgvector store uses cosine distance HNSW indexing.', + section_heading: 'Vector Store', + char_start: 0, + char_end: 54, + source_file: 'reference/core/architecture/adrs/core/0112-rag-embedding.md', + adr_id: 'ADR-0112', + language: 'en', + corpus_version: 'c-2026-07-13', + score: 0.77, +}; + +describe('PgVectorKnowledgeAdapter (GT-540)', () => { + it('embeds the query, runs the cosine SQL and maps rows to ranked cited chunks', async () => { + const { client, calls } = makeClient([ROW_A, ROW_B]); + const adapter = new PgVectorKnowledgeAdapter({ embed: stubEmbed, client }); + + const result = await adapter.query({ query: 'embedding model dimension' }); + + // Result contract: ranked chunks with score + citation metadata. + expect(result.chunks).toHaveLength(2); + expect(result.query).toBe('embedding model dimension'); + const first = result.chunks[0]; + expect(first.chunkId).toBe('chunk-a'); + expect(first.score).toBeCloseTo(0.91); + expect(first.sourceFile).toBe(ROW_A.source_file); + expect(first.adrId).toBe('ADR-0112'); + expect(first.sectionHeading).toBe('ADR-0112 Embedding Platform'); + expect(first.charStart).toBe(40); + expect(first.charEnd).toBe(210); + expect(first.textPreview).toBe(ROW_A.content.slice(0, 120)); + expect(first.tokenEstimate).toBeGreaterThan(0); + + // The retrieval SQL used the pgvector cosine operator and `1 - distance`. + const searchCall = calls.find(c => c.text.includes('<=>'))!; + expect(searchCall).toBeDefined(); + expect(searchCall.text).toContain('embedding <=> $1::vector'); + expect(searchCall.text).toContain('1 - (embedding <=> $1::vector) AS score'); + expect(searchCall.text).toContain('ORDER BY embedding <=> $1::vector'); + expect(searchCall.text).toContain('FROM rag_chunks'); + // $1 is the vector literal at the store dimension. + const vecLiteral = searchCall.params[0] as string; + expect(vecLiteral.startsWith('[')).toBe(true); + expect(vecLiteral.split(',')).toHaveLength(EXPECTED_DIM); + }); + + it('translates metadata filters into a parameterized WHERE', async () => { + const { client, calls } = makeClient([ROW_A]); + const adapter = new PgVectorKnowledgeAdapter({ embed: stubEmbed, client }); + + await adapter.query({ + query: 'q', + language: 'en', + adrPrefix: 'ADR-0112', + sourcePrefix: 'reference/core', + maxResults: 3, + }); + + const searchCall = calls.find(c => c.text.includes('<=>'))!; + expect(searchCall.text).toContain('WHERE'); + expect(searchCall.text).toContain('language = $2'); + expect(searchCall.text).toContain('adr_id LIKE $3'); + expect(searchCall.text).toContain('source_file LIKE $4'); + expect(searchCall.text).toContain('LIMIT $5'); + // Params carry the actual values (prefixes get a % wildcard). + expect(searchCall.params[1]).toBe('en'); + expect(searchCall.params[2]).toBe('ADR-0112%'); + expect(searchCall.params[3]).toBe('reference/core%'); + expect(searchCall.params[4]).toBe(3); + }); + + it('scopes reads to a pinned corpus_version when configured', async () => { + const { client, calls } = makeClient([ROW_A]); + const adapter = new PgVectorKnowledgeAdapter({ + embed: stubEmbed, + client, + corpusVersion: 'c-2026-07-13', + }); + + await adapter.query({ query: 'q' }); + + const searchCall = calls.find(c => c.text.includes('<=>'))!; + expect(searchCall.text).toContain('corpus_version = $2'); + expect(searchCall.params[1]).toBe('c-2026-07-13'); + }); + + it('fails closed when the embedder returns a wrong-dimension vector', async () => { + const { client } = makeClient([ROW_A]); + const badEmbed: EmbedQuery = async () => new Array(512).fill(0.1); + const adapter = new PgVectorKnowledgeAdapter({ embed: badEmbed, client }); + + await expect(adapter.query({ query: 'q' })).rejects.toThrow(/!= store dim 1024/); + }); + + it('rejects construction without an embed seam', () => { + expect( + () => new PgVectorKnowledgeAdapter({ embed: undefined as unknown as EmbedQuery, client: makeClient([]).client }), + ).toThrow(/embed/); + }); + + it('getDocument returns ordered chunks with document metadata', async () => { + const { client, calls } = makeClient([ROW_B, ROW_A]); + const adapter = new PgVectorKnowledgeAdapter({ embed: stubEmbed, client }); + + const doc = await adapter.getDocument(ROW_A.source_file); + expect(doc).toBeDefined(); + expect(doc!.sourceFile).toBe(ROW_A.source_file); + expect(doc!.chunks).toHaveLength(2); + expect(doc!.metadata.adrId).toBe('ADR-0112'); + expect(doc!.metadata.language).toBe('en'); + const docCall = calls[0]; + expect(docCall.text).toContain('WHERE source_file = $1'); + expect(docCall.text).toContain('ORDER BY char_start'); + expect(docCall.params[0]).toBe(ROW_A.source_file); + }); + + it('getDocument returns undefined when no rows match', async () => { + const { client } = makeClient([]); + const adapter = new PgVectorKnowledgeAdapter({ embed: stubEmbed, client }); + const doc = await adapter.getDocument('nope.md'); + expect(doc).toBeUndefined(); + }); + + it('corpusSize returns the count(*) result', async () => { + const { client } = makeClient([ROW_A, ROW_B]); + const adapter = new PgVectorKnowledgeAdapter({ embed: stubEmbed, client }); + expect(await adapter.corpusSize()).toBe(2); + }); +}); diff --git a/src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.ts b/src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.ts new file mode 100644 index 000000000..0f7b1bea1 --- /dev/null +++ b/src/packages/agent-runtime/src/adapters/knowledge/pgvector-knowledge.adapter.ts @@ -0,0 +1,275 @@ +import type { + IKnowledgePort, + KnowledgeQuery, + KnowledgeResult, + KnowledgeDocument, + KnowledgeChunk, +} from '../../domain/ports/knowledge.port'; + +/** + * GT-540 / ADR-0090 / ADR-0112 — Production `IKnowledgePort` retrieval adapter. + * + * The RUNTIME READ-SIDE of the RAG stack. It runs a cosine top-k search over + * the durable pgvector index created by the write-side (GT-538: `rag_chunks`, + * `embedding vector(1024)`, HNSW `vector_cosine_ops`) and returns ranked, + * fully-cited `KnowledgeChunk[]` so agents can ground recommendations over the + * ADR/ruleset corpus instead of the token-overlap `InMemoryKnowledgeAdapter`. + * + * Hexagonal edges — nothing here is hard-wired: + * + * 1. EMBEDDING is an INJECTED seam (`EmbedQuery`): `(text) => Promise`. + * The adapter never picks a model — production wires GT-539's Qwen3 sidecar + * embedder, tests inject a deterministic stub. The port stays model-agnostic + * (ADR-0090 §3). The returned vector MUST be exactly `EXPECTED_DIM` (1024, + * ADR-0112 §2) — a mismatch is not cosine-comparable with the store, so the + * adapter FAILS CLOSED rather than returning meaningless rankings. + * + * 2. The database CLIENT is an INJECTED node-postgres-shaped seam + * (`{ query(text, params) }`). There is NO compile-time `pg` dependency: + * only a real run with no injected client lazy-imports `pg` (`await import`) + * and builds a Pool from `config.connectionString` / env. Tests inject a + * minimal fake client and never touch a live Postgres. + * + * Retrieval SQL uses the pgvector cosine-distance operator `<=>`; the similarity + * `score` is `1 - distance` in [0,1]. Metadata filters (ADR-0090 §2: + * language / adr_id / source_file / corpus_version) are plain parameterized + * WHERE clauses over the first-class columns. + * + * The live pgvector + live Qwen3 sidecar run is DEPLOY-GATED (needs Postgres + + * the on-perimeter inference sidecar); this adapter is the code path that runs + * once those are provisioned. + */ + +/** ADR-0112 §2 — the pgvector column is `vector(1024)`; enforced fail-closed. */ +export const EXPECTED_DIM = 1024; + +/** Chunk table name — matches the GT-538 DDL (`rag-pgvector.schema.sql`). */ +export const RAG_CHUNKS_TABLE = 'rag_chunks'; + +/** + * Injected embedding seam. Embeds a single query string into a dense vector at + * the store dimension. Production wires GT-539's Qwen3 sidecar embedder; tests + * inject a deterministic stub. Model choice lives at the wiring edge, never here. + */ +export type EmbedQuery = (text: string) => Promise; + +/** Minimal node-postgres-shaped client seam (no compile-time `pg` dependency). */ +export interface PgClientLike { + query(text: string, params?: readonly unknown[]): Promise<{ rows: PgChunkRow[] }>; +} + +/** Shape of a `rag_chunks` row plus the computed `score` column. */ +interface PgChunkRow { + id: string; + content: string | null; + section_heading: string | null; + char_start: number | null; + char_end: number | null; + source_file: string; + adr_id: string | null; + language: string; + corpus_version: string; + score?: number | string | null; +} + +export interface PgVectorKnowledgeConfig { + /** Injected query embedder (required — the port is model-agnostic). */ + readonly embed: EmbedQuery; + /** + * Injected node-postgres-shaped client. When omitted, the adapter lazy-imports + * `pg` at first use and builds a Pool from `connectionString` / env. + */ + readonly client?: PgClientLike; + /** Postgres connection string used only when no `client` is injected. */ + readonly connectionString?: string; + /** + * Optional corpus-version pin. When set, every read is scoped to this corpus + * release (`corpus_version = $n`) so a deployment retrieves against exactly the + * index snapshot it was built for (ADR-0090 §2 / ADR-0112 §1). + */ + readonly corpusVersion?: string; + /** Default result count when a query omits `maxResults`. */ + readonly defaultMaxResults?: number; +} + +/** pgvector text literal for a numeric vector: `[0.1,0.2,...]`. */ +function toVectorLiteral(vec: readonly number[]): string { + return `[${vec.join(',')}]`; +} + +function estimateTokens(text: string): number { + // Coarse ~4 chars/token estimate; the port field is documented as an estimate. + return Math.max(1, Math.ceil(text.length / 4)); +} + +function rowToChunk(row: PgChunkRow): KnowledgeChunk { + const text = row.content ?? ''; + const rawScore = row.score; + const score = + rawScore === null || rawScore === undefined ? undefined : Number(rawScore); + return { + chunkId: row.id, + sourceFile: row.source_file, + sectionHeading: row.section_heading ?? '', + adrId: row.adr_id ?? null, + language: row.language, + tokenEstimate: estimateTokens(text), + textPreview: text.slice(0, 120), + text, + score, + charStart: row.char_start ?? undefined, + charEnd: row.char_end ?? undefined, + }; +} + +export class PgVectorKnowledgeAdapter implements IKnowledgePort { + private readonly embed: EmbedQuery; + private readonly corpusVersion?: string; + private readonly defaultMaxResults: number; + private readonly injectedClient?: PgClientLike; + private readonly connectionString?: string; + private clientPromise: Promise | null = null; + + constructor(config: PgVectorKnowledgeConfig) { + if (typeof config.embed !== 'function') { + throw new Error( + '[pgvector-knowledge] an `embed` seam is required — inject GT-539 Qwen3 (prod) or a stub (tests).', + ); + } + this.embed = config.embed; + this.injectedClient = config.client; + this.connectionString = config.connectionString; + this.corpusVersion = config.corpusVersion; + this.defaultMaxResults = config.defaultMaxResults ?? 10; + } + + /** Resolve the db client — injected wins; otherwise lazy-import `pg` once. */ + private getClient(): Promise { + if (this.injectedClient) return Promise.resolve(this.injectedClient); + if (!this.clientPromise) this.clientPromise = this.buildPgClient(); + return this.clientPromise; + } + + private async buildPgClient(): Promise { + const connectionString = + this.connectionString ?? + process.env.EVOLITH_RAG_PG_URL ?? + process.env.DATABASE_URL; + // Lazy — `pg` is never imported at module load, keeping the offline/test + // default free of a native dependency (design rule #5). + const pg: any = await import('pg'); + const Pool = pg.default?.Pool ?? pg.Pool; + if (typeof Pool !== 'function') { + throw new Error('[pgvector-knowledge] could not resolve a pg Pool constructor'); + } + return new Pool(connectionString ? { connectionString } : {}) as PgClientLike; + } + + /** Embed the query text and assert the store dimension (fail closed). */ + private async embedQuery(text: string): Promise { + const vec = await this.embed(text); + if (!Array.isArray(vec) || vec.length !== EXPECTED_DIM) { + throw new Error( + `[pgvector-knowledge] query embedding dimension ${ + Array.isArray(vec) ? vec.length : typeof vec + } != store dim ${EXPECTED_DIM} (ADR-0112 §2 — fail closed).`, + ); + } + return vec; + } + + async query(request: KnowledgeQuery): Promise { + const maxResults = request.maxResults ?? this.defaultMaxResults; + const vec = await this.embedQuery(request.query); + + // $1 is always the query vector; filter values follow, LIMIT is last. + const params: unknown[] = [toVectorLiteral(vec)]; + const where: string[] = []; + + if (request.language) { + params.push(request.language); + where.push(`language = $${params.length}`); + } + if (request.adrPrefix) { + params.push(`${request.adrPrefix}%`); + where.push(`adr_id LIKE $${params.length}`); + } + if (request.sourcePrefix) { + params.push(`${request.sourcePrefix}%`); + where.push(`source_file LIKE $${params.length}`); + } + if (this.corpusVersion) { + params.push(this.corpusVersion); + where.push(`corpus_version = $${params.length}`); + } + + const whereSql = where.length > 0 ? `WHERE ${where.join(' AND ')}` : ''; + params.push(maxResults); + const limitPlaceholder = `$${params.length}`; + + // Cosine distance operator `<=>` (HNSW vector_cosine_ops); score = 1 - dist. + const sql = + `SELECT id, content, section_heading, char_start, char_end, source_file, ` + + `adr_id, language, corpus_version, ` + + `1 - (embedding <=> $1::vector) AS score ` + + `FROM ${RAG_CHUNKS_TABLE} ` + + `${whereSql} ` + + `ORDER BY embedding <=> $1::vector ASC ` + + `LIMIT ${limitPlaceholder}`; + + const client = await this.getClient(); + const { rows } = await client.query(sql, params); + const chunks = rows.map(rowToChunk); + + return { + chunks, + totalChunks: await this.corpusSize(), + query: request.query, + }; + } + + async getDocument(sourceFile: string): Promise { + const params: unknown[] = [sourceFile]; + let corpusClause = ''; + if (this.corpusVersion) { + params.push(this.corpusVersion); + corpusClause = ` AND corpus_version = $${params.length}`; + } + const sql = + `SELECT id, content, section_heading, char_start, char_end, source_file, ` + + `adr_id, language, corpus_version ` + + `FROM ${RAG_CHUNKS_TABLE} ` + + `WHERE source_file = $1${corpusClause} ` + + `ORDER BY char_start ASC NULLS FIRST`; + + const client = await this.getClient(); + const { rows } = await client.query(sql, params); + if (rows.length === 0) return undefined; + + const chunks = rows.map(rowToChunk); + return { + sourceFile, + chunks, + metadata: { + adrId: chunks[0].adrId ?? undefined, + language: chunks[0].language, + }, + }; + } + + async corpusSize(): Promise { + const params: unknown[] = []; + let whereSql = ''; + if (this.corpusVersion) { + params.push(this.corpusVersion); + whereSql = ` WHERE corpus_version = $1`; + } + const client = await this.getClient(); + const { rows } = await client.query( + `SELECT count(*)::int AS n FROM ${RAG_CHUNKS_TABLE}${whereSql}`, + params, + ); + const n = (rows[0] as unknown as { n?: number } | undefined)?.n; + return typeof n === 'number' ? n : Number(n ?? 0); + } +} diff --git a/src/packages/agent-runtime/src/adapters/skills/default-skills.ts b/src/packages/agent-runtime/src/adapters/skills/default-skills.ts index fc02018e1..8f96bbc14 100644 --- a/src/packages/agent-runtime/src/adapters/skills/default-skills.ts +++ b/src/packages/agent-runtime/src/adapters/skills/default-skills.ts @@ -84,6 +84,24 @@ export const DEFAULT_SKILLS: readonly SkillDescriptor[] = [ emitsTrace: true, requiresPolicy: false, }, + { + id: 'code-quality-structural-review', + description: + 'Review code against the structural-review rubric (code-judo, file-size, ' + + 'spaghetti/abstraction/layering, dead-code) and emit findings ranked by the ' + + 'rubric severity hierarchy as probabilistic Evidence for the Quality Gate.', + intents: ['structural_review', 'code_quality_review', 'review_structure'], + // Orchestration runs the probabilistic reviewer behind IStructuralReviewer and + // hands the normalized Evidence to the Core as a code-quality signal (GT-535). + kind: 'evaluation', + evaluationKinds: ['code-quality'], + permissions: ['read:repo'], + requiresApproval: false, + emitsTrace: true, + // The structural regression gate mapping is deterministic policy (severity→decision). + requiresPolicy: true, + policyRef: 'evolith.structural_review', + }, { id: 'publish-trace-event', description: 'Publish a trazability event to Evolith Tracker.', diff --git a/src/packages/agent-runtime/src/application/agent-runtime.service.ts b/src/packages/agent-runtime/src/application/agent-runtime.service.ts index e5e57d091..3d1351ff8 100644 --- a/src/packages/agent-runtime/src/application/agent-runtime.service.ts +++ b/src/packages/agent-runtime/src/application/agent-runtime.service.ts @@ -26,6 +26,7 @@ import type { ITrackerTracePort } from '../domain/ports/tracker-trace.port'; import type { IMemoryPort } from '../domain/ports/memory.port'; import type { IApprovalPort } from '../domain/ports/approval.port'; import type { IAgentEnginePort } from '../domain/ports/agent-engine.port'; +import type { IKnowledgePort } from '../domain/ports/knowledge.port'; import type { AgentRuntimeRequest } from '../domain/contracts/agent-runtime-request'; import type { @@ -61,6 +62,12 @@ export interface AgentRuntimeDeps { readonly approval: IApprovalPort; /** Optional reasoning engine (Hermes adapter, stub, or none). */ readonly engine?: IAgentEnginePort; + /** + * Optional read-side knowledge/RAG port (GT-408 in-memory default, GT-540 + * pgvector in production). Carried on the bundle so grounded retrieval is + * reachable; the base flow itself does not depend on it. + */ + readonly knowledge?: IKnowledgePort; /** Injected clock for deterministic tests. */ readonly now?: () => string; /** Injected id generator for deterministic tests. */ diff --git a/src/packages/agent-runtime/src/application/index.ts b/src/packages/agent-runtime/src/application/index.ts index c38032709..80b407ff3 100644 --- a/src/packages/agent-runtime/src/application/index.ts +++ b/src/packages/agent-runtime/src/application/index.ts @@ -2,3 +2,5 @@ export * from './agent-runtime.service'; export * from './context-mapper'; export * from './result-assembler'; export * from './quality-signal-registry'; +export * from './structural-review-provider'; +export * from './structural-quality-gate'; diff --git a/src/packages/agent-runtime/src/application/structural-quality-gate.ts b/src/packages/agent-runtime/src/application/structural-quality-gate.ts new file mode 100644 index 000000000..6ddd754f1 --- /dev/null +++ b/src/packages/agent-runtime/src/application/structural-quality-gate.ts @@ -0,0 +1,112 @@ +/** + * Structural Quality Gate (GT-535 · ADR-0111) — the rubric hook that lets a + * structural regression be treated as BLOCKING. + * + * The findings are probabilistic (LLM-derived), but the gate mapping + * severity → decision is PURE and DETERMINISTIC: the same evidence always yields the + * same gate result. A policy declares two thresholds on the canonical severity + * hierarchy — at/above `blockAtOrAbove` blocks, at/above `warnAtOrAbove` (but below + * block) warns, everything else passes. The default policy makes a `critical` + * structural regression blocking. + * + * This is the deterministic seam a Quality Gate/policy engine calls: it reads + * canonical {@link Evidence} (as emitted by {@link StructuralReviewProvider}) — it + * does NOT run any provider or model itself. + */ + +import type { + Evidence, + EvidenceFinding, + EvidenceFindingSeverity, +} from '@beyondnet/evolith-core-domain/evaluation/contracts'; + +import { compareSeverity, STRUCTURAL_SEVERITY_RANK } from '../domain/rubrics/structural-review-rubric'; + +/** The gate outcome. `block` is the only regression-blocking verdict. */ +export type GateDecision = 'block' | 'warn' | 'pass'; + +/** Deterministic severity → decision policy (the rubric hook's configuration). */ +export interface StructuralGatePolicy { + /** A finding at/above this severity BLOCKS. */ + readonly blockAtOrAbove: EvidenceFindingSeverity; + /** A finding at/above this severity (but below block) WARNS. */ + readonly warnAtOrAbove: EvidenceFindingSeverity; +} + +/** + * Default policy: a `critical` OR `high` structural regression blocks; `medium` + * warns; `low`/`info` pass. Chosen so boundary/layering and spaghetti breaks (the + * rubric's `critical`/`high` defaults) are blocking out of the box. + */ +export const DEFAULT_STRUCTURAL_GATE_POLICY: StructuralGatePolicy = { + blockAtOrAbove: 'high', + warnAtOrAbove: 'medium', +}; + +/** The gate's result — deterministic and fully explained for audit. */ +export interface StructuralGateResult { + readonly decision: GateDecision; + /** Whether the decision blocks a merge/promotion. */ + readonly blocking: boolean; + /** The peak severity observed across the evidence (undefined when no findings). */ + readonly peakSeverity?: EvidenceFindingSeverity; + /** The findings at/above the decision's severity floor (empty on `pass`). */ + readonly triggeringFindings: readonly EvidenceFinding[]; + /** The policy that produced the decision. */ + readonly policy: StructuralGatePolicy; +} + +/** Map a single severity to a decision under a policy (the pure core mapping). */ +export function decideForSeverity( + severity: EvidenceFindingSeverity, + policy: StructuralGatePolicy = DEFAULT_STRUCTURAL_GATE_POLICY, +): GateDecision { + if (compareSeverity(severity, policy.blockAtOrAbove) >= 0) return 'block'; + if (compareSeverity(severity, policy.warnAtOrAbove) >= 0) return 'warn'; + return 'pass'; +} + +/** + * Evaluate the structural quality gate over the code-quality/structural findings of + * the supplied Evidence. Deterministic: the peak severity determines the decision, + * and the triggering findings are those at/above that decision's severity floor. + * + * Only findings whose `code` is a structural-review finding are considered when + * `source`-scoping is desired; by default this reads every finding of the passed + * evidence, since the caller is expected to pass the structural evidence. + */ +export function evaluateStructuralGate( + evidence: readonly Evidence[], + policy: StructuralGatePolicy = DEFAULT_STRUCTURAL_GATE_POLICY, +): StructuralGateResult { + const findings = evidence.flatMap((e) => e.findings); + + if (findings.length === 0) { + return { decision: 'pass', blocking: false, triggeringFindings: [], policy }; + } + + const peakSeverity = findings.reduce( + (peak, f) => (compareSeverity(f.severity, peak) > 0 ? f.severity : peak), + 'info', + ); + + const decision = decideForSeverity(peakSeverity, policy); + + // The severity floor of the decision: what counts as "triggering" this verdict. + const floor: EvidenceFindingSeverity | undefined = + decision === 'block' ? policy.blockAtOrAbove : decision === 'warn' ? policy.warnAtOrAbove : undefined; + + const triggeringFindings = + floor === undefined ? [] : findings.filter((f) => compareSeverity(f.severity, floor) >= 0); + + return { + decision, + blocking: decision === 'block', + peakSeverity, + triggeringFindings, + policy, + }; +} + +/** Re-exported for callers that want the raw ranking table. */ +export { STRUCTURAL_SEVERITY_RANK }; diff --git a/src/packages/agent-runtime/src/application/structural-review-provider.ts b/src/packages/agent-runtime/src/application/structural-review-provider.ts new file mode 100644 index 000000000..a18743225 --- /dev/null +++ b/src/packages/agent-runtime/src/application/structural-review-provider.ts @@ -0,0 +1,197 @@ +/** + * StructuralReviewProvider (GT-535 · ADR-0111) — the code-quality-review agent's + * structural pass behind the Quality Signal Provider seam. + * + * It runs the probabilistic {@link IStructuralReviewer} (an LLM/agent judging code + * against the seven structural standards), RANKS the raw findings by the rubric's + * severity hierarchy, and emits ONE normalized canonical {@link Evidence} with + * `determinism: 'probabilistic'` and mandatory provenance/attribution to the source + * methodology ({@link RUBRIC_ATTRIBUTION}). The runtime passes that Evidence INLINE + * to the Core (`EvaluationContext.qualitySignals`); the Core never runs this provider + * (ADR-0111 §1/§3). + * + * Structurally implements the agent-runtime `IQualitySignalProvider` port so the + * {@link TenantQualitySignalRegistry} can register it like any other provider. + */ + +import { createHash } from 'node:crypto'; + +import { + normalizeEvidence, + type Evidence, + type EvidenceFinding, + type EvidenceFindingSeverity, +} from '@beyondnet/evolith-core-domain/evaluation/contracts'; + +import type { + CollectionContext, + CollectionTarget, + IQualitySignalProvider, +} from '../domain/ports/quality-signal-provider.port'; +import type { + IStructuralReviewer, + RawStructuralFinding, + StructuralReviewInput, +} from '../domain/ports/structural-reviewer.port'; +import { + compareSeverity, + RUBRIC_ATTRIBUTION, + STRUCTURAL_RUBRIC_VERSION, + STRUCTURAL_REVIEW_SOURCE, + STRUCTURAL_SEVERITY_RANK, +} from '../domain/rubrics/structural-review-rubric'; + +/** Stable registry id for this provider (ADR-0111 §4). */ +export const STRUCTURAL_REVIEW_PROVIDER_ID = 'structural-review'; + +/** Dimensions this provider serves behind the port. */ +const SUPPORTED_DIMENSIONS: ReadonlySet = new Set([ + 'code-quality', + 'structure', + 'maintainability', +]); + +/** The dimension the emitted Evidence is tagged with when none is requested. */ +const DEFAULT_DIMENSION = 'code-quality'; + +export interface StructuralReviewProviderOptions { + /** Clock for the provenance timestamp fallback (deterministic in tests). */ + readonly now?: () => string; +} + +/** + * Rank raw findings by the rubric's severity hierarchy, most severe FIRST. Stable: + * findings of equal severity keep their input order (so the reviewer's own ordering + * is preserved as a tie-break). Returns a NEW array; does not mutate the input. + */ +export function rankStructuralFindings( + findings: readonly RawStructuralFinding[], +): RawStructuralFinding[] { + return findings + .map((finding, index) => ({ finding, index })) + .sort((a, b) => { + const bySeverity = compareSeverity(b.finding.severity, a.finding.severity); + return bySeverity !== 0 ? bySeverity : a.index - b.index; + }) + .map((entry) => entry.finding); +} + +/** + * Structural-review provider. Injects a probabilistic {@link IStructuralReviewer}; + * everything else (ranking, metrics, provenance) is pure and deterministic. + */ +export class StructuralReviewProvider implements IQualitySignalProvider { + public readonly id = STRUCTURAL_REVIEW_PROVIDER_ID; + + private readonly reviewer: IStructuralReviewer; + private readonly now: () => string; + + constructor(reviewer: IStructuralReviewer, options: StructuralReviewProviderOptions = {}) { + this.reviewer = reviewer; + this.now = options.now ?? (() => new Date().toISOString()); + } + + /** Serves the code-quality/structure dimensions; an undeclared dimension is served too. */ + supports(ctx: CollectionContext): boolean { + if (ctx.dimension === undefined) return true; + return SUPPORTED_DIMENSIONS.has(ctx.dimension); + } + + /** + * Run the reviewer, rank the findings by severity, and emit ONE `probabilistic` + * canonical {@link Evidence}. Metrics summarize the severity distribution plus the + * peak severity rank so downstream policy can weight without re-parsing findings. + * Provenance attributes the signal to this provider and the rubric version; the + * source methodology is cited via {@link RUBRIC_ATTRIBUTION}. + */ + async collect(target: CollectionTarget, ctx: CollectionContext): Promise { + const input: StructuralReviewInput = { + repositoryRef: target.repositoryRef, + files: readFilesFromConfig(target.config), + hints: ctx.hints, + }; + + const raw = await this.reviewer.review(input); + const ranked = rankStructuralFindings(raw); + + const findings: EvidenceFinding[] = ranked.map((f) => ({ + code: `${STRUCTURAL_REVIEW_SOURCE}.${f.standardId}`, + severity: f.severity, + message: f.message, + location: f.location, + })); + + const metrics = summarizeSeverities(ranked); + + // Attribution + rubric version participate in the tamper-evidence hash so the + // provenance of a probabilistic signal is auditable end-to-end (ADR-0111 §6). + const artifactHash = createHash('sha256') + .update( + JSON.stringify({ + rubric: STRUCTURAL_RUBRIC_VERSION, + attribution: RUBRIC_ATTRIBUTION, + findings: ranked, + }), + ) + .digest('hex'); + + return normalizeEvidence( + { + source: STRUCTURAL_REVIEW_SOURCE, + dimension: ctx.dimension ?? DEFAULT_DIMENSION, + metrics, + findings, + determinism: 'probabilistic', // LLM/agent-derived — never deterministic. + provenance: { + collectedBy: STRUCTURAL_REVIEW_PROVIDER_ID, + adapterVersion: STRUCTURAL_RUBRIC_VERSION, + artifactHash, + // timestamp defaults via normalize's clock when omitted. + }, + }, + { now: this.now }, + ); + } +} + +/** Read the optional inline files a tenant config may forward to the reviewer. */ +function readFilesFromConfig( + config: Readonly> | undefined, +): readonly { readonly path: string; readonly content: string }[] | undefined { + const files = config?.files; + if (!Array.isArray(files)) return undefined; + return files.filter( + (f): f is { path: string; content: string } => + typeof f === 'object' && f !== null && typeof (f as any).path === 'string' && typeof (f as any).content === 'string', + ); +} + +/** + * Summarize a ranked finding set into numeric metrics: a count per severity plus the + * peak severity rank (0..4) and the total. Pure and deterministic. + */ +export function summarizeSeverities( + findings: readonly RawStructuralFinding[], +): Record { + const counts: Record = { + info: 0, + low: 0, + medium: 0, + high: 0, + critical: 0, + }; + let peakRank = 0; + for (const f of findings) { + counts[f.severity] += 1; + peakRank = Math.max(peakRank, STRUCTURAL_SEVERITY_RANK[f.severity]); + } + return { + findings: findings.length, + 'severity.info': counts.info, + 'severity.low': counts.low, + 'severity.medium': counts.medium, + 'severity.high': counts.high, + 'severity.critical': counts.critical, + peakSeverityRank: findings.length > 0 ? peakRank : 0, + }; +} diff --git a/src/packages/agent-runtime/src/bootstrap.ts b/src/packages/agent-runtime/src/bootstrap.ts index 60a7270d6..e16945b12 100644 --- a/src/packages/agent-runtime/src/bootstrap.ts +++ b/src/packages/agent-runtime/src/bootstrap.ts @@ -31,6 +31,7 @@ import { InMemoryMemoryAdapter } from './adapters/memory/in-memory-memory.adapte import { LocalSkillRegistryAdapter } from './adapters/skills/local-skill-registry.adapter'; import { PendingApprovalAdapter } from './adapters/approval/pending-approval.adapter'; import { StubAgentEngineAdapter } from './adapters/engine/stub-agent-engine.adapter'; +import { InMemoryKnowledgeAdapter } from './adapters/knowledge/in-memory-knowledge.adapter'; import { HermesAgentAdapter } from './adapters/engine/hermes-agent.adapter'; import { SwarmsAgentAdapter } from './adapters/engine/swarms-agent.adapter'; import { RoutingAgentAdapter, type EngineRouterConfig } from './adapters/engine/routing-agent.adapter'; @@ -71,6 +72,9 @@ export function createAgentRuntime(overrides: AgentRuntimeOverrides = {}): Agent // Fail-closed real HITL gate by default (GT-441): nothing is silently // auto-granted. Opt into AutoApprovalAdapter explicitly for dev/tests. approval: overrides.approval ?? new PendingApprovalAdapter(), + // Read-side knowledge/RAG port: token-overlap in-memory default (GT-408), + // overridable to the pgvector production adapter (GT-540). + knowledge: overrides.knowledge ?? new InMemoryKnowledgeAdapter(), engine, now: overrides.now, id: overrides.id, diff --git a/src/packages/agent-runtime/src/domain/index.ts b/src/packages/agent-runtime/src/domain/index.ts index 210382a07..9f77f6dd8 100644 --- a/src/packages/agent-runtime/src/domain/index.ts +++ b/src/packages/agent-runtime/src/domain/index.ts @@ -1,3 +1,4 @@ export * from './contracts/index'; export * from './ports/index'; +export * from './rubrics/structural-review-rubric'; export * from './tokens'; diff --git a/src/packages/agent-runtime/src/domain/ports/index.ts b/src/packages/agent-runtime/src/domain/ports/index.ts index eb10b2749..0aed02283 100644 --- a/src/packages/agent-runtime/src/domain/ports/index.ts +++ b/src/packages/agent-runtime/src/domain/ports/index.ts @@ -12,3 +12,4 @@ export * from './approval.port'; export * from './agent-engine.port'; export * from './knowledge.port'; export * from './quality-signal-provider.port'; +export * from './structural-reviewer.port'; diff --git a/src/packages/agent-runtime/src/domain/ports/knowledge.port.ts b/src/packages/agent-runtime/src/domain/ports/knowledge.port.ts index 71a2af852..a73b1a86b 100644 --- a/src/packages/agent-runtime/src/domain/ports/knowledge.port.ts +++ b/src/packages/agent-runtime/src/domain/ports/knowledge.port.ts @@ -32,6 +32,14 @@ export interface KnowledgeChunk { readonly text: string; /** Similarity score (0-1) if returned from a semantic search. */ readonly score?: number; + /** + * Start character offset of the chunk within its source file, when the + * write-side store records it (GT-538 `rag_chunks.char_start`). Optional so + * offline/token adapters that don't track offsets stay contract-compliant. + */ + readonly charStart?: number; + /** End character offset within the source file (GT-538 `rag_chunks.char_end`). */ + readonly charEnd?: number; } /** diff --git a/src/packages/agent-runtime/src/domain/ports/structural-reviewer.port.ts b/src/packages/agent-runtime/src/domain/ports/structural-reviewer.port.ts new file mode 100644 index 000000000..16ddd8508 --- /dev/null +++ b/src/packages/agent-runtime/src/domain/ports/structural-reviewer.port.ts @@ -0,0 +1,47 @@ +/** + * IStructuralReviewer — the driven port that produces raw structural findings for + * the structural-review rubric (GT-535). This is the PROBABILISTIC edge: a concrete + * adapter is LLM-backed (an agent judging code against the seven standards), which + * is why the emitted {@link Evidence} carries `determinism: 'probabilistic'`. + * + * Owned by the orchestration layer (agent-runtime), NEVER by core-domain. Keeping the + * LLM/agent behind this port keeps {@link StructuralReviewProvider} pure of model I/O + * so it can be unit-tested with a deterministic stub reviewer, and lets any engine + * (Hermes, a local model, a human) back the same rubric without changing callers. + */ + +import type { EvidenceFindingSeverity } from '@beyondnet/evolith-core-domain/evaluation/contracts'; + +import type { StructuralStandardId } from '../rubrics/structural-review-rubric'; + +/** What the reviewer should judge — a code target, not source the Core stores. */ +export interface StructuralReviewInput { + /** Repository/commit reference the review runs against. */ + readonly repositoryRef?: string; + /** Optional inline files (path + content) for the reviewer to inspect. */ + readonly files?: readonly { readonly path: string; readonly content: string }[]; + /** Free-form, reviewer-interpreted hints (never reach the Core). */ + readonly hints?: Readonly>; +} + +/** + * One raw structural finding as judged by the reviewer, keyed to a rubric standard. + * `severity` is the reviewer's assessment of THIS instance (it may differ from the + * standard's default). The provider ranks and normalizes these into canonical Evidence. + */ +export interface RawStructuralFinding { + /** Which of the seven structural standards this finding violates. */ + readonly standardId: StructuralStandardId; + /** Severity on the canonical hierarchy (reviewer's assessment of this instance). */ + readonly severity: EvidenceFindingSeverity; + /** Human-readable explanation of the violation. */ + readonly message: string; + /** Optional pointer (path:line, symbol) — a reference, never a copy. */ + readonly location?: string; +} + +/** A probabilistic (LLM/agent-backed) reviewer of the structural rubric. */ +export interface IStructuralReviewer { + /** Judge the target against the seven structural standards; return raw findings. */ + review(input: StructuralReviewInput): Promise; +} diff --git a/src/packages/agent-runtime/src/domain/rubrics/structural-review-rubric.ts b/src/packages/agent-runtime/src/domain/rubrics/structural-review-rubric.ts new file mode 100644 index 000000000..1b0d38fd0 --- /dev/null +++ b/src/packages/agent-runtime/src/domain/rubrics/structural-review-rubric.ts @@ -0,0 +1,164 @@ +/** + * Structural-review rubric (GT-535 · ADR-0111 / builds on GT-533). + * + * A strict, auditable STRUCTURAL code-review methodology encoded as data so both + * the code-quality-review skill and the Quality Gate share ONE source of truth. + * It names seven structural standards and a single, total SEVERITY HIERARCHY, so + * findings can be RANKED deterministically and the gate can map severity → a + * block/warn decision (the mapping is pure even though the findings themselves are + * LLM-derived and therefore `probabilistic`). + * + * Attribution (respected per ADR-0111 §6): this rubric is INSPIRED BY the widely + * shared "structural / thermo-nuclear" code-review methodology popularized in the + * Cursor community (code-judo, file-size discipline, spaghetti/abstraction/layering + * checks, an explicit severity hierarchy). The seven standards below are RE-EXPRESSED + * in our own words — no proprietary text is copied verbatim. See {@link RUBRIC_ATTRIBUTION}. + * + * Layering: PURE domain. No I/O, no LLM, no provider knowledge — the probabilistic + * collection happens behind the `IStructuralReviewer` port (application layer). This + * module only DEFINES the standards, the ranking, and the attribution. + */ + +import type { EvidenceFindingSeverity } from '@beyondnet/evolith-core-domain/evaluation/contracts'; + +/** Stable id for the evidence `source`/provider (ADR-0111 §4). */ +export const STRUCTURAL_REVIEW_SOURCE = 'structural-review'; + +/** Version of THIS rubric encoding (stamped into provenance.adapterVersion). */ +export const STRUCTURAL_RUBRIC_VERSION = '1.0.0'; + +/** + * Attribution for the source methodology (ADR-0111 §6). Kept as data so it can be + * surfaced in audits and never lost. We cite the methodology and re-express it in + * our own words rather than copying any proprietary rubric text. + */ +export const RUBRIC_ATTRIBUTION = { + /** Human-readable name of the methodology this rubric encodes. */ + methodology: 'Structural ("thermo-nuclear") code-review methodology', + /** Where the methodology comes from (community-shared, Cursor ecosystem). */ + origin: 'Cursor community structural-review rubric (community-shared)', + /** How we relate to it: re-expressed, not copied. */ + note: 'Standards re-expressed in our own words; no proprietary text reproduced.', +} as const; + +/** The seven structural standards, by stable id. */ +export type StructuralStandardId = + | 'code-judo' + | 'file-size-discipline' + | 'spaghetti-detection' + | 'abstraction-quality' + | 'layering-and-boundaries' + | 'duplication-and-dry' + | 'dead-code-and-scope'; + +/** One structural standard in the rubric. */ +export interface StructuralStandard { + readonly id: StructuralStandardId; + /** Short human name. */ + readonly name: string; + /** What the standard checks for, in our own words. */ + readonly description: string; + /** + * The DEFAULT severity a violation of this standard carries. A concrete finding + * MAY override it (the reviewer decides how bad THIS instance is), but the default + * anchors the standard's baseline weight in the hierarchy. + */ + readonly defaultSeverity: EvidenceFindingSeverity; +} + +/** + * The rubric: seven structural standards, re-expressed from the source methodology. + * Order here is declaration order, NOT severity — severity is a separate, total + * hierarchy ({@link STRUCTURAL_SEVERITY_RANK}). + */ +export const STRUCTURAL_STANDARDS: readonly StructuralStandard[] = [ + { + id: 'layering-and-boundaries', + name: 'Layering & boundary integrity', + description: + 'Architectural layers and module boundaries are respected: no inward layer ' + + 'imports an outward one (e.g. domain importing infra), no dependency-direction ' + + 'inversion, no reach-through past a port. Boundary breaks are the most corrosive ' + + 'structural regressions because they compound.', + defaultSeverity: 'critical', + }, + { + id: 'spaghetti-detection', + name: 'Spaghetti / control-flow tangle', + description: + 'Control flow reads top-to-bottom without deep nesting, hidden coupling, or ' + + 'action-at-a-distance. Tangled, non-linear code that cannot be reasoned about ' + + 'locally is flagged.', + defaultSeverity: 'high', + }, + { + id: 'abstraction-quality', + name: 'Abstraction quality', + description: + 'Abstractions earn their keep: no premature, leaky, or wrong-level abstraction, ' + + 'and names reveal intent. A wrong abstraction is worse than duplication.', + defaultSeverity: 'high', + }, + { + id: 'code-judo', + name: 'Code-judo / simplicity', + description: + 'The change is the simplest one that works — it redirects existing force rather ' + + 'than adding mechanism. Over-engineering, needless indirection, and speculative ' + + 'generality are flagged.', + defaultSeverity: 'medium', + }, + { + id: 'duplication-and-dry', + name: 'Duplication / DRY', + description: + 'Repeated knowledge is consolidated — but only when a RIGHT abstraction exists ' + + '(this defers to abstraction-quality; do not DRY into a wrong abstraction).', + defaultSeverity: 'medium', + }, + { + id: 'file-size-discipline', + name: 'File & function-size discipline', + description: + 'Files and functions stay within a sane size budget. Oversized units are a ' + + 'structural smell that hides missing decomposition.', + defaultSeverity: 'low', + }, + { + id: 'dead-code-and-scope', + name: 'Dead code & scope discipline', + description: + 'No unused/dead code and no scope creep beyond the change intent: the diff stays ' + + 'minimal and reviewable.', + defaultSeverity: 'low', + }, +]; + +/** Fast lookup of a standard by id. */ +export const STRUCTURAL_STANDARD_BY_ID: ReadonlyMap = new Map( + STRUCTURAL_STANDARDS.map((s) => [s.id, s]), +); + +/** + * The single, TOTAL severity hierarchy — the rubric's ranking axis. Reuses the + * canonical {@link EvidenceFindingSeverity} scale so a rubric severity maps directly + * onto Evidence findings and onto the gate mapping with no translation. + * Higher number = more severe. + */ +export const STRUCTURAL_SEVERITY_RANK: Readonly> = { + info: 0, + low: 1, + medium: 2, + high: 3, + critical: 4, +}; + +/** Compare two severities by the hierarchy. Positive when `a` is MORE severe. */ +export function compareSeverity(a: EvidenceFindingSeverity, b: EvidenceFindingSeverity): number { + return STRUCTURAL_SEVERITY_RANK[a] - STRUCTURAL_SEVERITY_RANK[b]; +} + +/** The most severe of two severities (used to fold a finding set to a peak). */ +export function maxSeverity(a: EvidenceFindingSeverity, b: EvidenceFindingSeverity): EvidenceFindingSeverity { + return compareSeverity(a, b) >= 0 ? a : b; +} diff --git a/src/packages/contracts/README.md b/src/packages/contracts/README.md new file mode 100644 index 000000000..27b232386 --- /dev/null +++ b/src/packages/contracts/README.md @@ -0,0 +1,34 @@ +# @beyondnet/evolith-contracts + +The versioned **SemVer boundary** for the Evolith Core public contract (GT-513 · EAG-06). + +External (non-Tracker) consumers depend on this package — not on the Core engine — to +discover, at a pinned SemVer + `sha256`, what the stateless Core can evaluate. + +## What it exports + +- **`MACHINE_CONTRACT_SET`** / **`CONTRACT_SET_SHA256`** — the machine-contract / schema + set (id, version, path, per-file `sha256`) with a stable fingerprint over the schema + list. Unlike the raw `evolith-machine-contracts.json` (which lists only + `evolith_tracker`), this set advertises a first-class **`external`** consumer. +- **`EXPECTED_CAPABILITY_MANIFEST`** — the frozen snapshot of what + `GET /api/v1/capabilities` returns for this contract version (name, SemVer, schema + version, evaluation kinds, engines, surfaces, supported consumers, `sha256`). +- **`checkCapabilityManifestParity` / `assertCapabilityManifestParity`** — compare a live + manifest against the declared snapshot and report/throw on drift. + +## Parity guarantee + +Contract-parity tests bind this package to the live producer (`buildCapabilityManifest`, +which is exactly what the REST endpoint serves) and **fail on any drift**, so a Core +capability change cannot ship without a package + SemVer bump. + +```ts +import { checkCapabilityManifestParity } from '@beyondnet/evolith-contracts'; + +const env = await fetch(`${base}/api/v1/capabilities`).then((r) => r.json()); +const { ok, mismatches } = checkCapabilityManifestParity(env.data); +if (!ok) throw new Error(`Core drifted from contract: ${mismatches.join(', ')}`); +``` + +REST-only per ADR-0074 (no GraphQL). diff --git a/src/packages/contracts/jest.config.js b/src/packages/contracts/jest.config.js new file mode 100644 index 000000000..2c70505b7 --- /dev/null +++ b/src/packages/contracts/jest.config.js @@ -0,0 +1,22 @@ +module.exports = { + testEnvironment: 'node', + roots: ['/src'], + testMatch: ['**/*.spec.ts', '**/*.test.ts'], + transform: { + '^.+\\.ts$': ['ts-jest', { + tsconfig: '/tsconfig.spec.json', + diagnostics: false, + }], + }, + moduleNameMapper: { + '^(\\.{1,2}/.*)\\.js$': '$1', + }, + collectCoverageFrom: [ + 'src/**/*.ts', + '!src/**/*.d.ts', + '!src/**/index.ts', + '!src/**/*.spec.ts', + '!src/**/*.test.ts', + ], + coverageReporters: ['text', 'text-summary', 'lcov'], +}; diff --git a/src/packages/contracts/package.json b/src/packages/contracts/package.json new file mode 100644 index 000000000..1059824d5 --- /dev/null +++ b/src/packages/contracts/package.json @@ -0,0 +1,55 @@ +{ + "name": "@beyondnet/evolith-contracts", + "version": "1.0.0", + "description": "Versioned SemVer boundary for the Evolith Core public contract: machine-contract/schema set (sha256) and the capability manifest served by GET /api/v1/capabilities", + "license": "MIT", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./schemas": { + "types": "./dist/schemas/machine-contract-set.d.ts", + "default": "./dist/schemas/machine-contract-set.js" + }, + "./capabilities": { + "types": "./dist/capabilities/capability-contract.d.ts", + "default": "./dist/capabilities/capability-contract.js" + }, + "./*": { + "types": "./dist/*.d.ts", + "default": "./dist/*.js" + } + }, + "files": [ + "dist", + "README.md", + "LICENSE" + ], + "publishConfig": { + "access": "public", + "registry": "https://registry.npmjs.org" + }, + "scripts": { + "build": "tsc -p tsconfig.json", + "test": "jest --config jest.config.js --runInBand", + "test:cov": "jest --config jest.config.js --runInBand --coverage" + }, + "dependencies": {}, + "devDependencies": { + "@beyondnet/evolith-core-domain": "^1.0.0", + "@types/jest": "30.0.0", + "@types/node": "26.0.0", + "jest": "30.4.2", + "ts-jest": "29.4.11", + "typescript": "6.0.3" + }, + "homepage": "https://github.com/beyondnetcode/evolith_arch32#readme", + "repository": { + "type": "git", + "url": "https://github.com/beyondnetcode/evolith_arch32.git", + "directory": "src/packages/contracts" + } +} diff --git a/src/packages/contracts/src/capabilities/capability-contract.parity.spec.ts b/src/packages/contracts/src/capabilities/capability-contract.parity.spec.ts new file mode 100644 index 000000000..7a8d7d918 --- /dev/null +++ b/src/packages/contracts/src/capabilities/capability-contract.parity.spec.ts @@ -0,0 +1,70 @@ +/** + * Contract-parity: the package's declared capability contract vs the LIVE + * producer (GT-513 · EAG-06). `buildCapabilityManifest` from the Core domain is + * exactly what `GET /api/v1/capabilities` returns (bound by the core-api + * `CapabilitiesController` spec), so asserting parity here fails the build on any + * drift between `@beyondnet/evolith-contracts` and the live endpoint. + */ + +import { + buildCapabilityManifest, + type CapabilityManifest, +} from '@beyondnet/evolith-core-domain/capabilities/capabilities-manifest'; + +import { + EXPECTED_CAPABILITY_MANIFEST, + assertCapabilityManifestParity, + capabilityManifestFingerprint, + checkCapabilityManifestParity, + type CapabilityManifestShape, +} from './capability-contract'; + +describe('capability manifest contract parity', () => { + const live = buildCapabilityManifest() as CapabilityManifest & CapabilityManifestShape; + + it('the live manifest deep-equals the package-declared snapshot', () => { + expect(live).toEqual(EXPECTED_CAPABILITY_MANIFEST); + }); + + it('the declared snapshot sha256 matches its own recomputed fingerprint', () => { + expect(capabilityManifestFingerprint(EXPECTED_CAPABILITY_MANIFEST)).toBe( + EXPECTED_CAPABILITY_MANIFEST.sha256, + ); + }); + + it('the live manifest sha256 matches the package fingerprint algorithm', () => { + expect(capabilityManifestFingerprint(live)).toBe(live.sha256); + expect(live.sha256).toBe(EXPECTED_CAPABILITY_MANIFEST.sha256); + }); + + it('checkCapabilityManifestParity reports OK for the live manifest', () => { + const result = checkCapabilityManifestParity(live); + expect(result.mismatches).toEqual([]); + expect(result.ok).toBe(true); + }); + + it('assertCapabilityManifestParity does not throw for the live manifest', () => { + expect(() => assertCapabilityManifestParity(live)).not.toThrow(); + }); + + it('FAILS (throws with a precise reason) when a capability drifts', () => { + const drifted: CapabilityManifestShape = { + ...EXPECTED_CAPABILITY_MANIFEST, + engines: [...EXPECTED_CAPABILITY_MANIFEST.engines, 'ghost-engine'], + }; + const result = checkCapabilityManifestParity(drifted); + expect(result.ok).toBe(false); + expect(result.mismatches.join('\n')).toContain('engines'); + expect(() => assertCapabilityManifestParity(drifted)).toThrow(/drift/i); + }); + + it('FAILS when the supported-consumer set drifts (single-consumer regression)', () => { + const regressed: CapabilityManifestShape = { + ...EXPECTED_CAPABILITY_MANIFEST, + supportedConsumers: ['evolith_tracker'], + }; + const result = checkCapabilityManifestParity(regressed); + expect(result.ok).toBe(false); + expect(result.mismatches.join('\n')).toContain('supportedConsumers'); + }); +}); diff --git a/src/packages/contracts/src/capabilities/capability-contract.ts b/src/packages/contracts/src/capabilities/capability-contract.ts new file mode 100644 index 000000000..ee8df30e1 --- /dev/null +++ b/src/packages/contracts/src/capabilities/capability-contract.ts @@ -0,0 +1,130 @@ +/** + * The declared capability contract an external consumer can pin against + * (GT-513 · EAG-06 — Stable API + capabilities manifest). + * + * `EXPECTED_CAPABILITY_MANIFEST` is a frozen snapshot of exactly what + * `GET /api/v1/capabilities` (backed by the domain's `buildCapabilityManifest`) + * must return for the pinned contract version. The package is standalone — it + * does NOT import the Core domain — so a non-Tracker consumer gets the stable + * contract without pulling the engine. The monorepo's contract-parity tests + * compare the LIVE manifest against this snapshot and FAIL on any drift, which + * forces a package (and SemVer) bump whenever the Core's capabilities change. + */ + +import { sha256Hex } from '../schemas/contract-hash'; +import { CONTRACTS_PACKAGE_VERSION, SUPPORTED_CONSUMER_IDS } from '../schemas/machine-contract-set'; + +/** + * Structural shape of the versioned capability manifest. Mirrors the domain's + * `CapabilityManifest` without importing it, so the contract is self-contained. + */ +export interface CapabilityManifestShape { + readonly name: 'evolith-core'; + readonly version: string; + readonly schemaVersion: string; + readonly evaluationKinds: readonly string[]; + readonly engines: readonly string[]; + readonly surfaces: readonly string[]; + readonly supportedConsumers: readonly string[]; + readonly sha256: string; +} + +/** + * The frozen expected manifest for {@link CONTRACTS_PACKAGE_VERSION}. Its + * `sha256` is the canonical fingerprint the live endpoint must reproduce. + */ +export const EXPECTED_CAPABILITY_MANIFEST: CapabilityManifestShape = Object.freeze({ + name: 'evolith-core', + version: CONTRACTS_PACKAGE_VERSION, + schemaVersion: '1.0.0', + evaluationKinds: Object.freeze([ + 'gate', + 'artifact', + 'evidence', + 'architecture', + 'blueprint', + 'topology', + 'checkpoint', + 'deployment', + 'compliance', + 'design', + 'phaseArtifacts', + ]), + engines: Object.freeze(['native', 'opa', 'enforcer']), + surfaces: Object.freeze(['rest', 'cli', 'mcp']), + supportedConsumers: Object.freeze([...SUPPORTED_CONSUMER_IDS]), + sha256: '8f5a75c912287ab182484b90e69272723b2fa83184778458eaee5028ec8f2d16', +}) as CapabilityManifestShape; + +/** + * Recompute the fingerprint of a manifest EXCLUDING its own `sha256`, exactly as + * the domain does. A parity check compares this against `manifest.sha256`. + */ +export function capabilityManifestFingerprint(manifest: CapabilityManifestShape): string { + const { sha256: _ignored, ...content } = manifest; + void _ignored; + return sha256Hex(content); +} + +/** Structured result of a capability-manifest parity check. */ +export interface ManifestParityResult { + readonly ok: boolean; + /** Human-readable mismatch reasons; empty when `ok`. */ + readonly mismatches: readonly string[]; +} + +/** + * Compare a LIVE capability manifest (as served by `GET /api/v1/capabilities`) + * against the package's declared {@link EXPECTED_CAPABILITY_MANIFEST}. Returns a + * list of every field that drifted, plus a self-consistency check that the live + * `sha256` matches its own recomputed fingerprint. + */ +export function checkCapabilityManifestParity( + live: CapabilityManifestShape, +): ManifestParityResult { + const mismatches: string[] = []; + const expected = EXPECTED_CAPABILITY_MANIFEST; + + const scalarKeys = ['name', 'version', 'schemaVersion', 'sha256'] as const; + for (const key of scalarKeys) { + if (live[key] !== expected[key]) { + mismatches.push(`${key}: expected "${expected[key]}", got "${live[key]}"`); + } + } + + const listKeys = [ + 'evaluationKinds', + 'engines', + 'surfaces', + 'supportedConsumers', + ] as const; + for (const key of listKeys) { + const e = JSON.stringify(expected[key]); + const l = JSON.stringify(live[key]); + if (e !== l) { + mismatches.push(`${key}: expected ${e}, got ${l}`); + } + } + + const recomputed = capabilityManifestFingerprint(live); + if (recomputed !== live.sha256) { + mismatches.push( + `sha256 self-consistency: manifest declares "${live.sha256}" but content hashes to "${recomputed}"`, + ); + } + + return { ok: mismatches.length === 0, mismatches }; +} + +/** + * Assert parity or throw with every mismatch. Used by contract-parity tests to + * fail loudly on drift between the package and the live endpoint. + */ +export function assertCapabilityManifestParity(live: CapabilityManifestShape): void { + const { ok, mismatches } = checkCapabilityManifestParity(live); + if (!ok) { + throw new Error( + `Capability manifest drift vs @beyondnet/evolith-contracts@${CONTRACTS_PACKAGE_VERSION}:\n - ${mismatches.join('\n - ')}`, + ); + } +} diff --git a/src/packages/contracts/src/index.ts b/src/packages/contracts/src/index.ts new file mode 100644 index 000000000..773c176b6 --- /dev/null +++ b/src/packages/contracts/src/index.ts @@ -0,0 +1,55 @@ +/** + * @beyondnet/evolith-contracts — the versioned, self-contained SemVer boundary + * for the Evolith Core public contract (GT-513 · EAG-06). + * + * External (non-Tracker) consumers depend on THIS package — not on the Core + * engine — to discover, at a pinned SemVer + sha256, what the stateless Core + * can evaluate: + * + * - the machine-contract / schema set ({@link MACHINE_CONTRACT_SET}, + * {@link CONTRACT_SET_SHA256}) — now advertising a first-class `external` + * consumer, closing the single-consumer gap of the raw + * `evolith-machine-contracts.json`; and + * - the capability manifest contract ({@link EXPECTED_CAPABILITY_MANIFEST}) + * served live by `GET /api/v1/capabilities`. + * + * Contract-parity tests bind this package to the live endpoints and FAIL on any + * drift, so a Core capability change cannot ship without a package/SemVer bump. + * + * @example + * ```ts + * import { + * CONTRACTS_PACKAGE_VERSION, + * EXPECTED_CAPABILITY_MANIFEST, + * checkCapabilityManifestParity, + * } from '@beyondnet/evolith-contracts'; + * + * const live = await fetch(`${base}/api/v1/capabilities`) + * .then((r) => r.json()) + * .then((env) => env.data); + * const { ok, mismatches } = checkCapabilityManifestParity(live); + * if (!ok) throw new Error(`Core drifted from contract ${CONTRACTS_PACKAGE_VERSION}: ${mismatches}`); + * ``` + */ + +export { + CONTRACTS_PACKAGE_VERSION, + CONTRACT_SET_SHA256, + MACHINE_CONTRACT_SET, + SUPPORTED_CONSUMER_IDS, + contractSetFingerprint, + type ContractSchemaDescriptor, + type MachineContractSet, + type SupportedConsumer, +} from './schemas/machine-contract-set'; + +export { canonicalJson, sha256Hex, sortDeep } from './schemas/contract-hash'; + +export { + EXPECTED_CAPABILITY_MANIFEST, + assertCapabilityManifestParity, + capabilityManifestFingerprint, + checkCapabilityManifestParity, + type CapabilityManifestShape, + type ManifestParityResult, +} from './capabilities/capability-contract'; diff --git a/src/packages/contracts/src/schemas/contract-hash.ts b/src/packages/contracts/src/schemas/contract-hash.ts new file mode 100644 index 000000000..e4bad6b19 --- /dev/null +++ b/src/packages/contracts/src/schemas/contract-hash.ts @@ -0,0 +1,39 @@ +/** + * Canonical JSON serialization + sha256 fingerprinting for the contract set + * (GT-513 · EAG-06 — Stable API + capabilities manifest). + * + * The algorithm MUST stay byte-for-byte compatible with the domain's + * `capabilities-manifest` fingerprint so that a hash produced here can be + * compared 1:1 against the live capability manifest served by + * `GET /api/v1/capabilities`: object keys are sorted recursively, array order + * is preserved, and the result is hashed as UTF-8. + */ + +import { createHash } from 'node:crypto'; + +/** Deterministically sort object keys (recursively); arrays keep their order. */ +export function sortDeep(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(sortDeep); + } + if (value !== null && typeof value === 'object') { + const source = value as Record; + return Object.keys(source) + .sort() + .reduce>((acc, key) => { + acc[key] = sortDeep(source[key]); + return acc; + }, {}); + } + return value; +} + +/** Canonical JSON: stable, key-sorted serialization for stable hashing. */ +export function canonicalJson(value: unknown): string { + return JSON.stringify(sortDeep(value)); +} + +/** sha256 hex of the canonical JSON of `value`. */ +export function sha256Hex(value: unknown): string { + return createHash('sha256').update(canonicalJson(value), 'utf8').digest('hex'); +} diff --git a/src/packages/contracts/src/schemas/machine-contract-set.spec.ts b/src/packages/contracts/src/schemas/machine-contract-set.spec.ts new file mode 100644 index 000000000..1dd58467a --- /dev/null +++ b/src/packages/contracts/src/schemas/machine-contract-set.spec.ts @@ -0,0 +1,62 @@ +import { createHash } from 'node:crypto'; +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; + +import { + CONTRACTS_PACKAGE_VERSION, + CONTRACT_SET_SHA256, + MACHINE_CONTRACT_SET, + SUPPORTED_CONSUMER_IDS, + contractSetFingerprint, +} from './machine-contract-set'; + +// Repo root is four levels up from this file: src/packages/contracts/src/schemas +const REPO_ROOT = join(__dirname, '..', '..', '..', '..', '..'); +// Contract-set schema paths (`rulesets/schema/...`) are relative to `src/`. +const SRC_ROOT = join(REPO_ROOT, 'src'); + +describe('MACHINE_CONTRACT_SET', () => { + it('is versioned with a valid SemVer matching the package version', () => { + expect(MACHINE_CONTRACT_SET.contractVersion).toBe(CONTRACTS_PACKAGE_VERSION); + expect(CONTRACTS_PACKAGE_VERSION).toMatch(/^\d+\.\d+\.\d+$/); + }); + + it('advertises a first-class `external` consumer (closes the single-consumer gap)', () => { + expect(SUPPORTED_CONSUMER_IDS).toContain('evolith_tracker'); + expect(SUPPORTED_CONSUMER_IDS).toContain('external'); + }); + + it('exposes a stable, recomputable sha256 of the schema set', () => { + expect(CONTRACT_SET_SHA256).toMatch(/^[0-9a-f]{64}$/); + expect(contractSetFingerprint(MACHINE_CONTRACT_SET)).toBe(CONTRACT_SET_SHA256); + }); + + // Drift guard: the declared per-schema sha256 must match the real schema + // files (the single source of truth). If a schema changes without updating + // this descriptor + a version bump, this fails. + it.each(MACHINE_CONTRACT_SET.schemas.map((s) => [s.id, s] as const))( + 'schema %s hashes to its declared sha256', + (_id, schema) => { + const bytes = readFileSync(join(SRC_ROOT, schema.path)); + const actual = createHash('sha256').update(bytes).digest('hex'); + expect(actual).toBe(schema.sha256); + }, + ); + + it('stays byte-consistent with the producer machine-contracts descriptor', () => { + const raw = JSON.parse( + readFileSync( + join(REPO_ROOT, 'src', 'rulesets', 'contracts', 'evolith-machine-contracts.json'), + 'utf8', + ), + ) as { schemas: { id: string; path: string; sha256: string }[] }; + + // Every schema the producer declares must be present with the same hash. + for (const producer of raw.schemas) { + const declared = MACHINE_CONTRACT_SET.schemas.find((s) => s.id === producer.id); + expect(declared).toBeDefined(); + expect(declared?.path).toBe(producer.path); + expect(declared?.sha256).toBe(producer.sha256); + } + }); +}); diff --git a/src/packages/contracts/src/schemas/machine-contract-set.ts b/src/packages/contracts/src/schemas/machine-contract-set.ts new file mode 100644 index 000000000..167d4e1b9 --- /dev/null +++ b/src/packages/contracts/src/schemas/machine-contract-set.ts @@ -0,0 +1,119 @@ +/** + * The versioned machine-contract / schema set exposed to EXTERNAL consumers + * (GT-513 · EAG-06 — Stable API + capabilities manifest). + * + * This is the SemVer boundary of the Evolith Core public contract. It mirrors + * `src/rulesets/contracts/evolith-machine-contracts.json` (the producer-side + * descriptor authored by the CLI) but is packaged as a standalone, + * self-contained module so a non-Tracker consumer can depend on it directly: + * + * - `MACHINE_CONTRACT_SET` — the declared schema set (id/version/path/sha256) + * plus the officially supported consumers. Unlike the raw JSON — which lists + * only `evolith_tracker` — this set adds a first-class `external` consumer, + * which is the whole point of publishing the package: the contract is now + * stable and public. + * - `CONTRACT_SET_SHA256` — a sha256 over the canonical JSON of the schema + * descriptor list, the single value an external consumer pins against. + * + * Everything here is deeply `readonly`: the Core measures/exposes, never mutates. + */ + +import { sha256Hex } from './contract-hash'; + +/** A single machine-readable schema advertised in the contract set. */ +export interface ContractSchemaDescriptor { + /** Stable schema id (e.g. `gate-evidence`). */ + readonly id: string; + /** SemVer of this individual schema. */ + readonly version: string; + /** Repo-relative path to the JSON Schema file (single source of truth). */ + readonly path: string; + /** sha256 hex of the raw schema file bytes. */ + readonly sha256: string; +} + +/** A consumer the Core officially supports for the contract set. */ +export interface SupportedConsumer { + /** Logical consumer id — matches a `supportedConsumers` entry of the manifest. */ + readonly consumer: string; + /** Consumer repository, or `null` for the open/anonymous external consumer. */ + readonly repository: string | null; + /** Path to the consumer-side manifest, or `null` when not applicable. */ + readonly manifestPath: string | null; + /** Contract version the consumer is pinned to. */ + readonly contractVersion: string; +} + +/** The versioned machine-contract / schema set. */ +export interface MachineContractSet { + /** SemVer of the contract set as a whole (== the package version). */ + readonly contractVersion: string; + /** Compatibility policy consumers can rely on. */ + readonly compatibilityPolicy: 'semver-major'; + /** The advertised schema descriptors. */ + readonly schemas: readonly ContractSchemaDescriptor[]; + /** Consumers the Core officially supports (now includes `external`). */ + readonly supportedConsumers: readonly SupportedConsumer[]; +} + +/** + * SemVer version of THIS published contract package. Bumping the Core's public + * contract (schemas, hashes, capability manifest) requires bumping this in + * lockstep with `package.json#version` — the contract-parity tests enforce it. + */ +export const CONTRACTS_PACKAGE_VERSION = '1.0.0'; + +/** + * The frozen, versioned machine-contract set. Mirrors + * `rulesets/contracts/evolith-machine-contracts.json` and extends its + * `supportedConsumers` with the first-class `external` consumer. + */ +export const MACHINE_CONTRACT_SET: MachineContractSet = Object.freeze({ + contractVersion: CONTRACTS_PACKAGE_VERSION, + compatibilityPolicy: 'semver-major', + schemas: Object.freeze([ + Object.freeze({ + id: 'gate-evidence', + version: '1.0.0', + path: 'rulesets/schema/gate-evidence.schema.json', + sha256: '9225090e2ee851dbd2d5c22e7a0a6e2d7f97db9835f7f3822ac7d9f861b75754', + }), + Object.freeze({ + id: 'output-envelope', + version: '1.0.0', + path: 'rulesets/schema/output-envelope.schema.json', + sha256: '07eaffaae2c33071ea105fbcbdc497c06d149143f6cc9b94555170740707fd0b', + }), + ]), + supportedConsumers: Object.freeze([ + Object.freeze({ + consumer: 'evolith_tracker', + repository: 'beyondnetcode/evolith_tracker', + manifestPath: 'contracts/evolith-core-contracts.json', + contractVersion: '1.0.0', + }), + Object.freeze({ + consumer: 'external', + repository: null, + manifestPath: null, + contractVersion: '1.0.0', + }), + ]), +}) as MachineContractSet; + +/** + * sha256 hex over the canonical JSON of the schema descriptor list — the stable + * fingerprint of the schema set an external consumer pins against. Recomputable, + * so a drift test can detect any change to the advertised schemas. + */ +export const CONTRACT_SET_SHA256: string = sha256Hex(MACHINE_CONTRACT_SET.schemas); + +/** Recompute the schema-set fingerprint from a set (for drift detection). */ +export function contractSetFingerprint(set: MachineContractSet): string { + return sha256Hex(set.schemas); +} + +/** Logical consumer ids the contract set officially supports. */ +export const SUPPORTED_CONSUMER_IDS: readonly string[] = Object.freeze( + MACHINE_CONTRACT_SET.supportedConsumers.map((c) => c.consumer), +); diff --git a/src/packages/contracts/tsconfig.json b/src/packages/contracts/tsconfig.json new file mode 100644 index 000000000..976d171e6 --- /dev/null +++ b/src/packages/contracts/tsconfig.json @@ -0,0 +1,17 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "moduleResolution": "node", + "ignoreDeprecations": "6.0", + "outDir": "./dist", + "rootDir": "./src", + "baseUrl": "./", + "resolveJsonModule": true, + "noFallthroughCasesInSwitch": false, + "useUnknownInCatchVariables": false, + "types": ["node", "jest"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/src/packages/contracts/tsconfig.spec.json b/src/packages/contracts/tsconfig.spec.json new file mode 100644 index 000000000..8408e6597 --- /dev/null +++ b/src/packages/contracts/tsconfig.spec.json @@ -0,0 +1,8 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "composite": false, + "types": ["node", "jest"] + }, + "include": ["src/**/*.ts"] +} diff --git a/src/packages/core-domain/src/application/validators/enforcement/enforcer-catalog-doc-parity.spec.ts b/src/packages/core-domain/src/application/validators/enforcement/enforcer-catalog-doc-parity.spec.ts new file mode 100644 index 000000000..21174a626 --- /dev/null +++ b/src/packages/core-domain/src/application/validators/enforcement/enforcer-catalog-doc-parity.spec.ts @@ -0,0 +1,106 @@ +/** + * Reproducible toolchain — catalog ↔ doc parity + EXACT version pinning (GT-519 · EAG-14). + * + * The enforcer toolchain is only reproducible if the two sources of truth agree and every + * version is an exact pin: + * + * 1. `product/infra/validated-tool-catalog.md` §4.3 "Architecture Enforcement (Boundary + * Analyzers)" — the human-facing table. + * 2. `src/rulesets/enforcement/enforcer-catalog.json` — the machine-readable mirror the + * engine loads. + * + * This suite parses BOTH and fails on ANY drift between them (a version bumped in one file + * but not the other is exactly how a CI image silently diverges from the certified pin), and + * it fails if any version is not an EXACT `x.y.z` semver — a `16.x` / `^16` / `>=16` range + * makes the built image non-reproducible. Renovate (deploy-gated) keeps the pins current; + * this guard keeps the two files honest with each other in the meantime. + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +/** Exact three-part semver, no ranges/wildcards/operators. */ +const EXACT_SEMVER = /^\d+\.\d+\.\d+$/; + +interface CatalogEntry { + readonly tool: string; + readonly runtime: string; + readonly version: string; + readonly adr: string; +} + +const repoRoot = resolve(__dirname, '..', '..', '..', '..', '..', '..', '..'); + +function loadJsonCatalog(): CatalogEntry[] { + const catalogPath = resolve(repoRoot, 'src', 'rulesets', 'enforcement', 'enforcer-catalog.json'); + return (JSON.parse(readFileSync(catalogPath, 'utf8')).enforcers as CatalogEntry[]).map((e) => ({ + tool: e.tool, + runtime: e.runtime, + version: e.version, + adr: e.adr, + })); +} + +/** + * Parse the §4.3 table from the validated-tool-catalog markdown. Rows look like: + * `| **dependency-cruiser** | 16.10.4 | node | … | … | ADR-0002 |` + * We key by tool and read the version (col 2), runtime (col 3), ADR (last col). + */ +function loadDocCatalog(): Map { + const docPath = resolve(repoRoot, 'product', 'infra', 'validated-tool-catalog.md'); + const md = readFileSync(docPath, 'utf8'); + + const sectionStart = md.indexOf('### 4.3 Architecture Enforcement'); + if (sectionStart < 0) throw new Error('§4.3 Architecture Enforcement section not found in validated-tool-catalog.md'); + // The section runs until the next `---` horizontal rule or `## ` heading. + const rest = md.slice(sectionStart); + const endRel = rest.search(/\n---\n|\n## /); + const section = endRel < 0 ? rest : rest.slice(0, endRel); + + const rows = new Map(); + for (const line of section.split('\n')) { + const trimmed = line.trim(); + if (!trimmed.startsWith('|')) continue; + const cells = trimmed.split('|').map((c) => c.trim()).filter((c) => c.length > 0); + // Header (`Tool`) and separator (`----`) rows are skipped. + if (cells.length < 6) continue; + const tool = cells[0].replace(/\*/g, '').trim(); + if (tool === 'Tool' || /^-+$/.test(tool)) continue; + rows.set(tool, { version: cells[1], runtime: cells[2], adr: cells[cells.length - 1] }); + } + return rows; +} + +describe('enforcer toolchain — catalog.json ↔ validated-tool-catalog.md parity + exact pinning (GT-519)', () => { + const jsonCatalog = loadJsonCatalog(); + const docCatalog = loadDocCatalog(); + + it('parses a non-empty table from each source', () => { + expect(jsonCatalog.length).toBeGreaterThan(0); + expect(docCatalog.size).toBeGreaterThan(0); + }); + + it('lists exactly the same set of tools in both files (no orphan on either side)', () => { + const jsonTools = jsonCatalog.map((e) => e.tool).sort(); + const docTools = [...docCatalog.keys()].sort(); + expect(jsonTools).toEqual(docTools); + }); + + it('agrees on version + runtime + adr for every tool (fails on drift)', () => { + for (const entry of jsonCatalog) { + const doc = docCatalog.get(entry.tool); + expect(doc).toBeDefined(); + expect({ tool: entry.tool, version: doc!.version, runtime: doc!.runtime, adr: doc!.adr }) + .toEqual({ tool: entry.tool, version: entry.version, runtime: entry.runtime, adr: entry.adr }); + } + }); + + it('pins every version EXACTLY (x.y.z — no ranges/wildcards) in BOTH files', () => { + for (const entry of jsonCatalog) { + expect(entry.version).toMatch(EXACT_SEMVER); + } + for (const [, row] of docCatalog) { + expect(row.version).toMatch(EXACT_SEMVER); + } + }); +}); diff --git a/src/packages/core-domain/src/application/validators/enforcement/enforcer-surface-parity.spec.ts b/src/packages/core-domain/src/application/validators/enforcement/enforcer-surface-parity.spec.ts new file mode 100644 index 000000000..0268b8edc --- /dev/null +++ b/src/packages/core-domain/src/application/validators/enforcement/enforcer-surface-parity.spec.ts @@ -0,0 +1,149 @@ +/** + * Cross-surface enforcer parity — CLI · MCP · REST (BR-008 · GT-519 · EAG-14). + * + * The enforcer path (the {@link CompositeRuleEvaluator} the enforcer subsystem builds) must be + * reachable IDENTICALLY from all three consumption surfaces: + * - CLI `evaluate` → `src/sdk/cli/src/app.module.ts` + * - MCP `architecture` → `src/packages/mcp-server/src/domain/domain.module.ts` + * - REST `POST /api/v1/evaluate` → `src/apps/core-api/src/core-domain.module.ts` + * + * All three reach the enforcer through the SAME seam: `RulesetValidatorService`, which wraps its + * strategy with `createCompositeEnforcerStrategy` iff a `processRunner` is injected. Parity is + * therefore two claims, both verified here WITHOUT standing up REST/MCP servers (no infra): + * + * 1. BEHAVIOURAL — construct the strategy the way each surface's DI factory does (a shared + * process runner behind the composite) and assert the SAME enforcer rule yields + * byte-identical results across all three. A surface that forgot to inject the runner would + * instead DROP the enforcer rule — the divergence test pins that as the parity break the + * GT-519 wiring closes. + * 2. REGISTRATION (anti-drift) — assert each of the three real surface-module source files + * actually injects a `NodeProcessRunner` into `RulesetValidatorService`. If any surface + * silently drops it, the enforcer path is no longer reachable there and this fails. + */ + +import { readFileSync } from 'fs'; +import { resolve } from 'path'; + +import type { NormalizedRule } from '../../../domain/models/normalized-rule'; +import type { + IRuleEvaluatorStrategy, RuleEvaluationResult, WorkspaceEvaluationContext, +} from '../evaluators/evaluator.interface'; +import { createCompositeEnforcerStrategy } from './enforcer-subsystem'; +import { StubProcessRunner } from './enforcer.types'; + +const CTX: WorkspaceEvaluationContext = { satellitePath: '/w', corePath: '/c' }; + +/** Native strategy stub: records what it saw, passes everything (stands in for native/opa). */ +class RecordingNativeStrategy implements IRuleEvaluatorStrategy { + readonly seen: NormalizedRule[] = []; + async evaluateAll(rules: NormalizedRule[]): Promise { + this.seen.push(...rules); + return rules.map((rule) => ({ rule, result: 'passed' as const })); + } +} + +const netArchRule = (toolRuleId: string): NormalizedRule => ({ + id: 'HXA-01', + severity: 'MUST', + category: 'architecture', + title: 'Domain must not depend on Infrastructure', + description: 'Hexagonal boundary (ADR-0002).', + blocking: true, + sourceFile: 'adr-0002.rules.json', + enforce: { engine: 'enforcer', tool: 'NetArchTest', toolRuleId, runtime: 'dotnet' }, +}); + +const nativeRule: NormalizedRule = { + id: 'DOC-01', + severity: 'SHOULD', + category: 'docs', + title: 'A native rule', + description: 'Stays on the native engine.', + blocking: false, + sourceFile: 'native.rules.json', +}; + +const DOTNET_FAILURE = [ + ' Failed MyApp.Arch.Tests.Domain_should_not_depend_on_Infrastructure [45 ms]', + ' Error Message:', + ' NetArchTest: expected no dependencies but found MyApp.Domain.Order -> MyApp.Infrastructure.Db', + 'Failed! - Failed: 1, Passed: 9, Skipped: 0, Total: 10', +].join('\n'); + +/** Reproduce exactly what each surface's DI factory does: inject a runner ⇒ enforcer registered. */ +function surfaceStrategyWithRunner(): IRuleEvaluatorStrategy { + const runner = new StubProcessRunner({ exitCode: 0, stdout: '', stderr: '' }, { + dotnet: { exitCode: 1, stdout: DOTNET_FAILURE, stderr: '' }, + }); + return createCompositeEnforcerStrategy(new RecordingNativeStrategy(), runner); +} + +const SURFACES = ['cli', 'mcp', 'rest'] as const; +const RULE_ID = 'MyApp.Arch.Tests.Domain_should_not_depend_on_Infrastructure'; + +describe('enforcer path parity across CLI / MCP / REST (GT-519 · BR-008)', () => { + describe('behavioural: the same enforcer rule yields identical results on every surface', () => { + it('all three surfaces route the enforcer rule and produce equivalent results', async () => { + const rules = [nativeRule, netArchRule(RULE_ID)]; + + const perSurface = await Promise.all( + SURFACES.map(async () => { + const results = await surfaceStrategyWithRunner().evaluateAll(rules, CTX); + // Normalize ordering so the comparison is about content, not evaluation interleaving. + return results + .map((r) => ({ id: r.rule.id, result: r.result, message: r.message ?? null })) + .sort((a, b) => a.id.localeCompare(b.id)); + }), + ); + + // Every surface must agree with the first — full cross-surface equivalence. + const [reference, ...others] = perSurface; + for (const other of others) expect(other).toEqual(reference); + + // And the enforcer rule was actually exercised (a real violation → failed), so this is + // parity on the ENFORCER path, not merely on an empty result set. + const enforcerResult = reference.find((r) => r.id === 'HXA-01'); + expect(enforcerResult?.result).toBe('failed'); + expect(enforcerResult?.message).toContain('NetArchTest'); + }); + + it('divergence guard: a surface WITHOUT the runner drops the enforcer rule (the parity break GT-519 closes)', async () => { + const rules = [nativeRule, netArchRule(RULE_ID)]; + + // With the enforcer registered (post-GT-519 wiring): both rules produce a result. + const withEnforcer = await surfaceStrategyWithRunner().evaluateAll(rules, CTX); + expect(withEnforcer.map((r) => r.rule.id).sort()).toEqual(['DOC-01', 'HXA-01']); + + // Native-only surface (the pre-fix state, no processRunner): the enforcer rule is not + // evaluated at all — this is exactly the cross-surface divergence the wiring removes. + const native = new RecordingNativeStrategy(); + const nativeOnly = await native.evaluateAll(rules); + expect(nativeOnly.some((r) => r.rule.id === 'HXA-01')).toBe(true); + // The native strategy "passes" the enforcer rule instead of actually running the analyzer — + // a false pass relative to the enforcer surfaces. The results are NOT equivalent. + const enforcerOnEnforcerSurface = withEnforcer.find((r) => r.rule.id === 'HXA-01'); + const enforcerOnNativeOnly = nativeOnly.find((r) => r.rule.id === 'HXA-01'); + expect(enforcerOnEnforcerSurface!.result).not.toBe(enforcerOnNativeOnly!.result); + }); + }); + + describe('registration anti-drift: each real surface module injects the enforcer runner', () => { + const repoRoot = resolve(__dirname, '..', '..', '..', '..', '..', '..', '..'); + const SURFACE_MODULES: Record = { + 'REST (core-api)': resolve(repoRoot, 'src', 'apps', 'core-api', 'src', 'core-domain.module.ts'), + 'CLI (sdk/cli)': resolve(repoRoot, 'src', 'sdk', 'cli', 'src', 'app.module.ts'), + 'MCP (mcp-server)': resolve(repoRoot, 'src', 'packages', 'mcp-server', 'src', 'domain', 'domain.module.ts'), + }; + + for (const [surface, modulePath] of Object.entries(SURFACE_MODULES)) { + it(`${surface} wires a NodeProcessRunner into RulesetValidatorService`, () => { + const src = readFileSync(modulePath, 'utf8'); + expect(src).toContain('RulesetValidatorService'); + expect(src).toContain('NodeProcessRunner'); + // The runner must be threaded into the validator options (the enforcer seam), not merely + // imported. `processRunner:` is the option key `createCompositeEnforcerStrategy` keys off. + expect(src).toMatch(/processRunner:\s*new NodeProcessRunner\(\)/); + }); + } + }); +}); diff --git a/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.roundtrip.spec.ts b/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.roundtrip.spec.ts new file mode 100644 index 000000000..052210b3d --- /dev/null +++ b/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.roundtrip.spec.ts @@ -0,0 +1,101 @@ +/** + * PolicyCompiler round-trip (GT-516 AC3: compile → run → normalize to Violation, 0 FP). + * + * Closes the loop the {@link compileRuleset PolicyCompiler} opens: it lowers ADR-0002's + * `enforce:` blocks into the dependency-cruiser `forbidden` config, and this test feeds + * that SAME config's rule ids back through the GT-515 {@link createDependencyCruiserAdapter} + * over a deterministic {@link StubProcessRunner} — so a compiled rule id round-trips all the + * way to a canonical {@link Violation} whose `ruleId` is what the tool reports. + * + * The zero-false-positive assertion runs against a CLEAN fixture corpus (a + * `depcruise --output-type json` report with no violations): the pipeline yields ZERO + * Violations, never a spurious one. A DIRTY fixture confirms every compiled rule + * normalizes to exactly one Violation. + * + * A real cross-runtime EXECUTION (spawning depcruise/NetArchTest/Deptrac/Conftest on a + * restored workspace) is GT-512-sandbox-gated; this exercises the compile→normalize + * contract with a stubbed runner + fixture reports, which is the deterministic slice. + */ + +import { existsSync, readFileSync } from 'node:fs'; +import { dirname, resolve } from 'node:path'; + +import { compileRuleset, toDependencyCruiserConfig, type CompilableRule } from './policy-compiler'; +import { createDependencyCruiserAdapter, DEPENDENCY_CRUISER_TOOL } from './adapters/dependency-cruiser-adapter'; +import { StubProcessRunner, type EnforcerAnalysisContext, type ProcessResult } from './enforcer.types'; + +function findUp(rel: string): string { + let dir = __dirname; + for (let i = 0; i < 9; i++) { + const c = resolve(dir, rel); + if (existsSync(c)) return c; + const parent = dirname(dir); + if (parent === dir) break; + dir = parent; + } + throw new Error(`not found: ${rel}`); +} + +/** A minimal analysis context — the adapter only reads `satellitePath` (for cwd). */ +const CTX: EnforcerAnalysisContext = { satellitePath: '/repo', corePath: '/core', rules: [] }; + +/** Seed a StubProcessRunner with a canned `depcruise --output-type json` report. */ +function seedRunner(report: unknown): StubProcessRunner { + const result: ProcessResult = { exitCode: 0, stdout: JSON.stringify(report), stderr: '' }; + return new StubProcessRunner(undefined, { [DEPENDENCY_CRUISER_TOOL]: result }); +} + +describe('PolicyCompiler round-trip — ADR-0002 compile → normalize to Violation (GT-516 AC3)', () => { + const ruleset = JSON.parse(readFileSync(findUp('src/rulesets/adr/adr-0002-hexagonal-architecture.rules.json'), 'utf8')); + const rules: CompilableRule[] = ruleset.rules; + const { compiled } = compileRuleset(rules); + const config = toDependencyCruiserConfig(compiled); + const compiledRuleNames = compiled.map((c) => c.toolRuleId).sort(); + + it('lowers the compiled checks into the depcruise forbidden config the runner will apply', () => { + // Every compiled node rule appears in the emitted `.dependency-cruiser` config under + // the tool rule id the analyzer will later report — the compile → tool-config half. + expect(config.forbidden.map((r) => r.name).sort()).toEqual(compiledRuleNames); + expect(config.forbidden.length).toBeGreaterThanOrEqual(5); + }); + + it('a CLEAN corpus normalizes to ZERO violations (0 false positives)', async () => { + // A codebase that OBEYS every HXA rule → depcruise reports no violations. + const adapter = createDependencyCruiserAdapter(seedRunner({ summary: { violations: [] } })); + const violations = await adapter.analyze(CTX); + expect(violations).toEqual([]); + }); + + it('each compiled rule round-trips to a canonical Violation carrying its rule id', async () => { + // A DIRTY corpus that trips every compiled rule: one violation per compiled tool rule id. + const dirty = { + summary: { + violations: compiled.map((c) => ({ + from: `src/domain/offender-${c.toolRuleId}.ts`, + to: 'node_modules/@nestjs/common/index.js', + rule: { name: c.toolRuleId, severity: c.mode === 'block' ? 'error' : 'warn' }, + })), + }, + }; + const adapter = createDependencyCruiserAdapter(seedRunner(dirty)); + const violations = await adapter.analyze(CTX); + + // Every compiled rule id surfaces as exactly one Violation — the full round-trip. + expect(violations.map((v) => v.ruleId).sort()).toEqual(compiledRuleNames); + for (const v of violations) { + expect(v.tool).toBe(DEPENDENCY_CRUISER_TOOL); + expect(v.file).toMatch(/^src\/domain\/offender-/); + expect(['error', 'warning']).toContain(v.severity); + expect(v.fingerprint).toBeTruthy(); + } + }); + + it('a malformed tool report yields no spurious violations (0 FP on garbage)', async () => { + // Non-empty, unparseable stdout → the parser returns [] rather than inventing findings. + const runner = new StubProcessRunner(undefined, { + [DEPENDENCY_CRUISER_TOOL]: { exitCode: 0, stdout: '{ this is : not valid json', stderr: '' }, + }); + const adapter = createDependencyCruiserAdapter(runner); + await expect(adapter.analyze(CTX)).resolves.toEqual([]); + }); +}); diff --git a/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.spec.ts b/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.spec.ts index 857b185af..6952356a8 100644 --- a/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.spec.ts +++ b/src/packages/core-domain/src/application/validators/enforcement/policy-compiler.spec.ts @@ -170,18 +170,26 @@ describe('PolicyCompiler — compileRuleset partitions and never wholesale-fails }); describe('PolicyCompiler ⇄ ADR-0002 pilot (GT-516 AC1: HXA rules compile/fallback)', () => { - it('compiles every enforce-carrying HXA rule to a dependency-cruiser check', () => { + it('routes every HXA-01..07 rule through the enforcer and partitions compile vs per-rule fallback', () => { const ruleset = JSON.parse(readFileSync(findUp('src/rulesets/adr/adr-0002-hexagonal-architecture.rules.json'), 'utf8')); const rules: CompilableRule[] = ruleset.rules; const enforced = rules.filter((r) => r.enforce?.engine === 'enforcer'); - // The pilot populates enforce on the dependency-cruiser-expressible HXA rules. - expect(enforced.length).toBeGreaterThanOrEqual(3); + // The pilot populates an enforce block on all seven HXA rules. + expect(enforced.map((r) => r.id).sort()).toEqual(['HXA-01', 'HXA-02', 'HXA-03', 'HXA-04', 'HXA-05', 'HXA-06', 'HXA-07']); const { compiled, fallbacks } = compileRuleset(rules); - // Every enforce-carrying pilot rule compiles cleanly (they all supply a config/cycle clause). - expect(compiled.map((c) => c.ruleId).sort()).toEqual(enforced.map((r) => r.id).sort()); - expect(fallbacks).toHaveLength(0); + + // The five import-graph-expressible rules lower to a dependency-cruiser check. + expect(compiled.map((c) => c.ruleId).sort()).toEqual(['HXA-01', 'HXA-02', 'HXA-04', 'HXA-05', 'HXA-07']); + // The two structural/positive rules (implements-ports, AOP-in-infra-only) are not + // import-graph-expressible → they take the documented per-rule NATIVE fallback rather + // than emitting an all-matching (false-positive-prone) rule, and never fail the run. + expect(fallbacks.map((f) => f.ruleId).sort()).toEqual(['HXA-03', 'HXA-06']); + for (const f of fallbacks) { + expect(f.fallback).toBe('native'); + expect(f.tool).toBe('dependency-cruiser'); + } for (const c of compiled) { expect(c.tool).toBe('dependency-cruiser'); expect(c.runtime).toBe('node'); diff --git a/src/packages/core-domain/src/application/validators/enforcement/provisioning.spec.ts b/src/packages/core-domain/src/application/validators/enforcement/provisioning.spec.ts index 2a85161e3..b2cfd5c94 100644 --- a/src/packages/core-domain/src/application/validators/enforcement/provisioning.spec.ts +++ b/src/packages/core-domain/src/application/validators/enforcement/provisioning.spec.ts @@ -6,9 +6,16 @@ import { enforceSandboxPolicy, executeRestorePlan, InMemoryEvaluationCache, + materializeAndProvisionEnvironment, provisionEvaluationEnvironment, + type IRepositorySourceReader, + type IWorkspaceMaterializer, + type MaterializedEnvironment, type ProvisionedEnvironment, + type RepositorySourceRef, + type RepositorySources, resolveProjectScope, + resolveRestorePlanFromManifest, resolveRuntimeFromManifest, SandboxedProcessRunner, } from './provisioning'; @@ -205,4 +212,151 @@ describe('provisionEvaluationEnvironment (GT-512 PA-06 — compose scope + cache expect(retry.cached).toBe(false); expect(retry.ready).toBe(true); }); + + it('honours an explicit restorePlan override (PA-05: manifest-resolved commands)', async () => { + const runner = new SequenceRunner([ok()]); + const env = await provisionEvaluationEnvironment( + { ...base, restorePlan: [{ command: 'pnpm', args: ['install', '--frozen-lockfile'] }] }, + runner, + ); + expect(env.ready).toBe(true); + expect(runner.calls).toHaveLength(1); + expect(runner.calls[0]).toMatchObject({ command: 'pnpm', args: ['install', '--frozen-lockfile'], cwd: '/c' }); + }); +}); + +describe('resolveRestorePlanFromManifest (GT-512 PA-05 — toolchain from evolith.yaml)', () => { + it('prefers explicit toolchain.restore commands over the runtime default', () => { + const plan = resolveRestorePlanFromManifest({ + toolchain: { runtime: 'node', restore: [{ command: 'yarn', args: ['install', '--immutable'] }] }, + }); + expect(plan).toEqual([{ command: 'yarn', args: ['install', '--immutable'] }]); + }); + + it('falls back to the runtime default when no explicit commands are declared', () => { + expect(resolveRestorePlanFromManifest({ toolchain: { runtime: 'dotnet' } })).toEqual(buildRestorePlan('dotnet')); + }); + + it('drops malformed command entries and defaults args to []', () => { + const plan = resolveRestorePlanFromManifest({ + toolchain: { runtime: 'node', restore: [{ args: ['x'] } as never, { command: 'make' }] }, + }); + expect(plan).toEqual([{ command: 'make', args: [] }]); + }); + + it('returns undefined when the manifest declares neither a usable runtime nor commands', () => { + expect(resolveRestorePlanFromManifest({ toolchain: { runtime: 'ruby' } })).toBeUndefined(); + expect(resolveRestorePlanFromManifest(undefined)).toBeUndefined(); + }); +}); + +describe('materializeAndProvisionEnvironment (GT-512 PA-07 — fetch → materialize → restore → scope)', () => { + /** Stub reader delivering a TEXT tarball (no installed deps) + a resolved SHA. */ + class StubReader implements IRepositorySourceReader { + readonly calls: RepositorySourceRef[] = []; + constructor(private readonly sources: RepositorySources) {} + async fetchSources(ref: RepositorySourceRef): Promise { + this.calls.push(ref); + return this.sources; + } + } + /** Stub materializer recording what it wrote and returning a fake checkout path. */ + class StubMaterializer implements IWorkspaceMaterializer { + readonly written: Array>> = []; + constructor(private readonly path = '/work/checkout') {} + async materialize(files: Readonly>): Promise { + this.written.push(files); + return this.path; + } + } + const parseYaml: (t: string) => unknown = (t) => JSON.parse(t); // deterministic stub parser + + const source: RepositorySourceRef = { owner: 'acme', repo: 'sat', ref: 'main' }; + const files = { + 'evolith.yaml': JSON.stringify({ toolchain: { runtime: 'node' } }), + 'apps/a/src/index.ts': 'export const x = 1;', + }; + const sources: RepositorySources = { commitSha: 'deadbeef', files }; + + it('fetches, materializes, restores, and exposes Nx-project-scoped analysis paths', async () => { + const reader = new StubReader(sources); + const materializer = new StubMaterializer('/work/checkout'); + const runner = new SequenceRunner([ok()]); + const env = await materializeAndProvisionEnvironment( + { source, changedFiles: ['apps/a/src/index.ts'], projectRoots: ['apps/a', 'apps/b'] }, + { reader, materializer, runner, parseManifest: parseYaml }, + ); + + expect(reader.calls).toEqual([source]); + expect(materializer.written).toHaveLength(1); + expect(env.commitSha).toBe('deadbeef'); + expect(env.checkoutPath).toBe('/work/checkout'); + expect(env.runtime).toBe('node'); + // restore ran the manifest-resolved node plan (npm ci) in the materialized checkout + expect(runner.calls).toHaveLength(1); + expect(runner.calls[0]).toMatchObject({ command: 'npm', args: ['ci'], cwd: '/work/checkout' }); + // Nx scoping: only the affected project, joined under the restored checkout + expect(env.scope.projects).toEqual(['apps/a']); + expect(env.analysisPaths).toEqual(['/work/checkout/apps/a']); + expect(env.ready).toBe(true); + }); + + it('resolves the runtime + restore plan from the fetched manifest (PA-05), not hard-coded', async () => { + const dotnetFiles = { 'evolith.yaml': JSON.stringify({ toolchain: { runtime: 'dotnet' } }) }; + const reader = new StubReader({ commitSha: 'c1', files: dotnetFiles }); + const runner = new SequenceRunner([ok(), ok()]); + const env = await materializeAndProvisionEnvironment( + { source }, + { reader, materializer: new StubMaterializer(), runner, parseManifest: parseYaml }, + ); + expect(env.runtime).toBe('dotnet'); + expect(runner.calls.map((c) => c.command)).toEqual(['dotnet', 'dotnet']); // restore + build + }); + + it('serves a PA-03 cache hit WITHOUT re-fetching or re-materializing', async () => { + const reader = new StubReader(sources); + const materializer = new StubMaterializer(); + const runner = new SequenceRunner([ok(), ok()]); + const cache = new InMemoryEvaluationCache(); + const deps = { reader, materializer, runner, cache, parseManifest: parseYaml }; + await materializeAndProvisionEnvironment({ source, changedFiles: ['apps/a/src/index.ts'] }, deps); + const second = await materializeAndProvisionEnvironment({ source, changedFiles: ['apps/a/src/index.ts'] }, deps); + expect(second.cached).toBe(true); + expect(reader.calls).toHaveLength(2); // reader IS consulted to resolve the SHA... + expect(materializer.written).toHaveLength(1); // ...but materialize/restore are skipped + expect(runner.calls).toHaveLength(1); + }); + + it('unscoped changes expose the whole restored checkout as the analysis path', async () => { + const reader = new StubReader(sources); + const env = await materializeAndProvisionEnvironment( + { source, changedFiles: ['unmapped/file.ts'], projectRoots: ['apps/a'] }, + { reader, materializer: new StubMaterializer('/work/checkout'), runner: new SequenceRunner([ok()]), parseManifest: parseYaml }, + ); + expect(env.scope.unscoped).toBe(true); + expect(env.analysisPaths).toEqual(['/work/checkout']); + }); + + it('a manifest with no resolvable runtime skips restore rather than guessing a toolchain', async () => { + const reader = new StubReader({ commitSha: 'c2', files: { 'README.md': '# no manifest' } }); + const runner = new SequenceRunner([ok()]); + const env = await materializeAndProvisionEnvironment( + { source }, + { reader, materializer: new StubMaterializer(), runner, parseManifest: parseYaml }, + ); + expect(env.runtime).toBe('shell'); + expect(runner.calls).toHaveLength(0); // nothing restored + expect(env.ready).toBe(true); + }); + + it('a failed restore yields ready:false and is NOT cached', async () => { + const reader = new StubReader(sources); + const cache = new InMemoryEvaluationCache(); + const env = await materializeAndProvisionEnvironment( + { source }, + { reader, materializer: new StubMaterializer(), runner: new SequenceRunner([fail()]), cache, parseManifest: parseYaml }, + ); + expect(env.ready).toBe(false); + expect(cache.has(env.cacheKey)).toBe(false); + }); }); diff --git a/src/packages/core-domain/src/application/validators/enforcement/provisioning.ts b/src/packages/core-domain/src/application/validators/enforcement/provisioning.ts index c345b05f5..ad0ea09a3 100644 --- a/src/packages/core-domain/src/application/validators/enforcement/provisioning.ts +++ b/src/packages/core-domain/src/application/validators/enforcement/provisioning.ts @@ -14,6 +14,7 @@ */ import { createHash } from 'node:crypto'; +import { posix as posixPath } from 'node:path'; import type { EnforcerRuntime, IProcessRunner, ProcessResult, ProcessSpec } from './enforcer.types'; @@ -213,8 +214,20 @@ export class SandboxedProcessRunner implements IProcessRunner { // PA-05 Toolchain resolution from the evolith.yaml manifest // --------------------------------------------------------------------------- +/** One restore/toolchain command as declared in the manifest. */ +interface ManifestCommand { + readonly command?: string; + readonly args?: readonly string[]; +} + +interface ToolchainSection { + readonly runtime?: string; + /** Explicit, ordered restore commands overriding the runtime default (PA-05). */ + readonly restore?: readonly ManifestCommand[]; +} + interface ToolchainManifest { - readonly toolchain?: { readonly runtime?: string }; + readonly toolchain?: ToolchainSection; readonly runtime?: string; } @@ -230,6 +243,26 @@ export function resolveRuntimeFromManifest(manifest: ToolchainManifest | undefin return declared && (RUNTIMES as readonly string[]).includes(declared) ? (declared as EnforcerRuntime) : undefined; } +/** + * Resolve the ordered restore plan from the `evolith.yaml` manifest (PA-05) — the + * commands are read from the manifest, NOT hard-coded at the call site. Precedence: + * 1. explicit `toolchain.restore[]` in the manifest (any tenant-declared toolchain), else + * 2. the runtime default from {@link buildRestorePlan} for the manifest-declared runtime. + * Returns `undefined` when the manifest declares neither a usable runtime nor commands — + * the caller then skips restore rather than guessing a toolchain. + */ +export function resolveRestorePlanFromManifest(manifest: ToolchainManifest | undefined): ProcessSpec[] | undefined { + const explicit = manifest?.toolchain?.restore; + if (explicit && explicit.length) { + const specs = explicit + .filter((c): c is ManifestCommand & { command: string } => typeof c?.command === 'string' && c.command.length > 0) + .map((c) => ({ command: c.command, args: c.args ? [...c.args] : [] })); + if (specs.length) return specs; + } + const runtime = resolveRuntimeFromManifest(manifest); + return runtime ? buildRestorePlan(runtime) : undefined; +} + // --------------------------------------------------------------------------- // PA-06 Restore EXECUTION + provisioning orchestration // --------------------------------------------------------------------------- @@ -281,6 +314,12 @@ export interface ProvisionRequest { readonly projectRoots?: readonly string[]; /** Extra cache discriminators (e.g. ruleset version) so a ruleset bump misses. */ readonly cacheDiscriminators?: readonly string[]; + /** + * Explicit restore plan (PA-05: resolved from the `evolith.yaml` manifest rather than + * hard-coded). When set it overrides the runtime default; when omitted the runtime's + * {@link buildRestorePlan} default is used. + */ + readonly restorePlan?: readonly ProcessSpec[]; } export interface ProvisionedEnvironment { @@ -312,7 +351,7 @@ export async function provisionEvaluationEnvironment( if (hit) return { ...hit, cached: true }; const scope = resolveProjectScope(req.changedFiles ?? [], req.projectRoots ?? []); - const plan = buildRestorePlan(req.runtime); + const plan = req.restorePlan ?? buildRestorePlan(req.runtime); const restore = plan.length ? await executeRestorePlan(plan, runner, req.checkoutPath) : undefined; const ready = restore ? restore.ok : true; @@ -328,3 +367,181 @@ export async function provisionEvaluationEnvironment( if (ready) cache.set(cacheKey, env); return env; } + +// --------------------------------------------------------------------------- +// PA-07 Fetch → materialize → provision integration (repo checkout seam) +// --------------------------------------------------------------------------- + +/** + * An opaque locator for a satellite repository revision to analyze. The Core never + * receives raw disk paths (ADR-0074); it receives this reference and the reader resolves + * the bytes. Fields are all optional so the same shape covers GitHub coordinates, an + * evaluation `workspaceRef`, or an already-inlined payload. + */ +export interface RepositorySourceRef { + readonly owner?: string; + readonly repo?: string; + /** Branch, tag, or commit SHA to fetch. */ + readonly ref?: string; + /** Opaque workspace reference (ADR-0074) when coordinates are not used. */ + readonly workspaceRef?: string; +} + +/** + * The result of a fetch: a "TEXT tarball" — a map of RELATIVE posix path → text content + * with NO installed dependencies (the identical shape `OverlayFileSystem` ingests). The + * `GitHubRepositorySourceReader` delivers exactly this; provisioning then materializes it + * to disk and runs the restore plan so an analyzer sees a RESTORED checkout. + */ +export interface RepositorySources { + /** Resolved commit SHA the sources correspond to (drives the PA-03 cache key). */ + readonly commitSha: string; + /** RELATIVE posix path → text content. Deps are NOT included (restore installs them). */ + readonly files: Readonly>; +} + +/** + * Fetches repository sources as a TEXT tarball (no installed deps). The production adapter + * (`GitHubRepositorySourceReader`, infra) hits the GitHub API; tests inject a stub so the + * integration seam is exercised WITHOUT the network. Core depends only on this port. + */ +export interface IRepositorySourceReader { + fetchSources(ref: RepositorySourceRef): Promise; +} + +/** + * Writes an in-memory files map to a real working directory and returns the ABSOLUTE + * checkout path the restore plan and analyzers run against. Implemented by an infra + * adapter over `IFileSystem` (`NodeWorkspaceMaterializer`); the Core stays stateless and + * path-agnostic behind this port. Tests inject a stub that records what was materialized. + */ +export interface IWorkspaceMaterializer { + materialize(files: Readonly>): Promise; +} + +/** Manifest file names probed (in order) inside the fetched sources for PA-05 resolution. */ +const MANIFEST_FILENAMES: readonly string[] = ['evolith.yaml', 'evolith.yml']; + +/** Parses `evolith.yaml` text into a manifest object (inject the infra YAML parser). */ +export type ManifestParser = (text: string) => unknown; + +export interface MaterializeProvisionRequest { + readonly source: RepositorySourceRef; + readonly changedFiles?: readonly string[]; + readonly projectRoots?: readonly string[]; + readonly cacheDiscriminators?: readonly string[]; + /** + * Explicit runtime override. When omitted, the runtime AND restore plan are resolved + * from the fetched `evolith.yaml` manifest (PA-05) using {@link ManifestParser}. + */ + readonly runtime?: EnforcerRuntime; +} + +export interface MaterializedEnvironment extends ProvisionedEnvironment { + /** The revision reference that produced this checkout. */ + readonly source: RepositorySourceRef; + /** Resolved commit SHA (from the reader). */ + readonly commitSha: string; + /** + * Absolute, Nx-project-scoped paths exposed to the analyzers. One entry per affected + * project root (joined under the restored checkout); `[checkoutPath]` when unscoped. + */ + readonly analysisPaths: readonly string[]; +} + +/** Locate + parse the `evolith.yaml` manifest inside a fetched files map (PA-05). */ +function readManifest( + files: Readonly>, + parse: ManifestParser | undefined, +): ToolchainManifest | undefined { + if (!parse) return undefined; + const key = MANIFEST_FILENAMES.find((name) => typeof files[name] === 'string'); + if (!key) return undefined; + try { + const parsed = parse(files[key]); + return parsed && typeof parsed === 'object' ? (parsed as ToolchainManifest) : undefined; + } catch { + return undefined; // a malformed manifest must not crash provisioning; treat as absent + } +} + +/** Join the restored checkout with each scoped project root → absolute analyzer paths. */ +function resolveAnalysisPaths(checkoutPath: string, scope: ProjectScope): string[] { + if (scope.unscoped || scope.projects.length === 0) return [checkoutPath]; + return scope.projects.map((project) => posixPath.join(checkoutPath, project)); +} + +/** + * Wire the real repo fetch/checkout into provisioning (GT-512 PA-07). Composes the whole + * chain an analyzer needs to run against a RESTORED, project-scoped checkout: + * + * 1. FETCH — `reader.fetchSources(source)` returns the TEXT tarball (no installed deps) + * and the resolved commit SHA (drives the PA-03 cache key). + * 2. MANIFEST (PA-05) — resolve the runtime + restore plan from the fetched `evolith.yaml` + * rather than hard-coding; an explicit `req.runtime` wins. + * 3. MATERIALIZE — write the in-memory sources to a working dir (`materializer`). + * 4. RESTORE + SCOPE + CACHE — delegate to {@link provisionEvaluationEnvironment}, which + * runs the restore plan through the (sandbox-wrapped) `runner`, computes the + * Nx-affected scope, and caches by SHA + changed-files. + * 5. EXPOSE — return the restored checkout path plus the project-scoped `analysisPaths`. + * + * On a PA-03 cache HIT the fetch/materialize/restore are all skipped — the cache is keyed + * BEFORE any I/O so a re-evaluation of the same commit + scope never re-fetches. The + * `runner` SHOULD be a {@link SandboxedProcessRunner}. The `reader`/`materializer` are + * ports: tests inject stubs (no network, no real `npm ci`); production uses the GitHub + * reader + Node materializer. A checkout whose runtime cannot be resolved skips restore + * (`ready:true`, nothing to install) rather than guessing a toolchain. + */ +export async function materializeAndProvisionEnvironment( + req: MaterializeProvisionRequest, + deps: { + readonly reader: IRepositorySourceReader; + readonly materializer: IWorkspaceMaterializer; + readonly runner: IProcessRunner; + readonly cache?: IEvaluationCache; + readonly parseManifest?: ManifestParser; + }, +): Promise { + const cache = deps.cache ?? new InMemoryEvaluationCache(); + + const sources = await deps.reader.fetchSources(req.source); + + const cacheKey = computeEvaluationCacheKey( + sources.commitSha, + req.changedFiles ?? [], + req.cacheDiscriminators ?? [], + ); + const hit = cache.get(cacheKey); + if (hit) return { ...hit, cached: true }; + + const manifest = readManifest(sources.files, deps.parseManifest); + const runtime = req.runtime ?? resolveRuntimeFromManifest(manifest) ?? 'shell'; + const restorePlan = req.runtime ? buildRestorePlan(req.runtime) : resolveRestorePlanFromManifest(manifest) ?? []; + + const checkoutPath = await deps.materializer.materialize(sources.files); + + const provisioned = await provisionEvaluationEnvironment( + { + runtime, + checkoutPath, + commitSha: sources.commitSha, + changedFiles: req.changedFiles, + projectRoots: req.projectRoots, + cacheDiscriminators: req.cacheDiscriminators, + restorePlan, + }, + deps.runner, + // Inner cache disabled: this orchestrator owns caching of the richer MaterializedEnvironment. + new InMemoryEvaluationCache(), + ); + + const env: MaterializedEnvironment = { + ...provisioned, + cacheKey, + source: req.source, + commitSha: sources.commitSha, + analysisPaths: resolveAnalysisPaths(checkoutPath, provisioned.scope), + }; + if (env.ready) cache.set(cacheKey, env); + return env; +} diff --git a/src/packages/core-domain/src/domain/codeowners.spec.ts b/src/packages/core-domain/src/domain/codeowners.spec.ts new file mode 100644 index 000000000..273fd1d5c --- /dev/null +++ b/src/packages/core-domain/src/domain/codeowners.spec.ts @@ -0,0 +1,105 @@ +import { + parseCodeowners, + resolveCodeowner, + codeownersPatternToRegExp, + enrichViolationsWithCodeowners, +} from './codeowners'; +import { makeViolation } from './violation'; + +describe('parseCodeowners (GT-518 · EAG-13 — CODEOWNERS enrichment)', () => { + it('parses pattern + owners, skips comments/blank/owner-less lines, preserves order', () => { + const rules = parseCodeowners( + [ + '# comment', + '', + '* @org/default', + '/src/packages/core-domain/ @org/core-team @arch-lead', + '/legacy/', // owner-less → unset → dropped + 'docs/adr/*.md @org/governance', + ].join('\n'), + ); + expect(rules.map((r) => r.pattern)).toEqual([ + '*', + '/src/packages/core-domain/', + 'docs/adr/*.md', + ]); + expect(rules[1].owners).toEqual(['@org/core-team', '@arch-lead']); + expect(rules.map((r) => r.order)).toEqual([0, 1, 2]); + }); +}); + +describe('codeownersPatternToRegExp', () => { + it('bare catch-all `*` matches any depth', () => { + const re = codeownersPatternToRegExp('*'); + expect(re.test('a.ts')).toBe(true); + expect(re.test('src/deep/b.ts')).toBe(true); + }); + + it('anchored dir pattern matches the subtree only', () => { + const re = codeownersPatternToRegExp('/src/packages/core-domain/'); + expect(re.test('src/packages/core-domain/x/y.ts')).toBe(true); + expect(re.test('src/packages/core-domain')).toBe(true); + expect(re.test('src/packages/other/z.ts')).toBe(false); + }); + + it('unanchored extension pattern matches at any depth', () => { + const re = codeownersPatternToRegExp('*.md'); + expect(re.test('README.md')).toBe(true); + expect(re.test('docs/adr/ADR-0002.md')).toBe(true); + expect(re.test('docs/adr/ADR-0002.ts')).toBe(false); + }); + + it('`**` crosses directories', () => { + const re = codeownersPatternToRegExp('docs/**/adr'); + expect(re.test('docs/a/b/adr')).toBe(true); + expect(re.test('docs/adr')).toBe(true); + }); +}); + +describe('resolveCodeowner (last match wins)', () => { + const rules = parseCodeowners( + [ + '* @org/default', + '/src/packages/core-domain/ @org/core-team', + '/src/packages/core-domain/security/ @org/security', + ].join('\n'), + ); + + it('falls back to the catch-all default', () => { + expect(resolveCodeowner('README.md', rules)).toEqual(['@org/default']); + }); + + it('prefers the LAST (most specific, latest) matching rule', () => { + expect(resolveCodeowner('src/packages/core-domain/a.ts', rules)).toEqual(['@org/core-team']); + expect(resolveCodeowner('src/packages/core-domain/security/b.ts', rules)).toEqual(['@org/security']); + }); + + it('returns undefined for an empty/locationless path', () => { + expect(resolveCodeowner('', rules)).toBeUndefined(); + }); +}); + +describe('enrichViolationsWithCodeowners', () => { + const rules = parseCodeowners('/src/ @org/core-team @arch-lead\n'); + + it('sets owner (space-joined) without touching the fingerprint', () => { + const v = makeViolation({ + ruleId: 'ADR-0002', + tool: 'drift-gate', + file: 'src/a.ts', + severity: 'error', + message: 'boundary', + }); + const [enriched] = enrichViolationsWithCodeowners([v], rules); + expect(enriched.owner).toBe('@org/core-team @arch-lead'); + expect(enriched.fingerprint).toBe(v.fingerprint); + }); + + it('never overwrites an existing owner and leaves unmatched files unchanged', () => { + const owned = makeViolation({ ruleId: 'r', tool: 't', file: 'src/a.ts', severity: 'error', message: 'm', owner: '@keep' }); + const unmatched = makeViolation({ ruleId: 'r', tool: 't', file: 'lib/z.ts', severity: 'error', message: 'm' }); + const [a, b] = enrichViolationsWithCodeowners([owned, unmatched], rules); + expect(a.owner).toBe('@keep'); + expect(b.owner).toBeUndefined(); + }); +}); diff --git a/src/packages/core-domain/src/domain/codeowners.ts b/src/packages/core-domain/src/domain/codeowners.ts new file mode 100644 index 000000000..074c413d7 --- /dev/null +++ b/src/packages/core-domain/src/domain/codeowners.ts @@ -0,0 +1,137 @@ +/** + * CODEOWNERS enrichment (GT-518 · EAG-13). + * + * Resolves the accountable `owner` of a {@link Violation} from a GitHub/GitLab + * `CODEOWNERS` file — the source of truth most repos already maintain — so the CI/PR + * drift gate can cite WHO owns the ADR a change violated. This complements the IDP + * ownership ingestion in `./ownership` (Backstage/Port/Cortex): CODEOWNERS is the + * git-native fallback when no IDP blueprint exists. + * + * Layering: PURE. Reading `.github/CODEOWNERS` off disk is the caller's (infra) concern; + * these functions take the already-read file CONTENT and emit the parsed rules + a + * resolver + the same `enrichViolationsWith*` shape as `./ownership`. + */ + +import type { Violation } from './violation'; + +/** One parsed CODEOWNERS line: a path pattern and its accountable owners. */ +export interface CodeownersRule { + /** Raw path pattern as written (e.g. `/src/`, `*.ts`, `docs/adr/**`). */ + readonly pattern: string; + /** Owners (teams/users) verbatim, e.g. `@org/platform`, `a@b.com`. */ + readonly owners: readonly string[]; + /** Source order (0-based). Last matching rule wins, so a higher index outranks a lower one. */ + readonly order: number; +} + +/** Normalize a repo-relative path for matching: posix separators, strip `./` and leading/trailing `/`. */ +function normalizePath(p: string): string { + // Char-based trimming (no `^\/+` / `\/+$` regexes) to avoid polynomial + // backtracking (ReDoS) on inputs with many leading/trailing slashes. + let s = p.replace(/\\/g, '/').trim(); + if (s.startsWith('./')) s = s.slice(2); + let start = 0; + let end = s.length; + while (start < end && s.charCodeAt(start) === 47 /* '/' */) start += 1; + while (end > start && s.charCodeAt(end - 1) === 47) end -= 1; + return s.slice(start, end); +} + +/** + * Parse a CODEOWNERS file's CONTENT into ordered {@link CodeownersRule}s. Blank lines and + * `#` comments are dropped; a line with a pattern but no owners is dropped (it "unsets" + * ownership in the spec — we simply record no rule). Order is preserved for last-match-wins. + */ +export function parseCodeowners(content: string): CodeownersRule[] { + const rules: CodeownersRule[] = []; + const lines = content.split(/\r?\n/); + for (const raw of lines) { + // Strip trailing `#` comment via indexOf (no `#.*$` regex — avoids ReDoS). + const hashIdx = raw.indexOf('#'); + const line = (hashIdx === -1 ? raw : raw.slice(0, hashIdx)).trim(); + if (line.length === 0) continue; + const [pattern, ...owners] = line.split(/\s+/); + if (!pattern || owners.length === 0) continue; + rules.push({ pattern, owners, order: rules.length }); + } + return rules; +} + +/** + * Compile a CODEOWNERS pattern to a RegExp over normalized repo-relative paths. + * Supports the common gitignore-style semantics CODEOWNERS uses: + * - a leading `/` OR any internal `/` anchors the pattern to the repo root; + * - a bare name (`*.ts`, `Dockerfile`) matches at ANY depth; + * - a trailing `/` (and, implicitly, any dir prefix) matches the whole subtree; + * - `*` matches within a segment, `**` (and `**​/`) matches across directories, `?` one char. + */ +export function codeownersPatternToRegExp(pattern: string): RegExp { + let p = pattern.trim(); + const dirOnly = p.endsWith('/'); + if (dirOnly) p = p.slice(0, -1); + const anchored = p.startsWith('/'); + if (anchored) p = p.slice(1); + const hasInternalSlash = p.includes('/'); + + let body = ''; + for (let i = 0; i < p.length; i++) { + const c = p[i]; + if (c === '*') { + if (p[i + 1] === '*') { + i++; + if (p[i + 1] === '/') { + i++; + body += '(?:.*/)?'; + } else { + body += '.*'; + } + } else { + body += '[^/]*'; + } + } else if (c === '?') { + body += '[^/]'; + } else if (/[.+^${}()|[\]\\]/.test(c)) { + body += `\\${c}`; + } else { + body += c; + } + } + + const left = anchored || hasInternalSlash ? '^' : '^(?:.*/)?'; + // A file pattern matches the file; a dir (or dir-prefix) pattern matches the whole subtree. + const right = '(?:/.*)?$'; + return new RegExp(left + body + right); +} + +/** + * Resolve the owners of a file by CODEOWNERS last-match-wins precedence. Returns the owners of + * the LAST rule (by source order) whose pattern matches, or `undefined` when nothing matches. + */ +export function resolveCodeowner(filePath: string, rules: readonly CodeownersRule[]): readonly string[] | undefined { + const file = normalizePath(filePath); + if (file.length === 0) return undefined; + let match: CodeownersRule | undefined; + for (const rule of rules) { + if (codeownersPatternToRegExp(rule.pattern).test(file)) { + if (!match || rule.order > match.order) match = rule; + } + } + return match?.owners; +} + +/** + * Enrich violations with a resolved `owner` from CODEOWNERS, WITHOUT overwriting an owner a + * violation already carries (e.g. from IDP ownership). Owners are joined with a space (the + * CODEOWNERS wire format). Returns new objects; never mutates the inputs. `owner` is derived + * metadata excluded from the fingerprint. + */ +export function enrichViolationsWithCodeowners( + violations: readonly Violation[], + rules: readonly CodeownersRule[], +): Violation[] { + return violations.map((v) => { + if (v.owner || !v.file) return v; + const owners = resolveCodeowner(v.file, rules); + return owners && owners.length > 0 ? { ...v, owner: owners.join(' ') } : v; + }); +} diff --git a/src/packages/core-domain/src/domain/waiver.spec.ts b/src/packages/core-domain/src/domain/waiver.spec.ts new file mode 100644 index 000000000..7f6ff8867 --- /dev/null +++ b/src/packages/core-domain/src/domain/waiver.spec.ts @@ -0,0 +1,117 @@ +import { + requestWaiver, + approveWaiver, + rejectWaiver, + reviseWaiver, + isExpired, + effectiveStatus, + isWaiverActive, + activeWaiverFor, + applyWaivers, + InMemoryWaiverStore, + WaiverTransitionError, + type Waiver, +} from './waiver'; +import { makeViolation } from './violation'; + +const base = { + waiverRef: 'W-001', + fingerprint: 'fp-abc', + reason: 'temporary exception, tracked in GT-999', + requestedBy: 'alice', + requestedAt: '2026-07-01T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', +}; + +describe('waiver state machine (GT-518 · EAG-13 — request→approve→version→expire)', () => { + it('request creates version 1 in `requested`', () => { + const w = requestWaiver(base); + expect(w.version).toBe(1); + expect(w.status).toBe('requested'); + }); + + it('request rejects a non-future expiry', () => { + expect(() => requestWaiver({ ...base, expiresAt: base.requestedAt })).toThrow(WaiverTransitionError); + }); + + it('approve moves requested→approved and records approver/time', () => { + const w = approveWaiver(requestWaiver(base), 'bob', '2026-07-02T00:00:00.000Z'); + expect(w.status).toBe('approved'); + expect(w.approvedBy).toBe('bob'); + expect(w.approvedAt).toBe('2026-07-02T00:00:00.000Z'); + }); + + it('approve/reject from a non-requested state is illegal', () => { + const approved = approveWaiver(requestWaiver(base), 'bob', '2026-07-02T00:00:00.000Z'); + expect(() => approveWaiver(approved, 'carol', '2026-07-03T00:00:00.000Z')).toThrow(WaiverTransitionError); + expect(() => rejectWaiver(approved)).toThrow(WaiverTransitionError); + }); + + it('revise VERSIONS the waiver: v2 requested, supersedes v1, prior untouched', () => { + const v1 = approveWaiver(requestWaiver(base), 'bob', '2026-07-02T00:00:00.000Z'); + const v2 = reviseWaiver(v1, { + requestedBy: 'alice', + requestedAt: '2026-07-20T00:00:00.000Z', + expiresAt: '2026-09-01T00:00:00.000Z', + }); + expect(v2.version).toBe(2); + expect(v2.status).toBe('requested'); + expect(v2.supersedes).toBe(1); + expect(v1.status).toBe('approved'); // immutable + }); + + it('expiry is time-derived: an approved waiver reads `expired` past expiresAt', () => { + const w = approveWaiver(requestWaiver(base), 'bob', '2026-07-02T00:00:00.000Z'); + expect(isExpired(w, '2026-07-15T00:00:00.000Z')).toBe(false); + expect(isWaiverActive(w, '2026-07-15T00:00:00.000Z')).toBe(true); + expect(effectiveStatus(w, '2026-08-02T00:00:00.000Z')).toBe('expired'); + expect(isWaiverActive(w, '2026-08-02T00:00:00.000Z')).toBe(false); + }); +}); + +describe('waiver store + suppression', () => { + const fp = 'fp-abc'; + const violation = makeViolation({ + ruleId: 'ADR-0002', + tool: 'drift-gate', + file: 'src/a.ts', + severity: 'error', + message: 'boundary', + fingerprint: fp, + }); + const approved: Waiver = approveWaiver( + requestWaiver({ ...base, fingerprint: fp }), + 'bob', + '2026-07-02T00:00:00.000Z', + ); + + it('a valid (approved, unexpired) waiver suppresses its finding with an audit trail', () => { + const store = new InMemoryWaiverStore([approved]); + const { retained, suppressed } = applyWaivers([violation], store, '2026-07-15T00:00:00.000Z'); + expect(retained).toHaveLength(0); + expect(suppressed).toHaveLength(1); + expect(suppressed[0].waiver.waiverRef).toBe('W-001'); + }); + + it('an EXPIRED waiver does NOT suppress — the finding is retained', () => { + const store = new InMemoryWaiverStore([approved]); + const { retained, suppressed } = applyWaivers([violation], store, '2026-08-02T00:00:00.000Z'); + expect(retained).toHaveLength(1); + expect(suppressed).toHaveLength(0); + }); + + it('a merely REQUESTED (unapproved) waiver does not suppress', () => { + const store = new InMemoryWaiverStore([requestWaiver({ ...base, fingerprint: fp })]); + expect(activeWaiverFor(store, fp, '2026-07-15T00:00:00.000Z')).toBeUndefined(); + }); + + it('activeWaiverFor prefers the highest-version active waiver', () => { + const v2 = approveWaiver( + reviseWaiver(approved, { requestedBy: 'a', requestedAt: '2026-07-10T00:00:00.000Z', expiresAt: '2026-09-01T00:00:00.000Z' }), + 'bob', + '2026-07-11T00:00:00.000Z', + ); + const store = new InMemoryWaiverStore([approved, v2]); + expect(activeWaiverFor(store, fp, '2026-07-15T00:00:00.000Z')?.version).toBe(2); + }); +}); diff --git a/src/packages/core-domain/src/domain/waiver.ts b/src/packages/core-domain/src/domain/waiver.ts new file mode 100644 index 000000000..aef467b3a --- /dev/null +++ b/src/packages/core-domain/src/domain/waiver.ts @@ -0,0 +1,238 @@ +/** + * Waiver flow for `waiverRef` (GT-518 · EAG-13). + * + * A deterministic state machine + a store seam so a violation can be temporarily WAIVED + * (suppressed from blocking) with a full audit trail until it expires. The four lifecycle + * steps the gap mandates are pure transitions: + * + * request → approve → version → expire + * + * - `requestWaiver` creates version 1 in `requested`. + * - `approveWaiver` moves `requested → approved` (records approver + time). + * - `reviseWaiver` VERSIONS an existing waiver: a new immutable version (n+1) back in + * `requested`, `supersedes` the prior — nothing is mutated in place. + * - expiry is TIME-derived, not a stored transition: {@link effectiveStatus}/{@link isWaiverActive} + * treat an approved waiver as `expired` once `now >= expiresAt`. + * + * A waived finding is suppressed via {@link applyWaivers}, which returns both the retained + * violations AND the audit trail (which waiver, which version, expiry) of every suppression. + * + * Layering: PURE. Persisting waivers is the infra concern behind {@link IWaiverStore}; the + * in-memory store here is the deterministic reference implementation used by tests and the CLI. + */ + +import type { Violation } from './violation'; + +/** Lifecycle state of a single waiver VERSION. `expired` is time-derived (see {@link effectiveStatus}). */ +export type WaiverStatus = 'requested' | 'approved' | 'rejected' | 'expired'; + +/** An immutable waiver version. A `waiverRef` may have several versions; the highest active one wins. */ +export interface Waiver { + /** Stable id shared across every version of this waiver (the `waiverRef` a finding cites). */ + readonly waiverRef: string; + /** 1-based version; bumped by {@link reviseWaiver}. */ + readonly version: number; + /** Fingerprint of the {@link Violation} this waiver suppresses. */ + readonly fingerprint: string; + readonly status: WaiverStatus; + /** Why the violation is waived (audit). */ + readonly reason: string; + readonly requestedBy: string; + readonly requestedAt: string; + readonly approvedBy?: string; + readonly approvedAt?: string; + /** Hard expiry (ISO-8601 UTC). After this instant the waiver no longer suppresses. */ + readonly expiresAt: string; + /** The prior version this one replaces, when produced by {@link reviseWaiver}. */ + readonly supersedes?: number; +} + +/** Raised on an illegal state transition (e.g. approving an already-approved/rejected waiver). */ +export class WaiverTransitionError extends Error { + constructor(message: string) { + super(message); + this.name = 'WaiverTransitionError'; + } +} + +export interface RequestWaiverInput { + readonly waiverRef: string; + readonly fingerprint: string; + readonly reason: string; + readonly requestedBy: string; + readonly requestedAt: string; + readonly expiresAt: string; +} + +/** Create version 1 of a waiver in `requested`. */ +export function requestWaiver(input: RequestWaiverInput): Waiver { + if (Date.parse(input.expiresAt) <= Date.parse(input.requestedAt)) { + throw new WaiverTransitionError(`Waiver ${input.waiverRef} expiresAt must be after requestedAt`); + } + return { + waiverRef: input.waiverRef, + version: 1, + fingerprint: input.fingerprint, + status: 'requested', + reason: input.reason, + requestedBy: input.requestedBy, + requestedAt: input.requestedAt, + expiresAt: input.expiresAt, + }; +} + +/** Approve a `requested` waiver → `approved`. Any other starting state is illegal. */ +export function approveWaiver(waiver: Waiver, approvedBy: string, approvedAt: string): Waiver { + if (waiver.status !== 'requested') { + throw new WaiverTransitionError( + `Waiver ${waiver.waiverRef}@v${waiver.version} cannot be approved from '${waiver.status}'`, + ); + } + return { ...waiver, status: 'approved', approvedBy, approvedAt }; +} + +/** Reject a `requested` waiver → `rejected`. Any other starting state is illegal. */ +export function rejectWaiver(waiver: Waiver): Waiver { + if (waiver.status !== 'requested') { + throw new WaiverTransitionError( + `Waiver ${waiver.waiverRef}@v${waiver.version} cannot be rejected from '${waiver.status}'`, + ); + } + return { ...waiver, status: 'rejected' }; +} + +export interface ReviseWaiverInput { + readonly reason?: string; + readonly requestedBy: string; + readonly requestedAt: string; + readonly expiresAt: string; +} + +/** + * VERSION a waiver: produce a fresh `requested` version (n+1) that `supersedes` the current one. + * Nothing is mutated in place — the prior version stays in the store as history. + */ +export function reviseWaiver(current: Waiver, input: ReviseWaiverInput): Waiver { + if (Date.parse(input.expiresAt) <= Date.parse(input.requestedAt)) { + throw new WaiverTransitionError(`Waiver ${current.waiverRef} expiresAt must be after requestedAt`); + } + return { + waiverRef: current.waiverRef, + version: current.version + 1, + fingerprint: current.fingerprint, + status: 'requested', + reason: input.reason ?? current.reason, + requestedBy: input.requestedBy, + requestedAt: input.requestedAt, + expiresAt: input.expiresAt, + supersedes: current.version, + }; +} + +/** True when `now` is at or past the waiver's `expiresAt`. */ +export function isExpired(waiver: Waiver, now: string): boolean { + return Date.parse(now) >= Date.parse(waiver.expiresAt); +} + +/** + * The effective status of a waiver AT `now`: an approved waiver past its expiry reads as + * `expired`; every other stored status passes through unchanged. + */ +export function effectiveStatus(waiver: Waiver, now: string): WaiverStatus { + if (waiver.status === 'approved' && isExpired(waiver, now)) return 'expired'; + return waiver.status; +} + +/** A waiver actively suppresses a finding only when approved AND not yet expired. */ +export function isWaiverActive(waiver: Waiver, now: string): boolean { + return effectiveStatus(waiver, now) === 'approved'; +} + +// --------------------------------------------------------------------------- +// Store seam +// --------------------------------------------------------------------------- + +/** Persistence seam for waivers. Infra implements it (fs/db); the in-memory store is the reference. */ +export interface IWaiverStore { + /** Every version recorded for a fingerprint, in insertion order. */ + list(fingerprint: string): readonly Waiver[]; + /** All waivers across all fingerprints (audit/export). */ + all(): readonly Waiver[]; + /** Record a waiver version. Idempotent on (`waiverRef`, `version`) — a duplicate replaces it. */ + put(waiver: Waiver): void; +} + +/** Deterministic, dependency-free {@link IWaiverStore} for tests and the CLI. */ +export class InMemoryWaiverStore implements IWaiverStore { + private readonly byFingerprint = new Map(); + + constructor(seed: readonly Waiver[] = []) { + for (const w of seed) this.put(w); + } + + list(fingerprint: string): readonly Waiver[] { + return this.byFingerprint.get(fingerprint) ?? []; + } + + all(): readonly Waiver[] { + return [...this.byFingerprint.values()].flat(); + } + + put(waiver: Waiver): void { + const bucket = this.byFingerprint.get(waiver.fingerprint) ?? []; + const idx = bucket.findIndex((w) => w.waiverRef === waiver.waiverRef && w.version === waiver.version); + if (idx >= 0) bucket[idx] = waiver; + else bucket.push(waiver); + this.byFingerprint.set(waiver.fingerprint, bucket); + } +} + +/** + * The active waiver for a fingerprint at `now`: the HIGHEST-version waiver that is approved and + * not expired. Returns `undefined` when none suppresses the finding (unrequested / pending / + * rejected / expired) — so the finding blocks. + */ +export function activeWaiverFor( + store: IWaiverStore, + fingerprint: string, + now: string, +): Waiver | undefined { + let active: Waiver | undefined; + for (const w of store.list(fingerprint)) { + if (isWaiverActive(w, now) && (!active || w.version > active.version)) active = w; + } + return active; +} + +/** One suppressed violation + the waiver version that suppressed it (the audit trail). */ +export interface WaiverSuppression { + readonly violation: Violation; + readonly waiver: Waiver; +} + +export interface ApplyWaiversResult { + /** Violations that were NOT waived (they still count toward blocking), each left untouched. */ + readonly retained: readonly Violation[]; + /** Violations suppressed by an active waiver, paired with the responsible waiver version. */ + readonly suppressed: readonly WaiverSuppression[]; +} + +/** + * Split violations into those an active waiver suppresses and those retained. A suppressed + * violation keeps its identity; downstream (evidence) marks it `frozen` so it does not block. + * Deterministic: no clock — `now` is supplied by the caller. + */ +export function applyWaivers( + violations: readonly Violation[], + store: IWaiverStore, + now: string, +): ApplyWaiversResult { + const retained: Violation[] = []; + const suppressed: WaiverSuppression[] = []; + for (const v of violations) { + const waiver = activeWaiverFor(store, v.fingerprint, now); + if (waiver) suppressed.push({ violation: v, waiver }); + else retained.push(v); + } + return { retained, suppressed }; +} diff --git a/src/packages/core-domain/src/evaluation/contracts/evaluation-result.ts b/src/packages/core-domain/src/evaluation/contracts/evaluation-result.ts index 9d62ab093..07c74bdd1 100644 --- a/src/packages/core-domain/src/evaluation/contracts/evaluation-result.ts +++ b/src/packages/core-domain/src/evaluation/contracts/evaluation-result.ts @@ -9,6 +9,7 @@ import type { PhaseId } from '../../domain/sdlc/phase-id'; import type { Verdict, VerdictReason } from '../../domain/verdict/verdict'; +import type { EvidenceSignal } from './quality-evidence'; /** Schema version of this contract (bumped only on incompatible changes). */ export const EVALUATION_RESULT_SCHEMA_VERSION = '1.0.0'; @@ -270,6 +271,14 @@ export interface EvaluationResult { readonly recommendations: readonly Recommendation[]; readonly requiredActions: readonly RequiredAction[]; readonly decisionRecommendation?: DecisionRecommendation; + /** + * Per-dimension signals derived from the inline quality {@link EvidenceSignal} + * evidence received on the context (ADR-0111 / GT-533). The Core READS the + * received `Evidence[]` and folds a signal per observed dimension; a context + * with no evidence surfaces a single `no-evidence` signal — advisory, never a + * failure. See {@link foldQualitySignals}. + */ + readonly qualitySignals?: readonly EvidenceSignal[]; // --- Confidence & rationale --- readonly confidence: number; // 0..1 diff --git a/src/packages/core-domain/src/evaluation/contracts/quality-evidence.spec.ts b/src/packages/core-domain/src/evaluation/contracts/quality-evidence.spec.ts index 19b8fe04b..a325da34e 100644 --- a/src/packages/core-domain/src/evaluation/contracts/quality-evidence.spec.ts +++ b/src/packages/core-domain/src/evaluation/contracts/quality-evidence.spec.ts @@ -1,6 +1,8 @@ import { normalizeEvidence, resolveEvidenceSignals, + foldQualitySignals, + DEFAULT_QUALITY_DIMENSION, type Evidence, type RawEvidence, } from './quality-evidence'; @@ -113,4 +115,42 @@ describe('quality-evidence (GT-533 · ADR-0111)', () => { expect(signals.every((s) => s.status === 'no-evidence')).toBe(true); }); }); + + describe('foldQualitySignals (pipeline-side fold of inline evidence)', () => { + const perf: Evidence = normalizeEvidence( + { source: 'lighthouse', dimension: 'performance', determinism: 'deterministic', provenance: { collectedBy: 'lighthouse' } }, + { now: fixedNow }, + ); + const a11y: Evidence = normalizeEvidence( + { source: 'lighthouse', dimension: 'a11y', determinism: 'deterministic', provenance: { collectedBy: 'lighthouse' } }, + { now: fixedNow }, + ); + + it('surfaces a present signal per observed dimension when evidence is supplied', () => { + const signals = foldQualitySignals([perf, a11y]); + expect(signals).toHaveLength(2); + expect(signals.every((s) => s.status === 'present')).toBe(true); + expect(signals.map((s) => s.dimension).sort()).toEqual(['a11y', 'performance']); + }); + + it('deduplicates repeated dimensions and attaches all matching evidence', () => { + const perf2: Evidence = normalizeEvidence( + { source: 'webpagetest', dimension: 'performance', determinism: 'deterministic', provenance: { collectedBy: 'wpt' } }, + { now: fixedNow }, + ); + const signals = foldQualitySignals([perf, perf2]); + expect(signals).toHaveLength(1); + expect(signals[0].dimension).toBe('performance'); + expect(signals[0].evidence).toHaveLength(2); + }); + + it('surfaces a single no-evidence signal for the default dimension when no evidence is supplied', () => { + expect(foldQualitySignals([])).toEqual([ + { dimension: DEFAULT_QUALITY_DIMENSION, status: 'no-evidence', evidence: [] }, + ]); + expect(foldQualitySignals()).toEqual([ + { dimension: DEFAULT_QUALITY_DIMENSION, status: 'no-evidence', evidence: [] }, + ]); + }); + }); }); diff --git a/src/packages/core-domain/src/evaluation/contracts/quality-evidence.ts b/src/packages/core-domain/src/evaluation/contracts/quality-evidence.ts index c5d731d00..3b7574e83 100644 --- a/src/packages/core-domain/src/evaluation/contracts/quality-evidence.ts +++ b/src/packages/core-domain/src/evaluation/contracts/quality-evidence.ts @@ -155,3 +155,27 @@ export function resolveEvidenceSignals( }; }); } + +/** + * Canonical dimension used to surface a single `no-evidence` signal when the + * context carries NO quality evidence at all — so absence is reported explicitly + * and advisory, never as a failure (ADR-0111 §3). + */ +export const DEFAULT_QUALITY_DIMENSION = 'quality'; + +/** + * Fold the inline `Evidence[]` received on an {@link EvaluationContext} into the + * per-dimension {@link EvidenceSignal}s the Core surfaces on its result. This is + * the pipeline-side entry point for {@link resolveEvidenceSignals}: it resolves a + * signal for every dimension OBSERVED in the received evidence (each `present`), + * and — when no evidence was supplied — surfaces a single `no-evidence` signal for + * the {@link DEFAULT_QUALITY_DIMENSION}. The Core only READS the inline evidence; + * it never executes a provider (ADR-0111 §2). Missing/empty evidence is advisory, + * NEVER a failure the Core caused (ADR-0111 §3). + */ +export function foldQualitySignals(evidence: readonly Evidence[] = []): EvidenceSignal[] { + const observedDimensions = [...new Set(evidence.map((e) => e.dimension))]; + const requestedDimensions = + observedDimensions.length > 0 ? observedDimensions : [DEFAULT_QUALITY_DIMENSION]; + return resolveEvidenceSignals(requestedDimensions, evidence); +} diff --git a/src/packages/core-domain/src/evaluation/drift-gate.spec.ts b/src/packages/core-domain/src/evaluation/drift-gate.spec.ts new file mode 100644 index 000000000..73220feec --- /dev/null +++ b/src/packages/core-domain/src/evaluation/drift-gate.spec.ts @@ -0,0 +1,140 @@ +import type { EvaluationResult, GapFinding } from './contracts/evaluation-result'; +import { EVALUATION_RESULT_SCHEMA_VERSION } from './contracts/evaluation-result'; +import { Verdict } from '../domain/verdict/verdict'; +import { + evaluateDriftGate, + PrCommentFallbackPublisher, + DRIFT_GATE_BLOCK_EXIT_CODE, + DRIFT_GATE_PASS_EXIT_CODE, +} from './drift-gate'; +import { parseCodeowners } from '../domain/codeowners'; +import { requestWaiver, approveWaiver, InMemoryWaiverStore } from '../domain/waiver'; +import { evaluationResultToViolations } from './sarif-exporter'; + +function makeResult(overrides: Partial = {}): EvaluationResult { + return { + overallVerdict: Verdict.FAIL, + outcome: 'rejected', + results: {}, + rulesExecuted: [{ ruleId: 'ADR-0002', engine: 'native' } as never], + policiesApplied: [], + gaps: [], + risks: [], + missingEvidence: [], + incompleteArtifacts: [], + recommendations: [], + requiredActions: [], + confidence: 0.9, + rationale: 'test', + versions: { core: '1.2.3' }, + evaluatedAt: '2026-07-15T00:00:00.000Z', + correlationId: 'corr-1', + schemaVersion: EVALUATION_RESULT_SCHEMA_VERSION, + ...overrides, + }; +} + +const adrGap = (o: Partial = {}): GapFinding => ({ + id: 'g1', + requirementRef: 'ADR-0002', + severity: 'error', + message: 'domain imports infrastructure', + location: 'src/packages/core-domain/src/domain/a.ts:12:3', + ...o, +}); + +const codeowners = parseCodeowners( + ['* @org/default', '/src/packages/core-domain/ @org/core-team @arch-lead'].join('\n'), +); + +describe('evaluateDriftGate (GT-518 · EAG-13 — AC1 block + cite ADR/owner)', () => { + it('blocks a violating result with exit code + ADR + owner citation', () => { + const decision = evaluateDriftGate({ result: makeResult({ gaps: [adrGap()] }), codeowners }); + expect(decision.blocked).toBe(true); + expect(decision.exitCode).toBe(DRIFT_GATE_BLOCK_EXIT_CODE); + expect(decision.citations).toHaveLength(1); + const c = decision.citations[0]; + expect(c.ref).toBe('ADR-0002'); + expect(c.isAdr).toBe(true); + expect(c.owner).toBe('@org/core-team @arch-lead'); + expect(decision.prCommentBody).toContain('ADR-0002'); + expect(decision.prCommentBody).toContain('@org/core-team'); + expect(decision.prCommentBody).toContain('Blocked'); + }); + + it('passes (exit 0, no-block comment) when there are no error findings', () => { + const decision = evaluateDriftGate({ result: makeResult({ gaps: [], overallVerdict: Verdict.PASS }) }); + expect(decision.blocked).toBe(false); + expect(decision.exitCode).toBe(DRIFT_GATE_PASS_EXIT_CODE); + expect(decision.prCommentBody).toContain('No blocking architecture violations'); + }); + + it('a warning finding never blocks', () => { + const decision = evaluateDriftGate({ result: makeResult({ gaps: [adrGap({ severity: 'warning' })] }) }); + expect(decision.blocked).toBe(false); + }); + + it('the evidence manifest carries EVD-01..03 fields (id/source/status/blockingFailures)', () => { + const { evidence } = evaluateDriftGate({ result: makeResult({ gaps: [adrGap()] }), codeowners }); + expect(evidence.id).toContain('drift-gate'); + expect(evidence.status).toBe('fail'); + expect(evidence.blockingFailures).toBe(1); + expect(evidence.relatedRuleIds).toContain('ADR-0002'); + }); +}); + +describe('evaluateDriftGate — waiver suppression (AC3)', () => { + const result = makeResult({ gaps: [adrGap()] }); + // Fingerprint the drift gate will compute for the single violation. + const fp = evaluationResultToViolations(result, 'drift-gate')[0].fingerprint; + const approved = approveWaiver( + requestWaiver({ + waiverRef: 'W-42', + fingerprint: fp, + reason: 'temporary', + requestedBy: 'alice', + requestedAt: '2026-07-01T00:00:00.000Z', + expiresAt: '2026-08-01T00:00:00.000Z', + }), + 'bob', + '2026-07-02T00:00:00.000Z', + ); + + it('a valid waiver suppresses the finding → gate passes, evidence non-blocking', () => { + const store = new InMemoryWaiverStore([approved]); + const decision = evaluateDriftGate({ result, codeowners, waivers: store, now: '2026-07-15T00:00:00.000Z' }); + expect(decision.blocked).toBe(false); + expect(decision.exitCode).toBe(DRIFT_GATE_PASS_EXIT_CODE); + expect(decision.waived).toHaveLength(1); + expect(decision.waived[0].waiverRef).toBe('W-42'); + expect(decision.evidence.blockingFailures).toBe(0); + expect(decision.evidence.waiverRef).toBe('W-42'); + expect(decision.prCommentBody).toContain('Waived findings'); + }); + + it('an EXPIRED waiver does NOT suppress → gate blocks again', () => { + const store = new InMemoryWaiverStore([approved]); + const decision = evaluateDriftGate({ result, codeowners, waivers: store, now: '2026-08-02T00:00:00.000Z' }); + expect(decision.blocked).toBe(true); + expect(decision.exitCode).toBe(DRIFT_GATE_BLOCK_EXIT_CODE); + expect(decision.waived).toHaveLength(0); + }); +}); + +describe('PrCommentFallbackPublisher (deploy-gate-free fallback)', () => { + it('never no-ops: a blocked decision yields failure conclusion + non-zero exit', async () => { + const decision = evaluateDriftGate({ result: makeResult({ gaps: [adrGap()] }), codeowners }); + const published = await new PrCommentFallbackPublisher().publish(decision); + expect(published.channel).toBe('pr-comment-fallback'); + expect(published.conclusion).toBe('failure'); + expect(published.exitCode).toBe(DRIFT_GATE_BLOCK_EXIT_CODE); + expect(published.body).toContain('ADR-0002'); + }); + + it('a passing decision yields success + exit 0', async () => { + const decision = evaluateDriftGate({ result: makeResult({ gaps: [], overallVerdict: Verdict.PASS }) }); + const published = await new PrCommentFallbackPublisher().publish(decision); + expect(published.conclusion).toBe('success'); + expect(published.exitCode).toBe(DRIFT_GATE_PASS_EXIT_CODE); + }); +}); diff --git a/src/packages/core-domain/src/evaluation/drift-gate.ts b/src/packages/core-domain/src/evaluation/drift-gate.ts new file mode 100644 index 000000000..a300cac5c --- /dev/null +++ b/src/packages/core-domain/src/evaluation/drift-gate.ts @@ -0,0 +1,259 @@ +/** + * PR/CI drift gate (GT-518 · EAG-13). + * + * Turns an {@link EvaluationResult} into a DETERMINISTIC merge decision: block when a + * non-waived ADR/rule violation of `error` severity remains, and cite the ADR id + the + * accountable owner (resolved from CODEOWNERS / IDP ownership). The blocking path never + * silently no-ops — it always yields a PR-comment body AND a non-zero exit code. + * + * The live GitHub/GitLab Checks API publish (a GitHub App with `checks:write` + GHAS on + * private repos) is DEPLOY-GATED: it sits behind {@link IChecksPublisher}, and the + * dependency-free {@link PrCommentFallbackPublisher} is the fallback the gap mandates. + * + * Composition, not new mechanism: + * - findings → canonical {@link Violation}s via {@link evaluationResultToViolations}; + * - owner via {@link enrichViolationsWithCodeowners} / {@link enrichViolationsWithOwner}; + * - waivers via {@link applyWaivers} (suppressed findings are marked `frozen`, with an audit trail); + * - evidence via {@link buildEnforcerEvidence} (EVD-01..03 carried, waived findings do not block). + * + * Pure: no clock (caller passes `now`), no `fs`, no network. + */ + +import type { EvaluationResult } from './contracts/evaluation-result'; +import { evaluationResultToViolations } from './sarif-exporter'; +import { + buildEnforcerEvidence, + type EnforcerEvidenceManifest, + type Violation, +} from '../domain/violation'; +import { enrichViolationsWithCompliance } from '../domain/compliance'; +import { enrichViolationsWithCodeowners, type CodeownersRule } from '../domain/codeowners'; +import { enrichViolationsWithOwner, type OwnershipEntry } from '../domain/ownership'; +import { applyWaivers, type IWaiverStore, type Waiver } from '../domain/waiver'; + +/** Exit code CI honors when the gate blocks a merge (mirrors the enforce edit-gate convention). */ +export const DRIFT_GATE_BLOCK_EXIT_CODE = 1; +/** Exit code when the gate passes. */ +export const DRIFT_GATE_PASS_EXIT_CODE = 0; +/** Default `tool`/source stamped on the derived violations + evidence manifest. */ +export const DRIFT_GATE_SOURCE = 'drift-gate'; + +/** A single blocking violation, resolved to the ADR it traces to and the accountable owner. */ +export interface DriftCitation { + /** ADR id when the violation traces to one (e.g. `ADR-0002`), else the rule id. */ + readonly ref: string; + /** True when {@link DriftCitation.ref} is an ADR id (drives the "ADR violated" message). */ + readonly isAdr: boolean; + /** Rule that was violated. */ + readonly ruleId: string; + /** Accountable owner (CODEOWNERS / IDP), when resolved. */ + readonly owner?: string; + /** Offending file (repo-relative), `''` for a project-level finding. */ + readonly file: string; + readonly message: string; + readonly fingerprint: string; + /** Compliance controls this violation discharges (GT-525), when enriched. */ + readonly complianceControls?: readonly string[]; +} + +/** A finding suppressed by an active waiver — surfaced in the PR comment for transparency. */ +export interface DriftWaivedFinding { + readonly fingerprint: string; + readonly ruleId: string; + readonly waiverRef: string; + readonly waiverVersion: number; + readonly expiresAt: string; +} + +export interface DriftGateDecision { + /** Whether the merge is blocked (any retained `error` violation). */ + readonly blocked: boolean; + /** Process exit code — non-zero on block, so CI gates even without the Checks API. */ + readonly exitCode: number; + /** ADR/rule citations for every blocking violation. */ + readonly citations: readonly DriftCitation[]; + /** Findings suppressed by an active waiver (audit trail). */ + readonly waived: readonly DriftWaivedFinding[]; + /** Markdown PR-comment body — the deploy-gate-free fallback for the Checks API. */ + readonly prCommentBody: string; + /** Auditable evidence manifest (EVD-01..03); waived findings are `frozen` so they do not block. */ + readonly evidence: EnforcerEvidenceManifest; +} + +export interface DriftGateInput { + readonly result: EvaluationResult; + /** Parsed CODEOWNERS rules (from `.github/CODEOWNERS`), for owner enrichment. */ + readonly codeowners?: readonly CodeownersRule[]; + /** IDP ownership entries (Backstage/Port/Cortex), applied after CODEOWNERS as a fallback. */ + readonly ownership?: readonly OwnershipEntry[]; + /** Waiver store; findings with an active approved+unexpired waiver are suppressed. */ + readonly waivers?: IWaiverStore; + /** Evaluation instant for waiver expiry (ISO-8601 UTC). Defaults to `result.evaluatedAt`. */ + readonly now?: string; + /** Source/tool label for the derived violations + evidence. Defaults to `drift-gate`. */ + readonly source?: string; +} + +function toCitation(v: Violation): DriftCitation { + const isAdr = /^ADR-/i.test(v.adrRef ?? ''); + return { + ref: v.adrRef ?? v.ruleId, + isAdr, + ruleId: v.ruleId, + owner: v.owner, + file: v.file, + message: v.message, + fingerprint: v.fingerprint, + ...(v.complianceControls && v.complianceControls.length > 0 + ? { complianceControls: v.complianceControls } + : {}), + }; +} + +/** Render one blocking violation as a line citing the ADR/rule + owner. */ +function renderCitationLine(c: DriftCitation): string { + const subject = c.isAdr ? `**${c.ref}** violated` : `rule **${c.ref}** violated`; + const owner = c.owner ? ` — owner ${c.owner}` : ' — owner: unassigned (add a CODEOWNERS entry)'; + const where = c.file ? ` \`${c.file}\`` : ''; + return `- ${subject}${owner}:${where} ${c.message}`; +} + +function renderPrComment(decision: { + blocked: boolean; + citations: readonly DriftCitation[]; + waived: readonly DriftWaivedFinding[]; +}): string { + const lines: string[] = ['## Evolith architecture drift gate']; + if (!decision.blocked) { + lines.push('', ':white_check_mark: No blocking architecture violations. Safe to merge.'); + } else { + const n = decision.citations.length; + lines.push('', `:no_entry: **Blocked** — ${n} blocking architecture violation${n === 1 ? '' : 's'}:`, ''); + for (const c of decision.citations) lines.push(renderCitationLine(c)); + lines.push('', 'Resolve the violation, or file a waiver (`waiverRef`) approved by the owner.'); + } + if (decision.waived.length > 0) { + lines.push('', '
Waived findings (suppressed until expiry)', ''); + for (const w of decision.waived) { + lines.push(`- ${w.ruleId} — waiver \`${w.waiverRef}\`@v${w.waiverVersion}, expires ${w.expiresAt}`); + } + lines.push('', '
'); + } + return lines.join('\n'); +} + +/** + * Evaluate the drift gate over an {@link EvaluationResult}. Deterministic and pure. + * + * A merge is BLOCKED when, after suppressing waived findings, any `error`-severity violation + * remains. Warnings never block. Every blocking violation is cited with its ADR id (or rule id) + * and resolved owner; the same set drives the PR-comment fallback and the non-zero exit code. + */ +export function evaluateDriftGate(input: DriftGateInput): DriftGateDecision { + const source = input.source ?? DRIFT_GATE_SOURCE; + const now = input.now ?? input.result.evaluatedAt; + + // 1. Findings → canonical violations, enriched with owner (CODEOWNERS first, IDP fallback) + // and compliance controls (GT-525). + let violations = evaluationResultToViolations(input.result, source); + if (input.codeowners && input.codeowners.length > 0) { + violations = enrichViolationsWithCodeowners(violations, input.codeowners); + } + if (input.ownership && input.ownership.length > 0) { + violations = enrichViolationsWithOwner(violations, input.ownership); + } + violations = enrichViolationsWithCompliance(violations); + + // 2. Suppress waived findings, keeping the audit trail. A suppressed violation is marked + // `frozen` so the evidence manifest counts it as non-blocking (GT-517 semantics). + const waived: DriftWaivedFinding[] = []; + let evidenceViolations = violations; + if (input.waivers) { + const { suppressed } = applyWaivers(violations, input.waivers, now); + const suppressedByFp = new Map(suppressed.map((s) => [s.violation.fingerprint, s.waiver])); + evidenceViolations = violations.map((v) => { + const waiver = suppressedByFp.get(v.fingerprint); + if (!waiver) return v; + waived.push({ + fingerprint: v.fingerprint, + ruleId: v.ruleId, + waiverRef: waiver.waiverRef, + waiverVersion: waiver.version, + expiresAt: waiver.expiresAt, + }); + return { ...v, frozen: true }; + }); + } + + // 3. Blocking = retained (non-frozen) `error` violations. + const blocking = evidenceViolations.filter((v) => v.severity === 'error' && !v.frozen); + const citations = blocking.map(toCitation); + const blocked = blocking.length > 0; + + // 4. Evidence manifest (EVD-01..03). A single waiverRef is stamped when one waiver applied. + const evidence = buildEnforcerEvidence({ + id: `drift-gate:${input.result.correlationId ?? input.result.evaluatedAt}`, + source, + sourceRef: + input.result.correlationId ?? + (input.result.versions?.core ? `core@${input.result.versions.core}` : `run@${input.result.evaluatedAt}`), + generatedAt: input.result.evaluatedAt, + producer: source, + violations: evidenceViolations, + evaluatedRules: [...new Set(input.result.rulesExecuted.map((r) => r.ruleId))].sort(), + waiverRef: waived.length === 1 ? waived[0].waiverRef : undefined, + }); + + const prCommentBody = renderPrComment({ blocked, citations, waived }); + + return { + blocked, + exitCode: blocked ? DRIFT_GATE_BLOCK_EXIT_CODE : DRIFT_GATE_PASS_EXIT_CODE, + citations, + waived, + prCommentBody, + evidence, + }; +} + +// --------------------------------------------------------------------------- +// Publish seam — the live Checks API is DEPLOY-GATED behind this interface. +// --------------------------------------------------------------------------- + +/** Where a decision was published (a Checks API run url, or the local fallback). */ +export interface ChecksPublishResult { + /** `github-checks` for the live API, `pr-comment-fallback` for the deploy-gate-free path. */ + readonly channel: string; + /** The check-run conclusion CI reads (`success` | `failure`). */ + readonly conclusion: 'success' | 'failure'; + /** The comment/summary body that was (or would be) posted. */ + readonly body: string; + /** Exit code the caller should propagate. */ + readonly exitCode: number; + /** Live check-run url when published to the Checks API; absent for the fallback. */ + readonly url?: string; +} + +/** + * Publish seam for the drift decision. The LIVE GitHub/GitLab Checks API implementation + * (`checks:write` GitHub App) is DEPLOY-GATED and lives in an infra adapter, NOT here. + */ +export interface IChecksPublisher { + publish(decision: DriftGateDecision): Promise; +} + +/** + * The fallback the gap mandates when no GitHub App / GHAS is available: surface the PR-comment + * body and a non-zero exit code on block. Never a silent no-op — a blocked decision ALWAYS + * yields `conclusion: 'failure'` and a non-zero `exitCode`. Dependency-free (no network). + */ +export class PrCommentFallbackPublisher implements IChecksPublisher { + async publish(decision: DriftGateDecision): Promise { + return { + channel: 'pr-comment-fallback', + conclusion: decision.blocked ? 'failure' : 'success', + body: decision.prCommentBody, + exitCode: decision.exitCode, + }; + } +} diff --git a/src/packages/core-domain/src/evaluation/evaluation-orchestrator.service.ts b/src/packages/core-domain/src/evaluation/evaluation-orchestrator.service.ts index 236a52122..02c33a5f9 100644 --- a/src/packages/core-domain/src/evaluation/evaluation-orchestrator.service.ts +++ b/src/packages/core-domain/src/evaluation/evaluation-orchestrator.service.ts @@ -13,7 +13,7 @@ import { Verdict } from '../domain/verdict/verdict'; import type { EvaluationContext } from './contracts'; -import { EvaluationResult, DecisionRecommendation } from './contracts'; +import { EvaluationResult, DecisionRecommendation, foldQualitySignals } from './contracts'; import type { IEvaluationPipeline } from './ports/evaluation-pipeline.port'; import type { IWorkspaceReferenceResolver, ResolvedWorkspace } from './ports/workspace-reference-resolver.port'; import type { KindEvaluator, KindEvaluation } from './ports/kind-evaluator.port'; @@ -53,9 +53,14 @@ export class EvaluationOrchestrator { result = this.merge(result, ke); } + // Fold the inline quality Evidence[] carried on the context into per-dimension + // signals (ADR-0111 / GT-533). The Core only READS the received evidence — it + // never executes a provider; a context with no evidence yields a single + // `no-evidence` signal (advisory), so the verdict is never affected by absence. // Recompute the non-binding decision recommendation from the final verdict. return { ...result, + qualitySignals: foldQualitySignals(ctx.qualitySignals), decisionRecommendation: this.buildDecisionRecommendation(ctx, result.overallVerdict), }; } diff --git a/src/packages/core-domain/src/evaluation/evaluation-orchestrator.spec.ts b/src/packages/core-domain/src/evaluation/evaluation-orchestrator.spec.ts index 29a18bffb..b6050d128 100644 --- a/src/packages/core-domain/src/evaluation/evaluation-orchestrator.spec.ts +++ b/src/packages/core-domain/src/evaluation/evaluation-orchestrator.spec.ts @@ -2,6 +2,7 @@ import { EvaluationOrchestrator } from './evaluation-orchestrator.service'; import type { IEvaluationPipeline } from './ports/evaluation-pipeline.port'; import type { IWorkspaceReferenceResolver } from './ports/workspace-reference-resolver.port'; import type { EvaluationContext } from './contracts'; +import { normalizeEvidence } from './contracts'; import type { EvaluationVerdict } from '../domain/satellite-manifest'; import { Verdict } from '../domain/verdict/verdict'; @@ -131,4 +132,51 @@ describe('EvaluationOrchestrator (GT-378)', () => { expect(r.results.gate).toHaveLength(2); }); }); + + describe('quality-signal seam wiring (GT-533 · ADR-0111)', () => { + const perf = normalizeEvidence({ + source: 'lighthouse', + dimension: 'performance', + determinism: 'deterministic', + metrics: { score: 0.42 }, + findings: [{ code: 'lcp', severity: 'high', message: 'slow LCP' }], + provenance: { collectedBy: 'lighthouse', adapterVersion: '11.0.0', artifactHash: 'sha256:abc' }, + }); + + it('surfaces the evidence-derived signal on the result when the context carries qualitySignals', async () => { + const pipeline: IEvaluationPipeline = { evaluate: async () => makeVerdict(true) }; + const orch = new EvaluationOrchestrator(pipeline, resolver, '1.0.5'); + const r = await orch.evaluate({ ...ctx, qualitySignals: [perf] }); + + expect(r.qualitySignals).toEqual([ + { dimension: 'performance', status: 'present', evidence: [perf] }, + ]); + // Received Evidence[] actually influenced the result's signals. + expect(r.qualitySignals?.[0].evidence[0].findings[0].code).toBe('lcp'); + // Advisory only: the verdict is untouched by the presence of evidence. + expect(r.overallVerdict).toBe(Verdict.PASS); + }); + + it('surfaces a no-evidence signal and still succeeds when the context carries NO qualitySignals', async () => { + const pipeline: IEvaluationPipeline = { evaluate: async () => makeVerdict(true) }; + const orch = new EvaluationOrchestrator(pipeline, resolver, '1.0.5'); + const r = await orch.evaluate(ctx); // no qualitySignals + + expect(r.qualitySignals).toEqual([ + { dimension: 'quality', status: 'no-evidence', evidence: [] }, + ]); + // Absence of evidence is advisory — it never fails the evaluation. + expect(r.overallVerdict).toBe(Verdict.PASS); + expect(r.outcome).toBe('approved'); + }); + + it('does not fail a passing evaluation merely because evidence is absent (empty array)', async () => { + const pipeline: IEvaluationPipeline = { evaluate: async () => makeVerdict(true) }; + const orch = new EvaluationOrchestrator(pipeline, resolver, '1.0.5'); + const r = await orch.evaluate({ ...ctx, qualitySignals: [] }); + + expect(r.qualitySignals?.[0].status).toBe('no-evidence'); + expect(r.overallVerdict).toBe(Verdict.PASS); + }); + }); }); diff --git a/src/packages/core-domain/src/evaluation/index.ts b/src/packages/core-domain/src/evaluation/index.ts index 8b0db9372..78ef5ed74 100644 --- a/src/packages/core-domain/src/evaluation/index.ts +++ b/src/packages/core-domain/src/evaluation/index.ts @@ -7,7 +7,11 @@ export * from './contracts'; export * from '../domain/violation'; +export * from '../domain/codeowners'; +export * from '../domain/ownership'; +export * from '../domain/waiver'; export * from './sarif-exporter'; +export * from './drift-gate'; export * from './ports/evaluation-pipeline.port'; export * from './ports/workspace-reference-resolver.port'; export * from './ports/kind-evaluator.port'; diff --git a/src/packages/core-domain/src/evaluation/sarif-exporter.ts b/src/packages/core-domain/src/evaluation/sarif-exporter.ts index f9d8ae77d..8a1f67031 100644 --- a/src/packages/core-domain/src/evaluation/sarif-exporter.ts +++ b/src/packages/core-domain/src/evaluation/sarif-exporter.ts @@ -28,6 +28,7 @@ import { FINDING_SEVERITY_TO_VIOLATION, makeViolation, type EnforcerEvidenceManifest, + type Violation, } from '../domain/violation'; import { enrichViolationsWithCompliance } from '../domain/compliance'; @@ -216,14 +217,13 @@ export function exportEvaluationResultToSarif( } /** - * Emit the auditable evidence manifest (EVD-01..04) for an evaluation result. Each - * {@link GapFinding} is converted to a canonical {@link Violation} (rule id = - * `requirementRef`), then {@link buildEnforcerEvidence} derives the integrity fields - * (EVD-03: status, blockingFailures). `generatedAt` is taken from `result.evaluatedAt` - * (pure — no clock). `source` labels the evidence origin (e.g. a ruleset/gate ref). + * Convert an {@link EvaluationResult}'s gaps into canonical {@link Violation}s (rule id = + * `requirementRef`, ADR ref preserved when the requirement is an `ADR-*` ref). `source` labels + * the producing tool/gate. Shared by {@link emitEvaluationEvidence} and the drift gate so a + * single mapping produces the evidence manifest regardless of which surface asked for it. */ -export function emitEvaluationEvidence(result: EvaluationResult, source: string): EnforcerEvidenceManifest { - const rawViolations = result.gaps.map((gap) => { +export function evaluationResultToViolations(result: EvaluationResult, source: string): Violation[] { + return result.gaps.map((gap) => { const parsed = parseFindingLocation(gap.location); // `requirementRef` is the rule id OR an ADR ref (violationToGapFinding uses `adrRef ?? ruleId`); // keep the ADR shape so the compliance mapping (byAdr) resolves (GT-525). @@ -240,8 +240,18 @@ export function emitEvaluationEvidence(result: EvaluationResult, source: string) message: gap.message, }); }); +} + +/** + * Emit the auditable evidence manifest (EVD-01..04) for an evaluation result. Each + * {@link GapFinding} is converted to a canonical {@link Violation} (rule id = + * `requirementRef`), then {@link buildEnforcerEvidence} derives the integrity fields + * (EVD-03: status, blockingFailures). `generatedAt` is taken from `result.evaluatedAt` + * (pure — no clock). `source` labels the evidence origin (e.g. a ruleset/gate ref). + */ +export function emitEvaluationEvidence(result: EvaluationResult, source: string): EnforcerEvidenceManifest { // GT-525: attribute each violation to its compliance control(s) before emitting evidence. - const violations = enrichViolationsWithCompliance(rawViolations); + const violations = enrichViolationsWithCompliance(evaluationResultToViolations(result, source)); const evaluatedRules = [...new Set(result.rulesExecuted.map((r) => r.ruleId))].sort(); const sourceRef = diff --git a/src/packages/core-domain/src/index.ts b/src/packages/core-domain/src/index.ts index d741220cf..5e71fc8dc 100644 --- a/src/packages/core-domain/src/index.ts +++ b/src/packages/core-domain/src/index.ts @@ -18,8 +18,10 @@ export { DEFAULT_SANDBOX_POLICY, SandboxedProcessRunner, resolveRuntimeFromManifest, + resolveRestorePlanFromManifest, executeRestorePlan, provisionEvaluationEnvironment, + materializeAndProvisionEnvironment, } from './application/validators/enforcement/provisioning'; export type { ProjectScope, @@ -30,6 +32,13 @@ export type { RestoreResult, ProvisionRequest, ProvisionedEnvironment, + RepositorySourceRef, + RepositorySources, + IRepositorySourceReader, + IWorkspaceMaterializer, + ManifestParser, + MaterializeProvisionRequest, + MaterializedEnvironment, } from './application/validators/enforcement/provisioning'; // Canonical Core Evaluation Engine contracts (GT-377 / ADR-0101) are exposed via // the dedicated subpath '@beyondnet/evolith-core-domain/evaluation/contracts'. The former diff --git a/src/packages/infra-providers/src/index.ts b/src/packages/infra-providers/src/index.ts index f1ac2e2ad..a764f11ef 100644 --- a/src/packages/infra-providers/src/index.ts +++ b/src/packages/infra-providers/src/index.ts @@ -1,6 +1,8 @@ export { NodeFileSystemProvider } from './node-filesystem.provider'; export { NodeProcessRunner } from './node-process-runner.provider'; export type { NodeProcessRunnerOptions } from './node-process-runner.provider'; +export { NodeWorkspaceMaterializer } from './workspace-materializer.provider'; +export type { NodeWorkspaceMaterializerOptions } from './workspace-materializer.provider'; export { NestLoggerProvider, ConsoleLoggerProvider, NoOpLoggerProvider } from './logger.provider'; export { YamlConfigParserProvider, JsonConfigParserProvider } from './config-parser.provider'; export { DiskRulesetRepository, RulesetsNotFoundError } from './disk-ruleset.repository'; diff --git a/src/packages/infra-providers/src/workspace-materializer.provider.spec.ts b/src/packages/infra-providers/src/workspace-materializer.provider.spec.ts new file mode 100644 index 000000000..46edc26c5 --- /dev/null +++ b/src/packages/infra-providers/src/workspace-materializer.provider.spec.ts @@ -0,0 +1,56 @@ +import * as os from 'os'; +import * as path from 'path'; +import * as fs from 'fs-extra'; + +import { NodeFileSystemProvider } from './node-filesystem.provider'; +import { NodeWorkspaceMaterializer } from './workspace-materializer.provider'; + +describe('NodeWorkspaceMaterializer (GT-512 · EAG-04 · PA-07 — TEXT tarball → restorable checkout)', () => { + let base: string; + let materializer: NodeWorkspaceMaterializer; + let counter: number; + + beforeEach(async () => { + base = await fs.mkdtemp(path.join(os.tmpdir(), 'infra-materialize-')); + counter = 0; + materializer = new NodeWorkspaceMaterializer(new NodeFileSystemProvider(), { + baseDir: base, + idFactory: () => `t${counter++}`, // deterministic checkout dirs + }); + }); + + afterEach(async () => { + await fs.remove(base); + }); + + it('writes the in-memory files (creating nested dirs) and returns the checkout path', async () => { + const checkout = await materializer.materialize({ + 'evolith.yaml': 'toolchain:\n runtime: node\n', + 'apps/a/src/index.ts': 'export const x = 1;', + 'package.json': '{"name":"sat"}', + }); + + expect(checkout).toBe(path.resolve(base, 'checkout-t0')); + expect(await fs.readFile(path.join(checkout, 'evolith.yaml'), 'utf-8')).toContain('runtime: node'); + expect(await fs.readFile(path.join(checkout, 'apps/a/src/index.ts'), 'utf-8')).toBe('export const x = 1;'); + // no installed deps materialized — only the received text tarball + expect(await fs.pathExists(path.join(checkout, 'node_modules'))).toBe(false); + }); + + it('isolates each call in a fresh unique checkout directory', async () => { + const first = await materializer.materialize({ 'a.txt': '1' }); + const second = await materializer.materialize({ 'b.txt': '2' }); + expect(first).not.toBe(second); + expect(await fs.pathExists(path.join(first, 'b.txt'))).toBe(false); + expect(await fs.pathExists(path.join(second, 'a.txt'))).toBe(false); + }); + + it('rejects a path-traversal entry fail-closed (nothing escapes the root)', async () => { + await expect(materializer.materialize({ '../escape.txt': 'evil' })).rejects.toThrow(/escapes the checkout root/); + expect(await fs.pathExists(path.join(base, 'escape.txt'))).toBe(false); + }); + + it('rejects an absolute-path entry fail-closed', async () => { + await expect(materializer.materialize({ '/etc/passwd': 'evil' })).rejects.toThrow(/absolute path entry/); + }); +}); diff --git a/src/packages/infra-providers/src/workspace-materializer.provider.ts b/src/packages/infra-providers/src/workspace-materializer.provider.ts new file mode 100644 index 000000000..048472b4a --- /dev/null +++ b/src/packages/infra-providers/src/workspace-materializer.provider.ts @@ -0,0 +1,69 @@ +import * as path from 'node:path'; + +import type { IWorkspaceMaterializer } from '@beyondnet/evolith-core-domain'; +import type { IFileSystem } from '@beyondnet/evolith-core-domain/domain/interfaces'; + +/** + * Materializes a fetched "TEXT tarball" (in-memory `relPath → content` map, NO installed + * deps) to a real working directory on disk (GT-512 · EAG-04 · PA-07 infra adapter). + * + * This is the concrete half of {@link IWorkspaceMaterializer}: `materializeAndProvision- + * Environment` (core-domain) fetches sources via `IRepositorySourceReader`, hands them here + * to become a RESTORABLE checkout, then runs the restore plan (`npm ci` / `dotnet restore` + * / …) through the sandboxed `NodeProcessRunner` in the returned directory. The Core stays + * stateless and path-agnostic behind the port; only this adapter touches disk. + * + * Safety: + * - each checkout lands in a FRESH unique subdirectory under `baseDir` (no collisions, + * each evaluation is isolated), + * - path traversal is rejected fail-closed — an entry that resolves outside the checkout + * root (absolute path, or `..` escaping the root) throws before any write. + * + * DEPLOY-GATED (not here): the network fetch that produces the tarball (the + * `GitHubRepositorySourceReader`, which needs the GitHub API + tar extraction) and the + * OS-level sandbox the restore runs inside. This adapter only writes the received bytes. + */ +export interface NodeWorkspaceMaterializerOptions { + /** Root under which each checkout gets its own unique subdirectory. */ + readonly baseDir: string; + /** Injectable id generator (default: time + random) so tests are deterministic. */ + readonly idFactory?: () => string; +} + +export class NodeWorkspaceMaterializer implements IWorkspaceMaterializer { + private readonly baseDir: string; + private readonly idFactory: () => string; + + constructor( + private readonly fs: IFileSystem, + options: NodeWorkspaceMaterializerOptions, + ) { + this.baseDir = options.baseDir; + this.idFactory = options.idFactory ?? (() => `${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`); + } + + async materialize(files: Readonly>): Promise { + const checkoutPath = path.resolve(this.baseDir, `checkout-${this.idFactory()}`); + await this.fs.ensureDir(checkoutPath); + + for (const [rel, content] of Object.entries(files)) { + const target = this.safeResolve(checkoutPath, rel); + await this.fs.ensureDir(path.dirname(target)); + await this.fs.writeFile(target, content); + } + return checkoutPath; + } + + /** Resolve a relative entry under the root, rejecting anything that escapes it. */ + private safeResolve(root: string, rel: string): string { + if (path.isAbsolute(rel)) { + throw new Error(`workspace materializer: refusing absolute path entry '${rel}'`); + } + const resolved = path.resolve(root, rel); + const rootWithSep = root.endsWith(path.sep) ? root : `${root}${path.sep}`; + if (resolved !== root && !resolved.startsWith(rootWithSep)) { + throw new Error(`workspace materializer: path '${rel}' escapes the checkout root`); + } + return resolved; + } +} diff --git a/src/packages/mcp-server/src/domain/domain.module.ts b/src/packages/mcp-server/src/domain/domain.module.ts index 7459c148e..599567f33 100644 --- a/src/packages/mcp-server/src/domain/domain.module.ts +++ b/src/packages/mcp-server/src/domain/domain.module.ts @@ -7,6 +7,7 @@ import { DiskRulesetRepository, WebhookAdapter, MoscowPrioritizationService, + NodeProcessRunner, } from '@beyondnet/evolith-infra-providers'; import { FILE_SYSTEM, CONFIG_PARSER } from './domain.tokens'; @@ -34,7 +35,13 @@ import { FILE_SYSTEM, CONFIG_PARSER } from './domain.tokens'; const configParser = new YamlConfigParserProvider().createConfigParser('yaml'); const logger = new NestLoggerProvider().createLogger('RulesetValidator'); const rulesetRepo = new DiskRulesetRepository(fileSystem, logger); - return new RulesetValidatorService({ fileSystem, configParser, logger, rulesetRepo }); + // GT-519 parity: register the enforcer subsystem on the MCP surface identically to + // CLI/REST by injecting the real process runner. Non-forking — the composite delegates + // to the native strategy unless a ruleset authors an `enforce:` block. + return new RulesetValidatorService({ + fileSystem, configParser, logger, rulesetRepo, + processRunner: new NodeProcessRunner(), + }); }, }, { provide: WebhookAdapter, useFactory: () => new WebhookAdapter() }, diff --git a/src/packages/mcp-server/src/main.ts b/src/packages/mcp-server/src/main.ts index 7164cb434..da6741b1e 100644 --- a/src/packages/mcp-server/src/main.ts +++ b/src/packages/mcp-server/src/main.ts @@ -73,27 +73,36 @@ export interface StartMcpServerOptions { } // --------------------------------------------------------------------------- -// GT-520 · EAG-15 / AC1 — OAuth 2.1 bearer over Streamable HTTP: GATED (seam). +// GT-520 · EAG-15 / AC1 — OAuth 2.1 bearer over Streamable HTTP: IMPLEMENTED +// (IdP-agnostic); the concrete IdP choice remains gated on EAG-01. // -// The Streamable HTTP transport already ships (see McpServerService.startHttp), -// and HTTP requests are authenticated today via a shared API key or an -// HS256 JWT signed with a locally-held JWT_SECRET (mcp-server-auth.ts). Full -// OAuth 2.1 — validating a bearer access token issued by an EXTERNAL identity -// provider (JWKS/introspection, audience + issuer checks, resource-server -// metadata) — is intentionally NOT implemented here. It is blocked on the -// EAG-01 identity decision (which IdP, one shared IdP vs per-tenant, token -// audience model). Implementing a "fake" OAuth validator now would give a false -// sense of federated auth, so the seam is left explicit: +// The Streamable HTTP transport ships (see McpServerService.startHttp). Remote +// requests are now authenticated by a GENERIC OAuth 2.1 resource-server +// validator (oauth-resource-server.ts): when the OAuth env is set, a +// `Authorization: Bearer ` is verified against the configured issuer's +// JWKS (RS/PS/ES families) or a shared HS secret, with iss/aud/exp/nbf enforced, +// and the VERIFIED claims map to McpUserContext — the identity that flows into +// per-identity ABAC. An unauthenticated remote request (no/invalid/expired +// bearer, no other credential) is rejected with 401 (mcp-server-auth.ts → +// authenticateHttpRequest). Configure via env (no code change per IdP): // -// TODO(GT-520/EAG-01, GATED): plug an OAuth 2.1 resource-server validator into -// validateAuth() (mcp-server-auth.ts) — verify bearer against the chosen IdP's -// JWKS, enforce iss/aud/exp, and map verified claims → McpUserContext. Wire an -// `oauth` auth mode through startMcpServer/parseArgs once the IdP is selected. +// EVOLITH_MCP_OAUTH_ISSUER — expected `iss` (required to enable OAuth) +// EVOLITH_MCP_OAUTH_JWKS_URI — issuer JWKS endpoint (asymmetric tokens) +// EVOLITH_MCP_OAUTH_SECRET — shared secret (symmetric HS* tokens) +// EVOLITH_MCP_OAUTH_AUDIENCE — expected `aud` (optional but recommended) // -// Everything downstream of identity is already hardened per-identity: the -// dispatcher runs ABAC (native + OPA) on EVERY tools/call and audits the verdict -// (see McpServerService.handleCallTool), so it is agnostic to how the identity -// was established (API key, JWT, or a future OAuth token). +// GATED (EAG-01): the SELECTION of the concrete IdP — which provider, one shared +// IdP vs per-tenant, and the token audience model — is an org decision tracked +// in the Tracker. The validator is deliberately not wired to any single vendor; +// it works against any standards-compliant OAuth 2.1 / OIDC issuer once EAG-01 +// picks one and its issuer/JWKS/audience are supplied via the env above. +// +// The stdio/local path is unchanged: OAuth applies only to the remote HTTP +// surface, and the shared API key / local HS256 JWT / dev `--allow-no-auth` +// paths still work for local development. Everything downstream of identity is +// already hardened per-identity: the dispatcher runs ABAC (native + OPA) on +// EVERY tools/call and audits the verdict (see McpServerService.handleCallTool), +// so it is agnostic to how the identity was established. // --------------------------------------------------------------------------- /** diff --git a/src/packages/mcp-server/src/mcp/mcp-server-auth.spec.ts b/src/packages/mcp-server/src/mcp/mcp-server-auth.spec.ts new file mode 100644 index 000000000..953d9c2b5 --- /dev/null +++ b/src/packages/mcp-server/src/mcp/mcp-server-auth.spec.ts @@ -0,0 +1,206 @@ +import * as crypto from 'node:crypto'; +import * as http from 'node:http'; +import { authenticateHttpRequest } from './mcp-server-auth'; +import type { OAuthConfig, JwksKeyResolver } from './oauth-resource-server'; + +// --- test doubles --------------------------------------------------------- + +function fakeReq(headers: Record = {}): http.IncomingMessage { + return { headers } as unknown as http.IncomingMessage; +} + +interface CapturedRes extends http.ServerResponse { + _status?: number; + _body?: string; +} + +function fakeRes(): CapturedRes { + const res: Partial = { headersSent: false }; + res.writeHead = ((status: number) => { + res._status = status; + (res as CapturedRes).headersSent = true; + return res as CapturedRes; + }) as http.ServerResponse['writeHead']; + res.end = ((body?: string) => { + if (body) res._body = body; + return res as CapturedRes; + }) as http.ServerResponse['end']; + return res as CapturedRes; +} + +function b64url(obj: unknown): string { + return Buffer.from(JSON.stringify(obj)).toString('base64url'); +} + +function signRs256(privateKey: crypto.KeyObject, payload: Record, kid = 'k1'): string { + const signingInput = `${b64url({ alg: 'RS256', typ: 'JWT', kid })}.${b64url(payload)}`; + const sig = crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey).toString('base64url'); + return `${signingInput}.${sig}`; +} + +const nowSec = (): number => Math.floor(Date.now() / 1000); + +// --- GT-520 · EAG-15 / AC1 ------------------------------------------------- +// Remote MCP (Streamable HTTP) requires OAuth: the identity that reaches ABAC +// comes from a verified bearer token, not a header. + +describe('authenticateHttpRequest — OAuth resource-server (GT-520 AC1)', () => { + let publicKey: crypto.KeyObject; + let privateKey: crypto.KeyObject; + let resolver: JwksKeyResolver; + const oauthConfig: OAuthConfig = { issuer: 'https://idp.example.com', audience: 'evolith-mcp' }; + const OLD_ENV = { ...process.env }; + + beforeAll(() => { + const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + publicKey = pair.publicKey; + privateKey = pair.privateKey; + resolver = async (kid) => (kid === 'k1' ? publicKey : null); + }); + + beforeEach(() => { + // OAuth is the ONLY configured credential source: no API key, no local JWT, + // no dev bypass. This is the hardened remote posture. + delete process.env.JWT_SECRET; + process.env.NODE_ENV = 'production'; + }); + + afterAll(() => { + process.env = OLD_ENV; + }); + + it('rejects a remote request with no bearer (401)', async () => { + const res = fakeRes(); + const ctx = await authenticateHttpRequest(fakeReq(), res, undefined, false, oauthConfig, resolver); + expect(ctx).toBeNull(); + expect(res._status).toBe(401); + }); + + it('rejects an invalid/garbage bearer (401)', async () => { + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + fakeReq({ authorization: 'Bearer not-a-jwt' }), + res, + undefined, + false, + oauthConfig, + resolver, + ); + expect(ctx).toBeNull(); + expect(res._status).toBe(401); + }); + + it('rejects an expired bearer (401)', async () => { + const token = signRs256(privateKey, { + sub: 'agent-1', + iss: oauthConfig.issuer, + aud: oauthConfig.audience, + exp: nowSec() - 300, + }); + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + fakeReq({ authorization: `Bearer ${token}` }), + res, + undefined, + false, + oauthConfig, + resolver, + ); + expect(ctx).toBeNull(); + expect(res._status).toBe(401); + }); + + it('accepts a valid bearer and threads the token identity into the ABAC context', async () => { + const token = signRs256(privateKey, { + sub: 'agent-42', + iss: oauthConfig.issuer, + aud: oauthConfig.audience, + role: 'operator', + tenant: 'acme', + scope: 'read write', + exp: nowSec() + 300, + }); + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + fakeReq({ authorization: `Bearer ${token}` }), + res, + undefined, + false, + oauthConfig, + resolver, + ); + expect(res._status).toBeUndefined(); + expect(ctx).not.toBeNull(); + // Identity is derived from the VERIFIED token claims (not any request header). + expect(ctx).toMatchObject({ + id: 'agent-42', + role: 'operator', + tenant: 'acme', + scopes: ['read', 'write'], + }); + }); + + it('does not treat a header-supplied identity as authenticated (no bearer, spoofed headers → 401)', async () => { + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + // An attacker setting role/tenant headers must NOT become an identity. + fakeReq({ 'x-role': 'admin', 'x-tenant': 'victim', 'x-user-id': 'root' }), + res, + undefined, + false, + oauthConfig, + resolver, + ); + expect(ctx).toBeNull(); + expect(res._status).toBe(401); + }); +}); + +describe('authenticateHttpRequest — local/dev path preserved (OAuth off)', () => { + const OLD_ENV = { ...process.env }; + afterAll(() => { + process.env = OLD_ENV; + }); + + it('accepts the shared API key via Bearer when OAuth is not configured', async () => { + process.env.NODE_ENV = 'production'; + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + fakeReq({ authorization: 'Bearer super-secret-key' }), + res, + 'super-secret-key', + false, + null, + ); + expect(ctx).not.toBeNull(); + expect(ctx?.role).toBe('admin'); + }); + + it('accepts the shared API key even when OAuth IS configured (back-compat)', async () => { + process.env.NODE_ENV = 'production'; + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + fakeReq({ 'x-api-key': 'super-secret-key' }), + res, + 'super-secret-key', + false, + { issuer: 'https://idp', secret: 'unused-here' }, + ); + expect(ctx).not.toBeNull(); + expect(ctx?.role).toBe('admin'); + }); + + it('rejects a bad API key (401) when OAuth is off', async () => { + process.env.NODE_ENV = 'production'; + const res = fakeRes(); + const ctx = await authenticateHttpRequest( + fakeReq({ authorization: 'Bearer wrong' }), + res, + 'super-secret-key', + false, + null, + ); + expect(ctx).toBeNull(); + expect(res._status).toBe(401); + }); +}); diff --git a/src/packages/mcp-server/src/mcp/mcp-server-auth.ts b/src/packages/mcp-server/src/mcp/mcp-server-auth.ts index aa7dd4107..74fc3a582 100644 --- a/src/packages/mcp-server/src/mcp/mcp-server-auth.ts +++ b/src/packages/mcp-server/src/mcp/mcp-server-auth.ts @@ -3,6 +3,7 @@ import * as crypto from 'node:crypto'; import { ErrorCodes } from '../common/errors'; import { failure, generateCorrelationId } from '../common/envelopes'; import type { McpUserContext } from './mcp-user-context'; +import { verifyOAuthToken, type OAuthConfig, type JwksKeyResolver } from './oauth-resource-server'; /** * Constant-time API-key comparison (GAP MCP-TIMING). Guards against the timing @@ -25,6 +26,60 @@ const ADMIN_CONTEXT: McpUserContext = Object.freeze({ scopes: ['read', 'write', 'admin'], }) as McpUserContext; +function writeUnauthorized(res: http.ServerResponse, message: string): null { + const correlationId = generateCorrelationId(); + const err = failure(ErrorCodes.UNAUTHORIZED, message, { correlationId, tool: 'auth', durationMs: 0 }); + res.writeHead(401, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify(err)); + return null; +} + +/** + * GT-520 · EAG-15 / AC1 — remote Streamable HTTP authentication entry point. + * + * Order of precedence: + * 1. When OAuth is configured ({@link OAuthConfig}) and the request carries a + * `Bearer` token that is NOT the shared API key, the token is validated as an + * OAuth 2.1 access token (signature + iss/aud/exp). A valid token yields a + * {@link McpUserContext} built from its VERIFIED claims — this is the identity + * that flows into per-identity ABAC, never a raw request header. + * 2. Otherwise the existing local path ({@link validateAuth}: shared API key, + * local HS256 JWT, or the dev `allowNoAuth` bypass) applies — preserving the + * stdio/local developer experience unchanged. + * + * A remote request with no accepted credential is rejected with 401. When OAuth + * is configured, an unauthenticated request whose only credential is an + * invalid/expired bearer falls through to {@link validateAuth}, which 401s it + * (unless a shared API key or dev bypass independently authorizes it). + */ +export async function authenticateHttpRequest( + req: http.IncomingMessage, + res: http.ServerResponse, + apiKey: string | undefined, + allowNoAuth = false, + oauthConfig?: OAuthConfig | null, + keyResolver?: JwksKeyResolver, +): Promise { + const authHeader = req.headers.authorization || ''; + const bearerToken = authHeader.startsWith('Bearer ') ? authHeader.slice(7) : ''; + + if (oauthConfig && bearerToken && !safeKeyEqual(bearerToken, apiKey)) { + const payload = await verifyOAuthToken(bearerToken, oauthConfig, keyResolver); + if (payload) return getContextFromPayload(payload); + // A bearer was presented but is not a valid OAuth token. It may still be a + // local HS256 JWT (validateAuth handles that); if not, validateAuth 401s. + // But if OAuth is the ONLY configured credential source (no shared API key, + // no local JWT secret, not a dev bypass), reject here so an invalid/expired + // OAuth bearer never silently degrades to an unauthenticated path. + const hasLocalCredential = !!apiKey || !!process.env.JWT_SECRET || allowNoAuth; + if (!hasLocalCredential) { + return writeUnauthorized(res, 'Invalid or expired OAuth bearer token'); + } + } + + return validateAuth(req, res, apiKey, allowNoAuth); +} + export function validateAuth( req: http.IncomingMessage, res: http.ServerResponse, diff --git a/src/packages/mcp-server/src/mcp/mcp-server.service.ts b/src/packages/mcp-server/src/mcp/mcp-server.service.ts index b30b0c7d3..f039ab153 100644 --- a/src/packages/mcp-server/src/mcp/mcp-server.service.ts +++ b/src/packages/mcp-server/src/mcp/mcp-server.service.ts @@ -24,7 +24,8 @@ import { generateCorrelationId } from '../common/envelopes'; import { ErrorCodes } from '../common/errors'; import { AbacEvaluator } from './abac-evaluator'; import { mcpContextStorage, McpUserContext } from './mcp-user-context'; -import { validateAuth } from './mcp-server-auth'; +import { authenticateHttpRequest } from './mcp-server-auth'; +import { loadOAuthConfig, createJwksResolver, type OAuthConfig, type JwksKeyResolver } from './oauth-resource-server'; import { handleCallTool, handleListTools, ToolCallResult } from './mcp-tool-dispatch'; import { McpCacheService } from './mcp-cache.service'; @@ -70,6 +71,13 @@ export class McpServerService { private httpServer: http.Server | null = null; private apiKey?: string; private allowNoAuth = false; + // GT-520 · EAG-15 / AC1 — OAuth 2.1 resource-server config, derived from env at + // start(). When present, remote Streamable HTTP requests are authenticated + // against the configured issuer's JWKS/secret before ABAC. `null` = OAuth off + // (the API-key / local-JWT / dev path applies). The resolver is a field so + // tests can inject a pre-warmed JWKS key without a network round-trip. + private oauthConfig: OAuthConfig | null = null; + private oauthKeyResolver?: JwksKeyResolver; // StreamableHTTP is multi-client: one transport + Server per MCP session. // Keyed by the `mcp-session-id` the transport assigns on initialize. A single // shared transport (the old bug) rejected every second client with HTTP 400 @@ -219,11 +227,22 @@ export class McpServerService { const transport = options.transport ?? 'stdio'; this.apiKey = options.apiKey; this.allowNoAuth = options.allowNoAuth ?? false; + // Derive the OAuth resource-server config from env once at startup. When the + // IdP-agnostic OAuth env is set (issuer + JWKS/secret), remote HTTP requests + // are validated as OAuth 2.1 access tokens; otherwise this stays null and the + // existing API-key/local-JWT/dev path is used unchanged. + this.oauthConfig = loadOAuthConfig(process.env); + if (this.oauthConfig && !this.oauthKeyResolver && this.oauthConfig.jwksUri) { + this.oauthKeyResolver = createJwksResolver(this.oauthConfig.jwksUri); + } this.server = this.buildServer(); if (transport === 'http') { const isProduction = (process.env.NODE_ENV || 'development') === 'production'; - if (!this.apiKey) { + if (this.oauthConfig) { + this.logger.log(`MCP HTTP OAuth resource-server enabled (issuer=${this.oauthConfig.issuer}${this.oauthConfig.jwksUri ? ', JWKS' : ', HS-secret'}). Remote requests require a valid Bearer access token.`); + } + if (!this.apiKey && !this.oauthConfig) { if (isProduction) { // validateAuth() ignores allowNoAuth in production for safety: every // request is rejected with 401 until EVOLITH_API_KEY / --api-key is set. @@ -288,6 +307,34 @@ export class McpServerService { ensureDefaultMetrics(); this.httpServer = http.createServer((req, res) => { + // Auth (below) may be async (OAuth JWKS lookup), so run the request handler + // in an async IIFE. authenticateHttpRequest / verifyOAuthToken never throw, + // but guard defensively so a rejection can't take down the HTTP server. + void this.handleHttpRequest(req, res, port).catch((err: unknown) => { + this.logger.error(`MCP HTTP handler error: ${err instanceof Error ? err.message : String(err)}`); + if (!res.headersSent) { + res.writeHead(500, { 'Content-Type': 'application/json' }); + res.end(JSON.stringify({ jsonrpc: '2.0', error: { code: -32603, message: 'Internal error' }, id: null })); + } else if (!res.writableEnded) { + res.end(); + } + }); + }); + + // Bind 0.0.0.0 by default so reverse proxies (Traefik/Coolify/k8s) can route + // to the container. Override with MCP_HTTP_HOST=127.0.0.1 for local-only use. + const host = process.env.MCP_HTTP_HOST ?? '0.0.0.0'; + await new Promise((resolve, reject) => { + this.httpServer!.listen(port, host, () => { + this.logger.log(`Evolith MCP HTTP server listening on http://${host}:${port}`); + resolve(); + }); + this.httpServer!.on('error', reject); + }); + } + + /** Per-request handler for the Streamable HTTP transport. */ + private async handleHttpRequest(req: http.IncomingMessage, res: http.ServerResponse, port: number): Promise { res.setHeader('Content-Security-Policy', "default-src 'none'"); res.setHeader('X-Content-Type-Options', 'nosniff'); res.setHeader('X-Frame-Options', 'DENY'); @@ -327,7 +374,14 @@ export class McpServerService { return; } - const context = validateAuth(req, res, this.apiKey, this.allowNoAuth); + const context = await authenticateHttpRequest( + req, + res, + this.apiKey, + this.allowNoAuth, + this.oauthConfig, + this.oauthKeyResolver, + ); if (!context) return; const headerCorrelationId = (req.headers['x-correlation-id'] as string) || generateCorrelationId(); @@ -410,18 +464,6 @@ export class McpServerService { // GET (SSE stream) / DELETE without a session id have nothing to attach to. sendError(400, 'Missing mcp-session-id'); - }); - - // Bind 0.0.0.0 by default so reverse proxies (Traefik/Coolify/k8s) can route - // to the container. Override with MCP_HTTP_HOST=127.0.0.1 for local-only use. - const host = process.env.MCP_HTTP_HOST ?? '0.0.0.0'; - await new Promise((resolve, reject) => { - this.httpServer!.listen(port, host, () => { - this.logger.log(`Evolith MCP HTTP server listening on http://${host}:${port}`); - resolve(); - }); - this.httpServer!.on('error', reject); - }); } /** The actual bound TCP port for the HTTP transport (useful for tests). */ diff --git a/src/packages/mcp-server/src/mcp/oauth-resource-server.spec.ts b/src/packages/mcp-server/src/mcp/oauth-resource-server.spec.ts new file mode 100644 index 000000000..e3a193aae --- /dev/null +++ b/src/packages/mcp-server/src/mcp/oauth-resource-server.spec.ts @@ -0,0 +1,218 @@ +import * as crypto from 'node:crypto'; +import { + loadOAuthConfig, + verifyOAuthToken, + createJwksResolver, + type OAuthConfig, + type JwksKeyResolver, +} from './oauth-resource-server'; + +// --- test helpers --------------------------------------------------------- + +function b64url(obj: unknown): string { + return Buffer.from(JSON.stringify(obj)).toString('base64url'); +} + +/** Sign a JWT with an RSA (RS256) or EC (ES256) private key. */ +function signJwt( + privateKey: crypto.KeyObject, + alg: 'RS256' | 'ES256' | 'HS256', + payload: Record, + kid = 'k1', + secret?: string, +): string { + const header = { alg, typ: 'JWT', kid }; + const signingInput = `${b64url(header)}.${b64url(payload)}`; + let sig: Buffer; + if (alg === 'HS256') { + sig = crypto.createHmac('sha256', secret!).update(signingInput).digest(); + } else if (alg === 'ES256') { + sig = crypto.sign('sha256', Buffer.from(signingInput), { key: privateKey, dsaEncoding: 'ieee-p1363' }); + } else { + sig = crypto.sign('RSA-SHA256', Buffer.from(signingInput), privateKey); + } + return `${signingInput}.${sig.toString('base64url')}`; +} + +const nowSec = (): number => Math.floor(Date.now() / 1000); + +describe('loadOAuthConfig', () => { + it('returns null when no issuer is set', () => { + expect(loadOAuthConfig({ EVOLITH_MCP_OAUTH_JWKS_URI: 'https://idp/jwks' } as NodeJS.ProcessEnv)).toBeNull(); + }); + + it('returns null when issuer is set but neither JWKS nor secret is', () => { + expect(loadOAuthConfig({ EVOLITH_MCP_OAUTH_ISSUER: 'https://idp' } as NodeJS.ProcessEnv)).toBeNull(); + }); + + it('enables OAuth with issuer + JWKS', () => { + const cfg = loadOAuthConfig({ + EVOLITH_MCP_OAUTH_ISSUER: 'https://idp', + EVOLITH_MCP_OAUTH_JWKS_URI: 'https://idp/jwks', + EVOLITH_MCP_OAUTH_AUDIENCE: 'evolith-mcp', + } as NodeJS.ProcessEnv); + expect(cfg).toEqual({ + issuer: 'https://idp', + audience: 'evolith-mcp', + jwksUri: 'https://idp/jwks', + secret: undefined, + clockToleranceSec: undefined, + }); + }); + + it('enables OAuth with issuer + shared secret (HS)', () => { + const cfg = loadOAuthConfig({ + EVOLITH_MCP_OAUTH_ISSUER: 'https://idp', + EVOLITH_MCP_OAUTH_SECRET: 's3cret', + } as NodeJS.ProcessEnv); + expect(cfg?.secret).toBe('s3cret'); + expect(cfg?.issuer).toBe('https://idp'); + }); +}); + +describe('verifyOAuthToken — asymmetric (RS256 via injected JWKS resolver)', () => { + let publicKey: crypto.KeyObject; + let privateKey: crypto.KeyObject; + let resolver: JwksKeyResolver; + const config: OAuthConfig = { + issuer: 'https://idp.example.com', + audience: 'evolith-mcp', + }; + + beforeAll(() => { + const pair = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + publicKey = pair.publicKey; + privateKey = pair.privateKey; + resolver = async (kid) => (kid === 'k1' ? publicKey : null); + }); + + it('accepts a valid token and returns the claim set', async () => { + const token = signJwt(privateKey, 'RS256', { + sub: 'agent-42', + iss: config.issuer, + aud: config.audience, + role: 'operator', + tenant: 'acme', + scope: 'read write', + exp: nowSec() + 300, + }); + const payload = await verifyOAuthToken(token, config, resolver); + expect(payload).not.toBeNull(); + expect(payload?.sub).toBe('agent-42'); + expect(payload?.tenant).toBe('acme'); + }); + + it('rejects a token with a tampered payload (signature mismatch)', async () => { + const token = signJwt(privateKey, 'RS256', { + sub: 'agent-42', + iss: config.issuer, + aud: config.audience, + exp: nowSec() + 300, + }); + const [h, , s] = token.split('.'); + const forged = `${h}.${b64url({ sub: 'root', iss: config.issuer, aud: config.audience, exp: nowSec() + 300 })}.${s}`; + expect(await verifyOAuthToken(forged, config, resolver)).toBeNull(); + }); + + it('rejects an expired token', async () => { + const token = signJwt(privateKey, 'RS256', { + sub: 'agent-42', + iss: config.issuer, + aud: config.audience, + exp: nowSec() - 120, + }); + expect(await verifyOAuthToken(token, config, resolver)).toBeNull(); + }); + + it('rejects a wrong issuer', async () => { + const token = signJwt(privateKey, 'RS256', { + sub: 'agent-42', + iss: 'https://evil.example.com', + aud: config.audience, + exp: nowSec() + 300, + }); + expect(await verifyOAuthToken(token, config, resolver)).toBeNull(); + }); + + it('rejects a wrong audience', async () => { + const token = signJwt(privateKey, 'RS256', { + sub: 'agent-42', + iss: config.issuer, + aud: 'some-other-api', + exp: nowSec() + 300, + }); + expect(await verifyOAuthToken(token, config, resolver)).toBeNull(); + }); + + it('accepts an array audience that contains the expected value', async () => { + const token = signJwt(privateKey, 'RS256', { + sub: 'agent-42', + iss: config.issuer, + aud: ['other', config.audience], + exp: nowSec() + 300, + }); + expect(await verifyOAuthToken(token, config, resolver)).not.toBeNull(); + }); + + it('rejects when the resolver has no key for the kid', async () => { + const token = signJwt(privateKey, 'RS256', { sub: 'x', iss: config.issuer, aud: config.audience }, 'unknown-kid'); + expect(await verifyOAuthToken(token, config, resolver)).toBeNull(); + }); + + it('rejects the alg=none downgrade', async () => { + const header = { alg: 'none', typ: 'JWT' }; + const payload = { sub: 'x', iss: config.issuer, aud: config.audience }; + const token = `${b64url(header)}.${b64url(payload)}.`; + expect(await verifyOAuthToken(token, config, resolver)).toBeNull(); + }); +}); + +describe('verifyOAuthToken — symmetric (HS256 shared secret)', () => { + const config: OAuthConfig = { issuer: 'https://idp', audience: 'evolith-mcp', secret: 'top-secret' }; + + it('accepts a valid HS256 token', async () => { + const token = signJwt({} as crypto.KeyObject, 'HS256', { + sub: 'svc', + iss: config.issuer, + aud: config.audience, + exp: nowSec() + 300, + }, 'k1', config.secret); + const payload = await verifyOAuthToken(token, config); + expect(payload?.sub).toBe('svc'); + }); + + it('rejects an HS256 token signed with the wrong secret', async () => { + const token = signJwt({} as crypto.KeyObject, 'HS256', { + sub: 'svc', + iss: config.issuer, + aud: config.audience, + exp: nowSec() + 300, + }, 'k1', 'wrong-secret'); + expect(await verifyOAuthToken(token, config)).toBeNull(); + }); +}); + +describe('createJwksResolver', () => { + it('fetches the JWKS document and resolves the key by kid', async () => { + const { publicKey } = crypto.generateKeyPairSync('rsa', { modulusLength: 2048 }); + const jwk = { ...publicKey.export({ format: 'jwk' }), kid: 'kid-a', use: 'sig', alg: 'RS256' }; + const fetchImpl = jest.fn().mockResolvedValue({ + ok: true, + json: async () => ({ keys: [jwk] }), + }) as unknown as typeof fetch; + + const resolver = createJwksResolver('https://idp/jwks', { fetchImpl }); + const key = await resolver('kid-a', 'RS256'); + expect(key).not.toBeNull(); + // Second lookup is served from cache (no second fetch). + await resolver('kid-a', 'RS256'); + expect(fetchImpl).toHaveBeenCalledTimes(1); + }); + + it('returns null when the JWKS fetch fails', async () => { + const fetchImpl = jest.fn().mockResolvedValue({ ok: false, status: 500 }) as unknown as typeof fetch; + const resolver = createJwksResolver('https://idp/jwks', { fetchImpl }); + // verifyOAuthToken swallows the thrown fetch error and yields null. + await expect(resolver('kid-a', 'RS256')).rejects.toThrow(); + }); +}); diff --git a/src/packages/mcp-server/src/mcp/oauth-resource-server.ts b/src/packages/mcp-server/src/mcp/oauth-resource-server.ts new file mode 100644 index 000000000..8f41b0b50 --- /dev/null +++ b/src/packages/mcp-server/src/mcp/oauth-resource-server.ts @@ -0,0 +1,246 @@ +import * as crypto from 'node:crypto'; + +/** + * GT-520 · EAG-15 / AC1 — OAuth 2.1 resource-server bearer validation. + * + * A GENERIC, IdP-agnostic validator for OAuth access tokens presented as + * `Authorization: Bearer ` on the remote Streamable HTTP surface. It is + * deliberately NOT wired to a single identity provider: the concrete IdP (and + * whether it is one shared IdP or per-tenant) is an org decision tracked as + * EAG-01 in the Tracker. Everything here works against ANY standards-compliant + * OAuth 2.1 / OIDC issuer: + * + * - asymmetric tokens (RS/PS/ES family) verified against the issuer's published + * JWKS (`EVOLITH_MCP_OAUTH_JWKS_URI`), keyed by the token header `kid`; + * - or symmetric tokens (HS*) verified against a shared secret + * (`EVOLITH_MCP_OAUTH_SECRET`) for IdPs/gateways that mint HMAC tokens; + * - issuer (`iss`), audience (`aud`), expiry (`exp`) and not-before (`nbf`) + * are all enforced, with a small configurable clock-skew tolerance. + * + * The verified claims map to the same {@link McpUserContext} shape the rest of + * the server already threads through per-identity ABAC, so the identity used by + * authorization comes from the cryptographically verified token — never from an + * unauthenticated request header. + */ +export interface OAuthConfig { + /** Expected `iss` claim (the OAuth/OIDC issuer). Required to enable OAuth. */ + issuer: string; + /** Expected audience; when set, the token `aud` must contain it. */ + audience?: string; + /** JWKS endpoint for asymmetric (RS/PS/ES family) signature verification. */ + jwksUri?: string; + /** Shared secret for symmetric (HS*) token verification. */ + secret?: string; + /** Allowed clock skew in seconds for exp/nbf checks (default 60). */ + clockToleranceSec?: number; +} + +/** + * Resolves the verification key for an asymmetric token. Given the token header + * `kid` (may be undefined) and `alg`, returns a public key (KeyObject or PEM) or + * `null` when no matching key is known. Injectable so tests need no network. + */ +export type JwksKeyResolver = ( + kid: string | undefined, + alg: string, +) => Promise; + +/** + * Build an {@link OAuthConfig} from environment variables, or `null` when OAuth + * is not configured (in which case the server falls back to the existing + * API-key / local-JWT path). OAuth is considered enabled only when an issuer AND + * at least one verification source (JWKS URI or shared secret) are present. + */ +export function loadOAuthConfig(env: NodeJS.ProcessEnv): OAuthConfig | null { + const issuer = env.EVOLITH_MCP_OAUTH_ISSUER?.trim(); + const jwksUri = env.EVOLITH_MCP_OAUTH_JWKS_URI?.trim(); + const secret = env.EVOLITH_MCP_OAUTH_SECRET?.trim(); + const audience = env.EVOLITH_MCP_OAUTH_AUDIENCE?.trim(); + if (!issuer || (!jwksUri && !secret)) return null; + const tolRaw = env.EVOLITH_MCP_OAUTH_CLOCK_TOLERANCE_SEC; + const clockToleranceSec = tolRaw ? parseInt(tolRaw, 10) : undefined; + return { + issuer, + audience: audience || undefined, + jwksUri: jwksUri || undefined, + secret: secret || undefined, + clockToleranceSec: Number.isFinite(clockToleranceSec as number) ? clockToleranceSec : undefined, + }; +} + +interface Jwk { + kid?: string; + alg?: string; + kty?: string; + use?: string; + [k: string]: unknown; +} + +/** + * Default JWKS resolver: fetches the issuer's JWKS document and converts the + * matching JWK into a public {@link crypto.KeyObject}. Keys are cached in-memory + * by `kid` with a TTL; a cache miss (e.g. key rotation) triggers one refetch. + */ +export function createJwksResolver( + jwksUri: string, + opts: { ttlMs?: number; fetchImpl?: typeof fetch } = {}, +): JwksKeyResolver { + const ttlMs = opts.ttlMs ?? 5 * 60 * 1000; + const fetchImpl = opts.fetchImpl ?? fetch; + let cache: { keys: Map; fetchedAt: number } | null = null; + + async function refresh(): Promise> { + const res = await fetchImpl(jwksUri); + if (!res.ok) throw new Error(`JWKS fetch failed: ${res.status}`); + const doc = (await res.json()) as { keys?: Jwk[] }; + const keys = new Map(); + for (const jwk of doc.keys ?? []) { + try { + const key = crypto.createPublicKey({ key: jwk, format: 'jwk' } as crypto.JsonWebKeyInput); + // A JWK without a kid is still usable when it is the only key. + keys.set(jwk.kid ?? '', key); + } catch { + // Skip malformed keys rather than failing the whole set. + } + } + cache = { keys, fetchedAt: Date.now() }; + return keys; + } + + return async (kid: string | undefined): Promise => { + const lookup = (keys: Map): crypto.KeyObject | null => { + if (kid && keys.has(kid)) return keys.get(kid)!; + // No kid on the token (or unmatched): fall back to the sole key if unambiguous. + if (!kid && keys.size === 1) return [...keys.values()][0]; + if (!kid && keys.has('')) return keys.get('')!; + return null; + }; + + const fresh = cache && Date.now() - cache.fetchedAt < ttlMs; + if (fresh) { + const hit = lookup(cache!.keys); + if (hit) return hit; + } + const keys = await refresh(); + return lookup(keys); + }; +} + +function verifyAsymmetric( + alg: string, + signingInput: string, + signature: Buffer, + rawKey: crypto.KeyObject | string, +): boolean { + const data = Buffer.from(signingInput); + try { + const key = typeof rawKey === 'string' ? crypto.createPublicKey(rawKey) : rawKey; + switch (alg) { + case 'RS256': + return crypto.verify('RSA-SHA256', data, key, signature); + case 'RS384': + return crypto.verify('RSA-SHA384', data, key, signature); + case 'RS512': + return crypto.verify('RSA-SHA512', data, key, signature); + case 'PS256': + return crypto.verify( + 'sha256', + data, + { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST }, + signature, + ); + case 'PS384': + return crypto.verify( + 'sha384', + data, + { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST }, + signature, + ); + case 'PS512': + return crypto.verify( + 'sha512', + data, + { key, padding: crypto.constants.RSA_PKCS1_PSS_PADDING, saltLength: crypto.constants.RSA_PSS_SALTLEN_DIGEST }, + signature, + ); + case 'ES256': + return crypto.verify('sha256', data, { key, dsaEncoding: 'ieee-p1363' }, signature); + case 'ES384': + return crypto.verify('sha384', data, { key, dsaEncoding: 'ieee-p1363' }, signature); + case 'ES512': + return crypto.verify('sha512', data, { key, dsaEncoding: 'ieee-p1363' }, signature); + default: + return false; + } + } catch { + return false; + } +} + +function verifySymmetric(alg: string, signingInput: string, signature: Buffer, secret: string): boolean { + const hashAlg = alg === 'HS256' ? 'sha256' : alg === 'HS384' ? 'sha384' : alg === 'HS512' ? 'sha512' : ''; + if (!hashAlg) return false; + const expected = crypto.createHmac(hashAlg, secret).update(signingInput).digest(); + return expected.length === signature.length && crypto.timingSafeEqual(expected, signature); +} + +/** + * Verify an OAuth 2.1 access token (compact JWS). Returns the decoded claim set + * when the signature and all registered claims (iss/aud/exp/nbf) check out, or + * `null` on any failure. Never throws. + * + * @param keyResolver overrides the config's JWKS-derived resolver (for tests / + * pre-warmed keys). Ignored for symmetric (HS*) tokens. + */ +export async function verifyOAuthToken( + token: string, + config: OAuthConfig, + keyResolver?: JwksKeyResolver, +): Promise | null> { + try { + const parts = token.split('.'); + if (parts.length !== 3) return null; + const [headerB64, payloadB64, sigB64] = parts; + + const header = JSON.parse(Buffer.from(headerB64, 'base64url').toString('utf8')) as { + alg?: string; + kid?: string; + }; + const payload = JSON.parse(Buffer.from(payloadB64, 'base64url').toString('utf8')) as Record; + const alg = typeof header.alg === 'string' ? header.alg : ''; + if (!alg || alg === 'none') return null; + + const signingInput = `${headerB64}.${payloadB64}`; + const signature = Buffer.from(sigB64, 'base64url'); + + let verified = false; + if (alg.startsWith('HS')) { + if (!config.secret) return null; + verified = verifySymmetric(alg, signingInput, signature, config.secret); + } else { + const resolver = + keyResolver ?? (config.jwksUri ? createJwksResolver(config.jwksUri) : undefined); + if (!resolver) return null; + const key = await resolver(header.kid, alg); + if (!key) return null; + verified = verifyAsymmetric(alg, signingInput, signature, key); + } + if (!verified) return null; + + // Registered-claim checks (RFC 7519 §4.1). + const now = Math.floor(Date.now() / 1000); + const skew = config.clockToleranceSec ?? 60; + + if (typeof payload.exp === 'number' && now > payload.exp + skew) return null; + if (typeof payload.nbf === 'number' && now + skew < payload.nbf) return null; + if (config.issuer && payload.iss !== config.issuer) return null; + if (config.audience) { + const aud = payload.aud; + const ok = Array.isArray(aud) ? aud.includes(config.audience) : aud === config.audience; + if (!ok) return null; + } + return payload; + } catch { + return null; + } +} diff --git a/src/rulesets/adr/adr-0002-hexagonal-architecture.rules.json b/src/rulesets/adr/adr-0002-hexagonal-architecture.rules.json index 6cacdbfe7..b4d2692a2 100644 --- a/src/rulesets/adr/adr-0002-hexagonal-architecture.rules.json +++ b/src/rulesets/adr/adr-0002-hexagonal-architecture.rules.json @@ -63,7 +63,13 @@ "rationale": "ADR-0002 §Decision: Infrastructure is the adapter layer. It implements the ports defined by Core.", "validationQuery": "Infrastructure files implement interfaces defined in Core layer. No domain logic lives here.", "blocking": true, - "layer": "Infrastructure" + "layer": "Infrastructure", + "enforce": { + "engine": "enforcer", + "tool": "dependency-cruiser", + "toolRuleId": "hxa-03-infra-implements-ports", + "runtime": "node" + } }, { "id": "HXA-04", @@ -120,7 +126,14 @@ "rationale": "ADR-0002 §4: AOP in adapters preserves domain purity while enabling observability.", "validationQuery": "AOP implementations (interceptors, middleware, decorators) exist only in Infrastructure layer.", "blocking": false, - "layer": "Infrastructure" + "layer": "Infrastructure", + "enforce": { + "engine": "enforcer", + "tool": "dependency-cruiser", + "toolRuleId": "hxa-06-aop-in-infrastructure-only", + "runtime": "node", + "mode": "warn" + } }, { "id": "HXA-07", @@ -131,7 +144,19 @@ "rationale": "ADR-0002 §Consequences: Pure domain tests are fast and framework-independent.", "validationQuery": "Domain unit tests execute without TestBed or testcontainer initialization. Average execution time < 50ms per test file.", "blocking": false, - "layer": "Core" + "layer": "Core", + "enforce": { + "engine": "enforcer", + "tool": "dependency-cruiser", + "toolRuleId": "hxa-07-domain-tests-no-framework-bootstrap", + "runtime": "node", + "mode": "warn", + "config": { + "comment": "Domain/Core test files must not import a framework test harness or testcontainers.", + "from": { "path": "^src/(domain|core)/.+\\.(spec|test)\\.ts$" }, + "to": { "path": "node_modules/(@nestjs/testing|testcontainers|@testcontainers)/" } + } + } } ], "references": [ diff --git a/src/rulesets/enforcement/enforcer-catalog.json b/src/rulesets/enforcement/enforcer-catalog.json index da50cb05f..dff37e742 100644 --- a/src/rulesets/enforcement/enforcer-catalog.json +++ b/src/rulesets/enforcement/enforcer-catalog.json @@ -1,13 +1,13 @@ { "$schema": "http://json-schema.org/draft-07/schema#", "version": "1.0.0", - "description": "GT-514 / EAG-08. Validated architecture-enforcement analyzers. This is the machine-readable mirror of the 'Architecture Enforcement (Boundary Analyzers)' section (§4.3) of product/infra/validated-tool-catalog.md. Every entry's tool + version MUST appear in that doc. Exact version pinning is tracked in GT-519.", + "description": "GT-514 / EAG-08. Validated architecture-enforcement analyzers. This is the machine-readable mirror of the 'Architecture Enforcement (Boundary Analyzers)' section (§4.3) of product/infra/validated-tool-catalog.md. Every entry's tool + version MUST appear in that doc. GT-519: versions are pinned EXACT (x.y.z, no ranges/wildcards) so per-runtime CI images are reproducible; the enforcer-catalog ↔ doc parity test (enforcer-catalog-doc-parity.spec.ts) fails on any drift. Currency is maintained by Renovate against these pins (deploy-gated).", "source": "product/infra/validated-tool-catalog.md#43-architecture-enforcement-boundary-analyzers", "enforcers": [ { "tool": "dependency-cruiser", "runtime": "node", - "version": "16.x", + "version": "16.10.4", "purpose": "Module/layer boundary + cycle analysis (TS/JS)", "outputFormat": "json", "adr": "ADR-0002" @@ -15,7 +15,7 @@ { "tool": "NetArchTest", "runtime": "dotnet", - "version": "1.3.x", + "version": "1.3.2", "purpose": "Layer & dependency-direction rules (.NET)", "outputFormat": "exit-code", "adr": "ADR-0002" @@ -23,7 +23,7 @@ { "tool": "Deptrac", "runtime": "php", - "version": "2.x", + "version": "2.0.4", "purpose": "Layer boundary enforcement (PHP)", "outputFormat": "json", "adr": "ADR-0002" @@ -31,7 +31,7 @@ { "tool": "import-linter", "runtime": "python", - "version": "2.x", + "version": "2.1.0", "purpose": "Import contracts, grimp-backed (Python)", "outputFormat": "text", "adr": "ADR-0002" @@ -39,7 +39,7 @@ { "tool": "Conftest", "runtime": "iac", - "version": "0.56.x", + "version": "0.56.0", "purpose": "OPA/Rego policy checks for IaC/config manifests", "outputFormat": "json", "adr": "ADR-0002" diff --git a/src/sdk/cli/src/app.module.ts b/src/sdk/cli/src/app.module.ts index a97763bae..dc4cd9e51 100644 --- a/src/sdk/cli/src/app.module.ts +++ b/src/sdk/cli/src/app.module.ts @@ -51,6 +51,7 @@ import { NestLoggerProvider, YamlConfigParserProvider, WebhookAdapter, + NodeProcessRunner, } from '@beyondnet/evolith-infra-providers'; import { PluginModule } from './infrastructure/plugins/plugin.module'; @@ -102,11 +103,15 @@ import { PluginModule } from './infrastructure/plugins/plugin.module'; { provide: RulesetValidatorService, useFactory: (fs: IFileSystem, logger: ILogger, configParser: IConfigParser) => { + // GT-519 parity: register the enforcer subsystem on the CLI surface identically to + // REST/MCP by injecting the real process runner. Non-forking — the composite delegates + // to the native strategy unless a ruleset authors an `enforce:` block. return new RulesetValidatorService({ fileSystem: fs, logger, configParser, rulesetRepo: new DiskRulesetRepository(fs, logger), + processRunner: new NodeProcessRunner(), }); }, inject: ['IFileSystem', 'ILogger', 'IConfigParser'], diff --git a/src/sdk/cli/src/commands/evaluate/evaluate.command.spec.ts b/src/sdk/cli/src/commands/evaluate/evaluate.command.spec.ts index f81e09968..f941a4a20 100644 --- a/src/sdk/cli/src/commands/evaluate/evaluate.command.spec.ts +++ b/src/sdk/cli/src/commands/evaluate/evaluate.command.spec.ts @@ -122,3 +122,38 @@ describe('EvaluateCommand --format sarif (GT-518)', () => { expect(command.parseFormat('sarif')).toBe('sarif'); }); }); + +describe('EvaluateCommand --format drift (GT-518 — PR/CI drift gate fallback)', () => { + afterEach(() => jest.restoreAllMocks()); + + it('prints a PR-comment body citing the ADR and exits non-zero on a blocking finding', async () => { + const { command, log, exit } = setup(); + await command.executeCommand([], { format: 'drift' }); + const body = log.mock.calls[log.mock.calls.length - 1][0] as string; + expect(body).toContain('drift gate'); + expect(body).toContain('ADR-0002'); + expect(body).toContain('Blocked'); + // Fallback = non-zero exit code, never a silent no-op. + expect(exit).toHaveBeenCalledWith(1); + }); +}); + +describe('EvaluateCommand --evidence (GT-518 — evidence manifest EVD-01..03)', () => { + afterEach(() => jest.restoreAllMocks()); + + it('writes the enforcer-evidence manifest to the given path', async () => { + const os = require('node:os'); + const nodePath = require('node:path'); + const fs = require('fs-extra'); + const out = nodePath.join(os.tmpdir(), `evolith-evidence-${Date.now()}.json`); + const { command } = setup(); + await command.executeCommand([], { format: 'json', evidence: out }); + const manifest = JSON.parse(await fs.readFile(out, 'utf-8')); + expect(manifest.source).toBe('drift-gate'); + expect(manifest.producer).toBe('evolith-core'); + expect(manifest.status).toBe('fail'); // the error gap is a blocking failure + expect(manifest.blockingFailures).toBe(1); + expect(manifest.relatedRuleIds).toContain('ADR-0002'); + await fs.remove(out); + }); +}); diff --git a/src/sdk/cli/src/commands/evaluate/evaluate.command.ts b/src/sdk/cli/src/commands/evaluate/evaluate.command.ts index 7364914a9..e2644fa46 100644 --- a/src/sdk/cli/src/commands/evaluate/evaluate.command.ts +++ b/src/sdk/cli/src/commands/evaluate/evaluate.command.ts @@ -10,6 +10,12 @@ import { EvaluationOrchestrator, createDefaultKindEvaluators, exportEvaluationResultToSarif, + emitEvaluationEvidence, + evaluateDriftGate, + PrCommentFallbackPublisher, + parseCodeowners, + DRIFT_GATE_SOURCE, + type CodeownersRule, type EvaluationContext, type IEvaluationPipeline, type IWorkspaceReferenceResolver, @@ -32,6 +38,7 @@ interface EvaluateCommandOptions { phase?: string; topology?: string; format?: string; + evidence?: string; } /** @@ -130,11 +137,31 @@ export class EvaluateCommand extends BaseEvolithCommand { schemaVersion: result.schemaVersion, }); + // GT-518: emit the enforcer-evidence manifest (EVD-01..03 via the unified evidence + // normalizer) as a side output when requested — independent of the stdout format so + // SARIF stays a pure SARIF log. Carries the same evidence shape every surface produces. + if (options?.evidence) { + const fs = await import('fs-extra'); + const manifest = emitEvaluationEvidence(result, DRIFT_GATE_SOURCE); + await fs.writeFile(path.resolve(options.evidence), JSON.stringify(manifest, null, 2), 'utf-8'); + } + const format = options?.format || 'json'; if (format === 'sarif') { // GT-518: emit a SARIF 2.1.0 log (gaps/risks as `result`s) for the CI/PR // drift gate and any SARIF-consuming code scanner (GitHub code scanning). console.log(JSON.stringify(exportEvaluationResultToSarif(result))); + } else if (format === 'drift') { + // GT-518: the PR/CI drift gate. Cite the violated ADR + accountable owner + // (resolved from CODEOWNERS), print the PR-comment body and exit non-zero on + // block. The live Checks-API publish is deploy-gated behind IChecksPublisher; + // this is the mandated deploy-gate-free fallback (never a silent no-op). + const codeowners = await this.loadCodeowners(ctx.workspaceRef ?? process.cwd()); + const decision = evaluateDriftGate({ result, codeowners }); + const published = await new PrCommentFallbackPublisher().publish(decision); + console.log(published.body); + if (published.exitCode !== 0) process.exit(published.exitCode); + return; } else if (format === 'json') { console.log(JSON.stringify(envelope, null, 2)); } else { @@ -152,6 +179,28 @@ export class EvaluateCommand extends BaseEvolithCommand { } } + /** + * Load and parse the repo's CODEOWNERS (for drift-gate owner enrichment) from the + * conventional locations under the workspace. Returns `[]` when none exists — owner + * resolution degrades to "unassigned" rather than failing the gate. + */ + private async loadCodeowners(workspaceRef: string): Promise { + const fs = await import('fs-extra'); + const root = path.resolve(workspaceRef); + const candidates = ['.github/CODEOWNERS', 'CODEOWNERS', 'docs/CODEOWNERS']; + for (const rel of candidates) { + const full = path.join(root, rel); + try { + if (await fs.pathExists(full)) { + return parseCodeowners(await fs.readFile(full, 'utf-8')); + } + } catch { + // Unreadable CODEOWNERS is non-fatal — the gate still blocks, owner is just unassigned. + } + } + return []; + } + private async buildContext(options?: EvaluateCommandOptions): Promise { if (options?.context) { const fs = await import('fs-extra'); @@ -209,8 +258,16 @@ export class EvaluateCommand extends BaseEvolithCommand { return val; } - @Option({ flags: '-f, --format [string]', description: 'Output format (json | text | sarif). Default: json' }) + @Option({ flags: '-f, --format [string]', description: 'Output format (json | text | sarif | drift). Default: json' }) parseFormat(val: string): string { return val; } + + @Option({ + flags: '--evidence [path]', + description: 'Write the enforcer-evidence manifest (EVD-01..03) to a file (GT-518)', + }) + parseEvidence(val: string): string { + return val; + } } diff --git a/tsconfig.json b/tsconfig.json index 0bd74ac29..66ee45041 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,6 +3,7 @@ "files": [], "references": [ { "path": "./src/packages/core-domain" }, + { "path": "./src/packages/contracts" }, { "path": "./src/packages/infra-providers" }, { "path": "./src/packages/core" }, { "path": "./src/packages/agent-runtime" },