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
graphite/query_plan.py
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""Canonical query plans: schema, assembly, and strict validation.
|
|
2
|
+
|
|
3
|
+
A plan is the deterministic, inference-free intermediate representation of a
|
|
4
|
+
structured `graphite query` invocation: which operation runs, which target
|
|
5
|
+
inputs it resolves, and which limits bound the traversal. Plans are built
|
|
6
|
+
internally from the query string (never by a model) and validated with the
|
|
7
|
+
zero-dependency fail-closed subset validator from routing/schema_validation.py,
|
|
8
|
+
so plan structure is enforced by the same machinery that guards governed
|
|
9
|
+
structured output. This module must not import from graphite.query — the verb
|
|
10
|
+
registry supplies operation names and target roles as plain data.
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from .routing.schema_validation import matches_schema
|
|
17
|
+
|
|
18
|
+
PLAN_VERSION = 1
|
|
19
|
+
|
|
20
|
+
# Generous traversal bounds: large enough that real code graphs do not hit them
|
|
21
|
+
# in ordinary use, small enough that pathological graphs stay bounded. Reported
|
|
22
|
+
# via `graphite capabilities` and echoed in result `limits`/`truncated` fields.
|
|
23
|
+
DEFAULT_MAX_DEPTH = 32
|
|
24
|
+
DEFAULT_MAX_RESULTS = 200
|
|
25
|
+
|
|
26
|
+
_TARGET_SCHEMA: dict[str, Any] = {
|
|
27
|
+
"type": "object",
|
|
28
|
+
"additionalProperties": False,
|
|
29
|
+
"required": ["role", "input"],
|
|
30
|
+
"properties": {
|
|
31
|
+
"role": {"type": "string", "enum": ["node", "source", "target"]},
|
|
32
|
+
"input": {"type": "string", "minLength": 1},
|
|
33
|
+
},
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
# Plan schema v1. Kept inside the subset the in-house validator supports
|
|
37
|
+
# (is_supported_schema-compatible: no combinators); pinned by test.
|
|
38
|
+
PLAN_SCHEMA: dict[str, Any] = {
|
|
39
|
+
"type": "object",
|
|
40
|
+
"additionalProperties": False,
|
|
41
|
+
"required": ["plan_version", "operation", "targets", "options", "source"],
|
|
42
|
+
"properties": {
|
|
43
|
+
"plan_version": {"const": PLAN_VERSION},
|
|
44
|
+
"operation": {"type": "string", "minLength": 1},
|
|
45
|
+
"targets": {"type": "array", "maxItems": 2, "items": _TARGET_SCHEMA},
|
|
46
|
+
"options": {
|
|
47
|
+
"type": "object",
|
|
48
|
+
"additionalProperties": False,
|
|
49
|
+
"properties": {
|
|
50
|
+
"max_depth": {"type": "integer"},
|
|
51
|
+
"max_results": {"type": "integer"},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
"source": {
|
|
55
|
+
"type": "object",
|
|
56
|
+
"additionalProperties": False,
|
|
57
|
+
"required": ["mode"],
|
|
58
|
+
"properties": {"mode": {"const": "local-graph"}},
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def make_plan(
|
|
65
|
+
operation: str,
|
|
66
|
+
targets: list[tuple[str, str]],
|
|
67
|
+
options: dict[str, int],
|
|
68
|
+
) -> dict[str, Any]:
|
|
69
|
+
"""Assemble a plan document from (role, input) target pairs."""
|
|
70
|
+
return {
|
|
71
|
+
"plan_version": PLAN_VERSION,
|
|
72
|
+
"operation": operation,
|
|
73
|
+
"targets": [{"role": role, "input": text} for role, text in targets],
|
|
74
|
+
"options": dict(options),
|
|
75
|
+
"source": {"mode": "local-graph"},
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def plan_error(plan: object, expected_roles: dict[str, tuple[str, ...]]) -> str | None:
|
|
80
|
+
"""Reason `plan` is invalid, or None if it is executable.
|
|
81
|
+
|
|
82
|
+
expected_roles maps every known operation to its required target roles in
|
|
83
|
+
order; a plan whose operation is absent from the map is rejected, so a verb
|
|
84
|
+
cannot be executed through a plan without being registered.
|
|
85
|
+
"""
|
|
86
|
+
if not matches_schema(plan, PLAN_SCHEMA):
|
|
87
|
+
return "plan does not match plan schema v1"
|
|
88
|
+
assert isinstance(plan, dict) # narrowed by the schema match above
|
|
89
|
+
roles = expected_roles.get(plan["operation"])
|
|
90
|
+
if roles is None:
|
|
91
|
+
return f"unknown operation: {plan['operation']}"
|
|
92
|
+
if tuple(target["role"] for target in plan["targets"]) != roles:
|
|
93
|
+
return f"operation {plan['operation']} requires target roles {list(roles)}"
|
|
94
|
+
for key, value in plan["options"].items():
|
|
95
|
+
if value < 1:
|
|
96
|
+
return f"option {key} must be a positive integer"
|
|
97
|
+
return None
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
"""Audit whether Graphite is ready to replace legacy Graphify usage in a project."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Callable
|
|
7
|
+
|
|
8
|
+
from .bootstrap import GRAPHITE_AGENTS_HEADER, daemon_visibility
|
|
9
|
+
from .config import Config
|
|
10
|
+
from .daemon_health import HealthOptions, evaluate_daemon_health
|
|
11
|
+
from .ingest import collect_files
|
|
12
|
+
from .validation import validate_graph_bundle
|
|
13
|
+
|
|
14
|
+
GRAPHIFY_PATH_PATTERNS: tuple[str, ...] = (
|
|
15
|
+
"graphify",
|
|
16
|
+
"graphify-out",
|
|
17
|
+
"graphify-out/**",
|
|
18
|
+
"graphify/**",
|
|
19
|
+
"scripts/graphify_*.py",
|
|
20
|
+
"**/graphify_*.py",
|
|
21
|
+
"**/*graphify*",
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
GRAPHIFY_TEXT_FILES: tuple[str, ...] = (
|
|
25
|
+
".gitignore",
|
|
26
|
+
"AGENTS.md",
|
|
27
|
+
"README.md",
|
|
28
|
+
"package.json",
|
|
29
|
+
"pyproject.toml",
|
|
30
|
+
)
|
|
31
|
+
|
|
32
|
+
GRAPHITE_GITIGNORE_REQUIRED: tuple[str, ...] = (
|
|
33
|
+
"graph-out/",
|
|
34
|
+
".cache/graphite/",
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
HealthChecker = Callable[[Path, Path | None], dict[str, Any]]
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def audit_replacement(
|
|
41
|
+
project_root: Path,
|
|
42
|
+
*,
|
|
43
|
+
daemon_base: Path | None = None,
|
|
44
|
+
cfg: Config | None = None,
|
|
45
|
+
health_checker: HealthChecker | None = None,
|
|
46
|
+
) -> dict[str, Any]:
|
|
47
|
+
"""Return a machine-readable Graphify-to-Graphite replacement audit."""
|
|
48
|
+
root = project_root.resolve()
|
|
49
|
+
if not root.exists():
|
|
50
|
+
raise FileNotFoundError(root)
|
|
51
|
+
if not root.is_dir():
|
|
52
|
+
raise NotADirectoryError(root)
|
|
53
|
+
|
|
54
|
+
runtime_cfg = cfg or Config()
|
|
55
|
+
graph = _graph_status(root, runtime_cfg)
|
|
56
|
+
bootstrap = _bootstrap_status(root)
|
|
57
|
+
daemon = daemon_visibility(root, daemon_base=daemon_base)
|
|
58
|
+
health = _health_status(daemon, daemon_base, health_checker)
|
|
59
|
+
graphify = _graphify_status(root)
|
|
60
|
+
|
|
61
|
+
blockers: list[dict[str, Any]] = []
|
|
62
|
+
warnings: list[dict[str, Any]] = []
|
|
63
|
+
recommendations: list[str] = []
|
|
64
|
+
|
|
65
|
+
if not bootstrap["agents_configured"]:
|
|
66
|
+
blockers.append({"code": "agents_not_configured", "message": "AGENTS.md does not include the Graphite auto-consult workflow"})
|
|
67
|
+
if not bootstrap["gitignore_configured"]:
|
|
68
|
+
blockers.append({"code": "gitignore_not_configured", "message": "Graphite outputs are not fully ignored in .gitignore"})
|
|
69
|
+
if not graph["exists"]:
|
|
70
|
+
blockers.append({"code": "graph_missing", "message": "graph-out/graph.json does not exist"})
|
|
71
|
+
elif not graph["valid"]:
|
|
72
|
+
blockers.append({"code": "graph_invalid", "message": "graph-out/graph.json failed validation", "validation": graph.get("validation")})
|
|
73
|
+
elif graph["stale"]:
|
|
74
|
+
blockers.append({"code": "graph_stale", "message": "Graphite graph is stale", "changes": graph.get("changes")})
|
|
75
|
+
if not daemon.get("project_listed"):
|
|
76
|
+
blockers.append({"code": "daemon_project_missing", "message": "Graphite daemon status does not list this project"})
|
|
77
|
+
if health.get("checked") and not health.get("ok"):
|
|
78
|
+
blockers.append({"code": "daemon_health_not_ok", "message": "Graphite daemon health check is not ok", "health": health})
|
|
79
|
+
|
|
80
|
+
if graphify["existing_paths"]:
|
|
81
|
+
warnings.append({
|
|
82
|
+
"code": "graphify_paths_exist",
|
|
83
|
+
"message": "Graphify files or directories still exist in the project",
|
|
84
|
+
"paths": graphify["existing_paths"],
|
|
85
|
+
})
|
|
86
|
+
if graphify["text_references"]:
|
|
87
|
+
warnings.append({
|
|
88
|
+
"code": "graphify_text_references",
|
|
89
|
+
"message": "Graphify references remain in project text/config files",
|
|
90
|
+
"references": graphify["text_references"],
|
|
91
|
+
})
|
|
92
|
+
if graphify["gitignore_entries"]:
|
|
93
|
+
warnings.append({
|
|
94
|
+
"code": "graphify_gitignore_entries",
|
|
95
|
+
"message": "Legacy Graphify ignore entries remain",
|
|
96
|
+
"entries": graphify["gitignore_entries"],
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
if blockers:
|
|
100
|
+
recommendations.append("Resolve blockers before removing Graphify remnants.")
|
|
101
|
+
if graphify["existing_paths"]:
|
|
102
|
+
recommendations.append("Inspect existing Graphify files/directories and migrate any still-useful scripts or reports before deletion.")
|
|
103
|
+
elif graphify["gitignore_entries"]:
|
|
104
|
+
recommendations.append("Graphify ignore entries appear to be legacy-only; remove them after confirming no external workflow still writes Graphify outputs.")
|
|
105
|
+
if not warnings and not blockers:
|
|
106
|
+
recommendations.append("Graphite appears ready to fully replace Graphify for this project.")
|
|
107
|
+
|
|
108
|
+
return {
|
|
109
|
+
"ok": not blockers,
|
|
110
|
+
"replacement_ready": not blockers and not graphify["existing_paths"],
|
|
111
|
+
"project_root": str(root),
|
|
112
|
+
"blockers": blockers,
|
|
113
|
+
"warnings": warnings,
|
|
114
|
+
"recommendations": recommendations,
|
|
115
|
+
"graphite": {
|
|
116
|
+
"bootstrap": bootstrap,
|
|
117
|
+
"graph": graph,
|
|
118
|
+
"daemon": daemon,
|
|
119
|
+
"health": health,
|
|
120
|
+
},
|
|
121
|
+
"graphify": graphify,
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
# Keyed 1:1 off `status`, never off the separate `ok` boolean. `ok` is
|
|
126
|
+
# `not errors` in daemon_health._finalize, so it is True for the warnings-only
|
|
127
|
+
# tier and rendered the self-contradictory `daemon health: warning (ok)` (#25).
|
|
128
|
+
# Same shape as `resolution_health.healthy` vs the scoped `answer.grade`: an
|
|
129
|
+
# aggregate boolean that discards the middle tier can disagree with the
|
|
130
|
+
# granular status sitting next to it.
|
|
131
|
+
_HEALTH_ADVICE = {
|
|
132
|
+
"ok": "no action needed",
|
|
133
|
+
"warning": "attention suggested",
|
|
134
|
+
"degraded": "attention required",
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
def _health_advice(status: object) -> str:
|
|
139
|
+
"""Advice for a health tier, failing closed on anything unrecognised.
|
|
140
|
+
|
|
141
|
+
An unknown tier must not read as reassuring -- a status this code has
|
|
142
|
+
never heard of is precisely when a human should look.
|
|
143
|
+
"""
|
|
144
|
+
return _HEALTH_ADVICE.get(status, "attention required") if isinstance(status, str) else "attention required"
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
def format_replacement_audit(report: dict[str, Any]) -> str:
|
|
148
|
+
lines = [
|
|
149
|
+
f"[graphite] replacement audit: {'ready' if report['replacement_ready'] else 'not ready'}",
|
|
150
|
+
f" project: {report['project_root']}",
|
|
151
|
+
f" blockers: {len(report['blockers'])}",
|
|
152
|
+
f" warnings: {len(report['warnings'])}",
|
|
153
|
+
]
|
|
154
|
+
if report["blockers"]:
|
|
155
|
+
lines.append("Blockers:")
|
|
156
|
+
for item in report["blockers"]:
|
|
157
|
+
lines.append(f" - {item['code']}: {item['message']}")
|
|
158
|
+
if report["warnings"]:
|
|
159
|
+
lines.append("Warnings:")
|
|
160
|
+
for item in report["warnings"]:
|
|
161
|
+
lines.append(f" - {item['code']}: {item['message']}")
|
|
162
|
+
graph = report["graphite"]["graph"]
|
|
163
|
+
lines.append("Graphite:")
|
|
164
|
+
lines.append(f" - graph exists: {graph['exists']}")
|
|
165
|
+
lines.append(f" - graph valid: {graph['valid']}")
|
|
166
|
+
lines.append(f" - graph stale: {graph['stale']}")
|
|
167
|
+
daemon = report["graphite"]["daemon"]
|
|
168
|
+
lines.append(f" - daemon listed: {daemon.get('project_listed')}")
|
|
169
|
+
health = report["graphite"]["health"]
|
|
170
|
+
if health.get("checked"):
|
|
171
|
+
status = health.get("status")
|
|
172
|
+
lines.append(f" - daemon health: {status} ({_health_advice(status)})")
|
|
173
|
+
graphify = report["graphify"]
|
|
174
|
+
lines.append("Graphify remnants:")
|
|
175
|
+
lines.append(f" - existing paths: {len(graphify['existing_paths'])}")
|
|
176
|
+
lines.append(f" - text references: {len(graphify['text_references'])}")
|
|
177
|
+
lines.append(f" - gitignore entries: {len(graphify['gitignore_entries'])}")
|
|
178
|
+
if report["recommendations"]:
|
|
179
|
+
lines.append("Recommendations:")
|
|
180
|
+
for item in report["recommendations"]:
|
|
181
|
+
lines.append(f" - {item}")
|
|
182
|
+
return "\n".join(lines) + "\n"
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _bootstrap_status(root: Path) -> dict[str, Any]:
|
|
186
|
+
gitignore = root / ".gitignore"
|
|
187
|
+
agents = root / "AGENTS.md"
|
|
188
|
+
gitignore_text = gitignore.read_text(encoding="utf-8") if gitignore.exists() else ""
|
|
189
|
+
agents_text = agents.read_text(encoding="utf-8") if agents.exists() else ""
|
|
190
|
+
missing_gitignore = [entry for entry in GRAPHITE_GITIGNORE_REQUIRED if entry not in gitignore_text]
|
|
191
|
+
return {
|
|
192
|
+
"gitignore_exists": gitignore.exists(),
|
|
193
|
+
"gitignore_configured": not missing_gitignore,
|
|
194
|
+
"gitignore_missing": missing_gitignore,
|
|
195
|
+
"agents_exists": agents.exists(),
|
|
196
|
+
"agents_configured": GRAPHITE_AGENTS_HEADER in agents_text,
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _graph_status(root: Path, cfg: Config) -> dict[str, Any]:
|
|
201
|
+
graph_path = root / cfg.output_dir / "graph.json" if not cfg.output_dir.is_absolute() else cfg.output_dir / "graph.json"
|
|
202
|
+
manifest_path = root / cfg.output_dir / ".graphite_manifest.json" if not cfg.output_dir.is_absolute() else cfg.output_dir / ".graphite_manifest.json"
|
|
203
|
+
payload: dict[str, Any] = {
|
|
204
|
+
"path": str(graph_path),
|
|
205
|
+
"exists": graph_path.exists(),
|
|
206
|
+
"valid": False,
|
|
207
|
+
"stale": True,
|
|
208
|
+
}
|
|
209
|
+
if graph_path.exists():
|
|
210
|
+
try:
|
|
211
|
+
graph = json.loads(graph_path.read_text(encoding="utf-8"))
|
|
212
|
+
validation = validate_graph_bundle(graph)
|
|
213
|
+
payload["validation"] = validation
|
|
214
|
+
payload["valid"] = bool(validation.get("ok"))
|
|
215
|
+
payload["node_count"] = validation.get("node_count")
|
|
216
|
+
payload["edge_count"] = validation.get("edge_count")
|
|
217
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
218
|
+
payload["validation"] = {"ok": False, "error": str(exc)}
|
|
219
|
+
payload.update(_freshness(root, manifest_path, cfg))
|
|
220
|
+
return payload
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _freshness(root: Path, manifest_path: Path, cfg: Config) -> dict[str, Any]:
|
|
224
|
+
if not manifest_path.exists():
|
|
225
|
+
return {"stale": True, "stale_reason": "missing manifest", "changes": {"added": [], "changed": [], "removed": []}}
|
|
226
|
+
try:
|
|
227
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
228
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
229
|
+
return {"stale": True, "stale_reason": f"unreadable manifest: {exc}", "changes": {"added": [], "changed": [], "removed": []}}
|
|
230
|
+
current = {entry.rel_path: entry.content_hash for entry in collect_files(root, cfg)}
|
|
231
|
+
previous = {item["rel_path"]: item.get("hash", "") for item in manifest.get("files", []) if "rel_path" in item}
|
|
232
|
+
added = sorted(set(current) - set(previous))
|
|
233
|
+
removed = sorted(set(previous) - set(current))
|
|
234
|
+
changed = sorted(path for path in set(current) & set(previous) if current[path] != previous[path])
|
|
235
|
+
return {
|
|
236
|
+
"stale": bool(added or removed or changed),
|
|
237
|
+
"changes": {"added": added, "changed": changed, "removed": removed},
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def _health_status(daemon: dict[str, Any], daemon_base: Path | None, health_checker: HealthChecker | None) -> dict[str, Any]:
|
|
242
|
+
base_text = daemon.get("base")
|
|
243
|
+
if not base_text:
|
|
244
|
+
return {"checked": False, "ok": None, "reason": "daemon base unavailable"}
|
|
245
|
+
base = daemon_base or Path(str(base_text))
|
|
246
|
+
if health_checker:
|
|
247
|
+
health = health_checker(base, None)
|
|
248
|
+
else:
|
|
249
|
+
health = evaluate_daemon_health(base, options=HealthOptions())
|
|
250
|
+
return {"checked": True, **health}
|
|
251
|
+
|
|
252
|
+
|
|
253
|
+
def _graphify_status(root: Path) -> dict[str, Any]:
|
|
254
|
+
existing_paths = _find_graphify_paths(root)
|
|
255
|
+
text_refs = _find_graphify_text_references(root)
|
|
256
|
+
gitignore_entries = [ref for ref in text_refs if ref["file"] == ".gitignore"]
|
|
257
|
+
return {
|
|
258
|
+
"existing_paths": existing_paths,
|
|
259
|
+
"text_references": text_refs,
|
|
260
|
+
"gitignore_entries": gitignore_entries,
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _find_graphify_paths(root: Path) -> list[str]:
|
|
265
|
+
matches: set[str] = set()
|
|
266
|
+
for pattern in GRAPHIFY_PATH_PATTERNS:
|
|
267
|
+
for path in root.glob(pattern):
|
|
268
|
+
if path == root:
|
|
269
|
+
continue
|
|
270
|
+
rel = path.relative_to(root).as_posix()
|
|
271
|
+
if rel in (".gitignore", "AGENTS.md"):
|
|
272
|
+
continue
|
|
273
|
+
matches.add(rel + ("/" if path.is_dir() else ""))
|
|
274
|
+
return sorted(matches)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def _find_graphify_text_references(root: Path) -> list[dict[str, Any]]:
|
|
278
|
+
refs: list[dict[str, Any]] = []
|
|
279
|
+
for rel in GRAPHIFY_TEXT_FILES:
|
|
280
|
+
path = root / rel
|
|
281
|
+
if not path.exists() or not path.is_file():
|
|
282
|
+
continue
|
|
283
|
+
try:
|
|
284
|
+
lines = path.read_text(encoding="utf-8").splitlines()
|
|
285
|
+
except OSError:
|
|
286
|
+
continue
|
|
287
|
+
for index, line in enumerate(lines, start=1):
|
|
288
|
+
if "graphify" in line.lower():
|
|
289
|
+
refs.append({"file": rel, "line": index, "text": line.strip()})
|
|
290
|
+
return refs
|
|
291
|
+
|