diff --git a/CLAUDE.md b/CLAUDE.md
index 060ea5e..9de393c 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -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.
diff --git a/PrepStep.Tests/AssemblyComponentScannerTests.cs b/PrepStep.Tests/AssemblyComponentScannerTests.cs
index 434eab8..3856cb3 100644
--- a/PrepStep.Tests/AssemblyComponentScannerTests.cs
+++ b/PrepStep.Tests/AssemblyComponentScannerTests.cs
@@ -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));
}
diff --git a/PrepStep.Tests/IndexerScanTests.cs b/PrepStep.Tests/IndexerScanTests.cs
new file mode 100644
index 0000000..44c23f1
--- /dev/null
+++ b/PrepStep.Tests/IndexerScanTests.cs
@@ -0,0 +1,169 @@
+using Microsoft.Data.Sqlite;
+using PrepStep;
+
+namespace PrepStep.Tests;
+
+/// 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).
+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);
+ }
+
+ /// IProgress<T> sincrono: Progress<T> 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.
+ private sealed class ProgressoSincrono : IProgress
+ {
+ 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);
+ }
+}
diff --git a/PrepStep.Tests/PipelineEndToEndTests.cs b/PrepStep.Tests/PipelineEndToEndTests.cs
new file mode 100644
index 0000000..89d1bdb
--- /dev/null
+++ b/PrepStep.Tests/PipelineEndToEndTests.cs
@@ -0,0 +1,156 @@
+using Microsoft.Data.Sqlite;
+using PrepStep;
+
+namespace PrepStep.Tests;
+
+/// 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.
+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);
+ }
+
+ /// 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.
+ private static (SortedSet Trovati, List NonTrovati) RisolviAlbero(
+ string asmIniziale, Dictionary> codiciNoti)
+ {
+ string codiceProprio = Path.GetFileName(asmIniziale).Split('.')[0];
+ string codiceRadice = AssemblyComponentScanner.CodiceRadice(codiceProprio);
+ var trovati = new SortedSet(StringComparer.OrdinalIgnoreCase);
+ var nonTrovati = new List();
+ var visti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
+ var coda = new Queue(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> 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>(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();
+ 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();
+ 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();
+ 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);
+ }
+}
diff --git a/PrepStep/AssemblyComponentScanner.cs b/PrepStep/AssemblyComponentScanner.cs
index d56378d..b35f4be 100644
--- a/PrepStep/AssemblyComponentScanner.cs
+++ b/PrepStep/AssemblyComponentScanner.cs
@@ -70,7 +70,11 @@ namespace PrepStep;
/// silenzio come "nudo senza placement" - lì verrà comunque scartato come "non in indice",
/// ma in modo tracciabile.
///
-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(
@"(?` → `CUSCI_DGBB7` - comportamento
+corretto, non una discrepanza).
+
## Strumento di analisi manuale
[`scripts/Analyze.cs`](../scripts/Analyze.cs) stampa il contesto testuale ed
diff --git a/scripts/BomViaDb.cs b/scripts/BomViaDb.cs
index 37213a1..d64034a 100644
--- a/scripts/BomViaDb.cs
+++ b/scripts/BomViaDb.cs
@@ -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 -- [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
-// "" 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>(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 (...)"/"Component id N (...)",
-// il codice ripetuto due volte separato da un byte NUL, il campo
-// "family_name\0\0", o il codice seguito da un bracket "".
-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(StringComparer.OrdinalIgnoreCase);
-var scartatiNonInIndice = new SortedSet(StringComparer.OrdinalIgnoreCase);
-var scartatiSenzaPlacement = new SortedSet(StringComparer.OrdinalIgnoreCase);
+var nonTrovati = new List();
var codiciVisti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
var daScansionare = new Queue(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;
diff --git a/scripts/ExtractCreoStrings.cs b/scripts/ExtractCreoStrings.cs
index f1e9980..9fd78d9 100644
--- a/scripts/ExtractCreoStrings.cs
+++ b/scripts/ExtractCreoStrings.cs
@@ -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 -- [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#" 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.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."/"cusci_acbb7.prt." - 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(
- @"(?)",
- RegexOptions.Compiled);
-const int occorrenzeMinime = 2;
-
-HashSet EstraiCodiciReferenziati(string percorsoAsm)
-{
- var testo = Encoding.Latin1.GetString(File.ReadAllBytes(percorsoAsm));
- var occorrenze = new Dictionary();
- 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
- // (...)"/"Component id N (...)" (la stringa cambia con la
- // localizzazione Creo del file); il codice ripetuto due volte di seguito separato da
- // un singolo byte NUL ("\0\0", osservato per componenti di family
- // table non-UNI, es. cuscinetti "CUSCI_..."); il campo "family_name\0\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 "" - l'istanza di una family
- // table non-UNI vista nel nome (es. "2510060303", 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> CercaComponenti(IReadOnlyCollection codici)
+var codiciNoti = new Dictionary>(StringComparer.OrdinalIgnoreCase);
+using (var command = connection.CreateCommand())
{
- var risultato = new Dictionary>(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>(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();
- 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(StringComparer.OrdinalIgnoreCase);
var nonTrovati = new List();
var codiciVisti = new HashSet(StringComparer.OrdinalIgnoreCase) { codiceProprio, codiceRadice };
@@ -137,14 +52,11 @@ var daScansionare = new Queue(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;