From 552a790d92b08a9ee65c85b6b28bb2ca2da854e1 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 08:54:29 +0100 Subject: [PATCH 1/3] bugfix-835-sdd-path-validation-mistakes-protocol-notation-for-missing-repository-files: classify protocol notation before checking file references --- build/cli/index.js | 170 ++++++++-- build/github_action/index.js | 170 ++++++++-- docs/issues/pre-branch-sdds.mdx | 28 ++ specs/CATALOG.md | 10 +- specs/catalog.json | 13 +- specs/pre-branch-sdd-gate.md | 5 + specs/sdd-reference-classification.md | 316 ++++++++++++++++++ .../sdd_preparation_status_policy.test.ts | 24 ++ .../sdd_preparation_message_catalog.ts | 8 + .../policies/sdd_preparation_status_policy.ts | 4 +- .../sdd_repository_reference.test.ts | 222 ++++++++++++ src/domain/sdd_draft_consistency.ts | 61 +++- src/domain/sdd_repository_reference.ts | 91 +++++ src/domain/sdd_validation_diagnostic.ts | 2 + .../pre_branch_sdd_workspace_adapter.test.ts | 51 +++ src/testing/sdd_reference_fixture.ts | 24 ++ 16 files changed, 1131 insertions(+), 68 deletions(-) create mode 100644 specs/sdd-reference-classification.md create mode 100644 src/domain/__tests__/sdd_repository_reference.test.ts create mode 100644 src/domain/sdd_repository_reference.ts create mode 100644 src/testing/sdd_reference_fixture.ts diff --git a/build/cli/index.js b/build/cli/index.js index e083bed81..cb9e8a0ec 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -49802,6 +49802,7 @@ const UI = Object.freeze({ "attempts": ["Draft attempts", "Intentos de borrador"], "section": ["Section", "Sección"], "row": ["Data row", "Fila de datos"], + referenceOrdinal: ['Visible reference occurrence', 'Aparición de referencia visible'], "expected": ["Expected count", "Cantidad esperada"], "observed": ["Observed count", "Cantidad observada"], "recoveredHeading": ["SDD preparation status", "Estado de preparación del SDD"], @@ -49846,6 +49847,13 @@ const FACTS = Object.freeze({ "path-operation": ["The path operation is unsupported or targets a missing existing file.", "La operación de ruta no es compatible o apunta a un archivo existente que falta."], "path-reference": ["A repository reference cannot be verified as an existing or explicitly proposed path.", "No se puede verificar una referencia como ruta existente o explícitamente propuesta."], "path-link": ["A relative file link is invalid or escapes its repository boundary.", "Un enlace relativo no es válido o sale de los límites del repositorio."], + 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans in document order, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles en orden, excluyendo ejemplos delimitados y comentarios ocultos.'], + 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references, excluding links and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles con forma de archivo, excluyendo enlaces y fragmentos ya clasificados.'], + 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts visible Markdown destinations in document order.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos visibles en orden.'], + 'path-reference-unsafe': ['The reference violates the existing normalized repository-path spelling rules.', 'La referencia incumple las reglas existentes de escritura normalizada de rutas del repositorio.'], + 'path-reference-blocked': ['The immutable inventory blocks this reference or one of its ancestors; symbolic links and submodules cannot prove regular repository content.', 'El inventario inmutable bloquea la referencia o uno de sus antecesores; los enlaces simbólicos y submódulos no demuestran contenido regular del repositorio.'], + 'path-reference-missing': ['The reference is absent from the immutable inventory and exact proposed-file declarations. Cite a real artifact or declare the intended new file in Path / Change.', 'La referencia no está en el inventario inmutable ni en las declaraciones exactas de archivos propuestos. Cita un artefacto real o declara el nuevo archivo previsto en Path / Change.'], + 'path-reference-directory': ['A directory reference requires an existing directory or a safe ancestor of an explicitly proposed new file; a regular file does not establish a directory.', 'Una referencia de directorio requiere un directorio existente o un antecesor seguro de un archivo nuevo declarado; un archivo regular no demuestra un directorio.'], }); exports.SDD_PREPARATION_MESSAGE_IDS = Object.freeze([ ...Object.keys(UI).map(key => `sddPreparation.${key}`), @@ -49960,7 +49968,9 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess lines.push('', `- ${message(`sddPreparation.fact.${fact.detail}`)}`); for (const key of ['section', 'row', 'expected', 'observed']) { if (fact[key] !== undefined && (key !== 'section' || fact.section !== 0)) { - lines.push(` - ${message(`sddPreparation.${key}`)}: ${fact[key]}`); + const label = key === 'row' && ['path-inline-reference', 'path-prose-reference', 'path-link-reference'].includes(fact.detail) + ? 'referenceOrdinal' : key; + lines.push(` - ${message(`sddPreparation.${label}`)}: ${fact[key]}`); } } } @@ -100772,6 +100782,7 @@ exports.renderSddDerivedTables = renderSddDerivedTables; exports.reservedSddCaseIds = reservedSddCaseIds; exports.validateSddDraftConsistency = validateSddDraftConsistency; const pre_branch_sdd_1 = __nccwpck_require__(34730); +const sdd_repository_reference_1 = __nccwpck_require__(17791); 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; @@ -100782,7 +100793,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. 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.`; +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 with a File or Path label when ambiguous, or relative Markdown file links. Put every planned new file in this table; a proposed parent directory is valid only as an ancestor of a declared new file, never as a file declaration. HTTP endpoints, media types, mode alternatives and module names are protocol notation, not file-existence claims. Label file-shaped relative asset addresses with URL or URI; do not relabel a repository citation to evade validation. Explicit Path/Change rows and relative Markdown destinations always receive strict repository checks. These tables are publication consistency checks, not runtime/security acceptance.`; const BUDGET_SLOT = '{{SDD_CASE_BUDGET}}'; const TRACEABILITY_SLOT = '{{SDD_REQUIREMENT_CASES}}'; /** The provider writes definitions once; trusted code renders their two redundant projections. */ @@ -101013,10 +101024,6 @@ function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { fail('requirement-mapping', { detail: 'requirement-coverage', section: 17, expected: edges.size, observed: seen.size }); 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 expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; @@ -101025,7 +101032,7 @@ function validatePaths(source, inventory, sddPath) { const proposed = new Set(); const seen = new Set(); for (const [file, change] of rows) { - if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) + if (!(0, sdd_repository_reference_1.isSafeSddRepositoryPath)(file) || seen.has(file) || inventory.blocked.has(file)) fail('repository-path', { detail: 'path-identity', section: 15, row: seen.size + 1 }); seen.add(file); if (change === 'create') { @@ -101041,12 +101048,23 @@ function validatePaths(source, inventory, sddPath) { else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) fail('repository-path', { detail: 'path-operation', section: 15, row: seen.size }); } - const check = (file) => { - if (!safePath(file) || (!inventory.files.has(file) && !inventory.directories.has(file) && !proposed.has(file))) - fail('repository-path', { detail: 'path-reference', section: 0 }); + const referenceIndex = (0, sdd_repository_reference_1.buildSddReferenceIndex)(inventory, proposed); + const reject = (origin, ordinal, detail) => fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: origin, section: 0, row: ordinal }, { detail, section: 0 }]); + const check = (file, origin, ordinal, directory = false) => { + if (!(0, sdd_repository_reference_1.isSafeSddRepositoryPath)(file)) + reject(origin, ordinal, 'path-reference-unsafe'); + if ((0, sdd_repository_reference_1.hasBlockedSddAncestor)(file, referenceIndex)) + reject(origin, ordinal, 'path-reference-blocked'); + const isDirectory = inventory.directories.has(file) || referenceIndex.futureDirectories.has(file); + if (directory && !isDirectory) + reject(origin, ordinal, 'path-reference-directory'); + if (!inventory.files.has(file) && !isDirectory && !proposed.has(file)) + reject(origin, ordinal, 'path-reference-missing'); }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. + let linkOrdinal = 0; for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + linkOrdinal++; const href = match[1].split(/[?#]/u)[0]; if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; @@ -101055,28 +101073,43 @@ function validatePaths(source, inventory, sddPath) { decoded = decodeURIComponent(href); } catch { - return fail('repository-path', { detail: 'path-link', section: 0 }); + return fail('repository-path', { detail: 'path-link', section: 0 }, [{ detail: 'path-link-reference', section: 0, row: linkOrdinal }]); } + const directory = decoded.endsWith('/'); + if (directory) + decoded = decoded.slice(0, -1); const parts = sddPath.split('/').slice(0, -1); for (const part of decoded.split('/')) { if (part === '..') { if (!parts.length) - fail('repository-path', { detail: 'path-link', section: 0 }); + fail('repository-path', { detail: 'path-link', section: 0 }, [{ detail: 'path-link-reference', section: 0, row: linkOrdinal }]); parts.pop(); } else if (part !== '.') parts.push(part); } - check(parts.join('/')); + check(parts.join('/'), 'path-link-reference', linkOrdinal, directory); } - 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, '')); + let inlineOrdinal = 0; + const prose = source.replace(/`([^`\n]+)`/gu, (span, value, offset) => { + inlineOrdinal++; + if (!value.includes('/')) + return span; + if ((0, sdd_repository_reference_1.isSddNonFileNotation)(value, (0, sdd_repository_reference_1.sddReferencePrefix)(source, offset), referenceIndex)) + return ' '.repeat(span.length); + if (!/\s/u.test(value)) { + check(value.replace(/\/$/u, ''), 'path-inline-reference', inlineOrdinal, value.endsWith('/')); + return ' '.repeat(span.length); + } + return span; + }).replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + let proseOrdinal = 0; + for (const match of prose.matchAll(/(?]+/giu, ''); - for (const match of prose.matchAll(/(? { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.isSafeSddRepositoryPath = isSafeSddRepositoryPath; +exports.buildSddReferenceIndex = buildSddReferenceIndex; +exports.hasBlockedSddAncestor = hasBlockedSddAncestor; +exports.isSddNonFileNotation = isSddNonFileNotation; +exports.sddReferencePrefix = sddReferencePrefix; +/** Keep the existing repository spelling contract; protocol recognition never rewrites it. */ +function isSafeSddRepositoryPath(value) { + return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) + && value.split('/').every(segment => segment && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git'); +} +/** Call only after strict Path/Change validation; directories are constructive ancestry, not declarations. */ +function buildSddReferenceIndex(inventory, proposed) { + const roots = new Set(); + for (const paths of [inventory.files, inventory.directories, inventory.blocked]) { + for (const path of paths) + roots.add(path.split('/')[0]); + } + const futureDirectories = new Set(); + for (const file of proposed) { + for (let end = file.lastIndexOf('/'); end > 0; end = file.lastIndexOf('/', end - 1)) { + futureDirectories.add(file.slice(0, end)); + } + } + return { inventory, proposed, roots, futureDirectories }; +} +function hasBlockedSddAncestor(value, index) { + // Check immutable blocked entries rather than repeatedly joining an untrusted + // value's prefixes: a long slash-bearing span must not cause quadratic work. + for (const blocked of index.inventory.blocked) { + if (value === blocked || value.startsWith(`${blocked}/`)) + return true; + } + return false; +} +// IANA Top-Level Media Types, reviewed 2026-11-03; no runtime registry/network access. +// https://www.iana.org/assignments/top-level-media-types +const MEDIA_TYPES = new Set([ + 'application', 'audio', 'example', 'font', 'haptics', 'image', 'message', 'model', 'multipart', 'text', 'video', +]); +const MEDIA_NAME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/iu; +function mediaNotation(value) { + const essence = value.split(';', 1)[0]; + const [type, subtype, extra] = essence.split('/'); + return extra === undefined && ((type === '*' && subtype === '*') + || (MEDIA_TYPES.has(type.toLowerCase()) && (subtype === '*' || MEDIA_NAME.test(subtype ?? '')))); +} +/** URL/URI labels describe a non-file role, including file-shaped emitted assets. */ +function urlRole(prefix) { + return /\b(?:(?:URL|URI)(?:[ \t]+path)?|HTTP(?:[ \t]+(?:endpoint|route))?)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); +} +/** + * Not a protocol validator or an existence grant. Explicit Markdown destinations + * and Path/Change rows bypass this heuristic and always receive strict checks. + */ +function isSddNonFileNotation(value, prefix, index) { + if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value)) + return true; + const path = value.replace(/\/$/u, ''); + if (index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) + || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index)) + return false; + if (!/\s|[\p{Cc}]/u.test(value) && urlRole(prefix)) + return true; + if (/\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, ''))) + return false; + if (mediaNotation(value)) + return true; + if (/^(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s+\S+$/u.test(value)) + return true; + if (/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9_][a-z0-9._-]*)*$/iu.test(value) + && isSafeSddRepositoryPath(value)) + return true; + // Extensionless unknown namespaces are not evidence of a repository citation. + // Known roots, file-shaped names and explicit directory spellings remain checked. + const parts = value.split('/'); + return parts.length > 1 && !index.roots.has(parts[0]) + && parts.every(part => /^[a-z0-9_][a-z0-9_-]*$/iu.test(part)); +} +function sddReferencePrefix(source, offset) { + let end = offset; + while (end > 0 && /[ \t*_: =]/u.test(source[end - 1])) + end--; + const tail = source.slice(Math.max(0, end - 16), end); + return tail.slice(tail.lastIndexOf('\n') + 1); +} + + /***/ }), /***/ 44763: @@ -101135,6 +101263,8 @@ exports.SDD_VALIDATION_DETAILS = Object.freeze([ 'requirement-reference', 'requirement-coverage', 'traceability-links', 'traceability-membership', 'traceability-requirement', 'owner-path', 'path-identity', 'create-existing-path', 'create-path-overlap', 'create-path-parent', 'path-operation', 'path-reference', 'path-link', + 'path-inline-reference', 'path-prose-reference', 'path-link-reference', + 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', ]); const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape']; const PHASES = ['response', 'assembly', 'validation']; diff --git a/build/github_action/index.js b/build/github_action/index.js index dbd2ad9b0..967f85a1b 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -51661,6 +51661,7 @@ const UI = Object.freeze({ "attempts": ["Draft attempts", "Intentos de borrador"], "section": ["Section", "Sección"], "row": ["Data row", "Fila de datos"], + referenceOrdinal: ['Visible reference occurrence', 'Aparición de referencia visible'], "expected": ["Expected count", "Cantidad esperada"], "observed": ["Observed count", "Cantidad observada"], "recoveredHeading": ["SDD preparation status", "Estado de preparación del SDD"], @@ -51705,6 +51706,13 @@ const FACTS = Object.freeze({ "path-operation": ["The path operation is unsupported or targets a missing existing file.", "La operación de ruta no es compatible o apunta a un archivo existente que falta."], "path-reference": ["A repository reference cannot be verified as an existing or explicitly proposed path.", "No se puede verificar una referencia como ruta existente o explícitamente propuesta."], "path-link": ["A relative file link is invalid or escapes its repository boundary.", "Un enlace relativo no es válido o sale de los límites del repositorio."], + 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans in document order, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles en orden, excluyendo ejemplos delimitados y comentarios ocultos.'], + 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references, excluding links and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles con forma de archivo, excluyendo enlaces y fragmentos ya clasificados.'], + 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts visible Markdown destinations in document order.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos visibles en orden.'], + 'path-reference-unsafe': ['The reference violates the existing normalized repository-path spelling rules.', 'La referencia incumple las reglas existentes de escritura normalizada de rutas del repositorio.'], + 'path-reference-blocked': ['The immutable inventory blocks this reference or one of its ancestors; symbolic links and submodules cannot prove regular repository content.', 'El inventario inmutable bloquea la referencia o uno de sus antecesores; los enlaces simbólicos y submódulos no demuestran contenido regular del repositorio.'], + 'path-reference-missing': ['The reference is absent from the immutable inventory and exact proposed-file declarations. Cite a real artifact or declare the intended new file in Path / Change.', 'La referencia no está en el inventario inmutable ni en las declaraciones exactas de archivos propuestos. Cita un artefacto real o declara el nuevo archivo previsto en Path / Change.'], + 'path-reference-directory': ['A directory reference requires an existing directory or a safe ancestor of an explicitly proposed new file; a regular file does not establish a directory.', 'Una referencia de directorio requiere un directorio existente o un antecesor seguro de un archivo nuevo declarado; un archivo regular no demuestra un directorio.'], }); exports.SDD_PREPARATION_MESSAGE_IDS = Object.freeze([ ...Object.keys(UI).map(key => `sddPreparation.${key}`), @@ -51819,7 +51827,9 @@ function renderSddPreparationFailure(options, catalog = (0, sdd_preparation_mess lines.push('', `- ${message(`sddPreparation.fact.${fact.detail}`)}`); for (const key of ['section', 'row', 'expected', 'observed']) { if (fact[key] !== undefined && (key !== 'section' || fact.section !== 0)) { - lines.push(` - ${message(`sddPreparation.${key}`)}: ${fact[key]}`); + const label = key === 'row' && ['path-inline-reference', 'path-prose-reference', 'path-link-reference'].includes(fact.detail) + ? 'referenceOrdinal' : key; + lines.push(` - ${message(`sddPreparation.${label}`)}: ${fact[key]}`); } } } @@ -91772,6 +91782,7 @@ exports.renderSddDerivedTables = renderSddDerivedTables; exports.reservedSddCaseIds = reservedSddCaseIds; exports.validateSddDraftConsistency = validateSddDraftConsistency; const pre_branch_sdd_1 = __nccwpck_require__(34730); +const sdd_repository_reference_1 = __nccwpck_require__(17791); 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; @@ -91782,7 +91793,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. 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.`; +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 with a File or Path label when ambiguous, or relative Markdown file links. Put every planned new file in this table; a proposed parent directory is valid only as an ancestor of a declared new file, never as a file declaration. HTTP endpoints, media types, mode alternatives and module names are protocol notation, not file-existence claims. Label file-shaped relative asset addresses with URL or URI; do not relabel a repository citation to evade validation. Explicit Path/Change rows and relative Markdown destinations always receive strict repository checks. These tables are publication consistency checks, not runtime/security acceptance.`; const BUDGET_SLOT = '{{SDD_CASE_BUDGET}}'; const TRACEABILITY_SLOT = '{{SDD_REQUIREMENT_CASES}}'; /** The provider writes definitions once; trusted code renders their two redundant projections. */ @@ -92013,10 +92024,6 @@ function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { fail('requirement-mapping', { detail: 'requirement-coverage', section: 17, expected: edges.size, observed: seen.size }); 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 expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; @@ -92025,7 +92032,7 @@ function validatePaths(source, inventory, sddPath) { const proposed = new Set(); const seen = new Set(); for (const [file, change] of rows) { - if (!safePath(file) || seen.has(file) || inventory.blocked.has(file)) + if (!(0, sdd_repository_reference_1.isSafeSddRepositoryPath)(file) || seen.has(file) || inventory.blocked.has(file)) fail('repository-path', { detail: 'path-identity', section: 15, row: seen.size + 1 }); seen.add(file); if (change === 'create') { @@ -92041,12 +92048,23 @@ function validatePaths(source, inventory, sddPath) { else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) fail('repository-path', { detail: 'path-operation', section: 15, row: seen.size }); } - const check = (file) => { - if (!safePath(file) || (!inventory.files.has(file) && !inventory.directories.has(file) && !proposed.has(file))) - fail('repository-path', { detail: 'path-reference', section: 0 }); + const referenceIndex = (0, sdd_repository_reference_1.buildSddReferenceIndex)(inventory, proposed); + const reject = (origin, ordinal, detail) => fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: origin, section: 0, row: ordinal }, { detail, section: 0 }]); + const check = (file, origin, ordinal, directory = false) => { + if (!(0, sdd_repository_reference_1.isSafeSddRepositoryPath)(file)) + reject(origin, ordinal, 'path-reference-unsafe'); + if ((0, sdd_repository_reference_1.hasBlockedSddAncestor)(file, referenceIndex)) + reject(origin, ordinal, 'path-reference-blocked'); + const isDirectory = inventory.directories.has(file) || referenceIndex.futureDirectories.has(file); + if (directory && !isDirectory) + reject(origin, ordinal, 'path-reference-directory'); + if (!inventory.files.has(file) && !isDirectory && !proposed.has(file)) + reject(origin, ordinal, 'path-reference-missing'); }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. + let linkOrdinal = 0; for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + linkOrdinal++; const href = match[1].split(/[?#]/u)[0]; if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; @@ -92055,28 +92073,43 @@ function validatePaths(source, inventory, sddPath) { decoded = decodeURIComponent(href); } catch { - return fail('repository-path', { detail: 'path-link', section: 0 }); + return fail('repository-path', { detail: 'path-link', section: 0 }, [{ detail: 'path-link-reference', section: 0, row: linkOrdinal }]); } + const directory = decoded.endsWith('/'); + if (directory) + decoded = decoded.slice(0, -1); const parts = sddPath.split('/').slice(0, -1); for (const part of decoded.split('/')) { if (part === '..') { if (!parts.length) - fail('repository-path', { detail: 'path-link', section: 0 }); + fail('repository-path', { detail: 'path-link', section: 0 }, [{ detail: 'path-link-reference', section: 0, row: linkOrdinal }]); parts.pop(); } else if (part !== '.') parts.push(part); } - check(parts.join('/')); + check(parts.join('/'), 'path-link-reference', linkOrdinal, directory); } - 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, '')); + let inlineOrdinal = 0; + const prose = source.replace(/`([^`\n]+)`/gu, (span, value, offset) => { + inlineOrdinal++; + if (!value.includes('/')) + return span; + if ((0, sdd_repository_reference_1.isSddNonFileNotation)(value, (0, sdd_repository_reference_1.sddReferencePrefix)(source, offset), referenceIndex)) + return ' '.repeat(span.length); + if (!/\s/u.test(value)) { + check(value.replace(/\/$/u, ''), 'path-inline-reference', inlineOrdinal, value.endsWith('/')); + return ' '.repeat(span.length); + } + return span; + }).replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + let proseOrdinal = 0; + for (const match of prose.matchAll(/(?]+/giu, ''); - for (const match of prose.matchAll(/(? { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.isSafeSddRepositoryPath = isSafeSddRepositoryPath; +exports.buildSddReferenceIndex = buildSddReferenceIndex; +exports.hasBlockedSddAncestor = hasBlockedSddAncestor; +exports.isSddNonFileNotation = isSddNonFileNotation; +exports.sddReferencePrefix = sddReferencePrefix; +/** Keep the existing repository spelling contract; protocol recognition never rewrites it. */ +function isSafeSddRepositoryPath(value) { + return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) + && value.split('/').every(segment => segment && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git'); +} +/** Call only after strict Path/Change validation; directories are constructive ancestry, not declarations. */ +function buildSddReferenceIndex(inventory, proposed) { + const roots = new Set(); + for (const paths of [inventory.files, inventory.directories, inventory.blocked]) { + for (const path of paths) + roots.add(path.split('/')[0]); + } + const futureDirectories = new Set(); + for (const file of proposed) { + for (let end = file.lastIndexOf('/'); end > 0; end = file.lastIndexOf('/', end - 1)) { + futureDirectories.add(file.slice(0, end)); + } + } + return { inventory, proposed, roots, futureDirectories }; +} +function hasBlockedSddAncestor(value, index) { + // Check immutable blocked entries rather than repeatedly joining an untrusted + // value's prefixes: a long slash-bearing span must not cause quadratic work. + for (const blocked of index.inventory.blocked) { + if (value === blocked || value.startsWith(`${blocked}/`)) + return true; + } + return false; +} +// IANA Top-Level Media Types, reviewed 2026-11-03; no runtime registry/network access. +// https://www.iana.org/assignments/top-level-media-types +const MEDIA_TYPES = new Set([ + 'application', 'audio', 'example', 'font', 'haptics', 'image', 'message', 'model', 'multipart', 'text', 'video', +]); +const MEDIA_NAME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/iu; +function mediaNotation(value) { + const essence = value.split(';', 1)[0]; + const [type, subtype, extra] = essence.split('/'); + return extra === undefined && ((type === '*' && subtype === '*') + || (MEDIA_TYPES.has(type.toLowerCase()) && (subtype === '*' || MEDIA_NAME.test(subtype ?? '')))); +} +/** URL/URI labels describe a non-file role, including file-shaped emitted assets. */ +function urlRole(prefix) { + return /\b(?:(?:URL|URI)(?:[ \t]+path)?|HTTP(?:[ \t]+(?:endpoint|route))?)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); +} +/** + * Not a protocol validator or an existence grant. Explicit Markdown destinations + * and Path/Change rows bypass this heuristic and always receive strict checks. + */ +function isSddNonFileNotation(value, prefix, index) { + if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value)) + return true; + const path = value.replace(/\/$/u, ''); + if (index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) + || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index)) + return false; + if (!/\s|[\p{Cc}]/u.test(value) && urlRole(prefix)) + return true; + if (/\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, ''))) + return false; + if (mediaNotation(value)) + return true; + if (/^(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s+\S+$/u.test(value)) + return true; + if (/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9_][a-z0-9._-]*)*$/iu.test(value) + && isSafeSddRepositoryPath(value)) + return true; + // Extensionless unknown namespaces are not evidence of a repository citation. + // Known roots, file-shaped names and explicit directory spellings remain checked. + const parts = value.split('/'); + return parts.length > 1 && !index.roots.has(parts[0]) + && parts.every(part => /^[a-z0-9_][a-z0-9_-]*$/iu.test(part)); +} +function sddReferencePrefix(source, offset) { + let end = offset; + while (end > 0 && /[ \t*_: =]/u.test(source[end - 1])) + end--; + const tail = source.slice(Math.max(0, end - 16), end); + return tail.slice(tail.lastIndexOf('\n') + 1); +} + + /***/ }), /***/ 44763: @@ -92135,6 +92263,8 @@ exports.SDD_VALIDATION_DETAILS = Object.freeze([ 'requirement-reference', 'requirement-coverage', 'traceability-links', 'traceability-membership', 'traceability-requirement', 'owner-path', 'path-identity', 'create-existing-path', 'create-path-overlap', 'create-path-parent', 'path-operation', 'path-reference', 'path-link', + 'path-inline-reference', 'path-prose-reference', 'path-link-reference', + 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', ]); const REASONS = [...exports.SDD_DRAFT_FORMAT_REASONS, 'size', 'response-shape']; const PHASES = ['response', 'assembly', 'validation']; diff --git a/docs/issues/pre-branch-sdds.mdx b/docs/issues/pre-branch-sdds.mdx index d9344b46c..9c6948beb 100644 --- a/docs/issues/pre-branch-sdds.mdx +++ b/docs/issues/pre-branch-sdds.mdx @@ -76,6 +76,23 @@ 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. +HTTP endpoints such as `api/v1/session`, media types such as `application/json`, +mode alternatives such as `native/standalone` and scoped module identifiers are +notation rather than file-existence claims. Label a file-shaped relative asset +address explicitly, for example **URL:** `assets/main.js`. Without that role, +a file-shaped reference remains a repository citation. A known exact file or a +blocked symlink/submodule path retains repository checking even under a URL label. +For ambiguous file names, use **File:** or **Path:** before the backticked path, +a relative Markdown link or an explicit Path/Change row. Relative Markdown +destinations and Path/Change rows always receive strict repository checks, +regardless of whether their spelling resembles protocol notation. + +A future directory can be cited only when it is an exact ancestor of a validated +new-file `create` row. For example, declaring a new `frontend/src/domain/entry.ts` +allows a citation of `frontend/src/domain/`. It does not create that directory, +authorize application writes, permit a sibling directory or turn a regular file +into a directory. Declare the new file, not a directory placeholder. + 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, @@ -103,6 +120,17 @@ mapping sets can still disagree on their members; that check reports mismatched links rather than implying equal counts prove correctness. These are observations from the actual validator; missing evidence is reported as unavailable rather than guessed. +A repository-reference failure also identifies inline code, file-shaped prose +or a relative Markdown destination, with a **Visible reference occurrence**. +This is a 1-based ordinal within that category, not a source line or table row. +Inline positions count visible code spans; prose positions count file-shaped +matches after classified spans and links are removed; link positions count +visible Markdown destinations, including external ones. Hidden comments and +fenced examples are excluded. A separate fixed explanation distinguishes unsafe +spelling, blocked ancestry, missing evidence or a file used as a directory. +The rejected path/text remains private; these facts locate the first rejection +without retaining or exposing the failed draft. + An illustrative failure view is: > **Specification preparation is blocked** diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 052a2a0b3..59286706d 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) + 2 companion | 248 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 and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 4 companion | 159 paths · 2026-11-02 | +| `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 and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 5 companion | 161 paths · 2026-11-03 | | `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) | 75 paths · 2026-10-30 | | `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) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, policy-to-request transformation and source-bound assertion and generated-source coordinates with independent candidate verification, configuration-selected initial-session credential ownership, delegated test-path and external Action input-contract, host-aware self-repository references and Kotlin/Wasm lexical-binding and renderer-grounded Markdown path evidence, publish stable findings atomically, and apply authorized verified fixes with regression coverage for cwd/pattern-grounded indirect unittest discovery and source-bound module execution evidence, with active-instance Kotlin Json nullable-input policy grounding; verify complete large diffs through a source-bound closed framed profile with immutable local Git proof, live-base freshness and atomic global publication; ground Python exception syntax in declared interpreter versions and PEP 758 while preserving older-target and independent runtime defects; ground filesystem recovery findings in retention/no-overwrite, ownership and publication contracts while preserving required disposable cleanup and data-loss defects; independently adjudicate candidates for supplied Python source, retaining existing bounded source, identity and history controls; trace concrete helper defaults/overrides, database-file parent containment and stored query values before judging assertions, preserving exact fragment coordinates and genuine assertion failures | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 6 companion | 191 paths · 2026-11-03 | @@ -148,12 +148,12 @@ debt or convert unknown historic intent into a design decision. ### `issue-start-and-sdd-readiness` — Uniform issue start and pre-branch SDD readiness - Owner: Copilot maintainers -- Last verified: 2026-11-02 -- 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) · [`specs/pre-branch-sdd-source-context.md`](./pre-branch-sdd-source-context.md) +- Last verified: 2026-11-03 +- 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) · [`specs/pre-branch-sdd-source-context.md`](./pre-branch-sdd-source-context.md) · [`specs/sdd-reference-classification.md`](./sdd-reference-classification.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) · [`src/domain/sdd_draft_consistency.ts`](../src/domain/sdd_draft_consistency.ts) · [`src/domain/sdd_validation_diagnostic.ts`](../src/domain/sdd_validation_diagnostic.ts) · [`src/application/errors/sdd_draft_preparation_failure.ts`](../src/application/errors/sdd_draft_preparation_failure.ts) · [`src/application/policies/sdd_preparation_message_catalog.ts`](../src/application/policies/sdd_preparation_message_catalog.ts) · [`src/application/policies/sdd_preparation_status_policy.ts`](../src/application/policies/sdd_preparation_status_policy.ts) · [`src/domain/sdd_catalog_compatibility.ts`](../src/domain/sdd_catalog_compatibility.ts) · [`src/infrastructure/specification_catalog_publication.cjs`](../src/infrastructure/specification_catalog_publication.cjs) · [`jest.config.js`](../jest.config.js) · [`src/domain/sdd_trusted_catalog_failure.ts`](../src/domain/sdd_trusted_catalog_failure.ts) · [`src/domain/agent_source_snapshot.ts`](../src/domain/agent_source_snapshot.ts) · [`src/application/policies/agent_source_snapshot_policy.ts`](../src/application/policies/agent_source_snapshot_policy.ts) · [`src/infrastructure/agents/agent_source_snapshot_git.ts`](../src/infrastructure/agents/agent_source_snapshot_git.ts) · [`src/infrastructure/agents/agent_source_snapshot_workspace.ts`](../src/infrastructure/agents/agent_source_snapshot_workspace.ts) · [`src/infrastructure/agents/agent_source_snapshot_integrity.ts`](../src/infrastructure/agents/agent_source_snapshot_integrity.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) · [`src/domain/__tests__/sdd_derived_tables.test.ts`](../src/domain/__tests__/sdd_derived_tables.test.ts) · [`src/domain/__tests__/sdd_validation_diagnostic.test.ts`](../src/domain/__tests__/sdd_validation_diagnostic.test.ts) · [`src/application/policies/__tests__/sdd_preparation_status_policy.test.ts`](../src/application/policies/__tests__/sdd_preparation_status_policy.test.ts) · [`src/domain/__tests__/sdd_catalog_compatibility.test.ts`](../src/domain/__tests__/sdd_catalog_compatibility.test.ts) · [`src/testing/fixtures/sdd-native-catalog/catalog.json`](../src/testing/fixtures/sdd-native-catalog/catalog.json) · [`src/testing/fixtures/sdd-native-catalog/catalog.schema.json`](../src/testing/fixtures/sdd-native-catalog/catalog.schema.json) · [`src/testing/fixtures/sdd-native-catalog/provenance.json`](../src/testing/fixtures/sdd-native-catalog/provenance.json) · [`src/tooling/__tests__/specification_catalog_publication.test.ts`](../src/tooling/__tests__/specification_catalog_publication.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts) · [`src/tooling/__tests__/specification_catalog_markdown_links.test.ts`](../src/tooling/__tests__/specification_catalog_markdown_links.test.ts) · [`src/tooling/__tests__/specification_catalog_discovery.test.ts`](../src/tooling/__tests__/specification_catalog_discovery.test.ts) · [`src/domain/__tests__/sdd_trusted_catalog_failure.test.ts`](../src/domain/__tests__/sdd_trusted_catalog_failure.test.ts) · [`src/tooling/__tests__/specification_catalog_title_text.test.ts`](../src/tooling/__tests__/specification_catalog_title_text.test.ts) · [`src/domain/__tests__/sdd_repair_coordinates.test.ts`](../src/domain/__tests__/sdd_repair_coordinates.test.ts) · [`src/application/usecases/sdd/sdd_repair_feedback.test.ts`](../src/application/usecases/sdd/sdd_repair_feedback.test.ts) · [`src/application/policies/__tests__/agent_source_snapshot_policy.test.ts`](../src/application/policies/__tests__/agent_source_snapshot_policy.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts) · [`src/application/policies/__tests__/sdd_source_context_presentation.test.ts`](../src/application/policies/__tests__/sdd_source_context_presentation.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_admission.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) · [`src/domain/sdd_validation_diagnostic.ts`](../src/domain/sdd_validation_diagnostic.ts) · [`src/application/errors/sdd_draft_preparation_failure.ts`](../src/application/errors/sdd_draft_preparation_failure.ts) · [`src/application/policies/sdd_preparation_message_catalog.ts`](../src/application/policies/sdd_preparation_message_catalog.ts) · [`src/application/policies/sdd_preparation_status_policy.ts`](../src/application/policies/sdd_preparation_status_policy.ts) · [`src/domain/sdd_catalog_compatibility.ts`](../src/domain/sdd_catalog_compatibility.ts) · [`src/infrastructure/specification_catalog_publication.cjs`](../src/infrastructure/specification_catalog_publication.cjs) · [`jest.config.js`](../jest.config.js) · [`src/domain/sdd_trusted_catalog_failure.ts`](../src/domain/sdd_trusted_catalog_failure.ts) · [`src/domain/agent_source_snapshot.ts`](../src/domain/agent_source_snapshot.ts) · [`src/application/policies/agent_source_snapshot_policy.ts`](../src/application/policies/agent_source_snapshot_policy.ts) · [`src/infrastructure/agents/agent_source_snapshot_git.ts`](../src/infrastructure/agents/agent_source_snapshot_git.ts) · [`src/infrastructure/agents/agent_source_snapshot_workspace.ts`](../src/infrastructure/agents/agent_source_snapshot_workspace.ts) · [`src/infrastructure/agents/agent_source_snapshot_integrity.ts`](../src/infrastructure/agents/agent_source_snapshot_integrity.ts) · [`src/domain/sdd_repository_reference.ts`](../src/domain/sdd_repository_reference.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) · [`src/domain/__tests__/sdd_derived_tables.test.ts`](../src/domain/__tests__/sdd_derived_tables.test.ts) · [`src/domain/__tests__/sdd_validation_diagnostic.test.ts`](../src/domain/__tests__/sdd_validation_diagnostic.test.ts) · [`src/application/policies/__tests__/sdd_preparation_status_policy.test.ts`](../src/application/policies/__tests__/sdd_preparation_status_policy.test.ts) · [`src/domain/__tests__/sdd_catalog_compatibility.test.ts`](../src/domain/__tests__/sdd_catalog_compatibility.test.ts) · [`src/testing/fixtures/sdd-native-catalog/catalog.json`](../src/testing/fixtures/sdd-native-catalog/catalog.json) · [`src/testing/fixtures/sdd-native-catalog/catalog.schema.json`](../src/testing/fixtures/sdd-native-catalog/catalog.schema.json) · [`src/testing/fixtures/sdd-native-catalog/provenance.json`](../src/testing/fixtures/sdd-native-catalog/provenance.json) · [`src/tooling/__tests__/specification_catalog_publication.test.ts`](../src/tooling/__tests__/specification_catalog_publication.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts) · [`src/tooling/__tests__/specification_catalog_markdown_links.test.ts`](../src/tooling/__tests__/specification_catalog_markdown_links.test.ts) · [`src/tooling/__tests__/specification_catalog_discovery.test.ts`](../src/tooling/__tests__/specification_catalog_discovery.test.ts) · [`src/domain/__tests__/sdd_trusted_catalog_failure.test.ts`](../src/domain/__tests__/sdd_trusted_catalog_failure.test.ts) · [`src/tooling/__tests__/specification_catalog_title_text.test.ts`](../src/tooling/__tests__/specification_catalog_title_text.test.ts) · [`src/domain/__tests__/sdd_repair_coordinates.test.ts`](../src/domain/__tests__/sdd_repair_coordinates.test.ts) · [`src/application/usecases/sdd/sdd_repair_feedback.test.ts`](../src/application/usecases/sdd/sdd_repair_feedback.test.ts) · [`src/application/policies/__tests__/agent_source_snapshot_policy.test.ts`](../src/application/policies/__tests__/agent_source_snapshot_policy.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts) · [`src/application/policies/__tests__/sdd_source_context_presentation.test.ts`](../src/application/policies/__tests__/sdd_source_context_presentation.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts) · [`src/domain/__tests__/sdd_repository_reference.test.ts`](../src/domain/__tests__/sdd_repository_reference.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) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`specs/README.md`](./README.md) ### `managed-issue-lifecycle` — Managed issue and branch lifecycle diff --git a/specs/catalog.json b/specs/catalog.json index adebc39d3..d51eb096a 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1737,15 +1737,16 @@ "id": "issue-start-and-sdd-readiness", "title": "Uniform issue start and pre-branch SDD readiness", "status": "implemented", - "scope": "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 and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout", + "scope": "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 and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics", "owner": "Copilot maintainers", - "lastVerified": "2026-11-02", + "lastVerified": "2026-11-03", "specs": [ "specs/issue-start-and-branch-readiness.md", "specs/pre-branch-sdd-gate.md", "specs/pre-branch-sdd-diff.md", "specs/pre-branch-sdd-status-readiness.md", - "specs/pre-branch-sdd-source-context.md" + "specs/pre-branch-sdd-source-context.md", + "specs/sdd-reference-classification.md" ], "workflows": [ ".github/workflows/copilot_issue.yml", @@ -1831,7 +1832,8 @@ "src/application/policies/agent_source_snapshot_policy.ts", "src/infrastructure/agents/agent_source_snapshot_git.ts", "src/infrastructure/agents/agent_source_snapshot_workspace.ts", - "src/infrastructure/agents/agent_source_snapshot_integrity.ts" + "src/infrastructure/agents/agent_source_snapshot_integrity.ts", + "src/domain/sdd_repository_reference.ts" ], "tests": [ "src/domain/__tests__/issue_workflow_runtime_policy.test.ts", @@ -1900,7 +1902,8 @@ "src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts", "src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts", "src/application/policies/__tests__/sdd_source_context_presentation.test.ts", - "src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts" + "src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts", + "src/domain/__tests__/sdd_repository_reference.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 eb1bade31..ae8e33cc4 100644 --- a/specs/pre-branch-sdd-gate.md +++ b/specs/pre-branch-sdd-gate.md @@ -19,6 +19,11 @@ analysis, drafting and its existing repair to this gate's loaded snapshot SHA. It preserves the canonical checkout, lifecycle and freshness guards while providing an independently verified SDK-owned read-only source view. +The [repository-reference companion](sdd-reference-classification.md) distinguishes +protocol notation from file citations, defines constructive future directories +and adds private-safe reference occurrence diagnostics. Explicit file declarations +and relative Markdown destinations retain strict immutable-inventory checks. + A repository can enable a pre-branch SDD gate for feature issues and other branch-bearing issues explicitly marked as behavior changes. After an authorized diff --git a/specs/sdd-reference-classification.md b/specs/sdd-reference-classification.md new file mode 100644 index 000000000..3da9dca91 --- /dev/null +++ b/specs/sdd-reference-classification.md @@ -0,0 +1,316 @@ +# SDD Repository References and Protocol Notation + +- Status: Implemented contract — remote acceptance and release adoption pending +- Date: 2026-11-03 +- Catalog capability ID: `issue-start-and-sdd-readiness` +- Owners: Copilot maintainers +- Scope: Classify visible SDD references without mistaking protocol notation for missing files. +- Related issue: https://github.com/vypdev/copilot/issues/835 +- Required review gates: product UX, architecture, security, testing and documentation +- Open design decisions: none; acceptance requires the gates below. + +## 1. Executive summary + +An HTTP route, media type, emitted URL or module identifier does not establish +a repository file. Classify that role before checking visible citations against +the immutable Git inventory. Explicit file declarations and relative Markdown +destinations MUST retain strict checks. A future directory is evidence only +when it is an ancestor of an exact, already validated new-file declaration. + +```text +complete draft -> strict Path / Change declarations -> reference classification + -> immutable file/directory checks -> valid publication or private-safe diagnosis +``` + +Text equivalent: validate declarations first, distinguish notation from citations, +then publish only when every existing consistency and freshness guard passes. + +## 2. Problem, current behavior, and evidence + +The issue reports SDK revision `3e7ceea08e6131360e026937d1c0cc1b120472e0` +rejecting ordinary slash-bearing notation as `repository-path`. An independently +authored fixture using the actual twenty canonical template headings reproduces +the thirteen reported controls against the unmodified current policy at +`55329edfc741c447e6e1c8d01e7bbdb576c42a8f`. All thirteen pass format validation; +six fail consistency, including the correct rejection of a genuinely missing +file. Five erroneous rejections concern relative HTTP, compact MIME, asset URL, +mode alternatives and a declared file's future parent directory. + +All five historical issue comments were reviewed. Several consumer runs exhausted +the bounded repair with a generic path rejection, but their generated rejected +drafts are unavailable. Do not attribute a specific token to those unseen drafts. +Some supplied literal drafts validated separately; later Domus issues published +normally. An ordinary proposal comment absent from an actual drafting prompt is +not evidence of a model ignoring supplied source. No consumer application/model +execution or reproduction of every historical run is claimed here. + +Primary references: [RFC 3986 relative references](https://www.rfc-editor.org/rfc/rfc3986#section-4.2), +[RFC 6838 media names](https://www.rfc-editor.org/rfc/rfc6838#section-4.2), +[IANA top-level media types](https://www.iana.org/assignments/top-level-media-types) +and [Node 24 package subpaths](https://nodejs.org/docs/latest-v24.x/api/packages.html#subpath-exports). +The classifier does not validate complete protocols or query those registries. +Historic rationale is unknown; this is a bounded correction, not an as-built +justification. An initially malformed local fixture was discarded before scoring +and retained separately; it established no product result. + +## 3. Actors, surfaces, and terminology + +Issue authors describe intended behavior. The drafting agent proposes Markdown; +it cannot establish existence. Domain policies classify references. The workspace +adapter supplies regular files, directories and blocked symlink/submodule paths +from the selected immutable Git tree. Maintainers inspect issue status, run links +and collapsed diagnostics. A proposed file is an exact `create` leaf, not a +wildcard, directory declaration or grant to write application source. + +## 4. Goals, non-goals, and fixed invariants + +| Requirement | Behavior | +|---|---| +| SREF-01 | MUST accept supported non-file notation without fabricating file evidence. | +| SREF-02 | MUST preserve strict declarations, actual citations, safe spelling and blocked ancestry. | +| SREF-03 | MUST allow a future directory only through a validated proposed file's exact ancestry. | +| SREF-04 | MUST report closed origin, occurrence and cause facts without rejected text or paths. | +| SREF-05 | MUST preserve immutable evidence, owner bytes, publication/freshness and bounded repair. | +| SREF-06 | MUST document ambiguity and prove positive/negative controls across native platforms. | + +No new parser for all Markdown syntax, URL resolver, source executor, filesystem +mutation, consumer framework dependency or model-specific workaround is added. +Existing case identity, reciprocal mappings, per-area budgets, reserved owner IDs, +safe path spelling, catalogue ownership and selected-SDD declarations remain +mandatory. Protocol recognition MUST NOT grant repository or publication rights. + +## 5. Current versus proposed product journey + +| Stage | Current defect | Corrected behavior | +|---|---|---| +| Draft contains `application/json` | Missing repository file | Media notation; no existence claim | +| Draft describes `assets/main.js` | Missing file even with URL role | URL-labelled value is notation; unlabelled file-shaped citation stays strict | +| Draft cites declared future parent | Missing directory | Exact constructive ancestor is allowed | +| Draft cites `frontend/missing-runtime.ts` | Blocked with generic path fact | Still blocked, with origin, occurrence and missing-evidence cause | +| Repair fails again | Blocked | Same bounded recovery and preserved owner; no additional attempt | + +Pending, question/answer, stale-source, cancellation, replay and publication states +remain owned by the existing gate. This correction cannot create a branch by itself. + +## 6. Functional behavior and state model + +Validate section 15 before building reference facts. Its `update`/`reference` +rows MUST identify immutable regular files; `create` MUST remain an exact safe +new leaf without existing/blocked parents or overlap. No notation exemption is +applied to these rows or to relative Markdown destinations. + +For visible inline spans and the existing file-shaped prose scan, use this +precedence after existing scheme/absolute-address handling: + +1. Exact files, directories, proposed leaves, constructive directories and blocked + ancestors retain repository interpretation, even under a nearby URL label. +2. An immediate same-line URL/URI label (optionally `path`), or HTTP label + (optionally `endpoint`/`route`), gives a single value a non-file role. +3. An explicit File/Path/Archivo/Ruta label retains repository interpretation. +4. Recognize the reviewed eleven IANA media top-level names, including haptics, + with restricted subtype spelling, structured suffixes, parameters or ranges; + known uppercase HTTP methods followed by one address; scoped module/subpath + syntax; and extensionless slash alternatives under an unknown repository root. +5. Otherwise retain strict checking for file-shaped paths, known-root references + and explicit trailing-slash directories. Do not normalize unsafe file spelling. + +This is a role heuristic, not universal inference: identical relative-URL and file +bytes are inherently ambiguous. File-shaped relative asset addresses require a +URL/URI role; generic `assets/main.js` remains a strict citation. Ambiguous +extensionless/MIME/module-shaped file citations SHOULD use an explicit file label, +a relative Markdown destination or Path/Change row. Those explicit origins always +receive repository checks. A URL label does not certify a missing file exists. + +Visible spans exclude hidden comments and fenced examples. Validated/classified +spans are masked from the prose scan to prevent reclassification; command spans +can still contain actual file citations. Existing scheme/absolute-address behavior +is preserved. This is not a claim that the legacy scan parses all Markdown. + +Future directories are the exact nonempty ancestor paths of validated `create` +leaves. Existing directories also qualify. Siblings and arbitrary future roots do +not. A trailing slash requires a directory, not a regular/proposed file. Relative +Markdown destinations keep existing source-directory resolution, query/fragment +handling and percent decoding; traversal escaping the repository stays blocked. + +## 7. User-facing configuration + +No new setting, environment input, bypass, model override, migration or persisted +field. Recommended: describe URL roles explicitly and declare exact planned new +files. Alternative: cite existing files with relative Markdown links. Neither +changes safety, ownership, evidence, coverage or permission limits. The immutable +snapshot remains operation-owned; no current-checkout or model existence fallback. + +## 8. Clean Architecture design + +The pure domain reference module owns spelling, notation and constructive ancestry. +The consistency validator composes it after strict declarations. Existing +application orchestration passes normalized diagnostics to the bounded repair and +localized renderer. Infrastructure still loads immutable Git evidence and verifies +publication freshness. No new port/provider call or domain Node/filesystem import. + +```text +Git workspace adapter -> immutable inventory -> pure consistency/reference policy + -> closed diagnostic -> application repair + localized issue presentation +``` + +Text equivalent: adapters establish facts, domain decides, application coordinates, +presentation formats. The public inventory type remains re-exported from its old +module for import compatibility. Dependency/type/lint checks and real-Git adapter +regressions enforce this direction. No durable state or concurrency authority moves. + +## 9. UI/UX and content contract + +Existing plain-text states remain: pending, “Preparing the specification — no +action required”; action required, answer the issue's clarification; completed, +inspect the published SDD and linked managed branch; partial publication, “SDD +publication needs recovery” with its verified commit/branch facts. Do not replace +a partial-publication view with an untrue “no branch” claim. + +Representative blocked view (illustrative, not a new card/template): + +```markdown +## Specification preparation is blocked +No new specification or branch was published by this preparation attempt. +Action: Review validation diagnostics, clarify the intended file/URL role if needed, +then retry this same issue. Existing answers and specifications are retained. +[Open workflow run](https://github.com/vypdev/copilot/actions/runs/123) +
Validation diagnostics +repository-path · validation · attempts 2 +The rejected reference is in an inline code span. +Visible reference occurrence: 3 +The reference is absent from the immutable inventory and proposed declarations. +
+``` + +Three origin codes distinguish inline code, file-shaped prose and Markdown link +destinations. Their bounded `row` fact is rendered as **Visible reference occurrence**, +not a table-row or raw line coordinate. Inline counts all visible code spans; +prose counts file-shaped matches after masking classified spans/links; links count +all visible Markdown destinations, including external ones. Positions are 1-based +within that origin, not interchangeable across origins or source lines. Separate +closed causes identify unsafe spelling, blocked ancestry, absent evidence or a +file incorrectly used as a directory. Unchanged legacy path facts remain present. + +English/Spanish and existing validated dynamic localization/atomic English fallback +apply. Keep logical text/details, descriptive run links, stable owned status marker, +at most eight facts and the existing 16,000-character card. No new comment or +notification is required; the existing owned status is updated idempotently. +No browser UI/VoiceOver campaign is necessary for this diagnostic wording change. + +## 10. Failure, recovery, and cleanup + +First proven rejection remains fail-closed. Normalized closed facts feed only the +existing eligible regeneration; all checks rerun, with at most two attempts total. +Provider/cancellation/freshness failures keep their current recovery semantics. +No partial rejected SDD, source materialization or invented directory is published. +Keep original controls, raw diagnostics and runtime/base identities; remove only +owned unused outputs while preserving verification evidence and current rollback. + +## 11. Security and privacy + +Generated Markdown is untrusted. Do not echo rejected strings, paths, IDs, source, +credentials or raw exceptions into repair/status. The seven new details are closed +vocabulary, with existing numeric bounds and no new wire fields. Traversal, +backslashes, control characters, Git metadata, blocked ancestry, symlinks and +submodules retain strict rejection for repository references. No added permission, +fetch, remote registry lookup, filesystem write or runtime source execution. + +## 12. Observability + +Record current base/head, complete test results and exact before/after control +outcomes. The first rejection is not an exhaustive audit. An ordinal helps locate +the reference category without claiming a retained draft is available. Unavailable +historical drafts remain unknown, not guessed from nearby accepted source. Native +Git fixtures prove adapter behavior, not consumer application/runtime acceptance. + +## 13. Compatibility, rollout, and rollback + +No state migration. Existing public diagnostic fields/reasons and type imports +remain compatible; old consumers of the closed detail vocabulary need the updated +SDK, so source, localization, bundles and documentation ship together. Merge to +develop does not update master/installed consumers. A later authorized release +adopts it. Roll back the classifier and its guidance/docs together without +rewriting issue history or disabling strict publication checks. + +## 14. Testing strategy + +Reserve **90 distinct new deterministic cases**, excluding existing suites and +dynamic localization matrices. Each row is a disjoint risk allocation: + +| Area | Minimum distinct cases | Behaviors/risks covered | +|---|---:|---| +| Reported controls | 13 | Protocol/future-parent positives versus a genuine missing file | +| Additional notation | 16 | MIME/ranges/haptics, URL roles, methods, scoped subpaths and URI forms | +| Strict inline/prose citations | 16 | Missing files, command citations, unsafe spelling, blocked roots and false directories | +| Explicit file origins | 9 | Four notation-shaped Markdown destinations, an exact blocked destination and four strict Path/Change rows | +| Directory ancestry | 4 | Existing and exact constructive directories through inline/link origins | +| Safe origin diagnostics | 3 | Inline/prose/link missing evidence without candidate text | +| Label/index boundaries | 3 | No cross-line URL inheritance, immutable ancestry and bounded suffix | +| Additional diagnostics/security | 14 | Ordinals, causes, blocked precedence, decoding/escape, exact facts, Unicode/control spelling, incomplete media type and deeply segmented untrusted citation | +| Real Git integration | 8 | Update/companion notation, future parents, missing files and strict declarations; owner/worktree/remote intact | +| Localized product diagnostics | 4 | Inline/link origins × English/Spanish from actual validator failures | +| **Total** | **90** | No double counting | + +Run existing format/consistency/diagnostic, draft-repair/publication/freshness and +architecture tests too. Keep global/module budgets and **100% modified executable +lines with zero misses/partials** against the correct base. Full Node/extension +suites, build/hooks, types, lint, documentation/spec/workflow/package checks, +Graphify and actual current-head Linux/macOS/Windows runs remain mandatory. +No new LLM quality corpus is needed for this deterministic validator correction. + +## 15. Documentation work + +Update the pre-branch SDD guide with notation examples, ambiguous file guidance, +strict origins, constructive-directory semantics and safe occurrence diagnostics. +Link this companion from the owning gate; update catalog evidence and generated +index. Existing setup, permission, retry and migration instructions remain valid +because their inputs/state do not change. Do not advise root-absolute URL rewrites +as a workaround for valid relative protocol notation. + +## 16. Acceptance scenarios + +1. The thirteen frozen controls pass format; twelve pass consistency and only the + genuine missing-file control remains rejected. Original baseline is retained. +2. A full update or companion containing routes, MIME, URL-labelled assets and + alternatives validates against native Git without changing owner/source/remote. +3. A proposed file's parent is accepted without creating it; sibling/unknown roots, + blocked parents and files used as directories remain rejected. +4. Explicit destinations/declarations never gain protocol exemptions. All existing + identity, mapping, budget, immutable-source and publication tests still pass. +5. Real inline/link failures produce localized origin/occurrence/cause with stable + status ownership and no rejected text, path or invented row/line identity. + +## 17. Requirement traceability + +| Requirement | Executable evidence | +|---|---| +| SREF-01 | Domain reported/additional notation controls; real Git update/companion tests | +| SREF-02 | Strict citation and explicit-origin negatives; existing safe-path/publication tests | +| SREF-03 | Four ancestry controls, negative sibling/file/blocked controls; two native Git cases | +| SREF-04 | Three origin controls, twelve boundary/security cases and four localized real failures | +| SREF-05 | Existing draft-repair/freshness gates; eight native Git cases preserve owner/worktrees | +| SREF-06 | Before/after frozen controls, documented ambiguity and current-head native platform checks | + +## 18. Implementation sequence + +Freeze unmodified controls; implement pure classification/ancestry and private-safe +diagnostics; add contrasting regressions; update prompt/docs/catalog; run complete +local gates; publish normal commits to the Action-owned branch; complete current-head +review, CI, native platform and official Codecov checks before ordinary merge. + +## 19. Risks and open decisions + +No blocking design decision. Natural-language reference ambiguity remains a stated +limitation; explicit origins provide strict alternatives. Static media names may +need reviewed updates if the registry evolves. The original rejected consumer +drafts cannot be reconstructed from generic historical errors. Do not add broad +path exemptions or claim missing evidence is resolved by this bounded correction. + +## 20. Definition of Done + +All acceptance scenarios and required suites pass; no open finding, incomplete +review or failing applicable check; official Codecov reports every modified line +covered with no partials against live develop. Source, prompt, reviewed catalogs, +bundles and user guidance agree. Graphify is current; evidence, rollback and signed +Safari packages are preserved; only owned unused scratch is retired. Normal merge +closes the issue. Release adoption remains separate from this implementation. diff --git a/src/application/policies/__tests__/sdd_preparation_status_policy.test.ts b/src/application/policies/__tests__/sdd_preparation_status_policy.test.ts index 8ec9c7ea6..4707cac35 100644 --- a/src/application/policies/__tests__/sdd_preparation_status_policy.test.ts +++ b/src/application/policies/__tests__/sdd_preparation_status_policy.test.ts @@ -7,6 +7,9 @@ import { renderSddPreparationFailure, renderSddPreparationRecovered, sddWorkflow import { ENGLISH_SDD_PREPARATION_CATALOG, SDD_PREPARATION_CATALOGS, SDD_PREPARATION_MESSAGE_IDS, resolveSddPreparationCatalog, resolveStaticSddPreparationCatalog } from '../sdd_preparation_message_catalog'; import { validateCatalogDefinition } from '../../../domain/message_catalog'; import { ResolveMessageCatalogUseCase } from '../../usecases/localization/resolve_message_catalog_use_case'; +import { validateSddDraftConsistency } from '../../../domain/sdd_draft_consistency'; +import { SddDraftFormatFailure } from '../../../domain/pre_branch_sdd'; +import { sddReferenceFixture } from '../../../testing/sdd_reference_fixture'; const configuration = { provider: 'codex' as const, model: 'model' }; const run = { serverOrigin: 'https://github.example.test', owner: 'acme', repository: 'repo', runId: '123' }; @@ -15,6 +18,27 @@ const diagnostic = (): SddValidationDiagnostic => ({ reason: 'requirement-mappin const failure = () => new SddDraftPreparationFailure(diagnostic(), 2); describe('safe SDD blocked/recovered presentation', () => { + it.each(['en-US', 'es-ES'].flatMap(locale => [ + { locale, notation: '`frontend/PRIVATE.ts`', detail: 'path-inline-reference' }, + { locale, notation: '[Private](../frontend/PRIVATE.ts)', detail: 'path-link-reference' }, + ]))('renders a private-safe $detail occurrence in $locale from real validator evidence', ({ locale, notation, detail }) => { + let evidence: SddValidationDiagnostic | undefined; + try { + validateSddDraftConsistency(sddReferenceFixture(notation), [], { + files: new Set(['README.md']), directories: new Set(['specs', 'frontend']), blocked: new Set(), + }, 'specs/reference-control.md'); + } catch (error) { + expect(error).toBeInstanceOf(SddDraftFormatFailure); + evidence = (error as SddDraftFormatFailure).diagnostic; + } + expect(evidence?.facts).toContainEqual({ detail, section: 0, row: 1 }); + const body = renderSddPreparationFailure({ issueNumber: 42, locale, error: new SddDraftPreparationFailure(evidence!, 2), diagnostic: evidence, attempts: 2 }); + expect(body).toContain(locale === 'es-ES' ? 'Aparición de referencia visible: 1' : 'Visible reference occurrence: 1'); + expect(body).toContain(locale === 'es-ES' ? 'La referencia no está en el inventario inmutable' : 'The reference is absent from the immutable inventory'); + expect(body).toContain('copilot:sdd-analysis-status:v1'); + expect(body).not.toMatch(/PRIVATE|frontend|Section: 0|Sección: 0/u); + }); + it.each([...SDD_DRAFT_FORMAT_REASONS, 'size'] as const)('shows %s with bounded repair evidence and preserved state', reason => { const evidence = { ...diagnostic(), reason }; const error = new SddDraftPreparationFailure(evidence, 2); diff --git a/src/application/policies/sdd_preparation_message_catalog.ts b/src/application/policies/sdd_preparation_message_catalog.ts index d56100f81..3cca05b36 100644 --- a/src/application/policies/sdd_preparation_message_catalog.ts +++ b/src/application/policies/sdd_preparation_message_catalog.ts @@ -33,6 +33,7 @@ const UI = Object.freeze({ "attempts": ["Draft attempts", "Intentos de borrador"] as const, "section": ["Section", "Sección"] as const, "row": ["Data row", "Fila de datos"] as const, + referenceOrdinal: ['Visible reference occurrence', 'Aparición de referencia visible'] as const, "expected": ["Expected count", "Cantidad esperada"] as const, "observed": ["Observed count", "Cantidad observada"] as const, "recoveredHeading": ["SDD preparation status", "Estado de preparación del SDD"] as const, @@ -78,6 +79,13 @@ const FACTS = Object.freeze({ "path-operation": ["The path operation is unsupported or targets a missing existing file.", "La operación de ruta no es compatible o apunta a un archivo existente que falta."] as const, "path-reference": ["A repository reference cannot be verified as an existing or explicitly proposed path.", "No se puede verificar una referencia como ruta existente o explícitamente propuesta."] as const, "path-link": ["A relative file link is invalid or escapes its repository boundary.", "Un enlace relativo no es válido o sale de los límites del repositorio."] as const, + 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans in document order, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles en orden, excluyendo ejemplos delimitados y comentarios ocultos.'] as const, + 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references, excluding links and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles con forma de archivo, excluyendo enlaces y fragmentos ya clasificados.'] as const, + 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts visible Markdown destinations in document order.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos visibles en orden.'] as const, + 'path-reference-unsafe': ['The reference violates the existing normalized repository-path spelling rules.', 'La referencia incumple las reglas existentes de escritura normalizada de rutas del repositorio.'] as const, + 'path-reference-blocked': ['The immutable inventory blocks this reference or one of its ancestors; symbolic links and submodules cannot prove regular repository content.', 'El inventario inmutable bloquea la referencia o uno de sus antecesores; los enlaces simbólicos y submódulos no demuestran contenido regular del repositorio.'] as const, + 'path-reference-missing': ['The reference is absent from the immutable inventory and exact proposed-file declarations. Cite a real artifact or declare the intended new file in Path / Change.', 'La referencia no está en el inventario inmutable ni en las declaraciones exactas de archivos propuestos. Cita un artefacto real o declara el nuevo archivo previsto en Path / Change.'] as const, + 'path-reference-directory': ['A directory reference requires an existing directory or a safe ancestor of an explicitly proposed new file; a regular file does not establish a directory.', 'Una referencia de directorio requiere un directorio existente o un antecesor seguro de un archivo nuevo declarado; un archivo regular no demuestra un directorio.'] as const, } satisfies Record); type UiKey = keyof typeof UI; diff --git a/src/application/policies/sdd_preparation_status_policy.ts b/src/application/policies/sdd_preparation_status_policy.ts index 83e90a160..d1808369b 100644 --- a/src/application/policies/sdd_preparation_status_policy.ts +++ b/src/application/policies/sdd_preparation_status_policy.ts @@ -92,7 +92,9 @@ export function renderSddPreparationFailure(options: Readonly<{ lines.push('', `- ${message(`sddPreparation.fact.${fact.detail}`)}`); for (const key of ['section', 'row', 'expected', 'observed'] as const) { if (fact[key] !== undefined && (key !== 'section' || fact.section !== 0)) { - lines.push(` - ${message(`sddPreparation.${key}`)}: ${fact[key]}`); + const label = key === 'row' && ['path-inline-reference', 'path-prose-reference', 'path-link-reference'].includes(fact.detail) + ? 'referenceOrdinal' : key; + lines.push(` - ${message(`sddPreparation.${label}`)}: ${fact[key]}`); } } } diff --git a/src/domain/__tests__/sdd_repository_reference.test.ts b/src/domain/__tests__/sdd_repository_reference.test.ts new file mode 100644 index 000000000..ff76565fe --- /dev/null +++ b/src/domain/__tests__/sdd_repository_reference.test.ts @@ -0,0 +1,222 @@ +import { SddDraftFormatFailure, validateSddMarkdown } from '../pre_branch_sdd'; +import { validateSddDraftConsistency, type SddRepositoryInventory } from '../sdd_draft_consistency'; +import { sddReferenceFixture } from '../../testing/sdd_reference_fixture'; +import { buildSddReferenceIndex, hasBlockedSddAncestor, isSddNonFileNotation, sddReferencePrefix } from '../sdd_repository_reference'; + +const inventory: SddRepositoryInventory = { + files: new Set(['README.md', 'frontend/package.json', 'docs/guide.md']), + directories: new Set(['specs', 'frontend', 'docs', 'api']), + blocked: new Set(['linked', 'submodule']), +}; +const validate = (notation: string, paths?: string[][]) => { + const markdown = sddReferenceFixture(notation, paths); + validateSddMarkdown(markdown); + validateSddDraftConsistency(markdown, [], inventory, 'specs/reference-control.md'); +}; + +it.each([ + ['plain prose', 'Plain prose without protocol notation.'], + ['relative HTTP endpoint', 'Relative HTTP endpoint: `api/v1/session`.'], + ['absolute HTTP endpoint', 'Absolute HTTP endpoint: `/api/v1/session`.'], + ['method with relative endpoint', 'Operation: `GET api/v1/session`.'], + ['compact media type', 'Media type: `application/json`.'], + ['labeled media header', 'Header: `Content-Type: application/json`.'], + ['relative emitted URL', 'Relative emitted asset URL: `assets/main.js`.'], + ['deployment alternatives', 'Deployment alternatives: `native/standalone`.'], + ['existing file', 'Repository file: `frontend/package.json`.'], + ['exact proposed leaf', 'Proposed file: `frontend/src/domain/entry.ts`.'], + ['constructive future directory', 'Future parent directory: `frontend/src/domain/`.'], + ['future directory in prose', 'The future parent directory is frontend/src/domain/.'], +])('accepts the reported %s control without changing runtime semantics', (_name, notation) => { + expect(() => validate(notation)).not.toThrow(); +}); + +it('preserves rejection of the reported genuine missing file control', () => { + expect(() => validate('Repository file: `frontend/missing-runtime.ts`.')).toThrow(expect.objectContaining({ reason: 'repository-path' })); +}); + +it.each([ + ['vendor structured suffix', '`application/vnd.example+json`'], + ['registered current top-level type', '`haptics/ivs`'], + ['parameters', '`application/json;charset=utf-8`'], + ['media range', '`application/*`'], + ['all media range', '`*/*`'], + ['HTTP method with file-shaped URL', '`POST api/v1/session.json`'], + ['file-shaped URL outside code', 'Emitted URL: assets/main.js'], + ['URL label inside code', '`URL: assets/main.js`'], + ['URI field', 'URI = `assets/main.js`'], + ['emphasized URL field', '**URL**: `assets/main.js`'], + ['URL path label', 'URL path: `assets/main.js`'], + ['scoped module', '`@scope/package`'], + ['scoped module subpath', '`@scope/package/subpath.js`'], + ['module prose', 'Import @scope/package/subpath.js'], + ['built-in module', '`node:fs/promises`'], + ['external URI', '`https://example.test/api/result.json`'], +])('recognizes %s as notation rather than repository existence', (_name, notation) => { + expect(() => validate(notation)).not.toThrow(); +}); + +it.each([ + ['unknown file-shaped root', '`missing/runtime.ts`'], + ['known root without extension', '`frontend/missing`'], + ['plain prose missing file', 'Inspect frontend/missing-runtime.ts'], + ['command containing a missing file', '`cat frontend/missing-runtime.ts`'], + ['unsafe traversal', '`frontend/../outside.ts`'], + ['unknown traversal', '`unknown/../outside`'], + ['repeated separator', '`unknown//name`'], + ['MIME-shaped file role', 'File: `application/missing.py`'], + ['Spanish file role', 'Archivo: `application/missing.py`'], + ['blocked exact code reference', '`linked/file.ts`'], + ['blocked namespace', '`linked/package`'], + ['submodule reference', '`submodule/runtime.ts`'], + ['unknown directory', '`unknown/directory/`'], + ['sibling of declared directory', '`frontend/src/other/`'], + ['regular file as a directory', '`frontend/package.json/`'], + ['proposed file as a directory', '`frontend/src/domain/entry.ts/`'], +])('preserves strict citation checking for %s', (_name, notation) => { + expect(() => validate(notation)).toThrow(expect.objectContaining({ reason: 'repository-path' })); +}); + +it.each([ + '[Missing media-shaped file](../application/json)', + '[Missing module-shaped file](../@scope/package)', + '[Missing endpoint-shaped file](../api/v1/session)', + '[Missing asset](../assets/main.js)', + '[Blocked inventory node](../linked)', +])('keeps explicit Markdown destinations strict regardless of notation shape: %s', notation => { + expect(() => validate(notation)).toThrow(expect.objectContaining({ reason: 'repository-path' })); +}); + +it.each(['application/json', 'native/standalone', '@scope/package', 'assets/main.js'])('keeps Path/Change declarations strict for %s', file => { + expect(() => validate('Declared repository artifact.', [ + ['specs/reference-control.md', 'create'], [file, 'reference'], + ])).toThrow(expect.objectContaining({ reason: 'repository-path' })); +}); + +it.each([ + '`frontend/src/domain`', '`frontend/src/`', '[Future directory](../frontend/src/domain/)', + '[Existing directory](../docs/)', +])('accepts only existing or constructively proposed directory reference %s', notation => { + expect(() => validate(notation)).not.toThrow(); +}); + +it.each([ + ['inline', '`frontend/PRIVATE.ts`', 'path-inline-reference'], + ['prose', 'Inspect frontend/PRIVATE.ts', 'path-prose-reference'], + ['destination', '[Private](../frontend/PRIVATE.ts)', 'path-link-reference'], +])('reports the %s origin and bounded ordinal without candidate text', (_label, notation, detail) => { + try { + validate(notation); + throw new Error('Expected a reference rejection.'); + } catch (error) { + expect(error).toBeInstanceOf(SddDraftFormatFailure); + const diagnostic = (error as SddDraftFormatFailure).diagnostic; + expect(diagnostic.facts).toContainEqual({ detail, section: 0, row: 1 }); + expect(diagnostic.facts).toContainEqual({ detail: 'path-reference-missing', section: 0 }); + expect(JSON.stringify(diagnostic)).not.toMatch(/PRIVATE|frontend|Private/); + expect(diagnostic.facts.length).toBeLessThanOrEqual(8); + } +}); + +it('does not turn a URL label on another line into a file exemption', () => { + expect(() => validate('URL:\n`frontend/missing.ts`')).toThrow(expect.objectContaining({ reason: 'repository-path' })); +}); + +it('derives directory ancestry without mutating inventory or proposed file identities', () => { + const proposed = new Set(['frontend/new/layer/entry.ts']); + const index = buildSddReferenceIndex(inventory, proposed); + expect([...index.futureDirectories]).toEqual(['frontend/new/layer', 'frontend/new', 'frontend']); + expect([...proposed]).toEqual(['frontend/new/layer/entry.ts']); + expect(inventory.directories.has('frontend/new')).toBe(false); + expect(isSddNonFileNotation('frontend/new/layer/', '', index)).toBe(false); +}); + +it('bounds the retained label suffix while preserving arbitrary indentation of a URL field', () => { + const source = 'Unrelated prose '.repeat(3000) + '**URL**:' + ' '.repeat(4000); + const prefix = sddReferencePrefix(source, source.length); + expect(prefix.length).toBeLessThanOrEqual(16); + expect(isSddNonFileNotation('assets/main.js', prefix, buildSddReferenceIndex(inventory, new Set()))).toBe(true); +}); + +function diagnostic(notation: string) { + try { validate(notation); throw new Error('Expected rejection.'); } + catch (error) { + expect(error).toBeInstanceOf(SddDraftFormatFailure); + return (error as SddDraftFormatFailure).diagnostic; + } +} + +it('counts inline spans independently of hidden comments and fenced examples', () => { + const result = diagnostic('\n```text\n`hidden/span`\n```\n`first` `application/json` `frontend/PRIVATE.ts`'); + expect(result.facts).toContainEqual({ detail: 'path-inline-reference', section: 0, row: 3 }); +}); + +it('counts prose references after known references without recounting validated code spans', () => { + const result = diagnostic('`docs/guide.md` frontend/package.json then frontend/PRIVATE.ts'); + expect(result.facts).toContainEqual({ detail: 'path-prose-reference', section: 0, row: 2 }); +}); + +it('counts Markdown destinations including external links before the rejected destination', () => { + const result = diagnostic('[external](https://example.test/x) [valid](../docs/guide.md) [missing](../frontend/PRIVATE.ts)'); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 3 }); +}); + +it('identifies unsafe spelling without retaining its value', () => { + const result = diagnostic('`frontend/../PRIVATE`'); + expect(result.facts).toContainEqual({ detail: 'path-reference-unsafe', section: 0 }); + expect(JSON.stringify(result)).not.toContain('PRIVATE'); +}); + +it('identifies a blocked ancestor even when a URL label is nearby', () => { + expect(diagnostic('URL: `linked/PRIVATE.ts`').facts).toContainEqual({ detail: 'path-reference-blocked', section: 0 }); +}); + +it('identifies a regular file incorrectly referenced as a directory', () => { + expect(diagnostic('`frontend/package.json/`').facts).toContainEqual({ detail: 'path-reference-directory', section: 0 }); +}); + +it('locates an escaping Markdown destination without accepting traversal', () => { + const result = diagnostic('[outside](../../PRIVATE.md)'); + expect(result.facts).toContainEqual({ detail: 'path-link', section: 0 }); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 1 }); +}); + +it('locates an undecodable Markdown destination without echoing its bytes', () => { + const result = diagnostic('[broken](%PRIVATE)'); + expect(result.facts).toContainEqual({ detail: 'path-link', section: 0 }); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 1 }); + expect(JSON.stringify(result)).not.toContain('PRIVATE'); +}); + +it('treats an exact inventory file as repository evidence even when its shape resembles a scoped module', () => { + const index = buildSddReferenceIndex({ ...inventory, files: new Set([...inventory.files, '@scope/package/sub.js']) }, new Set()); + expect(isSddNonFileNotation('@scope/package/sub.js', 'URL', index)).toBe(false); +}); + +it('treats an exact inventory file as repository evidence even when its shape resembles a media type', () => { + const index = buildSddReferenceIndex({ ...inventory, files: new Set([...inventory.files, 'application/json']) }, new Set()); + expect(isSddNonFileNotation('application/json', '', index)).toBe(false); +}); + +it('keeps explicit Unicode file identities portable without inventing an ASCII-only repository rule', () => { + const paths = ['frontend/café.ts', 'frontend/🧪.ts']; + const populated = { ...inventory, files: new Set([...inventory.files, ...paths]) }; + for (const file of paths) { + expect(() => validateSddDraftConsistency(sddReferenceFixture('Repository file: `' + file + '`'), [], populated, 'specs/reference-control.md')).not.toThrow(); + } +}); + +it('keeps C1-control spelling rejected even under a URL context label', () => { + expect(diagnostic('URL: `frontend/PRIVATE\u0085.ts`').facts).toContainEqual({ detail: 'path-reference-unsafe', section: 0 }); +}); + +it('does not infer a media subtype from an incomplete top-level name', () => { + expect(isSddNonFileNotation('application', '', buildSddReferenceIndex(inventory, new Set()))).toBe(false); +}); + +it('checks a deeply segmented untrusted citation without reconstructing every prefix', () => { + const index = buildSddReferenceIndex(inventory, new Set()); + const deep = 'frontend/' + 'nested/'.repeat(20_000) + 'missing.ts'; + expect(hasBlockedSddAncestor(deep, index)).toBe(false); + expect(isSddNonFileNotation(deep, '', index)).toBe(false); +}); diff --git a/src/domain/sdd_draft_consistency.ts b/src/domain/sdd_draft_consistency.ts index 47ddacb26..19a6f9d25 100644 --- a/src/domain/sdd_draft_consistency.ts +++ b/src/domain/sdd_draft_consistency.ts @@ -1,9 +1,11 @@ import type { SddValidationFact } from './sdd_validation_diagnostic'; import { SddDraftFormatFailure } from './pre_branch_sdd'; +import { buildSddReferenceIndex, hasBlockedSddAncestor, isSafeSddRepositoryPath as safePath, + isSddNonFileNotation, sddReferencePrefix, type SddRepositoryInventory } from './sdd_repository_reference'; +export type { SddRepositoryInventory } from './sdd_repository_reference'; 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; @@ -16,7 +18,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. 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.`; +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 with a File or Path label when ambiguous, or relative Markdown file links. Put every planned new file in this table; a proposed parent directory is valid only as an ancestor of a declared new file, never as a file declaration. HTTP endpoints, media types, mode alternatives and module names are protocol notation, not file-existence claims. Label file-shaped relative asset addresses with URL or URI; do not relabel a repository citation to evade validation. Explicit Path/Change rows and relative Markdown destinations always receive strict repository checks. These tables are publication consistency checks, not runtime/security acceptance.`; const BUDGET_SLOT = '{{SDD_CASE_BUDGET}}'; const TRACEABILITY_SLOT = '{{SDD_REQUIREMENT_CASES}}'; @@ -227,11 +229,6 @@ export function validateSddDraftConsistency( 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 expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; @@ -249,28 +246,58 @@ function validatePaths(source: string, inventory: SddRepositoryInventory, sddPat proposed.add(file); } else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) fail('repository-path', { detail: 'path-operation', section: 15, row: seen.size }); } - const check = (file: string) => { - if (!safePath(file) || (!inventory.files.has(file) && !inventory.directories.has(file) && !proposed.has(file))) fail('repository-path', { detail: 'path-reference', section: 0 }); + const referenceIndex = buildSddReferenceIndex(inventory, proposed); + type Origin = 'path-inline-reference' | 'path-prose-reference' | 'path-link-reference'; + const reject = (origin: Origin, ordinal: number, detail: 'path-reference-unsafe' | 'path-reference-blocked' | 'path-reference-missing' | 'path-reference-directory') => + fail('repository-path', { detail: 'path-reference', section: 0 }, + [{ detail: origin, section: 0, row: ordinal }, { detail, section: 0 }]); + const check = (file: string, origin: Origin, ordinal: number, directory = false) => { + if (!safePath(file)) reject(origin, ordinal, 'path-reference-unsafe'); + if (hasBlockedSddAncestor(file, referenceIndex)) reject(origin, ordinal, 'path-reference-blocked'); + const isDirectory = inventory.directories.has(file) || referenceIndex.futureDirectories.has(file); + if (directory && !isDirectory) reject(origin, ordinal, 'path-reference-directory'); + if (!inventory.files.has(file) && !isDirectory && !proposed.has(file)) reject(origin, ordinal, 'path-reference-missing'); }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. + let linkOrdinal = 0; for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + linkOrdinal++; 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', { detail: 'path-link', section: 0 }); } + try { decoded = decodeURIComponent(href); } catch { + return fail('repository-path', { detail: 'path-link', section: 0 }, [{ detail: 'path-link-reference', section: 0, row: linkOrdinal }]); + } + const directory = decoded.endsWith('/'); + if (directory) decoded = decoded.slice(0, -1); const parts = sddPath.split('/').slice(0, -1); for (const part of decoded.split('/')) { - if (part === '..') { if (!parts.length) fail('repository-path', { detail: 'path-link', section: 0 }); parts.pop(); } + if (part === '..') { + if (!parts.length) fail('repository-path', { detail: 'path-link', section: 0 }, [{ detail: 'path-link-reference', section: 0, row: linkOrdinal }]); + parts.pop(); + } else if (part !== '.') parts.push(part); } - check(parts.join('/')); + check(parts.join('/'), 'path-link-reference', linkOrdinal, directory); } - 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, '')); + let inlineOrdinal = 0; + const prose = source.replace(/`([^`\n]+)`/gu, (span: string, value: string, offset: number) => { + inlineOrdinal++; + if (!value.includes('/')) return span; + if (isSddNonFileNotation(value, sddReferencePrefix(source, offset), referenceIndex)) return ' '.repeat(span.length); + if (!/\s/u.test(value)) { + check(value.replace(/\/$/u, ''), 'path-inline-reference', inlineOrdinal, value.endsWith('/')); + return ' '.repeat(span.length); + } + return span; + }).replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + let proseOrdinal = 0; + for (const match of prose.matchAll(/(?]+/giu, ''); - for (const match of prose.matchAll(/(?; + readonly directories: ReadonlySet; + readonly blocked: ReadonlySet; +} + +export interface SddReferenceIndex { + readonly inventory: SddRepositoryInventory; + readonly proposed: ReadonlySet; + readonly futureDirectories: ReadonlySet; + readonly roots: ReadonlySet; +} + +/** Keep the existing repository spelling contract; protocol recognition never rewrites it. */ +export function isSafeSddRepositoryPath(value: string): boolean { + return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) + && value.split('/').every(segment => segment && segment !== '.' && segment !== '..' && segment.toLowerCase() !== '.git'); +} + +/** Call only after strict Path/Change validation; directories are constructive ancestry, not declarations. */ +export function buildSddReferenceIndex(inventory: SddRepositoryInventory, proposed: ReadonlySet): SddReferenceIndex { + const roots = new Set(); + for (const paths of [inventory.files, inventory.directories, inventory.blocked]) { + for (const path of paths) roots.add(path.split('/')[0]); + } + const futureDirectories = new Set(); + for (const file of proposed) { + for (let end = file.lastIndexOf('/'); end > 0; end = file.lastIndexOf('/', end - 1)) { + futureDirectories.add(file.slice(0, end)); + } + } + return { inventory, proposed, roots, futureDirectories }; +} + +export function hasBlockedSddAncestor(value: string, index: SddReferenceIndex): boolean { + // Check immutable blocked entries rather than repeatedly joining an untrusted + // value's prefixes: a long slash-bearing span must not cause quadratic work. + for (const blocked of index.inventory.blocked) { + if (value === blocked || value.startsWith(`${blocked}/`)) return true; + } + return false; +} + +// IANA Top-Level Media Types, reviewed 2026-11-03; no runtime registry/network access. +// https://www.iana.org/assignments/top-level-media-types +const MEDIA_TYPES: ReadonlySet = new Set([ + 'application', 'audio', 'example', 'font', 'haptics', 'image', 'message', 'model', 'multipart', 'text', 'video', +]); +const MEDIA_NAME = /^[a-z0-9][a-z0-9!#$&^_.+-]{0,126}$/iu; + +function mediaNotation(value: string): boolean { + const essence = value.split(';', 1)[0]; + const [type, subtype, extra] = essence.split('/'); + return extra === undefined && ((type === '*' && subtype === '*') + || (MEDIA_TYPES.has(type.toLowerCase()) && (subtype === '*' || MEDIA_NAME.test(subtype ?? '')))); +} + +/** URL/URI labels describe a non-file role, including file-shaped emitted assets. */ +function urlRole(prefix: string): boolean { + return /\b(?:(?:URL|URI)(?:[ \t]+path)?|HTTP(?:[ \t]+(?:endpoint|route))?)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); +} + +/** + * Not a protocol validator or an existence grant. Explicit Markdown destinations + * and Path/Change rows bypass this heuristic and always receive strict checks. + */ +export function isSddNonFileNotation(value: string, prefix: string, index: SddReferenceIndex): boolean { + if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value)) return true; + const path = value.replace(/\/$/u, ''); + if (index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) + || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index)) return false; + if (!/\s|[\p{Cc}]/u.test(value) && urlRole(prefix)) return true; + if (/\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, ''))) return false; + if (mediaNotation(value)) return true; + if (/^(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s+\S+$/u.test(value)) return true; + if (/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9_][a-z0-9._-]*)*$/iu.test(value) + && isSafeSddRepositoryPath(value)) return true; + // Extensionless unknown namespaces are not evidence of a repository citation. + // Known roots, file-shaped names and explicit directory spellings remain checked. + const parts = value.split('/'); + return parts.length > 1 && !index.roots.has(parts[0]) + && parts.every(part => /^[a-z0-9_][a-z0-9_-]*$/iu.test(part)); +} + +export function sddReferencePrefix(source: string, offset: number): string { + let end = offset; + while (end > 0 && /[ \t*_: =]/u.test(source[end - 1])) end--; + const tail = source.slice(Math.max(0, end - 16), end); + return tail.slice(tail.lastIndexOf('\n') + 1); +} diff --git a/src/domain/sdd_validation_diagnostic.ts b/src/domain/sdd_validation_diagnostic.ts index 2c71d3e84..5524972cb 100644 --- a/src/domain/sdd_validation_diagnostic.ts +++ b/src/domain/sdd_validation_diagnostic.ts @@ -12,6 +12,8 @@ export const SDD_VALIDATION_DETAILS = Object.freeze([ 'requirement-reference', 'requirement-coverage', 'traceability-links', 'traceability-membership', 'traceability-requirement', 'owner-path', 'path-identity', 'create-existing-path', 'create-path-overlap', 'create-path-parent', 'path-operation', 'path-reference', 'path-link', + 'path-inline-reference', 'path-prose-reference', 'path-link-reference', + 'path-reference-unsafe', 'path-reference-blocked', 'path-reference-missing', 'path-reference-directory', ] as const); export type SddValidationDetail = typeof SDD_VALIDATION_DETAILS[number]; export interface SddValidationFact { 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 037e63c83..0c82bf6f2 100644 --- a/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts +++ b/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts @@ -75,6 +75,57 @@ 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)('accepts protocol notation in a complete %s against immutable Git evidence', 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 before = fs.readFileSync(path.join(repo, plan.path), 'utf8'); + const notation = '\nHTTP endpoint: `api/v1/session`\nMedia type: `application/json`\nURL: `assets/main.js`\nDeployment alternatives: `native/standalone`\n'; + const prepared = await workspace.validateDraft(snapshot, selected, draft(selected.path, action) + notation); + expect(prepared.markdown).toContain(notation.trim()); + expect(fs.readFileSync(path.join(repo, plan.path), 'utf8')).toBe(before); + expect(git(repo, 'status', '--porcelain')).toBe(''); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + }); + + it.each(['update', 'companion'] as const)('permits only constructive future directories in a validated %s without materializing source', 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('| docs/payments.mdx | update |', '| docs/payments.mdx | update |\n| src/new/domain/entry.ts | create |') + + '\nFuture directory: `src/new/domain/`.\n'; + const prepared = await workspace.validateDraft(snapshot, selected, markdown); + expect(prepared.markdown).toContain('`src/new/domain/`'); + expect(fs.existsSync(path.join(repo, 'src/new'))).toBe(false); + expect(git(repo, 'status', '--porcelain')).toBe(''); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + }); + + it.each(['update', 'companion'] as const)('still rejects a genuine missing %s citation and preserves the owner', 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 before = fs.readFileSync(path.join(repo, plan.path), 'utf8'); + await expect(workspace.validateDraft(snapshot, selected, draft(selected.path, action) + '\nFile: `src/PRIVATE.ts`.')) + .rejects.toMatchObject({ reason: 'repository-path', diagnostic: { facts: expect.arrayContaining([ + { detail: 'path-inline-reference', section: 0, row: 1 }, { detail: 'path-reference-missing', section: 0 }, + ]) } }); + expect(fs.readFileSync(path.join(repo, plan.path), 'utf8')).toBe(before); + expect(git(repo, 'status', '--porcelain')).toBe(''); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + }); + + it.each(['update', 'companion'] as const)('never exempts a protocol-shaped Path/Change file declaration in %s', 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('| docs/payments.mdx | update |', '| docs/payments.mdx | update |\n| application/json | reference |'); + await expect(workspace.validateDraft(snapshot, selected, markdown)).rejects.toMatchObject({ reason: 'repository-path' }); + expect(git(repo, 'status', '--porcelain')).toBe(''); + expect(git(repo, 'worktree', 'list', '--porcelain').match(/^worktree /gm)).toHaveLength(1); + expect(git(repo, 'ls-remote', '--heads', 'origin', 'feature/42-change')).toBe(''); + }); + 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'); diff --git a/src/testing/sdd_reference_fixture.ts b/src/testing/sdd_reference_fixture.ts new file mode 100644 index 000000000..5ae8276ba --- /dev/null +++ b/src/testing/sdd_reference_fixture.ts @@ -0,0 +1,24 @@ +import { readFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { renderSddDerivedTables } from '../domain/sdd_draft_consistency'; + +const headings = [...readFileSync(join(__dirname, '../../specs/_template.md'), 'utf8') + .matchAll(/^## (\d+)\. .+$/gmu)].map(match => ({ number: Number(match[1]), heading: match[0] })); + +/** Authored twenty-section control: no consumer source, provider call or runtime acceptance. */ +export function sddReferenceFixture(notation: string, paths = [ + ['specs/reference-control.md', 'create'], ['README.md', 'reference'], ['frontend/src/domain/entry.ts', 'create'], +]): string { + const sections: Record = { + 1: notation + '\n' + 'Controlled validator evidence; no application acceptance or consumer execution is claimed. '.repeat(18), + 4: '| Requirement | Behavior |\n|---|---|\n| REF-01 | Distinguish notation and repository evidence without weakening publication checks. |', + 8: 'Pure policy decisions remain separate from Git, publication, providers and presentation.', + 14: '{{SDD_CASE_BUDGET}}\n\n| Case ID | Area | Requirements | Scenario |\n|---|---|---|---|\n| reference-control-C01 | Adapter contracts | REF-01 | Validate the reference against the selected immutable source. |', + 15: '| Path | Change |\n|---|---|\n' + paths.map(([path, operation]) => `| ${path} | ${operation} |`).join('\n'), + 16: 'Format and consistency evidence does not establish application implementation or live consumer recovery.', + 17: '{{SDD_REQUIREMENT_CASES}}', + }; + return renderSddDerivedTables('# Reference control\n\n- Status: Draft\n- Owners: Maintainers\n\n' + + headings.map(({ number, heading }) => heading + '\n' + + (sections[number] ?? 'Not applicable to this isolated validator control; product acceptance remains independent.') + '\n').join('\n')); +} From df63e67bf930494170cc508f779060ec3d79ef38 Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 09:08:39 +0100 Subject: [PATCH 2/3] bugfix-835-sdd-path-validation-mistakes-protocol-notation-for-missing-repository-files: prove hidden links are filtered before occurrence counting --- specs/sdd-reference-classification.md | 5 +++-- .../__tests__/sdd_repository_reference.test.ts | 14 ++++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/specs/sdd-reference-classification.md b/specs/sdd-reference-classification.md index 3da9dca91..31cec4de6 100644 --- a/specs/sdd-reference-classification.md +++ b/specs/sdd-reference-classification.md @@ -234,7 +234,7 @@ rewriting issue history or disabling strict publication checks. ## 14. Testing strategy -Reserve **90 distinct new deterministic cases**, excluding existing suites and +Reserve **94 distinct new deterministic cases**, excluding existing suites and dynamic localization matrices. Each row is a disjoint risk allocation: | Area | Minimum distinct cases | Behaviors/risks covered | @@ -247,9 +247,10 @@ dynamic localization matrices. Each row is a disjoint risk allocation: | Safe origin diagnostics | 3 | Inline/prose/link missing evidence without candidate text | | Label/index boundaries | 3 | No cross-line URL inheritance, immutable ancestry and bounded suffix | | Additional diagnostics/security | 14 | Ordinals, causes, blocked precedence, decoding/escape, exact facts, Unicode/control spelling, incomplete media type and deeply segmented untrusted citation | +| Public link-visibility boundary | 4 | HTML, backtick, tilde and mixed hidden destinations before a visible failure; existing filtering must precede counting | | Real Git integration | 8 | Update/companion notation, future parents, missing files and strict declarations; owner/worktree/remote intact | | Localized product diagnostics | 4 | Inline/link origins × English/Spanish from actual validator failures | -| **Total** | **90** | No double counting | +| **Total** | **94** | No double counting | Run existing format/consistency/diagnostic, draft-repair/publication/freshness and architecture tests too. Keep global/module budgets and **100% modified executable diff --git a/src/domain/__tests__/sdd_repository_reference.test.ts b/src/domain/__tests__/sdd_repository_reference.test.ts index ff76565fe..8a459032f 100644 --- a/src/domain/__tests__/sdd_repository_reference.test.ts +++ b/src/domain/__tests__/sdd_repository_reference.test.ts @@ -161,6 +161,20 @@ it('counts Markdown destinations including external links before the rejected de expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 3 }); }); +it.each([ + ['HTML comment', '', '', 1], + ['backtick example', '```md\n[hidden](../HIDDEN.md)\n```', '', 1], + ['tilde example', '~~~md\n[hidden](../HIDDEN.md)\n~~~', '', 1], + ['mixed hidden regions and visible predecessors', '\n```md\n[hidden](../HIDDEN.md)\n```', + '[external](https://example.test/x) [valid](../docs/guide.md)', 3], +] as const)('excludes %s at the public validator boundary before counting link occurrences', (_name, hidden, visiblePredecessors, ordinal) => { + const result = diagnostic(hidden + '\n' + visiblePredecessors + '\n[missing](../frontend/PRIVATE.ts)'); + expect(result.reason).toBe('repository-path'); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: ordinal }); + expect(result.facts).toContainEqual({ detail: 'path-reference-missing', section: 0 }); + expect(JSON.stringify(result)).not.toMatch(/HIDDEN|PRIVATE|frontend/u); +}); + it('identifies unsafe spelling without retaining its value', () => { const result = diagnostic('`frontend/../PRIVATE`'); expect(result.facts).toContainEqual({ detail: 'path-reference-unsafe', section: 0 }); From 0e95be7a27739c8a165bcae74e369696c815a37d Mon Sep 17 00:00:00 2001 From: Efra Espada Date: Tue, 3 Nov 2026 10:15:14 +0100 Subject: [PATCH 3/3] bugfix-835-sdd-path-validation-mistakes-protocol-notation-for-missing-repository-files: distinguish inline link examples from real destinations --- build/cli/index.js | 129 +++++++++++++--- build/github_action/index.js | 129 +++++++++++++--- docs/issues/pre-branch-sdds.mdx | 17 ++- specs/CATALOG.md | 4 +- specs/catalog.json | 3 +- specs/sdd-reference-classification.md | 60 ++++++-- .../sdd_preparation_message_catalog.ts | 6 +- .../sdd_repository_reference.test.ts | 143 +++++++++++++++++- .../sdd_validation_diagnostic.test.ts | 2 +- src/domain/sdd_draft_consistency.ts | 42 +++-- src/domain/sdd_repository_reference.ts | 25 ++- src/domain/sdd_visible_references.ts | 36 +++++ .../pre_branch_sdd_workspace_adapter.test.ts | 2 +- 13 files changed, 513 insertions(+), 85 deletions(-) create mode 100644 src/domain/sdd_visible_references.ts diff --git a/build/cli/index.js b/build/cli/index.js index cb9e8a0ec..f483fd3c2 100755 --- a/build/cli/index.js +++ b/build/cli/index.js @@ -49847,9 +49847,9 @@ const FACTS = Object.freeze({ "path-operation": ["The path operation is unsupported or targets a missing existing file.", "La operación de ruta no es compatible o apunta a un archivo existente que falta."], "path-reference": ["A repository reference cannot be verified as an existing or explicitly proposed path.", "No se puede verificar una referencia como ruta existente o explícitamente propuesta."], "path-link": ["A relative file link is invalid or escapes its repository boundary.", "Un enlace relativo no es válido o sale de los límites del repositorio."], - 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans in document order, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles en orden, excluyendo ejemplos delimitados y comentarios ocultos.'], - 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references, excluding links and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles con forma de archivo, excluyendo enlaces y fragmentos ya clasificados.'], - 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts visible Markdown destinations in document order.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos visibles en orden.'], + 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans outside already validated Path / Change rows, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles fuera de las filas ya validadas de Path / Change, excluyendo ejemplos delimitados y comentarios ocultos.'], + 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references outside validated Path / Change rows, excluding link destinations and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles fuera de las filas validadas de Path / Change, excluyendo destinos de enlaces y fragmentos ya clasificados.'], + 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts actual Markdown destinations outside already validated Path / Change rows.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos reales fuera de las filas ya validadas de Path / Change.'], 'path-reference-unsafe': ['The reference violates the existing normalized repository-path spelling rules.', 'La referencia incumple las reglas existentes de escritura normalizada de rutas del repositorio.'], 'path-reference-blocked': ['The immutable inventory blocks this reference or one of its ancestors; symbolic links and submodules cannot prove regular repository content.', 'El inventario inmutable bloquea la referencia o uno de sus antecesores; los enlaces simbólicos y submódulos no demuestran contenido regular del repositorio.'], 'path-reference-missing': ['The reference is absent from the immutable inventory and exact proposed-file declarations. Cite a real artifact or declare the intended new file in Path / Change.', 'La referencia no está en el inventario inmutable ni en las declaraciones exactas de archivos propuestos. Cita un artefacto real o declara el nuevo archivo previsto en Path / Change.'], @@ -100783,6 +100783,7 @@ exports.reservedSddCaseIds = reservedSddCaseIds; exports.validateSddDraftConsistency = validateSddDraftConsistency; const pre_branch_sdd_1 = __nccwpck_require__(34730); const sdd_repository_reference_1 = __nccwpck_require__(17791); +const sdd_visible_references_1 = __nccwpck_require__(71766); 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; @@ -100849,7 +100850,7 @@ function section(source, number, reason) { const end = sections.find(heading => heading.index > start.index)?.index ?? source.length; return source.slice(start.index + start[0].length, end); } -function table(source, number, headers, reason) { +function table(source, number, headers, reason, rowSources) { const lines = section(source, number, reason).split('\n'); const found = []; for (let index = 0; index < lines.length; index++) { @@ -100870,6 +100871,7 @@ function table(source, number, headers, reason) { if (row.some(cell => !cell)) return fail(reason, { detail: 'table-empty-cell', section: number, row: rows.length + 1 }); rows.push(row); + rowSources?.add(lines[index]); } if (!rows.length) return fail(reason, { detail: 'table-empty', section: number, observed: 0 }); @@ -101025,7 +101027,8 @@ function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { validatePaths(source, inventory, sddPath); } function validatePaths(source, inventory, sddPath) { - const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); + const declarationRows = new Set(); + const rows = table(source, 15, ['Path', 'Change'], 'repository-path', declarationRows); const expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; if (!rows.some(([file, change]) => file === sddPath && change === expectedChange)) fail('repository-path', { detail: 'owner-path', section: 15 }); @@ -101049,6 +101052,14 @@ function validatePaths(source, inventory, sddPath) { fail('repository-path', { detail: 'path-operation', section: 15, row: seen.size }); } const referenceIndex = (0, sdd_repository_reference_1.buildSddReferenceIndex)(inventory, proposed); + // CommonMark replaces NUL with U+FFFD. Never let that normalization establish + // another repository identity from unsafe visible source spelling. + if (source.includes('\0')) + fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: 'path-reference-unsafe', section: 0 }]); + // These exact two-cell rows have already received strict declaration checks. + // Do not reinterpret their file identities as prose suffixes or Markdown links. + const referenceSource = source.split('\n').map(line => declarationRows.has(line) ? ' '.repeat(line.length) : line).join('\n'); + const references = (0, sdd_visible_references_1.readSddVisibleReferences)(referenceSource); const reject = (origin, ordinal, detail) => fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: origin, section: 0, row: ordinal }, { detail, section: 0 }]); const check = (file, origin, ordinal, directory = false) => { if (!(0, sdd_repository_reference_1.isSafeSddRepositoryPath)(file)) @@ -101063,9 +101074,9 @@ function validatePaths(source, inventory, sddPath) { }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. let linkOrdinal = 0; - for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + for (const destination of references.destinations) { linkOrdinal++; - const href = match[1].split(/[?#]/u)[0]; + const href = destination.split(/[?#]/u)[0]; if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; let decoded; @@ -101091,18 +101102,29 @@ function validatePaths(source, inventory, sddPath) { check(parts.join('/'), 'path-link-reference', linkOrdinal, directory); } let inlineOrdinal = 0; - const prose = source.replace(/`([^`\n]+)`/gu, (span, value, offset) => { + let text = ''; + let context = ''; + for (const part of references.parts) { + if (part.kind === 'text') { + text += part.value; + context = (0, sdd_repository_reference_1.appendSddReferenceContext)(context, part.value); + continue; + } inlineOrdinal++; - if (!value.includes('/')) - return span; - if ((0, sdd_repository_reference_1.isSddNonFileNotation)(value, (0, sdd_repository_reference_1.sddReferencePrefix)(source, offset), referenceIndex)) - return ' '.repeat(span.length); - if (!/\s/u.test(value)) { + const value = part.value; + const prefix = (0, sdd_repository_reference_1.sddReferencePrefix)(context, context.length); + context = '`'; // A prior code span cannot become the next value's URL/File label. + if (!value.includes('/') || (0, sdd_repository_reference_1.isSddNonFileNotation)(value, prefix, referenceIndex)) { + text += ' '.repeat(value.length + 2); + } + else if (!/\s/u.test(value) || (0, sdd_repository_reference_1.isSddFileRole)(prefix) || (0, sdd_repository_reference_1.isKnownSddRepositoryReference)(value, referenceIndex)) { check(value.replace(/\/$/u, ''), 'path-inline-reference', inlineOrdinal, value.endsWith('/')); - return ' '.repeat(span.length); + text += ' '.repeat(value.length + 2); } - return span; - }).replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + else + text += '`' + value + '`'; + } + const prose = text.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); let proseOrdinal = 0; for (const match of prose.matchAll(/(? { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -101132,8 +101154,12 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.isSafeSddRepositoryPath = isSafeSddRepositoryPath; exports.buildSddReferenceIndex = buildSddReferenceIndex; exports.hasBlockedSddAncestor = hasBlockedSddAncestor; +exports.isSddFileRole = isSddFileRole; +exports.isKnownSddRepositoryReference = isKnownSddRepositoryReference; exports.isSddNonFileNotation = isSddNonFileNotation; exports.sddReferencePrefix = sddReferencePrefix; +exports.appendSddReferenceContext = appendSddReferenceContext; +const sdd_visible_references_1 = __nccwpck_require__(71766); /** Keep the existing repository spelling contract; protocol recognition never rewrites it. */ function isSafeSddRepositoryPath(value) { return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) @@ -101179,6 +101205,14 @@ function mediaNotation(value) { function urlRole(prefix) { return /\b(?:(?:URL|URI)(?:[ \t]+path)?|HTTP(?:[ \t]+(?:endpoint|route))?)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); } +function isSddFileRole(prefix) { + return /\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); +} +function isKnownSddRepositoryReference(value, index) { + const path = value.replace(/\/$/u, ''); + return index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) + || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index); +} /** * Not a protocol validator or an existence grant. Explicit Markdown destinations * and Path/Change rows bypass this heuristic and always receive strict checks. @@ -101186,14 +101220,14 @@ function urlRole(prefix) { function isSddNonFileNotation(value, prefix, index) { if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value)) return true; - const path = value.replace(/\/$/u, ''); - if (index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) - || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index)) + if (isKnownSddRepositoryReference(value, index)) return false; if (!/\s|[\p{Cc}]/u.test(value) && urlRole(prefix)) return true; - if (/\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, ''))) + if (isSddFileRole(prefix)) return false; + if ((0, sdd_visible_references_1.isSddMarkdownLinkExample)(value)) + return true; if (mediaNotation(value)) return true; if (/^(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s+\S+$/u.test(value)) @@ -101214,6 +101248,11 @@ function sddReferencePrefix(source, offset) { const tail = source.slice(Math.max(0, end - 16), end); return tail.slice(tail.lastIndexOf('\n') + 1); } +/** Keep streaming label context bounded, including a separator across formatted text leaves. */ +function appendSddReferenceContext(previous, value) { + const combined = previous + value; + return sddReferencePrefix(combined, combined.length) + (/[ \t*_: =]$/u.test(combined) ? ' ' : ''); +} /***/ }), @@ -101294,6 +101333,54 @@ function boundedInteger(value, minimum) { } +/***/ }), + +/***/ 71766: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.readSddVisibleReferences = readSddVisibleReferences; +exports.isSddMarkdownLinkExample = isSddMarkdownLinkExample; +const commonmark_1 = __nccwpck_require__(86076); +/** Pure syntax projection; callers first remove the gate's hidden comments/fences. */ +function readSddVisibleReferences(source) { + const destinations = []; + const parts = []; + const walker = new commonmark_1.Parser().parse(source).walker(); + let event; + while ((event = walker.next())) { + const { node, entering } = event; + if (entering) { + if (node.type === 'link' || node.type === 'image') + destinations.push(node.destination); + if (node.type === 'code') + parts.push({ kind: 'code', value: node.literal }); + if (['text', 'html_inline', 'html_block'].includes(node.type)) + parts.push({ kind: 'text', value: node.literal }); + if (node.type === 'softbreak' || node.type === 'linebreak') + parts.push({ kind: 'text', value: '\n' }); + } + else if (['paragraph', 'heading', 'item'].includes(node.type)) + parts.push({ kind: 'text', value: '\n' }); + } + return { destinations, parts }; +} +/** A whole literal link/image/reference definition is illustrative syntax, never an existence grant. */ +function isSddMarkdownLinkExample(value) { + if (!/^!?\[/u.test(value)) + return false; + const document = new commonmark_1.Parser().parse(value); + const paragraph = document.firstChild; + if (!paragraph) + return true; // Valid reference definitions have no rendered block. + const child = paragraph.firstChild; // A rendered CommonMark paragraph is nonempty. + return paragraph.type === 'paragraph' && paragraph === document.lastChild + && (child.type === 'link' || child.type === 'image') && child === paragraph.lastChild; +} + + /***/ }), /***/ 47122: diff --git a/build/github_action/index.js b/build/github_action/index.js index 967f85a1b..997ab749d 100644 --- a/build/github_action/index.js +++ b/build/github_action/index.js @@ -51706,9 +51706,9 @@ const FACTS = Object.freeze({ "path-operation": ["The path operation is unsupported or targets a missing existing file.", "La operación de ruta no es compatible o apunta a un archivo existente que falta."], "path-reference": ["A repository reference cannot be verified as an existing or explicitly proposed path.", "No se puede verificar una referencia como ruta existente o explícitamente propuesta."], "path-link": ["A relative file link is invalid or escapes its repository boundary.", "Un enlace relativo no es válido o sale de los límites del repositorio."], - 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans in document order, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles en orden, excluyendo ejemplos delimitados y comentarios ocultos.'], - 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references, excluding links and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles con forma de archivo, excluyendo enlaces y fragmentos ya clasificados.'], - 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts visible Markdown destinations in document order.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos visibles en orden.'], + 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans outside already validated Path / Change rows, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles fuera de las filas ya validadas de Path / Change, excluyendo ejemplos delimitados y comentarios ocultos.'], + 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references outside validated Path / Change rows, excluding link destinations and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles fuera de las filas validadas de Path / Change, excluyendo destinos de enlaces y fragmentos ya clasificados.'], + 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts actual Markdown destinations outside already validated Path / Change rows.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos reales fuera de las filas ya validadas de Path / Change.'], 'path-reference-unsafe': ['The reference violates the existing normalized repository-path spelling rules.', 'La referencia incumple las reglas existentes de escritura normalizada de rutas del repositorio.'], 'path-reference-blocked': ['The immutable inventory blocks this reference or one of its ancestors; symbolic links and submodules cannot prove regular repository content.', 'El inventario inmutable bloquea la referencia o uno de sus antecesores; los enlaces simbólicos y submódulos no demuestran contenido regular del repositorio.'], 'path-reference-missing': ['The reference is absent from the immutable inventory and exact proposed-file declarations. Cite a real artifact or declare the intended new file in Path / Change.', 'La referencia no está en el inventario inmutable ni en las declaraciones exactas de archivos propuestos. Cita un artefacto real o declara el nuevo archivo previsto en Path / Change.'], @@ -91783,6 +91783,7 @@ exports.reservedSddCaseIds = reservedSddCaseIds; exports.validateSddDraftConsistency = validateSddDraftConsistency; const pre_branch_sdd_1 = __nccwpck_require__(34730); const sdd_repository_reference_1 = __nccwpck_require__(17791); +const sdd_visible_references_1 = __nccwpck_require__(71766); 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; @@ -91849,7 +91850,7 @@ function section(source, number, reason) { const end = sections.find(heading => heading.index > start.index)?.index ?? source.length; return source.slice(start.index + start[0].length, end); } -function table(source, number, headers, reason) { +function table(source, number, headers, reason, rowSources) { const lines = section(source, number, reason).split('\n'); const found = []; for (let index = 0; index < lines.length; index++) { @@ -91870,6 +91871,7 @@ function table(source, number, headers, reason) { if (row.some(cell => !cell)) return fail(reason, { detail: 'table-empty-cell', section: number, row: rows.length + 1 }); rows.push(row); + rowSources?.add(lines[index]); } if (!rows.length) return fail(reason, { detail: 'table-empty', section: number, observed: 0 }); @@ -92025,7 +92027,8 @@ function validateSddDraftConsistency(markdown, owners, inventory, sddPath) { validatePaths(source, inventory, sddPath); } function validatePaths(source, inventory, sddPath) { - const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); + const declarationRows = new Set(); + const rows = table(source, 15, ['Path', 'Change'], 'repository-path', declarationRows); const expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; if (!rows.some(([file, change]) => file === sddPath && change === expectedChange)) fail('repository-path', { detail: 'owner-path', section: 15 }); @@ -92049,6 +92052,14 @@ function validatePaths(source, inventory, sddPath) { fail('repository-path', { detail: 'path-operation', section: 15, row: seen.size }); } const referenceIndex = (0, sdd_repository_reference_1.buildSddReferenceIndex)(inventory, proposed); + // CommonMark replaces NUL with U+FFFD. Never let that normalization establish + // another repository identity from unsafe visible source spelling. + if (source.includes('\0')) + fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: 'path-reference-unsafe', section: 0 }]); + // These exact two-cell rows have already received strict declaration checks. + // Do not reinterpret their file identities as prose suffixes or Markdown links. + const referenceSource = source.split('\n').map(line => declarationRows.has(line) ? ' '.repeat(line.length) : line).join('\n'); + const references = (0, sdd_visible_references_1.readSddVisibleReferences)(referenceSource); const reject = (origin, ordinal, detail) => fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: origin, section: 0, row: ordinal }, { detail, section: 0 }]); const check = (file, origin, ordinal, directory = false) => { if (!(0, sdd_repository_reference_1.isSafeSddRepositoryPath)(file)) @@ -92063,9 +92074,9 @@ function validatePaths(source, inventory, sddPath) { }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. let linkOrdinal = 0; - for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + for (const destination of references.destinations) { linkOrdinal++; - const href = match[1].split(/[?#]/u)[0]; + const href = destination.split(/[?#]/u)[0]; if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; let decoded; @@ -92091,18 +92102,29 @@ function validatePaths(source, inventory, sddPath) { check(parts.join('/'), 'path-link-reference', linkOrdinal, directory); } let inlineOrdinal = 0; - const prose = source.replace(/`([^`\n]+)`/gu, (span, value, offset) => { + let text = ''; + let context = ''; + for (const part of references.parts) { + if (part.kind === 'text') { + text += part.value; + context = (0, sdd_repository_reference_1.appendSddReferenceContext)(context, part.value); + continue; + } inlineOrdinal++; - if (!value.includes('/')) - return span; - if ((0, sdd_repository_reference_1.isSddNonFileNotation)(value, (0, sdd_repository_reference_1.sddReferencePrefix)(source, offset), referenceIndex)) - return ' '.repeat(span.length); - if (!/\s/u.test(value)) { + const value = part.value; + const prefix = (0, sdd_repository_reference_1.sddReferencePrefix)(context, context.length); + context = '`'; // A prior code span cannot become the next value's URL/File label. + if (!value.includes('/') || (0, sdd_repository_reference_1.isSddNonFileNotation)(value, prefix, referenceIndex)) { + text += ' '.repeat(value.length + 2); + } + else if (!/\s/u.test(value) || (0, sdd_repository_reference_1.isSddFileRole)(prefix) || (0, sdd_repository_reference_1.isKnownSddRepositoryReference)(value, referenceIndex)) { check(value.replace(/\/$/u, ''), 'path-inline-reference', inlineOrdinal, value.endsWith('/')); - return ' '.repeat(span.length); + text += ' '.repeat(value.length + 2); } - return span; - }).replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + else + text += '`' + value + '`'; + } + const prose = text.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); let proseOrdinal = 0; for (const match of prose.matchAll(/(? { +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { "use strict"; @@ -92132,8 +92154,12 @@ Object.defineProperty(exports, "__esModule", ({ value: true })); exports.isSafeSddRepositoryPath = isSafeSddRepositoryPath; exports.buildSddReferenceIndex = buildSddReferenceIndex; exports.hasBlockedSddAncestor = hasBlockedSddAncestor; +exports.isSddFileRole = isSddFileRole; +exports.isKnownSddRepositoryReference = isKnownSddRepositoryReference; exports.isSddNonFileNotation = isSddNonFileNotation; exports.sddReferencePrefix = sddReferencePrefix; +exports.appendSddReferenceContext = appendSddReferenceContext; +const sdd_visible_references_1 = __nccwpck_require__(71766); /** Keep the existing repository spelling contract; protocol recognition never rewrites it. */ function isSafeSddRepositoryPath(value) { return value.length > 0 && !/[\\:*?"<>|\p{Cc}]/u.test(value) @@ -92179,6 +92205,14 @@ function mediaNotation(value) { function urlRole(prefix) { return /\b(?:(?:URL|URI)(?:[ \t]+path)?|HTTP(?:[ \t]+(?:endpoint|route))?)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); } +function isSddFileRole(prefix) { + return /\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); +} +function isKnownSddRepositoryReference(value, index) { + const path = value.replace(/\/$/u, ''); + return index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) + || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index); +} /** * Not a protocol validator or an existence grant. Explicit Markdown destinations * and Path/Change rows bypass this heuristic and always receive strict checks. @@ -92186,14 +92220,14 @@ function urlRole(prefix) { function isSddNonFileNotation(value, prefix, index) { if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value)) return true; - const path = value.replace(/\/$/u, ''); - if (index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) - || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index)) + if (isKnownSddRepositoryReference(value, index)) return false; if (!/\s|[\p{Cc}]/u.test(value) && urlRole(prefix)) return true; - if (/\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, ''))) + if (isSddFileRole(prefix)) return false; + if ((0, sdd_visible_references_1.isSddMarkdownLinkExample)(value)) + return true; if (mediaNotation(value)) return true; if (/^(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s+\S+$/u.test(value)) @@ -92214,6 +92248,11 @@ function sddReferencePrefix(source, offset) { const tail = source.slice(Math.max(0, end - 16), end); return tail.slice(tail.lastIndexOf('\n') + 1); } +/** Keep streaming label context bounded, including a separator across formatted text leaves. */ +function appendSddReferenceContext(previous, value) { + const combined = previous + value; + return sddReferencePrefix(combined, combined.length) + (/[ \t*_: =]$/u.test(combined) ? ' ' : ''); +} /***/ }), @@ -92294,6 +92333,54 @@ function boundedInteger(value, minimum) { } +/***/ }), + +/***/ 71766: +/***/ ((__unused_webpack_module, exports, __nccwpck_require__) => { + +"use strict"; + +Object.defineProperty(exports, "__esModule", ({ value: true })); +exports.readSddVisibleReferences = readSddVisibleReferences; +exports.isSddMarkdownLinkExample = isSddMarkdownLinkExample; +const commonmark_1 = __nccwpck_require__(86076); +/** Pure syntax projection; callers first remove the gate's hidden comments/fences. */ +function readSddVisibleReferences(source) { + const destinations = []; + const parts = []; + const walker = new commonmark_1.Parser().parse(source).walker(); + let event; + while ((event = walker.next())) { + const { node, entering } = event; + if (entering) { + if (node.type === 'link' || node.type === 'image') + destinations.push(node.destination); + if (node.type === 'code') + parts.push({ kind: 'code', value: node.literal }); + if (['text', 'html_inline', 'html_block'].includes(node.type)) + parts.push({ kind: 'text', value: node.literal }); + if (node.type === 'softbreak' || node.type === 'linebreak') + parts.push({ kind: 'text', value: '\n' }); + } + else if (['paragraph', 'heading', 'item'].includes(node.type)) + parts.push({ kind: 'text', value: '\n' }); + } + return { destinations, parts }; +} +/** A whole literal link/image/reference definition is illustrative syntax, never an existence grant. */ +function isSddMarkdownLinkExample(value) { + if (!/^!?\[/u.test(value)) + return false; + const document = new commonmark_1.Parser().parse(value); + const paragraph = document.firstChild; + if (!paragraph) + return true; // Valid reference definitions have no rendered block. + const child = paragraph.firstChild; // A rendered CommonMark paragraph is nonempty. + return paragraph.type === 'paragraph' && paragraph === document.lastChild + && (child.type === 'link' || child.type === 'image') && child === paragraph.lastChild; +} + + /***/ }), /***/ 47122: diff --git a/docs/issues/pre-branch-sdds.mdx b/docs/issues/pre-branch-sdds.mdx index 9c6948beb..4233c6b64 100644 --- a/docs/issues/pre-branch-sdds.mdx +++ b/docs/issues/pre-branch-sdds.mdx @@ -87,6 +87,17 @@ a relative Markdown link or an explicit Path/Change row. Relative Markdown destinations and Path/Change rows always receive strict repository checks, regardless of whether their spelling resembles protocol notation. +A whole link, image or reference-definition example inside code is illustrative +Markdown, rather than a real destination. For example, writing the literal +`` `[example](../missing.md)` `` does not claim that its example target exists. +Actual links, image destinations and used reference-style links remain strict. +Reference structure uses the existing Markdown parser, including longer backtick +delimiters and multiline code. It checks the operative URI against immutable +files/directories or exact proposed artifacts; normalization cannot establish +existence. Visible NUL is rejected before it can alias a replacement-character +filename. A file label or exact known/blocked path retains repository checking, +including space-bearing names. + A future directory can be cited only when it is an exact ancestor of a validated new-file `create` row. For example, declaring a new `frontend/src/domain/entry.ts` allows a citation of `frontend/src/domain/`. It does not create that directory, @@ -125,11 +136,15 @@ or a relative Markdown destination, with a **Visible reference occurrence**. This is a 1-based ordinal within that category, not a source line or table row. Inline positions count visible code spans; prose positions count file-shaped matches after classified spans and links are removed; link positions count -visible Markdown destinations, including external ones. Hidden comments and +actual parsed destinations, including external and empty same-document links. +Unused reference definitions add no visible destination. Hidden comments and fenced examples are excluded. A separate fixed explanation distinguishes unsafe spelling, blocked ancestry, missing evidence or a file used as a directory. The rejected path/text remains private; these facts locate the first rejection without retaining or exposing the failed draft. +Reference ordinals also exclude the already validated Path/Change data rows; +those declarations have their own table-row diagnostics. They are not parsed a +second time as prose fragments or link syntax, including space-bearing file names. An illustrative failure view is: diff --git a/specs/CATALOG.md b/specs/CATALOG.md index 59286706d..55b118f7a 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) + 2 companion | 248 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 and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 5 companion | 161 paths · 2026-11-03 | +| `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 and preserve complete navigable catalog links for accepted repository filenames and safely publish generated catalogs without following destination links; bootstrap truthful proposed SDK capabilities before implementation without changing readiness; reject incomplete specification discovery explicitly without following unregistered symbolic links; report confirmed missing trusted catalogs with immutable source provenance and prerequisite recovery before model or publication; bind pre-branch analysis and drafting to a verified independent read-only view of the selected immutable source without changing the canonical checkout; distinguish visible protocol notation from repository citations, allow only constructive proposed-file directory ancestry and expose bounded private-safe origin diagnostics | [Uniform issue start and pre-branch SDD readiness](./issue-start-and-branch-readiness.md) + 5 companion | 162 paths · 2026-11-03 | | `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) | 75 paths · 2026-10-30 | | `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) | 88 paths · 2026-10-26 | | `bugbot-analysis-and-autofix` | Implemented | Select one canonical PR, exhaustively analyze bounded diff partitions with version-grounded dependency, policy-to-request transformation and source-bound assertion and generated-source coordinates with independent candidate verification, configuration-selected initial-session credential ownership, delegated test-path and external Action input-contract, host-aware self-repository references and Kotlin/Wasm lexical-binding and renderer-grounded Markdown path evidence, publish stable findings atomically, and apply authorized verified fixes with regression coverage for cwd/pattern-grounded indirect unittest discovery and source-bound module execution evidence, with active-instance Kotlin Json nullable-input policy grounding; verify complete large diffs through a source-bound closed framed profile with immutable local Git proof, live-base freshness and atomic global publication; ground Python exception syntax in declared interpreter versions and PEP 758 while preserving older-target and independent runtime defects; ground filesystem recovery findings in retention/no-overwrite, ownership and publication contracts while preserving required disposable cleanup and data-loss defects; independently adjudicate candidates for supplied Python source, retaining existing bounded source, identity and history controls; trace concrete helper defaults/overrides, database-file parent containment and stored query values before judging assertions, preserving exact fragment coordinates and genuine assertion failures | [Bugbot analysis, finding publication, and autofix](./bugbot-analysis-publication-and-autofix.md) + 6 companion | 191 paths · 2026-11-03 | @@ -152,7 +152,7 @@ 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) · [`specs/pre-branch-sdd-source-context.md`](./pre-branch-sdd-source-context.md) · [`specs/sdd-reference-classification.md`](./sdd-reference-classification.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) · [`src/domain/sdd_draft_consistency.ts`](../src/domain/sdd_draft_consistency.ts) · [`src/domain/sdd_validation_diagnostic.ts`](../src/domain/sdd_validation_diagnostic.ts) · [`src/application/errors/sdd_draft_preparation_failure.ts`](../src/application/errors/sdd_draft_preparation_failure.ts) · [`src/application/policies/sdd_preparation_message_catalog.ts`](../src/application/policies/sdd_preparation_message_catalog.ts) · [`src/application/policies/sdd_preparation_status_policy.ts`](../src/application/policies/sdd_preparation_status_policy.ts) · [`src/domain/sdd_catalog_compatibility.ts`](../src/domain/sdd_catalog_compatibility.ts) · [`src/infrastructure/specification_catalog_publication.cjs`](../src/infrastructure/specification_catalog_publication.cjs) · [`jest.config.js`](../jest.config.js) · [`src/domain/sdd_trusted_catalog_failure.ts`](../src/domain/sdd_trusted_catalog_failure.ts) · [`src/domain/agent_source_snapshot.ts`](../src/domain/agent_source_snapshot.ts) · [`src/application/policies/agent_source_snapshot_policy.ts`](../src/application/policies/agent_source_snapshot_policy.ts) · [`src/infrastructure/agents/agent_source_snapshot_git.ts`](../src/infrastructure/agents/agent_source_snapshot_git.ts) · [`src/infrastructure/agents/agent_source_snapshot_workspace.ts`](../src/infrastructure/agents/agent_source_snapshot_workspace.ts) · [`src/infrastructure/agents/agent_source_snapshot_integrity.ts`](../src/infrastructure/agents/agent_source_snapshot_integrity.ts) · [`src/domain/sdd_repository_reference.ts`](../src/domain/sdd_repository_reference.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) · [`src/domain/sdd_validation_diagnostic.ts`](../src/domain/sdd_validation_diagnostic.ts) · [`src/application/errors/sdd_draft_preparation_failure.ts`](../src/application/errors/sdd_draft_preparation_failure.ts) · [`src/application/policies/sdd_preparation_message_catalog.ts`](../src/application/policies/sdd_preparation_message_catalog.ts) · [`src/application/policies/sdd_preparation_status_policy.ts`](../src/application/policies/sdd_preparation_status_policy.ts) · [`src/domain/sdd_catalog_compatibility.ts`](../src/domain/sdd_catalog_compatibility.ts) · [`src/infrastructure/specification_catalog_publication.cjs`](../src/infrastructure/specification_catalog_publication.cjs) · [`jest.config.js`](../jest.config.js) · [`src/domain/sdd_trusted_catalog_failure.ts`](../src/domain/sdd_trusted_catalog_failure.ts) · [`src/domain/agent_source_snapshot.ts`](../src/domain/agent_source_snapshot.ts) · [`src/application/policies/agent_source_snapshot_policy.ts`](../src/application/policies/agent_source_snapshot_policy.ts) · [`src/infrastructure/agents/agent_source_snapshot_git.ts`](../src/infrastructure/agents/agent_source_snapshot_git.ts) · [`src/infrastructure/agents/agent_source_snapshot_workspace.ts`](../src/infrastructure/agents/agent_source_snapshot_workspace.ts) · [`src/infrastructure/agents/agent_source_snapshot_integrity.ts`](../src/infrastructure/agents/agent_source_snapshot_integrity.ts) · [`src/domain/sdd_repository_reference.ts`](../src/domain/sdd_repository_reference.ts) · [`src/domain/sdd_visible_references.ts`](../src/domain/sdd_visible_references.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) · [`src/domain/__tests__/sdd_derived_tables.test.ts`](../src/domain/__tests__/sdd_derived_tables.test.ts) · [`src/domain/__tests__/sdd_validation_diagnostic.test.ts`](../src/domain/__tests__/sdd_validation_diagnostic.test.ts) · [`src/application/policies/__tests__/sdd_preparation_status_policy.test.ts`](../src/application/policies/__tests__/sdd_preparation_status_policy.test.ts) · [`src/domain/__tests__/sdd_catalog_compatibility.test.ts`](../src/domain/__tests__/sdd_catalog_compatibility.test.ts) · [`src/testing/fixtures/sdd-native-catalog/catalog.json`](../src/testing/fixtures/sdd-native-catalog/catalog.json) · [`src/testing/fixtures/sdd-native-catalog/catalog.schema.json`](../src/testing/fixtures/sdd-native-catalog/catalog.schema.json) · [`src/testing/fixtures/sdd-native-catalog/provenance.json`](../src/testing/fixtures/sdd-native-catalog/provenance.json) · [`src/tooling/__tests__/specification_catalog_publication.test.ts`](../src/tooling/__tests__/specification_catalog_publication.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog.test.ts) · [`src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts`](../src/tooling/__tests__/proposed_sdd_catalog_entrypoint.test.ts) · [`src/tooling/__tests__/specification_catalog_markdown_links.test.ts`](../src/tooling/__tests__/specification_catalog_markdown_links.test.ts) · [`src/tooling/__tests__/specification_catalog_discovery.test.ts`](../src/tooling/__tests__/specification_catalog_discovery.test.ts) · [`src/domain/__tests__/sdd_trusted_catalog_failure.test.ts`](../src/domain/__tests__/sdd_trusted_catalog_failure.test.ts) · [`src/tooling/__tests__/specification_catalog_title_text.test.ts`](../src/tooling/__tests__/specification_catalog_title_text.test.ts) · [`src/domain/__tests__/sdd_repair_coordinates.test.ts`](../src/domain/__tests__/sdd_repair_coordinates.test.ts) · [`src/application/usecases/sdd/sdd_repair_feedback.test.ts`](../src/application/usecases/sdd/sdd_repair_feedback.test.ts) · [`src/application/policies/__tests__/agent_source_snapshot_policy.test.ts`](../src/application/policies/__tests__/agent_source_snapshot_policy.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_inventory.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_workspace.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_regression.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_git.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_integrity.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_gate.integration.test.ts) · [`src/application/policies/__tests__/sdd_source_context_presentation.test.ts`](../src/application/policies/__tests__/sdd_source_context_presentation.test.ts) · [`src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts`](../src/infrastructure/agents/__tests__/agent_source_snapshot_admission.test.ts) · [`src/domain/__tests__/sdd_repository_reference.test.ts`](../src/domain/__tests__/sdd_repository_reference.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) · [`docs/security-operations/operations/error-reference.mdx`](../docs/security-operations/operations/error-reference.mdx) · [`specs/README.md`](./README.md) diff --git a/specs/catalog.json b/specs/catalog.json index d51eb096a..d2d2ea191 100644 --- a/specs/catalog.json +++ b/specs/catalog.json @@ -1833,7 +1833,8 @@ "src/infrastructure/agents/agent_source_snapshot_git.ts", "src/infrastructure/agents/agent_source_snapshot_workspace.ts", "src/infrastructure/agents/agent_source_snapshot_integrity.ts", - "src/domain/sdd_repository_reference.ts" + "src/domain/sdd_repository_reference.ts", + "src/domain/sdd_visible_references.ts" ], "tests": [ "src/domain/__tests__/issue_workflow_runtime_policy.test.ts", diff --git a/specs/sdd-reference-classification.md b/specs/sdd-reference-classification.md index 31cec4de6..4c2b2c32d 100644 --- a/specs/sdd-reference-classification.md +++ b/specs/sdd-reference-classification.md @@ -1,6 +1,6 @@ # SDD Repository References and Protocol Notation -- Status: Implemented contract — remote acceptance and release adoption pending +- Status: Implemented contract — release adoption pending - Date: 2026-11-03 - Catalog capability ID: `issue-start-and-sdd-readiness` - Owners: Copilot maintainers @@ -48,6 +48,8 @@ Primary references: [RFC 3986 relative references](https://www.rfc-editor.org/rf [RFC 6838 media names](https://www.rfc-editor.org/rfc/rfc6838#section-4.2), [IANA top-level media types](https://www.iana.org/assignments/top-level-media-types) and [Node 24 package subpaths](https://nodejs.org/docs/latest-v24.x/api/packages.html#subpath-exports). +Reference structure follows the project's existing [CommonMark 0.31.2 parser](https://spec.commonmark.org/0.31.2/#code-spans), +including code-span precedence over link syntax. The classifier does not validate complete protocols or query those registries. Historic rationale is unknown; this is a bounded correction, not an as-built justification. An initially malformed local fixture was discarded before scoring @@ -73,7 +75,7 @@ wildcard, directory declaration or grant to write application source. | SREF-05 | MUST preserve immutable evidence, owner bytes, publication/freshness and bounded repair. | | SREF-06 | MUST document ambiguity and prove positive/negative controls across native platforms. | -No new parser for all Markdown syntax, URL resolver, source executor, filesystem +No custom Markdown parser, URL resolver, source executor, filesystem mutation, consumer framework dependency or model-specific workaround is added. Existing case identity, reciprocal mappings, per-area budgets, reserved owner IDs, safe path spelling, catalogue ownership and selected-SDD declarations remain @@ -98,6 +100,11 @@ Validate section 15 before building reference facts. Its `update`/`reference` rows MUST identify immutable regular files; `create` MUST remain an exact safe new leaf without existing/blocked parents or overlap. No notation exemption is applied to these rows or to relative Markdown destinations. +After declaration checks, mask only those exact validated two-cell data rows from +reference parsing. Their files have already been checked; do not reinterpret a +space-bearing filename as a shorter prose suffix or embedded link syntax. Other +section-15 prose and all other reference content remain checked. Declaration +failures retain their own table data-row coordinates. For visible inline spans and the existing file-shaped prose scan, use this precedence after existing scheme/absolute-address handling: @@ -107,11 +114,13 @@ precedence after existing scheme/absolute-address handling: 2. An immediate same-line URL/URI label (optionally `path`), or HTTP label (optionally `endpoint`/`route`), gives a single value a non-file role. 3. An explicit File/Path/Archivo/Ruta label retains repository interpretation. -4. Recognize the reviewed eleven IANA media top-level names, including haptics, +4. Whole literal link/image/reference-definition examples inside code are syntax, + not actual destinations. A file label or exact known/blocked identity still wins. +5. Recognize the reviewed eleven IANA media top-level names, including haptics, with restricted subtype spelling, structured suffixes, parameters or ranges; known uppercase HTTP methods followed by one address; scoped module/subpath syntax; and extensionless slash alternatives under an unknown repository root. -5. Otherwise retain strict checking for file-shaped paths, known-root references +6. Otherwise retain strict checking for file-shaped paths, known-root references and explicit trailing-slash directories. Do not normalize unsafe file spelling. This is a role heuristic, not universal inference: identical relative-URL and file @@ -121,16 +130,28 @@ extensionless/MIME/module-shaped file citations SHOULD use an explicit file labe a relative Markdown destination or Path/Change row. Those explicit origins always receive repository checks. A URL label does not certify a missing file exists. -Visible spans exclude hidden comments and fenced examples. Validated/classified -spans are masked from the prose scan to prevent reclassification; command spans -can still contain actual file citations. Existing scheme/absolute-address behavior -is preserved. This is not a claim that the legacy scan parses all Markdown. +Keep the existing host comment/fence visibility filter and strict table parser. +Then project reference structure with the existing pure CommonMark dependency, +without a second hand-written delimiter parser. Real links, images and used +reference-style destinations are checked; code-span examples are not links. +Code values follow Markdown's delimiter, space and newline semantics. Validated/ +classified spans are masked from the prose scan; unclassified command spans and +visible HTML literals can still contain actual file citations. Exact known paths +and explicit file labels remain strict even when the value contains spaces. +Keep label context bounded across formatted text leaves, preserve a separator, +and reset it after code or line breaks so an earlier URL role cannot leak forward. +Existing scheme/absolute-address behavior is preserved. This does not replace +the complete gate's visibility/table grammar with a new Markdown acceptance gate. Future directories are the exact nonempty ancestor paths of validated `create` leaves. Existing directories also qualify. Siblings and arbitrary future roots do not. A trailing slash requires a directory, not a regular/proposed file. Relative -Markdown destinations keep existing source-directory resolution, query/fragment -handling and percent decoding; traversal escaping the repository stays blocked. +Markdown destinations use their operative CommonMark URI, source-directory +resolution, query/fragment handling and percent decoding; traversal escaping the +repository stays blocked. A literal percent spelling can resolve only to its +actual immutable filename; invalid UTF-8 escapes remain rejected. Reject visible +NUL before the parser can replace it and alias another filename. No parser +normalization establishes source existence or bypasses blocked ancestry. ## 7. User-facing configuration @@ -143,6 +164,9 @@ snapshot remains operation-owned; no current-checkout or model existence fallbac ## 8. Clean Architecture design The pure domain reference module owns spelling, notation and constructive ancestry. +Its syntax projection reuses the CommonMark dependency already used in the domain; +it returns only destinations and ordered text/code parts, without rendering HTML, +fetching links, inspecting files or mutating a document/provider. The consistency validator composes it after strict declarations. Existing application orchestration passes normalized diagnostics to the bounded repair and localized renderer. Infrastructure still loads immutable Git evidence and verifies @@ -185,8 +209,11 @@ The reference is absent from the immutable inventory and proposed declarations. Three origin codes distinguish inline code, file-shaped prose and Markdown link destinations. Their bounded `row` fact is rendered as **Visible reference occurrence**, not a table-row or raw line coordinate. Inline counts all visible code spans; -prose counts file-shaped matches after masking classified spans/links; links count -all visible Markdown destinations, including external ones. Positions are 1-based +prose counts file-shaped matches after masking classified spans/destinations; links +count actual parsed destinations, including external and empty same-document links. +Unused reference definitions do not create a rendered destination. All three +reference ordinals exclude the already validated Path/Change data rows, which +have their own strict diagnostics. Positions are 1-based within that origin, not interchangeable across origins or source lines. Separate closed causes identify unsafe spelling, blocked ancestry, absent evidence or a file incorrectly used as a directory. Unchanged legacy path facts remain present. @@ -234,7 +261,7 @@ rewriting issue history or disabling strict publication checks. ## 14. Testing strategy -Reserve **94 distinct new deterministic cases**, excluding existing suites and +Reserve **127 distinct new deterministic cases**, excluding existing suites and dynamic localization matrices. Each row is a disjoint risk allocation: | Area | Minimum distinct cases | Behaviors/risks covered | @@ -248,9 +275,11 @@ dynamic localization matrices. Each row is a disjoint risk allocation: | Label/index boundaries | 3 | No cross-line URL inheritance, immutable ancestry and bounded suffix | | Additional diagnostics/security | 14 | Ordinals, causes, blocked precedence, decoding/escape, exact facts, Unicode/control spelling, incomplete media type and deeply segmented untrusted citation | | Public link-visibility boundary | 4 | HTML, backtick, tilde and mixed hidden destinations before a visible failure; existing filtering must precede counting | +| Literal inline-link boundaries | 14 | Delimiter lengths/escapes, image examples, actual link ordinals, explicit file roles, unknown-directory rejection, spacing and adjacent examples | +| Parsed-reference safety and context | 19 | Multiline code, reference/image links, titles, URI-contained backticks, HTML evidence, NUL alias prevention, percent filenames, empty destinations, non-whole examples, definitions, space-bearing paths, hard breaks and different Markdown blocks | | Real Git integration | 8 | Update/companion notation, future parents, missing files and strict declarations; owner/worktree/remote intact | | Localized product diagnostics | 4 | Inline/link origins × English/Spanish from actual validator failures | -| **Total** | **94** | No double counting | +| **Total** | **127** | No double counting | Run existing format/consistency/diagnostic, draft-repair/publication/freshness and architecture tests too. Keep global/module budgets and **100% modified executable @@ -262,7 +291,8 @@ No new LLM quality corpus is needed for this deterministic validator correction. ## 15. Documentation work Update the pre-branch SDD guide with notation examples, ambiguous file guidance, -strict origins, constructive-directory semantics and safe occurrence diagnostics. +strict origins, literal link examples versus actual destinations, constructive- +directory semantics and safe occurrence diagnostics. Link this companion from the owning gate; update catalog evidence and generated index. Existing setup, permission, retry and migration instructions remain valid because their inputs/state do not change. Do not advise root-absolute URL rewrites diff --git a/src/application/policies/sdd_preparation_message_catalog.ts b/src/application/policies/sdd_preparation_message_catalog.ts index 3cca05b36..84f12e662 100644 --- a/src/application/policies/sdd_preparation_message_catalog.ts +++ b/src/application/policies/sdd_preparation_message_catalog.ts @@ -79,9 +79,9 @@ const FACTS = Object.freeze({ "path-operation": ["The path operation is unsupported or targets a missing existing file.", "La operación de ruta no es compatible o apunta a un archivo existente que falta."] as const, "path-reference": ["A repository reference cannot be verified as an existing or explicitly proposed path.", "No se puede verificar una referencia como ruta existente o explícitamente propuesta."] as const, "path-link": ["A relative file link is invalid or escapes its repository boundary.", "Un enlace relativo no es válido o sale de los límites del repositorio."] as const, - 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans in document order, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles en orden, excluyendo ejemplos delimitados y comentarios ocultos.'] as const, - 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references, excluding links and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles con forma de archivo, excluyendo enlaces y fragmentos ya clasificados.'] as const, - 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts visible Markdown destinations in document order.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos visibles en orden.'] as const, + 'path-inline-reference': ['The rejected reference is in an inline code span. Its position counts visible code spans outside already validated Path / Change rows, excluding fenced examples and hidden comments.', 'La referencia rechazada está en un fragmento de código en línea. Su posición cuenta los fragmentos visibles fuera de las filas ya validadas de Path / Change, excluyendo ejemplos delimitados y comentarios ocultos.'] as const, + 'path-prose-reference': ['The rejected reference is a file-shaped prose citation. Its position counts visible file-shaped prose references outside validated Path / Change rows, excluding link destinations and already classified code spans.', 'La referencia rechazada es una cita con forma de archivo en el texto. Su posición cuenta las citas visibles fuera de las filas validadas de Path / Change, excluyendo destinos de enlaces y fragmentos ya clasificados.'] as const, + 'path-link-reference': ['The rejected reference is a relative Markdown file destination. Its position counts actual Markdown destinations outside already validated Path / Change rows.', 'La referencia rechazada es un destino relativo de archivo en Markdown. Su posición cuenta los destinos reales fuera de las filas ya validadas de Path / Change.'] as const, 'path-reference-unsafe': ['The reference violates the existing normalized repository-path spelling rules.', 'La referencia incumple las reglas existentes de escritura normalizada de rutas del repositorio.'] as const, 'path-reference-blocked': ['The immutable inventory blocks this reference or one of its ancestors; symbolic links and submodules cannot prove regular repository content.', 'El inventario inmutable bloquea la referencia o uno de sus antecesores; los enlaces simbólicos y submódulos no demuestran contenido regular del repositorio.'] as const, 'path-reference-missing': ['The reference is absent from the immutable inventory and exact proposed-file declarations. Cite a real artifact or declare the intended new file in Path / Change.', 'La referencia no está en el inventario inmutable ni en las declaraciones exactas de archivos propuestos. Cita un artefacto real o declara el nuevo archivo previsto en Path / Change.'] as const, diff --git a/src/domain/__tests__/sdd_repository_reference.test.ts b/src/domain/__tests__/sdd_repository_reference.test.ts index 8a459032f..fe04d691c 100644 --- a/src/domain/__tests__/sdd_repository_reference.test.ts +++ b/src/domain/__tests__/sdd_repository_reference.test.ts @@ -1,7 +1,8 @@ import { SddDraftFormatFailure, validateSddMarkdown } from '../pre_branch_sdd'; import { validateSddDraftConsistency, type SddRepositoryInventory } from '../sdd_draft_consistency'; import { sddReferenceFixture } from '../../testing/sdd_reference_fixture'; -import { buildSddReferenceIndex, hasBlockedSddAncestor, isSddNonFileNotation, sddReferencePrefix } from '../sdd_repository_reference'; +import { appendSddReferenceContext, buildSddReferenceIndex, hasBlockedSddAncestor, isSddNonFileNotation, sddReferencePrefix } from '../sdd_repository_reference'; +import { isSddMarkdownLinkExample } from '../sdd_visible_references'; const inventory: SddRepositoryInventory = { files: new Set(['README.md', 'frontend/package.json', 'docs/guide.md']), @@ -136,6 +137,9 @@ it('bounds the retained label suffix while preserving arbitrary indentation of a const prefix = sddReferencePrefix(source, source.length); expect(prefix.length).toBeLessThanOrEqual(16); expect(isSddNonFileNotation('assets/main.js', prefix, buildSddReferenceIndex(inventory, new Set()))).toBe(true); + const streaming = appendSddReferenceContext('Prior prose ', '**URL**:' + ' '.repeat(4000)); + expect(streaming.length).toBeLessThanOrEqual(17); + expect(isSddNonFileNotation('assets/main.js', streaming, buildSddReferenceIndex(inventory, new Set()))).toBe(true); }); function diagnostic(notation: string) { @@ -196,7 +200,7 @@ it('locates an escaping Markdown destination without accepting traversal', () => }); it('locates an undecodable Markdown destination without echoing its bytes', () => { - const result = diagnostic('[broken](%PRIVATE)'); + const result = diagnostic('[broken](%FFPRIVATE)'); expect(result.facts).toContainEqual({ detail: 'path-link', section: 0 }); expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 1 }); expect(JSON.stringify(result)).not.toContain('PRIVATE'); @@ -234,3 +238,138 @@ it('checks a deeply segmented untrusted citation without reconstructing every pr expect(hasBlockedSddAncestor(deep, index)).toBe(false); expect(isSddNonFileNotation(deep, '', index)).toBe(false); }); + +it.each([ + '`[example](../missing.md)`', '``[example](../missing.md)``', '```[example](../missing.md)```', + '`![example](../missing.svg)`', '\\``[example](../missing.md)`', +])('treats whole inline Markdown examples as notation without granting file evidence: %s', notation => { + expect(() => validate('Example: ' + notation)).not.toThrow(); +}); + +it('rejects the exact unknown-root trailing-slash control cited by review', () => { + expect(diagnostic('`missing/dir/`').facts).toContainEqual({ detail: 'path-reference-directory', section: 0 }); +}); + +it('does not count a code example of an existing destination before an actual broken link', () => { + const result = diagnostic('`[example](../docs/guide.md)` [actual](../frontend/PRIVATE.ts)'); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 1 }); +}); + +it('accepts a literal missing-link example alongside a genuine existing Markdown destination', () => { + expect(() => validate('`[example](../missing.md)` [actual](../docs/guide.md)')).not.toThrow(); +}); + +it('keeps an explicit File role strict even when its value resembles inline Markdown', () => { + expect(() => validate('File: `[example](missing/file.md)`')).toThrow(expect.objectContaining({ reason: 'repository-path' })); +}); + +it('keeps a real missing file citation strict inside a longer backtick delimiter', () => { + expect(diagnostic('``frontend/PRIVATE.ts``').facts).toContainEqual({ detail: 'path-inline-reference', section: 0, row: 1 }); +}); + +it('does not let unmatched different delimiters hide an actual Markdown destination', () => { + const result = diagnostic('`[first](../docs/guide.md) `` [missing](../frontend/PRIVATE.ts)'); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 2 }); +}); + +it('does not let an escaped single opener hide an actual Markdown destination', () => { + const result = diagnostic('\\`[first](../docs/guide.md) [missing](../frontend/PRIVATE.ts)'); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 2 }); +}); + +it('recognizes the display value of a space-padded inline example', () => { + expect(() => validate('` [example](../missing.md) `')).not.toThrow(); +}); + +it('masks both independent inline examples without losing an actual destination after them', () => { + expect(() => validate('`[one](../missing.md)` `[two](../missing.md)` [actual](../docs/guide.md)')).not.toThrow(); +}); + +it('supports a multiline inline example using the rendered Markdown code value', () => { + expect(() => validate('`[multi\nline](../missing.md)`')).not.toThrow(); +}); + +it('checks a used reference-style destination through the same strict repository guard', () => { + const result = diagnostic('[actual][ref]\n\n[ref]: ../frontend/PRIVATE.ts'); + expect(result.facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 1 }); +}); + +it('keeps actual image destinations strict', () => { + expect(diagnostic('![actual](../frontend/PRIVATE.svg)').facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 1 }); +}); + +it('recognizes a whole inline link example with a title', () => { + expect(() => validate('`[example](../missing.md "Example title")`')).not.toThrow(); +}); + +it('does not interpret backticks inside a real destination as additional code references', () => { + const result = diagnostic('[actual](../docs/guide.md?x=`tag`) `frontend/PRIVATE.ts`'); + expect(result.facts).toContainEqual({ detail: 'path-inline-reference', section: 0, row: 1 }); +}); + +it('preserves checking of file-shaped evidence inside visible HTML attributes', () => { + expect(diagnostic('Text').facts).toContainEqual({ detail: 'path-prose-reference', section: 0, row: 1 }); +}); + +it('rejects NUL before Markdown normalization could alias an existing replacement-character filename', () => { + const populated = { ...inventory, files: new Set([...inventory.files, 'frontend/\uFFFD.ts']) }; + expect(() => validateSddDraftConsistency(sddReferenceFixture('`frontend/\0.ts`'), [], populated, 'specs/reference-control.md')) + .toThrow(expect.objectContaining({ diagnostic: { reason: 'repository-path', phase: 'validation', facts: expect.arrayContaining([ + { detail: 'path-reference-unsafe', section: 0 }, + ]) } })); +}); + +it('matches a literal-percent Markdown destination only to its actual immutable filename', () => { + const populated = { ...inventory, files: new Set([...inventory.files, 'frontend/percent%ZZ.ts']) }; + expect(() => validateSddDraftConsistency(sddReferenceFixture('[actual](../frontend/percent%ZZ.ts)'), [], populated, 'specs/reference-control.md')).not.toThrow(); +}); + +it('counts an empty same-document link before an actual broken destination', () => { + expect(diagnostic('[same]() [missing](../frontend/PRIVATE.ts)').facts).toContainEqual({ detail: 'path-link-reference', section: 0, row: 2 }); +}); + +it('does not exempt a non-whole Markdown example containing a real file citation', () => { + expect(diagnostic('`[label] then inspect frontend/PRIVATE.ts`').facts).toContainEqual({ detail: 'path-prose-reference', section: 0, row: 1 }); +}); + +it('recognizes a whole inline reference-definition example without validating its illustrative target', () => { + expect(() => validate('`[example]: ../missing.md`')).not.toThrow(); +}); + +it('keeps an explicit File value with spaces strict', () => { + expect(diagnostic('File: `frontend/private file.ts`').facts).toContainEqual({ detail: 'path-inline-reference', section: 0, row: 1 }); +}); + +it('accepts an exact immutable file with spaces inside code', () => { + const populated = { ...inventory, files: new Set([...inventory.files, 'frontend/real file.ts']) }; + expect(() => validateSddDraftConsistency(sddReferenceFixture('`frontend/real file.ts`'), [], populated, 'specs/reference-control.md')).not.toThrow(); +}); + +it('keeps a blocked ancestor with spaces strict instead of reducing it to prose fragments', () => { + const populated = { ...inventory, blocked: new Set([...inventory.blocked, 'linked directory']) }; + expect(() => validateSddDraftConsistency(sddReferenceFixture('`linked directory/PRIVATE.ts`'), [], populated, 'specs/reference-control.md')) + .toThrow(expect.objectContaining({ diagnostic: { reason: 'repository-path', phase: 'validation', facts: expect.arrayContaining([ + { detail: 'path-reference-blocked', section: 0 }, + ]) } })); +}); + +it('derives a future directory with spaces only from a validated exact new file', () => { + expect(() => validate('`frontend/new directory/`', [ + ['specs/reference-control.md', 'create'], ['README.md', 'reference'], ['frontend/new directory/entry.ts', 'create'], + ])).not.toThrow(); +}); + +it('does not carry a URL role across an explicit Markdown hard line break', () => { + expect(diagnostic('URL: \n`frontend/PRIVATE.ts`').facts).toContainEqual({ detail: 'path-reference-missing', section: 0 }); +}); + +it.each([ + '[example](../file.md)\n\nSecond paragraph.', + '[example](../file.md)\n====', +])('does not grant a whole-example role to a different Markdown block: %s', value => { + expect(isSddMarkdownLinkExample(value)).toBe(false); +}); + +it('does not exempt a real citation after a first literal link in the same code span', () => { + expect(diagnostic('`[label](/) then inspect frontend/PRIVATE.ts`').facts).toContainEqual({ detail: 'path-prose-reference', section: 0, row: 1 }); +}); diff --git a/src/domain/__tests__/sdd_validation_diagnostic.test.ts b/src/domain/__tests__/sdd_validation_diagnostic.test.ts index 9959f9c54..9c180cd88 100644 --- a/src/domain/__tests__/sdd_validation_diagnostic.test.ts +++ b/src/domain/__tests__/sdd_validation_diagnostic.test.ts @@ -92,7 +92,7 @@ describe('precise failing SDD facts (#714)', () => { ['unknown documentation operation', (s: string) => s.replace('| docs/payments.mdx | update |', '| docs/payments.mdx | delete |'), { detail: 'path-operation', section: 15, row: 1 }], ['overlapping proposed paths', (s: string) => s.replace('| specs/change.md | create |', '| specs/change.md | create |\n| specs/change.md/child | create |'), { detail: 'create-path-overlap', section: 15, row: 3 }], ['blocked proposed parent', (s: string) => s.replace('| specs/change.md | create |', '| specs/change.md | create |\n| docs/payments.mdx/child | create |'), { detail: 'create-path-parent', section: 15, row: 3 }], - ['invalid relative link', (s: string) => s + '\n[reference](%ZZ)', { detail: 'path-link', section: 0 }], + ['invalid UTF-8 relative link', (s: string) => s + '\n[reference](%FF)', { detail: 'path-link', section: 0 }], ['missing prose reference', (s: string) => s + '\nInspect `docs/PRIVATE.mdx`.', { detail: 'path-reference', section: 0 }], ] as const)('reports the actual failing constraint for %s', (_label, change, fact) => { try { diff --git a/src/domain/sdd_draft_consistency.ts b/src/domain/sdd_draft_consistency.ts index 19a6f9d25..a86099804 100644 --- a/src/domain/sdd_draft_consistency.ts +++ b/src/domain/sdd_draft_consistency.ts @@ -1,8 +1,9 @@ import type { SddValidationFact } from './sdd_validation_diagnostic'; import { SddDraftFormatFailure } from './pre_branch_sdd'; -import { buildSddReferenceIndex, hasBlockedSddAncestor, isSafeSddRepositoryPath as safePath, +import { appendSddReferenceContext, buildSddReferenceIndex, hasBlockedSddAncestor, isKnownSddRepositoryReference, isSddFileRole, isSafeSddRepositoryPath as safePath, isSddNonFileNotation, sddReferencePrefix, type SddRepositoryInventory } from './sdd_repository_reference'; export type { SddRepositoryInventory } from './sdd_repository_reference'; +import { readSddVisibleReferences } from './sdd_visible_references'; export type SddConsistencyReason = 'case-identity' | 'case-budget' | 'requirement-mapping' | 'repository-path'; export interface SddOwnerDocument { readonly path: string; readonly markdown: string } @@ -75,7 +76,7 @@ function section(source: string, number: Exclude, headers: readonly string[], reason: SddConsistencyReason): string[][] { +function table(source: string, number: Exclude, headers: readonly string[], reason: SddConsistencyReason, rowSources?: Set): string[][] { const lines = section(source, number, reason).split('\n'); const found: string[][][] = []; for (let index = 0; index < lines.length; index++) { @@ -91,6 +92,7 @@ function table(source: string, number: Exclude, if (row.length !== headers.length) return fail(reason, { detail: 'table-columns', section: number, row: rows.length + 1, expected: headers.length, observed: row.length }); if (row.some(cell => !cell)) return fail(reason, { detail: 'table-empty-cell', section: number, row: rows.length + 1 }); rows.push(row); + rowSources?.add(lines[index]); } if (!rows.length) return fail(reason, { detail: 'table-empty', section: number, observed: 0 }); found.push(rows); @@ -230,7 +232,8 @@ export function validateSddDraftConsistency( } function validatePaths(source: string, inventory: SddRepositoryInventory, sddPath: string): void { - const rows = table(source, 15, ['Path', 'Change'], 'repository-path'); + const declarationRows = new Set(); + const rows = table(source, 15, ['Path', 'Change'], 'repository-path', declarationRows); const expectedChange = inventory.files.has(sddPath) ? 'update' : 'create'; if (!rows.some(([file, change]) => file === sddPath && change === expectedChange)) fail('repository-path', { detail: 'owner-path', section: 15 }); const proposed = new Set(); @@ -247,6 +250,13 @@ function validatePaths(source: string, inventory: SddRepositoryInventory, sddPat } else if (!['update', 'reference'].includes(change) || !inventory.files.has(file)) fail('repository-path', { detail: 'path-operation', section: 15, row: seen.size }); } const referenceIndex = buildSddReferenceIndex(inventory, proposed); + // CommonMark replaces NUL with U+FFFD. Never let that normalization establish + // another repository identity from unsafe visible source spelling. + if (source.includes('\0')) fail('repository-path', { detail: 'path-reference', section: 0 }, [{ detail: 'path-reference-unsafe', section: 0 }]); + // These exact two-cell rows have already received strict declaration checks. + // Do not reinterpret their file identities as prose suffixes or Markdown links. + const referenceSource = source.split('\n').map(line => declarationRows.has(line) ? ' '.repeat(line.length) : line).join('\n'); + const references = readSddVisibleReferences(referenceSource); type Origin = 'path-inline-reference' | 'path-prose-reference' | 'path-link-reference'; const reject = (origin: Origin, ordinal: number, detail: 'path-reference-unsafe' | 'path-reference-blocked' | 'path-reference-missing' | 'path-reference-directory') => fail('repository-path', { detail: 'path-reference', section: 0 }, @@ -260,9 +270,9 @@ function validatePaths(source: string, inventory: SddRepositoryInventory, sddPat }; // Relative Markdown file links resolve against the selected SDD, never the current checkout. let linkOrdinal = 0; - for (const match of source.matchAll(/\]\(([^\s)]+)\)/gu)) { + for (const destination of references.destinations) { linkOrdinal++; - const href = match[1].split(/[?#]/u)[0]; + const href = destination.split(/[?#]/u)[0]; if (!href || /^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(href)) continue; let decoded: string; try { decoded = decodeURIComponent(href); } catch { @@ -281,16 +291,22 @@ function validatePaths(source: string, inventory: SddRepositoryInventory, sddPat check(parts.join('/'), 'path-link-reference', linkOrdinal, directory); } let inlineOrdinal = 0; - const prose = source.replace(/`([^`\n]+)`/gu, (span: string, value: string, offset: number) => { + let text = ''; + let context = ''; + for (const part of references.parts) { + if (part.kind === 'text') { text += part.value; context = appendSddReferenceContext(context, part.value); continue; } inlineOrdinal++; - if (!value.includes('/')) return span; - if (isSddNonFileNotation(value, sddReferencePrefix(source, offset), referenceIndex)) return ' '.repeat(span.length); - if (!/\s/u.test(value)) { + const value = part.value; + const prefix = sddReferencePrefix(context, context.length); + context = '`'; // A prior code span cannot become the next value's URL/File label. + if (!value.includes('/') || isSddNonFileNotation(value, prefix, referenceIndex)) { + text += ' '.repeat(value.length + 2); + } else if (!/\s/u.test(value) || isSddFileRole(prefix) || isKnownSddRepositoryReference(value, referenceIndex)) { check(value.replace(/\/$/u, ''), 'path-inline-reference', inlineOrdinal, value.endsWith('/')); - return ' '.repeat(span.length); - } - return span; - }).replace(/\]\([^\s)]+\)/gu, ']()').replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); + text += ' '.repeat(value.length + 2); + } else text += '`' + value + '`'; + } + const prose = text.replace(/[a-z][a-z0-9+.-]*:\/\/[^\s)>]+/giu, ''); let proseOrdinal = 0; for (const match of prose.matchAll(/(?; @@ -61,17 +63,26 @@ function urlRole(prefix: string): boolean { return /\b(?:(?:URL|URI)(?:[ \t]+path)?|HTTP(?:[ \t]+(?:endpoint|route))?)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); } +export function isSddFileRole(prefix: string): boolean { + return /\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, '')); +} + +export function isKnownSddRepositoryReference(value: string, index: SddReferenceIndex): boolean { + const path = value.replace(/\/$/u, ''); + return index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) + || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index); +} + /** * Not a protocol validator or an existence grant. Explicit Markdown destinations * and Path/Change rows bypass this heuristic and always receive strict checks. */ export function isSddNonFileNotation(value: string, prefix: string, index: SddReferenceIndex): boolean { if (/^(?:[a-z][a-z0-9+.-]*:|\/)/iu.test(value)) return true; - const path = value.replace(/\/$/u, ''); - if (index.inventory.files.has(path) || index.inventory.directories.has(path) || index.proposed.has(path) - || index.futureDirectories.has(path) || hasBlockedSddAncestor(path, index)) return false; + if (isKnownSddRepositoryReference(value, index)) return false; if (!/\s|[\p{Cc}]/u.test(value) && urlRole(prefix)) return true; - if (/\b(?:file|path|archivo|ruta)\s*[:=]?\s*$/iu.test(prefix.replace(/[*_]/gu, ''))) return false; + if (isSddFileRole(prefix)) return false; + if (isSddMarkdownLinkExample(value)) return true; if (mediaNotation(value)) return true; if (/^(?:GET|HEAD|POST|PUT|PATCH|DELETE|OPTIONS|TRACE|CONNECT)\s+\S+$/u.test(value)) return true; if (/^@[a-z0-9][a-z0-9._-]*\/[a-z0-9][a-z0-9._-]*(?:\/[a-z0-9_][a-z0-9._-]*)*$/iu.test(value) @@ -89,3 +100,9 @@ export function sddReferencePrefix(source: string, offset: number): string { const tail = source.slice(Math.max(0, end - 16), end); return tail.slice(tail.lastIndexOf('\n') + 1); } + +/** Keep streaming label context bounded, including a separator across formatted text leaves. */ +export function appendSddReferenceContext(previous: string, value: string): string { + const combined = previous + value; + return sddReferencePrefix(combined, combined.length) + (/[ \t*_: =]$/u.test(combined) ? ' ' : ''); +} diff --git a/src/domain/sdd_visible_references.ts b/src/domain/sdd_visible_references.ts new file mode 100644 index 000000000..d19d465fb --- /dev/null +++ b/src/domain/sdd_visible_references.ts @@ -0,0 +1,36 @@ +import { Parser } from 'commonmark'; + +export type SddReferencePart = Readonly<{ kind: 'text' | 'code'; value: string }>; +export interface SddVisibleReferences { + readonly destinations: readonly string[]; + readonly parts: readonly SddReferencePart[]; +} + +/** Pure syntax projection; callers first remove the gate's hidden comments/fences. */ +export function readSddVisibleReferences(source: string): SddVisibleReferences { + const destinations: string[] = []; + const parts: SddReferencePart[] = []; + const walker = new Parser().parse(source).walker(); + let event; + while ((event = walker.next())) { + const { node, entering } = event; + if (entering) { + if (node.type === 'link' || node.type === 'image') destinations.push(node.destination!); + if (node.type === 'code') parts.push({ kind: 'code', value: node.literal! }); + if (['text', 'html_inline', 'html_block'].includes(node.type)) parts.push({ kind: 'text', value: node.literal! }); + if (node.type === 'softbreak' || node.type === 'linebreak') parts.push({ kind: 'text', value: '\n' }); + } else if (['paragraph', 'heading', 'item'].includes(node.type)) parts.push({ kind: 'text', value: '\n' }); + } + return { destinations, parts }; +} + +/** A whole literal link/image/reference definition is illustrative syntax, never an existence grant. */ +export function isSddMarkdownLinkExample(value: string): boolean { + if (!/^!?\[/u.test(value)) return false; + const document = new Parser().parse(value); + const paragraph = document.firstChild; + if (!paragraph) return true; // Valid reference definitions have no rendered block. + const child = paragraph.firstChild!; // A rendered CommonMark paragraph is nonempty. + return paragraph.type === 'paragraph' && paragraph === document.lastChild + && (child.type === 'link' || child.type === 'image') && child === paragraph.lastChild; +} 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 0c82bf6f2..605786472 100644 --- a/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts +++ b/src/infrastructure/__tests__/pre_branch_sdd_workspace_adapter.test.ts @@ -80,7 +80,7 @@ describe('PreBranchSddWorkspaceAdapter with a local bare remote', () => { const snapshot = await workspace.loadSnapshot('develop'); const selected = { ...plan, action, path: action === 'update' ? plan.path : 'specs/companion.md' }; const before = fs.readFileSync(path.join(repo, plan.path), 'utf8'); - const notation = '\nHTTP endpoint: `api/v1/session`\nMedia type: `application/json`\nURL: `assets/main.js`\nDeployment alternatives: `native/standalone`\n'; + const notation = '\nHTTP endpoint: `api/v1/session`\nMedia type: `application/json`\nURL: `assets/main.js`\nDeployment alternatives: `native/standalone`\nExample: `[illustrative](../missing.md)`\n[Actual guide](../docs/payments.mdx)\n'; const prepared = await workspace.validateDraft(snapshot, selected, draft(selected.path, action) + notation); expect(prepared.markdown).toContain(notation.trim()); expect(fs.readFileSync(path.join(repo, plan.path), 'utf8')).toBe(before);