@mriekki/lean 0.4.5 → 0.4.6
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.
package/assets/components.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/lean",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"defaultProfile": "greenfield-poc",
|
|
6
6
|
"profiles": {
|
|
7
7
|
"greenfield-poc": {
|
|
@@ -14,7 +14,7 @@
|
|
|
14
14
|
"components": {
|
|
15
15
|
"core": {
|
|
16
16
|
"repository": "mriekki/lean-agentic-core",
|
|
17
|
-
"commit": "
|
|
17
|
+
"commit": "52905468c06fe39caa491a15a77e33cd400a60d9",
|
|
18
18
|
"version": "1.13.0",
|
|
19
19
|
"distribution": "greenfield-workspace",
|
|
20
20
|
"descriptor": "assets/core/distribution.json",
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"schemaVersion": 1,
|
|
3
3
|
"package": "@mriekki/lean",
|
|
4
|
-
"version": "0.4.
|
|
4
|
+
"version": "0.4.6",
|
|
5
5
|
"components": {
|
|
6
6
|
"core": {
|
|
7
7
|
"repository": "mriekki/lean-agentic-core",
|
|
8
|
-
"commit": "
|
|
8
|
+
"commit": "52905468c06fe39caa491a15a77e33cd400a60d9",
|
|
9
9
|
"version": "1.13.0",
|
|
10
10
|
"distribution": "greenfield-workspace",
|
|
11
11
|
"files": [
|
|
@@ -192,7 +192,7 @@
|
|
|
192
192
|
{
|
|
193
193
|
"source": "core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
|
|
194
194
|
"destination": "docs/process/PROJECT_DOCUMENTATION_FRAMEWORK.md",
|
|
195
|
-
"sha256": "
|
|
195
|
+
"sha256": "8dc0773a245ed23089caa02a1a2f0dd5670710646d446d190406ae47a85da4c6"
|
|
196
196
|
},
|
|
197
197
|
{
|
|
198
198
|
"source": "core/process/LEAN_ENGINEERING_GUIDE.md",
|
|
@@ -282,7 +282,7 @@
|
|
|
282
282
|
{
|
|
283
283
|
"source": "tools/docs_site/docs_site.py",
|
|
284
284
|
"destination": "tools/lean-docs/docs-site.py",
|
|
285
|
-
"sha256": "
|
|
285
|
+
"sha256": "50ea30f5d42f71176a74a642123089c6e246f2da616e0c0bba2f16168d2c15c9"
|
|
286
286
|
},
|
|
287
287
|
{
|
|
288
288
|
"source": "tools/package/check_baseline.py",
|
|
@@ -190,6 +190,8 @@ Build-only mode:
|
|
|
190
190
|
python .\tools\lean-docs\docs-site.py build --root . --out .\.lean\docs-site
|
|
191
191
|
```
|
|
192
192
|
|
|
193
|
+
The builder protects existing data: it refuses the repository root, paths overlapping either lab cache, linked paths, and non-empty output directories without its `.lean-docs-site` ownership marker. Existing unmarked output from an older viewer must be inspected and moved or a new output path selected before rebuilding.
|
|
194
|
+
|
|
193
195
|
The viewer should:
|
|
194
196
|
|
|
195
197
|
- bind to loopback only;
|
|
@@ -13,13 +13,15 @@ import webbrowser
|
|
|
13
13
|
from datetime import datetime, timezone
|
|
14
14
|
from functools import partial
|
|
15
15
|
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
-
from pathlib import Path
|
|
16
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
17
17
|
from typing import Any
|
|
18
18
|
|
|
19
19
|
IGNORE_DIRS = {".git", ".lean", "node_modules", "bin", "obj", "dist", "build", "target", ".venv", "venv", "__pycache__"}
|
|
20
20
|
DEFAULT_CACHE = Path(".lean/docs-cache")
|
|
21
21
|
DEFAULT_SITE = Path(".lean/docs-site")
|
|
22
22
|
MANIFEST = Path("docs/lean-docs.json")
|
|
23
|
+
SITE_MARKER = ".lean-docs-site"
|
|
24
|
+
SITE_MARKER_CONTENT = "lean-docs-site\n"
|
|
23
25
|
|
|
24
26
|
|
|
25
27
|
class DocsSiteError(RuntimeError):
|
|
@@ -86,17 +88,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
|
|
|
86
88
|
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
87
89
|
warnings.append(f"docs/lean-docs.json documents[{index}] is invalid")
|
|
88
90
|
continue
|
|
89
|
-
|
|
90
|
-
|
|
91
|
+
raw_path = item["path"]
|
|
92
|
+
windows_path = PureWindowsPath(raw_path)
|
|
93
|
+
relative_path = PurePosixPath(raw_path.replace("\\", "/"))
|
|
94
|
+
if (
|
|
95
|
+
relative_path.is_absolute()
|
|
96
|
+
or windows_path.root
|
|
97
|
+
or windows_path.drive
|
|
98
|
+
or ".." in relative_path.parts
|
|
99
|
+
):
|
|
91
100
|
warnings.append(f"Skipped unsafe documentation path: {item['path']}")
|
|
92
101
|
continue
|
|
102
|
+
rel = relative_path.as_posix()
|
|
93
103
|
path = root / rel
|
|
104
|
+
try:
|
|
105
|
+
resolved_path = root / _safe_rel(root, path)
|
|
106
|
+
except DocsSiteError:
|
|
107
|
+
warnings.append(f"Skipped unsafe documentation path: {item['path']}")
|
|
108
|
+
continue
|
|
94
109
|
seen.add(rel)
|
|
95
|
-
if not
|
|
110
|
+
if not resolved_path.is_file():
|
|
96
111
|
if item.get("optional") is not True:
|
|
97
112
|
warnings.append(f"Listed documentation is not present: {rel}")
|
|
98
113
|
continue
|
|
99
|
-
content =
|
|
114
|
+
content = resolved_path.read_text(encoding="utf-8", errors="replace")
|
|
100
115
|
docs.append(
|
|
101
116
|
{
|
|
102
117
|
"path": rel,
|
|
@@ -116,14 +131,30 @@ def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[li
|
|
|
116
131
|
candidates.append(path)
|
|
117
132
|
docs_root = root / "docs"
|
|
118
133
|
if docs_root.is_dir():
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
134
|
+
if _path_contains_link(docs_root):
|
|
135
|
+
warnings.append(f"Skipped unsafe documentation directory: {docs_root}")
|
|
136
|
+
else:
|
|
137
|
+
for base, dirs, names in os.walk(docs_root):
|
|
138
|
+
safe_dirs = []
|
|
139
|
+
for directory in dirs:
|
|
140
|
+
candidate = Path(base) / directory
|
|
141
|
+
if directory in IGNORE_DIRS:
|
|
142
|
+
continue
|
|
143
|
+
if _is_link(candidate):
|
|
144
|
+
warnings.append(f"Skipped unsafe documentation directory: {candidate}")
|
|
145
|
+
continue
|
|
146
|
+
safe_dirs.append(directory)
|
|
147
|
+
dirs[:] = safe_dirs
|
|
148
|
+
for name in names:
|
|
149
|
+
if name.lower().endswith(".md"):
|
|
150
|
+
candidates.append(Path(base) / name)
|
|
124
151
|
|
|
125
152
|
for path in sorted(candidates):
|
|
126
|
-
|
|
153
|
+
try:
|
|
154
|
+
rel = _safe_rel(root, path)
|
|
155
|
+
except DocsSiteError:
|
|
156
|
+
warnings.append(f"Skipped unsafe documentation path: {path}")
|
|
157
|
+
continue
|
|
127
158
|
if rel in seen:
|
|
128
159
|
continue
|
|
129
160
|
content = path.read_text(encoding="utf-8", errors="replace")
|
|
@@ -220,14 +251,113 @@ def _prepare_system_lab(root: Path) -> str | None:
|
|
|
220
251
|
return None
|
|
221
252
|
|
|
222
253
|
|
|
223
|
-
def
|
|
254
|
+
def _validate_lab_cache(root: Path, kind: str) -> Path:
|
|
224
255
|
source = root / DEFAULT_CACHE / kind
|
|
225
|
-
if
|
|
226
|
-
|
|
256
|
+
if _path_contains_link(source):
|
|
257
|
+
raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
|
|
258
|
+
if source.exists():
|
|
259
|
+
try:
|
|
260
|
+
source.resolve().relative_to(root)
|
|
261
|
+
except ValueError as exc:
|
|
262
|
+
raise DocsSiteError(f"Lab cache escapes repository root: {source}") from exc
|
|
263
|
+
if source.is_dir() and _directory_contains_link(source):
|
|
264
|
+
raise DocsSiteError(f"Lab cache contains a symlink or junction: {source}")
|
|
265
|
+
return source
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
def _copy_lab_cache(root: Path, out: Path, kind: str) -> None:
|
|
269
|
+
source = _validate_lab_cache(root, kind)
|
|
227
270
|
target = out / "labs" / kind
|
|
271
|
+
try:
|
|
272
|
+
target.resolve().relative_to(out)
|
|
273
|
+
except ValueError as exc:
|
|
274
|
+
raise DocsSiteError(f"Lab output escapes generated site directory: {target}") from exc
|
|
275
|
+
if _path_contains_link(target):
|
|
276
|
+
raise DocsSiteError(f"Lab output contains a symlink or junction: {target}")
|
|
228
277
|
if target.exists():
|
|
278
|
+
if target.is_symlink() or not target.is_dir():
|
|
279
|
+
raise DocsSiteError(f"Unexpected lab output path: {target}")
|
|
229
280
|
shutil.rmtree(target)
|
|
230
|
-
|
|
281
|
+
if source.is_dir():
|
|
282
|
+
shutil.copytree(source, target)
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def _is_link(path: Path) -> bool:
|
|
286
|
+
if path.is_symlink():
|
|
287
|
+
return True
|
|
288
|
+
is_junction = getattr(path, "is_junction", None)
|
|
289
|
+
return bool(is_junction and is_junction())
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
def _path_contains_link(path: Path) -> bool:
|
|
293
|
+
absolute = Path(os.path.abspath(path))
|
|
294
|
+
current = Path(absolute.anchor)
|
|
295
|
+
for part in absolute.parts[1:]:
|
|
296
|
+
current /= part
|
|
297
|
+
if _is_link(current):
|
|
298
|
+
return True
|
|
299
|
+
return False
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _paths_overlap(left: Path, right: Path) -> bool:
|
|
303
|
+
left = Path(os.path.abspath(left))
|
|
304
|
+
right = Path(os.path.abspath(right))
|
|
305
|
+
return left == right or left in right.parents or right in left.parents
|
|
306
|
+
|
|
307
|
+
|
|
308
|
+
def _directory_contains_link(root: Path) -> bool:
|
|
309
|
+
directories = [root]
|
|
310
|
+
while directories:
|
|
311
|
+
current = directories.pop()
|
|
312
|
+
with os.scandir(current) as entries:
|
|
313
|
+
for entry in entries:
|
|
314
|
+
path = Path(entry.path)
|
|
315
|
+
if _is_link(path):
|
|
316
|
+
return True
|
|
317
|
+
if entry.is_dir(follow_symlinks=False):
|
|
318
|
+
directories.append(path)
|
|
319
|
+
return False
|
|
320
|
+
|
|
321
|
+
|
|
322
|
+
def _prepare_output(root: Path, out: Path) -> None:
|
|
323
|
+
if _path_contains_link(out):
|
|
324
|
+
raise DocsSiteError(f"Refusing to use an output path containing a symlink or junction: {out}")
|
|
325
|
+
if out == root or out in root.parents:
|
|
326
|
+
raise DocsSiteError(f"Refusing to use the repository root or one of its parents as output: {out}")
|
|
327
|
+
for kind in ("system", "database"):
|
|
328
|
+
cache = root / DEFAULT_CACHE / kind
|
|
329
|
+
if _paths_overlap(out, cache):
|
|
330
|
+
raise DocsSiteError(f"Output path overlaps a lab cache: {cache}")
|
|
331
|
+
|
|
332
|
+
if out.exists():
|
|
333
|
+
if not out.is_dir():
|
|
334
|
+
raise DocsSiteError(f"Output path is not a directory: {out}")
|
|
335
|
+
marker = out / SITE_MARKER
|
|
336
|
+
if any(out.iterdir()):
|
|
337
|
+
if marker.is_symlink() or not marker.is_file():
|
|
338
|
+
raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
|
|
339
|
+
try:
|
|
340
|
+
marker_content = marker.read_text(encoding="utf-8")
|
|
341
|
+
except OSError as exc:
|
|
342
|
+
raise DocsSiteError(f"Unable to verify output ownership: {marker}") from exc
|
|
343
|
+
if marker_content != SITE_MARKER_CONTENT:
|
|
344
|
+
raise DocsSiteError(f"Refusing to overwrite an unowned output directory: {out}")
|
|
345
|
+
|
|
346
|
+
if _directory_contains_link(out):
|
|
347
|
+
raise DocsSiteError(f"Refusing to write through a symlink in output directory: {out}")
|
|
348
|
+
|
|
349
|
+
for name in (SITE_MARKER, "index.html", "site.json"):
|
|
350
|
+
path = out / name
|
|
351
|
+
if path.is_file():
|
|
352
|
+
try:
|
|
353
|
+
link_count = path.stat().st_nlink
|
|
354
|
+
except OSError as exc:
|
|
355
|
+
raise DocsSiteError(f"Unable to verify output file links: {path}") from exc
|
|
356
|
+
if link_count > 1:
|
|
357
|
+
raise DocsSiteError(f"Refusing to overwrite hard-linked output file: {path}")
|
|
358
|
+
|
|
359
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
360
|
+
(out / SITE_MARKER).write_text(SITE_MARKER_CONTENT, encoding="utf-8")
|
|
231
361
|
|
|
232
362
|
|
|
233
363
|
def _site_payload(root: Path) -> dict[str, Any]:
|
|
@@ -299,16 +429,16 @@ document.querySelector('#siteTitle').textContent=SITE.title;document.querySelect
|
|
|
299
429
|
|
|
300
430
|
def build(root: Path, out: Path, *, prepare_system_lab: bool = False) -> tuple[Path, dict[str, Any]]:
|
|
301
431
|
root = root.resolve()
|
|
302
|
-
out =
|
|
432
|
+
out = Path(os.path.abspath(out))
|
|
303
433
|
if not root.is_dir():
|
|
304
434
|
raise DocsSiteError(f"Repository root does not exist: {root}")
|
|
435
|
+
for kind in ("system", "database"):
|
|
436
|
+
_validate_lab_cache(root, kind)
|
|
437
|
+
_prepare_output(root, out)
|
|
305
438
|
prep_warning = _prepare_system_lab(root) if prepare_system_lab else None
|
|
306
439
|
payload = _site_payload(root)
|
|
307
440
|
if prep_warning:
|
|
308
441
|
payload["warnings"].append(prep_warning)
|
|
309
|
-
if out.exists():
|
|
310
|
-
shutil.rmtree(out)
|
|
311
|
-
out.mkdir(parents=True, exist_ok=True)
|
|
312
442
|
_copy_lab_cache(root, out, "system")
|
|
313
443
|
_copy_lab_cache(root, out, "database")
|
|
314
444
|
index = out / "index.html"
|
|
@@ -359,7 +489,7 @@ def parse_args() -> argparse.Namespace:
|
|
|
359
489
|
def main() -> int:
|
|
360
490
|
args = parse_args()
|
|
361
491
|
root = args.root.resolve()
|
|
362
|
-
out = args.out
|
|
492
|
+
out = args.out if args.out else root / DEFAULT_SITE
|
|
363
493
|
try:
|
|
364
494
|
if args.command == "build":
|
|
365
495
|
index, payload = build(root, out, prepare_system_lab=args.prepare_system_lab)
|