@foldspace_npm/harness 0.1.7 → 0.1.9
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 +38 -8
- package/bin/build.ts +20 -0
- package/bin/lint.mjs +9 -0
- package/package.json +10 -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/src/runtime/agent.ts +66 -0
- package/src/runtime/config.ts +78 -0
- package/src/runtime/http.ts +373 -0
- package/src/runtime/index.ts +36 -0
- package/src/runtime/match.ts +87 -0
- package/src/runtime/render.ts +97 -0
- package/templates/agent-starter/CLAUDE.md +82 -21
- package/templates/agent-starter/agent/actions/_example.ts +20 -25
- package/templates/agent-starter/agent/actions/index.ts +9 -1
- package/templates/agent-starter/agent/constants.ts +25 -0
- package/templates/agent-starter/agent/utils.ts +59 -12
- package/templates/agent-starter/docs/app-profile.md +109 -0
- package/templates/agent-starter/package.json +2 -0
- package/templates/agent-starter/tsconfig.json +1 -0
package/README.md
CHANGED
|
@@ -27,6 +27,7 @@ constraint this package exists to protect.
|
|
|
27
27
|
|
|
28
28
|
- `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
|
|
29
29
|
nothing more. The actions built for Figma run unmodified in either track.
|
|
30
|
+
- `agent/utils.ts` — configures and re-exports `@foldspace_npm/harness/runtime`
|
|
30
31
|
- `foldspace build` — esbuild → `dist/index.js`
|
|
31
32
|
- `foldspace deploy` — publish to `agent/actions/<env>/<productId>/<agentApiName>`
|
|
32
33
|
- fixtures and tests
|
|
@@ -93,7 +94,7 @@ npx --yes @foldspace_npm/harness init
|
|
|
93
94
|
Non-interactive / CI form:
|
|
94
95
|
|
|
95
96
|
```bash
|
|
96
|
-
npx --yes @foldspace_npm/harness init
|
|
97
|
+
npx --yes @foldspace_npm/harness init foldspace-agent \
|
|
97
98
|
--product-id FR8JUQZAQRZB \
|
|
98
99
|
--agent-key my-agent \
|
|
99
100
|
--domain app.example.com \
|
|
@@ -103,24 +104,25 @@ npx --yes @foldspace_npm/harness init my-agent \
|
|
|
103
104
|
When running directly from a harness checkout during development:
|
|
104
105
|
|
|
105
106
|
```bash
|
|
106
|
-
node bin/cli.mjs init ../
|
|
107
|
+
node bin/cli.mjs init ../foldspace-agent \
|
|
107
108
|
--product-id FR8JUQZAQRZB \
|
|
108
109
|
--agent-key my-agent \
|
|
109
110
|
--domain app.example.com
|
|
110
111
|
```
|
|
111
112
|
|
|
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
|
-
|
|
113
|
+
The Agent Key is the value shown in Agent Studio, such as `my-agent`. It is
|
|
114
|
+
not the sidecar directory name. `--agent-api-name` remains a deprecated alias
|
|
115
|
+
for `--agent-key`. `--name` is optional and defaults to the target directory
|
|
116
|
+
name (`foldspace-agent` unless you pass a directory). The product ID must be
|
|
117
|
+
the bare ID, not the `EU-…-1-1` SDK loader key. The domain may be a hostname
|
|
118
|
+
or an HTTP(S) URL without a port or path.
|
|
117
119
|
|
|
118
120
|
For safety, `init` requires a target path that does not exist. It does not
|
|
119
121
|
initialize Git or overwrite files. On a TTY it can offer
|
|
120
122
|
`npm install --ignore-scripts` after scaffolding. After creation:
|
|
121
123
|
|
|
122
124
|
```bash
|
|
123
|
-
cd
|
|
125
|
+
cd foldspace-agent
|
|
124
126
|
npm install --ignore-scripts # if you skipped the install prompt
|
|
125
127
|
npm run build
|
|
126
128
|
npm run inject
|
|
@@ -130,6 +132,34 @@ npm run attach
|
|
|
130
132
|
The generated npm scripts intentionally remain the normal project interface;
|
|
131
133
|
`foldspace init` is the one-time project creation command.
|
|
132
134
|
|
|
135
|
+
### Lint handlers before they ship
|
|
136
|
+
|
|
137
|
+
`foldspace build` runs `foldspace lint` first. Errors skip bundling; warnings
|
|
138
|
+
print and still produce `dist/index.js`.
|
|
139
|
+
|
|
140
|
+
```bash
|
|
141
|
+
foldspace lint
|
|
142
|
+
foldspace lint --json
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
v1 flags:
|
|
146
|
+
|
|
147
|
+
- **error** `no-static-agent-prompt` — `execute` / chatterblock `callback` returning copilot instructions (`directive`, “tell the user”, …)
|
|
148
|
+
- **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
|
|
149
|
+
- **warning** `no-emoji` — emoji in handler or `agent/views` string literals
|
|
150
|
+
- **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
|
|
151
|
+
|
|
152
|
+
Put action instructions in Agent Studio or MCP. `foldspace lint` cannot see that copy.
|
|
153
|
+
|
|
154
|
+
### Runtime helpers
|
|
155
|
+
|
|
156
|
+
Tenant actions import from `agent/utils.ts`, which configures and re-exports
|
|
157
|
+
`@foldspace_npm/harness/runtime`. That code is bundled into `dist/index.js` and
|
|
158
|
+
runs in the customer's page — it is not a CLI command.
|
|
159
|
+
|
|
160
|
+
`API_BASE` and `AUTH_SOURCE` stamp empty. Fill them from a captured XHR, not a
|
|
161
|
+
guess. Custom auth headers stay a tenant override of one function in `utils.ts`.
|
|
162
|
+
|
|
133
163
|
### Choose an attach mode
|
|
134
164
|
|
|
135
165
|
- **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,12 +1,19 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@foldspace_npm/harness",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.9",
|
|
4
4
|
"description": "Build and verify portable Foldspace action artifacts against a live app.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
7
7
|
"foldspace": "bin/cli.mjs",
|
|
8
8
|
"harness": "bin/cli.mjs"
|
|
9
9
|
},
|
|
10
|
+
"exports": {
|
|
11
|
+
"./runtime": {
|
|
12
|
+
"types": "./src/runtime/index.ts",
|
|
13
|
+
"default": "./src/runtime/index.ts"
|
|
14
|
+
},
|
|
15
|
+
"./package.json": "./package.json"
|
|
16
|
+
},
|
|
10
17
|
"scripts": {
|
|
11
18
|
"test": "node --test test/*.test.mjs"
|
|
12
19
|
},
|
|
@@ -18,7 +25,8 @@
|
|
|
18
25
|
],
|
|
19
26
|
"dependencies": {
|
|
20
27
|
"esbuild": "^0.20.0",
|
|
21
|
-
"tsx": "^4.7.0"
|
|
28
|
+
"tsx": "^4.7.0",
|
|
29
|
+
"typescript": "^5.3.3"
|
|
22
30
|
},
|
|
23
31
|
"engines": {
|
|
24
32
|
"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
|
+
}
|