@mriekki/project-forge 0.0.0-stage → 2.0.0
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/README.md +134 -2
- package/assets/components.json +33 -0
- package/assets/core/distribution.json +66 -0
- package/assets/deployment/distribution.json +54 -0
- package/assets/generated/ASSEMBLY.json +557 -0
- package/assets/generated/core/core/agents/coach.agent.md +69 -0
- package/assets/generated/core/core/agents/reviewer.agent.md +85 -0
- package/assets/generated/core/core/instructions/project-forge-docs.instructions.md +45 -0
- package/assets/generated/core/core/instructions/project-forge-workflow.instructions.md +79 -0
- package/assets/generated/core/core/learning/CODING_DRILLS.md +35 -0
- package/assets/generated/core/core/learning/DIAGRAM_GUIDE.md +59 -0
- package/assets/generated/core/core/learning/ENGINEERING_LEARNING_WORKBOOK.md +108 -0
- package/assets/generated/core/core/learning/LEARNING_MAP.md +108 -0
- package/assets/generated/core/core/learning/LEARNING_PROGRESS.md +39 -0
- package/assets/generated/core/core/learning/RESOURCE_CATALOG.md +25 -0
- package/assets/generated/core/core/learning/SQL_DRILLS.md +90 -0
- package/assets/generated/core/core/learning/SYSTEM_DESIGN_DRILLS.md +301 -0
- package/assets/generated/core/core/process/COACHING_GUIDE.md +92 -0
- package/assets/generated/core/core/process/DATABASE_LEARNING_WORKBENCH.md +397 -0
- package/assets/generated/core/core/process/DEPLOYMENT_ENABLEMENT.md +166 -0
- package/assets/generated/core/core/process/GUIDED_NEXT_STEPS.md +48 -0
- package/assets/generated/core/core/process/HUMAN_GATES.md +83 -0
- package/assets/generated/core/core/process/INTERACTIVE_SESSION_GUIDE.md +64 -0
- package/assets/generated/core/core/process/PROJECT_DOCUMENTATION_FRAMEWORK.md +326 -0
- package/assets/generated/core/core/process/PROJECT_FORGE_ENGINEERING_GUIDE.md +308 -0
- package/assets/generated/core/core/process/PR_WORKFLOW.md +55 -0
- package/assets/generated/core/core/process/SETUP_ACCEPTANCE_CHECKLIST.md +72 -0
- package/assets/generated/core/core/process/SYSTEM_DESIGN_LEARNING_WORKBENCH.md +520 -0
- package/assets/generated/core/core/process/WORK_ITEM_MODEL.md +56 -0
- package/assets/generated/core/core/skills/cleanup-branches/SKILL.md +190 -0
- package/assets/generated/core/core/skills/coach/SKILL.md +174 -0
- package/assets/generated/core/core/skills/code-walkthrough/SKILL.md +197 -0
- package/assets/generated/core/core/skills/core-sync/SKILL.md +178 -0
- package/assets/generated/core/core/skills/db-lab/SKILL.md +245 -0
- package/assets/generated/core/core/skills/define-feature/SKILL.md +144 -0
- package/assets/generated/core/core/skills/deliver/SKILL.md +397 -0
- package/assets/generated/core/core/skills/deploy/SKILL.md +188 -0
- package/assets/generated/core/core/skills/deployment-readiness/SKILL.md +234 -0
- package/assets/generated/core/core/skills/developer-env-mode/SKILL.md +231 -0
- package/assets/generated/core/core/skills/docs/SKILL.md +116 -0
- package/assets/generated/core/core/skills/grill-me/SKILL.md +77 -0
- package/assets/generated/core/core/skills/grill-with-docs/SKILL.md +90 -0
- package/assets/generated/core/core/skills/learning-review/SKILL.md +90 -0
- package/assets/generated/core/core/skills/next-step/SKILL.md +221 -0
- package/assets/generated/core/core/skills/progress/SKILL.md +207 -0
- package/assets/generated/core/core/skills/project-forge-update/SKILL.md +228 -0
- package/assets/generated/core/core/skills/pull-latest/SKILL.md +166 -0
- package/assets/generated/core/core/skills/refine-requirements/SKILL.md +207 -0
- package/assets/generated/core/core/skills/setup/SKILL.md +362 -0
- package/assets/generated/core/core/skills/start-work/SKILL.md +190 -0
- package/assets/generated/core/core/skills/system-lab/SKILL.md +333 -0
- package/assets/generated/core/core/skills/tear-down/SKILL.md +140 -0
- package/assets/generated/core/core/skills/upstream-fix/SKILL.md +221 -0
- package/assets/generated/core/core/skills/wait-what/SKILL.md +86 -0
- package/assets/generated/core/core/templates/ADR_TEMPLATE.md +41 -0
- package/assets/generated/core/core/templates/FEATURE_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PRODUCT_BRIEF_TEMPLATE.md +27 -0
- package/assets/generated/core/core/templates/PULL_REQUEST_TEMPLATE.md +37 -0
- package/assets/generated/core/core/templates/REQUIREMENTS_TEMPLATE.md +17 -0
- package/assets/generated/core/profiles/greenfield/project-forge-workspace.json +12 -0
- package/assets/generated/core/tools/db_lab/sqlite_workbench.py +463 -0
- package/assets/generated/core/tools/docs_site/docs_site.py +512 -0
- package/assets/generated/core/tools/package/check_baseline.py +102 -0
- package/assets/generated/core/tools/package/check_upstream.py +171 -0
- package/assets/generated/core/tools/system_lab/system_workbench.py +243 -0
- package/assets/generated/deployment/distribution/project-forge-deploy.py +8 -0
- package/assets/generated/deployment/docs/DEVELOPER_ENVIRONMENTS.md +335 -0
- package/assets/generated/deployment/docs/ENVIRONMENT_PROOF.md +95 -0
- package/assets/generated/deployment/docs/WORKSPACE_INSTANCES.md +154 -0
- package/assets/generated/deployment/docs/images/developer-environment-journey.svg +56 -0
- package/assets/generated/deployment/docs/images/personal-environment-architecture.svg +54 -0
- package/assets/generated/deployment/docs/images/poc-environment-model.svg +61 -0
- package/assets/generated/deployment/src/project_forge_deployment/__init__.py +3 -0
- package/assets/generated/deployment/src/project_forge_deployment/__main__.py +3 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_cli.py +163 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime.py +455 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_remote.py +416 -0
- package/assets/generated/deployment/src/project_forge_deployment/app_runtime_workstation.py +136 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform.py +208 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_assets.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/box_platform_remote.py +271 -0
- package/assets/generated/deployment/src/project_forge_deployment/cli.py +437 -0
- package/assets/generated/deployment/src/project_forge_deployment/doctor.py +121 -0
- package/assets/generated/deployment/src/project_forge_deployment/entrypoint.py +37 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_cli.py +271 -0
- package/assets/generated/deployment/src/project_forge_deployment/environment_probe.py +228 -0
- package/assets/generated/deployment/src/project_forge_deployment/errors.py +5 -0
- package/assets/generated/deployment/src/project_forge_deployment/home_lab.py +328 -0
- package/assets/generated/deployment/src/project_forge_deployment/image_transfer.py +105 -0
- package/assets/generated/deployment/src/project_forge_deployment/keys.py +171 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_cli.py +189 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_runtime.py +526 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_spec.py +82 -0
- package/assets/generated/deployment/src/project_forge_deployment/local_sqlite.py +205 -0
- package/assets/generated/deployment/src/project_forge_deployment/portable_profile.py +181 -0
- package/assets/generated/deployment/src/project_forge_deployment/profile_migration.py +70 -0
- package/assets/generated/deployment/src/project_forge_deployment/profile_repository.py +348 -0
- package/assets/generated/deployment/src/project_forge_deployment/profiles.py +204 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_access.py +114 -0
- package/assets/generated/deployment/src/project_forge_deployment/registry_cli.py +51 -0
- package/assets/generated/deployment/src/project_forge_deployment/route_registry.py +367 -0
- package/assets/generated/deployment/src/project_forge_deployment/route_registry_remote.py +443 -0
- package/assets/generated/deployment/src/project_forge_deployment/security.py +290 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter.py +335 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_adapter_remote.py +340 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_cli.py +86 -0
- package/assets/generated/deployment/src/project_forge_deployment/sqlite_import_recovery.py +437 -0
- package/assets/generated/deployment/src/project_forge_deployment/ssh.py +119 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown.py +300 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown_cli.py +139 -0
- package/assets/generated/deployment/src/project_forge_deployment/teardown_remote.py +100 -0
- package/assets/generated/deployment/src/project_forge_deployment/trust.py +278 -0
- package/bin/project-forge.mjs +9 -0
- package/package.json +31 -4
- package/src/cli.mjs +150 -0
- package/src/engine.mjs +243 -0
- package/src/package-data.mjs +18 -0
|
@@ -0,0 +1,512 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import html
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
import shutil
|
|
10
|
+
import subprocess
|
|
11
|
+
import sys
|
|
12
|
+
import webbrowser
|
|
13
|
+
from datetime import datetime, timezone
|
|
14
|
+
from functools import partial
|
|
15
|
+
from http.server import SimpleHTTPRequestHandler, ThreadingHTTPServer
|
|
16
|
+
from pathlib import Path, PurePosixPath, PureWindowsPath
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
IGNORE_DIRS = {".git", ".project-forge", "node_modules", "bin", "obj", "dist", "build", "target", ".venv", "venv", "__pycache__"}
|
|
20
|
+
DEFAULT_CACHE = Path(".project-forge/docs-cache")
|
|
21
|
+
DEFAULT_SITE = Path(".project-forge/docs-site")
|
|
22
|
+
MANIFEST = Path("docs/project-forge-docs.json")
|
|
23
|
+
SITE_MARKER = ".project-forge-docs-site"
|
|
24
|
+
SITE_MARKER_CONTENT = "project-forge-docs-site\n"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class DocsSiteError(RuntimeError):
|
|
28
|
+
pass
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def _read_json(path: Path) -> dict[str, Any] | None:
|
|
32
|
+
try:
|
|
33
|
+
value = json.loads(path.read_text(encoding="utf-8"))
|
|
34
|
+
except FileNotFoundError:
|
|
35
|
+
return None
|
|
36
|
+
except json.JSONDecodeError as exc:
|
|
37
|
+
raise DocsSiteError(f"Invalid JSON in {path}: {exc}") from exc
|
|
38
|
+
if not isinstance(value, dict):
|
|
39
|
+
raise DocsSiteError(f"Expected an object in {path}")
|
|
40
|
+
return value
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def _git(root: Path, *args: str) -> str | None:
|
|
44
|
+
try:
|
|
45
|
+
result = subprocess.run(
|
|
46
|
+
["git", "-C", str(root), *args],
|
|
47
|
+
text=True,
|
|
48
|
+
encoding="utf-8",
|
|
49
|
+
errors="replace",
|
|
50
|
+
capture_output=True,
|
|
51
|
+
timeout=5,
|
|
52
|
+
check=False,
|
|
53
|
+
)
|
|
54
|
+
except (OSError, subprocess.SubprocessError):
|
|
55
|
+
return None
|
|
56
|
+
value = (result.stdout or "").strip()
|
|
57
|
+
return value if result.returncode == 0 and value else None
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def _safe_rel(root: Path, path: Path) -> str:
|
|
61
|
+
try:
|
|
62
|
+
return path.resolve().relative_to(root.resolve()).as_posix()
|
|
63
|
+
except ValueError as exc:
|
|
64
|
+
raise DocsSiteError(f"Documentation path escapes repository root: {path}") from exc
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def _markdown_title(content: str, fallback: str) -> str:
|
|
68
|
+
for line in content.splitlines():
|
|
69
|
+
match = re.match(r"^#\s+(.+?)\s*$", line)
|
|
70
|
+
if match:
|
|
71
|
+
return re.sub(r"[`*_]", "", match.group(1)).strip()
|
|
72
|
+
return fallback
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _manifest_documents(root: Path, manifest: dict[str, Any] | None) -> tuple[list[dict[str, Any]], list[str]]:
|
|
76
|
+
docs: list[dict[str, Any]] = []
|
|
77
|
+
warnings: list[str] = []
|
|
78
|
+
seen: set[str] = set()
|
|
79
|
+
|
|
80
|
+
if manifest is not None:
|
|
81
|
+
if manifest.get("schemaVersion") != 1:
|
|
82
|
+
warnings.append("docs/project-forge-docs.json schemaVersion is not 1")
|
|
83
|
+
raw = manifest.get("documents")
|
|
84
|
+
if raw is not None and not isinstance(raw, list):
|
|
85
|
+
warnings.append("docs/project-forge-docs.json documents must be an array")
|
|
86
|
+
raw = []
|
|
87
|
+
for index, item in enumerate(raw or []):
|
|
88
|
+
if not isinstance(item, dict) or not isinstance(item.get("path"), str):
|
|
89
|
+
warnings.append(f"docs/project-forge-docs.json documents[{index}] is invalid")
|
|
90
|
+
continue
|
|
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
|
+
):
|
|
100
|
+
warnings.append(f"Skipped unsafe documentation path: {item['path']}")
|
|
101
|
+
continue
|
|
102
|
+
rel = relative_path.as_posix()
|
|
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
|
|
109
|
+
seen.add(rel)
|
|
110
|
+
if not resolved_path.is_file():
|
|
111
|
+
if item.get("optional") is not True:
|
|
112
|
+
warnings.append(f"Listed documentation is not present: {rel}")
|
|
113
|
+
continue
|
|
114
|
+
content = resolved_path.read_text(encoding="utf-8", errors="replace")
|
|
115
|
+
docs.append(
|
|
116
|
+
{
|
|
117
|
+
"path": rel,
|
|
118
|
+
"title": str(item.get("title") or _markdown_title(content, path.stem)),
|
|
119
|
+
"section": str(item.get("section") or "Other"),
|
|
120
|
+
"type": str(item.get("type") or "markdown"),
|
|
121
|
+
"ownership": str(item.get("ownership") or "project"),
|
|
122
|
+
"authority": str(item.get("authority") or "reference"),
|
|
123
|
+
"content": content,
|
|
124
|
+
}
|
|
125
|
+
)
|
|
126
|
+
|
|
127
|
+
candidates: list[Path] = []
|
|
128
|
+
for name in ("PROJECT_BRIEF.md", "README.md"):
|
|
129
|
+
path = root / name
|
|
130
|
+
if path.is_file():
|
|
131
|
+
candidates.append(path)
|
|
132
|
+
docs_root = root / "docs"
|
|
133
|
+
if docs_root.is_dir():
|
|
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)
|
|
151
|
+
|
|
152
|
+
for path in sorted(candidates):
|
|
153
|
+
try:
|
|
154
|
+
rel = _safe_rel(root, path)
|
|
155
|
+
except DocsSiteError:
|
|
156
|
+
warnings.append(f"Skipped unsafe documentation path: {path}")
|
|
157
|
+
continue
|
|
158
|
+
if rel in seen:
|
|
159
|
+
continue
|
|
160
|
+
content = path.read_text(encoding="utf-8", errors="replace")
|
|
161
|
+
section = "Overview" if rel in {"README.md", "PROJECT_BRIEF.md", "docs/index.md"} else rel.split("/")[1].replace("-", " ").title() if rel.startswith("docs/") and "/" in rel else "Other"
|
|
162
|
+
docs.append(
|
|
163
|
+
{
|
|
164
|
+
"path": rel,
|
|
165
|
+
"title": _markdown_title(content, path.stem),
|
|
166
|
+
"section": section,
|
|
167
|
+
"type": "markdown",
|
|
168
|
+
"ownership": "unknown",
|
|
169
|
+
"authority": "reference",
|
|
170
|
+
"content": content,
|
|
171
|
+
}
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
docs.sort(key=lambda item: (item["section"].lower(), item["path"].lower()))
|
|
175
|
+
return docs, warnings
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _lab_status(root: Path, kind: str, current_commit: str | None) -> dict[str, Any]:
|
|
179
|
+
cache = root / DEFAULT_CACHE / kind
|
|
180
|
+
data_path = cache / "workbench.json"
|
|
181
|
+
index_path = cache / "index.html"
|
|
182
|
+
command = (
|
|
183
|
+
"python tools/project-forge-system-lab/system-workbench.py export --repo . --out .project-forge/docs-cache/system"
|
|
184
|
+
if kind == "system"
|
|
185
|
+
else "/db-lab"
|
|
186
|
+
)
|
|
187
|
+
result: dict[str, Any] = {
|
|
188
|
+
"kind": kind,
|
|
189
|
+
"status": "NOT GENERATED",
|
|
190
|
+
"detail": "No disposable local projection is available.",
|
|
191
|
+
"command": command,
|
|
192
|
+
"hasIndex": index_path.is_file(),
|
|
193
|
+
}
|
|
194
|
+
if not data_path.is_file():
|
|
195
|
+
return result
|
|
196
|
+
try:
|
|
197
|
+
data = json.loads(data_path.read_text(encoding="utf-8"))
|
|
198
|
+
except (OSError, json.JSONDecodeError):
|
|
199
|
+
result["status"] = "AVAILABLE / FRESHNESS UNKNOWN"
|
|
200
|
+
result["detail"] = "A local projection exists, but its evidence metadata could not be read."
|
|
201
|
+
return result
|
|
202
|
+
|
|
203
|
+
generated = data.get("generatedAt") if isinstance(data, dict) else None
|
|
204
|
+
if generated:
|
|
205
|
+
result["generatedAt"] = str(generated)
|
|
206
|
+
|
|
207
|
+
recorded_commit: str | None = None
|
|
208
|
+
if isinstance(data, dict):
|
|
209
|
+
repository = data.get("repository")
|
|
210
|
+
if isinstance(repository, dict) and repository.get("commit"):
|
|
211
|
+
recorded_commit = str(repository["commit"])
|
|
212
|
+
elif data.get("repositoryCommit"):
|
|
213
|
+
recorded_commit = str(data["repositoryCommit"])
|
|
214
|
+
|
|
215
|
+
if recorded_commit and current_commit:
|
|
216
|
+
result["recordedCommit"] = recorded_commit
|
|
217
|
+
if recorded_commit == current_commit:
|
|
218
|
+
result["status"] = "CURRENT"
|
|
219
|
+
result["detail"] = f"Projection evidence is bound to current commit {current_commit[:10]}."
|
|
220
|
+
else:
|
|
221
|
+
result["status"] = "STALE"
|
|
222
|
+
result["detail"] = f"Projection records {recorded_commit[:10]}; checkout is {current_commit[:10]}."
|
|
223
|
+
else:
|
|
224
|
+
result["status"] = "AVAILABLE / FRESHNESS UNKNOWN"
|
|
225
|
+
result["detail"] = "A projection exists, but there is not enough evidence to prove it matches the current checkout."
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def _prepare_system_lab(root: Path) -> str | None:
|
|
230
|
+
tool = root / "tools/project-forge-system-lab/system-workbench.py"
|
|
231
|
+
if not tool.is_file():
|
|
232
|
+
return None
|
|
233
|
+
out = root / DEFAULT_CACHE / "system"
|
|
234
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
235
|
+
try:
|
|
236
|
+
result = subprocess.run(
|
|
237
|
+
[sys.executable, str(tool), "export", "--repo", str(root), "--out", str(out)],
|
|
238
|
+
cwd=str(root),
|
|
239
|
+
text=True,
|
|
240
|
+
encoding="utf-8",
|
|
241
|
+
errors="replace",
|
|
242
|
+
capture_output=True,
|
|
243
|
+
timeout=45,
|
|
244
|
+
check=False,
|
|
245
|
+
)
|
|
246
|
+
except (OSError, subprocess.SubprocessError) as exc:
|
|
247
|
+
return f"System Lab preparation unavailable: {exc}"
|
|
248
|
+
if result.returncode != 0:
|
|
249
|
+
detail = (result.stderr or result.stdout or "").strip().splitlines()
|
|
250
|
+
return "System Lab preparation failed: " + (detail[-1] if detail else f"exit {result.returncode}")
|
|
251
|
+
return None
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _validate_lab_cache(root: Path, kind: str) -> Path:
|
|
255
|
+
source = root / DEFAULT_CACHE / kind
|
|
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)
|
|
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}")
|
|
277
|
+
if target.exists():
|
|
278
|
+
if target.is_symlink() or not target.is_dir():
|
|
279
|
+
raise DocsSiteError(f"Unexpected lab output path: {target}")
|
|
280
|
+
shutil.rmtree(target)
|
|
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")
|
|
361
|
+
|
|
362
|
+
|
|
363
|
+
def _site_payload(root: Path) -> dict[str, Any]:
|
|
364
|
+
manifest = _read_json(root / MANIFEST)
|
|
365
|
+
docs, warnings = _manifest_documents(root, manifest)
|
|
366
|
+
current_commit = _git(root, "rev-parse", "HEAD")
|
|
367
|
+
branch = _git(root, "branch", "--show-current")
|
|
368
|
+
site = manifest.get("site") if isinstance(manifest, dict) and isinstance(manifest.get("site"), dict) else {}
|
|
369
|
+
title = str(site.get("title") or root.name)
|
|
370
|
+
entry = str(site.get("entry") or ("PROJECT_BRIEF.md" if (root / "PROJECT_BRIEF.md").is_file() else "README.md"))
|
|
371
|
+
return {
|
|
372
|
+
"title": title,
|
|
373
|
+
"entry": entry,
|
|
374
|
+
"generatedAt": datetime.now(timezone.utc).isoformat(),
|
|
375
|
+
"repository": {"name": root.name, "commit": current_commit, "branch": branch},
|
|
376
|
+
"documents": docs,
|
|
377
|
+
"warnings": warnings,
|
|
378
|
+
"labs": {
|
|
379
|
+
"system": _lab_status(root, "system", current_commit),
|
|
380
|
+
"database": _lab_status(root, "database", current_commit),
|
|
381
|
+
},
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def _html_document(payload: dict[str, Any]) -> str:
|
|
386
|
+
data = json.dumps(payload, ensure_ascii=False).replace("</", "<\\/")
|
|
387
|
+
title = html.escape(str(payload["title"]))
|
|
388
|
+
return f'''<!doctype html>
|
|
389
|
+
<html lang="en">
|
|
390
|
+
<head>
|
|
391
|
+
<meta charset="utf-8">
|
|
392
|
+
<meta name="viewport" content="width=device-width,initial-scale=1">
|
|
393
|
+
<title>{title} — Project Docs</title>
|
|
394
|
+
<style>
|
|
395
|
+
:root{{color-scheme:light dark;font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;--line:#8885;--muted:#777;--panel:color-mix(in srgb,Canvas 96%,CanvasText 4%);--accent:LinkText}}
|
|
396
|
+
*{{box-sizing:border-box}} body{{margin:0;background:Canvas;color:CanvasText}} a{{color:LinkText}}
|
|
397
|
+
header{{position:sticky;top:0;z-index:5;display:flex;gap:12px;align-items:center;padding:10px 14px;border-bottom:1px solid var(--line);background:Canvas}}
|
|
398
|
+
header h1{{font-size:17px;margin:0;white-space:nowrap}} #search{{flex:1;max-width:620px;padding:7px 9px;font:inherit}}
|
|
399
|
+
.repo{{font-size:12px;color:var(--muted);margin-left:auto}}
|
|
400
|
+
.layout{{display:grid;grid-template-columns:300px minmax(0,1fr) 300px;min-height:calc(100vh - 52px)}}
|
|
401
|
+
nav{{border-right:1px solid var(--line);padding:12px;overflow:auto}} nav h3{{font-size:12px;text-transform:uppercase;letter-spacing:.08em;margin:16px 4px 6px;color:var(--muted)}}
|
|
402
|
+
nav button{{width:100%;text-align:left;border:0;background:none;color:inherit;padding:7px;border-radius:6px;cursor:pointer}} nav button:hover,nav button.active{{background:var(--panel)}} nav small{{display:block;color:var(--muted);overflow:hidden;text-overflow:ellipsis}}
|
|
403
|
+
main{{padding:28px clamp(18px,4vw,64px);min-width:0}} article{{max-width:980px;margin:auto;line-height:1.6}} article h1{{font-size:2rem}} article h2{{margin-top:2.2rem;border-bottom:1px solid var(--line);padding-bottom:.25rem}} article pre{{overflow:auto;padding:12px;border:1px solid var(--line);border-radius:8px;background:var(--panel)}} article code{{font-family:ui-monospace,SFMono-Regular,Consolas,monospace}} article :not(pre)>code{{padding:.1em .3em;border-radius:4px;background:var(--panel)}} article table{{border-collapse:collapse;width:100%;display:block;overflow:auto}} article th,article td{{border:1px solid var(--line);padding:7px 9px;text-align:left;vertical-align:top}} article blockquote{{border-left:4px solid var(--line);margin-left:0;padding-left:14px;color:var(--muted)}}
|
|
404
|
+
aside{{border-left:1px solid var(--line);padding:12px;overflow:auto}} .card{{border:1px solid var(--line);border-radius:8px;padding:10px;margin-bottom:10px;background:var(--panel)}} .card h3{{margin:0 0 6px;font-size:14px}} .status{{font-weight:700;font-size:12px}} .muted{{color:var(--muted)}} .badge{{display:inline-block;border:1px solid var(--line);border-radius:999px;padding:2px 6px;font-size:11px;margin-right:4px}} .warning{{border-left:4px solid #b8860b;padding:8px 10px;background:var(--panel);margin:10px 0}}
|
|
405
|
+
button.copy{{font:inherit;font-size:12px;padding:4px 7px}} .mermaid-lite{{overflow:auto;border:1px solid var(--line);border-radius:8px;padding:8px;background:var(--panel)}} .mermaid-lite svg{{min-width:680px;width:100%;height:auto}} .mermaid-fallback{{white-space:pre-wrap;font-family:ui-monospace,monospace;font-size:12px}}
|
|
406
|
+
@media(max-width:1100px){{.layout{{grid-template-columns:250px 1fr}}aside{{display:none}}}} @media(max-width:760px){{.layout{{display:block}}nav{{border-right:0;border-bottom:1px solid var(--line);max-height:280px}}header{{flex-wrap:wrap}}.repo{{width:100%;margin-left:0}}}}
|
|
407
|
+
</style>
|
|
408
|
+
</head>
|
|
409
|
+
<body>
|
|
410
|
+
<header><h1 id="siteTitle"></h1><input id="search" type="search" placeholder="Search project docs"><div class="repo" id="repo"></div></header>
|
|
411
|
+
<div class="layout"><nav id="nav"></nav><main><article id="doc"></article></main><aside id="side"></aside></div>
|
|
412
|
+
<script>
|
|
413
|
+
const SITE={data};
|
|
414
|
+
const state={{path:SITE.entry,query:''}};
|
|
415
|
+
const byPath=new Map(SITE.documents.map(d=>[d.path,d]));
|
|
416
|
+
function esc(v){{return String(v??'').replace(/[&<>"']/g,c=>({{'&':'&','<':'<','>':'>','"':'"',"'":'''}})[c])}}
|
|
417
|
+
function safeHref(v){{let s=String(v||'').trim();if(!s)return '#';if(/^https?:\/\//i.test(s)||/^mailto:/i.test(s)||/^#/.test(s)||/^[.\w\/-]+(?:#[\w-]+)?$/.test(s))return s;return '#'}}
|
|
418
|
+
function inline(v){{let s=esc(v);s=s.replace(/`([^`]+)`/g,'<code>$1</code>');s=s.replace(/\*\*([^*]+)\*\*/g,'<strong>$1</strong>');s=s.replace(/\*([^*]+)\*/g,'<em>$1</em>');s=s.replace(/\[([^\]]+)\]\(([^)]+)\)/g,(_,t,u)=>`<a href="${{esc(safeHref(u))}}" target="${{/^https?:/.test(u)?'_blank':'_self'}}">${{t}}</a>`);return s}}
|
|
419
|
+
function md(text){{let lines=String(text||'').replace(/\r/g,'').split('\n'),out=[],i=0;while(i<lines.length){{let l=lines[i];if(/^```/.test(l)){{let lang=l.slice(3).trim(),buf=[];i++;while(i<lines.length&&!/^```/.test(lines[i]))buf.push(lines[i++]);i++;let src=buf.join('\n');if(lang==='mermaid')out.push(`<div class="mermaid-lite" data-mermaid="${{esc(src)}}"></div>`);else out.push(`<pre><code>${{esc(src)}}</code></pre>`);continue}}if(/^\|.*\|\s*$/.test(l)&&i+1<lines.length&&/^\|?\s*:?-+/.test(lines[i+1])){{let rows=[];rows.push(l);i+=2;while(i<lines.length&&/^\|.*\|\s*$/.test(lines[i]))rows.push(lines[i++]);let cells=x=>x.trim().replace(/^\||\|$/g,'').split('|').map(c=>c.trim());let h=cells(rows[0]);out.push('<table><thead><tr>'+h.map(c=>`<th>${{inline(c)}}</th>`).join('')+'</tr></thead><tbody>'+rows.slice(1).map(r=>'<tr>'+cells(r).map(c=>`<td>${{inline(c)}}</td>`).join('')+'</tr>').join('')+'</tbody></table>');continue}}let m=l.match(/^(#{{1,6}})\s+(.+)$/);if(m){{let n=m[1].length;out.push(`<h${{n}}>${{inline(m[2])}}</h${{n}}>`);i++;continue}}if(/^---+\s*$/.test(l)){{out.push('<hr>');i++;continue}}if(/^>\s?/.test(l)){{let b=[];while(i<lines.length&&/^>\s?/.test(lines[i]))b.push(lines[i++].replace(/^>\s?/,''));out.push(`<blockquote>${{md(b.join('\n'))}}</blockquote>`);continue}}if(/^[-*+]\s+/.test(l)){{let a=[];while(i<lines.length&&/^[-*+]\s+/.test(lines[i]))a.push(lines[i++].replace(/^[-*+]\s+/,''));out.push('<ul>'+a.map(x=>`<li>${{inline(x)}}</li>`).join('')+'</ul>');continue}}if(/^\d+\.\s+/.test(l)){{let a=[];while(i<lines.length&&/^\d+\.\s+/.test(lines[i]))a.push(lines[i++].replace(/^\d+\.\s+/,''));out.push('<ol>'+a.map(x=>`<li>${{inline(x)}}</li>`).join('')+'</ol>');continue}}if(!l.trim()){{i++;continue}}let p=[l];i++;while(i<lines.length&&lines[i].trim()&&!/^(#|```|>|[-*+]\s+|\d+\.\s+)/.test(lines[i])&&!/^\|.*\|\s*$/.test(lines[i]))p.push(lines[i++]);out.push(`<p>${{inline(p.join(' '))}}</p>`);}}return out.join('\n')}}
|
|
420
|
+
function mermaidLite(el){{let src=el.dataset.mermaid||'',first=src.split('\n').find(x=>x.trim())||'';let mm=first.match(/^\s*flowchart\s+(LR|TD)/i);if(!mm){{el.innerHTML=`<div class="mermaid-fallback">${{esc(src)}}</div>`;return}}let dir=mm[1].toUpperCase(),nodes=new Map(),edges=[];for(let raw of src.split('\n').slice(1)){{let l=raw.trim();if(!l||l.startsWith('%%'))continue;let re=/([A-Za-z0-9_-]+)(?:\[([^\]]+)\])?\s*--+>?\s*([A-Za-z0-9_-]+)(?:\[([^\]]+)\])?/;let x=l.match(re);if(x){{nodes.set(x[1],(x[2]||nodes.get(x[1])||x[1]).replace(/<br\s*\/?>/gi,' / '));nodes.set(x[3],(x[4]||nodes.get(x[3])||x[3]).replace(/<br\s*\/?>/gi,' / '));edges.push([x[1],x[3]]);continue}}let n=l.match(/^([A-Za-z0-9_-]+)\[([^\]]+)\]/);if(n)nodes.set(n[1],n[2].replace(/<br\s*\/?>/gi,' / '))}}let ids=[...nodes.keys()];if(!ids.length){{el.innerHTML=`<div class="mermaid-fallback">${{esc(src)}}</div>`;return}}let w=dir==='LR'?Math.max(720,ids.length*190):900,h=dir==='LR'?220:Math.max(260,Math.ceil(ids.length/4)*130),pos=new Map();ids.forEach((id,k)=>{{if(dir==='LR')pos.set(id,{{x:30+k*180,y:70}});else pos.set(id,{{x:30+(k%4)*215,y:35+Math.floor(k/4)*120}})}});let svg=[`<svg viewBox="0 0 ${{w}} ${{h}}" role="img"><defs><marker id="arr" markerWidth="8" markerHeight="8" refX="7" refY="4" orient="auto"><path d="M0,0 L8,4 L0,8 z" fill="currentColor"/></marker></defs>`];for(let [a,b] of edges){{let p=pos.get(a),q=pos.get(b);if(!p||!q)continue;svg.push(`<line x1="${{p.x+140}}" y1="${{p.y+35}}" x2="${{q.x}}" y2="${{q.y+35}}" stroke="currentColor" stroke-opacity=".55" marker-end="url(#arr)"/>`)}}for(let id of ids){{let p=pos.get(id),label=nodes.get(id);svg.push(`<rect x="${{p.x}}" y="${{p.y}}" width="140" height="70" rx="9" fill="Canvas" stroke="currentColor" stroke-opacity=".55"/><foreignObject x="${{p.x+8}}" y="${{p.y+8}}" width="124" height="54"><div xmlns="http://www.w3.org/1999/xhtml" style="font:12px system-ui;text-align:center;display:flex;align-items:center;justify-content:center;height:54px">${{esc(label)}}</div></foreignObject>`)}}svg.push('</svg>');el.innerHTML=svg.join('')}}
|
|
421
|
+
function nav(){{let q=state.query.toLowerCase(),groups=new Map();for(let d of SITE.documents){{if(q&&!`${{d.title}} ${{d.path}} ${{d.content}}`.toLowerCase().includes(q))continue;let a=groups.get(d.section)||[];a.push(d);groups.set(d.section,a)}}let n=document.querySelector('#nav');n.innerHTML='';for(let [g,ds] of groups){{let h=document.createElement('h3');h.textContent=g;n.appendChild(h);for(let d of ds){{let b=document.createElement('button');b.className=d.path===state.path?'active':'';b.innerHTML=`${{esc(d.title)}}<small>${{esc(d.path)}}</small>`;b.onclick=()=>{{state.path=d.path;render()}};n.appendChild(b)}}}}}}
|
|
422
|
+
function labCard(label,l){{let link=l.hasIndex?`<p><a href="labs/${{l.kind}}/index.html" target="_blank">Open existing projection</a></p>`:'';return `<div class="card"><h3>${{esc(label)}}</h3><div class="status">${{esc(l.status)}}</div><p class="muted">${{esc(l.detail)}}</p>${{link}}<p><code>${{esc(l.command)}}</code></p><button class="copy" data-copy="${{esc(l.command)}}">Copy command</button></div>`}}
|
|
423
|
+
function side(d){{let s=`<div class="card"><h3>Document</h3><span class="badge">${{esc(d.ownership)}}</span><span class="badge">${{esc(d.authority)}}</span><p class="muted">${{esc(d.path)}}</p></div>`;s+=labCard('System Lab',SITE.labs.system)+labCard('Database Lab',SITE.labs.database);if(SITE.warnings.length)s+=`<div class="card"><h3>Documentation checks</h3>${{SITE.warnings.map(x=>`<div class="warning">${{esc(x)}}</div>`).join('')}}</div>`;s+=`<div class="card"><h3>Learning</h3><p><code>/system-lab</code><br><code>/db-lab</code><br><code>/code-walkthrough</code><br><code>/learning-review</code><br><code>/coach</code></p></div>`;document.querySelector('#side').innerHTML=s;document.querySelectorAll('[data-copy]').forEach(b=>b.onclick=()=>navigator.clipboard?.writeText(b.dataset.copy))}}
|
|
424
|
+
function render(){{let d=byPath.get(state.path)||SITE.documents[0];if(!d){{document.querySelector('#doc').innerHTML='<h1>No Markdown documents found</h1>';return}}state.path=d.path;document.querySelector('#doc').innerHTML=md(d.content);document.querySelectorAll('.mermaid-lite').forEach(mermaidLite);nav();side(d);history.replaceState(null,'','#'+encodeURIComponent(d.path))}}
|
|
425
|
+
document.querySelector('#siteTitle').textContent=SITE.title;document.querySelector('#repo').textContent=`${{SITE.repository.branch||''}} ${{SITE.repository.commit?SITE.repository.commit.slice(0,10):'no git'}}`;document.querySelector('#search').oninput=e=>{{state.query=e.target.value;nav()}};let h=decodeURIComponent(location.hash.slice(1));if(byPath.has(h))state.path=h;render();
|
|
426
|
+
</script>
|
|
427
|
+
</body></html>'''
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def build(root: Path, out: Path, *, prepare_system_lab: bool = False) -> tuple[Path, dict[str, Any]]:
|
|
431
|
+
root = root.resolve()
|
|
432
|
+
out = Path(os.path.abspath(out))
|
|
433
|
+
if not root.is_dir():
|
|
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)
|
|
438
|
+
prep_warning = _prepare_system_lab(root) if prepare_system_lab else None
|
|
439
|
+
payload = _site_payload(root)
|
|
440
|
+
if prep_warning:
|
|
441
|
+
payload["warnings"].append(prep_warning)
|
|
442
|
+
_copy_lab_cache(root, out, "system")
|
|
443
|
+
_copy_lab_cache(root, out, "database")
|
|
444
|
+
index = out / "index.html"
|
|
445
|
+
index.write_text(_html_document(payload), encoding="utf-8")
|
|
446
|
+
(out / "site.json").write_text(json.dumps(payload, indent=2, ensure_ascii=False) + "\n", encoding="utf-8")
|
|
447
|
+
return index, payload
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def serve(root: Path, out: Path, port: int, *, open_browser: bool, prepare_system_lab: bool) -> int:
|
|
451
|
+
index, payload = build(root, out, prepare_system_lab=prepare_system_lab)
|
|
452
|
+
handler = partial(SimpleHTTPRequestHandler, directory=str(index.parent))
|
|
453
|
+
server = ThreadingHTTPServer(("127.0.0.1", port), handler)
|
|
454
|
+
actual_port = int(server.server_address[1])
|
|
455
|
+
url = f"http://127.0.0.1:{actual_port}/"
|
|
456
|
+
print(f"Project Forge Project Docs: {url}")
|
|
457
|
+
print(f"Documents: {len(payload['documents'])}")
|
|
458
|
+
print(f"System Lab: {payload['labs']['system']['status']}")
|
|
459
|
+
print(f"Database Lab: {payload['labs']['database']['status']}")
|
|
460
|
+
if open_browser:
|
|
461
|
+
webbrowser.open(url)
|
|
462
|
+
try:
|
|
463
|
+
server.serve_forever()
|
|
464
|
+
except KeyboardInterrupt:
|
|
465
|
+
pass
|
|
466
|
+
finally:
|
|
467
|
+
server.server_close()
|
|
468
|
+
return 0
|
|
469
|
+
|
|
470
|
+
|
|
471
|
+
def parse_args() -> argparse.Namespace:
|
|
472
|
+
parser = argparse.ArgumentParser(description="Build or serve the dependency-free Project Forge project documentation site.")
|
|
473
|
+
sub = parser.add_subparsers(dest="command", required=True)
|
|
474
|
+
|
|
475
|
+
build_parser = sub.add_parser("build")
|
|
476
|
+
build_parser.add_argument("--root", type=Path, default=Path("."))
|
|
477
|
+
build_parser.add_argument("--out", type=Path)
|
|
478
|
+
build_parser.add_argument("--prepare-system-lab", action="store_true")
|
|
479
|
+
|
|
480
|
+
serve_parser = sub.add_parser("serve")
|
|
481
|
+
serve_parser.add_argument("--root", type=Path, default=Path("."))
|
|
482
|
+
serve_parser.add_argument("--out", type=Path)
|
|
483
|
+
serve_parser.add_argument("--port", type=int, default=8765)
|
|
484
|
+
serve_parser.add_argument("--open", action="store_true", dest="open_browser")
|
|
485
|
+
serve_parser.add_argument("--no-system-lab", action="store_true")
|
|
486
|
+
return parser.parse_args()
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
def main() -> int:
|
|
490
|
+
args = parse_args()
|
|
491
|
+
root = args.root.resolve()
|
|
492
|
+
out = args.out if args.out else root / DEFAULT_SITE
|
|
493
|
+
try:
|
|
494
|
+
if args.command == "build":
|
|
495
|
+
index, payload = build(root, out, prepare_system_lab=args.prepare_system_lab)
|
|
496
|
+
print(f"Project Forge Project Docs built: {index}")
|
|
497
|
+
print(f"Documents: {len(payload['documents'])}")
|
|
498
|
+
return 0
|
|
499
|
+
return serve(
|
|
500
|
+
root,
|
|
501
|
+
out,
|
|
502
|
+
args.port,
|
|
503
|
+
open_browser=args.open_browser,
|
|
504
|
+
prepare_system_lab=not args.no_system_lab,
|
|
505
|
+
)
|
|
506
|
+
except DocsSiteError as exc:
|
|
507
|
+
print(f"ERROR: {exc}", file=sys.stderr)
|
|
508
|
+
return 2
|
|
509
|
+
|
|
510
|
+
|
|
511
|
+
if __name__ == "__main__":
|
|
512
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
BASELINE_FILENAME = "CORE_BASELINE.json"
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sha256_file(path: Path) -> str:
|
|
13
|
+
digest = hashlib.sha256()
|
|
14
|
+
with path.open("rb") as handle:
|
|
15
|
+
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
|
|
16
|
+
digest.update(chunk)
|
|
17
|
+
return digest.hexdigest()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def resolve_managed_path(root: Path, rel: str) -> Path:
|
|
21
|
+
candidate = Path(rel)
|
|
22
|
+
if candidate.is_absolute() or ".." in candidate.parts:
|
|
23
|
+
raise ValueError(f"unsafe managed path: {rel}")
|
|
24
|
+
resolved = (root / candidate).resolve()
|
|
25
|
+
try:
|
|
26
|
+
resolved.relative_to(root)
|
|
27
|
+
except ValueError as exc:
|
|
28
|
+
raise ValueError(f"managed path escapes repository root: {rel}") from exc
|
|
29
|
+
return resolved
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def verify(root: Path, baseline_path: Path | None = None) -> int:
|
|
33
|
+
root = root.resolve()
|
|
34
|
+
baseline_path = baseline_path.resolve() if baseline_path else root / BASELINE_FILENAME
|
|
35
|
+
if not baseline_path.is_file():
|
|
36
|
+
print(f"MISSING baseline: {baseline_path}")
|
|
37
|
+
return 2
|
|
38
|
+
|
|
39
|
+
try:
|
|
40
|
+
baseline = json.loads(baseline_path.read_text(encoding="utf-8"))
|
|
41
|
+
except json.JSONDecodeError as exc:
|
|
42
|
+
print(f"INVALID baseline JSON: {exc}")
|
|
43
|
+
return 2
|
|
44
|
+
|
|
45
|
+
if baseline.get("schemaVersion") != 1:
|
|
46
|
+
print(f"UNSUPPORTED baseline schemaVersion: {baseline.get('schemaVersion')}")
|
|
47
|
+
return 2
|
|
48
|
+
|
|
49
|
+
managed = baseline.get("managedPaths")
|
|
50
|
+
if not isinstance(managed, list) or not managed:
|
|
51
|
+
print("INVALID baseline: managedPaths is missing or empty")
|
|
52
|
+
return 2
|
|
53
|
+
|
|
54
|
+
failures = 0
|
|
55
|
+
seen: set[str] = set()
|
|
56
|
+
for item in managed:
|
|
57
|
+
rel = item.get("path")
|
|
58
|
+
expected = item.get("sha256")
|
|
59
|
+
if not rel or not expected:
|
|
60
|
+
print(f"INVALID managed path entry: {item!r}")
|
|
61
|
+
failures += 1
|
|
62
|
+
continue
|
|
63
|
+
if rel in seen:
|
|
64
|
+
print(f"INVALID duplicate managed path: {rel}")
|
|
65
|
+
failures += 1
|
|
66
|
+
continue
|
|
67
|
+
seen.add(rel)
|
|
68
|
+
try:
|
|
69
|
+
path = resolve_managed_path(root, rel)
|
|
70
|
+
except ValueError as exc:
|
|
71
|
+
print(f"INVALID {exc}")
|
|
72
|
+
failures += 1
|
|
73
|
+
continue
|
|
74
|
+
if not path.is_file():
|
|
75
|
+
print(f"MISSING {rel}")
|
|
76
|
+
failures += 1
|
|
77
|
+
continue
|
|
78
|
+
actual = sha256_file(path)
|
|
79
|
+
if actual != expected:
|
|
80
|
+
print(f"DRIFT {rel}")
|
|
81
|
+
failures += 1
|
|
82
|
+
else:
|
|
83
|
+
print(f"SAME {rel}")
|
|
84
|
+
|
|
85
|
+
print()
|
|
86
|
+
if failures:
|
|
87
|
+
print(f"Core baseline integrity: FAIL ({failures} path(s))")
|
|
88
|
+
return 1
|
|
89
|
+
print(f"Core baseline integrity: PASS ({len(managed)} managed path(s))")
|
|
90
|
+
return 0
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def main() -> int:
|
|
94
|
+
parser = argparse.ArgumentParser(description="Verify a consumer's Core-managed paths against CORE_BASELINE.json.")
|
|
95
|
+
parser.add_argument("--root", type=Path, default=Path.cwd(), help="Consumer repository root")
|
|
96
|
+
parser.add_argument("--baseline", type=Path, help="Optional explicit baseline file")
|
|
97
|
+
args = parser.parse_args()
|
|
98
|
+
return verify(args.root, args.baseline)
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
if __name__ == "__main__":
|
|
102
|
+
raise SystemExit(main())
|