diff --git a/.codex/config.toml b/.codex/config.toml deleted file mode 100644 index 8b5006a4..00000000 --- a/.codex/config.toml +++ /dev/null @@ -1,10 +0,0 @@ -[mcp_servers.nx] -command = "npx" -args = [ - "-y", - "nx-mcp@latest", - "/Users/beyondnet/Source/evolith_tracker/src", - "--minimal", - "false", - "--disableTelemetry", -] diff --git a/.gitignore b/.gitignore index d63c798c..1bfc83ba 100644 --- a/.gitignore +++ b/.gitignore @@ -165,3 +165,8 @@ vite.config.ts.timestamp-* # Informe generado por .harness/scripts/coverage-dashboard.mjs; el workflow lo sube # como artifact. No se versiona ni se valida como documentacion redactada. COVERAGE_REPORT.md + +# Local editor/tool config: machine-specific absolute paths, never shared. +# Committed by accident in a5f719c (a `git add -A`) and only caught when a PR +# targeted main, where the root-cleanliness guard rejects unknown root folders. +.codex/ diff --git a/docs/audit/tracker-gap-reference-catalog.md b/docs/audit/tracker-gap-reference-catalog.md index 045aa00c..da0c2212 100644 --- a/docs/audit/tracker-gap-reference-catalog.md +++ b/docs/audit/tracker-gap-reference-catalog.md @@ -46,10 +46,14 @@ This catalog explains each gap: problem, purpose, evidence, closure criteria, an - **Criticality:** P0 · **Complexity:** M - **Proposed fix:** Crear un `CoreArtifactFormatCatalog` que sincronice el catálogo por fase desde el Core, materialice una vista tenant-aware y permita overrides locales declarados, auditables y no destructivos. - **Acceptance criteria:** - - [ ] Discovery, design, construction, QA y release usan un catálogo de artefactos derivado del Core. - - [ ] Cada tenant puede marcar artefactos como requeridos, opcionales o no aplicables sin modificar el estándar base. - - [ ] El scorecard y las compuertas leen la misma fuente de catálogo. + - [x] **Parcial (construction/QA/release):** el expediente operativo (`GetPhaseArtifact`) ya se contrasta contra el catálogo derivado del Core, resolviendo por `IPhaseArtifactProfileSource` — la misma costura que lee `/phase-artifact-profiles`. Discovery y Design conservan sus verticales a medida y NO están cubiertas; el camino genérico solo cubre esas tres fases (`SdlcPhases.GenericGatePhases`). + - [ ] Cada tenant puede marcar artefactos como requeridos, opcionales o no aplicables sin modificar el estándar base. **Sin empezar.** No existe ningún override por tenant sobre este catálogo: el handler no recibe tenant y no hay entidad de aplicabilidad. Es la otra mitad del título de esta ficha. + - [ ] El scorecard y las compuertas leen la misma fuente de catálogo. **Parcial:** el expediente y el endpoint de solo lectura ya comparten fuente; `ScorecardEvaluator` y el camino de `GateSubmission` siguen sin consultarla. - [ ] `GAP-020` queda re-evaluado a la luz del catálogo vivo. + +**Hallazgo que motivó el cambio (2026-08-03):** el Tracker tenía DOS vocabularios de artefactos que nunca se encontraban. `/phase-artifact-profiles` derivaba del Core pero nada operativo lo consultaba; el expediente que la gente llena aceptaba cualquier cadena en `type` y arrancaba con `Items` vacío. Una fase podía completarse entera con artefactos que el Core no declara sin que ninguna superficie lo dijera — que es el **Risk** de esta ficha al pie de la letra. + +**Reporta, no impone (T-056).** El bloque `coreCatalog` dice las dos direcciones (lo declarado que falta, y lo presente que el Core no declara) y ahí se detiene. Nada rechaza un ítem ni cambia un estado: qué exige una compuerta lo configura el tenant, no lo cablea el motor. Se distingue además «no había catálogo para la fase» de «el catálogo no pide nada»: la primera omite el bloque en vez de devolver una lista vacía. - **Dependencies:** CP-01. #### CP-03 diff --git a/docs/audit/tracker-gap-tracking.md b/docs/audit/tracker-gap-tracking.md index ec4578ca..b014dd87 100644 --- a/docs/audit/tracker-gap-tracking.md +++ b/docs/audit/tracker-gap-tracking.md @@ -17,7 +17,7 @@ This board is the single source of truth for Tracker technical debt, gaps, oppor | ID | Gap | Qué significa | Ejemplo | Component | Phase | Criticality | Complexity | Status | |---|---|---|---|:---:|:---:|:---:|:---:|:---:| -| [`CP-02`](./tracker-gap-reference-catalog.md#cp-02) | El Tracker no tiene un catálogo vivo de formatos de artefacto por fase sincronizado con el Core y parametrizable por tenant | La iniciativa puede avanzar con una lista local de entregables que ya no coincide con el estándar que el Core evalúa | Core publica gates y schemas para `gate-f1..f5`; Tracker conserva catálogos derivados y un gap histórico de URLs de esquema | `Artifacts/Config` | Cross | P0 | M | `PENDING` | +| [`CP-02`](./tracker-gap-reference-catalog.md#cp-02) | El Tracker no tiene un catálogo vivo de formatos de artefacto por fase sincronizado con el Core y parametrizable por tenant | La iniciativa puede avanzar con una lista local de entregables que ya no coincide con el estándar que el Core evalúa | El expediente operativo de construction/QA/release ya se contrasta contra el catálogo derivado del Core por la misma costura que el endpoint de solo lectura; falta la parametrización por tenant, y el scorecard y las compuertas siguen sin leer esa fuente | `Artifacts/Config` | Cross | P0 | M | `PENDING` | | [`CP-06`](./tracker-gap-reference-catalog.md#cp-06) | Falta una matriz por tenant que convierta resultados del Core en decisión de compuerta distinguiendo bloqueante, requisito local y advisory | Un hallazgo técnico opcional puede terminar bloqueando una fase, o un requisito contractual puede quedar como simple recomendación | El gateway lee `overallVerdict` y `results.gate`, pero no aplica una política tenant-aware sobre `qualitySignals`, `phaseArtifacts`, `design`, `architecture` o recomendaciones | `Governance/Tenancy` | Cross | P0 | M | `PENDING` | | [`CP-12`](./tracker-gap-reference-catalog.md#cp-12) | No existe una mesa de ejecución documental por gate que permita operar cada artefacto tenant-aware de inicio a aprobación | El usuario sabe qué artefactos pide el gate, pero no tiene un lugar único para crear la copia del template, editarla, ver su estado y llevarla a aprobación | Los catálogos y wizards cubren piezas; falta el expediente operativo por iniciativa/fase/gate/artefacto | `WEB/Artifacts` | Cross | P0 | L | `PENDING` | | [`CP-15`](./tracker-gap-reference-catalog.md#cp-15) | Los artefactos y gates no tienen flujos de aprobación configurables por tenant con personas y agentes autorizados | Una fase puede depender de revisores humanos o agentes especializados, pero el Tracker no modela secuencias, quórum, delegaciones ni HITL por artefacto | Las aprobaciones existentes no resuelven miembros/equipos/agentes del tenant para cada artefacto SDLC | `Governance/Tenancy` | Cross | P0 | L | `PENDING` | diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs index 61ce04a3..edb8dd8f 100644 --- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/DTOs/PhaseArtifactDto.cs @@ -12,6 +12,51 @@ public sealed class ArtifactItemDto public string Status { get; init; } = string.Empty; } +/// +/// CP-02 — one artifact of the phase AS THE CORE DECLARES IT, next to whether this +/// record actually carries it. +/// +public sealed class CoreCatalogArtifactDto +{ + public string ArtifactKind { get; init; } = string.Empty; + public string Label { get; init; } = string.Empty; + + /// `binding` in the Core registry. Says what the STANDARD expects, not what this Tracker enforces. + public bool Required { get; init; } + + /// Whether an item of this kind exists on the record. Present, not valid — nothing here reads content. + public bool Present { get; init; } +} + +/// +/// CP-02 — the phase's artifact catalogue as the Core declares it, resolved through the +/// SAME seam the read-only `/phase-artifact-profiles` endpoint reads. +/// +/// Why this is REPORTED and never enforced. T-056: what a gate demands is +/// tenant configuration, not engine code. Comparing the record against the standard is +/// a fact; deciding that a missing artifact blocks a phase is an opinion, and it is not +/// ours to wire. So this block states both directions and stops there. +/// +/// Both directions, deliberately. `Artifacts` says what the Core declares +/// and whether the record carries it; says what the record +/// carries that the Core does not declare. Reporting only the first would make a record +/// full of off-standard items look complete. +/// +public sealed class PhaseCoreCatalogDto +{ + /// + /// `core-sync` once a sync has succeeded, `core-standin` while the mirror is being + /// served. A reader must be able to tell a synced answer from a mirrored one by + /// reading the payload rather than by assuming a sync happened. + /// + public string Source { get; init; } = string.Empty; + + public IReadOnlyList Artifacts { get; init; } = new List(); + + /// Item kinds on the record that the Core catalogue does not declare. + public IReadOnlyList UnknownItemKinds { get; init; } = new List(); +} + public sealed class PhaseArtifactDto { public Guid Id { get; init; } @@ -20,6 +65,13 @@ public sealed class PhaseArtifactDto public string Phase { get; init; } = string.Empty; public string Status { get; init; } = string.Empty; public IReadOnlyList Items { get; init; } = new List(); + + /// + /// Null when no catalogue was resolved for the phase — which is NOT the same as an + /// empty catalogue, and the difference matters: one means "the standard was not + /// available", the other means "the standard asks for nothing here". + /// + public PhaseCoreCatalogDto? CoreCatalog { get; init; } } public static class PhaseArtifactDtoMapper @@ -34,6 +86,54 @@ public static class PhaseArtifactDtoMapper Items = artifact.Items.Select(ToDto).ToList() }; + /// + /// CP-02 — the same record, contrasted against the Core's catalogue for its phase. + /// + /// Item kinds are matched case-insensitively: the catalogue writes slugs and the + /// record accepts whatever a caller sent, so a case difference would report a present + /// artifact as missing — a false gap on a governance surface, which is worse than no + /// comparison at all. + /// + public static PhaseArtifactDto ToDto( + DomainPhaseArtifact artifact, + string source, + IReadOnlyList<(string Kind, string Label, bool Required)> catalog) + { + var present = artifact.Items + .Select(i => i.Type) + .Where(t => !string.IsNullOrWhiteSpace(t)) + .ToHashSet(StringComparer.OrdinalIgnoreCase); + + var declared = catalog.Select(c => c.Kind).ToHashSet(StringComparer.OrdinalIgnoreCase); + + return new PhaseArtifactDto + { + Id = artifact.Id, + TenantId = artifact.TenantId, + InitiativeId = artifact.InitiativeId, + Phase = artifact.Phase, + Status = artifact.Status, + Items = artifact.Items.Select(ToDto).ToList(), + CoreCatalog = new PhaseCoreCatalogDto + { + Source = source, + Artifacts = catalog + .Select(c => new CoreCatalogArtifactDto + { + ArtifactKind = c.Kind, + Label = c.Label, + Required = c.Required, + Present = present.Contains(c.Kind), + }) + .ToList(), + UnknownItemKinds = present + .Where(t => !declared.Contains(t)) + .OrderBy(t => t, StringComparer.OrdinalIgnoreCase) + .ToList(), + }, + }; + } + private static ArtifactItemDto ToDto(ArtifactItem i) => new() { Type = i.Type, diff --git a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs index 97f1a82b..6ef91d70 100644 --- a/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs +++ b/src/apps/tracker-api/Tracker.Application/Sdlc/PhaseArtifact/Queries/GetPhaseArtifact/GetPhaseArtifactQueryHandler.cs @@ -1,16 +1,42 @@ using Tracker.Application.Sdlc.PhaseArtifact.DTOs; using Tracker.Domain.Sdlc.PhaseArtifact; +// `PhaseArtifactDto` is a live name in BOTH the governance catalogue and this module. The +// namespace is aliased rather than imported so the two never silently resolve to each other. +using IPhaseArtifactProfileSource = Tracker.Application.Governance.ArtifactFieldSchema.IPhaseArtifactProfileSource; + namespace Tracker.Application.Sdlc.PhaseArtifact.Queries.GetPhaseArtifact; +/// +/// CP-02 — the operative phase record, contrasted against the Core's catalogue. +/// +/// What this closes. The Tracker had TWO artifact vocabularies that never met: +/// `/phase-artifact-profiles`, read-only and derived from the Core, which nothing operational +/// consulted; and this record, which the user actually fills and which accepted any `type` +/// string whatsoever. A phase could therefore be completed entirely with items the Core does +/// not declare, and no surface would say so. That is CP-02's risk verbatim: the Tracker +/// looks like it governs the SDLC while governing a copy of the standard. +/// +/// It reports, it does not enforce. T-056: what a gate demands is tenant +/// configuration, not engine code. Nothing here rejects an item, changes a status, or blocks +/// a phase — the comparison is a fact the caller can act on under its own policy. +/// +/// Same seam, deliberately. It resolves through , +/// the very source the read-only endpoint reads. Two surfaces answering the same question from +/// two catalogues is the defect; a second copy here would have rebuilt it. +/// internal sealed class GetPhaseArtifactQueryHandler : IQueryHandler { private readonly IPhaseArtifactRepository _artifacts; + private readonly IPhaseArtifactProfileSource _profiles; - public GetPhaseArtifactQueryHandler(IPhaseArtifactRepository artifacts) + public GetPhaseArtifactQueryHandler( + IPhaseArtifactRepository artifacts, + IPhaseArtifactProfileSource profiles) { _artifacts = artifacts; + _profiles = profiles; } public async Task Handle( @@ -21,6 +47,18 @@ public GetPhaseArtifactQueryHandler(IPhaseArtifactRepository artifacts) if (artifact is null || artifact.TenantId != request.TenantId) return null; - return PhaseArtifactDtoMapper.ToDto(artifact); + var profile = _profiles.GetProfiles() + .FirstOrDefault(p => string.Equals(p.Phase, artifact.Phase, StringComparison.OrdinalIgnoreCase)); + + // No profile for the phase is NOT an empty profile. Reporting "the standard asks for + // nothing here" when the truth is "the standard was not available" is the kind of + // confident emptiness a governance surface must never produce, so the block is omitted. + if (profile is null) + return PhaseArtifactDtoMapper.ToDto(artifact); + + return PhaseArtifactDtoMapper.ToDto( + artifact, + profile.Source, + profile.Artifacts.Select(a => (a.ArtifactKind, a.Label, a.Required)).ToList()); } } diff --git a/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs new file mode 100644 index 00000000..d5baf1f7 --- /dev/null +++ b/src/apps/tracker-api/Tracker.Tests/Application/Sdlc/GetPhaseArtifactCoreCatalogTests.cs @@ -0,0 +1,149 @@ +using Tracker.Application.Governance.ArtifactFieldSchema; +using Tracker.Application.Sdlc.PhaseArtifact.Queries.GetPhaseArtifact; +using Tracker.Domain.Sdlc.PhaseArtifact; +using DomainPhaseArtifact = Tracker.Domain.Sdlc.PhaseArtifact.PhaseArtifact; +using GovPhaseArtifactDto = Tracker.Application.Governance.ArtifactFieldSchema.PhaseArtifactDto; + +namespace Tracker.Tests.Application.Sdlc; + +/// +/// CP-02 — el expediente operativo contrastado contra el catálogo del Core. +/// +/// Lo que se prueba no es que los campos se copien, sino las propiedades que hacen que +/// este contraste sea seguro en una superficie de gobierno: que REPORTE y no imponga (T-056), +/// que diga las DOS direcciones, que no confunda «no había catálogo» con «el catálogo no pide +/// nada», y que una diferencia de mayúsculas no invente un hueco que no existe. +/// +public class GetPhaseArtifactCoreCatalogTests +{ + private static readonly Guid Tenant = Guid.NewGuid(); + private static readonly Guid Initiative = Guid.NewGuid(); + + private sealed class FakeProfiles : IPhaseArtifactProfileSource + { + private readonly IReadOnlyList _profiles; + public FakeProfiles(string source, IReadOnlyList profiles) + { + Source = source; + _profiles = profiles; + } + + public string Source { get; } + + public IReadOnlyList GetProfiles() => new List + { + new() + { + Phase = "construction", + Source = Source, + Artifacts = _profiles.ToList(), + }, + }; + } + + private sealed class FakeRepo : IPhaseArtifactRepository + { + private readonly DomainPhaseArtifact? _one; + public FakeRepo(DomainPhaseArtifact? one) => _one = one; + + public Task GetByInitiativeAndPhaseAsync( + Guid initiativeId, string phase, CancellationToken ct) => Task.FromResult(_one); + + public Task AddAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + public Task UpdateAsync(DomainPhaseArtifact a, CancellationToken ct) => Task.CompletedTask; + } + + private static DomainPhaseArtifact Expediente(params (string Type, string? Ref)[] items) + { + var a = DomainPhaseArtifact.CreateForPhase(Tenant, Initiative, "construction", Guid.NewGuid()).Value; + foreach (var (type, @ref) in items) a.SetItem(type, null, @ref, null); + return a; + } + + private static GovPhaseArtifactDto Declarado(string kind, bool required = true) => + new() { ArtifactKind = kind, Label = kind.ToUpperInvariant(), Required = required }; + + private static Task Consultar( + DomainPhaseArtifact? expediente, IPhaseArtifactProfileSource profiles) => + new GetPhaseArtifactQueryHandler(new FakeRepo(expediente), profiles) + .Handle(new GetPhaseArtifactQuery(Tenant, Initiative, "construction"), CancellationToken.None); + + [Fact] + public async Task DiceLasDosDirecciones_LoQueFaltaYLoQueSobra() + { + // La razón de que existan las dos: reportar solo lo declarado haría que un expediente + // lleno de items fuera de estándar pareciera completo. + var dto = await Consultar( + Expediente(("coverage-report", "cov.xml"), ("invento-local", "x.md")), + new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, + new[] { Declarado("coverage-report"), Declarado("integration-evidence") })); + + var cat = dto!.CoreCatalog!; + cat.Artifacts.Single(a => a.ArtifactKind == "coverage-report").Present.Should().BeTrue(); + cat.Artifacts.Single(a => a.ArtifactKind == "integration-evidence").Present.Should().BeFalse(); + cat.UnknownItemKinds.Should().ContainSingle().Which.Should().Be("invento-local"); + } + + [Fact] + public async Task REPORTA_PERO_NO_IMPONE() + { + // T-056. Un artefacto requerido ausente NO puede cambiar el estado del expediente ni + // hacer fallar la consulta: qué exige una compuerta lo configura el tenant, no lo + // cablea el motor. Si esto se rompe, el producto empezó a opinar por su cuenta. + var dto = await Consultar( + Expediente(), + new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, new[] { Declarado("coverage-report") })); + + dto.Should().NotBeNull(); + dto!.Status.Should().Be(PhaseArtifactStatus.Draft); + dto.CoreCatalog!.Artifacts.Should().OnlyContain(a => !a.Present); + } + + [Fact] + public async Task SinCatalogoNoEsCatalogoVacio() + { + // Las dos ausencias son distintas y la diferencia importa: «el estándar no estaba + // disponible» no es «el estándar no pide nada aquí». La segunda es la clase de vacío + // confiado que una superficie de gobierno no debe producir. + var sinLaFase = new FakeProfilesOtraFase(); + var dto = await Consultar(Expediente(("coverage-report", "cov.xml")), sinLaFase); + + dto!.CoreCatalog.Should().BeNull(); + dto.Items.Should().ContainSingle("el expediente se sigue sirviendo entero"); + } + + private sealed class FakeProfilesOtraFase : IPhaseArtifactProfileSource + { + public string Source => PhaseArtifactProfileSourceKind.CoreSync; + public IReadOnlyList GetProfiles() => new List + { + new() { Phase = "qa", Source = PhaseArtifactProfileSourceKind.CoreSync }, + }; + } + + [Fact] + public async Task UnaDiferenciaDeMayusculasNoInventaUnHueco() + { + // El catálogo escribe slugs; el expediente acepta lo que le manden. Un cotejo sensible + // a mayúsculas reportaría como ausente un artefacto que está — un hueco falso, que en + // gobierno es peor que no cotejar. + var dto = await Consultar( + Expediente(("Coverage-Report", "cov.xml")), + new FakeProfiles(PhaseArtifactProfileSourceKind.CoreSync, new[] { Declarado("coverage-report") })); + + dto!.CoreCatalog!.Artifacts.Single().Present.Should().BeTrue(); + dto.CoreCatalog.UnknownItemKinds.Should().BeEmpty(); + } + + [Fact] + public async Task LaProcedenciaViajaEnElPayload() + { + // Un lector tiene que poder distinguir una respuesta sincronizada de una espejada + // leyendo el payload, no confiando en que hubo sincronización. + var dto = await Consultar( + Expediente(), + new FakeProfiles(PhaseArtifactProfileSourceKind.StandIn, new[] { Declarado("coverage-report") })); + + dto!.CoreCatalog!.Source.Should().Be(PhaseArtifactProfileSourceKind.StandIn); + } +}