diff --git a/project/ticket-104/README.md b/project/ticket-104/README.md new file mode 100644 index 0000000..474ebbd --- /dev/null +++ b/project/ticket-104/README.md @@ -0,0 +1,21 @@ +# Ticket 104: Preserve canonical conflict evidence during code extraction + +- **ID**: ticket-104 +- **Owner**: human:founder +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-09-10 + +SESSION_EXECUTION_AUTHORIZATION: napraw, zmerguj, przetestuj; continue the remaining autonomy acceptance through protected publication. + +## Goal and scope + +A Python file containing a complete merge conflict currently produces zero canonical records and cannot be edited through SubLLM. Add source-bound conflict facts using the existing DSL contract and ignore/size boundaries. A marker block is observed evidence, not proof of Git index state or authority to pick a side. + +## Acceptance criteria + +- [x] AC-01: Standard and diff3 blocks retain exact source ranges, stable identity and observed provenance even when AST parsing fails. +- [x] AC-02: Malformed or nested blocks fail closed; ignored, binary and oversized files cannot become fallback context. +- [ ] AC-03: Existing verification passes; an independently merged extractor is qualified through SubLLM with source-hash guarded edits and a negative stale-source case. + +Cross-repository evidence belongs in the [canonical autonomy receipt](https://github.com/subactor/docs/blob/main/architecture/analysis/autonomy-execution-receipt.md). diff --git a/project/ticket-104/intent.json b/project/ticket-104/intent.json new file mode 100644 index 0000000..228e49e --- /dev/null +++ b/project/ticket-104/intent.json @@ -0,0 +1,79 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-104", + "summary": "Preserve canonical conflict evidence during code extraction", + "workstream": "extractors", + "classification": { + "kind": "BUG", + "priority": "P1", + "origin": "requested" + }, + "allowedPaths": [ + "project/ticket-104/**", + "src/extractors/ast.ts", + "src/extractors/merge-conflicts.ts", + "test/ast-merge-conflicts.test.ts" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [ + "node" + ], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "1c82ce8e7a7c2d5310302bc5c69a093702560dfc", + "targetBranch": "main", + "outcome": "Represent well-formed conflict blocks as canonical source-bound facts so bounded code editing can repair syntax-invalid files.", + "nonGoals": [ + "No source-text fallback or automatic choice of merge side", + "No ignored files, credentials, budget increases or direct merge" + ], + "complexity": "M", + "estimatedMinutes": 90, + "budgets": { + "maxImplementationFiles": 3, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "Supplement public code2dsl with validated conflict-marker grammar records, distinct from AST facts; preserve exact ranges and existing extraction boundaries.", + "components": [ + { + "name": "canonical-conflict-extraction", + "paths": [ + "src/extractors/ast.ts", + "src/extractors/merge-conflicts.ts", + "test/ast-merge-conflicts.test.ts" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [ + "Add observed merge_conflict_fact records using the existing t2c.intent/v1 schema." + ], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "Revert the additive extractor hook through protected publication; retain prior pinned runtime." + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-01", + "commands": [ + "npm run verify", + "./project/governance-check.sh --actor agent --base origin/main" + ], + "evidence": "Five targeted regressions passed; SubLLM extracted one canonical conflict fact, applied a valid edit and rejected a stale-source replay without LLM calls. Full verification and protected publication tracked separately." + } + ] + } +} diff --git a/src/extractors/ast.ts b/src/extractors/ast.ts index 94467f5..9b9aaf1 100644 --- a/src/extractors/ast.ts +++ b/src/extractors/ast.ts @@ -14,6 +14,7 @@ import { extractPythonAst } from './ast/python.js'; import { extractRustAst } from './ast/rust.js'; import { extractTypeScriptFile, JS_EXTENSIONS, TYPESCRIPT_AST_CACHE_IDENTITY } from './ast/typescript.js'; import { unsupportedSourceWarning } from './ast/unsupported.js'; +import { extractMergeConflicts } from './merge-conflicts.js'; export interface AstExtractionOptions { root: string; @@ -34,6 +35,9 @@ export async function code2dsl( ): Promise { const root = requireStandaloneRoot(options?.root, 'code2dsl'); const result = await extractAstIntent({ root }, config); + const conflicts = await extractMergeConflicts(root, config); + result.records.push(...conflicts.records); + result.warnings.push(...conflicts.warnings); assertIntentRecords(result.records); return result; } diff --git a/src/extractors/merge-conflicts.ts b/src/extractors/merge-conflicts.ts new file mode 100644 index 0000000..58dd91d --- /dev/null +++ b/src/extractors/merge-conflicts.ts @@ -0,0 +1,90 @@ +import type { T2CConfig } from '../config/env.js'; +import { createIntentId, sha256 } from '../core/id.js'; +import { loadIgnoreMatcher } from '../core/ignore.js'; +import { readText, relativePosix, walkFiles } from '../core/io.js'; +import { buildRecord } from '../core/record.js'; +import type { ExtractionResult, IntentRecord } from '../core/types.js'; + +// Conflict grammar is independent of the language grammar it temporarily breaks. +// Restrict it to source/document/configuration formats accepted by the code editor. +const EXTENSIONS = ['.py', '.js', '.mjs', '.cjs', '.ts', '.tsx', '.jsx', '.go', '.java', + '.php', '.rs', '.md', '.json', '.toml', '.yaml', '.yml']; +interface Block { start: number; end: number; width: number; style: 'merge' | 'diff3' } +interface Marker { kind: string; width: number } + +function marker(line: string): Marker | null { + const match = /^([<|=>])\1{6,63}(?:[ \t].*)?$/.exec(line.replace(/\r$/, '')); + if (!match) return null; + const kind = match[1]!; + const width = line.match(/^[<|=>]+/)![0].length; + // A separator has no label. Labels are evidence, never paths or commands. + if (kind === '=' && line.slice(width).trim()) return null; + return { kind, width }; +} + +/** Parse complete, nonnested blocks; malformed input yields no partial facts. */ +function blocks(lines: string[]): Block[] | null { + const result: Block[] = []; + let current: { start: number; width: number; phase: 'ours' | 'base' | 'theirs'; style: Block['style'] } | null = null; + for (let index = 0; index < lines.length; index++) { + const token = marker(lines[index]!); + if (!token) continue; + if (!current) { + if (token.kind === '<') current = { start: index + 1, width: token.width, phase: 'ours', style: 'merge' }; + continue; + } + if (token.width !== current.width || token.kind === '<') return null; + if (token.kind === '|' && current.phase === 'ours') { + current.phase = 'base'; + current.style = 'diff3'; + } else if (token.kind === '=' && current.phase !== 'theirs') { + current.phase = 'theirs'; + } else if (token.kind === '>' && current.phase === 'theirs') { + result.push({ start: current.start, end: index + 1, width: current.width, style: current.style }); + current = null; + } else return null; + } + return current ? null : result; +} + +function conflictRecords(relative: string, body: string): IntentRecord[] | null { + const lines = body.split('\n'); + const parsed = blocks(lines); + if (!parsed) return null; + return parsed.map((block) => { + const excerpt = lines.slice(block.start - 1, block.end).join('\n'); + const blockHash = sha256(excerpt); + const record = buildRecord({ + kind: 'merge_conflict_fact', action: 'block', object: 'unresolved conflict marker block', + target: { paths: [relative], symbols: [] }, modality: 'observed', + text: `conflict marker block in ${relative}:${block.start}-${block.end}`, + lifecycle: 'blocked', sourceKind: 'git', sourcePath: relative, + sourceLines: { start: block.start, end: block.end }, extractor: 't2c/merge-conflict-markers@1', + rawExcerpt: excerpt.slice(0, 2000), + epistemicClass: 'fact', confidence: 1, basis: ['complete_git_conflict_marker_grammar'], + metadata: { llmUsed: false, gitIndexVerified: false, conflictStyle: block.style, markerWidth: block.width, blockSha256: blockHash }, + }); + return { ...record, id: createIntentId({ recordId: record.id, blockHash }, 'INT-GIT') }; + }); +} + +/** Observed marker syntax, not an assertion about Git state or a resolution choice. */ +export async function extractMergeConflicts(root: string, config: T2CConfig): Promise { + const records: IntentRecord[] = []; + const warnings: string[] = []; + const matcher = await loadIgnoreMatcher(root); + const files = await walkFiles(root, { extensions: EXTENSIONS, maxFiles: 20_000, matcher }); + for (const file of files) { + const relative = relativePosix(root, file); + try { + const body = await readText(file, config.maxFileBytes); + if (body.includes('\0')) continue; + const extracted = conflictRecords(relative, body); + if (extracted === null) warnings.push(`${relative}: malformed merge conflict markers; no conflict evidence emitted`); + else records.push(...extracted); + } catch { + warnings.push(`${relative}: conflict evidence could not be read within the source boundary`); + } + } + return { records, warnings }; +} diff --git a/test/ast-merge-conflicts.test.ts b/test/ast-merge-conflicts.test.ts new file mode 100644 index 0000000..fb1d27c --- /dev/null +++ b/test/ast-merge-conflicts.test.ts @@ -0,0 +1,89 @@ +import assert from 'node:assert/strict'; +import { promises as fs } from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import test, { type TestContext } from 'node:test'; +import { code2dsl } from '../src/extractors/ast.js'; +import { assertIntentRecords } from '../src/core/schema.js'; +import { makeConfig } from './helpers.js'; + +async function workspace(t: TestContext) { + const root = await fs.mkdtemp(path.join(os.tmpdir(), 't2c-conflicts-')); + t.after(() => fs.rm(root, { recursive: true, force: true })); + const config = makeConfig(root); + config.cacheEnabled = false; + return { root, config }; +} +const conflict = ['<<<<<<< HEAD', ' return 1', '=======', ' return 2', '>>>>>>> main'].join('\n'); + +test('code2dsl retains source-bound conflict facts when Python AST parsing fails', async (t) => { + const { root, config } = await workspace(t); + await fs.writeFile(path.join(root, 'broken.py'), `def choose():\n${conflict}\n`); + const result = await code2dsl({ root }, config); + assertIntentRecords(result.records); + const records = result.records.filter((record) => record.statement.kind === 'merge_conflict_fact'); + assert.equal(records.length, 1); + const record = records[0]!; + assert.deepEqual(record.source.lines, { start: 2, end: 6 }); + assert.equal(record.source.rawExcerpt, conflict); + assert.equal(record.source.kind, 'git'); + assert.equal(record.epistemic.class, 'fact'); + assert.equal(record.metadata.llmUsed, false); + assert.equal(record.metadata.gitIndexVerified, false); + assert.equal(record.metadata.conflictStyle, 'merge'); + assert.ok(result.warnings.length > 0, 'AST syntax failure remains visible'); + const repeated = await code2dsl({ root }, config); + assert.equal(repeated.records.find((r) => r.statement.kind === 'merge_conflict_fact')?.id, record.id); + await fs.writeFile(path.join(root, 'broken.py'), `def choose():\n${conflict.replace('return 2', 'return 3')}\n`); + const changed = await code2dsl({ root }, config); + assert.notEqual(changed.records.find((r) => r.statement.kind === 'merge_conflict_fact')?.id, record.id); +}); + +test('code2dsl handles diff3, multiple blocks, custom widths and CRLF without choosing a side', async (t) => { + const { root, config } = await workspace(t); + const diff3 = ['<<<<<<<<<< ours', 'one', '|||||||||| base', 'zero', '==========', 'two', '>>>>>>>>>> theirs']; + await fs.writeFile(path.join(root, 'settings.toml'), [...diff3, 'unrelated = true', ...diff3].join('\r\n')); + const records = (await code2dsl({ root }, config)).records.filter((r) => r.statement.kind === 'merge_conflict_fact'); + assert.deepEqual(records.map((r) => r.source.lines), [{ start: 1, end: 7 }, { start: 9, end: 15 }]); + assert.ok(records.every((r) => r.metadata.conflictStyle === 'diff3' && r.metadata.markerWidth === 10)); + assert.equal(records[0]!.source.rawExcerpt, diff3.join('\r\n') + '\r'); + assert.notEqual(records[0]!.id, records[1]!.id); +}); + +test('malformed and nested blocks never become partial editable conflict evidence', async (t) => { + const { root, config } = await workspace(t); + const bodies = [conflict.replace('=======', '========'), conflict.replace('=======', ''), + conflict.replace(' return 1', '<<<<<<< nested'), conflict.replace('>>>>>>> main', ''), + `${conflict}\n<<<<<<< unfinished`, conflict.replace('=======', '||||||| base\n||||||| duplicate\n=======')]; + for (let index = 0; index < bodies.length; index++) await fs.writeFile(path.join(root, `bad${index}.py`), bodies[index]!); + const result = await code2dsl({ root }, config); + assert.equal(result.records.filter((r) => r.statement.kind === 'merge_conflict_fact').length, 0); + assert.equal(result.warnings.filter((w) => w.includes('malformed merge conflict markers')).length, bodies.length); +}); + +test('conflict extraction respects ignore, file-size and binary boundaries', async (t) => { + const { root, config } = await workspace(t); + config.maxFileBytes = 200; + await fs.writeFile(path.join(root, '.intentignore'), 'ignored.py\n'); + await fs.writeFile(path.join(root, 'ignored.py'), conflict); + await fs.writeFile(path.join(root, 'binary.py'), `${conflict}\n\0`); + await fs.writeFile(path.join(root, 'large.py'), `${conflict}\n${'x'.repeat(201)}`); + await fs.writeFile(path.join(root, 'plain.py'), 'def invalid(:\n'); + const result = await code2dsl({ root }, config); + assert.equal(result.records.filter((r) => r.statement.kind === 'merge_conflict_fact').length, 0); + assert.ok(result.warnings.some((w) => w.includes('large.py'))); +}); + + +test('bounded excerpts still bind changes beyond their visible prefix', async (t) => { + const { root, config } = await workspace(t); + const body = ['<<<<<<< HEAD', 'x'.repeat(2100), '=======', 'old tail', '>>>>>>> main'].join('\n'); + await fs.writeFile(path.join(root, 'large.toml'), body); + const first = (await code2dsl({ root }, config)).records.find((r) => r.statement.kind === 'merge_conflict_fact')!; + await fs.writeFile(path.join(root, 'large.toml'), body.replace('old tail', 'new tail')); + const second = (await code2dsl({ root }, config)).records.find((r) => r.statement.kind === 'merge_conflict_fact')!; + assert.equal(first.source.rawExcerpt!.length, 2000); + assert.equal(first.source.rawExcerpt, second.source.rawExcerpt); + assert.notEqual(first.id, second.id); + assert.notEqual(first.metadata.blockSha256, second.metadata.blockSha256); +});