dsh-data-quality 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.
Files changed (91) hide show
  1. package/CHANGELOG.md +16 -0
  2. package/LICENSE +201 -0
  3. package/README.es.md +181 -0
  4. package/README.hi.md +181 -0
  5. package/README.md +181 -0
  6. package/README.pt.md +181 -0
  7. package/README.zh.md +181 -0
  8. package/THIRD_PARTY_NOTICES.md +20 -0
  9. package/cordis.patch.yml +46 -0
  10. package/lib/index.js +2458 -0
  11. package/lib/types/clean.d.ts +82 -0
  12. package/lib/types/clean.d.ts.map +1 -0
  13. package/lib/types/clean.js +351 -0
  14. package/lib/types/clean.js.map +1 -0
  15. package/lib/types/config.d.ts +47 -0
  16. package/lib/types/config.d.ts.map +1 -0
  17. package/lib/types/config.js +66 -0
  18. package/lib/types/config.js.map +1 -0
  19. package/lib/types/dataset.d.ts +133 -0
  20. package/lib/types/dataset.d.ts.map +1 -0
  21. package/lib/types/dataset.js +404 -0
  22. package/lib/types/dataset.js.map +1 -0
  23. package/lib/types/events.d.ts +73 -0
  24. package/lib/types/events.d.ts.map +1 -0
  25. package/lib/types/events.js +41 -0
  26. package/lib/types/events.js.map +1 -0
  27. package/lib/types/index.d.ts +45 -0
  28. package/lib/types/index.d.ts.map +1 -0
  29. package/lib/types/index.js +78 -0
  30. package/lib/types/index.js.map +1 -0
  31. package/lib/types/present.d.ts +24 -0
  32. package/lib/types/present.d.ts.map +1 -0
  33. package/lib/types/present.js +34 -0
  34. package/lib/types/present.js.map +1 -0
  35. package/lib/types/profile.d.ts +79 -0
  36. package/lib/types/profile.d.ts.map +1 -0
  37. package/lib/types/profile.js +196 -0
  38. package/lib/types/profile.js.map +1 -0
  39. package/lib/types/provider-local.d.ts +56 -0
  40. package/lib/types/provider-local.d.ts.map +1 -0
  41. package/lib/types/provider-local.js +163 -0
  42. package/lib/types/provider-local.js.map +1 -0
  43. package/lib/types/service.d.ts +160 -0
  44. package/lib/types/service.d.ts.map +1 -0
  45. package/lib/types/service.js +25 -0
  46. package/lib/types/service.js.map +1 -0
  47. package/lib/types/store.d.ts +61 -0
  48. package/lib/types/store.d.ts.map +1 -0
  49. package/lib/types/store.js +42 -0
  50. package/lib/types/store.js.map +1 -0
  51. package/lib/types/tools/clean.d.ts +14 -0
  52. package/lib/types/tools/clean.d.ts.map +1 -0
  53. package/lib/types/tools/clean.js +146 -0
  54. package/lib/types/tools/clean.js.map +1 -0
  55. package/lib/types/tools/profile.d.ts +13 -0
  56. package/lib/types/tools/profile.d.ts.map +1 -0
  57. package/lib/types/tools/profile.js +91 -0
  58. package/lib/types/tools/profile.js.map +1 -0
  59. package/lib/types/tools/shared.d.ts +19 -0
  60. package/lib/types/tools/shared.d.ts.map +1 -0
  61. package/lib/types/tools/shared.js +44 -0
  62. package/lib/types/tools/shared.js.map +1 -0
  63. package/lib/types/tools/verify.d.ts +14 -0
  64. package/lib/types/tools/verify.d.ts.map +1 -0
  65. package/lib/types/tools/verify.js +160 -0
  66. package/lib/types/tools/verify.js.map +1 -0
  67. package/lib/types/verify.d.ts +124 -0
  68. package/lib/types/verify.d.ts.map +1 -0
  69. package/lib/types/verify.js +391 -0
  70. package/lib/types/verify.js.map +1 -0
  71. package/lib/types/version.d.ts +8 -0
  72. package/lib/types/version.d.ts.map +1 -0
  73. package/lib/types/version.js +8 -0
  74. package/lib/types/version.js.map +1 -0
  75. package/package.json +137 -0
  76. package/src/clean.ts +382 -0
  77. package/src/config.ts +104 -0
  78. package/src/dataset.ts +445 -0
  79. package/src/events.ts +90 -0
  80. package/src/index.ts +115 -0
  81. package/src/present.ts +38 -0
  82. package/src/profile.ts +250 -0
  83. package/src/provider-local.ts +194 -0
  84. package/src/service.ts +172 -0
  85. package/src/store.ts +74 -0
  86. package/src/tools/clean.ts +150 -0
  87. package/src/tools/profile.ts +94 -0
  88. package/src/tools/shared.ts +47 -0
  89. package/src/tools/verify.ts +163 -0
  90. package/src/verify.ts +496 -0
  91. package/src/version.ts +8 -0
package/src/verify.ts ADDED
@@ -0,0 +1,496 @@
1
+ /**
2
+ * Declarative verification rules over a parsed {@link Table} (not-null,
3
+ * unique, range, regex, enum, cross-column, freshness) plus the frozen
4
+ * citation-checking semantics for `ctx.dataQuality.verifyCitations`. Pure and
5
+ * deterministic: the freshness clock is injected, numeric comparison never
6
+ * uses raw `===`, and evidence rows are capped by config.
7
+ * @module dsh-data-quality/verify
8
+ */
9
+
10
+ import { isMissing, parseDate, parseNumeric, throwIfAborted, type Cell, type DocumentRoot, type Row, type Table } from './dataset.ts'
11
+ import type { CitationCheckRequest, CitationCheckResult } from './service.ts'
12
+
13
+ /** One declarative verification rule (discriminated on `rule`). */
14
+ export type VerifyRule =
15
+ | { readonly rule: 'not-null'; readonly column: string }
16
+ | { readonly rule: 'unique'; readonly columns: readonly string[] }
17
+ | { readonly rule: 'range'; readonly column: string; readonly min?: number; readonly max?: number }
18
+ | { readonly rule: 'regex'; readonly column: string; readonly pattern: string; readonly flags?: string }
19
+ | { readonly rule: 'enum'; readonly column: string; readonly values: readonly string[] }
20
+ | {
21
+ readonly rule: 'cross-column'
22
+ readonly left: string
23
+ readonly op: '<' | '<=' | '==' | '!=' | '>=' | '>'
24
+ readonly rightColumn?: string
25
+ readonly value?: number | string
26
+ }
27
+ | { readonly rule: 'freshness'; readonly column: string; readonly maxAgeDays: number; readonly asOf?: string }
28
+
29
+ /** Raised for invalid rules; `message` names the rule index and reason. */
30
+ export class VerifyRuleError extends Error {
31
+ /**
32
+ * @param ruleIndex - index of the offending rule in the request array.
33
+ * @param message - actionable human-readable detail.
34
+ */
35
+ constructor(
36
+ readonly ruleIndex: number,
37
+ message: string,
38
+ ) {
39
+ super(message)
40
+ this.name = 'VerifyRuleError'
41
+ }
42
+ }
43
+
44
+ /** One failing row's evidence (capped per rule by `evidenceRowLimit`). */
45
+ export interface VerifyEvidenceRow {
46
+ /** 0-based data row index. */
47
+ readonly rowIndex: number
48
+ /** Only the columns the rule inspected. */
49
+ readonly values: Record<string, Cell>
50
+ }
51
+
52
+ /** One rule's outcome. */
53
+ export interface VerifyRuleResult {
54
+ readonly ruleIndex: number
55
+ readonly rule: VerifyRule['rule']
56
+ readonly passed: boolean
57
+ /** Rows the predicate rejected. */
58
+ readonly failedCount: number
59
+ /** Rows the predicate ran over. */
60
+ readonly checkedCount: number
61
+ /** First `evidenceRowLimit` failing rows. */
62
+ readonly evidence: VerifyEvidenceRow[]
63
+ }
64
+
65
+ /** The full verify report (also the persisted and tool-returned value). */
66
+ export interface VerifyReport {
67
+ readonly dataset: string
68
+ readonly passed: boolean
69
+ readonly rowCount: number
70
+ readonly rules: VerifyRuleResult[]
71
+ /** Storage-domain key of the persisted report, when persistence is on (set by the provider). */
72
+ readonly reportKey?: string
73
+ /** Injected generation timestamp (epoch ms). */
74
+ readonly generatedAt: number
75
+ }
76
+
77
+ /** Assert `column` exists. */
78
+ function requireColumn(columns: readonly string[], column: string, ruleIndex: number): void {
79
+ if (!columns.includes(column)) {
80
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: unknown column ${JSON.stringify(column)} (columns: ${columns.join(', ')})`)
81
+ }
82
+ }
83
+
84
+ /** The trimmed string form used for regex/enum/cross-column string comparison. */
85
+ function textOf(cell: Cell | undefined): string {
86
+ if (typeof cell === 'string') return cell.trim()
87
+ if (cell === null || cell === undefined) return ''
88
+ if (typeof cell === 'object') return JSON.stringify(cell)
89
+ return String(cell)
90
+ }
91
+
92
+ /** Run one per-row predicate and collect capped evidence. */
93
+ function evaluate(
94
+ table: Table,
95
+ ruleIndex: number,
96
+ ruleName: VerifyRule['rule'],
97
+ columns: readonly string[],
98
+ predicate: (row: Row) => boolean,
99
+ evidenceRowLimit: number,
100
+ signal?: AbortSignal,
101
+ ): VerifyRuleResult {
102
+ let failedCount = 0
103
+ const evidence: VerifyEvidenceRow[] = []
104
+ for (const [rowIndex, row] of table.rows.entries()) {
105
+ if (rowIndex % 1024 === 0) throwIfAborted(signal)
106
+ if (predicate(row)) continue
107
+ failedCount += 1
108
+ if (evidence.length < evidenceRowLimit) {
109
+ const values: Record<string, Cell> = {}
110
+ for (const column of columns) values[column] = row[column] ?? null
111
+ evidence.push({ rowIndex, values })
112
+ }
113
+ }
114
+ return {
115
+ ruleIndex,
116
+ rule: ruleName,
117
+ passed: failedCount === 0,
118
+ failedCount,
119
+ checkedCount: table.rows.length,
120
+ evidence,
121
+ }
122
+ }
123
+
124
+ const CROSS_OPS = ['<', '<=', '==', '!=', '>=', '>'] as const
125
+
126
+ /**
127
+ * Apply verification rules over a parsed table. A missing cell fails every
128
+ * rule that reads it. The overall `passed` is the conjunction of rule passes;
129
+ * a failing dataset is a normal result, never a thrown error.
130
+ * @param table - the parsed dataset.
131
+ * @param rules - non-empty rule list.
132
+ * @param options - evidence cap, injected clock for `freshness`, abort signal.
133
+ * @returns the verify report (without the dataset label; the caller adds it).
134
+ */
135
+ export function verifyTable(
136
+ table: Table,
137
+ rules: readonly VerifyRule[],
138
+ options: { evidenceRowLimit: number; now: () => number; signal?: AbortSignal | undefined },
139
+ ): Omit<VerifyReport, 'dataset'> {
140
+ if (rules.length === 0) {
141
+ throw new VerifyRuleError(0, 'rules must be a non-empty array')
142
+ }
143
+ const results: VerifyRuleResult[] = []
144
+ for (const [ruleIndex, rule] of rules.entries()) {
145
+ throwIfAborted(options.signal)
146
+ let result: VerifyRuleResult
147
+ switch (rule.rule) {
148
+ case 'not-null': {
149
+ requireColumn(table.columns, rule.column, ruleIndex)
150
+ result = evaluate(table, ruleIndex, rule.rule, [rule.column], (row) => !isMissing(row[rule.column]), options.evidenceRowLimit, options.signal)
151
+ break
152
+ }
153
+ case 'unique': {
154
+ if (rule.columns.length === 0) {
155
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: unique requires at least one column`)
156
+ }
157
+ for (const column of rule.columns) requireColumn(table.columns, column, ruleIndex)
158
+ const counts = new Map<string, number>()
159
+ for (const row of table.rows) {
160
+ const key = JSON.stringify(rule.columns.map((column) => row[column] ?? null))
161
+ counts.set(key, (counts.get(key) ?? 0) + 1)
162
+ }
163
+ result = evaluate(
164
+ table,
165
+ ruleIndex,
166
+ rule.rule,
167
+ rule.columns,
168
+ (row) => (counts.get(JSON.stringify(rule.columns.map((column) => row[column] ?? null))) ?? 0) === 1,
169
+ options.evidenceRowLimit,
170
+ options.signal,
171
+ )
172
+ break
173
+ }
174
+ case 'range': {
175
+ requireColumn(table.columns, rule.column, ruleIndex)
176
+ if (rule.min === undefined && rule.max === undefined) {
177
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: range requires at least one of min/max`)
178
+ }
179
+ result = evaluate(
180
+ table,
181
+ ruleIndex,
182
+ rule.rule,
183
+ [rule.column],
184
+ (row) => {
185
+ const value = parseNumeric(row[rule.column])
186
+ if (value === undefined) return false
187
+ if (rule.min !== undefined && value < rule.min) return false
188
+ if (rule.max !== undefined && value > rule.max) return false
189
+ return true
190
+ },
191
+ options.evidenceRowLimit,
192
+ options.signal,
193
+ )
194
+ break
195
+ }
196
+ case 'regex': {
197
+ requireColumn(table.columns, rule.column, ruleIndex)
198
+ let pattern: RegExp
199
+ try {
200
+ pattern = new RegExp(rule.pattern, rule.flags ?? '')
201
+ } catch (error) {
202
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: invalid regex ${JSON.stringify(rule.pattern)}: ${error instanceof Error ? error.message : String(error)}`)
203
+ }
204
+ result = evaluate(
205
+ table,
206
+ ruleIndex,
207
+ rule.rule,
208
+ [rule.column],
209
+ (row) => {
210
+ const cell = row[rule.column]
211
+ if (isMissing(cell)) return false
212
+ pattern.lastIndex = 0
213
+ return pattern.test(textOf(cell))
214
+ },
215
+ options.evidenceRowLimit,
216
+ options.signal,
217
+ )
218
+ break
219
+ }
220
+ case 'enum': {
221
+ requireColumn(table.columns, rule.column, ruleIndex)
222
+ if (rule.values.length === 0) {
223
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: enum requires a non-empty values array`)
224
+ }
225
+ const allowed = new Set(rule.values)
226
+ result = evaluate(
227
+ table,
228
+ ruleIndex,
229
+ rule.rule,
230
+ [rule.column],
231
+ (row) => {
232
+ const cell = row[rule.column]
233
+ if (isMissing(cell)) return false
234
+ return allowed.has(textOf(cell))
235
+ },
236
+ options.evidenceRowLimit,
237
+ options.signal,
238
+ )
239
+ break
240
+ }
241
+ case 'cross-column': {
242
+ requireColumn(table.columns, rule.left, ruleIndex)
243
+ if (!CROSS_OPS.includes(rule.op)) {
244
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: unsupported op ${JSON.stringify(rule.op)}`)
245
+ }
246
+ const hasColumn = rule.rightColumn !== undefined
247
+ const hasValue = rule.value !== undefined
248
+ if (hasColumn === hasValue) {
249
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: cross-column needs exactly one of rightColumn/value`)
250
+ }
251
+ if (hasColumn) requireColumn(table.columns, rule.rightColumn as string, ruleIndex)
252
+ const columns = hasColumn ? [rule.left, rule.rightColumn as string] : [rule.left]
253
+ result = evaluate(
254
+ table,
255
+ ruleIndex,
256
+ rule.rule,
257
+ columns,
258
+ (row) => {
259
+ const left = row[rule.left]
260
+ const right = hasColumn ? row[rule.rightColumn as string] : (rule.value as number | string)
261
+ if (isMissing(left) || isMissing(right)) return false
262
+ const leftNum = parseNumeric(left)
263
+ const rightNum = parseNumeric(right)
264
+ if (leftNum !== undefined && rightNum !== undefined) {
265
+ switch (rule.op) {
266
+ case '<': return leftNum < rightNum
267
+ case '<=': return leftNum <= rightNum
268
+ case '>': return leftNum > rightNum
269
+ case '>=': return leftNum >= rightNum
270
+ case '==': return Math.abs(leftNum - rightNum) <= 1e-12 * Math.max(Math.abs(leftNum), Math.abs(rightNum), 1)
271
+ case '!=': return Math.abs(leftNum - rightNum) > 1e-12 * Math.max(Math.abs(leftNum), Math.abs(rightNum), 1)
272
+ }
273
+ }
274
+ // Date fallback: both sides date-parseable compares epochs (the startDate < endDate case).
275
+ const leftDate = parseDate(left)
276
+ const rightDate = parseDate(right)
277
+ if (leftDate !== undefined && rightDate !== undefined) {
278
+ switch (rule.op) {
279
+ case '<': return leftDate < rightDate
280
+ case '<=': return leftDate <= rightDate
281
+ case '>': return leftDate > rightDate
282
+ case '>=': return leftDate >= rightDate
283
+ case '==': return leftDate === rightDate
284
+ case '!=': return leftDate !== rightDate
285
+ }
286
+ }
287
+ // String fallback: only equality operators are defined.
288
+ const leftText = textOf(left)
289
+ const rightText = textOf(right)
290
+ if (rule.op === '==') return leftText === rightText
291
+ if (rule.op === '!=') return leftText !== rightText
292
+ return false
293
+ },
294
+ options.evidenceRowLimit,
295
+ options.signal,
296
+ )
297
+ break
298
+ }
299
+ case 'freshness': {
300
+ requireColumn(table.columns, rule.column, ruleIndex)
301
+ if (!Number.isFinite(rule.maxAgeDays) || rule.maxAgeDays <= 0) {
302
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: freshness maxAgeDays must be a positive number, got ${String(rule.maxAgeDays)}`)
303
+ }
304
+ let asOf: number
305
+ if (rule.asOf !== undefined) {
306
+ const parsed = parseDate(rule.asOf)
307
+ if (parsed === undefined) {
308
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: freshness asOf is not a recognized date: ${JSON.stringify(rule.asOf)}`)
309
+ }
310
+ asOf = parsed
311
+ } else {
312
+ asOf = options.now()
313
+ }
314
+ const maxAgeMs = rule.maxAgeDays * 86_400_000
315
+ result = evaluate(
316
+ table,
317
+ ruleIndex,
318
+ rule.rule,
319
+ [rule.column],
320
+ (row) => {
321
+ const epoch = parseDate(row[rule.column])
322
+ if (epoch === undefined) return false
323
+ return asOf - epoch <= maxAgeMs
324
+ },
325
+ options.evidenceRowLimit,
326
+ options.signal,
327
+ )
328
+ break
329
+ }
330
+ default:
331
+ throw new VerifyRuleError(ruleIndex, `rule ${ruleIndex}: unknown rule type ${JSON.stringify((rule as VerifyRule).rule)}`)
332
+ }
333
+ results.push(result)
334
+ }
335
+ return {
336
+ passed: results.every((result) => result.passed),
337
+ rowCount: table.rows.length,
338
+ rules: results,
339
+ generatedAt: options.now(),
340
+ }
341
+ }
342
+
343
+ /** Human-readable verify summary for the tool's Native render. */
344
+ export function renderVerifyText(report: VerifyReport): string {
345
+ const lines: string[] = []
346
+ lines.push(`Verification of ${report.dataset}: ${report.passed ? 'PASSED' : 'FAILED'} (${report.rowCount} rows, ${report.rules.length} rules)`)
347
+ for (const rule of report.rules) {
348
+ lines.push(`- [${rule.passed ? 'pass' : 'FAIL'}] rule ${rule.ruleIndex} (${rule.rule}): ${rule.failedCount}/${rule.checkedCount} row(s) failed`)
349
+ for (const row of rule.evidence) {
350
+ const cells = Object.entries(row.values).map(([column, value]) => `${column}=${JSON.stringify(value)}`).join(', ')
351
+ lines.push(` row ${row.rowIndex}: ${cells}`)
352
+ }
353
+ if (rule.failedCount > rule.evidence.length) {
354
+ lines.push(` … and ${rule.failedCount - rule.evidence.length} more failing row(s)`)
355
+ }
356
+ }
357
+ return lines.join('\n')
358
+ }
359
+
360
+ /** One parsed locator segment: an object key plus optional array indices. */
361
+ interface LocatorSegment {
362
+ readonly key: string | undefined
363
+ readonly indices: readonly number[]
364
+ }
365
+
366
+ /**
367
+ * Parse a JSON-path-ish locator (`rows[3].nav`, `summary.annualReturn`,
368
+ * `[0].value`) into walk segments. Throws on unparsable input.
369
+ * @param locator - the citation's path string.
370
+ * @returns ordered walk segments.
371
+ */
372
+ export function parseLocator(locator: string): LocatorSegment[] {
373
+ if (locator.trim() === '') throw new Error('citation path must not be empty')
374
+ const segments: LocatorSegment[] = []
375
+ for (const part of locator.split('.')) {
376
+ const match = /^([A-Za-z0-9_$-]*)((?:\[\d+\])*)$/u.exec(part)
377
+ if (match === null || (match[1] === '' && match[2] === '')) {
378
+ throw new Error(`unparsable citation path segment ${JSON.stringify(part)} in ${JSON.stringify(locator)}`)
379
+ }
380
+ const indices = [...(match[2] ?? '').matchAll(/\[(\d+)\]/gu)].map((index) => Number(index[1]))
381
+ segments.push({ key: match[1] === '' ? undefined : match[1], indices })
382
+ }
383
+ return segments
384
+ }
385
+
386
+ /** Sentinel for a failed locator walk (distinguishable from an actual `undefined` value). */
387
+ const NOT_FOUND = Symbol('not-found')
388
+
389
+ /** Walk the document root along the parsed locator; returns {@link NOT_FOUND} when any step misses. */
390
+ function walkLocator(root: DocumentRoot, segments: readonly LocatorSegment[]): unknown {
391
+ let current: unknown = root.kind === 'table' ? { columns: root.columns, rows: root.rows } : root.value
392
+ for (const segment of segments) {
393
+ if (segment.key !== undefined) {
394
+ if (typeof current !== 'object' || current === null || Array.isArray(current)) return NOT_FOUND
395
+ if (!Object.prototype.hasOwnProperty.call(current, segment.key)) return NOT_FOUND
396
+ current = (current as Record<string, unknown>)[segment.key]
397
+ }
398
+ for (const index of segment.indices) {
399
+ if (!Array.isArray(current) || index >= current.length) return NOT_FOUND
400
+ current = current[index]
401
+ }
402
+ }
403
+ return current
404
+ }
405
+
406
+ /** Relative-tolerance numeric comparison (never raw `===` on floats). */
407
+ function numericClose(actual: number, cited: number, tolerance: number): boolean {
408
+ return Math.abs(actual - cited) <= tolerance * Math.max(Math.abs(actual), Math.abs(cited), Number.MIN_VALUE)
409
+ }
410
+
411
+ /** Display form of a located value for evidence notes (`undefined` when it cannot be rendered). */
412
+ function displayActual(located: unknown): string | undefined {
413
+ if (typeof located === 'string') return located
414
+ if (typeof located === 'number' || typeof located === 'boolean') return String(located)
415
+ if (located === null) return 'null'
416
+ const rendered = JSON.stringify(located)
417
+ return typeof rendered === 'string' ? rendered : undefined
418
+ }
419
+
420
+ /**
421
+ * The frozen citation-checking semantics: locate each citation's `path` in the
422
+ * dataset document, then compare. Numbers compare with relative `tolerance`
423
+ * (falling back to `defaultTolerance`); strings compare exactly. A missing
424
+ * location is `not-found`; an incomparable actual/cited type pair is
425
+ * `unverifiable`.
426
+ * @param root - the loaded dataset document.
427
+ * @param citations - citations to check.
428
+ * @param defaultTolerance - configured fallback relative tolerance.
429
+ * @returns one result per citation, ids echoed.
430
+ */
431
+ export function checkCitations(
432
+ root: DocumentRoot,
433
+ citations: CitationCheckRequest['citations'],
434
+ defaultTolerance: number,
435
+ ): CitationCheckResult {
436
+ const results: CitationCheckResult['results'] = []
437
+ for (const citation of citations) {
438
+ let located: unknown
439
+ try {
440
+ located = walkLocator(root, parseLocator(citation.path))
441
+ } catch (error) {
442
+ results.push({
443
+ id: citation.id,
444
+ status: 'not-found',
445
+ note: `path ${JSON.stringify(citation.path)} is not a valid locator: ${error instanceof Error ? error.message : String(error)}`,
446
+ })
447
+ continue
448
+ }
449
+ if (located === NOT_FOUND) {
450
+ results.push({ id: citation.id, status: 'not-found', note: `nothing found at ${JSON.stringify(citation.path)}` })
451
+ continue
452
+ }
453
+ if (typeof citation.value === 'number') {
454
+ const actualNumber = typeof located === 'number' ? located : parseNumeric(located as Cell)
455
+ if (actualNumber === undefined) {
456
+ const display = displayActual(located)
457
+ results.push({
458
+ id: citation.id,
459
+ status: 'unverifiable',
460
+ ...(display !== undefined ? { actual: display } : {}),
461
+ note: `value at ${JSON.stringify(citation.path)} is not numeric and cannot be compared to ${citation.value}`,
462
+ })
463
+ continue
464
+ }
465
+ const tolerance = citation.tolerance ?? defaultTolerance
466
+ if (numericClose(actualNumber, citation.value, tolerance)) {
467
+ results.push({ id: citation.id, status: 'verified', actual: actualNumber, note: `within relative tolerance ${tolerance}` })
468
+ } else {
469
+ results.push({
470
+ id: citation.id,
471
+ status: 'mismatch',
472
+ actual: actualNumber,
473
+ note: `cited ${citation.value} differs from actual ${actualNumber} beyond relative tolerance ${tolerance}`,
474
+ })
475
+ }
476
+ continue
477
+ }
478
+ // Cited a string: exact comparison against a string actual only.
479
+ if (typeof located === 'string') {
480
+ if (located === citation.value) {
481
+ results.push({ id: citation.id, status: 'verified', actual: located })
482
+ } else {
483
+ results.push({ id: citation.id, status: 'mismatch', actual: located, note: `cited ${JSON.stringify(citation.value)} != actual ${JSON.stringify(located)}` })
484
+ }
485
+ continue
486
+ }
487
+ const display = displayActual(located)
488
+ results.push({
489
+ id: citation.id,
490
+ status: 'unverifiable',
491
+ ...(display !== undefined ? { actual: display } : {}),
492
+ note: `value at ${JSON.stringify(citation.path)} is ${typeof located}, not comparable to cited string`,
493
+ })
494
+ }
495
+ return { results }
496
+ }
package/src/version.ts ADDED
@@ -0,0 +1,8 @@
1
+ /**
2
+ * Plugin version, kept in one place so `scripts/release.mjs` can stamp it and
3
+ * reports can name their generator.
4
+ * @module dsh-data-quality/version
5
+ */
6
+
7
+ /** The package version reported in persisted reports. */
8
+ export const VERSION = '0.1.0'