feat(import): recognise known bank layouts and report format drift #340
No reviewers
Labels
No labels
autopilot:pending-human
source:analyste
source:defenseur
source:human
source:medic
status:approved
status:blocked
status:in-progress
status:needs-clarification
status:needs-fix
status:ready
status:review
status:triage
type:bug
type:feature
type:infra
type:refactor
type:schema
type:security
No milestone
No project
No assignees
1 participant
Notifications
Due date
No due date set.
Dependencies
No dependencies set.
Reference: maximus/Simpl-Resultat#340
Loading…
Reference in a new issue
No description provided.
Delete branch "issue-330-bank-signatures-drift"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Link 8 of 10 of the import-format chantier (
spec-plan-import-csv-format.md).Stacked on
issue-329-mandatory-preview— review only the top commit.What it does
Two failure modes, both anchored on the header row.
A known bank is read by name
src/utils/bankSignatures.tsdeclares the documented export layout of Desjardins, RBC, Banque Nationale and Tangerine as a set of normalized header labels plus a delimiter and a preamble quirk. The table is evaluated before the generic dictionary; an unknown file falls straight through to it, unchanged. A recognised file is announced — « Format Desjardins reconnu — 6 des 6 lignes lues ».The signatures are not decorative.
headerDictionary.tsmatches keywords as substrings, one role at a time, and reads two of these four layouts wrong — both frozen as counterfactual test pairs (same rows, header renamed):Date,Transaction,Name,Memo,AmountTransaction— every row labelledDEBIT/CREDITName…,Cheque Number,Description 1,CAD$,USD$CAD$matches no amount keyword; it is sparse-complementary with the near-emptyCheque Number, so the pair is read as debit/credit and the cheque row imports as -247.95 instead of -6.95CAD$A signature stays a set of preferences: they are written from documented layouts, without real statements, so every hint is dropped the moment the data contradicts it. The single exception is the amount mode, which outranks the sparse-complementary scan — nothing inside an RBC file distinguishes that pair from a genuine one — and even that is refused unless the declared columns are candidates the shape scan itself proposed. Failing degrades to the generic path; it never breaks. Three preconditions guard every match (delimiter, preamble length, whole-line quoting), each tested.
Format drift is reported instead of imported
Every successful import records the normalized labels of its header row in
import_sources.header_signature— a JSON array, not a hash: the panel has to be able to name the columns that moved. On the next import, a header that normalizes differently opens aFormatDriftPanelabove the preview, column by column (Montant : 3 → 4), with the two outcomes that exist: adopt the re-detected format, or keep the stored one. A cosmetic rename (Montant→MONTANT ($)) normalizes identically and says nothing.A source whose file has no header row keeps
header_signatureNULL and drift detection is inoperative on it — documented, not worked around: a signature invented from the data would fire on every import.The repair path is in the interface
RepairPathNotice, rendered in the drift panel and beside the preview's sign-flip button.findDuplicates(transactionService.ts:164) matches ondate AND description AND amount, so re-importing a file "now that it reads right" does not correct the rows already written — it doubles them, and a flipped sign produces mirror pairs that net to ~0 in every report. The only safe path is deleting the faulty import from the history (deleteImportWithTransactions) before replaying it.Points a reviewer should check
await detectFormatForFile(guard moves 2 → 3, deliberately. The drift re-detection goes through the single detection path rather than around it;runAutoDetect(stays at 1. The test comment says why.MIN_SIGNATURE_LABELS = 4.Date;Description;Montantis the shape of half the synthetic corpus, so a 3-label variant would put a bank's name over files nobody can attribute to it. Consequence, accepted: the existing 3-columndesjardins-quotedfixture stays unrecognised and reads through the generic path exactly as before.header_signaturestays out of the format codec (link 3's separation), and is written only atexecuteImport— the single config write point of link 5. A test assertsimportFormat.tsnever names it.docs/: the spec plan centralises both in link 10 ("Le CHANGELOG est centralisé dans l'issue 10"), and no link of the stack has touched either file.Verification
npm test— 59 files, 1140 passed (1088 after link 7; +52 inbankSignatures.test.ts).npm run build— tsc + vite clean.cargo check— clean (no Rust work).Resolves #330
Generated autonomously by /autopilot run of 2026-08-13
Two failure modes, both anchored on the header row. A KNOWN BANK IS NOW READ BY NAME. `bankSignatures.ts` declares the documented export layout of Desjardins, RBC, Banque Nationale and Tangerine as a set of normalized header labels plus a delimiter and a preamble quirk. The table is evaluated BEFORE the generic dictionary and an unknown file falls straight through to it, unchanged. The signatures are not decorative. The generic dictionary matches keywords as substrings, one role at a time, which reads two of these four layouts wrong — both frozen as counterfactual test pairs, same rows, header renamed: - Tangerine writes `Date,Transaction,Name,Memo,Amount`. `Transaction` is a description keyword, so every row of the file was labelled with its direction word instead of the merchant. - RBC writes its amount column `CAD$`, which no amount keyword matches, next to a nearly empty `Cheque Number`. Those two are sparse-complementary, so the shape scan paired them as debit/credit and the one row carrying a cheque number imported as -247.95 instead of -6.95. A signature stays a set of PREFERENCES all the same: they are written from documented layouts, without real statements, so every hint is dropped the moment the data contradicts it. The single exception is the amount mode, which outranks the sparse-complementary scan — nothing inside an RBC file can tell that pair from a genuine one — and even that is refused unless the declared columns are candidates the shape scan proposed. Failing degrades to the generic path; it never breaks. FORMAT DRIFT IS NOW REPORTED INSTEAD OF IMPORTED. Every successful import records the normalized labels of its header row in `import_sources.header_signature`, as a JSON array and not a hash: the panel has to be able to name the columns that moved. On the next import, a header that normalizes differently opens a `FormatDriftPanel` above the preview — column by column, `Montant : 3 -> 4` — with the two outcomes that exist: adopt the re-detected format, or keep the stored one. A cosmetic rename (`Montant` -> `MONTANT ($)`) normalizes identically and says nothing. A source whose file has no header row keeps `header_signature` NULL and drift detection is inoperative on it. Documented, not worked around: a signature invented from the data would fire on every import. THE REPAIR PATH IS NOW IN THE INTERFACE, in the drift panel and beside the preview's sign flip. `findDuplicates` matches on date AND description AND amount, so re-importing a file "now that it reads right" does not correct the rows already written — it doubles them, and a flipped sign produces mirror pairs that net to zero in every report. The only safe path is deleting the faulty import from the history first. The drift re-detection reuses `detectFormatForFile`, so there is still exactly one detector; the static guard on its caller count moves from two to three deliberately. Its score and bank badge are dropped straight after: they measure the format the panel offers, not the one in use. Resolves #330/pr-review#340 — REQUEST_CHANGESRésumé
Revue adversariale, faits mesurés en rejouant la détection base-vs-branche hors du worktree (esbuild + node sur
origin/issue-329-mandatory-previewetorigin/issue-330-bank-signatures-drift), pas en lisant le diff.Ce qui tient, vérifié :
detectImportFormat: 13 configurations identiques au caractère près, 2 différences —bank-rbcetbank-tangerine, exactement les deux que les signatures visent.bank-desjardinsetbank-bncsortent la même config qu'avant : la signature n'y ajoute que le badge.Cheque Number(col 3) /CAD$(col 6) est bien luedebit_credit—{date:2, description:4, debitAmount:3, creditAmount:6}— et la ligne au chèque sort fausse. La justification porteuse de l'override tient.npm test: 1140 passés, 59 fichiers, chiffre du corps confirmé. Aucunskip/only.importFormat.tsne nomme niheader_signatureniheaderSignature(0 occurrence). La séparation du lien 3 tient.runAutoDetect(= 1,await detectFormatForFile(= 3 (3 sites réels :selectSource,parseAndPreview,autoDetectConfig),CSV_FIXTURE_NAMES= 15. Aucun affaibli.executeImportlitstate.sourceConfig, donc le format adopté est bien persisté et le format conservé reste intact ; le score et le badge sont mis ànullavant le panneau, rien ne cautionne un format non mesuré.fr.json/en.jsonparfaite (0 clé orpheline dans les deux sens), 10 clésdriftde chaque côté, nom de banque interpolé. Aucune migration ni fichiersrc-tauritouché. SQL paramétré.Ce qui bloque tient à une seule racine : l'empreinte Desjardins est faite de 4 mots génériques, et une signature qui matche obtient aussi le droit de passer devant le scan sparse-complementary.
Blocages
1.
src/utils/bankSignatures.ts:107— l'empreinte Desjardins réclame n'importe quelDate;Description;Montant;Solde, etMIN_SIGNATURE_LABELS = 4ne l'en empêche pas.Mesuré. Ce fichier, qui n'a rien de Desjardins, sort
bank = "desjardins"et affiche « Format Desjardins reconnu — 4 des 4 lignes lues » :Idem en virgules (
delimiters: [";", ","]), idem sur la variante anglaiseDate;Description;Amount;Balance, idem sur un fichier oùMontantest en texte (la config retombe correctement sur le générique — mais le badge, lui, reste).C'est exactement ce que le commentaire de
MIN_SIGNATURE_LABELS(bankSignatures.ts:86) déclare inacceptable : « poser "Format Desjardins reconnu" sur des fichiers que personne ne peut attribuer à Desjardins. Annoncer la mauvaise banque est pire que n'en annoncer aucune. » Le seuil de 4 ne protège pas, parce que la variante Desjardins est composée des 4 étiquettes les plus banales d'un relevé québécois —signed-amount.csvdu corpus en est à une colonne près. Les trois autres banques ne posent pas le problème : 6, 6 et 5 étiquettes, avecchequenumber,categorie,memopour les rendre distinctives. Desjardins est la seule entrée sans un seul mot discriminant, et c'est la première de la table donc la première essayée.Un utilisateur de la Banque Nationale, de Tangerine FR ou d'un export fait main se fera annoncer une banque qui n'est pas la sienne, dès le premier import.
2.
src/utils/csvAutoDetect.ts:790-802— combiné au point 1, l'override du mode montant peut rendre la lecture PIRE que le chemin générique.Le corps affirme « Failing degrades to the generic path; it never breaks. » Contre-exemple mesuré :
debit_creditsur{debitAmount:2, creditAmount:3}→ lecture correcte.detectAmountModeprend la main sur le scan sparse-complementary et imposeamount = Montant(col 4, magnitudes non signées) →amountMode: "single",signConvention: "positive_expense". Toute la paie et tout virement reçu entrent en dépense.Le garde-fou
amountCandidates.includes(amount)ne mord pas ici :Montantest un candidat parfaitement valide, il est juste le mauvais. L'étape 7b ne rattrape pas non plus — les voisins de la colonne montant sontDescriptionetSolde, aucun jeton D/C.La forme est étroite, je l'assume — mais c'est un contre-exemple à l'invariant central de la PR, dans la classe de panne exacte que le chantier existe pour supprimer (montants lus à l'envers, score à 100 %, aucun signal). L'override a été justifié par un cas précis (
CAD$face àCheque Number) ; il est en pratique accordé à toute signature, y compris à celle dont l'empreinte ne prouve rien.Piste de correction pour les deux — deux gestes indépendants, chacun tue le cas de données à lui seul :
amountd'une signature déplacer une paire sparse-complementary que si cette paire contient la colonne montant déclarée. Le gain RBC est préservé (la paire est(chequenumber, cad), elle contientcad) ; le cas ci-dessus retombe sur le générique (la paire(debit, credit)ne contient pasmontant).desjardins-quoted, et elle est cohérente : mieux vaut aucun nom qu'un nom faux.Suggestions (non bloquantes)
-247.95est faux (corps de PR, message de commit, et commentairebankSignatures.test.tsdu cas RBC).mapRowcalcule|crédit| − |débit|(importFormat.ts:307-309), donc la ligne au chèque 241 sort à-234.05, pas-247.95. Le phénomène est confirmé, seul le montant cité est à corriger — dommage sur une PR dont l'argument est « chaque contrefactuel a été mesuré avant d'être affirmé ».["date","description","montant"]→+ "devise"en position 3 ouvre le panneau). Rien n'a bougé, la table du dessous est parfaitement lisible, et l'intro dit pourtant « Les colonnes mémorisées ne pointent peut-être plus sur les bonnes données ». Un ajout en queue qui ne déplace aucune colonne existante pourrait être signalé plus doucement, ou pas du tout.columnMovedinterpoleentry.previousIndex/currentIndexbruts, donc la 1re colonne s'affiche « colonne 0 ». Le corps de PR écrit « Montant : 3 → 4 », ce que le code ne produit pas.+1à l'affichage.RepairPathNoticeest inconditionnel dans l'aperçu (FilePreviewTable.tsx:131) : il s'affiche au tout premier import d'une source neuve, où il n'y a rien à réparer, et même quandonFlipSignsest absent. Sa propre docstring dit « à côté du bouton d'inversion » — un{onFlipSigns && …}respecterait l'intention et éviterait un bloc de bruit sur le chemin le plus fréquent.parseFilesInternalécraseheadersà chaque fichier, donc la dérive est mesurée sur l'en-tête du dernier fichier, alors que la re-détection tourne surstate.selectedFiles[0](useImportWizard.ts:759). Cohérent avec ce queexecuteImportmémorise, mais les deux moitiés du panneau décrivent alors deux fichiers différents.detectFormatForFiledispatcheSET_ERRORquand elle échoue ou refuse, etparseAndPreviewcontinue jusqu'àfile-preview. L'utilisateur atterrit sur un aperçu correct surmonté d'un bandeau « détection échouée » — ou pire, du messageabsoluteIndicatorFormatqui parle d'un format que ce fichier n'utilise pas sous la config en cours. Le panneau dit déjà « La détection n'a pas su lire cette nouvelle forme » ; le bandeau fait doublon et contredit ce qui est à l'écran.Base
issue-329-mandatory-preview(ce19efd), tipc9872fc. Aucun fichier du worktree modifié pendant la revue.Blocage levé — corrigé sur la tête de pile
Deux correctifs : une variante faite uniquement de libellés génériques doit décrire l'en-tête exactement (Desjardins ne peut plus revendiquer un fichier plus riche) ; et le scan sparse-complementary est évalué avant la signature, dont la colonne de montant ne gagne que si la paire la contient — ce dont le cas RBC a besoin, et qui passe toujours.
Le correctif est le commit
37b832esurissue-332-docs-adr-changelog, la tête de la pile, plutôt que sur cette branche : la chaîne se merge en--ff-onlyd'un seul tenant, et corriger ici aurait imposé un rebase en cascade de toute la descendance. L'attribution reste lisible — le commit porteRefs #330.Vérifié par mutation : annuler le correctif fait rougir un test dédié. Un garde qui ne peut pas échouer ne garde rien.
Suite complète verte : 1181 vitest, build tsc + vite,
cargo check.Mergée dans
mainen fast-forward avec le reste de la pile (tip37b832e).Forgejo ne détecte pas un merge local comme merged — la PR est donc fermée à la main, et l'issue liée s'est fermée automatiquement via son
Resolves #N.Tip cumulé validé avant push : 1181 vitest, 111 tests Rust, build tsc + vite. La CI ne tourne pas sur push
main, cette validation locale était donc le seul filet.Pull request closed