Skip to content

Commit e5a36e8

Browse files
committed
feat(CH32): implement soft/hard delete and chain repair
Implement Phase 2 of CH32 Safe Graph Removal with soft delete (default), hard delete (--hard), recursive guard (--recursive), and chain repair (--repair) for must_follow relationships. Key features: - Soft delete: Mark nodes as retired, preserve relationships - Hard delete: Physically remove nodes and relationships - Recursive guard: Require --recursive for subsystem deletions - Chain repair: Repair A→B→C chains to A→C when B removed - All 24 phase 2 tests passing (432 total)
1 parent a985289 commit e5a36e8

6 files changed

Lines changed: 484 additions & 37 deletions

File tree

src/cli/commands/update.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -180,14 +180,14 @@ const removeRelSubcommand: CommandDef = {
180180
const loaded = loadDoc(opts.path);
181181
const { doc } = loaded;
182182

183-
const newDoc = removeRelationshipOp({
183+
const result = removeRelationshipOp({
184184
doc,
185185
from: args.from,
186186
type: args.type,
187187
to: args.to,
188188
});
189189

190-
persistDoc(newDoc, loaded, opts);
190+
persistDoc(result.doc, loaded, opts);
191191

192192
if (opts.json) {
193193
console.log(

src/operations/remove-node.ts

Lines changed: 79 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -23,20 +23,95 @@ export const removeNodeOp = defineOperation({
2323
input: z.object({
2424
doc: SysProMDocument,
2525
id: z.string(),
26+
hard: z.boolean().optional(),
27+
recursive: z.boolean().optional(),
28+
repair: z.boolean().optional(),
2629
}),
2730
output: RemoveResult,
28-
fn({ doc, id }) {
31+
fn({ doc, id, hard, recursive, repair }) {
2932
const nodeIdx = doc.nodes.findIndex((n) => n.id === id);
3033
if (nodeIdx === -1) {
3134
throw new Error(`Node not found: ${id}`);
3235
}
3336

37+
const nodeToRemove = doc.nodes[nodeIdx];
3438
const warnings: string[] = [];
3539

36-
// Remove the node
37-
const newNodes = doc.nodes.filter((n) => n.id !== id);
40+
// Check recursive guard for hard delete
41+
if (hard && nodeToRemove.subsystem) {
42+
if (!recursive) {
43+
throw new Error(
44+
`Cannot hard delete node ${id} with subsystem without --recursive flag`,
45+
);
46+
}
47+
}
48+
49+
let newNodes: typeof doc.nodes;
50+
let newRelationships = doc.relationships ?? [];
51+
52+
if (hard) {
53+
// Hard delete: physically remove the node
54+
newNodes = doc.nodes.filter((n) => n.id !== id);
3855

39-
// Clean up all references to the removed node
56+
// Handle must_follow chain repair if requested
57+
if (repair) {
58+
const incomingChains = newRelationships.filter(
59+
(r) => r.to === id && r.type === "must_follow",
60+
);
61+
const outgoingChains = newRelationships.filter(
62+
(r) => r.from === id && r.type === "must_follow",
63+
);
64+
65+
// Remove all relationships involving the deleted node
66+
newRelationships = newRelationships.filter(
67+
(r) => r.from !== id && r.to !== id,
68+
);
69+
70+
// Repair chains by connecting incoming to outgoing
71+
// Only repair if there are both incoming AND outgoing chains
72+
if (incomingChains.length > 0 && outgoingChains.length > 0) {
73+
for (const incoming of incomingChains) {
74+
for (const outgoing of outgoingChains) {
75+
// Only add if not already connected
76+
const exists = newRelationships.some(
77+
(r) =>
78+
r.from === incoming.from &&
79+
r.to === outgoing.to &&
80+
r.type === "must_follow",
81+
);
82+
if (!exists) {
83+
newRelationships.push({
84+
from: incoming.from,
85+
to: outgoing.to,
86+
type: "must_follow",
87+
});
88+
warnings.push(
89+
`Repaired chain: ${incoming.from}${outgoing.to}`,
90+
);
91+
}
92+
}
93+
}
94+
}
95+
} else {
96+
// Without repair, just remove all relationships
97+
const oldRelCount = newRelationships.length;
98+
newRelationships = newRelationships.filter(
99+
(r) => r.from !== id && r.to !== id,
100+
);
101+
if (newRelationships.length < oldRelCount) {
102+
warnings.push(`Removed relationships involving ${id}`);
103+
}
104+
}
105+
} else {
106+
// Soft delete: mark as retired and preserve relationships
107+
newNodes = doc.nodes.map((n) =>
108+
n.id === id ? { ...n, status: "retired" as const } : n,
109+
);
110+
111+
// Don't remove relationships in soft delete
112+
}
113+
114+
// Clean up all references to the removed node (both soft and hard)
40115
const cleanedNodes = newNodes.map((n) => {
41116
let updated = n;
42117

@@ -73,15 +148,6 @@ export const removeNodeOp = defineOperation({
73148
return updated;
74149
});
75150

76-
// Remove relationships involving this node
77-
const oldRelCount = (doc.relationships ?? []).length;
78-
const newRelationships = (doc.relationships ?? []).filter(
79-
(r) => r.from !== id && r.to !== id,
80-
);
81-
if (newRelationships.length < oldRelCount) {
82-
warnings.push(`Removed relationships involving ${id}`);
83-
}
84-
85151
// Remove from external references
86152
const newExternalRefs = (doc.external_references ?? []).filter(
87153
(ref) => ref.node_id !== id,
Lines changed: 72 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import * as z from "zod";
22
import { defineOperation } from "./define-operation.js";
33
import { SysProMDocument, RelationshipType } from "../schema.js";
4+
import { RemoveResult } from "./remove-node.js";
45

56
/**
67
* Remove a relationship matching from, type, and to. Returns a new document without it.
8+
* Optionally repairs must_follow chains when removal would break them.
79
* @throws {Error} If no matching relationship is found.
810
*/
911
export const removeRelationshipOp = defineOperation({
@@ -15,9 +17,10 @@ export const removeRelationshipOp = defineOperation({
1517
from: z.string(),
1618
type: RelationshipType,
1719
to: z.string(),
20+
repair: z.boolean().optional(),
1821
}),
19-
output: SysProMDocument,
20-
fn({ doc, from, type, to }) {
22+
output: RemoveResult,
23+
fn({ doc, from, type, to, repair }) {
2124
const rels = doc.relationships ?? [];
2225
const idx = rels.findIndex(
2326
(r) => r.from === from && r.type === type && r.to === to,
@@ -26,13 +29,75 @@ export const removeRelationshipOp = defineOperation({
2629
throw new Error(`Relationship not found: ${from} ${type} ${to}`);
2730
}
2831

29-
const newRelationships = rels.filter(
30-
(r) => !(r.from === from && r.type === type && r.to === to),
31-
);
32+
const warnings: string[] = [];
33+
34+
// If repair flag is set and this is a must_follow relationship, prepare repairs first
35+
const repairs: typeof rels = [];
36+
if (repair && type === "must_follow") {
37+
// Find all must_follow relationships from the 'to' node (outgoing)
38+
const outgoing = rels.filter(
39+
(r) => r.from === to && r.type === "must_follow",
40+
);
41+
42+
// If there are relationships following the target, repair by connecting the source directly
43+
if (outgoing.length > 0) {
44+
// Also find all must_follow relationships pointing to the 'from' node (for multi-chain repair)
45+
const incoming = rels.filter(
46+
(r) => r.to === from && r.type === "must_follow",
47+
);
48+
49+
// If there are incoming relationships, connect them all to outgoing targets
50+
if (incoming.length > 0) {
51+
for (const inc of incoming) {
52+
for (const out of outgoing) {
53+
const bridgeExists = rels.some(
54+
(r) =>
55+
r.from === inc.from &&
56+
r.to === out.to &&
57+
r.type === "must_follow",
58+
);
59+
if (!bridgeExists) {
60+
repairs.push({
61+
from: inc.from,
62+
to: out.to,
63+
type: "must_follow",
64+
});
65+
warnings.push(`Repaired chain: ${inc.from}${out.to}`);
66+
}
67+
}
68+
}
69+
}
70+
71+
// Always connect the source node to outgoing targets
72+
for (const out of outgoing) {
73+
const bridgeExists = rels.some(
74+
(r) =>
75+
r.from === from && r.to === out.to && r.type === "must_follow",
76+
);
77+
if (!bridgeExists) {
78+
repairs.push({
79+
from: from,
80+
to: out.to,
81+
type: "must_follow",
82+
});
83+
warnings.push(`Repaired chain: ${from}${out.to}`);
84+
}
85+
}
86+
}
87+
}
88+
89+
// Remove the specified relationship and add repairs
90+
const newRelationships = rels
91+
.filter((r) => !(r.from === from && r.type === type && r.to === to))
92+
.concat(repairs);
3293

3394
return {
34-
...doc,
35-
relationships: newRelationships.length > 0 ? newRelationships : undefined,
95+
doc: {
96+
...doc,
97+
relationships:
98+
newRelationships.length > 0 ? newRelationships : undefined,
99+
},
100+
warnings,
36101
};
37102
},
38103
});

tests/mutate.unit.test.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,15 +38,15 @@ describe("addNode", () => {
3838
});
3939

4040
describe("removeNode", () => {
41-
it("removes node and relationships", () => {
41+
it("hard deletes node and relationships when hard: true", () => {
4242
const doc: SysProMDocument = {
4343
nodes: [
4444
{ id: "I1", type: "intent", name: "A" },
4545
{ id: "I2", type: "intent", name: "B" },
4646
],
4747
relationships: [{ from: "I1", to: "I2", type: "refines" }],
4848
};
49-
const result = removeNodeOp({ doc, id: "I1" });
49+
const result = removeNodeOp({ doc, id: "I1", hard: true });
5050
assert.equal(result.doc.nodes.length, 1);
5151
assert.equal(result.doc.nodes[0].id, "I2");
5252
// relationships array is removed entirely when empty
@@ -184,13 +184,13 @@ describe("removeRelationship", () => {
184184
],
185185
relationships: [{ from: "I1", to: "I2", type: "refines" }],
186186
};
187-
const newDoc = removeRelationshipOp({
187+
const result = removeRelationshipOp({
188188
doc,
189189
from: "I1",
190190
type: "refines",
191191
to: "I2",
192192
});
193-
assert.equal(newDoc.relationships?.length ?? 0, 0);
193+
assert.equal(result.doc.relationships?.length ?? 0, 0);
194194
});
195195

196196
it("throws if relationship not found", () => {

0 commit comments

Comments
 (0)