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/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 c4b0d3a60..79d811460 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -39391,6 +39391,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.', @@ -39483,6 +39503,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.', @@ -60807,6 +60847,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; @@ -60818,13 +60859,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 }, }); } @@ -60858,12 +60899,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'; + } +} /***/ }), @@ -60884,6 +60934,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: { @@ -61038,9 +61089,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) @@ -78616,6 +78677,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', @@ -78830,6 +78895,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) { @@ -92189,6 +92258,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; @@ -92492,8 +92562,24 @@ 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; + // 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) { 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) @@ -92502,11 +92588,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. @@ -93103,6 +93189,252 @@ 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. 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.`); +} +/** Excludes examples and hidden records from the contract. */ +function visible(source) { + let fence; + let comment = false; + return source.replace(/\r\n/gu, '\n').split('\n').map(line => { + const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); + 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 ''; + } + 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(`\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/`, `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(); + }); + + it.each([ + '../docs/payments.mdx?view=compact', '../docs/payments.mdx?view=compact#intro', './change.md?draft=1', '?view=compact', + ])('resolves the repository pathname of relative destination %s without its query or fragment', href => { + expect(() => validate(sddConsistencyFixture() + `\n[Guide](${href})`)).not.toThrow(); + }); + + it.each([ + '../docs/missing.mdx?view=compact', '../docs/%broken.mdx?view=compact', '../docs/payments.mdx%3Fview=compact', + ])('does not let a query or encoded query delimiter hide invalid repository evidence in %s', href => { + expect(() => validate(sddConsistencyFixture() + `\n[Guide](${href})`)).toThrow(expect.objectContaining({ reason: 'repository-path' })); + }); + + 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..20222d32e 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,35 @@ 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; + // 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; +} + +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..e92ab6fd8 --- /dev/null +++ b/src/domain/sdd_draft_consistency.ts @@ -0,0 +1,207 @@ +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. 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. + 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; + let comment = false; + return source.replace(/\r\n/gu, '\n').split('\n').map(line => { + const boundary = /^\s*(`{3,}|~{3,})/u.exec(line); + 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 ''; + } + if (!comment && boundary) { fence = boundary[1]; return ''; } + let result = ''; + let offset = 0; + while (offset < line.length) { + const marker = comment ? '-->' : '