Fix euristica BOM, unifica script/GUI su un'unica fonte, aggiungi test integrazione/E2E

Due bug reali trovati confrontando l'estrazione BOM con la BOM ufficiale Creo:
un componente scartato per placement fallito in un file veniva bloccato per
sempre anche quando il file giusto aveva un vero placement (perdendo interi
sotto-alberi come 9256510047), e i codici con suffisso (_C01 ecc.) richiedevano
sempre placement esplicito invece dell'eccezione per occorrenze ripetute già
presente nella GUI. Entrambi corretti.

AssemblyComponentScanner reso pubblico e scripts/BomViaDb.cs, ExtractCreoStrings.cs
riscritti per referenziarlo via "#:project" invece di duplicare l'euristica:
un'unica fonte di verità condivisa da GUI e script standalone.

Aggiunti test di integrazione per Indexer.ScanAsync (mai coperto oltre al
parsing del nome file) e test end-to-end che ricostruiscono l'intera pipeline
disco -> indice -> risoluzione albero componenti senza toccare la UI.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
davideandClaude Sonnet 5 committed 2026-09-21 13:43:54 +02:00
1 parent 353a1028d9
commit 5c9661eb12
8 files changed
+514 -171

No files matched your search

+12 -6
View File
@@ -128,12 +128,18 @@ disco di default `X:\`, hardcoded in `Form1.RadiceDiscoDefault`).
## Script standalone (`scripts/`)
`scripts/` contiene C# file-based apps (.NET 10, `dotnet run scripts/Nome.cs -- arg1 arg2`),
senza csproj — dipendenze NuGet extra si aggiungono con `#:package Nome@versione` in cima
al file. `ExtractCreoStrings.cs` estrae la BOM di un `.asm` risolvendo ricorsivamente i
sotto-assiemi contro `cad_index.db` (stessa logica di `Form1.AssiemeCompleto`, solo codici
numerici Pro-E); `BomViaDb.cs` fa lo stesso ma con la tokenizzazione generica validata contro
il DB, usata per intercettare anche i nomi alfabetici delle parti commerciali (vedi
`docs/formato-asm.md`); `Analyze.cs` dumpa il contesto testo/hex attorno a un pattern in un
senza csproj proprio — dipendenze NuGet extra si aggiungono con `#:package Nome@versione` in
cima al file. `ExtractCreoStrings.cs` e `BomViaDb.cs` risolvono ricorsivamente l'albero
componenti di un `.asm` contro `cad_index.db` da riga di comando (stessa logica di
`Form1.RisolviAlberoComponenti`/`AssiemeCompleto`): entrambi referenziano
`PrepStep/PrepStep.csproj` via `#:project` (più `#:property TargetFramework=net10.0-windows`,
richiesto perché il progetto principale è WinForms) invece di duplicare l'euristica di
estrazione/placement, che vive solo in `PrepStep/AssemblyComponentScanner.cs` — un'unica
fonte di verità condivisa da GUI e script, vedi `docs/formato-asm.md`. Le due varianti restano
distinte solo nel candidato passato a `EstraiCodiciReferenziati`: `ExtractCreoStrings.cs` non
passa `codiciNoti` (riconosce solo i codici numerici Pro-E, dalla forma, senza bisogno del
DB); `BomViaDb.cs` lo passa, per intercettare anche i nomi alfabetici delle parti commerciali
validati contro l'indice. `Analyze.cs` dumpa il contesto testo/hex attorno a un pattern in un
file binario, per investigare a mano nuovi falsi positivi prima di toccare la regex; `Query.cs`
esegue una query SQL ad-hoc contro `cad_index.db` da riga di comando.
@@ -148,4 +148,54 @@ public class AssemblyComponentScannerTests
}
finally { File.Delete(percorso); }
}
[Fact]
public void Riconosce_codice_con_suffisso_anche_senza_dettaglio_nel_placement()
{
// Regressione: caso reale 9284218024b.asm -> 2546220101_C01.._C04. Il placement
// riporta solo il codice padre senza suffisso ("Componente ID N (2546220101.PRT)"),
// il dettaglio "_C01" compare solo altrove come stringa ripetuta, mai dentro un
// placement esplicito. Un codice nudo nella stessa situazione andrebbe scartato
// (vedi Scarta_codice_ripetuto_senza_un_vero_placement), ma un codice con suffisso
// di variante è considerato "forma nota": bastano le occorrenze ripetute. Prima
// che questa eccezione fosse portata anche in scripts/BomViaDb.cs, lo script
// perdeva l'intero sotto-assieme (vedi docs/formato-asm.md).
string percorso = ScriviAsmFinto(
"Componente ID 1 (2546220101.PRT) x 2546220101_C01 y 2546220101_C01 z");
try
{
var codici = AssemblyComponentScanner.EstraiCodiciReferenziati(percorso);
Assert.Contains("2546220101_C01", codici);
}
finally { File.Delete(percorso); }
}
[Fact]
public void Il_placement_e_indipendente_per_ogni_file_scansionato()
{
// Regressione: caso reale 9244228558.asm -> 9244236104 dentro 9244228558_C01.asm.
// Lo stesso codice compare come riferimento fantasma (nudo, senza placement) nel
// file dell'assieme padre, e con un vero placement nel file del sotto-assieme che
// lo contiene realmente. La proprietà che rende sicura la BFS del chiamante
// (Form1.RisolviAlberoComponenti, scripts/BomViaDb.cs, scripts/ExtractCreoStrings.cs)
// è che EstraiCodiciReferenziati valuta il placement da zero per ogni file, senza
// stato condiviso tra le chiamate: un fallimento nel file padre non deve
// condizionare il risultato quando lo stesso codice viene ripassato per il file
// figlio (vedi docs/formato-asm.md, "Trappola nella BFS").
string filePadre = ScriviAsmFinto("assieme 11112222 padre 11112222 fine");
string fileFiglio = ScriviAsmFinto("Componente ID 1 (11112222.PRT) x 11112222 y");
try
{
Assert.DoesNotContain("11112222", AssemblyComponentScanner.EstraiCodiciReferenziati(filePadre));
Assert.Contains("11112222", AssemblyComponentScanner.EstraiCodiciReferenziati(fileFiglio));
}
finally { File.Delete(filePadre); File.Delete(fileFiglio); }
}
[Theory]
[InlineData("9284208188", "9284208188")]
[InlineData("9284208188_D600", "9284208188")]
[InlineData("9244228558_C03-01", "9244228558")]
public void CodiceRadice_estrae_la_parte_prima_del_primo_underscore(string codice, string radiceAttesa) =>
Assert.Equal(radiceAttesa, AssemblyComponentScanner.CodiceRadice(codice));
}
+169
View File
@@ -0,0 +1,169 @@
using Microsoft.Data.Sqlite;
using PrepStep;
namespace PrepStep.Tests;
/// <summary>Integration test di Indexer.ScanAsync: disco temporaneo reale + DB SQLite reale,
/// nessun dato inserito a mano (a differenza di CaseInsensitiveLookupIntegrationTests, che
/// verifica solo le query a valle su righe già presenti). Copre la scansione vera - upsert
/// incrementale, rilevamento nuovi/aggiornati/invariati/rimossi, esclusione cartelle "temp_" -
/// che prima di questi test non aveva nessuna copertura (solo Indexer.ParseFilename era
/// testato).</summary>
public class IndexerScanTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"cadsearch_scan_{Guid.NewGuid():N}");
private readonly string _dbPath;
public IndexerScanTests()
{
Directory.CreateDirectory(_root);
_dbPath = Path.Combine(_root, "indice.db");
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
Directory.Delete(_root, recursive: true);
}
private static void ScriviFile(string percorso, string contenuto = "dati")
{
Directory.CreateDirectory(Path.GetDirectoryName(percorso)!);
File.WriteAllText(percorso, contenuto);
}
/// <summary>IProgress&lt;T&gt; sincrono: Progress&lt;T&gt; marshala il report tramite
/// SynchronizationContext.Post (o il thread pool, se assente, come nei test) - l'ultimo
/// valore non è garantito visibile subito dopo l'await. Qui Report gira sullo stesso
/// thread di background di Indexer.Scan, prima che Task.Run completi: l'ultimo valore è
/// quindi affidabile non appena l'await ritorna.</summary>
private sealed class ProgressoSincrono : IProgress<ScanProgress>
{
public ScanProgress? Ultimo { get; private set; }
public void Report(ScanProgress value) => Ultimo = value;
}
private async Task ScanAsync()
{
await Indexer.ScanAsync(_root, _dbPath, new ProgressoSincrono(), CancellationToken.None);
}
private List<(string Codice, string Tipo, string? Famiglia, string CartellaRadice)> LeggiFiles()
{
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT codice, tipo_file, famiglia, cartella_radice FROM files";
using var reader = cmd.ExecuteReader();
var righe = new List<(string, string, string?, string)>();
while (reader.Read())
righe.Add((reader.GetString(0), reader.GetString(1),
reader.IsDBNull(2) ? null : reader.GetString(2), reader.GetString(3)));
return righe;
}
[Fact]
public async Task Indicizza_un_file_estraendo_codice_tipo_versione_e_famiglia()
{
ScriviFile(Path.Combine(_root, "__Corpi", "442", "9244228573.asm.16"));
await ScanAsync();
var righe = LeggiFiles();
Assert.Single(righe);
Assert.Equal(("9244228573", "asm", "442", "__Corpi"), righe[0]);
}
[Fact]
public async Task Esclude_le_cartelle_radice_che_iniziano_con_temp_()
{
ScriviFile(Path.Combine(_root, "temp_paris", "vecchio.asm.1"));
ScriviFile(Path.Combine(_root, "__Corpi", "9244228573.asm.16"));
await ScanAsync();
var righe = LeggiFiles();
Assert.Single(righe);
Assert.Equal("__Corpi", righe[0].CartellaRadice);
}
[Fact]
public async Task File_non_modificato_resta_invariato_tra_due_scansioni()
{
string percorso = Path.Combine(_root, "__Corpi", "file.prt.1");
ScriviFile(percorso);
await ScanAsync();
var progress = new ProgressoSincrono();
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
Assert.NotNull(progress.Ultimo);
Assert.Equal(0, progress.Ultimo!.Nuovi);
Assert.Equal(0, progress.Ultimo.Aggiornati);
Assert.Equal(1, progress.Ultimo.Invariati);
Assert.Equal(0, progress.Ultimo.Rimossi);
}
[Fact]
public async Task File_modificato_viene_rilevato_come_aggiornato()
{
string percorso = Path.Combine(_root, "__Corpi", "file.prt.1");
ScriviFile(percorso, "versione 1");
await ScanAsync();
// Contenuto più lungo -> dimensione diversa, quindi rilevato come modifica anche se
// il file system non aggiornasse data_modifica entro la risoluzione del clock.
ScriviFile(percorso, "versione 2 con più testo dentro");
var progress = new ProgressoSincrono();
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
Assert.NotNull(progress.Ultimo);
Assert.Equal(1, progress.Ultimo!.Aggiornati);
Assert.Equal(0, progress.Ultimo.Nuovi);
}
[Fact]
public async Task File_cancellato_dal_disco_viene_rimosso_dal_db_alla_scansione_successiva()
{
string percorso = Path.Combine(_root, "__Corpi", "file.prt.1");
ScriviFile(percorso);
await ScanAsync();
Assert.Single(LeggiFiles());
File.Delete(percorso);
await ScanAsync();
Assert.Empty(LeggiFiles());
}
[Fact]
public async Task Files_ultima_versione_tiene_solo_la_versione_piu_recente_per_codice_e_tipo()
{
ScriviFile(Path.Combine(_root, "__Corpi", "9244228573.asm.40"));
ScriviFile(Path.Combine(_root, "__Corpi", "9244228573.asm.41"));
await ScanAsync();
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT versione FROM files_ultima_versione WHERE codice = '9244228573'";
using var reader = cmd.ExecuteReader();
Assert.True(reader.Read());
Assert.Equal(41L, reader.GetInt64(0));
Assert.False(reader.Read());
}
[Fact]
public async Task File_senza_pattern_codice_tipo_versione_va_in_tipo_altro()
{
ScriviFile(Path.Combine(_root, "__Corpi", "leggimi.txt"));
await ScanAsync();
var righe = LeggiFiles();
Assert.Single(righe);
Assert.Equal("altro", righe[0].Tipo);
}
}
+156
View File
@@ -0,0 +1,156 @@
using Microsoft.Data.Sqlite;
using PrepStep;
namespace PrepStep.Tests;
/// <summary>End-to-end: disco CAD finto -> Indexer.ScanAsync -> cad_index.db reale -> BFS di
/// risoluzione albero componenti (stesso algoritmo di Form1.RisolviAlberoComponenti e
/// scripts/BomViaDb.cs, che ora delegano entrambi ad AssemblyComponentScanner - vedi
/// docs/formato-asm.md). Nessuna UI coinvolta: verifica che le tre fasi si compongano
/// correttamente, non solo che ciascuna funzioni isolata. I contenuti .asm/.prt sono testo
/// sintetico con gli stessi marcatori documentati per un vero file Creo ("Componente ID N
/// (...)"), non file binari reali - sufficiente perché AssemblyComponentScanner legge il file
/// come testo Latin1 indipendentemente dal resto del formato.</summary>
public class PipelineEndToEndTests : IDisposable
{
private readonly string _root = Path.Combine(Path.GetTempPath(), $"cadsearch_e2e_{Guid.NewGuid():N}");
private readonly string _dbPath;
public PipelineEndToEndTests()
{
Directory.CreateDirectory(_root);
_dbPath = Path.Combine(_root, "indice.db");
}
public void Dispose()
{
SqliteConnection.ClearAllPools();
Directory.Delete(_root, recursive: true);
}
private static void ScriviFile(string percorso, string contenuto)
{
Directory.CreateDirectory(Path.GetDirectoryName(percorso)!);
File.WriteAllText(percorso, contenuto);
}
/// <summary>Stesso algoritmo BFS di scripts/BomViaDb.cs: qui non è duplicazione a rischio
/// come lo era prima della riscrittura (vedi docs/formato-asm.md) - è la prova, in un
/// test, che comporre AssemblyComponentScanner con un semplice ciclo di accodamento dà il
/// risultato atteso sopra un indice reale, non un mock.</summary>
private static (SortedSet<string> Trovati, List<string> NonTrovati) RisolviAlbero(
string asmIniziale, Dictionary<string, List<(string Tipo, string Percorso)>> codiciNoti)
{
string codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0];
string codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio);
var trovati = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var nonTrovati = new List<string>();
var visti = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
var coda = new Queue<string>(new[] { asmIniziale });
while (coda.Count > 0)
{
var asm = coda.Dequeue();
foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm, codiciNoti.Keys))
{
if (!visti.Add(codice))
continue;
if (!codiciNoti.TryGetValue(codice, out var componenti))
{
nonTrovati.Add(codice);
continue;
}
trovati.Add(codice);
foreach (var (tipo, percorso) in componenti)
if (tipo == "asm")
coda.Enqueue(percorso);
}
}
return (trovati, nonTrovati);
}
private Dictionary<string, List<(string Tipo, string Percorso)>> CaricaCodiciNoti()
{
using var connection = new SqliteConnection($"Data Source={_dbPath};Mode=ReadOnly");
connection.Open();
using var cmd = connection.CreateCommand();
cmd.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione "
+ "WHERE LOWER(tipo_file) IN ('prt', 'asm')";
using var reader = cmd.ExecuteReader();
var codiciNoti = new Dictionary<string, List<(string, string)>>(StringComparer.OrdinalIgnoreCase);
while (reader.Read())
{
string codice = reader.GetString(0);
var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new();
lista.Add((reader.GetString(1), reader.GetString(2)));
}
return codiciNoti;
}
[Fact]
public async Task Risolve_lalbero_completo_a_partire_dal_disco_indicizzato()
{
// TOP -> CHILD (vero placement, 2 occorrenze) -> LEAF (vero placement, 2 occorrenze).
// "99999999" compare una sola volta in TOP: rumore binario, va scartato anche se
// fosse indicizzato (vedi AssemblyComponentScannerTests.Scarta_codici_con_una_sola_occorrenza).
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1"),
"inizio Componente ID 1 (23456789.ASM) testo Componente ID 2 (23456789.ASM) rumore 99999999 fine");
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "23456789.asm.1"),
"Componente ID 1 (34567890.PRT) blah Componente ID 2 (34567890.PRT) fine");
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "34567890.prt.1"), "dati foglia");
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "99999999.prt.1"), "mai referenziato davvero");
var progress = new Progress<ScanProgress>();
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
var codiciNoti = CaricaCodiciNoti();
var asmIniziale = Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1");
var (trovati, nonTrovati) = RisolviAlbero(asmIniziale, codiciNoti);
Assert.Equal(new[] { "23456789", "34567890" }, trovati);
Assert.DoesNotContain("99999999", trovati);
Assert.Empty(nonTrovati);
}
[Fact]
public async Task Segnala_come_non_trovato_un_componente_con_placement_vero_ma_mai_indicizzato()
{
// "23456789" ha un vero placement in TOP ma il suo file non esiste sul disco finto
// (mai scansionato -> non in cad_index.db): deve finire tra i "non trovati", non far
// fallire la risoluzione dell'albero.
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1"),
"Componente ID 1 (23456789.ASM) testo Componente ID 2 (23456789.ASM) fine");
var progress = new Progress<ScanProgress>();
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
var codiciNoti = CaricaCodiciNoti();
var asmIniziale = Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1");
var (trovati, nonTrovati) = RisolviAlbero(asmIniziale, codiciNoti);
Assert.Empty(trovati);
Assert.Contains("23456789", nonTrovati);
}
[Fact]
public async Task File_in_cartella_temp_non_entra_nellindice_e_quindi_non_viene_risolto()
{
// Stesso codice "23456789" esiste per davvero, ma solo dentro una cartella radice
// "temp_..." - esclusa dalla scansione (vedi Indexer.cs e docs/formato-asm.md, falso
// positivo su copie scollegate dentro temp_*). Il componente deve restare "non
// trovato", non risolversi per sbaglio contro la copia in temp_.
ScriviFile(Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1"),
"Componente ID 1 (23456789.ASM) testo Componente ID 2 (23456789.ASM) fine");
ScriviFile(Path.Combine(_root, "temp_davide", "23456789.asm.1"), "copia scollegata");
var progress = new Progress<ScanProgress>();
await Indexer.ScanAsync(_root, _dbPath, progress, CancellationToken.None);
var codiciNoti = CaricaCodiciNoti();
var asmIniziale = Path.Combine(_root, "MACCHINE", "GRUPPO1", "12345678.asm.1");
var (trovati, nonTrovati) = RisolviAlbero(asmIniziale, codiciNoti);
Assert.Empty(trovati);
Assert.Contains("23456789", nonTrovati);
}
}
+5 -1
View File
@@ -70,7 +70,11 @@ namespace PrepStep;
/// silenzio come "nudo senza placement" - lì verrà comunque scartato come "non in indice",
/// ma in modo tracciabile.
/// </summary>
static class AssemblyComponentScanner
// Pubblica (non solo internal) perché è anche l'unica fonte di verità per l'euristica di
// estrazione, referenziata via "#:project" dagli script standalone (scripts/BomViaDb.cs,
// scripts/ExtractCreoStrings.cs) invece che duplicata al loro interno - vedi
// docs/formato-asm.md, sezione sulla trappola del "codice duplicato in tre punti".
public static class AssemblyComponentScanner
{
private static readonly Regex CodiceNumericoRegex = new(
@"(?<!ModelView#)\b\d{8,12}[A-Za-z]{0,2}(?:_[A-Za-z]?\d{2,4})?\b",
+76
View File
@@ -281,6 +281,82 @@ per `data_modifica DESC` e tenuta solo la riga più recente per tipo, altrimenti
si rischia di risolvere un codice contro una copia diversa (e potenzialmente
diversa nel contenuto) da quella realmente referenziata.
### Trappola nella BFS: un fallimento di placement non è definitivo
Un codice può comparire come riferimento fantasma (senza vero placement, vedi
sopra) in un file scansionato **prima** di quello in cui invece ha un vero
placement — tipicamente perché lo stesso codice è mostrato anche nel file
dell'assieme padre come promemoria/metadato, oltre che nel file del proprio
assieme dove è effettivamente posizionato. Osservato su un caso reale
(assieme `9244228558.asm`): il componente `9244236104` compare nel file
dell'assieme generale come riferimento nudo senza placement (viene scartato
lì, correttamente), ma ha un vero `Component id 45 (9244236104.PRT)` dentro
`9244228558_c01.asm`, il sotto-assieme che lo contiene realmente — scansionato
solo _dopo_ il file padre nella BFS.
Una BFS che marca un candidato come "già visto" al primo incontro, anche
quando quell'incontro è un fallimento di placement, blocca per sempre ogni
ri-verifica successiva: il componente resta perso anche quando il file giusto
viene scansionato più avanti. Nei casi più gravi il codice bloccato è esso
stesso un **sotto-assieme** (osservato con `9256510047` dentro
`9256518042.asm`): se il suo codice viene scartato prematuramente, il suo file
non viene nemmeno accodato, e si perde l'intero sotto-albero sottostante
(compresi `9056510047`/`9056510047_C01`/`9056510047_C02` in questo caso).
`scripts/BomViaDb.cs` (versione iniziale) aveva esattamente questo bug: un set
`codiciVisti` unico bloccava sia i candidati confermati sia quelli scartati
per mancato placement. Corretto separando le due cose: un fallimento di
placement non aggiunge il codice a nessun set di blocco, quindi resta
ritestabile ogni volta che lo stesso candidato ricompare in un file successivo
già in coda. L'accodamento di un nuovo file da scansionare resta invece
condizionato al placement confermato (non al solo essere un codice noto nel
DB) — senza questo secondo vincolo, un codice generico che per puro caso
coincide con un codice noto (es. `"915"`, `"932"`) farebbe esplodere la
scansione su file estranei in tutto l'indice (verificato: passa da ~100 a
quasi 19000 "componenti trovati" e la scansione si blocca).
**Il codice della GUI (`Form1.RisolviAlberoComponenti` +
`AssemblyComponentScanner.EstraiCodiciReferenziati`) non ha questo bug**,
verificato riproducendo lo stesso caso reale con un test dedicato: la
classificazione placement-sì/no è già interamente delegata a
`EstraiCodiciReferenziati`, chiamata da zero per ogni file scansionato — un
fallimento in un file non lascia traccia nel set `codiciVisti` del chiamante,
che accumula solo codici già confermati. Il file successivo che referenzia
davvero il componente lo ritrova quindi senza ostacoli, senza bisogno di
nessuna modifica. Vale comunque come nota di design da preservare: il giorno
in cui la logica di `RisolviAlberoComponenti` venisse riscritta per fare la
verifica di placement inline nella BFS (come `BomViaDb.cs`), andrebbe
applicata la stessa correzione.
### Seconda discrepanza trovata: l'eccezione dei codici con suffisso
Confrontando `scripts/BomViaDb.cs` (corretto come sopra) con la GUI sullo
stesso assieme di un caso reale più grande (`9284218024b.asm`, ~530
componenti), le due liste **non coincidevano**: lo script perdeva l'intero
sotto-assieme `2546220101` (`_C01`..`_C04`, inclusi i cuscinetti
`INAFAG_NA6901-XL_...` dentro `_C04`), presente invece nella BOM ufficiale
Creo e trovato correttamente dalla GUI.
Causa: `2546220101_C01`..`_C04` hanno un vero placement nel proprio file
(`2546220101.asm`), ma **senza il suffisso** - il testo riporta solo
`Componente ID N (2546220101.PRT)`, il dettaglio `_C01` compare solo altrove
nei metadati, non vicino al placement (stesso fenomeno già documentato sopra
in "Placement senza il dettaglio del suffisso/bracket", osservato lì solo per
famiglie UNI/dimensionali). `AssemblyComponentScanner.EstraiCodiciReferenziati`
già gestisce questo caso: un codice con suffisso di revisione/variante
(`SuffissoRegex`, es. `_C01`, `_D100`) o prefisso di family table nota (`UNI`,
`CUSCI_`) è considerato "forma nota" ed esentato dal placement esplicito -
basta che occorra almeno 2 volte nel file. `BomViaDb.cs` richiedeva invece
sempre il placement letterale per ogni codice, senza questa eccezione.
Corretto portando la stessa regola (`FormaNota` + conteggio occorrenze) in
`BomViaDb.cs`. Riverificato su entrambi i casi reali (`9244228558.asm`,
`9284218024b.asm`): script e GUI producono ora **esattamente lo stesso
insieme** di componenti, e in entrambi coincide con la BOM ufficiale Creo (a
meno delle sole istanze di family table risolte al nome del file realmente
indicizzato, es. `2502020105<CUSCI_DGBB7>` → `CUSCI_DGBB7` - comportamento
corretto, non una discrepanza).
## Strumento di analisi manuale
[`scripts/Analyze.cs`](../scripts/Analyze.cs) stampa il contesto testuale ed
+24 -54
View File
@@ -1,17 +1,16 @@
#:project ../PrepStep/PrepStep.csproj
#:property TargetFramework=net10.0-windows
#:package Microsoft.Data.Sqlite@10.0.12
// dotnet run scripts/BomViaDb.cs -- <file.asm> [cad_index.db] [output.txt]
//
// PROVA: a differenza di ExtractCreoStrings.cs (che restringe i candidati a un solo
// pattern di codice, tarato sui codici numerici Pro-E), qui l'estrazione è generica -
// qualunque run di lettere/cifre/underscore/trattino - e il DB decide se è un codice
// vero, così si intercettano anche i nomi alfabetici delle parti commerciali scaricate
// come STEP (es. "ANELLO_ESTERNO-1-SOLID1"). L'essere nel DB da solo non basta però:
// codici corti o generici (es. "907", "NONE") comparirebbero per puro rumore binario,
// quindi si richiede comunque il placement esplicito nel file, come in
// ExtractCreoStrings.cs. Stessa espansione ricorsiva sui sotto-assiemi.
using System.Text;
using System.Text.RegularExpressions;
// Stessa risoluzione ricorsiva dell'albero componenti di Form1.RisolviAlberoComponenti
// nella GUI, da riga di comando: usa direttamente PrepStep.AssemblyComponentScanner (via
// "#:project", niente ProjectReference separato) invece di duplicare l'euristica di
// estrazione/placement. Un'unica fonte di verità - niente rischio che script e GUI
// divergano silenziosamente, come accaduto prima di questa riscrittura (vedi
// docs/formato-asm.md).
using Microsoft.Data.Sqlite;
using PrepStep;
if (args.Length < 1)
{
@@ -23,20 +22,12 @@ string asmIniziale = args[0];
string dbPath = args.Length > 1 ? args[1] : "cad_index.db";
string outPath = args.Length > 2 ? args[2] : "BOM.txt";
// Oltre il 90% dei codici in indice sono numerici Pro-E, ma una minoranza (parti
// commerciali scaricate come STEP, es. "ANELLO_ESTERNO-1-SOLID1") ha nomi alfabetici
// con trattini/underscore. Niente forma di codice ipotizzata a mano: si tokenizza in
// modo generico (run di lettere/cifre/underscore/trattino) e si lascia al DB decidere
// cos'è un codice vero - stesso principio già usato per il filtro, esteso anche
// all'estrazione dei candidati. Cattura anche "UNI6592"/"CUSCI_ACBB7" dentro
// "<UNI6592>" gratis: '<' e '>' non fanno parte del charset e delimitano il token.
var codiceRegex = new Regex(@"[A-Za-z0-9_-]{3,}", RegexOptions.Compiled);
using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly");
connection.Open();
// Tutti i codici prt/asm noti all'indice, caricati una volta sola: il lookup per ogni
// candidato estratto dal binario diventa O(1) invece di una query per file.
// candidato estratto dal binario diventa O(1) invece di una query per file, e serve anche
// a EstraiCodiciReferenziati per validare i token generici delle parti commerciali.
var codiciNoti = new Dictionary<string, List<(string Tipo, string Percorso)>>(StringComparer.OrdinalIgnoreCase);
using (var command = connection.CreateCommand())
{
@@ -54,51 +45,31 @@ using (var command = connection.CreateCommand())
}
Console.WriteLine($"Codici prt/asm noti nell'indice: {codiciNoti.Count}");
// Stesso controllo di placement di ExtractCreoStrings.cs/AssemblyComponentScanner.cs:
// la sola presenza del codice nel DB non basta, perché codici corti o generici
// (es. "907", "NONE", "start_part") possono comparire per puro rumore binario. Un
// componente vero è sempre referenziato in una di queste forme (vedi
// docs/formato-asm.md): "Componente ID N (<codice>...)"/"Component id N (<codice>...)",
// il codice ripetuto due volte separato da un byte NUL, il campo
// "family_name\0<codice>\0", o il codice seguito da un bracket "<codice><GENERICO>".
bool HaPlacement(string testo, string codice) =>
Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase)
|| testo.Contains(codice + "\0" + codice + "\0")
|| testo.Contains("family_name\0" + codice + "\0")
|| testo.Contains(codice + "<");
var codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0];
// Stessa esclusione di ExtractCreoStrings.cs: un assieme "_D..." referenzia sempre al
// suo interno anche l'assieme generale a cui appartiene (riferimento fantasma verso
// l'alto), da escludere subito o si risale tutto l'albero del macchinario.
var codiceRadice = codiceProprio.Split('_')[0];
var codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio);
var componentiTrovati = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var scartatiNonInIndice = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var scartatiSenzaPlacement = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var nonTrovati = new List<string>();
var codiciVisti = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
var daScansionare = new Queue<string>(new[] { asmIniziale });
while (daScansionare.Count > 0)
{
var asm = daScansionare.Dequeue();
var testo = Encoding.Latin1.GetString(File.ReadAllBytes(asm));
var candidati = codiceRegex.Matches(testo).Select(m => m.Value).ToHashSet(StringComparer.OrdinalIgnoreCase);
foreach (var candidato in candidati)
foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm, codiciNoti.Keys))
{
if (!codiciVisti.Add(candidato))
// Solo i codici già confermati da EstraiCodiciReferenziati arrivano qui: nessun
// rischio del bug "primo fallimento blocca per sempre" (vedi docs/formato-asm.md),
// perché il placement è già stato verificato per questo file specifico al suo
// interno, da zero, indipendentemente da cosa è successo negli altri file.
if (!codiciVisti.Add(codice))
continue;
if (!codiciNoti.TryGetValue(candidato, out var componenti))
if (!codiciNoti.TryGetValue(codice, out var componenti))
{
scartatiNonInIndice.Add(candidato);
nonTrovati.Add(codice);
continue;
}
if (!HaPlacement(testo, candidato))
{
scartatiSenzaPlacement.Add(candidato);
continue;
}
componentiTrovati.Add(candidato);
componentiTrovati.Add(codice);
foreach (var (tipo, percorso) in componenti)
if (tipo == "asm")
daScansionare.Enqueue(percorso);
@@ -107,7 +78,6 @@ while (daScansionare.Count > 0)
File.WriteAllLines(outPath, componentiTrovati);
Console.WriteLine($"{componentiTrovati.Count} componenti scritti in {outPath}");
Console.WriteLine($"Candidati nell'indice ma senza placement (scartati, {scartatiSenzaPlacement.Count}): {string.Join(", ", scartatiSenzaPlacement.Take(30))}"
+ (scartatiSenzaPlacement.Count > 30 ? ", ..." : ""));
Console.WriteLine($"Candidati non nell'indice (rumore binario): {scartatiNonInIndice.Count}");
Console.WriteLine($"Candidati non nell'indice (scartati, {nonTrovati.Count}): {string.Join(", ", nonTrovati.Take(30))}"
+ (nonTrovati.Count > 30 ? ", ..." : ""));
return 0;
+22 -110
View File
@@ -1,15 +1,15 @@
#:project ../PrepStep/PrepStep.csproj
#:property TargetFramework=net10.0-windows
#:package Microsoft.Data.Sqlite@10.0.12
// dotnet run scripts/ExtractCreoStrings.cs -- <file.asm> [cad_index.db] [output.txt]
//
// Estrae dal file .asm i codici dei componenti referenziati, espandendo ricorsivamente
// ogni sotto-assieme (.asm) trovato nell'indice, finché non restano solo PRT (foglie) o
// codici non presenti nell'indice. Stessa euristica di PrepStep/AssemblyComponentScanner.cs:
// i codici veri compaiono più volte nel file, il rumore binario quasi sempre una sola volta;
// gli ID interni "ModelView#<numero>" sono esclusi via lookbehind - vedi scripts/Analyze.cs.
// Ogni codice trovato viene validato contro l'indice cad_index.db.
using System.Text;
using System.Text.RegularExpressions;
// Come scripts/BomViaDb.cs, ma senza passare "codiciNoti" a EstraiCodiciReferenziati: si
// riconoscono solo i codici numerici Pro-E (che si validano dalla forma, senza bisogno del
// DB - vedi PrepStep/AssemblyComponentScanner.cs), non le parti commerciali con nome
// alfabetico. Prova più semplice/mirata rispetto a BomViaDb.cs, utile quando serve solo
// individuare/segnalare codici numerici mancanti dall'indice.
using Microsoft.Data.Sqlite;
using PrepStep;
if (args.Length < 1)
{
@@ -21,114 +21,29 @@ string asmIniziale = args[0];
string dbPath = args.Length > 1 ? args[1] : "cad_index.db";
string outPath = args.Length > 2 ? args[2] : "BOM.txt";
// Seconda alternativa: componenti normalizzati definiti come family table in Creo (viti/
// dadi/rosette UNI, ma anche cuscinetti CUSCI_...). Compaiono come "Componente ID N
// (R12--_-_U6592<UNI6592>.PRT)": "R12--_-_U6592" è il nome della singola istanza (riga
// della family table, non un file separato su disco), il file reale indicizzato è il
// generico "uni6592.prt.<versione>"/"cusci_acbb7.prt.<versione>" - va quindi estratto solo
// il contenuto tra "<" e ">".
// Terza alternativa: varianti dimensionali con suffisso puramente numerico (es. cinghie
// "2702220182_8188", dove "8188" è una misura, non una lettera di revisione/sotto-assieme
// come "_C01"/"_D100") - la lettera dopo "_" è quindi opzionale, non obbligatoria. La
// lettera di revisione e il suffisso "_" possono anche comparire insieme sullo stesso
// codice (es. "9284200366A_C01", componente di un sotto-assieme già di per sé revisionato).
var codiceRegex = new Regex(
@"(?<!ModelView#)\b\d{8,12}[A-Za-z]{0,2}(?:_[A-Za-z]?\d{2,4})?\b|(?<=<)(?:UNI\d+|CUSCI_[A-Za-z0-9]+)(?=>)",
RegexOptions.Compiled);
const int occorrenzeMinime = 2;
HashSet<string> EstraiCodiciReferenziati(string percorsoAsm)
{
var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm));
var occorrenze = new Dictionary<string, int>();
foreach (Match m in codiceRegex.Matches(testo))
occorrenze[m.Value] = occorrenze.GetValueOrDefault(m.Value) + 1;
var candidati = occorrenze.Where(kv => kv.Value >= occorrenzeMinime).Select(kv => kv.Key).ToHashSet();
// Ripetersi >=2 volte nel file non basta per un codice "nudo" (senza suffisso "_..."):
// riferimenti al proprio assieme padre (nome sessione/file) o a codici superati/
// rinominati (caso "fantasma", docs/formato-asm.md) ripetono comunque il codice più
// volte pur non essendo mai un componente vero. Per questi si richiede un vero
// placement, in una delle quattro forme osservate nel binario: "Componente ID N
// (<codice>...)"/"Component id N (<codice>...)" (la stringa cambia con la
// localizzazione Creo del file); il codice ripetuto due volte di seguito separato da
// un singolo byte NUL ("<codice>\0<codice>\0", osservato per componenti di family
// table non-UNI, es. cuscinetti "CUSCI_..."); il campo "family_name\0<codice>\0"
// (osservato una sola volta per file, su un sotto-assieme referenziato come family
// table generica invece che con un placement testuale esplicito); oppure il codice
// seguito immediatamente da un bracket "<codice><GENERICO>" - l'istanza di una family
// table non-UNI vista nel nome (es. "2510060303<CUSCI_ACBB7>", un cuscinetto): il
// codice dell'istanza non è mai indicizzato a sé (solo il generico tra "<" e ">" lo è,
// vedi sotto), ma questo segnale lo fa comunque sopravvivere fino alla ricerca nel DB
// invece di scomparire in silenzio come "nudo senza placement" - lì verrà comunque
// scartato come "non in indice", ma in modo tracciabile. Non si applica alle varianti
// con suffisso "_..." né alle istanze family table UNI: in alcuni file il placement
// riporta solo il nome istanza/generico senza il dettaglio del suffisso o del bracket
// "<...>", che vive altrove nei metadati - lì l'occorrenza ripetuta resta l'unico
// segnale disponibile.
bool HaPlacement(string codice) =>
Regex.IsMatch(testo, $@"Component(?:e)? id \d+ \([^()]*{Regex.Escape(codice)}[^()]*\)", RegexOptions.IgnoreCase)
|| testo.Contains(codice + "\0" + codice + "\0")
|| testo.Contains("family_name\0" + codice + "\0")
|| testo.Contains(codice + "<");
candidati.RemoveWhere(c => Regex.IsMatch(c, @"^\d{8,12}[A-Za-z]{0,2}$") && !HaPlacement(c));
return candidati;
}
using var connection = new SqliteConnection($"Data Source={dbPath};Mode=ReadOnly");
connection.Open();
// "files_ultima_versione" è una VIEW con ROW_NUMBER() OVER (...), che SQLite non
// materializza: ogni query la ricalcola sull'intera tabella "files" (~230ms anche per un
// solo codice, su ~300k righe). Batchare tutti i codici trovati in un file in un'unica
// query paga quel costo una volta per file invece che una volta per codice - decisivo
// sugli assiemi con centinaia di sotto-componenti referenziati.
Dictionary<string, List<(string Tipo, string Percorso)>> CercaComponenti(IReadOnlyCollection<string> codici)
var codiciNoti = new Dictionary<string, List<(string Tipo, string Percorso)>>(StringComparer.OrdinalIgnoreCase);
using (var command = connection.CreateCommand())
{
var risultato = new Dictionary<string, List<(string, string)>>(StringComparer.OrdinalIgnoreCase);
foreach (var codice in codici)
risultato[codice] = new List<(string, string)>();
if (codici.Count == 0)
return risultato;
using var command = connection.CreateCommand();
var nomiParametri = codici.Select((_, i) => $"@c{i}").ToList();
// Lo stesso codice+tipo può esistere in più cartelle radice (duplicati/copie storiche
// sulla share, es. un generico family table ripetuto in 5 progetti scollegati): ordinato
// per data_modifica DESC, si tiene solo il più recente per codice+tipo (stessa regola di
// Form1.RisolviAlberoComponenti, vedi docs/formato-asm.md).
command.CommandText = "SELECT codice, tipo_file, percorso_completo FROM files_ultima_versione "
+ $"WHERE LOWER(codice) IN ({string.Join(",", nomiParametri)}) AND LOWER(tipo_file) IN ('prt', 'asm') "
+ "ORDER BY codice, tipo_file, data_modifica DESC";
int i = 0;
foreach (var codice in codici)
command.Parameters.AddWithValue(nomiParametri[i++], codice.ToLowerInvariant());
var tipiVisti = new Dictionary<string, HashSet<string>>(StringComparer.OrdinalIgnoreCase);
+ "WHERE LOWER(tipo_file) IN ('prt', 'asm') ORDER BY codice, tipo_file, data_modifica DESC";
using var reader = command.ExecuteReader();
while (reader.Read())
{
string codiceDb = reader.GetString(0);
string codice = reader.GetString(0);
string tipo = reader.GetString(1).ToLowerInvariant();
var visti = tipiVisti.TryGetValue(codiceDb, out var v) ? v : tipiVisti[codiceDb] = new HashSet<string>();
if (visti.Add(tipo))
risultato[codiceDb].Add((tipo, reader.GetString(2)));
var lista = codiciNoti.TryGetValue(codice, out var l) ? l : codiciNoti[codice] = new();
if (!lista.Any(x => x.Tipo == tipo))
lista.Add((tipo, reader.GetString(2)));
}
return risultato;
}
Console.WriteLine($"Codici prt/asm noti nell'indice: {codiciNoti.Count}");
// Espansione ricorsiva: ogni sotto-assieme (.asm) trovato viene a sua volta letto e i suoi
// componenti aggiunti alla coda, finché non restano solo PRT (foglie) o codici non presenti
// nell'indice - stessa logica di Form1.RisolviAlberoComponenti.
var codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0];
// Un assieme con suffisso "_D..." referenzia sempre al suo interno anche l'assieme
// generale (codice nudo, senza suffisso) a cui appartiene - riferimento fantasma verso
// l'alto, non un componente vero (vedi docs/formato-asm.md). Va escluso subito: se
// seguito, riporta dentro l'intero albero del macchinario invece del solo sotto-assieme
// richiesto (visto su 9284208188_D600 -> 9284208188, che da solo genera 598 falsi
// positivi invece di ~90 componenti reali).
var codiceRadice = codiceProprio.Split('_')[0];
var codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio);
var componentiTrovati = new SortedSet<string>(StringComparer.OrdinalIgnoreCase);
var nonTrovati = new List<string>();
var codiciVisti = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
@@ -137,14 +52,11 @@ var daScansionare = new Queue<string>(new[] { asmIniziale });
while (daScansionare.Count > 0)
{
var asm = daScansionare.Dequeue();
var candidati = EstraiCodiciReferenziati(asm)
.Where(codiciVisti.Add)
.ToList();
var trovatiPerCodice = CercaComponenti(candidati);
foreach (var codice in candidati)
foreach (var codice in AssemblyComponentScanner.EstraiCodiciReferenziati(asm))
{
var componenti = trovatiPerCodice[codice];
if (componenti.Count == 0)
if (!codiciVisti.Add(codice))
continue;
if (!codiciNoti.TryGetValue(codice, out var componenti))
{
nonTrovati.Add(codice);
continue;