@alint-js/plugin-simplicity 0.0.22

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/dist/index.mjs ADDED
@@ -0,0 +1,1303 @@
1
+ import { createRequire } from "node:module";
2
+ import { definePlugin, defineRule } from "@alint-js/core";
3
+ import { createHash } from "node:crypto";
4
+ import { defineTool, requireAgent } from "@alint-js/core/agent";
5
+ import { errorMessageFrom } from "@moeru/std/error";
6
+ import { minimatch } from "minimatch";
7
+ import { relative } from "pathe";
8
+ import Parser from "web-tree-sitter";
9
+ import { dirname, extname, join, relative as relative$1 } from "node:path";
10
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
11
+ import { DEFAULT_IGNORE_PATTERNS, listFiles } from "@alint-js/tools-fs";
12
+ import { array, boolean, description, getDotPath, integer, minValue, number, object, optional, pipe, safeParse, string } from "valibot";
13
+ import { formatOutputLanguageInstruction, generateStructured } from "@alint-js/core/structured-output";
14
+ //#region src/rules/no-duplicated-helper/prompt.ts
15
+ const duplicatedHelperInstructions = [
16
+ "You review small helper functions in one file, and decide whether any of them reimplements a helper that already exists elsewhere in this workspace.",
17
+ "",
18
+ "WHAT IS ALREADY DONE, AND MUST NOT BE REPEATED:",
19
+ "Helpers that are character-for-character identical, and helpers that differ only in the names they declare (their own name, their parameters, their locals), have ALREADY been found and reported without a model. Do not look for those, and do not report them. Your job is the case a fingerprint cannot settle: two helpers written differently that carry the same responsibility.",
20
+ "",
21
+ "THE QUESTION YOU ARE ANSWERING:",
22
+ "Not \"could one of these be deleted outright?\" but \"do these two answer the same question, and should they therefore live in one place?\" Two helpers can share a responsibility without being interchangeable at a call site, and they still belong together.",
23
+ "",
24
+ "THREE WORKED EXAMPLES. None of these helpers is in the workspace you are reviewing; they are here to show what the question means.",
25
+ "",
26
+ "EXAMPLE 1 — report it.",
27
+ " A: function isTimeout(error: unknown): boolean {",
28
+ " return error instanceof Error && error.name === 'TimeoutError'",
29
+ " }",
30
+ " B: function timedOut(value: unknown): boolean {",
31
+ " return isError(value) && 'name' in value && value.name === 'TimeoutError'",
32
+ " }",
33
+ " Duplicate. Both ask whether an error is a timeout. They share no name and no",
34
+ " fingerprint matches them: one uses `instanceof`, the other a helper and a key",
35
+ " check. One question, two shapes — which is exactly the case you are here for.",
36
+ " reason: \"Both ask whether an error is a timeout.\"",
37
+ "",
38
+ "EXAMPLE 2 — do NOT report it.",
39
+ " A: function firstLine(text: string): string {",
40
+ " return text.split('\\n')[0]",
41
+ " }",
42
+ " B: function firstWord(text: string): string {",
43
+ " return text.split(' ')[0]",
44
+ " }",
45
+ " Not a duplicate. One shape, two questions: a line is not a word, and the",
46
+ " separator they split on is the whole difference between them. A shape is not a",
47
+ " responsibility, and two helpers that merely look alike are the commonest way to",
48
+ " be wrong here.",
49
+ "",
50
+ "EXAMPLE 3 — report it, and note why it is not obvious.",
51
+ " A: function isHttpError(error: unknown): error is HttpError {",
52
+ " return error instanceof HttpError",
53
+ " }",
54
+ " B: function isHttpStatus(error: unknown, status: number): boolean {",
55
+ " return error instanceof HttpError && error.status === status",
56
+ " }",
57
+ " Duplicate. Both ask whether a value is an HTTP error; the second is the narrowed",
58
+ " form of the first. Neither can literally replace the other — only the guard",
59
+ " narrows the type at a call site — and they are still one family that belongs in",
60
+ " one place. Do not let \"they are not interchangeable\" talk you out of a duplicate.",
61
+ " reason: \"Both ask whether an error is an HTTP error.\"",
62
+ "",
63
+ "And when you are unsure, say nothing. A false report costs a reader's trust in",
64
+ "every other report; silence costs almost nothing, because the same helper will be",
65
+ "seen again on the next run.",
66
+ "",
67
+ "HOW TO WORK, one helper at a time:",
68
+ "1. Say in one sentence what the helper does. Describe the behavior, not the name.",
69
+ "2. The helpers that most resemble it are ALREADY BELOW, in full. Go through them one at a time and say, for each, whether it carries the same responsibility as the helper under review — and why, or why not. Work from the bodies, and do not dismiss one because its name is different: a reimplementation always has a different name. That is what makes it hard to see.",
70
+ "3. The ranking that put them there is crude — it counts shared words — so it can rank a stranger above a twin, and the twin may not be there at all. If none of these is it and you believe one exists, go and look: search_helper_bodies searches inside bodies for what the code DOES, such as `instanceof Error` or `.trim()`. Never search for the name. list_helpers, find_similar and get_helper are there too.",
71
+ "4. Never decide from a name or a rank. Decide from a body you have read.",
72
+ "5. Compare behavior against the rubric above, then decide. The order in which two helpers were shown to you means nothing.",
73
+ "6. If it is a duplicate, call report_duplicate once. In `reason`, name the responsibility the two share, in at most twelve words. It is read at the end of a line, beside two names and a path — so name the shared job, not what each helper does, and not what should be done about it. \"Both ask whether an error is a missing-file error.\" is the right length.",
74
+ "",
75
+ "If nothing qualifies, report nothing and say so. Reporting nothing is a common and correct outcome."
76
+ ].join("\n");
77
+ /**
78
+ * The nearest helpers are pasted in rather than fetched: measured, the agent spent three of its four steps discovering what to read,
79
+ * and every step re-sends the system prompt and every tool schema.
80
+ */
81
+ function buildDuplicatedHelperPrompt(options) {
82
+ const { candidates, filePath, helpers } = options;
83
+ return [
84
+ `Review the helpers of: ${filePath}`,
85
+ "",
86
+ "These are the helpers to review. No fingerprint could settle them, so each one is either original, or a reimplementation of something written differently elsewhere.",
87
+ "",
88
+ ...helpers.map((helper) => `--- ${helper.id} (${helper.name}, ${helper.language})\n${helper.text}`),
89
+ "",
90
+ candidates.length === 0 ? "No other helper in the workspace resembles these, so there is nothing to compare them against unless you go looking." : [
91
+ "These are the helpers elsewhere in the workspace that most resemble them, closest first. They are a place to start, not a shortlist: the twin may not be here, and most of these will be strangers that happen to look alike.",
92
+ "",
93
+ ...candidates.map((candidate) => `--- ${candidate.id} (${candidate.name}, ${candidate.language})\n${candidate.text}`)
94
+ ].join("\n"),
95
+ "",
96
+ `Work through the ${helpers.length === 1 ? "helper" : `${helpers.length} helpers`} one at a time, following the procedure.`
97
+ ].join("\n");
98
+ }
99
+ //#endregion
100
+ //#region src/extract/parser.ts
101
+ const grammarDir = join(dirname(createRequire(import.meta.url).resolve("tree-sitter-wasms/package.json")), "out");
102
+ const GRAMMAR = {
103
+ go: "tree-sitter-go.wasm",
104
+ javascript: "tree-sitter-typescript.wasm",
105
+ python: "tree-sitter-python.wasm",
106
+ rust: "tree-sitter-rust.wasm",
107
+ tsx: "tree-sitter-tsx.wasm",
108
+ typescript: "tree-sitter-typescript.wasm"
109
+ };
110
+ let ready;
111
+ const grammars = /* @__PURE__ */ new Map();
112
+ /** Grammars are loaded once per process; each WASM load costs tens of milliseconds. */
113
+ async function grammarFor(language) {
114
+ ready ??= Parser.init();
115
+ await ready;
116
+ let grammar = grammars.get(language);
117
+ if (grammar === void 0) {
118
+ grammar = await Parser.Language.load(join(grammarDir, GRAMMAR[language]));
119
+ grammars.set(language, grammar);
120
+ }
121
+ return grammar;
122
+ }
123
+ //#endregion
124
+ //#region src/extract/queries.ts
125
+ /** Go spells a struct field and a method name alike, so `field_identifier` is never renameable. */
126
+ const GO = `
127
+ (function_declaration) @function
128
+ (method_declaration) @function
129
+ (comment) @comment
130
+ (call_expression function: (identifier) @call)
131
+ (call_expression function: (selector_expression field: (field_identifier) @call))
132
+ (identifier) @identifier
133
+
134
+ (function_declaration name: (identifier) @binder)
135
+ (parameter_declaration name: (identifier) @binder)
136
+ (variadic_parameter_declaration name: (identifier) @binder)
137
+ (short_var_declaration left: (expression_list (identifier) @binder))
138
+ (var_spec name: (identifier) @binder)
139
+ (const_spec name: (identifier) @binder)
140
+ (range_clause left: (expression_list (identifier) @binder))
141
+ `;
142
+ const PYTHON = `
143
+ (function_definition) @function
144
+ (comment) @comment
145
+ (function_definition body: (block . (expression_statement (string) @comment)))
146
+ (call function: (identifier) @call)
147
+ (call function: (attribute attribute: (identifier) @call))
148
+ (identifier) @identifier
149
+ (attribute attribute: (identifier) @anchor)
150
+ (keyword_argument name: (identifier) @anchor)
151
+
152
+ (function_definition name: (identifier) @binder)
153
+ (parameters (identifier) @binder)
154
+ (default_parameter name: (identifier) @binder)
155
+ (typed_parameter (identifier) @binder)
156
+ (typed_default_parameter name: (identifier) @binder)
157
+ (assignment left: (identifier) @binder)
158
+ (for_statement left: (identifier) @binder)
159
+ (lambda_parameters (identifier) @binder)
160
+ `;
161
+ /** `macro_invocation` (`println!`, `vec!`) is not a `call_expression`, so macros are not counted as calls. */
162
+ const RUST = `
163
+ (function_item) @function
164
+ (line_comment) @comment
165
+ (block_comment) @comment
166
+ (call_expression function: (identifier) @call)
167
+ (call_expression function: (field_expression field: (field_identifier) @call))
168
+ (call_expression function: (scoped_identifier name: (identifier) @call))
169
+ (identifier) @identifier
170
+
171
+ (function_item name: (identifier) @binder)
172
+ (parameter pattern: (identifier) @binder)
173
+ (let_declaration pattern: (identifier) @binder)
174
+ (closure_parameters (identifier) @binder)
175
+ `;
176
+ const TYPESCRIPT = `
177
+ (function_declaration) @function
178
+ (method_definition) @function
179
+ (variable_declarator name: (identifier) value: (arrow_function)) @function
180
+ (variable_declarator name: (identifier) value: (function_expression)) @function
181
+ (comment) @comment
182
+ (call_expression function: (identifier) @call)
183
+ (call_expression function: (member_expression property: (property_identifier) @call))
184
+ (identifier) @identifier
185
+
186
+ (export_statement !source (export_clause (export_specifier name: (identifier) @export)))
187
+ (export_statement value: (identifier) @export)
188
+
189
+ (function_declaration name: (identifier) @binder)
190
+ (function_expression name: (identifier) @binder)
191
+ (required_parameter pattern: (identifier) @binder)
192
+ (optional_parameter pattern: (identifier) @binder)
193
+ (arrow_function parameter: (identifier) @binder)
194
+ (variable_declarator name: (identifier) @binder)
195
+ (catch_clause parameter: (identifier) @binder)
196
+ (pair_pattern value: (identifier) @binder)
197
+ (array_pattern (identifier) @binder)
198
+ (rest_pattern (identifier) @binder)
199
+ `;
200
+ const QUERY = {
201
+ go: GO,
202
+ javascript: TYPESCRIPT,
203
+ python: PYTHON,
204
+ rust: RUST,
205
+ tsx: TYPESCRIPT,
206
+ typescript: TYPESCRIPT
207
+ };
208
+ function querySource(language) {
209
+ return QUERY[language];
210
+ }
211
+ //#endregion
212
+ //#region src/extract/extract.ts
213
+ const queries = /* @__PURE__ */ new Map();
214
+ async function extractSource(source, language) {
215
+ const grammar = await grammarFor(language);
216
+ const parser = new Parser();
217
+ parser.setLanguage(grammar);
218
+ const tree = parser.parse(source);
219
+ let query = queries.get(language);
220
+ if (query === void 0) {
221
+ query = grammar.query(querySource(language));
222
+ queries.set(language, query);
223
+ }
224
+ const calls = [];
225
+ const comments = [];
226
+ const identifiers = [];
227
+ const anchored = /* @__PURE__ */ new Set();
228
+ const binders = [];
229
+ const functionNodes = [];
230
+ const exportedNames = /* @__PURE__ */ new Set();
231
+ for (const { name, node } of query.captures(tree.rootNode)) switch (name) {
232
+ case "anchor":
233
+ anchored.add(node.startIndex);
234
+ break;
235
+ case "binder":
236
+ binders.push(node);
237
+ break;
238
+ case "call":
239
+ calls.push({
240
+ name: node.text,
241
+ range: rangeOf(node)
242
+ });
243
+ break;
244
+ case "comment":
245
+ comments.push(rangeOf(node));
246
+ break;
247
+ case "export":
248
+ exportedNames.add(node.text);
249
+ break;
250
+ case "function":
251
+ functionNodes.push(node);
252
+ break;
253
+ case "identifier":
254
+ identifiers.push(rangeOf(node));
255
+ break;
256
+ }
257
+ const renameable = identifiers.filter((range) => !anchored.has(range.start));
258
+ const commentStarts = new Set(comments.map((range) => range.start));
259
+ return {
260
+ calls,
261
+ functions: functionNodes.map((node) => extractFunction(node, source, language, comments, commentStarts, renameable, binders, exportedNames))
262
+ };
263
+ }
264
+ function bodyIsSingleExpressionOf(node, commentStarts) {
265
+ const { arrowExpression, statements } = bodyOf(node, commentStarts);
266
+ if (arrowExpression) return true;
267
+ return statements.length === 1 && !holdsBlock(statements[0]);
268
+ }
269
+ /** The body's statements, with comments left out, and whether the body is a bare arrow expression. */
270
+ function bodyOf(node, commentStarts) {
271
+ const callable = callableOf(node);
272
+ const body = callable.childForFieldName("body");
273
+ if (body === null) return {
274
+ arrowExpression: false,
275
+ statements: []
276
+ };
277
+ if (callable.type === "arrow_function" && body.type !== "statement_block") return {
278
+ arrowExpression: true,
279
+ statements: [body]
280
+ };
281
+ const statements = [];
282
+ for (let index = 0; index < body.namedChildCount; index += 1) {
283
+ const child = body.namedChild(index);
284
+ if (child !== null && !commentStarts.has(child.startIndex)) statements.push(child);
285
+ }
286
+ return {
287
+ arrowExpression: false,
288
+ statements
289
+ };
290
+ }
291
+ /**
292
+ * A comment is a named child of its block in every grammar here, so comments must be filtered out
293
+ * or a commented one-line helper counts as two statements.
294
+ */
295
+ function bodyStatementsOf(node, commentStarts) {
296
+ return bodyOf(node, commentStarts).statements.length;
297
+ }
298
+ /** The captured node, except for an arrow or function expression, which sits in its declarator's `value`. */
299
+ function callableOf(node) {
300
+ if (node.type !== "variable_declarator") return node;
301
+ return node.childForFieldName("value") ?? node;
302
+ }
303
+ function extractFunction(node, source, language, comments, commentStarts, identifiers, binders, exportedNames) {
304
+ const range = rangeOf(node);
305
+ const nameNode = node.childForFieldName("name");
306
+ return {
307
+ binderNames: [.../* @__PURE__ */ new Set([...nodesInside(binders, range).map((binder) => binder.text), ...nameNode ? [nameNode.text] : []])],
308
+ bodyIsSingleExpression: bodyIsSingleExpressionOf(node, commentStarts),
309
+ bodyStatements: bodyStatementsOf(node, commentStarts),
310
+ commentRanges: rangesInside(comments, range),
311
+ exported: isExported(node, language, nameNode?.text ?? "", exportedNames),
312
+ identifierRanges: withOwnName(rangesInside(identifiers, range), nameNode, range),
313
+ loc: {
314
+ end: {
315
+ column: node.endPosition.column,
316
+ line: node.endPosition.row + 1
317
+ },
318
+ start: {
319
+ column: node.startPosition.column,
320
+ line: node.startPosition.row + 1
321
+ }
322
+ },
323
+ name: nameNode?.text ?? "",
324
+ range,
325
+ text: source.slice(range.start, range.end)
326
+ };
327
+ }
328
+ /** Whether anything under this node is a block, which is how every grammar here writes a branch or a loop. */
329
+ function holdsBlock(node) {
330
+ const pending = [...node.namedChildren];
331
+ while (pending.length > 0) {
332
+ const next = pending.pop();
333
+ if (next === void 0) continue;
334
+ if (next.type === "block" || next.type === "statement_block") return true;
335
+ pending.push(...next.namedChildren);
336
+ }
337
+ return false;
338
+ }
339
+ /** Reachable from outside its file. Each language spells it differently: `export_statement`, `pub`, or the name itself. */
340
+ function isExported(node, language, name, exportedNames) {
341
+ switch (language) {
342
+ case "go": return /^[A-Z]/.test(name);
343
+ case "python": return !name.startsWith("_");
344
+ case "rust": return node.children.some((child) => child.type === "visibility_modifier");
345
+ default: return (node.type === "variable_declarator" ? node.parent : node)?.parent?.type === "export_statement" || exportedNames.has(name);
346
+ }
347
+ }
348
+ function nodesInside(nodes, outer) {
349
+ return nodes.filter((node) => node.startIndex >= outer.start && node.endIndex <= outer.end);
350
+ }
351
+ /** Offsets are into the JS string, matching `source.slice`, not UTF-8 bytes. */
352
+ function rangeOf(node) {
353
+ return {
354
+ end: node.endIndex,
355
+ start: node.startIndex
356
+ };
357
+ }
358
+ /** Rebases the contained ranges onto the function's own text. */
359
+ function rangesInside(ranges, outer) {
360
+ return ranges.filter((range) => range.start >= outer.start && range.end <= outer.end).map((range) => ({
361
+ end: range.end - outer.start,
362
+ start: range.start - outer.start
363
+ }));
364
+ }
365
+ /**
366
+ * The function's own name, added to its renameable identifiers. A `method_definition` name is a
367
+ * `property_identifier`, which the query keeps out of `@identifier`; a `function_declaration` name is
368
+ * already there, hence the dedupe by start offset.
369
+ */
370
+ function withOwnName(ranges, nameNode, outer) {
371
+ if (nameNode === null) return ranges;
372
+ const name = {
373
+ end: nameNode.endIndex - outer.start,
374
+ start: nameNode.startIndex - outer.start
375
+ };
376
+ if (ranges.some((range) => range.start === name.start)) return ranges;
377
+ return [...ranges, name].sort((left, right) => left.start - right.start);
378
+ }
379
+ //#endregion
380
+ //#region src/extract/language.ts
381
+ const LANGUAGE_BY_EXTENSION = {
382
+ ".cjs": "javascript",
383
+ ".cts": "typescript",
384
+ ".go": "go",
385
+ ".js": "javascript",
386
+ ".jsx": "tsx",
387
+ ".mjs": "javascript",
388
+ ".mts": "typescript",
389
+ ".py": "python",
390
+ ".rs": "rust",
391
+ ".ts": "typescript",
392
+ ".tsx": "tsx"
393
+ };
394
+ function resolveExtractLanguage(filePath) {
395
+ return LANGUAGE_BY_EXTENSION[extname(filePath)];
396
+ }
397
+ //#endregion
398
+ //#region src/fingerprint/fingerprint.ts
399
+ const CONTENT_TOKEN = /[a-z_$][\w$]*|\d+(?:\.\d+)?/gi;
400
+ /**
401
+ * Hashes a function with the names it declares (own name, parameters, locals) replaced by
402
+ * placeholders. Same alpha fingerprint means the same function, renamed.
403
+ *
404
+ * `identifierRanges` must hold renameable identifiers only. Replacing property, field or type
405
+ * names is the mistake this fingerprint exists to avoid.
406
+ */
407
+ function alphaFingerprint(text, commentRanges, identifierRanges, binderNames) {
408
+ return hash(alphaNormalize(text, commentRanges, identifierRanges, binderNames));
409
+ }
410
+ /** Hashes a function with comments and formatting removed, so layout and docs do not count. */
411
+ function exactFingerprint(text, commentRanges) {
412
+ return hash(normalize(text, commentRanges));
413
+ }
414
+ /** Comments and formatting removed, names left alone, so `search_helper_bodies` can search real code. */
415
+ function normalizedBody(text, commentRanges) {
416
+ return normalize(text, commentRanges);
417
+ }
418
+ /** Content tokens, alpha-normalized. Punctuation is dropped: every function has braces. */
419
+ function tokenize(text, commentRanges, identifierRanges, binderNames) {
420
+ return alphaNormalize(text, commentRanges, identifierRanges, binderNames).match(CONTENT_TOKEN) ?? [];
421
+ }
422
+ /** Shared token fraction of the larger bag. Ranks candidates, decides nothing. */
423
+ function tokenOverlap(left, right) {
424
+ const longest = Math.max(left.length, right.length);
425
+ if (longest === 0) return 0;
426
+ const remaining = /* @__PURE__ */ new Map();
427
+ for (const token of left) remaining.set(token, (remaining.get(token) ?? 0) + 1);
428
+ let shared = 0;
429
+ for (const token of right) {
430
+ const available = remaining.get(token) ?? 0;
431
+ if (available > 0) {
432
+ remaining.set(token, available - 1);
433
+ shared += 1;
434
+ }
435
+ }
436
+ return shared / longest;
437
+ }
438
+ function alphaNormalize(text, commentRanges, identifierRanges, binderNames) {
439
+ const declared = new Set(binderNames);
440
+ return normalize(text, commentRanges, identifierRanges.filter((range) => declared.has(text.slice(range.start, range.end))));
441
+ }
442
+ function byStart(left, right) {
443
+ return left.start - right.start;
444
+ }
445
+ function hash(text) {
446
+ return createHash("sha256").update(text).digest("hex");
447
+ }
448
+ function normalize(text, commentRanges, renameableRanges) {
449
+ const edits = commentRanges.map((range) => ({
450
+ end: range.end,
451
+ replacement: " ",
452
+ start: range.start
453
+ }));
454
+ if (renameableRanges !== void 0) edits.push(...placeholderEdits(text, renameableRanges));
455
+ edits.sort(byStart);
456
+ let normalized = "";
457
+ let cursor = 0;
458
+ for (const edit of edits) {
459
+ normalized += text.slice(cursor, edit.start) + edit.replacement;
460
+ cursor = edit.end;
461
+ }
462
+ normalized += text.slice(cursor);
463
+ return normalized.replaceAll(/\s+/g, " ").trim();
464
+ }
465
+ function placeholderEdits(text, ranges) {
466
+ const placeholders = /* @__PURE__ */ new Map();
467
+ return [...ranges].sort(byStart).map((range) => {
468
+ const name = text.slice(range.start, range.end);
469
+ let placeholder = placeholders.get(name);
470
+ if (placeholder === void 0) {
471
+ placeholder = `$${placeholders.size}`;
472
+ placeholders.set(name, placeholder);
473
+ }
474
+ return {
475
+ end: range.end,
476
+ replacement: placeholder,
477
+ start: range.start
478
+ };
479
+ });
480
+ }
481
+ //#endregion
482
+ //#region src/repo/cache.ts
483
+ const SCHEMA_VERSION = 2;
484
+ const reviewCaches = /* @__PURE__ */ new WeakMap();
485
+ const decisionCaches = /* @__PURE__ */ new WeakMap();
486
+ function decisionCacheFor(ctx, options) {
487
+ const existing = decisionCaches.get(ctx.src);
488
+ if (existing !== void 0) return existing;
489
+ const loading = loadDecisions(ctx, options);
490
+ decisionCaches.set(ctx.src, loading);
491
+ return loading;
492
+ }
493
+ function reviewCacheFor(ctx, options) {
494
+ const existing = reviewCaches.get(ctx.src);
495
+ if (existing !== void 0) return existing;
496
+ const loading = loadReviews(ctx, options);
497
+ reviewCaches.set(ctx.src, loading);
498
+ return loading;
499
+ }
500
+ async function loadDecisions(ctx, options) {
501
+ if (!options.enabled) return {
502
+ get: () => void 0,
503
+ set: async () => {}
504
+ };
505
+ const location = join(options.cwd, ".alint", "simplicity", "decisions.json");
506
+ const decisions = await rememberedDecisions(location);
507
+ let writing = Promise.resolve();
508
+ return {
509
+ get: (filePath, key) => {
510
+ const remembered = decisions[relative$1(options.cwd, filePath)];
511
+ return remembered?.key === key ? remembered.findings : void 0;
512
+ },
513
+ set: async (filePath, key, findings) => {
514
+ decisions[relative$1(options.cwd, filePath)] = {
515
+ findings,
516
+ key
517
+ };
518
+ writing = writing.then(async () => write(ctx, location, {
519
+ decisions,
520
+ schemaVersion: SCHEMA_VERSION
521
+ }));
522
+ return writing;
523
+ }
524
+ };
525
+ }
526
+ async function loadReviews(ctx, options) {
527
+ if (!options.enabled) return {
528
+ get: () => void 0,
529
+ set: async () => {}
530
+ };
531
+ const location = join(options.cwd, ".alint", "simplicity", "reviews.json");
532
+ const reviews = await rememberedReviews(ctx, location, options.fingerprint);
533
+ let writing = Promise.resolve();
534
+ return {
535
+ get: (filePath) => reviews[relative$1(options.cwd, filePath)],
536
+ set: async (filePath, findings) => {
537
+ reviews[relative$1(options.cwd, filePath)] = findings;
538
+ writing = writing.then(async () => write(ctx, location, {
539
+ fingerprint: options.fingerprint,
540
+ reviews,
541
+ schemaVersion: SCHEMA_VERSION
542
+ }));
543
+ return writing;
544
+ }
545
+ };
546
+ }
547
+ /** What was written last time. A missing or unreadable cache is never an error. */
548
+ async function read(location) {
549
+ try {
550
+ return JSON.parse(await readFile(location, "utf8"));
551
+ } catch {
552
+ return;
553
+ }
554
+ }
555
+ /** Everything remembered, stale entries and all: each carries the question it answered, so `get` can tell them apart. */
556
+ async function rememberedDecisions(location) {
557
+ const parsed = await read(location);
558
+ if (parsed === void 0 || parsed.schemaVersion !== SCHEMA_VERSION) return {};
559
+ return parsed.decisions ?? {};
560
+ }
561
+ /** Reviews decided against this exact index, or nothing at all. */
562
+ async function rememberedReviews(ctx, location, fingerprint) {
563
+ const parsed = await read(location);
564
+ if (parsed === void 0) return {};
565
+ if (parsed.schemaVersion !== SCHEMA_VERSION || parsed.fingerprint !== fingerprint) {
566
+ ctx.logger.debug("simplicity: the workspace changed since the last run; the review cache was dropped");
567
+ return {};
568
+ }
569
+ return parsed.reviews ?? {};
570
+ }
571
+ async function write(ctx, location, file) {
572
+ try {
573
+ await mkdir(dirname(location), { recursive: true });
574
+ const pending = `${location}.tmp`;
575
+ await writeFile(pending, JSON.stringify(file), "utf8");
576
+ await rename(pending, location);
577
+ } catch (error) {
578
+ ctx.logger.debug(`simplicity: could not write ${location}: ${errorMessageFrom(error) ?? "unknown error"}`);
579
+ }
580
+ }
581
+ //#endregion
582
+ //#region src/repo/index.ts
583
+ const indexes = /* @__PURE__ */ new WeakMap();
584
+ /** Helpers of one file, in source order. */
585
+ function helpersIn(index, filePath) {
586
+ return index.helpers.filter((helper) => helper.filePath === filePath);
587
+ }
588
+ async function repoIndexFor(ctx, options) {
589
+ const existing = indexes.get(ctx.src);
590
+ if (existing !== void 0) return existing;
591
+ const building = buildRepoIndex(ctx, options);
592
+ indexes.set(ctx.src, building);
593
+ return building;
594
+ }
595
+ /** Closest first by token overlap, which ranks candidates and decides nothing; see `tokenOverlap`. */
596
+ function similarTo(index, helper, limit) {
597
+ return index.helpers.filter((other) => other.id !== helper.id && other.language === helper.language).map((other) => ({
598
+ other,
599
+ score: tokenOverlap(helper.tokens, other.tokens)
600
+ })).filter((entry) => entry.score > 0).sort((left, right) => right.score - left.score).slice(0, limit).map((entry) => entry.other);
601
+ }
602
+ /** Every other helper sharing a fingerprint. A helper is never its own twin. */
603
+ function twinsOf(index, helper, kind) {
604
+ const bucket = kind === "exact" ? index.byExact : index.byAlpha;
605
+ const fingerprint = kind === "exact" ? helper.exactFingerprint : helper.alphaFingerprint;
606
+ return (bucket.get(fingerprint) ?? []).filter((other) => other.id !== helper.id);
607
+ }
608
+ async function buildRepoIndex(ctx, options) {
609
+ const index = {
610
+ byAlpha: /* @__PURE__ */ new Map(),
611
+ byExact: /* @__PURE__ */ new Map(),
612
+ byId: /* @__PURE__ */ new Map(),
613
+ fingerprint: "",
614
+ helpers: []
615
+ };
616
+ const paths = await listFiles(options.cwd, {
617
+ ignore: [...DEFAULT_IGNORE_PATTERNS, ...options.ignores],
618
+ patterns: ["**/*.{ts,tsx,mts,cts,js,jsx,mjs,cjs,rs,go,py}"]
619
+ });
620
+ const calls = /* @__PURE__ */ new Map();
621
+ for (const path of paths.sort()) {
622
+ const language = resolveExtractLanguage(path);
623
+ if (language === void 0 || isIgnored$2(options.cwd, path, options.ignores)) continue;
624
+ let text;
625
+ try {
626
+ text = await readFile(path, "utf8");
627
+ } catch (error) {
628
+ ctx.logger.debug(`simplicity: could not read ${path}: ${errorMessageFrom(error) ?? "unknown error"}`);
629
+ continue;
630
+ }
631
+ for (const helper of await helpersOf(ctx, path, text, language, options, calls)) {
632
+ index.helpers.push(helper);
633
+ index.byId.set(helper.id, helper);
634
+ push(index.byExact, helper.exactFingerprint, helper);
635
+ push(index.byAlpha, helper.alphaFingerprint, helper);
636
+ }
637
+ }
638
+ for (const helper of index.helpers) helper.usageCount = calls.get(helper.name) ?? 0;
639
+ index.fingerprint = createHash("sha256").update(index.helpers.map((helper) => `${helper.id}:${helper.exactFingerprint}`).join("\n")).digest("hex");
640
+ ctx.logger.debug(`simplicity: indexed ${index.helpers.length} helpers from ${paths.length} files`);
641
+ return index;
642
+ }
643
+ async function helpersOf(ctx, filePath, text, language, options, calls) {
644
+ let functions;
645
+ let sourceCalls;
646
+ try {
647
+ ({calls: sourceCalls, functions} = await extractSource(text, language));
648
+ } catch (error) {
649
+ ctx.logger.debug(`simplicity: skipped ${filePath}: ${errorMessageFrom(error) ?? "could not be parsed"}`);
650
+ return [];
651
+ }
652
+ for (const call of sourceCalls) calls.set(call.name, (calls.get(call.name) ?? 0) + 1);
653
+ const helpers = [];
654
+ for (const fn of functions) {
655
+ const lines = fn.loc.end.line - fn.loc.start.line + 1;
656
+ const tokens = tokenize(fn.text, fn.commentRanges, fn.identifierRanges, fn.binderNames);
657
+ if (fn.name === "" || lines > options.maxLines || tokens.length < options.minTokens) continue;
658
+ helpers.push({
659
+ alphaFingerprint: alphaFingerprint(fn.text, fn.commentRanges, fn.identifierRanges, fn.binderNames),
660
+ body: normalizedBody(fn.text, fn.commentRanges),
661
+ bodyIsSingleExpression: fn.bodyIsSingleExpression,
662
+ bodyStatements: fn.bodyStatements,
663
+ exactFingerprint: exactFingerprint(fn.text, fn.commentRanges),
664
+ exported: fn.exported,
665
+ filePath,
666
+ id: `${relative$1(options.cwd, filePath)}:${fn.loc.start.line}`,
667
+ language,
668
+ line: fn.loc.start.line,
669
+ lines,
670
+ name: fn.name,
671
+ text: fn.text,
672
+ tokens,
673
+ usageCount: 0
674
+ });
675
+ }
676
+ return helpers;
677
+ }
678
+ function isIgnored$2(cwd, filePath, ignores) {
679
+ const relativePath = relative$1(cwd, filePath);
680
+ return ignores.some((pattern) => minimatch(relativePath, pattern, { dot: true }));
681
+ }
682
+ function push(bucket, key, helper) {
683
+ bucket.set(key, [...bucket.get(key) ?? [], helper]);
684
+ }
685
+ //#endregion
686
+ //#region src/rules/shared/settings.ts
687
+ const settingsSchema = object({
688
+ cache: optional(boolean(), true),
689
+ /** Globs matched against the repository-relative file path. */
690
+ ignores: optional(array(string()), []),
691
+ /**
692
+ * When false, no model is called: `no-duplicated-helper` still reports what a fingerprint settles,
693
+ * `no-needless-helper` reports nothing.
694
+ */
695
+ judge: optional(boolean(), true),
696
+ maxLines: optional(pipe(number(), integer(), minValue(1)), 10),
697
+ minTokens: optional(pipe(number(), integer(), minValue(1)), 5)
698
+ });
699
+ function readSimplicitySettings(ctx) {
700
+ const configured = ctx.settings.simplicity ?? {};
701
+ if (Array.isArray(configured)) throw new TypeError(`${ctx.id}: invalid "settings.simplicity": expected an object, received an array.`);
702
+ const result = safeParse(settingsSchema, configured);
703
+ if (!result.success) {
704
+ const problems = result.issues.map((issue) => {
705
+ const path = getDotPath(issue);
706
+ return path === null ? issue.message : `"${path}": ${issue.message}`;
707
+ }).join("; ");
708
+ throw new TypeError(`${ctx.id}: invalid "settings.simplicity": ${problems}`);
709
+ }
710
+ return result.output;
711
+ }
712
+ //#endregion
713
+ //#region src/rules/no-duplicated-helper/tools.ts
714
+ /** The most helpers any tool will list at once, so a broad filter cannot flood the loop. */
715
+ const LIST_LIMIT = 40;
716
+ function createDuplicateTools(options) {
717
+ return [
718
+ createListHelpersTool(options.index),
719
+ createGetHelperTool(options.index),
720
+ createFindSimilarTool(options.index),
721
+ createSearchHelperBodiesTool(options.index),
722
+ createReportDuplicateTool(options)
723
+ ];
724
+ }
725
+ function clamp(limit, fallback) {
726
+ if (typeof limit !== "number" || !Number.isFinite(limit) || limit < 1) return fallback;
727
+ return Math.min(Math.floor(limit), LIST_LIMIT);
728
+ }
729
+ function createFindSimilarTool(index) {
730
+ return defineTool({
731
+ description: [
732
+ "Rank the helpers most textually similar to a given helper, closest first.",
733
+ "The ranking is a crude token-overlap score: it is a hint about where to look, NOT evidence of duplication.",
734
+ "Two helpers can score high and do entirely different things. Read them with get_helper before deciding."
735
+ ].join(" "),
736
+ execute: (input) => {
737
+ const { id, limit } = input;
738
+ const helper = index.byId.get(id);
739
+ if (helper === void 0) return unknownId(id);
740
+ const ranked = similarTo(index, helper, clamp(limit, 10));
741
+ if (ranked.length === 0) return `No other ${helper.language} helper shares any token with ${id}.`;
742
+ return ranked.map((other) => `${other.id} ${other.name} (${other.lines} lines)`).join("\n");
743
+ },
744
+ name: "find_similar",
745
+ parameters: {
746
+ additionalProperties: false,
747
+ properties: {
748
+ id: {
749
+ description: "Helper id, as `path:line`.",
750
+ type: "string"
751
+ },
752
+ limit: {
753
+ description: "How many to return. Null for 10.",
754
+ type: ["number", "null"]
755
+ }
756
+ },
757
+ required: ["id", "limit"],
758
+ type: "object"
759
+ }
760
+ });
761
+ }
762
+ function createGetHelperTool(index) {
763
+ return defineTool({
764
+ description: "Read one helper in full, by id. Use this before deciding anything about it.",
765
+ execute: (input) => {
766
+ const { id } = input;
767
+ const helper = index.byId.get(id);
768
+ if (helper === void 0) return unknownId(id);
769
+ return [
770
+ `${helper.id} (${helper.language})`,
771
+ "",
772
+ helper.text
773
+ ].join("\n");
774
+ },
775
+ name: "get_helper",
776
+ parameters: {
777
+ additionalProperties: false,
778
+ properties: { id: {
779
+ description: "Helper id, as `path:line`.",
780
+ type: "string"
781
+ } },
782
+ required: ["id"],
783
+ type: "object"
784
+ }
785
+ });
786
+ }
787
+ function createListHelpersTool(index) {
788
+ return defineTool({
789
+ description: ["List the small helpers indexed across the whole workspace, as `id name` lines.", "Filter to keep the list short. Use get_helper to read any of them."].join(" "),
790
+ execute: (input) => {
791
+ const { directory, language, name_contains: nameContains } = input;
792
+ const matched = index.helpers.filter((helper) => (!directory || helper.id.startsWith(directory)) && (!language || helper.language === language) && (!nameContains || helper.name.toLowerCase().includes(nameContains.toLowerCase())));
793
+ if (matched.length === 0) return "No helper matched. Try a broader filter, or drop one.";
794
+ const shown = matched.slice(0, LIST_LIMIT);
795
+ const lines = shown.map((helper) => `${helper.id} ${helper.name} (${helper.lines} lines, ${helper.language})`);
796
+ if (matched.length > shown.length) lines.push(`… and ${matched.length - shown.length} more. Narrow the filter to see them.`);
797
+ return lines.join("\n");
798
+ },
799
+ name: "list_helpers",
800
+ parameters: {
801
+ additionalProperties: false,
802
+ properties: {
803
+ directory: {
804
+ description: "Keep only helpers whose path starts with this, e.g. `packages/cli/`. Null for all.",
805
+ type: ["string", "null"]
806
+ },
807
+ language: {
808
+ description: "One of: go, javascript, python, rust, tsx, typescript. Null for all.",
809
+ type: ["string", "null"]
810
+ },
811
+ name_contains: {
812
+ description: "Keep only helpers whose name contains this, case-insensitively. Null for all.",
813
+ type: ["string", "null"]
814
+ }
815
+ },
816
+ required: [
817
+ "directory",
818
+ "language",
819
+ "name_contains"
820
+ ],
821
+ type: "object"
822
+ }
823
+ });
824
+ }
825
+ function createReportDuplicateTool(options) {
826
+ const { findings, index, reviewing } = options;
827
+ const reviewable = new Set(reviewing.map((helper) => helper.id));
828
+ return defineTool({
829
+ description: ["Report that a helper in the file under review duplicates another helper elsewhere.", "Call once per duplicated helper. Identical and renamed copies are already reported without you — do not report those."].join(" "),
830
+ execute: (input) => {
831
+ const finding = input;
832
+ if (!reviewable.has(finding.helperId)) return `"${finding.helperId}" is not a helper of the file under review. Report only the helpers listed in the task, and pass the OTHER helper as twin_id.`;
833
+ const twin = index.byId.get(finding.twinId);
834
+ if (twin === void 0) return unknownId(finding.twinId);
835
+ if (finding.twinId === finding.helperId) return "A helper cannot duplicate itself. Pass the other helper as twin_id.";
836
+ const helper = index.byId.get(finding.helperId);
837
+ if (helper && twin.language !== helper.language) return `${finding.helperId} is ${helper.language} and ${finding.twinId} is ${twin.language}. A helper in one language cannot share a home with one in another, so this is not reportable.`;
838
+ if (typeof finding.reason !== "string" || finding.reason.trim() === "") return "reason is required: name the shared responsibility in at most twelve words.";
839
+ if (finding.reason.trim().split(/\s+/).length > 14) return `Too long (${finding.reason.trim().split(/\s+/).length} words). Name only the responsibility the two share, in at most twelve words. Not what each one does, and not what should be done about it.`;
840
+ if (findings.some((existing) => existing.helperId === finding.helperId && existing.twinId === finding.twinId)) return `Already recorded ${finding.helperId} against ${finding.twinId}.`;
841
+ findings.push({
842
+ helperId: finding.helperId,
843
+ reason: finding.reason.trim(),
844
+ twinId: finding.twinId
845
+ });
846
+ return `Recorded: ${finding.helperId} duplicates ${finding.twinId}.`;
847
+ },
848
+ name: "report_duplicate",
849
+ parameters: {
850
+ additionalProperties: false,
851
+ properties: {
852
+ helperId: {
853
+ description: "Id of the helper in the file under review.",
854
+ type: "string"
855
+ },
856
+ reason: {
857
+ description: "The responsibility both helpers carry, in at most twelve words. e.g. \"Both ask whether an error is a missing-file error.\"",
858
+ type: "string"
859
+ },
860
+ twinId: {
861
+ description: "Id of the helper it duplicates.",
862
+ type: "string"
863
+ }
864
+ },
865
+ required: [
866
+ "helperId",
867
+ "reason",
868
+ "twinId"
869
+ ],
870
+ type: "object"
871
+ }
872
+ });
873
+ }
874
+ function createSearchHelperBodiesTool(index) {
875
+ return defineTool({
876
+ description: [
877
+ "Search helper BODIES for a literal substring, and get back the helpers that contain it, as `id name` lines.",
878
+ "This is how you find a helper that does the same thing under a different name: search what it DOES.",
879
+ "Bodies are searched with comments and formatting removed, so search for code, e.g. `instanceof Error` or `.trim()`."
880
+ ].join(" "),
881
+ execute: (input) => {
882
+ const { language, query } = input;
883
+ if (typeof query !== "string" || query.trim() === "") return "query is required: a literal substring of the code you are looking for.";
884
+ const needle = query.toLowerCase();
885
+ const matched = index.helpers.filter((helper) => (!language || helper.language === language) && helper.body.toLowerCase().includes(needle));
886
+ if (matched.length === 0) return `No helper body contains "${query}". Try a shorter or more distinctive fragment.`;
887
+ const shown = matched.slice(0, LIST_LIMIT);
888
+ const lines = shown.map((helper) => `${helper.id} ${helper.name} (${helper.lines} lines, ${helper.language})`);
889
+ if (matched.length > shown.length) lines.push(`… and ${matched.length - shown.length} more. Use a longer fragment to narrow it.`);
890
+ return lines.join("\n");
891
+ },
892
+ name: "search_helper_bodies",
893
+ parameters: {
894
+ additionalProperties: false,
895
+ properties: {
896
+ language: {
897
+ description: "One of: go, javascript, python, rust, tsx, typescript. Null for all.",
898
+ type: ["string", "null"]
899
+ },
900
+ query: {
901
+ description: "A literal fragment of code to look for inside helper bodies.",
902
+ type: "string"
903
+ }
904
+ },
905
+ required: ["language", "query"],
906
+ type: "object"
907
+ }
908
+ });
909
+ }
910
+ function unknownId(id) {
911
+ return `No helper has the id "${id}". Ids look like \`packages/cli/src/lint.ts:57\`. Use list_helpers or search_helper_bodies to find one.`;
912
+ }
913
+ //#endregion
914
+ //#region src/rules/no-duplicated-helper/rule.ts
915
+ /**
916
+ * Reports a small helper already implemented somewhere else in the workspace.
917
+ *
918
+ * Identical bodies and renamed-only copies are settled by AST fingerprints, with no model. What
919
+ * a fingerprint cannot settle goes to an agent holding the whole index as tools.
920
+ */
921
+ const duplicatedHelperRule = defineRule({
922
+ cache: false,
923
+ create: (ctx) => {
924
+ const settings = readSimplicitySettings(ctx);
925
+ return { async onTarget(target) {
926
+ if (target.kind !== "file" || resolveExtractLanguage(target.file.path) === void 0) return;
927
+ if (isIgnored$1(ctx.cwd, target.file.path, settings.ignores)) return;
928
+ const index = await repoIndexFor(ctx, {
929
+ cwd: ctx.cwd,
930
+ ignores: settings.ignores,
931
+ maxLines: settings.maxLines,
932
+ minTokens: settings.minTokens
933
+ });
934
+ const unsettled = [];
935
+ for (const helper of helpersIn(index, target.file.path)) {
936
+ const exactTwins = twinsOf(index, helper, "exact");
937
+ if (exactTwins.length > 0) {
938
+ reportFingerprinted(ctx, helper, exactTwins, "exact");
939
+ continue;
940
+ }
941
+ const renamedTwins = twinsOf(index, helper, "alpha");
942
+ if (renamedTwins.length > 0) {
943
+ reportFingerprinted(ctx, helper, renamedTwins, "renamed");
944
+ continue;
945
+ }
946
+ unsettled.push(helper);
947
+ }
948
+ if (!settings.judge || unsettled.length === 0) return;
949
+ await review(ctx, index, unsettled, target.file.path, settings.cache);
950
+ } };
951
+ }
952
+ });
953
+ function isIgnored$1(cwd, filePath, ignores) {
954
+ const relativePath = relative(cwd, filePath);
955
+ return ignores.some((pattern) => minimatch(relativePath, pattern, { dot: true }));
956
+ }
957
+ /**
958
+ * Candidates pasted into the prompt so the agent does not have to search for them.
959
+ * Three each: a token budget, not a shortlist. The tools still reach everything.
960
+ */
961
+ function nearest(index, helpers) {
962
+ const seen = new Set(helpers.map((helper) => helper.id));
963
+ const nearby = [];
964
+ for (const helper of helpers) for (const candidate of similarTo(index, helper, 3)) {
965
+ if (seen.has(candidate.id)) continue;
966
+ seen.add(candidate.id);
967
+ nearby.push(candidate);
968
+ }
969
+ return nearby;
970
+ }
971
+ function report$1(ctx, index, findings) {
972
+ for (const finding of findings) {
973
+ const helper = index.byId.get(finding.helperId);
974
+ const twin = index.byId.get(finding.twinId);
975
+ if (helper === void 0 || twin === void 0) continue;
976
+ ctx.report({
977
+ evidence: {
978
+ match: "reimplemented",
979
+ reason: finding.reason,
980
+ twins: [{
981
+ filePath: twin.filePath,
982
+ line: twin.line,
983
+ name: twin.name
984
+ }]
985
+ },
986
+ filePath: helper.filePath,
987
+ loc: { start: {
988
+ column: 0,
989
+ line: helper.line
990
+ } },
991
+ message: `Helper "${helper.name}" duplicates "${twin.name}" at ${twin.id}: ${finding.reason}`
992
+ });
993
+ }
994
+ }
995
+ /** Both copies are reported, so either can be the one you delete, and neither depends on lint order. */
996
+ function reportFingerprinted(ctx, helper, twins, match) {
997
+ const where = twins.map((twin) => twin.id).join(", ");
998
+ ctx.report({
999
+ evidence: {
1000
+ match,
1001
+ twins: twins.map((twin) => ({
1002
+ filePath: twin.filePath,
1003
+ line: twin.line,
1004
+ name: twin.name
1005
+ }))
1006
+ },
1007
+ filePath: helper.filePath,
1008
+ loc: { start: {
1009
+ column: 0,
1010
+ line: helper.line
1011
+ } },
1012
+ message: match === "exact" ? `Helper "${helper.name}" is also defined at ${where}.` : `Helper "${helper.name}" is a renamed copy of ${twins.map((twin) => `"${twin.name}"`).join(", ")} at ${where}; only the names it declares differ.`
1013
+ });
1014
+ }
1015
+ /** Hands the file's unsettled helpers to the agent, unless a run already asked about them. */
1016
+ async function review(ctx, index, helpers, filePath, cacheEnabled) {
1017
+ const cache = await reviewCacheFor(ctx, {
1018
+ cwd: ctx.cwd,
1019
+ enabled: cacheEnabled,
1020
+ fingerprint: reviewFingerprint(index.fingerprint)
1021
+ });
1022
+ const remembered = cache.get(filePath);
1023
+ if (remembered !== void 0) {
1024
+ ctx.logger.debug(`no-duplicated-helper: ${filePath} was reviewed against this workspace already`);
1025
+ report$1(ctx, index, remembered);
1026
+ return;
1027
+ }
1028
+ const findings = [];
1029
+ try {
1030
+ const agent = requireAgent(ctx);
1031
+ const model = await ctx.model();
1032
+ const result = await agent({
1033
+ instructions: duplicatedHelperInstructions,
1034
+ model,
1035
+ prompt: buildDuplicatedHelperPrompt({
1036
+ candidates: nearest(index, helpers),
1037
+ filePath,
1038
+ helpers
1039
+ }),
1040
+ tools: createDuplicateTools({
1041
+ findings,
1042
+ index,
1043
+ reviewing: helpers
1044
+ })
1045
+ });
1046
+ if (result.usage) ctx.metering.recordUsage({
1047
+ filePath,
1048
+ inputTokens: result.usage.inputTokens,
1049
+ metadata: { operation: "no-duplicated-helper-review" },
1050
+ modelId: model.id,
1051
+ outputTokens: result.usage.outputTokens,
1052
+ providerId: model.provider.id,
1053
+ ruleId: ctx.id,
1054
+ totalTokens: result.usage.totalTokens
1055
+ });
1056
+ } catch (error) {
1057
+ ctx.logger.debug(`no-duplicated-helper: agent review of ${filePath} failed: ${errorMessageFrom(error) ?? "unknown error"}`);
1058
+ return;
1059
+ }
1060
+ await cache.set(filePath, findings);
1061
+ report$1(ctx, index, findings);
1062
+ }
1063
+ /**
1064
+ * Keys the cache on every helper in the workspace plus the instructions: change either and
1065
+ * a cached answer no longer answers the same question.
1066
+ */
1067
+ function reviewFingerprint(indexFingerprint) {
1068
+ return createHash("sha256").update(`${indexFingerprint}\n${duplicatedHelperInstructions}`).digest("hex");
1069
+ }
1070
+ //#endregion
1071
+ //#region src/rules/no-needless-helper/prompt.ts
1072
+ const needlessHelperPrompt = [
1073
+ "You review small helper functions whose whole body is a single expression, and decide whether each one earns its existence.",
1074
+ "",
1075
+ "THE QUESTION:",
1076
+ "A function earns its existence when its interface is simpler than its implementation — when the name tells a reader something the body would not. It does not earn it when the body says the same thing as the name, only shorter, so that every reader must jump to the declaration to learn nothing.",
1077
+ "",
1078
+ "You are given, for each helper, how often a function of its name is called across the workspace, and whether it is visible outside its file. Both are facts, not decisions. The count is approximate: it matches by name, so two helpers sharing a name share a count.",
1079
+ "",
1080
+ "FOUR WORKED EXAMPLES. None is a helper you will be shown.",
1081
+ "",
1082
+ "EXAMPLE 1 — report it.",
1083
+ " function sizeOf(entry: Entry): number {",
1084
+ " return entry.size",
1085
+ " }",
1086
+ " Needless. `entry.size` is shorter than `sizeOf(entry)` and says exactly as much.",
1087
+ " The helper adds a hop and a name for a property access that was already clear.",
1088
+ " reason: \"Reads one property; `entry.size` says the same thing.\"",
1089
+ "",
1090
+ "EXAMPLE 2 — report it.",
1091
+ " function parse(text: string): unknown {",
1092
+ " return JSON.parse(text)",
1093
+ " }",
1094
+ " Needless. A pure forward: same parameter, same order, nothing added — and it",
1095
+ " hides which parser is being used, so the name is worse than the body.",
1096
+ " reason: \"Forwards to JSON.parse unchanged.\"",
1097
+ "",
1098
+ "EXAMPLE 3 — do NOT report it.",
1099
+ " function clamp(value: number, low: number, high: number): number {",
1100
+ " return Math.min(Math.max(value, low), high)",
1101
+ " }",
1102
+ " Earns it. The name is the documentation. `clamp(x, 0, 1)` can be read at a",
1103
+ " glance; `Math.min(Math.max(x, 0), 1)` has to be worked out. The interface is",
1104
+ " simpler than the implementation, which is the whole test.",
1105
+ "",
1106
+ "EXAMPLE 4 — do NOT report it, and this is the one people get wrong.",
1107
+ " function isRecord(value: unknown): value is Record<string, unknown> {",
1108
+ " return typeof value === 'object' && value !== null && !Array.isArray(value)",
1109
+ " }",
1110
+ " Earns it. It is a type guard, and the narrowing is the point: inlined, the",
1111
+ " condition still runs but the call site loses the named type it narrowed to. A",
1112
+ " one-line guard is not a needless helper, however short its body. Being brief is",
1113
+ " not the same as being pointless.",
1114
+ "",
1115
+ "ALSO DO NOT REPORT:",
1116
+ "- A helper called from many places. It is holding one decision in one place, which is what a helper is for, and the shortness of its body is beside the point.",
1117
+ "- A helper that is exported, unless it is plainly gratuitous. Inlining somebody's public API breaks their callers, and that is their decision, not yours.",
1118
+ "- A helper whose name carries a domain meaning the expression does not — a unit, an invariant, a rule someone had to look up once.",
1119
+ "- Anything you are unsure about. A false report costs a reader's trust in every other report; silence costs almost nothing.",
1120
+ "",
1121
+ "For each helper you report, give the number shown beside it and a reason of at most twelve words, naming what the body already says. Report nothing when nothing qualifies — that is the common and correct outcome."
1122
+ ].join("\n");
1123
+ function buildNeedlessHelperPrompt(helpers) {
1124
+ return [
1125
+ "Review these helpers. Each one is a single expression, which is what makes it worth asking about — not what makes it guilty.",
1126
+ "",
1127
+ ...helpers.map((helper, position) => [
1128
+ `--- helper ${position + 1} ${helper.name} (${helper.language})`,
1129
+ `called ${helper.usageCount === 1 ? "once" : `${helper.usageCount} times`} in the workspace, by name; ${helper.exported ? "exported" : "private to its file"}`,
1130
+ helper.text
1131
+ ].join("\n"))
1132
+ ].join("\n");
1133
+ }
1134
+ //#endregion
1135
+ //#region src/rules/no-needless-helper/rule.ts
1136
+ const needlessHelperResponseSchema = object({ findings: array(object({
1137
+ helper: pipe(number(), description("The number shown beside the helper, exactly as given.")),
1138
+ name: pipe(string(), description("The helper's name, exactly as shown.")),
1139
+ reason: pipe(string(), description("At most twelve words, naming what the body already says."))
1140
+ })) });
1141
+ /**
1142
+ * Reports a helper whose interface is no simpler than its implementation.
1143
+ *
1144
+ * No AST approach is possible: a hash can prove a duplicate, but nothing can prove a helper
1145
+ * should not exist. The deterministic half only finds candidates and gathers facts (usage
1146
+ * count, exported), which are given to the model rather than applied as filters.
1147
+ */
1148
+ const needlessHelperRule = defineRule({
1149
+ cache: false,
1150
+ create: (ctx) => {
1151
+ const settings = readSimplicitySettings(ctx);
1152
+ return { async onTarget(target) {
1153
+ if (target.kind !== "file" || resolveExtractLanguage(target.file.path) === void 0) return;
1154
+ if (!settings.judge || isIgnored(ctx.cwd, target.file.path, settings.ignores)) return;
1155
+ const candidates = helpersIn(await repoIndexFor(ctx, {
1156
+ cwd: ctx.cwd,
1157
+ ignores: settings.ignores,
1158
+ maxLines: settings.maxLines,
1159
+ minTokens: settings.minTokens
1160
+ }), target.file.path).filter((helper) => helper.bodyIsSingleExpression);
1161
+ if (candidates.length === 0) return;
1162
+ await judge(ctx, target.file.path, candidates, settings.cache);
1163
+ } };
1164
+ }
1165
+ });
1166
+ /**
1167
+ * Everything the judge is told, so a cached answer is only replayed to an identical question.
1168
+ * The line is left out on purpose: a helper that only moved is the same helper.
1169
+ */
1170
+ function judgeKey(outputLanguage, candidates) {
1171
+ return createHash("sha256").update([
1172
+ needlessHelperPrompt,
1173
+ outputLanguage ?? "",
1174
+ ...candidates.map((helper) => [
1175
+ helper.name,
1176
+ helper.exported,
1177
+ helper.usageCount,
1178
+ helper.text
1179
+ ].join("\n"))
1180
+ ].join("\n--\n")).digest("hex");
1181
+ }
1182
+ /**
1183
+ * A name is not unique (a nested function is extracted on its own), so the ordinal the prompt
1184
+ * showed is the tiebreak. A finding that lands on no helper of that name is dropped: a diagnostic
1185
+ * on the wrong function is worse than none.
1186
+ */
1187
+ function resolveFinding(candidates, finding) {
1188
+ const named = candidates.filter((candidate) => candidate.name === finding.name);
1189
+ if (named.length === 0) return { outcome: "unknown" };
1190
+ if (named.length === 1) return {
1191
+ helper: named[0],
1192
+ outcome: "resolved"
1193
+ };
1194
+ const claimed = candidates[finding.helper - 1];
1195
+ return claimed === void 0 || claimed.name !== finding.name ? { outcome: "ambiguous" } : {
1196
+ helper: claimed,
1197
+ outcome: "resolved"
1198
+ };
1199
+ }
1200
+ function isIgnored(cwd, filePath, ignores) {
1201
+ const relativePath = relative(cwd, filePath);
1202
+ return ignores.some((pattern) => minimatch(relativePath, pattern, { dot: true }));
1203
+ }
1204
+ async function judge(ctx, filePath, candidates, cacheEnabled) {
1205
+ const cache = await decisionCacheFor(ctx, {
1206
+ cwd: ctx.cwd,
1207
+ enabled: cacheEnabled
1208
+ });
1209
+ const key = judgeKey(ctx.outputLanguage, candidates);
1210
+ const remembered = cache.get(filePath, key);
1211
+ if (remembered !== void 0) {
1212
+ ctx.logger.debug(`no-needless-helper: ${filePath} was judged on these helpers already`);
1213
+ report(ctx, resolveFindings(ctx, candidates, remembered));
1214
+ return;
1215
+ }
1216
+ let findings;
1217
+ try {
1218
+ ({findings} = await generateStructured({
1219
+ createMessages: (retryFeedback) => [
1220
+ {
1221
+ content: [needlessHelperPrompt, formatOutputLanguageInstruction(ctx.outputLanguage)].filter(Boolean).join("\n\n"),
1222
+ role: "system"
1223
+ },
1224
+ {
1225
+ content: buildNeedlessHelperPrompt(candidates),
1226
+ role: "user"
1227
+ },
1228
+ ...retryFeedback === void 0 ? [] : [{
1229
+ content: retryFeedback,
1230
+ role: "user"
1231
+ }]
1232
+ ],
1233
+ logger: ctx.logger,
1234
+ metering: ctx.metering,
1235
+ model: await ctx.model(),
1236
+ operation: "no-needless-helper-judge",
1237
+ schema: needlessHelperResponseSchema
1238
+ }));
1239
+ } catch (error) {
1240
+ ctx.logger.debug(`no-needless-helper: could not judge ${filePath}: ${errorMessageFrom(error) ?? "unknown error"}`);
1241
+ return;
1242
+ }
1243
+ const resolved = resolveFindings(ctx, candidates, findings);
1244
+ await cache.set(filePath, key, resolved.map(({ helper, reason }) => ({
1245
+ helper: candidates.indexOf(helper) + 1,
1246
+ name: helper.name,
1247
+ reason
1248
+ })));
1249
+ report(ctx, resolved);
1250
+ }
1251
+ function report(ctx, findings) {
1252
+ for (const { helper, reason } of findings) ctx.report({
1253
+ evidence: {
1254
+ exported: helper.exported,
1255
+ reason,
1256
+ usageCount: helper.usageCount
1257
+ },
1258
+ filePath: helper.filePath,
1259
+ loc: { start: {
1260
+ column: 0,
1261
+ line: helper.line
1262
+ } },
1263
+ message: `Helper "${helper.name}" does not earn its existence: ${reason}`
1264
+ });
1265
+ }
1266
+ /** Fresh and remembered findings carry the same fields, so both are pinned to where the helper stands now. */
1267
+ function resolveFindings(ctx, candidates, findings) {
1268
+ const resolved = [];
1269
+ for (const finding of findings) {
1270
+ const resolution = resolveFinding(candidates, finding);
1271
+ if (resolution.outcome === "unknown") {
1272
+ ctx.logger.debug(`no-needless-helper: ignored a finding for "${finding.name}", which was not one of the helpers under review`);
1273
+ continue;
1274
+ }
1275
+ if (resolution.outcome === "ambiguous") {
1276
+ ctx.logger.debug(`no-needless-helper: ignored a finding for "${finding.name}", which was ambiguous: several helpers under review share that name and helper ${finding.helper} is not one of them`);
1277
+ continue;
1278
+ }
1279
+ resolved.push({
1280
+ helper: resolution.helper,
1281
+ reason: finding.reason
1282
+ });
1283
+ }
1284
+ return resolved;
1285
+ }
1286
+ //#endregion
1287
+ //#region src/index.ts
1288
+ const simplicityPlugin = definePlugin({
1289
+ configs: { recommended: [{
1290
+ files: ["**/*.{ts,tsx,js,jsx,mts,cts,mjs,cjs,rs,go,py}"],
1291
+ language: "text/plain",
1292
+ rules: {
1293
+ "simplicity/no-duplicated-helper": "warn",
1294
+ "simplicity/no-needless-helper": "warn"
1295
+ }
1296
+ }] },
1297
+ rules: {
1298
+ "no-duplicated-helper": duplicatedHelperRule,
1299
+ "no-needless-helper": needlessHelperRule
1300
+ }
1301
+ });
1302
+ //#endregion
1303
+ export { alphaFingerprint, buildDuplicatedHelperPrompt, buildNeedlessHelperPrompt, createDuplicateTools, simplicityPlugin as default, simplicityPlugin, duplicatedHelperInstructions, duplicatedHelperRule, exactFingerprint, extractSource, helpersIn, needlessHelperPrompt, needlessHelperResponseSchema, needlessHelperRule, normalizedBody, repoIndexFor, resolveExtractLanguage, reviewCacheFor, tokenOverlap, tokenize, twinsOf };