@arbiterforge/ca-pi 0.16.5 → 0.17.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.
Files changed (46) hide show
  1. package/README.md +1 -1
  2. package/package.json +1 -1
  3. package/plugins/ca-pi/CHANGELOG.md +46 -0
  4. package/plugins/ca-pi/agents/map-deps.md +26 -26
  5. package/plugins/ca-pi/agents/map-structure.md +27 -28
  6. package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +70 -39
  7. package/plugins/ca-pi/generated/roles.json +2 -5
  8. package/plugins/ca-pi/helpers/artifacts/release.json +18 -18
  9. package/plugins/ca-pi/hooks/_artifactauthoritylib.py +16 -10
  10. package/plugins/ca-pi/hooks/_artifactlib.py +29 -2
  11. package/plugins/ca-pi/hooks/_contextmergelib.py +160 -0
  12. package/plugins/ca-pi/hooks/_githooks.py +2 -0
  13. package/plugins/ca-pi/hooks/_host.py +1 -1
  14. package/plugins/ca-pi/hooks/_tribunalinventorylib.py +653 -0
  15. package/plugins/ca-pi/hooks/_tribunalrunlib.py +652 -0
  16. package/plugins/ca-pi/hooks/hostapi.py +1 -1
  17. package/plugins/ca-pi/hooks/resolve-context-conflict.py +8 -0
  18. package/plugins/ca-pi/hooks/tribunal.py +113 -0
  19. package/plugins/ca-pi/package.json +1 -1
  20. package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +29 -0
  21. package/plugins/ca-pi/routines/tribunal/SKILL.md +192 -99
  22. package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +6 -27
  23. package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +73 -52
  24. package/plugins/ca-pi/routines/tribunal/references/finding-record.md +69 -13
  25. package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +63 -45
  26. package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +55 -12
  27. package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +51 -13
  28. package/plugins/ca-pi/routines/tribunal/references/lenses/change-closure.md +65 -0
  29. package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +51 -10
  30. package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +53 -13
  31. package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +52 -12
  32. package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +52 -11
  33. package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +52 -12
  34. package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +53 -13
  35. package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +55 -12
  36. package/plugins/ca-pi/routines/tribunal/references/lenses/semantic-contract.md +62 -0
  37. package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +48 -13
  38. package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +52 -11
  39. package/plugins/ca-pi/routines/tribunal/references/report.md +34 -12
  40. package/plugins/ca-pi/routines/tribunal/references/review-risk.md +78 -0
  41. package/plugins/ca-pi/routines/tribunal/references/schemas.md +171 -38
  42. package/plugins/ca-pi/routines/tribunal/references/telemetry.md +52 -17
  43. package/plugins/ca-pi/routines/tribunal/references/triage.md +95 -47
  44. package/plugins/ca-pi/routines/tribunal/references/verification-quality.md +61 -0
  45. package/plugins/ca-pi/skills/ca-pr/SKILL.md +29 -0
  46. package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +192 -99
@@ -0,0 +1,653 @@
1
+ #!/usr/bin/env python3
2
+ # codeArbiter — deterministic, inert Tribunal inventory and host sizing.
3
+ #
4
+ # Repository content is evidence, never instructions. Only bounded regular-file
5
+ # reads and fixed read-only Git commands run. No project imports, scripts,
6
+ # configuration evaluation, external parsers, installs, or network requests.
7
+ # Importing this module has no filesystem/process side effects. Collection does
8
+ # not write: the caller persists JSON and its Markdown projection in its run.
9
+ #
10
+ # Public API:
11
+ # collect_inventory(repo_root, scope='.', *, max_file_bytes=1048576,
12
+ # max_total_bytes=8388608, history_limit=100) -> dict
13
+ # canonical_inventory_json(inventory) -> str
14
+ # render_inventory_md(inventory) -> str
15
+ # resolve_profile(profile, capabilities) -> dict
16
+ # estimate_cost(packet_bytes, profile_counts, *, verification_candidates=0,
17
+ # concurrency=1, extraction_bytes=0) -> dict
18
+
19
+ from collections import Counter, defaultdict
20
+ import html
21
+ import json
22
+ import math
23
+ import os
24
+ from pathlib import Path, PurePosixPath
25
+ import re
26
+ import stat
27
+ import subprocess
28
+ import threading
29
+
30
+ from _gitexec import git_executable, root_bound_git_env
31
+
32
+ try:
33
+ import tomllib
34
+ except ImportError: # Python 3.10: do not install a parser to fill this gap.
35
+ tomllib = None
36
+
37
+
38
+ _GIT_BYTES = 8 * 1024 * 1024
39
+ _GIT_TIMEOUT = 10
40
+ _RELATIONSHIP_LIMIT = 10000
41
+ _LANGUAGES = {
42
+ ".py": "Python", ".js": "JavaScript", ".jsx": "JavaScript",
43
+ ".mjs": "JavaScript", ".cjs": "JavaScript", ".ts": "TypeScript",
44
+ ".tsx": "TypeScript", ".go": "Go", ".rs": "Rust", ".java": "Java",
45
+ ".cs": "C#", ".c": "C", ".h": "C/C++", ".cpp": "C++",
46
+ ".rb": "Ruby", ".php": "PHP", ".swift": "Swift", ".kt": "Kotlin",
47
+ ".sh": "Shell", ".ps1": "PowerShell", ".sql": "SQL",
48
+ ".html": "HTML", ".css": "CSS", ".json": "JSON", ".toml": "TOML",
49
+ ".yml": "YAML", ".yaml": "YAML", ".md": "Markdown",
50
+ }
51
+ _MANIFESTS = {"package.json", "composer.json", "pyproject.toml", "Cargo.toml",
52
+ "go.mod", "setup.py", "setup.cfg", "Pipfile", "Gemfile",
53
+ "pom.xml", "build.gradle", "build.gradle.kts", "mix.exs"}
54
+ _LOCKFILES = {"package-lock.json", "npm-shrinkwrap.json", "yarn.lock", "pnpm-lock.yaml",
55
+ "bun.lock", "bun.lockb", "Cargo.lock", "poetry.lock", "uv.lock",
56
+ "Pipfile.lock", "Gemfile.lock", "composer.lock", "go.sum"}
57
+ _PROFILE_WEIGHTS = {"deep": 6, "standard": 3, "extract": 1}
58
+
59
+
60
+ class _Unavailable(Exception):
61
+ """A fixed reason code, without untrusted command output or source content."""
62
+
63
+
64
+ def _positive_int(value, maximum):
65
+ return type(value) is int and 0 < value <= maximum
66
+
67
+
68
+ def _relative(value):
69
+ if not isinstance(value, str) or not value or "\x00" in value:
70
+ raise _Unavailable("invalid-path")
71
+ value = value.replace("\\", "/")
72
+ path = PurePosixPath(value)
73
+ if path.is_absolute() or ".." in path.parts or ":" in value:
74
+ raise _Unavailable("invalid-path")
75
+ return path.as_posix()
76
+
77
+
78
+ def _git(root, *args):
79
+ """Bound stdout while it is read, not after an unbounded capture finishes."""
80
+ env = root_bound_git_env()
81
+ for key in tuple(env):
82
+ if key.startswith("GIT_TRACE") or key in {
83
+ "GIT_EXTERNAL_DIFF", "GIT_DIFF_OPTS", "GIT_ASKPASS", "GIT_EXEC_PATH",
84
+ "GIT_NAMESPACE", "GIT_PAGER",
85
+ }:
86
+ env.pop(key, None)
87
+ env.update({"GIT_OPTIONAL_LOCKS": "0", "GIT_TERMINAL_PROMPT": "0",
88
+ "GIT_NO_LAZY_FETCH": "1", "GIT_PROTOCOL_FROM_USER": "0"})
89
+ # Keep the user's safe.directory decision; never add a trust override.
90
+ argv = [git_executable(), "--no-pager", "--no-replace-objects", "--no-lazy-fetch",
91
+ "--no-optional-locks", "--literal-pathspecs", "-C", str(root),
92
+ "-c", "core.fsmonitor=false", "-c", "core.untrackedCache=false",
93
+ "-c", "protocol.allow=never", *args]
94
+ try:
95
+ process = subprocess.Popen(argv, stdin=subprocess.DEVNULL, stdout=subprocess.PIPE,
96
+ stderr=subprocess.DEVNULL, env=env, shell=False)
97
+ except (OSError, RuntimeError):
98
+ raise _Unavailable("git-unavailable") from None
99
+ output = []
100
+
101
+ def read_bounded():
102
+ output.append(process.stdout.read(_GIT_BYTES + 1))
103
+
104
+ worker = threading.Thread(target=read_bounded, daemon=True)
105
+ worker.start()
106
+ worker.join(_GIT_TIMEOUT)
107
+ try:
108
+ if worker.is_alive() or not output:
109
+ raise _Unavailable("git-timeout")
110
+ if len(output[0]) > _GIT_BYTES:
111
+ raise _Unavailable("git-output-limit")
112
+ try:
113
+ code = process.wait(timeout=_GIT_TIMEOUT)
114
+ except subprocess.TimeoutExpired:
115
+ raise _Unavailable("git-timeout") from None
116
+ if code:
117
+ raise _Unavailable("git-unavailable")
118
+ return output[0]
119
+ finally:
120
+ if process.poll() is None:
121
+ process.kill()
122
+ process.wait()
123
+ worker.join(_GIT_TIMEOUT)
124
+ process.stdout.close()
125
+
126
+
127
+ def _note(result, field, reason, path=None):
128
+ item = {"field": field, "reason": reason}
129
+ if path is not None:
130
+ item["path"] = path
131
+ if item not in result["unavailable"]:
132
+ result["unavailable"].append(item)
133
+
134
+
135
+ def _regular_file(root, relative):
136
+ """Reject links/reparse points in every component, including directories."""
137
+ relative = _relative(relative)
138
+ path = root
139
+ for part in PurePosixPath(relative).parts:
140
+ path = path / part
141
+ info = path.lstat()
142
+ if stat.S_ISLNK(info.st_mode) or getattr(info, "st_file_attributes", 0) & 0x400:
143
+ raise _Unavailable("non-regular-file")
144
+ if not stat.S_ISREG(info.st_mode):
145
+ raise _Unavailable("non-regular-file")
146
+ if not path.resolve().is_relative_to(root):
147
+ raise _Unavailable("outside-repository")
148
+ return path, info
149
+
150
+
151
+ def _read_manifest(root, relative, result, limits):
152
+ path, before = _regular_file(root, relative)
153
+ if before.st_size > limits["file"]:
154
+ raise _Unavailable("file-byte-limit")
155
+ remaining = limits["total"] - limits["read"]
156
+ if before.st_size > remaining:
157
+ raise _Unavailable("total-byte-limit")
158
+ flags = os.O_RDONLY | getattr(os, "O_BINARY", 0) | getattr(os, "O_NOFOLLOW", 0)
159
+ flags |= getattr(os, "O_NONBLOCK", 0)
160
+ with os.fdopen(os.open(path, flags), "rb") as stream:
161
+ opened = os.fstat(stream.fileno())
162
+ _, current = _regular_file(root, relative)
163
+ if (opened.st_dev, opened.st_ino) != (current.st_dev, current.st_ino):
164
+ raise _Unavailable("file-changed-during-read")
165
+ if not stat.S_ISREG(opened.st_mode):
166
+ raise _Unavailable("non-regular-file")
167
+ data = stream.read(min(limits["file"], remaining) + 1)
168
+ limits["read"] += len(data)
169
+ if len(data) > limits["file"]:
170
+ raise _Unavailable("file-byte-limit")
171
+ if len(data) > remaining:
172
+ raise _Unavailable("total-byte-limit")
173
+ return data.decode("utf-8-sig")
174
+
175
+
176
+ def _dependency(result, manifest, ecosystem, group, name, version):
177
+ if not isinstance(name, str) or not name:
178
+ _note(result, "dependencies", "malformed-dependency", manifest)
179
+ return
180
+ declared = isinstance(version, str) and bool(version)
181
+ result["dependencies"].append({"manifest": manifest, "ecosystem": ecosystem,
182
+ "group": group, "name": name,
183
+ "version": version if declared else None,
184
+ "version_status": "declared" if declared else "not-declared"})
185
+
186
+
187
+ def _dependency_table(result, path, ecosystem, data, groups):
188
+ for group in groups:
189
+ table = data.get(group, {})
190
+ if not isinstance(table, dict):
191
+ _note(result, "dependencies", "malformed-dependency-group", path)
192
+ continue
193
+ for name, declaration in sorted(table.items()):
194
+ if not isinstance(declaration, str) and not (ecosystem == "cargo" and isinstance(declaration, dict)):
195
+ _note(result, "dependencies", "malformed-dependency", path)
196
+ continue
197
+ version = declaration.get("version") if isinstance(declaration, dict) else declaration
198
+ _dependency(result, path, ecosystem, group, name, version)
199
+
200
+
201
+ def _requirement(result, path, group, text):
202
+ if not isinstance(text, str):
203
+ _note(result, "dependencies", "unsupported-requirement", path)
204
+ return
205
+ # Only a named PEP-508-style requirement. Includes, URLs, editable installs
206
+ # and options are surfaced as unavailable, never followed or executed.
207
+ match = re.fullmatch(r"([A-Za-z0-9][A-Za-z0-9._-]*)(?:\[[A-Za-z0-9_,.-]+\])?"
208
+ r"\s*((?:[<>=!~].*|;.*)?)", text.strip())
209
+ if not match or "\\" in text:
210
+ _note(result, "dependencies", "unsupported-requirement", path)
211
+ return
212
+ version = match.group(2).split(";", 1)[0].strip() or None
213
+ _dependency(result, path, "python", group, match.group(1), version)
214
+
215
+
216
+ def _declared_path(manifest, value):
217
+ if not isinstance(value, str):
218
+ raise _Unavailable("unsupported-declared-path")
219
+ return _relative((PurePosixPath(manifest).parent / _relative(value)).as_posix())
220
+
221
+
222
+ def _entry(result, manifest, kind, value):
223
+ try:
224
+ path = _declared_path(manifest, value)
225
+ except _Unavailable:
226
+ _note(result, "entry_points", "unsupported-declared-path", manifest)
227
+ return
228
+ result["entry_points"].append({"path": path, "declared_in": manifest, "basis": kind})
229
+
230
+
231
+ def _generation(result, manifest, source, outputs, basis):
232
+ try:
233
+ source = _declared_path(manifest, source)
234
+ outputs = sorted({_declared_path(manifest, value) for value in outputs})
235
+ except _Unavailable:
236
+ _note(result, "generated_relationships", "unsupported-declared-path", manifest)
237
+ return
238
+ if outputs:
239
+ result["generated_relationships"].append({"declared_in": manifest, "source": source,
240
+ "outputs": outputs, "basis": basis})
241
+
242
+
243
+ def _parse_manifest(result, path, text):
244
+ name = PurePosixPath(path).name
245
+ if name in {"package.json", "composer.json", "tsconfig.json"} or path == "core/hosts.json":
246
+ data = json.loads(text)
247
+ elif name in {"pyproject.toml", "Cargo.toml"}:
248
+ if tomllib is None:
249
+ raise _Unavailable("stdlib-toml-unavailable")
250
+ data = tomllib.loads(text)
251
+ elif name.startswith("requirements") and name.endswith(".txt"):
252
+ for line in text.splitlines():
253
+ line = line.split(" #", 1)[0].strip()
254
+ if line and not line.startswith("#"):
255
+ _requirement(result, path, "requirements", line)
256
+ return
257
+ elif name == "go.mod":
258
+ in_require = False
259
+ for line in text.splitlines():
260
+ stripped = line.split("//", 1)[0].strip()
261
+ if stripped == "require (":
262
+ in_require = True
263
+ elif stripped == ")":
264
+ in_require = False
265
+ elif stripped.startswith("require ") or in_require:
266
+ if "// indirect" in line:
267
+ continue
268
+ parts = stripped.removeprefix("require ").split()
269
+ if len(parts) == 2:
270
+ _dependency(result, path, "go", "require", *parts)
271
+ elif stripped:
272
+ _note(result, "dependencies", "unsupported-requirement", path)
273
+ elif stripped.startswith("module "):
274
+ result["package_metadata"].append({"manifest": path, "name": stripped[7:].strip()})
275
+ return
276
+ else:
277
+ raise _Unavailable("unsupported-format")
278
+ if not isinstance(data, dict):
279
+ raise _Unavailable("malformed-manifest")
280
+
281
+ if path == "core/hosts.json":
282
+ _host_surface_rules(result, path, data)
283
+ elif name == "package.json":
284
+ _metadata(result, path, data)
285
+ _dependency_table(result, path, "npm", data,
286
+ ("dependencies", "devDependencies", "peerDependencies", "optionalDependencies"))
287
+ for key in ("main", "module", "types", "browser"):
288
+ if isinstance(data.get(key), str):
289
+ _entry(result, path, key, data[key])
290
+ binaries = data.get("bin", {})
291
+ if isinstance(binaries, str):
292
+ binaries = {"bin": binaries}
293
+ if isinstance(binaries, dict):
294
+ for value in binaries.values():
295
+ _entry(result, path, "bin", value)
296
+ if "source" in data:
297
+ _generation(result, path, data["source"],
298
+ [data[key] for key in ("main", "module", "types") if isinstance(data.get(key), str)],
299
+ "package-source-output")
300
+ if "exports" in data:
301
+ _note(result, "entry_points", "export-map-not-resolved", path)
302
+ elif name == "composer.json":
303
+ _metadata(result, path, data)
304
+ _dependency_table(result, path, "composer", data, ("require", "require-dev"))
305
+ elif name == "Cargo.toml":
306
+ _metadata(result, path, data.get("package", {}))
307
+ _dependency_table(result, path, "cargo", data,
308
+ ("dependencies", "dev-dependencies", "build-dependencies"))
309
+ if "target" in data or "workspace" in data:
310
+ _note(result, "dependencies", "target-or-workspace-dependencies-not-resolved", path)
311
+ elif name == "pyproject.toml":
312
+ project = data.get("project", {})
313
+ if not isinstance(project, dict):
314
+ raise _Unavailable("malformed-manifest")
315
+ _metadata(result, path, project)
316
+ dependencies = project.get("dependencies", [])
317
+ if not isinstance(dependencies, list):
318
+ raise _Unavailable("malformed-manifest")
319
+ for declaration in dependencies:
320
+ _requirement(result, path, "dependencies", declaration)
321
+ optional = project.get("optional-dependencies", {})
322
+ if isinstance(optional, dict):
323
+ for group, declarations in optional.items():
324
+ if isinstance(declarations, list):
325
+ for declaration in declarations:
326
+ _requirement(result, path, "optional:" + group, declaration)
327
+ else:
328
+ _note(result, "dependencies", "malformed-dependency-group", path)
329
+ for group in ("scripts", "gui-scripts"):
330
+ entries = project.get(group, {})
331
+ if isinstance(entries, dict):
332
+ for entry_name, target in sorted(entries.items()):
333
+ if isinstance(target, str):
334
+ result["entry_points"].append({"declared_in": path, "name": entry_name,
335
+ "target": target, "basis": group})
336
+ if project.get("dynamic") or "tool" in data:
337
+ _note(result, "dependencies", "dynamic-or-tool-dependencies-not-resolved", path)
338
+ elif name == "tsconfig.json":
339
+ options = data.get("compilerOptions", {})
340
+ if isinstance(options, dict) and "rootDir" in options and "outDir" in options:
341
+ _generation(result, path, options["rootDir"], [options["outDir"]], "compiler-root-out-dir")
342
+ if "extends" in data or "references" in data:
343
+ _note(result, "generated_relationships", "extended-config-not-resolved", path)
344
+
345
+
346
+ def _host_surface_rules(result, path, data):
347
+ """Read the existing host descriptor convention as data, never a generator."""
348
+ hosts = data.get("hosts")
349
+ if not isinstance(hosts, list):
350
+ raise _Unavailable("malformed-manifest")
351
+ for host in hosts:
352
+ try:
353
+ if not isinstance(host, dict) or not isinstance(host.get("surface"), dict):
354
+ raise _Unavailable("unsupported-host-surface-rule")
355
+ plugin = _relative(host.get("plugin_dir"))
356
+ rules = host["surface"].get("rules")
357
+ if not isinstance(rules, list):
358
+ raise _Unavailable("unsupported-host-surface-rule")
359
+ for rule in rules:
360
+ if not isinstance(rule, dict):
361
+ raise _Unavailable("unsupported-host-surface-rule")
362
+ source = _relative("core/surface/" + _relative(rule.get("source_prefix")))
363
+ output = _relative(plugin + "/" + _relative(rule.get("output_pattern")))
364
+ excludes = rule.get("exclude", [])
365
+ if not isinstance(excludes, list) or not all(isinstance(value, str) for value in excludes):
366
+ raise _Unavailable("unsupported-host-surface-rule")
367
+ result["generated_relationships"].append({"declared_in": path, "source": source,
368
+ "outputs": [output], "excludes": sorted(excludes),
369
+ "basis": "host-surface-rule"})
370
+ except _Unavailable as exc:
371
+ _note(result, "generated_relationships", str(exc), path)
372
+
373
+
374
+ def _metadata(result, path, data):
375
+ if not isinstance(data, dict):
376
+ raise _Unavailable("malformed-manifest")
377
+ row = {"manifest": path}
378
+ for key in ("name", "version", "license", "description", "homepage", "private"):
379
+ if isinstance(data.get(key), (str, bool)):
380
+ row[key] = data[key]
381
+ result["package_metadata"].append(row)
382
+
383
+
384
+ def _path_facts(result):
385
+ sources = defaultdict(list)
386
+ tests = []
387
+ languages = defaultdict(lambda: {"files": 0, "bytes": 0, "unknown_sizes": 0})
388
+ for row in result["files"]:
389
+ path = row["path"]
390
+ pure = PurePosixPath(path)
391
+ name = pure.name
392
+ lower = path.lower()
393
+ language = languages[row["language"]]
394
+ language["files"] += 1
395
+ if row["bytes"] is None:
396
+ language["unknown_sizes"] += 1
397
+ else:
398
+ language["bytes"] += row["bytes"]
399
+ stem = pure.stem
400
+ test_stem = re.sub(r"(?:[._](?:test|spec))$", "", stem.removeprefix("test_"))
401
+ is_test = test_stem != stem or any(part in {"test", "tests", "__tests__"} for part in pure.parts)
402
+ if is_test:
403
+ tests.append((path, test_stem, pure.suffix))
404
+ else:
405
+ sources[(stem, pure.suffix)].append(path)
406
+ if name in {"__main__.py", "main.py", "main.go", "main.rs", "index.js", "index.ts", "Program.cs"}:
407
+ result["entry_points"].append({"path": path, "basis": "path-convention"})
408
+ surfaces = result["surfaces"]
409
+ if path.startswith((".github/workflows/", ".circleci/")) or name in {
410
+ ".gitlab-ci.yml", "azure-pipelines.yml", "Jenkinsfile",
411
+ }:
412
+ surfaces["ci"].append(path)
413
+ if any(word in lower for word in ("release", "publish", "changelog")):
414
+ surfaces["release"].append(path)
415
+ if "deploy" in lower or name.startswith(("Dockerfile", "docker-compose")):
416
+ surfaces["deploy"].append(path)
417
+ if pure.suffix in {".json", ".toml", ".yaml", ".yml", ".ini", ".cfg"} or name in {
418
+ ".gitattributes", ".gitignore", "Dockerfile", "Makefile",
419
+ }:
420
+ surfaces["config"].append(path)
421
+ result["languages"] = [{"language": language, **counts} for language, counts in sorted(languages.items())]
422
+ for test, stem, suffix in tests:
423
+ for source in sources[(stem, suffix)]:
424
+ if len(result["test_source_relationships"]) >= _RELATIONSHIP_LIMIT:
425
+ _note(result, "test_source_relationships", "relationship-limit")
426
+ return
427
+ result["test_source_relationships"].append({"test": test, "source": source,
428
+ "basis": "filename-convention"})
429
+
430
+
431
+ def _git_facts(result, root, scope, history_limit):
432
+ git = result["git"]
433
+ try:
434
+ git["head"] = _git(root, "rev-parse", "--verify", "HEAD").decode("ascii").strip()
435
+ except _Unavailable as exc:
436
+ _note(result, "git.head", str(exc))
437
+ try:
438
+ status = _git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all",
439
+ "--ignore-submodules=all", "--no-renames", "--", scope)
440
+ for row in status.split(b"\0"):
441
+ if row:
442
+ git["dirty_paths"].append({"path": os.fsdecode(row[3:]),
443
+ "index": chr(row[0]), "worktree": chr(row[1])})
444
+ git["status"] = "ok"
445
+ except _Unavailable as exc:
446
+ _note(result, "git.dirty_paths", str(exc))
447
+ try:
448
+ commits = _git(root, "rev-list", "--max-count=" + str(history_limit), "HEAD", "--", scope)
449
+ paths = _git(root, "log", "--max-count=" + str(history_limit), "--format=", "--name-only", "-z",
450
+ "--no-show-signature", "--no-renames", "--no-ext-diff", "--no-textconv", "HEAD", "--", scope)
451
+ counts = Counter(os.fsdecode(value) for value in paths.split(b"\0") if value)
452
+ git["churn"] = {"status": "ok", "commit_limit": history_limit,
453
+ "commits_observed": len(commits.splitlines()),
454
+ "by_path": [{"path": path, "changes": count} for path, count in sorted(counts.items())]}
455
+ except _Unavailable as exc:
456
+ _note(result, "git.churn", str(exc))
457
+
458
+
459
+ def collect_inventory(repo_root, scope=".", *, max_file_bytes=1048576,
460
+ max_total_bytes=8388608, history_limit=100):
461
+ """Collect scoped tracked-worktree facts; untracked content is never parsed.
462
+
463
+ Missing/unsupported fields stay explicit. Sizes describe current regular
464
+ worktree files, while membership comes from Git's index. Naming conventions
465
+ are labelled as such, not promoted to semantic relationships. Parser support
466
+ is deliberately bounded; empty results never establish semantic absence.
467
+ """
468
+ result = {"schema_version": 1, "status": "unavailable", "scope": None, "repository_root": None,
469
+ "judgment": "not-performed", "files": [], "languages": [], "manifests": [],
470
+ "lockfiles": [], "dependencies": [], "package_metadata": [], "entry_points": [],
471
+ "generated_relationships": [], "test_source_relationships": [],
472
+ "surfaces": {"ci": [], "release": [], "deploy": [], "config": []},
473
+ "git": {"head": None, "status": "unavailable", "dirty_paths": [],
474
+ "churn": {"status": "unavailable", "commit_limit": None,
475
+ "commits_observed": None, "by_path": []}},
476
+ "unavailable": [], "parsed_bytes": 0}
477
+ try:
478
+ scope = _relative(scope)
479
+ if not (_positive_int(max_file_bytes, 16 * 1024 * 1024)
480
+ and _positive_int(max_total_bytes, 64 * 1024 * 1024)
481
+ and _positive_int(history_limit, 1000)):
482
+ raise _Unavailable("invalid-limits")
483
+ root = Path(repo_root).resolve(strict=True)
484
+ if not root.is_dir():
485
+ raise _Unavailable("invalid-repository")
486
+ reported = _git(root, "rev-parse", "--show-toplevel").decode("utf-8").strip()
487
+ if Path(reported).resolve() != root:
488
+ raise _Unavailable("repository-root-mismatch")
489
+ result.update({"scope": scope, "repository_root": str(root), "status": "ok"})
490
+ rows = _git(root, "ls-files", "--stage", "-z", "--", scope)
491
+ except (OSError, TypeError, ValueError, RuntimeError, _Unavailable) as exc:
492
+ _note(result, "inventory", str(exc) if isinstance(exc, _Unavailable) else "invalid-repository")
493
+ result["status"] = "unavailable"
494
+ return result
495
+ limits = {"file": max_file_bytes, "total": max_total_bytes, "read": 0}
496
+ members = {}
497
+ for row in rows.split(b"\0"):
498
+ if row:
499
+ metadata, path = row.split(b"\t", 1)
500
+ mode, _, stage = metadata.split()
501
+ members[os.fsdecode(path)] = (mode, stage)
502
+ for path, (mode, stage) in sorted(members.items()):
503
+ pure = PurePosixPath(path)
504
+ name = pure.name
505
+ item = {"path": path, "bytes": None, "language": _LANGUAGES.get(pure.suffix.lower(), "unknown"),
506
+ "kind": "file", "size_status": "unavailable"}
507
+ manifest = name in _MANIFESTS or (name.startswith("requirements") and name.endswith(".txt"))
508
+ parsed_config = name == "tsconfig.json" or path == "core/hosts.json"
509
+ if name in _LOCKFILES:
510
+ result["lockfiles"].append(path)
511
+ record = {"path": path, "parse_status": "unavailable"}
512
+ if manifest:
513
+ result["manifests"].append(record)
514
+ try:
515
+ if mode not in {b"100644", b"100755"}:
516
+ item["kind"] = "symlink" if mode == b"120000" else "submodule-or-special"
517
+ raise _Unavailable("non-regular-file")
518
+ _, info = _regular_file(root, path)
519
+ item.update({"bytes": info.st_size, "size_status": "ok"})
520
+ if stage != b"0":
521
+ raise _Unavailable("unmerged-index-entry")
522
+ if manifest or parsed_config:
523
+ if not parsed_config and name not in {"package.json", "composer.json", "pyproject.toml", "Cargo.toml",
524
+ "go.mod", "tsconfig.json"} and not name.startswith("requirements"):
525
+ raise _Unavailable("unsupported-format")
526
+ text = _read_manifest(root, path, result, limits)
527
+ _parse_manifest(result, path, text)
528
+ record["parse_status"] = "parsed"
529
+ except _Unavailable as exc:
530
+ _note(result, "manifest" if manifest or parsed_config else "files", str(exc), path)
531
+ except (UnicodeError, ValueError, RecursionError):
532
+ reason = "unsupported-jsonc-or-malformed" if name == "tsconfig.json" else "malformed-manifest"
533
+ _note(result, "manifest", reason, path)
534
+ except OSError:
535
+ _note(result, "files", "file-unavailable", path)
536
+ result["files"].append(item)
537
+ result["parsed_bytes"] = limits["read"]
538
+ _path_facts(result)
539
+ _git_facts(result, root, scope, history_limit)
540
+ _note(result, "import_call_graph", "no-inert-language-parser-selected")
541
+ for key in ("dependencies", "entry_points", "generated_relationships", "unavailable"):
542
+ result[key].sort(key=lambda row: json.dumps(row, sort_keys=True))
543
+ result["status"] = "partial" if result["unavailable"] else "ok"
544
+ return result
545
+
546
+
547
+ def canonical_inventory_json(inventory):
548
+ """Stable UTF-8-safe JSON; collection excludes clocks and model judgment."""
549
+ return json.dumps(inventory, sort_keys=True, separators=(",", ":"), ensure_ascii=True) + "\n"
550
+
551
+
552
+ def render_inventory_md(inventory):
553
+ """Readable projection. Escape repository-controlled text as inert text."""
554
+ def text(value):
555
+ plain = str(value).replace("\n", " ").replace("\r", " ")
556
+ # Escape original characters once, never punctuation in generated entities.
557
+ return re.sub(r"""[&<>"'\\`*_{}\[\]()#+!|~-]""",
558
+ lambda match: html.escape(match[0], quote=True)
559
+ if match[0] in "&<>\"'" else "&#%d;" % ord(match[0]), plain)
560
+
561
+ lines = ["# Tribunal inventory", "", "Mechanical facts; model judgment has not been performed.", "",
562
+ "Scope: " + text(inventory.get("scope")), "Status: " + text(inventory.get("status")), "",
563
+ "| Tracked path | Bytes | Language |", "|---|---:|---|"]
564
+ for row in inventory.get("files", []):
565
+ lines.append("| %s | %s | %s |" % (text(row["path"]), text(row["bytes"]), text(row["language"])))
566
+ for label, field in (("Direct dependencies", "dependencies"), ("Package metadata", "package_metadata"),
567
+ ("Entry points", "entry_points"), ("Generated relationships", "generated_relationships"),
568
+ ("Test/source naming relationships", "test_source_relationships")):
569
+ lines.extend(["", "## " + label, ""])
570
+ lines.extend("- " + text(json.dumps(row, sort_keys=True)) for row in inventory.get(field, []))
571
+ if not inventory.get(field):
572
+ lines.append("None found in supported, scoped declarations; this is not a semantic absence claim.")
573
+ lines.extend(["", "## Git and surfaces", "", text(json.dumps(inventory.get("git", {}), sort_keys=True)), "",
574
+ text(json.dumps(inventory.get("surfaces", {}), sort_keys=True)), "", "## Unavailable", ""])
575
+ lines.extend("- " + text(json.dumps(row, sort_keys=True)) for row in inventory.get("unavailable", []))
576
+ return "\n".join(lines) + "\n"
577
+
578
+
579
+ def resolve_profile(profile, capabilities):
580
+ """Resolve only caller-observed capabilities, never a provider model table.
581
+
582
+ capabilities: fresh_threads, model_override, reasoning_override booleans;
583
+ models/reasoning_levels lists; profiles maps deep/standard/extract to model
584
+ and reasoning settings. Missing capability is unavailable, never inferred
585
+ from native plugin-agent registration (`agents`). Empty settings inherit.
586
+ """
587
+ caps = capabilities if isinstance(capabilities, dict) else {}
588
+ fresh = caps.get("fresh_threads") is True
589
+ result = {"profile": profile if isinstance(profile, str) else None, "status": "ok", "settings": {},
590
+ "model_source": "inherited", "execution": "fresh-thread" if fresh else "inline",
591
+ "independence": "fresh-context" if fresh else "limited-shared-context", "limitations": []}
592
+ if not fresh:
593
+ result["limitations"].append("Fresh threads unavailable or unconfirmed; inline review shares context and has limited verification independence.")
594
+ if not isinstance(profile, str) or profile not in _PROFILE_WEIGHTS:
595
+ result["status"] = "unavailable"
596
+ result["limitations"].append("Unknown review profile.")
597
+ return result
598
+ profiles = caps.get("profiles", {})
599
+ desired = profiles.get(profile, {}) if isinstance(profiles, dict) else {}
600
+ desired = desired if isinstance(desired, dict) else {}
601
+ for setting, available in (("model", "models"), ("reasoning", "reasoning_levels")):
602
+ value = desired.get(setting)
603
+ choices = caps.get(available, [])
604
+ if (caps.get(setting + "_override") is True and isinstance(value, str)
605
+ and isinstance(choices, list) and value in choices):
606
+ result["settings"][setting] = value
607
+ else:
608
+ result["limitations"].append("No supported %s override for %s; inherit the configured setting." % (setting, profile))
609
+ if "model" in result["settings"]:
610
+ result["model_source"] = "host-profile"
611
+ if result["limitations"]:
612
+ result["status"] = "limited"
613
+ return result
614
+
615
+
616
+ def estimate_cost(packet_bytes, profile_counts, *, verification_candidates=0,
617
+ concurrency=1, extraction_bytes=0):
618
+ """Broad heuristic token band, not usage or a monetary quote.
619
+
620
+ packet_bytes is the SUM of selected packet bytes across active lenses (a
621
+ repeated packet counts again); extraction_bytes is inventory handed to the
622
+ model, not bytes scanned by Python. Profile multipliers 6/3/1, a 1,000-token
623
+ per-lens allowance and a half-to-double band are disclosed assumptions, not
624
+ measured calibration. Verification uses the average packet plus allowance.
625
+ """
626
+ result = {"status": "unavailable", "basis": "heuristic-not-usage", "token_band": None,
627
+ "measured_usage": None, "price": None, "inputs": {}, "limitations": []}
628
+ if (not isinstance(profile_counts, dict)
629
+ or any(key not in _PROFILE_WEIGHTS or type(value) is not int or not 0 <= value <= 1000
630
+ for key, value in profile_counts.items())
631
+ or any(type(value) is not int or not 0 <= value <= 10**12
632
+ for value in (packet_bytes, extraction_bytes, verification_candidates))
633
+ or not _positive_int(concurrency, 1000)):
634
+ result["limitations"].append("Invalid cost inputs; no estimate produced.")
635
+ return result
636
+ count = sum(profile_counts.values())
637
+ if not count and (packet_bytes or verification_candidates):
638
+ result["limitations"].append("Packet and verification work require active profile counts.")
639
+ return result
640
+ weighted = sum(_PROFILE_WEIGHTS[key] * value for key, value in profile_counts.items())
641
+ packet_tokens = math.ceil(packet_bytes / 4)
642
+ point = math.ceil(packet_tokens * weighted / max(count, 1) + 1000 * count + extraction_bytes / 4
643
+ + verification_candidates * (1000 + packet_tokens / max(count, 1)))
644
+ result.update({"status": "estimated", "token_band": {"low": math.floor(point / 2), "high": point * 2},
645
+ "inputs": {"packet_bytes": packet_bytes, "profile_counts": dict(sorted(profile_counts.items())),
646
+ "active_lenses": count, "verification_candidates": verification_candidates,
647
+ "extraction_bytes": extraction_bytes},
648
+ "assumptions": {"bytes_per_token": 4, "profile_multipliers": dict(_PROFILE_WEIGHTS),
649
+ "per_lens_allowance": 1000, "band_multipliers": [0.5, 2]},
650
+ "concurrency": {"requested": concurrency, "in_flight": min(count, concurrency),
651
+ "affects": ["elapsed-time", "peak-resource"], "reduces_total_tokens": False},
652
+ "limitations": ["Heuristic only; historical calibration and actual usage are unavailable."]})
653
+ return result