@foldspace_npm/harness 0.1.6 → 0.1.8
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 +28 -8
- package/bin/build.ts +20 -0
- package/bin/lint.mjs +9 -0
- package/package.json +3 -2
- package/src/cli-registry.mjs +25 -1
- package/src/init.mjs +3 -1
- package/src/lint/ast.mjs +237 -0
- package/src/lint/index.mjs +101 -0
- package/src/lint/rules.mjs +464 -0
- package/src/lint/ts.mjs +6 -0
- package/templates/agent-starter/CLAUDE.md +66 -18
- package/templates/agent-starter/agent/actions/_example.ts +15 -0
- package/templates/agent-starter/docs/app-profile.md +109 -0
- package/templates/agent-starter/package.json +2 -0
package/README.md
CHANGED
|
@@ -93,7 +93,7 @@ npx --yes @foldspace_npm/harness init
|
|
|
93
93
|
Non-interactive / CI form:
|
|
94
94
|
|
|
95
95
|
```bash
|
|
96
|
-
npx --yes @foldspace_npm/harness init
|
|
96
|
+
npx --yes @foldspace_npm/harness init foldspace-agent \
|
|
97
97
|
--product-id FR8JUQZAQRZB \
|
|
98
98
|
--agent-key my-agent \
|
|
99
99
|
--domain app.example.com \
|
|
@@ -103,24 +103,25 @@ npx --yes @foldspace_npm/harness init my-agent \
|
|
|
103
103
|
When running directly from a harness checkout during development:
|
|
104
104
|
|
|
105
105
|
```bash
|
|
106
|
-
node bin/cli.mjs init ../
|
|
106
|
+
node bin/cli.mjs init ../foldspace-agent \
|
|
107
107
|
--product-id FR8JUQZAQRZB \
|
|
108
108
|
--agent-key my-agent \
|
|
109
109
|
--domain app.example.com
|
|
110
110
|
```
|
|
111
111
|
|
|
112
|
-
The Agent Key is the value shown in Agent Studio, such as `my-agent`.
|
|
113
|
-
`--agent-api-name` remains a deprecated alias
|
|
114
|
-
optional and defaults to the target directory
|
|
115
|
-
|
|
116
|
-
|
|
112
|
+
The Agent Key is the value shown in Agent Studio, such as `my-agent`. It is
|
|
113
|
+
not the sidecar directory name. `--agent-api-name` remains a deprecated alias
|
|
114
|
+
for `--agent-key`. `--name` is optional and defaults to the target directory
|
|
115
|
+
name (`foldspace-agent` unless you pass a directory). The product ID must be
|
|
116
|
+
the bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname
|
|
117
|
+
or an HTTP(S) URL without a port or path.
|
|
117
118
|
|
|
118
119
|
For safety, `init` requires a target path that does not exist. It does not
|
|
119
120
|
initialize Git or overwrite files. On a TTY it can offer
|
|
120
121
|
`npm install --ignore-scripts` after scaffolding. After creation:
|
|
121
122
|
|
|
122
123
|
```bash
|
|
123
|
-
cd
|
|
124
|
+
cd foldspace-agent
|
|
124
125
|
npm install --ignore-scripts # if you skipped the install prompt
|
|
125
126
|
npm run build
|
|
126
127
|
npm run inject
|
|
@@ -130,6 +131,25 @@ npm run attach
|
|
|
130
131
|
The generated npm scripts intentionally remain the normal project interface;
|
|
131
132
|
`foldspace init` is the one-time project creation command.
|
|
132
133
|
|
|
134
|
+
### Lint handlers before they ship
|
|
135
|
+
|
|
136
|
+
`foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
|
|
137
|
+
print and still produce `dist/index.js`.
|
|
138
|
+
|
|
139
|
+
```bash
|
|
140
|
+
foldspace lint
|
|
141
|
+
foldspace lint --json
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
v1 flags:
|
|
145
|
+
|
|
146
|
+
- **error** `no-static-agent-prompt` — `execute` / chatterblock `callback` returning copilot instructions (`directive`, “tell the user”, …)
|
|
147
|
+
- **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
|
|
148
|
+
- **warning** `no-emoji` — emoji in handler or `agent/views` string literals
|
|
149
|
+
- **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
|
|
150
|
+
|
|
151
|
+
Put action instructions in Agent Studio or MCP. `foldspace lint` cannot see that copy.
|
|
152
|
+
|
|
133
153
|
### Choose an attach mode
|
|
134
154
|
|
|
135
155
|
- **Swap (default):** the page already uses the configured product and agent.
|
package/bin/build.ts
CHANGED
|
@@ -2,6 +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
6
|
|
|
6
7
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
8
|
const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
@@ -10,6 +11,14 @@ const agentActionsEntry = path.join(projectDir, "agent", "actions", "index.ts");
|
|
|
10
11
|
|
|
11
12
|
const watchMode = process.argv.includes("--watch");
|
|
12
13
|
|
|
14
|
+
function gateLint(): void {
|
|
15
|
+
const result = lintProject(projectDir);
|
|
16
|
+
printLint(result);
|
|
17
|
+
if (result.errorCount > 0) {
|
|
18
|
+
process.exit(1);
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
|
|
13
22
|
function buildOptions(): esbuild.BuildOptions {
|
|
14
23
|
return {
|
|
15
24
|
entryPoints: [agentActionsEntry],
|
|
@@ -27,6 +36,13 @@ function rebuildPlugin(): esbuild.Plugin {
|
|
|
27
36
|
return {
|
|
28
37
|
name: "rebuild-notify",
|
|
29
38
|
setup(build) {
|
|
39
|
+
build.onStart(() => {
|
|
40
|
+
const result = lintProject(projectDir);
|
|
41
|
+
printLint(result);
|
|
42
|
+
if (result.errorCount > 0) {
|
|
43
|
+
throw new Error("foldspace lint failed");
|
|
44
|
+
}
|
|
45
|
+
});
|
|
30
46
|
build.onEnd((result) => {
|
|
31
47
|
const time = new Date().toLocaleTimeString();
|
|
32
48
|
if (result.errors.length === 0) {
|
|
@@ -47,6 +63,10 @@ async function build() {
|
|
|
47
63
|
process.exit(1);
|
|
48
64
|
}
|
|
49
65
|
|
|
66
|
+
if (!watchMode) {
|
|
67
|
+
gateLint();
|
|
68
|
+
}
|
|
69
|
+
|
|
50
70
|
if (!fs.existsSync(distDir)) {
|
|
51
71
|
fs.mkdirSync(distDir, { recursive: true });
|
|
52
72
|
}
|
package/bin/lint.mjs
ADDED
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { lintProject, printLint } from "../src/lint/index.mjs";
|
|
4
|
+
|
|
5
|
+
const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
|
|
6
|
+
const json = process.argv.includes("--json");
|
|
7
|
+
const result = lintProject(projectDir);
|
|
8
|
+
printLint(result, { json });
|
|
9
|
+
process.exit(result.errorCount > 0 ? 1 : 0);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldspace_npm/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.8",
|
|
4
4
|
"description": "Build and verify portable Foldspace action artifacts against a live app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -18,7 +18,8 @@
|
|
|
18
18
|
],
|
|
19
19
|
"dependencies": {
|
|
20
20
|
"esbuild": "^0.20.0",
|
|
21
|
-
"tsx": "^4.7.0"
|
|
21
|
+
"tsx": "^4.7.0",
|
|
22
|
+
"typescript": "^5.3.3"
|
|
22
23
|
},
|
|
23
24
|
"engines": {
|
|
24
25
|
"node": ">=20"
|
package/src/cli-registry.mjs
CHANGED
|
@@ -74,9 +74,33 @@ export const CLI_COMMANDS = Object.freeze([
|
|
|
74
74
|
positionals: [],
|
|
75
75
|
options: [flag("--watch", "Rebuild when action source changes")],
|
|
76
76
|
prerequisites: ["agent/actions/index.ts"],
|
|
77
|
-
effects: [
|
|
77
|
+
effects: [
|
|
78
|
+
"Runs foldspace lint first; errors skip bundling",
|
|
79
|
+
"Writes dist/index.js when lint has no errors",
|
|
80
|
+
],
|
|
78
81
|
next: ["Run foldspace inject", "Run foldspace attach"],
|
|
79
82
|
}),
|
|
83
|
+
Object.freeze({
|
|
84
|
+
name: "lint",
|
|
85
|
+
entry: "lint.mjs",
|
|
86
|
+
group: "develop",
|
|
87
|
+
summary: "Check action handlers for prompt, registry, and copy issues",
|
|
88
|
+
usage: "foldspace lint [--json]",
|
|
89
|
+
risk: "local-write",
|
|
90
|
+
environment: "node",
|
|
91
|
+
environmentVariables: ["FOLDSPACE_PROJECT_DIR"],
|
|
92
|
+
capabilities: ["artifact.build"],
|
|
93
|
+
positionals: [],
|
|
94
|
+
options: [
|
|
95
|
+
flag("--json", "Print findings as machine-readable JSON"),
|
|
96
|
+
],
|
|
97
|
+
prerequisites: ["agent/actions/index.ts"],
|
|
98
|
+
effects: [
|
|
99
|
+
"Reads agent/actions (and agent/views when present); writes nothing",
|
|
100
|
+
"Errors fail the process; warnings print and still exit 0",
|
|
101
|
+
],
|
|
102
|
+
next: ["Fix reported findings", "Run foldspace build"],
|
|
103
|
+
}),
|
|
80
104
|
Object.freeze({
|
|
81
105
|
name: "inject",
|
|
82
106
|
entry: "inject.mjs",
|
package/src/init.mjs
CHANGED
|
@@ -16,7 +16,7 @@ const allowedFlags = new Set([
|
|
|
16
16
|
"domain",
|
|
17
17
|
]);
|
|
18
18
|
const tokenPattern = /\{\{([A-Z0-9_]+)\}\}/g;
|
|
19
|
-
const defaultDirectory = "
|
|
19
|
+
const defaultDirectory = "foldspace-agent";
|
|
20
20
|
|
|
21
21
|
export const initUsage = commandByName("init").usage;
|
|
22
22
|
|
|
@@ -306,7 +306,9 @@ function createTemplateValues(config, harnessVersion) {
|
|
|
306
306
|
PACKAGE_DESCRIPTION_JSON: JSON.stringify(`Foldspace browser actions for ${displayName}.`),
|
|
307
307
|
PRODUCT_ID_JSON: JSON.stringify(productId),
|
|
308
308
|
AGENT_API_NAME_JSON: JSON.stringify(agentApiName),
|
|
309
|
+
APP_DOMAIN: target.domain,
|
|
309
310
|
APP_DOMAIN_JSON: JSON.stringify(target.domain),
|
|
311
|
+
START_URL: target.startUrl,
|
|
310
312
|
START_URL_JSON: JSON.stringify(target.startUrl),
|
|
311
313
|
HOSTS_JSON: JSON.stringify(hosts),
|
|
312
314
|
HARNESS_VERSION: harnessVersion,
|
package/src/lint/ast.mjs
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { ts } from "./ts.mjs";
|
|
4
|
+
|
|
5
|
+
export function listSourceFiles(dir) {
|
|
6
|
+
if (!fs.existsSync(dir)) return [];
|
|
7
|
+
const out = [];
|
|
8
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
9
|
+
const full = path.join(dir, entry.name);
|
|
10
|
+
if (entry.isDirectory()) {
|
|
11
|
+
out.push(...listSourceFiles(full));
|
|
12
|
+
continue;
|
|
13
|
+
}
|
|
14
|
+
if (entry.isFile() && /\.(ts|js)$/.test(entry.name) && !entry.name.endsWith(".d.ts")) {
|
|
15
|
+
out.push(full);
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return out.sort();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function shouldSkipActionFile(filePath) {
|
|
22
|
+
const base = path.basename(filePath);
|
|
23
|
+
return base.startsWith("_example") || /\.test\./.test(base);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function parseFile(filePath) {
|
|
27
|
+
const text = fs.readFileSync(filePath, "utf8");
|
|
28
|
+
const kind = filePath.endsWith(".ts") ? ts.ScriptKind.TS : ts.ScriptKind.JS;
|
|
29
|
+
return ts.createSourceFile(filePath, text, ts.ScriptTarget.Latest, true, kind);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function loc(sourceFile, node) {
|
|
33
|
+
const start = node.getStart(sourceFile);
|
|
34
|
+
const { line, character } = sourceFile.getLineAndCharacterOfPosition(start);
|
|
35
|
+
return { line: line + 1, column: character + 1 };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function propertyName(node) {
|
|
39
|
+
if (!node?.name) return null;
|
|
40
|
+
if (ts.isIdentifier(node.name) || ts.isPrivateIdentifier(node.name)) {
|
|
41
|
+
return node.name.text;
|
|
42
|
+
}
|
|
43
|
+
if (ts.isStringLiteral(node.name) || ts.isNumericLiteral(node.name)) {
|
|
44
|
+
return node.name.text;
|
|
45
|
+
}
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function normalizeKey(name) {
|
|
50
|
+
return String(name || "")
|
|
51
|
+
.replace(/_/g, "")
|
|
52
|
+
.toLowerCase();
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isFunctionLike(node) {
|
|
56
|
+
return (
|
|
57
|
+
node &&
|
|
58
|
+
(ts.isFunctionDeclaration(node) ||
|
|
59
|
+
ts.isFunctionExpression(node) ||
|
|
60
|
+
ts.isArrowFunction(node) ||
|
|
61
|
+
ts.isMethodDeclaration(node) ||
|
|
62
|
+
ts.isConstructorDeclaration(node))
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function initializerOf(node) {
|
|
67
|
+
if (ts.isPropertyAssignment(node)) return node.initializer;
|
|
68
|
+
if (ts.isMethodDeclaration(node)) return node;
|
|
69
|
+
if (ts.isPropertyDeclaration(node)) return node.initializer;
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Call `visit(fn, name)` for execute / render / callback function-likes.
|
|
75
|
+
*/
|
|
76
|
+
export function forEachNamedFunction(sourceFile, names, visit) {
|
|
77
|
+
const wanted = names instanceof Set ? names : new Set(names);
|
|
78
|
+
|
|
79
|
+
function walk(node) {
|
|
80
|
+
if (
|
|
81
|
+
ts.isPropertyAssignment(node) ||
|
|
82
|
+
ts.isMethodDeclaration(node) ||
|
|
83
|
+
ts.isPropertyDeclaration(node)
|
|
84
|
+
) {
|
|
85
|
+
const name = propertyName(node);
|
|
86
|
+
if (name && wanted.has(name)) {
|
|
87
|
+
const target = initializerOf(node);
|
|
88
|
+
if (isFunctionLike(target)) visit(target, name, node);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
ts.forEachChild(node, walk);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
walk(sourceFile);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function callbackParamName(fn) {
|
|
98
|
+
const params = fn.parameters || [];
|
|
99
|
+
if (params.length >= 4 && ts.isIdentifier(params[3].name)) {
|
|
100
|
+
return params[3].name.text;
|
|
101
|
+
}
|
|
102
|
+
return "callback";
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export function collectConstBindings(scopeNode) {
|
|
106
|
+
const map = new Map();
|
|
107
|
+
|
|
108
|
+
function visit(node) {
|
|
109
|
+
if (node !== scopeNode && isFunctionLike(node)) return;
|
|
110
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.initializer) {
|
|
111
|
+
map.set(node.name.text, node.initializer);
|
|
112
|
+
}
|
|
113
|
+
ts.forEachChild(node, visit);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
visit(scopeNode);
|
|
117
|
+
return map;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export function collectModuleBindings(sourceFile) {
|
|
121
|
+
const map = new Map();
|
|
122
|
+
for (const stmt of sourceFile.statements) {
|
|
123
|
+
if (!ts.isVariableStatement(stmt)) continue;
|
|
124
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
125
|
+
if (ts.isIdentifier(decl.name) && decl.initializer) {
|
|
126
|
+
map.set(decl.name.text, decl.initializer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
return map;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export function resolveNode(node, bindings, depth = 0) {
|
|
134
|
+
if (!node || depth > 8) return node;
|
|
135
|
+
if (ts.isIdentifier(node)) {
|
|
136
|
+
const next = bindings.get(node.text);
|
|
137
|
+
if (!next) return node;
|
|
138
|
+
return resolveNode(next, bindings, depth + 1);
|
|
139
|
+
}
|
|
140
|
+
if (ts.isAsExpression(node) || ts.isParenthesizedExpression(node) || ts.isSatisfiesExpression(node)) {
|
|
141
|
+
return resolveNode(node.expression, bindings, depth + 1);
|
|
142
|
+
}
|
|
143
|
+
return node;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function stringText(node) {
|
|
147
|
+
if (!node) return null;
|
|
148
|
+
if (ts.isStringLiteral(node) || ts.isNoSubstitutionTemplateLiteral(node)) {
|
|
149
|
+
return node.text;
|
|
150
|
+
}
|
|
151
|
+
if (ts.isTemplateExpression(node)) {
|
|
152
|
+
return [
|
|
153
|
+
node.head.text,
|
|
154
|
+
...node.templateSpans.map((span) => span.literal.text),
|
|
155
|
+
].join("");
|
|
156
|
+
}
|
|
157
|
+
return null;
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
export function forEachStringLiteral(node, visit, { skipNestedFunctions = false, root = node } = {}) {
|
|
161
|
+
function walk(current) {
|
|
162
|
+
if (skipNestedFunctions && current !== root && isFunctionLike(current)) return;
|
|
163
|
+
if (
|
|
164
|
+
ts.isStringLiteral(current) ||
|
|
165
|
+
ts.isNoSubstitutionTemplateLiteral(current) ||
|
|
166
|
+
ts.isTemplateExpression(current)
|
|
167
|
+
) {
|
|
168
|
+
visit(current);
|
|
169
|
+
}
|
|
170
|
+
ts.forEachChild(current, walk);
|
|
171
|
+
}
|
|
172
|
+
walk(node);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
export function forEachReturn(fn, visit) {
|
|
176
|
+
if (ts.isArrowFunction(fn) && fn.body && !ts.isBlock(fn.body)) {
|
|
177
|
+
visit(fn.body, fn);
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
function walk(node) {
|
|
181
|
+
if (node !== fn && isFunctionLike(node)) return;
|
|
182
|
+
if (ts.isReturnStatement(node) && node.expression) visit(node.expression, node);
|
|
183
|
+
ts.forEachChild(node, walk);
|
|
184
|
+
}
|
|
185
|
+
walk(fn);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
export function forEachCallNamed(fn, name, visit) {
|
|
189
|
+
function walk(node) {
|
|
190
|
+
if (node !== fn && isFunctionLike(node)) return;
|
|
191
|
+
if (ts.isCallExpression(node)) {
|
|
192
|
+
if (ts.isIdentifier(node.expression) && node.expression.text === name) {
|
|
193
|
+
visit(node);
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
ts.forEachChild(node, walk);
|
|
197
|
+
}
|
|
198
|
+
walk(fn);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function objectProperties(obj) {
|
|
202
|
+
if (!obj || !ts.isObjectLiteralExpression(obj)) return [];
|
|
203
|
+
return obj.properties.filter(
|
|
204
|
+
(prop) => ts.isPropertyAssignment(prop) || ts.isShorthandPropertyAssignment(prop),
|
|
205
|
+
);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function propertyValue(prop) {
|
|
209
|
+
if (ts.isShorthandPropertyAssignment(prop)) return prop.name;
|
|
210
|
+
return prop.initializer;
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function resolveModulePath(fromFile, specifier) {
|
|
214
|
+
if (!specifier || !specifier.startsWith(".")) return null;
|
|
215
|
+
const base = path.resolve(path.dirname(fromFile), specifier);
|
|
216
|
+
const candidates = [
|
|
217
|
+
base,
|
|
218
|
+
`${base}.ts`,
|
|
219
|
+
`${base}.js`,
|
|
220
|
+
path.join(base, "index.ts"),
|
|
221
|
+
path.join(base, "index.js"),
|
|
222
|
+
];
|
|
223
|
+
return candidates.find((candidate) => fs.existsSync(candidate) && fs.statSync(candidate).isFile()) || null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
export function finding({ rule, severity, sourceFile, node, message, fix, file }) {
|
|
227
|
+
const position = loc(sourceFile, node);
|
|
228
|
+
return {
|
|
229
|
+
rule,
|
|
230
|
+
severity,
|
|
231
|
+
file,
|
|
232
|
+
line: position.line,
|
|
233
|
+
column: position.column,
|
|
234
|
+
message,
|
|
235
|
+
fix,
|
|
236
|
+
};
|
|
237
|
+
}
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import fs from "node:fs";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
|
|
4
|
+
import { lintActionFile, lintViewsFile, ruleRegistryIntegrity } from "./rules.mjs";
|
|
5
|
+
|
|
6
|
+
export const LINT_SCHEMA_VERSION = 1;
|
|
7
|
+
|
|
8
|
+
export function lintProject(projectDir) {
|
|
9
|
+
const actionsDir = path.join(projectDir, "agent", "actions");
|
|
10
|
+
const indexFile = path.join(actionsDir, "index.ts");
|
|
11
|
+
const indexJs = path.join(actionsDir, "index.js");
|
|
12
|
+
const entry = fs.existsSync(indexFile) ? indexFile : fs.existsSync(indexJs) ? indexJs : null;
|
|
13
|
+
|
|
14
|
+
if (!entry) {
|
|
15
|
+
return {
|
|
16
|
+
ok: false,
|
|
17
|
+
errorCount: 1,
|
|
18
|
+
warningCount: 0,
|
|
19
|
+
findings: [
|
|
20
|
+
{
|
|
21
|
+
rule: "registry-integrity",
|
|
22
|
+
severity: "error",
|
|
23
|
+
file: "agent/actions/index.ts",
|
|
24
|
+
line: 1,
|
|
25
|
+
column: 1,
|
|
26
|
+
message: "Entry point not found: agent/actions/index.ts",
|
|
27
|
+
fix: "Create agent/actions/index.ts and register handlers there.",
|
|
28
|
+
},
|
|
29
|
+
],
|
|
30
|
+
};
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const findings = [];
|
|
34
|
+
const actionFiles = listSourceFiles(actionsDir);
|
|
35
|
+
|
|
36
|
+
for (const filePath of actionFiles) {
|
|
37
|
+
lintActionFile(filePath, path.relative(projectDir, filePath), findings);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const viewsDir = path.join(projectDir, "agent", "views");
|
|
41
|
+
if (fs.existsSync(viewsDir)) {
|
|
42
|
+
for (const filePath of listSourceFiles(viewsDir)) {
|
|
43
|
+
lintViewsFile(filePath, path.relative(projectDir, filePath), findings);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
ruleRegistryIntegrity({
|
|
48
|
+
indexFile: entry,
|
|
49
|
+
indexSource: parseFile(entry),
|
|
50
|
+
actionFiles,
|
|
51
|
+
projectDir,
|
|
52
|
+
findings,
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const errorCount = findings.filter((item) => item.severity === "error").length;
|
|
56
|
+
const warningCount = findings.filter((item) => item.severity === "warning").length;
|
|
57
|
+
return {
|
|
58
|
+
ok: errorCount === 0,
|
|
59
|
+
errorCount,
|
|
60
|
+
warningCount,
|
|
61
|
+
findings,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function formatFinding(item) {
|
|
66
|
+
return `${item.file}:${item.line}:${item.column} ${item.severity} ${item.rule} ${item.message}\n Fix: ${item.fix}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function renderLintText(result) {
|
|
70
|
+
if (!result.findings.length) {
|
|
71
|
+
return "foldspace lint: no issues";
|
|
72
|
+
}
|
|
73
|
+
const body = result.findings.map(formatFinding).join("\n");
|
|
74
|
+
const summary = `${result.errorCount} error${result.errorCount === 1 ? "" : "s"}, ${result.warningCount} warning${result.warningCount === 1 ? "" : "s"}`;
|
|
75
|
+
return `${body}\n\n${summary}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function renderLintJson(result) {
|
|
79
|
+
return {
|
|
80
|
+
kind: "foldspace.lint",
|
|
81
|
+
schemaVersion: LINT_SCHEMA_VERSION,
|
|
82
|
+
ok: result.ok,
|
|
83
|
+
errorCount: result.errorCount,
|
|
84
|
+
warningCount: result.warningCount,
|
|
85
|
+
findings: result.findings,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function printLint(result, { json = false, stdout = console.log, stderr = console.error } = {}) {
|
|
90
|
+
if (json) {
|
|
91
|
+
stdout(JSON.stringify(renderLintJson(result), null, 2));
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
const text = renderLintText(result);
|
|
95
|
+
if (result.errorCount > 0) stderr(text);
|
|
96
|
+
else stdout(text);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function shouldSkipFile(filePath) {
|
|
100
|
+
return shouldSkipActionFile(filePath);
|
|
101
|
+
}
|
|
@@ -0,0 +1,464 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
import {
|
|
3
|
+
callbackParamName,
|
|
4
|
+
collectConstBindings,
|
|
5
|
+
collectModuleBindings,
|
|
6
|
+
finding,
|
|
7
|
+
forEachCallNamed,
|
|
8
|
+
forEachNamedFunction,
|
|
9
|
+
forEachReturn,
|
|
10
|
+
forEachStringLiteral,
|
|
11
|
+
isFunctionLike,
|
|
12
|
+
normalizeKey,
|
|
13
|
+
objectProperties,
|
|
14
|
+
parseFile,
|
|
15
|
+
propertyName,
|
|
16
|
+
propertyValue,
|
|
17
|
+
resolveModulePath,
|
|
18
|
+
resolveNode,
|
|
19
|
+
shouldSkipActionFile,
|
|
20
|
+
stringText,
|
|
21
|
+
} from "./ast.mjs";
|
|
22
|
+
import { ts } from "./ts.mjs";
|
|
23
|
+
|
|
24
|
+
/*
|
|
25
|
+
Backlog, not this slice:
|
|
26
|
+
- no-hardcoded-secrets
|
|
27
|
+
- no-unsafe-return (stack / cookies / Authorization / ApiResult.detail)
|
|
28
|
+
- snake_case action keys
|
|
29
|
+
- credentials: "include" on every fetch
|
|
30
|
+
- _gen2 vs _v2 taskKey
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
const BANNED_PROMPT_KEYS = new Set([
|
|
34
|
+
"directive",
|
|
35
|
+
"instructions",
|
|
36
|
+
"guidance",
|
|
37
|
+
"systemprompt",
|
|
38
|
+
"agentinstructions",
|
|
39
|
+
"nextprompt",
|
|
40
|
+
]);
|
|
41
|
+
|
|
42
|
+
const RUNTASK_PROMPT_KEYS = new Set(["prompt", "instructions", "systemprompt"]);
|
|
43
|
+
|
|
44
|
+
const IMPERATIVE_PATTERNS = [
|
|
45
|
+
/\btell the user\b/i,
|
|
46
|
+
/\bask the user\b/i,
|
|
47
|
+
/\bask if they(?:'d| would)\b/i,
|
|
48
|
+
/\bdo not (?:repeat|call|summarize)\b/i,
|
|
49
|
+
/\bdon't (?:repeat|call|summarize)\b/i,
|
|
50
|
+
/\bsummarize the following\b/i,
|
|
51
|
+
/\brun the [\w-]+ action\b/i,
|
|
52
|
+
/\bwalk the user through\b/i,
|
|
53
|
+
/\bproceed to the next step\b/i,
|
|
54
|
+
/\blet the user know\b/i,
|
|
55
|
+
];
|
|
56
|
+
|
|
57
|
+
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
|
|
58
|
+
|
|
59
|
+
const PROMPT_FIX =
|
|
60
|
+
"Put the wording in the action or agent instructions via MCP or Agent Studio; return data only.";
|
|
61
|
+
|
|
62
|
+
function mergeBindings(fn, sourceFile) {
|
|
63
|
+
return new Map([
|
|
64
|
+
...collectModuleBindings(sourceFile),
|
|
65
|
+
...collectConstBindings(fn),
|
|
66
|
+
]);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, origin) {
|
|
70
|
+
if (!obj || !ts.isObjectLiteralExpression(obj)) return;
|
|
71
|
+
|
|
72
|
+
for (const prop of objectProperties(obj)) {
|
|
73
|
+
const name = propertyName(prop);
|
|
74
|
+
const normalized = normalizeKey(name);
|
|
75
|
+
const valueNode = resolveNode(propertyValue(prop), bindings);
|
|
76
|
+
const at = prop.name || prop;
|
|
77
|
+
|
|
78
|
+
if (BANNED_PROMPT_KEYS.has(normalized)) {
|
|
79
|
+
findings.push(
|
|
80
|
+
finding({
|
|
81
|
+
rule: "no-static-agent-prompt",
|
|
82
|
+
severity: "error",
|
|
83
|
+
sourceFile,
|
|
84
|
+
node: at,
|
|
85
|
+
file: relative,
|
|
86
|
+
message: `Do not return '${name}' — that is a second instruction channel besides Agent Studio.`,
|
|
87
|
+
fix: PROMPT_FIX,
|
|
88
|
+
}),
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const text = stringText(valueNode);
|
|
93
|
+
if (text && IMPERATIVE_PATTERNS.some((pattern) => pattern.test(text))) {
|
|
94
|
+
findings.push(
|
|
95
|
+
finding({
|
|
96
|
+
rule: "no-static-agent-prompt",
|
|
97
|
+
severity: "error",
|
|
98
|
+
sourceFile,
|
|
99
|
+
node: at,
|
|
100
|
+
file: relative,
|
|
101
|
+
message: `${origin} sends the copilot operating instructions ('${text.slice(0, 80)}').`,
|
|
102
|
+
fix: PROMPT_FIX,
|
|
103
|
+
}),
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
if (valueNode && ts.isObjectLiteralExpression(valueNode)) {
|
|
108
|
+
inspectReturnedObject(valueNode, bindings, sourceFile, relative, findings, origin);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
|
|
114
|
+
forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn, name) => {
|
|
115
|
+
const bindings = mergeBindings(fn, sourceFile);
|
|
116
|
+
|
|
117
|
+
if (name === "execute" || name === "callback") {
|
|
118
|
+
forEachReturn(fn, (expression) => {
|
|
119
|
+
inspectReturnedObject(
|
|
120
|
+
resolveNode(expression, bindings),
|
|
121
|
+
bindings,
|
|
122
|
+
sourceFile,
|
|
123
|
+
relative,
|
|
124
|
+
findings,
|
|
125
|
+
name,
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (name === "render") {
|
|
131
|
+
const cbName = callbackParamName(fn);
|
|
132
|
+
forEachCallNamed(fn, cbName, (call) => {
|
|
133
|
+
const arg = call.arguments[0];
|
|
134
|
+
if (!arg) return;
|
|
135
|
+
inspectReturnedObject(
|
|
136
|
+
resolveNode(arg, bindings),
|
|
137
|
+
bindings,
|
|
138
|
+
sourceFile,
|
|
139
|
+
relative,
|
|
140
|
+
findings,
|
|
141
|
+
"callback",
|
|
142
|
+
);
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function inspectRunTaskData(dataNode, bindings, sourceFile, relative, findings, call) {
|
|
149
|
+
const resolved = resolveNode(dataNode, bindings);
|
|
150
|
+
if (!resolved || !ts.isObjectLiteralExpression(resolved)) return;
|
|
151
|
+
|
|
152
|
+
for (const prop of objectProperties(resolved)) {
|
|
153
|
+
const name = propertyName(prop);
|
|
154
|
+
if (!RUNTASK_PROMPT_KEYS.has(normalizeKey(name))) continue;
|
|
155
|
+
const value = resolveNode(propertyValue(prop), bindings);
|
|
156
|
+
if (stringText(value) === null) continue;
|
|
157
|
+
findings.push(
|
|
158
|
+
finding({
|
|
159
|
+
rule: "no-runtask-prompt",
|
|
160
|
+
severity: "error",
|
|
161
|
+
sourceFile,
|
|
162
|
+
node: prop.name || call,
|
|
163
|
+
file: relative,
|
|
164
|
+
message: `runTask data.${name} is a prompt. Task-agent instructions belong in Agent Studio.`,
|
|
165
|
+
fix: "Keep task-agent instructions in Agent Studio; pass only extracted facts in data.",
|
|
166
|
+
}),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function ruleNoRunTaskPrompt(sourceFile, relative, findings) {
|
|
172
|
+
const moduleBindings = collectModuleBindings(sourceFile);
|
|
173
|
+
|
|
174
|
+
function walk(node, fnBindings) {
|
|
175
|
+
if (isFunctionLike(node) && node !== sourceFile) {
|
|
176
|
+
const nested = mergeBindings(node, sourceFile);
|
|
177
|
+
ts.forEachChild(node, (child) => walk(child, nested));
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
if (ts.isCallExpression(node) && ts.isPropertyAccessExpression(node.expression)) {
|
|
182
|
+
if (node.expression.name.text === "runTask") {
|
|
183
|
+
const arg = node.arguments[0];
|
|
184
|
+
const bindings = fnBindings || moduleBindings;
|
|
185
|
+
const config = resolveNode(arg, bindings);
|
|
186
|
+
if (config && ts.isObjectLiteralExpression(config)) {
|
|
187
|
+
for (const prop of objectProperties(config)) {
|
|
188
|
+
if (propertyName(prop) !== "data") continue;
|
|
189
|
+
inspectRunTaskData(propertyValue(prop), bindings, sourceFile, relative, findings, node);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
ts.forEachChild(node, (child) => walk(child, fnBindings));
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
walk(sourceFile, moduleBindings);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function reportEmoji(node, sourceFile, relative, findings) {
|
|
202
|
+
const text = stringText(node);
|
|
203
|
+
if (!text || !EMOJI_PATTERN.test(text)) return;
|
|
204
|
+
findings.push(
|
|
205
|
+
finding({
|
|
206
|
+
rule: "no-emoji",
|
|
207
|
+
severity: "warning",
|
|
208
|
+
sourceFile,
|
|
209
|
+
node,
|
|
210
|
+
file: relative,
|
|
211
|
+
message: "Emoji in handler or widget copy reads as off-brand in a product UI that has none.",
|
|
212
|
+
fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the MCP cannot write that field, so each new action's Studio instructions must carry the line too.",
|
|
213
|
+
}),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
export function ruleNoEmoji(sourceFile, relative, findings, { wholeFile = false } = {}) {
|
|
218
|
+
if (wholeFile) {
|
|
219
|
+
forEachStringLiteral(sourceFile, (node) => {
|
|
220
|
+
if (ts.isImportDeclaration(node.parent) || ts.isExportDeclaration(node.parent)) return;
|
|
221
|
+
reportEmoji(node, sourceFile, relative, findings);
|
|
222
|
+
});
|
|
223
|
+
return;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
forEachNamedFunction(sourceFile, ["execute", "render", "callback"], (fn) => {
|
|
227
|
+
const bindings = mergeBindings(fn, sourceFile);
|
|
228
|
+
forEachStringLiteral(
|
|
229
|
+
fn,
|
|
230
|
+
(node) => reportEmoji(node, sourceFile, relative, findings),
|
|
231
|
+
{ skipNestedFunctions: true, root: fn },
|
|
232
|
+
);
|
|
233
|
+
|
|
234
|
+
function walk(current) {
|
|
235
|
+
if (current !== fn && isFunctionLike(current)) return;
|
|
236
|
+
if (ts.isIdentifier(current)) {
|
|
237
|
+
const resolved = resolveNode(current, bindings);
|
|
238
|
+
if (
|
|
239
|
+
resolved &&
|
|
240
|
+
resolved !== current &&
|
|
241
|
+
(resolved.getStart(sourceFile) < fn.getStart(sourceFile) ||
|
|
242
|
+
resolved.getEnd() > fn.getEnd())
|
|
243
|
+
) {
|
|
244
|
+
reportEmoji(resolved, sourceFile, relative, findings);
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
ts.forEachChild(current, walk);
|
|
248
|
+
}
|
|
249
|
+
walk(fn);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function exportedBindings(sourceFile) {
|
|
254
|
+
const exports = [];
|
|
255
|
+
|
|
256
|
+
function objectHasExecute(node) {
|
|
257
|
+
if (!node || !ts.isObjectLiteralExpression(node)) return false;
|
|
258
|
+
return objectProperties(node).some((prop) => propertyName(prop) === "execute");
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
for (const stmt of sourceFile.statements) {
|
|
262
|
+
if (ts.isVariableStatement(stmt) && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
263
|
+
for (const decl of stmt.declarationList.declarations) {
|
|
264
|
+
if (!ts.isIdentifier(decl.name)) continue;
|
|
265
|
+
const init = decl.initializer;
|
|
266
|
+
exports.push({
|
|
267
|
+
name: decl.name.text,
|
|
268
|
+
node: decl.name,
|
|
269
|
+
hasExecute: objectHasExecute(init),
|
|
270
|
+
isFunction: isFunctionLike(init),
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
if (ts.isFunctionDeclaration(stmt) && stmt.name && stmt.modifiers?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword)) {
|
|
275
|
+
exports.push({
|
|
276
|
+
name: stmt.name.text,
|
|
277
|
+
node: stmt.name,
|
|
278
|
+
hasExecute: false,
|
|
279
|
+
isFunction: true,
|
|
280
|
+
});
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return exports;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
function collectImports(sourceFile, filePath) {
|
|
287
|
+
const map = new Map();
|
|
288
|
+
for (const stmt of sourceFile.statements) {
|
|
289
|
+
if (!ts.isImportDeclaration(stmt) || !stmt.importClause) continue;
|
|
290
|
+
if (!ts.isStringLiteral(stmt.moduleSpecifier)) continue;
|
|
291
|
+
const resolved = resolveModulePath(filePath, stmt.moduleSpecifier.text);
|
|
292
|
+
if (!resolved) continue;
|
|
293
|
+
const clause = stmt.importClause;
|
|
294
|
+
if (clause.namedBindings && ts.isNamedImports(clause.namedBindings)) {
|
|
295
|
+
for (const spec of clause.namedBindings.elements) {
|
|
296
|
+
const imported = (spec.propertyName || spec.name).text;
|
|
297
|
+
map.set(spec.name.text, { file: resolved, imported });
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
if (clause.name) {
|
|
301
|
+
map.set(clause.name.text, { file: resolved, imported: "default" });
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
return map;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function findRegistryObject(sourceFile) {
|
|
308
|
+
let found = null;
|
|
309
|
+
|
|
310
|
+
function consider(node) {
|
|
311
|
+
if (node && ts.isObjectLiteralExpression(node)) found = found || node;
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
function walk(node) {
|
|
315
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === "actions") {
|
|
316
|
+
consider(node.initializer);
|
|
317
|
+
}
|
|
318
|
+
if (ts.isBinaryExpression(node) && node.operatorToken.kind === ts.SyntaxKind.EqualsToken) {
|
|
319
|
+
const left = node.left.getText(sourceFile);
|
|
320
|
+
if (left.includes("__FOLDSPACE_REMOTE_ACTIONS__") || left.endsWith(".actions")) {
|
|
321
|
+
if (ts.isIdentifier(node.right) && node.right.text === "actions") {
|
|
322
|
+
// resolved via variable declaration
|
|
323
|
+
} else {
|
|
324
|
+
consider(node.right);
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
ts.forEachChild(node, walk);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
walk(sourceFile);
|
|
332
|
+
return found;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, projectDir, findings }) {
|
|
336
|
+
const relativeIndex = path.relative(projectDir, indexFile);
|
|
337
|
+
const registry = findRegistryObject(indexSource);
|
|
338
|
+
const imports = collectImports(indexSource, indexFile);
|
|
339
|
+
const seenKeys = new Map();
|
|
340
|
+
const registeredNames = new Set();
|
|
341
|
+
|
|
342
|
+
if (registry) {
|
|
343
|
+
for (const prop of objectProperties(registry)) {
|
|
344
|
+
const key = propertyName(prop);
|
|
345
|
+
if (!key) continue;
|
|
346
|
+
if (seenKeys.has(key)) {
|
|
347
|
+
findings.push(
|
|
348
|
+
finding({
|
|
349
|
+
rule: "registry-integrity",
|
|
350
|
+
severity: "warning",
|
|
351
|
+
sourceFile: indexSource,
|
|
352
|
+
node: prop.name || prop,
|
|
353
|
+
file: relativeIndex,
|
|
354
|
+
message: `Duplicate registry key '${key}'. The second entry silently wins.`,
|
|
355
|
+
fix: "Give each registered action a unique key that matches Agent Studio.",
|
|
356
|
+
}),
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
seenKeys.set(key, prop);
|
|
360
|
+
|
|
361
|
+
const value = propertyValue(prop);
|
|
362
|
+
if (isFunctionLike(value)) {
|
|
363
|
+
findings.push(
|
|
364
|
+
finding({
|
|
365
|
+
rule: "registry-integrity",
|
|
366
|
+
severity: "warning",
|
|
367
|
+
sourceFile: indexSource,
|
|
368
|
+
node: prop.name || prop,
|
|
369
|
+
file: relativeIndex,
|
|
370
|
+
message: `Registry entry '${key}' is a function. The SDK expects { execute }.`,
|
|
371
|
+
fix: "Register an object with an execute function.",
|
|
372
|
+
}),
|
|
373
|
+
);
|
|
374
|
+
continue;
|
|
375
|
+
}
|
|
376
|
+
if (ts.isObjectLiteralExpression(value)) {
|
|
377
|
+
const hasExecute = objectProperties(value).some((entry) => propertyName(entry) === "execute");
|
|
378
|
+
if (!hasExecute) {
|
|
379
|
+
findings.push(
|
|
380
|
+
finding({
|
|
381
|
+
rule: "registry-integrity",
|
|
382
|
+
severity: "warning",
|
|
383
|
+
sourceFile: indexSource,
|
|
384
|
+
node: prop.name || prop,
|
|
385
|
+
file: relativeIndex,
|
|
386
|
+
message: `Registry entry '${key}' is an object without execute.`,
|
|
387
|
+
fix: "Register an object with an execute function.",
|
|
388
|
+
}),
|
|
389
|
+
);
|
|
390
|
+
}
|
|
391
|
+
continue;
|
|
392
|
+
}
|
|
393
|
+
if (ts.isIdentifier(value)) {
|
|
394
|
+
registeredNames.add(value.text);
|
|
395
|
+
const imported = imports.get(value.text);
|
|
396
|
+
if (imported) {
|
|
397
|
+
const exported = exportedBindings(parseFile(imported.file)).find(
|
|
398
|
+
(item) => item.name === imported.imported || item.name === value.text,
|
|
399
|
+
);
|
|
400
|
+
if (exported?.isFunction && !exported.hasExecute) {
|
|
401
|
+
findings.push(
|
|
402
|
+
finding({
|
|
403
|
+
rule: "registry-integrity",
|
|
404
|
+
severity: "warning",
|
|
405
|
+
sourceFile: indexSource,
|
|
406
|
+
node: prop.name || prop,
|
|
407
|
+
file: relativeIndex,
|
|
408
|
+
message: `Registry entry '${key}' points at a bare function, not { execute }.`,
|
|
409
|
+
fix: "Export and register an object with an execute function.",
|
|
410
|
+
}),
|
|
411
|
+
);
|
|
412
|
+
} else if (exported && !exported.hasExecute && !exported.isFunction) {
|
|
413
|
+
findings.push(
|
|
414
|
+
finding({
|
|
415
|
+
rule: "registry-integrity",
|
|
416
|
+
severity: "warning",
|
|
417
|
+
sourceFile: indexSource,
|
|
418
|
+
node: prop.name || prop,
|
|
419
|
+
file: relativeIndex,
|
|
420
|
+
message: `Registry entry '${key}' points at an object without execute.`,
|
|
421
|
+
fix: "Export and register an object with an execute function.",
|
|
422
|
+
}),
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
for (const filePath of actionFiles) {
|
|
431
|
+
if (filePath === indexFile) continue;
|
|
432
|
+
if (shouldSkipActionFile(filePath)) continue;
|
|
433
|
+
const sourceFile = parseFile(filePath);
|
|
434
|
+
const relative = path.relative(projectDir, filePath);
|
|
435
|
+
for (const exported of exportedBindings(sourceFile)) {
|
|
436
|
+
if (!exported.hasExecute) continue;
|
|
437
|
+
if (registeredNames.has(exported.name) || seenKeys.has(exported.name)) continue;
|
|
438
|
+
findings.push(
|
|
439
|
+
finding({
|
|
440
|
+
rule: "registry-integrity",
|
|
441
|
+
severity: "warning",
|
|
442
|
+
sourceFile,
|
|
443
|
+
node: exported.node,
|
|
444
|
+
file: relative,
|
|
445
|
+
message: `'${exported.name}' exports { execute } but is not registered in agent/actions/index.ts.`,
|
|
446
|
+
fix: "Import it and add it to the actions object. An unregistered handler never fires.",
|
|
447
|
+
}),
|
|
448
|
+
);
|
|
449
|
+
}
|
|
450
|
+
}
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
export function lintActionFile(filePath, relative, findings) {
|
|
454
|
+
if (shouldSkipActionFile(filePath)) return;
|
|
455
|
+
const sourceFile = parseFile(filePath);
|
|
456
|
+
ruleNoStaticAgentPrompt(sourceFile, relative, findings);
|
|
457
|
+
ruleNoRunTaskPrompt(sourceFile, relative, findings);
|
|
458
|
+
ruleNoEmoji(sourceFile, relative, findings);
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
export function lintViewsFile(filePath, relative, findings) {
|
|
462
|
+
const sourceFile = parseFile(filePath);
|
|
463
|
+
ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
|
|
464
|
+
}
|
package/src/lint/ts.mjs
ADDED
|
@@ -29,11 +29,13 @@ from actions that read or mutate a selected resource.
|
|
|
29
29
|
3. Capture at least one real HTTP 200 for the data the action needs. Use
|
|
30
30
|
chrome-devtools MCP against the inject Chrome, or a page-context `fetch`,
|
|
31
31
|
**before** `attach` owns the debug port. If there is no 200,
|
|
32
|
-
pivot; do not create Foldspace resources.
|
|
32
|
+
pivot; do not create Foldspace resources. Write what you establish into
|
|
33
|
+
`docs/app-profile.md` with **how you know it**. Do not promote an assumption
|
|
34
|
+
by quoting that file.
|
|
33
35
|
4. Create action metadata as a draft. `generate_action_handler` works from the
|
|
34
36
|
draft schema; do not publish yet.
|
|
35
37
|
5. Implement the handler using the observed request and response shapes.
|
|
36
|
-
6. Register the handler in `agent/actions/index.ts` and build.
|
|
38
|
+
6. Register the handler in `agent/actions/index.ts` and build (`foldspace build` lints first).
|
|
37
39
|
7. Run `npx foldspace attach --daemon` (add `--bootstrap` or `--replace` when
|
|
38
40
|
the page requires it). An empty local registry is valid if you only want to
|
|
39
41
|
see how the agent works.
|
|
@@ -52,7 +54,36 @@ Actions execute in the user's signed-in browser session.
|
|
|
52
54
|
- Verify that the user is signed in before observing a workflow.
|
|
53
55
|
- Do not substitute a public developer API when the browser session is missing;
|
|
54
56
|
ask the user to sign in.
|
|
55
|
-
- Validate parameters and return sanitized errors.
|
|
57
|
+
- Validate parameters and return sanitized errors. Return **data only** — never
|
|
58
|
+
`directive`, `instructions`, or a paragraph telling the copilot what to say.
|
|
59
|
+
Action and agent instructions live in Agent Studio / MCP.
|
|
60
|
+
- Actions that return data the user will inspect should include a `render`
|
|
61
|
+
function for in-chat UI (a chatterblock). If it makes more sense to output the data
|
|
62
|
+
in a UI component instead of text then consider using render to show a component.
|
|
63
|
+
- `render` receives **`execute`'s return value**, not the action's input params.
|
|
64
|
+
Returning anything that lacks the ids the card needs is why widgets pass in
|
|
65
|
+
isolation and fail in the real chat.
|
|
66
|
+
- `runAction` refuses render actions (`cannot be executed silently`). Driving
|
|
67
|
+
`render()` yourself never runs `execute()`, so it does not test that contract.
|
|
68
|
+
- Check https://docs.foldspace.ai/guides/in-chat-ui/ for more information
|
|
69
|
+
|
|
70
|
+
## Task agents
|
|
71
|
+
|
|
72
|
+
Use a Task Agent when the handler needs a one-time LLM subtask that
|
|
73
|
+
deterministic code cannot do well: extraction, summarization,
|
|
74
|
+
classification, normalization, enrichment, or generation.
|
|
75
|
+
|
|
76
|
+
Do not use a Task Agent for API calls, CRUD, routing, or parsing a
|
|
77
|
+
known response shape. Those stay in `execute`.
|
|
78
|
+
|
|
79
|
+
Task agents are created in Agent Studio, not in this repo. Ask before
|
|
80
|
+
creating or publishing one. Call a published task agent from the
|
|
81
|
+
handler with `runTask({ taskKey, data })` (if not published the runTask won't work).
|
|
82
|
+
`data` carries extracted facts only — not `prompt` / `instructions` strings.
|
|
83
|
+
Prefer JSON output when the handler must consume the result.
|
|
84
|
+
|
|
85
|
+
See https://docs.foldspace.ai/user-guides/task-agents/ and
|
|
86
|
+
https://docs.foldspace.ai/reference/task-agent-api/
|
|
56
87
|
|
|
57
88
|
## Local harness loop
|
|
58
89
|
|
|
@@ -93,41 +124,58 @@ execute/render lines; do not invoke the handler directly. Those lines record
|
|
|
93
124
|
names, statuses, durations, and parameter keys only — not results or error
|
|
94
125
|
bodies.
|
|
95
126
|
|
|
127
|
+
## Product defaults
|
|
128
|
+
|
|
129
|
+
These are not Joist-specific. Lint can catch some of them in handler code;
|
|
130
|
+
Agent Studio copy is on you.
|
|
131
|
+
|
|
132
|
+
- **No emoji** in copilot replies, cards, or handler strings. Put that in the
|
|
133
|
+
agent's Behavior instructions (MCP cannot write that field, so each new
|
|
134
|
+
action's Studio `instructions` must carry the line too).
|
|
135
|
+
- **Never send the user out of the host app.** No "open in <product>"
|
|
136
|
+
button and no pasted URLs — navigate with a navigation route, same tab.
|
|
137
|
+
- **Check row counts before choosing the experience.** On an empty account the
|
|
138
|
+
useful first action is one that creates data.
|
|
139
|
+
|
|
96
140
|
## Verification gates
|
|
97
141
|
|
|
98
142
|
Do not report success without all six:
|
|
99
143
|
|
|
100
|
-
1. TypeScript compiles with `
|
|
101
|
-
|
|
102
|
-
|
|
144
|
+
1. TypeScript compiles with `npm run typecheck` (`tsc --noEmit -p tsconfig.json`).
|
|
145
|
+
Do not run `npx tsc` — that can install the wrong package.
|
|
146
|
+
2. `foldspace lint` reports no errors (`foldspace build` runs this first).
|
|
147
|
+
3. The expected handler appears in `dist/index.js`.
|
|
148
|
+
4. The browser reports `inspect_registration:registration_ok`. For a named
|
|
103
149
|
action, the captured registry includes that handler.
|
|
104
|
-
|
|
150
|
+
5. The action behaves correctly against the real target workflow. Confirm
|
|
105
151
|
`[actions] local-handler:execute` in the attach log after the user exercises
|
|
106
|
-
the visible agent.
|
|
107
|
-
|
|
152
|
+
the visible agent. For a widget, that log must include `local-handler:render`
|
|
153
|
+
from a real chat turn — not a hand-built `render()` call.
|
|
108
154
|
6. Browser evidence came from the live target, not from hand-authored examples.
|
|
155
|
+
Neighbour fixtures still pass when they exist.
|
|
109
156
|
|
|
110
157
|
## Layout
|
|
111
158
|
|
|
112
|
-
- `agent/actions/` — one handler per action
|
|
159
|
+
- `agent/actions/` — one handler per action (`execute`, optional `render`),
|
|
160
|
+
registered in `index.ts`
|
|
113
161
|
- `agent/api/` — one HTTP helper per endpoint
|
|
114
162
|
- `agent/constants.ts` — agent, product, and domain identifiers
|
|
115
163
|
- `agent/utils.ts` — Foldspace agent lookup
|
|
116
164
|
- `foldspace.dev.json` — local harness target configuration
|
|
117
|
-
- `docs
|
|
165
|
+
- `docs/app-profile.md` — what is known about this app, and how it was established
|
|
118
166
|
|
|
119
167
|
Do not introduce another bundler or bundle format.
|
|
120
168
|
|
|
121
169
|
## Agent learnings
|
|
122
170
|
|
|
123
|
-
|
|
124
|
-
not
|
|
171
|
+
`docs/app-profile.md` is the durable record of this app. Fill it as you probe,
|
|
172
|
+
not afterwards. Other notes in `docs/` are fine for session-specific lessons.
|
|
125
173
|
|
|
126
|
-
- Before similar work, read any
|
|
127
|
-
- After non-obvious discoveries, add
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
174
|
+
- Before similar work, read `docs/app-profile.md` and any other notes in `docs/`.
|
|
175
|
+
- After non-obvious discoveries, add them to the profile (or a short extra
|
|
176
|
+
note) covering verified gotchas, failed approaches, and how they were
|
|
177
|
+
established.
|
|
178
|
+
- Keep notes concise and evidence-based.
|
|
131
179
|
- Do not store secrets, cookies, HAR files, browser storage, or other transient
|
|
132
180
|
session data in `docs/`.
|
|
133
181
|
|
|
@@ -15,4 +15,19 @@ export const example_action = {
|
|
|
15
15
|
return { ok: false, error: detail };
|
|
16
16
|
}
|
|
17
17
|
},
|
|
18
|
+
|
|
19
|
+
// Optional chatterblock: uncomment to render in-chat UI instead of
|
|
20
|
+
// returning text-only data. See https://docs.foldspace.ai/guides/in-chat-ui/ for more information. Set
|
|
21
|
+
// awaitUserInput: true for forms and confirmations.
|
|
22
|
+
//
|
|
23
|
+
// render: (data, host, header, callback, cancel) => {
|
|
24
|
+
// host.replaceChildren();
|
|
25
|
+
// if (data?.ok === false) {
|
|
26
|
+
// host.textContent = data.error;
|
|
27
|
+
// return;
|
|
28
|
+
// }
|
|
29
|
+
// const card = document.createElement("div");
|
|
30
|
+
// card.textContent = data.echo;
|
|
31
|
+
// host.append(card);
|
|
32
|
+
// },
|
|
18
33
|
};
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
# {{DISPLAY_NAME}} — app profile
|
|
2
|
+
|
|
3
|
+
Durable facts about `{{APP_DOMAIN}}` and its API, established by observing the
|
|
4
|
+
running app on **<date>** from a logged-in session.
|
|
5
|
+
|
|
6
|
+
**Everything here should be observed.** Each entry says how. Where something is
|
|
7
|
+
only believed, say so in the same breath — do not promote an assumption by
|
|
8
|
+
quoting this file. Re-run the probe before trusting an entry that matters.
|
|
9
|
+
|
|
10
|
+
Do not store secrets, cookies, HAR files, tokens, or session data here. Record
|
|
11
|
+
*where* the token lives and *what a redacted value looks like*, never the value.
|
|
12
|
+
|
|
13
|
+
How to re-run any probe below:
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npm run inject
|
|
17
|
+
# Sign in. Capture from chrome-devtools MCP, or a page-context fetch,
|
|
18
|
+
# before foldspace attach owns the debug port.
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Start URL: `{{START_URL}}`
|
|
22
|
+
|
|
23
|
+
---
|
|
24
|
+
|
|
25
|
+
## Auth
|
|
26
|
+
|
|
27
|
+
- Where the token lives (cookie name, `localStorage` key, custom header), and
|
|
28
|
+
how you know — wrapping XHR/`fetch` on the live page beats guessing.
|
|
29
|
+
- Its lifetime, measured. (One app's expired in ~52 minutes and killed three
|
|
30
|
+
probing sessions.)
|
|
31
|
+
- What an expired or refused response looks like, verbatim. A CORS-less refusal
|
|
32
|
+
often surfaces as `TypeError: Failed to fetch` with no status — that is not
|
|
33
|
+
"host down".
|
|
34
|
+
- Anything inherited but never exercised — mark it **assumed**.
|
|
35
|
+
|
|
36
|
+
## API base
|
|
37
|
+
|
|
38
|
+
- The exact base, including any explicit port. Note if omitting `:443` fails
|
|
39
|
+
CORS, and whether the API is a **different host** from the app (a relative
|
|
40
|
+
path then returns the HTML shell).
|
|
41
|
+
|
|
42
|
+
## Account contents
|
|
43
|
+
|
|
44
|
+
Row counts from the signed-in account **before** choosing the experience. On an
|
|
45
|
+
empty account the only useful first action is one that creates data.
|
|
46
|
+
|
|
47
|
+
| Resource | Count | How established |
|
|
48
|
+
|---|---|---|
|
|
49
|
+
| | | |
|
|
50
|
+
|
|
51
|
+
## Endpoints called and observed
|
|
52
|
+
|
|
53
|
+
For each: the path, the **envelope**, the row keys you actually read, and how
|
|
54
|
+
you established them.
|
|
55
|
+
|
|
56
|
+
> Watch for endpoints on the same host that wrap the same resource
|
|
57
|
+
> differently (`{data}` vs `{contacts}` vs `{items}`). Reaching for the wrong
|
|
58
|
+
> key yields `undefined`, not an empty array — it throws downstream rather
|
|
59
|
+
> than returning nothing.
|
|
60
|
+
|
|
61
|
+
| Path | Method | Envelope | Verified |
|
|
62
|
+
|---|---|---|---|
|
|
63
|
+
| | | | |
|
|
64
|
+
|
|
65
|
+
## Verified behaviour
|
|
66
|
+
|
|
67
|
+
A table of probe → result. Paging, search, sort, filters. One row per call you
|
|
68
|
+
actually made.
|
|
69
|
+
|
|
70
|
+
**What the search matches.** Test prefix, mid-word, case, digits, across spaces,
|
|
71
|
+
and one transposed letter. Most app search is a plain case-insensitive substring
|
|
72
|
+
with no tolerance.
|
|
73
|
+
|
|
74
|
+
> A probe using values no record has proves nothing. If a filter returns zero,
|
|
75
|
+
> confirm a matching record exists before concluding anything.
|
|
76
|
+
|
|
77
|
+
| Probe | Result |
|
|
78
|
+
|---|---|
|
|
79
|
+
| | |
|
|
80
|
+
|
|
81
|
+
## Vocabulary observed
|
|
82
|
+
|
|
83
|
+
Real enum values seen on live records — not the generic ones you would expect.
|
|
84
|
+
Mark which you verified and which came from reading the app bundle.
|
|
85
|
+
|
|
86
|
+
## Identifiers
|
|
87
|
+
|
|
88
|
+
| Identifier | Produced by | Example |
|
|
89
|
+
|---|---|---|
|
|
90
|
+
| | | |
|
|
91
|
+
|
|
92
|
+
## Brand (sampled, not guessed)
|
|
93
|
+
|
|
94
|
+
Colour, typeface, greys, base size and radii from the running app's computed
|
|
95
|
+
styles — not from a screenshot. Re-sample per tenant; do not carry values
|
|
96
|
+
across.
|
|
97
|
+
|
|
98
|
+
## Endpoints seen in the bundle but NOT called
|
|
99
|
+
|
|
100
|
+
Listed so the next person knows they exist **and knows they are unverified**.
|
|
101
|
+
|
|
102
|
+
## Failures observed
|
|
103
|
+
|
|
104
|
+
| Call | Result | What it means |
|
|
105
|
+
|---|---|---|
|
|
106
|
+
| | | |
|
|
107
|
+
|
|
108
|
+
Record inconclusive results as inconclusive. A 401 on a dead session says
|
|
109
|
+
nothing about whether the endpoint works.
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
"scripts": {
|
|
8
8
|
"dev": "foldspace build --watch",
|
|
9
9
|
"build": "foldspace build",
|
|
10
|
+
"lint": "foldspace lint",
|
|
11
|
+
"typecheck": "tsc --noEmit -p tsconfig.json",
|
|
10
12
|
"inject": "foldspace inject",
|
|
11
13
|
"attach": "foldspace attach",
|
|
12
14
|
"attach:daemon": "foldspace attach --daemon"
|