@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,305 @@
1
+ /**
2
+ * The interchange contract: what an adapter hands the core, and the canonical
3
+ * names it hands it in.
4
+ *
5
+ * Before this seam existed, the mapper read `row['AP1MAX_mm']`, `row['D3_mm']`,
6
+ * `row['Re_mm']` and `row['Z']` — Kennametal's own attribute codes, in the
7
+ * module that also held the reduced-shank rule and the bare-tool length
8
+ * convention. A second vendor had two options and both were bad: relabel its
9
+ * columns into Kennametal's on the way into the CSV, which puts a lie in the
10
+ * file whose whole job is to record what the vendor published; or fork the
11
+ * mapper, which copies the domain into every adapter to drift independently.
12
+ *
13
+ * So the mapper stopped reading vendor column names. **An adapter owns CSV →
14
+ * record; a record is where this package hands off.** The CSV keeps the
15
+ * vendor's own column labels, because it is the receipt — see `conventions`.
16
+ *
17
+ * ## The canonical names are ISO 13399
18
+ *
19
+ * `DC`, `OAL`, `LCF`, `RE`, `NOF`, `SIG`, `TP` are codes from **ISO 13399**,
20
+ * *Cutting tool data representation and exchange* — the machine-tool
21
+ * industry's own interchange dictionary. CAM vendors implement subsets of it;
22
+ * Autodesk Fusion implements one, which is why these names also appear in
23
+ * Fusion's tool JSON. This package uses the standard directly, so nothing here
24
+ * depends on any CAM vendor's choices, and the places where a CAM vendor
25
+ * departed from the standard are recorded as departures rather than adopted
26
+ * silently: see {@link GEOMETRY_FIELDS}, where three of the ten names are
27
+ * Autodesk's own and say so.
28
+ *
29
+ * The standard is paid and split across parts, so the working reference is a
30
+ * manufacturer's published table — Sandvik Coromant's and Dormer Pramet's are
31
+ * both complete.
32
+ *
33
+ * **A canonical name says nothing about units.** An adapter declares
34
+ * `DC: 'D1'` and the core appends `_mm`/`_in` from the family's declared unit;
35
+ * that rule lives in `conventions.UNIT_SUFFIX`, in one place, because choosing
36
+ * the suffix inside an adapter is exactly the mistake `unit` exists to prevent.
37
+ *
38
+ * ## What is deliberately *not* here
39
+ *
40
+ * `LB` and `assemblyGaugeLength` are not canonical inputs — they are `OAL`
41
+ * under another name on a bare tool, ISO code or not, and a field that is
42
+ * always a copy is not a second measurement. An adapter that could supply them
43
+ * separately could supply a tool that claims a holder it does not have.
44
+ */
45
+ import { type UnitSystem } from './conventions.js';
46
+ /** The kinds of cutting tool this package maps. */
47
+ export type ToolKind = 'drill' | 'tap' | 'endmill';
48
+ /**
49
+ * ISO 513's main workpiece-material groups, in the order every vendor's
50
+ * material groups must agree on — **core, not a Kennametal fact**, the same
51
+ * distinction that puts thread-designation parsing in `thread.ts`: this is a
52
+ * standard, not a table. A vendor whose own column publishes groups in a
53
+ * different order — Destiny Tool's `isoMaterialGroups` does, e.g.
54
+ * `['M', 'P', 'S']` — must reorder onto this sequence rather than passing its
55
+ * raw order through, because a consumer that renders a facet from one array
56
+ * and a tool's own list from another has no way to notice the two disagree.
57
+ */
58
+ export declare const ISO_MATERIAL_GROUPS: readonly ["P", "M", "K", "N", "S", "H", "C"];
59
+ /** One canonical geometry name: what it measures, and whose name it is. */
60
+ export interface GeometryField {
61
+ /**
62
+ * What the field measures, phrased so it can be quoted back at whoever
63
+ * mapped a column to the wrong one.
64
+ */
65
+ definition: string;
66
+ /**
67
+ * The ISO 13399 code for this measurement, or `null` where the standard's
68
+ * counterpart has not been pinned against the dictionary. Equal to the
69
+ * canonical name itself on every field that *is* the standard's code.
70
+ */
71
+ iso: string | null;
72
+ }
73
+ /**
74
+ * Canonical geometry fields an adapter may supply, and what each means.
75
+ *
76
+ * Keyed by name so the load-time check can quote a definition back at whoever
77
+ * mapped a column to the wrong one, and each entry carries its ISO 13399 code
78
+ * so the vocabulary's source is readable from the code rather than from a plan
79
+ * document.
80
+ *
81
+ * **Seven of the ten are the standard's codes with the standard's meanings.**
82
+ * The three that are not are Autodesk's, and each has an ISO counterpart
83
+ * Autodesk did not use:
84
+ *
85
+ * - `SFDM` is Autodesk's "Shaft Diameter"; ISO's shank diameter is `DMM`.
86
+ * - `shoulder-length` and `shoulder-diameter` are Autodesk's hyphenated
87
+ * lowercase keys. ISO's nearest are `LS` and `DN`, and they are recorded as
88
+ * unpinned rather than mapped, because "nearest" is not "the same" and the
89
+ * standing rule here is to leave a code unlabelled rather than guess at what
90
+ * it measures.
91
+ *
92
+ * They are kept under Autodesk's names anyway: renaming them would buy
93
+ * correctness in a document nothing reads yet and cost the one property that
94
+ * makes a canonical name useful, which is that a downstream consumer
95
+ * recognises it.
96
+ */
97
+ export declare const GEOMETRY_FIELDS: {
98
+ readonly DC: {
99
+ readonly definition: "cutting diameter";
100
+ readonly iso: "DC";
101
+ };
102
+ readonly SFDM: {
103
+ readonly definition: "shank diameter";
104
+ readonly iso: "DMM";
105
+ };
106
+ readonly OAL: {
107
+ readonly definition: "overall length";
108
+ readonly iso: "OAL";
109
+ };
110
+ readonly LCF: {
111
+ readonly definition: "flute length — the length of the cutting edge";
112
+ readonly iso: "LCF";
113
+ };
114
+ readonly RE: {
115
+ readonly definition: "corner radius; 0 on a square-end tool";
116
+ readonly iso: "RE";
117
+ };
118
+ readonly TP: {
119
+ readonly definition: "thread pitch, in the tool’s own unit system";
120
+ readonly iso: "TP";
121
+ };
122
+ readonly NOF: {
123
+ readonly definition: "number of flutes";
124
+ readonly iso: "NOF";
125
+ };
126
+ readonly SIG: {
127
+ readonly definition: "point angle, degrees included";
128
+ readonly iso: "SIG";
129
+ };
130
+ readonly 'shoulder-length': {
131
+ readonly definition: "usable length below the full shank";
132
+ readonly iso: null;
133
+ };
134
+ readonly 'shoulder-diameter': {
135
+ readonly definition: "diameter at the shoulder — the neck, where necked";
136
+ readonly iso: null;
137
+ };
138
+ };
139
+ /**
140
+ * The canonical names an adapter may map, as a type.
141
+ *
142
+ * Derived from {@link GEOMETRY_FIELDS}, so a column map naming `LFC` is a
143
+ * compile error rather than a key that sits there doing nothing while the
144
+ * geometry it was meant to fill is silently absent from every tool.
145
+ */
146
+ export type GeometryName = keyof typeof GEOMETRY_FIELDS;
147
+ /**
148
+ * The canonical names that are **not** ISO 13399's own codes, derived rather
149
+ * than listed so the two cannot disagree.
150
+ *
151
+ * Asserted by the tests as exactly these three, which is what makes adding a
152
+ * fourth a deliberate act: a canonical name that is one CAM vendor's invention
153
+ * is a departure from the standard, and this package's claim to be using the
154
+ * standard is only as good as the departures being counted.
155
+ */
156
+ export declare const NON_ISO_NAMES: readonly GeometryName[];
157
+ /**
158
+ * What each tool kind must map before a single row is read.
159
+ *
160
+ * The point of stating it per kind rather than per family: core can refuse
161
+ * "endmill family X maps no LCF" at config load, naming the family, instead of
162
+ * failing on a missing key from inside a mapper on row 1 of a scrape that
163
+ * already ran. A field's *absence* from this list is a claim too — `RE` is
164
+ * optional because a square-end family publishes no corner-radius column and 0
165
+ * is the right answer, not a missing one.
166
+ */
167
+ export declare const REQUIRED_GEOMETRY: Record<ToolKind, readonly GeometryName[]>;
168
+ /**
169
+ * Fields that carry a unit and therefore get a `_mm`/`_in` suffix appended to
170
+ * the vendor's column label. The rest are counts, angles and flags, published
171
+ * in one column whatever the family's unit system.
172
+ *
173
+ * `TP` is here and it is the interesting one: a thread pitch is a length, and
174
+ * on an inch tap it is `1/TPI` **inches** while on a metric tap it is
175
+ * millimetres. It is nonetheless read from a single column, because the
176
+ * scraper derives it in the family's native unit already — so it is listed as
177
+ * dimensional for documentation and excluded from suffixing by
178
+ * {@link DIMENSIONAL_COLUMNS}.
179
+ */
180
+ export declare const DIMENSIONAL: ReadonlySet<GeometryName>;
181
+ /**
182
+ * The subset of {@link DIMENSIONAL} whose CSV column is a unit *pair*.
183
+ *
184
+ * `TP` is dimensional but not paired: the Kennametal thread-pitch step derives
185
+ * one `Thread Pitch` column already in the tap's native system, so appending a
186
+ * suffix would look for a column that was never scraped.
187
+ */
188
+ export declare const DIMENSIONAL_COLUMNS: ReadonlySet<GeometryName>;
189
+ /**
190
+ * One orderable cutting tool, in canonical fields, ready for the core.
191
+ *
192
+ * `readonly` throughout because it is an interchange value: an adapter builds
193
+ * it and hands it over, and a mapper that mutated one would be reaching back
194
+ * across the seam this type exists to draw.
195
+ *
196
+ * `geometry` holds {@link GEOMETRY_FIELDS} names in `unit`. An **empty**
197
+ * `materialGroups` is a real answer — Kennametal indexes no tap by workpiece
198
+ * material, so all 129 carry none, and reading empty as "unconstrained" would
199
+ * put every tap under every material on no evidence.
200
+ */
201
+ export interface ToolRecord {
202
+ readonly vendor: string;
203
+ readonly materialNumber: string;
204
+ readonly catalogNumber: string;
205
+ readonly description: string;
206
+ readonly kind: ToolKind;
207
+ readonly unit: UnitSystem;
208
+ readonly substrate: string;
209
+ readonly grade: string;
210
+ readonly geometry: Readonly<Partial<Record<GeometryName, number | boolean>>>;
211
+ readonly coolantThrough: boolean;
212
+ readonly materialGroups: readonly string[];
213
+ /**
214
+ * Drills only, and deliberately `null` elsewhere rather than `false`: it
215
+ * drops the two ferrous presets downstream, and a default would ship them on
216
+ * a PCD tool.
217
+ */
218
+ readonly nonFerrous: boolean | null;
219
+ }
220
+ /**
221
+ * Build a {@link ToolRecord}, defaulting the two fields that have a meaningful
222
+ * absence.
223
+ *
224
+ * An interface cannot carry a default, and requiring every adapter to write
225
+ * `materialGroups: []` would put the decision back in the three places least
226
+ * able to notice it was wrong. They stay **required on the type** so a
227
+ * consumer reading a record never handles `undefined` — only the construction
228
+ * is optional.
229
+ *
230
+ * The result is frozen, geometry and material groups included: a record is an
231
+ * interchange value, and a mapper that mutated one would be reaching back
232
+ * across the seam this type exists to draw.
233
+ */
234
+ export declare function toolRecord(fields: Omit<ToolRecord, 'materialGroups' | 'nonFerrous'> & Partial<Pick<ToolRecord, 'materialGroups' | 'nonFerrous'>>): ToolRecord;
235
+ /**
236
+ * A family's canonical-field → CSV-column-label mapping, validated.
237
+ *
238
+ * Built once when the registry binds. `labels` are the vendor's own column
239
+ * labels **without** a unit suffix — `'D1'`, not `'D1_mm'` — because which
240
+ * suffix to read is the core's business, derived from the family's unit
241
+ * system. A vendor that wrote `'D1_mm'` here would be hardcoding the answer to
242
+ * the question `unit` exists to ask.
243
+ *
244
+ * A class rather than a plain object so that {@link checkColumnMap} is the
245
+ * only way to get one: a caller cannot accidentally use an unvalidated map.
246
+ */
247
+ export declare class ColumnMap {
248
+ readonly kind: ToolKind;
249
+ readonly labels: Readonly<Partial<Record<GeometryName, string>>>;
250
+ /** @internal Use {@link checkColumnMap}. */
251
+ constructor(kind: ToolKind, labels: Readonly<Partial<Record<GeometryName, string>>>);
252
+ /** The CSV column to read for `canonical`, or null when unmapped. */
253
+ column(canonical: GeometryName, unit: UnitSystem): string | null;
254
+ /** Every canonical name this family maps, in declaration order. */
255
+ mapped(): GeometryName[];
256
+ }
257
+ /** A family config, as far as this module is concerned. */
258
+ export interface ColumnBearing {
259
+ unit?: UnitSystem;
260
+ columns: ColumnMap;
261
+ }
262
+ /**
263
+ * The unit systems a family's rows can be in.
264
+ *
265
+ * Usually one, declared. **A tap family declares none**, and that asymmetry is
266
+ * real rather than an omission: a tap's system comes from its own
267
+ * `Thread System` column, so one family can hold metric and inch taps and both
268
+ * column sets must exist. Anything checking a family's columns has to check
269
+ * both for those.
270
+ */
271
+ export declare function familyUnits(cfg: {
272
+ unit?: UnitSystem;
273
+ }): UnitSystem[];
274
+ /**
275
+ * Every mapped column is really in the CSV — before a single row is read.
276
+ *
277
+ * {@link checkColumnMap} sees only the map, so it cannot catch a family that
278
+ * maps `LCF: 'AP1MAX'` against a table publishing `AP1MAX_in` alone while
279
+ * tagged metric. That resolves to a column which is not there, and the per-row
280
+ * failure names one row out of ninety-three instead of naming the family and
281
+ * the field.
282
+ *
283
+ * It is separate from binding because binding deliberately does no I/O — an
284
+ * installed package with no scraped data must still import.
285
+ */
286
+ export declare function checkColumnsExist(family: string, cfg: ColumnBearing, header: Iterable<string>): void;
287
+ /**
288
+ * Validate a family's column map, or refuse it by name.
289
+ *
290
+ * Three failures, and each is one that used to surface far from its cause:
291
+ *
292
+ * 1. **An unknown canonical field.** A typo like `LFC` would otherwise sit in
293
+ * the map doing nothing, and the geometry it was meant to fill would
294
+ * silently be absent from every tool in the family. {@link GeometryName}
295
+ * catches this at compile time for a map written in TypeScript; the check
296
+ * stays for one that arrives as data.
297
+ * 2. **A missing required field.** A missing-key fault from inside a mapper
298
+ * names the *column*, which is the one piece of information the person who
299
+ * wrote the map already had.
300
+ * 3. **An unknown kind**, which would otherwise skip the required-field check
301
+ * entirely by looking up an empty set.
302
+ *
303
+ * Returns the validated map so a caller cannot accidentally use the raw object.
304
+ */
305
+ export declare function checkColumnMap(family: string, kind: string, labels: Readonly<Record<string, string>>): ColumnMap;
@@ -0,0 +1,297 @@
1
+ /**
2
+ * The interchange contract: what an adapter hands the core, and the canonical
3
+ * names it hands it in.
4
+ *
5
+ * Before this seam existed, the mapper read `row['AP1MAX_mm']`, `row['D3_mm']`,
6
+ * `row['Re_mm']` and `row['Z']` — Kennametal's own attribute codes, in the
7
+ * module that also held the reduced-shank rule and the bare-tool length
8
+ * convention. A second vendor had two options and both were bad: relabel its
9
+ * columns into Kennametal's on the way into the CSV, which puts a lie in the
10
+ * file whose whole job is to record what the vendor published; or fork the
11
+ * mapper, which copies the domain into every adapter to drift independently.
12
+ *
13
+ * So the mapper stopped reading vendor column names. **An adapter owns CSV →
14
+ * record; a record is where this package hands off.** The CSV keeps the
15
+ * vendor's own column labels, because it is the receipt — see `conventions`.
16
+ *
17
+ * ## The canonical names are ISO 13399
18
+ *
19
+ * `DC`, `OAL`, `LCF`, `RE`, `NOF`, `SIG`, `TP` are codes from **ISO 13399**,
20
+ * *Cutting tool data representation and exchange* — the machine-tool
21
+ * industry's own interchange dictionary. CAM vendors implement subsets of it;
22
+ * Autodesk Fusion implements one, which is why these names also appear in
23
+ * Fusion's tool JSON. This package uses the standard directly, so nothing here
24
+ * depends on any CAM vendor's choices, and the places where a CAM vendor
25
+ * departed from the standard are recorded as departures rather than adopted
26
+ * silently: see {@link GEOMETRY_FIELDS}, where three of the ten names are
27
+ * Autodesk's own and say so.
28
+ *
29
+ * The standard is paid and split across parts, so the working reference is a
30
+ * manufacturer's published table — Sandvik Coromant's and Dormer Pramet's are
31
+ * both complete.
32
+ *
33
+ * **A canonical name says nothing about units.** An adapter declares
34
+ * `DC: 'D1'` and the core appends `_mm`/`_in` from the family's declared unit;
35
+ * that rule lives in `conventions.UNIT_SUFFIX`, in one place, because choosing
36
+ * the suffix inside an adapter is exactly the mistake `unit` exists to prevent.
37
+ *
38
+ * ## What is deliberately *not* here
39
+ *
40
+ * `LB` and `assemblyGaugeLength` are not canonical inputs — they are `OAL`
41
+ * under another name on a bare tool, ISO code or not, and a field that is
42
+ * always a copy is not a second measurement. An adapter that could supply them
43
+ * separately could supply a tool that claims a holder it does not have.
44
+ */
45
+ import { dimensionalColumn } from './conventions.js';
46
+ import { ScraperConfigError } from './errors.js';
47
+ /**
48
+ * ISO 513's main workpiece-material groups, in the order every vendor's
49
+ * material groups must agree on — **core, not a Kennametal fact**, the same
50
+ * distinction that puts thread-designation parsing in `thread.ts`: this is a
51
+ * standard, not a table. A vendor whose own column publishes groups in a
52
+ * different order — Destiny Tool's `isoMaterialGroups` does, e.g.
53
+ * `['M', 'P', 'S']` — must reorder onto this sequence rather than passing its
54
+ * raw order through, because a consumer that renders a facet from one array
55
+ * and a tool's own list from another has no way to notice the two disagree.
56
+ */
57
+ export const ISO_MATERIAL_GROUPS = ['P', 'M', 'K', 'N', 'S', 'H', 'C'];
58
+ /**
59
+ * Canonical geometry fields an adapter may supply, and what each means.
60
+ *
61
+ * Keyed by name so the load-time check can quote a definition back at whoever
62
+ * mapped a column to the wrong one, and each entry carries its ISO 13399 code
63
+ * so the vocabulary's source is readable from the code rather than from a plan
64
+ * document.
65
+ *
66
+ * **Seven of the ten are the standard's codes with the standard's meanings.**
67
+ * The three that are not are Autodesk's, and each has an ISO counterpart
68
+ * Autodesk did not use:
69
+ *
70
+ * - `SFDM` is Autodesk's "Shaft Diameter"; ISO's shank diameter is `DMM`.
71
+ * - `shoulder-length` and `shoulder-diameter` are Autodesk's hyphenated
72
+ * lowercase keys. ISO's nearest are `LS` and `DN`, and they are recorded as
73
+ * unpinned rather than mapped, because "nearest" is not "the same" and the
74
+ * standing rule here is to leave a code unlabelled rather than guess at what
75
+ * it measures.
76
+ *
77
+ * They are kept under Autodesk's names anyway: renaming them would buy
78
+ * correctness in a document nothing reads yet and cost the one property that
79
+ * makes a canonical name useful, which is that a downstream consumer
80
+ * recognises it.
81
+ */
82
+ export const GEOMETRY_FIELDS = {
83
+ DC: { definition: 'cutting diameter', iso: 'DC' },
84
+ SFDM: { definition: 'shank diameter', iso: 'DMM' },
85
+ OAL: { definition: 'overall length', iso: 'OAL' },
86
+ LCF: {
87
+ definition: 'flute length — the length of the cutting edge',
88
+ iso: 'LCF',
89
+ },
90
+ RE: { definition: 'corner radius; 0 on a square-end tool', iso: 'RE' },
91
+ TP: { definition: 'thread pitch, in the tool’s own unit system', iso: 'TP' },
92
+ NOF: { definition: 'number of flutes', iso: 'NOF' },
93
+ SIG: { definition: 'point angle, degrees included', iso: 'SIG' },
94
+ 'shoulder-length': {
95
+ definition: 'usable length below the full shank',
96
+ iso: null,
97
+ },
98
+ 'shoulder-diameter': {
99
+ definition: 'diameter at the shoulder — the neck, where necked',
100
+ iso: null,
101
+ },
102
+ };
103
+ /**
104
+ * The canonical names that are **not** ISO 13399's own codes, derived rather
105
+ * than listed so the two cannot disagree.
106
+ *
107
+ * Asserted by the tests as exactly these three, which is what makes adding a
108
+ * fourth a deliberate act: a canonical name that is one CAM vendor's invention
109
+ * is a departure from the standard, and this package's claim to be using the
110
+ * standard is only as good as the departures being counted.
111
+ */
112
+ export const NON_ISO_NAMES = Object.entries(GEOMETRY_FIELDS)
113
+ .filter(([name, field]) => field.iso !== name)
114
+ .map(([name]) => name);
115
+ /**
116
+ * What each tool kind must map before a single row is read.
117
+ *
118
+ * The point of stating it per kind rather than per family: core can refuse
119
+ * "endmill family X maps no LCF" at config load, naming the family, instead of
120
+ * failing on a missing key from inside a mapper on row 1 of a scrape that
121
+ * already ran. A field's *absence* from this list is a claim too — `RE` is
122
+ * optional because a square-end family publishes no corner-radius column and 0
123
+ * is the right answer, not a missing one.
124
+ */
125
+ export const REQUIRED_GEOMETRY = {
126
+ drill: ['DC', 'SFDM', 'OAL', 'LCF'],
127
+ tap: ['SFDM', 'OAL', 'LCF'],
128
+ endmill: ['DC', 'SFDM', 'OAL', 'LCF'],
129
+ };
130
+ /**
131
+ * Fields that carry a unit and therefore get a `_mm`/`_in` suffix appended to
132
+ * the vendor's column label. The rest are counts, angles and flags, published
133
+ * in one column whatever the family's unit system.
134
+ *
135
+ * `TP` is here and it is the interesting one: a thread pitch is a length, and
136
+ * on an inch tap it is `1/TPI` **inches** while on a metric tap it is
137
+ * millimetres. It is nonetheless read from a single column, because the
138
+ * scraper derives it in the family's native unit already — so it is listed as
139
+ * dimensional for documentation and excluded from suffixing by
140
+ * {@link DIMENSIONAL_COLUMNS}.
141
+ */
142
+ export const DIMENSIONAL = new Set([
143
+ 'DC',
144
+ 'SFDM',
145
+ 'OAL',
146
+ 'LCF',
147
+ 'RE',
148
+ 'TP',
149
+ 'shoulder-length',
150
+ 'shoulder-diameter',
151
+ ]);
152
+ /**
153
+ * The subset of {@link DIMENSIONAL} whose CSV column is a unit *pair*.
154
+ *
155
+ * `TP` is dimensional but not paired: the Kennametal thread-pitch step derives
156
+ * one `Thread Pitch` column already in the tap's native system, so appending a
157
+ * suffix would look for a column that was never scraped.
158
+ */
159
+ export const DIMENSIONAL_COLUMNS = new Set([...DIMENSIONAL].filter((name) => name !== 'TP'));
160
+ /**
161
+ * Build a {@link ToolRecord}, defaulting the two fields that have a meaningful
162
+ * absence.
163
+ *
164
+ * An interface cannot carry a default, and requiring every adapter to write
165
+ * `materialGroups: []` would put the decision back in the three places least
166
+ * able to notice it was wrong. They stay **required on the type** so a
167
+ * consumer reading a record never handles `undefined` — only the construction
168
+ * is optional.
169
+ *
170
+ * The result is frozen, geometry and material groups included: a record is an
171
+ * interchange value, and a mapper that mutated one would be reaching back
172
+ * across the seam this type exists to draw.
173
+ */
174
+ export function toolRecord(fields) {
175
+ return Object.freeze({
176
+ ...fields,
177
+ geometry: Object.freeze({ ...fields.geometry }),
178
+ materialGroups: Object.freeze([...(fields.materialGroups ?? [])]),
179
+ nonFerrous: fields.nonFerrous ?? null,
180
+ });
181
+ }
182
+ /**
183
+ * A family's canonical-field → CSV-column-label mapping, validated.
184
+ *
185
+ * Built once when the registry binds. `labels` are the vendor's own column
186
+ * labels **without** a unit suffix — `'D1'`, not `'D1_mm'` — because which
187
+ * suffix to read is the core's business, derived from the family's unit
188
+ * system. A vendor that wrote `'D1_mm'` here would be hardcoding the answer to
189
+ * the question `unit` exists to ask.
190
+ *
191
+ * A class rather than a plain object so that {@link checkColumnMap} is the
192
+ * only way to get one: a caller cannot accidentally use an unvalidated map.
193
+ */
194
+ export class ColumnMap {
195
+ kind;
196
+ labels;
197
+ /** @internal Use {@link checkColumnMap}. */
198
+ constructor(kind, labels) {
199
+ this.kind = kind;
200
+ this.labels = { ...labels };
201
+ }
202
+ /** The CSV column to read for `canonical`, or null when unmapped. */
203
+ column(canonical, unit) {
204
+ const label = this.labels[canonical];
205
+ if (label === undefined)
206
+ return null;
207
+ if (!DIMENSIONAL_COLUMNS.has(canonical))
208
+ return label;
209
+ return dimensionalColumn(label, unit);
210
+ }
211
+ /** Every canonical name this family maps, in declaration order. */
212
+ mapped() {
213
+ return Object.keys(this.labels);
214
+ }
215
+ }
216
+ /**
217
+ * The unit systems a family's rows can be in.
218
+ *
219
+ * Usually one, declared. **A tap family declares none**, and that asymmetry is
220
+ * real rather than an omission: a tap's system comes from its own
221
+ * `Thread System` column, so one family can hold metric and inch taps and both
222
+ * column sets must exist. Anything checking a family's columns has to check
223
+ * both for those.
224
+ */
225
+ export function familyUnits(cfg) {
226
+ return cfg.unit === undefined ? ['millimeters', 'inches'] : [cfg.unit];
227
+ }
228
+ /**
229
+ * Every mapped column is really in the CSV — before a single row is read.
230
+ *
231
+ * {@link checkColumnMap} sees only the map, so it cannot catch a family that
232
+ * maps `LCF: 'AP1MAX'` against a table publishing `AP1MAX_in` alone while
233
+ * tagged metric. That resolves to a column which is not there, and the per-row
234
+ * failure names one row out of ninety-three instead of naming the family and
235
+ * the field.
236
+ *
237
+ * It is separate from binding because binding deliberately does no I/O — an
238
+ * installed package with no scraped data must still import.
239
+ */
240
+ export function checkColumnsExist(family, cfg, header) {
241
+ const present = new Set(header);
242
+ const missing = [];
243
+ for (const unit of familyUnits(cfg)) {
244
+ for (const canonical of cfg.columns.mapped()) {
245
+ // Never null: `mapped()` yields only the names that carry a label, which
246
+ // is the one case `column()` has nothing to return.
247
+ const column = cfg.columns.column(canonical, unit);
248
+ if (!present.has(column)) {
249
+ missing.push(`${canonical} -> ${column}`);
250
+ }
251
+ }
252
+ }
253
+ if (missing.length > 0) {
254
+ throw new ScraperConfigError(family, `mapped column(s) absent from the CSV: ${[...new Set(missing)].sort().join(', ')}`);
255
+ }
256
+ }
257
+ /**
258
+ * Validate a family's column map, or refuse it by name.
259
+ *
260
+ * Three failures, and each is one that used to surface far from its cause:
261
+ *
262
+ * 1. **An unknown canonical field.** A typo like `LFC` would otherwise sit in
263
+ * the map doing nothing, and the geometry it was meant to fill would
264
+ * silently be absent from every tool in the family. {@link GeometryName}
265
+ * catches this at compile time for a map written in TypeScript; the check
266
+ * stays for one that arrives as data.
267
+ * 2. **A missing required field.** A missing-key fault from inside a mapper
268
+ * names the *column*, which is the one piece of information the person who
269
+ * wrote the map already had.
270
+ * 3. **An unknown kind**, which would otherwise skip the required-field check
271
+ * entirely by looking up an empty set.
272
+ *
273
+ * Returns the validated map so a caller cannot accidentally use the raw object.
274
+ */
275
+ export function checkColumnMap(family, kind, labels) {
276
+ if (!Object.hasOwn(REQUIRED_GEOMETRY, kind)) {
277
+ throw new ScraperConfigError(family, `unknown tool kind ${JSON.stringify(kind)} ` +
278
+ `(known: ${Object.keys(REQUIRED_GEOMETRY).sort().join(', ')})`);
279
+ }
280
+ const unknown = Object.keys(labels)
281
+ .filter((name) => !Object.hasOwn(GEOMETRY_FIELDS, name))
282
+ .sort();
283
+ if (unknown.length > 0) {
284
+ throw new ScraperConfigError(family, `maps ${unknown.join(', ')} which are not canonical geometry fields ` +
285
+ `(known: ${Object.keys(GEOMETRY_FIELDS).sort().join(', ')})`);
286
+ }
287
+ const missing = REQUIRED_GEOMETRY[kind]
288
+ .filter((name) => !Object.hasOwn(labels, name))
289
+ .sort();
290
+ if (missing.length > 0) {
291
+ const described = missing
292
+ .map((name) => `${name} (${GEOMETRY_FIELDS[name].definition})`)
293
+ .join(', ');
294
+ throw new ScraperConfigError(family, `a ${kind} family must map ${described}`);
295
+ }
296
+ return new ColumnMap(kind, labels);
297
+ }
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Which adapter serves which family — the composition root.
3
+ *
4
+ * `families/` is the config table and `vendors/` are the adapters; this is the
5
+ * one module that knows both, and it exists so that neither has to. Putting
6
+ * the binding in the config table made the table import a manufacturer, which
7
+ * `tests/vendor-boundary.test.ts` refuses for good reason: the table is read
8
+ * by every test, and none of those should drag a vendor's scraper in behind it.
9
+ *
10
+ * **This is the seam a per-vendor distribution would replace with an
11
+ * entry-point registry.** When a vendor package can be installed from outside
12
+ * the tree, the {@link ADAPTERS} table below becomes a lookup over those and
13
+ * nothing else here changes shape. Until then one repository holds every
14
+ * adapter, which is what lets one commit change the record contract and every
15
+ * adapter together.
16
+ *
17
+ * ## Binding is lazy, not an import side effect
18
+ *
19
+ * A mapping fault has to surface as an error naming the family, not as a
20
+ * missing-key fault from inside a mapper on row 1 of a scrape that already
21
+ * ran. Binding at import would buy that and charge for it: a consumer that
22
+ * imports one helper should not pay to validate a catalog it never reads.
23
+ *
24
+ * So it is memoised instead: the first call to {@link boundFamilies} validates
25
+ * every family and every fact, and the failure still names the family and the
26
+ * key. What changes is only *when* — first use rather than import — and every
27
+ * entry point into this package goes through here.
28
+ */
29
+ import type { BoundFamily, BoundToolholding, RecordMappers } from './family.js';
30
+ /**
31
+ * Brand -> its row-to-record mappers, by tool kind.
32
+ *
33
+ * One entry serves two brands: Kennametal and WIDIA are the same AEM platform
34
+ * and the same table vocabulary, so one adapter covers both, exactly as one
35
+ * scraper does. A brand absent from here can still be scraped — REGO-FIX ships
36
+ * toolholding and no cutting tools, and only cutting tools go through a column
37
+ * map.
38
+ */
39
+ export declare const ADAPTERS: Record<string, RecordMappers>;
40
+ /**
41
+ * Every cutting-tool family, validated and bound to its record mapper.
42
+ *
43
+ * Memoised, so calling it twice is free and a fault is reported once.
44
+ */
45
+ export declare function boundFamilies(): Map<string, BoundFamily>;
46
+ /**
47
+ * Every holder and collet family, with its facts checked and projected.
48
+ *
49
+ * They bind no adapter — only cutting tools go through a column map — but
50
+ * their facts pass the same gate: a taper or a clamping mode is a per-family
51
+ * constant no variant table states, exactly like a drill's flute count.
52
+ */
53
+ export declare function boundToolholding(): Map<string, BoundToolholding>;
54
+ /** One bound cutting-tool family by CSV name. */
55
+ export declare function boundFamily(name: string): BoundFamily;
56
+ /**
57
+ * Forget what has been bound.
58
+ *
59
+ * For tests that put a family in front of the gate. Nothing in a running
60
+ * scrape should need it — the tables are module constants, so a second bind
61
+ * would produce the same answer.
62
+ */
63
+ export declare function resetBindings(): void;