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.
Files changed (55) hide show
  1. package/THE-ARKAOS-GUIDE.md +1 -1
  2. package/VERSION +1 -1
  3. package/config/evals/marketing.yaml +4 -4
  4. package/core/governance/evidence_checks.py +526 -21
  5. package/core/hooks/stop.py +38 -1
  6. package/core/hooks/stop_governance.py +6 -2
  7. package/departments/dev/skills/security-compliance/SKILL.md +0 -5
  8. package/departments/dev/skills/threejs/SKILL.md +2 -2
  9. package/departments/landing/skills/offer-create/references/offer-formats.md +2 -2
  10. package/departments/marketing/skills/ad-creative/SKILL.md +1 -1
  11. package/departments/marketing/skills/ad-creative/references/creative-review-page.md +5 -5
  12. package/departments/ops/skills/gdpr-compliance/SKILL.md +0 -5
  13. package/departments/ops/skills/iso27001/SKILL.md +0 -5
  14. package/departments/ops/skills/quality-management/SKILL.md +0 -5
  15. package/departments/ops/skills/risk-management/SKILL.md +0 -5
  16. package/departments/ops/skills/soc2-compliance/SKILL.md +0 -5
  17. package/departments/sales/skills/prospecting/references/demand-signals.md +1 -1
  18. package/harness/codex/AGENTS.md +1 -1
  19. package/harness/copilot/copilot-instructions.md +1 -1
  20. package/harness/cursor/rules/arkaos.mdc +2 -2
  21. package/harness/gemini/GEMINI.md +1 -1
  22. package/harness/opencode/AGENTS.md +1 -1
  23. package/harness/opencode/agents/arka-architect-gabriel.md +1 -1
  24. package/harness/opencode/agents/arka-brand-director-valentina.md +1 -1
  25. package/harness/opencode/agents/arka-cfo-helena.md +1 -1
  26. package/harness/opencode/agents/arka-chief-of-staff-afonso.md +1 -1
  27. package/harness/opencode/agents/arka-community-strategist-beatriz.md +1 -1
  28. package/harness/opencode/agents/arka-content-strategist-rafael.md +1 -1
  29. package/harness/opencode/agents/arka-conversion-strategist-ines.md +1 -1
  30. package/harness/opencode/agents/arka-coo-sofia.md +1 -1
  31. package/harness/opencode/agents/arka-copy-director-eduardo.md +1 -1
  32. package/harness/opencode/agents/arka-cqo-marta.md +1 -1
  33. package/harness/opencode/agents/arka-cto-marco.md +1 -1
  34. package/harness/opencode/agents/arka-design-ops-lead-iris.md +1 -1
  35. package/harness/opencode/agents/arka-ecom-director-ricardo.md +1 -1
  36. package/harness/opencode/agents/arka-knowledge-director-clara.md +1 -1
  37. package/harness/opencode/agents/arka-leadership-director-rodrigo.md +1 -1
  38. package/harness/opencode/agents/arka-marketing-director-luna.md +1 -1
  39. package/harness/opencode/agents/arka-ops-lead-daniel.md +1 -1
  40. package/harness/opencode/agents/arka-pm-director-carolina.md +1 -1
  41. package/harness/opencode/agents/arka-revops-lead-vicente.md +1 -1
  42. package/harness/opencode/agents/arka-saas-strategist-tiago.md +1 -1
  43. package/harness/opencode/agents/arka-sales-director-miguel.md +1 -1
  44. package/harness/opencode/agents/arka-strategy-director-tomas.md +1 -1
  45. package/harness/opencode/agents/arka-tech-director-francisca.md +1 -1
  46. package/harness/opencode/agents/arka-tech-lead-paulo.md +1 -1
  47. package/harness/opencode/agents/arka-video-producer-simao.md +1 -1
  48. package/harness/zed/.rules +1 -1
  49. package/knowledge/skills-manifest.json +1 -1
  50. package/package.json +3 -1
  51. package/pyproject.toml +36 -2
  52. package/scripts/fenced-code-allowlist.json +308 -0
  53. package/scripts/lint_fenced_code.mjs +591 -0
  54. package/scripts/skills_catalog_gen.py +21 -8
  55. package/scripts/tools/docs_stats.py +42 -2
@@ -1,6 +1,6 @@
1
1
  # The ArkaOS Guide
2
2
 
3
- > v5.13.0 — 89 agents, 17 departments, 340 skills, 306 commands, 20 ADRs.
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.13.0
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 from the HTML template, not plain markdown
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 references/creative-review-page.md
402
- + the assets/creative-review-template.html template rather than producing plain markdown.
403
- Should copy the template into the output folder and populate its DATA object with the
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
- output = _tail(proc.stdout.strip() or proc.stderr.strip())
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=output,
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
- eslint = project_dir / "node_modules" / ".bin" / "eslint"
335
- if eslint.is_file():
336
- files = _scoped_files(project_dir, changed, _LINTABLE_JS)
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) and shutil.which("mypy"):
415
- return _run("typecheck", ["mypy", "."], project_dir, timeout)
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
- report = run_evidence_checks(
1237
- project_dir=args.project_dir,
1238
- changed_files=_csv(args.changed_files),
1239
- checks=_csv(args.checks),
1240
- test_command=args.test_command,
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: