@antoneeo/mkt-agentic-sdlc-skill 0.18.0 → 0.20.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/CHANGELOG.md +10 -0
- package/README.md +2 -0
- package/gemini-extension.json +1 -1
- package/package.json +6 -1
- package/skills/mkt-agentic-sdlc/SKILL.md +4 -3
- package/skills/mkt-agentic-sdlc/docs_site/app.js +1127 -0
- package/skills/mkt-agentic-sdlc/docs_site/index.html +17 -0
- package/skills/mkt-agentic-sdlc/docs_site/strings.js +323 -0
- package/skills/mkt-agentic-sdlc/docs_site/style.css +288 -0
- package/skills/mkt-agentic-sdlc/scripts/docs_site.py +1362 -0
- package/skills/mkt-agentic-sdlc/scripts/knowledge.py +20 -6
- package/skills/mkt-agentic-sdlc/scripts/mkt_check.py +9 -0
- package/skills/mkt-agentic-sdlc/scripts/sdlc_core.py +305 -0
- package/skills/mkt-agentic-sdlc/templates.md +12 -1
|
@@ -0,0 +1,1362 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# -*- coding: utf-8 -*-
|
|
3
|
+
"""The docs site (F-072): what the agent reads, made visible to the user.
|
|
4
|
+
|
|
5
|
+
python <skill_dir>/scripts/docs_site.py [--root <project>] [--docs-dir <name>]
|
|
6
|
+
[--out <folder>] [--open]
|
|
7
|
+
|
|
8
|
+
Turns a project's docs root into a static website opened from disk: what the
|
|
9
|
+
project is, what was done and when, what is open with its next step, and what is
|
|
10
|
+
known -- the same sources the agent reads to orient itself and resume work, no
|
|
11
|
+
more and no less. Read-only on the docs root, stdlib only, no network.
|
|
12
|
+
|
|
13
|
+
Shared spine file: byte-identical in every distribution of the family. It imports
|
|
14
|
+
sdlc_core and knowledge and changes neither. Listing documents, workstreams,
|
|
15
|
+
analyses, topics, claims, locators and the catalog goes through their readers; the
|
|
16
|
+
site adds what they do not offer -- a frontmatter split that keeps continuation
|
|
17
|
+
lines for display, the Vision's status line, and the `derived_from` ladder of
|
|
18
|
+
`kb_time_cycle` (a closure there), restated here with the inside-the-root rule. It is not named `site.py` -- that would shadow
|
|
19
|
+
Python's `site` module.
|
|
20
|
+
|
|
21
|
+
What it writes, outside the docs root: the page files (index.html, app.js,
|
|
22
|
+
style.css, strings.js) copied from `../docs_site/`, and `data/*.js` -- classic
|
|
23
|
+
scripts, because a page opened from disk cannot fetch JSON. All wording lives in
|
|
24
|
+
strings.js; this file emits codes, never interface text.
|
|
25
|
+
"""
|
|
26
|
+
import argparse
|
|
27
|
+
import html
|
|
28
|
+
import json
|
|
29
|
+
import math
|
|
30
|
+
import os
|
|
31
|
+
import posixpath
|
|
32
|
+
import re
|
|
33
|
+
import shutil
|
|
34
|
+
import subprocess
|
|
35
|
+
import sys
|
|
36
|
+
import unicodedata
|
|
37
|
+
import urllib.parse
|
|
38
|
+
from datetime import datetime
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
HERE = Path(__file__).resolve().parent
|
|
42
|
+
sys.path.insert(0, str(HERE))
|
|
43
|
+
|
|
44
|
+
import sdlc_core # noqa: E402
|
|
45
|
+
import knowledge # noqa: E402
|
|
46
|
+
|
|
47
|
+
PAGE_DIR = HERE.parent / "docs_site"
|
|
48
|
+
PAGE_FILES = ("index.html", "app.js", "style.css", "strings.js")
|
|
49
|
+
MARKER = ".docs-site-generated"
|
|
50
|
+
DEFAULT_OUT = ".docs-site"
|
|
51
|
+
|
|
52
|
+
# Status words -> group (Vision v9). A value marking a unit as available or next to
|
|
53
|
+
# take (READY, NEXT) is NOT a group: it falls under "other status", as written.
|
|
54
|
+
STATUS_GROUPS = {"BLOCKED": 1, "PLANNED": 2, "TODO": 2, "IN_PROGRESS": 3, "PROGRESS": 3,
|
|
55
|
+
"PAUSED": 4, "ON_HOLD": 4, "COMPLETED": 5, "DONE": 5, "CANCELLED": 6,
|
|
56
|
+
"ARCHIVED": 7}
|
|
57
|
+
OTHER_STATUS = 8
|
|
58
|
+
|
|
59
|
+
HEX12 = re.compile(r"^[0-9a-f]{12}$")
|
|
60
|
+
DASHES = re.compile("[‐-―−﹘﹣-]")
|
|
61
|
+
INVISIBLE = re.compile("[--]")
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
class SiteError(Exception):
|
|
65
|
+
"""A refusal: printed as one line, exit 2."""
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
# ------------------------------------------------------------------ reading
|
|
69
|
+
|
|
70
|
+
_UNREADABLE = []
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def _text(path):
|
|
74
|
+
"""The file's text, or None when it cannot be read (recorded, reported, exit 1)."""
|
|
75
|
+
try:
|
|
76
|
+
text = sdlc_core.read_text(path)
|
|
77
|
+
except OSError as exc:
|
|
78
|
+
_note_unreadable(path, exc)
|
|
79
|
+
return None
|
|
80
|
+
if any(Path(p) == Path(path) for p, _e in _UNREADABLE):
|
|
81
|
+
return None # the guard already read it as empty
|
|
82
|
+
return text
|
|
83
|
+
|
|
84
|
+
|
|
85
|
+
def _note_unreadable(path, exc):
|
|
86
|
+
if all(Path(p) != Path(path) for p, _e in _UNREADABLE):
|
|
87
|
+
_UNREADABLE.append((Path(path), exc))
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
class _GuardedReads:
|
|
91
|
+
"""For one run, every read the shared readers make goes through one guard: an
|
|
92
|
+
unreadable file is recorded and read as empty, so the run reports it by name
|
|
93
|
+
and still writes the site. The shared modules' code is untouched; only the
|
|
94
|
+
function their callers look up is wrapped, and restored on exit."""
|
|
95
|
+
|
|
96
|
+
def __enter__(self):
|
|
97
|
+
self.original = sdlc_core.read_text
|
|
98
|
+
original = self.original
|
|
99
|
+
|
|
100
|
+
def guarded(path):
|
|
101
|
+
try:
|
|
102
|
+
return original(path)
|
|
103
|
+
except OSError as exc:
|
|
104
|
+
_note_unreadable(path, exc)
|
|
105
|
+
return ""
|
|
106
|
+
sdlc_core.read_text = guarded
|
|
107
|
+
return self
|
|
108
|
+
|
|
109
|
+
def __exit__(self, *exc):
|
|
110
|
+
sdlc_core.read_text = self.original
|
|
111
|
+
return False
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
def split_frontmatter(text):
|
|
115
|
+
"""(meta pairs as written, body). Pairs keep continuation lines, joined."""
|
|
116
|
+
lines = text.split("\n")
|
|
117
|
+
if not lines or lines[0].strip() != "---":
|
|
118
|
+
return [], text
|
|
119
|
+
end = next((i for i in range(1, min(len(lines), 200)) if lines[i].strip() == "---"), None)
|
|
120
|
+
if end is None:
|
|
121
|
+
return [], text
|
|
122
|
+
pairs = []
|
|
123
|
+
for ln in lines[1:end]:
|
|
124
|
+
m = re.match(r"^([A-Za-z_][\w-]*):\s*(.*)$", ln)
|
|
125
|
+
if m:
|
|
126
|
+
pairs.append([m.group(1), m.group(2).strip()])
|
|
127
|
+
elif pairs and ln.strip():
|
|
128
|
+
item = ln.strip()
|
|
129
|
+
item = item[2:].strip() if item.startswith("- ") else item
|
|
130
|
+
pairs[-1][1] = (pairs[-1][1] + ", " + item) if pairs[-1][1] else item
|
|
131
|
+
return pairs, "\n".join(lines[end + 1:])
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def first_heading(body):
|
|
135
|
+
for ln in body.split("\n"):
|
|
136
|
+
if ln.startswith("# "):
|
|
137
|
+
return ln[2:].strip()
|
|
138
|
+
return None
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def normalize(s):
|
|
142
|
+
"""Search folding: case, accents, dash variants, invisible characters, spaces.
|
|
143
|
+
app.js carries the same function; the battery runs both on the same inputs."""
|
|
144
|
+
s = unicodedata.normalize("NFKD", s or "")
|
|
145
|
+
s = "".join(c for c in s if unicodedata.category(c) != "Mn")
|
|
146
|
+
s = s.lower()
|
|
147
|
+
s = DASHES.sub("-", s)
|
|
148
|
+
s = INVISIBLE.sub("", s)
|
|
149
|
+
return re.sub(r"\s+", " ", s).strip()
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def status_group(value):
|
|
153
|
+
return STATUS_GROUPS.get(str(value or "").strip().upper(), OTHER_STATUS)
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _v(meta, key):
|
|
157
|
+
return str(meta.get(key) or "").strip()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
# ---------------------------------------------------------------- markdown
|
|
161
|
+
# A formatter with its sanitizer built in: every byte of content is escaped, and
|
|
162
|
+
# the only markup in the output is the markup emitted here -- headings,
|
|
163
|
+
# paragraphs, lists, tables, emphasis, code, blockquotes, rules and links. A link
|
|
164
|
+
# survives only when relative, `#` or http(s); images become their alt text.
|
|
165
|
+
|
|
166
|
+
_SCHEME = re.compile(r"^[A-Za-z][A-Za-z0-9+.-]*:")
|
|
167
|
+
_LINK = re.compile(r"(!?)\[([^\]]*)\]\(\s*<?([^)\s>]*)>?(?:\s+\"[^\"]*\")?\s*\)")
|
|
168
|
+
_CODE = re.compile(r"(`+)(.+?)\1", re.S)
|
|
169
|
+
_AUTO = re.compile(r"<(https?://[^\s&]+)>")
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
class _Ctx:
|
|
173
|
+
def __init__(self, base="", facts=None, docs=None):
|
|
174
|
+
self.base = base # folder of the document, docs-root relative
|
|
175
|
+
self.facts = facts # set of fact ids, or None without a knowledge base
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def _esc(s):
|
|
179
|
+
return html.escape(s, quote=True)
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _href(target, ctx):
|
|
183
|
+
"""(href, external) for a link target, or None when it must not be a link."""
|
|
184
|
+
if target.startswith("#"):
|
|
185
|
+
return _esc(target), False
|
|
186
|
+
if re.match(r"^https?://", target, re.I):
|
|
187
|
+
return _esc(target), True
|
|
188
|
+
if _SCHEME.match(target) or target.startswith(("/", "\\")):
|
|
189
|
+
return None
|
|
190
|
+
path, _, _frag = target.partition("#")
|
|
191
|
+
if not path:
|
|
192
|
+
return None
|
|
193
|
+
rel = posixpath.normpath(posixpath.join(ctx.base, urllib.parse.unquote(path)))
|
|
194
|
+
if rel.startswith("..") or rel.startswith("/"):
|
|
195
|
+
return None
|
|
196
|
+
return "#/doc/" + urllib.parse.quote(rel, safe="/._-~"), False
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
def _inline(text, ctx, links=True):
|
|
200
|
+
out, pos = [], 0
|
|
201
|
+
for m in _CODE.finditer(text):
|
|
202
|
+
out.append(_inline_plain(text[pos:m.start()], ctx, links))
|
|
203
|
+
code = m.group(2).strip() if m.group(2).strip() else m.group(2)
|
|
204
|
+
if ctx.facts is not None and HEX12.match(code):
|
|
205
|
+
if code in ctx.facts:
|
|
206
|
+
out.append('<a class="factid" href="#/fact/%s"><code>%s</code></a>' % (code, code))
|
|
207
|
+
else:
|
|
208
|
+
out.append('<code class="notfact">%s</code>' % code)
|
|
209
|
+
else:
|
|
210
|
+
out.append("<code>%s</code>" % _esc(code))
|
|
211
|
+
pos = m.end()
|
|
212
|
+
out.append(_inline_plain(text[pos:], ctx, links))
|
|
213
|
+
return "".join(out)
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
def _emphasis(s):
|
|
217
|
+
s = re.sub(r"\*\*(?=\S)(.+?)(?<=\S)\*\*", r"<strong>\1</strong>", s)
|
|
218
|
+
s = re.sub(r"(?<![\w])__(?=\S)(.+?)(?<=\S)__(?![\w])", r"<strong>\1</strong>", s)
|
|
219
|
+
s = re.sub(r"(?<![\*\w])\*(?=[^\s*])(.+?)(?<=[^\s*])\*(?![\*\w])", r"<em>\1</em>", s)
|
|
220
|
+
s = re.sub(r"(?<![\w])_(?=\S)(.+?)(?<=\S)_(?![\w])", r"<em>\1</em>", s)
|
|
221
|
+
return s
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _inline_plain(text, ctx, links):
|
|
225
|
+
if not text:
|
|
226
|
+
return ""
|
|
227
|
+
text = text.replace("\x00", "") # content can never address a held piece of markup
|
|
228
|
+
held = []
|
|
229
|
+
|
|
230
|
+
def hold(markup):
|
|
231
|
+
held.append(markup)
|
|
232
|
+
return "\x00%d\x00" % (len(held) - 1)
|
|
233
|
+
|
|
234
|
+
def link(m):
|
|
235
|
+
image, label, target = m.group(1), m.group(2), m.group(3)
|
|
236
|
+
if image:
|
|
237
|
+
return hold(_esc(label))
|
|
238
|
+
inner = _emphasis(_esc(label))
|
|
239
|
+
h = _href(target, ctx) if links else None
|
|
240
|
+
if h is None:
|
|
241
|
+
return hold(inner)
|
|
242
|
+
href, external = h
|
|
243
|
+
extra = ' target="_blank" rel="noopener noreferrer"' if external else ""
|
|
244
|
+
return hold('<a href="%s"%s>%s</a>' % (href, extra, inner))
|
|
245
|
+
|
|
246
|
+
text = _LINK.sub(link, text)
|
|
247
|
+
s = _emphasis(_esc(text))
|
|
248
|
+
s = _AUTO.sub(lambda m: '<a href="%s" target="_blank" rel="noopener noreferrer">%s</a>'
|
|
249
|
+
% (m.group(1), m.group(1)), s)
|
|
250
|
+
return re.sub("\x00(\\d+)\x00", lambda m: held[int(m.group(1))], s)
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _slugify(text):
|
|
254
|
+
s = re.sub(r"[^\w\- ]", "", text.lower()).strip()
|
|
255
|
+
return re.sub(r"\s+", "-", s)
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
_LIST = re.compile(r"^(\s*)([-*+]|\d+[.)])\s+(.*)$")
|
|
259
|
+
_HR = re.compile(r"^\s{0,3}([-*_])(\s*\1){2,}\s*$")
|
|
260
|
+
_HEAD = re.compile(r"^(#{1,6})\s+(.*?)\s*#*\s*$")
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def _cells(line):
|
|
264
|
+
s = line.strip()
|
|
265
|
+
if s.startswith("|"):
|
|
266
|
+
s = s[1:]
|
|
267
|
+
if s.endswith("|") and not s.endswith("\\|"):
|
|
268
|
+
s = s[:-1]
|
|
269
|
+
return [c.strip().replace("\\|", "|") for c in re.split(r"(?<!\\)\|", s)]
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def _is_sep(line):
|
|
273
|
+
s = line.strip()
|
|
274
|
+
return s.startswith("|") and set(s) <= set("|-: ") and "-" in s
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _starts_block(lines, i):
|
|
278
|
+
ln = lines[i]
|
|
279
|
+
return (ln.strip().startswith("```") or _HEAD.match(ln) or _HR.match(ln)
|
|
280
|
+
or ln.lstrip().startswith(">") or _LIST.match(ln)
|
|
281
|
+
or (ln.strip().startswith("|") and i + 1 < len(lines) and _is_sep(lines[i + 1])))
|
|
282
|
+
|
|
283
|
+
|
|
284
|
+
def _blocks(lines, ctx):
|
|
285
|
+
out, i = [], 0
|
|
286
|
+
while i < len(lines):
|
|
287
|
+
ln = lines[i]
|
|
288
|
+
if not ln.strip():
|
|
289
|
+
i += 1
|
|
290
|
+
continue
|
|
291
|
+
if ln.strip().startswith("```"):
|
|
292
|
+
fence = ln.strip()[:3]
|
|
293
|
+
body, i = [], i + 1
|
|
294
|
+
while i < len(lines) and not lines[i].strip().startswith(fence):
|
|
295
|
+
body.append(lines[i])
|
|
296
|
+
i += 1
|
|
297
|
+
i += 1
|
|
298
|
+
out.append("<pre><code>%s</code></pre>" % _esc("\n".join(body)))
|
|
299
|
+
continue
|
|
300
|
+
m = _HEAD.match(ln)
|
|
301
|
+
if m:
|
|
302
|
+
n = len(m.group(1))
|
|
303
|
+
title = m.group(2)
|
|
304
|
+
out.append('<h%d id="%s">%s</h%d>' % (n, _esc(_slugify(title)), _inline(title, ctx), n))
|
|
305
|
+
i += 1
|
|
306
|
+
continue
|
|
307
|
+
if _HR.match(ln):
|
|
308
|
+
out.append("<hr>")
|
|
309
|
+
i += 1
|
|
310
|
+
continue
|
|
311
|
+
if ln.lstrip().startswith(">"):
|
|
312
|
+
quoted = []
|
|
313
|
+
while i < len(lines) and lines[i].lstrip().startswith(">"):
|
|
314
|
+
q = lines[i].lstrip()[1:]
|
|
315
|
+
quoted.append(q[1:] if q.startswith(" ") else q)
|
|
316
|
+
i += 1
|
|
317
|
+
out.append("<blockquote>%s</blockquote>" % _blocks(quoted, ctx))
|
|
318
|
+
continue
|
|
319
|
+
if ln.strip().startswith("|") and i + 1 < len(lines) and _is_sep(lines[i + 1]):
|
|
320
|
+
head = _cells(ln)
|
|
321
|
+
i += 2
|
|
322
|
+
rows = []
|
|
323
|
+
while i < len(lines) and lines[i].strip().startswith("|"):
|
|
324
|
+
rows.append(_cells(lines[i]))
|
|
325
|
+
i += 1
|
|
326
|
+
th = "".join("<th>%s</th>" % _inline(c, ctx) for c in head)
|
|
327
|
+
body = "".join("<tr>%s</tr>" % "".join("<td>%s</td>" % _inline(c, ctx) for c in r)
|
|
328
|
+
for r in rows)
|
|
329
|
+
out.append("<table><thead><tr>%s</tr></thead><tbody>%s</tbody></table>" % (th, body))
|
|
330
|
+
continue
|
|
331
|
+
if _LIST.match(ln):
|
|
332
|
+
i = _list(lines, i, ctx, out)
|
|
333
|
+
continue
|
|
334
|
+
para = [ln.strip()]
|
|
335
|
+
i += 1
|
|
336
|
+
while i < len(lines) and lines[i].strip() and not _starts_block(lines, i):
|
|
337
|
+
para.append(lines[i].strip())
|
|
338
|
+
i += 1
|
|
339
|
+
out.append("<p>%s</p>" % _inline(" ".join(para), ctx))
|
|
340
|
+
return "".join(out)
|
|
341
|
+
|
|
342
|
+
|
|
343
|
+
def _list(lines, i, ctx, out):
|
|
344
|
+
first = _LIST.match(lines[i])
|
|
345
|
+
indent = len(first.group(1))
|
|
346
|
+
ordered = first.group(2)[0].isdigit()
|
|
347
|
+
items = []
|
|
348
|
+
while i < len(lines):
|
|
349
|
+
m = _LIST.match(lines[i])
|
|
350
|
+
if m and len(m.group(1)) == indent:
|
|
351
|
+
items.append([m.group(3)])
|
|
352
|
+
i += 1
|
|
353
|
+
continue
|
|
354
|
+
if not lines[i].strip():
|
|
355
|
+
nxt = i + 1
|
|
356
|
+
if nxt < len(lines) and lines[nxt].strip() and (
|
|
357
|
+
len(lines[nxt]) - len(lines[nxt].lstrip()) > indent):
|
|
358
|
+
items[-1].append("")
|
|
359
|
+
i += 1
|
|
360
|
+
continue
|
|
361
|
+
break
|
|
362
|
+
lead = len(lines[i]) - len(lines[i].lstrip())
|
|
363
|
+
if lead > indent or (not m and not _starts_block(lines, i)):
|
|
364
|
+
items[-1].append(lines[i][min(lead, indent + 2):] if lead > indent else lines[i].strip())
|
|
365
|
+
i += 1
|
|
366
|
+
continue
|
|
367
|
+
break
|
|
368
|
+
tag = "ol" if ordered else "ul"
|
|
369
|
+
parts = []
|
|
370
|
+
for item in items:
|
|
371
|
+
head, rest = item[0], item[1:]
|
|
372
|
+
if any(_LIST.match(r) or not r.strip() for r in rest) or any(
|
|
373
|
+
r.strip().startswith(("```", ">", "|")) for r in rest):
|
|
374
|
+
cont = [head] + rest
|
|
375
|
+
k = 1
|
|
376
|
+
while k < len(cont) and cont[k].strip() and not _LIST.match(cont[k]):
|
|
377
|
+
k += 1
|
|
378
|
+
inner = "<span>%s</span>" % _inline(" ".join(c.strip() for c in cont[:k]), ctx)
|
|
379
|
+
inner += _blocks(cont[k:], ctx)
|
|
380
|
+
else:
|
|
381
|
+
inner = _inline(" ".join([head] + [r.strip() for r in rest]), ctx)
|
|
382
|
+
parts.append("<li>%s</li>" % inner)
|
|
383
|
+
out.append("<%s>%s</%s>" % (tag, "".join(parts), tag))
|
|
384
|
+
return i
|
|
385
|
+
|
|
386
|
+
|
|
387
|
+
def markdown(text, base="", facts=None):
|
|
388
|
+
"""Markdown -> sanitized HTML. `base`: the document's folder, for relative links;
|
|
389
|
+
`facts`: the fact ids, when a knowledge base is present (12-hex tokens link)."""
|
|
390
|
+
return _blocks((text or "").replace("\r\n", "\n").split("\n"), _Ctx(base, facts))
|
|
391
|
+
|
|
392
|
+
|
|
393
|
+
# ------------------------------------------------------------ project facts
|
|
394
|
+
|
|
395
|
+
def resolve_root(args):
|
|
396
|
+
"""(project, docs, name) as every entry point resolves them; SiteError otherwise."""
|
|
397
|
+
try:
|
|
398
|
+
discovered, name = sdlc_core.resolve_docs_dir(args, args.root)
|
|
399
|
+
except sdlc_core.AmbiguousDocsRoot as exc:
|
|
400
|
+
raise SiteError(str(exc))
|
|
401
|
+
sdlc_core.set_docs_dir(name)
|
|
402
|
+
project = Path(args.root).resolve() if args.root else (
|
|
403
|
+
discovered or sdlc_core.find_project_root())
|
|
404
|
+
docs = project / name
|
|
405
|
+
if not docs.is_dir():
|
|
406
|
+
raise SiteError("%s not found: refusing to generate a site here. Run the skill's "
|
|
407
|
+
"init first, or pass --root <project_root>." % docs)
|
|
408
|
+
return project, docs, name
|
|
409
|
+
|
|
410
|
+
|
|
411
|
+
def project_lens(project, name):
|
|
412
|
+
if name == "mkt_docs":
|
|
413
|
+
return "marketing"
|
|
414
|
+
return sdlc_core.project_default_domain(project)
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def units_page(lens):
|
|
418
|
+
return {"marketing": None, "course": "courses"}.get(lens, "features")
|
|
419
|
+
|
|
420
|
+
|
|
421
|
+
def vision_status(pairs, body):
|
|
422
|
+
for k, v in pairs:
|
|
423
|
+
if k.lower() == "status":
|
|
424
|
+
return v
|
|
425
|
+
for ln in body.split("\n")[:20]:
|
|
426
|
+
m = re.match(r"^\**\s*status\s*:?\s*\**\s*:?\s*(.+?)\s*$", ln, re.I)
|
|
427
|
+
if m:
|
|
428
|
+
return m.group(1).strip()
|
|
429
|
+
return None
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def first_section(body, named=None):
|
|
433
|
+
"""(title, body) of the first `## ` section -- or of the first one whose title
|
|
434
|
+
starts with `named`, when given (None when there is no such section)."""
|
|
435
|
+
lines = body.split("\n")
|
|
436
|
+
for i, ln in enumerate(lines):
|
|
437
|
+
if ln.startswith("## ") and (named is None or ln[3:].strip().lower().startswith(named)):
|
|
438
|
+
rest = []
|
|
439
|
+
for nxt in lines[i + 1:]:
|
|
440
|
+
if nxt.startswith("## "):
|
|
441
|
+
break
|
|
442
|
+
rest.append(nxt)
|
|
443
|
+
return ln[3:].strip(), "\n".join(rest).strip()
|
|
444
|
+
return None, None
|
|
445
|
+
|
|
446
|
+
|
|
447
|
+
NORTH_STAR = "north star"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
def vision_lead(body):
|
|
451
|
+
"""(title, body, is_north_star): the Vision's North Star wherever it sits (R25),
|
|
452
|
+
else its first section, which is then not called a North Star."""
|
|
453
|
+
title, text = first_section(body, NORTH_STAR)
|
|
454
|
+
if title:
|
|
455
|
+
return title, text, True
|
|
456
|
+
title, text = first_section(body)
|
|
457
|
+
return title, text, False
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def opening_paragraph(body):
|
|
461
|
+
lines = body.split("\n")
|
|
462
|
+
start = next((i + 1 for i, ln in enumerate(lines) if ln.startswith("# ")), 0)
|
|
463
|
+
para = []
|
|
464
|
+
for ln in lines[start:]:
|
|
465
|
+
if not ln.strip():
|
|
466
|
+
if para:
|
|
467
|
+
break
|
|
468
|
+
continue
|
|
469
|
+
if ln.startswith("#"):
|
|
470
|
+
if para:
|
|
471
|
+
break
|
|
472
|
+
continue
|
|
473
|
+
para.append(ln)
|
|
474
|
+
return "\n".join(para)
|
|
475
|
+
|
|
476
|
+
|
|
477
|
+
# ------------------------------------------------------------ knowledge base
|
|
478
|
+
|
|
479
|
+
def resolve_derived(docs, entry, inside_only=True):
|
|
480
|
+
"""A `derived_from` entry -> docs-root-relative path, or None.
|
|
481
|
+
|
|
482
|
+
The resolution ladder of `knowledge.kb_time_cycle` (its `_resolve` closure):
|
|
483
|
+
normalize, then a path with `/` must exist as written, a bare name is looked
|
|
484
|
+
up in notes/ then given/. The site adds one rule: the result is followed only
|
|
485
|
+
if it ends inside the docs root."""
|
|
486
|
+
entry = posixpath.normpath(str(entry).strip())
|
|
487
|
+
found = None
|
|
488
|
+
if "/" in entry:
|
|
489
|
+
found = entry if (docs / entry).is_file() else None
|
|
490
|
+
else:
|
|
491
|
+
for base in ("corpus/notes/", "corpus/given/"):
|
|
492
|
+
if (docs / (base + entry)).is_file():
|
|
493
|
+
found = base + entry
|
|
494
|
+
break
|
|
495
|
+
if found is None or not inside_only:
|
|
496
|
+
return found
|
|
497
|
+
try:
|
|
498
|
+
return (docs / found).resolve().relative_to(docs.resolve()).as_posix()
|
|
499
|
+
except (ValueError, OSError):
|
|
500
|
+
return None
|
|
501
|
+
|
|
502
|
+
|
|
503
|
+
def _inside(docs, rel):
|
|
504
|
+
return sdlc_core.confine_under(docs, rel) is not None
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
class Passages:
|
|
508
|
+
"""Reads the span a locator addresses exactly as `kb_check_locator` reads it,
|
|
509
|
+
one text per file (cached). Returns (where, before, passage, after) or a reason."""
|
|
510
|
+
|
|
511
|
+
CONTEXT = 160
|
|
512
|
+
|
|
513
|
+
def __init__(self, docs):
|
|
514
|
+
self.docs = docs
|
|
515
|
+
self.texts = {}
|
|
516
|
+
self.check_cache = {}
|
|
517
|
+
|
|
518
|
+
def _t(self, path):
|
|
519
|
+
if path not in self.texts:
|
|
520
|
+
self.texts[path] = _text(path)
|
|
521
|
+
return self.texts[path]
|
|
522
|
+
|
|
523
|
+
def read(self, rel, loc):
|
|
524
|
+
target = self.docs / rel
|
|
525
|
+
m = knowledge.LOC_PAGE_RE.match(loc)
|
|
526
|
+
if m:
|
|
527
|
+
page, a, b = map(int, m.groups())
|
|
528
|
+
ext = target if target.suffix == ".txt" else target.with_suffix(".txt")
|
|
529
|
+
if not ext.is_file():
|
|
530
|
+
return {"reason": "no_file" if not target.exists() else "no_extraction"}
|
|
531
|
+
errs = []
|
|
532
|
+
knowledge.kb_check_locator(target, loc, "site", errs, self.check_cache)
|
|
533
|
+
if errs:
|
|
534
|
+
return {"reason": "no_span", "where": {"page": page}}
|
|
535
|
+
text = self._t(ext)
|
|
536
|
+
if text is None:
|
|
537
|
+
return {"reason": "unreadable"}
|
|
538
|
+
body = text.split("\f")[page - 1]
|
|
539
|
+
return {"where": {"page": page}, "before": body[max(0, a - self.CONTEXT):a],
|
|
540
|
+
"passage": body[a:b], "after": body[b:b + self.CONTEXT]}
|
|
541
|
+
m = knowledge.LOC_LINE_RE.match(loc)
|
|
542
|
+
if m:
|
|
543
|
+
a, b = map(int, m.groups())
|
|
544
|
+
if not target.is_file():
|
|
545
|
+
return {"reason": "no_file"}
|
|
546
|
+
errs = []
|
|
547
|
+
knowledge.kb_check_locator(target, loc, "site", errs, self.check_cache)
|
|
548
|
+
if errs:
|
|
549
|
+
return {"reason": "no_span", "where": {"lines": [a, b]}}
|
|
550
|
+
text = self._t(target)
|
|
551
|
+
if text is None:
|
|
552
|
+
return {"reason": "unreadable"}
|
|
553
|
+
lines = text.split("\n")
|
|
554
|
+
return {"where": {"lines": [a, b]}, "before": "\n".join(lines[max(0, a - 3):a - 1]),
|
|
555
|
+
"passage": "\n".join(lines[a - 1:b]), "after": "\n".join(lines[b:b + 2])}
|
|
556
|
+
if knowledge.LOC_CELL_RE.match(loc):
|
|
557
|
+
return {"reason": "cell", "where": {"cell": loc}}
|
|
558
|
+
return {"reason": "unknown_form"}
|
|
559
|
+
|
|
560
|
+
|
|
561
|
+
def _kind(row):
|
|
562
|
+
parts = (row.get("state") or "").split(None, 1)
|
|
563
|
+
return (parts[0] if parts else "OK").upper(), (parts[1] if len(parts) > 1 else "")
|
|
564
|
+
|
|
565
|
+
|
|
566
|
+
def _ids(rest):
|
|
567
|
+
return [t.strip() for t in rest.split(",") if t.strip()]
|
|
568
|
+
|
|
569
|
+
|
|
570
|
+
def chain_ends(start, by_id):
|
|
571
|
+
"""Every branch of a replacement chain, walked to its end: the current fact,
|
|
572
|
+
or a stated stop -- a loop or a missing id."""
|
|
573
|
+
ends = []
|
|
574
|
+
|
|
575
|
+
def walk(fid, seen):
|
|
576
|
+
if fid not in by_id:
|
|
577
|
+
end = {"stop": "missing", "id": fid}
|
|
578
|
+
elif fid in seen:
|
|
579
|
+
end = {"stop": "loop", "id": fid}
|
|
580
|
+
else:
|
|
581
|
+
kind, rest = _kind(by_id[fid])
|
|
582
|
+
if kind == "SUPERSEDED" and _ids(rest):
|
|
583
|
+
for nxt in _ids(rest):
|
|
584
|
+
walk(nxt, seen | {fid})
|
|
585
|
+
return
|
|
586
|
+
end = {"id": fid}
|
|
587
|
+
if end not in ends:
|
|
588
|
+
ends.append(end)
|
|
589
|
+
|
|
590
|
+
kind, rest = _kind(by_id[start])
|
|
591
|
+
for nxt in _ids(rest):
|
|
592
|
+
walk(nxt, frozenset({start}))
|
|
593
|
+
return ends
|
|
594
|
+
|
|
595
|
+
|
|
596
|
+
class Grounds:
|
|
597
|
+
"""What an inferred fact rests on: the anti-echo walk of the probe, following a
|
|
598
|
+
path only when it stays inside the docs root."""
|
|
599
|
+
|
|
600
|
+
def __init__(self, docs):
|
|
601
|
+
self.docs = docs
|
|
602
|
+
self.meta = {}
|
|
603
|
+
|
|
604
|
+
def _meta(self, rel):
|
|
605
|
+
if rel not in self.meta:
|
|
606
|
+
t = _text(self.docs / rel)
|
|
607
|
+
self.meta[rel] = sdlc_core.load_frontmatter(t.splitlines()) if t else {}
|
|
608
|
+
return self.meta[rel]
|
|
609
|
+
|
|
610
|
+
def leaves(self, rel, seen=frozenset()):
|
|
611
|
+
if rel is None:
|
|
612
|
+
return [{"kind": "untraceable"}]
|
|
613
|
+
if rel in seen:
|
|
614
|
+
return [{"kind": "untraceable", "path": rel}]
|
|
615
|
+
seen = seen | {rel}
|
|
616
|
+
if rel.startswith("corpus/given/"):
|
|
617
|
+
return [{"kind": "document", "path": rel}]
|
|
618
|
+
if not _inside(self.docs, rel) or not (self.docs / rel).is_file():
|
|
619
|
+
return [{"kind": "untraceable", "path": rel}]
|
|
620
|
+
meta = self._meta(rel)
|
|
621
|
+
origin = _v(meta, "origin").lower()
|
|
622
|
+
kind = {"elicited": "statement", "ruling": "decision", "imported": "kb"}.get(origin)
|
|
623
|
+
if kind:
|
|
624
|
+
return [{"kind": kind, "path": rel}]
|
|
625
|
+
entries = knowledge._kb_df_entries(meta.get("derived_from"))
|
|
626
|
+
if not entries:
|
|
627
|
+
return [{"kind": "decision" if meta.get("basis") else "untraceable", "path": rel}]
|
|
628
|
+
out = []
|
|
629
|
+
for e in entries:
|
|
630
|
+
target = resolve_derived(self.docs, e)
|
|
631
|
+
got = self.leaves(target, seen) if target else [{"kind": "untraceable", "path": rel,
|
|
632
|
+
"entry": str(e)}]
|
|
633
|
+
for g in got:
|
|
634
|
+
if g not in out:
|
|
635
|
+
out.append(g)
|
|
636
|
+
return out
|
|
637
|
+
|
|
638
|
+
|
|
639
|
+
def ground_class(leaves):
|
|
640
|
+
kinds = {g["kind"] for g in leaves}
|
|
641
|
+
if "statement" in kinds:
|
|
642
|
+
return "statement"
|
|
643
|
+
if "untraceable" in kinds:
|
|
644
|
+
return "untraceable"
|
|
645
|
+
if "kb" in kinds:
|
|
646
|
+
return "imported"
|
|
647
|
+
return "documents"
|
|
648
|
+
|
|
649
|
+
|
|
650
|
+
def topic_area(slug, live, all_nodes):
|
|
651
|
+
"""The root reached by first parents; a cycle, a missing or a merged parent -> None."""
|
|
652
|
+
seen, cur = set(), slug
|
|
653
|
+
while True:
|
|
654
|
+
if cur in seen or cur not in live:
|
|
655
|
+
return None
|
|
656
|
+
seen.add(cur)
|
|
657
|
+
parents = knowledge._as_list(live[cur]["meta"].get("parents"))
|
|
658
|
+
if not parents:
|
|
659
|
+
return cur
|
|
660
|
+
cur = parents[0]
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def build_kb(docs):
|
|
664
|
+
all_nodes = knowledge.kb_load_topics(docs)
|
|
665
|
+
if not all_nodes:
|
|
666
|
+
return None
|
|
667
|
+
tomb = {s for s, n in all_nodes.items() if _v(n["meta"], "status") == "SUPERSEDED"}
|
|
668
|
+
live = {s: n for s, n in all_nodes.items() if s not in tomb}
|
|
669
|
+
topics, facts, rows_of = {}, {}, {}
|
|
670
|
+
by_id = {}
|
|
671
|
+
for slug in sorted(all_nodes):
|
|
672
|
+
node = all_nodes[slug]
|
|
673
|
+
t = _text(docs / node["file"]) or ""
|
|
674
|
+
_pairs, body = split_frontmatter(t)
|
|
675
|
+
rows, _errs = knowledge.kb_parse_claims(t)
|
|
676
|
+
for r in rows:
|
|
677
|
+
r["_topic"] = slug
|
|
678
|
+
r["id"] = r["id"] or "%s:%d" % (slug, r["_line"])
|
|
679
|
+
by_id.setdefault(r["id"], r)
|
|
680
|
+
rows_of[slug] = rows
|
|
681
|
+
meta = node["meta"]
|
|
682
|
+
if slug in tomb:
|
|
683
|
+
topics[slug] = {"name": slug, "merged_into": _v(meta, "redirect_to")}
|
|
684
|
+
continue
|
|
685
|
+
topics[slug] = {
|
|
686
|
+
"name": first_heading(body) or slug,
|
|
687
|
+
"description": _v(meta, "description"),
|
|
688
|
+
"aka": knowledge._as_list(meta.get("synonyms")),
|
|
689
|
+
"gaps": knowledge._as_list(meta.get("gaps")),
|
|
690
|
+
"related": knowledge._as_list(meta.get("related")),
|
|
691
|
+
"parents": knowledge._as_list(meta.get("parents")),
|
|
692
|
+
"file": node["file"],
|
|
693
|
+
"facts": [r["id"] for r in rows],
|
|
694
|
+
"count": len(rows),
|
|
695
|
+
}
|
|
696
|
+
for slug, t in topics.items():
|
|
697
|
+
if "merged_into" in t:
|
|
698
|
+
continue
|
|
699
|
+
t["area"] = topic_area(slug, live, all_nodes)
|
|
700
|
+
t["children"] = sorted(s for s, o in topics.items()
|
|
701
|
+
if "merged_into" not in o and slug in o["parents"])
|
|
702
|
+
passages, grounds = Passages(docs), Grounds(docs)
|
|
703
|
+
sources_of_topic = {}
|
|
704
|
+
for slug in sorted(rows_of):
|
|
705
|
+
for r in rows_of[slug]:
|
|
706
|
+
kind, rest = _kind(r)
|
|
707
|
+
f = {"text": r["claim"], "valid": r["valid"], "qty": r["qty"], "about": r["about"],
|
|
708
|
+
"origin": (r["prov"] or "").strip().lower(), "topic": slug,
|
|
709
|
+
"state": {"SUPERSEDED": "superseded", "CONTESTED": "contested"}.get(kind, "ok")}
|
|
710
|
+
if kind == "SUPERSEDED":
|
|
711
|
+
f["successors"] = _ids(rest)
|
|
712
|
+
f["chain_ends"] = chain_ends(r["id"], by_id)
|
|
713
|
+
if kind == "CONTESTED":
|
|
714
|
+
f["with"] = _ids(rest)
|
|
715
|
+
srcs = []
|
|
716
|
+
for src in r["source"].split(";"):
|
|
717
|
+
src = src.strip()
|
|
718
|
+
if not src:
|
|
719
|
+
continue
|
|
720
|
+
rel, _, loc = src.rpartition("#") if "#" in src else (src, "", "")
|
|
721
|
+
rel = rel.strip()
|
|
722
|
+
entry = {"path": rel}
|
|
723
|
+
if not _inside(docs, rel):
|
|
724
|
+
entry["reason"] = "outside"
|
|
725
|
+
elif rel.startswith("corpus/given/"):
|
|
726
|
+
entry["source"] = rel[len("corpus/given/"):]
|
|
727
|
+
sources_of_topic.setdefault(entry["source"], set()).add(slug)
|
|
728
|
+
entry.update(passages.read(rel, loc.strip()) if loc else {"reason": "no_locator"})
|
|
729
|
+
elif loc:
|
|
730
|
+
entry.update(passages.read(rel, loc.strip()))
|
|
731
|
+
if loc:
|
|
732
|
+
entry["loc"] = loc.strip()
|
|
733
|
+
srcs.append(entry)
|
|
734
|
+
f["sources"] = srcs
|
|
735
|
+
if f["origin"] == "derived":
|
|
736
|
+
leaves = []
|
|
737
|
+
for rel in knowledge.kb_claim_sources(r):
|
|
738
|
+
rel = rel.strip()
|
|
739
|
+
for g in grounds.leaves(rel if _inside(docs, rel) else None):
|
|
740
|
+
if g not in leaves:
|
|
741
|
+
leaves.append(g)
|
|
742
|
+
f["grounds"] = leaves
|
|
743
|
+
f["ground_class"] = ground_class(leaves)
|
|
744
|
+
f["statement"] = f["origin"] == "elicited" or f.get("ground_class") == "statement"
|
|
745
|
+
facts.setdefault(r["id"], f)
|
|
746
|
+
sources = build_sources(docs, rows_of)
|
|
747
|
+
order = sorted(s for s in topics if "merged_into" not in topics[s])
|
|
748
|
+
return {"topics": topics, "facts": facts, "sources": sources, "order": order,
|
|
749
|
+
"map": build_map(topics, order, facts, sources)}
|
|
750
|
+
|
|
751
|
+
|
|
752
|
+
def build_sources(docs, rows_of):
|
|
753
|
+
given = docs / "corpus" / "given"
|
|
754
|
+
out = {}
|
|
755
|
+
if not given.is_dir():
|
|
756
|
+
return out
|
|
757
|
+
cited = {}
|
|
758
|
+
for slug, rows in rows_of.items():
|
|
759
|
+
for r in rows:
|
|
760
|
+
for rel in sorted(set(knowledge.kb_claim_sources(r))):
|
|
761
|
+
if rel.startswith("corpus/given/"):
|
|
762
|
+
name = rel[len("corpus/given/"):]
|
|
763
|
+
c = cited.setdefault(name, [0, set()])
|
|
764
|
+
c[0] += 1
|
|
765
|
+
c[1].add(slug)
|
|
766
|
+
for mp in sorted(given.glob("*.meta.md")):
|
|
767
|
+
name = mp.name[:-len(".meta.md")]
|
|
768
|
+
t = _text(mp) or ""
|
|
769
|
+
meta = sdlc_core.load_frontmatter(t.splitlines()) or {}
|
|
770
|
+
through = _v(meta, "extracted_through")
|
|
771
|
+
parsed = knowledge.kb_parse_extracted_through(through) if through else None
|
|
772
|
+
if not through:
|
|
773
|
+
read = {"kind": "unrecorded"}
|
|
774
|
+
elif parsed is None:
|
|
775
|
+
read = {"kind": "unreadable", "raw": through}
|
|
776
|
+
elif parsed[0] == "complete":
|
|
777
|
+
read = {"kind": "complete"}
|
|
778
|
+
else:
|
|
779
|
+
total = knowledge.kb_extraction_extent(given / name, parsed[0])
|
|
780
|
+
read = {"kind": "page" if parsed[0] == "p" else "line", "n": parsed[1]}
|
|
781
|
+
if total:
|
|
782
|
+
read["of"] = total
|
|
783
|
+
c = cited.get(name, [0, set()])
|
|
784
|
+
entry = {"kind": Path(name).suffix.lstrip(".").lower(), "date": _v(meta, "date"),
|
|
785
|
+
"read": read, "facts": c[0], "topics": sorted(c[1]),
|
|
786
|
+
"sidecar": "corpus/given/" + mp.name}
|
|
787
|
+
sup = knowledge.kb_unquote(meta.get("supersedes"))
|
|
788
|
+
if sup:
|
|
789
|
+
entry["newer_of"] = sup
|
|
790
|
+
out[name] = entry
|
|
791
|
+
for name, e in list(out.items()):
|
|
792
|
+
older = e.get("newer_of")
|
|
793
|
+
if older in out:
|
|
794
|
+
out[older]["older_of"] = name
|
|
795
|
+
return out
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def build_map(topics, order, facts, sources):
|
|
799
|
+
"""The three views' geometry, computed here: deterministic, laid out, framed.
|
|
800
|
+
|
|
801
|
+
Topics: a radial tree by area (first parents), unplaced topics on an outer
|
|
802
|
+
ring. Everything: each topic's facts on a small spiral around it, each source
|
|
803
|
+
document at the centre of the topics that use it."""
|
|
804
|
+
pos = {}
|
|
805
|
+
areas = [s for s in order if topics[s].get("area") == s]
|
|
806
|
+
kids = {s: [] for s in order}
|
|
807
|
+
for s in order:
|
|
808
|
+
area = topics[s].get("area")
|
|
809
|
+
if area and area != s:
|
|
810
|
+
parent = topics[s]["parents"][0]
|
|
811
|
+
if parent in kids:
|
|
812
|
+
kids[parent].append(s)
|
|
813
|
+
weight = {}
|
|
814
|
+
|
|
815
|
+
def w(s):
|
|
816
|
+
if s not in weight:
|
|
817
|
+
weight[s] = max(1, sum(w(k) for k in kids[s]))
|
|
818
|
+
return weight[s]
|
|
819
|
+
|
|
820
|
+
total = sum(w(a) for a in areas) or 1
|
|
821
|
+
ring = 260.0
|
|
822
|
+
|
|
823
|
+
def place(s, depth, a0, a1):
|
|
824
|
+
ang = (a0 + a1) / 2
|
|
825
|
+
r = depth * ring
|
|
826
|
+
pos[s] = (round(r * math.cos(ang), 1), round(r * math.sin(ang), 1))
|
|
827
|
+
cur = a0
|
|
828
|
+
for k in kids[s]:
|
|
829
|
+
span = (a1 - a0) * w(k) / w(s)
|
|
830
|
+
place(k, depth + 1, cur, cur + span)
|
|
831
|
+
cur += span
|
|
832
|
+
|
|
833
|
+
cur = 0.0
|
|
834
|
+
for a in areas:
|
|
835
|
+
span = 2 * math.pi * w(a) / total
|
|
836
|
+
place(a, 1 if len(areas) > 1 else 0, cur, cur + span)
|
|
837
|
+
cur += span
|
|
838
|
+
rest = [s for s in order if s not in pos]
|
|
839
|
+
if rest:
|
|
840
|
+
depth = max([0] + [math.hypot(*p) for p in pos.values()]) + ring
|
|
841
|
+
for i, s in enumerate(rest):
|
|
842
|
+
ang = 2 * math.pi * i / len(rest)
|
|
843
|
+
pos[s] = (round(depth * math.cos(ang), 1), round(depth * math.sin(ang), 1))
|
|
844
|
+
nodes = [{"id": s, "x": pos[s][0], "y": pos[s][1], "area": topics[s].get("area"),
|
|
845
|
+
"count": topics[s]["count"]} for s in order]
|
|
846
|
+
edges = []
|
|
847
|
+
for s in order:
|
|
848
|
+
for p in topics[s]["parents"]:
|
|
849
|
+
if p in pos:
|
|
850
|
+
edges.append({"a": s, "b": p, "kind": "part_of"})
|
|
851
|
+
for r in topics[s]["related"]:
|
|
852
|
+
if r in pos:
|
|
853
|
+
edges.append({"a": s, "b": r, "kind": "related"})
|
|
854
|
+
shared = {}
|
|
855
|
+
for name in sorted(sources):
|
|
856
|
+
ts = [t for t in sources[name]["topics"] if t in pos]
|
|
857
|
+
for i, a in enumerate(ts):
|
|
858
|
+
for b in ts[i + 1:]:
|
|
859
|
+
shared[(a, b)] = shared.get((a, b), 0) + 1
|
|
860
|
+
for (a, b), n in sorted(shared.items()):
|
|
861
|
+
edges.append({"a": a, "b": b, "kind": "shared", "n": n, "computed": True})
|
|
862
|
+
# everything: facts spiral around their topic; sources at their topics' centre
|
|
863
|
+
every = []
|
|
864
|
+
golden = math.pi * (3 - math.sqrt(5))
|
|
865
|
+
for s in order:
|
|
866
|
+
cx, cy = pos[s]
|
|
867
|
+
for i, fid in enumerate(topics[s]["facts"]):
|
|
868
|
+
r = 18 + 7 * math.sqrt(i + 1)
|
|
869
|
+
every.append({"id": fid, "kind": "fact", "x": round(cx + r * math.cos(i * golden), 1),
|
|
870
|
+
"y": round(cy + r * math.sin(i * golden), 1)})
|
|
871
|
+
for i, name in enumerate(sorted(sources)):
|
|
872
|
+
ts = [pos[t] for t in sources[name]["topics"] if t in pos]
|
|
873
|
+
if ts:
|
|
874
|
+
x, y = sum(p[0] for p in ts) / len(ts), sum(p[1] for p in ts) / len(ts)
|
|
875
|
+
else:
|
|
876
|
+
x, y = 0.0, 0.0
|
|
877
|
+
r = 40 + 4 * (i % 7)
|
|
878
|
+
every.append({"id": name, "kind": "source", "x": round(x + r * math.cos(i * golden), 1),
|
|
879
|
+
"y": round(y + r * math.sin(i * golden), 1)})
|
|
880
|
+
return {"nodes": nodes, "edges": edges, "every": every}
|
|
881
|
+
|
|
882
|
+
|
|
883
|
+
# ------------------------------------------------------------ the documents
|
|
884
|
+
|
|
885
|
+
def doc_page(docs, rel, facts):
|
|
886
|
+
t = _text(docs / rel)
|
|
887
|
+
if t is None:
|
|
888
|
+
return None
|
|
889
|
+
pairs, body = split_frontmatter(t)
|
|
890
|
+
base = posixpath.dirname(rel)
|
|
891
|
+
page = {"title": first_heading(body) or posixpath.basename(rel),
|
|
892
|
+
"meta": pairs, "html": markdown(body, base, facts),
|
|
893
|
+
"_n": normalize(" ".join([first_heading(body) or "", rel,
|
|
894
|
+
" ".join(v for _k, v in pairs), body]))}
|
|
895
|
+
if re.match(r"^solutions/ANALYSIS_[^/]+\.md$", rel):
|
|
896
|
+
page["chain"] = {"steps": analysis_chain(body), "_stem": posixpath.basename(rel)[9:-3]}
|
|
897
|
+
return page
|
|
898
|
+
|
|
899
|
+
|
|
900
|
+
# The chain an analysis is read through (R24): why (Vision), what (use cases, flows and
|
|
901
|
+
# interfaces, risks), how (solution study, design and tests), then the merge brief and
|
|
902
|
+
# E2E script, then the Diary. Headings are the validator's own wherever it names them --
|
|
903
|
+
# ANALYSIS_SECTIONS, SECURITY_SECTION and every lens's risk section in DOMAINS -- and the
|
|
904
|
+
# template's otherwise; a heading matches by prefix, case-insensitively.
|
|
905
|
+
CHAIN_STEPS = ("vision", "use_cases", "flows", "risks", "solution", "design",
|
|
906
|
+
"merge_tests", "diary")
|
|
907
|
+
|
|
908
|
+
|
|
909
|
+
def _chain_headings():
|
|
910
|
+
named = {pair[0]: pair for pair in sdlc_core.ANALYSIS_SECTIONS}
|
|
911
|
+
risks = list(sdlc_core.SECURITY_SECTION)
|
|
912
|
+
for lens in sdlc_core.DOMAINS.values():
|
|
913
|
+
risks += [h for h in lens["risk_section"] if h not in risks]
|
|
914
|
+
return (("vision", named["## Objective"] + named["## Feature Vision"] + ("## Vision Alignment",)),
|
|
915
|
+
("use_cases", ("## Use Cases",)),
|
|
916
|
+
("flows", ("## Functional Spec", "## Interface Contract")),
|
|
917
|
+
("risks", tuple(risks)),
|
|
918
|
+
("solution", ("## Capability Ledger",) + named["## Impact"]),
|
|
919
|
+
("design", named["## Action Plan"] + named["## Test Strategy"]),
|
|
920
|
+
("diary", named["## Diary"]))
|
|
921
|
+
|
|
922
|
+
|
|
923
|
+
def chain_step(heading):
|
|
924
|
+
"""The chain step a level-2 heading belongs to, or None (it stays where it is)."""
|
|
925
|
+
h = heading.strip().lower()
|
|
926
|
+
for step, aliases in _chain_headings():
|
|
927
|
+
if any(h.startswith(a.lower()) for a in aliases):
|
|
928
|
+
return step
|
|
929
|
+
return None
|
|
930
|
+
|
|
931
|
+
|
|
932
|
+
def analysis_chain(body):
|
|
933
|
+
"""[{step, sections: [{title, id}]}] in chain order; sections as written, in
|
|
934
|
+
document order. The id is the one the formatter gives the heading."""
|
|
935
|
+
found = {s: [] for s in CHAIN_STEPS}
|
|
936
|
+
fenced = False
|
|
937
|
+
for ln in body.split("\n"):
|
|
938
|
+
if ln.strip().startswith("```"):
|
|
939
|
+
fenced = not fenced
|
|
940
|
+
continue
|
|
941
|
+
if fenced or not ln.startswith("## "):
|
|
942
|
+
continue
|
|
943
|
+
step = chain_step(ln)
|
|
944
|
+
m = _HEAD.match(ln)
|
|
945
|
+
if step and m:
|
|
946
|
+
found[step].append({"title": m.group(2), "id": _slugify(m.group(2))})
|
|
947
|
+
return [{"step": s, "sections": found[s]} for s in CHAIN_STEPS]
|
|
948
|
+
|
|
949
|
+
|
|
950
|
+
def link_companions(docs, pages):
|
|
951
|
+
"""Each analysis's merge brief, E2E script and request record, where they exist,
|
|
952
|
+
and the project's North Star (R25)."""
|
|
953
|
+
north_star = None
|
|
954
|
+
vp = "vision/project_vision.md"
|
|
955
|
+
if vp in pages:
|
|
956
|
+
title, _body, is_ns = vision_lead(split_frontmatter(_text(docs / vp) or "")[1])
|
|
957
|
+
if is_ns:
|
|
958
|
+
north_star = {"path": vp, "id": _slugify(title)}
|
|
959
|
+
for rel, page in pages.items():
|
|
960
|
+
chain = page.get("chain")
|
|
961
|
+
if not chain:
|
|
962
|
+
continue
|
|
963
|
+
stem = chain.pop("_stem")
|
|
964
|
+
chain["north_star"] = north_star
|
|
965
|
+
chain["companions"] = {
|
|
966
|
+
key: path if path in pages else None
|
|
967
|
+
for key, path in (("merge", "solutions/MERGE_REVIEW_%s.md" % stem),
|
|
968
|
+
("e2e", "solutions/E2E_TEST_%s.md" % stem),
|
|
969
|
+
("request", "audit/requests/REQUEST_%s.md" % stem))}
|
|
970
|
+
|
|
971
|
+
|
|
972
|
+
def orientation_sources(docs):
|
|
973
|
+
"""{role: docs-relative path} of what the agent reads at session start, from
|
|
974
|
+
`sdlc_core.orient_docs` -- the one list. Roles are matched by position: a fifth
|
|
975
|
+
source added there needs a role here, and the battery goes red until it has one."""
|
|
976
|
+
roles = ("readme", "manifest", "router", "register")
|
|
977
|
+
out = {}
|
|
978
|
+
for role, (_label, rel) in zip(roles, sdlc_core.orient_docs()):
|
|
979
|
+
rel = rel.split("/", 1)[1]
|
|
980
|
+
out[role] = rel if (docs / rel).is_file() else None
|
|
981
|
+
return out
|
|
982
|
+
|
|
983
|
+
|
|
984
|
+
def audit_pages(docs):
|
|
985
|
+
"""Documents the agent reads that have a page but no place in the tree (R23)."""
|
|
986
|
+
out = []
|
|
987
|
+
oriented = [r for r in orientation_sources(docs).values() if r]
|
|
988
|
+
for rel in oriented + ["topics/INDEX.md", "audit/project_notes.md", "audit/reviews/REVIEW_LOG.md"]:
|
|
989
|
+
if (docs / rel).is_file():
|
|
990
|
+
out.append(rel)
|
|
991
|
+
aud = docs / "audit"
|
|
992
|
+
if aud.is_dir():
|
|
993
|
+
out += ["audit/" + p.name for p in sorted(aud.glob("HANDOFF_*.md"))]
|
|
994
|
+
req = aud / "requests"
|
|
995
|
+
if req.is_dir():
|
|
996
|
+
out += ["audit/requests/" + p.name for p in sorted(req.glob("*.md"))]
|
|
997
|
+
return out
|
|
998
|
+
|
|
999
|
+
|
|
1000
|
+
def build(project, docs, name):
|
|
1001
|
+
lens = project_lens(project, name)
|
|
1002
|
+
kb = build_kb(docs)
|
|
1003
|
+
fact_ids = set(kb["facts"]) if kb else None
|
|
1004
|
+
catalog = knowledge.memory_records(docs)
|
|
1005
|
+
paths = [r[0] for r in catalog]
|
|
1006
|
+
pages = {}
|
|
1007
|
+
for rel in sorted(set(paths + audit_pages(docs))):
|
|
1008
|
+
page = doc_page(docs, rel, fact_ids)
|
|
1009
|
+
if page is not None:
|
|
1010
|
+
pages[rel] = page
|
|
1011
|
+
link_companions(docs, pages)
|
|
1012
|
+
search = [{"k": "doc", "path": rel, "title": p["title"], "n": p.pop("_n")}
|
|
1013
|
+
for rel, p in sorted(pages.items())]
|
|
1014
|
+
|
|
1015
|
+
tree = {}
|
|
1016
|
+
for rel in paths:
|
|
1017
|
+
if rel in pages:
|
|
1018
|
+
tree.setdefault(posixpath.dirname(rel), []).append(
|
|
1019
|
+
{"path": rel, "title": pages[rel]["title"]})
|
|
1020
|
+
tree = [{"folder": f, "docs": sorted(d, key=lambda e: e["path"])} for f, d in sorted(tree.items())]
|
|
1021
|
+
|
|
1022
|
+
work = build_work(project, docs, fact_ids)
|
|
1023
|
+
units = build_units(project, lens) if units_page(lens) else None
|
|
1024
|
+
rules = build_rules(docs, pages)
|
|
1025
|
+
home = build_home(docs, pages, work, kb, fact_ids)
|
|
1026
|
+
head = sdlc_core.git_head(project) if sdlc_core.git_available(project) else ""
|
|
1027
|
+
changed = sdlc_core.git_changed_since(project, "HEAD", name) if head else None
|
|
1028
|
+
core = {
|
|
1029
|
+
"meta": {"generated": datetime.now().strftime("%Y-%m-%d %H:%M"),
|
|
1030
|
+
"git": bool(head), "commit": head, "changed": len(changed or []),
|
|
1031
|
+
"docs_dir": name, "lens": lens, "units_page": units_page(lens),
|
|
1032
|
+
"has_kb": bool(kb), "has_sources": bool(kb and kb["sources"])},
|
|
1033
|
+
"home": home, "work": work, "units": units, "rules": rules,
|
|
1034
|
+
"guides": build_guides(docs, pages), "tree": tree,
|
|
1035
|
+
"chronology": home["lately_all"], "search": search,
|
|
1036
|
+
}
|
|
1037
|
+
home.pop("lately_all")
|
|
1038
|
+
if kb:
|
|
1039
|
+
kb["search"] = ([{"k": "topic", "id": s, "title": kb["topics"][s]["name"],
|
|
1040
|
+
"n": normalize(" ".join([s, kb["topics"][s]["name"],
|
|
1041
|
+
" ".join(kb["topics"][s]["aka"]),
|
|
1042
|
+
kb["topics"][s]["description"]]))}
|
|
1043
|
+
for s in kb["order"]]
|
|
1044
|
+
+ [{"k": "fact", "id": fid, "title": f["text"], "n": normalize(f["text"]),
|
|
1045
|
+
"statement": f["statement"]}
|
|
1046
|
+
for fid, f in kb["facts"].items()])
|
|
1047
|
+
return core, pages, kb
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def build_work(project, docs, facts):
|
|
1051
|
+
cards = []
|
|
1052
|
+
for p, meta in sdlc_core.list_workstreams(project):
|
|
1053
|
+
card = {"name": _v(meta, "workstream"), "group": status_group(meta.get("status")),
|
|
1054
|
+
"path": p.relative_to(docs).as_posix()}
|
|
1055
|
+
for key in ("status", "level", "since", "updated", "branch", "next"):
|
|
1056
|
+
if _v(meta, key):
|
|
1057
|
+
card[key] = _v(meta, key)
|
|
1058
|
+
cards.append(card)
|
|
1059
|
+
cards.sort(key=lambda c: c["name"].lower())
|
|
1060
|
+
cards.sort(key=lambda c: c.get("updated", ""), reverse=True)
|
|
1061
|
+
cards.sort(key=lambda c: (c["group"], not c.get("updated")))
|
|
1062
|
+
notes = None
|
|
1063
|
+
np = docs / "audit" / sdlc_core.PROJECT_NOTES
|
|
1064
|
+
if np.is_file():
|
|
1065
|
+
t = _text(np)
|
|
1066
|
+
if t is not None:
|
|
1067
|
+
notes = {"path": "audit/" + sdlc_core.PROJECT_NOTES, "html": markdown(t, "audit", facts)}
|
|
1068
|
+
register = "audit/handoff.md" if (docs / "audit" / "handoff.md").is_file() else None
|
|
1069
|
+
return {"cards": cards, "notes": notes, "register": register}
|
|
1070
|
+
|
|
1071
|
+
|
|
1072
|
+
def build_units(project, lens):
|
|
1073
|
+
default = sdlc_core.project_default_domain(project)
|
|
1074
|
+
units = []
|
|
1075
|
+
for p, meta, text in sdlc_core.list_analyses(project):
|
|
1076
|
+
_pairs, body = split_frontmatter(text)
|
|
1077
|
+
u = {"name": first_heading(body) or p.stem, "group": status_group(meta.get("status")),
|
|
1078
|
+
"lens": sdlc_core.resolve_domain(meta, default)[0],
|
|
1079
|
+
"path": "solutions/" + p.name}
|
|
1080
|
+
for key, field in (("id", "id"), ("status", "status"), ("level", "level"),
|
|
1081
|
+
("start", "start_date"), ("end", "end_date")):
|
|
1082
|
+
if _v(meta, field):
|
|
1083
|
+
u[key] = _v(meta, field)
|
|
1084
|
+
if "id" not in u and _v(meta, "feature"):
|
|
1085
|
+
u["id"] = _v(meta, "feature")
|
|
1086
|
+
units.append(u)
|
|
1087
|
+
units.sort(key=lambda u: (u.get("id", ""), u["path"]))
|
|
1088
|
+
return units
|
|
1089
|
+
|
|
1090
|
+
|
|
1091
|
+
def build_rules(docs, pages):
|
|
1092
|
+
def entries(folder, pattern):
|
|
1093
|
+
d = docs / folder
|
|
1094
|
+
return sorted(folder + "/" + p.name for p in d.glob(pattern)
|
|
1095
|
+
if p.name != "INDEX.md") if d.is_dir() else []
|
|
1096
|
+
|
|
1097
|
+
def item(rel):
|
|
1098
|
+
meta = dict((k.lower(), v) for k, v in pages[rel]["meta"])
|
|
1099
|
+
return {"path": rel, "title": pages[rel]["title"], "when": meta.get("description", "")}
|
|
1100
|
+
|
|
1101
|
+
vision = [item(r) for r in entries("vision", "*.md") if r in pages]
|
|
1102
|
+
for v in vision:
|
|
1103
|
+
t = _text(docs / v["path"]) or ""
|
|
1104
|
+
pairs, body = split_frontmatter(t)
|
|
1105
|
+
v["status"] = vision_status(pairs, body) or ""
|
|
1106
|
+
decisions = [item(r) for r in entries("architecture", "*.md") if r in pages]
|
|
1107
|
+
return {"vision": vision, "decisions": decisions,
|
|
1108
|
+
"manifest": "INDEX.md" if "INDEX.md" in pages else None}
|
|
1109
|
+
|
|
1110
|
+
|
|
1111
|
+
def build_guides(docs, pages):
|
|
1112
|
+
"""The guides page (R26): the router, then operative guides (when to read each, status
|
|
1113
|
+
as declared), then code-comprehension guides (`source_kind: code`)."""
|
|
1114
|
+
ref = docs / "reference"
|
|
1115
|
+
operative, comprehension = [], []
|
|
1116
|
+
for p in sorted(ref.glob("GUIDE_*.md")) if ref.is_dir() else []:
|
|
1117
|
+
rel = "reference/" + p.name
|
|
1118
|
+
if rel not in pages:
|
|
1119
|
+
continue
|
|
1120
|
+
meta = dict((k.lower(), v) for k, v in pages[rel]["meta"])
|
|
1121
|
+
g = {"path": rel, "title": pages[rel]["title"], "when": meta.get("description", ""),
|
|
1122
|
+
"status": meta.get("status", "")}
|
|
1123
|
+
(comprehension if meta.get("source_kind", "").strip().lower() == "code"
|
|
1124
|
+
else operative).append(g)
|
|
1125
|
+
return {"router": "reference/INDEX.md" if "reference/INDEX.md" in pages else None,
|
|
1126
|
+
"operative": operative, "comprehension": comprehension}
|
|
1127
|
+
|
|
1128
|
+
|
|
1129
|
+
def build_home(docs, pages, work, kb, facts):
|
|
1130
|
+
readme = pages.get("README.md")
|
|
1131
|
+
title, intro = None, ""
|
|
1132
|
+
t = _text(docs / "README.md") if readme else None
|
|
1133
|
+
if t is not None:
|
|
1134
|
+
_pairs, body = split_frontmatter(t)
|
|
1135
|
+
title = first_heading(body)
|
|
1136
|
+
intro = markdown(opening_paragraph(body), "", facts)
|
|
1137
|
+
vision = None
|
|
1138
|
+
vp = "vision/project_vision.md"
|
|
1139
|
+
if (docs / vp).is_file():
|
|
1140
|
+
vt = _text(docs / vp)
|
|
1141
|
+
if vt is not None:
|
|
1142
|
+
pairs, body = split_frontmatter(vt)
|
|
1143
|
+
st, sb, is_ns = vision_lead(body)
|
|
1144
|
+
vision = {"path": vp, "status": vision_status(pairs, body), "north_star": is_ns,
|
|
1145
|
+
"section_title": st, "section_html": markdown(sb or "", "vision", facts)}
|
|
1146
|
+
events = chronology(docs, pages)
|
|
1147
|
+
known = []
|
|
1148
|
+
if kb:
|
|
1149
|
+
for s in kb["order"]:
|
|
1150
|
+
if kb["topics"][s].get("area") == s:
|
|
1151
|
+
known.append({"slug": s, "name": kb["topics"][s]["name"],
|
|
1152
|
+
"topics": [o for o in kb["order"]
|
|
1153
|
+
if o != s and kb["topics"][o].get("area") == s]})
|
|
1154
|
+
links = {role: (rel if rel in pages else None)
|
|
1155
|
+
for role, rel in orientation_sources(docs).items()}
|
|
1156
|
+
links["work"] = "work"
|
|
1157
|
+
if work["notes"]:
|
|
1158
|
+
links["notes"] = work["notes"]["path"]
|
|
1159
|
+
if kb:
|
|
1160
|
+
links["topics"] = "map"
|
|
1161
|
+
return {"title": title or "", "intro_html": intro, "vision": vision, "links": links,
|
|
1162
|
+
"open": [{k: c[k] for k in ("name", "status", "next", "path") if k in c}
|
|
1163
|
+
for c in work["cards"]],
|
|
1164
|
+
"lately": events[:10], "lately_all": events, "known": known}
|
|
1165
|
+
|
|
1166
|
+
|
|
1167
|
+
SPIKE_OUTCOME = ("## Answer / Outcome",)
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def chronology(docs, pages):
|
|
1171
|
+
"""Dated events the documents declare, most recent first: {date, kind, label,
|
|
1172
|
+
path}. Which lines are events is F-073's rule (R22), read with the validator's
|
|
1173
|
+
own readers -- `section_lines` + `entry_openers` for Diary entries, spike
|
|
1174
|
+
outcomes and project notes, `request_entries`, `parse_review_log`, `adr_date`,
|
|
1175
|
+
`knowledge.frontmatter_date` -- so the page and `validate` cannot disagree. No
|
|
1176
|
+
analysis start or end date, no state, nothing from git. `path` is the page the
|
|
1177
|
+
event opens, or None when the site has no page for it."""
|
|
1178
|
+
events = []
|
|
1179
|
+
|
|
1180
|
+
def add(date, kind, label, rel, source=None):
|
|
1181
|
+
e = {"date": date, "kind": kind, "label": label,
|
|
1182
|
+
"path": rel if rel in pages else None}
|
|
1183
|
+
if source:
|
|
1184
|
+
e["source"] = source # opens the source's own page (#/source/<name>)
|
|
1185
|
+
events.append(e)
|
|
1186
|
+
|
|
1187
|
+
def openers(rel, kind, aliases=None):
|
|
1188
|
+
text = _text(docs / rel) if (docs / rel).is_file() else None
|
|
1189
|
+
if text is None:
|
|
1190
|
+
return
|
|
1191
|
+
if aliases is not None:
|
|
1192
|
+
section = sdlc_core.section_lines(text, aliases)
|
|
1193
|
+
if section is None:
|
|
1194
|
+
return
|
|
1195
|
+
text = section[1]
|
|
1196
|
+
for date, label, _ in sdlc_core.entry_openers(text):
|
|
1197
|
+
add(date, kind, label, rel)
|
|
1198
|
+
|
|
1199
|
+
def title_of(rel, text):
|
|
1200
|
+
return pages[rel]["title"] if rel in pages else (
|
|
1201
|
+
first_heading(split_frontmatter(text)[1]) or posixpath.basename(rel))
|
|
1202
|
+
|
|
1203
|
+
for p in sorted(docs.glob("solutions/ANALYSIS_*.md")):
|
|
1204
|
+
text = _text(p)
|
|
1205
|
+
# a shadow mirror is not an analysis to `validate` (list_analyses): not here either
|
|
1206
|
+
if text is not None and not sdlc_core.is_shadow(p, (text.splitlines() or [""])[0]):
|
|
1207
|
+
openers("solutions/" + p.name, "diary", sdlc_core.DIARY_SECTION)
|
|
1208
|
+
# spike outcomes: SPIKE_* in the code/kb/course lenses, RESEARCH_* in marketing
|
|
1209
|
+
for rel in sorted(["solutions/" + p.name for p in docs.glob("solutions/SPIKE_*.md")]
|
|
1210
|
+
+ ["spikes/" + p.name for p in docs.glob("spikes/RESEARCH_*.md")]):
|
|
1211
|
+
openers(rel, "spike", SPIKE_OUTCOME)
|
|
1212
|
+
openers("audit/" + sdlc_core.PROJECT_NOTES, "project_note")
|
|
1213
|
+
for p in sorted(docs.glob("audit/requests/REQUEST_*.md")):
|
|
1214
|
+
rel = "audit/requests/" + p.name
|
|
1215
|
+
text = _text(p)
|
|
1216
|
+
for rid, date, label, _ in sdlc_core.request_entries(text or "")[0]:
|
|
1217
|
+
add(date, "request", "%s — %s" % (rid, label), rel)
|
|
1218
|
+
rel = "audit/reviews/REVIEW_LOG.md"
|
|
1219
|
+
text = _text(docs / rel) if (docs / rel).is_file() else None
|
|
1220
|
+
if text is not None:
|
|
1221
|
+
for row in sdlc_core.parse_review_log(text)[0]:
|
|
1222
|
+
add(row["date"], "review", "%s — %s: %s" % (
|
|
1223
|
+
row["doc_key"], row["tier"], row["verdict"]), rel)
|
|
1224
|
+
for p in sorted(docs.glob("architecture/ADR_*.md")):
|
|
1225
|
+
rel = "architecture/" + p.name
|
|
1226
|
+
text = _text(p)
|
|
1227
|
+
date = sdlc_core.adr_date(p.name, text or "")
|
|
1228
|
+
if date:
|
|
1229
|
+
add(date, "decision", title_of(rel, text or ""), rel)
|
|
1230
|
+
for folder, kind, suffix in (("corpus/notes", "note", ".md"),
|
|
1231
|
+
("corpus/given", "source", ".meta.md")):
|
|
1232
|
+
for p in sorted(docs.glob(folder + "/*" + suffix)):
|
|
1233
|
+
rel = folder + "/" + p.name
|
|
1234
|
+
text = _text(p)
|
|
1235
|
+
if text is None:
|
|
1236
|
+
continue
|
|
1237
|
+
date = knowledge.frontmatter_date(
|
|
1238
|
+
sdlc_core.load_frontmatter(text.splitlines()))
|
|
1239
|
+
if not date:
|
|
1240
|
+
continue
|
|
1241
|
+
if kind == "source":
|
|
1242
|
+
name = p.name[:-len(suffix)]
|
|
1243
|
+
add(date, kind, name, rel, source=name)
|
|
1244
|
+
else:
|
|
1245
|
+
add(date, kind, title_of(rel, text), rel)
|
|
1246
|
+
# Most recent first. Within one day a document's later entry is the more recent
|
|
1247
|
+
# one, so the reading order is reversed too. Entries carry no time of day, so
|
|
1248
|
+
# between DIFFERENT documents on one day the order is fixed but not a time order:
|
|
1249
|
+
# the kinds come in the reverse of the reading order above.
|
|
1250
|
+
ranked = sorted(enumerate(events), key=lambda ne: (ne[1]["date"], ne[0]), reverse=True)
|
|
1251
|
+
return [e for _, e in ranked]
|
|
1252
|
+
|
|
1253
|
+
|
|
1254
|
+
# ----------------------------------------------------------------- writing
|
|
1255
|
+
|
|
1256
|
+
def _js(var, obj):
|
|
1257
|
+
s = json.dumps(obj, ensure_ascii=True, separators=(",", ":"))
|
|
1258
|
+
return "window.%s = %s;\n" % (var, s.replace("<", "\\u003c"))
|
|
1259
|
+
|
|
1260
|
+
|
|
1261
|
+
def check_out(out, docs):
|
|
1262
|
+
out = out.resolve()
|
|
1263
|
+
d = docs.resolve()
|
|
1264
|
+
if out == d or d in out.parents:
|
|
1265
|
+
raise SiteError("--out %s is inside the docs root %s: the site is written outside it."
|
|
1266
|
+
% (out, d))
|
|
1267
|
+
if out.exists() and not out.is_dir():
|
|
1268
|
+
raise SiteError("--out %s exists and is not a folder." % out)
|
|
1269
|
+
if out.is_dir() and any(out.iterdir()) and not (out / MARKER).is_file():
|
|
1270
|
+
raise SiteError("--out %s is not empty and was not written by this generator: "
|
|
1271
|
+
"refusing to write over it." % out)
|
|
1272
|
+
return out
|
|
1273
|
+
|
|
1274
|
+
|
|
1275
|
+
def write_site(out, core, pages, kb):
|
|
1276
|
+
out.mkdir(parents=True, exist_ok=True)
|
|
1277
|
+
for rel in PAGE_FILES + ("data",):
|
|
1278
|
+
p = out / rel
|
|
1279
|
+
if p.is_dir():
|
|
1280
|
+
shutil.rmtree(p)
|
|
1281
|
+
elif p.exists():
|
|
1282
|
+
p.unlink()
|
|
1283
|
+
(out / MARKER).write_text("Written by docs_site.py; regenerated on every run.\n",
|
|
1284
|
+
encoding="utf-8", newline="\n")
|
|
1285
|
+
for name in PAGE_FILES:
|
|
1286
|
+
shutil.copyfile(PAGE_DIR / name, out / name)
|
|
1287
|
+
data = out / "data"
|
|
1288
|
+
data.mkdir()
|
|
1289
|
+
(data / "core.js").write_text(_js("DOCS_SITE_CORE", core), encoding="utf-8", newline="\n")
|
|
1290
|
+
(data / "docs.js").write_text(_js("DOCS_SITE_DOCS", pages), encoding="utf-8", newline="\n")
|
|
1291
|
+
if kb:
|
|
1292
|
+
(data / "kb.js").write_text(_js("DOCS_SITE_KB", kb), encoding="utf-8", newline="\n")
|
|
1293
|
+
|
|
1294
|
+
|
|
1295
|
+
def git_ignored(project, out):
|
|
1296
|
+
if not sdlc_core.git_available(project):
|
|
1297
|
+
return None
|
|
1298
|
+
try:
|
|
1299
|
+
out.relative_to(project.resolve())
|
|
1300
|
+
except ValueError:
|
|
1301
|
+
return None # outside the repository: nothing to commit
|
|
1302
|
+
try:
|
|
1303
|
+
r = subprocess.run(["git", "check-ignore", "-q", str(out)], cwd=str(project),
|
|
1304
|
+
capture_output=True, text=True, timeout=10)
|
|
1305
|
+
return {0: True, 1: False}.get(r.returncode)
|
|
1306
|
+
except Exception:
|
|
1307
|
+
return None
|
|
1308
|
+
|
|
1309
|
+
|
|
1310
|
+
def _shown(path, docs):
|
|
1311
|
+
try:
|
|
1312
|
+
return Path(path).relative_to(docs).as_posix()
|
|
1313
|
+
except ValueError:
|
|
1314
|
+
return str(path)
|
|
1315
|
+
|
|
1316
|
+
|
|
1317
|
+
def main(argv=None):
|
|
1318
|
+
ap = argparse.ArgumentParser(
|
|
1319
|
+
prog="docs_site.py",
|
|
1320
|
+
description="Generate a static, read-only site of what the agent reads in this project.")
|
|
1321
|
+
ap.add_argument("--root", help="project root (default: discovered from the current folder)")
|
|
1322
|
+
ap.add_argument("--docs-dir", dest="docs_dir", help="docs root name (ai_docs or mkt_docs)")
|
|
1323
|
+
ap.add_argument("--out", help="output folder (default: <project>/%s)" % DEFAULT_OUT)
|
|
1324
|
+
ap.add_argument("--open", action="store_true", help="open the site in the browser")
|
|
1325
|
+
args = ap.parse_args(argv)
|
|
1326
|
+
del _UNREADABLE[:]
|
|
1327
|
+
try:
|
|
1328
|
+
project, docs, name = resolve_root(args)
|
|
1329
|
+
out = check_out(Path(args.out) if args.out else project / DEFAULT_OUT, docs)
|
|
1330
|
+
except SiteError as exc:
|
|
1331
|
+
print("[ERROR] %s" % exc)
|
|
1332
|
+
return 2
|
|
1333
|
+
if not PAGE_DIR.is_dir():
|
|
1334
|
+
print("[ERROR] the page files are missing: %s (reinstall the skill)" % PAGE_DIR)
|
|
1335
|
+
return 2
|
|
1336
|
+
try:
|
|
1337
|
+
with _GuardedReads():
|
|
1338
|
+
core, pages, kb = build(project, docs, name)
|
|
1339
|
+
except OSError as exc:
|
|
1340
|
+
# a reader that bypasses read_text (raw bytes) met an unreadable file
|
|
1341
|
+
_note_unreadable(getattr(exc, "filename", None) or "?", exc)
|
|
1342
|
+
for path, err in _UNREADABLE:
|
|
1343
|
+
print("[ERROR] unreadable: %s (%s)" % (_shown(path, docs), err))
|
|
1344
|
+
print("[ERROR] the site was not written: fix the file's permissions and run again.")
|
|
1345
|
+
return 1
|
|
1346
|
+
write_site(out, core, pages, kb)
|
|
1347
|
+
print("[ok] docs site written: %s (%d documents%s, from %s)" % (
|
|
1348
|
+
out / "index.html", len(pages),
|
|
1349
|
+
", %d topics, %d facts" % (len(kb["order"]), len(kb["facts"])) if kb else "", docs))
|
|
1350
|
+
for path, exc in _UNREADABLE:
|
|
1351
|
+
print("[ERROR] unreadable: %s (%s)" % (_shown(path, docs), exc))
|
|
1352
|
+
if git_ignored(project, out) is False:
|
|
1353
|
+
print("[note] %s is not git-ignored: add it to .gitignore so the site's copy of "
|
|
1354
|
+
"your documents is not committed." % out)
|
|
1355
|
+
if args.open:
|
|
1356
|
+
import webbrowser
|
|
1357
|
+
webbrowser.open((out / "index.html").as_uri())
|
|
1358
|
+
return 1 if _UNREADABLE else 0
|
|
1359
|
+
|
|
1360
|
+
|
|
1361
|
+
if __name__ == "__main__":
|
|
1362
|
+
sys.exit(main())
|