@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.
- package/LICENSE +21 -0
- package/README.md +98 -0
- package/dist/conventions.d.ts +124 -0
- package/dist/conventions.js +143 -0
- package/dist/errors.d.ts +46 -0
- package/dist/errors.js +53 -0
- package/dist/families/destinytool.d.ts +49 -0
- package/dist/families/destinytool.js +55 -0
- package/dist/families/index.d.ts +59 -0
- package/dist/families/index.js +91 -0
- package/dist/families/kennametal.d.ts +757 -0
- package/dist/families/kennametal.js +660 -0
- package/dist/families/regofix.d.ts +185 -0
- package/dist/families/regofix.js +250 -0
- package/dist/family.d.ts +130 -0
- package/dist/family.js +38 -0
- package/dist/fetch.d.ts +98 -0
- package/dist/fetch.js +116 -0
- package/dist/identity.d.ts +133 -0
- package/dist/identity.js +118 -0
- package/dist/index.d.ts +31 -0
- package/dist/index.js +31 -0
- package/dist/node/cad-mirror.d.ts +55 -0
- package/dist/node/cad-mirror.js +89 -0
- package/dist/node/cli.d.ts +35 -0
- package/dist/node/cli.js +340 -0
- package/dist/node/csv.d.ts +47 -0
- package/dist/node/csv.js +123 -0
- package/dist/node/index.d.ts +16 -0
- package/dist/node/index.js +16 -0
- package/dist/node/main.d.ts +13 -0
- package/dist/node/main.js +14 -0
- package/dist/node/paths.d.ts +60 -0
- package/dist/node/paths.js +80 -0
- package/dist/node/receipts.d.ts +100 -0
- package/dist/node/receipts.js +107 -0
- package/dist/order.d.ts +10 -0
- package/dist/order.js +12 -0
- package/dist/provenance.d.ts +125 -0
- package/dist/provenance.js +133 -0
- package/dist/records.d.ts +305 -0
- package/dist/records.js +297 -0
- package/dist/registry.d.ts +63 -0
- package/dist/registry.js +145 -0
- package/dist/scrape.d.ts +70 -0
- package/dist/scrape.js +37 -0
- package/dist/thread.d.ts +48 -0
- package/dist/thread.js +98 -0
- package/dist/uuid5.d.ts +31 -0
- package/dist/uuid5.js +64 -0
- package/dist/vendors/destinytool/index.d.ts +11 -0
- package/dist/vendors/destinytool/index.js +11 -0
- package/dist/vendors/destinytool/records.d.ts +118 -0
- package/dist/vendors/destinytool/records.js +266 -0
- package/dist/vendors/destinytool/scrape.d.ts +108 -0
- package/dist/vendors/destinytool/scrape.js +192 -0
- package/dist/vendors/kennametal/cad.d.ts +87 -0
- package/dist/vendors/kennametal/cad.js +119 -0
- package/dist/vendors/kennametal/index.d.ts +21 -0
- package/dist/vendors/kennametal/index.js +21 -0
- package/dist/vendors/kennametal/materials.d.ts +143 -0
- package/dist/vendors/kennametal/materials.js +200 -0
- package/dist/vendors/kennametal/records.d.ts +88 -0
- package/dist/vendors/kennametal/records.js +241 -0
- package/dist/vendors/kennametal/scrape.d.ts +111 -0
- package/dist/vendors/kennametal/scrape.js +226 -0
- package/dist/vendors/kennametal/thread-column.d.ts +28 -0
- package/dist/vendors/kennametal/thread-column.js +41 -0
- package/dist/vendors/regofix/index.d.ts +8 -0
- package/dist/vendors/regofix/index.js +8 -0
- package/dist/vendors/regofix/scrape.d.ts +237 -0
- package/dist/vendors/regofix/scrape.js +521 -0
- 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
|
+
}
|
package/dist/order.d.ts
ADDED
|
@@ -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
|
+
}
|