Two Files, One Node Path

A node's path comes from where its FILE sits, not from what the file says. The package installer (PackageInstaller.NodePathForFile), GitSync (GitHubSyncService.ParseFile) and the file-system adapter all map a repo-relative file through NodeFileMapper.FromRelativePath: the extension is stripped, X/index.* folds onto X, and mesh paths compare case-insensitively. So each of these pairs is ONE node:

Files Node path
Hosting/StuckDetector.md + Hosting/StuckDetector.json Hosting/StuckDetector
A.md + A/index.json A
Foo.md + foo.cs Foo

Nothing refuses the pair at import. The bulk writer treats a duplicate path as last-writer-wins (StaticRepoImporter, seenInStage), and which file is written last depends on enumeration order — so one author's node silently replaces another's: no error, no log line, nothing to grep.

The rule the guard mirrors

.github/scripts/check-node-path-collisions.py applies the importers' own rule, file for file:

Every colliding group is reported with ALL its files, and an empty scan is red — a gate over nothing passes vacuously. The self-test proves each half of the rule can fail: every case asserts its own expected outcome (its banner counts the cases it actually ran), and each of eight mutants of the rule (no extension strip, no case fold, no index fold, no content filter, every .json a node, src/ scanned, the file-system pair admitted everywhere, the vacuity line removed) reds it.

Where it runs

The one pair that is merged on purpose

FileSystemStorageAdapter.MergeIndexMarkdownAsync reads X.json and lays X/index.md in as its content — the "JSON registry + index.md split". Only that adapter merges; the installer and GitSync do not. So the pair is admitted only with --filesystem-layout, only for a --tree, and only as exactly those two files — the same pair in a node repo is an ordinary collision.

Core's samples tree used it for one pair, ACME.json + ACME/index.md, and CI passed --filesystem-layout for that tree. ACME left core with the other product demos (see Carving Projects Out Of Core). Its new home in MeshWeaver.Plugins keeps ACME/index.md alone, because that repo's --root check refuses the pair. So core's samples tree is now checked WITHOUT the flag, and no tree in the fleet relies on the exemption. The flag stays in the script for a FileSystem-served tree that genuinely needs it.

Measured when the guard landed

Every caller's default branch plus core: 19,569 files, 16,353 node candidates. One group, core's samples/Graph/Data/ACME.json + ACME/index.md — the adapter's merged pair above. No collision in MeshWeaver.Plugins, .Education, .Reinsurance, .SocialMedia, .Manufacturing, .Crm or Memex.