@ssobig/writer-cli 0.2.0
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 +35 -0
- package/asset-repository.js +278 -0
- package/config.js +14 -0
- package/package.json +28 -0
- package/project-runtime.js +102 -0
- package/storage-path.js +110 -0
- package/templates/mystery-v1/authoring-view-preference.js +34 -0
- package/templates/mystery-v1/character-perspective-preview.js +61 -0
- package/templates/mystery-v1/component-asset-operations.js +103 -0
- package/templates/mystery-v1/component-autosave.js +121 -0
- package/templates/mystery-v1/component-catalog-contract.js +340 -0
- package/templates/mystery-v1/component-checkpoint-history.js +145 -0
- package/templates/mystery-v1/component-contract.js +90 -0
- package/templates/mystery-v1/component-draft-operations.js +313 -0
- package/templates/mystery-v1/component-field-contracts.js +595 -0
- package/templates/mystery-v1/component-id-policy.js +64 -0
- package/templates/mystery-v1/component-manager.js +396 -0
- package/templates/mystery-v1/component-navigation-counts.js +64 -0
- package/templates/mystery-v1/component-registry.js +205 -0
- package/templates/mystery-v1/component-renderers.js +139 -0
- package/templates/mystery-v1/component-storage-contract.js +237 -0
- package/templates/mystery-v1/external-update-coordinator.js +91 -0
- package/templates/mystery-v1/output-clue-card-layout.js +46 -0
- package/templates/mystery-v1/page-header.js +26 -0
- package/templates/mystery-v1/render-ui-state.js +76 -0
- package/templates/mystery-v1/runtime-snapshot-reconciler.js +40 -0
- package/templates/mystery-v1/tab-bar.js +87 -0
- package/templates/mystery-v1/view-component-contract.js +152 -0
- package/templates/mystery-v1/view-component-registry.js +44 -0
- package/templates/mystery-v1/view-component-runtime.js +95 -0
- package/tools/writer-cli/bin/ssobig-writer-daemon.cjs +34 -0
- package/tools/writer-cli/bin/ssobig-writer.cjs +12 -0
- package/tools/writer-cli/package-lock.json +121 -0
- package/tools/writer-cli/package.json +22 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/SKILL.md +38 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/agents/openai.yaml +4 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/assets-checkpoints.md +5 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/errors.md +10 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/install-auth.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/projects-components.md +7 -0
- package/tools/writer-cli/skills/ssobig-writer-cli/references/read-search.md +5 -0
- package/tools/writer-cli/src/agent-paths.cjs +114 -0
- package/tools/writer-cli/src/agent-service.cjs +496 -0
- package/tools/writer-cli/src/asset-policy.cjs +113 -0
- package/tools/writer-cli/src/auth.cjs +655 -0
- package/tools/writer-cli/src/checkpoint-diff.cjs +128 -0
- package/tools/writer-cli/src/command-registry.cjs +152 -0
- package/tools/writer-cli/src/commands.cjs +841 -0
- package/tools/writer-cli/src/corpus.cjs +83 -0
- package/tools/writer-cli/src/daemon-app.cjs +106 -0
- package/tools/writer-cli/src/daemon-client.cjs +187 -0
- package/tools/writer-cli/src/daemon-protocol.cjs +184 -0
- package/tools/writer-cli/src/daemon-runner.cjs +97 -0
- package/tools/writer-cli/src/daemon-server.cjs +378 -0
- package/tools/writer-cli/src/diagnostics.cjs +235 -0
- package/tools/writer-cli/src/domain.cjs +731 -0
- package/tools/writer-cli/src/errors.cjs +47 -0
- package/tools/writer-cli/src/gateway.cjs +357 -0
- package/tools/writer-cli/src/investigation-board-layout.cjs +328 -0
- package/tools/writer-cli/src/json-patch.cjs +98 -0
- package/tools/writer-cli/src/json.cjs +26 -0
- package/tools/writer-cli/src/local-index-cache.cjs +139 -0
- package/tools/writer-cli/src/local-index-lookup.cjs +98 -0
- package/tools/writer-cli/src/local-index-query.cjs +304 -0
- package/tools/writer-cli/src/local-index-snapshot.cjs +235 -0
- package/tools/writer-cli/src/local-index-storage.cjs +284 -0
- package/tools/writer-cli/src/local-index.cjs +199 -0
- package/tools/writer-cli/src/mutations.cjs +722 -0
- package/tools/writer-cli/src/platform-runner.cjs +55 -0
- package/tools/writer-cli/src/project-import.cjs +485 -0
- package/tools/writer-cli/src/skill-manager.cjs +255 -0
- package/tools/writer-cli/src/source-fingerprint.cjs +90 -0
- package/tools/writer-cli/src/update-gate.cjs +102 -0
|
@@ -0,0 +1,328 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { sha256 } = require("./json.cjs");
|
|
4
|
+
const { cliError } = require("./errors.cjs");
|
|
5
|
+
|
|
6
|
+
const SPEC_SCHEMA_VERSION = "ssobig-investigation-board-layout-v1";
|
|
7
|
+
const ALGORITHM_VERSION = "hub-clusters-v1";
|
|
8
|
+
const VIEWPORT = Object.freeze({ width: 10000, height: 10000 });
|
|
9
|
+
const ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,127}$/;
|
|
10
|
+
const BAND_ORDER = Object.freeze(["top", "left", "center", "right", "bottom"]);
|
|
11
|
+
const BAND_RECTS = Object.freeze({
|
|
12
|
+
top: Object.freeze({ x: 3600, y: 3000, width: 2800, height: 800, columns: 4, capacity: 8 }),
|
|
13
|
+
left: Object.freeze({ x: 2800, y: 4100, width: 1600, height: 1600, columns: 2, capacity: 8 }),
|
|
14
|
+
center: Object.freeze({ x: 4400, y: 4000, width: 1200, height: 1800, columns: 2, capacity: 6 }),
|
|
15
|
+
right: Object.freeze({ x: 5600, y: 4100, width: 1600, height: 1600, columns: 2, capacity: 8 }),
|
|
16
|
+
bottom: Object.freeze({ x: 3600, y: 6000, width: 2800, height: 900, columns: 4, capacity: 8 })
|
|
17
|
+
});
|
|
18
|
+
const NODE_COLORS = Object.freeze({
|
|
19
|
+
character: "#d5e8ff",
|
|
20
|
+
evidence: "#e2ccff",
|
|
21
|
+
question: "#ffe58f",
|
|
22
|
+
allegation: "#ffcfb3",
|
|
23
|
+
fact: "#c9f7c2",
|
|
24
|
+
context: "#d9dde3"
|
|
25
|
+
});
|
|
26
|
+
const EDGE_COLORS = Object.freeze({
|
|
27
|
+
context: "#8a929d",
|
|
28
|
+
evidence: "#7b83a0",
|
|
29
|
+
claim: "#c56f46",
|
|
30
|
+
causal: "#8f6f2f",
|
|
31
|
+
primary: "#4f6475"
|
|
32
|
+
});
|
|
33
|
+
const EDGE_KIND_ORDER = Object.freeze(["context", "evidence", "claim", "causal", "primary"]);
|
|
34
|
+
|
|
35
|
+
function isObject(value) {
|
|
36
|
+
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function fail(message, details = null) {
|
|
40
|
+
throw cliError("E_VALIDATION", message, details);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function assertKeys(value, allowed, label) {
|
|
44
|
+
if (!isObject(value)) fail(`${label}은 객체여야 합니다.`);
|
|
45
|
+
const unknown = Object.keys(value).find(key => !allowed.includes(key));
|
|
46
|
+
if (unknown) fail(`${label}.${unknown}은 지원되지 않는 필드입니다.`);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function requiredString(value, label, maximum) {
|
|
50
|
+
if (typeof value !== "string" || !value.trim() || value.length > maximum) fail(`${label}은 비어 있지 않은 ${maximum}자 이하 문자열이어야 합니다.`);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function requiredId(value, label) {
|
|
55
|
+
const id = requiredString(value, label, 128);
|
|
56
|
+
if (!ID_PATTERN.test(id)) fail(`${label} 형식이 올바르지 않습니다.`);
|
|
57
|
+
return id;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function requiredOrder(value, label) {
|
|
61
|
+
if (!Number.isSafeInteger(value) || value < 0 || value > 999) fail(`${label}는 0~999 정수여야 합니다.`);
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function countBy(values) {
|
|
66
|
+
const result = {};
|
|
67
|
+
for (const value of values) result[value] = (result[value] || 0) + 1;
|
|
68
|
+
return Object.fromEntries(Object.entries(result).sort(([left], [right]) => left.localeCompare(right)));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function normalizeReferences(references) {
|
|
72
|
+
if (!Array.isArray(references)) fail("추리 보드 참조 목록이 필요합니다.");
|
|
73
|
+
const output = new Set();
|
|
74
|
+
for (const reference of references) {
|
|
75
|
+
if (!isObject(reference) || !["character", "clue"].includes(reference.type) || typeof reference.id !== "string" || !reference.id.trim()) {
|
|
76
|
+
fail("추리 보드 참조 목록에 올바르지 않은 항목이 있습니다.");
|
|
77
|
+
}
|
|
78
|
+
output.add(`${reference.type}:${reference.id}`);
|
|
79
|
+
}
|
|
80
|
+
return output;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function normalizeSpec(input, references) {
|
|
84
|
+
assertKeys(input, ["schemaVersion", "layout", "clusters", "nodes", "edges"], "layout spec");
|
|
85
|
+
if (input.schemaVersion !== SPEC_SCHEMA_VERSION) fail(`layout spec.schemaVersion은 ${SPEC_SCHEMA_VERSION}이어야 합니다.`);
|
|
86
|
+
if (input.layout !== "hub-clusters") fail("layout spec.layout은 hub-clusters여야 합니다.");
|
|
87
|
+
if (!Array.isArray(input.clusters) || input.clusters.length < 2 || input.clusters.length > BAND_ORDER.length) {
|
|
88
|
+
fail(`clusters는 2~${BAND_ORDER.length}개여야 합니다.`);
|
|
89
|
+
}
|
|
90
|
+
if (!Array.isArray(input.nodes) || input.nodes.length < 2 || input.nodes.length > 38) fail("nodes는 2~38개여야 합니다.");
|
|
91
|
+
if (!Array.isArray(input.edges) || input.edges.length < 1 || input.edges.length > 120) fail("edges는 1~120개여야 합니다.");
|
|
92
|
+
|
|
93
|
+
const clusterIds = new Set();
|
|
94
|
+
const bands = new Set();
|
|
95
|
+
const clusters = input.clusters.map((cluster, index) => {
|
|
96
|
+
assertKeys(cluster, ["id", "label", "band"], `clusters.${index}`);
|
|
97
|
+
const id = requiredId(cluster.id, `clusters.${index}.id`);
|
|
98
|
+
const label = requiredString(cluster.label, `clusters.${index}.label`, 60);
|
|
99
|
+
if (!BAND_ORDER.includes(cluster.band)) fail(`clusters.${index}.band가 올바르지 않습니다.`);
|
|
100
|
+
if (clusterIds.has(id)) fail(`중복된 cluster id입니다: ${id}`);
|
|
101
|
+
if (bands.has(cluster.band)) fail(`하나의 band에는 cluster를 하나만 둘 수 있습니다: ${cluster.band}`);
|
|
102
|
+
clusterIds.add(id);
|
|
103
|
+
bands.add(cluster.band);
|
|
104
|
+
return { id, label, band: cluster.band };
|
|
105
|
+
});
|
|
106
|
+
if (!bands.has("center")) fail("hub-clusters 배치에는 center cluster가 필요합니다.");
|
|
107
|
+
|
|
108
|
+
const referenceSet = normalizeReferences(references);
|
|
109
|
+
const nodeIds = new Set();
|
|
110
|
+
const clusterOrders = new Map();
|
|
111
|
+
const nodes = input.nodes.map((node, index) => {
|
|
112
|
+
if (!isObject(node)) fail(`nodes.${index}는 객체여야 합니다.`);
|
|
113
|
+
const common = ["id", "type", "clusterId", "order", "importance", "semantic"];
|
|
114
|
+
const fields = node.type === "reference"
|
|
115
|
+
? [...common, "referenceType", "referenceId"]
|
|
116
|
+
: node.type === "sticky" ? [...common, "title", "text"] : common;
|
|
117
|
+
assertKeys(node, fields, `nodes.${index}`);
|
|
118
|
+
const id = requiredId(node.id, `nodes.${index}.id`);
|
|
119
|
+
if (nodeIds.has(id)) fail(`중복된 node id입니다: ${id}`);
|
|
120
|
+
nodeIds.add(id);
|
|
121
|
+
if (!clusterIds.has(node.clusterId)) fail(`nodes.${index}.clusterId가 존재하지 않습니다: ${node.clusterId}`);
|
|
122
|
+
const order = requiredOrder(node.order, `nodes.${index}.order`);
|
|
123
|
+
const orderKey = `${node.clusterId}:${order}`;
|
|
124
|
+
if (clusterOrders.has(orderKey)) fail(`cluster 안에서 node order가 중복됩니다: ${orderKey}`);
|
|
125
|
+
clusterOrders.set(orderKey, id);
|
|
126
|
+
if (!["primary", "secondary"].includes(node.importance)) fail(`nodes.${index}.importance는 primary 또는 secondary여야 합니다.`);
|
|
127
|
+
if (!Object.hasOwn(NODE_COLORS, node.semantic)) fail(`nodes.${index}.semantic이 올바르지 않습니다.`);
|
|
128
|
+
if (node.type === "reference") {
|
|
129
|
+
if (!["character", "clue"].includes(node.referenceType)) fail(`nodes.${index}.referenceType이 올바르지 않습니다.`);
|
|
130
|
+
const referenceId = requiredString(node.referenceId, `nodes.${index}.referenceId`, 200);
|
|
131
|
+
if (!referenceSet.has(`${node.referenceType}:${referenceId}`)) fail(`작품에서 찾을 수 없는 참조입니다: ${node.referenceType}:${referenceId}`);
|
|
132
|
+
if (node.referenceType === "character" && node.semantic !== "character") fail(`캐릭터 reference node의 semantic은 character여야 합니다: ${id}`);
|
|
133
|
+
if (node.referenceType === "clue" && node.semantic === "character") fail(`단서 reference node는 character semantic을 사용할 수 없습니다: ${id}`);
|
|
134
|
+
return { id, type: node.type, clusterId: node.clusterId, order, importance: node.importance, semantic: node.semantic, referenceType: node.referenceType, referenceId };
|
|
135
|
+
}
|
|
136
|
+
if (node.type !== "sticky") fail(`nodes.${index}.type은 reference 또는 sticky여야 합니다.`);
|
|
137
|
+
if (node.semantic === "character") fail(`포스트잇은 character semantic을 사용할 수 없습니다: ${id}`);
|
|
138
|
+
const title = typeof node.title === "string" && node.title.length <= 120 ? node.title : fail(`nodes.${index}.title은 120자 이하 문자열이어야 합니다.`);
|
|
139
|
+
const text = typeof node.text === "string" && node.text.length <= 5000 ? node.text : fail(`nodes.${index}.text는 5000자 이하 문자열이어야 합니다.`);
|
|
140
|
+
return { id, type: node.type, clusterId: node.clusterId, order, importance: node.importance, semantic: node.semantic, title, text };
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
for (const cluster of clusters) {
|
|
144
|
+
const count = nodes.filter(node => node.clusterId === cluster.id).length;
|
|
145
|
+
if (!count) fail(`비어 있는 cluster는 사용할 수 없습니다: ${cluster.id}`);
|
|
146
|
+
if (count > BAND_RECTS[cluster.band].capacity) fail(`${cluster.band} cluster는 node를 최대 ${BAND_RECTS[cluster.band].capacity}개까지 배치할 수 있습니다.`);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
const edgeIds = new Set();
|
|
150
|
+
const edgeOrders = new Set();
|
|
151
|
+
const edges = input.edges.map((edge, index) => {
|
|
152
|
+
assertKeys(edge, ["id", "fromNodeId", "toNodeId", "order", "direction", "label", "kind"], `edges.${index}`);
|
|
153
|
+
const id = requiredId(edge.id, `edges.${index}.id`);
|
|
154
|
+
if (edgeIds.has(id)) fail(`중복된 edge id입니다: ${id}`);
|
|
155
|
+
edgeIds.add(id);
|
|
156
|
+
const order = requiredOrder(edge.order, `edges.${index}.order`);
|
|
157
|
+
if (edgeOrders.has(order)) fail(`edge order가 중복됩니다: ${order}`);
|
|
158
|
+
edgeOrders.add(order);
|
|
159
|
+
if (!nodeIds.has(edge.fromNodeId) || !nodeIds.has(edge.toNodeId) || edge.fromNodeId === edge.toNodeId) fail(`edges.${index}의 연결 node가 올바르지 않습니다.`);
|
|
160
|
+
if (!["none", "forward", "both"].includes(edge.direction)) fail(`edges.${index}.direction이 올바르지 않습니다.`);
|
|
161
|
+
const label = requiredString(edge.label, `edges.${index}.label`, 200);
|
|
162
|
+
if (!Object.hasOwn(EDGE_COLORS, edge.kind)) fail(`edges.${index}.kind가 올바르지 않습니다.`);
|
|
163
|
+
return { id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, order, direction: edge.direction, label, kind: edge.kind };
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
return { schemaVersion: SPEC_SCHEMA_VERSION, layout: "hub-clusters", clusters, nodes, edges };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
function nodeSize(node) {
|
|
170
|
+
if (node.type === "reference" && node.referenceType === "character") return node.importance === "primary" ? { width: 360, height: 280 } : { width: 320, height: 240 };
|
|
171
|
+
if (node.type === "reference") return node.importance === "primary" ? { width: 320, height: 200 } : { width: 300, height: 180 };
|
|
172
|
+
return node.importance === "primary" ? { width: 360, height: 220 } : { width: 320, height: 200 };
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function positionCluster(cluster, sourceNodes) {
|
|
176
|
+
const rect = BAND_RECTS[cluster.band];
|
|
177
|
+
const nodes = sourceNodes.slice().sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
|
178
|
+
const sized = nodes.map(node => ({ ...node, ...nodeSize(node) }));
|
|
179
|
+
const columns = Math.min(rect.columns, sized.length);
|
|
180
|
+
const rows = Math.ceil(sized.length / columns);
|
|
181
|
+
const cellWidth = Math.max(...sized.map(node => node.width)) + 120;
|
|
182
|
+
const cellHeight = Math.max(...sized.map(node => node.height)) + 120;
|
|
183
|
+
const gridWidth = columns * cellWidth - 120;
|
|
184
|
+
const gridHeight = rows * cellHeight - 120;
|
|
185
|
+
const startX = Math.round(rect.x + (rect.width - gridWidth) / 2);
|
|
186
|
+
const startY = Math.round(rect.y + (rect.height - gridHeight) / 2);
|
|
187
|
+
return sized.map((node, index) => {
|
|
188
|
+
const column = index % columns;
|
|
189
|
+
const row = Math.floor(index / columns);
|
|
190
|
+
const x = Math.round(startX + column * cellWidth + (cellWidth - 120 - node.width) / 2);
|
|
191
|
+
const y = Math.round(startY + row * cellHeight + (cellHeight - 120 - node.height) / 2);
|
|
192
|
+
const common = { id: node.id, type: node.type, x, y, width: node.width, height: node.height, color: NODE_COLORS[node.semantic] };
|
|
193
|
+
return node.type === "reference"
|
|
194
|
+
? { ...common, referenceType: node.referenceType, referenceId: node.referenceId }
|
|
195
|
+
: { ...common, title: node.title, text: node.text };
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function center(node) {
|
|
200
|
+
return { x: node.x + node.width / 2, y: node.y + node.height / 2 };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function orientation(a, b, c) {
|
|
204
|
+
return (b.x - a.x) * (c.y - a.y) - (b.y - a.y) * (c.x - a.x);
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function segmentsCross(first, second) {
|
|
208
|
+
const a = first.from;
|
|
209
|
+
const b = first.to;
|
|
210
|
+
const c = second.from;
|
|
211
|
+
const d = second.to;
|
|
212
|
+
return orientation(a, b, c) * orientation(a, b, d) < 0 && orientation(c, d, a) * orientation(c, d, b) < 0;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
function segmentIntersectsRect(segment, node, padding = 20) {
|
|
216
|
+
const left = node.x - padding;
|
|
217
|
+
const right = node.x + node.width + padding;
|
|
218
|
+
const top = node.y - padding;
|
|
219
|
+
const bottom = node.y + node.height + padding;
|
|
220
|
+
let minimum = 0;
|
|
221
|
+
let maximum = 1;
|
|
222
|
+
const dx = segment.to.x - segment.from.x;
|
|
223
|
+
const dy = segment.to.y - segment.from.y;
|
|
224
|
+
for (const [p, q] of [[-dx, segment.from.x - left], [dx, right - segment.from.x], [-dy, segment.from.y - top], [dy, bottom - segment.from.y]]) {
|
|
225
|
+
if (p === 0 && q < 0) return false;
|
|
226
|
+
if (p === 0) continue;
|
|
227
|
+
const ratio = q / p;
|
|
228
|
+
if (p < 0) minimum = Math.max(minimum, ratio);
|
|
229
|
+
else maximum = Math.min(maximum, ratio);
|
|
230
|
+
if (minimum > maximum) return false;
|
|
231
|
+
}
|
|
232
|
+
return true;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function layoutReview(spec, nodes, edges) {
|
|
236
|
+
const nodesById = new Map(nodes.map(node => [node.id, node]));
|
|
237
|
+
const segments = edges.map(edge => ({
|
|
238
|
+
edge,
|
|
239
|
+
from: center(nodesById.get(edge.fromNodeId)),
|
|
240
|
+
to: center(nodesById.get(edge.toNodeId))
|
|
241
|
+
}));
|
|
242
|
+
let crossingCount = 0;
|
|
243
|
+
for (let left = 0; left < segments.length; left += 1) {
|
|
244
|
+
for (let right = left + 1; right < segments.length; right += 1) {
|
|
245
|
+
const first = segments[left].edge;
|
|
246
|
+
const second = segments[right].edge;
|
|
247
|
+
if ([first.fromNodeId, first.toNodeId].some(id => id === second.fromNodeId || id === second.toNodeId)) continue;
|
|
248
|
+
if (segmentsCross(segments[left], segments[right])) crossingCount += 1;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
let edgeNodeOverlapCount = 0;
|
|
252
|
+
for (const segment of segments) {
|
|
253
|
+
for (const node of nodes) {
|
|
254
|
+
if (node.id === segment.edge.fromNodeId || node.id === segment.edge.toNodeId) continue;
|
|
255
|
+
if (segmentIntersectsRect(segment, node)) edgeNodeOverlapCount += 1;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
const degrees = new Map(nodes.map(node => [node.id, 0]));
|
|
259
|
+
for (const edge of edges) {
|
|
260
|
+
degrees.set(edge.fromNodeId, degrees.get(edge.fromNodeId) + 1);
|
|
261
|
+
degrees.set(edge.toNodeId, degrees.get(edge.toNodeId) + 1);
|
|
262
|
+
}
|
|
263
|
+
const orphanNodeIds = [...degrees].filter(([, degree]) => degree === 0).map(([id]) => id).sort();
|
|
264
|
+
const longEdgeCount = segments.filter(segment => Math.hypot(segment.to.x - segment.from.x, segment.to.y - segment.from.y) > 2200).length;
|
|
265
|
+
const primaryEdgeCount = spec.edges.filter(edge => edge.kind === "primary").length;
|
|
266
|
+
const warnings = [];
|
|
267
|
+
if (crossingCount) warnings.push(`관계선 교차 ${crossingCount}건: node order나 cluster band를 조정하세요.`);
|
|
268
|
+
if (edgeNodeOverlapCount) warnings.push(`다른 카드를 통과하는 관계선 ${edgeNodeOverlapCount}건: 관련 node를 같은 cluster에 가깝게 배치하세요.`);
|
|
269
|
+
if (longEdgeCount) warnings.push(`2,200px보다 긴 관계선 ${longEdgeCount}건: 보조 사건 node로 인과 흐름을 나누는 것을 검토하세요.`);
|
|
270
|
+
if (orphanNodeIds.length) warnings.push(`연결되지 않은 node ${orphanNodeIds.length}개: ${orphanNodeIds.join(", ")}`);
|
|
271
|
+
if (primaryEdgeCount > 7) warnings.push(`핵심 관계선이 ${primaryEdgeCount}개입니다. 전체 보기에는 7개 이하를 권장합니다.`);
|
|
272
|
+
if (nodes.length > 24) warnings.push(`node가 ${nodes.length}개입니다. 전체 보기와 상세 보드를 분리하는 것을 검토하세요.`);
|
|
273
|
+
const left = Math.min(...nodes.map(node => node.x));
|
|
274
|
+
const top = Math.min(...nodes.map(node => node.y));
|
|
275
|
+
const right = Math.max(...nodes.map(node => node.x + node.width));
|
|
276
|
+
const bottom = Math.max(...nodes.map(node => node.y + node.height));
|
|
277
|
+
const penalty = Math.min(35, crossingCount * 5)
|
|
278
|
+
+ Math.min(30, edgeNodeOverlapCount * 5)
|
|
279
|
+
+ Math.min(15, longEdgeCount * 2)
|
|
280
|
+
+ Math.min(20, orphanNodeIds.length * 10)
|
|
281
|
+
+ Math.max(0, primaryEdgeCount - 7) * 3;
|
|
282
|
+
return {
|
|
283
|
+
algorithmVersion: ALGORITHM_VERSION,
|
|
284
|
+
qualityScore: Math.max(0, 100 - penalty),
|
|
285
|
+
nodeCount: nodes.length,
|
|
286
|
+
nodeTypes: countBy(nodes.map(node => node.type === "sticky" ? "sticky" : node.referenceType)),
|
|
287
|
+
edgeCount: edges.length,
|
|
288
|
+
edgeKinds: countBy(spec.edges.map(edge => edge.kind)),
|
|
289
|
+
edgeDirections: countBy(edges.map(edge => edge.direction)),
|
|
290
|
+
clusters: spec.clusters
|
|
291
|
+
.slice().sort((leftCluster, rightCluster) => BAND_ORDER.indexOf(leftCluster.band) - BAND_ORDER.indexOf(rightCluster.band))
|
|
292
|
+
.map(cluster => ({ id: cluster.id, label: cluster.label, band: cluster.band, nodeIds: spec.nodes.filter(node => node.clusterId === cluster.id).sort((leftNode, rightNode) => leftNode.order - rightNode.order).map(node => node.id) })),
|
|
293
|
+
contentBounds: { x: left, y: top, width: right - left, height: bottom - top },
|
|
294
|
+
crossingCount,
|
|
295
|
+
edgeNodeOverlapCount,
|
|
296
|
+
longEdgeCount,
|
|
297
|
+
orphanNodeIds,
|
|
298
|
+
warnings
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function layoutInvestigationBoard(input, references) {
|
|
303
|
+
const spec = normalizeSpec(input, references);
|
|
304
|
+
const clusterByBand = new Map(spec.clusters.map(cluster => [cluster.band, cluster]));
|
|
305
|
+
const nodes = BAND_ORDER.flatMap(band => {
|
|
306
|
+
const cluster = clusterByBand.get(band);
|
|
307
|
+
return cluster ? positionCluster(cluster, spec.nodes.filter(node => node.clusterId === cluster.id)) : [];
|
|
308
|
+
});
|
|
309
|
+
const edges = spec.edges
|
|
310
|
+
.slice()
|
|
311
|
+
.sort((left, right) => EDGE_KIND_ORDER.indexOf(left.kind) - EDGE_KIND_ORDER.indexOf(right.kind) || left.order - right.order || left.id.localeCompare(right.id))
|
|
312
|
+
.map(edge => ({ id: edge.id, fromNodeId: edge.fromNodeId, toNodeId: edge.toNodeId, direction: edge.direction, label: edge.label, color: EDGE_COLORS[edge.kind] }));
|
|
313
|
+
return Object.freeze({
|
|
314
|
+
spec,
|
|
315
|
+
specHash: sha256(spec),
|
|
316
|
+
data: { viewport: { ...VIEWPORT }, nodes, edges },
|
|
317
|
+
review: layoutReview(spec, nodes, edges)
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
module.exports = Object.freeze({
|
|
322
|
+
SPEC_SCHEMA_VERSION,
|
|
323
|
+
ALGORITHM_VERSION,
|
|
324
|
+
VIEWPORT,
|
|
325
|
+
NODE_COLORS,
|
|
326
|
+
EDGE_COLORS,
|
|
327
|
+
layoutInvestigationBoard
|
|
328
|
+
});
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const { clone, equalJson } = require("./json.cjs");
|
|
4
|
+
const { cliError } = require("./errors.cjs");
|
|
5
|
+
|
|
6
|
+
const FORBIDDEN_SEGMENTS = new Set(["__proto__", "prototype", "constructor"]);
|
|
7
|
+
|
|
8
|
+
function pointerSegments(pointer) {
|
|
9
|
+
if (pointer === "") return [];
|
|
10
|
+
if (typeof pointer !== "string" || !pointer.startsWith("/")) {
|
|
11
|
+
throw cliError("E_INVALID_PATCH", `JSON Pointer가 올바르지 않습니다: ${String(pointer)}`);
|
|
12
|
+
}
|
|
13
|
+
return pointer.slice(1).split("/").map(segment => {
|
|
14
|
+
if (/~(?![01])/u.test(segment)) throw cliError("E_INVALID_PATCH", `JSON Pointer escape가 올바르지 않습니다: ${pointer}`);
|
|
15
|
+
const decoded = segment.replace(/~1/g, "/").replace(/~0/g, "~");
|
|
16
|
+
if (FORBIDDEN_SEGMENTS.has(decoded)) throw cliError("E_INVALID_PATCH", `안전하지 않은 JSON Pointer segment입니다: ${decoded}`);
|
|
17
|
+
return decoded;
|
|
18
|
+
});
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function arrayIndex(segment, length, options = {}) {
|
|
22
|
+
if (options.append && segment === "-") return length;
|
|
23
|
+
if (!/^(0|[1-9][0-9]*)$/.test(segment)) throw cliError("E_INVALID_PATCH", `배열 index가 올바르지 않습니다: ${segment}`);
|
|
24
|
+
const index = Number(segment);
|
|
25
|
+
const upper = options.allowEnd ? length : length - 1;
|
|
26
|
+
if (!Number.isSafeInteger(index) || index < 0 || index > upper) throw cliError("E_INVALID_PATCH", `배열 index가 범위를 벗어났습니다: ${segment}`);
|
|
27
|
+
return index;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function locate(document, segments) {
|
|
31
|
+
let current = document;
|
|
32
|
+
for (const segment of segments) {
|
|
33
|
+
if (Array.isArray(current)) current = current[arrayIndex(segment, current.length)];
|
|
34
|
+
else if (current && typeof current === "object" && Object.hasOwn(current, segment)) current = current[segment];
|
|
35
|
+
else throw cliError("E_INVALID_PATCH", `JSON Pointer 대상이 없습니다: /${segments.join("/")}`);
|
|
36
|
+
}
|
|
37
|
+
return current;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function parentAt(document, segments) {
|
|
41
|
+
if (!segments.length) return { parent: null, key: null };
|
|
42
|
+
const parent = locate(document, segments.slice(0, -1));
|
|
43
|
+
if (!parent || typeof parent !== "object") throw cliError("E_INVALID_PATCH", "JSON Patch 대상의 부모가 객체 또는 배열이 아닙니다.");
|
|
44
|
+
return { parent, key: segments.at(-1) };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function applyOperation(document, operation) {
|
|
48
|
+
if (!operation || typeof operation !== "object" || Array.isArray(operation)) throw cliError("E_INVALID_PATCH", "각 JSON Patch 작업은 객체여야 합니다.");
|
|
49
|
+
const op = String(operation.op || "");
|
|
50
|
+
const segments = pointerSegments(operation.path);
|
|
51
|
+
if (!['add', 'replace', 'remove', 'test'].includes(op)) {
|
|
52
|
+
throw cliError("E_INVALID_PATCH", `지원하지 않는 JSON Patch 작업입니다: ${op || "(비어 있음)"}`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (op === "test") {
|
|
56
|
+
if (!Object.hasOwn(operation, "value")) throw cliError("E_INVALID_PATCH", "test 작업에는 value가 필요합니다.");
|
|
57
|
+
const actual = segments.length ? locate(document, segments) : document;
|
|
58
|
+
if (!equalJson(actual, operation.value)) throw cliError("E_PATCH_TEST_FAILED", `JSON Patch test가 실패했습니다: ${operation.path}`);
|
|
59
|
+
return document;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (!segments.length) {
|
|
63
|
+
if (op === "remove") throw cliError("E_INVALID_PATCH", "Component data 루트는 삭제할 수 없습니다.");
|
|
64
|
+
if (!Object.hasOwn(operation, "value")) throw cliError("E_INVALID_PATCH", `${op} 작업에는 value가 필요합니다.`);
|
|
65
|
+
return clone(operation.value);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const { parent, key } = parentAt(document, segments);
|
|
69
|
+
if (op === "add") {
|
|
70
|
+
if (!Object.hasOwn(operation, "value")) throw cliError("E_INVALID_PATCH", "add 작업에는 value가 필요합니다.");
|
|
71
|
+
if (Array.isArray(parent)) parent.splice(arrayIndex(key, parent.length, { append: true, allowEnd: true }), 0, clone(operation.value));
|
|
72
|
+
else parent[key] = clone(operation.value);
|
|
73
|
+
return document;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (Array.isArray(parent)) {
|
|
77
|
+
const index = arrayIndex(key, parent.length);
|
|
78
|
+
if (op === "replace") {
|
|
79
|
+
if (!Object.hasOwn(operation, "value")) throw cliError("E_INVALID_PATCH", "replace 작업에는 value가 필요합니다.");
|
|
80
|
+
parent[index] = clone(operation.value);
|
|
81
|
+
} else parent.splice(index, 1);
|
|
82
|
+
return document;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
if (!Object.hasOwn(parent, key)) throw cliError("E_INVALID_PATCH", `JSON Pointer 대상이 없습니다: ${operation.path}`);
|
|
86
|
+
if (op === "replace") {
|
|
87
|
+
if (!Object.hasOwn(operation, "value")) throw cliError("E_INVALID_PATCH", "replace 작업에는 value가 필요합니다.");
|
|
88
|
+
parent[key] = clone(operation.value);
|
|
89
|
+
} else delete parent[key];
|
|
90
|
+
return document;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function applyPatch(value, patch) {
|
|
94
|
+
if (!Array.isArray(patch) || !patch.length) throw cliError("E_INVALID_PATCH", "비어 있지 않은 RFC 6902 JSON Patch 배열이 필요합니다.");
|
|
95
|
+
return patch.reduce(applyOperation, clone(value));
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
module.exports = Object.freeze({ pointerSegments, applyPatch });
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
|
|
5
|
+
function clone(value) {
|
|
6
|
+
return value === undefined ? undefined : structuredClone(value);
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
function canonicalJson(value) {
|
|
10
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
11
|
+
if (value && typeof value === "object") {
|
|
12
|
+
return `{${Object.keys(value).sort().map(key => `${JSON.stringify(key)}:${canonicalJson(value[key])}`).join(",")}}`;
|
|
13
|
+
}
|
|
14
|
+
return JSON.stringify(value);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function sha256(value) {
|
|
18
|
+
const source = Buffer.isBuffer(value) || typeof value === "string" ? value : canonicalJson(value);
|
|
19
|
+
return crypto.createHash("sha256").update(source).digest("hex");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function equalJson(left, right) {
|
|
23
|
+
return canonicalJson(left) === canonicalJson(right);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
module.exports = Object.freeze({ clone, canonicalJson, sha256, equalJson });
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
const crypto = require("node:crypto");
|
|
4
|
+
const fs = require("node:fs");
|
|
5
|
+
const path = require("node:path");
|
|
6
|
+
const { cliError } = require("./errors.cjs");
|
|
7
|
+
const { INDEX_SCHEMA_VERSION, SERVER_SNAPSHOT_SOURCE } = require("./local-index-snapshot.cjs");
|
|
8
|
+
|
|
9
|
+
const DEFAULT_FILE_NAMES = Object.freeze({ sqlite: "index.sqlite3", json: "index.json" });
|
|
10
|
+
|
|
11
|
+
function rejectSymlink(target, label) {
|
|
12
|
+
try {
|
|
13
|
+
if (fs.lstatSync(target).isSymbolicLink()) throw cliError("E_LOCAL_INDEX_PERMISSIONS", `${label} 경로는 symbolic link일 수 없습니다.`);
|
|
14
|
+
} catch (error) {
|
|
15
|
+
if (error?.code !== "ENOENT") throw error;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function ensurePrivateDirectory(directory) {
|
|
20
|
+
if (!directory) throw cliError("E_LOCAL_INDEX_CONFIGURATION", "계정별 cacheDir 또는 filePath가 필요합니다.");
|
|
21
|
+
const resolved = path.resolve(directory);
|
|
22
|
+
rejectSymlink(resolved, "로컬 인덱스 디렉터리");
|
|
23
|
+
fs.mkdirSync(resolved, { recursive: true, mode: 0o700 });
|
|
24
|
+
rejectSymlink(resolved, "로컬 인덱스 디렉터리");
|
|
25
|
+
const stat = fs.statSync(resolved);
|
|
26
|
+
if (!stat.isDirectory()) throw cliError("E_LOCAL_INDEX_PERMISSIONS", "로컬 인덱스 경로가 디렉터리가 아닙니다.");
|
|
27
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
28
|
+
throw cliError("E_LOCAL_INDEX_PERMISSIONS", "현재 사용자가 소유하지 않은 로컬 인덱스 디렉터리입니다.");
|
|
29
|
+
}
|
|
30
|
+
fs.chmodSync(resolved, 0o700);
|
|
31
|
+
return resolved;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function ensurePrivateFile(filePath) {
|
|
35
|
+
rejectSymlink(filePath, "로컬 인덱스 파일");
|
|
36
|
+
try {
|
|
37
|
+
const stat = fs.statSync(filePath);
|
|
38
|
+
if (!stat.isFile()) throw cliError("E_LOCAL_INDEX_PERMISSIONS", "로컬 인덱스 경로가 일반 파일이 아닙니다.");
|
|
39
|
+
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) {
|
|
40
|
+
throw cliError("E_LOCAL_INDEX_PERMISSIONS", "현재 사용자가 소유하지 않은 로컬 인덱스 파일입니다.");
|
|
41
|
+
}
|
|
42
|
+
fs.chmodSync(filePath, 0o600);
|
|
43
|
+
} catch (error) {
|
|
44
|
+
if (error?.code !== "ENOENT") throw error;
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function resolveStoragePaths(options, backend) {
|
|
49
|
+
const explicitFile = options.filePath ? path.resolve(String(options.filePath)) : null;
|
|
50
|
+
const cacheDir = ensurePrivateDirectory(options.cacheDir || (explicitFile ? path.dirname(explicitFile) : null));
|
|
51
|
+
if (explicitFile && path.dirname(explicitFile) !== cacheDir) {
|
|
52
|
+
throw cliError("E_LOCAL_INDEX_CONFIGURATION", "filePath는 cacheDir 바로 아래에 있어야 합니다.");
|
|
53
|
+
}
|
|
54
|
+
const filePath = explicitFile || path.join(cacheDir, DEFAULT_FILE_NAMES[backend]);
|
|
55
|
+
ensurePrivateFile(filePath);
|
|
56
|
+
return { cacheDir, filePath };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function readJsonState(filePath) {
|
|
60
|
+
try {
|
|
61
|
+
const value = JSON.parse(fs.readFileSync(filePath, "utf8"));
|
|
62
|
+
if (value?.metadata?.schemaVersion !== INDEX_SCHEMA_VERSION || value?.metadata?.source !== SERVER_SNAPSHOT_SOURCE
|
|
63
|
+
|| !Array.isArray(value.components) || !Array.isArray(value.nodes)) {
|
|
64
|
+
throw cliError("E_LOCAL_INDEX_SCHEMA", "지원하지 않는 로컬 인덱스 형식입니다. purge 후 다시 동기화해 주세요.");
|
|
65
|
+
}
|
|
66
|
+
return value;
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (error?.code === "ENOENT") return null;
|
|
69
|
+
throw error;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function atomicWritePrivateJson(cacheDir, filePath, value) {
|
|
74
|
+
const temporary = path.join(cacheDir, `.${path.basename(filePath)}.${process.pid}.${crypto.randomUUID()}.tmp`);
|
|
75
|
+
rejectSymlink(filePath, "로컬 인덱스 파일");
|
|
76
|
+
let descriptor = null;
|
|
77
|
+
try {
|
|
78
|
+
descriptor = fs.openSync(temporary, fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_WRONLY, 0o600);
|
|
79
|
+
fs.writeFileSync(descriptor, JSON.stringify(value));
|
|
80
|
+
fs.fsyncSync(descriptor);
|
|
81
|
+
fs.closeSync(descriptor);
|
|
82
|
+
descriptor = null;
|
|
83
|
+
fs.renameSync(temporary, filePath);
|
|
84
|
+
fs.chmodSync(filePath, 0o600);
|
|
85
|
+
if (process.platform !== "win32") {
|
|
86
|
+
const directoryDescriptor = fs.openSync(cacheDir, fs.constants.O_RDONLY);
|
|
87
|
+
try { fs.fsyncSync(directoryDescriptor); } finally { fs.closeSync(directoryDescriptor); }
|
|
88
|
+
}
|
|
89
|
+
} finally {
|
|
90
|
+
if (descriptor !== null) try { fs.closeSync(descriptor); } catch (error) { void error; }
|
|
91
|
+
try { fs.unlinkSync(temporary); } catch (error) { if (error?.code !== "ENOENT") throw error; }
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function createJsonBackend(options) {
|
|
96
|
+
const storage = resolveStoragePaths(options, "json");
|
|
97
|
+
let closed = false;
|
|
98
|
+
function state() {
|
|
99
|
+
if (closed) throw cliError("E_LOCAL_INDEX_CLOSED", "닫힌 로컬 인덱스입니다.");
|
|
100
|
+
return readJsonState(storage.filePath);
|
|
101
|
+
}
|
|
102
|
+
function replaceState(next) {
|
|
103
|
+
atomicWritePrivateJson(storage.cacheDir, storage.filePath, next);
|
|
104
|
+
}
|
|
105
|
+
function metadata() {
|
|
106
|
+
return state()?.metadata || null;
|
|
107
|
+
}
|
|
108
|
+
function queryRows() {
|
|
109
|
+
return state()?.nodes || [];
|
|
110
|
+
}
|
|
111
|
+
function getMatch(matchId) {
|
|
112
|
+
return (state()?.nodes || []).find(node => node.matchId === String(matchId || "")) || null;
|
|
113
|
+
}
|
|
114
|
+
function getComponent() {
|
|
115
|
+
return state()?.components || [];
|
|
116
|
+
}
|
|
117
|
+
function counts() {
|
|
118
|
+
const current = state();
|
|
119
|
+
return { components: current?.components.length || 0, scalars: current?.nodes.length || 0 };
|
|
120
|
+
}
|
|
121
|
+
function close() { closed = true; }
|
|
122
|
+
return { kind: "json", searchEngine: "json-bounded-scan", ...storage, replaceState, metadata, queryRows, getMatch, getComponent, counts, close };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function unlinkIfPresent(target) {
|
|
126
|
+
try { fs.unlinkSync(target); return true; }
|
|
127
|
+
catch (error) { if (error?.code === "ENOENT") return false; throw error; }
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function purgeBackendFiles(backend) {
|
|
131
|
+
const targets = backend.kind === "sqlite"
|
|
132
|
+
? [backend.filePath, `${backend.filePath}-journal`, `${backend.filePath}-wal`, `${backend.filePath}-shm`]
|
|
133
|
+
: [backend.filePath];
|
|
134
|
+
let removed = 0;
|
|
135
|
+
for (const target of targets) if (unlinkIfPresent(target)) removed += 1;
|
|
136
|
+
return removed;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
module.exports = Object.freeze({ resolveStoragePaths, createJsonBackend, purgeBackendFiles });
|