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

+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;