99def _walk_files(root: Path, temp_dir_names: frozenset[str]) -> list[Path]:
100 """Return all non-noise files under ``root`` (fail-closed on read errors)."""
102 def _on_error(error: OSError) ->
None:
103 """Convert an ``os.walk`` read error into a fail-closed scan error."""
105 f
"cannot enumerate source tree at {getattr(error, 'filename', root)!r}",
106 path=str(getattr(error,
"filename", root)
or root),
108 hint=
"check directory permissions and symlink targets under source/",
111 if not root.exists():
113 f
"source tree does not exist: {root}",
115 os_error=
"FileNotFoundError",
116 hint=
"create oc_chat/roadmap/source/ or pass the correct root",
118 files: list[Path] = []
119 for dirpath, dirnames, filenames
in os.walk(root, onerror=_on_error):
120 dirnames[:] = [d
for d
in dirnames
if d
not in temp_dir_names]
121 for filename
in filenames:
124 files.append(Path(dirpath) / filename)
128def _classify(root: Path, path: Path, phase_stems: frozenset[str]) -> ScannedRecord:
129 """Classify one file against the §3.1 allowlist + ``phases/`` path-shape rule."""
130 parts = path.relative_to(root).parts
131 rel =
"/".join(parts)
133 stem = name[:-3]
if name.endswith(
".md")
else name
135 def unknown() -> SourceValidationError:
136 """Build the SRC_UNKNOWN_FILE error for this path."""
138 f
"unknown file under source/: {rel}",
139 code=SRC_UNKNOWN_FILE,
141 hint=
"only the §3.1 allowlist paths are recognised under source/",
144 if not name.endswith(
".md"):
148 if name ==
"manifest.md":
149 return ScannedRecord(kind=
"manifest", path=path, rel_path=rel, stem=
"manifest")
153 if top ==
"eras" and len(parts) == 2:
154 return ScannedRecord(kind=
"era", path=path, rel_path=rel, stem=stem)
155 if top ==
"tracks" and len(parts) == 2:
156 return ScannedRecord(kind=
"track", path=path, rel_path=rel, stem=stem)
157 if top ==
"notes" and len(parts) == 2:
161 return ScannedRecord(kind=
"note", path=path, rel_path=rel, stem=stem)
162 if top ==
"items" and len(parts) == 3
and parts[1]
in ITEM_TRACK_KINDS:
164 kind=
"roadmap_item", path=path, rel_path=rel, stem=stem, track_kind=parts[1]
166 if top ==
"changelog" and len(parts) == 3
and parts[1]
in CHANGELOG_KINDS:
168 kind=
"version", path=path, rel_path=rel, stem=stem, changelog_kind=parts[1]
170 if top ==
"framing" and len(parts) == 2:
173 return ScannedRecord(kind=
"framing", path=path, rel_path=rel, stem=stem)
176 return ScannedRecord(kind=
"phase", path=path, rel_path=rel, stem=stem)
178 phase_code = parts[1]
179 if phase_code
not in phase_stems:
181 f
"promoted item {rel} has no parent phases/{phase_code}.md",
182 code=SRC_BAD_PHASE_PATH,
184 hint=
"a promoted sub-item requires an existing phases/<code>.md",
187 kind=
"promoted_item",
191 phase_code=phase_code,
192 track_kind=
"m_phase",
195 f
"illegal phases/ path shape (too deep): {rel}",
196 code=SRC_BAD_PHASE_PATH,
198 hint=
"phases/ admits only <id>.md or <code>/<sub_id>.md",
209 """Plan 142j R7 (VC-0007, layer 1): reject a **per-directory** casefold stem
210 collision over every discovered file BEFORE loading records (§3.2).
212 ``canonical.load_record`` is per-file and cannot see cross-file collisions;
213 only the scanner walks directories, so the physical stem-vs-stem check is
214 owned here. A case-only-different pair (e.g. ``Foo.md``/``foo.md`` in one dir)
215 breaks every case-insensitive NTFS/APFS checkout ⇒ ``SRC_DUP_OR_STEM_MISMATCH``.
216 The literal ``README.md`` is excluded (not an entity record).
218 by_dir: dict[str, dict[str, list[str]]] = {}
220 if path.name == _CASEFOLD_EXCLUDE:
222 rel = path.relative_to(root)
223 parent = rel.parent.as_posix()
224 stem = path.name[:-3]
if path.name.endswith(
".md")
else path.name
225 by_dir.setdefault(parent, {}).setdefault(stem.casefold(), []).append(rel.as_posix())
226 for _parent, stems
in sorted(by_dir.items()):
227 for _key, paths
in sorted(stems.items()):
230 f
"case-insensitive stem collision in one directory: {sorted(paths)}",
231 code=SRC_DUP_OR_STEM_MISMATCH,
232 records=sorted(paths),
233 hint=
"ids/slugs are unique case-insensitively per directory (§3.2); "
234 "rename so no two files share a stem modulo case",
239 root: Path, *, temp_dir_names: frozenset[str] = frozenset()
241 """All physically-observed non-noise files under ``root`` (sorted).
243 Used by the raw-source-tree-hash preimage (§10), which includes files that
244 later fail closed-world validation — so it walks independently of
245 classification and is computable before any parse.
250 _walk_files(root, temp_dir_names), key=
lambda p: p.relative_to(root).parts
255 root: Path, *, temp_dir_names: frozenset[str] = frozenset()
256) -> list[ScannedRecord]:
257 """Scan ``root`` and return the classified records (sorted by POSIX path).
259 Raises ``SourceScanError`` if the tree cannot be enumerated, or
260 ``SourceValidationError`` (``SRC_UNKNOWN_FILE`` / ``SRC_BAD_PHASE_PATH``) on
261 the first offending path in sorted order.
264 func =
"scan_source_tree"
266 files = sorted(
_walk_files(root, temp_dir_names), key=
lambda p: p.relative_to(root).parts)
268 phase_stems = frozenset(
271 if path.name.endswith(
".md")
and path.relative_to(root).parts[:1] == (
"phases",)
272 and len(path.relative_to(root).parts) == 2
274 records = [
_classify(root, path, phase_stems)
for path
in files]
275 _dbg(
"OC_CHAT.MAIN", func, f
"classified {len(records)} records", 3)