dsh-hs-classify 0.0.0-stage → 0.2.1

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/lib/index.mjs ADDED
@@ -0,0 +1,2705 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { existsSync } from "node:fs";
3
+ import { dirname, isAbsolute, resolve } from "node:path";
4
+ import { defineTool } from "@deepseek-ai/dsh-tools";
5
+ import Schema from "@deepseek-ai/schemastery";
6
+ //#region src/config.ts
7
+ const Config = Schema.object({
8
+ rulesFile: Schema.string().default("rules/hs-classify.yaml").description("规则库文件路径(相对插件包根目录)。替换该文件即可切换规则集版本。"),
9
+ disabledRules: Schema.array(Schema.string()).default([]).description("要停用的规则 id 列表;停用的规则会出现在报告的 skipped 中。"),
10
+ onlyRules: Schema.array(Schema.string()).default([]).description("只执行这些规则 id;留空表示执行全部规则。"),
11
+ skipNotes: Schema.string().default("").description("附加到每条 skipped 说明后的备注,例如标注本机构实施细则。"),
12
+ timeoutMs: Schema.number().default(12e4).description("工具协作式超时预算(毫秒)。")
13
+ });
14
+ //#endregion
15
+ //#region src/shared/yaml.ts
16
+ /**
17
+ * Minimal, dependency-free YAML reader for rule packs and check material.
18
+ *
19
+ * Rule packs are authored by hand and shipped inside each plugin, so the
20
+ * supported surface is deliberately small and strictly block-structured:
21
+ *
22
+ * ```yaml
23
+ * version: "2026.1"
24
+ * rules:
25
+ * - id: R-001
26
+ * severity: error
27
+ * basis:
28
+ * document: 《病历书写基本规范》
29
+ * clause: 第二十二条(八)
30
+ * params:
31
+ * maxHours: 6
32
+ * note: >-
33
+ * Folded scalars join their lines with single spaces.
34
+ * ```
35
+ *
36
+ * Supported: nested block mappings, block sequences, `- key: value` items,
37
+ * plain / single- / double-quoted scalars, the empty collections `[]` and `{}`,
38
+ * literal `|` and folded `>` block scalars, `#` comments and blank lines.
39
+ *
40
+ * Rejected loudly rather than guessed: flow collections, anchors, aliases, tags
41
+ * and multi-document streams.
42
+ */
43
+ /** Raised when a document uses syntax outside the supported subset. */
44
+ var YamlSubsetError = class extends Error {
45
+ constructor(message, line) {
46
+ super(line === void 0 ? message : `${message} (line ${line})`);
47
+ this.name = "YamlSubsetError";
48
+ }
49
+ };
50
+ function stripComment(raw, lineNum) {
51
+ let quote = null;
52
+ for (let index = 0; index < raw.length; index++) {
53
+ const char = raw[index];
54
+ if (quote !== null) {
55
+ if (quote === "\"" && char === "\\") {
56
+ index += 1;
57
+ continue;
58
+ }
59
+ if (char === quote) quote = null;
60
+ continue;
61
+ }
62
+ if (char === "\"" || char === "'") {
63
+ quote = char;
64
+ continue;
65
+ }
66
+ if (char === "#" && (index === 0 || /\s/.test(raw[index - 1]))) return raw.slice(0, index);
67
+ }
68
+ if (quote !== null) throw new YamlSubsetError("unterminated quoted scalar", lineNum);
69
+ return raw;
70
+ }
71
+ function unescapeDouble(body, lineNum) {
72
+ let out = "";
73
+ for (let index = 0; index < body.length; index++) {
74
+ const char = body[index];
75
+ if (char !== "\\") {
76
+ out += char;
77
+ continue;
78
+ }
79
+ const next = body[index + 1];
80
+ if (next === void 0) throw new YamlSubsetError("dangling escape in double-quoted scalar", lineNum);
81
+ index += 1;
82
+ switch (next) {
83
+ case "n":
84
+ out += "\n";
85
+ break;
86
+ case "t":
87
+ out += " ";
88
+ break;
89
+ case "r":
90
+ out += "\r";
91
+ break;
92
+ case "\"":
93
+ out += "\"";
94
+ break;
95
+ case "\\":
96
+ out += "\\";
97
+ break;
98
+ case "0":
99
+ out += "\0";
100
+ break;
101
+ default: throw new YamlSubsetError(`unsupported escape "\\${next}"`, lineNum);
102
+ }
103
+ }
104
+ return out;
105
+ }
106
+ /** Index of the `:` separating key from value, or -1 when the text is a scalar. */
107
+ function findKeySeparator(text) {
108
+ let quote = null;
109
+ for (let index = 0; index < text.length; index++) {
110
+ const char = text[index];
111
+ if (quote !== null) {
112
+ if (quote === "\"" && char === "\\") {
113
+ index += 1;
114
+ continue;
115
+ }
116
+ if (char === quote) quote = null;
117
+ continue;
118
+ }
119
+ if (char === "\"" || char === "'") {
120
+ quote = char;
121
+ continue;
122
+ }
123
+ if (char === ":" && (index + 1 === text.length || /\s/.test(text[index + 1]))) return index;
124
+ }
125
+ return -1;
126
+ }
127
+ function isSequenceItem(text) {
128
+ return text === "-" || text.startsWith("- ");
129
+ }
130
+ function parseScalar(raw, lineNum) {
131
+ const text = raw.trim();
132
+ if (text === "") return null;
133
+ if (text.startsWith("[") || text.startsWith("{")) return parseFlow(text, lineNum);
134
+ if (text.startsWith("&") || text.startsWith("*") || text.startsWith("!")) throw new YamlSubsetError("anchors, aliases and tags are not supported by this reader", lineNum);
135
+ if (text.startsWith("\"")) {
136
+ if (text.length < 2 || !text.endsWith("\"")) throw new YamlSubsetError("bad double-quoted scalar", lineNum);
137
+ return unescapeDouble(text.slice(1, -1), lineNum);
138
+ }
139
+ if (text.startsWith("'")) {
140
+ if (text.length < 2 || !text.endsWith("'")) throw new YamlSubsetError("bad single-quoted scalar", lineNum);
141
+ return text.slice(1, -1).replace(/''/g, "'");
142
+ }
143
+ if (text === "null" || text === "~") return null;
144
+ if (text === "true") return true;
145
+ if (text === "false") return false;
146
+ if (/^[+-]?\d+$/.test(text)) return Number.parseInt(text, 10);
147
+ if (/^[+-]?(?:\d+\.\d*|\.\d+|\d+)(?:[eE][+-]?\d+)?$/.test(text)) return Number.parseFloat(text);
148
+ return text;
149
+ }
150
+ /**
151
+ * Parse a flow collection (`[a, b]` or `{k: v}`), including nesting.
152
+ *
153
+ * Hand-written packs use flow style for short lists — a rule's `aliases`, for
154
+ * example — so supporting it keeps those packs readable. Only the JSON-compatible
155
+ * subset is accepted: no anchors, no tags, no multi-line flow.
156
+ */
157
+ function parseFlow(text, lineNum) {
158
+ let index = 0;
159
+ const skipSpace = () => {
160
+ while (index < text.length && /\s/.test(text[index])) index += 1;
161
+ };
162
+ const readToken = (stopAtColon) => {
163
+ skipSpace();
164
+ const char = text[index];
165
+ if (char === void 0) throw new YamlSubsetError("unexpected end of flow collection", lineNum);
166
+ if (char === "[" || char === "{") return readCollection();
167
+ if (char === "\"" || char === "'") {
168
+ const quote = char;
169
+ let body = "";
170
+ index += 1;
171
+ while (index < text.length) {
172
+ const current = text[index];
173
+ if (quote === "\"" && current === "\\") {
174
+ body += current + (text[index + 1] ?? "");
175
+ index += 2;
176
+ continue;
177
+ }
178
+ if (current === quote) {
179
+ if (quote === "'" && text[index + 1] === "'") {
180
+ body += "'";
181
+ index += 2;
182
+ continue;
183
+ }
184
+ index += 1;
185
+ return quote === "\"" ? unescapeDouble(body, lineNum) : body;
186
+ }
187
+ body += current;
188
+ index += 1;
189
+ }
190
+ throw new YamlSubsetError("unterminated quoted scalar in flow collection", lineNum);
191
+ }
192
+ let plain = "";
193
+ while (index < text.length) {
194
+ const current = text[index];
195
+ if (current === "," || current === "]" || current === "}") break;
196
+ if (stopAtColon && current === ":") break;
197
+ plain += current;
198
+ index += 1;
199
+ }
200
+ return parseScalar(plain, lineNum);
201
+ };
202
+ const readCollection = () => {
203
+ const open = text[index];
204
+ const close = open === "[" ? "]" : "}";
205
+ index += 1;
206
+ if (open === "[") {
207
+ const items = [];
208
+ skipSpace();
209
+ if (text[index] === close) {
210
+ index += 1;
211
+ return items;
212
+ }
213
+ for (;;) {
214
+ items.push(readToken(false));
215
+ skipSpace();
216
+ const separator = text[index];
217
+ if (separator === ",") {
218
+ index += 1;
219
+ continue;
220
+ }
221
+ if (separator === close) {
222
+ index += 1;
223
+ return items;
224
+ }
225
+ throw new YamlSubsetError(`expected "," or "${close}" in flow sequence`, lineNum);
226
+ }
227
+ }
228
+ const out = {};
229
+ skipSpace();
230
+ if (text[index] === close) {
231
+ index += 1;
232
+ return out;
233
+ }
234
+ for (;;) {
235
+ const key = readToken(true);
236
+ skipSpace();
237
+ if (text[index] !== ":") throw new YamlSubsetError("expected \":\" in flow mapping", lineNum);
238
+ index += 1;
239
+ const value = readToken(false);
240
+ out[String(key)] = value;
241
+ skipSpace();
242
+ const separator = text[index];
243
+ if (separator === ",") {
244
+ index += 1;
245
+ continue;
246
+ }
247
+ if (separator === close) {
248
+ index += 1;
249
+ return out;
250
+ }
251
+ throw new YamlSubsetError(`expected "," or "${close}" in flow mapping`, lineNum);
252
+ }
253
+ };
254
+ const value = readCollection();
255
+ skipSpace();
256
+ if (index !== text.length) throw new YamlSubsetError(`trailing characters after flow collection: "${text.slice(index)}"`, lineNum);
257
+ return value;
258
+ }
259
+ /**
260
+ * Recursive-descent reader over pre-tokenized content lines.
261
+ *
262
+ * The single invariant: every `parse*` method consumes only lines whose indent
263
+ * is at least the indent it was given for that collection, and stops at the
264
+ * first line that is shallower. A child may be indented by any amount deeper
265
+ * than its parent entry, because hand-written packs do not agree on a step.
266
+ */
267
+ var Reader = class {
268
+ index = 0;
269
+ /** Content lines, in document order. */
270
+ lines;
271
+ constructor(lines) {
272
+ this.lines = lines;
273
+ }
274
+ /** Current line, or undefined at end of document. */
275
+ peek() {
276
+ return this.lines[this.index];
277
+ }
278
+ /** Consume and return the current line. */
279
+ take() {
280
+ const line = this.lines[this.index];
281
+ if (line === void 0) throw new YamlSubsetError("unexpected end of document");
282
+ this.index += 1;
283
+ return line;
284
+ }
285
+ /** Body of a `|` or `>` block scalar, stopping at the first line at or above `parentIndent`. */
286
+ readBlockScalar(parentIndent, kind, startLine) {
287
+ const collected = [];
288
+ let blockIndent = null;
289
+ while (true) {
290
+ const next = this.peek();
291
+ if (next === void 0 || next.indent <= parentIndent) break;
292
+ this.index += 1;
293
+ if (blockIndent === null) blockIndent = next.indent;
294
+ collected.push(" ".repeat(Math.max(0, next.indent - blockIndent)) + next.text);
295
+ }
296
+ if (blockIndent === null) throw new YamlSubsetError("empty block scalar", startLine);
297
+ if (kind === "|") return collected.join("\n");
298
+ return collected.map((entry) => entry.trim()).filter((entry) => entry !== "").join(" ");
299
+ }
300
+ /**
301
+ * Read the value of `key:` whose scalar part was empty.
302
+ * @param keyIndent - column of the key, so a shallower sibling ends the value.
303
+ */
304
+ readNested(keyIndent) {
305
+ const next = this.peek();
306
+ if (next === void 0 || next.indent <= keyIndent) return null;
307
+ return this.readCollection(next.indent);
308
+ }
309
+ /**
310
+ * Read a mapping or sequence starting at the current line.
311
+ * @param indent - column at which the collection's entries must start.
312
+ */
313
+ readCollection(indent) {
314
+ const line = this.peek();
315
+ if (line === void 0 || line.indent < indent) return null;
316
+ return isSequenceItem(line.text) ? this.readSequence(line.indent) : this.readMapping(line.indent);
317
+ }
318
+ /**
319
+ * Read a block mapping whose entries sit at `indent`.
320
+ * @param indent - column the mapping entries occupy.
321
+ * @param seed - first entry that shares a `- ` line and was already consumed.
322
+ */
323
+ readMapping(indent, seed) {
324
+ const out = {};
325
+ let pending = seed;
326
+ for (;;) {
327
+ if (pending === void 0) {
328
+ const line = this.peek();
329
+ if (line === void 0 || line.indent < indent) break;
330
+ if (line.indent > indent) throw new YamlSubsetError("unexpected indentation in block mapping", line.num);
331
+ if (isSequenceItem(line.text)) break;
332
+ this.index += 1;
333
+ pending = {
334
+ text: line.text,
335
+ line: line.num
336
+ };
337
+ }
338
+ const separator = findKeySeparator(pending.text);
339
+ if (separator < 0) throw new YamlSubsetError(`expected "key: value", got "${pending.text}"`, pending.line);
340
+ const key = pending.text.slice(0, separator).trim();
341
+ if (key === "") throw new YamlSubsetError("empty mapping key", pending.line);
342
+ if (Object.hasOwn(out, key)) throw new YamlSubsetError(`duplicate mapping key "${key}"`, pending.line);
343
+ out[key] = this.readEntryValue(pending.text.slice(separator + 1).trim(), indent, pending.line);
344
+ pending = void 0;
345
+ }
346
+ return out;
347
+ }
348
+ /** Value that follows `key:` on the same line, or the nested collection below it. */
349
+ readEntryValue(rawValue, keyIndent, lineNum) {
350
+ if (rawValue === "|" || rawValue === "|-" || rawValue === ">" || rawValue === ">-") return this.readBlockScalar(keyIndent, rawValue.startsWith("|") ? "|" : ">", lineNum);
351
+ if (rawValue !== "") return parseScalar(rawValue, lineNum);
352
+ return this.readNested(keyIndent);
353
+ }
354
+ /**
355
+ * Read a block sequence whose dashes sit at `indent`. A `- key: value` line
356
+ * opens a mapping whose first entry shares the dash line; the remaining entries
357
+ * follow at the key column, which is two past the dash.
358
+ */
359
+ readSequence(indent) {
360
+ const items = [];
361
+ for (;;) {
362
+ const line = this.peek();
363
+ if (line === void 0 || line.indent < indent) break;
364
+ if (line.indent > indent) throw new YamlSubsetError("unexpected indentation in block sequence", line.num);
365
+ if (!isSequenceItem(line.text)) break;
366
+ this.index += 1;
367
+ const rest = line.text === "-" ? "" : line.text.slice(2).trim();
368
+ if ((rest === "" ? -1 : findKeySeparator(rest)) >= 0) {
369
+ items.push(this.readMapping(indent + 2, {
370
+ text: rest,
371
+ line: line.num
372
+ }));
373
+ continue;
374
+ }
375
+ items.push(rest === "" ? this.readNested(indent) : parseScalar(rest, line.num));
376
+ }
377
+ return items;
378
+ }
379
+ };
380
+ /**
381
+ * Turn raw YAML text into content lines, dropping comments and blank lines.
382
+ *
383
+ * Inside a `|` or `>` block scalar nothing is YAML syntax any more — quotes,
384
+ * hashes and colons are content — so the reader tracks the block's indentation
385
+ * and passes those lines through verbatim. Without this, a procurement clause
386
+ * quoted inside a rule's `note` would be read as an unterminated string.
387
+ */
388
+ function tokenize(source) {
389
+ const rawLines = source.split(/\r?\n/);
390
+ const lines = [];
391
+ let blockIndent = null;
392
+ let blockParentIndent = -1;
393
+ for (let index = 0; index < rawLines.length; index++) {
394
+ const num = index + 1;
395
+ const raw = rawLines[index];
396
+ const expanded = raw.replace(/\t/g, " ");
397
+ const indent = expanded.length - expanded.trimStart().length;
398
+ const body = expanded.trim();
399
+ if (blockIndent !== null) {
400
+ if (body === "") continue;
401
+ if (indent <= blockParentIndent) blockIndent = null;
402
+ else {
403
+ lines.push({
404
+ indent,
405
+ text: body,
406
+ num
407
+ });
408
+ continue;
409
+ }
410
+ }
411
+ const withoutComment = stripComment(raw, num);
412
+ if (withoutComment.trim() === "") continue;
413
+ const text = withoutComment.replace(/\t/g, " ").trim();
414
+ const effectiveIndent = withoutComment.replace(/\t/g, " ").length - withoutComment.replace(/\t/g, " ").trimStart().length;
415
+ lines.push({
416
+ indent: effectiveIndent,
417
+ text,
418
+ num
419
+ });
420
+ if (/:\s*[|>][+-]?\s*$/.test(text)) {
421
+ blockIndent = effectiveIndent + 1;
422
+ blockParentIndent = effectiveIndent;
423
+ }
424
+ }
425
+ return lines;
426
+ }
427
+ /**
428
+ * Parse one block-structured YAML document.
429
+ * @param source - YAML text.
430
+ * @returns the parsed value; an empty document yields `null`.
431
+ */
432
+ function parseYaml(source) {
433
+ const lines = tokenize(source);
434
+ if (lines.length === 0) return null;
435
+ const reader = new Reader(lines);
436
+ const value = reader.readCollection(lines[0].indent);
437
+ const leftover = reader.peek();
438
+ if (leftover !== void 0) throw new YamlSubsetError("trailing content after document", leftover.num);
439
+ return value;
440
+ }
441
+ /** Read a required string field, failing loudly when absent. */
442
+ function requireString(source, key, where) {
443
+ const value = source[key];
444
+ if (typeof value !== "string" || value.trim() === "") throw new YamlSubsetError(`${where}: missing required string field "${key}"`);
445
+ return value;
446
+ }
447
+ //#endregion
448
+ //#region src/shared/table.ts
449
+ /**
450
+ * Generic reader for table-shaped check plugins.
451
+ *
452
+ * Pairs with `rows.ts`: that module runs the checks, this one builds the table it
453
+ * runs them on. A plugin supplies a column alias map and gets back a `TableInput`,
454
+ * so the twenty-odd table plugins share one reader instead of each re-implementing
455
+ * JSON/YAML detection, numeric coercion and the "which columns did I actually
456
+ * see" diagnostic.
457
+ *
458
+ * The reader is deliberately strict about one thing: when the material carries
459
+ * **none** of the aliases it was given, it refuses rather than guessing. A reader
460
+ * that silently picks a column produces findings about the wrong data, which is
461
+ * worse than an error message that names the columns it saw.
462
+ */
463
+ /** Raised when the material cannot be read at all. */
464
+ var MaterialError = class extends Error {
465
+ constructor(message) {
466
+ super(message);
467
+ this.name = "MaterialError";
468
+ }
469
+ };
470
+ function isRecord(value) {
471
+ return typeof value === "object" && value !== null && !Array.isArray(value);
472
+ }
473
+ /** Render a scalar cell as text, dropping values that carry nothing. */
474
+ function cellText(value) {
475
+ if (value === void 0 || value === null) return void 0;
476
+ if (typeof value === "string") return value.trim() === "" ? void 0 : value.trim();
477
+ if (typeof value === "number" && Number.isFinite(value)) return String(value);
478
+ if (typeof value === "boolean") return String(value);
479
+ }
480
+ /** The comparison key for a column or field name. */
481
+ function normaliseName(value) {
482
+ return value.toLowerCase().replace(/[\s\u3000_-]/g, "");
483
+ }
484
+ /** Find a cell by any of its aliases, exact name first, then normalised. */
485
+ function pick(fields, aliases) {
486
+ for (const alias of aliases) {
487
+ const direct = fields[alias];
488
+ if (direct !== void 0 && direct !== "") return direct;
489
+ }
490
+ const normalised = new Map(Object.keys(fields).map((key) => [normaliseName(key), key]));
491
+ for (const alias of aliases) {
492
+ const key = normalised.get(normaliseName(alias));
493
+ if (key === void 0) continue;
494
+ const value = fields[key];
495
+ if (value !== void 0 && value !== "") return value;
496
+ }
497
+ }
498
+ /**
499
+ * Parse one material into a table.
500
+ *
501
+ * @param source - JSON or YAML text.
502
+ * @param target - description of where the material came from.
503
+ * @param spec - the plugin's table shape.
504
+ * @returns the normalized table.
505
+ */
506
+ function parseTable(source, target, spec) {
507
+ const trimmed = source.trim();
508
+ if (trimmed === "") throw new MaterialError("材料为空");
509
+ let document;
510
+ if (trimmed.startsWith("{") || trimmed.startsWith("[")) try {
511
+ document = JSON.parse(trimmed);
512
+ } catch (error) {
513
+ throw new MaterialError(`JSON 无法解析:${error instanceof Error ? error.message : String(error)}`);
514
+ }
515
+ else try {
516
+ document = parseYaml(trimmed);
517
+ } catch (error) {
518
+ if (error instanceof YamlSubsetError) throw new MaterialError(`YAML 无法解析:${error.message}`);
519
+ throw error;
520
+ }
521
+ const warnings = [];
522
+ let list;
523
+ const header = {};
524
+ if (Array.isArray(document)) list = document;
525
+ else if (isRecord(document)) {
526
+ for (const key of spec.rowKeys) {
527
+ const candidate = document[key];
528
+ if (candidate !== void 0 && candidate !== null) {
529
+ list = candidate;
530
+ break;
531
+ }
532
+ }
533
+ const scalars = {};
534
+ for (const [key, value] of Object.entries(document)) {
535
+ const rendered = cellText(value);
536
+ if (rendered !== void 0) scalars[key] = rendered;
537
+ }
538
+ for (const [field, aliases] of Object.entries(spec.header ?? {})) {
539
+ const value = pick(scalars, aliases);
540
+ if (value !== void 0) header[field] = value;
541
+ }
542
+ for (const [key, value] of Object.entries(scalars)) if (header[key] === void 0) header[key] = value;
543
+ } else throw new MaterialError("材料根节点必须是映射或列表");
544
+ if (list === void 0 || list === null) throw new MaterialError(`材料缺少 ${spec.rowKeys.join(" / ")} 任一列表,无法执行检查`);
545
+ if (!Array.isArray(list)) throw new MaterialError(`${spec.rowKeys[0] ?? "rows"} 必须是列表`);
546
+ if (list.length === 0) throw new MaterialError(`${spec.rowKeys[0] ?? "rows"} 为空列表,无法执行检查`);
547
+ const rowNumberColumns = spec.rowNumberColumns ?? [
548
+ "row",
549
+ "序号",
550
+ "行号"
551
+ ];
552
+ const rows = list.map((entry, index) => {
553
+ if (!isRecord(entry)) throw new MaterialError(`${spec.rowKeys[0] ?? "rows"}[${index}] 必须是映射`);
554
+ const fields = {};
555
+ for (const [key, value] of Object.entries(entry)) {
556
+ if (value === void 0 || value === null) continue;
557
+ if (isRecord(value) || Array.isArray(value)) continue;
558
+ fields[key] = typeof value === "string" ? value.trim() : cellText(value) ?? "";
559
+ }
560
+ const row = {
561
+ row: index + 1,
562
+ fields
563
+ };
564
+ const declared = cellText(entry[rowNumberColumns[0]]) ?? pick(fields, rowNumberColumns);
565
+ if (declared !== void 0 && /^\d+$/.test(declared)) row.row = Number.parseInt(declared, 10);
566
+ return row;
567
+ });
568
+ const columns = [...new Set(rows.flatMap((row) => Object.keys(row.fields)))];
569
+ const expected = spec.requireAnyOf ?? [...new Set(Object.values(spec.columns).flat())];
570
+ const known = new Set(expected.map(normaliseName));
571
+ if (!columns.some((column) => known.has(normaliseName(column)))) throw new MaterialError(`材料中没有可识别的字段,已识别的列名为:${columns.join(" / ") || "(无)"};已按以下名称查找:${expected.join(" / ")}`);
572
+ return {
573
+ target,
574
+ header,
575
+ rows,
576
+ columns,
577
+ warnings
578
+ };
579
+ }
580
+ /**
581
+ * Copy a material's recognized column values onto canonical field keys, and record
582
+ * which canonical fields the material actually carried.
583
+ *
584
+ * Row-level checks read `row.fields[field]`, so a plugin calls this once per row to
585
+ * make the aliases available under their canonical names. The original keys stay in
586
+ * place, which keeps a finding able to name the column it actually read.
587
+ *
588
+ * A field whose cell is blank is still **marked as provided** — with an empty value —
589
+ * because the difference between "this column is missing" and "this column is present
590
+ * but unchecked" is exactly what a presence check has to report. Assigning nothing
591
+ * would make an empty column indistinguishable from an absent one, and the check would
592
+ * silently pass.
593
+ *
594
+ * @param row - the row to extend, in place.
595
+ * @param spec - the same spec the reader was given.
596
+ * @returns the same row, for chaining.
597
+ */
598
+ function canonicaliseRow(row, spec) {
599
+ for (const [field, aliases] of Object.entries(spec.columns)) {
600
+ const existing = row.fields[field];
601
+ if (existing !== void 0 && existing !== "") continue;
602
+ const value = pick(row.fields, aliases);
603
+ if (value !== void 0) {
604
+ row.fields[field] = value;
605
+ continue;
606
+ }
607
+ const normalised = new Set(Object.keys(row.fields).map(normaliseName));
608
+ if (aliases.some((alias) => normalised.has(normaliseName(alias)))) row.fields[field] = "";
609
+ }
610
+ return row;
611
+ }
612
+ //#endregion
613
+ //#region src/shared/datetime.ts
614
+ const DATE_PATTERN = /^(\d{4})[-/.](\d{1,2})[-/.](\d{1,2})$/;
615
+ const TIME_PATTERN = /^(\d{1,2}):(\d{2})(?::(\d{2}))?$/;
616
+ function pad(value, width = 2) {
617
+ return String(value).padStart(width, "0");
618
+ }
619
+ /** True when the three components form a real calendar date. */
620
+ function isRealDate(year, month, day) {
621
+ if (month < 1 || month > 12) return false;
622
+ if (day < 1 || day > 31) return false;
623
+ const probe = new Date(Date.UTC(year, month - 1, day));
624
+ return probe.getUTCFullYear() === year && probe.getUTCMonth() === month - 1 && probe.getUTCDate() === day;
625
+ }
626
+ /**
627
+ * Parse a wall-clock timestamp.
628
+ * @param raw - text such as `2026-03-15 08:30`, `2026-03-15T08:30:00` or `2026/3/5`.
629
+ * @returns the parsed value, or `undefined` when the text is not a date.
630
+ */
631
+ function parseWallClock(raw) {
632
+ const text = raw.trim().replace(/[T\s]+/, " ");
633
+ if (text === "") return void 0;
634
+ const [datePart, timePart] = text.split(" ", 2);
635
+ const dateMatch = DATE_PATTERN.exec(datePart ?? "");
636
+ if (dateMatch === null) return void 0;
637
+ const year = Number.parseInt(dateMatch[1], 10);
638
+ const month = Number.parseInt(dateMatch[2], 10);
639
+ const day = Number.parseInt(dateMatch[3], 10);
640
+ if (!isRealDate(year, month, day)) return void 0;
641
+ const date = `${pad(year, 4)}-${pad(month)}-${pad(day)}`;
642
+ if (timePart === void 0 || timePart === "") return {
643
+ date,
644
+ time: "00:00",
645
+ hasTime: false,
646
+ minutes: 0
647
+ };
648
+ const timeMatch = TIME_PATTERN.exec(timePart);
649
+ if (timeMatch === null) return void 0;
650
+ const hour = Number.parseInt(timeMatch[1], 10);
651
+ const minute = Number.parseInt(timeMatch[2], 10);
652
+ if (hour > 23 || minute > 59) return void 0;
653
+ return {
654
+ date,
655
+ time: `${pad(hour)}:${pad(minute)}`,
656
+ hasTime: true,
657
+ minutes: hour * 60 + minute
658
+ };
659
+ }
660
+ /** Day number for a `YYYY-MM-DD` string (days since the Unix epoch). */
661
+ function dayNumber(date) {
662
+ const match = DATE_PATTERN.exec(date);
663
+ if (match === null) throw new Error(`dayNumber: bad date "${date}"`);
664
+ const year = Number.parseInt(match[1], 10);
665
+ const month = Number.parseInt(match[2], 10);
666
+ const day = Number.parseInt(match[3], 10);
667
+ return Math.round(Date.UTC(year, month - 1, day) / 864e5);
668
+ }
669
+ /** Whole calendar days from `from` to `to` (negative when `to` is earlier). */
670
+ function diffDays(from, to) {
671
+ return dayNumber(to) - dayNumber(from);
672
+ }
673
+ /** Minute-precision instant for a parsed wall clock. */
674
+ function instant(wall) {
675
+ return dayNumber(wall.date) * 1440 + wall.minutes;
676
+ }
677
+ /** Difference in minutes between two parsed wall clocks. */
678
+ function diffMinutes(from, to) {
679
+ return instant(to) - instant(from);
680
+ }
681
+ //#endregion
682
+ //#region src/shared/report.ts
683
+ /** Locator fields that participate in the stable issue id, in fixed order. */
684
+ const LOCATOR_ID_KEYS = [
685
+ "file",
686
+ "page",
687
+ "para",
688
+ "line",
689
+ "row",
690
+ "cell",
691
+ "column"
692
+ ];
693
+ /** FNV-1a 32-bit hash, rendered as 8 lowercase hex characters. */
694
+ function hashLocator(locator) {
695
+ let hash = 2166136261;
696
+ for (const key of LOCATOR_ID_KEYS) {
697
+ const value = locator[key];
698
+ if (value === void 0) continue;
699
+ const chunk = `${key}=${String(value)};`;
700
+ for (let index = 0; index < chunk.length; index++) {
701
+ hash ^= chunk.charCodeAt(index);
702
+ hash = Math.imul(hash, 16777619) >>> 0;
703
+ }
704
+ }
705
+ return hash.toString(16).padStart(8, "0");
706
+ }
707
+ /** Build an issue id that is stable for the same plugin, rule and location. */
708
+ function issueId(plugin, ruleId, locator) {
709
+ return `${plugin}.${ruleId}.${hashLocator(locator)}`;
710
+ }
711
+ /** Canonical JSON with object keys sorted at every level (stable hashing). */
712
+ function canonicalJson(value) {
713
+ return JSON.stringify(sortValue(value), null, 2);
714
+ }
715
+ function sortValue(value) {
716
+ if (Array.isArray(value)) return value.map(sortValue);
717
+ if (value === null || typeof value !== "object") return value;
718
+ const source = value;
719
+ const target = {};
720
+ for (const key of Object.keys(source).sort()) {
721
+ if (source[key] === void 0) continue;
722
+ target[key] = sortValue(source[key]);
723
+ }
724
+ return target;
725
+ }
726
+ //#endregion
727
+ //#region src/shared/rules.ts
728
+ /**
729
+ * Compose the one-line `basis` string rendered into reports.
730
+ * @param basis - the rule's normative basis.
731
+ * @param alsoBasis - additional clauses reinforcing the same check.
732
+ * @returns document, number, clause and verbatim excerpt, plus provenance qualifiers.
733
+ */
734
+ function formatBasis(basis, alsoBasis = []) {
735
+ const render = (entry) => {
736
+ const head = entry.number === "" ? entry.document : `${entry.document}(${entry.number})`;
737
+ const qualifier = entry.kind === "derived-from-principle" ? "[原则性条款推论,非针对本项的明文要求]" : entry.kind === "institutional-configuration" ? "[阈值为本机构配置,非国家标准]" : "";
738
+ return `${head} ${entry.clause}:「${entry.excerpt}」${qualifier}`;
739
+ };
740
+ const parts = [render(basis), ...alsoBasis.map(render)];
741
+ return parts.length === 1 ? parts[0] : `${parts[0]};另见 ${parts.slice(1).join(";另见 ")}`;
742
+ }
743
+ //#endregion
744
+ //#region src/shared/rows.ts
745
+ /**
746
+ * The generic table-check engine.
747
+ *
748
+ * One engine serves every table-shaped plugin in this family. A plugin declares its
749
+ * columns and its rule pack; the rule pack declares, per rule, a `check.kind` and the
750
+ * parameters that kind needs. Adding a check that fits an existing kind is therefore a
751
+ * rule-pack edit rather than a code change, which is what keeps these plugins small
752
+ * enough to review.
753
+ *
754
+ * Three conventions hold everywhere:
755
+ *
756
+ * 1. **A rule that cannot run says so.** Every handler calls `ctx.skip(reason)` when
757
+ * its configuration or its material is missing, and the reason reaches the report.
758
+ * Silence is never read as "no problem".
759
+ * 2. **Findings describe cells, not conclusions.** Messages say which column and which
760
+ * row, and what was expected; they never assert that a document is invalid, a bid
761
+ * should be rejected or a process is out of control.
762
+ * 3. **A field is looked up in the row first and then in the material's header**,
763
+ * because a register often states a figure once for the whole set (an FOB value, a
764
+ * reference document's revision) rather than repeating it on every row.
765
+ */
766
+ /** Read a string rule parameter. */
767
+ function str(params, key, fallback = "") {
768
+ const value = params[key];
769
+ return typeof value === "string" ? value : fallback;
770
+ }
771
+ /**
772
+ * Read a string-list rule parameter.
773
+ *
774
+ * A pack may name the key twice (`stringList(params, 'fields', 'fields')`) as a
775
+ * readability habit; when the second name is the same as the first, or when it
776
+ * resolves to nothing, an empty list is the honest fallback.
777
+ */
778
+ function stringList(params, key, fallback) {
779
+ const value = params[key];
780
+ if (Array.isArray(value)) {
781
+ const out = value.filter((entry) => typeof entry === "string");
782
+ if (out.length > 0) return out;
783
+ }
784
+ if (Array.isArray(fallback)) return [...fallback];
785
+ return [];
786
+ }
787
+ /** Read a number from a cell, tolerating the shapes a spreadsheet export produces. */
788
+ function numberOf(raw) {
789
+ if (raw === void 0) return void 0;
790
+ const normalised = raw.replace(/[\uff10-\uff19]/g, (ch) => String.fromCharCode(ch.charCodeAt(0) - 65248)).replace(/[,\s\u3000]/g, "");
791
+ const match = /-?\d+(?:\.\d+)?/.exec(normalised);
792
+ if (match === null) return void 0;
793
+ const value = Number(match[0]);
794
+ return Number.isFinite(value) ? value : void 0;
795
+ }
796
+ /**
797
+ * Read a field for a row, falling back to the material's header.
798
+ *
799
+ * A register often states a figure once for the whole set — an FOB value, a reference
800
+ * document's revision, a contract total — rather than repeating it on every row.
801
+ * Arithmetic that divides a row value by such a figure must find it, so every field
802
+ * lookup goes through here.
803
+ */
804
+ function valueOf(input, row, field) {
805
+ const fromRow = row.fields[field];
806
+ if (fromRow !== void 0 && fromRow.trim() !== "") return fromRow;
807
+ const fromHeader = input.header[field];
808
+ if (fromHeader !== void 0 && fromHeader.trim() !== "") return fromHeader;
809
+ }
810
+ /** True when the cell is present and not blank. */
811
+ function filled(value) {
812
+ return value !== void 0 && value.trim() !== "";
813
+ }
814
+ /**
815
+ * True when the material gave this row the column at all, even with a blank cell.
816
+ *
817
+ * Presence checks need this distinction: "the column is not in this material" means the
818
+ * check does not apply, while "the column is here and this cell is blank" is precisely
819
+ * the finding to report. The reader marks a blank cell as an empty string, so the test is
820
+ * whether the key exists — not whether `valueOf` returns text, which would fall through
821
+ * to the header and report the wrong thing.
822
+ */
823
+ function provided(input, row, field) {
824
+ return row.fields[field] !== void 0 || input.header[field] !== void 0;
825
+ }
826
+ /** Read a numeric parameter without inventing a default. */
827
+ function optionalNumber(params, key) {
828
+ const value = params[key];
829
+ if (typeof value !== "number" || !Number.isFinite(value)) return void 0;
830
+ return value;
831
+ }
832
+ /** A finding's location: a row when known, plus the field it concerns. */
833
+ function locatorOf(row, field) {
834
+ const out = {};
835
+ if (row !== void 0) out.row = row;
836
+ if (field !== "") out.field = field;
837
+ return out;
838
+ }
839
+ /**
840
+ * Whether the material carries the named column at all.
841
+ *
842
+ * Presence of the key, not presence of text: the reader marks a blank cell as an empty
843
+ * string, so "the column is not in this material" (the check does not apply) and "the
844
+ * column is here and its cells are blank" (exactly the finding to report) stay distinct.
845
+ * Testing for text here would make an all-blank column look absent and let the check
846
+ * pass silently — the one failure mode this engine family exists to prevent.
847
+ */
848
+ function columnExists(input, field) {
849
+ if (input.header[field] !== void 0) return true;
850
+ return input.rows.some((row) => row.fields[field] !== void 0);
851
+ }
852
+ /** `presence` — the named field must be filled on every row. */
853
+ function presence(ctx) {
854
+ const field = str(ctx.params, "field");
855
+ if (field === "") {
856
+ ctx.skip("规则库未配置 field,本条不执行");
857
+ return;
858
+ }
859
+ if (!columnExists(ctx.input, field)) {
860
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
861
+ return;
862
+ }
863
+ const covered = ctx.input.rows.filter((row) => provided(ctx.input, row, field));
864
+ if (covered.length === 0) {
865
+ ctx.skip(`材料没有携带「${field}」列的行,本条不适用`);
866
+ return;
867
+ }
868
+ for (const row of covered) {
869
+ if (filled(valueOf(ctx.input, row, field))) continue;
870
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为空`, `「${field}」应填写`, str(ctx.params, "fix", "补填该栏;本条只核对是否填写,不判断内容是否恰当"));
871
+ }
872
+ }
873
+ /** `presenceAny` — at least one of the named fields must be filled on every row. */
874
+ function presenceAny(ctx) {
875
+ const fields = stringList(ctx.params, "fields", "fields");
876
+ if (fields.length === 0) {
877
+ ctx.skip("规则库未配置 fields,本条不执行");
878
+ return;
879
+ }
880
+ if (!fields.some((field) => columnExists(ctx.input, field))) {
881
+ ctx.skip(`材料没有 ${fields.join("、")} 中的任何一列,本条不适用`);
882
+ return;
883
+ }
884
+ const covered = ctx.input.rows.filter((row) => fields.some((field) => provided(ctx.input, row, field)));
885
+ if (covered.length === 0) {
886
+ ctx.skip(`材料没有携带 ${fields.join("、")} 任一列的行,本条不适用`);
887
+ return;
888
+ }
889
+ for (const row of covered) {
890
+ if (fields.some((field) => filled(valueOf(ctx.input, row, field)))) continue;
891
+ ctx.add(locatorOf(row.row, fields[0]), `第 ${row.row} 行 ${fields.join("、")} 都为空`, `${fields.join("、")} 至少应填写一项`, str(ctx.params, "fix", "至少补填一项;本条只核对是否填写,不判断内容是否恰当"));
892
+ }
893
+ }
894
+ /** `enum` — the cell must hold one of the configured values. */
895
+ function enumCheck(ctx) {
896
+ const field = str(ctx.params, "field");
897
+ const allowed = stringList(ctx.params, "values", "values");
898
+ if (field === "") {
899
+ ctx.skip("规则库未配置 field,本条不执行");
900
+ return;
901
+ }
902
+ if (allowed.length === 0) {
903
+ ctx.skip(`规则库未配置 values:「${field}」的取值口径由本机构规定,本引擎不硬编码`);
904
+ return;
905
+ }
906
+ if (!columnExists(ctx.input, field)) {
907
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
908
+ return;
909
+ }
910
+ for (const row of ctx.input.rows) {
911
+ const value = (valueOf(ctx.input, row, field) ?? "").trim();
912
+ if (value === "") continue;
913
+ if (allowed.includes(value)) continue;
914
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」的值为「${value}」`, `「${field}」应为 ${allowed.join(" / ")} 之一`, str(ctx.params, "fix", "核对取值来源;本条只核对是否在册,不判断取值本身是否恰当"));
915
+ }
916
+ }
917
+ /** `pattern` — the cell must match the configured regular expression. */
918
+ function patternCheck(ctx) {
919
+ const field = str(ctx.params, "field");
920
+ const pattern = str(ctx.params, "pattern");
921
+ if (field === "" || pattern === "") {
922
+ ctx.skip("规则库未配置 field 或 pattern,本条不执行");
923
+ return;
924
+ }
925
+ let matcher;
926
+ try {
927
+ matcher = new RegExp(pattern);
928
+ } catch (error) {
929
+ ctx.skip(`规则库配置的 pattern 不是合法正则:${error instanceof Error ? error.message : String(error)}`);
930
+ return;
931
+ }
932
+ if (!columnExists(ctx.input, field)) {
933
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
934
+ return;
935
+ }
936
+ for (const row of ctx.input.rows) {
937
+ const value = (valueOf(ctx.input, row, field) ?? "").trim();
938
+ if (value === "") continue;
939
+ if (matcher.test(value)) continue;
940
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」的值「${value}」不符合配置的形式`, `「${field}」应匹配 ${pattern}`, str(ctx.params, "fix", "核对填写形式;本条只核对形式,不判断内容是否正确"));
941
+ }
942
+ }
943
+ /** `date` — the field must parse, and may be bounded by another field or a literal. */
944
+ function dateCheck(ctx) {
945
+ const field = str(ctx.params, "field");
946
+ if (field === "") {
947
+ ctx.skip("规则库未配置 field,本条不执行");
948
+ return;
949
+ }
950
+ const notBeforeField = str(ctx.params, "notBeforeField");
951
+ const notAfterField = str(ctx.params, "notAfterField");
952
+ const notBefore = str(ctx.params, "notBefore");
953
+ const notAfter = str(ctx.params, "notAfter");
954
+ if (!columnExists(ctx.input, field)) {
955
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
956
+ return;
957
+ }
958
+ const today = ctx.options.checkedAt.slice(0, 10);
959
+ for (const row of ctx.input.rows) {
960
+ const raw = (valueOf(ctx.input, row, field) ?? "").trim();
961
+ if (raw === "") continue;
962
+ const parsed = parseWallClock(raw);
963
+ if (parsed === void 0) {
964
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」的值「${raw}」无法解析为日期`, `「${field}」应为 2026-03-15 或 2026-03-15 09:30 这样的可解析日期`, "核对日期写法;本条不会因为无法解析而跳过该行");
965
+ continue;
966
+ }
967
+ const lowerRaw = notBeforeField !== "" ? valueOf(ctx.input, row, notBeforeField) ?? "" : notBefore === "today" ? today : notBefore;
968
+ const upperRaw = notAfterField !== "" ? valueOf(ctx.input, row, notAfterField) ?? "" : notAfter === "today" ? today : notAfter;
969
+ if (lowerRaw !== "") {
970
+ const lower = parseWallClock(lowerRaw);
971
+ if (lower === void 0) ctx.add(locatorOf(row.row, field), `第 ${row.row} 行的下限日期「${lowerRaw}」无法解析,无法与「${field}」比较`, "用于比较的日期应可解析", "核对日期写法");
972
+ else if (parsed.date < lower.date) ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${parsed.date},早于 ${notBeforeField !== "" ? `「${notBeforeField}」的 ` : ""}${lower.date}`, `「${field}」不应早于 ${lower.date}`, str(ctx.params, "fix", "核对两个日期的填写;本条只比较先后"));
973
+ }
974
+ if (upperRaw === "") continue;
975
+ const upper = parseWallClock(upperRaw);
976
+ if (upper === void 0) {
977
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行的上限日期「${upperRaw}」无法解析,无法与「${field}」比较`, "用于比较的日期应可解析", "核对日期写法");
978
+ continue;
979
+ }
980
+ if (parsed.date <= upper.date) continue;
981
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${parsed.date},晚于 ${notAfterField !== "" ? `「${notAfterField}」的 ` : ""}${upper.date}`, `「${field}」不应晚于 ${upper.date}`, str(ctx.params, "fix", "核对两个日期的填写;本条只比较先后"));
982
+ }
983
+ }
984
+ /** `noFutureDate` — a date must not be later than the check date. */
985
+ function noFutureDate(ctx) {
986
+ const field = str(ctx.params, "field");
987
+ if (field === "") {
988
+ ctx.skip("规则库未配置 field,本条不执行");
989
+ return;
990
+ }
991
+ if (!columnExists(ctx.input, field)) {
992
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
993
+ return;
994
+ }
995
+ const today = ctx.options.checkedAt.slice(0, 10);
996
+ for (const row of ctx.input.rows) {
997
+ const raw = (valueOf(ctx.input, row, field) ?? "").trim();
998
+ if (raw === "") continue;
999
+ const parsed = parseWallClock(raw);
1000
+ if (parsed === void 0) {
1001
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为「${raw}」,无法解析为日期`, `「${field}」应为 ${today} 之前的可解析日期`, "核对日期写法;本条识别 2026-03-15 与 2026-03-15 09:30 两种形式");
1002
+ continue;
1003
+ }
1004
+ if (parsed.date <= today) continue;
1005
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${parsed.date},晚于核对日 ${today}`, `「${field}」应不晚于核对日`, "核对日期是否填错;本条只核对先后,不判断数据是否可靠");
1006
+ }
1007
+ }
1008
+ /** `dataAge` — a dated cell must be recent enough under a configured window. */
1009
+ function dataAge(ctx) {
1010
+ const field = str(ctx.params, "field");
1011
+ const maxDays = optionalNumber(ctx.params, "maxDays");
1012
+ if (field === "") {
1013
+ ctx.skip("规则库未配置 field,本条不执行");
1014
+ return;
1015
+ }
1016
+ if (maxDays === void 0 || maxDays <= 0) {
1017
+ ctx.skip(`规则库未配置 maxDays:「${field}」的数据新鲜度要求由本机构编辑口径规定,本引擎不硬编码`);
1018
+ return;
1019
+ }
1020
+ if (!columnExists(ctx.input, field)) {
1021
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1022
+ return;
1023
+ }
1024
+ const today = ctx.options.checkedAt.slice(0, 10);
1025
+ for (const row of ctx.input.rows) {
1026
+ const raw = (valueOf(ctx.input, row, field) ?? "").trim();
1027
+ if (raw === "") continue;
1028
+ const parsed = parseWallClock(raw);
1029
+ if (parsed === void 0) {
1030
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为「${raw}」,无法解析为日期`, `「${field}」应为可解析日期,且距核对日不超过 ${maxDays} 天`, "核对日期写法");
1031
+ continue;
1032
+ }
1033
+ const age = diffDays(parsed.date, today);
1034
+ if (age <= maxDays) continue;
1035
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${parsed.date},距核对日 ${age} 天,超过配置的 ${maxDays} 天`, `「${field}」的数据不应早于核对日前 ${maxDays} 天`, str(ctx.params, "fix", "更新数据或说明引用旧数据的原因;本条的天数来自本机构配置,不是标准数值"));
1036
+ }
1037
+ }
1038
+ /** `number` — the cell must parse as a number, optionally positive or bounded. */
1039
+ function numberCheck(ctx) {
1040
+ const field = str(ctx.params, "field");
1041
+ if (field === "") {
1042
+ ctx.skip("规则库未配置 field,本条不执行");
1043
+ return;
1044
+ }
1045
+ const min = optionalNumber(ctx.params, "min");
1046
+ const max = optionalNumber(ctx.params, "max");
1047
+ const positive = ctx.params.positive === true;
1048
+ if (!columnExists(ctx.input, field)) {
1049
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1050
+ return;
1051
+ }
1052
+ for (const row of ctx.input.rows) {
1053
+ const raw = (valueOf(ctx.input, row, field) ?? "").trim();
1054
+ if (raw === "") continue;
1055
+ const value = numberOf(raw);
1056
+ if (value === void 0) {
1057
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」的值「${raw}」无法解析为数字`, `「${field}」应填写数字`, str(ctx.params, "fix", "核对填写形式;本条只核对可解析性,不判断数值是否合理"));
1058
+ continue;
1059
+ }
1060
+ if (positive && value <= 0) {
1061
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${value},不是正数`, `「${field}」应为大于零的数值`, str(ctx.params, "fix", "核对取值;本条只核对是否为正数"));
1062
+ continue;
1063
+ }
1064
+ if (min !== void 0 && value < min) {
1065
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${value},小于配置下限 ${min}`, `「${field}」应不小于 ${min}`, str(ctx.params, "fix", "核对取值;本条的上下限来自本机构配置"));
1066
+ continue;
1067
+ }
1068
+ if (max === void 0 || value <= max) continue;
1069
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${value},大于配置上限 ${max}`, `「${field}」应不大于 ${max}`, str(ctx.params, "fix", "核对取值;本条的上下限来自本机构配置"));
1070
+ }
1071
+ }
1072
+ /** `unique` — no two rows may share the same value, ignoring whitespace. */
1073
+ function uniqueCheck(ctx) {
1074
+ const field = str(ctx.params, "field");
1075
+ if (field === "") {
1076
+ ctx.skip("规则库未配置 field,本条不执行");
1077
+ return;
1078
+ }
1079
+ const seen = /* @__PURE__ */ new Map();
1080
+ let considered = 0;
1081
+ for (const row of ctx.input.rows) {
1082
+ const value = (valueOf(ctx.input, row, field) ?? "").replace(/[\s\u3000]/g, "");
1083
+ if (value === "") continue;
1084
+ considered += 1;
1085
+ const first = seen.get(value);
1086
+ if (first === void 0) {
1087
+ seen.set(value, row);
1088
+ continue;
1089
+ }
1090
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」的值「${valueOf(ctx.input, row, field)}」与第 ${first.row} 行重复`, `「${field}」在材料内应唯一`, str(ctx.params, "fix", "核对是否重复登记或抄错;本条只核对唯一性"));
1091
+ }
1092
+ if (considered === 0) ctx.skip(`材料没有「${field}」列,本条不适用`);
1093
+ }
1094
+ /** `sequence` — numbers in the field should be continuous over their own range. */
1095
+ function sequenceCheck(ctx) {
1096
+ const field = str(ctx.params, "field");
1097
+ if (field === "") {
1098
+ ctx.skip("规则库未配置 field,本条不执行");
1099
+ return;
1100
+ }
1101
+ const numbers = ctx.input.rows.map((row) => ({
1102
+ row,
1103
+ value: numberOf(valueOf(ctx.input, row, field))
1104
+ })).filter((entry) => entry.value !== void 0);
1105
+ if (numbers.length < 2) {
1106
+ ctx.skip(`材料中「${field}」可解析为数字的行不足两行,无法核对连续性`);
1107
+ return;
1108
+ }
1109
+ const values = numbers.map((entry) => Math.round(entry.value));
1110
+ const min = Math.min(...values);
1111
+ const max = Math.max(...values);
1112
+ if (max - min + 1 > 5e3) {
1113
+ ctx.skip(`「${field}」的取值跨度过大(${min}~${max}),本条不执行`);
1114
+ return;
1115
+ }
1116
+ const present = new Set(values);
1117
+ const missing = [];
1118
+ for (let value = min; value <= max; value += 1) if (!present.has(value)) missing.push(value);
1119
+ if (missing.length === 0) return;
1120
+ ctx.add(locatorOf(numbers[0]?.row.row, field), `${field} 范围 ${min}~${max} 中缺 ${missing.length} 个号:${missing.slice(0, 20).join("、")}${missing.length > 20 ? " 等" : ""}`, `「${field}」应在同一范围内连续`, "核对是否漏登记;缺号本身不构成问题,需人工确认");
1121
+ }
1122
+ /** `sum` — a column of numbers must total a header field. */
1123
+ function sumCheck(ctx) {
1124
+ const field = str(ctx.params, "field");
1125
+ const headerField = str(ctx.params, "headerField");
1126
+ if (field === "" || headerField === "") {
1127
+ ctx.skip("规则库未配置 field 或 headerField,本条不执行");
1128
+ return;
1129
+ }
1130
+ const parsed = ctx.input.rows.map((row) => numberOf(valueOf(ctx.input, row, field))).filter((value) => value !== void 0);
1131
+ if (parsed.length === 0) {
1132
+ ctx.skip(`材料没有可解析为数字的「${field}」值,无法核对合计`);
1133
+ return;
1134
+ }
1135
+ const declared = numberOf(ctx.input.header[headerField]);
1136
+ if (declared === void 0) {
1137
+ ctx.skip(`材料未提供可解析的「${headerField}」,无法与「${field}」的合计核对`);
1138
+ return;
1139
+ }
1140
+ const total = parsed.reduce((sum, value) => sum + value, 0);
1141
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1142
+ const gap = Math.abs(total - declared);
1143
+ if (gap <= tolerance) return;
1144
+ ctx.add(locatorOf(void 0, field), `${parsed.length} 行的「${field}」合计 ${Number(total.toFixed(4))},与「${headerField}」${declared} 相差 ${Number(gap.toFixed(4))}`, `「${field}」的合计应与「${headerField}」一致,允许偏差 ${tolerance}`, "核对是否有行遗漏、重复计入或口径不一致;本条只做加法核对");
1145
+ }
1146
+ /** `sumToConstant` — a column of numbers must total a configured constant. */
1147
+ function sumToConstant(ctx) {
1148
+ const field = str(ctx.params, "field");
1149
+ const target = optionalNumber(ctx.params, "target");
1150
+ if (field === "") {
1151
+ ctx.skip("规则库未配置 field,本条不执行");
1152
+ return;
1153
+ }
1154
+ if (target === void 0 || target <= 0) {
1155
+ ctx.skip(`规则库未配置 target:「${field}」的合计目标由招标文件或本机构口径规定,本引擎不硬编码`);
1156
+ return;
1157
+ }
1158
+ const parsed = ctx.input.rows.map((row) => numberOf(valueOf(ctx.input, row, field))).filter((value) => value !== void 0);
1159
+ if (parsed.length === 0) {
1160
+ ctx.skip(`材料没有可解析为数字的「${field}」值,无法核对合计`);
1161
+ return;
1162
+ }
1163
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1164
+ const total = parsed.reduce((sum, value) => sum + value, 0);
1165
+ const gap = Math.abs(total - target);
1166
+ if (gap <= tolerance) return;
1167
+ ctx.add(locatorOf(void 0, field), `${parsed.length} 行的「${field}」合计 ${Number(total.toFixed(4))},与配置的目标值 ${target} 相差 ${Number(gap.toFixed(4))}`, `「${field}」的合计应为 ${target},允许偏差 ${tolerance}`, str(ctx.params, "fix", "核对是否有行遗漏或数值抄错;本条只做加法核对,不判断设置是否合规"));
1168
+ }
1169
+ /** `countPerGroup` — each group value must appear at least N times. */
1170
+ function countPerGroup(ctx) {
1171
+ const field = str(ctx.params, "field");
1172
+ const minRows = optionalNumber(ctx.params, "minRows") ?? 1;
1173
+ if (field === "") {
1174
+ ctx.skip("规则库未配置 field,本条不执行");
1175
+ return;
1176
+ }
1177
+ const groups = /* @__PURE__ */ new Map();
1178
+ for (const row of ctx.input.rows) {
1179
+ const value = (valueOf(ctx.input, row, field) ?? "").trim();
1180
+ if (value === "") continue;
1181
+ const bucket = groups.get(value);
1182
+ if (bucket === void 0) groups.set(value, [row]);
1183
+ else bucket.push(row);
1184
+ }
1185
+ if (groups.size === 0) {
1186
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1187
+ return;
1188
+ }
1189
+ for (const [value, rows] of groups) {
1190
+ if (rows.length >= minRows) continue;
1191
+ ctx.add(locatorOf(rows[0]?.row, field), `「${field}」为「${value}」的只有 ${rows.length} 行,少于配置的 ${minRows} 行`, `每个「${field}」应至少出现 ${minRows} 行`, str(ctx.params, "fix", "核对是否漏登记;本条只核对行数,不判断内容是否完整"));
1192
+ }
1193
+ }
1194
+ /** `textContains` — the cell should contain all the configured terms. */
1195
+ function textContains(ctx) {
1196
+ const field = str(ctx.params, "field");
1197
+ const terms = stringList(ctx.params, "terms", "terms");
1198
+ if (field === "" || terms.length === 0) {
1199
+ ctx.skip("规则库未配置 field 或 terms,本条不执行");
1200
+ return;
1201
+ }
1202
+ if (!columnExists(ctx.input, field)) {
1203
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1204
+ return;
1205
+ }
1206
+ for (const row of ctx.input.rows) {
1207
+ const text = valueOf(ctx.input, row, field) ?? "";
1208
+ if (text === "") continue;
1209
+ const missing = terms.filter((term) => !text.includes(term));
1210
+ if (missing.length === 0) continue;
1211
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」中未见 ${missing.join("、")}`, `「${field}」应包含 ${terms.join("、")}`, str(ctx.params, "fix", "核对是否遗漏;本条只核对字面是否出现,不判断表述是否恰当"));
1212
+ }
1213
+ }
1214
+ /** `containsAny` — the cell must not contain any of the configured terms. */
1215
+ function containsAny(ctx) {
1216
+ const field = str(ctx.params, "field");
1217
+ const terms = stringList(ctx.params, "terms", "terms");
1218
+ if (field === "" || terms.length === 0) {
1219
+ ctx.skip("规则库未配置 field 或 terms,本条不执行");
1220
+ return;
1221
+ }
1222
+ if (!columnExists(ctx.input, field)) {
1223
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1224
+ return;
1225
+ }
1226
+ for (const row of ctx.input.rows) {
1227
+ const text = valueOf(ctx.input, row, field) ?? "";
1228
+ if (text === "") continue;
1229
+ const hit = terms.find((term) => text.includes(term));
1230
+ if (hit === void 0) continue;
1231
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」中残留占位符「${hit}」`, `「${field}」应填写实际内容,不含未替换的占位符`, str(ctx.params, "fix", "核对是否直接照抄了模板"));
1232
+ }
1233
+ }
1234
+ /** `length` — the cell's length must sit inside a configured range. */
1235
+ function lengthCheck(ctx) {
1236
+ const field = str(ctx.params, "field");
1237
+ if (field === "") {
1238
+ ctx.skip("规则库未配置 field,本条不执行");
1239
+ return;
1240
+ }
1241
+ if (!columnExists(ctx.input, field)) {
1242
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1243
+ return;
1244
+ }
1245
+ const max = optionalNumber(ctx.params, "maxLength");
1246
+ const min = optionalNumber(ctx.params, "minLength");
1247
+ if (max === void 0 && min === void 0) {
1248
+ ctx.skip(`规则库未配置 maxLength 或 minLength:「${field}」的长度限制属本机构口径,本条不执行`);
1249
+ return;
1250
+ }
1251
+ for (const row of ctx.input.rows) {
1252
+ const value = valueOf(ctx.input, row, field) ?? "";
1253
+ if (value === "") continue;
1254
+ const count = [...value].length;
1255
+ if (max !== void 0 && count > max) {
1256
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」共 ${count} 字,超过配置上限 ${max} 字`, `「${field}」应不超过 ${max} 字`, str(ctx.params, "fix", "核对长度限制来源;本条的上下限来自本机构配置"));
1257
+ continue;
1258
+ }
1259
+ if (min === void 0 || count >= min) continue;
1260
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」共 ${count} 字,少于配置下限 ${min} 字`, `「${field}」应不少于 ${min} 字`, str(ctx.params, "fix", "核对是否漏填;本条的上下限来自本机构配置"));
1261
+ }
1262
+ }
1263
+ /** `minLength` — the cell must hold at least N characters. */
1264
+ function minLength(ctx) {
1265
+ const field = str(ctx.params, "field");
1266
+ const min = optionalNumber(ctx.params, "minLength");
1267
+ if (field === "" || min === void 0 || min <= 0) {
1268
+ ctx.skip("规则库未配置 field 或 minLength,本条不执行");
1269
+ return;
1270
+ }
1271
+ if (!columnExists(ctx.input, field)) {
1272
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1273
+ return;
1274
+ }
1275
+ for (const row of ctx.input.rows) {
1276
+ const value = (valueOf(ctx.input, row, field) ?? "").trim();
1277
+ if (value === "") continue;
1278
+ const count = [...value].length;
1279
+ if (count >= min) continue;
1280
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」只有 ${count} 字,短于配置下限 ${min} 字`, `「${field}」应至少有 ${min} 字`, str(ctx.params, "fix", "核对是否真的是摘录;本条只核对长度,不判断内容是否忠实于原文"));
1281
+ }
1282
+ }
1283
+ /** `headerPresence` — the material's header must carry the named fields. */
1284
+ function headerPresence(ctx) {
1285
+ const fields = stringList(ctx.params, "fields", "fields");
1286
+ if (fields.length === 0) {
1287
+ ctx.skip("规则库未配置 fields,本条不执行");
1288
+ return;
1289
+ }
1290
+ const missing = fields.filter((field) => !filled(ctx.input.header[field]));
1291
+ if (missing.length === 0) return;
1292
+ ctx.add(locatorOf(void 0, missing[0]), `材料表头缺少 ${missing.join("、")}`, `表头应提供 ${fields.join("、")}`, str(ctx.params, "fix", "补齐表头字段;本条只核对表头是否声明,不判断内容是否恰当"));
1293
+ }
1294
+ /** `headerEnum` — a header value must come from the configured vocabulary. */
1295
+ function headerEnum(ctx) {
1296
+ const field = str(ctx.params, "field");
1297
+ const values = stringList(ctx.params, "values", "values");
1298
+ if (field === "") {
1299
+ ctx.skip("规则库未配置 field,本条不执行");
1300
+ return;
1301
+ }
1302
+ const actual = (ctx.input.header[field] ?? "").trim();
1303
+ if (actual === "") {
1304
+ ctx.skip(`材料表头未提供「${field}」,本条不适用`);
1305
+ return;
1306
+ }
1307
+ if (values.length === 0) {
1308
+ ctx.skip(`规则库未配置 values:「${field}」的取值口径由顾客或本机构规定,本引擎不硬编码`);
1309
+ return;
1310
+ }
1311
+ if (values.includes(actual)) return;
1312
+ ctx.add(locatorOf(void 0, field), `材料表头「${field}」的值为「${actual}」`, `「${field}」应为 ${values.join(" / ")} 之一`, "核对取值来源;本条只核对是否在册,不判断该取值本身是否恰当");
1313
+ }
1314
+ /**
1315
+ * `conditionalPresence` — when the condition field is met, the listed fields must be
1316
+ * filled. The condition can be "not blank" (the default), "one of these values", or
1317
+ * "matches this pattern".
1318
+ */
1319
+ function conditionalPresence(ctx) {
1320
+ const conditionField = str(ctx.params, "conditionField");
1321
+ const required = stringList(ctx.params, "requiredFields", "requiredFields");
1322
+ if (conditionField === "" || required.length === 0) {
1323
+ ctx.skip("规则库未配置 conditionField 或 requiredFields,本条不执行");
1324
+ return;
1325
+ }
1326
+ const values = stringList(ctx.params, "conditionValues", "conditionValues");
1327
+ const pattern = str(ctx.params, "conditionPattern");
1328
+ let matcher;
1329
+ if (pattern !== "") try {
1330
+ matcher = new RegExp(pattern);
1331
+ } catch (error) {
1332
+ ctx.skip(`规则库配置的 conditionPattern 不是合法正则:${error instanceof Error ? error.message : String(error)}`);
1333
+ return;
1334
+ }
1335
+ const triggered = ctx.input.rows.filter((row) => {
1336
+ const value = (valueOf(ctx.input, row, conditionField) ?? "").trim();
1337
+ if (value === "") return false;
1338
+ if (values.length > 0) return values.some((candidate) => candidate === value);
1339
+ if (matcher !== void 0) return matcher.test(value);
1340
+ return true;
1341
+ });
1342
+ if (triggered.length === 0) {
1343
+ ctx.skip(`材料中没有满足「${conditionField}」条件的行,本条不适用`);
1344
+ return;
1345
+ }
1346
+ for (const row of triggered) {
1347
+ const missing = required.filter((field) => !filled(valueOf(ctx.input, row, field)));
1348
+ if (missing.length === 0) continue;
1349
+ ctx.add(locatorOf(row.row, conditionField), `第 ${row.row} 行「${conditionField}」为「${valueOf(ctx.input, row, conditionField)}」,但缺 ${missing.join("、")}`, `满足该条件的行应同时填写 ${required.join("、")}`, str(ctx.params, "fix", "补齐;本条只核对字段是否存在,不判断内容是否恰当"));
1350
+ }
1351
+ }
1352
+ /** `highRiskEvidence` — a row whose requirement matches a keyword must carry evidence. */
1353
+ function highRiskEvidence(ctx) {
1354
+ const keywords = stringList(ctx.params, "highRiskKeywords", "highRiskKeywords");
1355
+ if (keywords.length === 0) {
1356
+ ctx.skip("规则库未配置 highRiskKeywords:哪些条款属于高风险取决于招标文件本身,本引擎不硬编码");
1357
+ return;
1358
+ }
1359
+ const evidenceField = str(ctx.params, "evidenceField", "evidence");
1360
+ const requirementField = str(ctx.params, "requirementField", "requirement");
1361
+ const matching = ctx.input.rows.filter((row) => {
1362
+ const text = valueOf(ctx.input, row, requirementField) ?? "";
1363
+ return text !== "" && keywords.some((keyword) => text.includes(keyword));
1364
+ });
1365
+ if (matching.length === 0) {
1366
+ ctx.skip(`材料中没有命中高风险关键词(${keywords.join("、")})的条款,本条不适用`);
1367
+ return;
1368
+ }
1369
+ for (const row of matching) {
1370
+ if (filled(valueOf(ctx.input, row, evidenceField))) continue;
1371
+ const text = valueOf(ctx.input, row, requirementField) ?? "";
1372
+ const hit = keywords.find((keyword) => text.includes(keyword));
1373
+ ctx.add(locatorOf(row.row, evidenceField), `第 ${row.row} 行的要求命中高风险关键词「${hit}」,但未填写证明材料`, `命中 ${keywords.join(" / ")} 的条款应填写证明材料`, "补填证明材料或页码;本条只核对是否填写,不判断证明材料是否有效");
1374
+ }
1375
+ }
1376
+ /** `thresholdPresence` — a row crossing the threshold must carry the named field. */
1377
+ function thresholdPresence(ctx) {
1378
+ const triggerField = str(ctx.params, "triggerField");
1379
+ const requiredField = str(ctx.params, "requiredField");
1380
+ const threshold = optionalNumber(ctx.params, "threshold");
1381
+ if (triggerField === "" || requiredField === "") {
1382
+ ctx.skip("规则库未配置 triggerField 或 requiredField,本条不执行");
1383
+ return;
1384
+ }
1385
+ if (threshold === void 0 || threshold <= 0) {
1386
+ ctx.skip("规则库未配置 threshold:「多高算高风险、多高必须提措施」取决于本机构风险准则,本引擎不硬编码");
1387
+ return;
1388
+ }
1389
+ const triggered = ctx.input.rows.filter((row) => {
1390
+ const value = numberOf(valueOf(ctx.input, row, triggerField));
1391
+ return value !== void 0 && value >= threshold;
1392
+ });
1393
+ if (triggered.length === 0) {
1394
+ ctx.skip(`材料中没有「${triggerField}」达到 ${threshold} 的行,本条不适用`);
1395
+ return;
1396
+ }
1397
+ for (const row of triggered) {
1398
+ if (filled(valueOf(ctx.input, row, requiredField))) continue;
1399
+ ctx.add(locatorOf(row.row, requiredField), `第 ${row.row} 行「${triggerField}」为 ${valueOf(ctx.input, row, triggerField)},达到配置阈值 ${threshold},但未填写「${requiredField}」`, `「${triggerField}」达到 ${threshold} 的行应填写「${requiredField}」`, "补填;本条的阈值来自本机构配置,不是标准数值");
1400
+ }
1401
+ }
1402
+ /** `thresholdBelow` — a value must not fall below the configured criterion. */
1403
+ function thresholdBelow(ctx) {
1404
+ const field = str(ctx.params, "field");
1405
+ const threshold = optionalNumber(ctx.params, "threshold");
1406
+ if (field === "") {
1407
+ ctx.skip("规则库未配置 field,本条不执行");
1408
+ return;
1409
+ }
1410
+ if (threshold === void 0 || threshold <= 0) {
1411
+ ctx.skip("规则库未配置 threshold:接收准则因行业、顾客与特性重要度而异,本引擎不硬编码任何准则");
1412
+ return;
1413
+ }
1414
+ if (!columnExists(ctx.input, field)) {
1415
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1416
+ return;
1417
+ }
1418
+ for (const row of ctx.input.rows) {
1419
+ const value = numberOf(valueOf(ctx.input, row, field));
1420
+ if (value === void 0) continue;
1421
+ if (value >= threshold) continue;
1422
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为 ${value},低于本机构配置的准则 ${threshold}`, `「${field}」应不低于 ${threshold}`, str(ctx.params, "fix", "核对取值;本条的准则来自本机构或顾客配置,不是标准数值"));
1423
+ }
1424
+ }
1425
+ /** `fieldComparison` — two fields must satisfy a stated relation. */
1426
+ function fieldComparison(ctx) {
1427
+ const leftField = str(ctx.params, "leftField");
1428
+ const rightField = str(ctx.params, "rightField");
1429
+ const relation = str(ctx.params, "relation", "gte");
1430
+ if (leftField === "" || rightField === "") {
1431
+ ctx.skip("规则库未配置 leftField 或 rightField,本条不执行");
1432
+ return;
1433
+ }
1434
+ const label = {
1435
+ gte: "不小于",
1436
+ gt: "大于",
1437
+ lte: "不大于",
1438
+ lt: "小于",
1439
+ eq: "等于"
1440
+ };
1441
+ const comparable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, leftField)) && filled(valueOf(ctx.input, row, rightField)));
1442
+ if (comparable.length === 0) {
1443
+ ctx.skip(`材料没有同时填写「${leftField}」与「${rightField}」的行,本条不适用`);
1444
+ return;
1445
+ }
1446
+ for (const row of comparable) {
1447
+ const leftRaw = valueOf(ctx.input, row, leftField) ?? "";
1448
+ const rightRaw = valueOf(ctx.input, row, rightField) ?? "";
1449
+ const left = numberOf(leftRaw);
1450
+ const right = numberOf(rightRaw);
1451
+ const leftDate = left === void 0 ? parseWallClock(leftRaw) : void 0;
1452
+ const rightDate = right === void 0 ? parseWallClock(rightRaw) : void 0;
1453
+ let ok;
1454
+ if (left !== void 0 && right !== void 0) ok = relation === "gte" ? left >= right : relation === "gt" ? left > right : relation === "lte" ? left <= right : relation === "lt" ? left < right : relation === "eq" ? left === right : void 0;
1455
+ else if (leftDate !== void 0 && rightDate !== void 0) ok = relation === "gte" ? leftDate.date >= rightDate.date : relation === "gt" ? leftDate.date > rightDate.date : relation === "lte" ? leftDate.date <= rightDate.date : relation === "lt" ? leftDate.date < rightDate.date : relation === "eq" ? leftDate.date === rightDate.date : void 0;
1456
+ if (ok === void 0) {
1457
+ ctx.add(locatorOf(row.row, leftField), `第 ${row.row} 行「${leftField}」${leftRaw} 与「${rightField}」${rightRaw} 无法作为可比数值或日期比较`, `「${leftField}」应${label[relation] ?? relation}「${rightField}」`, "核对两栏的填写形式;本条只做比较,不判断数值是否真实");
1458
+ continue;
1459
+ }
1460
+ if (ok) continue;
1461
+ ctx.add(locatorOf(row.row, leftField), `第 ${row.row} 行「${leftField}」为 ${leftRaw},「${rightField}」为 ${rightRaw},不满足${label[relation] ?? relation}关系`, `「${leftField}」应${label[relation] ?? relation}「${rightField}」`, str(ctx.params, "fix", "核对两栏是否填反或抄错;本条只做比较,不判断数值是否真实"));
1462
+ }
1463
+ }
1464
+ /** `intervalOrder` — three values must be ordered low ≤ middle ≤ high. */
1465
+ function intervalOrder(ctx) {
1466
+ const lowerField = str(ctx.params, "lowerField");
1467
+ const middleField = str(ctx.params, "middleField");
1468
+ const upperField = str(ctx.params, "upperField");
1469
+ if (lowerField === "" || middleField === "" || upperField === "") {
1470
+ ctx.skip("规则库未配置 lowerField、middleField 或 upperField,本条不执行");
1471
+ return;
1472
+ }
1473
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, lowerField)) && filled(valueOf(ctx.input, row, middleField)) && filled(valueOf(ctx.input, row, upperField)));
1474
+ if (usable.length === 0) {
1475
+ ctx.skip(`材料没有同时填写「${lowerField}」「${middleField}」「${upperField}」的行,本条不适用`);
1476
+ return;
1477
+ }
1478
+ for (const row of usable) {
1479
+ const lower = numberOf(valueOf(ctx.input, row, lowerField));
1480
+ const middle = numberOf(valueOf(ctx.input, row, middleField));
1481
+ const upper = numberOf(valueOf(ctx.input, row, upperField));
1482
+ if (lower === void 0 || middle === void 0 || upper === void 0) {
1483
+ ctx.add(locatorOf(row.row, middleField), `第 ${row.row} 行的「${lowerField}」「${middleField}」「${upperField}」中有无法解析为数值的值`, `三个值应可比较,且满足 ${lowerField} ≤ ${middleField} ≤ ${upperField}`, "核对填写形式");
1484
+ continue;
1485
+ }
1486
+ if (lower <= middle && middle <= upper) continue;
1487
+ ctx.add(locatorOf(row.row, middleField), `第 ${row.row} 行「${middleField}」为 ${middle},而「${lowerField}」为 ${lower}、「${upperField}」为 ${upper}`, `应满足 ${lowerField} ≤ ${middleField} ≤ ${upperField}`, "核对三个数值的填写;本条只核对次序,不判断数值是否正确");
1488
+ }
1489
+ }
1490
+ /** `productOf` — the result must equal the product of the named factors. */
1491
+ function productOf(ctx) {
1492
+ const resultField = str(ctx.params, "resultField");
1493
+ const factors = stringList(ctx.params, "factorFields", "factorFields");
1494
+ if (resultField === "" || factors.length === 0) {
1495
+ ctx.skip("规则库未配置 resultField 或 factorFields,本条不执行");
1496
+ return;
1497
+ }
1498
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1499
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, resultField)) && factors.every((factor) => filled(valueOf(ctx.input, row, factor))));
1500
+ if (usable.length === 0) {
1501
+ ctx.skip(`材料没有同时填写「${resultField}」与 ${factors.join("、")} 的行,无法核对乘积`);
1502
+ return;
1503
+ }
1504
+ const round = (value) => Number(value.toFixed(6));
1505
+ for (const row of usable) {
1506
+ const declared = numberOf(valueOf(ctx.input, row, resultField));
1507
+ const values = factors.map((factor) => numberOf(valueOf(ctx.input, row, factor)));
1508
+ if (declared === void 0 || values.some((value) => value === void 0)) {
1509
+ ctx.add(locatorOf(row.row, resultField), `第 ${row.row} 行的「${resultField}」或 ${factors.join("、")} 中有无法解析为数值的值`, `「${resultField}」应等于 ${factors.join(" × ")}`, "核对填写形式;本条只做算术核对,不判断各因子是否正确");
1510
+ continue;
1511
+ }
1512
+ const numbers = values;
1513
+ const product = numbers.reduce((total, value) => total * value, 1);
1514
+ if (Math.abs(product - declared) <= tolerance) continue;
1515
+ ctx.add(locatorOf(row.row, resultField), `第 ${row.row} 行「${resultField}」填报 ${declared},而 ${numbers.join(" × ")} = ${round(product)}`, `「${resultField}」应等于 ${factors.join(" × ")}`, "核对是否抄错或漏改;本条只做算术核对,不判断各因子是否正确");
1516
+ }
1517
+ }
1518
+ /** `formula` — the result must equal an arithmetic expression over other fields. */
1519
+ function formula(ctx) {
1520
+ const resultField = str(ctx.params, "resultField");
1521
+ const expression = ctx.params.expression;
1522
+ if (resultField === "" || typeof expression !== "object" || expression === null || Array.isArray(expression)) {
1523
+ ctx.skip("规则库未配置 resultField 或 expression,本条不执行");
1524
+ return;
1525
+ }
1526
+ const spec = expression;
1527
+ const op = typeof spec.op === "string" ? spec.op : "";
1528
+ const fields = Array.isArray(spec.fields) ? spec.fields.filter((entry) => typeof entry === "string") : [];
1529
+ const scale = typeof spec.scale === "number" && Number.isFinite(spec.scale) ? spec.scale : 1;
1530
+ /**
1531
+ * Per-term signs for `sum`.
1532
+ *
1533
+ * A composed total is naturally written as some terms added and others subtracted —
1534
+ * `配件费 + 工时费 − 折扣`. Expressing that as signs keeps it a single addition, so the
1535
+ * message reads like the arithmetic a person would write down, and a negative overall
1536
+ * scale is never needed (which would invert every term, not just the last).
1537
+ */
1538
+ const signs = Array.isArray(spec.signs) ? spec.signs.map((entry) => entry === -1 || entry === "-1" || entry === "-" ? -1 : 1) : [];
1539
+ if (fields.length === 0 || ![
1540
+ "divide",
1541
+ "product",
1542
+ "sum",
1543
+ "subtract",
1544
+ "percent"
1545
+ ].includes(op)) {
1546
+ ctx.skip("规则库配置的 expression 不完整:op 应为 divide/product/sum/subtract/percent,且需给出 fields");
1547
+ return;
1548
+ }
1549
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, resultField)) && fields.every((field) => filled(valueOf(ctx.input, row, field))));
1550
+ if (usable.length === 0) {
1551
+ ctx.skip(`材料没有同时填写「${resultField}」与 ${fields.join("、")} 的行,无法核对表达式`);
1552
+ return;
1553
+ }
1554
+ const headerUsable = fields.every((field) => filled(ctx.input.header[field]));
1555
+ if (usable.length === 0 && !headerUsable) {
1556
+ ctx.skip(`材料没有同时填写「${resultField}」与 ${fields.join("、")} 的行,无法核对表达式`);
1557
+ return;
1558
+ }
1559
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1560
+ const label = {
1561
+ divide: fields.join(" ÷ "),
1562
+ product: fields.join(" × "),
1563
+ sum: fields.map((field, index) => `${signs[index] === -1 ? "−" : index === 0 ? "" : "+"}${field}`).join(" "),
1564
+ subtract: `${fields[0]} − ${fields.slice(1).join(" − ")}`,
1565
+ percent: `${fields.join(" ÷ ")} × 100`
1566
+ };
1567
+ const round = (value) => Number(value.toFixed(6));
1568
+ for (const row of usable) {
1569
+ const declared = numberOf(valueOf(ctx.input, row, resultField));
1570
+ const values = fields.map((field) => numberOf(valueOf(ctx.input, row, field)));
1571
+ if (declared === void 0 || values.some((value) => value === void 0)) {
1572
+ ctx.add(locatorOf(row.row, resultField), `第 ${row.row} 行的「${resultField}」或 ${fields.join("、")} 中有无法解析为数值的值`, `「${resultField}」应等于 ${label[op] ?? op}`, "核对填写形式;本条只做算术核对");
1573
+ continue;
1574
+ }
1575
+ const numbers = values;
1576
+ const first = numbers[0];
1577
+ const rest = numbers.slice(1);
1578
+ let expected;
1579
+ switch (op) {
1580
+ case "divide":
1581
+ expected = rest.reduce((total, value) => total / value, first);
1582
+ break;
1583
+ case "product":
1584
+ expected = numbers.reduce((total, value) => total * value, 1);
1585
+ break;
1586
+ case "sum":
1587
+ expected = numbers.reduce((total, value, index) => total + (signs[index] ?? 1) * value, 0);
1588
+ break;
1589
+ case "subtract":
1590
+ expected = rest.reduce((total, value) => total - value, first);
1591
+ break;
1592
+ default: expected = first / (rest[0] ?? 1) * 100;
1593
+ }
1594
+ expected *= scale;
1595
+ if (Math.abs(declared - expected) <= tolerance) continue;
1596
+ ctx.add(locatorOf(row.row, resultField), `第 ${row.row} 行「${resultField}」填报 ${declared},按 ${label[op] ?? op} 应为 ${round(expected)}`, `「${resultField}」应等于 ${label[op] ?? op}`, str(ctx.params, "fix", "核对取数与计算口径;本条只做算术核对,不判断取数本身是否恰当"));
1597
+ }
1598
+ if (usable.length > 0 || !headerUsable) return;
1599
+ const headerDeclared = numberOf(valueOf(ctx.input, {}, resultField)) ?? numberOf(ctx.input.header[resultField]);
1600
+ const headerValues = fields.map((field) => numberOf(ctx.input.header[field]));
1601
+ if (headerDeclared === void 0 || headerValues.some((value) => value === void 0)) {
1602
+ ctx.skip(`材料表头的「${resultField}」或 ${fields.join("、")} 无法解析为数值,无法核对表达式`);
1603
+ return;
1604
+ }
1605
+ const numbers = headerValues;
1606
+ const first = numbers[0];
1607
+ const rest = numbers.slice(1);
1608
+ let expectedHeader;
1609
+ switch (op) {
1610
+ case "divide":
1611
+ expectedHeader = rest.reduce((total, value) => total / value, first);
1612
+ break;
1613
+ case "product":
1614
+ expectedHeader = numbers.reduce((total, value) => total * value, 1);
1615
+ break;
1616
+ case "sum":
1617
+ expectedHeader = numbers.reduce((total, value, index) => total + (signs[index] ?? 1) * value, 0);
1618
+ break;
1619
+ case "subtract":
1620
+ expectedHeader = rest.reduce((total, value) => total - value, first);
1621
+ break;
1622
+ default: expectedHeader = first / (rest[0] ?? 1) * 100;
1623
+ }
1624
+ expectedHeader *= scale;
1625
+ if (Math.abs(headerDeclared - expectedHeader) <= tolerance) return;
1626
+ ctx.add(locatorOf(void 0, resultField), `材料表头「${resultField}」为 ${headerDeclared},按 ${label[op] ?? op} 应为 ${round(expectedHeader)}`, `表头「${resultField}」应等于 ${label[op] ?? op}`, str(ctx.params, "fix", "核对取数与计算口径;本条只做算术核对,不判断取数本身是否恰当"));
1627
+ }
1628
+ /** `capabilityIndex` — Cp and Cpk must follow from the limits, the mean and sigma. */
1629
+ function capabilityIndex(ctx) {
1630
+ const cpField = str(ctx.params, "cpField");
1631
+ const cpkField = str(ctx.params, "cpkField");
1632
+ const uslField = str(ctx.params, "uslField");
1633
+ const lslField = str(ctx.params, "lslField");
1634
+ const stdDevField = str(ctx.params, "stdDevField");
1635
+ const meanField = str(ctx.params, "meanField");
1636
+ if ([
1637
+ cpField,
1638
+ cpkField,
1639
+ uslField,
1640
+ lslField,
1641
+ stdDevField,
1642
+ meanField
1643
+ ].some((field) => field === "")) {
1644
+ ctx.skip("规则库未配置 Cp/Cpk 定义式所需的字段名,本条不执行");
1645
+ return;
1646
+ }
1647
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1648
+ const usable = ctx.input.rows.filter((row) => (filled(valueOf(ctx.input, row, cpField)) || filled(valueOf(ctx.input, row, cpkField))) && filled(valueOf(ctx.input, row, uslField)) && filled(valueOf(ctx.input, row, lslField)) && filled(valueOf(ctx.input, row, stdDevField)) && filled(valueOf(ctx.input, row, meanField)));
1649
+ if (usable.length === 0) {
1650
+ ctx.skip(`材料没有同时填写「${uslField}」「${lslField}」「${stdDevField}」「${meanField}」与能力指数的行,无法核对定义式`);
1651
+ return;
1652
+ }
1653
+ const round = (value) => Number(value.toFixed(4));
1654
+ for (const row of usable) {
1655
+ const usl = numberOf(valueOf(ctx.input, row, uslField));
1656
+ const lsl = numberOf(valueOf(ctx.input, row, lslField));
1657
+ const sigma = numberOf(valueOf(ctx.input, row, stdDevField));
1658
+ const mean = numberOf(valueOf(ctx.input, row, meanField));
1659
+ if (usl === void 0 || lsl === void 0 || sigma === void 0 || mean === void 0 || sigma <= 0) {
1660
+ ctx.add(locatorOf(row.row, cpkField), `第 ${row.row} 行的规格限、标准差或均值无法用于计算(标准差须大于零)`, `Cp 与 Cpk 应由规格限、均值与标准差按定义式算出`, "核对填写形式与标准差取值");
1661
+ continue;
1662
+ }
1663
+ const expectedCp = (usl - lsl) / (6 * sigma);
1664
+ const expectedCpk = Math.min(usl - mean, mean - lsl) / (3 * sigma);
1665
+ const actualCp = numberOf(valueOf(ctx.input, row, cpField));
1666
+ const actualCpk = numberOf(valueOf(ctx.input, row, cpkField));
1667
+ if (actualCp !== void 0 && Math.abs(actualCp - expectedCp) > tolerance) {
1668
+ ctx.add(locatorOf(row.row, cpField), `第 ${row.row} 行 Cp 填报 ${actualCp},按 (${usl} − ${lsl}) / (6 × ${sigma}) 应为 ${round(expectedCp)}`, "Cp = (USL − LSL) / 6σ", "核对标准差口径:Cp/Cpk 用组内标准差,Pp/Ppk 用总体标准差;本条只做算术核对");
1669
+ continue;
1670
+ }
1671
+ if (actualCpk === void 0 || Math.abs(actualCpk - expectedCpk) <= tolerance) continue;
1672
+ ctx.add(locatorOf(row.row, cpkField), `第 ${row.row} 行 Cpk 填报 ${actualCpk},按 min(USL − X̄, X̄ − LSL) / 3σ 应为 ${round(expectedCpk)}`, "Cpk = min(USL − X̄, X̄ − LSL) / 3σ", "核对标准差口径与均值;本条只做算术核对,不判断能力是否足够");
1673
+ }
1674
+ }
1675
+ /**
1676
+ * `controlLimits` — the limits must follow from the centre line, the dispersion
1677
+ * statistic and the chart constants declared in the rule pack.
1678
+ */
1679
+ function controlLimits(ctx) {
1680
+ const centerField = str(ctx.params, "centerField");
1681
+ const rangeField = str(ctx.params, "rangeField");
1682
+ const uclField = str(ctx.params, "uclField");
1683
+ const lclField = str(ctx.params, "lclField");
1684
+ const sizeField = str(ctx.params, "sizeField");
1685
+ const rawTable = ctx.params.coefficients;
1686
+ if (uclField === "" || lclField === "" || centerField === "" && rangeField === "") {
1687
+ ctx.skip("规则库未配置 centerField/rangeField 或控制限字段,本条不执行");
1688
+ return;
1689
+ }
1690
+ if (typeof rawTable !== "object" || rawTable === null || Array.isArray(rawTable)) {
1691
+ ctx.skip("规则库未配置 coefficients 常数表:控制图系数随图种变化,本引擎不硬编码");
1692
+ return;
1693
+ }
1694
+ const table = rawTable;
1695
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1696
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, uclField)) && filled(valueOf(ctx.input, row, lclField)));
1697
+ if (usable.length === 0) {
1698
+ ctx.skip(`材料没有「${uclField}」与「${lclField}」,无法核对控制限`);
1699
+ return;
1700
+ }
1701
+ let checked = 0;
1702
+ for (const row of usable) {
1703
+ const key = sizeField === "" ? "" : (valueOf(ctx.input, row, sizeField) ?? "").trim();
1704
+ const entry = table[key];
1705
+ const coefficients = typeof entry === "object" && entry !== null && !Array.isArray(entry) ? entry : void 0;
1706
+ if (coefficients === void 0) {
1707
+ ctx.add(locatorOf(row.row, uclField), `第 ${row.row} 行的子组大小「${key === "" ? "(未填)" : key}」在常数表中没有对应档位`, "子组大小应为常数表覆盖的档位之一", "补齐子组大小,或把该档位的系数加进规则的 coefficients;本条只核对档位是否在册");
1708
+ continue;
1709
+ }
1710
+ const center = centerField === "" ? void 0 : numberOf(valueOf(ctx.input, row, centerField));
1711
+ const dispersion = rangeField === "" ? void 0 : numberOf(valueOf(ctx.input, row, rangeField));
1712
+ if (center === void 0 || dispersion === void 0) continue;
1713
+ checked += 1;
1714
+ const a2 = typeof coefficients.a2 === "number" ? coefficients.a2 : void 0;
1715
+ const d3 = typeof coefficients.d3 === "number" ? coefficients.d3 : 0;
1716
+ const d4 = typeof coefficients.d4 === "number" ? coefficients.d4 : 0;
1717
+ const expectedUcl = a2 !== void 0 ? center + a2 * dispersion : d4 * dispersion;
1718
+ const expectedLcl = a2 !== void 0 ? center - a2 * dispersion : d3 * dispersion;
1719
+ const actualUcl = numberOf(valueOf(ctx.input, row, uclField));
1720
+ const actualLcl = numberOf(valueOf(ctx.input, row, lclField));
1721
+ if (actualUcl === void 0 || actualLcl === void 0) {
1722
+ ctx.add(locatorOf(row.row, uclField), `第 ${row.row} 行的控制限不是可解析的数值`, "控制限应为数值", "核对填写形式");
1723
+ continue;
1724
+ }
1725
+ const round = (value) => Number(value.toFixed(6));
1726
+ if (Math.abs(actualUcl - expectedUcl) <= tolerance && Math.abs(actualLcl - expectedLcl) <= tolerance) continue;
1727
+ ctx.add(locatorOf(row.row, uclField), `第 ${row.row} 行控制限为 [${round(actualLcl)}, ${round(actualUcl)}],而按系数表应为 [${round(expectedLcl)}, ${round(expectedUcl)}]`, `控制限应等于中心线与系数、极差的组合(子组大小 ${key} 对应系数 ${JSON.stringify(coefficients)})`, "核对子组大小、极差与控制限的取数;本条只按配置的系数表做算术核对,不判断过程是否受控");
1728
+ }
1729
+ if (checked === 0) ctx.skip(`材料缺少「${centerField}」或「${rangeField}」,无法核对控制限与系数的一致性`);
1730
+ }
1731
+ /** `derivedDays` — a day count must equal a date gap less a third value. */
1732
+ function derivedDays(ctx) {
1733
+ const daysField = str(ctx.params, "daysField");
1734
+ const fromField = str(ctx.params, "fromField");
1735
+ const toField = str(ctx.params, "toField");
1736
+ const subtractField = str(ctx.params, "subtractField");
1737
+ if (daysField === "" || fromField === "" || toField === "") {
1738
+ ctx.skip("规则库未配置 daysField、fromField 或 toField,本条不执行");
1739
+ return;
1740
+ }
1741
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, daysField)) && filled(valueOf(ctx.input, row, fromField)) && filled(valueOf(ctx.input, row, toField)) && (subtractField === "" || filled(valueOf(ctx.input, row, subtractField))));
1742
+ if (usable.length === 0) {
1743
+ ctx.skip(`材料没有同时填写「${daysField}」与起止日期的行,无法核对天数`);
1744
+ return;
1745
+ }
1746
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
1747
+ for (const row of usable) {
1748
+ const declared = numberOf(valueOf(ctx.input, row, daysField));
1749
+ const from = parseWallClock(valueOf(ctx.input, row, fromField) ?? "");
1750
+ const to = parseWallClock(valueOf(ctx.input, row, toField) ?? "");
1751
+ const free = subtractField === "" ? 0 : numberOf(valueOf(ctx.input, row, subtractField));
1752
+ if (declared === void 0 || from === void 0 || to === void 0 || free === void 0) {
1753
+ ctx.add(locatorOf(row.row, daysField), `第 ${row.row} 行的「${daysField}」或起止日期中有无法解析的值`, `「${daysField}」应等于起止日期之差${subtractField === "" ? "" : `减去「${subtractField}」`}`, "核对填写形式;本条只做算术核对");
1754
+ continue;
1755
+ }
1756
+ const spanMinutes = diffMinutes(from, to);
1757
+ const derived = Math.max(0, spanMinutes / 1440 - free);
1758
+ const rounded = Number(derived.toFixed(6));
1759
+ if (Math.abs(rounded - declared) <= tolerance) continue;
1760
+ ctx.add(locatorOf(row.row, daysField), `第 ${row.row} 行「${daysField}」填报 ${declared},按「${toField}」${to.date}${to.hasTime ? ` ${to.time}` : ""} 减「${fromField}」${from.date}${from.hasTime ? ` ${from.time}` : ""}${subtractField === "" ? "" : ` 再减「${subtractField}」`} 应为 ${rounded} 天`, `「${daysField}」应等于起止时刻之差${subtractField === "" ? "" : `减去「${subtractField}」`},单位为天`, "核对是否抄错或口径不同;本条只做算术核对,不判断起算口径是否正确");
1761
+ }
1762
+ }
1763
+ /**
1764
+ * `sharedTerms` — two cells should share at least N terms.
1765
+ *
1766
+ * The weakest useful content check, and deliberately so: it looks for shared keywords
1767
+ * between a source text and its paraphrase, and reports only when there are none.
1768
+ * Paraphrase legitimately changes wording, so a finding means "these two columns look
1769
+ * unrelated, worth a human look" and never "the paraphrase is wrong". It also cannot
1770
+ * catch a paraphrase that is wrong while sharing vocabulary — a limit its rule's own
1771
+ * note states.
1772
+ */
1773
+ function sharedTerms(ctx) {
1774
+ const leftField = str(ctx.params, "leftField");
1775
+ const rightField = str(ctx.params, "rightField");
1776
+ const minShared = optionalNumber(ctx.params, "minShared") ?? 1;
1777
+ if (leftField === "" || rightField === "") {
1778
+ ctx.skip("规则库未配置 leftField 或 rightField,本条不执行");
1779
+ return;
1780
+ }
1781
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, leftField)) && filled(valueOf(ctx.input, row, rightField)));
1782
+ if (usable.length === 0) {
1783
+ ctx.skip(`材料没有同时填写「${leftField}」与「${rightField}」的行,本条不适用`);
1784
+ return;
1785
+ }
1786
+ /**
1787
+ * Break text into comparable terms. CJK text has no spaces, so runs of Han
1788
+ * characters become overlapping bigrams — which is what makes a shared keyword
1789
+ * visible without a dictionary — while Latin words and digit runs compare whole.
1790
+ */
1791
+ const terms = (text) => {
1792
+ const out = /* @__PURE__ */ new Set();
1793
+ for (const word of text.toLowerCase().match(/[a-z]+|\d+(?:\.\d+)?/g) ?? []) out.add(word);
1794
+ for (const run of text.match(/[\u4e00-\u9fff]+/g) ?? []) {
1795
+ if (run.length === 1) out.add(run);
1796
+ for (let index = 0; index + 1 < run.length; index += 1) out.add(run.slice(index, index + 2));
1797
+ }
1798
+ return out;
1799
+ };
1800
+ for (const row of usable) {
1801
+ const left = terms(valueOf(ctx.input, row, leftField) ?? "");
1802
+ const right = terms(valueOf(ctx.input, row, rightField) ?? "");
1803
+ if (left.size === 0 || right.size === 0) continue;
1804
+ let shared = 0;
1805
+ for (const term of right) if (left.has(term)) shared += 1;
1806
+ if (shared >= minShared) continue;
1807
+ ctx.add(locatorOf(row.row, rightField), `第 ${row.row} 行「${rightField}」与「${leftField}」没有共同词(共同词 ${shared} 个,配置下限 ${minShared} 个)`, `「${rightField}」应与「${leftField}」在字面上相关`, str(ctx.params, "fix", "本条只提示两栏看不出关系、值得人工复核,不断言提炼有误;同义改写会拉低共同词数,可调整 minShared"));
1808
+ }
1809
+ }
1810
+ /** Find a version marker in text such as `PFMEA-001 V2` or `CP-003 版本B`. */
1811
+ function versionToken(raw) {
1812
+ if (raw === void 0) return void 0;
1813
+ return /(?:^|[\s(([【])(?:版本|版次|rev(?:ision)?|ver(?:sion)?|v)\s*[::]?\s*([A-Za-z]?\d+(?:\.\d+)?)\s*(?:版)?\s*$/i.exec(raw.trim())?.[1];
1814
+ }
1815
+ /** `referenceVersion` — a cited revision must match the document's current one. */
1816
+ function referenceVersion(ctx) {
1817
+ const referenceField = str(ctx.params, "referenceField");
1818
+ const sameVersionField = str(ctx.params, "sameVersionField");
1819
+ const actualCandidates = stringList(ctx.params, "actualVersionField", "actualVersionField");
1820
+ if (referenceField === "") {
1821
+ ctx.skip("规则库未配置 referenceField,本条不执行");
1822
+ return;
1823
+ }
1824
+ if (sameVersionField === "" && actualCandidates.length === 0) {
1825
+ ctx.skip("规则库未配置 sameVersionField 或 actualVersionField,本条不执行");
1826
+ return;
1827
+ }
1828
+ const usable = ctx.input.rows.filter((row) => filled(row.fields[referenceField]));
1829
+ if (usable.length === 0) {
1830
+ ctx.skip(`材料没有「${referenceField}」列或该列全为空,无法核对引用版本`);
1831
+ return;
1832
+ }
1833
+ const currentVersion = (row) => {
1834
+ for (const candidate of actualCandidates) {
1835
+ const value = (row.fields[candidate] ?? "").trim();
1836
+ if (value !== "") return versionToken(value) ?? value;
1837
+ }
1838
+ for (const candidate of actualCandidates) {
1839
+ const value = (ctx.input.header[candidate] ?? "").trim();
1840
+ if (value !== "") return versionToken(value) ?? value;
1841
+ }
1842
+ return "";
1843
+ };
1844
+ const strip = (value) => value.replace(/^[vV]/, "").trim().toLowerCase();
1845
+ let compared = 0;
1846
+ for (const row of usable) {
1847
+ const expected = currentVersion(row);
1848
+ const declared = sameVersionField === "" ? versionToken(row.fields[referenceField]) : (row.fields[sameVersionField] ?? "").trim();
1849
+ if (declared === void 0 || declared === "" || expected === "") {
1850
+ ctx.add(locatorOf(row.row, referenceField), `第 ${row.row} 行引用了「${row.fields[referenceField]}」,但无法确定其版本或文件的现行版本`, "引用中应能看清版本,且文件应有可比的现行版本", "把引用版本单列一栏(sameVersionField),或在表头提供文件实际版本;本条只做版本串比对");
1851
+ continue;
1852
+ }
1853
+ compared += 1;
1854
+ if (strip(declared) === strip(expected)) continue;
1855
+ ctx.add(locatorOf(row.row, referenceField), `第 ${row.row} 行引用的版本「${declared}」与文件现行版本「${expected}」不一致`, "引用的版本应与该文件的现行版本一致", "核对是否还停在上一版;本条只比对版本串,不判断该引用应当指向哪一版");
1856
+ }
1857
+ if (compared === 0) ctx.skip(`材料中的「${referenceField}」既没有单独的版本栏,也没有可识别的版本串,无法核对引用版本`);
1858
+ }
1859
+ /** `sameVersionWithinGroup` — rows naming one controlled document must agree on its revision. */
1860
+ function sameVersionWithinGroup(ctx) {
1861
+ const groupField = str(ctx.params, "groupField");
1862
+ const versionField = str(ctx.params, "versionField");
1863
+ if (groupField === "" || versionField === "") {
1864
+ ctx.skip("规则库未配置 groupField 或 versionField,本条不执行");
1865
+ return;
1866
+ }
1867
+ const groups = /* @__PURE__ */ new Map();
1868
+ for (const row of ctx.input.rows) {
1869
+ const group = (valueOf(ctx.input, row, groupField) ?? "").trim();
1870
+ const version = (valueOf(ctx.input, row, versionField) ?? "").trim();
1871
+ if (group === "" || version === "") continue;
1872
+ const bucket = groups.get(group);
1873
+ if (bucket === void 0) groups.set(group, [{
1874
+ version,
1875
+ row
1876
+ }]);
1877
+ else bucket.push({
1878
+ version,
1879
+ row
1880
+ });
1881
+ }
1882
+ if (groups.size === 0) {
1883
+ ctx.skip(`材料没有同时填写「${groupField}」与「${versionField}」的行,本条不适用`);
1884
+ return;
1885
+ }
1886
+ const withSeveral = [...groups.entries()].filter(([, entries]) => entries.length > 1);
1887
+ if (withSeveral.length === 0) {
1888
+ ctx.skip(`材料中没有「${groupField}」出现多行的情况,无法核对版本是否一致`);
1889
+ return;
1890
+ }
1891
+ for (const [group, entries] of withSeveral) {
1892
+ const versions = [...new Set(entries.map((entry) => entry.version))];
1893
+ if (versions.length <= 1) continue;
1894
+ const first = entries[0];
1895
+ ctx.add(locatorOf(first.row.row, versionField), `「${groupField}」为「${group}」的行声明了 ${versions.length} 个不同版本:${versions.join(" / ")}`, "同一份受控文件在同一台账内应只有一个版本", "核对是否该文件只被部分修订(版本未同步),或版本栏填写有误");
1896
+ }
1897
+ }
1898
+ /** `monotonicSequence` — the configured order's items must appear in that order. */
1899
+ function monotonicSequence(ctx) {
1900
+ const field = str(ctx.params, "field");
1901
+ const order = stringList(ctx.params, "order", "order");
1902
+ if (field === "") {
1903
+ ctx.skip("规则库未配置 field,本条不执行");
1904
+ return;
1905
+ }
1906
+ if (order.length === 0) {
1907
+ ctx.skip("规则库未配置 order:环节清单与顺序由本机构管理办法规定,本引擎不硬编码");
1908
+ return;
1909
+ }
1910
+ const rankOf = (value) => {
1911
+ const trimmed = value.trim();
1912
+ const exact = order.indexOf(trimmed);
1913
+ if (exact >= 0) return exact;
1914
+ return order.findIndex((candidate) => trimmed.startsWith(candidate));
1915
+ };
1916
+ const seen = [];
1917
+ for (const row of ctx.input.rows) {
1918
+ const value = (valueOf(ctx.input, row, field) ?? "").trim();
1919
+ if (value === "") continue;
1920
+ const rank = rankOf(value);
1921
+ if (rank < 0) {
1922
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为「${value}」,不在本机构配置的顺序表中`, `「${field}」应为顺序表中的环节之一:${order.join(" → ")}`, "核对该环节名称,或把本机构的环节补进顺序表;本条只按配置的顺序表核对");
1923
+ continue;
1924
+ }
1925
+ seen.push({
1926
+ row: row.row,
1927
+ value,
1928
+ rank
1929
+ });
1930
+ }
1931
+ if (seen.length === 0) {
1932
+ ctx.skip(`材料没有可识别的「${field}」值,本条不适用`);
1933
+ return;
1934
+ }
1935
+ for (let index = 1; index < seen.length; index += 1) {
1936
+ const previous = seen[index - 1];
1937
+ const current = seen[index];
1938
+ if (current.rank >= previous.rank) continue;
1939
+ ctx.add(locatorOf(current.row, field), `第 ${current.row} 行「${field}」为「${current.value}」,出现在第 ${previous.row} 行的「${previous.value}」之后,与配置顺序相反`, `台账应按 ${order.join(" → ")} 的顺序登记`, "核对是否漏登记了中间环节,或两行的顺序填反;本条只按配置的顺序表核对");
1940
+ }
1941
+ }
1942
+ /** `prefixOf` — a code's stated components must be its successive prefixes. */
1943
+ function prefixOf(ctx) {
1944
+ const field = str(ctx.params, "field");
1945
+ const components = stringList(ctx.params, "components", "components");
1946
+ const digits = optionalNumber(ctx.params, "digits");
1947
+ if (field === "" || components.length === 0) {
1948
+ ctx.skip("规则库未配置 field 或 components,本条不执行");
1949
+ return;
1950
+ }
1951
+ const usable = ctx.input.rows.filter((row) => filled(row.fields[field]));
1952
+ if (usable.length === 0) {
1953
+ ctx.skip(`材料没有「${field}」列,本条不适用`);
1954
+ return;
1955
+ }
1956
+ for (const row of usable) {
1957
+ const code = (valueOf(ctx.input, row, field) ?? "").replace(/\s/g, "");
1958
+ if (digits !== void 0 && digits > 0 && code.length !== digits) {
1959
+ ctx.add(locatorOf(row.row, field), `第 ${row.row} 行「${field}」为「${code}」,共 ${code.length} 位,不是 ${digits} 位`, `「${field}」应为 ${digits} 位数字`, "核对位数;本条只核对层级结构,不判断归类是否正确");
1960
+ continue;
1961
+ }
1962
+ let previousLength = 0;
1963
+ let broken = false;
1964
+ for (const component of components) {
1965
+ const stated = (valueOf(ctx.input, row, component) ?? "").replace(/\s/g, "");
1966
+ if (stated === "") continue;
1967
+ if (!code.startsWith(stated)) {
1968
+ ctx.add(locatorOf(row.row, component), `第 ${row.row} 行「${component}」为「${stated}」,不是「${field}」(${code})的前缀`, `「${component}」应与「${field}」的前几位一致`, "核对层级填写;本条只核对前缀关系,不判断归类是否正确");
1969
+ broken = true;
1970
+ break;
1971
+ }
1972
+ if (stated.length <= previousLength) {
1973
+ ctx.add(locatorOf(row.row, component), `第 ${row.row} 行「${component}」为「${stated}」,不比上一级更细(上一级 ${previousLength} 位)`, "层级应由粗到细、位数递增", "核对层级填写;本条只核对层级顺序,不判断归类是否正确");
1974
+ broken = true;
1975
+ break;
1976
+ }
1977
+ previousLength = stated.length;
1978
+ }
1979
+ if (broken) continue;
1980
+ }
1981
+ }
1982
+ /**
1983
+ * `thresholdCompare` — a verdict must agree with how the value compares to its limit.
1984
+ *
1985
+ * Definitional: if a measured result exceeds the limit the register itself records, the
1986
+ * register's own verdict must say so. It never supplies a limit — the limit comes from the
1987
+ * material — so it cannot find a limit taken from the wrong standard, which is a substantive
1988
+ * question the plugin leaves to the reader.
1989
+ */
1990
+ function thresholdCompare(ctx) {
1991
+ const valueField = str(ctx.params, "valueField");
1992
+ const limitField = str(ctx.params, "limitField");
1993
+ const verdictField = str(ctx.params, "verdictField");
1994
+ const overValues = stringList(ctx.params, "overValues", "overValues");
1995
+ const atMostValues = stringList(ctx.params, "atMostValues", "atMostValues");
1996
+ if (valueField === "" || limitField === "" || verdictField === "") {
1997
+ ctx.skip("规则库未配置 valueField、limitField 或 verdictField,本条不执行");
1998
+ return;
1999
+ }
2000
+ if (overValues.length === 0 || atMostValues.length === 0) {
2001
+ ctx.skip("规则库未配置 overValues 或 atMostValues:判定取值由本机构口径决定,本引擎不硬编码");
2002
+ return;
2003
+ }
2004
+ const tolerance = optionalNumber(ctx.params, "tolerance") ?? 0;
2005
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, valueField)) && filled(valueOf(ctx.input, row, limitField)) && filled(valueOf(ctx.input, row, verdictField)));
2006
+ if (usable.length === 0) {
2007
+ ctx.skip(`材料没有同时填写「${valueField}」「${limitField}」与「${verdictField}」的行,本条不适用`);
2008
+ return;
2009
+ }
2010
+ for (const row of usable) {
2011
+ const value = numberOf(valueOf(ctx.input, row, valueField));
2012
+ const limit = numberOf(valueOf(ctx.input, row, limitField));
2013
+ const verdict = (valueOf(ctx.input, row, verdictField) ?? "").trim();
2014
+ if (value === void 0 || limit === void 0) {
2015
+ ctx.add(locatorOf(row.row, valueField), `第 ${row.row} 行的「${valueField}」或「${limitField}」无法解析为数值,无法与判定比对`, "两个数应可解析,判定应与它们的大小关系相符", "核对填写形式;本条只比对台账自己写的限值");
2016
+ continue;
2017
+ }
2018
+ const isOver = overValues.includes(verdict);
2019
+ const isAtMost = atMostValues.includes(verdict);
2020
+ if (!isOver && !isAtMost) {
2021
+ ctx.add(locatorOf(row.row, verdictField), `第 ${row.row} 行「${verdictField}」的值为「${verdict}」,不在配置的判定取值内`, `「${verdictField}」应为 ${overValues.join(" / ")}(超标)或 ${atMostValues.join(" / ")}(未超标)之一`, "补齐 overValues 或 atMostValues,或修正该栏;本条只核对取值是否在册");
2022
+ continue;
2023
+ }
2024
+ const actuallyOver = value > limit + tolerance;
2025
+ if (actuallyOver === isOver) continue;
2026
+ ctx.add(locatorOf(row.row, verdictField), `第 ${row.row} 行「${valueField}」为 ${value}、「${limitField}」为 ${limit},而判定为「${verdict}」`, actuallyOver ? `${value} 超过限值 ${limit},判定应表示超标` : `${value} 未超过限值 ${limit},判定应表示未超标`, "核对三个数值与判定;本条只按台账自己写的限值比较,不判断该限值是否取对了标准");
2027
+ }
2028
+ }
2029
+ /**
2030
+ * `dateGap` — a later date must be at least N days after an earlier one.
2031
+ *
2032
+ * Built for a waiting period between two events the register already records: an
2033
+ * application date and a harvest date with a withdrawal period between them. The N comes
2034
+ * from the material, so the check makes no claim about what the period *should* be.
2035
+ */
2036
+ function dateGap(ctx) {
2037
+ const earlierField = str(ctx.params, "earlierField");
2038
+ const laterField = str(ctx.params, "laterField");
2039
+ const minDaysField = str(ctx.params, "minDaysField");
2040
+ const minDaysLiteral = optionalNumber(ctx.params, "minDays");
2041
+ if (earlierField === "" || laterField === "" || minDaysField === "" && minDaysLiteral === void 0) {
2042
+ ctx.skip("规则库未配置 earlierField、laterField 与 minDays/minDaysField,本条不执行");
2043
+ return;
2044
+ }
2045
+ const usable = ctx.input.rows.filter((row) => filled(valueOf(ctx.input, row, earlierField)) && filled(valueOf(ctx.input, row, laterField)) && (minDaysField === "" || filled(valueOf(ctx.input, row, minDaysField))));
2046
+ if (usable.length === 0) {
2047
+ ctx.skip(`材料没有同时填写「${earlierField}」「${laterField}」${minDaysField === "" ? "" : `「${minDaysField}」`} 的行,本条不适用`);
2048
+ return;
2049
+ }
2050
+ let checked = 0;
2051
+ for (const row of usable) {
2052
+ const earlier = parseWallClock(valueOf(ctx.input, row, earlierField) ?? "");
2053
+ const later = parseWallClock(valueOf(ctx.input, row, laterField) ?? "");
2054
+ const required = minDaysField === "" ? minDaysLiteral : numberOf(valueOf(ctx.input, row, minDaysField));
2055
+ if (earlier === void 0 || later === void 0 || required === void 0) {
2056
+ ctx.add(locatorOf(row.row, laterField), `第 ${row.row} 行的「${earlierField}」「${laterField}」或「${minDaysField}」中有无法解析的值`, "两个日期应可解析,间隔天数应为数值", "核对填写形式;本条只做日期间隔核对");
2057
+ continue;
2058
+ }
2059
+ checked += 1;
2060
+ const gap = diffMinutes(earlier, later) / 1440;
2061
+ if (gap + 1e-9 >= required) continue;
2062
+ ctx.add(locatorOf(row.row, laterField), `第 ${row.row} 行「${laterField}」为 ${later.date},距「${earlierField}」${earlier.date} 只有 ${Number(gap.toFixed(4))} 天,少于${minDaysField === "" ? "配置的" : `「${minDaysField}」的`} ${required} 天`, `「${laterField}」应不早于「${earlierField}」之后 ${required} 天`, str(ctx.params, "fix", "核对三个字段的填写;本条只按台账自己写的间隔天数核对,不判断该天数是否恰当"));
2063
+ }
2064
+ if (checked === 0) ctx.skip(`材料中的「${earlierField}」「${laterField}」${minDaysField === "" ? "" : `「${minDaysField}」`} 没有可解析的组合,无法核对间隔`);
2065
+ }
2066
+ /** Run one check by its declared kind. */
2067
+ function runOneCheck(ctx) {
2068
+ const kind = str(ctx.params, "kind");
2069
+ switch (kind) {
2070
+ case "presence": return presence(ctx);
2071
+ case "presenceAny": return presenceAny(ctx);
2072
+ case "enum": return enumCheck(ctx);
2073
+ case "pattern": return patternCheck(ctx);
2074
+ case "date": return dateCheck(ctx);
2075
+ case "noFutureDate": return noFutureDate(ctx);
2076
+ case "dataAge": return dataAge(ctx);
2077
+ case "number": return numberCheck(ctx);
2078
+ case "unique": return uniqueCheck(ctx);
2079
+ case "sequence": return sequenceCheck(ctx);
2080
+ case "sum": return sumCheck(ctx);
2081
+ case "sumToConstant": return sumToConstant(ctx);
2082
+ case "countPerGroup": return countPerGroup(ctx);
2083
+ case "textContains": return textContains(ctx);
2084
+ case "containsAny": return containsAny(ctx);
2085
+ case "length": return lengthCheck(ctx);
2086
+ case "minLength": return minLength(ctx);
2087
+ case "headerPresence": return headerPresence(ctx);
2088
+ case "headerEnum": return headerEnum(ctx);
2089
+ case "conditionalPresence": return conditionalPresence(ctx);
2090
+ case "highRiskEvidence": return highRiskEvidence(ctx);
2091
+ case "thresholdPresence": return thresholdPresence(ctx);
2092
+ case "thresholdBelow": return thresholdBelow(ctx);
2093
+ case "fieldComparison": return fieldComparison(ctx);
2094
+ case "intervalOrder": return intervalOrder(ctx);
2095
+ case "productOf": return productOf(ctx);
2096
+ case "formula": return formula(ctx);
2097
+ case "capabilityIndex": return capabilityIndex(ctx);
2098
+ case "controlLimits": return controlLimits(ctx);
2099
+ case "derivedDays": return derivedDays(ctx);
2100
+ case "sharedTerms": return sharedTerms(ctx);
2101
+ case "referenceVersion": return referenceVersion(ctx);
2102
+ case "sameVersionWithinGroup": return sameVersionWithinGroup(ctx);
2103
+ case "monotonicSequence": return monotonicSequence(ctx);
2104
+ case "prefixOf": return prefixOf(ctx);
2105
+ case "thresholdCompare": return thresholdCompare(ctx);
2106
+ case "dateGap": return dateGap(ctx);
2107
+ default: ctx.skip(kind === "" ? "规则库未配置 check.kind,本条不执行" : `规则库配置的 check.kind「${kind}」不是本引擎支持的检查类型,本条不执行`);
2108
+ }
2109
+ }
2110
+ /** Run every rule, collecting issues and the reasons rules did not run. */
2111
+ function runChecks(input, ruleset, options) {
2112
+ const issues = [];
2113
+ const reasons = /* @__PURE__ */ new Map();
2114
+ const fired = /* @__PURE__ */ new Set();
2115
+ for (const rule of ruleset.rules) {
2116
+ const declaredCheck = typeof rule.params.check === "object" && rule.params.check !== null && !Array.isArray(rule.params.check) ? rule.params.check : {};
2117
+ const check = {
2118
+ ...rule.params,
2119
+ ...declaredCheck
2120
+ };
2121
+ delete check.check;
2122
+ const override = options.overrides?.[rule.id];
2123
+ if (override !== void 0) Object.assign(check, override);
2124
+ delete check.check;
2125
+ runOneCheck({
2126
+ input,
2127
+ ruleId: rule.id,
2128
+ params: check,
2129
+ options,
2130
+ add: (locator, found, expected, fix) => {
2131
+ const issue = {
2132
+ id: issueId(ruleset.plugin, rule.id, locator),
2133
+ ruleId: rule.id,
2134
+ severity: rule.severity,
2135
+ locator,
2136
+ found,
2137
+ expected,
2138
+ basis: formatBasis(rule.basis, rule.alsoBasis ?? [])
2139
+ };
2140
+ if (fix !== void 0) issue.fix = fix;
2141
+ issues.push(issue);
2142
+ fired.add(rule.id);
2143
+ },
2144
+ skip: (reason) => {
2145
+ reasons.set(rule.id, reason);
2146
+ }
2147
+ });
2148
+ }
2149
+ return {
2150
+ issues,
2151
+ fired,
2152
+ reasons
2153
+ };
2154
+ }
2155
+ /** One skipped entry per disabled rule, whether or not the pack declares it. */
2156
+ function disabledAsSkipped(ruleset, disabled, note) {
2157
+ const byId = new Map(ruleset.rules.map((rule) => [rule.id, rule]));
2158
+ const out = [];
2159
+ for (const ruleId of disabled) {
2160
+ const rule = byId.get(ruleId);
2161
+ out.push({
2162
+ rule: rule === void 0 ? ruleId : rule.id,
2163
+ reason: note(rule === void 0 ? "配置中禁用了该规则,但规则库中没有这一条" : "该规则在当前配置中被禁用")
2164
+ });
2165
+ }
2166
+ return out;
2167
+ }
2168
+ /**
2169
+ * Run a rule pack against a normalized table.
2170
+ * @param input - the table, with canonical field names attached.
2171
+ * @param ruleset - the validated rule pack.
2172
+ * @param options - plugin identity, clock value, rule selection and overrides.
2173
+ * @returns the report, with `skipped` listing every check that did not run.
2174
+ */
2175
+ function runTableCheck(input, ruleset, options) {
2176
+ const disabled = /* @__PURE__ */ new Set([...ruleset.disabled, ...options.disabledRules]);
2177
+ const only = new Set(options.onlyRules);
2178
+ const { issues, fired, reasons } = runChecks(input, ruleset, options);
2179
+ const withNote = (reason) => options.skipNotes === void 0 || options.skipNotes === "" ? reason : `${reason};${options.skipNotes}`;
2180
+ const skipped = disabledAsSkipped(ruleset, [...disabled], withNote);
2181
+ const already = new Set(skipped.map((entry) => entry.rule));
2182
+ for (const [ruleId, reason] of reasons) {
2183
+ if (already.has(ruleId)) continue;
2184
+ if (disabled.has(ruleId) || only.size > 0 && !only.has(ruleId)) continue;
2185
+ skipped.push({
2186
+ rule: ruleId,
2187
+ reason: withNote(reason)
2188
+ });
2189
+ already.add(ruleId);
2190
+ }
2191
+ for (const rule of ruleset.rules) {
2192
+ if (disabled.has(rule.id) || fired.has(rule.id) || already.has(rule.id)) continue;
2193
+ if (only.size > 0 && !only.has(rule.id)) continue;
2194
+ skipped.push({
2195
+ rule: rule.id,
2196
+ reason: withNote("材料满足该检查的前置条件且未发现差异条目")
2197
+ });
2198
+ }
2199
+ if (only.size > 0) {
2200
+ const notSelected = ruleset.rules.filter((rule) => !only.has(rule.id) && !disabled.has(rule.id));
2201
+ if (notSelected.length > 0) skipped.push({
2202
+ rule: notSelected.map((rule) => rule.id).join(","),
2203
+ reason: withNote(`本次调用通过 only 参数把执行范围限制为 ${[...only].join(", ")},上列规则未执行`)
2204
+ });
2205
+ }
2206
+ const summary = {
2207
+ error: 0,
2208
+ warn: 0,
2209
+ info: 0
2210
+ };
2211
+ for (const issue of issues) summary[issue.severity] += 1;
2212
+ return {
2213
+ plugin: options.plugin,
2214
+ target: input.target,
2215
+ checkedAt: options.checkedAt,
2216
+ rulesetVersion: ruleset.version,
2217
+ summary,
2218
+ issues,
2219
+ skipped
2220
+ };
2221
+ }
2222
+ /** How the material declares its table. */
2223
+ const SPEC = {
2224
+ rowKeys: [
2225
+ "rows",
2226
+ "items",
2227
+ "classifications",
2228
+ "归类"
2229
+ ],
2230
+ columns: {
2231
+ itemNo: [
2232
+ "序号",
2233
+ "项号",
2234
+ "编号",
2235
+ "itemNo"
2236
+ ],
2237
+ description: [
2238
+ "品名",
2239
+ "商品名称",
2240
+ "货物名称",
2241
+ "description"
2242
+ ],
2243
+ material: [
2244
+ "材质",
2245
+ "材料",
2246
+ "成分",
2247
+ "material"
2248
+ ],
2249
+ function: [
2250
+ "功能",
2251
+ "用途",
2252
+ "工作原理",
2253
+ "function"
2254
+ ],
2255
+ chapter: [
2256
+ "章",
2257
+ "章号",
2258
+ "第几章",
2259
+ "chapter"
2260
+ ],
2261
+ heading: [
2262
+ "品目",
2263
+ "四位品目",
2264
+ "品目号",
2265
+ "heading"
2266
+ ],
2267
+ subheading: [
2268
+ "子目",
2269
+ "六位子目",
2270
+ "子目号",
2271
+ "subheading"
2272
+ ],
2273
+ hsCode: [
2274
+ "商品编号",
2275
+ "HS编码",
2276
+ "税则号列",
2277
+ "hsCode"
2278
+ ],
2279
+ declaredAt: [
2280
+ "申报日期",
2281
+ "归类日期",
2282
+ "declaredAt"
2283
+ ],
2284
+ basis: [
2285
+ "归类依据",
2286
+ "归类理由",
2287
+ "依据",
2288
+ "basis"
2289
+ ],
2290
+ note: [
2291
+ "备注",
2292
+ "说明",
2293
+ "note",
2294
+ "remark"
2295
+ ]
2296
+ },
2297
+ header: {
2298
+ declarant: [
2299
+ "declarant",
2300
+ "申报人",
2301
+ "归类人"
2302
+ ],
2303
+ tariffVersion: [
2304
+ "tariffVersion",
2305
+ "税则版本",
2306
+ "适用年度税则"
2307
+ ],
2308
+ checkedAt: ["checkedAt", "核对日期"]
2309
+ }
2310
+ };
2311
+ /** Fields the material must carry somewhere for the reader to accept it. */
2312
+ const REQUIRE_ANY_OF = [
2313
+ "商品编号",
2314
+ "hsCode",
2315
+ "品名",
2316
+ "description",
2317
+ "章",
2318
+ "chapter",
2319
+ "品目",
2320
+ "heading"
2321
+ ];
2322
+ /**
2323
+ * Parse the material and attach its canonical field names.
2324
+ * @param source - JSON or YAML text.
2325
+ * @param target - description of where the material came from.
2326
+ * @returns the normalized table, with each row's aliases resolved to field names.
2327
+ */
2328
+ function parseMaterial(source, target) {
2329
+ const table = parseTable(source, target, {
2330
+ ...SPEC,
2331
+ ...REQUIRE_ANY_OF === void 0 ? {} : { requireAnyOf: REQUIRE_ANY_OF }
2332
+ });
2333
+ for (const row of table.rows) canonicaliseRow(row, SPEC);
2334
+ return table;
2335
+ }
2336
+ /**
2337
+ * Run the rule pack against the material.
2338
+ * @param input - normalized table.
2339
+ * @param ruleset - validated rule pack.
2340
+ * @param options - plugin identity, clock value, rule selection and overrides.
2341
+ * @returns the report.
2342
+ */
2343
+ function runCheck(input, ruleset, options) {
2344
+ return runTableCheck(input, ruleset, options);
2345
+ }
2346
+ //#endregion
2347
+ //#region src/shared/wording.ts
2348
+ /** Footer appended to every rendered report. */
2349
+ const DISCLAIMER = "免责声明:本报告由程序按声明的规则集自动生成,仅列出与所引条款之间的字面差异,供人工复核线索使用;不构成对任何事实、行为或文件的定性意见,也不替代主管人员、评审专家或监管机构的判断。规则集的时效性与适用范围以引用文件的最新有效版本为准。";
2350
+ //#endregion
2351
+ //#region src/view.ts
2352
+ /**
2353
+ * Model-facing projection of a {@link Report}.
2354
+ *
2355
+ * `buildView` is pure and covers the whole canonical output shape, so both the
2356
+ * tool's `output.render` and the test suite exercise exactly what the model
2357
+ * reads. `output.schema` in `index.ts` mirrors these keys.
2358
+ */
2359
+ const SEVERITY_ORDER = [
2360
+ "error",
2361
+ "warn",
2362
+ "info"
2363
+ ];
2364
+ function issueLines(issues) {
2365
+ const lines = [];
2366
+ for (const severity of SEVERITY_ORDER) {
2367
+ const group = issues.filter((issue) => issue.severity === severity);
2368
+ if (group.length === 0) continue;
2369
+ lines.push(`### ${severity}(${group.length} 条)`);
2370
+ lines.push("");
2371
+ for (const issue of group) {
2372
+ const where = [];
2373
+ if (issue.locator.file !== void 0) where.push(issue.locator.file);
2374
+ if (issue.locator.row !== void 0) where.push(`第${issue.locator.row}行`);
2375
+ if (issue.locator.page !== void 0) where.push(`第${issue.locator.page}页`);
2376
+ if (issue.locator.cell !== void 0) where.push(issue.locator.cell);
2377
+ if (issue.locator.column !== void 0) where.push(`字段 ${issue.locator.column}`);
2378
+ lines.push(`- \`${issue.ruleId}\` ${where.length === 0 ? "" : `[${where.join(" · ")}] `}${issue.found}`);
2379
+ lines.push(` - 依据:${issue.expected}`);
2380
+ lines.push(` - 条款:${issue.basis}`);
2381
+ if (issue.fix !== void 0) lines.push(` - 建议核对:${issue.fix}`);
2382
+ }
2383
+ lines.push("");
2384
+ }
2385
+ return lines;
2386
+ }
2387
+ /**
2388
+ * Build the model-facing view of a report.
2389
+ * @param report - canonical report value.
2390
+ * @returns the structured view consumed by `output.render` and by tests.
2391
+ */
2392
+ function buildView(report) {
2393
+ const lines = [];
2394
+ lines.push(`# ${report.plugin} 检查结果`);
2395
+ lines.push("");
2396
+ lines.push(`- 检查对象:${report.target}`);
2397
+ lines.push(`- 检查时间:${report.checkedAt}`);
2398
+ lines.push(`- 规则集版本:${report.rulesetVersion}`);
2399
+ lines.push(`- 统计:错误 ${report.summary.error} · 提示 ${report.summary.warn} · 参考 ${report.summary.info}`);
2400
+ lines.push("");
2401
+ lines.push("## 差异明细");
2402
+ lines.push("");
2403
+ if (report.issues.length === 0) {
2404
+ lines.push("本次执行的检查项未产生差异条目。");
2405
+ lines.push("");
2406
+ } else lines.push(...issueLines(report.issues));
2407
+ lines.push("## 未执行的检查");
2408
+ lines.push("");
2409
+ if (report.skipped.length === 0) lines.push("规则集声明的全部检查项均已执行。");
2410
+ else {
2411
+ lines.push(`以下 ${report.skipped.length} 项检查未执行,其结论不包含在本报告中:`);
2412
+ lines.push("");
2413
+ for (const entry of report.skipped) lines.push(`- \`${entry.rule}\`:${entry.reason}`);
2414
+ }
2415
+ lines.push("");
2416
+ lines.push("---");
2417
+ lines.push("");
2418
+ lines.push(DISCLAIMER);
2419
+ return {
2420
+ plugin: report.plugin,
2421
+ target: report.target,
2422
+ checkedAt: report.checkedAt,
2423
+ rulesetVersion: report.rulesetVersion,
2424
+ summary: report.summary,
2425
+ issueCount: report.issues.length,
2426
+ skippedCount: report.skipped.length,
2427
+ markdown: lines.join("\n"),
2428
+ reportJson: canonicalJson(report)
2429
+ };
2430
+ }
2431
+ //#endregion
2432
+ //#region src/shared/ruleset.ts
2433
+ /**
2434
+ * Rule-pack loading and validation.
2435
+ *
2436
+ * `loadRuleset` is the only entry point. It fails loudly rather than producing a
2437
+ * short report, and it enforces three rules that keep the family honest:
2438
+ *
2439
+ * 1. every rule carries an id, a severity and a complete citable basis;
2440
+ * 2. rule ids are unique and every requested disable names a real rule;
2441
+ * 3. a check whose basis is a general principle, or a locally configured
2442
+ * threshold, may not be declared `error` — those are leads for human review,
2443
+ * not statements about a violated requirement.
2444
+ */
2445
+ const SEVERITIES = [
2446
+ "error",
2447
+ "warn",
2448
+ "info"
2449
+ ];
2450
+ const BASIS_KINDS = [
2451
+ "direct",
2452
+ "derived-from-principle",
2453
+ "institutional-configuration"
2454
+ ];
2455
+ const MAX_SEVERITY_BY_KIND = {
2456
+ direct: "error",
2457
+ "derived-from-principle": "warn",
2458
+ "institutional-configuration": "info"
2459
+ };
2460
+ const SEVERITY_RANK = {
2461
+ error: 2,
2462
+ warn: 1,
2463
+ info: 0
2464
+ };
2465
+ function asRecord(value, where) {
2466
+ if (typeof value !== "object" || value === null || Array.isArray(value)) throw new YamlSubsetError(`${where}: expected a mapping`);
2467
+ return value;
2468
+ }
2469
+ function asStringArray(value, where) {
2470
+ if (value === void 0 || value === null) return [];
2471
+ if (!Array.isArray(value)) throw new YamlSubsetError(`${where}: expected a sequence`);
2472
+ return value.map((entry) => {
2473
+ if (typeof entry !== "string") throw new YamlSubsetError(`${where}: expected string entries`);
2474
+ return entry;
2475
+ });
2476
+ }
2477
+ function parseBasis(value, where) {
2478
+ const source = asRecord(value, where);
2479
+ const kindRaw = requireString(source, "kind", where);
2480
+ if (!BASIS_KINDS.includes(kindRaw)) throw new YamlSubsetError(`${where}: kind must be one of ${BASIS_KINDS.join(" / ")}`);
2481
+ const basis = {
2482
+ document: requireString(source, "document", where),
2483
+ number: typeof source.number === "string" ? source.number : "",
2484
+ clause: requireString(source, "clause", where),
2485
+ excerpt: requireString(source, "excerpt", where),
2486
+ kind: kindRaw,
2487
+ source: requireString(source, "source", where)
2488
+ };
2489
+ if (basis.excerpt.trim().length < 8) throw new YamlSubsetError(`${where}: excerpt is too short to be a verbatim quotation`);
2490
+ return basis;
2491
+ }
2492
+ function parseRule(value, index) {
2493
+ const where = `rules[${index}]`;
2494
+ const source = asRecord(value, where);
2495
+ const id = requireString(source, "id", where);
2496
+ const severityRaw = requireString(source, "severity", where);
2497
+ if (!SEVERITIES.includes(severityRaw)) throw new YamlSubsetError(`${where}: severity must be one of ${SEVERITIES.join(" / ")}`);
2498
+ const basis = parseBasis(source.basis, `${where}.basis`);
2499
+ const severity = severityRaw;
2500
+ const ceiling = MAX_SEVERITY_BY_KIND[basis.kind];
2501
+ if (SEVERITY_RANK[severity] > SEVERITY_RANK[ceiling]) throw new YamlSubsetError(`${where} (${id}): severity "${severity}" is not allowed for a "${basis.kind}" basis; the strongest permitted severity is "${ceiling}"`);
2502
+ const rule = {
2503
+ id,
2504
+ title: requireString(source, "title", where),
2505
+ severity,
2506
+ basis,
2507
+ params: source.params === void 0 || source.params === null ? {} : asRecord(source.params, `${where}.params`)
2508
+ };
2509
+ if (source.alsoBasis !== void 0 && source.alsoBasis !== null) {
2510
+ if (!Array.isArray(source.alsoBasis)) throw new YamlSubsetError(`${where}.alsoBasis: expected a sequence`);
2511
+ rule.alsoBasis = source.alsoBasis.map((entry, offset) => parseBasis(entry, `${where}.alsoBasis[${offset}]`));
2512
+ }
2513
+ if (typeof source.note === "string") rule.note = source.note;
2514
+ return rule;
2515
+ }
2516
+ /**
2517
+ * Parse and validate a rule pack.
2518
+ * @param source - YAML text of the rule pack.
2519
+ * @returns the validated ruleset.
2520
+ */
2521
+ function loadRuleset(source) {
2522
+ const root = asRecord(parseYaml(source), "ruleset");
2523
+ const plugin = requireString(root, "plugin", "ruleset");
2524
+ const version = requireString(root, "version", "ruleset");
2525
+ const rulesRaw = root.rules;
2526
+ if (!Array.isArray(rulesRaw) || rulesRaw.length === 0) throw new YamlSubsetError("ruleset: \"rules\" must be a non-empty sequence");
2527
+ const rules = rulesRaw.map((entry, index) => parseRule(entry, index));
2528
+ const seen = /* @__PURE__ */ new Set();
2529
+ for (const rule of rules) {
2530
+ if (seen.has(rule.id)) throw new YamlSubsetError(`ruleset: duplicate rule id "${rule.id}"`);
2531
+ seen.add(rule.id);
2532
+ }
2533
+ const disabled = asStringArray(root.disabled, "ruleset.disabled");
2534
+ for (const id of disabled) if (!seen.has(id)) throw new YamlSubsetError(`ruleset.disabled: unknown rule id "${id}"`);
2535
+ return {
2536
+ version,
2537
+ plugin,
2538
+ rules,
2539
+ disabled
2540
+ };
2541
+ }
2542
+ //#endregion
2543
+ //#region src/shared/memo.ts
2544
+ /**
2545
+ * Memoized loading of package-owned rule packs.
2546
+ *
2547
+ * Rule packs ship with the plugin, are read-only at runtime and are small, so
2548
+ * they are parsed once per process and reused. Keeping the reads here means the
2549
+ * pure check core still receives a plain `Ruleset` value and stays testable
2550
+ * without a plugin context.
2551
+ */
2552
+ const cache = /* @__PURE__ */ new Map();
2553
+ /**
2554
+ * Load and cache a rule pack by absolute path.
2555
+ * @param path - absolute path to the rule-pack YAML.
2556
+ * @returns the validated ruleset.
2557
+ */
2558
+ function loadRulesetFile(path) {
2559
+ const cached = cache.get(path);
2560
+ if (cached !== void 0) return cached;
2561
+ const pending = readFile(path, "utf8").then((text) => loadRuleset(text));
2562
+ cache.set(path, pending);
2563
+ pending.catch(() => cache.delete(path));
2564
+ return pending;
2565
+ }
2566
+ //#endregion
2567
+ //#region src/index.ts
2568
+ const name = "dsh-hs-classify";
2569
+ const inject = ["tools"];
2570
+ /** Tool id exposed to the model, and the row id in `cordis.patch.yml`. */
2571
+ const TOOL_NAME = "hs_classify";
2572
+ /**
2573
+ * Locate a package-owned file such as the rule pack.
2574
+ *
2575
+ * Resolution order: absolute path, then every ancestor of the module directory,
2576
+ * then the process working directory. A wrong silent fallback would build a
2577
+ * report from the wrong rule pack, so a miss throws with the paths tried.
2578
+ *
2579
+ * @param relative - configured path, relative to the plugin package root.
2580
+ * @returns the resolved absolute path.
2581
+ * @throws Error naming every location tried, when the file is absent.
2582
+ */
2583
+ function resolvePackageFile(relative) {
2584
+ if (isAbsolute(relative)) {
2585
+ if (existsSync(relative)) return relative;
2586
+ throw new Error(`规则库文件不存在:${relative}`);
2587
+ }
2588
+ const tried = [];
2589
+ let current = import.meta.dirname ?? process.cwd();
2590
+ for (;;) {
2591
+ const candidate = resolve(current, relative);
2592
+ tried.push(candidate);
2593
+ if (existsSync(candidate)) return candidate;
2594
+ const parent = dirname(current);
2595
+ if (parent === current) break;
2596
+ current = parent;
2597
+ }
2598
+ const fromCwd = resolve(process.cwd(), relative);
2599
+ if (!tried.includes(fromCwd)) {
2600
+ tried.push(fromCwd);
2601
+ if (existsSync(fromCwd)) return fromCwd;
2602
+ }
2603
+ throw new Error(`规则库文件未找到:${relative};已尝试 ${tried.length} 个位置,最近一处为 ${tried[0] ?? ""}`);
2604
+ }
2605
+ /** Read material from a file path or use the inline text as-is. */
2606
+ async function readMaterial(file, material, signal) {
2607
+ if (file !== void 0 && file.trim() !== "") {
2608
+ const path = resolve(file);
2609
+ signal.throwIfAborted();
2610
+ return {
2611
+ text: await readFile(path, {
2612
+ encoding: "utf8",
2613
+ signal
2614
+ }),
2615
+ target: path
2616
+ };
2617
+ }
2618
+ if (material !== void 0 && material.trim() !== "") return {
2619
+ text: material,
2620
+ target: "(内联材料)"
2621
+ };
2622
+ throw new MaterialError("必须提供 file 或 material 之一");
2623
+ }
2624
+ /**
2625
+ * Register the checker tool.
2626
+ *
2627
+ * Registration is an effect: `ctx.tools.register` returns the disposer that
2628
+ * removes the tool when this plugin unloads, which is what keeps the plugin
2629
+ * hot-reloadable.
2630
+ *
2631
+ * @param ctx - plugin context, with `tools` already available.
2632
+ * @param config - validated configuration.
2633
+ */
2634
+ function apply(ctx, config) {
2635
+ return ctx.tools.register(defineTool({
2636
+ name: TOOL_NAME,
2637
+ description: "商品归类层级一致性核对(按章、品目、子目与商品编号的层级关系核对自洽,仅提示差异,不作出定性结论)本工具只列出材料与所引条款之间的字面差异,供人工复核,不作出任何定性结论。材料为 JSON 或 YAML;无法执行的检查会列在 skipped 中。",
2638
+ parameters: {
2639
+ file: {
2640
+ type: "string",
2641
+ description: "材料文件绝对路径(JSON 或 YAML)。与 material 二选一。"
2642
+ },
2643
+ material: {
2644
+ type: "string",
2645
+ description: "内联材料文本(JSON 或 YAML)。与 file 二选一。"
2646
+ },
2647
+ rulesFile: {
2648
+ type: "string",
2649
+ description: "覆盖配置中的规则库路径(相对插件包根或绝对路径)。"
2650
+ },
2651
+ only: {
2652
+ type: "array",
2653
+ items: { type: "string" },
2654
+ description: "只执行这些规则 id;留空表示执行全部规则。"
2655
+ }
2656
+ },
2657
+ output: {
2658
+ schema: {
2659
+ type: "object",
2660
+ additionalProperties: false,
2661
+ properties: {
2662
+ plugin: { type: "string" },
2663
+ target: { type: "string" },
2664
+ checkedAt: { type: "string" },
2665
+ rulesetVersion: { type: "string" },
2666
+ summary: {
2667
+ type: "object",
2668
+ additionalProperties: false,
2669
+ properties: {
2670
+ error: { type: "integer" },
2671
+ warn: { type: "integer" },
2672
+ info: { type: "integer" }
2673
+ }
2674
+ },
2675
+ issueCount: { type: "integer" },
2676
+ skippedCount: { type: "integer" },
2677
+ markdown: { type: "string" },
2678
+ reportJson: { type: "string" }
2679
+ }
2680
+ },
2681
+ render: (_args, value) => [{
2682
+ type: "text",
2683
+ text: value.markdown
2684
+ }]
2685
+ },
2686
+ timeoutMs: config.timeoutMs,
2687
+ isConcurrencySafe: () => true,
2688
+ async execute(args, exec) {
2689
+ const relative = args.rulesFile !== void 0 && args.rulesFile.trim() !== "" ? args.rulesFile : config.rulesFile;
2690
+ const [material, ruleset] = await Promise.all([readMaterial(args.file, args.material, exec.signal), loadRulesetFile(resolvePackageFile(relative))]);
2691
+ exec.signal.throwIfAborted();
2692
+ const input = parseMaterial(material.text, material.target);
2693
+ const only = (args.only ?? []).length > 0 ? args.only : config.onlyRules;
2694
+ return buildView(runCheck(input, ruleset, {
2695
+ plugin: name,
2696
+ checkedAt: (/* @__PURE__ */ new Date()).toISOString(),
2697
+ disabledRules: config.disabledRules,
2698
+ onlyRules: only,
2699
+ skipNotes: config.skipNotes === "" ? void 0 : config.skipNotes
2700
+ }));
2701
+ }
2702
+ }));
2703
+ }
2704
+ //#endregion
2705
+ export { Config, TOOL_NAME, apply, inject, name, resolvePackageFile };