@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.
- package/README.md +89 -2
- package/dist/collapse.d.ts +13 -0
- package/dist/collapse.d.ts.map +1 -0
- package/dist/collapse.js +151 -0
- package/dist/collapse.js.map +1 -0
- package/dist/documentation.d.ts +84 -0
- package/dist/documentation.d.ts.map +1 -0
- package/dist/documentation.js +1244 -0
- package/dist/documentation.js.map +1 -0
- package/dist/files.d.ts +39 -0
- package/dist/files.d.ts.map +1 -0
- package/dist/files.js +90 -0
- package/dist/files.js.map +1 -0
- package/dist/github-report.d.ts +89 -0
- package/dist/github-report.d.ts.map +1 -0
- package/dist/github-report.js +469 -0
- package/dist/github-report.js.map +1 -0
- package/dist/history-report.d.ts +52 -0
- package/dist/history-report.d.ts.map +1 -0
- package/dist/history-report.js +317 -0
- package/dist/history-report.js.map +1 -0
- package/dist/imports.d.ts +19 -0
- package/dist/imports.d.ts.map +1 -0
- package/dist/imports.js +90 -0
- package/dist/imports.js.map +1 -0
- package/dist/index.d.ts +19 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +19 -0
- package/dist/index.js.map +1 -0
- package/dist/inline-if.d.ts +13 -0
- package/dist/inline-if.d.ts.map +1 -0
- package/dist/inline-if.js +48 -0
- package/dist/inline-if.js.map +1 -0
- package/dist/naming.d.ts +148 -0
- package/dist/naming.d.ts.map +1 -0
- package/dist/naming.js +642 -0
- package/dist/naming.js.map +1 -0
- package/dist/object-forwarding.d.ts +44 -0
- package/dist/object-forwarding.d.ts.map +1 -0
- package/dist/object-forwarding.js +182 -0
- package/dist/object-forwarding.js.map +1 -0
- package/dist/patterns.d.ts +85 -0
- package/dist/patterns.d.ts.map +1 -0
- package/dist/patterns.js +905 -0
- package/dist/patterns.js.map +1 -0
- package/dist/port.d.ts +21 -0
- package/dist/port.d.ts.map +1 -0
- package/dist/port.js +81 -0
- package/dist/port.js.map +1 -0
- package/dist/progress.d.ts +16 -0
- package/dist/progress.d.ts.map +1 -0
- package/dist/progress.js +30 -0
- package/dist/progress.js.map +1 -0
- package/dist/runner.d.ts +257 -0
- package/dist/runner.d.ts.map +1 -0
- package/dist/runner.js +2481 -0
- package/dist/runner.js.map +1 -0
- package/dist/security.d.ts +19 -0
- package/dist/security.d.ts.map +1 -0
- package/dist/security.js +76 -0
- package/dist/security.js.map +1 -0
- package/dist/single-use.d.ts +60 -0
- package/dist/single-use.d.ts.map +1 -0
- package/dist/single-use.js +1209 -0
- package/dist/single-use.js.map +1 -0
- package/package.json +45 -5
- package/quality.schema.json +130 -0
|
@@ -0,0 +1,1244 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* packages/quality/src/documentation.ts
|
|
3
|
+
* Create comments documenting the file, grouping imports, and sectioning code.
|
|
4
|
+
*/
|
|
5
|
+
/*//////////////////////////////////////////////////
|
|
6
|
+
IMPORTS
|
|
7
|
+
//////////////////////////////////////////////////*/
|
|
8
|
+
// API
|
|
9
|
+
import ts from 'typescript';
|
|
10
|
+
import { displayPath, srcFiles } from './files.js';
|
|
11
|
+
import { extname, relative, sep } from 'node:path';
|
|
12
|
+
import { readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
13
|
+
/*//////////////////////////////////////////////////
|
|
14
|
+
REGEX
|
|
15
|
+
//////////////////////////////////////////////////*/
|
|
16
|
+
const TYPE_S_REGEX = /^type\s+/;
|
|
17
|
+
/*//////////////////////////////////////////////////
|
|
18
|
+
REGEX
|
|
19
|
+
//////////////////////////////////////////////////*/
|
|
20
|
+
const R_N_REGEX = /\r?\n/;
|
|
21
|
+
const S_S_G_REGEX = () => /\/\*\*[\s\S]*?\*\//g;
|
|
22
|
+
/*//////////////////////////////////////////////////
|
|
23
|
+
REGEX
|
|
24
|
+
//////////////////////////////////////////////////*/
|
|
25
|
+
const T_R_N_S_S_R_N_T_T_R_N_REGEX = /^\/\*\*[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\*\/[ \t]*(?:\r?\n)*/;
|
|
26
|
+
const T_T_REGEX = /^[ \t]*\*[ \t]?/;
|
|
27
|
+
const S_REGEX = /^\s*/;
|
|
28
|
+
const S_CLASS_2 = /^\s*(?:\/\/|\/\*|\*\/|\*)/;
|
|
29
|
+
const S_CLASS_3 = /^\s*\* ?/;
|
|
30
|
+
const S_W_REGEX = /^\s*@\w+/;
|
|
31
|
+
const RETURNS_B_REGEX = /^@returns?\b/;
|
|
32
|
+
const UFEFF_REGEX = /^\uFEFF/;
|
|
33
|
+
const S_CLASS_4 = /\s*$/;
|
|
34
|
+
const C_M_JS_JSX_TS_TSX_REGEX = /(?:^|[\\/])[^\\/]+\.(?:c|m)?(?:js|jsx|ts|tsx)$/;
|
|
35
|
+
const S_CLASS_5 = /^\{[^}]+\}\s*/;
|
|
36
|
+
const OPEN = `/*${'/'.repeat(50)}`;
|
|
37
|
+
const CLOSE = `${'/'.repeat(50)}*/`;
|
|
38
|
+
const SUPPORTED = new Set(['.cjs', '.cts', '.js', '.jsx', '.mjs', '.mts', '.ts', '.tsx']);
|
|
39
|
+
export const DOCUMENTATION_DESCRIPTION_NOTICE = 'Ask an agent for meaningful documentation prose; this check only reports missing descriptions and never generates them in bulk.';
|
|
40
|
+
const DESCRIPTION_OPTIONAL_TAGS = new Set(['abstract', 'const', 'default', 'event', 'hideconstructor', 'internal', 'package', 'private', 'protected', 'public', 'readonly', 'sealed', 'virtual']);
|
|
41
|
+
/*//////////////////////////////////////////////////
|
|
42
|
+
HELPERS
|
|
43
|
+
//////////////////////////////////////////////////*/
|
|
44
|
+
/**
|
|
45
|
+
* Writes a generated source update through a temporary file after validating
|
|
46
|
+
* that the candidate still has no TypeScript or JavaScript parse errors.
|
|
47
|
+
*
|
|
48
|
+
* @param fileName Source file to replace.
|
|
49
|
+
* @param src Candidate source text to validate and write.
|
|
50
|
+
*
|
|
51
|
+
* @return Nothing; throws before replacing the source when parsing fails.
|
|
52
|
+
*/
|
|
53
|
+
function writeValidatedSrc(fileName, src) {
|
|
54
|
+
try {
|
|
55
|
+
if (/\.d\.[cm]?ts$/.test(fileName)) {
|
|
56
|
+
const parsed = ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
57
|
+
if (parsed.parseDiagnostics.length)
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
else {
|
|
61
|
+
const result = ts.transpileModule(src, {
|
|
62
|
+
fileName,
|
|
63
|
+
reportDiagnostics: true,
|
|
64
|
+
compilerOptions: { allowJs: true, jsx: ts.JsxEmit.Preserve, module: ts.ModuleKind.ESNext, target: ts.ScriptTarget.ESNext },
|
|
65
|
+
});
|
|
66
|
+
if (result.diagnostics?.length)
|
|
67
|
+
return false;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
catch {
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
const temp = `${fileName}.${process.pid}.quality.tmp`;
|
|
74
|
+
try {
|
|
75
|
+
writeFileSync(temp, src);
|
|
76
|
+
renameSync(temp, fileName);
|
|
77
|
+
}
|
|
78
|
+
finally {
|
|
79
|
+
try {
|
|
80
|
+
unlinkSync(temp);
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// The temporary file was renamed successfully.
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
return true;
|
|
87
|
+
}
|
|
88
|
+
/**
|
|
89
|
+
* Repairs broken duplicate header fragments left by interrupted comment edits.
|
|
90
|
+
* Only contiguous comments immediately following a valid path header are
|
|
91
|
+
* merged, so declaration documentation elsewhere in the file is untouched.
|
|
92
|
+
*
|
|
93
|
+
* @param src Source text whose leading header may contain broken fragments.
|
|
94
|
+
* @param path Canonical repository-relative path for the file header.
|
|
95
|
+
* @param eol Line-ending sequence to emit.
|
|
96
|
+
*
|
|
97
|
+
* @return Source with one valid path-and-description header when healing applies.
|
|
98
|
+
*/
|
|
99
|
+
function healHeaderFragments(src, path, eol) {
|
|
100
|
+
const header = src.match(T_R_N_S_S_R_N_T_T_R_N_REGEX);
|
|
101
|
+
if (!header)
|
|
102
|
+
return src;
|
|
103
|
+
const lines = header[1]
|
|
104
|
+
.split(R_N_REGEX)
|
|
105
|
+
.map((l) => l.replace(T_T_REGEX, '').trim())
|
|
106
|
+
.filter(Boolean);
|
|
107
|
+
if (lines[0] !== path && lines[0] !== path.replace(/^.*\//, ''))
|
|
108
|
+
return src;
|
|
109
|
+
const descriptions = lines.slice(1);
|
|
110
|
+
let cursor = header[0].length;
|
|
111
|
+
let rest = src.slice(cursor);
|
|
112
|
+
const orphanPattern = /^(?:[ \t]*\*[ \t]?[^\r\n]*\r?\n)+[ \t]*\*\/[ \t]*(?:\r?\n)*/;
|
|
113
|
+
const docPattern = /^\/\*\*(?:[ \t]*([^\r\n]*?)[ \t]*\*\/[ \t]*(?:\r?\n)*|[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*\*\/[ \t]*(?:\r?\n)*)/;
|
|
114
|
+
let changed = false;
|
|
115
|
+
while (true) {
|
|
116
|
+
const whitespace = rest.match(/^(?:\s*)/);
|
|
117
|
+
const prefix = whitespace?.[0] ?? '';
|
|
118
|
+
const orphan = rest.slice(prefix.length).match(orphanPattern);
|
|
119
|
+
if (orphan) {
|
|
120
|
+
const orphanLines = orphan[0]
|
|
121
|
+
.split(R_N_REGEX)
|
|
122
|
+
.map((l) => l
|
|
123
|
+
.replace(T_T_REGEX, '')
|
|
124
|
+
.replace(/[ \t]*\*\/?[ \t]*$/, '')
|
|
125
|
+
.trim())
|
|
126
|
+
.map((l) => (l === '/' ? '' : l))
|
|
127
|
+
.filter(Boolean);
|
|
128
|
+
for (const line of orphanLines)
|
|
129
|
+
if (line !== path && !descriptions.includes(line))
|
|
130
|
+
descriptions.push(line);
|
|
131
|
+
cursor += prefix.length + orphan[0].length;
|
|
132
|
+
rest = rest.slice(prefix.length + orphan[0].length);
|
|
133
|
+
changed = true;
|
|
134
|
+
continue;
|
|
135
|
+
}
|
|
136
|
+
const doc = rest.slice(prefix.length).match(docPattern);
|
|
137
|
+
if (!doc)
|
|
138
|
+
break;
|
|
139
|
+
const docLines = (doc[1] ?? doc[2] ?? '')
|
|
140
|
+
.split(R_N_REGEX)
|
|
141
|
+
.map((l) => l.replace(T_T_REGEX, '').trim())
|
|
142
|
+
.filter(Boolean);
|
|
143
|
+
for (const line of docLines)
|
|
144
|
+
if (line !== path && !descriptions.includes(line))
|
|
145
|
+
descriptions.push(line);
|
|
146
|
+
cursor += prefix.length + doc[0].length;
|
|
147
|
+
rest = rest.slice(prefix.length + doc[0].length);
|
|
148
|
+
changed = true;
|
|
149
|
+
}
|
|
150
|
+
if (!changed)
|
|
151
|
+
return src;
|
|
152
|
+
const norm = ['/**', path, ...descriptions, ' */'].map((l, i, a) => (i === 0 || i === a.length - 1 ? l : ` * ${l}`)).join(eol);
|
|
153
|
+
return `${norm}${eol}${eol}${rest.replace(S_REGEX, '')}`;
|
|
154
|
+
}
|
|
155
|
+
/**
|
|
156
|
+
* Selects the TypeScript parser mode for a JavaScript or TypeScript filename.
|
|
157
|
+
*
|
|
158
|
+
* @param fileName Source filename whose extension determines the parser mode.
|
|
159
|
+
*
|
|
160
|
+
* @return TypeScript compiler script kind for the filename.
|
|
161
|
+
*/
|
|
162
|
+
function srcKind(fileName) {
|
|
163
|
+
return /\.[cm]?tsx?$/.test(fileName) && fileName.endsWith('x') ? ts.ScriptKind.TSX : ts.ScriptKind.TS;
|
|
164
|
+
}
|
|
165
|
+
/**
|
|
166
|
+
* Reads the module specifier text from an import declaration.
|
|
167
|
+
*
|
|
168
|
+
* @param n Import declaration to inspect.
|
|
169
|
+
*
|
|
170
|
+
* @return Module specifier text, or an empty string for a non-string specifier.
|
|
171
|
+
*/
|
|
172
|
+
function importSrc(n) {
|
|
173
|
+
return ts.isStringLiteral(n.moduleSpecifier) ? n.moduleSpecifier.text : '';
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Returns whether an import would collide with a declaration in the file.
|
|
177
|
+
* This also handles self-imports and duplicate local component declarations.
|
|
178
|
+
*
|
|
179
|
+
* @param parsed Parsed source file whose top-level declarations define local names.
|
|
180
|
+
* @param n Import declaration to test.
|
|
181
|
+
*
|
|
182
|
+
* @return `true` when the import would collide with a local declaration.
|
|
183
|
+
*/
|
|
184
|
+
function selfImportConflicts(parsed, n) {
|
|
185
|
+
const localNames = new Set();
|
|
186
|
+
for (const s of parsed.statements) {
|
|
187
|
+
if (ts.isFunctionDeclaration(s) || ts.isClassDeclaration(s) || ts.isInterfaceDeclaration(s) || ts.isTypeAliasDeclaration(s) || ts.isEnumDeclaration(s))
|
|
188
|
+
if (s.name)
|
|
189
|
+
localNames.add(s.name.text);
|
|
190
|
+
if (ts.isVariableStatement(s))
|
|
191
|
+
for (const dec of s.declarationList.declarations)
|
|
192
|
+
if (ts.isIdentifier(dec.name))
|
|
193
|
+
localNames.add(dec.name.text);
|
|
194
|
+
}
|
|
195
|
+
if (!n.importClause)
|
|
196
|
+
return false;
|
|
197
|
+
if (n.importClause.name && localNames.has(n.importClause.name.text))
|
|
198
|
+
return true;
|
|
199
|
+
if (n.importClause.namedBindings && ts.isNamespaceImport(n.importClause.namedBindings) && localNames.has(n.importClause.namedBindings.name.text))
|
|
200
|
+
return true;
|
|
201
|
+
return Boolean(n.importClause.namedBindings && ts.isNamedImports(n.importClause.namedBindings) && n.importClause.namedBindings.elements.some((e) => localNames.has(e.name.text)));
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Assigns an import to the canonical group used by the source-maintenance layout.
|
|
205
|
+
*
|
|
206
|
+
* @param name Local import name whose casing indicates its category.
|
|
207
|
+
*
|
|
208
|
+
* @return Import group for API, component, or uppercase data names.
|
|
209
|
+
*/
|
|
210
|
+
function importGroup(name) {
|
|
211
|
+
if (/^[A-Z][A-Z0-9_]*$/.test(name))
|
|
212
|
+
return 'DATA';
|
|
213
|
+
if (/^[A-Z]/.test(name))
|
|
214
|
+
return 'COMPONENTS';
|
|
215
|
+
return 'API';
|
|
216
|
+
}
|
|
217
|
+
/**
|
|
218
|
+
* Finds repeated named bindings that the source maintainer can merge without
|
|
219
|
+
* changing import evaluation order or side-effect-only imports.
|
|
220
|
+
*
|
|
221
|
+
* @param files Configured source files to inspect.
|
|
222
|
+
*
|
|
223
|
+
* @return One finding for each later import declaration that repeats a binding.
|
|
224
|
+
*/
|
|
225
|
+
export function duplicateImportFindings(files) {
|
|
226
|
+
const findings = [];
|
|
227
|
+
for (const file of files) {
|
|
228
|
+
if (!SUPPORTED.has(extname(file)))
|
|
229
|
+
continue;
|
|
230
|
+
const text = readFileSync(file, 'utf8');
|
|
231
|
+
const parsed = ts.createSourceFile(file, text, ts.ScriptTarget.Latest, true, srcKind(file));
|
|
232
|
+
const seen = new Set();
|
|
233
|
+
for (const declaration of parsed.statements.filter(ts.isImportDeclaration)) {
|
|
234
|
+
if (!declaration.importClause || !ts.isStringLiteral(declaration.moduleSpecifier))
|
|
235
|
+
continue;
|
|
236
|
+
const keys = [];
|
|
237
|
+
if (declaration.importClause.name)
|
|
238
|
+
keys.push(`${declaration.moduleSpecifier.text}|${importGroup(declaration.importClause.name.text)}|${declaration.importClause.isTypeOnly ? 'type' : 'value'}|default:${declaration.importClause.name.text}`);
|
|
239
|
+
if (declaration.importClause.namedBindings && ts.isNamespaceImport(declaration.importClause.namedBindings))
|
|
240
|
+
keys.push(`${declaration.moduleSpecifier.text}|${importGroup(declaration.importClause.namedBindings.name.text)}|${declaration.importClause.isTypeOnly ? 'type' : 'value'}|namespace:${declaration.importClause.namedBindings.name.text}`);
|
|
241
|
+
if (declaration.importClause.namedBindings && ts.isNamedImports(declaration.importClause.namedBindings)) {
|
|
242
|
+
for (const element of declaration.importClause.namedBindings.elements) {
|
|
243
|
+
const typeOnly = declaration.importClause.isTypeOnly || element.isTypeOnly;
|
|
244
|
+
const group = typeOnly ? 'TYPES' : importGroup(element.name.text);
|
|
245
|
+
const binding = element.getText(parsed).replace(TYPE_S_REGEX, '');
|
|
246
|
+
keys.push(`${declaration.moduleSpecifier.text}|${group}|${typeOnly ? 'type' : 'value'}|${binding}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
if (keys.some((key) => seen.has(key))) {
|
|
250
|
+
const { line } = parsed.getLineAndCharacterOfPosition(declaration.getStart(parsed));
|
|
251
|
+
findings.push({ file, line: line + 1, module: declaration.moduleSpecifier.text });
|
|
252
|
+
}
|
|
253
|
+
for (const key of keys)
|
|
254
|
+
seen.add(key);
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
return findings;
|
|
258
|
+
}
|
|
259
|
+
/**
|
|
260
|
+
* Merges and renders imports into deterministic groups while preserving type-only imports.
|
|
261
|
+
*
|
|
262
|
+
* @param parsed Parsed source file used to recover exact module-specifier text.
|
|
263
|
+
* @param imports Import declarations selected for maintenance.
|
|
264
|
+
*
|
|
265
|
+
* @return Renderable import entries grouped by their canonical section.
|
|
266
|
+
*/
|
|
267
|
+
function groupedImports(parsed, imports) {
|
|
268
|
+
const groups = { API: [], COMPONENTS: [], DATA: [], TYPES: [] };
|
|
269
|
+
const merged = new Map();
|
|
270
|
+
const get = (group, n, typeOnly) => {
|
|
271
|
+
const src = importSrc(n);
|
|
272
|
+
const key = `${group}|${typeOnly ? 'type|' : ''}${src}`;
|
|
273
|
+
const existing = merged.get(key);
|
|
274
|
+
if (existing)
|
|
275
|
+
return existing;
|
|
276
|
+
const created = { group, source: src, module: n.moduleSpecifier.getText(parsed), typeOnly, named: new Set() };
|
|
277
|
+
merged.set(key, created);
|
|
278
|
+
return created;
|
|
279
|
+
};
|
|
280
|
+
for (const n of imports) {
|
|
281
|
+
if (!n.importClause) {
|
|
282
|
+
const entry = get('API', n, false);
|
|
283
|
+
entry.named.add('');
|
|
284
|
+
continue;
|
|
285
|
+
}
|
|
286
|
+
if (n.importClause.name)
|
|
287
|
+
get(n.importClause.isTypeOnly ? 'TYPES' : importGroup(n.importClause.name.text), n, n.importClause.isTypeOnly).defaultName = n.importClause.name.text;
|
|
288
|
+
if (n.importClause.namedBindings && ts.isNamespaceImport(n.importClause.namedBindings))
|
|
289
|
+
get(n.importClause.isTypeOnly ? 'TYPES' : importGroup(n.importClause.namedBindings.name.text), n, n.importClause.isTypeOnly).namespace = n.importClause.namedBindings.name.text;
|
|
290
|
+
if (n.importClause.namedBindings && ts.isNamedImports(n.importClause.namedBindings)) {
|
|
291
|
+
for (const el of n.importClause.namedBindings.elements) {
|
|
292
|
+
const elTypeOnly = n.importClause.isTypeOnly || el.isTypeOnly;
|
|
293
|
+
const group = elTypeOnly ? 'TYPES' : importGroup(el.name.text);
|
|
294
|
+
get(group, n, elTypeOnly).named.add(el.getText(parsed).replace(TYPE_S_REGEX, ''));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
for (const v of merged.values()) {
|
|
299
|
+
const prefix = v.typeOnly ? 'import type ' : 'import ';
|
|
300
|
+
if (v.named.has('')) {
|
|
301
|
+
groups[v.group].push({ source: v.source, text: `import ${v.module};` });
|
|
302
|
+
continue;
|
|
303
|
+
}
|
|
304
|
+
if (v.namespace) {
|
|
305
|
+
const namespaceParts = [v.defaultName, `* as ${v.namespace}`].filter(Boolean);
|
|
306
|
+
groups[v.group].push({ source: v.source, text: `${prefix}${namespaceParts.join(', ')} from ${v.module};` });
|
|
307
|
+
if (!v.named.size)
|
|
308
|
+
continue;
|
|
309
|
+
}
|
|
310
|
+
const named = v.named.size ? `{ ${[...v.named].sort().join(', ')} }` : undefined;
|
|
311
|
+
const clause = v.namespace ? named : [v.defaultName, named].filter(Boolean).join(', ');
|
|
312
|
+
if (v.typeOnly && v.defaultName && named) {
|
|
313
|
+
groups[v.group].push({ source: v.source, text: `${prefix}${v.defaultName} from ${v.module};` });
|
|
314
|
+
groups[v.group].push({ source: v.source, text: `${prefix}${named} from ${v.module};` });
|
|
315
|
+
}
|
|
316
|
+
else if (clause)
|
|
317
|
+
groups[v.group].push({ source: v.source, text: `${prefix}${clause} from ${v.module};` });
|
|
318
|
+
}
|
|
319
|
+
return groups;
|
|
320
|
+
}
|
|
321
|
+
/**
|
|
322
|
+
* Classifies a top-level statement for source-section organization.
|
|
323
|
+
*
|
|
324
|
+
* @param n Top-level statement to classify.
|
|
325
|
+
*
|
|
326
|
+
* @return `CONFIG` for declarations/constants, `HELPERS` for functions, or `undefined` otherwise.
|
|
327
|
+
*/
|
|
328
|
+
function srcGroup(n) {
|
|
329
|
+
if (ts.isTypeAliasDeclaration(n) || ts.isInterfaceDeclaration(n) || ts.isEnumDeclaration(n) || (ts.isVariableStatement(n) && (n.declarationList.flags & ts.NodeFlags.Const) !== 0))
|
|
330
|
+
return 'CONFIG';
|
|
331
|
+
if (ts.isFunctionDeclaration(n))
|
|
332
|
+
return 'HELPERS';
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Renders one managed source section and its statements using the requested line ending.
|
|
337
|
+
*
|
|
338
|
+
* @param label Section label to emit.
|
|
339
|
+
* @param statements Statements belonging to the section.
|
|
340
|
+
* @param eol Line-ending sequence used by the source file.
|
|
341
|
+
*
|
|
342
|
+
* @return Formatted section text.
|
|
343
|
+
*/
|
|
344
|
+
function srcSection(label, statements, eol) {
|
|
345
|
+
return `${OPEN}${eol}${label}${eol}${CLOSE}${eol}${statements.join(label === 'CONFIG' ? eol : `${eol}${eol}`)}`;
|
|
346
|
+
}
|
|
347
|
+
/**
|
|
348
|
+
* Returns source spans whose whitespace is content rather than layout.
|
|
349
|
+
*
|
|
350
|
+
* @param src Source text to parse for literals.
|
|
351
|
+
* @param fileName Filename used to choose the parser mode.
|
|
352
|
+
*
|
|
353
|
+
* @return Sorted source spans occupied by literals or JSX text.
|
|
354
|
+
*/
|
|
355
|
+
function literalSpans(src, fileName) {
|
|
356
|
+
const parsed = ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
357
|
+
const spans = [];
|
|
358
|
+
const visit = (n) => {
|
|
359
|
+
if (ts.isStringLiteral(n) || ts.isRegularExpressionLiteral(n) || ts.isNoSubstitutionTemplateLiteral(n) || ts.isTemplateExpression(n) || ts.isJsxText(n)) {
|
|
360
|
+
spans.push({ start: n.getStart(parsed), end: n.end });
|
|
361
|
+
return;
|
|
362
|
+
}
|
|
363
|
+
ts.forEachChild(n, visit);
|
|
364
|
+
};
|
|
365
|
+
visit(parsed);
|
|
366
|
+
return spans.sort((a, b) => a.start - b.start);
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Normalizes structural comment spacing without changing literal content.
|
|
370
|
+
*
|
|
371
|
+
* @param src Source text whose structural whitespace should be normalized.
|
|
372
|
+
* @param fileName Filename used to protect literal content during parsing.
|
|
373
|
+
* @param eol Line-ending sequence to emit.
|
|
374
|
+
*
|
|
375
|
+
* @return Source text with managed comment spacing normalized.
|
|
376
|
+
*/
|
|
377
|
+
function normCommentLayout(src, fileName, eol) {
|
|
378
|
+
const spans = literalSpans(src, fileName);
|
|
379
|
+
const rawLines = src.split(R_N_REGEX);
|
|
380
|
+
const nlineSize = src.includes('\r\n') ? 2 : 1;
|
|
381
|
+
let offset = 0;
|
|
382
|
+
const lines = rawLines.map((t) => {
|
|
383
|
+
const line = { text: t, start: offset };
|
|
384
|
+
offset += t.length + nlineSize;
|
|
385
|
+
return line;
|
|
386
|
+
});
|
|
387
|
+
const protectedLine = (line) => {
|
|
388
|
+
let low = 0;
|
|
389
|
+
let high = spans.length - 1;
|
|
390
|
+
while (low <= high) {
|
|
391
|
+
const mid = (low + high) >> 1;
|
|
392
|
+
const span = spans[mid];
|
|
393
|
+
if (line.start < span.start)
|
|
394
|
+
high = mid - 1;
|
|
395
|
+
else if (line.start >= span.end)
|
|
396
|
+
low = mid + 1;
|
|
397
|
+
else
|
|
398
|
+
return true;
|
|
399
|
+
}
|
|
400
|
+
return false;
|
|
401
|
+
};
|
|
402
|
+
const sectionOpen = (i) => !protectedLine(lines[i]) && /^\s*\/\*\/{10,}\s*$/.test(lines[i].text);
|
|
403
|
+
const sectionClose = (i) => !protectedLine(lines[i]) && /^\s*\/{10,}\*\/\s*$/.test(lines[i].text);
|
|
404
|
+
const lineGroup = (i) => !protectedLine(lines[i]) && /^\s*\/\/ [A-Z][A-Z0-9 _-]*\s*$/.test(lines[i].text);
|
|
405
|
+
// Remove an empty section immediately followed by another section.
|
|
406
|
+
for (let i = 0; i + 5 < lines.length; i++) {
|
|
407
|
+
if (!sectionOpen(i) || !sectionClose(i + 2))
|
|
408
|
+
continue;
|
|
409
|
+
let n = i + 3;
|
|
410
|
+
while (n < lines.length && !lines[n].text.trim())
|
|
411
|
+
n++;
|
|
412
|
+
if (n < lines.length && sectionOpen(n)) {
|
|
413
|
+
lines.splice(i, n - i);
|
|
414
|
+
i = Math.max(-1, i - 2);
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
const out = [];
|
|
418
|
+
const pushBlank = () => {
|
|
419
|
+
if (out.length && out.at(-1) !== '')
|
|
420
|
+
out.push('');
|
|
421
|
+
};
|
|
422
|
+
for (let i = 0; i < lines.length; i++) {
|
|
423
|
+
const text = lines[i].text.replace(/[ \t]+$/, '');
|
|
424
|
+
const closesSect = sectionClose(i);
|
|
425
|
+
if (!text && !protectedLine(lines[i])) {
|
|
426
|
+
const prev = out.at(-1) ?? '';
|
|
427
|
+
let next = i + 1;
|
|
428
|
+
while (next < lines.length && !lines[next].text.trim() && !protectedLine(lines[next]))
|
|
429
|
+
next++;
|
|
430
|
+
const adjacentComment = S_CLASS_2.test(prev) || (next < lines.length && S_CLASS_2.test(lines[next].text));
|
|
431
|
+
if (adjacentComment && out.length && out.at(-1) !== '')
|
|
432
|
+
out.push('');
|
|
433
|
+
continue;
|
|
434
|
+
}
|
|
435
|
+
if (sectionOpen(i))
|
|
436
|
+
pushBlank();
|
|
437
|
+
if (lineGroup(i))
|
|
438
|
+
pushBlank();
|
|
439
|
+
out.push(text);
|
|
440
|
+
if (closesSect || lineGroup(i)) {
|
|
441
|
+
while (i + 1 < lines.length && !lines[i + 1].text.trim() && !protectedLine(lines[i + 1]))
|
|
442
|
+
i++;
|
|
443
|
+
if (closesSect && i + 1 < lines.length)
|
|
444
|
+
pushBlank();
|
|
445
|
+
}
|
|
446
|
+
}
|
|
447
|
+
while (out.at(-1) === '')
|
|
448
|
+
out.pop();
|
|
449
|
+
// Keep blank lines only when they explicitly separate comments/groups.
|
|
450
|
+
// This also removes stale whitespace inside function bodies while preserving
|
|
451
|
+
// spacing around TSDoc, line groups, and section fences.
|
|
452
|
+
const compact = out.filter((l, i) => {
|
|
453
|
+
if (l !== '')
|
|
454
|
+
return true;
|
|
455
|
+
const prev = out[i - 1] ?? '';
|
|
456
|
+
const following = out[i + 1] ?? '';
|
|
457
|
+
const comment = (textVal) => S_CLASS_2.test(textVal);
|
|
458
|
+
return comment(prev) || comment(following);
|
|
459
|
+
});
|
|
460
|
+
return `${compact.join(eol)}${eol}`;
|
|
461
|
+
}
|
|
462
|
+
/**
|
|
463
|
+
* Removes managed CONFIG and HELPERS fences without touching matching text inside literals.
|
|
464
|
+
*
|
|
465
|
+
* @param src Source text containing possible managed sections.
|
|
466
|
+
* @param fileName Filename used to identify literal spans.
|
|
467
|
+
*
|
|
468
|
+
* @return Source text with existing managed sections removed.
|
|
469
|
+
*/
|
|
470
|
+
function removeManagedSects(src, fileName) {
|
|
471
|
+
const parsed = ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
472
|
+
const literals = [];
|
|
473
|
+
const visit = (n) => {
|
|
474
|
+
if (ts.isStringLiteral(n) ||
|
|
475
|
+
ts.isRegularExpressionLiteral(n) ||
|
|
476
|
+
ts.isNoSubstitutionTemplateLiteral(n) ||
|
|
477
|
+
ts.isTemplateHead(n) ||
|
|
478
|
+
ts.isTemplateMiddle(n) ||
|
|
479
|
+
ts.isTemplateTail(n) ||
|
|
480
|
+
ts.isJsxText(n)) {
|
|
481
|
+
literals.push({ start: n.getStart(parsed), end: n.end });
|
|
482
|
+
return;
|
|
483
|
+
}
|
|
484
|
+
ts.forEachChild(n, visit);
|
|
485
|
+
};
|
|
486
|
+
visit(parsed);
|
|
487
|
+
const pattern = /^[ \t]*\/\*\/{10,}[ \t]*\r?\n[ \t]*(?:CONFIG|HELPERS)[ \t]*\r?\n[ \t]*\/{10,}\*\/[ \t]*(?:\r?\n)*/gm;
|
|
488
|
+
const matches = [...src.matchAll(pattern)].filter((m) => !literals.some((l) => m.index >= l.start && m.index < l.end));
|
|
489
|
+
return matches.sort((a, b) => b.index - a.index).reduce((t, m) => `${t.slice(0, m.index)}${t.slice(m.index + m[0].length)}`, src);
|
|
490
|
+
}
|
|
491
|
+
/** Chooses the TSDoc parameter-tag name for a declared parameter.
|
|
492
|
+
* @param p Parameter declaration whose binding name should be documented.
|
|
493
|
+
* @param i Zero-based parameter position used when the binding has no simple identifier.
|
|
494
|
+
* @returns The identifier name, `props` for object bindings, or a positional fallback.
|
|
495
|
+
*/
|
|
496
|
+
function fnParamName(p, i) {
|
|
497
|
+
return ts.isIdentifier(p.name) ? p.name.text : ts.isObjectBindingPattern(p.name) ? 'props' : `param${i + 1}`;
|
|
498
|
+
}
|
|
499
|
+
/**
|
|
500
|
+
* Rebuilds a function's TSDoc layout while preserving existing summary and tag prose.
|
|
501
|
+
* @param n Function declaration whose signature controls required parameter and return tags.
|
|
502
|
+
* @param parsed Source file containing the declaration.
|
|
503
|
+
* @param eol Line-ending sequence to emit.
|
|
504
|
+
* @param existing Existing TSDoc block, when one is present.
|
|
505
|
+
* @returns Normalized TSDoc text; missing prose remains blank for later authoring.
|
|
506
|
+
*/
|
|
507
|
+
function genFnDoc(n, parsed, eol, existing = '') {
|
|
508
|
+
const params = n.parameters.map(fnParamName);
|
|
509
|
+
const body = existing
|
|
510
|
+
.slice(3, -2)
|
|
511
|
+
.split(R_N_REGEX)
|
|
512
|
+
.map((l) => l.replace(S_CLASS_3, '').trim());
|
|
513
|
+
while (body.length && !body.at(0)?.trim())
|
|
514
|
+
body.shift();
|
|
515
|
+
while (body.length && !body.at(-1)?.trim())
|
|
516
|
+
body.pop();
|
|
517
|
+
const firstTag = body.findIndex((l) => S_W_REGEX.test(l));
|
|
518
|
+
const summary = (firstTag < 0 ? body : body.slice(0, firstTag)).map((l) => l.trim());
|
|
519
|
+
while (summary.length && !summary.at(-1)?.trim())
|
|
520
|
+
summary.pop();
|
|
521
|
+
const tagLines = firstTag < 0 ? [] : body.slice(firstTag).filter((l) => l.trim());
|
|
522
|
+
const blocks = [];
|
|
523
|
+
for (const line of tagLines) {
|
|
524
|
+
if (S_W_REGEX.test(line) || !blocks.length)
|
|
525
|
+
blocks.push([line.trim()]);
|
|
526
|
+
else
|
|
527
|
+
blocks.at(-1).push(line);
|
|
528
|
+
}
|
|
529
|
+
const paramBlocks = new Map();
|
|
530
|
+
let returnBlock;
|
|
531
|
+
const otherBlocks = [];
|
|
532
|
+
for (const block of blocks) {
|
|
533
|
+
const param = block[0]?.match(/^@param\s+(?:\{[^}]*\}\s*)?(\S+)/);
|
|
534
|
+
if (param) {
|
|
535
|
+
paramBlocks.set(param[1].replace(/^\[|\]$/g, '').split('=')[0], block);
|
|
536
|
+
continue;
|
|
537
|
+
}
|
|
538
|
+
if (RETURNS_B_REGEX.test(block[0] ?? '')) {
|
|
539
|
+
returnBlock = [`${block[0].replace(RETURNS_B_REGEX, '@return')}`, ...block.slice(1)];
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
otherBlocks.push(block);
|
|
543
|
+
}
|
|
544
|
+
const content = [...summary];
|
|
545
|
+
if (!content.length || content.at(-1) !== '')
|
|
546
|
+
content.push('');
|
|
547
|
+
for (const name of params)
|
|
548
|
+
content.push(...(paramBlocks.get(name) ?? [`@param ${name}`]));
|
|
549
|
+
for (const [name, block] of paramBlocks)
|
|
550
|
+
if (!params.includes(name))
|
|
551
|
+
content.push(...block);
|
|
552
|
+
if (returnBlock || returnsVal(n)) {
|
|
553
|
+
if (content.at(-1) !== '')
|
|
554
|
+
content.push('');
|
|
555
|
+
content.push(...(returnBlock ?? ['@return']));
|
|
556
|
+
}
|
|
557
|
+
for (const block of otherBlocks)
|
|
558
|
+
content.push('', ...block);
|
|
559
|
+
const norm = ['/**', ...content.map((l) => (l ? ` * ${l}` : ' *')), ' */'].join(eol);
|
|
560
|
+
return existing === norm ? existing : norm;
|
|
561
|
+
}
|
|
562
|
+
/**
|
|
563
|
+
* Adds missing parameter and return tags to existing function TSDoc without inventing prose.
|
|
564
|
+
*
|
|
565
|
+
* @param src Source text whose function documentation should be normalized.
|
|
566
|
+
* @param fileName Filename used to select the source parser mode.
|
|
567
|
+
* @param eol Line-ending sequence to emit.
|
|
568
|
+
*
|
|
569
|
+
* @return Updated source text with the required parameter and return tags.
|
|
570
|
+
*/
|
|
571
|
+
function maintainFnDocs(src, fileName, eol) {
|
|
572
|
+
const parsed = ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
573
|
+
const updates = [];
|
|
574
|
+
const fns = [];
|
|
575
|
+
const visit = (node) => {
|
|
576
|
+
if (ts.isFunctionDeclaration(node))
|
|
577
|
+
fns.push(node);
|
|
578
|
+
ts.forEachChild(node, visit);
|
|
579
|
+
};
|
|
580
|
+
visit(parsed);
|
|
581
|
+
for (const node of fns) {
|
|
582
|
+
const leading = src.slice(node.getFullStart(), node.getStart(parsed));
|
|
583
|
+
const doc = [...leading.matchAll(S_S_G_REGEX())].at(-1);
|
|
584
|
+
if (!doc || doc.index === undefined)
|
|
585
|
+
continue;
|
|
586
|
+
const start = node.getFullStart() + doc.index;
|
|
587
|
+
const indentation = src.slice(src.lastIndexOf('\n', start - 1) + 1, start);
|
|
588
|
+
const text = genFnDoc(node, parsed, eol, doc[0]).replace(/\r?\n/g, `${eol}${indentation}`);
|
|
589
|
+
if (text !== doc[0])
|
|
590
|
+
updates.push({ start, end: start + doc[0].length, text });
|
|
591
|
+
}
|
|
592
|
+
return updates.sort((a, b) => b.start - a.start).reduce((c, u) => `${c.slice(0, u.start)}${u.text}${c.slice(u.end)}`, src);
|
|
593
|
+
}
|
|
594
|
+
/**
|
|
595
|
+
* Renders a top-level statement with its existing comments and normalized function documentation.
|
|
596
|
+
*
|
|
597
|
+
* @param n Statement being rendered.
|
|
598
|
+
* @param parsed Source file containing the statement.
|
|
599
|
+
* @param src Original source text used to recover leading comments.
|
|
600
|
+
* @param eol Line-ending sequence to emit.
|
|
601
|
+
*
|
|
602
|
+
* @return Statement text with preserved leading comments and normalized TSDoc.
|
|
603
|
+
*/
|
|
604
|
+
function statementText(n, parsed, src, eol) {
|
|
605
|
+
const leading = src.slice(n.getFullStart(), n.getStart(parsed)).trim();
|
|
606
|
+
const text = src.slice(n.getStart(parsed), n.end);
|
|
607
|
+
if (!ts.isFunctionDeclaration(n))
|
|
608
|
+
return [leading, text].filter(Boolean).join(eol);
|
|
609
|
+
const docs = [...leading.matchAll(S_S_G_REGEX())];
|
|
610
|
+
const doc = docs.at(-1);
|
|
611
|
+
const gen = genFnDoc(n, parsed, eol, doc?.[0]);
|
|
612
|
+
if (doc?.index !== undefined)
|
|
613
|
+
return [`${leading.slice(0, doc.index)}${gen}${leading.slice(doc.index + doc[0].length)}`.trim(), text].filter(Boolean).join(eol);
|
|
614
|
+
return [leading, gen, text].filter(Boolean).join(eol);
|
|
615
|
+
}
|
|
616
|
+
/**
|
|
617
|
+
* Moves top-level configuration and helper declarations into canonical sections after imports.
|
|
618
|
+
*
|
|
619
|
+
* @param src Source text whose managed sections should be rebuilt.
|
|
620
|
+
* @param fileName Filename used for parsing and literal protection.
|
|
621
|
+
* @param eol Line-ending sequence to emit.
|
|
622
|
+
*
|
|
623
|
+
* @return Source text with canonical CONFIG and HELPERS sections.
|
|
624
|
+
*/
|
|
625
|
+
function organizeSrcSects(src, fileName, eol) {
|
|
626
|
+
src = maintainFnDocs(src, fileName, eol);
|
|
627
|
+
if ([...src.matchAll(/\/\*\/{10,}\s*\n\s*([A-Z][A-Z _-]*)\s*\n\s*\/{10,}\*\//g)].some((m) => !['IMPORTS', 'CONFIG', 'HELPERS'].includes(m[1].trim())))
|
|
628
|
+
return src;
|
|
629
|
+
const withoutManagedSections = removeManagedSects(src, fileName);
|
|
630
|
+
const parsed = ts.createSourceFile(fileName, withoutManagedSections, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
631
|
+
const imports = parsed.statements.filter(ts.isImportDeclaration);
|
|
632
|
+
if (!imports.length)
|
|
633
|
+
return src;
|
|
634
|
+
const lastImport = imports.at(-1);
|
|
635
|
+
const statements = parsed.statements.filter((n) => !ts.isImportDeclaration(n) && n.getStart(parsed) >= lastImport.end);
|
|
636
|
+
const groups = { CONFIG: [], HELPERS: [] };
|
|
637
|
+
const remaining = [];
|
|
638
|
+
for (const n of statements) {
|
|
639
|
+
const text = statementText(n, parsed, withoutManagedSections, eol);
|
|
640
|
+
const group = srcGroup(n);
|
|
641
|
+
if (group)
|
|
642
|
+
groups[group].push(text);
|
|
643
|
+
else
|
|
644
|
+
remaining.push(text);
|
|
645
|
+
}
|
|
646
|
+
if (!groups.CONFIG.length && !groups.HELPERS.length)
|
|
647
|
+
return withoutManagedSections;
|
|
648
|
+
const tail = statements.length ? withoutManagedSections.slice(statements.at(-1).end).trim() : withoutManagedSections.slice(lastImport.end).trim();
|
|
649
|
+
const sections = ['CONFIG', 'HELPERS'].flatMap((l) => (groups[l].length ? [srcSection(l, groups[l], eol)] : []));
|
|
650
|
+
if (remaining.length)
|
|
651
|
+
sections.push(remaining.join(`${eol}${eol}`));
|
|
652
|
+
if (tail)
|
|
653
|
+
sections.push(tail);
|
|
654
|
+
return `${withoutManagedSections.slice(0, lastImport.end).trimEnd()}${eol}${eol}${sections.join(`${eol}${eol}`)}${eol}`;
|
|
655
|
+
}
|
|
656
|
+
/**
|
|
657
|
+
* Adds the canonical header, grouped imports, and top-level source sections.
|
|
658
|
+
*
|
|
659
|
+
* @param root Repository root used to create the file header path.
|
|
660
|
+
* @param fileName Absolute path to the source file to maintain.
|
|
661
|
+
*
|
|
662
|
+
* @return `true` when the file was rewritten; otherwise `false`.
|
|
663
|
+
*/
|
|
664
|
+
export function maintainSrcFile(root, fileName) {
|
|
665
|
+
if (!SUPPORTED.has(extname(fileName)))
|
|
666
|
+
return false;
|
|
667
|
+
const original = readFileSync(fileName, 'utf8');
|
|
668
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
669
|
+
let src = original.replace(UFEFF_REGEX, '').replace(/^\s+/, '');
|
|
670
|
+
const path = relative(root, fileName).split(sep).join('/');
|
|
671
|
+
src = src.replace(/^\/\*\*[ \t]+([^\r\n]*?)\s*\*\/[ \t]*(?:\r?\n)*/, (_match, d) => `/**${eol} * ${d}${eol} */${eol}`);
|
|
672
|
+
src = healHeaderFragments(src, path, eol);
|
|
673
|
+
const existingHeader = src.match(T_R_N_S_S_R_N_T_T_R_N_REGEX);
|
|
674
|
+
const headerLines = existingHeader?.[1]
|
|
675
|
+
.split(R_N_REGEX)
|
|
676
|
+
.map((l) => l.replace(T_T_REGEX, '').trim())
|
|
677
|
+
.filter(Boolean) ?? [];
|
|
678
|
+
const hasPathHeader = headerLines[0]?.includes('/') || /\.(?:c|m)?(?:js|ts)x?$/.test(headerLines[0] ?? '');
|
|
679
|
+
const headerDescription = hasPathHeader ? headerLines.slice(1) : headerLines;
|
|
680
|
+
const header = ['/**', path, ...headerDescription, ' */'].map((l, i) => (i === 0 ? l : i === headerDescription.length + 2 ? l : ` * ${l}`)).join(eol);
|
|
681
|
+
if (existingHeader) {
|
|
682
|
+
src = src.replace(T_R_N_S_S_R_N_T_T_R_N_REGEX, `${header}${eol}${eol}`);
|
|
683
|
+
const escapedPath = path.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
684
|
+
const duplicateHeader = new RegExp(`^(\\/\\*\\*\\r?\\n \\* ${escapedPath}\\r?\\n \\*\\/)(?:\\r?\\n)+\\1(?:\\r?\\n)+`);
|
|
685
|
+
src = src.replace(duplicateHeader, `$1${eol}${eol}`);
|
|
686
|
+
}
|
|
687
|
+
else if (!src.startsWith('#!') && !src.startsWith('/**'))
|
|
688
|
+
src = `${header}${eol}${eol}${src}`;
|
|
689
|
+
const parsed = ts.createSourceFile(fileName, src, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
690
|
+
const imports = parsed.statements.filter((n) => ts.isImportDeclaration(n) && !selfImportConflicts(parsed, n));
|
|
691
|
+
if (imports.length) {
|
|
692
|
+
const first = imports[0];
|
|
693
|
+
const last = imports.at(-1);
|
|
694
|
+
const groups = groupedImports(parsed, imports);
|
|
695
|
+
const comp = (a, b) => (a < b ? -1 : a > b ? 1 : 0);
|
|
696
|
+
const render = (label) => {
|
|
697
|
+
const entries = groups[label].sort((a, b) => comp(a.text, b.text) || comp(a.source, b.source));
|
|
698
|
+
const seen = new Set();
|
|
699
|
+
const uniqueEntries = entries.filter((e) => {
|
|
700
|
+
if (seen.has(e.text))
|
|
701
|
+
return false;
|
|
702
|
+
seen.add(e.text);
|
|
703
|
+
return true;
|
|
704
|
+
});
|
|
705
|
+
return uniqueEntries.length ? [`// ${label}`, ...uniqueEntries.map((e) => e.text), ''] : [];
|
|
706
|
+
};
|
|
707
|
+
const sortedGroups = Object.keys(groups).sort(comp);
|
|
708
|
+
const block = `${OPEN}${eol}IMPORTS${eol}${CLOSE}${eol}${sortedGroups.flatMap(render).join(eol)}`;
|
|
709
|
+
const prefix = src.slice(0, first.getStart(parsed));
|
|
710
|
+
const fenceStart = prefix.search(/\/\*\/{10,}[ \t]*\r?\n[ \t]*IMPORTS[ \t]*\r?\n[ \t]*\/{10,}\*\//);
|
|
711
|
+
const start = fenceStart >= 0 ? fenceStart : first.getStart(parsed);
|
|
712
|
+
src = `${src.slice(0, start).replace(S_CLASS_4, `${eol}${eol}`)}${block}${src.slice(last.end).replace(S_REGEX, eol)}`;
|
|
713
|
+
}
|
|
714
|
+
src = organizeSrcSects(src, fileName, eol);
|
|
715
|
+
const norm = src
|
|
716
|
+
.replace(/^([ \t]*)\/\*\/{10,}[ \t]*$/gm, `$1${OPEN}`)
|
|
717
|
+
.replace(/^([ \t]*)\/{10,}\*\/[ \t]*$/gm, `$1${CLOSE}`)
|
|
718
|
+
.replace(S_CLASS_4, eol);
|
|
719
|
+
const laidOut = normCommentLayout(norm, fileName, eol);
|
|
720
|
+
const n = original.startsWith('\uFEFF') ? `\uFEFF${laidOut}` : laidOut;
|
|
721
|
+
if (n === original)
|
|
722
|
+
return false;
|
|
723
|
+
return writeValidatedSrc(fileName, n);
|
|
724
|
+
}
|
|
725
|
+
/**
|
|
726
|
+
* Removes generated section scaffolding and empty or detached TSDoc after source edits.
|
|
727
|
+
*
|
|
728
|
+
* @param fileName Source filename whose generated or detached comments should be removed.
|
|
729
|
+
*
|
|
730
|
+
* @return Whether the file was changed after a validated write.
|
|
731
|
+
*/
|
|
732
|
+
export function removeNonCodeComments(fileName) {
|
|
733
|
+
if (!SUPPORTED.has(extname(fileName)))
|
|
734
|
+
return false;
|
|
735
|
+
const src = readFileSync(fileName, 'utf8');
|
|
736
|
+
const original = src.replace(/(^|\n)([ \t]*(?:CONFIG|TESTS|HELPERS|IMPORTS)[ \t]*\r?\n[ \t]*\/{10,}\*\/[ \t]*(?=\r?\n|$))/g, (m, p, _fragment, o) => {
|
|
737
|
+
const precedingLine = src
|
|
738
|
+
.slice(0, o + p.length)
|
|
739
|
+
.trimEnd()
|
|
740
|
+
.split(R_N_REGEX)
|
|
741
|
+
.at(-1)
|
|
742
|
+
?.trim();
|
|
743
|
+
return precedingLine && /^\/\*\/{10,}$/.test(precedingLine) ? m : p;
|
|
744
|
+
});
|
|
745
|
+
if (original !== src)
|
|
746
|
+
writeValidatedSrc(fileName, original);
|
|
747
|
+
const parsed = ts.createSourceFile(fileName, original, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
748
|
+
const removals = [];
|
|
749
|
+
const nodeStarts = [];
|
|
750
|
+
const codeStarts = [];
|
|
751
|
+
const inspect = (node) => {
|
|
752
|
+
codeStarts.push(node.getStart(parsed));
|
|
753
|
+
if (ts.isVariableStatement(node))
|
|
754
|
+
nodeStarts.push(node.getStart(parsed));
|
|
755
|
+
if (ts.isClassLike(node) ||
|
|
756
|
+
ts.isFunctionLike(node) ||
|
|
757
|
+
ts.isVariableDeclaration(node) ||
|
|
758
|
+
ts.isTypeAliasDeclaration(node) ||
|
|
759
|
+
ts.isInterfaceDeclaration(node) ||
|
|
760
|
+
ts.isEnumDeclaration(node) ||
|
|
761
|
+
ts.isModuleDeclaration(node) ||
|
|
762
|
+
ts.isPropertyDeclaration(node) ||
|
|
763
|
+
ts.isMethodDeclaration(node) ||
|
|
764
|
+
ts.isParameter(node))
|
|
765
|
+
nodeStarts.push(node.getStart(parsed));
|
|
766
|
+
ts.forEachChild(node, inspect);
|
|
767
|
+
};
|
|
768
|
+
inspect(parsed);
|
|
769
|
+
const comments = [];
|
|
770
|
+
const scanner = ts.createScanner(ts.ScriptTarget.Latest, false, parsed.languageVariant, original);
|
|
771
|
+
for (let token = scanner.scan(); token !== ts.SyntaxKind.EndOfFileToken; token = scanner.scan())
|
|
772
|
+
if (token === ts.SyntaxKind.SingleLineCommentTrivia || token === ts.SyntaxKind.MultiLineCommentTrivia)
|
|
773
|
+
comments.push({ start: scanner.getTokenPos(), end: scanner.getTextPos(), text: scanner.getTokenText() });
|
|
774
|
+
for (const block of tsdocBlocks(original, parsed)) {
|
|
775
|
+
const followingDeclaration = nodeStarts.filter((s) => s >= block.end).sort((a, b) => a - b)[0];
|
|
776
|
+
const detached = followingDeclaration === undefined || !S_REGEX.test(original.slice(block.end, followingDeclaration));
|
|
777
|
+
const empty = !block.text
|
|
778
|
+
.replace(/^\/\*\*|\*\/$/g, '')
|
|
779
|
+
.replace(/^\s*\* ?/gm, '')
|
|
780
|
+
.trim();
|
|
781
|
+
if ((detached || empty) && !fileHeaderBlock(original, [block]))
|
|
782
|
+
removals.push({ start: block.start, end: block.end });
|
|
783
|
+
}
|
|
784
|
+
const headings = comments.filter(({ text: t }) => /^\/\*\/{10,}/.test(t) || /^\/\/\s*[A-Z][A-Z _-]*\s*$/.test(t));
|
|
785
|
+
for (const [idx, heading] of headings.entries()) {
|
|
786
|
+
const section = heading.text.startsWith('/*');
|
|
787
|
+
const n = headings.slice(idx + 1).find((c) => c.text.startsWith('/*') === section)?.start ?? original.length;
|
|
788
|
+
if (!codeStarts.some((s) => s >= heading.end && s < n))
|
|
789
|
+
removals.push({ start: heading.start, end: heading.end });
|
|
790
|
+
}
|
|
791
|
+
if (!removals.length)
|
|
792
|
+
return false;
|
|
793
|
+
let updated = original;
|
|
794
|
+
for (const { start, end } of removals.sort((a, b) => b.start - a.start))
|
|
795
|
+
updated = `${updated.slice(0, start)}${updated.slice(end)}`;
|
|
796
|
+
updated = updated.replace(/\n{3,}/g, '\n\n');
|
|
797
|
+
return updated === original ? false : writeValidatedSrc(fileName, updated);
|
|
798
|
+
}
|
|
799
|
+
/**
|
|
800
|
+
* Adds Prettier protection to one-line imports that exceed the configured print width.
|
|
801
|
+
*
|
|
802
|
+
* @param fileName Absolute path to the source file whose imports are inspected.
|
|
803
|
+
* @param printWidth Maximum line width before a Prettier ignore marker is inserted.
|
|
804
|
+
*
|
|
805
|
+
* @return `true` when one or more ignore markers were inserted.
|
|
806
|
+
*/
|
|
807
|
+
export function protectLongImports(fileName, printWidth = 200) {
|
|
808
|
+
if (!SUPPORTED.has(extname(fileName)))
|
|
809
|
+
return false;
|
|
810
|
+
const original = readFileSync(fileName, 'utf8');
|
|
811
|
+
const eol = original.includes('\r\n') ? '\r\n' : '\n';
|
|
812
|
+
const parsed = ts.createSourceFile(fileName, original, ts.ScriptTarget.Latest, true, srcKind(fileName));
|
|
813
|
+
const lineStarts = parsed.getLineStarts();
|
|
814
|
+
const insertions = new Set();
|
|
815
|
+
for (const n of parsed.statements.filter(ts.isImportDeclaration)) {
|
|
816
|
+
const lineStart = lineStarts[parsed.getLineAndCharacterOfPosition(n.getStart(parsed)).line] ?? 0;
|
|
817
|
+
const nline = original.indexOf('\n', n.end);
|
|
818
|
+
const line = original.slice(lineStart, nline < 0 ? original.length : nline).replace(/\r$/, '');
|
|
819
|
+
if (line.length <= printWidth || original.slice(lineStart, n.getStart(parsed)).trim())
|
|
820
|
+
continue;
|
|
821
|
+
const prevLineEnd = lineStart > 0 ? lineStart - 1 : -1;
|
|
822
|
+
const prevLineStart = prevLineEnd >= 0 ? original.lastIndexOf('\n', prevLineEnd - 1) + 1 : 0;
|
|
823
|
+
if (original.slice(prevLineStart, prevLineEnd + 1).trim() === '// prettier-ignore')
|
|
824
|
+
continue;
|
|
825
|
+
insertions.add(lineStart);
|
|
826
|
+
}
|
|
827
|
+
if (!insertions.size)
|
|
828
|
+
return false;
|
|
829
|
+
let next = original;
|
|
830
|
+
for (const offset of [...insertions].sort((a, b) => b - a)) {
|
|
831
|
+
const nLine = original.indexOf('\n', offset);
|
|
832
|
+
const indent = original.slice(offset, nLine < 0 ? original.length : nLine).match(S_REGEX)?.[0] ?? '';
|
|
833
|
+
next = `${next.slice(0, offset)}${indent}// prettier-ignore${eol}${next.slice(offset)}`;
|
|
834
|
+
}
|
|
835
|
+
return writeValidatedSrc(fileName, next);
|
|
836
|
+
}
|
|
837
|
+
/**
|
|
838
|
+
* Selects the TypeScript parser mode used by documentation checks.
|
|
839
|
+
*
|
|
840
|
+
* @param fileName Filename whose extension determines the parser mode.
|
|
841
|
+
*
|
|
842
|
+
* @return TypeScript compiler script kind for the filename.
|
|
843
|
+
*/
|
|
844
|
+
function documentationScriptKind(fileName) {
|
|
845
|
+
if (/\.[cm]?tsx$/.test(fileName))
|
|
846
|
+
return ts.ScriptKind.TSX;
|
|
847
|
+
if (/\.[cm]?jsx$/.test(fileName))
|
|
848
|
+
return ts.ScriptKind.JSX;
|
|
849
|
+
if (/\.[cm]?js$/.test(fileName))
|
|
850
|
+
return ts.ScriptKind.JS;
|
|
851
|
+
return ts.ScriptKind.TS;
|
|
852
|
+
}
|
|
853
|
+
/**
|
|
854
|
+
* Checks whether a filename is supported by the documentation policy.
|
|
855
|
+
*
|
|
856
|
+
* @param fileName Source filename to test.
|
|
857
|
+
*
|
|
858
|
+
* @return `true` for supported JavaScript and TypeScript extensions.
|
|
859
|
+
*/
|
|
860
|
+
function documentationSupported(fileName) {
|
|
861
|
+
return /\.(?:c|m)?(?:js|jsx|ts|tsx)$/.test(fileName);
|
|
862
|
+
}
|
|
863
|
+
/**
|
|
864
|
+
* Checks whether a node has a JSDoc or TSDoc block in its leading trivia.
|
|
865
|
+
*
|
|
866
|
+
* @param node Declaration node to inspect.
|
|
867
|
+
* @param src Source text containing the node.
|
|
868
|
+
*
|
|
869
|
+
* @return `true` when a documentation comment directly precedes the node.
|
|
870
|
+
*/
|
|
871
|
+
function documented(node, src) {
|
|
872
|
+
const leading = src.slice(node.getFullStart(), node.getStart());
|
|
873
|
+
const docs = [...leading.matchAll(S_S_G_REGEX())];
|
|
874
|
+
const doc = docs.at(-1);
|
|
875
|
+
if (!doc)
|
|
876
|
+
return false;
|
|
877
|
+
// The generated file header is source-level metadata, not documentation for
|
|
878
|
+
// the first declaration. Only exclude it when the block is the first
|
|
879
|
+
// path-bearing TSDoc in the file; a declaration's own later TSDoc remains
|
|
880
|
+
// valid even when the file also has a header.
|
|
881
|
+
const blockStart = node.getFullStart() + doc.index;
|
|
882
|
+
let prefix = src.slice(0, blockStart).replace(UFEFF_REGEX, '');
|
|
883
|
+
if (prefix.startsWith('#!'))
|
|
884
|
+
prefix = prefix.slice(prefix.indexOf('\n') + 1);
|
|
885
|
+
const docLines = doc[0]
|
|
886
|
+
.slice(3, -2)
|
|
887
|
+
.split(R_N_REGEX)
|
|
888
|
+
.map((l) => l.replace(S_CLASS_3, '').trim());
|
|
889
|
+
const isFileHeader = prefix.trim() === '' && docLines.some((l) => C_M_JS_JSX_TS_TSX_REGEX.test(l));
|
|
890
|
+
return !isFileHeader;
|
|
891
|
+
}
|
|
892
|
+
/**
|
|
893
|
+
* Checks whether a declaration carries an export modifier.
|
|
894
|
+
*
|
|
895
|
+
* @param node Declaration node to inspect.
|
|
896
|
+
*
|
|
897
|
+
* @return `true` when the node is explicitly exported.
|
|
898
|
+
*/
|
|
899
|
+
function exported(node) {
|
|
900
|
+
return !!(ts.canHaveModifiers(node) && ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
|
|
901
|
+
}
|
|
902
|
+
/**
|
|
903
|
+
* Gets the declared identifier used in a documentation diagnostic.
|
|
904
|
+
*
|
|
905
|
+
* @param node Declaration node whose name should be reported.
|
|
906
|
+
*
|
|
907
|
+
* @return Identifier text, or `<anonymous>` when the declaration has no identifier.
|
|
908
|
+
*/
|
|
909
|
+
function documentationNodeName(node) {
|
|
910
|
+
const candidate = node.name ?? node;
|
|
911
|
+
return ts.isIdentifier(candidate) ? candidate.text : '<anonymous>';
|
|
912
|
+
}
|
|
913
|
+
/**
|
|
914
|
+
* Scans source text for TSDoc blocks without treating comment-shaped text inside literals as documentation.
|
|
915
|
+
*
|
|
916
|
+
* @param src Source text to scan.
|
|
917
|
+
* @param parsed Parsed source file whose language variant should be respected.
|
|
918
|
+
*
|
|
919
|
+
* @return TSDoc blocks with their source offsets and original text.
|
|
920
|
+
*/
|
|
921
|
+
function tsdocBlocks(src, parsed) {
|
|
922
|
+
const blocks = new Map();
|
|
923
|
+
const add = (ranges) => {
|
|
924
|
+
for (const range of ranges ?? []) {
|
|
925
|
+
const text = src.slice(range.pos, range.end);
|
|
926
|
+
if (text.startsWith('/**'))
|
|
927
|
+
blocks.set(range.pos, { start: range.pos, end: range.end, text });
|
|
928
|
+
}
|
|
929
|
+
};
|
|
930
|
+
const visit = (node) => {
|
|
931
|
+
add(ts.getLeadingCommentRanges(src, node.getFullStart()));
|
|
932
|
+
add(ts.getTrailingCommentRanges(src, node.end));
|
|
933
|
+
ts.forEachChild(node, visit);
|
|
934
|
+
};
|
|
935
|
+
visit(parsed);
|
|
936
|
+
return [...blocks.values()].sort((l, r) => l.start - r.start);
|
|
937
|
+
}
|
|
938
|
+
/**
|
|
939
|
+
* Converts a TSDoc block into normalized content lines while retaining each line's source offset.
|
|
940
|
+
*
|
|
941
|
+
* @param block TSDoc block to split.
|
|
942
|
+
*
|
|
943
|
+
* @return TSDoc content lines with comment markers removed.
|
|
944
|
+
*/
|
|
945
|
+
function tsdocLines(block) {
|
|
946
|
+
const body = block.text.slice(3, -2);
|
|
947
|
+
const lineSize = body.includes('\r\n') ? 2 : 1;
|
|
948
|
+
let offset = block.start + 3;
|
|
949
|
+
return body.split(R_N_REGEX).map((l) => {
|
|
950
|
+
const result = { offset, text: l.replace(S_CLASS_3, '').trim() };
|
|
951
|
+
offset += l.length + lineSize;
|
|
952
|
+
return result;
|
|
953
|
+
});
|
|
954
|
+
}
|
|
955
|
+
/**
|
|
956
|
+
* Finds the generated path-bearing mock-TSDoc block at the top of a source file, allowing a shebang and whitespace before it.
|
|
957
|
+
*
|
|
958
|
+
* @param src Source text to inspect.
|
|
959
|
+
* @param blocks TSDoc blocks found in the source.
|
|
960
|
+
*
|
|
961
|
+
* @return The top-level file-header block, or `undefined` when it is missing.
|
|
962
|
+
*/
|
|
963
|
+
function fileHeaderBlock(src, blocks) {
|
|
964
|
+
return blocks.find((b) => {
|
|
965
|
+
let prefix = src.slice(0, b.start).replace(UFEFF_REGEX, '');
|
|
966
|
+
if (prefix.startsWith('#!'))
|
|
967
|
+
prefix = prefix.slice(prefix.indexOf('\n') + 1);
|
|
968
|
+
return prefix.trim() === '' && tsdocLines(b).some((l) => C_M_JS_JSX_TS_TSX_REGEX.test(l.text));
|
|
969
|
+
});
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* Extracts a tag's human-written description after removing its optional type and parameter name.
|
|
973
|
+
*
|
|
974
|
+
* @param tag TSDoc tag name without the leading `@`.
|
|
975
|
+
* @param content Text following the tag name, including any continuation lines.
|
|
976
|
+
*
|
|
977
|
+
* @return Tag description, or an empty string when only syntax is present.
|
|
978
|
+
*/
|
|
979
|
+
function tagDescription(tag, content) {
|
|
980
|
+
let description = content.trim().replace(S_CLASS_5, '');
|
|
981
|
+
if (tag.toLowerCase() === 'param' || tag.toLowerCase() === 'typeparam')
|
|
982
|
+
description = description.replace(/^\[[^\]]+\]\s*/, '').replace(/^\S+\s*/, '');
|
|
983
|
+
return description.replace(/^[-:]\s*/, '').trim();
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Parses TSDoc tags and joins continuation lines with their leading tag line.
|
|
987
|
+
*
|
|
988
|
+
* @param lines Normalized TSDoc content lines.
|
|
989
|
+
*
|
|
990
|
+
* @return Parsed tag names, subjects, descriptions, and source offsets.
|
|
991
|
+
*/
|
|
992
|
+
function tsdocTagEntries(lines) {
|
|
993
|
+
const entries = [];
|
|
994
|
+
for (let idx = 0; idx < lines.length; idx += 1) {
|
|
995
|
+
const match = lines[idx].text.match(/^@(\S+)(?:\s+(.*))?$/);
|
|
996
|
+
if (!match)
|
|
997
|
+
continue;
|
|
998
|
+
const tag = match[1];
|
|
999
|
+
const content = [match[2] ?? ''];
|
|
1000
|
+
let n = idx + 1;
|
|
1001
|
+
while (n < lines.length && !lines[n].text.startsWith('@')) {
|
|
1002
|
+
if (lines[n].text)
|
|
1003
|
+
content.push(lines[n].text);
|
|
1004
|
+
n += 1;
|
|
1005
|
+
}
|
|
1006
|
+
const rawContent = content.join(' ').trim();
|
|
1007
|
+
const subject = tag.toLowerCase() === 'param' || tag.toLowerCase() === 'typeparam' ? (rawContent.replace(S_CLASS_5, '').split(/\s+/)[0] ?? '') : '';
|
|
1008
|
+
entries.push({ tag, subject, description: tagDescription(tag, rawContent), offset: lines[idx].offset });
|
|
1009
|
+
idx = n - 1;
|
|
1010
|
+
}
|
|
1011
|
+
return entries;
|
|
1012
|
+
}
|
|
1013
|
+
/**
|
|
1014
|
+
* Determines whether a function exposes a value through its return type or body.
|
|
1015
|
+
*
|
|
1016
|
+
* @param node Function declaration whose return contract is being checked.
|
|
1017
|
+
*
|
|
1018
|
+
* @return `true` when callers can receive a value rather than only completion.
|
|
1019
|
+
*/
|
|
1020
|
+
function returnsVal(node) {
|
|
1021
|
+
const returnType = node.type?.getText().replace(/\s+/g, '') ?? '';
|
|
1022
|
+
if (returnType === 'void' || returnType === 'Promise<void>')
|
|
1023
|
+
return false;
|
|
1024
|
+
if (node.type)
|
|
1025
|
+
return true;
|
|
1026
|
+
let valReturn = false;
|
|
1027
|
+
const visit = (child) => {
|
|
1028
|
+
if (valReturn)
|
|
1029
|
+
return;
|
|
1030
|
+
if (ts.isReturnStatement(child) && child.expression)
|
|
1031
|
+
valReturn = true;
|
|
1032
|
+
ts.forEachChild(child, visit);
|
|
1033
|
+
};
|
|
1034
|
+
if (node.body)
|
|
1035
|
+
visit(node.body);
|
|
1036
|
+
return valReturn;
|
|
1037
|
+
}
|
|
1038
|
+
/**
|
|
1039
|
+
* Finds the TSDoc block directly attached to a declaration.
|
|
1040
|
+
*
|
|
1041
|
+
* @param src Source text containing the declaration and its comments.
|
|
1042
|
+
* @param node Declaration whose leading documentation is required.
|
|
1043
|
+
*
|
|
1044
|
+
* @return The attached TSDoc block, or `undefined` when the declaration is undocumented.
|
|
1045
|
+
*/
|
|
1046
|
+
function documentationBlockFor(src, node) {
|
|
1047
|
+
const ranges = ts.getLeadingCommentRanges(src, node.getFullStart()) ?? [];
|
|
1048
|
+
const range = ranges.at(-1);
|
|
1049
|
+
if (!range || range.kind !== ts.SyntaxKind.MultiLineCommentTrivia || !src.slice(range.pos, range.end).startsWith('/**'))
|
|
1050
|
+
return undefined;
|
|
1051
|
+
return { start: range.pos, end: range.end, text: src.slice(range.pos, range.end) };
|
|
1052
|
+
}
|
|
1053
|
+
/**
|
|
1054
|
+
* Audits existing TSDoc prose without inventing replacement text. Generated file
|
|
1055
|
+
* path headers are source metadata and are intentionally excluded from this audit.
|
|
1056
|
+
*
|
|
1057
|
+
* @param cfg Quality configuration used to render repository-relative paths.
|
|
1058
|
+
* @param file Absolute source path being inspected.
|
|
1059
|
+
* @param src Source text containing the documentation blocks.
|
|
1060
|
+
* @param parsed Parsed source file used for locations and comment scanning.
|
|
1061
|
+
*
|
|
1062
|
+
* @return Warning findings for missing file, summary, or tag descriptions.
|
|
1063
|
+
*/
|
|
1064
|
+
function descriptionFindingsForFile(cfg, file, src, parsed) {
|
|
1065
|
+
const blocks = tsdocBlocks(src, parsed);
|
|
1066
|
+
const findings = [];
|
|
1067
|
+
const header = fileHeaderBlock(src, blocks);
|
|
1068
|
+
const attached = new Set();
|
|
1069
|
+
const markAttached = (node) => {
|
|
1070
|
+
if (ts.isDeclarationStatement(node) ||
|
|
1071
|
+
ts.isFunctionLike(node) ||
|
|
1072
|
+
ts.isClassLike(node) ||
|
|
1073
|
+
ts.isEnumMember(node) ||
|
|
1074
|
+
ts.isPropertySignature(node) ||
|
|
1075
|
+
ts.isMethodSignature(node) ||
|
|
1076
|
+
ts.isCallSignatureDeclaration(node) ||
|
|
1077
|
+
ts.isConstructSignatureDeclaration(node) ||
|
|
1078
|
+
ts.isIndexSignatureDeclaration(node) ||
|
|
1079
|
+
ts.isTypeParameterDeclaration(node) ||
|
|
1080
|
+
ts.isVariableStatement(node))
|
|
1081
|
+
for (const range of ts.getLeadingCommentRanges(src, node.getFullStart()) ?? [])
|
|
1082
|
+
if (src.slice(range.pos, range.end).startsWith('/**') && S_REGEX.test(src.slice(range.end, node.getStart(parsed))))
|
|
1083
|
+
attached.add(range.pos);
|
|
1084
|
+
ts.forEachChild(node, markAttached);
|
|
1085
|
+
};
|
|
1086
|
+
markAttached(parsed);
|
|
1087
|
+
for (const block of blocks) {
|
|
1088
|
+
if (block === header)
|
|
1089
|
+
continue;
|
|
1090
|
+
if (!attached.has(block.start)) {
|
|
1091
|
+
const finding = documentationCommentReport(file, parsed, block.start, '<detached TSDoc>', 'orphan', 'remove or attach this TSDoc to a declaration', cfg);
|
|
1092
|
+
findings.push({ ...finding, severity: 'warning' });
|
|
1093
|
+
continue;
|
|
1094
|
+
}
|
|
1095
|
+
const lines = tsdocLines(block);
|
|
1096
|
+
const firstTag = lines.findIndex((l) => l.text.startsWith('@'));
|
|
1097
|
+
const summary = lines
|
|
1098
|
+
.slice(0, firstTag < 0 ? lines.length : firstTag)
|
|
1099
|
+
.map((l) => l.text)
|
|
1100
|
+
.filter(Boolean);
|
|
1101
|
+
if (!summary.length)
|
|
1102
|
+
findings.push(documentationCommentReport(file, parsed, block.start, '<TSDoc summary>', 'summary', 'add a meaningful description for this TSDoc block', cfg));
|
|
1103
|
+
for (const entry of tsdocTagEntries(lines)) {
|
|
1104
|
+
if (DESCRIPTION_OPTIONAL_TAGS.has(entry.tag.toLowerCase()) || entry.description)
|
|
1105
|
+
continue;
|
|
1106
|
+
const kind = entry.tag.toLowerCase() === 'param' ? 'param' : entry.tag.toLowerCase() === 'return' || entry.tag.toLowerCase() === 'returns' ? 'return' : 'tag';
|
|
1107
|
+
const name = `@${entry.tag}${entry.subject ? ` ${entry.subject}` : ''}`;
|
|
1108
|
+
findings.push(documentationCommentReport(file, parsed, entry.offset, name, kind, `add a meaningful description for ${name}`, cfg));
|
|
1109
|
+
}
|
|
1110
|
+
}
|
|
1111
|
+
return findings;
|
|
1112
|
+
}
|
|
1113
|
+
/**
|
|
1114
|
+
* Creates a warning finding anchored to a TSDoc comment offset.
|
|
1115
|
+
*
|
|
1116
|
+
* @param file Absolute source path containing the comment.
|
|
1117
|
+
* @param parsed Parsed source file used to calculate the location.
|
|
1118
|
+
* @param offset Zero-based source offset for the comment or tag.
|
|
1119
|
+
* @param name Display name for the missing documentation.
|
|
1120
|
+
* @param kind Documentation audit category.
|
|
1121
|
+
* @param reason Human-readable remediation reason.
|
|
1122
|
+
* @param cfg Quality configuration defining the repository root.
|
|
1123
|
+
*
|
|
1124
|
+
* @return Warning documentation finding.
|
|
1125
|
+
*/
|
|
1126
|
+
function documentationCommentReport(file, parsed, offset, name, kind, reason, cfg) {
|
|
1127
|
+
const pos = parsed.getLineAndCharacterOfPosition(offset);
|
|
1128
|
+
return { file: displayPath(cfg, file), line: pos.line + 1, column: pos.character + 1, name, kind, reason, severity: 'warning' };
|
|
1129
|
+
}
|
|
1130
|
+
/**
|
|
1131
|
+
* Creates a documentation finding with a repository-relative source location.
|
|
1132
|
+
*
|
|
1133
|
+
* @param file Absolute path to the source file.
|
|
1134
|
+
* @param parsed Parsed source file used to calculate the location.
|
|
1135
|
+
* @param node Undocumented node that triggered the finding.
|
|
1136
|
+
* @param name Display name for the node.
|
|
1137
|
+
* @param kind Documentation category for the finding.
|
|
1138
|
+
* @param reason Human-readable remediation reason.
|
|
1139
|
+
* @param cfg Quality configuration defining the repository root.
|
|
1140
|
+
*
|
|
1141
|
+
* @return Structured documentation finding.
|
|
1142
|
+
*/
|
|
1143
|
+
function documentationReport(file, parsed, node, name, kind, reason, cfg) {
|
|
1144
|
+
const pos = parsed.getLineAndCharacterOfPosition(node.getStart(parsed));
|
|
1145
|
+
return { file: displayPath(cfg, file), line: pos.line + 1, column: pos.character + 1, name, kind, reason, severity: 'error' };
|
|
1146
|
+
}
|
|
1147
|
+
/**
|
|
1148
|
+
* Evaluates documentation requirements for one source file.
|
|
1149
|
+
*
|
|
1150
|
+
* @param cfg Quality configuration used to render diagnostic paths.
|
|
1151
|
+
* @param file Absolute source path to inspect.
|
|
1152
|
+
* @param requirements Enabled documentation policies.
|
|
1153
|
+
* @param analysis Optional shared program and source cache for the current run.
|
|
1154
|
+
*
|
|
1155
|
+
* @return Documentation findings for the file.
|
|
1156
|
+
*/
|
|
1157
|
+
function documentationFindingsForFile(cfg, file, requirements, analysis) {
|
|
1158
|
+
if (!documentationSupported(file))
|
|
1159
|
+
return [];
|
|
1160
|
+
const src = analysis?.texts.get(file) ?? readFileSync(file, 'utf8');
|
|
1161
|
+
const parsed = analysis?.sources.get(file) ?? ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, documentationScriptKind(file));
|
|
1162
|
+
const findings = descriptionFindingsForFile(cfg, file, src, parsed);
|
|
1163
|
+
const visit = (node) => {
|
|
1164
|
+
const isType = ts.isClassDeclaration(node) || ts.isEnumDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node);
|
|
1165
|
+
if (requirements.types && isType && !documented(node, src))
|
|
1166
|
+
findings.push(documentationReport(file, parsed, node, documentationNodeName(node), 'type', 'add TSDoc for this type', cfg));
|
|
1167
|
+
if (ts.isFunctionDeclaration(node)) {
|
|
1168
|
+
const needsDoc = requirements.functions || (requirements.exportedFunctions && exported(node));
|
|
1169
|
+
if (needsDoc) {
|
|
1170
|
+
if (!documented(node, src)) {
|
|
1171
|
+
findings.push(documentationReport(file, parsed, node, documentationNodeName(node), 'function', 'add TSDoc for this function', cfg));
|
|
1172
|
+
}
|
|
1173
|
+
else {
|
|
1174
|
+
const block = documentationBlockFor(src, node);
|
|
1175
|
+
if (block) {
|
|
1176
|
+
const entries = tsdocTagEntries(tsdocLines(block));
|
|
1177
|
+
const documentedParams = new Set(entries.filter((e) => e.tag.toLowerCase() === 'param').map((e) => e.subject));
|
|
1178
|
+
for (const [idx, param] of node.parameters.entries()) {
|
|
1179
|
+
const name = fnParamName(param, idx);
|
|
1180
|
+
if (!documentedParams.has(name))
|
|
1181
|
+
findings.push(documentationReport(file, parsed, node, `@param ${name}`, 'param', `document the ${name} parameter`, cfg));
|
|
1182
|
+
}
|
|
1183
|
+
const hasReturnTag = entries.some((e) => ['return', 'returns'].includes(e.tag.toLowerCase()));
|
|
1184
|
+
if (returnsVal(node) && !hasReturnTag)
|
|
1185
|
+
findings.push(documentationReport(file, parsed, node, '@return', 'return', 'document the value returned by this function', cfg));
|
|
1186
|
+
}
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
}
|
|
1190
|
+
if (requirements.noInlineFunctions && (ts.isArrowFunction(node) || ts.isFunctionExpression(node)))
|
|
1191
|
+
findings.push(documentationReport(file, parsed, node, documentationNodeName(node), 'inline', 'move this function to a named declaration', cfg));
|
|
1192
|
+
ts.forEachChild(node, visit);
|
|
1193
|
+
};
|
|
1194
|
+
visit(parsed);
|
|
1195
|
+
return findings;
|
|
1196
|
+
}
|
|
1197
|
+
/**
|
|
1198
|
+
* Finds documentation-policy violations for the configured source scope.
|
|
1199
|
+
*
|
|
1200
|
+
* @param cfg Quality configuration containing required documentation policies.
|
|
1201
|
+
* @param analysis Optional shared program and source cache for the current run.
|
|
1202
|
+
*
|
|
1203
|
+
* @return Documentation findings across the configured source scope.
|
|
1204
|
+
*/
|
|
1205
|
+
export function documentationFindings(cfg, analysis) {
|
|
1206
|
+
const requirements = cfg.documentation?.required ?? {};
|
|
1207
|
+
return analysis?.files
|
|
1208
|
+
? analysis.files.flatMap((f) => documentationFindingsForFile(cfg, f, requirements, analysis))
|
|
1209
|
+
: srcFiles(cfg).flatMap((f) => documentationFindingsForFile(cfg, f, requirements));
|
|
1210
|
+
}
|
|
1211
|
+
/**
|
|
1212
|
+
* Inserts an empty TSDoc scaffold before declarations required to be documented.
|
|
1213
|
+
* The scaffold never invents a description; prose must be authored after understanding the declaration.
|
|
1214
|
+
*
|
|
1215
|
+
* @param cfg Quality configuration containing documentation requirements.
|
|
1216
|
+
* @param file Absolute source path to update.
|
|
1217
|
+
*
|
|
1218
|
+
* @return `true` when one or more scaffolds were inserted.
|
|
1219
|
+
*/
|
|
1220
|
+
export function documentSrcFile(cfg, file) {
|
|
1221
|
+
if (!documentationSupported(file))
|
|
1222
|
+
return false;
|
|
1223
|
+
const requirements = cfg.documentation?.required;
|
|
1224
|
+
if (!requirements)
|
|
1225
|
+
return false;
|
|
1226
|
+
const src = readFileSync(file, 'utf8');
|
|
1227
|
+
const parsed = ts.createSourceFile(file, src, ts.ScriptTarget.Latest, true, documentationScriptKind(file));
|
|
1228
|
+
const insertions = [];
|
|
1229
|
+
const visit = (node) => {
|
|
1230
|
+
const isType = ts.isClassDeclaration(node) || ts.isEnumDeclaration(node) || ts.isInterfaceDeclaration(node) || ts.isTypeAliasDeclaration(node);
|
|
1231
|
+
const isFn = ts.isFunctionDeclaration(node);
|
|
1232
|
+
const needsFn = isFn && (requirements.functions || (requirements.exportedFunctions && exported(node)));
|
|
1233
|
+
if (((requirements.types && isType) || needsFn) && !documented(node, src))
|
|
1234
|
+
insertions.push(node.getStart(parsed));
|
|
1235
|
+
ts.forEachChild(node, visit);
|
|
1236
|
+
};
|
|
1237
|
+
visit(parsed);
|
|
1238
|
+
if (!insertions.length)
|
|
1239
|
+
return false;
|
|
1240
|
+
const eol = src.includes('\r\n') ? '\r\n' : '\n';
|
|
1241
|
+
const updated = insertions.sort((a, b) => b - a).reduce((t, p) => `${t.slice(0, p)}/**${eol} *${eol} */${eol}${t.slice(p)}`, src);
|
|
1242
|
+
return writeValidatedSrc(file, updated);
|
|
1243
|
+
}
|
|
1244
|
+
//# sourceMappingURL=documentation.js.map
|