@foldspace_npm/harness 0.1.28 → 0.1.30

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 CHANGED
@@ -696,6 +696,8 @@ Each of these cost a real build.
696
696
  `AUTH_SOURCE` and `LOAD_MODE`
697
697
  - `agent/utils.ts` — configures the harness runtime and re-exports it. **The
698
698
  only import surface for actions.**
699
+ - `agent/runtime/` — a code project only: this harness's runtime, which every
700
+ build keeps current. Never edit it.
699
701
  - `agent/views/` — branded cards
700
702
  - `docs/app-profile.md` — what is known about this app, and how it was established
701
703
  - `foldspace.dev.json` — local harness target configuration
@@ -766,10 +768,17 @@ README's **Adapt it** table lists to what you observed.
766
768
  package: Foldspace builds the published bundle with no `node_modules`, so a
767
769
  package import passes every gate in this manual and then fails on publish.
768
770
  Import the helpers the way the project's own `agent/utils.ts` does - from
769
- `./runtime` - in a recipe file you copy too. A project with an `agent/utils/`
770
- folder has its own helpers instead: change the sign-in there, and never add an
771
- `agent/utils.ts` beside it, which would win the import. Without a code project,
772
- the package import is right.
771
+ `./runtime` - in a recipe file you copy too. `agent/runtime/` is this harness's
772
+ runtime, copied in for that reason. A build that changes files there names
773
+ them: save exactly those files in a save of their own as soon as the agent is
774
+ on their page. Say you are bringing their project up to date - no need to ask,
775
+ it changes nothing for their users. If their account cannot save, carry on.
776
+ Never edit a file there: the next build puts the harness's copy back. To change
777
+ how a helper behaves, define it in `agent/utils.ts` and export it from there in
778
+ place of the runtime's. A project with an `agent/utils/` folder has its own
779
+ helpers instead: change the sign-in there, and never add an `agent/utils.ts`
780
+ beside it, which would win the import. Without a code project, the package
781
+ import is right.
773
782
 
774
783
  ## Safety
775
784
 
package/README.md CHANGED
@@ -28,7 +28,7 @@ constraint this package exists to protect.
28
28
  - `agent/actions/*` and `agent/api/*` — a handler is `fetch` plus `runTask`,
29
29
  nothing more. Actions built in one track run unmodified in the other.
30
30
  - `agent/utils.ts` — configures and re-exports `@foldspace_npm/harness/runtime`
31
- - `foldspace build` — esbuild → `dist/index.js`
31
+ - `foldspace build` — esbuild → `dist/index.js`. In a code project, it first makes `agent/runtime/` match this harness's runtime (never rolling back a copy of a newer runtime; the version is in `src/runtime/VERSION`) and names every file it wrote
32
32
  - `foldspace deploy` (Foldspace-internal) - uploads `dist/index.js` to `agent/actions/<env>/<productId>/<agentApiName>`
33
33
  - fixtures and tests
34
34
  - **the verb interface** below
@@ -225,7 +225,7 @@ v1 flags:
225
225
  - **error** `no-static-agent-prompt` - `execute`, a card's `callback` or its `readyState` handing the agent instructions (`directive`, “tell the user”, …)
226
226
  - **error** `no-runtask-prompt` — `runTask({ data: { prompt } })` and the same keys; a customer API field named `prompt` is allowed
227
227
  - **error** `card-contract` - `awaitUserInput: true` on a view card (its `render` uses no `callback`, `cancel` or `readyState`): the chat panel ignores it, but in headless mode the agent waits for a callback that never comes
228
- - **error** `no-test-mode-in-bundle` - `armAllInstances(...)`, or `setTestMode(...)` with anything but a literal `false`, anywhere in `agent/`
228
+ - **error** `no-test-mode-in-bundle` - `armAllInstances` used anywhere in `agent/` (called, or handed on to be called later), or `setTestMode(...)` with anything but a literal `false`. A code project's own copy of the runtime in `agent/runtime/` is not refused for the `setTestMode` inside its `armAllInstances`
229
229
  - **warning** `card-contract` - any other `awaitUserInput` on a view card, `awaitUserInput` on an input card (not needed), or a `render` that declares `callback`, `cancel` or `readyState` and uses none of them (the SDK treats it as a view card)
230
230
  - **warning** `no-emoji` — emoji in handler or `agent/views` string literals
231
231
  - **warning** `registry-integrity` — duplicate keys, `{ execute }` not registered, registry value missing `execute`
package/bin/build.ts CHANGED
@@ -2,7 +2,8 @@ import * as esbuild from "esbuild";
2
2
  import * as fs from "fs";
3
3
  import * as path from "path";
4
4
  import { fileURLToPath } from "url";
5
- import { lintProject, printLint } from "../src/lint/index.mjs";
5
+ import { bundleTurnsOnTestMode, lintProject, printLint } from "../src/lint/index.mjs";
6
+ import { compareVersions } from "../src/upgrade.mjs";
6
7
 
7
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
8
9
  const projectDir = process.env.FOLDSPACE_PROJECT_DIR || process.cwd();
@@ -11,6 +12,100 @@ const agentActionsEntry = path.join(projectDir, "agent", "actions", "index.ts");
11
12
 
12
13
  const watchMode = process.argv.includes("--watch");
13
14
 
15
+ // A code project carries its own copy of this harness's runtime in
16
+ // agent/runtime/, because Foldspace builds it with no node_modules. Each build
17
+ // keeps that copy the same as the runtime here, so the harness's fixes reach
18
+ // the project, and names what it wrote so the session saves it.
19
+ // src/runtime/VERSION is the runtime's own version - it changes only when the
20
+ // runtime does - and the copy carries it in agent/runtime/VERSION: a copy of a
21
+ // newer runtime is left alone, so an older harness never rolls it back.
22
+ // Other files there, and links, are left alone too.
23
+ let runtimeSaveLine = "";
24
+
25
+ function plainFile(file: string): "missing" | "file" | "other" {
26
+ const stat = fs.lstatSync(file, { throwIfNoEntry: false });
27
+ if (!stat) return "missing";
28
+ return stat.isFile() ? "file" : "other";
29
+ }
30
+
31
+ function sameText(left: string, right: string): boolean {
32
+ return left.replace(/\r\n/g, "\n") === right.replace(/\r\n/g, "\n");
33
+ }
34
+
35
+ function refreshRuntimeCopy(): void {
36
+ const copyDir = path.join(projectDir, "agent", "runtime");
37
+ const copyStat = fs.lstatSync(copyDir, { throwIfNoEntry: false });
38
+ if (!copyStat) return;
39
+ if (copyStat.isSymbolicLink()) {
40
+ console.log("agent/runtime/ is a link, so this build leaves it as is.\n");
41
+ return;
42
+ }
43
+ if (!copyStat.isDirectory()) return;
44
+
45
+ const sourceDir = path.join(__dirname, "..", "src", "runtime");
46
+ const runtimeVersion = fs.readFileSync(path.join(sourceDir, "VERSION"), "utf8").trim();
47
+ const versionFile = path.join(copyDir, "VERSION");
48
+ const versionKind = plainFile(versionFile);
49
+ if (versionKind === "other") {
50
+ console.log("agent/runtime/VERSION is not a plain file, so this build leaves agent/runtime/ as is.\n");
51
+ return;
52
+ }
53
+ const copyVersion = versionKind === "file" ? fs.readFileSync(versionFile, "utf8").trim() : "";
54
+ if (copyVersion && compareVersions(copyVersion, runtimeVersion) > 0) {
55
+ console.log(
56
+ `agent/runtime/ is runtime ${copyVersion}, newer than this harness's (${runtimeVersion}), so this build uses it as it is. ` +
57
+ "Unless the human already said no to upgrading this session, ask them, then run npx foldspace upgrade --yes and build again.\n",
58
+ );
59
+ return;
60
+ }
61
+
62
+ const toWrite: Array<[string, string]> = [];
63
+ for (const name of fs.readdirSync(sourceDir).filter((file) => file.endsWith(".ts")).sort()) {
64
+ const source = fs.readFileSync(path.join(sourceDir, name), "utf8");
65
+ const target = path.join(copyDir, name);
66
+ const kind = plainFile(target);
67
+ if (kind === "other") continue;
68
+ if (kind === "file" && sameText(fs.readFileSync(target, "utf8"), source)) continue;
69
+ toWrite.push([name, source]);
70
+ }
71
+ const stamp = copyVersion !== runtimeVersion;
72
+ if (toWrite.length === 0 && !stamp) return;
73
+
74
+ const targets = [...toWrite.map(([name]) => path.join(copyDir, name)), ...(stamp ? [versionFile] : [])];
75
+ const blocked = targets.filter((file) => {
76
+ try {
77
+ fs.accessSync(fs.existsSync(file) ? file : copyDir, fs.constants.W_OK);
78
+ return false;
79
+ } catch {
80
+ return true;
81
+ }
82
+ });
83
+ const named = (files: string[]) => files.map((file) => `agent/runtime/${path.basename(file)}`).join(", ");
84
+ if (blocked.length > 0) {
85
+ console.error(`Could not update agent/runtime/: ${named(blocked)} cannot be written. Fix that and build again.`);
86
+ process.exit(1);
87
+ }
88
+ try {
89
+ for (const [name, source] of toWrite) fs.writeFileSync(path.join(copyDir, name), source);
90
+ if (stamp) fs.writeFileSync(versionFile, `${runtimeVersion}\n`);
91
+ } catch (error) {
92
+ console.error(`Could not update agent/runtime/: ${(error as Error).message}. Fix that and build again.`);
93
+ process.exit(1);
94
+ }
95
+
96
+ // The same runtime version that no longer matches was changed after it was copied.
97
+ if (!stamp) {
98
+ console.log(
99
+ `${named(toWrite.map(([name]) => name))} did not match this harness's copy of the same runtime; this build put the harness's copy back. ` +
100
+ "Never edit agent/runtime/ - to change how a helper behaves, define it in agent/utils.ts and export it from there in place of the runtime's.",
101
+ );
102
+ }
103
+ runtimeSaveLine =
104
+ `Updated to this harness's runtime (${runtimeVersion}): ${named(targets)}. ` +
105
+ "Save exactly these files to the code project in a save of their own, as soon as the agent is on their page - saving changes nothing for their users.";
106
+ console.log(`${runtimeSaveLine}\n`);
107
+ }
108
+
14
109
  function gateLint(): void {
15
110
  const result = lintProject(projectDir);
16
111
  printLint(result);
@@ -19,6 +114,21 @@ function gateLint(): void {
19
114
  }
20
115
  }
21
116
 
117
+ const TEST_MODE_IN_BUNDLE =
118
+ "foldspace build: the bundle can turn test mode on - it uses `setTestMode` with something other than `false`. " +
119
+ "Any bundle can ship to production, where test mode would stop real users' conversations being tracked. " +
120
+ "Remove it: test mode is only `npx foldspace attach --test-mode`, which never touches the bundle. dist/index.js was not kept.";
121
+
122
+ // The net under lint's no-test-mode-in-bundle: read what was actually built.
123
+ function bundleIsSafe(): boolean {
124
+ const outfile = path.join(distDir, "index.js");
125
+ if (!fs.existsSync(outfile)) return true;
126
+ if (!bundleTurnsOnTestMode(fs.readFileSync(outfile, "utf8"))) return true;
127
+ fs.rmSync(outfile, { force: true });
128
+ console.error(TEST_MODE_IN_BUNDLE);
129
+ return false;
130
+ }
131
+
22
132
  function buildOptions(): esbuild.BuildOptions {
23
133
  return {
24
134
  entryPoints: [agentActionsEntry],
@@ -45,11 +155,8 @@ function rebuildPlugin(): esbuild.Plugin {
45
155
  });
46
156
  build.onEnd((result) => {
47
157
  const time = new Date().toLocaleTimeString();
48
- if (result.errors.length === 0) {
49
- console.log(`[${time}] Rebuilt: dist/index.js`);
50
- } else {
51
- console.log(`[${time}] Error: dist/index.js`);
52
- }
158
+ const built = result.errors.length === 0 && bundleIsSafe();
159
+ console.log(`[${time}] ${built ? "Rebuilt" : "Error"}: dist/index.js`);
53
160
  });
54
161
  },
55
162
  };
@@ -63,6 +170,8 @@ async function build() {
63
170
  process.exit(1);
64
171
  }
65
172
 
173
+ refreshRuntimeCopy();
174
+
66
175
  if (!watchMode) {
67
176
  gateLint();
68
177
  }
@@ -90,8 +199,11 @@ async function build() {
90
199
  });
91
200
  } else {
92
201
  await esbuild.build(buildOptions());
202
+ if (!bundleIsSafe()) process.exit(1);
93
203
  console.log("Built: dist/index.js");
94
204
  console.log("\nBuild complete!");
205
+ // Said again at the end, where a session reading only the last lines sees it.
206
+ if (runtimeSaveLine) console.log(`\n${runtimeSaveLine}`);
95
207
  }
96
208
  } catch (error) {
97
209
  console.error("Build failed:", error);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foldspace_npm/harness",
3
- "version": "0.1.28",
3
+ "version": "0.1.30",
4
4
  "description": "Build and verify portable Foldspace action artifacts against a live app.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -129,11 +129,13 @@ export const CLI_COMMANDS = Object.freeze([
129
129
  prerequisites: ["agent/actions/index.ts"],
130
130
  effects: [
131
131
  "Checks the harness version first: behind prints one line, a withdrawn version refuses to run (FOLDSPACE_SKIP_UPDATE_CHECK=1 skips the check)",
132
+ "In a code project (agent/runtime/ present), first makes each agent/runtime/*.ts match this harness's runtime and records the runtime's version in agent/runtime/VERSION, naming every file it wrote; a copy of a newer runtime, other files there and links are left alone",
132
133
  "Runs foldspace lint first; errors skip bundling",
133
134
  "Writes dist/index.js when lint has no errors",
134
135
  ],
135
136
  next: [
136
137
  "First build: `npm run inject` (the human signs in in that window), `npx foldspace observe wait-login`, then `npx foldspace attach --daemon`",
138
+ "If the build names files in agent/runtime/ to save: save exactly those to the code project, in a save of their own, once the agent is on their page",
137
139
  "Rebuild while attach runs: `npx foldspace attach --stop`, then `npx foldspace attach --daemon` with the flags it ran with (`--bootstrap`, `--replace`, `--test-mode`) - a running attach keeps the actions it loaded",
138
140
  ],
139
141
  }),
@@ -3,6 +3,8 @@ import path from "node:path";
3
3
  import { listSourceFiles, parseFile, shouldSkipActionFile } from "./ast.mjs";
4
4
  import { lintActionFile, lintViewsFile, ruleNoTestModeInBundle, ruleRegistryIntegrity } from "./rules.mjs";
5
5
 
6
+ export { bundleTurnsOnTestMode } from "./rules.mjs";
7
+
6
8
  export const LINT_SCHEMA_VERSION = 1;
7
9
 
8
10
  export function lintProject(projectDir) {
@@ -644,34 +644,173 @@ export function ruleCardContract(sourceFile, relative, findings) {
644
644
 
645
645
  // Test mode belongs to `attach --test-mode`, which never touches the bundle.
646
646
  // A call in agent code that turns it on can ship to production and mark real
647
- // users' conversations as test traffic. A call to `setTestMode` by name is
648
- // refused unless its first argument is the literal `false`. A call to
649
- // `armAllInstances` by name is always refused: even with `false` it sends a
650
- // partial `setConfiguration` to every instance.
647
+ // users' conversations as test traffic.
648
+ //
649
+ // `setTestMode` is refused unless it is called with the literal `false` - and
650
+ // also when it is reached any other way (handed on, `.call`, `.bind`).
651
+ // `armAllInstances` is refused however it is used, whatever its argument:
652
+ // called (by name, under the name it was imported as, by element access),
653
+ // handed on (a timer, a listener, an object, a default value) or exported
654
+ // under another name. Even with `false` it sends a partial `setConfiguration`
655
+ // to every instance. Importing it, re-exporting it under its own name and
656
+ // types are not uses. Names are matched within the file, not by binding, so
657
+ // the rule errs on the side of refusing.
658
+ //
659
+ // One call is not refused. A code project carries its own copy of the runtime,
660
+ // and that copy's `agent/runtime/agent.ts` defines `function
661
+ // armAllInstances(testMode)`, which calls `setTestMode(testMode)`. That call -
662
+ // with the helper's own first parameter, in that function, in that file - runs
663
+ // only when something uses `armAllInstances`, and every use is refused. The
664
+ // build also checks the bundle itself (`bundleTurnsOnTestMode`), which catches
665
+ // what reading the source cannot.
651
666
  export function ruleNoTestModeInBundle(sourceFile, relative, findings) {
652
- const walk = (node) => {
653
- if (ts.isCallExpression(node)) {
654
- const callee = node.expression;
655
- const name = ts.isPropertyAccessExpression(callee) ? callee.name.text : ts.isIdentifier(callee) ? callee.text : "";
656
- const first = node.arguments[0];
657
- const setTestModeOff = name === "setTestMode" && first && first.kind === ts.SyntaxKind.FalseKeyword;
658
- if ((name === "setTestMode" || name === "armAllInstances") && !setTestModeOff) {
659
- findings.push(
660
- finding({
661
- rule: "no-test-mode-in-bundle",
662
- severity: "error",
663
- sourceFile,
664
- node,
665
- file: relative,
666
- 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.`,
667
- fix: "Remove it. Test mode is only for an agent that already has real users, and only through `npx foldspace attach --test-mode`, which never touches the bundle. For an agent with no real users yet, leave test mode off.",
668
- }),
669
- );
670
- }
667
+ // path.sep, so a POSIX file whose name holds a backslash is not read as a folder.
668
+ const inRuntimeHelperFile = relative.split(path.sep).join("/") === "agent/runtime/agent.ts";
669
+
670
+ const ARM = "armAllInstances";
671
+ const armNames = new Set([ARM]);
672
+ const keyText = (key) => {
673
+ if (!key) return undefined;
674
+ if (ts.isIdentifier(key) || ts.isStringLiteralLike(key)) return key.text;
675
+ if (ts.isComputedPropertyName(key) && ts.isStringLiteralLike(key.expression)) return key.expression.text;
676
+ return undefined;
677
+ };
678
+ const collectImportedNames = (node) => {
679
+ if (ts.isImportSpecifier(node) && keyText(node.propertyName ?? node.name) === ARM) armNames.add(node.name.text);
680
+ if (ts.isBindingElement(node) && ts.isIdentifier(node.name) && keyText(node.propertyName ?? node.name) === ARM) {
681
+ armNames.add(node.name.text);
671
682
  }
672
- ts.forEachChild(node, walk);
683
+ ts.forEachChild(node, collectImportedNames);
673
684
  };
674
- walk(sourceFile);
685
+ collectImportedNames(sourceFile);
686
+
687
+ const memberName = (node) => {
688
+ if (ts.isPropertyAccessExpression(node)) return node.name.text;
689
+ if (ts.isElementAccessExpression(node) && ts.isStringLiteralLike(node.argumentExpression)) {
690
+ return node.argumentExpression.text;
691
+ }
692
+ return "";
693
+ };
694
+ const unwrap = (node) => {
695
+ while (
696
+ node &&
697
+ (ts.isParenthesizedExpression(node) ||
698
+ ts.isNonNullExpression(node) ||
699
+ ts.isAsExpression(node) ||
700
+ ts.isSatisfiesExpression(node) ||
701
+ ts.isTypeAssertionExpression(node))
702
+ ) {
703
+ node = node.expression;
704
+ }
705
+ return node;
706
+ };
707
+ const isArm = (node) => (ts.isIdentifier(node) && armNames.has(node.text)) || memberName(node) === ARM;
708
+ const isSetTestMode = (node) =>
709
+ (ts.isIdentifier(node) && node.text === "setTestMode") || memberName(node) === "setTestMode";
710
+ const isCallee = (node) => {
711
+ let parent = node.parent;
712
+ while (parent && (ts.isParenthesizedExpression(parent) || ts.isNonNullExpression(parent) || ts.isAsExpression(parent))) {
713
+ parent = parent.parent;
714
+ }
715
+ return parent !== undefined && ts.isCallExpression(parent) && unwrap(parent.expression) === node;
716
+ };
717
+
718
+ const inImportEquals = (node) => {
719
+ let n = node;
720
+ while (n.parent && ts.isQualifiedName(n.parent)) n = n.parent;
721
+ return n.parent !== undefined && ts.isImportEqualsDeclaration(n.parent);
722
+ };
723
+ // Positions where the name is declared, imported, a key or a type - not a
724
+ // value that can be called later.
725
+ const isNamedNotUsed = (node) => {
726
+ const parent = node.parent;
727
+ if (!parent) return true;
728
+ if (ts.isPropertyAccessExpression(parent) && parent.name === node) return true;
729
+ if (ts.isQualifiedName(parent)) return !inImportEquals(node);
730
+ if (ts.isExportSpecifier(parent)) return true; // the export itself is judged below, once
731
+ if (ts.isBindingElement(parent)) return parent.name === node || parent.propertyName === node;
732
+ if (
733
+ ts.isImportSpecifier(parent) ||
734
+ ts.isImportClause(parent) ||
735
+ ts.isNamespaceImport(parent) ||
736
+ ts.isTypeQueryNode(parent) ||
737
+ ts.isImportTypeNode(parent)
738
+ ) {
739
+ return true;
740
+ }
741
+ if (ts.isFunctionDeclaration(parent) && parent.name === node) {
742
+ return !parent.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword);
743
+ }
744
+ return (
745
+ (ts.isFunctionExpression(parent) ||
746
+ ts.isVariableDeclaration(parent) ||
747
+ ts.isParameter(parent) ||
748
+ ts.isPropertyAssignment(parent) ||
749
+ ts.isMethodDeclaration(parent) ||
750
+ ts.isMethodSignature(parent) ||
751
+ ts.isGetAccessorDeclaration(parent) ||
752
+ ts.isSetAccessorDeclaration(parent) ||
753
+ ts.isPropertyDeclaration(parent) ||
754
+ ts.isPropertySignature(parent) ||
755
+ ts.isEnumMember(parent)) &&
756
+ parent.name === node
757
+ );
758
+ };
759
+
760
+ // The helper's first parameter, when node is the runtime copy's own
761
+ // `function armAllInstances(testMode)` in agent/runtime/agent.ts.
762
+ const helperParam = (node) => {
763
+ if (!inRuntimeHelperFile || !ts.isFunctionDeclaration(node) || node.name?.text !== ARM) return undefined;
764
+ if (node.modifiers?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword)) return undefined;
765
+ const first = node.parameters[0];
766
+ return first && ts.isIdentifier(first.name) ? first.name.text : undefined;
767
+ };
768
+
769
+ const refuse = (node, name) => {
770
+ findings.push(
771
+ finding({
772
+ rule: "no-test-mode-in-bundle",
773
+ severity: "error",
774
+ sourceFile,
775
+ node,
776
+ file: relative,
777
+ message: `\`${name}\` does not belong in agent code: any file in \`agent/\` can ship to production, where test mode would stop real users' conversations being tracked.`,
778
+ fix: "Remove it. Test mode is only for an agent that already has real users, and only through `npx foldspace attach --test-mode`, which never touches the bundle. For an agent with no real users yet, leave test mode off.",
779
+ }),
780
+ );
781
+ };
782
+
783
+ const walk = (node, exemptParam) => {
784
+ if (ts.isCallExpression(node) && isSetTestMode(unwrap(node.expression))) {
785
+ const [first] = node.arguments;
786
+ const off = first !== undefined && first.kind === ts.SyntaxKind.FalseKeyword;
787
+ const helperOwnCall =
788
+ exemptParam !== undefined && node.arguments.length === 1 && ts.isIdentifier(first) && first.text === exemptParam;
789
+ if (!off && !helperOwnCall) refuse(node, "setTestMode");
790
+ } else if (isSetTestMode(node) && !isCallee(node) && !(ts.isIdentifier(node) && isNamedNotUsed(node))) {
791
+ refuse(node, "setTestMode");
792
+ }
793
+ if (ts.isCallExpression(node) && isArm(unwrap(node.expression))) {
794
+ refuse(node, ARM);
795
+ } else if (isArm(node)) {
796
+ if (!isCallee(node) && !(ts.isIdentifier(node) && isNamedNotUsed(node))) refuse(node, ARM);
797
+ } else if (ts.isExportSpecifier(node)) {
798
+ const local = keyText(node.propertyName ?? node.name);
799
+ if (local !== undefined && armNames.has(local) && keyText(node.name) !== ARM) refuse(node, ARM);
800
+ }
801
+ const param = exemptParam ?? helperParam(node);
802
+ ts.forEachChild(node, (child) => walk(child, param));
803
+ };
804
+ walk(sourceFile, undefined);
805
+ }
806
+
807
+ // What the build checks in the bundle it wrote: minify keeps property names,
808
+ // so a bundle that can turn test mode on still says `setTestMode`. Calls with
809
+ // `false` (`!1` once minified) are allowed; any other mention is not - the
810
+ // net under the lint rule above, for code it cannot read (a computed name, a
811
+ // file lint does not walk).
812
+ export function bundleTurnsOnTestMode(js) {
813
+ return /setTestMode/.test(js.replace(/\??\.setTestMode(\?\.)?\(\s*(!1|false)\s*\)/g, ""));
675
814
  }
676
815
 
677
816
  export function lintActionFile(filePath, relative, findings) {
@@ -0,0 +1 @@
1
+ 0.1.30