/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at https://mozilla.org/MPL/2.0/. */ import { createPatchDiagnostic, type PatchScriptDiagnostic, type PreflightScriptDiagnostic, } from './script-diagnostics.js'; import { validateScriptPreflightDetailed } from './script-preflight.js'; export type ScriptMutationIntent = 'create' | 'repair' | 'explicit_rewrite'; export type ScriptReplacementSource = 'replaceAll' | 'code_block_fallback' | 'manual_replace_all'; export interface ScriptReplacementCheckResult { ok: boolean; diagnostic?: PatchScriptDiagnostic; detachedDiagnostics: PreflightScriptDiagnostic[]; } const CRITICAL_ANCHORS = [ { key: 'project', label: 'project handle', pattern: /\bbim\.create\.project\s*\(/ }, { key: 'storey', label: 'storey creation', pattern: /\bbim\.create\.addIfcBuildingStorey\s*\(/ }, { key: 'export', label: 'IFC export', pattern: /\bbim\.create\.toIfc\s*\(/ }, { key: 'load', label: 'viewer load', pattern: /\bbim\.model\.loadIfc\s*\(/ }, { key: 'loop', label: 'outer loop', pattern: /\bfor\s*\(/ }, ] as const; function countMeaningfulLines(text: string): number { return text .split('\n') .map((line) => line.trim()) .filter((line) => line.length > 0 && !line.startsWith('//')) .length; } function getDroppedAnchors(previousContent: string, candidateContent: string): string[] { return CRITICAL_ANCHORS .filter(({ pattern }) => pattern.test(previousContent) && !pattern.test(candidateContent)) .map(({ label }) => label); } function buildReplacementDiagnostic( code: 'unsafe_full_replacement' | 'destructive_partial_rewrite', message: string, data: Record, detachedDiagnostics: PreflightScriptDiagnostic[], ): ScriptReplacementCheckResult { return { ok: false, diagnostic: createPatchDiagnostic(code, message, 'error', { ...data, detachedDiagnosticCount: detachedDiagnostics.length, }), detachedDiagnostics, }; } export function canUsePlainCodeBlockFallback(intent: ScriptMutationIntent): boolean { return intent !== 'repair'; } export function validateScriptReplacementCandidate(params: { previousContent: string; candidateContent: string; intent: ScriptMutationIntent; source: ScriptReplacementSource; }): ScriptReplacementCheckResult { const previousContent = params.previousContent.trim(); const candidateContent = params.candidateContent.trim(); const previousLineCount = countMeaningfulLines(previousContent); const candidateLineCount = countMeaningfulLines(candidateContent); const shrinkRatio = previousLineCount > 0 ? candidateLineCount / previousLineCount : 1; const droppedAnchors = getDroppedAnchors(previousContent, candidateContent); const detachedDiagnostics = validateScriptPreflightDetailed(candidateContent).filter( (diagnostic): diagnostic is PreflightScriptDiagnostic => diagnostic.code === 'detached_snippet_scope', ); const baseData = { intent: params.intent, source: params.source, previousLineCount, candidateLineCount, shrinkRatio, droppedAnchors, }; if (!candidateContent) { return buildReplacementDiagnostic( 'unsafe_full_replacement', 'Full-script replacement candidate is empty.', baseData, detachedDiagnostics, ); } if (params.intent === 'repair' && params.source === 'code_block_fallback') { return buildReplacementDiagnostic( 'unsafe_full_replacement', 'Repair turns must not replace the whole script from a plain `js` block. Return localized SEARCH/REPLACE edits inside one `ifc-script-edits` fence against the current revision instead.', baseData, detachedDiagnostics, ); } if (params.intent === 'repair' && params.source === 'replaceAll') { return buildReplacementDiagnostic( 'unsafe_full_replacement', 'Repair turns cannot use `replaceAll` unless the system explicitly allows a full rewrite. Use localized SEARCH/REPLACE edits instead.', baseData, detachedDiagnostics, ); } if (detachedDiagnostics.length > 0 && (params.intent !== 'create' || previousLineCount >= 8)) { return buildReplacementDiagnostic( 'destructive_partial_rewrite', 'Replacement candidate still looks like a detached local snippet and would discard surrounding script context.', baseData, detachedDiagnostics, ); } if (params.intent === 'repair' && droppedAnchors.length > 0) { return buildReplacementDiagnostic( 'destructive_partial_rewrite', `Repair candidate dropped critical script anchors: ${droppedAnchors.join(', ')}.`, baseData, detachedDiagnostics, ); } if (params.intent === 'repair' && previousLineCount >= 12 && shrinkRatio < 0.6) { return buildReplacementDiagnostic( 'destructive_partial_rewrite', 'Repair candidate shrank far below the current script and likely removed unrelated building context.', baseData, detachedDiagnostics, ); } if (params.intent !== 'repair' && previousLineCount >= 12 && shrinkRatio < 0.35 && droppedAnchors.length >= 2) { return buildReplacementDiagnostic( 'unsafe_full_replacement', 'Full-script replacement candidate looks like a local fragment rather than a complete script.', baseData, detachedDiagnostics, ); } return { ok: true, detachedDiagnostics }; }