oc_chat
Loading...
Searching...
No Matches
scanner.py
Go to the documentation of this file.
1#!/usr/bin/env python3
2# -*- coding: utf-8 -*-
3# oc_chat/registry/source/scanner.py
4
5# OCT-LINT: disable=tests_exist reason="Covered by oc_chat/tests/registry/source/test_scanner.py"
6"""
7Closed-world scanner for the AuthoredSource tree.
8
9Purpose
10-------
11Enumerate ``oc_chat/roadmap/source/**`` and classify every file against the
12Registry Source & Storage Spec V0.22 §3.1 allowlist, hard-failing any unknown
13path and enforcing the ``phases/`` path-shape rule, while silently skipping a
14hardcoded OS/editor-noise set independent of ``.gitignore``.
15
16Responsibilities
17----------------
18- Walk the tree fail-closed (``SourceScanError`` on unreadable trees).
19- Classify each recognised file into a typed ``ScannedRecord`` (entity kind +
20 resolution metadata: ``track_kind``, promoted ``phase_code``, changelog kind).
21- Enforce the two legal ``phases/`` shapes (depth-1 phase, depth-2 promoted
22 item under an existing ``phases/<code>.md``); reject every other shape.
23- Skip ``.DS_Store``/``Thumbs.db``/``*.swp``/``*~``/``*.tmp`` silently.
24
25Diagnostics
26-----------
27Domain: OC_CHAT.MAIN
28Levels:
29 L2 - scan lifecycle
30 L3 - per-file classification
31 L4 - phases path-shape resolution
32
33Contracts
34---------
35- Read-only: never mutates the source tree.
36- Output is deterministic — records sorted by POSIX relative path.
37- Unknown/illegal paths raise ``SourceValidationError`` with the offending path;
38 unreadable trees raise ``SourceScanError`` (``raw_source_tree_hash`` → null).
39
40Dependencies
41------------
42- oc_diagnostics (structured debug logging via _dbg)
43- oc_chat.registry.source.errors (SRC_* codes + exception types)
44"""
45from __future__ import annotations
46
47from dataclasses import dataclass, field
48import fnmatch
49import os
50from pathlib import Path
51from typing import Optional
52
53from oc_diagnostics import _dbg
54
56 SRC_BAD_PHASE_PATH,
57 SRC_DUP_OR_STEM_MISMATCH,
58 SRC_UNKNOWN_FILE,
59 SourceScanError,
60 SourceValidationError,
61)
62
63_dbg("OC_CHAT.MAIN", "module", "loaded", 4)
64
65# Hardcoded OS/editor-noise set (§3.1): skipped silently, independent of
66# ``.gitignore``. Exact names + glob patterns.
67NOISE_EXACT: frozenset[str] = frozenset({".DS_Store", "Thumbs.db"})
68NOISE_GLOBS: tuple[str, ...] = ("*.swp", "*~", "*.tmp")
69
70# The non-phase ``items/<track_kind>/`` subdirectories (§3.1).
71ITEM_TRACK_KINDS: frozenset[str] = frozenset(
72 {"backlog", "vision", "git_workflow", "pre_m0_era"}
73)
74# The ``changelog/<kind>/`` subdirectories (§3.1).
75CHANGELOG_KINDS: frozenset[str] = frozenset({"roadmap", "product"})
76
77
78@dataclass(frozen=True)
80 """A recognised source file + the resolution metadata the loader needs."""
81
82 kind: str # manifest | era | track | phase | promoted_item | roadmap_item | version | framing | note
83 path: Path
84 rel_path: str # POSIX relative path from the source root
85 stem: str # filename stem (validated == frontmatter id by the loader)
86 track_kind: Optional[str] = None # for roadmap_item / promoted_item
87 phase_code: Optional[str] = None # for promoted_item (immediate parent dir)
88 changelog_kind: Optional[str] = None # for version (routing subdir)
89 extra: dict = field(default_factory=dict)
90
91
92def _is_noise(name: str) -> bool:
93 """True if ``name`` is in the hardcoded OS/editor-noise set (§3.1)."""
94 if name in NOISE_EXACT:
95 return True
96 return any(fnmatch.fnmatch(name, pattern) for pattern in NOISE_GLOBS)
97
98
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)."""
101
102 def _on_error(error: OSError) -> None:
103 """Convert an ``os.walk`` read error into a fail-closed scan error."""
104 raise SourceScanError(
105 f"cannot enumerate source tree at {getattr(error, 'filename', root)!r}",
106 path=str(getattr(error, "filename", root) or root),
107 os_error=str(error),
108 hint="check directory permissions and symlink targets under source/",
109 )
110
111 if not root.exists():
112 raise SourceScanError(
113 f"source tree does not exist: {root}",
114 path=str(root),
115 os_error="FileNotFoundError",
116 hint="create oc_chat/roadmap/source/ or pass the correct root",
117 )
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:
122 if _is_noise(filename):
123 continue
124 files.append(Path(dirpath) / filename)
125 return files
126
127
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)
132 name = parts[-1]
133 stem = name[:-3] if name.endswith(".md") else name
134
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,
140 records=[rel],
141 hint="only the §3.1 allowlist paths are recognised under source/",
142 )
143
144 if not name.endswith(".md"):
145 raise unknown()
146
147 if len(parts) == 1:
148 if name == "manifest.md":
149 return ScannedRecord(kind="manifest", path=path, rel_path=rel, stem="manifest")
150 raise unknown()
151
152 top = parts[0]
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:
158 # Plan 142j R7: only ``notes/<id>.md`` is legal (§3.1); a deeper
159 # ``notes/<sub>/<id>.md`` falls through to SRC_UNKNOWN_FILE (mirrors the
160 # len(parts) guards on tracks/items).
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:
163 return ScannedRecord(
164 kind="roadmap_item", path=path, rel_path=rel, stem=stem, track_kind=parts[1]
165 )
166 if top == "changelog" and len(parts) == 3 and parts[1] in CHANGELOG_KINDS:
167 return ScannedRecord(
168 kind="version", path=path, rel_path=rel, stem=stem, changelog_kind=parts[1]
169 )
170 if top == "framing" and len(parts) == 2:
171 # [NEW·V24] content-bearing framing (labeled divider / section header),
172 # §3.1 allowlist ``framing/<id>.md``; stem == frontmatter id (loader).
173 return ScannedRecord(kind="framing", path=path, rel_path=rel, stem=stem)
174 if top == "phases":
175 if len(parts) == 2:
176 return ScannedRecord(kind="phase", path=path, rel_path=rel, stem=stem)
177 if len(parts) == 3:
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,
183 records=[rel],
184 hint="a promoted sub-item requires an existing phases/<code>.md",
185 )
186 return ScannedRecord(
187 kind="promoted_item",
188 path=path,
189 rel_path=rel,
190 stem=stem,
191 phase_code=phase_code,
192 track_kind="m_phase",
193 )
195 f"illegal phases/ path shape (too deep): {rel}",
196 code=SRC_BAD_PHASE_PATH,
197 records=[rel],
198 hint="phases/ admits only <id>.md or <code>/<sub_id>.md",
199 )
200 raise unknown()
201
202
203#: The literal allowlist-instruction file excluded from the casefold stem index
204#: (DeepSeek-R12-NP2-2) — it is not an entity record.
205_CASEFOLD_EXCLUDE = "README.md"
206
207
208def _check_casefold_stems(root: Path, files: list[Path]) -> None:
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).
211
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).
217 """
218 by_dir: dict[str, dict[str, list[str]]] = {}
219 for path in files:
220 if path.name == _CASEFOLD_EXCLUDE:
221 continue
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()):
228 if len(paths) > 1:
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",
235 )
236
237
239 root: Path, *, temp_dir_names: frozenset[str] = frozenset()
240) -> list[Path]:
241 """All physically-observed non-noise files under ``root`` (sorted).
242
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.
246 """
247
248 root = Path(root)
249 return sorted(
250 _walk_files(root, temp_dir_names), key=lambda p: p.relative_to(root).parts
251 )
252
253
255 root: Path, *, temp_dir_names: frozenset[str] = frozenset()
256) -> list[ScannedRecord]:
257 """Scan ``root`` and return the classified records (sorted by POSIX path).
258
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.
262 """
263
264 func = "scan_source_tree"
265 root = Path(root)
266 files = sorted(_walk_files(root, temp_dir_names), key=lambda p: p.relative_to(root).parts)
267 _check_casefold_stems(root, files) # R7 layer 1: per-directory casefold uniqueness
268 phase_stems = frozenset(
269 path.stem
270 for path in files
271 if path.name.endswith(".md") and path.relative_to(root).parts[:1] == ("phases",)
272 and len(path.relative_to(root).parts) == 2
273 )
274 records = [_classify(root, path, phase_stems) for path in files]
275 _dbg("OC_CHAT.MAIN", func, f"classified {len(records)} records", 3)
276 return records
list[ScannedRecord] scan_source_tree(Path root, *, frozenset[str] temp_dir_names=frozenset())
Definition scanner.py:256
None _check_casefold_stems(Path root, list[Path] files)
Definition scanner.py:208
list[Path] observed_files(Path root, *, frozenset[str] temp_dir_names=frozenset())
Definition scanner.py:240
ScannedRecord _classify(Path root, Path path, frozenset[str] phase_stems)
Definition scanner.py:128
list[Path] _walk_files(Path root, frozenset[str] temp_dir_names)
Definition scanner.py:99