md-verified 0.1.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/LICENSE +21 -0
- package/README.md +493 -0
- package/dist/check.d.ts +2 -0
- package/dist/check.js +342 -0
- package/dist/src/assertions.d.ts +25 -0
- package/dist/src/assertions.js +84 -0
- package/dist/src/coerce.d.ts +19 -0
- package/dist/src/coerce.js +122 -0
- package/dist/src/covers.d.ts +38 -0
- package/dist/src/covers.js +59 -0
- package/dist/src/framework.d.ts +64 -0
- package/dist/src/framework.js +73 -0
- package/dist/src/index.d.ts +19 -0
- package/dist/src/index.js +13 -0
- package/dist/src/mdast-gfm.d.ts +22 -0
- package/dist/src/mdast-gfm.js +90 -0
- package/dist/src/mermaid.d.ts +17 -0
- package/dist/src/mermaid.js +304 -0
- package/dist/src/parser.d.ts +26 -0
- package/dist/src/parser.js +423 -0
- package/dist/src/references.d.ts +33 -0
- package/dist/src/references.js +198 -0
- package/dist/src/report.d.ts +52 -0
- package/dist/src/report.js +278 -0
- package/dist/src/reviews.d.ts +19 -0
- package/dist/src/reviews.js +137 -0
- package/dist/src/runner.d.ts +94 -0
- package/dist/src/runner.js +353 -0
- package/dist/src/symbols.d.ts +15 -0
- package/dist/src/symbols.js +112 -0
- package/dist/src/types.d.ts +278 -0
- package/dist/src/types.js +27 -0
- package/package.json +70 -0
|
@@ -0,0 +1,353 @@
|
|
|
1
|
+
var __rewriteRelativeImportExtension = (this && this.__rewriteRelativeImportExtension) || function (path, preserveJsx) {
|
|
2
|
+
if (typeof path === "string" && /^\.\.?\//.test(path)) {
|
|
3
|
+
return path.replace(/\.(tsx)$|((?:\.d)?)((?:\.[^./]+?)?)\.([cm]?)ts$/i, function (m, tsx, d, ext, cm) {
|
|
4
|
+
return tsx ? preserveJsx ? ".jsx" : ".js" : d && (!ext || !cm) ? m : (d + ext + "." + cm.toLowerCase() + "js");
|
|
5
|
+
});
|
|
6
|
+
}
|
|
7
|
+
return path;
|
|
8
|
+
};
|
|
9
|
+
/**
|
|
10
|
+
* The execution engine.
|
|
11
|
+
*
|
|
12
|
+
* Loads glue code, parses a Markdown file, matches anchors to registered
|
|
13
|
+
* handlers, and runs them. Everything here is pure library -- the CLI in
|
|
14
|
+
* `check.ts` is a thin wrapper so the same engine can be driven from
|
|
15
|
+
* `bun test` (see `spec.test.ts`).
|
|
16
|
+
*/
|
|
17
|
+
import { resolve, dirname, basename, extname, join, relative } from 'node:path';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { readFile } from 'node:fs/promises';
|
|
20
|
+
import { findGlueHint, parseMarkdown } from "./parser.js";
|
|
21
|
+
import { checkReferences } from "./references.js";
|
|
22
|
+
import { checkReviews } from "./reviews.js";
|
|
23
|
+
import { getRegistrations, verify } from "./framework.js";
|
|
24
|
+
/** Parse and run one Markdown file against whatever is currently registered. */
|
|
25
|
+
export async function runFile(file, options = {}) {
|
|
26
|
+
const path = resolve(file);
|
|
27
|
+
const source = await readFile(path, 'utf8');
|
|
28
|
+
const parsed = parseMarkdown(source, file);
|
|
29
|
+
const run = await runParsed(parsed, options);
|
|
30
|
+
return { run, parsed };
|
|
31
|
+
}
|
|
32
|
+
/** Run an already-parsed document. */
|
|
33
|
+
export async function runParsed(parsed, options = {}) {
|
|
34
|
+
const started = performance.now();
|
|
35
|
+
const results = [];
|
|
36
|
+
let bailed = false;
|
|
37
|
+
for (const anchor of parsed.anchors) {
|
|
38
|
+
if (options.only?.length && !options.only.includes(anchor.id)) {
|
|
39
|
+
results.push(skipped(anchor, 'filtered out by --only'));
|
|
40
|
+
continue;
|
|
41
|
+
}
|
|
42
|
+
if (bailed) {
|
|
43
|
+
results.push(skipped(anchor, 'not run (--bail)'));
|
|
44
|
+
continue;
|
|
45
|
+
}
|
|
46
|
+
const result = await runAnchor(anchor, parsed.file, options);
|
|
47
|
+
results.push(result);
|
|
48
|
+
if (options.bail && result.status === 'failed')
|
|
49
|
+
bailed = true;
|
|
50
|
+
}
|
|
51
|
+
// Referential integrity of the surrounding prose. Skipped for in-memory
|
|
52
|
+
// documents, which have no directory to resolve relative links against.
|
|
53
|
+
const references = options.links === false
|
|
54
|
+
? []
|
|
55
|
+
: await checkReferences(parsed, { symbols: options.symbols });
|
|
56
|
+
const problems = [...parsed.problems, ...references];
|
|
57
|
+
// Reviews attest that a human read a section against the code behind it.
|
|
58
|
+
const reviews = checkReviews(parsed, { reviews: options.reviews });
|
|
59
|
+
const summary = {
|
|
60
|
+
anchors: results.length,
|
|
61
|
+
reviews: reviews.length,
|
|
62
|
+
reviewsStale: reviews.filter((r) => r.status === 'failed').length,
|
|
63
|
+
passed: results.filter((r) => r.status === 'passed').length,
|
|
64
|
+
failed: results.filter((r) => r.status === 'failed').length,
|
|
65
|
+
skipped: results.filter((r) => r.status === 'skipped').length,
|
|
66
|
+
cases: results.reduce((n, r) => n + r.cases.length, 0),
|
|
67
|
+
casesPassed: results.reduce((n, r) => n + r.cases.filter((x) => x.status === 'passed').length, 0),
|
|
68
|
+
casesFailed: results.reduce((n, r) => n + r.cases.filter((x) => x.status === 'failed').length, 0),
|
|
69
|
+
durationMs: performance.now() - started,
|
|
70
|
+
};
|
|
71
|
+
return {
|
|
72
|
+
file: parsed.file,
|
|
73
|
+
source: parsed.source,
|
|
74
|
+
anchors: results,
|
|
75
|
+
reviews,
|
|
76
|
+
problems,
|
|
77
|
+
// Structural problems are failures too -- a document that cannot bind, that
|
|
78
|
+
// points at things which no longer exist, or whose prose has not been read
|
|
79
|
+
// since the code moved, is not green.
|
|
80
|
+
ok: summary.failed === 0 && summary.reviewsStale === 0 && problems.length === 0,
|
|
81
|
+
summary,
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
/** Run every case for one anchor. */ /** Run every case for one anchor. */
|
|
85
|
+
export async function runAnchor(anchor, file, options = {}) {
|
|
86
|
+
const plan = planCases(anchor, file);
|
|
87
|
+
if (plan.skipReason)
|
|
88
|
+
return skipped(anchor, plan.skipReason);
|
|
89
|
+
if (plan.failReason) {
|
|
90
|
+
return { ...base(anchor), status: 'failed', reason: plan.failReason, cases: [] };
|
|
91
|
+
}
|
|
92
|
+
const results = [];
|
|
93
|
+
for (const kase of plan.cases) {
|
|
94
|
+
if (options.bail && results.some((r) => r.status === 'failed')) {
|
|
95
|
+
results.push({ name: kase.name, status: 'skipped', error: null, stack: null, durationMs: 0, line: kase.line });
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
results.push(await runPlanned(kase, options.timeout ?? 5000));
|
|
99
|
+
}
|
|
100
|
+
const failed = results.some((r) => r.status === 'failed');
|
|
101
|
+
return {
|
|
102
|
+
...base(anchor),
|
|
103
|
+
status: failed ? 'failed' : 'passed',
|
|
104
|
+
reason: null,
|
|
105
|
+
cases: results,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
/**
|
|
109
|
+
* Resolve an anchor into runnable cases without executing them, so a test
|
|
110
|
+
* framework can own the scheduling and reporting.
|
|
111
|
+
*
|
|
112
|
+
* This is the single planning path: `runAnchor` and `bun test` both consume
|
|
113
|
+
* it, so a defective row fails identically under either.
|
|
114
|
+
*/
|
|
115
|
+
export function planCases(anchor, file) {
|
|
116
|
+
const registrations = getRegistrations(anchor.id);
|
|
117
|
+
if (registrations.length === 0) {
|
|
118
|
+
return { skipReason: `no handler registered for \`${anchor.id}\``, failReason: null, cases: [] };
|
|
119
|
+
}
|
|
120
|
+
const wrongKind = registrations.find((r) => r.kind !== anchor.kind);
|
|
121
|
+
if (wrongKind) {
|
|
122
|
+
return {
|
|
123
|
+
skipReason: null,
|
|
124
|
+
failReason: `handler for \`${anchor.id}\` is registered as verify.${wrongKind.kind}, but the document binds it to a ${anchor.kind}`,
|
|
125
|
+
cases: [],
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
// The asset itself could not be read: fail the anchor, but keep it bound so
|
|
129
|
+
// the reason is written back into the document.
|
|
130
|
+
if (anchor.defect) {
|
|
131
|
+
return { skipReason: null, failReason: anchor.defect, cases: [] };
|
|
132
|
+
}
|
|
133
|
+
const ctx = {
|
|
134
|
+
id: anchor.id,
|
|
135
|
+
kind: anchor.kind,
|
|
136
|
+
label: anchor.label,
|
|
137
|
+
file,
|
|
138
|
+
line: anchor.line,
|
|
139
|
+
meta: anchor.meta,
|
|
140
|
+
};
|
|
141
|
+
const cases = [];
|
|
142
|
+
const defects = anchor.kind === 'table' ? anchor.data.defects : [];
|
|
143
|
+
// A row that failed to coerce is a failing case in its own right. It never
|
|
144
|
+
// reaches a handler, so glue code never sees a half-built row.
|
|
145
|
+
for (const defect of defects) {
|
|
146
|
+
cases.push({
|
|
147
|
+
name: `row ${defect.index + 1}`,
|
|
148
|
+
line: defect.line,
|
|
149
|
+
run: async () => {
|
|
150
|
+
throw new Error(defect.message);
|
|
151
|
+
},
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
for (const registration of registrations) {
|
|
155
|
+
// A whole-asset handler must not run against a table that is silently
|
|
156
|
+
// missing rows -- a coverage check would report phantom gaps.
|
|
157
|
+
if (registration.mode === 'all' && defects.length > 0) {
|
|
158
|
+
cases.push({
|
|
159
|
+
name: wholeName(anchor.kind),
|
|
160
|
+
line: anchor.line,
|
|
161
|
+
run: async () => {
|
|
162
|
+
throw new Error(`not run: ${defects.length} row(s) could not be read, so the ${anchor.kind} is incomplete`);
|
|
163
|
+
},
|
|
164
|
+
});
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
for (const kase of buildCases(anchor, registration.mode)) {
|
|
168
|
+
cases.push({
|
|
169
|
+
name: kase.name,
|
|
170
|
+
line: kase.line,
|
|
171
|
+
run: async () => {
|
|
172
|
+
await registration.fn(kase.payload, ctx);
|
|
173
|
+
},
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
return { skipReason: null, failReason: null, cases };
|
|
178
|
+
}
|
|
179
|
+
/** How a whole-asset case is named, in the terminal and in error comments. */
|
|
180
|
+
function wholeName(kind) {
|
|
181
|
+
return kind === 'mermaid' ? 'whole diagram' : `whole ${kind}`;
|
|
182
|
+
}
|
|
183
|
+
/** Fan an asset out into the cases a handler expects. */
|
|
184
|
+
function buildCases(anchor, mode) {
|
|
185
|
+
if (mode === 'all') {
|
|
186
|
+
return [{ name: wholeName(anchor.kind), line: anchor.line, payload: anchor.data }];
|
|
187
|
+
}
|
|
188
|
+
if (anchor.kind === 'table') {
|
|
189
|
+
const table = anchor.data;
|
|
190
|
+
return table.rows.map((row) => ({
|
|
191
|
+
name: `row ${row.$index + 1}`,
|
|
192
|
+
line: row.$line,
|
|
193
|
+
payload: row,
|
|
194
|
+
}));
|
|
195
|
+
}
|
|
196
|
+
if (anchor.kind === 'mermaid') {
|
|
197
|
+
const graph = anchor.data;
|
|
198
|
+
return graph.edges.map((edge) => ({
|
|
199
|
+
name: `${edge.from} ${edge.directed ? '->' : '--'} ${edge.to}`,
|
|
200
|
+
line: null,
|
|
201
|
+
payload: edge,
|
|
202
|
+
}));
|
|
203
|
+
}
|
|
204
|
+
const list = anchor.data;
|
|
205
|
+
return list.flat.map((item) => ({
|
|
206
|
+
name: truncate(item.text || `item ${item.index + 1}`, 48),
|
|
207
|
+
line: item.line,
|
|
208
|
+
payload: item,
|
|
209
|
+
}));
|
|
210
|
+
}
|
|
211
|
+
async function runPlanned(kase, timeout) {
|
|
212
|
+
const started = performance.now();
|
|
213
|
+
try {
|
|
214
|
+
const value = kase.run();
|
|
215
|
+
await (timeout > 0 ? withTimeout(value, timeout, kase.name) : value);
|
|
216
|
+
return {
|
|
217
|
+
name: kase.name,
|
|
218
|
+
status: 'passed',
|
|
219
|
+
error: null,
|
|
220
|
+
stack: null,
|
|
221
|
+
durationMs: performance.now() - started,
|
|
222
|
+
line: kase.line,
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
const error = err instanceof Error ? err : new Error(String(err));
|
|
227
|
+
return {
|
|
228
|
+
name: kase.name,
|
|
229
|
+
status: 'failed',
|
|
230
|
+
error: error.message || String(err),
|
|
231
|
+
stack: error.stack ?? null,
|
|
232
|
+
durationMs: performance.now() - started,
|
|
233
|
+
line: kase.line,
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
function withTimeout(promise, ms, label) {
|
|
238
|
+
return new Promise((res, rej) => {
|
|
239
|
+
const timer = setTimeout(() => rej(new Error(`timed out after ${ms}ms (${label})`)), ms);
|
|
240
|
+
promise.then((v) => { clearTimeout(timer); res(v); }, (e) => { clearTimeout(timer); rej(e); });
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
/**
|
|
244
|
+
* Load one document and everything needed to run it, in isolation.
|
|
245
|
+
*
|
|
246
|
+
* The registry is a module-level singleton, so two documents that happen to
|
|
247
|
+
* share an anchor id -- `prices` is not an unusual name -- collide if their
|
|
248
|
+
* glue files are simply imported into the same process. Bun shares module
|
|
249
|
+
* state across test files, so that is not hypothetical.
|
|
250
|
+
*
|
|
251
|
+
* This resets the registry, loads only this document's glue, and returns cases
|
|
252
|
+
* whose closures already hold their handler. A later `loadDocument` call may
|
|
253
|
+
* reset the registry again without disturbing them. Use this rather than
|
|
254
|
+
* importing glue directly when a process handles more than one document.
|
|
255
|
+
*/
|
|
256
|
+
export async function loadDocument(file, options = {}) {
|
|
257
|
+
const source = await readFile(file, 'utf8');
|
|
258
|
+
// Isolate: whatever a previous document registered is not ours.
|
|
259
|
+
verify.reset();
|
|
260
|
+
const parsed = parseMarkdown(source, file);
|
|
261
|
+
if (parsed.anchors.length > 0) {
|
|
262
|
+
const gluePath = resolveGlue(file, options.glue, source);
|
|
263
|
+
if (!gluePath) {
|
|
264
|
+
throw new Error(`${file}: no glue code found. Add a <!-- verify: ./x.verify.ts --> hint, ` +
|
|
265
|
+
`create ${basename(file, extname(file))}.verify.ts next to it, or pass { glue }.`);
|
|
266
|
+
}
|
|
267
|
+
await loadGlue(gluePath);
|
|
268
|
+
}
|
|
269
|
+
const suites = parsed.anchors.map((anchor) => {
|
|
270
|
+
const plan = planCases(anchor, file);
|
|
271
|
+
return {
|
|
272
|
+
id: anchor.id,
|
|
273
|
+
kind: anchor.kind,
|
|
274
|
+
label: anchor.label,
|
|
275
|
+
line: anchor.line,
|
|
276
|
+
skipReason: plan.skipReason,
|
|
277
|
+
failReason: plan.failReason,
|
|
278
|
+
cases: plan.cases,
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
const references = options.links === false ? [] : await checkReferences(parsed, { symbols: options.symbols });
|
|
282
|
+
return {
|
|
283
|
+
file,
|
|
284
|
+
parsed,
|
|
285
|
+
suites,
|
|
286
|
+
problems: [...parsed.problems, ...references],
|
|
287
|
+
reviews: checkReviews(parsed, { reviews: options.reviews }),
|
|
288
|
+
};
|
|
289
|
+
}
|
|
290
|
+
// ---------------------------------------------------------------------------
|
|
291
|
+
// glue-code loading
|
|
292
|
+
// ---------------------------------------------------------------------------
|
|
293
|
+
/** Conventional glue filenames tried next to `spec.md`, in order. */
|
|
294
|
+
const GLUE_SUFFIXES = ['.verify.ts', '.verify.js', '.spec.ts', '.test.ts'];
|
|
295
|
+
/**
|
|
296
|
+
* Find the glue file for a Markdown document: an explicit path wins, then a
|
|
297
|
+
* `<!-- verify: ./x.ts -->` hint in the document, then convention.
|
|
298
|
+
*/
|
|
299
|
+
export function resolveGlue(mdPath, explicit, source) {
|
|
300
|
+
if (explicit) {
|
|
301
|
+
const path = resolve(explicit);
|
|
302
|
+
if (!existsSync(path))
|
|
303
|
+
throw new Error(`glue file not found: ${explicit}`);
|
|
304
|
+
return path;
|
|
305
|
+
}
|
|
306
|
+
const dir = dirname(resolve(mdPath));
|
|
307
|
+
if (source) {
|
|
308
|
+
const hint = findGlueHint(source);
|
|
309
|
+
if (hint) {
|
|
310
|
+
const path = resolve(dir, hint);
|
|
311
|
+
if (!existsSync(path)) {
|
|
312
|
+
throw new Error(`glue file not found: ${hint} (from a <!-- verify: --> hint in ${basename(mdPath)})`);
|
|
313
|
+
}
|
|
314
|
+
return path;
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const stem = basename(mdPath, extname(mdPath));
|
|
318
|
+
for (const suffix of GLUE_SUFFIXES) {
|
|
319
|
+
const candidate = join(dir, stem + suffix);
|
|
320
|
+
if (existsSync(candidate))
|
|
321
|
+
return candidate;
|
|
322
|
+
}
|
|
323
|
+
return null;
|
|
324
|
+
}
|
|
325
|
+
/**
|
|
326
|
+
* Import a glue module, bypassing the module cache so repeat runs re-register.
|
|
327
|
+
*
|
|
328
|
+
* A glue file that throws while loading is a common authoring mistake, and the
|
|
329
|
+
* bare error says nothing about which file it came from. Callers add the
|
|
330
|
+
* document; this adds the glue file and keeps the original as `cause`.
|
|
331
|
+
*/
|
|
332
|
+
export async function loadGlue(path) {
|
|
333
|
+
try {
|
|
334
|
+
await import(__rewriteRelativeImportExtension(`${path}?v=${Date.now()}`));
|
|
335
|
+
}
|
|
336
|
+
catch (err) {
|
|
337
|
+
const cause = err instanceof Error ? err : new Error(String(err));
|
|
338
|
+
throw new Error(`glue file ${relative(process.cwd(), path)} failed to load: ${cause.message}`, { cause });
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
// ---------------------------------------------------------------------------
|
|
342
|
+
// helpers
|
|
343
|
+
// ---------------------------------------------------------------------------
|
|
344
|
+
function base(anchor) {
|
|
345
|
+
return { id: anchor.id, kind: anchor.kind, label: anchor.label, line: anchor.line };
|
|
346
|
+
}
|
|
347
|
+
function skipped(anchor, reason) {
|
|
348
|
+
return { ...base(anchor), status: 'skipped', reason, cases: [] };
|
|
349
|
+
}
|
|
350
|
+
function truncate(s, n) {
|
|
351
|
+
const flat = s.replace(/\s+/g, ' ').trim();
|
|
352
|
+
return flat.length > n ? flat.slice(0, n - 1) + '…' : flat;
|
|
353
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface SymbolInfo {
|
|
2
|
+
name: string;
|
|
3
|
+
/** Declaration source, excluding leading comments. */
|
|
4
|
+
text: string;
|
|
5
|
+
/** 1-based line of the declaration. */
|
|
6
|
+
line: number;
|
|
7
|
+
kind: string;
|
|
8
|
+
}
|
|
9
|
+
/** Every exported symbol in a file, keyed by name. */
|
|
10
|
+
export declare function exportedSymbols(path: string): Map<string, SymbolInfo> | Error;
|
|
11
|
+
/** Names only. */
|
|
12
|
+
export declare function exportedNames(path: string): Set<string> | Error;
|
|
13
|
+
/** One exported symbol's declaration, or `undefined` if there is no such export. */
|
|
14
|
+
export declare function exportedSymbol(path: string, name: string): SymbolInfo | Error | undefined;
|
|
15
|
+
export declare function clearSymbolCache(): void;
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Static symbol lookup, via the TypeScript compiler API.
|
|
3
|
+
*
|
|
4
|
+
* Two features need to know what a module exports and what a given export's
|
|
5
|
+
* source text is: link checking (`references.ts`) and review digests
|
|
6
|
+
* (`reviews.ts`).
|
|
7
|
+
*
|
|
8
|
+
* This reads the file rather than importing it. That matters for three
|
|
9
|
+
* reasons: importing a module runs it, which a lint has no business doing;
|
|
10
|
+
* type-only exports do not exist at runtime and so cannot be seen by an
|
|
11
|
+
* import; and a file that fails to load can still be read.
|
|
12
|
+
*
|
|
13
|
+
* The trade-off is that `export * from './x'` is not followed -- re-exported
|
|
14
|
+
* names are invisible here in a way they would not be to an import.
|
|
15
|
+
*/
|
|
16
|
+
import ts from 'typescript';
|
|
17
|
+
const fileCache = new Map();
|
|
18
|
+
/** Every exported symbol in a file, keyed by name. */
|
|
19
|
+
export function exportedSymbols(path) {
|
|
20
|
+
const cached = fileCache.get(path);
|
|
21
|
+
if (cached !== undefined)
|
|
22
|
+
return cached;
|
|
23
|
+
let result;
|
|
24
|
+
try {
|
|
25
|
+
result = read(path);
|
|
26
|
+
}
|
|
27
|
+
catch (err) {
|
|
28
|
+
result = err instanceof Error ? err : new Error(String(err));
|
|
29
|
+
}
|
|
30
|
+
fileCache.set(path, result);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
/** Names only. */
|
|
34
|
+
export function exportedNames(path) {
|
|
35
|
+
const symbols = exportedSymbols(path);
|
|
36
|
+
return symbols instanceof Error ? symbols : new Set(symbols.keys());
|
|
37
|
+
}
|
|
38
|
+
/** One exported symbol's declaration, or `undefined` if there is no such export. */
|
|
39
|
+
export function exportedSymbol(path, name) {
|
|
40
|
+
const symbols = exportedSymbols(path);
|
|
41
|
+
return symbols instanceof Error ? symbols : symbols.get(name);
|
|
42
|
+
}
|
|
43
|
+
export function clearSymbolCache() {
|
|
44
|
+
fileCache.clear();
|
|
45
|
+
}
|
|
46
|
+
// ---------------------------------------------------------------------------
|
|
47
|
+
function read(path) {
|
|
48
|
+
const text = ts.sys.readFile(path);
|
|
49
|
+
if (text === undefined)
|
|
50
|
+
throw new Error('could not read file');
|
|
51
|
+
const source = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true);
|
|
52
|
+
const found = new Map();
|
|
53
|
+
const add = (name, node, kind) => {
|
|
54
|
+
if (found.has(name))
|
|
55
|
+
return;
|
|
56
|
+
found.set(name, {
|
|
57
|
+
name,
|
|
58
|
+
text: node.getText(source),
|
|
59
|
+
line: source.getLineAndCharacterOfPosition(node.getStart(source)).line + 1,
|
|
60
|
+
kind,
|
|
61
|
+
});
|
|
62
|
+
};
|
|
63
|
+
for (const statement of source.statements) {
|
|
64
|
+
if (!isExported(statement)) {
|
|
65
|
+
// `export { a, b }` carries no modifier of its own.
|
|
66
|
+
if (ts.isExportDeclaration(statement) && statement.exportClause && ts.isNamedExports(statement.exportClause)) {
|
|
67
|
+
for (const element of statement.exportClause.elements) {
|
|
68
|
+
add(element.name.text, element, 'export');
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (ts.isVariableStatement(statement)) {
|
|
74
|
+
for (const declaration of statement.declarationList.declarations) {
|
|
75
|
+
if (ts.isIdentifier(declaration.name)) {
|
|
76
|
+
add(declaration.name.text, declaration, 'variable');
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
if (isDefault(statement)) {
|
|
82
|
+
add('default', statement, 'default');
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
const name = statement.name;
|
|
86
|
+
if (name && ts.isIdentifier(name)) {
|
|
87
|
+
add(name.text, statement, kindOf(statement));
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
return found;
|
|
91
|
+
}
|
|
92
|
+
function isExported(node) {
|
|
93
|
+
return Boolean(ts.canHaveModifiers(node) &&
|
|
94
|
+
ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.ExportKeyword));
|
|
95
|
+
}
|
|
96
|
+
function isDefault(node) {
|
|
97
|
+
return Boolean(ts.canHaveModifiers(node) &&
|
|
98
|
+
ts.getModifiers(node)?.some((m) => m.kind === ts.SyntaxKind.DefaultKeyword));
|
|
99
|
+
}
|
|
100
|
+
function kindOf(node) {
|
|
101
|
+
if (ts.isFunctionDeclaration(node))
|
|
102
|
+
return 'function';
|
|
103
|
+
if (ts.isClassDeclaration(node))
|
|
104
|
+
return 'class';
|
|
105
|
+
if (ts.isInterfaceDeclaration(node))
|
|
106
|
+
return 'interface';
|
|
107
|
+
if (ts.isTypeAliasDeclaration(node))
|
|
108
|
+
return 'type';
|
|
109
|
+
if (ts.isEnumDeclaration(node))
|
|
110
|
+
return 'enum';
|
|
111
|
+
return 'declaration';
|
|
112
|
+
}
|