@ontrails/warden 1.0.0-beta.45 → 1.0.0-beta.47

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.
@@ -0,0 +1,484 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { posix } from 'node:path';
3
+ import { z } from 'zod';
4
+
5
+ const sha256Schema = z.string().regex(/^[0-9a-f]{64}$/);
6
+ const gitObjectIdSchema = z.string().regex(/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/);
7
+ const countSchema = z.number().int().nonnegative();
8
+ const vocabularyDispositionValues = [
9
+ 'code-context-out-of-engine',
10
+ 'docs-only',
11
+ 'explicit-preserve',
12
+ 'forward-pointer',
13
+ 'historical-by-policy',
14
+ 'ignored-by-scope',
15
+ 'in-family-modified',
16
+ 'in-family-unresolved',
17
+ 'out-of-family',
18
+ 'preserve-current-live-api',
19
+ ] as const;
20
+
21
+ const scopeSchema = z
22
+ .object({
23
+ exclude: z.array(z.string()).optional(),
24
+ extensions: z.array(z.string()).optional(),
25
+ include: z.array(z.string()).optional(),
26
+ })
27
+ .strict();
28
+
29
+ const vocabularyScopeSchema = scopeSchema.extend({
30
+ ignoredDirectories: z.array(z.string()).optional(),
31
+ policyClassified: z
32
+ .array(
33
+ z
34
+ .object({
35
+ disposition: z.enum(vocabularyDispositionValues),
36
+ expectMatches: z.boolean().optional(),
37
+ paths: z.array(z.string().min(1)).min(1),
38
+ reason: z.string().min(1),
39
+ })
40
+ .strict()
41
+ )
42
+ .optional(),
43
+ teachingSurfaces: z.array(z.string().min(1)).optional(),
44
+ });
45
+
46
+ const vocabularyPlanSchema = z
47
+ .object({
48
+ caseSensitive: z.boolean().optional(),
49
+ deferForms: z.array(z.string().min(1)).optional(),
50
+ fileRenames: z
51
+ .array(
52
+ z.object({ from: z.string().min(1), to: z.string().min(1) }).strict()
53
+ )
54
+ .optional(),
55
+ from: z.string().min(1),
56
+ id: z.string().optional(),
57
+ intent: z.string().optional(),
58
+ kind: z.literal('vocabulary'),
59
+ overrides: z.record(z.string().min(1), z.string().min(1)).optional(),
60
+ preserve: z
61
+ .array(
62
+ z
63
+ .object({
64
+ disposition: z.enum(vocabularyDispositionValues).optional(),
65
+ forms: z.array(z.string().min(1)).optional(),
66
+ paths: z.array(z.string()).optional(),
67
+ pattern: z.string(),
68
+ reason: z.string().optional(),
69
+ })
70
+ .strict()
71
+ )
72
+ .optional(),
73
+ scope: vocabularyScopeSchema.optional(),
74
+ to: z.string().min(1),
75
+ })
76
+ .strict();
77
+
78
+ const classPlanSchema = z
79
+ .object({
80
+ classIds: z.array(z.string().min(1)).min(1),
81
+ id: z.string().min(1),
82
+ intent: z.string().optional(),
83
+ kind: z.literal('class'),
84
+ name: z.string().min(1).optional(),
85
+ scope: scopeSchema.optional(),
86
+ })
87
+ .strict();
88
+
89
+ const planSchema = z.discriminatedUnion('kind', [
90
+ vocabularyPlanSchema,
91
+ classPlanSchema,
92
+ ]);
93
+
94
+ const planProvenanceSchema = z
95
+ .object({
96
+ fields: z.record(z.string(), z.enum(['authored', 'derived'])),
97
+ })
98
+ .strict();
99
+
100
+ const formJudgmentSchema = z
101
+ .object({
102
+ disposition: z.enum(['mapped', 'out-of-family', 'preserved', 'unresolved']),
103
+ form: z.string().min(1),
104
+ reason: z.string().min(1).optional(),
105
+ representative: z
106
+ .object({ line: z.number().int().positive(), path: z.string().min(1) })
107
+ .strict()
108
+ .optional(),
109
+ target: z.string().min(1).optional(),
110
+ })
111
+ .strict();
112
+
113
+ const completionSchema = z
114
+ .object({
115
+ counts: z
116
+ .object({
117
+ dispositions: z.record(z.string().min(1), countSchema),
118
+ matched: countSchema,
119
+ preserved: countSchema,
120
+ review: countSchema,
121
+ rewritten: countSchema,
122
+ skippedByReason: z.record(z.string().min(1), countSchema),
123
+ unknown: countSchema,
124
+ })
125
+ .strict(),
126
+ gate: z
127
+ .object({
128
+ reasons: z.array(z.string().min(1)),
129
+ remaining: countSchema,
130
+ status: z.enum(['green', 'open']),
131
+ })
132
+ .strict(),
133
+ metrics: z
134
+ .object({
135
+ filesChanged: countSchema,
136
+ formsMapped: countSchema,
137
+ occurrencesRewritten: countSchema,
138
+ })
139
+ .strict(),
140
+ })
141
+ .strict();
142
+
143
+ const runSchema = z
144
+ .object({
145
+ classifiedState: z.discriminatedUnion('kind', [
146
+ z
147
+ .object({
148
+ caseSensitive: z.boolean(),
149
+ forms: z.array(formJudgmentSchema),
150
+ kind: z.literal('embedded'),
151
+ stateHash: sha256Schema,
152
+ })
153
+ .strict(),
154
+ z
155
+ .object({ kind: z.literal('reference'), stateHash: sha256Schema })
156
+ .strict(),
157
+ ]),
158
+ completion: completionSchema,
159
+ evidence: z
160
+ .object({
161
+ changedFiles: z.array(
162
+ z
163
+ .object({
164
+ afterBlobHash: gitObjectIdSchema,
165
+ afterPath: z.string().min(1),
166
+ beforeBlobHash: gitObjectIdSchema,
167
+ beforePath: z.string().min(1),
168
+ })
169
+ .strict()
170
+ ),
171
+ detailEvidenceHash: sha256Schema,
172
+ lockStateHash: sha256Schema,
173
+ policyHash: sha256Schema,
174
+ sourceRevision: gitObjectIdSchema,
175
+ sourceStateHash: sha256Schema,
176
+ toolVersion: z.string().min(1),
177
+ })
178
+ .strict(),
179
+ intent: z.discriminatedUnion('kind', [
180
+ z
181
+ .object({
182
+ kind: z.literal('embedded'),
183
+ plan: planSchema,
184
+ planContentHash: sha256Schema,
185
+ provenance: planProvenanceSchema,
186
+ })
187
+ .strict(),
188
+ z
189
+ .object({ kind: z.literal('reference'), planContentHash: sha256Schema })
190
+ .strict(),
191
+ ]),
192
+ project: z.object({ root: z.literal('.') }).strict(),
193
+ runId: z.string().min(1),
194
+ runKind: z.enum(['original', 'adjust', 'proof']),
195
+ timestamp: z.iso.datetime(),
196
+ transitionId: z.string().min(1),
197
+ })
198
+ .strict();
199
+
200
+ const receiptSchema = z
201
+ .object({
202
+ conversion: z
203
+ .object({
204
+ convertedAt: z.iso.datetime(),
205
+ fromSchemaVersion: z.literal(2),
206
+ sourceContentHash: sha256Schema,
207
+ toolVersion: z.string().min(1),
208
+ })
209
+ .strict()
210
+ .optional(),
211
+ id: z.string().min(1),
212
+ kind: z.literal('regrade-history'),
213
+ path: z.string().min(1),
214
+ runs: z.array(runSchema).min(1),
215
+ schemaVersion: z.literal(3),
216
+ })
217
+ .strict();
218
+
219
+ type Receipt = z.output<typeof receiptSchema>;
220
+ type Plan = z.output<typeof planSchema>;
221
+ type FormJudgment = z.output<typeof formJudgmentSchema>;
222
+
223
+ export interface WardenResolvedReceipt {
224
+ readonly artifact: Receipt;
225
+ readonly runs: readonly {
226
+ readonly caseSensitive: boolean;
227
+ readonly forms: readonly FormJudgment[];
228
+ readonly plan: Plan;
229
+ }[];
230
+ }
231
+
232
+ const isPlainObject = (value: unknown): value is Record<string, unknown> =>
233
+ typeof value === 'object' && value !== null && !Array.isArray(value);
234
+
235
+ const canonicalize = (value: unknown): unknown => {
236
+ if (Array.isArray(value)) {
237
+ return value.map(canonicalize);
238
+ }
239
+ if (!isPlainObject(value)) {
240
+ return value;
241
+ }
242
+ return Object.fromEntries(
243
+ Object.keys(value)
244
+ .toSorted()
245
+ .map((key) => [key, canonicalize(value[key])])
246
+ );
247
+ };
248
+
249
+ const contentHash = (value: unknown): string =>
250
+ createHash('sha256')
251
+ .update(JSON.stringify(canonicalize(value)))
252
+ .digest('hex');
253
+
254
+ const compareCodeUnits = (left: string, right: string): number => {
255
+ if (left < right) {
256
+ return -1;
257
+ }
258
+ if (left > right) {
259
+ return 1;
260
+ }
261
+ return 0;
262
+ };
263
+
264
+ const stateHash = (state: {
265
+ readonly caseSensitive: boolean;
266
+ readonly forms: readonly FormJudgment[];
267
+ }): string =>
268
+ contentHash({
269
+ caseSensitive: state.caseSensitive,
270
+ forms: [...state.forms].toSorted(
271
+ (left, right) =>
272
+ compareCodeUnits(left.form, right.form) ||
273
+ compareCodeUnits(left.disposition, right.disposition)
274
+ ),
275
+ });
276
+
277
+ const validRootRelativePath = (value: string): boolean =>
278
+ value.length > 0 &&
279
+ value !== '.' &&
280
+ !posix.isAbsolute(value) &&
281
+ !/^[A-Za-z]:[\\/]/u.test(value) &&
282
+ !value.startsWith('\\\\') &&
283
+ !value.includes('\\') &&
284
+ !value.includes('\u0000') &&
285
+ posix.normalize(value) === value &&
286
+ !value.startsWith('./') &&
287
+ value !== '..' &&
288
+ !value.startsWith('../') &&
289
+ !value.includes('/../');
290
+
291
+ const planPaths = (plan: Plan): readonly string[] => {
292
+ const common = [
293
+ ...(plan.scope?.include ?? []),
294
+ ...(plan.scope?.exclude ?? []),
295
+ ];
296
+ if (plan.kind === 'class') {
297
+ return common;
298
+ }
299
+ return [
300
+ ...common,
301
+ ...(plan.scope?.ignoredDirectories ?? []),
302
+ ...(plan.scope?.teachingSurfaces ?? []),
303
+ ...(plan.scope?.policyClassified ?? []).flatMap((item) => item.paths),
304
+ ...(plan.fileRenames ?? []).flatMap((rename) => [rename.from, rename.to]),
305
+ ...(plan.preserve ?? []).flatMap((item) => item.paths ?? []),
306
+ ];
307
+ };
308
+
309
+ const proofIsMinimal = (run: Receipt['runs'][number]): boolean => {
310
+ const { counts, gate, metrics } = run.completion;
311
+ return (
312
+ run.intent.kind === 'reference' &&
313
+ run.classifiedState.kind === 'reference' &&
314
+ run.evidence.changedFiles.length === 0 &&
315
+ gate.status === 'green' &&
316
+ gate.remaining === 0 &&
317
+ gate.reasons.length === 0 &&
318
+ counts.matched === 0 &&
319
+ counts.preserved === 0 &&
320
+ counts.review === 0 &&
321
+ counts.rewritten === 0 &&
322
+ counts.unknown === 0 &&
323
+ Object.keys(counts.dispositions).length === 0 &&
324
+ Object.keys(counts.skippedByReason).length === 0 &&
325
+ metrics.filesChanged === 0 &&
326
+ metrics.formsMapped === 0 &&
327
+ metrics.occurrencesRewritten === 0
328
+ );
329
+ };
330
+
331
+ type ReceiptRun = Receipt['runs'][number];
332
+ interface ResolvedState {
333
+ readonly caseSensitive: boolean;
334
+ readonly forms: readonly FormJudgment[];
335
+ }
336
+ type Resolution<T> =
337
+ | { readonly ok: true; readonly value: T }
338
+ | { readonly error: string; readonly ok: false };
339
+
340
+ const validateRunEvidence = (run: ReceiptRun): string | undefined => {
341
+ const beforePaths = new Set<string>();
342
+ const afterPaths = new Set<string>();
343
+ for (const file of run.evidence.changedFiles) {
344
+ if (
345
+ !validRootRelativePath(file.beforePath) ||
346
+ !validRootRelativePath(file.afterPath) ||
347
+ beforePaths.has(file.beforePath) ||
348
+ afterPaths.has(file.afterPath) ||
349
+ (file.beforePath === file.afterPath &&
350
+ file.beforeBlobHash === file.afterBlobHash)
351
+ ) {
352
+ return 'invalid changed-file evidence';
353
+ }
354
+ beforePaths.add(file.beforePath);
355
+ afterPaths.add(file.afterPath);
356
+ }
357
+ if (
358
+ run.completion.metrics.filesChanged !== run.evidence.changedFiles.length
359
+ ) {
360
+ return 'changed-file count mismatch';
361
+ }
362
+ const { counts, gate, metrics } = run.completion;
363
+ const hasUnresolvedCounts = counts.review > 0 || counts.unknown > 0;
364
+ const gateIsCoherent =
365
+ gate.status === 'green'
366
+ ? gate.remaining === 0 &&
367
+ gate.reasons.length === 0 &&
368
+ !hasUnresolvedCounts
369
+ : gate.reasons.length > 0;
370
+ if (counts.rewritten !== metrics.occurrencesRewritten || !gateIsCoherent) {
371
+ return 'incoherent completion facts';
372
+ }
373
+ return undefined;
374
+ };
375
+
376
+ const resolvePlan = (
377
+ run: ReceiptRun,
378
+ plans: Map<string, Plan>
379
+ ): Resolution<Plan> => {
380
+ if (run.intent.kind === 'reference') {
381
+ const plan = plans.get(run.intent.planContentHash);
382
+ return plan === undefined
383
+ ? { error: 'broken plan reference', ok: false }
384
+ : { ok: true, value: plan };
385
+ }
386
+ if (
387
+ contentHash({
388
+ plan: run.intent.plan,
389
+ provenance: run.intent.provenance,
390
+ }) !== run.intent.planContentHash ||
391
+ planPaths(run.intent.plan).some((path) => !validRootRelativePath(path))
392
+ ) {
393
+ return { error: 'invalid embedded plan', ok: false };
394
+ }
395
+ plans.set(run.intent.planContentHash, run.intent.plan);
396
+ return { ok: true, value: run.intent.plan };
397
+ };
398
+
399
+ const resolveState = (
400
+ run: ReceiptRun,
401
+ states: Map<string, ResolvedState>
402
+ ): Resolution<ResolvedState> => {
403
+ if (run.classifiedState.kind === 'reference') {
404
+ const state = states.get(run.classifiedState.stateHash);
405
+ return state === undefined
406
+ ? { error: 'broken classified state reference', ok: false }
407
+ : { ok: true, value: state };
408
+ }
409
+ const { caseSensitive, forms } = run.classifiedState;
410
+ const identities = new Set(
411
+ forms.map((form) => (caseSensitive ? form.form : form.form.toLowerCase()))
412
+ );
413
+ if (
414
+ identities.size !== forms.length ||
415
+ forms.some(
416
+ (form) =>
417
+ (form.disposition === 'mapped') !== (form.target !== undefined) ||
418
+ (form.representative !== undefined &&
419
+ !validRootRelativePath(form.representative.path))
420
+ )
421
+ ) {
422
+ return { error: 'invalid classified form state', ok: false };
423
+ }
424
+ const state = { caseSensitive, forms };
425
+ if (stateHash(state) !== run.classifiedState.stateHash) {
426
+ return { error: 'classified state hash mismatch', ok: false };
427
+ }
428
+ states.set(run.classifiedState.stateHash, state);
429
+ return { ok: true, value: state };
430
+ };
431
+
432
+ /** Validate and resolve only the compact receipt facts consumed by Warden. */
433
+ export const resolveReceiptForWarden = (
434
+ value: unknown
435
+ ): { readonly error?: string; readonly value?: WardenResolvedReceipt } => {
436
+ const parsed = receiptSchema.safeParse(value);
437
+ if (!parsed.success) {
438
+ return { error: 'invalid evidence shape' };
439
+ }
440
+ const artifact = parsed.data;
441
+ if (
442
+ !validRootRelativePath(artifact.path) ||
443
+ !/^\.trails\/regrade\/history\/[^/]+\.json$/u.test(artifact.path)
444
+ ) {
445
+ return { error: 'invalid receipt path' };
446
+ }
447
+
448
+ const plans = new Map<string, Plan>();
449
+ const states = new Map<string, ResolvedState>();
450
+ const runIds = new Set<string>();
451
+ const runs: WardenResolvedReceipt['runs'][number][] = [];
452
+
453
+ for (const run of artifact.runs) {
454
+ if (run.transitionId !== artifact.id || runIds.has(run.runId)) {
455
+ return { error: 'invalid transition or duplicate run identity' };
456
+ }
457
+ runIds.add(run.runId);
458
+ if (run.runKind === 'proof' && !proofIsMinimal(run)) {
459
+ return { error: 'invalid proof receipt' };
460
+ }
461
+ if (run.runKind !== 'proof' && run.intent.kind !== 'embedded') {
462
+ return { error: 'non-proof receipt must embed intent' };
463
+ }
464
+
465
+ const evidenceError = validateRunEvidence(run);
466
+ if (evidenceError !== undefined) {
467
+ return { error: evidenceError };
468
+ }
469
+ const plan = resolvePlan(run, plans);
470
+ if (!plan.ok) {
471
+ return { error: plan.error };
472
+ }
473
+ const state = resolveState(run, states);
474
+ if (!state.ok) {
475
+ return { error: state.error };
476
+ }
477
+ runs.push({
478
+ caseSensitive: state.value.caseSensitive,
479
+ forms: state.value.forms,
480
+ plan: plan.value,
481
+ });
482
+ }
483
+ return { value: { artifact, runs } };
484
+ };
@@ -286,6 +286,10 @@ const reviewReasonFor = (
286
286
  }
287
287
  }
288
288
 
289
+ if (match.rename.safety === 'review') {
290
+ return 'requires semantic classification';
291
+ }
292
+
289
293
  if (shorthandIdentifiers.has(match.from)) {
290
294
  return 'participates in an authored shorthand property';
291
295
  }
@@ -1,6 +1,6 @@
1
1
  import type {
2
2
  GovernedVocabularyHistoryEvidence,
3
- GovernedVocabularyHistoryFormObservation,
3
+ GovernedVocabularyHistoryFormJudgment,
4
4
  ProjectAwareWardenRule,
5
5
  ProjectContext,
6
6
  WardenDiagnostic,
@@ -14,18 +14,19 @@ const formIdentity = (
14
14
  ): string => (evidence.caseSensitive ? form : form.toLowerCase());
15
15
 
16
16
  const isUnknownPermutation = (
17
- observation: GovernedVocabularyHistoryFormObservation
17
+ judgment: GovernedVocabularyHistoryFormJudgment
18
18
  ): boolean =>
19
- observation.reason === 'unclassified-neighbor' &&
20
- observation.verdict === 'deferred' &&
21
- (observation.scopeTier === undefined || observation.scopeTier === 'in-scope');
19
+ judgment.disposition === 'unresolved' &&
20
+ judgment.reason === 'unclassified-neighbor';
22
21
 
23
22
  const compareObservation = (
24
- left: GovernedVocabularyHistoryFormObservation,
25
- right: GovernedVocabularyHistoryFormObservation
23
+ left: GovernedVocabularyHistoryFormJudgment,
24
+ right: GovernedVocabularyHistoryFormJudgment
26
25
  ): number =>
27
- left.path.localeCompare(right.path) ||
28
- left.line - right.line ||
26
+ (left.representative?.path ?? '').localeCompare(
27
+ right.representative?.path ?? ''
28
+ ) ||
29
+ (left.representative?.line ?? 1) - (right.representative?.line ?? 1) ||
29
30
  left.form.localeCompare(right.form);
30
31
 
31
32
  const diagnosticsForHistory = (
@@ -33,26 +34,26 @@ const diagnosticsForHistory = (
33
34
  ): readonly WardenDiagnostic[] => {
34
35
  const unknownByIdentity = new Map<
35
36
  string,
36
- GovernedVocabularyHistoryFormObservation
37
+ GovernedVocabularyHistoryFormJudgment
37
38
  >();
38
39
 
39
- for (const observation of evidence.latestFormObservations) {
40
- if (!isUnknownPermutation(observation)) {
40
+ for (const judgment of evidence.latestFormJudgments) {
41
+ if (!isUnknownPermutation(judgment)) {
41
42
  continue;
42
43
  }
43
- const identity = formIdentity(observation.form, evidence);
44
+ const identity = formIdentity(judgment.form, evidence);
44
45
  const current = unknownByIdentity.get(identity);
45
- if (current === undefined || compareObservation(observation, current) < 0) {
46
- unknownByIdentity.set(identity, observation);
46
+ if (current === undefined || compareObservation(judgment, current) < 0) {
47
+ unknownByIdentity.set(identity, judgment);
47
48
  }
48
49
  }
49
50
 
50
51
  return [...unknownByIdentity.entries()]
51
52
  .toSorted(([left], [right]) => left.localeCompare(right))
52
- .map(([, observation]) => ({
53
- filePath: observation.path,
54
- line: observation.line,
55
- message: `Governed transition '${evidence.transitionId}' recorded unknown vocabulary form '${observation.form}' in committed Regrade history '${evidence.path}' (${evidence.id}). Add the form and run an incremental plan, or classify it as out-of-family or preserved.`,
53
+ .map(([, judgment]) => ({
54
+ filePath: judgment.representative?.path ?? evidence.path,
55
+ line: judgment.representative?.line ?? 1,
56
+ message: `Governed transition '${evidence.transitionId}' recorded unknown vocabulary form '${judgment.form}' in committed Regrade history '${evidence.path}' (${evidence.id}). Add the form and run an incremental plan, or classify it as out-of-family or preserved.`,
56
57
  rule: RULE_NAME,
57
58
  severity: 'warn' as const,
58
59
  }));
@@ -81,7 +81,7 @@ import { webhookRouteCollision } from './webhook-route-collision.js';
81
81
 
82
82
  export type {
83
83
  GovernedVocabularyHistoryEvidence,
84
- GovernedVocabularyHistoryFormObservation,
84
+ GovernedVocabularyHistoryFormJudgment,
85
85
  GovernedVocabularyHistoryIssue,
86
86
  WardenFix,
87
87
  WardenFixCapability,
@@ -44,8 +44,9 @@ import {
44
44
  getNodeValue,
45
45
  offsetToLine,
46
46
  parse,
47
+ parseWithDiagnostics,
47
48
  } from '@ontrails/source';
48
- import type { AstNode } from '@ontrails/source';
49
+ import type { AstNode, SourceComment } from '@ontrails/source';
49
50
  import type { WardenDiagnostic, WardenRule } from './types.js';
50
51
 
51
52
  const RULE_NAME = 'public-export-example-coverage';
@@ -379,44 +380,19 @@ const declarationNameMatches = (
379
380
  return false;
380
381
  };
381
382
 
382
- /**
383
- * Comment texts found in an inter-statement trivia gap. The gap between two
384
- * top-level statements contains only whitespace and comments, so a small
385
- * line/block comment scan recovers the same comment set TypeScript's
386
- * `getLeadingCommentRanges` returns for the statement's full start.
387
- */
388
- const collectCommentTexts = (gapText: string): readonly string[] => {
389
- const comments: string[] = [];
390
- let index = 0;
391
- while (index < gapText.length - 1) {
392
- if (gapText[index] === '/' && gapText[index + 1] === '/') {
393
- const lineEnd = gapText.indexOf('\n', index);
394
- const stop = lineEnd === -1 ? gapText.length : lineEnd;
395
- comments.push(gapText.slice(index, stop));
396
- index = stop + 1;
397
- } else if (gapText[index] === '/' && gapText[index + 1] === '*') {
398
- const blockEnd = gapText.indexOf('*/', index + 2);
399
- const stop = blockEnd === -1 ? gapText.length : blockEnd + 2;
400
- comments.push(gapText.slice(index, stop));
401
- index = stop;
402
- } else {
403
- index += 1;
404
- }
405
- }
406
- return comments;
407
- };
408
-
409
383
  const EXAMPLE_TAG_PATTERN = /@example\b/;
384
+ const LINE_TERMINATOR_PATTERN = /[\n\r\u2028\u2029]/u;
410
385
 
411
386
  /**
412
387
  * True when the exported declaration named `importedName` in `sourceText`
413
- * carries a leading comment containing `@example`. Leading comments are
414
- * recovered from the trivia gap between the preceding top-level statement's
415
- * end (or file start) and the matching export statement's start.
388
+ * carries a leading comment containing `@example`. Parser-native comments in
389
+ * the trivia gap between the preceding top-level statement's end (or file
390
+ * start) and the matching export statement are considered leading.
416
391
  */
417
392
  const hasLeadingExampleForExport = (
418
393
  sourceText: string,
419
394
  ast: AstNode,
395
+ comments: readonly SourceComment[],
420
396
  importedName: string
421
397
  ): boolean => {
422
398
  const body = programBody(ast);
@@ -429,9 +405,16 @@ const hasLeadingExampleForExport = (
429
405
  continue;
430
406
  }
431
407
  const previous = body[statementIndex - 1];
432
- const gapText = sourceText.slice(previous?.end ?? 0, statement.start);
433
- return collectCommentTexts(gapText).some((comment) =>
434
- EXAMPLE_TAG_PATTERN.test(comment)
408
+ const gapStart = previous?.end ?? 0;
409
+ return comments.some(
410
+ (comment) =>
411
+ gapStart <= comment.start &&
412
+ comment.end <= statement.start &&
413
+ (previous === undefined ||
414
+ LINE_TERMINATOR_PATTERN.test(
415
+ sourceText.slice(gapStart, comment.start)
416
+ )) &&
417
+ EXAMPLE_TAG_PATTERN.test(comment.value)
435
418
  );
436
419
  }
437
420
  return false;
@@ -468,8 +451,8 @@ const coverageDiagnosticsForSpecifier = (
468
451
  ),
469
452
  ];
470
453
  }
471
- const sourceAst = parse(sourcePath, sourceText);
472
- if (!sourceAst) {
454
+ const parsedSource = parseWithDiagnostics(sourcePath, sourceText);
455
+ if (!parsedSource.ast || parsedSource.diagnostics.length > 0) {
473
456
  return [
474
457
  diagnostic(
475
458
  sourceCode,
@@ -481,7 +464,12 @@ const coverageDiagnosticsForSpecifier = (
481
464
  ];
482
465
  }
483
466
  if (
484
- hasLeadingExampleForExport(sourceText, sourceAst, specifier.importedName)
467
+ hasLeadingExampleForExport(
468
+ sourceText,
469
+ parsedSource.ast,
470
+ parsedSource.comments,
471
+ specifier.importedName
472
+ )
485
473
  ) {
486
474
  return [];
487
475
  }