@xenonbyte/da-vinci-workflow 0.1.25 → 0.2.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/CHANGELOG.md +37 -0
- package/README.md +48 -67
- package/README.zh-CN.md +36 -66
- package/SKILL.md +3 -0
- package/commands/claude/dv/continue.md +5 -0
- package/commands/claude/dv/design.md +1 -0
- package/commands/codex/prompts/dv-continue.md +6 -1
- package/commands/codex/prompts/dv-design.md +1 -0
- package/commands/gemini/dv/continue.toml +5 -0
- package/commands/gemini/dv/design.toml +1 -0
- package/commands/templates/dv-continue.shared.md +33 -0
- package/docs/dv-command-reference.md +45 -2
- package/docs/execution-chain-migration.md +46 -0
- package/docs/execution-chain-plan.md +125 -0
- package/docs/pencil-rendering-workflow.md +9 -7
- package/docs/prompt-entrypoints.md +6 -0
- package/docs/prompt-presets/README.md +4 -0
- package/docs/visual-assist-presets/README.md +4 -0
- package/docs/workflow-examples.md +23 -11
- package/docs/workflow-overview.md +27 -0
- package/docs/zh-CN/dv-command-reference.md +45 -2
- package/docs/zh-CN/execution-chain-migration.md +46 -0
- package/docs/zh-CN/pencil-rendering-workflow.md +9 -7
- package/docs/zh-CN/prompt-entrypoints.md +6 -0
- package/docs/zh-CN/prompt-presets/README.md +5 -1
- package/docs/zh-CN/visual-assist-presets/README.md +5 -1
- package/docs/zh-CN/workflow-examples.md +23 -11
- package/docs/zh-CN/workflow-overview.md +27 -0
- package/examples/greenfield-spec-markupflow/README.md +6 -1
- package/lib/artifact-parsers.js +120 -0
- package/lib/async-offload-worker.js +26 -0
- package/lib/async-offload.js +82 -0
- package/lib/audit-parsers.js +152 -32
- package/lib/audit.js +145 -23
- package/lib/cli.js +1068 -437
- package/lib/diff-spec.js +242 -0
- package/lib/execution-signals.js +136 -0
- package/lib/fs-safety.js +1 -4
- package/lib/icon-aliases.js +7 -7
- package/lib/icon-search.js +21 -14
- package/lib/icon-sync.js +220 -41
- package/lib/install.js +128 -60
- package/lib/lint-bindings.js +143 -0
- package/lib/lint-spec.js +408 -0
- package/lib/lint-tasks.js +176 -0
- package/lib/mcp-runtime-gate.js +4 -7
- package/lib/pen-persistence.js +318 -46
- package/lib/pencil-lock.js +237 -25
- package/lib/pencil-preflight.js +233 -12
- package/lib/pencil-session.js +216 -36
- package/lib/planning-parsers.js +567 -0
- package/lib/scaffold.js +193 -0
- package/lib/scope-check.js +603 -0
- package/lib/sidecars.js +369 -0
- package/lib/supervisor-review.js +82 -35
- package/lib/utils.js +129 -0
- package/lib/verify.js +652 -0
- package/lib/workflow-bootstrap.js +255 -0
- package/lib/workflow-contract.js +107 -0
- package/lib/workflow-persisted-state.js +297 -0
- package/lib/workflow-state.js +785 -0
- package/package.json +21 -3
- package/references/artifact-templates.md +26 -0
- package/references/checkpoints.md +16 -0
- package/references/design-inputs.md +2 -0
- package/references/modes.md +10 -0
- package/references/pencil-design-to-code.md +2 -0
- package/scripts/fixtures/complex-sample.pen +0 -295
- package/scripts/fixtures/mock-pencil.js +0 -49
- package/scripts/test-audit-context-delta.js +0 -446
- package/scripts/test-audit-design-supervisor.js +0 -691
- package/scripts/test-audit-safety.js +0 -92
- package/scripts/test-icon-aliases.js +0 -96
- package/scripts/test-icon-search.js +0 -77
- package/scripts/test-icon-sync.js +0 -178
- package/scripts/test-mcp-runtime-gate.js +0 -287
- package/scripts/test-mode-consistency.js +0 -344
- package/scripts/test-pen-persistence.js +0 -403
- package/scripts/test-pencil-lock.js +0 -130
- package/scripts/test-pencil-preflight.js +0 -169
- package/scripts/test-pencil-session.js +0 -192
- package/scripts/test-persistence-flows.js +0 -345
- package/scripts/test-supervisor-review-cli.js +0 -619
- package/scripts/test-supervisor-review-integration.js +0 -115
package/lib/pencil-lock.js
CHANGED
|
@@ -1,43 +1,230 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const os = require("os");
|
|
3
3
|
const path = require("path");
|
|
4
|
+
const { isPlainObject, pathExists, sleepSync, writeFileExclusiveAtomic } = require("./utils");
|
|
4
5
|
|
|
5
|
-
const
|
|
6
|
+
const STALE_SESSION_GRACE_MS = 30 * 1000;
|
|
7
|
+
const ACTIVE_SESSION_STALE_MS = 12 * 60 * 60 * 1000;
|
|
8
|
+
const STALE_RECOVERY_LOCK_GRACE_MS = 15 * 1000;
|
|
9
|
+
let warnedSleepFailure = false;
|
|
6
10
|
|
|
7
11
|
function getLockPath(options = {}) {
|
|
8
12
|
const homeDir = options.homeDir ? path.resolve(options.homeDir) : os.homedir();
|
|
9
13
|
return path.join(homeDir, ".da-vinci", "pencil-mcp.lock");
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
function getRecoveryLockPath(lockPath) {
|
|
17
|
+
return `${lockPath}.recover`;
|
|
18
|
+
}
|
|
19
|
+
|
|
12
20
|
function sleepMs(durationMs) {
|
|
13
21
|
const timeoutMs = Math.max(0, Number(durationMs) || 0);
|
|
14
22
|
if (timeoutMs === 0) {
|
|
15
23
|
return;
|
|
16
24
|
}
|
|
17
25
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
26
|
+
try {
|
|
27
|
+
sleepSync(timeoutMs, "perform synchronous Pencil lock wait");
|
|
28
|
+
} catch (error) {
|
|
29
|
+
if (!warnedSleepFailure) {
|
|
30
|
+
warnedSleepFailure = true;
|
|
31
|
+
process.emitWarning(error.message || String(error));
|
|
32
|
+
}
|
|
33
|
+
throw error;
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function buildLockHolderLabel(lock) {
|
|
38
|
+
if (!lock || lock.__invalid) {
|
|
39
|
+
return "unknown holder";
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
return `${lock.projectPath} (owner: ${lock.owner || "unknown"}, pid: ${lock.pid || "unknown"})`;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function hasWaitTimeRemaining(deadline) {
|
|
46
|
+
return Date.now() < deadline;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function sleepBeforeRetry(deadline, pollIntervalMs) {
|
|
50
|
+
const remainingMs = Math.max(0, deadline - Date.now());
|
|
51
|
+
if (remainingMs === 0) {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
sleepMs(Math.min(pollIntervalMs, remainingMs));
|
|
56
|
+
return true;
|
|
21
57
|
}
|
|
22
58
|
|
|
23
59
|
function readLock(lockPath) {
|
|
24
60
|
const resolvedLockPath = path.resolve(lockPath);
|
|
25
|
-
if (!
|
|
61
|
+
if (!pathExists(resolvedLockPath)) {
|
|
26
62
|
return null;
|
|
27
63
|
}
|
|
28
64
|
|
|
29
|
-
|
|
65
|
+
const raw = fs.readFileSync(resolvedLockPath, "utf8");
|
|
66
|
+
try {
|
|
67
|
+
const parsed = JSON.parse(raw);
|
|
68
|
+
if (!isPlainObject(parsed)) {
|
|
69
|
+
return {
|
|
70
|
+
__invalid: true,
|
|
71
|
+
parseError: "Lock payload is not a JSON object.",
|
|
72
|
+
raw
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
return parsed;
|
|
76
|
+
} catch (error) {
|
|
77
|
+
return {
|
|
78
|
+
__invalid: true,
|
|
79
|
+
parseError: error && error.message ? error.message : String(error),
|
|
80
|
+
raw
|
|
81
|
+
};
|
|
82
|
+
}
|
|
30
83
|
}
|
|
31
84
|
|
|
32
85
|
function writeLock(lockPath, payload) {
|
|
33
86
|
const resolvedLockPath = path.resolve(lockPath);
|
|
34
|
-
|
|
35
|
-
|
|
87
|
+
writeFileExclusiveAtomic(resolvedLockPath, JSON.stringify(payload, null, 2) + "\n");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function getProjectSessionStatePath(projectPath) {
|
|
91
|
+
const resolvedProjectPath = path.resolve(String(projectPath || ""));
|
|
92
|
+
return path.join(resolvedProjectPath, ".da-vinci", "state", "pencil-session.json");
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function readProjectSession(projectPath) {
|
|
96
|
+
const sessionStatePath = getProjectSessionStatePath(projectPath);
|
|
97
|
+
if (!pathExists(sessionStatePath)) {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
36
100
|
|
|
37
101
|
try {
|
|
38
|
-
|
|
102
|
+
return JSON.parse(fs.readFileSync(sessionStatePath, "utf8"));
|
|
103
|
+
} catch (error) {
|
|
104
|
+
return null;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function hasActiveProjectSession(projectPath) {
|
|
109
|
+
const session = readProjectSession(projectPath);
|
|
110
|
+
return Boolean(session) && session.status === "active";
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function hasFreshActiveProjectSession(projectPath) {
|
|
114
|
+
const session = readProjectSession(projectPath);
|
|
115
|
+
if (!session || session.status !== "active") {
|
|
116
|
+
return false;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const activityAtMs = Date.parse(String(session.lastActivityAt || session.beganAt || ""));
|
|
120
|
+
if (!Number.isFinite(activityAtMs)) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return Date.now() - activityAtMs < ACTIVE_SESSION_STALE_MS;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
function getProcessLiveness(pidValue) {
|
|
128
|
+
const pid = Number(pidValue);
|
|
129
|
+
if (!Number.isInteger(pid) || pid <= 0) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
process.kill(pid, 0);
|
|
135
|
+
return true;
|
|
136
|
+
} catch (error) {
|
|
137
|
+
if (error && error.code === "ESRCH") {
|
|
138
|
+
return false;
|
|
139
|
+
}
|
|
140
|
+
if (error && error.code === "EPERM") {
|
|
141
|
+
return true;
|
|
142
|
+
}
|
|
143
|
+
return null;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isStaleLock(lock) {
|
|
148
|
+
if (!lock || lock.__invalid) {
|
|
149
|
+
return true;
|
|
150
|
+
}
|
|
151
|
+
if (typeof lock.projectPath !== "string" || !lock.projectPath.trim()) {
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (hasFreshActiveProjectSession(lock.projectPath)) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const ownerAlive = getProcessLiveness(lock.pid);
|
|
160
|
+
if (ownerAlive === true) {
|
|
161
|
+
return false;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const acquiredAtMs = Date.parse(String(lock.acquiredAt || ""));
|
|
165
|
+
if (Number.isFinite(acquiredAtMs) && Date.now() - acquiredAtMs < STALE_SESSION_GRACE_MS) {
|
|
166
|
+
return false;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
return true;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function isStaleRecoveryLock(lock) {
|
|
173
|
+
if (!lock || lock.__invalid) {
|
|
174
|
+
return true;
|
|
175
|
+
}
|
|
176
|
+
const acquiredAtMs = Date.parse(String(lock.acquiredAt || ""));
|
|
177
|
+
if (!Number.isFinite(acquiredAtMs)) {
|
|
178
|
+
return true;
|
|
179
|
+
}
|
|
180
|
+
return Date.now() - acquiredAtMs >= STALE_RECOVERY_LOCK_GRACE_MS;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function recoverStaleLock(lockPath, payload) {
|
|
184
|
+
const recoveryLockPath = getRecoveryLockPath(lockPath);
|
|
185
|
+
const recoveryPayload = {
|
|
186
|
+
schema: 1,
|
|
187
|
+
owner: payload.owner,
|
|
188
|
+
projectPath: payload.projectPath,
|
|
189
|
+
pid: process.pid,
|
|
190
|
+
acquiredAt: new Date().toISOString()
|
|
191
|
+
};
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
writeLock(recoveryLockPath, recoveryPayload);
|
|
195
|
+
} catch (error) {
|
|
196
|
+
if (error.code !== "EEXIST") {
|
|
197
|
+
throw error;
|
|
198
|
+
}
|
|
199
|
+
const recoveryLock = readLock(recoveryLockPath);
|
|
200
|
+
if (recoveryLock && isStaleRecoveryLock(recoveryLock)) {
|
|
201
|
+
fs.rmSync(recoveryLockPath, { force: true });
|
|
202
|
+
return {
|
|
203
|
+
recovered: false,
|
|
204
|
+
busy: false
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
return {
|
|
208
|
+
recovered: false,
|
|
209
|
+
busy: true
|
|
210
|
+
};
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
const latest = readLock(lockPath);
|
|
215
|
+
if (!latest || !isStaleLock(latest)) {
|
|
216
|
+
return {
|
|
217
|
+
recovered: false,
|
|
218
|
+
busy: false
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
fs.rmSync(lockPath, { force: true });
|
|
222
|
+
return {
|
|
223
|
+
recovered: true,
|
|
224
|
+
busy: false
|
|
225
|
+
};
|
|
39
226
|
} finally {
|
|
40
|
-
fs.
|
|
227
|
+
fs.rmSync(recoveryLockPath, { force: true });
|
|
41
228
|
}
|
|
42
229
|
}
|
|
43
230
|
|
|
@@ -55,6 +242,7 @@ function acquirePencilLock(options = {}) {
|
|
|
55
242
|
pid: process.pid,
|
|
56
243
|
acquiredAt: new Date().toISOString()
|
|
57
244
|
};
|
|
245
|
+
let staleLockRecovered = false;
|
|
58
246
|
|
|
59
247
|
while (true) {
|
|
60
248
|
try {
|
|
@@ -63,6 +251,7 @@ function acquirePencilLock(options = {}) {
|
|
|
63
251
|
lockPath,
|
|
64
252
|
acquired: true,
|
|
65
253
|
alreadyHeld: false,
|
|
254
|
+
staleLockRecovered,
|
|
66
255
|
lock: payload
|
|
67
256
|
};
|
|
68
257
|
} catch (error) {
|
|
@@ -71,23 +260,28 @@ function acquirePencilLock(options = {}) {
|
|
|
71
260
|
}
|
|
72
261
|
|
|
73
262
|
const current = readLock(lockPath);
|
|
74
|
-
if (current && current
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
263
|
+
if (current && isStaleLock(current)) {
|
|
264
|
+
const recovery = recoverStaleLock(lockPath, payload);
|
|
265
|
+
if (recovery.recovered) {
|
|
266
|
+
staleLockRecovered = true;
|
|
267
|
+
continue;
|
|
268
|
+
}
|
|
269
|
+
if (recovery.busy) {
|
|
270
|
+
if (!hasWaitTimeRemaining(deadline)) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`Pencil MCP lock recovery is already in progress while the lock is still held by ${buildLockHolderLabel(current)}.`
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
sleepBeforeRetry(deadline, pollIntervalMs);
|
|
276
|
+
continue;
|
|
277
|
+
}
|
|
278
|
+
continue;
|
|
81
279
|
}
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
const holder = current
|
|
85
|
-
? `${current.projectPath} (owner: ${current.owner || "unknown"}, pid: ${current.pid || "unknown"})`
|
|
86
|
-
: "unknown holder";
|
|
87
|
-
throw new Error(`Pencil MCP lock is already held by ${holder}.`);
|
|
280
|
+
if (!hasWaitTimeRemaining(deadline)) {
|
|
281
|
+
throw new Error(`Pencil MCP lock is already held by ${buildLockHolderLabel(current)}.`);
|
|
88
282
|
}
|
|
89
283
|
|
|
90
|
-
|
|
284
|
+
sleepBeforeRetry(deadline, pollIntervalMs);
|
|
91
285
|
}
|
|
92
286
|
}
|
|
93
287
|
}
|
|
@@ -106,6 +300,17 @@ function releasePencilLock(options = {}) {
|
|
|
106
300
|
};
|
|
107
301
|
}
|
|
108
302
|
|
|
303
|
+
if (current.__invalid) {
|
|
304
|
+
fs.rmSync(lockPath, { force: true });
|
|
305
|
+
return {
|
|
306
|
+
lockPath,
|
|
307
|
+
released: true,
|
|
308
|
+
hadLock: true,
|
|
309
|
+
lock: current,
|
|
310
|
+
recoveredInvalidLock: true
|
|
311
|
+
};
|
|
312
|
+
}
|
|
313
|
+
|
|
109
314
|
if (projectPath && current.projectPath !== projectPath && !options.force) {
|
|
110
315
|
throw new Error(
|
|
111
316
|
`Pencil MCP lock is held by a different project: ${current.projectPath}`
|
|
@@ -131,9 +336,16 @@ function getPencilLockStatus(options = {}) {
|
|
|
131
336
|
|
|
132
337
|
module.exports = {
|
|
133
338
|
getLockPath,
|
|
339
|
+
getProjectSessionStatePath,
|
|
134
340
|
readLock,
|
|
135
341
|
sleepMs,
|
|
342
|
+
hasActiveProjectSession,
|
|
343
|
+
hasFreshActiveProjectSession,
|
|
344
|
+
isStaleLock,
|
|
345
|
+
getProcessLiveness,
|
|
136
346
|
acquirePencilLock,
|
|
137
347
|
releasePencilLock,
|
|
138
|
-
getPencilLockStatus
|
|
348
|
+
getPencilLockStatus,
|
|
349
|
+
STALE_SESSION_GRACE_MS,
|
|
350
|
+
ACTIVE_SESSION_STALE_MS
|
|
139
351
|
};
|
package/lib/pencil-preflight.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
const fs = require("fs");
|
|
2
2
|
const path = require("path");
|
|
3
3
|
const vm = require("vm");
|
|
4
|
+
const { isPlainObject } = require("./utils");
|
|
4
5
|
|
|
5
6
|
const PASS = "PASS";
|
|
6
7
|
const WARN = "WARN";
|
|
@@ -73,10 +74,6 @@ function isValidNumericOrVariable(value) {
|
|
|
73
74
|
return (typeof value === "number" && Number.isFinite(value)) || isVariableReference(value);
|
|
74
75
|
}
|
|
75
76
|
|
|
76
|
-
function isPlainObject(value) {
|
|
77
|
-
return Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
78
|
-
}
|
|
79
|
-
|
|
80
77
|
function normalizeLines(operations) {
|
|
81
78
|
return String(operations || "")
|
|
82
79
|
.split(/\r?\n/)
|
|
@@ -87,22 +84,62 @@ function normalizeLines(operations) {
|
|
|
87
84
|
function stripQuotedLiterals(sourceText) {
|
|
88
85
|
const source = String(sourceText || "");
|
|
89
86
|
let result = "";
|
|
90
|
-
let
|
|
87
|
+
let state = "code";
|
|
91
88
|
let escaped = false;
|
|
92
89
|
|
|
93
90
|
for (let index = 0; index < source.length; index += 1) {
|
|
94
91
|
const char = source[index];
|
|
92
|
+
const next = source[index + 1];
|
|
95
93
|
|
|
96
|
-
if (
|
|
97
|
-
if (char === "
|
|
98
|
-
|
|
94
|
+
if (state === "line-comment") {
|
|
95
|
+
if (char === "\n") {
|
|
96
|
+
state = "code";
|
|
97
|
+
result += "\n";
|
|
98
|
+
} else {
|
|
99
99
|
result += " ";
|
|
100
|
+
}
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
if (state === "block-comment") {
|
|
105
|
+
if (char === "*" && next === "/") {
|
|
106
|
+
state = "code";
|
|
107
|
+
result += " ";
|
|
108
|
+
index += 1;
|
|
109
|
+
} else if (char === "\n") {
|
|
110
|
+
result += "\n";
|
|
100
111
|
} else {
|
|
101
|
-
result +=
|
|
112
|
+
result += " ";
|
|
102
113
|
}
|
|
103
114
|
continue;
|
|
104
115
|
}
|
|
105
116
|
|
|
117
|
+
if (state === "code") {
|
|
118
|
+
if (char === "/" && next === "/") {
|
|
119
|
+
state = "line-comment";
|
|
120
|
+
result += " ";
|
|
121
|
+
index += 1;
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (char === "/" && next === "*") {
|
|
126
|
+
state = "block-comment";
|
|
127
|
+
result += " ";
|
|
128
|
+
index += 1;
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (char === "'" || char === '"' || char === "`") {
|
|
133
|
+
state = char;
|
|
134
|
+
escaped = false;
|
|
135
|
+
result += " ";
|
|
136
|
+
continue;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
result += char;
|
|
140
|
+
continue;
|
|
141
|
+
}
|
|
142
|
+
|
|
106
143
|
if (escaped) {
|
|
107
144
|
escaped = false;
|
|
108
145
|
result += " ";
|
|
@@ -115,19 +152,201 @@ function stripQuotedLiterals(sourceText) {
|
|
|
115
152
|
continue;
|
|
116
153
|
}
|
|
117
154
|
|
|
118
|
-
if (char ===
|
|
119
|
-
|
|
155
|
+
if (char === state) {
|
|
156
|
+
state = "code";
|
|
120
157
|
result += " ";
|
|
121
158
|
continue;
|
|
122
159
|
}
|
|
123
160
|
|
|
124
|
-
result += " ";
|
|
161
|
+
result += char === "\n" ? "\n" : " ";
|
|
125
162
|
}
|
|
126
163
|
|
|
127
164
|
return result;
|
|
128
165
|
}
|
|
129
166
|
|
|
167
|
+
function isIdentifierStart(char) {
|
|
168
|
+
return /^[A-Za-z_$]$/.test(char);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
function isIdentifierPart(char) {
|
|
172
|
+
return /^[A-Za-z0-9_$]$/.test(char);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function skipWhitespace(text, startIndex) {
|
|
176
|
+
let index = startIndex;
|
|
177
|
+
while (index < text.length && /\s/.test(text[index])) {
|
|
178
|
+
index += 1;
|
|
179
|
+
}
|
|
180
|
+
return index;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function validateOperationLineShape(line) {
|
|
184
|
+
const source = String(line || "");
|
|
185
|
+
const allowedCallees = new Set(["I", "C", "U", "R", "M", "D", "G"]);
|
|
186
|
+
let index = skipWhitespace(source, 0);
|
|
187
|
+
const expressionStart = index;
|
|
188
|
+
|
|
189
|
+
// Optional binding assignment: `name = <op-call>(...)`
|
|
190
|
+
if (index < source.length && isIdentifierStart(source[index])) {
|
|
191
|
+
index += 1;
|
|
192
|
+
while (index < source.length && isIdentifierPart(source[index])) {
|
|
193
|
+
index += 1;
|
|
194
|
+
}
|
|
195
|
+
const afterIdentifier = skipWhitespace(source, index);
|
|
196
|
+
if (source[afterIdentifier] === "=") {
|
|
197
|
+
index = skipWhitespace(source, afterIdentifier + 1);
|
|
198
|
+
} else {
|
|
199
|
+
index = expressionStart;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
const callee = source[index];
|
|
204
|
+
if (!allowedCallees.has(callee)) {
|
|
205
|
+
return {
|
|
206
|
+
valid: false,
|
|
207
|
+
message:
|
|
208
|
+
"Each non-empty line must be a single declarative operation call (`I/C/U/R/M/D/G`) with optional binding assignment."
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
index += 1;
|
|
212
|
+
index = skipWhitespace(source, index);
|
|
213
|
+
if (source[index] !== "(") {
|
|
214
|
+
return {
|
|
215
|
+
valid: false,
|
|
216
|
+
message:
|
|
217
|
+
"Each non-empty line must be a single declarative operation call (`I/C/U/R/M/D/G`) with optional binding assignment."
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
let depth = 0;
|
|
222
|
+
let state = "code";
|
|
223
|
+
let escaped = false;
|
|
224
|
+
let endIndex = -1;
|
|
225
|
+
|
|
226
|
+
for (; index < source.length; index += 1) {
|
|
227
|
+
const char = source[index];
|
|
228
|
+
const next = source[index + 1];
|
|
229
|
+
|
|
230
|
+
if (state === "line-comment") {
|
|
231
|
+
if (char === "\n") {
|
|
232
|
+
state = "code";
|
|
233
|
+
}
|
|
234
|
+
continue;
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (state === "block-comment") {
|
|
238
|
+
if (char === "*" && next === "/") {
|
|
239
|
+
state = "code";
|
|
240
|
+
index += 1;
|
|
241
|
+
}
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (state === "code") {
|
|
246
|
+
if (char === "/" && next === "/") {
|
|
247
|
+
state = "line-comment";
|
|
248
|
+
index += 1;
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
if (char === "/" && next === "*") {
|
|
253
|
+
state = "block-comment";
|
|
254
|
+
index += 1;
|
|
255
|
+
continue;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
if (char === "'" || char === '"') {
|
|
259
|
+
state = char;
|
|
260
|
+
escaped = false;
|
|
261
|
+
continue;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
if (char === "`") {
|
|
265
|
+
return {
|
|
266
|
+
valid: false,
|
|
267
|
+
message:
|
|
268
|
+
"Template literals are not allowed in Pencil operations. Use single or double quoted strings."
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
if (char === "(") {
|
|
273
|
+
depth += 1;
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (char === ")") {
|
|
278
|
+
depth -= 1;
|
|
279
|
+
if (depth < 0) {
|
|
280
|
+
return {
|
|
281
|
+
valid: false,
|
|
282
|
+
message:
|
|
283
|
+
"Each non-empty line must be a single declarative operation call (`I/C/U/R/M/D/G`) with optional binding assignment."
|
|
284
|
+
};
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
if (depth === 0) {
|
|
288
|
+
endIndex = index + 1;
|
|
289
|
+
break;
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
continue;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (escaped) {
|
|
297
|
+
escaped = false;
|
|
298
|
+
continue;
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
if (char === "\\") {
|
|
302
|
+
escaped = true;
|
|
303
|
+
continue;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
if (char === state) {
|
|
307
|
+
state = "code";
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (endIndex < 0 || depth !== 0) {
|
|
312
|
+
return {
|
|
313
|
+
valid: false,
|
|
314
|
+
message:
|
|
315
|
+
"Each non-empty line must be a single declarative operation call (`I/C/U/R/M/D/G`) with optional binding assignment."
|
|
316
|
+
};
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const trailing = source.slice(endIndex).trim();
|
|
320
|
+
if (trailing.length > 0) {
|
|
321
|
+
return {
|
|
322
|
+
valid: false,
|
|
323
|
+
message:
|
|
324
|
+
"Each non-empty line must contain exactly one operation expression with no trailing statements."
|
|
325
|
+
};
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return { valid: true, message: "" };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function collectLineShapeIssues(lines, issues) {
|
|
332
|
+
lines.forEach((line, index) => {
|
|
333
|
+
const result = validateOperationLineShape(line);
|
|
334
|
+
if (!result.valid) {
|
|
335
|
+
issues.push(
|
|
336
|
+
createIssue(
|
|
337
|
+
"FAIL",
|
|
338
|
+
`Line ${index + 1}: ${result.message}`
|
|
339
|
+
)
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
});
|
|
343
|
+
}
|
|
344
|
+
|
|
130
345
|
function detectUnsafeOperationSource(operations) {
|
|
346
|
+
if (String(operations || "").includes("`")) {
|
|
347
|
+
return "Template literals are not allowed in Pencil operations. Use single or double quoted strings.";
|
|
348
|
+
}
|
|
349
|
+
|
|
131
350
|
const sanitizedSource = stripQuotedLiterals(operations);
|
|
132
351
|
for (const rule of UNSAFE_OPERATION_PATTERNS) {
|
|
133
352
|
if (rule.pattern.test(sanitizedSource)) {
|
|
@@ -485,6 +704,8 @@ function preflightPencilBatch(operations, options = {}) {
|
|
|
485
704
|
);
|
|
486
705
|
}
|
|
487
706
|
|
|
707
|
+
collectLineShapeIssues(nonEmptyLines, issues);
|
|
708
|
+
|
|
488
709
|
const unsafeSourceMessage = detectUnsafeOperationSource(normalizedOps);
|
|
489
710
|
if (unsafeSourceMessage) {
|
|
490
711
|
issues.push(createIssue("FAIL", unsafeSourceMessage));
|