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.
Files changed (112) hide show
  1. graphite/__init__.py +41 -0
  2. graphite/__main__.py +7 -0
  3. graphite/_cleanup_worker.py +525 -0
  4. graphite/activation.py +164 -0
  5. graphite/agent_hooks.py +577 -0
  6. graphite/agent_settings.py +226 -0
  7. graphite/analyze.py +146 -0
  8. graphite/answer_contract.py +420 -0
  9. graphite/bootstrap.py +210 -0
  10. graphite/buildlock.py +99 -0
  11. graphite/cache.py +131 -0
  12. graphite/channel.py +1325 -0
  13. graphite/cli.py +3053 -0
  14. graphite/cluster.py +111 -0
  15. graphite/config.py +209 -0
  16. graphite/context.py +355 -0
  17. graphite/daemon.py +745 -0
  18. graphite/daemon_health.py +733 -0
  19. graphite/debt.py +118 -0
  20. graphite/dependency_install.py +1597 -0
  21. graphite/detach.py +33 -0
  22. graphite/doctor.py +678 -0
  23. graphite/doctor_probes.py +2100 -0
  24. graphite/engine_identity.py +238 -0
  25. graphite/export/__init__.py +6 -0
  26. graphite/export/html.py +244 -0
  27. graphite/export/json.py +39 -0
  28. graphite/export/md.py +68 -0
  29. graphite/extract/__init__.py +4 -0
  30. graphite/extract/ast.py +1964 -0
  31. graphite/freshness.py +127 -0
  32. graphite/git.py +406 -0
  33. graphite/graph.py +117 -0
  34. graphite/graph_io.py +188 -0
  35. graphite/health.py +147 -0
  36. graphite/hook_entry.py +68 -0
  37. graphite/hookinstall.py +224 -0
  38. graphite/hookshim.py +86 -0
  39. graphite/incident_ledger.py +247 -0
  40. graphite/ingest.py +279 -0
  41. graphite/init.py +791 -0
  42. graphite/io.py +32 -0
  43. graphite/listing.py +51 -0
  44. graphite/llm.py +518 -0
  45. graphite/llm_probe.py +157 -0
  46. graphite/mcp.py +7 -0
  47. graphite/mcp_server.py +450 -0
  48. graphite/natural_query.py +252 -0
  49. graphite/overlays.py +713 -0
  50. graphite/probe_process.py +879 -0
  51. graphite/probe_workspace.py +728 -0
  52. graphite/process_contracts.py +22 -0
  53. graphite/provider_observer.py +397 -0
  54. graphite/query.py +646 -0
  55. graphite/query_plan.py +97 -0
  56. graphite/replacement_audit.py +291 -0
  57. graphite/resolve.py +660 -0
  58. graphite/review.py +782 -0
  59. graphite/routing/__init__.py +5 -0
  60. graphite/routing/approval.py +362 -0
  61. graphite/routing/classifier.py +169 -0
  62. graphite/routing/claude_executor.py +419 -0
  63. graphite/routing/claude_probe.py +102 -0
  64. graphite/routing/cli_identity.py +84 -0
  65. graphite/routing/codex_executor.py +383 -0
  66. graphite/routing/codex_probe.py +93 -0
  67. graphite/routing/context_builder.py +327 -0
  68. graphite/routing/contracts.py +802 -0
  69. graphite/routing/diff_policy.py +468 -0
  70. graphite/routing/edit_apply.py +166 -0
  71. graphite/routing/effort.py +43 -0
  72. graphite/routing/lifecycle.py +771 -0
  73. graphite/routing/lifecycle_operator.py +227 -0
  74. graphite/routing/lifecycle_service.py +555 -0
  75. graphite/routing/lifecycle_storage.py +977 -0
  76. graphite/routing/ollama_executor.py +341 -0
  77. graphite/routing/ollama_probe.py +72 -0
  78. graphite/routing/openrouter_executor.py +338 -0
  79. graphite/routing/openrouter_probe.py +188 -0
  80. graphite/routing/policy.py +815 -0
  81. graphite/routing/probe_runner.py +543 -0
  82. graphite/routing/process_runner.py +523 -0
  83. graphite/routing/profiles.py +554 -0
  84. graphite/routing/prompt.py +58 -0
  85. graphite/routing/registry.py +444 -0
  86. graphite/routing/route_pool.py +629 -0
  87. graphite/routing/route_pool_execution.py +275 -0
  88. graphite/routing/schema_validation.py +169 -0
  89. graphite/routing/service.py +1263 -0
  90. graphite/routing/settings.py +99 -0
  91. graphite/routing/shadow.py +201 -0
  92. graphite/routing/storage.py +4001 -0
  93. graphite/routing/telemetry.py +346 -0
  94. graphite/routing/worktree.py +259 -0
  95. graphite/routing/zai_edit.py +113 -0
  96. graphite/routing/zai_executor.py +191 -0
  97. graphite/routing/zai_probe.py +126 -0
  98. graphite/savings.py +84 -0
  99. graphite/ts_bridge.py +142 -0
  100. graphite/ts_resolver.mjs +314 -0
  101. graphite/typescript_activation.py +1586 -0
  102. graphite/usage_ledger.py +156 -0
  103. graphite/validation.py +148 -0
  104. graphite/watch.py +167 -0
  105. graphite/windows_job.py +368 -0
  106. graphite/windows_startup.py +144 -0
  107. graphite/windows_task.py +212 -0
  108. graphite_code-0.3.0.dist-info/METADATA +743 -0
  109. graphite_code-0.3.0.dist-info/RECORD +112 -0
  110. graphite_code-0.3.0.dist-info/WHEEL +4 -0
  111. graphite_code-0.3.0.dist-info/entry_points.txt +3 -0
  112. 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