fux-engine 0.1.2__tar.gz → 0.2.0__tar.gz
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.
- {fux_engine-0.1.2/fux_engine.egg-info → fux_engine-0.2.0}/PKG-INFO +1 -1
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/__init__.py +1 -1
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cli.py +19 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cligraph.py +29 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cliquery.py +15 -3
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/community.py +14 -8
- fux_engine-0.2.0/fux/components.py +98 -0
- fux_engine-0.2.0/fux/feedback.py +65 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/graph.py +24 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/graphquery.py +17 -4
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/hybrid.py +5 -1
- fux_engine-0.2.0/fux/impact.py +100 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/mcpserver.py +26 -2
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/report.py +11 -0
- fux_engine-0.2.0/fux/uispec.py +64 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0/fux_engine.egg-info}/PKG-INFO +1 -1
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/SOURCES.txt +9 -0
- fux_engine-0.2.0/tests/test_components.py +57 -0
- fux_engine-0.2.0/tests/test_edge_confidence.py +79 -0
- fux_engine-0.2.0/tests/test_feedback.py +25 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_hybrid.py +14 -0
- fux_engine-0.2.0/tests/test_impact.py +52 -0
- fux_engine-0.2.0/tests/test_uispec.py +51 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/LICENSE +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/README.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/__main__.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/fux-icon.svg +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/fux-lockup.svg +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/fux-mark.svg +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/graph_boot.js +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/assets/graph_template.html +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/astextract.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/bench.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/build.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/capture.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/check.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/clicmds.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/cliutil.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/config.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/context.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/costledger.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/coverage.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/copilot/prompts/fux-plan.prompt.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/copilot/prompts/fux.prompt.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/README.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/async-everywhere.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/doc-per-code-change.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/files-max-100-lines.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/global/rules/no-secrets-in-vcs.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/_common.sh +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/post_tool_use.sh +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/session_start.sh +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/stop.sh +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/hooks/user_prompt_submit.sh +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/packs/indian-markets-tax/pack.toml +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/packs/indian-markets-tax/rules/capital-gains-equity.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/packs/indian-markets-tax/rules/market-hours-nse.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/schema.json +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/adr/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/distill/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/fetch-rules/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/fux/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/plan/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/savings/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/data/skills/trace/SKILL.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/drift.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/embed.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/explain.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/fetchrules.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/findings.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/fix.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/fmwrite.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/frontmatter.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/gate.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/gitutil.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/globs.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/governance.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/graphhtml.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/hookio.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/hooks.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/importer.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/index.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/initcmd.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/lint.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/loader.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/mine.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/model.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/narrative.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/pack.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/parity.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/paths.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/recall.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/savings.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/scaffold.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/scalars.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/schema.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/seal.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/serve.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/settings.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/stats.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/templates/formula.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/templates/spec.md +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/touch.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/tour.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/usage.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/verify.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux/vexamples.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/dependency_links.txt +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/entry_points.txt +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/requires.txt +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/fux_engine.egg-info/top_level.txt +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/pyproject.toml +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/setup.cfg +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_ast_backend.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_astextract.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_bm25f_expand.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_capture_governance.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_centrality.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_check_fix.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_costledger.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_crossfile_calls.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_embed_rerank.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_examples.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_fetch_rules.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_frontmatter.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_fuzz_mine.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_globs.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_graphhtml.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_lint_stats_gate.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_mcp.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_mcp_extra.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_pack.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_parity_import.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_recall_build_verify.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_recall_eval.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_resolution.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_savings.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_schema_scaffold_init.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_seal.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_serve_sanitize.py +0 -0
- {fux_engine-0.1.2 → fux_engine-0.2.0}/tests/test_verify_hardening.py +0 -0
|
@@ -120,6 +120,25 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
120
120
|
ex.add_argument("term")
|
|
121
121
|
ex.set_defaults(fn=cligraph.cmd_explain)
|
|
122
122
|
|
|
123
|
+
imp2 = sub.add_parser("impact", help="downstream blast radius of changing a file ($0)")
|
|
124
|
+
imp2.add_argument("file")
|
|
125
|
+
imp2.set_defaults(fn=cligraph.cmd_impact)
|
|
126
|
+
|
|
127
|
+
cmp = sub.add_parser("components", help="design-system registry + data-binding catalog ($0)")
|
|
128
|
+
cmp.add_argument("--kind", choices=["all", "components", "hooks", "dtos"], default="all")
|
|
129
|
+
cmp.add_argument("--scope", help="restrict to files under this path prefix")
|
|
130
|
+
cmp.add_argument("--json", action="store_true", help="machine-readable output (for Orff)")
|
|
131
|
+
cmp.set_defaults(fn=cligraph.cmd_components)
|
|
132
|
+
|
|
133
|
+
vs = sub.add_parser("validate-spec", help="validate a generated UISpec against the registry ($0)")
|
|
134
|
+
vs.add_argument("file")
|
|
135
|
+
vs.add_argument("--json", action="store_true", help="emit {ok, errors} as JSON")
|
|
136
|
+
vs.set_defaults(fn=cligraph.cmd_validate_spec)
|
|
137
|
+
|
|
138
|
+
fb = sub.add_parser("feedback", help="record/summarise on-the-fly generation outcomes ($0)")
|
|
139
|
+
fb.add_argument("--record", metavar="FILE", help="append one outcome from JSON ('-' = stdin)")
|
|
140
|
+
fb.set_defaults(fn=cliquery.cmd_feedback)
|
|
141
|
+
|
|
123
142
|
sub.add_parser("report", help="write GRAPH_REPORT.md (god nodes + communities)").set_defaults(fn=cligraph.cmd_report)
|
|
124
143
|
|
|
125
144
|
sub.add_parser("setup", help="copy bundled assets (schema, hooks, skills) to ~/.claude/fux/").set_defaults(fn=clicmds.cmd_setup)
|
|
@@ -113,6 +113,35 @@ def cmd_explain(args) -> int:
|
|
|
113
113
|
return 0
|
|
114
114
|
|
|
115
115
|
|
|
116
|
+
def cmd_impact(args) -> int:
|
|
117
|
+
from fux import impact
|
|
118
|
+
print(impact.render(impact.run(root(), args.file)), end="")
|
|
119
|
+
return 0
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def cmd_components(args) -> int:
|
|
123
|
+
from fux import components
|
|
124
|
+
reg = components.registry(root(), scope=getattr(args, "scope", None))
|
|
125
|
+
if getattr(args, "json", False):
|
|
126
|
+
print(components.render_json(reg), end="")
|
|
127
|
+
else:
|
|
128
|
+
print(components.render(reg, kind=getattr(args, "kind", "all")), end="")
|
|
129
|
+
return 0
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
def cmd_validate_spec(args) -> int:
|
|
133
|
+
import json
|
|
134
|
+
from pathlib import Path
|
|
135
|
+
|
|
136
|
+
from fux import uispec
|
|
137
|
+
ok, errs = uispec.run(root(), Path(args.file))
|
|
138
|
+
if getattr(args, "json", False):
|
|
139
|
+
print(json.dumps({"ok": ok, "errors": errs}))
|
|
140
|
+
else:
|
|
141
|
+
print(uispec.render(ok, errs), end="")
|
|
142
|
+
return 0 if ok else 2
|
|
143
|
+
|
|
144
|
+
|
|
116
145
|
def cmd_report(_args) -> int:
|
|
117
146
|
from fux import paths
|
|
118
147
|
here = root()
|
|
@@ -2,12 +2,24 @@
|
|
|
2
2
|
from __future__ import annotations
|
|
3
3
|
import sys
|
|
4
4
|
|
|
5
|
-
from fux import (capture, config, costledger, coverage, explain,
|
|
6
|
-
lint, loader, mine, parity, paths, recall, savings,
|
|
7
|
-
seal, stats, tour, verify)
|
|
5
|
+
from fux import (capture, config, costledger, coverage, explain, feedback,
|
|
6
|
+
fetchrules, lint, loader, mine, parity, paths, recall, savings,
|
|
7
|
+
scaffold, seal, stats, tour, verify)
|
|
8
8
|
from fux.cliutil import root
|
|
9
9
|
|
|
10
10
|
|
|
11
|
+
def cmd_feedback(args) -> int:
|
|
12
|
+
import json
|
|
13
|
+
here = root()
|
|
14
|
+
if getattr(args, "record", None):
|
|
15
|
+
raw = sys.stdin.read() if args.record == "-" else open(args.record).read()
|
|
16
|
+
feedback.record(here, json.loads(raw))
|
|
17
|
+
print("fux feedback: recorded")
|
|
18
|
+
return 0
|
|
19
|
+
print(feedback.render(feedback.load(here)))
|
|
20
|
+
return 0
|
|
21
|
+
|
|
22
|
+
|
|
11
23
|
def cmd_recall(args) -> int:
|
|
12
24
|
hybrid = True if getattr(args, "hybrid", False) else None
|
|
13
25
|
expand = True if getattr(args, "expand", False) else None
|
|
@@ -2,20 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
Replaces graphify's clustering. Synchronous label propagation with sorted,
|
|
4
4
|
tie-broken updates so the result is reproducible across runs (no randomness).
|
|
5
|
+
Votes are **edge-weighted**: a low-confidence `references` edge (weight 0.25) pulls
|
|
6
|
+
a node into a community far less than a precise `calls`/`contains` edge, so the
|
|
7
|
+
loose whole-file xref can't over-fragment or mis-merge clusters by raw count.
|
|
5
8
|
"""
|
|
6
9
|
from __future__ import annotations
|
|
7
10
|
|
|
8
|
-
|
|
11
|
+
_TIE = 1e-9
|
|
9
12
|
|
|
10
13
|
|
|
11
|
-
def _adjacency(nodes: list[dict], edges: list[dict]) -> dict[str,
|
|
14
|
+
def _adjacency(nodes: list[dict], edges: list[dict]) -> dict[str, dict[str, float]]:
|
|
12
15
|
ids = {n["id"] for n in nodes}
|
|
13
|
-
adj: dict[str,
|
|
16
|
+
adj: dict[str, dict[str, float]] = {n["id"]: {} for n in nodes}
|
|
14
17
|
for e in edges:
|
|
15
18
|
s, t = e.get("source"), e.get("target")
|
|
16
19
|
if s in ids and t in ids and s != t:
|
|
17
|
-
|
|
18
|
-
adj[t].
|
|
20
|
+
w = float(e.get("weight", 1.0))
|
|
21
|
+
adj[s][t] = adj[s].get(t, 0.0) + w
|
|
22
|
+
adj[t][s] = adj[t].get(s, 0.0) + w
|
|
19
23
|
return adj
|
|
20
24
|
|
|
21
25
|
|
|
@@ -29,10 +33,12 @@ def detect(nodes: list[dict], edges: list[dict], max_iter: int = 30) -> dict[str
|
|
|
29
33
|
for nid in order:
|
|
30
34
|
if not adj[nid]:
|
|
31
35
|
continue
|
|
32
|
-
votes
|
|
36
|
+
votes: dict[str, float] = {}
|
|
37
|
+
for nbr, w in adj[nid].items():
|
|
38
|
+
votes[label[nbr]] = votes.get(label[nbr], 0.0) + w
|
|
33
39
|
top = max(votes.values())
|
|
34
|
-
# Deterministic tie-break: smallest label among the winners.
|
|
35
|
-
best = min(lab for lab, c in votes.items() if c
|
|
40
|
+
# Deterministic tie-break: smallest label among the (near-)winners.
|
|
41
|
+
best = min(lab for lab, c in votes.items() if top - c < _TIE)
|
|
36
42
|
if label[nid] != best:
|
|
37
43
|
label[nid] = best
|
|
38
44
|
changed = True
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"""`fux components` — the design-system registry + data-binding catalog ($0, §18.3).
|
|
2
|
+
|
|
3
|
+
The runtime-generation prerequisite: so Orff composes UI from existing primitives
|
|
4
|
+
and binds to real data instead of inventing either. Pure stdlib analysis over the
|
|
5
|
+
built graph + source files — component names with their prop fields, plus the
|
|
6
|
+
hooks and DTOs a generated component must wire to. Never calls an LLM.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import json
|
|
11
|
+
import re
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
|
|
14
|
+
from fux.astextract import sanitize_lines
|
|
15
|
+
from fux.graphquery import load
|
|
16
|
+
|
|
17
|
+
_DECL = re.compile(r"\b(?:interface|type)\s+(\w+)\b")
|
|
18
|
+
_MEMBER = re.compile(r"^\s*(?:readonly\s+)?([A-Za-z_]\w*)\s*(\?)?\s*:\s*(.+?);?\s*$")
|
|
19
|
+
# Source-scan patterns — backend-independent (work with or without tree-sitter).
|
|
20
|
+
_PROPS = re.compile(r"(?:export\s+)?(?:interface|type)\s+(\w+)Props\b")
|
|
21
|
+
_DTO = re.compile(r"(?:export\s+)?(?:interface|type)\s+(\w+DTO)\b")
|
|
22
|
+
_HOOK = re.compile(r"(?:export\s+)?(?:const|(?:async\s+)?function)\s+(use[A-Z]\w*)")
|
|
23
|
+
_TS = (".ts", ".tsx")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def _props_of(text: str, name: str) -> list[dict]:
|
|
27
|
+
"""Extract the field list of an `interface/type <name>` block, depth-1 only."""
|
|
28
|
+
san, lines = sanitize_lines(text), text.split("\n")
|
|
29
|
+
start = next((i for i, s in enumerate(san)
|
|
30
|
+
if (m := _DECL.search(s)) and m.group(1) == name), None)
|
|
31
|
+
if start is None:
|
|
32
|
+
return []
|
|
33
|
+
depth, opened, out = 0, False, []
|
|
34
|
+
for k in range(start, len(san)):
|
|
35
|
+
at_start = depth
|
|
36
|
+
if opened and at_start == 1:
|
|
37
|
+
mm = _MEMBER.match(lines[k])
|
|
38
|
+
if mm and not lines[k].lstrip().startswith(("//", "*", "/*")):
|
|
39
|
+
out.append({"name": mm.group(1), "optional": bool(mm.group(2)),
|
|
40
|
+
"type": mm.group(3).strip().rstrip(";")})
|
|
41
|
+
for ch in san[k]:
|
|
42
|
+
if ch == "{":
|
|
43
|
+
depth += 1; opened = True
|
|
44
|
+
elif ch == "}":
|
|
45
|
+
depth -= 1
|
|
46
|
+
if opened and depth <= 0:
|
|
47
|
+
break
|
|
48
|
+
return out
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def registry(root: Path, scope: str | None = None) -> dict:
|
|
52
|
+
"""Components (name + props), data hooks (use*), and DTOs, by scanning the
|
|
53
|
+
TS/TSX sources that the graph covers. Backend-independent — relies on naming
|
|
54
|
+
conventions in the text, not on tree-sitter symbol nodes."""
|
|
55
|
+
graph = load(root) # raises SystemExit if no graph yet
|
|
56
|
+
files = sorted({n["file"] for n in graph["nodes"]
|
|
57
|
+
if n.get("type") == "code-file" and (n.get("file") or "").endswith(_TS)
|
|
58
|
+
and (not scope or n["file"].startswith(scope))})
|
|
59
|
+
comps, hooks, dtos, seen = [], [], [], set()
|
|
60
|
+
for rel in files:
|
|
61
|
+
try:
|
|
62
|
+
text = (root / rel).read_text(encoding="utf-8")
|
|
63
|
+
except OSError:
|
|
64
|
+
continue
|
|
65
|
+
for i, line in enumerate(sanitize_lines(text), 1):
|
|
66
|
+
if (m := _PROPS.search(line)):
|
|
67
|
+
comps.append({"name": m.group(1), "file": rel, "line": i,
|
|
68
|
+
"props": _props_of(text, m.group(1) + "Props")})
|
|
69
|
+
if (d := _DTO.search(line)):
|
|
70
|
+
dtos.append({"name": d.group(1), "file": rel})
|
|
71
|
+
if (h := _HOOK.search(line)) and (h.group(1), rel) not in seen:
|
|
72
|
+
seen.add((h.group(1), rel))
|
|
73
|
+
hooks.append({"name": h.group(1), "file": rel})
|
|
74
|
+
key = lambda x: x["name"]
|
|
75
|
+
return {"components": sorted(comps, key=key),
|
|
76
|
+
"hooks": sorted(hooks, key=key), "dtos": sorted(dtos, key=key)}
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def render(reg: dict, kind: str = "all") -> str:
|
|
80
|
+
out: list[str] = []
|
|
81
|
+
if kind in ("all", "components"):
|
|
82
|
+
out.append(f"## Components ({len(reg['components'])}) — compose from these")
|
|
83
|
+
for c in reg["components"]:
|
|
84
|
+
ps = ", ".join(p["name"] + ("?" if p["optional"] else "") for p in c["props"])
|
|
85
|
+
out.append(f"- **{c['name']}** ({c['file']}) — props: {ps or '—'}")
|
|
86
|
+
out.append("")
|
|
87
|
+
if kind in ("all", "hooks"):
|
|
88
|
+
out.append(f"## Data hooks ({len(reg['hooks'])}) — bind to these, don't refetch")
|
|
89
|
+
out += [f"- {h['name']} ({h['file']})" for h in reg["hooks"]]
|
|
90
|
+
out.append("")
|
|
91
|
+
if kind in ("all", "dtos"):
|
|
92
|
+
out.append(f"## DTOs ({len(reg['dtos'])}) — the data shapes")
|
|
93
|
+
out += [f"- {d['name']} ({d['file']})" for d in reg["dtos"]]
|
|
94
|
+
return "\n".join(out).rstrip() + "\n"
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def render_json(reg: dict) -> str:
|
|
98
|
+
return json.dumps(reg, indent=2, ensure_ascii=False) + "\n"
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
"""`fux feedback` — record + summarise on-the-fly generation outcomes (§18.4, $0).
|
|
2
|
+
|
|
3
|
+
The brain's learning loop: every Orff compose attempt (valid / rejected / repaired)
|
|
4
|
+
is appended as a JSON line, and `fux feedback` reports the acceptance rate and the
|
|
5
|
+
most common rejection reasons — so a recurring validator failure becomes a signal to
|
|
6
|
+
add a component, a prop, or a contract rule. Deterministic; no LLM, no memory writes.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import datetime as _dt
|
|
11
|
+
import json
|
|
12
|
+
from collections import Counter
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
from fux import paths
|
|
16
|
+
|
|
17
|
+
_FIELDS = ("prompt", "valid", "errors", "attempts", "provider", "model")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _file(root: Path) -> Path:
|
|
21
|
+
d = paths.Footprint(root).base / "capture"
|
|
22
|
+
d.mkdir(parents=True, exist_ok=True)
|
|
23
|
+
return d / "feedback.jsonl"
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def record(root: Path, data: dict) -> dict:
|
|
27
|
+
rec = {
|
|
28
|
+
"ts": _dt.datetime.now().isoformat(timespec="seconds"),
|
|
29
|
+
"prompt": str(data.get("prompt", ""))[:200],
|
|
30
|
+
"valid": bool(data.get("valid")),
|
|
31
|
+
"errors": list(data.get("errors") or []),
|
|
32
|
+
"attempts": int(data.get("attempts", 1)),
|
|
33
|
+
"provider": data.get("provider"),
|
|
34
|
+
"model": data.get("model"),
|
|
35
|
+
}
|
|
36
|
+
with _file(root).open("a", encoding="utf-8") as fh:
|
|
37
|
+
fh.write(json.dumps(rec) + "\n")
|
|
38
|
+
return rec
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def load(root: Path) -> list[dict]:
|
|
42
|
+
f = _file(root)
|
|
43
|
+
return [json.loads(x) for x in f.read_text().splitlines() if x.strip()] if f.exists() else []
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _reason(err: str) -> str:
|
|
47
|
+
for key in ("unknown component", "not on", "missing required prop",
|
|
48
|
+
"data hook", "takes no children", "did not return"):
|
|
49
|
+
if key in err:
|
|
50
|
+
return key
|
|
51
|
+
return err[:40]
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def render(rows: list[dict]) -> str:
|
|
55
|
+
if not rows:
|
|
56
|
+
return "fux feedback: no generation outcomes recorded yet."
|
|
57
|
+
ok = sum(1 for r in rows if r["valid"])
|
|
58
|
+
first_try = sum(1 for r in rows if r["valid"] and r["attempts"] == 1)
|
|
59
|
+
reasons = Counter(_reason(e) for r in rows if not r["valid"] for e in r["errors"])
|
|
60
|
+
out = [f"fux feedback — {len(rows)} compose(s): {ok} valid "
|
|
61
|
+
f"({100 * ok // len(rows)}%), {first_try} on first try."]
|
|
62
|
+
if reasons:
|
|
63
|
+
out.append("Top rejection reasons (candidate registry/rule gaps):")
|
|
64
|
+
out += [f" {n}× {r}" for r, n in reasons.most_common(5)]
|
|
65
|
+
return "\n".join(out)
|
|
@@ -10,6 +10,29 @@ from fux.model import RuleSet
|
|
|
10
10
|
|
|
11
11
|
REF_RE = re.compile(r"^([^#]+)(?:#L(\d+)(?:-L?(\d+))?)?$")
|
|
12
12
|
|
|
13
|
+
# Per-edge-type confidence + clustering/centrality weight. Structural (`contains`,
|
|
14
|
+
# `calls`) and authored (`governs`, `related`, typed rule↔rule) edges are precise —
|
|
15
|
+
# EXTRACTED, full weight. The looser whole-file `references` xref ([_xref]) matches
|
|
16
|
+
# any identifier against any symbol label, so it is INFERRED and down-weighted: it
|
|
17
|
+
# is the dominant edge by raw count and would otherwise drown the precise signal in
|
|
18
|
+
# community detection + PageRank. graphify carried this confidence label; Fux now does too.
|
|
19
|
+
_EDGE_CONF: dict[str, tuple[str, float]] = {
|
|
20
|
+
"contains": ("EXTRACTED", 1.0),
|
|
21
|
+
"calls": ("EXTRACTED", 1.0),
|
|
22
|
+
"governs": ("EXTRACTED", 1.0),
|
|
23
|
+
"related": ("EXTRACTED", 1.0),
|
|
24
|
+
"references": ("INFERRED", 0.25),
|
|
25
|
+
}
|
|
26
|
+
_DEFAULT_CONF = ("EXTRACTED", 1.0) # authored typed rule↔rule edges
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _stamp_confidence(edges: list[dict]) -> None:
|
|
30
|
+
"""Annotate each edge with ``confidence`` (EXTRACTED|INFERRED) + clustering ``weight``."""
|
|
31
|
+
for e in edges:
|
|
32
|
+
conf, weight = _EDGE_CONF.get(e["type"], _DEFAULT_CONF)
|
|
33
|
+
e.setdefault("confidence", conf)
|
|
34
|
+
e.setdefault("weight", weight)
|
|
35
|
+
|
|
13
36
|
|
|
14
37
|
def _iter_sources(root: Path, include: list[str], ignore: list[str]):
|
|
15
38
|
for path in sorted(root.rglob("*")):
|
|
@@ -51,6 +74,7 @@ def build(root: Path, rs: RuleSet, cfg: dict, full: bool = False) -> dict:
|
|
|
51
74
|
edges += xcalls
|
|
52
75
|
edges += _xref(nodes, texts, covered)
|
|
53
76
|
_add_knowledge(nodes, edges, rs)
|
|
77
|
+
_stamp_confidence(edges) # weight-aware before clustering/centrality
|
|
54
78
|
comm = community.detect(list(nodes.values()), edges)
|
|
55
79
|
for nid, c in comm.items():
|
|
56
80
|
nodes[nid]["community"] = c
|
|
@@ -28,6 +28,19 @@ def _adj(graph: dict) -> dict[str, set[str]]:
|
|
|
28
28
|
return adj
|
|
29
29
|
|
|
30
30
|
|
|
31
|
+
def _wadj(graph: dict) -> dict[str, dict[str, float]]:
|
|
32
|
+
"""Weighted undirected adjacency for centrality — low-confidence `references`
|
|
33
|
+
edges (weight 0.25) carry proportionally less rank than precise `calls`."""
|
|
34
|
+
adj: dict[str, dict[str, float]] = {n["id"]: {} for n in graph["nodes"]}
|
|
35
|
+
for e in graph["edges"]:
|
|
36
|
+
s, t = e["source"], e["target"]
|
|
37
|
+
if s in adj and t in adj:
|
|
38
|
+
w = float(e.get("weight", 1.0))
|
|
39
|
+
adj[s][t] = adj[s].get(t, 0.0) + w
|
|
40
|
+
adj[t][s] = adj[t].get(s, 0.0) + w
|
|
41
|
+
return adj
|
|
42
|
+
|
|
43
|
+
|
|
31
44
|
def find(graph: dict, term: str) -> dict | None:
|
|
32
45
|
"""Resolve a free-text term to the best-matching node."""
|
|
33
46
|
t = term.lower().strip()
|
|
@@ -109,8 +122,8 @@ def pagerank(graph: dict, damping: float = 0.85, iterations: int = 100,
|
|
|
109
122
|
"""
|
|
110
123
|
ids = sorted(n["id"] for n in graph["nodes"])
|
|
111
124
|
n = len(ids) or 1
|
|
112
|
-
adj =
|
|
113
|
-
deg = {nid:
|
|
125
|
+
adj = _wadj(graph)
|
|
126
|
+
deg = {nid: sum(adj.get(nid, {}).values()) for nid in ids} # weighted degree
|
|
114
127
|
rank = {nid: 1.0 / n for nid in ids}
|
|
115
128
|
base = (1.0 - damping) / n
|
|
116
129
|
for _ in range(iterations):
|
|
@@ -119,8 +132,8 @@ def pagerank(graph: dict, damping: float = 0.85, iterations: int = 100,
|
|
|
119
132
|
for nid in ids:
|
|
120
133
|
if deg[nid]:
|
|
121
134
|
share = damping * rank[nid] / deg[nid]
|
|
122
|
-
for m in sorted(adj[nid]):
|
|
123
|
-
nxt[m] += share
|
|
135
|
+
for m, w in sorted(adj[nid].items()):
|
|
136
|
+
nxt[m] += share * w
|
|
124
137
|
if sum(abs(nxt[nid] - rank[nid]) for nid in ids) < tol:
|
|
125
138
|
rank = nxt
|
|
126
139
|
break
|
|
@@ -29,7 +29,11 @@ def fuse(root: Path, query: str, rules: list[Rule], top: int = 6,
|
|
|
29
29
|
if semantic:
|
|
30
30
|
rankings.append(semantic)
|
|
31
31
|
|
|
32
|
-
|
|
32
|
+
# Seed graph proximity from the lexical anchors — but fall back to the semantic
|
|
33
|
+
# top when lexical is empty, which is exactly the paraphrase case hybrid exists
|
|
34
|
+
# to rescue (no shared keyword → no lexical hit → the graph leg would otherwise
|
|
35
|
+
# go dark). plan §17.1 / recall-engine.compare.md phase-2 trigger.
|
|
36
|
+
graphical = _graph_ranking(root, (lexical or semantic)[:3])
|
|
33
37
|
if graphical:
|
|
34
38
|
rankings.append([rid for rid in graphical if rid in by_id])
|
|
35
39
|
|
|
@@ -0,0 +1,100 @@
|
|
|
1
|
+
"""`fux impact <file>` — downstream blast radius of changing a file ($0, §18.1).
|
|
2
|
+
|
|
3
|
+
The "maintain code" brain capability: before you touch a file, see what it can
|
|
4
|
+
break — invariants to re-verify, governing rules whose *why* may go stale, and
|
|
5
|
+
the callers that depend on its symbols. Stdlib graph traversal; no LLM.
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from fux import explain, graphquery
|
|
13
|
+
from fux.model import Rule
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
@dataclass
|
|
17
|
+
class Impact:
|
|
18
|
+
target: str
|
|
19
|
+
symbols: list[str] = field(default_factory=list) # symbols defined here
|
|
20
|
+
governing: list[Rule] = field(default_factory=list) # rules that govern it
|
|
21
|
+
invariants: list[Rule] = field(default_factory=list) # governing ⊃ machine-checkable
|
|
22
|
+
callers: list[str] = field(default_factory=list) # precise `calls` dependents
|
|
23
|
+
referenced_by: list[str] = field(default_factory=list) # loose `references` (INFERRED)
|
|
24
|
+
related: list[str] = field(default_factory=list) # one-hop related rule ids
|
|
25
|
+
in_graph: bool = True
|
|
26
|
+
|
|
27
|
+
@property
|
|
28
|
+
def empty(self) -> bool:
|
|
29
|
+
return not (self.governing or self.callers or self.referenced_by)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _norm(file_rel: str) -> str:
|
|
33
|
+
return file_rel.replace("\\", "/").lstrip("./").rstrip("/")
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def run(root: Path, file_rel: str) -> Impact:
|
|
37
|
+
"""Compute the blast radius of editing ``file_rel`` from the built graph."""
|
|
38
|
+
target = _norm(file_rel)
|
|
39
|
+
graph = graphquery.load(root) # raises SystemExit if no graph yet
|
|
40
|
+
ids = {n["id"] for n in graph["nodes"]}
|
|
41
|
+
# Symbols defined in the file (contains edges: file → symbol).
|
|
42
|
+
symbols = sorted(e["target"] for e in graph["edges"]
|
|
43
|
+
if e["type"] == "contains" and e["source"] == target)
|
|
44
|
+
sym_set = set(symbols) | {target}
|
|
45
|
+
|
|
46
|
+
# Split precise `calls` from loose `references` (INFERRED) so real callers
|
|
47
|
+
# aren't drowned by generic-name collisions (value/total/get).
|
|
48
|
+
callers: set[str] = set()
|
|
49
|
+
referenced_by: set[str] = set()
|
|
50
|
+
for e in graph["edges"]:
|
|
51
|
+
if e["target"] not in sym_set:
|
|
52
|
+
continue
|
|
53
|
+
dependent = e["source"].split("::", 1)[0]
|
|
54
|
+
if dependent == target:
|
|
55
|
+
continue
|
|
56
|
+
if e["type"] == "calls":
|
|
57
|
+
callers.add(dependent)
|
|
58
|
+
elif e["type"] == "references":
|
|
59
|
+
referenced_by.add(dependent)
|
|
60
|
+
referenced_by -= callers # a precise call beats a loose ref
|
|
61
|
+
|
|
62
|
+
governing = explain.refs(root, target)
|
|
63
|
+
invariants = [r for r in governing if r.type == "invariant" or r.fm.get("check")]
|
|
64
|
+
related = sorted({rid for r in governing for rid in r.related})
|
|
65
|
+
|
|
66
|
+
return Impact(target=target, symbols=symbols, governing=governing,
|
|
67
|
+
invariants=invariants, callers=sorted(callers),
|
|
68
|
+
referenced_by=sorted(referenced_by),
|
|
69
|
+
related=related, in_graph=target in ids)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def render(im: Impact) -> str:
|
|
73
|
+
"""A maintenance checklist, ordered by how expensive it is to get wrong."""
|
|
74
|
+
out = [f"# impact of changing {im.target}"]
|
|
75
|
+
if not im.in_graph:
|
|
76
|
+
out.append("_(not a graphed file — `fux build` may be stale, or the path is wrong)_")
|
|
77
|
+
inv_ids = {r.id for r in im.invariants}
|
|
78
|
+
|
|
79
|
+
def section(title: str, items: list[str], cap: int | None = None) -> None:
|
|
80
|
+
if not items:
|
|
81
|
+
return
|
|
82
|
+
out.extend(["", f"## {title}"])
|
|
83
|
+
out.extend(f"- {x}" for x in (items[:cap] if cap else items))
|
|
84
|
+
if cap and len(items) > cap:
|
|
85
|
+
out.append(f" …and {len(items) - cap} more")
|
|
86
|
+
|
|
87
|
+
section("Invariants that must still hold — run `fux verify`",
|
|
88
|
+
[f"**{r.id}** ({r.type})" + (f" — `{r.fm['check']}`" if r.fm.get("check") else "")
|
|
89
|
+
for r in im.invariants])
|
|
90
|
+
section("Governing rules — update the *why* if behaviour changed",
|
|
91
|
+
[f"{r.id} ({r.type}) — {r.title}" for r in im.governing if r.id not in inv_ids])
|
|
92
|
+
if im.related:
|
|
93
|
+
out += ["", "## Related knowledge to review", " " + ", ".join(im.related)]
|
|
94
|
+
section(f"Downstream callers ({len(im.callers)} file(s) call into this — re-test)", im.callers)
|
|
95
|
+
section(f"Possibly affected ({len(im.referenced_by)} reference these names — lower confidence)",
|
|
96
|
+
im.referenced_by, cap=10)
|
|
97
|
+
if im.empty:
|
|
98
|
+
out += ["", "No governing rules and no downstream callers found — low blast "
|
|
99
|
+
"radius, or this file isn't graphed/governed yet."]
|
|
100
|
+
return "\n".join(out).strip() + "\n"
|
|
@@ -16,8 +16,8 @@ import json
|
|
|
16
16
|
import sys
|
|
17
17
|
from pathlib import Path
|
|
18
18
|
|
|
19
|
-
from fux import (__version__, coverage, explain, graphquery,
|
|
20
|
-
savings, scaffold, stats)
|
|
19
|
+
from fux import (__version__, components, coverage, explain, graphquery, impact,
|
|
20
|
+
paths, recall, savings, scaffold, stats, uispec)
|
|
21
21
|
|
|
22
22
|
PROTOCOL = "2024-11-05"
|
|
23
23
|
|
|
@@ -34,6 +34,23 @@ TOOLS = [
|
|
|
34
34
|
"description": "Reverse lookup — which rules govern a given file path.",
|
|
35
35
|
"inputSchema": {"type": "object", "required": ["file"],
|
|
36
36
|
"properties": {"file": {"type": "string"}}}},
|
|
37
|
+
{"name": "fux_impact",
|
|
38
|
+
"description": "Downstream blast radius of changing a file: governing rules whose "
|
|
39
|
+
"why may go stale, invariants to re-verify, and caller files that may break.",
|
|
40
|
+
"inputSchema": {"type": "object", "required": ["file"],
|
|
41
|
+
"properties": {"file": {"type": "string"}}}},
|
|
42
|
+
{"name": "fux_components",
|
|
43
|
+
"description": "The design-system registry + data-binding catalog (JSON): UI components "
|
|
44
|
+
"with their prop fields, data hooks (use*), and DTO shapes — so a generated "
|
|
45
|
+
"component composes from real primitives and binds to real data.",
|
|
46
|
+
"inputSchema": {"type": "object", "properties": {
|
|
47
|
+
"scope": {"type": "string"}}}},
|
|
48
|
+
{"name": "fux_validate_spec",
|
|
49
|
+
"description": "Validate a declarative UISpec against the registry before mounting — "
|
|
50
|
+
"rejects unknown components, undeclared props, and unknown data hooks. "
|
|
51
|
+
"Returns 'valid' or the list of violations. The runtime guardrail.",
|
|
52
|
+
"inputSchema": {"type": "object", "required": ["spec"], "properties": {
|
|
53
|
+
"spec": {"type": "object", "description": "the UISpec node tree"}}}},
|
|
37
54
|
{"name": "fux_coverage",
|
|
38
55
|
"description": "Percent of important code files that carry a governing rule.",
|
|
39
56
|
"inputSchema": {"type": "object", "properties": {}}},
|
|
@@ -83,6 +100,13 @@ def _call(name: str, args: dict) -> str:
|
|
|
83
100
|
hits = explain.refs(root, args["file"])
|
|
84
101
|
return "\n".join(f"{r.id} ({r.type}) — {r.title}" for r in hits) \
|
|
85
102
|
or f"(no rules govern {args['file']})"
|
|
103
|
+
if name == "fux_impact":
|
|
104
|
+
return impact.render(impact.run(root, args["file"]))
|
|
105
|
+
if name == "fux_components":
|
|
106
|
+
return components.render_json(components.registry(root, scope=args.get("scope")))
|
|
107
|
+
if name == "fux_validate_spec":
|
|
108
|
+
errs = uispec.validate(components.registry(root), args["spec"])
|
|
109
|
+
return uispec.render(not errs, errs)
|
|
86
110
|
if name == "fux_coverage":
|
|
87
111
|
c = coverage.run(root)
|
|
88
112
|
return f"{c.pct:.0f}% ({c.governed}/{c.total} important files governed)"
|
|
@@ -15,6 +15,7 @@ def render(graph: dict) -> str:
|
|
|
15
15
|
f"{meta.get('code_files', 0)} code files · {meta.get('rules', 0)} rules · "
|
|
16
16
|
f"{meta.get('communities', 0)} communities._", ""]
|
|
17
17
|
lines += _types(nodes)
|
|
18
|
+
lines += _edges(graph["edges"])
|
|
18
19
|
lines += _god(graph, by_id)
|
|
19
20
|
lines += _chokepoints(graph, by_id)
|
|
20
21
|
lines += _communities(nodes)
|
|
@@ -28,6 +29,16 @@ def _types(nodes: list[dict]) -> list[str]:
|
|
|
28
29
|
return out + [""]
|
|
29
30
|
|
|
30
31
|
|
|
32
|
+
def _edges(edges: list[dict]) -> list[str]:
|
|
33
|
+
"""Edge mix by type + confidence — INFERRED edges are the loose, down-weighted ones."""
|
|
34
|
+
by_type = Counter(e.get("type") for e in edges)
|
|
35
|
+
inferred = sum(1 for e in edges if e.get("confidence") == "INFERRED")
|
|
36
|
+
out = ["## Edges", "", f"_{inferred} of {len(edges)} are INFERRED "
|
|
37
|
+
"(low-confidence `references`, down-weighted in clustering/centrality)._", ""]
|
|
38
|
+
out += [f"- {t}: {c}" for t, c in sorted(by_type.items(), key=lambda kv: -kv[1])]
|
|
39
|
+
return out + [""]
|
|
40
|
+
|
|
41
|
+
|
|
31
42
|
def _god(graph: dict, by_id: dict) -> list[str]:
|
|
32
43
|
out = ["## God nodes (highest connectivity)", ""]
|
|
33
44
|
for nid, deg in graphquery.god_nodes(graph, 12):
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""`fux validate-spec` — validate a declarative UISpec against the registry ($0, §18.3.3).
|
|
2
|
+
|
|
3
|
+
The mount-time guardrail for Orff's on-the-fly generation: a generated UI may only
|
|
4
|
+
compose **registry** components, with **declared** props, bound to **known** data
|
|
5
|
+
hooks. Anything else is rejected before it can render. This is what makes runtime
|
|
6
|
+
generation safe — the model emits a declarative tree, never code; the frontend
|
|
7
|
+
renders it from a whitelist. Pure structural validation; no code execution, no LLM.
|
|
8
|
+
|
|
9
|
+
UISpec node: {"component": "Card", "props": {...}, "data": "useHoldings",
|
|
10
|
+
"children": [ <node>, {"text": "literal"} ]}
|
|
11
|
+
"""
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
|
|
17
|
+
from fux import components
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def validate(reg: dict, spec: object) -> list[str]:
|
|
21
|
+
"""Return a list of violations; empty means the spec is safe to mount."""
|
|
22
|
+
by_name = {c["name"]: c for c in reg["components"]}
|
|
23
|
+
hooks = {h["name"] for h in reg["hooks"]}
|
|
24
|
+
errs: list[str] = []
|
|
25
|
+
_node(spec, "$", by_name, hooks, errs)
|
|
26
|
+
return errs
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _node(node: object, path: str, by_name: dict, hooks: set, errs: list[str]) -> None:
|
|
30
|
+
if isinstance(node, dict) and "text" in node and "component" not in node:
|
|
31
|
+
return # literal text leaf
|
|
32
|
+
if not isinstance(node, dict) or "component" not in node:
|
|
33
|
+
errs.append(f"{path}: node needs a 'component' or 'text'")
|
|
34
|
+
return
|
|
35
|
+
name = node["component"]
|
|
36
|
+
comp = by_name.get(name)
|
|
37
|
+
if comp is None:
|
|
38
|
+
errs.append(f"{path}: unknown component '{name}' — not in the registry")
|
|
39
|
+
return
|
|
40
|
+
prop_names = {p["name"] for p in comp["props"]}
|
|
41
|
+
required = {p["name"] for p in comp["props"] if not p["optional"]} - {"children", "className"}
|
|
42
|
+
here = f"{path}.{name}"
|
|
43
|
+
props = node.get("props") or {}
|
|
44
|
+
errs += [f"{here}: prop '{k}' not on {name}" for k in props if k not in prop_names]
|
|
45
|
+
errs += [f"{here}: missing required prop '{r}'" for r in sorted(required - set(props))]
|
|
46
|
+
if (data := node.get("data")) and data not in hooks:
|
|
47
|
+
errs.append(f"{here}: data hook '{data}' not in the registry")
|
|
48
|
+
children = node.get("children") or []
|
|
49
|
+
if children and "children" not in prop_names:
|
|
50
|
+
errs.append(f"{here}: {name} takes no children")
|
|
51
|
+
for i, ch in enumerate(children):
|
|
52
|
+
_node(ch, f"{here}[{i}]", by_name, hooks, errs)
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def run(root: Path, spec_path: Path) -> tuple[bool, list[str]]:
|
|
56
|
+
reg = components.registry(root)
|
|
57
|
+
errs = validate(reg, json.loads(spec_path.read_text(encoding="utf-8")))
|
|
58
|
+
return not errs, errs
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def render(ok: bool, errs: list[str]) -> str:
|
|
62
|
+
if ok:
|
|
63
|
+
return "✔ spec valid — every component, prop, and data hook is in the registry\n"
|
|
64
|
+
return "✘ spec rejected:\n" + "\n".join(f" - {e}" for e in errs) + "\n"
|