@shgroup/dsh-serenity-hooks 1.56.2 → 1.56.3

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/lib/index.js CHANGED
@@ -20,12 +20,12 @@ import { platform as platform$1 } from "node:process";
20
20
  import { fileURLToPath } from "node:url";
21
21
  import { createUserMessage } from "@deepseek-ai/dsh-llm";
22
22
  import { createHash, createHmac, randomBytes, randomUUID, scryptSync, timingSafeEqual } from "node:crypto";
23
+ import { Resvg, initWasm } from "@resvg/resvg-wasm";
23
24
  import { createServer, request } from "node:http";
24
25
  import * as zlib from "node:zlib";
25
26
  import { deriveEventMessage } from "@deepseek-ai/dsh-session";
26
27
  import { lookup } from "node:dns/promises";
27
28
  import { isIP } from "node:net";
28
- import { Resvg, initWasm } from "@resvg/resvg-wasm";
29
29
  //#region src/fs-ops.ts
30
30
  /**
31
31
  * fs-ops.ts — container_fs 纯操作层(cc_fs → container_fs,v1.30;零 DSH 依赖,可独立单测)
@@ -5646,6 +5646,9 @@ function parseTurtle(raw) {
5646
5646
  errors
5647
5647
  };
5648
5648
  }
5649
+ function bnodeId(node) {
5650
+ return node.startsWith("_:b") ? node : "";
5651
+ }
5649
5652
  function i2q(iri, prefixes) {
5650
5653
  let best = null;
5651
5654
  let bestLen = 0;
@@ -5666,7 +5669,8 @@ function turtleToOnto(r) {
5666
5669
  const onto = {
5667
5670
  types: {},
5668
5671
  relations: {},
5669
- axioms: []
5672
+ axioms: [],
5673
+ instances: []
5670
5674
  };
5671
5675
  const subjectMap = /* @__PURE__ */ new Map();
5672
5676
  for (const t of triples) {
@@ -5754,9 +5758,12 @@ function turtleToOnto(r) {
5754
5758
  }
5755
5759
  onto.relations[name] = rd;
5756
5760
  }
5757
- for (const [subj, stmts] of subjectMap) for (const s of stmts) if (s.predicate === SR + "axiom" && s.objectType === "blank") {
5758
- const anode = String(s.object);
5759
- const astmts = subjectMap.get(anode) || [];
5761
+ const axiomBnodes = /* @__PURE__ */ new Set();
5762
+ for (const [, stmts] of subjectMap) for (const s of stmts) if (s.predicate === SR + "axiom" && s.objectType === "blank") axiomBnodes.add(String(s.object));
5763
+ for (const [subj, stmts] of subjectMap) {
5764
+ const isNamed = stmts.some((s) => s.predicate === "http://www.w3.org/1999/02/22-rdf-syntax-ns#type" && stripDefaultPrefix(i2q(String(s.object), prefixes)) === "sr:Axiom");
5765
+ if (!axiomBnodes.has(subj) && !isNamed) continue;
5766
+ const astmts = stmts;
5760
5767
  const ad = { rule: "" };
5761
5768
  for (const as_ of astmts) {
5762
5769
  if (as_.predicate === SR + "description") ad.description = String(as_.object);
@@ -5767,11 +5774,29 @@ function turtleToOnto(r) {
5767
5774
  ad.comment = String(as_.object);
5768
5775
  if (ad.description === void 0) ad.description = String(as_.object);
5769
5776
  }
5770
- if (as_.predicate === SR + "rule") ad.rule = String(as_.object);
5777
+ if (as_.predicate === SR + "axiomRule" || as_.predicate === SR + "rule") ad.rule = String(as_.object);
5771
5778
  if (as_.predicate === SR + "axiomType") ad.type = String(as_.object);
5772
5779
  }
5773
5780
  onto.axioms.push(ad);
5774
5781
  }
5782
+ for (const [subj, stmts] of subjectMap) {
5783
+ if (bnodeId(subj) !== "") continue;
5784
+ const typeStmt = stmts.find((s) => s.predicate === "http://www.w3.org/1999/02/22-rdf-syntax-ns#type");
5785
+ if (typeStmt === void 0) continue;
5786
+ const cls = stripDefaultPrefix(i2q(String(typeStmt.object), prefixes));
5787
+ if (cls === "owl:Ontology" || propIris.has(String(typeStmt.object))) continue;
5788
+ if (onto.types === void 0 || onto.types[cls] === void 0) continue;
5789
+ const inst = {
5790
+ id: stripDefaultPrefix(i2q(subj, prefixes)),
5791
+ ofClass: cls
5792
+ };
5793
+ const litOf = (p) => {
5794
+ const hit = stmts.find((s) => s.predicate === p && s.objectType === "literal");
5795
+ return hit === void 0 ? void 0 : String(hit.object);
5796
+ };
5797
+ inst.label = litOf("http://www.w3.org/2000/01/rdf-schema#label") ?? litOf(SR + "trapName") ?? litOf(SR + "name") ?? stmts.find((s) => s.objectType === "literal" && !s.predicate.startsWith(RDF))?.object !== void 0 ? String(stmts.find((s) => s.objectType === "literal" && !s.predicate.startsWith(RDF)).object) : void 0;
5798
+ onto.instances.push(inst);
5799
+ }
5775
5800
  return onto;
5776
5801
  }
5777
5802
  function ontoToTurtle(onto, baseIri = "http://example.org/onto#") {
@@ -7235,7 +7260,8 @@ function cmdReview(args) {
7235
7260
  name: n,
7236
7261
  ...r
7237
7262
  })),
7238
- axioms
7263
+ axioms,
7264
+ instances: onto.instances || []
7239
7265
  },
7240
7266
  reviewLevel: level,
7241
7267
  dimensions: dimensions.map((d) => ({
@@ -7659,6 +7685,849 @@ function runOntologyCli(argv, opts = {}) {
7659
7685
  };
7660
7686
  }
7661
7687
  //#endregion
7688
+ //#region src/ontology/viz.ts
7689
+ /**
7690
+ * viz.ts — 把 `.ttl` 本体**渲染成一张可读的结构图**(2026-10-13 S142 从 CCC 搬进 ACC)
7691
+ *
7692
+ * 🔴 **本次搬家的两条纪律**(否则"搬"会变成"抄一份新的"):
7693
+ * ① **渲染器(layout / svg / html)逐字保留** —— 它们的视觉是 owner 亲自认可的,
7694
+ * 搬的过程里**一个像素都不该动**。本文件里 `toSvg`/`toHtml`/`layout`/`edgePath`
7695
+ * 等仍是原件,未做任何"顺手改好一点"。
7696
+ * ② 🔴 **自带解析器整块删除**,改用**引擎的 `turtleToOnto`** —— 这是本次搬家的
7697
+ * **全部意义**:原 viz 的解析器实测有三类错(只认具名式公理 ⇒ **8/16 文件公理数归零**;
7698
+ * `sr:identity` 只认括号形 ⇒ **18 处假阳**;`sr:description` 从不读 ⇒ 公理正文丢失)。
7699
+ * 复用引擎后这三类**自动消失**,因为引擎两种写法都认。
7700
+ *
7701
+ * ⚠️ 调用面由 `src/tools/ontology-eng.ts` 提供(`ontology viz`),本文件**不自己读 argv/退出**。
7702
+ *
7703
+ * 原始出处 = CCC 的 `.opencode/skills/home-serenity/scripts/ontology-viz.ts`(1505 行)。
7704
+ * 搬家判据与验收基准 = `AGENT_SESSIONS/…S142…/2026-10-13T0751-a3f1-viz-parser-baseline.md`
7705
+ * (16 份 `.ttl` 的逐文件读数,改后须与之**逐条对读**)。
7706
+ *
7707
+ * ---
7708
+ * 原文件头(保留其下的设计说明,仍成立的部分):
7709
+ *
7710
+ * 把 `.ttl` 本体**渲染成一张可读的结构图**(Mech,零环境依赖)
7711
+ *
7712
+ * ## 它解决什么
7713
+ * 本体的真相是文本(`.ttl`),但"这个领域里有哪些东西、它们怎么连、约束在哪"——
7714
+ * 在 200 行 Turtle 里是**看不出来**的。`ontology explain` 给的是**清单**(段落文字),
7715
+ * 本工具给的是**图**(空间关系:谁在中心、谁孤立、链路怎么走)。
7716
+ *
7717
+ * ## 与既有工具的分工(不重复造)
7718
+ * | 工具 | 给什么 | 形态 |
7719
+ * |---|---|---|
7720
+ * | `ontology check/verify` | **判定**(对不对) | 报表 / 退出码 |
7721
+ * | `ontology explain` | **清单**(有什么) | 段落文字 |
7722
+ * | **本工具** | **结构**(怎么连的) | 图 |
7723
+ *
7724
+ * ## 子命令
7725
+ * stats <ttl> 只出读数(类/关系/公理/实例/外部链),不画图
7726
+ * diagram <ttl> [--show-axioms] 输出**内置 `diagram` 工具的标签源码**(可直接贴进对话看)
7727
+ * svg <ttl> [--out=路径] 输出独立 SVG(矢量、可入文档)
7728
+ * html <ttl> [--out=路径] 输出**交互版** HTML(拖拽平移/滚轮缩放/点节点看详情/搜索)
7729
+ * list [扫描根] 列出所有可用 .ttl(缺省 = <CCC>/.opencode/skills)
7730
+ * all [扫描根] [--out-dir] 批量出 html(体检用)
7731
+ *
7732
+ * 业务 flag(协议 flag --format/--log/--help 由 msm 拦截,本脚本不接受):
7733
+ * --out=<path> 输出路径(缺省 _tmp/ontology-viz/<名>.{svg,html})
7734
+ * --show-axioms 公理也画成节点并连到它提到的类(缺省只在侧栏列)
7735
+ * --show-instances 实例画成小方块挂在所属类下(缺省只在侧栏列)
7736
+ * --no-imports 不画外部链节点
7737
+ * --max-nodes=N 超过 N 个类就拒绝画图并提示(缺省 40;防一张糊图)
7738
+ *
7739
+ * 退出码:0 成功 / 1 参数错 / 2 文件不可读 / 4 内部异常 / 5 跑完但有发现(如超节点上限)
7740
+ *
7741
+ * ## 解析到什么程度(诚实边界 E↑)
7742
+ * **不是**通用 Turtle 解析器:不递归处理嵌套空白节点、不做 IRI 归一、不校验语义。
7743
+ * 它认的是**本容器实际在写的那种形态**(逐条实测自 16 份 `.ttl`):
7744
+ * `@prefix` / `@base` / `<>` / `:X a owl:Class ; rdfs:label "…"@en ; sr:rigidity sr:rigid ;
7745
+ * sr:identity (…) ; sr:property [ sr:name "…" ; sr:type "…" ; sr:cardinality "…" ] .`
7746
+ * `:p a owl:ObjectProperty ; rdfs:domain :A ; rdfs:range :B .`
7747
+ * `:ax a sr:Axiom ; sr:axiomRule "…" ; sr:axiomType "…" .` / 实例 `:i a :SomeClass ; …`
7748
+ * 遇到不认识的构造**跳过并在 stats 里报数**(fail-loud,不静默吞)。
7749
+ */
7750
+ /**
7751
+ * `OntoModel` 的**唯一来源 = 引擎的解析器**(2026-10-13 S142)。
7752
+ *
7753
+ * 🔴 这里**不解析任何 Turtle** —— 只把引擎的 `OntologyDefinition` **翻译**成本文件的
7754
+ * 渲染模型。原 viz 自带的手写解析器已整块删除:它实测错三类
7755
+ * (只认具名式公理 ⇒ 8/16 文件公理数归零;`sr:identity` 只认括号形 ⇒ 18 处假阳;
7756
+ * 从不读 `sr:description` ⇒ 公理正文丢失)。引擎两种写法都认 ⇒ 三类**自动消失**。
7757
+ *
7758
+ * 需引擎侧先满足两件事(已随 82e740f 落地):导出 `loadOntoFile`/类型;模型收 `instances`。
7759
+ */
7760
+ function fromEngine(file, absPath) {
7761
+ const onto = loadOntoFile(absPath).onto;
7762
+ const model = {
7763
+ file,
7764
+ prefixes: /* @__PURE__ */ new Map(),
7765
+ base: null,
7766
+ meta: { imports: onto.imports ?? [] },
7767
+ classes: [],
7768
+ relations: [],
7769
+ axioms: [],
7770
+ instances: [],
7771
+ unknownTypes: []
7772
+ };
7773
+ for (const [id, td] of Object.entries(onto.types ?? {})) model.classes.push({
7774
+ id,
7775
+ label: td.label ?? td.description ?? id,
7776
+ comment: td.comment ?? td.description ?? "",
7777
+ rigidity: td.rigidity ?? null,
7778
+ identity: td.identity ?? [],
7779
+ properties: Object.entries(td.properties ?? {}).map(([name, pd]) => ({
7780
+ name,
7781
+ type: pd.type,
7782
+ cardinality: pd.cardinality ?? ""
7783
+ })),
7784
+ parents: td.superTypes ?? []
7785
+ });
7786
+ for (const [id, rd] of Object.entries(onto.relations ?? {})) model.relations.push({
7787
+ id,
7788
+ label: rd.label ?? id,
7789
+ comment: rd.comment ?? "",
7790
+ domain: rd.domain ?? null,
7791
+ range: rd.range ?? null,
7792
+ cardinality: rd.cardinality ?? "",
7793
+ isObject: true
7794
+ });
7795
+ onto.axioms?.forEach((ax, i) => {
7796
+ const rule = ax.rule !== "" ? ax.rule : ax.description ?? "";
7797
+ model.axioms.push({
7798
+ id: `#${i + 1}`,
7799
+ rule,
7800
+ type: ax.type ?? "",
7801
+ comment: ax.comment ?? ax.description ?? "",
7802
+ mentions: []
7803
+ });
7804
+ });
7805
+ for (const inst of onto.instances ?? []) model.instances.push({
7806
+ id: inst.id,
7807
+ ofClass: inst.ofClass,
7808
+ label: inst.label ?? inst.id
7809
+ });
7810
+ for (const ax of model.axioms) {
7811
+ const found = /* @__PURE__ */ new Set();
7812
+ for (const c of model.classes) {
7813
+ const bare = c.id.includes(":") ? c.id.slice(c.id.indexOf(":") + 1) : c.id;
7814
+ const esc = (s) => s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
7815
+ if (new RegExp(`(?:^|[^\\w:])${esc(bare)}(?![\\w])`).test(ax.rule)) found.add(c.id);
7816
+ }
7817
+ ax.mentions = [...found];
7818
+ }
7819
+ return model;
7820
+ }
7821
+ const RIG_FILL = {
7822
+ rigid: {
7823
+ class: "#1e3a5f",
7824
+ label: "稳定(rigid)"
7825
+ },
7826
+ "anti-rigid": {
7827
+ class: "#4a2f5f",
7828
+ label: "易变(anti-rigid)"
7829
+ },
7830
+ "semi-rigid": {
7831
+ class: "#2f4a3a",
7832
+ label: "半稳定(semi-rigid)"
7833
+ },
7834
+ none: {
7835
+ class: "#3a3f46",
7836
+ label: "未声明刚性"
7837
+ }
7838
+ };
7839
+ function planLayers(ids, edges) {
7840
+ const out = /* @__PURE__ */ new Map();
7841
+ const inc = /* @__PURE__ */ new Map();
7842
+ for (const id of ids) {
7843
+ out.set(id, []);
7844
+ inc.set(id, []);
7845
+ }
7846
+ const backEdges = [];
7847
+ const seen = /* @__PURE__ */ new Set();
7848
+ for (const e of edges) {
7849
+ if (e.from === e.to) {
7850
+ backEdges.push(e);
7851
+ continue;
7852
+ }
7853
+ if (!out.has(e.from) || !inc.has(e.to)) continue;
7854
+ const key = `${e.from}->${e.to}`;
7855
+ if (seen.has(key)) continue;
7856
+ seen.add(key);
7857
+ out.get(e.from).push(e.to);
7858
+ inc.get(e.to).push(e.from);
7859
+ }
7860
+ const level = new Map(ids.map((id) => [id, 0]));
7861
+ const indeg = new Map(ids.map((id) => [id, inc.get(id).length]));
7862
+ const queue = ids.filter((id) => (indeg.get(id) ?? 0) === 0).sort();
7863
+ const processed = /* @__PURE__ */ new Set();
7864
+ while (queue.length) {
7865
+ const u = queue.shift();
7866
+ processed.add(u);
7867
+ for (const v of out.get(u) ?? []) {
7868
+ level.set(v, Math.max(level.get(v) ?? 0, (level.get(u) ?? 0) + 1));
7869
+ const d = (indeg.get(v) ?? 1) - 1;
7870
+ indeg.set(v, d);
7871
+ if (d === 0) queue.push(v);
7872
+ }
7873
+ }
7874
+ for (const id of ids) {
7875
+ if (processed.has(id)) continue;
7876
+ for (const e of edges) if (e.from === id && processed.has(e.to)) backEdges.push(e);
7877
+ }
7878
+ const maxLevel = Math.max(0, ...[...level.values()]);
7879
+ const layers = Array.from({ length: maxLevel + 1 }, () => []);
7880
+ for (const id of ids) layers[level.get(id) ?? 0].push(id);
7881
+ return {
7882
+ layers,
7883
+ level,
7884
+ backEdges
7885
+ };
7886
+ }
7887
+ /**
7888
+ * 框尺寸**由内容反推**(不拍脑袋定常数)。
7889
+ *
7890
+ *
7891
+ * 🔴 为什么必须这样:`diagram` 工具的体检(体检 2)会按**实际文字**算每行宽度,
7892
+ * 固定尺寸必然报"标签溢出"(实测:3 行标签需 ≈360px 而框只有 206px)。
7893
+ * 这里**照搬 `diagram` 的度量规则**(`src/diagram/dsl-render.ts` 的 `textWidth`:
7894
+ * `codePoint > 0x2e80 ⇒ 整宽,否则 0.55×`),保证两边算出来一致 ⇒ 不再报假溢出。
7895
+ */
7896
+ function textWidth$1(s, size) {
7897
+ let w = 0;
7898
+ for (const ch of s) w += (ch.codePointAt(0) ?? 0) > 11904 ? size : size * .55;
7899
+ return w;
7900
+ }
7901
+ /** 一个框:标题 1 行(14px)+ 正文若干行(11px)⇒ 宽高 */
7902
+ function measureBox(title, lines) {
7903
+ const titleW = textWidth$1(title, 14) + 20;
7904
+ const lineW = Math.max(0, ...lines.map((l) => textWidth$1(l, 11))) + 20;
7905
+ return {
7906
+ w: Math.max(140, Math.ceil(Math.max(titleW, lineW))),
7907
+ h: Math.ceil(26 + Math.max(1, lines.length) * 16 + 12)
7908
+ };
7909
+ }
7910
+ function layout(model, opts) {
7911
+ const notes = [];
7912
+ const boxes = [];
7913
+ const edges = [];
7914
+ const PAD = 34;
7915
+ const COL_GAP = 210;
7916
+ const ROW_GAP = 30;
7917
+ const nodes = [];
7918
+ for (const c of model.classes) nodes.push({
7919
+ id: `class:${c.id}`,
7920
+ kind: "class",
7921
+ title: c.label,
7922
+ lines: [`${c.id}${c.identity.length ? ` ◆ ${c.identity.join(",")}` : ""}`, `${c.properties.length} 属性${c.rigidity ? ` / ${c.rigidity}` : ""}`],
7923
+ fill: RIG_FILL[c.rigidity ?? "none"].class
7924
+ });
7925
+ if (opts.instances) for (const i of model.instances) nodes.push({
7926
+ id: `inst:${i.id}`,
7927
+ kind: "instance",
7928
+ title: i.id,
7929
+ lines: [`属于 ${i.ofClass ?? "?"}`],
7930
+ fill: "#3f3420"
7931
+ });
7932
+ if (opts.axioms) for (const a of model.axioms) nodes.push({
7933
+ id: `ax:${a.id}`,
7934
+ kind: "axiom",
7935
+ title: a.id,
7936
+ lines: [`[${a.type || "rule"}]`],
7937
+ fill: "#5f2f2f"
7938
+ });
7939
+ const classIds = new Set(model.classes.map((c) => `class:${c.id}`));
7940
+ for (const r of model.relations) {
7941
+ const fromId = r.domain ? `class:${r.domain}` : null;
7942
+ const toId = r.range ? `class:${r.range}` : null;
7943
+ if (fromId && toId && classIds.has(fromId) && classIds.has(toId)) edges.push({
7944
+ from: fromId,
7945
+ to: toId,
7946
+ label: `${r.label} [${r.cardinality || "?"}]`,
7947
+ kind: "relation"
7948
+ });
7949
+ else {
7950
+ const why = [!r.domain ? "缺 domain" : !classIds.has(fromId) ? `domain=${r.domain} 不在本文件的类里` : "", !r.range ? "缺 range" : !classIds.has(toId) ? `range=${r.range} 不在本文件的类里` : ""].filter(Boolean).join(" / ");
7951
+ notes.push(`关系 \`${r.id}\` 没画成边:${why}`);
7952
+ }
7953
+ }
7954
+ if (opts.axioms) {
7955
+ for (const ax of model.axioms) for (const m of ax.mentions) if (classIds.has(`class:${m}`)) edges.push({
7956
+ from: `ax:${ax.id}`,
7957
+ to: `class:${m}`,
7958
+ label: "",
7959
+ kind: "axiom"
7960
+ });
7961
+ }
7962
+ if (opts.instances) {
7963
+ for (const inst of model.instances) if (inst.ofClass && classIds.has(`class:${inst.ofClass}`)) edges.push({
7964
+ from: `inst:${inst.id}`,
7965
+ to: `class:${inst.ofClass}`,
7966
+ label: "",
7967
+ kind: "instance"
7968
+ });
7969
+ }
7970
+ const ids = nodes.map((n) => n.id);
7971
+ const layerEdges = edges.filter((e) => e.kind === "relation" && e.from !== e.to);
7972
+ const plan = planLayers(ids.filter((i) => classIds.has(i)), layerEdges);
7973
+ const sideNodes = nodes.filter((n) => !classIds.has(n.id));
7974
+ const leftCols = sideNodes.length ? 1 : 0;
7975
+ const level = /* @__PURE__ */ new Map();
7976
+ for (const [id, lv] of plan.level) level.set(id, lv + leftCols);
7977
+ for (const n of sideNodes) level.set(n.id, 0);
7978
+ for (const id of ids) if (!level.has(id)) level.set(id, 0);
7979
+ const maxLevel = Math.max(0, ...[...level.values()]);
7980
+ const layers = Array.from({ length: maxLevel + 1 }, () => []);
7981
+ const nodeById = new Map(nodes.map((n) => [n.id, n]));
7982
+ const deg = /* @__PURE__ */ new Map();
7983
+ for (const e of edges) {
7984
+ deg.set(e.from, (deg.get(e.from) ?? 0) + 1);
7985
+ deg.set(e.to, (deg.get(e.to) ?? 0) + 1);
7986
+ }
7987
+ for (const id of [...nodes].map((n) => n.id).sort((a, b) => (deg.get(b) ?? 0) - (deg.get(a) ?? 0) || a.localeCompare(b))) layers[level.get(id) ?? 0].push(id);
7988
+ const sizes = new Map(nodes.map((n) => [n.id, measureBox(n.title, n.lines)]));
7989
+ const colW = layers.map((l) => Math.max(140, ...l.map((id) => sizes.get(id).w)));
7990
+ const colX = [];
7991
+ let cursor = PAD;
7992
+ for (let i = 0; i < layers.length; i++) {
7993
+ colX.push(cursor);
7994
+ cursor += colW[i] + COL_GAP;
7995
+ }
7996
+ const colH = layers.map((l) => l.reduce((a, id) => a + sizes.get(id).h, 0) + Math.max(0, l.length - 1) * ROW_GAP);
7997
+ const maxH = Math.max(1, ...colH);
7998
+ const headerBoxes = [];
7999
+ let headerBottom = PAD;
8000
+ if (opts.imports && model.meta.imports.length) {
8001
+ let ix = PAD;
8002
+ model.meta.imports.forEach((im, i) => {
8003
+ const s = measureBox("外部链", [im]);
8004
+ headerBoxes.push({
8005
+ id: `import:${i}`,
8006
+ kind: "import",
8007
+ x: ix,
8008
+ y: PAD,
8009
+ w: s.w,
8010
+ h: s.h,
8011
+ title: "外部链",
8012
+ lines: [im],
8013
+ fill: "#4a3b1f"
8014
+ });
8015
+ edges.push({
8016
+ from: "self",
8017
+ to: `import:${i}`,
8018
+ label: "owl:imports",
8019
+ kind: "import"
8020
+ });
8021
+ ix += s.w + ROW_GAP;
8022
+ headerBottom = Math.max(headerBottom, PAD + s.h);
8023
+ });
8024
+ cursor = Math.max(cursor, ix + PAD);
8025
+ }
8026
+ const selfLines = [`版本 ${model.meta.version ?? "—"}`, `类 ${model.classes.length} / 关系 ${model.relations.length} / 公理 ${model.axioms.length} / 实例 ${model.instances.length}`];
8027
+ const selfSize = measureBox(model.meta.label || basename(model.file), selfLines);
8028
+ headerBoxes.push({
8029
+ id: "self",
8030
+ kind: "self",
8031
+ x: PAD,
8032
+ y: headerBottom + 16,
8033
+ w: selfSize.w,
8034
+ h: selfSize.h,
8035
+ title: model.meta.label || basename(model.file),
8036
+ lines: selfLines,
8037
+ fill: "#14524a"
8038
+ });
8039
+ headerBottom = headerBottom + 16 + selfSize.h;
8040
+ boxes.push(...headerBoxes);
8041
+ const TOP = headerBottom + 34;
8042
+ layers.forEach((l, li) => {
8043
+ const slack = Math.max(0, maxH - l.reduce((a, id) => a + sizes.get(id).h, 0));
8044
+ const gap = l.length > 1 ? slack / (l.length - 1) : 0;
8045
+ let y = TOP;
8046
+ for (const id of l) {
8047
+ const s = sizes.get(id);
8048
+ const n = nodeById.get(id);
8049
+ boxes.push({
8050
+ id,
8051
+ kind: n.kind,
8052
+ x: colX[li],
8053
+ y,
8054
+ w: colW[li],
8055
+ h: s.h,
8056
+ title: n.title,
8057
+ lines: n.lines,
8058
+ fill: n.fill
8059
+ });
8060
+ y += s.h + gap;
8061
+ }
8062
+ });
8063
+ const width = Math.max(600, cursor);
8064
+ const height = Math.max(320, TOP + maxH + PAD);
8065
+ const boxById = new Map(boxes.map((b) => [b.id, b]));
8066
+ const gaps = /* @__PURE__ */ new Map();
8067
+ for (const e of edges) {
8068
+ const a = boxById.get(e.from);
8069
+ const b = boxById.get(e.to);
8070
+ if (!a || !b || a.id === b.id) continue;
8071
+ if (b.x < a.x) {
8072
+ e.detour = true;
8073
+ continue;
8074
+ }
8075
+ const gapLo = a.x + a.w;
8076
+ const gapHi = b.x;
8077
+ const key = `${Math.round(gapLo)}:${Math.round(gapHi)}`;
8078
+ if (!gaps.has(key)) gaps.set(key, []);
8079
+ gaps.get(key).push(e);
8080
+ }
8081
+ for (const [, group] of gaps) {
8082
+ if (group.length === 1) {
8083
+ const a = boxById.get(group[0].from);
8084
+ const b = boxById.get(group[0].to);
8085
+ group[0].channelX = (a.x + a.w + b.x) / 2;
8086
+ continue;
8087
+ }
8088
+ group.sort((p, q) => (boxById.get(p.to)?.y ?? 0) - (boxById.get(q.to)?.y ?? 0) || p.label.localeCompare(q.label) || p.to.localeCompare(q.to));
8089
+ const lo = Math.min(...group.map((e) => boxById.get(e.from).x + boxById.get(e.from).w));
8090
+ const step = (Math.max(...group.map((e) => boxById.get(e.to).x)) - lo) / (group.length + 1);
8091
+ group.forEach((e, i) => {
8092
+ e.channelX = lo + step * (i + 1);
8093
+ });
8094
+ }
8095
+ return {
8096
+ boxes,
8097
+ edges,
8098
+ width,
8099
+ height,
8100
+ notes
8101
+ };
8102
+ }
8103
+ function trim(s, n) {
8104
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
8105
+ }
8106
+ function esc$1(s) {
8107
+ return s.replace(/[&<>"]/g, (c) => ({
8108
+ "&": "&amp;",
8109
+ "<": "&lt;",
8110
+ ">": "&gt;",
8111
+ "\"": "&quot;"
8112
+ })[c]);
8113
+ }
8114
+ const EDGE_COLOR = {
8115
+ relation: "#8ab4f8",
8116
+ axiom: "#e08a8a",
8117
+ instance: "#c8a860",
8118
+ import: "#7fd1b9"
8119
+ };
8120
+ /**
8121
+ * 折线路径:
8122
+ * **正向**(相邻/跨列向右)⇒ 出右边界 → 走到**本边专属的竖直通道** → 水平进目标左边界。
8123
+ * **自环** ⇒ 在框右上角画一个小弧;**绕行**(右→左)⇒ 从上方绕过。
8124
+ * 每条边的通道由布局逐条错开(`channelX`)—— 否则同源的多条边会叠成一条竖线,
8125
+ * 标签也全糊在一起(实测:mail 的 4 条边全走 x=527)。
8126
+ */
8127
+ function edgePath(a, b, e) {
8128
+ if (a.id === b.id) {
8129
+ const x = a.x + a.w;
8130
+ const y = a.y + a.h / 2 - 14;
8131
+ return {
8132
+ d: `M ${x} ${y} C ${x + 44} ${y - 30}, ${x + 44} ${y + 44}, ${x} ${y + 28}`,
8133
+ mid: [x + 42, y + 8]
8134
+ };
8135
+ }
8136
+ if (e.detour || b.x < a.x) {
8137
+ const yTop = Math.min(a.y, b.y) - 26;
8138
+ return {
8139
+ d: `M ${a.x + a.w / 2} ${a.y} L ${a.x + a.w / 2} ${yTop} L ${b.x + b.w / 2} ${yTop} L ${b.x + b.w / 2} ${b.y}`,
8140
+ mid: [(a.x + a.w / 2 + b.x + b.w / 2) / 2, yTop]
8141
+ };
8142
+ }
8143
+ const x1 = a.x + a.w;
8144
+ const y1 = a.y + a.h / 2;
8145
+ const x2 = b.x;
8146
+ const y2 = b.y + b.h / 2;
8147
+ const mx = e.channelX ?? (x1 + x2) / 2;
8148
+ const ly = (y1 + y2) / 2;
8149
+ return {
8150
+ d: `M ${x1} ${y1} L ${mx} ${y1} L ${mx} ${y2} L ${x2} ${y2}`,
8151
+ mid: e.labelAt ?? [mx, ly]
8152
+ };
8153
+ }
8154
+ function toSvg(model, L, opts) {
8155
+ const byId = new Map(L.boxes.map((b) => [b.id, b]));
8156
+ const parts = [];
8157
+ parts.push(`<svg xmlns="http://www.w3.org/2000/svg" width="${Math.round(L.width)}" height="${Math.round(L.height)}" viewBox="0 0 ${Math.round(L.width)} ${Math.round(L.height)}" font-family="Noto Sans CJK SC, Noto Sans, DejaVu Sans, sans-serif">`);
8158
+ parts.push(`<defs>
8159
+ <marker id="ah" viewBox="0 0 10 10" refX="9" refY="5" markerWidth="7" markerHeight="7" orient="auto-start-reverse">
8160
+ <path d="M 0 0 L 10 5 L 0 10 z" fill="context-stroke"/>
8161
+ </marker>
8162
+ </defs>`);
8163
+ parts.push(`<rect width="100%" height="100%" fill="#12161c"/>`);
8164
+ const occupied = [];
8165
+ const boxRects = L.boxes.map((b) => [
8166
+ b.x,
8167
+ b.y,
8168
+ b.x + b.w,
8169
+ b.y + b.h
8170
+ ]);
8171
+ const hits = (r, list) => list.some((o) => Math.min(r[2], o[2]) - Math.max(r[0], o[0]) > 2 && Math.min(r[3], o[3]) - Math.max(r[1], o[1]) > 2);
8172
+ for (const e of L.edges) {
8173
+ const a = byId.get(e.from);
8174
+ const b = byId.get(e.to);
8175
+ if (!a || !b) continue;
8176
+ const c = EDGE_COLOR[e.kind];
8177
+ const dash = e.kind === "axiom" ? ` stroke-dasharray="6 4"` : e.kind === "instance" ? ` stroke-dasharray="2 3"` : "";
8178
+ const { d, mid } = edgePath(a, b, e);
8179
+ parts.push(`<path d="${d}" fill="none" stroke="${c}" stroke-width="1.7" marker-end="url(#ah)"${dash} opacity="0.85"/>`);
8180
+ if (e.label) {
8181
+ const w = textWidth$1(e.label, 11) + 8;
8182
+ let [lx, ly] = mid;
8183
+ for (const dy of [
8184
+ 0,
8185
+ -15,
8186
+ 15,
8187
+ -30,
8188
+ 30,
8189
+ -45,
8190
+ 45
8191
+ ]) {
8192
+ const r = [
8193
+ lx - w / 2,
8194
+ ly + dy - 9,
8195
+ lx + w / 2,
8196
+ ly + dy + 7
8197
+ ];
8198
+ if (!hits(r, occupied) && !hits(r, boxRects)) {
8199
+ parts.push(`<rect x="${(lx - w / 2).toFixed(1)}" y="${(ly + dy - 9).toFixed(1)}" width="${w.toFixed(1)}" height="16" rx="3" fill="#12161c" opacity="0.95"/>`);
8200
+ parts.push(`<text x="${lx.toFixed(1)}" y="${(ly + dy + 2.5).toFixed(1)}" fill="${c}" font-size="11" text-anchor="middle">${esc$1(trim(e.label, 26))}</text>`);
8201
+ occupied.push(r);
8202
+ break;
8203
+ }
8204
+ }
8205
+ }
8206
+ }
8207
+ for (const b of L.boxes) {
8208
+ parts.push(`<rect x="${b.x}" y="${b.y}" width="${b.w}" height="${b.h}" rx="8" fill="${b.fill}" stroke="#48536488" stroke-width="1"/>`);
8209
+ parts.push(`<text x="${b.x + 12}" y="${b.y + 22}" fill="#eef3f8" font-size="14" font-weight="600">${esc$1(trim(b.title, 30))}</text>`);
8210
+ b.lines.slice(0, 2).forEach((ln, i) => {
8211
+ parts.push(`<text x="${b.x + 12}" y="${b.y + 40 + i * 15}" fill="#b8c4d2" font-size="11" font-family="DejaVu Sans Mono, monospace">${esc$1(trim(ln, 36))}</text>`);
8212
+ });
8213
+ }
8214
+ parts.push("</svg>");
8215
+ return parts.join("\n");
8216
+ }
8217
+ function payloads(model, L) {
8218
+ const out = [];
8219
+ const relByClass = (cid) => ({
8220
+ out: model.relations.filter((r) => r.domain === cid).map((r) => `${r.label} → ${r.range ?? "?"} (${r.cardinality})`),
8221
+ in: model.relations.filter((r) => r.range === cid).map((r) => `${r.domain ?? "?"} → ${r.label} (${r.cardinality})`)
8222
+ });
8223
+ out.push({
8224
+ id: "self",
8225
+ kind: "self",
8226
+ title: model.meta.label || basename(model.file),
8227
+ comment: model.meta.comment ?? "",
8228
+ rows: [
8229
+ ["文件", basename(model.file)],
8230
+ ["版本", model.meta.version ?? "—"],
8231
+ ["外部链", model.meta.imports.join(", ") || "(无)"],
8232
+ ["类/关系/公理/实例", `${model.classes.length} / ${model.relations.length} / ${model.axioms.length} / ${model.instances.length}`]
8233
+ ],
8234
+ linksIn: [],
8235
+ linksOut: model.meta.imports.map((i) => `owl:imports ${i}`),
8236
+ axioms: []
8237
+ });
8238
+ for (const c of model.classes) {
8239
+ const rel = relByClass(c.id);
8240
+ out.push({
8241
+ id: `class:${c.id}`,
8242
+ kind: "class",
8243
+ title: c.label,
8244
+ comment: c.comment,
8245
+ rows: [
8246
+ ["标识", c.id],
8247
+ ["刚性", c.rigidity ?? "未声明"],
8248
+ ["身份判据", c.identity.join(", ") || "—(无 identity ⇒ M4 会警告)"],
8249
+ ...c.parents.length ? [["父类", c.parents.join(", ")]] : [],
8250
+ ...c.properties.map((p) => [`属性 ${p.name}`, `${p.type} / ${p.cardinality}`])
8251
+ ],
8252
+ linksIn: rel.in,
8253
+ linksOut: rel.out,
8254
+ axioms: model.axioms.filter((a) => a.mentions.includes(c.id)).map((a) => `${a.id}: ${a.rule}`)
8255
+ });
8256
+ }
8257
+ for (const ax of model.axioms) out.push({
8258
+ id: `ax:${ax.id}`,
8259
+ kind: "axiom",
8260
+ title: ax.id,
8261
+ comment: ax.comment,
8262
+ rows: [
8263
+ ["类型", ax.type],
8264
+ ["规则", ax.rule],
8265
+ ["涉及", ax.mentions.join(", ") || "(本文件里没有它提到的类型)"]
8266
+ ],
8267
+ linksIn: [],
8268
+ linksOut: ax.mentions.map((m) => `涉及 ${m}`),
8269
+ axioms: []
8270
+ });
8271
+ for (const inst of model.instances) out.push({
8272
+ id: `inst:${inst.id}`,
8273
+ kind: "instance",
8274
+ title: inst.id,
8275
+ comment: "",
8276
+ rows: [["属于", inst.ofClass ?? "?"]],
8277
+ linksIn: [],
8278
+ linksOut: [],
8279
+ axioms: []
8280
+ });
8281
+ return out;
8282
+ }
8283
+ function toHtml(model, L, svg) {
8284
+ const data = JSON.stringify(payloads(model, L));
8285
+ const notes = L.notes.length ? `<div class="notes"><b>未画出的边(${L.notes.length})</b><ul>${L.notes.map((n) => `<li>${esc$1(n)}</li>`).join("")}</ul></div>` : "";
8286
+ const unknowns = model.unknownTypes.length ? `<div class="notes"><b>没认出来的类型(${model.unknownTypes.length})</b> ${esc$1(model.unknownTypes.join(", "))}</div>` : "";
8287
+ return `<!doctype html><html lang="zh"><head><meta charset="utf-8">
8288
+ <title>${esc$1(model.meta.label || basename(model.file))} — 本体结构图</title>
8289
+ <style>
8290
+ :root{--bg:#0d1117;--panel:#161b22;--fg:#e6edf3;--dim:#8b949e;--line:#30363d}
8291
+ *{box-sizing:border-box}
8292
+ body{margin:0;background:var(--bg);color:var(--fg);font:14px/1.5 "Noto Sans CJK SC",system-ui,sans-serif;overflow:hidden}
8293
+ #wrap{display:flex;height:100vh}
8294
+ #stage{flex:1;position:relative;overflow:hidden;cursor:grab}
8295
+ #stage.drag{cursor:grabbing}
8296
+ #stage svg{position:absolute;left:0;top:0;transform-origin:0 0}
8297
+ #side{width:340px;min-width:340px;background:var(--panel);border-left:1px solid var(--line);overflow-y:auto;padding:16px}
8298
+ #bar{position:absolute;left:12px;top:12px;display:flex;gap:8px;align-items:center;background:#161b22dd;border:1px solid var(--line);border-radius:8px;padding:8px 10px;backdrop-filter:blur(6px);z-index:5;flex-wrap:wrap;max-width:calc(100% - 24px)}
8299
+ button,label{background:#21262d;color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 9px;font-size:12px;cursor:pointer}
8300
+ button:hover{background:#30363d}
8301
+ input{background:#0d1117;color:var(--fg);border:1px solid var(--line);border-radius:6px;padding:4px 8px;font-size:12px;width:150px}
8302
+ h1{font-size:15px;margin:0 0 4px}
8303
+ h2{font-size:12px;color:var(--dim);text-transform:uppercase;letter-spacing:.08em;margin:16px 0 6px;border-bottom:1px solid var(--line);padding-bottom:4px}
8304
+ .meta{color:var(--dim);font-size:12px}
8305
+ table{width:100%;border-collapse:collapse;font-size:12px}
8306
+ td{padding:3px 4px;vertical-align:top;border-bottom:1px solid #21262d}
8307
+ td:first-child{color:var(--dim);width:34%}
8308
+ code,.mono{font-family:DejaVu Sans Mono,monospace;font-size:11.5px;word-break:break-all}
8309
+ .notes{margin-top:14px;font-size:12px;color:#d2a24c}
8310
+ .notes ul{margin:4px 0 0 14px;padding:0}
8311
+ .tag{display:inline-block;background:#21262d;border:1px solid var(--line);border-radius:4px;padding:1px 5px;font-size:11px;margin-right:4px}
8312
+ .pill{display:inline-block;border-radius:10px;padding:1px 8px;font-size:11px}
8313
+ </style></head><body>
8314
+ <div id="wrap">
8315
+ <div id="stage">
8316
+ <div id="bar">
8317
+ <input id="q" placeholder="搜索类 / 关系 / 公理…">
8318
+ <button id="fit">适应窗口</button>
8319
+ <button id="reset">100%</button>
8320
+ <span class="meta" id="hint">拖动平移 · 滚轮缩放 · 点节点看详情</span>
8321
+ </div>
8322
+ ${svg.replace("<svg ", "<svg id=\"graph\" ")}
8323
+ </div>
8324
+ <div id="side">
8325
+ <h1>${esc$1(model.meta.label || basename(model.file))}</h1>
8326
+ <div class="meta">${esc$1(basename(model.file))}${model.meta.version ? " · v" + esc$1(model.meta.version) : ""}</div>
8327
+ <div class="meta" style="margin-top:6px">
8328
+ <span class="tag">类 ${model.classes.length}</span><span class="tag">关系 ${model.relations.length}</span>
8329
+ <span class="tag">公理 ${model.axioms.length}</span><span class="tag">实例 ${model.instances.length}</span>
8330
+ <span class="tag">外部链 ${model.meta.imports.length}</span>
8331
+ </div>
8332
+ <div id="detail"><div class="meta">点左侧任一方块看它的详情(注释 / 属性 / 进出关系 / 涉及的公理)。</div></div>
8333
+ ${notes}${unknowns}
8334
+ <h2>图例</h2>
8335
+ <div class="meta">
8336
+ <div><span class="pill" style="background:#1e3a5f">稳定 rigid</span> <span class="pill" style="background:#4a2f5f">易变 anti-rigid</span> <span class="pill" style="background:#2f4a3a">半稳定</span> <span class="pill" style="background:#3a3f46">未声明</span></div>
8337
+ <div style="margin-top:6px">蓝线=对象关系(带基数) · 红虚线=公理涉及 · 黄虚线=实例归属 · 绿线=外部链</div>
8338
+ </div>
8339
+ </div>
8340
+ </div>
8341
+ <script>
8342
+ const DATA=${data}; const BY=new Map(DATA.map(d=>[d.id,d]));
8343
+ const svg=document.getElementById('graph'), stage=document.getElementById('stage');
8344
+ let k=1,tx=0,ty=0;
8345
+ function apply(){svg.style.transform=\`translate(\${tx}px,\${ty}px) scale(\${k})\`}
8346
+ function fit(){const w=svg.viewBox.baseVal.width,h=svg.viewBox.baseVal.height;
8347
+ const r=stage.getBoundingClientRect(); k=Math.min((r.width-40)/w,(r.height-40)/h,1.4); tx=(r.width-w*k)/2; ty=(r.height-h*k)/2; apply()}
8348
+ stage.addEventListener('mousedown',e=>{if(e.target.closest('#bar'))return;let sx=e.clientX,sy=e.clientY;const ox=tx,oy=ty;stage.classList.add('drag');
8349
+ const mv=ev=>{tx=ox+ev.clientX-sx;ty=oy+ev.clientY-sy;apply()};
8350
+ const up=()=>{stage.classList.remove('drag');window.removeEventListener('mousemove',mv);window.removeEventListener('mouseup',up)};
8351
+ window.addEventListener('mousemove',mv);window.addEventListener('mouseup',up)});
8352
+ stage.addEventListener('wheel',e=>{e.preventDefault();const r=stage.getBoundingClientRect();const mx=e.clientX-r.left,my=e.clientY-r.top;
8353
+ const f=e.deltaY<0?1.12:1/1.12;const nk=Math.max(.15,Math.min(6,k*f));tx=mx-(mx-tx)*(nk/k);ty=my-(my-ty)*(nk/k);k=nk;apply()},{passive:false});
8354
+ svg.querySelectorAll('rect[id]').forEach(el=>{el.style.cursor='pointer';
8355
+ el.addEventListener('click',ev=>{ev.stopPropagation();show(el.id);highlight(el.id)})});
8356
+ svg.querySelectorAll('rect').forEach(el=>{if(!el.id)el.style.pointerEvents='none'});
8357
+ function highlight(id){svg.querySelectorAll('rect[id]').forEach(r=>r.setAttribute('stroke',r.id===id?'#e6edf3':'#48536488'));
8358
+ svg.querySelectorAll('rect[id]').forEach(r=>r.setAttribute('stroke-width',r.id===id?'2.5':'1'))}
8359
+ function show(id){const d=BY.get(id);if(!d)return;
8360
+ const row=(a,b)=>\`<tr><td>\${esc(a)}</td><td class="mono">\${esc(b)}</td></tr>\`;
8361
+ let h=\`<h1>\${esc(d.title)}</h1>\`;
8362
+ if(d.comment)h+=\`<div class="meta" style="margin-bottom:8px">\${esc(d.comment)}</div>\`;
8363
+ h+='<table>'+d.rows.map(r=>row(r[0],r[1])).join('')+'</table>';
8364
+ if(d.linksOut.length)h+='<h2>出去的关系</h2>'+d.linksOut.map(x=>\`<div class="mono">\${esc(x)}</div>\`).join('');
8365
+ if(d.linksIn.length)h+='<h2>进来的关系</h2>'+d.linksIn.map(x=>\`<div class="mono">\${esc(x)}</div>\`).join('');
8366
+ if(d.axioms&&d.axioms.length)h+='<h2>涉及的公理</h2>'+d.axioms.map(x=>\`<div class="mono">\${esc(x)}</div>\`).join('');
8367
+ document.getElementById('detail').innerHTML=h}
8368
+ function esc(s){return String(s).replace(/[&<>"]/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;'}[c]))}
8369
+ document.getElementById('fit').onclick=fit;
8370
+ document.getElementById('reset').onclick=()=>{k=1;tx=0;ty=0;apply()};
8371
+ document.getElementById('q').oninput=e=>{const q=e.target.value.toLowerCase();
8372
+ svg.querySelectorAll('rect[id]').forEach(r=>{if(!q){r.style.opacity=1;return}
8373
+ const d=BY.get(r.id);const hay=(r.id+' '+JSON.stringify(d||'')).toLowerCase();
8374
+ r.style.opacity=hay.includes(q)?1:.16})};
8375
+ fit();
8376
+ <\/script></body></html>`;
8377
+ }
8378
+ //#endregion
8379
+ //#region src/diagram/fonts.ts
8380
+ /**
8381
+ * diagram 字体事实源(**唯一真相源**:资产路径 + 家族名 + CSS 家族栈)
8382
+ *
8383
+ * 为什么单独成模块(R↓):两处都要它,而它们的依赖面**必须**保持不同 ——
8384
+ * · `dsl-render.ts` 只生成 SVG 字符串(**纯函数、零 fs**),它需要在 SVG 里写 `font-family` 栈;
8385
+ * · `rasterize.ts` 要读字体文件喂给 wasm,并设 `defaultFontFamily`。
8386
+ * ⇒ 谁都不该从对方 import(前者会被拖进 `node:fs`/wasm,后者不该定义 SVG 措辞)。
8387
+ *
8388
+ * ## 为什么是**三个**字型(每一个都由一张实测图证明,不是偏好,也不是"多带点保险")
8389
+ *
8390
+ * | 字型 | 覆盖 | 它修好了什么(实测) |
8391
+ * |---|---|---|
8392
+ * | `DroidSansFallbackFull.ttf` | 汉字 | 没有它 ⇒ 中文全是方框(docker 实测:wasm 对系统字体**完全不可见**) |
8393
+ * | `NotoSans-Regular.ttf` | 拉丁 | 没有它 ⇒ `diagram` / `parseDsl` 全变方框(Droid 是 **CJK 兜底字型**,**不含拉丁**) |
8394
+ * | `DejaVuSans.ttf` | 符号 | 没有它 ⇒ `① ② ③` / `⇒ ⇒` / `→` 仍是方框(**前两个都没有这些字形**,实测对照图见 `docs/diagram-tool-feasibility.md`) |
8395
+ *
8396
+ * 🔴 **上一版的判据漏洞**(v1.51.1 修的):那条"有字 vs 无字逐字节不同"只证明**有墨**,
8397
+ * 没证明**墨是那个字** —— 方框也是墨 ⇒ 拉丁全变方框时它**照样绿**。
8398
+ * ⇒ 现在每条覆盖声明都配一条**点名字符集**的差分用例:同长度不同字形 ⇒ 必须出不同墨。
8399
+ *
8400
+ * ## 家族名是**量出来的**,不是猜的
8401
+ * 下表取自各文件 `name` 表 `nameID=1`(平台 3 / UTF-16BE):
8402
+ * `Droid Sans Fallback` / `Noto Sans` / `DejaVu Sans`。
8403
+ * 写错名字时 resvg 的自动回退仍可能把字画出来(**图上看不出来**)⇒ 名字正确与否只能这样证明。
8404
+ */
8405
+ const DIAGRAM_CJK_FONT_ASSET = "assets/diagram/DroidSansFallbackFull.ttf";
8406
+ const DIAGRAM_LATIN_FONT_ASSET = "assets/diagram/NotoSans-Regular.ttf";
8407
+ const DIAGRAM_SYMBOL_FONT_ASSET = "assets/diagram/DejaVuSans.ttf";
8408
+ /** 汉字字型的家族名(**字体文件自报的族名**,写错就取不到) */
8409
+ const DIAGRAM_CJK_FONT_FAMILY = "Droid Sans Fallback";
8410
+ /**
8411
+ * SVG 里写的家族栈(**顺序即回退顺序**):拉丁优先取 Noto,符号落 DejaVu,其余字形落到 Droid。
8412
+ * ⚠️ 末尾的通用名(`sans-serif`)只是兜底惯例:本部署里 resvg **不带系统字体**
8413
+ * (`loadSystemFonts: false`)⇒ 它取不到任何东西,真正的兜底是上面三个内置字型。
8414
+ */
8415
+ const DIAGRAM_FONT_STACK = `'Noto Sans', 'DejaVu Sans', '${DIAGRAM_CJK_FONT_FAMILY}', sans-serif`;
8416
+ //#endregion
8417
+ //#region src/diagram/rasterize.ts
8418
+ /**
8419
+ * diagram 光栅化 —— SVG → PNG(**纯进程内,不起浏览器、不截图**)
8420
+ *
8421
+ * 为什么是 `@resvg/resvg-wasm`(owner 2026-09-29 裁「内置」,判据 = 可行性调研 §7 的 docker 实测):
8422
+ * ① **纯 wasm**:无原生二进制、无平台/架构约束、无 postinstall 构建(公开包最怕那三样);
8423
+ * ② 实测渲染一张图尺寸画布 ≈60 ms / ~55 KB PNG。
8424
+ *
8425
+ * 🔴 **必须喂字体,且这是实测结论不是设计偏好**:docker 里给容器装好中文字体(`fc-list` 认它)
8426
+ * 后,resvg-wasm 渲染中文**仍得到空白画布**,且 `loadSystemFonts:true` 与 `false` **逐字节相同**
8427
+ * ⇒ **wasm 对系统字体完全不可见**。只有 `fontBuffers` 才画得出字。
8428
+ * ⇒ 顺带白拿一个好处:**输出与机器无关**(同一份内置字体 ⇒ 逐字节可复现,S↑ 稳定)。
8429
+ *
8430
+ * 字体资产随包分发(三个,各有各的覆盖,见 `fonts.ts` 的表):汉字 3.8 MB(Apache-2.0)/
8431
+ * 拉丁 0.5 MB(OFL-1.1)/符号 0.75 MB(DejaVu 许可证),各自带许可证与出处。
8432
+ */
8433
+ /**
8434
+ * 包根 = **向上找到含 `package.json` 的那一级**。
8435
+ *
8436
+ * 🔴 为什么不能照抄 `voyage-page.ts` 的「`..` 一次」:那个文件在 `src/` 根下,一次 `..` 恰好是包根;
8437
+ * 本模块在 `src/diagram/` 里 ⇒ 开发态(vitest 直接跑 TS 源码)一次 `..` 只到 `src/`,
8438
+ * 于是去找 `src/assets/…` —— **实测 ENOENT**(测试全红,且现象是"字体不在",与真因"路径层级算错"
8439
+ * 长得完全不同)。按 `package.json` 上溯对**开发态(src/diagram)与发布态(lib/diagram)都成立**。
8440
+ * @returns 包根绝对路径;四层内找不到 `package.json` 时回落"上一级"(不抛——定位失败由调用方响亮报错)
8441
+ */
8442
+ function packageRootPath$1() {
8443
+ const start = dirname(fileURLToPath(import.meta.url));
8444
+ let dir = start;
8445
+ for (let i = 0; i < 4; i++) {
8446
+ if (existsSync(join(dir, "package.json"))) return dir;
8447
+ dir = dirname(dir);
8448
+ }
8449
+ return join(start, "..");
8450
+ }
8451
+ /** 汉字字型绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
8452
+ function diagramFontPath() {
8453
+ return join(packageRootPath$1(), DIAGRAM_CJK_FONT_ASSET);
8454
+ }
8455
+ /** 拉丁字型绝对路径 */
8456
+ function diagramLatinFontPath() {
8457
+ return join(packageRootPath$1(), DIAGRAM_LATIN_FONT_ASSET);
8458
+ }
8459
+ /** 符号字型绝对路径 */
8460
+ function diagramSymbolFontPath() {
8461
+ return join(packageRootPath$1(), DIAGRAM_SYMBOL_FONT_ASSET);
8462
+ }
8463
+ /**
8464
+ * 兜底字型的族名 = **汉字字型**(本工具的图以中文为主)。
8465
+ * 两个内置字型的家族名与 CSS 家族栈统一定义在 `fonts.ts`(单一真相源),此处只透出兜底那一个。
8466
+ */
8467
+ const DIAGRAM_FONT_FAMILY = DIAGRAM_CJK_FONT_FAMILY;
8468
+ let wasmReady = null;
8469
+ let fontBuf = null;
8470
+ let latinBuf = null;
8471
+ let symbolBuf = null;
8472
+ /**
8473
+ * wasm 模块路径:**先解析 JS 入口再取同目录的 `.wasm`**。
8474
+ * 不直接 resolve `.wasm` 的原因:包的 `exports` 表未必暴露该子路径(解析失败会抛,
8475
+ * 而"抛"与"文件不在"在验收上长得一样 —— 故走这条更稳的路)。
8476
+ */
8477
+ function wasmPath() {
8478
+ const req = createRequire(import.meta.url);
8479
+ return join(dirname(req.resolve("@resvg/resvg-wasm")), "index_bg.wasm");
8480
+ }
8481
+ function ensureReady() {
8482
+ if (wasmReady === null) wasmReady = initWasm(readFileSync(wasmPath())).catch((err) => {
8483
+ if (/already initialized/iu.test(String(err?.message ?? err))) return;
8484
+ throw err;
8485
+ });
8486
+ return wasmReady;
8487
+ }
8488
+ /** 三个字型各只读一次(3.8 MB + 0.5 MB + 0.75 MB,进程内缓存) */
8489
+ function fontBuffer() {
8490
+ if (fontBuf === null) fontBuf = readFileSync(diagramFontPath());
8491
+ return fontBuf;
8492
+ }
8493
+ function latinFontBuffer() {
8494
+ if (latinBuf === null) latinBuf = readFileSync(diagramLatinFontPath());
8495
+ return latinBuf;
8496
+ }
8497
+ function symbolFontBuffer() {
8498
+ if (symbolBuf === null) symbolBuf = readFileSync(diagramSymbolFontPath());
8499
+ return symbolBuf;
8500
+ }
8501
+ /**
8502
+ * SVG → PNG。
8503
+ * @param width 输出像素宽(矢量无损放大;高度按 SVG 自身比例推出)
8504
+ */
8505
+ async function rasterizeSvg(svg, width) {
8506
+ await ensureReady();
8507
+ const t0 = Date.now();
8508
+ const img = new Resvg(svg, {
8509
+ fitTo: {
8510
+ mode: "width",
8511
+ value: width
8512
+ },
8513
+ font: {
8514
+ fontBuffers: [
8515
+ latinFontBuffer(),
8516
+ symbolFontBuffer(),
8517
+ fontBuffer()
8518
+ ],
8519
+ loadSystemFonts: false,
8520
+ defaultFontFamily: DIAGRAM_FONT_FAMILY
8521
+ }
8522
+ }).render();
8523
+ return {
8524
+ png: img.asPng(),
8525
+ width: img.width,
8526
+ height: img.height,
8527
+ ms: Date.now() - t0
8528
+ };
8529
+ }
8530
+ //#endregion
7662
8531
  //#region src/tools/ontology-eng.ts
7663
8532
  /**
7664
8533
  * ontology-eng.ts(工具面)— 本体工程**引擎**(v1.52.0,S142 D121)
@@ -7685,7 +8554,8 @@ const COMMANDS = [
7685
8554
  "explain",
7686
8555
  "verify",
7687
8556
  "review",
7688
- "compliance"
8557
+ "compliance",
8558
+ "viz"
7689
8559
  ];
7690
8560
  const LEVELS = [
7691
8561
  "normal",
@@ -7769,6 +8639,10 @@ function createOntologyTool() {
7769
8639
  type: "string",
7770
8640
  enum: [...FORMATS],
7771
8641
  description: "review / compliance only: prompt (LLM-readable checklist, default) or json."
8642
+ },
8643
+ png: {
8644
+ type: "boolean",
8645
+ description: "viz only: also rasterize a PNG next to the HTML (for pasting a static image into the conversation)."
7772
8646
  }
7773
8647
  },
7774
8648
  output: {
@@ -7785,6 +8659,35 @@ function createOntologyTool() {
7785
8659
  const absPath = resolveInside(root, rawPath);
7786
8660
  const relPath = relative(root, absPath) || ".";
7787
8661
  const argv = [command, absPath];
8662
+ if (command === "viz") {
8663
+ const model = fromEngine(relPath, absPath);
8664
+ const L = layout(model, {
8665
+ axioms: false,
8666
+ instances: true,
8667
+ imports: true
8668
+ });
8669
+ const svg = toSvg(model, L, { axioms: false });
8670
+ const html = toHtml(model, L, svg);
8671
+ const stem = basename(relPath).replace(/\.ttl$/, "");
8672
+ const outDir = join(root, "_tmp", "ontology-viz");
8673
+ mkdirSync(outDir, { recursive: true });
8674
+ const htmlPath = join(outDir, `${stem}.html`);
8675
+ writeFileSync(htmlPath, html, "utf-8");
8676
+ const lines = [
8677
+ `[ontology viz] ✓ ${relPath}`,
8678
+ ``,
8679
+ `类 ${model.classes.length} / 关系 ${model.relations.length} / 公理 ${model.axioms.length} / 实例 ${model.instances.length}`,
8680
+ `交互版 HTML ⇒ ${relative(root, htmlPath)}`,
8681
+ ` 打开后可拖动平移、滚轮缩放、点节点看详情(注释/属性/进出关系/涉及的公理)`
8682
+ ];
8683
+ if (args.png === true) {
8684
+ const r = await rasterizeSvg(svg, 2400);
8685
+ const pngPath = join(outDir, `${stem}.png`);
8686
+ writeFileSync(pngPath, r.png);
8687
+ lines.push(`静态 PNG ⇒ ${relative(root, pngPath)}(${r.width}×${r.height},${r.ms} ms)`);
8688
+ }
8689
+ return lines.join("\n");
8690
+ }
7788
8691
  const level = typeof args.level === "string" ? args.level.trim() : "";
7789
8692
  if (level !== "") {
7790
8693
  const allowed = LEVELS_BY_COMMAND[command];
@@ -14723,12 +15626,12 @@ const VOYAGE_PATH = "/serenity/voyage";
14723
15626
  */
14724
15627
  const VOYAGE_ASSET = "assets/serenity-voyage.html";
14725
15628
  /** 包根 = 本模块所在目录的上一级(构建产物在 `lib/`,与 `assets/` 同级;同 constants.ts 定位法) */
14726
- function packageRootPath$1() {
15629
+ function packageRootPath() {
14727
15630
  return join(dirname(fileURLToPath(import.meta.url)), "..");
14728
15631
  }
14729
15632
  /** 资产绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
14730
15633
  function voyageAssetPath() {
14731
- return join(packageRootPath$1(), VOYAGE_ASSET);
15634
+ return join(packageRootPath(), VOYAGE_ASSET);
14732
15635
  }
14733
15636
  /** 缓存(830 KB,避免每次展开卡片都读盘;进程内只读一次) */
14734
15637
  let cachedHtml = null;
@@ -23621,44 +24524,6 @@ function parseDsl(source) {
23621
24524
  };
23622
24525
  }
23623
24526
  //#endregion
23624
- //#region src/diagram/fonts.ts
23625
- /**
23626
- * diagram 字体事实源(**唯一真相源**:资产路径 + 家族名 + CSS 家族栈)
23627
- *
23628
- * 为什么单独成模块(R↓):两处都要它,而它们的依赖面**必须**保持不同 ——
23629
- * · `dsl-render.ts` 只生成 SVG 字符串(**纯函数、零 fs**),它需要在 SVG 里写 `font-family` 栈;
23630
- * · `rasterize.ts` 要读字体文件喂给 wasm,并设 `defaultFontFamily`。
23631
- * ⇒ 谁都不该从对方 import(前者会被拖进 `node:fs`/wasm,后者不该定义 SVG 措辞)。
23632
- *
23633
- * ## 为什么是**三个**字型(每一个都由一张实测图证明,不是偏好,也不是"多带点保险")
23634
- *
23635
- * | 字型 | 覆盖 | 它修好了什么(实测) |
23636
- * |---|---|---|
23637
- * | `DroidSansFallbackFull.ttf` | 汉字 | 没有它 ⇒ 中文全是方框(docker 实测:wasm 对系统字体**完全不可见**) |
23638
- * | `NotoSans-Regular.ttf` | 拉丁 | 没有它 ⇒ `diagram` / `parseDsl` 全变方框(Droid 是 **CJK 兜底字型**,**不含拉丁**) |
23639
- * | `DejaVuSans.ttf` | 符号 | 没有它 ⇒ `① ② ③` / `⇒ ⇒` / `→` 仍是方框(**前两个都没有这些字形**,实测对照图见 `docs/diagram-tool-feasibility.md`) |
23640
- *
23641
- * 🔴 **上一版的判据漏洞**(v1.51.1 修的):那条"有字 vs 无字逐字节不同"只证明**有墨**,
23642
- * 没证明**墨是那个字** —— 方框也是墨 ⇒ 拉丁全变方框时它**照样绿**。
23643
- * ⇒ 现在每条覆盖声明都配一条**点名字符集**的差分用例:同长度不同字形 ⇒ 必须出不同墨。
23644
- *
23645
- * ## 家族名是**量出来的**,不是猜的
23646
- * 下表取自各文件 `name` 表 `nameID=1`(平台 3 / UTF-16BE):
23647
- * `Droid Sans Fallback` / `Noto Sans` / `DejaVu Sans`。
23648
- * 写错名字时 resvg 的自动回退仍可能把字画出来(**图上看不出来**)⇒ 名字正确与否只能这样证明。
23649
- */
23650
- const DIAGRAM_CJK_FONT_ASSET = "assets/diagram/DroidSansFallbackFull.ttf";
23651
- const DIAGRAM_LATIN_FONT_ASSET = "assets/diagram/NotoSans-Regular.ttf";
23652
- const DIAGRAM_SYMBOL_FONT_ASSET = "assets/diagram/DejaVuSans.ttf";
23653
- /** 汉字字型的家族名(**字体文件自报的族名**,写错就取不到) */
23654
- const DIAGRAM_CJK_FONT_FAMILY = "Droid Sans Fallback";
23655
- /**
23656
- * SVG 里写的家族栈(**顺序即回退顺序**):拉丁优先取 Noto,符号落 DejaVu,其余字形落到 Droid。
23657
- * ⚠️ 末尾的通用名(`sans-serif`)只是兜底惯例:本部署里 resvg **不带系统字体**
23658
- * (`loadSystemFonts: false`)⇒ 它取不到任何东西,真正的兜底是上面三个内置字型。
23659
- */
23660
- const DIAGRAM_FONT_STACK = `'Noto Sans', 'DejaVu Sans', '${DIAGRAM_CJK_FONT_FAMILY}', sans-serif`;
23661
- //#endregion
23662
24527
  //#region src/diagram/dsl-render.ts
23663
24528
  const TITLE_BAND = 68;
23664
24529
  const EPS = .35;
@@ -23976,120 +24841,6 @@ function renderDsl(model) {
23976
24841
  };
23977
24842
  }
23978
24843
  //#endregion
23979
- //#region src/diagram/rasterize.ts
23980
- /**
23981
- * diagram 光栅化 —— SVG → PNG(**纯进程内,不起浏览器、不截图**)
23982
- *
23983
- * 为什么是 `@resvg/resvg-wasm`(owner 2026-09-29 裁「内置」,判据 = 可行性调研 §7 的 docker 实测):
23984
- * ① **纯 wasm**:无原生二进制、无平台/架构约束、无 postinstall 构建(公开包最怕那三样);
23985
- * ② 实测渲染一张图尺寸画布 ≈60 ms / ~55 KB PNG。
23986
- *
23987
- * 🔴 **必须喂字体,且这是实测结论不是设计偏好**:docker 里给容器装好中文字体(`fc-list` 认它)
23988
- * 后,resvg-wasm 渲染中文**仍得到空白画布**,且 `loadSystemFonts:true` 与 `false` **逐字节相同**
23989
- * ⇒ **wasm 对系统字体完全不可见**。只有 `fontBuffers` 才画得出字。
23990
- * ⇒ 顺带白拿一个好处:**输出与机器无关**(同一份内置字体 ⇒ 逐字节可复现,S↑ 稳定)。
23991
- *
23992
- * 字体资产随包分发(三个,各有各的覆盖,见 `fonts.ts` 的表):汉字 3.8 MB(Apache-2.0)/
23993
- * 拉丁 0.5 MB(OFL-1.1)/符号 0.75 MB(DejaVu 许可证),各自带许可证与出处。
23994
- */
23995
- /**
23996
- * 包根 = **向上找到含 `package.json` 的那一级**。
23997
- *
23998
- * 🔴 为什么不能照抄 `voyage-page.ts` 的「`..` 一次」:那个文件在 `src/` 根下,一次 `..` 恰好是包根;
23999
- * 本模块在 `src/diagram/` 里 ⇒ 开发态(vitest 直接跑 TS 源码)一次 `..` 只到 `src/`,
24000
- * 于是去找 `src/assets/…` —— **实测 ENOENT**(测试全红,且现象是"字体不在",与真因"路径层级算错"
24001
- * 长得完全不同)。按 `package.json` 上溯对**开发态(src/diagram)与发布态(lib/diagram)都成立**。
24002
- * @returns 包根绝对路径;四层内找不到 `package.json` 时回落"上一级"(不抛——定位失败由调用方响亮报错)
24003
- */
24004
- function packageRootPath() {
24005
- const start = dirname(fileURLToPath(import.meta.url));
24006
- let dir = start;
24007
- for (let i = 0; i < 4; i++) {
24008
- if (existsSync(join(dir, "package.json"))) return dir;
24009
- dir = dirname(dir);
24010
- }
24011
- return join(start, "..");
24012
- }
24013
- /** 汉字字型绝对路径(导出以便测试与 pack-check 共用同一判据来源) */
24014
- function diagramFontPath() {
24015
- return join(packageRootPath(), DIAGRAM_CJK_FONT_ASSET);
24016
- }
24017
- /** 拉丁字型绝对路径 */
24018
- function diagramLatinFontPath() {
24019
- return join(packageRootPath(), DIAGRAM_LATIN_FONT_ASSET);
24020
- }
24021
- /** 符号字型绝对路径 */
24022
- function diagramSymbolFontPath() {
24023
- return join(packageRootPath(), DIAGRAM_SYMBOL_FONT_ASSET);
24024
- }
24025
- /**
24026
- * 兜底字型的族名 = **汉字字型**(本工具的图以中文为主)。
24027
- * 两个内置字型的家族名与 CSS 家族栈统一定义在 `fonts.ts`(单一真相源),此处只透出兜底那一个。
24028
- */
24029
- const DIAGRAM_FONT_FAMILY = DIAGRAM_CJK_FONT_FAMILY;
24030
- let wasmReady = null;
24031
- let fontBuf = null;
24032
- let latinBuf = null;
24033
- let symbolBuf = null;
24034
- /**
24035
- * wasm 模块路径:**先解析 JS 入口再取同目录的 `.wasm`**。
24036
- * 不直接 resolve `.wasm` 的原因:包的 `exports` 表未必暴露该子路径(解析失败会抛,
24037
- * 而"抛"与"文件不在"在验收上长得一样 —— 故走这条更稳的路)。
24038
- */
24039
- function wasmPath() {
24040
- const req = createRequire(import.meta.url);
24041
- return join(dirname(req.resolve("@resvg/resvg-wasm")), "index_bg.wasm");
24042
- }
24043
- function ensureReady() {
24044
- if (wasmReady === null) wasmReady = initWasm(readFileSync(wasmPath())).catch((err) => {
24045
- if (/already initialized/iu.test(String(err?.message ?? err))) return;
24046
- throw err;
24047
- });
24048
- return wasmReady;
24049
- }
24050
- /** 三个字型各只读一次(3.8 MB + 0.5 MB + 0.75 MB,进程内缓存) */
24051
- function fontBuffer() {
24052
- if (fontBuf === null) fontBuf = readFileSync(diagramFontPath());
24053
- return fontBuf;
24054
- }
24055
- function latinFontBuffer() {
24056
- if (latinBuf === null) latinBuf = readFileSync(diagramLatinFontPath());
24057
- return latinBuf;
24058
- }
24059
- function symbolFontBuffer() {
24060
- if (symbolBuf === null) symbolBuf = readFileSync(diagramSymbolFontPath());
24061
- return symbolBuf;
24062
- }
24063
- /**
24064
- * SVG → PNG。
24065
- * @param width 输出像素宽(矢量无损放大;高度按 SVG 自身比例推出)
24066
- */
24067
- async function rasterizeSvg(svg, width) {
24068
- await ensureReady();
24069
- const t0 = Date.now();
24070
- const img = new Resvg(svg, {
24071
- fitTo: {
24072
- mode: "width",
24073
- value: width
24074
- },
24075
- font: {
24076
- fontBuffers: [
24077
- latinFontBuffer(),
24078
- symbolFontBuffer(),
24079
- fontBuffer()
24080
- ],
24081
- loadSystemFonts: false,
24082
- defaultFontFamily: DIAGRAM_FONT_FAMILY
24083
- }
24084
- }).render();
24085
- return {
24086
- png: img.asPng(),
24087
- width: img.width,
24088
- height: img.height,
24089
- ms: Date.now() - t0
24090
- };
24091
- }
24092
- //#endregion
24093
24844
  //#region src/tools/diagram.ts
24094
24845
  /**
24095
24846
  * diagram.ts — `diagram` 工具:**agent 直接给一段标签格式源码,自动出图**