arkaos 5.13.0 → 5.14.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/THE-ARKAOS-GUIDE.md +1 -1
- package/VERSION +1 -1
- package/config/evals/marketing.yaml +4 -4
- package/core/governance/evidence_checks.py +526 -21
- package/core/hooks/stop.py +38 -1
- package/core/hooks/stop_governance.py +6 -2
- package/departments/dev/skills/security-compliance/SKILL.md +0 -5
- package/departments/dev/skills/threejs/SKILL.md +2 -2
- package/departments/landing/skills/offer-create/references/offer-formats.md +2 -2
- package/departments/marketing/skills/ad-creative/SKILL.md +1 -1
- package/departments/marketing/skills/ad-creative/references/creative-review-page.md +5 -5
- package/departments/ops/skills/gdpr-compliance/SKILL.md +0 -5
- package/departments/ops/skills/iso27001/SKILL.md +0 -5
- package/departments/ops/skills/quality-management/SKILL.md +0 -5
- package/departments/ops/skills/risk-management/SKILL.md +0 -5
- package/departments/ops/skills/soc2-compliance/SKILL.md +0 -5
- package/departments/sales/skills/prospecting/references/demand-signals.md +1 -1
- package/harness/codex/AGENTS.md +1 -1
- package/harness/copilot/copilot-instructions.md +1 -1
- package/harness/cursor/rules/arkaos.mdc +2 -2
- package/harness/gemini/GEMINI.md +1 -1
- package/harness/opencode/AGENTS.md +1 -1
- package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
- package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
- package/harness/opencode/agents/arka-cfo-helena.md +1 -1
- package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
- package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
- package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
- package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
- package/harness/opencode/agents/arka-coo-sofia.md +1 -1
- package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
- package/harness/opencode/agents/arka-cqo-marta.md +1 -1
- package/harness/opencode/agents/arka-cto-marco.md +1 -1
- package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
- package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
- package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
- package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
- package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
- package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
- package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
- package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
- package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
- package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
- package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
- package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
- package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
- package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
- package/harness/zed/.rules +1 -1
- package/knowledge/skills-manifest.json +1 -1
- package/package.json +3 -1
- package/pyproject.toml +36 -2
- package/scripts/fenced-code-allowlist.json +308 -0
- package/scripts/lint_fenced_code.mjs +591 -0
- package/scripts/skills_catalog_gen.py +21 -8
- package/scripts/tools/docs_stats.py +42 -2
package/THE-ARKAOS-GUIDE.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# The ArkaOS Guide
|
|
2
2
|
|
|
3
|
-
> v5.
|
|
3
|
+
> v5.14.0 — 89 agents, 17 departments, 340 skills, 306 commands, 20 ADRs.
|
|
4
4
|
> One file, everything you need to start. Generated by `scripts/guide_gen.py` — never hand-edited.
|
|
5
5
|
|
|
6
6
|
## What it is
|
package/VERSION
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
5.
|
|
1
|
+
5.14.0
|
|
@@ -389,7 +389,7 @@
|
|
|
389
389
|
the carousel frames, and the different headline options they can compare. Can you put
|
|
390
390
|
that together?
|
|
391
391
|
expected_properties:
|
|
392
|
-
- Produces a creative review page
|
|
392
|
+
- Produces a self-contained HTML creative review page, not plain markdown
|
|
393
393
|
- Populates the DATA object (concept tabs, in-feed preview, frame storyboard, headline variations,
|
|
394
394
|
copy, destination)
|
|
395
395
|
- Labels storyboard frames by narrative job rather than by pictured content
|
|
@@ -398,9 +398,9 @@
|
|
|
398
398
|
- Does not present invented stats or stock imagery as the brand's real assets
|
|
399
399
|
- Uses placeholders for unrendered frames and keeps image paths relative
|
|
400
400
|
- Explains how to deliver the page (open locally / host / hand off the file)
|
|
401
|
-
rubric: Should recognize this as a creative review page request and apply
|
|
402
|
-
|
|
403
|
-
|
|
401
|
+
rubric: Should recognize this as a creative review page request and apply the build spec in
|
|
402
|
+
references/creative-review-page.md rather than producing plain markdown. Should build a
|
|
403
|
+
single self-contained review.html in the output folder and populate its DATA block with the
|
|
404
404
|
four concepts as tabs, each with an in-feed Instagram preview, a labeled frame-by-frame
|
|
405
405
|
storyboard (frames labeled by narrative job — Hook / Problem / Proof / Ask — not by pictured
|
|
406
406
|
content), selectable headline variations, primary text, and destination/CTA. Should curate
|
|
@@ -34,10 +34,12 @@ import shutil
|
|
|
34
34
|
import subprocess
|
|
35
35
|
import sys
|
|
36
36
|
import time
|
|
37
|
+
import tomllib
|
|
37
38
|
from dataclasses import asdict, dataclass, field, replace
|
|
38
39
|
from pathlib import Path, PurePosixPath
|
|
39
40
|
from xml.etree import ElementTree
|
|
40
41
|
|
|
42
|
+
import core
|
|
41
43
|
from core.governance.qg_digest import evidence_digest
|
|
42
44
|
from core.shared.test_evidence import coverage_percent_from_xml
|
|
43
45
|
|
|
@@ -113,6 +115,10 @@ _SECURITY_PATTERNS: tuple[tuple[str, re.Pattern[str]], ...] = (
|
|
|
113
115
|
_SEC_OK_RE = re.compile(r"arka:sec-ok\(([a-z0-9-]+)\):\s*(\S.+)")
|
|
114
116
|
|
|
115
117
|
|
|
118
|
+
class ProvenanceError(RuntimeError):
|
|
119
|
+
"""The engine that would produce the report is not the code under review."""
|
|
120
|
+
|
|
121
|
+
|
|
116
122
|
@dataclass
|
|
117
123
|
class CheckResult:
|
|
118
124
|
"""Outcome of one executable check."""
|
|
@@ -189,6 +195,20 @@ def _run(
|
|
|
189
195
|
check: str, cmd: list[str], project_dir: Path, timeout: int,
|
|
190
196
|
) -> CheckResult:
|
|
191
197
|
"""Run one read-only project command; capture exit code + tail."""
|
|
198
|
+
return _run_capturing(check, cmd, project_dir, timeout)[0]
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
def _run_capturing(
|
|
202
|
+
check: str, cmd: list[str], project_dir: Path, timeout: int,
|
|
203
|
+
) -> tuple[CheckResult, str]:
|
|
204
|
+
"""``_run`` plus the UNTRUNCATED output.
|
|
205
|
+
|
|
206
|
+
A caller that must ATTRIBUTE individual findings cannot work from the
|
|
207
|
+
800-char tail: every finding truncated away would read as absent, and
|
|
208
|
+
"absent" is exactly what a gate must never infer from its own
|
|
209
|
+
formatting. The tail stays the reported summary; the full text is for
|
|
210
|
+
analysis only.
|
|
211
|
+
"""
|
|
192
212
|
command_str = " ".join(cmd)
|
|
193
213
|
try:
|
|
194
214
|
proc = subprocess.run(
|
|
@@ -196,7 +216,7 @@ def _run(
|
|
|
196
216
|
timeout=timeout,
|
|
197
217
|
)
|
|
198
218
|
except FileNotFoundError:
|
|
199
|
-
return _skip(check, f"tool not found: {cmd[0]}")
|
|
219
|
+
return _skip(check, f"tool not found: {cmd[0]}"), ""
|
|
200
220
|
except OSError as exc:
|
|
201
221
|
# Anything else exec can refuse — a directory, a non-executable
|
|
202
222
|
# file, a broken symlink. The gate must report, never raise: an
|
|
@@ -205,18 +225,18 @@ def _run(
|
|
|
205
225
|
return CheckResult(
|
|
206
226
|
check=check, ran=True, passed=False, command=command_str,
|
|
207
227
|
exit_code=None, summary=f"cannot execute {cmd[0]}: {exc.strerror}",
|
|
208
|
-
)
|
|
228
|
+
), ""
|
|
209
229
|
except subprocess.TimeoutExpired:
|
|
210
230
|
# subprocess.run kills the child on expiry before raising.
|
|
211
231
|
return CheckResult(
|
|
212
232
|
check=check, ran=True, passed=None, command=command_str,
|
|
213
233
|
exit_code=None, summary="timeout",
|
|
214
|
-
)
|
|
215
|
-
|
|
234
|
+
), ""
|
|
235
|
+
full = proc.stdout.strip() or proc.stderr.strip()
|
|
216
236
|
return CheckResult(
|
|
217
237
|
check=check, ran=True, passed=proc.returncode == 0,
|
|
218
|
-
command=command_str, exit_code=proc.returncode, summary=
|
|
219
|
-
)
|
|
238
|
+
command=command_str, exit_code=proc.returncode, summary=_tail(full),
|
|
239
|
+
), full
|
|
220
240
|
|
|
221
241
|
|
|
222
242
|
# ─── Applicability detection ────────────────────────────────────────────
|
|
@@ -260,6 +280,83 @@ def _mypy_configured(project_dir: Path) -> bool:
|
|
|
260
280
|
return False
|
|
261
281
|
|
|
262
282
|
|
|
283
|
+
def _mypy_declared_scope(project_dir: Path) -> list[str] | None:
|
|
284
|
+
"""The project's declared mypy ``files`` roots, or None if undeclared.
|
|
285
|
+
|
|
286
|
+
mypy IGNORES the config's ``files`` the moment any path is passed on
|
|
287
|
+
the command line, so a gate that unconditionally appends ``.``
|
|
288
|
+
overrides the very scope the project declared. That is how ``mypy .``
|
|
289
|
+
came to ABORT on this repo before checking a single line — two
|
|
290
|
+
top-level ``server`` modules under mcps/, plus bare script names
|
|
291
|
+
colliding between plugins/ and scripts/ (issue #452).
|
|
292
|
+
|
|
293
|
+
The same override bites the SCOPED run, which also passes explicit
|
|
294
|
+
paths: without this list the gate strict-typechecks files the project
|
|
295
|
+
deliberately excludes (measured: a diff touching tests/ produced 351
|
|
296
|
+
errors in a tree `files = ["core", "scripts"]` never covers).
|
|
297
|
+
"""
|
|
298
|
+
for name, reader in (
|
|
299
|
+
("mypy.ini", _ini_declared_files),
|
|
300
|
+
("setup.cfg", _ini_declared_files),
|
|
301
|
+
("pyproject.toml", _pyproject_declared_files),
|
|
302
|
+
):
|
|
303
|
+
declared = reader(project_dir / name)
|
|
304
|
+
if declared:
|
|
305
|
+
return declared
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
|
|
309
|
+
def _mypy_scope_configured(project_dir: Path) -> bool:
|
|
310
|
+
"""True when the project declares its own mypy file scope."""
|
|
311
|
+
return _mypy_declared_scope(project_dir) is not None
|
|
312
|
+
|
|
313
|
+
|
|
314
|
+
def _ini_declared_files(path: Path) -> list[str]:
|
|
315
|
+
"""`files = a, b` under an ini-style ``[mypy]`` section."""
|
|
316
|
+
if not path.is_file():
|
|
317
|
+
return []
|
|
318
|
+
parser = configparser.ConfigParser()
|
|
319
|
+
try:
|
|
320
|
+
parser.read(path, encoding="utf-8")
|
|
321
|
+
raw = parser.get("mypy", "files", fallback="")
|
|
322
|
+
except (OSError, configparser.Error):
|
|
323
|
+
return []
|
|
324
|
+
return [item.strip() for item in raw.split(",") if item.strip()]
|
|
325
|
+
|
|
326
|
+
|
|
327
|
+
def _pyproject_declared_files(path: Path) -> list[str]:
|
|
328
|
+
"""`files = [...]` under ``[tool.mypy]`` in pyproject.toml."""
|
|
329
|
+
if not path.is_file():
|
|
330
|
+
return []
|
|
331
|
+
try:
|
|
332
|
+
data = tomllib.loads(path.read_text(encoding="utf-8"))
|
|
333
|
+
except (OSError, tomllib.TOMLDecodeError, UnicodeDecodeError):
|
|
334
|
+
return []
|
|
335
|
+
mypy_cfg = data.get("tool", {}).get("mypy", {})
|
|
336
|
+
if not isinstance(mypy_cfg, dict):
|
|
337
|
+
return []
|
|
338
|
+
declared = mypy_cfg.get("files")
|
|
339
|
+
if isinstance(declared, str):
|
|
340
|
+
declared = [declared]
|
|
341
|
+
if not isinstance(declared, list):
|
|
342
|
+
return []
|
|
343
|
+
return [str(item).strip() for item in declared if str(item).strip()]
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def _within_declared_scope(rel: str, declared: list[str] | None) -> bool:
|
|
347
|
+
"""True when a project-relative path sits under a declared mypy root."""
|
|
348
|
+
if declared is None:
|
|
349
|
+
return True
|
|
350
|
+
candidate = PurePosixPath(rel)
|
|
351
|
+
for root in declared:
|
|
352
|
+
prefix = PurePosixPath(root.rstrip("/") or ".")
|
|
353
|
+
if prefix == PurePosixPath("."):
|
|
354
|
+
return True
|
|
355
|
+
if candidate == prefix or prefix in candidate.parents:
|
|
356
|
+
return True
|
|
357
|
+
return False
|
|
358
|
+
|
|
359
|
+
|
|
263
360
|
# ─── Individual checks ──────────────────────────────────────────────────
|
|
264
361
|
|
|
265
362
|
_LINTABLE_PY = frozenset({".py"})
|
|
@@ -316,6 +413,113 @@ def _ruff_cmd() -> list[str] | None:
|
|
|
316
413
|
return _tool_cmd("ruff")
|
|
317
414
|
|
|
318
415
|
|
|
416
|
+
def _eslint_root(project_dir: Path, rel: str) -> Path | None:
|
|
417
|
+
"""Nearest ancestor of ``rel``, up to project_dir, with a local eslint.
|
|
418
|
+
|
|
419
|
+
A monorepo keeps its parser and plugins in the sub-package that owns
|
|
420
|
+
the files, so resolving eslint only at ``project_dir`` lints the whole
|
|
421
|
+
diff with the WRONG config. Reproduced on this repo: the root eslint
|
|
422
|
+
over ``dashboard/app/composables/useApi.ts`` reports
|
|
423
|
+
``Parsing error: Unexpected token`` (no TypeScript parser) while
|
|
424
|
+
``dashboard``'s own eslint exits 0 over the same file (QG Fase 1).
|
|
425
|
+
|
|
426
|
+
That is a permanent false FAIL today and a false GREEN tomorrow: a
|
|
427
|
+
parser that cannot parse a file evaluates ZERO rules over it, so the
|
|
428
|
+
day the root config gains one, a whole tree passes on nothing.
|
|
429
|
+
"""
|
|
430
|
+
try:
|
|
431
|
+
root = project_dir.resolve()
|
|
432
|
+
current = (root / rel).resolve().parent
|
|
433
|
+
except OSError:
|
|
434
|
+
return None
|
|
435
|
+
while True:
|
|
436
|
+
if (current / "node_modules" / ".bin" / "eslint").is_file():
|
|
437
|
+
return current
|
|
438
|
+
if current == root or current.parent == current:
|
|
439
|
+
return None
|
|
440
|
+
current = current.parent
|
|
441
|
+
|
|
442
|
+
|
|
443
|
+
def _merge_scoped_lint(
|
|
444
|
+
results: list[tuple[str, CheckResult]], total: int, orphans: list[str],
|
|
445
|
+
) -> CheckResult:
|
|
446
|
+
"""Fold per-root eslint runs into one honest CheckResult.
|
|
447
|
+
|
|
448
|
+
FAILS if any root failed; stays non-conclusive when a root could not
|
|
449
|
+
conclude and none failed. Files with no eslint anywhere above them are
|
|
450
|
+
NAMED, last, so the note survives ``_tail`` truncation — a silently
|
|
451
|
+
unlinted file is the same blind gate this fix exists to close.
|
|
452
|
+
"""
|
|
453
|
+
if any(r.passed is False for _, r in results):
|
|
454
|
+
passed: bool | None = False
|
|
455
|
+
elif any(r.passed is None for _, r in results):
|
|
456
|
+
passed = None
|
|
457
|
+
else:
|
|
458
|
+
passed = True
|
|
459
|
+
exit_code = next(
|
|
460
|
+
(r.exit_code for _, r in results if r.exit_code not in (None, 0)), 0,
|
|
461
|
+
)
|
|
462
|
+
sections = [
|
|
463
|
+
f"[{label}] {r.summary or 'no output'}" for label, r in results
|
|
464
|
+
]
|
|
465
|
+
if orphans:
|
|
466
|
+
sections.append(
|
|
467
|
+
f"NOT LINTED — no eslint above {len(orphans)} changed file(s): "
|
|
468
|
+
+ ", ".join(orphans[:5])
|
|
469
|
+
)
|
|
470
|
+
command = " && ".join(f"(cd {label} && {r.command})" for label, r in results)
|
|
471
|
+
return CheckResult(
|
|
472
|
+
check="lint", ran=any(r.ran for _, r in results), passed=passed,
|
|
473
|
+
command=(
|
|
474
|
+
f"lint(scoped: {total - len(orphans)} file(s) across "
|
|
475
|
+
f"{len(results)} eslint root(s)) {command}"
|
|
476
|
+
),
|
|
477
|
+
exit_code=exit_code, summary=_tail("\n".join(sections)),
|
|
478
|
+
)
|
|
479
|
+
|
|
480
|
+
|
|
481
|
+
def _lint_eslint_grouped(
|
|
482
|
+
project_dir: Path, changed: list[str], timeout: int,
|
|
483
|
+
) -> CheckResult | None:
|
|
484
|
+
"""One eslint run per OWNING package root, never one run for all.
|
|
485
|
+
|
|
486
|
+
Returns None when no changed JS/TS file has an eslint above it, so
|
|
487
|
+
the caller falls through to the project-wide path unchanged.
|
|
488
|
+
"""
|
|
489
|
+
files = _scoped_files(project_dir, changed, _LINTABLE_JS)
|
|
490
|
+
if not files:
|
|
491
|
+
return None
|
|
492
|
+
root = project_dir.resolve()
|
|
493
|
+
groups: dict[Path, list[str]] = {}
|
|
494
|
+
orphans: list[str] = []
|
|
495
|
+
for rel in files:
|
|
496
|
+
owner = _eslint_root(project_dir, rel)
|
|
497
|
+
if owner is None:
|
|
498
|
+
orphans.append(rel)
|
|
499
|
+
else:
|
|
500
|
+
groups.setdefault(owner, []).append(rel)
|
|
501
|
+
if not groups:
|
|
502
|
+
return None
|
|
503
|
+
results: list[tuple[str, CheckResult]] = []
|
|
504
|
+
for owner in sorted(groups):
|
|
505
|
+
eslint = owner / "node_modules" / ".bin" / "eslint"
|
|
506
|
+
# Paths are re-expressed against the owning root because that is
|
|
507
|
+
# the cwd eslint runs in — its config resolution, ignore files and
|
|
508
|
+
# plugin lookup all key off it.
|
|
509
|
+
local = [
|
|
510
|
+
PurePosixPath((root / rel).resolve().relative_to(owner)).as_posix()
|
|
511
|
+
for rel in groups[owner]
|
|
512
|
+
]
|
|
513
|
+
label = (
|
|
514
|
+
"." if owner == root
|
|
515
|
+
else PurePosixPath(owner.relative_to(root)).as_posix()
|
|
516
|
+
)
|
|
517
|
+
results.append(
|
|
518
|
+
(label, _run("lint", [str(eslint), *local], owner, timeout))
|
|
519
|
+
)
|
|
520
|
+
return _merge_scoped_lint(results, len(files), orphans)
|
|
521
|
+
|
|
522
|
+
|
|
319
523
|
def _lint_scoped(
|
|
320
524
|
project_dir: Path, changed: list[str], timeout: int,
|
|
321
525
|
) -> CheckResult | None:
|
|
@@ -331,12 +535,9 @@ def _lint_scoped(
|
|
|
331
535
|
if files:
|
|
332
536
|
result = _run("lint", [*ruff, "check", *files], project_dir, timeout)
|
|
333
537
|
return _labelled(result, f"lint(scoped: {len(files)} file(s))")
|
|
334
|
-
|
|
335
|
-
if
|
|
336
|
-
|
|
337
|
-
if files:
|
|
338
|
-
result = _run("lint", [str(eslint), *files], project_dir, timeout)
|
|
339
|
-
return _labelled(result, f"lint(scoped: {len(files)} file(s))")
|
|
538
|
+
grouped = _lint_eslint_grouped(project_dir, changed, timeout)
|
|
539
|
+
if grouped is not None:
|
|
540
|
+
return grouped
|
|
340
541
|
pint = project_dir / "vendor" / "bin" / "pint"
|
|
341
542
|
if pint.is_file():
|
|
342
543
|
files = _scoped_files(project_dir, changed, _LINTABLE_PHP)
|
|
@@ -402,6 +603,238 @@ def _check_lint(
|
|
|
402
603
|
return _skip("lint", "no lint tooling detected (ruff/eslint/pint)")
|
|
403
604
|
|
|
404
605
|
|
|
606
|
+
_MYPY_ABORT_MARKER = "errors prevented further checking"
|
|
607
|
+
|
|
608
|
+
|
|
609
|
+
def _mypy_verdict(result: CheckResult) -> CheckResult:
|
|
610
|
+
"""Never let a mypy ABORT read as an ordinary type-error failure.
|
|
611
|
+
|
|
612
|
+
A blocking error (duplicate module names, an unparseable followed
|
|
613
|
+
stub, a bad invocation) makes mypy stop after checking only PART of
|
|
614
|
+
the tree, yet the exit code is indistinguishable from a completed run
|
|
615
|
+
that found errors. Reporting that as a plain FAIL hides the real
|
|
616
|
+
problem — the checker never finished — and a reviewer reading
|
|
617
|
+
"N errors" reasonably assumes N is the whole truth. Stays
|
|
618
|
+
``ran=True, passed=False``; only the reason is made honest.
|
|
619
|
+
"""
|
|
620
|
+
if result.exit_code in (None, 0):
|
|
621
|
+
return result
|
|
622
|
+
text = result.summary or ""
|
|
623
|
+
if _MYPY_ABORT_MARKER not in text:
|
|
624
|
+
return result
|
|
625
|
+
blocking = [ln.strip() for ln in text.splitlines() if ": error:" in ln]
|
|
626
|
+
prefix = (
|
|
627
|
+
"mypy ABORTED before checking every file — the count below is a "
|
|
628
|
+
"LOWER BOUND, not a complete typecheck"
|
|
629
|
+
)
|
|
630
|
+
if blocking:
|
|
631
|
+
prefix += f"; blocking: {blocking[-1]}"
|
|
632
|
+
return replace(result, passed=False, summary=f"{prefix}\n{text}")
|
|
633
|
+
|
|
634
|
+
|
|
635
|
+
_MYPY_FOUND_RE = re.compile(r"Found \d+ errors? in \d+ files?[^\n]*")
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def _mypy_project_argv(project_dir: Path, mypy: list[str]) -> list[str]:
|
|
639
|
+
"""Project-wide invocation, honouring a config-declared ``files`` scope."""
|
|
640
|
+
return list(mypy) if _mypy_scope_configured(project_dir) else [*mypy, "."]
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
def _project_wide_advisory(
|
|
644
|
+
project_dir: Path, mypy: list[str], timeout: int,
|
|
645
|
+
) -> str:
|
|
646
|
+
"""Master's accumulated count, carried as an explicitly NON-GATING note.
|
|
647
|
+
|
|
648
|
+
Scoping the verdict to the diff must not make the accumulated debt
|
|
649
|
+
invisible: a number nobody sees is a number nobody ever cleans. It
|
|
650
|
+
rides at the END of the summary because ``_tail`` keeps the tail.
|
|
651
|
+
"""
|
|
652
|
+
result = _run(
|
|
653
|
+
"typecheck", _mypy_project_argv(project_dir, mypy), project_dir, timeout,
|
|
654
|
+
)
|
|
655
|
+
prefix = (
|
|
656
|
+
"typecheck (project-wide over the working tree, including this "
|
|
657
|
+
"diff; advisory, NOT gating): "
|
|
658
|
+
)
|
|
659
|
+
if not result.ran:
|
|
660
|
+
return ""
|
|
661
|
+
if result.exit_code == 0:
|
|
662
|
+
return prefix + "clean"
|
|
663
|
+
if result.passed is None:
|
|
664
|
+
return prefix + "did not finish (timeout)"
|
|
665
|
+
found = _MYPY_FOUND_RE.search(result.summary or "")
|
|
666
|
+
if found is None:
|
|
667
|
+
return prefix + f"could not be summarised (exit {result.exit_code})"
|
|
668
|
+
return f"{prefix}{found.group(0)} — master's debt, not this diff's"
|
|
669
|
+
|
|
670
|
+
|
|
671
|
+
_MYPY_ERROR_RE = re.compile(
|
|
672
|
+
r"^(?P<path>[^\s:][^:]*):(?P<line>\d+):(?:\d+:)?\s*error:\s*(?P<msg>.*)$"
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
|
|
676
|
+
def _git_tracks(project_dir: Path, name: str) -> bool:
|
|
677
|
+
"""True when git has ``name`` in the index."""
|
|
678
|
+
try:
|
|
679
|
+
proc = subprocess.run(
|
|
680
|
+
["git", "ls-files", "--error-unmatch", "--", name],
|
|
681
|
+
cwd=project_dir, capture_output=True, text=True, timeout=10,
|
|
682
|
+
)
|
|
683
|
+
except (OSError, subprocess.TimeoutExpired):
|
|
684
|
+
return False
|
|
685
|
+
return proc.returncode == 0
|
|
686
|
+
|
|
687
|
+
|
|
688
|
+
def _added_line_numbers(
|
|
689
|
+
project_dir: Path, base: str, name: str,
|
|
690
|
+
) -> set[int] | None:
|
|
691
|
+
"""Line numbers this diff ADDED to ``name``; None when git cannot say.
|
|
692
|
+
|
|
693
|
+
An UNTRACKED file is entirely new, so every line in it is added — but
|
|
694
|
+
``git diff`` reports nothing at all for such a path (exit 0, empty
|
|
695
|
+
output). Reading that silence as "no added lines" would file every
|
|
696
|
+
error in a brand-new module under master's debt, and a whole new
|
|
697
|
+
type-broken file would gate GREEN. Caught by
|
|
698
|
+
``test_an_untrackable_file_fails_closed``.
|
|
699
|
+
"""
|
|
700
|
+
if not _git_tracks(project_dir, name):
|
|
701
|
+
return None
|
|
702
|
+
added = _added_lines(project_dir, base, name)
|
|
703
|
+
if added is None:
|
|
704
|
+
return None
|
|
705
|
+
return {lineno for lineno, _ in added}
|
|
706
|
+
|
|
707
|
+
|
|
708
|
+
def _attribute_mypy_errors(
|
|
709
|
+
project_dir: Path, output: str,
|
|
710
|
+
) -> tuple[list[str], list[str]] | None:
|
|
711
|
+
"""Split mypy errors into (on ADDED lines, pre-existing in touched files).
|
|
712
|
+
|
|
713
|
+
Same machinery and same contract as ``_check_security_grep``: only
|
|
714
|
+
lines ADDED relative to the default-branch merge-base belong to this
|
|
715
|
+
change; a finding elsewhere in a touched file is master's debt. File
|
|
716
|
+
granularity was not enough — it rejected a branch whose measured type
|
|
717
|
+
debt delta was exactly zero.
|
|
718
|
+
|
|
719
|
+
KNOWN IMPRECISION, accepted deliberately: mypy reports the line of the
|
|
720
|
+
ERROR, which is not always the added line that CAUSED it — change a
|
|
721
|
+
signature and the error surfaces at an untouched caller. That
|
|
722
|
+
mis-attribution (both ways) is the same one the security sweep has
|
|
723
|
+
always accepted; resolving cause across lines would need dataflow the
|
|
724
|
+
gate has no business doing. Only the three fail-closed paths (no
|
|
725
|
+
merge-base, undiffable path, untracked file) bias deliberately toward
|
|
726
|
+
the diff; elsewhere the direction is unknowable, and the
|
|
727
|
+
not-on-added-lines count below keeps the remainder visible either way.
|
|
728
|
+
|
|
729
|
+
Returns None when no merge-base exists — attribution is then
|
|
730
|
+
impossible and the caller must gate on everything.
|
|
731
|
+
"""
|
|
732
|
+
base = _diff_base(project_dir)
|
|
733
|
+
if base is None:
|
|
734
|
+
return None
|
|
735
|
+
added_by_file: dict[str, set[int] | None] = {}
|
|
736
|
+
gating: list[str] = []
|
|
737
|
+
inherited: list[str] = []
|
|
738
|
+
for raw in output.splitlines():
|
|
739
|
+
match = _MYPY_ERROR_RE.match(raw.strip())
|
|
740
|
+
if match is None:
|
|
741
|
+
continue
|
|
742
|
+
path, lineno = match.group("path"), int(match.group("line"))
|
|
743
|
+
if path not in added_by_file:
|
|
744
|
+
added_by_file[path] = _added_line_numbers(project_dir, base, path)
|
|
745
|
+
added = added_by_file[path]
|
|
746
|
+
# git could not describe this file (brand-new path, rename, sparse
|
|
747
|
+
# checkout) — fail CLOSED: an unattributable error gates.
|
|
748
|
+
if added is None or lineno in added:
|
|
749
|
+
gating.append(raw.strip())
|
|
750
|
+
else:
|
|
751
|
+
inherited.append(raw.strip())
|
|
752
|
+
return gating, inherited
|
|
753
|
+
|
|
754
|
+
|
|
755
|
+
def _attributed_verdict(
|
|
756
|
+
result: CheckResult, gating: list[str], inherited: list[str],
|
|
757
|
+
) -> CheckResult:
|
|
758
|
+
"""Rebuild the verdict from errors this diff actually introduced."""
|
|
759
|
+
if gating:
|
|
760
|
+
shown = "\n".join(gating[:_MAX_GREP_HITS])
|
|
761
|
+
if len(gating) > _MAX_GREP_HITS:
|
|
762
|
+
shown += f"\n(+{len(gating) - _MAX_GREP_HITS} more on added lines)"
|
|
763
|
+
summary = f"{len(gating)} type error(s) on lines this diff added:\n{shown}"
|
|
764
|
+
else:
|
|
765
|
+
summary = "no type errors on lines this diff added"
|
|
766
|
+
if inherited:
|
|
767
|
+
summary += (
|
|
768
|
+
f"\n{len(inherited)} strict error(s) on lines this diff did "
|
|
769
|
+
"not add — NOT gating; line position, not provenance"
|
|
770
|
+
)
|
|
771
|
+
return replace(result, passed=not gating, summary=summary)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _typecheck_scoped(
|
|
775
|
+
project_dir: Path, mypy: list[str], changed: list[str] | None, timeout: int,
|
|
776
|
+
) -> CheckResult | None:
|
|
777
|
+
"""Typecheck the DIFF, not the accumulated tree.
|
|
778
|
+
|
|
779
|
+
The same principle ``_lint_scoped`` already applies: pre-existing
|
|
780
|
+
project-wide debt is master's debt, not this change's. Measured on this
|
|
781
|
+
repo, project-wide mypy reports 1246 errors in 192 files — a verdict
|
|
782
|
+
that would REJECT every deliverable regardless of its own quality, and
|
|
783
|
+
a permanently red gate is one reviewers learn to ignore.
|
|
784
|
+
|
|
785
|
+
``--follow-imports=silent`` keeps the full import graph for inference
|
|
786
|
+
(so types resolve correctly) while reporting only on the named files.
|
|
787
|
+
Returns None when the diff carries no Python, so the caller falls back
|
|
788
|
+
to the project-wide run unchanged.
|
|
789
|
+
"""
|
|
790
|
+
files = _scoped_files(project_dir, changed, _LINTABLE_PY)
|
|
791
|
+
if not files:
|
|
792
|
+
return None
|
|
793
|
+
declared = _mypy_declared_scope(project_dir)
|
|
794
|
+
in_scope = [f for f in files if _within_declared_scope(f, declared)]
|
|
795
|
+
advisory = _project_wide_advisory(project_dir, mypy, timeout)
|
|
796
|
+
excluded = len(files) - len(in_scope)
|
|
797
|
+
if not in_scope:
|
|
798
|
+
# Every changed .py sits outside the project's declared scope.
|
|
799
|
+
# Falling through to the project-wide run would gate this diff on
|
|
800
|
+
# master's whole accumulated debt, which is the opposite of the
|
|
801
|
+
# point; the advisory still carries that number.
|
|
802
|
+
return _skip(
|
|
803
|
+
"typecheck",
|
|
804
|
+
f"{excluded} changed .py file(s) all outside the project's "
|
|
805
|
+
f"declared mypy scope ({', '.join(declared or [])})"
|
|
806
|
+
+ (f"\n{advisory}" if advisory else ""),
|
|
807
|
+
)
|
|
808
|
+
raw_result, full_output = _run_capturing(
|
|
809
|
+
"typecheck", [*mypy, "--follow-imports=silent", *in_scope],
|
|
810
|
+
project_dir, timeout,
|
|
811
|
+
)
|
|
812
|
+
result = _mypy_verdict(raw_result)
|
|
813
|
+
notes = [advisory] if advisory else []
|
|
814
|
+
# Attribute only a run that COMPLETED with type errors. An abort (or a
|
|
815
|
+
# timeout, or a usage error) means the checker never finished, and an
|
|
816
|
+
# empty gating list would then be an artefact of not looking.
|
|
817
|
+
if result.exit_code == 1 and _MYPY_ABORT_MARKER not in full_output:
|
|
818
|
+
attribution = _attribute_mypy_errors(project_dir, full_output)
|
|
819
|
+
if attribution is None:
|
|
820
|
+
notes.append(
|
|
821
|
+
"no merge-base with the default branch — errors could not "
|
|
822
|
+
"be attributed to added lines; ALL of them gate"
|
|
823
|
+
)
|
|
824
|
+
else:
|
|
825
|
+
result = _attributed_verdict(result, *attribution)
|
|
826
|
+
if excluded:
|
|
827
|
+
notes.append(
|
|
828
|
+
f"{excluded} changed .py file(s) skipped — outside the "
|
|
829
|
+
f"project's declared mypy scope ({', '.join(declared or [])})"
|
|
830
|
+
)
|
|
831
|
+
if notes:
|
|
832
|
+
result = replace(
|
|
833
|
+
result, summary="\n".join([result.summary, *notes]).strip(),
|
|
834
|
+
)
|
|
835
|
+
return _labelled(result, f"typecheck(scoped: {len(in_scope)} file(s))")
|
|
836
|
+
|
|
837
|
+
|
|
405
838
|
def _check_typecheck(
|
|
406
839
|
project_dir: Path, changed: list[str] | None,
|
|
407
840
|
test_command: str | None, timeout: int,
|
|
@@ -411,8 +844,30 @@ def _check_typecheck(
|
|
|
411
844
|
# never read the diff, so a known-empty diff would inherit
|
|
412
845
|
# master's type debt the moment tooling is configured.
|
|
413
846
|
return _skip("typecheck", "no changed files (empty diff)")
|
|
414
|
-
if _mypy_configured(project_dir)
|
|
415
|
-
|
|
847
|
+
if _mypy_configured(project_dir):
|
|
848
|
+
# `shutil.which` alone read a venv-installed mypy as "no typecheck
|
|
849
|
+
# configuration detected" — the generic skip, on a project that
|
|
850
|
+
# had explicitly opted in. `_tool_cmd` is the same resolver
|
|
851
|
+
# ruff/pytest/codespell already use, and its docstring names this
|
|
852
|
+
# exact class of bug: a FALSE GREEN on a NON-NEGOTIABLE gate
|
|
853
|
+
# (issue #452).
|
|
854
|
+
mypy = _tool_cmd("mypy")
|
|
855
|
+
if mypy is None:
|
|
856
|
+
return _skip(
|
|
857
|
+
"typecheck",
|
|
858
|
+
"mypy configured but not installed — install it "
|
|
859
|
+
"(pip install mypy) or drop the mypy config; the gate has "
|
|
860
|
+
"NO type signal for this run",
|
|
861
|
+
)
|
|
862
|
+
scoped = _typecheck_scoped(project_dir, mypy, changed, timeout)
|
|
863
|
+
if scoped is not None:
|
|
864
|
+
return scoped
|
|
865
|
+
return _mypy_verdict(
|
|
866
|
+
_run(
|
|
867
|
+
"typecheck", _mypy_project_argv(project_dir, mypy),
|
|
868
|
+
project_dir, timeout,
|
|
869
|
+
)
|
|
870
|
+
)
|
|
416
871
|
if (project_dir / "tsconfig.json").is_file():
|
|
417
872
|
local_tsc = project_dir / "node_modules" / ".bin" / "tsc"
|
|
418
873
|
if local_tsc.is_file():
|
|
@@ -1183,6 +1638,44 @@ def _derive_overall(results: list[CheckResult]) -> str:
|
|
|
1183
1638
|
return "insufficient-evidence"
|
|
1184
1639
|
|
|
1185
1640
|
|
|
1641
|
+
def _assert_provenance(project_dir: Path) -> None:
|
|
1642
|
+
"""Refuse to gate an ArkaOS checkout with a DIFFERENT copy of the engine.
|
|
1643
|
+
|
|
1644
|
+
The ArkaOS venv carries an editable ``.pth`` that points at the npx
|
|
1645
|
+
install cache, so ``import core`` resolves by CWD: run the gate from
|
|
1646
|
+
anywhere but the repo and it loads the PUBLISHED copy while reporting
|
|
1647
|
+
on the working tree (issue #453 — reproduced: v5.11.0 from
|
|
1648
|
+
``~/.npm/_npx/.../arkaos/core`` against a v5.13.0 checkout). A verdict
|
|
1649
|
+
derived from code the reviewer never saw is a provenance break on a
|
|
1650
|
+
NON-NEGOTIABLE gate, not a warning — so it kills the whole run rather
|
|
1651
|
+
than emitting a report that looks exactly like a trustworthy one.
|
|
1652
|
+
|
|
1653
|
+
Fires ONLY when ``project_dir`` is itself an engine checkout. Gating a
|
|
1654
|
+
CLIENT project legitimately runs ArkaOS's core from somewhere else,
|
|
1655
|
+
and refusing that would break every cross-project QG.
|
|
1656
|
+
"""
|
|
1657
|
+
try:
|
|
1658
|
+
target = Path(project_dir).resolve()
|
|
1659
|
+
engine = Path(core.__file__).resolve().parent
|
|
1660
|
+
except (OSError, TypeError, ValueError):
|
|
1661
|
+
return
|
|
1662
|
+
if not (target / "core" / "governance" / "evidence_checks.py").is_file():
|
|
1663
|
+
return # foreign project — the engine is expected to live elsewhere
|
|
1664
|
+
try:
|
|
1665
|
+
expected = (target / "core").resolve()
|
|
1666
|
+
except OSError:
|
|
1667
|
+
return
|
|
1668
|
+
if engine == expected:
|
|
1669
|
+
return
|
|
1670
|
+
raise ProvenanceError(
|
|
1671
|
+
"evidence gate provenance mismatch — the report would describe "
|
|
1672
|
+
f"code that was never loaded.\n project under review: {target}\n"
|
|
1673
|
+
f" engine actually imported: {engine}\n"
|
|
1674
|
+
f"Re-run from {target} (or with PYTHONPATH={target}) so the gate "
|
|
1675
|
+
"and the diff are the same code."
|
|
1676
|
+
)
|
|
1677
|
+
|
|
1678
|
+
|
|
1186
1679
|
def run_evidence_checks(
|
|
1187
1680
|
project_dir: Path,
|
|
1188
1681
|
changed_files: list[str] | None = None,
|
|
@@ -1190,7 +1683,12 @@ def run_evidence_checks(
|
|
|
1190
1683
|
test_command: str | None = None,
|
|
1191
1684
|
timeout: int = TIMEOUT_SECONDS,
|
|
1192
1685
|
) -> EvidenceReport:
|
|
1193
|
-
"""Run the selected checks and derive the overall evidence status.
|
|
1686
|
+
"""Run the selected checks and derive the overall evidence status.
|
|
1687
|
+
|
|
1688
|
+
Raises ``ProvenanceError`` before running anything when the imported
|
|
1689
|
+
engine is not the checkout being gated (see ``_assert_provenance``).
|
|
1690
|
+
"""
|
|
1691
|
+
_assert_provenance(project_dir)
|
|
1194
1692
|
project_dir = Path(project_dir)
|
|
1195
1693
|
selected = list(checks) if checks else list(ALL_CHECKS)
|
|
1196
1694
|
results: list[CheckResult] = []
|
|
@@ -1233,12 +1731,19 @@ def _csv(value: str | None) -> list[str] | None:
|
|
|
1233
1731
|
|
|
1234
1732
|
def main(argv: list[str] | None = None) -> int:
|
|
1235
1733
|
args = _build_parser().parse_args(argv)
|
|
1236
|
-
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1734
|
+
try:
|
|
1735
|
+
report = run_evidence_checks(
|
|
1736
|
+
project_dir=args.project_dir,
|
|
1737
|
+
changed_files=_csv(args.changed_files),
|
|
1738
|
+
checks=_csv(args.checks),
|
|
1739
|
+
test_command=args.test_command,
|
|
1740
|
+
)
|
|
1741
|
+
except ProvenanceError as exc:
|
|
1742
|
+
# Loud and unmissable, on stderr, with NO report on stdout: a
|
|
1743
|
+
# caller that pipes --json must get nothing to interpret rather
|
|
1744
|
+
# than a plausible-looking verdict from the wrong engine.
|
|
1745
|
+
print(f"[PROVENANCE-FAIL] {exc}", file=sys.stderr)
|
|
1746
|
+
return 3
|
|
1242
1747
|
if args.json:
|
|
1243
1748
|
print(json.dumps(report.to_dict(), indent=2))
|
|
1244
1749
|
else:
|