kicad-validity B3+C+S: revert toast, corpus lint, .kicad_mod tier

B3 (editor): kicad-binding observes kdoc_meta.revertNonce (deduped, like
seedNonce) → DOC_REVERTED_EVENT → WasmTool doc-reverted-toast (15 s,
data-testid for the deferred e2e check).
C: tests/tools/corpus-lint.ts (npm run corpus:lint) — kicad_tools --lint
over the fixture corpora raw + shared-codec round-trips
(docToFile(fileToDoc(x))); the E3/wrapInBoardEnvelope-class gate. Skips
when output/kicad_tools.js isn't built; CI step lands when kicad_tools
joins the CI app set. 7 fixtures / 7 round-trips green.
S: --lint + --resave gain the .kicad_mod tier (pcbToolsLintFootprint /
pcbToolsResaveFootprint; writer needs CTL_FOR_LIBRARY or the board flags
strip the (version) header). Verified: qa footprint round-trip lints
clean, resave stamps 20260206, garbage exits 1. Also bumps pcbjam-shared
(mergeYUpdates for the bisect pass).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01HLua64PCVwkQ1hpWdaf1Gm
This commit is contained in:
Gergő Törcsvári 2026-07-14 10:57:46 +02:00
commit fb2aa2759d
No known key found for this signature in database
GPG key ID: 8E75F2CDE64E5322
8 changed files with 323 additions and 7 deletions

View file

@ -44,6 +44,7 @@
"test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed", "test:asyncify:chrome": "playwright test --config=playwright-asyncify.config.ts --project=chromium --headed",
"test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit", "test:asyncify:safari": "playwright test --config=playwright-asyncify.config.ts --project=webkit",
"test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome", "test:asyncify:all": "npm run test:asyncify:firefox && npm run test:asyncify:safari && npm run test:asyncify:chrome",
"corpus:lint": "tsx tools/corpus-lint.ts",
"lint:determinism": "tsx tools/lint-determinism.ts", "lint:determinism": "tsx tools/lint-determinism.ts",
"screenshots:check": "tsx tools/screenshots/compare.ts", "screenshots:check": "tsx tools/screenshots/compare.ts",
"screenshots:promote": "tsx tools/screenshots/promote.ts", "screenshots:promote": "tsx tools/screenshots/promote.ts",

115
tests/tools/corpus-lint.ts Normal file
View file

@ -0,0 +1,115 @@
/**
* Corpus lint (kicad-validity 0001 C) — the E3 gate: our own s-expr PRODUCERS
* must agree with KiCad's parser. Runs `kicad_tools --lint` over:
*
* 1. the fixture corpus RAW (tests/fixtures + pcbjam-shared/test/fixtures)
* — guards fixtures themselves going stale vs the current parser;
* 2. every fixture ROUND-TRIPPED through the shared codec
* (docToFile(fileToDoc(text))) — the exact writer path the backend's
* ydoc materialization uses. A codec output KiCad rejects is the
* wrapInBoardEnvelope class of bug (pcbjam f07b997), caught pre-merge.
*
* Needs pcbjam/output/kicad_tools.js (docker/build.sh kicad_tools). When the
* artifact is absent the script SKIPS with exit 0 so it can sit in any CI leg;
* it becomes a hard gate once kicad_tools joins the CI build set (follow-up in
* docs/features/kicad-validity/0001 §5).
*
* Run: cd tests && npm run corpus:lint
*/
import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import * as path from "node:path";
import { fileURLToPath } from "node:url";
import { docToFile, fileToDoc } from "../../web/pcbjam-shared/src/index.js";
const here = path.dirname(fileURLToPath(import.meta.url));
const repo = path.resolve(here, "../..");
const cli = path.join(repo, "output/kicad_tools.js");
if (!existsSync(cli)) {
console.log("corpus-lint: SKIP — output/kicad_tools.js not built");
process.exit(0);
}
const CORPORA = [
path.join(repo, "tests/fixtures"),
path.join(repo, "web/pcbjam-shared/test/fixtures"),
];
const LINTABLE = /\.(kicad_pcb|kicad_sch|kicad_sym|kicad_mod)$/;
// The codec round-trips the doc formats it materializes (not symbol libs).
const CODEC = /\.(kicad_pcb|kicad_sch)$/;
function collect(dir: string): string[] {
if (!existsSync(dir)) return [];
const out: string[] = [];
for (const entry of readdirSync(dir, { withFileTypes: true, recursive: true })) {
const p = path.join(entry.parentPath ?? (entry as { path: string }).path, entry.name);
if (entry.isFile() && LINTABLE.test(entry.name)) out.push(p);
}
return out.sort();
}
function lint(file: string): { ok: boolean; detail: string } {
try {
execFileSync("node", [cli, "--lint", file], { stdio: ["ignore", "ignore", "pipe"] });
return { ok: true, detail: "" };
} catch (e) {
const err = e as { status?: number; stderr?: Buffer };
return { ok: false, detail: err.stderr?.toString().trim() ?? String(e) };
}
}
const files = CORPORA.flatMap(collect);
if (!files.length) {
console.error("corpus-lint: no fixtures found — corpus roots moved?");
process.exit(1);
}
const tmp = mkdtempSync(path.join(tmpdir(), "corpus-lint-"));
let failures = 0;
let roundTripped = 0;
try {
for (const file of files) {
const raw = lint(file);
if (!raw.ok) {
failures++;
console.error(`RAW FAIL ${path.relative(repo, file)}\n${raw.detail}`);
continue; // an invalid source can't meaningfully round-trip
}
if (!CODEC.test(file)) continue;
// Hierarchical sub-sheets don't parse standalone through the codec the
// same way; fileToDoc throwing marks "codec can't represent it" — that is
// a finding too, but a soft one: report, don't fail (multi-file sheets
// are exercised by the shared vitest suite with full projects).
let out: string;
try {
out = docToFile(fileToDoc(readFileSync(file, "utf8")));
} catch (e) {
console.warn(
`codec skip ${path.relative(repo, file)}: ${e instanceof Error ? e.message : e}`,
);
continue;
}
const rt = path.join(tmp, `rt-${roundTripped++}${path.extname(file)}`);
writeFileSync(rt, out, "utf8");
const verdict = lint(rt);
if (!verdict.ok) {
failures++;
console.error(
`ROUND-TRIP FAIL ${path.relative(repo, file)} — codec output rejected by KiCad:\n${verdict.detail}`,
);
}
}
} finally {
rmSync(tmp, { recursive: true, force: true });
}
console.log(
`corpus-lint: ${files.length} fixture(s), ${roundTripped} round-trip(s), ${failures} failure(s)`,
);
process.exit(failures ? 1 : 0);

View file

@ -14,9 +14,9 @@
* eeschema side (sym_convert_main.cpp) — everything else: * eeschema side (sym_convert_main.cpp) — everything else:
* kicad_tools --convert-lib <input.lib> <output.kicad_sym> * kicad_tools --convert-lib <input.lib> <output.kicad_sym>
* kicad_tools --lint [--strict] <file> [<file>...] * kicad_tools --lint [--strict] <file> [<file>...]
* (.kicad_pcb files get a FULL parse here — the pcbnew * (.kicad_pcb/.kicad_mod get a FULL parse here — the
* parser is linked; the lint driver calls back into * pcbnew parser is linked; the lint driver calls back
* pcbToolsLintBoard on the pcb side) * into pcbToolsLintBoard/pcbToolsLintFootprint)
* kicad_tools --resave <file> <outdir> * kicad_tools --resave <file> <outdir>
* (full parse + rewrite in the current file-format * (full parse + rewrite in the current file-format
* version — kicad-validity 0001; .kicad_pcb via the * version — kicad-validity 0001; .kicad_pcb via the

View file

@ -69,8 +69,11 @@
#include <layer_ids.h> #include <layer_ids.h>
#include <lset.h> #include <lset.h>
#include <libraries/library_manager.h> #include <libraries/library_manager.h>
#include <footprint.h>
#include <pcb_io/kicad_sexpr/pcb_io_kicad_sexpr.h>
#include <pcb_io/pcb_io_mgr.h> #include <pcb_io/pcb_io_mgr.h>
#include <pcb_marker.h> #include <pcb_marker.h>
#include <richio.h>
#include <pcb_plotter.h> #include <pcb_plotter.h>
#include <pcbplot.h> #include <pcbplot.h>
#include <pgm_base.h> #include <pgm_base.h>
@ -681,6 +684,120 @@ int pcbToolsLintBoard( const char* aInPath, std::string& aError )
} }
// Full-parse footprint lint for the merged image's --lint driver
// (kicad-validity 0001 S): parse one .kicad_mod via the s-expr plugin's
// ImportFootprint. Returns the pad count, or -1 with aError filled.
int pcbToolsLintFootprint( const char* aInPath, std::string& aError )
{
wxFileName fn( wxString::FromUTF8( aInPath ) );
fn.MakeAbsolute();
kiRuntime();
try
{
PCB_IO_KICAD_SEXPR plugin;
wxString name;
std::unique_ptr<FOOTPRINT> fp( plugin.ImportFootprint( fn.GetFullPath(), name ) );
if( !fp )
{
aError = std::string( aInPath ) + ": error: not a footprint file";
return -1;
}
return (int) fp->Pads().size();
}
catch( PARSE_ERROR& pe )
{
char buf[1024];
std::snprintf( buf, sizeof( buf ), "%s:%d:%d: error: %s", aInPath, pe.lineNumber,
pe.byteIndex, (const char*) pe.ParseProblem().ToUTF8() );
aError = buf;
return -1;
}
catch( const IO_ERROR& ioe )
{
aError = std::string( aInPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() );
return -1;
}
catch( const std::exception& e )
{
aError = std::string( aInPath ) + ": error: " + e.what();
return -1;
}
}
// Footprint resave for --resave (kicad-validity 0001 S): parse + rewrite one
// .kicad_mod in the current format, the exact FP_CACHE::Save incantation
// (PRETTIFIED formatter + plugin Format). 0 / 4 load failed / 5 write failed.
int pcbToolsResaveFootprint( const char* aInPath, const char* aOutPath, std::string& aError )
{
wxFileName fn( wxString::FromUTF8( aInPath ) );
fn.MakeAbsolute();
kiRuntime();
std::unique_ptr<FOOTPRINT> fp;
try
{
PCB_IO_KICAD_SEXPR plugin;
wxString name;
fp.reset( plugin.ImportFootprint( fn.GetFullPath(), name ) );
if( !fp )
{
aError = std::string( aInPath ) + ": error: not a footprint file";
return 4;
}
}
catch( PARSE_ERROR& pe )
{
char buf[1024];
std::snprintf( buf, sizeof( buf ), "%s:%d:%d: error: %s", aInPath, pe.lineNumber,
pe.byteIndex, (const char*) pe.ParseProblem().ToUTF8() );
aError = buf;
return 4;
}
catch( const IO_ERROR& ioe )
{
aError = std::string( aInPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() );
return 4;
}
catch( const std::exception& e )
{
aError = std::string( aInPath ) + ": error: " + e.what();
return 4;
}
try
{
// CTL_FOR_LIBRARY, NOT the default CTL_FOR_BOARD — the board flags
// include CTL_OMIT_FOOTPRINT_VERSION, which would strip the (version)
// header a standalone .kicad_mod must carry (FootprintSave sets the
// same flags before formatting).
PCB_IO_KICAD_SEXPR writer( CTL_FOR_LIBRARY );
PRETTIFIED_FILE_OUTPUTFORMATTER formatter( wxString::FromUTF8( aOutPath ) );
writer.SetOutputFormatter( &formatter );
writer.Format( fp.get() );
}
catch( const IO_ERROR& ioe )
{
aError = std::string( aOutPath ) + ": error: " + std::string( ioe.Problem().ToUTF8() );
return 5;
}
catch( const std::exception& e )
{
aError = std::string( aOutPath ) + ": error: " + e.what();
return 5;
}
return 0;
}
// Full-parse board resave for the merged image's --resave driver (also on the // Full-parse board resave for the merged image's --resave driver (also on the
// eeschema side): a bare load like pcbToolsLintBoard, then the s-expr writer // eeschema side): a bare load like pcbToolsLintBoard, then the s-expr writer
// rewrites the board in the current format version. Zones are written // rewrites the board in the current format version. Zones are written

View file

@ -102,6 +102,10 @@ int pcbToolsLintBoard( const char* aInPath, std::string& aError );
// Merged image only: board load + s-expr rewrite for --resave (kicad-validity // Merged image only: board load + s-expr rewrite for --resave (kicad-validity
// 0001). 0 resaved / 4 load failed / 5 write failed. // 0001). 0 resaved / 4 load failed / 5 write failed.
int pcbToolsResaveBoard( const char* aInPath, const char* aOutPath, std::string& aError ); int pcbToolsResaveBoard( const char* aInPath, const char* aOutPath, std::string& aError );
// Merged image only: .kicad_mod full parse (returns pad count or -1) and
// resave (0/4/5) — kicad-validity 0001 S.
int pcbToolsLintFootprint( const char* aInPath, std::string& aError );
int pcbToolsResaveFootprint( const char* aInPath, const char* aOutPath, std::string& aError );
#endif #endif
#include <connection_graph.h> #include <connection_graph.h>
@ -539,6 +543,15 @@ bool lintOneFile( const char* aPath, bool aStrict )
else else
tier = "full parse"; tier = "full parse";
} }
else if( ext == wxS( "kicad_mod" ) )
{
std::string fpError;
if( pcbToolsLintFootprint( aPath, fpError ) < 0 )
report.errors.push_back( fpError );
else
tier = "full parse";
}
#endif #endif
// Anything else (kicad_wks, kicad_pro …— and kicad_pcb outside // Anything else (kicad_wks, kicad_pro …— and kicad_pcb outside
// the merged kicad_tools image) stays structure-only. // the merged kicad_tools image) stays structure-only.
@ -813,12 +826,14 @@ int runResave( const char* aInPath, const char* aOutDir )
return 0; return 0;
} }
if( ext == wxS( "kicad_pcb" ) ) if( ext == wxS( "kicad_pcb" ) || ext == wxS( "kicad_mod" ) )
{ {
#ifdef KICAD_TOOLS_COMBINED #ifdef KICAD_TOOLS_COMBINED
const wxString outPath = outDir + inFn.GetFullName(); const wxString outPath = outDir + inFn.GetFullName();
std::string error; std::string error;
const int rc = pcbToolsResaveBoard( aInPath, (const char*) outPath.ToUTF8(), error ); const int rc = ext == wxS( "kicad_pcb" )
? pcbToolsResaveBoard( aInPath, (const char*) outPath.ToUTF8(), error )
: pcbToolsResaveFootprint( aInPath, (const char*) outPath.ToUTF8(), error );
if( rc != 0 ) if( rc != 0 )
std::fprintf( stderr, "%s\n", error.c_str() ); std::fprintf( stderr, "%s\n", error.c_str() );
@ -828,7 +843,8 @@ int runResave( const char* aInPath, const char* aOutDir )
return rc; return rc;
#else #else
std::fprintf( stderr, "%s: error: board resave requires the merged kicad_tools build\n", std::fprintf( stderr, "%s: error: board/footprint resave requires the merged "
"kicad_tools build\n",
aInPath ); aInPath );
return 2; return 2;
#endif #endif

@ -1 +1 @@
Subproject commit 3c04b71a6008a7071ce7b58535cf0e35dba7d110 Subproject commit ed67f0f454a43c672fab8460b2d88b5a1a1ef6f9

View file

@ -67,6 +67,7 @@ import {
type FollowTarget, type FollowTarget,
} from "@/wasm/collab/follow-user"; } from "@/wasm/collab/follow-user";
import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app"; import { startCrossAppPresence, type CrossAppHandle } from "@/wasm/collab/cross-app";
import { DOC_REVERTED_EVENT } from "@/wasm/collab/kicad-binding";
import { import {
createComments, createComments,
hasCommentsBridge, hasCommentsBridge,
@ -790,6 +791,9 @@ export function WasmTool({
// A collaborator updated library items that are PLACED in the open document // A collaborator updated library items that are PLACED in the open document
// (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn. // (LIB_ITEM_UPDATED_EVENT) — placed copies keep the previous version, so warn.
const [libUpdate, setLibUpdate] = React.useState<string | null>(null); const [libUpdate, setLibUpdate] = React.useState<string | null>(null);
// The backend rolled this document back to its last valid state
// (kicad-validity 0001 — DOC_REVERTED_EVENT from the collab binding).
const [docReverted, setDocReverted] = React.useState<string | null>(null);
// Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on // Eager whole-library idb→wasm load in flight (the ~tens-of-seconds fat-load on
// first chooser/editor open). Drives a full-cover overlay so the freeze reads as // first chooser/editor open). Drives a full-cover overlay so the freeze reads as
// "loading, just slow" rather than a hang. Null when idle; `done/total` count the // "loading, just slow" rather than a hang. Null when idle; `done/total` count the
@ -856,14 +860,23 @@ export function WasmTool({
`placed copies keep the previous version until updated from the library.`, `placed copies keep the previous version until updated from the library.`,
); );
}; };
const onDocReverted = (e: Event) => {
const d = (e as CustomEvent<{ reason?: string; at?: string }>).detail;
setDocReverted(
`This document was rolled back to its last valid state — invalid content ` +
`was detected${d?.reason ? ` (${d.reason})` : ""}. Recent edits may have been undone.`,
);
};
window.addEventListener(LIB_BUSY_EVENT, onBusy); window.addEventListener(LIB_BUSY_EVENT, onBusy);
window.addEventListener(LIB_ERROR_EVENT, onError); window.addEventListener(LIB_ERROR_EVENT, onError);
window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated); window.addEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.addEventListener(DOC_REVERTED_EVENT, onDocReverted);
return () => { return () => {
clearTimeout(busyTimer); clearTimeout(busyTimer);
window.removeEventListener(LIB_BUSY_EVENT, onBusy); window.removeEventListener(LIB_BUSY_EVENT, onBusy);
window.removeEventListener(LIB_ERROR_EVENT, onError); window.removeEventListener(LIB_ERROR_EVENT, onError);
window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated); window.removeEventListener(LIB_ITEM_UPDATED_EVENT, onItemUpdated);
window.removeEventListener(DOC_REVERTED_EVENT, onDocReverted);
}; };
}, []); }, []);
@ -881,6 +894,13 @@ export function WasmTool({
return () => clearTimeout(t); return () => clearTimeout(t);
}, [libUpdate]); }, [libUpdate]);
// Auto-dismiss the doc-reverted toast (longest — the user should see it).
React.useEffect(() => {
if (!docReverted) return;
const t = setTimeout(() => setDocReverted(null), 15_000);
return () => clearTimeout(t);
}, [docReverted]);
// Full-library eager load overlay. The fat-load fires one loading:true/false // Full-library eager load overlay. The fat-load fires one loading:true/false
// pair PER library (222 on the full set), and between them the C++ side parses // pair PER library (222 on the full set), and between them the C++ side parses
// with the main thread blocked. Show immediately on `true`, and only hide after // with the main thread blocked. Show immediately on `true`, and only hide after
@ -1647,6 +1667,18 @@ export function WasmTool({
</button> </button>
)} )}
{/* Backend rolled this doc back to the last valid state (kicad-validity). */}
{docReverted && (
<button
data-testid="doc-reverted-toast"
className="absolute left-1/2 top-3 z-40 max-w-md -translate-x-1/2 rounded bg-orange-950/95 px-3 py-2 text-center text-xs text-orange-100 shadow-lg ring-1 ring-orange-500/40"
onClick={() => setDocReverted(null)}
title="Dismiss"
>
{docReverted}
</button>
)}
{!effectiveChromeHidden && ( {!effectiveChromeHidden && (
<div className="absolute bottom-0 left-0 right-0 z-20"> <div className="absolute bottom-0 left-0 right-0 z-20">
<button <button

View file

@ -15,6 +15,9 @@ import {
wireItemUuids, wireItemUuids,
wireLibSymbols, wireLibSymbols,
Y_KDOC_META, Y_KDOC_META,
Y_KDOC_REVERT_AT,
Y_KDOC_REVERT_NONCE,
Y_KDOC_REVERT_REASON,
Y_KDOC_SEED_NONCE, Y_KDOC_SEED_NONCE,
ydocHasState, ydocHasState,
ydocSexprVersion, ydocSexprVersion,
@ -44,6 +47,12 @@ import { clog, cwarn } from "./debug";
* fake editor bridge (kicad-binding.test.ts). * fake editor bridge (kicad-binding.test.ts).
*/ */
/**
* Window event fired when the backend rolled this doc back to its last valid
* state (kicad-validity 0001 B3). detail: { reason?: string; at?: string }.
*/
export const DOC_REVERTED_EVENT = "pcbjam:doc-reverted";
/** The v2 per-item s-expr bridge (Stage C C++ contract), runtime-adapted. */ /** The v2 per-item s-expr bridge (Stage C C++ contract), runtime-adapted. */
export interface KicadItemsBridge { export interface KicadItemsBridge {
/** Full current model as an all-`added` ItemsWireDelta JSON. */ /** Full current model as an all-`added` ItemsWireDelta JSON. */
@ -192,6 +201,31 @@ export function bindKicadCollab(
}; };
items.observeDeep(observer); items.observeDeep(observer);
// Validity-revert notice (kicad-validity 0001 B3): the backend stamps
// kdoc_meta.revertNonce when it rolls the doc back to the last valid state
// (the content itself arrives through the normal item sync above). Watched
// like seedNonce; surfaced as a window event for the shell's toast. The
// nonce is deduped so a reconnect replaying the same marker stays silent.
const revMeta = doc.getMap(Y_KDOC_META);
let lastRevertNonce = revMeta.get(Y_KDOC_REVERT_NONCE);
const onRevertMeta = () => {
const nonce = revMeta.get(Y_KDOC_REVERT_NONCE);
if (nonce === undefined || nonce === lastRevertNonce) return;
lastRevertNonce = nonce;
clog("doc reverted by backend:", revMeta.get(Y_KDOC_REVERT_REASON));
if (typeof window !== "undefined") {
window.dispatchEvent(
new CustomEvent(DOC_REVERTED_EVENT, {
detail: {
reason: revMeta.get(Y_KDOC_REVERT_REASON),
at: revMeta.get(Y_KDOC_REVERT_AT),
},
}),
);
}
};
revMeta.observe(onRevertMeta);
function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void { function seed(seedDoc?: KicadDoc, opts?: { editorMatchesDoc?: boolean }): void {
seeded = true; // open the UP gate; everything below runs synchronously seeded = true; // open the UP gate; everything below runs synchronously
// `ydocHasState` (meta + layout + items), NOT `items.size`: a populated // `ydocHasState` (meta + layout + items), NOT `items.size`: a populated
@ -344,6 +378,7 @@ export function bindKicadCollab(
detachSeedArbitration?.(); detachSeedArbitration?.();
detachSeedArbitration = undefined; detachSeedArbitration = undefined;
items.unobserveDeep(observer); items.unobserveDeep(observer);
revMeta.unobserve(onRevertMeta);
}, },
items, items,
}; };