fmsonar 1.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.
- fm_ddr/__init__.py +4 -0
- fm_ddr/cli.py +469 -0
- fm_ddr/parse.py +646 -0
- fm_ddr/report.py +122 -0
- fm_ddr/resolve.sql +189 -0
- fm_ddr/schema.sql +89 -0
- fm_ddr/skill/SKILL.md +113 -0
- fm_ddr/snippet.py +147 -0
- fm_ddr/web/index.html +1782 -0
- fmsonar-1.3.0.dist-info/METADATA +330 -0
- fmsonar-1.3.0.dist-info/RECORD +15 -0
- fmsonar-1.3.0.dist-info/WHEEL +5 -0
- fmsonar-1.3.0.dist-info/entry_points.txt +3 -0
- fmsonar-1.3.0.dist-info/licenses/LICENSE +21 -0
- fmsonar-1.3.0.dist-info/top_level.txt +1 -0
fm_ddr/__init__.py
ADDED
fm_ddr/cli.py
ADDED
|
@@ -0,0 +1,469 @@
|
|
|
1
|
+
"""Command-line interface for the FM DDR analyzer.
|
|
2
|
+
|
|
3
|
+
python -m fm_ddr.cli build DDR.xml [-o out.db] [--label NAME]
|
|
4
|
+
python -m fm_ddr.cli where out.db "TO::Field" | "ScriptName" | "Layout"
|
|
5
|
+
python -m fm_ddr.cli search out.db "some text"
|
|
6
|
+
python -m fm_ddr.cli sql out.db "SELECT ..."
|
|
7
|
+
python -m fm_ddr.cli stats out.db
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import argparse
|
|
11
|
+
import os
|
|
12
|
+
import sqlite3
|
|
13
|
+
import sys
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _rows(conn, q, params=()):
|
|
17
|
+
cur = conn.execute(q, params)
|
|
18
|
+
cols = [c[0] for c in cur.description]
|
|
19
|
+
return cols, cur.fetchall()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def _print_table(cols, rows, limit=200, full=False):
|
|
23
|
+
if not rows:
|
|
24
|
+
print("(no rows)")
|
|
25
|
+
return
|
|
26
|
+
cap = 10**9 if full else 80
|
|
27
|
+
widths = [len(c) for c in cols]
|
|
28
|
+
shown = rows[:limit]
|
|
29
|
+
for r in shown:
|
|
30
|
+
for i, v in enumerate(r):
|
|
31
|
+
widths[i] = min(max(widths[i], len(str(v)) if v is not None else 4), cap)
|
|
32
|
+
line = " ".join(c.ljust(widths[i]) for i, c in enumerate(cols))
|
|
33
|
+
print(line)
|
|
34
|
+
print(" ".join("-" * widths[i] for i in range(len(cols))))
|
|
35
|
+
for r in shown:
|
|
36
|
+
print(" ".join((str(v) if v is not None else "").ljust(widths[i])[:cap]
|
|
37
|
+
for i, v in enumerate(r)))
|
|
38
|
+
if len(rows) > limit:
|
|
39
|
+
print(f"... {len(rows) - limit} more rows")
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def cmd_build(args):
|
|
43
|
+
from .parse import build
|
|
44
|
+
out = args.out or os.path.splitext(args.ddr[0])[0] + ".db"
|
|
45
|
+
print(f"Parsing {len(args.ddr)} file(s) -> {out} ...", file=sys.stderr)
|
|
46
|
+
summary = build(args.ddr, out, label=args.label, force=getattr(args, "force", False))
|
|
47
|
+
print(f"Done: {out}")
|
|
48
|
+
for k, v in sorted(summary.items(), key=lambda kv: (-kv[1], kv[0])):
|
|
49
|
+
print(f" {k:24s} {v}")
|
|
50
|
+
total, resolved = summary.get("_refs", 0), summary.get("_refs_resolved", 0)
|
|
51
|
+
if total and resolved / total < 0.95:
|
|
52
|
+
conn = sqlite3.connect(out)
|
|
53
|
+
ver = conn.execute("SELECT ddr_version FROM ddr_run LIMIT 1").fetchone()[0]
|
|
54
|
+
conn.close()
|
|
55
|
+
print(f"\nWARNING: only {100*resolved//total}% of references resolved "
|
|
56
|
+
f"(DDR version {ver}). On a healthy single-file solution expect ~98%. "
|
|
57
|
+
f"Low resolution usually means an unmapped FM-version construct or a "
|
|
58
|
+
f"multi-file solution — check `stats` / v_unresolved.", file=sys.stderr)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _resolve_term(conn, term):
|
|
62
|
+
"""Resolve a search term to concrete entities. 'TO::field' resolves through
|
|
63
|
+
the table occurrence; a bare name matches any entity kind by exact name."""
|
|
64
|
+
if "::" in term:
|
|
65
|
+
to_name, leaf = term.split("::", 1)
|
|
66
|
+
return conn.execute("""
|
|
67
|
+
SELECT f.entity_id, f.kind, f.base_table || '::' || f.name
|
|
68
|
+
FROM entities f
|
|
69
|
+
WHERE f.kind = 'field' AND f.name = ?
|
|
70
|
+
AND f.base_table = (SELECT t.base_table FROM entities t
|
|
71
|
+
WHERE t.kind = 'table_occurrence' AND t.name = ?
|
|
72
|
+
LIMIT 1)
|
|
73
|
+
ORDER BY f.entity_id""", (leaf, to_name)).fetchall()
|
|
74
|
+
return conn.execute("""
|
|
75
|
+
SELECT entity_id, kind,
|
|
76
|
+
CASE WHEN kind = 'field' THEN base_table || '::' || name ELSE name END
|
|
77
|
+
FROM entities
|
|
78
|
+
WHERE name = ? AND kind IN ('field','script','layout','table_occurrence',
|
|
79
|
+
'custom_function','value_list','base_table')
|
|
80
|
+
ORDER BY kind, entity_id""", (term,)).fetchall()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _connect(db_path):
|
|
85
|
+
"""Open an index and warn if it was built by an older parser than the one
|
|
86
|
+
running now — a stale index silently lacks newer reference types (learned
|
|
87
|
+
the hard way: an index built pre-step_target reported 98% healthy while
|
|
88
|
+
missing every Set Field write target)."""
|
|
89
|
+
conn = sqlite3.connect(db_path)
|
|
90
|
+
try:
|
|
91
|
+
row = conn.execute("SELECT parser_version FROM ddr_run LIMIT 1").fetchone()
|
|
92
|
+
built_with = row[0] if row else None
|
|
93
|
+
except sqlite3.OperationalError:
|
|
94
|
+
built_with = None # pre-1.2.0 schema: column doesn't exist
|
|
95
|
+
from fm_ddr import __version__
|
|
96
|
+
if built_with != __version__:
|
|
97
|
+
print(f"WARNING: index built with fm_ddr {built_with or '<1.2.0'}, "
|
|
98
|
+
f"running {__version__} — newer parsers capture more references. "
|
|
99
|
+
f"Rebuild: fm-ddr build <ddr.xml...> -o {db_path} --force",
|
|
100
|
+
file=sys.stderr)
|
|
101
|
+
return conn
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def cmd_where(args):
|
|
105
|
+
"""Where is a field / script / layout / TO / CF used?
|
|
106
|
+
|
|
107
|
+
Resolves the term to concrete entities first, then reports references per
|
|
108
|
+
entity — a bare field name that exists in several tables is reported per
|
|
109
|
+
table, never lumped together."""
|
|
110
|
+
conn = _connect(args.db)
|
|
111
|
+
term = args.name
|
|
112
|
+
targets = _resolve_term(conn, term)
|
|
113
|
+
if not targets:
|
|
114
|
+
print(f"No entity named '{term}' found. Try `search` for free text, "
|
|
115
|
+
f"or qualify a field as TO::field.")
|
|
116
|
+
return
|
|
117
|
+
if len(targets) > 1:
|
|
118
|
+
labels = ", ".join(f"{label} ({kind})" for _, kind, label in targets)
|
|
119
|
+
print(f"'{term}' matches {len(targets)} entities: {labels}")
|
|
120
|
+
print("Showing references per entity. Qualify as TO::field to narrow.\n")
|
|
121
|
+
for entity_id, kind, label in targets:
|
|
122
|
+
cols, rows = _rows(conn,
|
|
123
|
+
"""SELECT context, source_kind, source_parent_name, source_name,
|
|
124
|
+
ambiguous
|
|
125
|
+
FROM v_usage WHERE target_id = ?
|
|
126
|
+
ORDER BY context, source_kind, source_parent_name, source_name""",
|
|
127
|
+
(entity_id,))
|
|
128
|
+
print(f"# {label} ({kind}) — {len(rows)} reference(s)")
|
|
129
|
+
if rows:
|
|
130
|
+
_print_table(cols, rows, limit=args.limit)
|
|
131
|
+
else:
|
|
132
|
+
print("(none — but see COVERAGE.md for what references are not captured)")
|
|
133
|
+
print()
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def cmd_search(args):
|
|
137
|
+
conn = _connect(args.db)
|
|
138
|
+
sql = ("""SELECT ti.kind, ti.name, snippet(text_index, 4, '[', ']', '...', 12) AS match
|
|
139
|
+
FROM text_index ti
|
|
140
|
+
WHERE text_index MATCH ? ORDER BY rank LIMIT ?""")
|
|
141
|
+
try:
|
|
142
|
+
cols, rows = _rows(conn, sql, (args.query, args.limit))
|
|
143
|
+
except sqlite3.OperationalError:
|
|
144
|
+
# The query used FTS5 operator syntax that didn't parse (unbalanced
|
|
145
|
+
# quotes/parens, a bare AND/NEAR, a colon). Fall back to treating the
|
|
146
|
+
# whole thing as a literal phrase so a plain search never crashes.
|
|
147
|
+
literal = '"' + args.query.replace('"', '""') + '"'
|
|
148
|
+
cols, rows = _rows(conn, sql, (literal, args.limit))
|
|
149
|
+
print(f"# Text search '{args.query}' ({len(rows)} hits)\n")
|
|
150
|
+
_print_table(cols, rows, limit=args.limit)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def cmd_sql(args):
|
|
154
|
+
conn = _connect(args.db)
|
|
155
|
+
cols, rows = _rows(conn, args.query)
|
|
156
|
+
_print_table(cols, rows, limit=args.limit, full=getattr(args, "full", False))
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def cmd_snippet(args):
|
|
160
|
+
from .snippet import snippet
|
|
161
|
+
res = snippet(args.ddr, args.script, out_path=args.out,
|
|
162
|
+
to_clipboard=args.clip, script_id=getattr(args, "id", None))
|
|
163
|
+
print(f"{res['steps']} steps -> {res['bytes']} bytes of fmxmlsnippet")
|
|
164
|
+
if res["out"]:
|
|
165
|
+
print(f"written to {res['out']}")
|
|
166
|
+
if res["clipboard"]:
|
|
167
|
+
print("on the clipboard (XMSS) - paste into FileMaker Script Workspace")
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
def cmd_investigate(args):
|
|
172
|
+
"""One-shot neighborhood report for a script: everything the investigation
|
|
173
|
+
protocol demands, in one command — survey context (callers incl. layout
|
|
174
|
+
buttons WITH their launch params), callees, $$global hygiene, and the full
|
|
175
|
+
body. Exists because every A/B-judged miss traced to one of these being
|
|
176
|
+
skipped when it took a separate query."""
|
|
177
|
+
conn = _connect(args.db)
|
|
178
|
+
# resolve the script (exact name, LIKE, or fm_id)
|
|
179
|
+
rows = conn.execute(
|
|
180
|
+
"SELECT entity_id, fm_id, name, grp FROM entities WHERE kind='script' "
|
|
181
|
+
"AND (name = ? OR fm_id = ?)", (args.script, args.script)).fetchall()
|
|
182
|
+
if not rows:
|
|
183
|
+
rows = conn.execute(
|
|
184
|
+
"SELECT entity_id, fm_id, name, grp FROM entities WHERE kind='script' "
|
|
185
|
+
"AND name LIKE ? ORDER BY name", (f"%{args.script}%",)).fetchall()
|
|
186
|
+
if not rows:
|
|
187
|
+
print(f"No script matching '{args.script}'.")
|
|
188
|
+
return
|
|
189
|
+
if len(rows) > 1:
|
|
190
|
+
print(f"'{args.script}' matches {len(rows)} scripts — pick one (name or fm_id):")
|
|
191
|
+
for _, fmid, nm, grp in rows[:30]:
|
|
192
|
+
print(f" [{fmid}] {nm}" + (f" ({grp})" if grp else ""))
|
|
193
|
+
return
|
|
194
|
+
eid, fmid, name, grp = rows[0]
|
|
195
|
+
nsteps = conn.execute(
|
|
196
|
+
"SELECT COUNT(*) FROM entities WHERE parent_entity_id=? AND kind='script_step'",
|
|
197
|
+
(eid,)).fetchone()[0]
|
|
198
|
+
print(f"# {name}")
|
|
199
|
+
print(f"fm_id {fmid} · group {grp or '-'} · {nsteps} steps\n")
|
|
200
|
+
|
|
201
|
+
# ---- callers (scripts, triggers, layout buttons) ----
|
|
202
|
+
callers = conn.execute("""
|
|
203
|
+
SELECT context, source_kind, source_parent_name, source_name, COUNT(*)
|
|
204
|
+
FROM v_usage WHERE target_id = ?
|
|
205
|
+
GROUP BY context, source_kind, source_parent_name, source_name
|
|
206
|
+
ORDER BY context, source_parent_name""", (eid,)).fetchall()
|
|
207
|
+
print(f"## Callers ({len(callers)})")
|
|
208
|
+
if not callers:
|
|
209
|
+
print("(none found — may still run from a menu, external file, or schedule)")
|
|
210
|
+
for ctx, sk, spn, sn, n in callers:
|
|
211
|
+
where = f"{spn} > {sn}" if spn and sn else (sn or spn or "?")
|
|
212
|
+
print(f" {ctx:15} {sk:14} {where}" + (f" x{n}" if n > 1 else ""))
|
|
213
|
+
|
|
214
|
+
# ---- layout launch sites with their params (the round-2 lesson) ----
|
|
215
|
+
launches = conn.execute("""
|
|
216
|
+
WITH RECURSIVE up(entity_id, parent, name, kind, top) AS (
|
|
217
|
+
SELECT o.entity_id, o.parent_entity_id, o.name, o.kind, o.entity_id
|
|
218
|
+
FROM refs r JOIN entities o ON o.entity_id = r.source_entity_id
|
|
219
|
+
WHERE r.target_entity_id = ? AND o.kind='layout_object'
|
|
220
|
+
UNION ALL
|
|
221
|
+
SELECT p.entity_id, p.parent_entity_id, p.name, p.kind, up.top
|
|
222
|
+
FROM up JOIN entities p ON p.entity_id = up.parent
|
|
223
|
+
WHERE up.kind != 'layout'
|
|
224
|
+
)
|
|
225
|
+
SELECT DISTINCT
|
|
226
|
+
(SELECT name FROM up u2 WHERE u2.top = up.top AND u2.kind='layout'),
|
|
227
|
+
o.name,
|
|
228
|
+
json_extract(o.extra_json,'$.object_type'),
|
|
229
|
+
json_extract(o.extra_json,'$.step_text'),
|
|
230
|
+
json_extract(o.extra_json,'$.hide_calc')
|
|
231
|
+
FROM up JOIN entities o ON o.entity_id = up.top
|
|
232
|
+
WHERE up.kind='layout'""", (eid,)).fetchall()
|
|
233
|
+
if launches:
|
|
234
|
+
print(f"\n## Layout launch sites ({len(launches)}) — check the params")
|
|
235
|
+
for lay, oname, otype, stext, hide in launches:
|
|
236
|
+
print(f" layout {lay!r} · {otype or 'object'}"
|
|
237
|
+
+ (f" {oname!r}" if oname else ""))
|
|
238
|
+
if stext:
|
|
239
|
+
print(" " + (stext or "").replace("\n", "\n ")[:2000])
|
|
240
|
+
if hide:
|
|
241
|
+
print(f" hidden when: {hide[:300]}")
|
|
242
|
+
|
|
243
|
+
# ---- callees ----
|
|
244
|
+
callees = conn.execute("""
|
|
245
|
+
SELECT DISTINCT u.target_name FROM refs r
|
|
246
|
+
JOIN entities st ON st.entity_id = r.source_entity_id
|
|
247
|
+
AND st.parent_entity_id = ?
|
|
248
|
+
JOIN v_usage u ON u.ref_id = r.ref_id
|
|
249
|
+
WHERE u.context='perform_script' ORDER BY 1""", (eid,)).fetchall()
|
|
250
|
+
print(f"\n## Calls out to ({len(callees)})")
|
|
251
|
+
for (nm,) in callees:
|
|
252
|
+
print(f" {nm}")
|
|
253
|
+
|
|
254
|
+
# ---- $$global hygiene (write-only detector) ----
|
|
255
|
+
hygiene = conn.execute("""
|
|
256
|
+
WITH t AS (
|
|
257
|
+
SELECT json_extract(s.extra_json,'$.step_text') AS txt
|
|
258
|
+
FROM entities s WHERE s.parent_entity_id = ? AND s.kind='script_step'
|
|
259
|
+
AND json_extract(s.extra_json,'$.step_text') LIKE 'Set Variable [ $$%'
|
|
260
|
+
), w AS (
|
|
261
|
+
SELECT DISTINCT substr(txt, instr(txt,'$$'),
|
|
262
|
+
CASE WHEN instr(substr(txt,instr(txt,'$$')),';') > 0
|
|
263
|
+
THEN instr(substr(txt,instr(txt,'$$')),';') - 1 ELSE 40 END) AS var
|
|
264
|
+
FROM t
|
|
265
|
+
)
|
|
266
|
+
SELECT w.var,
|
|
267
|
+
(SELECT COUNT(*) FROM entities st WHERE st.kind='script_step'
|
|
268
|
+
AND json_extract(st.extra_json,'$.step_text')
|
|
269
|
+
LIKE '%'||w.var||'%'
|
|
270
|
+
AND json_extract(st.extra_json,'$.step_text')
|
|
271
|
+
NOT LIKE 'Set Variable [ '||w.var||';%') AS other_mentions
|
|
272
|
+
FROM w ORDER BY other_mentions""", (eid,)).fetchall()
|
|
273
|
+
if hygiene:
|
|
274
|
+
print(f"\n## $$globals written here ({len(hygiene)})")
|
|
275
|
+
for var, om in hygiene:
|
|
276
|
+
flag = " <-- WRITE-ONLY in steps? confirm with: fm-ddr search <db> '\"%s\"'" % var.lstrip("$") if om == 0 else ""
|
|
277
|
+
print(f" {var:48} step-mentions elsewhere: {om}{flag}")
|
|
278
|
+
|
|
279
|
+
# ---- body ----
|
|
280
|
+
if not args.no_body:
|
|
281
|
+
print(f"\n## Body ({nsteps} steps)")
|
|
282
|
+
for seq, stype, txt in conn.execute("""
|
|
283
|
+
SELECT seq, step_type, json_extract(extra_json,'$.step_text')
|
|
284
|
+
FROM entities WHERE parent_entity_id=? AND kind='script_step'
|
|
285
|
+
ORDER BY entity_id""", (eid,)):
|
|
286
|
+
print(f" {txt or stype or ''}")
|
|
287
|
+
|
|
288
|
+
|
|
289
|
+
SKILL_RAW_URL = ("https://raw.githubusercontent.com/oogi-io/fm-ddr-analyzer/"
|
|
290
|
+
"main/fm_ddr/skill/SKILL.md")
|
|
291
|
+
|
|
292
|
+
|
|
293
|
+
def cmd_install_skill(args):
|
|
294
|
+
"""Install the fmsonar skill for Claude Code, or check its freshness.
|
|
295
|
+
|
|
296
|
+
--check compare the installed skill against the one shipped with this
|
|
297
|
+
fm_ddr version (fast, offline). Exit 0 = up to date, 1 = differs,
|
|
298
|
+
2 = not installed.
|
|
299
|
+
--remote compare against the current GitHub main instead (network).
|
|
300
|
+
Neither flag: (re)install the packaged skill.
|
|
301
|
+
"""
|
|
302
|
+
import hashlib
|
|
303
|
+
import shutil
|
|
304
|
+
|
|
305
|
+
src = os.path.join(os.path.dirname(os.path.abspath(__file__)), "skill", "SKILL.md")
|
|
306
|
+
dest_dir = os.path.expanduser("~/.claude/skills/fmsonar")
|
|
307
|
+
dest = os.path.join(dest_dir, "SKILL.md")
|
|
308
|
+
from fm_ddr import __version__
|
|
309
|
+
|
|
310
|
+
def digest(b):
|
|
311
|
+
return hashlib.sha256(b).hexdigest()[:12]
|
|
312
|
+
|
|
313
|
+
if args.check or args.remote:
|
|
314
|
+
if not os.path.exists(dest):
|
|
315
|
+
print(f"fmsonar skill is not installed ({dest} missing). "
|
|
316
|
+
f"Run: fm-ddr install-skill")
|
|
317
|
+
sys.exit(2)
|
|
318
|
+
installed = open(dest, "rb").read()
|
|
319
|
+
if args.remote:
|
|
320
|
+
import urllib.request
|
|
321
|
+
try:
|
|
322
|
+
ref = urllib.request.urlopen(SKILL_RAW_URL, timeout=10).read()
|
|
323
|
+
except Exception as e: # noqa: BLE001 — report and bail, offline is fine
|
|
324
|
+
print(f"Could not fetch {SKILL_RAW_URL}: {e}")
|
|
325
|
+
sys.exit(2)
|
|
326
|
+
ref_label = "GitHub main"
|
|
327
|
+
hint = ("update your install first (pipx upgrade fmsonar / pipx reinstall fmsonar, "
|
|
328
|
+
"or git pull in a clone), then: fm-ddr install-skill")
|
|
329
|
+
else:
|
|
330
|
+
ref = open(src, "rb").read()
|
|
331
|
+
ref_label = f"the skill shipped with fm_ddr {__version__}"
|
|
332
|
+
hint = "run: fm-ddr install-skill"
|
|
333
|
+
if digest(installed) == digest(ref):
|
|
334
|
+
print(f"fmsonar skill is up to date with {ref_label}.")
|
|
335
|
+
sys.exit(0)
|
|
336
|
+
print(f"fmsonar skill DIFFERS from {ref_label} "
|
|
337
|
+
f"(installed {digest(installed)}, reference {digest(ref)}).")
|
|
338
|
+
print(f"To update: {hint}")
|
|
339
|
+
sys.exit(1)
|
|
340
|
+
|
|
341
|
+
os.makedirs(dest_dir, exist_ok=True)
|
|
342
|
+
shutil.copy(src, dest)
|
|
343
|
+
print(f"Installed the 'fmsonar' skill (fm_ddr {__version__}) -> {dest_dir}")
|
|
344
|
+
print("Claude Code can now analyze FileMaker DDRs from ANY directory -")
|
|
345
|
+
print("just mention a DDR or ask a where-used question.")
|
|
346
|
+
print("Freshness check anytime: fm-ddr install-skill --check "
|
|
347
|
+
"(or --remote to compare against GitHub main)")
|
|
348
|
+
|
|
349
|
+
|
|
350
|
+
def cmd_clip(args):
|
|
351
|
+
from .snippet import clip_text_to_fm
|
|
352
|
+
n = clip_text_to_fm()
|
|
353
|
+
print(f"{n} bytes -> FileMaker clipboard (XMSS); paste into Script Workspace")
|
|
354
|
+
|
|
355
|
+
|
|
356
|
+
def cmd_report(args):
|
|
357
|
+
from .report import report
|
|
358
|
+
out = report(args.db, args.out)
|
|
359
|
+
print(f"Wrote {out}")
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
def cmd_stats(args):
|
|
363
|
+
conn = _connect(args.db)
|
|
364
|
+
print("# Entity counts")
|
|
365
|
+
_, rows = _rows(conn, "SELECT kind, COUNT(*) n FROM entities GROUP BY kind ORDER BY n DESC")
|
|
366
|
+
for kind, n in rows:
|
|
367
|
+
print(f" {kind:22s} {n}")
|
|
368
|
+
tot = conn.execute("SELECT COUNT(*) FROM refs").fetchone()[0]
|
|
369
|
+
res = conn.execute("SELECT COUNT(*) FROM refs WHERE target_entity_id IS NOT NULL").fetchone()[0]
|
|
370
|
+
print(f"\n# References: {tot} total, {res} resolved ({100*res//max(tot,1)}%)")
|
|
371
|
+
print("\n# Unresolved by kind/context (external / built-in / broken)")
|
|
372
|
+
_, rows = _rows(conn, "SELECT * FROM v_unresolved")
|
|
373
|
+
_print_table(["target_kind", "context", "n"], rows)
|
|
374
|
+
|
|
375
|
+
|
|
376
|
+
def main(argv=None):
|
|
377
|
+
p = argparse.ArgumentParser(prog="fm_ddr", description="FileMaker DDR analyzer")
|
|
378
|
+
p.add_argument("--debug", action="store_true",
|
|
379
|
+
help="show the full Python traceback on error")
|
|
380
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
381
|
+
|
|
382
|
+
b = sub.add_parser("build",
|
|
383
|
+
help="parse DDR XML(s) or a Summary.xml manifest into SQLite")
|
|
384
|
+
b.add_argument("ddr", nargs="+",
|
|
385
|
+
help="one or more *_fmp12.xml files, or a single Summary.xml")
|
|
386
|
+
b.add_argument("-o", "--out")
|
|
387
|
+
b.add_argument("--label")
|
|
388
|
+
b.add_argument("--force", action="store_true",
|
|
389
|
+
help="overwrite the -o file even if it is not an fmsonar database")
|
|
390
|
+
b.set_defaults(func=cmd_build)
|
|
391
|
+
|
|
392
|
+
w = sub.add_parser("where", help="where is a field/script/layout/TO/CF used")
|
|
393
|
+
w.add_argument("db")
|
|
394
|
+
w.add_argument("name")
|
|
395
|
+
w.add_argument("--limit", type=int, default=200)
|
|
396
|
+
w.set_defaults(func=cmd_where)
|
|
397
|
+
|
|
398
|
+
s = sub.add_parser("search", help="full-text search across calcs/steps/names")
|
|
399
|
+
s.add_argument("db")
|
|
400
|
+
s.add_argument("query")
|
|
401
|
+
s.add_argument("--limit", type=int, default=50)
|
|
402
|
+
s.set_defaults(func=cmd_search)
|
|
403
|
+
|
|
404
|
+
q = sub.add_parser("sql", help="run arbitrary SQL")
|
|
405
|
+
q.add_argument("db")
|
|
406
|
+
q.add_argument("query")
|
|
407
|
+
q.add_argument("--limit", type=int, default=200)
|
|
408
|
+
q.add_argument("--full", action="store_true",
|
|
409
|
+
help="no cell truncation (default clips cells at 80 chars)")
|
|
410
|
+
q.set_defaults(func=cmd_sql)
|
|
411
|
+
|
|
412
|
+
inv = sub.add_parser("investigate",
|
|
413
|
+
help="one-shot neighborhood report for a script "
|
|
414
|
+
"(callers, layout launch params, callees, "
|
|
415
|
+
"$$global hygiene, body)")
|
|
416
|
+
inv.add_argument("db")
|
|
417
|
+
inv.add_argument("script", help="script name (exact or partial) or fm_id")
|
|
418
|
+
inv.add_argument("--no-body", action="store_true",
|
|
419
|
+
help="omit the full step body")
|
|
420
|
+
inv.set_defaults(func=cmd_investigate)
|
|
421
|
+
|
|
422
|
+
sn = sub.add_parser("snippet",
|
|
423
|
+
help="copy a script's steps as a FileMaker clipboard snippet")
|
|
424
|
+
sn.add_argument("ddr", help="DDR XML file containing the script")
|
|
425
|
+
sn.add_argument("script", help="script name (exact)")
|
|
426
|
+
sn.add_argument("--id", help="FileMaker script id, to disambiguate a duplicate name")
|
|
427
|
+
sn.add_argument("-o", "--out", help="write fmxmlsnippet XML to this file")
|
|
428
|
+
sn.add_argument("--clip", action="store_true",
|
|
429
|
+
help="place on the macOS clipboard (XMSS flavor) for direct paste")
|
|
430
|
+
sn.set_defaults(func=cmd_snippet)
|
|
431
|
+
|
|
432
|
+
ins = sub.add_parser("install-skill",
|
|
433
|
+
help="install the Claude Code skill globally (~/.claude/skills)")
|
|
434
|
+
ins.add_argument("--check", action="store_true",
|
|
435
|
+
help="don't install; report whether the installed skill "
|
|
436
|
+
"matches the one shipped with this fm_ddr version")
|
|
437
|
+
ins.add_argument("--remote", action="store_true",
|
|
438
|
+
help="don't install; compare the installed skill against "
|
|
439
|
+
"GitHub main (network)")
|
|
440
|
+
ins.set_defaults(func=cmd_install_skill)
|
|
441
|
+
|
|
442
|
+
cl = sub.add_parser("clip",
|
|
443
|
+
help="convert snippet XML text on the clipboard to FileMaker objects (macOS)")
|
|
444
|
+
cl.set_defaults(func=cmd_clip)
|
|
445
|
+
|
|
446
|
+
rp = sub.add_parser("report", help="generate a self-contained interactive HTML viewer")
|
|
447
|
+
rp.add_argument("db")
|
|
448
|
+
rp.add_argument("-o", "--out")
|
|
449
|
+
rp.set_defaults(func=cmd_report)
|
|
450
|
+
|
|
451
|
+
st = sub.add_parser("stats", help="entity + reference counts")
|
|
452
|
+
st.add_argument("db")
|
|
453
|
+
st.set_defaults(func=cmd_stats)
|
|
454
|
+
|
|
455
|
+
args = p.parse_args(argv)
|
|
456
|
+
try:
|
|
457
|
+
args.func(args)
|
|
458
|
+
except (BrokenPipeError, KeyboardInterrupt):
|
|
459
|
+
raise
|
|
460
|
+
except Exception as e:
|
|
461
|
+
if args.debug:
|
|
462
|
+
raise
|
|
463
|
+
msg = str(e) or e.__class__.__name__
|
|
464
|
+
print(f"error: {msg}", file=sys.stderr)
|
|
465
|
+
sys.exit(1)
|
|
466
|
+
|
|
467
|
+
|
|
468
|
+
if __name__ == "__main__":
|
|
469
|
+
main()
|