@fjall/eslint-plugin 21.0.0 → 21.1.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 +5 -1
- package/no-adhoc-fjall-home.js +95 -0
- package/package.json +1 -1
- package/require-agent-mode-decision.js +131 -0
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
|
@@ -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
|
+
};
|