@rubytech/create-maxy 1.0.743 → 1.0.745

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 (55) hide show
  1. package/package.json +1 -1
  2. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.d.ts +2 -0
  3. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.d.ts.map +1 -0
  4. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.js +97 -0
  5. package/payload/platform/lib/graph-mcp/dist/__tests__/cypher-validate-write.test.js.map +1 -0
  6. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts +37 -0
  7. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.d.ts.map +1 -0
  8. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js +333 -0
  9. package/payload/platform/lib/graph-mcp/dist/cypher-rewrite-stamp.js.map +1 -0
  10. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts +71 -0
  11. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.d.ts.map +1 -0
  12. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js +168 -0
  13. package/payload/platform/lib/graph-mcp/dist/cypher-shim-write.js.map +1 -0
  14. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts +13 -1
  15. package/payload/platform/lib/graph-mcp/dist/cypher-validate.d.ts.map +1 -1
  16. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js +70 -3
  17. package/payload/platform/lib/graph-mcp/dist/cypher-validate.js.map +1 -1
  18. package/payload/platform/lib/graph-mcp/dist/index.js +297 -11
  19. package/payload/platform/lib/graph-mcp/dist/index.js.map +1 -1
  20. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts +3 -6
  21. package/payload/platform/lib/graph-mcp/dist/schema-cache.d.ts.map +1 -1
  22. package/payload/platform/lib/graph-mcp/dist/schema-cache.js +30 -7
  23. package/payload/platform/lib/graph-mcp/dist/schema-cache.js.map +1 -1
  24. package/payload/platform/lib/graph-mcp/src/__tests__/cypher-validate-write.test.ts +150 -0
  25. package/payload/platform/lib/graph-mcp/src/cypher-rewrite-stamp.ts +349 -0
  26. package/payload/platform/lib/graph-mcp/src/cypher-shim-write.ts +240 -0
  27. package/payload/platform/lib/graph-mcp/src/cypher-validate.ts +95 -3
  28. package/payload/platform/lib/graph-mcp/src/index.ts +415 -18
  29. package/payload/platform/lib/graph-mcp/src/schema-cache.ts +37 -7
  30. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts +2 -0
  31. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.d.ts.map +1 -0
  32. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js +147 -0
  33. package/payload/platform/lib/graph-write/dist/__tests__/audit.test.js.map +1 -0
  34. package/payload/platform/lib/graph-write/dist/audit.d.ts +84 -0
  35. package/payload/platform/lib/graph-write/dist/audit.d.ts.map +1 -0
  36. package/payload/platform/lib/graph-write/dist/audit.js +129 -0
  37. package/payload/platform/lib/graph-write/dist/audit.js.map +1 -0
  38. package/payload/platform/lib/graph-write/dist/index.d.ts +1 -0
  39. package/payload/platform/lib/graph-write/dist/index.d.ts.map +1 -1
  40. package/payload/platform/lib/graph-write/dist/index.js +18 -22
  41. package/payload/platform/lib/graph-write/dist/index.js.map +1 -1
  42. package/payload/platform/lib/graph-write/src/__tests__/audit.test.ts +162 -0
  43. package/payload/platform/lib/graph-write/src/audit.ts +182 -0
  44. package/payload/platform/lib/graph-write/src/index.ts +5 -0
  45. package/payload/platform/package.json +2 -2
  46. package/payload/platform/plugins/docs/references/deployment.md +2 -1
  47. package/payload/platform/plugins/docs/references/memory-guide.md +2 -0
  48. package/payload/platform/plugins/docs/references/troubleshooting.md +16 -0
  49. package/payload/platform/templates/specialists/agents/database-operator.md +20 -6
  50. package/payload/server/chunk-2T4RRIJK.js +9462 -0
  51. package/payload/server/chunk-SPTD7L7Z.js +9474 -0
  52. package/payload/server/maxy-edge.js +94 -16
  53. package/payload/server/public/assets/{graph-DhNy70eS.js → graph-BoSJpLG3.js} +1 -1
  54. package/payload/server/public/graph.html +1 -1
  55. package/payload/server/server.js +1 -1
@@ -0,0 +1,150 @@
1
+ import test from "node:test";
2
+ import assert from "node:assert/strict";
3
+ import { validate, type SchemaSnapshot } from "../cypher-validate.js";
4
+
5
+ const snapshot: SchemaSnapshot = {
6
+ labels: new Set(["Person", "Organization", "Task", "KnowledgeDocument"]),
7
+ relationshipTypes: new Set(["KNOWS", "PARTICIPANT", "REFERENCES", "MENTIONS", "PART_OF"]),
8
+ };
9
+
10
+ test("write mode allows CREATE node + relationship statement", () => {
11
+ const result = validate(
12
+ "MATCH (a:Person), (b:Organization) WHERE a.name = $a AND b.name = $b CREATE (a)-[:KNOWS]->(b)",
13
+ snapshot,
14
+ { mode: "write" },
15
+ );
16
+ assert.equal(result.ok, true, "expected valid write");
17
+ assert.deepEqual(result.unknown, []);
18
+ assert.deepEqual(result.forbidden, []);
19
+ });
20
+
21
+ test("write mode allows MERGE with SET clause", () => {
22
+ const result = validate(
23
+ "MATCH (a:Person {name: $name}) MERGE (a)-[r:KNOWS]->(b:Person {name: $other}) SET r.createdAt = datetime()",
24
+ snapshot,
25
+ { mode: "write" },
26
+ );
27
+ assert.equal(result.ok, true);
28
+ });
29
+
30
+ test("write mode allows DETACH DELETE", () => {
31
+ const result = validate(
32
+ "MATCH (n:Person) WHERE n.archived = true DETACH DELETE n",
33
+ snapshot,
34
+ { mode: "write" },
35
+ );
36
+ assert.equal(result.ok, true);
37
+ });
38
+
39
+ test("write mode allows REMOVE label + SET label (re-label)", () => {
40
+ // Allow renaming labels — the new label is unknown to the schema until the
41
+ // write commits, but the validator must not block legitimate normalisation.
42
+ const result = validate(
43
+ "MATCH (n:Person) WHERE n.legacy = true REMOVE n:Person SET n:LegacyPerson",
44
+ snapshot,
45
+ { mode: "write" },
46
+ );
47
+ // LegacyPerson is unknown — fail-soft for write mode (audit emits warning, not reject).
48
+ // The token check is shared with read-mode; the write mode adds DDL/admin reject layer.
49
+ // For this test we accept the write-mode-specific outcome: no forbidden patterns.
50
+ assert.deepEqual(result.forbidden, []);
51
+ });
52
+
53
+ test("write mode allows CALL apoc.refactor.mergeNodes", () => {
54
+ const result = validate(
55
+ "MATCH (a:Person), (b:Person) WHERE a.email = b.email AND id(a) < id(b) WITH a, b CALL apoc.refactor.mergeNodes([a, b]) YIELD node RETURN node",
56
+ snapshot,
57
+ { mode: "write" },
58
+ );
59
+ assert.deepEqual(result.forbidden, []);
60
+ });
61
+
62
+ test("write mode REJECTS DROP DATABASE", () => {
63
+ const result = validate("DROP DATABASE neo4j", snapshot, { mode: "write" });
64
+ assert.equal(result.ok, false);
65
+ assert.equal(result.forbidden.length, 1);
66
+ assert.equal(result.forbidden[0].kind, "drop-database");
67
+ });
68
+
69
+ test("write mode REJECTS CREATE INDEX", () => {
70
+ const result = validate(
71
+ "CREATE INDEX person_name_idx FOR (n:Person) ON (n.name)",
72
+ snapshot,
73
+ { mode: "write" },
74
+ );
75
+ assert.equal(result.ok, false);
76
+ const f = result.forbidden.find((x) => x.kind === "create-index");
77
+ assert.ok(f, "expected create-index forbidden");
78
+ });
79
+
80
+ test("write mode REJECTS CREATE CONSTRAINT", () => {
81
+ const result = validate(
82
+ "CREATE CONSTRAINT person_email_unique FOR (n:Person) REQUIRE n.email IS UNIQUE",
83
+ snapshot,
84
+ { mode: "write" },
85
+ );
86
+ assert.equal(result.ok, false);
87
+ const f = result.forbidden.find((x) => x.kind === "create-constraint");
88
+ assert.ok(f);
89
+ });
90
+
91
+ test("write mode REJECTS CALL dbms.security.*", () => {
92
+ const result = validate(
93
+ "CALL dbms.security.createUser('attacker', 'pwd', false)",
94
+ snapshot,
95
+ { mode: "write" },
96
+ );
97
+ assert.equal(result.ok, false);
98
+ const f = result.forbidden.find((x) => x.kind === "call-dbms");
99
+ assert.ok(f);
100
+ });
101
+
102
+ test("write mode REJECTS CALL db.create*", () => {
103
+ const result = validate(
104
+ "CALL db.createLabel('Foo')",
105
+ snapshot,
106
+ { mode: "write" },
107
+ );
108
+ assert.equal(result.ok, false);
109
+ const f = result.forbidden.find((x) => x.kind === "call-db-create");
110
+ assert.ok(f);
111
+ });
112
+
113
+ test("write mode allows CALL db.labels (read-only db.* call)", () => {
114
+ // db.labels is a read primitive — not a create/drop. Validator only
115
+ // forbids db.create* family; CALL db.labels remains allowed.
116
+ const result = validate("CALL db.labels()", snapshot, { mode: "write" });
117
+ assert.deepEqual(result.forbidden, []);
118
+ });
119
+
120
+ test("read mode does NOT apply forbidden checks (DDL pattern in MATCH still allowed)", () => {
121
+ // String literals containing forbidden tokens never trip — DDL forbidden check
122
+ // is mode-gated. Read-mode WHERE clauses comparing on a string with the
123
+ // word DROP must not reject.
124
+ const result = validate(
125
+ "MATCH (n:Person) WHERE n.bio CONTAINS 'DROP DATABASE' RETURN n",
126
+ snapshot,
127
+ { mode: "read" },
128
+ );
129
+ assert.deepEqual(result.forbidden, []);
130
+ assert.equal(result.ok, true);
131
+ });
132
+
133
+ test("write mode strips string literals before forbidden check", () => {
134
+ // The forbidden check must use stripStringLiterals so a string property
135
+ // value like 'DROP DATABASE archive' does not trip the rejection.
136
+ const result = validate(
137
+ "MATCH (n:Person) WHERE n.bio CONTAINS 'DROP DATABASE' SET n.flagged = true",
138
+ snapshot,
139
+ { mode: "write" },
140
+ );
141
+ assert.deepEqual(result.forbidden, [], "string-literal content must not trip DDL check");
142
+ });
143
+
144
+ test("default mode is read (backward compatibility)", () => {
145
+ // Existing call sites pass no mode option — must remain read-mode behavior.
146
+ const result = validate("MATCH (n:Person) RETURN n", snapshot);
147
+ assert.equal(result.ok, true);
148
+ assert.equal(Array.isArray(result.forbidden), true);
149
+ assert.equal(result.forbidden.length, 0);
150
+ });
@@ -0,0 +1,349 @@
1
+ /**
2
+ * Cypher rewrite pass that injects provenance stamps into operator-supplied
3
+ * write_neo4j_cypher (Task 797). Sibling to cypher-validate.ts: validate first,
4
+ * rewrite second, run third.
5
+ *
6
+ * Doctrine context: Task 673 wrapped writers stamp every CREATE node
7
+ * structurally via writeNodeWithEdges; raw cypher (Task 796) relied on
8
+ * prompt-internalised discipline. This module closes the asymmetry — the
9
+ * rewriter appends `SET <alias>.createdAt = $__autoStartTimestamp,
10
+ * <alias>.createdByAgent = $__autoAgent, <alias>.createdByTool =
11
+ * 'graph-cypher-write', <alias>.createdBySession = $__autoSession` to every
12
+ * CREATE/MERGE clause that introduces a new labeled node. Caller supplies
13
+ * the three `$__auto*` params at tx.run time.
14
+ *
15
+ * MERGE handling preserves the create-vs-match distinction. CREATE always
16
+ * stamps (every CREATE introduces a new node by definition). MERGE injects
17
+ * `ON CREATE SET` so an existing-node match path keeps the original writer's
18
+ * provenance intact — overwriting it would destroy forensic attribution.
19
+ *
20
+ * Node-only stamping. Edge aliases (`[r:R]`) are not auto-stamped — matches
21
+ * the wrapped writer's writeNodeWithEdges, which stamps the node only.
22
+ *
23
+ * Idempotent: re-applying the rewriter to its own output is a no-op. The
24
+ * marker that proves "already stamped" is `<alias>.createdBySession =
25
+ * $__autoSession` — the unique combination of property name + auto param.
26
+ *
27
+ * String literals and comments are redacted (chars replaced with spaces,
28
+ * offsets preserved) so a `CREATE (fake:Foo)` inside a comment or quoted
29
+ * string cannot mistakenly trip the rewriter.
30
+ */
31
+
32
+ export interface RewriteResult {
33
+ cypher: string;
34
+ /** Number of node aliases that received a fresh stamp injection. */
35
+ stampsAppended: number;
36
+ }
37
+
38
+ const AUTO_PARAM_AGENT = "$__autoAgent";
39
+ const AUTO_PARAM_SESSION = "$__autoSession";
40
+ const AUTO_PARAM_TIMESTAMP = "$__autoStartTimestamp";
41
+ const AUTO_TOOL_LITERAL = "'graph-cypher-write'";
42
+
43
+ const KEYWORD_TERMINATORS = new Set([
44
+ "SET",
45
+ "ON",
46
+ "MATCH",
47
+ "MERGE",
48
+ "CREATE",
49
+ "RETURN",
50
+ "WITH",
51
+ "WHERE",
52
+ "REMOVE",
53
+ "DELETE",
54
+ "DETACH",
55
+ "UNION",
56
+ "ORDER",
57
+ "LIMIT",
58
+ "SKIP",
59
+ "FOREACH",
60
+ "UNWIND",
61
+ "CALL",
62
+ "USE",
63
+ "LOAD",
64
+ "YIELD",
65
+ ]);
66
+
67
+ function buildRedacted(cypher: string): string {
68
+ let out = "";
69
+ let i = 0;
70
+ while (i < cypher.length) {
71
+ const c = cypher[i];
72
+ if (c === "/" && cypher[i + 1] === "/") {
73
+ while (i < cypher.length && cypher[i] !== "\n") {
74
+ out += " ";
75
+ i++;
76
+ }
77
+ continue;
78
+ }
79
+ if (c === "/" && cypher[i + 1] === "*") {
80
+ out += " ";
81
+ i += 2;
82
+ while (i < cypher.length && !(cypher[i] === "*" && cypher[i + 1] === "/")) {
83
+ out += " ";
84
+ i++;
85
+ }
86
+ if (i < cypher.length) {
87
+ out += " ";
88
+ i += 2;
89
+ }
90
+ continue;
91
+ }
92
+ if (c === "'" || c === '"') {
93
+ const quote = c;
94
+ out += c;
95
+ i++;
96
+ while (i < cypher.length && cypher[i] !== quote) {
97
+ if (cypher[i] === "\\" && i + 1 < cypher.length) {
98
+ out += " ";
99
+ i += 2;
100
+ continue;
101
+ }
102
+ out += " ";
103
+ i++;
104
+ }
105
+ if (i < cypher.length) {
106
+ out += cypher[i];
107
+ i++;
108
+ }
109
+ continue;
110
+ }
111
+ out += c;
112
+ i++;
113
+ }
114
+ return out;
115
+ }
116
+
117
+ function findKeywordHits(
118
+ redacted: string,
119
+ ): Array<{ kw: "CREATE" | "MERGE"; afterKw: number }> {
120
+ const re = /\b(CREATE|MERGE)\b/gi;
121
+ const results: Array<{ kw: "CREATE" | "MERGE"; afterKw: number }> = [];
122
+ for (const m of redacted.matchAll(re)) {
123
+ if (m.index === undefined) continue;
124
+ const upper = m[1].toUpperCase();
125
+ // Skip `CREATE` that's part of `ON CREATE SET` (a MERGE sub-clause, not
126
+ // an introducing CREATE). Without this, the auto-stamp's own injected
127
+ // `ON CREATE SET` would register as a separate CREATE hit on the next
128
+ // rewriter pass, breaking idempotency by truncating the stamp window.
129
+ if (upper === "CREATE") {
130
+ const prefix = redacted.slice(Math.max(0, m.index - 8), m.index);
131
+ if (/\bON\s+$/i.test(prefix)) continue;
132
+ }
133
+ results.push({
134
+ kw: upper as "CREATE" | "MERGE",
135
+ afterKw: m.index + m[0].length,
136
+ });
137
+ }
138
+ return results;
139
+ }
140
+
141
+ const TERMINATOR_PROBE = /^(\s*)(?:;|([A-Za-z]+))/;
142
+
143
+ /**
144
+ * Returns true iff position `i` in `redacted` is at a word boundary — i.e.,
145
+ * the immediately preceding character is not part of an identifier (letters,
146
+ * digits, underscore). Without this guard, TERMINATOR_PROBE matches the
147
+ * trailing letters of a multi-char identifier as a keyword (e.g. `Session`
148
+ * ending in `on` would match the `ON` keyword and truncate the scan
149
+ * mid-identifier).
150
+ */
151
+ function atWordBoundary(redacted: string, i: number): boolean {
152
+ if (i === 0) return true;
153
+ return !/[A-Za-z0-9_]/.test(redacted[i - 1]);
154
+ }
155
+
156
+ /**
157
+ * Returns the index just past the closing `)` of the CREATE/MERGE pattern,
158
+ * or null if no node pattern follows the keyword (e.g. CREATE INDEX, CREATE
159
+ * CONSTRAINT — neither would have reached the rewriter, but defensive).
160
+ */
161
+ function findPatternEnd(redacted: string, afterKw: number): number | null {
162
+ let i = afterKw;
163
+ while (i < redacted.length && /\s/.test(redacted[i])) i++;
164
+ if (i >= redacted.length || redacted[i] !== "(") return null;
165
+ let parenDepth = 0;
166
+ let bracketDepth = 0;
167
+ while (i < redacted.length) {
168
+ if (parenDepth === 0 && bracketDepth === 0 && i > afterKw) {
169
+ const tmatch = redacted.slice(i).match(TERMINATOR_PROBE);
170
+ if (tmatch) {
171
+ const wsLen = tmatch[1].length;
172
+ const ch = redacted[i + wsLen];
173
+ if (ch === ";") return i + wsLen;
174
+ const word = tmatch[2];
175
+ if (
176
+ word &&
177
+ KEYWORD_TERMINATORS.has(word.toUpperCase()) &&
178
+ atWordBoundary(redacted, i + wsLen)
179
+ ) {
180
+ return i + wsLen;
181
+ }
182
+ }
183
+ }
184
+ const c = redacted[i];
185
+ if (c === "(") parenDepth++;
186
+ else if (c === ")") parenDepth--;
187
+ else if (c === "[") bracketDepth++;
188
+ else if (c === "]") bracketDepth--;
189
+ i++;
190
+ }
191
+ return i;
192
+ }
193
+
194
+ function extractLabeledAliases(redactedSlice: string): string[] {
195
+ const out: string[] = [];
196
+ const seen = new Set<string>();
197
+ const re = /\(\s*([A-Za-z_][A-Za-z0-9_]*)\s*:\s*[A-Z]/g;
198
+ for (const m of redactedSlice.matchAll(re)) {
199
+ const alias = m[1];
200
+ if (!seen.has(alias)) {
201
+ seen.add(alias);
202
+ out.push(alias);
203
+ }
204
+ }
205
+ return out;
206
+ }
207
+
208
+ function aliasAlreadyStamped(slice: string, alias: string): boolean {
209
+ // Per-pattern scope (review F1). The `slice` covers this CREATE/MERGE
210
+ // pattern's possible SET / ON CREATE SET clauses up to the next CREATE/
211
+ // MERGE boundary — scoping the marker check globally would mis-classify
212
+ // a re-introduction of the same alias name across two distinct patterns
213
+ // as "already stamped" (the first pattern's stamp would shadow the second).
214
+ const escaped = alias.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
215
+ const marker = new RegExp(
216
+ `\\b${escaped}\\.createdBySession\\s*=\\s*\\$__autoSession\\b`,
217
+ );
218
+ return marker.test(slice);
219
+ }
220
+
221
+ function buildAssignments(aliases: string[]): string {
222
+ return aliases
223
+ .flatMap((a) => [
224
+ `${a}.createdAt = ${AUTO_PARAM_TIMESTAMP}`,
225
+ `${a}.createdByAgent = ${AUTO_PARAM_AGENT}`,
226
+ `${a}.createdByTool = ${AUTO_TOOL_LITERAL}`,
227
+ `${a}.createdBySession = ${AUTO_PARAM_SESSION}`,
228
+ ])
229
+ .join(", ");
230
+ }
231
+
232
+ const ON_CREATE_SET_PROBE = /^ON\s+CREATE\s+SET\s+/i;
233
+ const ON_MATCH_SET_PROBE = /^ON\s+MATCH\s+SET\b/i;
234
+
235
+ function findOnCreateSetEnd(
236
+ redacted: string,
237
+ fromIdx: number,
238
+ ): { end: number } | null {
239
+ let i = fromIdx;
240
+ while (i < redacted.length && /\s/.test(redacted[i])) i++;
241
+ const m = redacted.slice(i).match(ON_CREATE_SET_PROBE);
242
+ if (!m) return null;
243
+ let j = i + m[0].length;
244
+ let parenDepth = 0;
245
+ let bracketDepth = 0;
246
+ while (j < redacted.length) {
247
+ if (parenDepth === 0 && bracketDepth === 0) {
248
+ const tmatch = redacted.slice(j).match(TERMINATOR_PROBE);
249
+ if (tmatch) {
250
+ const wsLen = tmatch[1].length;
251
+ const ch = redacted[j + wsLen];
252
+ if (ch === ";") return { end: j + wsLen };
253
+ const word = tmatch[2];
254
+ if (
255
+ word &&
256
+ KEYWORD_TERMINATORS.has(word.toUpperCase()) &&
257
+ atWordBoundary(redacted, j + wsLen)
258
+ ) {
259
+ return { end: j + wsLen };
260
+ }
261
+ }
262
+ }
263
+ const c = redacted[j];
264
+ if (c === "(") parenDepth++;
265
+ else if (c === ")") parenDepth--;
266
+ else if (c === "[") bracketDepth++;
267
+ else if (c === "]") bracketDepth--;
268
+ j++;
269
+ }
270
+ return { end: j };
271
+ }
272
+
273
+ function findOnMatchSetStart(redacted: string, fromIdx: number): number | null {
274
+ let i = fromIdx;
275
+ while (i < redacted.length && /\s/.test(redacted[i])) i++;
276
+ if (ON_MATCH_SET_PROBE.test(redacted.slice(i))) {
277
+ return i;
278
+ }
279
+ return null;
280
+ }
281
+
282
+ export function rewriteWithProvenanceStamps(cypher: string): RewriteResult {
283
+ const redacted = buildRedacted(cypher);
284
+ const hits = findKeywordHits(redacted);
285
+
286
+ type Edit = { at: number; insert: string };
287
+ const edits: Edit[] = [];
288
+ let stampsAppended = 0;
289
+
290
+ for (let h = 0; h < hits.length; h++) {
291
+ const { kw, afterKw } = hits[h];
292
+ const patternEnd = findPatternEnd(redacted, afterKw);
293
+ if (patternEnd === null) continue;
294
+
295
+ let pStart = afterKw;
296
+ while (pStart < redacted.length && /\s/.test(redacted[pStart])) pStart++;
297
+
298
+ const patternSlice = redacted.slice(pStart, patternEnd);
299
+ const labeled = extractLabeledAliases(patternSlice);
300
+ // Per-pattern stamp window: from this pattern's start through the next
301
+ // CREATE/MERGE boundary (or end of cypher). Scoping the "already stamped"
302
+ // check globally would mis-classify a re-introduction of the same alias
303
+ // name across two distinct patterns (review F1).
304
+ const nextHitAfterKw =
305
+ h + 1 < hits.length ? hits[h + 1].afterKw : redacted.length;
306
+ const stampWindow = redacted.slice(pStart, nextHitAfterKw);
307
+ const aliases = labeled.filter((a) => !aliasAlreadyStamped(stampWindow, a));
308
+ if (aliases.length === 0) continue;
309
+
310
+ if (kw === "CREATE") {
311
+ // Trailing space matters: when patternEnd lands at a clause keyword
312
+ // (e.g. ` WITH` immediately after `(n:Person)`), our `findPatternEnd`
313
+ // returns the keyword's position. Without a trailing space, the
314
+ // injected `$__autoSession` runs into the keyword and Cypher parses
315
+ // `$__autoSessionWITH` as one parameter name.
316
+ edits.push({
317
+ at: patternEnd,
318
+ insert: ` SET ${buildAssignments(aliases)} `,
319
+ });
320
+ stampsAppended += aliases.length;
321
+ continue;
322
+ }
323
+
324
+ const existingOnCreate = findOnCreateSetEnd(redacted, patternEnd);
325
+ if (existingOnCreate) {
326
+ edits.push({
327
+ at: existingOnCreate.end,
328
+ insert: `, ${buildAssignments(aliases)} `,
329
+ });
330
+ stampsAppended += aliases.length;
331
+ continue;
332
+ }
333
+ const onMatchStart = findOnMatchSetStart(redacted, patternEnd);
334
+ const insertAt = onMatchStart ?? patternEnd;
335
+ edits.push({
336
+ at: insertAt,
337
+ insert: ` ON CREATE SET ${buildAssignments(aliases)} `,
338
+ });
339
+ stampsAppended += aliases.length;
340
+ }
341
+
342
+ edits.sort((a, b) => b.at - a.at);
343
+ let result = cypher;
344
+ for (const edit of edits) {
345
+ result = result.slice(0, edit.at) + edit.insert + result.slice(edit.at);
346
+ }
347
+
348
+ return { cypher: result, stampsAppended };
349
+ }