diff --git a/.github/workflows/ci-cd.yml b/.github/workflows/ci-cd.yml index 4afbc715..0f96ad61 100644 --- a/.github/workflows/ci-cd.yml +++ b/.github/workflows/ci-cd.yml @@ -456,11 +456,22 @@ jobs: git fetch --no-tags --depth=1 origin "${{ github.base_ref || github.event.repository.default_branch }}" node .harness/scripts/ci/49-validate-gap-id-allocation.mjs --verbose + # GT-639: a board row carries a STATUS but not who is working it or where, so + # two sessions can both read `PENDING` and both start. On 2026-07-30 the same + # work was done twice, three times over. The claim set is DERIVED from open + # pull requests — never hand-written, which would go stale in the direction + # that matters — and an id claimed by two open PRs fails here, naming both. + - name: One gap, one claim + env: + GH_TOKEN: ${{ github.token }} + run: node .harness/scripts/ci/50-validate-gap-claim.mjs + - name: Self-tests for the governance guards run: | node --test .harness/scripts/ci/42-validate-guard-denominators.test.mjs node --test .harness/scripts/ci/48-validate-security-publish-lag.test.mjs node --test .harness/scripts/ci/49-validate-gap-id-allocation.test.mjs + node --test .harness/scripts/ci/50-validate-gap-claim.test.mjs node --test .harness/scripts/ci/41-validate-evidence-commands.test.mjs node --test .harness/scripts/ci/43-validate-guard-negative-fixtures.test.mjs node --test .harness/scripts/ci/44-validate-adr-implementation-status.test.mjs diff --git a/.harness/scripts/ci/47-validate-joined-paths.mjs b/.harness/scripts/ci/47-validate-joined-paths.mjs index 2be445c0..f6a35b7d 100644 --- a/.harness/scripts/ci/47-validate-joined-paths.mjs +++ b/.harness/scripts/ci/47-validate-joined-paths.mjs @@ -120,12 +120,18 @@ const GENERATED = new Set(['dist', 'evidence', 'coverage', 'node_modules', 'poli */ const GENERATED_PREFIXES = [ { - prefix: 'src/sdk/cli/rulesets/opa', - producedBy: '.harness/scripts/compile-opa-wasm.mjs', + prefix: 'src/sdk/cli/rulesets', + producedBy: '.harness/scripts/compile-opa-wasm.mjs + src/sdk/cli/scripts/copy-assets.js', reason: - 'The compile step mkdirSyncs this directory and copies policy.wasm into it. Nothing ' + - 'under it is tracked, so on a clean checkout the directory itself does not exist — ' + - 'which is why this only ever failed on the runner.', + 'The compile step mkdirSyncs `rulesets/opa` and copies policy.wasm into it. The WHOLE ' + + 'subtree is produced, not just that leaf: `src/sdk/cli/.gitignore` ignores `rulesets/*`, ' + + 'so on a clean checkout the directory itself does not exist — which is why this only ' + + 'ever failed on the runner. GT-643 — the prefix used to be `src/sdk/cli/rulesets/opa`, ' + + 'anchored on `src/sdk/cli/rulesets`, and that anchor held ONLY because three files a ' + + 'test had written were committed under `rulesets/agents/`. Deleting that residue removed ' + + 'the directory from the checkout and this guard went red, naming policy.wasm for a cause ' + + 'that was somewhere else entirely. An anchor that depends on committed test output is ' + + 'not an anchor; it now rests on `src/sdk/cli`, which is authored.', }, ]; diff --git a/.harness/scripts/ci/50-validate-gap-claim.mjs b/.harness/scripts/ci/50-validate-gap-claim.mjs new file mode 100644 index 00000000..744eced8 --- /dev/null +++ b/.harness/scripts/ci/50-validate-gap-claim.mjs @@ -0,0 +1,199 @@ +#!/usr/bin/env node + +/** + * GT-639 — two sessions must not work the same gap without knowing. + * + * ## The defect + * + * On 2026-07-30 the same work was done twice, three separate times, by sessions + * that could not see each other: GT-640 (then GT-633) was fixed by a standalone + * capture script on `main` and by an independent in-spec renderer on `develop`; + * the evidence guard's parser was fixed on both branches; and a GT id was + * allocated twice. The cost was not the merge conflict — it was the duplicated + * work before anyone reached it, plus a reconciliation that introduced a defect + * of its own. + * + * A board row carries a STATUS but not who is working it or where. Two sessions + * can both read `GT-640 · PENDING` and both start, correctly. + * + * ## What it checks + * + * The claim set is DERIVED from open pull requests — never hand-written, because + * a hand-written claim is stale in the direction that matters (someone forgets to + * remove it, and the next session works around a claim nobody holds). Every open + * PR claims the `GT-*` ids that appear in its title, its body or its branch name. + * + * An id claimed by MORE THAN ONE open pull request is a contested claim, and this + * guard fails naming both claimants. + * + * ## What it deliberately does NOT do + * + * It does not write a committed claim list. Such a file would be derived from + * live GitHub state, so it would be stale the moment anyone opened a PR, and the + * repository already has a guard (46) whose whole premise is that derived + * artifacts must reach a fixed point. A perpetually-stale artifact in that chain + * would be worse than none. The live view is this check's output, on the PR where + * it matters. + * + * It also cannot see work that has no open PR. A branch someone is working on + * privately claims nothing, and the guard says so rather than implying coverage. + * + * ## Anti-vacuous pass + * + * Zero open pull requests examined is reported, not silently passed: with no PRs + * there is nothing to contest, and a run that found none because the query broke + * looks identical unless it says which happened. A query that fails outright is a + * hard failure — unable to answer is not the same as nothing to report. + * + * USAGE + * node .harness/scripts/ci/50-validate-gap-claim.mjs + * node .harness/scripts/ci/50-validate-gap-claim.mjs --json + * node .harness/scripts/ci/50-validate-gap-claim.mjs --fixture # offline + * + * EXIT CODES + * 0 no id is claimed by more than one open pull request + * 1 a contested claim, or the pull-request query could not be answered + */ + +import fs from 'node:fs'; +import path from 'node:path'; +import { execFileSync } from 'node:child_process'; +import { fileURLToPath } from 'node:url'; + +const GUARD = '50-validate-gap-claim'; + +// --------------------------------------------------------------------------- +// Pure core — takes pull requests as data, returns the claim map. No network. +// --------------------------------------------------------------------------- + +/** Every `GT-NNN` mentioned in a pull request's title, body or branch name. */ +export function claimedIds(pr) { + const haystack = `${pr.title ?? ''}\n${pr.body ?? ''}\n${pr.headRefName ?? ''}`; + return [...new Set(haystack.match(/GT-\d+/g) ?? [])].sort(); +} + +/** + * id -> the open pull requests claiming it. + * + * @param {Array<{number:number,title?:string,body?:string,headRefName?:string,url?:string}>} prs + * @returns {Map>} + */ +export function buildClaimMap(prs) { + const map = new Map(); + for (const pr of prs) { + for (const id of claimedIds(pr)) { + if (!map.has(id)) map.set(id, []); + map.get(id).push(pr); + } + } + return map; +} + +/** Ids claimed by more than one open pull request. */ +export function findContested(claimMap) { + return [...claimMap.entries()] + .filter(([, prs]) => prs.length > 1) + .map(([id, prs]) => ({ id, prs })) + .sort((a, b) => a.id.localeCompare(b.id)); +} + +// --------------------------------------------------------------------------- +// I/O edges +// --------------------------------------------------------------------------- + +function fail(lines) { + console.error(`\n✗ ${GUARD}: ${lines[0]}`); + for (const l of lines.slice(1)) console.error(` ${l}`); + process.exit(1); +} + +/** Open pull requests, from `gh`. Returns null when the query cannot be answered. */ +export function fetchOpenPullRequests() { + try { + const raw = execFileSync( + 'gh', + ['pr', 'list', '--state', 'open', '--limit', '200', '--json', 'number,title,body,headRefName,url'], + { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, stdio: ['ignore', 'pipe', 'ignore'] }, + ); + const parsed = JSON.parse(raw); + return Array.isArray(parsed) ? parsed : null; + } catch { + return null; + } +} + +function main(argv) { + const asJson = argv.includes('--json'); + const fixtureIdx = argv.indexOf('--fixture'); + + const prs = fixtureIdx !== -1 + ? JSON.parse(fs.readFileSync(path.resolve(process.cwd(), argv[fixtureIdx + 1]), 'utf8')) + : fetchOpenPullRequests(); + + if (prs === null) { + fail([ + 'could not read the open pull requests, so no claim was checked.', + ' `gh pr list` failed — in CI that usually means GH_TOKEN is not set on the step.', + '', + ' Unable to answer is not the same as nothing to report. This guard exists', + ' because two sessions could not see each other; a version of it that stays', + ' quiet when it cannot look would reproduce exactly that.', + ]); + } + + const claimMap = buildClaimMap(prs); + const contested = findContested(claimMap); + + if (asJson) { + process.stdout.write(JSON.stringify({ + pullRequests: prs.length, + claims: Object.fromEntries([...claimMap].map(([id, list]) => [id, list.map((p) => p.number)])), + contested: contested.map((c) => ({ id: c.id, prs: c.prs.map((p) => p.number) })), + }) + '\n'); + return contested.length > 0 ? 1 : 0; + } + + console.log(`${GUARD} — one gap, one claim`); + console.log(` open pull requests .. ${prs.length}`); + console.log(` ids claimed ......... ${claimMap.size}`); + console.log(` contested ........... ${contested.length}`); + + if (prs.length === 0) { + // Said out loud: with no open PRs there is nothing to contest, which is a + // legitimate state and looks identical to a broken query unless named. + console.log('\n No pull request is open, so nothing is claimed and nothing can be contested.'); + } + + for (const [id, list] of [...claimMap].sort()) { + if (list.length === 1) console.log(` · ${id} — #${list[0].number} ${list[0].headRefName ?? ''}`); + } + + if (contested.length > 0) { + fail([ + `${contested.length} gap id(s) are claimed by more than one open pull request:`, + ...contested.flatMap((c) => [ + ` • ${c.id}`, + ...c.prs.map((p) => ` #${p.number} ${p.headRefName ?? '?'} ${p.title ?? ''}`), + ]), + '', + ' Two sessions are working the same gap. That is not a merge conflict yet, and', + ' it is much cheaper to resolve now than after both have landed: on 2026-07-30', + ' the same fix was written twice and a third session spent a full reconciliation', + ' collapsing them (GT-639).', + '', + ' Decide which pull request owns the id, and say so in the other one.', + '', + ' NOT COVERED by this check: a branch with no open pull request claims nothing.', + ' Opening the PR early — draft is enough — is what makes the claim visible.', + ]); + } + + console.log( + `\n✓ ${GUARD}: ${claimMap.size} claimed id(s) across ${prs.length} open pull request(s), none contested.`, + ); + return 0; +} + +const invokedDirectly = + process.argv[1] && path.resolve(process.argv[1]) === path.resolve(fileURLToPath(import.meta.url)); +if (invokedDirectly) process.exit(main(process.argv.slice(2))); diff --git a/.harness/scripts/ci/50-validate-gap-claim.test.mjs b/.harness/scripts/ci/50-validate-gap-claim.test.mjs new file mode 100644 index 00000000..afa9c020 --- /dev/null +++ b/.harness/scripts/ci/50-validate-gap-claim.test.mjs @@ -0,0 +1,136 @@ +#!/usr/bin/env node + +/** + * GT-639 — fixtures for the gap-claim guard. + * + * The case that matters is the one this repository lived through: two open pull + * requests working the same gap, neither aware of the other. If that fixture ever + * goes green the guard is decoration — which is why GT-639's last criterion asks + * for a fixture OBSERVED red rather than a guard someone believes works. + * + * The pull requests are supplied as data through `--fixture`, so nothing here + * touches the network: the guard's core is pure by construction and the I/O edge + * is one function. + */ + +import { describe, it, before, after } from 'node:test'; +import assert from 'node:assert/strict'; +import { mkdtempSync, writeFileSync, rmSync } from 'node:fs'; +import { tmpdir } from 'node:os'; +import { join, dirname, resolve } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { spawnSync } from 'node:child_process'; + +import { claimedIds, buildClaimMap, findContested } from './50-validate-gap-claim.mjs'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const GUARD = resolve(__dirname, '50-validate-gap-claim.mjs'); + +let sandbox; +before(() => { sandbox = mkdtempSync(join(tmpdir(), 'gt639-')); }); +after(() => { if (sandbox) rmSync(sandbox, { recursive: true, force: true }); }); + +const runWith = (prs, extra = []) => { + const file = join(sandbox, `prs-${Math.abs(JSON.stringify(prs).length)}-${prs.length}.json`); + writeFileSync(file, JSON.stringify(prs)); + const r = spawnSync(process.execPath, [GUARD, '--fixture', file, ...extra], { + encoding: 'utf8', timeout: 60000, + }); + return { status: r.status, out: `${r.stdout}\n${r.stderr}` }; +}; + +describe('claimedIds', () => { + it('reads a claim from the title, the body or the branch name', () => { + assert.deepEqual(claimedIds({ title: 'fix(x): close GT-100' }), ['GT-100']); + assert.deepEqual(claimedIds({ body: 'refs GT-101 and GT-102' }), ['GT-101', 'GT-102']); + assert.deepEqual(claimedIds({ headRefName: 'feat/gt-103-thing' }), []); // lowercase is not an id + assert.deepEqual(claimedIds({ headRefName: 'docs/GT-104-rows' }), ['GT-104']); + }); + + it('does not claim the same id twice from one pull request', () => { + assert.deepEqual(claimedIds({ title: 'GT-105', body: 'GT-105 again', headRefName: 'x/GT-105' }), ['GT-105']); + }); + + it('claims nothing when a pull request names no gap', () => { + assert.deepEqual(claimedIds({ title: 'chore: tidy', body: '', headRefName: 'chore/tidy' }), []); + }); +}); + +describe('findContested', () => { + it('THE CASE: one id, two open pull requests', () => { + const contested = findContested(buildClaimMap([ + { number: 1, title: 'fix: GT-200 via a capture script' }, + { number: 2, title: 'fix: GT-200 via an in-spec renderer' }, + { number: 3, title: 'docs: GT-201' }, + ])); + assert.equal(contested.length, 1); + assert.equal(contested[0].id, 'GT-200'); + assert.deepEqual(contested[0].prs.map((p) => p.number), [1, 2]); + }); + + it('one pull request claiming many ids is not contested', () => { + assert.deepEqual(findContested(buildClaimMap([{ number: 1, title: 'GT-300 GT-301 GT-302' }])), []); + }); +}); + +describe('the guard end to end', () => { + it('THE FIXTURE: two open PRs on one gap — RED, naming both', () => { + const { status, out } = runWith([ + { number: 276, title: 'fix(standards): capture script', headRefName: 'claude/fervent', body: 'closes GT-640' }, + { number: 279, title: 'fix(standards): in-spec renderer for GT-640', headRefName: 'claude/nice-cohen', body: '' }, + ]); + assert.equal(status, 1, out); + assert.match(out, /1 gap id\(s\) are claimed by more than one open pull request/); + assert.match(out, /#276/); + assert.match(out, /#279/); + // It must say why this is worth stopping for, and what to do. + assert.match(out, /much cheaper to resolve now than after both have landed/); + assert.match(out, /Decide which pull request owns the id/); + // And it must not imply coverage it does not have. + assert.match(out, /a branch with no open pull request claims nothing/); + }); + + it('distinct gaps across many PRs are green, and each claim is listed', () => { + const { status, out } = runWith([ + { number: 10, title: 'GT-400', headRefName: 'a' }, + { number: 11, title: 'GT-401', headRefName: 'b' }, + ]); + assert.equal(status, 0, out); + assert.match(out, /ids claimed \.+ 2/); + assert.match(out, /GT-400 — #10/); + }); + + it('says out loud when there is nothing to check', () => { + // Zero open PRs is a legitimate state that looks identical to a broken query + // unless it is named. + const { status, out } = runWith([]); + assert.equal(status, 0, out); + assert.match(out, /No pull request is open, so nothing is claimed/); + }); + + it('--json reports the claim map and exits non-zero when contested', () => { + const { status, out } = runWith( + [{ number: 1, title: 'GT-500' }, { number: 2, title: 'GT-500' }], + ['--json'], + ); + assert.equal(status, 1, out); + const payload = JSON.parse(out.trim().split('\n')[0]); + assert.deepEqual(payload.contested, [{ id: 'GT-500', prs: [1, 2] }]); + }); +}); + +describe('anti-vacuous floor', () => { + it('refuses to pass when the pull-request query cannot be answered', () => { + // No --fixture, and `gh` unusable: the guard must fail rather than report a + // clean run over a list it never got. + const r = spawnSync(process.execPath, [GUARD], { + encoding: 'utf8', + timeout: 60000, + env: { ...process.env, PATH: '/nonexistent' }, + }); + assert.equal(r.status, 1, r.stdout + r.stderr); + const out = `${r.stdout}\n${r.stderr}`; + assert.match(out, /could not read the open pull requests/); + assert.match(out, /Unable to answer is not the same as nothing to report/); + }); +}); diff --git a/.harness/scripts/lib/guard-classification.mjs b/.harness/scripts/lib/guard-classification.mjs index 8b69ba19..18aa8171 100644 --- a/.harness/scripts/lib/guard-classification.mjs +++ b/.harness/scripts/lib/guard-classification.mjs @@ -40,6 +40,15 @@ export const CALLS_COVERAGE = /\b(?:assertScanned|assertScannedPerSource|scanned * deleting the check and leaving the exemption behind. */ export const SELF_GUARDED = [ + { + file: '50-validate-gap-claim.mjs', + proof: /could not read the open pull requests/, + reason: + 'GT-639 gap-claim guard; refuses to pass when the pull-request query cannot be answered — ' + + 'the guard exists because two sessions could not see each other, so one that stays quiet ' + + 'when it cannot look would reproduce the defect. Zero open PRs is reported in words rather ' + + 'than passed silently, because it looks identical to a broken query otherwise', + }, { file: '49-validate-gap-id-allocation.mjs', proof: /at least one side yielded NOTHING/, diff --git a/.husky/pre-commit b/.husky/pre-commit index eb8181ba..8839e7b6 100644 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -29,6 +29,19 @@ MODE="" TARGET="" MERGE_METHOD="" +# Handoff to pre-push. `.git` is a DIRECTORY only in the primary checkout; in a +# `git worktree` it is a FILE holding a `gitdir:` pointer, so writing to +# `.git/EVOLITH_*` fails with ENOTDIR and `set -e` aborts the commit. +# `--git-dir` resolves both layouts. Keep this identical to .husky/pre-push, +# which reads these files back. +GIT_DIR_PATH="$(git rev-parse --git-dir)" + +write_push_intent() { + echo "$MODE" > "$GIT_DIR_PATH/EVOLITH_CI_MODE" + echo "$TARGET" > "$GIT_DIR_PATH/EVOLITH_PUSH_TARGET" + echo "$MERGE_METHOD" > "$GIT_DIR_PATH/EVOLITH_MERGE_METHOD" +} + resolve_from_env() { MODE="${EVOLITH_CI_MODE:-skip}" TARGET="${EVOLITH_PUSH_TARGET:-develop}" @@ -43,9 +56,7 @@ fi if [ "$TTY_AVAILABLE" = false ]; then if [ "$IS_OWNER" = true ]; then resolve_from_env - echo "$MODE" > .git/EVOLITH_CI_MODE - echo "$TARGET" > .git/EVOLITH_PUSH_TARGET - echo "$MERGE_METHOD" > .git/EVOLITH_MERGE_METHOD + write_push_intent echo "No TTY available — owner mode: mode=$MODE." # Antes se salia con exit 0 SIEMPRE, asi que ningun commit sin TTY (agente, # script, CI) llegaba nunca al ci-runner de la linea final. Ahora, si el @@ -60,9 +71,7 @@ if [ "$TTY_AVAILABLE" = false ]; then if [ -n "$EVOLITH_CI_MODE" ] || [ -n "$EVOLITH_PUSH_TARGET" ] || [ -n "$EVOLITH_MERGE_METHOD" ]; then resolve_from_env - echo "$MODE" > .git/EVOLITH_CI_MODE - echo "$TARGET" > .git/EVOLITH_PUSH_TARGET - echo "$MERGE_METHOD" > .git/EVOLITH_MERGE_METHOD + write_push_intent echo "Non-TTY: using EVOLITH_CI_MODE, EVOLITH_PUSH_TARGET, EVOLITH_MERGE_METHOD." exit 0 fi @@ -131,9 +140,7 @@ if [ "$TARGET" = "main" ] || [ "$TARGET" = "sync" ]; then esac fi -echo "$MODE" > .git/EVOLITH_CI_MODE -echo "$TARGET" > .git/EVOLITH_PUSH_TARGET -echo "$MERGE_METHOD" > .git/EVOLITH_MERGE_METHOD +write_push_intent if [ "$MODE" = "skip" ]; then echo "" diff --git a/.husky/pre-push b/.husky/pre-push index 428264a6..51a15b17 100755 --- a/.husky/pre-push +++ b/.husky/pre-push @@ -1,21 +1,69 @@ set -e +# --------------------------------------------------------------------------- +# Where pre-commit left the push intent. +# +# This used to be a hard-coded `.git/`, which only exists as a DIRECTORY in the +# primary checkout. Inside a `git worktree`, `.git` is a FILE holding a +# `gitdir:` pointer, so every `.git/EVOLITH_*` path is ENOTDIR: the reads found +# nothing and the `rm -f` failed outright, which `set -e` turned into "every +# push from a worktree is rejected" — the reason `--no-verify` became habitual. +# +# `git rev-parse --git-dir` resolves both layouts (`.git` here, the per-worktree +# admin dir there), and per-worktree is the right scope: the push intent belongs +# to the checkout that was committed in, not to every checkout in the repo. +# --------------------------------------------------------------------------- +GIT_DIR_PATH="$(git rev-parse --git-dir)" + TARGET="" MERGE_METHOD="merge" -if [ -f .git/EVOLITH_PUSH_TARGET ]; then - TARGET=$(cat .git/EVOLITH_PUSH_TARGET) +if [ -f "$GIT_DIR_PATH/EVOLITH_PUSH_TARGET" ]; then + TARGET=$(cat "$GIT_DIR_PATH/EVOLITH_PUSH_TARGET") fi -if [ -f .git/EVOLITH_MERGE_METHOD ]; then - MERGE_METHOD=$(cat .git/EVOLITH_MERGE_METHOD) +if [ -f "$GIT_DIR_PATH/EVOLITH_MERGE_METHOD" ]; then + MERGE_METHOD=$(cat "$GIT_DIR_PATH/EVOLITH_MERGE_METHOD") fi -rm -f .git/EVOLITH_PUSH_TARGET .git/EVOLITH_CI_MODE .git/EVOLITH_MERGE_METHOD +rm -f "$GIT_DIR_PATH/EVOLITH_PUSH_TARGET" \ + "$GIT_DIR_PATH/EVOLITH_CI_MODE" \ + "$GIT_DIR_PATH/EVOLITH_MERGE_METHOD" if [ "$TARGET" = "none" ] || [ "$TARGET" = "" ]; then echo "No push requested. Commit only." exit 0 fi +# --------------------------------------------------------------------------- +# A branch another worktree has checked out cannot be checked out here. +# +# The merge dispatch below runs `git checkout "$to"`, which git refuses with +# `fatal: '' is already used by worktree at ...`. Left unchecked that +# fires AFTER repo optimization, board sync and wiki regeneration have already +# run and staged files, so the push dies half-done. Refuse up front instead, +# and name the checkout that holds the branch. +# --------------------------------------------------------------------------- +assert_checkoutable() { + branch=$1 + holder=$(git worktree list --porcelain | awk -v b="branch refs/heads/$branch" ' + /^worktree / { wt = substr($0, 10) } + $0 == b { print wt; exit }') + + if [ -n "$holder" ] && [ "$holder" != "$(git rev-parse --show-toplevel)" ]; then + echo "" + echo "ERROR: cannot merge into '$branch' from this checkout." + echo " '$branch' is checked out in another worktree: $holder" + echo "" + echo " Run the push from that checkout, or choose a target that does not" + echo " check out a branch here (EVOLITH_PUSH_TARGET=develop, or =none)." + exit 1 + fi +} + +if [ "$TARGET" = "main" ] || [ "$TARGET" = "sync" ]; then + assert_checkoutable main + assert_checkoutable develop +fi + echo "" echo "Running repository optimization before push..." node .harness/scripts/ci/02-optimize-repo.mjs diff --git a/reference/core/control-center/evidence/gap-closure-evidence.json b/reference/core/control-center/evidence/gap-closure-evidence.json index 13a4abd7..932950a2 100644 --- a/reference/core/control-center/evidence/gap-closure-evidence.json +++ b/reference/core/control-center/evidence/gap-closure-evidence.json @@ -8875,6 +8875,31 @@ "dependencyDisposition": "accepted-scope", "dependencyRationale": "What this guard cannot see is stated rather than left to be assumed: an id allocated on a branch from which no pull request has been opened. It compares HEAD against ONE base, so two feature branches that both allocate GT-640 stay invisible to each other until the first of them merges. The `--verbose` output says so on every newly allocated id instead of implying coverage it does not have. Closing that remainder needs the open-PR claim set described in GT-639, which is why it is tracked there and not bolted on here." }, + { + "id": "GT-641", + "closedAt": "2026-07-30", + "closureCommit": "fe49e9e4", + "evidence": [ + "src/packages/core-domain/src/application/services/use-case.types.ts", + "src/packages/core-domain/src/application/validators/phase-gate-validator.types.ts", + "src/packages/core-domain/src/application/services/index.ts", + "src/packages/core-domain/src/application/validators/phase-gate-validator.service.ts", + "reference/core/control-center/gaps/gap-reference-catalog.md" + ], + "validationCommands": [ + "THE MEASUREMENT, before and after, over the same tree: 346 modules / 2 cycles -> 348 modules / 0 cycles. Both runs used GT-589 extractor built from feat/gt-589-repo-facts, because that package is NOT on main -- the before-run was taken over a pristine checkout of HEAD rather than over memory of it.", + "THE REPORTED CHAIN WAS THE SMALLER HALF OF THE FINDING. Each cycle was a two-node chain inside a four-module strongly connected component: project-scaffolder.service and phase-transition.use-case in the first, evidence-validator and ruleset-loader in the second. Fixing only the two files named in the chain would have moved the cycle, not removed it -- the component field is what said so.", + "THE FIX IS NOT A TYPE-ONLY IMPORT. Erasing the back-edge with `import type` would satisfy the extractor (it classifies type-only edges as erased-at-runtime) while leaving the layering inverted; the type moves out of the module that also constructs its consumer instead.", + "npm --workspace @beyondnet/evolith-core-domain test -> 129 suites passed / 1 skipped, 1455 tests passed. The quoted 131/1479 baseline is from the GT-589 branch, which adds spec files absent here; suite count is unchanged by this closure because no spec was added or removed.", + "npm run build -> tsc -b clean across the workspace, so the barrel re-exports resolve for every consumer, not just core-domain.", + "npm --workspace @beyondnet/evolith-cli run test:unit -> 101/101 suites, 1446 tests. FIRST RUN WAS RED (6 suites) FOR AN ENVIRONMENT REASON, recorded because it is the trap: a fresh worktree has no built dist for @beyondnet/evolith-agent-runtime, so jest cannot resolve it. Green only after the workspace-wide build.", + "npm --workspace @beyondnet/evolith-cli run test:e2e -> 18/18 suites, 132 tests.", + "npm --workspace @beyondnet/evolith-mcp test -> 53/53 suites, 433 passed / 4 skipped.", + "A PRE-EXISTING FAILURE WAS RULED OUT RATHER THAN INHERITED: nest build for mcp-server reports 3 TS2345 duplicate-identity errors on RulesetValidatorService. Reproduced on a reverted tree before attributing it -- it is the worktree dual-resolution problem (some specifiers resolve to the main checkout dist), not this change." + ], + "dependencyDisposition": "accepted-scope", + "dependencyRationale": "No guard is delivered with this closure and the last acceptance criterion is deliberately left open. @beyondnet/evolith-repo-facts lives only on feat/gt-589-repo-facts (PR #309); a check on main cannot run a measurement whose implementation is not on main, and reimplementing cycle detection here would be a second source of truth for the exact question GT-589 exists to answer. The guard belongs with #309. Until it lands, the two cycles are removed but a third would regress unobserved -- stated here rather than left for a reader to discover." + }, { "id": "GT-640", "closedAt": "2026-07-30", 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 74e599a9..8d0aa4bd 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.es.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.es.md @@ -7855,19 +7855,65 @@ Serie histórica de gaps registrada en el antiguo `gap-analysis-core.es.md`, pre - [x] El tipo `security` está declarado en la config de commitlint con un mapeo explícito de salto de versión, o se deja de usar. - [x] Ningún hook de `.husky/` imprime un mensaje de "skipping" y sale con cero — un hook que no puede correr se borra, no se silencia. +#### GT-643 + +**Title:** Un `--dry-run` que escribe: la bandera está declarada en `agents install`, documentada, y nunca se lee + +- **Purpose:** Que la única bandera cuyo contrato entero es "no cambies nada" efectivamente no cambie nada, y que la salida de un test deje de estar commiteada como dato de producto. +- **Evidence:** **`evolith agents install --dry-run` escribe en el directorio de trabajo.** `agents.command.ts:647` declara `-d, --dry-run`, `AgentsCommandOptions.dryRun` la tipa en la línea 29, y `installAgent()` no la consulta nunca: la línea 218 llama a `this.registry.installAgent(process.cwd(), config, rulesetContent)` sin condición, tras lo cual el registro escribe `rulesets/agents//` y reescribe `agents-registry.json`. **Esto es un comando fuera de la convención, no una convención ausente** — `init` cambia el filesystem por un `DryRunFileSystem` (`init.command.ts:132-135`), `upgrade` retorna antes de aplicar (`upgrade.command.ts:92-100`), `adr` la pasa a cada escritor. `agents` es la única declaración del CLI sin lector. **DEMOSTRADO POR REPRODUCCIÓN, no por lectura del código:** con el árbol limpio, `npx jest --config test/jest-e2e.json test/agents.e2e-spec.ts -t "dry-run"` en solitario deja tres archivos VERSIONADOS modificados — `src/sdk/cli/rulesets/agents/agents-registry.json`, `rulesets/agents/test-value/agent.config.json` y `rulesets/agents/test-value/agent.rules.json`. Primero se corrió el spec completo y después el caso único, para asegurar que la atribución era a `--dry-run` y no a un test hermano. **La salida ya está commiteada como dato de producto:** `test-value` es lo que el mock de prompts responde a toda pregunta (`test/mock-prompt.service.ts:11-12`), y `6bb43cfa` versionó ese agente dentro de los propios `rulesets/` del repositorio. Así que el repo hoy publica un agente con forma de fixture que nadie escribió, y todo desarrollador que corra la suite e2e encuentra tres archivos sucios en `git status` — que es como se encontró. **Un simulacro que escribe es peor que no tener simulacro:** es la bandera a la que un usuario recurre justo cuando no está dispuesto a que se le confíe la de verdad, y hoy reporta éxito habiendo hecho lo que prometió no hacer. **Por qué ningún check lo detectó:** el tester de conformidad cross-superficie compara lo que CLI, MCP y REST *responden*, y ningún oráculo pregunta si el disco cambió — una bandera cuyo contrato observable entero es la AUSENCIA de un efecto es invisible para un oráculo que solo lee respuestas. +- **Component:** `CLI` · **Criticality:** P1 · **Complexity:** S +- **Provenance:** Encontrado el 2026-07-30 al cerrar [`GT-641`](./gap-reference-catalog.es.md#gt-641): la suite e2e del CLI dejó tres archivos versionados sucios, y rastrear qué spec lo hacía llevó a la bandera y no al test. Registrado aparte porque el síntoma de higiene de tests es aguas abajo de un defecto de producto — arreglar el directorio de trabajo del spec escondería el defecto y dejaría a `--dry-run` mintiendo a los usuarios. +- **Acceptance criteria:** + - [x] `agents install --dry-run` no realiza ninguna escritura. La rama seca no llama nunca al escritor, en vez de llamarlo a través de un filesystem inerte: una rama que no puede alcanzar al escritor no puede regresar cuando el escritor gana una llamada nueva. Reporta las rutas que habría creado mediante un nuevo `planInstall`, que usa el propio escritor, así que el reporte no puede desviarse del layout. + - [x] La ruta del menú reenvía las opciones del llamador. `evolith agents --dry-run` sin subcomando las descartaba y escribía igual — justo la ruta que toma quien duda lo bastante como para querer un simulacro. + - [x] Los tres artefactos commiteados bajo `src/sdk/cli/rulesets/agents/` quedan eliminados. El registro contenía exactamente un agente, `test-value`, así que el archivo entero era residuo de test y no dato de producto con residuo dentro. + - [x] La suite e2e corre en un directorio temporal y su oráculo afirma que el disco no cambió, con un caso de contraste de que una instalación real SÍ escribe — si no, la afirmación del simulacro también pasaría si install se hubiera roto. Módulo y directorio por test, porque una instancia compartida filtraba las opciones parseadas de commander entre corridas y hacía parecer roto el arreglo. + - [x] Barrido de cada declaración de `--dry-run` del CLI, registrado incluidos los correctos: `init` (cambia a `DryRunFileSystem`), `upgrade`, `adr`, `scaffold`, `docs`, `fixtures` y `generate-domain` condicionan sus escrituras a la bandera. `agents` era la única declaración sin lector. + - [x] Los dos dependientes ocultos que destapó el borrado quedan reparados, no rodeados. `47-validate-joined-paths` anclaba su declaración de subárbol generado en que existiera `src/sdk/cli/rulesets`, y ese directorio existía en un checkout limpio SOLO porque el residuo estaba dentro — así que el guard se puso rojo señalando a `policy.wasm`, una causa que estaba en otro sitio; el ancla ahora descansa en `src/sdk/cli`, que sí está autorado. `rulesets-resolver.bundled.spec.ts` tenía un caso que aún leía el árbol real, cuya simulación de EACCES solo se disparaba porque algún directorio `…/rulesets` era alcanzable; ahora usa el árbol sintético que los casos hermanos ya usaban. Ambos verificados apartando el artefacto de build y repitiendo: 101 suites / 1447 tests en verde con `src/sdk/cli/rulesets` ausente. + - [ ] Existe un oráculo que pregunta si una bandera sin efecto no tuvo efecto, de modo que quede cubierta la CLASE y no esta instancia. No construido: el tester cross-superficie compara respuestas, y un contrato cuyo contenido entero es la ausencia de un efecto necesita que compare también el estado. Hasta entonces, la siguiente bandera así se encuentra a mano, como se encontró esta. + +#### GT-642 + +**Title:** Nada falla ante un ciclo de import en tiempo de ejecución, así que los dos que existían se encontraron a mano y el siguiente también + +- **Purpose:** Convertir la medición de ciclos en un check que corre en cada pull request, sobre todos los paquetes, en vez de algo que alguien recuerda apuntar a un paquete una vez. +- **Evidence:** **Los dos ciclos que eliminó [`GT-641`](./gap-reference-catalog.es.md#gt-641) llegaron sin ser vistos a un paquete de 346 módulos, y la razón es que nada mira.** `lint:boundaries` impone la dirección entre capas, no la ciclicidad — habría dado verde a ambos, porque `services/index.ts` y `use-cases/` son la misma capa. `tsc` compila un ciclo `require` sin diagnóstico; en ejecución resuelve a un módulo parcialmente inicializado, un defecto que aparece como `undefined` en tiempo de import y no como error de compilación. Ningún guard de gobernanza lee el grafo de módulos. **La medición ya está escrita y queda fuera de alcance:** [`GT-589`](./gap-reference-catalog.es.md#gt-589) entrega `@beyondnet/evolith-repo-facts` y `findImportCycles`, y solo existen en `feat/gt-589-repo-facts` (PR #309) — los números de GT-641 hubo que producirlos construyendo el paquete de esa rama contra un árbol de trabajo que no lo contiene. **Esto está BLOQUEADO y no simplemente pendiente, y la distinción es toda la fila:** un check de la rama por defecto no puede correr un extractor ausente de la rama por defecto, y escribir un segundo detector de ciclos al lado del primero sería exactamente el defecto de doble fuente de verdad que GT-589 se construyó para eliminar. **El alcance, dicho para que no se estreche en silencio después:** solo `core-domain` ha sido medido alguna vez. `cli`, `mcp-server`, `core`, `agent-runtime` y `sdk-client` no, y la forma que produjo ambos ciclos de GT-641 — un barril dueño de un tipo que además reexporta al módulo que lo consume — es un idiom de todo el monorepo, no un accidente de `core-domain`. +- **Component:** `Governance` · **Criticality:** P2 · **Complexity:** S +- **Provenance:** Registrado el 2026-07-30 a partir de [`GT-641`](./gap-reference-catalog.es.md#gt-641), de quien este es el último criterio de aceptación. Se lleva como fila propia en vez de dejarlo sin marcar dentro de una cerrada: una fila DONE con un criterio abierto se lee como terminada para quien no abra el catálogo, que es el fallo de reporte que describe [`GT-640`](./gap-reference-catalog.es.md#gt-640) con otro disfraz. +- **Acceptance criteria:** + - [ ] Un guard corre el extractor de GT-589 sobre cada paquete del workspace y falla cuando `findImportCycles` devuelve resultado no vacío, reportando la cadena Y el componente — el componente es lo que hace correcto el arreglo, como demostró GT-641. + - [ ] Los ciclos solo de tipos se reportan aparte de los de ejecución y no rompen el build: confundirlos empujaría a usar `import type` como silenciador, que esconde la estratificación invertida en vez de arreglarla. + - [ ] El guard queda cableado en `Governance guards (GT-578)` y observado en rojo por `43-validate-guard-negative-fixtures`, de modo que se le ha visto fallar antes de confiar en él. + - [ ] La primera corrida sobre todo el monorepo queda registrada con sus conteos por paquete, incluidos los paquetes que resulten limpios — un guard cuya primera corrida no reporta nada es indistinguible de uno que no puede correr. + +#### GT-641 + +**Title:** Dos ciclos de import en tiempo de ejecución dentro de las fuentes del propio Core, invisibles para todo check que corre hoy + +- **Purpose:** Eliminar los ciclos que la base de hechos estructural del propio Core encuentra en el Core, y dejar registrado —en vez de dar por supuesto— que nada en `main` podrá encontrar el siguiente hasta que esa base de hechos llegue allí. +- **Evidence:** **El Core arrastra dos ciclos de import en tiempo de ejecución en su propia capa de aplicación.** Correr el extractor de [`GT-589`](./gap-reference-catalog.es.md#gt-589) sobre `src/packages/core-domain` reporta 346 módulos y 2 ciclos: `application/services/index.ts` ⇄ `application/use-cases/initialize-project.use-case.ts`, y `application/validators/blocking-criteria-validator.ts` ⇄ `application/validators/phase-gate-validator.service.ts`. **Ninguno es solo de tipos**, que es lo que los convierte en defectos y no en ruido: la base de hechos distingue las aristas type-only de las de valor precisamente para no reportar como ciclo un import que se borra al compilar, y estos dos no se borran. **Son el mismo defecto dos veces.** Un módulo publica un tipo Y ADEMÁS construye o reexporta al colaborador que lo consume, así que el colaborador tiene que importar de vuelta a través de él para nombrar sus propios parámetros: `services/index.ts` era dueño de `InitProjectInput` mientras reexportaba `InitializeProjectUseCase`; `phase-gate-validator.service.ts` era dueño de `PhaseGateDefinition` mientras construía `EvidenceValidator`, `BlockingCriteriaValidator` y `RulesetLoader`. **Las cadenas que nombra el reporte son más estrechas que los componentes que también reporta, y leer solo la cadena habría producido un arreglo equivocado.** Los componentes fuertemente conexos son de cuatro módulos cada uno — `project-scaffolder.service` y `phase-transition.use-case` entran en el primero, `evidence-validator` y `ruleset-loader` en el segundo — así que repuntar solo los dos archivos nombrados en cada cadena habría reubicado el ciclo en lugar de romperlo. **ARREGLADO 2026-07-30** extrayendo las formas compartidas a `application/services/use-case.types.ts` y `application/validators/phase-gate-validator.types.ts` y apuntando a ellas los seis consumidores directamente; los dos dueños anteriores reexportan los módulos nuevos, así que ninguna ruta de import cambia — incluida `@beyondnet/evolith-core-domain/application/services`, de donde el CLI y el servidor MCP importan `InitProjectInput`, y los 15 sitios que importan el contrato de gates desde el servicio. **NADA EN CI PUEDE REPRODUCIR ESTO, y esa es la mitad abierta de la fila:** `@beyondnet/evolith-repo-facts` solo existe en `feat/gt-589-repo-facts` (PR #309), no en `main`, así que la medición hubo que correrla desde un build del paquete de esa rama contra este árbol. Hasta que #309 aterrice no hay guard, y un ciclo nuevo regresa tan en silencio como lo hicieron estos dos — que es exactamente cómo llegaron sin ser vistos a un paquete de 346 módulos. +- **Component:** `Core` · **Criticality:** P2 · **Complexity:** S +- **Provenance:** Encontrado el 2026-07-30 al apuntar el extractor de [`GT-589`](./gap-reference-catalog.es.md#gt-589) al propio Core — lo primero que se le pidió medir a esa herramienta fue el repositorio que la produjo, y devolvió dos defectos en su propia casa. Registrado aparte de GT-589 porque los ciclos son deuda de la capa de aplicación del Core, anterior a la base de hechos y posterior a su merge, mientras que el guard ausente es un follow-on que pertenece a #309. El id se asignó tomando la unión de ids `GT-*` entre `main`, `develop`, la rama de integración abierta y el PR #309 — el mayor era GT-640 — según [`GT-638`](./gap-reference-catalog.es.md#gt-638). +- **Acceptance criteria:** + - [x] Ambos ciclos se rompen en el origen de la inversión (el tipo se muda), no volviendo `import type` la arista de vuelta — una arista borrada solo escondería el acoplamiento de la base de hechos dejando la estratificación invertida. + - [x] Toda ruta de import existente sigue resolviendo: `services/index.ts` y `phase-gate-validator.service.ts` reexportan los módulos extraídos, y ningún consumidor fuera de core-domain fue editado. + - [x] Medido con el mismo extractor antes y después sobre el mismo árbol: 346 módulos / 2 ciclos → 348 módulos / 0 ciclos. + - [x] Se repunta el componente fuertemente conexo completo en cada caso, no solo los dos módulos nombrados en la cadena reportada. + - [x] El guard ausente se lleva como fila propia — [`GT-642`](./gap-reference-catalog.es.md#gt-642), bloqueada por el PR #309 — y no como criterio abierto dentro de una fila cerrada. Una fila cerrada cuyo último criterio está sin marcar se lee como hecha para todo el que no abra el catálogo. + #### GT-639 **Title:** Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces - **Purpose:** Que un gap que ya se está trabajando sea descubrible ANTES de que una segunda sesión lo empiece, y no en el merge. -- **Evidence:** **El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse.** (1) [`GT-640`](./gap-reference-catalog.es.md#gt-640) — registrado como GT-633 hasta la renumeración de abajo — se arregló dos veces: un script de captura aterrizó en `main` como #276 y un renderer independiente dentro del spec aterrizó en `develop` como #279. Los dos eran correctos, los dos recapturaron los mismos números — y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una TERCERA sesión gastó una reconciliación entera (#294, #295, #296, #297) en dejar un solo renderer. (2) El parser del guard de evidencias se arregló dos veces: `d1ea72a3` en `main` ("stop the evidence guard blaming the evidence for two parser defects") y `5acb29ed` en `develop` ("the ratchet was stuck by two bugs in itself"). (3) Un id de GT se asignó dos veces, forzando la renumeración que registra [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge.** Es el trabajo duplicado que ocurrió antes de que nadie llegara al conflicto, más la reconciliación que hizo falta después — y esa reconciliación introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible durante un día porque ambas estampaban la misma fecha). Tres duplicaciones, una sesión de reparación y un bug nuevo: ésa es la factura real. **Por qué el tablero no lo ve hoy:** una fila lleva estado (`PENDIENTE`, `EN-PROGRESO`) pero no QUIÉN la trabaja ni DÓNDE. Dos sesiones pueden leer ambas `GT-640 · PENDIENTE` (registrado como GT-633 entonces) y empezar ambas, correctamente. `08-validate-tracking` valida dentro de un único árbol de trabajo por construcción, así que no puede saber que existe otra rama. **Esto no es una convención que falte — la convención existe.** La regla de un solo driver para las olas de gaps es práctica establecida; lo que falta es cualquier mecanismo que la haga observable, así que en un día cargado se degrada en silencio y nadie se entera hasta el merge. Direcciones de arreglo: exigir una reclamación (rama o PR) en la fila que pasa a `EN-PROGRESO`, y derivar el conjunto de reclamaciones de los PR abiertos para que no pueda quedarse rancio; después, fallar en PR cuando un id `GT-*` esté reclamado por más de una rama abierta, nombrando a las dos. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese mismo id lleva en la rama base: ausente en base es id nuevo, presente con el mismo título es el mismo gap editado, presente con título DISTINTO es un número nombrando dos gaps. El título es el discriminador a propósito — "el id ya existe en base" es el caso normal de 500 y pico filas y no dice nada. Corrido contra `origin/develop` reporta **`GT-633`**: en develop es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main es la fila del guard tautológico (`c3221276`, 2026-07-30). Dos gaps, un número, y el merge todavía no lo había sacado a la luz. **RESUELTO el 2026-07-30 renumerando la fila más nueva —la de main— a [`GT-640`](./gap-reference-catalog.es.md#gt-640)**, según el propio consejo del guard. Hacerlo a mano es el coste que esta fila existe para describir: hubo que cambiar el id en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, las referencias cruzadas de otras dos filas en los dos idiomas y tres comentarios de código — y los mensajes de commit y cuerpos de PR que nombran GT-633 no se pueden cambiar. **El guard se niega a adivinar qué lado está mal** — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos y lo dice. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene y un guard que fallara por ESO enseñaría a ignorarlo. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). **Lo que sigue sin ver, dicho y no insinuado:** un número asignado en una rama de la que nadie ha abierto PR. La salida `--verbose` lo advierte en cada id recién asignado en vez de dejar que el lector suponga una cobertura que no tiene. +- **Evidence:** **El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse.** (1) [`GT-640`](./gap-reference-catalog.es.md#gt-640) — registrado como GT-633 hasta la renumeración de abajo — se arregló dos veces: un script de captura aterrizó en `main` como #276 y un renderer independiente dentro del spec aterrizó en `develop` como #279. Los dos eran correctos, los dos recapturaron los mismos números — y dos generadores para un artefacto es el defecto del propio GT-633 un nivel más arriba, así que una TERCERA sesión gastó una reconciliación entera (#294, #295, #296, #297) en dejar un solo renderer. (2) El parser del guard de evidencias se arregló dos veces: `d1ea72a3` en `main` ("stop the evidence guard blaming the evidence for two parser defects") y `5acb29ed` en `develop` ("the ratchet was stuck by two bugs in itself"). (3) Un id de GT se asignó dos veces, forzando la renumeración que registra [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge.** Es el trabajo duplicado que ocurrió antes de que nadie llegara al conflicto, más la reconciliación que hizo falta después — y esa reconciliación introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible durante un día porque ambas estampaban la misma fecha). Tres duplicaciones, una sesión de reparación y un bug nuevo: ésa es la factura real. **Por qué el tablero no lo ve hoy:** una fila lleva estado (`PENDIENTE`, `EN-PROGRESO`) pero no QUIÉN la trabaja ni DÓNDE. Dos sesiones pueden leer ambas `GT-640 · PENDIENTE` (registrado como GT-633 entonces) y empezar ambas, correctamente. `08-validate-tracking` valida dentro de un único árbol de trabajo por construcción, así que no puede saber que existe otra rama. **Esto no es una convención que falte — la convención existe.** La regla de un solo driver para las olas de gaps es práctica establecida; lo que falta es cualquier mecanismo que la haga observable, así que en un día cargado se degrada en silencio y nadie se entera hasta el merge. Direcciones de arreglo: exigir una reclamación (rama o PR) en la fila que pasa a `EN-PROGRESO`, y derivar el conjunto de reclamaciones de los PR abiertos para que no pueda quedarse rancio; después, fallar en PR cuando un id `GT-*` esté reclamado por más de una rama abierta, nombrando a las dos. **GUARD CONSTRUIDO el 2026-07-30, tres criterios de cuatro.** `50-validate-gap-claim` deriva el conjunto de reclamaciones de los PULL REQUESTS ABIERTOS — cada `GT-*` en el título, el cuerpo o el nombre de rama de un PR — y falla cuando un id lo reclaman dos, nombrando ambos con su rama. Derivado y no escrito a mano a propósito: una reclamación escrita a mano se queda rancia en la dirección que importa, porque alguien olvida quitarla y la siguiente sesión esquiva una reclamación que ya no sostiene nadie. Cableado en `Governance guards (GT-578)` con `GH_TOKEN`, y observado en rojo por `43-validate-guard-negative-fixtures` (39/39). **EL CRITERIO 1 SE DEJA ABIERTO A PROPÓSITO, y el motivo es una restricción de diseño, no un olvido:** una lista de reclamaciones commiteada en el tablero se derivaría de estado vivo de GitHub, así que estaría rancia en cuanto alguien abriera un PR — y la cadena de [`GT-630`](./gap-reference-catalog.es.md#gt-630) existe justamente para exigir que los artefactos derivados alcancen un punto fijo. Un artefacto perpetuamente rancio dentro de esa cadena sería peor que ninguno. La vista viva es la salida del propio check en el PR donde importa; hacerla descubrible desde el tablero necesita algo que el tablero pueda renderizar sin commitear, y eso no está construido. **Lo que no puede ver, dicho en su propio texto de fallo y no insinuado:** una rama sin PR abierto no reclama nada. Abrir el PR pronto —basta en borrador— es lo que hace visible la reclamación, y eso ya es una convención con un check detrás en vez de una regla escrita. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese mismo id lleva en la rama base: ausente en base es id nuevo, presente con el mismo título es el mismo gap editado, presente con título DISTINTO es un número nombrando dos gaps. El título es el discriminador a propósito — "el id ya existe en base" es el caso normal de 500 y pico filas y no dice nada. Corrido contra `origin/develop` reporta **`GT-633`**: en develop es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main es la fila del guard tautológico (`c3221276`, 2026-07-30). Dos gaps, un número, y el merge todavía no lo había sacado a la luz. **RESUELTO el 2026-07-30 renumerando la fila más nueva —la de main— a [`GT-640`](./gap-reference-catalog.es.md#gt-640)**, según el propio consejo del guard. Hacerlo a mano es el coste que esta fila existe para describir: hubo que cambiar el id en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, las referencias cruzadas de otras dos filas en los dos idiomas y tres comentarios de código — y los mensajes de commit y cuerpos de PR que nombran GT-633 no se pueden cambiar. **El guard se niega a adivinar qué lado está mal** — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos y lo dice. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene y un guard que fallara por ESO enseñaría a ignorarlo. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). **Lo que sigue sin ver, dicho y no insinuado:** un número asignado en una rama de la que nadie ha abierto PR. La salida `--verbose` lo advierte en cada id recién asignado en vez de dejar que el lector suponga una cobertura que no tiene. - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M - **Provenance:** Registrado el 2026-07-30 a partir de tres instancias observadas en un solo día, todas en el merge `develop` → `main` que las hizo visibles. Relacionado pero distinto de [`GT-638`](./gap-reference-catalog.es.md#gt-638): aquella fila trata de asignar un NOMBRE dos veces, ésta de hacer el TRABAJO dos veces. La misma carencia de coordinación produce ambas, y la colisión de id es su síntoma más barato. - **Acceptance criteria:** - [ ] Un gap que se está trabajando es descubrible desde el tablero junto con la rama o el PR que lo reclama, antes de que el trabajo aterrice. - - [ ] Una comprobación falla en pull request cuando un id `GT-*` está reclamado por más de una rama abierta, nombrando a ambos reclamantes. - - [ ] La reclamación se DERIVA de los pull requests abiertos y no se escribe a mano, para que no pueda quedarse rancia en la dirección que importa. - - [ ] Incluye una fixture negativa OBSERVADA en rojo, no sólo declarada capaz de fallar. + - [x] Una comprobación falla en pull request cuando un id `GT-*` está reclamado por más de una rama abierta, nombrando a ambos reclamantes — `50-validate-gap-claim`, cableado en `Governance guards (GT-578)`. + - [x] La reclamación se DERIVA de los pull requests abiertos y no se escribe a mano, para que no pueda quedarse rancia en la dirección que importa — título, cuerpo y nombre de rama de cada PR abierto. + - [x] Incluye fixtures negativas OBSERVADAS en rojo — 10, entre ellas dos PRs abiertos sobre un mismo gap y el suelo anti-vacuo, y `43-validate-guard-negative-fixtures` la cuenta en 39/39. #### GT-638 diff --git a/reference/core/control-center/gaps/gap-reference-catalog.md b/reference/core/control-center/gaps/gap-reference-catalog.md index c7bd95f0..2384d10f 100644 --- a/reference/core/control-center/gaps/gap-reference-catalog.md +++ b/reference/core/control-center/gaps/gap-reference-catalog.md @@ -7950,19 +7950,65 @@ Historical gap series tracked in the former `gap-analysis-core.md`, preserved fo - [x] The `security` type is either declared in the commitlint config with an explicit version-bump mapping, or removed from use. - [x] No hook in `.husky/` prints a "skipping" message and exits zero — a hook that cannot run is deleted, not silenced. +#### GT-643 + +**Title:** A `--dry-run` that writes: the flag is declared on `agents install`, documented, and never read + +- **Purpose:** Make the one flag whose entire contract is "change nothing" actually change nothing, and stop a test's output from being committed as product data. +- **Evidence:** **`evolith agents install --dry-run` writes to the working directory.** `agents.command.ts:647` declares `-d, --dry-run`, `AgentsCommandOptions.dryRun` types it at line 29, and `installAgent()` never consults it: line 218 calls `this.registry.installAgent(process.cwd(), config, rulesetContent)` unconditionally, after which the registry writes `rulesets/agents//` and rewrites `agents-registry.json`. **This is one command out of step, not an absent convention** — `init` swaps the filesystem for a `DryRunFileSystem` (`init.command.ts:132-135`), `upgrade` returns before applying (`upgrade.command.ts:92-100`), `adr` threads it through every writer. `agents` is the only declaration in the CLI with no reader. **PROVEN BY REPRODUCTION, not by reading the code:** with a clean tree, `npx jest --config test/jest-e2e.json test/agents.e2e-spec.ts -t "dry-run"` alone leaves three TRACKED files modified — `src/sdk/cli/rulesets/agents/agents-registry.json`, `rulesets/agents/test-value/agent.config.json` and `rulesets/agents/test-value/agent.rules.json`. The whole spec was run first, then the single case, to be sure the attribution was to `--dry-run` and not to a sibling test. **The output has already been committed as product data:** `test-value` is what the prompt mock answers to every question (`test/mock-prompt.service.ts:11-12`), and `6bb43cfa` versioned that agent into the repository's own `rulesets/`. So the repo now ships a fixture-shaped agent nobody authored, and every developer who runs the e2e suite finds three files dirty in `git status` — which is how it was found. **A dry run that writes is worse than no dry run at all:** it is the flag a user reaches for exactly when they are not willing to be trusted with the real one, and it currently reports success while having done the thing it promised not to do. **Why no check caught it:** the cross-surface conformance tester compares what CLI, MCP and REST *answer*, and no oracle asks whether the disk changed — a flag whose entire observable contract is the ABSENCE of an effect is invisible to an oracle that only reads replies. +- **Component:** `CLI` · **Criticality:** P1 · **Complexity:** S +- **Provenance:** Found on 2026-07-30 while closing [`GT-641`](./gap-reference-catalog.md#gt-641): the CLI e2e suite left three tracked files dirty, and tracing which spec did it led to the flag rather than to the test. Registered separately because the test-hygiene symptom is downstream of a product defect — fixing the spec's working directory would hide the defect and leave `--dry-run` lying to users. +- **Acceptance criteria:** + - [x] `agents install --dry-run` performs no write. The dry branch never calls the writer, rather than calling it through a no-op filesystem: a branch that cannot reach the writer cannot regress when the writer grows a new call. It reports the paths it would have created through a new `planInstall`, which the writer itself uses, so the report cannot drift from the layout. + - [x] The menu path forwards the caller's options. `evolith agents --dry-run` with no subcommand dropped them and wrote anyway — the path a user is most likely to take when they are unsure enough to want a dry run. + - [x] The three committed artifacts under `src/sdk/cli/rulesets/agents/` are deleted. The registry held exactly one agent, `test-value`, so the whole file was test residue rather than product data with residue in it. + - [x] The e2e suite runs in a temp directory and its oracle asserts the disk is unchanged, with a contrast case that a real install DOES write — otherwise the dry-run assertion would also pass if install had simply broken. Per-test module and directory, because a shared instance leaked commander's parsed options between runs and made the fix look broken. + - [x] Survey of every `--dry-run` declaration in the CLI, recorded including the correct ones: `init` (swaps in `DryRunFileSystem`), `upgrade`, `adr`, `scaffold`, `docs`, `fixtures`, `generate-domain` all gate their writes on it. `agents` was the only declaration with no reader. + - [x] The two hidden dependents the deletion exposed are repaired, not worked around. `47-validate-joined-paths` anchored its generated-subtree claim on `src/sdk/cli/rulesets` existing, and that directory existed in a clean checkout ONLY because the residue sat inside it — so the guard went red naming `policy.wasm`, a cause somewhere else entirely; the anchor now rests on `src/sdk/cli`, which is authored. `rulesets-resolver.bundled.spec.ts` had one case still reading the real tree, whose EACCES simulation only fired because some `…/rulesets` directory was reachable; it drives the synthetic tree the sibling cases already used. Both verified by parking the build artifact and re-running: 101 suites / 1447 tests green with `src/sdk/cli/rulesets` absent. + - [ ] An oracle exists that asks whether a no-effect flag had no effect, so the CLASS is covered rather than this instance. Not built: the cross-surface tester compares replies, and a contract whose whole content is the absence of an effect needs it to compare state as well. Until then the next such flag is found by hand, as this one was. + +#### GT-642 + +**Title:** Nothing fails on a runtime import cycle, so the two that existed were found by hand and the next one will be too + +- **Purpose:** Turn the cycle measurement into a check that runs on every pull request, over every package, instead of a thing someone remembers to point at one package once. +- **Evidence:** **The two cycles [`GT-641`](./gap-reference-catalog.md#gt-641) removed reached a 346-module package unobserved, and the reason is that nothing looks.** `lint:boundaries` enforces layer direction, not cyclicity — it would have passed both, since `services/index.ts` and `use-cases/` are the same layer. `tsc` compiles a `require` cycle without a diagnostic; at runtime it resolves to a partially-initialised module, which is a defect that shows up as `undefined` at import time rather than as a compile error. No governance guard reads the module graph at all. **The measurement is already written and is out of reach:** [`GT-589`](./gap-reference-catalog.md#gt-589) delivers `@beyondnet/evolith-repo-facts` and `findImportCycles`, and they exist only on `feat/gt-589-repo-facts` (PR #309) — the GT-641 numbers had to be produced by building that branch's package against a working tree that does not contain it. **This is BLOCKED rather than pending, and the distinction is the whole row:** a check on the default branch cannot run an extractor absent from the default branch, and writing a second cycle detector next to the first would be exactly the duplicated-source-of-truth defect GT-589 was built to remove. **Scope, stated so it is not quietly narrowed later:** only `core-domain` has ever been measured. `cli`, `mcp-server`, `core`, `agent-runtime` and `sdk-client` have not, and the shape that produced both GT-641 cycles — a barrel that owns a type while re-exporting the module consuming it — is a monorepo-wide idiom, not a `core-domain` accident. +- **Component:** `Governance` · **Criticality:** P2 · **Complexity:** S +- **Provenance:** Registered on 2026-07-30 out of [`GT-641`](./gap-reference-catalog.md#gt-641), whose last acceptance criterion this is. Carried as its own row rather than left unchecked inside a closed one: a DONE row with an open criterion reads as finished to anyone who does not open the catalog, which is the reporting failure [`GT-640`](./gap-reference-catalog.md#gt-640) describes in a different guise. +- **Acceptance criteria:** + - [ ] A guard runs the GT-589 extractor over every workspace package and fails when `findImportCycles` returns a non-empty result, reporting the chain AND the component — the component is what makes the fix correct, as GT-641 showed. + - [ ] Type-only cycles are reported separately from runtime cycles and do not fail the build: conflating them would push people toward `import type` as a silencer, which hides the inverted layering instead of fixing it. + - [ ] The guard is wired into `Governance guards (GT-578)` and observed red by `43-validate-guard-negative-fixtures`, so it has been seen failing before it is trusted. + - [ ] The first full-monorepo run is recorded with its counts per package, including the packages that turn out clean — a guard whose first run reports nothing is indistinguishable from one that cannot run. + +#### GT-641 + +**Title:** Two runtime import cycles inside the Core's own sources, invisible to every check that runs today + +- **Purpose:** Remove the cycles the Core's own structural fact base finds in the Core, and record — rather than imply — that nothing on `main` can find the next one until that fact base ships there. +- **Evidence:** **The Core carries two runtime import cycles in its own application layer.** Running [`GT-589`](./gap-reference-catalog.md#gt-589)'s extractor over `src/packages/core-domain` reports 346 modules and 2 cycles: `application/services/index.ts` ⇄ `application/use-cases/initialize-project.use-case.ts`, and `application/validators/blocking-criteria-validator.ts` ⇄ `application/validators/phase-gate-validator.service.ts`. **Neither is type-only**, which is the part that makes them defects rather than noise: the fact base distinguishes type-only edges from value edges precisely so an erased-at-compile-time import is not reported as a cycle, and these two are not erased. **They are the same defect twice.** A module publishes a type AND constructs or re-exports the collaborator that consumes it, so the collaborator must import back through it to name its own parameters: `services/index.ts` owned `InitProjectInput` while re-exporting `InitializeProjectUseCase`; `phase-gate-validator.service.ts` owned `PhaseGateDefinition` while constructing `EvidenceValidator`, `BlockingCriteriaValidator` and `RulesetLoader`. **The chains the report names are narrower than the components it also reports, and reading only the chain would have produced a wrong fix.** The strongly connected components are four modules each — `project-scaffolder.service` and `phase-transition.use-case` join the first, `evidence-validator` and `ruleset-loader` the second — so repointing only the two files named in each chain would have relocated the cycle instead of breaking it. **FIXED 2026-07-30** by extracting the shared shapes into `application/services/use-case.types.ts` and `application/validators/phase-gate-validator.types.ts` and pointing all six consumers at them directly; the two former owners re-export the new modules, so no import path changes anywhere — including `@beyondnet/evolith-core-domain/application/services`, from which the CLI and the MCP server import `InitProjectInput`, and the 15 sites that import the gate contract from the service. **NOTHING IN CI CAN REPRODUCE THIS, and that is the open half of the row:** `@beyondnet/evolith-repo-facts` exists only on `feat/gt-589-repo-facts` (PR #309), not on `main`, so the measurement had to be run from a build of that branch's package against this tree. Until #309 lands there is no guard, and a new cycle regresses as silently as these two did — which is how they reached a 346-module package unnoticed in the first place. +- **Component:** `Core` · **Criticality:** P2 · **Complexity:** S +- **Provenance:** Found on 2026-07-30 by pointing [`GT-589`](./gap-reference-catalog.md#gt-589)'s extractor at the Core itself — the first thing that tool was asked to measure was the repository that produced it, and it returned two defects in its own house. Registered separately from GT-589 because the cycles are Core application-layer debt that predates the fact base and outlives its merge, while the missing guard is a follow-on that belongs to #309. The id was allocated by taking the union of `GT-*` ids across `main`, `develop`, the open integration branch and PR #309 — highest was GT-640 — per [`GT-638`](./gap-reference-catalog.md#gt-638). +- **Acceptance criteria:** + - [x] Both cycles are broken at the source of the inversion (the type moves out), not by making the back-edge `import type` — an erased edge would only hide the coupling from the fact base while leaving the layering inverted. + - [x] Every existing import path still resolves: `services/index.ts` and `phase-gate-validator.service.ts` re-export the extracted modules, and no consumer outside core-domain was edited. + - [x] Measured with the same extractor before and after over the same tree: 346 modules / 2 cycles → 348 modules / 0 cycles. + - [x] The whole strongly connected component is repointed in each case, not just the two modules named in the reported chain. + - [x] The missing guard is carried as its own row — [`GT-642`](./gap-reference-catalog.md#gt-642), blocked on PR #309 — rather than as an open criterion inside a closed one. A closed row whose last criterion is unchecked reads as done to every reader who does not open the catalog. + #### GT-639 **Title:** Nothing makes in-flight work visible across branches, so the same gap gets fixed twice - **Purpose:** Make a gap that is already being worked discoverable BEFORE a second session starts it, rather than at merge time. -- **Evidence:** **On 2026-07-30 the same work was done twice, three separate times, by sessions that could not see each other.** (1) [`GT-640`](./gap-reference-catalog.md#gt-640) — registered as GT-633 until the renumber below — was fixed twice: a standalone capture script landed on `main` as #276, and an independent in-spec renderer landed on `develop` as #279. Both were correct, both recaptured the same numbers — and two generators for one artifact is GT-633's own defect one level up, so a THIRD session spent a full reconciliation (#294, #295, #296, #297) collapsing them to one renderer. (2) The evidence guard's parser was fixed twice: `d1ea72a3` on `main` ("stop the evidence guard blaming the evidence for two parser defects") and `5acb29ed` on `develop` ("the ratchet was stuck by two bugs in itself"). (3) A GT id was allocated twice, forcing the renumber recorded in [`GT-638`](./gap-reference-catalog.md#gt-638). **The cost is not the merge conflict.** It is the duplicated work that happened before anyone reached the conflict, plus the reconciliation it then required — and that reconciliation introduced a defect of its own (an order-sensitive comparison that rewrote the capture date on every run, invisible for a day because both runs stamped the same date). Three duplications, one repair session, one new bug: that is the real bill. **Why the board cannot see it today:** a row carries a status (`PENDING`, `IN-PROGRESS`) but not WHO is working it or WHERE. Two sessions can both read `GT-640 · PENDING` (registered as GT-633 at the time) and both start, correctly. `08-validate-tracking` validates within one working tree by construction, so it cannot know another branch exists. **This is not a missing convention — the convention exists.** The single-driver rule for gap waves is already established practice; what is missing is any mechanism that makes it observable, so on a busy day it degrades silently and nobody learns until the merge. Fix directions: require a claim (branch or PR) on a row that moves to `IN-PROGRESS`, and derive the claim set from open PRs so it cannot go stale; then fail at PR time when one GT id is claimed by more than one open branch, naming both. +- **Evidence:** **On 2026-07-30 the same work was done twice, three separate times, by sessions that could not see each other.** (1) [`GT-640`](./gap-reference-catalog.md#gt-640) — registered as GT-633 until the renumber below — was fixed twice: a standalone capture script landed on `main` as #276, and an independent in-spec renderer landed on `develop` as #279. Both were correct, both recaptured the same numbers — and two generators for one artifact is GT-633's own defect one level up, so a THIRD session spent a full reconciliation (#294, #295, #296, #297) collapsing them to one renderer. (2) The evidence guard's parser was fixed twice: `d1ea72a3` on `main` ("stop the evidence guard blaming the evidence for two parser defects") and `5acb29ed` on `develop` ("the ratchet was stuck by two bugs in itself"). (3) A GT id was allocated twice, forcing the renumber recorded in [`GT-638`](./gap-reference-catalog.md#gt-638). **The cost is not the merge conflict.** It is the duplicated work that happened before anyone reached the conflict, plus the reconciliation it then required — and that reconciliation introduced a defect of its own (an order-sensitive comparison that rewrote the capture date on every run, invisible for a day because both runs stamped the same date). Three duplications, one repair session, one new bug: that is the real bill. **Why the board cannot see it today:** a row carries a status (`PENDING`, `IN-PROGRESS`) but not WHO is working it or WHERE. Two sessions can both read `GT-640 · PENDING` (registered as GT-633 at the time) and both start, correctly. `08-validate-tracking` validates within one working tree by construction, so it cannot know another branch exists. **This is not a missing convention — the convention exists.** The single-driver rule for gap waves is already established practice; what is missing is any mechanism that makes it observable, so on a busy day it degrades silently and nobody learns until the merge. Fix directions: require a claim (branch or PR) on a row that moves to `IN-PROGRESS`, and derive the claim set from open PRs so it cannot go stale; then fail at PR time when one GT id is claimed by more than one open branch, naming both. **GUARD BUILT 2026-07-30, three criteria of four.** `50-validate-gap-claim` derives the claim set from OPEN PULL REQUESTS — every `GT-*` in a PR's title, body or branch name — and fails when one id is claimed by two of them, naming both with their branches. Derived rather than hand-written on purpose: a hand-written claim goes stale in the direction that matters, because someone forgets to remove it and the next session works around a claim nobody holds. Wired into `Governance guards (GT-578)` with `GH_TOKEN`, and observed red by `43-validate-guard-negative-fixtures` (39/39). **CRITERION 1 IS DELIBERATELY LEFT OPEN, and the reason is a design constraint rather than an omission:** a claim list committed to the board would be derived from live GitHub state, so it would be stale the moment anyone opened a pull request — and [`GT-630`](./gap-reference-catalog.md#gt-630)'s chain exists precisely to insist that derived artifacts reach a fixed point. A perpetually-stale artifact inside that chain would be worse than none. The live view is the check's own output on the pull request where it matters; making it discoverable from the board needs something the board can render without committing, and that is not built. **What it cannot see, said in its own failure text rather than implied:** a branch with no open pull request claims nothing. Opening the PR early — draft is enough — is what makes the claim visible, and that is now a working convention with a check behind it rather than a rule written down. - **Component:** `Governance` · **Criticality:** P2 · **Complexity:** M - **Provenance:** Registered 2026-07-30 from three observed instances in a single day, all of them in the `develop` → `main` merge that made them visible. Related but distinct from [`GT-638`](./gap-reference-catalog.md#gt-638): that row is about allocating a NAME twice, this one about doing the WORK twice. The same coordination gap produces both, and the id collision is the cheapest symptom of it. - **Acceptance criteria:** - [ ] A gap being worked is discoverable from the board together with the branch or PR that claims it, before the work lands. - - [ ] A check fails at pull-request time when one `GT-*` id is claimed by more than one open branch, naming both claimants. - - [ ] The claim is DERIVED from open pull requests rather than hand-written, so it cannot be stale in the direction that matters. - - [ ] It ships with a negative fixture that has been OBSERVED red, not merely declared able to fail. + - [x] A check fails at pull-request time when one `GT-*` id is claimed by more than one open branch, naming both claimants — `50-validate-gap-claim`, wired into `Governance guards (GT-578)`. + - [x] The claim is DERIVED from open pull requests rather than hand-written, so it cannot be stale in the direction that matters — title, body and branch name of every open PR. + - [x] It ships with negative fixtures that have been OBSERVED red — 10 of them, including two open PRs on one gap and the anti-vacuous floor, and `43-validate-guard-negative-fixtures` counts it at 39/39. #### GT-638 diff --git a/reference/core/control-center/gaps/gap-tracking.es.md b/reference/core/control-center/gaps/gap-tracking.es.md index feef4d09..bec64aab 100644 --- a/reference/core/control-center/gaps/gap-tracking.es.md +++ b/reference/core/control-center/gaps/gap-tracking.es.md @@ -13,8 +13,11 @@ Este tablero es la única fuente de verdad para deuda técnica, gaps, oportunida | ID | Gap | Qué significa | Ejemplo | Componente | Fase | Criticidad | Complejidad | Estado | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| +| [`GT-643`](./gap-reference-catalog.es.md#gt-643) | **`evolith agents install --dry-run` escribe en disco — la bandera está declarada, documentada y nunca se lee.** `agents.command.ts:647` declara `-d, --dry-run`, `AgentsCommandOptions.dryRun` la tipa, y `installAgent()` no la consulta jamás: llama a `this.registry.installAgent(process.cwd(), config, rulesetContent)` sin condición (`agents.command.ts:218`). **`init`, `upgrade` y `adr` sí leen la suya** — `init` cambia a un `DryRunFileSystem`, `upgrade` retorna antes de aplicar — así que esto es un comando fuera de la convención, no una convención ausente. **DEMOSTRADO, no inferido:** correr `test/agents.e2e-spec.ts -t "dry-run"` en solitario contra un árbol limpio deja tres archivos VERSIONADOS modificados — `src/sdk/cli/rulesets/agents/agents-registry.json` y los dos bajo `rulesets/agents/test-value/`. **La salida del test está commiteada como si fuera dato de producto:** `test-value` es la cadena que el mock de prompts devuelve a toda pregunta (`test/mock-prompt.service.ts:11-12`), y `6bb43cfa` versionó ese agente dentro de los propios rulesets del repositorio. **Un simulacro que escribe es peor que no tener simulacro**, porque es la bandera a la que un usuario recurre justo cuando no quiere que se le confíe la de verdad — y el tester de conformidad de superficies nunca lo detectó, porque ningún oráculo pregunta si `--dry-run` dejó el disco intacto. **ARREGLADO 2026-07-30:** la rama seca no alcanza el escritor en absoluto — en vez de escribir a través de un filesystem inerte, que regresa en cuanto el escritor gana una llamada nueva — reporta las rutas que habría creado con un nuevo `planInstall`, y la ruta del menú ahora reenvía las opciones del llamador, porque `evolith agents --dry-run` sin subcomando las descartaba y escribía. Los tres artefactos commiteados quedan eliminados, la suite e2e corre en un directorio temporal, y su oráculo ahora AFIRMA que el disco no cambió, más un caso de contraste que prueba que una instalación real sí escribe: `no reventó` pasó todo el tiempo que el defecto existió. **Barrido registrado:** `init`, `upgrade`, `adr`, `scaffold`, `docs`, `fixtures` y `generate-domain` condicionan sus escrituras a la bandera — `agents` era la única declaración del CLI sin lector. **SIGUE ABIERTO:** el tester cross-superficie no tiene oráculo para las banderas sin efecto como clase, así que la siguiente se vuelve a encontrar a mano. **Borrar el residuo destapó dos dependientes ocultos de él**, ambos reparados aquí: `47-validate-joined-paths` anclaba una declaración de subárbol generado en que existiera `src/sdk/cli/rulesets` —cierto en un checkout limpio solo porque el residuo estaba dentro— y se puso rojo señalando a `policy.wasm`; y un spec del resolver seguía leyendo el árbol real, así que su simulación de EACCES dejó de dispararse. Verificado apartando el artefacto de build: 101 suites / 1447 tests en verde con el directorio ausente. | | | `CLI` | Cross | P1 | S | `EN-PROGRESO` | +| [`GT-642`](./gap-reference-catalog.es.md#gt-642) | **Ningún check falla ante un ciclo de import en tiempo de ejecución, y los dos que existían se encontraron a mano.** [`GT-641`](./gap-reference-catalog.es.md#gt-641) eliminó dos ciclos `require` reales de `core-domain` — habían llegado sin ser vistos a un paquete de 346 módulos porque la medición que los encuentra no corre en ninguna parte. `lint:boundaries` vigila la dirección entre capas, no la ciclicidad; `tsc` compila un ciclo sin quejarse; ningún guard de gobernanza lee el grafo de módulos. **La medición ya existe y no es alcanzable:** [`GT-589`](./gap-reference-catalog.es.md#gt-589) entrega `@beyondnet/evolith-repo-facts` y `findImportCycles`, y ambos solo viven en `feat/gt-589-repo-facts` (PR #309). **BLOQUEADO, no simplemente pendiente** — un guard en `main` no puede correr un extractor que no está en `main`, y reimplementar la detección de ciclos al lado sería la segunda fuente de verdad que GT-589 existe para evitar. Cuando #309 aterrice: un script que corre el extractor sobre cada paquete del workspace y falla con `findImportCycles(...).length > 0`, cableado en `Governance guards (GT-578)`, con el fixture negativo que demuestre que se le ha visto en rojo. **Debe barrer todos los paquetes, no solo el ya medido** — `cli`, `mcp-server`, `core`, `agent-runtime` y `sdk-client` nunca han pasado por el extractor, y la forma "el barril es dueño del tipo" que produjo ambos ciclos de GT-641 no es específica de `core-domain`. | | | `Governance` | Cross | P2 | S | `PENDIENTE` | +| [`GT-641`](./gap-reference-catalog.es.md#gt-641) | **Las fuentes del propio Core arrastran dos ciclos de import en tiempo de ejecución, y nada de lo que corre hoy en CI puede verlos.** La base de hechos estructural de [`GT-589`](./gap-reference-catalog.es.md#gt-589), apuntada a `src/packages/core-domain`, reporta `application/services/index.ts` ⇄ `application/use-cases/initialize-project.use-case.ts` y `application/validators/blocking-criteria-validator.ts` ⇄ `application/validators/phase-gate-validator.service.ts`. **Ninguno es solo de tipos**, así que ninguno se borra al compilar: son ciclos `require` reales, y la base de hechos los clasifica así porque distingue las aristas type-only de las de valor. **Una misma forma, dos veces:** un módulo publica un tipo Y ADEMÁS construye o reexporta al colaborador que lo consume, así que el colaborador tiene que importar de vuelta a través de él para nombrar sus propios parámetros. **Las cadenas de dos nodos que nombra el reporte son la mitad pequeña del hallazgo** — los componentes fuertemente conexos son más anchos: `project-scaffolder.service` y `phase-transition.use-case` están en el primero, `evidence-validator` y `ruleset-loader` en el segundo, de modo que un arreglo limitado a los dos archivos de la cadena habría movido el ciclo en vez de eliminarlo. **ARREGLADO 2026-07-30:** las formas compartidas se mudan a `application/services/use-case.types.ts` y `application/validators/phase-gate-validator.types.ts`; ambos dueños anteriores las reexportan, así que ninguna ruta de import existente cambia — incluida `@beyondnet/evolith-core-domain/application/services`, de donde el CLI y el servidor MCP importan `InitProjectInput`. **EL GUARD NO ESTÁ CONSTRUIDO, y decir por qué importa más que el arreglo:** `@beyondnet/evolith-repo-facts` solo existe en `feat/gt-589-repo-facts` (PR #309) y no está en `main`, así que ningún check de la rama por defecto puede correr esta medición. Hasta que #309 aterrice, un ciclo nuevo vuelve a pasar en silencio igual que estos dos. | | | `Core` | Cross | P2 | S | `COMPLETADO` | | [`GT-640`](./gap-reference-catalog.es.md#gt-640) | **El guard comparaba el snapshot contra seis números copiados del propio snapshot, así que solo podía pasar — y el archivo que no estaba vigilando se estampa en 388 filas de un artefacto mayor.** `native-evaluability-snapshot.json` declaraba en su propia cabecera que era "un SNAPSHOT CAPTURADO, no la fuente de verdad". **No existía ningún script de captura.** Se mantenía a mano y derivó: fijaba `documentation-only: 129` mientras Core fijaba 136, y seguía llamando `unimplemented-native` a reglas que ya tenían handler. Su guard en `iso-5055-mapping.test.mjs` asertaba los seis conteos de clase del snapshot contra seis números escritos a mano en el test — los mismos seis literales que el snapshot ya contenía. **El valor esperado y el valor real eran copias el uno del otro**, así que la aserción se sostenía mientras el archivo no cambiara, fijara Core lo que fijara; informaba de una concordancia que nunca comprobó, lo cual es peor que no tener guard, porque la fila que protege se lee como verificada. **La deriva no se queda donde empieza.** `build-iso-5055-mapping.mjs` estampa `nativeEvaluability` en TODAS las filas del mapeo ISO/IEC 5055 desde ese archivo — 388 tras la recaptura, 381 antes — así que una sola clase rancia se blanquea en un artefacto derivado cinco veces mayor que su entrada, y sobredimensiona el backlog de handlers que es el entregable entero de [`GT-598`](./gap-reference-catalog.es.md#gt-598). El orden recaptura→reconstrucción no estaba escrito en ningún sitio, y el guard del propio mapeo no corría en **ningún workflow**. **CORREGIDO el 2026-07-29:** un script de captura que ejecuta el triage REAL de Core vía `ts-node` en vez de reimplementar la clasificación, la medición extraída del spec de jest a `test/rule-corpus-triage.ts` para que un script pueda alcanzarla — esa inalcanzabilidad es la razón de que el archivo se mantuviera a mano —, aserciones snapshot-contra-triage-fresco en ambas direcciones, un guard del job de documentación que lee los conteos que Core fija LEYENDO el spec y **lanza excepción** cuando el literal falta o cambia de forma, y ambos pasos declarados como eslabones de la cadena de artefactos derivados de [`GT-630`](./gap-reference-catalog.es.md#gt-630). **Recapturado el 2026-07-29 tras mergear el fix en la línea actual, y la deriva había crecido mientras estaba en vuelo:** `native-handler` 139 → 151, `documentation-only` 129 → 136, el backlog de handlers 60 → **48**, corpus 379 → 386, mapeo 381 → 388 filas. Doce de esos handlers los cerraron [`GT-595`](./gap-reference-catalog.es.md#gt-595) y [`GT-632`](./gap-reference-catalog.es.md#gt-632) mientras el snapshot mantenido a mano seguía reportándolos como backlog — el defecto demostrándose una vez más, sobre números que nadie escribió dos veces. **RENUMERADA GT-633 -> GT-640 el 2026-07-30**, después de que `49-validate-gap-id-allocation` detectara que `GT-633` ya nombraba otro gap en `develop` (el scaffolding de `evolith init`, `c8270e48`, registrado un día antes). Se mueve la fila más nueva, según el propio consejo del guard. | | | `Governance` | Cross | P1 | S | `COMPLETADO` | -| [`GT-639`](./gap-reference-catalog.es.md#gt-639) | **Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces.** El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse: [`GT-640`](./gap-reference-catalog.es.md#gt-640) (registrado como GT-633 entonces) arreglado por un script de captura en `main` (#276) Y por un renderer independiente dentro del spec en `develop` (#279) — los dos correctos, los dos recapturando los mismos números, y dos generadores para un artefacto es el defecto del propio GT-640 un nivel más arriba, así que una tercera sesión gastó una reconciliación entera en dejar uno; el parser del guard de evidencias arreglado dos veces (`d1ea72a3` en main, `5acb29ed` en develop); y un id de GT asignado dos veces, forzando la renumeración que hay detrás de [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge** — es el trabajo duplicado antes de que nadie llegara a él, más la reconciliación, que introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible un día porque ambas estampaban la misma fecha). Una fila lleva estado pero no QUIÉN la trabaja ni DÓNDE, así que dos sesiones pueden leer ambas `PENDIENTE` y empezar ambas, correctamente. **La convención de un solo driver ya existe; lo que falta es un mecanismo que la haga observable**, así que en un día cargado se degrada en silencio. | | | `Governance` | Cross | P2 | M | `PENDIENTE` | +| [`GT-639`](./gap-reference-catalog.es.md#gt-639) | **Nada hace visible el trabajo en vuelo entre ramas, así que el mismo gap se arregla dos veces.** El 2026-07-30 el mismo trabajo se hizo dos veces, en tres ocasiones distintas, por sesiones que no podían verse: [`GT-640`](./gap-reference-catalog.es.md#gt-640) (registrado como GT-633 entonces) arreglado por un script de captura en `main` (#276) Y por un renderer independiente dentro del spec en `develop` (#279) — los dos correctos, los dos recapturando los mismos números, y dos generadores para un artefacto es el defecto del propio GT-640 un nivel más arriba, así que una tercera sesión gastó una reconciliación entera en dejar uno; el parser del guard de evidencias arreglado dos veces (`d1ea72a3` en main, `5acb29ed` en develop); y un id de GT asignado dos veces, forzando la renumeración que hay detrás de [`GT-638`](./gap-reference-catalog.es.md#gt-638). **El coste no es el conflicto de merge** — es el trabajo duplicado antes de que nadie llegara a él, más la reconciliación, que introdujo un defecto propio (una comparación sensible al orden que reescribía la fecha de captura en cada corrida, invisible un día porque ambas estampaban la misma fecha). Una fila lleva estado pero no QUIÉN la trabaja ni DÓNDE, así que dos sesiones pueden leer ambas `PENDIENTE` y empezar ambas, correctamente. **La convención de un solo driver ya existe; lo que falta es un mecanismo que la haga observable**, así que en un día cargado se degrada en silencio. **GUARD CONSTRUIDO el 2026-07-30 — tres criterios de cuatro.** `50-validate-gap-claim` deriva las reclamaciones de los PULL REQUESTS ABIERTOS (cada `GT-*` en título, cuerpo o nombre de rama) y falla cuando un id lo reclaman dos, nombrando ambos con su rama. Derivado y no a mano a propósito: una reclamación escrita a mano se queda rancia en la dirección que importa. Cableado en `Governance guards (GT-578)` con `GH_TOKEN`; observado en rojo por `43-validate-guard-negative-fixtures` (39/39). **El criterio 1 sigue abierto a propósito:** una lista commiteada en el tablero derivaría de estado vivo de GitHub y estaría rancia en cuanto alguien abriera un PR — y la cadena de [`GT-630`](./gap-reference-catalog.es.md#gt-630) existe para exigir que los derivados alcancen un punto fijo, así que uno perpetuamente rancio sería peor que ninguno. Tampoco ve una rama sin PR abierto, y lo dice en su propio texto de fallo. | | | `Governance` | Cross | P2 | M | `EN-PROGRESO` | | [`GT-638`](./gap-reference-catalog.es.md#gt-638) | **El tablero no tiene asignador de ids, así que dos ramas paralelas reparten el mismo número GT.** Un id se elige leyendo el `GT-*` más alto de la rama en la que uno está, y nada lo contrasta con otra rama — así que dos sesiones en paralelo asignan el mismo número y una se entera en el merge. Ya pasó: `8449af3d` en `develop` dice *"renumber the ratchet fix GT-634 -> GT-637, ID collision with develop"*; esa fila y [`GT-634`](./gap-reference-catalog.es.md#gt-634) en `main` tomaron el mismo id con horas de diferencia, desde sesiones que no podían verse. **La renumeración es manual y con pérdidas**, que cuesta más que el choque: un id vive en la fila del tablero, el ancla del catálogo, el registro de evidencia de cierre, referencias cruzadas en dos idiomas, mensajes de commit y cuerpos de PR — y sólo los tres primeros son comprobables mecánicamente. `08-validate-tracking` no puede ayudar por construcción: valida dentro de un único árbol de trabajo, y las ramas quedan fuera de su mundo. Tampoco es un desliz aislado — en el merge `develop` → `main` del 2026-07-30 el mismo patrón de duplicación convergente aparece tres veces: GT-640 (entonces GT-633) arreglado dos veces, el parser del guard de evidencias arreglado dos veces, este id asignado dos veces. **CERRADO el 2026-07-30 — y en su primera corrida real encontró una SEGUNDA colisión que nadie conocía.** `49-validate-gap-id-allocation` compara el **Title:** de cada id en HEAD contra el título que ese id lleva en la rama base; un título distinto para el mismo id es un número nombrando dos gaps. Contra `origin/develop` reportó **`GT-633`**: allí es "`evolith init` scaffolds a repository that cannot pass its own governance on the first run" (`c8270e48`, 2026-07-29) y en main era la fila del guard tautológico (`c3221276`, 2026-07-30), renumerada después a `GT-640`. El merge no lo había sacado a la luz. Se niega a adivinar qué lado está mal — un retítulo y una colisión se ven igual desde fuera —, así que nombra los dos. Cableado en `Governance guards (GT-578)` con un `git fetch` explícito de la base, porque un checkout superficial de CI no la tiene. Observado en rojo por `43-validate-guard-negative-fixtures` (38/38). Sigue sin ver un id asignado en una rama sin PR abierto, y su salida `--verbose` lo advierte en cada id nuevo. | | | `Governance` | Cross | P2 | S | `COMPLETADO` | | [`GT-637`](./gap-reference-catalog.es.md#gt-637) | **El ratchet de referencias muertas de GT-578 estaba atascado en 305 por dos bugs reales en el propio GUARD, no por el tamaño del backlog de registros que decía medir.** Al cerrar el PR de `GT-633` apareció un fallo en `Governance guards (GT-578)` y, al investigarlo, un refactor sin commitear y sin terminar que ya estaba en el árbol de trabajo: `resolveCommand` llamaba a `npmScriptOperand(...)`, una función que nunca se definió, así que el guard crasheaba con `ReferenceError` en cada invocación — incluso en modo reporte plano. Reparar esa función (restaurar su comportamiento y luego corregirlo) bajó el conteo de 309 a 78, lo que destapó el segundo bug: el corpus usa `npm run --workspace