codetour-cli 0.1.0__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
codetour_cli/lint.py ADDED
@@ -0,0 +1,334 @@
1
+ """
2
+ Step-level tour linting (ADR-0014).
3
+
4
+ Checks tours against filesystem ground truth — the algorithmic half of the
5
+ "algorithmic support to inference" duo: models author tours (inference),
6
+ this module verifies what they wrote (algorithm).
7
+
8
+ Boundary doctrine (ADR-0014): this linter never attempts `adr:...#qst-...`
9
+ handle/deeplink resolution — that belongs to the vscode crew's `tour-lint`,
10
+ which needs their parser/index. Step-level filesystem checks live here.
11
+
12
+ Pure functions over Tour models + a workspace path; no CLI dependency.
13
+ CLI wiring is deferred until json-source-edit's Phase 2b lands (ADR-0014,
14
+ shared-worktree discipline).
15
+ """
16
+
17
+ import json
18
+ import re
19
+ from dataclasses import dataclass
20
+ from pathlib import Path
21
+ from typing import Dict, List, Optional, Tuple
22
+
23
+ from pydantic import ValidationError
24
+
25
+ from .tour.schema import Tour
26
+
27
+ # ADR-0014 check-family defaults. Namespaced IDs (`schema/`, `step/`, `tour/`)
28
+ # are compatible with the Council-owned shared-taxonomy proposal (QST-TAXONOMY);
29
+ # `ref/*` is reserved for the vscode crew's tour-lint.
30
+ DEFAULT_SEVERITIES: Dict[str, str] = {
31
+ "schema/parse-error": "error",
32
+ "schema/invalid": "error",
33
+ "step/missing-file": "error",
34
+ "step/line-out-of-range": "error",
35
+ "step/pattern-invalid": "error",
36
+ "step/pattern-no-match": "error",
37
+ "step/pattern-ambiguous": "warn",
38
+ "tour/step-title-missing": "warn",
39
+ "tour/line-not-pattern": "warn",
40
+ "tour/next-tour-missing": "error",
41
+ "tour/is-primary-multiple": "warn",
42
+ }
43
+
44
+
45
+ @dataclass
46
+ class Finding:
47
+ """One lint finding, addressable by check ID and location."""
48
+
49
+ check_id: str
50
+ severity: str
51
+ tour_file: str
52
+ message: str
53
+ step_index: Optional[int] = None # 0-based; None for tour-level findings
54
+
55
+ def to_dict(self) -> dict:
56
+ return {
57
+ "check_id": self.check_id,
58
+ "severity": self.severity,
59
+ "tour_file": self.tour_file,
60
+ "step_index": self.step_index,
61
+ "message": self.message,
62
+ }
63
+
64
+
65
+ def _severity(check_id: str, severities: Optional[Dict[str, str]]) -> str:
66
+ merged = dict(DEFAULT_SEVERITIES)
67
+ if severities:
68
+ merged.update(severities)
69
+ return merged.get(check_id, "warn")
70
+
71
+
72
+ def _finding(
73
+ check_id: str,
74
+ tour_file: Path,
75
+ message: str,
76
+ step_index: Optional[int] = None,
77
+ severities: Optional[Dict[str, str]] = None,
78
+ ) -> Finding:
79
+ return Finding(
80
+ check_id=check_id,
81
+ severity=_severity(check_id, severities),
82
+ tour_file=str(tour_file),
83
+ message=message,
84
+ step_index=step_index,
85
+ )
86
+
87
+
88
+ def _read_file_lines(path: Path) -> Optional[List[str]]:
89
+ """Read a workspace file's lines, byte-safe (no newline translation).
90
+
91
+ Returns None if the file is unreadable as UTF-8 text (binary etc.) —
92
+ callers treat that as "cannot check line/pattern", not as a finding.
93
+ """
94
+ try:
95
+ return path.read_bytes().decode("utf-8").splitlines()
96
+ except (OSError, UnicodeDecodeError):
97
+ return None
98
+
99
+
100
+ def load_tour(tour_path: Path) -> Tuple[Optional[Tour], List[Finding]]:
101
+ """Parse + schema-validate one tour file.
102
+
103
+ Returns (tour, findings); tour is None when parse/validation failed.
104
+ """
105
+ findings: List[Finding] = []
106
+ try:
107
+ raw = tour_path.read_bytes().decode("utf-8")
108
+ except (OSError, UnicodeDecodeError) as exc:
109
+ findings.append(
110
+ _finding("schema/parse-error", tour_path, f"unreadable as UTF-8 text: {exc}")
111
+ )
112
+ return None, findings
113
+
114
+ try:
115
+ data = json.loads(raw)
116
+ except json.JSONDecodeError as exc:
117
+ findings.append(
118
+ _finding(
119
+ "schema/parse-error",
120
+ tour_path,
121
+ f"invalid JSON at line {exc.lineno}, column {exc.colno}: {exc.msg}",
122
+ )
123
+ )
124
+ return None, findings
125
+
126
+ try:
127
+ tour = Tour.model_validate(data)
128
+ except ValidationError as exc:
129
+ first = exc.errors()[0]
130
+ loc = ".".join(str(p) for p in first.get("loc", ())) or "(root)"
131
+ findings.append(
132
+ _finding(
133
+ "schema/invalid",
134
+ tour_path,
135
+ f"does not validate against the Tour schema: {loc}: {first.get('msg')}",
136
+ )
137
+ )
138
+ return None, findings
139
+
140
+ return tour, findings
141
+
142
+
143
+ def lint_tour_file(
144
+ tour_path: Path,
145
+ workspace_root: Path,
146
+ severities: Optional[Dict[str, str]] = None,
147
+ ) -> Tuple[Optional[Tour], List[Finding]]:
148
+ """Lint one tour against the workspace. Returns (tour-or-None, findings)."""
149
+ tour, findings = load_tour(tour_path)
150
+ if tour is None:
151
+ return None, findings
152
+
153
+ long_lived = bool(tour.ref) or bool(tour.isPrimary)
154
+
155
+ for idx, step in enumerate(tour.steps):
156
+ # schema-level conflict the permissive model can't express
157
+ if step.file is not None and step.uri is not None:
158
+ findings.append(
159
+ _finding(
160
+ "schema/invalid",
161
+ tour_path,
162
+ "step sets both `file` and `uri` (mutually exclusive per the CodeTour schema)",
163
+ idx,
164
+ severities,
165
+ )
166
+ )
167
+
168
+ if step.title is None:
169
+ findings.append(
170
+ _finding(
171
+ "tour/step-title-missing",
172
+ tour_path,
173
+ "step has no `title` (headings inside `description` render oddly; set `title`)",
174
+ idx,
175
+ severities,
176
+ )
177
+ )
178
+
179
+ if step.directory is not None:
180
+ target_dir = workspace_root / step.directory
181
+ if not target_dir.is_dir():
182
+ findings.append(
183
+ _finding(
184
+ "step/missing-file",
185
+ tour_path,
186
+ f"directory does not exist in workspace: {step.directory}",
187
+ idx,
188
+ severities,
189
+ )
190
+ )
191
+ continue # directory steps carry no line/pattern checks
192
+
193
+ if step.uri is not None or step.file is None:
194
+ continue # uri steps and content steps: no filesystem ground truth to check
195
+
196
+ target = workspace_root / step.file
197
+ if not target.is_file():
198
+ findings.append(
199
+ _finding(
200
+ "step/missing-file",
201
+ tour_path,
202
+ f"file does not exist in workspace: {step.file}",
203
+ idx,
204
+ severities,
205
+ )
206
+ )
207
+ continue # no line/pattern checks against a missing file
208
+
209
+ lines = _read_file_lines(target)
210
+
211
+ if step.pattern is not None:
212
+ try:
213
+ compiled = re.compile(step.pattern)
214
+ except re.error as exc:
215
+ findings.append(
216
+ _finding(
217
+ "step/pattern-invalid",
218
+ tour_path,
219
+ f"pattern does not compile as a regex: {exc}",
220
+ idx,
221
+ severities,
222
+ )
223
+ )
224
+ compiled = None
225
+ if compiled is not None and lines is not None:
226
+ matches = sum(1 for line in lines if compiled.search(line))
227
+ if matches == 0:
228
+ findings.append(
229
+ _finding(
230
+ "step/pattern-no-match",
231
+ tour_path,
232
+ f"pattern matches no line in {step.file}: {step.pattern!r}",
233
+ idx,
234
+ severities,
235
+ )
236
+ )
237
+ elif matches > 1:
238
+ findings.append(
239
+ _finding(
240
+ "step/pattern-ambiguous",
241
+ tour_path,
242
+ f"pattern matches {matches} lines in {step.file} "
243
+ "(CodeTour navigates to the first match)",
244
+ idx,
245
+ severities,
246
+ )
247
+ )
248
+ elif step.line is not None:
249
+ if lines is not None and not (1 <= step.line <= len(lines)):
250
+ findings.append(
251
+ _finding(
252
+ "step/line-out-of-range",
253
+ tour_path,
254
+ f"line {step.line} is outside {step.file} (1..{len(lines)})",
255
+ idx,
256
+ severities,
257
+ )
258
+ )
259
+ if long_lived:
260
+ findings.append(
261
+ _finding(
262
+ "tour/line-not-pattern",
263
+ tour_path,
264
+ "long-lived tour (has `ref`/`isPrimary`) pins `line` without "
265
+ "`pattern` — fragile across code changes",
266
+ idx,
267
+ severities,
268
+ )
269
+ )
270
+
271
+ return tour, findings
272
+
273
+
274
+ def lint_collection(
275
+ tour_paths: List[Path],
276
+ workspace_root: Path,
277
+ severities: Optional[Dict[str, str]] = None,
278
+ ) -> List[Finding]:
279
+ """Lint a set of tours together, adding cross-tour checks.
280
+
281
+ `nextTour` is matched liberally against sibling tour *titles* and
282
+ *filenames* (both appear in the wild — Postel's rule, per the
283
+ methodology's parse-liberally doctrine).
284
+ """
285
+ findings: List[Finding] = []
286
+ parsed: List[Tuple[Path, Tour]] = []
287
+
288
+ for tour_path in tour_paths:
289
+ tour, file_findings = lint_tour_file(tour_path, workspace_root, severities)
290
+ findings.extend(file_findings)
291
+ if tour is not None:
292
+ parsed.append((tour_path, tour))
293
+
294
+ known_names = set()
295
+ for tour_path, tour in parsed:
296
+ known_names.add(tour.title)
297
+ known_names.add(tour_path.name)
298
+ known_names.add(tour_path.stem)
299
+
300
+ primaries = [(p, t) for p, t in parsed if t.isPrimary]
301
+ if len(primaries) > 1:
302
+ names = ", ".join(p.name for p, _ in primaries)
303
+ for tour_path, _ in primaries:
304
+ findings.append(
305
+ _finding(
306
+ "tour/is-primary-multiple",
307
+ tour_path,
308
+ f"{len(primaries)} tours set `isPrimary` ({names}); CodeTour expects one",
309
+ None,
310
+ severities,
311
+ )
312
+ )
313
+
314
+ for tour_path, tour in parsed:
315
+ if tour.nextTour and tour.nextTour not in known_names:
316
+ findings.append(
317
+ _finding(
318
+ "tour/next-tour-missing",
319
+ tour_path,
320
+ f"`nextTour` target not found among sibling tour titles/filenames: "
321
+ f"{tour.nextTour!r}",
322
+ None,
323
+ severities,
324
+ )
325
+ )
326
+
327
+ return findings
328
+
329
+
330
+ def has_errors(findings: List[Finding], strict: bool = False) -> bool:
331
+ """CI gate: True if any finding is at error level (or any finding, when strict)."""
332
+ if strict:
333
+ return bool(findings)
334
+ return any(f.severity == "error" for f in findings)
@@ -0,0 +1,6 @@
1
+ """
2
+ Logging module for migration operations.
3
+
4
+ Implements structured logging with structlog for detailed audit trails of
5
+ all migration decisions and operations.
6
+ """
@@ -0,0 +1,63 @@
1
+ """
2
+ Structured logging configuration using structlog.
3
+
4
+ Provides comprehensive logging for all migration operations with clear audit trails.
5
+ """
6
+
7
+ import logging
8
+ import sys
9
+ import structlog
10
+ from typing import Optional
11
+
12
+
13
+ def configure_logging(
14
+ level: str = "INFO",
15
+ use_colors: bool = True,
16
+ use_json: bool = False
17
+ ) -> None:
18
+ """
19
+ Configure structured logging for the application.
20
+
21
+ Args:
22
+ level: Logging level (DEBUG, INFO, WARNING, ERROR, CRITICAL)
23
+ use_colors: Whether to use colored output (for terminals)
24
+ use_json: Whether to output JSON format (for parsing)
25
+ """
26
+ processors = [
27
+ structlog.contextvars.merge_contextvars,
28
+ structlog.processors.add_log_level,
29
+ structlog.processors.StackInfoRenderer(),
30
+ structlog.dev.set_exc_info,
31
+ structlog.processors.TimeStamper(fmt="iso"),
32
+ ]
33
+
34
+ if use_json:
35
+ processors.append(structlog.processors.JSONRenderer())
36
+ else:
37
+ if use_colors:
38
+ processors.append(structlog.dev.ConsoleRenderer())
39
+ else:
40
+ processors.append(structlog.dev.ConsoleRenderer(colors=False))
41
+
42
+ structlog.configure(
43
+ processors=processors,
44
+ wrapper_class=structlog.make_filtering_bound_logger(
45
+ getattr(logging, level.upper(), logging.INFO)
46
+ ),
47
+ context_class=dict,
48
+ logger_factory=structlog.PrintLoggerFactory(file=sys.stderr),
49
+ cache_logger_on_first_use=True,
50
+ )
51
+
52
+
53
+ def get_logger(name: Optional[str] = None) -> structlog.BoundLogger:
54
+ """
55
+ Get a logger instance.
56
+
57
+ Args:
58
+ name: Optional logger name (typically module name)
59
+
60
+ Returns:
61
+ Configured structlog logger
62
+ """
63
+ return structlog.get_logger(name)
@@ -0,0 +1,6 @@
1
+ """
2
+ Migration module for tracking line changes across Git commits.
3
+
4
+ This module implements both direct (A→C) and step-by-step (A→B→C) migration
5
+ strategies for updating CodeTour line references.
6
+ """