diff --git a/app.js b/app.js index d9ba075..f24dac8 100644 --- a/app.js +++ b/app.js @@ -23,6 +23,13 @@ import { findMissingCodeFiles, } from "./src/flutterFlowCodeFileProvisioning.js"; import { buildReviewPresentation } from "./src/reviewPresentation.js"; +import { + expectedWidgetClassFromFileName, + findUnbalancedBracketError, + getDeclaredWidgetClasses, + sanitizeGeneratedDart, + widgetFileNameForClass, +} from "./src/flutterFlowCodeSanitizer.js"; import { formatFlutterFlowFileError } from "./src/flutterFlowFileErrors.js"; import { extractPackageImports } from "./src/dartPackageImports.js"; import { readProvisionResponse } from "./src/provisionStream.js"; @@ -1447,6 +1454,12 @@ function getCurrentArtifactMetadata() { return { artifactType: artifact.artifactType || "CustomWidget", artifactName: artifact.artifactName || "GeneratedWidget", + // Single-file deploys must name the committed file after the artifact's + // own validated fileName (FF naively snake_cases the declared class), not + // a fresh name derived from artifactName. The bundle planner already uses + // artifact.fileName; the single-file path was dropping it, so FF saw a + // file named after the artifact name and found no matching widget class. + fileName: artifact.fileName || "", }; } @@ -1498,6 +1511,24 @@ const FF_API_ENDPOINTS = { staging: "https://api.flutterflow.io/v2-staging/", }; +/** + * Builds the actionable error shown when listing projects is denied. Both + * callers surface this text verbatim in their dropdowns, and re-entering a key + * in API Keys settings is the app's re-auth path for static FlutterFlow keys. + * @param {number} status - HTTP status from listProjects + * @param {string} errorText - Server-provided detail, truncated for display + * @returns {string} User-facing message naming the fix + */ +function buildListProjectsAuthError(status, errorText) { + const detail = errorText?.trim() + ? ` (${errorText.trim().slice(0, 200)})` + : ""; + if (status === 401) { + return `Your FlutterFlow API key was rejected (401 Unauthorized)${detail}. Re-enter a current key under API Keys settings, then try again.`; + } + return `Listing FlutterFlow projects was denied (403)${detail}. The key may be scoped to sync a single project without list permission - verify the key's access in FlutterFlow, re-enter it under API Keys settings, then retry.`; +} + /** * Client for interacting with the FlutterFlow API. * Adapted from the VS Code extension for browser use. @@ -1744,72 +1775,88 @@ class FlutterFlowApiClient { } /** - * Lists projects accessible with the current API key. - * @param {Object} [options] - Optional parameters - * @param {number} [options.page] - Page number for pagination - * @param {number} [options.limit] - Maximum number of projects per page - * @returns {Promise>} Array of project objects with id and name + * Parses a successful listProjects payload. Handles FlutterFlow's wrapper + * format ({ success: true, value: "" }) plus looser + * shapes older deployments return. + * @param {Object} data - Parsed JSON body + * @returns {Array} Projects as { id, name } */ - async listProjects(options = {}) { - const { page = 1, limit = 100 } = options; - console.log(`Listing projects for API key via V2 endpoint`); + parseProjectsResponse(data) { + if (data?.success && typeof data.value === "string") { + try { + const parsedValue = JSON.parse(data.value); + if (parsedValue && Array.isArray(parsedValue.entries)) { + return parsedValue.entries.map((entry) => ({ + id: entry.id, + name: entry.project?.name || entry.id, + })); + } + } catch (parseError) { + console.error("Failed to parse stringified project value:", parseError); + } + } - try { - const response = await fetch( - "https://api.flutterflow.io/v2/l/listProjects", - { - method: "POST", - headers: { - "Content-Type": "application/json", - Authorization: `Bearer ${this.apiKey}`, - }, - body: JSON.stringify({ - project_type: "ALL", - deserialize_response: true, - }), - }, - ); + const projects = + data?.projects || data?.items || data?.entries + || (Array.isArray(data) ? data : []); + return Array.isArray(projects) ? projects : []; + } - if (!response.ok) { - const errorText = await response.text(); - throw new Error( - `List projects failed: ${response.status} - ${errorText}`, - ); - } + async listProjects() { + console.log("Listing projects for API key"); - const data = await response.json(); + // Every other call in this client targets `${baseUrl}`; this one + // alone hardcoded a legacy `/v2/l/` path that the gateway rejects with + // 401/403 before the key is evaluated, surfacing as "List projects failed: + // 403 Unauthorized" while sync calls with the same key worked. The + // convention path goes first; the legacy path is retried once on 404 only, + // so an auth rejection is never masked by a retry. + const attemptUrls = [ + `${this.baseUrl}listProjects`, + "https://api.flutterflow.io/v2/l/listProjects", + ]; + let lastStatus = 0; + let lastErrorText = ""; + + for (const url of attemptUrls) { + const response = await fetch(url, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${this.apiKey}`, + }, + body: JSON.stringify({ + project_type: "ALL", + deserialize_response: true, + }), + }); - // Handle the specific FlutterFlow API wrapper format: - // { success: true, value: "{\"entries\": [...]}" } - if (data.success && typeof data.value === "string") { - try { - const parsedValue = JSON.parse(data.value); - if (parsedValue && Array.isArray(parsedValue.entries)) { - // Map to standard format: { id, name } - return parsedValue.entries.map((entry) => ({ - id: entry.id, - name: entry.project?.name || entry.id, - })); - } - } catch (parseError) { - console.error( - "Failed to parse stringified project value:", - parseError, - ); + if (response.ok) { + if (url !== attemptUrls[0]) { + console.log(`listProjects answered on legacy path ${url}`); } + return this.parseProjectsResponse(await response.json()); } - // Fallback for other potential formats - const projects = - data.projects || - data.items || - data.entries || - (Array.isArray(data) ? data : []); - return Array.isArray(projects) ? projects : []; - } catch (error) { - console.error("Error listing projects:", error); - throw error; + lastStatus = response.status; + lastErrorText = await response.text(); + console.warn( + `listProjects via ${url} returned ${lastStatus}: ${lastErrorText}`, + ); + + // 401 means the key itself is invalid/expired; 403 usually means the key + // is valid but scoped to sync a single project without list permission. + // Either way the user must act in API Keys settings, so fail with that + // instruction instead of a raw status line. + if (response.status === 401 || response.status === 403) { + throw new Error(buildListProjectsAuthError(response.status, lastErrorText)); + } + if (response.status !== 404) { + throw new Error(`List projects failed: ${response.status} - ${lastErrorText}`); + } } + + throw new Error(`List projects failed: ${lastStatus} - ${lastErrorText}`); } } @@ -2226,6 +2273,37 @@ function runPreCommitChecks(codeInfo) { ); } + // FlutterFlow formats every pushed file with dart_style, which fails on + // unbalanced brackets with the opaque "Custom widget code is not + // formattable". Catch it here, where the message can say exactly what and + // where is wrong, instead of after a round-trip to FF. + const unbalancedBrackets = findUnbalancedBracketError(codeInfo.content); + if (unbalancedBrackets) { + issues.push( + `FlutterFlow cannot format this code - ${unbalancedBrackets}. Fix or regenerate before committing.`, + ); + } + + // A CustomWidget is committed under a file name FlutterFlow reads the widget + // identity back out of. If the code declares no public widget class, or the + // declared class cannot be recovered from the file name, FlutterFlow rejects + // the push with "Custom widget code is not formattable" / "No widget + // found". Catch that here instead of after a round-trip to FF. + if (codeInfo.codeType === CodeType.WIDGET) { + const declared = getDeclaredWidgetClasses(codeInfo.content); + const expectedFromFile = expectedWidgetClassFromFileName(codeInfo.fileName); + if (declared.length === 0) { + issues.push( + "No public widget class found (must extend StatelessWidget or StatefulWidget).", + ); + } else if (!declared.includes(expectedFromFile)) { + const expected = `${widgetFileNameForClass(declared[0])}`; + issues.push( + `Widget class name "${declared[0]}" does not match the file name "${codeInfo.fileName}". FlutterFlow derives the widget from the file name, so it will report "No widget ${expectedFromFile} found". Rename the file to "${expected}" or the class to match before committing.`, + ); + } + } + return { canProceed: issues.length === 0, issues, @@ -2244,26 +2322,41 @@ function runPreCommitChecks(codeInfo) { * @returns {Object} Prepared code info { content: string, fileName: string, codeType: string } */ function prepareCodeForCommit(rawCode, options = {}) { - const { artifactType = "CustomWidget", artifactName = "GeneratedCode" } = - options; - - // Clean up the code - let cleanedCode = rawCode.trim(); - - // Remove markdown code fences if present - if (cleanedCode.startsWith("```dart")) { - cleanedCode = cleanedCode.replace(/^```dart\n/, ""); - } else if (cleanedCode.startsWith("```")) { - cleanedCode = cleanedCode.replace(/^```\n/, ""); - } - - if (cleanedCode.endsWith("```")) { - cleanedCode = cleanedCode.replace(/\n```$/, ""); - } - - // Ensure proper class/function naming - let fileName = artifactName; - if (!fileName.endsWith(".dart")) { + const { + artifactType = "CustomWidget", + artifactName = "GeneratedCode", + fileName: providedFileName, + } = options; + + // Strip BOM, markdown code fences, and blank padding. LLM responses arrive + // wrapped in fences often enough that leaving them in guarantees FlutterFlow + // rejects the push as "not formattable". + const cleanedCode = sanitizeGeneratedDart(rawCode); + + // FF derives a widget's identity from the committed file name, so a widget + // must land under the FF naive snake_case of the class the code actually + // declares - not under the artifact's display name ("Liquid Glass Orbs" + // becomes a file FF cannot resolve to any widget). Any other name - even one + // that merely capitalizes differently - makes FF report "No widget + // found", so a declared class always wins and the file is renamed (and + // logged) to match. CustomFunction always lands in custom_functions.dart. + let fileName = providedFileName || artifactName; + if (artifactType === "CustomFunction") { + fileName = "custom_functions.dart"; + } else if (artifactType === "CustomWidget") { + const declaredClass = getDeclaredWidgetClasses(cleanedCode)[0]; + const canonicalName = declaredClass + ? widgetFileNameForClass(declaredClass) + : null; + if (canonicalName && fileName !== canonicalName) { + console.warn( + `Commit file name "${fileName}" does not match declared widget class "${declaredClass}"; renaming to "${canonicalName}" so FlutterFlow can find the widget.`, + ); + fileName = canonicalName; + } else if (!fileName.endsWith(".dart")) { + fileName += ".dart"; + } + } else if (!fileName.endsWith(".dart")) { fileName += ".dart"; } @@ -2278,7 +2371,6 @@ function prepareCodeForCommit(rawCode, options = {}) { break; case "CustomFunction": codeType = CodeType.FUNCTION; - fileName = "custom_functions.dart"; break; case "CustomClass": case "CodeFile": @@ -2776,14 +2868,14 @@ async function createZipFromFileMap(fileMap) { } async function executeCommit(code, options = {}) { - const { artifactType, artifactName, pipelineResult } = options; + const { artifactType, artifactName, fileName, pipelineResult } = options; console.log(`Starting commit for ${artifactName} (${artifactType})`); try { // Step 1: Prepare the code commitState.setState(CommitState.PREPARING); - const codeInfo = prepareCodeForCommit(code, { artifactType, artifactName }); + const codeInfo = prepareCodeForCommit(code, { artifactType, artifactName, fileName }); // Step 2: Extract dependencies const deps = extractDependencies(codeInfo.content); @@ -3752,9 +3844,9 @@ async function initiateCommitToFlutterFlow() { return; } - const { artifactType, artifactName } = getCurrentArtifactMetadata(); + const { artifactType, artifactName, fileName } = getCurrentArtifactMetadata(); - const codeInfo = prepareCodeForCommit(code, { artifactType, artifactName }); + const codeInfo = prepareCodeForCommit(code, { artifactType, artifactName, fileName }); const checks = runPreCommitChecks(codeInfo); @@ -5466,11 +5558,12 @@ async function confirmCommitToFlutterFlow() { const { codeInfo } = commitData; - const { artifactType, artifactName } = getCurrentArtifactMetadata(); + const { artifactType, artifactName, fileName } = getCurrentArtifactMetadata(); const result = await executeCommit(codeInfo.content, { artifactType, artifactName, + fileName, pipelineResult: { step1Result: pipelineState.step1Result, selectedModel: document.getElementById("code-generator-model")?.value, diff --git a/src/flutterFlowArtifactValidation.js b/src/flutterFlowArtifactValidation.js index b0649e1..0ed5f40 100644 --- a/src/flutterFlowArtifactValidation.js +++ b/src/flutterFlowArtifactValidation.js @@ -461,7 +461,7 @@ export function getCustomClassFileNameError(fileName, code) { * @param {string} name - Dart identifier, e.g. "initQAAnalytics" * @returns {string} File stem, e.g. "init_q_a_analytics" */ -function identifierToFlutterFlowFileStem(name) { +export function identifierToFlutterFlowFileStem(name) { return String(name || "") .replace(/([A-Z])/g, "_$1") .toLowerCase() diff --git a/src/flutterFlowCodeSanitizer.js b/src/flutterFlowCodeSanitizer.js new file mode 100644 index 0000000..f204627 --- /dev/null +++ b/src/flutterFlowCodeSanitizer.js @@ -0,0 +1,453 @@ +import { deriveIdentifierName } from "./flutterFlowSyncMetadata.js"; +import { identifierToFlutterFlowFileStem } from "./flutterFlowArtifactValidation.js"; + +// LLM responses routinely arrive wrapped in markdown code fences, with a BOM, +// or with prose before/after the Dart. FlutterFlow's push-time formatter +// rejects any of that with "Custom widget code is not formattable", so it must +// be stripped before the code is validated or committed. +const BOM_PATTERN = /^\uFEFF/; + +function trimBlankEdgeLines(lines) { + let start = 0; + let end = lines.length; + + while (start < end && !lines[start].trim()) start++; + while (end > start && !lines[end - 1].trim()) end--; + + return lines.slice(start, end).join("\n"); +} + +/** + * Removes markdown artifacts and surrounding junk from generated Dart. + * + * Fence recognition is phase-separated. Phase one is purely structural: a + * fence marker is a line containing nothing but three-or-more backticks and + * an optional info-string tag. Everything before the first accepted opening + * marker and after the last accepted closing marker is markdown prose by + * definition and is NEVER scanned as Dart, so stray tokens in prose (an + * unmatched `/*`, a stray quote) cannot poison recognition and hide later + * code blocks (STU-148). Phase two applies Dart awareness only INSIDE a + * fenced block: a candidate closer is accepted only when the scanner proves + * the marker sits outside every comment/string span of the accumulated + * block content, so literal ``` lines inside triple-quoted strings or + * (nested) block comments survive byte-for-byte (STU-147). + * + * Completed pairs are kept and joined; inter-block prose is dropped. An + * open block at end of input is a truncated response and is dropped: + * committing cut-off Dart fails formatting anyway. With exactly one fence + * line (a truncated wrap), whichever side holds more content is kept. + * Blank edge lines are trimmed so header application downstream starts + * from clean source. + * @param {string} rawCode - Raw generated response text + * @returns {string} Dart-only source, empty when the input has no content + */ +export function sanitizeGeneratedDart(rawCode) { + const code = String(rawCode ?? "").replace(BOM_PATTERN, ""); + if (!code.trim()) return ""; + + const lines = code.split("\n"); + + // Structural candidates only: the trimmed line must be a bare fence + // marker with an optional language tag - nothing else on the line. + const fenceIndent = (line) => { + const trimmed = line.trim(); + return /^`{3,}[A-Za-z0-9+#_.-]*$/.test(trimmed) + ? line.length - line.trimStart().length + : -1; + }; + + let candidates = 0; + let firstCandidate = -1; + const candidateOffsets = []; + { + let lineStartOffset = 0; + for (let index = 0; index < lines.length; index++) { + const indent = fenceIndent(lines[index]); + if (indent >= 0) { + candidates++; + if (firstCandidate < 0) firstCandidate = index; + candidateOffsets.push(lineStartOffset + indent); + } + lineStartOffset += lines[index].length + 1; + } + } + + // No fences: the response is a plain Dart file (possibly padded). + if (candidates === 0) return trimBlankEdgeLines(lines); + + // Plain-Dart interpretation: when the full source scans without a single + // lexical problem AND every candidate marker sits inside a comment/string + // span, none of them is a markdown delimiter - the input is a fence-less + // Dart file whose doc examples happen to contain backtick lines. Return it + // untouched instead of pairing literals as fences (STU-147). The + // error-free requirement keeps prose tokens like a stray `/*` - which + // would swallow every later marker into one phantom comment span - from + // spoofing this branch; genuinely broken responses fall through to + // markdown extraction. + { + const wholeScan = scanDartSource(code); + const allLiteral = + !wholeScan.error && + candidateOffsets.every((offset) => + offsetIsInsideCommentOrString(offset, wholeScan.commentAndStringRanges) + ); + if (allLiteral) return trimBlankEdgeLines(lines); + } + + // Exactly one fence line means a truncated or partial wrap; keep the side + // that actually carries code instead of emitting an empty or doubled file. + if (candidates === 1) { + const before = trimBlankEdgeLines(lines.slice(0, firstCandidate)); + const after = trimBlankEdgeLines(lines.slice(firstCandidate + 1)); + return after.length >= before.length ? after : before; + } + + const segments = []; + let block = null; // accumulated lines of the currently-open fenced block + for (let index = 0; index < lines.length; index++) { + const line = lines[index]; + const indent = fenceIndent(line); + + if (indent < 0) { + if (block) block.push(line); + continue; + } + + if (block === null) { + // Opening fence: acceptance is structural - preceding prose is never + // consulted, so tokens in prose cannot suppress this block. + block = []; + continue; + } + + // Candidate closer inside an open block: accept only when the marker + // sits outside every comment/string span of the block content itself. + // The candidate line is part of the scanned text so an open triple-quoted + // string (whose span runs to end-of-scan) covers the marker position. + const content = block.join("\n"); + const scanText = `${content}\n${line}`; + const { commentAndStringRanges } = scanDartSource(scanText); + const markerOffset = content.length + 1 + indent; + if (offsetIsInsideCommentOrString(markerOffset, commentAndStringRanges)) { + block.push(line); // literal ``` inside the block's own strings/comments + continue; + } + segments.push(trimBlankEdgeLines(block)); + block = null; // closing fence accepted; following prose is dropped + } + // A block still open at EOF is a truncated response: drop it, along with + // all leading/trailing prose outside completed pairs. + + const kept = segments.filter((segment) => segment.length > 0); + if (kept.length === 0) return ""; + + return kept.join("\n\n"); +} + +/** + * Blanks comments and string literal bodies so name detection never matches + * prose. Order matters: block comments first (they may contain // and quotes), + * then line comments, then triple-quoted strings, then ordinary strings. + * @param {string} code - Dart source + * @returns {string} Source with comment/string content removed + */ +function stripCommentsAndStringBodies(code) { + return String(code ?? "") + .replace(/\/\*[\s\S]*?\*\//g, " ") + .replace(/\/\/[^\n]*/g, "") + .replace(/'''[\s\S]*?'''/g, '""') + .replace(/"""[\s\S]*?"""/g, '""') + .replace(/'(?:\\.|[^'\\\n])*'/g, '""') + .replace(/"(?:\\.|[^"\\\n])*"/g, '""'); +} + +/** + * Names of the public widget classes declared in Dart source. FlutterFlow can + * place any public class whose superclass is a Widget - not only a literal + * `StatelessWidget`/`StatefulWidget`: `ConsumerStatefulWidget`, + * `StatelessHookWidget`, and other transitive Widget subclasses place + * normally, so matching "extends Widget" covers them without + * false-rejecting a healthy single-file widget. Private (underscore-prefixed) + * helpers are excluded: FlutterFlow never places them directly. + * @param {string} code - Dart source + * @returns {string[]} Declared public widget class names, in declaration order + */ +export function getDeclaredWidgetClasses(code) { + const stripped = stripCommentsAndStringBodies(code); + return Array.from( + stripped.matchAll(/class\s+([A-Z]\w*)\s+extends\s+[A-Za-z_]\w*Widget\b/g), + (match) => match[1], + ); +} + +/** + * The file name FlutterFlow expects for a widget class: its naive snake_case + * of the identifier (`LiquidGlassOrbs` -> `liquid_glass_orbs.dart`). FF reads + * the widget's identity back out of the committed file name, so this is the + * only name under which the class is findable. + * @param {string} className - Public widget class name + * @returns {string} File name to commit the class under + */ +export function widgetFileNameForClass(className) { + return `${identifierToFlutterFlowFileStem(className)}.dart`; +} + +/** + * The widget class FlutterFlow will look for inside a committed file, derived + * the same way FF derives it - from the file name alone. + * @param {string} fileName - Bare file name, e.g. "liquid_glass_orbs.dart" + * @returns {string} Class name FF resolves, e.g. "LiquidGlassOrbs" + */ +export function expectedWidgetClassFromFileName(fileName) { + return deriveIdentifierName(fileName, "W"); +} + +const OPENERS = { "(": ")", "[": "]", "{": "}" }; +const CLOSERS = { ")": "(", "]": "[", "}": "{" }; +// Identifier characters that disqualify a preceding r/R from starting a raw +// string. `$` is deliberately absent: in `${r'...'}'` the character before +// the prefix can be `$`/`{` interpolation syntax, and treating `$` as an +// identifier tail made valid interpolated raw strings misclassify as +// ordinary strings whose backslash escapes the closing quote (STU-148). +const IDENTIFIER_TAIL = /[A-Za-z0-9_]/; + +/** + * Single lexical pass over Dart source using a frame stack that models + * nesting: top-level code, bracketed regions, comments, strings, and string + * interpolations. It produces two things from one walk: + * + * - `error`: the first bracket-balance problem, or null when brackets balance. + * This is what FlutterFlow's formatter actually fails on - "Custom widget + * code is not formattable" - so catching it client-side turns a cryptic + * post-push rejection into an actionable pre-commit error. + * - `commentAndStringRanges`: character spans occupied by comments and string + * literals (delimiters included). Consumers use these to tell literal Dart + * content apart from structural code - e.g. a ``` marker inside a doc string + * is content, not a markdown fence (STU-147). + * + * The scan tracks comments, string literals, and `${...}` interpolations so + * brackets that belong to strings or docs never count, including nested cases + * like `user['name']` inside an interpolation. Block comments nest the way + * Dart defines them: every inner comment opener raises the depth and only + * enough closers bring the span back to zero, so a lone inner closer cannot + * terminate the comment early (STU-147). Raw strings (`r'...'`, `R'''...'''`) are + * honored: their backslash escapes nothing and they never interpolate, so a + * raw string ending in a backslash closes at its quote instead of being + * misread as an escaped one (STU-148). + * + * Unterminated tokens never pass silently: a string frame still open at EOF, + * or an ordinary string broken by a bare newline (illegal in Dart), is + * reported through `error` with its opening line - triple-quoted strings stay + * legal while open mid-source but error if never closed by EOF (STU-148). + * Tokens still open at end of input have their span closed there, so + * consumers always see the full extent of unterminated strings/comments even + * though the scan flags them. + * @param {string} src - Dart source (any text; never throws) + * @returns {{error: string|null, commentAndStringRanges: Array<{start: number, end: number}>}} + */ +function scanDartSource(src) { + // Frames model nesting: top-level code, bracketed regions, comments, + // strings, and string interpolations. Every frame knows what ends it. + const frames = [{ kind: "code", opener: null, openedLine: 0 }]; + const commentAndStringRanges = []; + let line = 1; + let i = 0; + + // First problem found that does not halt the scan (an unterminated ordinary + // string broken by a newline). The scan keeps walking so ranges and later + // bracket attribution stay correct, but this error still blocks the gate. + let firstError = null; + + // Unterminated tokens still occupy source: close their spans at end of + // input so consumers always see the full extent of any string/comment still + // open when the scan stops - on an error or at EOF alike. + const closeOpenTokenSpans = () => { + for (const frame of frames) { + if ( + frame.kind === "string" + || frame.kind === "block-comment" + || frame.kind === "line-comment" + ) { + commentAndStringRanges.push({ start: frame.startIndex, end: src.length }); + } + } + }; + + while (i < src.length) { + const ch = src[i]; + const frame = frames[frames.length - 1]; + + if (frame.kind === "line-comment") { + if (ch === "\n") { + commentAndStringRanges.push({ start: frame.startIndex, end: i }); + frames.pop(); + } else i++; + continue; + } + + if (frame.kind === "block-comment") { + // Dart block comments nest: each inner /* raises the depth, and the + // comment only ends once enough */ closers bring it back to zero. A + // lone inner closer must not end the span - everything up to the real + // close stays protected content. + if (ch === "*" && src[i + 1] === "/") { + frame.depth--; + if (frame.depth === 0) { + commentAndStringRanges.push({ start: frame.startIndex, end: i + 2 }); + frames.pop(); + } + i += 2; + } else if (ch === "/" && src[i + 1] === "*") { + frame.depth++; + i += 2; + } else { + if (ch === "\n") line++; + i++; + } + continue; + } + + if (frame.kind === "string") { + // A bare newline cannot appear inside a non-triple Dart string: this + // string is unterminated. End its span at the newline so later lines + // still scan (and fence recognition still sees them) but surface the + // break - silently healing used to let malformed source pass the + // pre-commit gate and fail opaquely inside FlutterFlow (STU-148). + if (!frame.triple && ch === "\n") { + commentAndStringRanges.push({ start: frame.startIndex, end: i }); + firstError ??= `line ${line}: string starting on line ${frame.openedLine} is never closed`; + frames.pop(); + continue; + } + if (!frame.raw && ch === "\\") { + // An escaped newline is a line continuation - keep the line count true. + if (src[i + 1] === "\n") line++; + i += 2; + continue; + } + if (!frame.raw && ch === "$" && src[i + 1] === "{") { + frames.push({ kind: "code", opener: null, interpolation: true }); + i += 2; + continue; + } + const closerLength = frame.triple ? 3 : 1; + if ( + ch === frame.quote + && src.slice(i, i + closerLength) === frame.quote.repeat(closerLength) + ) { + commentAndStringRanges.push({ + start: frame.startIndex, + end: i + closerLength, + }); + frames.pop(); + i += closerLength; + continue; + } + if (ch === "\n") line++; + i++; + continue; + } + + // --- code frame --- + if (ch === "/" && src[i + 1] === "/") { + frames.push({ kind: "line-comment", startIndex: i }); + i += 2; + continue; + } + if (ch === "/" && src[i + 1] === "*") { + frames.push({ kind: "block-comment", startIndex: i, openedLine: line, depth: 1 }); + i += 2; + continue; + } + if (ch === "'" || ch === '"') { + const triple = src.slice(i, i + 3) === ch.repeat(3); + // r'...' / R'''...''' are raw strings: the r must not be the tail of a + // longer identifier, or `var bar'` style code would misclassify. + const prev = i > 0 ? src[i - 1] : ""; + const beforePrev = i > 1 ? src[i - 2] : ""; + const raw = + (prev === "r" || prev === "R") && !IDENTIFIER_TAIL.test(beforePrev); + frames.push({ kind: "string", quote: ch, triple, raw, startIndex: i, openedLine: line }); + i += triple ? 3 : 1; + continue; + } + if (OPENERS[ch]) { + frames.push({ kind: "code", opener: ch, openedLine: line }); + i++; + continue; + } + if (CLOSERS[ch]) { + if (frame.interpolation && ch === "}") { + frames.pop(); + i++; + continue; + } + if (frame.opener && OPENERS[frame.opener] === ch) { + frames.pop(); + i++; + continue; + } + if (frame.opener) { + closeOpenTokenSpans(); + return { + error: firstError + ?? `line ${line}: "${ch}" closes nothing - "${frame.opener}" opened on line ${frame.openedLine} is still open`, + commentAndStringRanges, + }; + } + // A closer can never legitimately reach an interpolation or top-level + // frame: whatever it closes would have to sit above it in the stack. + closeOpenTokenSpans(); + return { + error: firstError ?? `line ${line}: unexpected "${ch}" with no matching opener`, + commentAndStringRanges, + }; + } + if (ch === "\n") line++; + i++; + } + + closeOpenTokenSpans(); + + let error = firstError; + if (!error) { + const remaining = frames[frames.length - 1]; + if (!(remaining.kind === "code" && !remaining.opener && !remaining.interpolation)) { + if (remaining.kind === "string") { + error = `unclosed ${remaining.triple ? "triple-quoted " : ""}string starting on line ${remaining.openedLine} was never closed`; + } else if (remaining.kind === "block-comment") { + error = `unterminated /* comment starting on line ${remaining.openedLine}`; + } else if (remaining.kind === "line-comment") { + error = null; // Ends at end-of-input by definition. + } else if (remaining.interpolation) { + const openerFrame = frames.findLast((f) => f.opener); + const where = openerFrame ? ` opened on line ${openerFrame.openedLine}` : ""; + error = `unclosed "\${" expression${where} was never closed`; + } else { + error = `"${remaining.opener}" opened on line ${remaining.openedLine} is never closed`; + } + } + } + + return { error, commentAndStringRanges }; +} + +/** + * Reports the first bracket-balance problem in Dart source, or null when the + * brackets balance. See scanDartSource for the lexical rules. + * @param {string} code - Dart source + * @returns {string|null} Precise error message, or null when balanced + */ +export function findUnbalancedBracketError(code) { + return scanDartSource(String(code ?? "")).error; +} + +/** + * Whether a character offset falls inside any recorded comment or string span. + * @param {number} offset - Character offset into the scanned source + * @param {Array<{start: number, end: number}>} ranges - Comment/string spans + * @returns {boolean} True when the offset lies inside a span + */ +function offsetIsInsideCommentOrString(offset, ranges) { + return ranges.some(({ start, end }) => offset >= start && offset < end); +} diff --git a/src/flutterFlowCodeSanitizer.test.js b/src/flutterFlowCodeSanitizer.test.js new file mode 100644 index 0000000..a18d73b --- /dev/null +++ b/src/flutterFlowCodeSanitizer.test.js @@ -0,0 +1,408 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { + expectedWidgetClassFromFileName, + findUnbalancedBracketError, + getDeclaredWidgetClasses, + sanitizeGeneratedDart, + widgetFileNameForClass, +} from "./flutterFlowCodeSanitizer.js"; + +const SIMPLE_WIDGET = [ + "class LiquidGlassOrbs extends StatefulWidget {", + " const LiquidGlassOrbs({super.key});", + " @override", + " State createState() => _LiquidGlassOrbsState();", + "}", +].join("\n"); + +test("sanitizeGeneratedDart leaves plain Dart untouched apart from edge padding", () => { + assert.equal(sanitizeGeneratedDart(`\n\n${SIMPLE_WIDGET}\n\n`), SIMPLE_WIDGET); +}); + +test("sanitizeGeneratedDart strips dart-fenced responses", () => { + assert.equal( + sanitizeGeneratedDart("```dart\n" + SIMPLE_WIDGET + "\n```"), + SIMPLE_WIDGET, + ); +}); + +test("sanitizeGeneratedDart strips bare fences and surrounding prose", () => { + const raw = [ + "Here is your widget:", + "", + "```", + SIMPLE_WIDGET, + "```", + "Let me know if you need changes!", + ].join("\n"); + + assert.equal(sanitizeGeneratedDart(raw), SIMPLE_WIDGET); +}); + +test("sanitizeGeneratedDart drops prose between multi-block responses", () => { + // STU-148: the previous sanitizer removed only the fence lines and kept + // whatever explanatory text sat between the blocks, so the committed file + // still failed FlutterFlow's formatter. + const raw = [ + "```dart", + "class A extends StatelessWidget {}", + "```", + "And another:", + "```dart", + "class B extends StatelessWidget {}", + "```", + ].join("\n"); + const result = sanitizeGeneratedDart(raw); + + assert.equal(result.includes("```"), false); + assert.equal(result.includes("And another"), false); + assert.match(result, /class A extends/); + assert.match(result, /class B extends/); +}); + +test("STU-147: fences inside triple-quoted strings survive as literal content", () => { + // A doc string whose example shows markdown fences must not be mistaken for + // real delimiters: pairing those lines used to slice valid Dart apart. + const dartWithFenceDoc = [ + "class FenceDoc extends StatelessWidget {", + " static const usage = '''", + "```dart", + "FenceDoc()", + "```", + "''';", + "}", + ].join("\n"); + const raw = [ + "Here is your widget:", + "", + "```dart", + dartWithFenceDoc, + "```", + ].join("\n"); + + assert.equal(sanitizeGeneratedDart(raw), dartWithFenceDoc); +}); + +test("STU-147: fences inside block comments survive as literal content", () => { + const dartWithCommentedFences = [ + "/* Generated snippet notes:", + "```json", + '{"name": "demo"}', + "```", + "*/", + "class Demo extends StatelessWidget {}", + ].join("\n"); + const raw = [ + "```dart", + dartWithCommentedFences, + "```", + "Hope that helps!", + ].join("\n"); + + assert.equal(sanitizeGeneratedDart(raw), dartWithCommentedFences); +}); + +test("sanitizeGeneratedDart drops content after an unclosed trailing fence", () => { + // A response cut off mid-block cannot be valid Dart; committing its tail + // would only move the failure to FlutterFlow's formatter. + const raw = [ + "```dart", + SIMPLE_WIDGET, + "```", + "Second part:", + "```dart", + "class Broken extends Stateless", + ].join("\n"); + const result = sanitizeGeneratedDart(raw); + + assert.equal(result, SIMPLE_WIDGET); +}); + +test("sanitizeGeneratedDart keeps the code side of a single truncated fence", () => { + assert.equal(sanitizeGeneratedDart("```\n" + SIMPLE_WIDGET), SIMPLE_WIDGET); + assert.equal(sanitizeGeneratedDart(SIMPLE_WIDGET + "\n```"), SIMPLE_WIDGET); +}); + +test("sanitizeGeneratedDart strips a BOM", () => { + assert.equal(sanitizeGeneratedDart("\uFEFF" + SIMPLE_WIDGET), SIMPLE_WIDGET); +}); + +test("sanitizeGeneratedDart returns empty for empty or blank input", () => { + assert.equal(sanitizeGeneratedDart(""), ""); + assert.equal(sanitizeGeneratedDart(" \n\t "), ""); + assert.equal(sanitizeGeneratedDart(null), ""); + assert.equal(sanitizeGeneratedDart(undefined), ""); +}); + +test("getDeclaredWidgetClasses finds public Stateless and Stateful widgets in order", () => { + const code = [ + "class First extends StatelessWidget {}", + "class _Private extends StatelessWidget {}", + "class NotAWidget extends Object {}", + "class Second extends StatefulWidget {}", + ].join("\n"); + + assert.deepEqual(getDeclaredWidgetClasses(code), ["First", "Second"]); +}); + +test("getDeclaredWidgetClasses accepts transitive Widget superclasses", () => { + const code = "class Consumer extends ConsumerStatefulWidget {}"; + + assert.deepEqual(getDeclaredWidgetClasses(code), ["Consumer"]); +}); + +test("getDeclaredWidgetClasses ignores class-shaped prose in comments and strings", () => { + const code = [ + "// class Fake extends StatelessWidget should not count.", + "final hint = 'class FakeToo extends StatefulWidget';", + "class Real extends StatelessWidget {}", + ].join("\n"); + + assert.deepEqual(getDeclaredWidgetClasses(code), ["Real"]); +}); + +test("findUnbalancedBracketError accepts healthy widget code", () => { + const code = [ + "class W extends StatelessWidget {", + " // braces } in a comment { don't count", + " final label = 'text with ) and (';", + " @override", + " Widget build(BuildContext context) {", + " return Text('${user['name']}: {literal}');", + " }", + "}", + ].join("\n"); + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("findUnbalancedBracketError reports the line of an unclosed bracket", () => { + const code = "\n{\n x();\n"; + + const error = findUnbalancedBracketError(code); + assert.match(error, /"\{" opened on line 2 is never closed/); +}); + +test("findUnbalancedBracketError reports an extra closer", () => { + const error = findUnbalancedBracketError("void a() {}\n}"); + + assert.match(error, /line 2.*unexpected "\}"/); +}); + +test("findUnbalancedBracketError reports mismatched pairs precisely", () => { + const error = findUnbalancedBracketError("void a() {\n]"); + + assert.match(error, /"\]" closes nothing - "\{" opened on line 1/); +}); + +test("findUnbalancedBracketError ignores brackets inside triple-quoted strings", () => { + const code = [ + "/// Docs:", + 'const doc = """', + "unclosed { [ ( stuff", + '""";', + "void main() {}", + ].join("\n"); + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("findUnbalancedBracketError reports an unterminated triple-quoted string", () => { + const error = findUnbalancedBracketError("const doc = '''\nnever ended {"); + + assert.match(error, /unclosed triple-quoted string/); +}); + +test("STU-148: a runaway quote fails the gate instead of self-healing", () => { + // A single-quoted string can never legally span lines. The scan still ends + // the string at the newline so later brackets attribute correctly, but it + // must surface the break through the gate - silently healing let malformed + // source reach FlutterFlow and fail with an opaque formatter rejection. + const error = findUnbalancedBracketError("final x = 'oops;\nvoid main() {}"); + + assert.match(error, /line 1: string starting on line 1 is never closed/); +}); + +test("STU-148: unterminated ordinary string followed by balanced code errors naming its line", () => { + // The Greptile P1 scenario: "abc on one line, healthy code after - the + // balanced remainder used to hide the broken string from the gate. + const code = [ + "class Broken extends StatelessWidget {", + ' final label = "abc', + " void f() {}", + "}", + ].join("\n"); + + const error = findUnbalancedBracketError(code); + assert.match(error, /string starting on line 2 is never closed/); +}); + +test("STU-148: multi-line triple-quoted strings stay legal while open", () => { + const code = [ + "final doc = '''", + "line one with ``` fences and { brackets [", + "line two keeps going", + "''';", + "void main() {}", + ].join("\n"); + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("STU-148: an unterminated triple-quoted string names its opening line", () => { + // The old message reported the live line counter at EOF instead of where + // the string actually opened. + const code = "var s = 'fine';\n\nvar t = '''\nnever closed"; + const error = findUnbalancedBracketError(code); + + assert.match(error, /unclosed triple-quoted string starting on line 3 was never closed/); +}); + +test("STU-147: nested block comments stay protected until the real close", () => { + // Dart nests block comments: the inner */ must not terminate the outer + // comment, or every later line-leading ``` inside the still-open region is + // misread as a markdown fence and the source gets dropped/rearranged. + const nestedCommentDart = [ + "/* outer doc /* inner ``` example */", + "still-open outer comment", + "```dart", + "FenceDoc()", + "```", + "*/", + "class NestedCommentDoc extends StatelessWidget {}", + ].join("\n"); + + // Byte-for-byte survival: no real fences exist, nothing may be stripped. + assert.equal(sanitizeGeneratedDart(nestedCommentDart), nestedCommentDart); + assert.equal(findUnbalancedBracketError(nestedCommentDart), null); + + // Protection holds until the true close: once the outer comment really + // ends, surrounding markdown fences pair up normally around intact content. + const raw = ["```dart", nestedCommentDart, "```", "Hope that helps!"].join("\n"); + assert.equal(sanitizeGeneratedDart(raw), nestedCommentDart); +}); + +test("STU-147: an unterminated nested block comment reports its opening line", () => { + const error = findUnbalancedBracketError("void f() {}\n/* a /* b\nstill open"); + + assert.match(error, /unterminated \/\* comment starting on line 2/); +}); + +test("findUnbalancedBracketError handles nested interpolation strings", () => { + // items[0]'s brackets sit inside the ${...} interpolation of a string; the + // nested ['...'] quotes must not terminate the outer string early. + assert.equal(findUnbalancedBracketError("f('${items[0]}');"), null); + assert.equal(findUnbalancedBracketError("t('${m['k']} v');"), null); + + // A quote left open inside an interpolation is still a real defect. + const error = findUnbalancedBracketError("f('${a[');"); + assert.match(error, /unclosed string/); +}); + +test("STU-148: a raw string ending in a backslash closes at its quote", () => { + // r'\' holds one literal backslash; treating the backslash as an escape used + // to skip the closing quote and report every following bracket against it. + const code = [ + "class BackslashSplitter extends StatelessWidget {", + " static final RegExp sep = RegExp(r'\\');", + " @override", + " Widget build(BuildContext context) => const SizedBox.shrink();", + "}", + ].join("\n"); + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("STU-148: raw triple-quoted strings keep their literal backslashes", () => { + const code = "final doc = r'''a \\ b { [ (''';\nvoid main() {}"; + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("STU-148: raw strings do not interpolate so ${ is literal", () => { + // Interpolation is disabled inside raw strings; pushing an interpolation + // frame for ${ used to misattribute the braces that follow. + const code = "final s = r'\${([{';\nvoid main() {}"; + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("STU-148: uppercase R prefix marks raw strings too", () => { + const code = "final sep = RegExp(R'\\');\nvoid main() {}"; + + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("escaped backslashes in normal strings still close correctly", () => { + // 'a\\' is a complete non-raw string holding one backslash; the escape must + // still be honored outside raw mode. + assert.equal(findUnbalancedBracketError("final x = 'a\\\\';\nvoid f() {}"), null); + + // ...and a genuinely unterminated non-raw string is still reported. + const error = findUnbalancedBracketError("final x = 'a\\;"); + assert.match(error, /unclosed string/); +}); + +test("widget file naming round-trips through FlutterFlow's naive snake_case", () => { + assert.equal(widgetFileNameForClass("LiquidGlassOrbs"), "liquid_glass_orbs.dart"); + assert.equal(expectedWidgetClassFromFileName("liquid_glass_orbs.dart"), "LiquidGlassOrbs"); + + // Acronyms: FF's naive stem puts an underscore before EVERY capital, so the + // inverse must rebuild "QAReport" from "q_a_report". + assert.equal(widgetFileNameForClass("QAReport"), "q_a_report.dart"); + assert.equal(expectedWidgetClassFromFileName("q_a_report.dart"), "QAReport"); +}); + +test("STU-147 scenario: fenced response with display-name artifact resolves cleanly", () => { + // The reported failure: LLM output wrapped in fences, artifact named + // "Liquid Glass Orbs" (display form), class declared as LiquidGlassOrbs. + const generated = [ + "```dart", + SIMPLE_WIDGET, + "```", + ].join("\n"); + const artifactName = "Liquid Glass Orbs"; + + const code = sanitizeGeneratedDart(generated); + const [declared] = getDeclaredWidgetClasses(code); + const fileName = widgetFileNameForClass(declared); + + assert.equal(declared, "LiquidGlassOrbs"); + assert.equal(fileName, "liquid_glass_orbs.dart"); + // The committed name is exactly what FF derives from its own side. + assert.equal(expectedWidgetClassFromFileName(fileName), declared); + assert.equal(findUnbalancedBracketError(code), null); +}); + +test("STU-148: prose tokens before a later block cannot hide that block", () => { + // An unmatched /* in the prose used to extend a comment range to EOF when + // the raw response was scanned as Dart, classifying the second block's + // fences as comment content and silently dropping it. + const response = [ + "Here is a note with a stray opener: /* not really a comment", + "", + "```dart", + "class First {}", + "```", + "And another note with a stray quote: \"", + "", + "```dart", + "class Second {}", + "```", + ].join("\n"); + const out = sanitizeGeneratedDart(response); + assert.ok(out.includes("class First {}"), "first block must survive"); + assert.ok(out.includes("class Second {}"), "second block must survive"); + assert.ok(!out.includes("stray"), "prose must be dropped"); +}); + +test("STU-148: dollar-prefixed raw strings inside interpolation pass the gate", () => { + // `${r'...'}` sequences used to make the r-prefix look like an identifier + // tail ($ was in IDENTIFIER_TAIL), so the backslash escaped the closing + // quote and the gate reported an unclosed string on valid Dart. + const source = `var s = '\${r'x\\'}';`; + assert.equal(findUnbalancedBracketError(source), null); + const fenced = ["```dart", source, "```"].join("\n"); + assert.equal(sanitizeGeneratedDart(fenced).trim(), source); +});