From cda84cd92fff5ac80642951544a1e6954a4e44f1 Mon Sep 17 00:00:00 2001 From: Jeremy Oustrich Date: Thu, 4 Jun 2026 14:30:19 -0400 Subject: [PATCH 1/3] checkpoint --- lib/src/commands/scan.ts | 133 +++---- lib/src/http/scan.ts | 44 +++ lib/src/http/types.ts | 9 + lib/src/index.ts | 14 +- lib/src/scan/classify.ts | 367 ----------------- lib/src/scan/extract.ts | 23 +- lib/src/scan/lang/i18n-file-discovery.ts | 33 +- lib/src/scan/preClassify.ts | 50 --- .../{rules/drop.test.ts => rules.test.ts} | 4 +- lib/src/scan/{rules/drop.ts => rules.ts} | 2 +- lib/src/scan/rules/accept/common.ts | 100 ----- lib/src/scan/rules/accept/index.ts | 16 - lib/src/scan/rules/accept/kotlin.ts | 45 --- lib/src/scan/rules/accept/swift.ts | 5 - lib/src/scan/rules/index.ts | 51 --- lib/src/scan/rules/reject/common.ts | 9 - lib/src/scan/rules/reject/index.ts | 24 -- lib/src/scan/rules/reject/javascript.ts | 72 ---- lib/src/scan/rules/reject/kotlin.ts | 75 ---- lib/src/scan/rules/reject/swift.ts | 37 -- lib/src/scan/rules/types.ts | 50 --- lib/src/scan/rules/verdict.test.ts | 314 --------------- lib/src/services/gemini.ts | 373 ------------------ 23 files changed, 142 insertions(+), 1708 deletions(-) create mode 100644 lib/src/http/scan.ts delete mode 100644 lib/src/scan/classify.ts delete mode 100644 lib/src/scan/preClassify.ts rename lib/src/scan/{rules/drop.test.ts => rules.test.ts} (97%) rename lib/src/scan/{rules/drop.ts => rules.ts} (99%) delete mode 100644 lib/src/scan/rules/accept/common.ts delete mode 100644 lib/src/scan/rules/accept/index.ts delete mode 100644 lib/src/scan/rules/accept/kotlin.ts delete mode 100644 lib/src/scan/rules/accept/swift.ts delete mode 100644 lib/src/scan/rules/index.ts delete mode 100644 lib/src/scan/rules/reject/common.ts delete mode 100644 lib/src/scan/rules/reject/index.ts delete mode 100644 lib/src/scan/rules/reject/javascript.ts delete mode 100644 lib/src/scan/rules/reject/kotlin.ts delete mode 100644 lib/src/scan/rules/reject/swift.ts delete mode 100644 lib/src/scan/rules/types.ts delete mode 100644 lib/src/scan/rules/verdict.test.ts delete mode 100644 lib/src/services/gemini.ts diff --git a/lib/src/commands/scan.ts b/lib/src/commands/scan.ts index 3d39e56..ef53e53 100644 --- a/lib/src/commands/scan.ts +++ b/lib/src/commands/scan.ts @@ -3,11 +3,12 @@ import fs from "fs/promises"; import path from "path"; import logger from "../utils/logger"; -import { quit } from "../utils/quit"; import { DittoScanExtractSummary, runExtract } from "../scan/extract"; -import { DittoScanCandidate, DittoScanResult } from "../scan/types"; -import { preClassify } from "../scan/preClassify"; -import { DittoScanClassifyResult, runClassify } from "../scan/classify"; +import { DittoScanCandidate } from "../scan/types"; +import { quit } from "../utils/quit"; +import initAPIToken from "../services/apiToken/initAPIToken"; +import appContext from "../utils/appContext"; +import { initiateScan, uploadCandidatesToS3 } from "../http/scan"; // Yarn sets INIT_CWD to the directory the user invoked yarn from, which // matters when we proxy via `cd product-text-detection && yarn ptd`. @@ -23,12 +24,6 @@ function buildOutputPaths(outDir: string, prefix?: string) { const p = prefix ? `${prefix}-` : ""; return { candidates: path.join(outDir, `${p}candidates.ndjson`), - results: path.join(outDir, `${p}results.ndjson`), - summary: path.join(outDir, `${p}summary.json`), - verify: path.join(outDir, `${p}resultsToVerify.ndjson`), - analysis: path.join(outDir, `${p}analysis.json`), - schema: path.join(outDir, `${p}schema.json`), - stagings: path.join(outDir, `${p}stagings.ndjson`), }; } @@ -49,7 +44,7 @@ async function writeCandidatesNdjson( // per-detection-kind candidate counts, and the output path. function logExtractSummary( summary: DittoScanExtractSummary, - outputPath: string + outputPath?: string ): void { process.stderr.write( `[ditto-cli scan][extract] framework: ${ @@ -84,89 +79,38 @@ function logExtractSummary( ); } process.stderr.write( - `[ditto-cli scan][extract] emitted ${summary.candidatesEmitted} candidates -> ${outputPath}\n` + `[ditto-cli scan][extract] emitted ${ + summary.candidatesEmitted + } candidates -> ${outputPath ?? "Ditto"}\n` ); for (const [kind, count] of Object.entries(summary.candidatesByKind)) { if (count > 0) process.stderr.write(` candidates.${kind}: ${count}\n`); } - const { accept, reject, llm } = summary.candidatesByVerdict; - if (accept > 0 || reject > 0 || llm > 0) { - process.stderr.write( - `[ditto-cli scan][extract] rule verdicts: accept=${accept}, reject=${reject}, llm=${llm}\n` - ); - for (const [name, count] of Object.entries(summary.ruleHits)) { - process.stderr.write(` rule.${name}: ${count}\n`); - } - } } -// Telemetry for the classify phase — per-status counts, LLM call/token -// totals, and the paths to the results and summary artifacts. -function logClassifySummary( - result: DittoScanClassifyResult, - summaryPath: string -): void { - process.stderr.write( - `[ditto-cli scan][classify] classified ${result.summary.candidate_total} candidates\n` - ); - for (const [status, count] of Object.entries(result.summary.by_status)) { - process.stderr.write(` ${status}: ${count}\n`); - } - process.stderr.write( - `[ditto-cli scan][classify] llm_calls: ${result.summary.llm_calls}, tokens_in: ${result.summary.llm_tokens_in}, tokens_out: ${result.summary.llm_tokens_out}\n` - ); - process.stderr.write( - `[ditto-cli scan][classify] to_manually_validate: ${result.summary.num_results_to_manually_verify}\n` - ); - process.stderr.write( - `[ditto-cli scan][classify] pct_results_needing_manual_verification: ${result.summary.pct_results_needing_manual_verification}\n` - ); - for (const [status, filePath] of Object.entries( - result.writtenPaths.resultsByStatus - )) { - process.stderr.write(`[ptd classify] results.${status} -> ${filePath}\n`); - } - process.stderr.write(`[ptd classify] summary -> ${summaryPath}\n`); - if (result.writtenPaths.verify) { - process.stderr.write( - `[ditto-cli scan][classify] verify -> ${result.writtenPaths.verify}\n` - ); - } +interface ISyncOptions { + local: boolean; + outDir?: string; + prefix?: string; } - export const scan = async ( path: string, - outDir: string = "./out", - prefix: string = "" + { local, outDir = "", prefix = "" }: ISyncOptions ) => { - const apiKey = process.env.GEMINI_API_KEY; - if (!apiKey || apiKey.trim().length === 0) { + if (local && !outDir) { return await quit( - logger.errorText("GEMINI_API_KEY is not set. Aborting."), + logger.errorText( + "Must specify --out-dir if outputting candidates locally" + ), 2 ); } const resolvedInput = resolveUserPath(path); - const resolvedOutDir = resolveUserPath(outDir); - await fs.mkdir(resolvedOutDir, { recursive: true }); - const { - candidates: candidatesPath, - results: resultsPath, - summary: summaryPath, - verify: verifyPath, - schema: schemaPath, - stagings: stagingsPath, - } = buildOutputPaths(resolvedOutDir, prefix); - - const { - candidates, - verdicts, - summary: extractSummary, - } = await runExtract({ inputPath: resolvedInput }); - await writeCandidatesNdjson(candidates, candidatesPath); - logExtractSummary(extractSummary, candidatesPath); + const { candidates, summary: extractSummary } = await runExtract({ + inputPath: resolvedInput, + }); if (candidates.length === 0) { logger.warnText( @@ -174,14 +118,31 @@ export const scan = async ( ); } - const { llmCandidates, preClassified } = preClassify(candidates, verdicts); + if (local) { + const resolvedOutDir = resolveUserPath(outDir); + await fs.mkdir(resolvedOutDir, { recursive: true }); - const classifyResult = await runClassify({ - candidates: llmCandidates, - preClassified, - outputPath: resultsPath, - summaryPath, - verifyPath, - }); - logClassifySummary(classifyResult, summaryPath); + const { candidates: candidatesPath } = buildOutputPaths( + resolvedOutDir, + prefix + ); + + await writeCandidatesNdjson(candidates, candidatesPath); + logExtractSummary(extractSummary, candidatesPath); + } else { + const token = await initAPIToken(); + appContext.setApiToken(token); + const { + candidatesSignedS3Url, + record: { _id: recordId }, + } = await initiateScan(path); + await uploadCandidatesToS3(candidates, candidatesSignedS3Url); + logExtractSummary(extractSummary); + await quit( + logger.info( + `Scan initiated! Visit https://app.dittowords.com/scan/${recordId} to view progress and see results.` + ), + 0 + ); + } }; diff --git a/lib/src/http/scan.ts b/lib/src/http/scan.ts new file mode 100644 index 0000000..958c251 --- /dev/null +++ b/lib/src/http/scan.ts @@ -0,0 +1,44 @@ +import { AxiosError } from "axios"; +import getHttpClient from "./client"; +import { IInitiateScanResponse, ZInitiateScanResponse } from "./types"; +import { DittoScanCandidate } from "../scan/types"; +import { Blob } from "buffer"; + +export async function initiateScan( + path: string +): Promise { + try { + const httpClient = getHttpClient({}); + const response = await httpClient.post("/v2/scan", { path }); + return ZInitiateScanResponse.parse(response.data); + } catch (e) { + if (!(e instanceof AxiosError)) { + throw new Error( + "Sorry! We're having trouble reaching the Ditto API. Please try again later." + ); + } + throw e; + } +} + +export async function uploadCandidatesToS3( + candidates: DittoScanCandidate[], + signedUrl: string +) { + // Convert TypeScript array to NDJSON format (JSON objects separated by \n) + const ndjsonString = candidates + .map((candidate) => JSON.stringify(candidate)) + .join("\n"); + + // Create a Blob from the NDJSON string + const blob = new Blob([ndjsonString], { type: "application/x-ndjson" }); + + // Upload via PUT request to the pre-signed URL + const httpClient = getHttpClient({}); + const response = await httpClient.put(signedUrl, blob, { + headers: { + // S3 requires the Content-Length header for PUT requests + "Content-Length": blob.size, + }, + }); +} diff --git a/lib/src/http/types.ts b/lib/src/http/types.ts index 96f6deb..e56b714 100644 --- a/lib/src/http/types.ts +++ b/lib/src/http/types.ts @@ -177,3 +177,12 @@ export const ZExportSwiftFileRequest = z.object({ }); export type IExportSwiftFileRequest = z.infer; + +export const ZInitiateScanBodySchema = z.object({ + path: z.string(), +}); +export const ZInitiateScanResponse = z.object({ + record: z.object({ _id: z.string() }), + candidatesSignedS3Url: z.string(), +}); +export type IInitiateScanResponse = z.infer; diff --git a/lib/src/index.ts b/lib/src/index.ts index e186d08..1aefb5b 100644 --- a/lib/src/index.ts +++ b/lib/src/index.ts @@ -77,12 +77,20 @@ const appEntry = async () => { .description( "Run extract + classify + infer end-to-end; emits schema.json and stagings.ndjson to --out-dir" ) - .option("--out-dir ", "output directory", "./out") + .option( + "--local", + "outputs the candidates file locally to the out-dir with the given prefix", + false + ) + .option("--out-dir ", "output directory", "") .option("--prefix ", "prefix for output files", "") .action( - async (inputPath: string, opts: { outDir: string; prefix?: string }) => { + async ( + inputPath: string, + opts: { local: boolean; outDir: string; prefix?: string } + ) => { try { - return await scan(inputPath, opts.outDir, opts.prefix); + return await scan(inputPath, opts); } catch (error) { handleCommandError(error); } diff --git a/lib/src/scan/classify.ts b/lib/src/scan/classify.ts deleted file mode 100644 index 3ddae06..0000000 --- a/lib/src/scan/classify.ts +++ /dev/null @@ -1,367 +0,0 @@ -import { createHash } from "crypto"; -import fs from "fs/promises"; -import path from "path"; -import { z } from "zod"; - -import { callGemini } from "../services/gemini"; - -import { - DittoScanCandidate, - DittoScanStatusSchema, - type DittoScanResult, - type DittoScanStatus, - type DittoScanSummary, -} from "./types"; -import DittoError, { ErrorType } from "../utils/DittoError"; - -export interface DittoScanClassifyOptions { - candidates?: DittoScanCandidate[]; - // Results already decided by the deterministic rule classifier in - // `pre-classify`. Merged into the output file ahead of LLM-decided - // results. Their `decided_by` is "rule". - preClassified?: DittoScanResult[]; - inputPath?: string; - outputPath: string; - summaryPath: string; - verifyPath: string; -} - -interface DittoScanClassifyData { - results: DittoScanResult[]; - summary: DittoScanSummary; -} - -export interface DittoScanClassifyResult extends DittoScanClassifyData { - writtenPaths: { - resultsByStatus: Partial>; - verify: string | null; - }; -} - -const MIN_CONFIDENCE_LEVEL = 0.9; -const BATCH_SIZE = 10; -const PARALLEL_BATCHES = 5; -const CLASSIFY_MODEL = "gemini-2.5-flash"; - -const SYSTEM_PROMPT = `You are classifying string literals extracted from an application codebase to determine whether they are user-facing (visible to end users in the UI) or not. The application type (web, mobile, desktop, etc.) can be inferred from the framework field on each candidate. - -For each candidate examine: -- value_raw: the actual string literal -- detection_kind: markup_text (JSX/HTML text content), markup_attr (HTML attribute value), resource_value (value inside a localization resource file), or other (any other code position) -- context_identifiers: attribute names or identifiers enclosing the string (e.g. "placeholder", "aria-label", "classname", "textid"); for resource_value this is the localization key and any variant labels (e.g. ["greeting"] or ["items", "one"]) -- language: the file's language (tsx, jsx, typescript, javascript, vue, kotlin, swift, ios_strings, android_resources, etc.) — helps interpret the source_context syntax -- framework: UI frameworks and platforms detected in the project (e.g. ["react"], ["vue"], ["android"], ["ios"]) — a React/Vue/iOS/Android file is far more likely to contain user-facing strings than a plain Node.js utility -- source_context: surrounding lines of source code with line numbers -- usage_evidence (if present): how the string constant is used elsewhere in the codebase - -Classification statuses: -- "user-facing": text visible to end users — button labels, form placeholders, error messages shown in the UI, modal titles, headings, aria-labels, tooltip text, etc. -- "not-user-facing": internal strings — CSS class names, import paths, localStorage keys, API route prefixes, internal error codes used for programmatic comparisons, keyboard key names for event matching (e.g. "Enter"), enum-like string values, Ditto textId references (e.g. "text_630d31..."), etc. -- "unsure": genuinely ambiguous; use sparingly - -Use confidence >= ${MIN_CONFIDENCE_LEVEL} when evidence clearly points one way, 0.6 - ${MIN_CONFIDENCE_LEVEL} when leaning one way with some ambiguity, < 0.6 for truly unclear cases. -Set info_needed to null unless specific missing context would meaningfully change the verdict.`; - -const ZBatchItem = z.object({ - id: z.string(), - status: DittoScanStatusSchema, - reason: z.string(), - confidence: z.number().min(0).max(1), - info_needed: z.string().nullable(), -}); - -const ZBatchResponse = z.object({ - items: z.array(ZBatchItem), -}); - -interface Stats { - llmCalls: number; - tokensIn: number; - tokensOut: number; -} - -function formatCandidate(c: DittoScanCandidate, index: number): string { - const parts = [ - `[Candidate ${index + 1}]`, - `id: ${JSON.stringify(c.id)}`, - `value_raw: ${JSON.stringify(c.value_raw)}`, - `detection_kind: ${c.detection_kind}`, - `context_identifiers: [${c.context_identifiers - .map((x) => JSON.stringify(x)) - .join(", ")}]`, - `language: ${c.language}`, - `framework: [${c.framework.map((f) => JSON.stringify(f)).join(", ")}]`, - `source_context:\n${c.source_context}`, - ]; - if (c.usage_evidence?.length) { - parts.push( - `usage_evidence:\n${c.usage_evidence - .map((e) => ` ${e.file}:${e.line}: ${e.excerpt}`) - .join("\n")}` - ); - } - parts.push(`[/Candidate ${index + 1}]`); - return parts.join("\n"); -} - -async function classifyBatch( - batch: DittoScanCandidate[], - stats: Stats -): Promise[] | null> { - const userPrompt = [ - `Classify the following ${batch.length} string literal candidates.`, - `Return a JSON object with an "items" array containing one classification per candidate, in the same order.`, - "", - batch.map((c, i) => formatCandidate(c, i)).join("\n\n"), - ].join("\n"); - - try { - const result = await callGemini(userPrompt, ZBatchResponse, SYSTEM_PROMPT); - - if (!result) return null; - - stats.llmCalls++; - stats.tokensIn += - result.metadata.geminiUsageMetadata?.promptTokenCount ?? 0; - stats.tokensOut += - result.metadata.geminiUsageMetadata?.candidatesTokenCount ?? 0; - - if (result.data.items.length !== batch.length) { - process.stderr.write( - `[classify] response had ${result.data.items.length} items for ${batch.length} candidates\n` - ); - return null; - } - return result.data.items; - } catch (e: any) { - process.stderr.write( - `[classify/gemini] error: ${e.message ?? String(e)}\n` - ); - return null; - } -} - -async function classifyWithRecovery( - batch: DittoScanCandidate[], - stats: Stats -): Promise { - let items = await classifyBatch(batch, stats); - - if (!items) { - process.stderr.write(`[classify] retrying batch of ${batch.length}...\n`); - items = await classifyBatch(batch, stats); - } - - if (items) { - const byId = new Map(items.map((item) => [item.id, item])); - return batch.flatMap((c) => { - const item = byId.get(c.id); - if (!item) return []; - return [ - { - ...c, - status: item.status, - reason: item.reason, - confidence: item.confidence, - info_needed: item.info_needed, - decided_by: "llm" as const, - }, - ]; - }); - } - - if (batch.length > 1) { - process.stderr.write( - `[classify] splitting batch of ${batch.length} after failure\n` - ); - const mid = Math.ceil(batch.length / 2); - const left = await classifyWithRecovery(batch.slice(0, mid), stats); - const right = await classifyWithRecovery(batch.slice(mid), stats); - return [...left, ...right]; - } - - process.stderr.write( - `[classify] marking candidate ${batch[0].id} as error\n` - ); - return [ - { - ...batch[0], - status: "error" as DittoScanStatus, - reason: "LLM classification failed after retries", - confidence: 0, - info_needed: null, - decided_by: "llm" as const, - }, - ]; -} - -async function classify( - candidates: DittoScanCandidate[], - preClassified: DittoScanResult[] = [] -): Promise { - const startedAt = new Date().toISOString(); - const promptHash = createHash("sha1") - .update(SYSTEM_PROMPT) - .digest("hex") - .slice(0, 12); - const stats: Stats = { llmCalls: 0, tokensIn: 0, tokensOut: 0 }; - const llmResults: DittoScanResult[] = []; - - const allBatches: DittoScanCandidate[][] = []; - for (let i = 0; i < candidates.length; i += BATCH_SIZE) { - allBatches.push(candidates.slice(i, i + BATCH_SIZE)); - } - - for (let i = 0; i < allBatches.length; i += PARALLEL_BATCHES) { - const chunk = allBatches.slice(i, i + PARALLEL_BATCHES); - const chunkResults = await Promise.all( - chunk.map((batch, j) => { - const batchIdx = i + j + 1; - process.stderr.write( - `[classify] batch ${batchIdx}/${allBatches.length} (${batch.length} candidates)\n` - ); - return classifyWithRecovery(batch, stats); - }) - ); - for (const batchResult of chunkResults) { - llmResults.push(...batchResult); - } - } - - // Rule-decided results lead so they appear first in the output file. - // by_status / counts reflect the union. - const results: DittoScanResult[] = [...preClassified, ...llmResults]; - - const byStatus: Partial> = { - unsure: 0, - error: 0, - }; - // by_rule keys are `${language}:${reason}` so rule names that appear in - // multiple languages (e.g. `logger_call` in JS / Kotlin / Swift) stay - // distinguishable. Each rule file owns one logical concept per language; - // collapsing across languages would hide which implementation is doing - // the work. - const byRule: Record = {}; - let ruleCount = 0; - let llmCount = 0; - for (const r of results) { - byStatus[r.status] = (byStatus[r.status] ?? 0) + 1; - if (r.decided_by === "rule") { - ruleCount++; - const key = `${r.language}:${r.reason}`; - byRule[key] = (byRule[key] ?? 0) + 1; - } else { - llmCount++; - } - } - - const numResultsToManuallyValidate = results.filter( - (r) => r.confidence < MIN_CONFIDENCE_LEVEL - ).length; - - const finishedAt = new Date().toISOString(); - const summary: DittoScanSummary = { - run_id: createHash("sha1") - .update(`${startedAt}:${promptHash}`) - .digest("hex") - .slice(0, 16), - started_at: startedAt, - finished_at: finishedAt, - model: CLASSIFY_MODEL, - prompt_hash: promptHash, - candidate_total: results.length, - by_status: byStatus, - num_results_to_manually_verify: numResultsToManuallyValidate, - pct_results_needing_manual_verification: - results.length > 0 - ? (numResultsToManuallyValidate / results.length) * 100 - : 0, - llm_calls: stats.llmCalls, - llm_tokens_in: stats.tokensIn, - llm_tokens_out: stats.tokensOut, - decided_by: { rule: ruleCount, llm: llmCount }, - by_rule: byRule, - }; - - return { results, summary }; -} - -export async function runClassify( - options: DittoScanClassifyOptions -): Promise { - let candidates: DittoScanCandidate[]; - - if (options.candidates) { - candidates = options.candidates; - } else if (options.inputPath) { - const raw = await fs.readFile(options.inputPath, "utf8"); - candidates = raw - .split("\n") - .filter(Boolean) - .map((line) => JSON.parse(line) as DittoScanCandidate); - } else { - const errorMessage = - "Invalid inputs. Missing both 'candidates' and 'inputPath'"; - throw new DittoError({ - type: ErrorType.ScanError, - data: { rawErrorMessage: errorMessage }, - message: errorMessage, - }); - } - - const result = await classify(candidates, options.preClassified); - - const parsedOutput = path.parse(options.outputPath); - const ext = parsedOutput.ext; - const base = path.join(parsedOutput.dir, parsedOutput.name); - - const allStatuses: DittoScanStatus[] = [ - "user-facing", - "not-user-facing", - "unsure", - "error", - ]; - const resultsByStatus: Partial> = {}; - await Promise.all( - allStatuses.map(async (status) => { - const statusResults = result.results.filter((r) => r.status === status); - const filePath = `${base}-${status}${ext}`; - if (statusResults.length === 0) { - await fs.rm(filePath, { force: true }); - return; - } - await fs.writeFile( - filePath, - statusResults.map((r) => JSON.stringify(r)).join("\n") + "\n", - "utf8" - ); - resultsByStatus[status] = filePath; - }) - ); - - await fs.writeFile( - options.summaryPath, - JSON.stringify(result.summary, null, 2) + "\n", - "utf8" - ); - - const toVerify = result.results.filter( - (r) => r.confidence < MIN_CONFIDENCE_LEVEL - ); - let writtenVerifyPath: string | null = null; - if (toVerify.length > 0) { - await fs.writeFile( - options.verifyPath, - toVerify.map((r) => JSON.stringify(r)).join("\n") + "\n", - "utf8" - ); - writtenVerifyPath = options.verifyPath; - } else { - await fs.rm(options.verifyPath, { force: true }); - } - - return { - ...result, - writtenPaths: { resultsByStatus, verify: writtenVerifyPath }, - }; -} diff --git a/lib/src/scan/extract.ts b/lib/src/scan/extract.ts index 1c9148f..fa4241d 100644 --- a/lib/src/scan/extract.ts +++ b/lib/src/scan/extract.ts @@ -9,7 +9,7 @@ import { } from "./types"; import { createHash } from "crypto"; import type { LlmFileTaskStats } from "./lang/llm-file-discovery"; -import { getClassificationVerdict, shouldEmit, type Verdict } from "./rules"; +import { shouldEmit } from "./rules"; import { walkCodebase } from "./walk"; export interface DittoScanExtractOptions { @@ -18,7 +18,6 @@ export interface DittoScanExtractOptions { export interface DittoScanExtractResult { candidates: DittoScanCandidate[]; - verdicts: Map; summary: DittoScanExtractSummary; } @@ -28,8 +27,6 @@ export interface DittoScanExtractSummary { filesSkippedMinified: number; candidatesEmitted: number; candidatesByKind: Record; - candidatesByVerdict: { accept: number; reject: number; llm: number }; - ruleHits: Record; framework: string[]; elapsedMs: number; i18nFileDiscovery: LlmFileTaskStats | null; @@ -249,9 +246,6 @@ export async function runExtract( const candidatesByKind = zeroKindCounts(); const candidates: DittoScanCandidate[] = []; - const verdicts = new Map(); - const candidatesByVerdict = { accept: 0, reject: 0, llm: 0 }; - const ruleHits: Record = {}; for (const file of files) { const lang = file.language; @@ -296,32 +290,17 @@ export async function runExtract( }; candidates.push(candidate); candidatesByKind[hit.context.parentRole]++; - - const verdict = getClassificationVerdict({ - candidate, - context: hit.context, - }); - verdicts.set(candidate.id, verdict); - if (verdict.kind === "accept" || verdict.kind === "reject") { - candidatesByVerdict[verdict.kind]++; - ruleHits[verdict.rule] = (ruleHits[verdict.rule] ?? 0) + 1; - } else { - candidatesByVerdict.llm++; - } } } return { candidates, - verdicts, summary: { filesScanned: files.length, filesByKind, filesSkippedMinified, candidatesEmitted: candidates.length, candidatesByKind, - candidatesByVerdict, - ruleHits, framework, elapsedMs: Date.now() - t0, i18nFileDiscovery, diff --git a/lib/src/scan/lang/i18n-file-discovery.ts b/lib/src/scan/lang/i18n-file-discovery.ts index 01fb31c..0d78a5d 100644 --- a/lib/src/scan/lang/i18n-file-discovery.ts +++ b/lib/src/scan/lang/i18n-file-discovery.ts @@ -1,4 +1,8 @@ -import { runLlmFileTask, type LlmFileTask, type LlmFileTaskResult } from "./llm-file-discovery"; +import { + runLlmFileTask, + type LlmFileTask, + type LlmFileTaskResult, +} from "./llm-file-discovery"; const NEVER_FILENAMES: ReadonlySet = new Set([ "package.json", @@ -10,7 +14,11 @@ const NEVER_FILENAMES: ReadonlySet = new Set([ "bun.lockb", ]); -const NEVER_PATTERNS: readonly RegExp[] = [/^tsconfig\.[^/]+\.json$/i, /\.schema\.json$/i, /\.lock$/i]; +const NEVER_PATTERNS: readonly RegExp[] = [ + /^tsconfig\.[^/]+\.json$/i, + /\.schema\.json$/i, + /\.lock$/i, +]; const SYSTEM_PROMPT = `You identify internationalization (i18n) files in a codebase. @@ -52,19 +60,32 @@ export const I18N_FILE_EXTENSIONS: ReadonlySet = new Set([ ]); // Subset that's single-purpose enough to skip the LLM round trip. -const AUTO_INCLUDE_EXTENSIONS: ReadonlySet = new Set([".po", ".arb", ".xliff", ".xlf", ".resx", ".resw"]); +const AUTO_INCLUDE_EXTENSIONS: ReadonlySet = new Set([ + ".po", + ".arb", + ".xliff", + ".xlf", + ".resx", + ".resw", +]); export const I18N_FILES_TASK: LlmFileTask = { taskName: "i18n files", globs: [...I18N_FILE_EXTENSIONS].map((ext) => `**/*${ext}`), - preFilter: (base) => NEVER_FILENAMES.has(base) || NEVER_PATTERNS.some((re) => re.test(base)), + preFilter: (base) => + NEVER_FILENAMES.has(base) || NEVER_PATTERNS.some((re) => re.test(base)), autoInclude: (relPath) => { const dot = relPath.lastIndexOf("."); - return dot !== -1 && AUTO_INCLUDE_EXTENSIONS.has(relPath.slice(dot).toLowerCase()); + return ( + dot !== -1 && + AUTO_INCLUDE_EXTENSIONS.has(relPath.slice(dot).toLowerCase()) + ); }, systemPrompt: SYSTEM_PROMPT, }; -export async function findI18nFiles(rootPath: string): Promise { +export async function findI18nFiles( + rootPath: string +): Promise { return runLlmFileTask(rootPath, I18N_FILES_TASK); } diff --git a/lib/src/scan/preClassify.ts b/lib/src/scan/preClassify.ts deleted file mode 100644 index 24fe90f..0000000 --- a/lib/src/scan/preClassify.ts +++ /dev/null @@ -1,50 +0,0 @@ -import type { DittoScanCandidate, DittoScanResult } from "./types"; -import type { Verdict } from "./rules"; - -export interface DittoScanPreClassifyResult { - // Candidates the rule classifier did not decide on. These flow to the - // LLM stage. - llmCandidates: DittoScanCandidate[]; - // Candidates the rule classifier decided. Each is materialized as a - // Result with confidence=1 and decided_by="rule" so it interleaves with - // LLM-decided results in the final output. - preClassified: DittoScanResult[]; -} - -// Bridge between `runExtract` (which produces Candidates + Verdicts) and -// `runClassify` (which only wants LLM-bound Candidates). Splits the -// extract output and materializes rule-decided Results. -export function preClassify( - candidates: DittoScanCandidate[], - verdicts: Map -): DittoScanPreClassifyResult { - const llmCandidates: DittoScanCandidate[] = []; - const preClassified: DittoScanResult[] = []; - - for (const c of candidates) { - const verdict = verdicts.get(c.id) ?? { kind: "llm" as const }; - if (verdict.kind === "accept") { - preClassified.push({ - ...c, - status: "user-facing", - reason: `rule:${verdict.rule}`, - confidence: 1, - info_needed: null, - decided_by: "rule", - }); - } else if (verdict.kind === "reject") { - preClassified.push({ - ...c, - status: "not-user-facing", - reason: `rule:${verdict.rule}`, - confidence: 1, - info_needed: null, - decided_by: "rule", - }); - } else { - llmCandidates.push(c); - } - } - - return { llmCandidates, preClassified }; -} diff --git a/lib/src/scan/rules/drop.test.ts b/lib/src/scan/rules.test.ts similarity index 97% rename from lib/src/scan/rules/drop.test.ts rename to lib/src/scan/rules.test.ts index e78dcf4..05467cb 100644 --- a/lib/src/scan/rules/drop.test.ts +++ b/lib/src/scan/rules.test.ts @@ -1,6 +1,6 @@ -import type { DittoScanEnclosingContext } from "../types"; +import type { DittoScanEnclosingContext } from "./types"; -import { shouldEmit } from "./drop"; +import { shouldEmit } from "./rules"; const other: DittoScanEnclosingContext = { parentRole: "other", diff --git a/lib/src/scan/rules/drop.ts b/lib/src/scan/rules.ts similarity index 99% rename from lib/src/scan/rules/drop.ts rename to lib/src/scan/rules.ts index bc5e840..f976c85 100644 --- a/lib/src/scan/rules/drop.ts +++ b/lib/src/scan/rules.ts @@ -1,7 +1,7 @@ import type { DittoScanDetectionKind, DittoScanEnclosingContext, -} from "../types"; +} from "./types"; // Unambiguous non-text value shapes. A pattern belongs here only if there // is effectively zero chance a real UI string could match it. diff --git a/lib/src/scan/rules/accept/common.ts b/lib/src/scan/rules/accept/common.ts deleted file mode 100644 index 97307d7..0000000 --- a/lib/src/scan/rules/accept/common.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { Rule } from "../types"; - -// HTML/JSX/Vue attribute names whose values are exclusively human-readable -// copy. Symmetric counterpart to NON_TEXT_ATTR_NAMES in drop.ts: those are -// always-non-text attrs (dropped); these are always-copy attrs (accepted). -const COPY_ATTR_NAMES: ReadonlySet = new Set([ - "placeholder", - "alt", - "aria-label", - "aria-placeholder", - "aria-roledescription", - "aria-valuetext", - "title", - "summary", -]); - -// HTML elements whose text content is unambiguously user-facing copy. -// Allowlist (rather than denylist) so component tags like `` and -// generic containers like `
` fall through to the LLM — components -// can have arbitrary semantics (e.g. `` in react-i18next treats -// children as a translation key), and `
` is too generic. -// -// `text` is included to cover two distinct cases that happen to share -// the same lowercased tag name: -// - React Native's `...` (the canonical RN copy holder) -// - SVG's `...` (renders the inner text inside the SVG) -// Both are unambiguous copy. -const COPY_TAGS: ReadonlySet = new Set([ - // Headings + sectioning - "h1", - "h2", - "h3", - "h4", - "h5", - "h6", - "p", - "blockquote", - // Lists / tables - "li", - "dd", - "dt", - "td", - "th", - "caption", - // Forms / interactive - "button", - "a", - "label", - "option", - "legend", - "summary", - // Figures - "figcaption", - // Inline phrasing - "span", - "text", - "em", - "strong", - "b", - "i", - "small", - "mark", - "q", - "cite", - "dfn", - "abbr", - "time", - "sub", - "sup", - "ins", - "del", -]); - -export const COMMON_ACCEPT_RULES: Rule[] = [ - { - name: "resource_value", - // Every entry in a localization resource file (.strings, .stringsdict, - // .xcstrings, strings.xml) is intentional product copy, including the - // entries the developer marked `translatable="false"`. Those are still - // user-facing strings (app names, brand strings, accessibility labels); - // the flag just opts them out of localization, not out of the UI. - match: ({ candidate }) => candidate.detection_kind === "resource_value", - }, - { - name: "markup_attr_copy", - match: ({ candidate }) => - candidate.detection_kind === "markup_attr" && - candidate.context_identifiers.length > 0 && - COPY_ATTR_NAMES.has(candidate.context_identifiers[0].toLowerCase()), - }, - { - name: "markup_text_safe", - // JSX/Vue text content (``, `

...

`) is - // unambiguous copy when the wrapping tag is a known text-bearing - // HTML element. Components, fragments, generic containers (`
`), - // and code-bearing tags fall through to the LLM. - match: ({ context }) => - context.parentRole === "markup_text" && context.parentTag !== undefined && COPY_TAGS.has(context.parentTag), - }, -]; diff --git a/lib/src/scan/rules/accept/index.ts b/lib/src/scan/rules/accept/index.ts deleted file mode 100644 index 4474d07..0000000 --- a/lib/src/scan/rules/accept/index.ts +++ /dev/null @@ -1,16 +0,0 @@ -import type { Rule, RulesByLanguage } from "../types"; - -import { COMMON_ACCEPT_RULES } from "./common"; -import { KOTLIN_ACCEPT_RULES } from "./kotlin"; -import { SWIFT_ACCEPT_RULES } from "./swift"; - -// Accept rules keyed by Candidate.language. Language-specific rules run -// before cross-language ones; the first match wins. The map is typed -// against `RulesByLanguage` (= `{ [K in RuleLanguage]?: Rule[] }`) so an -// unknown or misspelled key is a compile error. -export const ACCEPT_RULES_BY_LANGUAGE: RulesByLanguage = { - kotlin: KOTLIN_ACCEPT_RULES, - swift: SWIFT_ACCEPT_RULES, -}; - -export const ACCEPT_RULES_COMMON: Rule[] = COMMON_ACCEPT_RULES; diff --git a/lib/src/scan/rules/accept/kotlin.ts b/lib/src/scan/rules/accept/kotlin.ts deleted file mode 100644 index c242761..0000000 --- a/lib/src/scan/rules/accept/kotlin.ts +++ /dev/null @@ -1,45 +0,0 @@ -import type { Rule } from "../types"; - -// `(receiver, method)` pairs whose first string argument is user-facing -// copy. These are the standard Android UI surfaces. Receiver match is -// strict (callee + calleeMember, bare identifier) so an unrelated -// variable named `Toast` doesn't fire. The factory signatures -// (`Toast.makeText(ctx, text, duration)`, `Snackbar.make(view, text, duration)`) -// place the only String-typed parameter at arg index 1, so any string -// literal at these call sites is necessarily the message. -const ANDROID_UI_FACTORY_CALLS: ReadonlyArray = [ - ["Toast", "makeText"], - ["Snackbar", "make"], -]; - -export const KOTLIN_ACCEPT_RULES: Rule[] = [ - { - name: "compose_text", - // Jetpack Compose's `Text(...)` composable takes a String literal that - // is rendered directly as UI. Unlike SwiftUI's Text (which auto-looks - // up against Localizable bundles via LocalizedStringKey), Compose - // performs no resource lookup — the literal IS the displayed copy. - // - // Gated on the `android` framework token so a non-Android Kotlin - // project that happens to define its own `Text(...)` function doesn't - // get every constructor argument auto-classified as UI copy. - // - // We match only the bare-identifier call `Text("...")`, not - // `Text(stringResource(...))` (which doesn't put a literal in the - // first arg position) and not method calls like `myWidget.Text(...)`. - match: ({ candidate, context }) => - candidate.framework.includes("android") && context.callee === "Text" && context.calleeMember === undefined, - }, - { - name: "android_ui_factory", - // `Toast.makeText(ctx, "Saved", LENGTH_SHORT)`, `Snackbar.make(view, "...", ...)`. - // Receiver is a bare type identifier; the method is a known factory. - match: ({ context }) => { - if (!context.callee || !context.calleeMember) return false; - for (const [receiver, method] of ANDROID_UI_FACTORY_CALLS) { - if (context.callee === receiver && context.calleeMember === method) return true; - } - return false; - }, - }, -]; diff --git a/lib/src/scan/rules/accept/swift.ts b/lib/src/scan/rules/accept/swift.ts deleted file mode 100644 index 3f0247e..0000000 --- a/lib/src/scan/rules/accept/swift.ts +++ /dev/null @@ -1,5 +0,0 @@ -import type { Rule } from "../types"; - -// SwiftUI string arguments are often user-facing, but can also be -// localization keys. Keep those ambiguous cases on the LLM path. -export const SWIFT_ACCEPT_RULES: Rule[] = []; diff --git a/lib/src/scan/rules/index.ts b/lib/src/scan/rules/index.ts deleted file mode 100644 index 93ef9c3..0000000 --- a/lib/src/scan/rules/index.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { ACCEPT_RULES_BY_LANGUAGE, ACCEPT_RULES_COMMON } from "./accept"; -import { REJECT_RULES_BY_LANGUAGE, REJECT_RULES_COMMON } from "./reject"; -import type { Rule, RuleInput, RuleLanguage, RulesByLanguage, Verdict } from "./types"; - -export { shouldEmit } from "./drop"; -export type { Rule, RuleInput, Verdict } from "./types"; - -// Guarded lookup for language-keyed rule maps. `candidate.language` is a -// free-form string at runtime (the extractor emits a wider set than rules -// dispatch on), so a value like "constructor" would otherwise surface an -// inherited prototype property and crash the `for...of` below. -function getLanguageRules(rules: RulesByLanguage, lang: string): Rule[] { - return Object.prototype.hasOwnProperty.call(rules, lang) ? rules[lang as RuleLanguage] ?? [] : []; -} - -// Apply the deterministic rule classifier to a candidate that has already -// cleared `shouldEmit`. Returns `{ kind: "llm" }` when no rule matches; -// the caller then sends the candidate to the LLM stage. -// -// Dispatch order, first match wins: -// 1. Reject before accept. A reject carve-out like Android -// `translatable="false"` needs to win over a broad accept rule -// (`resource_value`) that would otherwise fire on the same candidate. -// 2. Within reject (and within accept): language-specific before common. -// Lets a per-language rule disambiguate a shape that the cross-language -// list would otherwise treat generically. -// -// The candidate's `language` is a free string at the type level (the -// extractor emits a wider set than rules dispatch on, including -// regex-fallback extensions). The lookups below treat any non-RuleLanguage -// tag as "no language-specific rules", so the candidate flows naturally -// through common-then-llm. -export function getClassificationVerdict(input: RuleInput): Verdict { - const lang = input.candidate.language; - const rejectLangRules = getLanguageRules(REJECT_RULES_BY_LANGUAGE, lang); - const acceptLangRules = getLanguageRules(ACCEPT_RULES_BY_LANGUAGE, lang); - - for (const rule of rejectLangRules) { - if (rule.match(input)) return { kind: "reject", rule: rule.name }; - } - for (const rule of REJECT_RULES_COMMON) { - if (rule.match(input)) return { kind: "reject", rule: rule.name }; - } - for (const rule of acceptLangRules) { - if (rule.match(input)) return { kind: "accept", rule: rule.name }; - } - for (const rule of ACCEPT_RULES_COMMON) { - if (rule.match(input)) return { kind: "accept", rule: rule.name }; - } - return { kind: "llm" }; -} diff --git a/lib/src/scan/rules/reject/common.ts b/lib/src/scan/rules/reject/common.ts deleted file mode 100644 index e7cd710..0000000 --- a/lib/src/scan/rules/reject/common.ts +++ /dev/null @@ -1,9 +0,0 @@ -import type { Rule } from "../types"; - -// No cross-language reject rules at present. We previously rejected -// Android `` entries on the theory that -// they're developer-only configuration values. In practice many of -// those entries are real UI copy that the developer just chose not to -// localize (app names, brand strings, accessibility labels), so they -// flow through as `resource_value` accepts like everything else. -export const COMMON_REJECT_RULES: Rule[] = []; diff --git a/lib/src/scan/rules/reject/index.ts b/lib/src/scan/rules/reject/index.ts deleted file mode 100644 index aa86292..0000000 --- a/lib/src/scan/rules/reject/index.ts +++ /dev/null @@ -1,24 +0,0 @@ -import type { Rule, RulesByLanguage } from "../types"; - -import { COMMON_REJECT_RULES } from "./common"; -import { JAVASCRIPT_REJECT_RULES } from "./javascript"; -import { KOTLIN_REJECT_RULES } from "./kotlin"; -import { SWIFT_REJECT_RULES } from "./swift"; - -// Reject rules keyed by Candidate.language. Language-specific rules run -// before cross-language ones; the first match wins. The map is typed -// against `RulesByLanguage` (= `{ [K in RuleLanguage]?: Rule[] }`) so an -// unknown or misspelled key is a compile error. -export const REJECT_RULES_BY_LANGUAGE: RulesByLanguage = { - javascript: JAVASCRIPT_REJECT_RULES, - typescript: JAVASCRIPT_REJECT_RULES, - jsx: JAVASCRIPT_REJECT_RULES, - tsx: JAVASCRIPT_REJECT_RULES, - // Vue files run their `