This is the "documentation IS the logic" document for SWFileFixer. Read this before touching any code, and update it whenever a design decision changes.
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.
- 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.
+----------------------+
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)
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.
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.
Wraps SWFormat plus an extended COMPINSTANCETREE parser.
Public functions:
read_part_identity(path) -> PartRecord | None— readsswXmlContents/Featuresviaswformat.api.components.read_part_config_tree. Returns aPartRecordwithcreation_time,configs,most_recent_config, plus the resolved absolute path. ReturnsNonewhen the file has no Features stream (drawing, corrupt, non-part).read_assembly_components(path) -> AssemblyRecord | None— the extended reader. Usesswformat.io.reader.read_documentto grab the rawCOMPINSTANCETREEbytes, then parses them once — extracting both:- The
Componentlist SWFormat already gives us (name, path, config, exclude_from_bom, flexible, hidden, suppressed, virtual, transform, bounding_box, model_ref). - 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. ReturnsNonefor non-assembly / no-COMPINSTANCETREE files.
- The
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.
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.
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.
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.
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:
- On the
<swFile>element inside the parent assembly'sCOMPINSTANCETREE— this is the parent's expected stamp for the referenced child file at the moment the parent was last saved. - On the
<swFile>element inside the child part's ownswXmlContents/Featuresstream — 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— abasenameappearing twice under the scan root with two differentcreation_timevalues 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.
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).
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_identityreads the document's stream map once and feeds three parsers: the Features config tree,is_toolbox_part_from_streams(the flag decision), andparse_solidworks_information(the rawswToolBoxPartType_efor evidence). Oneread_documentper part, unchanged scan cost.PartRecord.is_toolbox_partis three-state:True(Toolbox),False(info stream present, part not Toolbox),None(stream absent — undeterminable; never treated asFalseby checks).PartRecord.toolbox_part_typecarries the raw 0/1/2 value (not-toolbox / standard / copied).toolbox_flag_path_mismatchfires on three directions: flag-on outside root (A), flag-off inside root (B), and the original basename-split heuristic (C). Seedocs/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).
The Repair Plan tab is populated three ways, all editable afterwards:
-
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 underbrowseris 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 candidates — opt-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 INPUTnote, 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. - browser-subpath remap — a dead path containing
-
Send to Repair Plan — per-finding right-click on the Findings tab (pre-fills one rule).
-
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.
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:
- At build time — the "Prefer closest name match (same internal ID)"
checkbox beside Auto-build sets
build_plan(..., prefer_closest_name=True). - 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
candidateslist 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.
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.
Per user directive 2026-08-05, in order:
-
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.
-
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 forC:\Users\<somebody-else>\Desktop\...references. We walk up the dead path and stop at the first directory that exists and accepts a write; we nevermkdira vanished tree, because inventingX:\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 atC:\— 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 bytest_placeholder_dir_never_climbs_to_a_drive_root. -
The workspace placeholder folder —
Workspace.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.
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/CreationDate2on 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.mdproved the ID has no stable byte position (a Pack-and-Go pair differs in 98% of its bytes), andlesson_20260521_sw_file_id_not_byte_readableis why this project fingerprints withswCreationTimefrom 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:
-
Records the original identity on the stub, as custom properties written by the same csx that creates it:
Property Value SWFileFixerPlaceholder1— the machine-readable markerSWFF_OriginalPaththe full dead reference path, always written SWFF_OriginalFileIdthe creation stamp the parent expected, when the registry knew one SWFF_CreatedUtcwhen 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_referencecheck something unambiguous to detect. -
Leans on the finalize pass, which already solves the practical half of the problem.
ReplaceReferencedDocumentleaves 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 lingeringswFileLoadWarning_IdMismatch. The stub's ID never equals the vanished file's — but nothing in the corpus is looking for that value any more.
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.csxcloses every open document (itsReplaceReferencedDocumentprecondition). 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_pathis 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.
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, aQHeaderViewsubclass 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()movesQTableWidgetItems but does not move cell widgets installed withsetCellWidget. This table puts a per-row EditQPushButtonin column 0 and a per-row candidatesQComboBoxin 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.
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
QLabeldoes 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
QCheckBoxcannot 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.
resolve_combridge_exe() looks in this order:
- an explicit per-workspace override,
$SWFILEFIXER_COMBRIDGE— what the portable launchers set,<app root>/combridge/combridge.exe— the copy bundled beside the application (found even when the user bypasses the.batand starts the interpreter directly),combridgeonPATH,- 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 withSWFILEFIXER_COMBRIDGEdeliberately 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-combridgealso now fails rather than warning when combridge cannot be found, and verifiesplugins/SolidWorks/*.dllafter the copy — the plugin lives one level deeper than a flat*.dlllisting 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 runningcombridge 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.
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 like1-2-3as 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.
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".
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.
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.
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.
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_kind —
weldment_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.
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:
AccessSelections(mandatory) on the member'sIStructuralMemberFeatureData; obtain it with a direct cast —assilently returns null on the RCW.- Capture
ConfigurationName+RotationAngle(and, defensively, each group'sMirrorProfile/MirrorProfileAxis/Angle/AlignAxis). - Set
WeldmentProfilePath. - Re-assert the captured values in the same
ModifyDefinitioncall.
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:
profile_index.read_profile_configs()reads a.SLDLFP's configuration list off disk (a profile file is part-format, so SWFormat's ordinaryread_configurationsworks unchanged). The GUI populates a size dropdown from it with zero SolidWorks.- 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.
- The csx reads
ConfigurationNameback afterModifyDefinitionand 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 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_sizeand 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.SLDLFPacross 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
ConfigurationNameresolves instead of falling back to the profile default. That isnew_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.
-
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 storedConfigurationNameis 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.
-
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 withrank_sizes.Plain string similarity is not enough —
P0.5andPIPE 0.5 SCH 40share 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.5→PIPE 0.5 SCH 40at 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. -
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.5 → PIPE 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.
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 Falseinstead 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.
weldment_profile_root (a new build_plan parameter) enables two
strategies, in order:
- 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 bothSolidWorks Weldment ProfilesandSolidworks Weldment Profiles), and rejoin the remainder under the current root. - Basename + folder tail — the basename under the root. Usually
ambiguous (3–5 hits across standards folders), so
_best_tail_overlappicks 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).
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.
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:
- Restores the profile sketch's original name.
- 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.Visibleis read-only (swVisibilityState_e: 1 = Hide, 2 = Shown), so the restore goes through select +BlankSketch/UnblankSketch(andBlankRefGeom/UnBlankRefGeomfor the member'sRefPlane). It runs after the rename so the selection resolves the original name. - Deletes the orphan sketch, identified exactly as "a top-level
ProfileFeaturewhose 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).
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.
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 broadexceptturns a signature mismatch into a generic "failed" outcome rather than a visibleTypeError, so a stale double fails confusingly —test_apply_out_notes_reaches_ repair_result_notespins the contract. - A raising
result_cbis swallowed: a bad observer is not a repair failure. - Intra-rule granularity ("which document is open right now") is
deliberately NOT reported — a
.csxonly 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.
"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 |
refuses — ReplaceReferencedDocument 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.
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.
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:
- Conflict detection — refuse (or warn about) rule sets whose combined effect is contradictory or order-dependent.
- Phase ordering — sort the surviving rules into a fixed sequence of phases so the result no longer depends on table order.
- 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.
These are not stylistic preferences. Both are properties of
SolidWorks / our .csx scripts that make certain orderings fail.
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_duplicatesrule.
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.
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_sizemust run BEFOREset_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.
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.
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).
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 aschunk 3/17, and a failure's blast radius is one chunk instead of the whole rule. Defaultbatch_size = 25,0disables chunking. - The control file (script side). Chunking alone still cannot
interrupt a chunk. The control file gives the
.csxan interruption point between items inside the call.
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:
- 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". - 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.
pauseobserved mid-item takes effect after that item completes. cancelis a clean early return, not an abort. The script breaks out of its loop, finishes its normal reporting path, and returnsok: truewithnotes.interrupted = "cancel"and the per-item results it did complete. The dispatcher records it as a normal result with aninterruptednote. A cancelled batch is a partial success, and the audit log must be able to say exactly which items succeeded.pauseblocks the script, polling the control file every 200 ms and re-writingstatus.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.- 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
.csxinvocation 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.
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:
- The filesystem wins over the stored
kind.is_dir()/is_file()answer the question definitively, so they decide andkindis treated as a hint. Be liberal in what we accept. - A target that contributes nothing is reported, never silent.
_collect_pathsreturns(paths, warnings); the warnings reachscan_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.
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 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.
- A parse error on file X emits a
parse_errorfinding but doesn't abort the scan. - A referenced part that isn't in the registry (out-of-tree, missing)
is silently skipped by
missing_configurationandmismatched_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.
broken_referencecheck (referenced part not present anywhere in the tree).is_toolbox_bitextraction (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.--fixmode is explicitly deferred; repairs must go through SolidWorks with human review.