eslint-plugin-ownership 0.0.0-stage → 0.4.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/README.md CHANGED
@@ -1,3 +1,112 @@
1
- # Temporary Holding Version
1
+ # eslint-plugin-ownership
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Path-sensitive local ownership checking for TypeScript and roblox-ts. **v0.4.0 auto-discovers contracts from JSDoc** through TypeScript symbols. No method lists are needed in ESLint configuration.
4
+
5
+ ## Install
6
+
7
+ ```bash
8
+ npm install -D eslint @typescript-eslint/parser typescript ./tools/eslint-plugin-ownership
9
+ ```
10
+
11
+ Use a typed parser (TypeScript project service is required for annotation discovery):
12
+
13
+ ```js
14
+ // eslint.config.mjs
15
+ import tsParser from "@typescript-eslint/parser";
16
+ import ownership from "eslint-plugin-ownership";
17
+
18
+ export default [{
19
+ files: ["**/*.ts", "**/*.tsx"],
20
+ languageOptions: {
21
+ parser: tsParser,
22
+ parserOptions: { projectService: true, tsconfigRootDir: import.meta.dirname },
23
+ },
24
+ plugins: { ownership },
25
+ rules: { "ownership/must-consume": "error" },
26
+ }];
27
+ ```
28
+
29
+ ## Add to your existing typescript-eslint flat config
30
+
31
+ Your `typescript-eslint` type-checked presets already configure the parser. Add
32
+ this import, plugin entry, and rule to your existing configuration:
33
+
34
+ ```js
35
+ import ownership from "eslint-plugin-ownership";
36
+
37
+ // Inside your existing defineConfig({ ... }):
38
+ // plugins: { async: asyncPlugin, exhaustive: exhaustivePlugin,
39
+ // result: resultPlugin, roblox: robloxPlugin, ownership },
40
+ // rules: { ...yourRules, "ownership/must-consume": "error" },
41
+ ```
42
+
43
+ Keep `languageOptions.parserOptions.projectService: true`. No separate
44
+ `@typescript-eslint/utils` dependency is required by this plugin. Tested with
45
+ ESLint 10.8.1, TypeScript 5.9.3, and `@typescript-eslint/parser` 8.72.2.
46
+
47
+ ## Annotate APIs, not ESLint configuration
48
+
49
+ ```ts
50
+ interface Transaction {
51
+ /** @ownership.consume */
52
+ commit(): Promise<void>;
53
+ /** @ownership.consume */
54
+ rollback(): Promise<void>;
55
+ }
56
+
57
+ interface Database {
58
+ /** @ownership.acquire */
59
+ beginTransaction(): Promise<Transaction>;
60
+ }
61
+
62
+ /** @ownership.transfer tx */
63
+ declare function adopt(tx: Transaction): void;
64
+
65
+ /** @ownership.borrow tx */
66
+ declare function logTransaction(tx: Transaction): void;
67
+ ```
68
+
69
+ - `@ownership.acquire` on a function or method: its return value is owned. A `Promise<T>` should be awaited before binding.
70
+ - `@ownership.consume` on a resource method: consumes the receiver on that execution path; if `requireAwait` is true (default), calls must be awaited.
71
+ - `@ownership.transfer <parameterName>` on a function or method: ownership transfers when a tracked resource is passed to that named parameter.
72
+ - `@ownership.borrow <parameterName>`: passing the resource is permitted but does not consume it.
73
+
74
+ Annotations work on declarations in imported TypeScript modules and `.d.ts` files when the parser's TypeScript project can resolve them. The rule reads declarations using TypeScript symbol resolution, not by matching names globally. An unannotated unrelated `commit()` is ignored. JSDoc has no roblox-ts runtime footprint.
75
+
76
+ ```ts
77
+ const tx = await db.beginTransaction();
78
+ await doWork(); // ERROR: tx not consumed on every exit path
79
+ ```
80
+
81
+ ```ts
82
+ const tx = await db.beginTransaction();
83
+ try {
84
+ await doWork();
85
+ await tx.commit();
86
+ } catch (error) {
87
+ await tx.rollback();
88
+ throw error;
89
+ }
90
+ ```
91
+
92
+ ## Explicit opt-in legacy configuration
93
+
94
+ Existing resource registries are still accepted:
95
+
96
+ ```js
97
+ "ownership/must-consume": ["error", {
98
+ resources: [{
99
+ type: "Transaction",
100
+ acquire: ["beginTransaction"],
101
+ consume: ["commit", "rollback"],
102
+ }],
103
+ }]
104
+ ```
105
+
106
+ Or use `acquire` and `consume` method lists for name-only compatibility. **Name-only detection is disabled by default**, avoiding false positives on unannotated APIs. Annotations take precedence when present. The options `requireAwait`, `maxPaths`, and `maxLoopIterations` still apply.
107
+
108
+ ## Analysis limitations
109
+
110
+ This is a conservative intraprocedural checker with branch-sensitive states, aliases, and a bounded loop worklist. It does not prove behavior across arbitrary called functions, reassignment, destructuring, closure capture, all exceptions, or complex property aliases. Unannotated function calls receiving owned values are reported as escapes. Finalizer calls are assumed to complete ownership transitions when awaited; a rejected finalizer is **not** modeled as an exception preserving ownership. Complex `switch` and labeled control flow are unsupported. The rule does not perform whole-program ownership inference.
111
+
112
+ Run `npm test` after installing dependencies.
package/package.json CHANGED
@@ -1,6 +1,16 @@
1
1
  {
2
2
  "name": "eslint-plugin-ownership",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.4.0",
4
+ "type": "module",
5
+ "exports": {
6
+ ".": "./src/index.js"
7
+ },
8
+ "peerDependencies": {
9
+ "eslint": "10.8.1",
10
+ "typescript": "5.9.3",
11
+ "@typescript-eslint/parser": "^8.0.0"
12
+ },
13
+ "scripts": {
14
+ "test": "node --test"
15
+ }
16
+ }
package/src/index.js ADDED
@@ -0,0 +1,6 @@
1
+ import { mustConsume } from "./must-consume.js";
2
+
3
+ export default {
4
+ meta: { name: "eslint-plugin-ownership", version: "0.4.0" },
5
+ rules: { "must-consume": mustConsume },
6
+ };
@@ -0,0 +1,436 @@
1
+ import ts from "typescript";
2
+ /**
3
+ * Local, path-sensitive resource ownership checker for TypeScript/roblox-ts.
4
+ * A tracked resource is an affine value: every reachable function exit must
5
+ * consume it exactly once. This is a conservative intraprocedural analysis.
6
+ */
7
+ const DEFAULTS = {
8
+ resources: [],
9
+ acquire: [],
10
+ consume: [],
11
+ transfer: [],
12
+ requireAwait: true,
13
+ maxPaths: 256,
14
+ maxLoopIterations: 64,
15
+ };
16
+ const FUNCTIONS = new Set(["FunctionDeclaration", "FunctionExpression", "ArrowFunctionExpression"]);
17
+ const WRAPPERS = new Set(["TSAsExpression", "TSTypeAssertion", "TSNonNullExpression", "TSSatisfiesExpression", "ChainExpression"]);
18
+ const LOOPS = new Set(["WhileStatement", "DoWhileStatement", "ForStatement", "ForOfStatement", "ForInStatement"]);
19
+ const UNSUPPORTED = new Set(["SwitchStatement", "LabeledStatement", "WithStatement"]);
20
+ const unwrap = node => { while (node && (WRAPPERS.has(node.type) || node.type === "AwaitExpression")) node = node.type === "AwaitExpression" ? node.argument : node.expression; return node; };
21
+ const nameOf = node => node?.type === "Identifier" ? node.name : null;
22
+ function methodCall(node) {
23
+ node = unwrap(node);
24
+ if (node?.type !== "CallExpression" || node.optional || node.callee?.type !== "MemberExpression" || node.callee.optional || node.callee.computed) return null;
25
+ return { call: node, receiver: unwrap(node.callee.object), method: nameOf(node.callee.property) };
26
+ }
27
+ const clone = (p, exit = p.exit) => ({ states: new Map(p.states), aliases: new Map(p.aliases), exit });
28
+ const outcome = (p, exit) => ({ ...p, exit });
29
+
30
+ export const mustConsume = {
31
+ meta: {
32
+ type: "problem",
33
+ docs: { description: "Require typed resources to be consumed on every control-flow exit" },
34
+ schema: [{
35
+ type: "object", additionalProperties: false,
36
+ properties: {
37
+ resources: { type: "array", items: { type: "object", additionalProperties: false, required: ["type", "acquire", "consume"], properties: {
38
+ type: { type: "string" },
39
+ declarationFile: { type: "string" },
40
+ acquire: { type: "array", items: { type: "string" }, minItems: 1 },
41
+ consume: { type: "array", items: { type: "string" }, minItems: 1 },
42
+ transfer: { type: "array", items: { type: "string" } },
43
+ requireAwait: { type: "boolean" },
44
+ } } },
45
+ acquire: { type: "array", items: { type: "string" } },
46
+ consume: { type: "array", items: { type: "string" } },
47
+ transfer: { type: "array", items: { type: "string" } },
48
+ requireAwait: { type: "boolean" },
49
+ maxPaths: { type: "integer", minimum: 2, maximum: 4096 },
50
+ maxLoopIterations: { type: "integer", minimum: 2, maximum: 4096 },
51
+ },
52
+ }],
53
+ messages: {
54
+ leaked: "Resource '{{name}}' is not consumed on every exit path.",
55
+ doubleConsume: "Resource '{{name}}' is already consumed on this path.",
56
+ escape: "Resource '{{name}}' escapes without an explicit ownership-transfer contract.",
57
+ untracked: "Acquired resource must be bound to a local identifier.",
58
+ unawaited: "Finalizer for '{{name}}' must be awaited.",
59
+ unsupported: "Cannot prove ownership through '{{kind}}'; refactor this construct.",
60
+ limit: "Ownership analysis exceeded the configured {{kind}} limit; safety cannot be proven.",
61
+ typeInfo: "Ownership JSDoc annotations and typed resource contracts require @typescript-eslint/parser with projectService or project enabled.",
62
+ },
63
+ },
64
+ create(context) {
65
+ const opt = { ...DEFAULTS, ...context.options[0] };
66
+ const src = context.sourceCode;
67
+ const ps = src.parserServices ?? {};
68
+ const checker = ps.program?.getTypeChecker();
69
+ const reported = new Set();
70
+ function report(node, messageId, data = {}) {
71
+ const key = `${node?.range?.[0] ?? node?.start}:${messageId}:${data.name ?? ""}:${data.kind ?? ""}`;
72
+ if (reported.has(key)) return;
73
+ reported.add(key);
74
+ context.report({ node, messageId, data });
75
+ }
76
+ const hasLegacy = opt.acquire.length > 0;
77
+ const contracts = opt.resources.length ? opt.resources.map((r, i) => ({ ...r, id: i, acquireSet: new Set(r.acquire), consumeSet: new Set(r.consume), transferSet: new Set(r.transfer ?? []), requireAwait: r.requireAwait ?? opt.requireAwait }))
78
+ : hasLegacy ? [{ id: 0, type: null, acquireSet: new Set(opt.acquire), consumeSet: new Set(opt.consume), transferSet: new Set(opt.transfer), requireAwait: opt.requireAwait }] : [];
79
+ // JSDoc annotations are compile-time only; roblox-ts does not emit them.
80
+ // TypeScript parses `@ownership.acquire` as tag `ownership` and comment
81
+ // `.acquire`, so inspect the declaration AST rather than relying on a
82
+ // nonexistent tag named `ownership.acquire`.
83
+ function tsSymbol(node) {
84
+ if (!checker || !ps.esTreeNodeToTSNodeMap || !node) return null;
85
+ const target = ps.esTreeNodeToTSNodeMap.get(node);
86
+ if (!target) return null;
87
+ let symbol = checker.getSymbolAtLocation(target);
88
+ if (symbol && (symbol.flags & ts.SymbolFlags.Alias)) symbol = checker.getAliasedSymbol(symbol);
89
+ return symbol ?? null;
90
+ }
91
+ const annotationCache = new Map();
92
+ function annotations(symbol) {
93
+ if (!symbol) return [];
94
+ if (annotationCache.has(symbol)) return annotationCache.get(symbol);
95
+ const results = [];
96
+ for (const declaration of symbol.declarations ?? []) {
97
+ for (const tag of ts.getJSDocTags(declaration)) {
98
+ const name = tag.tagName.text;
99
+ const raw = typeof tag.comment === "string" ? tag.comment :
100
+ (tag.comment ?? []).map(x => x.text ?? "").join("");
101
+ const full = name === "ownership" ? raw.trim().replace(/^\./, "") :
102
+ name.startsWith("ownership.") ? name.slice("ownership.".length) + " " + raw : "";
103
+ const match = /^(acquire|consume|transfer|borrow)(?:\s+(.+))?$/.exec(full.trim());
104
+ if (match) results.push({ kind: match[1], parameter: match[2]?.trim() });
105
+ }
106
+ }
107
+ annotationCache.set(symbol, results);
108
+ return results;
109
+ }
110
+ const hasTag = (symbol, kind) => annotations(symbol).some(t => t.kind === kind);
111
+ function callSymbol(call) {
112
+ const callee = unwrap(call.callee);
113
+ if (!callee) return null;
114
+ return tsSymbol(callee.type === "MemberExpression" && !callee.computed ? callee.property : callee);
115
+ }
116
+ function annotationContract(call) {
117
+ const symbol = callSymbol(call);
118
+ if (!hasTag(symbol, "acquire")) return null;
119
+ return { id: symbol, annotated: true, type: null,
120
+ requireAwait: opt.requireAwait, consumeSet: new Set(), transferSet: new Set() };
121
+ }
122
+ function parameterEffects(call, p) {
123
+ const symbol = callSymbol(call);
124
+ const tags = annotations(symbol).filter(t => t.kind === "transfer" || t.kind === "borrow");
125
+ if (!tags.length) return { transferred: [], borrowed: [] };
126
+ const params = (symbol?.declarations ?? []).find(d => d.parameters)?.parameters ?? [];
127
+ const transferred = [], borrowed = [];
128
+ for (const tag of tags) {
129
+ const index = params.findIndex(param => ts.isIdentifier(param.name) && param.name.text === tag.parameter);
130
+ if (index < 0 || index >= call.arguments.length) continue;
131
+ const arg = call.arguments[index];
132
+ const v = bindingOf(unwrap(arg.type === "SpreadElement" ? arg.argument : arg), p);
133
+ if (!v) continue;
134
+ (tag.kind === "transfer" ? transferred : borrowed).push(v);
135
+ }
136
+ return { transferred, borrowed };
137
+ }
138
+ // Compare TS declaration symbols, not text rendered by typeToString().
139
+ // declarationFile disambiguates same-named symbols from separate modules.
140
+ function matchesSymbol(symbol, contract) {
141
+ if (!symbol || symbol.getName() !== contract.type) return false;
142
+ if (!contract.declarationFile) return true;
143
+ const file = contract.declarationFile.replaceAll("\\", "/");
144
+ return (symbol.declarations ?? []).some(d =>
145
+ d.getSourceFile().fileName.replaceAll("\\", "/").endsWith(file));
146
+ }
147
+ function typeMatches(node, contract) {
148
+ if (!contract.type) return true;
149
+ if (!checker || !ps.esTreeNodeToTSNodeMap) return false;
150
+ const tsNode = ps.esTreeNodeToTSNodeMap.get(node);
151
+ if (!tsNode) return false;
152
+ let type = checker.getTypeAtLocation(tsNode);
153
+ type = checker.getAwaitedType?.(type) ?? type;
154
+ const seen = new Set();
155
+ function matches(t) {
156
+ if (!t || seen.has(t)) return false;
157
+ seen.add(t);
158
+ if (t.isUnionOrIntersection?.()) return t.types.some(matches);
159
+ if (matchesSymbol(t.aliasSymbol, contract) || matchesSymbol(t.symbol, contract)) return true;
160
+ // Interfaces and classes may derive from a declared resource contract.
161
+ return (t.getBaseTypes?.() ?? []).some(matches);
162
+ }
163
+ return matches(type);
164
+ }
165
+ function resolvedMember(m) {
166
+ if (!checker || !ps.esTreeNodeToTSNodeMap) return true; // name-only legacy mode
167
+ const property = ps.esTreeNodeToTSNodeMap.get(m.call.callee.property);
168
+ const receiver = ps.esTreeNodeToTSNodeMap.get(m.receiver);
169
+ if (!property || !receiver) return false;
170
+ const actual = checker.getSymbolAtLocation(property);
171
+ const expected = checker.getPropertyOfType(checker.getTypeAtLocation(receiver), m.method);
172
+ return Boolean(actual && expected && actual === expected);
173
+ }
174
+ function variableFor(id) {
175
+ if (id?.type !== "Identifier") return null;
176
+ for (let scope = src.getScope(id); scope; scope = scope.upper) {
177
+ const variable = scope.set.get(id.name);
178
+ if (variable) return variable;
179
+ }
180
+ return null;
181
+ }
182
+ function bindingOf(id, p) {
183
+ const v = variableFor(id);
184
+ if (!v) return null;
185
+ return p.aliases.get(v) ?? (p.states.has(v) ? v : null);
186
+ }
187
+ function awaited(call) {
188
+ let parent = call.parent;
189
+ while (parent && WRAPPERS.has(parent.type)) parent = parent.parent;
190
+ return parent?.type === "AwaitExpression";
191
+ }
192
+ function keyOf(p) {
193
+ const states = [...p.states].map(([v, s]) => `${v.identifiers[0]?.range?.[0] ?? v.name}:${s}`).sort().join(",");
194
+ const aliases = [...p.aliases].map(([v, target]) => `${v.identifiers[0]?.range?.[0] ?? v.name}->${target.identifiers[0]?.range?.[0] ?? target.name}`).sort().join(",");
195
+ return `${p.exit}|${states}|${aliases}`;
196
+ }
197
+ function dedup(paths, node) {
198
+ const unique = [...new Map(paths.map(p => [keyOf(p), p])).values()];
199
+ if (unique.length > opt.maxPaths) {
200
+ report(node, "limit", { kind: "maxPaths" });
201
+ // Do not silently drop unproven ownership paths.
202
+ for (const p of unique) for (const [v, state] of p.states) if (state === "acquired") report(v.identifiers[0] ?? node, "leaked", { name: v.name });
203
+ }
204
+ return unique.slice(0, opt.maxPaths);
205
+ }
206
+ function checkEscape(node, p) {
207
+ const id = unwrap(node);
208
+ const resource = bindingOf(id, p);
209
+ if (resource) report(id, "escape", { name: resource.name });
210
+ }
211
+ function acquisition(node) {
212
+ const call = unwrap(node);
213
+ if (call?.type !== "CallExpression") return null;
214
+ const annotated = annotationContract(call);
215
+ if (annotated) return annotated;
216
+ const m = methodCall(call);
217
+ if (!m) return null;
218
+ return contracts.find(c => c.acquireSet.has(m.method) && typeMatches(m.call, c) && (!c.type || resolvedMember(m))) ?? null;
219
+ }
220
+ function finalized(m, p) {
221
+ const resource = bindingOf(m.receiver, p);
222
+ if (!resource) return null;
223
+ const contract = resourceContracts.get(resource);
224
+ if (!contract) return null;
225
+ if (contract.annotated) {
226
+ return hasTag(callSymbol(m.call), "consume") ? { resource, contract } : null;
227
+ }
228
+ return contract.consumeSet.has(m.method) && (!contract.type || (typeMatches(m.receiver, contract) && resolvedMember(m))) ? { resource, contract } : null;
229
+ }
230
+ function transferCall(node, p) {
231
+ const callee = unwrap(node.callee);
232
+ const fn = nameOf(callee) ?? (callee?.type === "MemberExpression" && !callee.computed ? nameOf(callee.property) : null);
233
+ const targets = [];
234
+ for (const arg of node.arguments) {
235
+ const v = bindingOf(unwrap(arg.type === "SpreadElement" ? arg.argument : arg), p);
236
+ if (v && resourceContracts.get(v)?.transferSet.has(fn)) targets.push(v);
237
+ }
238
+ return targets;
239
+ }
240
+ const resourceContracts = new Map();
241
+ function expression(node, p) {
242
+ if (!node || FUNCTIONS.has(node.type) || p.exit !== "normal") return [p];
243
+ if (WRAPPERS.has(node.type)) return expression(node.expression, p);
244
+ if (node.type === "AwaitExpression") {
245
+ const m = methodCall(node.argument);
246
+ const isFinal = m && finalized(m, p);
247
+ const isAcquire = acquisition(node.argument);
248
+ // An acquisition failure creates no owned value; caller handles the successful binding.
249
+ const failures = isFinal || isAcquire ? [] : [clone(p, "throw")];
250
+ return dedup([...failures, ...expression(node.argument, p)], node);
251
+ }
252
+ if (node.type === "CallExpression") {
253
+ const m = methodCall(node);
254
+ if (acquisition(node)) { report(node, "untracked"); return [p]; }
255
+ const final = m && finalized(m, p);
256
+ const next = clone(p);
257
+ if (final) {
258
+ if (final.contract.requireAwait && !awaited(node)) report(node, "unawaited", { name: final.resource.name });
259
+ else if (next.states.get(final.resource) === "consumed") report(node, "doubleConsume", { name: final.resource.name });
260
+ else next.states.set(final.resource, "consumed");
261
+ }
262
+ const effects = parameterEffects(node, next);
263
+ const transferred = [...new Set([...transferCall(node, next), ...effects.transferred])];
264
+ for (const v of transferred) {
265
+ if (next.states.get(v) === "consumed") report(node, "doubleConsume", { name: v.name });
266
+ next.states.set(v, "consumed");
267
+ }
268
+ for (const arg of node.arguments) {
269
+ const value = arg.type === "SpreadElement" ? arg.argument : arg;
270
+ const v = bindingOf(unwrap(value), next);
271
+ if (v && !transferred.includes(v) && !effects.borrowed.includes(v)) checkEscape(value, next);
272
+ }
273
+ let paths = [next];
274
+ if (m && !final) paths = paths.flatMap(q => expression(m.receiver, q));
275
+ else if (node.callee.type !== "Identifier" && !m) paths = paths.flatMap(q => expression(node.callee, q));
276
+ for (const arg of node.arguments) paths = paths.flatMap(q => expression(arg.type === "SpreadElement" ? arg.argument : arg, q));
277
+ return dedup(paths, node);
278
+ }
279
+ if (node.type === "Identifier" || node.type === "Literal" || node.type === "ThisExpression") return [p];
280
+ if (node.type === "AssignmentExpression") {
281
+ checkEscape(node.left, p);
282
+ checkEscape(node.right, p);
283
+ return expression(node.right, p);
284
+ }
285
+ if (node.type === "UpdateExpression") { checkEscape(node.argument, p); return [p]; }
286
+ if (node.type === "ConditionalExpression") return expression(node.test, p).flatMap(q => q.exit !== "normal" ? [q] : [...expression(node.consequent, clone(q)), ...expression(node.alternate, clone(q))]);
287
+ if (node.type === "LogicalExpression") return expression(node.left, p).flatMap(q => q.exit !== "normal" ? [q] : [clone(q), ...expression(node.right, clone(q))]);
288
+ if (node.type === "SequenceExpression") return sequence(node.expressions, [p], expression);
289
+ if (node.type === "MemberExpression") return expression(node.object, p);
290
+ if (node.type === "BinaryExpression") return expression(node.left, p).flatMap(q => expression(node.right, q));
291
+ if (node.type === "UnaryExpression") return expression(node.argument, p);
292
+ if (node.type === "ArrayExpression" || node.type === "ObjectExpression") {
293
+ const els = node.type === "ArrayExpression" ? node.elements : node.properties.map(x => x.value ?? x.argument);
294
+ for (const el of els) checkEscape(el, p);
295
+ return sequence(els.filter(Boolean), [p], expression);
296
+ }
297
+ if (node.type === "NewExpression") {
298
+ for (const arg of node.arguments ?? []) checkEscape(arg, p);
299
+ return sequence(node.arguments ?? [], [p], expression);
300
+ }
301
+ if (node.type === "TSInstantiationExpression") return expression(node.expression, p);
302
+ return [p];
303
+ }
304
+ function sequence(nodes, initial, visit) {
305
+ return nodes.reduce((paths, node) => dedup(paths.flatMap(p => p.exit === "normal" ? visit(node, p) : [p]), node), initial);
306
+ }
307
+ function block(nodes, initial) { return sequence(nodes, initial, statement); }
308
+ function loop(node, p) {
309
+ // Worklist fixpoint over a finite lattice of {acquired, consumed} states,
310
+ // alias mappings, and exit kinds. A loop is not accepted simply because
311
+ // its body contains a finalizer: the zero-iteration path is retained.
312
+ const isDo = node.type === "DoWhileStatement";
313
+ const isFor = node.type === "ForStatement";
314
+ const isIter = node.type === "ForOfStatement" || node.type === "ForInStatement";
315
+ let starts = [p];
316
+ if (isFor && node.init) starts = node.init.type === "VariableDeclaration" ? starts.flatMap(q => statement(node.init, q)) : starts.flatMap(q => expression(node.init, q));
317
+ if (isIter) starts = starts.flatMap(q => expression(node.right, q));
318
+ const exits = [];
319
+ let pending = starts;
320
+ const visited = new Set();
321
+ let iterations = 0;
322
+ while (pending.length && iterations++ < opt.maxLoopIterations) {
323
+ const current = pending;
324
+ pending = [];
325
+ for (const entry of current) {
326
+ if (entry.exit !== "normal") { exits.push(entry); continue; }
327
+ const key = keyOf(entry);
328
+ if (visited.has(key)) continue;
329
+ visited.add(key);
330
+ const tests = !isDo && node.test ? expression(node.test, entry) : [entry];
331
+ for (const t of tests) {
332
+ if (t.exit !== "normal") { exits.push(t); continue; }
333
+ if (!isDo) exits.push(clone(t)); // loop can finish without executing body
334
+ let bodyPaths = statement(node.body, clone(t));
335
+ for (let b of bodyPaths) {
336
+ if (b.exit === "break") { exits.push(clone(b, "normal")); continue; }
337
+ if (b.exit !== "normal" && b.exit !== "continue") { exits.push(b); continue; }
338
+ b = clone(b, "normal");
339
+ if (isFor && node.update) {
340
+ for (const u of expression(node.update, b)) pending.push(u);
341
+ } else if (isDo && node.test) {
342
+ for (const u of expression(node.test, b)) {
343
+ if (u.exit === "normal") { exits.push(clone(u)); pending.push(u); }
344
+ else exits.push(u);
345
+ }
346
+ } else pending.push(b);
347
+ }
348
+ }
349
+ }
350
+ pending = dedup(pending, node);
351
+ }
352
+ if (pending.length) {
353
+ report(node, "limit", { kind: "maxLoopIterations" });
354
+ // Preserve unproven paths as exits: never pronounce a possibly-live resource safe.
355
+ exits.push(...pending);
356
+ }
357
+ return dedup(exits, node);
358
+ }
359
+ function statement(node, p) {
360
+ switch (node.type) {
361
+ case "BlockStatement": return block(node.body, [p]);
362
+ case "ExportNamedDeclaration":
363
+ case "ExportDefaultDeclaration":
364
+ if (!node.declaration) return [p];
365
+ return node.declaration.type.endsWith("Declaration")
366
+ ? statement(node.declaration, p)
367
+ : expression(node.declaration, p);
368
+ case "VariableDeclaration": {
369
+ let paths = [p];
370
+ for (const decl of node.declarations) {
371
+ paths = paths.flatMap(q => {
372
+ if (q.exit !== "normal") return [q];
373
+ const contract = acquisition(decl.init);
374
+ if (contract) {
375
+ if (decl.id.type !== "Identifier") { report(decl, "untracked"); return [q]; }
376
+ const variable = variableFor(decl.id);
377
+ if (!variable) { report(decl, "untracked"); return [q]; }
378
+ resourceContracts.set(variable, contract);
379
+ const next = clone(q);
380
+ if (next.states.get(variable) === "acquired") {
381
+ report(decl.id, "leaked", { name: variable.name });
382
+ }
383
+ // A new loop iteration creates a fresh resource. Old aliases
384
+ // cannot safely stand for this new value.
385
+ for (const [alias, target] of next.aliases) {
386
+ if (target === variable) next.aliases.delete(alias);
387
+ }
388
+ next.states.set(variable, "acquired");
389
+ return decl.init?.type === "AwaitExpression" ? [clone(q, "throw"), next] : [next];
390
+ }
391
+ const alias = bindingOf(unwrap(decl.init), q);
392
+ if (alias && decl.id.type === "Identifier") {
393
+ const v = variableFor(decl.id);
394
+ if (v) { const next = clone(q); next.aliases.set(v, alias); return [next]; }
395
+ }
396
+ return expression(decl.init, q);
397
+ });
398
+ }
399
+ return dedup(paths, node);
400
+ }
401
+ case "ExpressionStatement": return expression(node.expression, p);
402
+ case "IfStatement": return expression(node.test, p).flatMap(q => q.exit !== "normal" ? [q] : dedup([
403
+ ...statement(node.consequent, clone(q)), ...(node.alternate ? statement(node.alternate, clone(q)) : [clone(q)]),
404
+ ], node));
405
+ case "TryStatement": {
406
+ const attempted = statement(node.block, p);
407
+ const caught = attempted.flatMap(q => q.exit === "throw" && node.handler ? statement(node.handler.body, clone(q, "normal")) : [q]);
408
+ if (!node.finalizer) return caught;
409
+ return dedup(caught.flatMap(q => statement(node.finalizer, clone(q, "normal")).map(f => f.exit === "normal" ? outcome(f, q.exit) : f)), node);
410
+ }
411
+ case "ReturnStatement":
412
+ case "ThrowStatement":
413
+ checkEscape(node.argument, p);
414
+ return expression(node.argument, p).map(q => q.exit === "normal" ? outcome(q, node.type === "ReturnStatement" ? "return" : "throw") : q);
415
+ case "BreakStatement": return [clone(p, "break")];
416
+ case "ContinueStatement": return [clone(p, "continue")];
417
+ case "EmptyStatement": case "FunctionDeclaration": case "TSInterfaceDeclaration": case "TSTypeAliasDeclaration": return [p];
418
+ default:
419
+ if (LOOPS.has(node.type)) return loop(node, p);
420
+ if (UNSUPPORTED.has(node.type) && [...p.states.values()].includes("acquired")) report(node, "unsupported", { kind: node.type });
421
+ return [p];
422
+ }
423
+ }
424
+ function analyze(node) {
425
+ if ((opt.resources.length || !hasLegacy) && !checker) { report(node, "typeInfo"); return; }
426
+ resourceContracts.clear();
427
+ const initial = { states: new Map(), aliases: new Map(), exit: "normal" };
428
+ const body = node.type === "Program" ? node.body : node.body?.type === "BlockStatement" ? node.body.body : null;
429
+ const paths = body ? block(body, [initial]) : expression(node.body, initial);
430
+ for (const p of paths) for (const [v, state] of p.states) {
431
+ if (state === "acquired") report(v.identifiers[0] ?? node, "leaked", { name: v.name });
432
+ }
433
+ }
434
+ return { Program: analyze, FunctionDeclaration: analyze, FunctionExpression: analyze, ArrowFunctionExpression: analyze };
435
+ },
436
+ };
@@ -0,0 +1,64 @@
1
+ import { RuleTester } from "eslint";
2
+ import parser from "@typescript-eslint/parser";
3
+ import { mustConsume } from "../src/must-consume.js";
4
+ import { dirname } from "node:path";
5
+ import { fileURLToPath } from "node:url";
6
+
7
+ const root = dirname(fileURLToPath(import.meta.url));
8
+ const tester = new RuleTester({
9
+ languageOptions: {
10
+ parser,
11
+ parserOptions: {
12
+ projectService: { allowDefaultProject: ["*.ts"], maximumDefaultProjectFileMatchCount_THIS_WILL_SLOW_DOWN_LINTING: 30 },
13
+ tsconfigRootDir: root,
14
+ },
15
+ },
16
+ });
17
+ const declarations = `
18
+ interface Transaction {
19
+ /** @ownership.consume */
20
+ commit(): Promise<void>;
21
+ /** @ownership.consume */
22
+ rollback(): Promise<void>;
23
+ inspect(): void;
24
+ }
25
+ declare const db: {
26
+ /** @ownership.acquire */
27
+ beginTransaction(): Promise<Transaction>;
28
+ };
29
+ declare function work(): Promise<void>;
30
+ declare function shouldCommit(): boolean;
31
+ /** @ownership.transfer tx */
32
+ declare function adopt(tx: Transaction): void;
33
+ /** @ownership.borrow tx */
34
+ declare function observe(tx: Transaction): void;
35
+ declare const unrelated: { commit(): Promise<void> };
36
+ /** @ownership.acquire */
37
+ declare function getResource(): Transaction;
38
+ `;
39
+ const code = body => declarations + `\nasync function example() { ${body} }`;
40
+ const valid = (name, body) => ({ filename: `annotation-${name}.ts`, code: code(body) });
41
+ const invalid = (name, body, errors) => ({ filename: `annotation-${name}.ts`, code: code(body), errors: errors.map(messageId => ({ messageId })) });
42
+
43
+ tester.run("must-consume (JSDoc auto-discovery)", mustConsume, {
44
+ valid: [
45
+ valid("commit", `const tx = await db.beginTransaction(); await tx.commit();`),
46
+ valid("rollback", `const tx = await db.beginTransaction(); await tx.rollback();`),
47
+ valid("try", `const tx = await db.beginTransaction(); try { await work(); await tx.commit(); } catch (e) { await tx.rollback(); throw e; }`),
48
+ valid("transfer", `const tx = await db.beginTransaction(); adopt(tx);`),
49
+ valid("borrow", `const tx = await db.beginTransaction(); observe(tx); await tx.rollback();`),
50
+ valid("alias", `const tx = await db.beginTransaction(); const alias = tx; await alias.commit();`),
51
+ valid("function", `const tx = getResource(); await tx.rollback();`),
52
+ valid("unrelated", `await unrelated.commit();`),
53
+ valid("loop", `while (shouldCommit()) { const tx = await db.beginTransaction(); await tx.rollback(); }`),
54
+ ],
55
+ invalid: [
56
+ invalid("leak", `const tx = await db.beginTransaction(); await work();`, ["leaked"]),
57
+ invalid("conditional", `const tx = await db.beginTransaction(); if (shouldCommit()) await tx.commit();`, ["leaked"]),
58
+ invalid("double", `const tx = await db.beginTransaction(); await tx.commit(); await tx.rollback();`, ["doubleConsume"]),
59
+ invalid("unawaited", `const tx = await db.beginTransaction(); tx.commit();`, ["leaked", "unawaited"]),
60
+ invalid("borrow-leak", `const tx = await db.beginTransaction(); observe(tx);`, ["leaked"]),
61
+ invalid("escape", `const tx = await db.beginTransaction(); work(tx);`, ["leaked", "escape"]),
62
+ invalid("loop-zero", `const tx = await db.beginTransaction(); while (shouldCommit()) await tx.commit();`, ["leaked", "doubleConsume"]),
63
+ ],
64
+ });
@@ -0,0 +1,34 @@
1
+ import assert from "node:assert/strict";
2
+ import { test } from "node:test";
3
+ import { Linter } from "eslint";
4
+ import { defineConfig } from "eslint/config";
5
+ import parser from "@typescript-eslint/parser";
6
+ import ownership from "../src/index.js";
7
+
8
+ test("flat config discovers annotations through typed parser services", () => {
9
+ const config = defineConfig({
10
+ files: ["**/*.ts"],
11
+ languageOptions: {
12
+ parser,
13
+ parserOptions: {
14
+ projectService: { allowDefaultProject: ["integration.ts"] },
15
+ tsconfigRootDir: process.cwd(),
16
+ },
17
+ },
18
+ plugins: { ownership },
19
+ rules: { "ownership/must-consume": "error" },
20
+ });
21
+ const declarations = `
22
+ interface Resource {
23
+ /** @ownership.consume */
24
+ close(): void;
25
+ }
26
+ /** @ownership.acquire */
27
+ declare function acquire(): Resource;
28
+ `;
29
+ const linter = new Linter();
30
+ const lint = body => linter.verify(declarations + body, config, { filename: "integration.ts" });
31
+ assert.deepEqual(lint("export const resource = acquire();").map(m => m.messageId), ["leaked"]);
32
+ assert.deepEqual(lint("async function run() { const resource = acquire(); await resource.close(); }"), []);
33
+ assert.equal(ownership.meta.version, "0.4.0");
34
+ });
@@ -0,0 +1,114 @@
1
+ import { RuleTester } from "eslint";
2
+ import parser from "@typescript-eslint/parser";
3
+ import { mustConsume } from "../src/must-consume.js";
4
+
5
+ const tester = new RuleTester({
6
+ languageOptions: { parser, parserOptions: { ecmaVersion: "latest", sourceType: "module" } },
7
+ });
8
+
9
+ const legacy = { acquire: ["beginTransaction"], consume: ["commit", "rollback"] };
10
+ tester.run("must-consume", mustConsume, {
11
+ valid: [
12
+ `async function work() {
13
+ const transaction = await db.beginTransaction();
14
+ try { await doWork(); await transaction.commit(); }
15
+ catch (error) { await transaction.rollback(); throw error; }
16
+ }`,
17
+ `async function work(flag: boolean) {
18
+ const tx = await db.beginTransaction();
19
+ if (flag) await tx.commit(); else await tx.rollback();
20
+ }`,
21
+ `async function work() {
22
+ const tx = await db.beginTransaction();
23
+ try { await doWork(); } finally { await tx.rollback(); }
24
+ }`,
25
+ `async function work() {
26
+ const tx = await db.beginTransaction();
27
+ const alias = tx;
28
+ await alias.rollback();
29
+ }`,
30
+ `async function work() {
31
+ while (ready()) {
32
+ const tx = await db.beginTransaction();
33
+ await tx.commit();
34
+ }
35
+ }`,
36
+ `async function work() {
37
+ for (let i = 0; i < 3; i++) {
38
+ const tx = await db.beginTransaction();
39
+ await tx.rollback();
40
+ }
41
+ }`,
42
+ {
43
+ code: `async function work() {
44
+ const tx = await db.beginTransaction();
45
+ handOff(tx);
46
+ }`,
47
+ options: [{ transfer: ["handOff"] }],
48
+ },
49
+ ].map(item => typeof item === "string" ? { code: item, options: [legacy] } : { ...item, options: [{ ...legacy, ...(item.options?.[0] ?? {}) }] }),
50
+ invalid: [
51
+ {
52
+ code: `export const tx = await db.beginTransaction();`,
53
+ errors: [{ messageId: "leaked" }],
54
+ },
55
+ {
56
+ code: `async function work() {
57
+ while (ready()) {
58
+ const tx = await db.beginTransaction();
59
+ if (ready()) continue;
60
+ await tx.commit();
61
+ }
62
+ }`,
63
+ errors: [{ messageId: "leaked" }],
64
+ },
65
+ {
66
+ code: `async function work() { const tx = await db.beginTransaction(); await doWork(); }`,
67
+ errors: [{ messageId: "leaked" }],
68
+ },
69
+ {
70
+ code: `async function work(flag: boolean) {
71
+ const tx = await db.beginTransaction();
72
+ if (flag) await tx.commit();
73
+ }`,
74
+ errors: [{ messageId: "leaked" }],
75
+ },
76
+ {
77
+ code: `async function work() { const tx = await db.beginTransaction(); return 123; }`,
78
+ errors: [{ messageId: "leaked" }],
79
+ },
80
+ {
81
+ code: `async function work() { const tx = await db.beginTransaction(); tx.commit(); }`,
82
+ errors: [{ messageId: "leaked" }, { messageId: "unawaited" }],
83
+ },
84
+ {
85
+ code: `async function work() {
86
+ const tx = await db.beginTransaction();
87
+ await tx.commit(); await tx.rollback();
88
+ }`,
89
+ errors: [{ messageId: "doubleConsume" }],
90
+ },
91
+ {
92
+ code: `async function work() {
93
+ const tx = await db.beginTransaction();
94
+ const ref = tx;
95
+ await ref.commit(); await tx.rollback();
96
+ }`,
97
+ errors: [{ messageId: "doubleConsume" }],
98
+ },
99
+ {
100
+ code: `async function work() {
101
+ const tx = await db.beginTransaction();
102
+ while (ready()) await tx.commit();
103
+ }`,
104
+ errors: [{ messageId: "leaked" }, { messageId: "doubleConsume" }],
105
+ },
106
+ {
107
+ code: `async function work() {
108
+ const tx = await db.beginTransaction();
109
+ consumeElsewhere(tx);
110
+ }`,
111
+ errors: [{ messageId: "leaked" }, { messageId: "escape" }],
112
+ },
113
+ ].map(item => ({ ...item, options: [legacy] })),
114
+ });
@@ -0,0 +1,82 @@
1
+ import { RuleTester } from "eslint";
2
+ import parser from "@typescript-eslint/parser";
3
+ import { mustConsume } from "../src/must-consume.js";
4
+ import { fileURLToPath } from "node:url";
5
+ import { dirname } from "node:path";
6
+
7
+ const root = dirname(fileURLToPath(import.meta.url));
8
+ const config = [{ resources: [{ type: "Transaction", acquire: ["beginTransaction"], consume: ["commit", "rollback"] }] }];
9
+ const prelude = `
10
+ interface Transaction { commit(): Promise<void>; rollback(): Promise<void> }
11
+ interface Other { commit(): Promise<void> }
12
+ declare const db: { beginTransaction(): Promise<Transaction> };
13
+ declare const other: { beginTransaction(): Promise<Other> };
14
+ declare function work(): Promise<void>;
15
+ declare function ready(): boolean;
16
+ `;
17
+
18
+ const tester = new RuleTester({
19
+ languageOptions: {
20
+ parser,
21
+ parserOptions: {
22
+ projectService: { allowDefaultProject: ["*.ts"] },
23
+ tsconfigRootDir: root,
24
+ },
25
+ },
26
+ });
27
+
28
+ tester.run("must-consume (typed)", mustConsume, {
29
+ valid: [
30
+ {
31
+ filename: "typed-valid.ts",
32
+ options: config,
33
+ code: prelude + `async function f() {
34
+ const tx = await db.beginTransaction();
35
+ try { await work(); await tx.commit(); }
36
+ catch (e) { await tx.rollback(); throw e; }
37
+ }`,
38
+ },
39
+ {
40
+ filename: "typed-unrelated.ts",
41
+ options: config,
42
+ code: prelude + `async function f() { const value = await other.beginTransaction(); }`,
43
+ },
44
+ {
45
+ filename: "typed-loop-good.ts",
46
+ options: config,
47
+ code: prelude + `async function f() {
48
+ while (ready()) {
49
+ const tx = await db.beginTransaction();
50
+ await tx.rollback();
51
+ }
52
+ }`,
53
+ },
54
+ ],
55
+ invalid: [
56
+ {
57
+ filename: "typed-leak.ts",
58
+ options: config,
59
+ code: prelude + `async function f() { const tx = await db.beginTransaction(); await work(); }`,
60
+ errors: [{ messageId: "leaked" }],
61
+ },
62
+ {
63
+ filename: "typed-loop-leak.ts",
64
+ options: config,
65
+ code: prelude + `async function f() {
66
+ const tx = await db.beginTransaction();
67
+ while (ready()) await tx.commit();
68
+ }`,
69
+ errors: [{ messageId: "leaked" }, { messageId: "doubleConsume" }],
70
+ },
71
+ {
72
+ filename: "typed-double.ts",
73
+ options: config,
74
+ code: prelude + `async function f() {
75
+ const tx = await db.beginTransaction();
76
+ await tx.commit();
77
+ await tx.rollback();
78
+ }`,
79
+ errors: [{ messageId: "doubleConsume" }],
80
+ },
81
+ ],
82
+ });