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 ADDED
@@ -0,0 +1,4 @@
1
+ """FM DDR Analyzer — parse FileMaker Database Design Reports into queryable SQLite."""
2
+ __version__ = "1.3.0"
3
+
4
+ from .parse import build # noqa: F401
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()