@arbiterforge/ca-pi 0.6.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/LICENSE +661 -0
- package/README.md +558 -0
- package/package.json +35 -0
- package/plugins/ca-pi/CHANGELOG.md +1030 -0
- package/plugins/ca-pi/COMMANDS.md +90 -0
- package/plugins/ca-pi/ORCHESTRATOR.md +159 -0
- package/plugins/ca-pi/SKILLS.md +47 -0
- package/plugins/ca-pi/SPRINT.md +142 -0
- package/plugins/ca-pi/agents/INDEX.md +31 -0
- package/plugins/ca-pi/agents/architecture-drift-reviewer.md +86 -0
- package/plugins/ca-pi/agents/auth-crypto-reviewer.md +60 -0
- package/plugins/ca-pi/agents/backend-author.md +60 -0
- package/plugins/ca-pi/agents/checkpoint-aggregator.md +111 -0
- package/plugins/ca-pi/agents/coverage-auditor.md +71 -0
- package/plugins/ca-pi/agents/decision-challenger.md +116 -0
- package/plugins/ca-pi/agents/dependency-reviewer.md +79 -0
- package/plugins/ca-pi/agents/design-quality-reviewer.md +80 -0
- package/plugins/ca-pi/agents/finding-triage.md +86 -0
- package/plugins/ca-pi/agents/frontend-author.md +64 -0
- package/plugins/ca-pi/agents/grader.md +173 -0
- package/plugins/ca-pi/agents/infra-author.md +64 -0
- package/plugins/ca-pi/agents/map-deps.md +35 -0
- package/plugins/ca-pi/agents/map-structure.md +37 -0
- package/plugins/ca-pi/agents/migration-reviewer.md +65 -0
- package/plugins/ca-pi/agents/scout.md +127 -0
- package/plugins/ca-pi/agents/security-reviewer.md +72 -0
- package/plugins/ca-pi/agents/tribunal-lens-reviewer.md +65 -0
- package/plugins/ca-pi/extensions/codearbiter-child.js +1885 -0
- package/plugins/ca-pi/extensions/codearbiter.js +9802 -0
- package/plugins/ca-pi/generated/command-catalog.json +197 -0
- package/plugins/ca-pi/generated/roles.json +213 -0
- package/plugins/ca-pi/helpers/windows-supervisor.js +205 -0
- package/plugins/ca-pi/hooks/_activationlib.py +196 -0
- package/plugins/ca-pi/hooks/_arbiterstatelib.py +208 -0
- package/plugins/ca-pi/hooks/_babysitlib.py +76 -0
- package/plugins/ca-pi/hooks/_bashguardlib.py +1667 -0
- package/plugins/ca-pi/hooks/_boxlib.py +131 -0
- package/plugins/ca-pi/hooks/_colorlib.py +304 -0
- package/plugins/ca-pi/hooks/_durabilitylib.py +186 -0
- package/plugins/ca-pi/hooks/_entrylib.py +41 -0
- package/plugins/ca-pi/hooks/_fmtlib.py +161 -0
- package/plugins/ca-pi/hooks/_gitexec.py +45 -0
- package/plugins/ca-pi/hooks/_githooks.py +920 -0
- package/plugins/ca-pi/hooks/_gitlib.py +110 -0
- package/plugins/ca-pi/hooks/_hooklib.py +595 -0
- package/plugins/ca-pi/hooks/_host.py +115 -0
- package/plugins/ca-pi/hooks/_intentlib.py +242 -0
- package/plugins/ca-pi/hooks/_ledgerlib.py +1035 -0
- package/plugins/ca-pi/hooks/_metricslib.py +709 -0
- package/plugins/ca-pi/hooks/_pathnorm.py +74 -0
- package/plugins/ca-pi/hooks/_planfilelib.py +664 -0
- package/plugins/ca-pi/hooks/_previewlib.py +193 -0
- package/plugins/ca-pi/hooks/_protectedlib.py +312 -0
- package/plugins/ca-pi/hooks/_protectedstatelib.py +411 -0
- package/plugins/ca-pi/hooks/_provenancelib.py +971 -0
- package/plugins/ca-pi/hooks/_prunelib.py +1398 -0
- package/plugins/ca-pi/hooks/_prunepolicy.py +235 -0
- package/plugins/ca-pi/hooks/_readinjectlib.py +1080 -0
- package/plugins/ca-pi/hooks/_releaselib.py +2657 -0
- package/plugins/ca-pi/hooks/_scopelib.py +262 -0
- package/plugins/ca-pi/hooks/_segmentslib.py +278 -0
- package/plugins/ca-pi/hooks/_sensitivelib.py +270 -0
- package/plugins/ca-pi/hooks/_sessionlib.py +78 -0
- package/plugins/ca-pi/hooks/_sloplib.py +244 -0
- package/plugins/ca-pi/hooks/_standuplib.py +214 -0
- package/plugins/ca-pi/hooks/_subagentslib.py +219 -0
- package/plugins/ca-pi/hooks/_taskboardlib.py +1088 -0
- package/plugins/ca-pi/hooks/_updatelib.py +278 -0
- package/plugins/ca-pi/hooks/babysit.py +47 -0
- package/plugins/ca-pi/hooks/boardsync.py +129 -0
- package/plugins/ca-pi/hooks/doctor.py +420 -0
- package/plugins/ca-pi/hooks/git-enforce.py +325 -0
- package/plugins/ca-pi/hooks/hostapi.py +460 -0
- package/plugins/ca-pi/hooks/init-codearbiter.py +225 -0
- package/plugins/ca-pi/hooks/metrics.py +62 -0
- package/plugins/ca-pi/hooks/migration-pass.py +129 -0
- package/plugins/ca-pi/hooks/pi-bridge.py +543 -0
- package/plugins/ca-pi/hooks/post-write-edit.py +231 -0
- package/plugins/ca-pi/hooks/pre-bash.py +90 -0
- package/plugins/ca-pi/hooks/pre-edit.py +284 -0
- package/plugins/ca-pi/hooks/pre-read.py +81 -0
- package/plugins/ca-pi/hooks/pre-write.py +217 -0
- package/plugins/ca-pi/hooks/preview.py +69 -0
- package/plugins/ca-pi/hooks/prune-transcript.py +232 -0
- package/plugins/ca-pi/hooks/releasehash.py +216 -0
- package/plugins/ca-pi/hooks/security-pass.py +139 -0
- package/plugins/ca-pi/hooks/session-start.py +1218 -0
- package/plugins/ca-pi/hooks/statusline.py +736 -0
- package/plugins/ca-pi/hooks/taskwrite.py +351 -0
- package/plugins/ca-pi/hooks/update-refresh.py +51 -0
- package/plugins/ca-pi/hooks/wire-statusline.py +435 -0
- package/plugins/ca-pi/includes/anti-slop-design/INDEX.md +55 -0
- package/plugins/ca-pi/includes/anti-slop-design/color.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/core.md +244 -0
- package/plugins/ca-pi/includes/anti-slop-design/images.md +32 -0
- package/plugins/ca-pi/includes/anti-slop-design/layout.md +45 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-cli.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-dataviz.md +43 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-diagram.md +35 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-documents.md +70 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-slides.md +30 -0
- package/plugins/ca-pi/includes/anti-slop-design/medium-web.md +39 -0
- package/plugins/ca-pi/includes/anti-slop-design/typography.md +51 -0
- package/plugins/ca-pi/includes/author-tdd-workflow.md +14 -0
- package/plugins/ca-pi/includes/compaction-charter.md +16 -0
- package/plugins/ca-pi/includes/cut-docs.md +16 -0
- package/plugins/ca-pi/includes/dev-mode.md +30 -0
- package/plugins/ca-pi/includes/farm.md +237 -0
- package/plugins/ca-pi/includes/fresh-verification.md +14 -0
- package/plugins/ca-pi/includes/harvest.md +69 -0
- package/plugins/ca-pi/includes/maturity-coverage.md +102 -0
- package/plugins/ca-pi/includes/pi-host-notes.md +69 -0
- package/plugins/ca-pi/includes/redirect.md +69 -0
- package/plugins/ca-pi/includes/reference-map.md +22 -0
- package/plugins/ca-pi/includes/review-matrix.md +14 -0
- package/plugins/ca-pi/includes/reviewer-contract.md +53 -0
- package/plugins/ca-pi/includes/routing-table.md +47 -0
- package/plugins/ca-pi/includes/security-gate-record.md +22 -0
- package/plugins/ca-pi/includes/smarts/core.md +90 -0
- package/plugins/ca-pi/includes/smarts/decision-log-format.md +56 -0
- package/plugins/ca-pi/routines/INDEX.md +32 -0
- package/plugins/ca-pi/routines/brainstorming/SKILL.md +122 -0
- package/plugins/ca-pi/routines/commit-gate/SKILL.md +151 -0
- package/plugins/ca-pi/routines/context-check/SKILL.md +85 -0
- package/plugins/ca-pi/routines/context-creation/SKILL.md +171 -0
- package/plugins/ca-pi/routines/crypto-compliance/SKILL.md +41 -0
- package/plugins/ca-pi/routines/debug/SKILL.md +99 -0
- package/plugins/ca-pi/routines/decision-lifecycle/SKILL.md +104 -0
- package/plugins/ca-pi/routines/decision-lifecycle/references/adr-template.md +74 -0
- package/plugins/ca-pi/routines/decision-variance/SKILL.md +147 -0
- package/plugins/ca-pi/routines/decompose/SKILL.md +168 -0
- package/plugins/ca-pi/routines/dispatching-parallel-agents/SKILL.md +76 -0
- package/plugins/ca-pi/routines/executing-plans/SKILL.md +83 -0
- package/plugins/ca-pi/routines/finishing-a-development-branch/SKILL.md +91 -0
- package/plugins/ca-pi/routines/post-merge-cleanup/SKILL.md +233 -0
- package/plugins/ca-pi/routines/refactor/SKILL.md +91 -0
- package/plugins/ca-pi/routines/release/SKILL.md +315 -0
- package/plugins/ca-pi/routines/secret-handling/SKILL.md +67 -0
- package/plugins/ca-pi/routines/security-architecture/SKILL.md +63 -0
- package/plugins/ca-pi/routines/skill-author/SKILL.md +108 -0
- package/plugins/ca-pi/routines/skill-author/references/skill-template.md +58 -0
- package/plugins/ca-pi/routines/subagent-driven-development/SKILL.md +149 -0
- package/plugins/ca-pi/routines/subagent-driven-development/references/farm-dispatch.md +145 -0
- package/plugins/ca-pi/routines/tdd/SKILL.md +139 -0
- package/plugins/ca-pi/routines/tribunal/SKILL.md +109 -0
- package/plugins/ca-pi/routines/tribunal/references/ai-markers.md +29 -0
- package/plugins/ca-pi/routines/tribunal/references/cost-and-models.md +64 -0
- package/plugins/ca-pi/routines/tribunal/references/finding-record.md +27 -0
- package/plugins/ca-pi/routines/tribunal/references/issue-filing.md +47 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/appsec.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/architecture.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/coverage.md +20 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/infra.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/migration.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/observability.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/performance.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/reliability.md +23 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/secrets-supply.md +22 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/test-fidelity.md +24 -0
- package/plugins/ca-pi/routines/tribunal/references/lenses/typesafety.md +21 -0
- package/plugins/ca-pi/routines/tribunal/references/report.md +19 -0
- package/plugins/ca-pi/routines/tribunal/references/schemas.md +58 -0
- package/plugins/ca-pi/routines/tribunal/references/telemetry.md +28 -0
- package/plugins/ca-pi/routines/tribunal/references/triage.md +53 -0
- package/plugins/ca-pi/routines/using-git-worktrees/SKILL.md +85 -0
- package/plugins/ca-pi/routines/writing-plans/SKILL.md +129 -0
- package/plugins/ca-pi/routines/writing-plans/references/farm-plan.md +50 -0
- package/plugins/ca-pi/skills/ca-add-dep/SKILL.md +88 -0
- package/plugins/ca-pi/skills/ca-adr/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-adr-status/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-arbiter/SKILL.md +36 -0
- package/plugins/ca-pi/skills/ca-audit/SKILL.md +51 -0
- package/plugins/ca-pi/skills/ca-btw/SKILL.md +23 -0
- package/plugins/ca-pi/skills/ca-checkpoint/SKILL.md +50 -0
- package/plugins/ca-pi/skills/ca-chore/SKILL.md +58 -0
- package/plugins/ca-pi/skills/ca-cleanup/SKILL.md +55 -0
- package/plugins/ca-pi/skills/ca-commands/SKILL.md +21 -0
- package/plugins/ca-pi/skills/ca-commit/SKILL.md +27 -0
- package/plugins/ca-pi/skills/ca-conflict/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-context-check/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-create-context/SKILL.md +32 -0
- package/plugins/ca-pi/skills/ca-debug/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-decompose/SKILL.md +30 -0
- package/plugins/ca-pi/skills/ca-dev/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-doctor/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-feature/SKILL.md +105 -0
- package/plugins/ca-pi/skills/ca-fix/SKILL.md +42 -0
- package/plugins/ca-pi/skills/ca-init/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-metrics/SKILL.md +80 -0
- package/plugins/ca-pi/skills/ca-new-skill/SKILL.md +34 -0
- package/plugins/ca-pi/skills/ca-override/SKILL.md +72 -0
- package/plugins/ca-pi/skills/ca-pr/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-preview/SKILL.md +86 -0
- package/plugins/ca-pi/skills/ca-prune/SKILL.md +100 -0
- package/plugins/ca-pi/skills/ca-reconcile/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-refactor/SKILL.md +43 -0
- package/plugins/ca-pi/skills/ca-release/SKILL.md +57 -0
- package/plugins/ca-pi/skills/ca-review/SKILL.md +63 -0
- package/plugins/ca-pi/skills/ca-spike/SKILL.md +41 -0
- package/plugins/ca-pi/skills/ca-sprint/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-standup/SKILL.md +112 -0
- package/plugins/ca-pi/skills/ca-status/SKILL.md +56 -0
- package/plugins/ca-pi/skills/ca-task/SKILL.md +61 -0
- package/plugins/ca-pi/skills/ca-threat-model/SKILL.md +45 -0
- package/plugins/ca-pi/skills/ca-tribunal/SKILL.md +44 -0
- package/plugins/ca-pi/skills/ca-watch/SKILL.md +78 -0
|
@@ -0,0 +1,971 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
# codeArbiter — provenance store for per-doc source evidence and drift detection.
|
|
3
|
+
#
|
|
4
|
+
# Captures the scout evidence that backs each derived `.codearbiter/` doc
|
|
5
|
+
# (tech-stack.md, coding-standards.md, security-controls.md, CONTEXT.md,
|
|
6
|
+
# code-map.md) as a per-doc JSON file at `.codearbiter/.provenance/<doc>.json`.
|
|
7
|
+
# When a tracked source file changes, drift detection (compute_drift) finds the
|
|
8
|
+
# gap; commit-gate auto-heals it (pillar 3 of context-drift-provenance spec).
|
|
9
|
+
#
|
|
10
|
+
# Design invariants (mirroring _taskboardlib.py / _metricslib.py):
|
|
11
|
+
# - Stdlib only; no third-party imports ever — runs on stock Python.
|
|
12
|
+
# - Zero side effects at import time: no git calls, no file I/O on import.
|
|
13
|
+
# - Pure functions are fully testable with synthetic data (no real file
|
|
14
|
+
# needed). write_provenance() and read_provenance() are the ONLY functions
|
|
15
|
+
# that touch the filesystem.
|
|
16
|
+
# - Never raise on malformed input — degrade gracefully (this runs on the
|
|
17
|
+
# SessionStart linchpin path).
|
|
18
|
+
# - Hashing uses git hash-object via an injectable runner (batch_hash);
|
|
19
|
+
# raw byte sha256 is intentionally NOT used — git honors .gitattributes
|
|
20
|
+
# EOL normalization so an LF<->CRLF flip (documented Edit hazard on
|
|
21
|
+
# Windows) never false-flags as drift.
|
|
22
|
+
#
|
|
23
|
+
# Schema (per-doc provenance file, v1):
|
|
24
|
+
# {
|
|
25
|
+
# "schema": 1,
|
|
26
|
+
# "doc": "tech-stack",
|
|
27
|
+
# "created": "2026-06-26",
|
|
28
|
+
# "interview_derived": false,
|
|
29
|
+
# "entries": [
|
|
30
|
+
# {
|
|
31
|
+
# "path": "plugins/ca/tools/package.json",
|
|
32
|
+
# "hash": "<git oid or null>",
|
|
33
|
+
# "drift_trigger": true,
|
|
34
|
+
# "claims": [
|
|
35
|
+
# {
|
|
36
|
+
# "lines": "12-40",
|
|
37
|
+
# "claim": "Node 20 runtime declared in package.json",
|
|
38
|
+
# "confidence": "strong"
|
|
39
|
+
# }
|
|
40
|
+
# ]
|
|
41
|
+
# }
|
|
42
|
+
# ]
|
|
43
|
+
# }
|
|
44
|
+
#
|
|
45
|
+
# Files live at: .codearbiter/.provenance/<doc>.json
|
|
46
|
+
# JSON format: pretty-printed, sorted keys, trailing newline, utf-8, LF endings.
|
|
47
|
+
#
|
|
48
|
+
# Public API:
|
|
49
|
+
# new_record(doc, *, interview_derived=False, entries=None, created=None)
|
|
50
|
+
# -> dict canonical record with schema=1
|
|
51
|
+
# write_provenance(path, record) -> None pretty JSON; creates parent dirs
|
|
52
|
+
# valid_provenance_record(record) -> bool True iff a well-formed v1
|
|
53
|
+
# record (top-level shape)
|
|
54
|
+
# read_provenance(path) -> dict | None None on missing/corrupt
|
|
55
|
+
# — including valid JSON whose
|
|
56
|
+
# shape is not a v1 record
|
|
57
|
+
# batch_hash(paths, runner) -> dict[str, str] one git hash-object --stdin-paths
|
|
58
|
+
# call; input-order-preserving; {}
|
|
59
|
+
# on empty paths or runner failure
|
|
60
|
+
# classify_source(path) -> bool True iff path is drift_trigger
|
|
61
|
+
# (config/manifest/schema/security-entry);
|
|
62
|
+
# False for general source; never raises
|
|
63
|
+
# compute_drift(provenance_map, current_hashes)
|
|
64
|
+
# -> dict {doc: [{"path":…,"kind":"changed"|"missing"}]}
|
|
65
|
+
# kind="changed": path present, hash diverged.
|
|
66
|
+
# kind="missing": path absent from current_hashes
|
|
67
|
+
# (source renamed/deleted — AC-05/T-06).
|
|
68
|
+
# Both kinds filtered to drift_trigger:true only
|
|
69
|
+
# (AC-09/T-05). Docs with no drift omitted (→ {}).
|
|
70
|
+
# load_provenance_dir(provenance_dir, skipped=None)
|
|
71
|
+
# -> dict {doc: record}; {} on missing/corrupt dir.
|
|
72
|
+
# Per-FILE error boundary: one bad record
|
|
73
|
+
# never suppresses a later valid one.
|
|
74
|
+
# `skipped` (optional list) collects up to
|
|
75
|
+
# MAX_SKIPPED_REPORTED rejected basenames.
|
|
76
|
+
# startup_drift_line(root, runner=None)
|
|
77
|
+
# -> str "" when clean (AC-06);
|
|
78
|
+
# "context drift: N stale source(s) across M doc(s) -- run /ca:context-check"
|
|
79
|
+
# when drift > 0 (AC-07). ASCII-only, exactly one line.
|
|
80
|
+
# changed_scope(doc_provenance, drift) -> list[str] drifted paths for this doc only (both
|
|
81
|
+
# changed+missing kinds), in drift order;
|
|
82
|
+
# [] when the doc has no drift entry or
|
|
83
|
+
# any input is malformed/None (never raises)
|
|
84
|
+
# rebaseline(provenance, current_hashes) -> dict new record with each drift_trigger
|
|
85
|
+
# entry's hash set to current_hashes[path];
|
|
86
|
+
# absent paths left as-is; never raises
|
|
87
|
+
# heal_worklist(staged_paths, provenance, current_hashes) -> list[str]
|
|
88
|
+
# commit-gate worklist: staged paths that are
|
|
89
|
+
# drift_trigger:true with a diverged or absent
|
|
90
|
+
# current hash; [] when no staged file is tracked
|
|
91
|
+
# (cost guarantee — ordinary commits pay nothing, AC-13)
|
|
92
|
+
# lint_code_map(text) -> list[str] cap / multi-line violations
|
|
93
|
+
# write_stub(path, doc, *, interview_derived=True, created=None) -> None
|
|
94
|
+
# write greenfield stub: interview_derived=True, entries=[]
|
|
95
|
+
|
|
96
|
+
import datetime
|
|
97
|
+
import glob
|
|
98
|
+
import json
|
|
99
|
+
import ntpath
|
|
100
|
+
import os
|
|
101
|
+
import posixpath
|
|
102
|
+
import re
|
|
103
|
+
import subprocess
|
|
104
|
+
|
|
105
|
+
from _gitexec import git_executable
|
|
106
|
+
|
|
107
|
+
import _hooklib
|
|
108
|
+
|
|
109
|
+
# ---------------------------------------------------------------------------
|
|
110
|
+
# Constants
|
|
111
|
+
# ---------------------------------------------------------------------------
|
|
112
|
+
|
|
113
|
+
SCHEMA_VERSION = 1
|
|
114
|
+
|
|
115
|
+
# Maximum seconds allowed for a single git read call. A hung git process must
|
|
116
|
+
# never stall the SessionStart linchpin hook indefinitely; timeout degrades to
|
|
117
|
+
# the existing except-Exception degrade paths (batch_hash → {}, startup_drift_line → "").
|
|
118
|
+
GIT_TIMEOUT = 5 # seconds; a git read must never stall SessionStart
|
|
119
|
+
|
|
120
|
+
# Tunable cap for code-map entry count. 50 enforces module/concern granularity
|
|
121
|
+
# (coarse index only); raise it only when a project legitimately has more top-level
|
|
122
|
+
# concerns than this. The lint is the guard against the code map drifting into
|
|
123
|
+
# a full file index — never allow it to grow past this without deliberate review.
|
|
124
|
+
CODE_MAP_MAX_ENTRIES = 50
|
|
125
|
+
|
|
126
|
+
# Regex matching a column-0 entry bullet: starts with '- `' (dash, space, backtick).
|
|
127
|
+
# Concern '## heading' lines are NOT entries. Captures the path between backticks.
|
|
128
|
+
_ENTRY_RE = re.compile(r"^- `([^`]+)`")
|
|
129
|
+
|
|
130
|
+
# ---------------------------------------------------------------------------
|
|
131
|
+
# classify_source constants
|
|
132
|
+
# ---------------------------------------------------------------------------
|
|
133
|
+
|
|
134
|
+
# Fixed filenames that are always drift_trigger — exact case match (no lowering).
|
|
135
|
+
# These are canonical ecosystem names that do not vary in capitalisation except
|
|
136
|
+
# for Cargo.toml / Gemfile / Gemfile.lock (capital-first by convention).
|
|
137
|
+
_DRIFT_FIXED_NAMES = frozenset({
|
|
138
|
+
"package.json",
|
|
139
|
+
"package-lock.json",
|
|
140
|
+
"yarn.lock",
|
|
141
|
+
"pnpm-lock.yaml",
|
|
142
|
+
"pyproject.toml",
|
|
143
|
+
"go.mod",
|
|
144
|
+
"Cargo.toml",
|
|
145
|
+
"Gemfile",
|
|
146
|
+
"Gemfile.lock",
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
# Path-pattern rules (applied to the separator-normalised path, case-insensitive):
|
|
150
|
+
# • requirements*.txt — basename wildcard for any requirements file
|
|
151
|
+
# • .github/workflows/ — CI/pipeline yaml anywhere in the path
|
|
152
|
+
# • *.prisma — Prisma schema files
|
|
153
|
+
# • *.sql — SQL files (schema dumps, migrations)
|
|
154
|
+
# • migrations/ — any path segment named "migrations"
|
|
155
|
+
# • .env.example / .env.sample / .env.template — env templates
|
|
156
|
+
_DRIFT_PATH_RE = re.compile(
|
|
157
|
+
r"""
|
|
158
|
+
# requirements*.txt (basename: starts with 'requirements', ends with '.txt')
|
|
159
|
+
(?:^|/) requirements [^/]* \.txt $
|
|
160
|
+
|
|
|
161
|
+
# CI / pipeline yaml: under .github/workflows/
|
|
162
|
+
(?:^|/) \.github/workflows/ [^/]+ \.ya?ml $
|
|
163
|
+
|
|
|
164
|
+
# Prisma schema files (any directory)
|
|
165
|
+
[^/]+ \.prisma $
|
|
166
|
+
|
|
|
167
|
+
# SQL files (any directory)
|
|
168
|
+
[^/]+ \.sql $
|
|
169
|
+
|
|
|
170
|
+
# Any path containing a 'migrations/' segment
|
|
171
|
+
(?:^|/) migrations /
|
|
172
|
+
|
|
|
173
|
+
# Env templates: .env.example / .env.sample / .env.template
|
|
174
|
+
(?:^|/) \.env\. (?:example|sample|template) $
|
|
175
|
+
""",
|
|
176
|
+
re.IGNORECASE | re.VERBOSE,
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
# Tokeniser that splits a normalised path on separator characters AND camelCase
|
|
180
|
+
# transitions so security-entry keywords are matched as whole tokens, not as
|
|
181
|
+
# substrings. Separator characters covered: / \ . _ - (backslash is already
|
|
182
|
+
# normalised to / before classify_source calls split, but kept here for defence).
|
|
183
|
+
# camelCase boundary: (?<=[a-z])(?=[A-Z]) — e.g. "authMiddleware" → ["auth","Middleware"].
|
|
184
|
+
_SECURITY_TOKEN_SPLIT_RE = re.compile(r"[\\/._\-]|(?<=[a-z])(?=[A-Z])")
|
|
185
|
+
|
|
186
|
+
# Security-entry token keywords. A path is a drift_trigger when any token in the
|
|
187
|
+
# tokenised, lowercased normalised path is an exact member of this set.
|
|
188
|
+
# MATCH : auth.ts, src/middleware/cors.py, jwt.go, authMiddleware.ts, jwt_utils.py
|
|
189
|
+
# NO-MATCH : author.py, AuthorCard.tsx, oauth.ts (tokens are "author"/"Author"/"oauth")
|
|
190
|
+
_SECURITY_ENTRY_TOKENS = frozenset({"auth", "middleware", "jwt"})
|
|
191
|
+
|
|
192
|
+
# ---------------------------------------------------------------------------
|
|
193
|
+
# Constructor helper
|
|
194
|
+
# ---------------------------------------------------------------------------
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def new_record(doc, *, interview_derived=False, entries=None, created=None):
|
|
198
|
+
"""Build a canonical provenance record dict with schema=SCHEMA_VERSION.
|
|
199
|
+
|
|
200
|
+
`doc` — short name of the derived doc, e.g. "tech-stack".
|
|
201
|
+
`interview_derived`— True for greenfield stubs (no source files yet).
|
|
202
|
+
`entries` — list of entry dicts; defaults to [].
|
|
203
|
+
`created` — ISO date string; defaults to today (datetime.date).
|
|
204
|
+
|
|
205
|
+
Never raises. Returns a new dict on every call; the caller owns it.
|
|
206
|
+
"""
|
|
207
|
+
if created is None:
|
|
208
|
+
created = datetime.date.today().isoformat()
|
|
209
|
+
return {
|
|
210
|
+
"schema": SCHEMA_VERSION,
|
|
211
|
+
"doc": str(doc),
|
|
212
|
+
"created": str(created),
|
|
213
|
+
"interview_derived": bool(interview_derived),
|
|
214
|
+
"entries": list(entries) if entries is not None else [],
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
|
|
218
|
+
# ---------------------------------------------------------------------------
|
|
219
|
+
# Filesystem functions (the ONLY functions that touch the filesystem)
|
|
220
|
+
# ---------------------------------------------------------------------------
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def write_provenance(path, record):
|
|
224
|
+
"""Write `record` as pretty JSON to `path`, atomically.
|
|
225
|
+
|
|
226
|
+
Format: indent=2, sorted keys, ensure_ascii=False, trailing newline, utf-8,
|
|
227
|
+
LF line endings (canonical EOL for this repo). Creates the parent directory
|
|
228
|
+
if it does not exist. Routed through _hooklib.write_text_atomic (sibling
|
|
229
|
+
temp file + os.replace) so a crash mid-write leaves the previous provenance
|
|
230
|
+
record intact instead of a truncated/corrupt file (reliability-016).
|
|
231
|
+
"""
|
|
232
|
+
parent = os.path.dirname(path)
|
|
233
|
+
if parent:
|
|
234
|
+
os.makedirs(parent, exist_ok=True)
|
|
235
|
+
text = json.dumps(record, indent=2, sort_keys=True, ensure_ascii=False) + "\n"
|
|
236
|
+
_hooklib.write_text_atomic(path, text, newline="\n")
|
|
237
|
+
|
|
238
|
+
|
|
239
|
+
# Top-level provenance-record contract (v1). Each required key maps to the
|
|
240
|
+
# type(s) a canonical record — the one new_record() builds — always carries.
|
|
241
|
+
# `bool` is excluded from the `schema` check explicitly because in Python
|
|
242
|
+
# `True == 1`, and a record whose schema is literally `true` is corrupt, not v1.
|
|
243
|
+
_RECORD_FIELD_TYPES = (
|
|
244
|
+
("doc", str),
|
|
245
|
+
("created", str),
|
|
246
|
+
("interview_derived", bool),
|
|
247
|
+
("entries", list),
|
|
248
|
+
)
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def valid_provenance_record(record):
|
|
252
|
+
"""True iff `record` is a well-formed v1 provenance record (#410).
|
|
253
|
+
|
|
254
|
+
Checks the TOP-LEVEL shape only: the store's own frame. Entry-level
|
|
255
|
+
tolerance stays where it already lives — compute_drift and the read-inject
|
|
256
|
+
pointer both skip a malformed entry without dropping its record — so one
|
|
257
|
+
odd claim never costs a doc its entire provenance.
|
|
258
|
+
|
|
259
|
+
A schema other than SCHEMA_VERSION is rejected rather than best-effort
|
|
260
|
+
parsed: every field expectation below is v1-specific, so admitting an
|
|
261
|
+
unknown version would mean reading it with the wrong rules. Bumping
|
|
262
|
+
SCHEMA_VERSION therefore requires revisiting this function deliberately.
|
|
263
|
+
Never raises.
|
|
264
|
+
"""
|
|
265
|
+
if not isinstance(record, dict):
|
|
266
|
+
return False
|
|
267
|
+
schema = record.get("schema")
|
|
268
|
+
if isinstance(schema, bool) or not isinstance(schema, int):
|
|
269
|
+
return False
|
|
270
|
+
if schema != SCHEMA_VERSION:
|
|
271
|
+
return False
|
|
272
|
+
for key, want in _RECORD_FIELD_TYPES:
|
|
273
|
+
if key not in record or not isinstance(record[key], want):
|
|
274
|
+
return False
|
|
275
|
+
return True
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def read_provenance(path):
|
|
279
|
+
"""Read provenance JSON from `path`; return the dict, or None if missing/corrupt.
|
|
280
|
+
|
|
281
|
+
Never raises — mirrors read_board() in _taskboardlib.py. A missing file,
|
|
282
|
+
a permission error, or malformed JSON all return None; the caller degrades
|
|
283
|
+
gracefully.
|
|
284
|
+
|
|
285
|
+
#410: "corrupt" now includes SYNTACTICALLY VALID JSON of the wrong shape.
|
|
286
|
+
The documented return type is `dict | None`, but the raw json.load result
|
|
287
|
+
was handed straight back — so a file containing `[]` was admitted to the
|
|
288
|
+
store as a list and the first `.get()` downstream raised AttributeError.
|
|
289
|
+
Honouring the documented type here is what lets every caller treat a
|
|
290
|
+
non-None result as a usable record.
|
|
291
|
+
"""
|
|
292
|
+
try:
|
|
293
|
+
with open(path, encoding="utf-8") as f:
|
|
294
|
+
record = json.load(f)
|
|
295
|
+
except (OSError, ValueError):
|
|
296
|
+
return None
|
|
297
|
+
return record if valid_provenance_record(record) else None
|
|
298
|
+
|
|
299
|
+
|
|
300
|
+
# ---------------------------------------------------------------------------
|
|
301
|
+
# Git hashing (injectable runner contract)
|
|
302
|
+
# ---------------------------------------------------------------------------
|
|
303
|
+
|
|
304
|
+
|
|
305
|
+
def _default_hash_runner(args, stdin_text):
|
|
306
|
+
"""Run `git <args>` feeding stdin_text on stdin; return stdout as str.
|
|
307
|
+
|
|
308
|
+
Runner contract: runner(args, stdin_text) -> str
|
|
309
|
+
|
|
310
|
+
Uses subprocess.run with capture_output=True, text=True, encoding="utf-8".
|
|
311
|
+
Called only by batch_hash; never called at import time (zero side effects).
|
|
312
|
+
"""
|
|
313
|
+
result = subprocess.run(
|
|
314
|
+
[git_executable()] + list(args),
|
|
315
|
+
input=stdin_text,
|
|
316
|
+
capture_output=True,
|
|
317
|
+
text=True,
|
|
318
|
+
encoding="utf-8",
|
|
319
|
+
timeout=GIT_TIMEOUT,
|
|
320
|
+
)
|
|
321
|
+
return result.stdout
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def batch_hash(paths, runner=None):
|
|
325
|
+
"""Hash all paths in a single git hash-object --stdin-paths call.
|
|
326
|
+
|
|
327
|
+
`paths` — list of repo-relative file paths.
|
|
328
|
+
`runner` — injectable; contract: runner(args, stdin_text) -> str.
|
|
329
|
+
Default is _default_hash_runner (uses subprocess, offline-safe
|
|
330
|
+
to inject a fake in tests).
|
|
331
|
+
|
|
332
|
+
Issues exactly ONE runner call for any non-empty paths list.
|
|
333
|
+
Returns {path: git_oid} preserving input order (insertion order = input
|
|
334
|
+
order via dict(zip(paths, oids))).
|
|
335
|
+
|
|
336
|
+
Degrade-not-fail: empty paths → {} with zero runner calls; a runner that
|
|
337
|
+
raises or returns fewer oids than paths → {} (or safely-zippable subset);
|
|
338
|
+
never raises.
|
|
339
|
+
"""
|
|
340
|
+
if runner is None:
|
|
341
|
+
runner = _default_hash_runner
|
|
342
|
+
if not paths:
|
|
343
|
+
return {}
|
|
344
|
+
stdin_text = "\n".join(paths) + "\n"
|
|
345
|
+
try:
|
|
346
|
+
stdout = runner(["hash-object", "--stdin-paths"], stdin_text)
|
|
347
|
+
oids = [line for line in stdout.splitlines() if line]
|
|
348
|
+
return dict(zip(paths, oids))
|
|
349
|
+
except Exception:
|
|
350
|
+
return {}
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
# ---------------------------------------------------------------------------
|
|
354
|
+
# Source classification (drift_trigger predicate)
|
|
355
|
+
# ---------------------------------------------------------------------------
|
|
356
|
+
|
|
357
|
+
|
|
358
|
+
def classify_source(path):
|
|
359
|
+
"""Return True iff path is a drift_trigger (config/manifest/schema/security-entry).
|
|
360
|
+
|
|
361
|
+
drift_trigger: True — low-churn, high-signal sources where derived-doc claims
|
|
362
|
+
live: package manifests, lockfiles, CI yaml, schema/
|
|
363
|
+
migration files, env templates, and auth/middleware/jwt
|
|
364
|
+
entry files.
|
|
365
|
+
drift_trigger: False — general implementation source (ts, py, go, tsx, md, …)
|
|
366
|
+
that still feeds the code-map/audit but never rings the
|
|
367
|
+
drift alarm.
|
|
368
|
+
|
|
369
|
+
Normalises path separators (backslash → forward slash) before matching, so a
|
|
370
|
+
Windows path classifies identically to its POSIX equivalent. Security-entry
|
|
371
|
+
keywords (auth, middleware, jwt) are matched as whole tokens anywhere in the
|
|
372
|
+
normalised path — the path is split on separators and camelCase transitions,
|
|
373
|
+
then lowercased, so "author.py" and "AuthorCard.tsx" do NOT fire but
|
|
374
|
+
"authMiddleware.ts" and "src/middleware/cors.py" do. Extension matching is
|
|
375
|
+
case-insensitive; fixed filenames (e.g. package.json, Cargo.toml) are exact.
|
|
376
|
+
|
|
377
|
+
Never raises — a None or garbage path returns False.
|
|
378
|
+
"""
|
|
379
|
+
if not path:
|
|
380
|
+
return False
|
|
381
|
+
try:
|
|
382
|
+
norm = str(path).replace("\\", "/")
|
|
383
|
+
except Exception:
|
|
384
|
+
return False
|
|
385
|
+
|
|
386
|
+
# Extract the basename (last path segment after normalization).
|
|
387
|
+
basename = norm.rsplit("/", 1)[-1]
|
|
388
|
+
|
|
389
|
+
# 1. Fixed filename exact match (case-sensitive, per spec).
|
|
390
|
+
if basename in _DRIFT_FIXED_NAMES:
|
|
391
|
+
return True
|
|
392
|
+
|
|
393
|
+
# 2. Path-pattern rules (requirements*.txt, CI yaml, *.prisma, *.sql,
|
|
394
|
+
# migrations/, .env.*).
|
|
395
|
+
if _DRIFT_PATH_RE.search(norm):
|
|
396
|
+
return True
|
|
397
|
+
|
|
398
|
+
# 3. Security-entry token match: any token in the full normalised path (split
|
|
399
|
+
# on separators + camelCase boundaries, then lowercased) must be an exact
|
|
400
|
+
# member of _SECURITY_ENTRY_TOKENS. Whole-token matching prevents false
|
|
401
|
+
# positives from substrings such as "author" (contains "auth") or
|
|
402
|
+
# "AuthorCard" (camelCase token "Author", not "auth"). Matching the full
|
|
403
|
+
# path (not just the basename) catches path-segment entries like
|
|
404
|
+
# src/middleware/cors.py where "middleware" is a directory name.
|
|
405
|
+
path_tokens = [t.lower() for t in _SECURITY_TOKEN_SPLIT_RE.split(norm) if t]
|
|
406
|
+
if _SECURITY_ENTRY_TOKENS.intersection(path_tokens):
|
|
407
|
+
return True
|
|
408
|
+
|
|
409
|
+
return False
|
|
410
|
+
|
|
411
|
+
|
|
412
|
+
# ---------------------------------------------------------------------------
|
|
413
|
+
# Drift detection
|
|
414
|
+
# ---------------------------------------------------------------------------
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
def compute_drift(provenance_map, current_hashes):
|
|
418
|
+
"""Detect changed-hash and missing entries across all docs in provenance_map.
|
|
419
|
+
|
|
420
|
+
provenance_map — {doc_name: provenance_record} as returned by
|
|
421
|
+
new_record/read_provenance. A None or malformed record
|
|
422
|
+
value is skipped without raising.
|
|
423
|
+
current_hashes — {path: git_oid} as returned by batch_hash.
|
|
424
|
+
|
|
425
|
+
Returns {doc_name: [{"path": str, "kind": str}, ...]} containing only docs
|
|
426
|
+
that have at least one diverged or missing entry. A doc with no drift is
|
|
427
|
+
OMITTED so that an empty result ({}) signals a fully clean state.
|
|
428
|
+
|
|
429
|
+
Drift kinds:
|
|
430
|
+
• "changed" — entry path IS present in current_hashes AND the stored
|
|
431
|
+
hash differs from current_hashes[path].
|
|
432
|
+
• "missing" — entry path is ABSENT from current_hashes entirely
|
|
433
|
+
(the source file was renamed or deleted).
|
|
434
|
+
|
|
435
|
+
Both kinds respect the drift_trigger filter (AC-09): only entries with
|
|
436
|
+
drift_trigger == True are ever considered. An absent or falsy
|
|
437
|
+
drift_trigger is treated as False — the conservative default — so
|
|
438
|
+
general architecture source captured for the code-map/audit never rings
|
|
439
|
+
the drift alarm regardless of whether the path is present or absent.
|
|
440
|
+
|
|
441
|
+
Never raises — malformed entries, None records, and unexpected structures
|
|
442
|
+
are all skipped gracefully.
|
|
443
|
+
"""
|
|
444
|
+
result = {}
|
|
445
|
+
try:
|
|
446
|
+
items = provenance_map.items()
|
|
447
|
+
except Exception:
|
|
448
|
+
return {}
|
|
449
|
+
|
|
450
|
+
for doc_name, record in items:
|
|
451
|
+
try:
|
|
452
|
+
if record is None:
|
|
453
|
+
continue
|
|
454
|
+
entries = record.get("entries")
|
|
455
|
+
if not entries:
|
|
456
|
+
continue
|
|
457
|
+
doc_drifts = []
|
|
458
|
+
for entry in entries:
|
|
459
|
+
try:
|
|
460
|
+
# AC-09: only explicit True fires drift; absent/falsy → skip.
|
|
461
|
+
if entry.get("drift_trigger") is not True:
|
|
462
|
+
continue
|
|
463
|
+
path = entry.get("path")
|
|
464
|
+
stored_hash = entry.get("hash")
|
|
465
|
+
if path is None:
|
|
466
|
+
continue
|
|
467
|
+
if path not in current_hashes:
|
|
468
|
+
# AC-05 (T-06): drift_trigger:true entry absent from
|
|
469
|
+
# current_hashes means the source was renamed/deleted.
|
|
470
|
+
doc_drifts.append({"path": path, "kind": "missing"})
|
|
471
|
+
continue
|
|
472
|
+
current_hash = current_hashes[path]
|
|
473
|
+
if current_hash != stored_hash:
|
|
474
|
+
doc_drifts.append({"path": path, "kind": "changed"})
|
|
475
|
+
except Exception:
|
|
476
|
+
continue
|
|
477
|
+
if doc_drifts:
|
|
478
|
+
result[doc_name] = doc_drifts
|
|
479
|
+
except Exception:
|
|
480
|
+
continue
|
|
481
|
+
|
|
482
|
+
return result
|
|
483
|
+
|
|
484
|
+
|
|
485
|
+
# ---------------------------------------------------------------------------
|
|
486
|
+
# Per-doc drift scope (commit-gate auto-heal / /ca:context-check locality)
|
|
487
|
+
# ---------------------------------------------------------------------------
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def changed_scope(doc_provenance, drift):
|
|
491
|
+
"""Return drifted paths for this doc only — never another doc's paths (AC-11).
|
|
492
|
+
|
|
493
|
+
doc_provenance — a single doc's provenance record (must have a 'doc' field).
|
|
494
|
+
drift — the full compute_drift output {doc_name: [{"path","kind"}, ...]}.
|
|
495
|
+
|
|
496
|
+
Returns the list of path strings drifted for doc_provenance["doc"] in the
|
|
497
|
+
order they appear under that doc in drift. Both "changed" and "missing" kinds
|
|
498
|
+
are included. If the doc has no drift entry, returns []. If doc_provenance is
|
|
499
|
+
malformed/None, or drift is malformed, returns [] without raising.
|
|
500
|
+
"""
|
|
501
|
+
try:
|
|
502
|
+
doc_name = doc_provenance.get("doc")
|
|
503
|
+
if not doc_name:
|
|
504
|
+
return []
|
|
505
|
+
doc_drifts = drift.get(doc_name)
|
|
506
|
+
if not doc_drifts:
|
|
507
|
+
return []
|
|
508
|
+
result = []
|
|
509
|
+
for entry in doc_drifts:
|
|
510
|
+
try:
|
|
511
|
+
result.append(entry["path"])
|
|
512
|
+
except Exception:
|
|
513
|
+
continue
|
|
514
|
+
return result
|
|
515
|
+
except Exception:
|
|
516
|
+
return []
|
|
517
|
+
|
|
518
|
+
|
|
519
|
+
# ---------------------------------------------------------------------------
|
|
520
|
+
# Re-baseline (commit-gate auto-heal — "claim still holds" branch, AC-12)
|
|
521
|
+
# ---------------------------------------------------------------------------
|
|
522
|
+
|
|
523
|
+
|
|
524
|
+
def rebaseline(provenance, current_hashes):
|
|
525
|
+
"""Return a new record with each entry's hash updated to current_hashes[path].
|
|
526
|
+
|
|
527
|
+
Functional style: returns a new dict (shallow copy of the record; each
|
|
528
|
+
entry dict is also a new dict) so the caller's original is never mutated.
|
|
529
|
+
Only the 'hash' field of matching entries changes — 'claims', 'drift_trigger',
|
|
530
|
+
'path', and all top-level record fields ('doc', 'schema', 'created',
|
|
531
|
+
'interview_derived') are left untouched.
|
|
532
|
+
|
|
533
|
+
For entries whose path is absent from current_hashes the stored hash is kept
|
|
534
|
+
unchanged — a genuinely-deleted file is a separate decision, not a silent
|
|
535
|
+
re-baseline.
|
|
536
|
+
|
|
537
|
+
Never raises — None or malformed input returns the input unchanged.
|
|
538
|
+
"""
|
|
539
|
+
try:
|
|
540
|
+
if provenance is None:
|
|
541
|
+
return provenance
|
|
542
|
+
# Shallow-copy the top-level record so we return a new object.
|
|
543
|
+
result = dict(provenance)
|
|
544
|
+
entries = provenance.get("entries")
|
|
545
|
+
if not isinstance(entries, list):
|
|
546
|
+
return result
|
|
547
|
+
# Build a safe hash lookup; degrade to empty dict on None/malformed.
|
|
548
|
+
try:
|
|
549
|
+
hashes = dict(current_hashes) if current_hashes is not None else {}
|
|
550
|
+
except Exception:
|
|
551
|
+
hashes = {}
|
|
552
|
+
new_entries = []
|
|
553
|
+
for entry in entries:
|
|
554
|
+
try:
|
|
555
|
+
new_entry = dict(entry)
|
|
556
|
+
path = new_entry.get("path")
|
|
557
|
+
if path is not None and path in hashes:
|
|
558
|
+
new_entry["hash"] = hashes[path]
|
|
559
|
+
new_entries.append(new_entry)
|
|
560
|
+
except Exception:
|
|
561
|
+
# Malformed entry: append as-is without crashing.
|
|
562
|
+
new_entries.append(entry)
|
|
563
|
+
result["entries"] = new_entries
|
|
564
|
+
return result
|
|
565
|
+
except Exception:
|
|
566
|
+
return provenance
|
|
567
|
+
|
|
568
|
+
|
|
569
|
+
# ---------------------------------------------------------------------------
|
|
570
|
+
# Commit-gate auto-heal selector (AC-13)
|
|
571
|
+
# ---------------------------------------------------------------------------
|
|
572
|
+
|
|
573
|
+
|
|
574
|
+
def heal_worklist(staged_paths, provenance, current_hashes):
|
|
575
|
+
"""Return staged paths that are drift_trigger:true entries with diverged/absent hashes.
|
|
576
|
+
|
|
577
|
+
staged_paths — list of repo-relative paths staged for this commit.
|
|
578
|
+
provenance — {doc_name: record} map (same shape as compute_drift uses).
|
|
579
|
+
current_hashes — {path: git_oid} for the staged paths.
|
|
580
|
+
|
|
581
|
+
Returns the subset of staged_paths that are BOTH:
|
|
582
|
+
(a) present as a drift_trigger:true entry in some doc's provenance, AND
|
|
583
|
+
(b) diverged — current_hashes.get(path) differs from the stored hash,
|
|
584
|
+
OR path is absent from current_hashes (staged deletion/rename).
|
|
585
|
+
|
|
586
|
+
A staged path that is not a provenance entry, or is drift_trigger:false, or
|
|
587
|
+
whose hash matches → excluded. Preserves staged_paths order; deduplicates.
|
|
588
|
+
Empty staged_paths, or no staged file tracked → [] (cost guarantee: ordinary
|
|
589
|
+
commits touching no provenance source do zero re-scout work, AC-13).
|
|
590
|
+
|
|
591
|
+
Never raises — malformed map/None → [].
|
|
592
|
+
"""
|
|
593
|
+
try:
|
|
594
|
+
if not staged_paths:
|
|
595
|
+
return []
|
|
596
|
+
|
|
597
|
+
# Build {path: stored_hash} for every drift_trigger:true entry across all docs.
|
|
598
|
+
# First occurrence wins for a path that appears in multiple docs.
|
|
599
|
+
drift_trigger_map = {}
|
|
600
|
+
try:
|
|
601
|
+
items = provenance.items()
|
|
602
|
+
except Exception:
|
|
603
|
+
return []
|
|
604
|
+
|
|
605
|
+
for _doc_name, record in items:
|
|
606
|
+
try:
|
|
607
|
+
if record is None:
|
|
608
|
+
continue
|
|
609
|
+
entries = record.get("entries")
|
|
610
|
+
if not entries:
|
|
611
|
+
continue
|
|
612
|
+
for entry in entries:
|
|
613
|
+
try:
|
|
614
|
+
if entry.get("drift_trigger") is not True:
|
|
615
|
+
continue
|
|
616
|
+
path = entry.get("path")
|
|
617
|
+
if path is None:
|
|
618
|
+
continue
|
|
619
|
+
if path not in drift_trigger_map:
|
|
620
|
+
drift_trigger_map[path] = entry.get("hash")
|
|
621
|
+
except Exception:
|
|
622
|
+
continue
|
|
623
|
+
except Exception:
|
|
624
|
+
continue
|
|
625
|
+
|
|
626
|
+
# Build a safe hash lookup; degrade to {} on None/malformed.
|
|
627
|
+
try:
|
|
628
|
+
hashes = dict(current_hashes) if current_hashes is not None else {}
|
|
629
|
+
except Exception:
|
|
630
|
+
hashes = {}
|
|
631
|
+
|
|
632
|
+
# Filter staged_paths: include only drift_trigger:true paths that diverged.
|
|
633
|
+
# Preserve order; deduplicate via a seen-set.
|
|
634
|
+
seen = set()
|
|
635
|
+
result = []
|
|
636
|
+
for path in staged_paths:
|
|
637
|
+
try:
|
|
638
|
+
if path in seen:
|
|
639
|
+
continue
|
|
640
|
+
seen.add(path)
|
|
641
|
+
if path not in drift_trigger_map:
|
|
642
|
+
continue
|
|
643
|
+
stored_hash = drift_trigger_map[path]
|
|
644
|
+
# Include when absent from current_hashes (staged deletion/rename)
|
|
645
|
+
# OR when the hash has diverged.
|
|
646
|
+
if path not in hashes or hashes[path] != stored_hash:
|
|
647
|
+
result.append(path)
|
|
648
|
+
except Exception:
|
|
649
|
+
continue
|
|
650
|
+
|
|
651
|
+
return result
|
|
652
|
+
except Exception:
|
|
653
|
+
return []
|
|
654
|
+
|
|
655
|
+
|
|
656
|
+
# ---------------------------------------------------------------------------
|
|
657
|
+
# Provenance directory loader + SessionStart drift line
|
|
658
|
+
# ---------------------------------------------------------------------------
|
|
659
|
+
|
|
660
|
+
|
|
661
|
+
MAX_SKIPPED_REPORTED = 5 # bounded corruption diagnostic (#410)
|
|
662
|
+
|
|
663
|
+
|
|
664
|
+
def load_provenance_dir(provenance_dir, skipped=None):
|
|
665
|
+
"""Load all provenance records from provenance_dir into {doc: record}.
|
|
666
|
+
|
|
667
|
+
Globs <provenance_dir>/*.json, calls read_provenance on each file, and
|
|
668
|
+
skips any that return None (missing, unreadable, or corrupt JSON — which
|
|
669
|
+
since #410 includes valid JSON of the wrong shape). Each surviving record
|
|
670
|
+
is keyed by its 'doc' field; if 'doc' is absent or empty, falls back to the
|
|
671
|
+
filename stem so the dict never loses an entry. A missing or non-directory
|
|
672
|
+
provenance_dir returns {}.
|
|
673
|
+
|
|
674
|
+
#410: the per-file work sits inside its OWN error boundary. The whole glob
|
|
675
|
+
loop used to share one try/except, so the first record that raised aborted
|
|
676
|
+
the scan and every LATER valid record vanished with no diagnostic —
|
|
677
|
+
SessionStart drift reporting and commit-gate auto-heal then ran against a
|
|
678
|
+
silently truncated map. One bad file now costs exactly itself.
|
|
679
|
+
|
|
680
|
+
`skipped`, when a list is passed, collects the BASENAMES of rejected files
|
|
681
|
+
(at most MAX_SKIPPED_REPORTED) so a caller that has somewhere to put a
|
|
682
|
+
warning can name the corrupt record. Names only — never file contents, which
|
|
683
|
+
may hold paths or claims that do not belong in a log line.
|
|
684
|
+
|
|
685
|
+
NO PRODUCTION CALLER PASSES `skipped` TODAY, and that is deliberate rather
|
|
686
|
+
than an oversight. The two real call sites cannot carry the diagnostic:
|
|
687
|
+
startup_drift_line is bound by AC-08 ("degrade to silence" — an all-corrupt
|
|
688
|
+
provenance dir MUST return '', pinned by test_degrade_corrupt_json), and
|
|
689
|
+
build_index runs on every PreToolUse:Read, where a per-call warning would be
|
|
690
|
+
noise on the hottest path in the hook layer. Surfacing corruption to a user
|
|
691
|
+
therefore belongs to /ca:context-check or /ca:doctor, which are surface
|
|
692
|
+
prose and out of this module's reach. The channel exists, is bounded, and is
|
|
693
|
+
tested; anything reading it is a follow-up.
|
|
694
|
+
|
|
695
|
+
Never raises — filesystem errors and malformed records are skipped (this
|
|
696
|
+
runs on the SessionStart linchpin path).
|
|
697
|
+
"""
|
|
698
|
+
result = {}
|
|
699
|
+
|
|
700
|
+
def note(fpath):
|
|
701
|
+
if skipped is None or len(skipped) >= MAX_SKIPPED_REPORTED:
|
|
702
|
+
return
|
|
703
|
+
try:
|
|
704
|
+
skipped.append(os.path.basename(fpath))
|
|
705
|
+
except Exception: # noqa: BLE001 — a diagnostic must never break the load
|
|
706
|
+
pass
|
|
707
|
+
|
|
708
|
+
try:
|
|
709
|
+
if not os.path.isdir(provenance_dir):
|
|
710
|
+
return {}
|
|
711
|
+
paths = glob.glob(os.path.join(provenance_dir, "*.json"))
|
|
712
|
+
except Exception: # noqa: BLE001 — an unreadable dir is an empty map
|
|
713
|
+
return {}
|
|
714
|
+
|
|
715
|
+
for fpath in paths:
|
|
716
|
+
try:
|
|
717
|
+
record = read_provenance(fpath)
|
|
718
|
+
if record is None:
|
|
719
|
+
note(fpath)
|
|
720
|
+
continue
|
|
721
|
+
doc = record.get("doc") or os.path.splitext(os.path.basename(fpath))[0]
|
|
722
|
+
result[str(doc)] = record
|
|
723
|
+
except Exception: # noqa: BLE001 — per-file boundary: skip THIS file only
|
|
724
|
+
note(fpath)
|
|
725
|
+
return result
|
|
726
|
+
|
|
727
|
+
|
|
728
|
+
def _make_root_runner(root):
|
|
729
|
+
"""Return a batch_hash-compatible runner bound to root via git -C.
|
|
730
|
+
|
|
731
|
+
The default runner used by startup_drift_line resolves repo-relative
|
|
732
|
+
paths from <root> rather than the process cwd. T-16 injects its own
|
|
733
|
+
runner; tests inject a fake to avoid real git calls.
|
|
734
|
+
"""
|
|
735
|
+
def runner(args, stdin_text):
|
|
736
|
+
result = subprocess.run(
|
|
737
|
+
[git_executable(), "-C", root] + list(args),
|
|
738
|
+
input=stdin_text,
|
|
739
|
+
capture_output=True,
|
|
740
|
+
text=True,
|
|
741
|
+
encoding="utf-8",
|
|
742
|
+
timeout=GIT_TIMEOUT,
|
|
743
|
+
)
|
|
744
|
+
return result.stdout
|
|
745
|
+
return runner
|
|
746
|
+
|
|
747
|
+
|
|
748
|
+
def _is_confined_provenance_path(root, path):
|
|
749
|
+
"""Return whether path is a safe, non-root descendant of root.
|
|
750
|
+
|
|
751
|
+
Provenance JSON is repository data, so treat its paths as untrusted before
|
|
752
|
+
feeding them to git's newline-delimited ``--stdin-paths`` protocol. Check
|
|
753
|
+
both POSIX and Windows absolute/drive syntax so a record remains safe when
|
|
754
|
+
a checkout moves between hosts, then resolve on the current host to catch
|
|
755
|
+
traversal and symlink escapes.
|
|
756
|
+
"""
|
|
757
|
+
try:
|
|
758
|
+
if not isinstance(path, str) or not path:
|
|
759
|
+
return False
|
|
760
|
+
if any(char in path for char in ("\x00", "\n", "\r")):
|
|
761
|
+
return False
|
|
762
|
+
if posixpath.isabs(path) or ntpath.isabs(path) or ntpath.splitdrive(path)[0]:
|
|
763
|
+
return False
|
|
764
|
+
|
|
765
|
+
portable_path = posixpath.normpath(path.replace("\\", "/"))
|
|
766
|
+
if portable_path in (".", "..") or portable_path.startswith("../"):
|
|
767
|
+
return False
|
|
768
|
+
|
|
769
|
+
confined_root = os.path.realpath(os.path.abspath(root))
|
|
770
|
+
candidate = os.path.realpath(os.path.join(confined_root, path))
|
|
771
|
+
return (
|
|
772
|
+
candidate != confined_root
|
|
773
|
+
and os.path.commonpath((confined_root, candidate)) == confined_root
|
|
774
|
+
)
|
|
775
|
+
except (OSError, TypeError, ValueError):
|
|
776
|
+
return False
|
|
777
|
+
|
|
778
|
+
|
|
779
|
+
def startup_drift_line(root, runner=None, cmd_ref=None):
|
|
780
|
+
"""Return a one-line drift summary for SessionStart, or '' when clean (AC-06).
|
|
781
|
+
|
|
782
|
+
Pipeline:
|
|
783
|
+
1. Load all provenance records from <root>/.codearbiter/.provenance/.
|
|
784
|
+
2. If the map is empty, return '' immediately (nothing to check).
|
|
785
|
+
3. Reject unsafe drift-trigger paths, then collect the safe set across all
|
|
786
|
+
docs. Rejected entries are also excluded from drift comparison so they
|
|
787
|
+
cannot become false missing-file alarms.
|
|
788
|
+
4. Split into existing paths (os.path.exists(<root>/<path>)) and missing
|
|
789
|
+
ones. Hash only the existing ones via batch_hash — git hash-object
|
|
790
|
+
errors on non-existent paths and would corrupt the batch. Absent paths
|
|
791
|
+
stay absent from current_hashes so compute_drift reports them as
|
|
792
|
+
kind='missing' (a deleted drift_trigger source IS drift).
|
|
793
|
+
4a. AC-08 degrade-to-silence on hash failure: if batch_hash returned
|
|
794
|
+
fewer hashes than existing_paths (runner raised, git is unavailable,
|
|
795
|
+
or git aborted mid-stream producing a partial stdout), the tooling
|
|
796
|
+
has failed — not the source files. Return '' rather than feeding
|
|
797
|
+
a short hash map to compute_drift, which would falsely flag the
|
|
798
|
+
un-hashed existing files as 'missing'. A path that genuinely does
|
|
799
|
+
not exist on disk is still reported as missing (it is not in
|
|
800
|
+
existing_paths so the count comparison is unaffected).
|
|
801
|
+
5. Call compute_drift(pm, current_hashes).
|
|
802
|
+
6. Empty result → return '' (AC-06: silent when docs are fresh).
|
|
803
|
+
Non-empty → return exactly one ASCII line (AC-07):
|
|
804
|
+
"context drift: N stale source(s) across M doc(s) -- run /ca:context-check"
|
|
805
|
+
where N = sum of all drifted entry paths and M = number of affected docs.
|
|
806
|
+
|
|
807
|
+
runner: injectable; contract: runner(args, stdin_text) -> str.
|
|
808
|
+
The default runner uses 'git -C root hash-object --stdin-paths' so
|
|
809
|
+
provenance paths are resolved from root. T-16 injects its own root-bound
|
|
810
|
+
runner; tests inject a fake.
|
|
811
|
+
|
|
812
|
+
Never raises — all errors degrade to '' (safe for the SessionStart path).
|
|
813
|
+
"""
|
|
814
|
+
try:
|
|
815
|
+
if runner is None:
|
|
816
|
+
runner = _make_root_runner(root)
|
|
817
|
+
provenance_dir = os.path.join(root, ".codearbiter", ".provenance")
|
|
818
|
+
pm = load_provenance_dir(provenance_dir)
|
|
819
|
+
if not pm:
|
|
820
|
+
return ""
|
|
821
|
+
|
|
822
|
+
# Collect safe drift_trigger:true paths across all docs (deduped,
|
|
823
|
+
# order-stable). Build a filtered in-memory map at the same time so a
|
|
824
|
+
# rejected path cannot reappear as a false missing-file drift result.
|
|
825
|
+
drift_trigger_paths = []
|
|
826
|
+
safe_pm = {}
|
|
827
|
+
seen = set()
|
|
828
|
+
for doc, record in pm.items():
|
|
829
|
+
try:
|
|
830
|
+
entries = record.get("entries") or []
|
|
831
|
+
safe_entries = []
|
|
832
|
+
for entry in entries:
|
|
833
|
+
try:
|
|
834
|
+
if entry.get("drift_trigger") is True:
|
|
835
|
+
path = entry.get("path")
|
|
836
|
+
if not _is_confined_provenance_path(root, path):
|
|
837
|
+
continue
|
|
838
|
+
if path not in seen:
|
|
839
|
+
drift_trigger_paths.append(path)
|
|
840
|
+
seen.add(path)
|
|
841
|
+
safe_entries.append(entry)
|
|
842
|
+
except Exception:
|
|
843
|
+
continue
|
|
844
|
+
safe_record = dict(record)
|
|
845
|
+
safe_record["entries"] = safe_entries
|
|
846
|
+
safe_pm[doc] = safe_record
|
|
847
|
+
except Exception:
|
|
848
|
+
continue
|
|
849
|
+
pm = safe_pm
|
|
850
|
+
|
|
851
|
+
if not pm:
|
|
852
|
+
return ""
|
|
853
|
+
|
|
854
|
+
# Existence-aware hashing: only hash files that exist on disk.
|
|
855
|
+
# Non-existent paths are intentionally absent from current_hashes so
|
|
856
|
+
# compute_drift reports them as kind='missing' (deleted source = drift).
|
|
857
|
+
existing_paths = [
|
|
858
|
+
p for p in drift_trigger_paths
|
|
859
|
+
if os.path.exists(os.path.join(root, p))
|
|
860
|
+
]
|
|
861
|
+
current_hashes = batch_hash(existing_paths, runner) if existing_paths else {}
|
|
862
|
+
|
|
863
|
+
# AC-08: degrade-to-silence if the hash step did not return a hash for
|
|
864
|
+
# every existing drift_trigger path (git unavailable, runner raised, or a
|
|
865
|
+
# partial/aborted stdout). Feeding a short hash map to compute_drift would
|
|
866
|
+
# falsely report the un-hashed files as 'missing'. Conservative: stay silent.
|
|
867
|
+
if len(current_hashes) < len(existing_paths):
|
|
868
|
+
return ""
|
|
869
|
+
|
|
870
|
+
drift = compute_drift(pm, current_hashes)
|
|
871
|
+
if not drift:
|
|
872
|
+
return ""
|
|
873
|
+
# AC-07: one ASCII line: stale-source count, affected-doc count, pointer.
|
|
874
|
+
stale_count = sum(len(v) for v in drift.values())
|
|
875
|
+
doc_count = len(drift)
|
|
876
|
+
ref = cmd_ref("context-check") if cmd_ref else "/ca:context-check"
|
|
877
|
+
return (
|
|
878
|
+
"context drift: {} stale source(s) across {} doc(s)"
|
|
879
|
+
" -- run {}".format(stale_count, doc_count, ref)
|
|
880
|
+
)
|
|
881
|
+
except Exception:
|
|
882
|
+
return ""
|
|
883
|
+
|
|
884
|
+
|
|
885
|
+
# ---------------------------------------------------------------------------
|
|
886
|
+
# Code-map linter (AC-15)
|
|
887
|
+
# ---------------------------------------------------------------------------
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def lint_code_map(text):
|
|
891
|
+
"""Lint a code-map.md string for entry-cap and multi-line-role violations.
|
|
892
|
+
|
|
893
|
+
Code-map format: markdown where entries are column-0 '- `path` -- role'
|
|
894
|
+
bullets. Concern '## <name>' headings are structural labels, NOT entries.
|
|
895
|
+
|
|
896
|
+
Checks applied:
|
|
897
|
+
1. Entry count > CODE_MAP_MAX_ENTRIES -> one warning naming count and cap.
|
|
898
|
+
The cap enforces module/concern granularity: the code map must stay a
|
|
899
|
+
coarse index, never a full file listing.
|
|
900
|
+
2. For each entry whose immediately-following physical line starts with
|
|
901
|
+
whitespace (an indented continuation — the role spilled onto a second
|
|
902
|
+
line) -> one warning per offending entry, naming the entry number and
|
|
903
|
+
path. Column-0 '- ' bullets and '## ' headings do NOT start with
|
|
904
|
+
whitespace so are excluded automatically.
|
|
905
|
+
|
|
906
|
+
Returns a list of human-readable ASCII warning strings; [] means clean.
|
|
907
|
+
Empty or None text -> [] (a missing/empty code map has no violations).
|
|
908
|
+
Never raises.
|
|
909
|
+
"""
|
|
910
|
+
if not text:
|
|
911
|
+
return []
|
|
912
|
+
try:
|
|
913
|
+
lines = text.splitlines()
|
|
914
|
+
entry_paths = [] # ordered list of extracted paths (one per entry)
|
|
915
|
+
multi_warnings = [] # per-entry multi-line-role warnings (built inline)
|
|
916
|
+
|
|
917
|
+
for i, line in enumerate(lines):
|
|
918
|
+
m = _ENTRY_RE.match(line)
|
|
919
|
+
if not m:
|
|
920
|
+
continue
|
|
921
|
+
entry_path = m.group(1)
|
|
922
|
+
entry_num = len(entry_paths) + 1 # 1-indexed
|
|
923
|
+
entry_paths.append(entry_path)
|
|
924
|
+
|
|
925
|
+
# Multi-line role: the next physical line starts with whitespace AND
|
|
926
|
+
# contains non-whitespace content (a genuine continuation, not blank
|
|
927
|
+
# vertical spacing). Column-0 bullets ('- ') and headings ('## ')
|
|
928
|
+
# do not start with whitespace and are excluded by this check.
|
|
929
|
+
if i + 1 < len(lines):
|
|
930
|
+
next_line = lines[i + 1]
|
|
931
|
+
if next_line.strip() and next_line[0:1] in (" ", "\t"):
|
|
932
|
+
multi_warnings.append(
|
|
933
|
+
"multi-line role at entry {} (path {}) -- roles must be one line".format(
|
|
934
|
+
entry_num, entry_path
|
|
935
|
+
)
|
|
936
|
+
)
|
|
937
|
+
|
|
938
|
+
result = []
|
|
939
|
+
count = len(entry_paths)
|
|
940
|
+
if count > CODE_MAP_MAX_ENTRIES:
|
|
941
|
+
result.append(
|
|
942
|
+
"code map has {} entries (cap {}) -- coarsen to module/concern granularity".format(
|
|
943
|
+
count, CODE_MAP_MAX_ENTRIES
|
|
944
|
+
)
|
|
945
|
+
)
|
|
946
|
+
result.extend(multi_warnings)
|
|
947
|
+
return result
|
|
948
|
+
except Exception:
|
|
949
|
+
return []
|
|
950
|
+
|
|
951
|
+
|
|
952
|
+
# ---------------------------------------------------------------------------
|
|
953
|
+
# Greenfield stub writer (AC-18)
|
|
954
|
+
# ---------------------------------------------------------------------------
|
|
955
|
+
|
|
956
|
+
|
|
957
|
+
def write_stub(path, doc, *, interview_derived=True, created=None):
|
|
958
|
+
"""Write a greenfield provenance stub to `path` for `doc`.
|
|
959
|
+
|
|
960
|
+
The stub record is new_record(doc, interview_derived=interview_derived,
|
|
961
|
+
entries=[], created=created), written via write_provenance (which creates
|
|
962
|
+
parent directories, pretty-prints JSON with LF endings, and does not raise
|
|
963
|
+
beyond documented I/O errors). interview_derived defaults to True — stubs
|
|
964
|
+
are for greenfield docs where no source files exist yet. created passes
|
|
965
|
+
through to new_record; None means today (datetime.date.today()).
|
|
966
|
+
|
|
967
|
+
Reuses new_record + write_provenance — contains no duplicated JSON logic.
|
|
968
|
+
Never raises beyond what write_provenance already does.
|
|
969
|
+
"""
|
|
970
|
+
record = new_record(doc, interview_derived=interview_derived, entries=[], created=created)
|
|
971
|
+
write_provenance(path, record)
|