@fjall/eslint-plugin 21.0.0 → 22.0.0

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/index.js CHANGED
@@ -31,6 +31,8 @@ import noReplacementStringExpansion from "./no-replacement-string-expansion.js";
31
31
  import noSilentResultDiscard from "./no-silent-result-discard.js";
32
32
  import noOptionalWarningEmission from "./no-optional-warning-emission.js";
33
33
  import noUnguardedChmodInTests from "./no-unguarded-chmod-in-tests.js";
34
+ import noAdhocFjallHome from "./no-adhoc-fjall-home.js";
35
+ import requireAgentModeDecision from "./require-agent-mode-decision.js";
34
36
 
35
37
  export default {
36
38
  rules: {
@@ -68,6 +70,8 @@ export default {
68
70
  "no-replacement-string-expansion": noReplacementStringExpansion,
69
71
  "no-silent-result-discard": noSilentResultDiscard,
70
72
  "no-optional-warning-emission": noOptionalWarningEmission,
71
- "no-unguarded-chmod-in-tests": noUnguardedChmodInTests
73
+ "no-unguarded-chmod-in-tests": noUnguardedChmodInTests,
74
+ "no-adhoc-fjall-home": noAdhocFjallHome,
75
+ "require-agent-mode-decision": requireAgentModeDecision
72
76
  }
73
77
  };
@@ -0,0 +1,95 @@
1
+ /**
2
+ * @fileoverview ESLint rule: no-adhoc-fjall-home.
3
+ *
4
+ * Fjall's per-user state root — credentials, caches, journals, logs — is
5
+ * `fjallHomeDir()` / `fjallStatePath(...)` from `@fjall/util`, honouring
6
+ * `FJALL_CONFIG_DIR`. A hand-rolled `join(homedir(), ".fjall", …)` is
7
+ * unreachable by that override, so a site that re-derives the path silently
8
+ * opts itself out of the one mechanism that lets an operator hold two
9
+ * organisations at once.
10
+ *
11
+ * There is no typecheck signal for this: every re-derivation is a correct
12
+ * `string` and agrees with the seam for the default case. It diverges only
13
+ * once someone sets the env var, which is exactly when they need it not to.
14
+ *
15
+ * Authored after the census that introduced the seam found ELEVEN independent
16
+ * derivations across four packages — `Credentials.directoryPath`, the
17
+ * connection / org-config / app-id caches, the agent session hooks, the CLI
18
+ * log dir, deploy-core's drift, forensics, remediation and cfn-registry
19
+ * journals, and the MCP server's credential reader. Three of them captured
20
+ * the path in a module-level `const`, so they could not have honoured an
21
+ * override even if one had existed. The MCP server and the CLI agreed about
22
+ * where the live production API key lives only by coincidence: two packages,
23
+ * same two string literals, nothing linking them.
24
+ *
25
+ * The rule flags a `join`/`resolve` call whose arguments contain a `homedir()`
26
+ * call and a `.fjall` string literal. It deliberately does NOT flag
27
+ * `homedir()` alone (`~/.aws`, `~/.npmrc` and `~/.claude` are other tools'
28
+ * state and correctly derived from the real home), nor a `.fjall` literal
29
+ * alone (project-local `.fjall/` beside a checkout is a different thing, and
30
+ * `configPaths.ts` owns those names).
31
+ */
32
+
33
+ const STATE_ROOT_SEGMENT = ".fjall";
34
+ const PATH_JOINERS = new Set(["join", "resolve"]);
35
+
36
+ /** `homedir()` / `os.homedir()` — the call, however it was imported. */
37
+ function isHomedirCall(node) {
38
+ if (node.type !== "CallExpression") return false;
39
+ const { callee } = node;
40
+ if (callee.type === "Identifier") return callee.name === "homedir";
41
+ return (
42
+ callee.type === "MemberExpression" &&
43
+ callee.property.type === "Identifier" &&
44
+ callee.property.name === "homedir"
45
+ );
46
+ }
47
+
48
+ /** A literal naming the state root, whole (".fjall") or led (".fjall/x"). */
49
+ function isStateRootLiteral(node) {
50
+ if (node.type !== "Literal" || typeof node.value !== "string") return false;
51
+ return (
52
+ node.value === STATE_ROOT_SEGMENT ||
53
+ node.value.startsWith(`${STATE_ROOT_SEGMENT}/`)
54
+ );
55
+ }
56
+
57
+ /** `join(...)` / `path.join(...)` / `resolve(...)`. */
58
+ function isPathJoiner(node) {
59
+ const { callee } = node;
60
+ if (callee.type === "Identifier") return PATH_JOINERS.has(callee.name);
61
+ return (
62
+ callee.type === "MemberExpression" &&
63
+ callee.property.type === "Identifier" &&
64
+ PATH_JOINERS.has(callee.property.name)
65
+ );
66
+ }
67
+
68
+ /** @type {import('eslint').Rule.RuleModule} */
69
+ const noAdhocFjallHome = {
70
+ meta: {
71
+ type: "problem",
72
+ docs: {
73
+ description:
74
+ "Fjall's per-user state root must come from fjallHomeDir()/fjallStatePath() in @fjall/util, not a hand-rolled join(homedir(), '.fjall')."
75
+ },
76
+ messages: {
77
+ adhocFjallHome:
78
+ 'Use `fjallStatePath(...)` (or `fjallHomeDir()`) from `@fjall/util` instead of joining `homedir()` with ".fjall". A hand-rolled path cannot honour `FJALL_CONFIG_DIR`, so this state stays in ~/.fjall while the rest of the CLI follows the override — which is how one organisation\'s cache ends up answering for another. For the credential file specifically, use `fjallCredentialsPath()`.'
79
+ },
80
+ schema: []
81
+ },
82
+ create(context) {
83
+ return {
84
+ CallExpression(node) {
85
+ if (!isPathJoiner(node)) return;
86
+ const args = node.arguments;
87
+ if (!args.some(isHomedirCall)) return;
88
+ if (!args.some(isStateRootLiteral)) return;
89
+ context.report({ node, messageId: "adhocFjallHome" });
90
+ }
91
+ };
92
+ }
93
+ };
94
+
95
+ export default noAdhocFjallHome;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fjall/eslint-plugin",
3
- "version": "21.0.0",
3
+ "version": "22.0.0",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "git+https://github.com/fjall-tech/fjall.git",
@@ -0,0 +1,131 @@
1
+ /**
2
+ * ESLint Rule: require-agent-mode-decision
3
+ *
4
+ * A command handler that writes MACHINE-READABLE output to stdout must decide,
5
+ * in code, what agent mode means for it.
6
+ *
7
+ * `fjall apps detect` shipped writing `process.stdout.write(JSON.stringify(...))`
8
+ * and never once consulting `isAgentMode`. It registered the agent flag set, so
9
+ * `--agent` was accepted; it had no lane, so `--agent` did nothing. Every
10
+ * structural check in the estate stayed green, because all of them were
11
+ * DECLARATION checks — the command was declared, and no test asked whether the
12
+ * declaration was honoured. An agent following the shipped skill hit raw JSON
13
+ * at the first mandatory step of the app-creation recipe.
14
+ *
15
+ * The rule is deliberately not "never write JSON to stdout": the `--output json`
16
+ * lane is a real contract that the MCP server depends on. What it requires is
17
+ * that a file emitting structured stdout ALSO references `isAgentMode`, i.e.
18
+ * that the author made the call rather than never facing it. A genuinely
19
+ * human-only handler declares itself with a disable comment naming its
20
+ * `HUMAN_ONLY_LEAVES` entry, which turns an invisible omission into a
21
+ * reviewable claim.
22
+ *
23
+ * Flags:
24
+ * - `process.stdout.write(...)` containing a `JSON.stringify(...)` call, in a
25
+ * file under `src/commands/` that never references `isAgentMode`.
26
+ *
27
+ * Allowed:
28
+ * - the same write in a file that consults `isAgentMode` anywhere;
29
+ * - human text (`process.stdout.write("...")`) — no structured payload;
30
+ * - anything outside `src/commands/` (services and renderers are not the
31
+ * surface where the mode decision belongs).
32
+ */
33
+
34
+ const COMMANDS_PATH_FRAGMENT = "src/commands/";
35
+ const AGENT_MODE_IDENTIFIER = "isAgentMode";
36
+
37
+ function isProcessStdoutWrite(node) {
38
+ const callee = node.callee;
39
+ if (callee === undefined || callee.type !== "MemberExpression") return false;
40
+ if (
41
+ callee.property.type !== "Identifier" ||
42
+ callee.property.name !== "write"
43
+ ) {
44
+ return false;
45
+ }
46
+ const target = callee.object;
47
+ return (
48
+ target.type === "MemberExpression" &&
49
+ target.object.type === "Identifier" &&
50
+ target.object.name === "process" &&
51
+ target.property.type === "Identifier" &&
52
+ target.property.name === "stdout"
53
+ );
54
+ }
55
+
56
+ function isJsonStringifyCall(node) {
57
+ return (
58
+ node.type === "CallExpression" &&
59
+ node.callee.type === "MemberExpression" &&
60
+ node.callee.object.type === "Identifier" &&
61
+ node.callee.object.name === "JSON" &&
62
+ node.callee.property.type === "Identifier" &&
63
+ node.callee.property.name === "stringify"
64
+ );
65
+ }
66
+
67
+ /** Walk an expression tree looking for a JSON.stringify call. */
68
+ function containsJsonStringify(node, depth = 0) {
69
+ if (node === undefined || node === null || typeof node !== "object") {
70
+ return false;
71
+ }
72
+ // Template literals and concatenations nest, but not deeply in practice.
73
+ if (depth > 12) return false;
74
+ if (isJsonStringifyCall(node)) return true;
75
+ for (const key of Object.keys(node)) {
76
+ if (key === "parent" || key === "loc" || key === "range") continue;
77
+ const value = node[key];
78
+ if (Array.isArray(value)) {
79
+ for (const item of value) {
80
+ if (containsJsonStringify(item, depth + 1)) return true;
81
+ }
82
+ } else if (value !== null && typeof value === "object" && value.type) {
83
+ if (containsJsonStringify(value, depth + 1)) return true;
84
+ }
85
+ }
86
+ return false;
87
+ }
88
+
89
+ /** @type {import('eslint').Rule.RuleModule} */
90
+ export default {
91
+ meta: {
92
+ type: "problem",
93
+ docs: {
94
+ description:
95
+ "Command handlers that write structured stdout must consult isAgentMode, so an agent surface cannot be declared and then silently ignored",
96
+ category: "Best Practices",
97
+ recommended: false
98
+ },
99
+ messages: {
100
+ undeclaredJsonStdout:
101
+ "This handler writes JSON to stdout but never consults `isAgentMode`, so `--agent` would be accepted and ignored (the `apps detect` defect). Add an agent lane, or disable this rule with a comment naming the command's HUMAN_ONLY_LEAVES entry."
102
+ },
103
+ schema: []
104
+ },
105
+
106
+ create(context) {
107
+ const filename = context.filename ?? context.getFilename();
108
+ if (!filename.includes(COMMANDS_PATH_FRAGMENT)) return {};
109
+
110
+ let referencesAgentMode = false;
111
+ const offenders = [];
112
+
113
+ return {
114
+ Identifier(node) {
115
+ if (node.name === AGENT_MODE_IDENTIFIER) referencesAgentMode = true;
116
+ },
117
+ CallExpression(node) {
118
+ if (!isProcessStdoutWrite(node)) return;
119
+ if (node.arguments.some((arg) => containsJsonStringify(arg))) {
120
+ offenders.push(node);
121
+ }
122
+ },
123
+ "Program:exit"() {
124
+ if (referencesAgentMode) return;
125
+ for (const node of offenders) {
126
+ context.report({ node, messageId: "undeclaredJsonStdout" });
127
+ }
128
+ }
129
+ };
130
+ }
131
+ };