code2okf 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.
- code2okf/SPEC.md +1006 -0
- code2okf/__init__.py +8 -0
- code2okf/cli.py +234 -0
- code2okf/clis/inspectmd/pyproject.toml +40 -0
- code2okf/clis/inspectmd/src/inspectmd/__init__.py +8 -0
- code2okf/clis/inspectmd/src/inspectmd/__main__.py +5 -0
- code2okf/clis/inspectmd/src/inspectmd/cli.py +159 -0
- code2okf/clis/inspectmd/src/inspectmd/parse.py +212 -0
- code2okf/clis/inspectokf/pyproject.toml +40 -0
- code2okf/clis/inspectokf/src/inspectokf/__init__.py +8 -0
- code2okf/clis/inspectokf/src/inspectokf/__main__.py +5 -0
- code2okf/clis/inspectokf/src/inspectokf/cli.py +104 -0
- code2okf/clis/merkleokf/pyproject.toml +40 -0
- code2okf/clis/merkleokf/src/merkleokf/__init__.py +8 -0
- code2okf/clis/merkleokf/src/merkleokf/__main__.py +5 -0
- code2okf/clis/merkleokf/src/merkleokf/cli.py +121 -0
- code2okf/clis/merkleokf/src/merkleokf/merkle.py +145 -0
- code2okf/clis/sizeokf/pyproject.toml +40 -0
- code2okf/clis/sizeokf/src/sizeokf/__init__.py +8 -0
- code2okf/clis/sizeokf/src/sizeokf/__main__.py +5 -0
- code2okf/clis/sizeokf/src/sizeokf/cli.py +93 -0
- code2okf/clis/sizeokf/src/sizeokf/sizes.py +155 -0
- code2okf/compile.py +267 -0
- code2okf/events.py +86 -0
- code2okf/kit/README.md +128 -0
- code2okf/kit/files/home/.local/lib/code2okf/mount-state.sh +48 -0
- code2okf/kit/files/home/.pi/agent/AGENTS.md +185 -0
- code2okf/kit/files/home/.pi/agent/models.json +84 -0
- code2okf/kit/files/home/.pi/agent/settings.json +7 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/SKILL.md +142 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/check-okf.sh +155 -0
- code2okf/kit/files/home/.pi/agent/skills/compile-okf/scripts/frontmatter-guard.py +289 -0
- code2okf/kit/files/home/.pi/agent/skills/curate-okf/SKILL.md +68 -0
- code2okf/kit/files/home/.pi/agent/skills/inspect-md/SKILL.md +52 -0
- code2okf/kit/files/home/.pi/agent/skills/inspect-okf/SKILL.md +47 -0
- code2okf/kit/files/home/.pi/agent/skills/merkle-okf/SKILL.md +59 -0
- code2okf/kit/files/home/.pi/agent/skills/size-okf/SKILL.md +52 -0
- code2okf/kit/spec.yaml +312 -0
- code2okf/resources.py +74 -0
- code2okf/sandbox.py +266 -0
- code2okf/workbench.py +572 -0
- code2okf-0.1.0.dist-info/METADATA +391 -0
- code2okf-0.1.0.dist-info/RECORD +48 -0
- code2okf-0.1.0.dist-info/WHEEL +4 -0
- code2okf-0.1.0.dist-info/entry_points.txt +2 -0
- code2okf-0.1.0.dist-info/licenses/LICENSE +21 -0
- code2okf-0.1.0.dist-info/licenses/LICENSE-OKF-SPEC.txt +203 -0
- code2okf-0.1.0.dist-info/licenses/NOTICE-OKF-SPEC.md +37 -0
code2okf/__init__.py
ADDED
code2okf/cli.py
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
"""The code2okf command.
|
|
2
|
+
|
|
3
|
+
Compiles Markdown into an OKF wiki with the Pi coding agent, via a sandboxed
|
|
4
|
+
sbx runtime. See .claude/plans/interface-plan.md.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import argparse
|
|
10
|
+
import sys
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from code2okf import __version__, resources, sandbox, workbench
|
|
14
|
+
from code2okf import compile as compile_mod
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
18
|
+
parser = argparse.ArgumentParser(
|
|
19
|
+
prog="code2okf",
|
|
20
|
+
description="Compile Markdown into an OKF wiki with the Pi coding agent.",
|
|
21
|
+
)
|
|
22
|
+
parser.add_argument(
|
|
23
|
+
"paths", nargs="*", metavar="FILE|DIR", help="Markdown files or folders; '-' or none means stdin"
|
|
24
|
+
)
|
|
25
|
+
parser.add_argument("-o", "--output", default="okf", metavar="DIR", help="wiki output directory (default: ./okf)")
|
|
26
|
+
parser.add_argument("--spec", metavar="FILE", help="OKF spec file (default: the bundled SPEC.md)")
|
|
27
|
+
parser.add_argument(
|
|
28
|
+
"-n",
|
|
29
|
+
type=int,
|
|
30
|
+
default=compile_mod.DEFAULT_MAX_ITERATIONS,
|
|
31
|
+
metavar="N",
|
|
32
|
+
help="max Ralph loop iterations per document (default: 10)",
|
|
33
|
+
)
|
|
34
|
+
parser.add_argument("--fresh", action="store_true", help="recreate the sandbox even if it could be reused")
|
|
35
|
+
parser.add_argument("--dry-run", action="store_true", help="resolve and print what would run; do nothing paid")
|
|
36
|
+
verbosity = parser.add_mutually_exclusive_group()
|
|
37
|
+
verbosity.add_argument(
|
|
38
|
+
"-q", "--quiet", action="store_true", help="suppress progress and TSV rows (fatal errors still print)"
|
|
39
|
+
)
|
|
40
|
+
verbosity.add_argument("-v", "--verbose", action="store_true", help="also show Pi's tool calls and prose")
|
|
41
|
+
parser.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
42
|
+
return parser
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def _resolve_inputs(
|
|
46
|
+
args: argparse.Namespace,
|
|
47
|
+
) -> tuple[list[compile_mod.Document], Path, Path, workbench.Workbench] | int:
|
|
48
|
+
"""Everything decided before any work starts. Returns exit code 2 on failure."""
|
|
49
|
+
try:
|
|
50
|
+
if args.n < 1:
|
|
51
|
+
raise compile_mod.UsageError(f"-n must be at least 1 (got {args.n})")
|
|
52
|
+
documents = compile_mod.resolve_documents(args.paths)
|
|
53
|
+
spec_path = Path(args.spec) if args.spec else resources.spec_md()
|
|
54
|
+
workbench.reject_if_unsafe(spec_path, what="--spec")
|
|
55
|
+
if not spec_path.is_file():
|
|
56
|
+
raise compile_mod.UsageError(f"--spec is not a file: {spec_path}")
|
|
57
|
+
|
|
58
|
+
output_dir = Path(args.output)
|
|
59
|
+
if not workbench.is_adoptable_output(output_dir):
|
|
60
|
+
raise compile_mod.UsageError(
|
|
61
|
+
f"-o {output_dir} is not empty and is not a recognised OKF bundle root; refusing to adopt it"
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
wb = workbench.Workbench.default()
|
|
65
|
+
overlap_paths = [Path(raw) for raw in args.paths if raw != "-"]
|
|
66
|
+
overlap_paths += [spec_path, output_dir, wb.root]
|
|
67
|
+
workbench.check_no_overlap(overlap_paths)
|
|
68
|
+
except (compile_mod.UsageError, workbench.WorkbenchError, resources.ResourcesError) as exc:
|
|
69
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
70
|
+
return 2
|
|
71
|
+
return documents, spec_path, output_dir, wb
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def _print_dry_run(
|
|
75
|
+
documents: list[compile_mod.Document], spec_path: Path, output_dir: Path, wb: workbench.Workbench
|
|
76
|
+
) -> None:
|
|
77
|
+
print("code2okf --dry-run: resolving only -- no sandbox will be created, nothing paid will run.")
|
|
78
|
+
print(f" spec: {spec_path}")
|
|
79
|
+
print(f" output: {output_dir}")
|
|
80
|
+
print(" documents:")
|
|
81
|
+
for doc in documents:
|
|
82
|
+
print(f" {doc.display} -> work/md/{doc.basename}")
|
|
83
|
+
print(" mounts:")
|
|
84
|
+
for mount in wb.mounts():
|
|
85
|
+
print(f" {mount.as_arg()}")
|
|
86
|
+
print(" commands:")
|
|
87
|
+
print(f" {_format_sbx_run(wb)}")
|
|
88
|
+
for doc in documents:
|
|
89
|
+
print(f" {_format_sbx_exec_pi(wb, doc)}")
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _format_sbx_run(wb: workbench.Workbench) -> str:
|
|
93
|
+
"""The `sbx run` line that would create the sandbox, if one is needed.
|
|
94
|
+
|
|
95
|
+
Whether it actually runs depends on sandbox reuse -- a live decision
|
|
96
|
+
--dry-run must not make (that would mean querying `sbx`). This shows
|
|
97
|
+
what *would* run if a (re)creation turns out to be necessary.
|
|
98
|
+
"""
|
|
99
|
+
mount_args = " ".join(mount.as_arg() for mount in wb.mounts())
|
|
100
|
+
return (
|
|
101
|
+
f"sbx run --detached --name {workbench.SANDBOX_NAME} "
|
|
102
|
+
f"-e CODE2OKF_STATE_DIR={wb.root} {resources.kit_dir()} {mount_args}"
|
|
103
|
+
)
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def _format_sbx_exec_pi(wb: workbench.Workbench, doc: compile_mod.Document) -> str:
|
|
107
|
+
"""The first-iteration `sbx exec ... pi` line for one document.
|
|
108
|
+
|
|
109
|
+
Later Ralph loop iterations append the continuation prompt; --dry-run
|
|
110
|
+
shows only the first, since how many would actually run is exactly
|
|
111
|
+
what compiling determines.
|
|
112
|
+
"""
|
|
113
|
+
document_path = wb.work_md / doc.basename
|
|
114
|
+
prompt = compile_mod.COMPILE_PROMPT.format(document=document_path)
|
|
115
|
+
return f"sbx exec {workbench.SANDBOX_NAME} -- pi --mode json {prompt!r}"
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
def _ensure_sandbox(wb: workbench.Workbench, args: argparse.Namespace) -> int | None:
|
|
119
|
+
"""Reuse or (re)create the sandbox. Returns an exit code on failure, else None."""
|
|
120
|
+
try:
|
|
121
|
+
workbench.ensure_sandbox(wb, fresh=args.fresh)
|
|
122
|
+
except (
|
|
123
|
+
workbench.UnownedSandboxError,
|
|
124
|
+
workbench.KeyNotProxyManagedError,
|
|
125
|
+
sandbox.SandboxError,
|
|
126
|
+
resources.ResourcesError,
|
|
127
|
+
) as exc:
|
|
128
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
129
|
+
return 2
|
|
130
|
+
return None
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def _run(
|
|
134
|
+
args: argparse.Namespace,
|
|
135
|
+
documents: list[compile_mod.Document],
|
|
136
|
+
spec_path: Path,
|
|
137
|
+
output_dir: Path,
|
|
138
|
+
wb: workbench.Workbench,
|
|
139
|
+
) -> int:
|
|
140
|
+
wb.ensure_roots()
|
|
141
|
+
|
|
142
|
+
failure = _ensure_sandbox(wb, args)
|
|
143
|
+
if failure is not None:
|
|
144
|
+
return failure
|
|
145
|
+
|
|
146
|
+
try:
|
|
147
|
+
clis_dir = resources.clis_dir()
|
|
148
|
+
except resources.ResourcesError as exc:
|
|
149
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
150
|
+
return 2
|
|
151
|
+
|
|
152
|
+
try:
|
|
153
|
+
workbench.restage(
|
|
154
|
+
wb,
|
|
155
|
+
inputs=compile_mod.stage_items(documents),
|
|
156
|
+
clis_dir=clis_dir,
|
|
157
|
+
spec_source=spec_path,
|
|
158
|
+
output_dir=output_dir,
|
|
159
|
+
)
|
|
160
|
+
except workbench.WorkbenchError as exc:
|
|
161
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
162
|
+
return 1
|
|
163
|
+
|
|
164
|
+
def on_progress(line: str) -> None:
|
|
165
|
+
if not args.quiet:
|
|
166
|
+
print(line, file=sys.stderr)
|
|
167
|
+
|
|
168
|
+
def on_event(line: str) -> None:
|
|
169
|
+
if args.verbose:
|
|
170
|
+
print(line, file=sys.stderr)
|
|
171
|
+
|
|
172
|
+
for doc in documents:
|
|
173
|
+
try:
|
|
174
|
+
row = compile_mod.compile_document(
|
|
175
|
+
workbench.SANDBOX_NAME, doc, wb, output_dir, args.n, on_progress=on_progress, on_event=on_event
|
|
176
|
+
)
|
|
177
|
+
except compile_mod.CompileError as exc:
|
|
178
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
179
|
+
return 1
|
|
180
|
+
if not args.quiet:
|
|
181
|
+
print(row.as_tsv())
|
|
182
|
+
return 0
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def main(argv: list[str] | None = None) -> int:
|
|
186
|
+
"""Parse argv, run, and return a process exit code."""
|
|
187
|
+
args = _build_parser().parse_args(argv)
|
|
188
|
+
|
|
189
|
+
resolved = _resolve_inputs(args)
|
|
190
|
+
if isinstance(resolved, int):
|
|
191
|
+
return resolved
|
|
192
|
+
documents, spec_path, output_dir, wb = resolved
|
|
193
|
+
|
|
194
|
+
if args.dry_run:
|
|
195
|
+
try:
|
|
196
|
+
_print_dry_run(documents, spec_path, output_dir, wb)
|
|
197
|
+
except resources.ResourcesError as exc:
|
|
198
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
199
|
+
return 2
|
|
200
|
+
return 0
|
|
201
|
+
|
|
202
|
+
try:
|
|
203
|
+
sandbox.preflight()
|
|
204
|
+
except sandbox.SandboxError as exc:
|
|
205
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
206
|
+
return 2
|
|
207
|
+
|
|
208
|
+
try:
|
|
209
|
+
with workbench.lock():
|
|
210
|
+
return _run(args, documents, spec_path, output_dir, wb)
|
|
211
|
+
except workbench.LockHeld:
|
|
212
|
+
print("code2okf: another code2okf run is using the sandbox; try again later", file=sys.stderr)
|
|
213
|
+
return 2
|
|
214
|
+
except workbench.UnsafeLockFile as exc:
|
|
215
|
+
# Caught here rather than with the setup errors above because lock()
|
|
216
|
+
# is entered after them; it is still an environment problem decided
|
|
217
|
+
# before any work starts, so exit 2. Kept narrow on purpose: a broad
|
|
218
|
+
# WorkbenchError clause here would also swallow run-phase failures
|
|
219
|
+
# that owe the caller exit 1.
|
|
220
|
+
print(f"code2okf: {exc}", file=sys.stderr)
|
|
221
|
+
return 2
|
|
222
|
+
except KeyboardInterrupt:
|
|
223
|
+
# Ctrl-C is a failed run (exit 1), not a crash: a bare traceback tells
|
|
224
|
+
# the user nothing about what survived. The lock is already released
|
|
225
|
+
# by lock()'s own finally, and mirror_out() only ever runs after a
|
|
226
|
+
# completed iteration, so -o DIR cannot hold a half-written pass --
|
|
227
|
+
# though the copy itself is not atomic, hence "may be partial" for
|
|
228
|
+
# the workbench side.
|
|
229
|
+
print(
|
|
230
|
+
f"\ncode2okf: interrupted. {output_dir} holds the last completed pass; "
|
|
231
|
+
f"the workbench copy at {wb.work_okf} may be a partial one.",
|
|
232
|
+
file=sys.stderr,
|
|
233
|
+
)
|
|
234
|
+
return 1
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
[project]
|
|
2
|
+
name = "inspectmd"
|
|
3
|
+
version = "0.1.0"
|
|
4
|
+
description = "Print a Markdown heading map with line ranges and word counts"
|
|
5
|
+
requires-python = ">=3.12"
|
|
6
|
+
dependencies = []
|
|
7
|
+
|
|
8
|
+
[project.scripts]
|
|
9
|
+
inspectmd = "inspectmd.cli:entrypoint"
|
|
10
|
+
|
|
11
|
+
[build-system]
|
|
12
|
+
requires = ["hatchling"]
|
|
13
|
+
build-backend = "hatchling.build"
|
|
14
|
+
|
|
15
|
+
[dependency-groups]
|
|
16
|
+
test = ["pytest>=8.4"]
|
|
17
|
+
|
|
18
|
+
# Paths are relative to this file, so they resolve wherever the project is
|
|
19
|
+
# invoked from. pythonpath keeps `import inspectmd` working without an install.
|
|
20
|
+
[tool.pytest.ini_options]
|
|
21
|
+
minversion = "8.0"
|
|
22
|
+
addopts = ["-ra", "--strict-markers", "--strict-config"]
|
|
23
|
+
testpaths = ["tests"]
|
|
24
|
+
pythonpath = ["src"]
|
|
25
|
+
|
|
26
|
+
[tool.ruff]
|
|
27
|
+
target-version = "py312"
|
|
28
|
+
line-length = 120
|
|
29
|
+
|
|
30
|
+
# Same rule set as web2md: owned here under the zero-overlap rule, not shared.
|
|
31
|
+
[tool.ruff.lint]
|
|
32
|
+
select = ["E", "W", "F", "I", "UP", "B", "SIM", "C4", "RET", "PT", "D"]
|
|
33
|
+
|
|
34
|
+
[tool.ruff.lint.pydocstyle]
|
|
35
|
+
convention = "google"
|
|
36
|
+
|
|
37
|
+
[tool.ruff.lint.per-file-ignores]
|
|
38
|
+
# A test's name is its documentation; a docstring on each of them would be noise.
|
|
39
|
+
# Fixtures and helpers are still expected to explain themselves.
|
|
40
|
+
"tests/*" = ["D100", "D103"]
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Command-line interface for inspectmd."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Sequence
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from inspectmd import __version__
|
|
11
|
+
from inspectmd.parse import Section, inspect_markdown
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
def format_table(sections: Sequence[Section], *, max_level: int | None = None) -> str:
|
|
15
|
+
"""Render sections as a fixed-width table.
|
|
16
|
+
|
|
17
|
+
When ``max_level`` is set, only the preamble (level 0) and headings with
|
|
18
|
+
``level <= max_level`` are shown.
|
|
19
|
+
"""
|
|
20
|
+
visible = [
|
|
21
|
+
s
|
|
22
|
+
for s in sections
|
|
23
|
+
if max_level is None or s.level == 0 or s.level <= max_level
|
|
24
|
+
]
|
|
25
|
+
if not visible:
|
|
26
|
+
return "(no sections at this depth)\n"
|
|
27
|
+
|
|
28
|
+
headers = ("Index", "Level", "Lines", "Words", "Slug", "Title")
|
|
29
|
+
rows: list[tuple[str, str, str, str, str, str]] = []
|
|
30
|
+
for s in visible:
|
|
31
|
+
rows.append(
|
|
32
|
+
(
|
|
33
|
+
str(s.index),
|
|
34
|
+
str(s.level),
|
|
35
|
+
f"{s.start}-{s.end}",
|
|
36
|
+
str(s.words),
|
|
37
|
+
s.slug,
|
|
38
|
+
s.title,
|
|
39
|
+
)
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
widths = [len(h) for h in headers]
|
|
43
|
+
for row in rows:
|
|
44
|
+
for i, cell in enumerate(row):
|
|
45
|
+
widths[i] = max(widths[i], len(cell))
|
|
46
|
+
|
|
47
|
+
def fmt(row: tuple[str, ...]) -> str:
|
|
48
|
+
return " ".join(cell.ljust(widths[i]) for i, cell in enumerate(row))
|
|
49
|
+
|
|
50
|
+
lines = [fmt(headers), fmt(tuple("-" * w for w in widths))]
|
|
51
|
+
lines.extend(fmt(row) for row in rows)
|
|
52
|
+
return "\n".join(lines) + "\n"
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def format_section_range(section: Section) -> str:
|
|
56
|
+
"""One section as ``start:end`` plus word count, for a ranged read."""
|
|
57
|
+
return f"{section.start}:{section.end} {section.words} words\n"
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _build_parser() -> argparse.ArgumentParser:
|
|
61
|
+
parser = argparse.ArgumentParser(
|
|
62
|
+
prog="inspectmd",
|
|
63
|
+
formatter_class=argparse.RawDescriptionHelpFormatter,
|
|
64
|
+
description="Print a Markdown heading map with line ranges and word counts.",
|
|
65
|
+
epilog="""\
|
|
66
|
+
Output columns (default table):
|
|
67
|
+
Index Section number in document order (0 = preamble when present).
|
|
68
|
+
Pass this value to --section.
|
|
69
|
+
Level Heading depth: 0 for the preamble, 1 for #, 2 for ##, …, 6 for ######.
|
|
70
|
+
Lines 1-based inclusive line range of the section (start-end).
|
|
71
|
+
Words Whitespace-split word count of that range.
|
|
72
|
+
Slug Kebab-case slug derived from the heading title (OKF file-name style).
|
|
73
|
+
Title Heading text as written (or "(preamble)" / "(empty)").
|
|
74
|
+
|
|
75
|
+
--section N prints only "start:end N words" for ranged reads.
|
|
76
|
+
""",
|
|
77
|
+
)
|
|
78
|
+
parser.add_argument(
|
|
79
|
+
"file",
|
|
80
|
+
type=Path,
|
|
81
|
+
help="Markdown file to inspect",
|
|
82
|
+
)
|
|
83
|
+
parser.add_argument(
|
|
84
|
+
"--version",
|
|
85
|
+
action="version",
|
|
86
|
+
version=f"%(prog)s {__version__}",
|
|
87
|
+
)
|
|
88
|
+
parser.add_argument(
|
|
89
|
+
"--section",
|
|
90
|
+
type=int,
|
|
91
|
+
metavar="N",
|
|
92
|
+
help="print only section N as start:end and word count",
|
|
93
|
+
)
|
|
94
|
+
parser.add_argument(
|
|
95
|
+
"-L",
|
|
96
|
+
"--level",
|
|
97
|
+
type=int,
|
|
98
|
+
metavar="N",
|
|
99
|
+
help="show only headings at this level or above (1=H1, …)",
|
|
100
|
+
)
|
|
101
|
+
return parser
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def main(argv: list[str] | None = None) -> int:
|
|
105
|
+
"""Parse argv and print the heading map. Returns a process exit code."""
|
|
106
|
+
parser = _build_parser()
|
|
107
|
+
args = parser.parse_args(argv)
|
|
108
|
+
|
|
109
|
+
# Checked before the path so the message is the same wherever it is run
|
|
110
|
+
# from, and so every `-L` in this repo rejects the same values.
|
|
111
|
+
level: int | None = args.level
|
|
112
|
+
if level is not None and level < 1:
|
|
113
|
+
print(f"inspectmd: --level must be 1 or greater (got {level})", file=sys.stderr)
|
|
114
|
+
return 2
|
|
115
|
+
|
|
116
|
+
path: Path = args.file
|
|
117
|
+
if not path.is_file():
|
|
118
|
+
print(f"inspectmd: not a file: {path}", file=sys.stderr)
|
|
119
|
+
return 2
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
text = path.read_text(encoding="utf-8")
|
|
123
|
+
except OSError as exc:
|
|
124
|
+
print(f"inspectmd: {exc}", file=sys.stderr)
|
|
125
|
+
return 2
|
|
126
|
+
|
|
127
|
+
line_count = text.count("\n") + (0 if text.endswith("\n") or text == "" else 1)
|
|
128
|
+
if text == "":
|
|
129
|
+
line_count = 0
|
|
130
|
+
sections = inspect_markdown(text)
|
|
131
|
+
|
|
132
|
+
if args.section is not None:
|
|
133
|
+
match = next((s for s in sections if s.index == args.section), None)
|
|
134
|
+
if match is None:
|
|
135
|
+
print(
|
|
136
|
+
f"inspectmd: section {args.section} out of range "
|
|
137
|
+
f"(0..{sections[-1].index if sections else 'none'})",
|
|
138
|
+
file=sys.stderr,
|
|
139
|
+
)
|
|
140
|
+
return 2
|
|
141
|
+
sys.stdout.write(format_section_range(match))
|
|
142
|
+
return 0
|
|
143
|
+
|
|
144
|
+
summary = (
|
|
145
|
+
f"{path.name}: {line_count} lines, {len(text.split())} words, "
|
|
146
|
+
f"{len(sections)} sections"
|
|
147
|
+
)
|
|
148
|
+
sys.stdout.write(summary + "\n")
|
|
149
|
+
if not sections:
|
|
150
|
+
sys.stdout.write("(no headings)\n")
|
|
151
|
+
return 0
|
|
152
|
+
|
|
153
|
+
sys.stdout.write(format_table(sections, max_level=args.level))
|
|
154
|
+
return 0
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def entrypoint() -> None:
|
|
158
|
+
"""Console-script entry: exit with ``main``'s return code."""
|
|
159
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""Parse ATX Markdown headings into a section map.
|
|
2
|
+
|
|
3
|
+
Only ATX headings (``#`` … ``######``) count. Setext underlines are ignored on
|
|
4
|
+
purpose: both producers in this repo emit ATX (``web2md`` sets
|
|
5
|
+
``heading_style = ATX``, and Marker output under ``md/`` is ATX throughout).
|
|
6
|
+
Headings inside fenced code blocks (backticks or tildes) are ignored. A leading
|
|
7
|
+
YAML frontmatter block is skipped so its ``---`` lines are not mistaken for
|
|
8
|
+
content. Text between the frontmatter and the first heading is section 0, the
|
|
9
|
+
preamble.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import re
|
|
15
|
+
import unicodedata
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
|
|
18
|
+
_ATX_RE = re.compile(r"^( {0,3})(#{1,6})(?:[ \t]+(.*))?$")
|
|
19
|
+
_CLOSING_HASHES_RE = re.compile(r"[ \t]+#*[ \t]*$")
|
|
20
|
+
_FENCE_RE = re.compile(r"^( {0,3})(`{3,}|~{3,})(.*)$")
|
|
21
|
+
_NON_ALNUM_RE = re.compile(r"[^a-z0-9]+")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class Section:
|
|
26
|
+
"""One preamble or ATX-headed region of a Markdown document."""
|
|
27
|
+
|
|
28
|
+
index: int
|
|
29
|
+
"""0 is the preamble, if any; otherwise the first section is 0."""
|
|
30
|
+
|
|
31
|
+
level: int
|
|
32
|
+
"""1–6 for ATX headings; 0 for the preamble."""
|
|
33
|
+
|
|
34
|
+
title: str
|
|
35
|
+
slug: str
|
|
36
|
+
"""Kebab-case slug matching the AGENTS.md file-name convention."""
|
|
37
|
+
|
|
38
|
+
start: int
|
|
39
|
+
"""1-based inclusive line number (the heading line itself, or first body)."""
|
|
40
|
+
|
|
41
|
+
end: int
|
|
42
|
+
"""1-based inclusive line number."""
|
|
43
|
+
|
|
44
|
+
words: int
|
|
45
|
+
"""Whitespace-split word count of the section's lines."""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def count_words(text: str) -> int:
|
|
49
|
+
"""Count whitespace-separated tokens in ``text``."""
|
|
50
|
+
return len(text.split())
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def slugify(title: str) -> str:
|
|
54
|
+
"""Reduce a heading title to a kebab-case slug.
|
|
55
|
+
|
|
56
|
+
Lowercases ASCII letters, strips combining marks from non-ASCII characters,
|
|
57
|
+
replaces any remaining non ``[a-z0-9]`` run with a single hyphen, and trims
|
|
58
|
+
leading/trailing hyphens. Empty input yields an empty string.
|
|
59
|
+
"""
|
|
60
|
+
normalized = unicodedata.normalize("NFKD", title)
|
|
61
|
+
ascii_only = "".join(c for c in normalized if not unicodedata.combining(c))
|
|
62
|
+
return _NON_ALNUM_RE.sub("-", ascii_only.casefold()).strip("-")
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def split_frontmatter(text: str) -> tuple[int, str]:
|
|
66
|
+
"""Strip a leading YAML frontmatter block.
|
|
67
|
+
|
|
68
|
+
Returns ``(last_frontmatter_line, remainder)``. Line numbers are 1-based:
|
|
69
|
+
``last_frontmatter_line`` is the closing ``---`` line, or ``0`` when there
|
|
70
|
+
is no frontmatter. The remainder starts at the first content line.
|
|
71
|
+
"""
|
|
72
|
+
if text.startswith("\ufeff"):
|
|
73
|
+
text = text[1:]
|
|
74
|
+
|
|
75
|
+
if not text.startswith("---"):
|
|
76
|
+
return 0, text
|
|
77
|
+
|
|
78
|
+
lines = text.splitlines(keepends=True)
|
|
79
|
+
if not lines or lines[0].strip() != "---":
|
|
80
|
+
return 0, text
|
|
81
|
+
|
|
82
|
+
for i in range(1, len(lines)):
|
|
83
|
+
if lines[i].strip() == "---":
|
|
84
|
+
return i + 1, "".join(lines[i + 1 :])
|
|
85
|
+
return 0, text
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def _fence_opener(line: str) -> tuple[str, int] | None:
|
|
89
|
+
"""Return ``(fence_char, length)`` if ``line`` opens a fenced code block."""
|
|
90
|
+
match = _FENCE_RE.match(line.rstrip("\n"))
|
|
91
|
+
if match is None:
|
|
92
|
+
return None
|
|
93
|
+
marker = match.group(2)
|
|
94
|
+
return marker[0], len(marker)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _fence_closer(line: str, char: str, length: int) -> bool:
|
|
98
|
+
"""True if ``line`` closes a fence opened with ``char`` × ``length``."""
|
|
99
|
+
match = _FENCE_RE.match(line.rstrip("\n"))
|
|
100
|
+
if match is None:
|
|
101
|
+
return False
|
|
102
|
+
marker = match.group(2)
|
|
103
|
+
info = match.group(3)
|
|
104
|
+
if marker[0] != char or len(marker) < length:
|
|
105
|
+
return False
|
|
106
|
+
# Closing fences may only carry trailing whitespace as "info".
|
|
107
|
+
return info.strip() == ""
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def _parse_atx(line: str) -> tuple[int, str] | None:
|
|
111
|
+
"""Return ``(level, title)`` for an ATX heading line, else ``None``."""
|
|
112
|
+
match = _ATX_RE.match(line.rstrip("\n"))
|
|
113
|
+
if match is None:
|
|
114
|
+
return None
|
|
115
|
+
hashes = match.group(2)
|
|
116
|
+
rest = match.group(3)
|
|
117
|
+
if rest is None:
|
|
118
|
+
return len(hashes), ""
|
|
119
|
+
title = _CLOSING_HASHES_RE.sub("", rest).strip()
|
|
120
|
+
return len(hashes), title
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
def parse_sections(text: str, *, line_offset: int = 0) -> list[Section]:
|
|
124
|
+
"""Parse ``text`` into sections.
|
|
125
|
+
|
|
126
|
+
``line_offset`` is the number of lines already consumed before ``text``
|
|
127
|
+
(typically the frontmatter). Section line numbers are 1-based in the full
|
|
128
|
+
file: the first line of ``text`` is ``line_offset + 1``.
|
|
129
|
+
"""
|
|
130
|
+
lines = text.splitlines(keepends=True)
|
|
131
|
+
if not lines:
|
|
132
|
+
return []
|
|
133
|
+
|
|
134
|
+
headings: list[tuple[int, int, str]] = []
|
|
135
|
+
fence: tuple[str, int] | None = None
|
|
136
|
+
|
|
137
|
+
for i, line in enumerate(lines):
|
|
138
|
+
if fence is not None:
|
|
139
|
+
char, length = fence
|
|
140
|
+
if _fence_closer(line, char, length):
|
|
141
|
+
fence = None
|
|
142
|
+
continue
|
|
143
|
+
opener = _fence_opener(line)
|
|
144
|
+
if opener is not None:
|
|
145
|
+
fence = opener
|
|
146
|
+
continue
|
|
147
|
+
atx = _parse_atx(line)
|
|
148
|
+
if atx is not None:
|
|
149
|
+
headings.append((i, atx[0], atx[1]))
|
|
150
|
+
|
|
151
|
+
sections: list[Section] = []
|
|
152
|
+
|
|
153
|
+
def text_between(start_i: int, end_i: int) -> str:
|
|
154
|
+
return "".join(lines[j] for j in range(start_i, end_i + 1))
|
|
155
|
+
|
|
156
|
+
first_heading_i = headings[0][0] if headings else None
|
|
157
|
+
|
|
158
|
+
if first_heading_i is None:
|
|
159
|
+
body = "".join(lines)
|
|
160
|
+
if body.strip():
|
|
161
|
+
sections.append(
|
|
162
|
+
Section(
|
|
163
|
+
index=0,
|
|
164
|
+
level=0,
|
|
165
|
+
title="(preamble)",
|
|
166
|
+
slug="preamble",
|
|
167
|
+
start=line_offset + 1,
|
|
168
|
+
end=line_offset + len(lines),
|
|
169
|
+
words=count_words(body),
|
|
170
|
+
)
|
|
171
|
+
)
|
|
172
|
+
return sections
|
|
173
|
+
|
|
174
|
+
if first_heading_i > 0:
|
|
175
|
+
preamble_text = "".join(lines[:first_heading_i])
|
|
176
|
+
if preamble_text.strip():
|
|
177
|
+
sections.append(
|
|
178
|
+
Section(
|
|
179
|
+
index=0,
|
|
180
|
+
level=0,
|
|
181
|
+
title="(preamble)",
|
|
182
|
+
slug="preamble",
|
|
183
|
+
start=line_offset + 1,
|
|
184
|
+
end=line_offset + first_heading_i,
|
|
185
|
+
words=count_words(preamble_text),
|
|
186
|
+
)
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
for idx, (start_i, level, title) in enumerate(headings):
|
|
190
|
+
end_i = headings[idx + 1][0] - 1 if idx + 1 < len(headings) else len(lines) - 1
|
|
191
|
+
next_index = sections[-1].index + 1 if sections else 0
|
|
192
|
+
display_title = title if title else "(empty)"
|
|
193
|
+
display_slug = slugify(title) if title else "empty"
|
|
194
|
+
sections.append(
|
|
195
|
+
Section(
|
|
196
|
+
index=next_index,
|
|
197
|
+
level=level,
|
|
198
|
+
title=display_title,
|
|
199
|
+
slug=display_slug,
|
|
200
|
+
start=line_offset + start_i + 1,
|
|
201
|
+
end=line_offset + end_i + 1,
|
|
202
|
+
words=count_words(text_between(start_i, end_i)),
|
|
203
|
+
)
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
return sections
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def inspect_markdown(text: str) -> list[Section]:
|
|
210
|
+
"""Split frontmatter and parse the remainder into sections."""
|
|
211
|
+
offset, body = split_frontmatter(text)
|
|
212
|
+
return parse_sections(body, line_offset=offset)
|