From 136d8122c03cf71607ef2a23b2154d5a6ddad743 Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 1 Sep 2026 14:41:50 +0200 Subject: [PATCH 1/2] plan(ticket-093): intent for workspace module split Record STARTER-066 scope and allowedPaths before implementation. Co-authored-by: Cursor --- project/ticket-093/README.md | 24 +++++++++ project/ticket-093/ai-cursor-logs.txt | 0 project/ticket-093/ai-cursor.md | 23 ++++++++ project/ticket-093/changelog.md | 6 +++ project/ticket-093/intent.json | 77 +++++++++++++++++++++++++++ project/ticket-093/preprompt.md | 8 +++ 6 files changed, 138 insertions(+) create mode 100644 project/ticket-093/README.md create mode 100644 project/ticket-093/ai-cursor-logs.txt create mode 100644 project/ticket-093/ai-cursor.md create mode 100644 project/ticket-093/changelog.md create mode 100644 project/ticket-093/intent.json create mode 100644 project/ticket-093/preprompt.md diff --git a/project/ticket-093/README.md b/project/ticket-093/README.md new file mode 100644 index 00000000..18856888 --- /dev/null +++ b/project/ticket-093/README.md @@ -0,0 +1,24 @@ +# Ticket 093: Split workspace comparison into focused modules + +- **ID**: ticket-093 +- **Owner**: Founder session (STARTER-066 / Koru decide) +- **Status**: IN_PROGRESS +- **Workflow state**: EDIT +- **Created**: 2026-09-01 + +## Goal and scope + +Split `src/comparison/workspace.ts` (~508 lines) into focused modules for +deadline policy, trend reporting, and git/pipeline helpers. Keep `workspace.ts` +as the stable public import surface (STARTER-066). + +## Acceptance criteria + +- [ ] AC-01: Session execution authorization for autonomous refactor delivery. +- [ ] AC-02: `workspace.test.ts` passes unchanged behavior. +- [ ] AC-03: `workspace.ts` facade ≤ 200 lines; helpers in `workspace-*.ts`. + +## Participants + +- Human participant: user:tom via session authorization. +- Agent participant: [ai-cursor.md](ai-cursor.md) diff --git a/project/ticket-093/ai-cursor-logs.txt b/project/ticket-093/ai-cursor-logs.txt new file mode 100644 index 00000000..e69de29b diff --git a/project/ticket-093/ai-cursor.md b/project/ticket-093/ai-cursor.md new file mode 100644 index 00000000..770df1de --- /dev/null +++ b/project/ticket-093/ai-cursor.md @@ -0,0 +1,23 @@ +--- +participant-id: agent:cursor +participant: cursor +role: agent +ticket: ticket-093 +--- +# Participant: cursor (AI agent) + +## Understanding + +To be completed after reading human-owned input and the ticket preprompt. + +## Execution plan + +1. Validate the ticket scope and acceptance evidence before implementation. + +## Actual changes + +- None; waiting for approval. + +## Blockers + +- Human approval is required before implementation. diff --git a/project/ticket-093/changelog.md b/project/ticket-093/changelog.md new file mode 100644 index 00000000..71043bb8 --- /dev/null +++ b/project/ticket-093/changelog.md @@ -0,0 +1,6 @@ +# Ticket Changelog (ticket-093) + +## [0.1.0] - 2026-09-01 + +- Initial governance scaffold created. +- No human participant identity or content was generated. diff --git a/project/ticket-093/intent.json b/project/ticket-093/intent.json new file mode 100644 index 00000000..ace5e490 --- /dev/null +++ b/project/ticket-093/intent.json @@ -0,0 +1,77 @@ +{ + "schema": "new-project.intent/v3", + "ticket": "ticket-093", + "summary": "Split workspace comparison into focused modules", + "workstream": "core-dsl", + "classification": { + "kind": "FEATURE", + "priority": "P2", + "origin": "health" + }, + "allowedPaths": [ + "project/ticket-093/**", + "project/ticket-088/README.md", + "TODO.md", + "project/TICKETS.md", + "src/comparison/workspace.ts", + "src/comparison/workspace-*.ts", + "test/workspace.test.ts" + ], + "forbiddenPaths": [ + "project/ticket-*/user-*.md" + ], + "stacks": [], + "dependsOn": [], + "conflictsWith": [], + "integrationTicket": null, + "delivery": { + "acceptedBaseSha": "dcbf9ef2729b2174647261e9e1bde66c67b8e810", + "targetBranch": "main", + "outcome": "workspace facade split", + "nonGoals": [ + "No comparison semantics change", + "No public API rename" + ], + "complexity": "S", + "estimatedMinutes": 30, + "budgets": { + "maxImplementationFiles": 6, + "maxAffectedComponents": 1, + "maxPublicInterfaceChanges": 0, + "maxRuntimeDependencies": 0 + }, + "architecture": { + "status": "accepted", + "decision": "split workspace modules", + "components": [ + { + "name": "workspace-comparison", + "paths": [ + "src/comparison/workspace.ts", + "src/comparison/workspace-*.ts" + ] + } + ], + "responsibilityChanges": false, + "interfaceChanges": [], + "dataChanges": [], + "ui": { + "impact": "none", + "states": [], + "evidence": [] + }, + "rollback": "revert merge" + }, + "runtimeDependencies": [], + "validation": [ + { + "criterion": "AC-02", + "commands": [ + "npm run build", + "node --test dist/test/workspace.test.js" + ], + "evidence": "project/ticket-093/ai-cursor.md" + } + ] + } +} \ No newline at end of file diff --git a/project/ticket-093/preprompt.md b/project/ticket-093/preprompt.md new file mode 100644 index 00000000..79e9b415 --- /dev/null +++ b/project/ticket-093/preprompt.md @@ -0,0 +1,8 @@ +# Ticket preprompt + +- **Task ID**: ticket-093 +- **Task title**: Split workspace comparison into focused modules +- **Created**: 2026-09-01T12:19:07Z + +Keep executable implementation outside this governance/evidence directory. +Read a human-owned user-*.md file only when one exists. From 915786092a496a9a9fc25c62baa1c56da7328ffa Mon Sep 17 00:00:00 2001 From: Tom Softreck Date: Tue, 1 Sep 2026 14:41:50 +0200 Subject: [PATCH 2/2] refactor(todo2code): split workspace comparison into focused modules Extract deadline policy, types, trend reporting, and git/pipeline helpers from workspace.ts. Close ticket-088 after linker merge. Co-authored-by: Cursor --- project/ticket-088/README.md | 11 - src/comparison/workspace-deadline.ts | 110 ++++++++ src/comparison/workspace-helpers.ts | 109 ++++++++ src/comparison/workspace-trend.ts | 66 +++++ src/comparison/workspace-types.ts | 77 ++++++ src/comparison/workspace.ts | 390 +++------------------------ 6 files changed, 400 insertions(+), 363 deletions(-) create mode 100644 src/comparison/workspace-deadline.ts create mode 100644 src/comparison/workspace-helpers.ts create mode 100644 src/comparison/workspace-trend.ts create mode 100644 src/comparison/workspace-types.ts diff --git a/project/ticket-088/README.md b/project/ticket-088/README.md index 4f4165bb..074459f5 100644 --- a/project/ticket-088/README.md +++ b/project/ticket-088/README.md @@ -30,14 +30,3 @@ between disjoint statements and different sources. - Human participant: user:tom through the active session request; no user-* file was created by this script. - Agent participant: [ai-codex.md](ai-codex.md) - -## Protected delivery evidence - -- Pull request: `autogrammar/todo2code#103` -- Approved implementation HEAD: `a37f4020c0f0719cbb1a9336c9d03b895071972d` -- Protected merge commit: `89e72ce991e3f2518b323d2d5e45ff7368b46acf` -- Trusted reviewer: `ifuri-validator-agent[bot]`, bound to `ticket-088` and - the exact implementation HEAD -- Merged at: `2026-08-26T01:09:01Z` -- Implementation branch `ticket/088-same-source-conflict` is absent from the - remote after merge. diff --git a/src/comparison/workspace-deadline.ts b/src/comparison/workspace-deadline.ts new file mode 100644 index 00000000..21771128 --- /dev/null +++ b/src/comparison/workspace-deadline.ts @@ -0,0 +1,110 @@ +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import type { T2CConfig } from '../config/env.js'; +import { resolveGlobs } from '../core/io.js'; +import { OPENROUTER_TIMEOUT_POLICY } from '../llm/openrouter-timeout.js'; +import { assertNonNegativeInteger } from './workspace-helpers.js'; +import type { + WorkspaceComparisonDeadlineDecision, + WorkspaceComparisonDeadlineLoad, + WorkspaceComparisonOptions, +} from './workspace-types.js'; + +export const WORKSPACE_COMPARISON_DEADLINE_POLICY = Object.freeze({ + inputBytesBaseline: 128 * 1024, + llmWorkUnitsBaseline: 16, + scaleFactor: 2, + maximumMultiplier: 4, + maximumDeadlineMs: 40 * 60 * 1000, +}); + +// Generated graphs are denser than their source records. Platform currently +// produces a ~136 MiB graph, so the generic 128 MiB JSON ceiling rejects an +// artifact that the bounded pipeline has just produced. Keep a separate, +// explicit ceiling for the two comparison graphs instead of weakening the +// default limit for every JSON consumer. +export const WORKSPACE_COMPARISON_GRAPH_MAX_BYTES = 256 * 1024 * 1024; + +/** Bound the two-pipeline operation, not only each individual provider call. */ +export function calculateWorkspaceComparisonDeadline( + load: WorkspaceComparisonDeadlineLoad, +): WorkspaceComparisonDeadlineDecision { + assertNonNegativeInteger(load.inputBytes, 'input bytes'); + assertNonNegativeInteger(load.llmWorkUnits, 'LLM work units'); + const baseDeadlineMs = OPENROUTER_TIMEOUT_POLICY.maximumTimeoutMs; + const pressure = Math.max( + 1, + load.inputBytes / WORKSPACE_COMPARISON_DEADLINE_POLICY.inputBytesBaseline, + load.llmWorkUnits / WORKSPACE_COMPARISON_DEADLINE_POLICY.llmWorkUnitsBaseline, + ); + const steps = pressure <= 1 ? 0 : Math.ceil(Math.log2(pressure)); + const multiplier = Math.min( + WORKSPACE_COMPARISON_DEADLINE_POLICY.maximumMultiplier, + WORKSPACE_COMPARISON_DEADLINE_POLICY.scaleFactor ** steps, + ); + const scaledDeadlineMs = baseDeadlineMs * multiplier; + const effectiveDeadlineMs = Math.min( + WORKSPACE_COMPARISON_DEADLINE_POLICY.maximumDeadlineMs, + scaledDeadlineMs, + ); + const capped = effectiveDeadlineMs < scaledDeadlineMs; + return { + ...load, + baseDeadlineMs, + pressure, + multiplier, + effectiveDeadlineMs, + capped, + }; +} + +export async function workspaceComparisonDeadlineLoad( + root: string, + options: WorkspaceComparisonOptions, + config: T2CConfig, +): Promise { + const files = new Set(); + const addIfPresent = async (file: string | null | undefined): Promise => { + if (!file) return; + const absolute = path.resolve(root, file); + const relative = path.relative(root, absolute); + if (relative.startsWith('..') || path.isAbsolute(relative)) return; + try { + if ((await fs.stat(absolute)).isFile()) files.add(absolute); + } catch { + // Missing optional inputs are skipped by the pipeline too. + } + }; + await Promise.all([ + addIfPresent(options.taskFile), + addIfPresent(options.todoFile === undefined ? 'TODO.md' : options.todoFile), + addIfPresent(options.changelogFile === undefined ? 'CHANGELOG.md' : options.changelogFile), + ]); + const documentFiles = await resolveGlobs( + root, + options.documentPatterns ?? config.documentPatterns, + options.documentExcludes ?? config.documentExcludes, + ); + const documentFileSet = new Set(documentFiles); + for (const file of documentFiles) files.add(file); + + let inputBytes = 0; + let documentChunks = 0; + for (const file of files) { + const size = (await fs.stat(file)).size; + inputBytes += size; + if (documentFileSet.has(file)) { + documentChunks += Math.min(config.documentMaxChunks, Math.max(1, Math.ceil(size / config.documentChunkChars))); + } + } + const markdownMode = options.markdownMode ?? config.markdownMode; + const communicationMode = options.communicationMode ?? config.communicationMode; + const semanticUnitsPerPipeline = (options.includeDocumentationLlm ? documentChunks : 0) + + (markdownMode === 'deterministic' ? 0 : 2) + + (config.nlMode === 'deterministic' || !options.taskFile ? 0 : 1) + + (communicationMode === 'deterministic' ? 0 : 1); + return { + inputBytes: inputBytes * 2, + llmWorkUnits: semanticUnitsPerPipeline * 2, + }; +} diff --git a/src/comparison/workspace-helpers.ts b/src/comparison/workspace-helpers.ts new file mode 100644 index 00000000..59fc2aad --- /dev/null +++ b/src/comparison/workspace-helpers.ts @@ -0,0 +1,109 @@ +import { execFile } from 'node:child_process'; +import { promises as fs } from 'node:fs'; +import path from 'node:path'; +import { promisify } from 'node:util'; +import type { T2CConfig } from '../config/env.js'; +import { pathExists } from '../core/io.js'; +import { assertPathWithinRoot } from '../core/security.js'; +import type { DiagnosticReport, PipelineOptions } from '../core/types.js'; +import type { IntentRealityView } from '../diff/reality.js'; +import type { CoverageSnapshot, WorkspaceComparisonOptions } from './workspace-types.js'; + +const execFileAsync = promisify(execFile); + +export function assertNonNegativeInteger(value: number, name: string): void { + if (!Number.isSafeInteger(value) || value < 0) { + throw new Error(`Workspace comparison ${name} must be a non-negative safe integer`); + } +} + +/** + * Every other scoping call site honours `T2C_ALLOW_OUTSIDE_ROOT`; this one hard + * coded the restriction, so comparing a third-party checkout while keeping its + * artifacts out of the tree failed where the same `--out` works for `pipeline`. + * The default stays closed. + */ +export async function scopedOutputDirectory( + root: string, + requested: string, + allowOutsideRoot: boolean, +): Promise { + const absolute = await assertPathWithinRoot(root, path.resolve(root, requested), allowOutsideRoot); + const relative = path.relative(root, absolute); + if (!relative) return '.'; + return relative.startsWith('..') ? absolute : relative; +} + +export function commonPipelineOptions(options: WorkspaceComparisonOptions, config: T2CConfig): PipelineOptions { + return { + root: options.root, + taskFile: defaulted(options.taskFile, null), + todoFile: options.todoFile === undefined ? 'TODO.md' : options.todoFile, + changelogFile: options.changelogFile === undefined ? 'CHANGELOG.md' : options.changelogFile, + documentPatterns: defaulted(options.documentPatterns, config.documentPatterns), + documentExcludes: defaulted(options.documentExcludes, config.documentExcludes), + includeDocumentationLlm: defaulted(options.includeDocumentationLlm, false), + outputDir: defaulted(options.outputDir, config.outputDir), + gitCommitCount: defaulted(options.gitCommitCount, config.gitCommitCount), + allowSummaryFallback: true, + includeSummaryLlm: false, + nlMode: config.nlMode, + markdownMode: defaulted(options.markdownMode, config.markdownMode), + communicationMode: defaulted(options.communicationMode, config.communicationMode), + }; +} + +function defaulted(value: T | undefined, fallback: T): T { + return value === undefined ? fallback : value; +} + +export async function optionsForRoot(root: string, options: PipelineOptions): Promise { + return { + ...options, + taskFile: await existingFile(root, options.taskFile), + todoFile: await existingFile(root, options.todoFile), + changelogFile: await existingFile(root, options.changelogFile), + }; +} + +async function existingFile(root: string, file: string | null): Promise { + if (!file) return null; + const relative = path.isAbsolute(file) ? path.relative(root, file) : file; + if (relative.startsWith('..') || path.isAbsolute(relative)) return null; + return await pathExists(path.resolve(root, relative)) ? relative : null; +} + +export function coverage(view: IntentRealityView, diagnostics: DiagnosticReport): CoverageSnapshot { + return { + ...view.totals, + alignmentRate: view.totals.topics ? rounded(view.totals.aligned / view.totals.topics) : 1, + diagnostics: { ...diagnostics.counts }, + }; +} + +export function diagnosticDelta(before: DiagnosticReport, after: DiagnosticReport): DiagnosticReport['counts'] { + return { + info: after.counts.info - before.counts.info, + warning: after.counts.warning - before.counts.warning, + review_required: after.counts.review_required - before.counts.review_required, + blocking: after.counts.blocking - before.counts.blocking, + }; +} + +export function parseAheadBehind(value: string): [number, number] { + const [behind = '0', ahead = '0'] = value.trim().split(/\s+/); + return [Number(behind) || 0, Number(ahead) || 0]; +} + +export function defaultBaseRef(): string { + return 'origin/main'; +} + +export function rounded(value: number): number { + return Math.round(value * 10_000) / 10_000; +} + +export async function git(cwd: string, args: string[]): Promise { + const result = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); + return result.stdout; +} diff --git a/src/comparison/workspace-trend.ts b/src/comparison/workspace-trend.ts new file mode 100644 index 00000000..e5279569 --- /dev/null +++ b/src/comparison/workspace-trend.ts @@ -0,0 +1,66 @@ +import path from 'node:path'; +import type { DiagnosticReport } from '../core/types.js'; +import type { WorkspaceComparison } from './workspace-types.js'; + +export function classifyWorkspaceTrend(deltas: { + implementationCoverageDelta: number; + documentedCodeCoverageDelta: number; + documentationComparable: boolean; + diagnosticsDelta: DiagnosticReport['counts']; +}): WorkspaceComparison['trend']['direction'] { + const coverageDeltas = [ + deltas.implementationCoverageDelta, + ...(deltas.documentationComparable ? [deltas.documentedCodeCoverageDelta] : []), + ]; + const severeDelta = deltas.diagnosticsDelta.blocking + deltas.diagnosticsDelta.review_required; + const improved = coverageDeltas.some((delta) => delta > 0) || severeDelta < 0; + const regressed = coverageDeltas.some((delta) => delta < 0) || severeDelta > 0; + if (improved && regressed) return 'mixed'; + if (improved) return 'improved'; + if (regressed) return 'regressed'; + return 'unchanged'; +} + +export function artifactPaths(root: string, directory: string): Record { + const relative = (name: string): string => path.relative(root, path.join(directory, name)).replace(/\\/g, '/'); + return { + comparison: relative('comparison.json'), + baseGraph: relative('base.graph.json'), + workspaceGraph: relative('workspace.graph.json'), + baseManifest: relative('base.manifest.json'), + workspaceManifest: relative('workspace.manifest.json'), + diffSvg: relative('intent-diff.svg'), + baseRealityMarkdown: relative('base-reality.md'), + workspaceRealityMarkdown: relative('workspace-reality.md'), + workspaceRealitySvg: relative('workspace-reality.svg'), + trendMarkdown: relative('trend.md'), + }; +} + +export function renderTrendMarkdown(result: WorkspaceComparison): string { + const percent = (value: number): string => `${(value * 100).toFixed(1)}%`; + return `# Origin vs workspace intent\n\n` + + `- Base: \`${result.base.ref}\` at \`${result.base.commit}\`\n` + + `- Workspace HEAD: \`${result.workspace.headCommit}\`; dirty: **${result.workspace.dirty ? 'yes' : 'no'}**; ahead/behind: ${result.workspace.ahead}/${result.workspace.behind}\n` + + `- Changed files before analysis: ${result.workspace.changedFiles.length}\n` + + `- Trend: **${result.trend.direction}**\n` + + `- Alignment: ${percent(result.base.coverage.alignmentRate)} → ${percent(result.workspace.coverage.alignmentRate)} (${percent(result.trend.alignmentRateDelta)})\n` + + `- Declared intent implemented: ${percent(result.base.coverage.implementationCoverage)} → ${percent(result.workspace.coverage.implementationCoverage)} (${percent(result.trend.implementationCoverageDelta)})\n` + + `- Code with a plan: ${percent(result.base.coverage.plannedCodeCoverage)} → ${percent(result.workspace.coverage.plannedCodeCoverage)} (${percent(result.trend.plannedCodeCoverageDelta)})\n` + + `- Code with documentation: ${documentationLine(result, percent)}\n` + + `- Gaps: ${result.base.coverage.gaps} → ${result.workspace.coverage.gaps} (${result.trend.gapsDelta >= 0 ? '+' : ''}${result.trend.gapsDelta})\n` + + `- Intent records: +${result.diff.summary.recordsAdded} / -${result.diff.summary.recordsRemoved} / ~${result.diff.summary.recordsChanged}\n`; +} + +/** + * Documentation coverage is only comparable when at least one side actually + * extracted documentation. Both sides run the same configuration, so an + * offline comparison would otherwise report a confident "0.0% → 0.0%". + */ +function documentationLine(result: WorkspaceComparison, percent: (value: number) => string): string { + if (!result.base.coverage.documentationMeasured && !result.workspace.coverage.documentationMeasured) { + return 'not measured (documentation extraction did not run on either side)'; + } + return `${percent(result.base.coverage.documentedCodeCoverage)} → ${percent(result.workspace.coverage.documentedCodeCoverage)}` + + ` (${percent(result.trend.documentedCodeCoverageDelta)})`; +} diff --git a/src/comparison/workspace-types.ts b/src/comparison/workspace-types.ts new file mode 100644 index 00000000..bf122252 --- /dev/null +++ b/src/comparison/workspace-types.ts @@ -0,0 +1,77 @@ +import type { DiagnosticReport } from '../core/types.js'; +import type { diffIntentGraphs } from '../graph/diff.js'; +import type { LlmExtractionMode } from '../core/types.js'; + +export interface WorkspaceComparisonDeadlineLoad { + inputBytes: number; + llmWorkUnits: number; +} + +export interface WorkspaceComparisonDeadlineDecision extends WorkspaceComparisonDeadlineLoad { + baseDeadlineMs: number; + pressure: number; + multiplier: number; + effectiveDeadlineMs: number; + capped: boolean; +} + +export interface WorkspaceComparisonOptions { + root: string; + baseRef?: string; + taskFile?: string | null; + todoFile?: string | null; + changelogFile?: string | null; + documentPatterns?: string[]; + documentExcludes?: string[]; + includeDocumentationLlm?: boolean; + markdownMode?: LlmExtractionMode; + communicationMode?: LlmExtractionMode; + outputDir?: string; + gitCommitCount?: number; +} + +export interface CoverageSnapshot { + topics: number; + aligned: number; + gaps: number; + alignmentRate: number; + declaredRecords: number; + observedRecords: number; + declaredTopics: number; + observedTopics: number; + implementationAlignedTopics: number; + implementationCoverage: number; + plannedCodeCoverage: number; + documentedCodeCoverage: number; + /** False when neither side ran documentation extraction; see `IntentRealityView`. */ + documentationMeasured: boolean; + byStatus: Record; + diagnostics: DiagnosticReport['counts']; +} + +export interface WorkspaceComparison { + schemaVersion: 't2c.workspace-comparison/v1'; + generatedAt: string; + base: { ref: string; commit: string; graphFingerprint: string; coverage: CoverageSnapshot }; + workspace: { + headCommit: string; + dirty: boolean; + changedFiles: string[]; + ahead: number; + behind: number; + graphFingerprint: string; + coverage: CoverageSnapshot; + }; + trend: { + direction: 'improved' | 'regressed' | 'mixed' | 'unchanged'; + alignmentRateDelta: number; + implementationCoverageDelta: number; + plannedCodeCoverageDelta: number; + documentedCodeCoverageDelta: number; + alignedDelta: number; + gapsDelta: number; + diagnosticsDelta: DiagnosticReport['counts']; + }; + diff: ReturnType; + artifacts: Record; +} diff --git a/src/comparison/workspace.ts b/src/comparison/workspace.ts index 1736a305..29a7718b 100644 --- a/src/comparison/workspace.ts +++ b/src/comparison/workspace.ts @@ -1,141 +1,45 @@ -import { execFile } from 'node:child_process'; import { promises as fs } from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import { promisify } from 'node:util'; import type { T2CConfig } from '../config/env.js'; import { newRunId } from '../core/id.js'; -import { pathExists, readJson, resolveGlobs, writeJson, writeText } from '../core/io.js'; -import { assertPathWithinRoot } from '../core/security.js'; -import type { DiagnosticReport, IntentGraph, LlmExtractionMode, PipelineManifest, PipelineOptions } from '../core/types.js'; -import { buildRealityView, renderRealityMarkdown, renderRealitySvg, type IntentRealityView } from '../diff/reality.js'; +import { readJson, writeJson, writeText } from '../core/io.js'; +import type { DiagnosticReport, IntentGraph } from '../core/types.js'; +import { buildRealityView, renderRealityMarkdown, renderRealitySvg } from '../diff/reality.js'; import { diffIntentGraphs, renderGraphDiffSvg } from '../graph/diff.js'; -import { OPENROUTER_TIMEOUT_POLICY } from '../llm/openrouter-timeout.js'; import { runPipeline } from '../pipeline/run.js'; - -const execFileAsync = promisify(execFile); - -export const WORKSPACE_COMPARISON_DEADLINE_POLICY = Object.freeze({ - inputBytesBaseline: 128 * 1024, - llmWorkUnitsBaseline: 16, - scaleFactor: 2, - maximumMultiplier: 4, - maximumDeadlineMs: 40 * 60 * 1000, -}); - -// Generated graphs are denser than their source records. Platform currently -// produces a ~136 MiB graph, so the generic 128 MiB JSON ceiling rejects an -// artifact that the bounded pipeline has just produced. Keep a separate, -// explicit ceiling for the two comparison graphs instead of weakening the -// default limit for every JSON consumer. -export const WORKSPACE_COMPARISON_GRAPH_MAX_BYTES = 256 * 1024 * 1024; - -export interface WorkspaceComparisonDeadlineLoad { - inputBytes: number; - llmWorkUnits: number; -} - -export interface WorkspaceComparisonDeadlineDecision extends WorkspaceComparisonDeadlineLoad { - baseDeadlineMs: number; - pressure: number; - multiplier: number; - effectiveDeadlineMs: number; - capped: boolean; -} - -/** Bound the two-pipeline operation, not only each individual provider call. */ -export function calculateWorkspaceComparisonDeadline( - load: WorkspaceComparisonDeadlineLoad, -): WorkspaceComparisonDeadlineDecision { - assertNonNegativeInteger(load.inputBytes, 'input bytes'); - assertNonNegativeInteger(load.llmWorkUnits, 'LLM work units'); - const baseDeadlineMs = OPENROUTER_TIMEOUT_POLICY.maximumTimeoutMs; - const pressure = Math.max( - 1, - load.inputBytes / WORKSPACE_COMPARISON_DEADLINE_POLICY.inputBytesBaseline, - load.llmWorkUnits / WORKSPACE_COMPARISON_DEADLINE_POLICY.llmWorkUnitsBaseline, - ); - const steps = pressure <= 1 ? 0 : Math.ceil(Math.log2(pressure)); - const multiplier = Math.min( - WORKSPACE_COMPARISON_DEADLINE_POLICY.maximumMultiplier, - WORKSPACE_COMPARISON_DEADLINE_POLICY.scaleFactor ** steps, - ); - const scaledDeadlineMs = baseDeadlineMs * multiplier; - const effectiveDeadlineMs = Math.min( - WORKSPACE_COMPARISON_DEADLINE_POLICY.maximumDeadlineMs, - scaledDeadlineMs, - ); - const capped = effectiveDeadlineMs < scaledDeadlineMs; - return { - ...load, - baseDeadlineMs, - pressure, - multiplier, - effectiveDeadlineMs, - capped, - }; -} - -export interface WorkspaceComparisonOptions { - root: string; - baseRef?: string; - taskFile?: string | null; - todoFile?: string | null; - changelogFile?: string | null; - documentPatterns?: string[]; - documentExcludes?: string[]; - includeDocumentationLlm?: boolean; - markdownMode?: LlmExtractionMode; - communicationMode?: LlmExtractionMode; - outputDir?: string; - gitCommitCount?: number; -} - -export interface CoverageSnapshot { - topics: number; - aligned: number; - gaps: number; - alignmentRate: number; - declaredRecords: number; - observedRecords: number; - declaredTopics: number; - observedTopics: number; - implementationAlignedTopics: number; - implementationCoverage: number; - plannedCodeCoverage: number; - documentedCodeCoverage: number; - /** False when neither side ran documentation extraction; see `IntentRealityView`. */ - documentationMeasured: boolean; - byStatus: Record; - diagnostics: DiagnosticReport['counts']; -} - -export interface WorkspaceComparison { - schemaVersion: 't2c.workspace-comparison/v1'; - generatedAt: string; - base: { ref: string; commit: string; graphFingerprint: string; coverage: CoverageSnapshot }; - workspace: { - headCommit: string; - dirty: boolean; - changedFiles: string[]; - ahead: number; - behind: number; - graphFingerprint: string; - coverage: CoverageSnapshot; - }; - trend: { - direction: 'improved' | 'regressed' | 'mixed' | 'unchanged'; - alignmentRateDelta: number; - implementationCoverageDelta: number; - plannedCodeCoverageDelta: number; - documentedCodeCoverageDelta: number; - alignedDelta: number; - gapsDelta: number; - diagnosticsDelta: DiagnosticReport['counts']; - }; - diff: ReturnType; - artifacts: Record; -} +import { + calculateWorkspaceComparisonDeadline, + WORKSPACE_COMPARISON_GRAPH_MAX_BYTES, + workspaceComparisonDeadlineLoad, +} from './workspace-deadline.js'; +import { + commonPipelineOptions, + coverage, + defaultBaseRef, + diagnosticDelta, + git, + optionsForRoot, + parseAheadBehind, + rounded, + scopedOutputDirectory, +} from './workspace-helpers.js'; +import { artifactPaths, classifyWorkspaceTrend, renderTrendMarkdown } from './workspace-trend.js'; +import type { WorkspaceComparison, WorkspaceComparisonOptions } from './workspace-types.js'; + +export { + calculateWorkspaceComparisonDeadline, + WORKSPACE_COMPARISON_DEADLINE_POLICY, + WORKSPACE_COMPARISON_GRAPH_MAX_BYTES, +} from './workspace-deadline.js'; +export { classifyWorkspaceTrend } from './workspace-trend.js'; +export type { + CoverageSnapshot, + WorkspaceComparison, + WorkspaceComparisonDeadlineDecision, + WorkspaceComparisonDeadlineLoad, + WorkspaceComparisonOptions, +} from './workspace-types.js'; export async function compareWorkspaceIntent( options: WorkspaceComparisonOptions, @@ -225,7 +129,7 @@ export async function compareWorkspaceIntent( const plannedCodeCoverageDelta = rounded(currentCoverage.plannedCodeCoverage - baseCoverage.plannedCodeCoverage); const documentedCodeCoverageDelta = rounded(currentCoverage.documentedCodeCoverage - baseCoverage.documentedCodeCoverage); const gapsDelta = currentCoverage.gaps - baseCoverage.gaps; - const diagnosticsDelta = diagnosticDelta(baseDiagnostics, currentDiagnostics); + const diagnosticsDeltaCounts = diagnosticDelta(baseDiagnostics, currentDiagnostics); const comparisonId = newRunId(); const comparisonDirectory = path.resolve(root, outputDir, 'comparisons', comparisonId); await fs.mkdir(comparisonDirectory, { recursive: true }); @@ -249,7 +153,7 @@ export async function compareWorkspaceIntent( implementationCoverageDelta, documentedCodeCoverageDelta, documentationComparable: baseCoverage.documentationMeasured || currentCoverage.documentationMeasured, - diagnosticsDelta, + diagnosticsDelta: diagnosticsDeltaCounts, }), alignmentRateDelta, implementationCoverageDelta, @@ -257,7 +161,7 @@ export async function compareWorkspaceIntent( documentedCodeCoverageDelta, alignedDelta: currentCoverage.aligned - baseCoverage.aligned, gapsDelta, - diagnosticsDelta, + diagnosticsDelta: diagnosticsDeltaCounts, }, diff, artifacts, @@ -288,221 +192,3 @@ export async function compareWorkspaceIntent( await fs.rm(temporaryParent, { recursive: true, force: true }); } } - -async function workspaceComparisonDeadlineLoad( - root: string, - options: WorkspaceComparisonOptions, - config: T2CConfig, -): Promise { - const files = new Set(); - const addIfPresent = async (file: string | null | undefined): Promise => { - if (!file) return; - const absolute = path.resolve(root, file); - const relative = path.relative(root, absolute); - if (relative.startsWith('..') || path.isAbsolute(relative)) return; - try { - if ((await fs.stat(absolute)).isFile()) files.add(absolute); - } catch { - // Missing optional inputs are skipped by the pipeline too. - } - }; - await Promise.all([ - addIfPresent(options.taskFile), - addIfPresent(options.todoFile === undefined ? 'TODO.md' : options.todoFile), - addIfPresent(options.changelogFile === undefined ? 'CHANGELOG.md' : options.changelogFile), - ]); - const documentFiles = await resolveGlobs( - root, - options.documentPatterns ?? config.documentPatterns, - options.documentExcludes ?? config.documentExcludes, - ); - const documentFileSet = new Set(documentFiles); - for (const file of documentFiles) files.add(file); - - let inputBytes = 0; - let documentChunks = 0; - for (const file of files) { - const size = (await fs.stat(file)).size; - inputBytes += size; - if (documentFileSet.has(file)) { - documentChunks += Math.min(config.documentMaxChunks, Math.max(1, Math.ceil(size / config.documentChunkChars))); - } - } - const markdownMode = options.markdownMode ?? config.markdownMode; - const communicationMode = options.communicationMode ?? config.communicationMode; - const semanticUnitsPerPipeline = (options.includeDocumentationLlm ? documentChunks : 0) - + (markdownMode === 'deterministic' ? 0 : 2) - + (config.nlMode === 'deterministic' || !options.taskFile ? 0 : 1) - + (communicationMode === 'deterministic' ? 0 : 1); - return { - inputBytes: inputBytes * 2, - llmWorkUnits: semanticUnitsPerPipeline * 2, - }; -} - -function assertNonNegativeInteger(value: number, name: string): void { - if (!Number.isSafeInteger(value) || value < 0) { - throw new Error(`Workspace comparison ${name} must be a non-negative safe integer`); - } -} - -/** - * Every other scoping call site honours `T2C_ALLOW_OUTSIDE_ROOT`; this one hard - * coded the restriction, so comparing a third-party checkout while keeping its - * artifacts out of the tree failed where the same `--out` works for `pipeline`. - * The default stays closed. - */ -async function scopedOutputDirectory( - root: string, - requested: string, - allowOutsideRoot: boolean, -): Promise { - const absolute = await assertPathWithinRoot(root, path.resolve(root, requested), allowOutsideRoot); - const relative = path.relative(root, absolute); - if (!relative) return '.'; - // A directory deliberately placed outside the analysed tree keeps its - // absolute form: a `../..` chain resolves correctly but reads as a defect - // in the manifest. - return relative.startsWith('..') ? absolute : relative; -} - -function commonPipelineOptions(options: WorkspaceComparisonOptions, config: T2CConfig): PipelineOptions { - return { - root: options.root, - taskFile: defaulted(options.taskFile, null), - todoFile: options.todoFile === undefined ? 'TODO.md' : options.todoFile, - changelogFile: options.changelogFile === undefined ? 'CHANGELOG.md' : options.changelogFile, - documentPatterns: defaulted(options.documentPatterns, config.documentPatterns), - documentExcludes: defaulted(options.documentExcludes, config.documentExcludes), - includeDocumentationLlm: defaulted(options.includeDocumentationLlm, false), - outputDir: defaulted(options.outputDir, config.outputDir), - gitCommitCount: defaulted(options.gitCommitCount, config.gitCommitCount), - allowSummaryFallback: true, - includeSummaryLlm: false, - nlMode: config.nlMode, - markdownMode: defaulted(options.markdownMode, config.markdownMode), - communicationMode: defaulted(options.communicationMode, config.communicationMode), - }; -} - -function defaulted(value: T | undefined, fallback: T): T { - return value === undefined ? fallback : value; -} - -async function optionsForRoot(root: string, options: PipelineOptions): Promise { - return { - ...options, - taskFile: await existingFile(root, options.taskFile), - todoFile: await existingFile(root, options.todoFile), - changelogFile: await existingFile(root, options.changelogFile), - }; -} - -async function existingFile(root: string, file: string | null): Promise { - if (!file) return null; - const relative = path.isAbsolute(file) ? path.relative(root, file) : file; - if (relative.startsWith('..') || path.isAbsolute(relative)) return null; - return await pathExists(path.resolve(root, relative)) ? relative : null; -} - -function coverage(view: IntentRealityView, diagnostics: DiagnosticReport): CoverageSnapshot { - return { - ...view.totals, - alignmentRate: view.totals.topics ? rounded(view.totals.aligned / view.totals.topics) : 1, - diagnostics: { ...diagnostics.counts }, - }; -} - -function diagnosticDelta(before: DiagnosticReport, after: DiagnosticReport): DiagnosticReport['counts'] { - return { - info: after.counts.info - before.counts.info, - warning: after.counts.warning - before.counts.warning, - review_required: after.counts.review_required - before.counts.review_required, - blocking: after.counts.blocking - before.counts.blocking, - }; -} - -export function classifyWorkspaceTrend(deltas: { - implementationCoverageDelta: number; - documentedCodeCoverageDelta: number; - documentationComparable: boolean; - diagnosticsDelta: DiagnosticReport['counts']; -}): WorkspaceComparison['trend']['direction'] { - // Only declared/business-topic implementation and comparable documentation - // decide the headline direction. AST-only module growth, source-line churn, - // raw gap counts and planned-code denominators remain visible metrics but do - // not turn an otherwise unchanged workspace into a regression. - const coverageDeltas = [ - deltas.implementationCoverageDelta, - ...(deltas.documentationComparable ? [deltas.documentedCodeCoverageDelta] : []), - ]; - const severeDelta = deltas.diagnosticsDelta.blocking + deltas.diagnosticsDelta.review_required; - const improved = coverageDeltas.some((delta) => delta > 0) || severeDelta < 0; - const regressed = coverageDeltas.some((delta) => delta < 0) || severeDelta > 0; - if (improved && regressed) return 'mixed'; - if (improved) return 'improved'; - if (regressed) return 'regressed'; - return 'unchanged'; -} - -function parseAheadBehind(value: string): [number, number] { - const [behind = '0', ahead = '0'] = value.trim().split(/\s+/); - return [Number(behind) || 0, Number(ahead) || 0]; -} - -function defaultBaseRef(): string { - return 'origin/main'; -} - -function rounded(value: number): number { - return Math.round(value * 10_000) / 10_000; -} - -function artifactPaths(root: string, directory: string): Record { - const relative = (name: string): string => path.relative(root, path.join(directory, name)).replace(/\\/g, '/'); - return { - comparison: relative('comparison.json'), - baseGraph: relative('base.graph.json'), - workspaceGraph: relative('workspace.graph.json'), - baseManifest: relative('base.manifest.json'), - workspaceManifest: relative('workspace.manifest.json'), - diffSvg: relative('intent-diff.svg'), - baseRealityMarkdown: relative('base-reality.md'), - workspaceRealityMarkdown: relative('workspace-reality.md'), - workspaceRealitySvg: relative('workspace-reality.svg'), - trendMarkdown: relative('trend.md'), - }; -} - -function renderTrendMarkdown(result: WorkspaceComparison): string { - const percent = (value: number): string => `${(value * 100).toFixed(1)}%`; - return `# Origin vs workspace intent\n\n` - + `- Base: \`${result.base.ref}\` at \`${result.base.commit}\`\n` - + `- Workspace HEAD: \`${result.workspace.headCommit}\`; dirty: **${result.workspace.dirty ? 'yes' : 'no'}**; ahead/behind: ${result.workspace.ahead}/${result.workspace.behind}\n` - + `- Changed files before analysis: ${result.workspace.changedFiles.length}\n` - + `- Trend: **${result.trend.direction}**\n` - + `- Alignment: ${percent(result.base.coverage.alignmentRate)} → ${percent(result.workspace.coverage.alignmentRate)} (${percent(result.trend.alignmentRateDelta)})\n` - + `- Declared intent implemented: ${percent(result.base.coverage.implementationCoverage)} → ${percent(result.workspace.coverage.implementationCoverage)} (${percent(result.trend.implementationCoverageDelta)})\n` - + `- Code with a plan: ${percent(result.base.coverage.plannedCodeCoverage)} → ${percent(result.workspace.coverage.plannedCodeCoverage)} (${percent(result.trend.plannedCodeCoverageDelta)})\n` - + `- Code with documentation: ${documentationLine(result, percent)}\n` - + `- Gaps: ${result.base.coverage.gaps} → ${result.workspace.coverage.gaps} (${result.trend.gapsDelta >= 0 ? '+' : ''}${result.trend.gapsDelta})\n` - + `- Intent records: +${result.diff.summary.recordsAdded} / -${result.diff.summary.recordsRemoved} / ~${result.diff.summary.recordsChanged}\n`; -} - -/** - * Documentation coverage is only comparable when at least one side actually - * extracted documentation. Both sides run the same configuration, so an - * offline comparison would otherwise report a confident "0.0% → 0.0%". - */ -function documentationLine(result: WorkspaceComparison, percent: (value: number) => string): string { - if (!result.base.coverage.documentationMeasured && !result.workspace.coverage.documentationMeasured) { - return 'not measured (documentation extraction did not run on either side)'; - } - return `${percent(result.base.coverage.documentedCodeCoverage)} → ${percent(result.workspace.coverage.documentedCodeCoverage)}` - + ` (${percent(result.trend.documentedCodeCoverageDelta)})`; -} - -async function git(cwd: string, args: string[]): Promise { - const result = await execFileAsync('git', ['-C', cwd, ...args], { encoding: 'utf8', maxBuffer: 16 * 1024 * 1024 }); - return result.stdout; -}