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/__init__.py +9 -0
- codetour_cli/cli.py +2087 -0
- codetour_cli/config.py +144 -0
- codetour_cli/description_validator.py +197 -0
- codetour_cli/detection.py +91 -0
- codetour_cli/discovery.py +226 -0
- codetour_cli/lint.py +334 -0
- codetour_cli/logging/__init__.py +6 -0
- codetour_cli/logging/setup.py +63 -0
- codetour_cli/migration/__init__.py +6 -0
- codetour_cli/migration/direct.py +385 -0
- codetour_cli/migration/git_diff.py +418 -0
- codetour_cli/migration/stepwise.py +400 -0
- codetour_cli/review_parser.py +348 -0
- codetour_cli/schema_models.py +203 -0
- codetour_cli/tour/__init__.py +6 -0
- codetour_cli/tour/schema.py +255 -0
- codetour_cli/tour/updater.py +428 -0
- codetour_cli-0.1.0.dist-info/METADATA +47 -0
- codetour_cli-0.1.0.dist-info/RECORD +24 -0
- codetour_cli-0.1.0.dist-info/WHEEL +5 -0
- codetour_cli-0.1.0.dist-info/entry_points.txt +2 -0
- codetour_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- codetour_cli-0.1.0.dist-info/top_level.txt +1 -0
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,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)
|