From b339a9cacabb872b4ec219757efe2276910346c6 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 16 Aug 2026 22:26:30 -0500 Subject: [PATCH 1/5] fix(release): assert a package's declared exports against its own tarball, and delete a promise that was never real GT-706. The release DID assert that a tarball carries the entry points its manifest promises -- and computed "promised" as `[pkg.main, ...Object.values(pkg.bin)]`. `exports` was not in that list, so it answered a narrower question than the manifest asks, and contracts@1.1.0 published an export subpath it did not ship. That failed one package later, at infra-providers@1.2.1's install smoke, with core-domain@1.3.1 already irreversibly on the registry and no unpublish available past 72 hours. Measured, not argued: a two-file package declaring "./ingest" with only dist/index.js on disk passes the old assertion with exit 0, while `require pkg/ingest` answers MODULE_NOT_FOUND. 67-validate-declared-exports.mjs collects EVERY string leaf of the condition tree -- a missing types/*.d.ts breaks TypeScript consumers exactly as a missing default/*.js breaks Node -- and folds in main/bin, so it is a superset of the check it replaces rather than a second opinion beside it. The packlist comes from `npm pack --dry-run --json`, so `files` and `.npmignore` are npm's answer. Two places on purpose: ci-cd.yml over every publishable workspace at PR time, npm-release.yml per package inside the publish loop immediately before `npm publish`. A red PR costs a commit; a red release costs a version nobody can withdraw. THE GUARD REFUTED THE ROW THAT ASKED FOR IT, ON ITS FIRST RUN. GT-706 claimed the registry was clean -- 22 of 22 subpaths resolve, 0 phantom. That measurement excluded wildcard keys by its own filter, and one is DEAD: core-domain declares `./infrastructure/adapters/*` and there is no `adapters` directory at all. 0 matches in a 796-file packlist, MODULE_NOT_FOUND from the published 1.3.1 for every name under it, and `git log --all --name-only` over that path returns NOTHING -- introduced by 2453f9a2, never carried by any commit. It has been a promise nobody could keep since the day it was written. Deleted, not widened, because there was never anything behind it. The row now records the correction rather than the claim. Falsifiability observed on both sides, four ways. RED: the ./ingest fixture; core-domain on the real tree; and a file present ON DISK but excluded by `files`, which a filesystem check would call clean. GREEN: the same fixture once the file ships, and the whole repository -- 68 declared targets across 9 packages. core-domain 1.3.1 -> 1.3.2 so the fix can reach the registry: the planner is idempotent by version, so a fix that does not bump never ships. That drags the CLI to 1.3.2 with it, which is exactly the cost the exact-pin commit documented -- "a sibling patch now REQUIRES a CLI release" -- arriving on its first use. The four artifacts that DECLARE the CLI version were regenerated, not hand-edited. Board: 673/704 done, 2 pending. Verified: 67 green (68 targets, 9 packages), its 25 fixtures pass, tsc -b clean, core-domain 1992/1992, and 08 / 49 / 01 / 04 / 41 / 57 green with 46 at a fixed point. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .github/workflows/ci-cd.yml | 21 ++ .github/workflows/npm-release.yml | 39 +-- .../ci/67-validate-declared-exports.mjs | Bin 0 -> 12451 bytes .../ci/67-validate-declared-exports.test.mjs | 262 ++++++++++++++++++ package-lock.json | 6 +- .../smart-cli/product-inventory.es.md | 2 +- .../products/smart-cli/product-inventory.md | 2 +- .../evidence/gap-closure-evidence.json | 22 ++ .../gaps/gap-reference-catalog.es.md | 14 +- .../gaps/gap-reference-catalog.md | 14 +- .../control-center/gaps/gap-tracking.es.md | 4 +- .../core/control-center/gaps/gap-tracking.md | 4 +- .../maturity-reports/executive-summary.es.md | 16 +- .../maturity-reports/executive-summary.md | 16 +- .../maturity-reconciliation.json | 8 +- src/packages/core-domain/package.json | 6 +- .../contracts/evolith-machine-contracts.json | 2 +- src/sdk/cli/package.json | 4 +- 18 files changed, 373 insertions(+), 69 deletions(-) create mode 100644 .harness/scripts/ci/67-validate-declared-exports.mjs create mode 100644 .harness/scripts/ci/67-validate-declared-exports.test.mjs diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4baddbc4..4c213d50 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -693,6 +693,27 @@ jobs: node .harness/scripts/ci/65-validate-standards-rule-class.mjs --verbose node --test .harness/scripts/ci/65-validate-standards-rule-class.test.mjs + # GT-706 — a package must prove its own manifest before it is published. + # + # `contracts@1.1.0` declared an export subpath it did not ship. That failed + # at `infra-providers@1.2.1`'s install smoke, AFTER `core-domain@1.3.1` was + # already irreversibly on the registry, leaving the release half-shipped + # with no unpublish available past 72 hours. + # + # The release's own assertion could not see it: it computes "promised" as + # `[pkg.main, ...bin]`, and `exports` is not in that list. Measured — a + # package declaring `./ingest` with only `dist/index.js` on disk passes it + # with exit 0 while `require pkg/ingest` answers MODULE_NOT_FOUND. + # + # Running it HERE, at PR time, is the point: the release also runs it (see + # npm-release.yml), but by then the producer's turn is the last moment + # before something irreversible. A red PR costs a commit; a red release + # costs a version nobody can withdraw. + - name: A package's declared exports are present in its own tarball + run: | + node .harness/scripts/ci/67-validate-declared-exports.mjs --verbose + node --test .harness/scripts/ci/67-validate-declared-exports.test.mjs + # GT-650 / ADR-0125 — the artifact registry is the accepted single declaration, and the gate # corpus is still hand-maintained while the migration lands. For as long as both exist, the # only thing making that intermediate state safe is that they are checked to agree. This diff --git a/.github/workflows/npm-release.yml b/.github/workflows/npm-release.yml index 1ae92063..e26a6cca 100644 --- a/.github/workflows/npm-release.yml +++ b/.github/workflows/npm-release.yml @@ -185,9 +185,22 @@ jobs: # dist/main.js`, and the job went green. Reporting is not asserting. A log line # no step fails on is decoration. # - # This reads the packlist and fails when `main` or any `bin` target is absent, - # which is the narrowest question that would have caught it, and is general - # enough to catch the next mechanism rather than only this one. + # GT-706 — THE NARROW VERSION OF THIS CHECK LET A SECOND DEFECT THROUGH, and the + # inline node it replaced is now a guard with its own fixtures. + # + # It read the packlist and failed when `main` or any `bin` target was absent — + # "the narrowest question that would have caught it". It was too narrow. + # `contracts@1.1.0` declared an EXPORT SUBPATH it did not ship, `exports` was + # not in the list this checked, and the failure surfaced one package later, at + # `infra-providers@1.2.1`'s install smoke, with `core-domain@1.3.1` already + # irreversibly published and no unpublish available past 72 hours. + # + # Measured rather than argued: a two-file package declaring + # `"./ingest": "./dist/ingest/index.js"` with only `dist/index.js` on disk passed + # the old assertion with exit 0, while `require pkg/ingest` answered + # MODULE_NOT_FOUND. The guard below covers `main` and `bin` too, so this is a + # superset and not a second opinion beside it. + # # Done in node, in one process, on purpose. The first version of this check was a # shell pipeline — `echo "$PACKED" | grep -qxF "$f"` — and it reported a FALSE # POSITIVE on its first real run: it named dist/main.js missing from a tarball that @@ -203,21 +216,11 @@ jobs: # # A `<<<` herestring fixes it too. node fixes the whole class: no pipe to break, no # word-splitting on the promised list, and the JSON is already JSON. - echo "-- packlist assertion" - ( cd "$DIR" && npm pack --dry-run --json > /tmp/packlist.json ) - node -e ' - const fs = require("fs"); - const dir = process.argv[1]; - const pkg = JSON.parse(fs.readFileSync(dir + "/package.json", "utf8")); - const packed = new Set(JSON.parse(fs.readFileSync("/tmp/packlist.json", "utf8"))[0].files.map((f) => f.path)); - const promised = [...new Set([pkg.main, ...Object.values(pkg.bin || {})].filter(Boolean).map((s) => s.replace(/^\.\//, "")))]; - console.log(" " + packed.size + " file(s) packed; entry points declared: " + (promised.join(", ") || "(none)")); - const missing = promised.filter((p) => !packed.has(p)); - if (missing.length) { - console.log("::error::" + pkg.name + "@" + pkg.version + " would publish WITHOUT: " + missing.join(", ") + " — the tarball does not contain the entry points its own package.json declares. Publishing it would put a package on the registry that cannot be required or executed."); - process.exit(1); - } - ' "$DIR" + echo "-- packlist assertion (main, bin AND every exports target)" + if ! node .harness/scripts/ci/67-validate-declared-exports.mjs --pkg "$DIR" --verbose; then + echo "::error::$NAME@$VERSION declares targets its own tarball does not carry. Publishing it would put a package on the registry whose manifest promises import paths that do not resolve — and npm forbids unpublishing after 72 hours." + exit 1 + fi if [ "$DRY_RUN" = "true" ]; then ( cd "$DIR" && npm publish --dry-run --provenance --access public ) diff --git a/.harness/scripts/ci/67-validate-declared-exports.mjs b/.harness/scripts/ci/67-validate-declared-exports.mjs new file mode 100644 index 0000000000000000000000000000000000000000..97ba9e87f7f563f8d67ee43e59494e5b2b28022a GIT binary patch literal 12451 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 { + it('takes a bare string', () => { + assert.deepEqual(targetLeaves('./dist/index.js'), ['./dist/index.js']); + }); + + it('takes types AND default, because a missing types breaks TypeScript consumers', () => { + assert.deepEqual( + targetLeaves({ types: './dist/a.d.ts', default: './dist/a.js' }), + ['./dist/a.d.ts', './dist/a.js'], + ); + }); + + it('descends through nested conditions', () => { + assert.deepEqual( + targetLeaves({ import: { types: './dist/a.d.mts', default: './dist/a.mjs' }, require: './dist/a.cjs' }), + ['./dist/a.d.mts', './dist/a.mjs', './dist/a.cjs'], + ); + }); + + it('descends through arrays', () => { + assert.deepEqual(targetLeaves(['./dist/a.js', './dist/b.js']), ['./dist/a.js', './dist/b.js']); + }); + + it('yields nothing for null — a null target is a deliberate block, not a promise', () => { + assert.deepEqual(targetLeaves(null), []); + }); +}); + +describe('declaredTargets — split by whether a target names a file or a pattern', () => { + it('separates plain subpaths from wildcards', () => { + const { plain, wildcard } = declaredTargets({ + exports: { '.': './dist/index.js', './ingest': './dist/ingest/index.js', './*': './dist/*.js' }, + }); + assert.deepEqual(plain.map((r) => r.target), ['dist/index.js', 'dist/ingest/index.js']); + assert.deepEqual(wildcard.map((r) => r.target), ['dist/*.js']); + }); + + it('subsumes main and bin, so this guard is a superset of the release assertion', () => { + const { plain } = declaredTargets({ main: './dist/main.js', bin: { tool: 'dist/cli.js' } }); + assert.deepEqual(plain.map((r) => r.target).sort(), ['dist/cli.js', 'dist/main.js']); + }); + + it('normalizes the leading ./ that packlists never carry', () => { + const { plain } = declaredTargets({ exports: { '.': './dist/index.js' } }); + assert.equal(plain[0].target, 'dist/index.js'); + }); + + it('does not report the same subpath/target pair twice', () => { + const { plain } = declaredTargets({ main: './dist/index.js', exports: { '.': './dist/index.js' } }); + assert.equal(plain.filter((r) => r.target === 'dist/index.js').length, 2, 'distinct subpaths stay distinct'); + const { plain: once } = declaredTargets({ exports: { '.': { types: './dist/i.js', default: './dist/i.js' } } }); + assert.equal(once.length, 1, 'the same pair under two conditions collapses'); + }); +}); + +describe('wildcardMatcher — a * stands for a segment run, never for a /', () => { + it('matches within one segment', () => { + assert.ok(wildcardMatcher('dist/*.js').test('dist/index.js')); + }); + + it('refuses to cross a directory boundary', () => { + assert.ok(!wildcardMatcher('dist/*.js').test('dist/nested/index.js')); + }); + + it('is anchored, so a suffix match is not a match', () => { + assert.ok(!wildcardMatcher('dist/*.js').test('other/dist/index.js')); + assert.ok(!wildcardMatcher('dist/*.js').test('dist/index.js.map')); + }); + + it('escapes regex metacharacters in the literal parts', () => { + assert.ok(wildcardMatcher('dist/a.b/*.js').test('dist/a.b/c.js')); + assert.ok(!wildcardMatcher('dist/a.b/*.js').test('dist/axb/c.js')); + }); +}); + +describe('reconcile — the defect this guard exists for', () => { + // THE FIXTURE. This is `contracts@1.1.0` reduced to its essentials: an exports + // map naming a file the tarball does not carry. The release's own assertion + // passes it, because `exports` is not in the list it checks. + const phantom = { + name: 'phantom', + main: './dist/index.js', + exports: { '.': './dist/index.js', './ingest': './dist/ingest/index.js' }, + }; + + it('is RED on a declared export that is not packed', () => { + const { missing } = reconcile(phantom, ['dist/index.js', 'package.json']); + assert.equal(missing.length, 1); + assert.equal(missing[0].subpath, './ingest'); + assert.equal(missing[0].target, 'dist/ingest/index.js'); + }); + + it('is GREEN on the same package once the file ships — the check is not simply strict', () => { + const { missing, dead } = reconcile(phantom, ['dist/index.js', 'dist/ingest/index.js', 'package.json']); + assert.equal(missing.length, 0); + assert.equal(dead.length, 0); + }); + + it('is RED on a wildcard that matches nothing — the live defect on the registry', () => { + // `core-domain` declared `./infrastructure/adapters/*` and there is no + // adapters directory at all: 0 matches in a 796-file packlist, and + // MODULE_NOT_FOUND from the published 1.3.1 for every name under it. A check + // that skipped wildcards would have called that package clean. + const { dead } = reconcile( + { exports: { './infrastructure/adapters/*': './dist/infrastructure/adapters/*.js' } }, + ['dist/infrastructure/audit/x.js', 'dist/infrastructure/events/y.js'], + ); + assert.equal(dead.length, 1); + assert.equal(dead[0].subpath, './infrastructure/adapters/*'); + }); + + it('is GREEN on a wildcard that matches at least one file', () => { + const { dead } = reconcile({ exports: { './*': './dist/*.js' } }, ['dist/index.js']); + assert.equal(dead.length, 0); + }); + + it('counts every target it checked, so a vacuous pass is visible', () => { + const { checked } = reconcile(phantom, ['dist/index.js']); + assert.equal(checked, 3, 'two exports targets plus main'); + }); + + it('reports a package with no exports at all as trivially clean', () => { + const { missing, dead, checked } = reconcile({ name: 'plain' }, ['package.json']); + assert.deepEqual([missing.length, dead.length, checked], [0, 0, 0]); + }); +}); + +describe('publishableWorkspaces — coverage comes from the manifest, not a hardcoded list', () => { + it('resolves more than zero packages in this repository', () => { + const dirs = publishableWorkspaces(REPO_ROOT); + assert.ok(dirs.length > 0, 'an empty scan would make every run vacuously green'); + }); + + it('excludes private packages, which are never published', () => { + const dirs = publishableWorkspaces(REPO_ROOT); + for (const rel of dirs) { + const pkg = JSON.parse(readFileSync(join(REPO_ROOT, rel, 'package.json'), 'utf8')); + assert.notEqual(pkg.private, true, `${rel} is private and should not be scanned`); + } + }); +}); + +describe('end to end — the binary, on a real package, through a real npm pack', () => { + const run = (dir) => spawnSync(process.execPath, [GUARD, '--pkg', dir], { encoding: 'utf8' }); + + const write = (root, exportsMap) => { + mkdirSync(join(root, 'dist'), { recursive: true }); + writeFileSync(join(root, 'dist', 'index.js'), 'module.exports = {};\n'); + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { name: 'gt706-fixture', version: '1.0.0', main: './dist/index.js', files: ['dist'], exports: exportsMap }, + null, + 2, + ), + ); + }; + + it('exits non-zero and names the missing target, so the failure is actionable without opening the tarball', () => { + const root = mkdtempSync(join(tmpdir(), 'gt706-red-')); + try { + write(root, { '.': './dist/index.js', './ingest': './dist/ingest/index.js' }); + const r = run(root); + assert.notEqual(r.status, 0, 'a phantom export must be red'); + const out = `${r.stdout}${r.stderr}`; + assert.match(out, /gt706-fixture/, 'names the package'); + assert.match(out, /\.\/ingest/, 'names the subpath'); + assert.match(out, /dist\/ingest\/index\.js/, 'names the target file'); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('exits zero on the same package once the declared file is actually shipped', () => { + const root = mkdtempSync(join(tmpdir(), 'gt706-green-')); + try { + write(root, { '.': './dist/index.js', './ingest': './dist/ingest/index.js' }); + mkdirSync(join(root, 'dist', 'ingest'), { recursive: true }); + writeFileSync(join(root, 'dist', 'ingest', 'index.js'), 'module.exports = {};\n'); + const r = run(root); + assert.equal(r.status, 0, `expected green, got: ${r.stdout}${r.stderr}`); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('is red on a package whose `files` field excludes a file that exists on disk', () => { + // The mechanism that produced the original defect: the file was written, and + // the manifest simply did not ship it. Checking the filesystem instead of the + // PACKLIST would call this clean. + const root = mkdtempSync(join(tmpdir(), 'gt706-files-')); + try { + mkdirSync(join(root, 'dist', 'ingest'), { recursive: true }); + writeFileSync(join(root, 'dist', 'index.js'), 'module.exports = {};\n'); + writeFileSync(join(root, 'dist', 'ingest', 'index.js'), 'module.exports = {};\n'); + writeFileSync( + join(root, 'package.json'), + JSON.stringify( + { + name: 'gt706-fixture', + version: '1.0.0', + main: './dist/index.js', + files: ['dist/index.js'], + exports: { '.': './dist/index.js', './ingest': './dist/ingest/index.js' }, + }, + null, + 2, + ), + ); + const r = run(root); + assert.notEqual(r.status, 0, 'on disk but not in the tarball is still a phantom'); + assert.match(`${r.stdout}${r.stderr}`, /dist\/ingest\/index\.js/); + } finally { + rmSync(root, { recursive: true, force: true }); + } + }); + + it('is green across this repository as it stands', () => { + const r = spawnSync(process.execPath, [GUARD], { encoding: 'utf8', cwd: REPO_ROOT }); + assert.equal(r.status, 0, `${r.stdout}${r.stderr}`); + assert.match(r.stdout, /declared target\(s\) across \d+ publishable package\(s\)/); + }); +}); diff --git a/package-lock.json b/package-lock.json index 79bcb7ef..1c7cb8d7 100644 --- a/package-lock.json +++ b/package-lock.json @@ -16347,7 +16347,7 @@ }, "src/packages/core-domain": { "name": "@beyondnet/evolith-core-domain", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "@open-policy-agent/opa-wasm": "1.10.0", @@ -16913,11 +16913,11 @@ }, "src/sdk/cli": { "name": "@beyondnet/evolith-cli", - "version": "1.3.1", + "version": "1.3.2", "license": "MIT", "dependencies": { "@beyondnet/evolith-agent-runtime": "1.2.0", - "@beyondnet/evolith-core-domain": "1.3.1", + "@beyondnet/evolith-core-domain": "1.3.2", "@beyondnet/evolith-infra-providers": "1.2.1", "@beyondnet/evolith-sdk": "2.0.0", "@clack/prompts": "1.5.1", diff --git a/product/products/smart-cli/product-inventory.es.md b/product/products/smart-cli/product-inventory.es.md index 49e33742..6f1bc20b 100644 --- a/product/products/smart-cli/product-inventory.es.md +++ b/product/products/smart-cli/product-inventory.es.md @@ -7,7 +7,7 @@ Inventario generado de la superficie instalable de Evolith CLI y MCP. No editar | Campo | Valor | |---|---| -| Package | `@beyondnet/evolith-cli@1.3.1` | +| Package | `@beyondnet/evolith-cli@1.3.2` | | Binary | `evolith`, `evolith-cli` | | CLI commands | 38 | | MCP tools | 52 | diff --git a/product/products/smart-cli/product-inventory.md b/product/products/smart-cli/product-inventory.md index a87460ad..8238e7a1 100644 --- a/product/products/smart-cli/product-inventory.md +++ b/product/products/smart-cli/product-inventory.md @@ -7,7 +7,7 @@ Generated inventory of the installable Evolith CLI and MCP surface. Do not edit | Field | Value | |---|---| -| Package | `@beyondnet/evolith-cli@1.3.1` | +| Package | `@beyondnet/evolith-cli@1.3.2` | | Binary | `evolith`, `evolith-cli` | | CLI commands | 38 | | MCP tools | 52 | diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 6b7c8814..17557acf 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -10529,6 +10529,28 @@ "SUITES AFTER: core-domain 1992/1992, mcp-server 590/590.", "NOT CLOSED BY THIS ROW: the fix is in the tree, not in the registry. GT-671 stays IN-PROGRESS until a build carrying the corpus is published and its canary observes a real gate verdict from npm." ] + }, + { + "id": "GT-706", + "closedAt": "2026-08-16", + "closureCommit": "e61af1da", + "dependencyDisposition": "none", + "evidence": [ + ".harness/scripts/ci/67-validate-declared-exports.mjs", + ".harness/scripts/ci/67-validate-declared-exports.test.mjs", + ".github/workflows/npm-release.yml", + ".github/workflows/ci-cd.yml", + "src/packages/core-domain/package.json" + ], + "validationCommands": [ + "THE CHECK THAT EXISTED WAS THE WRONG SHAPE, MEASURED: npm-release.yml:213 computed 'promised' as [pkg.main, ...Object.values(pkg.bin)], and `exports` was not in that list. A two-file package declaring \"./ingest\": \"./dist/ingest/index.js\" with only dist/index.js on disk passed that assertion run VERBATIM with exit 0, while `require pkg/ingest` answered MODULE_NOT_FOUND.", + "THE GUARD REFUTED THIS ROW'S OWN CLAIM ON ITS FIRST RUN. The row said the registry was clean -- 22 of 22 subpaths resolve, 0 phantom. That measurement excluded wildcard keys by its own filter. core-domain declares ./infrastructure/adapters/* and there is NO adapters directory: 0 matches in a 796-file packlist, MODULE_NOT_FOUND from the published 1.3.1 for every name under it. `git log --all --name-only` over that path returns nothing -- the key was introduced by 2453f9a2 and no commit in this repository ever carried it. Deleted rather than widened, because there was never anything behind it.", + "TWO PLACES ON PURPOSE: ci-cd.yml runs it over every publishable workspace at PR time, npm-release.yml runs it per package inside the publish loop immediately before `npm publish`. A red PR costs a commit; a red release costs a version nobody can withdraw after 72 hours.", + "FALSIFIABILITY OBSERVED ON BOTH SIDES, four ways. RED: the ./ingest fixture; core-domain on the real tree; and a file present ON DISK but excluded by `files`, which a filesystem check would call clean. GREEN: the same fixture once the file ships, and the whole repository -- 68 declared targets across 9 publishable packages.", + "SUPERSET, NOT A SECOND OPINION: main and bin are folded into the same check, and every string leaf of the condition tree counts, so a missing types/*.d.ts fails exactly as a missing default/*.js does.", + "node .harness/scripts/ci/67-validate-declared-exports.mjs --verbose", + "node --test .harness/scripts/ci/67-validate-declared-exports.test.mjs" + ] } ] } 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 1b473743..9f4ced80 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -9943,7 +9943,7 @@ La declaración tiene un hueco — un pack que no declara — y el directorio lo - **Por qué el smoke de sala limpia tampoco lo cubre, y no es un defecto de ese script.** `check-install-smoke.mjs` resuelve cada especificador `@beyondnet/*` que un paquete **importa** (`:74-101`), lo cual es del lado consumidor por diseño — es lo que llegó a cazar este caso. Pero un export fantasma del productor es invisible hasta que alguien lo importa, así que la comprobación dispara **en el turno del consumidor dentro del orden de publicación**, es decir, cuando el productor y todo lo anterior ya son inmutables en el registry. Tarde e irreversible es la parte que cuesta. - **Exposición, medida en todo el workspace:** 3 de 8 paquetes publicables declaran subrutas de export — `contracts` (5), `core-domain` (16), `agent-runtime` (2) — **23 subrutas, ninguna asegurada por la release.** Dos de los tres declaran además un comodín `./*`, que promete que *cualquier* `./dist/*.js` es importable y por construcción no tiene cota. -- **Lo que esta fila NO afirma, porque se midió y es falso.** Hoy no hay ningún export fantasma en el registry. Instalando los publicados actuales `contracts@1.2.0`, `core-domain@1.3.1` y `agent-runtime@1.2.0` en un prefijo limpio y resolviendo cada subruta declarada: **22 resuelven, 0 fantasmas.** El registry está sano; lo que falta es algo que lo mantenga así. Registrar esto como «hay exports rotos» habría sido una fila que se cierra sola por accidente en la siguiente release. +- **Lo que esta fila afirmó del registry, y lo que el guard refutó en su primera corrida.** La fila decía que no había ningún fantasma en el registry — medido, 22 subrutas resuelven, 0 fantasmas. **Esa medición excluía las claves con comodín por su propio filtro, y una de ellas está muerta.** `core-domain` declara `./infrastructure/adapters/*` y **no existe ningún directorio `adapters`**: 0 coincidencias en un packlist de 796 ficheros, y `MODULE_NOT_FOUND` desde el `1.3.1` publicado para cualquier nombre bajo esa ruta. La historia de git dice que nunca fue real — la clave la introdujo `2453f9a2` y **ningún commit de este repositorio llevó jamás ese path**, así que ha sido una promesa imposible desde el día en que se escribió. Un check que se saltara los comodines habría dado ese paquete por limpio, y por eso el guard los comprueba y por eso el criterio de abajo no dejó que se saltaran. - **Casos de uso:** - Un paquete gana una subruta de export y un cambio de `files`/build deja de incluirla en silencio; la release se niega en vez de publicar un manifiesto que miente. - Una actualización de consumidor deja de fallar en instalación por un defecto que pertenece a un paquete publicado días antes. @@ -9955,9 +9955,9 @@ La declaración tiene un hueco — un pack que no declara — y el directorio lo - **Principal:** `S` · **Interés:** `HIGH` · **Base:** `estimate` - **Procedencia:** Registrada el 2026-08-16 desde la release 1.3.x, donde la clase costó dos intentos de publicación fallidos y una release parcial irreversible. Hermana de [`GT-625`](./gap-reference-catalog.es.md#gt-625) y [`GT-671`](./gap-reference-catalog.es.md#gt-671): la misma asimetría árbol-contra-tarball, una capa antes — esas dos preguntan si el artefacto PUBLICADO funciona, esta pregunta si debió publicarse siquiera. - **Criterios de aceptación:** - - [ ] La release resuelve cada destino de `exports` sin comodín contra el packlist del propio paquete, y tumba la publicación cuando falta uno. - - [ ] La aserción corre ANTES del paso irreversible, en el turno del productor — no en la instalación de un consumidor. - - [ ] El comodín `./*` se trata de forma explícita en vez de saltarse: o la fila registra por qué una promesa sin cota es aceptable, o el comodín se estrecha a lo que realmente se incluye. - - [ ] Falsabilidad demostrada: un paquete que declara un export que no empaqueta pone la comprobación en rojo, OBSERVADO, y la misma comprobación sigue verde sobre los tres paquetes reales cuyas 23 subrutas miden sanas hoy. - - [ ] La comprobación nombra el destino que falta y el paquete, para que el fallo sea accionable sin abrir el tarball. -- **Estado:** `PENDIENTE` + - [x] La release resuelve cada destino de `exports` sin comodín contra el packlist del propio paquete, y tumba la publicación cuando falta uno. **CUMPLIDO** — `.harness/scripts/ci/67-validate-declared-exports.mjs`. Recoge **cada hoja de texto del árbol de condiciones**, así que `types` cuenta tanto como `default`: un `.d.ts` que falta rompe a los consumidores de TypeScript igual que un `.js` que falta rompe a Node. `main` y `bin` van incluidos, así que el guard es un **superconjunto** de la aserción en línea que sustituye y no una segunda opinión a su lado. El packlist sale de `npm pack --dry-run --json`, así que `files`, `.npmignore` y toda regla de empaquetado son la respuesta de npm y no una reimplementación suya. + - [x] La aserción corre ANTES del paso irreversible, en el turno del productor — no en la instalación de un consumidor. **CUMPLIDO, y en dos sitios a propósito.** `npm-release.yml` la corre por paquete dentro del bucle de publicación, justo antes de `npm publish`; `ci-cd.yml` la corre sobre todos los workspaces publicables en tiempo de PR. Un PR rojo cuesta un commit, una release roja cuesta una versión que nadie puede retirar, y la comprobación de la release es el suelo para cualquier cosa que llegue al bucle sin pasar por un PR. + - [x] El comodín `./*` se trata de forma explícita en vez de saltarse: o la fila registra por qué una promesa sin cota es aceptable, o el comodín se estrecha a lo que realmente se incluye. **CUMPLIDO, y es el criterio que encontró el segundo defecto.** Un comodín debe casar con **al menos un** fichero empaquetado; cero coincidencias es una promesa muerta y falla. En su primera corrida el guard se puso rojo con `./infrastructure/adapters/*` de `core-domain` — sin directorio `adapters` en un packlist de 796 ficheros, `MODULE_NOT_FOUND` en el `1.3.1` publicado, y **ningún commit de este repositorio llevó jamás ese path**. La promesa se borró, no se amplió, porque nunca hubo nada detrás. Las claves `./*` que quedan resuelven. El `*` se casa como `[^/]*`, anclado por ambos extremos, así que `dist/a.js` no puede satisfacer `dist/nested/*.js`. + - [x] Falsabilidad demostrada: un paquete que declara un export que no empaqueta pone la comprobación en rojo, OBSERVADO, y la misma comprobación sigue verde sobre los tres paquetes reales cuyas 23 subrutas miden sanas hoy. **CUMPLIDO, rojo observado dos veces y verde observado dos veces.** Rojo: la fixture `./ingest`, y `core-domain` sobre el árbol real. Verde: la misma fixture en cuanto el fichero se incluye, y el repositorio entero — **68 destinos declarados en 9 paquetes publicables**. Una tercera fixture cubre el mecanismo que produjo el defecto original: un fichero presente **en disco** pero excluido por `files`, que una comprobación del sistema de ficheros daría por limpio y una del packlist caza. + - [x] La comprobación nombra el destino que falta y el paquete, para que el fallo sea accionable sin abrir el tarball. **CUMPLIDO** — cada línea es `MISSING -> ` o `DEAD -> (el patrón no casa con ningún fichero empaquetado)` bajo `@ ()`, asegurado de punta a punta contra el binario real. Una corrida que resuelva **cero** paquetes, o cuyo packlist no se pueda leer, es un fallo duro: «la comprobación no corrió» nunca debe leerse como «la comprobación no encontró nada». +- **Estado:** `COMPLETADO` diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index ce417dda..b9db006a 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -10038,7 +10038,7 @@ The declaration has one hole — a pack that does not declare — and the direct - **Why the clean-room smoke does not cover it either, and it is not a defect of that script.** `check-install-smoke.mjs` resolves every `@beyondnet/*` specifier a package **imports** (`:74-101`), which is consumer-side by design — it is what caught this one at all. But a producer's phantom export is invisible until somebody imports it, so the check fires **at the consumer's turn in the publish order**, which is after the producer and everything before it are already immutable on the registry. Late and irreversible is the part that costs. - **Exposure, measured across the workspace:** 3 of 8 publishable packages declare export subpaths — `contracts` (5), `core-domain` (16), `agent-runtime` (2) — **23 subpaths, none of them asserted by the release.** Two of the three also declare a `./*` wildcard, which promises that *any* `./dist/*.js` is importable and is therefore unbounded by construction. -- **What this row does NOT claim, because it was measured and is false.** There is no phantom export on the registry today. Installing the current published `contracts@1.2.0`, `core-domain@1.3.1` and `agent-runtime@1.2.0` into a clean prefix and resolving every declared subpath gives **22 resolve, 0 phantom**. The registry is healthy; what is missing is anything that keeps it that way. Registering this as "there are broken exports" would have been a row that closes itself by accident on the next release. +- **What this row claimed about the registry, and what the guard refuted on its first run.** The row said there was no phantom on the registry — measured, 22 subpaths resolve, 0 phantom. **That measurement excluded wildcard keys by its own filter, and one of them is dead.** `core-domain` declares `./infrastructure/adapters/*` and there is **no `adapters` directory at all**: 0 matches in a 796-file packlist, and `MODULE_NOT_FOUND` from the published `1.3.1` for every name under it. Git history says it was never real — the key was introduced by `2453f9a2` and **no commit in this repository ever carried that path**, so it has been a promise nobody could keep since the day it was written. A check that skipped wildcards would have called that package clean, which is why the guard checks them and why the criterion below refused to let them be skipped. - **Use cases:** - A package gains an export subpath and a `files`/build change silently stops shipping it; the release refuses instead of publishing a manifest that lies. - A consumer upgrade stops failing at install time for a defect that belongs to a package published days earlier. @@ -10050,9 +10050,9 @@ The declaration has one hole — a pack that does not declare — and the direct - **Principal:** `S` · **Interest:** `HIGH` · **Basis:** `estimate` - **Provenance:** Registered 2026-08-16 from the 1.3.x release, where the class cost two failed publish attempts and one irreversible partial release. Sibling of [`GT-625`](./gap-reference-catalog.md#gt-625) and [`GT-671`](./gap-reference-catalog.md#gt-671): the same tree-versus-tarball asymmetry, one layer earlier — those two ask whether the PUBLISHED artifact works, this one asks whether the artifact should have been published at all. - **Acceptance criteria:** - - [ ] The release resolves every non-wildcard `exports` target of a package against that package's own packlist, and fails the publish when one is absent. - - [ ] The assertion runs BEFORE the irreversible step, in the producer's own turn — not at a consumer's install. - - [ ] The `./*` wildcard is handled explicitly rather than skipped: either the row records why an unbounded promise is acceptable, or the wildcard is narrowed to what is actually shipped. - - [ ] Proven falsifiable: a package declaring an export it does not pack turns the check red, OBSERVED, and the same check stays green on the three real packages whose 23 subpaths measure healthy today. - - [ ] The check names the missing target and the package, so the failure is actionable without opening the tarball. -- **Status:** `PENDING` + - [x] The release resolves every non-wildcard `exports` target of a package against that package's own packlist, and fails the publish when one is absent. **MET** — `.harness/scripts/ci/67-validate-declared-exports.mjs`. It collects **every string leaf of the condition tree**, so `types` counts as much as `default`: a missing `.d.ts` breaks TypeScript consumers exactly as a missing `.js` breaks Node. `main` and `bin` are folded in, so the guard is a **superset** of the inline assertion it replaces rather than a second opinion beside it. The packlist comes from `npm pack --dry-run --json`, so `files`, `.npmignore` and every other shipping rule are npm's answer and not a reimplementation of it. + - [x] The assertion runs BEFORE the irreversible step, in the producer's own turn — not at a consumer's install. **MET, in two places on purpose.** `npm-release.yml` runs it per package inside the publish loop, immediately before `npm publish`; `ci-cd.yml` runs it over every publishable workspace at PR time. A red PR costs a commit, a red release costs a version nobody can withdraw, and the release check is the floor for anything that reaches the loop without passing through a PR. + - [x] The `./*` wildcard is handled explicitly rather than skipped: either the row records why an unbounded promise is acceptable, or the wildcard is narrowed to what is actually shipped. **MET, and this is the criterion that found the second defect.** A wildcard must match **at least one** packed file; zero matches is a dead promise and fails. On its first run the guard turned red on `core-domain`'s `./infrastructure/adapters/*` — no `adapters` directory in a 796-file packlist, `MODULE_NOT_FOUND` on the published `1.3.1`, and **no commit in this repository ever carried that path**. The promise was deleted, not widened, because there was never anything behind it. The surviving `./*` keys resolve. `*` is matched as `[^/]*`, anchored at both ends, so `dist/a.js` cannot satisfy `dist/nested/*.js`. + - [x] Proven falsifiable: a package declaring an export it does not pack turns the check red, OBSERVED, and the same check stays green on the three real packages whose 23 subpaths measure healthy today. **MET, red observed twice and green observed twice.** Red: the `./ingest` fixture, and `core-domain` on the real tree. Green: the same fixture once the file ships, and the whole repository — **68 declared targets across 9 publishable packages**. A third fixture covers the mechanism that produced the original defect: a file present **on disk** but excluded by `files`, which a filesystem check would call clean and a packlist check catches. + - [x] The check names the missing target and the package, so the failure is actionable without opening the tarball. **MET** — each line is `MISSING -> ` or `DEAD -> (pattern matches no packed file)` under `@ ()`, asserted end to end against the real binary. A run that resolves **zero** packages, or whose packlist cannot be read, is a hard failure: "the check did not run" must never read as "the check found nothing". +- **Status:** `DONE` diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index c3e2f8c3..c844e810 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -722,10 +722,10 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | [`GT-668`](./gap-reference-catalog.es.md#gt-668) | **La prueba de `GT-666` de que a su guarda se la había visto fallar alguna vez estaba anclada a una REFERENCIA MÓVIL, así que dejó de ser evidencia justo en el momento en que el arreglo aterrizó.** El único caso que lee el artefacto real previo al arreglo lo obtenía con `git show ${PRE_FIX_REF}:iso-5055-mapping.json`, con `PRE_FIX_REF` por defecto a `origin/develop`. Eso solo es cierto mientras el arreglo vive en una rama: **`59d62bae` se mergeó, `origin/develop` empezó a servir el artefacto CORREGIDO, y el caso que afirma 64 hallazgos encontró 0** — verde en su propia rama, rojo justo cuando importaba, bloqueando el PR de promoción `develop` → `main` **#483** en `Governance guards (GT-578)`. **Reproducido antes de actuar**, no tomado del traspaso: `node --test` sobre `develop` en `59d62bae` falla ese caso con `0 !== 64` mientras los otros 18 pasan. **La mitad peor es la que nadie habría visto:** el caso llevaba `if (before.status !== 0) return void assert.ok(true, 'SKIPPED: …')` para clones superficiales, así que en un checkout con historial truncado la misma podredumbre habría **pasado en silencio** en vez de fallar — una vía de escape de la única prueba de que la guarda estuvo roja alguna vez. **ENTREGADO 2026-08-09.** El razonamiento del comentario era correcto y se conserva: *«leído de git en vez de reconstruido … Los fixtures reconstruidos coinciden con lo que el autor creía que estaba mal; este no puede.»* Eso argumenta a favor de un artefacto REAL previo al arreglo, no de leer una rama en tiempo de test — así que el artefacto queda **congelado en el repositorio**: `.harness/fixtures/standards-rule-class/iso-5055-mapping.pre-gt-666.json`, tomado de `01308346` (`59d62bae^`, blob `6684e8a4`), **sin recortar**, y verificado byte a byte idéntico a ese blob una vez se quita su única clave añadida `_fixture` de procedencia. El `git show`, la variable de entorno `PRE_FIX_REF` y el salto por clon superficial quedan **eliminados** — un salto solo puede ocultar un fallo. **Afirmaciones sin cambios y sin debilitar: 64 hallazgos, 16 de ellos `is classified `governance``**, medidos contra el fixture congelado y los packs de hoy. Se AÑADE un caso en vez de relajar ninguno: un fixture «refrescado» desde el mapeo vivo es ROJO, y eso se **observó** — se simuló el refresco y pone en rojo 2 de 20 casos, así que el fixture negativo no puede convertirse en silencio en una copia del artefacto que existe para atrapar. Metaguardas reejecutadas: `42` 78/78 clasificadas, `43` 54/54 vistas fallar. | — | — | `Evolith Core` | Cross | P1 | XS | `COMPLETADO` | | [`GT-704`](./gap-reference-catalog.es.md#gt-704) | **Ningún job de CI corre LOS DOS motores sobre el corpus, así que una divergencia de veredicto entre ellos la descubre una persona o no la descubre nadie.** Separado de `GT-675`, cuyo AC7 pedía ese barrido y lo costeaba en ≈17 s. Lo que `GT-675` entregó es más estrecho y a propósito: un guard que ejecuta el bundle compilado y falla si no sabe declarar su propio alcance — la invariante cuya pérdida silenciosa des-arreglaría 234 reglas. No compara los dos motores. Medido el 2026-08-16 con ambos sobre el corpus completo: **16 ids reciben VEREDICTOS opuestos** (`passed` en uno, `failed` en el otro) — `TAX-01`, `TAX-05`, `INH-02`, `SVC-03`, `ACL-01` entre ellos —, que es la clase que a `ADR-0041` sí le importa, frente a las 82 diferencias de cobertura (65 decide-OPA/salta-nativo, 17 al revés) que son legítimas. También medido: `cli/exit-code-taxonomy`, que `GT-675` llamaba el pack de control donde ambos coinciden, NO coincide — nativo `passed / exit 0` contra OPA `failed / exit 2`. | Nadie corre los dos motores en paralelo, así que pueden separarse sin que nadie lo note. | Una discrepancia entre motores la caza CI el día que aparece. | `Core Domain` | Cross | P2 | M | `PENDIENTE` | | [`GT-705`](./gap-reference-catalog.es.md#gt-705) | **El servidor MCP publicado no traía corpus de rulesets Y adivinaba dónde estaba Core, así que 48 de sus 50 herramientas no podían gobernar nada desde una instalación limpia.** Encontrado por el canario de `GT-671`. **ARREGLADO el 2026-08-16 — dos causas independientes, y arreglar solo una no cambiaba nada.** (1) `files: ["dist/"…]` no llevaba corpus y ninguna dependencia lo aportaba; el paquete empaqueta ahora **los dos** árboles que el servidor necesita — corpus de rulesets y definiciones de gate SDLC. Que hacían falta ambos se OBSERVÓ, no se predijo: con solo el corpus, `evolith-validate` funcionaba y `evolith-gate-evaluate` seguía sin hacerlo. (2) `path.join(process.cwd(), '..', 'evolith')` — un directorio hermano con el nombre de este monorepo — en **9 sitios de 5 ficheros de mcp-server y 4 servicios de core-domain**, la capa que comparten las tres superficies. Ahora hay un solo resolutor: llamante → `EVOLITH_CORE_PATH` → subir desde el satélite → corpus empaquetado; `process.cwd()` no aparece. La búsqueda cualifica **por contenido**, lo que además cierra `GT-566` en esas cuatro copias — buscaban un directorio LLAMADO `rulesets` y este repo tiene un `rulesets/agents` que comparte nombre y no tiene reglas. DE PUNTA A PUNTA desde una instalación npm limpia y sin repositorio en disco: `evolith-validate` `INTERNAL_ERROR` → **veredicto `failed`**; `evolith-gate-evaluate` `RULESET_NOT_FOUND` → **veredicto `failed`, gate `business-sign-off`**. Tres specs aseguraban el contrato viejo y se reescribieron — uno se llamaba *«falls back to the sibling ../evolith convention»*. | El servidor MCP que instalas de npm anunciaba 50 herramientas y solo respondía las que no necesitan reglas. | Un agente conectado al servidor publicado puede gobernar de verdad. | `MCP Server` | Cross | P1 | M | `COMPLETADO` | -| [`GT-706`](./gap-reference-catalog.es.md#gt-706) | **Nada asegura que los `exports` que un paquete declara resuelvan dentro de su propio tarball, así que un productor publica una subruta fantasma y solo la descubre un consumidor — una publicación demasiado tarde.** `contracts@1.1.0` declaró una subruta de export que no incluía; el fallo salió en el smoke de sala limpia de `infra-providers@1.2.1`, **después de que `core-domain@1.3.1` ya estuviera irreversiblemente en el registry**, dejando la release a medio entregar y sin despublicar posible pasadas 72 horas. La comprobación que existe es real y tiene la forma equivocada: `npm-release.yml:213` calcula «prometidos» como `[pkg.main, ...bin]`, y **`exports` no está en esa lista**. FALSABILIDAD DEMOSTRADA, OBSERVADA EN VERDE: un paquete de dos ficheros que declara `"./ingest"` con solo `dist/index.js` en disco pasa esa aserción corrida literal — `exit=0`, mientras `require pkg/ingest` responde `MODULE_NOT_FOUND`. El smoke de sala limpia tampoco lo cubre, y no es defecto suyo: resuelve lo que un paquete IMPORTA, así que el fantasma del productor es invisible hasta el turno de un consumidor, que es después del paso irreversible. Exposición: 3 de 8 paquetes publicables declaran **23 subrutas de export**, ninguna asegurada, y dos declaran además un `./*` sin cota. **Lo que esta fila NO afirma, medido:** hoy no hay ningún fantasma en el registry — los tres paquetes publicados resuelven **22 de 22**. El registry está sano; nada lo mantiene así. | Un paquete puede prometer una ruta de import que nunca incluyó, y quien se entera es el siguiente paquete en publicarse. | La release se niega a publicar un manifiesto que miente, antes de que nada sea irreversible. | `Infra` | Cross | P1 | S | `PENDIENTE` | +| [`GT-706`](./gap-reference-catalog.es.md#gt-706) | **Nada asegura que los `exports` que un paquete declara resuelvan dentro de su propio tarball, así que un productor publica una subruta fantasma y solo la descubre un consumidor — una publicación demasiado tarde.** `contracts@1.1.0` declaró una subruta de export que no incluía; el fallo salió en el smoke de sala limpia de `infra-providers@1.2.1`, **después de que `core-domain@1.3.1` ya estuviera irreversiblemente en el registry**, dejando la release a medio entregar y sin despublicar posible pasadas 72 horas. La comprobación que existe es real y tiene la forma equivocada: `npm-release.yml:213` calcula «prometidos» como `[pkg.main, ...bin]`, y **`exports` no está en esa lista**. FALSABILIDAD DEMOSTRADA, OBSERVADA EN VERDE: un paquete de dos ficheros que declara `"./ingest"` con solo `dist/index.js` en disco pasa esa aserción corrida literal — `exit=0`, mientras `require pkg/ingest` responde `MODULE_NOT_FOUND`. El smoke de sala limpia tampoco lo cubre, y no es defecto suyo: resuelve lo que un paquete IMPORTA, así que el fantasma del productor es invisible hasta el turno de un consumidor, que es después del paso irreversible. Exposición: 3 de 8 paquetes publicables declaran **23 subrutas de export**, ninguna asegurada, y dos declaran además un `./*` sin cota. **ARREGLADO 2026-08-16 — `.harness/scripts/ci/67-validate-declared-exports.mjs`, corriendo en tiempo de PR sobre todos los workspaces publicables Y por paquete dentro del bucle de release, justo antes de `npm publish`.** Recoge cada hoja de texto del árbol de condiciones, así que `types` cuenta tanto como `default`, e incluye `main`/`bin`, siendo un superconjunto de la aserción que sustituye. **La propia afirmación de esta fila sobre el registry la refutó el guard en su primera corrida:** «22 de 22 resuelven, 0 fantasmas» excluía las claves con comodín por su propio filtro, y una está MUERTA — `core-domain` declara `./infrastructure/adapters/*` **sin ningún directorio `adapters`**, 0 coincidencias en un packlist de 796 ficheros, `MODULE_NOT_FOUND` en el 1.3.1 publicado, y **ningún commit de este repositorio llevó jamás ese path**. Borrada, no ampliada: nunca hubo nada detrás. Falsabilidad observada por los dos lados — rojo con la fixture `./ingest`, con `core-domain` de verdad, y con un fichero presente en disco pero excluido por `files`; verde con la misma fixture en cuanto se incluye y con el árbol entero, **68 destinos declarados en 9 paquetes**. | Un paquete puede prometer una ruta de import que nunca incluyó, y quien se entera es el siguiente paquete en publicarse. | La release se niega a publicar un manifiesto que miente, antes de que nada sea irreversible. | `Infra` | Cross | P1 | S | `COMPLETADO` | -**Progreso:** 672 / 704 completados · 2 en progreso · 3 pendientes · 27 diferidos +**Progreso:** 673 / 704 completados · 2 en progreso · 2 pendientes · 27 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 d86f6f26..d1df9fa3 100644 --- a/reference/core/control-center/gaps/gap-tracking.md +++ b/reference/core/control-center/gaps/gap-tracking.md @@ -722,10 +722,10 @@ This board is the single source of truth for technical debt, gaps, opportunities | [`GT-668`](./gap-reference-catalog.md#gt-668) | **`GT-666`'s proof that its guard had ever been observed failing was anchored to a MOVING REF, so it stopped being evidence at the exact moment the fix landed.** The one case that reads the real pre-fix artifact obtained it with `git show ${PRE_FIX_REF}:iso-5055-mapping.json`, `PRE_FIX_REF` defaulting to `origin/develop`. That is true only while the fix lives on a branch: **`59d62bae` merged, `origin/develop` began serving the CORRECTED artifact, and the case asserting 64 findings found 0** — green on its own branch, red the moment it mattered, blocking the `develop` → `main` promotion PR **#483** in `Governance guards (GT-578)`. **Reproduced before acting**, not taken from the handover: `node --test` on `develop` at `59d62bae` fails that one case with `0 !== 64` while the other 18 pass. **The worse half is the one nobody would have seen:** the case carried `if (before.status !== 0) return void assert.ok(true, 'SKIPPED: …')` for shallow clones, so in a checkout with a truncated history the same rot would have **passed in silence** rather than failing — an escape hatch out of the only case that proves the guard was ever red. **DELIVERED 2026-08-09.** The comment's reasoning was right and is kept: *«read out of git rather than reconstructed … Reconstructed fixtures agree with whatever the author believed was wrong; this one cannot.»* That argues for a REAL pre-fix artifact, not for reading a branch at test time — so the artifact is **frozen in the repository**: `.harness/fixtures/standards-rule-class/iso-5055-mapping.pre-gt-666.json`, taken from `01308346` (`59d62bae^`, blob `6684e8a4`), **not trimmed**, and verified byte-identical to that blob once its one added `_fixture` provenance key is dropped. The `git show`, the `PRE_FIX_REF` env var and the shallow-clone skip are **deleted** — a skip can only ever hide a failure. **Assertions unchanged and unweakened: 64 findings, 16 of them `is classified `governance``**, measured against the frozen fixture and today's packs. One case is ADDED rather than any relaxed: a fixture «refreshed» from the live mapping is RED, and that was **observed** — the refresh was simulated and turns 2 of 20 cases red, so the negative fixture cannot silently become a copy of the artifact it exists to catch. Meta-guards re-run: `42` 78/78 classified, `43` 54/54 observed failing. | — | — | `Evolith Core` | Cross | P1 | XS | `DONE` | | [`GT-704`](./gap-reference-catalog.md#gt-704) | **No CI job runs BOTH engines over the corpus, so a verdict divergence between them is discovered by a human or not at all.** Split out of `GT-675`, whose AC7 asked for the sweep and costed it at ≈17 s. What `GT-675` shipped is narrower and deliberately so: a guard that executes the compiled bundle and fails if it cannot state its own scope — the invariant whose quiet loss would silently un-fix 234 rules. It does not compare the two engines. Measured 2026-08-16 with both engines on the whole corpus: **16 rule ids get opposite VERDICTS** (`passed` on one, `failed` on the other) — `TAX-01`, `TAX-05`, `INH-02`, `SVC-03`, `ACL-01` among them — which is the class `ADR-0041` does care about, as opposed to the 82 coverage differences (65 OPA-decides/native-skips, 17 the reverse) that are legitimate. Also measured: `cli/exit-code-taxonomy`, which `GT-675` called the control pack where both engines agree, does NOT agree — native `passed / exit 0` against OPA `failed / exit 2`. | Nobody runs the two engines side by side, so they can drift apart unnoticed. | A disagreement between the engines is caught by CI the day it appears. | `Core Domain` | Cross | P2 | M | `PENDING` | | [`GT-705`](./gap-reference-catalog.md#gt-705) | **The published MCP server shipped no ruleset corpus AND guessed where Core was, so 48 of its 50 tools could not govern anything from a clean install.** Found by `GT-671`'s canary. **FIXED 2026-08-16 — two independent causes, and fixing either alone changed nothing.** (1) `files: ["dist/"…]` carried no corpus and no dependency supplied one; the package now bundles **both** trees the server needs — the ruleset corpus and the SDLC gate definitions. That both were required was OBSERVED, not predicted: with only the corpus, `evolith-validate` worked and `evolith-gate-evaluate` still did not. (2) `path.join(process.cwd(), '..', 'evolith')` — a sibling directory named after this monorepo — in **9 places across 5 files of mcp-server and 4 services of core-domain**, the layer all three surfaces share. One resolver now: caller → `EVOLITH_CORE_PATH` → walk up from the satellite → bundled corpus; `process.cwd()` is absent. The walk qualifies **by content**, which also closes `GT-566` in those four copies — they probed for a directory NAMED `rulesets` and this repo has a `rulesets/agents` that shares the name and holds no rules. END TO END from a clean npm install with no repository on disk: `evolith-validate` `INTERNAL_ERROR` → **verdict `failed`**; `evolith-gate-evaluate` `RULESET_NOT_FOUND` → **verdict `failed`, gate `business-sign-off`**. Three specs asserted the old contract and were rewritten — one was named *"falls back to the sibling ../evolith convention"*. | The MCP server you install from npm announced 50 tools and could only answer the ones needing no rules. | An agent connecting to the published server can actually govern something. | `MCP Server` | Cross | P1 | M | `DONE` | -| [`GT-706`](./gap-reference-catalog.md#gt-706) | **Nothing asserts that a package's own declared `exports` resolve inside its own tarball, so a producer publishes a phantom subpath and only a consumer discovers it — one publish too late.** `contracts@1.1.0` declared an export subpath it did not ship; the failure surfaced at `infra-providers@1.2.1`'s clean-room smoke, **after `core-domain@1.3.1` was already irreversibly on the registry**, leaving the release half-shipped with no unpublish available after 72 hours. The check that exists is real and the wrong shape: `npm-release.yml:213` computes "promised" as `[pkg.main, ...bin]`, and **`exports` is not in that list**. PROVEN FALSIFIABLE, OBSERVED GREEN: a two-file package declaring `"./ingest"` with only `dist/index.js` on disk passes that assertion run verbatim — `exit=0`, while `require pkg/ingest` answers `MODULE_NOT_FOUND`. The clean-room smoke does not cover it either, and that is not its defect: it resolves what a package IMPORTS, so a producer's phantom is invisible until a consumer's turn, which is after the irreversible step. Exposure: 3 of 8 publishable packages declare **23 export subpaths**, none asserted, two of them also declaring an unbounded `./*`. **What this row does NOT claim, measured:** there is no phantom on the registry today — the three published packages resolve **22 of 22**. The registry is healthy; nothing keeps it that way. | A package can promise an import path it never shipped, and the next package to publish is the one that finds out. | The release refuses to publish a manifest that lies, before anything becomes irreversible. | `Infra` | Cross | P1 | S | `PENDING` | +| [`GT-706`](./gap-reference-catalog.md#gt-706) | **Nothing asserts that a package's own declared `exports` resolve inside its own tarball, so a producer publishes a phantom subpath and only a consumer discovers it — one publish too late.** `contracts@1.1.0` declared an export subpath it did not ship; the failure surfaced at `infra-providers@1.2.1`'s clean-room smoke, **after `core-domain@1.3.1` was already irreversibly on the registry**, leaving the release half-shipped with no unpublish available after 72 hours. The check that exists is real and the wrong shape: `npm-release.yml:213` computes "promised" as `[pkg.main, ...bin]`, and **`exports` is not in that list**. PROVEN FALSIFIABLE, OBSERVED GREEN: a two-file package declaring `"./ingest"` with only `dist/index.js` on disk passes that assertion run verbatim — `exit=0`, while `require pkg/ingest` answers `MODULE_NOT_FOUND`. The clean-room smoke does not cover it either, and that is not its defect: it resolves what a package IMPORTS, so a producer's phantom is invisible until a consumer's turn, which is after the irreversible step. Exposure: 3 of 8 publishable packages declare **23 export subpaths**, none asserted, two of them also declaring an unbounded `./*`. **FIXED 2026-08-16 — `.harness/scripts/ci/67-validate-declared-exports.mjs`, run at PR time over every publishable workspace AND per package inside the release loop, immediately before `npm publish`.** It collects every string leaf of the condition tree, so `types` counts as much as `default`, and folds in `main`/`bin`, making it a superset of the assertion it replaces. **The row's own claim about the registry was refuted by the guard on its first run:** "22 of 22 resolve, 0 phantom" excluded wildcard keys by its own filter, and one is DEAD — `core-domain` declares `./infrastructure/adapters/*` with **no `adapters` directory at all**, 0 matches in a 796-file packlist, `MODULE_NOT_FOUND` on the published 1.3.1, and **no commit in this repository ever carried that path**. Deleted, not widened: there was never anything behind it. Falsifiability observed on both sides — red on the `./ingest` fixture, on `core-domain` for real, and on a file present on disk but excluded by `files`; green on the same fixture once it ships and on the whole tree, **68 declared targets across 9 packages**. | A package can promise an import path it never shipped, and the next package to publish is the one that finds out. | The release refuses to publish a manifest that lies, before anything becomes irreversible. | `Infra` | Cross | P1 | S | `DONE` | -**Progress:** 672 / 704 done · 2 in progress · 3 pending · 27 deferred +**Progress:** 673 / 704 done · 2 in progress · 2 pending · 27 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/executive-summary.es.md b/reference/core/control-center/maturity-reports/executive-summary.es.md index 512213f6..29034563 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.es.md +++ b/reference/core/control-center/maturity-reports/executive-summary.es.md @@ -27,8 +27,8 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so |---:|---|---|---| | 1 | Bloqueadores P0 | Impiden afirmar readiness productivo o release mayor. | [GT-435](../gaps/gap-reference-catalog.es.md#gt-435) | | 2 | Área de mayor riesgo | `Governance` tiene la mayor carga ponderada abierta. | [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), [GT-672](../gaps/gap-reference-catalog.es.md#gt-672), [GT-689](../gaps/gap-reference-catalog.es.md#gt-689), [GT-588](../gaps/gap-reference-catalog.es.md#gt-588), +2 | -| 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-706](../gaps/gap-reference-catalog.es.md#gt-706) | -| 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-706](../gaps/gap-reference-catalog.es.md#gt-706), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-680](../gaps/gap-reference-catalog.es.md#gt-680), [GT-681](../gaps/gap-reference-catalog.es.md#gt-681), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), +1 | +| 3 | Ganancias rápidas | Alta criticidad con complejidad XS/S. | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684) | +| 4 | Ola P1 | Endurecimiento siguiente después de limpiar P0. | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-680](../gaps/gap-reference-catalog.es.md#gt-680), [GT-681](../gaps/gap-reference-catalog.es.md#gt-681), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), [GT-448](../gaps/gap-reference-catalog.es.md#gt-448) | | 5 | P2/P3 | Solo después de estabilizar seguridad, CI, reglas y contratos. | [GT-444](../gaps/gap-reference-catalog.es.md#gt-444), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-674](../gaps/gap-reference-catalog.es.md#gt-674), [GT-685](../gaps/gap-reference-catalog.es.md#gt-685), [GT-686](../gaps/gap-reference-catalog.es.md#gt-686), [GT-687](../gaps/gap-reference-catalog.es.md#gt-687), +12 | ## Bloqueadores Actuales @@ -43,21 +43,21 @@ La forma correcta de usar este resumen es simple: si necesitas contexto, abre so |---|---:| | Fecha canónica del tablero | 2026-08-08 | | Gaps totales | 704 | -| Gaps cerrados | 672 | -| Gaps pendientes | 32 | +| Gaps cerrados | 673 | +| Gaps pendientes | 31 | | P0 abiertos | 1 | -| P1 abiertos | 9 | +| P1 abiertos | 8 | | P2 abiertos | 18 | -| Cierre total | 95.5% | -| Registros de evidencia de cierre | 654 | +| Cierre total | 95.6% | +| Registros de evidencia de cierre | 655 | | Readiness registrado | 4 PASS | | Área | Pendientes | P0 | P1 | Primeros IDs | |---|---:|---:|---:|---| | `Governance` | 8 | 0 | 3 | [GT-670](../gaps/gap-reference-catalog.es.md#gt-670), [GT-585](../gaps/gap-reference-catalog.es.md#gt-585), [GT-669](../gaps/gap-reference-catalog.es.md#gt-669), [GT-672](../gaps/gap-reference-catalog.es.md#gt-672), +4 | | `Cross` | 3 | 1 | 1 | [GT-435](../gaps/gap-reference-catalog.es.md#gt-435), [GT-448](../gaps/gap-reference-catalog.es.md#gt-448), [GT-651](../gaps/gap-reference-catalog.es.md#gt-651) | -| `Infra` | 6 | 0 | 2 | [GT-706](../gaps/gap-reference-catalog.es.md#gt-706), [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-685](../gaps/gap-reference-catalog.es.md#gt-685), +2 | | `MCP Server` | 3 | 0 | 3 | [GT-684](../gaps/gap-reference-catalog.es.md#gt-684), [GT-680](../gaps/gap-reference-catalog.es.md#gt-680), [GT-681](../gaps/gap-reference-catalog.es.md#gt-681) | +| `Infra` | 5 | 0 | 1 | [GT-324](../gaps/gap-reference-catalog.es.md#gt-324), [GT-464](../gaps/gap-reference-catalog.es.md#gt-464), [GT-685](../gaps/gap-reference-catalog.es.md#gt-685), [GT-691](../gaps/gap-reference-catalog.es.md#gt-691), +1 | | `Core Domain` | 4 | 0 | 0 | [GT-674](../gaps/gap-reference-catalog.es.md#gt-674), [GT-687](../gaps/gap-reference-catalog.es.md#gt-687), [GT-678](../gaps/gap-reference-catalog.es.md#gt-678), [GT-704](../gaps/gap-reference-catalog.es.md#gt-704) | ## Fuente y Regla de Actualización diff --git a/reference/core/control-center/maturity-reports/executive-summary.md b/reference/core/control-center/maturity-reports/executive-summary.md index c56ab276..daaa0390 100644 --- a/reference/core/control-center/maturity-reports/executive-summary.md +++ b/reference/core/control-center/maturity-reports/executive-summary.md @@ -27,8 +27,8 @@ Use this summary with a simple rule: if you need context, open only the linked I |---:|---|---|---| | 1 | P0 blockers | They prevent production-readiness or major-release confidence. | [GT-435](../gaps/gap-reference-catalog.md#gt-435) | | 2 | Highest-risk area | `Governance` has the largest weighted open load. | [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), [GT-672](../gaps/gap-reference-catalog.md#gt-672), [GT-689](../gaps/gap-reference-catalog.md#gt-689), [GT-588](../gaps/gap-reference-catalog.md#gt-588), +2 | -| 3 | Quick wins | High criticality with XS/S complexity. | [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-706](../gaps/gap-reference-catalog.md#gt-706) | -| 4 | P1 wave | Next hardening after P0 is cleared. | [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-706](../gaps/gap-reference-catalog.md#gt-706), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-680](../gaps/gap-reference-catalog.md#gt-680), [GT-681](../gaps/gap-reference-catalog.md#gt-681), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), +1 | +| 3 | Quick wins | High criticality with XS/S complexity. | [GT-684](../gaps/gap-reference-catalog.md#gt-684) | +| 4 | P1 wave | Next hardening after P0 is cleared. | [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-680](../gaps/gap-reference-catalog.md#gt-680), [GT-681](../gaps/gap-reference-catalog.md#gt-681), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), [GT-448](../gaps/gap-reference-catalog.md#gt-448) | | 5 | P2/P3 | Only after security, CI, rules, and contracts stabilize. | [GT-444](../gaps/gap-reference-catalog.md#gt-444), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-674](../gaps/gap-reference-catalog.md#gt-674), [GT-685](../gaps/gap-reference-catalog.md#gt-685), [GT-686](../gaps/gap-reference-catalog.md#gt-686), [GT-687](../gaps/gap-reference-catalog.md#gt-687), +12 | ## Current Blockers @@ -43,21 +43,21 @@ Use this summary with a simple rule: if you need context, open only the linked I |---|---:| | Canonical board date | 2026-08-08 | | Total gaps | 704 | -| Closed gaps | 672 | -| Open gaps | 32 | +| Closed gaps | 673 | +| Open gaps | 31 | | Open P0 | 1 | -| Open P1 | 9 | +| Open P1 | 8 | | Open P2 | 18 | -| Total closure | 95.5% | -| Closure evidence records | 654 | +| Total closure | 95.6% | +| Closure evidence records | 655 | | Recorded readiness | 4 PASS | | Area | Open | P0 | P1 | First IDs | |---|---:|---:|---:|---| | `Governance` | 8 | 0 | 3 | [GT-670](../gaps/gap-reference-catalog.md#gt-670), [GT-585](../gaps/gap-reference-catalog.md#gt-585), [GT-669](../gaps/gap-reference-catalog.md#gt-669), [GT-672](../gaps/gap-reference-catalog.md#gt-672), +4 | | `Cross` | 3 | 1 | 1 | [GT-435](../gaps/gap-reference-catalog.md#gt-435), [GT-448](../gaps/gap-reference-catalog.md#gt-448), [GT-651](../gaps/gap-reference-catalog.md#gt-651) | -| `Infra` | 6 | 0 | 2 | [GT-706](../gaps/gap-reference-catalog.md#gt-706), [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-685](../gaps/gap-reference-catalog.md#gt-685), +2 | | `MCP Server` | 3 | 0 | 3 | [GT-684](../gaps/gap-reference-catalog.md#gt-684), [GT-680](../gaps/gap-reference-catalog.md#gt-680), [GT-681](../gaps/gap-reference-catalog.md#gt-681) | +| `Infra` | 5 | 0 | 1 | [GT-324](../gaps/gap-reference-catalog.md#gt-324), [GT-464](../gaps/gap-reference-catalog.md#gt-464), [GT-685](../gaps/gap-reference-catalog.md#gt-685), [GT-691](../gaps/gap-reference-catalog.md#gt-691), +1 | | `Core Domain` | 4 | 0 | 0 | [GT-674](../gaps/gap-reference-catalog.md#gt-674), [GT-687](../gaps/gap-reference-catalog.md#gt-687), [GT-678](../gaps/gap-reference-catalog.md#gt-678), [GT-704](../gaps/gap-reference-catalog.md#gt-704) | ## Source and Refresh Rule diff --git a/reference/core/control-center/maturity-reports/maturity-reconciliation.json b/reference/core/control-center/maturity-reports/maturity-reconciliation.json index 53925296..7d3412e6 100644 --- a/reference/core/control-center/maturity-reports/maturity-reconciliation.json +++ b/reference/core/control-center/maturity-reports/maturity-reconciliation.json @@ -4,14 +4,14 @@ "asOf": "2026-08-08", "gaps": { "total": 704, - "done": 672, - "pending": 3, + "done": 673, + "pending": 2, "inProgress": 2, "deferred": 27 }, "evidence": { - "closureRecords": 654, - "cliPackage": "@beyondnet/evolith-cli@1.3.1", + "closureRecords": 655, + "cliPackage": "@beyondnet/evolith-cli@1.3.2", "adrCount": 141, "rulesetCount": 181, "schemaCount": 50 diff --git a/src/packages/core-domain/package.json b/src/packages/core-domain/package.json index ce0a0617..368c2230 100644 --- a/src/packages/core-domain/package.json +++ b/src/packages/core-domain/package.json @@ -1,6 +1,6 @@ { "name": "@beyondnet/evolith-core-domain", - "version": "1.3.1", + "version": "1.3.2", "description": "Shared Domain and Application logic for Evolith Core", "keywords": [ "evolith", @@ -64,10 +64,6 @@ "types": "./dist/infrastructure/transparency/index.d.ts", "default": "./dist/infrastructure/transparency/index.js" }, - "./infrastructure/adapters/*": { - "types": "./dist/infrastructure/adapters/*.d.ts", - "default": "./dist/infrastructure/adapters/*.js" - }, "./domain/interfaces": { "types": "./dist/domain/interfaces.d.ts", "default": "./dist/domain/interfaces.js" diff --git a/src/rulesets/contracts/evolith-machine-contracts.json b/src/rulesets/contracts/evolith-machine-contracts.json index 398d2e72..65028da3 100644 --- a/src/rulesets/contracts/evolith-machine-contracts.json +++ b/src/rulesets/contracts/evolith-machine-contracts.json @@ -3,7 +3,7 @@ "compatibilityPolicy": "semver-major", "producer": { "package": "@beyondnet/evolith-cli", - "version": "1.3.1" + "version": "1.3.2" }, "schemas": [ { diff --git a/src/sdk/cli/package.json b/src/sdk/cli/package.json index 93b2d80e..a33e0cd8 100644 --- a/src/sdk/cli/package.json +++ b/src/sdk/cli/package.json @@ -1,6 +1,6 @@ { "name": "@beyondnet/evolith-cli", - "version": "1.3.1", + "version": "1.3.2", "description": "Evolith CLI - Governance, standards validation, and AI agent integration for satellite repositories", "main": "dist/main.js", "bin": { @@ -71,7 +71,7 @@ "dependencies": { "@clack/prompts": "1.5.1", "@beyondnet/evolith-agent-runtime": "1.2.0", - "@beyondnet/evolith-core-domain": "1.3.1", + "@beyondnet/evolith-core-domain": "1.3.2", "@beyondnet/evolith-infra-providers": "1.2.1", "@beyondnet/evolith-sdk": "2.0.0", "@hono/node-server": "1.19.14", From e33cc2793ab74c52f7e44d22eef84fe6d67a74ee Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 16 Aug 2026 22:39:17 -0500 Subject: [PATCH 2/5] fix(harness): register the new guard's own anti-vacuous refusal, which the corpus caught it lacking `42-validate-guard-denominators` turned red on `67-validate-declared-exports.mjs` for precisely the defect that guard exists to prevent -- and it was right. The new guard DOES refuse a zero-element scan, but it did so in hand-rolled code that no classification knew about, and an unclassified guard is one that can report a pass over an empty scan. Registered SELF_GUARDED with a proof regex on its own refusal message. Its denominator is the set of publishable workspaces, read from the root manifest rather than hardcoded, and all three ways it could pass over nothing are hard failures: zero workspaces resolved, a package directory with no manifest, and a packlist `npm pack` cannot produce -- that last one being the state in which "nothing was found to be missing" is most misleading. The registration is not the evidence; the exercise is. `43-validate-guard-negative- fixtures` now points 59 guards at a repo-shaped tree with no corpus, up from 58, and 59/59 refused it. Guard 67 has been OBSERVED failing on an empty tree, not merely declared able to. Verified: 42 (62/62 scanning guards, 83 classified, 0 PENDING) and 43 (59/59) green, every self-test in that CI step green. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .harness/scripts/lib/guard-classification.mjs | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/.harness/scripts/lib/guard-classification.mjs b/.harness/scripts/lib/guard-classification.mjs index 8e8775e4..2381ff66 100644 --- a/.harness/scripts/lib/guard-classification.mjs +++ b/.harness/scripts/lib/guard-classification.mjs @@ -40,6 +40,19 @@ export const CALLS_COVERAGE = /\b(?:assertScanned|assertScannedPerSource|scanned * deleting the check and leaving the exemption behind. */ export const SELF_GUARDED = [ + { + file: '67-validate-declared-exports.mjs', + proof: /ZERO publishable workspaces resolved/, + reason: + 'GT-706 declared-exports guard; its denominator is the set of publishable workspaces, read ' + + 'from the root manifest rather than hardcoded, and all three ways it could pass over ' + + 'nothing are hard failures: zero workspaces resolved (the "workspaces" field moved or ' + + 'stopped matching), a package directory with no manifest, and a packlist `npm pack` cannot ' + + 'produce — that last one is the state in which "nothing was found to be missing" is most ' + + 'misleading, so it refuses instead of skipping the package. The guard exists because a ' + + 'check that asked a NARROWER question than the manifest read as clean; one that asked ' + + 'nothing at all would read the same way', + }, { file: '63-validate-npm-audit-gate.mjs', proof: /denominator is unknown/, From c17db8dbd54ce59ed519f2a810dff72f827454df Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 16 Aug 2026 22:50:15 -0500 Subject: [PATCH 3/5] fix(ci): build the four workspaces the new guard scans, measured under the runner's condition The declared-exports guard was green locally and red on the runner. The asymmetry was not subtle and it was mine: the guard reads each package's PACKLIST, and a workspace whose dist has not been emitted packs no dist, so every declared target reads as missing. The governance job builds FIVE workspaces; the guard covers NINE. Not diagnosed from the log alone -- reproduced. Every dist in the tree was parked and only the five builds that job runs were replayed, which reproduced the runner's four failures exactly: core -> dist/index.js sdk -> dist/index.js mcp -> dist/main.js cli -> dist/index.js Adding those four builds turns the same parked-dist tree green: 68 declared targets across 9 publishable packages. The alternative was to scan only the packages that happen to be built. Rejected on purpose: that is the same "narrower question than the manifest asks" that let contracts@1.1.0 publish an export it never shipped, which is the whole reason this guard exists. The ratchet the neighbouring comment warns about is unaffected -- 41's basis is 35 on a clean checkout by construction, independent of what this machine has built. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .github/workflows/ci-cd.yml | 15 +++++++++++++++ 1 file changed, 15 insertions(+) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4c213d50..7f103a89 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -529,6 +529,21 @@ jobs: # one, so the extractor has to be built or the guard has nothing to run. It # fails loudly in that case instead of reporting "no cycles found". npm run build --workspace @beyondnet/evolith-repo-facts + # GT-706: the declared-exports guard reads each package's PACKLIST, and a + # package whose dist has not been emitted packs no dist — so an unbuilt + # workspace reports every declared target as missing. This list was five + # workspaces and the guard covers nine; the four added here are exactly the + # ones whose absence turned it red on its first CI run, MEASURED by parking + # every dist locally and replaying only the five builds above: + # core -> dist/index.js, sdk -> dist/index.js, mcp -> dist/main.js, + # cli -> dist/index.js + # Building the subset the guard scans is the alternative that was rejected: + # scanning only what happens to be built is the same "narrower question than + # the manifest asks" that let contracts@1.1.0 through in the first place. + npm run build --workspace @beyondnet/evolith-core + npm run build --workspace @beyondnet/evolith-sdk + npm run build --workspace @beyondnet/evolith-mcp + npm run build --workspace src/sdk/cli # STRICT. Every script under .harness/scripts/ci must either refuse a # zero-element scan or be registered, with a reason, as something that is From 4cfeb0941b557a2a1e3a23403c996850b288a79d Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 16 Aug 2026 23:01:16 -0500 Subject: [PATCH 4/5] fix(ci): host the declared-exports guard in the job that builds, not the one that does not MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The guard reads each package's PACKLIST, so a workspace whose dist has not been emitted reports every declared target as missing. It was placed in `governance-guards`, which builds five workspaces; the guard scans nine. Adding the missing four there worked, and cost more than it was worth. It now lives in `test`, which already builds eight of the nine — `repo-facts` is added, and that is the whole delta. `Test` is a REQUIRED check, so a phantom export blocks the MERGE rather than the release, which is strictly earlier than where this row asked the assertion to sit. WHAT BUILDING THE CLI IN THE GOVERNANCE JOB UNCOVERED, recorded rather than re-buried. `41-validate-evidence-commands --strict` counts a recorded validationCommand whose binary is absent as DEAD and tolerates it under `--max-dead 17`. With the CLI unbuilt there, four commands had NEVER executed: [GT-665] evolith rulesets --core "$PWD" [GT-665] evolith validate --core "$PWD" --select …/slsa-v1.rules.json --format json [GT-677] evolith evaluate --workspace $W --format drift --evidence $W/ev.json [GT-688] evolith evaluate -t modular-monolith --format json Built, all four exited non-zero in CI. Locally on a full tree, 1 and 2 exit 0 and 4 exits 2 -- `evaluate` exits 2 by design on blocking findings, so THAT one looks like a defect in the record. The divergence on 1 and 2 is unexplained and is not being guessed at here. Evidence recorded against a command that never once ran is indistinguishable from evidence that passed, and `--max-dead` exists for generated state, which is exactly what hides it. Its own row, not a rider on this one. Verified under the runner's condition rather than on a warm tree: every dist parked, only the `test` job's builds replayed, guard green -- 68 declared targets across 9 publishable packages. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .github/workflows/ci-cd.yml | 66 +++++++++++++++++-------------------- 1 file changed, 30 insertions(+), 36 deletions(-) diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 7f103a89..d9635a09 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -51,6 +51,36 @@ jobs: npm run build --workspace @beyondnet/evolith-infra-providers npm run build --workspace @beyondnet/evolith-mcp npm run build --workspace src/sdk/cli + # GT-706: the declared-exports guard below reads each package's PACKLIST, + # and it covers `repo-facts` too. Every other workspace it scans is + # already built above. + npm run build --workspace @beyondnet/evolith-repo-facts + + # GT-706 — a package must prove its own manifest before it is published. + # + # `contracts@1.1.0` declared an export subpath it did not ship. That failed + # at `infra-providers@1.2.1`'s install smoke, AFTER `core-domain@1.3.1` was + # already irreversibly on the registry, leaving the release half-shipped with + # no unpublish available past 72 hours. The release's own assertion could not + # see it: it computed "promised" as `[pkg.main, ...bin]`, and `exports` is not + # in that list. Measured — a package declaring `./ingest` with only + # `dist/index.js` on disk passes it with exit 0 while `require pkg/ingest` + # answers MODULE_NOT_FOUND. + # + # IT LIVES IN THIS JOB BECAUSE THIS JOB BUILDS. A packlist without a dist + # reports every declared target as missing, so the guard needs the same nine + # workspaces it scans to be emitted. It was tried in `governance-guards` + # first, which builds five, and it went red on the four that were not — cli, + # mcp, sdk and core. Building those four there also turned four DEAD evidence + # references into four live non-zero commands, which is a real finding and a + # different gap; it is not silently re-buried, it is recorded in the PR. + # + # `Test` being a REQUIRED check is the other half: a phantom export now + # blocks the merge rather than the release. + - name: A package's declared exports are present in its own tarball + run: | + node .harness/scripts/ci/67-validate-declared-exports.mjs --verbose + node --test .harness/scripts/ci/67-validate-declared-exports.test.mjs # CD gate = the evolith-cli unit suite (fast, deterministic). The full e2e # (env-sensitive: spawns servers, loads rulesets) is covered by the @@ -529,21 +559,6 @@ jobs: # one, so the extractor has to be built or the guard has nothing to run. It # fails loudly in that case instead of reporting "no cycles found". npm run build --workspace @beyondnet/evolith-repo-facts - # GT-706: the declared-exports guard reads each package's PACKLIST, and a - # package whose dist has not been emitted packs no dist — so an unbuilt - # workspace reports every declared target as missing. This list was five - # workspaces and the guard covers nine; the four added here are exactly the - # ones whose absence turned it red on its first CI run, MEASURED by parking - # every dist locally and replaying only the five builds above: - # core -> dist/index.js, sdk -> dist/index.js, mcp -> dist/main.js, - # cli -> dist/index.js - # Building the subset the guard scans is the alternative that was rejected: - # scanning only what happens to be built is the same "narrower question than - # the manifest asks" that let contracts@1.1.0 through in the first place. - npm run build --workspace @beyondnet/evolith-core - npm run build --workspace @beyondnet/evolith-sdk - npm run build --workspace @beyondnet/evolith-mcp - npm run build --workspace src/sdk/cli # STRICT. Every script under .harness/scripts/ci must either refuse a # zero-element scan or be registered, with a reason, as something that is @@ -708,27 +723,6 @@ jobs: node .harness/scripts/ci/65-validate-standards-rule-class.mjs --verbose node --test .harness/scripts/ci/65-validate-standards-rule-class.test.mjs - # GT-706 — a package must prove its own manifest before it is published. - # - # `contracts@1.1.0` declared an export subpath it did not ship. That failed - # at `infra-providers@1.2.1`'s install smoke, AFTER `core-domain@1.3.1` was - # already irreversibly on the registry, leaving the release half-shipped - # with no unpublish available past 72 hours. - # - # The release's own assertion could not see it: it computes "promised" as - # `[pkg.main, ...bin]`, and `exports` is not in that list. Measured — a - # package declaring `./ingest` with only `dist/index.js` on disk passes it - # with exit 0 while `require pkg/ingest` answers MODULE_NOT_FOUND. - # - # Running it HERE, at PR time, is the point: the release also runs it (see - # npm-release.yml), but by then the producer's turn is the last moment - # before something irreversible. A red PR costs a commit; a red release - # costs a version nobody can withdraw. - - name: A package's declared exports are present in its own tarball - run: | - node .harness/scripts/ci/67-validate-declared-exports.mjs --verbose - node --test .harness/scripts/ci/67-validate-declared-exports.test.mjs - # GT-650 / ADR-0125 — the artifact registry is the accepted single declaration, and the gate # corpus is still hand-maintained while the migration lands. For as long as both exist, the # only thing making that intermediate state safe is that they are checked to agree. This From 57f40c1e9266f2c9ef037efb490e3b9ad670e188 Mon Sep 17 00:00:00 2001 From: aarroyo Date: Sun, 16 Aug 2026 23:42:26 -0500 Subject: [PATCH 5/5] fix(harness): let a guard declare that it needs a built workspace, instead of failing where nothing is built GT-706's own closure record named its guard as a validationCommand, and `41-validate-evidence-commands --execute --strict` ran it in `governance-guards`, which builds five of the nine workspaces it scans. It exited non-zero on the absence of a build, not on anything it was written to check. That is precisely the shape GT-675 already fixed for a remote-tracking ref: the command was fine and the ENVIRONMENT was the wrong one, so the answer was to classify rather than to skip. Same here, with the same consequence -- the command stays in the census and is reported, it is simply not executed in a job that cannot answer it. THE DECLARATION LIVES IN THE GUARD. `export const REQUIRES_BUILT_WORKSPACE = true` in 67, read from SOURCE by 41 -- not imported, because importing every referenced script to ask one question would execute their module bodies, and a classifier that runs the thing it is classifying is not a classifier. A hardcoded roster of build-dependent scripts in 41 was the alternative: one more list that silently stops covering the next one. Two fixtures, because a classification that cannot be observed NOT to apply is a silent skip: a script declaring the marker is classified `heavy-toolchain`, and a script that merely MENTIONS it in a comment is still executed. The marker is anchored to a top-level export for that reason. Verified: 41's 48 self-tests pass, the classifier answers `heavy-toolchain` for 67 and `executable` for 63 as a control, 42 still classifies all 83 guards, and 67 itself is green over 68 targets in 9 packages. Co-Authored-By: Claude Opus 5 Signed-off-by: aarroyo --- .../ci/41-validate-evidence-commands.mjs | 39 ++++++++++++++++++ .../ci/41-validate-evidence-commands.test.mjs | 27 ++++++++++++ .../ci/67-validate-declared-exports.mjs | Bin 12451 -> 13051 bytes 3 files changed, 66 insertions(+) diff --git a/.harness/scripts/ci/41-validate-evidence-commands.mjs b/.harness/scripts/ci/41-validate-evidence-commands.mjs index 91adb276..f1868d1e 100644 --- a/.harness/scripts/ci/41-validate-evidence-commands.mjs +++ b/.harness/scripts/ci/41-validate-evidence-commands.mjs @@ -712,10 +712,49 @@ export function classifyExecutability(cmd, root = ROOT) { if (writer) { return { executable: false, bucket: 'writes-to-tree', reason: `source calls ${writer} — executing it would edit the working tree` }; } + // GT-706 — a guard that reads emitted `dist/` cannot run where nothing is built. + // + // `67-validate-declared-exports.mjs` reads each package's PACKLIST, so an + // unbuilt workspace reports every declared target as missing. This job builds + // five of the nine it scans, so executing it here fails on the absence of a + // build and not on anything it was written to check — the same shape as + // GT-675's remote-ref case, where the command was fine and the ENVIRONMENT + // was the wrong one. + // + // The declaration lives in the guard, not in a list here. A hardcoded roster + // of build-dependent scripts is one more thing that silently stops covering a + // new one; a marker the script itself carries moves with it. Classifying, not + // skipping: the command stays in the census and is reported, it is simply not + // executed in a job that cannot answer it. + if (declaresBuiltWorkspace(join(root, cmd.cwd || '.', script))) { + return { + executable: false, + bucket: 'heavy-toolchain', + reason: 'declares REQUIRES_BUILT_WORKSPACE — reads emitted dist/, which this job does not produce', + }; + } } return { executable: true }; } +/** + * Does a script declare that it needs an emitted `dist/`? + * + * Read from source rather than imported: importing every referenced script to ask + * one question would execute their module bodies, and a classifier that runs the + * thing it is classifying is not a classifier. + * + * @param {string} abs absolute path to the script + * @returns {boolean} + */ +function declaresBuiltWorkspace(abs) { + try { + return /^export const REQUIRES_BUILT_WORKSPACE = true;/m.test(readFileSync(abs, 'utf8')); + } catch { + return false; + } +} + /** First filesystem-write API found in a script's source, or null. */ function writeApiUsed(abs) { let src; diff --git a/.harness/scripts/ci/41-validate-evidence-commands.test.mjs b/.harness/scripts/ci/41-validate-evidence-commands.test.mjs index a6816ec5..e072f6f2 100644 --- a/.harness/scripts/ci/41-validate-evidence-commands.test.mjs +++ b/.harness/scripts/ci/41-validate-evidence-commands.test.mjs @@ -311,6 +311,33 @@ describe('classifyExecutability', () => { assert.equal(c.bucket, 'writes-to-tree'); }); + // GT-706 — a guard that reads emitted `dist/` fails on the absence of a build + // rather than on what it was written to check. The declaration lives in the + // guard, so the pair of tests below is the whole contract: a script that + // declares it is classified, and one that does not is still executed. A + // classification that could not be observed to NOT apply is a silent skip. + test('a script declaring REQUIRES_BUILT_WORKSPACE is classified, not executed', () => { + writeFileSync( + join(fixtureRoot, 'scripts', 'needs-build.mjs'), + 'export const REQUIRES_BUILT_WORKSPACE = true;\nconsole.log("ok");\n', + ); + const c = classifyExecutability(extractCommand('node scripts/needs-build.mjs'), fixtureRoot); + assert.equal(c.executable, false); + assert.equal(c.bucket, 'heavy-toolchain'); + assert.match(c.reason, /REQUIRES_BUILT_WORKSPACE/); + }); + + test('a script that merely MENTIONS the marker is still executed', () => { + // The marker is a declaration, not a keyword: matching it in a comment or a + // string would let any script opt out of execution by talking about it. + writeFileSync( + join(fixtureRoot, 'scripts', 'mentions-marker.mjs'), + '// see REQUIRES_BUILT_WORKSPACE in 67-validate-declared-exports.mjs\nconsole.log("ok");\n', + ); + const c = classifyExecutability(extractCommand('node scripts/mentions-marker.mjs'), fixtureRoot); + assert.equal(c.executable, true, 'a mention must not opt a script out of execution'); + }); + test('`gh api ...` is excluded as needing credentials, not silently dropped', () => { const c = classifyExecutability(extractCommand('gh api repos/o/r/branches/main/protection'), fixtureRoot); assert.equal(c.bucket, 'network-or-credentials'); diff --git a/.harness/scripts/ci/67-validate-declared-exports.mjs b/.harness/scripts/ci/67-validate-declared-exports.mjs index 97ba9e87f7f563f8d67ee43e59494e5b2b28022a..c91046955b7ef5487c47b2e113ce3a0b7d4c6beb 100644 GIT binary patch delta 563 zcmY+Bv2GJV5QZD76nO=H(I8oN5E67rLC{H9i3He=DC6&s$5m*l;SKTv zD38Qzq{-~zhI(he`TqGI=!yTlIK3Z@PB6m4OUAmRR3kCf8BES&M{YxFex323lIpj-Y@ptU75G`x2CXs&1H}e}LN^t%hh(p|F@^%Aa8O?m_I+=}eG*kRK!o$`HfJ_e=9cf?PI_vk zCglnDQWx~Z#qE|totMXuhO+(tL`lU&niRJdu}daWm@S!ca{v`+Vi!|wiH@8SY3Z5z z!$f0johJ!PNXTj@EIIg&U8hG~36|@7T$MOla6`r>Bg7%nnU%o*$+XgQ#;0mljjMCNiu-BQ5{>`SSgF8OSclPRp6zobX+#%+i=K iX4Ti5>8zUHUfxWv7q{O&&#vaydW8#U>iF^W;ng#lIm1Q( delta 16 XcmeyJx;SwI3*Y1-Ils-T1Ok-+KK2H9