task-pipeline-skill 1.74.0 → 1.76.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.
@@ -0,0 +1,1267 @@
1
+ #!/usr/bin/env python3
2
+ """The mechanical half of a project audit: collect, compare, report.
3
+
4
+ **What this file is for, and what it deliberately is not.** The skill beside it
5
+ owns *judgement* — which seams matter, how to read a silence, when an axis is
6
+ exhausted. This owns *mechanics*: what the project is, what can be measured
7
+ without being asked twice, and the two artefacts a run leaves behind. The split
8
+ is not tidiness. A judgement encoded in a script becomes a gate that refuses
9
+ things nobody decided to refuse; a mechanic left in prose becomes a step nobody
10
+ runs. Both failures have shipped in this family and both are on its boards.
11
+
12
+ **Stdlib only** (`references/portability.md`): `scripts/` is the one Claude Code
13
+ capability that travels to every channel, and a dependency here would make the
14
+ audit Claude-Code-shaped. That constraint is why the HTML is a string and the
15
+ tokeniser is a ratio nobody is asked to trust.
16
+
17
+ **Three verdicts, not two.** `clean`, `finding`, and `blind`. A probe that could
18
+ not look returns `blind` with the reason, and the reason reaches the page. This
19
+ is `references/audit.md`'s *silence is not a reading* raised from a command to a
20
+ probe: without the third value, "no Sentry configured" and "no errors" produce
21
+ the same empty section, and the second is what a reader takes away.
22
+
23
+ **Committed state is the subject.** Every probe reads `git ls-files` /
24
+ `git show`, and the working tree's disagreement is disclosed rather than failing
25
+ the run — the family's standing instruction #10, learned from two guards that
26
+ reported a state no clone could reproduce.
27
+
28
+ Exit codes: `0` the audit ran, findings or not; `1` it could not start. Findings
29
+ never change the code, because the operator asked for a report and a report that
30
+ exits non-zero is a gate somebody has to disarm.
31
+ """
32
+ import argparse
33
+ import collections
34
+ import datetime
35
+ import hashlib
36
+ import html as _html
37
+ import json
38
+ import os
39
+ import re
40
+ import shutil
41
+ import subprocess
42
+ import sys
43
+
44
+ SCHEMA = "project-audit/1"
45
+ VERDICTS = ("clean", "finding", "blind")
46
+ PHASES = ("discover", "probe", "prod", "seams", "report", "propose")
47
+ OUT_DIR = os.path.join("docs", "audit")
48
+
49
+ # How long a command may run before the probe behind it is called blind. A probe
50
+ # that hangs is worse than one that fails: it takes the whole audit with it.
51
+ TIMEOUT = 45
52
+
53
+
54
+ # ---------------------------------------------------------------------------
55
+ # the three values a probe may return
56
+ # ---------------------------------------------------------------------------
57
+
58
+ class Result(object):
59
+ """A probe's answer. Constructing one with a fourth verdict is refused.
60
+
61
+ The vocabulary is closed on purpose. An open one grows a `partial` and a
62
+ `warn` within a month, and then the page has five sections nobody can rank.
63
+ """
64
+
65
+ __slots__ = ("verdict", "reason", "evidence", "findings")
66
+
67
+ def __init__(self, verdict, reason="", evidence=None, findings=()):
68
+ if verdict not in VERDICTS:
69
+ raise ValueError(
70
+ "verdict %r is not one of %r" % (verdict, list(VERDICTS)))
71
+ self.verdict = verdict
72
+ self.reason = reason
73
+ self.evidence = evidence
74
+ self.findings = list(findings)
75
+
76
+
77
+ Probe = collections.namedtuple("Probe", "id phase needs run")
78
+
79
+ PROBES = []
80
+
81
+
82
+ def probe(id, phase, needs=()):
83
+ """Register a probe. `needs` are capability names, checked before it runs."""
84
+ def wrap(fn):
85
+ PROBES.append(Probe(id=id, phase=phase, needs=tuple(needs), run=fn))
86
+ return fn
87
+ return wrap
88
+
89
+
90
+ class Ctx(object):
91
+ """What a probe is given. Never a live handle — probes ask, they do not own."""
92
+
93
+ def __init__(self, root, profile, capabilities, offline=False,
94
+ out_rel=OUT_DIR):
95
+ self.root = root
96
+ self.profile = profile
97
+ self.capabilities = set(capabilities)
98
+ self.offline = offline
99
+ # Where this run writes. A probe excludes it so the audit does not
100
+ # read its own artefacts as project state.
101
+ self.out_rel = out_rel
102
+
103
+ def sh(self, *args, **kw):
104
+ """Run a command and return `(returncode, stdout, stderr)`.
105
+
106
+ Never raises. A probe decides what an empty answer means; this only
107
+ reports what happened, including the case where the binary is absent.
108
+ """
109
+ cwd = kw.pop("cwd", self.root)
110
+ try:
111
+ p = subprocess.run(list(args), cwd=cwd, capture_output=True,
112
+ text=True, timeout=TIMEOUT)
113
+ return p.returncode, p.stdout, p.stderr
114
+ except FileNotFoundError as exc:
115
+ return 127, "", str(exc)
116
+ except subprocess.TimeoutExpired:
117
+ return 124, "", "timed out after %ss" % TIMEOUT
118
+ except OSError as exc: # permissions, exec format
119
+ return 126, "", "%s: %s" % (type(exc).__name__, exc)
120
+
121
+
122
+ def classify_output(returncode, out, err):
123
+ """`read` only when something actually came back.
124
+
125
+ A zero exit with no output is the exact shape of both *nothing is wrong* and
126
+ *the instrument never looked*, so this refuses to call it an answer.
127
+ """
128
+ if returncode != 0:
129
+ return "blind"
130
+ if not (out or "").strip():
131
+ return "blind"
132
+ return "read"
133
+
134
+
135
+ def run_probe(p, ctx):
136
+ """Run one probe with its two guards, and never let it take the run down."""
137
+ missing = [n for n in p.needs if n not in ctx.capabilities]
138
+ if missing:
139
+ return Result("blind", "needs %s, not available here"
140
+ % ", ".join(sorted(missing)))
141
+ try:
142
+ result = p.run(ctx)
143
+ except Exception as exc: # noqa: BLE001 — deliberate
144
+ return Result("blind", "%s: %s" % (type(exc).__name__, exc))
145
+ if result is None:
146
+ # Standing instruction #1: a component that never received its input
147
+ # fails OPEN and is indistinguishable from one that approved.
148
+ return Result("blind", "the probe returned nothing")
149
+ return result
150
+
151
+
152
+ # ---------------------------------------------------------------------------
153
+ # git — the committed tree is the subject
154
+ # ---------------------------------------------------------------------------
155
+
156
+ def _git(root, *args):
157
+ try:
158
+ p = subprocess.run(["git", "-C", root] + list(args),
159
+ capture_output=True, text=True, timeout=TIMEOUT)
160
+ return p.returncode, p.stdout, p.stderr
161
+ except Exception as exc: # noqa: BLE001
162
+ return 127, "", str(exc)
163
+
164
+
165
+ def is_repo(root):
166
+ rc, out, _ = _git(root, "rev-parse", "--is-inside-work-tree")
167
+ return rc == 0 and out.strip() == "true"
168
+
169
+
170
+ def tracked_files(root):
171
+ """What a clone would get. Never `os.walk` — that reads build residue."""
172
+ rc, out, _ = _git(root, "ls-files")
173
+ if rc != 0:
174
+ return []
175
+ return [line for line in out.split("\n") if line.strip()]
176
+
177
+
178
+ def worktree_state(root, ignore=()):
179
+ """Disclose the disagreement; do not fail on it (standing instruction #10).
180
+
181
+ `ignore` exists because of a defect this suite caught on its own three-run
182
+ fixture: run 1 read a clean tree, run 2 read `docs/audit/` — the artefacts
183
+ run 1 had just written — and reported the project dirty. An instrument that
184
+ reads its own output is measuring itself, and the second reading is the one
185
+ an operator would have acted on.
186
+ """
187
+ # `-uall` matters and is not a preference. Plain `--porcelain` collapses an
188
+ # untracked directory to its shallowest path -- `?? docs/`, never
189
+ # `?? docs/audit/x.html` -- so an exclusion by path silently fails to match.
190
+ # Widening the match to "either is a prefix of the other" would fix this run
191
+ # and hide every other new file under `docs/`, which is worse than the bug.
192
+ rc, out, _ = _git(root, "status", "--porcelain", "-uall")
193
+ if rc != 0:
194
+ return {"dirty": None, "paths": [], "reason": "not a git repository"}
195
+ skip = tuple(i.rstrip("/") + "/" for i in ignore)
196
+ paths = [line[3:].strip().strip('"') for line in out.split("\n") if line.strip()]
197
+ paths = [p for p in paths if not any(p.startswith(s) for s in skip)]
198
+ return {"dirty": bool(paths), "paths": paths, "reason": ""}
199
+
200
+
201
+ def submodules(root):
202
+ rc, out, _ = _git(root, "submodule", "status")
203
+ if rc != 0 or not out.strip():
204
+ return []
205
+ rows = []
206
+ for line in out.split("\n"):
207
+ if not line.strip():
208
+ continue
209
+ parts = line.split()
210
+ if len(parts) >= 2:
211
+ rows.append({"sha": parts[0].lstrip("+-U"), "path": parts[1],
212
+ "describe": parts[2].strip("()") if len(parts) > 2 else ""})
213
+ return rows
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # discovery — what this project IS, before anything is measured about it
218
+ # ---------------------------------------------------------------------------
219
+
220
+ MANIFESTS = [
221
+ ("package.json", "javascript", "npm"),
222
+ ("pyproject.toml", "python", ""),
223
+ ("setup.py", "python", ""),
224
+ ("requirements.txt", "python", "pip"),
225
+ ("go.mod", "go", "go"),
226
+ ("Cargo.toml", "rust", "cargo"),
227
+ ("composer.json", "php", "composer"),
228
+ ("Gemfile", "ruby", "bundler"),
229
+ ("pom.xml", "java", "maven"),
230
+ ("build.gradle", "java", "gradle"),
231
+ ("pubspec.yaml", "dart", "pub"),
232
+ ("Package.swift", "swift", "spm"),
233
+ ]
234
+
235
+ LOCKS = {
236
+ "package-lock.json": "npm", "yarn.lock": "yarn", "pnpm-lock.yaml": "pnpm",
237
+ "bun.lockb": "bun", "poetry.lock": "poetry", "uv.lock": "uv",
238
+ "Pipfile.lock": "pipenv", "Cargo.lock": "cargo", "go.sum": "go",
239
+ "composer.lock": "composer", "Gemfile.lock": "bundler",
240
+ }
241
+
242
+ CI_MARKERS = [
243
+ (".github/workflows", "github-actions"),
244
+ (".gitlab-ci.yml", "gitlab-ci"),
245
+ ("Jenkinsfile", "jenkins"),
246
+ (".circleci/config.yml", "circleci"),
247
+ ("azure-pipelines.yml", "azure"),
248
+ (".drone.yml", "drone"),
249
+ ]
250
+
251
+ DEPLOY_MARKERS = [
252
+ ("Dockerfile", "docker"), ("docker-compose.yml", "compose"),
253
+ ("fly.toml", "fly"), ("vercel.json", "vercel"),
254
+ ("netlify.toml", "netlify"), ("wrangler.toml", "cloudflare-workers"),
255
+ ("app.yaml", "app-engine"), ("Procfile", "procfile"),
256
+ ("serverless.yml", "serverless"), ("k8s", "kubernetes"),
257
+ ("charts", "helm"), ("terraform", "terraform"),
258
+ ]
259
+
260
+ TELEMETRY_HINTS = [
261
+ ("sentry", "sentry"), ("@sentry/", "sentry"), ("sentry-sdk", "sentry"),
262
+ ("opentelemetry", "opentelemetry"), ("bugsnag", "bugsnag"),
263
+ ("rollbar", "rollbar"), ("datadog", "datadog"), ("dd-trace", "datadog"),
264
+ ("posthog", "posthog"), ("mixpanel", "mixpanel"), ("amplitude", "amplitude"),
265
+ ("newrelic", "new-relic"), ("prometheus", "prometheus"),
266
+ ]
267
+
268
+ DOC_MARKERS = ["README.md", "docs", "CLAUDE.md", "AGENTS.md", "CONTRIBUTING.md",
269
+ "CHANGELOG.md", "docs/adr", "docs/evidence", "ARCHITECTURE.md"]
270
+
271
+
272
+ def _read(path, limit=200000):
273
+ try:
274
+ with open(path, encoding="utf-8", errors="replace") as fh:
275
+ return fh.read(limit)
276
+ except OSError:
277
+ return ""
278
+
279
+
280
+ def _manifest_version(root):
281
+ """The version the project states about itself, from the manifest it uses."""
282
+ pj = os.path.join(root, "package.json")
283
+ if os.path.exists(pj):
284
+ try:
285
+ return (json.loads(_read(pj)) or {}).get("version")
286
+ except ValueError:
287
+ return None
288
+ pt = os.path.join(root, "pyproject.toml")
289
+ if os.path.exists(pt):
290
+ m = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)', _read(pt))
291
+ if m:
292
+ return m.group(1)
293
+ cg = os.path.join(root, "Cargo.toml")
294
+ if os.path.exists(cg):
295
+ m = re.search(r'(?m)^\s*version\s*=\s*["\']([^"\']+)', _read(cg))
296
+ if m:
297
+ return m.group(1)
298
+ return None
299
+
300
+
301
+ def _manifest_name(root):
302
+ pj = os.path.join(root, "package.json")
303
+ if os.path.exists(pj):
304
+ try:
305
+ return (json.loads(_read(pj)) or {}).get("name")
306
+ except ValueError:
307
+ pass
308
+ pt = os.path.join(root, "pyproject.toml")
309
+ if os.path.exists(pt):
310
+ m = re.search(r'(?m)^\s*name\s*=\s*["\']([^"\']+)', _read(pt))
311
+ if m:
312
+ return m.group(1)
313
+ return os.path.basename(os.path.abspath(root))
314
+
315
+
316
+ def discover(root):
317
+ """The profile every later phase is chosen from.
318
+
319
+ Deliberately answers about the *committed* project where git is available:
320
+ a `node_modules` directory says nothing about what the project is, and a
321
+ walk of the working tree would find thousands of them.
322
+ """
323
+ root = os.path.abspath(root)
324
+ tracked = set(tracked_files(root)) if is_repo(root) else set()
325
+
326
+ def present(rel):
327
+ if tracked:
328
+ return rel in tracked or any(p.startswith(rel.rstrip("/") + "/")
329
+ for p in tracked)
330
+ return os.path.exists(os.path.join(root, rel))
331
+
332
+ languages, managers = [], []
333
+ for fname, lang, mgr in MANIFESTS:
334
+ if present(fname):
335
+ if lang not in languages:
336
+ languages.append(lang)
337
+ if mgr and mgr not in managers:
338
+ managers.append(mgr)
339
+ for lock, mgr in LOCKS.items():
340
+ if present(lock) and mgr not in managers:
341
+ managers.append(mgr)
342
+
343
+ ci = [name for marker, name in CI_MARKERS if present(marker)]
344
+ deploy = [name for marker, name in DEPLOY_MARKERS if present(marker)]
345
+ docs = [d for d in DOC_MARKERS if present(d)]
346
+ subs = submodules(root)
347
+
348
+ # A monorepo by any of the three shapes that actually change how it is read.
349
+ nested_manifests = [p for p in tracked
350
+ if p.count("/") >= 1 and os.path.basename(p) in
351
+ {m[0] for m in MANIFESTS}]
352
+ workspaces = False
353
+ pj = os.path.join(root, "package.json")
354
+ if os.path.exists(pj):
355
+ try:
356
+ workspaces = bool((json.loads(_read(pj)) or {}).get("workspaces"))
357
+ except ValueError:
358
+ workspaces = False
359
+
360
+ manifest_blob = " ".join(
361
+ _read(os.path.join(root, f)) for f in
362
+ ("package.json", "pyproject.toml", "requirements.txt", "go.mod",
363
+ "Cargo.toml", "composer.json") if os.path.exists(os.path.join(root, f))
364
+ ).lower()
365
+ telemetry = sorted({name for hint, name in TELEMETRY_HINTS
366
+ if hint in manifest_blob})
367
+
368
+ return {
369
+ "root": root,
370
+ "name": _manifest_name(root),
371
+ "version": _manifest_version(root),
372
+ "vcs": "git" if is_repo(root) else "none",
373
+ "languages": sorted(languages),
374
+ "managers": sorted(managers),
375
+ "monorepo": bool(workspaces or subs or len(nested_manifests) > 1),
376
+ "workspaces": workspaces,
377
+ "submodules": subs,
378
+ "ci": ci,
379
+ "deploy": deploy,
380
+ "docs": docs,
381
+ "telemetry": telemetry,
382
+ "tracked_files": len(tracked),
383
+ }
384
+
385
+
386
+ # ---------------------------------------------------------------------------
387
+ # REQ-04 — one version, two trees
388
+ # ---------------------------------------------------------------------------
389
+
390
+ def _digest(blob):
391
+ if isinstance(blob, str):
392
+ blob = blob.encode("utf-8", "replace")
393
+ return hashlib.sha256(blob).hexdigest()
394
+
395
+
396
+ def compare_channels(label, trees):
397
+ """Do the channels that claim one label actually ship one tree?
398
+
399
+ `trees` maps a channel name to `{path: content}`, or to `None` where the
400
+ channel could not be fetched. The whole point of this function is that it
401
+ never looks at the label: yesterday's family defect shipped three channels
402
+ agreeing on `1.15.0` and disagreeing by 231 lines, under a pin checker that
403
+ was green because it compared the two strings.
404
+ """
405
+ blind = sorted(name for name, tree in trees.items() if tree is None)
406
+ readable = {name: tree for name, tree in trees.items() if tree is not None}
407
+ if len(readable) < 2:
408
+ return {"label": label, "diverged": None, "differing": [],
409
+ "blind": blind, "channels": sorted(trees)}
410
+
411
+ per_path = collections.defaultdict(dict)
412
+ for channel, tree in readable.items():
413
+ for path, content in tree.items():
414
+ per_path[path][channel] = _digest(content)
415
+
416
+ # Two different facts, and conflating them produces a false positive of the
417
+ # exact shape this whole skill exists to catch. A path in one channel and
418
+ # not another is *packaging* -- an npm tarball ships what `files` allows and
419
+ # nothing else, so `.github/` is absent by design. A path in BOTH channels
420
+ # whose bytes differ is *divergence*: one version string, two artefacts.
421
+ # The first draft counted both and reported 22 differing paths on a member
422
+ # where one file had actually moved.
423
+ differing, only_in = [], collections.defaultdict(list)
424
+ for path, by_channel in per_path.items():
425
+ if len(by_channel) != len(readable):
426
+ for channel in readable:
427
+ if channel not in by_channel:
428
+ only_in[channel].append(path)
429
+ continue
430
+ if len(set(by_channel.values())) > 1:
431
+ differing.append(path)
432
+ return {"label": label, "diverged": bool(differing),
433
+ "differing": sorted(differing), "blind": blind,
434
+ "only_in": {k: sorted(v) for k, v in only_in.items()},
435
+ "channels": sorted(trees)}
436
+
437
+
438
+ # ---------------------------------------------------------------------------
439
+ # REQ-09 — a secret's place and class, never its value
440
+ # ---------------------------------------------------------------------------
441
+
442
+ SECRET_PATTERNS = [
443
+ ("npm token", re.compile(r"\bnpm_[A-Za-z0-9]{36}\b")),
444
+ ("github token", re.compile(r"\bgh[pousr]_[A-Za-z0-9]{36,}\b")),
445
+ ("openai-style key", re.compile(r"\bsk-[A-Za-z0-9_-]{20,}\b")),
446
+ ("linear api key", re.compile(r"\blin_api_[A-Za-z0-9]{20,}\b")),
447
+ ("aws access key", re.compile(r"\bAKIA[0-9A-Z]{16}\b")),
448
+ ("slack token", re.compile(r"\bxox[abprs]-[A-Za-z0-9-]{10,}\b")),
449
+ ("google api key", re.compile(r"\bAIza[0-9A-Za-z_-]{35}\b")),
450
+ ("stripe secret", re.compile(r"\b[sr]k_(?:live|test)_[A-Za-z0-9]{20,}\b")),
451
+ ("private key block", re.compile(r"-----BEGIN [A-Z ]*PRIVATE KEY-----")),
452
+ ("jwt", re.compile(r"\beyJ[A-Za-z0-9_-]{10,}\.[A-Za-z0-9_-]{10,}\."
453
+ r"[A-Za-z0-9_-]{10,}\b")),
454
+ ]
455
+
456
+ SKIP_EXT = {".png", ".jpg", ".jpeg", ".gif", ".webp", ".ico", ".pdf", ".zip",
457
+ ".gz", ".tgz", ".woff", ".woff2", ".ttf", ".mp4", ".mp3", ".lockb"}
458
+
459
+
460
+ def scan_secrets(root, paths=None):
461
+ """Findings that name where and which, and carry no part of the value.
462
+
463
+ The redaction is total rather than a prefix: a report is a file people
464
+ forward, and half a credential plus its context is often enough to finish.
465
+ """
466
+ root = os.path.abspath(root)
467
+ if paths is None:
468
+ paths = tracked_files(root) or []
469
+ out = []
470
+ for rel in paths:
471
+ if os.path.splitext(rel)[1].lower() in SKIP_EXT:
472
+ continue
473
+ full = os.path.join(root, rel)
474
+ if not os.path.isfile(full):
475
+ continue
476
+ try:
477
+ if os.path.getsize(full) > 2_000_000:
478
+ continue
479
+ except OSError:
480
+ continue
481
+ text = _read(full)
482
+ if not text:
483
+ continue
484
+ for line_no, line in enumerate(text.split("\n"), 1):
485
+ for name, pattern in SECRET_PATTERNS:
486
+ if pattern.search(line):
487
+ out.append({
488
+ "where": "%s:%d" % (rel, line_no),
489
+ "class": name,
490
+ "remedy": "rotate the credential at its issuer, then "
491
+ "remove it from the tree; if it is in "
492
+ "history, rotation is the fix and rewriting "
493
+ "history is not",
494
+ "in_history": False,
495
+ })
496
+ break
497
+ return out
498
+
499
+
500
+ def scan_secret_history(root, limit=400):
501
+ """The same classes, in what git still holds. Also value-free."""
502
+ rc, out, _ = _git(root, "log", "--format=%H", "-n", str(limit))
503
+ if rc != 0:
504
+ return []
505
+ found = []
506
+ for sha in [s for s in out.split("\n") if s.strip()][:limit]:
507
+ rc2, diff, _ = _git(root, "show", "--format=", "--unified=0", sha)
508
+ if rc2 != 0 or not diff:
509
+ continue
510
+ for name, pattern in SECRET_PATTERNS:
511
+ if pattern.search(diff):
512
+ found.append({"where": "commit %s" % sha[:12], "class": name,
513
+ "remedy": "rotate the credential at its issuer",
514
+ "in_history": True})
515
+ break
516
+ return found
517
+
518
+
519
+ # ---------------------------------------------------------------------------
520
+ # findings, their identity, and the board's own arithmetic
521
+ # ---------------------------------------------------------------------------
522
+
523
+ def finding_id(probe_id, where, title):
524
+ """Stable across a rewording, distinct across a place.
525
+
526
+ An id derived from the free text makes every run report every finding as
527
+ new, and a ratchet whose diff is always total is a snapshot with extra
528
+ steps.
529
+ """
530
+ norm = re.sub(r"[^a-z0-9]+", " ", (title or "").lower()).strip()
531
+ key = "|".join([probe_id or "", where or "", norm])
532
+ return "f-" + hashlib.sha256(key.encode("utf-8")).hexdigest()[:12]
533
+
534
+
535
+ def priority(blast, age_runs, effort):
536
+ """The board's formula, unchanged: `P = blast × (1 + age_runs) / effort`."""
537
+ effort = effort or 1
538
+ return round(float(blast) * (1 + float(age_runs)) / float(effort), 2)
539
+
540
+
541
+ def board_row(finding, board_id, source):
542
+ """One line, pipes escaped, in the columns the board already has.
543
+
544
+ A row that spans lines, or carries a bare pipe, shifts every later column —
545
+ the family has already lost four ids and one status cell that way.
546
+ """
547
+ def cell(value):
548
+ text = " ".join(str(value or "").split())
549
+ return text.replace("|", r"\|")
550
+
551
+ what = "**%s** %s" % (cell(finding.get("title")),
552
+ cell(finding.get("remedy")))
553
+ return "| %s | %s | %s | %s | %s | %s | %s | open |" % (
554
+ board_id, what, cell(source), finding.get("blast", 2),
555
+ finding.get("runs_open", 0), finding.get("effort", 2),
556
+ finding.get("p", priority(finding.get("blast", 2),
557
+ finding.get("runs_open", 0),
558
+ finding.get("effort", 2))))
559
+
560
+
561
+ # ---------------------------------------------------------------------------
562
+ # the ratchet
563
+ # ---------------------------------------------------------------------------
564
+
565
+ def _ids(payload):
566
+ return [f.get("id") for f in (payload or {}).get("findings", []) if f.get("id")]
567
+
568
+
569
+ def ratchet(current, prior):
570
+ """What moved. A first run says so rather than calling everything new."""
571
+ now = set(_ids(current))
572
+ if prior is None:
573
+ return {"first_run": True, "closed": [], "new": [], "carried": [],
574
+ "unranked": sorted(now)}
575
+ before = set(_ids(prior))
576
+ return {
577
+ "first_run": False,
578
+ "closed": sorted(before - now),
579
+ "new": sorted(now - before),
580
+ "carried": sorted(now & before),
581
+ "unranked": [],
582
+ }
583
+
584
+
585
+ def carry_forward(current, prior):
586
+ """Age a surviving finding, and keep the date it was first seen.
587
+
588
+ `runs_open` is the age term in the board's priority, so a row nobody picks
589
+ up rises on its own. Without this, an audit's findings are all equally new
590
+ forever and the ordering never learns anything.
591
+ """
592
+ prior_by_id = {f.get("id"): f for f in (prior or {}).get("findings", [])}
593
+ for f in current.get("findings", []):
594
+ old = prior_by_id.get(f.get("id"))
595
+ if old:
596
+ f["first_seen"] = old.get("first_seen", f.get("first_seen"))
597
+ f["runs_open"] = int(old.get("runs_open", 0)) + 1
598
+ else:
599
+ f.setdefault("runs_open", 0)
600
+ f["p"] = priority(f.get("blast", 2), f.get("runs_open", 0),
601
+ f.get("effort", 2))
602
+ return current
603
+
604
+
605
+ def load_prior(out_dir, exclude=None):
606
+ """The newest sidecar that is not this run's own."""
607
+ if not os.path.isdir(out_dir):
608
+ return None
609
+ names = sorted(n for n in os.listdir(out_dir)
610
+ if n.endswith(".json") and n != (exclude or ""))
611
+ for name in reversed(names):
612
+ try:
613
+ with open(os.path.join(out_dir, name), encoding="utf-8") as fh:
614
+ payload = json.load(fh)
615
+ if payload.get("schema") == SCHEMA:
616
+ return payload
617
+ except (ValueError, OSError):
618
+ continue
619
+ return None
620
+
621
+
622
+ # ---------------------------------------------------------------------------
623
+ # the page
624
+ # ---------------------------------------------------------------------------
625
+
626
+ STAMP_RE = re.compile(
627
+ r"\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2})?(Z|[+-]\d{2}:?\d{2})?")
628
+
629
+
630
+ def normalise_for_compare(body):
631
+ """Everything a second run legitimately changes, removed.
632
+
633
+ Standing instruction #2 asks for three real runs against a real tree; this
634
+ is what makes their outputs comparable without making the comparison
635
+ vacuous — only timestamps and the absolute root are erased.
636
+ """
637
+ body = STAMP_RE.sub("<STAMP>", body)
638
+ body = re.sub(r"/(?:private/)?(?:tmp|var)/[^\s\"'<]+", "<PATH>", body)
639
+ return body
640
+
641
+
642
+ CSS = """
643
+ :root{--bg:#fbfaf8;--surface:#fff;--surface-2:#f4f2ee;--ink:#1a1917;
644
+ --ink-soft:#5e5b55;--ink-faint:#8b8780;--line:#e2ded6;--line-2:#c9c4b8;
645
+ --ok:#0f7a4a;--ok-bg:#e8f5ee;--warn:#8a5a00;--warn-bg:#fdf3e0;
646
+ --bad:#a32a24;--bad-bg:#fdeceb;--info:#1f5c8f;--info-bg:#e9f1f8;--accent:#2a4d8f;
647
+ --mono:ui-monospace,SFMono-Regular,Menlo,Consolas,monospace;
648
+ --sans:-apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif}
649
+ @media (prefers-color-scheme: dark){:root:not([data-theme="light"]){
650
+ --bg:#16171a;--surface:#1d1f23;--surface-2:#23262b;--ink:#eceae6;
651
+ --ink-soft:#a8a49c;--ink-faint:#7c7871;--line:#2e3238;--line-2:#40454d;
652
+ --ok:#6fd39b;--ok-bg:#17332a;--warn:#e0a94a;--warn-bg:#33290f;
653
+ --bad:#f08a84;--bad-bg:#3a1c1a;--info:#8ec2ee;--info-bg:#15293a;--accent:#8fb4ee}}
654
+ *{box-sizing:border-box}
655
+ body{margin:0;background:var(--bg);color:var(--ink);font:16px/1.62 var(--sans);
656
+ padding:0 0 5rem}
657
+ .wrap{max-width:58rem;margin:0 auto;padding:0 1.5rem}
658
+ header.top{border-bottom:1px solid var(--line);background:var(--surface);
659
+ padding:2.4rem 0 1.8rem;margin-bottom:2.2rem}
660
+ .kicker{font:600 .72rem/1 var(--mono);letter-spacing:.14em;text-transform:uppercase;
661
+ color:var(--ink-faint);margin:0 0 .8rem}
662
+ h1{font-size:1.95rem;line-height:1.18;margin:0 0 .6rem;letter-spacing:-.02em;font-weight:640}
663
+ .lede{font-size:1.03rem;color:var(--ink-soft);margin:0;max-width:44rem}
664
+ h2{font-size:1.28rem;margin:2.8rem 0 .5rem;font-weight:640;padding-bottom:.4rem;
665
+ border-bottom:2px solid var(--line-2)}
666
+ h3{font-size:1rem;margin:1.6rem 0 .35rem;font-weight:650}
667
+ p{margin:.6rem 0}
668
+ code{font:.85em/1.45 var(--mono);background:var(--surface-2);padding:.1em .34em;
669
+ border-radius:4px;border:1px solid var(--line)}
670
+ pre{background:var(--surface-2);border:1px solid var(--line);border-radius:8px;
671
+ padding:.8rem 1rem;overflow-x:auto;font:.79rem/1.55 var(--mono);margin:.6rem 0}
672
+ pre code{background:none;border:none;padding:0}
673
+ .tw{overflow-x:auto;margin:.9rem 0;border:1px solid var(--line);border-radius:8px;
674
+ background:var(--surface)}
675
+ table{border-collapse:collapse;width:100%;font-size:.86rem}
676
+ th,td{padding:.48rem .7rem;text-align:left;border-bottom:1px solid var(--line);
677
+ vertical-align:top}
678
+ th{background:var(--surface-2);font-weight:650;font-size:.76rem;letter-spacing:.02em;
679
+ text-transform:uppercase;color:var(--ink-soft);white-space:nowrap}
680
+ tbody tr:last-child td{border-bottom:none}
681
+ td.num,th.num{text-align:right;font-family:var(--mono);font-size:.81rem;white-space:nowrap}
682
+ td.mono{font-family:var(--mono);font-size:.79rem}
683
+ .pill{display:inline-block;font:600 .7rem/1.35 var(--mono);padding:.12rem .45rem;
684
+ border-radius:4px;white-space:nowrap}
685
+ .p-ok{background:var(--ok-bg);color:var(--ok)}
686
+ .p-warn{background:var(--warn-bg);color:var(--warn)}
687
+ .p-bad{background:var(--bad-bg);color:var(--bad)}
688
+ .p-info{background:var(--info-bg);color:var(--info)}
689
+ .p-neutral{background:var(--surface-2);color:var(--ink-faint)}
690
+ .stats{display:grid;grid-template-columns:repeat(auto-fit,minmax(8rem,1fr));
691
+ gap:.7rem;margin:1.3rem 0}
692
+ .stat{background:var(--surface);border:1px solid var(--line);border-radius:8px;
693
+ padding:.7rem .85rem}
694
+ .stat .v{font:650 1.35rem/1.1 var(--sans);letter-spacing:-.02em;display:block}
695
+ .stat .k{font:.69rem/1.3 var(--mono);color:var(--ink-faint);text-transform:uppercase;
696
+ letter-spacing:.05em}
697
+ .card{border:1px solid var(--line);border-left:3px solid var(--line-2);
698
+ background:var(--surface);border-radius:6px;padding:.9rem 1.1rem;margin:1rem 0}
699
+ .card.bad{border-left-color:var(--bad)}.card.warn{border-left-color:var(--warn)}
700
+ .card.info{border-left-color:var(--info)}.card.ok{border-left-color:var(--ok)}
701
+ .card h3{margin-top:0}
702
+ .meta{font:.73rem/1.4 var(--mono);color:var(--ink-faint);margin:.3rem 0 .5rem}
703
+ .note{font-size:.86rem;color:var(--ink-soft);border-left:2px solid var(--line-2);
704
+ padding-left:.8rem;margin:.8rem 0}
705
+ footer{margin-top:3.5rem;padding-top:1.3rem;border-top:1px solid var(--line);
706
+ font-size:.81rem;color:var(--ink-faint)}
707
+ @media (max-width:640px){h1{font-size:1.5rem}}
708
+ """
709
+
710
+
711
+ def _e(value):
712
+ return _html.escape(str(value if value is not None else ""), quote=True)
713
+
714
+
715
+ def _sev_pill(sev):
716
+ cls = {"critical": "p-bad", "high": "p-bad", "medium": "p-warn",
717
+ "low": "p-info"}.get(str(sev).lower(), "p-neutral")
718
+ return '<span class="pill %s">%s</span>' % (cls, _e(sev))
719
+
720
+
721
+ def render_html(payload):
722
+ """A self-contained page. No external request is reachable from it.
723
+
724
+ Everything a probe could not do is rendered as loudly as everything it
725
+ found — the *what was not looked at* table is not an appendix, because a
726
+ page that hides its blind spots is read as a clean bill.
727
+ """
728
+ p = payload.get("profile", {}) or {}
729
+ counts = payload.get("counts", {}) or {}
730
+ rat = payload.get("ratchet", {}) or {}
731
+ findings = payload.get("findings", []) or []
732
+ probes = payload.get("probes", []) or []
733
+ blind = [x for x in probes if x.get("verdict") == "blind"]
734
+
735
+ out = []
736
+ add = out.append
737
+ add("<!doctype html>\n<html lang=\"en\">\n<head>\n<meta charset=\"utf-8\">")
738
+ add('<meta name="viewport" content="width=device-width, initial-scale=1">')
739
+ add("<title>Audit — %s</title>" % _e(p.get("name") or "project"))
740
+ add("<style>%s</style>\n</head>\n<body>" % CSS)
741
+
742
+ add('<header class="top"><div class="wrap">')
743
+ add('<p class="kicker">project-audit · %s · read-only</p>'
744
+ % _e(payload.get("generated_at", "")))
745
+ add("<h1>%s</h1>" % _e(p.get("name") or "project"))
746
+ add('<p class="lede">Every number below was produced by a command this run '
747
+ 'executed. What could not be measured is listed as such, never omitted '
748
+ 'and never counted as clean.</p>')
749
+ add("</div></header>")
750
+
751
+ add('<div class="wrap">')
752
+
753
+ # --- headline -----------------------------------------------------------
754
+ add('<div class="stats">')
755
+ for value, key in (
756
+ (counts.get("findings", len(findings)), "findings"),
757
+ (counts.get("probes_run", 0), "probes run"),
758
+ (counts.get("probes_blind", len(blind)), "blind"),
759
+ (len(rat.get("closed", [])), "closed since last"),
760
+ (len(rat.get("new", [])), "new"),
761
+ (sum(1 for f in findings if int(f.get("runs_open", 0)) >= 3), "open 3+ runs"),
762
+ ):
763
+ add('<div class="stat"><span class="v">%s</span>'
764
+ '<span class="k">%s</span></div>' % (_e(value), _e(key)))
765
+ add("</div>")
766
+
767
+ if rat.get("first_run"):
768
+ add('<p class="note"><strong>First run.</strong> There is no earlier '
769
+ 'sidecar in this directory, so nothing is reported as closed or '
770
+ 'new — a diff against a run that never happened would be a claim '
771
+ 'about nothing. The next audit will have both columns.</p>')
772
+
773
+ # --- what this project is ----------------------------------------------
774
+ add("<h2>What this project is</h2>")
775
+ add('<div class="tw"><table><tbody>')
776
+ for label, value in (
777
+ ("version", p.get("version") or "— (no manifest version)"),
778
+ ("languages", ", ".join(p.get("languages") or []) or "—"),
779
+ ("package managers", ", ".join(p.get("managers") or []) or "—"),
780
+ ("monorepo", "yes" if p.get("monorepo") else "no"),
781
+ ("submodules", len(p.get("submodules") or []) or "0"),
782
+ ("CI", ", ".join(p.get("ci") or []) or "none configured"),
783
+ ("deploy targets", ", ".join(p.get("deploy") or []) or "none declared"),
784
+ ("error telemetry", ", ".join(p.get("telemetry") or [])
785
+ or "none found in manifests"),
786
+ ("tracked files", p.get("tracked_files", 0)),
787
+ ):
788
+ add("<tr><th>%s</th><td>%s</td></tr>" % (_e(label), _e(value)))
789
+ add("</tbody></table></div>")
790
+
791
+ # --- findings -----------------------------------------------------------
792
+ add("<h2>Findings</h2>")
793
+ if not findings:
794
+ add('<p>No finding survived this run\'s probes. That is a statement '
795
+ 'about the probes that ran — read the next section before taking '
796
+ 'it as a clean bill.</p>')
797
+ else:
798
+ for f in sorted(findings, key=lambda x: -float(x.get("p", 0))):
799
+ cls = {"critical": "bad", "high": "bad", "medium": "warn"}.get(
800
+ str(f.get("severity", "")).lower(), "info")
801
+ add('<div class="card %s">' % cls)
802
+ add("<h3>%s %s</h3>" % (_sev_pill(f.get("severity", "info")),
803
+ _e(f.get("title"))))
804
+ age = int(f.get("runs_open", 0))
805
+ add('<p class="meta">%s · P=%s · first seen %s%s</p>' % (
806
+ _e(f.get("where") or "—"), _e(f.get("p", "—")),
807
+ _e(f.get("first_seen") or "—"),
808
+ (" · open %d runs" % age) if age else ""))
809
+ if f.get("detail"):
810
+ add("<p>%s</p>" % _e(f["detail"]))
811
+ if f.get("evidence"):
812
+ add("<pre><code>%s</code></pre>" % _e(f["evidence"]))
813
+ if f.get("remedy"):
814
+ add("<p><strong>Remedy.</strong> %s</p>" % _e(f["remedy"]))
815
+ add("</div>")
816
+
817
+ # --- the blind spots, deliberately not an appendix ----------------------
818
+ add("<h2>What was not looked at</h2>")
819
+ add('<p>A probe that could not run returns <code>blind</code>, never '
820
+ '<code>clean</code>. An empty section here would mean every probe '
821
+ 'answered — not that nothing is wrong.</p>')
822
+ if not blind:
823
+ add('<p><span class="pill p-ok">every probe answered</span></p>')
824
+ else:
825
+ add('<div class="tw"><table><thead><tr><th>Probe</th><th>Phase</th>'
826
+ "<th>Why not</th></tr></thead><tbody>")
827
+ for b in blind:
828
+ add("<tr><td class=\"mono\">%s</td><td>%s</td><td>%s</td></tr>"
829
+ % (_e(b.get("id")), _e(b.get("phase")), _e(b.get("reason"))))
830
+ add("</tbody></table></div>")
831
+
832
+ # --- the ratchet --------------------------------------------------------
833
+ if not rat.get("first_run"):
834
+ add("<h2>What moved since the last audit</h2>")
835
+ add('<div class="tw"><table><thead><tr><th>Movement</th>'
836
+ '<th class="num">Count</th><th>Ids</th></tr></thead><tbody>')
837
+ for label, key in (("closed", "closed"), ("new", "new"),
838
+ ("still open", "carried")):
839
+ ids = rat.get(key, [])
840
+ add('<tr><td>%s</td><td class="num">%d</td><td class="mono">%s</td></tr>'
841
+ % (_e(label), len(ids), _e(", ".join(ids[:12]) or "—")))
842
+ add("</tbody></table></div>")
843
+ stale = [f for f in findings if int(f.get("runs_open", 0)) >= 3]
844
+ if stale:
845
+ add('<p class="note"><strong>%d finding(s) have survived three or '
846
+ 'more audits.</strong> That is itself the finding: a defect '
847
+ 'nobody picks up is a decision nobody wrote down.</p>'
848
+ % len(stale))
849
+
850
+ # --- probes ran ---------------------------------------------------------
851
+ add("<h2>Probes</h2>")
852
+ add('<div class="tw"><table><thead><tr><th>Probe</th><th>Phase</th>'
853
+ "<th>Verdict</th><th>Note</th></tr></thead><tbody>")
854
+ for x in probes:
855
+ v = x.get("verdict", "")
856
+ pill = {"clean": "p-ok", "finding": "p-bad",
857
+ "blind": "p-neutral"}.get(v, "p-neutral")
858
+ add('<tr><td class="mono">%s</td><td>%s</td>'
859
+ '<td><span class="pill %s">%s</span></td><td>%s</td></tr>'
860
+ % (_e(x.get("id")), _e(x.get("phase")), pill, _e(v),
861
+ _e(x.get("reason") or "")))
862
+ add("</tbody></table></div>")
863
+
864
+ add("<footer><p>Generated by <code>project-audit</code> at %s against "
865
+ "<code>%s</code>. Read-only: this run changed nothing but the two "
866
+ "files it wrote. Sidecar: <code>%s</code>.</p></footer>"
867
+ % (_e(payload.get("generated_at", "")), _e(payload.get("root", "")),
868
+ _e(payload.get("sidecar", ""))))
869
+ add("</div>\n</body>\n</html>")
870
+ return "\n".join(out)
871
+
872
+
873
+ def open_in_browser(path, opener=None):
874
+ """Best effort, and a miss never fails the run (REQ-10)."""
875
+ candidates = [opener] if opener else (
876
+ ["open"] if sys.platform == "darwin" else
877
+ ["xdg-open"] if sys.platform.startswith("linux") else ["start"])
878
+ for cmd in candidates:
879
+ if not cmd:
880
+ continue
881
+ if shutil.which(cmd) is None:
882
+ return False, "%s is not on PATH — open %s yourself" % (cmd, path)
883
+ try:
884
+ rc = subprocess.run([cmd, path], capture_output=True,
885
+ timeout=TIMEOUT).returncode
886
+ except Exception as exc: # noqa: BLE001
887
+ return False, "%s: %s" % (type(exc).__name__, exc)
888
+ if rc == 0:
889
+ return True, ""
890
+ return False, "%s exited %d — open %s yourself" % (cmd, rc, path)
891
+ return False, "no opener for this platform — open %s yourself" % path
892
+
893
+
894
+ # ---------------------------------------------------------------------------
895
+ # the probes that ship with the collector
896
+ # ---------------------------------------------------------------------------
897
+
898
+ def _finding(probe_id, where, title, severity, blast, effort, remedy,
899
+ detail="", evidence=""):
900
+ return {
901
+ "id": finding_id(probe_id, where, title), "probe": probe_id,
902
+ "title": title, "severity": severity, "where": where,
903
+ "detail": detail, "evidence": evidence, "remedy": remedy,
904
+ "blast": blast, "effort": effort, "runs_open": 0,
905
+ "first_seen": datetime.date.today().isoformat(),
906
+ }
907
+
908
+
909
+ @probe("secrets-tree", "probe", needs=("git",))
910
+ def _p_secrets_tree(ctx):
911
+ rows = scan_secrets(ctx.root)
912
+ if not rows:
913
+ return Result("clean", "no credential pattern in the tracked tree")
914
+ return Result("finding", "%d credential pattern(s)" % len(rows), findings=[
915
+ _finding("secrets-tree", r["where"],
916
+ "A %s is committed in the tree" % r["class"],
917
+ "critical", 3, 1, r["remedy"]) for r in rows])
918
+
919
+
920
+ @probe("secrets-history", "probe", needs=("git",))
921
+ def _p_secrets_history(ctx):
922
+ rows = scan_secret_history(ctx.root)
923
+ if not rows:
924
+ return Result("clean", "no credential pattern in the last 400 commits")
925
+ return Result("finding", "%d in history" % len(rows), findings=[
926
+ _finding("secrets-history", r["where"],
927
+ "A %s appears in git history" % r["class"],
928
+ "critical", 3, 2, r["remedy"]) for r in rows])
929
+
930
+
931
+ @probe("worktree", "probe", needs=("git",))
932
+ def _p_worktree(ctx):
933
+ state = worktree_state(ctx.root, ignore=(ctx.out_rel,))
934
+ if state.get("dirty") is None:
935
+ return Result("blind", state.get("reason") or "cannot read git status")
936
+ if not state["dirty"]:
937
+ return Result("clean", "working tree clean")
938
+ return Result("clean", "%d uncommitted path(s) — disclosed, not a finding: "
939
+ "an audit reports the committed project"
940
+ % len(state["paths"]))
941
+
942
+
943
+ @probe("telemetry", "prod", needs=())
944
+ def _p_telemetry(ctx):
945
+ found = ctx.profile.get("telemetry") or []
946
+ if found:
947
+ return Result("clean", "declares %s" % ", ".join(found))
948
+ langs = ctx.profile.get("languages") or []
949
+ deploy = ctx.profile.get("deploy") or []
950
+ if not deploy:
951
+ return Result("clean", "no deploy target declared — a library or tool, "
952
+ "for which absent telemetry is a design, not a gap")
953
+ return Result("finding", "no error reporting found", findings=[_finding(
954
+ "telemetry", "manifests",
955
+ "A deployed surface reports no errors anywhere its maintainer can see",
956
+ "medium", 2, 2,
957
+ "add an error reporter, or record the decision not to — the gap worth "
958
+ "closing is that nobody wrote down which it is",
959
+ detail="Deploy targets declared (%s) with no telemetry dependency in "
960
+ "any manifest. A failure on a user's machine is invisible."
961
+ % (", ".join(deploy) or "none"),
962
+ evidence="languages=%s deploy=%s telemetry=[]"
963
+ % (",".join(langs), ",".join(deploy)))])
964
+
965
+
966
+ @probe("ci-present", "prod", needs=())
967
+ def _p_ci(ctx):
968
+ if ctx.profile.get("ci"):
969
+ return Result("clean", "CI configured: %s"
970
+ % ", ".join(ctx.profile["ci"]))
971
+ return Result("finding", "no CI configuration", findings=[_finding(
972
+ "ci-present", "repository root",
973
+ "Nothing runs the checks except a person remembering to",
974
+ "medium", 2, 2,
975
+ "add a workflow that runs the project's own test command on push",
976
+ evidence="no .github/workflows, .gitlab-ci.yml, Jenkinsfile or "
977
+ ".circleci/config.yml in the tracked tree")])
978
+
979
+
980
+ @probe("docs-present", "seams", needs=())
981
+ def _p_docs(ctx):
982
+ docs = ctx.profile.get("docs") or []
983
+ if "README.md" in docs:
984
+ return Result("clean", "%d documentation marker(s): %s"
985
+ % (len(docs), ", ".join(docs)))
986
+ return Result("finding", "no README", findings=[_finding(
987
+ "docs-present", "repository root",
988
+ "The project has no README, so its entry point is a person",
989
+ "low", 1, 1, "write the four lines: what it is, how to run it, how to "
990
+ "test it, where the docs are")])
991
+
992
+
993
+ @probe("gitignore-secrets", "probe", needs=("git",))
994
+ def _p_gitignore(ctx):
995
+ tracked = tracked_files(ctx.root)
996
+ risky = [p for p in tracked
997
+ if os.path.basename(p) in (".env", ".npmrc", ".pypirc",
998
+ "id_rsa", "credentials")
999
+ or p.endswith(".pem") or p.endswith(".p12")]
1000
+ if not risky:
1001
+ return Result("clean", "no credential-shaped file is tracked")
1002
+ return Result("finding", "%d tracked" % len(risky), findings=[_finding(
1003
+ "gitignore-secrets", p,
1004
+ "A file that normally holds credentials is tracked by git",
1005
+ "high", 3, 1,
1006
+ "move it out of the tree and add it to .gitignore; if it ever held a "
1007
+ "live value, rotate that value first") for p in risky])
1008
+
1009
+
1010
+ def _tree_from_tag(root, ref, limit=4000):
1011
+ """`{path: content}` for a git ref, read through git rather than the disk."""
1012
+ rc, out, _ = _git(root, "ls-tree", "-r", "--name-only", ref)
1013
+ if rc != 0:
1014
+ return None
1015
+ tree = {}
1016
+ for rel in [x for x in out.split("\n") if x.strip()][:limit]:
1017
+ if os.path.splitext(rel)[1].lower() in SKIP_EXT:
1018
+ continue
1019
+ rc2, blob, _ = _git(root, "show", "%s:%s" % (ref, rel))
1020
+ if rc2 == 0:
1021
+ tree[rel] = blob
1022
+ return tree
1023
+
1024
+
1025
+ def _tree_from_npm(name, version, workdir):
1026
+ """`{path: content}` for what the registry actually serves."""
1027
+ import tarfile
1028
+ import urllib.request
1029
+ spec = "%s@%s" % (name, version)
1030
+ try:
1031
+ p = subprocess.run(["npm", "view", spec, "dist.tarball"],
1032
+ capture_output=True, text=True, timeout=TIMEOUT)
1033
+ except Exception: # noqa: BLE001
1034
+ return None
1035
+ url = (p.stdout or "").strip().split("\n")[0]
1036
+ if p.returncode != 0 or not url.startswith("https://"):
1037
+ return None
1038
+ tgz = os.path.join(workdir, "pkg.tgz")
1039
+ try:
1040
+ with urllib.request.urlopen(url, timeout=TIMEOUT) as resp:
1041
+ with open(tgz, "wb") as fh:
1042
+ shutil.copyfileobj(resp, fh, length=1 << 20)
1043
+ tree = {}
1044
+ with tarfile.open(tgz) as tar:
1045
+ for member in tar.getmembers():
1046
+ if not member.isfile() or member.size > 2_000_000:
1047
+ continue
1048
+ rel = member.name.split("/", 1)[-1] # strip the `package/` root
1049
+ if os.path.splitext(rel)[1].lower() in SKIP_EXT:
1050
+ continue
1051
+ fh = tar.extractfile(member)
1052
+ if fh is None:
1053
+ continue
1054
+ tree[rel] = fh.read().decode("utf-8", "replace")
1055
+ return tree
1056
+ except Exception: # noqa: BLE001
1057
+ return None
1058
+
1059
+
1060
+ @probe("channel-divergence", "prod", needs=("git",))
1061
+ def _p_channels(ctx):
1062
+ """One version string, more than one tree — the class a pin check cannot see.
1063
+
1064
+ **Which pair, and why not the obvious one.** The first draft compared the
1065
+ npm tarball against the git tag and reported `clean`. Those two agree by
1066
+ construction — npm publishes *from* the tag — so the answer was a tautology,
1067
+ and a tautology returning green is the `false success` shape `gates.md`
1068
+ names: a mechanism trusted by its own reply.
1069
+
1070
+ The channels a consumer actually installs from disagree elsewhere. npm
1071
+ serves the **tag**; a plugin marketplace and a skills CLI serve the **branch
1072
+ tip**. Measured in this family on 2026-08-22: npm served `agent_sync.py` at
1073
+ 4344 lines while the marketplace served 4575, and all three answered
1074
+ `1.15.0`. The check only means something when the two sides both claim the
1075
+ same version, so that is the precondition rather than the finding.
1076
+ """
1077
+ version = ctx.profile.get("version")
1078
+ name = ctx.profile.get("name")
1079
+ if not version:
1080
+ return Result("blind", "no version in a manifest to make a claim about")
1081
+ rc, _, _ = _git(ctx.root, "rev-parse", "--verify", "v%s^{}" % version)
1082
+ if rc != 0:
1083
+ return Result("blind", "HEAD says %s and no tag v%s exists — the "
1084
+ "channels make no common claim yet"
1085
+ % (version, version))
1086
+
1087
+ channels = {"git-tag v%s" % version: _tree_from_tag(ctx.root, "v%s" % version),
1088
+ "branch tip (HEAD)": _tree_from_tag(ctx.root, "HEAD")}
1089
+ if {"npm", "network"} <= ctx.capabilities and name:
1090
+ import tempfile
1091
+ work = tempfile.mkdtemp(prefix="pa-channels-")
1092
+ try:
1093
+ channels["npm %s" % name] = _tree_from_npm(name, version, work)
1094
+ finally:
1095
+ shutil.rmtree(work, ignore_errors=True)
1096
+
1097
+ verdict = compare_channels(version, channels)
1098
+ if verdict["diverged"] is None:
1099
+ return Result("blind", "fewer than two channels were readable: %s"
1100
+ % ", ".join(verdict["blind"]))
1101
+ if not verdict["diverged"]:
1102
+ return Result("clean", "%d channel(s) claiming %s ship the same tree"
1103
+ % (len(channels) - len(verdict["blind"]), version))
1104
+
1105
+ paths = verdict["differing"]
1106
+ shown = ", ".join(paths[:5]) + (" …" if len(paths) > 5 else "")
1107
+ return Result("finding", "%d path(s) differ" % len(paths), findings=[_finding(
1108
+ "channel-divergence", "v%s" % version,
1109
+ "One version string, more than one tree",
1110
+ "critical", 3, 1,
1111
+ "tag the tree the channels should share — a patch release from the "
1112
+ "branch tip, or a revert of what landed after the tag; a version that "
1113
+ "identifies two artefacts cannot be reasoned about, and no version "
1114
+ "check comparing strings will ever say so",
1115
+ detail="%s all answer to %s and disagree on %d path(s). Anything that "
1116
+ "compares version STRINGS stays green through this."
1117
+ % (", ".join(sorted(verdict["channels"])), version, len(paths)),
1118
+ evidence="differing: %s" % shown)])
1119
+
1120
+
1121
+ @probe("published-version", "prod", needs=("npm", "network"))
1122
+ def _p_published(ctx):
1123
+ name = ctx.profile.get("name")
1124
+ version = ctx.profile.get("version")
1125
+ if not (name and version):
1126
+ return Result("blind", "no name+version in a manifest")
1127
+ try:
1128
+ p = subprocess.run(["npm", "view", name, "version"],
1129
+ capture_output=True, text=True, timeout=TIMEOUT)
1130
+ except Exception as exc: # noqa: BLE001
1131
+ return Result("blind", "%s: %s" % (type(exc).__name__, exc))
1132
+ if classify_output(p.returncode, p.stdout, p.stderr) == "blind":
1133
+ return Result("blind", "npm view said nothing about %s — unpublished, "
1134
+ "private, or the registry is unreachable" % name)
1135
+ latest = p.stdout.strip().split("\n")[0]
1136
+ if latest == version:
1137
+ return Result("clean", "the registry serves %s, the manifest says %s"
1138
+ % (latest, version))
1139
+ return Result("finding", "registry %s vs manifest %s" % (latest, version),
1140
+ findings=[_finding(
1141
+ "published-version", "package.json",
1142
+ "The registry and the manifest disagree about the current version",
1143
+ "high", 3, 1,
1144
+ "publish the manifest's version, or bring the manifest back to what "
1145
+ "shipped; a reader cannot tell which one is the product",
1146
+ evidence="npm view %s version -> %s; manifest -> %s"
1147
+ % (name, latest, version))])
1148
+
1149
+
1150
+ # ---------------------------------------------------------------------------
1151
+ # the run
1152
+ # ---------------------------------------------------------------------------
1153
+
1154
+ def capabilities(root, offline):
1155
+ """What this machine can actually do, resolved once.
1156
+
1157
+ A probe asks for a capability by name and never shells out to find out —
1158
+ that way the reason a probe was skipped is a fact the report can print.
1159
+ """
1160
+ caps = set()
1161
+ if is_repo(root):
1162
+ caps.add("git")
1163
+ for binary, name in (("gh", "gh"), ("npm", "npm"), ("python3", "python3"),
1164
+ ("node", "node"), ("cargo", "cargo"), ("go", "go"),
1165
+ ("docker", "docker")):
1166
+ if shutil.which(binary):
1167
+ caps.add(name)
1168
+ if not offline:
1169
+ caps.add("network")
1170
+ return caps
1171
+
1172
+
1173
+ def collect(root, offline=False, out_rel=OUT_DIR):
1174
+ root = os.path.abspath(root)
1175
+ profile = discover(root)
1176
+ caps = capabilities(root, offline)
1177
+ ctx = Ctx(root=root, profile=profile, capabilities=caps, offline=offline,
1178
+ out_rel=out_rel)
1179
+
1180
+ rows, findings = [], []
1181
+ for p in PROBES:
1182
+ result = run_probe(p, ctx)
1183
+ rows.append({"id": p.id, "phase": p.phase, "needs": list(p.needs),
1184
+ "verdict": result.verdict, "reason": result.reason})
1185
+ findings.extend(result.findings)
1186
+
1187
+ return {
1188
+ "schema": SCHEMA,
1189
+ "generated_at": datetime.datetime.now(
1190
+ datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
1191
+ "root": root,
1192
+ "profile": profile,
1193
+ "capabilities": sorted(caps),
1194
+ "probes": rows,
1195
+ "findings": findings,
1196
+ "counts": {
1197
+ "probes_run": sum(1 for r in rows if r["verdict"] != "blind"),
1198
+ "probes_blind": sum(1 for r in rows if r["verdict"] == "blind"),
1199
+ "findings": len(findings),
1200
+ },
1201
+ }
1202
+
1203
+
1204
+ def main(argv=None):
1205
+ ap = argparse.ArgumentParser(
1206
+ description="Collect a project audit and write two artefacts.")
1207
+ ap.add_argument("--root", default=".", help="project root (default: .)")
1208
+ ap.add_argument("--out", default=None,
1209
+ help="output directory (default: <root>/docs/audit)")
1210
+ ap.add_argument("--no-open", action="store_true",
1211
+ help="do not open the report in a browser")
1212
+ ap.add_argument("--offline", action="store_true",
1213
+ help="skip every probe that needs the network")
1214
+ ap.add_argument("--json", action="store_true",
1215
+ help="print the sidecar to stdout instead of a summary")
1216
+ args = ap.parse_args(argv)
1217
+
1218
+ root = os.path.abspath(args.root)
1219
+ if not os.path.isdir(root):
1220
+ sys.stderr.write("not a directory: %s\n" % root)
1221
+ return 1
1222
+
1223
+ out_dir = args.out or os.path.join(root, OUT_DIR)
1224
+ out_rel = os.path.relpath(out_dir, root)
1225
+ payload = collect(root, offline=args.offline, out_rel=out_rel)
1226
+
1227
+ day = payload["generated_at"][:10]
1228
+ base = "%s-audit" % day
1229
+ os.makedirs(out_dir, exist_ok=True)
1230
+ prior = load_prior(out_dir, exclude=base + ".json")
1231
+ payload = carry_forward(payload, prior)
1232
+ payload["ratchet"] = ratchet(payload, prior)
1233
+ payload["sidecar"] = os.path.join(out_dir, base + ".json")
1234
+
1235
+ json_path = os.path.join(out_dir, base + ".json")
1236
+ html_path = os.path.join(out_dir, base + ".html")
1237
+ with open(json_path, "w", encoding="utf-8") as fh:
1238
+ json.dump(payload, fh, indent=1, ensure_ascii=False, sort_keys=True)
1239
+ fh.write("\n")
1240
+ with open(html_path, "w", encoding="utf-8") as fh:
1241
+ fh.write(render_html(payload))
1242
+
1243
+ if args.json:
1244
+ print(json.dumps(payload, indent=1, ensure_ascii=False))
1245
+ else:
1246
+ c = payload["counts"]
1247
+ r = payload["ratchet"]
1248
+ print("project-audit %s — %d finding(s), %d probe(s) ran, %d blind"
1249
+ % (payload["profile"].get("name"), c["findings"],
1250
+ c["probes_run"], c["probes_blind"]))
1251
+ if r["first_run"]:
1252
+ print(" first run in %s — no earlier sidecar to compare against"
1253
+ % out_dir)
1254
+ else:
1255
+ print(" closed %d · new %d · still open %d"
1256
+ % (len(r["closed"]), len(r["new"]), len(r["carried"])))
1257
+ print(" %s\n %s" % (html_path, json_path))
1258
+
1259
+ if not args.no_open:
1260
+ ok, note = open_in_browser(html_path)
1261
+ if not ok:
1262
+ sys.stderr.write("could not open the report: %s\n" % note)
1263
+ return 0
1264
+
1265
+
1266
+ if __name__ == "__main__":
1267
+ sys.exit(main())