@polycode-projects/the-mechanical-code-talker 2.11.12 → 3.0.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.
package/README.md CHANGED
@@ -1150,8 +1150,10 @@ node bin/tmct.mjs cli tmct_untested '{"repo_path":"examples/mini-webapp"}'
1150
1150
 
1151
1151
  ## The repository interface
1152
1152
 
1153
- tmct is not an indexer, so it consumes a graph through a typed contract any
1154
- producer can implement. That contract is first-class: a **versioned (1.1.0),
1153
+ tmct consumes a graph through a typed contract any producer can implement —
1154
+ including its own `tmct index` command, which walks a repo's source and writes
1155
+ the graph, and any external producer (seonix, a CI indexer, a hand-written JSON
1156
+ file) feeding the same seam. That contract is first-class: a **versioned (1.1.0),
1155
1157
  OWL-grounded, machine-readable service definition** (`docs/repository-interface.md`
1156
1158
  plus a JSON schema) of every service, its arguments, result types, and error
1157
1159
  contract. The interface returns a miss as a normal value. It never throws to
package/bin/tmct.mjs CHANGED
@@ -2,7 +2,8 @@
2
2
  // tmct — The Mechanical Code Talker. The headline entry is CHAT: a bare
3
3
  // invocation drops you into a tolerant, offline, $0 prompt that guides you
4
4
  // toward precision queries about a repository (ELIZA/PARRY-style, but obsessed
5
- // with software). No model calls; tmct keeps no codebase index of its own.
5
+ // with software). No model calls; tmct indexes a repo on request (tmct index)
6
+ // or reads a graph any other producer wrote.
6
7
  //
7
8
  // tmct → interactive chat (the headline)
8
9
  // tmct chat [--repo <abs>] [--plain] → same, explicit
@@ -49,7 +50,7 @@ process.on("warning", (warning) => {
49
50
  const HELP = `tmct — The Mechanical Code Talker
50
51
 
51
52
  A tolerant, offline, $0 chat that guides you toward precision queries about a
52
- software repository. No model calls; no codebase index of its own.
53
+ software repository. No model calls; index a repo with \`tmct index\`, or read any producer's graph.
53
54
 
54
55
  Usage:
55
56
  ${renderUsage()}
@@ -919,6 +920,35 @@ async function main() {
919
920
  // on disk: one copy-paste Bash invocation per tool, rewritten on every init.
920
921
  process.stdout.write(`cold-tool catalog: ${await writeToolsCatalog(repoRoot)}\n`);
921
922
 
923
+ // `--with-persona code`: on top of the corpus-vocabulary bias `initRepo` just wrote,
924
+ // also run the repository INDEXER (`tmct index`'s own machinery) against this repo's
925
+ // real source, so one command produces a `.tmct/graph.json` backed by the repo itself —
926
+ // not just a bias preset. `chat --repo` already reads whatever graph is on disk; this
927
+ // is the onboarding path that puts one there. Failure-tolerant like the corpus seed
928
+ // above: a repo that can't be indexed (no supported source, or a parse error) degrades
929
+ // to an initialized-but-graphless repo, never a crashed init.
930
+ if (personaName === "code") {
931
+ try {
932
+ const { indexRepository } = await import("../src/index/index-repo.mjs");
933
+ const stats = await indexRepository(repoRoot);
934
+ for (const { pass, message } of stats.gitErrors || []) {
935
+ process.stderr.write(`tmct init: WARNING git history pass '${pass}' — ${message} (graph built without those edges)\n`);
936
+ }
937
+ const perLang = Object.entries(stats.perLang)
938
+ .map(([lang, s]) => `${lang}: ${s.modules} modules, ${s.symbols} symbols`).join("; ");
939
+ const kib = (stats.bytes / 1024).toFixed(1);
940
+ process.stdout.write(
941
+ `code persona: indexed the repo — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
942
+ + (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
943
+ );
944
+ if (stats.failures?.length) {
945
+ process.stderr.write(`tmct init: ${stats.failures.length} file(s) failed to parse (skipped): ${stats.failures.slice(0, 5).join(", ")}${stats.failures.length > 5 ? ", …" : ""}\n`);
946
+ }
947
+ } catch (e) {
948
+ process.stderr.write(`tmct init: code persona indexing skipped (${e?.message || e})\n`);
949
+ }
950
+ }
951
+
922
952
  // `--corpus`/`--ontology`/`--lexicon` now mean "activate this bundle and
923
953
  // PERSIST that into tmct.toml" — so a second `tmct init` (or the next chat
924
954
  // bootstrap) remembers the choice, unlike the old ad hoc path, which had
@@ -986,6 +1016,36 @@ async function main() {
986
1016
  return;
987
1017
  }
988
1018
 
1019
+ if (mode === "index") {
1020
+ // `tmct index` — the code-graph PRODUCER. Walks a repo's own source, parses
1021
+ // it (JS/TS today, via the TypeScript compiler API), reads git history, and
1022
+ // writes <repo>/.tmct/graph.json — the same artifact chat/serve/cli read
1023
+ // through the provider seam. This is tmct producing a graph for the first
1024
+ // time; the seam that consumes one is unchanged.
1025
+ const rest = process.argv.slice(3);
1026
+ const { strFlag } = await import("../src/services/cli-args.mjs");
1027
+ const { resolve: resolvePath } = await import("node:path");
1028
+ const { indexRepository } = await import("../src/index/index-repo.mjs");
1029
+ const repoFlag = strFlag(rest, ["--repo"]);
1030
+ const repoRoot = repoFlag ? resolvePath(process.cwd(), repoFlag) : process.cwd();
1031
+ const noHistory = rest.includes("--no-history");
1032
+ const stats = await indexRepository(repoRoot, noHistory ? { historyDepth: 0 } : {});
1033
+ for (const { pass, message } of stats.gitErrors || []) {
1034
+ process.stderr.write(`tmct index: WARNING git history pass '${pass}' — ${message} (graph built without those edges)\n`);
1035
+ }
1036
+ const perLang = Object.entries(stats.perLang)
1037
+ .map(([lang, s]) => `${lang}: ${s.modules} modules, ${s.symbols} symbols`).join("; ");
1038
+ const kib = (stats.bytes / 1024).toFixed(1);
1039
+ process.stdout.write(
1040
+ `tmct index — wrote ${stats.graphFile} (${stats.modules} modules, ${stats.symbols} symbols; ${kib} KiB)\n`
1041
+ + (perLang ? `${perLang}\n` : "no supported source found under the repo\n"),
1042
+ );
1043
+ if (stats.failures?.length) {
1044
+ process.stderr.write(`tmct index: ${stats.failures.length} file(s) failed to parse (skipped): ${stats.failures.slice(0, 5).join(", ")}${stats.failures.length > 5 ? ", …" : ""}\n`);
1045
+ }
1046
+ return;
1047
+ }
1048
+
989
1049
  if (mode === "import") {
990
1050
  // `tmct import` — activate+seed into an ALREADY-initialized repo, reusing
991
1051
  // the SAME resolvePluggableInput/activatePluggableInput seam `init`'s own
@@ -173,7 +173,7 @@ export const CORPUSES = {
173
173
  ],
174
174
  },
175
175
 
176
- // PLAN_AGENTS.md Phase 1's "wider general-knowledge seed set" bullet: the
176
+ // The wider general-knowledge seed set: the
177
177
  // three corpuses above are all code-domain-specific (a LANGUAGE or a cloud
178
178
  // DOMAIN); this one deliberately is NOT — everyday-knowledge concepts (the
179
179
  // natural world, weather, food, common objects) with zero code-domain
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@polycode-projects/the-mechanical-code-talker",
3
- "version": "2.11.12",
3
+ "version": "3.0.1",
4
4
  "private": false,
5
5
  "type": "module",
6
- "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; no codebase index of its own.",
6
+ "description": "The Mechanical Code Talker (tmct) — a tolerant, offline, $0 chat surface that guides you toward precision queries about a software repository. ELIZA/PARRY-style but domain-obsessed with code. No model calls; indexes a repo on request (tmct index) or reads any producer's graph.",
7
7
  "keywords": [
8
8
  "chatbot",
9
9
  "no-llm",
@@ -78,6 +78,7 @@
78
78
  "ink": "^7.1.0",
79
79
  "react": "^19.2.7",
80
80
  "smol-toml": "^1.7.0",
81
+ "typescript": "~5.6.2",
81
82
  "wink-eng-lite-web-model": "^1.8.1",
82
83
  "wink-nlp": "^2.4.0"
83
84
  },
@@ -3,12 +3,14 @@
3
3
  // stub it); in production it reads the JSON artifact the deterministic indexer
4
4
  // wrote to config.graphFile. No network, no model calls.
5
5
  //
6
- // This module is the PROVIDER SEAM (docs/adapter-contract.md):
7
- // any graph producer can feed tmct either by writing the entities-payload JSON
8
- // where config.graphFile points, or by registering a custom loader with
9
- // registerProvider() no indexer is ever imported here. tmct only READS
10
- // through this seam; its own writes go to .tmct/memory/ (src/memory/), never
11
- // back into a provider's artifact.
6
+ // This module is the READ SEAM (docs/adapter-contract.md): any graph producer
7
+ // can feed tmct either by writing the entities-payload JSON where
8
+ // config.graphFile points, or by registering a custom loader with
9
+ // registerProvider(). The PRODUCER lives elsewhere tmct's own `tmct index`
10
+ // (src/index/) is one such producer, and it writes through that same file path,
11
+ // not through this module. Keeping the reader and the producer in separate module
12
+ // trees is deliberate: this module only READS, and tmct's own writes go to
13
+ // .tmct/memory/ (src/memory/), never back into a provider's graph artifact.
12
14
 
13
15
  import { readFile } from "node:fs/promises";
14
16
  import { ToolError } from "./config.mjs";
@@ -102,6 +102,14 @@ export async function normalizeConfig(raw, { configDir } = {}) {
102
102
  const arr = Array.isArray(src.graph_files) ? src.graph_files : [src.graph_files];
103
103
  cfg.graphFiles = arr.map((p) => resolve(dir, String(p)));
104
104
  }
105
+ // [graph] read_only — a chat session against this repo READS the graph but
106
+ // writes nothing back into its .tmct/: no per-turn session upsert into
107
+ // graph.json, no transcript/sidecar logs, no memory droppings. A committed
108
+ // example fixture sets it so a plain `tmct chat --repo examples/<x>` (no
109
+ // --ephemeral) can never rewrite the hand-stamped graph. Sparse like the
110
+ // rest: absent when unset, so "unset" stays distinguishable from "false".
111
+ const graph = src.graph || {};
112
+ if (graph.read_only !== undefined) cfg.graph = { readOnly: graph.read_only };
105
113
  const corpus = src.corpus || {};
106
114
  if (corpus.tier !== undefined) cfg.corpus = { tier: corpus.tier };
107
115
  const seed = src.seed || {};
@@ -68,6 +68,15 @@ export const CLI_VERBS = [
68
68
  { flag: "[--memory-backend <default|memory|sqlite>]", prose: ["write tmct.toml's [memory] backend", "(same flag name as `tmct chat`) — a later `tmct chat`", "in this repo picks it up with no flag needed"] },
69
69
  ],
70
70
  },
71
+ {
72
+ mode: "index",
73
+ errorLabel: "index",
74
+ usage: "tmct index [--repo <abs>]",
75
+ prose: ["produce a code graph from a repo's OWN source (default: cwd):"],
76
+ flags: [
77
+ { flag: "[--no-history]", prose: ["walk the tree, parse JS/TS with the TypeScript compiler", "API, read git history, and write <repo>/.tmct/graph.json —", "the artifact chat/serve/cli then read. --no-history skips", "the git passes (no commit/touches/cochange edges)"] },
78
+ ],
79
+ },
71
80
  {
72
81
  mode: "import",
73
82
  errorLabel: "import",
@@ -0,0 +1,239 @@
1
+ // graph-delta.mjs — the planning STATE for code changes and the closed
2
+ // vocabulary of effects that move between states. Pure, no I/O.
3
+ //
4
+ // A code-graph snapshot is the typed entity/edge payload the repo already
5
+ // queries (codegraph.mjs / repository-interface.mjs), reduced to the two things
6
+ // a planner moves over: entities (id + class + title) and edges
7
+ // (subject/predicate/object). An action's declared EFFECT is a graph delta — a
8
+ // short list of add/del-entity, add/del-edge, retitle-entity tokens over the
9
+ // ontology's closed classes and predicates. Plan search projects snapshots
10
+ // without touching any base state, the way domain.mjs projects board@step
11
+ // snapshots.
12
+ //
13
+ // The state KEY canonicalizes the resulting graph, not the path taken to it, so
14
+ // two orderings of independent steps that reach the same graph are one state —
15
+ // which is what lets findActionPath's cycle detection merge them, and what keeps
16
+ // the representation e-class-friendly (PLAN_CODE.md §3.3).
17
+
18
+ /** The entity classes an effect may add — the ontology's code-entity classes
19
+ * (ontology/tmct-core.ttl; the same set codegraph.mjs's graph carries). Closed:
20
+ * an effect naming a class outside this set is a programming error, not a
21
+ * guess. */
22
+ export const ENTITY_CLASSES = Object.freeze([
23
+ "Module", "Class", "Function", "Method", "Attribute", "GlobalVariable", "Commit", "Session",
24
+ ]);
25
+
26
+ /** The edge predicates an effect may add or remove — repository-interface.mjs's
27
+ * EDGE_KINDS, restated here because a domain module imports nothing outside its
28
+ * own layer (test/estate/import-layers). Kept in step by
29
+ * test/domain/codeplan-graph-delta: a drift between the two lists fails it. */
30
+ export const EDGE_PREDICATES = Object.freeze([
31
+ "imports", "calls", "callsSymbol", "defines", "tests",
32
+ "touches", "touchesSymbol", "contains", "inherits", "cochange", "reexports",
33
+ ]);
34
+
35
+ /** The closed effect vocabulary — every way an operator's declared delta can
36
+ * change a graph state. retitle-entity is the rename primitive: an entity keeps
37
+ * its id (its stable graph identity) while its human title changes. */
38
+ export const EFFECT_OPS = Object.freeze([
39
+ "add-entity", "del-entity", "add-edge", "del-edge", "retitle-entity",
40
+ ]);
41
+
42
+ const ENTITY_CLASS_SET = new Set(ENTITY_CLASSES);
43
+ const EDGE_PREDICATE_SET = new Set(EDGE_PREDICATES);
44
+ const EFFECT_OP_SET = new Set(EFFECT_OPS);
45
+
46
+ // NUL-joined identity, the same discipline as domain.mjs's stateKeyFor: a
47
+ // multi-word title can never collide with the field separator. Spelled via
48
+ // fromCharCode because tooling has turned a source-level escape into a literal
49
+ // NUL byte in this repo before.
50
+ const SEP = String.fromCharCode(0);
51
+
52
+ const str = (value) => String(value ?? "");
53
+
54
+ const entitySort = (a, b) =>
55
+ a.id.localeCompare(b.id) || a.class.localeCompare(b.class) || a.title.localeCompare(b.title);
56
+
57
+ const edgeSort = (a, b) =>
58
+ a.subject.localeCompare(b.subject) || a.predicate.localeCompare(b.predicate) || a.object.localeCompare(b.object);
59
+
60
+ const normEntity = (e) => ({ id: str(e.id), class: str(e.class), title: str(e.title) });
61
+ const normEdge = (r) => ({ subject: str(r.subject), predicate: str(r.predicate), object: str(r.object) });
62
+
63
+ const dedupSorted = (rows, keyOf) => {
64
+ const out = [];
65
+ const seen = new Set();
66
+ for (const row of rows) {
67
+ const key = keyOf(row);
68
+ if (seen.has(key)) continue;
69
+ seen.add(key);
70
+ out.push(row);
71
+ }
72
+ return out;
73
+ };
74
+
75
+ /** An empty code-graph state. */
76
+ export const emptyGraphState = () => ({ entities: [], edges: [] });
77
+
78
+ /** Canonical, sorted, de-duplicated copy of a state — the shape every function
79
+ * here returns, so a state is always in one normal form. */
80
+ export function normalizeGraphState(state) {
81
+ const entities = dedupSorted(
82
+ (state?.entities || []).map(normEntity).sort(entitySort),
83
+ (e) => [e.id, e.class, e.title].join(SEP),
84
+ );
85
+ const edges = dedupSorted(
86
+ (state?.edges || []).map(normEdge).sort(edgeSort),
87
+ (r) => [r.subject, r.predicate, r.object].join(SEP),
88
+ );
89
+ return { entities, edges };
90
+ }
91
+
92
+ /** Throw unless an effect is a well-formed member of the closed vocabulary,
93
+ * over the closed class/predicate sets. The gate every apply/diff runs first,
94
+ * so a malformed delta never silently no-ops. */
95
+ export function validateEffect(effect) {
96
+ const op = str(effect?.op);
97
+ if (!EFFECT_OP_SET.has(op)) throw new Error(`unknown effect op ${JSON.stringify(op)} (not in EFFECT_OPS)`);
98
+ if (op === "add-entity") {
99
+ if (!str(effect.id)) throw new Error("add-entity needs an id");
100
+ if (!ENTITY_CLASS_SET.has(str(effect.class))) throw new Error(`add-entity class ${JSON.stringify(str(effect.class))} is not an ENTITY_CLASS`);
101
+ } else if (op === "del-entity") {
102
+ if (!str(effect.id)) throw new Error("del-entity needs an id");
103
+ } else if (op === "retitle-entity") {
104
+ if (!str(effect.id)) throw new Error("retitle-entity needs an id");
105
+ if (!str(effect.title)) throw new Error("retitle-entity needs a title");
106
+ } else {
107
+ // add-edge / del-edge
108
+ if (!str(effect.subject) || !str(effect.object)) throw new Error(`${op} needs a subject and an object`);
109
+ if (!EDGE_PREDICATE_SET.has(str(effect.predicate))) throw new Error(`${op} predicate ${JSON.stringify(str(effect.predicate))} is not an EDGE_PREDICATE`);
110
+ }
111
+ return op;
112
+ }
113
+
114
+ const hasEntity = (state, id) => state.entities.some((e) => e.id === id);
115
+ const edgeIncident = (state, id) => state.edges.some((r) => r.subject === id || r.object === id);
116
+
117
+ /** Apply one effect to a state, returning a NEW normalized state (the input is
118
+ * never mutated). Throws on an effect that cannot apply to this state — a
119
+ * duplicate add, a delete of something absent, an edge onto a missing endpoint,
120
+ * or a del-entity that would leave a dangling edge. A precondition that should
121
+ * have blocked the move is the caller's job (operators.mjs); this layer fails
122
+ * loud rather than producing an ill-formed graph. */
123
+ export function applyGraphEffect(state, effect) {
124
+ const s = normalizeGraphState(state);
125
+ const op = validateEffect(effect);
126
+ if (op === "add-entity") {
127
+ const id = str(effect.id);
128
+ if (hasEntity(s, id)) throw new Error(`add-entity: ${id} already exists`);
129
+ s.entities.push({ id, class: str(effect.class), title: str(effect.title) });
130
+ } else if (op === "del-entity") {
131
+ const id = str(effect.id);
132
+ if (!hasEntity(s, id)) throw new Error(`del-entity: ${id} does not exist`);
133
+ if (edgeIncident(s, id)) throw new Error(`del-entity: ${id} still has incident edges (remove them first)`);
134
+ s.entities = s.entities.filter((e) => e.id !== id);
135
+ } else if (op === "retitle-entity") {
136
+ const id = str(effect.id);
137
+ const ent = s.entities.find((e) => e.id === id);
138
+ if (!ent) throw new Error(`retitle-entity: ${id} does not exist`);
139
+ ent.title = str(effect.title);
140
+ } else if (op === "add-edge") {
141
+ const edge = normEdge(effect);
142
+ if (!hasEntity(s, edge.subject)) throw new Error(`add-edge: subject ${edge.subject} is not an entity`);
143
+ if (!hasEntity(s, edge.object)) throw new Error(`add-edge: object ${edge.object} is not an entity`);
144
+ s.edges.push(edge);
145
+ } else {
146
+ const edge = normEdge(effect);
147
+ if (!s.edges.some((r) => r.subject === edge.subject && r.predicate === edge.predicate && r.object === edge.object)) {
148
+ throw new Error(`del-edge: (${edge.subject} ${edge.predicate} ${edge.object}) does not exist`);
149
+ }
150
+ s.edges = s.edges.filter((r) => !(r.subject === edge.subject && r.predicate === edge.predicate && r.object === edge.object));
151
+ }
152
+ return normalizeGraphState(s);
153
+ }
154
+
155
+ /** Fold a list of effects onto a state in order. */
156
+ export function applyGraphEffects(state, effects) {
157
+ let s = normalizeGraphState(state);
158
+ for (const effect of effects || []) s = applyGraphEffect(s, effect);
159
+ return s;
160
+ }
161
+
162
+ /** The canonical identity of a state: its sorted entity and edge sets, joined.
163
+ * Keys the RESULTING graph, never the path — so independent-step reorderings
164
+ * that land on the same graph share one key. */
165
+ export function canonicalStateKey(state) {
166
+ const s = normalizeGraphState(state);
167
+ const eLines = s.entities.map((e) => ["E", e.id, e.class, e.title].join(SEP));
168
+ const rLines = s.edges.map((r) => ["R", r.subject, r.predicate, r.object].join(SEP));
169
+ return [...eLines, ...rLines].join("\n");
170
+ }
171
+
172
+ /** A canonical string for one effect, used to compare declared vs observed
173
+ * deltas irrespective of list order (PLAN_CODE.md §3.5 tier 1). */
174
+ export function effectKey(effect) {
175
+ const op = validateEffect(effect);
176
+ if (op === "add-entity") return [op, str(effect.id), str(effect.class), str(effect.title)].join(SEP);
177
+ if (op === "del-entity") return [op, str(effect.id)].join(SEP);
178
+ if (op === "retitle-entity") return [op, str(effect.id), str(effect.title)].join(SEP);
179
+ return [op, str(effect.subject), str(effect.predicate), str(effect.object)].join(SEP);
180
+ }
181
+
182
+ /** True when two effect lists describe the same delta regardless of order. */
183
+ export function effectsEqual(a, b) {
184
+ const keys = (list) => (list || []).map(effectKey).sort();
185
+ const ka = keys(a);
186
+ const kb = keys(b);
187
+ return ka.length === kb.length && ka.every((k, i) => k === kb[i]);
188
+ }
189
+
190
+ /** The observed delta between two states, as a sorted effect list: what a
191
+ * re-index would report an operator actually did. A del-entity's incident-edge
192
+ * removals surface as their own del-edge tokens, so a declared delta that lists
193
+ * every edge it removes matches the observed one exactly. */
194
+ export function diffGraphStates(before, after) {
195
+ const a = normalizeGraphState(before);
196
+ const b = normalizeGraphState(after);
197
+ const aEnt = new Map(a.entities.map((e) => [e.id, e]));
198
+ const bEnt = new Map(b.entities.map((e) => [e.id, e]));
199
+ const effects = [];
200
+ for (const e of a.entities) if (!bEnt.has(e.id)) effects.push({ op: "del-entity", id: e.id });
201
+ for (const e of b.entities) {
202
+ if (!aEnt.has(e.id)) { effects.push({ op: "add-entity", id: e.id, class: e.class, title: e.title }); continue; }
203
+ if (aEnt.get(e.id).title !== e.title) effects.push({ op: "retitle-entity", id: e.id, title: e.title });
204
+ }
205
+ const edgeKey = (r) => [r.subject, r.predicate, r.object].join(SEP);
206
+ const aEdge = new Set(a.edges.map(edgeKey));
207
+ const bEdge = new Set(b.edges.map(edgeKey));
208
+ for (const r of a.edges) if (!bEdge.has(edgeKey(r))) effects.push({ op: "del-edge", ...r });
209
+ for (const r of b.edges) if (!aEdge.has(edgeKey(r))) effects.push({ op: "add-edge", ...r });
210
+ return effects.sort((x, y) => effectKey(x).localeCompare(effectKey(y)));
211
+ }
212
+
213
+ /** Build a code-graph state from a loaded graph payload (the .tmct/graph.json
214
+ * shape). Reads the code individuals (skipping the schema:* meta-entities) and
215
+ * every objectProperties edge over a known predicate. Pure — a test can build a
216
+ * state from the committed fixture graph without touching disk logic here. */
217
+ export function graphStateFromEntities(payload) {
218
+ const individuals = Array.isArray(payload?.individuals) ? payload.individuals : [];
219
+ const entities = [];
220
+ for (const ind of individuals) {
221
+ if (!ind?.id || typeof ind.id !== "string") continue;
222
+ if (ind.id.startsWith("schema:")) continue; // ontology meta-entities, not code
223
+ if (!ENTITY_CLASS_SET.has(str(ind.class))) continue;
224
+ entities.push({ id: ind.id, class: str(ind.class), title: str(ind.label) });
225
+ }
226
+ const known = new Set(entities.map((e) => e.id));
227
+ const groups = Array.isArray(payload?.objectProperties) ? payload.objectProperties : [];
228
+ const edges = [];
229
+ for (const group of groups) {
230
+ const predicate = str(group?.predicate);
231
+ if (!EDGE_PREDICATE_SET.has(predicate)) continue;
232
+ for (const ex of Array.isArray(group.examples) ? group.examples : []) {
233
+ if (!ex?.subject || !ex?.object) continue;
234
+ if (!known.has(ex.subject) || !known.has(ex.object)) continue; // never a dangling edge
235
+ edges.push({ subject: str(ex.subject), predicate, object: str(ex.object) });
236
+ }
237
+ }
238
+ return normalizeGraphState({ entities, edges });
239
+ }
@@ -0,0 +1,232 @@
1
+ // operators.mjs — the code-transformation operator catalogue: each entry one
2
+ // taught-action family, slot for slot (PLAN_CODE.md §3.2). A signature (the
3
+ // graph shape it applies to), preconditions (named graph predicates from
4
+ // graph-predicates.mjs), a declared effect (a graph delta from graph-delta.mjs),
5
+ // and the standing constraint that every currently-covered test stays covered.
6
+ //
7
+ // The catalogue is DATA — authored and reviewed like any rule base, not engine
8
+ // code. A grounded operator supplies `ground(state, context)`, which enumerates
9
+ // its legal moves over a state given a goal-derived parameter pool; the entries
10
+ // still pending a grounder carry their signature/precondition metadata so the
11
+ // catalogue reads complete. No I/O.
12
+ //
13
+ // An entity id here is a STABLE node identity, independent of the module that
14
+ // currently defines it — a move swaps the `defines` edge and leaves the id
15
+ // alone, the way a graph rewrite renames a node's neighbourhood, not the node.
16
+ // The language adaptor (§3.4) maps that stable identity onto concrete paths.
17
+
18
+ import { applyGraphEffects, canonicalStateKey } from "./graph-delta.mjs";
19
+ import {
20
+ moduleDefining, callersOf, importsInducedByMove, preconditionsHold,
21
+ } from "./graph-predicates.mjs";
22
+
23
+ const asSet = (classes) => new Set(Array.isArray(classes) ? classes : [classes]);
24
+ const titleOf = (state, id) => state.entities.find((e) => e.id === id)?.title;
25
+ const classOf = (state, id) => state.entities.find((e) => e.id === id)?.class;
26
+ const hasEntity = (state, id) => state.entities.some((e) => e.id === id);
27
+
28
+ const signatureMatches = (state, entityId, signature) => {
29
+ const cls = classOf(state, entityId);
30
+ return cls != null && asSet(signature.subjectClass).has(cls);
31
+ };
32
+
33
+ // ---- grounders ---------------------------------------------------------------
34
+
35
+ /** rename: retitle an entity in place. One move per (entity matching the
36
+ * signature) × (candidate title in the pool) that survives the no-collision
37
+ * precondition. */
38
+ function groundRename(operator, state, context) {
39
+ const out = [];
40
+ const titles = context?.titles || [];
41
+ for (const ent of state.entities) {
42
+ if (!signatureMatches(state, ent.id, operator.signature)) continue;
43
+ for (const newTitle of titles) {
44
+ if (newTitle === ent.title) continue;
45
+ const binding = { entityId: ent.id, newTitle };
46
+ if (!preconditionsHold(operator.preconditions, state, binding)) continue;
47
+ out.push({
48
+ name: operator.name,
49
+ binding,
50
+ effects: [{ op: "retitle-entity", id: ent.id, title: newTitle }],
51
+ label: `rename ${ent.title} to ${newTitle}`,
52
+ });
53
+ }
54
+ }
55
+ return out;
56
+ }
57
+
58
+ /** create-module: add a Module the plan will move a symbol into. One move per
59
+ * target-module descriptor in the pool not already present. */
60
+ function groundCreateModule(operator, state, context) {
61
+ const out = [];
62
+ for (const target of context?.moduleTargets || []) {
63
+ if (!target?.id || hasEntity(state, target.id)) continue;
64
+ out.push({
65
+ name: operator.name,
66
+ binding: { moduleId: target.id },
67
+ effects: [{ op: "add-entity", id: target.id, class: "Module", title: target.title || target.id }],
68
+ label: `create module ${target.title || target.id}`,
69
+ });
70
+ }
71
+ return out;
72
+ }
73
+
74
+ /** move: hand an entity to a different module — swap its `defines` edge and add
75
+ * the import edges every call site now needs — without creating an import
76
+ * cycle. */
77
+ function groundMove(operator, state, context) {
78
+ const out = [];
79
+ const targets = (context?.moduleTargets || []).map((t) => t.id);
80
+ for (const ent of state.entities) {
81
+ if (!signatureMatches(state, ent.id, operator.signature)) continue;
82
+ const from = moduleDefining(state, ent.id);
83
+ if (!from) continue;
84
+ for (const toModuleId of targets) {
85
+ if (toModuleId === from || !hasEntity(state, toModuleId)) continue;
86
+ const binding = { entityId: ent.id, toModuleId };
87
+ if (!preconditionsHold(operator.preconditions, state, binding)) continue;
88
+ const effects = [
89
+ { op: "del-edge", subject: from, predicate: "defines", object: ent.id },
90
+ { op: "add-edge", subject: toModuleId, predicate: "defines", object: ent.id },
91
+ ...importsInducedByMove(state, binding).map((r) => ({ op: "add-edge", ...r })),
92
+ ];
93
+ out.push({
94
+ name: operator.name,
95
+ binding,
96
+ effects,
97
+ label: `move ${ent.title} to ${titleOf(state, toModuleId)}`,
98
+ });
99
+ }
100
+ }
101
+ return out;
102
+ }
103
+
104
+ /** delete-dead: remove an entity nothing depends on — drop its `defines` edge,
105
+ * then the entity. Only entities the goal names as delete targets are offered. */
106
+ function groundDeleteDead(operator, state, context) {
107
+ const out = [];
108
+ for (const entityId of context?.deleteTargets || []) {
109
+ if (!hasEntity(state, entityId)) continue;
110
+ if (!signatureMatches(state, entityId, operator.signature)) continue;
111
+ const binding = { entityId };
112
+ if (!preconditionsHold(operator.preconditions, state, binding)) continue;
113
+ const from = moduleDefining(state, entityId);
114
+ const effects = [
115
+ ...(from ? [{ op: "del-edge", subject: from, predicate: "defines", object: entityId }] : []),
116
+ { op: "del-entity", id: entityId },
117
+ ];
118
+ out.push({ name: operator.name, binding, effects, label: `delete ${titleOf(state, entityId)}` });
119
+ }
120
+ return out;
121
+ }
122
+
123
+ // ---- the catalogue -----------------------------------------------------------
124
+
125
+ const SYMBOL = ["Function", "Method"];
126
+
127
+ /** The starting operator catalogue: the refactoring literature's settled core
128
+ * (Opdyke 1992; Fowler 1999). Grounded entries carry a `ground` function; the
129
+ * rest declare their signature and preconditions and await one. */
130
+ export const CODE_OPERATORS = Object.freeze([
131
+ {
132
+ name: "rename",
133
+ summary: "retitle an entity in place; every call site keeps resolving by id",
134
+ signature: { subjectClass: SYMBOL },
135
+ preconditions: ["no-name-collision"],
136
+ ground: groundRename,
137
+ },
138
+ {
139
+ name: "create-module",
140
+ summary: "add a Module a later move can hand a symbol into",
141
+ signature: { subjectClass: "Module" },
142
+ preconditions: [],
143
+ ground: groundCreateModule,
144
+ },
145
+ {
146
+ name: "move",
147
+ summary: "hand an entity to another module, adding the imports its callers need",
148
+ signature: { subjectClass: SYMBOL },
149
+ preconditions: ["move-introduces-no-import-cycle"],
150
+ ground: groundMove,
151
+ },
152
+ {
153
+ name: "delete-dead",
154
+ summary: "remove an entity with no inbound dependency edges",
155
+ signature: { subjectClass: SYMBOL },
156
+ preconditions: ["no-inbound-dependencies"],
157
+ ground: groundDeleteDead,
158
+ },
159
+ {
160
+ name: "inline",
161
+ summary: "replace calls with the single definition's body; the definition then deletes",
162
+ signature: { subjectClass: SYMBOL },
163
+ preconditions: ["single-definition", "no-self-recursion"],
164
+ },
165
+ {
166
+ name: "extract-function",
167
+ summary: "lift a fragment into its own function, its call taking the fragment's place",
168
+ signature: { subjectClass: SYMBOL },
169
+ preconditions: ["no-name-collision"],
170
+ },
171
+ {
172
+ name: "add-parameter",
173
+ summary: "add a parameter with a default; update every call site",
174
+ signature: { subjectClass: SYMBOL },
175
+ preconditions: [],
176
+ },
177
+ {
178
+ name: "wrap",
179
+ summary: "put a guard or decorator around a call boundary",
180
+ signature: { subjectClass: SYMBOL },
181
+ preconditions: [],
182
+ },
183
+ {
184
+ name: "split-module",
185
+ summary: "divide a module's definitions across two, importers re-pointed",
186
+ signature: { subjectClass: "Module" },
187
+ preconditions: [],
188
+ },
189
+ {
190
+ name: "apply-semantic-patch",
191
+ summary: "a taught pattern→replacement pair promoted to a first-class operator (comby/ast-grep-shaped)",
192
+ signature: { subjectClass: SYMBOL },
193
+ preconditions: [],
194
+ },
195
+ ]);
196
+
197
+ /** The grounded subset — operators a planner can currently enumerate moves for. */
198
+ export const groundedOperators = (catalogue = CODE_OPERATORS) => catalogue.filter((op) => typeof op.ground === "function");
199
+
200
+ // A move may not drop a test-coverage edge, nor delete a module something still
201
+ // covers — the standing "every currently-covered test stays covered" constraint,
202
+ // applied to every grounded move regardless of which operator produced it.
203
+ const preservesCoverage = (state, move) => {
204
+ const nextState = applyGraphEffects(state, move.effects);
205
+ const covered = new Set(state.edges.filter((r) => r.predicate === "tests").map((r) => r.object));
206
+ const stillCovered = new Set(nextState.edges.filter((r) => r.predicate === "tests").map((r) => r.object));
207
+ for (const id of covered) if (!stillCovered.has(id)) return false;
208
+ return true;
209
+ };
210
+
211
+ /**
212
+ * Every legal grounded move from `state`, given the goal-derived `context`
213
+ * parameter pool, as `{ action, nextState }` pairs — the `applyActions`
214
+ * findActionPath consumes. Deterministic: operators are walked in catalogue
215
+ * order and each grounder is order-stable. The coverage constraint prunes any
216
+ * move that would drop test coverage.
217
+ */
218
+ export function codeGraphMoves(state, context, { catalogue = CODE_OPERATORS } = {}) {
219
+ const out = [];
220
+ const seen = new Set([canonicalStateKey(state)]);
221
+ for (const op of groundedOperators(catalogue)) {
222
+ for (const move of op.ground(op, state, context)) {
223
+ if (!preservesCoverage(state, move)) continue;
224
+ const nextState = applyGraphEffects(state, move.effects);
225
+ const key = canonicalStateKey(nextState);
226
+ if (seen.has(key)) continue; // a no-op or a duplicate successor
227
+ seen.add(key);
228
+ out.push({ action: move, nextState });
229
+ }
230
+ }
231
+ return out;
232
+ }