skillscript-runtime 0.2.10 → 0.3.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 +54 -10
- package/dist/cli.js +13 -9
- package/dist/cli.js.map +1 -1
- package/dist/compile.d.ts +7 -0
- package/dist/compile.d.ts.map +1 -1
- package/dist/compile.js +13 -2
- package/dist/compile.js.map +1 -1
- package/dist/help-content.d.ts.map +1 -1
- package/dist/help-content.js +181 -4
- package/dist/help-content.js.map +1 -1
- package/dist/lint.d.ts +10 -0
- package/dist/lint.d.ts.map +1 -1
- package/dist/lint.js +359 -22
- package/dist/lint.js.map +1 -1
- package/dist/mcp-server.d.ts.map +1 -1
- package/dist/mcp-server.js +9 -2
- package/dist/mcp-server.js.map +1 -1
- package/dist/parser.d.ts +1 -1
- package/dist/parser.d.ts.map +1 -1
- package/dist/parser.js +76 -4
- package/dist/parser.js.map +1 -1
- package/dist/runtime.d.ts.map +1 -1
- package/dist/runtime.js +47 -2
- package/dist/runtime.js.map +1 -1
- package/dist/version.d.ts +2 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +30 -0
- package/dist/version.js.map +1 -0
- package/examples/hello.skill.provenance.json +1 -1
- package/package.json +1 -1
package/dist/lint.js
CHANGED
|
@@ -9,6 +9,7 @@ export async function lint(source, options) {
|
|
|
9
9
|
skillStore: options?.skillStore,
|
|
10
10
|
hasSkillStore: options?.skillStore !== undefined,
|
|
11
11
|
callSite: options?.callSite ?? "api",
|
|
12
|
+
enableUnsafeShell: options?.enableUnsafeShell,
|
|
12
13
|
};
|
|
13
14
|
const findings = [];
|
|
14
15
|
for (const rule of RULES) {
|
|
@@ -41,6 +42,7 @@ export function lintSync(source, options) {
|
|
|
41
42
|
skillStore: options?.skillStore,
|
|
42
43
|
hasSkillStore: options?.skillStore !== undefined,
|
|
43
44
|
callSite: options?.callSite ?? "api",
|
|
45
|
+
enableUnsafeShell: options?.enableUnsafeShell,
|
|
44
46
|
};
|
|
45
47
|
const findings = [];
|
|
46
48
|
for (const rule of RULES) {
|
|
@@ -364,20 +366,21 @@ const UNKNOWN_SKILL_REFERENCE = {
|
|
|
364
366
|
const findings = [];
|
|
365
367
|
const seen = new Set();
|
|
366
368
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
367
|
-
for (const
|
|
368
|
-
|
|
369
|
+
for (const ref of collectAmpRefsFromOps(target.ops)) {
|
|
370
|
+
const key = `${ref.via}:${ref.name}`;
|
|
371
|
+
if (seen.has(key))
|
|
369
372
|
continue;
|
|
370
|
-
seen.add(
|
|
373
|
+
seen.add(key);
|
|
371
374
|
try {
|
|
372
|
-
await ctx.skillStore.metadata(
|
|
375
|
+
await ctx.skillStore.metadata(ref.name);
|
|
373
376
|
}
|
|
374
377
|
catch {
|
|
375
378
|
findings.push({
|
|
376
379
|
rule: "unknown-skill-reference",
|
|
377
380
|
severity: "error",
|
|
378
|
-
message: `Skill '${targetName}' references skill '${
|
|
381
|
+
message: `Skill '${targetName}' references skill '${ref.name}' via \`${ref.via}\`, but the SkillStore has no skill by that name.`,
|
|
379
382
|
block: targetName,
|
|
380
|
-
extras: { referenced_skill:
|
|
383
|
+
extras: { referenced_skill: ref.name, via: ref.via },
|
|
381
384
|
});
|
|
382
385
|
}
|
|
383
386
|
}
|
|
@@ -385,6 +388,70 @@ const UNKNOWN_SKILL_REFERENCE = {
|
|
|
385
388
|
return findings;
|
|
386
389
|
},
|
|
387
390
|
};
|
|
391
|
+
// v0.2.12 Bug 26. Tier-2 warning when a `>` retrieval op carries kwargs
|
|
392
|
+
// outside the documented set. Cold author wrote `since=1h` (hallucinated
|
|
393
|
+
// time-window predicate) and the kwarg passed silently. The documented
|
|
394
|
+
// kwarg set is mode/query/limit/connector/fallback plus filter shapes
|
|
395
|
+
// the connector advertises via staticCapabilities (out of band here).
|
|
396
|
+
const KNOWN_RETRIEVAL_KWARGS = new Set(["mode", "query", "limit", "connector", "fallback"]);
|
|
397
|
+
const UNKNOWN_RETRIEVAL_ARG = {
|
|
398
|
+
id: "unknown-retrieval-arg",
|
|
399
|
+
severity: "warning",
|
|
400
|
+
description: "A `>` retrieval op carries a kwarg outside the documented set (mode/query/limit/connector/fallback).",
|
|
401
|
+
remediation: "Remove the kwarg, or check it against your MemoryStore connector's documentation — extras pass through to the connector but unrecognized ones often indicate hallucinated syntax.",
|
|
402
|
+
check: (ctx) => {
|
|
403
|
+
const findings = [];
|
|
404
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
405
|
+
walkOps(target.ops, (op) => {
|
|
406
|
+
if (op.kind !== ">")
|
|
407
|
+
return;
|
|
408
|
+
const extras = op.retrievalParams?.extra ?? {};
|
|
409
|
+
for (const k of Object.keys(extras)) {
|
|
410
|
+
if (KNOWN_RETRIEVAL_KWARGS.has(k))
|
|
411
|
+
continue;
|
|
412
|
+
findings.push({
|
|
413
|
+
rule: "unknown-retrieval-arg",
|
|
414
|
+
severity: "warning",
|
|
415
|
+
message: `\`>\` op in target '${targetName}' carries unknown kwarg '${k}'. Documented kwargs: ${Array.from(KNOWN_RETRIEVAL_KWARGS).join(", ")}. If '${k}' is a connector-specific filter, confirm it against the connector's docs.`,
|
|
416
|
+
block: targetName,
|
|
417
|
+
extras: { unknown_kwarg: k },
|
|
418
|
+
});
|
|
419
|
+
}
|
|
420
|
+
});
|
|
421
|
+
}
|
|
422
|
+
return findings;
|
|
423
|
+
},
|
|
424
|
+
};
|
|
425
|
+
// v0.2.12 Bug 17. `# Templates:` refs were not lint-validated despite
|
|
426
|
+
// `# OnError:` having compile-time validation (since v0.2.10). Tier-1
|
|
427
|
+
// because a missing template fails delivery at runtime.
|
|
428
|
+
const UNKNOWN_TEMPLATE_REFERENCE = {
|
|
429
|
+
id: "unknown-template-reference",
|
|
430
|
+
severity: "error",
|
|
431
|
+
description: "`# Templates: <name>` references a skill that's not present in the configured SkillStore.",
|
|
432
|
+
remediation: "Check the template name spelling, or store the missing template skill before referencing it. Templates resolve at delivery time; missing ones fail the agent delivery.",
|
|
433
|
+
check: async (ctx) => {
|
|
434
|
+
if (ctx.skillStore === undefined)
|
|
435
|
+
return [];
|
|
436
|
+
if (ctx.parsed.templates.length === 0)
|
|
437
|
+
return [];
|
|
438
|
+
const findings = [];
|
|
439
|
+
for (const name of ctx.parsed.templates) {
|
|
440
|
+
try {
|
|
441
|
+
await ctx.skillStore.metadata(name);
|
|
442
|
+
}
|
|
443
|
+
catch {
|
|
444
|
+
findings.push({
|
|
445
|
+
rule: "unknown-template-reference",
|
|
446
|
+
severity: "error",
|
|
447
|
+
message: `Skill references template '${name}' via \`# Templates:\`, but the SkillStore has no skill by that name.`,
|
|
448
|
+
extras: { referenced_skill: name },
|
|
449
|
+
});
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
return findings;
|
|
453
|
+
},
|
|
454
|
+
};
|
|
388
455
|
const DISABLED_SKILL_REFERENCE = {
|
|
389
456
|
id: "disabled-skill-reference",
|
|
390
457
|
severity: "error",
|
|
@@ -396,19 +463,19 @@ const DISABLED_SKILL_REFERENCE = {
|
|
|
396
463
|
const findings = [];
|
|
397
464
|
const checked = new Set();
|
|
398
465
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
399
|
-
for (const
|
|
400
|
-
if (checked.has(
|
|
466
|
+
for (const ref of collectAmpRefsFromOps(target.ops)) {
|
|
467
|
+
if (checked.has(ref.name))
|
|
401
468
|
continue;
|
|
402
|
-
checked.add(
|
|
469
|
+
checked.add(ref.name);
|
|
403
470
|
try {
|
|
404
|
-
const meta = await ctx.skillStore.metadata(
|
|
471
|
+
const meta = await ctx.skillStore.metadata(ref.name);
|
|
405
472
|
if (meta.status === "Disabled") {
|
|
406
473
|
findings.push({
|
|
407
474
|
rule: "disabled-skill-reference",
|
|
408
475
|
severity: "error",
|
|
409
|
-
message: `Skill '${targetName}' references '${
|
|
476
|
+
message: `Skill '${targetName}' references '${ref.name}' via \`${ref.via}\` which is disabled.`,
|
|
410
477
|
block: targetName,
|
|
411
|
-
extras: { referenced_skill:
|
|
478
|
+
extras: { referenced_skill: ref.name, via: ref.via, target_status: meta.status },
|
|
412
479
|
});
|
|
413
480
|
}
|
|
414
481
|
}
|
|
@@ -627,6 +694,19 @@ const UNSAFE_SHELL_AMBIGUOUS_SUBST = {
|
|
|
627
694
|
const trimmed = inner.trim();
|
|
628
695
|
if (/^[A-Za-z_]\w*$/.test(trimmed) && declared.has(trimmed))
|
|
629
696
|
continue;
|
|
697
|
+
// v0.2.11 Bug 4: dotted refs (EVENT.fired_at_unix, MEMORY.x,
|
|
698
|
+
// <target>.output) are runtime ambient/output families — same
|
|
699
|
+
// dotted-passthrough heuristic as `undeclared-var`. The
|
|
700
|
+
// unsafe-shell warning was telling cold authors to rewrite
|
|
701
|
+
// `$(EVENT.fired_at_unix)` as `$$(EVENT.fired_at_unix)` (bash
|
|
702
|
+
// command-sub), which would just try to execute "EVENT...".
|
|
703
|
+
if (trimmed.includes("."))
|
|
704
|
+
continue;
|
|
705
|
+
// v0.2.11 Bug 4: bare ambient refs (NOW, USER, ERROR_CONTEXT,
|
|
706
|
+
// SESSION_CONTEXT, TRIGGER_TYPE, TRIGGER_PAYLOAD) also pass —
|
|
707
|
+
// runtime injects them, author doesn't declare.
|
|
708
|
+
if (AMBIENT_VARS.includes(trimmed))
|
|
709
|
+
continue;
|
|
630
710
|
if (reported.has(inner))
|
|
631
711
|
continue;
|
|
632
712
|
reported.add(inner);
|
|
@@ -665,8 +745,55 @@ const UNSAFE_SHELL_OP = {
|
|
|
665
745
|
return findings;
|
|
666
746
|
},
|
|
667
747
|
};
|
|
668
|
-
/**
|
|
669
|
-
|
|
748
|
+
/**
|
|
749
|
+
* v0.2.11 Bug 5. Tier-1 escalation of the unsafe-shell signal — only
|
|
750
|
+
* fires when the caller passed `enableUnsafeShell: false` explicitly.
|
|
751
|
+
* Without that knowledge (the field is undefined), this rule stays
|
|
752
|
+
* silent and the tier-2 `unsafe-shell-op` warning is the only signal.
|
|
753
|
+
*
|
|
754
|
+
* When the runtime is known-disabled, every `@ unsafe` op is a guaranteed
|
|
755
|
+
* runtime refusal (`UnsafeShellDisabledError`). Surfacing that at compile
|
|
756
|
+
* time instead of letting the skill compile clean and then fail at first
|
|
757
|
+
* fire avoids the "compiles clean but won't run" gap Perry's harness
|
|
758
|
+
* surfaced (memory `b6176e02`).
|
|
759
|
+
*/
|
|
760
|
+
const UNSAFE_SHELL_DISABLED = {
|
|
761
|
+
id: "unsafe-shell-disabled",
|
|
762
|
+
severity: "error",
|
|
763
|
+
description: "Skill uses `@ unsafe`, but the runtime was configured with `enableUnsafeShell: false`. The op will refuse at first fire.",
|
|
764
|
+
remediation: "Either set `enableUnsafeShell: true` on the runtime (after reviewing the shell content), or refactor the `@ unsafe` op to use the structured `@ <binary> <args>` form (sandboxed, no bash).",
|
|
765
|
+
check: (ctx) => {
|
|
766
|
+
if (ctx.enableUnsafeShell !== false)
|
|
767
|
+
return [];
|
|
768
|
+
const findings = [];
|
|
769
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
770
|
+
walkOps(target.ops, (op) => {
|
|
771
|
+
if (op.kind === "@" && op.policy === "unsafe") {
|
|
772
|
+
findings.push({
|
|
773
|
+
rule: "unsafe-shell-disabled",
|
|
774
|
+
severity: "error",
|
|
775
|
+
message: `\`@ unsafe\` op in target '${targetName}' would refuse at runtime: \`enableUnsafeShell\` is false. Command: '${op.body.slice(0, 60)}${op.body.length > 60 ? "..." : ""}'`,
|
|
776
|
+
block: targetName,
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
});
|
|
780
|
+
}
|
|
781
|
+
return findings;
|
|
782
|
+
},
|
|
783
|
+
};
|
|
784
|
+
/**
|
|
785
|
+
* Tool-name patterns that strongly suggest mutating operations.
|
|
786
|
+
* Conservative — false positives are tolerable for warnings; false
|
|
787
|
+
* negatives are dangerous.
|
|
788
|
+
*
|
|
789
|
+
* v0.2.11 Bug 6: extended with archive_/prune_/deploy_/expire_/
|
|
790
|
+
* consolidate_/purge_/reset_/rotate_/move_/rename_/drop_/truncate_/
|
|
791
|
+
* upsert_/overwrite_/clear_/wipe_/finalize_ — Perry's wild-and-crazy
|
|
792
|
+
* harness surfaced a cluster of mutating tools that the original
|
|
793
|
+
* write/update/delete/etc. set didn't catch (`archive_old_threads`,
|
|
794
|
+
* `prune_threads`, `deploy_release`, `dangerous-cleanup`'s `expire_*`).
|
|
795
|
+
*/
|
|
796
|
+
const MUTATING_TOOL_PATTERN = /^(?:write_|update_|delete_|remove_|set_|create_|insert_|put_|patch_|destroy_|archive_|prune_|deploy_|expire_|consolidate_|purge_|reset_|rotate_|move_|rename_|drop_|truncate_|upsert_|overwrite_|clear_|wipe_|finalize_).*/;
|
|
670
797
|
const UNCONFIRMED_MUTATION = {
|
|
671
798
|
id: "unconfirmed-mutation",
|
|
672
799
|
severity: "warning",
|
|
@@ -756,19 +883,19 @@ const REFERENCE_TO_DISABLED_SKILL = {
|
|
|
756
883
|
const findings = [];
|
|
757
884
|
const checked = new Set();
|
|
758
885
|
for (const [targetName, target] of ctx.parsed.targets) {
|
|
759
|
-
for (const
|
|
760
|
-
if (checked.has(
|
|
886
|
+
for (const ref of collectAmpRefsFromOps(target.ops)) {
|
|
887
|
+
if (checked.has(ref.name))
|
|
761
888
|
continue;
|
|
762
|
-
checked.add(
|
|
889
|
+
checked.add(ref.name);
|
|
763
890
|
try {
|
|
764
|
-
const meta = await ctx.skillStore.metadata(
|
|
891
|
+
const meta = await ctx.skillStore.metadata(ref.name);
|
|
765
892
|
if (meta.status === "Disabled") {
|
|
766
893
|
findings.push({
|
|
767
894
|
rule: "reference-to-disabled-skill",
|
|
768
895
|
severity: "warning",
|
|
769
|
-
message: `Target '${targetName}' references '${
|
|
896
|
+
message: `Target '${targetName}' references '${ref.name}' via \`${ref.via}\` which is disabled.`,
|
|
770
897
|
block: targetName,
|
|
771
|
-
extras: { referenced_skill:
|
|
898
|
+
extras: { referenced_skill: ref.name, via: ref.via },
|
|
772
899
|
});
|
|
773
900
|
}
|
|
774
901
|
}
|
|
@@ -857,6 +984,193 @@ const UNUSED_AUGMENTING_HEADER = {
|
|
|
857
984
|
return findings;
|
|
858
985
|
},
|
|
859
986
|
};
|
|
987
|
+
function isAncestorScope(initPath, appendPath) {
|
|
988
|
+
if (initPath.length >= appendPath.length)
|
|
989
|
+
return false;
|
|
990
|
+
for (let i = 0; i < initPath.length; i++) {
|
|
991
|
+
if (initPath[i].id !== appendPath[i].id)
|
|
992
|
+
return false;
|
|
993
|
+
}
|
|
994
|
+
return true;
|
|
995
|
+
}
|
|
996
|
+
function isSameScope(a, b) {
|
|
997
|
+
if (a.length !== b.length)
|
|
998
|
+
return false;
|
|
999
|
+
for (let i = 0; i < a.length; i++)
|
|
1000
|
+
if (a[i].id !== b[i].id)
|
|
1001
|
+
return false;
|
|
1002
|
+
return true;
|
|
1003
|
+
}
|
|
1004
|
+
function pathContainsForeach(p) {
|
|
1005
|
+
for (const n of p)
|
|
1006
|
+
if (n.kind === "foreach")
|
|
1007
|
+
return true;
|
|
1008
|
+
return false;
|
|
1009
|
+
}
|
|
1010
|
+
function walkOpsWithScope(ops, visit, nextScopeId, path = []) {
|
|
1011
|
+
for (const op of ops) {
|
|
1012
|
+
visit(op, path);
|
|
1013
|
+
if (op.foreachBody !== undefined) {
|
|
1014
|
+
const child = { id: nextScopeId.n++, kind: "foreach" };
|
|
1015
|
+
walkOpsWithScope(op.foreachBody, visit, nextScopeId, [...path, child]);
|
|
1016
|
+
}
|
|
1017
|
+
if (op.ifBranches !== undefined) {
|
|
1018
|
+
for (const b of op.ifBranches) {
|
|
1019
|
+
const child = { id: nextScopeId.n++, kind: "if-branch" };
|
|
1020
|
+
walkOpsWithScope(b.body, visit, nextScopeId, [...path, child]);
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
if (op.ifElseBody !== undefined) {
|
|
1024
|
+
const child = { id: nextScopeId.n++, kind: "if-else" };
|
|
1025
|
+
walkOpsWithScope(op.ifElseBody, visit, nextScopeId, [...path, child]);
|
|
1026
|
+
}
|
|
1027
|
+
}
|
|
1028
|
+
}
|
|
1029
|
+
function isStaticListLiteral(raw) {
|
|
1030
|
+
const t = raw.trim();
|
|
1031
|
+
return t.startsWith("[") && t.endsWith("]");
|
|
1032
|
+
}
|
|
1033
|
+
const UNINITIALIZED_APPEND = {
|
|
1034
|
+
id: "uninitialized-append",
|
|
1035
|
+
severity: "error",
|
|
1036
|
+
description: "`$append VAR ...` where VAR isn't initialized in any enclosing scope (target body, # Vars: declaration, or shallower foreach/if block).",
|
|
1037
|
+
remediation: "Add `$set VAR = []` before the `$append` (in the target body, not inside the foreach), or declare in `# Vars: VAR=[]`. If you meant a different variable, check the spelling against your declarations.",
|
|
1038
|
+
check: (ctx) => {
|
|
1039
|
+
const declaredGlobal = new Set();
|
|
1040
|
+
for (const v of ctx.parsed.vars)
|
|
1041
|
+
declaredGlobal.add(v.name);
|
|
1042
|
+
for (const r of ctx.parsed.requires)
|
|
1043
|
+
declaredGlobal.add(r.target);
|
|
1044
|
+
const findings = [];
|
|
1045
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1046
|
+
const inits = new Map();
|
|
1047
|
+
const sc1 = { n: 1 };
|
|
1048
|
+
walkOpsWithScope(target.ops, (op, path) => {
|
|
1049
|
+
if (op.kind === "$set" && op.setName !== undefined) {
|
|
1050
|
+
const arr = inits.get(op.setName) ?? [];
|
|
1051
|
+
arr.push([...path]);
|
|
1052
|
+
inits.set(op.setName, arr);
|
|
1053
|
+
}
|
|
1054
|
+
}, sc1);
|
|
1055
|
+
const sc2 = { n: 1 };
|
|
1056
|
+
walkOpsWithScope(target.ops, (op, path) => {
|
|
1057
|
+
if (op.kind !== "$append" || op.setName === undefined)
|
|
1058
|
+
return;
|
|
1059
|
+
const varName = op.setName;
|
|
1060
|
+
if (declaredGlobal.has(varName))
|
|
1061
|
+
return;
|
|
1062
|
+
const initPaths = inits.get(varName) ?? [];
|
|
1063
|
+
const hasAncestor = initPaths.some((ip) => isAncestorScope(ip, path));
|
|
1064
|
+
const hasSame = initPaths.some((ip) => isSameScope(ip, path));
|
|
1065
|
+
// Same-scope counts as "visible" for resolution purposes — the
|
|
1066
|
+
// init runs before the append in the same block (foreach iteration,
|
|
1067
|
+
// straight target body, etc.). Whether it's the RIGHT shape for an
|
|
1068
|
+
// accumulator is `foreach-local-accumulator-target`'s job.
|
|
1069
|
+
const isVisible = hasAncestor || hasSame;
|
|
1070
|
+
const hasOther = initPaths.some((ip) => !isAncestorScope(ip, path) && !isSameScope(ip, path));
|
|
1071
|
+
if (initPaths.length === 0) {
|
|
1072
|
+
findings.push({
|
|
1073
|
+
rule: "uninitialized-append",
|
|
1074
|
+
severity: "error",
|
|
1075
|
+
message: `\`$append ${varName} ...\` in target '${targetName}': ${varName} is not initialized. Add \`$set ${varName} = []\` before the \`$append\` (or declare in \`# Vars: ${varName}=[]\`). If you meant a different variable, check the spelling against your declarations.`,
|
|
1076
|
+
block: targetName,
|
|
1077
|
+
extras: { var_name: varName },
|
|
1078
|
+
});
|
|
1079
|
+
}
|
|
1080
|
+
else if (!isVisible && hasOther) {
|
|
1081
|
+
// init exists in a sibling/inner scope, not visible at the append site.
|
|
1082
|
+
findings.push({
|
|
1083
|
+
rule: "uninitialized-append",
|
|
1084
|
+
severity: "error",
|
|
1085
|
+
message: `\`$append ${varName} ...\` in target '${targetName}': ${varName}'s \`$set\` initialization is in a sibling or inner block, not visible at this append site. Move the init to the target body (or a common enclosing scope) before the \`$append\`.`,
|
|
1086
|
+
block: targetName,
|
|
1087
|
+
extras: { var_name: varName },
|
|
1088
|
+
});
|
|
1089
|
+
}
|
|
1090
|
+
}, sc2);
|
|
1091
|
+
}
|
|
1092
|
+
return findings;
|
|
1093
|
+
},
|
|
1094
|
+
};
|
|
1095
|
+
const FOREACH_LOCAL_ACCUMULATOR_TARGET = {
|
|
1096
|
+
id: "foreach-local-accumulator-target",
|
|
1097
|
+
severity: "error",
|
|
1098
|
+
description: "`$append VAR ...` where VAR's `$set VAR = []` initialization is in the SAME scope (typically same foreach body). Each iteration resets VAR; the accumulator silently loses all but the last iteration's append.",
|
|
1099
|
+
remediation: "Move `$set VAR = []` outside the foreach (to the target body), so the append mutates a single outer-scope list that persists across iterations.",
|
|
1100
|
+
check: (ctx) => {
|
|
1101
|
+
const findings = [];
|
|
1102
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1103
|
+
const inits = new Map();
|
|
1104
|
+
const sc1 = { n: 1 };
|
|
1105
|
+
walkOpsWithScope(target.ops, (op, path) => {
|
|
1106
|
+
if (op.kind === "$set" && op.setName !== undefined) {
|
|
1107
|
+
const arr = inits.get(op.setName) ?? [];
|
|
1108
|
+
arr.push([...path]);
|
|
1109
|
+
inits.set(op.setName, arr);
|
|
1110
|
+
}
|
|
1111
|
+
}, sc1);
|
|
1112
|
+
const sc2 = { n: 1 };
|
|
1113
|
+
walkOpsWithScope(target.ops, (op, path) => {
|
|
1114
|
+
if (op.kind !== "$append" || op.setName === undefined)
|
|
1115
|
+
return;
|
|
1116
|
+
const initPaths = inits.get(op.setName) ?? [];
|
|
1117
|
+
const hasAncestor = initPaths.some((ip) => isAncestorScope(ip, path));
|
|
1118
|
+
const hasSame = initPaths.some((ip) => isSameScope(ip, path));
|
|
1119
|
+
// Only fires when the SAME scope is inside a foreach. Same scope at
|
|
1120
|
+
// target-body level (both ops at depth 0) is fine — that's just
|
|
1121
|
+
// sequential init + append, no iteration to lose data across.
|
|
1122
|
+
if (!hasAncestor && hasSame && pathContainsForeach(path)) {
|
|
1123
|
+
findings.push({
|
|
1124
|
+
rule: "foreach-local-accumulator-target",
|
|
1125
|
+
severity: "error",
|
|
1126
|
+
message: `\`$append ${op.setName} ...\` in target '${targetName}': \`$set ${op.setName} = []\` is in the same scope as the append (typically the same foreach body). Each iteration resets ${op.setName}, silently losing all but the last iteration's data. Move the \`$set ${op.setName} = []\` to the target body, before the foreach.`,
|
|
1127
|
+
block: targetName,
|
|
1128
|
+
extras: { var_name: op.setName },
|
|
1129
|
+
});
|
|
1130
|
+
}
|
|
1131
|
+
}, sc2);
|
|
1132
|
+
}
|
|
1133
|
+
return findings;
|
|
1134
|
+
},
|
|
1135
|
+
};
|
|
1136
|
+
const APPEND_TO_NON_LIST = {
|
|
1137
|
+
id: "append-to-non-list",
|
|
1138
|
+
severity: "error",
|
|
1139
|
+
description: "`$append VAR ...` where VAR's static initialization is a non-list value. $append v0.3.0 is list-only.",
|
|
1140
|
+
remediation: "Initialize VAR with a list literal (`$set VAR = []` or `# Vars: VAR=[]`). String concat and map-shaped accumulation are out of scope for v0.3.0.",
|
|
1141
|
+
check: (ctx) => {
|
|
1142
|
+
const staticInits = new Map();
|
|
1143
|
+
for (const v of ctx.parsed.vars) {
|
|
1144
|
+
if (v.default !== undefined)
|
|
1145
|
+
staticInits.set(v.name, v.default);
|
|
1146
|
+
}
|
|
1147
|
+
const findings = [];
|
|
1148
|
+
for (const [targetName, target] of ctx.parsed.targets) {
|
|
1149
|
+
walkOps(target.ops, (op) => {
|
|
1150
|
+
if (op.kind === "$set" && op.setName !== undefined && op.setValue !== undefined && !/\$\(/.test(op.setValue)) {
|
|
1151
|
+
staticInits.set(op.setName, op.setValue);
|
|
1152
|
+
}
|
|
1153
|
+
});
|
|
1154
|
+
walkOps(target.ops, (op) => {
|
|
1155
|
+
if (op.kind !== "$append" || op.setName === undefined)
|
|
1156
|
+
return;
|
|
1157
|
+
const init = staticInits.get(op.setName);
|
|
1158
|
+
if (init === undefined)
|
|
1159
|
+
return;
|
|
1160
|
+
if (!isStaticListLiteral(init)) {
|
|
1161
|
+
findings.push({
|
|
1162
|
+
rule: "append-to-non-list",
|
|
1163
|
+
severity: "error",
|
|
1164
|
+
message: `\`$append ${op.setName} ...\` in target '${targetName}': ${op.setName} is initialized to a non-list value (\`${init.slice(0, 40)}${init.length > 40 ? "..." : ""}\`). $append v0.3.0 requires a list-typed target.`,
|
|
1165
|
+
block: targetName,
|
|
1166
|
+
extras: { var_name: op.setName, init_value: init },
|
|
1167
|
+
});
|
|
1168
|
+
}
|
|
1169
|
+
});
|
|
1170
|
+
}
|
|
1171
|
+
return findings;
|
|
1172
|
+
},
|
|
1173
|
+
};
|
|
860
1174
|
const RULES = [
|
|
861
1175
|
// Tier-1 (error)
|
|
862
1176
|
PARSE_ERROR,
|
|
@@ -872,6 +1186,11 @@ const RULES = [
|
|
|
872
1186
|
INDENTATION,
|
|
873
1187
|
RESERVED_KEYWORD,
|
|
874
1188
|
UNKNOWN_SKILL_REFERENCE,
|
|
1189
|
+
UNKNOWN_TEMPLATE_REFERENCE,
|
|
1190
|
+
UNKNOWN_RETRIEVAL_ARG,
|
|
1191
|
+
UNINITIALIZED_APPEND,
|
|
1192
|
+
FOREACH_LOCAL_ACCUMULATOR_TARGET,
|
|
1193
|
+
APPEND_TO_NON_LIST,
|
|
875
1194
|
DISABLED_SKILL_REFERENCE,
|
|
876
1195
|
CREDENTIAL_IN_ARGS,
|
|
877
1196
|
STATUS_DISABLED,
|
|
@@ -882,6 +1201,7 @@ const RULES = [
|
|
|
882
1201
|
DEPRECATED_QUESTION,
|
|
883
1202
|
UNSAFE_SHELL_AMBIGUOUS_SUBST,
|
|
884
1203
|
UNSAFE_SHELL_OP,
|
|
1204
|
+
UNSAFE_SHELL_DISABLED,
|
|
885
1205
|
UNCONFIRMED_MUTATION,
|
|
886
1206
|
MODEL_CONTENTION,
|
|
887
1207
|
DRAFT_WITH_TRIGGER,
|
|
@@ -911,10 +1231,27 @@ function walkOps(ops, visit) {
|
|
|
911
1231
|
}
|
|
912
1232
|
}
|
|
913
1233
|
function collectAmpRefsFromOps(ops) {
|
|
914
|
-
const out =
|
|
1234
|
+
const out = [];
|
|
1235
|
+
const seen = new Set();
|
|
1236
|
+
const emit = (name, via) => {
|
|
1237
|
+
const key = `${via}:${name}`;
|
|
1238
|
+
if (seen.has(key))
|
|
1239
|
+
return;
|
|
1240
|
+
seen.add(key);
|
|
1241
|
+
out.push({ name, via });
|
|
1242
|
+
};
|
|
915
1243
|
walkOps(ops, (op) => {
|
|
916
1244
|
if (op.kind === "&" && op.ampParams !== undefined)
|
|
917
|
-
|
|
1245
|
+
emit(op.ampParams.skillName, "&");
|
|
1246
|
+
// v0.2.11 Bug 7: $ execute_skill is also a composition primitive.
|
|
1247
|
+
if (op.kind === "$" && /^execute_skill\b/.test(op.body)) {
|
|
1248
|
+
const m = /\bskill_name\s*=\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z_][\w-]*))/.exec(op.body);
|
|
1249
|
+
if (m !== null) {
|
|
1250
|
+
const name = m[1] ?? m[2] ?? m[3];
|
|
1251
|
+
if (name !== undefined && name !== "")
|
|
1252
|
+
emit(name, "$ execute_skill");
|
|
1253
|
+
}
|
|
1254
|
+
}
|
|
918
1255
|
});
|
|
919
1256
|
return out;
|
|
920
1257
|
}
|