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". Core's samples tree uses it (ACME.json + ACME/index.md), and stage-samples-gate.sh removes the index.md before a gate install for that reason. 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.

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.