ambitgraph 0.8.2__tar.gz → 0.8.4__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.
Files changed (103) hide show
  1. {ambitgraph-0.8.2/ambitgraph.egg-info → ambitgraph-0.8.4}/PKG-INFO +1 -1
  2. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_telegraph/responsibility.py +76 -24
  3. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/cli.py +5 -4
  4. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/__init__.py +7 -1
  5. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/graph.js +8 -2
  6. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/library.js +7 -1
  7. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/search.js +6 -1
  8. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/siderail.js +16 -5
  9. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/wholemap.js +13 -2
  10. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/responsibility.py +83 -36
  11. {ambitgraph-0.8.2 → ambitgraph-0.8.4/ambitgraph.egg-info}/PKG-INFO +1 -1
  12. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/pyproject.toml +6 -1
  13. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/LICENSE +0 -0
  14. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/MANIFEST.in +0 -0
  15. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/README.md +0 -0
  16. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_contracts/__init__.py +0 -0
  17. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_contracts/edge.py +0 -0
  18. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_contracts/envelope.py +0 -0
  19. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_contracts/fields.py +0 -0
  20. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_contracts/grant.py +0 -0
  21. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_contracts/household.py +0 -0
  22. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/__init__.py +0 -0
  23. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/__main__.py +0 -0
  24. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/ambit-map-enrich.md +0 -0
  25. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/ambit-mapllm-contract.md +0 -0
  26. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/cli.py +0 -0
  27. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/daemon.py +0 -0
  28. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/__init__.py +0 -0
  29. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/contract.py +0 -0
  30. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/express_pack.py +0 -0
  31. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/fastapi_pack.py +0 -0
  32. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/flask_pack.py +0 -0
  33. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/nextjs_pack.py +0 -0
  34. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/dialects/supabase_pack.py +0 -0
  35. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/explain.py +0 -0
  36. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/families.py +0 -0
  37. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/langmirror.py +0 -0
  38. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/load.py +0 -0
  39. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/mapllm.py +0 -0
  40. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/model.py +0 -0
  41. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/orchestrate.py +0 -0
  42. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/registry.py +0 -0
  43. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_map/serve.py +0 -0
  44. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_telegraph/__init__.py +0 -0
  45. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_telegraph/cli.py +0 -0
  46. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_telegraph/export.py +0 -0
  47. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_telegraph/ledger.py +0 -0
  48. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambit_telegraph/store.py +0 -0
  49. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/WALKTHROUGH.md +0 -0
  50. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/__init__.py +0 -0
  51. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/__main__.py +0 -0
  52. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/_delimited.py +0 -0
  53. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/cockpit.py +0 -0
  54. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/codelib.py +0 -0
  55. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/config.py +0 -0
  56. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/console.py +0 -0
  57. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/demo.py +0 -0
  58. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/design_tokens.py +0 -0
  59. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/docs_discovery.py +0 -0
  60. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/doctor.py +0 -0
  61. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/duplication.py +0 -0
  62. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/edges.py +0 -0
  63. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/emit.py +0 -0
  64. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/engine_identity.py +0 -0
  65. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/estimate.py +0 -0
  66. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/eval.py +0 -0
  67. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/feeds.py +0 -0
  68. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/gate.py +0 -0
  69. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/gate_check.py +0 -0
  70. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/gate_server.py +0 -0
  71. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/harvest.py +0 -0
  72. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/inventory.py +0 -0
  73. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/jscalls.py +0 -0
  74. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/langs.py +0 -0
  75. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/mds.py +0 -0
  76. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/merge.py +0 -0
  77. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/merge_jsp.py +0 -0
  78. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/_init_js.py +0 -0
  79. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/filterpanel.js +0 -0
  80. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/next_embed.css +0 -0
  81. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/omniscient_next/standalone.py +0 -0
  82. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/parameterize.py +0 -0
  83. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/pipeline.py +0 -0
  84. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/portal.py +0 -0
  85. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/prompts.py +0 -0
  86. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/pycalls.py +0 -0
  87. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/ratify.py +0 -0
  88. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/report.py +0 -0
  89. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/reports.py +0 -0
  90. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/sample.py +0 -0
  91. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/secretary.py +0 -0
  92. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/shell.py +0 -0
  93. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/stages.py +0 -0
  94. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/symbols.py +0 -0
  95. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/tables.py +0 -0
  96. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/tasks.py +0 -0
  97. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph/urcode.py +0 -0
  98. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph.egg-info/SOURCES.txt +0 -0
  99. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph.egg-info/dependency_links.txt +0 -0
  100. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph.egg-info/entry_points.txt +0 -0
  101. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph.egg-info/requires.txt +0 -0
  102. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/ambitgraph.egg-info/top_level.txt +0 -0
  103. {ambitgraph-0.8.2 → ambitgraph-0.8.4}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ambitgraph
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Read-only codebase scan: reusable units, families, roles, ownership candidates, duplication.
5
5
  Author: AmbitGraph
6
6
  License: Proprietary
@@ -17,6 +17,17 @@ the exact chain, latest line wins, so a transfer is an off record plus an
17
17
  on record and the file itself is the transfer history. Two different
18
18
  chains both on for one node are two owners, not a conflict.
19
19
 
20
+ A node ending with "/" is a folder node; any other node is a file node
21
+ (queries for a file path never end with "/"). A file's own exact record
22
+ does not replace the nearest covering folder's record -- it ADDS to it:
23
+ a file answers through its own on-records first, then through the on-
24
+ records standing at the nearest folder that covers it. A sub-folder's own
25
+ claim is itself a folder node, so it carves its subtree out of any outer
26
+ folder's reach. Turning a file's exact chain off closes only that
27
+ chain's claim on the file; the folder's owners are untouched. Querying a
28
+ folder node itself is unaffected: the longest matching folder prefix
29
+ still wins alone.
30
+
20
31
  Authorship law: a record's `set_by` must already stand in the chain
21
32
  strictly before the first element the record changes relative to the
22
33
  standing lineage it extends or supersedes; a chain's root is changed only
@@ -185,36 +196,77 @@ def _covers(rec_node: str, node: str) -> bool:
185
196
  return rec_node.endswith("/") and node.startswith(rec_node)
186
197
 
187
198
 
199
+ def _resolve_one(rec, *, now, gone) -> "Resolution":
200
+ """One standing on-record turned into its Resolution: the holder is the
201
+ deepest chain element that is neither departed nor (for the acting,
202
+ last element) past its expiry."""
203
+ live = []
204
+ # pair each holder with its root-first depth, so the acting (last)
205
+ # holder can be singled out below for the expiry check
206
+ for position, holder in enumerate(rec.chain):
207
+ if holder in gone:
208
+ break
209
+ # only the chain's last element is the acting holder -- an expiry
210
+ # never applies to a delegator further up the chain
211
+ acting = position == len(rec.chain) - 1
212
+ if acting and rec.expires is not None \
213
+ and now >= _parse_ts(rec.expires, "expires"):
214
+ break
215
+ live.append(holder)
216
+ # the deepest surviving holder answers; an empty walk means nobody does
217
+ return Resolution(record=rec, chain=rec.chain,
218
+ holder=live[-1] if live else None)
219
+
220
+
188
221
  def resolve_owners(records, node, *, now, gone=frozenset()) -> tuple:
189
222
  """Who owns `node` right now? Pure: the clock arrives as `now`, the
190
223
  departed arrive as `gone`, and nothing here touches the file system.
191
224
 
192
- The longest matching node prefix wins; every on-record standing at that
193
- winning node is one owner. Each owner's holder is the deepest chain
194
- element that is neither in `gone` nor past its expiry (the expiry
195
- applies to the acting holder alone) — the longest surviving prefix
196
- answers, so a departure never orphans an area."""
225
+ A folder query (`node` ends with "/") keeps the plain longest-prefix
226
+ rule: every on-record standing at the longest matching folder node is
227
+ one owner. A file query answers with every on-record standing at the
228
+ file's own exact node, PLUS every on-record standing at the nearest
229
+ covering folder (the longest folder node that reaches it) -- a file
230
+ claim adds an owner beside its folder's, it does not replace it, and a
231
+ sub-folder's own claim still carves that sub-folder out of any outer
232
+ folder. The exact owners come first, then the folder owners, each
233
+ group in its own standing order -- one Resolution per standing
234
+ on-record, always: two records whose chains both resolve to the same
235
+ holder are two owners sharing that holder, never merged into one.
236
+ Each owner's holder is the deepest chain element that is neither in
237
+ `gone` nor past its expiry (the expiry applies to the acting holder
238
+ alone) -- the longest surviving prefix answers, so a departure never
239
+ orphans an area."""
197
240
  node = _require_node(node)
198
241
  standing = _latest_by_lineage(records)
199
- matches = [rec for rec in standing.values() if _covers(rec.node, node)]
200
- if not matches:
201
- return ()
202
- winning = max(len(rec.node) for rec in matches)
203
- out = []
204
- for rec in matches:
205
- if len(rec.node) != winning or rec.state != "on":
206
- continue
207
- live = []
208
- for position, holder in enumerate(rec.chain):
209
- if holder in gone:
210
- break
211
- acting = position == len(rec.chain) - 1
212
- if acting and rec.expires is not None \
213
- and now >= _parse_ts(rec.expires, "expires"):
214
- break
215
- live.append(holder)
216
- out.append(Resolution(record=rec, chain=rec.chain,
217
- holder=live[-1] if live else None))
242
+ if node.endswith("/"):
243
+ # Folder query: today's rule, unchanged -- the longest covering
244
+ # node wins alone, whatever its depth.
245
+ matches = [rec for rec in standing.values() if _covers(rec.node, node)]
246
+ if not matches:
247
+ return ()
248
+ winning = max(len(rec.node) for rec in matches)
249
+ return tuple(_resolve_one(rec, now=now, gone=gone)
250
+ for rec in matches
251
+ if len(rec.node) == winning and rec.state == "on")
252
+ # File query: every record standing at the exact node...
253
+ exact = [rec for rec in standing.values() if rec.node == node]
254
+ # ...plus every folder node that covers this file, ON OR OFF -- an off
255
+ # record must still be counted here, or a nearer folder that is off
256
+ # would stop masking the outer folder and an outer on-record would
257
+ # wrongly reach past the nearer one
258
+ folders = [rec for rec in standing.values()
259
+ if rec.node.endswith("/") and _covers(rec.node, node)]
260
+ # the exact owners resolve first, in their own standing order, on-only
261
+ out = [_resolve_one(rec, now=now, gone=gone)
262
+ for rec in exact if rec.state == "on"]
263
+ if folders:
264
+ # only the longest (nearest) covering folder node is consulted --
265
+ # an outer folder never reaches past a nearer one
266
+ nearest = max(len(rec.node) for rec in folders)
267
+ out.extend(_resolve_one(rec, now=now, gone=gone)
268
+ for rec in folders
269
+ if len(rec.node) == nearest and rec.state == "on")
218
270
  return tuple(out)
219
271
 
220
272
 
@@ -2560,10 +2560,11 @@ def build_parser() -> argparse.ArgumentParser:
2560
2560
  "read by construction, and a derived default would trade "
2561
2561
  "that guarantee for a convention")
2562
2562
 
2563
- ow = tsub.add_parser("owner", help="name who answers for a node -- the "
2564
- "holder and the full chain, read from the repo's "
2565
- "own responsibility record; an unowned node is an "
2566
- "answer, never an error")
2563
+ ow = tsub.add_parser("owner", help="who answers for this node: every "
2564
+ "owner, the file's own claim first, then its "
2565
+ "folder's, each through its chain -- read from "
2566
+ "the repo's own responsibility record; an unowned "
2567
+ "node is an answer, never an error")
2567
2568
  ow.add_argument("node", help="the node whose owner to name (a file or "
2568
2569
  "folder path as the map writes it)")
2569
2570
  ow.add_argument("--repo", default=".",
@@ -140,7 +140,13 @@ def render_screen(run_path: str) -> str:
140
140
  scripts (modules + payload + wiring). The caller (shell.py) owns the
141
141
  absence branch; calling this without data is a build error."""
142
142
  data = build_data(run_path)
143
- payload = json.dumps(data, separators=(",", ":")).replace("</", "<\\/")
143
+ # EVERY `<` becomes an escaped unicode point, not just the ones that open a
144
+ # closing tag. Replacing `</` alone defends against `</script>` and against
145
+ # nothing else: a scanned name containing `<!--` opens a comment inside the
146
+ # script block and swallows the payload after it, and `<script` opens a
147
+ # second one. The escape is invisible to JSON.parse, so the data a reader
148
+ # gets is unchanged -- this is the cockpit's own pattern, not a new one.
149
+ payload = json.dumps(data, separators=(",", ":")).replace("<", "\\u003c")
144
150
  return (
145
151
  '<div class="cp-next-shell">'
146
152
  '<div class="sh-top">'
@@ -153,7 +153,13 @@ class SingleGraph {
153
153
  destroy(){ this.container.innerHTML=""; }
154
154
 
155
155
  /* ---------------- internals ---------------- */
156
- _esc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;"); }
156
+ /* All five characters that change how markup is read. This pane writes a
157
+ folder's name into data-expand="...", so an unescaped quote in a directory
158
+ name ends that attribute early and the rest of the name is read as markup.
159
+ The ampersand is replaced FIRST, or a later substitution's own output is
160
+ escaped a second time. */
161
+ _esc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")
162
+ .replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"); }
157
163
 
158
164
  _neigh(applyFilter=true){
159
165
  // inbound/outbound entries, deduped per file, weights summed
@@ -214,7 +220,7 @@ class SingleGraph {
214
220
  '<div class="sg-e-name"><span class="sg-e-dot" style="background:'+
215
221
  (FC[n.f]||"#8b96a8")+'"></span>'+esc(n.id.split("/").pop())+'</div>'+
216
222
  '<div class="sg-e-path">'+esc(n.id)+'</div>'+
217
- '<div class="sg-e-meta">'+n.d+' links · '+n.in+' inbound · '+n.f+'/</div></div>'; });
223
+ '<div class="sg-e-meta">'+n.d+' links · '+n.in+' inbound · '+esc(n.f)+'/</div></div>'; });
218
224
  h+='</div></div>';
219
225
  this._canvas.innerHTML=h;
220
226
  }
@@ -74,7 +74,13 @@ class LibraryView {
74
74
  if (i >= 0) { this._state = {...this._state, level:"file", file:i}; this._render(); } }
75
75
  home() { this._state = {...this._state, level:"home"}; this._render(); }
76
76
 
77
- _esc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;"); }
77
+ /* All five characters that change how markup is read. Two are enough for
78
+ text between tags and are no defence at all for a value placed inside a
79
+ quoted attribute, where one unescaped quote ends the attribute early and
80
+ everything after it is read as markup. The ampersand is replaced FIRST,
81
+ or a later substitution's own output is escaped a second time. */
82
+ _esc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")
83
+ .replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"); }
78
84
  _cat(key){ return this._CATS.find(c => c[0] === key); }
79
85
 
80
86
  _click(e) {
@@ -61,7 +61,12 @@ class SearchDrop {
61
61
  if (m.syms.length) { return this._data.nodes[m.syms[0][1]].id; }
62
62
  return null;
63
63
  }
64
- _esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;"); }
64
+ /* All five characters that change how markup is read. Two are enough for
65
+ text between tags and are no defence at all for a value placed inside a
66
+ quoted attribute. The ampersand is replaced FIRST, or a later
67
+ substitution's own output is escaped a second time. */
68
+ _esc(s) { return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;")
69
+ .replace(/>/g, "&gt;").replace(/"/g, "&quot;").replace(/'/g, "&#39;"); }
65
70
  _render() {
66
71
  const SEARCH_ROW_CAP = 12;
67
72
  const m = this.matches(this._input.value);
@@ -3,6 +3,17 @@
3
3
  navigation lands (a test pins that retirement). Architecture constraints:
4
4
  one view, one class, all CSS class names prefixed to avoid collisions,
5
5
  and only the documented public surface is used. */
6
+ /* All five characters that change how markup is read. This module had none:
7
+ its labels and notes are file and folder names from the scanned repository
8
+ and they were written straight into innerHTML, so a name decided markup on
9
+ a page that opens by default. The ampersand is replaced FIRST, or a later
10
+ substitution's own output is escaped a second time.
11
+ The name is module-local because every module is concatenated into one
12
+ script and a bare `esc` would collide; one shared escaper replacing all of
13
+ these is the next release's work, not this patch's. */
14
+ function _srEsc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")
15
+ .replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"); }
16
+
6
17
  /* ============================================================
7
18
  SideRail — the left rail with NESTED quick-nav: entry points
8
19
  nested under Graph, categories under Library, each group
@@ -36,8 +47,8 @@ class SideRail {
36
47
  b.className = "sr-item"; b.tabIndex = 0; b.setAttribute("role", "button");
37
48
  b.innerHTML =
38
49
  (it.children ? "<span class='sr-caret'>▸</span>" : "<span class='sr-caret sr-blank'></span>") +
39
- "<span class='sr-label'>" + it.label + "</span>" +
40
- (it.note ? "<span class='sr-note'>" + it.note + "</span>" : "");
50
+ "<span class='sr-label'>" + _srEsc(it.label) + "</span>" +
51
+ (it.note ? "<span class='sr-note'>" + _srEsc(it.note) + "</span>" : "");
41
52
  const switchTo = () => { if (typeof opts.onSwitch === "function") opts.onSwitch(it.key); };
42
53
  b.querySelector(".sr-label").onclick = switchTo;
43
54
  if (it.note) b.querySelector(".sr-note").onclick = switchTo;
@@ -58,9 +69,9 @@ class SideRail {
58
69
  it.children.forEach(c => {
59
70
  const k = document.createElement("div");
60
71
  k.className = "sr-kid"; k.tabIndex = 0; k.setAttribute("role", "button");
61
- k.innerHTML = (c.color ? "<span class='sr-dot' style='background:"+c.color+"'></span>" : "") +
62
- "<span class='sr-klabel'>" + c.label + "</span>" +
63
- (c.note ? "<span class='sr-note'>" + c.note + "</span>" : "");
72
+ k.innerHTML = (c.color ? "<span class='sr-dot' style='background:"+_srEsc(c.color)+"'></span>" : "") +
73
+ "<span class='sr-klabel'>" + _srEsc(c.label) + "</span>" +
74
+ (c.note ? "<span class='sr-note'>" + _srEsc(c.note) + "</span>" : "");
64
75
  k.onclick = () => c.onClick && c.onClick();
65
76
  k.addEventListener("keydown", e => { if (e.key === "Enter") k.onclick(); });
66
77
  kids.appendChild(k);
@@ -2,6 +2,17 @@
2
2
  implementation and is expected to be retired once the map product view
3
3
  ships (a test pins this until then). Architecture constraints: one view,
4
4
  one class, CSS prefixed to this module, public surface only. */
5
+ /* All five characters that change how markup is read. This module had none:
6
+ the folder legend wrote a scanned folder's name straight into innerHTML, on
7
+ the pane the page opens on, so the name decided markup with no click at all.
8
+ The ampersand is replaced FIRST, or a later substitution's own output is
9
+ escaped a second time.
10
+ The name is module-local because every module is concatenated into one
11
+ script and a bare `esc` would collide; one shared escaper replacing all of
12
+ these is the next release's work, not this patch's. */
13
+ function _wmEsc(s){ return String(s).replace(/&/g,"&amp;").replace(/</g,"&lt;")
14
+ .replace(/>/g,"&gt;").replace(/"/g,"&quot;").replace(/'/g,"&#39;"); }
15
+
5
16
  /* ============================================================
6
17
  WholeMap — the whole-graph view, as its own module.
7
18
 
@@ -471,8 +482,8 @@ class WholeMap {
471
482
  /* chips — computed from THIS repo's data, never assumed */
472
483
  data.folders.forEach(f=>{ const [name,count]=f;
473
484
  const el=document.createElement("span"); el.className="wm-chip"; el.tabIndex=0;
474
- el.innerHTML="<span class='wm-sw' style='background:"+folderColor[name]+"'></span>"
475
- +name+" "+count;
485
+ el.innerHTML="<span class='wm-sw' style='background:"+_wmEsc(folderColor[name])+"'></span>"
486
+ +_wmEsc(name)+" "+count;
476
487
  el.onclick=()=>{ traced = traced===name? null : name;
477
488
  chipbar.querySelectorAll(".wm-chip").forEach(c=>c.classList.remove("wm-on"));
478
489
  if(traced) el.classList.add("wm-on");
@@ -16,7 +16,16 @@ Absence is not an alarm: a repository with no record yields an empty
16
16
  overlay and the map renders exactly as it always did. A torn record
17
17
  yields only a damaged marker under "__damaged__", never a crash and never
18
18
  rows -- a half-read record could mis-assign, so a damaged file assigns
19
- nothing and says why."""
19
+ nothing and says why.
20
+
21
+ A node ending with "/" is a folder node; any other node is a file node.
22
+ A file's own exact record does not replace the nearest covering folder's
23
+ record -- it ADDS to it: the unit's rows carry its own on-records first,
24
+ then the nearest covering folder's on-records. A sub-folder's own claim
25
+ is itself a folder node, so it carves its subtree out of any outer
26
+ folder's reach; turning a file's exact chain off closes only that
27
+ chain's row, leaving the folder's rows untouched. A folder unit query is
28
+ unaffected: the longest matching folder prefix still wins alone."""
20
29
 
21
30
  from __future__ import annotations
22
31
 
@@ -76,6 +85,39 @@ def _covers(rec_node: str, unit: str) -> bool:
76
85
  and unit.startswith(rec_node))
77
86
 
78
87
 
88
+ def _row_for(rec, now):
89
+ """One standing on-record turned into its overlay row: holder is the
90
+ deepest chain element still live against `now`, expiry applying only
91
+ to the acting (last) element."""
92
+ # the record's chain arrives as a plain JSON list; make it a tuple so
93
+ # every row's chain is the same immutable shape the record library uses
94
+ chain = tuple(rec["chain"])
95
+ expires = rec.get("expires")
96
+ # Walk root-first; the expiry applies to the acting holder alone, so a
97
+ # passed expiry ends the walk before the last element and the answer
98
+ # rolls up the surviving prefix.
99
+ live = []
100
+ for position, holder in enumerate(chain):
101
+ # only the chain's last element is the acting holder -- an expiry
102
+ # never applies to a delegator further up the chain
103
+ acting = position == len(chain) - 1
104
+ if acting and expires is not None \
105
+ and now >= _parse_ts(expires):
106
+ break
107
+ live.append(holder)
108
+ # the row this record answers with: the deepest surviving holder (None
109
+ # when the whole walk broke before adding one), the full chain, the
110
+ # reason, the node that covered the query, and the guest-term fields
111
+ return {
112
+ "holder": live[-1] if live else None,
113
+ "chain": chain,
114
+ "why": rec.get("why", ""),
115
+ "via": rec["node"],
116
+ "guest": expires is not None,
117
+ "expires": expires,
118
+ }
119
+
120
+
79
121
  def load_ownership(repo_root, unit_ids, *, now) -> dict:
80
122
  """{unit_id: (owner_row, ...)} for every unit the record covers; units
81
123
  the record does not reach are absent, never present-and-empty.
@@ -84,8 +126,15 @@ def load_ownership(repo_root, unit_ids, *, now) -> dict:
84
126
  element is gone), chain, why, via (the record node that covered the
85
127
  unit -- a folder when ownership is inherited), guest (an expiry is
86
128
  set) and expires. The reduction mirrors the record library: latest
87
- line wins per (node, exact chain); the longest matching node prefix
88
- owns the unit; two chains both on are two owners; an expiry on the
129
+ line wins per (node, exact chain). A folder unit (ends with "/") keeps
130
+ the plain longest-prefix rule. A file unit answers with its own
131
+ exact on-records first, then the on-records standing at the nearest
132
+ covering folder (the longest folder node reaching it) -- a file claim
133
+ adds a row beside its folder's, it does not replace it, and a
134
+ sub-folder's own claim carves its subtree out of any outer folder --
135
+ one row per standing on-record, always: two records whose chains both
136
+ resolve to the same holder are two owners sharing that holder, never
137
+ merged into one. Two chains both on are two owners; an expiry on the
89
138
  acting holder rolls the answer up the surviving prefix against the
90
139
  caller's `now` -- this function reads no clock of its own."""
91
140
  path = Path(repo_root) / RESPONSIBILITY_RELPATH
@@ -101,40 +150,38 @@ def load_ownership(repo_root, unit_ids, *, now) -> dict:
101
150
  standing[(rec["node"], tuple(rec["chain"]))] = rec
102
151
  overlay = {}
103
152
  for unit in unit_ids:
104
- # Every standing record whose node reaches this unit, any state --
105
- # an OFF standing at a deeper node must still take the winning
106
- # length, masking a shallower ON, exactly as the record library
107
- # decides it.
108
- matches = [rec for rec in standing.values()
109
- if _covers(rec["node"], unit)]
110
- if not matches:
111
- continue
112
- # The longest matching node string owns the unit.
113
- winning = max(len(rec["node"]) for rec in matches)
114
- rows = []
115
- for rec in matches:
116
- if len(rec["node"]) != winning or rec["state"] != "on":
153
+ if unit.endswith("/"):
154
+ # Folder query: today's rule, unchanged -- the longest
155
+ # covering node wins alone, whatever its depth.
156
+ matches = [rec for rec in standing.values()
157
+ if _covers(rec["node"], unit)]
158
+ if not matches:
117
159
  continue
118
- chain = tuple(rec["chain"])
119
- expires = rec.get("expires")
120
- # Walk root-first; the expiry applies to the acting holder
121
- # alone, so a passed expiry ends the walk before the last
122
- # element and the answer rolls up the surviving prefix.
123
- live = []
124
- for position, holder in enumerate(chain):
125
- acting = position == len(chain) - 1
126
- if acting and expires is not None \
127
- and now >= _parse_ts(expires):
128
- break
129
- live.append(holder)
130
- rows.append({
131
- "holder": live[-1] if live else None,
132
- "chain": chain,
133
- "why": rec.get("why", ""),
134
- "via": rec["node"],
135
- "guest": expires is not None,
136
- "expires": expires,
137
- })
160
+ winning = max(len(rec["node"]) for rec in matches)
161
+ rows = [_row_for(rec, now) for rec in matches
162
+ if len(rec["node"]) == winning and rec["state"] == "on"]
163
+ else:
164
+ # File query: every record standing at the exact node...
165
+ exact = [rec for rec in standing.values() if rec["node"] == unit]
166
+ # ...plus every folder node that covers this file, ON OR OFF --
167
+ # an off record must still be counted here, or a nearer folder
168
+ # that is off would stop masking the outer folder and an outer
169
+ # on-record would wrongly reach past the nearer one
170
+ folders = [rec for rec in standing.values()
171
+ if rec["node"].endswith("/")
172
+ and _covers(rec["node"], unit)]
173
+ # the exact rows resolve first, in their own standing order,
174
+ # on-only
175
+ rows = [_row_for(rec, now) for rec in exact
176
+ if rec["state"] == "on"]
177
+ if folders:
178
+ # only the longest (nearest) covering folder node is
179
+ # consulted -- an outer folder never reaches past a nearer
180
+ # one
181
+ nearest = max(len(rec["node"]) for rec in folders)
182
+ rows.extend(_row_for(rec, now) for rec in folders
183
+ if len(rec["node"]) == nearest
184
+ and rec["state"] == "on")
138
185
  if rows:
139
186
  overlay[unit] = tuple(rows)
140
187
  return overlay
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: ambitgraph
3
- Version: 0.8.2
3
+ Version: 0.8.4
4
4
  Summary: Read-only codebase scan: reusable units, families, roles, ownership candidates, duplication.
5
5
  Author: AmbitGraph
6
6
  License: Proprietary
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "ambitgraph"
7
- version = "0.8.2"
7
+ version = "0.8.4"
8
8
  description = "Read-only codebase scan: reusable units, families, roles, ownership candidates, duplication."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11"
@@ -67,3 +67,8 @@ ambit_map = ["ambit-map-enrich.md", "ambit-mapllm-contract.md"]
67
67
  [tool.pytest.ini_options]
68
68
  testpaths = ["tests"]
69
69
  pythonpath = ["."]
70
+ # an unregistered mark refuses instead of silently selecting nothing
71
+ addopts = "--strict-markers"
72
+ markers = [
73
+ "timing: asserts on a measured duration, a deadline wait, or a product timing constant; runs in the timing leg under a machine window",
74
+ ]
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes