@polycode-projects/seonix 0.2.1 → 0.4.0

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.
package/src/browser.mjs CHANGED
@@ -14,7 +14,7 @@ import { execFile } from "node:child_process";
14
14
  import { dirname, join } from "node:path";
15
15
  import { fileURLToPath } from "node:url";
16
16
  import { promisify } from "node:util";
17
- import { deriveNodeInterval, deriveEdgeInterval } from "./temporal.mjs";
17
+ import { deriveNodeInterval, deriveEdgeInterval, repoOfId, assignRepo, isMultiRepo } from "./temporal.mjs";
18
18
 
19
19
  const here = dirname(fileURLToPath(import.meta.url));
20
20
  const execFileP = promisify(execFile);
@@ -79,8 +79,22 @@ const attr = (ind, key) => {
79
79
  return a ? String(a.value) : "";
80
80
  };
81
81
 
82
- /** Ordered (oldest → newest) shas for the graph's Commit individuals via git log. */
83
- export async function gitCommitOrder(repo, graphCommitIds) {
82
+ /**
83
+ * Ordered (oldest → newest) shas for the graph's Commit individuals via git log.
84
+ *
85
+ * A MERGED multi-repo graph (extract.mjs indexRepositories) has no single git repo to
86
+ * shell — `repo` is the common-ancestor out_root, which is usually not itself a git
87
+ * checkout — so `git log` there returns nothing that matches. In that case, if the
88
+ * caller supplies `datesBySha` (commit sha → ISO commit date), fall back to a global
89
+ * chronological order across all repos. Single-repo callers pass no dates and keep the
90
+ * existing git path unchanged. (buildTemporalGraph independently re-derives the
91
+ * date-order when it detects a merge, so a viz caller that passes only cwd is still
92
+ * correct; this fallback makes gitCommitOrder itself repo-set-agnostic and testable.)
93
+ * @param {string} repo
94
+ * @param {Iterable<string>} graphCommitIds
95
+ * @param {{datesBySha?: Map<string,string>|null}} [opts]
96
+ */
97
+ export async function gitCommitOrder(repo, graphCommitIds, { datesBySha = null } = {}) {
84
98
  const shas = new Set([...graphCommitIds].map((id) => String(id).replace(/^commit:/, "")));
85
99
  let ordered = [];
86
100
  try {
@@ -89,7 +103,18 @@ export async function gitCommitOrder(repo, graphCommitIds) {
89
103
  });
90
104
  ordered = stdout.trim().split("\n").reverse().filter((s) => shas.has(s));
91
105
  } catch {
92
- /* fall through to graph order */
106
+ /* fall through to graph/date order */
107
+ }
108
+ if (!ordered.length && datesBySha) {
109
+ // Merged-graph fallback: one commit date axis across every repo (tie-break by sha
110
+ // so equal timestamps stay deterministic).
111
+ ordered = [...shas].sort((a, b) => {
112
+ const da = datesBySha.get(a) || "";
113
+ const db = datesBySha.get(b) || "";
114
+ if (da < db) return -1;
115
+ if (da > db) return 1;
116
+ return a < b ? -1 : a > b ? 1 : 0;
117
+ });
93
118
  }
94
119
  if (!ordered.length) ordered = [...shas];
95
120
  return ordered;
@@ -137,9 +162,40 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
137
162
 
138
163
  const commitInds = individuals.filter((i) => i.class === "Commit");
139
164
  const commitById = new Map(commitInds.map((c) => [c.id, c]));
140
- const order = (orderShas && orderShas.length)
141
- ? orderShas
142
- : commitInds.map((c) => String(c.id).replace(/^commit:/, ""));
165
+
166
+ // P4 cross-repo: a commit's repo is inferred from the id-prefixes of the modules it
167
+ // touches (touchesSymbol edges) commits themselves are unprefixed (one per sha).
168
+ // This is order-independent, so we can detect a merge BEFORE choosing the time axis.
169
+ const prefixByCommit = new Map(); // commit id -> Map<repoPrefix, touch count>
170
+ for (const g of groups) {
171
+ if (kindOf(g) !== "touchesSymbol") continue;
172
+ for (const e of g.examples || []) {
173
+ const prefix = repoOfId(e.object);
174
+ if (!prefix || !commitById.has(e.subject)) continue;
175
+ let m = prefixByCommit.get(e.subject);
176
+ if (!m) { m = new Map(); prefixByCommit.set(e.subject, m); }
177
+ m.set(prefix, (m.get(prefix) || 0) + 1);
178
+ }
179
+ }
180
+ const multi = isMultiRepo(commitInds.map((c) => prefixByCommit.get(c.id) || new Map()));
181
+ const repoOfCommitId = (id) => (multi ? assignRepo(prefixByCommit.get(id) || new Map()) : null);
182
+
183
+ // Single-repo: git-log order (or the given order) exactly as before. Merged graph:
184
+ // there is no single repo to shell, so order by the stored commit date across ALL
185
+ // repos — one global time axis, tie-broken by sha for determinism.
186
+ const order = multi
187
+ ? [...commitInds]
188
+ .sort((a, b) => {
189
+ const da = attr(a, "date");
190
+ const db = attr(b, "date");
191
+ if (da < db) return -1;
192
+ if (da > db) return 1;
193
+ return a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
194
+ })
195
+ .map((c) => String(c.id).replace(/^commit:/, ""))
196
+ : (orderShas && orderShas.length)
197
+ ? orderShas
198
+ : commitInds.map((c) => String(c.id).replace(/^commit:/, ""));
143
199
  const ordinalOf = new Map(order.map((sha, i) => [`commit:${sha}`, i]));
144
200
  const headIdx = Math.max(0, order.length - 1);
145
201
  const commits = order.map((sha, i) => {
@@ -155,7 +211,7 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
155
211
  if (pi != null) parentIdx.push(pi);
156
212
  else ghostParents += 1;
157
213
  }
158
- return {
214
+ const commit = {
159
215
  idx: i,
160
216
  sha,
161
217
  shortSha: sha.slice(0, 12),
@@ -166,6 +222,12 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
166
222
  ghostParents,
167
223
  merge: parentIdx.length + ghostParents > 1,
168
224
  };
225
+ // P4: tag with the owning repo ONLY on a merged graph (single-repo stays identical).
226
+ if (multi) {
227
+ const r = repoOfCommitId(`commit:${sha}`);
228
+ if (r) commit.repo = r;
229
+ }
230
+ return commit;
169
231
  });
170
232
 
171
233
  const symInds = individuals.filter((i) => i.class !== "Commit" && inScope(i.id));
@@ -232,7 +294,7 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
232
294
  const touches = [...(touchesByNode.get(ind.id) || [])].sort((a, b) => a - b);
233
295
  const [born, died] = deriveNodeInterval(touches, headIdx, fallbackBorn(ind.id));
234
296
  bornOf.set(ind.id, born);
235
- nodes.push({
297
+ const node = {
236
298
  id: ind.id,
237
299
  type: ind.class,
238
300
  label: ind.label || ind.id,
@@ -241,13 +303,19 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
241
303
  died,
242
304
  touches,
243
305
  churn: touches.length,
244
- });
306
+ };
307
+ // P4: a symbol's repo is right in its id prefix on a merged graph — carry it so the
308
+ // browser can badge by repo without re-parsing. Guarded → single-repo unchanged.
309
+ if (multi) { const r = repoOfId(ind.id); if (r) node.repo = r; }
310
+ nodes.push(node);
245
311
  }
246
312
  for (const c of commits) {
247
- nodes.push({
313
+ const node = {
248
314
  id: `commit:${c.sha}`, type: "Commit", label: c.shortSha, site: "",
249
315
  born: c.idx, died: headIdx, touches: [], churn: 0, commitIdx: c.idx,
250
- });
316
+ };
317
+ if (multi && c.repo) node.repo = c.repo;
318
+ nodes.push(node);
251
319
  bornOf.set(`commit:${c.sha}`, c.idx);
252
320
  }
253
321
 
@@ -289,9 +357,13 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
289
357
  return {
290
358
  meta: {
291
359
  scope,
292
- commit_order: "oldest-first (ordinal 0 = oldest, N-1 = HEAD)",
360
+ commit_order: multi
361
+ ? "oldest-first by commit date across all repos (merged multi-repo graph)"
362
+ : "oldest-first (ordinal 0 = oldest, N-1 = HEAD)",
293
363
  note: "died = HEAD for every node: a HEAD-only index cannot observe deletions.",
294
364
  counts: { commits: commits.length, nodes: nodes.length, edges: edges.length, touchesSymbol_edges: tsEdges.length },
365
+ // P4: present ONLY on a merged graph, so single-repo meta is byte-identical.
366
+ ...(multi ? { multi_repo: true, repos: [...new Set(commits.map((c) => c.repo).filter(Boolean))].sort() } : {}),
295
367
  },
296
368
  commits,
297
369
  nodes,
@@ -305,8 +377,10 @@ export function buildTemporalGraph(raw, orderShas, { scope = "product", parentsB
305
377
  * the page's whole view state already round-trips through the URL (P1) — a plain
306
378
  * reload on HEAD change is enough to pick up a re-index without losing the cursor,
307
379
  * selection, or query. */
308
- export function buildBrowserData(tg, { repoUrl = "", repoRef = "main", siteNav = false, live = false, gitHead = "" } = {}) {
309
- return { ...tg, repoUrl: String(repoUrl).replace(/\/+$/, ""), repoRef, siteNav: !!siteNav, live: !!live, gitHead };
380
+ export function buildBrowserData(tg, { repoUrl = "", repoRef = "main", siteNav = false, live = false, gitHead = "", nav = null } = {}) {
381
+ // `nav` ({name: href}) rebuilds the page's #sitenav; `siteNav` is the legacy
382
+ // flag — stale deployed data keeps its absolute links.
383
+ return { ...tg, repoUrl: String(repoUrl).replace(/\/+$/, ""), repoRef, siteNav: !!siteNav, ...(nav ? { nav } : {}), live: !!live, gitHead };
310
384
  }
311
385
 
312
386
  /** The tested temporal core, ready to inline into the page (exports stripped). */
@@ -430,7 +504,8 @@ function init(tg){
430
504
  for(const n of tg.nodes)S.byId.set(n.id,n);
431
505
  document.title='seon chronograph — '+(tg.meta&&tg.meta.scope||'');
432
506
  $('scopelabel').textContent=(tg.meta&&tg.meta.scope?'· '+tg.meta.scope:'')+' · '+tg.commits.length+' commits · '+tg.nodes.length+' nodes'+(tg.live?' · live':'');
433
- if(tg.siteNav)$('sitenav').hidden=false;
507
+ if(tg.nav){$('sitenav').innerHTML=Object.entries(tg.nav).map(([k,h])=>'<a href="'+esc(h)+'">'+esc(k)+'</a>').join('');$('sitenav').hidden=false;}
508
+ else if(tg.siteNav)$('sitenav').hidden=false;
434
509
  // ---- URL state in (query-as-URL: the whole view is the link) ----
435
510
  const st=decodeViewState(location.search);
436
511
  if(st.q){S.q=st.q;$('q').value=st.q;S.qSet=matchQuery(tg,st.q);}
@@ -477,9 +552,12 @@ function syncUrl(){
477
552
  const full=(keep?'data='+encodeURIComponent(keep)+(qs?'&':''):'')+qs;
478
553
  history.replaceState(null,'',full?('?'+full):location.pathname);
479
554
  }
555
+ // Legend chips per type GROUP — Function/Method share one chip (legend-only
556
+ // merge; node fills stay distinct), counts summed across the group.
557
+ const GROUPS=[['Module'],['Class'],['Function','Method'],['Attribute'],['GlobalVariable'],['Commit']];
480
558
  function buildChips(){
481
- $('legend').innerHTML=TYPES.map(t=>'<label class="lg" title="show/hide '+t+' nodes"><input type="checkbox" class="typechk" data-cls="'+t+'"'+(S.enabled.has(t)?' checked':'')+'><i style="background:'+COLORS[t]+'"></i>'+t+'<span class="cnt" data-cnt="'+t+'"></span></label>').join(' ');
482
- document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',()=>{c.checked?S.enabled.add(c.dataset.cls):S.enabled.delete(c.dataset.cls);render();}));
559
+ $('legend').innerHTML=GROUPS.map(g=>'<label class="lg" title="show/hide '+g.join('/')+' nodes"><input type="checkbox" class="typechk" data-cls="'+g.join(',')+'"'+(g.every(t=>S.enabled.has(t))?' checked':'')+'>'+g.map(t=>'<i style="background:'+COLORS[t]+'"></i>').join('')+g.join('/')+'<span class="cnt" data-cnt="'+g.join(',')+'"></span></label>').join(' ');
560
+ document.querySelectorAll('.typechk').forEach(c=>c.addEventListener('change',()=>{c.dataset.cls.split(',').forEach(t=>c.checked?S.enabled.add(t):S.enabled.delete(t));render();}));
483
561
  }
484
562
  function buildCy(tg){
485
563
  const els=[];
@@ -562,7 +640,7 @@ function render(){
562
640
  const vis={},tot={};
563
641
  S.tg.nodes.forEach(n=>{tot[n.type]=(tot[n.type]||0)+1;});
564
642
  cy.nodes().forEach(cn=>{if(cn.style('display')==='element'){const t=cn.data('type');vis[t]=(vis[t]||0)+1;}});
565
- document.querySelectorAll('.cnt').forEach(sp=>{const k=sp.dataset.cnt;sp.textContent=(vis[k]||0)+'/'+(tot[k]||0);});
643
+ document.querySelectorAll('.cnt').forEach(sp=>{const ks=sp.dataset.cnt.split(',');sp.textContent=ks.reduce((a,k)=>a+(vis[k]||0),0)+'/'+ks.reduce((a,k)=>a+(tot[k]||0),0);});
566
644
  // narration: single cursor = the commit's story; two cursors = the range's story
567
645
  $('narr').textContent=cmp?narrateRange(S.tg,lo,hi):narrateCommit(S.tg,idx);
568
646
  $('qnote').style.display=(S.qSet&&S.qSet.size===0)?'block':'none';
@@ -695,6 +773,7 @@ function drawTimeline(){
695
773
  const mergeNote=c&&c.merge?' <span style="color:#565f89">· ⑂ merge ('+(c.parentIdx.length+c.ghostParents)+' parent'+((c.parentIdx.length+c.ghostParents)===1?'':'s')+')</span>':'';
696
774
  $('tlmeta').innerHTML=(c?('<b>'+esc(c.shortSha)+'</b> · '+(c.date||'').slice(0,10)+' · '+esc(c.author)+' — '+esc(c.subject)):'')
697
775
  +mergeNote
776
+ +(c&&c.repo?' <span style="color:#7aa2f7">· ⛃ '+esc(c.repo)+'</span>':'')
698
777
  +(S.cursorB!=null?' <span style="color:#e0af68">· B: '+esc(shortAt(S.cursorB))+'</span>':'')
699
778
  +' <span style="color:#565f89">('+(S.cursor+1)+' / '+S.tg.commits.length+')</span>';
700
779
  }
@@ -729,6 +808,7 @@ function showBiography(id){
729
808
  :'';
730
809
  $('detail').innerHTML='<h3>'+esc(c.shortSha)+'</h3>'
731
810
  +'<span class="badge"><i style="background:'+COLORS.Commit+'"></i>Commit '+(c.idx+1)+' of '+S.tg.commits.length+'</span>'
811
+ +(c.repo?' <span class="badge">repo '+esc(c.repo)+'</span>':'')
732
812
  +'<div class="row">'+esc(c.author)+' · '+(c.date||'').slice(0,10)+'</div><div class="row"><em>'+esc(c.subject)+'</em></div>'
733
813
  +'<div class="row">'+(url?'<a class="btn" href="'+url+'" target="_blank" rel="noopener">open in GitLab ↗</a>':'')
734
814
  +'<button class="btn" id="gocommit">scrub here</button></div>'