whykit 0.3.1__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.
- whykit/__init__.py +21 -0
- whykit/adopt.py +776 -0
- whykit/backlinks.py +106 -0
- whykit/check.py +320 -0
- whykit/ci_formats.py +283 -0
- whykit/cli.py +1719 -0
- whykit/completion.py +193 -0
- whykit/config.py +233 -0
- whykit/console.py +217 -0
- whykit/context.py +204 -0
- whykit/contract.py +210 -0
- whykit/contract_schemas/backlinks-report.schema.json +61 -0
- whykit/contract_schemas/context-bundle.schema.json +27 -0
- whykit/contract_schemas/context-pack.schema.json +29 -0
- whykit/contract_schemas/impact-report.schema.json +122 -0
- whykit/contract_schemas/query-result.schema.json +105 -0
- whykit/contract_schemas/status-report.schema.json +176 -0
- whykit/contract_schemas/trace-report.schema.json +211 -0
- whykit/diff.py +823 -0
- whykit/evidence.py +178 -0
- whykit/explorer_index.py +554 -0
- whykit/graph.py +318 -0
- whykit/immutability.py +624 -0
- whykit/impact.py +272 -0
- whykit/init_layout.py +205 -0
- whykit/io.py +563 -0
- whykit/lint.py +2190 -0
- whykit/lsp.py +1241 -0
- whykit/markdown_code.py +107 -0
- whykit/mcp_server.py +2289 -0
- whykit/messages.py +46 -0
- whykit/pack.py +294 -0
- whykit/parse_cache.py +752 -0
- whykit/placeholders.py +84 -0
- whykit/promote.py +651 -0
- whykit/py.typed +0 -0
- whykit/query.py +174 -0
- whykit/review.py +527 -0
- whykit/rule_policy.py +1063 -0
- whykit/rules.py +274 -0
- whykit/scaffold.py +784 -0
- whykit/sensitivity.py +95 -0
- whykit/snapshot.py +319 -0
- whykit/status.py +167 -0
- whykit/tables.py +120 -0
- whykit/template/00-context/README.md +17 -0
- whykit/template/00-context/company.md +52 -0
- whykit/template/00-context/evidence-register.md +34 -0
- whykit/template/00-context/goals.md +38 -0
- whykit/template/00-context/review-log.md +22 -0
- whykit/template/00-context/terminology.md +34 -0
- whykit/template/01-strategy/README.md +7 -0
- whykit/template/02-discoverability/README.md +10 -0
- whykit/template/03-website/README.md +7 -0
- whykit/template/04-automation/README.md +10 -0
- whykit/template/05-operations/README.md +19 -0
- whykit/template/06-decisions/README.md +11 -0
- whykit/template/06-decisions/decision-log.md +38 -0
- whykit/template/07-research/README.md +10 -0
- whykit/template/AGENTS.md +163 -0
- whykit/template/Home.md +57 -0
- whykit/template/INTEROP.md +117 -0
- whykit/template/OBSIDIAN.md +92 -0
- whykit/template/README.md +51 -0
- whykit/template/assets/README.md +6 -0
- whykit/template/reports/README.md +7 -0
- whykit/template/templates/README.md +7 -0
- whykit/template/templates/automation-spec-template.md +70 -0
- whykit/template/templates/decision-record-template.md +65 -0
- whykit/template/templates/document-template.md +55 -0
- whykit/template/templates/experiment-template.md +62 -0
- whykit/template/templates/meeting-note-template.md +47 -0
- whykit/template/templates/page-brief-template.md +63 -0
- whykit/template/templates/research-note-template.md +53 -0
- whykit/template/templates/source-ingestion-template.md +43 -0
- whykit/template/whykit.toml +39 -0
- whykit/trace.py +272 -0
- whykit/vault_index.py +190 -0
- whykit-0.3.1.dist-info/METADATA +162 -0
- whykit-0.3.1.dist-info/RECORD +84 -0
- whykit-0.3.1.dist-info/WHEEL +4 -0
- whykit-0.3.1.dist-info/entry_points.txt +3 -0
- whykit-0.3.1.dist-info/licenses/LICENSE +201 -0
- whykit-0.3.1.dist-info/licenses/NOTICE +13 -0
whykit/adopt.py
ADDED
|
@@ -0,0 +1,776 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Bring an existing pile of Markdown into a WhyKit vault.
|
|
3
|
+
|
|
4
|
+
Nobody adopting this tool starts from nothing. They start from a docs directory,
|
|
5
|
+
a folder of ADRs, a Notion export, or four years of meeting notes. `init` serves
|
|
6
|
+
the empty case; this serves the real one.
|
|
7
|
+
|
|
8
|
+
The design rule comes straight from the vault's own AGENTS.md: *raw platform
|
|
9
|
+
exports are never canonical notes.* So `adopt` never writes into a workstream.
|
|
10
|
+
It stages files under `.import-staging/`, records what it saw — including a
|
|
11
|
+
SHA-256 for every source — and leaves the judgement calls to a human. What it
|
|
12
|
+
automates is the inventory, which is the boring half and the half people skip.
|
|
13
|
+
|
|
14
|
+
whykit adopt ../old-docs
|
|
15
|
+
whykit adopt ../old-docs --profile obsidian-loose --json
|
|
16
|
+
whykit adopt ../old-docs --write
|
|
17
|
+
"""
|
|
18
|
+
from __future__ import annotations
|
|
19
|
+
|
|
20
|
+
import argparse
|
|
21
|
+
import datetime as dt
|
|
22
|
+
import hashlib
|
|
23
|
+
import json
|
|
24
|
+
import os
|
|
25
|
+
import re
|
|
26
|
+
import secrets
|
|
27
|
+
import shutil
|
|
28
|
+
import unicodedata
|
|
29
|
+
from collections import Counter
|
|
30
|
+
from dataclasses import dataclass, field
|
|
31
|
+
from pathlib import Path
|
|
32
|
+
|
|
33
|
+
from .contract import emit_error
|
|
34
|
+
from .io import atomic_write_bytes, atomic_write_text, safe_vault_dir, safe_vault_target, vault_mutation_lock
|
|
35
|
+
from .lint import (
|
|
36
|
+
EVIDENCE_ID_RE, DECISION_ID_RE, WIKILINK_RE, _split_table_row,
|
|
37
|
+
Note, check_front_matter, find_vault_root, is_markdown_name, is_vault_root, iter_markdown, load_note,
|
|
38
|
+
)
|
|
39
|
+
from .console import as_printed, emit_machine
|
|
40
|
+
from .immutability import _review_log_parts
|
|
41
|
+
from .sensitivity import SENSITIVITY_LEVEL, UNREADABLE_LEVEL, classify_evidence_register, note_sensitivity_level
|
|
42
|
+
|
|
43
|
+
SKIP_DIRS = {
|
|
44
|
+
".git", ".obsidian", ".import-staging", "node_modules", "__pycache__",
|
|
45
|
+
".venv", "venv", "dist", "build", ".next", ".cache",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
# Michael Nygard's layout, MADR, adr-tools and log4brains all land in this shape.
|
|
49
|
+
ADR_FILENAME_RE = re.compile(r"^(?:adr[-_]?)?(\d{3,4})[-_]")
|
|
50
|
+
ADR_HEADING_RE = re.compile(r"(?mi)^#{1,3}\s*(status|context|decision|consequences)\b")
|
|
51
|
+
FRONT_MATTER_RE = re.compile(r"\A---\r?\n")
|
|
52
|
+
|
|
53
|
+
ASSESSMENTS = ("useful", "duplicate", "empty", "heading-only", "unsupported")
|
|
54
|
+
ADOPT_PROFILES = ("generic", "adr-only", "obsidian-loose")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
@dataclass
|
|
58
|
+
class Candidate:
|
|
59
|
+
path: Path
|
|
60
|
+
relative: str
|
|
61
|
+
sha256: str
|
|
62
|
+
bytes: int
|
|
63
|
+
words: int
|
|
64
|
+
assessment: str
|
|
65
|
+
has_front_matter: bool
|
|
66
|
+
looks_like_decision: bool
|
|
67
|
+
duplicate_of: str | None = None
|
|
68
|
+
whykit_ready: bool = False
|
|
69
|
+
decision_id: str | None = None
|
|
70
|
+
format_warnings: list[str] = field(default_factory=list)
|
|
71
|
+
|
|
72
|
+
def destination_for(self, profile: str) -> str:
|
|
73
|
+
if self.assessment != "useful":
|
|
74
|
+
return "—"
|
|
75
|
+
if self.looks_like_decision or profile == "adr-only":
|
|
76
|
+
return "06-decisions/ (assign D-NNN)"
|
|
77
|
+
if profile == "obsidian-loose":
|
|
78
|
+
return "notes/ (normalize front matter)"
|
|
79
|
+
return "notes/ or a workstream (human choice)"
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _looks_like_decision(name: str, text: str) -> bool:
|
|
83
|
+
if ADR_FILENAME_RE.match(name):
|
|
84
|
+
return True
|
|
85
|
+
headings = {m.group(1).lower() for m in ADR_HEADING_RE.finditer(text)}
|
|
86
|
+
return {"context", "decision"}.issubset(headings)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _whykit_ready(path: Path, source: Path, text: str, note: Note | None = None) -> bool:
|
|
90
|
+
"""Check front matter with the same parser and rules as `whykit lint`.
|
|
91
|
+
|
|
92
|
+
This does not certify links, evidence or the containing vault.
|
|
93
|
+
"""
|
|
94
|
+
note = note or load_note(path, text=text)
|
|
95
|
+
if not note.has_front:
|
|
96
|
+
return False
|
|
97
|
+
findings = []
|
|
98
|
+
try:
|
|
99
|
+
check_front_matter(source, note, findings, dt.date.today())
|
|
100
|
+
except (TypeError, ValueError):
|
|
101
|
+
# A malformed imported value must not crash a dry-run or score as ready.
|
|
102
|
+
return False
|
|
103
|
+
return not any(f.level == "error" for f in findings)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_warnings(relative: str, text: str) -> list[str]:
|
|
107
|
+
"""Point out known ledger-table layouts that `adopt` cannot normalize."""
|
|
108
|
+
name = Path(relative).name
|
|
109
|
+
warnings = []
|
|
110
|
+
if name not in {"evidence-register.md", "decision-log.md"}:
|
|
111
|
+
return warnings
|
|
112
|
+
for line in text.splitlines():
|
|
113
|
+
if not line.lstrip().startswith("|"):
|
|
114
|
+
continue
|
|
115
|
+
cells = _split_table_row(line)
|
|
116
|
+
if name == "evidence-register.md" and cells and EVIDENCE_ID_RE.fullmatch(cells[0]):
|
|
117
|
+
if len(cells) < 7:
|
|
118
|
+
warnings.append(f"{relative}: evidence row has {len(cells)} columns; WhyKit needs 7 (including Accessed and Location)")
|
|
119
|
+
break
|
|
120
|
+
elif name == "decision-log.md" and cells and DECISION_ID_RE.fullmatch(cells[0]):
|
|
121
|
+
if len(cells) < 6 or not WIKILINK_RE.search(cells[5]):
|
|
122
|
+
warnings.append(f"{relative}: decision row has no record wikilink in column 6; manual table mapping is required")
|
|
123
|
+
break
|
|
124
|
+
return warnings
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def _has_front_matter(text: str) -> bool:
|
|
128
|
+
# A UTF-8 byte-order mark is not content; the linter skips it too.
|
|
129
|
+
return bool(FRONT_MATTER_RE.match(text.removeprefix("\ufeff")))
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
HEADING_LINE_RE = re.compile(r"(?m)^\s{0,3}#{1,6}(?:\s.*)?$")
|
|
133
|
+
HTML_COMMENT_RE = re.compile(r"<!--.*?-->", re.S)
|
|
134
|
+
LIST_MARKER_RE = re.compile(r"^\s*(?:[-*+]|\d+[.)])(?:\s+\[[ xX]?\])?\s*")
|
|
135
|
+
# A line that only says the content is missing. Matched after list markers
|
|
136
|
+
# and emphasis are removed, case-insensitively.
|
|
137
|
+
PLACEHOLDER_LINE_RE = re.compile(
|
|
138
|
+
r"^(?:"
|
|
139
|
+
r"(?:todo|tbd|tba|fixme|xxx|wip)\b.*"
|
|
140
|
+
r"|n/?a|none|nothing yet|empty|placeholder|draft"
|
|
141
|
+
r"|(?:notes\s+)?(?:coming soon|to follow|to be (?:written|added|done|decided))"
|
|
142
|
+
r"|lorem ipsum\b.*"
|
|
143
|
+
r"|\.{2,}|…|-+|_+"
|
|
144
|
+
r")[\s.!:;…]*$",
|
|
145
|
+
re.I,
|
|
146
|
+
)
|
|
147
|
+
# `Date:` or `**Attendees:**` with nothing after it: a template field left blank.
|
|
148
|
+
EMPTY_FIELD_RE = re.compile(r"^[\w][\w ./&()-]{0,40}:$")
|
|
149
|
+
LINK_RE = re.compile(r"\[\[[^\]]+\]\]|\]\([^)]+\)|https?://\S+")
|
|
150
|
+
WORD_RE = re.compile(r"\w{2,}|\d")
|
|
151
|
+
# A short note is real content once it says this much: "Shipped the export."
|
|
152
|
+
# is a note; a lone "Draft" under a heading is not.
|
|
153
|
+
MIN_CONTENT_WORDS = 2
|
|
154
|
+
STUB_MAX_CHARS = 4000
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def _content_words(text: str) -> int:
|
|
158
|
+
"""Words a person wrote, not counting headings, placeholders and blank fields.
|
|
159
|
+
|
|
160
|
+
This is what separates a stub from a short note. A standup line, a one
|
|
161
|
+
sentence decision or a list of links is short but real; a heading followed
|
|
162
|
+
by ``TODO``, empty bullets or an unfilled template is not.
|
|
163
|
+
"""
|
|
164
|
+
if len(text) > STUB_MAX_CHARS:
|
|
165
|
+
# Nobody writes kilobytes of placeholders; this also bounds the regexes.
|
|
166
|
+
return MIN_CONTENT_WORDS
|
|
167
|
+
text = HTML_COMMENT_RE.sub(" ", text)
|
|
168
|
+
words = 0
|
|
169
|
+
lines = HEADING_LINE_RE.sub("", text).splitlines()
|
|
170
|
+
for index, raw in enumerate(lines):
|
|
171
|
+
if words >= MIN_CONTENT_WORDS:
|
|
172
|
+
break
|
|
173
|
+
following = lines[index + 1].strip() if index + 1 < len(lines) else ""
|
|
174
|
+
if following.startswith("|") and set(following) <= set("|-: "):
|
|
175
|
+
continue # a table header: column names are not content
|
|
176
|
+
line = LIST_MARKER_RE.sub("", raw.strip())
|
|
177
|
+
line = line.strip("|").strip()
|
|
178
|
+
if not line or set(line) <= set("|-: "):
|
|
179
|
+
continue
|
|
180
|
+
plain = re.sub(r"[*_`~>]+", "", line).strip()
|
|
181
|
+
if not plain or PLACEHOLDER_LINE_RE.match(plain) or EMPTY_FIELD_RE.match(plain):
|
|
182
|
+
continue
|
|
183
|
+
if LINK_RE.search(line):
|
|
184
|
+
# A bare link is a pointer somebody kept on purpose.
|
|
185
|
+
words += MIN_CONTENT_WORDS
|
|
186
|
+
continue
|
|
187
|
+
words += len(WORD_RE.findall(plain))
|
|
188
|
+
return words
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def _assess(text: str, *, profile: str, name: str) -> str:
|
|
192
|
+
stripped = text.strip()
|
|
193
|
+
if not stripped:
|
|
194
|
+
return "empty"
|
|
195
|
+
# Short is not the same as empty: a terse ADR or a note somebody gave front
|
|
196
|
+
# matter is structured work, and a two-line standup note is still a note.
|
|
197
|
+
# Only headings, placeholders and unfilled template fields are stubs.
|
|
198
|
+
structured = _has_front_matter(text) or _looks_like_decision(name, text)
|
|
199
|
+
if not structured and _content_words(text) < MIN_CONTENT_WORDS:
|
|
200
|
+
return "heading-only"
|
|
201
|
+
if profile == "adr-only" and not _looks_like_decision(name, text):
|
|
202
|
+
return "unsupported"
|
|
203
|
+
return "useful"
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def _within(root: Path, path: Path) -> bool:
|
|
207
|
+
try:
|
|
208
|
+
path.resolve().relative_to(root.resolve())
|
|
209
|
+
return True
|
|
210
|
+
except (OSError, ValueError):
|
|
211
|
+
return False
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def scan(source: Path, *, profile: str = "generic") -> list[Candidate]:
|
|
215
|
+
"""Inventory Markdown under ``source`` without following file symlinks outside it."""
|
|
216
|
+
source = source.resolve()
|
|
217
|
+
seen: dict[str, str] = {}
|
|
218
|
+
out: list[Candidate] = []
|
|
219
|
+
# Real files first: a symlink is an alias, so it should be reported as the
|
|
220
|
+
# duplicate of its target, never the other way round.
|
|
221
|
+
for path in sorted(iter_markdown(source), key=lambda p: (p.is_symlink(), p)):
|
|
222
|
+
if not path.is_file() or any(part in SKIP_DIRS for part in path.relative_to(source).parts):
|
|
223
|
+
continue
|
|
224
|
+
if not _within(source, path):
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
data = path.read_bytes()
|
|
228
|
+
text = data.decode("utf-8")
|
|
229
|
+
except (UnicodeDecodeError, OSError):
|
|
230
|
+
continue
|
|
231
|
+
if "\0" in text:
|
|
232
|
+
# Valid UTF-8 but not text: a binary renamed to .md.
|
|
233
|
+
continue
|
|
234
|
+
digest = hashlib.sha256(data).hexdigest()
|
|
235
|
+
relative = path.relative_to(source).as_posix()
|
|
236
|
+
assessment = _assess(text, profile=profile, name=path.name)
|
|
237
|
+
duplicate_of = None
|
|
238
|
+
if digest in seen:
|
|
239
|
+
assessment, duplicate_of = "duplicate", seen[digest]
|
|
240
|
+
else:
|
|
241
|
+
seen[digest] = relative
|
|
242
|
+
note = load_note(path, text=text)
|
|
243
|
+
declared = str(note.front.get("decision_id") or "").strip() if note.has_front else ""
|
|
244
|
+
out.append(Candidate(
|
|
245
|
+
path=path,
|
|
246
|
+
relative=relative,
|
|
247
|
+
sha256=digest,
|
|
248
|
+
bytes=len(data),
|
|
249
|
+
words=len(text.split()),
|
|
250
|
+
assessment=assessment,
|
|
251
|
+
has_front_matter=_has_front_matter(text),
|
|
252
|
+
looks_like_decision=_looks_like_decision(path.name, text),
|
|
253
|
+
duplicate_of=duplicate_of,
|
|
254
|
+
whykit_ready=_whykit_ready(path, source, text, note),
|
|
255
|
+
decision_id=declared if DECISION_ID_RE.fullmatch(declared) else None,
|
|
256
|
+
format_warnings=_format_warnings(relative, text),
|
|
257
|
+
))
|
|
258
|
+
out.sort(key=lambda c: c.path)
|
|
259
|
+
return out
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def _vault_decision_ids(vault: Path) -> dict[str, str]:
|
|
263
|
+
"""Map each decision ID already declared in the vault to its record path."""
|
|
264
|
+
owners: dict[str, str] = {}
|
|
265
|
+
folder = vault / "06-decisions"
|
|
266
|
+
if folder.is_symlink() or not folder.is_dir():
|
|
267
|
+
return owners
|
|
268
|
+
for path in sorted(child for child in folder.iterdir() if is_markdown_name(child.name)):
|
|
269
|
+
if path.is_symlink() or not path.is_file():
|
|
270
|
+
continue
|
|
271
|
+
try:
|
|
272
|
+
note = load_note(path)
|
|
273
|
+
except (OSError, UnicodeDecodeError):
|
|
274
|
+
continue
|
|
275
|
+
declared = str(note.front.get("decision_id") or "").strip()
|
|
276
|
+
if DECISION_ID_RE.fullmatch(declared):
|
|
277
|
+
owners.setdefault(declared, path.relative_to(vault).as_posix())
|
|
278
|
+
return owners
|
|
279
|
+
|
|
280
|
+
|
|
281
|
+
def id_conflicts(candidates: list[Candidate], vault: Path | None = None) -> list[str]:
|
|
282
|
+
"""Report decision IDs claimed twice: within the import, or by the vault already.
|
|
283
|
+
|
|
284
|
+
Two ADR folders that both start at D-001 are the normal case, not the edge
|
|
285
|
+
case. Staging both is fine; finding out at lint time after normalizing is not.
|
|
286
|
+
"""
|
|
287
|
+
claims: dict[str, list[str]] = {}
|
|
288
|
+
for candidate in candidates:
|
|
289
|
+
if candidate.assessment == "useful" and candidate.decision_id:
|
|
290
|
+
claims.setdefault(candidate.decision_id, []).append(candidate.relative)
|
|
291
|
+
existing = _vault_decision_ids(vault) if vault is not None else {}
|
|
292
|
+
conflicts = []
|
|
293
|
+
for decision_id, paths in sorted(claims.items()):
|
|
294
|
+
listed = ", ".join(f"`{path}`" for path in paths)
|
|
295
|
+
if len(paths) > 1:
|
|
296
|
+
conflicts.append(f"{decision_id} is declared by {len(paths)} source files: {listed}")
|
|
297
|
+
if decision_id in existing:
|
|
298
|
+
conflicts.append(f"{decision_id} ({listed}) is already used in the vault by `{existing[decision_id]}`")
|
|
299
|
+
return conflicts
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def score_adoption(candidates: list[Candidate], vault: Path | None = None) -> dict:
|
|
303
|
+
total = len(candidates)
|
|
304
|
+
useful = [c for c in candidates if c.assessment == "useful"]
|
|
305
|
+
ready = [c for c in useful if c.whykit_ready]
|
|
306
|
+
decisions = [c for c in useful if c.looks_like_decision]
|
|
307
|
+
needs_front = [c for c in useful if not c.has_front_matter]
|
|
308
|
+
minutes = (
|
|
309
|
+
len(useful) * 3
|
|
310
|
+
+ len(needs_front) * 5
|
|
311
|
+
+ len(decisions) * 8
|
|
312
|
+
+ sum(1 for c in candidates if c.assessment in {"heading-only", "unsupported"}) * 1
|
|
313
|
+
)
|
|
314
|
+
return {
|
|
315
|
+
"files": total,
|
|
316
|
+
"useful": len(useful),
|
|
317
|
+
"whykit_ready": len(ready),
|
|
318
|
+
"need_front_matter": len(needs_front),
|
|
319
|
+
"looks_like_decision": len(decisions),
|
|
320
|
+
"duplicates": sum(1 for c in candidates if c.assessment == "duplicate"),
|
|
321
|
+
"empty_or_stub": sum(1 for c in candidates if c.assessment in {"empty", "heading-only"}),
|
|
322
|
+
"unsupported": sum(1 for c in candidates if c.assessment == "unsupported"),
|
|
323
|
+
"estimated_minutes_to_first_green_lint": minutes,
|
|
324
|
+
"readiness_pct": round(100.0 * len(ready) / total, 1) if total else 0.0,
|
|
325
|
+
"format_warnings": [warning for c in candidates for warning in c.format_warnings],
|
|
326
|
+
"id_conflicts": id_conflicts(candidates, vault),
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
|
|
330
|
+
def compare_migration(source: Path, target: Path, candidates: list[Candidate]) -> dict:
|
|
331
|
+
"""Compare same-path Markdown snapshots without rewriting either tree."""
|
|
332
|
+
files = []
|
|
333
|
+
scanned = {candidate.relative: candidate for candidate in candidates}
|
|
334
|
+
names = set(scanned)
|
|
335
|
+
|
|
336
|
+
def unreadable(exc: OSError) -> None:
|
|
337
|
+
names.add(Path(exc.filename or source).relative_to(source).as_posix())
|
|
338
|
+
|
|
339
|
+
for directory, folders, filenames in os.walk(source, followlinks=False, onerror=unreadable):
|
|
340
|
+
folder = Path(directory)
|
|
341
|
+
folders[:] = [name for name in folders if name not in SKIP_DIRS]
|
|
342
|
+
for name in folders[:]:
|
|
343
|
+
if (folder / name).is_symlink():
|
|
344
|
+
names.add((folder / name).relative_to(source).as_posix())
|
|
345
|
+
folders.remove(name)
|
|
346
|
+
names.update((folder / name).relative_to(source).as_posix() for name in filenames if is_markdown_name(name))
|
|
347
|
+
for relative in sorted(names):
|
|
348
|
+
path = source / relative
|
|
349
|
+
candidate = scanned.get(relative)
|
|
350
|
+
entry: dict = {"path": relative, "source_sha256": candidate.sha256 if candidate else None,
|
|
351
|
+
"target_sha256": None, "unchanged": False, "missing_wikilinks": [],
|
|
352
|
+
"missing_ids": [], "review_history": "not_checked", "issues": []}
|
|
353
|
+
try:
|
|
354
|
+
if candidate is None or not _within(source, path):
|
|
355
|
+
entry["issues"].append("unreadable_source")
|
|
356
|
+
files.append(entry)
|
|
357
|
+
continue
|
|
358
|
+
before = path.read_bytes()
|
|
359
|
+
if hashlib.sha256(before).hexdigest() != candidate.sha256:
|
|
360
|
+
entry["issues"].append("source_changed")
|
|
361
|
+
files.append(entry)
|
|
362
|
+
continue
|
|
363
|
+
destination = target / relative
|
|
364
|
+
if not _within(target, destination):
|
|
365
|
+
entry["issues"].append("unsafe_target")
|
|
366
|
+
files.append(entry)
|
|
367
|
+
continue
|
|
368
|
+
if not destination.is_file():
|
|
369
|
+
entry["issues"].append("missing_file")
|
|
370
|
+
files.append(entry)
|
|
371
|
+
continue
|
|
372
|
+
after = destination.read_bytes()
|
|
373
|
+
if b"\0" in after:
|
|
374
|
+
raise ValueError("binary target")
|
|
375
|
+
entry["target_sha256"] = hashlib.sha256(after).hexdigest()
|
|
376
|
+
entry["unchanged"] = before == after
|
|
377
|
+
old = load_note(path, text=before.decode("utf-8"))
|
|
378
|
+
new = load_note(destination, text=after.decode("utf-8"))
|
|
379
|
+
if old.front_error or new.front_error:
|
|
380
|
+
raise ValueError("unreadable front matter")
|
|
381
|
+
old_floor, new_floor = note_sensitivity_level(old), note_sensitivity_level(new)
|
|
382
|
+
lowered = new_floor < old_floor
|
|
383
|
+
if path.name.casefold() == "evidence-register.md":
|
|
384
|
+
old_active, old_retired, _, _ = classify_evidence_register(old)
|
|
385
|
+
new_active, new_retired, _, _ = classify_evidence_register(new)
|
|
386
|
+
new_rows = {**new_active, **new_retired}
|
|
387
|
+
for identifier, row in {**old_active, **old_retired}.items():
|
|
388
|
+
replacement = new_rows.get(identifier)
|
|
389
|
+
if replacement is None:
|
|
390
|
+
if "evidence_rows_lost" not in entry["issues"]:
|
|
391
|
+
entry["issues"].append("evidence_rows_lost")
|
|
392
|
+
continue
|
|
393
|
+
before_level = SENSITIVITY_LEVEL.get(row["sensitivity"], UNREADABLE_LEVEL) if "sensitivity" in row else old_floor
|
|
394
|
+
after_level = SENSITIVITY_LEVEL.get(replacement["sensitivity"], UNREADABLE_LEVEL) if "sensitivity" in replacement else new_floor
|
|
395
|
+
lowered = lowered or max(new_floor, after_level) < max(old_floor, before_level)
|
|
396
|
+
if lowered:
|
|
397
|
+
entry["issues"].append("sensitivity_lowered")
|
|
398
|
+
old_links = Counter(unicodedata.normalize("NFC", m.group(0).replace("\\|", "|")) for m in WIKILINK_RE.finditer(old.masked))
|
|
399
|
+
new_links = Counter(unicodedata.normalize("NFC", m.group(0).replace("\\|", "|")) for m in WIKILINK_RE.finditer(new.masked))
|
|
400
|
+
entry["missing_wikilinks"] = sorted((old_links - new_links).elements())
|
|
401
|
+
old_ids = set(EVIDENCE_ID_RE.findall(old.masked) + DECISION_ID_RE.findall(old.masked))
|
|
402
|
+
new_ids = set(EVIDENCE_ID_RE.findall(new.masked) + DECISION_ID_RE.findall(new.masked))
|
|
403
|
+
entry["missing_ids"] = sorted(old_ids - new_ids)
|
|
404
|
+
if entry["missing_wikilinks"]:
|
|
405
|
+
entry["issues"].append("wikilinks_lost")
|
|
406
|
+
if entry["missing_ids"]:
|
|
407
|
+
entry["issues"].append("ids_lost")
|
|
408
|
+
declared = old.front.get("decision_id")
|
|
409
|
+
if isinstance(declared, str) and DECISION_ID_RE.fullmatch(declared) and new.front.get("decision_id") != declared:
|
|
410
|
+
entry["issues"].append("decision_id_changed")
|
|
411
|
+
sources = old.front.get("source_ids")
|
|
412
|
+
if isinstance(sources, list) and all(isinstance(item, str) for item in sources):
|
|
413
|
+
replacement = new.front.get("source_ids")
|
|
414
|
+
if sources and (not isinstance(replacement, list) or not all(isinstance(item, str) for item in replacement)
|
|
415
|
+
or not set(sources).issubset(replacement)):
|
|
416
|
+
entry["issues"].append("source_ids_lost")
|
|
417
|
+
entry["review_history"] = "not_present"
|
|
418
|
+
if path.name.casefold() == "review-log.md":
|
|
419
|
+
old_log, new_log = _review_log_parts(old.text), _review_log_parts(new.text)
|
|
420
|
+
if before == after:
|
|
421
|
+
entry["review_history"] = "unchanged"
|
|
422
|
+
elif old_log is None or new_log is None:
|
|
423
|
+
entry["review_history"] = "unrecognized"
|
|
424
|
+
entry["issues"].append("history_unrecognized")
|
|
425
|
+
elif new_log[1][:len(old_log[1])] != old_log[1]:
|
|
426
|
+
entry["review_history"] = "changed"
|
|
427
|
+
entry["issues"].append("history_changed")
|
|
428
|
+
else:
|
|
429
|
+
entry["review_history"] = "preserved"
|
|
430
|
+
except (OSError, UnicodeError, ValueError):
|
|
431
|
+
entry["issues"].append("unreadable_file")
|
|
432
|
+
files.append(entry)
|
|
433
|
+
return {"target": str(target), "passed": bool(files) and not any(f["issues"] for f in files),
|
|
434
|
+
"files": files, "scope": "same-relative-path Markdown: links, IDs, declarations, sensitivity floors and review rows; prose meaning, code examples, attachments and Git history are not checked"}
|
|
435
|
+
|
|
436
|
+
|
|
437
|
+
|
|
438
|
+
def _migration_md(source: Path, candidates: list[Candidate], score: dict, profile: str) -> str:
|
|
439
|
+
lines = [
|
|
440
|
+
f"# Migration from `{source}`",
|
|
441
|
+
"",
|
|
442
|
+
f"Profile: `{profile}`. Generated by `whykit adopt`.",
|
|
443
|
+
"",
|
|
444
|
+
"## Score",
|
|
445
|
+
"",
|
|
446
|
+
f"- Files scanned: **{score['files']}**",
|
|
447
|
+
f"- Useful: **{score['useful']}** (front matter passes WhyKit checks: **{score['whykit_ready']}**)",
|
|
448
|
+
f"- Estimated human minutes to first green lint: **{score['estimated_minutes_to_first_green_lint']}**",
|
|
449
|
+
f"- Readiness: **{score['readiness_pct']}%**",
|
|
450
|
+
"- Scope: UTF-8 `*.md` only. Other assets need separate review; this is not a full vault lint.",
|
|
451
|
+
"",
|
|
452
|
+
"## Format warnings",
|
|
453
|
+
"",
|
|
454
|
+
*(f"- {warning}" for warning in score["format_warnings"]),
|
|
455
|
+
*(["- None detected."] if not score["format_warnings"] else []),
|
|
456
|
+
"",
|
|
457
|
+
"## ID conflicts",
|
|
458
|
+
"",
|
|
459
|
+
*(f"- {conflict}" for conflict in score["id_conflicts"]),
|
|
460
|
+
*(["- None detected."] if not score["id_conflicts"] else []),
|
|
461
|
+
"",
|
|
462
|
+
"## Bring into the vault",
|
|
463
|
+
"",
|
|
464
|
+
]
|
|
465
|
+
for candidate in candidates:
|
|
466
|
+
if candidate.assessment != "useful":
|
|
467
|
+
continue
|
|
468
|
+
lines.append(f"- `{candidate.relative}` → {candidate.destination_for(profile)}")
|
|
469
|
+
lines.extend(["", "## Leave outside / discard", ""])
|
|
470
|
+
for candidate in candidates:
|
|
471
|
+
if candidate.assessment == "useful":
|
|
472
|
+
continue
|
|
473
|
+
reason = candidate.assessment
|
|
474
|
+
if candidate.duplicate_of:
|
|
475
|
+
reason += f" (same bytes as `{candidate.duplicate_of}`)"
|
|
476
|
+
lines.append(f"- `{candidate.relative}` — {reason}")
|
|
477
|
+
lines.extend([
|
|
478
|
+
"",
|
|
479
|
+
"## Suggested next steps",
|
|
480
|
+
"",
|
|
481
|
+
"1. Review staged files under `.import-staging/`.",
|
|
482
|
+
"2. Normalize front matter for notes that are not WhyKit-ready.",
|
|
483
|
+
"3. Allocate `D-NNN` / `E-NNN` where claims must stay durable.",
|
|
484
|
+
"4. Link durable notes from `Home.md`, then `whykit lint --strict`.",
|
|
485
|
+
"",
|
|
486
|
+
])
|
|
487
|
+
return "\n".join(lines)
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def _ingestion_record(
|
|
491
|
+
batch: str,
|
|
492
|
+
today: dt.date,
|
|
493
|
+
source: Path,
|
|
494
|
+
candidates: list[Candidate],
|
|
495
|
+
owner: str,
|
|
496
|
+
profile: str,
|
|
497
|
+
) -> str:
|
|
498
|
+
rows = "\n".join(
|
|
499
|
+
f"| `{c.relative}` | `{c.sha256}` | {c.assessment} | | {c.destination_for(profile)} |"
|
|
500
|
+
for c in candidates
|
|
501
|
+
) or "| | | | | |"
|
|
502
|
+
useful = sum(1 for c in candidates if c.assessment == "useful")
|
|
503
|
+
decisions = sum(1 for c in candidates if c.assessment == "useful" and c.looks_like_decision)
|
|
504
|
+
# This record is committed with the vault, so it names the source directory
|
|
505
|
+
# only; the absolute path would publish a local home directory.
|
|
506
|
+
return f"""---
|
|
507
|
+
title: "Source ingestion batch {batch}"
|
|
508
|
+
aliases: []
|
|
509
|
+
type: research
|
|
510
|
+
status: draft
|
|
511
|
+
owner: "{owner}"
|
|
512
|
+
created: {today.isoformat()}
|
|
513
|
+
last_updated: {today.isoformat()}
|
|
514
|
+
source_of_truth: false
|
|
515
|
+
sensitivity: internal
|
|
516
|
+
source_ids: []
|
|
517
|
+
tags: ["ingestion", "adoption"]
|
|
518
|
+
---
|
|
519
|
+
|
|
520
|
+
# Source ingestion batch {batch}
|
|
521
|
+
|
|
522
|
+
## Purpose
|
|
523
|
+
|
|
524
|
+
Record how one batch of existing material was assessed on import. Generated by
|
|
525
|
+
`whykit adopt --profile {profile}` from `{source.name or source}` on {today.isoformat()}.
|
|
526
|
+
Nothing here is canonical until a person normalizes it and changes its status.
|
|
527
|
+
|
|
528
|
+
## Results
|
|
529
|
+
|
|
530
|
+
| Uploaded file | SHA-256 | Assessment | Evidence ID | Destination in repository |
|
|
531
|
+
|---|---|---|---|---|
|
|
532
|
+
{rows}
|
|
533
|
+
|
|
534
|
+
Scanned {len(candidates)} file(s): {useful} worth keeping, of which {decisions} look
|
|
535
|
+
like existing decision records.
|
|
536
|
+
|
|
537
|
+
## Normalization performed
|
|
538
|
+
|
|
539
|
+
- Not yet assessed.
|
|
540
|
+
|
|
541
|
+
## Review required
|
|
542
|
+
|
|
543
|
+
- Assign evidence IDs to sources that support a claim anywhere in the vault.
|
|
544
|
+
- Decide a sensitivity label per file before anything leaves `.import-staging/`.
|
|
545
|
+
"""
|
|
546
|
+
|
|
547
|
+
|
|
548
|
+
def _ingestion_dir(vault: Path) -> Path:
|
|
549
|
+
legacy_parent = vault / "07-research"
|
|
550
|
+
if legacy_parent.is_dir() or (vault / "07-research" / "sources").is_dir():
|
|
551
|
+
return safe_vault_dir(vault, "07-research/sources")
|
|
552
|
+
return safe_vault_dir(vault, "notes")
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def adopt(
|
|
556
|
+
source: Path,
|
|
557
|
+
vault: Path,
|
|
558
|
+
*,
|
|
559
|
+
write: bool,
|
|
560
|
+
owner: str,
|
|
561
|
+
profile: str = "generic",
|
|
562
|
+
today: dt.date | None = None,
|
|
563
|
+
) -> tuple[list[Candidate], Path | None, Path | None, dict]:
|
|
564
|
+
today = today or dt.date.today()
|
|
565
|
+
candidates = scan(source, profile=profile)
|
|
566
|
+
score = score_adoption(candidates, vault)
|
|
567
|
+
if not write:
|
|
568
|
+
return candidates, None, None, score
|
|
569
|
+
|
|
570
|
+
with vault_mutation_lock(vault):
|
|
571
|
+
staging_root = safe_vault_dir(vault, ".import-staging")
|
|
572
|
+
batch = today.isoformat()
|
|
573
|
+
suffix = 1
|
|
574
|
+
while (staging_root / batch).exists() or (staging_root / batch).is_symlink():
|
|
575
|
+
suffix += 1
|
|
576
|
+
batch = f"{today.isoformat()}-{suffix}"
|
|
577
|
+
# Stage into a hidden directory and rename it into place only once it
|
|
578
|
+
# is complete. A failure removes everything this run created, and a
|
|
579
|
+
# crash leaves a `.partial-*` directory that no later run mistakes for
|
|
580
|
+
# a finished batch.
|
|
581
|
+
partial_rel = Path(".import-staging") / f".partial-{secrets.token_hex(6)}"
|
|
582
|
+
partial = safe_vault_dir(vault, partial_rel)
|
|
583
|
+
final = staging_root / batch
|
|
584
|
+
created = partial
|
|
585
|
+
try:
|
|
586
|
+
for candidate in candidates:
|
|
587
|
+
if candidate.assessment != "useful":
|
|
588
|
+
continue
|
|
589
|
+
target = safe_vault_target(vault, partial_rel / candidate.relative)
|
|
590
|
+
data = candidate.path.read_bytes()
|
|
591
|
+
current_digest = hashlib.sha256(data).hexdigest()
|
|
592
|
+
if current_digest != candidate.sha256:
|
|
593
|
+
raise RuntimeError(
|
|
594
|
+
f"source changed during adoption: {candidate.relative} "
|
|
595
|
+
f"({candidate.sha256[:12]} -> {current_digest[:12]})"
|
|
596
|
+
)
|
|
597
|
+
atomic_write_bytes(target, data)
|
|
598
|
+
|
|
599
|
+
migration_name = "MIGRATION.md"
|
|
600
|
+
suffix = 1
|
|
601
|
+
while (partial / migration_name).exists() or (partial / migration_name).is_symlink():
|
|
602
|
+
suffix += 1
|
|
603
|
+
migration_name = f"MIGRATION-{suffix}.md"
|
|
604
|
+
atomic_write_text(
|
|
605
|
+
safe_vault_target(vault, partial_rel / migration_name),
|
|
606
|
+
_migration_md(source, candidates, score, profile),
|
|
607
|
+
)
|
|
608
|
+
os.rename(partial, final)
|
|
609
|
+
created = final
|
|
610
|
+
|
|
611
|
+
sources_dir = _ingestion_dir(vault)
|
|
612
|
+
record_name = f"ingestion-{batch}.md"
|
|
613
|
+
record = sources_dir / record_name
|
|
614
|
+
suffix = 1
|
|
615
|
+
while record.exists() or record.is_symlink():
|
|
616
|
+
suffix += 1
|
|
617
|
+
record_name = f"ingestion-{batch}-{suffix}.md"
|
|
618
|
+
record = sources_dir / record_name
|
|
619
|
+
record = safe_vault_target(vault, record.relative_to(vault.resolve()))
|
|
620
|
+
atomic_write_text(record, _ingestion_record(batch, today, source, candidates, owner, profile))
|
|
621
|
+
except BaseException:
|
|
622
|
+
shutil.rmtree(created, ignore_errors=True)
|
|
623
|
+
raise
|
|
624
|
+
return candidates, record, final / migration_name, score
|
|
625
|
+
|
|
626
|
+
|
|
627
|
+
def _cells(text: str) -> int:
|
|
628
|
+
"""Terminal columns *text* occupies once printed.
|
|
629
|
+
|
|
630
|
+
Wide and fullwidth characters take two; a console that cannot encode them
|
|
631
|
+
prints escapes instead, so measure the text in the form it is printed.
|
|
632
|
+
"""
|
|
633
|
+
return sum(2 if unicodedata.east_asian_width(ch) in "WF" else 1 for ch in as_printed(text))
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
def main(argv: list[str] | None = None) -> int:
|
|
637
|
+
parser = argparse.ArgumentParser(
|
|
638
|
+
prog="whykit adopt",
|
|
639
|
+
description="Inventory existing Markdown and stage it for a WhyKit vault.",
|
|
640
|
+
)
|
|
641
|
+
parser.add_argument("source", help="directory of existing Markdown to adopt")
|
|
642
|
+
parser.add_argument("--into", help="target vault (default: nearest vault at or above the working directory)")
|
|
643
|
+
parser.add_argument("--write", action="store_true", help="stage files and write an ingestion record")
|
|
644
|
+
parser.add_argument("--owner", default="TODO", help="owner recorded on the ingestion record")
|
|
645
|
+
parser.add_argument("--profile", choices=ADOPT_PROFILES, default="generic", help="adoption mapping profile")
|
|
646
|
+
parser.add_argument("--compare", metavar="DIR", help="read-only preservation check against files at the same relative paths")
|
|
647
|
+
parser.add_argument("--json", action="store_true", help="machine-readable inventory + score (implies dry-run shape)")
|
|
648
|
+
args = parser.parse_args(argv)
|
|
649
|
+
if args.compare is not None and (args.write or not args.compare):
|
|
650
|
+
return emit_error("usage", "--compare requires a directory and cannot be combined with --write", json_mode=args.json)
|
|
651
|
+
|
|
652
|
+
source = Path(args.source).expanduser().resolve()
|
|
653
|
+
if not source.is_dir():
|
|
654
|
+
return emit_error("invalid_target", f"not a directory: {source}", json_mode=args.json)
|
|
655
|
+
|
|
656
|
+
vault = Path(args.into).expanduser().resolve() if args.into else find_vault_root()
|
|
657
|
+
if vault is None or not is_vault_root(vault):
|
|
658
|
+
return emit_error(
|
|
659
|
+
"vault_not_found",
|
|
660
|
+
"no WhyKit vault found — run `whykit init <dir>` first, or pass --into",
|
|
661
|
+
json_mode=args.json,
|
|
662
|
+
)
|
|
663
|
+
if not args.compare and (source == vault or vault in source.parents or source in vault.parents):
|
|
664
|
+
return emit_error("invalid_target", "refusing to adopt overlapping source/vault directories", json_mode=args.json)
|
|
665
|
+
compare = Path(args.compare).expanduser().resolve() if args.compare else None
|
|
666
|
+
if compare is not None and (not compare.is_dir() or source.samefile(compare)):
|
|
667
|
+
return emit_error("invalid_target", "--compare must name a different existing directory", json_mode=args.json)
|
|
668
|
+
|
|
669
|
+
try:
|
|
670
|
+
candidates, record, migration, score = adopt(
|
|
671
|
+
source, vault, write=args.write, owner=args.owner, profile=args.profile,
|
|
672
|
+
)
|
|
673
|
+
except (OSError, RuntimeError) as exc:
|
|
674
|
+
return emit_error("io_error", f"adoption failed: {exc}", json_mode=args.json)
|
|
675
|
+
preservation = compare_migration(source, compare, candidates) if compare is not None else None
|
|
676
|
+
|
|
677
|
+
visible_files = [
|
|
678
|
+
path for path in source.rglob("*")
|
|
679
|
+
if path.is_file() and _within(source, path)
|
|
680
|
+
and not any(part in SKIP_DIRS for part in path.relative_to(source).parts)
|
|
681
|
+
]
|
|
682
|
+
omitted = sorted(path.relative_to(source).as_posix() for path in visible_files if not is_markdown_name(path.name))
|
|
683
|
+
scanned = {candidate.relative for candidate in candidates}
|
|
684
|
+
unreadable_markdown = sorted(
|
|
685
|
+
path.relative_to(source).as_posix()
|
|
686
|
+
for path in visible_files if is_markdown_name(path.name)
|
|
687
|
+
and path.relative_to(source).as_posix() not in scanned
|
|
688
|
+
)
|
|
689
|
+
scope_note = (
|
|
690
|
+
"Only UTF-8 *.md files were scanned; "
|
|
691
|
+
f"{len(omitted)} other file(s) excluded"
|
|
692
|
+
+ (f" (e.g. {', '.join(omitted[:5])})" if omitted else "")
|
|
693
|
+
+ f"; {len(unreadable_markdown)} Markdown file(s) unreadable, binary or non-UTF-8"
|
|
694
|
+
+ (f" (e.g. {', '.join(unreadable_markdown[:5])})" if unreadable_markdown else "")
|
|
695
|
+
+ ". Front matter readiness is not full vault lint."
|
|
696
|
+
)
|
|
697
|
+
|
|
698
|
+
if args.json:
|
|
699
|
+
payload = {
|
|
700
|
+
"contract_version": 1,
|
|
701
|
+
"profile": args.profile,
|
|
702
|
+
"source": str(source),
|
|
703
|
+
"vault": str(vault),
|
|
704
|
+
"write": bool(args.write),
|
|
705
|
+
"score": score,
|
|
706
|
+
"scope_note": scope_note,
|
|
707
|
+
"candidates": [
|
|
708
|
+
{
|
|
709
|
+
"path": c.relative,
|
|
710
|
+
"assessment": c.assessment,
|
|
711
|
+
"destination": c.destination_for(args.profile),
|
|
712
|
+
"has_front_matter": c.has_front_matter,
|
|
713
|
+
"whykit_ready": c.whykit_ready,
|
|
714
|
+
"looks_like_decision": c.looks_like_decision,
|
|
715
|
+
"duplicate_of": c.duplicate_of,
|
|
716
|
+
"bytes": c.bytes,
|
|
717
|
+
"words": c.words,
|
|
718
|
+
"sha256": c.sha256,
|
|
719
|
+
}
|
|
720
|
+
for c in candidates
|
|
721
|
+
],
|
|
722
|
+
"ingestion_record": record.relative_to(vault).as_posix() if record else None,
|
|
723
|
+
"migration": migration.relative_to(vault).as_posix() if migration else None,
|
|
724
|
+
}
|
|
725
|
+
if preservation is not None:
|
|
726
|
+
payload["preservation"] = preservation
|
|
727
|
+
emit_machine(json.dumps(payload, ensure_ascii=False, indent=2))
|
|
728
|
+
return int(preservation is not None and not preservation["passed"])
|
|
729
|
+
|
|
730
|
+
if preservation is not None:
|
|
731
|
+
print(f"Preservation: {'PASS' if preservation['passed'] else 'FAIL'}")
|
|
732
|
+
print(preservation["scope"])
|
|
733
|
+
for item in preservation["files"]:
|
|
734
|
+
if item["issues"]:
|
|
735
|
+
print(f" {item['path']}: {', '.join(item['issues'])}")
|
|
736
|
+
return int(not preservation["passed"])
|
|
737
|
+
|
|
738
|
+
if not candidates:
|
|
739
|
+
print(f"no Markdown found under {source}")
|
|
740
|
+
print(scope_note)
|
|
741
|
+
return 0
|
|
742
|
+
|
|
743
|
+
counts = {a: sum(1 for c in candidates if c.assessment == a) for a in ASSESSMENTS}
|
|
744
|
+
decisions = [c for c in candidates if c.assessment == "useful" and c.looks_like_decision]
|
|
745
|
+
# Cap the column: one deeply nested file should not push every row off screen.
|
|
746
|
+
# Pad by terminal cells, not code points: East Asian wide characters take two.
|
|
747
|
+
width = min(max(_cells(c.relative) for c in candidates), 60)
|
|
748
|
+
for candidate in candidates:
|
|
749
|
+
marker = "decision?" if candidate.looks_like_decision and candidate.assessment == "useful" else ""
|
|
750
|
+
note = f" (same bytes as {candidate.duplicate_of})" if candidate.duplicate_of else ""
|
|
751
|
+
padding = " " * max(width - _cells(candidate.relative), 0)
|
|
752
|
+
print(f" {candidate.relative}{padding} {candidate.assessment:<12}{marker}{note}")
|
|
753
|
+
|
|
754
|
+
print(f"\n{len(candidates)} file(s): " + ", ".join(f"{v} {k}" for k, v in counts.items() if v))
|
|
755
|
+
print(
|
|
756
|
+
f"Score: {score['readiness_pct']}% front-matter-ready only; "
|
|
757
|
+
f"~{score['estimated_minutes_to_first_green_lint']} heuristic minutes to first green lint"
|
|
758
|
+
)
|
|
759
|
+
print(scope_note)
|
|
760
|
+
for warning in score["format_warnings"]:
|
|
761
|
+
print(f"Format warning: {warning}")
|
|
762
|
+
for conflict in score["id_conflicts"]:
|
|
763
|
+
print(f"ID conflict: {conflict}")
|
|
764
|
+
if decisions:
|
|
765
|
+
print(f"{len(decisions)} look like existing decision records — they need D-NNN IDs and log rows.")
|
|
766
|
+
|
|
767
|
+
if not args.write:
|
|
768
|
+
print("\nDry run. Nothing was written. Re-run with --write to stage these files")
|
|
769
|
+
print(f"under {vault}/.import-staging/ and create an ingestion record + MIGRATION.md.")
|
|
770
|
+
return 0
|
|
771
|
+
|
|
772
|
+
print(f"\nStaged under {vault / '.import-staging'}{os.sep}")
|
|
773
|
+
print(f"Ingestion record: {record.relative_to(vault).as_posix() if record else '—'}")
|
|
774
|
+
print(f"Migration guide: {migration.relative_to(vault).as_posix() if migration else '—'}")
|
|
775
|
+
print("Staging is gitignored on purpose. Normalize into a workstream before committing.")
|
|
776
|
+
return 0
|