diff --git a/docs/tally/compatibility/compatibility-matrix.json b/docs/tally/compatibility/compatibility-matrix.json index 938461dd..00063d30 100644 --- a/docs/tally/compatibility/compatibility-matrix.json +++ b/docs/tally/compatibility/compatibility-matrix.json @@ -1,7 +1,7 @@ { "schema_version": 1, "bridge_commit_sha": "be1c20cc3fd66fa1ece196505c69f26e555e4b8e", - "compatibility_surface_sha256": "dc36ca8aa1d8fbd2ec129db0ccdcf84304e41e743f57198473ad65befa39c716", + "compatibility_surface_sha256": "bf986e5fed55b598ef5c55656955b647a2fdd315d16aca9e63745b80ca9f2e3f", "claims": [ { "claim_id": "erp9-6-6-3-windows-education-xml-one-company", diff --git a/docs/tally/compatibility/compatibility-surface.json b/docs/tally/compatibility/compatibility-surface.json index afff1ede..0d5b88cb 100644 --- a/docs/tally/compatibility/compatibility-surface.json +++ b/docs/tally/compatibility/compatibility-surface.json @@ -91,7 +91,7 @@ }, { "path": "scripts/check-tally-request-builder-hazards.mjs", - "sha256": "d8a013f3a9e7fcb05e523f8afd82272c04563fe67f71d4d4e43cf00fb80e3c12" + "sha256": "552e91525bfcd2d3d3708cfa648914a93bfa193eeb447a7752bf91c2db7a37fc" }, { "path": "scripts/ledger-entries-screen.test.tsx", @@ -958,5 +958,5 @@ "sha256": "a8ac2714fecf51947f2822c8c46d7ce2e8602c732780ff60566a7771f0836f9a" } ], - "manifest_sha256": "dc36ca8aa1d8fbd2ec129db0ccdcf84304e41e743f57198473ad65befa39c716" + "manifest_sha256": "bf986e5fed55b598ef5c55656955b647a2fdd315d16aca9e63745b80ca9f2e3f" } \ No newline at end of file diff --git a/scripts/check-tally-request-builder-hazards.mjs b/scripts/check-tally-request-builder-hazards.mjs index 71cd9bec..fdd76310 100644 --- a/scripts/check-tally-request-builder-hazards.mjs +++ b/scripts/check-tally-request-builder-hazards.mjs @@ -1,21 +1,14 @@ // SPDX-License-Identifier: Apache-2.0 -import { readFileSync, readdirSync } from "node:fs"; -import { dirname, relative, resolve } from "node:path"; +import { readFileSync, readdirSync, realpathSync } from "node:fs"; +import { basename, dirname, relative, resolve } from "node:path"; import { fileURLToPath } from "node:url"; -const scriptRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); -const rootArgument = process.argv.indexOf("--root"); -if (rootArgument !== -1 && !process.argv[rootArgument + 1]) { - throw new Error("--root requires a repository path"); -} -const repositoryRoot = rootArgument === -1 ? scriptRoot : resolve(process.argv[rootArgument + 1]); - // These are legacy, deliberately quarantined request profiles. The match is // exact: removing one does not create capacity for another, and adding either // hazard anywhere else fails this gate. New exceptions require reviewed edits // to this list and a distinct request-profile decision. -const expected = new Set([ +export const expected = new Set([ "custom-report|src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs::render_company_list|Company Report", "custom-report|src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs::render_ledgers|BRIDGE Ledger Export V1", "custom-report|src-tauri/crates/bridge-tally-protocol/src/xml_read_profiles.rs::render_vouchers|BRIDGE Voucher Export V2", @@ -28,25 +21,146 @@ const expected = new Set([ "function-argument-with-space|src-tauri/src/tally/tdl_engine.rs::ledger_period_balances_request|$$NumItems:BRIDGE Ledger Period Collection V1", ]); -const actual = new Set(); -for (const sourceRoot of ["src-tauri", "tools"]) { - for (const path of rustFiles(resolve(repositoryRoot, sourceRoot))) { - scanRequestBuilderStrings(repositoryRoot, path, actual); +// The module files the scan skips as test code, pinned exactly for the same +// reason the violations are. The quarantine below decides which files are test +// modules, but it lexes Rust and cannot see every way a file can be loaded; a +// mistake there must surface as a named diff here, not as a quieter gate. When +// you extract a test module, confirm the new file is loaded only under +// #[cfg(test)] and add it; nothing else should ever be added. +export const EXPECTED_TEST_MODULE_FILES = new Set([ + "src-tauri/crates/bridge-tally-core/src/book_presence_tests.rs", + "src-tauri/crates/bridge-tally-core/src/master_binding_tests.rs", + "src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire_currency_tests.rs", + "src-tauri/crates/bridge-tally-protocol/src/native_outstandings/wire_group_tests.rs", + "src-tauri/crates/bridge-tally-protocol/src/native_trial_balance/tests.rs", + "src-tauri/src/agent_admission_tests.rs", + "src-tauri/src/agent_company_identity_tests.rs", + "src-tauri/src/agent_company_tuple_tests.rs", + "src-tauri/src/agent_delivery_tests.rs", + "src-tauri/src/agent_desktop_journal_tests.rs", + "src-tauri/src/agent_egress_tests.rs", + "src-tauri/src/agent_failure_tests.rs", + "src-tauri/src/agent_financial_profile_tests.rs", + "src-tauri/src/agent_import_amend_tests.rs", + "src-tauri/src/agent_import_bank_tests.rs", + "src-tauri/src/agent_import_boundary_tests.rs", + "src-tauri/src/agent_import_file_tests.rs", + "src-tauri/src/agent_import_identity_tests.rs", + "src-tauri/src/agent_import_index_tests.rs", + "src-tauri/src/agent_import_ledger_stream_tests.rs", + "src-tauri/src/agent_import_ledger_tests.rs", + "src-tauri/src/agent_import_mode_tests.rs", + "src-tauri/src/agent_import_multiplicity_tests.rs", + "src-tauri/src/agent_import_persistence_tests.rs", + "src-tauri/src/agent_import_post_tests.rs", + "src-tauri/src/agent_import_preflight_tests.rs", + "src-tauri/src/agent_import_qualification_tests.rs", + "src-tauri/src/agent_import_source_tests.rs", + "src-tauri/src/agent_import_tests.rs", + "src-tauri/src/agent_import_text_tests.rs", + "src-tauri/src/agent_import_verify_mode_tests.rs", + "src-tauri/src/agent_lab_import_tests.rs", + "src-tauri/src/agent_movement_snapshot_tests.rs", + "src-tauri/src/agent_movement_tests.rs", + "src-tauri/src/agent_outstandings_tests.rs", + "src-tauri/src/agent_post_cancellation_tests.rs", + "src-tauri/src/agent_post_recovery_tests.rs", + "src-tauri/src/agent_presence_tests.rs", + "src-tauri/src/agent_protocol_cap_tests.rs", + "src-tauri/src/agent_protocol_evidence_tests.rs", + "src-tauri/src/agent_protocol_redaction_tests.rs", + "src-tauri/src/agent_protocol_tests.rs", + "src-tauri/src/agent_receipt_fields_tests.rs", + "src-tauri/src/agent_recovery_tests.rs", + "src-tauri/src/agent_response_tests.rs", + "src-tauri/src/agent_status_identity_tests.rs", + "src-tauri/src/agent_status_tests.rs", + "src-tauri/src/agent_tests.rs", + "src-tauri/src/agent_voucher_parse_tests.rs", + "src-tauri/src/agent_voucher_selection_tests.rs", + "src-tauri/src/agent_wire_evidence_tests.rs", + "src-tauri/src/commands_native_ledger_tests.rs", + "src-tauri/src/commands_party_statement_export_tests.rs", + "src-tauri/src/commands_statement_export_tests.rs", + "src-tauri/src/commands_tests.rs", + "src-tauri/src/db/tally_capability_license_tests.rs", + "src-tauri/src/db/tally_mirror_tests.rs", + "src-tauri/src/db/tally_write_store_tests.rs", + "src-tauri/src/endpoint_coordination_macos_tests.rs", + "src-tauri/src/endpoint_coordination_tests.rs", + "src-tauri/src/local_files/paths_tests.rs", + "src-tauri/src/sync/coordinator_lease_tests.rs", + "src-tauri/src/sync/reconciliation_tests.rs", + "src-tauri/src/sync/snapshot_tests.rs", + "src-tauri/src/tally/connection_tests.rs", + "src-tauri/src/tally/runtime_agent_read_evidence_tests.rs", + "src-tauri/src/tally/runtime_failure_evidence_inventory_tests.rs", + "src-tauri/src/tally/runtime_financial_mode_tests.rs", + "src-tauri/src/tally/runtime_import_admission_tests.rs", + "src-tauri/src/tally/runtime_ledger_opening_tests.rs", + "src-tauri/src/tally/runtime_outstandings_currency_tests.rs", + "src-tauri/src/tally/runtime_party_evidence_tests.rs", + "src-tauri/src/tally/runtime_tests.rs", + "src-tauri/src/tally/runtime_trial_balance_tests.rs", +]); + +export function collect(repositoryRoot) { + const testModules = testOnlyModuleFiles(repositoryRoot); + const violations = new Set(); + const skipped = new Set(); + for (const sourceRoot of ["src-tauri", "tools"]) { + for (const path of rustFiles(resolve(repositoryRoot, sourceRoot))) { + const file = relativePath(repositoryRoot, path); + if (testModules.has(file)) { + skipped.add(file); + continue; + } + scanRequestBuilderStrings(repositoryRoot, path, violations); + } } + return { violations, skipped }; } -const unexpected = [...actual].filter((violation) => !expected.has(violation)).sort(); -const missing = [...expected].filter((violation) => !actual.has(violation)).sort(); -if (unexpected.length || missing.length) { - throw new Error( - "Tally request-builder hazard allowlist changed:\n" + - (unexpected.length ? `unexpected:\n${unexpected.map((value) => `- ${value}`).join("\n")}\n` : "") + - (missing.length ? `missing:\n${missing.map((value) => `- ${value}`).join("\n")}\n` : "") + - "Use a native Collection export by default; a new exception requires a reviewed exact-set update.", +function main() { + const scriptRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); + const rootArgument = process.argv.indexOf("--root"); + if (rootArgument !== -1 && !process.argv[rootArgument + 1]) { + throw new Error("--root requires a repository path"); + } + const repositoryRoot = rootArgument === -1 ? scriptRoot : resolve(process.argv[rootArgument + 1]); + const { violations: actual, skipped } = collect(repositoryRoot); + + const unexpected = [...actual].filter((violation) => !expected.has(violation)).sort(); + const missing = [...expected].filter((violation) => !actual.has(violation)).sort(); + if (unexpected.length || missing.length) { + throw new Error( + "Tally request-builder hazard allowlist changed:\n" + + (unexpected.length ? `unexpected:\n${unexpected.map((value) => `- ${value}`).join("\n")}\n` : "") + + (missing.length ? `missing:\n${missing.map((value) => `- ${value}`).join("\n")}\n` : "") + + "Use a native Collection export by default; a new exception requires a reviewed exact-set update.", + ); + } + if (rootArgument === -1) { + const newlySkipped = [...skipped].filter((file) => !EXPECTED_TEST_MODULE_FILES.has(file)).sort(); + const noLongerSkipped = [...EXPECTED_TEST_MODULE_FILES].filter((file) => !skipped.has(file)).sort(); + if (newlySkipped.length || noLongerSkipped.length) { + throw new Error( + "test-module quarantine changed:\n" + + (newlySkipped.length ? `newly skipped:\n${newlySkipped.map((value) => `- ${value}`).join("\n")}\n` : "") + + (noLongerSkipped.length ? `no longer skipped:\n${noLongerSkipped.map((value) => `- ${value}`).join("\n")}\n` : "") + + "Add a file only after confirming it is loaded solely under #[cfg(test)].", + ); + } + } + console.log( + `Tally request-builder hazards match the pinned set (${actual.size} violations; ` + + `${skipped.size} test-only module files skipped).`, ); } -console.log(`Tally request-builder hazards match the pinned set (${actual.size} violations).`); +function relativePath(repositoryRoot, path) { + return relative(repositoryRoot, path).replaceAll("\\", "/"); +} function rustFiles(directory) { const files = []; @@ -78,7 +192,9 @@ function scanRequestBuilderStrings(repositoryRoot, path, violations) { // Test-quarantine strategy: strings are skipped when they fall inside a // #[cfg(test)] *module* body (specifically `#[cfg(test)] mod name { ... }`, // tracked via brace-depth) or inside a file under a tests/ directory - // (integration tests). This is deliberate, not incidental: unit tests such + // (integration tests). The same module moved to its own file and loaded by + // `#[cfg(test)] #[path = "..."] mod name;` is skipped whole by collect(); + // see testOnlyModuleFiles() at the end of this file for how that is decided. This is deliberate, not incidental: unit tests such // as `exact_report_collection_is_shared_by_count_and_rows`, which live // inside `#[cfg(test)] mod tests { ... }`, assert against string literals // containing `` or `$$NumItems:... With Spaces` as @@ -340,3 +456,405 @@ function enclosingFunction(source, position) { const functions = [...prefix.matchAll(/(?:pub(?:\([^)]*\))?\s+)?fn\s+([A-Za-z0-9_]+)/g)]; return functions.at(-1)?.[1] ?? ""; } + +// --------------------------------------------------------------------------- +// Test-module quarantine for extracted test files. +// +// An inline `#[cfg(test)] mod tests { ... }` body is skipped by scanStrings(). +// The same module moved to its own file -- `#[cfg(test)] #[path = "x_tests.rs"] +// mod tests;` -- would otherwise be scanned as production code. This finds the +// files that are loaded only as test modules and lets collect() skip them. +// +// Every rule errs toward scanning: a wrong answer here may raise a false alarm, +// but must never skip a production file. File names are never trusted. +// +// A file is test-only when a trusted edge reaches it and no veto names it: +// +// Edges (only from files whose brace depth never underflows and ends at 0): +// - a brace-depth-0 out-of-line `mod name;` whose attribute group has a +// `cfg` implying `test` (`cfg(test)`, or `test` as an argument of +// `cfg(all(...))`), with `#[path]`, or bare in a crate root or mod.rs; +// - any brace-depth-0 out-of-line `mod name;` declared *by* a test-only file +// (it cannot compile unless its parent does), iterated to a fixed point. +// `#[path]` resolves beside the declaring file (Rust Reference, "The path +// attribute", for modules not inside an inline module); a bare `mod name;` +// resolves beside a file that owns its directory and under `/` otherwise. +// +// Vetoes, collected from every .rs file in the repository except those already +// test-only, and matched by basename case-insensitively so they over-veto: +// - any `mod name;` at any depth that is not itself an edge (`name.rs`, +// `name/mod.rs`), including inside inline modules and macro bodies; +// - any string literal written as `path = "..."` or passed to `include!`, +// `include_str!` or `include_bytes!` that is not part of an edge, which +// covers `#[cfg_attr(..., path = "...")]`; +// - every Cargo crate root, by exact path. +// +// Not handled, all of which leave a file scanned: `cfg(any(...))` and other +// cfg shapes, `#[path]` inside an inline module, `mod r#name;`. +// --------------------------------------------------------------------------- + +export function testOnlyModuleFiles(repositoryRoot) { + const files = allFiles(repositoryRoot); + const rust = files.filter((file) => file.endsWith(".rs")); + const exists = new Set(files); + const sources = new Map(rust.map((file) => [file, readFileSync(resolve(repositoryRoot, file), "utf8")])); + for (const [file, source] of sources) refuseUnrecognisedLoaders(file, source); + const lexed = new Map([...sources].map(([file, source]) => [file, lexModules(source)])); + const roots = crateRoots(repositoryRoot, files); + + let testOnly = new Map(); // file -> owns its directory + for (let round = 0; round < 64; round += 1) { + const vetoBase = new Set(); + const vetoDirModule = new Set(); + const candidates = new Map(); + for (const [file, lex] of lexed) { + const parentIsTest = testOnly.has(file); + for (const declaration of lex.declarations) { + const edge = + lex.healthy && + declaration.depth === 0 && + (parentIsTest || declaration.attributes.some(impliesTest)); + const target = edge ? resolveDeclaration(file, declaration, parentIsTest ? testOnly.get(file) : null, exists) : null; + if (target) { + if (!candidates.has(target.path)) candidates.set(target.path, target.ownsDirectory); + continue; + } + if (parentIsTest) continue; + vetoBase.add(`${declaration.name}.rs`.toLowerCase()); + vetoDirModule.add(`${declaration.name}/mod.rs`.toLowerCase()); + for (const value of declaration.pathValues) vetoBase.add(basename(value).toLowerCase()); + } + if (parentIsTest) continue; + for (const reference of lex.looseReferences) vetoBase.add(basename(reference).toLowerCase()); + } + const next = new Map(); + for (const [path, ownsDirectory] of candidates) { + const base = basename(path).toLowerCase(); + const dirModule = `${basename(dirname(path))}/${base}`.toLowerCase(); + if (roots.has(path) || vetoBase.has(base) || (base === "mod.rs" && vetoDirModule.has(dirModule))) continue; + next.set(path, ownsDirectory); + } + const stable = next.size === testOnly.size && [...next.keys()].every((path) => testOnly.has(path)); + testOnly = next; + if (stable) return new Set(testOnly.keys()); + } + throw new Error("test-module quarantine did not converge"); +} + +// Ways to load a module file that the lexer below does not model. Matched on raw +// source, so text in comments or strings can trip it too: that fails the gate +// loudly, which is the intended direction. Every pattern here is absent from the +// repository today; if one is ever needed, teach the lexer about it first. +const UNRECOGNISED_LOADERS = [ + [/\bmod\s*\$/, "a macro declaring a module through a metavariable"], + [/\bmod\s+r#/, "a raw-identifier module name"], + [/\bmod\b\s*\/[*/]|\bmod\s+[A-Za-z_][A-Za-z0-9_]*\b\s*\/[*/]/, "a comment inside a module declaration"], + [/\bpath\s*=\s*\/[*/]/, "a comment inside a path attribute"], + [/\bpath\s*=\s*r?#*"[^"\n]*\\/, "a path attribute containing a backslash"], + [/\binclude\s+!|\binclude!\s*[[{]|\binclude!\s*\(\s*(?!r?#*")/, "include! without a literal argument"], + [/\binclude!\s*\(\s*r?#*"(?![^"]*\.rs")/, "include! of a file that is not .rs"], +]; + +function refuseUnrecognisedLoaders(file, source) { + for (const [pattern, description] of UNRECOGNISED_LOADERS) { + const match = pattern.exec(source); + if (match) { + const line = source.slice(0, match.index).split("\n").length; + throw new Error( + `test-module quarantine refuses ${file}:${line}: ${description}. ` + + "It cannot tell whether that loads a file as production code.", + ); + } + } +} + +function resolveDeclaration(file, declaration, parentOwnsDirectory, exists) { + const directory = dirname(file); + const explicit = declaration.attributes.map(pathAttribute).find((value) => value !== null); + if (explicit !== undefined) { + const path = normalise(`${directory}/${explicit}`); + return exists.has(path) ? { path, ownsDirectory: true } : null; + } + const ownsDirectory = + parentOwnsDirectory ?? ["lib.rs", "main.rs", "mod.rs"].includes(basename(file)); + const base = ownsDirectory || basename(file) === "mod.rs" ? directory : `${directory}/${basename(file, ".rs")}`; + for (const candidate of [`${base}/${declaration.name}.rs`, `${base}/${declaration.name}/mod.rs`]) { + const path = normalise(candidate); + if (exists.has(path)) return { path, ownsDirectory: basename(path) === "mod.rs" }; + } + return null; +} + +function normalise(path) { + const parts = []; + for (const part of path.split("/")) { + if (part === "" || part === ".") continue; + if (part === "..") parts.pop(); + else parts.push(part); + } + return parts.join("/"); +} + +function impliesTest(attribute) { + // A comment or raw string inside the predicate is not parsed; treat it as not + // implying test, which keeps the module scanned. + if (/\/\*|\/\/|\br#*"/.test(attribute)) return false; + const cfg = /^\s*cfg\s*\(([\s\S]*)\)\s*$/.exec(attribute); + return cfg !== null && predicateImpliesTest(cfg[1].trim()); +} + +function predicateImpliesTest(predicate) { + if (predicate === "test") return true; + const all = /^all\s*\(([\s\S]*)\)$/.exec(predicate); + return all !== null && topLevelArguments(all[1]).some(predicateImpliesTest); +} + +function topLevelArguments(text) { + const argumentsFound = []; + let depth = 0; + let quoted = false; + let current = ""; + for (let index = 0; index < text.length; index += 1) { + const c = text[index]; + if (quoted) { + current += c; + if (c === "\\") { + current += text[index + 1] ?? ""; + index += 1; + } else if (c === '"') quoted = false; + continue; + } + if (c === '"') quoted = true; + else if (c === "(") depth += 1; + else if (c === ")") depth -= 1; + else if (c === "," && depth === 0) { + argumentsFound.push(current.trim()); + current = ""; + continue; + } + current += c; + } + if (current.trim()) argumentsFound.push(current.trim()); + return argumentsFound; +} + +function pathAttribute(attribute) { + const path = /^\s*path\s*=\s*(?:r(#*)"([\s\S]*)"\1|"((?:\\.|[^"\\])*)")\s*$/.exec(attribute); + if (path === null) return null; + return path[2] ?? path[3]; +} + +// One forward pass that skips comments, string and char literals, and records: +// out-of-line `mod name;` declarations with their full outer attribute group and +// brace depth; string literals used as `path = "..."` or `include*!("...")` +// outside such a group; and whether the file's braces balance. +export function lexModules(source) { + const declarations = []; + const looseReferences = []; + const n = source.length; + let i = 0; + let depth = 0; + let healthy = true; + let group = null; // { start, attributes, pathValues } for the attribute group being read + + const skipTrivia = (position) => { + for (;;) { + while (position < n && /\s/.test(source[position])) position += 1; + if (source.startsWith("//", position)) { + const end = source.indexOf("\n", position); + position = end === -1 ? n : end; + } else if (source.startsWith("/*", position)) { + position = skipBlockComment(source, position); + } else return position; + } + }; + + while (i < n) { + if (source.startsWith("//", i)) { + const end = source.indexOf("\n", i); + i = end === -1 ? n : end; + continue; + } + if (source.startsWith("/*", i)) { + i = skipBlockComment(source, i); + continue; + } + const literal = readLiteral(source, i); + if (literal) { + const before = source.slice(Math.max(0, i - 256), i).replace(/\s+$/, ""); + if (/(?:^|[^A-Za-z0-9_])path\s*=$/.test(before) || /include(?:_str|_bytes)?!\s*\($/.test(before)) { + looseReferences.push(literal.value); + } + i = literal.end; + continue; + } + if (source.startsWith("#[", i)) { + // Read the whole outer attribute group, then decide whether it attaches + // to an out-of-line module declaration. + const attributes = []; + let cursor = i; + while (source.startsWith("#[", cursor)) { + const end = readBracket(source, cursor + 1); + attributes.push(source.slice(cursor + 2, end - 1)); + cursor = skipTrivia(end); + } + const item = /^(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/.exec(source.slice(cursor, cursor + 160)); + if (item) { + const pathValues = attributes.flatMap((attribute) => attributeStrings(attribute, /(?:^|[^A-Za-z0-9_])path\s*=$/)); + declarations.push({ name: item[1], attributes, pathValues, depth }); + i = cursor + item[0].length; + continue; + } + // Not a module declaration: record any path/include strings inside the + // attributes as loose references, and carry on after the group. + for (const attribute of attributes) { + looseReferences.push(...attributeStrings(attribute, /(?:^|[^A-Za-z0-9_])path\s*=$|include(?:_str|_bytes)?!\s*\($/)); + } + i = cursor; + continue; + } + const bare = /^(?:pub(?:\s*\([^)]*\))?\s+)?mod\s+([A-Za-z_][A-Za-z0-9_]*)\s*;/.exec(source.slice(i, i + 160)); + if (bare && (i === 0 || !/[A-Za-z0-9_]/.test(source[i - 1]))) { + declarations.push({ name: bare[1], attributes: [], pathValues: [], depth }); + i += bare[0].length; + continue; + } + if (source[i] === "{") depth += 1; + else if (source[i] === "}") { + if (depth === 0) healthy = false; + else depth -= 1; + } + const word = /^[A-Za-z_][A-Za-z0-9_]*/.exec(source.slice(i, i + 64)); + i += word ? word[0].length : 1; + } + return { declarations, looseReferences, healthy: healthy && depth === 0 }; +} + +function attributeStrings(attribute, prefix) { + const values = []; + let i = 0; + while (i < attribute.length) { + const literal = readLiteral(attribute, i); + if (literal) { + if (prefix.test(attribute.slice(Math.max(0, i - 256), i).replace(/\s+$/, ""))) values.push(literal.value); + i = literal.end; + } else i += 1; + } + return values; +} + +function skipBlockComment(source, start) { + let depth = 1; + let i = start + 2; + while (i < source.length && depth > 0) { + if (source.startsWith("/*", i)) { + depth += 1; + i += 2; + } else if (source.startsWith("*/", i)) { + depth -= 1; + i += 2; + } else i += 1; + } + return i; +} + +function readBracket(source, start) { + let depth = 0; + let i = start; + while (i < source.length) { + const literal = readLiteral(source, i); + if (literal) { + i = literal.end; + continue; + } + if (source[i] === "[") depth += 1; + else if (source[i] === "]") { + depth -= 1; + if (depth === 0) return i + 1; + } + i += 1; + } + return source.length; +} + +// A string, raw string, byte string or char literal starting at `i`, if any. +// Lifetimes (`'a`) are not literals. +function readLiteral(source, i) { + if (i > 0 && /[A-Za-z0-9_]/.test(source[i - 1]) && source[i] !== '"' && source[i] !== "'") return null; + const raw = /^[bc]?r(#*)"/.exec(source.slice(i, i + 40)); + if (raw) { + const close = `"${raw[1]}`; + const valueStart = i + raw[0].length; + const end = source.indexOf(close, valueStart); + const stop = end === -1 ? source.length : end; + return { value: source.slice(valueStart, stop), end: end === -1 ? source.length : end + close.length }; + } + const quote = source[i] === '"' ? i : (source[i] === "b" || source[i] === "c") && source[i + 1] === '"' ? i + 1 : -1; + if (quote !== -1) { + let j = quote + 1; + let value = ""; + while (j < source.length && source[j] !== '"') { + if (source[j] === "\\" && j + 1 < source.length) { + value += source[j + 1]; + j += 2; + } else { + value += source[j]; + j += 1; + } + } + return { value, end: Math.min(source.length, j + 1) }; + } + const char = /^b?'(?:\\(?:x[0-9A-Fa-f]{2}|u\{[0-9A-Fa-f]{1,6}\}|.)|[^\\'\n])'/u.exec(source.slice(i, i + 16)); + if (char) return { value: "", end: i + char[0].length }; + return null; +} + +function allFiles(repositoryRoot) { + const files = []; + const walk = (directory) => { + const entries = readdirSync(directory, { withFileTypes: true }); + const isCrate = entries.some((entry) => entry.isFile() && entry.name === "Cargo.toml"); + for (const entry of entries) { + if ([".git", "node_modules"].includes(entry.name)) continue; + // Only a crate's (or the repository's) build directory is pruned; a source + // directory that happens to be named `target` is still read. + if (entry.name === "target" && (isCrate || directory === repositoryRoot)) continue; + const path = resolve(directory, entry.name); + if (entry.isDirectory()) walk(path); + else if (entry.isFile()) files.push(relativePath(repositoryRoot, path)); + } + }; + walk(repositoryRoot); + return files; +} + +function crateRoots(repositoryRoot, files) { + const roots = new Set(); + for (const manifest of files.filter((file) => basename(file) === "Cargo.toml")) { + const crate = dirname(manifest); + const prefix = crate === "." ? "" : `${crate}/`; + for (const file of files) { + if (!file.startsWith(prefix) || !file.endsWith(".rs")) continue; + const rest = file.slice(prefix.length).split("/"); + if ( + ["src/lib.rs", "src/main.rs", "build.rs"].includes(rest.join("/")) || + (rest[0] === "src" && rest[1] === "bin") || + (["tests", "benches", "examples"].includes(rest[0]) && (rest.length === 2 || rest.at(-1) === "main.rs")) + ) { + roots.add(file); + } + } + for (const match of readFileSync(resolve(repositoryRoot, manifest), "utf8").matchAll(/\b(?:path|build)\s*=\s*["']([^"']+\.rs)["']/g)) { + roots.add(normalise(`${crate}/${match[1]}`)); + } + } + return roots; +} + +// `import.meta.main` needs Node 24.2+. The job that runs this gate does not pin +// Node, so fall back to comparing real paths rather than silently doing nothing. +const invokedDirectly = + import.meta.main ?? + (process.argv[1] !== undefined && + realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))); +if (invokedDirectly) main(); diff --git a/scripts/check-tally-request-builder-hazards.test.mjs b/scripts/check-tally-request-builder-hazards.test.mjs new file mode 100644 index 00000000..f157664a --- /dev/null +++ b/scripts/check-tally-request-builder-hazards.test.mjs @@ -0,0 +1,356 @@ +// SPDX-License-Identifier: Apache-2.0 + +import assert from "node:assert/strict"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join, resolve } from "node:path"; +import test from "node:test"; +import { fileURLToPath } from "node:url"; + +import { collect, EXPECTED_TEST_MODULE_FILES } from "./check-tally-request-builder-hazards.mjs"; + +const repositoryRoot = resolve(dirname(fileURLToPath(import.meta.url)), ".."); +const CARGO = '[package]\nname = "fixture"\nversion = "0.0.0"\n'; +// A request-builder hazard the scanner reports wherever it is scanned. +const HAZARD = 'pub fn build() -> &\'static str { "" }\n'; + +function fixture(files) { + const root = mkdtempSync(join(tmpdir(), "hazards-")); + mkdirSync(join(root, "tools")); + for (const [path, content] of Object.entries({ "src-tauri/Cargo.toml": CARGO, ...files })) { + mkdirSync(dirname(join(root, path)), { recursive: true }); + writeFileSync(join(root, path), content); + } + try { + return collect(root); + } finally { + rmSync(root, { recursive: true, force: true }); + } +} + +const scanned = (result, file) => [...result.violations].some((violation) => violation.includes(`|src-tauri/src/${file}::`)); +const skipped = (result, file) => result.skipped.has(`src-tauri/src/${file}`); + +function assertSkipped(result, file) { + assert.ok(skipped(result, file), `${file} should be skipped as a test module`); + assert.ok(!scanned(result, file), `${file} should not be scanned`); +} + +function assertScanned(result, file) { + assert.ok(!skipped(result, file), `${file} must not be skipped`); + assert.ok(scanned(result, file), `${file} must be scanned and its hazard reported`); +} + +// --- the case this exists for, and the existing rules it must not disturb --- + +test("an extracted test module loaded under #[cfg(test)] #[path] is skipped", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "lib_tests.rs"]\nmod tests;\n', + "src-tauri/src/lib_tests.rs": HAZARD, + }); + assertSkipped(result, "lib_tests.rs"); +}); + +test("an inline #[cfg(test)] mod body is still skipped and a bare #[cfg(test)] fn is still scanned", () => { + const result = fixture({ + "src-tauri/src/lib.rs": + '#[cfg(test)]\nmod tests {\n fn inline() -> &\'static str { "" }\n}\n' + + '#[cfg(test)]\nfn legacy_request() -> &\'static str { "" }\n', + }); + assert.ok(![...result.violations].some((violation) => violation.endsWith("|Inline"))); + assert.ok([...result.violations].some((violation) => violation.endsWith("|Bare Test Fn"))); +}); + +// --- file names are never trusted --- + +test("a file named *_tests.rs loaded without #[cfg(test)] is scanned", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '#[path = "evil_tests.rs"]\nmod evil;\n', + "src-tauri/src/evil_tests.rs": HAZARD, + }); + assertScanned(result, "evil_tests.rs"); +}); + +// --- cfg shapes --- + +test("cfg(all(test, ...)) implies test and is skipped", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '#[cfg(all(test, target_os = "macos"))]\n#[path = "mac_tests.rs"]\nmod mac_tests;\n', + "src-tauri/src/mac_tests.rs": HAZARD, + }); + assertSkipped(result, "mac_tests.rs"); +}); + +test("cfg(any(test, ...)), cfg(not(test)) and cfg_attr(..., cfg(test)) do not imply test and are scanned", () => { + const result = fixture({ + "src-tauri/src/lib.rs": + '#[cfg(any(test, feature = "x"))]\n#[path = "any_tests.rs"]\nmod any_tests;\n' + + '#[cfg(not(test))]\n#[path = "not_tests.rs"]\nmod not_tests;\n' + + '#[cfg_attr(feature = "x", cfg(test))]\n#[path = "attr_tests.rs"]\nmod attr_tests;\n', + "src-tauri/src/any_tests.rs": HAZARD, + "src-tauri/src/not_tests.rs": HAZARD, + "src-tauri/src/attr_tests.rs": HAZARD, + }); + assertScanned(result, "any_tests.rs"); + assertScanned(result, "not_tests.rs"); + assertScanned(result, "attr_tests.rs"); +}); + +// --- attribute group parsing --- + +test("#[path] before #[cfg(test)], a raw-string path, and an attribute containing ] are all recognised", () => { + const result = fixture({ + "src-tauri/src/lib.rs": + '#[path = "first_tests.rs"]\n#[cfg(test)]\nmod first;\n' + + '#[cfg(test)]\n#[path = r"raw_tests.rs"]\nmod raw;\n' + + '#[doc = "a]"]\n#[cfg(test)]\n#[path = "bracket_tests.rs"]\nmod bracket;\n', + "src-tauri/src/first_tests.rs": HAZARD, + "src-tauri/src/raw_tests.rs": HAZARD, + "src-tauri/src/bracket_tests.rs": HAZARD, + }); + assertSkipped(result, "first_tests.rs"); + assertSkipped(result, "raw_tests.rs"); + assertSkipped(result, "bracket_tests.rs"); +}); + +test("a bare #[cfg(test)] mod tests; in mod.rs resolves beside it and is skipped", () => { + const result = fixture({ + "src-tauri/src/lib.rs": "mod report;\n", + "src-tauri/src/report/mod.rs": "#[cfg(test)]\nmod tests;\n", + "src-tauri/src/report/tests.rs": HAZARD, + }); + assertSkipped(result, "report/tests.rs"); +}); + +// --- declarations that are not trusted edges --- + +test("declaration text inside a comment or a string is not an edge", () => { + const result = fixture({ + "src-tauri/src/lib.rs": + '// #[cfg(test)] #[path = "commented_tests.rs"] mod a;\n' + + 'const S: &str = r#"\n#[cfg(test)]\n#[path = "quoted_tests.rs"]\nmod b;\n"#;\n', + // No production declaration loads these: the scanner reads every .rs file + // under its roots, and a production declaration would veto the skip and + // hide a lexer that mistook the text for a real declaration. + "src-tauri/src/commented_tests.rs": HAZARD, + "src-tauri/src/quoted_tests.rs": HAZARD, + }); + assertScanned(result, "commented_tests.rs"); + assertScanned(result, "quoted_tests.rs"); +}); + +test("a #[cfg(test)] #[path] declaration inside an inline module is not an edge", () => { + const result = fixture({ + "src-tauri/src/lib.rs": 'mod outer {\n #[cfg(test)]\n #[path = "nested_tests.rs"]\n mod nested;\n}\n', + "src-tauri/src/nested_tests.rs": HAZARD, + }); + assertScanned(result, "nested_tests.rs"); +}); + +test("edges from a file whose braces do not balance are not trusted", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '}\n#[cfg(test)]\n#[path = "unbalanced_tests.rs"]\nmod tests;\n', + "src-tauri/src/unbalanced_tests.rs": HAZARD, + }); + assertScanned(result, "unbalanced_tests.rs"); +}); + +// --- propagation --- + +test("a module declared by a test-only file is test-only, unless production also declares it", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "parent_tests.rs"]\nmod tests;\n#[path = "shared_tests.rs"]\nmod shared;\n', + "src-tauri/src/parent_tests.rs": '#[path = "child_tests.rs"]\nmod child;\n#[path = "shared_tests.rs"]\nmod shared;\n', + "src-tauri/src/child_tests.rs": HAZARD, + "src-tauri/src/shared_tests.rs": HAZARD, + }); + assertSkipped(result, "parent_tests.rs"); + assertSkipped(result, "child_tests.rs"); + assertScanned(result, "shared_tests.rs"); +}); + +// --- vetoes: anything that might load the file as production keeps it scanned --- + +test("a bare production mod declaration of the same file vetoes the skip", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "vetoed_tests.rs"]\nmod tests;\nmod vetoed_tests;\n', + "src-tauri/src/vetoed_tests.rs": HAZARD, + }); + assertScanned(result, "vetoed_tests.rs"); +}); + +test("cfg_attr path, a macro body, include!, an inline module and a case variant each veto the skip", () => { + const cases = { + "cfg_attr_tests.rs": '#[cfg_attr(not(test), path = "cfg_attr_tests.rs")]\nmod production;\n', + "macro_tests.rs": "macro_rules! declare { () => { mod macro_tests; }; }\n", + "include_tests.rs": 'const X: &str = include_str!("include_tests.rs");\n', + "inline_tests.rs": "mod outer {\n mod inline_tests;\n}\n", + "case_tests.rs": '#[path = "CASE_TESTS.rs"]\nmod case_variant;\n', + }; + for (const [file, veto] of Object.entries(cases)) { + const result = fixture({ + "src-tauri/src/lib.rs": `#[cfg(test)]\n#[path = "${file}"]\nmod tests;\n`, + "src-tauri/src/other.rs": veto, + [`src-tauri/src/${file}`]: HAZARD, + }); + assertScanned(result, file); + } +}); + +test("a Cargo target path vetoes the skip", () => { + const result = fixture({ + "src-tauri/Cargo.toml": `${CARGO}[[bin]]\nname = "x"\npath = "src/bin_tests.rs"\n`, + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "bin_tests.rs"]\nmod tests;\n', + "src-tauri/src/bin_tests.rs": HAZARD, + }); + assertScanned(result, "bin_tests.rs"); +}); + +// --- the real repository --- + +test("the gate itself runs, matches the pinned violations and pins the quarantine size", () => { + const run = spawnSync(process.execPath, [join(repositoryRoot, "scripts/check-tally-request-builder-hazards.mjs")], { + encoding: "utf8", + }); + assert.equal(run.status, 0, run.stderr); + assert.match(run.stdout, new RegExp(`\\(10 violations; ${EXPECTED_TEST_MODULE_FILES.size} test-only module files skipped\\)`)); +}); + +test("the pinned skip set is exactly what the quarantine finds, file by file", () => { + const { skipped } = collect(repositoryRoot); + assert.deepEqual([...skipped].sort(), [...EXPECTED_TEST_MODULE_FILES].sort()); +}); + +// --- review of #443: loaders the lexer cannot model make the quarantine refuse --- + +test("an unrecognised way of loading a module refuses to skip anything, naming the file", () => { + const loaders = { + metavariable: "macro_rules! d { ($n:ident) => { mod $n; } }\n", + "raw identifier": "mod r#prod;\n", + "comment in declaration": "pub mod /* c */ prod;\n", + "comment after name": "mod prod // c\n;\n", + "comment in path": '#[path = /* c */ "prod.rs"]\nmod p;\n', + backslash: '#[path = "sub\\\\..\\\\prod.rs"]\nmod p;\n', + "concat include": 'include!(concat!("prod", ".rs"));\n', + "brace include": 'include! { "prod.rs" }\n', + "bracket include": 'include!["prod.rs"];\n', + "spaced include": 'include !("prod.rs");\n', + "commented include": 'include!(/* c */ "prod.rs");\n', + "non-rust include": 'include!("modules.in");\n', + }; + for (const [label, loader] of Object.entries(loaders)) { + assert.throws( + () => fixture({ "src-tauri/src/lib.rs": loader, "src-tauri/src/prod.rs": HAZARD }), + /test-module quarantine refuses src-tauri\/src\/lib\.rs:\d+/, + label, + ); + } +}); + +test("lexing gaps the review demonstrated no longer hide a production declaration", () => { + const cases = { + "c raw string": 'const X: &CStr = cr#"a"b"#;\nmod prod_c_tests;\n', + // Exactly the review's input: with no space, `','` is where a one-code-unit char regex goes wrong. + "non-BMP char": "const X: [char; 2] = ['\u{1F600}','\"'];\nmod prod_emoji_tests;\n\"\"; // \"\n", + "long whitespace before include_str! string": `const X: &str = include_str!(${" ".repeat(60)}"prod_space_tests.rs");\n`, + }; + for (const [label, production] of Object.entries(cases)) { + const name = /mod (prod_[a-z]+_tests);|"(prod_[a-z]+_tests)\.rs"/.exec(production).slice(1).find(Boolean); + const result = fixture({ + "src-tauri/src/lib.rs": `#[cfg(test)]\n#[path = "${name}.rs"]\nmod tests;\n`, + "src-tauri/src/other.rs": production, + [`src-tauri/src/${name}.rs`]: HAZARD, + }); + assert.ok(!skipped(result, `${name}.rs`), `${label}: ${name}.rs must not be skipped`); + } +}); + +test("a source directory named target is still read for vetoes", () => { + const result = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "hidden_tests.rs"]\nmod tests;\n', + "src-tauri/src/target/mod.rs": '#[path = "../hidden_tests.rs"]\nmod hidden;\n', + "src-tauri/src/hidden_tests.rs": HAZARD, + }); + assertScanned(result, "hidden_tests.rs"); +}); + +test("a comment or raw string inside cfg(all(...)) does not imply test", () => { + const result = fixture({ + "src-tauri/src/lib.rs": + '#[cfg(all(feature = "live" /* , test, */))]\n#[path = "commented_cfg_tests.rs"]\nmod a;\n' + + '#[cfg(all(feature = r#"a", test, "#))]\n#[path = "raw_cfg_tests.rs"]\nmod b;\n' + + '#[cfg(all(not(test)))]\n#[path = "not_all_tests.rs"]\nmod c;\n' + + '#[cfg(all(feature = "x,test,y"))]\n#[path = "quoted_comma_tests.rs"]\nmod d;\n', + "src-tauri/src/commented_cfg_tests.rs": HAZARD, + "src-tauri/src/raw_cfg_tests.rs": HAZARD, + "src-tauri/src/not_all_tests.rs": HAZARD, + "src-tauri/src/quoted_comma_tests.rs": HAZARD, + }); + for (const file of ["commented_cfg_tests.rs", "raw_cfg_tests.rs", "not_all_tests.rs", "quoted_comma_tests.rs"]) { + assertScanned(result, file); + } +}); + +test("Cargo roots in inline tables, single quotes, build = and default layouts veto the skip", () => { + const manifests = { + "inline table": 'bin = [{ name = "x", path = "src/root_tests.rs" }]\n', + "single quotes": "[[bin]]\nname = 'x'\npath = 'src/root_tests.rs'\n", + "build key": 'build = "src/root_tests.rs"\n', + }; + for (const [label, manifest] of Object.entries(manifests)) { + const result = fixture({ + "src-tauri/Cargo.toml": `[package]\nname = "fixture"\nversion = "0.0.0"\n${manifest}`, + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "root_tests.rs"]\nmod tests;\n', + "src-tauri/src/root_tests.rs": HAZARD, + }); + assert.ok(!skipped(result, "root_tests.rs"), `${label}: a Cargo root must not be skipped`); + } + const defaults = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "bin/tool.rs"]\nmod a;\n#[cfg(test)]\n#[path = "../build.rs"]\nmod b;\n', + "src-tauri/src/bin/tool.rs": HAZARD, + "src-tauri/build.rs": HAZARD, + }); + assert.ok(!defaults.skipped.has("src-tauri/src/bin/tool.rs") && !defaults.skipped.has("src-tauri/build.rs")); +}); + +test("the remaining vetoes and health rules each keep a file scanned", () => { + const cases = { + "plain include!": ['const X: &str = include!("inc_tests.rs");\n', "inc_tests.rs"], + "path in an attribute on another item": ['#[doc(path = "attr_tests.rs")]\nfn f() {}\n', "attr_tests.rs"], + }; + for (const [label, [veto, file]] of Object.entries(cases)) { + const result = fixture({ + "src-tauri/src/lib.rs": `#[cfg(test)]\n#[path = "${file}"]\nmod tests;\n`, + "src-tauri/src/other.rs": veto, + [`src-tauri/src/${file}`]: HAZARD, + }); + assert.ok(!skipped(result, file), `${label}: ${file} must not be skipped`); + } + const dirModule = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "dirmod/mod.rs"]\nmod tests;\nmod dirmod;\n', + "src-tauri/src/dirmod/mod.rs": HAZARD, + }); + assert.ok(!dirModule.skipped.has("src-tauri/src/dirmod/mod.rs"), "a bare `mod dirmod;` vetoes dirmod/mod.rs"); + const unclosed = fixture({ + "src-tauri/src/lib.rs": '#[cfg(test)]\n#[path = "unclosed_tests.rs"]\nmod tests;\nfn f() {\n', + "src-tauri/src/unclosed_tests.rs": HAZARD, + }); + assertScanned(unclosed, "unclosed_tests.rs"); + const nested = fixture({ + "src-tauri/src/lib.rs": '/* outer /* inner */ #[cfg(test)] #[path = "nested_comment_tests.rs"] mod t; */\n', + "src-tauri/src/nested_comment_tests.rs": HAZARD, + }); + assertScanned(nested, "nested_comment_tests.rs"); + // A file targeted by a test edge but also loaded by production is not + // test-only, so what it declares is production too and must still veto. + const candidate = fixture({ + "src-tauri/src/lib.rs": + '#[cfg(test)]\n#[path = "loaded_tests.rs"]\nmod a;\nmod loaded_tests;\n' + + '#[cfg(test)]\n#[path = "grandchild_tests.rs"]\nmod b;\n', + "src-tauri/src/loaded_tests.rs": '#[path = "grandchild_tests.rs"]\nmod grandchild;\n', + "src-tauri/src/grandchild_tests.rs": HAZARD, + }); + assert.ok(!candidate.skipped.has("src-tauri/src/loaded_tests.rs")); + assertScanned(candidate, "grandchild_tests.rs"); +});