From 59fda5fca380e29380989658b4753b6447179046 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 21 Oct 2026 18:23:34 +0200 Subject: [PATCH 1/6] bugfix-653-bug-generated-companion-sdd-publishes-colliding-case-ids-and-inconsistent-acceptance-mappings: validate companion case and acceptance consistency --- build/api/index.js | 8 + .../api/src/data/model/application_error.d.ts | 6 +- build/cli/index.js | 367 +++++++++++++++++- build/github_action/index.js | 367 +++++++++++++++++- docs/issues/pre-branch-sdds.mdx | 38 ++ specs/CATALOG.md | 6 +- specs/catalog.json | 6 +- specs/pre-branch-sdd-gate.md | 122 ++++++ ...lication_error_presentation_policy.test.ts | 5 + .../application_error_message_catalog.ts | 40 ++ .../sdd/pre_branch_sdd_draft_workflow.test.ts | 23 ++ .../sdd/pre_branch_sdd_draft_workflow.ts | 19 +- .../sdd/pre_branch_sdd_gate_use_case.ts | 11 +- .../pre_branch_sdd_gate_use_case.test.ts | 39 ++ src/data/model/application_error.ts | 15 + .../__tests__/sdd_draft_consistency.test.ts | 117 ++++++ src/domain/pre_branch_sdd.ts | 28 +- src/domain/sdd_draft_consistency.ts | 192 +++++++++ .../pre_branch_sdd_workspace_adapter.test.ts | 34 +- .../pre_branch_sdd_workspace_adapter.ts | 35 +- src/testing/sdd_consistency_fixture.ts | 47 +++ 21 files changed, 1480 insertions(+), 45 deletions(-) create mode 100644 src/domain/__tests__/sdd_draft_consistency.test.ts create mode 100644 src/domain/sdd_draft_consistency.ts create mode 100644 src/testing/sdd_consistency_fixture.ts diff --git a/build/api/index.js b/build/api/index.js index 1d58a5f8b..f36f61d33 100644 --- a/build/api/index.js +++ b/build/api/index.js @@ -5239,6 +5239,10 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', + 'sdd-draft-case-identity-invalid', + 'sdd-draft-case-budget-invalid', + 'sdd-draft-requirement-mapping-invalid', + 'sdd-draft-repository-path-invalid', 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', @@ -5453,6 +5457,10 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-draft-format-invalid': Object.freeze([]), + 'sdd-draft-case-identity-invalid': Object.freeze([]), + 'sdd-draft-case-budget-invalid': Object.freeze([]), + 'sdd-draft-requirement-mapping-invalid': Object.freeze([]), + 'sdd-draft-repository-path-invalid': Object.freeze([]), 'workspace-changes-rejected': Object.freeze([]), }); function normalizeApplicationErrorRecovery(recovery) { diff --git a/build/api/src/data/model/application_error.d.ts b/build/api/src/data/model/application_error.d.ts index 63bbe93cb..1fabf91cc 100644 --- a/build/api/src/data/model/application_error.d.ts +++ b/build/api/src/data/model/application_error.d.ts @@ -3,7 +3,7 @@ import { type ApprovalEvidenceDiagnostic } from '../../domain/approval_evidence_ import { type BranchSyncValidationCode } from '../../domain/branch_sync_validation'; export type ApplicationErrorKind = 'configuration' | 'authorization' | 'provider' | 'agent' | 'validation' | 'workflow' | 'unknown'; export type ApplicationErrorCode = 'configuration.invalid' | 'configuration.unsupported' | 'authorization.denied' | 'authorization.credential-invalid' | 'provider.not-found' | 'provider.conflict' | 'provider.rate-limited' | 'provider.unavailable' | 'provider.contract-invalid' | 'agent.policy-rejected' | 'agent.failed' | 'locale.output-invalid' | 'locale.translation-failed' | 'validation.invalid-input' | 'validation.evidence-too-large' | 'validation.evidence-unavailable' | 'workflow.invalid-event' | 'workflow.stale' | 'workflow.cancelled' | 'workflow.failed' | 'workflow.presentation-pending' | 'timeout' | 'unexpected'; -export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "sdd-draft-size-exceeded", "sdd-draft-format-invalid", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; +export declare const APPLICATION_ERROR_RECOVERY_IDS: readonly ["pull-request-link-restored", "pull-request-link-base-retained", "pull-request-link-reference-retained", "pull-request-link-base-and-reference-retained", "managed-branch-enrichment-failed", "inactivity-explanation-failed", "bugbot-review-blocks-pending", "branch-sync-agent-disabled", "branch-sync-fixer-unavailable", "branch-sync-resolution-invalid", "sdd-draft-size-exceeded", "sdd-draft-format-invalid", "sdd-draft-case-identity-invalid", "sdd-draft-case-budget-invalid", "sdd-draft-requirement-mapping-invalid", "sdd-draft-repository-path-invalid", "workspace-changes-rejected", "closed-issue-reconciliation-failed", "approval-evidence-read-failed", "stored-configuration-schema-missing", "stored-configuration-schema-unsupported", "stored-configuration-invalid"]; export type ApplicationErrorRecoveryId = typeof APPLICATION_ERROR_RECOVERY_IDS[number]; interface ApplicationErrorRecoveryVariables { readonly 'stored-configuration-schema-missing': Readonly<{ @@ -46,6 +46,10 @@ interface ApplicationErrorRecoveryVariables { }>; readonly 'sdd-draft-size-exceeded': Readonly>; readonly 'sdd-draft-format-invalid': Readonly>; + readonly 'sdd-draft-case-identity-invalid': Readonly>; + readonly 'sdd-draft-case-budget-invalid': Readonly>; + readonly 'sdd-draft-requirement-mapping-invalid': Readonly>; + readonly 'sdd-draft-repository-path-invalid': Readonly>; readonly 'workspace-changes-rejected': Readonly>; } export type ApplicationErrorRecovery = { diff --git a/build/cli/index.js b/build/cli/index.js index 1138be24d..3b3c41ed1 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -39388,6 +39388,26 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ action: 'Inspect the rejected paths in the workflow and correct the workspace before retrying.', retainedState: 'The normal commit and push steps were skipped. Verify the current state before retrying.', }), + 'sdd-draft-case-identity-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: case IDs collide with an owning SDD or are ambiguous.', + action: 'Review the draft tables; use distinct fully qualified case IDs and preserve the baseline inventory. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-case-budget-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: per-area allocations disagree with the distinct case inventory.', + action: 'Review the draft tables; reconcile each area and the total with the enumerated cases. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-requirement-mapping-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: requirements and case mappings are incomplete or inconsistent.', + action: 'Review the draft tables; map every requirement to defined cases and match the reverse links. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-repository-path-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: cited repository paths cannot be verified at the immutable base.', + action: 'Review the draft tables; correct existing paths or explicitly declare intended new files. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), 'sdd-draft-format-invalid': Object.freeze({ impact: 'SDD preparation stopped because the draft still lacked required headings or a numeric test budget after one automatic repair.', action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', @@ -39480,6 +39500,26 @@ const SPANISH_RECOVERY_CONTENT = Object.freeze({ action: 'Revisa las rutas rechazadas en la ejecución y corrige el espacio de trabajo antes de reintentar.', retainedState: 'Se omitieron los pasos normales de commit y push. Comprueba el estado actual antes de reintentar.', }), + 'sdd-draft-case-identity-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: los identificadores de casos coinciden con un SDD existente o son ambiguos.', + action: 'Revisa las tablas del borrador; usa identificadores completos y distintos y conserva el inventario original. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-case-budget-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: las asignaciones por área no coinciden con el inventario de casos distintos.', + action: 'Revisa las tablas del borrador; reconcilia cada área y el total con los casos enumerados. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-requirement-mapping-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: los requisitos y sus casos están incompletos o son inconsistentes.', + action: 'Revisa las tablas del borrador; vincula cada requisito a casos definidos y comprueba los enlaces inversos. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-repository-path-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: no se pueden verificar las rutas citadas en la revisión base inmutable.', + action: 'Revisa las tablas del borrador; corrige las rutas existentes o declara explícitamente los archivos nuevos previstos. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), 'sdd-draft-format-invalid': Object.freeze({ impact: 'La preparación del SDD se detuvo porque seguían faltando secciones obligatorias o un presupuesto numérico de pruebas tras un intento automático de corrección.', action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', @@ -60731,6 +60771,7 @@ exports.draftSddMarkdown = draftSddMarkdown; const application_error_1 = __nccwpck_require__(75999); const pre_branch_sdd_1 = __nccwpck_require__(34730); const agent_response_length_failure_1 = __nccwpck_require__(28528); +const sdd_draft_consistency_1 = __nccwpck_require__(27910); /** Size and typed format failures share one regeneration; the writer still validates every candidate. */ async function draftSddMarkdown(agent, configuration, plan, prompt, validate) { const maximum = plan.action === 'update' ? pre_branch_sdd_1.SDD_MAX_UPDATE_CHARACTERS : pre_branch_sdd_1.SDD_MAX_DRAFT_CHARACTERS; @@ -60742,13 +60783,13 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate) { }; let problem = 'size'; let formatReason = 'required-heading'; - const contractPrompt = `${prompt}\n\nDraft contract: keep the complete draft within ${maximum} characters; aim for ${target} characters. Preserve the same owner, decisions and requirements. Include these literal canonical heading prefixes as Markdown headings, with the shown capitalization:\n${pre_branch_sdd_1.SDD_REQUIRED_HEADINGS.join('\n')}\nUse the canonical numeric test-budget table in section 14: Area | Minimum distinct cases | Behaviors/risks covered. Every allocation and the Total must be a positive integer, and the Total must equal the sum of the allocations. A justified positive integer sentence of distinct tests or cases is also accepted. Return only the required JSON.`; + const contractPrompt = `${prompt}\n\nDraft contract: keep the complete draft within ${maximum} characters; aim for ${target} characters. Preserve the same owner, decisions and requirements. Include these literal canonical heading prefixes as Markdown headings, with the shown capitalization:\n${pre_branch_sdd_1.SDD_REQUIRED_HEADINGS.join('\n')}\nUse the canonical numeric test-budget table in section 14: Area | Minimum distinct cases | Behaviors/risks covered. Every allocation and the Total must be a positive integer, and the Total must equal the sum of the allocations. Return only the required JSON.`; for (let attempt = 0; attempt < 2; attempt++) { let value; try { value = await agent.query({ configuration, agentId: 'pre-branch-sdd-draft', - prompt: attempt === 0 ? contractPrompt : `${contractPrompt}\n\n${problem === 'size' ? `The previous response exceeded the ${maximum}-character draft limit.` : `The previous response failed the required SDD ${formatReason} contract.`} Regenerate a concise complete draft for this same owner and plan. Use the exact numbered canonical headings from the supplied template. Preserve all resolved requirements, architecture, documentation and acceptance. Do not change ownership or truncate a requirement.`, + prompt: `${attempt === 0 ? contractPrompt : `${contractPrompt}\n\n${problem === 'size' ? `The previous response exceeded the ${maximum}-character draft limit.` : `The previous response failed the required SDD ${formatReason} contract.`} Regenerate a concise complete draft for this same owner and plan. Use the exact numbered canonical headings from the supplied template. Preserve all resolved requirements, architecture, documentation and acceptance. Do not change ownership or truncate a requirement.`}\n\n${sdd_draft_consistency_1.SDD_CONSISTENCY_PROMPT}`, options: { expectJson: true, schemaName: 'pre_branch_sdd_draft', schema }, }); } @@ -60782,12 +60823,21 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate) { } if (problem === 'format') throw new application_error_1.ApplicationError('validation.invalid-input', `The SDD draft still violates its ${formatReason} format contract after one bounded repair.`, { - recovery: { id: 'sdd-draft-format-invalid', variables: {} }, + recovery: { id: consistencyRecovery(formatReason), variables: {} }, }); throw new application_error_1.ApplicationError('validation.invalid-input', 'The SDD draft exceeded its size limit after one bounded repair.', { recovery: { id: 'sdd-draft-size-exceeded', variables: {} }, }); } +function consistencyRecovery(reason) { + switch (reason) { + case 'case-identity': return 'sdd-draft-case-identity-invalid'; + case 'case-budget': return 'sdd-draft-case-budget-invalid'; + case 'requirement-mapping': return 'sdd-draft-requirement-mapping-invalid'; + case 'repository-path': return 'sdd-draft-repository-path-invalid'; + default: return 'sdd-draft-format-invalid'; + } +} /***/ }), @@ -60808,6 +60858,7 @@ const logging_ports_1 = __nccwpck_require__(6152); const sdd_analysis_status_policy_1 = __nccwpck_require__(81053); const application_error_1 = __nccwpck_require__(75999); const pre_branch_sdd_draft_workflow_1 = __nccwpck_require__(93454); +const sdd_draft_consistency_1 = __nccwpck_require__(27910); const ANALYSIS_SCHEMA = { type: 'object', properties: { @@ -60962,9 +61013,19 @@ class PreBranchSddGateUseCase { const currentSdd = plan.action === 'update' ? await this.workspace.readSdd(snapshot.baseSha, plan.path) : undefined; if (plan.action === 'update' && !currentSdd) throw new Error('The catalogued SDD owner is missing from the selected base.'); + const ownerDocuments = []; + for (const path of snapshot.capabilities.find(capability => capability.id === plan.capabilityId)?.specs ?? []) { + const markdown = path === plan.path && currentSdd ? currentSdd : await this.workspace.readSdd(snapshot.baseSha, path); + if (!markdown) + throw new Error('A catalogued owning SDD is unavailable at the selected immutable base.'); + ownerDocuments.push({ path, markdown }); + } + const reservedCases = JSON.stringify((0, sdd_draft_consistency_1.reservedSddCaseIds)(ownerDocuments)); + if (reservedCases.length > 30000) + throw new Error('The owning SDD case inventory exceeds the bounded drafting context; maintainer review is required.'); const newCapability = plan.action === 'new' ? parseNewCapability(analysisValue.newCapability, plan) : undefined; let prepared; - await (0, pre_branch_sdd_draft_workflow_1.draftSddMarkdown)(this.agent, context.agentConfiguration, plan, buildDraftPrompt(context, snapshot, plan, answers, currentSdd), async (markdown) => { + await (0, pre_branch_sdd_draft_workflow_1.draftSddMarkdown)(this.agent, context.agentConfiguration, plan, `${buildDraftPrompt(context, snapshot, plan, answers, currentSdd)}\n\nCase IDs reserved across every owning SDD at ${snapshot.baseSha}:\n${reservedCases}`, async (markdown) => { prepared = await this.workspace.validateDraft(snapshot, plan, markdown, newCapability); }); if (!prepared) @@ -78539,6 +78600,10 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', + 'sdd-draft-case-identity-invalid', + 'sdd-draft-case-budget-invalid', + 'sdd-draft-requirement-mapping-invalid', + 'sdd-draft-repository-path-invalid', 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', @@ -78753,6 +78818,10 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-draft-format-invalid': Object.freeze([]), + 'sdd-draft-case-identity-invalid': Object.freeze([]), + 'sdd-draft-case-budget-invalid': Object.freeze([]), + 'sdd-draft-requirement-mapping-invalid': Object.freeze([]), + 'sdd-draft-repository-path-invalid': Object.freeze([]), 'workspace-changes-rejected': Object.freeze([]), }); function normalizeApplicationErrorRecovery(recovery) { @@ -92111,6 +92180,7 @@ exports.isSddAnswerComment = isSddAnswerComment; exports.parseSddAnswer = parseSddAnswer; exports.validateSddMarkdown = validateSddMarkdown; exports.composeSddUpdate = composeSddUpdate; +exports.recoverSddUpdate = recoverSddUpdate; exports.SDD_GATE_MARKER = 'copilot:sdd-gate:v1'; exports.SDD_MIN_DRAFT_CHARACTERS = 1800; exports.SDD_MAX_DRAFT_CHARACTERS = 70000; @@ -92414,8 +92484,22 @@ function composeSddUpdate(existing, change) { validateSddMarkdown(change); if (change.length > exports.SDD_MAX_UPDATE_CHARACTERS) throw new Error('An SDD update appendix exceeds 18000 characters; use a proportional change or a companion SDD.'); + const appendix = shiftSddHeadings(change, 1); + const merged = `${existing.trimEnd()}\n\n${appendix.trim()}\n`; + validateSddMarkdown(merged); + return merged; +} +/** Recovery validates the original candidate, not a second copy of its retained baseline. */ +function recoverSddUpdate(existing, published) { + const prefix = `${existing.trimEnd()}\n\n`; + if (!published.startsWith(prefix)) + return undefined; + const change = shiftSddHeadings(published.slice(prefix.length), -1); + return composeSddUpdate(existing, change) === published ? change : undefined; +} +function shiftSddHeadings(source, direction) { let fence; - const appendix = change.split('\n').map(line => { + return source.split('\n').map(line => { const boundary = line.match(/^\s*(`{3,}|~{3,})/); if (boundary) { if (!fence) @@ -92424,11 +92508,11 @@ function composeSddUpdate(existing, change) { fence = undefined; return line; } - return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; + if (fence) + return line; + return direction === 1 ? (/^#{1,5} /.test(line) ? `#${line}` : line) + : (/^#{2,6} /.test(line) ? line.slice(1) : line); }).join('\n'); - const merged = `${existing.trimEnd()}\n\n${appendix.trim()}\n`; - validateSddMarkdown(merged); - return merged; } function serializeSddGateValue(value) { // Human answer text must never terminate its hidden state comment. @@ -93025,6 +93109,232 @@ function replyRequestBodyDigest(body) { } +/***/ }), + +/***/ 27910: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SDD_CONSISTENCY_PROMPT = void 0; +exports.reservedSddCaseIds = reservedSddCaseIds; +exports.validateSddDraftConsistency = validateSddDraftConsistency; +const pre_branch_sdd_1 = __nccwpck_require__(34730); +const CASE = /\b(?:[A-Za-z][A-Za-z0-9]*-)+C\d+\b/gu; +const CASE_ID = /^(?:[A-Za-z][A-Za-z0-9]*-)+C\d+$/u; +const REQUIREMENT_ID = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/u; +/** Generated candidates use visible tables; retained legacy owners are never reformatted. */ +exports.SDD_CONSISTENCY_PROMPT = `Consistency contract for the proposed draft/appendix (not the retained owner): +In section 4 include one table: Requirement | Behavior. Give every goal a stable requirement ID. +In section 14 include one budget table: Area | Minimum distinct cases | Behaviors/risks covered; positive integers and one Total. +Also in section 14 include one inventory table: Case ID | Area | Requirements | Scenario. +Each row is one distinct executable case with a fully qualified ID such as capability-change-C01, one exact budget area, comma-separated requirement IDs and an observable scenario. Never use shorthand C01, ranges or redefine an ID found in any owning SDD. Reference retained baseline cases in prose using their full IDs; count only the new inventory. +In section 17 include one table: Requirement | Cases. List comma-separated full case IDs for every requirement. Its links must exactly match the case inventory in both directions, with no undefined or generic documentation checks. +In section 15 include one table: Path | Change. Change is update, reference or create. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; +function fail(reason) { + // Closed diagnostics: rejected Markdown and repository text never become repair instructions. + throw new pre_branch_sdd_1.SddDraftFormatFailure(reason, `The SDD draft violates its ${reason} consistency contract.`); +} +/** Excludes examples and hidden records from the contract. */ +function visible(source) { + let fence; + return source.replace(/|$)/gu, '\n').replace(/\r\n/gu, '\n').split('\n').map(line => { + const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); + if (boundary) { + if (!fence) + fence = boundary[1]; + else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) + fence = undefined; + return ''; + } + return fence ? '' : line; + }).join('\n'); +} +function clean(value) { return value.trim().replace(/^(?:\*\*|__|`)(.*?)(?:\*\*|__|`)$/u, '$1').trim(); } +function table(source, section, headers, reason) { + const sections = [...source.matchAll(/^## (\d+)\. [^\n]*$/gmu)]; + const matches = sections.filter(heading => Number(heading[1]) === section); + if (matches.length !== 1) + return fail(reason); + const start = matches[0]; + const end = sections.find(heading => heading.index > start.index)?.index ?? source.length; + const lines = source.slice(start.index + start[0].length, end).split('\n'); + const found = []; + for (let index = 0; index < lines.length; index++) { + if (!/^\s*\|/u.test(lines[index])) + continue; + const cells = (line) => line.trim().replace(/^\||\|$/gu, '').split('|').map(clean); + const header = cells(lines[index]); + if (!headers.every((value, position) => header[position] === value) || header.length !== headers.length) + continue; + const separator = cells(lines[++index] ?? ''); + if (separator.length !== headers.length || !separator.every(cell => /^:?-{3,}:?$/u.test(cell))) + return fail(reason); + const rows = []; + while (++index < lines.length && /^\s*\|/u.test(lines[index])) { + const row = cells(lines[index]); + if (row.length !== headers.length || row.some(cell => !cell)) + return fail(reason); + rows.push(row); + } + if (!rows.length) + return fail(reason); + found.push(rows); + } + return found.length === 1 ? found[0] : fail(reason); +} +function ids(value, pattern, reason) { + const values = value.split(/\s*,\s*/u); + if (!values.every(id => pattern.test(id)) || new Set(values).size !== values.length) + return fail(reason); + return values; +} +function reservedSddCaseIds(documents) { + const reserved = new Set(); + for (const document of documents) { + const source = visible(document.markdown); + const qualified = source.match(CASE) ?? []; + const namespaces = new Set(qualified.map(id => id.slice(0, id.lastIndexOf('-C') + 2))); + for (const id of qualified) + reserved.add(id); + // Legacy inventories often qualify C01 and abbreviate the remaining rows. + // Reserve every plausible namespace instead of silently assigning an alias a new meaning. + for (const match of source.matchAll(/(? 1000) { + throw new Error('An owning SDD case range cannot be reconciled within the fixed bound; review the baseline before retrying.'); + } + for (let number = start; number <= end; number++) { + for (const namespace of namespaces) + reserved.add(`${namespace}${String(number).padStart(match[1].length, '0')}`); + } + } + } + return [...reserved].sort(); +} +function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { + const source = visible(markdown); + const requirements = table(source, 4, ['Requirement', 'Behavior'], 'requirement-mapping'); + const requirementIds = requirements.map(row => row[0]); + if (new Set(requirementIds).size !== requirementIds.length || !requirementIds.every(id => REQUIREMENT_ID.test(id))) + fail('requirement-mapping'); + const cases = table(source, 14, ['Case ID', 'Area', 'Requirements', 'Scenario'], 'case-identity'); + const caseIds = cases.map(row => row[0]); + const reserved = new Set(reservedSddCaseIds(owners)); + if (new Set(caseIds).size !== caseIds.length || caseIds.some(id => !CASE_ID.test(id) || reserved.has(id)) + || /(? !caseIds.includes(id) && !reserved.has(id))) + fail('case-identity'); + const budget = table(source, 14, ['Area', 'Minimum distinct cases', 'Behaviors/risks covered'], 'case-budget'); + const counts = new Map(); + let total; + for (const [area, value] of budget) { + const count = /^[1-9]\d*$/u.test(value) ? Number(value) : NaN; + if (!Number.isSafeInteger(count) || counts.has(area) || total !== undefined) + fail('case-budget'); + if (area === 'Total') + total = count; + else + counts.set(area, count); + } + if (!counts.size || total !== caseIds.length || [...counts.values()].reduce((sum, count) => sum + count, 0) !== total + || cases.some(row => !counts.has(row[1]))) + fail('case-budget'); + for (const [area, count] of counts) + if (cases.filter(row => row[1] === area).length !== count) + fail('case-budget'); + const edges = new Map(requirementIds.map(id => [id, new Set()])); + for (const [id, , links] of cases) { + for (const requirement of ids(links, REQUIREMENT_ID, 'requirement-mapping')) { + const linked = edges.get(requirement); + if (!linked) + fail('requirement-mapping'); + linked.add(id); + } + } + const traceability = table(source, 17, ['Requirement', 'Cases'], 'requirement-mapping'); + const seen = new Set(); + for (const [requirement, links] of traceability) { + const expected = edges.get(requirement); + const actual = ids(links, CASE_ID, 'requirement-mapping'); + if (!expected || seen.has(requirement) || expected.size !== actual.length || actual.some(id => !expected.has(id))) + fail('requirement-mapping'); + seen.add(requirement); + } + if (seen.size !== edges.size || [...edges.values()].some(links => links.size === 0)) + fail('requirement-mapping'); + validatePaths(source, inventory, sddPath); +} +function safePath(value) { + return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) + && value.split('/').every(segment => segment && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git'); +} +function validatePaths(source, inventory, sddPath) { + const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); + const proposed = new Set([sddPath]); + const seen = new Set(); + for (const [file, change] of rows) { + if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) + fail('repository-path'); + seen.add(file); + if (change === 'create') { + if (inventory.files.has(file) || inventory.directories.has(file)) + fail('repository-path'); + const segments = file.split('/'); + if (segments.some((_, index) => inventory.files.has(segments.slice(0, index + 1).join('/')) || inventory.blocked.has(segments.slice(0, index + 1).join('/')))) + fail('repository-path'); + proposed.add(file); + } + else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) + fail('repository-path'); + } + const check = (file) => { + if (!safePath(file) || (!inventory.files.has(file) && !inventory.directories.has(file) && !proposed.has(file))) + fail('repository-path'); + }; + // Relative Markdown file links resolve against the selected SDD, never the current checkout. + for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + const href = match[1].split('#')[0]; + if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) + continue; + let decoded; + try { + decoded = decodeURIComponent(href); + } + catch { + return fail('repository-path'); + } + const parts = sddPath.split('/').slice(0, -1); + for (const part of decoded.split('/')) { + if (part === '..') { + if (!parts.length) + fail('repository-path'); + parts.pop(); + } + else if (part !== '.') + parts.push(part); + } + check(parts.join('/')); + } + for (const match of source.matchAll(/`([^`\n]+)`/gu)) { + const value = match[1]; + if (value.includes('/') && !/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value) && !/\s/u.test(value)) + check(value.replace(/\/$/u, '')); + } + const prose = source.replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + for (const match of prose.matchAll(/(? capability.id === plan.capabilityId)?.specs ?? []) { + if (!(0, pre_branch_sdd_1.isSafeSddPath)(file)) + throw new Error('An owning SDD path is unsafe.'); + if (!inventory.files.has(file)) + throw new Error('An owning SDD is not a regular file at the immutable base.'); + assertRegularSpecFile(path.join(root, file)); + owners.push({ path: file, markdown: await this.git(['show', `${snapshot.baseSha}:${file}`]) }); + } + (0, sdd_draft_consistency_1.validateSddDraftConsistency)(markdown, owners, inventory, plan.path); if (plan.action === 'update') { assertRegularSpecFile(target); const owner = await this.git(['show', `${snapshot.baseSha}:${plan.path}`]); @@ -98414,7 +98736,13 @@ class PreBranchSddWorkspaceAdapter { const raw = await this.git(['show', `${first}:specs/catalog.json`]); newCapability = JSON.parse(raw).capabilities.find(capability => capability.id === prepared.plan.capabilityId); } - const recovered = await this.validateDraft(snapshot, prepared.plan, content, newCapability); + const candidate = prepared.plan.action === 'update' + ? (0, pre_branch_sdd_1.recoverSddUpdate)(await this.git(['show', `${prepared.baseSha}:${prepared.plan.path}`]), content) : content; + if (!candidate) + return undefined; + const recovered = await this.validateDraft(snapshot, prepared.plan, candidate, newCapability); + if (recovered.markdown !== content) + return undefined; const committedPaths = (await this.git(['diff-tree', '--no-commit-id', '--name-only', '-r', first])).trim().split('\n').filter(Boolean).sort(); if (committedPaths.join('\n') !== recovered.changedPaths.join('\n')) return undefined; @@ -98449,6 +98777,25 @@ class PreBranchSddWorkspaceAdapter { const output = await this.git(['status', '--porcelain', '--untracked-files=all'], root); return output.split('\n').filter(Boolean).map(line => line.slice(3)).sort(); } + async inventoryAt(sha) { + const files = new Set(); + const directories = new Set(); + const blocked = new Set(); + const tree = await this.git(['ls-tree', '-rz', '--full-tree', sha]); + for (const entry of tree.split('\0').filter(Boolean)) { + const tab = entry.indexOf('\t'); + const file = entry.slice(tab + 1); + const mode = entry.slice(0, 6); + if (mode === '100644' || mode === '100755') + files.add(file); + else + blocked.add(file); + const parts = file.split('/'); + for (let index = 1; index < parts.length; index++) + directories.add(parts.slice(0, index).join('/')); + } + return { files, directories, blocked }; + } async addDetachedWorktree(sha) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-sdd-')); try { diff --git a/build/github_action/index.js b/build/github_action/index.js index c11de64b9..96314c0c1 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -40750,6 +40750,26 @@ const ENGLISH_RECOVERY_CONTENT = Object.freeze({ action: 'Inspect the rejected paths in the workflow and correct the workspace before retrying.', retainedState: 'The normal commit and push steps were skipped. Verify the current state before retrying.', }), + 'sdd-draft-case-identity-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: case IDs collide with an owning SDD or are ambiguous.', + action: 'Review the draft tables; use distinct fully qualified case IDs and preserve the baseline inventory. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-case-budget-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: per-area allocations disagree with the distinct case inventory.', + action: 'Review the draft tables; reconcile each area and the total with the enumerated cases. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-requirement-mapping-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: requirements and case mappings are incomplete or inconsistent.', + action: 'Review the draft tables; map every requirement to defined cases and match the reverse links. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-repository-path-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: cited repository paths cannot be verified at the immutable base.', + action: 'Review the draft tables; correct existing paths or explicitly declare intended new files. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), 'sdd-draft-format-invalid': Object.freeze({ impact: 'SDD preparation stopped because the draft still lacked required headings or a numeric test budget after one automatic repair.', action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', @@ -40842,6 +40862,26 @@ const SPANISH_RECOVERY_CONTENT = Object.freeze({ action: 'Revisa las rutas rechazadas en la ejecución y corrige el espacio de trabajo antes de reintentar.', retainedState: 'Se omitieron los pasos normales de commit y push. Comprueba el estado actual antes de reintentar.', }), + 'sdd-draft-case-identity-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: los identificadores de casos coinciden con un SDD existente o son ambiguos.', + action: 'Revisa las tablas del borrador; usa identificadores completos y distintos y conserva el inventario original. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-case-budget-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: las asignaciones por área no coinciden con el inventario de casos distintos.', + action: 'Revisa las tablas del borrador; reconcilia cada área y el total con los casos enumerados. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-requirement-mapping-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: los requisitos y sus casos están incompletos o son inconsistentes.', + action: 'Revisa las tablas del borrador; vincula cada requisito a casos definidos y comprueba los enlaces inversos. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-repository-path-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: no se pueden verificar las rutas citadas en la revisión base inmutable.', + action: 'Revisa las tablas del borrador; corrige las rutas existentes o declara explícitamente los archivos nuevos previstos. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), 'sdd-draft-format-invalid': Object.freeze({ impact: 'La preparación del SDD se detuvo porque seguían faltando secciones obligatorias o un presupuesto numérico de pruebas tras un intento automático de corrección.', action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', @@ -58772,6 +58812,7 @@ exports.draftSddMarkdown = draftSddMarkdown; const application_error_1 = __nccwpck_require__(75999); const pre_branch_sdd_1 = __nccwpck_require__(34730); const agent_response_length_failure_1 = __nccwpck_require__(28528); +const sdd_draft_consistency_1 = __nccwpck_require__(27910); /** Size and typed format failures share one regeneration; the writer still validates every candidate. */ async function draftSddMarkdown(agent, configuration, plan, prompt, validate) { const maximum = plan.action === 'update' ? pre_branch_sdd_1.SDD_MAX_UPDATE_CHARACTERS : pre_branch_sdd_1.SDD_MAX_DRAFT_CHARACTERS; @@ -58783,13 +58824,13 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate) { }; let problem = 'size'; let formatReason = 'required-heading'; - const contractPrompt = `${prompt}\n\nDraft contract: keep the complete draft within ${maximum} characters; aim for ${target} characters. Preserve the same owner, decisions and requirements. Include these literal canonical heading prefixes as Markdown headings, with the shown capitalization:\n${pre_branch_sdd_1.SDD_REQUIRED_HEADINGS.join('\n')}\nUse the canonical numeric test-budget table in section 14: Area | Minimum distinct cases | Behaviors/risks covered. Every allocation and the Total must be a positive integer, and the Total must equal the sum of the allocations. A justified positive integer sentence of distinct tests or cases is also accepted. Return only the required JSON.`; + const contractPrompt = `${prompt}\n\nDraft contract: keep the complete draft within ${maximum} characters; aim for ${target} characters. Preserve the same owner, decisions and requirements. Include these literal canonical heading prefixes as Markdown headings, with the shown capitalization:\n${pre_branch_sdd_1.SDD_REQUIRED_HEADINGS.join('\n')}\nUse the canonical numeric test-budget table in section 14: Area | Minimum distinct cases | Behaviors/risks covered. Every allocation and the Total must be a positive integer, and the Total must equal the sum of the allocations. Return only the required JSON.`; for (let attempt = 0; attempt < 2; attempt++) { let value; try { value = await agent.query({ configuration, agentId: 'pre-branch-sdd-draft', - prompt: attempt === 0 ? contractPrompt : `${contractPrompt}\n\n${problem === 'size' ? `The previous response exceeded the ${maximum}-character draft limit.` : `The previous response failed the required SDD ${formatReason} contract.`} Regenerate a concise complete draft for this same owner and plan. Use the exact numbered canonical headings from the supplied template. Preserve all resolved requirements, architecture, documentation and acceptance. Do not change ownership or truncate a requirement.`, + prompt: `${attempt === 0 ? contractPrompt : `${contractPrompt}\n\n${problem === 'size' ? `The previous response exceeded the ${maximum}-character draft limit.` : `The previous response failed the required SDD ${formatReason} contract.`} Regenerate a concise complete draft for this same owner and plan. Use the exact numbered canonical headings from the supplied template. Preserve all resolved requirements, architecture, documentation and acceptance. Do not change ownership or truncate a requirement.`}\n\n${sdd_draft_consistency_1.SDD_CONSISTENCY_PROMPT}`, options: { expectJson: true, schemaName: 'pre_branch_sdd_draft', schema }, }); } @@ -58823,12 +58864,21 @@ async function draftSddMarkdown(agent, configuration, plan, prompt, validate) { } if (problem === 'format') throw new application_error_1.ApplicationError('validation.invalid-input', `The SDD draft still violates its ${formatReason} format contract after one bounded repair.`, { - recovery: { id: 'sdd-draft-format-invalid', variables: {} }, + recovery: { id: consistencyRecovery(formatReason), variables: {} }, }); throw new application_error_1.ApplicationError('validation.invalid-input', 'The SDD draft exceeded its size limit after one bounded repair.', { recovery: { id: 'sdd-draft-size-exceeded', variables: {} }, }); } +function consistencyRecovery(reason) { + switch (reason) { + case 'case-identity': return 'sdd-draft-case-identity-invalid'; + case 'case-budget': return 'sdd-draft-case-budget-invalid'; + case 'requirement-mapping': return 'sdd-draft-requirement-mapping-invalid'; + case 'repository-path': return 'sdd-draft-repository-path-invalid'; + default: return 'sdd-draft-format-invalid'; + } +} /***/ }), @@ -58849,6 +58899,7 @@ const logging_ports_1 = __nccwpck_require__(6152); const sdd_analysis_status_policy_1 = __nccwpck_require__(81053); const application_error_1 = __nccwpck_require__(75999); const pre_branch_sdd_draft_workflow_1 = __nccwpck_require__(93454); +const sdd_draft_consistency_1 = __nccwpck_require__(27910); const ANALYSIS_SCHEMA = { type: 'object', properties: { @@ -59003,9 +59054,19 @@ class PreBranchSddGateUseCase { const currentSdd = plan.action === 'update' ? await this.workspace.readSdd(snapshot.baseSha, plan.path) : undefined; if (plan.action === 'update' && !currentSdd) throw new Error('The catalogued SDD owner is missing from the selected base.'); + const ownerDocuments = []; + for (const path of snapshot.capabilities.find(capability => capability.id === plan.capabilityId)?.specs ?? []) { + const markdown = path === plan.path && currentSdd ? currentSdd : await this.workspace.readSdd(snapshot.baseSha, path); + if (!markdown) + throw new Error('A catalogued owning SDD is unavailable at the selected immutable base.'); + ownerDocuments.push({ path, markdown }); + } + const reservedCases = JSON.stringify((0, sdd_draft_consistency_1.reservedSddCaseIds)(ownerDocuments)); + if (reservedCases.length > 30000) + throw new Error('The owning SDD case inventory exceeds the bounded drafting context; maintainer review is required.'); const newCapability = plan.action === 'new' ? parseNewCapability(analysisValue.newCapability, plan) : undefined; let prepared; - await (0, pre_branch_sdd_draft_workflow_1.draftSddMarkdown)(this.agent, context.agentConfiguration, plan, buildDraftPrompt(context, snapshot, plan, answers, currentSdd), async (markdown) => { + await (0, pre_branch_sdd_draft_workflow_1.draftSddMarkdown)(this.agent, context.agentConfiguration, plan, `${buildDraftPrompt(context, snapshot, plan, answers, currentSdd)}\n\nCase IDs reserved across every owning SDD at ${snapshot.baseSha}:\n${reservedCases}`, async (markdown) => { prepared = await this.workspace.validateDraft(snapshot, plan, markdown, newCapability); }); if (!prepared) @@ -68963,6 +69024,10 @@ exports.APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', + 'sdd-draft-case-identity-invalid', + 'sdd-draft-case-budget-invalid', + 'sdd-draft-requirement-mapping-invalid', + 'sdd-draft-repository-path-invalid', 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', @@ -69177,6 +69242,10 @@ const RECOVERY_VARIABLE_KEYS = Object.freeze({ 'branch-sync-resolution-invalid': Object.freeze(['validationCode', 'command']), 'sdd-draft-size-exceeded': Object.freeze([]), 'sdd-draft-format-invalid': Object.freeze([]), + 'sdd-draft-case-identity-invalid': Object.freeze([]), + 'sdd-draft-case-budget-invalid': Object.freeze([]), + 'sdd-draft-requirement-mapping-invalid': Object.freeze([]), + 'sdd-draft-repository-path-invalid': Object.freeze([]), 'workspace-changes-rejected': Object.freeze([]), }); function normalizeApplicationErrorRecovery(recovery) { @@ -82604,6 +82673,7 @@ exports.isSddAnswerComment = isSddAnswerComment; exports.parseSddAnswer = parseSddAnswer; exports.validateSddMarkdown = validateSddMarkdown; exports.composeSddUpdate = composeSddUpdate; +exports.recoverSddUpdate = recoverSddUpdate; exports.SDD_GATE_MARKER = 'copilot:sdd-gate:v1'; exports.SDD_MIN_DRAFT_CHARACTERS = 1800; exports.SDD_MAX_DRAFT_CHARACTERS = 70000; @@ -82907,8 +82977,22 @@ function composeSddUpdate(existing, change) { validateSddMarkdown(change); if (change.length > exports.SDD_MAX_UPDATE_CHARACTERS) throw new Error('An SDD update appendix exceeds 18000 characters; use a proportional change or a companion SDD.'); + const appendix = shiftSddHeadings(change, 1); + const merged = `${existing.trimEnd()}\n\n${appendix.trim()}\n`; + validateSddMarkdown(merged); + return merged; +} +/** Recovery validates the original candidate, not a second copy of its retained baseline. */ +function recoverSddUpdate(existing, published) { + const prefix = `${existing.trimEnd()}\n\n`; + if (!published.startsWith(prefix)) + return undefined; + const change = shiftSddHeadings(published.slice(prefix.length), -1); + return composeSddUpdate(existing, change) === published ? change : undefined; +} +function shiftSddHeadings(source, direction) { let fence; - const appendix = change.split('\n').map(line => { + return source.split('\n').map(line => { const boundary = line.match(/^\s*(`{3,}|~{3,})/); if (boundary) { if (!fence) @@ -82917,11 +83001,11 @@ function composeSddUpdate(existing, change) { fence = undefined; return line; } - return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; + if (fence) + return line; + return direction === 1 ? (/^#{1,5} /.test(line) ? `#${line}` : line) + : (/^#{2,6} /.test(line) ? line.slice(1) : line); }).join('\n'); - const merged = `${existing.trimEnd()}\n\n${appendix.trim()}\n`; - validateSddMarkdown(merged); - return merged; } function serializeSddGateValue(value) { // Human answer text must never terminate its hidden state comment. @@ -83518,6 +83602,232 @@ function replyRequestBodyDigest(body) { } +/***/ }), + +/***/ 27910: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.SDD_CONSISTENCY_PROMPT = void 0; +exports.reservedSddCaseIds = reservedSddCaseIds; +exports.validateSddDraftConsistency = validateSddDraftConsistency; +const pre_branch_sdd_1 = __nccwpck_require__(34730); +const CASE = /\b(?:[A-Za-z][A-Za-z0-9]*-)+C\d+\b/gu; +const CASE_ID = /^(?:[A-Za-z][A-Za-z0-9]*-)+C\d+$/u; +const REQUIREMENT_ID = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/u; +/** Generated candidates use visible tables; retained legacy owners are never reformatted. */ +exports.SDD_CONSISTENCY_PROMPT = `Consistency contract for the proposed draft/appendix (not the retained owner): +In section 4 include one table: Requirement | Behavior. Give every goal a stable requirement ID. +In section 14 include one budget table: Area | Minimum distinct cases | Behaviors/risks covered; positive integers and one Total. +Also in section 14 include one inventory table: Case ID | Area | Requirements | Scenario. +Each row is one distinct executable case with a fully qualified ID such as capability-change-C01, one exact budget area, comma-separated requirement IDs and an observable scenario. Never use shorthand C01, ranges or redefine an ID found in any owning SDD. Reference retained baseline cases in prose using their full IDs; count only the new inventory. +In section 17 include one table: Requirement | Cases. List comma-separated full case IDs for every requirement. Its links must exactly match the case inventory in both directions, with no undefined or generic documentation checks. +In section 15 include one table: Path | Change. Change is update, reference or create. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; +function fail(reason) { + // Closed diagnostics: rejected Markdown and repository text never become repair instructions. + throw new pre_branch_sdd_1.SddDraftFormatFailure(reason, `The SDD draft violates its ${reason} consistency contract.`); +} +/** Excludes examples and hidden records from the contract. */ +function visible(source) { + let fence; + return source.replace(/|$)/gu, '\n').replace(/\r\n/gu, '\n').split('\n').map(line => { + const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); + if (boundary) { + if (!fence) + fence = boundary[1]; + else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) + fence = undefined; + return ''; + } + return fence ? '' : line; + }).join('\n'); +} +function clean(value) { return value.trim().replace(/^(?:\*\*|__|`)(.*?)(?:\*\*|__|`)$/u, '$1').trim(); } +function table(source, section, headers, reason) { + const sections = [...source.matchAll(/^## (\d+)\. [^\n]*$/gmu)]; + const matches = sections.filter(heading => Number(heading[1]) === section); + if (matches.length !== 1) + return fail(reason); + const start = matches[0]; + const end = sections.find(heading => heading.index > start.index)?.index ?? source.length; + const lines = source.slice(start.index + start[0].length, end).split('\n'); + const found = []; + for (let index = 0; index < lines.length; index++) { + if (!/^\s*\|/u.test(lines[index])) + continue; + const cells = (line) => line.trim().replace(/^\||\|$/gu, '').split('|').map(clean); + const header = cells(lines[index]); + if (!headers.every((value, position) => header[position] === value) || header.length !== headers.length) + continue; + const separator = cells(lines[++index] ?? ''); + if (separator.length !== headers.length || !separator.every(cell => /^:?-{3,}:?$/u.test(cell))) + return fail(reason); + const rows = []; + while (++index < lines.length && /^\s*\|/u.test(lines[index])) { + const row = cells(lines[index]); + if (row.length !== headers.length || row.some(cell => !cell)) + return fail(reason); + rows.push(row); + } + if (!rows.length) + return fail(reason); + found.push(rows); + } + return found.length === 1 ? found[0] : fail(reason); +} +function ids(value, pattern, reason) { + const values = value.split(/\s*,\s*/u); + if (!values.every(id => pattern.test(id)) || new Set(values).size !== values.length) + return fail(reason); + return values; +} +function reservedSddCaseIds(documents) { + const reserved = new Set(); + for (const document of documents) { + const source = visible(document.markdown); + const qualified = source.match(CASE) ?? []; + const namespaces = new Set(qualified.map(id => id.slice(0, id.lastIndexOf('-C') + 2))); + for (const id of qualified) + reserved.add(id); + // Legacy inventories often qualify C01 and abbreviate the remaining rows. + // Reserve every plausible namespace instead of silently assigning an alias a new meaning. + for (const match of source.matchAll(/(? 1000) { + throw new Error('An owning SDD case range cannot be reconciled within the fixed bound; review the baseline before retrying.'); + } + for (let number = start; number <= end; number++) { + for (const namespace of namespaces) + reserved.add(`${namespace}${String(number).padStart(match[1].length, '0')}`); + } + } + } + return [...reserved].sort(); +} +function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { + const source = visible(markdown); + const requirements = table(source, 4, ['Requirement', 'Behavior'], 'requirement-mapping'); + const requirementIds = requirements.map(row => row[0]); + if (new Set(requirementIds).size !== requirementIds.length || !requirementIds.every(id => REQUIREMENT_ID.test(id))) + fail('requirement-mapping'); + const cases = table(source, 14, ['Case ID', 'Area', 'Requirements', 'Scenario'], 'case-identity'); + const caseIds = cases.map(row => row[0]); + const reserved = new Set(reservedSddCaseIds(owners)); + if (new Set(caseIds).size !== caseIds.length || caseIds.some(id => !CASE_ID.test(id) || reserved.has(id)) + || /(? !caseIds.includes(id) && !reserved.has(id))) + fail('case-identity'); + const budget = table(source, 14, ['Area', 'Minimum distinct cases', 'Behaviors/risks covered'], 'case-budget'); + const counts = new Map(); + let total; + for (const [area, value] of budget) { + const count = /^[1-9]\d*$/u.test(value) ? Number(value) : NaN; + if (!Number.isSafeInteger(count) || counts.has(area) || total !== undefined) + fail('case-budget'); + if (area === 'Total') + total = count; + else + counts.set(area, count); + } + if (!counts.size || total !== caseIds.length || [...counts.values()].reduce((sum, count) => sum + count, 0) !== total + || cases.some(row => !counts.has(row[1]))) + fail('case-budget'); + for (const [area, count] of counts) + if (cases.filter(row => row[1] === area).length !== count) + fail('case-budget'); + const edges = new Map(requirementIds.map(id => [id, new Set()])); + for (const [id, , links] of cases) { + for (const requirement of ids(links, REQUIREMENT_ID, 'requirement-mapping')) { + const linked = edges.get(requirement); + if (!linked) + fail('requirement-mapping'); + linked.add(id); + } + } + const traceability = table(source, 17, ['Requirement', 'Cases'], 'requirement-mapping'); + const seen = new Set(); + for (const [requirement, links] of traceability) { + const expected = edges.get(requirement); + const actual = ids(links, CASE_ID, 'requirement-mapping'); + if (!expected || seen.has(requirement) || expected.size !== actual.length || actual.some(id => !expected.has(id))) + fail('requirement-mapping'); + seen.add(requirement); + } + if (seen.size !== edges.size || [...edges.values()].some(links => links.size === 0)) + fail('requirement-mapping'); + validatePaths(source, inventory, sddPath); +} +function safePath(value) { + return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) + && value.split('/').every(segment => segment && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git'); +} +function validatePaths(source, inventory, sddPath) { + const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); + const proposed = new Set([sddPath]); + const seen = new Set(); + for (const [file, change] of rows) { + if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) + fail('repository-path'); + seen.add(file); + if (change === 'create') { + if (inventory.files.has(file) || inventory.directories.has(file)) + fail('repository-path'); + const segments = file.split('/'); + if (segments.some((_, index) => inventory.files.has(segments.slice(0, index + 1).join('/')) || inventory.blocked.has(segments.slice(0, index + 1).join('/')))) + fail('repository-path'); + proposed.add(file); + } + else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) + fail('repository-path'); + } + const check = (file) => { + if (!safePath(file) || (!inventory.files.has(file) && !inventory.directories.has(file) && !proposed.has(file))) + fail('repository-path'); + }; + // Relative Markdown file links resolve against the selected SDD, never the current checkout. + for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + const href = match[1].split('#')[0]; + if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) + continue; + let decoded; + try { + decoded = decodeURIComponent(href); + } + catch { + return fail('repository-path'); + } + const parts = sddPath.split('/').slice(0, -1); + for (const part of decoded.split('/')) { + if (part === '..') { + if (!parts.length) + fail('repository-path'); + parts.pop(); + } + else if (part !== '.') + parts.push(part); + } + check(parts.join('/')); + } + for (const match of source.matchAll(/`([^`\n]+)`/gu)) { + const value = match[1]; + if (value.includes('/') && !/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value) && !/\s/u.test(value)) + check(value.replace(/\/$/u, '')); + } + const prose = source.replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + for (const match of prose.matchAll(/(? capability.id === plan.capabilityId)?.specs ?? []) { + if (!(0, pre_branch_sdd_1.isSafeSddPath)(file)) + throw new Error('An owning SDD path is unsafe.'); + if (!inventory.files.has(file)) + throw new Error('An owning SDD is not a regular file at the immutable base.'); + assertRegularSpecFile(path.join(root, file)); + owners.push({ path: file, markdown: await this.git(['show', `${snapshot.baseSha}:${file}`]) }); + } + (0, sdd_draft_consistency_1.validateSddDraftConsistency)(markdown, owners, inventory, plan.path); if (plan.action === 'update') { assertRegularSpecFile(target); const owner = await this.git(['show', `${snapshot.baseSha}:${plan.path}`]); @@ -88511,7 +88833,13 @@ class PreBranchSddWorkspaceAdapter { const raw = await this.git(['show', `${first}:specs/catalog.json`]); newCapability = JSON.parse(raw).capabilities.find(capability => capability.id === prepared.plan.capabilityId); } - const recovered = await this.validateDraft(snapshot, prepared.plan, content, newCapability); + const candidate = prepared.plan.action === 'update' + ? (0, pre_branch_sdd_1.recoverSddUpdate)(await this.git(['show', `${prepared.baseSha}:${prepared.plan.path}`]), content) : content; + if (!candidate) + return undefined; + const recovered = await this.validateDraft(snapshot, prepared.plan, candidate, newCapability); + if (recovered.markdown !== content) + return undefined; const committedPaths = (await this.git(['diff-tree', '--no-commit-id', '--name-only', '-r', first])).trim().split('\n').filter(Boolean).sort(); if (committedPaths.join('\n') !== recovered.changedPaths.join('\n')) return undefined; @@ -88546,6 +88874,25 @@ class PreBranchSddWorkspaceAdapter { const output = await this.git(['status', '--porcelain', '--untracked-files=all'], root); return output.split('\n').filter(Boolean).map(line => line.slice(3)).sort(); } + async inventoryAt(sha) { + const files = new Set(); + const directories = new Set(); + const blocked = new Set(); + const tree = await this.git(['ls-tree', '-rz', '--full-tree', sha]); + for (const entry of tree.split('\0').filter(Boolean)) { + const tab = entry.indexOf('\t'); + const file = entry.slice(tab + 1); + const mode = entry.slice(0, 6); + if (mode === '100644' || mode === '100755') + files.add(file); + else + blocked.add(file); + const parts = file.split('/'); + for (let index = 1; index < parts.length; index++) + directories.add(parts.slice(0, index).join('/')); + } + return { files, directories, blocked }; + } async addDetachedWorktree(sha) { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-sdd-')); try { diff --git a/docs/issues/pre-branch-sdds.mdx b/docs/issues/pre-branch-sdds.mdx index 4b25c54cc..7adeb05e0 100644 --- a/docs/issues/pre-branch-sdds.mdx +++ b/docs/issues/pre-branch-sdds.mdx @@ -20,6 +20,38 @@ and fenced examples do not prove a budget. Existing positive numeric prose remains supported. A malformed existing owner needs contributor review; regenerating a proposed appendix cannot repair that retained contract. +Newly generated drafts and appendices also need a checkable inventory. They use +these visible tables, with the English column names retained in localized prose: + +| Section | Columns | What publication verifies | +|---|---|---| +| 4 | `Requirement`, `Behavior` | Every declared goal has one stable ID | +| 14 | `Area`, `Minimum distinct cases`, `Behaviors/risks covered` | Every area and the total match its distinct cases | +| 14 | `Case ID`, `Area`, `Requirements`, `Scenario` | Fully qualified, unique IDs, exact areas and comma-separated requirement IDs | +| 15 | `Path`, `Change` | `update` and `reference` files exist at the immutable base; `create` explicitly proposes an absent file | +| 17 | `Requirement`, `Cases` | Every requirement names defined cases and the reverse links match | + +Use a separate case namespace for each proposed change, such as +`identity-development-C01`. Copilot reserves identifiers across **every** SDD +registered to the selected owner, including legacy shorthand inventories. A +new case cannot redefine a baseline case. Use full IDs in references; shorthand +and ranges do not establish new cases. Each case belongs to one budget area. +Equal grand totals cannot hide an over-allocation in one area and a shortage in +another. Generic documentation checks cannot replace defined acceptance cases. + +Cite repository files with full repository-relative paths in backticks or +relative Markdown file links. Existing paths are checked against the selected +Git commit, including files outside the catalog evidence arrays. A file present +only in the current checkout does not count. Declare intended new files in the +section 15 table with `create`; ordinary documentation updates must identify +the real existing document. Symlinks, submodules and paths below them cannot +establish file evidence. Source paths remain language and layout neutral. + +The new tables apply to generated candidates. Existing owner documents remain +verbatim and do not require a table migration to receive an appendix. These +checks establish internal consistency for draft publication. Product, +architecture, security, operator review and runtime acceptance remain separate. + `pre-branch-sdd: true` enables a specification gate for admitted **feature** issues and issues marked `contract-change`. It requires `issue-managed-branches: true` and an agent configuration that can return structured responses. The default is `false`. The repository must already contain a valid `specs/catalog.json`, `specs/README.md`, and `specs/_template.md`. The catalog identifies the owning SDD. The Action can update that SDD, add a companion SDD to its capability, or create a new proposed capability when the catalog evidence and referenced paths are valid. Drafting receives the selected owner's verified source, test and documentation paths at the immutable base, the canonical issue link and retained authorized answers. A new companion identifies the existing owner; its own new path is proposed evidence. It does not generate PRDs or ADRs in this flow. @@ -147,6 +179,12 @@ Copilot limits proposed update appendices to 18,000 characters and complete new or companion specifications to 70,000. If a generated draft exceeds its limit, it tries one concise regeneration for the same owner and answers. Missing required sections or a numeric test budget can use that same single attempt. +Case identity, per-area case budgets, requirement mappings and repository paths +share that attempt too. A repeated failure gives a specific recovery message: +choose distinct full IDs, reconcile each area's cases, repair both directions +of the mappings, or correct the paths and explicitly proposed files. No further +generation or publication occurs in that run. Review the named contract and +retry the same issue workflow; answered questions and historical commits remain. All validation and freshness checks still apply. If the second draft still fails, no draft is published; existing answers and SDDs are retained. Retry with a proportional change or an approved companion specification. diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 46b62664a..f3f911236 100644 --- a/specs/CATALOG.md +++ b/specs/CATALOG.md @@ -20,7 +20,7 @@ debt or convert unknown historic intent into a design decision. | `local-web-setup-assistant` | Proposed | Offer a packaged, loopback-only Svelte setup interface over the existing engine with four-language guidance, English CLI help, basic/custom first-run paths, editable question and plan review, source-labelled GitHub facts, assisted CI and Project evidence, role-separated PATs, and truthful itemized outcomes; inspect existing configuration and apply independent, scope-aware runtime adjustments with permission, drift and read-back verification | [Local web setup assistant](./local-web-setup-assistant.md) + 1 companion | 238 paths · 2026-10-21 | | `guided-bot-pat-onboarding` | Proposed | Guide creation of the persistent workflow PAT, verify bot identity and grants, recover explicitly from wrong-account input within the session, and install the approved Actions Secret | [Guided bot PAT onboarding](./guided-bot-pat-onboarding.md) | 39 paths · 2026-10-07 | | `temporary-setup-operator-authorization` | Proposed | Guide creation of the one-run operator PAT through GitHub's official form, verify final setup access, and report user-owned deletion accurately | [Assisted setup PAT creation](./temporary-setup-operator-authorization.md) | 36 paths · 2026-10-18 | -| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 3 companion | 117 paths · 2026-10-21 | +| `issue-start-and-sdd-readiness` | Implemented | Start every admitted issue with one explicit signal and publish a validated SDD before eligible Action-managed branch work; inspect verified SDD status and bounded structural publication comparisons | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 3 companion | 119 paths · 2026-10-21 | | `managed-issue-lifecycle` | As-built baseline | Convert typed issues into traceable work branches, project state, and lifecycle state | [Managed issue and branch lifecycle](./managed-issue-and-branch-lifecycle.md) | 71 paths · 2026-10-20 | | `comment-automation` | Implemented | Admit only explicit commands or exact mentions, then route them while protecting repository mutations | [Comment automation and authorization](./comment-automation-and-authorization.md) | 79 paths · 2026-10-11 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze its bounded diff partitions, publish stable findings atomically, and apply authorized verified fixes | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 2 companion | 109 paths · 2026-10-13 | @@ -151,8 +151,8 @@ debt or convert unknown historic intent into a design decision. - Specifications: [`specs/issue-start-and-branch-readiness.md`](./issue-start-and-branch-readiness.md) · [`specs/pre-branch-sdd-gate.md`](./pre-branch-sdd-gate.md) · [`specs/pre-branch-sdd-diff.md`](./pre-branch-sdd-diff.md) · [`specs/pre-branch-sdd-status-readiness.md`](./pre-branch-sdd-status-readiness.md) - Workflows: [`.github/workflows/copilot_issue.yml`](../.github/workflows/copilot_issue.yml) · [`.github/workflows/copilot_issue_comment.yml`](../.github/workflows/copilot_issue_comment.yml) · [`.github/workflows/copilot_commit.yml`](../.github/workflows/copilot_commit.yml) · [`.github/workflows/copilot_pull_request_comment.yml`](../.github/workflows/copilot_pull_request_comment.yml) - Entrypoints: [`action.yml`](../action.yml) · [`src/actions/common_action.ts`](../src/actions/common_action.ts) · [`src/actions/github_action.ts`](../src/actions/github_action.ts) -- Core code: [`src/domain/issue_workflow_runtime_policy.ts`](../src/domain/issue_workflow_runtime_policy.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/data/model/execution.ts`](../src/data/model/execution.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.ts) · [`src/application/policies/setup_issue_workflow_policy.ts`](../src/application/policies/setup_issue_workflow_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`scripts/validate-specification-catalog.cjs`](../scripts/validate-specification-catalog.cjs) · [`src/domain/issue_start_policy.ts`](../src/domain/issue_start_policy.ts) · [`src/domain/pre_branch_sdd.ts`](../src/domain/pre_branch_sdd.ts) · [`src/application/ports/pre_branch_sdd_ports.ts`](../src/application/ports/pre_branch_sdd_ports.ts) · [`src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts`](../src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts) · [`src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts`](../src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts) · [`src/data/repository/branch/linked_branch_readiness_repository.ts`](../src/data/repository/branch/linked_branch_readiness_repository.ts) · [`src/infrastructure/pre_branch_sdd_workspace_adapter.ts`](../src/infrastructure/pre_branch_sdd_workspace_adapter.ts) · [`src/infrastructure/specification_catalog_validator.cjs`](../src/infrastructure/specification_catalog_validator.cjs) · [`src/actions/input_boolean_policy.ts`](../src/actions/input_boolean_policy.ts) · [`src/domain/issue_workflow_profile.ts`](../src/domain/issue_workflow_profile.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/domain/agent_response_length_failure.ts`](../src/domain/agent_response_length_failure.ts) · [`src/data/repository/agent_json_schema_validator.ts`](../src/data/repository/agent_json_schema_validator.ts) · [`src/application/ports/sdd_evidence_read_ports.ts`](../src/application/ports/sdd_evidence_read_ports.ts) · [`src/application/policies/sdd_issue_identity_policy.ts`](../src/application/policies/sdd_issue_identity_policy.ts) · [`src/application/policies/sdd_query_card_policy.ts`](../src/application/policies/sdd_query_card_policy.ts) · [`src/application/policies/sdd_status_policy.ts`](../src/application/policies/sdd_status_policy.ts) · [`src/application/usecases/sdd/sdd_evidence_query_use_case.ts`](../src/application/usecases/sdd/sdd_evidence_query_use_case.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/comment_automation_command_workflow.ts`](../src/application/usecases/comment_automation_command_workflow.ts) · [`src/application/usecases/comment_automation_contracts.ts`](../src/application/usecases/comment_automation_contracts.ts) · [`src/application/usecases/issue_comment_use_case.ts`](../src/application/usecases/issue_comment_use_case.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/infrastructure/composition/sdd_evidence_composition_root.ts`](../src/infrastructure/composition/sdd_evidence_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/domain/pre_branch_sdd_diff.ts`](../src/domain/pre_branch_sdd_diff.ts) · [`src/application/policies/sdd_diff_message_catalog.ts`](../src/application/policies/sdd_diff_message_catalog.ts) · [`src/application/policies/sdd_diff_presentation_policy.ts`](../src/application/policies/sdd_diff_presentation_policy.ts) · [`src/application/ports/sdd_diff_read_ports.ts`](../src/application/ports/sdd_diff_read_ports.ts) · [`src/application/usecases/sdd/sdd_diff_query_use_case.ts`](../src/application/usecases/sdd/sdd_diff_query_use_case.ts) · [`src/data/repository/sdd/sdd_diff_link_repository.ts`](../src/data/repository/sdd/sdd_diff_link_repository.ts) · [`src/infrastructure/composition/sdd_diff_composition_root.ts`](../src/infrastructure/composition/sdd_diff_composition_root.ts) · [`src/application/usecases/pull_request_review_comment_use_case.ts`](../src/application/usecases/pull_request_review_comment_use_case.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/domain/specification_catalog_path_policy.cjs`](../src/domain/specification_catalog_path_policy.cjs) · [`src/application/policies/github_runtime_doctor_policy.ts`](../src/application/policies/github_runtime_doctor_policy.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/domain/sdd_prerequisite_policy.cjs`](../src/domain/sdd_prerequisite_policy.cjs) · [`src/application/policies/sdd_analysis_status_policy.ts`](../src/application/policies/sdd_analysis_status_policy.ts) -- Tests: [`src/domain/__tests__/issue_workflow_runtime_policy.test.ts`](../src/domain/__tests__/issue_workflow_runtime_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/tooling/__tests__/validate_specification_catalog.test.ts`](../src/tooling/__tests__/validate_specification_catalog.test.ts) · [`src/domain/__tests__/issue_start_policy.test.ts`](../src/domain/__tests__/issue_start_policy.test.ts) · [`src/domain/__tests__/pre_branch_sdd.test.ts`](../src/domain/__tests__/pre_branch_sdd.test.ts) · [`src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts) · [`src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts`](../src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/actions/__tests__/input_boolean_policy.test.ts`](../src/actions/__tests__/input_boolean_policy.test.ts) · [`src/domain/__tests__/issue_workflow_profile.test.ts`](../src/domain/__tests__/issue_workflow_profile.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/data/repository/__tests__/agent_json_schema_validator.test.ts`](../src/data/repository/__tests__/agent_json_schema_validator.test.ts) · [`src/application/policies/__tests__/sdd_issue_identity_policy.test.ts`](../src/application/policies/__tests__/sdd_issue_identity_policy.test.ts) · [`src/application/policies/__tests__/sdd_query_card_policy.test.ts`](../src/application/policies/__tests__/sdd_query_card_policy.test.ts) · [`src/application/policies/__tests__/sdd_status_policy.test.ts`](../src/application/policies/__tests__/sdd_status_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts) · [`src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts`](../src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts) · [`src/domain/__tests__/pre_branch_sdd_diff.test.ts`](../src/domain/__tests__/pre_branch_sdd_diff.test.ts) · [`src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts`](../src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts) · [`src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts`](../src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts) · [`src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts) · [`src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts`](../src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts) · [`src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts`](../src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts) · [`src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts`](../src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts) · [`src/tooling/__tests__/portable_specification_catalog.test.ts`](../src/tooling/__tests__/portable_specification_catalog.test.ts) · [`src/application/policies/__tests__/github_runtime_doctor_policy.test.ts`](../src/application/policies/__tests__/github_runtime_doctor_policy.test.ts) · [`src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts`](../src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) +- Core code: [`src/domain/issue_workflow_runtime_policy.ts`](../src/domain/issue_workflow_runtime_policy.ts) · [`src/domain/copilot_lifecycle.ts`](../src/domain/copilot_lifecycle.ts) · [`src/data/model/execution.ts`](../src/data/model/execution.ts) · [`src/application/usecases/issue_workflow.ts`](../src/application/usecases/issue_workflow.ts) · [`src/application/usecases/steps/issue/prepare_branches_use_case.ts`](../src/application/usecases/steps/issue/prepare_branches_use_case.ts) · [`src/application/policies/setup_issue_workflow_policy.ts`](../src/application/policies/setup_issue_workflow_policy.ts) · [`src/application/policies/lifecycle_state_policy.ts`](../src/application/policies/lifecycle_state_policy.ts) · [`src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts`](../src/application/usecases/actions/synchronize_lifecycle_state_use_case.ts) · [`src/infrastructure/git_commit_adapter.ts`](../src/infrastructure/git_commit_adapter.ts) · [`scripts/validate-specification-catalog.cjs`](../scripts/validate-specification-catalog.cjs) · [`src/domain/issue_start_policy.ts`](../src/domain/issue_start_policy.ts) · [`src/domain/pre_branch_sdd.ts`](../src/domain/pre_branch_sdd.ts) · [`src/application/ports/pre_branch_sdd_ports.ts`](../src/application/ports/pre_branch_sdd_ports.ts) · [`src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts`](../src/application/usecases/sdd/pre_branch_sdd_gate_use_case.ts) · [`src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts`](../src/application/usecases/steps/issue/reconcile_branch_readiness_use_case.ts) · [`src/data/repository/branch/linked_branch_readiness_repository.ts`](../src/data/repository/branch/linked_branch_readiness_repository.ts) · [`src/infrastructure/pre_branch_sdd_workspace_adapter.ts`](../src/infrastructure/pre_branch_sdd_workspace_adapter.ts) · [`src/infrastructure/specification_catalog_validator.cjs`](../src/infrastructure/specification_catalog_validator.cjs) · [`src/actions/input_boolean_policy.ts`](../src/actions/input_boolean_policy.ts) · [`src/domain/issue_workflow_profile.ts`](../src/domain/issue_workflow_profile.ts) · [`src/application/usecases/read_copilot_status_workflow.ts`](../src/application/usecases/read_copilot_status_workflow.ts) · [`src/application/policies/status_command_policy.ts`](../src/application/policies/status_command_policy.ts) · [`src/application/policies/publication_message_catalog.ts`](../src/application/policies/publication_message_catalog.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts) · [`src/data/model/application_error.ts`](../src/data/model/application_error.ts) · [`src/application/policies/application_error_message_catalog.ts`](../src/application/policies/application_error_message_catalog.ts) · [`src/domain/agent_response_length_failure.ts`](../src/domain/agent_response_length_failure.ts) · [`src/data/repository/agent_json_schema_validator.ts`](../src/data/repository/agent_json_schema_validator.ts) · [`src/application/ports/sdd_evidence_read_ports.ts`](../src/application/ports/sdd_evidence_read_ports.ts) · [`src/application/policies/sdd_issue_identity_policy.ts`](../src/application/policies/sdd_issue_identity_policy.ts) · [`src/application/policies/sdd_query_card_policy.ts`](../src/application/policies/sdd_query_card_policy.ts) · [`src/application/policies/sdd_status_policy.ts`](../src/application/policies/sdd_status_policy.ts) · [`src/application/usecases/sdd/sdd_evidence_query_use_case.ts`](../src/application/usecases/sdd/sdd_evidence_query_use_case.ts) · [`src/application/usecases/comment_automation_context.ts`](../src/application/usecases/comment_automation_context.ts) · [`src/application/usecases/comment_automation_command_workflow.ts`](../src/application/usecases/comment_automation_command_workflow.ts) · [`src/application/usecases/comment_automation_contracts.ts`](../src/application/usecases/comment_automation_contracts.ts) · [`src/application/usecases/issue_comment_use_case.ts`](../src/application/usecases/issue_comment_use_case.ts) · [`src/application/policies/semantic_result_publication_policy.ts`](../src/application/policies/semantic_result_publication_policy.ts) · [`src/infrastructure/composition/sdd_evidence_composition_root.ts`](../src/infrastructure/composition/sdd_evidence_composition_root.ts) · [`src/infrastructure/composition/main_run_route_composition_root.ts`](../src/infrastructure/composition/main_run_route_composition_root.ts) · [`src/domain/pre_branch_sdd_diff.ts`](../src/domain/pre_branch_sdd_diff.ts) · [`src/application/policies/sdd_diff_message_catalog.ts`](../src/application/policies/sdd_diff_message_catalog.ts) · [`src/application/policies/sdd_diff_presentation_policy.ts`](../src/application/policies/sdd_diff_presentation_policy.ts) · [`src/application/ports/sdd_diff_read_ports.ts`](../src/application/ports/sdd_diff_read_ports.ts) · [`src/application/usecases/sdd/sdd_diff_query_use_case.ts`](../src/application/usecases/sdd/sdd_diff_query_use_case.ts) · [`src/data/repository/sdd/sdd_diff_link_repository.ts`](../src/data/repository/sdd/sdd_diff_link_repository.ts) · [`src/infrastructure/composition/sdd_diff_composition_root.ts`](../src/infrastructure/composition/sdd_diff_composition_root.ts) · [`src/application/usecases/pull_request_review_comment_use_case.ts`](../src/application/usecases/pull_request_review_comment_use_case.ts) · [`src/application/usecases/steps/common/reply_publication_workflow.ts`](../src/application/usecases/steps/common/reply_publication_workflow.ts) · [`src/application/usecases/steps/common/publish_resume_workflow.ts`](../src/application/usecases/steps/common/publish_resume_workflow.ts) · [`scripts/coverage-budgets.json`](../scripts/coverage-budgets.json) · [`src/architecture/github_publication_mutation_baseline.json`](../src/architecture/github_publication_mutation_baseline.json) · [`src/domain/specification_catalog_path_policy.cjs`](../src/domain/specification_catalog_path_policy.cjs) · [`src/application/policies/github_runtime_doctor_policy.ts`](../src/application/policies/github_runtime_doctor_policy.ts) · [`src/infrastructure/setup_workspace_adapter.ts`](../src/infrastructure/setup_workspace_adapter.ts) · [`src/application/usecases/setup/doctor_use_case.ts`](../src/application/usecases/setup/doctor_use_case.ts) · [`src/application/policies/setup_doctor_message_catalog.ts`](../src/application/policies/setup_doctor_message_catalog.ts) · [`src/domain/sdd_prerequisite_policy.cjs`](../src/domain/sdd_prerequisite_policy.cjs) · [`src/application/policies/sdd_analysis_status_policy.ts`](../src/application/policies/sdd_analysis_status_policy.ts) · [`src/domain/sdd_draft_consistency.ts`](../src/domain/sdd_draft_consistency.ts) +- Tests: [`src/domain/__tests__/issue_workflow_runtime_policy.test.ts`](../src/domain/__tests__/issue_workflow_runtime_policy.test.ts) · [`src/data/model/__tests__/execution.test.ts`](../src/data/model/__tests__/execution.test.ts) · [`src/application/usecases/__tests__/issue_use_case.test.ts`](../src/application/usecases/__tests__/issue_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/prepare_branches_use_case.test.ts) · [`src/application/policies/__tests__/lifecycle_state_policy.test.ts`](../src/application/policies/__tests__/lifecycle_state_policy.test.ts) · [`src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts`](../src/application/usecases/actions/__tests__/synchronize_lifecycle_state_use_case.test.ts) · [`src/tooling/__tests__/validate_specification_catalog.test.ts`](../src/tooling/__tests__/validate_specification_catalog.test.ts) · [`src/domain/__tests__/issue_start_policy.test.ts`](../src/domain/__tests__/issue_start_policy.test.ts) · [`src/domain/__tests__/pre_branch_sdd.test.ts`](../src/domain/__tests__/pre_branch_sdd.test.ts) · [`src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts) · [`src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts`](../src/application/usecases/steps/issue/__tests__/reconcile_branch_readiness_use_case.test.ts) · [`src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts`](../src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts) · [`src/architecture/__tests__/github_publication_boundaries.test.ts`](../src/architecture/__tests__/github_publication_boundaries.test.ts) · [`src/actions/__tests__/input_boolean_policy.test.ts`](../src/actions/__tests__/input_boolean_policy.test.ts) · [`src/domain/__tests__/issue_workflow_profile.test.ts`](../src/domain/__tests__/issue_workflow_profile.test.ts) · [`src/application/policies/__tests__/status_command_policy.test.ts`](../src/application/policies/__tests__/status_command_policy.test.ts) · [`src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts`](../src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts) · [`src/application/policies/__tests__/application_error_presentation_policy.test.ts`](../src/application/policies/__tests__/application_error_presentation_policy.test.ts) · [`src/data/repository/__tests__/agent_json_schema_validator.test.ts`](../src/data/repository/__tests__/agent_json_schema_validator.test.ts) · [`src/application/policies/__tests__/sdd_issue_identity_policy.test.ts`](../src/application/policies/__tests__/sdd_issue_identity_policy.test.ts) · [`src/application/policies/__tests__/sdd_query_card_policy.test.ts`](../src/application/policies/__tests__/sdd_query_card_policy.test.ts) · [`src/application/policies/__tests__/sdd_status_policy.test.ts`](../src/application/policies/__tests__/sdd_status_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_evidence_query_use_case.test.ts) · [`src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts`](../src/data/repository/branch/__tests__/linked_branch_readiness_repository.test.ts) · [`src/application/usecases/__tests__/read_copilot_status_workflow.test.ts`](../src/application/usecases/__tests__/read_copilot_status_workflow.test.ts) · [`src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts`](../src/infrastructure/composition/__tests__/main_run_route_composition_root.test.ts) · [`src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_evidence_composition_root.test.ts) · [`src/domain/__tests__/pre_branch_sdd_diff.test.ts`](../src/domain/__tests__/pre_branch_sdd_diff.test.ts) · [`src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts`](../src/application/policies/__tests__/sdd_diff_presentation_policy.test.ts) · [`src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts`](../src/application/usecases/sdd/__tests__/sdd_diff_query_use_case.test.ts) · [`src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts`](../src/data/repository/sdd/__tests__/sdd_diff_link_repository.test.ts) · [`src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts`](../src/infrastructure/composition/__tests__/sdd_diff_composition_root.test.ts) · [`src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts`](../src/application/usecases/__tests__/sdd_diff_comment_interaction.test.ts) · [`src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts`](../src/application/usecases/steps/common/__tests__/sdd_diff_reply_publication.test.ts) · [`src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts`](../src/application/usecases/__tests__/sdd_status_workflow_integration.test.ts) · [`src/tooling/__tests__/portable_specification_catalog.test.ts`](../src/tooling/__tests__/portable_specification_catalog.test.ts) · [`src/application/policies/__tests__/github_runtime_doctor_policy.test.ts`](../src/application/policies/__tests__/github_runtime_doctor_policy.test.ts) · [`src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts`](../src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts) · [`src/application/usecases/setup/__tests__/doctor_use_case.test.ts`](../src/application/usecases/setup/__tests__/doctor_use_case.test.ts) · [`src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts`](../src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts) · [`src/cli/__tests__/setup_presenters.test.ts`](../src/cli/__tests__/setup_presenters.test.ts) · [`src/domain/__tests__/sdd_draft_consistency.test.ts`](../src/domain/__tests__/sdd_draft_consistency.test.ts) - User documentation: [`docs/issues/branch-management.mdx`](../docs/issues/branch-management.mdx) · [`docs/issues/workflow-setup.mdx`](../docs/issues/workflow-setup.mdx) · [`docs/issues/comment-commands.mdx`](../docs/issues/comment-commands.mdx) · [`docs/development/specifications.mdx`](../docs/development/specifications.mdx) · [`docs/issues/pre-branch-sdds.mdx`](../docs/issues/pre-branch-sdds.mdx) · [`docs/issues/index.mdx`](../docs/issues/index.mdx) · [`docs/issues/labels-and-branch-types.mdx`](../docs/issues/labels-and-branch-types.mdx) · [`docs/issues/configuration.mdx`](../docs/issues/configuration.mdx) · [`docs/configuration.mdx`](../docs/configuration.mdx) · [`docs/single-actions/workflow-and-cli.mdx`](../docs/single-actions/workflow-and-cli.mdx) ### `managed-issue-lifecycle` — Managed issue and branch lifecycle diff --git a/specs/catalog.json b/specs/catalog.json index b66029e09..7202c92bd 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1550,7 +1550,8 @@ "src/application/usecases/setup/doctor_use_case.ts", "src/application/policies/setup_doctor_message_catalog.ts", "src/domain/sdd_prerequisite_policy.cjs", - "src/application/policies/sdd_analysis_status_policy.ts" + "src/application/policies/sdd_analysis_status_policy.ts", + "src/domain/sdd_draft_consistency.ts" ], "tests": [ "src/domain/__tests__/issue_workflow_runtime_policy.test.ts", @@ -1593,7 +1594,8 @@ "src/infrastructure/__tests__/setup_sdd_prerequisites.test.ts", "src/application/usecases/setup/__tests__/doctor_use_case.test.ts", "src/infrastructure/__tests__/branch_sync_sdd_catalog.test.ts", - "src/cli/__tests__/setup_presenters.test.ts" + "src/cli/__tests__/setup_presenters.test.ts", + "src/domain/__tests__/sdd_draft_consistency.test.ts" ], "documentation": [ "docs/issues/branch-management.mdx", diff --git a/specs/pre-branch-sdd-gate.md b/specs/pre-branch-sdd-gate.md index 43fa46a50..b04c546c7 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -1140,3 +1140,125 @@ not parsed as readiness. Documentation: ownership analysis recovery in the pre-branch SDD guide. Definition of Done also requires existing global quality, package and catalog gates, preserved safety checks and review of both locale messages; tests do not claim a live consumer recovery. + +### Generated draft consistency (2026-10-21, #653) + +Issue [#653](https://github.com/vypdev/copilot/issues/653) documented a published +companion that reused a baseline case ID for the opposite authentication +behavior, had offsetting area-allocation errors and one-sided requirement links, +and cited a nonexistent documentation update path. Arithmetic and catalog +validity alone did not establish a coherent generated contract. This amendment +requires internal publication consistency; Draft/Proposed publication MUST +remain separate from product, security, operator and runtime acceptance. + +Generated candidates MUST use these visible English-header tables in their +canonical numbered sections; localized surrounding prose remains supported: + +| Section | Columns | Contract | +|---|---|---| +| 4 | Requirement / Behavior | Unique stable IDs for the draft's requirements | +| 14 | Area / Minimum distinct cases / Behaviors/risks covered | Positive integer allocations and one final Total | +| 14 | Case ID / Area / Requirements / Scenario | One distinct full case ID, exact area, comma-separated requirement IDs and observable scenario per row | +| 15 | Path / Change | Immutable existing files for update/reference; explicitly absent proposed files for create | +| 17 | Requirement / Cases | Comma-separated full case IDs; exact reciprocal links for every requirement | + +Missing, duplicate, malformed, empty, hidden or fenced inventories block. Every +area's allocation MUST equal its distinct inventory count, and the total MUST +match both. Cases and requirements MUST name only defined IDs and agree in both +directions. Uncovered goals and generic documentation checks cannot establish +acceptance. These structural checks cannot certify the completeness or meaning +of every natural-language requirement; human review remains mandatory. + +Every catalogued SDD for the selected owner MUST be read at the immutable base. +Its full case IDs are reserved, including namespaces inferred from legacy +shorthand rows/ranges. A new case MUST NOT redefine a reserved ID, even when its +scenario seems similar. Baseline references use full IDs and do not count as +new cases. New cases use a separate namespace, such as identity-development-C01. +Unavailable owners, more than 30,000 serialized reserved-ID characters, or a +legacy range spanning over 1,000 increments block without truncation or repair +of the immutable owner. These fixed bounds have no public setting. Retained +owners keep their existing prose budgets and bytes; only new candidates need +the tables. No catalog or durable-status schema migration is introduced. + +The workspace MUST resolve cited paths against the immutable Git tree, not +uncommitted files or the post-write scratch tree. Repository-relative code +spans, ordinary file citations and relative Markdown destinations are checked; +external URLs and local anchors do not claim repository existence. Existing +files can use any language/layout. Future files MUST be explicitly declared +create, absent from the base and outside symlink/submodule or regular-file +parents. Update/reference declarations require regular files. The selected new +SDD is itself proposed. This flow writes only the selected SDD/catalog files. + +```text +answered plan -> read all immutable owners -> reserve baseline case identities + -> draft visible inventories -> check cases, areas, mappings and paths + -> one shared repair, or specific non-ready recovery + -> unchanged freshness/link/first-commit proof -> draft publication + -> independent human and runtime acceptance +``` + +Text equivalent: owner evidence precedes drafting; deterministic validation +accepts a candidate or spends the existing single repair. Repeated contradiction +stops before publication. The existing branch/commit proof still follows. + +Architecture: the pure src/domain/sdd_draft_consistency.ts policy owns inventory, +identity, budget, mapping and path decisions without filesystem, process, +provider or presentation imports. Application code reads every owner through +the existing port and supplies reserved IDs to both bounded drafting attempts. +The workspace adapter supplies immutable regular-file/directory/blocked-path +sets, validates before composing the retained owner or writing, and always +removes scratch worktrees. Recovery extracts and revalidates the proposed +appendix and verifies recomposed bytes, preserving the existing first commit. +This avoids treating retained owner cases as new definitions during replay. +Existing architecture, type, catalog and publication tests enforce the boundary. + +UX retains the existing localized error surface and notification budget. +Pending: validation runs with answers retained. Action required/blocked: after +the shared repair, a closed category identifies case identities, allocations, +requirement mappings or paths. Representative content: "SDD publication stopped +after one automatic repair: per-area allocations disagree with the distinct +case inventory. Review the draft tables; reconcile each area and the total with +the enumerated cases. Retry the same issue workflow." Retained state says +answers and published SDDs remain and no draft was published. Partial push +success keeps the exact branch/commit for ordinary replay. Complete means the +consistent draft was published; runtime acceptance remains open. English and +Spanish messages use the existing impact/action/retained-state order, textual +headings, mobile/screen-reader behavior and closed sanitized content. Rejected +Markdown, case IDs, paths and diagnostic text MUST NOT enter repair instructions +or public messages. There is no additional card, label, credential or setting. + +The amendment has a minimum **73 distinct cases**, separate from earlier floors: + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Pure consistency policy | 56 | Owner collisions and aliases, hidden/malformed inventories, offsetting budgets, reciprocal mappings, immutable/proposed paths, portable layouts and bounded ranges | +| Application and bounded draft workflow | 9 | All-owner reads, missing/oversized context, blocked publication, four closed repairs and corrected-draft revalidation | +| Real Git workspace | 4 | Each reported contradiction blocks publication, ignores uncommitted evidence, preserves the owner and removes scratch worktrees | +| Localized recovery | 4 | All four closed variants render English/Spanish retained state without runtime acceptance claims | +| **Total** | **73** | Existing cases are not recounted | + +Repository coverage thresholds remain mandatory; new pure policy targets at +least 95% lines, with negative/boundary branch fixtures. Tests use deterministic +text/agents and local bare remotes without providers or sleeps. Existing +update/companion publication and post-push recovery fixtures remain required. +Human content review checks the specific recovery action and the separation of +publication/runtime status; no new interactive layout requires visual testing. + +Acceptance/traceability: sdd_draft_consistency.test.ts proves that a case changed +to an owner ID, offsetting allocations, a one-sided link or a missing document +fails its specific category. Workspace tests repeat the four failures against +Git objects and prove no branch or scratch workspace remains. Gate tests prove +every owner reaches reservation and exhausted validation never publishes. +Draft workflow tests prove closed feedback, specific recovery and the shared +two-attempt ceiling. Presentation tests prove both locales. Corrected tables +and intentional new paths pass; existing owner bytes and verified replay remain +preserved. Fixtures establish consistency, not live provider or runtime quality. + +Delivery sequence/Definition of Done: implement pure policy and tests; bind +immutable inventory and owner context; preserve verified replay; add localized +closed recovery; update docs/issues/pre-branch-sdds.mdx, catalog evidence and +CATALOG; pass specification, documentation, architecture, type, lint, test, +build and package gates. All new evidence files MUST exist. No runtime +prerequisite, framework or acceptance check may be weakened. In-flight drafts +use the contract at their next validation. Rollback selects the previous Action +version without rewriting historical commits or branches. diff --git a/src/application/policies/__tests__/application_error_presentation_policy.test.ts b/src/application/policies/__tests__/application_error_presentation_policy.test.ts index 1318ac91e..ccee35196 100644 --- a/src/application/policies/__tests__/application_error_presentation_policy.test.ts +++ b/src/application/policies/__tests__/application_error_presentation_policy.test.ts @@ -19,6 +19,11 @@ const RECOVERY_CASES: readonly Readonly<{ english: string; spanish: string; }>[] = [ + ...(['sdd-draft-case-identity-invalid', 'sdd-draft-case-budget-invalid', 'sdd-draft-requirement-mapping-invalid', 'sdd-draft-repository-path-invalid'] as const).map(id => ({ + recovery: { id, variables: {} }, + english: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + spanish: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + })), ...(['stored-configuration-schema-missing', 'stored-configuration-schema-unsupported', 'stored-configuration-invalid'] as const).map(id => ({ recovery: id === 'stored-configuration-schema-unsupported' ? { id, variables: { issueNumber: 144, expectedVersion: 3, actualVersion: 2 } } diff --git a/src/application/policies/application_error_message_catalog.ts b/src/application/policies/application_error_message_catalog.ts index e5d244305..7ea972c98 100644 --- a/src/application/policies/application_error_message_catalog.ts +++ b/src/application/policies/application_error_message_catalog.ts @@ -242,6 +242,26 @@ const ENGLISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ action: 'Inspect the rejected paths in the workflow and correct the workspace before retrying.', retainedState: 'The normal commit and push steps were skipped. Verify the current state before retrying.', }), + 'sdd-draft-case-identity-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: case IDs collide with an owning SDD or are ambiguous.', + action: 'Review the draft tables; use distinct fully qualified case IDs and preserve the baseline inventory. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-case-budget-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: per-area allocations disagree with the distinct case inventory.', + action: 'Review the draft tables; reconcile each area and the total with the enumerated cases. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-requirement-mapping-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: requirements and case mappings are incomplete or inconsistent.', + action: 'Review the draft tables; map every requirement to defined cases and match the reverse links. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), + 'sdd-draft-repository-path-invalid': Object.freeze({ + impact: 'SDD publication stopped after one automatic repair: cited repository paths cannot be verified at the immutable base.', + action: 'Review the draft tables; correct existing paths or explicitly declare intended new files. Retry the same issue workflow.', + retainedState: 'Existing answers and published SDDs were preserved; no draft was published. Publication checks do not establish runtime acceptance.', + }), 'sdd-draft-format-invalid': Object.freeze({ impact: 'SDD preparation stopped because the draft still lacked required headings or a numeric test budget after one automatic repair.', action: 'Retry SDD preparation with the canonical specification template and the explicit acceptance requirements.', @@ -336,6 +356,26 @@ const SPANISH_RECOVERY_CONTENT: RecoveryContent = Object.freeze({ action: 'Revisa las rutas rechazadas en la ejecución y corrige el espacio de trabajo antes de reintentar.', retainedState: 'Se omitieron los pasos normales de commit y push. Comprueba el estado actual antes de reintentar.', }), + 'sdd-draft-case-identity-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: los identificadores de casos coinciden con un SDD existente o son ambiguos.', + action: 'Revisa las tablas del borrador; usa identificadores completos y distintos y conserva el inventario original. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-case-budget-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: las asignaciones por área no coinciden con el inventario de casos distintos.', + action: 'Revisa las tablas del borrador; reconcilia cada área y el total con los casos enumerados. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-requirement-mapping-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: los requisitos y sus casos están incompletos o son inconsistentes.', + action: 'Revisa las tablas del borrador; vincula cada requisito a casos definidos y comprueba los enlaces inversos. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), + 'sdd-draft-repository-path-invalid': Object.freeze({ + impact: 'La publicación del SDD se detuvo tras un intento automático de corrección: no se pueden verificar las rutas citadas en la revisión base inmutable.', + action: 'Revisa las tablas del borrador; corrige las rutas existentes o declara explícitamente los archivos nuevos previstos. Reintenta el workflow de la misma incidencia.', + retainedState: 'Se conservaron las respuestas y los SDD publicados; no se publicó ningún borrador. Estas comprobaciones no demuestran la aceptación en ejecución.', + }), 'sdd-draft-format-invalid': Object.freeze({ impact: 'La preparación del SDD se detuvo porque seguían faltando secciones obligatorias o un presupuesto numérico de pruebas tras un intento automático de corrección.', action: 'Reintenta la preparación del SDD con la plantilla canónica y los criterios de aceptación explícitos.', diff --git a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts index 46a35e477..b49beaaf5 100644 --- a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts +++ b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.test.ts @@ -7,6 +7,29 @@ const configuration = { provider: 'codex', model: 'model' } as never; const plan = { action: 'update', path: 'specs/payments.md', capabilityId: 'payments', reason: 'A bounded contract change.', questions: [] } as const; describe('draftSddMarkdown', () => { + it.each(['case-identity', 'case-budget', 'requirement-mapping', 'repository-path'] as const)( + 'repairs %s once with closed feedback and returns a specific non-ready recovery on repeated failure', async reason => { + const query = jest.fn().mockResolvedValue({ markdown: 'Untrusted invalid document' }); + const validate = jest.fn().mockRejectedValue(new SddDraftFormatFailure(reason, 'untrusted diagnostic @actor /command')); + await expect(draftSddMarkdown({ query }, configuration, plan, 'Resolved requirements', validate)).rejects.toMatchObject({ + recovery: { id: `sdd-draft-${reason}-invalid`, variables: {} }, + }); + expect(query).toHaveBeenCalledTimes(2); + expect(validate).toHaveBeenCalledTimes(2); + expect(query.mock.calls[1][0].prompt).toContain(`SDD ${reason} contract`); + expect(query.mock.calls[1][0].prompt).toContain('Case ID | Area | Requirements | Scenario'); + expect(query.mock.calls[1][0].prompt).not.toMatch(/Untrusted invalid document|untrusted diagnostic|@actor/); + }, + ); + + it('revalidates a corrected semantic draft without spending a third generation', async () => { + const query = jest.fn().mockResolvedValueOnce({ markdown: 'Invalid cases' }).mockResolvedValueOnce({ markdown: 'Corrected cases' }); + const validate = jest.fn().mockRejectedValueOnce(new SddDraftFormatFailure('case-budget', 'Rejected')).mockResolvedValueOnce(undefined); + await expect(draftSddMarkdown({ query }, configuration, plan, 'Resolved requirements', validate)).resolves.toBe('Corrected cases'); + expect(validate.mock.calls.map(call => call[0])).toEqual(['Invalid cases', 'Corrected cases']); + expect(query).toHaveBeenCalledTimes(2); + }); + it('accepts a real findings response with only the canonical numeric table without regeneration', async () => { const markdown = `${'Explicit product scope and architecture. '.repeat(55)}\n## 1. Executive summary\n## 4. Goals\n## 8. Clean Architecture\n## 14. Testing strategy\n| Area | Minimum distinct cases | Risks |\n|---|---:|---|\n| Policy | 4 | Authority |\n| Total | 4 | Distinct |\n## 16. Acceptance\n`; const execute = jest.fn().mockResolvedValue(JSON.stringify({ markdown })); diff --git a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts index 04b39d85c..4c0d6cdd6 100644 --- a/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts +++ b/src/application/usecases/sdd/pre_branch_sdd_draft_workflow.ts @@ -8,6 +8,7 @@ import { type SddPlan, } from '../../../domain/pre_branch_sdd'; import { AgentResponseLengthFailure } from '../../../domain/agent_response_length_failure'; +import { SDD_CONSISTENCY_PROMPT } from '../../../domain/sdd_draft_consistency'; /** Size and typed format failures share one regeneration; the writer still validates every candidate. */ export async function draftSddMarkdown( @@ -25,14 +26,14 @@ export async function draftSddMarkdown( required: ['markdown'], additionalProperties: false, }; let problem: 'size' | 'format' = 'size'; - let formatReason: 'required-heading' | 'test-budget' = 'required-heading'; - const contractPrompt = `${prompt}\n\nDraft contract: keep the complete draft within ${maximum} characters; aim for ${target} characters. Preserve the same owner, decisions and requirements. Include these literal canonical heading prefixes as Markdown headings, with the shown capitalization:\n${SDD_REQUIRED_HEADINGS.join('\n')}\nUse the canonical numeric test-budget table in section 14: Area | Minimum distinct cases | Behaviors/risks covered. Every allocation and the Total must be a positive integer, and the Total must equal the sum of the allocations. A justified positive integer sentence of distinct tests or cases is also accepted. Return only the required JSON.`; + let formatReason: SddDraftFormatFailure['reason'] = 'required-heading'; + const contractPrompt = `${prompt}\n\nDraft contract: keep the complete draft within ${maximum} characters; aim for ${target} characters. Preserve the same owner, decisions and requirements. Include these literal canonical heading prefixes as Markdown headings, with the shown capitalization:\n${SDD_REQUIRED_HEADINGS.join('\n')}\nUse the canonical numeric test-budget table in section 14: Area | Minimum distinct cases | Behaviors/risks covered. Every allocation and the Total must be a positive integer, and the Total must equal the sum of the allocations. Return only the required JSON.`; for (let attempt = 0; attempt < 2; attempt++) { let value; try { value = await agent.query({ configuration, agentId: 'pre-branch-sdd-draft', - prompt: attempt === 0 ? contractPrompt : `${contractPrompt}\n\n${problem === 'size' ? `The previous response exceeded the ${maximum}-character draft limit.` : `The previous response failed the required SDD ${formatReason} contract.`} Regenerate a concise complete draft for this same owner and plan. Use the exact numbered canonical headings from the supplied template. Preserve all resolved requirements, architecture, documentation and acceptance. Do not change ownership or truncate a requirement.`, + prompt: `${attempt === 0 ? contractPrompt : `${contractPrompt}\n\n${problem === 'size' ? `The previous response exceeded the ${maximum}-character draft limit.` : `The previous response failed the required SDD ${formatReason} contract.`} Regenerate a concise complete draft for this same owner and plan. Use the exact numbered canonical headings from the supplied template. Preserve all resolved requirements, architecture, documentation and acceptance. Do not change ownership or truncate a requirement.`}\n\n${SDD_CONSISTENCY_PROMPT}`, options: { expectJson: true, schemaName: 'pre_branch_sdd_draft', schema }, }); } catch (error) { @@ -58,9 +59,19 @@ export async function draftSddMarkdown( } } if (problem === 'format') throw new ApplicationError('validation.invalid-input', `The SDD draft still violates its ${formatReason} format contract after one bounded repair.`, { - recovery: { id: 'sdd-draft-format-invalid', variables: {} }, + recovery: { id: consistencyRecovery(formatReason), variables: {} }, }); throw new ApplicationError('validation.invalid-input', 'The SDD draft exceeded its size limit after one bounded repair.', { recovery: { id: 'sdd-draft-size-exceeded', variables: {} }, }); } + +function consistencyRecovery(reason: SddDraftFormatFailure['reason']) { + switch (reason) { + case 'case-identity': return 'sdd-draft-case-identity-invalid'; + case 'case-budget': return 'sdd-draft-case-budget-invalid'; + case 'requirement-mapping': return 'sdd-draft-requirement-mapping-invalid'; + case 'repository-path': return 'sdd-draft-repository-path-invalid'; + default: return 'sdd-draft-format-invalid'; + } +} 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 f81d37acc..cae209b50 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 @@ -28,6 +28,7 @@ import { logError } from '../../ports/logging_ports'; import { renderSddAnalysisStatus, sddAnalysisStatusMarker } from '../../policies/sdd_analysis_status_policy'; import { ApplicationError, toApplicationError } from '../../errors/application_error'; import { draftSddMarkdown } from './pre_branch_sdd_draft_workflow'; +import { reservedSddCaseIds, type SddOwnerDocument } from '../../../domain/sdd_draft_consistency'; export interface PreBranchSddContext { readonly issueNumber: number; @@ -207,10 +208,18 @@ export class PreBranchSddGateUseCase { const currentSdd = plan.action === 'update' ? await this.workspace.readSdd(snapshot.baseSha, plan.path) : undefined; if (plan.action === 'update' && !currentSdd) throw new Error('The catalogued SDD owner is missing from the selected base.'); + const ownerDocuments: SddOwnerDocument[] = []; + for (const path of snapshot.capabilities.find(capability => capability.id === plan.capabilityId)?.specs ?? []) { + const markdown = path === plan.path && currentSdd ? currentSdd : await this.workspace.readSdd(snapshot.baseSha, path); + if (!markdown) throw new Error('A catalogued owning SDD is unavailable at the selected immutable base.'); + ownerDocuments.push({ path, markdown }); + } + const reservedCases = JSON.stringify(reservedSddCaseIds(ownerDocuments)); + if (reservedCases.length > 30000) throw new Error('The owning SDD case inventory exceeds the bounded drafting context; maintainer review is required.'); const newCapability = plan.action === 'new' ? parseNewCapability(analysisValue.newCapability, plan) : undefined; let prepared: SddPreparedDraft | undefined; await draftSddMarkdown(this.agent, context.agentConfiguration, plan, - buildDraftPrompt(context, snapshot, plan, answers, currentSdd), async markdown => { + `${buildDraftPrompt(context, snapshot, plan, answers, currentSdd)}\n\nCase IDs reserved across every owning SDD at ${snapshot.baseSha}:\n${reservedCases}`, async markdown => { prepared = await this.workspace.validateDraft(snapshot, plan, markdown, newCapability); }); if (!prepared) throw new Error('The SDD writer returned no validated draft.'); diff --git a/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts b/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts index 4cf54ffc7..a378e5a34 100644 --- a/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts +++ b/src/application/usecases/steps/issue/__tests__/pre_branch_sdd_gate_use_case.test.ts @@ -63,6 +63,45 @@ function harness() { } describe('PreBranchSddGateUseCase', () => { + it('supplies reserved identities from every immutable owner document when drafting a companion', async () => { + const h = harness(); + h.loadSnapshot.mockResolvedValue({ ...snapshot, capabilities: [{ ...snapshot.capabilities[0], specs: ['specs/payments.md', 'specs/session.md'] }] }); + h.readSdd.mockImplementation(async (_sha, path) => path === 'specs/payments.md' ? 'payments-C01 missing bearer' : 'payments-session-C01 valid session'); + h.query.mockResolvedValueOnce({ ...plan, action: 'companion', path: 'specs/change.md', newCapability: null }).mockResolvedValueOnce({ markdown: '# Draft' }); + expect((await h.useCase.begin(context())).status).toBe('drafted'); + expect(h.readSdd.mock.calls).toEqual([[baseSha, 'specs/payments.md'], [baseSha, 'specs/session.md']]); + expect(h.query.mock.calls[1][0].prompt).toContain('["payments-C01","payments-session-C01"]'); + }); + + it('blocks before drafting when a companion baseline cannot be read', async () => { + const h = harness(); + h.readSdd.mockResolvedValue(undefined); + h.query.mockResolvedValueOnce({ ...plan, action: 'companion', path: 'specs/change.md', newCapability: null }); + expect((await h.useCase.begin(context())).status).toBe('blocked'); + expect(h.query).toHaveBeenCalledTimes(1); + expect(h.validateDraft).not.toHaveBeenCalled(); + }); + + it('blocks oversized owner identity context without truncating or drafting', async () => { + const h = harness(); + h.readSdd.mockResolvedValue(Array.from({ length: 2500 }, (_, index) => `payments-long-baseline-C${index}`).join('\n')); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }); + expect((await h.useCase.begin(context())).status).toBe('blocked'); + expect(h.query).toHaveBeenCalledTimes(1); + expect(h.validateDraft).not.toHaveBeenCalled(); + }); + + it('keeps publication blocked after repeated case inconsistencies', async () => { + const h = harness(); + h.query.mockResolvedValueOnce({ ...plan, newCapability: null }).mockResolvedValue({ markdown: '# Contradictory draft' }); + h.validateDraft.mockRejectedValue(new SddDraftFormatFailure('requirement-mapping', 'Mismatch')); + const result = await h.useCase.begin(context()); + expect(result.status).toBe('blocked'); + expect(h.validateDraft).toHaveBeenCalledTimes(2); + expect(h.publish).not.toHaveBeenCalled(); + expect(h.labels()).not.toContain('branched'); + }); + it('withholds oversized immutable owner evidence while preserving the owner and bounded drafting instructions', async () => { const h = harness(); const large = { ...snapshot, capabilities: [{ ...snapshot.capabilities[0], diff --git a/src/data/model/application_error.ts b/src/data/model/application_error.ts index 274af0a25..7b30537b3 100644 --- a/src/data/model/application_error.ts +++ b/src/data/model/application_error.ts @@ -50,6 +50,11 @@ export const APPLICATION_ERROR_RECOVERY_IDS = Object.freeze([ 'branch-sync-resolution-invalid', 'sdd-draft-size-exceeded', 'sdd-draft-format-invalid', + 'sdd-draft-case-identity-invalid', + 'sdd-draft-case-budget-invalid', + 'sdd-draft-requirement-mapping-invalid', + 'sdd-draft-repository-path-invalid', + 'workspace-changes-rejected', 'closed-issue-reconciliation-failed', 'approval-evidence-read-failed', @@ -80,6 +85,11 @@ interface ApplicationErrorRecoveryVariables { readonly 'branch-sync-resolution-invalid': Readonly<{ validationCode: BranchSyncValidationCode; command: '/copilot sync-branch' }>; readonly 'sdd-draft-size-exceeded': Readonly>; readonly 'sdd-draft-format-invalid': Readonly>; + readonly 'sdd-draft-case-identity-invalid': Readonly>; + readonly 'sdd-draft-case-budget-invalid': Readonly>; + readonly 'sdd-draft-requirement-mapping-invalid': Readonly>; + readonly 'sdd-draft-repository-path-invalid': Readonly>; + readonly 'workspace-changes-rejected': Readonly>; } @@ -340,6 +350,11 @@ const RECOVERY_VARIABLE_KEYS: Readonly validateSddDraftConsistency(markdown, owners, inventory, 'specs/change.md'); + +describe('generated SDD consistency', () => { + it('accepts complete tables with an independently scoped case namespace and exact reverse mappings', () => { + expect(() => validate(sddConsistencyFixture())).not.toThrow(); + }); + + it.each([ + ['primary collision', (s: string) => s.replace(/payments-change-C01/gu, 'payments-C01')], + ['companion collision', (s: string) => s.replace(/payments-change-C01/gu, 'payments-session-C09')], + ['duplicate definition', (s: string) => s.replace(/payments-change-C02/gu, 'payments-change-C01')], + ['shorthand identity', (s: string) => s.replace(/payments-change-C02/gu, 'C02')], + ['unknown reference', (s: string) => `${s}\nSee payments-missing-C99.`], + ['missing inventory', (s: string) => s.replace('Case ID | Area', 'Other ID | Area')], + ['hidden inventory', (s: string) => s.replace('| Case ID | Area', '\n## 15.')], + ['fenced inventory', (s: string) => s.replace('| Case ID | Area', '```md\n| Case ID | Area').replace('## 15.', '```\n## 15.')], + ['malformed inventory separator', (s: string) => s.replace('|---|---|---|---|', '|---|---|bad|---|')], + ['empty scenario', (s: string) => s.replace('Missing credentials return 401.', '')], + ['extra inventory column', (s: string) => s.replace('Missing credentials return 401. |', 'Missing credentials return 401. | extra |')], + ])('rejects %s before publication', (_name, mutate) => { + expect(() => validate(mutate(sddConsistencyFixture()))).toThrow(expect.objectContaining({ reason: 'case-identity' })); + }); + + it.each([ + ['offsetting area errors', (s: string) => s.replace('| Policy | 2 |', '| Policy | 1 |').replace('| Adapter | 1 |', '| Adapter | 2 |')], + ['unknown area', (s: string) => s.replace('| Adapter | G-02 |', '| Browser | G-02 |')], + ['missing total', (s: string) => s.replace('| **Total** | **3** | No double counting |', '')], + ['incorrect total', (s: string) => s.replace('**3**', '**4**')], + ['zero allocation', (s: string) => s.replace('| Policy | 2 |', '| Policy | 0 |')], + ['placeholder count', (s: string) => s.replace('| Policy | 2 |', '| Policy | |')], + ['unsafe integer', (s: string) => s.replace('| Policy | 2 |', '| Policy | 9007199254740992 |')], + ['duplicate area', (s: string) => s.replace('| Adapter | 1 |', '| Policy | 1 |')], + ['row after total', (s: string) => s.replace('| **Total** | **3** | No double counting |', '| **Total** | **3** | No double counting |\n| Extra | 1 | Duplicate |')], + ['prose bypass', (s: string) => s.replace('Minimum distinct cases', 'Unknown budget') + '\nAt least 3 distinct cases.'], + ['duplicate budget', (s: string) => s.replace('## 16.', '## 14. Testing strategy\n## 16.')], + ])('rejects %s even if numeric prose or the grand total appears valid', (_name, mutate) => { + const reason = _name === 'duplicate budget' ? 'case-identity' : 'case-budget'; + expect(() => validate(mutate(sddConsistencyFixture()))).toThrow(expect.objectContaining({ reason })); + }); + + it.each([ + ['one-sided requirement', (s: string) => s.replace('| G-02 | payments-change-C03 |', '| G-02 | payments-change-C02 |')], + ['undefined requirement', (s: string) => s.replace('| Adapter | G-02 |', '| Adapter | G-09 |')], + ['missing requirement coverage', (s: string) => s.replace('| G-02 | Preserve', '| G-09 | Preserve')], + ['generic documentation check', (s: string) => s.replace('| G-02 | payments-change-C03 |', '| G-02 | documentation check |')], + ['undefined case', (s: string) => s.replace('| G-02 | payments-change-C03 |', '| G-02 | C03 |')], + ['duplicate mapping', (s: string) => s.replace('| G-02 | payments-change-C03 |', '| G-01 | payments-change-C03 |')], + ['duplicate goal', (s: string) => s.replace('| G-02 | Preserve', '| G-01 | Preserve')], + ['duplicate case requirement', (s: string) => s.replace('| Policy | G-01 |', '| Policy | G-01, G-01 |')], + ['empty reverse links', (s: string) => s.replace('| G-02 | payments-change-C03 |', '')], + ['missing requirement table', (s: string) => s.replace('| Requirement | Behavior |', '| Goal | Behavior |')], + ['uncovered goal', (s: string) => s.replace('| G-02 | Preserve the accepted principal through publication. |', '| G-02 | Preserve the accepted principal through publication. |\n| G-03 | Describe recovery. |')], + ['missing traceability table', (s: string) => s.replace('| Requirement | Cases |', '| Requirement | Evidence |')], + ])('rejects %s', (_name, mutate) => { + const reason = _name === 'undefined case' ? 'case-identity' : 'requirement-mapping'; + expect(() => validate(mutate(sddConsistencyFixture()))).toThrow(expect.objectContaining({ reason })); + }); + + it.each([ + ['nonexistent update', (s: string) => s.replace('docs/payments.mdx', 'docs/cloud-development-environment.md')], + ['existing file declared new', (s: string) => s.replace('| update |', '| create |')], + ['directory as file', (s: string) => s.replace('docs/payments.mdx', 'docs')], + ['unsupported action', (s: string) => s.replace('| update |', '| someday |')], + ['duplicate path', (s: string) => s.replace('| docs/payments.mdx | update |', '| docs/payments.mdx | update |\n| docs/payments.mdx | reference |')], + ['traversal in table', (s: string) => s.replace('docs/payments.mdx', '../outside.mdx')], + ['undeclared new citation', (s: string) => `${s}\nUpdate \`docs/missing.mdx\`.`], + ['unformatted missing citation', (s: string) => `${s}\nUpdate docs/missing.mdx.`], + ['unknown relative link', (s: string) => `${s}\n[Guide](../docs/missing.mdx)`], + ['escaping link', (s: string) => `${s}\n[Guide](../../outside.mdx)`], + ['invalid encoded link', (s: string) => `${s}\n[Guide](%broken)`], + ['backslash path', (s: string) => s.replace('docs/payments.mdx', 'docs\\payments.mdx')], + ['symlink proposal', (s: string) => s.replace('| docs/payments.mdx | update |', '| docs/link/new.md | create |')], + ['submodule proposal', (s: string) => s.replace('| docs/payments.mdx | update |', '| submodule/new.md | create |')], + ['file as parent', (s: string) => s.replace('| docs/payments.mdx | update |', '| backend/app.kt/new.md | create |')], + ])('rejects %s against the immutable inventory', (_name, mutate) => { + expect(() => validate(mutate(sddConsistencyFixture()))).toThrow(expect.objectContaining({ reason: 'repository-path' })); + }); + + it('allows intentional new files, relative file links, language-neutral paths, directories and external links', () => { + const markdown = sddConsistencyFixture().replace('| docs/payments.mdx | update |', '| docs/new_guide.md | create |\n| backend/app.kt | reference |') + + '\nSee `docs/new_guide.md`, `docs/my_guide.mdx`, `backend/`, [existing](../docs/payments.mdx#intro), [same](#scope), [proposed](../docs/new_guide.md), [provider](https://example.test/path/file.md) and the retained payments-C01.'; + expect(() => validate(markdown)).not.toThrow(); + }); + + it('reserves cases from every owner without treating hidden or fenced examples as definitions', () => { + expect(reservedSddCaseIds([...owners, { path: 'specs/examples.md', markdown: '\n```md\nexample-C01\n```' }])) + .toEqual(['payments-C01', 'payments-session-C09']); + }); + + it('does not let a duplicate canonical inventory prove a single contract', () => { + const markdown = sddConsistencyFixture().replace('## 15.', '| Case ID | Area | Requirements | Scenario |\n|---|---|---|---|\n| payments-extra-C01 | Policy | G-01 | Another case. |\n\n## 15.'); + expect(() => validate(markdown)).toThrow(expect.objectContaining({ reason: 'case-identity' satisfies SddConsistencyReason })); + }); + + it('reconciles shorthand legacy inventory rows against their fully qualified namespace', () => { + expect(reservedSddCaseIds([{ path: 'specs/legacy.md', markdown: 'identity-access-C01 missing bearer; C02 invalid claims; C03 expired session.' }])) + .toEqual(['identity-access-C01', 'identity-access-C02', 'identity-access-C03']); + }); + + it('reserves intermediate cases in a legacy shorthand range', () => { + expect(reservedSddCaseIds([{ path: 'specs/legacy.md', markdown: 'identity-access-C01; C02–C05.' }])) + .toEqual(['identity-access-C01', 'identity-access-C02', 'identity-access-C03', 'identity-access-C04', 'identity-access-C05']); + }); + + it('blocks an unreconcilable owner range rather than truncating its reservation', () => { + expect(() => reservedSddCaseIds([{ path: 'specs/legacy.md', markdown: 'identity-access-C01; C02–C99999.' }])).toThrow('review the baseline'); + }); +}); diff --git a/src/domain/pre_branch_sdd.ts b/src/domain/pre_branch_sdd.ts index 7b1943f8c..c9d2a52b2 100644 --- a/src/domain/pre_branch_sdd.ts +++ b/src/domain/pre_branch_sdd.ts @@ -229,12 +229,12 @@ export function parseSddAnswer(body: string, questionId: string): string | undef /** Only these content failures may trigger a bounded draft regeneration. */ export class SddDraftFormatFailure extends Error { - constructor(readonly reason: 'required-heading' | 'test-budget', message: string) { super(message); } + constructor(readonly reason: 'required-heading' | 'test-budget' | 'case-identity' | 'case-budget' | 'requirement-mapping' | 'repository-path', message: string) { super(message); } } /** An immutable owner cannot be repaired by regenerating its proposed appendix. */ export class SddOwnerFormatFailure extends Error { - constructor(readonly reason: 'required-heading' | 'test-budget') { + constructor(readonly reason: SddDraftFormatFailure['reason']) { super(`The existing owning SDD violates its ${reason} contract; review the owner before retrying.`); } } @@ -318,19 +318,33 @@ export function composeSddUpdate(existing: string, change: string): string { } validateSddMarkdown(change); if (change.length > SDD_MAX_UPDATE_CHARACTERS) throw new Error('An SDD update appendix exceeds 18000 characters; use a proportional change or a companion SDD.'); + const appendix = shiftSddHeadings(change, 1); + const merged = `${existing.trimEnd()}\n\n${appendix.trim()}\n`; + validateSddMarkdown(merged); + return merged; +} + +/** Recovery validates the original candidate, not a second copy of its retained baseline. */ +export function recoverSddUpdate(existing: string, published: string): string | undefined { + const prefix = `${existing.trimEnd()}\n\n`; + if (!published.startsWith(prefix)) return undefined; + const change = shiftSddHeadings(published.slice(prefix.length), -1); + return composeSddUpdate(existing, change) === published ? change : undefined; +} + +function shiftSddHeadings(source: string, direction: 1 | -1): string { let fence: string | undefined; - const appendix = change.split('\n').map(line => { + return source.split('\n').map(line => { const boundary = line.match(/^\s*(`{3,}|~{3,})/); if (boundary) { if (!fence) fence = boundary[1]; else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) fence = undefined; return line; } - return !fence && /^#{1,5} /.test(line) ? `#${line}` : line; + if (fence) return line; + return direction === 1 ? (/^#{1,5} /.test(line) ? `#${line}` : line) + : (/^#{2,6} /.test(line) ? line.slice(1) : line); }).join('\n'); - const merged = `${existing.trimEnd()}\n\n${appendix.trim()}\n`; - validateSddMarkdown(merged); - return merged; } function serializeSddGateValue(value: SddGateRecord | readonly SddAnswer[]): string { diff --git a/src/domain/sdd_draft_consistency.ts b/src/domain/sdd_draft_consistency.ts new file mode 100644 index 000000000..5e40da676 --- /dev/null +++ b/src/domain/sdd_draft_consistency.ts @@ -0,0 +1,192 @@ +import { SddDraftFormatFailure } from './pre_branch_sdd'; + +export type SddConsistencyReason = 'case-identity' | 'case-budget' | 'requirement-mapping' | 'repository-path'; +export interface SddOwnerDocument { readonly path: string; readonly markdown: string } +export interface SddRepositoryInventory { readonly files: ReadonlySet; readonly directories: ReadonlySet; readonly blocked: ReadonlySet } + +const CASE = /\b(?:[A-Za-z][A-Za-z0-9]*-)+C\d+\b/gu; +const CASE_ID = /^(?:[A-Za-z][A-Za-z0-9]*-)+C\d+$/u; +const REQUIREMENT_ID = /^[A-Za-z][A-Za-z0-9]*(?:-[A-Za-z0-9]+)+$/u; + +/** Generated candidates use visible tables; retained legacy owners are never reformatted. */ +export const SDD_CONSISTENCY_PROMPT = `Consistency contract for the proposed draft/appendix (not the retained owner): +In section 4 include one table: Requirement | Behavior. Give every goal a stable requirement ID. +In section 14 include one budget table: Area | Minimum distinct cases | Behaviors/risks covered; positive integers and one Total. +Also in section 14 include one inventory table: Case ID | Area | Requirements | Scenario. +Each row is one distinct executable case with a fully qualified ID such as capability-change-C01, one exact budget area, comma-separated requirement IDs and an observable scenario. Never use shorthand C01, ranges or redefine an ID found in any owning SDD. Reference retained baseline cases in prose using their full IDs; count only the new inventory. +In section 17 include one table: Requirement | Cases. List comma-separated full case IDs for every requirement. Its links must exactly match the case inventory in both directions, with no undefined or generic documentation checks. +In section 15 include one table: Path | Change. Change is update, reference or create. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; + +function fail(reason: SddConsistencyReason): never { + // Closed diagnostics: rejected Markdown and repository text never become repair instructions. + throw new SddDraftFormatFailure(reason, `The SDD draft violates its ${reason} consistency contract.`); +} + +/** Excludes examples and hidden records from the contract. */ +function visible(source: string): string { + let fence: string | undefined; + return source.replace(/|$)/gu, '\n').replace(/\r\n/gu, '\n').split('\n').map(line => { + const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); + if (boundary) { + if (!fence) fence = boundary[1]; + else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) fence = undefined; + return ''; + } + return fence ? '' : line; + }).join('\n'); +} + +function clean(value: string): string { return value.trim().replace(/^(?:\*\*|__|`)(.*?)(?:\*\*|__|`)$/u, '$1').trim(); } + +function table(source: string, section: number, headers: readonly string[], reason: SddConsistencyReason): string[][] { + const sections = [...source.matchAll(/^## (\d+)\. [^\n]*$/gmu)]; + const matches = sections.filter(heading => Number(heading[1]) === section); + if (matches.length !== 1) return fail(reason); + const start = matches[0]; + const end = sections.find(heading => heading.index > start.index)?.index ?? source.length; + const lines = source.slice(start.index + start[0].length, end).split('\n'); + const found: string[][][] = []; + for (let index = 0; index < lines.length; index++) { + if (!/^\s*\|/u.test(lines[index])) continue; + const cells = (line: string) => line.trim().replace(/^\||\|$/gu, '').split('|').map(clean); + const header = cells(lines[index]); + if (!headers.every((value, position) => header[position] === value) || header.length !== headers.length) continue; + const separator = cells(lines[++index] ?? ''); + if (separator.length !== headers.length || !separator.every(cell => /^:?-{3,}:?$/u.test(cell))) return fail(reason); + const rows: string[][] = []; + while (++index < lines.length && /^\s*\|/u.test(lines[index])) { + const row = cells(lines[index]); + if (row.length !== headers.length || row.some(cell => !cell)) return fail(reason); + rows.push(row); + } + if (!rows.length) return fail(reason); + found.push(rows); + } + return found.length === 1 ? found[0] : fail(reason); +} + +function ids(value: string, pattern: RegExp, reason: SddConsistencyReason): string[] { + const values = value.split(/\s*,\s*/u); + if (!values.every(id => pattern.test(id)) || new Set(values).size !== values.length) return fail(reason); + return values; +} + +export function reservedSddCaseIds(documents: readonly SddOwnerDocument[]): string[] { + const reserved = new Set(); + for (const document of documents) { + const source = visible(document.markdown); + const qualified = source.match(CASE) ?? []; + const namespaces = new Set(qualified.map(id => id.slice(0, id.lastIndexOf('-C') + 2))); + for (const id of qualified) reserved.add(id); + // Legacy inventories often qualify C01 and abbreviate the remaining rows. + // Reserve every plausible namespace instead of silently assigning an alias a new meaning. + for (const match of source.matchAll(/(? 1000) { + throw new Error('An owning SDD case range cannot be reconciled within the fixed bound; review the baseline before retrying.'); + } + for (let number = start; number <= end; number++) { + for (const namespace of namespaces) reserved.add(`${namespace}${String(number).padStart(match[1].length, '0')}`); + } + } + } + return [...reserved].sort(); +} + +export function validateSddDraftConsistency( + markdown: string, + owners: readonly SddOwnerDocument[], + inventory: SddRepositoryInventory, + sddPath: string, +): void { + const source = visible(markdown); + const requirements = table(source, 4, ['Requirement', 'Behavior'], 'requirement-mapping'); + const requirementIds = requirements.map(row => row[0]); + if (new Set(requirementIds).size !== requirementIds.length || !requirementIds.every(id => REQUIREMENT_ID.test(id))) fail('requirement-mapping'); + const cases = table(source, 14, ['Case ID', 'Area', 'Requirements', 'Scenario'], 'case-identity'); + const caseIds = cases.map(row => row[0]); + const reserved = new Set(reservedSddCaseIds(owners)); + if (new Set(caseIds).size !== caseIds.length || caseIds.some(id => !CASE_ID.test(id) || reserved.has(id)) + || /(? !caseIds.includes(id) && !reserved.has(id))) fail('case-identity'); + + const budget = table(source, 14, ['Area', 'Minimum distinct cases', 'Behaviors/risks covered'], 'case-budget'); + const counts = new Map(); + let total: number | undefined; + for (const [area, value] of budget) { + const count = /^[1-9]\d*$/u.test(value) ? Number(value) : NaN; + if (!Number.isSafeInteger(count) || counts.has(area) || total !== undefined) fail('case-budget'); + if (area === 'Total') total = count; + else counts.set(area, count); + } + if (!counts.size || total !== caseIds.length || [...counts.values()].reduce((sum, count) => sum + count, 0) !== total + || cases.some(row => !counts.has(row[1]))) fail('case-budget'); + for (const [area, count] of counts) if (cases.filter(row => row[1] === area).length !== count) fail('case-budget'); + + const edges = new Map(requirementIds.map(id => [id, new Set()])); + for (const [id, , links] of cases) { + for (const requirement of ids(links, REQUIREMENT_ID, 'requirement-mapping')) { + const linked = edges.get(requirement); + if (!linked) fail('requirement-mapping'); + linked.add(id); + } + } + const traceability = table(source, 17, ['Requirement', 'Cases'], 'requirement-mapping'); + const seen = new Set(); + for (const [requirement, links] of traceability) { + const expected = edges.get(requirement); + const actual = ids(links, CASE_ID, 'requirement-mapping'); + if (!expected || seen.has(requirement) || expected.size !== actual.length || actual.some(id => !expected.has(id))) fail('requirement-mapping'); + seen.add(requirement); + } + if (seen.size !== edges.size || [...edges.values()].some(links => links.size === 0)) fail('requirement-mapping'); + validatePaths(source, inventory, sddPath); +} + +function safePath(value: string): boolean { + return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) + && value.split('/').every(segment => segment && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git'); +} + +function validatePaths(source: string, inventory: SddRepositoryInventory, sddPath: string): void { + const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); + const proposed = new Set([sddPath]); + const seen = new Set(); + for (const [file, change] of rows) { + if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) fail('repository-path'); + seen.add(file); + if (change === 'create') { + if (inventory.files.has(file) || inventory.directories.has(file)) fail('repository-path'); + const segments = file.split('/'); + if (segments.some((_, index) => inventory.files.has(segments.slice(0, index + 1).join('/')) || inventory.blocked.has(segments.slice(0, index + 1).join('/')))) fail('repository-path'); + proposed.add(file); + } else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) fail('repository-path'); + } + const check = (file: string) => { + if (!safePath(file) || (!inventory.files.has(file) && !inventory.directories.has(file) && !proposed.has(file))) fail('repository-path'); + }; + // Relative Markdown file links resolve against the selected SDD, never the current checkout. + for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + const href = match[1].split('#')[0]; + if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; + let decoded: string; + try { decoded = decodeURIComponent(href); } catch { return fail('repository-path'); } + const parts = sddPath.split('/').slice(0, -1); + for (const part of decoded.split('/')) { + if (part === '..') { if (!parts.length) fail('repository-path'); parts.pop(); } + else if (part !== '.') parts.push(part); + } + check(parts.join('/')); + } + for (const match of source.matchAll(/`([^`\n]+)`/gu)) { + const value = match[1]; + if (value.includes('/') && !/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value) && !/\s/u.test(value)) check(value.replace(/\/$/u, '')); + } + const prose = source.replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + for (const match of prose.matchAll(/(? { beforeEach(() => { ({ temp, repo } = fixture()); }); afterEach(() => fs.rmSync(temp, { recursive: true, force: true })); + it.each([ + ['case-identity', (s: string) => s.replace(/payments-change-C01/gu, 'payments-baseline-C01')], + ['case-budget', (s: string) => s.replace('| Policy | 2 |', '| Policy | 1 |').replace('| Adapter | 1 |', '| Adapter | 2 |')], + ['requirement-mapping', (s: string) => s.replace('| G-02 | payments-change-C03 |', '| G-02 | payments-change-C01 |')], + ['repository-path', (s: string) => s.replace('docs/payments.mdx', 'docs/current-checkout-only.md')], + ])('rejects %s in a companion without changing the baseline, publishing or retaining scratch worktrees', async (reason, mutate) => { + fs.appendFileSync(path.join(repo, plan.path), '\npayments-baseline-C01: Missing bearer returns 401.\n'); + git(repo, 'add', plan.path); + git(repo, 'commit', '-m', 'stable owner cases'); + git(repo, 'push', 'origin', 'develop'); + const workspace = new PreBranchSddWorkspaceAdapter(repo); + const snapshot = await workspace.loadSnapshot('develop'); + fs.writeFileSync(path.join(repo, 'docs/current-checkout-only.md'), '# Uncommitted file cannot prove immutable existence.'); + const before = fs.readFileSync(path.join(repo, plan.path), 'utf8'); + await expect(workspace.validateDraft(snapshot, { ...plan, action: 'companion', path: 'specs/companion.md' }, mutate(draft()))) + .rejects.toMatchObject({ reason }); + expect(fs.readFileSync(path.join(repo, plan.path), 'utf8')).toBe(before); + expect(git(repo, 'ls-remote', '--heads', 'origin', 'feature/42-change')).toBe(''); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + }); + it.each(['update', 'companion'] as const)('validates and publishes a portable %s through the unchanged SDD-only first-commit allowlist', async action => { const capability = portableSddCapability(); writePortableSddEvidence(repo, capability); @@ -101,7 +125,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { }); it('validates a table-only owner update with the real scratch-worktree and catalog boundary', async () => { - const owner = draft().replace('At least 20 distinct cases cover success, replay, authorization, and failure.', + const owner = legacyDraft().replace('At least 20 distinct cases cover success, replay, authorization, and failure.', '| Area | Minimum cases | Risks |\n|---|---:|---|\n| Policy | 12 | Authority |\n| Adapter | 8 | Publication |\n| Total | 20 | Distinct |'); fs.writeFileSync(path.join(repo, plan.path), owner); git(repo, 'add', plan.path); @@ -109,7 +133,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { git(repo, 'push', 'origin', 'develop'); const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop'); - const prepared = await workspace.validateDraft(snapshot, plan, owner.replace('# Payment behavior', '# Proposed extension')); + const prepared = await workspace.validateDraft(snapshot, plan, draft().replace('# Payment behavior', '# Proposed extension')); expect(prepared.markdown.startsWith(owner.trimEnd())).toBe(true); expect(prepared.markdown).toContain('### 14. Testing strategy'); expect(git(repo, 'status', '--porcelain')).toBe(''); @@ -209,7 +233,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { expect(published.startsWith(previous.trimEnd())).toBe(true); expect(published).toContain('20 distinct cases'); - expect(published).toContain('4 distinct cases'); + expect(published).toContain('| **Total** | **3** |'); expect(published).toContain('## Read-only status'); }); diff --git a/src/infrastructure/pre_branch_sdd_workspace_adapter.ts b/src/infrastructure/pre_branch_sdd_workspace_adapter.ts index 28646c542..50f421e05 100644 --- a/src/infrastructure/pre_branch_sdd_workspace_adapter.ts +++ b/src/infrastructure/pre_branch_sdd_workspace_adapter.ts @@ -10,7 +10,8 @@ import type { SddCatalogSnapshot, SddPreparedDraft, } from '../application/ports/pre_branch_sdd_ports'; -import { composeSddUpdate, isSafeSddPath, validateSddMarkdown, type SddPlan } from '../domain/pre_branch_sdd'; +import { composeSddUpdate, recoverSddUpdate, isSafeSddPath, validateSddMarkdown, type SddPlan } from '../domain/pre_branch_sdd'; +import { validateSddDraftConsistency, type SddRepositoryInventory, type SddOwnerDocument } from '../domain/sdd_draft_consistency'; import { buildGitAuthenticationEnvironment } from './git_authentication_environment'; import { withoutGitHooks } from './git_hook_isolation'; @@ -94,6 +95,15 @@ export class PreBranchSddWorkspaceAdapter implements PreBranchSddWorkspacePort { if (fs.existsSync(target) !== (plan.action === 'update')) { throw new Error('The SDD owner changed since analysis; restart clarification.'); } + const owners: SddOwnerDocument[] = []; + const inventory = await this.inventoryAt(snapshot.baseSha); + for (const file of snapshot.capabilities.find(capability => capability.id === plan.capabilityId)?.specs ?? []) { + if (!isSafeSddPath(file)) throw new Error('An owning SDD path is unsafe.'); + if (!inventory.files.has(file)) throw new Error('An owning SDD is not a regular file at the immutable base.'); + assertRegularSpecFile(path.join(root, file)); + owners.push({ path: file, markdown: await this.git(['show', `${snapshot.baseSha}:${file}`]) }); + } + validateSddDraftConsistency(markdown, owners, inventory, plan.path); if (plan.action === 'update') { assertRegularSpecFile(target); const owner = await this.git(['show', `${snapshot.baseSha}:${plan.path}`]); @@ -219,7 +229,11 @@ export class PreBranchSddWorkspaceAdapter implements PreBranchSddWorkspacePort { const raw = await this.git(['show', `${first}:specs/catalog.json`]); newCapability = (JSON.parse(raw) as Catalog).capabilities.find(capability => capability.id === prepared.plan.capabilityId); } - const recovered = await this.validateDraft(snapshot, prepared.plan, content, newCapability); + const candidate = prepared.plan.action === 'update' + ? recoverSddUpdate(await this.git(['show', `${prepared.baseSha}:${prepared.plan.path}`]), content) : content; + if (!candidate) return undefined; + const recovered = await this.validateDraft(snapshot, prepared.plan, candidate, newCapability); + if (recovered.markdown !== content) return undefined; const committedPaths = (await this.git(['diff-tree', '--no-commit-id', '--name-only', '-r', first])).trim().split('\n').filter(Boolean).sort(); if (committedPaths.join('\n') !== recovered.changedPaths.join('\n')) return undefined; if (recovered.catalogJson && await this.git(['show', `${first}:specs/catalog.json`]) !== recovered.catalogJson) return undefined; @@ -251,6 +265,23 @@ export class PreBranchSddWorkspaceAdapter implements PreBranchSddWorkspacePort { return output.split('\n').filter(Boolean).map(line => line.slice(3)).sort(); } + private async inventoryAt(sha: string): Promise { + const files = new Set(); + const directories = new Set(); + const blocked = new Set(); + const tree = await this.git(['ls-tree', '-rz', '--full-tree', sha]); + for (const entry of tree.split('\0').filter(Boolean)) { + const tab = entry.indexOf('\t'); + const file = entry.slice(tab + 1); + const mode = entry.slice(0, 6); + if (mode === '100644' || mode === '100755') files.add(file); + else blocked.add(file); + const parts = file.split('/'); + for (let index = 1; index < parts.length; index++) directories.add(parts.slice(0, index).join('/')); + } + return { files, directories, blocked }; + } + private async addDetachedWorktree(sha: string): Promise { const root = fs.mkdtempSync(path.join(os.tmpdir(), 'copilot-sdd-')); try { diff --git a/src/testing/sdd_consistency_fixture.ts b/src/testing/sdd_consistency_fixture.ts new file mode 100644 index 000000000..3396e96d3 --- /dev/null +++ b/src/testing/sdd_consistency_fixture.ts @@ -0,0 +1,47 @@ +/** Visible canonical tables shared by pure consistency and real Git publication fixtures. */ +export function sddConsistencyFixture(prefix = 'payments-change'): string { + return `# Payment behavior + +- Status: Draft +- Owners: Maintainers + +## 1. Executive summary +${'Preserve authorization, immutable evidence and the existing owning product contract. '.repeat(18)} + +## 4. Goals +| Requirement | Behavior | +|---|---| +| G-01 | Reject missing credentials and accept valid credentials. | +| G-02 | Preserve the accepted principal through publication. | + +## 8. Clean Architecture +The domain owns pure decisions; the workspace adapter reads immutable Git objects and the application coordinates validation before publication. + +## 14. Testing strategy +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Policy | 2 | Authentication | +| Adapter | 1 | Publication | +| **Total** | **3** | No double counting | + +| Case ID | Area | Requirements | Scenario | +|---|---|---|---| +| ${prefix}-C01 | Policy | G-01 | Missing credentials return 401. | +| ${prefix}-C02 | Policy | G-01 | Valid credentials produce an accepted principal. | +| ${prefix}-C03 | Adapter | G-02 | Publishing preserves the principal. | + +## 15. Documentation +| Path | Change | +|---|---| +| docs/payments.mdx | update | + +## 16. Acceptance scenarios +The three cases above are observable product outcomes; runtime and security review remain separate acceptance gates. + +## 17. Requirements traceability +| Requirement | Cases | +|---|---| +| G-01 | ${prefix}-C01, ${prefix}-C02 | +| G-02 | ${prefix}-C03 | +`; +} From 3b933a5f1c8f2e825b3645274d1d007ceac7b8f3 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 21 Oct 2026 18:28:31 +0200 Subject: [PATCH 2/6] bugfix-653-bug-generated-companion-sdd-publishes-colliding-case-ids-and-inconsistent-acceptance-mappings: preserve appendix recovery size boundaries --- build/cli/index.js | 4 +++- build/github_action/index.js | 4 +++- specs/pre-branch-sdd-gate.md | 5 ++-- src/domain/__tests__/pre_branch_sdd.test.ts | 26 +++++++++++++++++++++ src/domain/pre_branch_sdd.ts | 4 +++- 5 files changed, 38 insertions(+), 5 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 3b3c41ed1..56329dbf5 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -92494,7 +92494,9 @@ function recoverSddUpdate(existing, published) { const prefix = `${existing.trimEnd()}\n\n`; if (!published.startsWith(prefix)) return undefined; - const change = shiftSddHeadings(published.slice(prefix.length), -1); + // Composition normalizes outer whitespace and adds a publication newline. + // Reconstruct an equivalent candidate inside the original size bounds. + const change = shiftSddHeadings(published.slice(prefix.length), -1).trim().padEnd(exports.SDD_MIN_DRAFT_CHARACTERS, '\n'); return composeSddUpdate(existing, change) === published ? change : undefined; } function shiftSddHeadings(source, direction) { diff --git a/build/github_action/index.js b/build/github_action/index.js index 96314c0c1..6760fdba5 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -82987,7 +82987,9 @@ function recoverSddUpdate(existing, published) { const prefix = `${existing.trimEnd()}\n\n`; if (!published.startsWith(prefix)) return undefined; - const change = shiftSddHeadings(published.slice(prefix.length), -1); + // Composition normalizes outer whitespace and adds a publication newline. + // Reconstruct an equivalent candidate inside the original size bounds. + const change = shiftSddHeadings(published.slice(prefix.length), -1).trim().padEnd(exports.SDD_MIN_DRAFT_CHARACTERS, '\n'); return composeSddUpdate(existing, change) === published ? change : undefined; } function shiftSddHeadings(source, direction) { diff --git a/specs/pre-branch-sdd-gate.md b/specs/pre-branch-sdd-gate.md index b04c546c7..28c587955 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -1227,15 +1227,16 @@ headings, mobile/screen-reader behavior and closed sanitized content. Rejected Markdown, case IDs, paths and diagnostic text MUST NOT enter repair instructions or public messages. There is no additional card, label, credential or setting. -The amendment has a minimum **73 distinct cases**, separate from earlier floors: +The amendment has a minimum **78 distinct cases**, separate from earlier floors: | Area | Minimum distinct cases | Behaviors/risks covered | |---|---:|---| | Pure consistency policy | 56 | Owner collisions and aliases, hidden/malformed inventories, offsetting budgets, reciprocal mappings, immutable/proposed paths, portable layouts and bounded ranges | | Application and bounded draft workflow | 9 | All-owner reads, missing/oversized context, blocked publication, four closed repairs and corrected-draft revalidation | +| Publication replay boundaries | 5 | Minimum/maximum candidate lengths, normalized outer whitespace, retained prefix and exact recomposition | | Real Git workspace | 4 | Each reported contradiction blocks publication, ignores uncommitted evidence, preserves the owner and removes scratch worktrees | | Localized recovery | 4 | All four closed variants render English/Spanish retained state without runtime acceptance claims | -| **Total** | **73** | Existing cases are not recounted | +| **Total** | **78** | Existing cases are not recounted | Repository coverage thresholds remain mandatory; new pure policy targets at least 95% lines, with negative/boundary branch fixtures. Tests use deterministic diff --git a/src/domain/__tests__/pre_branch_sdd.test.ts b/src/domain/__tests__/pre_branch_sdd.test.ts index a13c1ffd6..00c26ea69 100644 --- a/src/domain/__tests__/pre_branch_sdd.test.ts +++ b/src/domain/__tests__/pre_branch_sdd.test.ts @@ -8,6 +8,7 @@ import { renderSddGateRecord, validateSddMarkdown, composeSddUpdate, + recoverSddUpdate, normalizeSddIssueTitle, retainSddAnswers, SddOwnerFormatFailure, @@ -39,6 +40,31 @@ describe('pre-branch SDD policy', () => { .toThrow('appendix exceeds 18000'); }); + it.each([1800, 18000])('recovers a valid %i-character appendix without counting the publication newline', size => { + const owner = validDocument('Owner'); + const content = '# Change\n## 1. Executive summary\n## 4. Goals\n## 8. Clean Architecture\n## 14. Testing strategy\n3 distinct cases\n## 16. Acceptance\n'; + const published = composeSddUpdate(owner, content.padEnd(size, 'x')); + const recovered = recoverSddUpdate(owner, published)!; + expect(recovered).toHaveLength(size); + expect(composeSddUpdate(owner, recovered)).toBe(published); + }); + + it('recovers minimum-length candidates whose outer whitespace was normalized during composition', () => { + const owner = validDocument('Owner'); + const content = '# Change\n## 1. Executive summary\n## 4. Goals\n## 8. Clean Architecture\n## 14. Testing strategy\n3 distinct cases\n## 16. Acceptance\n'; + const published = composeSddUpdate(owner, content.padEnd(1800, ' ')); + expect(composeSddUpdate(owner, recoverSddUpdate(owner, published)!)).toBe(published); + }); + + it('rejects recovery when the published bytes do not preserve the owning prefix', () => { + expect(recoverSddUpdate(validDocument('Owner'), validDocument('Replaced owner'))).toBeUndefined(); + }); + + it('rejects recovery when normalization cannot reproduce the published bytes exactly', () => { + const owner = validDocument('Owner'); + expect(recoverSddUpdate(owner, `${composeSddUpdate(owner, validDocument('Change'))}\n`)).toBeUndefined(); + }); + it('rejects a combined owner beyond the fixed document limit instead of truncating it', () => { expect(() => composeSddUpdate(validDocument('Owner', 'x'.repeat(60_000)), validDocument('Change', 'x'.repeat(10_000)))) .toThrow('length is outside'); diff --git a/src/domain/pre_branch_sdd.ts b/src/domain/pre_branch_sdd.ts index c9d2a52b2..20222d32e 100644 --- a/src/domain/pre_branch_sdd.ts +++ b/src/domain/pre_branch_sdd.ts @@ -328,7 +328,9 @@ export function composeSddUpdate(existing: string, change: string): string { export function recoverSddUpdate(existing: string, published: string): string | undefined { const prefix = `${existing.trimEnd()}\n\n`; if (!published.startsWith(prefix)) return undefined; - const change = shiftSddHeadings(published.slice(prefix.length), -1); + // Composition normalizes outer whitespace and adds a publication newline. + // Reconstruct an equivalent candidate inside the original size bounds. + const change = shiftSddHeadings(published.slice(prefix.length), -1).trim().padEnd(SDD_MIN_DRAFT_CHARACTERS, '\n'); return composeSddUpdate(existing, change) === published ? change : undefined; } From 7fc05766d5ad7aa808f3954c8e61e97e8930f4d6 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 21 Oct 2026 19:31:32 +0200 Subject: [PATCH 3/6] bugfix-653-bug-generated-companion-sdd-publishes-colliding-case-ids-and-inconsistent-acceptance-mappings: require selected specification path declarations --- build/cli/index.js | 9 ++- build/github_action/index.js | 9 ++- docs/issues/pre-branch-sdds.mdx | 3 + specs/pre-branch-sdd-gate.md | 19 +++++-- .../__tests__/sdd_draft_consistency.test.ts | 33 ++++++++++- src/domain/sdd_draft_consistency.ts | 8 ++- .../pre_branch_sdd_workspace_adapter.test.ts | 56 ++++++++++++++++--- src/testing/sdd_consistency_fixture.ts | 3 +- 8 files changed, 117 insertions(+), 23 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index d6556ca8a..5ee4d1497 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -93211,7 +93211,7 @@ In section 14 include one budget table: Area | Minimum distinct cases | Behavior Also in section 14 include one inventory table: Case ID | Area | Requirements | Scenario. Each row is one distinct executable case with a fully qualified ID such as capability-change-C01, one exact budget area, comma-separated requirement IDs and an observable scenario. Never use shorthand C01, ranges or redefine an ID found in any owning SDD. Reference retained baseline cases in prose using their full IDs; count only the new inventory. In section 17 include one table: Requirement | Cases. List comma-separated full case IDs for every requirement. Its links must exactly match the case inventory in both directions, with no undefined or generic documentation checks. -In section 15 include one table: Path | Change. Change is update, reference or create. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; +In section 15 include one table: Path | Change. Change is update, reference or create. Include the selected SDD path itself with update for an existing SDD or create for a new/companion SDD. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; function fail(reason) { // Closed diagnostics: rejected Markdown and repository text never become repair instructions. throw new pre_branch_sdd_1.SddDraftFormatFailure(reason, `The SDD draft violates its ${reason} consistency contract.`); @@ -93311,7 +93311,7 @@ function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { || /(? !caseIds.includes(id) && !reserved.has(id))) + if ([...source.matchAll(CASE)].some(([id]) => !caseIds.includes(id) && !reserved.has(id))) fail('case-identity'); const budget = table(source, 14, ['Area', 'Minimum distinct cases', 'Behaviors/risks covered'], 'case-budget'); const counts = new Map(); @@ -93359,7 +93359,10 @@ function safePath(value) { } function validatePaths(source, inventory, sddPath) { const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); - const proposed = new Set([sddPath]); + const expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; + if (!rows.some(([file, change]) => file === sddPath && change === expectedChange)) + fail('repository-path'); + const proposed = new Set(); const seen = new Set(); for (const [file, change] of rows) { if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) diff --git a/build/github_action/index.js b/build/github_action/index.js index 02085af61..46f6a8a32 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -83704,7 +83704,7 @@ In section 14 include one budget table: Area | Minimum distinct cases | Behavior Also in section 14 include one inventory table: Case ID | Area | Requirements | Scenario. Each row is one distinct executable case with a fully qualified ID such as capability-change-C01, one exact budget area, comma-separated requirement IDs and an observable scenario. Never use shorthand C01, ranges or redefine an ID found in any owning SDD. Reference retained baseline cases in prose using their full IDs; count only the new inventory. In section 17 include one table: Requirement | Cases. List comma-separated full case IDs for every requirement. Its links must exactly match the case inventory in both directions, with no undefined or generic documentation checks. -In section 15 include one table: Path | Change. Change is update, reference or create. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; +In section 15 include one table: Path | Change. Change is update, reference or create. Include the selected SDD path itself with update for an existing SDD or create for a new/companion SDD. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; function fail(reason) { // Closed diagnostics: rejected Markdown and repository text never become repair instructions. throw new pre_branch_sdd_1.SddDraftFormatFailure(reason, `The SDD draft violates its ${reason} consistency contract.`); @@ -83804,7 +83804,7 @@ function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { || /(? !caseIds.includes(id) && !reserved.has(id))) + if ([...source.matchAll(CASE)].some(([id]) => !caseIds.includes(id) && !reserved.has(id))) fail('case-identity'); const budget = table(source, 14, ['Area', 'Minimum distinct cases', 'Behaviors/risks covered'], 'case-budget'); const counts = new Map(); @@ -83852,7 +83852,10 @@ function safePath(value) { } function validatePaths(source, inventory, sddPath) { const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); - const proposed = new Set([sddPath]); + const expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; + if (!rows.some(([file, change]) => file === sddPath && change === expectedChange)) + fail('repository-path'); + const proposed = new Set(); const seen = new Set(); for (const [file, change] of rows) { if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) diff --git a/docs/issues/pre-branch-sdds.mdx b/docs/issues/pre-branch-sdds.mdx index 7adeb05e0..363810614 100644 --- a/docs/issues/pre-branch-sdds.mdx +++ b/docs/issues/pre-branch-sdds.mdx @@ -46,6 +46,9 @@ only in the current checkout does not count. Declare intended new files in the section 15 table with `create`; ordinary documentation updates must identify the real existing document. Symlinks, submodules and paths below them cannot establish file evidence. Source paths remain language and layout neutral. +The selected SDD must appear in this table too: use `create` for a new or +companion SDD, and `update` for an existing one. A self-link or a `reference` +row cannot replace that declaration. The new tables apply to generated candidates. Existing owner documents remain verbatim and do not require a table migration to receive an appendix. These diff --git a/specs/pre-branch-sdd-gate.md b/specs/pre-branch-sdd-gate.md index 28c587955..feaf25199 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -1227,16 +1227,16 @@ headings, mobile/screen-reader behavior and closed sanitized content. Rejected Markdown, case IDs, paths and diagnostic text MUST NOT enter repair instructions or public messages. There is no additional card, label, credential or setting. -The amendment has a minimum **78 distinct cases**, separate from earlier floors: +The amendment has a minimum **93 distinct cases**, separate from earlier floors: | Area | Minimum distinct cases | Behaviors/risks covered | |---|---:|---| -| Pure consistency policy | 56 | Owner collisions and aliases, hidden/malformed inventories, offsetting budgets, reciprocal mappings, immutable/proposed paths, portable layouts and bounded ranges | +| Pure consistency policy | 66 | Owner collisions and aliases, hidden/malformed inventories, offsetting budgets, reciprocal mappings, immutable/proposed paths, portable layouts and bounded ranges | | Application and bounded draft workflow | 9 | All-owner reads, missing/oversized context, blocked publication, four closed repairs and corrected-draft revalidation | | Publication replay boundaries | 5 | Minimum/maximum candidate lengths, normalized outer whitespace, retained prefix and exact recomposition | -| Real Git workspace | 4 | Each reported contradiction blocks publication, ignores uncommitted evidence, preserves the owner and removes scratch worktrees | +| Real Git workspace | 9 | Each reported contradiction blocks publication, ignores uncommitted evidence, preserves the owner and removes scratch worktrees | | Localized recovery | 4 | All four closed variants render English/Spanish retained state without runtime acceptance claims | -| **Total** | **78** | Existing cases are not recounted | +| **Total** | **93** | Existing cases are not recounted | Repository coverage thresholds remain mandatory; new pure policy targets at least 95% lines, with negative/boundary branch fixtures. Tests use deterministic @@ -1263,3 +1263,14 @@ build and package gates. All new evidence files MUST exist. No runtime prerequisite, framework or acceptance check may be weakened. In-flight drafts use the contract at their next validation. Rollback selects the previous Action version without rewriting historical commits or branches. + +PR #654 review finding 4339327223 closes an inventory exemption: section 15 MUST +include the selected SDD itself, with create when absent from the immutable base +and update when it already exists. Reference, omission or a self-link cannot +substitute for this row. No SDD path is implicitly pre-approved. Ten additional +pure cases cover these declarations and malformed table/fence boundaries; five +workspace cases cover omitted update/companion rows, unsafe owner evidence and +recovery rejection for a replaced owner or changed revalidation bytes. They are +included once in the 93-case amendment floor above. The existing closed path +recovery, shared retry ceiling and localized publication/acceptance distinction +remain unchanged. diff --git a/src/domain/__tests__/sdd_draft_consistency.test.ts b/src/domain/__tests__/sdd_draft_consistency.test.ts index 057c71955..281f1ea6c 100644 --- a/src/domain/__tests__/sdd_draft_consistency.test.ts +++ b/src/domain/__tests__/sdd_draft_consistency.test.ts @@ -14,6 +14,37 @@ describe('generated SDD consistency', () => { expect(() => validate(sddConsistencyFixture())).not.toThrow(); }); + it.each(['omitted', 'reference', 'update'] as const)('rejects a new SDD declared as %s even when a self-link resolves to its path', change => { + const row = '| specs/change.md | create |'; + const markdown = sddConsistencyFixture().replace(row, change === 'omitted' ? '' : `| specs/change.md | ${change} |`) + '\n[Draft](./change.md)'; + expect(() => validate(markdown)).toThrow(expect.objectContaining({ reason: 'repository-path' })); + }); + + it('accepts a declared update to an immutable existing SDD', () => { + const existing = { ...inventory, files: new Set([...inventory.files, 'specs/change.md']) }; + expect(() => validateSddDraftConsistency(sddConsistencyFixture('payments-change', 'specs/change.md', 'update'), owners, existing, 'specs/change.md')).not.toThrow(); + }); + + it.each(['omitted', 'reference', 'create'] as const)('rejects an existing SDD declared as %s', change => { + const existing = { ...inventory, files: new Set([...inventory.files, 'specs/change.md']) }; + const markdown = sddConsistencyFixture().replace('| specs/change.md | create |', change === 'omitted' ? '' : `| specs/change.md | ${change} |`); + expect(() => validateSddDraftConsistency(markdown, owners, existing, 'specs/change.md')).toThrow(expect.objectContaining({ reason: 'repository-path' })); + }); + + it('rejects a canonical table truncated before its separator', () => { + const markdown = `${sddConsistencyFixture().split('| Requirement | Cases |')[0]}| Requirement | Cases |`; + expect(() => validate(markdown)).toThrow(expect.objectContaining({ reason: 'requirement-mapping' })); + }); + + it('rejects an empty canonical traceability table', () => { + const markdown = sddConsistencyFixture().split('| Requirement | Cases |')[0] + '| Requirement | Cases |\n|---|---|\n'; + expect(() => validate(markdown)).toThrow(expect.objectContaining({ reason: 'requirement-mapping' })); + }); + + it('keeps shorter and mismatched fence markers inside the excluded example', () => { + expect(reservedSddCaseIds([{ path: 'specs/examples.md', markdown: '````md\n```\n~~~\nignored-C01\n````\nretained-C01' }])).toEqual(['retained-C01']); + }); + it.each([ ['primary collision', (s: string) => s.replace(/payments-change-C01/gu, 'payments-C01')], ['companion collision', (s: string) => s.replace(/payments-change-C01/gu, 'payments-session-C09')], @@ -87,7 +118,7 @@ describe('generated SDD consistency', () => { it('allows intentional new files, relative file links, language-neutral paths, directories and external links', () => { const markdown = sddConsistencyFixture().replace('| docs/payments.mdx | update |', '| docs/new_guide.md | create |\n| backend/app.kt | reference |') - + '\nSee `docs/new_guide.md`, `docs/my_guide.mdx`, `backend/`, [existing](../docs/payments.mdx#intro), [same](#scope), [proposed](../docs/new_guide.md), [provider](https://example.test/path/file.md) and the retained payments-C01.'; + + '\nSee `docs/new_guide.md`, `docs/my_guide.mdx`, `backend/`, `plain-text`, `/api/session`, `node command /path`, [self](./change.md), [existing](../docs/payments.mdx#intro), [same](#scope), [proposed](../docs/new_guide.md), [provider](https://example.test/path/file.md) and the retained payments-C01.'; expect(() => validate(markdown)).not.toThrow(); }); diff --git a/src/domain/sdd_draft_consistency.ts b/src/domain/sdd_draft_consistency.ts index 5e40da676..1f7a7f452 100644 --- a/src/domain/sdd_draft_consistency.ts +++ b/src/domain/sdd_draft_consistency.ts @@ -15,7 +15,7 @@ In section 14 include one budget table: Area | Minimum distinct cases | Behavior Also in section 14 include one inventory table: Case ID | Area | Requirements | Scenario. Each row is one distinct executable case with a fully qualified ID such as capability-change-C01, one exact budget area, comma-separated requirement IDs and an observable scenario. Never use shorthand C01, ranges or redefine an ID found in any owning SDD. Reference retained baseline cases in prose using their full IDs; count only the new inventory. In section 17 include one table: Requirement | Cases. List comma-separated full case IDs for every requirement. Its links must exactly match the case inventory in both directions, with no undefined or generic documentation checks. -In section 15 include one table: Path | Change. Change is update, reference or create. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; +In section 15 include one table: Path | Change. Change is update, reference or create. Include the selected SDD path itself with update for an existing SDD or create for a new/companion SDD. Use existing immutable-base files for update/reference. Only explicitly proposed new files may use create; never mark a missing current document as an existing artifact. Cite repository files as full repository-relative paths in backticks, or relative Markdown file links. Put every planned new file in this table. These tables are publication consistency checks, not runtime/security acceptance.`; function fail(reason: SddConsistencyReason): never { // Closed diagnostics: rejected Markdown and repository text never become repair instructions. @@ -113,7 +113,7 @@ export function validateSddDraftConsistency( if (new Set(caseIds).size !== caseIds.length || caseIds.some(id => !CASE_ID.test(id) || reserved.has(id)) || /(? !caseIds.includes(id) && !reserved.has(id))) fail('case-identity'); + if ([...source.matchAll(CASE)].some(([id]) => !caseIds.includes(id) && !reserved.has(id))) fail('case-identity'); const budget = table(source, 14, ['Area', 'Minimum distinct cases', 'Behaviors/risks covered'], 'case-budget'); const counts = new Map(); @@ -155,7 +155,9 @@ function safePath(value: string): boolean { function validatePaths(source: string, inventory: SddRepositoryInventory, sddPath: string): void { const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); - const proposed = new Set([sddPath]); + const expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; + if (!rows.some(([file, change]) => file === sddPath && change === expectedChange)) fail('repository-path'); + const proposed = new Set(); const seen = new Set(); for (const [file, change] of rows) { if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) fail('repository-path'); 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 65699f216..a0cf5b1e8 100644 --- a/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts +++ b/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts @@ -59,7 +59,9 @@ function fixture() { return { temp, remote, repo }; } -function draft(): string { return sddConsistencyFixture(); } +function draft(sddPath = plan.path, action: SddPlan['action'] = 'update'): string { + return sddConsistencyFixture('payments-change', sddPath, action === 'update' ? 'update' : 'create'); +} function legacyDraft(): string { return `# Payment behavior\n\n- Status: Draft\n- Date: 2026-09-17\n- Catalog capability ID: payments\n- Owners: Maintainers\n\n## 1. Executive summary\n${'The payment behavior has a verified product contract. '.repeat(20)}\n\n## 4. Goals\n${'The new behavior preserves authorization and traceability. '.repeat(12)}\n\n## 8. Clean Architecture\n${'The domain policy is separate from application orchestration and adapters. '.repeat(12)}\n\n## 14. Testing strategy\nAt least 20 distinct cases cover success, replay, authorization, and failure.\n\n## 16. Acceptance scenarios\n${'The issue owner can verify the resulting payment contract. '.repeat(12)}\n`; @@ -72,6 +74,44 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { beforeEach(() => { ({ temp, repo } = fixture()); }); afterEach(() => fs.rmSync(temp, { recursive: true, force: true })); + it.each(['update', 'companion'] as const)('requires the selected %s SDD in its own path inventory before publication', async action => { + const workspace = new PreBranchSddWorkspaceAdapter(repo); + const snapshot = await workspace.loadSnapshot('develop'); + const selected = { ...plan, action, path: action === 'update' ? plan.path : 'specs/companion.md' }; + const markdown = draft(selected.path, action).replace(`| ${selected.path} | ${action === 'update' ? 'update' : 'create'} |`, ''); + await expect(workspace.validateDraft(snapshot, selected, markdown)).rejects.toMatchObject({ reason: 'repository-path' }); + expect(git(repo, 'ls-remote', '--heads', 'origin', 'feature/42-change')).toBe(''); + }); + + it('rejects an unsafe owning SDD path before reading external owner evidence', async () => { + const workspace = new PreBranchSddWorkspaceAdapter(repo); + const snapshot = await workspace.loadSnapshot('develop'); + const invalid = { ...snapshot, capabilities: [{ ...snapshot.capabilities[0], specs: ['../outside.md'] }] }; + await expect(workspace.validateDraft(invalid, plan, draft())).rejects.toThrow('An owning SDD path is unsafe'); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + }); + + it('does not recover an Action-looking commit that replaced the immutable owner', async () => { + const workspace = new PreBranchSddWorkspaceAdapter(repo); + const snapshot = await workspace.loadSnapshot('develop'); + const prepared = await workspace.validateDraft(snapshot, plan, draft()); + fs.writeFileSync(path.join(repo, plan.path), draft()); + git(repo, 'add', plan.path); + git(repo, '-c', 'user.email=41898282+github-actions[bot]@users.noreply.github.com', 'commit', '-m', `docs(sdd): specify issue contract in ${plan.path}`); + git(repo, 'push', 'origin', 'HEAD:refs/heads/feature/42-change'); + expect(await workspace.recoverPublished('feature/42-change', prepared)).toBeUndefined(); + }); + + it('does not recover publication when revalidation returns different document bytes', async () => { + const workspace = new PreBranchSddWorkspaceAdapter(repo); + const snapshot = await workspace.loadSnapshot('develop'); + const prepared = await workspace.validateDraft(snapshot, plan, draft()); + git(repo, 'push', 'origin', 'develop:refs/heads/feature/42-change'); + await workspace.publish('feature/42-change', prepared); + jest.spyOn(workspace, 'validateDraft').mockResolvedValue({ ...prepared, markdown: '# Altered validation result' }); + expect(await workspace.recoverPublished('feature/42-change', prepared)).toBeUndefined(); + }); + it.each([ ['case-identity', (s: string) => s.replace(/payments-change-C01/gu, 'payments-baseline-C01')], ['case-budget', (s: string) => s.replace('| Policy | 2 |', '| Policy | 1 |').replace('| Adapter | 1 |', '| Adapter | 2 |')], @@ -86,7 +126,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { const snapshot = await workspace.loadSnapshot('develop'); fs.writeFileSync(path.join(repo, 'docs/current-checkout-only.md'), '# Uncommitted file cannot prove immutable existence.'); const before = fs.readFileSync(path.join(repo, plan.path), 'utf8'); - await expect(workspace.validateDraft(snapshot, { ...plan, action: 'companion', path: 'specs/companion.md' }, mutate(draft()))) + await expect(workspace.validateDraft(snapshot, { ...plan, action: 'companion', path: 'specs/companion.md' }, mutate(draft('specs/companion.md', 'companion')))) .rejects.toMatchObject({ reason }); expect(fs.readFileSync(path.join(repo, plan.path), 'utf8')).toBe(before); expect(git(repo, 'ls-remote', '--heads', 'origin', 'feature/42-change')).toBe(''); @@ -101,7 +141,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop'); const selected = { ...plan, action, path: action === 'update' ? plan.path : 'specs/portable-companion.md' }; - const prepared = await workspace.validateDraft(snapshot, selected, draft()); + const prepared = await workspace.validateDraft(snapshot, selected, draft(selected.path, selected.action)); const expected = action === 'update' ? [plan.path] : ['specs/CATALOG.md', 'specs/catalog.json', selected.path]; expect(prepared.changedPaths).toEqual(expected); git(repo, 'push', 'origin', 'develop:refs/heads/feature/42-change'); @@ -273,7 +313,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop'); const companion: SddPlan = { ...plan, action: 'companion', path: 'specs/payments-extension.md' }; - const prepared = await workspace.validateDraft(snapshot, companion, draft()); + const prepared = await workspace.validateDraft(snapshot, companion, draft(companion.path, companion.action)); expect(prepared.changedPaths).toEqual(['specs/CATALOG.md', 'specs/catalog.json', companion.path]); expect(JSON.parse(prepared.catalogJson!).capabilities[0].specs).toEqual([plan.path, companion.path]); git(repo, 'push', 'origin', 'develop:refs/heads/feature/42-change'); @@ -300,7 +340,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { } }); try { - await expect(workspace.validateDraft(snapshot, companion, draft())).rejects.toThrow('already occupied'); + await expect(workspace.validateDraft(snapshot, companion, draft(companion.path, companion.action))).rejects.toThrow('already occupied'); } finally { link.mockRestore(); } @@ -319,7 +359,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { ...snapshot.capabilities[0], id: 'invoicing', title: 'Invoicing', status: 'proposed' as const, scope: 'Invoice behavior', specs: [proposal.path], }; - const prepared = await workspace.validateDraft(snapshot, proposal, draft(), capability); + const prepared = await workspace.validateDraft(snapshot, proposal, draft(proposal.path, proposal.action), capability); expect(prepared.changedPaths).toEqual(['specs/CATALOG.md', 'specs/catalog.json', proposal.path]); git(repo, 'push', 'origin', 'develop:refs/heads/feature/42-change'); @@ -332,7 +372,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop'); const proposal: SddPlan = { ...plan, action: 'new', path: 'specs/invoicing.md', capabilityId: 'invoicing' }; - await expect(workspace.validateDraft(snapshot, proposal, draft())).rejects.toThrow('proposed catalog capability'); + await expect(workspace.validateDraft(snapshot, proposal, draft(proposal.path, proposal.action))).rejects.toThrow('proposed catalog capability'); expect(git(repo, 'branch', '--list', 'feature/42-change')).toBe(''); }); @@ -359,7 +399,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop'); const companion: SddPlan = { ...plan, action: 'companion', path: 'specs/payments-extension.md' }; - await expect(workspace.validateDraft(snapshot, companion, draft())).rejects.toThrow('not a regular file'); + await expect(workspace.validateDraft(snapshot, companion, draft(companion.path, companion.action))).rejects.toThrow('not a regular file'); expect(fs.readFileSync(external, 'utf8')).toBe('# Catalog\n'); }); diff --git a/src/testing/sdd_consistency_fixture.ts b/src/testing/sdd_consistency_fixture.ts index 3396e96d3..cb1450b43 100644 --- a/src/testing/sdd_consistency_fixture.ts +++ b/src/testing/sdd_consistency_fixture.ts @@ -1,5 +1,5 @@ /** Visible canonical tables shared by pure consistency and real Git publication fixtures. */ -export function sddConsistencyFixture(prefix = 'payments-change'): string { +export function sddConsistencyFixture(prefix = 'payments-change', sddPath = 'specs/change.md', change: 'create' | 'update' = 'create'): string { return `# Payment behavior - Status: Draft @@ -34,6 +34,7 @@ The domain owns pure decisions; the workspace adapter reads immutable Git object | Path | Change | |---|---| | docs/payments.mdx | update | +| ${sddPath} | ${change} | ## 16. Acceptance scenarios The three cases above are observable product outcomes; runtime and security review remain separate acceptance gates. From 902fff8510348133013d23a6682c0400f9f05a4c Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 21 Oct 2026 20:00:53 +0200 Subject: [PATCH 4/6] bugfix-653-bug-generated-companion-sdd-publishes-colliding-case-ids-and-inconsistent-acceptance-mappings: allow hosted Windows full verification to finish --- .github/workflows/setup_platform_smoke.yml | 2 +- docs/development/testing.mdx | 5 +++++ specs/agent-runtime-provider-and-model-routing.md | 13 +++++++++++++ specs/local-web-setup-assistant.md | 15 +++++++++++++++ 4 files changed, 34 insertions(+), 1 deletion(-) diff --git a/.github/workflows/setup_platform_smoke.yml b/.github/workflows/setup_platform_smoke.yml index 81a8fec6c..f6bb0cadd 100644 --- a/.github/workflows/setup_platform_smoke.yml +++ b/.github/workflows/setup_platform_smoke.yml @@ -41,7 +41,7 @@ jobs: if: ${{ github.event_name == 'pull_request' || (github.event_name == 'workflow_dispatch' && (inputs.windows_startup_probe != true || inputs.upload_windows_coverage == true)) }} name: Setup package and local session (${{ matrix.os }}) runs-on: ${{ matrix.os }} - timeout-minutes: 20 + timeout-minutes: ${{ matrix.os == 'windows-latest' && 30 || 20 }} strategy: fail-fast: false matrix: diff --git a/docs/development/testing.mdx b/docs/development/testing.mdx index 01a16d9f1..2c05ee01a 100644 --- a/docs/development/testing.mdx +++ b/docs/development/testing.mdx @@ -264,6 +264,11 @@ budgets and contract validators. Native macOS self-hosted acceptance remains pending until the operator restores the Apple Intel runners and macOS row; hosted macOS results cannot substitute for that evidence. +Hosted Windows has a 30-minute job limit; hosted Ubuntu and macOS retain +20 minutes. This includes native setup, package checks, isolated fixtures and +the full coverage suite. A timeout is missing evidence and must be corrected +or rerun before merging; it never substitutes for successful verification. + `upload_windows_coverage` defaults to `false`. Coverage export is a separate explicit opt-in, never a prerequisite for hosted fixture validation. Set `windows_startup_probe=true` with coverage export disabled for the isolated diff --git a/specs/agent-runtime-provider-and-model-routing.md b/specs/agent-runtime-provider-and-model-routing.md index 231037824..b4b3dcda5 100644 --- a/specs/agent-runtime-provider-and-model-routing.md +++ b/specs/agent-runtime-provider-and-model-routing.md @@ -1311,3 +1311,16 @@ in `docs/development/testing.mdx` MUST explain defaults, isolated probe, publication opt-in, runner recovery and partial evidence. Run full coverage, `validate:workflows`, `validate:documentation`, `validate:specifications` and the native platform smoke before declaring complete cross-platform acceptance. + +### Hosted Windows job headroom (2026-10-21, PR #654) + +The hosted platform fixture job uses a fixed 30-minute Windows limit and +20 minutes for Ubuntu/macOS. Evidence: run 39644175943 completed Windows in +19m34s; run 39645186744 reached the former 20-minute job bound during coverage +without an assertion failure. This increases only orchestration headroom: +agent process, PowerShell, ACL, credential, worker-concurrency and coverage +requirements remain unchanged. A fresh exact-head Windows pass is mandatory; +other platforms and a canceled run cannot substitute. Existing workflow and +platform checks remain the acceptance artifacts; this configuration amendment +adds no agent capability, user input or distinct application test case. See +docs/development/testing.mdx for contributor operation. diff --git a/specs/local-web-setup-assistant.md b/specs/local-web-setup-assistant.md index 4b04c5fa2..a86cf8391 100644 --- a/specs/local-web-setup-assistant.md +++ b/specs/local-web-setup-assistant.md @@ -2948,3 +2948,18 @@ help and retained question, and public Git-ref documentation renders correctly. That inspection is agent evidence, not a substitute for the outstanding human accessibility or cross-platform acceptance reviews. No open product decision blocks this bounded amendment. Related work: issues #617 and PR #618. + +### Hosted Windows verification budget (2026-10-21, PR #654) + +The synchronized pre-branch SDD fix reached the hosted Windows job's 20-minute +limit without a failing assertion (run 39645186744). The preceding exact-base +Windows job took 19 minutes 34 seconds (run 39644175943). The hosted Windows +job MUST have a fixed 30-minute limit to complete native setup, isolated +fixtures, package checks and full coverage; hosted Ubuntu/macOS retain 20. +The existing complete suite, two-worker cap, subprocess deadlines, coverage and +acceptance budgets, documentation gates and deferred cleanup remain mandatory. +A canceled or timed-out run is missing evidence, never a pass. Acceptance is a +successful fresh Windows run on the reviewed head, alongside the other platform +jobs. Existing parsed workflow-contract checks cover this configuration change; +no new application behavior or test count is introduced. Contributor guidance +is in docs/development/testing.mdx; no consumer setting or migration is added. From a8f8dbba6d9956063c3fbdd0d42d24be8d2eb639 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Wed, 21 Oct 2026 20:16:41 +0200 Subject: [PATCH 5/6] bugfix-653-bug-generated-companion-sdd-publishes-colliding-case-ids-and-inconsistent-acceptance-mappings: preserve fenced SDD content until a valid closing marker --- build/cli/index.js | 29 +++++++++++---- build/github_action/index.js | 29 +++++++++++---- docs/issues/pre-branch-sdds.mdx | 6 ++++ specs/pre-branch-sdd-gate.md | 25 ++++++++++--- .../__tests__/sdd_draft_consistency.test.ts | 36 +++++++++++++++++++ src/domain/sdd_draft_consistency.ts | 23 +++++++++--- .../pre_branch_sdd_workspace_adapter.test.ts | 12 +++++++ 7 files changed, 138 insertions(+), 22 deletions(-) diff --git a/build/cli/index.js b/build/cli/index.js index 5ee4d1497..eefac3f27 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -93219,16 +93219,33 @@ function fail(reason) { /** Excludes examples and hidden records from the contract. */ function visible(source) { let fence; - return source.replace(/|$)/gu, '\n').replace(/\r\n/gu, '\n').split('\n').map(line => { + let comment = false; + return source.replace(/\r\n/gu, '\n').split('\n').map(line => { const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); - if (boundary) { - if (!fence) - fence = boundary[1]; - else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) + if (fence) { + // Comment syntax inside code is literal; only the original line can close it. + if (boundary && boundary[1][0] === fence[0] && boundary[1].length >= fence.length + && /^ {0,3}(?:`+|~+)[ \t]*$/u.test(line)) fence = undefined; return ''; } - return fence ? '' : line; + if (!comment && boundary) { + fence = boundary[1]; + return ''; + } + let result = ''; + let offset = 0; + while (offset < line.length) { + const marker = comment ? '-->' : '|$)/gu, '\n').replace(/\r\n/gu, '\n').split('\n').map(line => { + let comment = false; + return source.replace(/\r\n/gu, '\n').split('\n').map(line => { const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); - if (boundary) { - if (!fence) - fence = boundary[1]; - else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) + if (fence) { + // Comment syntax inside code is literal; only the original line can close it. + if (boundary && boundary[1][0] === fence[0] && boundary[1].length >= fence.length + && /^ {0,3}(?:`+|~+)[ \t]*$/u.test(line)) fence = undefined; return ''; } - return fence ? '' : line; + if (!comment && boundary) { + fence = boundary[1]; + return ''; + } + let result = ''; + let offset = 0; + while (offset < line.length) { + const marker = comment ? '-->' : '\n${sddConsistencyFixture()}`; + expect(() => validate(markdown)).toThrow(expect.objectContaining({ reason: 'requirement-mapping' })); + }); + + it('keeps comment syntax literal inside a fence so it cannot hide the visible contract after the close', () => { + expect(() => validate('```md\n\n' + sddConsistencyFixture())).not.toThrow(); + }); + + it('keeps owner identities around multiple inline comments while excluding their hidden identities', () => { + const markdown = 'first-C01middle-C01final-C01'; + expect(reservedSddCaseIds([{ path: 'specs/examples.md', markdown }])).toEqual(['final-C01', 'first-C01', 'middle-C01']); + }); + + it('does not let an unclosed HTML comment expose its contract', () => { + expect(() => validate(`|$)/gu, '\n').replace(/\r\n/gu, '\n').split('\n').map(line => { + let comment = false; + return source.replace(/\r\n/gu, '\n').split('\n').map(line => { const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); - if (boundary) { - if (!fence) fence = boundary[1]; - else if (boundary[1][0] === fence[0] && boundary[1].length >= fence.length) fence = undefined; + if (fence) { + // Comment syntax inside code is literal; only the original line can close it. + if (boundary && boundary[1][0] === fence[0] && boundary[1].length >= fence.length + && /^ {0,3}(?:`+|~+)[ \t]*$/u.test(line)) fence = undefined; return ''; } - return fence ? '' : line; + if (!comment && boundary) { fence = boundary[1]; return ''; } + let result = ''; + let offset = 0; + while (offset < line.length) { + const marker = comment ? '-->' : '\n```md\nexample-C01\n```' }])) .toEqual(['payments-C01', 'payments-session-C09']); diff --git a/src/domain/sdd_draft_consistency.ts b/src/domain/sdd_draft_consistency.ts index ba2d21b64..e92ab6fd8 100644 --- a/src/domain/sdd_draft_consistency.ts +++ b/src/domain/sdd_draft_consistency.ts @@ -187,7 +187,7 @@ function validatePaths(source: string, inventory: SddRepositoryInventory, sddPat }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { - const href = match[1].split('#')[0]; + const href = match[1].split(/[?#]/u)[0]; if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; let decoded: string; try { decoded = decodeURIComponent(href); } catch { return fail('repository-path'); } 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 535c4bede..d33439208 100644 --- a/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts +++ b/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts @@ -95,6 +95,17 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); }); + it.each(['update', 'companion'] as const)('validates %s links with queries against immutable files and explicitly proposed SDDs', async action => { + const workspace = new PreBranchSddWorkspaceAdapter(repo); + const snapshot = await workspace.loadSnapshot('develop'); + const selected = { ...plan, action, path: action === 'update' ? plan.path : 'specs/companion.md' }; + const markdown = draft(selected.path, action) + `\n[Guide](../docs/payments.mdx?view=compact#intro) and [SDD](./${path.basename(selected.path)}?draft=1)`; + const prepared = await workspace.validateDraft(snapshot, selected, markdown); + expect(prepared.markdown).toContain('[Guide](../docs/payments.mdx?view=compact#intro)'); + expect(git(repo, 'status', '--porcelain')).toBe(''); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + }); + it('rejects an unsafe owning SDD path before reading external owner evidence', async () => { const workspace = new PreBranchSddWorkspaceAdapter(repo); const snapshot = await workspace.loadSnapshot('develop');