@toolpath/tool-scraper 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +98 -0
  3. package/dist/conventions.d.ts +124 -0
  4. package/dist/conventions.js +143 -0
  5. package/dist/errors.d.ts +46 -0
  6. package/dist/errors.js +53 -0
  7. package/dist/families/destinytool.d.ts +49 -0
  8. package/dist/families/destinytool.js +55 -0
  9. package/dist/families/index.d.ts +59 -0
  10. package/dist/families/index.js +91 -0
  11. package/dist/families/kennametal.d.ts +757 -0
  12. package/dist/families/kennametal.js +660 -0
  13. package/dist/families/regofix.d.ts +185 -0
  14. package/dist/families/regofix.js +250 -0
  15. package/dist/family.d.ts +130 -0
  16. package/dist/family.js +38 -0
  17. package/dist/fetch.d.ts +98 -0
  18. package/dist/fetch.js +116 -0
  19. package/dist/identity.d.ts +133 -0
  20. package/dist/identity.js +118 -0
  21. package/dist/index.d.ts +31 -0
  22. package/dist/index.js +31 -0
  23. package/dist/node/cad-mirror.d.ts +55 -0
  24. package/dist/node/cad-mirror.js +89 -0
  25. package/dist/node/cli.d.ts +35 -0
  26. package/dist/node/cli.js +340 -0
  27. package/dist/node/csv.d.ts +47 -0
  28. package/dist/node/csv.js +123 -0
  29. package/dist/node/index.d.ts +16 -0
  30. package/dist/node/index.js +16 -0
  31. package/dist/node/main.d.ts +13 -0
  32. package/dist/node/main.js +14 -0
  33. package/dist/node/paths.d.ts +60 -0
  34. package/dist/node/paths.js +80 -0
  35. package/dist/node/receipts.d.ts +100 -0
  36. package/dist/node/receipts.js +107 -0
  37. package/dist/order.d.ts +10 -0
  38. package/dist/order.js +12 -0
  39. package/dist/provenance.d.ts +125 -0
  40. package/dist/provenance.js +133 -0
  41. package/dist/records.d.ts +305 -0
  42. package/dist/records.js +297 -0
  43. package/dist/registry.d.ts +63 -0
  44. package/dist/registry.js +145 -0
  45. package/dist/scrape.d.ts +70 -0
  46. package/dist/scrape.js +37 -0
  47. package/dist/thread.d.ts +48 -0
  48. package/dist/thread.js +98 -0
  49. package/dist/uuid5.d.ts +31 -0
  50. package/dist/uuid5.js +64 -0
  51. package/dist/vendors/destinytool/index.d.ts +11 -0
  52. package/dist/vendors/destinytool/index.js +11 -0
  53. package/dist/vendors/destinytool/records.d.ts +118 -0
  54. package/dist/vendors/destinytool/records.js +266 -0
  55. package/dist/vendors/destinytool/scrape.d.ts +108 -0
  56. package/dist/vendors/destinytool/scrape.js +192 -0
  57. package/dist/vendors/kennametal/cad.d.ts +87 -0
  58. package/dist/vendors/kennametal/cad.js +119 -0
  59. package/dist/vendors/kennametal/index.d.ts +21 -0
  60. package/dist/vendors/kennametal/index.js +21 -0
  61. package/dist/vendors/kennametal/materials.d.ts +143 -0
  62. package/dist/vendors/kennametal/materials.js +200 -0
  63. package/dist/vendors/kennametal/records.d.ts +88 -0
  64. package/dist/vendors/kennametal/records.js +241 -0
  65. package/dist/vendors/kennametal/scrape.d.ts +111 -0
  66. package/dist/vendors/kennametal/scrape.js +226 -0
  67. package/dist/vendors/kennametal/thread-column.d.ts +28 -0
  68. package/dist/vendors/kennametal/thread-column.js +41 -0
  69. package/dist/vendors/regofix/index.d.ts +8 -0
  70. package/dist/vendors/regofix/index.js +8 -0
  71. package/dist/vendors/regofix/scrape.d.ts +237 -0
  72. package/dist/vendors/regofix/scrape.js +521 -0
  73. package/package.json +76 -0
@@ -0,0 +1,80 @@
1
+ /**
2
+ * Where a scrape lands.
3
+ *
4
+ * **Scraped output is never committed.** A CSV is a vendor's data and a
5
+ * working file, not source, and this repository is public — which is a second
6
+ * reason, independent of size, to keep it out. Git was carrying the provenance
7
+ * of those CSVs for free; now that it is not, every scrape writes a
8
+ * {@link receipts} sidecar beside its file.
9
+ *
10
+ * This module is in `node/` rather than beside the config tables: resolving a
11
+ * root needs `process.env` and a path relative to this file's own location,
12
+ * and a family table that needed either would be unimportable in anything that
13
+ * only wants to read records.
14
+ */
15
+ import { dirname, join, resolve } from 'node:path';
16
+ import { homedir } from 'node:os';
17
+ import { fileURLToPath } from 'node:url';
18
+ import { familyBrand } from '../family.js';
19
+ import { familyConfig } from '../families/index.js';
20
+ /**
21
+ * Where scraped CSVs are read from and written to.
22
+ *
23
+ * Set it when the package is installed rather than run from a checkout: the
24
+ * default below is derived from this file's own location, which is right in a
25
+ * working tree and meaningless inside `node_modules`. Every command prints the
26
+ * resolved root for exactly that reason — a scrape that wrote somewhere
27
+ * surprising should say so on the way, not be discovered later.
28
+ */
29
+ export const SCRAPE_ROOT_ENV = 'TOOLPATH_SCRAPE_ROOT';
30
+ /** `packages/tool-scraper/scrape-out`, which `.gitignore` already covers. */
31
+ export const DEFAULT_SCRAPE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '../../scrape-out');
32
+ /** `~` expanded, the way a shell would. */
33
+ function expandHome(path) {
34
+ return path.startsWith('~/') ? join(homedir(), path.slice(2)) : path;
35
+ }
36
+ /** The directory holding every vendor's scraped CSVs. */
37
+ export function scrapeRoot() {
38
+ const override = process.env[SCRAPE_ROOT_ENV];
39
+ return override ? resolve(expandHome(override)) : DEFAULT_SCRAPE_ROOT;
40
+ }
41
+ /**
42
+ * One line naming the resolved root and how it was resolved.
43
+ *
44
+ * Printed by every command. The distinction it carries is the one that matters
45
+ * when a scrape goes somewhere unexpected: whether the path came from the
46
+ * environment or from this package's own location.
47
+ */
48
+ export function describeRoot() {
49
+ const how = process.env[SCRAPE_ROOT_ENV] ? 'set' : 'default';
50
+ return `scrape root: ${scrapeRoot()} (${SCRAPE_ROOT_ENV} ${how})`;
51
+ }
52
+ /**
53
+ * Where one vendor's scraped CSVs live — the receipts.
54
+ *
55
+ * Per brand rather than per adapter, and the distinction is worth holding on
56
+ * to: an adapter is a fact about *code*, a scraped table is a fact about who
57
+ * published it. WIDIA's tables are WIDIA's even though Kennametal's adapter is
58
+ * what fetched them.
59
+ */
60
+ export function csvDir(brand) {
61
+ return join(scrapeRoot(), brand, 'csv');
62
+ }
63
+ /**
64
+ * One vendor's mirrored STEP models.
65
+ *
66
+ * Nothing is redistributed from here: these are a local working copy for
67
+ * measuring a holder, and only a derived profile is ever meant to leave.
68
+ */
69
+ export function stepDir(brand) {
70
+ return join(scrapeRoot(), brand, 'step');
71
+ }
72
+ /**
73
+ * Where one family's CSV lives, resolved through its own brand.
74
+ *
75
+ * Takes a bare CSV name rather than a path, so a caller cannot pass a file
76
+ * from somewhere else and have it silently treated as this family's receipt.
77
+ */
78
+ export function familyCsv(name) {
79
+ return join(csvDir(familyBrand(familyConfig(name))), name);
80
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * What a scrape did, written beside what it produced.
3
+ *
4
+ * The CSVs are not committed — they are vendor data, and a public repository
5
+ * is the wrong place for it — so `git log` cannot answer "when was this
6
+ * scraped, from which URL, under which family code, how many rows". Each
7
+ * scrape writes a sidecar instead. It is cheap now and effectively impossible
8
+ * to backfill: nothing in a CSV records the URL it came from, and a re-scrape
9
+ * a month later answers a different question than the one asked.
10
+ *
11
+ * **The row count is the interesting field.** `families/` states how many rows
12
+ * a human counted at scrape time, and a receipt states how many the scrape
13
+ * actually wrote. Every other count in a pipeline like this is computed from
14
+ * the same file it is checking, so a scrape that silently lost rows agrees
15
+ * with itself; two independently-arrived-at numbers do not.
16
+ */
17
+ import type { BrandName } from '../identity.js';
18
+ /**
19
+ * The suffix a receipt takes, beside the CSV it describes.
20
+ *
21
+ * A suffix rather than a parallel directory, so a CSV and its receipt cannot
22
+ * be separated by moving one — and so a directory listing shows immediately
23
+ * which scrapes have one.
24
+ */
25
+ export declare const SUFFIX = ".scrape.json";
26
+ /**
27
+ * This package's version, or `unknown` when it cannot be read.
28
+ *
29
+ * Not an error: the package runs perfectly well from a source checkout, and
30
+ * refusing to record a scrape over a missing version string would be the tail
31
+ * wagging the dog. `unknown` is the honest value and reads as one.
32
+ */
33
+ export declare function scraperVersion(): string;
34
+ /** One scrape: what was fetched, from where, when, and how much of it. */
35
+ export interface Receipt {
36
+ /**
37
+ * The CSV this describes, by name — so a receipt read on its own says what
38
+ * it belongs to.
39
+ */
40
+ csv: string;
41
+ brand: BrandName;
42
+ /**
43
+ * The URL the rows came from. A request, not a page: this is the thing to
44
+ * re-issue when a column changes shape, and finding it again is the most
45
+ * expensive part of adding a vendor.
46
+ */
47
+ source: string;
48
+ /** How many rows were written. See the module docstring. */
49
+ rows: number;
50
+ /** UTC, ISO 8601, to the second. */
51
+ scrapedAt: string;
52
+ scraper: string;
53
+ /**
54
+ * The vendor's own family code where there is one. REGO-FIX and Destiny Tool
55
+ * have none — their scrape target is a set of index filters — so it is null
56
+ * rather than an empty string, which would read as a code the vendor left
57
+ * blank.
58
+ */
59
+ familyCode: string | null;
60
+ }
61
+ /** Where the receipt for `csvPath` goes. */
62
+ export declare function pathFor(csvPath: string): string;
63
+ /** What {@link write} needs to record. */
64
+ export interface ReceiptInput {
65
+ brand: BrandName;
66
+ source: string;
67
+ rows: number;
68
+ familyCode?: string | null;
69
+ /** The moment to stamp. Injectable so a test is not a clock. */
70
+ now?: Date;
71
+ }
72
+ /**
73
+ * Record a scrape beside its CSV, replacing any earlier receipt.
74
+ *
75
+ * Replacing rather than appending: a receipt describes the file sitting next
76
+ * to it, and a history of scrapes that produced files no longer there is a
77
+ * log, not a receipt.
78
+ */
79
+ export declare function write(csvPath: string, input: ReceiptInput): string;
80
+ /**
81
+ * The receipt beside `csvPath`, or null when the scrape predates them.
82
+ *
83
+ * Null rather than a throw: a CSV somebody scraped before this existed is
84
+ * still a usable CSV, and refusing it would be refusing data over its
85
+ * paperwork.
86
+ */
87
+ export declare function read(csvPath: string): Receipt | null;
88
+ /**
89
+ * Refuse a scrape whose row count disagrees with the declared one.
90
+ *
91
+ * The one check two independent numbers make possible. A silently truncated
92
+ * response, a facet that started filtering, a vendor discontinuing half a
93
+ * family — all of them produce a CSV that parses cleanly and is wrong, and
94
+ * none of them is visible from the file alone.
95
+ *
96
+ * Throwing rather than warning, because the declared count is a human's
97
+ * statement about the vendor's own page: if the scrape now disagrees with it,
98
+ * one of the two needs updating and neither can be guessed at from here.
99
+ */
100
+ export declare function checkRows(family: string, declared: number, receipt: Receipt): void;
@@ -0,0 +1,107 @@
1
+ /**
2
+ * What a scrape did, written beside what it produced.
3
+ *
4
+ * The CSVs are not committed — they are vendor data, and a public repository
5
+ * is the wrong place for it — so `git log` cannot answer "when was this
6
+ * scraped, from which URL, under which family code, how many rows". Each
7
+ * scrape writes a sidecar instead. It is cheap now and effectively impossible
8
+ * to backfill: nothing in a CSV records the URL it came from, and a re-scrape
9
+ * a month later answers a different question than the one asked.
10
+ *
11
+ * **The row count is the interesting field.** `families/` states how many rows
12
+ * a human counted at scrape time, and a receipt states how many the scrape
13
+ * actually wrote. Every other count in a pipeline like this is computed from
14
+ * the same file it is checking, so a scrape that silently lost rows agrees
15
+ * with itself; two independently-arrived-at numbers do not.
16
+ */
17
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
18
+ import { dirname, basename, join } from 'node:path';
19
+ import { createRequire } from 'node:module';
20
+ import { VendorResponseError } from '../errors.js';
21
+ import { compare } from '../order.js';
22
+ /**
23
+ * The suffix a receipt takes, beside the CSV it describes.
24
+ *
25
+ * A suffix rather than a parallel directory, so a CSV and its receipt cannot
26
+ * be separated by moving one — and so a directory listing shows immediately
27
+ * which scrapes have one.
28
+ */
29
+ export const SUFFIX = '.scrape.json';
30
+ /**
31
+ * This package's version, or `unknown` when it cannot be read.
32
+ *
33
+ * Not an error: the package runs perfectly well from a source checkout, and
34
+ * refusing to record a scrape over a missing version string would be the tail
35
+ * wagging the dog. `unknown` is the honest value and reads as one.
36
+ */
37
+ export function scraperVersion() {
38
+ try {
39
+ const require = createRequire(import.meta.url);
40
+ const manifest = require('../../package.json');
41
+ return manifest.version ?? 'unknown';
42
+ }
43
+ catch {
44
+ return 'unknown';
45
+ }
46
+ }
47
+ /** Where the receipt for `csvPath` goes. */
48
+ export function pathFor(csvPath) {
49
+ return join(dirname(csvPath), basename(csvPath) + SUFFIX);
50
+ }
51
+ /**
52
+ * Record a scrape beside its CSV, replacing any earlier receipt.
53
+ *
54
+ * Replacing rather than appending: a receipt describes the file sitting next
55
+ * to it, and a history of scrapes that produced files no longer there is a
56
+ * log, not a receipt.
57
+ */
58
+ export function write(csvPath, input) {
59
+ const stamped = input.now ?? new Date();
60
+ const receipt = {
61
+ csv: basename(csvPath),
62
+ brand: input.brand,
63
+ source: input.source,
64
+ rows: input.rows,
65
+ scrapedAt: `${stamped.toISOString().slice(0, 19)}+00:00`,
66
+ scraper: scraperVersion(),
67
+ familyCode: input.familyCode ?? null,
68
+ };
69
+ const out = pathFor(csvPath);
70
+ mkdirSync(dirname(out), { recursive: true });
71
+ // Sorted keys, so a re-scrape's receipt diffs only where it differs.
72
+ const ordered = Object.fromEntries(Object.entries(receipt).sort(([a], [b]) => compare(a, b)));
73
+ writeFileSync(out, `${JSON.stringify(ordered, null, 2)}\n`);
74
+ return out;
75
+ }
76
+ /**
77
+ * The receipt beside `csvPath`, or null when the scrape predates them.
78
+ *
79
+ * Null rather than a throw: a CSV somebody scraped before this existed is
80
+ * still a usable CSV, and refusing it would be refusing data over its
81
+ * paperwork.
82
+ */
83
+ export function read(csvPath) {
84
+ const path = pathFor(csvPath);
85
+ if (!existsSync(path))
86
+ return null;
87
+ return JSON.parse(readFileSync(path, 'utf8'));
88
+ }
89
+ /**
90
+ * Refuse a scrape whose row count disagrees with the declared one.
91
+ *
92
+ * The one check two independent numbers make possible. A silently truncated
93
+ * response, a facet that started filtering, a vendor discontinuing half a
94
+ * family — all of them produce a CSV that parses cleanly and is wrong, and
95
+ * none of them is visible from the file alone.
96
+ *
97
+ * Throwing rather than warning, because the declared count is a human's
98
+ * statement about the vendor's own page: if the scrape now disagrees with it,
99
+ * one of the two needs updating and neither can be guessed at from here.
100
+ */
101
+ export function checkRows(family, declared, receipt) {
102
+ if (receipt.rows !== declared) {
103
+ throw new VendorResponseError(family, `the scrape wrote ${receipt.rows} rows where this family declares ` +
104
+ `${declared} — either the vendor changed the family or the scrape ` +
105
+ `lost rows. Re-count and update \`rows\`, or find out which.`);
106
+ }
107
+ }
@@ -0,0 +1,10 @@
1
+ /**
2
+ * One string comparator, so every ordered output in this package is ordered
3
+ * the same way.
4
+ *
5
+ * By code unit rather than `localeCompare`: an assumptions document, a receipt
6
+ * and a scraped row order all have to be byte-stable for a diff or a gate to
7
+ * read them, and collation is a machine's locale setting rather than a
8
+ * property of the data.
9
+ */
10
+ export declare function compare(a: string, b: string): number;
package/dist/order.js ADDED
@@ -0,0 +1,12 @@
1
+ /**
2
+ * One string comparator, so every ordered output in this package is ordered
3
+ * the same way.
4
+ *
5
+ * By code unit rather than `localeCompare`: an assumptions document, a receipt
6
+ * and a scraped row order all have to be byte-stable for a diff or a gate to
7
+ * read them, and collation is a machine's locale setting rather than a
8
+ * property of the data.
9
+ */
10
+ export function compare(a, b) {
11
+ return a < b ? -1 : a > b ? 1 : 0;
12
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Where a per-family fact came from, as data rather than as a comment.
3
+ *
4
+ * The rule this enforces: **when a vendor label is unclear, ask** — record the
5
+ * answer and its date, and never guess and flag it afterwards. It was kept
6
+ * scrupulously in the source package, and kept in prose: `JG 2026-08-06`,
7
+ * breadcrumb citations, the three paragraphs deriving the KenDrill point angle
8
+ * by least squares. All of it invisible to code, and therefore unenforceable
9
+ * against a stranger who adds a family next month.
10
+ *
11
+ * A {@link Fact} is the index into that prose. It carries the value the
12
+ * pipeline uses plus **how it was arrived at**, which turns the cultural rule
13
+ * into a gate: an assumed fact with no note, date and initials does not
14
+ * compile, and a vendor-stated one with no citation does not either. An
15
+ * assumptions document is generated from the same data, so every guess is on
16
+ * one page instead of scattered across 700 lines of config comments.
17
+ *
18
+ * ## The three sources, and why the distinction is not cosmetic
19
+ *
20
+ * - **`vendor-stated`** — the vendor published it. Needs a `cite`: which
21
+ * column, breadcrumb, tagline or facet said so, specific enough to re-check
22
+ * with one request. `contact: 'face'` cites *"Shank - SK BT Taper Face
23
+ * Contact"*.
24
+ * - **`derived`** — this repo's arithmetic over vendor inputs. Needs a `note`
25
+ * saying what was computed from what. The KenDrill 142° point angle is the
26
+ * worked example: no page states it, but `L5 = D1 / (2·tan(SIG/2))` over 49
27
+ * rows does. A derived fact is checkable.
28
+ * - **`assumed`** — nobody said it and nothing proves it. Needs a note, a date
29
+ * and initials, because the only thing standing behind it is a person on a
30
+ * day. These are what an assumptions document exists to list: every place
31
+ * the catalog would be wrong if the guess were wrong.
32
+ *
33
+ * The prose does not go away. A fact's note is a sentence; the KenDrill
34
+ * derivation stays in `families/kennametal.ts` and the runbook where it has
35
+ * room.
36
+ *
37
+ * ## What the types do, and what {@link checkFact} still has to
38
+ *
39
+ * A union discriminated on `source` rather than one shape with five optional
40
+ * fields, so "an assumed fact needs `by`" is a compile error at the point
41
+ * somebody writes the family.
42
+ *
43
+ * The gate stays, because a type cannot say a string is non-empty or that it
44
+ * is shaped `YYYY-MM-DD` — and `cite: ''` would otherwise satisfy the
45
+ * compiler while citing nothing.
46
+ */
47
+ /** What a per-family constant can be. */
48
+ export type FactValue = string | number | boolean;
49
+ /**
50
+ * How a fact was arrived at. Closed, and ordered from strongest to weakest.
51
+ *
52
+ * An assumptions document sorts by it, so a reader meets the checkable facts
53
+ * before the guesses and finishes on the ones only a person stands behind —
54
+ * `derived` first, then `assumed`. (`vendor-stated` leads the order but is
55
+ * filtered out of that document entirely; see {@link assumptions}.)
56
+ */
57
+ export declare const SOURCES: readonly ["vendor-stated", "derived", "assumed"];
58
+ export type FactSource = (typeof SOURCES)[number];
59
+ /**
60
+ * One per-family constant, with its provenance.
61
+ *
62
+ * `value` is what the pipeline uses — the registry projects it onto the family
63
+ * config under the fact's own name, so readers say `cfg.pointAngle` and never
64
+ * learn about provenance. That projection is what keeps this from being a
65
+ * second source of truth: the fact is the only authored copy, and a family
66
+ * that also set the plain key is refused.
67
+ */
68
+ export type Fact<T extends FactValue = FactValue> = {
69
+ readonly value: T;
70
+ readonly source: 'vendor-stated';
71
+ /** What the vendor said, and where. */
72
+ readonly cite: string;
73
+ } | {
74
+ readonly value: T;
75
+ readonly source: 'derived';
76
+ /** What was worked out, from what. */
77
+ readonly note: string;
78
+ } | {
79
+ readonly value: T;
80
+ readonly source: 'assumed';
81
+ /** What was guessed, and why. */
82
+ readonly note: string;
83
+ /** When a person last checked it, `YYYY-MM-DD`. */
84
+ readonly checked: string;
85
+ /**
86
+ * Who. Required for the same reason the date is: an assumption is only
87
+ * as good as someone being willing to be named beside it.
88
+ */
89
+ readonly by: string;
90
+ };
91
+ /**
92
+ * Refuse a fact that does not carry what its source kind requires.
93
+ *
94
+ * Called for every fact of every family when the registry binds, so the
95
+ * failure names the family and the key rather than surfacing as a thin
96
+ * assumptions-document row nobody reads.
97
+ */
98
+ export declare function checkFact(family: string, key: string, fact: Fact): void;
99
+ /** One row of an assumptions document. */
100
+ export interface Assumption {
101
+ table: string;
102
+ family: string;
103
+ key: string;
104
+ value: FactValue;
105
+ source: FactSource;
106
+ note: string;
107
+ checked: string | null;
108
+ by: string | null;
109
+ }
110
+ /** A config table's shape, as far as provenance is concerned. */
111
+ export interface FactBearing {
112
+ facts?: Record<string, Fact>;
113
+ }
114
+ /**
115
+ * Every non-vendor-stated fact in the catalog, flattened and sorted.
116
+ *
117
+ * `vendor-stated` facts are excluded because the document's purpose is the
118
+ * list of things that would be wrong if somebody guessed wrong — a citation is
119
+ * a different kind of claim and has its own re-check path (one `curl`).
120
+ *
121
+ * Sorted by source then family then key — so the derived facts lead, the
122
+ * assumed ones close the list, and the output is byte-stable for whatever
123
+ * gate reads it.
124
+ */
125
+ export declare function assumptions(tables: Record<string, Record<string, FactBearing>>): Assumption[];
@@ -0,0 +1,133 @@
1
+ /**
2
+ * Where a per-family fact came from, as data rather than as a comment.
3
+ *
4
+ * The rule this enforces: **when a vendor label is unclear, ask** — record the
5
+ * answer and its date, and never guess and flag it afterwards. It was kept
6
+ * scrupulously in the source package, and kept in prose: `JG 2026-08-06`,
7
+ * breadcrumb citations, the three paragraphs deriving the KenDrill point angle
8
+ * by least squares. All of it invisible to code, and therefore unenforceable
9
+ * against a stranger who adds a family next month.
10
+ *
11
+ * A {@link Fact} is the index into that prose. It carries the value the
12
+ * pipeline uses plus **how it was arrived at**, which turns the cultural rule
13
+ * into a gate: an assumed fact with no note, date and initials does not
14
+ * compile, and a vendor-stated one with no citation does not either. An
15
+ * assumptions document is generated from the same data, so every guess is on
16
+ * one page instead of scattered across 700 lines of config comments.
17
+ *
18
+ * ## The three sources, and why the distinction is not cosmetic
19
+ *
20
+ * - **`vendor-stated`** — the vendor published it. Needs a `cite`: which
21
+ * column, breadcrumb, tagline or facet said so, specific enough to re-check
22
+ * with one request. `contact: 'face'` cites *"Shank - SK BT Taper Face
23
+ * Contact"*.
24
+ * - **`derived`** — this repo's arithmetic over vendor inputs. Needs a `note`
25
+ * saying what was computed from what. The KenDrill 142° point angle is the
26
+ * worked example: no page states it, but `L5 = D1 / (2·tan(SIG/2))` over 49
27
+ * rows does. A derived fact is checkable.
28
+ * - **`assumed`** — nobody said it and nothing proves it. Needs a note, a date
29
+ * and initials, because the only thing standing behind it is a person on a
30
+ * day. These are what an assumptions document exists to list: every place
31
+ * the catalog would be wrong if the guess were wrong.
32
+ *
33
+ * The prose does not go away. A fact's note is a sentence; the KenDrill
34
+ * derivation stays in `families/kennametal.ts` and the runbook where it has
35
+ * room.
36
+ *
37
+ * ## What the types do, and what {@link checkFact} still has to
38
+ *
39
+ * A union discriminated on `source` rather than one shape with five optional
40
+ * fields, so "an assumed fact needs `by`" is a compile error at the point
41
+ * somebody writes the family.
42
+ *
43
+ * The gate stays, because a type cannot say a string is non-empty or that it
44
+ * is shaped `YYYY-MM-DD` — and `cite: ''` would otherwise satisfy the
45
+ * compiler while citing nothing.
46
+ */
47
+ import { ScraperConfigError } from './errors.js';
48
+ import { compare } from './order.js';
49
+ /**
50
+ * How a fact was arrived at. Closed, and ordered from strongest to weakest.
51
+ *
52
+ * An assumptions document sorts by it, so a reader meets the checkable facts
53
+ * before the guesses and finishes on the ones only a person stands behind —
54
+ * `derived` first, then `assumed`. (`vendor-stated` leads the order but is
55
+ * filtered out of that document entirely; see {@link assumptions}.)
56
+ */
57
+ export const SOURCES = ['vendor-stated', 'derived', 'assumed'];
58
+ const DATE = /^\d{4}-\d{2}-\d{2}$/;
59
+ /** What a note has to say, per source kind. */
60
+ const WANTED = {
61
+ derived: 'computed from what',
62
+ assumed: 'guessed, and why',
63
+ };
64
+ /**
65
+ * Refuse a fact that does not carry what its source kind requires.
66
+ *
67
+ * Called for every fact of every family when the registry binds, so the
68
+ * failure names the family and the key rather than surfacing as a thin
69
+ * assumptions-document row nobody reads.
70
+ */
71
+ export function checkFact(family, key, fact) {
72
+ const where = `${family}: ${key}`;
73
+ if (!SOURCES.includes(fact.source)) {
74
+ throw new ScraperConfigError(where, `source ${JSON.stringify(fact.source)} is not one of ${SOURCES.join(', ')}`);
75
+ }
76
+ if (fact.source === 'vendor-stated') {
77
+ if (!fact.cite) {
78
+ throw new ScraperConfigError(where, 'a vendor-stated fact needs a `cite` — which column, breadcrumb, ' +
79
+ 'tagline or facet says so');
80
+ }
81
+ return;
82
+ }
83
+ if (!fact.note) {
84
+ throw new ScraperConfigError(where, `a ${fact.source} fact needs a \`note\` saying what was ${WANTED[fact.source]}`);
85
+ }
86
+ if (fact.source === 'assumed') {
87
+ // The whole weight of an assumption is a person on a day. Without both, an
88
+ // assumptions document lists a guess nobody can be asked about.
89
+ if (!fact.checked || !DATE.test(fact.checked)) {
90
+ throw new ScraperConfigError(where, `an assumed fact needs \`checked\` as YYYY-MM-DD, not ${JSON.stringify(fact.checked)}`);
91
+ }
92
+ if (!fact.by) {
93
+ throw new ScraperConfigError(where, 'an assumed fact needs `by`');
94
+ }
95
+ }
96
+ }
97
+ /**
98
+ * Every non-vendor-stated fact in the catalog, flattened and sorted.
99
+ *
100
+ * `vendor-stated` facts are excluded because the document's purpose is the
101
+ * list of things that would be wrong if somebody guessed wrong — a citation is
102
+ * a different kind of claim and has its own re-check path (one `curl`).
103
+ *
104
+ * Sorted by source then family then key — so the derived facts lead, the
105
+ * assumed ones close the list, and the output is byte-stable for whatever
106
+ * gate reads it.
107
+ */
108
+ export function assumptions(tables) {
109
+ const rows = [];
110
+ for (const [table, families] of Object.entries(tables)) {
111
+ for (const [family, cfg] of Object.entries(families)) {
112
+ // Unsorted: the sort below orders the whole list by source, family and
113
+ // key, which subsumes any order the facts arrive in.
114
+ for (const [key, fact] of Object.entries(cfg.facts ?? {})) {
115
+ if (fact.source === 'vendor-stated')
116
+ continue;
117
+ rows.push({
118
+ table,
119
+ family,
120
+ key,
121
+ value: fact.value,
122
+ source: fact.source,
123
+ note: fact.note,
124
+ checked: fact.source === 'assumed' ? fact.checked : null,
125
+ by: fact.source === 'assumed' ? fact.by : null,
126
+ });
127
+ }
128
+ }
129
+ }
130
+ return rows.sort((a, b) => SOURCES.indexOf(a.source) - SOURCES.indexOf(b.source) ||
131
+ compare(a.family, b.family) ||
132
+ compare(a.key, b.key));
133
+ }