@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.
Files changed (84) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +48 -67
  3. package/README.zh-CN.md +36 -66
  4. package/SKILL.md +3 -0
  5. package/commands/claude/dv/continue.md +5 -0
  6. package/commands/claude/dv/design.md +1 -0
  7. package/commands/codex/prompts/dv-continue.md +6 -1
  8. package/commands/codex/prompts/dv-design.md +1 -0
  9. package/commands/gemini/dv/continue.toml +5 -0
  10. package/commands/gemini/dv/design.toml +1 -0
  11. package/commands/templates/dv-continue.shared.md +33 -0
  12. package/docs/dv-command-reference.md +45 -2
  13. package/docs/execution-chain-migration.md +46 -0
  14. package/docs/execution-chain-plan.md +125 -0
  15. package/docs/pencil-rendering-workflow.md +9 -7
  16. package/docs/prompt-entrypoints.md +6 -0
  17. package/docs/prompt-presets/README.md +4 -0
  18. package/docs/visual-assist-presets/README.md +4 -0
  19. package/docs/workflow-examples.md +23 -11
  20. package/docs/workflow-overview.md +27 -0
  21. package/docs/zh-CN/dv-command-reference.md +45 -2
  22. package/docs/zh-CN/execution-chain-migration.md +46 -0
  23. package/docs/zh-CN/pencil-rendering-workflow.md +9 -7
  24. package/docs/zh-CN/prompt-entrypoints.md +6 -0
  25. package/docs/zh-CN/prompt-presets/README.md +5 -1
  26. package/docs/zh-CN/visual-assist-presets/README.md +5 -1
  27. package/docs/zh-CN/workflow-examples.md +23 -11
  28. package/docs/zh-CN/workflow-overview.md +27 -0
  29. package/examples/greenfield-spec-markupflow/README.md +6 -1
  30. package/lib/artifact-parsers.js +120 -0
  31. package/lib/async-offload-worker.js +26 -0
  32. package/lib/async-offload.js +82 -0
  33. package/lib/audit-parsers.js +152 -32
  34. package/lib/audit.js +145 -23
  35. package/lib/cli.js +1068 -437
  36. package/lib/diff-spec.js +242 -0
  37. package/lib/execution-signals.js +136 -0
  38. package/lib/fs-safety.js +1 -4
  39. package/lib/icon-aliases.js +7 -7
  40. package/lib/icon-search.js +21 -14
  41. package/lib/icon-sync.js +220 -41
  42. package/lib/install.js +128 -60
  43. package/lib/lint-bindings.js +143 -0
  44. package/lib/lint-spec.js +408 -0
  45. package/lib/lint-tasks.js +176 -0
  46. package/lib/mcp-runtime-gate.js +4 -7
  47. package/lib/pen-persistence.js +318 -46
  48. package/lib/pencil-lock.js +237 -25
  49. package/lib/pencil-preflight.js +233 -12
  50. package/lib/pencil-session.js +216 -36
  51. package/lib/planning-parsers.js +567 -0
  52. package/lib/scaffold.js +193 -0
  53. package/lib/scope-check.js +603 -0
  54. package/lib/sidecars.js +369 -0
  55. package/lib/supervisor-review.js +82 -35
  56. package/lib/utils.js +129 -0
  57. package/lib/verify.js +652 -0
  58. package/lib/workflow-bootstrap.js +255 -0
  59. package/lib/workflow-contract.js +107 -0
  60. package/lib/workflow-persisted-state.js +297 -0
  61. package/lib/workflow-state.js +785 -0
  62. package/package.json +21 -3
  63. package/references/artifact-templates.md +26 -0
  64. package/references/checkpoints.md +16 -0
  65. package/references/design-inputs.md +2 -0
  66. package/references/modes.md +10 -0
  67. package/references/pencil-design-to-code.md +2 -0
  68. package/scripts/fixtures/complex-sample.pen +0 -295
  69. package/scripts/fixtures/mock-pencil.js +0 -49
  70. package/scripts/test-audit-context-delta.js +0 -446
  71. package/scripts/test-audit-design-supervisor.js +0 -691
  72. package/scripts/test-audit-safety.js +0 -92
  73. package/scripts/test-icon-aliases.js +0 -96
  74. package/scripts/test-icon-search.js +0 -77
  75. package/scripts/test-icon-sync.js +0 -178
  76. package/scripts/test-mcp-runtime-gate.js +0 -287
  77. package/scripts/test-mode-consistency.js +0 -344
  78. package/scripts/test-pen-persistence.js +0 -403
  79. package/scripts/test-pencil-lock.js +0 -130
  80. package/scripts/test-pencil-preflight.js +0 -169
  81. package/scripts/test-pencil-session.js +0 -192
  82. package/scripts/test-persistence-flows.js +0 -345
  83. package/scripts/test-supervisor-review-cli.js +0 -619
  84. package/scripts/test-supervisor-review-integration.js +0 -115
@@ -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 SLEEP_ARRAY = new Int32Array(new SharedArrayBuffer(4));
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
- // Use Atomics.wait to block without burning CPU while preserving the current
19
- // synchronous CLI call graph and lock semantics.
20
- Atomics.wait(SLEEP_ARRAY, 0, 0, timeoutMs);
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 (!fs.existsSync(resolvedLockPath)) {
61
+ if (!pathExists(resolvedLockPath)) {
26
62
  return null;
27
63
  }
28
64
 
29
- return JSON.parse(fs.readFileSync(resolvedLockPath, "utf8"));
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
- fs.mkdirSync(path.dirname(resolvedLockPath), { recursive: true });
35
- const handle = fs.openSync(resolvedLockPath, "wx");
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
- fs.writeFileSync(handle, JSON.stringify(payload, null, 2) + "\n", "utf8");
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.closeSync(handle);
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.projectPath === projectPath) {
75
- return {
76
- lockPath,
77
- acquired: true,
78
- alreadyHeld: true,
79
- lock: current
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
- if (Date.now() >= deadline) {
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
- sleepMs(pollIntervalMs);
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
  };
@@ -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 quote = null;
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 (!quote) {
97
- if (char === "'" || char === '"' || char === "`") {
98
- quote = char;
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 += char;
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 === quote) {
119
- quote = null;
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));