@foldspace_npm/harness 0.1.28 → 0.1.29
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 +1 -1
- package/bin/build.ts +19 -6
- package/package.json +1 -1
- package/src/lint/index.mjs +2 -0
- package/src/lint/rules.mjs +164 -25
package/README.md
CHANGED
|
@@ -225,7 +225,7 @@ v1 flags:
|
|
|
225
225
|
- **error** `no-static-agent-prompt` - `execute`, a card's `callback` or its `readyState` handing the agent instructions (`directive`, “tell the user”, …)
|
|
226
226
|
- **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
|
|
227
227
|
- **error** `card-contract` - `awaitUserInput: true` on a view card (its `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
|
|
228
|
-
- **error** `no-test-mode-in-bundle` - `armAllInstances(
|
|
228
|
+
- **error** `no-test-mode-in-bundle` - `armAllInstances` used anywhere in `agent/` (called, or handed on to be called later), or `setTestMode(...)` with anything but a literal `false`. A code project's own copy of the runtime in `agent/runtime/` is not refused for the `setTestMode` inside its `armAllInstances`
|
|
229
229
|
- **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed), or a `render` that declares `callback`, `cancel` or `readyState` and uses none of them (the SDK treats it as a view card)
|
|
230
230
|
- **warning** `no-emoji` — emoji in handler or `agent/views` string literals
|
|
231
231
|
- **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
|
package/bin/build.ts
CHANGED
|
@@ -2,7 +2,7 @@ import * as esbuild from "esbuild";
|
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
4
|
import { fileURLToPath } from "url";
|
|
5
|
-
import { lintProject, printLint } from "../src/lint/index.mjs";
|
|
5
|
+
import { bundleTurnsOnTestMode, lintProject, printLint } from "../src/lint/index.mjs";
|
|
6
6
|
|
|
7
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
8
8
|
const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
@@ -19,6 +19,21 @@ function gateLint(): void {
|
|
|
19
19
|
}
|
|
20
20
|
}
|
|
21
21
|
|
|
22
|
+
const TEST_MODE_IN_BUNDLE =
|
|
23
|
+
"foldspace build: the bundle can turn test mode on - it uses `setTestMode` with something other than `false`. " +
|
|
24
|
+
"Any bundle can ship to production, where test mode would stop real users' conversations being tracked. " +
|
|
25
|
+
"Remove it: test mode is only `npx foldspace attach --test-mode`, which never touches the bundle. dist/index.js was not kept.";
|
|
26
|
+
|
|
27
|
+
// The net under lint's no-test-mode-in-bundle: read what was actually built.
|
|
28
|
+
function bundleIsSafe(): boolean {
|
|
29
|
+
const outfile = path.join(distDir, "index.js");
|
|
30
|
+
if (!fs.existsSync(outfile)) return true;
|
|
31
|
+
if (!bundleTurnsOnTestMode(fs.readFileSync(outfile, "utf8"))) return true;
|
|
32
|
+
fs.rmSync(outfile, { force: true });
|
|
33
|
+
console.error(TEST_MODE_IN_BUNDLE);
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
|
|
22
37
|
function buildOptions(): esbuild.BuildOptions {
|
|
23
38
|
return {
|
|
24
39
|
entryPoints: [agentActionsEntry],
|
|
@@ -45,11 +60,8 @@ function rebuildPlugin(): esbuild.Plugin {
|
|
|
45
60
|
});
|
|
46
61
|
build.onEnd((result) => {
|
|
47
62
|
const time = new Date().toLocaleTimeString();
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
} else {
|
|
51
|
-
console.log(`[${time}] Error: dist/index.js`);
|
|
52
|
-
}
|
|
63
|
+
const built = result.errors.length === 0 && bundleIsSafe();
|
|
64
|
+
console.log(`[${time}] ${built ? "Rebuilt" : "Error"}: dist/index.js`);
|
|
53
65
|
});
|
|
54
66
|
},
|
|
55
67
|
};
|
|
@@ -90,6 +102,7 @@ async function build() {
|
|
|
90
102
|
});
|
|
91
103
|
} else {
|
|
92
104
|
await esbuild.build(buildOptions());
|
|
105
|
+
if (!bundleIsSafe()) process.exit(1);
|
|
93
106
|
console.log("Built: dist/index.js");
|
|
94
107
|
console.log("\nBuild complete!");
|
|
95
108
|
}
|
package/package.json
CHANGED
package/src/lint/index.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import path from "node:path";
|
|
|
3
3
|
import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
|
|
4
4
|
import { lintActionFile, lintViewsFile, ruleNoTestModeInBundle, ruleRegistryIntegrity } from "./rules.mjs";
|
|
5
5
|
|
|
6
|
+
export { bundleTurnsOnTestMode } from "./rules.mjs";
|
|
7
|
+
|
|
6
8
|
export const LINT_SCHEMA_VERSION = 1;
|
|
7
9
|
|
|
8
10
|
export function lintProject(projectDir) {
|
package/src/lint/rules.mjs
CHANGED
|
@@ -644,34 +644,173 @@ export function ruleCardContract(sourceFile, relative, findings) {
|
|
|
644
644
|
|
|
645
645
|
// Test mode belongs to `attach --test-mode`, which never touches the bundle.
|
|
646
646
|
// A call in agent code that turns it on can ship to production and mark real
|
|
647
|
-
// users' conversations as test traffic.
|
|
648
|
-
//
|
|
649
|
-
// `
|
|
650
|
-
//
|
|
647
|
+
// users' conversations as test traffic.
|
|
648
|
+
//
|
|
649
|
+
// `setTestMode` is refused unless it is called with the literal `false` - and
|
|
650
|
+
// also when it is reached any other way (handed on, `.call`, `.bind`).
|
|
651
|
+
// `armAllInstances` is refused however it is used, whatever its argument:
|
|
652
|
+
// called (by name, under the name it was imported as, by element access),
|
|
653
|
+
// handed on (a timer, a listener, an object, a default value) or exported
|
|
654
|
+
// under another name. Even with `false` it sends a partial `setConfiguration`
|
|
655
|
+
// to every instance. Importing it, re-exporting it under its own name and
|
|
656
|
+
// types are not uses. Names are matched within the file, not by binding, so
|
|
657
|
+
// the rule errs on the side of refusing.
|
|
658
|
+
//
|
|
659
|
+
// One call is not refused. A code project carries its own copy of the runtime,
|
|
660
|
+
// and that copy's `agent/runtime/agent.ts` defines `function
|
|
661
|
+
// armAllInstances(testMode)`, which calls `setTestMode(testMode)`. That call -
|
|
662
|
+
// with the helper's own first parameter, in that function, in that file - runs
|
|
663
|
+
// only when something uses `armAllInstances`, and every use is refused. The
|
|
664
|
+
// build also checks the bundle itself (`bundleTurnsOnTestMode`), which catches
|
|
665
|
+
// what reading the source cannot.
|
|
651
666
|
export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
fix: "Remove it. Test mode is only for an agent that already has real users, and only through `npx foldspace attach --test-mode`, which never touches the bundle. For an agent with no real users yet, leave test mode off.",
|
|
668
|
-
}),
|
|
669
|
-
);
|
|
670
|
-
}
|
|
667
|
+
// path.sep, so a POSIX file whose name holds a backslash is not read as a folder.
|
|
668
|
+
const inRuntimeHelperFile = relative.split(path.sep).join("/") === "agent/runtime/agent.ts";
|
|
669
|
+
|
|
670
|
+
const ARM = "armAllInstances";
|
|
671
|
+
const armNames = new Set([ARM]);
|
|
672
|
+
const keyText = (key) => {
|
|
673
|
+
if (!key) return undefined;
|
|
674
|
+
if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) return key.text;
|
|
675
|
+
if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text;
|
|
676
|
+
return undefined;
|
|
677
|
+
};
|
|
678
|
+
const collectImportedNames = (node) => {
|
|
679
|
+
if (ts.isImportSpecifier(node) && keyText(node.propertyName ?? node.name) === ARM) armNames.add(node.name.text);
|
|
680
|
+
if (ts.isBindingElement(node) && ts.isIdentifier(node.name) && keyText(node.propertyName ?? node.name) === ARM) {
|
|
681
|
+
armNames.add(node.name.text);
|
|
671
682
|
}
|
|
672
|
-
ts.forEachChild(node,
|
|
683
|
+
ts.forEachChild(node, collectImportedNames);
|
|
673
684
|
};
|
|
674
|
-
|
|
685
|
+
collectImportedNames(sourceFile);
|
|
686
|
+
|
|
687
|
+
const memberName = (node) => {
|
|
688
|
+
if (ts.isPropertyAccessExpression(node)) return node.name.text;
|
|
689
|
+
if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) {
|
|
690
|
+
return node.argumentExpression.text;
|
|
691
|
+
}
|
|
692
|
+
return "";
|
|
693
|
+
};
|
|
694
|
+
const unwrap = (node) => {
|
|
695
|
+
while (
|
|
696
|
+
node &&
|
|
697
|
+
(ts.isParenthesizedExpression(node) ||
|
|
698
|
+
ts.isNonNullExpression(node) ||
|
|
699
|
+
ts.isAsExpression(node) ||
|
|
700
|
+
ts.isSatisfiesExpression(node) ||
|
|
701
|
+
ts.isTypeAssertionExpression(node))
|
|
702
|
+
) {
|
|
703
|
+
node = node.expression;
|
|
704
|
+
}
|
|
705
|
+
return node;
|
|
706
|
+
};
|
|
707
|
+
const isArm = (node) => (ts.isIdentifier(node) && armNames.has(node.text)) || memberName(node) === ARM;
|
|
708
|
+
const isSetTestMode = (node) =>
|
|
709
|
+
(ts.isIdentifier(node) && node.text === "setTestMode") || memberName(node) === "setTestMode";
|
|
710
|
+
const isCallee = (node) => {
|
|
711
|
+
let parent = node.parent;
|
|
712
|
+
while (parent && (ts.isParenthesizedExpression(parent) || ts.isNonNullExpression(parent) || ts.isAsExpression(parent))) {
|
|
713
|
+
parent = parent.parent;
|
|
714
|
+
}
|
|
715
|
+
return parent !== undefined && ts.isCallExpression(parent) && unwrap(parent.expression) === node;
|
|
716
|
+
};
|
|
717
|
+
|
|
718
|
+
const inImportEquals = (node) => {
|
|
719
|
+
let n = node;
|
|
720
|
+
while (n.parent && ts.isQualifiedName(n.parent)) n = n.parent;
|
|
721
|
+
return n.parent !== undefined && ts.isImportEqualsDeclaration(n.parent);
|
|
722
|
+
};
|
|
723
|
+
// Positions where the name is declared, imported, a key or a type - not a
|
|
724
|
+
// value that can be called later.
|
|
725
|
+
const isNamedNotUsed = (node) => {
|
|
726
|
+
const parent = node.parent;
|
|
727
|
+
if (!parent) return true;
|
|
728
|
+
if (ts.isPropertyAccessExpression(parent) && parent.name === node) return true;
|
|
729
|
+
if (ts.isQualifiedName(parent)) return !inImportEquals(node);
|
|
730
|
+
if (ts.isExportSpecifier(parent)) return true; // the export itself is judged below, once
|
|
731
|
+
if (ts.isBindingElement(parent)) return parent.name === node || parent.propertyName === node;
|
|
732
|
+
if (
|
|
733
|
+
ts.isImportSpecifier(parent) ||
|
|
734
|
+
ts.isImportClause(parent) ||
|
|
735
|
+
ts.isNamespaceImport(parent) ||
|
|
736
|
+
ts.isTypeQueryNode(parent) ||
|
|
737
|
+
ts.isImportTypeNode(parent)
|
|
738
|
+
) {
|
|
739
|
+
return true;
|
|
740
|
+
}
|
|
741
|
+
if (ts.isFunctionDeclaration(parent) && parent.name === node) {
|
|
742
|
+
return !parent.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
|
|
743
|
+
}
|
|
744
|
+
return (
|
|
745
|
+
(ts.isFunctionExpression(parent) ||
|
|
746
|
+
ts.isVariableDeclaration(parent) ||
|
|
747
|
+
ts.isParameter(parent) ||
|
|
748
|
+
ts.isPropertyAssignment(parent) ||
|
|
749
|
+
ts.isMethodDeclaration(parent) ||
|
|
750
|
+
ts.isMethodSignature(parent) ||
|
|
751
|
+
ts.isGetAccessorDeclaration(parent) ||
|
|
752
|
+
ts.isSetAccessorDeclaration(parent) ||
|
|
753
|
+
ts.isPropertyDeclaration(parent) ||
|
|
754
|
+
ts.isPropertySignature(parent) ||
|
|
755
|
+
ts.isEnumMember(parent)) &&
|
|
756
|
+
parent.name === node
|
|
757
|
+
);
|
|
758
|
+
};
|
|
759
|
+
|
|
760
|
+
// The helper's first parameter, when node is the runtime copy's own
|
|
761
|
+
// `function armAllInstances(testMode)` in agent/runtime/agent.ts.
|
|
762
|
+
const helperParam = (node) => {
|
|
763
|
+
if (!inRuntimeHelperFile || !ts.isFunctionDeclaration(node) || node.name?.text !== ARM) return undefined;
|
|
764
|
+
if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) return undefined;
|
|
765
|
+
const first = node.parameters[0];
|
|
766
|
+
return first && ts.isIdentifier(first.name) ? first.name.text : undefined;
|
|
767
|
+
};
|
|
768
|
+
|
|
769
|
+
const refuse = (node, name) => {
|
|
770
|
+
findings.push(
|
|
771
|
+
finding({
|
|
772
|
+
rule: "no-test-mode-in-bundle",
|
|
773
|
+
severity: "error",
|
|
774
|
+
sourceFile,
|
|
775
|
+
node,
|
|
776
|
+
file: relative,
|
|
777
|
+
message: `\`${name}\` does not belong in agent code: any file in \`agent/\` can ship to production, where test mode would stop real users' conversations being tracked.`,
|
|
778
|
+
fix: "Remove it. Test mode is only for an agent that already has real users, and only through `npx foldspace attach --test-mode`, which never touches the bundle. For an agent with no real users yet, leave test mode off.",
|
|
779
|
+
}),
|
|
780
|
+
);
|
|
781
|
+
};
|
|
782
|
+
|
|
783
|
+
const walk = (node, exemptParam) => {
|
|
784
|
+
if (ts.isCallExpression(node) && isSetTestMode(unwrap(node.expression))) {
|
|
785
|
+
const [first] = node.arguments;
|
|
786
|
+
const off = first !== undefined && first.kind === ts.SyntaxKind.FalseKeyword;
|
|
787
|
+
const helperOwnCall =
|
|
788
|
+
exemptParam !== undefined && node.arguments.length === 1 && ts.isIdentifier(first) && first.text === exemptParam;
|
|
789
|
+
if (!off && !helperOwnCall) refuse(node, "setTestMode");
|
|
790
|
+
} else if (isSetTestMode(node) && !isCallee(node) && !(ts.isIdentifier(node) && isNamedNotUsed(node))) {
|
|
791
|
+
refuse(node, "setTestMode");
|
|
792
|
+
}
|
|
793
|
+
if (ts.isCallExpression(node) && isArm(unwrap(node.expression))) {
|
|
794
|
+
refuse(node, ARM);
|
|
795
|
+
} else if (isArm(node)) {
|
|
796
|
+
if (!isCallee(node) && !(ts.isIdentifier(node) && isNamedNotUsed(node))) refuse(node, ARM);
|
|
797
|
+
} else if (ts.isExportSpecifier(node)) {
|
|
798
|
+
const local = keyText(node.propertyName ?? node.name);
|
|
799
|
+
if (local !== undefined && armNames.has(local) && keyText(node.name) !== ARM) refuse(node, ARM);
|
|
800
|
+
}
|
|
801
|
+
const param = exemptParam ?? helperParam(node);
|
|
802
|
+
ts.forEachChild(node, (child) => walk(child, param));
|
|
803
|
+
};
|
|
804
|
+
walk(sourceFile, undefined);
|
|
805
|
+
}
|
|
806
|
+
|
|
807
|
+
// What the build checks in the bundle it wrote: minify keeps property names,
|
|
808
|
+
// so a bundle that can turn test mode on still says `setTestMode`. Calls with
|
|
809
|
+
// `false` (`!1` once minified) are allowed; any other mention is not - the
|
|
810
|
+
// net under the lint rule above, for code it cannot read (a computed name, a
|
|
811
|
+
// file lint does not walk).
|
|
812
|
+
export function bundleTurnsOnTestMode(js) {
|
|
813
|
+
return /setTestMode/.test(js.replace(/\??\.setTestMode(\?\.)?\(\s*(!1|false)\s*\)/g, ""));
|
|
675
814
|
}
|
|
676
815
|
|
|
677
816
|
export function lintActionFile(filePath, relative, findings) {
|