Skip to content

Latest commit

 

History

History
1686 lines (1411 loc) · 87.7 KB

File metadata and controls

1686 lines (1411 loc) · 87.7 KB

SWFileFixer — Architecture

This is the "documentation IS the logic" document for SWFileFixer. Read this before touching any code, and update it whenever a design decision changes.

Goal

Given a folder tree of SolidWorks assemblies and parts, tell the user — without opening SolidWorks — every Toolbox integrity problem the tree contains: duplicate IDs, mismatched linked IDs, missing configurations, Toolbox-flag/path mismatches, and name-drift.

The output is a machine-readable list of findings. Repairs happen in SolidWorks (with the human in the loop). This tool never writes to a SolidWorks file — the write side belongs in SWFormat, not here.

Non-goals

  • Editing / auto-fixing files. Every finding is human-reviewed.
  • Live COM. There is a sibling toolchain (combridge, sw_bridge) for that.
  • Toolbox admin surgery (rebuilding SWBrowser.sldedb, regenerating parts, etc.). Also live-COM territory.

Data flow

                                             +----------------------+
    root_dir  ------> walker  ------>  every  |  .SLDASM / .SLDPRT   |
                                             +----------+-----------+
                                                        |
                                          +-------------+-------------+
                                          |                           |
                            +-------------v-------+         +---------v----------+
                            | reader.read_asm_    |         | reader.read_part_  |
                            |   components(asm)   |         |   identity(part)   |
                            |                     |         |                    |
                            | Component list, +   |         | creation_time, +   |
                            | per-swFile          |         | config list        |
                            | expected creation   |         +---------+----------+
                            | stamps              |                   |
                            +-------------+-------+                   |
                                          |                           |
                                          v                           v
                                   +----------------- registry -------------------+
                                   |  ScanRegistry                                |
                                   |    parts:    {abspath  -> PartRecord}        |
                                   |    by_name:  {basename -> [PartRecord ...]}  |
                                   |    assemblies: [AssemblyRecord ...]          |
                                   |    (each AssemblyRecord holds the resolved   |
                                   |    Component list PLUS the parent-side       |
                                   |    "expected creation_time" per swFile)      |
                                   +----------------------+-----------------------+
                                                          |
                                     +--------------------+--------------------+
                                     |         |          |         |         |
                                +----v---+ +---v----+ +---v----+ +--v-----+ +-v------+
                                | dup_id | | mism_  | | miss_  | | tb_pth | | name_  |
                                |        | | linked | | config | |        | | drift  |
                                +--------+ +--------+ +--------+ +--------+ +--------+
                                     |          |         |         |         |
                                     +----------+---------+---------+---------+
                                                          |
                                                          v
                                                    Finding list
                                                          |
                                          +---------------+---------------+
                                          |                               |
                                     json_writer                     text_writer
                                     (--out FILE.json)          (default stdout / --text)

Modules

swfilefixer._swformat_bootstrap

Prepends D:/Dev/SWFormat/src (or $SWFILEFIXER_SWFORMAT_SRC) to sys.path at import time so import swformat works without a pip-install. Imported at the top of swfilefixer/__init__.py. If the bootstrap path doesn't exist, the import raises with a clear message telling the user how to override it.

swfilefixer.io.walker

walk(root, patterns=("*.SLDASM", "*.SLDPRT")) -> Iterable[Path]

Recursive glob walk. Skips SolidWorks backup / bak / autorecover files (~$*, *.bak, *_backup*, AutoRecover_*) and skips hidden directories. Yields resolved absolute paths.

swfilefixer.io.reader

Wraps SWFormat plus an extended COMPINSTANCETREE parser.

Public functions:

  • read_part_identity(path) -> PartRecord | None — reads swXmlContents/Features via swformat.api.components.read_part_config_tree. Returns a PartRecord with creation_time, configs, most_recent_config, plus the resolved absolute path. Returns None when the file has no Features stream (drawing, corrupt, non-part).
  • read_assembly_components(path) -> AssemblyRecord | None — the extended reader. Uses swformat.io.reader.read_document to grab the raw COMPINSTANCETREE bytes, then parses them once — extracting both:
    1. The Component list SWFormat already gives us (name, path, config, exclude_from_bom, flexible, hidden, suppressed, virtual, transform, bounding_box, model_ref).
    2. A parallel expected_creation_time[model_ref] map extracted from the <swFile> elements — this is the identity fingerprint the parent assembly stored the last time it saved a resolved link to that file. read_component_tree() in SWFormat drops this attribute; we don't, because we need it for the mismatched-linked-ID check. Returns None for non-assembly / no-COMPINSTANCETREE files.

swfilefixer.io.registry

ScanRegistry accumulates results across the whole walk:

  • parts: dict[str, PartRecord] keyed by lowercased absolute path.
  • by_basename: dict[str, list[PartRecord]] keyed by lowercased basename (e.g. hex bolt.sldprt). This is where duplicate-ID detection lives.
  • assemblies: list[AssemblyRecord] in scan order.

Also stores per-file parse errors so the report can note "N files were unreadable" without hiding the fact.

swfilefixer.checks

Each check is a module exposing run(registry) -> Iterable[Finding]. The registry is passed in already fully populated — checks don't do I/O.

checks/__init__.py holds REGISTRY: dict[str, Check] mapping the short name (duplicate_ids, mismatched_linked_id, missing_configuration, toolbox_flag_path_mismatch, read_only_toolbox_part, broken_reference, name_drift, parse_error) to its run function.

Purity exception: two checks perform minimal read-only I/O over paths already in the registry — broken_reference (existence stat per unique unresolved path) and read_only_toolbox_part (attribute stat per toolbox-resident part). Both are documented deliberate exceptions; the "checks do no I/O" rule otherwise stands.

See docs/CHECKS.md for one page per check.

swfilefixer.report

  • json_writer.write(findings, scan_meta, path) — one JSON object with {scan: {...}, findings: [...]}. Every finding serializes its evidence verbatim (no summarization).
  • text_writer.write(findings, scan_meta, stream) — grouped by severity, then by check, then by file. Human-readable, not intended for parsing.

swfilefixer.cli

argparse CLI. Subcommands:

  • scan ROOT [--toolbox-root PATH] [--check LIST] [--out FILE.json] [--text FILE.txt] [--include-drawings] — the main command.
  • checks — print the registry with descriptions and severities.

Identity fingerprint — why swCreationTime

SolidWorks stores an internal ID on every model document. This ID is NOT byte-readable from the file (see C:/personal_rag/solidworks/lesson_20260521_sw_file_id_not_byte_readable.md — confirmed empirically that no regex, no offset math, no CArchive walk recovers it as a stable stamp). What IS in the plain-XML mirror is swCreationTime, and it appears in TWO places:

  1. On the <swFile> element inside the parent assembly's COMPINSTANCETREE — this is the parent's expected stamp for the referenced child file at the moment the parent was last saved.
  2. On the <swFile> element inside the child part's own swXmlContents/Features stream — this is the child's current stamp on disk.

If those two disagree for a given (parent, child) pair, the parent now points at a different file than it was pointing at last save. That is the exact scenario SolidWorks flags at open time as "swComponentInternalIdMismatch=5" (see C:/personal_rag/solidworks/lesson_20260521_swcomponentinternalidmismatch_marker.md).

So swCreationTime gives us:

  • duplicate_ids — a basename appearing twice under the scan root with two different creation_time values means two distinct files share the same filename. That is a genuine duplication that will bite SW's Toolbox resolver at some point.
  • mismatched_linked_id — parent-expected != child-actual for a specific reference means SW will fire the InternalIdMismatch warning the next time this assembly is opened.

Caveat: swCreationTime is a creation stamp, not an update stamp. Two files can have the same creation stamp and different content (rebuilt, resaved). But two files with different creation stamps are provably distinct. This asymmetry is fine for the check we run — we only trigger on different, not same.

Configuration matching — what counts as "missing"

For missing_configuration we compare, case-insensitively, the component's config string against the target part's configs list (from read_part_config_tree). A missing config is a case where the component's string is neither in the list nor equal to "" (empty string means "use the part's default" and SW handles that fine).

Special case: if the target part cannot be resolved (path stale, missing file), missing_configuration skips it — that's a "broken reference" finding of a different class (not yet in v0.1; tracked in the roadmap).

Toolbox flag detection

Implemented (2026-07-16). SolidWorks persists the IsToolboxPart flag in each part's docProps/ISolidWorksInformation.xml — a plain-XML OPC side-channel. SWFormat exposes it via swformat.api.toolbox (shipped same-day off our feature request in D:/Dev/FeatureRequests/SWFormat_FeatureRequests/is_toolbox_part_bit/).

Integration points:

  • swfilefixer.io.reader.read_part_identity reads the document's stream map once and feeds three parsers: the Features config tree, is_toolbox_part_from_streams (the flag decision), and parse_solidworks_information (the raw swToolBoxPartType_e for evidence). One read_document per part, unchanged scan cost.
  • PartRecord.is_toolbox_part is three-state: True (Toolbox), False (info stream present, part not Toolbox), None (stream absent — undeterminable; never treated as False by checks).
  • PartRecord.toolbox_part_type carries the raw 0/1/2 value (not-toolbox / standard / copied).
  • toolbox_flag_path_mismatch fires on three directions: flag-on outside root (A), flag-off inside root (B), and the original basename-split heuristic (C). See docs/CHECKS.md.
  • The GUI auto-detects the Toolbox root from HKCU\SOFTWARE\SolidWorks\SOLIDWORKS 20NN\General\Toolbox Data Location (swfilefixer.io.sw_registry.detect_toolbox_root, newest installed version first, folder-exists validated).

Repair plan automation (GUI)

The Repair Plan tab is populated three ways, all editable afterwards:

  1. Auto-build from findings (swfilefixer_gui/engine/plan_builder.py, button on the Repair tab / F7). Walks the last scan's findings and generates rules, resolving replacement paths via four strategies in order:

    • browser-subpath remap — a dead path containing \browser\<subpath> is retried as <current_toolbox_root>\browser\<subpath>. Toolbox's folder layout under browser is standard-defined and stable across machines, so a hit is near-certainly the same catalogue part.
    • Toolbox basename match — the missing file's basename exists under the current Toolbox root (lazy cache-backed index): remap there automatically, even when internal-ID matching would be ambiguous (user directive 2026-07-25 — Toolbox seed stamps are shared by hundreds of files, so stamp ambiguity is the norm for a part that merely moved roots; prompting on each defeats the auto-build). Same-named files in several folders disambiguate by requested-config coverage → expected internal ID → deepest folder-tail agreement with the dead path; only a full tie falls to the candidates dropdown.
    • internal-ID (creation-stamp) match — reached only when the basename is NOT in the Toolbox (renamed/copied part): unique stamp+requested-config hit auto-resolves; ambiguity → ranked candidates dropdown (never silently relink to a renamed file).
    • closest-name tie-break among the same-ID candidatesopt-in, default OFF (checkbox beside the Auto-build button). Breaks the ambiguity the previous bullet leaves behind by comparing filenames. See Closest-name tie-break below.
    • scanned-parts basename match — unique same-basename file already read during the scan. Unresolvable rules are still generated (empty replacement, NEEDS INPUT note, amber row) — the plan is editable, not filtered. Pressing the button repeatedly is safe: rules are deduped by repair-intent key against the existing plan.

    Selection propagation (user feature 2026-07-25): when the user resolves one NEEDS-INPUT rule by picking a candidate from its dropdown, the tab offers to apply the analogous choice to every other still-incomplete rule of the same kind (plan_builder.propose_similar_selections, pure and unit-tested; the GUI only confirms and applies). A proposal is made per rule only when it is unambiguous for THAT rule: the single candidate in the same directory as the chosen path first, else the candidate sharing the strictly longest leading-path prefix (minimum drive + one folder); config-name rules propagate the chosen config verbatim to rules listing it among their candidates. The offer fires only on an actual candidate pick (typing a path never matches a full candidate string mid-keystroke), a confirmation dialog previews the affected rows (capped at 10 lines), and propagated rules get an explanatory note. First real-corpus run (reference corpus, 250 broken refs): 238 rules in 0.21 s, 178 auto-resolved (34 remap + 144 scanned-basename), 60 needing input.

  2. Send to Repair Plan — per-finding right-click on the Findings tab (pre-fills one rule).

  3. By hand — Add-rule dialogs, in-place cell editing (Match / Replacement / Note columns), or Edit as text (the whole plan in a line format that round-trips losslessly; see the plan_builder module docstring for the grammar). ? marks a still-unresolved replacement.

Apply preflight: incomplete rules are counted, surfaced, and skipped — they never reach an executor.

Closest-name tie-break among same-ID candidates (opt-in)

The failure mode. Pack-and-Go copies a job to a new folder and lets the user prefix or suffix every filename (JOB1234-bracket.SLDPRT, bracket_REV_B.SLDPRT). If the part files are then renamed again by hand — or the Pack-and-Go is re-run against a subset — the parent assembly can end up holding the old name while the file on disk carries the new one. The assembly's reference is dead, but the descendant file is right there, and it still carries the original's creation stamp (SolidWorks preserves the stamp through copy/rename — that is the whole basis of the internal-ID strategy).

Why the existing cascade stops short. When several files share the expected stamp, _resolve_replacement deliberately refuses to guess and drops the ranked list into the candidates dropdown. That refusal is correct in general — stamps are batch-shared by Toolbox seeds, so "same stamp" alone is weak evidence — but in the Pack-and-Go case it means the user hand-picks the obvious answer dozens of times in a row.

The tie-break. When enabled, and only among candidates that already share the expected internal ID, the builder scores each candidate's stem against the dead file's stem with name_affinity():

Relationship Score Rationale
stems equal (case-insensitive) 1.0 the file merely moved
one stem contains the other 0.70 + 0.30 · len(shorter)/len(longer) exactly the affix case. The 0.70 base says containment is strong evidence however long the affix; the ratio then orders them so the least-added-affix candidate wins — JOB1-bracket (0.875) beats JOB1-bracket-OLD (0.831)
otherwise difflib.SequenceMatcher.ratio() separator drift and typos (bracket-01 vs bracket_01 → 0.90); near-zero for unrelated names

A candidate auto-resolves only on a strict win: best score >= NAME_AFFINITY_FLOOR (0.60 — every containment clears it by construction, a non-containment pair has to be a near-typo) and at least NAME_AFFINITY_MARGIN (0.02) clear of the runner-up. The margin is deliberately small because the meaningful gap between two Pack-and-Go siblings is itself small (0.044 in the example above) and because the pool is already restricted to same-ID files; the floor does the heavy lifting. Ties, near-ties, and low-similarity fields stay NEEDS INPUT — but the dropdown is re-ranked by the same score either way, so even with the option off the best guess is first in the list. Config-carrying hits remain the preferred pool: name affinity breaks ties within the pool the existing cascade selected, it never overrules requested-config evidence.

Two ways to run it, because a corpus is usually triaged twice:

  1. At build time — the "Prefer closest name match (same internal ID)" checkbox beside Auto-build sets build_plan(..., prefer_closest_name=True).
  2. After the fact — the Resolve by closest name match entry in the Repair tab's automations dropdown, applied to all or checked rows. This variant scores against whatever candidates the row carries (the flat candidates list does not record which entries were stamp-matched), so its note says so explicitly. It is the right tool after a build that ran with the option off.

Every auto-resolution writes an auditable note naming the score and the runner-up, e.g. auto: closest name match among same-ID candidates (0.71 vs 0.42) — a check that cannot show its work is unfixable, and that applies to repairs as much as to findings.

Default OFF (user directive 2026-08-05). It relaxes the documented "never silently relink to a RENAMED file on ambiguous stamp evidence" invariant, so the user opts in per run rather than discovering it.

Placeholder replacements — empty.<name>.SLDPRT

The failure mode. A missing reference with no candidate at all: no ID match, nothing under the Toolbox root, nothing in the scan. The file is simply gone — a supplier model that was never copied, a part deleted years ago. The rule is generated with a blank Replacement and no dropdown, and the user can do nothing with it except delete the row. Meanwhile the assembly stays unopenable-without-complaint forever.

The repair. Create a deliberately empty stand-in part or assembly named empty.<original stem>.<original ext> and repoint the reference at it. The assembly then opens clean, the missing item is visible in the tree under an unmistakable name, and the mates that referenced it fail loudly instead of the whole document failing. This is a triage aid, not a restoration: every mate to the vanished geometry will dangle, because there is no geometry to mate to. The GUI says so in the confirmation dialog.

Two body styles (user directive 2026-08-05), chosen per automation entry:

  • empty — no geometry at all. Honest; nothing to accidentally measure or mate.
  • dummy body — a 10 mm cube at the origin. Selectable and visible in the graphics area, which is what you want when you are walking a colleague through "here is what is missing". Never mistakable for real geometry at assembly scale.

Where the stub file is written (cascade)

Per user directive 2026-08-05, in order:

  1. The dead reference's own folder. The first choice always — the placeholder should sit where the missing file was expected, so the next engineer finds it next to its siblings.

  2. The nearest existing, writable ancestor folder — but only within _MAX_ANCESTOR_CLIMB (3) levels, and never a drive root or a system folder. Used when (1) is on a drive that no longer exists or is read-only — the common case for C:\Users\<somebody-else>\Desktop\... references. We walk up the dead path and stop at the first directory that exists and accepts a write; we never mkdir a vanished tree, because inventing X:\Jobs\2019\... on a live drive is worse than being honest about where the stub went.

    Why the cap exists — found on a real corpus, 2026-08-05. That tree is full of references into C:\Dropbox (Company)\..., a folder that does not exist on this machine. Five levels are missing, so an uncapped "nearest existing writable ancestor" walk arrives at C:\ — which exists, and is writable. The first run of this feature therefore proposed creating 1,094 placeholder files in the root of the system drive. A parent five levels up is a different project, not a near miss. Three guards now apply: the depth cap, an explicit refusal of the path anchor (C:\, \\server\share) and of known system folders, and the fallback below. Regression-tested by test_placeholder_dir_never_climbs_to_a_drive_root.

  3. The workspace placeholder folderWorkspace.placeholder_fallback_dir. Reached whenever the cascade above refuses, which after the cap is the common case for a corpus whose references point at a vanished tree. It is not preconfigured: when an automation finds rows with nowhere to go, the Repair tab asks once ("Choose one folder to hold those placeholders?"), then retries exactly those rows and remembers the folder in the workspace. Refusing plus offering the fix beats improvising a location.

If a stub of that name already exists in the chosen folder it is reused when its recorded original path matches (so re-running is idempotent), and disambiguated to empty.<stem> (2).<ext> when it does not — two different dead paths with the same basename must not collapse onto one stub.

The internal ID — what we can and cannot do

Ken's directive was "make the ID match the original if it can be made to match; otherwise record it in a custom property." It cannot be made to match, and this is settled, not unexplored:

  • Every API that exposes it — ISwDMDocument.CreationDate / CreationDate2 on all SwDM interfaces — is declared {get;}. There is no setter anywhere in the SW or SwDM object model.
  • It is not writable off disk either. The RAG lesson lesson_20260521_sw_file_id_not_byte_readable.md proved the ID has no stable byte position (a Pack-and-Go pair differs in 98% of its bytes), and lesson_20260521_sw_file_id_not_byte_readable is why this project fingerprints with swCreationTime from the plain-XML mirror in the first place. Writing only the XML mirror would be worse than doing nothing: our own scanner would report the stub as ID-matched while SolidWorks still saw a mismatch — a repair that lies.

So the design does two honest things instead:

  1. Records the original identity on the stub, as custom properties written by the same csx that creates it:

    Property Value
    SWFileFixerPlaceholder 1 — the machine-readable marker
    SWFF_OriginalPath the full dead reference path, always written
    SWFF_OriginalFileId the creation stamp the parent expected, when the registry knew one
    SWFF_CreatedUtc when the placeholder was made

    These make the stub self-describing: opening it in SolidWorks and reading the Summary Information tells you exactly what it stands in for. They also give a future placeholder_reference check something unambiguous to detect.

  2. Leans on the finalize pass, which already solves the practical half of the problem. ReplaceReferencedDocument leaves the parent holding the old ID; the existing "Re-save affected assemblies" pass reopens and saves each parent, at which point SolidWorks re-stamps the stored ID to the stub's. After finalize there is no lingering swFileLoadWarning_IdMismatch. The stub's ID never equals the vanished file's — but nothing in the corpus is looking for that value any more.

Execution — a dispatcher pre-pass

Stub creation is a pre-pass, mirroring the existing finalize post-pass: RepairDispatcher._create_placeholders() collects every ReplaceReferenceRule with create_stub=True whose replacement_path does not yet exist, and makes them all in one combridge call (create_placeholder_documents.csx) before the first rule runs. Why a pre-pass and not part of the replace executor:

  • replace_reference.csx closes every open document (its ReplaceReferencedDocument precondition). Creating documents from inside that flow would fight it.
  • One SolidWorks round-trip for N stubs instead of N.
  • The rule stays a plain ReplaceReferenceRule, so preview, filtering, affects-counting, propagation and the plan-text round trip all work with no special cases. replacement_path is populated at plan time, so the row reads as complete and shows the user exactly what it is about to create.

The csx builds each document from SolidWorks' own default template (swUserPreferenceStringValue_e.swDefaultTemplatePart / swDefaultTemplateAssembly) — no seed file is vendored, so the stub inherits the shop's units and drafting standard. A stub that fails to create fails its own rule only; the rest of the plan continues (fail-open, same as everywhere else).

In dry-run the pre-pass writes nothing and reports would create N placeholder file(s) into the log.

Repair-tab batch operations — checkboxes, sorting, automations

Three UI mechanics that only make sense together, added 2026-08-05.

Row checkboxes (COL_SELECT, column 0). A plan built from a real corpus is 200+ rows; the previous batch story was "highlight rows and press Remove". Checkboxes give a selection that survives sorting, filtering and rebuilds, which highlight-selection does not.

  • The header cell of column 0 carries a tri-state master checkbox (_CheckableHeader, a QHeaderView subclass that paints the indicator and toggles on click in section 0): unchecked → all off, checked → all on, partial → shown when only some rows are checked.
  • Ctrl+click toggles one row (plain click does too).
  • Shift+click applies the clicked row's new state across the whole range from the last-clicked anchor — the usual file-manager idiom, and the one the user asked for by name.
  • Delete checked removes every checked row behind a confirmation that names the count.

Check state is stored as a transient attribute on the rule object (rule._swff_checked), which the repair_rules module docstring explicitly sanctions ("the dataclasses are deliberately open ... so the GUI can attach transient state"). It is invisible to rule_to_json (asdict walks declared fields only), so nothing leaks into the workspace file, and because it rides on the rule rather than the row it is immune to re-sorting.

Checked ≠ visible. The pre-existing hardware/weldment filter owns the "visible == runnable" invariant: hidden rows do not run. Checkboxes are an editing selection and have no effect on what runs. Automations applied to "checked rows" deliberately still skip hidden rows, so a filtered-out row can never be silently rewritten.

Column sorting. Clicking a header sorts ascending, clicking again descending; an arrow marks the active column. Implemented by sorting the rule list and rebuilding the rows, not by setSortingEnabled(True). That is not a style preference — it is forced:

QTableWidget.sortItems() moves QTableWidgetItems but does not move cell widgets installed with setCellWidget. This table puts a per-row Edit QPushButton in column 0 and a per-row candidates QComboBox in the Replacement column. Native sorting would leave every button and combo attached to the wrong rule — and the combos carry lambdas bound to their rule, so the corruption would be silent and destructive. Rebuilding re-creates the widgets against the right rules by construction.

Sort keys are per column: check state for COL_SELECT, the numeric affects count for COL_AFFECTS (so 9 sorts before 10), case-folded text elsewhere. Incomplete rules sort together within a key because their Replacement cell is empty. Sorting rewrites plan order, which is also execution order — harmless (rules are independent by construction) but worth knowing when reading an audit log.

Automations dropdown. A QComboBox above the table plus Apply to all rows / Apply to checked rows. Entries:

Automation Applies to Effect
Resolve by closest name match incomplete rules with ≥ 2 candidates picks the closest-named candidate (rules above)
Replace with empty placeholder incomplete replace_reference rules with no candidates allocates the stub path, sets create_stub, body none
Replace with placeholder + dummy body same same, body dummy
Clear replacement (back to NEEDS INPUT) any rule the two above touched the undo, since bulk edits need one

Rows that do not qualify are skipped, not failed — the summary dialog reports applied N, skipped M with a per-reason breakdown, so "nothing happened" is never a mystery. The dropdown joins the widgets set_busy() disables, so it cannot fire mid-run.

One skip reason gets special treatment instead of a line in that breakdown: nowhere to put the placeholder. Those rows are collected separately by _automation_pass, the user is offered a folder picker once, and only those rows are retried (a second call to the same method, so retry cannot drift from the first pass). On the reference corpus this is the difference between 41 rows resolving and 1,094.

Sorting vs. the master checkbox. Both live in the column-0 header, so the click is routed by position: on the checkbox indicator it toggles every row and the event stops there; anywhere else in that header section it falls through to sorting like any other column.

The sort arrow, and why it was missing. setSortIndicator() was being called from the first version, and no arrow ever appeared. QTableView.setHorizontalHeader() resets sortIndicatorShown to the view's own sortingEnabled flag — which is False here, because this table sorts by rebuilding rather than natively — so everything the custom header set in its constructor was silently undone at installation. The visibility must therefore be (re-)asserted after the header is installed. It is deliberately left off until the first sort: a fresh plan is in build order, and an arrow on column 0 would claim a sort key that is not in effect.

Keeping the window resizable

QHBoxLayout reports a minimum width equal to the sum of its children, and that number propagates all the way up: one row of buttons sets a floor under the whole application. The Repair tab's plan-builder row alone reported 3,732 px, and the window as a whole could not be made narrower than 3,754 px — unusable beside SolidWorks on a 1080p screen, with no scrollbar and no wrapping, just a hard stop.

views/flow_layout.py provides a wrapping layout whose minimum width is its widest single item, not the sum, and whose height grows as the window narrows (hasHeightForWidth / heightForWidth / setGeometry, all sharing one _do_layout so the predicted and drawn heights cannot disagree). Every strip of controls across the three tabs now uses it.

Two non-obvious contributors had to be fixed the same way:

  • A plain QLabel does not wrap unless told to. One sentence of help text above the rules table was holding the window 1,512 px wide by itself. setWordWrap(True).
  • A QCheckBox cannot wrap at all, so its full single-line text is an unbreakable floor. "Save modified files (and re-save affected assemblies to accept new IDs)" was 876 px — more than the entire rest of the tab after wrapping. The label was shortened and the detail left in the tooltip, where the full explanation already lived.

Result: window minimum 3,754 → 898 px, and 574 px for the Repair tab itself. tests/test_gui_layout.py asserts a ceiling of 1,000 px per tab, because the regression is easy to reintroduce (add one more button) and invisible until someone actually drags a window edge.

FlowLayout accepts addStretch() as a documented no-op and implements addSpacing() for real, so an existing row converts by changing one line — a row that fails to convert cleanly tends to get converted back.

Finding combridge.exe — and the bug that made a shipped bundle useless

resolve_combridge_exe() looks in this order:

  1. an explicit per-workspace override,
  2. $SWFILEFIXER_COMBRIDGE — what the portable launchers set,
  3. <app root>/combridge/combridge.exe — the copy bundled beside the application (found even when the user bypasses the .bat and starts the interpreter directly),
  4. combridge on PATH,
  5. the maintainer's deployed ScripTree copies.

The order is the entire point, and it was wrong. This function replaced a module constant that was only item 5's first entry:

DEFAULT_COMBRIDGE_EXE = r"D:\Dev\ScripTree\lib\combridge\combridge.exe"

The portable build shipped a complete combridge (exe + plugins/ SolidWorks/) and its launchers set SWFILEFIXER_COMBRIDGE to it — but nothing in the application ever read that variable, and the bundled copy was referenced by nothing at all. On the build machine the hard-coded path always exists, so every test passed and the Repair tab showed combridge ✓. The first machine that had never had ScripTree installed reported combridge ✗, with a perfectly good combridge sitting inside the folder it was running from.

Three defences now exist, because the failure was invisible on every machine capable of catching it:

  • Anything shipped outranks anything that only exists on the build machine. The dev fallbacks are last.
  • The build proves it. _verify_combridge_resolution() runs the resolver under the bundled interpreter with SWFILEFIXER_COMBRIDGE deliberately unset, and fails the build unless the answer is a path inside the bundle. Unsetting is what forces the app-relative fallback to be exercised. --with-combridge also now fails rather than warning when combridge cannot be found, and verifies plugins/SolidWorks/*.dll after the copy — the plugin lives one level deeper than a flat *.dll listing shows, which is combridge's own documented packaging trap.
  • The user can self-diagnose. CombridgeExecutor.diagnose() and the Repair tab's Test engine… button report which path was chosen and how, whether the SolidWorks plugin is present, and — by running combridge list-plugins — whether it can actually start.

diagnose() separates the two failures that look identical from outside: not found, and found but cannot start. The second is almost always the missing .NET runtime, because combridge is framework-dependent (net10.0-windows). Windows' apphost emits a specific message for that, which is translated into the actual instruction (install the .NET Desktop Runtime 10, with the URL) instead of a raw exit code. Scanning is unaffected either way — it never needs combridge, and the message says so.

is_available() stays a cheap stat because it runs on every status refresh; diagnose() may spawn a process and therefore runs only when asked.

Spreadsheet export (Findings + Repair plan)

JSON is what a downstream tool wants and the text report is what a person reads once. Neither is what a shop actually does with a 1,200-finding scan, which is: sort by check, filter to one job folder, tick rows off as they are fixed, and mail the sheet to whoever owns the mess. swfilefixer/report/spreadsheet_writer.py produces that, and is shared by three call sites — the CLI's --sheet, the Findings tab's Export…, and the Repair tab's Export….

One flat, wide table. No nesting, no summarising, no merged cells: the entire value of the format is that Excel's AutoFilter, sort and pivot work on it, and all three break on a prettified layout. A list-valued cell is joined with " | " (illegal in Windows filenames, so it can never occur inside a path being joined); a nested object becomes compact JSON.

Columns are discovered, not declared. union_table() takes fixed human-ordered leading columns, then the union of every remaining key across the records, ordered by a preference list and then alphabetically. So a finding's evidence is promoted to real columns (ev.referenced_path, ev.requested_config, ev.direction) rather than hidden in one JSON blob — and a new check or rule kind appears in the export automatically. That is deliberate: the failure mode of a hard-coded column list is silently dropped evidence, discovered months later by someone who trusted the sheet.

Formula injection is neutralised on every cell. Excel and LibreOffice execute a cell that begins with =, + or @, and openpyxl writes a string starting with = as a live formula. These cells carry paths, config names and notes that ultimately come out of files we did not write, so sanitize_cell() prefixes an apostrophe. - is escaped only when the value is not a number, so -3.5 stays numeric while -SHCS 0.5-13x1 (a real Toolbox config-name shape) is treated as text.

Two more corruption guards that are easy to omit and impossible to undo:

  • CSV is written UTF-8 with BOM. Without it Excel-on-Windows opens the file as the legacy ANSI code page and mangles every Ø, × and ° — and this corpus is full of them.
  • XLSX cells are all forced to data_type="s". Otherwise Excel reads a part number like 1-2-3 as a date. The user cannot get the original string back.

XLSX also gets a frozen header row, AutoFilter over the used range, and content-fitted column widths capped at 80 characters.

openpyxl is an optional extra (pip install swfilefixer[xlsx], vendored by the portable build when present). Absent, .xlsx is not offered and CSV — stdlib only, opens natively in Excel — still works. The format degrades; the feature never does.

What each tab exports, and how it says so. The Findings tab writes the filtered set, because the filters are how a user narrows to the job in hand, and exporting something other than what is on screen would be a quietly wrong record; when rows are hidden the success dialog says how many and lists the active filters. The Repair tab writes every rule, hidden ones included, plus a runs_now column — a plan is a record and must be complete, but on that tab visible is runnable, so the distinction has to survive into the sheet. Repair rows also carry complete (would the apply preflight run it) and affects where it has already been computed — left blank rather than forced, because forcing it would run the preview over every rule in the plan, which is exactly the frozen-UI cost the lazy Affects column exists to avoid.

The Repair export is a report, not a round trip: "Edit as text…" remains the losslessly re-importable format. The spreadsheet is for reviewing, sharing and signing off.

Finalize pass — accepting new internal IDs

ReplaceReferencedDocument rewrites the reference PATH in a closed parent but the parent still stores the OLD file's internal ID. SW surfaces that at next open (swFileLoadWarning_IdMismatch = 1) and only re-stamps the stored ID when the parent is saved. So a consolidation is only HALF done until every affected assembly has been opened + saved once. The dispatcher automates that: after a non-dry-run apply, it collects every assembly touched by replace_reference / consolidate_duplicates rules and runs resave_assemblies.csx (open silent → rebuild → save → close, per-assembly error isolation, id_mismatch_seen recorded in the audit log). Toggle: the "Re-save affected assemblies (accept new IDs)" checkbox on the Repair tab (default ON). After the finalize pass a re-scan shows no mismatched_linked_id findings for the repaired assemblies — the end-to-end answer to "two identical Toolbox parts with different IDs across assemblies".

Toolbox-flag surgery (set_toolbox_flag)

flag_off_inside_root findings (a file in the Toolbox tree without the IsToolboxPart flag — typically kit parts copied into browser folders) generate a SetToolboxFlagRule instead of being skipped. The combridge executor applies it via IModelDocExtension.ToolboxPartType {get; set;} (swToolBoxPartType_e: 0 = not Toolbox, 1 = standard, 2 = copied) — open silently, set, Save3, QuitDoc, with a read-back check. This replaces the manual sldsetdocprop.exe workflow. The reverse direction (clearing the flag on a part that deliberately lives in a project folder) is addable via the "Add — Set Toolbox flag" dialog or the SETFLAG <path> -> no plan-text line. Preview is pure registry reasoning: affected iff the scanned flag differs from the desired state. The SwDM engine will use the settable ISwDMDocument.ToolboxPart when the licence key lands.

Toolbox size generation (generate_toolbox_size)

Generates missing Toolbox size configurations inside a Toolbox master part (e.g. every Style-2 M8 hex-nut size the standard defines but the part file doesn't yet carry). Field origin: this replaces the manual "open Toolbox settings, tick sizes, wait for per-config rebuilds" workflow. Engine: swfilefixer_gui/engine/toolbox_generator.py (read its module docstring — it is the authoritative mechanism doc); GUI entry points: the auto-plan builder, the "Add — Generate Toolbox sizes…" dialog, and Tools → "Build Toolbox sizes…" (which just adds an all-missing-sizes rule to the plan — the run still goes through the normal Preview/Apply flow, so nothing writes silently).

Offline-solve / live-apply split. The expensive, error-prone part of size generation — resolving a size into a config name plus the exact dimension values and feature-suppression states — needs NO SolidWorks at all. tools/toolbox_solver.ps1 (Windows PowerShell 5.1; sldtoolboxdata.dll is a .NET Framework assembly and will not load in pwsh 7) drives the same .NET engine Toolbox Settings uses, so the recipes are what Toolbox itself would produce, including data-driven per-size defaults like washer-face variants. Only the last step — actually adding configurations to the part — runs live SolidWorks, via combridge and combridge_scripts/generate_toolbox_configs_batch.csx. Preview (F8) therefore shows the full, exact list of configs that would be created ("CREATE <config> in <part>", plus "UNSOLVABLE …" lines) with zero writes and zero SW.

Why the batch apply is fast. The csx opens the part ONCE, adds every config with AddConfiguration3 and applies each recipe via SetSystemValue3 / SetSuppression2 scoped to the named config — configurations are never activated one by one. One rebuild, one save. Geometry for each new config regenerates lazily on its first activation, which is normal SolidWorks behavior (identical to design-table configs) and is exactly what the native Toolbox flow pays for eagerly (activate + rebuild per config). Measured: 13 configs in ~24 s.

Unit-conversion ownership. The solver emits dimension values in the standard's units (part_units = MM or INCH); the csx script takes strictly SI meters (value_si). The conversion happens in ONE place — toolbox_generator.solve_part (×0.001 / ×0.0254, applied only when the solver marks the element convert_unit: true). Neither the executor nor the csx ever converts; a recipe that reaches the payload is already SI.

Length-axis enumeration (bolts/screws). Enumerate-all mode yields one solve per size, but bolt/screw families have a second axis — length — that lives only in the Toolbox SQLite DB (<root>/lang/english/swbrowser.sldedb, opened mode=ro&immutable=1). enumerate_length_jobs walks: <STD>_TYPE_* row (matched by Filename basename) → its ConfigurationTable grid → the grid row named Length → its AltDataSource +DATA_*_LENGTHS table → distinct (SIZE, LENGTH) pairs (thread-length variants produce duplicate rows; deduped — the solver picks the canonical thread length). Prefixed-table gotcha (real bug, regression-tested): the intra-DB links are stored WITHOUT the standard prefix (+CFG_BS_HHBOLT) while the physical tables carry it (AM_CFG_BS_HHBOLT); both spellings are tried. Parts with no length axis (nuts, washers) return None and enumerate-all covers them fully.

Two modes. config_names == [] means "build all missing sizes"; a non-empty list solves exactly those names (SolveForGivenConfig). Guard kept from live testing: SolveForGivenConfig silently solves a bogus name to a nearest/ parseable config instead of erroring, so a produced recipe only counts for a requested name when the names match (whitespace-collapsed, case-insensitive) — everything else lands in failed_jobs and, in explicit mode, fails the rule BEFORE any live work (a subset of an explicit request would be a silent partial repair).

Plan-builder strategy. A missing_configuration finding whose referenced part lies under the Toolbox root and whose requested config is genuinely absent (not case drift) generates a GenerateToolboxSizeRule instead of a NEEDS-INPUT set_component_config: creating the config in the master makes the assembly's stored reference valid with no assembly edit at all. Findings against the SAME part merge into one rule carrying all the requested names (one open/save cycle). Cosmetic case/whitespace drift still auto-fixes via set_component_config — generating a config differing only by case would collide, since SW matches config names case-insensitively. Parts outside the Toolbox root keep the previous behavior. Plan-text line: GENSIZE <part> -> all or GENSIZE <part> -> <cfg1> ;; <cfg2> (;; because Toolbox config names can contain commas and | is the note separator).

No finalize, live-only. The rule touches ONLY the part file — no assembly reference paths or internal IDs change — so it never enters the dispatcher's finalize (resave-assemblies) pass; the finalize collector stays a whitelist of replace_reference / consolidate_duplicates for exactly this reason. The SwDM engine reports the rule unsupported (the Document Manager API cannot create configurations with driven dimensions), so the dispatcher always routes it to combridge.

Weldment profile repathing

A weldment structural member pulls its cross-section from an external profile library file (.SLDLFP) and stores that file's path. When the library moves, the members go unresolved — or worse, SolidWorks silently re-resolves to a same-named profile from a different standard (Tube Square.SLDLFP exists under ANSI Configurations, ISO Configurations, DIN Configurations, … in the same library) and the cut list quietly lies.

Read side — off disk, via SWFormat (2026-07-31)

This was previously believed impossible. Both the ScripTree WeldmentProfileRepath README and C:/personal_rag/solidworks/lesson_20260731_weldment_profile_repath_preserves_locateprofile.md stated there was "no headless path" for profile references. That is true of the Document Manager API and of SWFormat's read_referenced_models (which reads only Contents/Definition, empty of paths on a part) — but false of the bytes on disk. The path is an ordinary MFC CStringW living in Contents/Config-N-ResolvedFeatures.

Rather than hand-roll that read here (which the project's hard rules forbid), we contributed swformat.api.weldments to SWFormat. It reuses SWFormat's existing carchive.cstring + api.references suffix-anchor scanner — no new binary schema, no object-map decode. PartRecord gains weldment_profile_refs (path + which config streams carried it) and structural_member_names, both filled by read_weldment_info_from_streams inside the existing single read_document call, so the scan cost is unchanged.

.SLDLFP means "library feature part", which covers ordinary Design Library features too. So the check reports reference_kindweldment_profile when a referencing part has Type="Structural Member" features in its KeyWords index, library_feature when not. The two agreed exactly across 78 production weldment parts, but the gate is offered rather than the equivalence assumed.

Write side — live SolidWorks only, and why

Config-N-ResolvedFeatures is a large undecoded CArchive object map, and the SW API itself couples the profile path to ConfigurationName. So there is no validated off-disk write path and SWFormat ships this read-only. RepathWeldmentProfileRule executes through combridge (combridge_scripts/repath_weldment_profile.csx), porting the field-proven recipe from the ScripTree tool:

  1. AccessSelections (mandatory) on the member's IStructuralMemberFeatureData; obtain it with a direct castas silently returns null on the RCW.
  2. Capture ConfigurationName + RotationAngle (and, defensively, each group's MirrorProfile / MirrorProfileAxis / Angle / AlignAxis).
  3. Set WeldmentProfilePath.
  4. Re-assert the captured values in the same ModifyDefinition call.

Step 4 is the whole trick. Setting the path alone silently resets ConfigurationName to the profile's default config, and that reset is what drags the Locate Profile pierce point off — not the path change. With the config held constant, SolidWorks preserves the pierce-point corner identity automatically for same-topology replacements (the "same file, new location" case). The csx refuses to repath onto a non-existent file — trading one broken reference for another is not a repair.

Optional size change. A .SLDLFP holds one configuration per size (Tube Square.SLDLFP carries 98; Pipe Standard S40.SLDLFP 19). RepathWeldmentProfileRule.new_config is empty by default, meaning "keep whatever size the member already has" — the normal case, where only the path moves. Setting it switches size deliberately, which is needed when the replacement library names its sizes differently: re-asserting a name the new file lacks makes SolidWorks fall back to the profile's default config and silently resize the member.

Three guards make that safe:

  1. profile_index.read_profile_configs() reads a .SLDLFP's configuration list off disk (a profile file is part-format, so SWFormat's ordinary read_configurations works unchanged). The GUI populates a size dropdown from it with zero SolidWorks.
  2. The executor validates the requested size exists in the replacement profile before any live work — a bad name fails the rule instead of silently defaulting.
  3. The csx reads ConfigurationName back after ModifyDefinition and fails the member if SW did not apply the requested size, so a fallback can never be reported as success.

Changing size is safe for the pierce point: the corner identity is preserved and coordinates simply re-resolve against the new size's sketch — empirically, a config change moved a pierce from (0.0254, 0.0381) to (0.0254, 0.127), i.e. the same corner of a larger tube.

Which size? — engine/profile_size.py

"Which configuration of the replacement most closely matches what this member uses?" is answerable off disk, in three tiers, and the tiers are the safety mechanism. Only tier 1 may proceed without asking (SizeSuggestion.is_safe_to_apply), because a wrong size is a silent geometry change — new cross-section, new cut length, wrong BOM.

An exact match means "keep the current size", NOT "write this size". Getting this backwards corrupts data, so it is encoded as SizeSuggestion.implies_keep_current_size and regression-tested.

A repath rule targets every part referencing the dead profile, and different parts legitimately use different sizes of the same profile. Measured on the ROPS corpus: 27 parts reference Tube Square.SLDLFP across 8 distinct sizes (TS5x5x0.375, TS4x4x0.5, TS2x2x0.25, TS6x6x0.5, …). The exact match is corroborated from ONE sampled part, so writing it into the rule would force that size on all 27 and silently resize about twenty of them.

What an exact match actually proves is narrower and is precisely what is needed: the size naming carries over to the replacement, so re-asserting each member's own ConfigurationName resolves instead of falling back to the profile default. That is new_config = "". An explicit size belongs to the close case, where the naming does not carry over and a confirmed size change is the only way to avoid a silent fallback.

  1. EXACT — trustworthy. Intersect the strings in the part's Contents/Config-N-ResolvedFeatures (swformat.api.weldments.iter_resolved_feature_strings) with the replacement's configuration names. The member's stored ConfigurationName is one of those strings, so a unique hit means "the size this member uses exists verbatim in the replacement".

    This needs no structural decode: it is membership testing against a known candidate set, not parsing an object map, so it cannot silently mis-parse. Measured: a ~300-string part against a 98-entry profile intersects to exactly one name, independently confirmed by that part's cut list.

  2. CLOSE — a suggestion, never auto-applied. No exact hit means the replacement library names sizes differently. Recover the member's current size from, in priority order: the cut-list Description; the structural member's name (SW builds them as <profile> <size>(n)); or the missing profile's own basename when the library is the one-file-per-size kind. Then rank the replacement's configurations with rank_sizes.

    Plain string similarity is not enough — P0.5 and PIPE 0.5 SCH 40 share few characters yet denote the same pipe. Size names are numbers plus a naming convention, so the score blends numeric-token agreement (0.55), leading-number agreement (0.30, the primary dimension and strongest single discriminator) and character similarity (0.15). Real case: P0.5PIPE 0.5 SCH 40 at 0.91 with every runner-up at 0.06.

    A pick also requires a margin over the runner-up, not just an absolute score: within a family (TS5x5x0.375 / TS5x5x0.5 / TS5x5x0.1875) a confident-looking best match would really be a coin toss between three wall thicknesses.

  3. UNKNOWN — declines to guess. Nothing recoverable (member renamed to Structural Member1, no cut list, non-size filename), or the part references several profiles and sizes for more than one of them are present — we cannot tell which belongs to this profile without decoding the object map, so we refuse and offer the list.

Verified on the real corpus: S BEAM.SLDPRT → exact TR16x4x0.5; hyro tube 10 INCH.SLDPRT → close, P0.5PIPE 0.5 SCH 40 (recovered from the missing profile's filename, because the member had been renamed); TEMP2.SLDPRT → unknown, correctly declining because it uses two profiles.

How the dialog presents it

RepathWeldmentProfileDialog takes a referencing_parts_provider(match_path) -> [(part_path, member_names)] callback rather than a static list — in the Add flow the match path does not exist when the dialog is constructed, and it stays editable afterwards, so a list would be stale on arrival. RepairTab supplies it from CombridgeExecutor.preview, the same source as the Affects column, guaranteeing the sampled part is one the rule actually covers.

The suggestion refreshes on the settle triggers only (Browse, library picker, editingFinished) — never per keystroke — and reads one representative part to build part_strings. Any failure degrades to the filename/member-name tiers rather than raising.

Presentation follows the tiers: green for exact, an amber bordered panel for close, neutral grey for unknown. Two behaviours matter more than the styling:

  • An untouched close preselection is gated behind a confirmation ("Use size" vs "Keep current size", conservative default) rather than trusting an amber label to be read. This enacts is_safe_to_apply is False instead of merely displaying it.
  • A hand-picked size rewrites the hint to name what was corroborated versus what will be recorded, so stale advice never sits above a changed selection.

Combo order stays alphabetical: the suggestion is already preselected, so promoting ranked entries would buy nothing for the one entry that matters while destroying positional predictability in a 98-entry list. Finding a different size is the substring completer's job.

This rule touches only PART files, so it is deliberately excluded from the dispatcher's finalize (resave-assemblies) pass. It is the subtlest exclusion of the three: it does rewrite a stored external path, which looks like a reference edit — but the path lives in the part's own feature data and the csx already saves that part, so no parent assembly's stored ID is invalidated. The SwDM engine reports it unsupported (the Document Manager cannot see feature data at all), so the dispatcher always routes it to combridge.

Resolution cascade (plan builder)

weldment_profile_root (a new build_plan parameter) enables two strategies, in order:

  1. Profile-root remap — generalises the Toolbox \browser\ remap. A profile library has no universal marker folder, so we split the dead path on the root's own leaf folder name, matched case-insensitively (the same corpus spells it both SolidWorks Weldment Profiles and Solidworks Weldment Profiles), and rejoin the remainder under the current root.
  2. Basename + folder tail — the basename under the root. Usually ambiguous (3–5 hits across standards folders), so _best_tail_overlap picks the candidate whose parent folders agree with the dead path. Ties fall to the candidates dropdown; nothing is ever guessed, because guessing swaps a member's standard silently.

Unresolvable refs still generate an editable NEEDS-INPUT rule. WeldmentProfileIndex (engine/profile_index.py) is the lazy, cache-backed .sldlfp index behind strategy 2; an unreadable root indexes to empty rather than raising.

First real-corpus run (D:/ROPS TEST STATION BACKUP, 587 files, profile root W:\...\Solidworks Weldment Profiles with 123 profiles): 78 weldment parts → 9 distinct missing profiles across 14 parts → 7 auto-resolved by root-remap, 2 correctly NEEDS INPUT (a per-size ANSI Inch\Tube (square)\TS5x5x0.375.sldlfp from a different library layout, and a contractor's C:\Users\sam\Desktop\... path — neither has a counterpart under the current root, and inventing one would be wrong).

Auto-detect + selector

swfilefixer.io.sw_registry.detect_weldment_profile_roots() reads Weldment Profile Folders from HKCU\SOFTWARE\SolidWorks\SOLIDWORKS 20NN\ExtReferences, newest version first. Structural difference from the Toolbox root: that value is semicolon-separated — SolidWorks File Locations is inherently a list of folders searched in order — so the detector returns list[str], entry 0 being the folder SW would consult first. The GUI's Detect button fills directly on a single hit and offers a chooser on multiple, which is why this side has a selector where Toolbox has a one-shot fill.

Feature-tree restoration (live-verified 2026-07-31)

A profile change is not a neutral edit: SolidWorks renames the member's profile sketch and inserts an orphan sketch above the member. Observed exactly:

+ Sketch13  ProfileFeature  id=117     <- new orphan, above the member
  Structural Member1
-      sub: Sketch11  id=31            <- original profile sketch
+      sub: Sketch14  id=119           <- replaced

That matters because SolidWorks equations and drawing dimensions reference sketches by name (D1@Sketch11), so an un-restored rename silently breaks every such reference — the user's stated risk ("I sometimes reference dimensions from these").

The csx therefore snapshots, per part, every top-level feature ID before touching anything, and per member the names of its ProfileFeature sub-features. After a successful ModifyDefinition it:

  1. Restores the profile sketch's original name.
  2. Restores its visibility. SolidWorks builds a brand-new profile sketch, so it arrives at the default (shown) state — a hidden profile sketch becomes visible and clutters every view. Reproduced exactly: the path sketch stayed HIDDEN while the member's profile sketch went HIDDEN → shown. IFeature.Visible is read-only (swVisibilityState_e: 1 = Hide, 2 = Shown), so the restore goes through select + BlankSketch/UnblankSketch (and BlankRefGeom/UnBlankRefGeom for the member's RefPlane). It runs after the rename so the selection resolves the original name.
  3. Deletes the orphan sketch, identified exactly as "a top-level ProfileFeature whose ID did not exist before, and which no feature consumes". IDs are the right key here — names get reused, IDs do not.

The internal feature ID cannot be restored. IFeature.GetID() has no setter; the ID is SolidWorks-assigned. This is a real limit, but not the one that bites: equations and dimensions bind by name, not ID. Verified end-to-end — after a repath the feature tree diffs identical to the original.

Reported per part as sketches_renamed_back, visibility_restored and orphan_sketches_deleted. Controlled by CombridgeExecutor.restore_weldment_tree (default on).

Saving and leaving files open

save_parts (default on) governs whether a part-modifying rule writes to disk at all, and leave_open leaves modified documents open in SolidWorks for review. These exist because of a reported surprise: unchecking "re-save affected assemblies" stopped assembly writes but part files were still saved. A user who says "don't re-save" means "don't write", so the same toggle now governs both. set_toolbox_flag refuses outright when saving is disabled — that flag lives in the file and has no meaningful in-memory form.

Running a repair — pause, cancel, and the save policy

How the repair log gets its detail

CombridgeExecutor.apply(rule, registry, out_notes=None) populates the caller's out_notes dict with the .csx script's own response. The dispatcher passes one in, merges it into RepairResult.notes, and hands each completed result to result_cb(res) the moment it finishes — so the UI logs per-rule progress live instead of waiting for the whole run to return.

That is the whole mechanism, and it is deliberately boring. The earlier version monkey-patched dispatcher.audit.write and CombridgeExecutor._run_script with pass-through wrappers for the duration of a run. It worked and unwound cleanly in a finally, but it reached into another module's privates to read values the engine simply was not exposing. The fix was to expose them.

Consequences worth knowing:

  • apply() still returns the affected-file list. An out-parameter was chosen over a richer return type because the file list is what every caller and the audit log want; changing the return would churn the executor protocol and every test to serve one consumer.
  • Both executors must accept out_notes, and so must any test double. The dispatcher's broad except turns a signature mismatch into a generic "failed" outcome rather than a visible TypeError, so a stale double fails confusingly — test_apply_out_notes_reaches_ repair_result_notes pins the contract.
  • A raising result_cb is swallowed: a bad observer is not a repair failure.
  • Intra-rule granularity ("which document is open right now") is deliberately NOT reported — a .csx only answers when it returns, so it would cost a combridge round-trip per update.

Interruption happens BETWEEN rules, never inside one. A rule is a single combridge call that opens, edits, saves and closes SolidWorks documents. Killing it mid-flight would leave half-edited or ownerless documents open, and the off-disk scanner would then disagree with what is on screen. Pause and Cancel are therefore evaluated at the top of each rule iteration (through the dispatcher's cancel_cb), so a requested pause takes effect only once the rule now running finishes. Both the UI and the log say so, because a cancel that looks like nothing happened invites the user to reach for Task Manager — the one action that genuinely can corrupt a half-repaired document.

Controls: Apply becomes Pause while running and Resume while paused; Cancel is hidden when idle and shown while running or paused; auto-pause every N rules (0 = never) stops the run periodically so the operator can eyeball SolidWorks. N is persisted in QSettings rather than the workspace — it describes how this operator likes to work, not the corpus being repaired, so it should follow them across workspaces.

One save switch, not two

"Save modified files (and re-save affected assemblies to accept new IDs)" drives both CombridgeExecutor.save_parts and the dispatcher's finalize pass. Two independent toggles would merely relocate the reported surprise: the state "save parts ON, re-save OFF" is reachable and means writing to disk after the user said not to.

"Off" genuinely means off, and where it cannot, the rule refuses rather than writing:

Rule kind Behaviour when saving is OFF
repath_weldment_profile honoured — edits left in memory
set_component_config honoured — edits left in memory
generate_toolbox_size honoured — configs left in memory
set_toolbox_flag refuses — the flag exists only in the file
replace_reference / consolidate_duplicates refusesReplaceReferencedDocument rewrites a CLOSED parent, so the write is the operation

leave_open additionally keeps modified documents open in SolidWorks for review instead of closing them; it pairs naturally with saving off.

Session pinning (safety)

CombridgeExecutor.session pins combridge's --session. combridge defaults to MRU — the SolidWorks window the user last focused — and shops routinely run several instances. Since our scripts open, save and close documents, an unpinned run can land in a window holding unsaved work. This is also what makes it safe to exercise a write path against a scratch instance while a real assembly is open elsewhere.

Repair ordering — conflicts, phases, and batching

Everything above describes how one rule is executed. This section describes what has to be true of the rules as a set, and it exists because a repair plan is not a list of independent edits: rules interact, and until v0.3 the only thing standing between the operator and an order-dependent surprise was the order they happened to appear in the table.

Three mechanisms, applied in this order, all of them pure functions over the rule list in swfilefixer_gui.engine.repair_ordering:

  1. Conflict detection — refuse (or warn about) rule sets whose combined effect is contradictory or order-dependent.
  2. Phase ordering — sort the surviving rules into a fixed sequence of phases so the result no longer depends on table order.
  3. Batching — split a rule whose fan-out is large into bounded chunks, so a long-running call has interruption points inside it.

None of the three touches SolidWorks, none does I/O, and none mutates the rules. They run at plan time (for the UI's preflight) and again at apply time (as the dispatcher's own guard), and both callers get the same answer from the same code.

The two hard constraints that force an order

These are not stylistic preferences. Both are properties of SolidWorks / our .csx scripts that make certain orderings fail.

1. The dirty-document interlock

replace_reference.csx opens by walking swApp.GetDocuments() and aborting outright if any open document reports GetSaveFlag():

if (d != null && d.GetSaveFlag())
    throw new InvalidOperationException(
        "Aborting: SolidWorks has unsaved changes in '" + d.GetTitle() + "'...");

It has to. ReplaceReferencedDocument requires a closed parent, so the script closes every open document with QuitDoc — and QuitDoc discards unsaved changes silently. Refusing is the only way not to destroy the operator's work.

The consequence for ordering is severe and was previously unwritten:

Any rule that can leave a document open and dirty must run AFTER every replace_reference / consolidate_duplicates rule.

With save_parts = False and leave_open = True — a combination the UI fully supports and which the "leave it in memory so I can review it" workflow actively encourages — generate_toolbox_size, set_component_config and repath_weldment_profile all leave exactly that: an open, modified, unsaved document. Put one of them before a replace rule and the replace does not partially succeed, it does not warn, it aborts the entire remaining batch with a message about a file the operator may not connect to the rule that failed.

create_placeholder_documents.csx already knew this — its finally block closes every placeholder it opened and says why in a comment: "the next script in the run (replace_reference.csx) ABORTS outright if any document is open and dirty." That knowledge was correct, local, and unenforced anywhere else. Phase ordering promotes it to a global property of every run.

2. The config-existence dependency

set_component_config verifies that the requested configuration actually exists in the target part before setting it — deliberately, so we do not reproduce the silent-fallback failure the missing_configuration check was written to catch. generate_toolbox_size is the rule that creates missing configurations.

generate_toolbox_size must run BEFORE set_component_config.

Run them the other way and the set-config rule fails validation against a part that was about to grow the very configuration it wanted. The plan was correct; only the order was wrong. This is the single most likely ordering bug in a real Toolbox repair, because build_plan emits both kinds from the same missing_configuration finding.

A third, softer dependency rounds it out: a set_component_config rule naming a component whose reference is being repointed by a replace_reference rule must run after that replace, because the configuration has to exist in the new target, not the old one.

Phase ordering

order_rules(rules) performs a stable sort by phase number. Within a phase the operator's table order is preserved exactly — the sort changes only what it must, so a plan the user has arranged stays recognisable.

Phase Rule kinds Why here
0 (placeholder pre-pass) Stubs must exist before any replace can point at one. Not a rule kind — the dispatcher's existing pre-pass, listed for completeness. Its script closes every document it creates, precisely to satisfy phase 1.
1 replace_reference, consolidate_duplicates First, because of the dirty-document interlock. At this point nothing in the run has opened anything, so the session is clean by construction. These operate on closed files and close everything they find.
2 generate_toolbox_size Part-level. Must precede phase 4 (creates the configurations phase 4 will ask for).
3 set_toolbox_flag, repath_weldment_profile Part-level metadata / feature edits. Independent of one another; both refuse or stay in memory per the save policy.
4 set_component_config Assembly-level. Last of the editing phases: needs phase 1's new reference targets and phase 2's new configurations to both be in place.
5 (finalize resave) Unchanged, still last. Re-opens and re-saves the assemblies phase 1 touched so SolidWorks accepts the new internal IDs.

Why phase 1 is first rather than last (the tempting alternative — "do all the part work, then rewire") is worth stating explicitly, because the tempting alternative is what a reasonable person writes first: it is exactly the ordering the dirty-document interlock forbids. Part work leaves dirty documents; rewiring refuses to start when dirty documents exist. The only orderings that work put rewiring before any part edit, or require saving to be on. We do not want a correct-order guarantee that silently depends on a UI toggle, so rewiring goes first unconditionally.

Interaction with the save policy, for completeness: when saving is OFF the finalize pass is off too (one switch drives both — see One save switch, not two), so the "finalize re-saves a document the user asked to leave unsaved" hazard is unreachable. Phase ordering does not change that; it just no longer relies on it.

Ordering is applied by RepairDispatcher.execute(..., order=True) (the default). Pass order=False to run the list exactly as given — used by tests that pin per-rule behaviour, and available as an escape hatch if a future rule kind needs hand-sequencing. The dispatcher reports each phase transition through the existing phase_cb, so the repair log shows phase 1/5: rewiring references (3 rules) rather than an undifferentiated rule count.

Conflict detection

detect_conflicts(rules) -> list[RuleConflict] classifies a rule set. A RuleConflict carries a severity ("error" / "warning"), a stable kind string, the indices of the rules involved, a human-readable message, and an evidence dict — the same show-your-work discipline the scanner's findings follow (design invariant 5).

Errors block Apply. The plan is contradictory; running it would produce a result that depends on rule order, and no ordering is "right". The UI refuses and names the rows.

kind Trigger Why it is an error
contradictory_replacement Two rules rewrite the same reference to different paths (exact-vs-exact, exact-vs-basename, or replace-vs-consolidate overlapping on one basename) Whichever runs last wins. The operator asked for two different outcomes for one reference.
contradictory_toolbox_flag Two set_toolbox_flag rules on one part with opposite make_toolbox Same file, opposite terminal states.
chained_replacement Rule A rewrites X → Y and rule B rewrites Y → Z Order-dependent and almost never intended: run A then B and the references A just moved to Y get moved again to Z; run B then A and they stay at Y. Reported as an error rather than silently ordered because the operator's actual intent (X → Z? X → Y?) is not recoverable from the plan.

Warnings do not block. The plan is coherent; something in it is suspicious or wasteful, and the operator should see it once.

kind Trigger What it means
self_replacement replacement_path equals match_path (case-insensitively, after separator normalisation) A no-op that still costs a full SolidWorks round trip. Usually a mis-click in a candidate dropdown.
superseded_part_edit A part-level rule (set_toolbox_flag, repath_weldment_profile, generate_toolbox_size) targets a file that a replace_reference rule is repointing away from The edit will be applied to a file nothing references any more. Not wrong — the file may still matter — but it is usually a leftover from an earlier plan.
duplicate_generate_size Two generate_toolbox_size rules on one part build_plan merges these into one rule by design (one part open/save cycle); a hand-edited or round-tripped plan can reintroduce the split. Harmless, just slower.
config_needs_generated_size A set_component_config names a config that a generate_toolbox_size rule in the same plan will create Informational, and the reason phase ordering exists. Emitted so the log explains why the two rules are being reordered rather than appearing to shuffle rows for no reason.

Deliberate non-goals of the conflict pass: it does not check that a replacement path exists on disk (that is the executor's preflight, and a create_stub replacement legitimately does not exist yet), and it does not check that a configuration exists in a part (that needs I/O — conflict detection is a pure function over the rule list and stays that way).

Batching and the control-file protocol

The problem batching solves

One rule is one combridge call, and a rule's fan-out is unbounded: a single basename-mode replace_reference against a consolidated Toolbox can carry hundreds of parent assemblies in one replacements array. The existing pause/cancel gate lives between rules, so for the whole of that call the operator's Pause button does nothing. On a real shop corpus that is minutes of apparent hang — and the documented advice ("never kill the subprocess, the COM session outlives it") means they cannot do anything about it but wait.

Two changes fix it, and both are needed:

  • Chunking (Python side). plan_batches(items, batch_size) splits a rule's item list into bounded chunks; the dispatcher issues one script call per chunk. Interruption then lands between chunks even for a script with no control-file support, progress is reported as chunk 3/17, and a failure's blast radius is one chunk instead of the whole rule. Default batch_size = 25, 0 disables chunking.
  • The control file (script side). Chunking alone still cannot interrupt a chunk. The control file gives the .csx an interruption point between items inside the call.

The protocol

Deliberately the dumbest thing that works, because one end is a Roslyn script with no shared library and the other is a Python worker thread.

Files. The GUI creates a temp directory per run holding two files:

File Written by Read by Contents
control the Python worker (GUI thread) the .csx, between items exactly one token: run, pause, or cancel
status the .csx, on every state change the Python executor, while waiting one JSON object: {"state": "running"|"paused", "done": int, "total": int, "item": str}

Both are written atomically (write to <name>.tmp in the same directory, then os.replace / File.Move(..., overwrite: true)) so a reader never sees a half-written token. Neither side ever holds a lock.

Rules of the protocol:

  1. Fail-open, always. A missing, empty, locked or unparseable control file reads as run. A repair must never stall because a temp file hiccuped — that would turn an antivirus scan into a hung SolidWorks session. Every read is wrapped; every failure means "carry on".
  2. Interruption is between items, never inside one. Identical to the between-rules rule and for identical reasons: one item is one document's open/edit/save/close cycle. pause observed mid-item takes effect after that item completes.
  3. cancel is a clean early return, not an abort. The script breaks out of its loop, finishes its normal reporting path, and returns ok: true with notes.interrupted = "cancel" and the per-item results it did complete. The dispatcher records it as a normal result with an interrupted note. A cancelled batch is a partial success, and the audit log must be able to say exactly which items succeeded.
  4. pause blocks the script, polling the control file every 200 ms and re-writing status.state = "paused" once. It never times out: a paused repair stays paused until the operator resumes or cancels, because the alternative (resuming itself after N minutes) would drive SolidWorks while nobody is watching.
  5. No control file in the payload = no polling. Scripts run byte-identically to before when the key is absent, which is what keeps every existing test and any direct .csx invocation valid.

Why a file and not stdin/stdout. The .csx protocol is single-shot: one JSON object in on stdin, one JSON object out on stdout, and the script's stdout is the return channel — interleaving control traffic would corrupt it. combridge offers no side channel, and a socket would need a port, a handshake and a firewall exemption for what is two processes on one machine sharing a temp directory.

How the GUI learns it actually paused. CombridgeExecutor._run_script normally blocks in subprocess.run. When a control file is in play it switches to Popen + a communicate(timeout=…) poll loop, reading the status file each time round and forwarding it to an optional status_cb. That is what lets the worker emit pause_state(True, "paused mid-batch after 40 of 213 items") — a real state, observed, not predicted. Without a control file the old subprocess.run path is used unchanged, so the common case keeps its exact previous behaviour and risk profile.

Scripts that honour it — the five whose item list can be long: replace_reference, resave_assemblies, repath_weldment_profile, generate_toolbox_configs_batch, create_placeholder_documents. Each inlines the same ~20-line helper rather than sharing one — combridge's run-script has no #load mechanism, and a duplicated helper that is 20 lines of File.ReadAllText in a try is a better trade than a build step.

set_toolbox_flag.csx is deliberately excluded even though it too loops over an items array: a SetToolboxFlagRule carries exactly one part_path, so the executor always sends it a single item. Its loop exists for payload symmetry, not fan-out, and there is no second iteration at which a gate could fire. Adding polling there would be pure ceremony.

generate_toolbox_configs_batch.csx is the one script whose "item" is not a whole document cycle — it adds N configurations to a single open part and saves once at the end. A cancel there still breaks the loop and falls through to that same single Save3, so every configuration created before the stop is persisted exactly as if the plan had asked for only those. The interruption is therefore clean for the same reason as elsewhere, by a different mechanism, and the script says so at the gate.

Scan targets — never scan nothing silently

ScanTarget carries a kind ("dir" / "file") alongside its path, and the two can disagree: typing DIR <some file> in the Edit-as-text dialog, or editing the path cell of a row originally added as a folder, both produce a file path labelled as a directory. The original _collect_paths branched on kind and skipped the mismatch, so such a target contributed zero files, silently — and the scan then reported success with zero findings.

Reported from the shop on 2026-07-31: "When I added an assembly and a part file to the targets list, the scans didn't pick up anything. When I added a folder, it seemed to pick up the problems from the other two." The checks were fine; nothing was ever handed to them.

Two rules now hold:

  1. The filesystem wins over the stored kind. is_dir() / is_file() answer the question definitively, so they decide and kind is treated as a hint. Be liberal in what we accept.
  2. A target that contributes nothing is reported, never silent. _collect_paths returns (paths, warnings); the warnings reach scan_meta["target_warnings"] and the GUI raises a dialog. When zero files were scanned the dialog is a warning that says plainly the empty result does not mean the files are clean.

This matters more than it looks: for an integrity scanner, "0 findings because everything is fine" and "0 findings because I looked at nothing" are indistinguishable to the user, and the second one is actively dangerous. scan_meta also carries files_scanned so any consumer can tell them apart.

Legacy OLE2 files — "unreadable" must never look like "clean"

SWFormat's chunk walker reads the modern (2015+) SolidWorks container. A pre-2015 file is an OLE2 compound document, and read_document(...).streams() returns {} for it — no exception.

Before 2026-07-31 that propagated silently: the component parser reported zero components, every check found nothing to complain about, and the scan declared success. Reported from the shop — an assembly known to be broken (W:/Engineering/Products/<project>/<assembly>.SLDASM) scanned clean.

Measured on that production tree: 843 of 1,200 files (70%) are legacy OLE2. The scanner had been silently blind to most of it.

reader._require_streams() now raises UnreadableFormatError when the stream map is empty, which the fail-open wrappers turn into a parse_error finding naming the detected container and what to do about it ("open in SolidWorks and re-save"). An empty stream map is never legitimate for a real model document — even an empty part carries dozens of streams.

This is the same principle as the target-collection fix above, one layer down: for an integrity scanner, "I found nothing" and "I could not read it" must never be indistinguishable.

Drawings

Drawings are scannable targets, read for exactly one thing: the models they depict (DrawingRecord.referenced_models, via SWFormat's shipped read_referenced_models). A drawing whose part or assembly has moved shows empty or errored views — the same defect class as a broken component reference — so it feeds broken_reference rather than a parallel check. A model referenced by both an assembly and its drawing is ONE finding, with referencing_assemblies and referencing_drawings kept separate because the two are repaired differently.

We deliberately do not parse drawing sheets, geometry or annotations. SWFormat can, but no check needs them and it would multiply scan cost on drawing-heavy shops for no finding.

First real-corpus run (ROPS, drawings enabled): 10 missing models were referenced only by a drawing — invisible to the scanner until now.

Drawings feed the reference-resolution pass. Phase B originally collected referenced paths from assemblies only, so a drawing's models were listed (for broken_reference) but never opened — meaning every content check had nothing to work on when a drawing was the target. It is now a bounded fixed point over assemblies AND drawings, dispatching by extension, so a drawing → assembly → parts chain resolves. Capped (_MAX_RESOLVE_ROUNDS) because a reference cycle between assemblies would otherwise spin; each file is still opened at most once. The CLI mirrors this and now also accepts a single FILE as its root, not just a folder.

Failure semantics

  • A parse error on file X emits a parse_error finding but doesn't abort the scan.
  • A referenced part that isn't in the registry (out-of-tree, missing) is silently skipped by missing_configuration and mismatched_linked_id. It IS logged in the scan metadata as an unresolved reference count.
  • Empty output (no findings) is a legitimate scan result — the scanner prints a "clean" banner.

Roadmap

  • broken_reference check (referenced part not present anywhere in the tree).
  • is_toolbox_bit extraction (needs an SWFormat contribution).
  • create_parts_vs_create_configurations_family — detect Toolbox families where some sizes are per-file and some are per-config; requires clustering by naming template.
  • --fix mode is explicitly deferred; repairs must go through SolidWorks with human review.