@terra-symposium/standards-quality 0.0.0-stage → 0.5.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.
Files changed (67) hide show
  1. package/README.md +89 -2
  2. package/dist/collapse.d.ts +13 -0
  3. package/dist/collapse.d.ts.map +1 -0
  4. package/dist/collapse.js +151 -0
  5. package/dist/collapse.js.map +1 -0
  6. package/dist/documentation.d.ts +84 -0
  7. package/dist/documentation.d.ts.map +1 -0
  8. package/dist/documentation.js +1244 -0
  9. package/dist/documentation.js.map +1 -0
  10. package/dist/files.d.ts +39 -0
  11. package/dist/files.d.ts.map +1 -0
  12. package/dist/files.js +90 -0
  13. package/dist/files.js.map +1 -0
  14. package/dist/github-report.d.ts +89 -0
  15. package/dist/github-report.d.ts.map +1 -0
  16. package/dist/github-report.js +469 -0
  17. package/dist/github-report.js.map +1 -0
  18. package/dist/history-report.d.ts +52 -0
  19. package/dist/history-report.d.ts.map +1 -0
  20. package/dist/history-report.js +317 -0
  21. package/dist/history-report.js.map +1 -0
  22. package/dist/imports.d.ts +19 -0
  23. package/dist/imports.d.ts.map +1 -0
  24. package/dist/imports.js +90 -0
  25. package/dist/imports.js.map +1 -0
  26. package/dist/index.d.ts +19 -0
  27. package/dist/index.d.ts.map +1 -0
  28. package/dist/index.js +19 -0
  29. package/dist/index.js.map +1 -0
  30. package/dist/inline-if.d.ts +13 -0
  31. package/dist/inline-if.d.ts.map +1 -0
  32. package/dist/inline-if.js +48 -0
  33. package/dist/inline-if.js.map +1 -0
  34. package/dist/naming.d.ts +148 -0
  35. package/dist/naming.d.ts.map +1 -0
  36. package/dist/naming.js +642 -0
  37. package/dist/naming.js.map +1 -0
  38. package/dist/object-forwarding.d.ts +44 -0
  39. package/dist/object-forwarding.d.ts.map +1 -0
  40. package/dist/object-forwarding.js +182 -0
  41. package/dist/object-forwarding.js.map +1 -0
  42. package/dist/patterns.d.ts +85 -0
  43. package/dist/patterns.d.ts.map +1 -0
  44. package/dist/patterns.js +905 -0
  45. package/dist/patterns.js.map +1 -0
  46. package/dist/port.d.ts +21 -0
  47. package/dist/port.d.ts.map +1 -0
  48. package/dist/port.js +81 -0
  49. package/dist/port.js.map +1 -0
  50. package/dist/progress.d.ts +16 -0
  51. package/dist/progress.d.ts.map +1 -0
  52. package/dist/progress.js +30 -0
  53. package/dist/progress.js.map +1 -0
  54. package/dist/runner.d.ts +257 -0
  55. package/dist/runner.d.ts.map +1 -0
  56. package/dist/runner.js +2481 -0
  57. package/dist/runner.js.map +1 -0
  58. package/dist/security.d.ts +19 -0
  59. package/dist/security.d.ts.map +1 -0
  60. package/dist/security.js +76 -0
  61. package/dist/security.js.map +1 -0
  62. package/dist/single-use.d.ts +60 -0
  63. package/dist/single-use.d.ts.map +1 -0
  64. package/dist/single-use.js +1209 -0
  65. package/dist/single-use.js.map +1 -0
  66. package/package.json +45 -5
  67. package/quality.schema.json +130 -0
@@ -0,0 +1,1209 @@
1
+ /**
2
+ * packages/quality/src/single-use.ts
3
+ * AST inventory and safe autofix for single-consumer declarations (types, and helpers).
4
+ */
5
+ /*//////////////////////////////////////////////////
6
+ IMPORTS
7
+ //////////////////////////////////////////////////*/
8
+ // API
9
+ import ts from 'typescript';
10
+ import { dirname, join, relative, sep } from 'node:path';
11
+ import { qualityProgress } from './progress.js';
12
+ import { readFileSync, statSync, writeFileSync } from 'node:fs';
13
+ import { srcFiles } from './files.js';
14
+ /*//////////////////////////////////////////////////
15
+ REGEX
16
+ //////////////////////////////////////////////////*/
17
+ const S_REGEX = /^\s*$/;
18
+ const C_M_JS_JSX_TS_TSX_REGEX = /\.(?:c|m)?(?:js|jsx|ts|tsx)$/;
19
+ /*//////////////////////////////////////////////////
20
+ HELPERS
21
+ //////////////////////////////////////////////////*/
22
+ const reportProgress = (label, idx, total) => {
23
+ if (idx === 0 || idx === total || idx % Math.max(1, Math.ceil(total / 20)) === 0)
24
+ qualityProgress(label, idx, total);
25
+ };
26
+ const rejectedInlineCandidates = new Map();
27
+ const astroConsumerCache = new WeakMap();
28
+ const canonical = (c, s) => (s && s.flags & ts.SymbolFlags.Alias ? c.getAliasedSymbol(s) : s);
29
+ const isCandidateStatement = (s) => {
30
+ if (ts.canHaveModifiers(s) && ts.getModifiers(s)?.some((m) => m.kind === ts.SyntaxKind.DeclareKeyword))
31
+ return false;
32
+ return (ts.isTypeAliasDeclaration(s) ||
33
+ ts.isInterfaceDeclaration(s) ||
34
+ (ts.isFunctionDeclaration(s) && Boolean(s.body)) ||
35
+ (ts.isVariableStatement(s) && s.declarationList.declarations.length === 1 && ts.isIdentifier(s.declarationList.declarations[0]?.name)));
36
+ };
37
+ const isImportNode = (n) => {
38
+ const src = n.getSourceFile();
39
+ if (src.statements.some((s) => ts.isImportDeclaration(s) && n.getStart(src) >= s.getStart(src) && n.getEnd() <= s.getEnd()))
40
+ return true;
41
+ for (let parent = n.parent; parent && !ts.isSourceFile(parent); parent = parent.parent)
42
+ if (ts.isImportDeclaration(parent))
43
+ return true;
44
+ return false;
45
+ };
46
+ /**
47
+ * Creates the TypeScript program used to identify declarations and their consumers.
48
+ *
49
+ * @param cfg Quality configuration defining the source files to include.
50
+ *
51
+ * @return TypeScript program configured for repository JavaScript and TypeScript sources.
52
+ */
53
+ function programFor(cfg) {
54
+ const paths = Object.fromEntries(Object.entries(cfg.aliases ?? {}).map(([p, t]) => [p.endsWith('*') ? p : `${p}*`, [t.endsWith('*') ? t : `${t}*`]]));
55
+ return ts.createProgram({
56
+ rootNames: srcFiles(cfg).filter((f) => C_M_JS_JSX_TS_TSX_REGEX.test(f)),
57
+ options: {
58
+ allowJs: true,
59
+ baseUrl: cfg.root,
60
+ checkJs: false,
61
+ jsx: ts.JsxEmit.Preserve,
62
+ module: ts.ModuleKind.ESNext,
63
+ moduleResolution: ts.ModuleResolutionKind.Bundler,
64
+ paths,
65
+ skipLibCheck: true,
66
+ target: ts.ScriptTarget.ESNext,
67
+ },
68
+ });
69
+ }
70
+ /**
71
+ * Creates the shared TypeScript program used to validate a complete dead-code edit batch.
72
+ *
73
+ * @param cfg Quality configuration defining the source roots and compiler settings.
74
+ *
75
+ * @return TypeScript program used to inspect and validate the configured source files.
76
+ */
77
+ export function deadCodeTypeProgram(cfg) {
78
+ return programFor(cfg);
79
+ }
80
+ /**
81
+ * Checks the single-use-specific scope without changing other quality checks.
82
+ *
83
+ * @param cfg Quality configuration containing rule-specific directory exclusions.
84
+ * @param file Absolute source path being considered.
85
+ *
86
+ * @return `true` when the file is in scope for the single-use rule.
87
+ */
88
+ function inSingleUseScope(cfg, file) {
89
+ const roots = [...cfg.sourceRoots, ...(cfg.includeRoots ?? [])];
90
+ return roots.some((r) => file === r || file.startsWith(`${r}${sep}`));
91
+ }
92
+ /**
93
+ * Excludes configured directories from single-use candidate analysis.
94
+ *
95
+ * @param cfg Quality configuration containing source and single-use roots.
96
+ * @param file Absolute source path being considered.
97
+ *
98
+ * @return Whether the path is eligible for single-use analysis.
99
+ */
100
+ function isSingleUseCandidate(cfg, file) {
101
+ return inSingleUseScope(cfg, file) && !(cfg.singleUse?.skippedDirectories ?? []).some((directory) => file === directory || file.startsWith(`${directory}${sep}`));
102
+ }
103
+ /**
104
+ * Checks whether a file can contribute references, including files skipped as declaration candidates.
105
+ *
106
+ * @param cfg Quality configuration defining the source roots.
107
+ * @param file Absolute source path being considered.
108
+ *
109
+ * @return `true` when the file is in a configured source root.
110
+ */
111
+ function inSingleUseRoots(cfg, file) {
112
+ return [...cfg.sourceRoots, ...(cfg.includeRoots ?? [])].some((r) => file === r || file.startsWith(`${r}${sep}`));
113
+ }
114
+ /**
115
+ * Collects eligible declarations and all non-import references to their symbols.
116
+ *
117
+ * @param cfg Quality configuration defining which source roots are in scope.
118
+ * @param program TypeScript program containing the source files to inspect.
119
+ * @param checker TypeScript type checker used to canonicalize aliases and references.
120
+ *
121
+ * @return Candidate declarations and references grouped by canonical symbol.
122
+ */
123
+ function collect(cfg, program, checker) {
124
+ const candidates = [];
125
+ const declarationByLocation = new Map();
126
+ const candidateBySymbol = new Map();
127
+ const references = new Map();
128
+ const scopedSrcs = program.getSourceFiles().filter((source) => !source.isDeclarationFile && isSingleUseCandidate(cfg, source.fileName) && C_M_JS_JSX_TS_TSX_REGEX.test(source.fileName));
129
+ reportProgress('Single-use declaration scan:', 0, scopedSrcs.length);
130
+ for (const [sourceIndex, source] of scopedSrcs.entries()) {
131
+ reportProgress('Single-use declaration scan:', sourceIndex + 1, scopedSrcs.length);
132
+ const visitCandidates = (node) => {
133
+ if (!isCandidateStatement(node)) {
134
+ ts.forEachChild(node, visitCandidates);
135
+ return;
136
+ }
137
+ const statement = node;
138
+ const name = ts.isTypeAliasDeclaration(statement) || ts.isInterfaceDeclaration(statement) || ts.isFunctionDeclaration(statement)
139
+ ? statement.name
140
+ : ts.isVariableStatement(statement)
141
+ ? statement.declarationList.declarations[0]?.name
142
+ : undefined;
143
+ if (!name || !ts.isIdentifier(name)) {
144
+ ts.forEachChild(node, visitCandidates);
145
+ return;
146
+ }
147
+ const symbol = canonical(checker, checker.getSymbolAtLocation(name));
148
+ if (!symbol) {
149
+ ts.forEachChild(node, visitCandidates);
150
+ return;
151
+ }
152
+ const kind = ts.isTypeAliasDeclaration(statement) ? 'type' : ts.isInterfaceDeclaration(statement) ? 'interface' : ts.isFunctionDeclaration(statement) ? 'function' : 'variable';
153
+ const candidate = { file: source.fileName, source, statement, name, symbol, kind };
154
+ candidates.push(candidate);
155
+ declarationByLocation.set(`${source.fileName}:${name.getStart(source)}`, candidate);
156
+ candidateBySymbol.set(symbol, candidate);
157
+ ts.forEachChild(node, visitCandidates);
158
+ };
159
+ visitCandidates(source);
160
+ }
161
+ const candidateSymbols = new Set(candidates.map((c) => c.symbol));
162
+ const referenceSrcs = program.getSourceFiles().filter((source) => !source.isDeclarationFile && inSingleUseRoots(cfg, source.fileName) && C_M_JS_JSX_TS_TSX_REGEX.test(source.fileName));
163
+ const requiredExports = new Map();
164
+ for (const src of referenceSrcs) {
165
+ const visitRequires = (node) => {
166
+ if (ts.isVariableDeclaration(node) &&
167
+ ts.isObjectBindingPattern(node.name) &&
168
+ node.initializer &&
169
+ ts.isCallExpression(node.initializer) &&
170
+ ts.isIdentifier(node.initializer.expression) &&
171
+ node.initializer.expression.text === 'require' &&
172
+ node.initializer.arguments.length === 1 &&
173
+ ts.isStringLiteral(node.initializer.arguments[0])) {
174
+ const moduleSpecifier = node.initializer.arguments[0];
175
+ const moduleSrc = program.getSourceFile(ts.resolveModuleName(moduleSpecifier.text, src.fileName, program.getCompilerOptions(), ts.sys).resolvedModule?.resolvedFileName ?? '');
176
+ const moduleSymbol = moduleSrc && checker.getSymbolAtLocation(moduleSrc);
177
+ if (moduleSymbol) {
178
+ const exports = checker.getExportsOfModule(moduleSymbol);
179
+ for (const element of node.name.elements) {
180
+ if (!ts.isIdentifier(element.name) || element.dotDotDotToken)
181
+ continue;
182
+ const exportName = element.propertyName && ts.isIdentifier(element.propertyName) ? element.propertyName.text : element.name.text;
183
+ const localSymbol = canonical(checker, checker.getSymbolAtLocation(element.name));
184
+ const exportSymbol = canonical(checker, exports.find((i) => i.name === exportName));
185
+ if (localSymbol && exportSymbol && candidateSymbols.has(exportSymbol))
186
+ requiredExports.set(localSymbol, exportSymbol);
187
+ }
188
+ }
189
+ }
190
+ ts.forEachChild(node, visitRequires);
191
+ };
192
+ visitRequires(src);
193
+ }
194
+ reportProgress('Single-use reference scan:', 0, referenceSrcs.length);
195
+ for (const [sourceIndex, src] of referenceSrcs.entries()) {
196
+ reportProgress('Single-use reference scan:', sourceIndex + 1, referenceSrcs.length);
197
+ const visit = (node) => {
198
+ if (ts.isIdentifier(node)) {
199
+ if (ts.isBindingElement(node.parent)) {
200
+ ts.forEachChild(node, visit);
201
+ return;
202
+ }
203
+ let importUse = false;
204
+ for (let parent = node.parent; parent && !ts.isSourceFile(parent); parent = parent.parent) {
205
+ if (ts.isImportDeclaration(parent)) {
206
+ importUse = true;
207
+ break;
208
+ }
209
+ }
210
+ const shorthandSymbol = ts.isShorthandPropertyAssignment(node.parent) ? checker.getShorthandAssignmentValueSymbol(node.parent) : undefined;
211
+ const symbol = canonical(checker, shorthandSymbol ?? checker.getSymbolAtLocation(node) ?? (ts.isTypeQueryNode(node.parent) ? checker.getSymbolAtLocation(node.parent) : undefined));
212
+ const candidateSymbol = symbol && (candidateSymbols.has(symbol) ? symbol : requiredExports.get(symbol));
213
+ if (!importUse && candidateSymbol) {
214
+ const declaration = declarationByLocation.get(`${src.fileName}:${node.getStart(src)}`);
215
+ const candidate = candidateBySymbol.get(candidateSymbol);
216
+ const selfReference = candidate?.file === src.fileName && node.getStart(src) >= candidate.statement.getStart(src) && node.getEnd() <= candidate.statement.getEnd();
217
+ if (!declaration && !selfReference)
218
+ references.set(candidateSymbol, [...(references.get(candidateSymbol) ?? []), { file: src.fileName, source: src, node }]);
219
+ }
220
+ }
221
+ ts.forEachChild(node, visit);
222
+ };
223
+ visit(src);
224
+ }
225
+ return { candidates, references };
226
+ }
227
+ /**
228
+ * Classifies a single-consumer declaration according to its safety and readability boundary.
229
+ *
230
+ * @param candidate Declaration candidate whose kind and initializer determine the recommendation.
231
+ *
232
+ * @return Decision and explanation for the candidate.
233
+ */
234
+ function classify(candidate) {
235
+ if (candidate.kind === 'function')
236
+ return ['REVIEW', 'inlining a function can change its identity, closure, stack, or component behavior'];
237
+ if (candidate.kind === 'variable') {
238
+ if (!initSafeToInline(candidate))
239
+ return ['REVIEW', 'initializer is not a side-effect-free primitive constant'];
240
+ return ['INLINE', 'side-effect-free primitive constant has one in-scope consumer'];
241
+ }
242
+ return ['INLINE', `${candidate.kind} declaration has one in-scope consumer`];
243
+ }
244
+ /** Builds the review action for an eligible or advisory single-use candidate.
245
+ * @param candidate Declaration whose only consumer is being considered.
246
+ * @param use The source reference that consumes the declaration.
247
+ * @param canInline Whether the canonical rewrite can safely apply automatically.
248
+ * @param reason Finding reason used to select a boundary-specific action.
249
+ * @returns A concise next step for reviewing or applying the finding.
250
+ */
251
+ function singleUseAgentAction(candidate, use, canInline, reason) {
252
+ if (canInline)
253
+ return 'Run the canonical dead-code autofix, then verify the inlined type/value remains equivalent and rerun the affected test or typecheck.';
254
+ if (candidate.kind === 'function') {
255
+ if (candidate.file !== use.file)
256
+ return 'Keep the helper in its declaring module unless its module-local dependencies move with it; if you restructure that boundary, preserve closure capture, recursion, async behavior, and the caller contract.';
257
+ const isJsxUse = ts.isJsxOpeningElement(use.node.parent) || ts.isJsxSelfClosingElement(use.node.parent);
258
+ return isJsxUse
259
+ ? 'Inline this component at its only JSX use only if React component identity, local state, hooks, effects, and render behavior remain unchanged. Otherwise retain it only for a concrete semantic boundary and verify with a focused render test.'
260
+ : 'Inline this helper at its sole call only if closure capture, recursion, `this`, async boundaries, stack-sensitive behavior, and evaluation order remain unchanged. Otherwise retain it only for a concrete semantic boundary and run its focused test.';
261
+ }
262
+ if (candidate.kind === 'type' || candidate.kind === 'interface')
263
+ return 'Inline this declaration at its sole type use only if generic arguments, imported names, and inferred specificity remain equivalent. Otherwise retain the separate type only when its name is materially useful, and verify with typecheck.';
264
+ if (reason.includes('cross-file initializer'))
265
+ return 'Keep the value in its declaring module unless its module-local dependencies move with it; if you restructure that boundary, verify initialization order and the consumer behavior.';
266
+ if (reason.includes('move await'))
267
+ return 'Keep the await at its current async boundary. Only inline after a deliberate async refactor that preserves sequencing, rejection handling, and the caller contract.';
268
+ if (reason.includes('write target'))
269
+ return 'Keep the binding because its only use is a write target. Do not substitute its initializer unless the same writable location and mutation semantics are preserved.';
270
+ if (reason.includes('imported type annotation'))
271
+ return 'Keep the binding and its imported type annotation together; only inline after preserving the annotation and confirming the affected typecheck.';
272
+ if (reason.includes('explicit any'))
273
+ return 'Keep the named type boundary unless it can be inlined without introducing explicit any; verify the consumer with typecheck.';
274
+ const reactSrc = /\.(?:tsx|jsx)$/.test(candidate.source.fileName);
275
+ const invariants = reactSrc
276
+ ? 'evaluation order and timing, closure capture, identity, writes, React hook order, and observable effects'
277
+ : 'evaluation order and timing, closure capture, identity, writes, and observable effects';
278
+ return `Inline the initializer at its sole use only if ${invariants} remain unchanged. Otherwise retain the binding for the concrete semantic reason reported above and verify the focused behavior.`;
279
+ }
280
+ /**
281
+ * Finds type-position references to a candidate declaration across the configured source scope.
282
+ *
283
+ * @param candidate Declaration whose references are being filtered.
284
+ * @param references Resolved references grouped by declaration symbol.
285
+ *
286
+ * @return Type references excluding the declaration and import syntax itself.
287
+ */
288
+ function typeReferences(candidate, references) {
289
+ return (references.get(candidate.symbol) ?? []).filter((reference) => {
290
+ const tgt = referenceTgt(reference.node);
291
+ return !isExportSpecifierReference(reference) && (ts.isTypeReferenceNode(tgt) || ts.isExpressionWithTypeArguments(tgt));
292
+ });
293
+ }
294
+ /**
295
+ * Collects Astro frontmatter and markup consumers once for an analysis run.
296
+ *
297
+ * @param cfg Quality configuration defining the files to inspect.
298
+ * @param program TypeScript program used to map declarations and imports.
299
+ *
300
+ * @return Source-and-name keys for declarations referenced from Astro files.
301
+ */
302
+ function astroConsumersFor(cfg, program) {
303
+ const astroFiles = srcFiles(cfg).filter((path) => path.endsWith('.astro'));
304
+ const signature = astroFiles
305
+ .map((f) => {
306
+ const stats = statSync(f);
307
+ return `${f}:${stats.mtimeMs}:${stats.size}`;
308
+ })
309
+ .join('\n');
310
+ let cached = astroConsumerCache.get(cfg);
311
+ if (!cached || cached.signature !== signature) {
312
+ const consumers = new Set();
313
+ for (const f of astroFiles) {
314
+ const raw = readFileSync(f, 'utf8');
315
+ const match = /^---\s*\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/.exec(raw);
316
+ if (!match)
317
+ continue;
318
+ const src = ts.createSourceFile(f, match[1], ts.ScriptTarget.Latest, true, ts.ScriptKind.TS);
319
+ const markup = raw.slice(match[0].length);
320
+ for (const statement of src.statements) {
321
+ if (!ts.isImportDeclaration(statement) || !ts.isStringLiteral(statement.moduleSpecifier))
322
+ continue;
323
+ const bindings = statement.importClause?.namedBindings;
324
+ if (!bindings || !ts.isNamedImports(bindings))
325
+ continue;
326
+ const resolved = ts.resolveModuleName(statement.moduleSpecifier.text, f, program.getCompilerOptions(), ts.sys).resolvedModule?.resolvedFileName;
327
+ if (!resolved)
328
+ continue;
329
+ for (const imported of bindings.elements) {
330
+ if (!markup.includes(imported.name.text)) {
331
+ let used = false;
332
+ const inspect = (node) => {
333
+ if (node === statement || used)
334
+ return;
335
+ if (ts.isIdentifier(node) && node.text === imported.name.text && !isImportNode(node))
336
+ used = true;
337
+ ts.forEachChild(node, inspect);
338
+ };
339
+ for (const bodyStatement of src.statements)
340
+ inspect(bodyStatement);
341
+ if (!used)
342
+ continue;
343
+ }
344
+ consumers.add(`${resolved}\u0000${(imported.propertyName ?? imported.name).text}`);
345
+ }
346
+ }
347
+ }
348
+ cached = { signature, consumers };
349
+ astroConsumerCache.set(cfg, cached);
350
+ }
351
+ return cached.consumers;
352
+ }
353
+ /**
354
+ * Keeps exported bindings that are imported from Astro frontmatter outside the TypeScript program.
355
+ *
356
+ * @param candidate Candidate declaration whose Astro use should be checked.
357
+ * @param consumers Source-and-name keys collected from Astro frontmatter and markup.
358
+ *
359
+ * @return Whether an Astro file references this declaration.
360
+ */
361
+ function hasAstroConsumer(candidate, consumers) {
362
+ return consumers.has(`${candidate.file}\u0000${candidate.name.text}`);
363
+ }
364
+ /**
365
+ * Keeps explicitly configured module contracts out of single-consumer cleanup.
366
+ *
367
+ * @param cfg Quality configuration containing explicit framework export contracts.
368
+ * @param candidate Candidate declaration to match against the configured contract.
369
+ * @param checker TypeScript checker used to confirm the binding is exported.
370
+ *
371
+ * @return Whether this named export is protected by configuration.
372
+ */
373
+ function isCfgdPublicContract(cfg, candidate, checker) {
374
+ const file = relative(cfg.root, candidate.file).split(sep).join('/');
375
+ if (!cfg.frameworkEntryExports?.[file]?.includes(candidate.name.text))
376
+ return false;
377
+ const moduleSymbol = checker.getSymbolAtLocation(candidate.source);
378
+ const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === candidate.name.text);
379
+ return Boolean(exported && canonical(checker, exported) === candidate.symbol);
380
+ }
381
+ /**
382
+ * Keeps declarations exported by a package entrypoint out of local single-use advice.
383
+ *
384
+ * @param cfg Quality configuration defining the repository package root.
385
+ * @param candidate Candidate declaration to match against the package entry points.
386
+ * @param checker TypeScript checker used to resolve module exports.
387
+ *
388
+ * @return Whether the declaration is reachable from a package entry point.
389
+ */
390
+ function isPackageEntryExport(cfg, candidate, checker) {
391
+ const moduleSymbol = checker.getSymbolAtLocation(candidate.source);
392
+ const exported = moduleSymbol && checker.getExportsOfModule(moduleSymbol).find((symbol) => symbol.name === candidate.name.text);
393
+ if (!exported || canonical(checker, exported) !== candidate.symbol)
394
+ return false;
395
+ for (let dir = dirname(candidate.file); dir.startsWith(cfg.root); dir = dirname(dir)) {
396
+ const manifestPath = join(dir, 'package.json');
397
+ if (!statSync(manifestPath, { throwIfNoEntry: false })?.isFile())
398
+ continue;
399
+ try {
400
+ const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
401
+ const tgts = [];
402
+ const collectTgts = (val) => {
403
+ if (typeof val === 'string')
404
+ tgts.push(val);
405
+ else if (Array.isArray(val))
406
+ val.forEach(collectTgts);
407
+ else if (val && typeof val === 'object')
408
+ Object.values(val).forEach(collectTgts);
409
+ };
410
+ collectTgts(manifest.exports);
411
+ if (tgts.length === 0)
412
+ tgts.push(...[manifest.main, manifest.module, manifest.types, manifest.typings].filter((v) => Boolean(v)));
413
+ const relFile = relative(dir, candidate.file).split(sep).join('/');
414
+ for (const tgt of tgts) {
415
+ const norm = tgt.replace(/^\.\//, '').replace(/\.(?:m|c)?(?:js|jsx|ts|tsx|d\.ts)$/, '');
416
+ if (norm === relFile.replace(C_M_JS_JSX_TS_TSX_REGEX, ''))
417
+ return true;
418
+ const tsconfigPath = join(dir, 'tsconfig.json');
419
+ if (!statSync(tsconfigPath, { throwIfNoEntry: false })?.isFile())
420
+ continue;
421
+ const parsed = ts.readConfigFile(tsconfigPath, ts.sys.readFile);
422
+ if (parsed.error)
423
+ continue;
424
+ const { rootDir, outDir } = ts.parseJsonConfigFileContent(parsed.config, ts.sys, dir).options;
425
+ if (!rootDir || !outDir)
426
+ continue;
427
+ const outputFile = relative(dir, join(outDir, `${relative(rootDir, candidate.file).replace(C_M_JS_JSX_TS_TSX_REGEX, '')}.js`))
428
+ .split(sep)
429
+ .join('/');
430
+ if (norm === outputFile.replace(C_M_JS_JSX_TS_TSX_REGEX, ''))
431
+ return true;
432
+ }
433
+ return false;
434
+ }
435
+ catch {
436
+ return false;
437
+ }
438
+ }
439
+ return false;
440
+ }
441
+ /**
442
+ * Preserves a React identity hook or interval handle whose one use is required by lifecycle semantics.
443
+ *
444
+ * @param candidate Candidate value declaration whose lifecycle behavior is checked.
445
+ * @param use The candidate's single reference.
446
+ *
447
+ * @return Whether retaining the binding protects hook or interval lifecycle semantics.
448
+ */
449
+ function isRequiredLifecycleVal(candidate, use) {
450
+ if (candidate.kind !== 'variable' || !ts.isVariableStatement(candidate.statement))
451
+ return false;
452
+ const init = candidate.statement.declarationList.declarations[0]?.initializer;
453
+ if (!init || !ts.isCallExpression(init))
454
+ return false;
455
+ const hookCall = ts.isIdentifier(init.expression) && init.expression.text === 'useId';
456
+ const intervalCall = ts.isPropertyAccessExpression(init.expression) && ts.isIdentifier(init.expression.expression) && init.expression.expression.text === 'window' && init.expression.name.text === 'setInterval';
457
+ if (hookCall)
458
+ return true;
459
+ if (!intervalCall || !ts.isCallExpression(use.node.parent))
460
+ return false;
461
+ return (ts.isPropertyAccessExpression(use.node.parent.expression) &&
462
+ ts.isIdentifier(use.node.parent.expression.expression) &&
463
+ use.node.parent.expression.expression.text === 'window' &&
464
+ use.node.parent.expression.name.text === 'clearInterval');
465
+ }
466
+ /**
467
+ * Keeps imported type names in scope when an inline would discard their annotation.
468
+ *
469
+ * @param candidate Variable candidate whose explicit annotation might be discarded.
470
+ * @param checker TypeScript checker used to identify whether annotation names are imported.
471
+ *
472
+ * @return Whether inlining would remove a required imported type annotation.
473
+ */
474
+ function hasImportedTypeAnnotation(candidate, checker) {
475
+ if (candidate.kind !== 'variable' || !ts.isVariableStatement(candidate.statement))
476
+ return false;
477
+ return candidate.statement.declarationList.declarations.some((d) => {
478
+ if (!d.type)
479
+ return false;
480
+ let imported = false;
481
+ const inspect = (node) => {
482
+ const symbol = ts.isIdentifier(node) ? checker.getSymbolAtLocation(node) : undefined;
483
+ if (symbol?.flags && symbol.flags & ts.SymbolFlags.Alias)
484
+ imported = true;
485
+ ts.forEachChild(node, inspect);
486
+ };
487
+ inspect(d.type);
488
+ return imported;
489
+ });
490
+ }
491
+ /**
492
+ * Checks whether a cross-file type body depends on names unavailable to the consumer.
493
+ *
494
+ * @param candidate Type declaration proposed for inlining.
495
+ * @param reference Its consumer reference.
496
+ * @param checker Type checker used to resolve names in the declaration body.
497
+ *
498
+ * @return `true` when copied type syntax remains resolvable in the consumer file.
499
+ */
500
+ function crossFileTypeSafe(candidate, reference, checker) {
501
+ if (candidate.file === reference.file)
502
+ return true;
503
+ let s = true;
504
+ const visit = (node) => {
505
+ if (ts.isTypeReferenceNode(node)) {
506
+ const symbol = checker.getSymbolAtLocation(node.typeName);
507
+ const declaration = symbol?.declarations?.[0];
508
+ if (declaration && !ts.isTypeParameterDeclaration(declaration) && declaration.getSourceFile().fileName !== reference.file && declaration.getSourceFile().isDeclarationFile === false)
509
+ s = false;
510
+ }
511
+ ts.forEachChild(node, visit);
512
+ };
513
+ if (ts.isTypeAliasDeclaration(candidate.statement))
514
+ visit(candidate.statement.type);
515
+ if (ts.isInterfaceDeclaration(candidate.statement)) {
516
+ for (const mem of candidate.statement.members)
517
+ visit(mem);
518
+ for (const clause of candidate.statement.heritageClauses ?? [])
519
+ visit(clause);
520
+ }
521
+ return s;
522
+ }
523
+ /**
524
+ * Rejects type aliases whose expansion would introduce explicit `any` at their use site.
525
+ *
526
+ * @param candidate Type alias or interface proposed for inlining.
527
+ *
528
+ * @return `true` when the declaration body contains an explicit `any` type node.
529
+ */
530
+ function containsExplicitAny(candidate) {
531
+ let found = false;
532
+ const visit = (node) => {
533
+ if (node.kind === ts.SyntaxKind.AnyKeyword)
534
+ found = true;
535
+ ts.forEachChild(node, visit);
536
+ };
537
+ if (ts.isTypeAliasDeclaration(candidate.statement))
538
+ visit(candidate.statement.type);
539
+ if (ts.isInterfaceDeclaration(candidate.statement))
540
+ visit(candidate.statement);
541
+ return found;
542
+ }
543
+ /**
544
+ * Rejects initializers whose await would be moved into an unrelated callback.
545
+ *
546
+ * @param candidate Candidate declaration whose initializer is inspected.
547
+ *
548
+ * @return Whether the initializer contains an await expression.
549
+ */
550
+ function containsAwait(candidate) {
551
+ if (!ts.isVariableStatement(candidate.statement))
552
+ return false;
553
+ let found = false;
554
+ const visit = (node) => {
555
+ if (ts.isAwaitExpression(node))
556
+ found = true;
557
+ ts.forEachChild(node, visit);
558
+ };
559
+ const init = candidate.statement.declarationList.declarations[0]?.initializer;
560
+ if (init)
561
+ visit(init);
562
+ return found;
563
+ }
564
+ /**
565
+ * Rejects expressions whose timing, hook order, or observable identity can change when moved.
566
+ *
567
+ * @param candidate Candidate declaration whose initializer is checked for safe substitution.
568
+ *
569
+ * @return Whether inlining the initializer preserves simple constant evaluation.
570
+ */
571
+ function initSafeToInline(candidate) {
572
+ if (candidate.kind !== 'variable' || !ts.isVariableStatement(candidate.statement))
573
+ return true;
574
+ const declaration = candidate.statement.declarationList.declarations[0];
575
+ const init = declaration?.initializer;
576
+ if (!init || !(candidate.statement.declarationList.flags & ts.NodeFlags.Const) || init.getText(candidate.source).length > 120)
577
+ return false;
578
+ return (ts.isStringLiteral(init) ||
579
+ ts.isNumericLiteral(init) ||
580
+ ts.isNoSubstitutionTemplateLiteral(init) ||
581
+ init.kind === ts.SyntaxKind.TrueKeyword ||
582
+ init.kind === ts.SyntaxKind.FalseKeyword ||
583
+ init.kind === ts.SyntaxKind.NullKeyword);
584
+ }
585
+ /**
586
+ * Checks whether a cross-file variable initializer is a self-contained literal value.
587
+ *
588
+ * @param candidate Variable declaration proposed for inlining.
589
+ *
590
+ * @return `true` when the initializer has no references to module-local bindings.
591
+ */
592
+ function crossFileVariableSafe(candidate) {
593
+ if (candidate.kind !== 'variable' || !ts.isVariableStatement(candidate.statement))
594
+ return false;
595
+ const init = candidate.statement.declarationList.declarations[0]?.initializer;
596
+ if (!init)
597
+ return false;
598
+ let s = true;
599
+ const visit = (node) => {
600
+ if (ts.isIdentifier(node) && !((ts.isPropertyAssignment(node.parent) && node.parent.name === node) || (ts.isMethodDeclaration(node.parent) && node.parent.name === node)))
601
+ s = false;
602
+ ts.forEachChild(node, visit);
603
+ };
604
+ visit(init);
605
+ return s;
606
+ }
607
+ /**
608
+ * Checks whether replacing a variable reference would make it an invalid or mutating target.
609
+ *
610
+ * @param reference Reference to a variable candidate.
611
+ *
612
+ * @return `true` when the reference is used as a write target.
613
+ */
614
+ function isWriteReference(reference) {
615
+ let tgt = reference.node;
616
+ while (tgt.parent && ((ts.isPropertyAccessExpression(tgt.parent) && tgt.parent.expression === tgt) || (ts.isElementAccessExpression(tgt.parent) && tgt.parent.expression === tgt)))
617
+ tgt = tgt.parent;
618
+ return (((ts.isPrefixUnaryExpression(tgt.parent) || ts.isPostfixUnaryExpression(tgt.parent)) && [ts.SyntaxKind.PlusPlusToken, ts.SyntaxKind.MinusMinusToken].includes(tgt.parent.operator)) ||
619
+ (ts.isBinaryExpression(tgt.parent) && tgt.parent.left === tgt && tgt.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment && tgt.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment) ||
620
+ ts.isDeleteExpression(tgt.parent));
621
+ }
622
+ /**
623
+ * Preserves saved values used to restore mutable object or property state.
624
+ *
625
+ * @param reference Candidate reference whose assignment target is inspected.
626
+ *
627
+ * @return Whether the reference is a saved value used to restore mutable state.
628
+ */
629
+ function isStateRestorationReference(reference) {
630
+ return (ts.isBinaryExpression(reference.node.parent) &&
631
+ reference.node.parent.right === reference.node &&
632
+ reference.node.parent.operatorToken.kind >= ts.SyntaxKind.FirstAssignment &&
633
+ reference.node.parent.operatorToken.kind <= ts.SyntaxKind.LastAssignment &&
634
+ (ts.isPropertyAccessExpression(reference.node.parent.left) || ts.isElementAccessExpression(reference.node.parent.left)));
635
+ }
636
+ /**
637
+ * Identifies a reference that only re-exports an imported binding.
638
+ *
639
+ * @param reference Symbol reference being classified.
640
+ *
641
+ * @return `true` when the reference is part of an export list, not an inline consumer.
642
+ */
643
+ function isExportSpecifierReference(reference) {
644
+ return ts.isExportSpecifier(reference.node.parent);
645
+ }
646
+ /**
647
+ * Reports declarations that have exactly one in-scope consumer.
648
+ *
649
+ * @param cfg Quality configuration defining the source scope.
650
+ *
651
+ * @return Single-use recommendations with their locations and safety decisions.
652
+ */
653
+ export function singleUseFindings(cfg) {
654
+ const program = programFor(cfg);
655
+ const checker = program.getTypeChecker();
656
+ const { candidates, references } = collect(cfg, program, checker);
657
+ const astroConsumers = astroConsumersFor(cfg, program);
658
+ const findings = [];
659
+ reportProgress('Single-use classification:', 0, candidates.length);
660
+ for (const [candidateIdx, c] of candidates.entries()) {
661
+ reportProgress('Single-use classification:', candidateIdx + 1, candidates.length);
662
+ if (isCfgdPublicContract(cfg, c, checker) || isPackageEntryExport(cfg, c, checker))
663
+ continue;
664
+ const uses = c.kind === 'type' || c.kind === 'interface' ? typeReferences(c, references) : (references.get(c.symbol) ?? []).filter((r) => !isImportNode(r.node) && !isExportSpecifierReference(r));
665
+ if (uses.length !== 1)
666
+ continue;
667
+ const use = uses[0];
668
+ if (!inSingleUseScope(cfg, use.file))
669
+ continue;
670
+ if (isRequiredLifecycleVal(c, use))
671
+ continue;
672
+ const [initialDecision, initialReason] = classify(c);
673
+ let reason = initialReason;
674
+ let canInline = initialDecision === 'INLINE';
675
+ if (hasAstroConsumer(c, astroConsumers))
676
+ continue;
677
+ if (c.kind === 'function')
678
+ [canInline, reason] = [false, 'inlining a function can change its identity, closure, stack, or component behavior'];
679
+ else if (c.kind === 'variable' && c.file !== use.file && !crossFileVariableSafe(c))
680
+ [canInline, reason] = [false, 'cross-file initializer depends on module-local values'];
681
+ else if ((c.kind === 'type' || c.kind === 'interface') && !crossFileTypeSafe(c, use, checker))
682
+ [canInline, reason] = [false, 'type depends on declarations that are not available at the consumer'];
683
+ else if ((c.kind === 'type' || c.kind === 'interface') && containsExplicitAny(c))
684
+ [canInline, reason] = [false, 'inlining would introduce explicit any'];
685
+ else if (c.kind === 'variable' && containsAwait(c))
686
+ [canInline, reason] = [false, 'inlining would move await into another evaluation context'];
687
+ else if (c.kind === 'variable' && !initSafeToInline(c))
688
+ [canInline, reason] = [false, 'inlining could change evaluation timing, hook order, or side effects'];
689
+ else if (c.kind === 'variable' && isStateRestorationReference(use))
690
+ [canInline, reason] = [false, 'inlining would discard a saved value needed to restore mutable state'];
691
+ else if (c.kind === 'variable' && isWriteReference(use))
692
+ [canInline, reason] = [false, 'the sole reference is a write target'];
693
+ else if (c.kind === 'variable' && (ts.isJsxOpeningElement(use.node.parent) || ts.isJsxSelfClosingElement(use.node.parent)))
694
+ [canInline, reason] = [false, 'this is a standalone JSX component; keep its component identity and declaration boundary, or export it from its definition if it is a module-level component'];
695
+ else if (hasImportedTypeAnnotation(c, checker))
696
+ [canInline, reason] = [false, 'inlining would remove a required imported type annotation'];
697
+ const decision = canInline ? 'INLINE' : 'REVIEW';
698
+ const rejectedReason = rejectedInlineCandidates.get(`${c.file}:${c.name.text}`);
699
+ findings.push({
700
+ file: relative(cfg.root, c.file),
701
+ line: c.source.getLineAndCharacterOfPosition(c.name.getStart(c.source)).line + 1,
702
+ name: c.name.text,
703
+ decision: rejectedReason ? 'REVIEW' : decision,
704
+ autofixable: !rejectedReason && decision === 'INLINE',
705
+ reason: rejectedReason ?? reason,
706
+ agentAction: singleUseAgentAction(c, use, !rejectedReason && canInline, rejectedReason ?? reason),
707
+ });
708
+ }
709
+ return findings;
710
+ }
711
+ /**
712
+ * Finds the source span of an imported named binding that refers to a target symbol.
713
+ *
714
+ * @param reference Consumer reference whose import statement may be edited.
715
+ * @param checker TypeScript type checker used to compare canonical symbols.
716
+ * @param tgt Canonical declaration symbol being inlined.
717
+ *
718
+ * @return Import-binding span to remove, or `null` when the reference is not a named import.
719
+ */
720
+ function importEdit(reference, checker, tgt) {
721
+ for (const statement of reference.source.statements) {
722
+ if (!ts.isImportDeclaration(statement))
723
+ continue;
724
+ const bindings = statement.importClause?.namedBindings;
725
+ if (!bindings || !ts.isNamedImports(bindings))
726
+ continue;
727
+ const specifier = bindings.elements.find((e) => canonical(checker, checker.getSymbolAtLocation(e.name)) === tgt);
728
+ if (!specifier)
729
+ continue;
730
+ if (bindings.elements.length === 1)
731
+ return { start: statement.getStart(reference.source), end: statement.getEnd() };
732
+ const idx = bindings.elements.indexOf(specifier);
733
+ return idx === 0
734
+ ? { start: specifier.getStart(reference.source), end: bindings.elements[1].getStart(reference.source) }
735
+ : { start: bindings.elements[idx - 1].getEnd(), end: specifier.getEnd() };
736
+ }
737
+ return null;
738
+ }
739
+ /**
740
+ * Builds a function type from a declaration for consumers in a type query.
741
+ *
742
+ * @param statement Function declaration used by the type query.
743
+ *
744
+ * @return A TypeScript function type preserving declared parameters and result.
745
+ */
746
+ function fnType(statement) {
747
+ const params = statement.parameters.map((p) => ts.factory.createParameterDeclaration(undefined, p.dotDotDotToken, p.name, p.questionToken, p.type ?? ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword)));
748
+ const type = ts.factory.createFunctionTypeNode(statement.typeParameters, params, statement.type ?? ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword));
749
+ return ts.createPrinter().printNode(ts.EmitHint.Unspecified, type, statement.getSourceFile());
750
+ }
751
+ /**
752
+ * Expands a component use into a createElement call so the component remains a React component.
753
+ *
754
+ * @param ref Single JSX component reference.
755
+ * @param replacement Inline function or component expression.
756
+ * @param reactName Local import alias for React's createElement function.
757
+ * @param componentType Optional type annotation applied to the generated component expression.
758
+ *
759
+ * @return JSX replacement edit, or `null` when the reference is not a JSX tag.
760
+ */
761
+ function jsxInlineEdit(ref, replacement, reactName, componentType) {
762
+ if ((!ts.isJsxOpeningElement(ref.node.parent) && !ts.isJsxSelfClosingElement(ref.node.parent)) || ref.node.parent.tagName !== ref.node)
763
+ return null;
764
+ const el = ts.isJsxSelfClosingElement(ref.node.parent) ? ref.node.parent : ts.isJsxElement(ref.node.parent.parent) ? ref.node.parent.parent : null;
765
+ if (!el)
766
+ return null;
767
+ const props = ref.node.parent.attributes.properties.map((a) => {
768
+ if (ts.isJsxSpreadAttribute(a))
769
+ return `...${a.expression.getText(ref.source)}`;
770
+ const name = JSON.stringify(a.name.getText(ref.source));
771
+ if (!a.initializer)
772
+ return `${name}: true`;
773
+ if (ts.isStringLiteral(a.initializer))
774
+ return `${name}: ${JSON.stringify(a.initializer.text)}`;
775
+ if (ts.isJsxExpression(a.initializer))
776
+ return `${name}: ${a.initializer.expression?.getText(ref.source) ?? 'undefined'}`;
777
+ return `${name}: ${a.initializer.getText(ref.source)}`;
778
+ });
779
+ if (ts.isJsxElement(el) && el.children.length)
780
+ props.push(`children: <>${el.children.map((c) => c.getText(ref.source)).join('')}</>`);
781
+ const call = `${reactName}(${componentType ? `(${replacement} as ${componentType})` : replacement}, { ${props.join(', ')} })`;
782
+ const nested = ts.isJsxElement(el.parent) || ts.isJsxFragment(el.parent);
783
+ return { start: el.getStart(ref.source), end: el.getEnd(), text: nested ? `{${call}}` : call };
784
+ }
785
+ /**
786
+ * Expands a property or namespace access when the declaration is its right-hand identifier.
787
+ *
788
+ * @param node Identifier reference whose surrounding access expression is expanded.
789
+ *
790
+ * @return Largest qualified/property access expression containing the identifier.
791
+ */
792
+ function referenceTgt(node) {
793
+ let tgt = node;
794
+ while (tgt.parent && ((ts.isQualifiedName(tgt.parent) && tgt.parent.right === tgt) || (ts.isPropertyAccessExpression(tgt.parent) && tgt.parent.name === tgt)))
795
+ tgt = tgt.parent;
796
+ if (tgt.parent && ((ts.isTypeReferenceNode(tgt.parent) && tgt.parent.typeName === tgt) || (ts.isExpressionWithTypeArguments(tgt.parent) && tgt.parent.expression === tgt)))
797
+ tgt = tgt.parent;
798
+ return tgt;
799
+ }
800
+ /**
801
+ * Substitutes generic arguments into an inlined type declaration.
802
+ *
803
+ * @param declaration Type declaration supplying the generic parameters.
804
+ * @param reference Consumer type reference carrying the concrete arguments.
805
+ * @param type Type body to expand.
806
+ *
807
+ * @return Expanded type text, or `null` when generic arity does not match.
808
+ */
809
+ function inlineGenericType(declaration, reference, type) {
810
+ const params = declaration.typeParameters ?? [];
811
+ const arguments_ = reference.typeArguments ?? [];
812
+ if (params.length !== arguments_.length)
813
+ return null;
814
+ const substitutions = new Map(params.map((p, i) => [p.name.text, arguments_[i]]));
815
+ const transformed = ts.transform(type, [
816
+ (ctx) => {
817
+ const visit = (node) => {
818
+ if (ts.isTypeReferenceNode(node) && ts.isIdentifier(node.typeName) && substitutions.has(node.typeName.text))
819
+ return substitutions.get(node.typeName.text);
820
+ if (ts.isTypeQueryNode(node) && ts.isIdentifier(node.exprName) && substitutions.has(node.exprName.text))
821
+ return substitutions.get(node.exprName.text);
822
+ return ts.visitEachChild(node, visit, ctx);
823
+ };
824
+ return (root) => ts.visitNode(root, visit);
825
+ },
826
+ ]);
827
+ const result = ts.createPrinter().printNode(ts.EmitHint.Unspecified, transformed.transformed[0], declaration.getSourceFile());
828
+ transformed.dispose();
829
+ return `(${result})`;
830
+ }
831
+ /**
832
+ * Removes export modifiers and converts a function declaration into an expression.
833
+ *
834
+ * @param statement Function declaration selected for inlining.
835
+ *
836
+ * @return Function expression source or `null` for an overload without a body.
837
+ */
838
+ function fnExpr(statement) {
839
+ if (!statement.body)
840
+ return null;
841
+ return `(${statement
842
+ .getText(statement.getSourceFile())
843
+ .replace(/^export\s+default\s+/, '')
844
+ .replace(/^export\s+/, '')
845
+ .replace(/^declare\s+/, '')})`;
846
+ }
847
+ /**
848
+ * Chooses the expression or type text that replaces a declaration reference.
849
+ *
850
+ * @param c Candidate selected for inlining.
851
+ * @param ref Single consumer location.
852
+ * @param checker TypeScript checker used to preserve resolved declaration details.
853
+ *
854
+ * @return Inline source text, or `null` when the declaration has no replacement.
855
+ */
856
+ function replacementFor(c, ref, checker) {
857
+ const typeReference = referenceTgt(ref.node);
858
+ if ((ts.isTypeAliasDeclaration(c.statement) || ts.isInterfaceDeclaration(c.statement)) && ts.isTypeReferenceNode(typeReference)) {
859
+ if (ts.isTypeAliasDeclaration(c.statement))
860
+ return inlineGenericType(c.statement, typeReference, c.statement.type);
861
+ const interfaceType = ts.factory.createTypeLiteralNode(c.statement.members);
862
+ return inlineGenericType(c.statement, typeReference, interfaceType);
863
+ }
864
+ if (ts.isTypeAliasDeclaration(c.statement))
865
+ return c.statement.typeParameters?.length ? null : `(${c.statement.type.getText(c.source)})`;
866
+ if (ts.isInterfaceDeclaration(c.statement)) {
867
+ if (c.statement.typeParameters?.length)
868
+ return null;
869
+ const mems = `{ ${c.statement.members.map((m) => m.getText(c.source)).join(' ')} }`;
870
+ const bases = c.statement.heritageClauses?.flatMap((v) => v.types.map((t) => t.getText(c.source))) ?? [];
871
+ return bases.length ? `(${[...bases, mems].join(' & ')})` : `(${mems})`;
872
+ }
873
+ if (ts.isFunctionDeclaration(c.statement)) {
874
+ if (ts.isTypeQueryNode(ref.node.parent))
875
+ return fnType(c.statement);
876
+ return fnExpr(c.statement);
877
+ }
878
+ if (!ts.isVariableStatement(c.statement))
879
+ return null;
880
+ const init = c.statement.declarationList.declarations[0]?.initializer;
881
+ if (init && ts.isTypeQueryNode(ref.node.parent)) {
882
+ const inferredType = checker.typeToTypeNode(checker.getTypeAtLocation(init), undefined, ts.NodeBuilderFlags.NoTruncation);
883
+ return inferredType ? ts.createPrinter().printNode(ts.EmitHint.Unspecified, inferredType, ref.source) : null;
884
+ }
885
+ return init ? `(${init.getText(c.source)})` : 'undefined';
886
+ }
887
+ /**
888
+ * Finds a collision-free local name for React's createElement import.
889
+ *
890
+ * @param src Consumer source file.
891
+ *
892
+ * @return An identifier that does not occur in the consumer.
893
+ */
894
+ function reactCreateElementName(src) {
895
+ let name = '__terraQualityCreateElement';
896
+ while (src.text.includes(name))
897
+ name += '_';
898
+ return name;
899
+ }
900
+ /**
901
+ * Places an injected React import after existing imports or the file header.
902
+ *
903
+ * @param src Consumer source file.
904
+ *
905
+ * @return Source offset for the generated import.
906
+ */
907
+ function reactImportPos(src) {
908
+ const imports = src.statements.filter(ts.isImportDeclaration);
909
+ if (imports.length)
910
+ return imports[imports.length - 1].end;
911
+ return src.statements[0]?.getStart(src) ?? 0;
912
+ }
913
+ /** Collects meaningful leading comments that belong to a candidate declaration.
914
+ * @param c Candidate whose leading source comments are being preserved.
915
+ * @returns Comment ranges and text that can move safely with an inline rewrite.
916
+ */
917
+ function attachedComments(c) {
918
+ const start = c.statement.getFullStart();
919
+ const end = c.statement.getStart(c.source);
920
+ const ranges = ts.getLeadingCommentRanges(c.source.text, start) ?? [];
921
+ return ranges
922
+ .filter((r, i) => r.end <= end && S_REGEX.test(c.source.text.slice(r.end, ranges[i + 1]?.pos ?? end)))
923
+ .filter((r) => {
924
+ const val = c.source.text.slice(r.pos, r.end);
925
+ return !(S_REGEX.test(c.source.text.slice(0, r.pos)) && /^\/\*\*\s*(?:\*\s*)?[\w./-]+\.[cm]?[jt]sx?\b/.test(val)) && !/^\/\*\/{10,}/.test(val) && !/^\/\/\s*[A-Z][A-Z _-]*\s*$/.test(val);
926
+ })
927
+ .map((r) => ({ start: r.pos, end: r.end, text: c.source.text.slice(r.pos, r.end) }));
928
+ }
929
+ /** Extracts comments between a variable declaration and its initializer.
930
+ * @param c Candidate variable declaration being rewritten.
931
+ * @returns Comments that would otherwise be lost when the declaration is removed.
932
+ */
933
+ function discardedDeclarationComments(c) {
934
+ if (!ts.isVariableStatement(c.statement))
935
+ return [];
936
+ const declaration = c.statement.declarationList.declarations[0];
937
+ if (!declaration?.initializer)
938
+ return [];
939
+ const start = c.statement.getStart(c.source);
940
+ const prefix = c.source.text.slice(start, declaration.initializer.getStart(c.source));
941
+ const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, c.source.languageVariant, prefix);
942
+ const comments = [];
943
+ for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan()) {
944
+ if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia)
945
+ comments.push(scanner.getTokenText());
946
+ }
947
+ return comments;
948
+ }
949
+ /**
950
+ * Rejects a batch when its edited files gain TypeScript errors.
951
+ *
952
+ * @param program Original TypeScript program used as the baseline.
953
+ * @param updates Candidate source texts keyed by the edited absolute filename.
954
+ *
955
+ * @return Files whose proposed edits add TypeScript diagnostics.
956
+ */
957
+ function filesWithNewTypeErrs(program, updates) {
958
+ const files = [...updates.keys()];
959
+ const total = files.length * 2;
960
+ qualityProgress('TypeScript validation', 0, total);
961
+ const opts = program.getCompilerOptions();
962
+ const host = ts.createCompilerHost(opts);
963
+ const originalGetSrcFile = host.getSourceFile.bind(host);
964
+ const originalDiagnostics = [];
965
+ for (const [fileIndex, file] of files.entries()) {
966
+ const sourceFile = program.getSourceFile(file);
967
+ if (sourceFile)
968
+ originalDiagnostics.push(...ts.getPreEmitDiagnostics(program, sourceFile));
969
+ qualityProgress('TypeScript validation', fileIndex + 1, total);
970
+ }
971
+ const virtualFiles = new Map([...updates].map(([f, text]) => [f, text]));
972
+ host.getSourceFile = (fileName, langVersion, onErr, shouldCreateNewSrcFile) => {
973
+ const text = virtualFiles.get(fileName);
974
+ const scriptKind = fileName.endsWith('.tsx')
975
+ ? ts.ScriptKind.TSX
976
+ : fileName.endsWith('.jsx')
977
+ ? ts.ScriptKind.JSX
978
+ : fileName.endsWith('.js') || fileName.endsWith('.mjs') || fileName.endsWith('.cjs')
979
+ ? ts.ScriptKind.JS
980
+ : ts.ScriptKind.TS;
981
+ return text === undefined ? originalGetSrcFile(fileName, langVersion, onErr, shouldCreateNewSrcFile) : ts.createSourceFile(fileName, text, langVersion, true, scriptKind);
982
+ };
983
+ host.readFile = (fileName) => virtualFiles.get(fileName) ?? ts.sys.readFile(fileName);
984
+ const originalFileExists = host.fileExists.bind(host);
985
+ host.fileExists = (fileName) => virtualFiles.has(fileName) || originalFileExists(fileName);
986
+ const updatedProgram = ts.createProgram({ rootNames: program.getRootFileNames(), options: opts, host });
987
+ const diagnosticKey = (diagnostic) => `${diagnostic.code}:${ts.flattenDiagnosticMessageText(diagnostic.messageText, '\n')}`;
988
+ const prevCounts = new Map();
989
+ for (const diagnostic of originalDiagnostics) {
990
+ if (diagnostic.category !== ts.DiagnosticCategory.Error || !diagnostic.file)
991
+ continue;
992
+ const counts = prevCounts.get(diagnostic.file.fileName) ?? new Map();
993
+ const key = diagnosticKey(diagnostic);
994
+ counts.set(key, (counts.get(key) ?? 0) + 1);
995
+ prevCounts.set(diagnostic.file.fileName, counts);
996
+ }
997
+ const currCounts = new Map();
998
+ for (const [fileIndex, file] of files.entries()) {
999
+ const sourceFile = updatedProgram.getSourceFile(file);
1000
+ if (sourceFile) {
1001
+ for (const diagnostic of ts.getPreEmitDiagnostics(updatedProgram, sourceFile)) {
1002
+ if (diagnostic.category !== ts.DiagnosticCategory.Error || !diagnostic.file)
1003
+ continue;
1004
+ const counts = currCounts.get(diagnostic.file.fileName) ?? new Map();
1005
+ const key = diagnosticKey(diagnostic);
1006
+ counts.set(key, (counts.get(key) ?? 0) + 1);
1007
+ currCounts.set(diagnostic.file.fileName, counts);
1008
+ }
1009
+ }
1010
+ qualityProgress('TypeScript validation', files.length + fileIndex + 1, total);
1011
+ }
1012
+ const introducedErrs = [...currCounts].some(([f, after]) => {
1013
+ const before = prevCounts.get(f) ?? new Map();
1014
+ return [...after].some(([key, count]) => count > (before.get(key) ?? 0));
1015
+ });
1016
+ return introducedErrs ? new Set(updates.keys()) : new Set();
1017
+ }
1018
+ /**
1019
+ * Returns whether a proposed batch adds any TypeScript error in an edited file.
1020
+ *
1021
+ * @param program Original TypeScript program used as the baseline.
1022
+ * @param updates Candidate source texts keyed by the edited absolute filename.
1023
+ *
1024
+ * @return Whether any edited file would gain TypeScript diagnostics.
1025
+ */
1026
+ export function deadCodeEditsIntroduceTypeErrors(program, updates) {
1027
+ return filesWithNewTypeErrs(program, updates).size > 0;
1028
+ }
1029
+ /**
1030
+ * Inlines every single-use declaration and validates each rewritten source file.
1031
+ *
1032
+ * @param cfg Quality configuration defining the source scope and files to rewrite.
1033
+ * @param changedFiles Set updated with files that receive a validated rewrite.
1034
+ *
1035
+ * @return Number of files containing edits, including declaration and consumer files.
1036
+ */
1037
+ function fixSingleUsePass(cfg, changedFiles) {
1038
+ const program = programFor(cfg);
1039
+ const checker = program.getTypeChecker();
1040
+ const { candidates, references: refs } = collect(cfg, program, checker);
1041
+ const astroConsumers = astroConsumersFor(cfg, program);
1042
+ const edits = new Map();
1043
+ const pending = [];
1044
+ for (const c of candidates) {
1045
+ if (hasAstroConsumer(c, astroConsumers) || isCfgdPublicContract(cfg, c, checker) || isPackageEntryExport(cfg, c, checker))
1046
+ continue;
1047
+ const uses = c.kind === 'type' || c.kind === 'interface' ? typeReferences(c, refs) : (refs.get(c.symbol) ?? []).filter((r) => !isImportNode(r.node) && !isExportSpecifierReference(r));
1048
+ if (uses.length !== 1)
1049
+ continue;
1050
+ const ref = uses[0];
1051
+ if (!inSingleUseScope(cfg, ref.file))
1052
+ continue;
1053
+ if (isRequiredLifecycleVal(c, ref))
1054
+ continue;
1055
+ if (c.kind === 'variable' && (ts.isJsxOpeningElement(ref.node.parent) || ts.isJsxSelfClosingElement(ref.node.parent)))
1056
+ continue;
1057
+ if (c.kind === 'function')
1058
+ continue;
1059
+ if (c.kind === 'variable' && c.file !== ref.file && !crossFileVariableSafe(c))
1060
+ continue;
1061
+ if ((c.kind === 'type' || c.kind === 'interface') && !crossFileTypeSafe(c, ref, checker))
1062
+ continue;
1063
+ if ((c.kind === 'type' || c.kind === 'interface') && containsExplicitAny(c))
1064
+ continue;
1065
+ if (c.kind === 'variable' && containsAwait(c))
1066
+ continue;
1067
+ if (c.kind === 'variable' && !initSafeToInline(c))
1068
+ continue;
1069
+ if (c.kind === 'variable' && isStateRestorationReference(ref))
1070
+ continue;
1071
+ if (c.kind === 'variable' && isWriteReference(ref))
1072
+ continue;
1073
+ if (hasImportedTypeAnnotation(c, checker))
1074
+ continue;
1075
+ if (c.kind === 'variable' &&
1076
+ candidates.some((o) => {
1077
+ if (o === c || o.kind !== 'function' || o.file !== c.file || ref.file !== o.file)
1078
+ return false;
1079
+ if (ref.node.getStart(ref.source) < o.statement.getStart(o.source) || ref.node.getEnd() > o.statement.getEnd())
1080
+ return false;
1081
+ const outerUses = (refs.get(o.symbol) ?? []).filter((u) => !isImportNode(u.node) && !isExportSpecifierReference(u));
1082
+ return outerUses.length === 1 && o.file === outerUses[0].file && inSingleUseScope(cfg, outerUses[0].file);
1083
+ }))
1084
+ continue;
1085
+ const replacement = replacementFor(c, ref, checker);
1086
+ if (!replacement)
1087
+ continue;
1088
+ const comments = attachedComments(c);
1089
+ const commentTexts = [...comments.map(({ text: val }) => val).filter((val) => !val.startsWith('/**')), ...discardedDeclarationComments(c)];
1090
+ const inlineReplacement = commentTexts.length ? `${commentTexts.map((v) => (v.startsWith('//') ? `/* ${v.slice(2).trim()} */` : v)).join(' ')} ${replacement}` : replacement;
1091
+ const candidateEdits = [];
1092
+ for (const comment of comments)
1093
+ candidateEdits.push({ file: c.file, start: comment.start, end: comment.end, text: '' });
1094
+ const componentType = ts.isFunctionDeclaration(c.statement) && c.statement.parameters[0]?.type ? `React.ComponentType<${c.statement.parameters[0].type.getText(c.source)}>` : undefined;
1095
+ const jsxName = jsxInlineEdit(ref, inlineReplacement, reactCreateElementName(ref.source), componentType);
1096
+ let jsxFile;
1097
+ if (jsxName) {
1098
+ candidateEdits.push({ file: ref.file, ...jsxName });
1099
+ jsxFile = ref.file;
1100
+ }
1101
+ else if (ts.isShorthandPropertyAssignment(ref.node.parent)) {
1102
+ candidateEdits.push({ file: ref.file, start: ref.node.getStart(ref.source), end: ref.node.getEnd(), text: `${ref.node.text}: ${inlineReplacement}` });
1103
+ }
1104
+ else {
1105
+ const tgt = ts.isTypeQueryNode(ref.node.parent) ? ref.node.parent : referenceTgt(ref.node);
1106
+ candidateEdits.push({ file: ref.file, start: tgt.getStart(ref.source), end: tgt.getEnd(), text: inlineReplacement });
1107
+ }
1108
+ const removedImport = importEdit(ref, checker, c.symbol);
1109
+ if (removedImport)
1110
+ candidateEdits.push({ file: ref.file, ...removedImport, text: '' });
1111
+ candidateEdits.push({ file: c.file, start: c.statement.getStart(c.source), end: c.statement.getEnd(), text: '' });
1112
+ const referenceEdit = candidateEdits.find((e) => e.file === ref.file && e.end > e.start);
1113
+ const nestedReference = candidates.some((o) => o !== c && o.source === ref.source && ref.node.getStart(ref.source) >= o.statement.getStart(ref.source) && ref.node.getEnd() <= o.statement.getEnd());
1114
+ pending.push({ fileEdits: candidateEdits, referenceLength: referenceEdit.end - referenceEdit.start, nestedReference, candidateKey: `${c.file}:${c.name.text}`, jsxFile });
1115
+ }
1116
+ const selected = [];
1117
+ for (const candidate of pending.sort((l, r) => Number(r.nestedReference) - Number(l.nestedReference) || l.referenceLength - r.referenceLength)) {
1118
+ if (selected.some((other) => candidate.fileEdits.some((edit) => other.fileEdits.some((existing) => edit.file === existing.file && edit.start < existing.end && existing.start < edit.end))))
1119
+ continue;
1120
+ selected.push(candidate);
1121
+ }
1122
+ let remaining = selected;
1123
+ while (remaining.length) {
1124
+ edits.clear();
1125
+ const jsxFiles = new Set(remaining.flatMap((c) => (c.jsxFile ? [c.jsxFile] : [])));
1126
+ for (const candidate of remaining)
1127
+ for (const edit of candidate.fileEdits)
1128
+ edits.set(edit.file, [...(edits.get(edit.file) ?? []), edit]);
1129
+ for (const file of jsxFiles) {
1130
+ const source = program.getSourceFile(file);
1131
+ const pos = reactImportPos(source);
1132
+ edits.set(file, [...(edits.get(file) ?? []), { start: pos, end: pos, text: `${pos ? '\n' : ''}import { createElement as ${reactCreateElementName(source)} } from 'react';\n` }]);
1133
+ }
1134
+ const invalidFiles = new Set();
1135
+ for (const [file, fileEdits] of edits) {
1136
+ const source = program.getSourceFile(file);
1137
+ const ascending = [...fileEdits].sort((a, b) => a.start - b.start || a.end - b.end);
1138
+ if (!source || ascending.some((edit, i) => i > 0 && edit.end > edit.start && ascending[i - 1].end > ascending[i - 1].start && edit.start < ascending[i - 1].end)) {
1139
+ invalidFiles.add(file);
1140
+ continue;
1141
+ }
1142
+ const original = readFileSync(file, 'utf8');
1143
+ const updated = ascending.reverse().reduce((text, edit) => text.slice(0, edit.start) + edit.text + text.slice(edit.end), original);
1144
+ const parsed = ts.createSourceFile(file, updated, ts.ScriptTarget.Latest, true, file.endsWith('.tsx') || file.endsWith('.jsx') ? ts.ScriptKind.TSX : file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
1145
+ if (parsed.parseDiagnostics.length)
1146
+ invalidFiles.add(file);
1147
+ }
1148
+ if (!invalidFiles.size)
1149
+ break;
1150
+ const rejected = remaining.filter((c) => c.fileEdits.some((edit) => invalidFiles.has(edit.file)) || (c.jsxFile && invalidFiles.has(c.jsxFile)));
1151
+ for (const candidate of rejected)
1152
+ rejectedInlineCandidates.set(candidate.candidateKey, `the combined inline in ${[...invalidFiles].map((f) => relative(cfg.root, f)).join(', ')} would produce invalid syntax; resolve this candidate manually`);
1153
+ remaining = remaining.filter((c) => !rejected.includes(c));
1154
+ }
1155
+ const updates = [];
1156
+ for (const [file, fileEdits] of edits) {
1157
+ const src = readFileSync(file, 'utf8');
1158
+ const ascending = [...fileEdits].sort((a, b) => a.start - b.start || a.end - b.end);
1159
+ if (ascending.some((e, i) => e.end > e.start && i > 0 && ascending[i - 1].end > ascending[i - 1].start && e.start < ascending[i - 1].end))
1160
+ continue;
1161
+ const updated = ascending.reverse().reduce((t, e) => t.slice(0, e.start) + e.text + t.slice(e.end), src);
1162
+ if (updated === src)
1163
+ continue;
1164
+ const parsed = ts.createSourceFile(file, updated, ts.ScriptTarget.Latest, true, file.endsWith('.tsx') || file.endsWith('.jsx') ? ts.ScriptKind.TSX : file.endsWith('.ts') ? ts.ScriptKind.TS : ts.ScriptKind.JS);
1165
+ const parseDiagnostics = parsed.parseDiagnostics;
1166
+ if (parseDiagnostics.length)
1167
+ continue;
1168
+ updates.push({ file, text: updated });
1169
+ }
1170
+ for (const { file, text } of updates) {
1171
+ writeFileSync(file, text);
1172
+ changedFiles.add(file);
1173
+ }
1174
+ return updates.length;
1175
+ }
1176
+ /**
1177
+ * Repeats single-use inlining until nested edits have been applied in separate valid passes.
1178
+ *
1179
+ * @param cfg Quality configuration defining the source scope and files to rewrite.
1180
+ * @param changedFileNames Optional caller-owned set updated with files changed by this run.
1181
+ * @param onPass Optional callback invoked after each pass with its index and changed-file count.
1182
+ *
1183
+ * @return Number of files changed across all passes.
1184
+ */
1185
+ export function fixSingleUseDeclarations(cfg, changedFileNames, onPass) {
1186
+ rejectedInlineCandidates.clear();
1187
+ const changedFiles = new Set();
1188
+ for (let pass = 0; pass < 1000; pass++) {
1189
+ onPass?.(pass + 1, changedFiles.size);
1190
+ const changed = fixSingleUsePass(cfg, changedFiles);
1191
+ if (changed === 0) {
1192
+ for (const file of changedFiles)
1193
+ changedFileNames?.add(file);
1194
+ return changedFiles.size;
1195
+ }
1196
+ }
1197
+ throw new Error('Single-use inlining exceeded 1000 passes; check for a rewrite that does not converge.');
1198
+ }
1199
+ /**
1200
+ * Backwards-compatible name for callers that previously fixed only type declarations.
1201
+ *
1202
+ * @param cfg Quality configuration defining the source scope and files to rewrite.
1203
+ *
1204
+ * @return Number of files containing edits.
1205
+ */
1206
+ export function fixSingleUseTypes(cfg) {
1207
+ return fixSingleUseDeclarations(cfg);
1208
+ }
1209
+ //# sourceMappingURL=single-use.js.map