diff --git a/build/cli/index.js b/build/cli/index.js index e9aa01d82..1aba34cfb 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -42489,6 +42489,27 @@ const PUBLICATION_SURFACE_MESSAGE_IDS = Object.freeze([ 'interaction.status.findings', 'interaction.status.findingsInvalid', 'interaction.status.findingCounts', + 'interaction.status.sddHeading', + 'interaction.status.sddRequirement', + 'interaction.status.sddState', + 'interaction.status.sddRequired', + 'interaction.status.sddNotRequired', + 'interaction.status.sddUnavailable', + 'interaction.status.sddRequirementUnknown', + 'interaction.status.sddUnverified', + 'interaction.status.sddAwaitingAnswers', + 'interaction.status.sddPublicationPending', + 'interaction.status.sddRevisionPending', + 'interaction.status.sddReady', + 'interaction.status.sddOwner', + 'interaction.status.sddQuestions', + 'interaction.status.sddCommit', + 'interaction.status.sddFirstCommit', + 'interaction.status.sddNextInspect', + 'interaction.status.sddNextAnswers', + 'interaction.status.sddNextPublication', + 'interaction.status.sddNextReady', + 'interaction.status.sddCard', 'cli.answer', 'cli.outcome', 'cli.status', @@ -42599,6 +42620,27 @@ const ENGLISH_MESSAGES = Object.freeze({ 'interaction.status.findings': 'Bugbot findings', 'interaction.status.findingsInvalid': 'invalid evidence; inspect the workflow result.', 'interaction.status.findingCounts': '{open} open, {reopened} reopened, {verificationRequired} verification required, {unknown} unknown, {resolved} resolved', + 'interaction.status.sddHeading': 'SDD readiness', + 'interaction.status.sddRequirement': 'Specification', + 'interaction.status.sddState': 'Status', + 'interaction.status.sddRequired': 'Required', + 'interaction.status.sddNotRequired': 'Not required', + 'interaction.status.sddUnavailable': 'Specification evidence unavailable; readiness is unverified.', + 'interaction.status.sddRequirementUnknown': 'Requirement unavailable', + 'interaction.status.sddUnverified': 'Publication evidence is invalid, absent or stale; readiness is unverified.', + 'interaction.status.sddAwaitingAnswers': 'Answers required', + 'interaction.status.sddPublicationPending': 'Publication pending', + 'interaction.status.sddRevisionPending': 'Revision pending; current readiness is unverified.', + 'interaction.status.sddReady': 'Published and verified', + 'interaction.status.sddOwner': 'Specification', + 'interaction.status.sddQuestions': 'Pending questions', + 'interaction.status.sddCommit': 'Verified publication', + 'interaction.status.sddFirstCommit': 'First verified publication (history)', + 'interaction.status.sddNextInspect': 'Inspect the SDD card and retry after correcting its evidence.', + 'interaction.status.sddNextAnswers': 'Answer these questions in the issue using their question identifiers.', + 'interaction.status.sddNextPublication': 'Resume SDD preparation to publish the approved specification.', + 'interaction.status.sddNextReady': 'Continue implementation on the linked branch.', + 'interaction.status.sddCard': 'Open SDD card', 'cli.answer': 'Answer', 'cli.outcome': 'Outcome', 'cli.status': 'Status', @@ -42710,6 +42752,27 @@ const SPANISH_MESSAGES = Object.freeze({ 'interaction.status.findings': 'Hallazgos de Bugbot', 'interaction.status.findingsInvalid': 'evidencia no válida; revisa el resultado del workflow.', 'interaction.status.findingCounts': '{open} abiertos, {reopened} reabiertos, {verificationRequired} requieren verificación, {unknown} desconocidos, {resolved} resueltos', + 'interaction.status.sddHeading': 'Preparación del SDD', + 'interaction.status.sddRequirement': 'Especificación', + 'interaction.status.sddState': 'Estado', + 'interaction.status.sddRequired': 'Obligatoria', + 'interaction.status.sddNotRequired': 'No obligatoria', + 'interaction.status.sddUnavailable': 'Evidencia de la especificación no disponible; preparación sin verificar.', + 'interaction.status.sddRequirementUnknown': 'Requisito no disponible', + 'interaction.status.sddUnverified': 'La publicación es inválida, está ausente o desactualizada; preparación sin verificar.', + 'interaction.status.sddAwaitingAnswers': 'Respuestas necesarias', + 'interaction.status.sddPublicationPending': 'Publicación pendiente', + 'interaction.status.sddRevisionPending': 'Revisión pendiente; preparación actual sin verificar.', + 'interaction.status.sddReady': 'Publicado y verificado', + 'interaction.status.sddOwner': 'Especificación', + 'interaction.status.sddQuestions': 'Preguntas pendientes', + 'interaction.status.sddCommit': 'Publicación verificada', + 'interaction.status.sddFirstCommit': 'Primera publicación verificada (histórica)', + 'interaction.status.sddNextInspect': 'Revisa la tarjeta del SDD y reintenta tras corregir su evidencia.', + 'interaction.status.sddNextAnswers': 'Responde estas preguntas en la issue usando sus identificadores.', + 'interaction.status.sddNextPublication': 'Retoma la preparación del SDD para publicar la especificación aprobada.', + 'interaction.status.sddNextReady': 'Continúa la implementación en la rama vinculada.', + 'interaction.status.sddCard': 'Abrir tarjeta del SDD', 'cli.answer': 'Respuesta', 'cli.outcome': 'Resultado', 'cli.status': 'Estado', @@ -43490,6 +43553,199 @@ function calculateReviewersStillNeeded(desiredCount, currentCount, confirmedCoun } +/***/ }), + +/***/ 99167: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.buildSddIssueDigest = buildSddIssueDigest; +const node_crypto_1 = __nccwpck_require__(6005); +const pre_branch_sdd_1 = __nccwpck_require__(34730); +const recommendation_policy_1 = __nccwpck_require__(39410); +/** Producer and read-only queries use the same identity and immutable first base. */ +function buildSddIssueDigest(context, baseSha) { + return (0, node_crypto_1.createHash)('sha256').update(JSON.stringify([ + context.issueNumber, (0, pre_branch_sdd_1.normalizeSddIssueTitle)(context.issueTitle), (0, recommendation_policy_1.getVisibleIssueDescription)(context.issueBody), + context.admittedKind, context.profileDigest ?? '', baseSha, + ])).digest('hex'); +} + + +/***/ }), + +/***/ 56285: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.selectOwnedSddCard = selectOwnedSddCard; +const pre_branch_sdd_1 = __nccwpck_require__(34730); +const publication_identity_policy_1 = __nccwpck_require__(45403); +/** The latest owned card is authoritative even when invalid; an older card cannot restore readiness. */ +function selectOwnedSddCard(comments, issueNumber, trustedBotLogin) { + const login = trustedBotLogin.trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9-]{0,38}(?:\[bot\])?$/u.test(login)) + return { state: 'unavailable' }; + const owned = comments.filter(comment => comment.user?.login?.toLowerCase() === login); + const versions = new Map(); + for (const comment of owned) { + const bodies = versions.get(comment.id) ?? new Set(); + bodies.add(comment.body); + versions.set(comment.id, bodies); + } + // Unknown owned comments are potential damaged SDD cards. Unsafe IDs and + // ambiguous duplicate bodies cannot be filtered as another product surface. + const candidates = owned.filter(comment => !Number.isSafeInteger(comment.id) || comment.id < 1 + || versions.get(comment.id).size !== 1 || !isOtherOwnedPublication(comment.body, issueNumber)); + if (!candidates.length) + return { state: 'absent' }; + if (candidates.some(comment => !Number.isSafeInteger(comment.id) || comment.id < 1)) + return { state: 'invalid' }; + const latestId = Math.max(...candidates.map(comment => comment.id)); + const latest = candidates.filter(comment => comment.id === latestId); + const bodies = new Set(latest.map(comment => comment.body)); + if (bodies.size !== 1) + return { state: 'invalid' }; + const body = latest[0].body; + if (typeof body !== 'string') + return { state: 'invalid' }; + if ([...body.matchAll(/ B[Read latest gate record] + B --> C{Validate ownership, schema, identity, freshness} + C -->|Valid| D[Derive readiness fields] + C -->|Missing or invalid| E[Render unknown or unverified] + D --> F[Append localized read-only section] + E --> F + F --> G[Return existing snapshot plus readiness] +``` + +Text equivalent: the command reads and validates the latest gate evidence, derives readiness or an honest unavailable/unverified state, then returns the existing snapshot with a localized section and no mutations. + +## 6. Functional behavior and state model + +### 6.1 Happy path + +1. The existing status entrypoint receives the issue request and builds the existing snapshot. +2. A typed application port retrieves the latest candidate Action-owned gate record for that issue. +3. A pure validator checks version, ownership, schema, and current issue identity, including the stated record fields: `issueNumber`, `phase`, `plan.path`, `plan.capabilityId`, `plan.questions`, `answers`, `branchName`, `baseSha`, `commitSha`, `revisionBaseSha`, and `revisionSha`. +4. The application derives required status from existing issue classification and gate policy; it does not introduce a classification rule or flag. It derives pending IDs only from current validated questions minus valid authorized answers. +5. For published evidence, the application reports `revisionSha` if present, otherwise `commitSha`, only when existing publication verification proves exact linked remote branch, recorded base ancestry, and an owning SDD change. The first `commitSha` remains immutable. +6. The presenter adds a compact readiness section through existing catalogs and returns all existing status fields unchanged. + +### 6.2 Alternative paths + +- Help: show “Specification not required / Especificación no obligatoria”; do not create or link a branch. +- Required gate record missing: show evidence unavailable/unknown and implementation blocked; do not infer from branch state. +- Malformed, foreign, stale, or internally inconsistent record: show invalid or unverified; do not guess question IDs or commit. +- Valid unanswered questions: show IDs and “Awaiting specification answers”. +- Valid required record with no verified published SHA: show first publication pending where applicable; do not claim ready. +- Retrieval failure: preserve the original snapshot and show unavailable/unverified readiness. +- Exempt non-help issue: reflect the existing gate eligibility decision; no new policy is introduced. + +### 6.3 State machine + +| State | Entered when | User-visible meaning | Allowed next states | Recovery/owner | +|---|---|---|---|---| +| Not required | Existing policy exempts issue, including help | No SDD gate applies | Not required | Existing policy | +| Awaiting answers | Valid record has unanswered current questions | Listed Q IDs need answers | Awaiting publication, invalid/unverified | Issue author/maintainer answers through existing flow | +| Publication pending | Required valid record has no verified published SHA | SDD publication is not verified | Ready, invalid/unverified | Existing gate publication flow | +| Ready | Required evidence and remote publication checks pass | SDD evidence is verified | Revision pending if gate evidence changes | Existing gate flow | +| Unknown | Required evidence absent or retrieval unavailable | Readiness cannot be established | Any state on later status read | Retry status / inspect gate | +| Invalid/unverified | Evidence fails validation or verification | Do not proceed on this evidence | Any state after corrected authoritative evidence | Maintainer/operator resolves gate record | + +Duplicate status requests repeat the read and produce no write or new card. Superseded question rounds are ignored. Out-of-order or inconsistent records fail closed to unknown/invalid rather than merging. Status does not cancel or advance gate operations. + +## 7. User-facing configuration + +No new configuration is introduced. Existing issue classification, gate policy, locale selection, and command admission remain authoritative. + +| Input | Type | Recommended default | Allowed values/range | Scope/persistence | +|---|---|---|---|---| +| Existing issue classification | Existing policy value | Existing repository behavior | Existing bounded policy | Current issue/gate policy | +| Existing locale selection | Existing locale | Existing behavior and fallback | Supported existing locales, including English and Spanish | Existing status presentation | + +No new precedence, migration, or retired value is applicable. Status MUST NOT add a flag to select evidence sources or weaken validation. English and Spanish catalogs must both contain complete readiness messages. + +## 8. Clean Architecture design + +### 8.1 Responsibilities and dependency direction + +| Layer/boundary | Owns | Must not own/import | +|---|---|---| +| Domain/pure policy | Evidence validity, question subtraction, readiness state, SHA selection preconditions | GitHub SDK, UI, process code | +| Application | Status orchestration and immutable readiness result | Provider DTOs and rendering details | +| Semantic ports | Read latest gate evidence; verify publication facts | Octokit/Git shapes | +| Adapters/data | Retrieve records and map provider results/errors | Eligibility or readiness policy | +| Infrastructure/composition | Wire existing provider and catalogs | Product decisions | +| Entrypoints | Parse existing command and pass issue context | Duplicate gate logic | +| Presentation | Localized readiness text appended to existing snapshot | Mutations or evidence validation | + +```mermaid +flowchart LR + E[Status entrypoint] --> A[Status application use case] + A --> P[Pure readiness policy] + A --> R[Gate evidence read port] + A --> V[Publication verification port] + A --> C[Existing message catalogs] + X[Provider adapters] --> R + X --> V + A --> O[Existing status response] +``` + +Text equivalent: the existing status use case calls typed read and verification ports, applies pure readiness policy, then passes a view model to the existing catalog-backed presenter. + +### 8.2 Contracts, state, and trust boundaries + +- Pure decisions: validate the candidate record against current issue identity; derive pending IDs from current questions and valid authorized answers; choose revision SHA over first SHA only after proof; map evidence to ready, pending, unknown, or invalid. +- Application contracts: immutable issue identity and existing snapshot input; typed `SddReadiness` result containing requirement, owner path, pending IDs, verified SHA, and evidence state. +- Semantic ports: `readLatestValidSddGateEvidence(issueIdentity)` and existing publication proof query, without provider-specific DTOs. +- Durable state/schema ownership: the existing gate owns `copilot:sdd-gate:v1`; the status command is a read-only consumer and creates no new durable state. +- Concurrency/idempotency: status is a snapshot read; each request validates current evidence. It neither combines rounds nor writes markers. Maintainer authorization is memoized by case-insensitive account only within one read, shared between retained and live answers. At most 16 distinct maintainer accounts are checked; an exhausted bound or failed permission read makes evidence unavailable. Issue-author identity checks consume no permission lookup. No grant survives into another request. +- Trusted and untrusted inputs: issue text, human comments, arbitrary markers, and provider payloads are untrusted. Only validated Action-owned durable evidence and existing authorization/publication proofs count. +- Provider error mapping: retrieval failure yields unavailable/unknown; invalid schema or identity yields invalid/unverified. Errors do not discard the existing status snapshot or expose raw exceptions. + +### 8.3 Executable architecture constraints + +- Readiness policy modules MUST have no provider, rendering, or process imports; enforce through existing dependency rules. +- The status use case MUST depend on typed semantic ports; adapter contract tests validate field mapping and error conversion. +- Tests MUST assert no write-capable port is called and no status card is created. +- Catalog completeness checks MUST parse both supported message catalogs and verify every required message key. + +## 9. UI/UX and content contract + +### 9.1 Information hierarchy + +Keep the existing status content and ordering intact. Add a compact SDD readiness section after the existing snapshot fields, using existing localized status formatting. It presents requirement, owning path, pending IDs, and verified commit when established; otherwise it clearly identifies awaiting answers, publication pending, unavailable, or invalid/unverified. No new comment or timeline card is created. + +### 9.2 Representative primary view + +Pending answers, English: + +```markdown +## SDD readiness +- **Specification:** Required +- **Owning SDD:** `specs/pre-branch-sdd-gate.md` +- **Pending questions:** Q2, Q4 +- **Status:** Awaiting specification answers +``` + +Pending publication, Spanish: + +```markdown +## Preparación del SDD +- **Especificación:** Obligatoria +- **SDD responsable:** `specs/pre-branch-sdd-gate.md` +- **Preguntas pendientes:** Ninguna +- **Estado:** Primera publicación del SDD pendiente +``` + +Blocked/unavailable: + +```markdown +## SDD readiness +- **Specification:** Required +- **Owning SDD:** Unavailable +- **Status:** Specification evidence unavailable +- **Impact:** Implementation readiness cannot be established. +``` + +Partially complete/unverified: + +```markdown +## SDD readiness +- **Specification:** Required +- **Owning SDD:** `specs/pre-branch-sdd-gate.md` +- **Verified SDD commit:** Unverified +- **Status:** Specification evidence invalid or unverified +``` + +Completed: + +```markdown +## SDD readiness +- **Specification:** Required +- **Owning SDD:** `specs/pre-branch-sdd-gate.md` +- **Pending questions:** None +- **Verified SDD commit:** `a1b2c3d4` +- **Status:** Specification ready +``` + +Help issue: + +```markdown +## SDD readiness +- **Specification:** Not required +- **Status:** Specification not required +``` + +### 9.3 Issue, PR, and comment behavior + +- Deterministic section title and existing status response location; no new durable marker is required unless the existing renderer requires one, in which case it must not create a separate card. +- Repeated status requests are read-only and do not post duplicate timeline cards. +- Labels remain supplemental existing fields and do not determine readiness. +- The owner path and verified SHA are shown as safe text or descriptive links using existing formatting; exact URL construction follows existing repository conventions. +- Existing command grammar and lifecycle remain unchanged. + +### 9.4 Accessibility, localization, and responsive behavior + +Use complete English and Spanish catalogs with the exact approved message pairs: Specification required / Especificación obligatoria; Specification not required / Especificación no obligatoria; Owning SDD / SDD responsable; Pending questions / Preguntas pendientes; Verified SDD commit / Commit del SDD verificado; Awaiting specification answers / Esperando respuestas de la especificación; First SDD publication pending / Primera publicación del SDD pendiente; Specification ready / Especificación lista; Specification evidence unavailable / Evidencia de la especificación no disponible; Specification evidence invalid or unverified / Evidencia de la especificación no válida o sin verificar. Use existing locale selection and fallback. Use semantic headings and short lists, readable on mobile and light/dark themes. Escape paths and values; do not render arbitrary comment text or untrusted Markdown. + +## 10. Failure, recovery, and cleanup + +| Failure/partial state | User impact | Retained facts | Automatic retry | Required action | Cleanup | +|---|---|---|---|---|---| +| Gate evidence absent, required | Readiness unknown | Original status snapshot | On next status request | Inspect gate status/continue existing flow | None | +| Retrieval error | Readiness unavailable | Original status snapshot | Next request/provider retry policy | Retry or inspect provider status | None | +| Malformed/foreign/stale record | Readiness invalid | Original snapshot and safe diagnostic code | No guessed recovery | Maintainer/operator resolve authoritative record | No record mutation by status | +| Publication proof unavailable | Not ready | Valid gate fields, no verified SHA | Next read when proof available | Complete/reconcile existing publication | None | +| Catalog message missing | Existing response must remain safe | Existing snapshot | Normal deployment repair | Restore catalog completeness | None | + +Errors follow impact, cause, action, retained state. A readiness read failure never implies the existing status snapshot failed and never changes issue state. + +## 11. Security, permissions, and privacy + +1. The command uses read-only access for issue and gate evidence; no write credential or mutation port is available to the status use case. +2. Treat issue/comment text and records as untrusted. Validate bot ownership, schema, version, and issue identity before use; never infer from arbitrary comments. +3. Do not expose secrets or raw provider exceptions. Render only validated path, IDs, and commit SHA. +4. Reject foreign, stale, forged, and inconsistent evidence; do not interpret branch presence or phase text as proof. + +## 12. Observability and operational UX + +- User-facing state: compact readiness state alongside the existing snapshot. +- Job Summary/operator evidence: retain existing status diagnostics; readiness may record bounded reason codes, not raw issue text. +- Logs, metrics, and correlation: use existing request correlation; no new durable data or metrics are required by this scope. +- Pending external dependency versus workflow failure: unavailable evidence is shown distinctly from valid pending questions. +- Rate-limit/retry behavior: map provider retrieval errors to unavailable while preserving the snapshot; follow existing provider retry policy. +- Noise/comment budget: zero additional cards or notifications from status. + +## 13. Compatibility, migration, rollout, and rollback + +No schema or configuration migration is introduced. Existing gate records remain authoritative and are read using their current version and validation rules. Existing fields, order, command grammar, supported locales, lifecycle, labels, and finding counts remain compatible. Deploying the change adds one section; rollback removes that section without affecting gate records or branches. No rollout flag is supported or needed per the supplied scope. + +## 14. Testing strategy and numeric budget + +Minimum: **195 distinct cases** for this extension. Existing gate and PR status cases are retained but not counted again here. + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Existing basic status presentation | 30 | Original issue readiness, unavailable/not-required and bilingual render regression cases | +| Issue identity | 8 | Canonical visible body/title normalization, profile, kind, issue number and original base digest | +| Owned card selection | 28 | Exact bot ownership, highest comment identity, corruption without older fallback, duplicate markers, safe IDs and retained answer IDs | +| Authority/application/fences | 62 | Immutable first/revision proof, catalog ownership, current authorized answers, partial inventory, issue/card/label/head races, no write capabilities, status integration and both pending-revision outcomes | +| Closed projection/UX | 60 | Canonical requirement/configuration, eight evidence outcomes, explicitly historical first publication during a pending revision and alongside a different verified revision, malformed proof, safe same-issue links, bounded IDs and both bundled locales | +| Native linked ref adapter | 4 | Stale/deleted refs, foreign/missing repository identity; none may fall back to another PR | +| Immutable workspace reads | 2 | Read the original catalog without checkout/current-file mutation; reject non-SHA identifiers | +| Read-only composition | 1 | Repository-bound live publication query and current maintainer authorization without mutation capability | +| **Total** | **195** | No case counted twice | + + +Maintain repository-wide thresholds. New pure readiness policy should meet at least 95% branch coverage; changed status modules should meet existing module thresholds, with a target of 90% branches and 95% lines/statements. Use deterministic records, clocks, provider fakes, and publication proofs; no sleeps or live services. Parse catalog structures and gate records rather than grep prose. UI fixtures require semantic assertions beyond snapshots. Manual acceptance confirms the section is readable on mobile and desktop and preserves the existing status ordering. + +## 15. Documentation and discoverability + +| Audience | Artifact/page | Required content | Validation/navigation | +|---|---|---|---| +| Issue author/maintainer | Existing status command documentation, including `docs/issues/comment-commands.mdx` if it owns this command | Readiness fields and meanings of unknown, invalid, pending answers, and verified commit | Existing docs link/navigation checks | +| Operator | Existing workflow/status guidance | Evidence source, fail-closed behavior, and recovery through existing gate flow | Contract fixture for examples | +| Contributor | `specs/pre-branch-sdd-gate.md` and status implementation guidance | Read-only consumer boundary and verified SHA conditions | Specification validation and architecture checks | + +Keep examples synchronized with catalog fixtures. No new guide is mandated if existing command documentation can be updated clearly. + +## 16. Acceptance scenarios + +1. Given a valid required gate record with unanswered current questions, when `/copilot status` runs, then it shows required, the owning path, and exactly the unanswered current Q IDs. +2. Given authorized answers to all current questions, when status runs before verified publication, then it shows no pending IDs and first publication pending; it does not claim ready. +3. Given valid publication proof on the exact linked remote branch, from the recorded base, changing the owning SDD, when status runs, then it shows `revisionSha` if present, otherwise immutable `commitSha`, as verified. +4. Given only a branched issue or `published` phase without publication proof, when status runs, then it reports unverified and implementation readiness is blocked. +5. Given missing evidence for a required SDD, when status runs, then readiness is unknown/unavailable and existing status fields remain present and unchanged. +6. Given malformed, foreign, stale, or inconsistent evidence, when status runs, then it reports invalid/unverified and does not guess owner, IDs, or SHA. +7. Given an issue classified by existing policy as not requiring an SDD, including help, when status runs, then it says not required; help remains branchless. +8. Given a provider retrieval error, when status runs, then the original snapshot is preserved and readiness is unavailable without a write. +9. Given English or Spanish locale, when status runs in any readiness state, then every readiness label/state is supplied by the corresponding complete catalog. +10. Given repeated status requests, when each request completes, then no issue, label, branch, commit, SDD, or timeline card is created or changed. +11. Given superseded question rounds or arbitrary human comments, when status derives pending IDs, then only current validated gate questions and valid authorized answers are considered. +12. Given existing branch, lifecycle, labels, and finding counts, when the new readiness section is added, then their values and ordering remain compatible. + +## 17. Requirements traceability + +| Requirement | Policy/use case/adapter/presentation | Test or evidence | Documentation | +|---|---|---|---| +| Read latest Action-owned evidence | Evidence port and validator | Ownership/schema/identity cases | Status command docs | +| Derive pending IDs from current round | Pure readiness policy | Superseded and answer matrix | Status command docs | +| Prove publication before SHA | Verification port and SHA selector | Exact branch/base/path/revision cases | Gate SDD | +| Fail closed on missing/invalid evidence | Application result mapping | Missing, malformed, foreign, stale cases | Operator guidance | +| Preserve snapshot and read-only behavior | Status use case and port set | Snapshot and no-write integration cases | Status command docs | +| Localize complete output | Catalog-backed presenter | English/Spanish key and state coverage | Status command docs | +| Preserve help branchlessness and existing fields | Existing policy/renderer integration | Compatibility scenarios | Existing lifecycle docs | + +## 18. Implementation sequence + +1. Locate the existing status use case, evidence schema/validator, publication verification, and English/Spanish catalogs; preserve the owning gate contract. +2. Define typed readiness result and semantic read/verification ports; add pure policy tests for ownership, identity, pending IDs, and state mapping. +3. Integrate read-only orchestration into status while retaining the original snapshot on retrieval errors. +4. Add catalog messages and compact rendering for all states; ensure no timeline-card path is invoked. +5. Add adapter, integration, localization, security, and compatibility tests within the 195-case implementation budget. +6. Update command/operator documentation and contract examples. +7. Run specification, architecture, catalog, and repository validation required by the existing project workflow. + +## 19. Definition of Done + +- [x] Required SDD evidence is shown as unavailable/unverified until authoritative evidence can be verified; status never infers readiness from a marker or branch. +- [x] `/copilot status` remains read-only and preserves all existing fields and ordering. +- [x] English and Spanish catalogs cover the readiness section. +- [x] Typed application ports and Clean Architecture boundaries are enforced by tests/checks. +- [x] All 195 distinct extension cases, full coverage budgets and generated bundles pass before publication. +- [x] User documentation explains the evidence limit and fail-closed behavior. +- [x] Existing command grammar, lifecycle, labels, finding counts, and gate record ownership remain compatible. +- [x] `specs/catalog.json` evidence and generated `specs/CATALOG.md` remain current; `pnpm run validate:specifications` passes. + +- [ ] Live runtime acceptance after promotion verifies published, awaiting, stale and unavailable outcomes on GitHub. + +## 20. References and decisions + +- Owning SDD: `specs/pre-branch-sdd-gate.md`, capability `issue-start-and-sdd-readiness`. +- Issue: #454, dogfooding status readiness request. +- Decisions from explicit answers: use existing feature/contract-change classification; latest Action-owned `copilot:sdd-gate:v1` record only; derive question IDs only from current validated questions and authorized answers; verified revision SHA takes precedence over immutable first commit SHA; help is exempt and branchless; use approved English/Spanish catalog wording; preserve existing status fields and make retrieval failure non-destructive. +- Rejected: inferring readiness from branch existence or `published` phase alone; parsing arbitrary human comments; combining superseded clarification rounds; adding writes, cards, configuration, or locale support. +- Follow-up: exact code and catalog paths are to be resolved during implementation discovery; no product decision remains open. + +### As-built evidence and authority limits + +The highest owned SDD-card ID is selected before schema validation. A newer +corrupted card cannot revive an older ready record. Stored answers count only +with exact current round and question-text provenance plus current role +authorization; ordinary live answers must follow the card/retained-answer +cutoff. Every ready SHA has first-publication parent/SDD proof, immutable +catalog ownership and live same-repository branch ancestry. Revisions retain +the first publication and prove their own recorded base and owning path. +Final issue title/body, labels, owned card and remote-head reads can downgrade +the result to unverified. Status does not expose question text, raw failure +reasons, model capabilities or mutation ports. Immutable catalog reads may +fetch Git objects locally but never checkout, commit, push or rewrite source. +The four native-ref regressions and two workspace-read cases above belong to +this extension; the gate's existing numeric budget does not count them again. + +### Offline implementation validation — 2026-10-11 + +The initial implementation comprised 141 distinct extension cases. The first review added eight cases (149), the publication-history correction added 21 (170), and the bounded-authorization/stale-history correction added 11 (181). These increments are described below and are not recounted as shared gate tests. The current 181-case implementation was verified by the 570-suite, 7,594-pass run documented under “Bounded permission reads and stale-publication history”. Initial full validation on the Action-managed head `43d710770972df3443b519ca0bd61c6ad6256c93` plus this contribution passed 567 Jest suites with 7,485 tests passed and 35 configured skips. Repository coverage budgets and the setup acceptance ledger passed; the ledger retains its 42 explicitly open cases. New identity and card policies and the read-only composition have 100% coverage; projection branch coverage is 96% and query branch coverage is 97.36%. Typecheck, lint, documentation, specification validation and generated bundles passed. Live acceptance remains pending promotion and is not inferred from these offline checks. + +### PR #465 review corrections (2026-10-11) + +The review identified two status defects. Evidence navigation now compares an +issue URL against the trusted workflow server origin supplied by the read-only +composition, defaulting to github.com. Matching owner/repository/issue paths on +another origin cannot produce links. Independently configured GitHub Enterprise +origins remain supported; unsafe server configuration suppresses navigation +without changing the evidence state or exposing its value. + +An issue with unknown admitted workflow kind now retains the SDD section with +unknown requirement and unavailable evidence. PR and push targets do not infer +issue eligibility from a coincidental kind. No model or new mutation capability +is introduced. Eight distinct cases extend the presentation allocation: three +foreign-origin URLs, configured Enterprise navigation, credential-bearing server +configuration, missing issue admission, and PR/push compatibility. + +The initial autofix run 38146515770 admitted the Windows agent but produced no +safe changed paths. These corrections are a normal contributor response on the +exact Action branch and are not evidence that the authenticated fixer succeeded. +Live recheck and native status acceptance remain pending. + + +### Native dogfooding evidence after parent synchronization + +Action run `38166458576` on `windows-intel-runner-1` completed 570 suites and 7,576 tests with 20 platform skips, then published synchronized head `3edeb4da098712385c9176c918d3dd0814883296` and its correlated receipt for comment `6112587961`. Its native PowerShell hash-module and clean-checkout fixtures passed in 1.086, 1.982 and 1.828 seconds respectively, with unchanged isolation and budgets. The synchronized bundles validate locally. This establishes native verification and an actual pushed branch, not Windows agent writing or live status acceptance after promotion. User documentation explicitly separates SDD readiness from implementation completion, CI/review and merge authorization. + +### Publication history and pending-revision correction from dogfooding + +The original progress comparison exposed the missing revision-pending view. Subsequent real Bugbot findings `4242981181` and `4242981184` identified loss of the first publication alongside a verified revision and an obsolete implementation claim in the owning gate SDD. The projection now retains an independently verified immutable first SHA: pending revisions label it as historical and withhold current readiness; verified revisions show current and first publications separately only when they differ. Current pending question identifiers remain visible. Absent, stale, invalid or denied evidence never promotes historical publication to current readiness. Independently verified first-publication evidence may remain explicitly historical after an issue-body revision; a failed final identity fence removes that proof entirely. The owning gate SDD and user documentation describe the actual implementation while keeping post-promotion acceptance explicitly open. + +Twenty-one distinct new contracts cover both pending phases, incomplete publication metadata, unknown requirement, bilingual history, invalid navigation, malformed projected evidence, deduplication of identical publication links and real status integration. The extension budget increases from 149 to 170 without recounting shared gate tests. Full local verification passed 570 suites and 7,583 tests with 35 existing platform skips (7,618 total). The changed projection has 100% lines/statements/branches/functions; all existing budgets, typecheck, lint, specifications/catalog, documentation, graph update and compilation passed. Fresh independent review and live command/replay checks still require the published implementation and promotion. + + +### Bounded permission reads and stale-publication history + +Fresh native review on head `bad9f8a2cbc21deabc505cd2356937530cb9da2c` identified `4243078289` and `4243078290`. The reader now checks each maintainer account once per request, with a 16-account maximum and no cross-request permission cache. It preserves an independently verified first publication as historical revision-pending evidence when the issue body has changed. Failed final fences, missing first-publication proof and mismatched specification paths cannot restore history or certify current readiness. + +Eleven new distinct contracts cover permission reuse across case variants, questions and retained/live answers; fresh reauthorization; the exact account bound and exhaustion; denied-read retry; issue-author identity without permission requests; bilingual stale history; loss of final proof; and foreign specification paths. The extension budget increases from 170 to 181 without recounting shared gate tests. Focused verification passed 117 cases. Full local verification passed 570 suites and 7,594 tests with 35 configured skips (7,629 total). The projection has 100% coverage; the evidence reader has 99.01% lines, 96.96% statements, 97.43% branches and 100% functions, satisfying unchanged coverage budgets. Typecheck and lint passed. Fresh review and live command acceptance remain required after publication and promotion. + + +### Complete native re-review with unchanged bounds + +Native review `38173590725` attempt 1 returned partial human-conversation coverage because the author's clarification comment `6102272561` was 4,074 characters, just over the existing 4,000-character per-item limit. The neutral Check and aggregate card explicitly withheld a clean-PR claim and retained the two open findings. The author condensed only redundant phrasing in that same clarification to 3,981 normalized characters, preserving every Q1–Q8 requirement, numeric budget and template heading. The issue state/title/labels and the exact feature ref SHA remained unchanged. + +Attempt 2 re-read the same implementation head `14a539a09b9a8e03ce5f8b08116417db2e95e7b9` with complete human coverage. It resolved both findings and published complete approval evidence with zero open, reopened, dismissed, verification-required or unknown findings. All six findings from this feature's review are now resolved by the Action. The prior CI rejection was a final blank line in a shared SDK ZIP fixture from default source `972e11d52dee5d6bb00df4b0b3d4ab262751b101`; default head `3d906a34218ecc7988354bcf3843db1f19a78b23` corrects that whitespace. Fresh CI of this contribution and live status acceptance remain separate gates. No conversation limit or review guard was relaxed. + +### Reconciled case-count evidence + +Native finding `4243268247` identified that the initial 141-case verification paragraph could be read as the current implemented total. The evidence now labels that historical baseline explicitly and accounts for all later increments: 141 + 8 + 21 + 11 = 181 distinct extension cases. This documentation correction introduces no new test or runtime behavior and preserves the separate pending live-acceptance gate. + +### Complete card-body loss and current authority budget + +Native finding `4243323229` exposed fallback to an older record when a newer +bot-owned SDD card lost both its metadata marker and heading. Selection now +retains unidentified owned comments as potential damaged cards and orders them +by stable native comment ID before parsing their body. A newer unknown, empty +or null body blocks older readiness. Unsafe native IDs and ambiguous duplicate +bodies cannot be excluded as another product surface; the original safe-ID +and latest-ID contradiction guards remain intact. A failed final selection +fence drops all publication proof. + +The reader excludes only one canonical header for a different, typed product +publication (status, reply or transition), belonging to the exact same issue +and containing no SDD metadata or heading anywhere. Quoted, foreign-target, +multiple or malformed headers cannot hide unknown ownership. This conservative +rule may withhold readiness for an unclassified bot message; a maintainer must +inspect and reconcile the damaged conversation. It adds no stored registry, +mutable identity hint, mutation capability, model call or authorization bypass. +An unclassified bot answer still cannot satisfy a blocking question and now +invalidates ambiguous ownership rather than exposing an older pending view. + +Twelve new pure authority cases cover full identifier loss, three other +canonical surfaces, foreign/quoted identities, duplicate native-ID ambiguity, +embedded SDD corruption and a newer valid replacement. Two application cases +cover initial selection and final-fence loss. The current extension budget is +181 + 12 + 2 = 195, distributed in section 14. The historical 30 presentation +cases remain a subset of this total; the gate's own budget is separate. Live +command acceptance remains pending promotion. + +Full local validation of this correction passed 570 suites, 7,608 tests and 35 existing platform skips (7,643 total). The card selection policy has 100% statement/branch/function coverage and the evidence reader has 96.97% statements, 97.44% branches and 100% functions; all existing coverage budgets passed. Typecheck, lint, specification and documentation validation passed. Fresh native review and live command acceptance remain independent gates. diff --git a/src/application/policies/__tests__/sdd_issue_identity_policy.test.ts b/src/application/policies/__tests__/sdd_issue_identity_policy.test.ts new file mode 100644 index 000000000..db4f06eeb --- /dev/null +++ b/src/application/policies/__tests__/sdd_issue_identity_policy.test.ts @@ -0,0 +1,28 @@ +import { createHash } from 'node:crypto'; +import { buildSddIssueDigest } from '../sdd_issue_identity_policy'; + +const identity = { issueNumber: 42, issueTitle: 'Change payments', issueBody: 'Preserve verified callers.', admittedKind: 'feature', profileDigest: 'profile' }; +const base = 'a'.repeat(40); + +describe('shared SDD issue identity', () => { + it('retains the legacy empty-profile digest without substituting an unrelated profile', () => { + expect(buildSddIssueDigest({ ...identity, profileDigest: undefined }, base)).toBe(buildSddIssueDigest({ ...identity, profileDigest: '' }, base)); + expect(buildSddIssueDigest({ ...identity, profileDigest: undefined }, base)).not.toBe(buildSddIssueDigest(identity, base)); + }); + it('retains byte-for-byte compatibility with already published gate digests', () => { + expect(buildSddIssueDigest(identity, base)).toBe(createHash('sha256').update(JSON.stringify([ + 42, 'Change payments', 'Preserve verified callers.', 'feature', 'profile', base, + ])).digest('hex')); + }); + it('ignores the Action title decoration without ignoring a human title edit', () => { + expect(buildSddIssueDigest({ ...identity, issueTitle: '✨ - Change payments' }, base)).toBe(buildSddIssueDigest(identity, base)); + expect(buildSddIssueDigest({ ...identity, issueTitle: 'Change authorization' }, base)).not.toBe(buildSddIssueDigest(identity, base)); + }); + it.each(['issueNumber', 'issueBody', 'admittedKind', 'profileDigest'] as const)('binds the digest to the current %s', field => { + const changed = { ...identity, [field]: field === 'issueNumber' ? 43 : 'changed' }; + expect(buildSddIssueDigest(changed, base)).not.toBe(buildSddIssueDigest(identity, base)); + }); + it('binds the digest to the immutable first base rather than a moving development tip', () => { + expect(buildSddIssueDigest(identity, 'b'.repeat(40))).not.toBe(buildSddIssueDigest(identity, base)); + }); +}); diff --git a/src/application/policies/__tests__/sdd_query_card_policy.test.ts b/src/application/policies/__tests__/sdd_query_card_policy.test.ts new file mode 100644 index 000000000..cbd5efc1a --- /dev/null +++ b/src/application/policies/__tests__/sdd_query_card_policy.test.ts @@ -0,0 +1,80 @@ +import { renderSddGateRecord, type SddGateRecord } from '../../../domain/pre_branch_sdd'; +import { buildPublicationMarker, buildPublicationReplyMarker, buildPublicationTransitionMarker } from '../publication_identity_policy'; +import { selectOwnedSddCard, type SddQueryComment } from '../sdd_query_card_policy'; + +const record: SddGateRecord = { version: 1, issueNumber: 42, phase: 'published', issueDigest: 'd'.repeat(64), + baseSha: 'a'.repeat(40), round: 1, branchName: 'feature/42', commitSha: 'b'.repeat(40), + plan: { action: 'update', path: 'specs/payments.md', capabilityId: 'payments', + reason: 'This capability owns the payment workflow contract.', questions: [] } }; +const body = renderSddGateRecord(record); +const card = (id: number, content = body, login = 'vypbot'): SddQueryComment => ({ id, body: content, user: { login } }); +const select = (comments: readonly SddQueryComment[], bot = 'vypbot') => selectOwnedSddCard(comments, 42, bot); + +describe('owned SDD card authority', () => { + it('rejects retained answer IDs that cannot be compared safely with live comments', () => { + const unsafe = renderSddGateRecord({ ...record, answers: [{ questionId: 'Q1', author: 'author', commentId: Number.MAX_SAFE_INTEGER + 1, text: 'Choice' }] }); + expect(select([card(7, unsafe)])).toEqual({ state: 'invalid' }); + }); + it('reports absence from an empty complete comment inventory', () => expect(select([])).toEqual({ state: 'absent' })); + it('accepts only the exact configured login with GitHub case semantics', () => { + expect(select([card(7, body, 'VypBot')])).toMatchObject({ state: 'valid', id: 7, record }); + }); + it.each(['human', 'foreign[bot]'])('ignores copied or malformed metadata by %s', login => { + expect(select([card(7), card(8, '', login)])).toMatchObject({ state: 'valid', id: 7 }); + }); + it('never falls back after the latest owned metadata becomes malformed', () => { + expect(select([card(7), card(8, '')])).toEqual({ state: 'invalid' }); + }); + it('never falls back after a newer owned record claims a different issue', () => { + expect(select([card(7), card(8, renderSddGateRecord({ ...record, issueNumber: 43 }))])).toEqual({ state: 'invalid' }); + }); + it.each([body, renderSddGateRecord({ ...record, commitSha: 'c'.repeat(40) })])('rejects multiple markers within one owned card', duplicate => { + expect(select([card(7, `${body}\n${duplicate}`)])).toEqual({ state: 'invalid' }); + }); + it('rejects contradictory representations of one latest comment ID', () => { + expect(select([card(7), card(7, renderSddGateRecord({ ...record, commitSha: 'c'.repeat(40) }))])).toEqual({ state: 'invalid' }); + }); + it('collapses byte-identical repetitions of the same provider comment', () => { + expect(select([card(7), card(7)])).toMatchObject({ state: 'valid', id: 7 }); + }); + it.each([0, -1, Number.MAX_SAFE_INTEGER + 1])('cannot order an owned card with unsafe ID %s', id => { + expect(select([card(7), card(id)])).toEqual({ state: 'invalid' }); + }); + it('does not infer bot authority from a bot suffix when configuration is absent', () => { + expect(select([card(7, body, 'vypbot[bot]')], '')).toEqual({ state: 'unavailable' }); + }); + it('recognizes a latest localized owned card that lost its marker as invalid', () => { + expect(select([card(7), card(8, '## Estado del SDD\n\nMetadata unavailable.')])).toEqual({ state: 'invalid' }); + }); +}); + +const otherPublication = (kind: 'status' | 'reply' | 'transition', issueNumber = 42) => { + const identity = { topic: 'branch-sync' as const, target: { kind: 'issue' as const, number: issueNumber }, key: 'dependency:abc' }; + if (kind === 'status') return buildPublicationMarker({ identity, sourceVersion: 'comparison:abc', digest: '12345678' }); + if (kind === 'transition') return buildPublicationTransitionMarker({ identity, fingerprint: '12345678', messageKey: 'branchSync.transition.required' }); + return buildPublicationReplyMarker({ target: `issue:${issueNumber}`, correlationId: 'comment:9', messageKey: 'status-command', digest: '12345678' }); +}; + +describe('native identity survives complete SDD-card body loss', () => { + it.each([null, '', 'Both the metadata and the heading have been removed.'])('never revives an older publication behind a newer unidentified owned body: %s', content => { + expect(select([card(7), { ...card(8), body: content }])).toEqual({ state: 'invalid' }); + }); + it.each(['status', 'reply', 'transition'] as const)('preserves readiness after a canonical exact-issue %s publication', kind => { + expect(select([card(7), card(8, `${otherPublication(kind)}\n\nAnother product surface.`)])).toMatchObject({ state: 'valid', id: 7 }); + }); + it.each(['foreign issue', 'foreign PR', 'quoted header'])('does not use a %s marker to exclude unknown newer ownership', scenario => { + let newer = otherPublication('reply', scenario === 'foreign issue' ? 43 : 42); + if (scenario === 'foreign PR') newer = newer.replace('target="issue:42"', 'target="pr:42"'); + if (scenario === 'quoted header') newer = `Quoted content:\n${newer}`; + expect(select([card(7), card(8, newer)])).toEqual({ state: 'invalid' }); + }); + it('rejects contradictory native IDs before excluding a canonical other surface', () => { + expect(select([card(7), card(8, 'Damaged card'), card(8, otherPublication('reply'))])).toEqual({ state: 'invalid' }); + }); + it('never masks SDD metadata behind a canonical other publication header', () => { + expect(select([card(7), card(8, `${otherPublication('reply')}\n`)])).toEqual({ state: 'invalid' }); + }); + it('allows a newer identified SDD card to supersede an older unknown owned comment', () => { + expect(select([card(6, 'Older damaged card'), card(7)])).toMatchObject({ state: 'valid', id: 7 }); + }); +}); diff --git a/src/application/policies/__tests__/sdd_status_policy.test.ts b/src/application/policies/__tests__/sdd_status_policy.test.ts new file mode 100644 index 000000000..387d8dbc9 --- /dev/null +++ b/src/application/policies/__tests__/sdd_status_policy.test.ts @@ -0,0 +1,177 @@ +import { initialSddStatus, projectSddStatus, formatSddStatus } from '../sdd_status_policy'; +import type { SddEvidenceQueryContext } from '../../ports/sdd_evidence_read_ports'; +import { resolveStaticPublicationCatalog } from '../publication_message_catalog'; + +const sha = 'a'.repeat(40); +const context: SddEvidenceQueryContext = { issueNumber: 42, admittedKind: 'feature', issueManagedBranches: true, preBranchSdd: true, issueAuthor: 'author', trustedBotLogin: 'vypbot' }; +const verified = { path: 'specs/feature.md', capabilityId: 'feature', branchName: 'feature/42', branchHeadSha: sha, firstSha: sha, currentSha: sha }; +const identity = { owner: 'acme', repository: 'demo' }; +const issueUrl = 'https://github.com/acme/demo/issues/42'; +const render = (readiness: ReturnType, url = issueUrl) => formatSddStatus(readiness, resolveStaticPublicationCatalog('en-US').catalog, url, 42, identity).join('\n'); + +describe('SDD status projection and safe navigation', () => { + it.each([ + ['admitted feature', {}, ['in-progress'], true], + ['contract change', { admittedKind: 'bugfix' }, ['in-progress', 'contract-change'], true], + ['output label alone', { admittedKind: 'bugfix' }, ['in-progress', 'SDD'], false], + ['unstarted feature', {}, ['feature'], false], + ['help', { admittedKind: 'help' }, ['in-progress', 'contract-change'], false], + ['disabled gate', { preBranchSdd: false }, ['in-progress'], false], + ['unknown configuration', { preBranchSdd: undefined }, ['in-progress'], null], + ['invalid configuration', { issueManagedBranches: false }, ['in-progress'], null], + ] as const)('uses canonical requirement for %s', (_name, override, labels, required) => { + expect(initialSddStatus({ ...context, ...override }, labels)).toEqual({ required, state: required === false ? 'not-required' : 'unavailable' }); + }); + + it.each([ + ['not-required', 'not-required'], ['absent', 'unavailable'], ['unavailable', 'unavailable'], + ['invalid', 'unverified'], ['stale', 'revision-pending'], ['awaiting-answers', 'revision-pending'], + ['publication-pending', 'revision-pending'], ['published', 'ready'], + ] as const)('projects %s with no historical SHA mistaken for readiness', (state, expected) => { + const status = projectSddStatus({ state, path: verified.path, verified, pendingQuestionIds: ['Q1'], cardId: 7 }, { state: 'unavailable', required: true }); + expect(status.state).toBe(expected); + expect(status.commitSha).toBe(state === 'published' ? sha : undefined); + expect(status.path).toBe(['not-required', 'unavailable', 'unverified'].includes(expected) ? undefined : verified.path); + expect(Object.isFrozen(status)).toBe(true); + }); + + it.each(['awaiting-answers', 'publication-pending'] as const)('retains only immutable first-publication history while %s', state => { + const status = projectSddStatus({ state, path: verified.path, verified: { ...verified, currentSha: undefined }, pendingQuestionIds: ['Q1'] }); + expect(status).toMatchObject({ state: 'revision-pending', firstCommitSha: sha, pendingQuestionIds: ['Q1'] }); + expect(status.commitSha).toBeUndefined(); + }); + it.each(['no proof', 'wrong path', 'abbreviated SHA'])('does not invent revision history with %s', scenario => { + const proof = scenario === 'no proof' ? undefined : { ...verified, + path: scenario === 'wrong path' ? 'specs/foreign.md' : verified.path, + firstSha: scenario === 'abbreviated SHA' ? 'abc123' : sha }; + const status = projectSddStatus({ state: 'publication-pending', path: verified.path, verified: proof }); + expect(status.state).toBe('publication-pending'); expect(status.firstCommitSha).toBeUndefined(); + expect(render(status)).not.toContain('/blob/'); + }); + it.each(['en-US', 'es-ES'])('labels pending revision evidence as history in %s without certifying readiness', locale => { + const status = projectSddStatus({ state: 'awaiting-answers', path: verified.path, + verified: { ...verified, currentSha: undefined }, pendingQuestionIds: ['Q1'], cardId: 7 }); + const body = formatSddStatus(status, resolveStaticPublicationCatalog(locale).catalog, issueUrl, 42, identity).join('\n'); + expect(body).toContain(locale === 'es-ES' ? 'Revisión pendiente' : 'Revision pending'); + expect(body).toContain(locale === 'es-ES' ? '(histórica)' : '(history)'); + expect(body).toContain(`/blob/${sha}/${verified.path}`); expect(body).toContain('Q1'); + expect(body).not.toContain(locale === 'es-ES' ? '**Publicación verificada:**' : '**Verified publication:**'); + expect(body).not.toContain(locale === 'es-ES' ? 'Continúa la implementación' : 'Continue implementation'); + }); + it.each(['missing path', 'missing SHA', 'short SHA'])('withholds malformed revision projection: %s', scenario => { + const body = render({ state: 'revision-pending', required: true, + path: scenario === 'missing path' ? undefined : verified.path, + firstCommitSha: scenario === 'missing SHA' ? undefined : scenario === 'short SHA' ? 'abc123' : sha }); + expect(body).not.toContain('/blob/'); expect(body).not.toContain('(history)'); + expect(body).toContain('readiness is unverified'); + }); + it('offers publication recovery after revision answers are complete', () => { + const body = render(projectSddStatus({ state: 'publication-pending', path: verified.path, verified })); + expect(body).toContain('Resume SDD preparation'); expect(body).not.toContain('Answer these questions'); + }); + it('retains the historical SHA without a link when the issue URL is foreign', () => { + const body = render(projectSddStatus({ state: 'awaiting-answers', path: verified.path, verified }), 'https://attacker.example/acme/demo/issues/42'); + expect(body).toContain(sha); expect(body).toContain('(history)'); expect(body).not.toContain(']('); + }); + it('rejects matching-path revision metadata with a missing first SHA', () => { + const incomplete = { ...verified, firstSha: undefined } as unknown as typeof verified; + const status = projectSddStatus({ state: 'publication-pending', path: verified.path, verified: incomplete }); + expect(status.state).toBe('publication-pending'); expect(status.firstCommitSha).toBeUndefined(); + expect(render(status)).not.toContain('/blob/'); + }); + it('does not certify a published record that only retains the first SHA', () => { + const status = projectSddStatus({ state: 'published', path: verified.path, verified: { ...verified, currentSha: undefined } }); + expect(status.state).toBe('unverified'); expect(status.commitSha).toBeUndefined(); + expect(render(status)).not.toContain('/blob/'); + }); + it('keeps the requirement unknown when unavailable evidence has no admitted initial state', () => { + const status = projectSddStatus({ state: 'unavailable' }); + expect(status).toEqual({ state: 'unavailable', required: null }); + expect(render(status)).toContain('Requirement unavailable'); + }); + it.each(['en-US', 'es-ES'])('retains the immutable first publication alongside a verified revision in %s', locale => { + const current = 'b'.repeat(40); + const status = projectSddStatus({ state: 'published', path: verified.path, verified: { ...verified, currentSha: current } }); + expect(status).toMatchObject({ state: 'ready', commitSha: current, firstCommitSha: sha }); + const body = formatSddStatus(status, resolveStaticPublicationCatalog(locale).catalog, issueUrl, 42, identity).join('\n'); + expect(body).toContain(`/blob/${current}/${verified.path}`); expect(body).toContain(`/blob/${sha}/${verified.path}`); + expect(body).toContain(locale === 'es-ES' ? '(histórica)' : '(history)'); + }); + it('does not duplicate the first publication when it is also the current verified commit', () => { + const status = projectSddStatus({ state: 'published', path: verified.path, verified }); + expect(status.firstCommitSha).toBe(sha); + const body = render(status); expect(body.match(/\/blob\//gu)).toHaveLength(1); expect(body).not.toContain('(history)'); + }); + it('keeps a verified current revision visible without echoing a malformed historical SHA', () => { + const body = render({ state: 'ready', required: true, path: verified.path, commitSha: sha, firstCommitSha: 'PRIVATE-CREDENTIAL' }); + expect(body).toContain(`/blob/${sha}/${verified.path}`); expect(body).not.toContain('PRIVATE-'); expect(body).not.toContain('(history)'); + }); + it.each(['en-US', 'es-ES'])('retains only verified publication history after a stable issue edit in %s', locale => { + const status = projectSddStatus({ state: 'stale', verified: { ...verified, currentSha: 'b'.repeat(40) } }); + expect(status).toMatchObject({ state: 'revision-pending', firstCommitSha: sha, path: verified.path }); expect(status.commitSha).toBeUndefined(); + const body = formatSddStatus(status, resolveStaticPublicationCatalog(locale).catalog, issueUrl, 42, identity).join('\n'); + expect(body).toContain(`/blob/${sha}/${verified.path}`); expect(body).not.toContain(`/blob/${'b'.repeat(40)}/`); + expect(body).toContain(locale === 'es-ES' ? 'Revisión pendiente' : 'Revision pending'); + }); + it('does not revive history after a stale result loses publication proof at its final fence', () => { + const status = projectSddStatus({ state: 'stale' }); + expect(status).toEqual({ state: 'unverified', required: true }); expect(render(status)).not.toContain('/blob/'); + }); + it('does not substitute a mismatched path to restore stale publication history', () => { + const status = projectSddStatus({ state: 'stale', path: 'specs/foreign.md', verified }); + expect(status.state).toBe('unverified'); expect(status.firstCommitSha).toBeUndefined(); expect(render(status)).not.toContain('/blob/'); + }); + + it.each(['no proof', 'wrong path', 'abbreviated SHA'])('rejects a published projection with %s', scenario => { + const proof = scenario === 'no proof' ? undefined : { ...verified, path: scenario === 'wrong path' ? 'specs/foreign.md' : verified.path, currentSha: scenario === 'abbreviated SHA' ? 'abc123' : sha }; + const status = projectSddStatus({ state: 'published', path: verified.path, verified: proof }); + expect(status.state).toBe('unverified'); expect(status.commitSha).toBeUndefined(); + expect(render(status)).not.toContain('/blob/'); + }); + + it.each([ + 'http://github.com/acme/demo/issues/42', 'https://github.com/foreign/demo/issues/42', + 'https://github.com/acme/foreign/issues/42', 'https://github.com/acme/demo/issues/43', + 'https://attacker.example/acme/demo/issues/42', 'https://github.com.attacker.example/acme/demo/issues/42', + 'https://github.com:444/acme/demo/issues/42', 'https://user:secret@github.com/acme/demo/issues/42', 'https://github.com/acme/demo/issues/42?token=secret', 'broken', + ])('omits all evidence links for an invalid issue identity %s', url => { + const status = projectSddStatus({ state: 'published', path: verified.path, verified, cardId: 7 }); + const body = render(status, url); + expect(body).not.toContain(']('); expect(body).not.toContain('secret'); expect(body).toContain(sha); + }); + + it('bounds pending identifiers without disclosing question text and links verified evidence to its own issue', () => { + const pending = projectSddStatus({ state: 'awaiting-answers', path: verified.path, pendingQuestionIds: ['Q1', 'Q1', 'Q9', 'TOKEN=secret'], cardId: 7 }); + expect(Object.isFrozen(pending.pendingQuestionIds)).toBe(true); + const body = render(pending); + expect(body).toContain('**Pending questions:** Q1'); expect(body).not.toMatch(/Q9|TOKEN|secret/u); + expect(body).toContain(issueUrl + '#issuecomment-7'); + const ready = render(projectSddStatus({ state: 'published', path: verified.path, verified })); + expect(ready).toContain('https://github.com/acme/demo/blob/' + sha + '/' + verified.path); + }); + + it.each(['en-US', 'es-ES'])('renders all six closed states using the bundled %s catalog', locale => { + const catalog = resolveStaticPublicationCatalog(locale).catalog; + for (const state of ['not-required', 'unavailable', 'unverified', 'awaiting-answers', 'publication-pending', 'ready'] as const) { + const body = formatSddStatus({ state, required: state !== 'not-required', path: verified.path, commitSha: sha, pendingQuestionIds: ['Q1'] }, catalog).join('\n'); + expect(body).not.toMatch(/interaction\.status|undefined|TOKEN/u); + expect(body).toContain(locale === 'es-ES' ? 'Preparación del SDD' : 'SDD readiness'); + } + }); +}); + + describe('trusted GitHub server navigation', () => { + it('retains GitHub Enterprise links only for the independently configured origin', () => { + const status = projectSddStatus({ state: 'published', path: verified.path, verified, cardId: 7 }); + const body = formatSddStatus(status, resolveStaticPublicationCatalog('en-US').catalog, 'https://github.example/acme/demo/issues/42', 42, { ...identity, serverOrigin: 'https://github.example' }).join('\n'); + expect(body).toContain(`https://github.example/acme/demo/blob/${sha}/specs/feature.md`); + expect(body).toContain('https://github.example/acme/demo/issues/42#issuecomment-7'); + }); + it('omits links when the trusted server configuration has credentials', () => { + const status = projectSddStatus({ state: 'published', path: verified.path, verified, cardId: 7 }); + const body = formatSddStatus(status, resolveStaticPublicationCatalog('en-US').catalog, issueUrl, 42, { ...identity, serverOrigin: 'https://user:secret@github.com' }).join('\n'); + expect(body).not.toContain(']('); + expect(body).not.toContain('secret'); + expect(body).toContain(sha); + }); +}); diff --git a/src/application/policies/__tests__/status_command_policy.test.ts b/src/application/policies/__tests__/status_command_policy.test.ts index b0f3a119f..569ed6a8e 100644 --- a/src/application/policies/__tests__/status_command_policy.test.ts +++ b/src/application/policies/__tests__/status_command_policy.test.ts @@ -195,4 +195,318 @@ describe('status command policy', () => { expect(body).toContain('pull-request / PR #21 / issue #17'); expect(body).toContain('1 abiertos, 2 reabiertos, 3 requieren verificación, 4 desconocidos, 5 resueltos'); }); + // SDD readiness regression budget: 30 independent semantic cases. + it('renders required English heading', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('required'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders required Spanish heading', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('obligatoria'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders not required English', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: false, state: 'not-required' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('not required'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders not required Spanish', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: false, state: 'not-required' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no obligatoria'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders unavailable English', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body.toLowerCase()).toContain('evidence unavailable'); + expect(body.toLowerCase()).toContain('required'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders unavailable Spanish', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body.toLowerCase()).toContain('obligatoria'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 01', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 02', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 03', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 04', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 05', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 06', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 07', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 08', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 09', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 10', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 11', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 12', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 13', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 14', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 15', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 16', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 17', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 18', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 19', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 20', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 21', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 22', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 23', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'en-US'); + expect(body).toContain('## SDD readiness'); + expect(body.toLowerCase()).toContain('unavailable'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); + it('renders readiness remains fail-closed on evidence variant 24', () => { + const body = formatCopilotStatus({ owner: 'acme', repository: 'demo', event: 'issue_comment', action: '', target: 'issue', + issueNumber: 17, issueLabels: [], pullRequestLabels: [], pullRequestDescriptionMode: 'disabled', + sddReadiness: { required: true, state: 'unavailable' }, + }, 'es-ES'); + expect(body).toContain('## Preparación del SDD'); + expect(body.toLowerCase()).toContain('no disponible'); + expect(body).toContain('issue #17'); + expect(body).not.toContain('ready'); + }); +}); + +describe('unknown issue admission status', () => { + it('shows unknown requirement and unavailable SDD evidence for an issue with no admitted kind', () => { + const snapshot = buildCopilotStatusSnapshot(execution({ isIssue: true, isPullRequest: false, issueNumber: 17, issue: { number: 17 }, pullRequest: { number: -1 }, issueWorkflowKind: undefined }) as never); + expect(snapshot.target).toBe('issue'); + expect(snapshot.sddReadiness).toEqual({ required: null, state: 'unavailable' }); + expect(formatCopilotStatus(snapshot)).toContain('SDD readiness'); + }); + it.each(['pull-request', 'push'])('does not infer issue SDD eligibility for a %s target', target => { + const snapshot = buildCopilotStatusSnapshot(execution({ isPush: target === 'push', isPullRequest: target === 'pull-request', pullRequest: { number: target === 'push' ? -1 : 21 }, issueWorkflowKind: 'feature' }) as never); + expect(snapshot.sddReadiness).toBeUndefined(); + }); }); diff --git a/src/application/policies/publication_message_catalog.ts b/src/application/policies/publication_message_catalog.ts index 37f8e04e6..b18160ed4 100644 --- a/src/application/policies/publication_message_catalog.ts +++ b/src/application/policies/publication_message_catalog.ts @@ -97,6 +97,27 @@ const PUBLICATION_SURFACE_MESSAGE_IDS = Object.freeze([ 'interaction.status.findings', 'interaction.status.findingsInvalid', 'interaction.status.findingCounts', + 'interaction.status.sddHeading', + 'interaction.status.sddRequirement', + 'interaction.status.sddState', + 'interaction.status.sddRequired', + 'interaction.status.sddNotRequired', + 'interaction.status.sddUnavailable', + 'interaction.status.sddRequirementUnknown', + 'interaction.status.sddUnverified', + 'interaction.status.sddAwaitingAnswers', + 'interaction.status.sddPublicationPending', + 'interaction.status.sddRevisionPending', + 'interaction.status.sddReady', + 'interaction.status.sddOwner', + 'interaction.status.sddQuestions', + 'interaction.status.sddCommit', + 'interaction.status.sddFirstCommit', + 'interaction.status.sddNextInspect', + 'interaction.status.sddNextAnswers', + 'interaction.status.sddNextPublication', + 'interaction.status.sddNextReady', + 'interaction.status.sddCard', 'cli.answer', 'cli.outcome', 'cli.status', @@ -250,6 +271,27 @@ const ENGLISH_MESSAGES: Readonly> = 'interaction.status.findings': 'Bugbot findings', 'interaction.status.findingsInvalid': 'invalid evidence; inspect the workflow result.', 'interaction.status.findingCounts': '{open} open, {reopened} reopened, {verificationRequired} verification required, {unknown} unknown, {resolved} resolved', + 'interaction.status.sddHeading': 'SDD readiness', + 'interaction.status.sddRequirement': 'Specification', + 'interaction.status.sddState': 'Status', + 'interaction.status.sddRequired': 'Required', + 'interaction.status.sddNotRequired': 'Not required', + 'interaction.status.sddUnavailable': 'Specification evidence unavailable; readiness is unverified.', + 'interaction.status.sddRequirementUnknown': 'Requirement unavailable', + 'interaction.status.sddUnverified': 'Publication evidence is invalid, absent or stale; readiness is unverified.', + 'interaction.status.sddAwaitingAnswers': 'Answers required', + 'interaction.status.sddPublicationPending': 'Publication pending', + 'interaction.status.sddRevisionPending': 'Revision pending; current readiness is unverified.', + 'interaction.status.sddReady': 'Published and verified', + 'interaction.status.sddOwner': 'Specification', + 'interaction.status.sddQuestions': 'Pending questions', + 'interaction.status.sddCommit': 'Verified publication', + 'interaction.status.sddFirstCommit': 'First verified publication (history)', + 'interaction.status.sddNextInspect': 'Inspect the SDD card and retry after correcting its evidence.', + 'interaction.status.sddNextAnswers': 'Answer these questions in the issue using their question identifiers.', + 'interaction.status.sddNextPublication': 'Resume SDD preparation to publish the approved specification.', + 'interaction.status.sddNextReady': 'Continue implementation on the linked branch.', + 'interaction.status.sddCard': 'Open SDD card', 'cli.answer': 'Answer', 'cli.outcome': 'Outcome', 'cli.status': 'Status', @@ -362,6 +404,27 @@ const SPANISH_MESSAGES: Readonly> = 'interaction.status.findings': 'Hallazgos de Bugbot', 'interaction.status.findingsInvalid': 'evidencia no válida; revisa el resultado del workflow.', 'interaction.status.findingCounts': '{open} abiertos, {reopened} reabiertos, {verificationRequired} requieren verificación, {unknown} desconocidos, {resolved} resueltos', + 'interaction.status.sddHeading': 'Preparación del SDD', + 'interaction.status.sddRequirement': 'Especificación', + 'interaction.status.sddState': 'Estado', + 'interaction.status.sddRequired': 'Obligatoria', + 'interaction.status.sddNotRequired': 'No obligatoria', + 'interaction.status.sddUnavailable': 'Evidencia de la especificación no disponible; preparación sin verificar.', + 'interaction.status.sddRequirementUnknown': 'Requisito no disponible', + 'interaction.status.sddUnverified': 'La publicación es inválida, está ausente o desactualizada; preparación sin verificar.', + 'interaction.status.sddAwaitingAnswers': 'Respuestas necesarias', + 'interaction.status.sddPublicationPending': 'Publicación pendiente', + 'interaction.status.sddRevisionPending': 'Revisión pendiente; preparación actual sin verificar.', + 'interaction.status.sddReady': 'Publicado y verificado', + 'interaction.status.sddOwner': 'Especificación', + 'interaction.status.sddQuestions': 'Preguntas pendientes', + 'interaction.status.sddCommit': 'Publicación verificada', + 'interaction.status.sddFirstCommit': 'Primera publicación verificada (histórica)', + 'interaction.status.sddNextInspect': 'Revisa la tarjeta del SDD y reintenta tras corregir su evidencia.', + 'interaction.status.sddNextAnswers': 'Responde estas preguntas en la issue usando sus identificadores.', + 'interaction.status.sddNextPublication': 'Retoma la preparación del SDD para publicar la especificación aprobada.', + 'interaction.status.sddNextReady': 'Continúa la implementación en la rama vinculada.', + 'interaction.status.sddCard': 'Abrir tarjeta del SDD', 'cli.answer': 'Respuesta', 'cli.outcome': 'Resultado', 'cli.status': 'Estado', diff --git a/src/application/policies/sdd_issue_identity_policy.ts b/src/application/policies/sdd_issue_identity_policy.ts new file mode 100644 index 000000000..2cad45edb --- /dev/null +++ b/src/application/policies/sdd_issue_identity_policy.ts @@ -0,0 +1,19 @@ +import { createHash } from 'node:crypto'; +import { normalizeSddIssueTitle } from '../../domain/pre_branch_sdd'; +import { getVisibleIssueDescription } from './recommendation_policy'; + +export interface SddIssueIdentity { + readonly issueNumber: number; + readonly issueTitle: string; + readonly issueBody: string; + readonly admittedKind: string; + readonly profileDigest?: string; +} + +/** Producer and read-only queries use the same identity and immutable first base. */ +export function buildSddIssueDigest(context: SddIssueIdentity, baseSha: string): string { + return createHash('sha256').update(JSON.stringify([ + context.issueNumber, normalizeSddIssueTitle(context.issueTitle), getVisibleIssueDescription(context.issueBody), + context.admittedKind, context.profileDigest ?? '', baseSha, + ])).digest('hex'); +} diff --git a/src/application/policies/sdd_query_card_policy.ts b/src/application/policies/sdd_query_card_policy.ts new file mode 100644 index 000000000..f36d577b8 --- /dev/null +++ b/src/application/policies/sdd_query_card_policy.ts @@ -0,0 +1,55 @@ +import { readSddGateRecord, SDD_GATE_MARKER, type SddGateRecord } from '../../domain/pre_branch_sdd'; +import { parsePublicationMarker, parsePublicationReplyMarker, parsePublicationTransitionMarker } from './publication_identity_policy'; + +export interface SddQueryComment { + readonly id: number; + readonly body: string | null; + readonly user?: { readonly login?: string }; +} + +export type SddCardSelection = + | { readonly state: 'absent' | 'invalid' | 'unavailable' } + | { readonly state: 'valid'; readonly id: number; readonly body: string; readonly record: SddGateRecord }; + +/** The latest owned card is authoritative even when invalid; an older card cannot restore readiness. */ +export function selectOwnedSddCard(comments: readonly SddQueryComment[], issueNumber: number, trustedBotLogin: string): SddCardSelection { + const login = trustedBotLogin.trim().toLowerCase(); + if (!/^[a-z0-9][a-z0-9-]{0,38}(?:\[bot\])?$/u.test(login)) return { state: 'unavailable' }; + const owned = comments.filter(comment => comment.user?.login?.toLowerCase() === login); + const versions = new Map>(); + for (const comment of owned) { + const bodies = versions.get(comment.id) ?? new Set(); + bodies.add(comment.body); + versions.set(comment.id, bodies); + } + // Unknown owned comments are potential damaged SDD cards. Unsafe IDs and + // ambiguous duplicate bodies cannot be filtered as another product surface. + const candidates = owned.filter(comment => !Number.isSafeInteger(comment.id) || comment.id < 1 + || versions.get(comment.id)!.size !== 1 || !isOtherOwnedPublication(comment.body, issueNumber)); + if (!candidates.length) return { state: 'absent' }; + if (candidates.some(comment => !Number.isSafeInteger(comment.id) || comment.id < 1)) return { state: 'invalid' }; + const latestId = Math.max(...candidates.map(comment => comment.id)); + const latest = candidates.filter(comment => comment.id === latestId); + const bodies = new Set(latest.map(comment => comment.body)); + if (bodies.size !== 1) return { state: 'invalid' }; + const body = latest[0].body; + if (typeof body !== 'string') return { state: 'invalid' }; + if ([...body.matchAll(/', 8)] }); + expect(await f.query.read(f.context)).toEqual({ state: 'invalid' }); + }); + it('ignores a copied publication by a foreign author', async () => { + const f = fixture(); f.comments.mockResolvedValue({ ...(await f.comments()), value: [f.comment(), f.comment('', 8, 'human')] }); + expect(await f.query.read(f.context)).toMatchObject({ state: 'published' }); + }); + it('keeps a reused Q1 pending when its retained answer belongs to the previous round', async () => { + const f = fixture(); Object.assign(f.record, { phase: 'awaiting-answer', round: 2, + plan: { ...f.record.plan, questions: [{ id: 'Q1', text: 'What should the new behavior be?', owner: 'issue-author' }] }, + answers: [{ questionId: 'Q1', author: 'author', commentId: 6, text: 'Old choice', round: 1, questionText: 'What was the previous behavior?' }] }); + expect(await f.query.read(f.context)).toMatchObject({ state: 'awaiting-answers', pendingQuestionIds: ['Q1'], verified: { firstSha: first } }); + }); + it.each(['issue-author', 'maintainer'])('accepts only a current answer authorized for %s', async owner => { + const f = fixture(); Object.assign(f.record, { phase: 'awaiting-answer', plan: { ...f.record.plan, questions: [{ id: 'Q1', text: 'What should this behavior be?', owner }] } }); + const current = await f.comments(); current.value.push(f.comment('SDD Q1: Keep the verified behavior.', 9, owner === 'maintainer' ? 'maintainer' : 'author')); + f.comments.mockResolvedValue(current); + expect(await f.query.read(f.context)).toMatchObject({ state: 'publication-pending', pendingQuestionIds: [] }); + }); + it.each(['human', 'vypbot'])('does not consume a pending answer by %s', author => { + const f = fixture(); Object.assign(f.record, { phase: 'awaiting-answer', plan: { ...f.record.plan, questions: [{ id: 'Q1', text: 'What should this behavior be?', owner: 'issue-author' }] } }); + return f.comments().then(async current => { + current.value.push(f.comment('SDD Q1: An unauthorized answer.', 9, author)); f.comments.mockResolvedValue(current); + expect(await f.query.read(f.context)).toMatchObject(author === 'vypbot' + ? { state: 'invalid' } : { state: 'awaiting-answers', pendingQuestionIds: ['Q1'] }); + }); + }); + it('reports first publication pending from validated new-owner metadata without a branch', async () => { + const f = fixture(); Object.assign(f.record, { phase: 'awaiting-answer', commitSha: undefined, branchName: undefined, + plan: { ...f.record.plan, action: 'companion', path: 'specs/new-feature.md' } }); + expect(await f.query.read(f.context)).toMatchObject({ state: 'publication-pending', path: 'specs/new-feature.md', pendingQuestionIds: [] }); + expect(f.ports.workspace.verifyPublication).not.toHaveBeenCalled(); + }); + it('distinguishes absent evidence from a malformed publication', async () => { + const f = fixture(); f.comments.mockResolvedValue({ ...(await f.comments()), value: [] }); + expect(await f.query.read(f.context)).toEqual({ state: 'absent' }); + }); + it('does not turn partial provider inventory into an absent or ready result', async () => { + const f = fixture(); const incomplete = await f.comments(); incomplete.coverage.limitReached = true; f.comments.mockResolvedValue(incomplete); + expect(await f.query.read(f.context)).toEqual({ state: 'unavailable' }); + }); + it.each(['issue edit', 'card edit', 'branch push', 'label edit'])('loses readiness when the final fence observes %s', async mutation => { + const f = fixture(); + if (mutation === 'issue edit') f.ports.descriptions.getDescription.mockResolvedValueOnce('Human requirements').mockResolvedValue('New requirements'); + if (mutation === 'card edit') { + const initial = await f.comments(); + f.comments.mockResolvedValueOnce(initial).mockResolvedValue({ ...initial, value: [f.comment('## SDD work status\nmissing evidence')] }); + } + if (mutation === 'branch push') f.ports.branches.getLinkedBranch.mockResolvedValueOnce({ name: 'feature/42', headSha: head }).mockResolvedValue({ name: 'feature/42', headSha: 'f'.repeat(40) }); + if (mutation === 'label edit') f.ports.labels.getLabels.mockResolvedValueOnce(['feature', 'in-progress']).mockResolvedValue(['feature']); + expect(await f.query.read(f.context)).toEqual({ state: 'stale' }); + }); + it('does not infer disabled gate configuration from missing legacy fields', async () => { + const f = fixture(); expect(await f.query.read({ ...f.context, preBranchSdd: undefined })).toEqual({ state: 'unavailable' }); + expect(f.comments).not.toHaveBeenCalled(); + }); + it('keeps help branchless without reading publication or comment authority', async () => { + const f = fixture(); expect(await f.query.read({ ...f.context, admittedKind: 'help' })).toEqual({ state: 'not-required' }); + expect(f.comments).not.toHaveBeenCalled(); + }); + it('uses canonical admission and start policy rather than the SDD output label', async () => { + const f = fixture(); f.ports.labels.getLabels.mockResolvedValue(['feature', 'SDD']); + expect(await f.query.read(f.context)).toEqual({ state: 'not-required' }); + expect(f.ports.workspace.verifyPublication).not.toHaveBeenCalled(); + }); + it('returns unavailable after an authority read fails without exposing the provider error', async () => { + const f = fixture(); f.comments.mockRejectedValue(new Error('private provider response')); + expect(await f.query.read(f.context)).toEqual({ state: 'unavailable' }); + }); + it('physically excludes source, label, publication and agent mutation methods from bound capabilities', () => { + const f = fixture(); Object.assign(f.ports.labels, { setLabels: jest.fn() }); Object.assign(f.ports.workspace, { publish: jest.fn(), validateDraft: jest.fn() }); + const bound = bindSddEvidenceReadPorts(f.ports); + expect(Object.keys(bound.labels)).toEqual(['getLabels']); + expect(Object.keys(bound.workspace)).toEqual(['loadSnapshot', 'loadSnapshotAtCommit', 'verifyPublication', 'isAncestor']); + expect(Object.isFrozen(bound)).toBe(true); expect(Object.isFrozen(bound.workspace)).toBe(true); + }); +}); + +describe('complete owned SDD-card identity loss across query fences', () => { + it.each(['initial read', 'final fence'])('withholds readiness when a newer owned card lost all metadata during %s', async stage => { + const f = fixture(); + const complete = await f.comments(); + const damaged = { ...complete, value: [...complete.value, f.comment('Unidentified replacement body', 8)] }; + if (stage === 'initial read') f.comments.mockResolvedValue(damaged); + else f.comments.mockResolvedValueOnce(complete).mockResolvedValue(damaged); + expect(await f.query.read(f.context)).toEqual({ state: stage === 'initial read' ? 'invalid' : 'stale' }); + if (stage === 'initial read') expect(f.ports.workspace.verifyPublication).not.toHaveBeenCalled(); + }); +}); diff --git a/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts b/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts index 5d31ec705..f636d74dc 100644 --- a/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts +++ b/src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts @@ -1,4 +1,4 @@ -import { createHash } from 'node:crypto'; +import { buildSddIssueDigest } from '../../policies/sdd_issue_identity_policy'; import type { AgentConfiguration } from '../../ports/agent_configuration_ports'; import type { FindingsQueryPort } from '../../ports/agent_findings_ports'; import type { BoundIssueCommentUpsertPort, IssueCommentPublicationTarget } from '../../ports/issue_lifecycle_ports'; @@ -320,10 +320,7 @@ function latestOwnedCard(comments: readonly IssueCommentPublicationTarget[], iss } function issueDigest(context: PreBranchSddContext, baseSha: string): string { - return createHash('sha256').update(JSON.stringify([ - context.issueNumber, normalizeSddIssueTitle(context.issueTitle), getVisibleIssueDescription(context.issueBody), context.admittedKind, - context.profileDigest ?? '', baseSha, - ])).digest('hex'); + return buildSddIssueDigest(context, baseSha); } function asRecord(value: unknown): Record { diff --git a/src/application/usecases/sdd/sdd_evidence_query_use_case.ts b/src/application/usecases/sdd/sdd_evidence_query_use_case.ts new file mode 100644 index 000000000..609636ecd --- /dev/null +++ b/src/application/usecases/sdd/sdd_evidence_query_use_case.ts @@ -0,0 +1,175 @@ +import { decideIssueStart } from '../../../domain/issue_start_policy'; +import { parseSddAnswer, parseSddPlan, type SddGateRecord } from '../../../domain/pre_branch_sdd'; +import { bindSddEvidenceReadPorts, type SddEvidenceQueryContext, type SddEvidenceReadPorts, type VerifiedSddEvidence, type SddEvidenceSnapshot } from '../../ports/sdd_evidence_read_ports'; +import type { SddCatalogSnapshot } from '../../ports/pre_branch_sdd_ports'; +import { buildSddIssueDigest } from '../../policies/sdd_issue_identity_policy'; +import { selectOwnedSddCard, type SddQueryComment } from '../../policies/sdd_query_card_policy'; + +export type { VerifiedSddEvidence, SddEvidenceSnapshot } from '../../ports/sdd_evidence_read_ports'; + +interface ReadFacts { + readonly title: string; + readonly body: string; + readonly labels: readonly string[]; + readonly comments: readonly SddQueryComment[]; +} +const MAX_AUTHORIZATION_LOOKUPS = 16; + +/** Reads gate authority and immutable publication evidence without any mutation or model capability. */ +export class SddEvidenceQueryUseCase { + private readonly ports: SddEvidenceReadPorts; + constructor(ports: SddEvidenceReadPorts) { this.ports = bindSddEvidenceReadPorts(ports); } + + async read(context: SddEvidenceQueryContext): Promise { + try { + if (!Number.isSafeInteger(context.issueNumber) || context.issueNumber < 1 + || typeof context.issueManagedBranches !== 'boolean' || typeof context.preBranchSdd !== 'boolean' + || !context.admittedKind) return { state: 'unavailable' }; + decideIssueStart({ kind: context.admittedKind, labels: [], issueManagedBranches: context.issueManagedBranches, preBranchSdd: context.preBranchSdd }); + if (context.admittedKind === 'help' || !context.preBranchSdd) return { state: 'not-required' }; + const facts = await this.readFacts(context.issueNumber); + if (!this.required(context, facts.labels)) return { state: 'not-required' }; + const selection = selectOwnedSddCard(facts.comments, context.issueNumber, context.trustedBotLogin); + if (selection.state !== 'valid') return { state: selection.state }; + const { record, id: cardId } = selection; + const digest = this.digest(context, facts, record.baseSha); + const stale = digest !== record.issueDigest; + const result = await this.inspectRecord(context, facts, record, cardId, stale, new Map()); + if (!['invalid', 'unavailable'].includes(result.state) + && !await this.fence(context, facts, selection.body, cardId, record, result.verified)) return { state: 'stale' }; + return Object.freeze(result); + } catch { return { state: 'unavailable' }; } + } + + private async inspectRecord(context: SddEvidenceQueryContext, facts: ReadFacts, record: SddGateRecord, cardId: number, stale: boolean, + authorizations: Map>): Promise { + const path = record.plan.path; + let verified: VerifiedSddEvidence | undefined; + if (record.commitSha) { + if (!record.branchName) return { state: 'invalid' }; + const linked = await this.ports.branches.getLinkedBranch(context.issueNumber, record.branchName); + if (linked?.name !== record.branchName || !fullSha(linked.headSha)) return { state: 'invalid' }; + if (!await this.ports.workspace.verifyPublication(record.branchName, record.baseSha, record.commitSha, path) + || !await this.ports.workspace.isAncestor(record.commitSha, linked.headSha)) return { state: 'invalid' }; + if (!ownedAt(await this.ports.workspace.loadSnapshotAtCommit(record.commitSha), record.commitSha, record)) return { state: 'invalid' }; + verified = Object.freeze({ path, capabilityId: record.plan.capabilityId, branchName: record.branchName, + branchHeadSha: linked.headSha, firstSha: record.commitSha }); + if (record.phase === 'published') { + const currentSha = record.revisionSha ?? record.commitSha; + if (record.revisionSha && (!record.revisionBaseSha + || !await this.ports.workspace.isAncestor(record.commitSha, record.revisionBaseSha) + || !await this.ports.workspace.verifyPublication(record.branchName, record.revisionBaseSha, record.revisionSha, path) + || !await this.ports.workspace.isAncestor(record.revisionSha, linked.headSha) + || !ownedAt(await this.ports.workspace.loadSnapshotAtCommit(record.revisionSha), record.revisionSha, record))) return { state: 'invalid' }; + verified = Object.freeze({ ...verified, currentSha }); + } + } else { + if (record.phase === 'published') return { state: 'invalid' }; + const source = record.branchName ?? context.baseBranch; + if (!source) return { state: 'unavailable' }; + if (record.branchName) { + const linked = await this.ports.branches.getLinkedBranch(context.issueNumber, record.branchName); + if (linked?.name !== record.branchName || linked.headSha !== record.baseSha) return { state: 'invalid' }; + } + const snapshot = await this.ports.workspace.loadSnapshot(source); + if (snapshot.baseSha !== record.baseSha) return { state: 'stale' }; + try { parseSddPlan(record.plan, new Map(snapshot.capabilities.map(capability => [capability.id, capability.specs]))); } + catch { return { state: 'invalid' }; } + } + if (stale) return { state: 'stale', ...(verified ? { verified } : {}) }; + if (record.phase === 'published') return { state: 'published', path, cardId, verified }; + const pendingQuestionIds = await this.pendingQuestions(context, facts.comments, record, cardId, authorizations); + return { state: pendingQuestionIds.length ? 'awaiting-answers' : 'publication-pending', path, cardId, + pendingQuestionIds: Object.freeze(pendingQuestionIds), ...(verified ? { verified } : {}) }; + } + + private async pendingQuestions(context: SddEvidenceQueryContext, comments: readonly SddQueryComment[], record: SddGateRecord, cardId: number, + authorizations: Map>): Promise { + const pending: string[] = []; + const cutoff = Math.max(cardId, ...(record.answers ?? []).map(answer => answer.commentId)); + for (const question of record.plan.questions) { + const retained = (record.answers ?? []).filter(answer => { + const provenance = answer as typeof answer & { readonly round?: number; readonly questionText?: string }; + return Number.isSafeInteger(answer.commentId) && provenance.round === record.round + && answer.questionId === question.id && provenance.questionText === question.text; + }); + let answered = false; + for (const answer of retained) if (await this.authorized(context, question.owner, answer.author, authorizations)) { answered = true; break; } + if (!answered) { + for (const comment of [...comments].sort((a, b) => b.id - a.id)) { + if (!Number.isSafeInteger(comment.id) || comment.id <= cutoff || !comment.user?.login + || comment.user.login.toLowerCase() === context.trustedBotLogin.toLowerCase() + || !comment.body || !parseSddAnswer(comment.body, question.id)) continue; + if (await this.authorized(context, question.owner, comment.user.login, authorizations)) { answered = true; break; } + } + } + if (!answered) pending.push(question.id); + } + return pending; + } + + private async authorized(context: SddEvidenceQueryContext, owner: 'issue-author' | 'maintainer', author: string, + authorizations: Map>): Promise { + if (owner === 'issue-author') return Boolean(context.issueAuthor && author.toLowerCase() === context.issueAuthor.toLowerCase()); + const key = author.toLowerCase(); + let authorization = authorizations.get(key); + if (!authorization) { + if (authorizations.size >= MAX_AUTHORIZATION_LOOKUPS) throw new Error('SDD authorization inventory exceeds its bound.'); + authorization = this.ports.actors.isActorAllowedToModifyFiles(author); + authorizations.set(key, authorization); + } + return authorization; + } + + private async fence(context: SddEvidenceQueryContext, original: ReadFacts, cardBody: string, cardId: number, record: SddGateRecord, verified?: VerifiedSddEvidence): Promise { + const fresh = await this.readFacts(context.issueNumber); + const card = selectOwnedSddCard(fresh.comments, context.issueNumber, context.trustedBotLogin); + if (card.state !== 'valid' || card.id !== cardId || card.body !== cardBody + || this.digest(context, fresh, record.baseSha) !== this.digest(context, original, record.baseSha) + || canonicalLabels(fresh.labels) !== canonicalLabels(original.labels) + || !this.required(context, fresh.labels)) return false; + // Answer arrivals or edits must not let a stale question projection become current. + if (record.phase === 'awaiting-answer' + && JSON.stringify(fresh.comments) !== JSON.stringify(original.comments)) return false; + if (record.branchName) { + const linked = await this.ports.branches.getLinkedBranch(context.issueNumber, record.branchName); + if (linked?.name !== record.branchName || linked.headSha !== (verified?.branchHeadSha ?? record.baseSha)) return false; + if (verified && (!await this.ports.workspace.verifyPublication(record.branchName, record.baseSha, record.commitSha!, record.plan.path) + || (record.phase === 'published' && record.revisionSha + && !await this.ports.workspace.verifyPublication(record.branchName, record.revisionBaseSha!, record.revisionSha, record.plan.path)))) return false; + } else if (!context.baseBranch || (await this.ports.workspace.loadSnapshot(context.baseBranch)).baseSha !== record.baseSha) return false; + return true; + } + + private required(context: SddEvidenceQueryContext, labels: readonly string[]): boolean { + return decideIssueStart({ kind: context.admittedKind, labels, + issueManagedBranches: context.issueManagedBranches!, preBranchSdd: context.preBranchSdd! }).sddRequired; + } + + private digest(context: SddEvidenceQueryContext, facts: ReadFacts, baseSha: string): string { + return buildSddIssueDigest({ issueNumber: context.issueNumber, issueTitle: facts.title, issueBody: facts.body, + admittedKind: context.admittedKind!, profileDigest: context.profileDigest }, baseSha); + } + + private async readFacts(issueNumber: number): Promise { + const [title, body, labels, comments] = await Promise.all([ + this.ports.titles.getTitle(issueNumber), this.ports.descriptions.getDescription(issueNumber), + this.ports.labels.getLabels(issueNumber), this.ports.comments.listIssueComments(issueNumber), + ]); + if (title === undefined || body === undefined || comments.coverage.source !== 'issue-comments' + || comments.coverage.status !== 'complete' || comments.coverage.limitReached || comments.coverage.providerLimitReached + || comments.coverage.omittedItems !== 0 || comments.coverage.truncatedItems !== 0) throw new Error('Incomplete SDD authority.'); + return { title, body, labels, comments: comments.value }; + } +} + +function ownedAt(snapshot: SddCatalogSnapshot, sha: string, record: SddGateRecord): boolean { + if (!Array.isArray(snapshot.capabilities) || snapshot.capabilities.some(capability => !capability || typeof capability.id !== 'string' + || !Array.isArray(capability.specs) || capability.specs.some((path: unknown) => typeof path !== 'string')) + || new Set(snapshot.capabilities.map(capability => capability.id)).size !== snapshot.capabilities.length) return false; + const owners = snapshot.capabilities.filter(capability => capability.specs.includes(record.plan.path)); + return snapshot.baseSha === sha && owners.length === 1 && owners[0].id === record.plan.capabilityId; +} + +function fullSha(value: unknown): value is string { return typeof value === 'string' && /^[a-f0-9]{40}$/u.test(value); } +function canonicalLabels(labels: readonly string[]): string { return JSON.stringify([...new Set(labels.map(label => label.trim().toLowerCase()))].sort()); } diff --git a/src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts b/src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts index eec21c66e..09bb18fd6 100644 --- a/src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts +++ b/src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts @@ -4,9 +4,9 @@ const sha = 'a'.repeat(40); describe('LinkedBranchReadinessRepository', () => { it('selects the exact issue-linked ref and its commit SHA', async () => { - const graphql = jest.fn().mockResolvedValue({ repository: { issue: { linkedBranches: { nodes: [ + const graphql = jest.fn().mockResolvedValue({ repository: { ref: { target: { oid: sha } }, issue: { linkedBranches: { nodes: [ { ref: { name: 'refs/heads/feature/420-other', target: { oid: 'b'.repeat(40) } } }, - { ref: { name: 'refs/heads/feature/42-change', target: { oid: sha } } }, + { ref: { name: 'refs/heads/feature/42-change', repository: { nameWithOwner: 'acme/repo' }, target: { oid: sha } } }, ] } } } }); const repository = new LinkedBranchReadinessRepository({ getClient: () => ({ graphql }) } as never); await expect(repository.getLinkedBranch('acme', 'repo', 42, 'feature/42-change', 'token')) @@ -16,6 +16,22 @@ describe('LinkedBranchReadinessRepository', () => { }); }); + it.each([ + ['stale native SHA', 'b'.repeat(40), 'acme/repo'], + ['deleted native branch', undefined, 'acme/repo'], + ['foreign native repository', sha, 'other/repo'], + ['missing native repository', sha, undefined], + ])('rejects %s even with an otherwise valid PR fallback', async (_reason, remoteSha, nameWithOwner) => { + const graphql = jest.fn().mockResolvedValue({ repository: { + ref: { target: { oid: remoteSha } }, issue: { + linkedBranches: { nodes: [{ ref: { name: 'feature/42-change', repository: { nameWithOwner }, target: { oid: sha } } }] }, + closedByPullRequestsReferences: { nodes: [{ state: 'OPEN', headRefName: 'feature/42-change', headRefOid: remoteSha, headRepository: { nameWithOwner: 'acme/repo' } }] }, + }, + } }); + const repository = new LinkedBranchReadinessRepository({ getClient: () => ({ graphql }) } as never); + await expect(repository.getLinkedBranch('acme', 'repo', 42, 'feature/42-change', 'token')).resolves.toBeUndefined(); + }); + it.each(['', '../feature/42-change', '/feature/42-change'])('rejects unsafe expected ref %s', async branch => { const graphql = jest.fn().mockResolvedValue({ repository: { issue: { linkedBranches: { nodes: [] } } } }); const repository = new LinkedBranchReadinessRepository({ getClient: () => ({ graphql }) } as never); diff --git a/src/data/repository/branch/linked_branch_readiness_repository.ts b/src/data/repository/branch/linked_branch_readiness_repository.ts index 0fd495c76..74098652f 100644 --- a/src/data/repository/branch/linked_branch_readiness_repository.ts +++ b/src/data/repository/branch/linked_branch_readiness_repository.ts @@ -8,7 +8,7 @@ interface Response { readonly issue?: { readonly linkedBranches?: { readonly nodes?: ReadonlyArray<{ - readonly ref?: { readonly name?: string; readonly target?: { readonly oid?: string } }; + readonly ref?: { readonly name?: string; readonly repository?: { readonly nameWithOwner?: string }; readonly target?: { readonly oid?: string } }; } | null>; }; readonly closedByPullRequestsReferences?: { @@ -42,7 +42,7 @@ export class LinkedBranchReadinessRepository implements LinkedBranchReadinessPor ref(qualifiedName: $ref) { target { ... on Commit { oid } } } issue(number: $issueNumber) { linkedBranches(first: 100) { - nodes { ref { name target { ... on Commit { oid } } } } + nodes { ref { name repository { nameWithOwner } target { ... on Commit { oid } } } } } closedByPullRequestsReferences(first: 100) { nodes { state headRefName headRefOid headRepository { nameWithOwner } } @@ -56,11 +56,13 @@ export class LinkedBranchReadinessRepository implements LinkedBranchReadinessPor return name === expected || name === `refs/heads/${expected}` || name === `/${expected}`; }); const directlyLinkedSha = fullSha(match?.ref?.target?.oid); - if (directlyLinkedSha) return Object.freeze({ name: expected, headSha: directlyLinkedSha }); + const remoteSha = fullSha(response.repository?.ref?.target?.oid); + if (match) return directlyLinkedSha && directlyLinkedSha === remoteSha + && match.ref?.repository?.nameWithOwner?.toLowerCase() === `${owner}/${repository}`.toLowerCase() + ? Object.freeze({ name: expected, headSha: directlyLinkedSha }) : undefined; // GitHub removes linkedBranches when its branch becomes a linked PR. // Native issue association plus the exact same-repository live ref retains // that proof; a PR body or a stale/deleted head alone is insufficient. - const remoteSha = fullSha(response.repository?.ref?.target?.oid); const linkedPullRequest = response.repository?.issue?.closedByPullRequestsReferences?.nodes?.find(pr => pr?.state === 'OPEN' && pr.headRefName === expected && pr.headRepository?.nameWithOwner?.toLowerCase() === `${owner}/${repository}`.toLowerCase() diff --git a/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts b/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts index ba6443430..086c70631 100644 --- a/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts +++ b/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts @@ -83,6 +83,24 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { expect(git(repo, 'status', '--porcelain')).toBe(''); }); + it('reads the owning catalog at an immutable commit without checking out or changing current files', async () => { + const original = git(repo, 'rev-parse', 'HEAD'); + fs.writeFileSync(path.join(repo, 'specs/catalog.json'), '{"version":2,"capabilities":[]}\n'); + git(repo, 'add', 'specs/catalog.json'); git(repo, 'commit', '-m', 'later catalog'); + const current = git(repo, 'rev-parse', 'HEAD'); + const snapshot = await new PreBranchSddWorkspaceAdapter(repo).loadSnapshotAtCommit(original); + expect(snapshot.baseSha).toBe(original); + expect(snapshot.capabilities.map(capability => capability.id)).toEqual(['payments']); + expect(git(repo, 'rev-parse', 'HEAD')).toBe(current); + expect(fs.readFileSync(path.join(repo, 'specs/catalog.json'), 'utf8')).toContain('"version":2'); + expect(git(repo, 'status', '--porcelain')).toBe(''); + }); + + it('rejects a non-SHA publication identifier before reading Git objects', async () => { + await expect(new PreBranchSddWorkspaceAdapter(repo).loadSnapshotAtCommit('develop:specs/catalog.json')).rejects.toThrow('invalid commit SHA'); + expect(git(repo, 'status', '--porcelain')).toBe(''); + }); + it('reads only safe SDD paths from the selected base commit', async () => { const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop'); diff --git a/src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts b/src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts index 8f8808ac3..58e72ddcf 100644 --- a/src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts +++ b/src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts @@ -19,7 +19,7 @@ import { const mockIssue = {}; const mockPullRequest = {}; -const mockContext = { kind: 'context' }; +const mockContext = { kind: 'context', listIssueComments: jest.fn() }; const mockResolution = { kind: "resolution" }; const mockFindings = {}; const mockLanguage = {}; @@ -118,7 +118,11 @@ describe("main run route composition root", () => { expect(IssueCommentUseCase).toHaveBeenCalledWith( ...Array.from({ length: 14 }, () => expect.anything()), mockContext, + expect.objectContaining({ read: expect.any(Function) }), ); + const query = jest.mocked(IssueCommentUseCase).mock.calls[0][15]; + expect(Object.keys(query!)).toEqual(['read', 'serverOrigin']); + expect(Object.isFrozen(query)).toBe(true); }); it("keeps Bugbot resolution out of the review-comment autofix composition", () => { diff --git a/src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts b/src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts new file mode 100644 index 000000000..67ffd8e83 --- /dev/null +++ b/src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts @@ -0,0 +1,63 @@ +import { renderSddGateRecord, type SddGateRecord } from '../../../domain/pre_branch_sdd'; +import { buildSddIssueDigest } from '../../../application/policies/sdd_issue_identity_policy'; +import { createSddEvidenceQueryPort } from '../sdd_evidence_composition_root'; + +const mockTitle = jest.fn(async () => 'Feature'); +const mockDescription = jest.fn(async () => 'Human requirements'); +const mockLabels = jest.fn(async () => ['feature', 'in-progress']); +const mockPermission = jest.fn(async () => true); +const mockWrite = jest.fn(() => { throw new Error('query must not mutate'); }); +const mockHead = 'e'.repeat(40), mockBase = 'a'.repeat(40), mockFirst = 'b'.repeat(40); +const mockGraphql = jest.fn(async () => ({ repository: { ref: { target: { oid: mockHead } }, issue: { linkedBranches: { nodes: [{ ref: { + name: 'feature/42', repository: { nameWithOwner: 'acme/demo' }, target: { oid: mockHead }, +} }] } } } })); +const mockGetClient = jest.fn(() => ({ graphql: mockGraphql })); +const mockSnapshot = jest.fn(async (sha: string) => ({ baseSha: sha, template: '', standard: '', capabilities: [{ + id: 'feature', title: 'Feature', status: 'implemented', scope: 'Feature', owner: 'Maintainer', lastVerified: '2026-10-11', + specs: ['specs/feature.md'], workflows: [], entrypoints: [], code: [], tests: [], documentation: [], +}] })); +const mockVerify = jest.fn(async () => true); +const mockAncestor = jest.fn(async () => true); + +jest.mock('../issue_metadata_composition_root', () => ({ createIssueMetadataCompositionRoot: () => ({ getTitle: mockTitle, setTitle: mockWrite }) })); +jest.mock('../issue_content_composition_root', () => ({ createIssueContentCompositionRoot: () => ({ getDescription: mockDescription, updateDescription: mockWrite }) })); +jest.mock('../issue_labels_composition_root', () => ({ createIssueLabelRepository: () => ({ getLabels: mockLabels, setLabels: mockWrite }) })); +jest.mock('../actor_authorization_composition_root', () => ({ createActorAuthorizationRepository: () => ({ isActorAllowedToModifyFiles: mockPermission }) })); +jest.mock('../github_project_client_factory', () => ({ createGraphqlTransportClient: () => ({ getClient: mockGetClient }) })); +jest.mock('../../pre_branch_sdd_workspace_adapter', () => ({ PreBranchSddWorkspaceAdapter: jest.fn(() => ({ + loadSnapshot: mockSnapshot, loadSnapshotAtCommit: mockSnapshot, verifyPublication: mockVerify, isAncestor: mockAncestor, + publish: mockWrite, ensureBranch: mockWrite, +})) })); + +describe('composed read-only SDD query', () => { + it('reads owned publication and currently authorized answers with the bound repository identity while discarding mutation capabilities', async () => { + const binding = { owner: 'acme', repository: 'demo', token: 'fixture-token' }; + const context = { issueNumber: 42, admittedKind: 'feature' as const, profileDigest: 'profile', issueAuthor: 'author', + trustedBotLogin: 'vypbot', issueManagedBranches: true, preBranchSdd: true, baseBranch: 'develop' }; + const questionText = 'Which current behavior should be adopted?'; + const record: SddGateRecord = { version: 1, issueNumber: 42, phase: 'awaiting-answer', round: 2, + issueDigest: buildSddIssueDigest({ issueNumber: 42, issueTitle: 'Feature', issueBody: 'Human requirements', admittedKind: 'feature', profileDigest: 'profile' }, mockBase), + baseSha: mockBase, commitSha: mockFirst, branchName: 'feature/42', + plan: { action: 'update', path: 'specs/feature.md', capabilityId: 'feature', reason: 'This capability owns the feature contract.', + questions: [{ id: 'Q1', text: questionText, owner: 'maintainer' }] }, + answers: [{ questionId: 'Q1', author: 'maintainer', commentId: 6, text: 'Current authorized answer', round: 2, questionText }], + }; + const listIssueComments = jest.fn(async () => ({ value: [{ id: 7, body: renderSddGateRecord(record), user: { login: 'vypbot' } }], + coverage: { source: 'issue-comments' as const, status: 'complete' as const, pagesFetched: 1, itemsFetched: 1, itemsRetained: 1, + omittedItems: 0, truncatedItems: 0, limitReached: false } })); + const query = createSddEvidenceQueryPort(binding, { listIssueComments }); + expect(Object.isFrozen(query)).toBe(true); + expect(Object.keys(query)).toEqual(['read', 'serverOrigin']); + expect(query.serverOrigin).toBe(process.env.GITHUB_SERVER_URL ?? 'https://github.com'); + expect(await query.read(context)).toMatchObject({ state: 'publication-pending', pendingQuestionIds: [], verified: { firstSha: mockFirst, branchHeadSha: mockHead } }); + expect(mockTitle).toHaveBeenCalledWith('acme', 'demo', 42, 'fixture-token'); + expect(mockDescription).toHaveBeenCalledWith('acme', 'demo', 42, 'fixture-token'); + expect(mockLabels).toHaveBeenCalledWith('acme', 'demo', 42, 'fixture-token'); + expect(mockPermission).toHaveBeenCalledWith('acme', 'demo', 'maintainer', 'fixture-token'); + expect(mockGetClient).toHaveBeenCalledWith('fixture-token'); + expect(mockGraphql).toHaveBeenCalledWith(expect.any(String), { owner: 'acme', repository: 'demo', issueNumber: 42, ref: 'refs/heads/feature/42' }); + expect(mockSnapshot).toHaveBeenCalledWith(mockFirst); + expect(mockVerify).toHaveBeenCalledWith('feature/42', mockBase, mockFirst, 'specs/feature.md'); + expect(mockWrite).not.toHaveBeenCalled(); + }); +}); diff --git a/src/infrastructure/composition/main_run_route_composition_root.ts b/src/infrastructure/composition/main_run_route_composition_root.ts index 808abbcc4..f6e6965d3 100644 --- a/src/infrastructure/composition/main_run_route_composition_root.ts +++ b/src/infrastructure/composition/main_run_route_composition_root.ts @@ -1,4 +1,5 @@ import type { RememberBranchQueryPorts } from '../../application/usecases/comment_automation_contracts'; +import { createSddEvidenceQueryPort } from './sdd_evidence_composition_root'; import { LinkedBranchReadinessRepository } from '../../data/repository/branch/linked_branch_readiness_repository'; import { PullRequestChangesRepository } from '../../data/repository/pull_request/pull_request_changes_repository'; import { CommitUseCase } from "../../application/usecases/commit_use_case"; @@ -239,6 +240,7 @@ export function createIssueCommentUseCaseCompositionRoot(binding: BugbotScmBindi createIssueUseCaseCompositionRoot(binding), rememberBranchPorts(binding, bugbot.scm.context), bugbot.scm.context, + createSddEvidenceQueryPort(binding, bugbot.scm.context), ); } diff --git a/src/infrastructure/composition/sdd_evidence_composition_root.ts b/src/infrastructure/composition/sdd_evidence_composition_root.ts new file mode 100644 index 000000000..bd4fd484f --- /dev/null +++ b/src/infrastructure/composition/sdd_evidence_composition_root.ts @@ -0,0 +1,33 @@ +import { SddEvidenceQueryUseCase } from '../../application/usecases/sdd/sdd_evidence_query_use_case'; +import { bindSddEvidenceReadPorts, type SddEvidenceReadPorts, type SddEvidenceQueryPort } from '../../application/ports/sdd_evidence_read_ports'; +import type { BoundBugbotContextReadPorts } from '../../application/ports/bugbot_context_ports'; +import { LinkedBranchReadinessRepository } from '../../data/repository/branch/linked_branch_readiness_repository'; +import { PreBranchSddWorkspaceAdapter } from '../pre_branch_sdd_workspace_adapter'; +import type { BugbotScmBinding } from './bugbot_scm_port_factory'; +import { createGraphqlTransportClient } from './github_project_client_factory'; +import { createIssueMetadataCompositionRoot } from './issue_metadata_composition_root'; +import { createIssueContentCompositionRoot } from './issue_content_composition_root'; +import { createIssueLabelRepository } from './issue_labels_composition_root'; +import { createActorAuthorizationRepository } from './actor_authorization_composition_root'; +import { bindActorAuthorization, bindIssueLabels } from './lifecycle_capability_port_binding'; +import { bindIssueDescriptionQuery } from './shared_capability_port_binding'; + +/** No provisioning, mutation, agent or approval capability crosses this binding. */ +export function createSddEvidenceReadPorts(binding: BugbotScmBinding, comments: Pick): SddEvidenceReadPorts { + const titles = createIssueMetadataCompositionRoot(); + const branches = new LinkedBranchReadinessRepository(createGraphqlTransportClient()); + return bindSddEvidenceReadPorts({ + comments, + descriptions: bindIssueDescriptionQuery(createIssueContentCompositionRoot(), binding), + titles: { getTitle: issueNumber => titles.getTitle(binding.owner, binding.repository, issueNumber, binding.token) }, + labels: bindIssueLabels(createIssueLabelRepository(), binding), + actors: bindActorAuthorization(createActorAuthorizationRepository(), binding), + branches: { getLinkedBranch: (issueNumber, name) => branches.getLinkedBranch(binding.owner, binding.repository, issueNumber, name, binding.token) }, + workspace: new PreBranchSddWorkspaceAdapter(process.cwd(), binding.token), + }); +} + +export function createSddEvidenceQueryPort(binding: BugbotScmBinding, comments: Pick): SddEvidenceQueryPort { + const query = new SddEvidenceQueryUseCase(createSddEvidenceReadPorts(binding, comments)); + return Object.freeze({ read: query.read.bind(query), serverOrigin: process.env.GITHUB_SERVER_URL ?? 'https://github.com' }); +} diff --git a/src/infrastructure/pre_branch_sdd_workspace_adapter.ts b/src/infrastructure/pre_branch_sdd_workspace_adapter.ts index f8013101f..28646c542 100644 --- a/src/infrastructure/pre_branch_sdd_workspace_adapter.ts +++ b/src/infrastructure/pre_branch_sdd_workspace_adapter.ts @@ -38,6 +38,11 @@ export class PreBranchSddWorkspaceAdapter implements PreBranchSddWorkspacePort { return this.readSnapshotAtSha(baseSha); } + async loadSnapshotAtCommit(commitSha: string): Promise { + assertSha(commitSha); + return this.readSnapshotAtSha(commitSha); + } + private async readSnapshotAtSha(baseSha: string): Promise { const raw = await this.git(['show', `${baseSha}:specs/catalog.json`]); const catalog = JSON.parse(raw) as Catalog;