Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 21 additions & 0 deletions project/ticket-104/README.md

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

79 changes: 79 additions & 0 deletions project/ticket-104/intent.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 4 additions & 0 deletions src/extractors/ast.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -34,6 +35,9 @@ export async function code2dsl(
): Promise<CachedExtractionResult> {
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;
}
Expand Down
90 changes: 90 additions & 0 deletions src/extractors/merge-conflicts.ts
Original file line number Diff line number Diff line change
@@ -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<ExtractionResult> {
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 };
}
89 changes: 89 additions & 0 deletions test/ast-merge-conflicts.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
Loading