graphite-code 0.3.0__py3-none-any.whl
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.
- graphite/__init__.py +41 -0
- graphite/__main__.py +7 -0
- graphite/_cleanup_worker.py +525 -0
- graphite/activation.py +164 -0
- graphite/agent_hooks.py +577 -0
- graphite/agent_settings.py +226 -0
- graphite/analyze.py +146 -0
- graphite/answer_contract.py +420 -0
- graphite/bootstrap.py +210 -0
- graphite/buildlock.py +99 -0
- graphite/cache.py +131 -0
- graphite/channel.py +1325 -0
- graphite/cli.py +3053 -0
- graphite/cluster.py +111 -0
- graphite/config.py +209 -0
- graphite/context.py +355 -0
- graphite/daemon.py +745 -0
- graphite/daemon_health.py +733 -0
- graphite/debt.py +118 -0
- graphite/dependency_install.py +1597 -0
- graphite/detach.py +33 -0
- graphite/doctor.py +678 -0
- graphite/doctor_probes.py +2100 -0
- graphite/engine_identity.py +238 -0
- graphite/export/__init__.py +6 -0
- graphite/export/html.py +244 -0
- graphite/export/json.py +39 -0
- graphite/export/md.py +68 -0
- graphite/extract/__init__.py +4 -0
- graphite/extract/ast.py +1964 -0
- graphite/freshness.py +127 -0
- graphite/git.py +406 -0
- graphite/graph.py +117 -0
- graphite/graph_io.py +188 -0
- graphite/health.py +147 -0
- graphite/hook_entry.py +68 -0
- graphite/hookinstall.py +224 -0
- graphite/hookshim.py +86 -0
- graphite/incident_ledger.py +247 -0
- graphite/ingest.py +279 -0
- graphite/init.py +791 -0
- graphite/io.py +32 -0
- graphite/listing.py +51 -0
- graphite/llm.py +518 -0
- graphite/llm_probe.py +157 -0
- graphite/mcp.py +7 -0
- graphite/mcp_server.py +450 -0
- graphite/natural_query.py +252 -0
- graphite/overlays.py +713 -0
- graphite/probe_process.py +879 -0
- graphite/probe_workspace.py +728 -0
- graphite/process_contracts.py +22 -0
- graphite/provider_observer.py +397 -0
- graphite/query.py +646 -0
- graphite/query_plan.py +97 -0
- graphite/replacement_audit.py +291 -0
- graphite/resolve.py +660 -0
- graphite/review.py +782 -0
- graphite/routing/__init__.py +5 -0
- graphite/routing/approval.py +362 -0
- graphite/routing/classifier.py +169 -0
- graphite/routing/claude_executor.py +419 -0
- graphite/routing/claude_probe.py +102 -0
- graphite/routing/cli_identity.py +84 -0
- graphite/routing/codex_executor.py +383 -0
- graphite/routing/codex_probe.py +93 -0
- graphite/routing/context_builder.py +327 -0
- graphite/routing/contracts.py +802 -0
- graphite/routing/diff_policy.py +468 -0
- graphite/routing/edit_apply.py +166 -0
- graphite/routing/effort.py +43 -0
- graphite/routing/lifecycle.py +771 -0
- graphite/routing/lifecycle_operator.py +227 -0
- graphite/routing/lifecycle_service.py +555 -0
- graphite/routing/lifecycle_storage.py +977 -0
- graphite/routing/ollama_executor.py +341 -0
- graphite/routing/ollama_probe.py +72 -0
- graphite/routing/openrouter_executor.py +338 -0
- graphite/routing/openrouter_probe.py +188 -0
- graphite/routing/policy.py +815 -0
- graphite/routing/probe_runner.py +543 -0
- graphite/routing/process_runner.py +523 -0
- graphite/routing/profiles.py +554 -0
- graphite/routing/prompt.py +58 -0
- graphite/routing/registry.py +444 -0
- graphite/routing/route_pool.py +629 -0
- graphite/routing/route_pool_execution.py +275 -0
- graphite/routing/schema_validation.py +169 -0
- graphite/routing/service.py +1263 -0
- graphite/routing/settings.py +99 -0
- graphite/routing/shadow.py +201 -0
- graphite/routing/storage.py +4001 -0
- graphite/routing/telemetry.py +346 -0
- graphite/routing/worktree.py +259 -0
- graphite/routing/zai_edit.py +113 -0
- graphite/routing/zai_executor.py +191 -0
- graphite/routing/zai_probe.py +126 -0
- graphite/savings.py +84 -0
- graphite/ts_bridge.py +142 -0
- graphite/ts_resolver.mjs +314 -0
- graphite/typescript_activation.py +1586 -0
- graphite/usage_ledger.py +156 -0
- graphite/validation.py +148 -0
- graphite/watch.py +167 -0
- graphite/windows_job.py +368 -0
- graphite/windows_startup.py +144 -0
- graphite/windows_task.py +212 -0
- graphite_code-0.3.0.dist-info/METADATA +743 -0
- graphite_code-0.3.0.dist-info/RECORD +112 -0
- graphite_code-0.3.0.dist-info/WHEEL +4 -0
- graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
- graphite_code-0.3.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,420 @@
|
|
|
1
|
+
"""Answer-scoped confidence contract (spec 2026-07-26).
|
|
2
|
+
|
|
3
|
+
Every canonical graph answer carries an `answer` block: the relations the
|
|
4
|
+
verb walked, the languages in scope, per-relation per-language health
|
|
5
|
+
cells, a derived grade, applicable caveat codes, and — when the primary
|
|
6
|
+
result is empty — what the emptiness means.
|
|
7
|
+
|
|
8
|
+
Fail-open: build_answer_block returns None on any internal failure and
|
|
9
|
+
callers omit the key; the block may be dropped, never wrong.
|
|
10
|
+
"""
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from typing import Any, Iterable, Sequence
|
|
14
|
+
|
|
15
|
+
import networkx as nx
|
|
16
|
+
|
|
17
|
+
from .health import RESOLUTION_HEALTHY_RATIO, _edge_language, resolution_health
|
|
18
|
+
|
|
19
|
+
ANSWER_SCHEMA = 1
|
|
20
|
+
|
|
21
|
+
GRADE_DECISION = "decision_grade"
|
|
22
|
+
GRADE_ADVISORY = "advisory"
|
|
23
|
+
GRADE_INCONCLUSIVE = "inconclusive"
|
|
24
|
+
|
|
25
|
+
# Confirmed blindspot classes. Process rule (spec §5): a confirmed class
|
|
26
|
+
# gets an entry the day it is confirmed, decoupled from its fix; fixed
|
|
27
|
+
# classes get retired_by and are never emitted again.
|
|
28
|
+
CAVEAT_REGISTRY: tuple[dict[str, Any], ...] = (
|
|
29
|
+
{
|
|
30
|
+
"code": "python-dynamic-dispatch",
|
|
31
|
+
"relations": ("calls",),
|
|
32
|
+
"languages": ("python",),
|
|
33
|
+
"summary": "dynamically dispatched calls (getattr, decorator rebinding) are not modeled",
|
|
34
|
+
"since": "2026-07-26",
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
"code": "python-callback-registration",
|
|
38
|
+
"relations": ("calls",),
|
|
39
|
+
"languages": ("python",),
|
|
40
|
+
"summary": (
|
|
41
|
+
"a function passed as a VALUE to a registrar (threading.Timer, atexit.register, "
|
|
42
|
+
"Thread(target=), signal.signal) produces no call edge, so an empty callers "
|
|
43
|
+
"result may be wrong -- confirm with grep before treating it as dead"
|
|
44
|
+
),
|
|
45
|
+
"since": "2026-08-06",
|
|
46
|
+
# The first CONDITIONAL caveat. Every other entry is a scope
|
|
47
|
+
# disclosure -- true of the relation x language, regardless of what
|
|
48
|
+
# this particular answer found -- which means it cannot discriminate a
|
|
49
|
+
# sound answer from an unsound one. aramid measured
|
|
50
|
+
# `python-dynamic-dispatch` byte-identical across six `callers`
|
|
51
|
+
# queries, five with non-zero counts (round 55). An always-on caveat
|
|
52
|
+
# trains readers to ignore caveats, so a hedge that is meant to be
|
|
53
|
+
# ACTED on has to be absent when it does not apply.
|
|
54
|
+
"only_when_empty": True,
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
"code": "ts-external-calls-unclassified",
|
|
58
|
+
"relations": ("calls",),
|
|
59
|
+
"languages": ("typescript", "javascript"),
|
|
60
|
+
"summary": "calls to external-package symbols, runtime globals, and destructured locals count as unbound",
|
|
61
|
+
"since": "2026-07-26",
|
|
62
|
+
"retired_by": "2026-07-27",
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
"code": "ts-destructured-locals-unbound",
|
|
66
|
+
"relations": ("calls",),
|
|
67
|
+
"languages": ("typescript", "javascript"),
|
|
68
|
+
"summary": "calls through destructured local bindings (const { f } = require(...)) count as unbound",
|
|
69
|
+
"since": "2026-07-27",
|
|
70
|
+
# Fixed by #49: a literal `require()` now binds its destructured names
|
|
71
|
+
# to the exporting file's definitions, measured in both JavaScript and
|
|
72
|
+
# TypeScript. The summary keeps its published wording -- a published
|
|
73
|
+
# code's meaning never changes.
|
|
74
|
+
"retired_by": "2026-08-10",
|
|
75
|
+
},
|
|
76
|
+
{
|
|
77
|
+
"code": "js-require-emits-no-import-edge",
|
|
78
|
+
"relations": ("imports",),
|
|
79
|
+
"languages": ("javascript", "typescript"),
|
|
80
|
+
"summary": (
|
|
81
|
+
"a CommonJS require() is a call expression, not an import statement, so it "
|
|
82
|
+
"emits NO import edge at all -- an empty imported-by or depends-on result "
|
|
83
|
+
"may be wrong, and the imports ratio cannot see the omission"
|
|
84
|
+
),
|
|
85
|
+
"since": "2026-08-10",
|
|
86
|
+
# Declared the day it was measured, decoupled from the fix. This is the
|
|
87
|
+
# first entry whose relation is `imports`, and it is why `imports`
|
|
88
|
+
# joined NON_DETECTION_RELATIONS: a two-file fixture requiring one
|
|
89
|
+
# module TWICE produced exactly one import edge (the unrelated ESM
|
|
90
|
+
# one), a 1.0 imports ratio, and an empty `imported-by` at
|
|
91
|
+
# decision_grade. A missing SITE cannot lower a ratio computed over
|
|
92
|
+
# sites, so the metric graded its own blind spot healthy.
|
|
93
|
+
#
|
|
94
|
+
# Fixed by #49 for a LITERAL require, which is what this code names.
|
|
95
|
+
# The non-detection class it created is narrowed, not gone -- see
|
|
96
|
+
# `js-dynamic-module-load-unmodelled`, which is why `imports` stays in
|
|
97
|
+
# NON_DETECTION_RELATIONS for these languages.
|
|
98
|
+
"retired_by": "2026-08-10",
|
|
99
|
+
},
|
|
100
|
+
{
|
|
101
|
+
"code": "js-module-object-calls-unbound",
|
|
102
|
+
"relations": ("calls",),
|
|
103
|
+
"languages": ("javascript", "typescript"),
|
|
104
|
+
"summary": (
|
|
105
|
+
"calls through a module object (const m = require('./x'); m.f(), or "
|
|
106
|
+
"import * as ns from './x'; ns.f()) are not bound to the target"
|
|
107
|
+
),
|
|
108
|
+
"since": "2026-08-10",
|
|
109
|
+
# Distinct from `ts-destructured-locals-unbound`, which covers only
|
|
110
|
+
# `const { f } = require(...)`. A published code's meaning never
|
|
111
|
+
# changes, so the member-access shape gets its own entry rather than a
|
|
112
|
+
# widened summary on that one. Python already binds this shape via
|
|
113
|
+
# `alias_map` (extract/ast.py); JavaScript has no equivalent.
|
|
114
|
+
#
|
|
115
|
+
# Fixed by #49, which gave JavaScript that equivalent: `_ImportBindings
|
|
116
|
+
# .namespaces` maps a whole-module local to its file, and `_resolve_call`
|
|
117
|
+
# turns `m.f()` into that file's `f`. Measured on all four shapes in
|
|
118
|
+
# both JavaScript and TypeScript; the fixture's placeholder share fell
|
|
119
|
+
# 0.143 -> 0.077 as the `m.f` phantoms stopped being invented.
|
|
120
|
+
"retired_by": "2026-08-10",
|
|
121
|
+
},
|
|
122
|
+
{
|
|
123
|
+
"code": "js-shadowed-module-local-unbound",
|
|
124
|
+
"relations": ("calls",),
|
|
125
|
+
"languages": ("javascript", "typescript"),
|
|
126
|
+
"summary": (
|
|
127
|
+
"when a name bound by require() is also bound elsewhere in the same file "
|
|
128
|
+
"(an inner declaration, a parameter), calls through it are left unbound "
|
|
129
|
+
"rather than risk claiming the module's definition"
|
|
130
|
+
),
|
|
131
|
+
"since": "2026-08-10",
|
|
132
|
+
# The residue of `js-module-object-calls-unbound`, and the reason that
|
|
133
|
+
# retirement is honest rather than tidy. #49's binding maps are
|
|
134
|
+
# FILE-level while calls are walked per scope, so a rebound name cannot
|
|
135
|
+
# be told from the module binding at resolution time. The guard
|
|
136
|
+
# (`_rebound_local_names`) distrusts any name bound twice, which fails
|
|
137
|
+
# CLOSED -- it gives up an edge instead of inventing one, because a
|
|
138
|
+
# wrong caller in `callers f` is worse than a missing one.
|
|
139
|
+
#
|
|
140
|
+
# Measured: a file with `const m = require('./mod')` and an inner
|
|
141
|
+
# `const m = {...}` loses binding for every `m.x()` in it, which is
|
|
142
|
+
# exactly the pre-#49 behaviour for that file and no worse. Retires
|
|
143
|
+
# when scope-aware resolution replaces the blunt count.
|
|
144
|
+
},
|
|
145
|
+
{
|
|
146
|
+
"code": "js-dynamic-module-load-unmodelled",
|
|
147
|
+
"relations": ("calls", "imports"),
|
|
148
|
+
"languages": ("javascript", "typescript"),
|
|
149
|
+
"summary": (
|
|
150
|
+
"a dynamic module load -- require(expr) with a non-literal argument, or an "
|
|
151
|
+
"import() expression -- emits no import edge and binds no callable name, so "
|
|
152
|
+
"an empty imported-by or callers result may be wrong"
|
|
153
|
+
),
|
|
154
|
+
"since": "2026-08-10",
|
|
155
|
+
# The narrowed successor to `js-require-emits-no-import-edge`. #49 fixed
|
|
156
|
+
# the LITERAL form; this is the residue, and it is why `imports` stays
|
|
157
|
+
# in NON_DETECTION_RELATIONS for these languages. Measured the same day
|
|
158
|
+
# on a file containing `require(name)` and `await import('./mod')`:
|
|
159
|
+
# neither produced an import edge. Note the second still has a string
|
|
160
|
+
# literal -- what defeats it is `import()` being an expression rather
|
|
161
|
+
# than an import statement, so "literal argument" is not the test.
|
|
162
|
+
#
|
|
163
|
+
# Retiring the predecessor without this entry would have removed the
|
|
164
|
+
# honest grade from a class of absence that is still not proof: the
|
|
165
|
+
# concern was mostly gone, not gone.
|
|
166
|
+
},
|
|
167
|
+
{
|
|
168
|
+
"code": "calls-unattributable-receiver-false-external",
|
|
169
|
+
"relations": ("calls",),
|
|
170
|
+
"languages": ("typescript", "javascript", "python"),
|
|
171
|
+
"summary": "a call whose receiver is not a simple identifier is classified by its bare method name and may be wrongly excluded from the ratio as external",
|
|
172
|
+
"since": "2026-07-27",
|
|
173
|
+
# Fixed by #14: an unattributable receiver is no longer classified at
|
|
174
|
+
# all, so it can no longer be excused from the ratio. The entry keeps
|
|
175
|
+
# its original summary -- a published code's meaning never changes.
|
|
176
|
+
"retired_by": "2026-07-27",
|
|
177
|
+
},
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
|
|
181
|
+
#: Relations where a real edge can be MISSING ENTIRELY rather than merely
|
|
182
|
+
#: unresolved, so an empty answer cannot be a trustworthy absence.
|
|
183
|
+
#:
|
|
184
|
+
#: `resolution_health` is a RESOLUTION metric, not a COVERAGE one: it measures
|
|
185
|
+
#: how many detected sites bound to a target. An invocation that produces no
|
|
186
|
+
#: site at all -- a function passed as a value to `threading.Timer`,
|
|
187
|
+
#: `atexit.register`, `Thread(target=)` -- never enters `total`, so it cannot
|
|
188
|
+
#: lower the ratio. A perfect 1.0 is therefore compatible with an entire class
|
|
189
|
+
#: of invocation being unmodelled, and grading an empty answer `decision_grade`
|
|
190
|
+
#: on the strength of that ratio reads the denominator as if it excluded
|
|
191
|
+
#: nothing. Measured: a file with two ordinary calls and two callback
|
|
192
|
+
#: registrations scored `total 2, bound 2, ratio 1.0` while `callers` on both
|
|
193
|
+
#: registered functions returned 0 at decision_grade (aramid, round 55).
|
|
194
|
+
#:
|
|
195
|
+
#: `imports` USED to be excluded here, on the reasoning that "an import is a
|
|
196
|
+
#: syntactic construct that extraction either sees or does not". CommonJS
|
|
197
|
+
#: falsifies that: `require('./mod')` is a call expression, so the import
|
|
198
|
+
#: extractor never sees it and no candidate edge is emitted. Measured on a
|
|
199
|
+
#: two-file fixture where `consumer.js` requires `./mod` TWICE -- the graph held
|
|
200
|
+
#: exactly one import edge (the unrelated ESM one), the imports cell read
|
|
201
|
+
#: `total 1, bound 1, ratio 1.0`, and `imported-by src/mod.js` answered nothing
|
|
202
|
+
#: at decision_grade. The rule that governed this entry was "add a relation only
|
|
203
|
+
#: with a measured non-detection case, not on suspicion", and that is now met.
|
|
204
|
+
#:
|
|
205
|
+
#: Scoped BY LANGUAGE rather than added outright. `None` means every language;
|
|
206
|
+
#: a frozenset restricts it. Rust `use` and Go imports have no dynamic form
|
|
207
|
+
#: graphite models, so their absences are still evidence and must not be
|
|
208
|
+
#: downgraded to buy a fix for JavaScript.
|
|
209
|
+
NON_DETECTION_RELATIONS: dict[str, frozenset[str] | None] = {
|
|
210
|
+
"calls": None,
|
|
211
|
+
"imports": frozenset({"javascript", "typescript"}),
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
#: Why an absence in this relation is not proof. Reaches the human listing, so
|
|
215
|
+
#: it names the construct a reader can go and grep for.
|
|
216
|
+
NON_DETECTION_REASONS = {
|
|
217
|
+
"calls": "a callback-registered caller emits no edge",
|
|
218
|
+
"imports": "a dynamic `require(expr)` or `import()` emits no import edge",
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
|
|
222
|
+
def non_detecting_relations(
|
|
223
|
+
relations: Iterable[str], languages: Iterable[str]
|
|
224
|
+
) -> list[str]:
|
|
225
|
+
"""Relations whose absence cannot be trusted for these languages."""
|
|
226
|
+
language_set = set(languages)
|
|
227
|
+
return sorted(
|
|
228
|
+
relation
|
|
229
|
+
for relation in set(relations)
|
|
230
|
+
if relation in NON_DETECTION_RELATIONS
|
|
231
|
+
and (
|
|
232
|
+
NON_DETECTION_RELATIONS[relation] is None
|
|
233
|
+
or language_set & NON_DETECTION_RELATIONS[relation]
|
|
234
|
+
)
|
|
235
|
+
)
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def active_caveats() -> list[dict[str, Any]]:
|
|
239
|
+
"""Registry entries that are live (no retired_by), full published shape."""
|
|
240
|
+
return [dict(e) for e in CAVEAT_REGISTRY if not e.get("retired_by")]
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
INCONCLUSIVE_EMPTY = "none found — INCONCLUSIVE: treat as unverified and confirm with grep"
|
|
244
|
+
|
|
245
|
+
#: Empty listing under a HEALTHY answer whose absence still cannot be trusted:
|
|
246
|
+
#: the cells are measured and fine, but the relation has a known non-detection
|
|
247
|
+
#: class (see NON_DETECTION_RELATIONS), so "none" may simply be unmodelled.
|
|
248
|
+
#:
|
|
249
|
+
#: Distinct wording from INCONCLUSIVE_EMPTY on purpose -- the two say different
|
|
250
|
+
#: things and collapsing them would misreport a good measurement as a failed
|
|
251
|
+
#: one. Distinct from a bare "none found" for the reason round 55 exists: a
|
|
252
|
+
#: grade the human line does not echo is a hedge the reader never sees.
|
|
253
|
+
def unverified_empty(reason: str) -> str:
|
|
254
|
+
"""The UNVERIFIED listing line for one non-detection reason."""
|
|
255
|
+
return (
|
|
256
|
+
f"none found — UNVERIFIED: {reason}, "
|
|
257
|
+
"so this absence is not proof; confirm with grep"
|
|
258
|
+
)
|
|
259
|
+
|
|
260
|
+
|
|
261
|
+
#: The `calls` wording, kept as a module constant because it is the published
|
|
262
|
+
#: shape round 55 introduced and is asserted verbatim.
|
|
263
|
+
UNVERIFIED_EMPTY = unverified_empty(NON_DETECTION_REASONS["calls"])
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def is_degraded(block: dict[str, Any] | None) -> bool:
|
|
267
|
+
"""True when any scoped health cell in an answer block is below threshold."""
|
|
268
|
+
if not block:
|
|
269
|
+
return False
|
|
270
|
+
return any(
|
|
271
|
+
not cell.get("healthy", True)
|
|
272
|
+
for langs in block.get("health", {}).values()
|
|
273
|
+
for cell in langs.values()
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def is_unmeasured(block: dict[str, Any] | None) -> bool:
|
|
278
|
+
"""True when an answer block exists but carries no scoped health cells.
|
|
279
|
+
|
|
280
|
+
Distinct from a fail-open `None`: the block was built, but nothing was
|
|
281
|
+
measured for the relations x languages the answer used. An empty listing
|
|
282
|
+
under it is unverified, not a trustworthy absence (#12). A `None` block is
|
|
283
|
+
the fail-open path and stays permissive.
|
|
284
|
+
"""
|
|
285
|
+
if not block:
|
|
286
|
+
return False
|
|
287
|
+
return not any(langs for langs in (block.get("health") or {}).values())
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def empty_marker(block: dict[str, Any] | None) -> str:
|
|
291
|
+
"""Empty-listing text for an answer surface, scoped to the answer's grade.
|
|
292
|
+
|
|
293
|
+
A degraded-and-empty listing is `inconclusive` by this contract's own
|
|
294
|
+
definition, even when the answer as a whole graded `advisory` because its
|
|
295
|
+
other half was non-empty. See spec §5. An unmeasured block gets the same
|
|
296
|
+
treatment: zero cells is zero evidence, so a bare "none found" would claim
|
|
297
|
+
an absence nothing verified.
|
|
298
|
+
"""
|
|
299
|
+
if is_degraded(block) or is_unmeasured(block):
|
|
300
|
+
return INCONCLUSIVE_EMPTY
|
|
301
|
+
# Healthy cells, but the grade says the absence is not evidence. Echo that
|
|
302
|
+
# in the human line: a machine-readable grade the printed listing
|
|
303
|
+
# contradicts is exactly the hedge a reader misses (round 55).
|
|
304
|
+
if block and block.get("grade") == GRADE_ADVISORY:
|
|
305
|
+
# Name the construct that went undetected, not just "unverified" -- the
|
|
306
|
+
# reader's next action is a grep, and which one depends on whether the
|
|
307
|
+
# missing edge is a callback registration or a `require()`.
|
|
308
|
+
triggered = non_detecting_relations(
|
|
309
|
+
block.get("relations", ()), block.get("languages", ())
|
|
310
|
+
)
|
|
311
|
+
if triggered:
|
|
312
|
+
return unverified_empty(
|
|
313
|
+
" and ".join(NON_DETECTION_REASONS[r] for r in triggered)
|
|
314
|
+
)
|
|
315
|
+
return UNVERIFIED_EMPTY
|
|
316
|
+
return "none found"
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def languages_for_nodes(g: nx.DiGraph, node_ids: Iterable[str]) -> list[str]:
|
|
320
|
+
"""Sorted unique languages of the nodes' source files ('other' dropped)."""
|
|
321
|
+
langs: set[str] = set()
|
|
322
|
+
for node_id in node_ids:
|
|
323
|
+
if node_id is None or node_id not in g:
|
|
324
|
+
continue
|
|
325
|
+
language = _edge_language(g.nodes[node_id].get("source_file"))
|
|
326
|
+
if language != "other":
|
|
327
|
+
langs.add(language)
|
|
328
|
+
return sorted(langs)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
def build_answer_block(
|
|
332
|
+
g: nx.DiGraph,
|
|
333
|
+
*,
|
|
334
|
+
relations: Sequence[str],
|
|
335
|
+
languages: Sequence[str] | None,
|
|
336
|
+
total: int,
|
|
337
|
+
empty_meaning: str | None = None,
|
|
338
|
+
) -> dict[str, Any] | None:
|
|
339
|
+
"""The `answer` block for one graph answer, or None (fail-open).
|
|
340
|
+
|
|
341
|
+
``languages=None`` means "no filter, grade against every language in the
|
|
342
|
+
graph" -- distinct from ``languages=()``/``[]``, which means the caller
|
|
343
|
+
computed the matched nodes' languages and found none apply (e.g. the
|
|
344
|
+
matched nodes are markdown/config files, not code). The two must not
|
|
345
|
+
collapse to the same branch: no real caller passes ``None`` today (every
|
|
346
|
+
call site derives its filter from the matched nodes via
|
|
347
|
+
``languages_for_nodes``), so treating an empty list as "no filter" meant
|
|
348
|
+
a query about a non-code file silently graded against the WHOLE graph's
|
|
349
|
+
unrelated-language health instead of having nothing to grade at all
|
|
350
|
+
(found via operation-firewall dogfooding, 2026-07-31: a `README.md`
|
|
351
|
+
query inherited Rust's and Python's degraded health and came back
|
|
352
|
+
inconclusive, polluting the incident ledger over a file that structurally
|
|
353
|
+
has no calls or imports). When no language applies, there is nothing to
|
|
354
|
+
grade -- return None (fail-open), the same as the no-relations case just
|
|
355
|
+
above, rather than a spuriously degraded or inconclusive block.
|
|
356
|
+
"""
|
|
357
|
+
try:
|
|
358
|
+
if not relations:
|
|
359
|
+
return None
|
|
360
|
+
if languages is not None and not languages:
|
|
361
|
+
return None
|
|
362
|
+
health = resolution_health(g)
|
|
363
|
+
by_language = health.get("by_language") or {}
|
|
364
|
+
threshold = health.get("threshold", RESOLUTION_HEALTHY_RATIO)
|
|
365
|
+
langs = sorted(languages) if languages is not None else sorted(by_language)
|
|
366
|
+
cells: dict[str, dict[str, dict[str, Any]]] = {}
|
|
367
|
+
degraded = False
|
|
368
|
+
for relation in relations:
|
|
369
|
+
for language in langs:
|
|
370
|
+
cell = (by_language.get(language) or {}).get(relation)
|
|
371
|
+
if not cell or cell.get("ratio") is None:
|
|
372
|
+
continue
|
|
373
|
+
healthy = cell["ratio"] >= threshold
|
|
374
|
+
degraded = degraded or not healthy
|
|
375
|
+
cells.setdefault(relation, {})[language] = {**cell, "healthy": healthy}
|
|
376
|
+
empty = total == 0
|
|
377
|
+
relation_set = set(relations)
|
|
378
|
+
language_set = set(langs)
|
|
379
|
+
# No cells at all means nothing was measured for the relations x
|
|
380
|
+
# languages this answer actually used. That is not evidence of health;
|
|
381
|
+
# it is the absence of evidence, so it cannot grade decision_grade (#12).
|
|
382
|
+
unmeasured = not cells
|
|
383
|
+
# An empty answer over a relation with a known non-detection class is
|
|
384
|
+
# NOT a trustworthy absence, however healthy the ratio -- the ratio is
|
|
385
|
+
# blind to the edges that were never emitted. See
|
|
386
|
+
# NON_DETECTION_RELATIONS. Advisory rather than inconclusive on
|
|
387
|
+
# purpose: the health genuinely IS good, so calling it inconclusive
|
|
388
|
+
# would misreport a measurement. What is unsupported is the absence,
|
|
389
|
+
# and `advisory` already means "use it, and verify".
|
|
390
|
+
undetectable_absence = empty and bool(
|
|
391
|
+
non_detecting_relations(relation_set, language_set)
|
|
392
|
+
)
|
|
393
|
+
if degraded or unmeasured:
|
|
394
|
+
grade = GRADE_INCONCLUSIVE if empty else GRADE_ADVISORY
|
|
395
|
+
elif undetectable_absence:
|
|
396
|
+
grade = GRADE_ADVISORY
|
|
397
|
+
else:
|
|
398
|
+
grade = GRADE_DECISION
|
|
399
|
+
caveats = [
|
|
400
|
+
{"code": e["code"], "summary": e["summary"]}
|
|
401
|
+
for e in active_caveats()
|
|
402
|
+
if relation_set.intersection(e["relations"])
|
|
403
|
+
and language_set.intersection(e["languages"])
|
|
404
|
+
# A conditional caveat is emitted only where it applies, so its
|
|
405
|
+
# presence carries signal a reader can act on.
|
|
406
|
+
and (empty or not e.get("only_when_empty"))
|
|
407
|
+
]
|
|
408
|
+
block: dict[str, Any] = {
|
|
409
|
+
"schema": ANSWER_SCHEMA,
|
|
410
|
+
"relations": sorted(relation_set),
|
|
411
|
+
"languages": langs,
|
|
412
|
+
"health": cells,
|
|
413
|
+
"grade": grade,
|
|
414
|
+
"caveats": caveats,
|
|
415
|
+
}
|
|
416
|
+
if empty and empty_meaning:
|
|
417
|
+
block["empty_meaning"] = empty_meaning
|
|
418
|
+
return block
|
|
419
|
+
except Exception:
|
|
420
|
+
return None
|
graphite/bootstrap.py
ADDED
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
"""Project bootstrap helpers for Graphite-aware development workflows."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import os
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from .io import atomic_write_text
|
|
11
|
+
|
|
12
|
+
GRAPHITE_GITIGNORE_MARKER = "# Graphite"
|
|
13
|
+
# `**/.cache/graphite/` instead of `.cache/graphite/`: a gitignore pattern with
|
|
14
|
+
# an internal slash is anchored to the gitignore's directory, so the anchored
|
|
15
|
+
# form misses caches created by manual builds inside monorepo workspaces.
|
|
16
|
+
GRAPHITE_GITIGNORE_LINES: tuple[str, ...] = (
|
|
17
|
+
"# Graphite",
|
|
18
|
+
"graph-out/",
|
|
19
|
+
"**/.cache/graphite/",
|
|
20
|
+
"**/.graphite/",
|
|
21
|
+
".graphite-daemon/",
|
|
22
|
+
# Hook trampolines are MACHINE-LOCAL, not repository content: the shim
|
|
23
|
+
# `hookshim.render_trigger_shim` writes embeds an absolute interpreter path
|
|
24
|
+
# (`INTERP="/c/Python314/python.exe"` on the machine that ran `init`).
|
|
25
|
+
# Committing one bakes another machine's Python location into the repo, and
|
|
26
|
+
# every `init` on a different box produces a spurious diff.
|
|
27
|
+
#
|
|
28
|
+
# They are distributed per machine instead -- `graphite hooks template`
|
|
29
|
+
# (a16b00f) installs a git template so fresh clones get hooks without the
|
|
30
|
+
# repo carrying them. Evidence this was always the intent: no repo on this
|
|
31
|
+
# machine has ever tracked a file under `.githooks/`.
|
|
32
|
+
#
|
|
33
|
+
# Ignored rather than left merely untracked so the intent is stated. On
|
|
34
|
+
# 2026-07-31 `doctor` briefly reported these as "generated but never
|
|
35
|
+
# committed" and `init` allowlisted them out of a default-deny gitignore --
|
|
36
|
+
# advice that would have committed a machine-specific path.
|
|
37
|
+
".githooks/",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
GRAPHITE_AGENTS_HEADER = "## Automatic Graphite Consult"
|
|
41
|
+
GRAPHITE_AGENTS_SECTION = """## Automatic Graphite Consult
|
|
42
|
+
|
|
43
|
+
For any non-trivial code change, run Graphite before broad file reads or edits:
|
|
44
|
+
|
|
45
|
+
```bash
|
|
46
|
+
python -m graphite check .
|
|
47
|
+
python -m graphite context <likely-changed-file>
|
|
48
|
+
python -m graphite impact <likely-changed-file>
|
|
49
|
+
python -m graphite query "stats"
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Use `python -m graphite context` first when the likely target file is known. Treat its output as a dependency map, not as proof of correctness: still read the relevant source files and tests before editing. If `python -m graphite check .` reports stale output, rebuild before relying on context or impact data.
|
|
53
|
+
|
|
54
|
+
If the graph is missing or stale, rebuild it from the repository root with:
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
python -m graphite -v build .
|
|
58
|
+
```
|
|
59
|
+
|
|
60
|
+
During active multi-file development, keep the graph current with:
|
|
61
|
+
|
|
62
|
+
```bash
|
|
63
|
+
python -m graphite watch . --impact
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
Graphite is a centrally installed Python package (importable from any repository). Canonical graph commands run locally, never read provider credentials, and never use LLM or network inference; model output belongs only in an explicit non-authoritative overlay. `python -m graphite` works in every shell; a bare `graphite` command is equivalent where the console script is on PATH.
|
|
67
|
+
|
|
68
|
+
For TypeScript, Graphite uses the local TypeScript compiler resolver automatically when available. If a project has a broken TypeScript setup, fall back with `python -m graphite --typescript-resolver disabled build .`.
|
|
69
|
+
"""
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
@dataclass(frozen=True)
|
|
73
|
+
class BootstrapResult:
|
|
74
|
+
project_root: Path
|
|
75
|
+
gitignore: dict[str, Any]
|
|
76
|
+
agents: dict[str, Any]
|
|
77
|
+
daemon: dict[str, Any]
|
|
78
|
+
|
|
79
|
+
def to_dict(self) -> dict[str, Any]:
|
|
80
|
+
return {
|
|
81
|
+
"project_root": str(self.project_root),
|
|
82
|
+
"gitignore": self.gitignore,
|
|
83
|
+
"agents": self.agents,
|
|
84
|
+
"daemon": self.daemon,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def bootstrap_project(project_root: Path, *, daemon_base: Path | None = None) -> BootstrapResult:
|
|
89
|
+
"""Make a project Graphite-ready without disturbing unrelated user content."""
|
|
90
|
+
root = project_root.resolve()
|
|
91
|
+
if not root.exists():
|
|
92
|
+
raise FileNotFoundError(root)
|
|
93
|
+
if not root.is_dir():
|
|
94
|
+
raise NotADirectoryError(root)
|
|
95
|
+
|
|
96
|
+
gitignore = ensure_gitignore(root / ".gitignore")
|
|
97
|
+
agents = ensure_agents(root / "AGENTS.md", root.name)
|
|
98
|
+
daemon = daemon_visibility(root, daemon_base=daemon_base)
|
|
99
|
+
return BootstrapResult(project_root=root, gitignore=gitignore, agents=agents, daemon=daemon)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def ensure_gitignore(path: Path) -> dict[str, Any]:
|
|
103
|
+
original = path.read_text(encoding="utf-8") if path.exists() else ""
|
|
104
|
+
lines = original.splitlines()
|
|
105
|
+
existing = {line.strip() for line in lines}
|
|
106
|
+
has_graphite_header = any(line.strip().lower().startswith("# graphite") for line in lines)
|
|
107
|
+
missing: list[str] = []
|
|
108
|
+
for line in GRAPHITE_GITIGNORE_LINES:
|
|
109
|
+
stripped = line.strip()
|
|
110
|
+
if stripped == GRAPHITE_GITIGNORE_MARKER and has_graphite_header:
|
|
111
|
+
continue
|
|
112
|
+
if stripped not in existing:
|
|
113
|
+
missing.append(line)
|
|
114
|
+
changed = bool(missing)
|
|
115
|
+
if changed:
|
|
116
|
+
new_text = original
|
|
117
|
+
if new_text and not new_text.endswith("\n"):
|
|
118
|
+
new_text += "\n"
|
|
119
|
+
if new_text and not new_text.endswith("\n\n"):
|
|
120
|
+
new_text += "\n"
|
|
121
|
+
new_text += "\n".join(missing) + "\n"
|
|
122
|
+
atomic_write_text(path, new_text)
|
|
123
|
+
return {
|
|
124
|
+
"path": str(path),
|
|
125
|
+
"changed": changed,
|
|
126
|
+
"added": missing,
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def ensure_agents(path: Path, project_name: str) -> dict[str, Any]:
|
|
131
|
+
if path.exists():
|
|
132
|
+
original = path.read_text(encoding="utf-8")
|
|
133
|
+
else:
|
|
134
|
+
original = f"# {project_name} Agent Notes\n\n"
|
|
135
|
+
changed = GRAPHITE_AGENTS_HEADER not in original
|
|
136
|
+
if changed:
|
|
137
|
+
new_text = original
|
|
138
|
+
if new_text and not new_text.endswith("\n"):
|
|
139
|
+
new_text += "\n"
|
|
140
|
+
if new_text and not new_text.endswith("\n\n"):
|
|
141
|
+
new_text += "\n"
|
|
142
|
+
new_text += GRAPHITE_AGENTS_SECTION
|
|
143
|
+
if not new_text.endswith("\n"):
|
|
144
|
+
new_text += "\n"
|
|
145
|
+
atomic_write_text(path, new_text)
|
|
146
|
+
return {
|
|
147
|
+
"path": str(path),
|
|
148
|
+
"changed": changed,
|
|
149
|
+
"section": GRAPHITE_AGENTS_HEADER,
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
#: The directory the daemon writes its state into. Named once because
|
|
154
|
+
#: `_default_daemon_base` now FINDS the base by looking for it, so a drifted
|
|
155
|
+
#: literal there would silently stop discovering the very thing it points at.
|
|
156
|
+
DAEMON_STATE_DIRNAME = ".graphite-daemon"
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def daemon_visibility(project_root: Path, *, daemon_base: Path | None = None) -> dict[str, Any]:
|
|
160
|
+
base = (daemon_base or _default_daemon_base(project_root)).resolve()
|
|
161
|
+
status_path = base / DAEMON_STATE_DIRNAME / "status.json"
|
|
162
|
+
payload: dict[str, Any] = {
|
|
163
|
+
"base": str(base),
|
|
164
|
+
"status_path": str(status_path),
|
|
165
|
+
"status_found": status_path.exists(),
|
|
166
|
+
"project_listed": False,
|
|
167
|
+
}
|
|
168
|
+
if not status_path.exists():
|
|
169
|
+
return payload
|
|
170
|
+
try:
|
|
171
|
+
data = json.loads(status_path.read_text(encoding="utf-8"))
|
|
172
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
173
|
+
payload["error"] = str(exc)
|
|
174
|
+
return payload
|
|
175
|
+
project = str(project_root.resolve()).lower()
|
|
176
|
+
projects = data.get("projects", [])
|
|
177
|
+
for item in projects:
|
|
178
|
+
if str(item.get("root", "")).lower() == project:
|
|
179
|
+
payload["project_listed"] = True
|
|
180
|
+
payload["project_status"] = {
|
|
181
|
+
"build_count": item.get("build_count"),
|
|
182
|
+
"failure_count": item.get("failure_count"),
|
|
183
|
+
"last_error": item.get("last_error"),
|
|
184
|
+
"file_count": item.get("file_count"),
|
|
185
|
+
}
|
|
186
|
+
break
|
|
187
|
+
payload["daemon_status"] = data.get("status")
|
|
188
|
+
payload["project_count"] = data.get("project_count")
|
|
189
|
+
return payload
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def _default_daemon_base(project_root: Path) -> Path:
|
|
193
|
+
"""Find the directory the daemon writes its state into.
|
|
194
|
+
|
|
195
|
+
Discovered by the marker rather than by name. From the first commit onward
|
|
196
|
+
this compared each parent against one hardcoded absolute path -- the
|
|
197
|
+
maintainer's own layout compiled into shipped code -- so discovery worked on
|
|
198
|
+
exactly one machine, and the wheel carried an absolute developer path, which
|
|
199
|
+
RELEASING.md forbids in a release archive. The value is only ever used to
|
|
200
|
+
reach `base/DAEMON_STATE_DIRNAME/status.json`, so look for that. Do not name
|
|
201
|
+
a concrete path here: this docstring ships inside the wheel.
|
|
202
|
+
"""
|
|
203
|
+
env = os.environ.get("GRAPHITE_PROJECTS_ROOT")
|
|
204
|
+
if env:
|
|
205
|
+
return Path(env)
|
|
206
|
+
resolved = project_root.resolve()
|
|
207
|
+
for parent in (resolved, *resolved.parents):
|
|
208
|
+
if (parent / DAEMON_STATE_DIRNAME).is_dir():
|
|
209
|
+
return parent
|
|
210
|
+
return resolved
|