@hoplogic/hopjit 0.1.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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +99 -0
  3. package/dist/act-body-interpreter.d.ts +24 -0
  4. package/dist/act-body-interpreter.js +159 -0
  5. package/dist/act-body-parser.d.ts +10 -0
  6. package/dist/act-body-parser.js +577 -0
  7. package/dist/act-builtins.d.ts +12 -0
  8. package/dist/act-builtins.js +104 -0
  9. package/dist/ast-helpers.d.ts +26 -0
  10. package/dist/ast-helpers.js +58 -0
  11. package/dist/ast-runtime.d.ts +49 -0
  12. package/dist/ast-runtime.js +224 -0
  13. package/dist/ast-types.d.ts +260 -0
  14. package/dist/ast-types.js +4 -0
  15. package/dist/cli-types.d.ts +190 -0
  16. package/dist/cli-types.js +4 -0
  17. package/dist/cli.d.ts +26 -0
  18. package/dist/cli.js +623 -0
  19. package/dist/dispatcher.d.ts +92 -0
  20. package/dist/dispatcher.js +692 -0
  21. package/dist/doc-ref.d.ts +41 -0
  22. package/dist/doc-ref.js +214 -0
  23. package/dist/engine-traverse.d.ts +37 -0
  24. package/dist/engine-traverse.js +385 -0
  25. package/dist/engine.d.ts +141 -0
  26. package/dist/engine.js +1792 -0
  27. package/dist/errors.d.ts +47 -0
  28. package/dist/errors.js +34 -0
  29. package/dist/hoplog.d.ts +120 -0
  30. package/dist/hoplog.js +501 -0
  31. package/dist/parser.d.ts +7 -0
  32. package/dist/parser.js +802 -0
  33. package/dist/persistence.d.ts +60 -0
  34. package/dist/persistence.js +208 -0
  35. package/dist/prompt.d.ts +130 -0
  36. package/dist/prompt.js +1014 -0
  37. package/dist/provider-types.d.ts +134 -0
  38. package/dist/provider-types.js +3 -0
  39. package/dist/runtime-types.d.ts +84 -0
  40. package/dist/runtime-types.js +4 -0
  41. package/dist/tools.d.ts +16 -0
  42. package/dist/tools.js +141 -0
  43. package/dist/validator.d.ts +23 -0
  44. package/dist/validator.js +959 -0
  45. package/driver/codex/AGENTS.md +54 -0
  46. package/driver/hopskill-build/SKILL.md +137 -0
  47. package/driver/hopskill-build/references/spec-skeleton.md +132 -0
  48. package/driver/hopskill-build/references/step-type-cheatsheet.md +56 -0
  49. package/driver/hopskill-build/references/three-focus-rules.md +148 -0
  50. package/driver/hopspec-skill.md +187 -0
  51. package/driver/references/cli-discovery.md +36 -0
  52. package/driver/references/discovery.md +45 -0
  53. package/driver/references/driver-subagent.md +50 -0
  54. package/driver/references/parallel-worker.md +50 -0
  55. package/driver/references/step-execution-rules.md +47 -0
  56. package/package.json +52 -0
@@ -0,0 +1,959 @@
1
+ // @module: spec-parser ^anc-struct-spec-parser
2
+ // 37-rule validator for SpecAST (from design/spec-parser.md appendix).
3
+ // S1-S12 structure, C1-C7 control flow, V1-V7 variables, P1-P15 special steps.
4
+ import { ALL_STEP_TYPES, CONTAINER_STEP_TYPES, EXECUTABLE_STEP_TYPES, hasChildren, getChildren, isUpdateModeOutput } from './ast-helpers.js';
5
+ import { ACT_BUILTINS, ACT_BUILTIN_NAMES } from './act-builtins.js';
6
+ import { checkDocRefExists } from './doc-ref.js';
7
+ // branch 声明聚合输出(统一接口名),各 case 同名填充(同一个东西)。
8
+ // branch + case 都开作用域。见 ^anc-exec-container-output
9
+ const SCOPE_CREATING_CONTAINERS = new Set(['subtask', 'parallel', 'loop', 'branch', 'case']);
10
+ /** Leaf step types — cannot have children */
11
+ const LEAF_STEP_TYPES = new Set([
12
+ 'reason', 'act', 'check', 'confirm', 'ask', 'commit', 'call', 'break', 'continue', 'exit',
13
+ ]);
14
+ /** Spec 校验入口——对 SpecAST 执行结构/控制流/变量/特殊步骤共 37 条规则,返回违规列表(放行前闸门)。见 [[spec-parser#^anc-rule-all]]。 */
15
+ export function validateSpec(ast, docRefCtx) {
16
+ const errors = [];
17
+ structuralRuleS10(ast, errors);
18
+ structuralRules(ast, errors);
19
+ controlFlowRules(ast, errors);
20
+ variableRules(ast, errors);
21
+ specialStepRules(ast, errors);
22
+ if (docRefCtx)
23
+ docRefRuleP15(ast, errors, docRefCtx); // 仅当有 workspace 访问能力时校验
24
+ return errors;
25
+ }
26
+ // P15: doc-ref [[doc#章节]] 静态存在性校验 // @a: anc-rule-p15
27
+ function docRefRuleP15(ast, errors, ctx) {
28
+ const check = (refs, step_id, line) => {
29
+ for (const ref of refs ?? []) {
30
+ const err = checkDocRefExists(ctx.workspace_dir, ctx.sandbox, ref);
31
+ if (err)
32
+ errors.push(ve('P15', 'error', `doc-ref [[${ref.doc}#${ref.section}]] 校验失败: ${err}`, step_id, line));
33
+ }
34
+ };
35
+ check(ast.header.doc_refs); // spec 级(Constraints)
36
+ for (const step of ast.steps ? flattenSteps(ast.steps) : []) {
37
+ check(step.doc_refs, step.step_id, step.source_location?.line_start);
38
+ }
39
+ }
40
+ // ===== Helpers =====
41
+ function ve(rule, severity, message, step_id, line) {
42
+ return { kind: 'validate', rule, severity, message, step_id, line };
43
+ }
44
+ function flattenSteps(steps) {
45
+ const result = [];
46
+ function walk(nodes) {
47
+ for (const n of nodes) {
48
+ result.push(n);
49
+ for (const c of getChildren(n))
50
+ walk([c]);
51
+ }
52
+ }
53
+ walk(steps);
54
+ return result;
55
+ }
56
+ function findAncestor(stepId, allSteps, predicate) {
57
+ let id = stepId;
58
+ while (id.includes('.')) {
59
+ id = id.slice(0, id.lastIndexOf('.'));
60
+ const parent = allSteps.get(id);
61
+ if (parent && predicate(parent))
62
+ return parent;
63
+ }
64
+ return undefined;
65
+ }
66
+ // ===== S1-S9: Structural rules =====
67
+ function structuralRules(ast, errors) {
68
+ // S1: title non-empty // @a: anc-rule-s1
69
+ if (!ast.header.title?.trim()) {
70
+ errors.push(ve('S1', 'error', 'Spec must have a non-empty title'));
71
+ }
72
+ // S8: goal non-empty // @a: anc-rule-s8
73
+ if (!ast.header.goal?.trim()) {
74
+ errors.push(ve('S8', 'error', 'Spec must have a non-empty goal'));
75
+ }
76
+ if (!ast.steps)
77
+ return;
78
+ const allFlat = flattenSteps(ast.steps);
79
+ const idSet = new Set();
80
+ for (const step of allFlat) {
81
+ const loc = step.source_location?.line_start;
82
+ // S2: step_id format // @a: anc-rule-s2
83
+ if (!/^\d+(\.\d+)*$/.test(step.step_id) || step.step_id.split('.').some(n => Number(n) < 1)) {
84
+ errors.push(ve('S2', 'error', `Invalid step_id format: "${step.step_id}"`, step.step_id, loc));
85
+ }
86
+ // S3: step_id uniqueness // @a: anc-rule-s3
87
+ if (idSet.has(step.step_id)) {
88
+ errors.push(ve('S3', 'error', `Duplicate step_id: "${step.step_id}"`, step.step_id, loc));
89
+ }
90
+ idSet.add(step.step_id);
91
+ // S4: valid step_type // @a: anc-rule-s4
92
+ if (!ALL_STEP_TYPES.has(step.step_type)) {
93
+ errors.push(ve('S4', 'error', `Invalid step_type: "${step.step_type}"`, step.step_id, loc));
94
+ }
95
+ // S5: container steps must have ≥1 child(含 case≡subtask)// @a: anc-rule-s5
96
+ if (CONTAINER_STEP_TYPES.has(step.step_type)) {
97
+ const children = getChildren(step);
98
+ if (children.length === 0) {
99
+ errors.push(ve('S5', 'error', `Container step "${step.step_id}" (${step.step_type}) has no children`, step.step_id, loc));
100
+ }
101
+ }
102
+ // S9: container with outputs referenced downstream must declare them // @a: anc-rule-s9
103
+ // branch 声明聚合输出(统一接口名),case 同名填充(同一个东西),都受 S9 约束
104
+ if (CONTAINER_STEP_TYPES.has(step.step_type)) {
105
+ if (!step.outputs || step.outputs.length === 0) {
106
+ const prefix = step.step_id + '.';
107
+ const isReferenced = allFlat.some(other => other.inputs?.some(b => b.source.includes(prefix)));
108
+ if (isReferenced) {
109
+ errors.push(ve('S9', 'error', `Container step "${step.step_id}" (${step.step_type}) outputs are referenced but not declared`, step.step_id, loc));
110
+ }
111
+ }
112
+ }
113
+ // S6: child step_id must inherit parent step_id as prefix // @a: anc-rule-s6
114
+ const children = getChildren(step);
115
+ for (const child of children) {
116
+ if (!child.step_id.startsWith(step.step_id + '.')) {
117
+ errors.push(ve('S6', 'error', `Child step_id "${child.step_id}" does not start with parent "${step.step_id}."`, child.step_id, child.source_location?.line_start));
118
+ }
119
+ }
120
+ }
121
+ // S7: top-level step IDs must be single numbers // @a: anc-rule-s7
122
+ for (const step of ast.steps) {
123
+ if (step.step_id.includes('.')) {
124
+ errors.push(ve('S7', 'error', `Top-level step_id "${step.step_id}" must be a single number`, step.step_id, step.source_location?.line_start));
125
+ }
126
+ }
127
+ // S11: leaf steps cannot have children // @a: anc-rule-s11
128
+ for (const step of allFlat) {
129
+ if (LEAF_STEP_TYPES.has(step.step_type) && 'children' in step) {
130
+ const children = step.children;
131
+ if (Array.isArray(children) && children.length > 0) {
132
+ const loc = step.source_location?.line_start;
133
+ errors.push(ve('S11', 'error', `叶子步骤 "${step.step_id}" (${step.step_type}) 不能有 children`, step.step_id, loc));
134
+ }
135
+ }
136
+ }
137
+ // S12: parallel children must not have inter-sibling data flow dependencies // @a: anc-rule-s12
138
+ // 注:parallel 嵌套 parallel 不报错——v2 真并行只扇出最外层,内层在 worker 子实例内退化顺序模拟
139
+ // (决策3 一层并行)。S12 的无依赖约束保证串/并等价,故嵌套退化语义安全。见 design/exec-engine.md ^anc-exec-parallel-batch
140
+ for (const step of allFlat) {
141
+ if (step.step_type === 'parallel' && hasChildren(step)) {
142
+ const pStep = step;
143
+ // Collect output names declared by each child (top-level only)
144
+ const childOutputSets = new Map();
145
+ for (const child of pStep.children) {
146
+ const outputs = new Set();
147
+ if (child.outputs) {
148
+ for (const o of child.outputs)
149
+ outputs.add(o.name);
150
+ }
151
+ // Also collect outputs from all descendants of this child
152
+ const descendants = flattenSteps([child]);
153
+ for (const d of descendants) {
154
+ if (d.outputs) {
155
+ for (const o of d.outputs)
156
+ outputs.add(o.name);
157
+ }
158
+ }
159
+ childOutputSets.set(child.step_id, outputs);
160
+ }
161
+ // Check if any child references outputs from a sibling
162
+ for (const child of pStep.children) {
163
+ const allChildSteps = flattenSteps([child]);
164
+ for (const descendant of allChildSteps) {
165
+ if (!descendant.inputs)
166
+ continue;
167
+ for (const binding of descendant.inputs) {
168
+ // Check if binding.source matches any sibling's output
169
+ childOutputSets.forEach((siblingOutputs, siblingId) => {
170
+ if (siblingId === child.step_id)
171
+ return; // same child tree, skip
172
+ if (siblingOutputs.has(binding.source)) {
173
+ const loc2 = descendant.source_location?.line_start;
174
+ errors.push(ve('S12', 'error', `parallel 内步骤 "${descendant.step_id}" 引用了同级子步骤 "${siblingId}" 的输出 "${binding.source}"`, descendant.step_id, loc2));
175
+ }
176
+ });
177
+ }
178
+ }
179
+ }
180
+ }
181
+ }
182
+ }
183
+ // S10: spec with steps must have ≥1 step; spec without steps must have goal+outputs // @a: anc-rule-s10
184
+ function structuralRuleS10(ast, errors) {
185
+ if (ast.steps !== undefined) {
186
+ // Has Steps section — must contain at least one step
187
+ if (ast.steps.length === 0) {
188
+ errors.push(ve('S10', 'error', '有 Steps 的 Spec 至少包含一个步骤'));
189
+ }
190
+ }
191
+ else {
192
+ // No Steps — capability declaration must have Goal and Outputs
193
+ if (!ast.header.goal?.trim()) {
194
+ errors.push(ve('S10', 'error', '无 Steps 的 Spec(能力声明)必须有 Goal'));
195
+ }
196
+ if (!ast.header.outputs || ast.header.outputs.length === 0) {
197
+ errors.push(ve('S10', 'error', '无 Steps 的 Spec(能力声明)必须有 Outputs'));
198
+ }
199
+ }
200
+ }
201
+ // ===== C1-C6: Control flow rules =====
202
+ function controlFlowRules(ast, errors) {
203
+ if (!ast.steps)
204
+ return;
205
+ const allFlat = flattenSteps(ast.steps);
206
+ const stepMap = new Map(allFlat.map(s => [s.step_id, s]));
207
+ for (const step of allFlat) {
208
+ const loc = step.source_location?.line_start;
209
+ // C1: case only as direct child of branch // @a: anc-rule-c1
210
+ if (step.step_type === 'case') {
211
+ const parentId = step.step_id.includes('.') ? step.step_id.slice(0, step.step_id.lastIndexOf('.')) : null;
212
+ const parent = parentId ? stepMap.get(parentId) : undefined;
213
+ if (!parent || parent.step_type !== 'branch') {
214
+ errors.push(ve('C1', 'error', `Case step "${step.step_id}" is not a direct child of a branch`, step.step_id, loc));
215
+ }
216
+ }
217
+ // C2: branch children must all be case, ≥1 (single case = if-then) // @a: anc-rule-c2
218
+ if (step.step_type === 'branch') {
219
+ const children = getChildren(step);
220
+ const nonCaseChildren = children.filter(c => c.step_type !== 'case');
221
+ if (nonCaseChildren.length > 0) {
222
+ errors.push(ve('C2', 'error', `Branch "${step.step_id}" children must all be case steps`, step.step_id, loc));
223
+ }
224
+ if (children.length < 1) {
225
+ errors.push(ve('C2', 'error', `Branch "${step.step_id}" must have at least 1 case child`, step.step_id, loc));
226
+ }
227
+ }
228
+ // C3: break/continue only inside loop // @a: anc-rule-c3
229
+ if (step.step_type === 'break' || step.step_type === 'continue') {
230
+ const loopAncestor = findAncestor(step.step_id, stepMap, s => s.step_type === 'loop');
231
+ if (!loopAncestor) {
232
+ errors.push(ve('C3', 'error', `${step.step_type} step "${step.step_id}" is not inside a loop`, step.step_id, loc));
233
+ }
234
+ }
235
+ // C6: check must be inside subtask or case // @a: anc-rule-c6
236
+ if (step.step_type === 'check') {
237
+ const containerAncestor = findAncestor(step.step_id, stepMap, s => s.step_type === 'subtask' || s.step_type === 'case');
238
+ if (!containerAncestor) {
239
+ errors.push(ve('C6', 'error', `Check step "${step.step_id}" must be inside a subtask or case`, step.step_id, loc));
240
+ }
241
+ }
242
+ // P11: check fixed signature — exactly one bool slot + one text slot // @a: anc-rule-p11
243
+ // check 是内置验证算子,固定输出签名(封闭):恰好 bool(判定槽)+ text(说明槽),
244
+ // 引擎按类型定位([[exec-engine#^anc-exec-check-verdict]])。不能多/少/换类型。
245
+ if (step.step_type === 'check') {
246
+ const outs = step.outputs ?? [];
247
+ const boolCount = outs.filter(o => o.type === 'bool').length;
248
+ const textCount = outs.filter(o => o.type === 'text').length;
249
+ if (!(outs.length === 2 && boolCount === 1 && textCount === 1)) {
250
+ errors.push(ve('P11', 'error', `Check step "${step.step_id}" must declare exactly two outputs: one bool (verdict) + one text (explanation), got [${outs.map(o => o.type).join(', ') || 'none'}]`, step.step_id, loc));
251
+ }
252
+ }
253
+ // C5: loop must have reachable termination path // @a: anc-rule-c5
254
+ if (step.step_type === 'loop') {
255
+ if (!hasTerminationPath(step)) {
256
+ errors.push(ve('C5', 'warn', `Loop "${step.step_id}" has no reachable termination path (break/exit)`, step.step_id, loc));
257
+ }
258
+ }
259
+ }
260
+ // C4: warn about unreachable steps after exit/break // @a: anc-rule-c4
261
+ checkUnreachableAfterExit(ast.steps, errors);
262
+ // C7: check finally must be inside subtask and at the end of children // @a: anc-rule-c7
263
+ for (const step of allFlat) {
264
+ if (step.step_type === 'check' && step.is_finally) {
265
+ const loc = step.source_location?.line_start;
266
+ // Must be inside a subtask
267
+ const subtaskAncestor = findAncestor(step.step_id, stepMap, s => s.step_type === 'subtask');
268
+ if (!subtaskAncestor) {
269
+ errors.push(ve('C7', 'error', `Check finally step "${step.step_id}" must be inside a subtask`, step.step_id, loc));
270
+ continue;
271
+ }
272
+ // Must be at the end of immediate parent's children
273
+ const parentId = step.step_id.includes('.') ? step.step_id.slice(0, step.step_id.lastIndexOf('.')) : null;
274
+ const parent = parentId ? stepMap.get(parentId) : undefined;
275
+ if (parent && CONTAINER_STEP_TYPES.has(parent.step_type)) {
276
+ const siblings = getChildren(parent);
277
+ // Find index of this step among siblings
278
+ const idx = siblings.findIndex(s => s.step_id === step.step_id);
279
+ // Check that all steps after this one are also check finally
280
+ for (let i = idx + 1; i < siblings.length; i++) {
281
+ const sib = siblings[i];
282
+ if (sib.step_type !== 'check' || !sib.is_finally) {
283
+ errors.push(ve('C7', 'error', `Check finally step "${step.step_id}" must be at the end of its container's children (non-finally step "${sib.step_id}" follows)`, step.step_id, loc));
284
+ break;
285
+ }
286
+ }
287
+ // Check that all steps before this one that are not check-finally don't follow a check-finally
288
+ for (let i = 0; i < idx; i++) {
289
+ const sib = siblings[i];
290
+ if (sib.step_type === 'check' && sib.is_finally) {
291
+ // A check-finally before this one, check there's no non-finally between them
292
+ for (let j = i + 1; j < idx; j++) {
293
+ const between = siblings[j];
294
+ if (between.step_type !== 'check' || !between.is_finally) {
295
+ errors.push(ve('C7', 'error', `Check finally steps must be contiguous at the end of container — non-finally step "${between.step_id}" interrupts the sequence`, between.step_id, between.source_location?.line_start));
296
+ break;
297
+ }
298
+ }
299
+ break;
300
+ }
301
+ }
302
+ }
303
+ }
304
+ }
305
+ }
306
+ function checkUnreachableAfterExit(steps, errors) {
307
+ scanSiblingList(steps, errors);
308
+ for (const step of steps) {
309
+ const children = getChildren(step);
310
+ if (children.length > 0) {
311
+ if (step.step_type === 'branch') {
312
+ for (const c of children) {
313
+ checkUnreachableAfterExit(getChildren(c), errors);
314
+ }
315
+ }
316
+ else {
317
+ checkUnreachableAfterExit(children, errors);
318
+ }
319
+ }
320
+ }
321
+ }
322
+ function scanSiblingList(siblings, errors) {
323
+ for (let i = 0; i < siblings.length - 1; i++) {
324
+ const step = siblings[i];
325
+ if (step.step_type === 'exit' || step.step_type === 'break') {
326
+ const next = siblings[i + 1];
327
+ const loc = next.source_location?.line_start;
328
+ errors.push(ve('C4', 'warn', `Step "${next.step_id}" is unreachable after ${step.step_type} step "${step.step_id}"`, next.step_id, loc));
329
+ break;
330
+ }
331
+ }
332
+ }
333
+ function hasTerminationPath(loop) {
334
+ function scan(nodes) {
335
+ if (!nodes)
336
+ return false;
337
+ for (const n of nodes) {
338
+ if (n.step_type === 'break' || n.step_type === 'exit')
339
+ return true;
340
+ if (n.step_type === 'loop')
341
+ continue; // nested loop's break doesn't terminate outer
342
+ const children = getChildren(n);
343
+ if (children.length > 0 && scan(children))
344
+ return true;
345
+ }
346
+ return false;
347
+ }
348
+ return loop.max_iterations !== undefined || scan(loop.children);
349
+ }
350
+ function variableRules(ast, errors) {
351
+ // V5: input names unique (header-level, no steps needed) // @a: anc-rule-v5
352
+ if (ast.header.inputs) {
353
+ const seen = new Set();
354
+ for (const v of ast.header.inputs) {
355
+ if (seen.has(v.name)) {
356
+ errors.push(ve('V5', 'error', `Duplicate input variable: "${v.name}"`));
357
+ }
358
+ seen.add(v.name);
359
+ }
360
+ }
361
+ // V7: input variable types must be valid (ValueTypeString or declared TypeDecl) // @a: anc-rule-v7
362
+ if (ast.header.inputs) {
363
+ const declaredTypes = new Set();
364
+ if (ast.header.types) {
365
+ for (const t of ast.header.types)
366
+ declaredTypes.add(t.name);
367
+ }
368
+ for (const v of ast.header.inputs) {
369
+ if (!isValidTypeWithDecls(v.type, declaredTypes)) {
370
+ errors.push(ve('V7', 'error', `Inputs 段变量 "${v.name}" 的类型 "${v.type}" 无效(须为 ValueTypeString 或已声明 TypeDecl)`));
371
+ }
372
+ }
373
+ }
374
+ if (!ast.steps)
375
+ return;
376
+ const rootScope = { declared: new Map() };
377
+ if (ast.header.inputs) {
378
+ for (const v of ast.header.inputs)
379
+ rootScope.declared.set(v.name, v.type);
380
+ }
381
+ const declaredTypes = new Set();
382
+ const typeDecls = new Map(); // C8 dotted 下钻用(带 fields)
383
+ if (ast.header.types) {
384
+ for (const t of ast.header.types) {
385
+ declaredTypes.add(t.name);
386
+ typeDecls.set(t.name, t);
387
+ }
388
+ }
389
+ walkVariableScope(ast.steps, rootScope, errors, declaredTypes, typeDecls);
390
+ }
391
+ function walkVariableScope(steps, parentScope, errors, declaredTypes, typeDecls) {
392
+ const scope = { declared: new Map(parentScope.declared), parent: parentScope };
393
+ for (const step of steps) {
394
+ const loc = step.source_location?.line_start;
395
+ // V1: input bindings reference existing variables // @a: anc-rule-v1
396
+ if (step.inputs) {
397
+ for (const binding of step.inputs) {
398
+ if (!isVarVisible(binding.source, scope)) {
399
+ errors.push(ve('V1', 'error', `Step "${step.step_id}" references undefined variable "${binding.source}"`, step.step_id, loc));
400
+ }
401
+ }
402
+ }
403
+ // V4: output type validity // @a: anc-rule-v4
404
+ if (step.outputs) {
405
+ for (const out of step.outputs) {
406
+ if (!isValidTypeWithDecls(out.type, declaredTypes)) {
407
+ errors.push(ve('V4', 'error', `Step "${step.step_id}" output "${out.name}" has invalid type: "${out.type}"`, step.step_id, loc));
408
+ }
409
+ }
410
+ }
411
+ // Register this step's outputs into scope (for subsequent sibling visibility)
412
+ if (step.outputs) {
413
+ for (const out of step.outputs) {
414
+ // V2: no duplicate output names in same scope // @a: anc-rule-v2
415
+ if (scope.declared.has(out.name) && !parentScope.declared.has(out.name)) {
416
+ errors.push(ve('V2', 'error', `Duplicate output variable "${out.name}" in same scope (step "${step.step_id}")`, step.step_id, loc));
417
+ }
418
+ // V3: no shadowing ancestor scope // @a: anc-rule-v3
419
+ // 例外(放行):① 更新模式——step 同时 ← out.name(读入即输出)是"在祖先变量基础上
420
+ // 更新"(典型为 loop 内更新累加器);② 显式初值/重置——step `+ → X = 初值`(带 default)
421
+ // 是对已有变量的显式重置(Python X=None 语义),非新建遮蔽。见
422
+ // [[HopSpec V3核心规范#^anc-exec-loop-var-scope]] / exec-engine ^anc-exec-output-init
423
+ // ① 更新模式(← X 且 → X,共享 isUpdateModeOutput 判据)② `= 初值` 显式重置(out.default)。// @a: anc-exec-output-init
424
+ const isUpdate = isUpdateModeOutput(step, out.name) || out.default !== undefined;
425
+ if (!isUpdate && isVarInAncestorScope(out.name, parentScope)) {
426
+ errors.push(ve('V3', 'error', `Output "${out.name}" in step "${step.step_id}" shadows ancestor scope variable`, step.step_id, loc));
427
+ }
428
+ scope.declared.set(out.name, out.type);
429
+ }
430
+ }
431
+ // V8: for-each parallel 的 listVar 声明类型必须是列表 [T](error)——非列表会让引擎把整个值
432
+ // 当单元素、只展开 1 个 child。见 design ^anc-rule-v8 / exec-engine ^anc-exec-parallel-foreach。// @a: anc-rule-v8
433
+ if (step.step_type === 'parallel' && step.forEach) {
434
+ const listVar = step.forEach.listVar;
435
+ const declType = scope.declared.get(listVar); // 此刻 scope 已含前序 + 本步 outputs
436
+ if (declType !== undefined && !/^\[.+\]$/.test(declType.trim())) {
437
+ errors.push(ve('V8', 'error', `for-each parallel "${step.step_id}" 的 listVar "${listVar}" 声明类型为 "${declType}",必须是列表 [T](否则引擎把整值当单元素、只展开 1 个 child)`, step.step_id, loc));
438
+ }
439
+ }
440
+ // C8: branch case 条件引用的变量路径必须合法(error)。见 design ^anc-rule-c8。// @a: anc-rule-c8
441
+ if (step.step_type === 'branch') {
442
+ for (const caseStep of getChildren(step)) {
443
+ const cond = caseStep.condition;
444
+ checkCaseCondition(cond, caseStep, scope, typeDecls, errors);
445
+ }
446
+ }
447
+ // call 步骤通过 output_mapping 产出父变量(call 的 outputs 为 undefined,+→ 解析为
448
+ // output_mapping,见 parser)。其 to(父变量)是 call 的实际产出,注册进 scope 供后续
449
+ // 步骤 ← 引用——否则下游引用 call 输出会被 V1 误判未定义。// @a: anc-rule-v1, anc-step-call
450
+ if (step.step_type === 'call') {
451
+ for (const om of step.output_mapping ?? []) {
452
+ if (om.to)
453
+ scope.declared.set(om.to, ''); // call 输出类型未在 mapping 声明,记空
454
+ }
455
+ }
456
+ // act/commit body 校验(B 系列规则):仅当 step 有 body。// @a: anc-rule-b-all
457
+ if ((step.step_type === 'act' || step.step_type === 'commit') && step.body) {
458
+ const visible = new Set(scope.declared.keys()); // body 校验只需变量名集合
459
+ for (const b of step.inputs ?? [])
460
+ visible.add(b.name); // body 用 ← 输入的 name 引用
461
+ checkActBodyScope(step.body, visible, step, errors);
462
+ }
463
+ // Recurse into container children with new child scope
464
+ if (CONTAINER_STEP_TYPES.has(step.step_type)) {
465
+ const children = getChildren(step);
466
+ if (children.length > 0) {
467
+ if (SCOPE_CREATING_CONTAINERS.has(step.step_type) && step.outputs) {
468
+ const childParent = { declared: new Map(scope.declared), parent: parentScope };
469
+ // subtask/parallel/branch/case 输出对子步骤不可见(子步骤产出后才聚合)→ 从子作用域删除。
470
+ // loop 例外:loop 节点输出是累加器,loop 内子步骤需以更新模式(← X + → X)读取并更新它,
471
+ // 故保留可见(见 [[../../HopSpec V3核心规范#^anc-exec-loop-var-scope]])
472
+ if (step.step_type !== 'loop') {
473
+ for (const out of step.outputs)
474
+ childParent.declared.delete(out.name);
475
+ }
476
+ // for-each parallel:itemVar 注册为 child 可见变量(在 output 删除之后,确保不被误删) // @a: anc-exec-parallel-foreach
477
+ // itemVar 元素类型 = listVar 声明类型 [T] 剥括号(供 case 条件 C8 dotted 下钻)。
478
+ if (step.step_type === 'parallel' && step.forEach) {
479
+ const fe = step.forEach;
480
+ const listType = scope.declared.get(fe.listVar) ?? '';
481
+ const elemType = /^\[(.+)\]$/.exec(listType.trim())?.[1] ?? ''; // [CheckPoint] → CheckPoint
482
+ childParent.declared.set(fe.itemVar, elemType);
483
+ }
484
+ // branch 特殊处理:互斥 case 各走独立 scope 副本递归(case 声明与 branch 同名输出不碰撞,
485
+ // "同名=同一个东西"原则——V2 不误杀)。branch 自己的输出已注册到外层 scope(L484),
486
+ // 后续步骤通过 branch 的输出名引用,不需要 case 输出 union。
487
+ if (step.step_type === 'branch') {
488
+ for (const child of children) {
489
+ const caseScopeCopy = { declared: new Map(childParent.declared), parent: childParent.parent };
490
+ walkVariableScope([child], caseScopeCopy, errors, declaredTypes, typeDecls);
491
+ }
492
+ }
493
+ else {
494
+ walkVariableScope(children, childParent, errors, declaredTypes, typeDecls);
495
+ }
496
+ }
497
+ else {
498
+ walkVariableScope(children, scope, errors, declaredTypes, typeDecls);
499
+ }
500
+ }
501
+ }
502
+ }
503
+ }
504
+ function isVarVisible(name, scope) {
505
+ return scope.declared.has(name);
506
+ }
507
+ // C8: 校验 branch case 条件引用的变量路径合法。// @a: anc-rule-c8
508
+ // cond 形态:`{var} == literal` / `var != literal` / 裸 `var`(truthy);var 可含 dotted path。
509
+ // 只校验 == / != 左侧或裸条件的变量路径,不校验右侧 literal。default(空/`default`)跳过。
510
+ function checkCaseCondition(cond, caseStep, scope, typeDecls, errors) {
511
+ if (cond === undefined)
512
+ return;
513
+ const t = cond.trim();
514
+ if (t === '' || t === 'default')
515
+ return; // default case
516
+ // 取比较左侧(或裸条件)作变量表达式:切在首个 == / != 前
517
+ const neq = t.indexOf('!='), eq = t.indexOf('==');
518
+ const cut = [neq, eq].filter(i => i >= 0).sort((a, b) => a - b)[0];
519
+ let varExpr = (cut === undefined ? t : t.slice(0, cut)).trim();
520
+ if (varExpr.startsWith('{') && varExpr.endsWith('}'))
521
+ varExpr = varExpr.slice(1, -1).trim();
522
+ if (varExpr === '')
523
+ return;
524
+ const loc = caseStep.source_location?.line_start;
525
+ // 归一化 a[0].b → a.0.b,逐段
526
+ const segs = varExpr.replace(/\[(\w+)\]/g, '.$1').split('.').filter(s => s !== '');
527
+ const root = segs[0];
528
+ // ① 根变量必须可见
529
+ if (!scope.declared.has(root)) {
530
+ errors.push(ve('C8', 'error', `Case "${caseStep.step_id}" 条件引用未定义变量 "${root}"(条件: ${t})`, caseStep.step_id, loc));
531
+ return;
532
+ }
533
+ // ② 逐段顺类型下钻(根类型未知 '' → 无法下钻,止于根、放行——避免误报无类型信息的变量)
534
+ let curType = (scope.declared.get(root) ?? '').trim();
535
+ for (let i = 1; i < segs.length; i++) {
536
+ if (curType === '')
537
+ return; // 类型信息缺失,不再下钻(不误报)
538
+ const arrM = /^\[(.+)\]$/.exec(curType);
539
+ if (arrM) {
540
+ // 数组段:要求 segs[i] 是整数下标,下钻到元素类型
541
+ if (!/^\d+$/.test(segs[i])) {
542
+ errors.push(ve('C8', 'error', `Case "${caseStep.step_id}" 条件路径 "${varExpr}" 段 "${segs[i]}" 对列表类型 "${curType}" 非法(列表须数字下标)`, caseStep.step_id, loc));
543
+ return;
544
+ }
545
+ curType = arrM[1].trim();
546
+ continue;
547
+ }
548
+ const decl = typeDecls.get(curType);
549
+ if (!decl)
550
+ return; // 非 TypeDecl(如 line/text 标量)无字段可下钻——类型信息不足,放行不误报
551
+ if (!(segs[i] in decl.fields)) {
552
+ errors.push(ve('C8', 'error', `Case "${caseStep.step_id}" 条件路径 "${varExpr}" 中 "${segs[i]}" 不是类型 "${curType}" 的字段(合法字段: ${Object.keys(decl.fields).join(', ')})`, caseStep.step_id, loc));
553
+ return;
554
+ }
555
+ curType = decl.fields[segs[i]].trim();
556
+ }
557
+ }
558
+ // ===== B 系列:act/commit body 校验(hop_python)===== // @a: anc-rule-b-all
559
+ // visible:进入 body 时可见的变量集(步骤 ← 输入名 ∪ 当前 scope 已声明)。
560
+ // 顺序遍历语句:赋值 target 加入 visible;if 分支各拷贝 visible(分支局部不泄漏)。
561
+ function checkActBodyScope(body, visible, step, errors) {
562
+ const loc = step.source_location?.line_start;
563
+ const writtenOutputs = new Set();
564
+ walkActStatements(body.statements, visible, step, errors, writtenOutputs);
565
+ // B5:声明的 +→ 输出,body 应有对应赋值;缺则 warn // @a: anc-rule-b5
566
+ for (const out of step.outputs ?? []) {
567
+ if (!writtenOutputs.has(out.name)) {
568
+ errors.push(ve('B5', 'warn', `act body 声明输出 "${out.name}" 但 body 中无对应赋值(step "${step.step_id}")`, step.step_id, loc));
569
+ }
570
+ }
571
+ }
572
+ function walkActStatements(stmts, visible, step, errors, writtenOutputs) {
573
+ const loc = step.source_location?.line_start;
574
+ const declaredOutputs = new Set((step.outputs ?? []).map(o => o.name));
575
+ for (const stmt of stmts) {
576
+ if (stmt.type === 'assign') {
577
+ checkActExpr(stmt.value, visible, step, errors);
578
+ const target = stmt.target;
579
+ visible.add(target); // 赋值后该名可见
580
+ if (declaredOutputs.has(target))
581
+ writtenOutputs.add(target);
582
+ }
583
+ else if (stmt.type === 'call') {
584
+ checkActExpr(stmt.call, visible, step, errors);
585
+ }
586
+ else if (stmt.type === 'if') {
587
+ const ifs = stmt;
588
+ checkActExpr(ifs.condition, visible, step, errors);
589
+ // 分支各拷贝可见集校验;分支内新赋值的变量默认不泄漏到分支外。
590
+ // 例外——汇合赋值:若某变量在 then 与 else 两分支都被赋值,分支后必然有值,提升到外层可见
591
+ // (Python/TS 同样行为;否则 if/else 双分支赋值同名变量、分支后引用会被误判未定义)。
592
+ const thenVis = new Set(visible);
593
+ walkActStatements(ifs.then_body, thenVis, step, errors, writtenOutputs);
594
+ if (ifs.else_body) {
595
+ const elseVis = new Set(visible);
596
+ walkActStatements(ifs.else_body, elseVis, step, errors, writtenOutputs);
597
+ for (const v of thenVis) {
598
+ if (!visible.has(v) && elseVis.has(v))
599
+ visible.add(v); // then ∩ else 新增 → 提升
600
+ }
601
+ }
602
+ }
603
+ else {
604
+ // B1 兜底:理论上 parser 已拒循环,AST 不应出现其他语句类型 // @a: anc-rule-b1
605
+ errors.push(ve('B1', 'error', `act body 含非法语句类型(step "${step.step_id}")`, step.step_id, loc));
606
+ }
607
+ }
608
+ }
609
+ function checkActExpr(expr, visible, step, errors) {
610
+ const loc = step.source_location?.line_start;
611
+ switch (expr.type) {
612
+ case 'literal': return;
613
+ case 'var':
614
+ // B4:变量引用须可见 // @a: anc-rule-b4
615
+ if (!visible.has(expr.name)) {
616
+ errors.push(ve('B4', 'error', `act body 引用未定义变量 "${expr.name}"(step "${step.step_id}")`, step.step_id, loc));
617
+ }
618
+ return;
619
+ case 'field':
620
+ checkActExpr(expr.object, visible, step, errors);
621
+ return;
622
+ case 'binary':
623
+ checkActExpr(expr.left, visible, step, errors);
624
+ checkActExpr(expr.right, visible, step, errors);
625
+ return;
626
+ case 'unary':
627
+ checkActExpr(expr.operand, visible, step, errors);
628
+ return;
629
+ case 'call': {
630
+ // B2:callee 须 ∈ 内置白名单 ∪ ToolProvider 工具(运行期)。内置则校验 arity,非内置 warn。 // @a: anc-rule-b2
631
+ const call = expr;
632
+ if (ACT_BUILTIN_NAMES.has(call.callee)) {
633
+ const def = ACT_BUILTINS.get(call.callee);
634
+ const n = call.args.length;
635
+ if (n < def.arity.min || n > def.arity.max) {
636
+ const range = def.arity.max === Infinity ? `≥${def.arity.min}` : `${def.arity.min}-${def.arity.max}`;
637
+ errors.push(ve('B2', 'error', `act body 内置函数 "${call.callee}" 参数个数 ${n} 不符(期望 ${range})(step "${step.step_id}")`, step.step_id, loc));
638
+ }
639
+ }
640
+ else {
641
+ errors.push(ve('B2', 'warn', `act body 调用 "${call.callee}" 非内置函数——视为工具,运行期对照 ToolProvider 清单确认(step "${step.step_id}")`, step.step_id, loc));
642
+ }
643
+ for (const arg of call.args)
644
+ checkActExpr(arg.value, visible, step, errors);
645
+ return;
646
+ }
647
+ }
648
+ }
649
+ function isVarInAncestorScope(name, scope) {
650
+ let s = scope;
651
+ while (s) {
652
+ if (s.declared.has(name))
653
+ return true;
654
+ s = s.parent;
655
+ }
656
+ return false;
657
+ }
658
+ const BUILTIN_TYPES = new Set([
659
+ 'text', 'bool', 'line', 'number', 'int', 'float', 'markdown', 'yaml', 'prompt', 'HopSpec', '[line]',
660
+ ]);
661
+ /** Strict type check: valid only if builtin, enum, list, tuple, or explicitly declared TypeDecl */
662
+ function isValidTypeWithDecls(type, declaredTypes) {
663
+ if (BUILTIN_TYPES.has(type))
664
+ return true;
665
+ if (/^enum\(.+\)$/.test(type))
666
+ return true;
667
+ if (/^\[.+\]$/.test(type))
668
+ return true; // [TypeName] list syntax
669
+ if (/^\(.+\)$/.test(type))
670
+ return true; // (Type, Type) tuple syntax
671
+ if (declaredTypes.has(type))
672
+ return true;
673
+ return false;
674
+ }
675
+ // ===== P1-P7: Special step rules =====
676
+ function specialStepRules(ast, errors) {
677
+ if (!ast.steps)
678
+ return;
679
+ const allFlat = flattenSteps(ast.steps);
680
+ for (const step of allFlat) {
681
+ const loc = step.source_location?.line_start;
682
+ // P1: call must have callee_spec_id // @a: anc-rule-p1
683
+ if (step.step_type === 'call') {
684
+ const cs = step;
685
+ if (!cs.callee_spec_id?.trim()) {
686
+ errors.push(ve('P1', 'error', `Call step "${step.step_id}" is missing callee_spec_id`, step.step_id, loc));
687
+ }
688
+ }
689
+ // P2【已废除】:旧 commit 语义(commit 自带审批/输出 approval)残留。新概念下授权前置到
690
+ // confirm,commit 不自带审批,不再要求声明 approval。规则不再生效,编号位保留不重编。
691
+ // 见 design/spec-parser.md ^anc-rule-p2 废除说明、概念 ^anc-step-commit。
692
+ // P3: confirm response_options must have ≥1 if specified // @a: anc-rule-p3
693
+ if (step.step_type === 'confirm') {
694
+ const cs = step;
695
+ if (cs.response_options && cs.response_options.length === 0) {
696
+ errors.push(ve('P3', 'warn', `Confirm step "${step.step_id}" has empty response_options`, step.step_id, loc));
697
+ }
698
+ // P12: confirm 纯审批闸门——+→ 仅允许 bool(数据收集用 ask)// @a: anc-rule-p12, anc-step-confirm
699
+ for (const out of step.outputs ?? []) {
700
+ if (out.type !== 'bool') {
701
+ errors.push(ve('P12', 'error', `Confirm step "${step.step_id}" output "${out.name}" must be bool (confirm 是纯审批闸门,数据收集用 ask 步骤)`, step.step_id, loc));
702
+ }
703
+ }
704
+ }
705
+ // P13: ask 步骤须声明 +→ 输出(数据收集的目标变量)// @a: anc-rule-p13, anc-step-ask
706
+ if (step.step_type === 'ask') {
707
+ if (!step.outputs || step.outputs.length === 0) {
708
+ errors.push(ve('P13', 'error', `Ask step "${step.step_id}" must declare at least one +→ output (要 caller 提供的数据)`, step.step_id, loc));
709
+ }
710
+ // P14: ask present_inputs 必须是 ← inputs 的子集 // @a: anc-rule-p14, anc-step-ask
711
+ const askStep = step;
712
+ if (askStep.present_inputs && askStep.present_inputs.length > 0) {
713
+ const inputNames = new Set((askStep.inputs ?? []).map(b => b.name));
714
+ for (const name of askStep.present_inputs) {
715
+ if (!inputNames.has(name)) {
716
+ errors.push(ve('P14', 'error', `Ask step "${step.step_id}" present_inputs="${name}" not declared in ← inputs (present_inputs 必须是 ← inputs 子集,driver 无值可呈现)`, step.step_id, loc));
717
+ }
718
+ }
719
+ }
720
+ }
721
+ // P4: exit exit_outputs keys must exactly match header.outputs // @a: anc-rule-p4
722
+ if (step.step_type === 'exit') {
723
+ const es = step;
724
+ if (es.exit_outputs && ast.header.outputs) {
725
+ const headerOutputNames = new Set(ast.header.outputs.map(o => o.name));
726
+ const exitKeys = new Set(Object.keys(es.exit_outputs));
727
+ for (const key of exitKeys) {
728
+ if (!headerOutputNames.has(key)) {
729
+ errors.push(ve('P4', 'error', `Exit step "${step.step_id}" output "${key}" is not in header outputs`, step.step_id, loc));
730
+ }
731
+ }
732
+ for (const name of headerOutputNames) {
733
+ if (!exitKeys.has(name)) {
734
+ errors.push(ve('P4', 'error', `Exit step "${step.step_id}" is missing required output "${name}"`, step.step_id, loc));
735
+ }
736
+ }
737
+ }
738
+ }
739
+ // P5: commit must have non-empty irreversible_action // @a: anc-rule-p5
740
+ if (step.step_type === 'commit') {
741
+ const cs = step;
742
+ if (!cs.irreversible_action?.trim()) {
743
+ errors.push(ve('P5', 'error', `Commit step "${step.step_id}" is missing irreversible_action description`, step.step_id, loc));
744
+ }
745
+ }
746
+ // P6: subtask.retry ≥ 1, loop.max_iterations ≥ 1 // @a: anc-rule-p6
747
+ if (step.step_type === 'subtask') {
748
+ const st = step;
749
+ if (st.retry !== undefined && st.retry < 1) {
750
+ errors.push(ve('P6', 'warn', `Subtask "${step.step_id}" retry=${st.retry} should be ≥ 1`, step.step_id, loc));
751
+ }
752
+ }
753
+ if (step.step_type === 'loop') {
754
+ const lt = step;
755
+ if (lt.max_iterations !== undefined && lt.max_iterations < 1) {
756
+ errors.push(ve('P6', 'warn', `Loop "${step.step_id}" max_iterations=${lt.max_iterations} should be ≥ 1`, step.step_id, loc));
757
+ }
758
+ }
759
+ // P7: ResponseOption values and labels non-empty // @a: anc-rule-p7
760
+ if (step.step_type === 'confirm') {
761
+ const cs = step;
762
+ if (cs.response_options) {
763
+ for (const opt of cs.response_options) {
764
+ if (!opt.value?.trim() || !opt.label?.trim()) {
765
+ errors.push(ve('P7', 'warn', `Confirm step "${step.step_id}" has response_option with empty value or label`, step.step_id, loc));
766
+ }
767
+ }
768
+ }
769
+ }
770
+ // P9: retry and adaptive only allowed on subtask and case steps // @a: anc-rule-p9
771
+ // case 就是 branch 下的 subtask,继承 retry/adaptive 语义(见 anc-step-case)
772
+ if (step.step_type !== 'subtask' && step.step_type !== 'case') {
773
+ const anyStep = step;
774
+ if (anyStep.retry !== undefined) {
775
+ errors.push(ve('P9', 'error', `retry 属性仅适用于 subtask/case 步骤,当前步骤 "${step.step_id}" 类型为 ${step.step_type}`, step.step_id, loc));
776
+ }
777
+ if (anyStep.adaptive !== undefined) {
778
+ errors.push(ve('P9', 'error', `adaptive 属性仅适用于 subtask/case 步骤,当前步骤 "${step.step_id}" 类型为 ${step.step_type}`, step.step_id, loc));
779
+ }
780
+ }
781
+ // @model annotation only valid on executable steps
782
+ if (step.model_override && !EXECUTABLE_STEP_TYPES.has(step.step_type)) {
783
+ errors.push(ve('P9', 'warn', `@model annotation on non-executable step "${step.step_id}" (type: ${step.step_type}) has no effect`, step.step_id, loc));
784
+ }
785
+ // P8: commit in retry/adaptive subtask requires confirm guard // @a: anc-rule-p8
786
+ if (step.step_type === 'subtask') {
787
+ const st = step;
788
+ if ((st.retry !== undefined && st.retry > 0) || st.adaptive === true) {
789
+ const descendants = flattenSteps(st.children);
790
+ const hasCommit = descendants.some(d => d.step_type === 'commit');
791
+ const hasConfirm = descendants.some(d => d.step_type === 'confirm');
792
+ if (hasCommit && !hasConfirm) {
793
+ errors.push(ve('P8', 'error', `Subtask "${step.step_id}" has retry/adaptive but contains commit without confirm guard`, step.step_id, loc));
794
+ }
795
+ const hasCall = descendants.some(d => d.step_type === 'call');
796
+ if (hasCall && !hasConfirm) {
797
+ errors.push(ve('P8', 'warn', `Subtask "${step.step_id}" has retry/adaptive and contains call — cannot verify callee has no unguarded commit (v2+ runtime check)`, step.step_id, loc));
798
+ }
799
+ }
800
+ }
801
+ }
802
+ // V6: call param_mapping + output_mapping validity (placed here with special step rules) // @a: anc-rule-v6
803
+ for (const step of allFlat) {
804
+ if (step.step_type !== 'call')
805
+ continue;
806
+ const cs = step;
807
+ const loc = step.source_location?.line_start;
808
+ for (const pm of cs.param_mapping ?? []) {
809
+ if (!pm.from?.trim()) {
810
+ errors.push(ve('V6', 'error', `Call step "${step.step_id}" param_mapping has empty 'from'`, step.step_id, loc));
811
+ }
812
+ if (!pm.to?.trim()) {
813
+ errors.push(ve('V6', 'error', `Call step "${step.step_id}" param_mapping has empty 'to'`, step.step_id, loc));
814
+ }
815
+ }
816
+ for (const om of cs.output_mapping ?? []) {
817
+ if (!om.from?.trim()) {
818
+ errors.push(ve('V6', 'error', `Call step "${step.step_id}" output_mapping has empty 'from'`, step.step_id, loc));
819
+ }
820
+ if (!om.to?.trim()) {
821
+ errors.push(ve('V6', 'error', `Call step "${step.step_id}" output_mapping has empty 'to'`, step.step_id, loc));
822
+ }
823
+ }
824
+ }
825
+ // P10: declared output should have a producer (static reachability) // @a: anc-rule-p10
826
+ // 递归遍历子树,返回该子树产出的变量名集合(+→ 输出 + exit 交付)。
827
+ // 顺带就地检查容器:scope-creating 容器声明的 +→ 须由其子树产出,否则 warn。
828
+ function collectProduced(node) {
829
+ const produced = new Set();
830
+ if (node.step_type === 'exit') {
831
+ for (const k of Object.keys(node.exit_outputs ?? {}))
832
+ produced.add(k);
833
+ }
834
+ if (hasChildren(node)) {
835
+ const childProduced = new Set();
836
+ for (const child of getChildren(node)) {
837
+ for (const name of collectProduced(child))
838
+ childProduced.add(name);
839
+ }
840
+ // 容器自身的 +→ 须由子树产出(否则可能漏写产出步骤)
841
+ if (SCOPE_CREATING_CONTAINERS.has(node.step_type)) {
842
+ // for-each parallel 的 itemVar 是引擎运行时注入的元素绑定,非子树产出 → 豁免 // @a: anc-exec-parallel-foreach
843
+ const forEachItemVar = node.step_type === 'parallel' ? node.forEach?.itemVar : undefined;
844
+ for (const out of node.outputs ?? []) {
845
+ if (out.name === forEachItemVar)
846
+ continue;
847
+ if (!childProduced.has(out.name)) {
848
+ errors.push(ve('P10', 'warn', `Container "${node.step_id}" declares output "${out.name}" but no descendant step produces it`, node.step_id, node.source_location?.line_start));
849
+ }
850
+ }
851
+ }
852
+ for (const name of childProduced)
853
+ produced.add(name);
854
+ }
855
+ // 叶子/自身的 +→ 也是产出
856
+ for (const o of node.outputs ?? [])
857
+ produced.add(o.name);
858
+ return produced;
859
+ }
860
+ const allProduced = new Set();
861
+ for (const top of ast.steps) {
862
+ for (const name of collectProduced(top))
863
+ allProduced.add(name);
864
+ }
865
+ // Spec Outputs:声明的交付物若无任何步骤产出 → warn(可能漏写产出步骤,也可能是合法 impl)
866
+ for (const out of ast.header.outputs ?? []) {
867
+ if (!allProduced.has(out.name)) {
868
+ errors.push(ve('P10', 'warn', `Declared output "${out.name}" has no producing step (no '+→' or exit produces it)`, undefined, undefined));
869
+ }
870
+ }
871
+ }
872
+ const VALUE_TRUNC = 60;
873
+ function truncateValue(v) {
874
+ let s;
875
+ try {
876
+ s = typeof v === 'string' ? v : JSON.stringify(v);
877
+ }
878
+ catch {
879
+ s = String(v);
880
+ }
881
+ if (s === undefined)
882
+ s = String(v);
883
+ return s.length > VALUE_TRUNC ? s.slice(0, VALUE_TRUNC) + '…' : s;
884
+ }
885
+ /** 校验单个值是否匹配声明类型(v1 策略)。匹配返回 null,不匹配返回原因字符串。 */
886
+ function checkValue(type, value) {
887
+ // 宽松字符串类型:接受任意(含可序列化为字符串的)值,不强约束
888
+ if (['text', 'line', 'markdown', 'yaml', 'prompt', 'HopSpec'].includes(type)) {
889
+ return null;
890
+ }
891
+ if (type === 'bool') {
892
+ if (typeof value === 'boolean')
893
+ return null;
894
+ if (typeof value === 'string' && /^(true|false)$/i.test(value.trim()))
895
+ return null;
896
+ return '期望 bool(true/false),实际非布尔值';
897
+ }
898
+ if (type === 'number' || type === 'int' || type === 'float') {
899
+ if (typeof value === 'number' && !Number.isNaN(value))
900
+ return null;
901
+ if (typeof value === 'string' && value.trim() !== '' && !Number.isNaN(Number(value)))
902
+ return null;
903
+ return `期望 ${type}(可转为数字),实际无法转为数字`;
904
+ }
905
+ // enum(a,b,c):值须在列举内
906
+ const enumMatch = /^enum\((.+)\)$/.exec(type);
907
+ if (enumMatch) {
908
+ const allowed = enumMatch[1].split(',').map(s => s.trim());
909
+ if (allowed.includes(String(value).trim()))
910
+ return null;
911
+ return `期望 enum 之一 [${allowed.join(', ')}],实际不在列举内`;
912
+ }
913
+ // [Type] 列表:严格要求数组,字符串一律拒绝(列表语义=数组,字符串=类型谎报)。
914
+ // 曾"宽松接受非空字符串"是静默容错:谎报值(如 @file 指针字符串)漂过校验、污染下游,
915
+ // 到 for-each join 才 Array.isArray 失败、聚合全空、故障漂离源头两步。契约违反须当场炸——
916
+ // SCHEMA_MISMATCH 触发带反馈重试,driver 自然改出真数组。见 design ^anc-exec-output-schema-check。
917
+ if (/^\[.+\]$/.test(type)) {
918
+ if (Array.isArray(value))
919
+ return null;
920
+ return '期望列表 [Type],实际非数组';
921
+ }
922
+ // 自定义 TypeDecl / 元组 / 其他:v1 仅做存在性(非空),不递归类型匹配
923
+ if (value === null || value === undefined) {
924
+ return `期望 ${type},实际为空`;
925
+ }
926
+ return null;
927
+ }
928
+ /**
929
+ * 校验一组输出值是否匹配步骤的 +→ 声明(OutputDecl[])。
930
+ * 返回不匹配项列表(空数组 = 全部通过)。
931
+ * @a: anc-exec-output-schema-check
932
+ */
933
+ export function validateOutputValues(decls, outputs) {
934
+ if (!decls || decls.length === 0)
935
+ return [];
936
+ const mismatches = [];
937
+ for (const decl of decls) {
938
+ // 声明的字段缺失(未产出)
939
+ if (!(decl.name in outputs)) {
940
+ mismatches.push({
941
+ field: decl.name, declared_type: decl.type,
942
+ actual: '(缺失)', reason: '声明的输出未产出',
943
+ });
944
+ continue;
945
+ }
946
+ const reason = checkValue(decl.type, outputs[decl.name]);
947
+ if (reason) {
948
+ mismatches.push({
949
+ field: decl.name, declared_type: decl.type,
950
+ actual: truncateValue(outputs[decl.name]), reason,
951
+ });
952
+ }
953
+ }
954
+ return mismatches;
955
+ }
956
+ /** 把不匹配项格式化为 SCHEMA_MISMATCH 的 message(供算子级重试构造反馈)。 */
957
+ export function formatSchemaMismatch(mismatches) {
958
+ return 'SCHEMA_MISMATCH: ' + mismatches.map(m => `字段 "${m.field}"(声明 ${m.declared_type}):${m.reason}(实际:${m.actual})`).join(';');
959
+ }