entropy-machines 0.1.1
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/LICENSE +93 -0
- package/README.md +68 -0
- package/agents/isolated-worker.md +128 -0
- package/agents/verifier.md +158 -0
- package/bin/dispatch +700 -0
- package/bin/doclint +460 -0
- package/bin/drain +507 -0
- package/bin/drain-pick.py +168 -0
- package/bin/drain-prompt.md +67 -0
- package/bin/drain-run.sh +342 -0
- package/bin/entropy-machines-init +285 -0
- package/bin/handoff +1151 -0
- package/bin/init +232 -0
- package/bin/post-fold-audit +377 -0
- package/bin/serve +724 -0
- package/bin/status +208 -0
- package/bin/tracker +153 -0
- package/docs/AGENT-QUICKSTART.md +86 -0
- package/docs/CONFIG.md +68 -0
- package/docs/NPM.md +91 -0
- package/docs/SERVE.md +74 -0
- package/docs/TRACKER-ADAPTER.md +66 -0
- package/doctrine/HANDOFF-PROMPT.md +63 -0
- package/doctrine/README.md +62 -0
- package/doctrine/ROLES.md +27 -0
- package/doctrine/WORKFLOW.md +87 -0
- package/hooks/commit-msg +24 -0
- package/hooks/post-checkout +354 -0
- package/hooks/pre-commit +33 -0
- package/lib/PRD-001-orientation.html +1180 -0
- package/lib/REPORT-TEMPLATE.html +413 -0
- package/lib/changelog-collate.mjs +328 -0
- package/lib/changelog-guard.sh +157 -0
- package/lib/changelog-new.mjs +70 -0
- package/lib/config.mjs +283 -0
- package/lib/config.py +317 -0
- package/lib/doc-template.html +807 -0
- package/lib/entropy-drain.plist.in +59 -0
- package/lib/entropy-drain.service.in +53 -0
- package/lib/entropy-drain.timer.in +36 -0
- package/lib/fail-first.mjs +901 -0
- package/lib/handoff-guard.sh +623 -0
- package/lib/install-hooks.sh +169 -0
- package/lib/notes.py +675 -0
- package/lib/preflight-tree.mjs +82 -0
- package/lib/roots.sh +212 -0
- package/lib/themes/daylight.css +84 -0
- package/lib/themes/high-contrast.css +36 -0
- package/lib/tracker-file +333 -0
- package/lib/tracker-view.py +784 -0
- package/package.json +38 -0
package/bin/doclint
ADDED
|
@@ -0,0 +1,460 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""doclint — refuse a doc that phones out, or that nobody can answer.
|
|
3
|
+
|
|
4
|
+
bin/doclint every *.html in the project's docs directory
|
|
5
|
+
bin/doclint <path> [<path>…] those files (a directory expands to its *.html)
|
|
6
|
+
|
|
7
|
+
Exit 0 clean, 1 with violations named, 2 refused (bad usage, unreadable input,
|
|
8
|
+
no docs directory). Every problem found is reported, not just the first.
|
|
9
|
+
|
|
10
|
+
WHAT IT ENFORCES, AND WHY THOSE TWO THINGS
|
|
11
|
+
|
|
12
|
+
1. LOCAL ONLY. A report, plan or PRD in this factory is a file on this
|
|
13
|
+
machine, served by bin/serve, answered in a browser and saved back to disk.
|
|
14
|
+
It is never published to an external site and it never depends on one. So
|
|
15
|
+
any reference — an href, a src, a CSS url(), a string literal inside a
|
|
16
|
+
script — pointing at http://, https:// or a protocol-relative //host is
|
|
17
|
+
refused, naming the file, the line and the URL. A doc that loads something
|
|
18
|
+
from someone else's server stops working the moment it leaves this machine
|
|
19
|
+
or the machine goes offline, and it leaks the fact that it was opened.
|
|
20
|
+
|
|
21
|
+
This subsumes the remote-webfont case for the right reason: a Google Fonts
|
|
22
|
+
stylesheet is refused because it is remote, not because it is a font.
|
|
23
|
+
|
|
24
|
+
PROSE IS NOT A REFERENCE. Text that merely mentions a URL fetches nothing
|
|
25
|
+
and passes. So do data: URIs, blob:, mailto:, same-document #anchors, and
|
|
26
|
+
every relative or root-relative path.
|
|
27
|
+
|
|
28
|
+
2. ANSWERABLE. A doc with nowhere to answer is a broadcast. Every <h2> gets an
|
|
29
|
+
answer box, the boxes have unique keys and a textarea each, and the page
|
|
30
|
+
carries the save button and the saved-answers JSON block that get an answer
|
|
31
|
+
from the browser onto disk. Miss any of those and the owner's typing dies
|
|
32
|
+
in the tab.
|
|
33
|
+
|
|
34
|
+
A section that is genuinely NOT for answering — orientation prose, a
|
|
35
|
+
findings page the agent fills in — says so in the markup: put
|
|
36
|
+
data-informational on its <h2> and this check skips it. That is a
|
|
37
|
+
declaration, not a switch: it is visible in the file, it is per-section,
|
|
38
|
+
and writing it is a claim that nobody is expected to respond there. There
|
|
39
|
+
is no flag, no env var and no whole-file exemption, because the failure
|
|
40
|
+
this rule exists to catch is a doc that LOOKS answerable and is not.
|
|
41
|
+
|
|
42
|
+
WHAT IT DELIBERATELY DOES NOT ENFORCE: the look. Colours, fonts, type scale,
|
|
43
|
+
layout, whether there is a theme toggle — all of it is the author's. The house
|
|
44
|
+
style in lib/REPORT-TEMPLATE.html is a good default and a starting point, not
|
|
45
|
+
a conformance target. Somebody using this harness may want a report that looks
|
|
46
|
+
nothing like ours, and that is fine; a report that fetches from the internet is
|
|
47
|
+
not.
|
|
48
|
+
|
|
49
|
+
TARGETED REGEX, NOT AN HTML PARSER. Deliberate, and the same thing the rest of
|
|
50
|
+
this harness does (bin/serve's save merge and answer counts, bin/status's
|
|
51
|
+
unanswered scan): the questions asked here are lexical — does this attribute
|
|
52
|
+
value name a remote origin, is there an answer box between these two headings
|
|
53
|
+
— and vendoring a parser to answer them would add a dependency to a harness
|
|
54
|
+
that has none. The cost is real and bounded: exotic markup (an attribute value
|
|
55
|
+
split across a CDATA section, a URL assembled at runtime from fragments) can
|
|
56
|
+
slip past. A doc built by hand or from the template does not do those things.
|
|
57
|
+
|
|
58
|
+
HTML COMMENTS ARE MASKED BEFORE ANYTHING IS COUNTED. A commented-out remote
|
|
59
|
+
link loads nothing, and — the expensive half — a comment that DOCUMENTS the
|
|
60
|
+
answer-box markup is not a question. lib/doc-template.html once carried a
|
|
61
|
+
literal example in its header comment; every regex reader of these docs counted
|
|
62
|
+
it, so each doc reported one phantom unanswered question nobody could answer
|
|
63
|
+
and "fully answered" was unreachable. Masking keeps line numbers intact.
|
|
64
|
+
"""
|
|
65
|
+
import os
|
|
66
|
+
import re
|
|
67
|
+
import sys
|
|
68
|
+
|
|
69
|
+
# ENTROPY_MACHINES_HOME is the harness DIRECTORY — where lib/config.py sits — which may
|
|
70
|
+
# be the repo root or a subdirectory of it. Not a root and not a separate repo;
|
|
71
|
+
# see lib/roots.sh.
|
|
72
|
+
ENTROPY_MACHINES_HOME = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
73
|
+
sys.path.insert(0, os.path.join(ENTROPY_MACHINES_HOME, "lib"))
|
|
74
|
+
import config # noqa: E402 -- needs ENTROPY_MACHINES_HOME on the path first
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
# ---------------------------------------------------------------------------
|
|
78
|
+
# lexing helpers
|
|
79
|
+
# ---------------------------------------------------------------------------
|
|
80
|
+
_COMMENT_RE = re.compile(r"<!--[\s\S]*?-->")
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def mask_comments(text):
|
|
84
|
+
"""Blank every HTML comment, preserving length and newlines so offsets and
|
|
85
|
+
line numbers still refer to the real file."""
|
|
86
|
+
def blank(m):
|
|
87
|
+
return "".join("\n" if c == "\n" else " " for c in m.group(0))
|
|
88
|
+
return _COMMENT_RE.sub(blank, text)
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def line_of(text, offset):
|
|
92
|
+
return text.count("\n", 0, offset) + 1
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
# ---------------------------------------------------------------------------
|
|
96
|
+
# rule 1 — no external references
|
|
97
|
+
# ---------------------------------------------------------------------------
|
|
98
|
+
# Attributes whose value the browser FETCHES. `data` is <object data=…>.
|
|
99
|
+
_URL_ATTRS = (
|
|
100
|
+
"href|src|srcset|action|formaction|poster|cite|ping|manifest|data|background|longdesc"
|
|
101
|
+
)
|
|
102
|
+
_ATTR_RE = re.compile(
|
|
103
|
+
r"\b(" + _URL_ATTRS + r")\s*=\s*(?:\"([^\"]*)\"|'([^']*)'|([^\s>\"'`]+))",
|
|
104
|
+
re.I,
|
|
105
|
+
)
|
|
106
|
+
_URL_FN_RE = re.compile(r"url\(\s*(?:\"([^\"]*)\"|'([^']*)'|([^)\s]*))\s*\)", re.I)
|
|
107
|
+
_IMPORT_RE = re.compile(r"@import\s+(?:\"([^\"]*)\"|'([^']*)')", re.I)
|
|
108
|
+
_SCRIPT_RE = re.compile(r"<script\b[^>]*>([\s\S]*?)</script>", re.I)
|
|
109
|
+
_STRING_RE = re.compile(r"\"([^\"\n]*)\"|'([^'\n]*)'|`([^`]*)`")
|
|
110
|
+
|
|
111
|
+
# http/https/ftp/ws and friends are off-machine by definition. file: is not
|
|
112
|
+
# checked here: it is a local path, brittle but not a request to somebody
|
|
113
|
+
# else's server, and this rule is about the latter.
|
|
114
|
+
_REMOTE_SCHEME_RE = re.compile(r"^(?:https?|ftps?|wss?)://", re.I)
|
|
115
|
+
# Protocol-relative //host/path inherits the page's scheme and is a remote
|
|
116
|
+
# fetch when the page is served over http, which is exactly how bin/serve
|
|
117
|
+
# serves it. Requires a host character so a bare "//" string in script code is
|
|
118
|
+
# not mistaken for one.
|
|
119
|
+
_PROTOCOL_RELATIVE_RE = re.compile(r"^//[A-Za-z0-9]")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def remote_target(value):
|
|
123
|
+
"""The URL if this reference leaves the machine, else None."""
|
|
124
|
+
if value is None:
|
|
125
|
+
return None
|
|
126
|
+
v = value.strip()
|
|
127
|
+
if not v:
|
|
128
|
+
return None
|
|
129
|
+
if _REMOTE_SCHEME_RE.match(v) or _PROTOCOL_RELATIVE_RE.match(v):
|
|
130
|
+
return v
|
|
131
|
+
return None
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def _group(m):
|
|
135
|
+
"""The first non-None capture — the quoted or unquoted value."""
|
|
136
|
+
for g in m.groups()[1:] if m.re is _ATTR_RE else m.groups():
|
|
137
|
+
if g is not None:
|
|
138
|
+
return g
|
|
139
|
+
return None
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def check_external(masked, findings):
|
|
143
|
+
seen = set()
|
|
144
|
+
|
|
145
|
+
def flag(offset, url, where):
|
|
146
|
+
key = (offset, url)
|
|
147
|
+
if key in seen:
|
|
148
|
+
return
|
|
149
|
+
seen.add(key)
|
|
150
|
+
findings.append((
|
|
151
|
+
line_of(masked, offset),
|
|
152
|
+
"external-reference",
|
|
153
|
+
"%s points off this machine: %s" % (where, url),
|
|
154
|
+
))
|
|
155
|
+
|
|
156
|
+
for m in _ATTR_RE.finditer(masked):
|
|
157
|
+
attr = m.group(1).lower()
|
|
158
|
+
value = _group(m)
|
|
159
|
+
if value is None:
|
|
160
|
+
continue
|
|
161
|
+
# srcset holds a comma-separated candidate list, each "<url> <descriptor>".
|
|
162
|
+
candidates = [c.strip().split()[0] for c in value.split(",") if c.strip()] \
|
|
163
|
+
if attr == "srcset" else [value]
|
|
164
|
+
for c in candidates:
|
|
165
|
+
url = remote_target(c)
|
|
166
|
+
if url:
|
|
167
|
+
flag(m.start(), url, "%s=" % attr)
|
|
168
|
+
|
|
169
|
+
for m in _URL_FN_RE.finditer(masked):
|
|
170
|
+
url = remote_target(_group(m))
|
|
171
|
+
if url:
|
|
172
|
+
flag(m.start(), url, "css url()")
|
|
173
|
+
|
|
174
|
+
for m in _IMPORT_RE.finditer(masked):
|
|
175
|
+
url = remote_target(_group(m))
|
|
176
|
+
if url:
|
|
177
|
+
flag(m.start(), url, "@import")
|
|
178
|
+
|
|
179
|
+
# Inside a script, any string literal naming a remote origin is a fetch
|
|
180
|
+
# waiting to happen — fetch(), XHR, import(), new Worker(), an <img>.src
|
|
181
|
+
# assignment. Matching string LITERALS and not bare text is what keeps a
|
|
182
|
+
# `// comment` from reading as a protocol-relative URL.
|
|
183
|
+
for block in _SCRIPT_RE.finditer(masked):
|
|
184
|
+
body = block.group(1)
|
|
185
|
+
base = block.start(1)
|
|
186
|
+
for m in _STRING_RE.finditer(body):
|
|
187
|
+
url = remote_target(_group(m))
|
|
188
|
+
if url:
|
|
189
|
+
flag(base + m.start(), url, "script string")
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# rule 2 — the doc is answerable
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
_H2_RE = re.compile(r"<h2\b([^>]*)>([\s\S]*?)</h2>", re.I)
|
|
196
|
+
# An <h2 data-informational> declares "nobody is expected to answer here".
|
|
197
|
+
_INFORMATIONAL_RE = re.compile(r"\bdata-informational\b", re.I)
|
|
198
|
+
_RESP_TAG_RE = re.compile(r"<[a-zA-Z][a-zA-Z0-9]*\b[^>]*\bdata-resp\s*=\s*\"([^\"]+)\"[^>]*>")
|
|
199
|
+
_CLASS_RE = re.compile(r"\bclass\s*=\s*\"([^\"]*)\"", re.I)
|
|
200
|
+
_SAVE_BTN_RE = re.compile(r"\bid\s*=\s*[\"']saveBtn[\"']")
|
|
201
|
+
_RESP_DATA_RE = re.compile(r"\bid\s*=\s*[\"']responses-data[\"']")
|
|
202
|
+
# The exact shape bin/serve's merge() rewrites. A different attribute order or
|
|
203
|
+
# a missing type= means a saved answer never reaches the mirror block, so the
|
|
204
|
+
# answer counters keep reporting the question as open after it was answered.
|
|
205
|
+
_RESP_DATA_EXACT_RE = re.compile(
|
|
206
|
+
r"<script type=\"application/json\" id=\"responses-data\">", re.I
|
|
207
|
+
)
|
|
208
|
+
_TEXTAREA_RE = re.compile(r"<textarea\b", re.I)
|
|
209
|
+
_TAGS_RE = re.compile(r"<[^>]+>")
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def check_answerable(masked, findings):
|
|
213
|
+
boxes = list(_RESP_TAG_RE.finditer(masked))
|
|
214
|
+
|
|
215
|
+
if not boxes:
|
|
216
|
+
findings.append((
|
|
217
|
+
1, "no-answer-boxes",
|
|
218
|
+
"no answer box anywhere — this is a broadcast, not a doc anyone "
|
|
219
|
+
"can respond to",
|
|
220
|
+
))
|
|
221
|
+
else:
|
|
222
|
+
seen_keys = {}
|
|
223
|
+
for i, m in enumerate(boxes):
|
|
224
|
+
key = m.group(1)
|
|
225
|
+
line = line_of(masked, m.start())
|
|
226
|
+
if key in seen_keys:
|
|
227
|
+
findings.append((
|
|
228
|
+
line, "duplicate-key",
|
|
229
|
+
"answer key %r is already used on line %d — the second "
|
|
230
|
+
"answer overwrites the first on save" % (key, seen_keys[key]),
|
|
231
|
+
))
|
|
232
|
+
else:
|
|
233
|
+
seen_keys[key] = line
|
|
234
|
+
|
|
235
|
+
cls = _CLASS_RE.search(m.group(0))
|
|
236
|
+
if not cls or "response" not in cls.group(1).split():
|
|
237
|
+
findings.append((
|
|
238
|
+
line, "answer-box-missing-class",
|
|
239
|
+
"the box for %r has no class=\"response\" — bin/status "
|
|
240
|
+
"will not count it" % key,
|
|
241
|
+
))
|
|
242
|
+
|
|
243
|
+
end = boxes[i + 1].start() if i + 1 < len(boxes) else len(masked)
|
|
244
|
+
if not _TEXTAREA_RE.search(masked, m.end(), end):
|
|
245
|
+
findings.append((
|
|
246
|
+
line, "empty-answer-box",
|
|
247
|
+
"the box for %r has no <textarea> — there is nothing to "
|
|
248
|
+
"type into" % key,
|
|
249
|
+
))
|
|
250
|
+
|
|
251
|
+
box_starts = [m.start() for m in boxes]
|
|
252
|
+
heads = list(_H2_RE.finditer(masked))
|
|
253
|
+
for i, h in enumerate(heads):
|
|
254
|
+
end = heads[i + 1].start() if i + 1 < len(heads) else len(masked)
|
|
255
|
+
if any(h.end() <= s < end for s in box_starts):
|
|
256
|
+
continue
|
|
257
|
+
if _INFORMATIONAL_RE.search(h.group(1)):
|
|
258
|
+
continue # declared not-for-answering, in the markup itself
|
|
259
|
+
title = " ".join(_TAGS_RE.sub("", h.group(2)).split()) or "(untitled)"
|
|
260
|
+
findings.append((
|
|
261
|
+
line_of(masked, h.start()), "no-answer-box",
|
|
262
|
+
"the section %r has no answer box under it — add one, or mark the "
|
|
263
|
+
"heading data-informational if nobody is meant to answer there"
|
|
264
|
+
% title[:60],
|
|
265
|
+
))
|
|
266
|
+
|
|
267
|
+
if not _SAVE_BTN_RE.search(masked):
|
|
268
|
+
findings.append((
|
|
269
|
+
1, "no-save-button",
|
|
270
|
+
"no element with id=\"saveBtn\" — nothing can write the answers to "
|
|
271
|
+
"disk, so they die in the browser",
|
|
272
|
+
))
|
|
273
|
+
if not _RESP_DATA_RE.search(masked):
|
|
274
|
+
findings.append((
|
|
275
|
+
1, "no-responses-data",
|
|
276
|
+
"no <script type=\"application/json\" id=\"responses-data\"> block "
|
|
277
|
+
"— bin/serve has nowhere to mirror the saved answers",
|
|
278
|
+
))
|
|
279
|
+
elif not _RESP_DATA_EXACT_RE.search(masked):
|
|
280
|
+
findings.append((
|
|
281
|
+
1, "responses-data-shape",
|
|
282
|
+
"the responses-data block is not the exact shape bin/serve "
|
|
283
|
+
"rewrites — it must read: "
|
|
284
|
+
"<script type=\"application/json\" id=\"responses-data\">",
|
|
285
|
+
))
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
# ---------------------------------------------------------------------------
|
|
289
|
+
# driving it
|
|
290
|
+
# ---------------------------------------------------------------------------
|
|
291
|
+
_SCRIPT_BODY_RE = re.compile(r"(<script\b[^>]*>)([\s\S]*?)(</script\s*>)", re.I)
|
|
292
|
+
_GENERATED_RE = re.compile(r"<html\b[^>]*\bdata-generated\b", re.I)
|
|
293
|
+
|
|
294
|
+
|
|
295
|
+
def mask_script_bodies(text):
|
|
296
|
+
"""Blank the INSIDE of every <script>, preserving offsets.
|
|
297
|
+
|
|
298
|
+
check_answerable counts <h2> tags, and a generator that builds markup in JS
|
|
299
|
+
has `'<h2>' + title` in a string literal. Those were reported as sections
|
|
300
|
+
with no answer box: a rule firing on code that is not markup. The script
|
|
301
|
+
TAGS stay visible because check_external still has to see src= and any
|
|
302
|
+
remote URL inside the body.
|
|
303
|
+
"""
|
|
304
|
+
def blank(m):
|
|
305
|
+
body = "".join("\n" if c == "\n" else " " for c in m.group(2))
|
|
306
|
+
return m.group(1) + body + m.group(3)
|
|
307
|
+
return _SCRIPT_BODY_RE.sub(blank, text)
|
|
308
|
+
|
|
309
|
+
|
|
310
|
+
def is_generated(text):
|
|
311
|
+
"""True when the root element declares data-generated.
|
|
312
|
+
|
|
313
|
+
A generated read-only view — bin/tracker render's TRACKER.html — legitimately
|
|
314
|
+
has no answer boxes: it is a report of state, regenerated on demand, and hand
|
|
315
|
+
edits to it are discarded. The ANSWERABILITY rule does not apply to it. The
|
|
316
|
+
external-reference rule still does, and is the one that actually matters:
|
|
317
|
+
generated or not, a doc that phones out is not a local doc.
|
|
318
|
+
|
|
319
|
+
Declared in the markup on the root element, not a flag and not a filename
|
|
320
|
+
allow-list, so the exemption travels with the document and is visible to
|
|
321
|
+
anyone reading it.
|
|
322
|
+
"""
|
|
323
|
+
return bool(_GENERATED_RE.search(text))
|
|
324
|
+
|
|
325
|
+
|
|
326
|
+
def lint_file(path):
|
|
327
|
+
"""[(line, code, message), …], or None if the file could not be read.
|
|
328
|
+
|
|
329
|
+
None is not "clean" — a gate that cannot read its input must say so. The
|
|
330
|
+
caller turns it into a refusal.
|
|
331
|
+
"""
|
|
332
|
+
try:
|
|
333
|
+
with open(path, encoding="utf-8") as f:
|
|
334
|
+
text = f.read()
|
|
335
|
+
except (OSError, UnicodeDecodeError):
|
|
336
|
+
return None
|
|
337
|
+
masked = mask_comments(text)
|
|
338
|
+
findings = []
|
|
339
|
+
check_external(masked, findings)
|
|
340
|
+
if not is_generated(masked):
|
|
341
|
+
check_answerable(mask_script_bodies(masked), findings)
|
|
342
|
+
findings.sort(key=lambda f: (f[0], f[1]))
|
|
343
|
+
return findings
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def html_files_in(directory):
|
|
347
|
+
try:
|
|
348
|
+
return [os.path.join(directory, n)
|
|
349
|
+
for n in sorted(os.listdir(directory)) if n.endswith(".html")]
|
|
350
|
+
except OSError:
|
|
351
|
+
return None
|
|
352
|
+
|
|
353
|
+
|
|
354
|
+
def docs_dir(cfg):
|
|
355
|
+
d = (cfg.get("docs") or {}).get("dir")
|
|
356
|
+
if isinstance(d, str) and d.strip():
|
|
357
|
+
d = d.strip()
|
|
358
|
+
else:
|
|
359
|
+
# Same default bin/init writes and bin/serve serves from. docs.dir has
|
|
360
|
+
# no entry in lib/config.py's DEFAULTS; that shared file is not this
|
|
361
|
+
# tool's to extend, so the fallback lives here exactly as it does in
|
|
362
|
+
# bin/serve and bin/status.
|
|
363
|
+
d = "entropy-machines-docs"
|
|
364
|
+
return d if os.path.isabs(d) else os.path.join(cfg["_root"], d.strip("/"))
|
|
365
|
+
|
|
366
|
+
|
|
367
|
+
def refuse(msg, *more):
|
|
368
|
+
sys.stderr.write("doclint: REFUSED — %s\n" % msg)
|
|
369
|
+
for line in more:
|
|
370
|
+
sys.stderr.write(" %s\n" % line)
|
|
371
|
+
return 2
|
|
372
|
+
|
|
373
|
+
|
|
374
|
+
def main(argv):
|
|
375
|
+
if argv and argv[0] in ("-h", "--help"):
|
|
376
|
+
print(__doc__.strip("\n"))
|
|
377
|
+
return 0
|
|
378
|
+
for a in argv:
|
|
379
|
+
if a.startswith("-"):
|
|
380
|
+
sys.stderr.write("usage: bin/doclint [path…]\n")
|
|
381
|
+
return 2
|
|
382
|
+
|
|
383
|
+
config.refuse_nested_clone("doclint")
|
|
384
|
+
|
|
385
|
+
targets = []
|
|
386
|
+
if argv:
|
|
387
|
+
for a in argv:
|
|
388
|
+
if os.path.isdir(a):
|
|
389
|
+
found = html_files_in(a)
|
|
390
|
+
if found is None:
|
|
391
|
+
return refuse("cannot read the directory %s." % a)
|
|
392
|
+
if not found:
|
|
393
|
+
return refuse("no .html files in %s; nothing was checked." % a)
|
|
394
|
+
targets.extend(found)
|
|
395
|
+
elif os.path.isfile(a):
|
|
396
|
+
targets.append(a)
|
|
397
|
+
else:
|
|
398
|
+
return refuse("no such file: %s." % a,
|
|
399
|
+
"Nothing was checked — an unreadable input is not a pass.")
|
|
400
|
+
else:
|
|
401
|
+
cfg = config.load_config()
|
|
402
|
+
if not cfg["_path"]:
|
|
403
|
+
return refuse("no config.json at %s." % cfg["_root"],
|
|
404
|
+
"Run bin/init, or name the docs to check explicitly:",
|
|
405
|
+
" bin/doclint path/to/doc.html")
|
|
406
|
+
d = docs_dir(cfg)
|
|
407
|
+
if not os.path.isdir(d):
|
|
408
|
+
return refuse("the docs directory %s does not exist." % d,
|
|
409
|
+
"That is docs.dir in config.json. Nothing was checked.")
|
|
410
|
+
found = html_files_in(d)
|
|
411
|
+
if found is None:
|
|
412
|
+
return refuse("cannot read the docs directory %s." % d)
|
|
413
|
+
if not found:
|
|
414
|
+
return refuse("no .html docs in %s; nothing was checked." % d,
|
|
415
|
+
"This is a DECLINE, not a pass — a gate with no input",
|
|
416
|
+
"must not report success.")
|
|
417
|
+
targets = found
|
|
418
|
+
|
|
419
|
+
total = 0
|
|
420
|
+
bad_files = 0
|
|
421
|
+
for path in targets:
|
|
422
|
+
findings = lint_file(path)
|
|
423
|
+
if findings is None:
|
|
424
|
+
return refuse("cannot read %s." % path,
|
|
425
|
+
"Nothing was checked — an unreadable input is not a pass.")
|
|
426
|
+
if not findings:
|
|
427
|
+
continue
|
|
428
|
+
bad_files += 1
|
|
429
|
+
total += len(findings)
|
|
430
|
+
for line, code, msg in findings:
|
|
431
|
+
print("%s:%d: %s: %s" % (path, line, code, msg))
|
|
432
|
+
|
|
433
|
+
if total:
|
|
434
|
+
print("")
|
|
435
|
+
print("doclint: REFUSED — %d problem(s) in %d of %d doc(s)."
|
|
436
|
+
% (total, bad_files, len(targets)))
|
|
437
|
+
print(" A doc in this factory is a LOCAL file that the owner can "
|
|
438
|
+
"answer in place.")
|
|
439
|
+
print(" It never fetches from an external site, and every section "
|
|
440
|
+
"has a box to")
|
|
441
|
+
print(" answer in. Start from lib/REPORT-TEMPLATE.html. The look is "
|
|
442
|
+
"yours; these")
|
|
443
|
+
print(" two rules are not.")
|
|
444
|
+
return 1
|
|
445
|
+
|
|
446
|
+
print("doclint: %d doc(s) checked, all local and answerable." % len(targets))
|
|
447
|
+
return 0
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
if __name__ == "__main__":
|
|
451
|
+
try:
|
|
452
|
+
sys.exit(main(sys.argv[1:]))
|
|
453
|
+
except KeyboardInterrupt:
|
|
454
|
+
sys.exit(130)
|
|
455
|
+
except Exception as exc: # noqa: BLE001
|
|
456
|
+
# A gate that dies must REFUSE, never look like a pass and never exit
|
|
457
|
+
# via a traceback. lib/handoff-guard.sh was found doing exactly that.
|
|
458
|
+
sys.stderr.write("doclint: REFUSED — could not complete the check (%s: %s).\n"
|
|
459
|
+
% (type(exc).__name__, exc))
|
|
460
|
+
sys.exit(2)
|