skillscript-runtime 0.19.8 → 0.19.10

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/dist/lint.js CHANGED
@@ -535,6 +535,155 @@ const UNKNOWN_CONNECTOR = {
535
535
  return findings;
536
536
  },
537
537
  };
538
+ // v0.19.10 — catches the `$ <connector> <tool>` foot-gun: author writes the
539
+ // connector name + tool name space-separated (CLI muscle memory: `git status`),
540
+ // but skillscript's bare form treats the FIRST token as the tool name → the
541
+ // dispatch sends `name: "<connector>"` to the resolved server, which replies
542
+ // "Tool with name 'X' not found" — pointing the author at the wrong layer
543
+ // (looks like the connector is broken). Closes Perry's `650c5a9c` Finding 1.
544
+ const CONNECTOR_AS_TOOL = {
545
+ id: "connector-as-tool",
546
+ severity: "error",
547
+ description: "A bare `$ <connector> <tool>` op was written space-separated, but skillscript's bare-form dispatch uses the first token as the TOOL name. The connector name is being interpreted as a tool, which fails at the MCP server with a misdirecting error.",
548
+ remediation: "Two correct forms: `$ <connector>.<tool> args ...` (dotted; explicit connector.tool routing) OR `$ <tool> args ...` (bare tool; runtime resolves to the owning connector). Don't write `$ <connector> <tool>` space-separated.",
549
+ check: (ctx) => {
550
+ if (ctx.mcpConnectorNames === undefined)
551
+ return [];
552
+ const known = new Set(ctx.mcpConnectorNames);
553
+ if (known.size === 0)
554
+ return [];
555
+ const findings = [];
556
+ const reported = new Set();
557
+ for (const [targetName, target] of ctx.parsed.targets) {
558
+ walkOps(target.ops, (op) => {
559
+ // Only bare-form `$` ops (no explicit `connector.tool` dot).
560
+ if (op.kind !== "$" || op.mcpConnector !== undefined)
561
+ return;
562
+ const body = op.body.trimStart();
563
+ // First token = the would-be tool name (or kwarg target). Match
564
+ // `<token1> <token2>` where token1 is a connector name AND token2
565
+ // is a BARE identifier NOT followed by `=` (which would make it a
566
+ // kwarg key — the legit same-name shape `$ data_write content="..."`
567
+ // where `data_write` is both the connector and tool name).
568
+ // `\b` after the second identifier prevents the greedy quantifier
569
+ // from backtracking into a partial match: without `\b`, `content`
570
+ // followed by `=` makes the lookahead fail, and the engine backs off
571
+ // to `conten`/`conte`/... until the lookahead is satisfied (false
572
+ // positive on the legitimate same-name kwarg shape).
573
+ const m = /^([a-z_][a-z0-9_-]*)\s+([A-Za-z_][\w-]*)\b(?!\s*=)/.exec(body);
574
+ if (m === null)
575
+ return;
576
+ const firstToken = m[1];
577
+ if (!known.has(firstToken))
578
+ return;
579
+ const secondToken = m[2];
580
+ const key = `${targetName}:${firstToken}`;
581
+ if (reported.has(key))
582
+ return;
583
+ reported.add(key);
584
+ findings.push({
585
+ rule: "connector-as-tool",
586
+ severity: "error",
587
+ message: `\`$ ${firstToken} ${secondToken} ...\` in target '${targetName}' — \`${firstToken}\` is a connector name, not a tool. Bare-form dispatch treats the first token as the tool name, so this sends \`name: "${firstToken}"\` to the server (which replies \"Tool '${firstToken}' not found\" — a misdirecting error). Two correct forms: \`$ ${firstToken}.${secondToken} ...\` (dotted; explicit connector.tool) OR \`$ ${secondToken} ...\` (bare tool; runtime resolves to owning connector).`,
588
+ block: targetName,
589
+ extras: { connector: firstToken, would_be_tool: secondToken },
590
+ });
591
+ });
592
+ }
593
+ return findings;
594
+ },
595
+ };
596
+ // v0.19.10 — tier-3 advisory closing Perry's `650c5a9c` Finding 2 partially.
597
+ // Per-MCP-server result shape varies: some servers return JSON-as-text in
598
+ // `content[0].text`, which the runtime's `unwrapToolResult` JSON.parses
599
+ // cleanly. Others return prose-wrapped or multi-content responses that
600
+ // fail to parse → the bound var is a string, not a structured object.
601
+ // `${R|length}` then silently returns char-count of the string instead of
602
+ // element-count of the (intended) array — silent-wrong, the worst class.
603
+ //
604
+ // Narrow to `${R|length}` specifically: it's the silent-wrong path.
605
+ // `${R.field}` on a string raises UnresolvedVariableError (loud), so the
606
+ // runtime tells the author already. `|length` is the trap.
607
+ //
608
+ // Skip the advisory when the skill already does `$ json_parse ${R}`
609
+ // somewhere — the author already defended.
610
+ const REMOTE_RESULT_NEEDS_PARSE = {
611
+ id: "remote-result-needs-parse",
612
+ severity: "info",
613
+ description: "A `${R|length}` access on a value bound by a `$` dispatch. Per-MCP-server result shape varies: if the connector's server returns prose-wrapped or non-JSON text in `content[0].text`, the runtime's auto-parse falls back to the raw string and `|length` returns the string's char-count (NOT the intended array element-count). Silent-wrong is the trap.",
614
+ remediation: "If `${R|length}` returns suspicious counts (e.g., 1394 when you expected 5), add `$ json_parse ${R} -> P` after the dispatch and access via `${P|length}`. This advisory suppresses itself when the skill already does `$ json_parse ${R}` somewhere — your defensive parse is taken as intent.",
615
+ check: (ctx) => {
616
+ // Bindings from $ ops that aren't json_parse itself.
617
+ const dollarBindings = new Map(); // bindVar → targetName
618
+ for (const [targetName, target] of ctx.parsed.targets) {
619
+ walkOps(target.ops, (op) => {
620
+ if (op.kind !== "$" || op.outputVar === undefined)
621
+ return;
622
+ // Skip json_parse bindings — those produce parsed structure by design.
623
+ if (/^json_parse\b|\.json_parse\b/.test(op.body))
624
+ return;
625
+ dollarBindings.set(op.outputVar, targetName);
626
+ });
627
+ }
628
+ if (dollarBindings.size === 0)
629
+ return [];
630
+ // Collect var names that the skill already passes through `json_parse`
631
+ // anywhere — author has defended; suppress the advisory for those.
632
+ const alreadyDefended = new Set();
633
+ for (const target of ctx.parsed.targets.values()) {
634
+ walkOps(target.ops, (op) => {
635
+ if (op.kind !== "$")
636
+ return;
637
+ const m = /^json_parse\s+\$[({]([A-Za-z_]\w*)/.exec(op.body);
638
+ if (m !== null)
639
+ alreadyDefended.add(m[1]);
640
+ });
641
+ }
642
+ const findings = [];
643
+ const reported = new Set();
644
+ const lengthRe = /\$[({]([A-Za-z_]\w*)\|length\b/g;
645
+ for (const [targetName, target] of ctx.parsed.targets) {
646
+ const scan = (s) => {
647
+ let m;
648
+ lengthRe.lastIndex = 0;
649
+ while ((m = lengthRe.exec(s)) !== null) {
650
+ const refBase = m[1];
651
+ if (!dollarBindings.has(refBase))
652
+ continue;
653
+ if (alreadyDefended.has(refBase))
654
+ continue;
655
+ const key = `${targetName}:${refBase}`;
656
+ if (reported.has(key))
657
+ continue;
658
+ reported.add(key);
659
+ findings.push({
660
+ rule: "remote-result-needs-parse",
661
+ severity: "info",
662
+ message: `\`\${${refBase}|length}\` in target '${targetName}' takes the length of a value bound by a \`$\` dispatch. If the connector's MCP server returns prose-wrapped or non-JSON text, the runtime's auto-parse falls back to the raw string and \`|length\` returns the string's char-count instead of element-count — silent-wrong. If results look suspiciously large, add \`$ json_parse \${${refBase}} -> P\` after the dispatch and use \`\${P|length}\`.`,
663
+ block: targetName,
664
+ extras: { bind_var: refBase },
665
+ });
666
+ }
667
+ };
668
+ const visit = (op) => {
669
+ if (op.body !== undefined)
670
+ scan(op.body);
671
+ if (op.setValue !== undefined)
672
+ scan(op.setValue);
673
+ if (op.foreachBody !== undefined)
674
+ op.foreachBody.forEach(visit);
675
+ if (op.ifBranches !== undefined)
676
+ op.ifBranches.forEach((b) => b.body.forEach(visit));
677
+ if (op.ifElseBody !== undefined)
678
+ op.ifElseBody.forEach(visit);
679
+ };
680
+ target.ops.forEach(visit);
681
+ if (target.elseBlock !== undefined)
682
+ target.elseBlock.forEach(visit);
683
+ }
684
+ return findings;
685
+ },
686
+ };
538
687
  // v0.16.4 — `$ llm prompt="..." model="X"` where X matches neither any
539
688
  // registered LocalModel alias name NOR any `models_available` entry from
540
689
  // any registered LocalModel's `manifest()` payload. Tier-2 warning.
@@ -3054,6 +3203,7 @@ const RULES = [
3054
3203
  UNKNOWN_SKILL_REFERENCE,
3055
3204
  UNKNOWN_TEMPLATE_REFERENCE,
3056
3205
  UNKNOWN_CONNECTOR,
3206
+ CONNECTOR_AS_TOOL,
3057
3207
  UNKNOWN_CONNECTOR_CLASS,
3058
3208
  UNWIRED_PRIMARY_CONNECTOR,
3059
3209
  DISALLOWED_TOOL,
@@ -3106,6 +3256,7 @@ const RULES = [
3106
3256
  ADDRESS_ROUTED_WAKE_INFO,
3107
3257
  BODY_TEMPLATE_DETECTED,
3108
3258
  EMIT_WITH_TEMPLATE,
3259
+ REMOTE_RESULT_NEEDS_PARSE,
3109
3260
  ];
3110
3261
  /** Read-only view of the rule registry — for tooling that introspects v1 rules. */
3111
3262
  export function listRules() {