codingest 0.2.16

codingest — standalone extraction of KGLite's code-tree component: parse polyglot codebases into kglite knowledge graphs (tree-sitter parsers, call/type/route edges, docs pass, multi-rev merge)
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
//! Feature-parity regression test for the `codingest` code-tree builder.
//!
//! History: KGLite's in-tree `kglite::code_tree` module was the authority
//! this crate was extracted from. While both existed, `corpus_parity` and
//! `rev_path_parity` built every input with BOTH builders and asserted the
//! graphs were byte-for-byte equivalent. KGLite deleted its in-tree builder
//! on 2026-07-16, so cross-builder comparison is no longer possible — those
//! two tests were removed. The authority they enforced was frozen (while the
//! builders were still verified-identical) into per-corpus SHA-256 golden
//! digests, and the surviving tests carry it forward:
//!
//!   * `golden_parity` builds each corpus `BUILDS_PER_CORPUS` times with
//!     `codingest` and asserts every build's canonical digest matches the
//!     frozen golden (see `tests/goldens/README.md`). Repeating the build is
//!     what makes this the project's **determinism** gate as well: hash
//!     iteration order is randomized per `HashMap` instance, so an
//!     order-dependent builder produces digests that disagree with each other
//!     (reported as NONDETERMINISM) or agree with each other but not the
//!     golden (reported as a behaviour change). This replaced a Makefile-only
//!     step that ran three builds of an *external sibling checkout* and pinned
//!     its exact edge count — a gate whose verdict depended on a repository
//!     this project does not own, which never ran in CI, and which skipped
//!     silently when the checkout was absent.
//!   * `rev_self_consistency` builds the same 2-commit tempdir repo twice with
//!     `codingest` and asserts the graphs are equivalent — INCLUDING the
//!     `revs` / `rev_fp` list properties stamped onto every node and edge — a
//!     determinism check for the multi-rev path (whose digest can't be frozen;
//!     fresh commit SHAs leak into `revs`).
//!
//! `assert_graphs_equiv` compares two graphs along every dimension:
//!   * identical node-type → count maps
//!   * identical edge-type → count maps
//!   * identical sets of (node_type, id)
//!   * identical per-node property maps (full sweep, nothing excluded)
//!   * identical per-edge property maps (full sweep, nothing excluded)
//!
//! Nothing is excluded from the property sweep: file-path properties are
//! stored relative to the project root (`builder/mod.rs`), so they match
//! between two builds of the same tree.

use kglite::api::{DirGraph, GraphRead, Value};
use sha2::{Digest, Sha256};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
use std::process::Command;

const CORPORA: &[&str] = &[
    "py_basic",
    "py_inheritance",
    "rust_xfile",
    "ts_callback",
    "cross_ts_py",
    "dup_minified_assets",
    "agc_basic",
    // Added 2026-08-01. Every other corpus is blind to TS/JS import
    // resolution — none contains a single TS `import` statement, so a change
    // to it could land with zero golden movement. Its golden is therefore
    // captured additively (see `tests/goldens/README.md`).
    "ts_monorepo",
    // Added 2026-08-01. No other corpus contains a `const` bound to a
    // function literal, a `function*` in any spelling, or a factory-wrapped
    // binding (`Effect.fn(…)(function*…)`), so depth-0 higher-order-function
    // bindings and the TS grammar vocabulary could be changed — or broken —
    // with zero golden movement. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "ts_hof_binding",
    // Added 2026-08-01. Nothing else in the corpus set declares anything
    // below the top level of a TS file — no nested named binding, no
    // `namespace`, no closure-scoped helper — so the entire scope walk
    // (D1/D2/D3/D4) could be changed, or deleted, with zero golden movement.
    // It pins the shapes that must become nodes *and* the ones that must not
    // (a binding under an anonymous callback, `arr.map(f)` at depth > 0, a
    // plain IIFE), the `#{line}` tie-break, and D3's same-file-only CALLS
    // participation via a cross-file caller that must resolve nothing. Its
    // golden is captured additively (see `tests/goldens/README.md`).
    "ts_closure_scope",
    // Added 2026-08-01. The four committed Python corpora contain nothing but
    // top-level `def`s and plain classes — not one nested definition between
    // them — so the Python scope walk (D1/D2/D3/D4) could be changed, or
    // deleted, with zero golden movement. It pins the shapes that must become
    // nodes (a decorator factory two levels deep, a closure factory, a nested
    // helper, a method-local, a function-local class's methods), the block
    // transparency and lambda rules that are Python's answer to D1 clause 5,
    // the `#{line}` tie-break on both the `if`/`else` and `try`/`except`
    // conditional-definition idioms, and D3 from both sides — a cross-file
    // caller that must resolve nothing against same-file CALLS, REFERENCES_FN
    // and DECORATES edges that must resolve. Its golden is captured additively
    // (see `tests/goldens/README.md`).
    "py_nested_defs",
    // Added 2026-08-01. Not one of the ten pre-existing corpora contains an
    // `.mdx`, an upper-cased `.MD`, or a `.txt` that must stay out — so the
    // docs pass's accepted-extension match could be widened (to `.txt`, the
    // tempting "helpful" change) or narrowed (dropping `.mdx`) with zero
    // golden movement. It pins the `.mdx` arm end to end — frontmatter
    // properties, headings, backtick MENTIONS, a doc→doc DOCUMENTS edge whose
    // target is an `.mdx` and whose source is a `.md`, and a doc→File edge —
    // plus the extension-stripped `:Doc` id (`README.MD` → `README`) and the
    // inertness of embedded JSX/ESM. `NOTES.txt` is markdown-shaped and must
    // contribute nothing. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "docs_mdx",
    // Added 2026-08-10. No other corpus contains two docs in one directory
    // whose names differ only by markup extension, so the concept-id collision
    // policy (`.mdx` > `.md` > `.rst`; the loser is dropped from doc-node
    // emission entirely) could be changed — or regress to the old silent
    // last-write-wins overwrite — with zero golden movement. It pins the
    // survivor's identity (the `.mdx` file's title, `file_path` and mentions),
    // the loser's total absence (a symbol and a link that exist ONLY in
    // `docs/guide.md` must contribute no edge), a link written against the
    // DROPPED spelling (`docs/guide.md`) still resolving to the surviving
    // `docs/guide` node, an uncolliding `.rst` surviving untouched, and a
    // mixed-case `Notes.MD` stripping to `Notes`. Its golden is captured
    // additively (see `tests/goldens/README.md`).
    "docs_ext_collide",
    // Added 2026-08-10. No other corpus registers the same route path from two
    // different files, so Route identity could collapse every methodless
    // `@app.route('/')` in a repo into one node — reporting whichever file the
    // sorted walk reached first as the source location of all of them — with
    // zero golden movement. It pins both sides of the registration model: two
    // files registering `/` are two Route nodes each carrying its own truthful
    // `file_path`/`line_number`, while two registrations of `/dup` inside one
    // file are one registration site with parallel HANDLES edges (the id
    // carries the declaring file, deliberately not the line). Its golden is
    // captured additively (see `tests/goldens/README.md`).
    "py_routes_dup",
    // Added 2026-08-10. The `lang_group` CALLS tier had NO golden coverage:
    // `cross_ts_py` shares no bare name between its TS and Python halves
    // (`createSession` vs `create_session`), and `dup_minified_assets`'s
    // `index.html` contains no `<script>` at all, so no corpus ever reached
    // tier 3 with candidates from two different language families — the whole
    // grouping could be changed, or deleted, with every digest staying green.
    // It pins the case that motivated declaring groups in the registry: an
    // HTML-embedded script (rescoped to `index.html:script_N.<name>`, a qname
    // whose punctuation looks Python-ish) calling a name defined BOTH in a
    // `.js` and in a `.py`, which must resolve to the JavaScript definition.
    // Its golden is captured additively (see `tests/goldens/README.md`).
    "html_js_lang_group",
    // Added 2026-08-15 (post-016 program B0). The four corpora below exist
    // because the import-resolution audit found the defect shapes invisible:
    // rust_xfile carries ONE `pub mod` and zero `use` statements, the Python
    // corpora carry only absolute happy-path imports, and C/C++ and Dart had
    // no files at all — so five verified resolver defects (mcp-servers report,
    // 2026-08-14) had no golden that could see a fix or a regression. Each
    // corpus pins the broken pre-fix behaviour first; the fix phase
    // regenerates its golden with the reason recorded, so the golden diff IS
    // the record of what the fix changed.
    "rust_import", // crate::/super::/self::, use-as aliases, scoped_use_list
    "py_import",   // relative imports, `import a, b`, aliased, from-pkg-import-sub, nested walk
    "cpp_include", // quoted #include vs <system>, same-dir + subdir resolution
    "dart_import", // package: URIs with directory structure; a/x.dart vs b/x.dart
    // Added 2026-08-15 with the R parser (post-016 E2). No other corpus
    // contains a single `.R`/`.r` file, so the whole language — both
    // assignment shapes (`<-`, `=`) and the `\(x)` lambda, S4
    // setClass/setGeneric/setMethod extraction, the `source()` path-import
    // chain A→B→C (`main.R` → `sub/helpers.R` → `sub/deep.R`, including a
    // file-relative `source("deep.R")` from inside `sub/`), the lowercase
    // `.r` extension registration, and the `library(tools)` collision bait
    // (a local `tools.R` that must attract NO edge from the package
    // reference) — could be changed, or deleted, with zero golden movement.
    // Its golden is captured additively (see `tests/goldens/README.md`).
    "r_basic",
    // Added 2026-08-15 (post-016 program E, Julia support). No other corpus
    // contains a `.jl` file, so the entire Julia parser — long-form and
    // short-form (`f(x) = …`) functions, a multi-dispatch pair that the
    // builder's overload pass must keep as two nodes, `module` blocks,
    // structs with fields and `<:` supertypes — could be changed, or deleted,
    // with zero golden movement. It also pins Julia's split import model from
    // both sides: an `include("…")` chain (Main.jl → geometry.jl →
    // shapes/circle.jl) that MUST form File→File edges through the path
    // route, and a `using Downloads` whose name collides with a never-included
    // src/Downloads.jl file bait that must form NO File→File edge — `using`
    // is namespace-shaped and julia is deliberately absent from the
    // file-anchored raw-walk allowlist. Its golden is captured additively
    // (see `tests/goldens/README.md`).
    "julia_basic",
    // Added 2026-08-22. Not one corpus contained a `.cs` file, so the entire
    // C# parser — and with it the ONE call tier that needs a namespace
    // import to fire — could be changed, or deleted, with every digest green.
    // It pins the using-alias fix from BOTH sides, because the pre-fix graph
    // was well-formed and wrong rather than empty: `using_directive` took its
    // first identifier child, so `using Log = MyApp.Logging;` recorded the
    // ALIAS as the imported namespace, and `src/Decoy/Log/Logger.cs` declares
    // a real `namespace Log` holding a real `Logger.Emit` for that mistake to
    // land on. Measured pre-fix on this corpus: `Service.cs` IMPORTS `Log`
    // and `Run` CALLS `Log.Logger.Emit` — a plausible edge onto the decoy, at
    // the same resolution and candidate count as the right one. Post-fix the
    // import lands on `MyApp.Logging` and the call on
    // `MyApp.Logging.Logger.Emit`, still through the `namespace_import` tier,
    // which no other corpus can reach: the tier requires a `.`/`::` after the
    // imported prefix, so PHP's `\`-separated namespaces can never exercise
    // it. `using System` must attract no edge, `Missing.Publish` must resolve
    // to nothing, and the corpus deliberately declares no method with a bare
    // user-defined return type — the second half of the same fix, pinned by
    // unit tests, because the pre-fix parser named such a method after its
    // return type and would have hidden the alias evidence behind a second
    // defect. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "csharp_using_alias",
    // Added 2026-08-22. Not one corpus contained a `.php` file, so PHP's
    // grouped `use` — `use App\Domain\{Billing\Invoice, Catalog\Product};` —
    // had no coverage: `extract_use_imports` never matched the
    // `namespace_use_group` body and recorded ONE import, the bare ancestor
    // `App\Domain`, dropping both members. That is the ancestor-edge family
    // the import walk's `min_end` bound exists to prevent, reintroduced at
    // extraction where the bound cannot see it. Measured pre-fix on this
    // corpus: two IMPORTS edges (`App\Models` and `App\Domain`); post-fix,
    // three (`App\Models`, `App\Domain\Billing`, `App\Domain\Catalog`). The
    // group members are deliberately SUB-NAMESPACE-qualified — a bare
    // `{User, Post}` group trims straight back to the ancestor module and
    // would pin nothing. The corpus also pins the `is_method` inversion from
    // the same commit: top-level `build_report` was stored `is_method=true`
    // and is now `false`, while the four class methods stay `true`. A
    // `use … as Logger` for a package that does not exist must attract no
    // edge, `compute_missing_total` must resolve to nothing, `User
    // implements Identified` pins the IMPLEMENTS edge, and `displayName`
    // — declared on both the interface and the class — pins the `lang_group`
    // fan-out to two candidates. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "php_group_use",
    // Added 2026-08-22. Not one corpus contained a `.java` file, so Java's
    // docstring extraction — `get_doc_comment`, the ONLY producer of a
    // `docstring` property in this parser — could be changed, or deleted,
    // with every digest green. It pins the property from both sides: eight
    // javadoc'd declarations (four types — an interface, an abstract class,
    // a concrete subclass, a final utility class — and one method on each)
    // carry a docstring, and `quiet`, the one method preceded by a plain
    // `//` line comment instead of a javadoc block, must carry NULL. The
    // same `//` line is the other half of the pin: a `// TODO:` inside
    // `AbstractGreeter` must appear in that FILE's `annotations`, which is a
    // different extraction path over the same comment vocabulary. It also
    // pins the namespace import walk's one-segment `min_end` bound with live
    // bait — `import com.acme.Formatter` must form NO edge even though
    // Module `com` exists in the graph (a deeper trim is what landed 252
    // measured false edges on gson's ancestor modules), while
    // `import com.example.util.Text` next to it must resolve. IMPLEMENTS and
    // EXTENDS are pinned across three files, and Java's short
    // `source_type` spelling with them. Its golden is captured additively
    // (see `tests/goldens/README.md`).
    "java_javadoc",
    // Added 2026-08-22. Not one corpus contained a `.go` file, so Go's
    // interface-method arm — `method_elem`, the node tree-sitter-go 0.25
    // renamed `method_spec` to, and the sole producer of interface-method
    // Functions and their HAS_METHOD edges — could be deleted and every
    // digest would stay green. The corpus pins that arm twice over, by two
    // independent detectors: the `Reader.Fetch`/`Reader.Reset` nodes and
    // their two HAS_METHOD edges, and the fan-out of `s.Fetch(…)` in
    // `main`, which must reach BOTH `Reader.Fetch` and `Memory.Fetch` as a
    // two-candidate `lang_group` resolution — if the arm dies, that
    // collapses silently into one `unique_name` edge onto the struct method.
    // `NewMemory` (cross-file, `unique_name`) and `normalize` (called from
    // the sibling file in the same package) pin the ordinary routes;
    // `fmt.Println`, `strings.TrimSpace` and `missingHelper` must resolve to
    // nothing. Two absences are deliberate. Go's implicit interface
    // conformance is unmodeled, so `Memory` satisfying `Reader` produces NO
    // IMPLEMENTS edge. And `import "demo/store"` — the ordinary shape, the
    // module path from `go.mod` plus the package directory — produces NO
    // File→Module IMPORTS edge: `go.mod` is not read (the manifest reader
    // takes only `pyproject.toml` and `Cargo.toml`), and a Go module path is
    // built package-name-FIRST (`store/store` for `store/*.go` in package
    // `store`), so no prefix of any real import specifier can ever match
    // one. Both absences are pinned so that a fix has to move this digest.
    // Its golden is captured additively (see `tests/goldens/README.md`).
    "go_interface",
    // Added 2026-08-22. Not one corpus contained a `.swift` file, so the
    // whole Swift parser was invisible to the golden net. It pins the
    // `is_method` inversion fixed alongside PHP's: top-level `trim` was
    // stored `is_method=true` (the parser's own comment asserted the
    // opposite of its code) and is now `false`, while the struct's and the
    // enum's methods stay `true`. `Package.swift` is REAL PARSED SOURCE
    // here, not an inert manifest — it pins the non-SwiftPM fallback module
    // path `swift_basic.Package`, derived from the corpus directory name,
    // and `import PackageDescription` inside it must attract no edge.
    // Swift is one of only two languages in the set with an implicit module
    // hierarchy, so the `Demo`/`Support` parents and their HAS_SUBMODULE
    // chains are pinned here too, together with `Greeter.swift`'s
    // `import Support` resolving onto the `Support` Module. Two absences are
    // deliberate and are what a future fix would move: `struct Greeter:
    // Greeting` produces NO IMPLEMENTS edge (the inheritance specifier is
    // unparsed, as the parser's header admits), and `Text.shout` is reached
    // by its terminal segment alone because Swift call extraction never
    // keeps the receiver. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "swift_basic",
    // Added 2026-08-22. `dart_import` is the only other Dart corpus and it
    // contains no `part` file at all, so Dart's `part of` handling — the one
    // place a file's module path comes from something other than its own
    // location on disk — had zero golden coverage and could be changed, or
    // broken, with every digest staying green. It pins the property the
    // feature exists for: a library split across `lib/collection.dart` and
    // two parts under `lib/src/` is ONE module, with all three files hanging
    // off `dart_part_of.lib.collection`. The pre-fix derivation kept only the
    // URI's stem (`{pkg}.{stem}`), so the parts landed in a phantom
    // `dart_part_of.collection` module that no file declared and the parent
    // library never joined; the `../collection.dart` spelling is what makes
    // the dropped directory segments visible. `fromA` calling the parent
    // file's `seed` pins that the cross-file CALLS edge still resolves once
    // the three files share a module. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "dart_part_of",
    // Added 2026-08-22. Every Rust `use` in every other corpus sits at file
    // level, so the scope a `use` is written in never mattered and the flat,
    // scope-less `FileInfo::imports` list could stay wrong indefinitely: a
    // `use super::…` inside an inline `mod` was popped against the FILE's
    // module, one level too shallow, and landed on the parent module's file.
    // `mod tests { use super::*; }` — the most common shape in Rust source —
    // is exactly that bug. The corpus pins all three arms of the re-anchoring
    // (`parsers::rust_lang::RustParser::rebase_inline_mod_use`): supers equal
    // to the inline depth resolve to the file itself and form NO edge (the
    // self-guard eats them), a surplus super still pops the file's real
    // parent and reaches `beta.rs`, and two nested inline levels cancel two
    // supers. `beta.rs`'s file-level `use crate::…` is the control that must
    // not move. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "rust_inline_mod",
    // Added 2026-08-22. The four committed Python corpora all put their
    // packages where the importing file can already see them, so the ONE
    // layout `pyproject.toml` made standard — the package under `src/`,
    // imported by its absolute installed name from a sibling `tests/` tree —
    // had no golden at all: `from pkg.util import helper` in `tests/`
    // resolved to nothing, and it could have kept resolving to nothing
    // forever with every digest green. It pins the evidence-gated src root
    // from both ends: the import DOES resolve (File→File and File→Module,
    // onto `src/pkg/util.py`), and the root is offered only because the file
    // set contains a `src/` directory — `py_import`, which has none, is the
    // control that must not move. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "py_src_layout",
    // Added 2026-08-22. `cpp_include`'s every `#include` sits at a file's top
    // level, so the single most common real-world header prologue —
    // `#ifdef __cplusplus` / `extern "C" {` / `#endif` — had no coverage, and
    // the includes inside it were silently dropped (measured on
    // DaveGamble/cJSON: 5 of 96 quoted includes missed, `cJSON_Utils.h` →
    // `cJSON.h` among them — the library's own core edge). It pins both ways
    // tree-sitter renders that prologue: `utils.h` closes the block, so the
    // body becomes a `linkage_specification` the C router had no arm for;
    // `decls_begin.h` leaves it open, so the rest of the file collapses into
    // one `ERROR` recovery subtree. It also pins what must NOT be extracted —
    // the `<vector.h>` angle include inside the same region (a REAL project
    // file, excluded by its delimiters) and `phantom.h`, named by an
    // include-SHAPED line with no `#`, which only a text scan of the ERROR
    // region could ever reach. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "cpp_extern_c",
    // Added 2026-08-22. No other corpus contains a leading-`/` web reference
    // at all, so the whole served-root question was invisible: a built site
    // under `dist/` is served with `dist/` as `/`, and `/_astro/app.css`
    // resolved against the project root only — never reaching
    // `dist/_astro/app.css`, which is right there in the graph. It pins the
    // new candidate AND its precedence: `/shared/reset.css`, which resolves
    // at the project root today, must keep resolving there, so the project
    // root is still tried first. Its golden is captured additively (see
    // `tests/goldens/README.md`).
    "web_served_root",
];

/// Independent builds of each corpus per `golden_parity` run.
///
/// One build proves the output still matches the frozen golden; repeating it
/// also proves the output does not depend on hash iteration order. The
/// original nondeterminism bug (randomized `HashMap` iteration over DEFINES
/// (source_type, target_type) pairs — whichever pair went first got
/// skip-existence-check dedup semantics) is reproduced by the
/// `dup_minified_assets` corpus, which defines the same selector/element id
/// from one file more than once. Three builds is enough to make an
/// order-dependent builder fail loudly and quickly; the digest comparison
/// against the golden catches the residual case where all three happen to
/// agree on a wrong order.
const BUILDS_PER_CORPUS: usize = 3;

fn corpus_root() -> PathBuf {
    // tests/parity.rs lives in crates/code-tree; corpus is at the workspace root.
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("..")
        .join("..")
        .join("tests")
        .join("corpus")
}

/// Deterministic canonical string for a property value. `{:?}` on `Value`
/// is stable (enums, not maps) and captures scalars and nested lists alike.
fn canon(v: &Value) -> String {
    format!("{v:?}")
}

/// node_type -> count.
fn node_type_counts(g: &DirGraph) -> BTreeMap<String, usize> {
    let mut m = BTreeMap::new();
    for idx in g.graph.node_indices() {
        if let Some(n) = g.node_view(idx) {
            *m.entry(n.node_type_str(&g.interner).to_string())
                .or_insert(0) += 1;
        }
    }
    m
}

/// connection_type -> count.
fn edge_type_counts(g: &DirGraph) -> BTreeMap<String, usize> {
    let mut m = BTreeMap::new();
    for e in g.graph.edge_indices() {
        if let Some(edge) = g.graph.edge_weight(e) {
            *m.entry(edge.connection_type_str(&g.interner).to_string())
                .or_insert(0) += 1;
        }
    }
    m
}

/// Sorted (node_type, id) identity set.
fn node_identities(g: &DirGraph) -> Vec<(String, String)> {
    // Read through `node_view`, not the raw `node_weight`. kglite 0.16.0 made
    // every graph columnar from its first node and deleted the row layout, so a
    // node's own struct no longer carries its id — `NodeData::id()` yields the
    // `Null` sentinel and this digest silently became a list of "NULL". The
    // view resolves the sentinel through the type's column store.
    let mut v: Vec<(String, String)> = g
        .graph
        .node_indices()
        .filter_map(|i| g.node_view(i))
        .map(|n| (n.node_type_str(&g.interner).to_string(), n.id().to_string()))
        .collect();
    v.sort();
    v
}

/// Full per-node property sweep: sorted list of
/// (node_type, id, sorted{prop -> canon}). Nothing excluded.
fn node_props(g: &DirGraph) -> Vec<(String, String, BTreeMap<String, String>)> {
    let mut v: Vec<(String, String, BTreeMap<String, String>)> = g
        .graph
        .node_indices()
        .filter_map(|i| g.node_view(i))
        .map(|n| {
            let props: BTreeMap<String, String> = n
                .properties_cloned(&g.interner)
                .iter()
                .map(|(k, val)| (k.clone(), canon(val)))
                .collect();
            (
                n.node_type_str(&g.interner).to_string(),
                n.id().to_string(),
                props,
            )
        })
        .collect();
    v.sort();
    v
}

/// Full per-edge property sweep: sorted list of
/// (conn, src_id, tgt_id, sorted{prop -> canon}). Edges can share an
/// identity triple, so this is a multiset kept as a sorted Vec.
fn edge_props(g: &DirGraph) -> Vec<(String, String, String, BTreeMap<String, String>)> {
    let mut v: Vec<(String, String, String, BTreeMap<String, String>)> = g
        .graph
        .edge_indices()
        .filter_map(|e| {
            let edge = g.graph.edge_weight(e)?;
            let (s, t) = g.graph.edge_endpoints(e)?;
            // Endpoint ids come from the column store — see `node_identities`.
            let sn = g.node_view(s)?;
            let tn = g.node_view(t)?;
            let props: BTreeMap<String, String> = edge
                .properties_cloned(&g.interner)
                .iter()
                .map(|(k, val)| (k.clone(), canon(val)))
                .collect();
            Some((
                edge.connection_type_str(&g.interner).to_string(),
                sn.id().to_string(),
                tn.id().to_string(),
                props,
            ))
        })
        .collect();
    v.sort();
    v
}

// ── canonical rendering + golden digest ──────────────────────────────────
//
// The golden oracle renders a graph to a single deterministic string built
// from the *exact same* data the equivalence assertions sweep — node-type
// counts, edge-type counts, the sorted (node_type, id) identity set, the full
// per-node property sweep, and the full per-edge property sweep — then hashes
// it with SHA-256. Because every input is already sorted (BTreeMap / sorted
// Vec) and `canon` is `Value`'s stable `Debug` form, the rendering is
// reproducible across runs and machines. Nothing is excluded: whatever
// `assert_graphs_equiv` compares, the digest covers.

/// Deterministic, exhaustive canonical rendering of a graph. Two graphs render
/// to the same string iff `assert_graphs_equiv` would consider them equivalent.
fn canonical_graph_string(g: &DirGraph) -> String {
    let mut s = String::new();

    s.push_str("## node_type_counts\n");
    for (ty, n) in node_type_counts(g) {
        s.push_str(&format!("{ty}\t{n}\n"));
    }

    s.push_str("## edge_type_counts\n");
    for (ty, n) in edge_type_counts(g) {
        s.push_str(&format!("{ty}\t{n}\n"));
    }

    s.push_str("## node_identities\n");
    for (ty, id) in node_identities(g) {
        s.push_str(&format!("{ty}\t{id}\n"));
    }

    s.push_str("## node_props\n");
    for (ty, id, props) in node_props(g) {
        s.push_str(&format!("{ty}\t{id}\n"));
        for (k, v) in props {
            s.push_str(&format!("\t{k}={v}\n"));
        }
    }

    s.push_str("## edge_props\n");
    for (conn, src, tgt, props) in edge_props(g) {
        s.push_str(&format!("{conn}\t{src}\t{tgt}\n"));
        for (k, v) in props {
            s.push_str(&format!("\t{k}={v}\n"));
        }
    }

    s
}

/// SHA-256 (lowercase hex) of the canonical graph rendering — the golden digest.
fn graph_digest(g: &DirGraph) -> String {
    let mut hasher = Sha256::new();
    hasher.update(canonical_graph_string(g).as_bytes());
    let out = hasher.finalize();
    out.iter().map(|b| format!("{b:02x}")).collect()
}

fn goldens_dir() -> PathBuf {
    Path::new(env!("CARGO_MANIFEST_DIR"))
        .join("tests")
        .join("goldens")
}

/// Read the stored golden digest for a corpus (one hex line), trimmed.
fn read_golden(corpus: &str) -> String {
    let path = goldens_dir().join(format!("{corpus}.sha256"));
    std::fs::read_to_string(&path)
        .unwrap_or_else(|e| {
            panic!(
                "missing golden {} — capture with `cargo test -p codingest --test parity -- --ignored capture_goldens`: {e}",
                path.display()
            )
        })
        .trim()
        .to_string()
}

/// Assert the two graphs are equivalent along every dimension, with
/// pinpointed diffs on failure.
fn assert_graphs_equiv(label: &str, left: &DirGraph, right: &DirGraph) {
    // 1. node-type counts
    let (a, b) = (node_type_counts(left), node_type_counts(right));
    assert_eq!(
        a, b,
        "[{label}] node-type counts differ\nleft={a:?}\nright={b:?}"
    );

    // 2. edge-type counts
    let (a, b) = (edge_type_counts(left), edge_type_counts(right));
    assert_eq!(
        a, b,
        "[{label}] edge-type counts differ\nleft={a:?}\nright={b:?}"
    );

    // 3. node identity set
    let (a, b) = (node_identities(left), node_identities(right));
    if a != b {
        let only_in: Vec<_> = a.iter().filter(|x| !b.contains(x)).take(10).collect();
        let only_st: Vec<_> = b.iter().filter(|x| !a.contains(x)).take(10).collect();
        panic!(
            "[{label}] node identity sets differ\nonly left (<=10): {only_in:?}\nonly right (<=10): {only_st:?}"
        );
    }

    // 4. per-node property sweep
    let (a, b) = (node_props(left), node_props(right));
    if a != b {
        for (x, y) in a.iter().zip(b.iter()) {
            if x != y {
                panic!(
                    "[{label}] node properties differ\nfirst mismatch:\n  left=({}, {}) {:?}\n  right=({}, {}) {:?}",
                    x.0, x.1, x.2, y.0, y.1, y.2
                );
            }
        }
        panic!(
            "[{label}] node property lists differ in length: left={} right={}",
            a.len(),
            b.len()
        );
    }

    // 5. per-edge property sweep
    let (a, b) = (edge_props(left), edge_props(right));
    if a != b {
        for (x, y) in a.iter().zip(b.iter()) {
            if x != y {
                panic!(
                    "[{label}] edge properties differ\nfirst mismatch:\n  left=({}, {}->{}) {:?}\n  right=({}, {}->{}) {:?}",
                    x.0, x.1, x.2, x.3, y.0, y.1, y.2, y.3
                );
            }
        }
        panic!(
            "[{label}] edge property lists differ in length: left={} right={}",
            a.len(),
            b.len()
        );
    }
}

// ── golden parity (the authority carried forward past the in-tree deletion) ─
//
// Builds every corpus with ONLY the `codingest` builder, digests the result,
// and compares it to the frozen golden captured from the in-tree authority
// (see `tests/goldens/README.md`). This is the test that carries the
// authority forward now that KGLite has deleted its in-tree builder: it keeps
// proving that codingest reproduces the frozen graph byte-for-byte.
//
// It is also the determinism gate: each corpus is built `BUILDS_PER_CORPUS`
// times and every build must produce the same digest as every other *and* as
// the golden. The two failure modes are reported separately, because they call
// for opposite responses — a behaviour change may be legitimate and
// regenerated, while nondeterminism is always a bug.
#[test]
fn golden_parity() {
    let root = corpus_root();
    for name in CORPORA {
        let dir = root.join(name);
        assert!(dir.is_dir(), "missing corpus dir: {}", dir.display());

        let build = || {
            codingest::builder::run_with_options(&dir, false, true, None, None, true)
                .unwrap_or_else(|e| panic!("[{name}] codingest build failed: {e}"))
        };

        let first = build();
        let got = graph_digest(&first);

        // 1. Determinism: repeat builds must be identical to the first.
        for run in 2..=BUILDS_PER_CORPUS {
            let again = build();
            let again_digest = graph_digest(&again);
            if again_digest != got {
                // Pinpoint the divergence before reporting it.
                assert_graphs_equiv(
                    &format!("{name} nondeterminism run 1 vs {run}"),
                    &first,
                    &again,
                );
                panic!(
                    "[{name}] NONDETERMINISM: build 1 and build {run} of the same corpus \
                     produced different digests ({got} != {again_digest}) but compared equal \
                     dimension-by-dimension — widen the canonical rendering."
                );
            }
        }

        // 2. Frozen authority: the (now proven stable) digest must be the golden.
        let want = read_golden(name);
        assert_eq!(
            got, want,
            "[{name}] golden digest mismatch\n  golden (frozen authority) = {want}\n  codingest build           = {got}\n\
             All {BUILDS_PER_CORPUS} builds agreed, so this is a builder BEHAVIOUR change, not nondeterminism.\n\
             If it is deliberate, regenerate with\n\
             `cargo test -p codingest --test parity -- --ignored capture_goldens`."
        );
    }
}

/// Regeneration path for the corpus goldens. `#[ignore]` so it never runs in
/// the normal suite — invoke explicitly:
///
///   cargo test -p codingest --test parity -- --ignored capture_goldens
///
/// It writes `tests/goldens/<corpus>.sha256` for every corpus.
///
/// AUTHORITY NOTE: KGLite deleted its in-tree builder on 2026-07-16, so
/// regeneration now captures from the `codingest` builder — codingest is its
/// own oracle. The frozen digests it overwrites were originally captured (on
/// 2026-07-16) from the last in-sync in-tree authority, so only regenerate
/// when a deliberate builder-behavior change makes a golden legitimately
/// stale (see `tests/goldens/README.md`), never to paper over a red
/// `golden_parity`.
#[test]
#[ignore = "regeneration path; run explicitly with --ignored capture_goldens"]
fn capture_goldens() {
    let root = corpus_root();
    let out_dir = goldens_dir();
    std::fs::create_dir_all(&out_dir).expect("create goldens dir");
    for name in CORPORA {
        let dir = root.join(name);
        assert!(dir.is_dir(), "missing corpus dir: {}", dir.display());

        // Capture from the codingest builder — the authority, now that the
        // in-tree builder is gone.
        let g = codingest::builder::run_with_options(&dir, false, true, None, None, true)
            .unwrap_or_else(|e| panic!("[{name}] codingest build failed: {e}"));
        let digest = graph_digest(&g);
        let path = out_dir.join(format!("{name}.sha256"));
        std::fs::write(&path, format!("{digest}\n")).expect("write golden");
        eprintln!("captured {name} -> {digest}");
    }
}

// ── serialized-bytes determinism ─────────────────────────────────────────
//
// `golden_parity` digests the graph as it exists IN MEMORY, from sorted maps
// and sorted vectors. That rendering is structurally insulated from the order
// properties were inserted in — which is precisely the bug class that bit this
// project before: two builds with identical in-memory digests wrote `.kgl`
// files that differed byte-for-byte, because edge-property insertion order
// leaked into the serialization (fixed engine-side in kglite 0.14.5; the
// codingest-side assertion never existed). CALLS edges now carry three more
// properties each, so the exposure is larger, not smaller.
//
// This is the net the in-memory digest cannot provide: build the same corpus
// repeatedly, save each build, compare the FILES.
#[test]
fn reloaded_graph_renders_identically() {
    // The property kglite 0.16.0's headline change is about: a freshly built
    // graph and a reloaded one are "the same shape". Assert it at CONTENT
    // level — build → save `.kgl` (v6) → load → the canonical rendering of the
    // reloaded graph is byte-identical to the in-memory one. `kgl_bytes`
    // proves the WRITER is deterministic without ever loading a file; the cli
    // query tests reload without digesting. Until this test, "a reload loses
    // or rewrites values" was caught by nothing — and a reloaded graph is
    // exactly where the columnar sentinel lived on 0.15.x.
    let tmp = tempfile::Builder::new()
        .prefix("codingest-reload-digest-")
        .tempdir()
        .unwrap();
    let root = corpus_root();
    // Densest edge-property corpus + a docs corpus (Doc nodes carry the widest
    // string properties) + the sparse-column AGC shape.
    for name in ["ts_monorepo", "docs_mdx", "agc_basic"] {
        let dir = root.join(name);
        assert!(dir.is_dir(), "missing corpus dir: {}", dir.display());
        let dest = tmp.path().join(format!("{name}.kgl"));
        let built =
            codingest::builder::run_with_options(&dir, false, true, Some(&dest), None, true)
                .unwrap_or_else(|e| panic!("[{name}] build failed: {e}"));
        let reloaded = kglite::api::io::load_file(&dest.to_string_lossy())
            .unwrap_or_else(|e| panic!("[{name}] reload failed: {e}"));
        assert_eq!(
            canonical_graph_string(&built),
            canonical_graph_string(&reloaded),
            "[{name}] reloaded graph renders differently from the one saved"
        );
    }
}

#[test]
fn kgl_bytes_are_stable_across_builds() {
    let tmp = tempfile::Builder::new()
        .prefix("codingest-kgl-bytes-")
        .tempdir()
        .unwrap();
    let root = corpus_root();

    // `ts_monorepo` carries the densest multi-property CALLS edge set
    // (resolution + candidates + import_backed on every edge); `agc_basic`
    // exercises the sparse-column path, where the semantic pass leaves those
    // three null while populating raw_targets / via / address_lines.
    for name in ["ts_monorepo", "agc_basic"] {
        let dir = root.join(name);
        assert!(dir.is_dir(), "missing corpus dir: {}", dir.display());

        let mut bytes: Vec<Vec<u8>> = Vec::new();
        for run in 1..=BUILDS_PER_CORPUS {
            let dest = tmp.path().join(format!("{name}-{run}.kgl"));
            codingest::builder::run_with_options(&dir, false, true, Some(&dest), None, true)
                .unwrap_or_else(|e| panic!("[{name}] build {run} failed: {e}"));
            bytes.push(std::fs::read(&dest).expect("read written .kgl"));
        }

        for run in 2..=BUILDS_PER_CORPUS {
            let (first, again) = (&bytes[0], &bytes[run - 1]);
            if first == again {
                continue;
            }
            let at = first
                .iter()
                .zip(again.iter())
                .position(|(a, b)| a != b)
                .map(|i| i.to_string())
                .unwrap_or_else(|| "end (length differs)".into());
            panic!(
                "[{name}] .kgl BYTES differ between build 1 and build {run} \
                 ({} vs {} bytes, first difference at offset {at}). The in-memory \
                 parity digest cannot see this: it is built from sorted maps, so \
                 property INSERTION order is invisible to it while remaining \
                 visible in the serialized file.",
                first.len(),
                again.len()
            );
        }
    }
}

/// Diagnostic dump of every corpus's canonical rendering, one file per
/// corpus, into the directory named by `CODINGEST_CANONICAL_DUMP`.
///
/// This is the tool the golden-regeneration protocol runs on: when a change is
/// *supposed* to move only one section of the rendering (e.g. adding edge
/// properties must not touch `node_type_counts`, `edge_type_counts`,
/// `node_identities` or `node_props`), dumping before and after and diffing
/// the sections proves it. A digest tells you *that* something moved; only the
/// rendering tells you *what*, and blessing an edge-set change inside a
/// "properties-only" regeneration would hide it forever.
///
///   CODINGEST_CANONICAL_DUMP=/tmp/before \
///     cargo test -p codingest --test parity -- --ignored dump_canonical
#[test]
#[ignore = "diagnostic; run explicitly with --ignored dump_canonical"]
fn dump_canonical() {
    let out_dir = PathBuf::from(
        std::env::var("CODINGEST_CANONICAL_DUMP")
            .expect("set CODINGEST_CANONICAL_DUMP to the output directory"),
    );
    std::fs::create_dir_all(&out_dir).expect("create dump dir");
    let root = corpus_root();
    for name in CORPORA {
        let dir = root.join(name);
        let g = codingest::builder::run_with_options(&dir, false, true, None, None, true)
            .unwrap_or_else(|e| panic!("[{name}] codingest build failed: {e}"));
        let path = out_dir.join(format!("{name}.txt"));
        std::fs::write(&path, canonical_graph_string(&g)).expect("write dump");
        eprintln!("dumped {name} -> {}", path.display());
    }
}

// ── rev-path parity ──────────────────────────────────────────────────────

fn git_available() -> bool {
    Command::new("git")
        .arg("--version")
        .output()
        .map(|o| o.status.success())
        .unwrap_or(false)
}

fn git(dir: &Path, args: &[&str]) -> String {
    let out = Command::new("git")
        .arg("-C")
        .arg(dir)
        .args(args)
        .output()
        .expect("git spawn");
    assert!(
        out.status.success(),
        "git {args:?} failed: {}",
        String::from_utf8_lossy(&out.stderr)
    );
    String::from_utf8_lossy(&out.stdout).trim().to_string()
}

fn git_commit(dir: &Path, msg: &str) -> String {
    git(dir, &["add", "-A"]);
    git(
        dir,
        &[
            "-c",
            "user.email=t@t",
            "-c",
            "user.name=t",
            "commit",
            "-q",
            "-m",
            msg,
        ],
    );
    git(dir, &["rev-parse", "HEAD"])
}

/// Build a throwaway 2-commit python repo in `repo` and return the two commit
/// SHAs (oldest → newest). rev2 widens `foo`'s signature (a fingerprint
/// change), drops `gone`, adds `bar`, and makes `driver` call `bar` too.
fn build_two_commit_repo(repo: &Path) -> Vec<String> {
    git(repo, &["init", "-q"]);

    // rev1: a small python package with two functions and a caller.
    std::fs::create_dir_all(repo.join("pkg")).unwrap();
    std::fs::write(
        repo.join("pkg/mod_a.py"),
        "def foo(a):\n    return a + 1\n\n\ndef gone():\n    return 0\n",
    )
    .unwrap();
    std::fs::write(
        repo.join("pkg/mod_b.py"),
        "from pkg.mod_a import foo\n\n\ndef driver(x):\n    return foo(x)\n",
    )
    .unwrap();
    let s1 = git_commit(repo, "rev1");

    // rev2: widen foo's signature (fingerprint change), drop `gone`, add `bar`,
    // and make driver call the new bar too.
    std::fs::write(
        repo.join("pkg/mod_a.py"),
        "def foo(a, b):\n    return a + b\n\n\ndef bar(y):\n    return y * 2\n",
    )
    .unwrap();
    std::fs::write(
        repo.join("pkg/mod_b.py"),
        "from pkg.mod_a import foo, bar\n\n\ndef driver(x):\n    return foo(x, 1) + bar(x)\n",
    )
    .unwrap();
    let s2 = git_commit(repo, "rev2");

    vec![s1, s2]
}

fn assert_has_stamped_revs(g: &DirGraph) {
    let has_revs = g
        .graph
        .node_indices()
        .filter_map(|i| g.node_view(i))
        .any(|n| matches!(n.get_property_value("revs"), Some(Value::List(_))));
    assert!(
        has_revs,
        "expected stamped `revs` list properties on rev-merge graph"
    );
}

// ── rev self-consistency (the multi-rev survivor) ──────────────────────────
//
// The rev fixture can't be frozen into a golden (commit SHAs leak into the
// `revs` property, so its digest is unstable across from-scratch runs — see
// `tests/goldens/README.md`). Instead we prove the codingest builder is
// self-consistent: building the SAME two-commit repo twice (identical SHAs,
// since it's one repo) yields two equivalent graphs, INCLUDING the stamped
// `revs`/`rev_fp` provenance.
#[test]
fn rev_self_consistency() {
    if !git_available() {
        eprintln!("SKIP rev_self_consistency: git not available in test environment");
        return;
    }

    let tmp = tempfile::Builder::new()
        .prefix("codingest-rev-selfconsistency-")
        .tempdir()
        .unwrap();
    let repo = tmp.path();
    let revs = build_two_commit_repo(repo);

    let build = || {
        codingest::rev::build_code_tree_revs(repo, &revs, Some(repo), false, true, None, None, true)
            .expect("codingest build_code_tree_revs")
    };
    let first = build();
    let second = build();

    // Confirm the multi-rev provenance is actually present before comparing.
    assert_has_stamped_revs(&first);

    // Two builds of the same fixed rev input must be byte-for-byte equivalent.
    assert_graphs_equiv("rev-self-consistency", &first, &second);
}