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).
- 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.
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.
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.