@graphorin/eslint-plugin 0.6.1 → 0.7.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.
@@ -0,0 +1,37 @@
1
+ /**
2
+ * Comment-scanning helpers shared by the rule modules. We use raw
3
+ * `getAllComments` + line-distance heuristics instead of `getComments
4
+ * Before / Inside / After` because ESLint's per-node comment attachment
5
+ * leaves orphan comments at the program top-level when the node
6
+ * itself is nested inside an expression statement.
7
+ */
8
+
9
+ import type { Rule } from 'eslint';
10
+ import type { Comment, Node } from 'estree';
11
+
12
+ /**
13
+ * Returns `true` when the source contains a comment whose `value`
14
+ * matches `tag` and which is positioned within `1` line of `node`'s
15
+ * start, or anywhere inside `node`'s span.
16
+ *
17
+ * @internal
18
+ */
19
+ export function nodeHasNearbyComment(
20
+ context: Rule.RuleContext,
21
+ node: Node,
22
+ tagPattern: RegExp,
23
+ maxGap: number = 1,
24
+ ): boolean {
25
+ const startLine = node.loc?.start.line ?? 0;
26
+ const endLine = node.loc?.end.line ?? Number.POSITIVE_INFINITY;
27
+ const allComments = context.sourceCode.getAllComments() as Comment[];
28
+ for (const c of allComments) {
29
+ if (!tagPattern.test(c.value)) continue;
30
+ const cStart = c.loc?.start.line ?? 0;
31
+ const cEnd = c.loc?.end.line ?? 0;
32
+ if (cEnd <= startLine && startLine - cEnd <= maxGap) return true;
33
+ if (cStart >= startLine && cStart <= endLine) return true;
34
+ if (cStart >= endLine && cStart - endLine <= maxGap) return true;
35
+ }
36
+ return false;
37
+ }
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Rule: `@graphorin/no-bare-tool-exec`. Flags any `tool({...})`
3
+ * invocation whose `execute` function does not reference
4
+ * `ctx.signal`. The intent is principle 3 (streaming-first) +
5
+ * DEC-143 (cancellation contract): every long-running tool MUST
6
+ * propagate the abort signal to the underlying I/O so cancellation
7
+ * actually frees resources.
8
+ *
9
+ * The check is intentionally lexical - it scans the function body for
10
+ * the literal string `signal` (matching `ctx.signal`,
11
+ * `request.signal`, `args.signal`, etc.). False positives are
12
+ * acceptable because the fix is one comment line; false negatives are
13
+ * not, because they hide cancellation bugs in production.
14
+ *
15
+ * Per-call opt-out: `// graphorin-allow-bare-exec: <reason>` on the
16
+ * line above the `execute` function or anywhere inside the tool
17
+ * builder call.
18
+ *
19
+ * @packageDocumentation
20
+ */
21
+
22
+ import type { Rule } from 'eslint';
23
+ import type {
24
+ ArrowFunctionExpression,
25
+ CallExpression,
26
+ FunctionExpression,
27
+ Identifier,
28
+ ObjectExpression,
29
+ Property,
30
+ } from 'estree';
31
+
32
+ import { nodeHasNearbyComment } from './_comment-utils.js';
33
+
34
+ const ALLOW_TAG = /graphorin-allow-bare-exec/;
35
+ const SIGNAL_REFERENCE_RE = /\bsignal\b/;
36
+
37
+ const rule: Rule.RuleModule = {
38
+ meta: {
39
+ type: 'suggestion',
40
+ docs: {
41
+ description:
42
+ 'Require every `tool({...})` `execute` function to reference `ctx.signal` so cancellation propagates to the underlying I/O (principle 3, DEC-143).',
43
+ recommended: true,
44
+ },
45
+ schema: [],
46
+ messages: {
47
+ missingSignal:
48
+ "tool '{{name}}' `execute` function does not reference `ctx.signal`; long-running tools MUST propagate the abort signal. Add `// graphorin-allow-bare-exec: <reason>` to opt out.",
49
+ },
50
+ },
51
+ create(context: Rule.RuleContext): Rule.RuleListener {
52
+ return {
53
+ CallExpression(node: CallExpression): void {
54
+ if (!isToolBuilderCall(node)) return;
55
+ const arg = node.arguments[0];
56
+ if (arg === undefined || arg.type !== 'ObjectExpression') return;
57
+ const exec = findExecuteProperty(arg as ObjectExpression);
58
+ if (exec === null) return;
59
+ if (!isFunctionLike(exec)) return;
60
+ const fn = exec as ArrowFunctionExpression | FunctionExpression;
61
+ if (!fn.body) return;
62
+ const fnSource = context.sourceCode.getText(fn.body);
63
+ if (SIGNAL_REFERENCE_RE.test(fnSource)) return;
64
+ if (hasAllowComment(context, node)) return;
65
+ const name = extractToolName(arg as ObjectExpression) ?? '<unknown>';
66
+ context.report({
67
+ node: fn,
68
+ messageId: 'missingSignal',
69
+ data: { name },
70
+ });
71
+ },
72
+ };
73
+ },
74
+ };
75
+
76
+ function isToolBuilderCall(node: CallExpression): boolean {
77
+ if (node.callee.type !== 'Identifier') return false;
78
+ return (node.callee as Identifier).name === 'tool';
79
+ }
80
+
81
+ function findExecuteProperty(obj: ObjectExpression): Property['value'] | null {
82
+ for (const prop of obj.properties) {
83
+ if (prop.type !== 'Property') continue;
84
+ const property = prop as Property;
85
+ if (property.computed) continue;
86
+ const key = property.key;
87
+ if (key.type === 'Identifier' && key.name === 'execute') return property.value;
88
+ if (key.type === 'Literal' && key.value === 'execute') return property.value;
89
+ }
90
+ return null;
91
+ }
92
+
93
+ function isFunctionLike(
94
+ value: Property['value'],
95
+ ): value is ArrowFunctionExpression | FunctionExpression {
96
+ return value.type === 'ArrowFunctionExpression' || value.type === 'FunctionExpression';
97
+ }
98
+
99
+ function extractToolName(obj: ObjectExpression): string | null {
100
+ for (const prop of obj.properties) {
101
+ if (prop.type !== 'Property') continue;
102
+ const property = prop as Property;
103
+ if (property.computed) continue;
104
+ const key = property.key;
105
+ const isName =
106
+ (key.type === 'Identifier' && key.name === 'name') ||
107
+ (key.type === 'Literal' && key.value === 'name');
108
+ if (!isName) continue;
109
+ if (property.value.type === 'Literal' && typeof property.value.value === 'string') {
110
+ return property.value.value;
111
+ }
112
+ }
113
+ return null;
114
+ }
115
+
116
+ function hasAllowComment(context: Rule.RuleContext, node: CallExpression): boolean {
117
+ return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
118
+ }
119
+
120
+ export default rule;
@@ -0,0 +1,236 @@
1
+ /**
2
+ * Rule: `@graphorin/no-implicit-network-call` (DEC-154 / ADR-041).
3
+ * Flags any direct network primitive in code under a `@graphorin/*`
4
+ * package's `src/` directory unless the call site carries an opt-out
5
+ * comment whose text contains `'graphorin-allow-network'`:
6
+ *
7
+ * - calls: `fetch(...)`, `http(s).request/get(...)`, `axios(...)` /
8
+ * `axios.<verb>(...)`, `undici.<verb>(...)` / `got.<verb>(...)`,
9
+ * `net.createConnection/connect(...)`, `tls.connect(...)`,
10
+ * `dgram.createSocket(...)`
11
+ * - constructors: `new XMLHttpRequest()`, `new WebSocket(...)`,
12
+ * `new EventSource(...)`
13
+ * - imports of HTTP clients: `node-fetch`, `undici`, `got`, `axios`,
14
+ * `ky`, `ws` (static, dynamic `import(...)`, and `require(...)`)
15
+ *
16
+ * Companion to the `pnpm run check-no-network` static analysis script;
17
+ * the two matchers are kept in lockstep (this rule mirrors the EB-10
18
+ * hardening that taught the script about undici/got, raw sockets,
19
+ * WebSocket/EventSource, and HTTP-client import specifiers). The lint
20
+ * surface catches the pattern at author time so reviewers do not need
21
+ * to wait for CI to flag a missed network gate.
22
+ *
23
+ * The rule is intentionally limited to the framework's own code paths
24
+ * - consumer applications can call `fetch` freely. Activation is
25
+ * two-stage (W-039): the linted file path must match
26
+ * `/packages/<pkg>/src/` AND the nearest package.json's `name` must
27
+ * start with one of `options[0].packagePrefixes` (default
28
+ * `['@graphorin/']`), so a downstream pnpm monorepo with the standard
29
+ * `packages/*\/src` layout no longer gets errors on its own `fetch()`
30
+ * calls just for adopting `flat/recommended`.
31
+ *
32
+ * FAIL-OPEN fallback: when no package.json resolves above the file
33
+ * (virtual paths in editor buffers or programmatic `Linter` runs), the
34
+ * rule activates on the path match alone - exactly the pre-W-039
35
+ * behaviour, so a resolution hiccup can only over-flag framework-shaped
36
+ * paths, never silently disable the guard.
37
+ *
38
+ * @packageDocumentation
39
+ */
40
+
41
+ import { readFileSync } from 'node:fs';
42
+ import { dirname, join } from 'node:path';
43
+
44
+ import type { Rule } from 'eslint';
45
+ import type {
46
+ CallExpression,
47
+ Identifier,
48
+ ImportDeclaration,
49
+ MemberExpression,
50
+ NewExpression,
51
+ } from 'estree';
52
+
53
+ import { nodeHasNearbyComment } from './_comment-utils.js';
54
+
55
+ const ALLOW_TAG = /graphorin-allow-network/;
56
+ const FRAMEWORK_PATH_RE = /\bpackages\/[a-z0-9_-]+\/src\b/;
57
+
58
+ /**
59
+ * Directory -> nearest package.json `name` (or null when none
60
+ * resolves). Module-level so one walk serves every file of a package
61
+ * within a lint run.
62
+ */
63
+ const packageNameCache = new Map<string, string | null>();
64
+
65
+ /** Walk up from `fileDir` to the nearest package.json name (cached). */
66
+ function nearestPackageName(fileDir: string): string | null {
67
+ const cached = packageNameCache.get(fileDir);
68
+ if (cached !== undefined) return cached;
69
+ let name: string | null = null;
70
+ let dir = fileDir;
71
+ for (;;) {
72
+ try {
73
+ const raw = readFileSync(join(dir, 'package.json'), 'utf8');
74
+ const parsed = JSON.parse(raw) as { name?: unknown };
75
+ name = typeof parsed.name === 'string' ? parsed.name : null;
76
+ break;
77
+ } catch {
78
+ const parent = dirname(dir);
79
+ if (parent === dir) break;
80
+ dir = parent;
81
+ }
82
+ }
83
+ packageNameCache.set(fileDir, name);
84
+ return name;
85
+ }
86
+
87
+ const NETWORK_CALLEES = new Set(['fetch', 'XMLHttpRequest']);
88
+ const HTTP_VERBS = new Set(['get', 'post', 'put', 'patch', 'delete', 'head', 'options', 'request']);
89
+ const CLIENT_NAMESPACE_VERBS = new Set(['request', 'stream', 'fetch', 'get', 'post']);
90
+ const NETWORK_CONSTRUCTORS = new Set(['XMLHttpRequest', 'WebSocket', 'EventSource']);
91
+ const HTTP_CLIENT_SPECIFIERS = new Set(['node-fetch', 'undici', 'got', 'axios', 'ky', 'ws']);
92
+
93
+ const rule: Rule.RuleModule = {
94
+ meta: {
95
+ type: 'problem',
96
+ docs: {
97
+ description:
98
+ 'Disallow direct network primitives (`fetch`, `axios`/`undici`/`got`, `http.request`, raw `net`/`tls`/`dgram` sockets, `WebSocket`/`EventSource`/`XMLHttpRequest`, HTTP-client imports) in `@graphorin/*` framework code without an explicit opt-out comment (DEC-154).',
99
+ recommended: true,
100
+ },
101
+ schema: [
102
+ {
103
+ type: 'object',
104
+ properties: {
105
+ packagePrefixes: {
106
+ type: 'array',
107
+ items: { type: 'string' },
108
+ },
109
+ },
110
+ additionalProperties: false,
111
+ },
112
+ ],
113
+ messages: {
114
+ forbidden:
115
+ "direct network call '{{callee}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out.",
116
+ forbiddenImport:
117
+ "HTTP-client import '{{specifier}}' in framework code; user actions must initiate network I/O. Add `// graphorin-allow-network: <reason>` to opt out.",
118
+ },
119
+ },
120
+ create(context: Rule.RuleContext): Rule.RuleListener {
121
+ const filename = context.filename.replace(/\\/g, '/');
122
+ if (!FRAMEWORK_PATH_RE.test(filename)) {
123
+ return {};
124
+ }
125
+ // W-039 second stage: only packages whose manifest name matches a
126
+ // configured prefix are "framework code". No resolvable manifest ->
127
+ // fail open (path-only activation, the historical behaviour).
128
+ const options = (context.options?.[0] ?? {}) as { packagePrefixes?: readonly string[] };
129
+ const prefixes = options.packagePrefixes ?? ['@graphorin/'];
130
+ const packageName = nearestPackageName(dirname(context.filename));
131
+ if (packageName !== null && !prefixes.some((p) => packageName.startsWith(p))) {
132
+ return {};
133
+ }
134
+ return {
135
+ CallExpression(node: CallExpression): void {
136
+ const specifier = requiredClientSpecifier(node);
137
+ if (specifier !== null) {
138
+ if (hasAllowComment(context, node)) return;
139
+ context.report({
140
+ node,
141
+ messageId: 'forbiddenImport',
142
+ data: { specifier },
143
+ });
144
+ return;
145
+ }
146
+ const name = describeCallee(node);
147
+ if (name === null) return;
148
+ if (hasAllowComment(context, node)) return;
149
+ context.report({
150
+ node,
151
+ messageId: 'forbidden',
152
+ data: { callee: name },
153
+ });
154
+ },
155
+ NewExpression(node: NewExpression): void {
156
+ if (node.callee.type !== 'Identifier') return;
157
+ const callee = node.callee as Identifier;
158
+ if (!NETWORK_CONSTRUCTORS.has(callee.name)) return;
159
+ if (hasAllowComment(context, node)) return;
160
+ context.report({
161
+ node,
162
+ messageId: 'forbidden',
163
+ data: { callee: `new ${callee.name}` },
164
+ });
165
+ },
166
+ ImportDeclaration(node: ImportDeclaration): void {
167
+ const source = node.source.value;
168
+ if (typeof source !== 'string' || !HTTP_CLIENT_SPECIFIERS.has(source)) return;
169
+ if (nodeHasNearbyComment(context, node, ALLOW_TAG, 1)) return;
170
+ context.report({
171
+ node,
172
+ messageId: 'forbiddenImport',
173
+ data: { specifier: source },
174
+ });
175
+ },
176
+ ImportExpression(node: Rule.Node): void {
177
+ const source = (node as { source?: { type?: string; value?: unknown } }).source;
178
+ if (source?.type !== 'Literal' || typeof source.value !== 'string') return;
179
+ if (!HTTP_CLIENT_SPECIFIERS.has(source.value)) return;
180
+ if (nodeHasNearbyComment(context, node as never, ALLOW_TAG, 1)) return;
181
+ context.report({
182
+ node: node as never,
183
+ messageId: 'forbiddenImport',
184
+ data: { specifier: source.value },
185
+ });
186
+ },
187
+ };
188
+ },
189
+ };
190
+
191
+ /** `require('<http client>')` - import-shaped despite being a call. */
192
+ function requiredClientSpecifier(node: CallExpression): string | null {
193
+ if (node.callee.type !== 'Identifier') return null;
194
+ if ((node.callee as Identifier).name !== 'require') return null;
195
+ const arg = node.arguments[0];
196
+ if (arg === undefined || arg.type !== 'Literal') return null;
197
+ const value = (arg as { value?: unknown }).value;
198
+ if (typeof value !== 'string' || !HTTP_CLIENT_SPECIFIERS.has(value)) return null;
199
+ return value;
200
+ }
201
+
202
+ function describeCallee(node: CallExpression): string | null {
203
+ if (node.callee.type === 'Identifier') {
204
+ const name = (node.callee as Identifier).name;
205
+ if (NETWORK_CALLEES.has(name) || name === 'axios') return name;
206
+ return null;
207
+ }
208
+ if (node.callee.type === 'MemberExpression') {
209
+ const me = node.callee as MemberExpression;
210
+ if (me.computed) return null;
211
+ const objName = me.object.type === 'Identifier' ? (me.object as Identifier).name : null;
212
+ if (objName === null) return null;
213
+ const propName = me.property.type === 'Identifier' ? (me.property as Identifier).name : null;
214
+ if (propName === null) return null;
215
+ if (
216
+ (objName === 'http' || objName === 'https') &&
217
+ (propName === 'request' || propName === 'get')
218
+ )
219
+ return `${objName}.${propName}`;
220
+ if (objName === 'axios' && HTTP_VERBS.has(propName)) return `axios.${propName}`;
221
+ if ((objName === 'undici' || objName === 'got') && CLIENT_NAMESPACE_VERBS.has(propName))
222
+ return `${objName}.${propName}`;
223
+ if (objName === 'net' && (propName === 'createConnection' || propName === 'connect'))
224
+ return `net.${propName}`;
225
+ if (objName === 'tls' && propName === 'connect') return 'tls.connect';
226
+ if (objName === 'dgram' && propName === 'createSocket') return 'dgram.createSocket';
227
+ return null;
228
+ }
229
+ return null;
230
+ }
231
+
232
+ function hasAllowComment(context: Rule.RuleContext, node: CallExpression | NewExpression): boolean {
233
+ return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
234
+ }
235
+
236
+ export default rule;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Rule: `@graphorin/no-secret-in-deps` (RB-24 / DEC-137). Flags any
3
+ * call shaped like `withChildToolSecretsContext({ secretsAllowed:
4
+ * [...] }, fn)` where the `secretsAllowed` array is non-empty and the
5
+ * surrounding call site does not include a comment whose text starts
6
+ * with `'rb-24-justification:'` (case-insensitive).
7
+ *
8
+ * The rule enforces the principle-of-least-authority discipline from
9
+ * DEC-137: granting a child tool scope access to parent secrets is
10
+ * opt-in, and the opt-in MUST be explained in code so security
11
+ * reviewers can audit the inheritance graph without guessing.
12
+ *
13
+ * History: this rule originally matched `Agent.toTool({
14
+ * inheritSecrets: [...] })`, a pre-0.5 API shape that no longer
15
+ * exists - `AgentToToolOptions` deliberately has no secret-inheritance
16
+ * mechanism at that boundary. The DEC-137 grant point today is the
17
+ * explicit child ACL scope opened via `withChildToolSecretsContext`
18
+ * from `@graphorin/security` (whose `secretsAllowed` is intersected
19
+ * with the parent allowlist), so that is what the rule matches now.
20
+ *
21
+ * The rule is intentionally syntactic - it operates on the literal
22
+ * call site without trying to resolve the value of the array. This
23
+ * keeps the rule cheap and avoids false negatives from spread /
24
+ * function-call expressions that hide the inheritance shape.
25
+ *
26
+ * @packageDocumentation
27
+ */
28
+
29
+ import type { Rule } from 'eslint';
30
+ import type {
31
+ ArrayExpression,
32
+ CallExpression,
33
+ Identifier,
34
+ MemberExpression,
35
+ ObjectExpression,
36
+ Property,
37
+ } from 'estree';
38
+
39
+ import { nodeHasNearbyComment } from './_comment-utils.js';
40
+
41
+ const JUSTIFICATION_TAG = /\brb-24-justification\s*:/i;
42
+ const GRANT_CALLEE = 'withChildToolSecretsContext';
43
+
44
+ const rule: Rule.RuleModule = {
45
+ meta: {
46
+ type: 'suggestion',
47
+ docs: {
48
+ description:
49
+ 'Require an `// rb-24-justification:` comment when `withChildToolSecretsContext({ secretsAllowed: [...] })` grants a non-empty allowlist to a child tool scope (DEC-137).',
50
+ recommended: true,
51
+ },
52
+ schema: [],
53
+ messages: {
54
+ missingJustification:
55
+ '`secretsAllowed` is non-empty but the call site lacks an `// rb-24-justification: <reason>` comment. Document why this child tool scope inherits parent secrets per DEC-137.',
56
+ },
57
+ },
58
+ create(context: Rule.RuleContext): Rule.RuleListener {
59
+ return {
60
+ CallExpression(node: CallExpression): void {
61
+ if (!isGrantCall(node)) return;
62
+ const arg = node.arguments[0];
63
+ if (arg === undefined || arg.type !== 'ObjectExpression') return;
64
+ const objectArg = arg as ObjectExpression;
65
+ const secretsAllowed = findSecretsAllowedProperty(objectArg);
66
+ if (secretsAllowed === null) return;
67
+ if (!isNonEmptyArray(secretsAllowed)) return;
68
+ if (hasJustificationComment(context, node)) return;
69
+ context.report({
70
+ node: secretsAllowed,
71
+ messageId: 'missingJustification',
72
+ });
73
+ },
74
+ };
75
+ },
76
+ };
77
+
78
+ function isGrantCall(node: CallExpression): boolean {
79
+ if (node.callee.type === 'Identifier') {
80
+ return (node.callee as Identifier).name === GRANT_CALLEE;
81
+ }
82
+ if (node.callee.type === 'MemberExpression') {
83
+ const callee = node.callee as MemberExpression;
84
+ if (callee.computed) return false;
85
+ if (callee.property.type !== 'Identifier') return false;
86
+ return callee.property.name === GRANT_CALLEE;
87
+ }
88
+ return false;
89
+ }
90
+
91
+ function findSecretsAllowedProperty(obj: ObjectExpression): Property['value'] | null {
92
+ for (const prop of obj.properties) {
93
+ if (prop.type !== 'Property') continue;
94
+ const property = prop as Property;
95
+ const key = property.key;
96
+ if (property.computed) continue;
97
+ if (key.type === 'Identifier' && key.name === 'secretsAllowed') return property.value;
98
+ if (key.type === 'Literal' && key.value === 'secretsAllowed') return property.value;
99
+ }
100
+ return null;
101
+ }
102
+
103
+ function isNonEmptyArray(value: Property['value']): boolean {
104
+ if (value.type !== 'ArrayExpression') return false;
105
+ const arr = value as ArrayExpression;
106
+ return arr.elements.some((e) => e !== null);
107
+ }
108
+
109
+ function hasJustificationComment(context: Rule.RuleContext, node: CallExpression): boolean {
110
+ return nodeHasNearbyComment(context, node, JUSTIFICATION_TAG, 1);
111
+ }
112
+
113
+ export default rule;
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Rule: `@graphorin/no-secret-unwrap` (DEC-020 / ADR-026 / Phase 16).
3
+ *
4
+ * Flags member expressions that look like an unprotected unwrap of a
5
+ * `SecretValue` instance - `<expr>.unwrap()` and `<expr>.reveal()` -
6
+ * outside an allow-listed context. The framework convention is:
7
+ *
8
+ * - Prefer `value.use(fn)` / `value.useBuffer(fn)` for scoped reads;
9
+ * they limit the lifetime of the derived V8 string to a single
10
+ * callback invocation.
11
+ * - Use `value.reveal()` only as a one-shot escape hatch with an
12
+ * adjacent justification. The audit log records the call.
13
+ * - Never use `value.unwrap()` - it is `@deprecated` and is an alias
14
+ * for `.reveal()` retained only for the v0.x compatibility window.
15
+ * The rule reports `unwrap()` as `'error'` even when an
16
+ * opt-out comment is present so the deprecation cliff stays sharp.
17
+ *
18
+ * Per-call opt-out: `// graphorin-allow-secret-unwrap: <reason>` on
19
+ * the line above the call site or anywhere inside the enclosing
20
+ * expression-statement source span. The opt-out is honoured for
21
+ * `reveal()` but **not** for `unwrap()` (the deprecation supersedes
22
+ * any local justification).
23
+ *
24
+ * The rule is intentionally lexical - it matches `.unwrap()` /
25
+ * `.reveal()` on any `MemberExpression` regardless of the receiver
26
+ * type. False positives are tolerated because (a) the receiver is
27
+ * almost always a `SecretValue` in this codebase and (b) the
28
+ * one-line opt-out is cheap.
29
+ *
30
+ * Known collision (W-043): Zod's `ZodOptional`/`ZodNullable`/
31
+ * `ZodDefault` expose `.unwrap()`, and Rust-style result libraries use
32
+ * it too - schema-introspection code trips the rule. The
33
+ * `allowReceiverPattern` option carves those receivers out: when the
34
+ * SOURCE TEXT of the receiver expression matches the pattern, both
35
+ * `unwrap` and `reveal` reports are skipped. The default stays
36
+ * undefined (byte-identical historical behaviour - the deprecation
37
+ * cliff keeps its edge), and there is deliberately NO built-in
38
+ * "looks like Zod" auto-heuristic: a nondeterministic guess is worse
39
+ * than an explicit, narrow setting (pick a suffix pattern like
40
+ * `'Schema$'`, or prefer a file-glob rule override).
41
+ *
42
+ * @packageDocumentation
43
+ */
44
+
45
+ import type { Rule } from 'eslint';
46
+ import type { CallExpression, Identifier, MemberExpression } from 'estree';
47
+
48
+ import { nodeHasNearbyComment } from './_comment-utils.js';
49
+
50
+ const ALLOW_TAG = /graphorin-allow-secret-unwrap/;
51
+
52
+ const rule: Rule.RuleModule = {
53
+ meta: {
54
+ type: 'problem',
55
+ docs: {
56
+ description:
57
+ 'Disallow `.unwrap()` / `.reveal()` calls on `SecretValue` instances. Prefer `.use(fn)` (scoped) or attach a `// graphorin-allow-secret-unwrap: <reason>` opt-out comment for `.reveal()`.',
58
+ recommended: true,
59
+ },
60
+ schema: [
61
+ {
62
+ type: 'object',
63
+ properties: {
64
+ allowReceiverPattern: { type: 'string' },
65
+ },
66
+ additionalProperties: false,
67
+ },
68
+ ],
69
+ messages: {
70
+ avoidReveal:
71
+ '`.reveal()` returns the unwrapped secret as a V8 string. Prefer `.use(fn)` so the value is scoped to a single callback. Add `// graphorin-allow-secret-unwrap: <reason>` to opt out.',
72
+ avoidUnwrap:
73
+ '`.unwrap()` is deprecated - call `.reveal()` (audited) or `.use(fn)` (scoped). The opt-out comment is intentionally NOT honoured for `.unwrap()` so the deprecation stays sharp.',
74
+ },
75
+ },
76
+ create(context: Rule.RuleContext): Rule.RuleListener {
77
+ const options = (context.options?.[0] ?? {}) as { allowReceiverPattern?: string };
78
+ const allowReceiver =
79
+ options.allowReceiverPattern !== undefined ? new RegExp(options.allowReceiverPattern) : null;
80
+ return {
81
+ CallExpression(node: CallExpression): void {
82
+ const callee = node.callee;
83
+ if (callee.type !== 'MemberExpression') return;
84
+ const me = callee as MemberExpression;
85
+ if (me.computed) return;
86
+ if (me.property.type !== 'Identifier') return;
87
+ const propName = (me.property as Identifier).name;
88
+ if (propName !== 'unwrap' && propName !== 'reveal') return;
89
+ // W-043 carve-out: a receiver whose source text matches the
90
+ // configured pattern is not a SecretValue (Zod schemas,
91
+ // Rust-style results) - skip both report paths.
92
+ if (allowReceiver?.test(context.sourceCode.getText(me.object)) === true) {
93
+ return;
94
+ }
95
+ if (propName === 'unwrap') {
96
+ context.report({
97
+ node,
98
+ messageId: 'avoidUnwrap',
99
+ });
100
+ return;
101
+ }
102
+ if (propName === 'reveal') {
103
+ if (hasAllowComment(context, node)) return;
104
+ context.report({
105
+ node,
106
+ messageId: 'avoidReveal',
107
+ });
108
+ }
109
+ },
110
+ };
111
+ },
112
+ };
113
+
114
+ function hasAllowComment(context: Rule.RuleContext, node: CallExpression): boolean {
115
+ return nodeHasNearbyComment(context, node, ALLOW_TAG, 1);
116
+ }
117
+
118
+ export default rule;