@foldspace_npm/harness 0.1.27 → 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/CLAUDE.md +301 -188
- package/README.md +54 -36
- package/bin/ask.mjs +9 -7
- package/bin/attach.mjs +80 -51
- package/bin/badge.mjs +28 -8
- package/bin/build.ts +19 -6
- package/bin/cli.mjs +23 -6
- package/bin/inject.mjs +3 -4
- package/bin/observe.mjs +22 -6
- package/bin/published.mjs +1 -1
- package/package.json +6 -2
- package/recipes/INDEX.md +13 -8
- package/recipes/README.md +30 -7
- package/recipes/account-overview/README.md +6 -6
- package/recipes/account-overview/agent/actions/show_account_overview.ts +11 -16
- package/recipes/account-overview/agent/views/brand.ts +23 -14
- package/recipes/account-overview/agent/views/overview.ts +13 -17
- package/recipes/bottom-bar/README.md +12 -9
- package/recipes/bottom-bar/agent/bottomBar.ts +14 -12
- package/recipes/create-a-record/README.md +97 -0
- package/recipes/create-a-record/agent/actions/create_project.ts +26 -0
- package/recipes/create-a-record/agent/api/projects.ts +62 -0
- package/recipes/create-a-record/agent/projects.ts +145 -0
- package/recipes/create-a-record/agent/views/approval.ts +181 -0
- package/recipes/create-a-record/agent/views/brand.ts +35 -0
- package/recipes/create-a-record/agent/views/fields.ts +252 -0
- package/recipes/create-a-record/agent/views/project-card.ts +253 -0
- package/recipes/create-a-record/agent/views/steps.ts +92 -0
- package/recipes/create-a-record/fixtures/owners.ok.json +9 -0
- package/recipes/create-a-record/fixtures/project.created.json +1 -0
- package/recipes/create-a-record/fixtures/project.rejected.json +1 -0
- package/recipes/create-a-record/recipe.json +13 -0
- package/recipes/create-a-record/variants/actions/create_project.steps.ts +160 -0
- package/recipes/find-by-name/README.md +3 -1
- package/recipes/find-by-name/agent/actions/find_project_id.ts +1 -1
- package/recipes/lookup-then-view/README.md +73 -0
- package/recipes/lookup-then-view/agent/accounts.ts +174 -0
- package/recipes/lookup-then-view/agent/actions/find_account.ts +108 -0
- package/recipes/lookup-then-view/agent/actions/show_account_overview.ts +42 -0
- package/recipes/lookup-then-view/agent/api/accounts.ts +68 -0
- package/recipes/lookup-then-view/agent/views/account.ts +183 -0
- package/recipes/lookup-then-view/agent/views/brand.ts +35 -0
- package/recipes/lookup-then-view/agent/views/format.ts +73 -0
- package/recipes/lookup-then-view/agent/views/pick-one.ts +240 -0
- package/recipes/lookup-then-view/fixtures/accounts.json +319 -0
- package/recipes/lookup-then-view/fixtures/overview.ok.json +122 -0
- package/recipes/lookup-then-view/fixtures/overview.unsigned.json +14 -0
- package/recipes/lookup-then-view/recipe.json +11 -0
- package/recipes/lookup-then-view/studio/find_account.json +10 -0
- package/recipes/lookup-then-view/studio/show_account_overview.json +15 -0
- package/recipes/opportunities-at-risk/README.md +5 -5
- package/recipes/opportunities-at-risk/agent/actions/show_opportunities_at_risk.ts +9 -7
- package/recipes/opportunities-at-risk/agent/opportunities.ts +11 -10
- package/recipes/opportunities-at-risk/agent/views/at-risk.ts +1 -1
- package/recipes/opportunities-at-risk/agent/views/brand.ts +26 -15
- package/recipes/pick-from-a-list/README.md +7 -6
- package/recipes/pick-from-a-list/agent/actions/choose_project.ts +27 -12
- package/recipes/pick-from-a-list/agent/views/brand.ts +30 -9
- package/recipes/pick-from-a-list/agent/views/picker.ts +3 -3
- package/recipes/prepare-for-a-meeting/README.md +4 -4
- package/recipes/prepare-for-a-meeting/agent/actions/prepare_for_meeting.ts +3 -3
- package/recipes/records-list/README.md +6 -6
- package/recipes/records-list/agent/accounts.ts +3 -1
- package/recipes/records-list/agent/actions/list_accounts.ts +7 -7
- package/recipes/records-list/agent/views/brand.ts +26 -14
- package/recipes/server-rendered-forms/README.md +35 -0
- package/recipes/server-rendered-forms/agent/actions/create_record_via_form.ts +62 -0
- package/recipes/server-rendered-forms/agent/api/pages.ts +57 -0
- package/recipes/server-rendered-forms/agent/forms.ts +192 -0
- package/recipes/server-rendered-forms/fixtures/form.errors.html +9 -0
- package/recipes/server-rendered-forms/fixtures/form.html +17 -0
- package/recipes/server-rendered-forms/fixtures/list.html +9 -0
- package/recipes/server-rendered-forms/fixtures/record.html +4 -0
- package/recipes/server-rendered-forms/recipe.json +10 -0
- package/recipes/several-hosts/README.md +10 -13
- package/recipes/several-hosts/agent/hosts.ts +19 -136
- package/recipes/several-hosts/fixtures/wrong-page.json +6 -0
- package/recipes/several-hosts/recipe.json +3 -3
- package/recipes/swap-the-login-method/README.md +16 -5
- package/recipes/swap-the-login-method/agent/utils.ts +52 -3
- package/recipes/swap-the-login-method/variants/utils.cookies.ts +59 -6
- package/recipes/update-meeting-notes/README.md +10 -4
- package/recipes/update-meeting-notes/agent/actions/update_meeting_notes.ts +16 -5
- package/recipes/upload-contacts/README.md +5 -5
- package/recipes/upload-contacts/agent/actions/upload_contacts.ts +7 -7
- package/recipes/upload-contacts/agent/views/brand.ts +24 -14
- package/recipes/upload-contacts/agent/views/uploader.ts +28 -17
- package/recipes/who-is-the-user/README.md +7 -3
- package/src/ask-core.mjs +3 -3
- package/src/attach-daemon.mjs +9 -2
- package/src/attach-helpers.mjs +105 -0
- package/src/attach-preflight.mjs +34 -11
- package/src/badge-core.mjs +1 -1
- package/src/cdp-client.mjs +14 -19
- package/src/cdp-ownership.mjs +41 -20
- package/src/cli-help.mjs +7 -5
- package/src/cli-registry.mjs +65 -46
- package/src/diagnostics.mjs +1 -1
- package/src/init.mjs +5 -3
- package/src/lint/index.mjs +10 -1
- package/src/lint/rules.mjs +371 -35
- package/src/observe-core.mjs +163 -34
- package/src/region.mjs +1 -1
- package/src/runtime/agent.ts +16 -12
- package/src/runtime/http.ts +25 -15
- package/src/runtime/match.ts +1 -1
- package/src/runtime/render.ts +4 -4
- package/src/transports/README.md +2 -2
- package/src/upgrade.mjs +33 -10
- package/templates/agent-starter/CLAUDE.md +8 -5
- package/templates/agent-starter/README.md +32 -33
- package/templates/agent-starter/agent/actions/_example.ts +4 -4
- package/templates/agent-starter/agent/actions/index.ts +2 -2
- package/templates/agent-starter/agent/constants.ts +4 -3
- package/templates/agent-starter/agent/utils.ts +15 -18
- package/templates/agent-starter/claude-settings.json +1 -1
- package/templates/agent-starter/docs/app-profile.md +19 -14
- package/templates/agent-starter/foldspace.dev.json +1 -1
- package/recipes/several-hosts/fixtures/nav-record.json +0 -5
package/src/lint/rules.mjs
CHANGED
|
@@ -57,7 +57,7 @@ const IMPERATIVE_PATTERNS = [
|
|
|
57
57
|
const EMOJI_PATTERN = /\p{Extended_Pictographic}/u;
|
|
58
58
|
|
|
59
59
|
const PROMPT_FIX =
|
|
60
|
-
"Put the wording in the action
|
|
60
|
+
"Put the wording in the action's instructions (with the Foldspace tools or in Agent Studio) or in the agent's instructions (Agent Studio only); return data only.";
|
|
61
61
|
|
|
62
62
|
function mergeBindings(fn, sourceFile) {
|
|
63
63
|
return new Map([
|
|
@@ -98,7 +98,7 @@ function inspectReturnedObject(obj, bindings, sourceFile, relative, findings, or
|
|
|
98
98
|
sourceFile,
|
|
99
99
|
node: at,
|
|
100
100
|
file: relative,
|
|
101
|
-
message: `${origin} sends the
|
|
101
|
+
message: `${origin} sends the agent operating instructions ('${text.slice(0, 80)}').`,
|
|
102
102
|
fix: PROMPT_FIX,
|
|
103
103
|
}),
|
|
104
104
|
);
|
|
@@ -128,19 +128,29 @@ export function ruleNoStaticAgentPrompt(sourceFile, relative, findings) {
|
|
|
128
128
|
}
|
|
129
129
|
|
|
130
130
|
if (name === "render") {
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
131
|
+
// What a card hands to callback, and to readyState (render's 6th
|
|
132
|
+
// parameter), reaches the agent as the card's result - check both.
|
|
133
|
+
const params = fn.parameters || [];
|
|
134
|
+
const readyName =
|
|
135
|
+
params.length >= 6 && ts.isIdentifier(params[5].name) ? params[5].name.text : null;
|
|
136
|
+
for (const [paramName, origin] of [
|
|
137
|
+
[callbackParamName(fn), "callback"],
|
|
138
|
+
[readyName, "readyState"],
|
|
139
|
+
]) {
|
|
140
|
+
if (!paramName) continue;
|
|
141
|
+
forEachCallNamed(fn, paramName, (call) => {
|
|
142
|
+
const arg = call.arguments[0];
|
|
143
|
+
if (!arg) return;
|
|
144
|
+
inspectReturnedObject(
|
|
145
|
+
resolveNode(arg, bindings),
|
|
146
|
+
bindings,
|
|
147
|
+
sourceFile,
|
|
148
|
+
relative,
|
|
149
|
+
findings,
|
|
150
|
+
origin,
|
|
151
|
+
);
|
|
152
|
+
});
|
|
153
|
+
}
|
|
144
154
|
}
|
|
145
155
|
});
|
|
146
156
|
}
|
|
@@ -208,8 +218,8 @@ function reportEmoji(node, sourceFile, relative, findings) {
|
|
|
208
218
|
sourceFile,
|
|
209
219
|
node,
|
|
210
220
|
file: relative,
|
|
211
|
-
message: "Emoji in handler or
|
|
212
|
-
fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the
|
|
221
|
+
message: "Emoji in handler or card copy reads as off-brand in a product UI that has none.",
|
|
222
|
+
fix: "Strip the glyph from handler/render copy. Put “no emoji” in the agent's Behavior instructions; the Foldspace tools cannot write that field, so each new action's Studio instructions must carry the line too.",
|
|
213
223
|
}),
|
|
214
224
|
);
|
|
215
225
|
}
|
|
@@ -450,30 +460,357 @@ export function ruleRegistryIntegrity({ indexFile, indexSource, actionFiles, pro
|
|
|
450
460
|
}
|
|
451
461
|
}
|
|
452
462
|
|
|
453
|
-
// The
|
|
454
|
-
//
|
|
455
|
-
//
|
|
456
|
-
//
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
463
|
+
// The card contract of the page SDK. A card is a view card or an input card.
|
|
464
|
+
// An input card's render uses one of its 4th to 6th parameters - callback,
|
|
465
|
+
// cancel, readyState - and the agent waits until callback or cancel runs, or
|
|
466
|
+
// until readyState reports. A view card uses none of them: as soon as render
|
|
467
|
+
// returns, the agent is told it was shown, with everything execute returned.
|
|
468
|
+
// awaitUserInput is never needed. The chat panel ignores it; in headless mode
|
|
469
|
+
// awaitUserInput: true forces the wait, so on a view card the agent waits for
|
|
470
|
+
// a callback that never comes.
|
|
471
|
+
//
|
|
472
|
+
// "Uses" is read the way the SDK reads render in the built bundle, where
|
|
473
|
+
// parameters are renamed and types are erased: the parameter's name anywhere
|
|
474
|
+
// in the body, nested functions included, but not as a property name
|
|
475
|
+
// (`host.callback`, `{ callback: 1 }`) and not inside a type. A destructured
|
|
476
|
+
// parameter, a `...rest` parameter or `arguments` count as used. A TypeScript
|
|
477
|
+
// `this` parameter is erased, so it takes no position.
|
|
478
|
+
const CARD_PARAMS = ["callback", "cancel", "readyState"];
|
|
479
|
+
|
|
480
|
+
function mentions(fn, test) {
|
|
481
|
+
let found = false;
|
|
482
|
+
function walk(node) {
|
|
483
|
+
if (found) return;
|
|
484
|
+
if (ts.isIdentifier(node) && test(node.text)) {
|
|
485
|
+
const parent = node.parent;
|
|
486
|
+
const isName =
|
|
487
|
+
parent &&
|
|
488
|
+
(ts.isPropertyAccessExpression(parent) ||
|
|
489
|
+
ts.isPropertyAssignment(parent) ||
|
|
490
|
+
ts.isMethodDeclaration(parent) ||
|
|
491
|
+
ts.isPropertyDeclaration(parent) ||
|
|
492
|
+
ts.isGetAccessorDeclaration(parent) ||
|
|
493
|
+
ts.isSetAccessorDeclaration(parent)) &&
|
|
494
|
+
parent.name === node;
|
|
495
|
+
const isKey = parent && ts.isBindingElement(parent) && parent.propertyName === node;
|
|
496
|
+
let inType = false;
|
|
497
|
+
for (let up = parent; up && up !== fn; up = up.parent) {
|
|
498
|
+
if (ts.isTypeNode(up) && !ts.isExpressionWithTypeArguments(up)) {
|
|
499
|
+
inType = true;
|
|
500
|
+
break;
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
if (!isName && !isKey && !inType) {
|
|
504
|
+
found = true;
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
}
|
|
508
|
+
ts.forEachChild(node, walk);
|
|
509
|
+
}
|
|
510
|
+
if (fn.body) walk(fn.body);
|
|
511
|
+
return found;
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
/** What the SDK concludes about a render function: does the agent wait, and which card parameters are declared but unused. */
|
|
515
|
+
function readRender(fn) {
|
|
516
|
+
const params = fn.parameters.filter((param) => !(ts.isIdentifier(param.name) && param.name.text === "this"));
|
|
517
|
+
const cardParams = params.slice(3, 3 + CARD_PARAMS.length);
|
|
518
|
+
const restOrArguments = params.some((param) => param.dotDotDotToken) || mentions(fn, (name) => name === "arguments");
|
|
519
|
+
const destructured = cardParams.some((param) => !ts.isIdentifier(param.name));
|
|
520
|
+
const names = cardParams.filter((param) => ts.isIdentifier(param.name)).map((param) => param.name.text);
|
|
521
|
+
const used = names.filter((name) => mentions(fn, (text) => text === name));
|
|
522
|
+
const waits = restOrArguments || destructured || used.length > 0;
|
|
523
|
+
return { waits, declaredUnused: !waits && names.length > 0 ? names : [] };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
// The function `render` names: inline, a method, or - for `render` and
|
|
527
|
+
// `render: renderCard` - a `function` or `const` of that name in the same file.
|
|
528
|
+
function declaredFunction(sourceFile, name) {
|
|
529
|
+
let found = null;
|
|
530
|
+
function walk(node) {
|
|
531
|
+
if (found) return;
|
|
532
|
+
if (ts.isFunctionDeclaration(node) && node.name?.text === name) {
|
|
533
|
+
found = node;
|
|
534
|
+
return;
|
|
535
|
+
}
|
|
536
|
+
if (ts.isVariableDeclaration(node) && ts.isIdentifier(node.name) && node.name.text === name && isFunctionLike(node.initializer)) {
|
|
537
|
+
found = node.initializer;
|
|
538
|
+
return;
|
|
539
|
+
}
|
|
540
|
+
ts.forEachChild(node, walk);
|
|
541
|
+
}
|
|
542
|
+
walk(sourceFile);
|
|
543
|
+
return found;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function renderFunction(sourceFile, member) {
|
|
547
|
+
if (ts.isMethodDeclaration(member)) return member;
|
|
548
|
+
if (ts.isShorthandPropertyAssignment(member)) return declaredFunction(sourceFile, member.name.text);
|
|
549
|
+
let value = member.initializer;
|
|
550
|
+
while (
|
|
551
|
+
value &&
|
|
552
|
+
(ts.isParenthesizedExpression(value) ||
|
|
553
|
+
ts.isAsExpression(value) ||
|
|
554
|
+
ts.isSatisfiesExpression(value) ||
|
|
555
|
+
ts.isNonNullExpression(value) ||
|
|
556
|
+
ts.isTypeAssertionExpression(value))
|
|
557
|
+
)
|
|
558
|
+
value = value.expression;
|
|
559
|
+
if (isFunctionLike(value)) return value;
|
|
560
|
+
if (value && ts.isIdentifier(value)) return declaredFunction(sourceFile, value.text);
|
|
561
|
+
return null;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
export function ruleCardContract(sourceFile, relative, findings) {
|
|
565
|
+
function walk(node) {
|
|
566
|
+
if (ts.isObjectLiteralExpression(node)) {
|
|
567
|
+
const members = node.properties;
|
|
568
|
+
const render = members.find(
|
|
569
|
+
(member) =>
|
|
570
|
+
propertyName(member) === "render" &&
|
|
571
|
+
(ts.isPropertyAssignment(member) || ts.isMethodDeclaration(member) || ts.isShorthandPropertyAssignment(member)),
|
|
572
|
+
);
|
|
573
|
+
const fn = render ? renderFunction(sourceFile, render) : null;
|
|
574
|
+
if (fn) {
|
|
575
|
+
const { waits, declaredUnused } = readRender(fn);
|
|
576
|
+
const flag = members.find((member) => propertyName(member) === "awaitUserInput");
|
|
577
|
+
const flagTrue = flag && ts.isPropertyAssignment(flag) && flag.initializer.kind === ts.SyntaxKind.TrueKeyword;
|
|
578
|
+
if (flagTrue && !waits) {
|
|
579
|
+
findings.push(
|
|
580
|
+
finding({
|
|
581
|
+
rule: "card-contract",
|
|
582
|
+
severity: "error",
|
|
583
|
+
sourceFile,
|
|
584
|
+
node: flag.name,
|
|
585
|
+
file: relative,
|
|
586
|
+
message:
|
|
587
|
+
"A view card (render uses neither callback nor cancel) sets awaitUserInput: true. The chat panel ignores it, but in headless mode the agent then waits for a callback that never comes.",
|
|
588
|
+
fix: "Remove awaitUserInput. A view card is reported to the agent as shown, with what execute returned.",
|
|
589
|
+
}),
|
|
590
|
+
);
|
|
591
|
+
}
|
|
592
|
+
if (flag && !waits && !flagTrue) {
|
|
593
|
+
findings.push(
|
|
594
|
+
finding({
|
|
595
|
+
rule: "card-contract",
|
|
596
|
+
severity: "warning",
|
|
597
|
+
sourceFile,
|
|
598
|
+
node: flag.name,
|
|
599
|
+
file: relative,
|
|
600
|
+
message:
|
|
601
|
+
"A view card does not need awaitUserInput: render uses neither callback nor cancel, so the agent is told the card was shown, with what execute returned.",
|
|
602
|
+
fix: "Remove awaitUserInput.",
|
|
603
|
+
}),
|
|
604
|
+
);
|
|
605
|
+
}
|
|
606
|
+
if (flag && waits) {
|
|
607
|
+
findings.push(
|
|
608
|
+
finding({
|
|
609
|
+
rule: "card-contract",
|
|
610
|
+
severity: "warning",
|
|
611
|
+
sourceFile,
|
|
612
|
+
node: flag.name,
|
|
613
|
+
file: relative,
|
|
614
|
+
message:
|
|
615
|
+
"An input card does not need awaitUserInput: render uses callback, cancel or readyState, and that is what makes the agent wait.",
|
|
616
|
+
fix: 'Remove awaitUserInput. Then check that every path calls callback or cancel - choosing, "none of these", closing the card, failing. When the conversation should go on while the user decides, call readyState(value) once the card is drawn.',
|
|
617
|
+
}),
|
|
618
|
+
);
|
|
619
|
+
}
|
|
620
|
+
if (declaredUnused.length > 0) {
|
|
621
|
+
const list =
|
|
622
|
+
declaredUnused.length === 1
|
|
623
|
+
? declaredUnused[0]
|
|
624
|
+
: `${declaredUnused.slice(0, -1).join(", ")} and ${declaredUnused.at(-1)}`;
|
|
625
|
+
const unused = declaredUnused.length === 1 ? "never uses it" : "uses none of them";
|
|
626
|
+
findings.push(
|
|
627
|
+
finding({
|
|
628
|
+
rule: "card-contract",
|
|
629
|
+
severity: "warning",
|
|
630
|
+
sourceFile,
|
|
631
|
+
node: render.name,
|
|
632
|
+
file: relative,
|
|
633
|
+
message: `render declares ${list} but ${unused} - the SDK treats the card as a view card: the agent is told it was shown, with what execute returned.`,
|
|
634
|
+
fix: `For a view card, remove ${list} from render. For an input card, call callback or cancel on every path, or readyState(value) once the card is drawn.`,
|
|
635
|
+
}),
|
|
636
|
+
);
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
}
|
|
640
|
+
ts.forEachChild(node, walk);
|
|
641
|
+
}
|
|
642
|
+
walk(sourceFile);
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
// Test mode belongs to `attach --test-mode`, which never touches the bundle.
|
|
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
|
+
// `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.
|
|
666
|
+
export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
|
|
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);
|
|
682
|
+
}
|
|
683
|
+
ts.forEachChild(node, collectImportedNames);
|
|
684
|
+
};
|
|
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) => {
|
|
465
770
|
findings.push(
|
|
466
771
|
finding({
|
|
467
|
-
rule: "no-
|
|
772
|
+
rule: "no-test-mode-in-bundle",
|
|
468
773
|
severity: "error",
|
|
469
774
|
sourceFile,
|
|
470
775
|
node,
|
|
471
776
|
file: relative,
|
|
472
|
-
message:
|
|
473
|
-
fix: "
|
|
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.",
|
|
474
779
|
}),
|
|
475
780
|
);
|
|
476
|
-
}
|
|
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, ""));
|
|
477
814
|
}
|
|
478
815
|
|
|
479
816
|
export function lintActionFile(filePath, relative, findings) {
|
|
@@ -482,11 +819,10 @@ export function lintActionFile(filePath, relative, findings) {
|
|
|
482
819
|
ruleNoStaticAgentPrompt(sourceFile, relative, findings);
|
|
483
820
|
ruleNoRunTaskPrompt(sourceFile, relative, findings);
|
|
484
821
|
ruleNoEmoji(sourceFile, relative, findings);
|
|
485
|
-
|
|
822
|
+
ruleCardContract(sourceFile, relative, findings);
|
|
486
823
|
}
|
|
487
824
|
|
|
488
825
|
export function lintViewsFile(filePath, relative, findings) {
|
|
489
826
|
const sourceFile = parseFile(filePath);
|
|
490
827
|
ruleNoEmoji(sourceFile, relative, findings, { wholeFile: true });
|
|
491
|
-
ruleNoUnsnapshottableColor(sourceFile, relative, findings);
|
|
492
828
|
}
|