@polycode-projects/the-mechanical-code-talker 2.2.0 → 2.3.1

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 (137) hide show
  1. package/bin/tmct.mjs +4 -5
  2. package/corpus/LICENSES.json +19 -4
  3. package/corpus/README.md +48 -0
  4. package/corpus/generated/README.md +24 -9
  5. package/corpus/generated/ace-surface-variants.jsonl +4 -1
  6. package/corpus/generated/manifest.json +4 -4
  7. package/corpus/prose/manifest.json +512 -0
  8. package/corpus/prose/sqlite/LICENSE-NOTICE +53 -0
  9. package/corpus/prose/sqlite/arch.txt +213 -0
  10. package/corpus/prose/sqlite/atomiccommit.txt +1117 -0
  11. package/corpus/prose/sqlite/faq.txt +473 -0
  12. package/corpus/prose/sqlite/fileformat.txt +1589 -0
  13. package/corpus/prose/sqlite/lang_createtable.txt +1339 -0
  14. package/corpus/prose/sqlite/lang_insert.txt +580 -0
  15. package/corpus/prose/sqlite/lang_select.txt +3293 -0
  16. package/corpus/prose/sqlite/optoverview.txt +908 -0
  17. package/corpus/prose/sqlite/queryplanner.txt +447 -0
  18. package/corpus/prose/sqlite/transactional.txt +41 -0
  19. package/corpus/prose/sqlite/wal.txt +567 -0
  20. package/corpus/prose/sqlite/whentouse.txt +300 -0
  21. package/corpus/prose/wikipedia/Apple.txt +4 -0
  22. package/corpus/prose/wikipedia/Attempto_Controlled_English.txt +169 -0
  23. package/corpus/prose/wikipedia/Automated_planning_and_scheduling.txt +67 -0
  24. package/corpus/prose/wikipedia/Bee.txt +7 -0
  25. package/corpus/prose/wikipedia/Bird.txt +8 -0
  26. package/corpus/prose/wikipedia/Bone.txt +4 -0
  27. package/corpus/prose/wikipedia/Book.txt +7 -0
  28. package/corpus/prose/wikipedia/Bread.txt +6 -0
  29. package/corpus/prose/wikipedia/Butterfly.txt +6 -0
  30. package/corpus/prose/wikipedia/Car.txt +1 -0
  31. package/corpus/prose/wikipedia/Cat.txt +1 -0
  32. package/corpus/prose/wikipedia/Child.txt +3 -0
  33. package/corpus/prose/wikipedia/City.txt +2 -0
  34. package/corpus/prose/wikipedia/Clock.txt +2 -0
  35. package/corpus/prose/wikipedia/Cooking.txt +1 -0
  36. package/corpus/prose/wikipedia/Description_logic.txt +660 -0
  37. package/corpus/prose/wikipedia/Doctor.txt +6 -0
  38. package/corpus/prose/wikipedia/Dog.txt +4 -0
  39. package/corpus/prose/wikipedia/Eagle.txt +4 -0
  40. package/corpus/prose/wikipedia/Emotion.txt +9 -0
  41. package/corpus/prose/wikipedia/Eye.txt +5 -0
  42. package/corpus/prose/wikipedia/Family.txt +3 -0
  43. package/corpus/prose/wikipedia/Farm.txt +4 -0
  44. package/corpus/prose/wikipedia/Fear.txt +4 -0
  45. package/corpus/prose/wikipedia/First-order_logic.txt +1518 -0
  46. package/corpus/prose/wikipedia/Fish.txt +10 -0
  47. package/corpus/prose/wikipedia/Flower.txt +3 -0
  48. package/corpus/prose/wikipedia/Food.txt +10 -0
  49. package/corpus/prose/wikipedia/Grass.txt +9 -0
  50. package/corpus/prose/wikipedia/Hand.txt +2 -0
  51. package/corpus/prose/wikipedia/Happiness.txt +3 -0
  52. package/corpus/prose/wikipedia/Heart.txt +4 -0
  53. package/corpus/prose/wikipedia/Horse.txt +4 -0
  54. package/corpus/prose/wikipedia/House.txt +6 -0
  55. package/corpus/prose/wikipedia/Human.txt +4 -0
  56. package/corpus/prose/wikipedia/Insect.txt +6 -0
  57. package/corpus/prose/wikipedia/Interactive_fiction.txt +112 -0
  58. package/corpus/prose/wikipedia/Knowledge.txt +5 -0
  59. package/corpus/prose/wikipedia/Knowledge_representation_and_reasoning.txt +87 -0
  60. package/corpus/prose/wikipedia/LICENSE-NOTICE +94 -0
  61. package/corpus/prose/wikipedia/Language.txt +10 -0
  62. package/corpus/prose/wikipedia/Learning.txt +4 -0
  63. package/corpus/prose/wikipedia/Mammal.txt +3 -0
  64. package/corpus/prose/wikipedia/Memory.txt +5 -0
  65. package/corpus/prose/wikipedia/Milk.txt +1 -0
  66. package/corpus/prose/wikipedia/Mountain.txt +1 -0
  67. package/corpus/prose/wikipedia/Natural_language_processing.txt +211 -0
  68. package/corpus/prose/wikipedia/Ostrich.txt +2 -0
  69. package/corpus/prose/wikipedia/Owl.txt +2 -0
  70. package/corpus/prose/wikipedia/Penguin.txt +2 -0
  71. package/corpus/prose/wikipedia/Plant.txt +5 -0
  72. package/corpus/prose/wikipedia/Rain.txt +1 -0
  73. package/corpus/prose/wikipedia/Resource_Description_Framework.txt +184 -0
  74. package/corpus/prose/wikipedia/River.txt +1 -0
  75. package/corpus/prose/wikipedia/School.txt +8 -0
  76. package/corpus/prose/wikipedia/Sea.txt +1 -0
  77. package/corpus/prose/wikipedia/Semantic_Web.txt +114 -0
  78. package/corpus/prose/wikipedia/Semantic_reasoner.txt +29 -0
  79. package/corpus/prose/wikipedia/Snow.txt +5 -0
  80. package/corpus/prose/wikipedia/Sun.txt +5 -0
  81. package/corpus/prose/wikipedia/Teacher.txt +4 -0
  82. package/corpus/prose/wikipedia/Team.txt +3 -0
  83. package/corpus/prose/wikipedia/Text-based_game.txt +17 -0
  84. package/corpus/prose/wikipedia/Tool.txt +4 -0
  85. package/corpus/prose/wikipedia/Tree.txt +7 -0
  86. package/corpus/prose/wikipedia/Weather.txt +4 -0
  87. package/corpus/prose/wikipedia/Web_Ontology_Language.txt +133 -0
  88. package/corpus/prose/wikipedia/Wind.txt +8 -0
  89. package/corpus/prose/wikipedia/Writing.txt +5 -0
  90. package/corpus/wordnet/generate.mjs +6 -7
  91. package/package.json +31 -2
  92. package/src/adapters/corpus/conceptnet.mjs +1 -1
  93. package/src/adapters/graph-build.mjs +3 -3
  94. package/src/adapters/memory/blocks.mjs +2 -2
  95. package/src/adapters/memory/core.mjs +5 -5
  96. package/src/adapters/providers/bootstrap.mjs +1 -1
  97. package/src/adapters/providers/fixture.mjs +1 -1
  98. package/src/adapters/wink-model.mjs +1 -1
  99. package/src/adapters/wordnet-source.mjs +70 -0
  100. package/src/domain/answer-variants.json +1 -1
  101. package/src/domain/ask-vocab.mjs +2 -2
  102. package/src/domain/ask.mjs +4 -4
  103. package/src/domain/codegraph.mjs +3 -3
  104. package/src/domain/corpus-matrix.mjs +87 -0
  105. package/src/domain/grammar/ace.mjs +11 -11
  106. package/src/domain/grammar/lexicon.mjs +3 -3
  107. package/src/domain/inflect.mjs +67 -0
  108. package/src/domain/interpret/fuzzy.mjs +1 -1
  109. package/src/domain/interpret/merge.mjs +1 -1
  110. package/src/domain/interpret/normalize.mjs +1 -1
  111. package/src/domain/licences.mjs +68 -0
  112. package/src/domain/memory/capability.mjs +1 -1
  113. package/src/domain/memory/trust.mjs +2 -2
  114. package/src/domain/persona/codegen.mjs +123 -0
  115. package/src/domain/persona/examples.mjs +26 -0
  116. package/src/domain/persona/tiers.mjs +270 -0
  117. package/src/domain/publish-gate.mjs +41 -0
  118. package/src/domain/router/call-validator.mjs +1 -1
  119. package/src/domain/router/drive.mjs +3 -4
  120. package/src/domain/router/registry.mjs +12 -13
  121. package/src/domain/router/resolver.mjs +18 -5
  122. package/src/domain/router/results.mjs +3 -3
  123. package/src/domain/router/taught.mjs +4 -3
  124. package/src/domain/schemaorg/turtle.mjs +25 -0
  125. package/src/domain/semcor/parse.mjs +87 -0
  126. package/src/domain/syllogise.mjs +6 -6
  127. package/src/domain/version-stamp.mjs +36 -0
  128. package/src/domain/wordnet/yaml.mjs +133 -0
  129. package/src/services/chat-session.mjs +2 -2
  130. package/src/services/chat.mjs +2 -2
  131. package/src/services/cli-args.mjs +4 -4
  132. package/src/services/finish.mjs +1 -1
  133. package/src/services/ledger-viz.mjs +2 -3
  134. package/src/services/sessions.mjs +4 -4
  135. package/src/services/viz-theme.mjs +3 -4
  136. package/src/surfaces/web/memory-ask-browser.bundle.js +1 -18
  137. package/src/domain/router/guardrail.mjs +0 -116
@@ -0,0 +1,123 @@
1
+ // codegen.mjs — renders a reviewed persona-tier worksheet as JS source, ready
2
+ // to splice into corpus/tier2/generate.mjs's CORPUSES object, and as the
3
+ // lexicon-core.json noun entries those facts need.
4
+ //
5
+ // Pure: worksheet in, source text out, no imports.
6
+
7
+ export const CLUMP_ORDER = [
8
+ "human-core", "human-places", "human-objects", "human-nature",
9
+ "human-time-events", "human-body-food", "human-mind",
10
+ ];
11
+
12
+ export const CLUMP_LABEL = {
13
+ "human-core": "people, family, common roles",
14
+ "human-places": "places",
15
+ "human-objects": "objects, clothing, tools",
16
+ "human-nature": "animals, plants, substances",
17
+ "human-time-events": "time and events",
18
+ "human-body-food": "body and food",
19
+ "human-mind": "communication, cognition, feeling",
20
+ };
21
+
22
+ // The irregular plurals among the words these batches introduce (Latin/Greek-
23
+ // derived cognition/body/nature vocabulary especially). A word absent from this
24
+ // map gets `{}` and relies on the regular -s/-es/-ies suffix fold at LOOKUP
25
+ // time (src/domain/grammar/lexicon.mjs's foldCandidates), same as the vast
26
+ // majority of Small tier's own additions. So this map is not a pluralizer: it
27
+ // is the list of exceptions that folding cannot recover, and declaring a
28
+ // regular plural here would be redundant.
29
+ //
30
+ // This is deliberately NOT src/domain/inflect.mjs's pluralOf, and the two must
31
+ // not be merged. They answer opposite questions. pluralOf GENERATES candidate
32
+ // surface forms for the real-word collision table, where over-generating is the
33
+ // cheap mistake and its own header commits to regular rules only — it wants
34
+ // "foots", because a form it fails to generate is a real word the repair tier
35
+ // may rewrite into a different question. This map DECLARES the one correct
36
+ // plural for a lexicon entry, where "foots" would simply be a lie the grammar
37
+ // then trusts. Teaching pluralOf about "feet" would cost the collision table
38
+ // "foots"; deriving this map from pluralOf would put "foots" in the lexicon.
39
+ export const IRREGULAR_PLURALS = {
40
+ foot: "feet", tooth: "teeth", goose: "geese", ox: "oxen", die: "dice",
41
+ louse: "lice", mouse: "mice", crisis: "crises", analysis: "analyses",
42
+ hypothesis: "hypotheses", thesis: "theses", axis: "axes", basis: "bases",
43
+ oasis: "oases", criterion: "criteria", phenomenon: "phenomena",
44
+ alumnus: "alumni", cactus: "cacti", focus: "foci", radius: "radii",
45
+ fungus: "fungi", nucleus: "nuclei", stimulus: "stimuli", larva: "larvae",
46
+ alga: "algae", vertebra: "vertebrae", antenna: "antennae",
47
+ formula: "formulae", datum: "data", medium: "media", index: "indices",
48
+ matrix: "matrices", appendix: "appendices", curriculum: "curricula",
49
+ memorandum: "memoranda", millennium: "millennia", bacterium: "bacteria",
50
+ stratum: "strata", genus: "genera", species: "species", series: "series",
51
+ sheep: "sheep", deer: "deer", moose: "moose", salmon: "salmon",
52
+ trout: "trout", swine: "swine", offspring: "offspring", spacecraft: "spacecraft",
53
+ aircraft: "aircraft", scissors: "scissors", knife: "knives", wife: "wives",
54
+ life: "lives", leaf: "leaves", loaf: "loaves", thief: "thieves",
55
+ shelf: "shelves", elf: "elves", calf: "calves", half: "halves",
56
+ wolf: "wolves", self: "selves", woman: "women", man: "men",
57
+ child: "children", person: "people", tempo: "tempi",
58
+ };
59
+
60
+ /** One noun's lexicon-core.json entry: an explicit plural only where folding
61
+ * could not recover it. */
62
+ export function lexiconNounEntry(word) {
63
+ const plural = IRREGULAR_PLURALS[word];
64
+ return plural ? { plural } : {};
65
+ }
66
+
67
+ /** One clump's facts as indented JS array-literal source lines. */
68
+ export function factsBlock(facts) {
69
+ return facts.map(([s, r, o]) => ` ["${s}", "${r}", "${o}"],`).join("\n");
70
+ }
71
+
72
+ /** A noun list wrapped at ~8 words/line, matching Small tier's own
73
+ * lexicon-list style. */
74
+ export function nounsListBlock(words) {
75
+ const lines = [];
76
+ for (let i = 0; i < words.length; i += 8) {
77
+ lines.push(` ${words.slice(i, i + 8).map((w) => `"${w}"`).join(", ")},`);
78
+ }
79
+ return lines.join("\n");
80
+ }
81
+
82
+ /** One CORPUSES entry as JS source, for `id` ("human-medium"/"human-large")
83
+ * from `byClump`, the reviewed worksheet's per-clump {facts, newNouns}. */
84
+ export function buildCorpusEntry(id, tierLabel, byClump) {
85
+ const nounsSrc = CLUMP_ORDER.map((c) => ` // ${c} (+${byClump[c].newNouns.length} words: ${CLUMP_LABEL[c]})\n${nounsListBlock(byClump[c].newNouns)}`).join("\n");
86
+ const factsSrc = CLUMP_ORDER.map((c) => ` // ---- ${c} (+${byClump[c].facts.length} facts, ${tierLabel}) ----\n${factsBlock(byClump[c].facts)}`).join("\n\n");
87
+
88
+ return `
89
+ // ${tierLabel} tier — INCREMENTAL facts beyond ${id === "human-medium" ? "Small (corpus/tier2/human.jsonl)" : "Medium (corpus/tier2/human-medium.jsonl)"} only
90
+ // (PLAN_SEED.md §3's tier-selection design: Small/Medium/Large are SIZES of
91
+ // one bundle, not separate corpus ids — this file holds only what ${tierLabel}
92
+ // ADDS beyond the previous tier). Built by scripts/build-persona-tiers.mjs
93
+ // from the same two locally-cloned WordNet source files as Small
94
+ // (~/projects/globalwordnet/english-wordnet/src/yaml/), automatically
95
+ // curated: candidate words ranked by WordNet sense-count (a commonness
96
+ // proxy, PLAN_SEED.md §12), restricted to each word's own TOP senses (not
97
+ // some rare/slang meaning that happens to live in this domain), obscure/
98
+ // archaic/offensive/pharmaceutical content excluded via a definition-text
99
+ // blocklist plus an explicit word denylist, reviewed by hand before being
100
+ // spliced in here (scripts/apply-persona-tiers.mjs). ${id === "human-large" ? "Large's own facts walk real multi-hop hypernym chains (up to 4 hops, PLAN_SEED.md §3's own \"surgeon ⊑ doctor ⊑ … ⊑ person\" example) wherever WordNet's real structure supports it, not a flat one-hop-per-word cap." : "Medium stays flat, one hop per word, same style as Small."}
101
+ "${id}": {
102
+ kind: "domain",
103
+ description: "The ${tierLabel} tier of the default human-world persona (PLAN_SEED.md): incremental facts beyond ${id === "human-medium" ? "Small" : "Medium"} only — activated alongside \\"human\\" via --persona-size ${id === "human-medium" ? "medium" : "large"}, never active by default.",
104
+ lexicon: {
105
+ nouns: [
106
+ ${nounsSrc}
107
+ ],
108
+ },
109
+ facts: [
110
+ ${factsSrc}
111
+ ],
112
+ },
113
+ `;
114
+ }
115
+
116
+ /** The CORPUSES source with `entries` spliced in before its closing brace.
117
+ * Throws when the anchor is absent rather than writing a mangled file. */
118
+ export function spliceCorpusEntries(generateSrc, entries) {
119
+ const anchor = "\n};\n\nconst conceptUri = ";
120
+ const idx = generateSrc.indexOf(anchor);
121
+ if (idx === -1) throw new Error("apply-persona-tiers: could not find CORPUSES closing anchor in generate.mjs");
122
+ return generateSrc.slice(0, idx) + entries.join("") + generateSrc.slice(idx + 1);
123
+ }
@@ -0,0 +1,26 @@
1
+ // examples.mjs — the two rules that decide whether a WordNet `example:` field
2
+ // can serve as a persona example sentence, and what its text actually is.
3
+ //
4
+ // Pure: values in, values out, no imports.
5
+
6
+ /** A handful of WordNet examples are cross-reference stubs ("see table 1"),
7
+ * not real sentences. Filtering them keeps a re-run reproducing the committed
8
+ * corpus/tier2/human-examples.jsonl exactly — this was the one candidate
9
+ * dropped by hand when that file was first curated. */
10
+ export const isRealSentence = (s) => !/^see\s+\w+\s*\d*\.?$/i.test(String(s).trim());
11
+
12
+ /** A WordNet `example:` is usually a plain string, but a few are an ATTRIBUTED
13
+ * LITERARY QUOTE — a `{source, text}` mapping, e.g. "ecstasy"'s example is
14
+ * `{source: "Charles Dickens", text: "listening to sweet music…"}`. That shape
15
+ * is real and was found live while extending coverage past Small tier's own
16
+ * 665-word list; none of Small's words happened to hit it, so it went uncaught
17
+ * until Medium/Large's much wider coverage.
18
+ *
19
+ * Returns the plain sentence text, or null for any other shape. The literary
20
+ * source is real but this corpus wants a plain example sentence, not a
21
+ * citation index. */
22
+ export function normalizeExample(example) {
23
+ if (typeof example === "string") return example;
24
+ if (example && typeof example === "object" && typeof example.text === "string") return example.text;
25
+ return null;
26
+ }
@@ -0,0 +1,270 @@
1
+ // tiers.mjs — the curation rules that build the Medium/Large persona tiers out
2
+ // of real WordNet structure. No invented facts: every hop and every meronym is
3
+ // a pointer WordNet already declares.
4
+ //
5
+ // Pure throughout — these read in-memory maps a caller loaded from disk, so
6
+ // they are testable with no WordNet clone present. The loading lives in
7
+ // src/adapters/wordnet-source.mjs, the fact targets and the run itself in
8
+ // scripts/build-persona-tiers.mjs.
9
+
10
+ // human-base's own category roots, plus every hypernym TARGET term Small's
11
+ // curation already established as a "root" word (generate.mjs's own comment:
12
+ // "category-root nouns used as a hypernym TARGET") — a real hypernym chain
13
+ // walk stops here rather than continuing on to WordNet's ultra-abstract
14
+ // "entity"/"abstraction"/"physical_entity" tops, which would add depth
15
+ // without adding anything a plain-English question would ever ask about.
16
+ export const STOP_SET = new Set([
17
+ "person", "place", "object", "event", "time", "quantity", "organization", "group",
18
+ "animal", "plant", "furniture", "vehicle", "insect", "emotion", "metal", "liquid",
19
+ "weather", "planet", "jewelry", "cutlery", "government", "material", "artifact",
20
+ "location", "structure", "food", "drink", "clothing", "body", "language", "mind",
21
+ "family", "meal", "season", "number", "entity", "abstraction", "physical_entity",
22
+ "attribute", "state", "act", "communication", "cognition", "measure", "unit",
23
+ ]);
24
+
25
+ export const BLOCKLIST_RE = /\b(archaic|obsolete|offensive|derogatory|informal|slang|dialect|euphemism|hypothetical|imaginary|mythical|mythology|extraterrestrial|fictional|taxonomic genus|genus of|family [A-Z]|nonstandard|vulgar|disparaging|obscene|coarse|genital|ethnic slur|ethnic epithet|excrement|contemptuous|insulting|trade name|street name|controlled substance|illegal|sexual assault|monoclonal antibody|chemical compound|chemical formula|proprietary name)\b/i;
26
+
27
+ // A short, explicit denylist for specific words WordNet's own definitions
28
+ // don't reliably self-tag (the blocklist regex above misses some — e.g. the
29
+ // "female genitals" sense of a common word is tagged only "obscene terms
30
+ // for…", but the word itself has an unrelated clean sense too, so it isn't
31
+ // caught by filtering on OTHER senses' definitions). Checked directly
32
+ // against candidate headwords, not definitions.
33
+ export const WORD_DENYLIST = new Set([
34
+ "cunt", "pussy", "dick", "cock", "prick", "twat", "boob", "tit", "tits",
35
+ "fuck", "shit", "piss", "bitch", "whore", "slut", "fag", "faggot", "nigger",
36
+ "nigga", "spic", "chink", "kike", "wetback", "retard", "cripple",
37
+ "asshole", "poop", "rape", "bastard",
38
+ // deictic/function words that happen to carry a marginal WordNet noun
39
+ // sense ("here" = "this place") — technically real, pragmatically not
40
+ // something a plain-English question would ever ask "what is X" about.
41
+ "here", "there", "somewhere", "elsewhere", "nowhere", "anywhere", "everywhere",
42
+ // Real, live test-fixture collisions (test/fixtures/entities.fixture.json's
43
+ // code-graph class/individual names double as ordinary WordNet-common
44
+ // words) — found by actually running the test suite against the first
45
+ // draft of this batch, not guessed in advance. "base"/"button" are
46
+ // exactly the kind of everyday-but-also-a-common-class-name word that
47
+ // will keep recurring as the persona vocabulary grows; excluded rather
48
+ // than editing the shared fixture (many other tests depend on its exact
49
+ // shape). "john" is also excluded on its own merits — WordNet's sense
50
+ // for it (a prostitute's customer) is exactly the "obscure/informal
51
+ // long-tail" this batch's curation is meant to skip, its own definition
52
+ // just doesn't happen to carry one of the blocklist's tag words.
53
+ "base", "button", "register", "john", "store",
54
+ ]);
55
+
56
+ const WORD_RE = /^[a-z]+$/;
57
+
58
+ const humanize = (term) => String(term).replace(/_/g, " ");
59
+
60
+ /** Definition text of a synset (first line only — enough for the blocklist). */
61
+ export function defOf(synset) {
62
+ return Array.isArray(synset?.definition) ? synset.definition[0] : synset?.definition || "";
63
+ }
64
+
65
+ /** Every word the lexicon already declares, across ALL THREE parts of speech,
66
+ * plus the previous tier's own nouns.
67
+ *
68
+ * Adjectives and verbs count, not just nouns: a word already declared as an
69
+ * adjective (e.g. "male") must never ALSO become a noun. That was a real bug,
70
+ * caught only by running the suite — the first pass added "male" as a noun
71
+ * since WordNet legitimately has that sense too, which made ACE reclassify
72
+ * "ahab is male" as class-membership (rdfs:subClassOf) instead of the intended
73
+ * property fact (mgx:hasProperty), silently breaking every filter-rule test
74
+ * built on "who is male". Nouns/verbs/adjectives are independent lookup maps
75
+ * and a word CAN legitimately sit in two ("cook", "love" already do, noun +
76
+ * verb), but a NEW second classification for an EXISTING word is never
77
+ * introduced — only the word's original part of speech is authoritative. */
78
+ export function declaredWords(lex, previousTierNouns = []) {
79
+ return new Set([
80
+ ...Object.keys(lex.nouns).map((w) => w.toLowerCase()),
81
+ ...Object.keys(lex.verbs).map((w) => w.toLowerCase()),
82
+ ...Object.keys(lex.adjectives).map((w) => w.toLowerCase()),
83
+ ...[...previousTierNouns].map((w) => w.toLowerCase()),
84
+ ]);
85
+ }
86
+
87
+ /** Walk UP a synset's hypernym chain from `synsetId`, resolving each
88
+ * ancestor's member[0] term, to check membership of a "building-like" root
89
+ * set (human-places' artifact-subtree filter) — up to 8 hops, memoized. */
90
+ export function makeAncestorRootCheck(synsetMap, rootWords) {
91
+ const memo = new Map();
92
+ function isUnderRoot(id, depth = 0) {
93
+ if (depth > 8 || !id) return false;
94
+ if (memo.has(id)) return memo.get(id);
95
+ const s = synsetMap.get(id);
96
+ if (!s) { memo.set(id, false); return false; }
97
+ const members = (s.members || []).map((m) => m.toLowerCase());
98
+ if (members.some((m) => rootWords.has(m))) { memo.set(id, true); return true; }
99
+ const hyperId = Array.isArray(s.hypernym) ? s.hypernym[0] : null;
100
+ const result = hyperId ? isUnderRoot(hyperId, depth + 1) : false;
101
+ memo.set(id, result);
102
+ return result;
103
+ }
104
+ return isUnderRoot;
105
+ }
106
+
107
+ // A candidate is only accepted for a clump if the synset we found it in is
108
+ // among the word's own TOP senses overall (its sense-rank in the entries
109
+ // reverse index, 0-based) — otherwise a common, highly polysemous word
110
+ // (e.g. "run", "light", "draw", "back") gets swept in via some rare/slang
111
+ // sense that just happens to live in this domain ("light" = a friend,
112
+ // "draw" = an entertainer), which is a genuinely obscure long-tail sense —
113
+ // just obscure at the SENSE level rather than the word level. Top-3 senses
114
+ // (rank <= 2) gives real latitude (a word's domain-relevant meaning is very
115
+ // often sense 2 or 3, not always sense 1) while still excluding deep-tail
116
+ // marginal senses.
117
+ export const MAX_SENSE_RANK = 2;
118
+
119
+ export function senseRank(word, synsetId, entriesIdx) {
120
+ const nounSenses = entriesIdx.get(word)?.senses?.n;
121
+ if (!nounSenses) return -1;
122
+ return nounSenses.findIndex((s) => s.synset === synsetId);
123
+ }
124
+
125
+ /** Candidate headwords from a set of synsets: up to 2 qualifying members per
126
+ * synset (real WordNet synonyms, not invented) — word regex, length bound,
127
+ * not blocklisted, not already used, and the synset must be among the
128
+ * word's own top senses (see senseRank above). */
129
+ export function collectCandidates(synsetEntries, usedWords, entriesIdx) {
130
+ const candidates = new Map(); // word -> first-seen synsetId (existence only)
131
+ for (const [id, synset] of synsetEntries) {
132
+ if (BLOCKLIST_RE.test(defOf(synset))) continue;
133
+ const members = synset.members || [];
134
+ let taken = 0;
135
+ for (const m of members) {
136
+ if (taken >= 2) break;
137
+ const w = String(m).toLowerCase();
138
+ if (!WORD_RE.test(w) || w.length < 2 || w.length > 16 || WORD_DENYLIST.has(w)) continue;
139
+ if (usedWords.has(w) || candidates.has(w)) continue;
140
+ const rank = senseRank(w, id, entriesIdx);
141
+ if (rank < 0 || rank > MAX_SENSE_RANK) continue;
142
+ candidates.set(w, id);
143
+ taken += 1;
144
+ }
145
+ }
146
+ return candidates;
147
+ }
148
+
149
+ /** Resolve a word to the SPECIFIC synset it was discovered under in the
150
+ * clump's own source file(s) — deliberately NOT the entries index's
151
+ * sense-1 (a word's globally-most-frequent sense across ALL of WordNet is
152
+ * routinely a completely different domain than the clump it was found in —
153
+ * e.g. "run" turning up as a noun.group.yaml member resolves, via a global
154
+ * sense-1 lookup, to a baseball score, not anything group-related). The
155
+ * entries index is used ONLY for the sense-count ranking heuristic
156
+ * (rankCandidates), never for resolution. */
157
+ export function resolveSynset(word, candidateSynsetId, synsetMap) {
158
+ const synset = synsetMap.get(candidateSynsetId);
159
+ if (!synset) return null;
160
+ return { synsetId: candidateSynsetId, synset };
161
+ }
162
+
163
+ // Chemical/pharmaceutical trade names (e.g. "methylenedioxymethamphetamine",
164
+ // "infliximab") are almost always a single very long unbroken word with no
165
+ // spaces — real everyday concepts, even multi-word ones ("medium of
166
+ // exchange"), never have an individual token this long. A cheap, effective
167
+ // shape filter: reject any candidate/hypernym/meronym TERM with a token over
168
+ // 15 characters, independent of the definition-text blocklist (which these
169
+ // technical entries routinely don't trip, since their definitions are
170
+ // clinically neutral — "a monoclonal antibody used to treat…" carries none
171
+ // of the archaic/slang/offensive keywords above).
172
+ export const looksLikeCommonTerm = (term) => String(term).split(" ").every((tok) => tok.length <= 15);
173
+
174
+ /** One real hypernym hop: [subjectTerm, "/r/IsA", hypernymTerm], plus the
175
+ * next synset to continue from (or null at a stop/dead end/blocklisted
176
+ * ancestor — a chain never walks INTO an obscure/archaic/mythical/technical
177
+ * concept, even if the word that started the chain was clean). */
178
+ export function nextHop(term, synsetId, synsetMap) {
179
+ const s = synsetMap.get(synsetId);
180
+ const hyperId = Array.isArray(s?.hypernym) ? s.hypernym[0] : null;
181
+ if (!hyperId) return null;
182
+ const hyper = synsetMap.get(hyperId);
183
+ if (BLOCKLIST_RE.test(defOf(hyper))) return null;
184
+ const hyperTerm = Array.isArray(hyper?.members) ? humanize(hyper.members[0]).toLowerCase() : null;
185
+ if (!hyperTerm || hyperTerm === term || !looksLikeCommonTerm(hyperTerm)) return null;
186
+ return { fact: [term, "/r/IsA", hyperTerm], nextSynsetId: hyperId, nextTerm: hyperTerm };
187
+ }
188
+
189
+ /** A real meronym-derived secondary fact for `synset`, preferring
190
+ * mero_part > mero_member > mero_substance (word HasA part / HasA member /
191
+ * MadeOf substance) — real WordNet pointers, never invented. */
192
+ export function meronymFact(word, synset, synsetMap) {
193
+ const pick = (key, rel) => {
194
+ const ids = synset[key];
195
+ if (!Array.isArray(ids) || !ids.length) return null;
196
+ const target = synsetMap.get(ids[0]);
197
+ if (BLOCKLIST_RE.test(defOf(target))) return null;
198
+ const term = Array.isArray(target?.members) ? humanize(target.members[0]).toLowerCase() : null;
199
+ if (!term || term === word || !looksLikeCommonTerm(term)) return null;
200
+ return [word, rel, term];
201
+ };
202
+ return pick("mero_part", "/r/HasA") || pick("mero_member", "/r/HasA") || pick("mero_substance", "/r/MadeOf");
203
+ }
204
+
205
+ // Sense-count score, tie-broken by shorter word then alphabetically —
206
+ // deterministic across re-runs (same inputs -> same output, no Math.random).
207
+ export function rankCandidates(words, entriesIdx) {
208
+ return [...words].sort((a, b) => {
209
+ const sa = entriesIdx.get(a)?.total || 0;
210
+ const sb = entriesIdx.get(b)?.total || 0;
211
+ if (sb !== sa) return sb - sa;
212
+ if (a.length !== b.length) return a.length - b.length;
213
+ return a < b ? -1 : a > b ? 1 : 0;
214
+ });
215
+ }
216
+
217
+ /** Build one tier's incremental facts + new-noun list for one clump.
218
+ * `candidatesMap` is word -> the SPECIFIC synset id it was discovered under
219
+ * (from collectCandidates) — the actual resolution source (see
220
+ * resolveSynset's doc comment); `entriesIdx` is used only for ranking. */
221
+ export function buildClump(clumpId, candidatesMap, entriesIdx, synsetMap, target, usedWords, seenTriples, opts) {
222
+ const { maxHops } = opts;
223
+ const ranked = rankCandidates(candidatesMap.keys(), entriesIdx);
224
+ const facts = [];
225
+ const newNouns = [];
226
+ for (const word of ranked) {
227
+ if (facts.length >= target) break;
228
+ if (usedWords.has(word)) continue;
229
+ const resolved = resolveSynset(word, candidatesMap.get(word), synsetMap);
230
+ if (!resolved) continue;
231
+ const wordFacts = [];
232
+ let curTerm = word;
233
+ let curSynsetId = resolved.synsetId;
234
+ for (let hop = 0; hop < maxHops; hop += 1) {
235
+ const h = nextHop(curTerm, curSynsetId, synsetMap);
236
+ if (!h) break;
237
+ const key = `${h.fact[0]}|${h.fact[1]}|${h.fact[2]}`;
238
+ if (!seenTriples.has(key)) { wordFacts.push(h.fact); seenTriples.add(key); }
239
+ if (STOP_SET.has(h.nextTerm)) break;
240
+ curTerm = h.nextTerm;
241
+ curSynsetId = h.nextSynsetId;
242
+ }
243
+ const mero = meronymFact(word, resolved.synset, synsetMap);
244
+ if (mero) {
245
+ const key = `${mero[0]}|${mero[1]}|${mero[2]}`;
246
+ if (!seenTriples.has(key)) { wordFacts.push(mero); seenTriples.add(key); }
247
+ }
248
+ if (!wordFacts.length) continue; // every candidate hop/mero fact was already present elsewhere — skip
249
+ facts.push(...wordFacts);
250
+ newNouns.push(word);
251
+ usedWords.add(word);
252
+ }
253
+ return { facts, newNouns, clumpId, requested: target, got: facts.length };
254
+ }
255
+
256
+ /** Final safety net: a denylisted word (see WORD_DENYLIST) can still reach a
257
+ * fact as a HYPERNYM/MERONYM TARGET (nextHop/meronymFact only check the
258
+ * definition-text blocklist + the shape filter, not the explicit word list —
259
+ * that list is deliberately checked here, once, against every final fact's
260
+ * subject AND object, rather than duplicated at every resolution call site).
261
+ * Drops the fact outright and prunes any newNoun left with no remaining
262
+ * supporting fact (mirrors generate.mjs's own verifyLexiconAlignment
263
+ * "orphaned metadata" check). */
264
+ export function stripDenylisted(result) {
265
+ const hasDenied = (term) => term.split(" ").some((tok) => WORD_DENYLIST.has(tok));
266
+ const facts = result.facts.filter((f) => !hasDenied(f[0]) && !hasDenied(f[2]));
267
+ const survivingTerms = new Set(facts.flatMap((f) => [f[0], f[2]]));
268
+ const newNouns = result.newNouns.filter((w) => survivingTerms.has(w));
269
+ return { ...result, facts, newNouns, got: facts.length };
270
+ }
@@ -0,0 +1,41 @@
1
+ // publish-gate.mjs — should this commit publish to npm? Pure: two version
2
+ // strings in, a decision and a reason out, no imports, so CI can ask without
3
+ // npm ci.
4
+ //
5
+ // CI asked this with `[ "$PUBLISHED" = "$LOCAL" ]`, which only answers "same".
6
+ // A local version BELOW what npm already has — a revert, a bad merge, a branch
7
+ // landing behind — reads as "different" and goes to `npm publish`, where the
8
+ // registry rejects it and the job fails on a confusing error instead of a clear
9
+ // skip. Equality cannot tell "ahead" from "behind"; comparing can.
10
+
11
+ const CORE = /^(\d+)\.(\d+)\.(\d+)(?:[-+].*)?$/;
12
+
13
+ /** [major, minor, patch] for a semver string, or null if it isn't one. */
14
+ function core(version) {
15
+ const found = CORE.exec(String(version ?? "").trim());
16
+ return found ? [Number(found[1]), Number(found[2]), Number(found[3])] : null;
17
+ }
18
+
19
+ /** -1 | 0 | 1 comparing the release cores of `a` and `b`. Pre-release and build
20
+ * metadata are ignored: this gate decides whether a release moved, and 2.2.0
21
+ * and 2.2.0-rc.1 are the same release for that purpose. */
22
+ export function compareVersions(a, b) {
23
+ const [x, y] = [core(a), core(b)];
24
+ if (!x || !y) throw new Error(`not a comparable version: "${!x ? a : b}"`);
25
+ for (let i = 0; i < 3; i++) if (x[i] !== y[i]) return x[i] < y[i] ? -1 : 1;
26
+ return 0;
27
+ }
28
+
29
+ /** Should `local` publish, given the registry currently serves `published`?
30
+ * `published` is "none" when the package has never been published.
31
+ * Returns { publish, reason } — the reason is what CI prints either way. */
32
+ export function shouldPublish(local, published) {
33
+ if (!core(local)) throw new Error(`not a publishable version: "${local}"`);
34
+ if (published === "none" || published == null || published === "") {
35
+ return { publish: true, reason: `publishing ${local} (nothing published yet)` };
36
+ }
37
+ const order = compareVersions(local, published);
38
+ if (order === 0) return { publish: false, reason: `npm already has ${local} — no version bump in this push, skipping publish` };
39
+ if (order < 0) return { publish: false, reason: `local ${local} is BEHIND npm's ${published} — refusing to publish; the registry would reject it` };
40
+ return { publish: true, reason: `publishing ${local} (npm currently has ${published})` };
41
+ }
@@ -1,5 +1,5 @@
1
1
  // src/domain/router/call-validator.mjs — pure registry validators shared by the
2
- // product router (resolver / guardrail / goal-reasoner) + the bench grader
2
+ // product router (resolver / goal-reasoner) + the bench grader
3
3
  // (agentbench/grade.mjs re-exports these). Depends ONLY on registry.mjs — no
4
4
  // bench code — so the product←bench dependency stays inverted: the bench
5
5
  // imports the product, never the other way round. No I/O, no Date.now, no LLM.
@@ -1,7 +1,7 @@
1
1
  // src/domain/router/drive.mjs — the product-facing drive of the capability router: the
2
2
  // piece that turns a real English request into a real, executed answer over a
3
- // real repo graph. registry/resolver/planner/guardrail/goal-reasoner/
4
- // call-validator are all pure, deterministic decision machinery — this module
3
+ // real repo graph. registry/resolver/planner/goal-reasoner/call-validator
4
+ // are all pure, deterministic decision machinery — this module
5
5
  // is the thin, stateful shell around them that a CLI or chat surface calls:
6
6
  // build a { dispatch, resolve, graph } context against the repo's actual code
7
7
  // graph, then run a request through resolver -> planner -> goal-reasoner.
@@ -162,8 +162,7 @@ const WORLD_GOAL_RE = new RegExp(
162
162
  * ground the move sequence by pure simulation over the taught rules
163
163
  * (compileDomain + stateFromFacts + compileGoal + findActionPath — all
164
164
  * read-only). Returned calls are NEVER dispatched: taught records carry
165
- * readOnly:false / dispatchable:false, so the plan is simulated and chat's
166
- * "next" executes move 1. Returns a loopResult, or null when the request is
165
+ * readOnly:false, so the plan is simulated and chat's "next" executes move 1. Returns a loopResult, or null when the request is
167
166
  * not a world-goal shape (the caller falls through to the goal-reasoner). */
168
167
  export async function runTaughtPlan(request, tools, ctx) {
169
168
  const m = WORLD_GOAL_RE.exec(String(request || "").trim());
@@ -1,15 +1,15 @@
1
1
  // src/domain/router/registry.mjs — the capability registry.
2
2
  //
3
3
  // Each tmct tool is modelled as a STRIPS/PDDL operator declared as DATA: a `Capability` with
4
- // typed `Parameter`s, `Precondition`s, and `Effect`s (add-list/delete-list). Preconditions are
5
- // the safety gate guardrail.mjs checks before a call fires; resolver.mjs backward-chains from
6
- // a goal to a capability whose add-list achieves it.
4
+ // typed `Parameter`s, `Precondition`s, and `Effect`s (add-list/delete-list). resolver.mjs
5
+ // backward-chains from a goal to a capability whose add-list achieves it, and proves the
6
+ // preconditions bind before the call fires.
7
7
  //
8
8
  // Plain data + pure accessors, no I/O. Tool names + parameter arg keys are the
9
9
  // exact ones src/tools/server.mjs `dispatchTool` reads, so a bound call this registry validates is
10
10
  // directly dispatchable.
11
11
 
12
- export const PREFIXES = Object.freeze({
12
+ const PREFIXES = Object.freeze({
13
13
  cap: "urn:tmct:cap#", // the capability/operator vocabulary (this module)
14
14
  mgx: "urn:tmct:mgx#", // tmct's code-graph predicates (imports/calls/tests/…)
15
15
  seon: "http://se-on.org/ontologies/seon.owl#", // software-evolution ontology classes
@@ -25,7 +25,7 @@ export const VOCAB = Object.freeze({
25
25
 
26
26
  // Parameter entity-KINDS — the seon/mgx classes a slot ranges over. `Query` and
27
27
  // `Kind`/`Package` are free-text / enum slots (no graph resolution); the rest
28
- // name a graph entity the guardrail must prove RESOLVES before the call fires.
28
+ // name a graph entity the resolver must prove RESOLVES before the call fires.
29
29
  export const KINDS = Object.freeze({
30
30
  Symbol: "seon:CodeEntity", // any code symbol: function/method/class/module/attribute
31
31
  Module: "seon:Module",
@@ -196,11 +196,10 @@ function deepFreeze(value) {
196
196
  }
197
197
 
198
198
  /** Register a capability at runtime (e.g. a taught action family bridged in by
199
- * src/domain/router/taught.mjs). `readOnly` must be an explicit boolean; a
200
- * `readOnly: false` record is forced `dispatchable: false` the guardrail's
201
- * candidate enrichment re-dispatches a tool once per tied candidate, which is
202
- * only safe when dispatch performs no writes. Returns an `unregister()`
203
- * disposer. */
199
+ * src/domain/router/taught.mjs). `readOnly` must be an explicit boolean: it is
200
+ * what resolver.mjs's dispatch gate reads, and a `readOnly: false` record is
201
+ * never dispatched. `dispatchable` is derived from it for callers that want the
202
+ * record to state the conclusion. Returns an `unregister()` disposer. */
204
203
  export function registerCapability(cap) {
205
204
  const name = cap && typeof cap.name === "string" ? cap.name.trim() : "";
206
205
  if (!name) throw new Error("registerCapability: a non-empty name is required");
@@ -260,14 +259,14 @@ export function isCapability(n) { return Boolean(byName[n]); }
260
259
  /** The parameter slots of capability `n` (empty array if unknown/no-arg). */
261
260
  export function parametersOf(n) { return byName[n]?.parameters ?? []; }
262
261
 
263
- /** The preconditions of capability `n` (the safety gate the guardrail checks). */
262
+ /** The preconditions of capability `n` (the safety gate the resolver checks). */
264
263
  export function preconditionsOf(n) { return byName[n]?.preconditions ?? []; }
265
264
 
266
265
  /** The effects of capability `n` — `{ add, del }` (the proof-chain contribution). */
267
266
  export function effectsOf(n) { return byName[n]?.effects ?? { add: [], del: [] }; }
268
267
 
269
- /** The set of arg keys capability `n` accepts (for the guardrail's unknown-arg
270
- * check). Returns a Set of strings. */
268
+ /** The set of arg keys capability `n` accepts (for call-validator.mjs's
269
+ * unknown-arg check). Returns a Set of strings. */
271
270
  export function argKeysOf(n) {
272
271
  return new Set(parametersOf(n).map((p) => p.arg));
273
272
  }
@@ -200,7 +200,7 @@ export function commandCapability(request, declaredNames, selectTool) {
200
200
 
201
201
  /** Build the glass-box proof chain for a grounded single call: its preconditions then the
202
202
  * epistemic add-effect. Dispatch has succeeded, so `resolves` steps are ok. */
203
- export function proofFor(name, input) {
203
+ function proofFor(name, input) {
204
204
  const steps = [];
205
205
  for (const pre of preconditionsOf(name)) {
206
206
  if (pre.pred === PRECOND.graphLoaded) steps.push({ step: "precondition", pred: pre.pred, ok: true });
@@ -213,11 +213,21 @@ export function proofFor(name, input) {
213
213
 
214
214
  const REFUSE = (why, extra) => ({ selected: null, refused: true, reason: why, ...(extra || {}) });
215
215
 
216
- /** Breadth-first ambiguity: dispatches the SAME tool once per tied candidate (safe since
217
- * every registered capability is read-only). Returns `[{candidate, result}, ...]`, or
218
- * undefined when there is no dispatcher to run it with. */
216
+ /** The gate both dispatch sites below go through. Dispatching is an OBSERVATION, so it
217
+ * may only ever run a capability whose own record says it performs no writes. A
218
+ * world-mutating record (the ones src/domain/router/taught.mjs registers carry
219
+ * `readOnly: false`) is planned over and simulated, never fired here. */
220
+ function dispatchPerformsNoWrites(capName) {
221
+ return capabilityByName(capName)?.readOnly === true;
222
+ }
223
+
224
+ /** Breadth-first ambiguity: dispatches the SAME tool once per tied candidate, which only
225
+ * stays safe while the tool writes nothing. Returns `[{candidate, result}, ...]`, or
226
+ * undefined when there is no dispatcher to run it with — or when the capability is not
227
+ * read-only. */
219
228
  async function dispatchEachCandidate(pool, capName, arg, ctx, execute) {
220
229
  if (!execute || !ctx.dispatch) return undefined;
230
+ if (!dispatchPerformsNoWrites(capName)) return undefined;
221
231
  const results = [];
222
232
  for (const c of pool) {
223
233
  const res = await ctx.dispatch(capName, { [arg]: c.label });
@@ -283,6 +293,9 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
283
293
  if (problems.length) return REFUSE(`bound call did not validate: ${problems.map((p) => p.reason).join(",")}`);
284
294
 
285
295
  if (execute && ctx.dispatch) {
296
+ if (!dispatchPerformsNoWrites(pick.name)) {
297
+ return REFUSE(`${pick.name} is not read-only; the resolver observes, it never fires a world-mutating capability`);
298
+ }
286
299
  const res = await ctx.dispatch(pick.name, input);
287
300
  if (!res.ok) return REFUSE(`unresolvable at dispatch: ${res.error}`);
288
301
  return { selected: call, proof: proofFor(pick.name, input), why, resolved: res.resolved ?? resolved, observed: String(res.text ?? "").slice(0, 240) };
@@ -293,7 +306,7 @@ export async function resolveOne(request, declaredNames, ctx, { execute = true }
293
306
  // ---- reachability (used by the bidirectional conformance test + docs) ---------
294
307
 
295
308
  /** The epistemic topics some NL intent or imperative frame can reach. */
296
- export function nlReachableTopics() {
309
+ function nlReachableTopics() {
297
310
  const topics = new Set();
298
311
  for (const v of Object.values(NL_INTENTS)) topics.add(v.topic);
299
312
  for (const f of FRAMES) topics.add(f.topic);
@@ -147,7 +147,7 @@ export function callersLabels(graph, ind) {
147
147
  }
148
148
 
149
149
  /** Callees of a symbol (mirrors renderCallees). */
150
- export function calleesLabels(graph, ind) {
150
+ function calleesLabels(graph, ind) {
151
151
  if (CALL_SYMBOL_CLASSES.has(ind.class)) {
152
152
  return uniqSort(edgesOfKind(graph, "callsSymbol").filter((e) => e.subject === ind.id).map((e) => e.objectLabel || e.object));
153
153
  }
@@ -157,7 +157,7 @@ export function calleesLabels(graph, ind) {
157
157
  }
158
158
 
159
159
  /** Subclasses (transitive) of a class (mirrors renderSubclasses closure). */
160
- export function subclassesLabels(graph, ind) {
160
+ function subclassesLabels(graph, ind) {
161
161
  const inherits = edgesOfKind(graph, "inherits");
162
162
  const childrenOf = new Map();
163
163
  for (const e of inherits) {
@@ -198,7 +198,7 @@ export function cochangesLabels(graph, ind) {
198
198
  }
199
199
 
200
200
  /** A module's public exports (mirrors renderExports — the `reexports` edge). */
201
- export function exportsLabels(graph, ind) {
201
+ function exportsLabels(graph, ind) {
202
202
  const modId = moduleIdOf(graph, ind);
203
203
  if (!modId) return [];
204
204
  return uniqSort(edgesOfKind(graph, "reexports").filter((e) => e.subject === modId).map((e) => e.objectLabel || e.object));