@toolpath/tool-scraper 2.3.0 → 2.5.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.
@@ -73,6 +73,40 @@ const NO_COOLANT_THROUGH_TAP = {
73
73
  checked: '2026-08-29',
74
74
  by: 'JG',
75
75
  };
76
+ /**
77
+ * The tap-type facet, and what it settles.
78
+ *
79
+ * **The variant table does not carry it.** All three tap families publish
80
+ * `D1-TDZ`, `Thread Tolerance Class ANSI`, `Tap Pitch Diameter Limit` and
81
+ * `Type of Thread` — the thread's *class*, never how it is produced. What does
82
+ * carry it is the vendor's own `newTapType` Solr facet, and it narrows the same
83
+ * `.variants.<code>.html` endpoint `vendors/kennametal/scrape.ts` already
84
+ * calls: `scrape.ACTIVE_ONLY` is one facet on that query and this is a second,
85
+ * exactly as `vendors/kennametal/materials.ts` appends one.
86
+ *
87
+ * Its vocabulary, read off the threading category listing on 2026-09-07, is
88
+ * `2-Hand Tap`, `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap`,
89
+ * `11-Spiral Point Tap` and `13-Straight Flute Tap`. Exactly one of the six is
90
+ * a forming tap; the other five cut. So a family that answers any value but
91
+ * `3-Forming Tap` is a cutting family, and each of the three below was probed
92
+ * for its own value **and** for `3-Forming Tap`, which returned the vendor's
93
+ * no-results notice every time.
94
+ *
95
+ * That is why these are `vendor-stated` and not `assumed`: the cite is a query
96
+ * anybody can re-run, and the *negative* half of it is the part that matters.
97
+ * A `cite` naming only the CSV's own filename would be this table reading its
98
+ * own name back to itself.
99
+ */
100
+ const SPIRAL_POINT_TAP = {
101
+ value: 'cutting',
102
+ source: 'vendor-stated',
103
+ cite: "the vendor's own `newTapType` facet on the variants endpoint: every row of this family answers `:relevance:obsoleteFacet:false:newTapType:11-Spiral Point Tap`, and `3-Forming Tap` — the one forming value of the six — returns the no-results notice (JG 2026-09-07)",
104
+ };
105
+ const HAND_TAP = {
106
+ value: 'cutting',
107
+ source: 'vendor-stated',
108
+ cite: 'the same `newTapType` facet: every row of this family answers `2-Hand Tap`, and `3-Forming Tap`, `8-Spiral Flute Tap`, `10-Pipe Tap` and `11-Spiral Point Tap` each return the no-results notice (JG 2026-09-07)',
109
+ };
76
110
  const HSS_ASSUMED = {
77
111
  value: 'hss',
78
112
  source: 'assumed',
@@ -105,6 +139,51 @@ const ER_COLLET_CHUCK = {
105
139
  source: 'vendor-stated',
106
140
  cite: 'breadcrumb ".../ER Collet Chucks/ER(tm) Collet Adapter -BT30"',
107
141
  };
142
+ /**
143
+ * The three ER collet styles, each stated once for the families that share it.
144
+ *
145
+ * `style` is the finer axis beside a holder's `clamping`, and here it is what
146
+ * tells a consumer which kind of collet it is holding. All three are
147
+ * Kennametal's own product lines, read off the collet category tree — see
148
+ * `vendors/kennametal/catalog.ts`, which is what enumerates them.
149
+ */
150
+ const ER_STANDARD = {
151
+ value: 'er-standard',
152
+ source: 'vendor-stated',
153
+ cite: "the vendor's category is 'ER Collets / ER Standard Collets'; the family publishes a CCCN-CCCX capacity band and no square",
154
+ };
155
+ /**
156
+ * Sealed coolant-through collets. `style` separates them from the standard ones
157
+ * because they behave differently in a way the numbers show but a label should
158
+ * not hide: `CCCX == CCCN` on every row, so each clamps one exact size rather
159
+ * than a 1 mm band.
160
+ *
161
+ * No special case is needed anywhere — a zero-width range is still a range —
162
+ * but a user choosing one deserves to be told which kind it is.
163
+ *
164
+ * **The ER40 inch line is designated larger than it clamps**, and that is the
165
+ * vendor's own statement rather than a rounding: `40ERSS1000` is named for one
166
+ * inch and publishes a capacity of 0.9938 in / 25.243 mm against a `D1` of
167
+ * 1.0 in / 25.4 mm — both figures agree across both unit columns. Ten of its
168
+ * twelve rows do this, up to 0.193 mm on `40ERSS0812`. `holding.NOMINAL_SLACK`
169
+ * is the gate that lets a designation sit outside the size it measures, and
170
+ * those rows are where its bound comes from.
171
+ */
172
+ const ER_SEALED = {
173
+ value: 'er-sealed',
174
+ source: 'vendor-stated',
175
+ cite: "the vendor's category is 'ER Collets / ER Coolant Through Collets'; CCCX == CCCN == D1 on all 139 rows of the twelve families, and Kennametal specs an H6 shank, so each clamps one exact size",
176
+ };
177
+ /**
178
+ * Tap collets. The square drive is the fact that matters and it is scraped
179
+ * rather than declared — `holding.ColletRecord.squareSize` — so this is the
180
+ * label and not the discriminant.
181
+ */
182
+ const ER_TAP = {
183
+ value: 'er-tap',
184
+ source: 'vendor-stated',
185
+ cite: "the vendor's category is 'ER Collets / ER Tap Collets'; every row publishes an S10 square size and no capacity band",
186
+ };
108
187
  const METRIC_CATALOG = {
109
188
  value: 'millimeters',
110
189
  source: 'vendor-stated',
@@ -307,6 +386,7 @@ export const FAMILIES = {
307
386
  facts: {
308
387
  bmc: HSS_ASSUMED,
309
388
  coolantThrough: NO_COOLANT_THROUGH_TAP,
389
+ threadMethod: SPIRAL_POINT_TAP,
310
390
  },
311
391
  },
312
392
  'khsst_hand_metric_plug.csv': {
@@ -318,6 +398,7 @@ export const FAMILIES = {
318
398
  facts: {
319
399
  bmc: HSS_ASSUMED,
320
400
  coolantThrough: NO_COOLANT_THROUGH_TAP,
401
+ threadMethod: HAND_TAP,
321
402
  },
322
403
  },
323
404
  'spiral_point_metric_plug.csv': {
@@ -329,6 +410,7 @@ export const FAMILIES = {
329
410
  facts: {
330
411
  bmc: HSS_ASSUMED,
331
412
  coolantThrough: NO_COOLANT_THROUGH_TAP,
413
+ threadMethod: SPIRAL_POINT_TAP,
332
414
  },
333
415
  },
334
416
  'gomill_pro_radiused_4fl_necked_metric.csv': {
@@ -636,35 +718,152 @@ export const HOLDER_FAMILIES = {
636
718
  },
637
719
  },
638
720
  };
721
+ /**
722
+ * ER collets, one entry per vendor family.
723
+ *
724
+ * **One entry per vendor family and not one per style.** Kennametal codes the
725
+ * coolant-through line as twelve families — one per series per unit — and their
726
+ * column shapes genuinely differ: the inch half publishes `LF` and `L`, the
727
+ * metric half publishes `L9` instead, and only `109321468` publishes both.
728
+ * Merging them into two CSVs would also destroy `ToolholdingDefinition.rows`,
729
+ * which is a per-family restatement and the only independent check that a
730
+ * re-scrape did not lose parts.
731
+ *
732
+ * Every `familyCode` here came out of `kennametal --collets`, which is what
733
+ * `vendors/kennametal/catalog.ts` exists for. The row counts are that walk's
734
+ * own per-family totals, and each category's parts sum to them exactly —
735
+ * 117 + 107 = 224 standard, 75 + 74 = 149 coolant-through, 47 + 49 = 96 tap.
736
+ *
737
+ * ## The four kit families are deliberately absent
738
+ *
739
+ * `100000428`, `100000425`, `109433662` and `109433658` — 26 parts — publish
740
+ * `Kit Series`, `Number-Kit Items`, `Dimension Range-Kit Items` and
741
+ * `Incremental Division-Kit Items`, and no `D1`, no capacity and no length. A
742
+ * kit is a purchasing unit whose contents are already the per-part families
743
+ * below, and minting one into a `holding.ColletRecord` would mean deriving a
744
+ * capacity band from a range string — authoring tool data, which this package
745
+ * does not do. `kennametal --collets` lists them every run, so they stay
746
+ * visible rather than forgotten.
747
+ *
748
+ * ## ER8 fits nothing in this catalog
749
+ *
750
+ * Nine of the 110 standard metric collets are ER8, and no BT30 ER8 adapter is
751
+ * configured. They are scraped anyway: `holding.HolderRecord.colletSeries`
752
+ * states the direction — a collet nothing takes costs an option, and a collet
753
+ * offered for a holder it does not fit costs a machinist a purchase.
754
+ */
639
755
  export const COLLET_FAMILIES = {
756
+ // ── ER standard collets (JG 2026-09-08) ──────────────────────────────────
640
757
  'er_standard_collets_metric.csv': {
641
758
  catalogName: 'Kennametal ER Standard Collets Metric',
642
759
  rows: 110,
643
- facts: {
644
- style: {
645
- value: 'er-standard',
646
- source: 'vendor-stated',
647
- cite: "the family is Kennametal's plain ER collet line",
648
- },
649
- unit: METRIC_CATALOG,
650
- },
760
+ familyCode: '100000478',
761
+ facts: { style: ER_STANDARD, unit: METRIC_CATALOG },
762
+ },
763
+ 'er_standard_collets_inch.csv': {
764
+ catalogName: 'Kennametal ER Standard Collets Inch',
765
+ rows: 98,
766
+ familyCode: '100000479',
767
+ facts: { style: ER_STANDARD, unit: INCH_CATALOG },
768
+ },
769
+ // ── ER coolant-through collets (JG 2026-09-08) ───────────────────────────
770
+ // Twelve families, one per series per unit. `ER8` has no coolant-through
771
+ // line; `ER11` upward do.
772
+ 'er11_collets_coolant_through_metric.csv': {
773
+ catalogName: 'Kennametal ER11 Collets Coolant-Through Metric',
774
+ rows: 5,
775
+ familyCode: '109333979',
776
+ facts: { style: ER_SEALED, unit: METRIC_CATALOG },
777
+ },
778
+ 'er16_collets_coolant_through_metric.csv': {
779
+ catalogName: 'Kennametal ER16 Collets Coolant-Through Metric',
780
+ rows: 8,
781
+ familyCode: '109333976',
782
+ facts: { style: ER_SEALED, unit: METRIC_CATALOG },
783
+ },
784
+ 'er20_collets_coolant_through_metric.csv': {
785
+ catalogName: 'Kennametal ER20 Collets Coolant-Through Metric',
786
+ rows: 11,
787
+ familyCode: '109333973',
788
+ facts: { style: ER_SEALED, unit: METRIC_CATALOG },
789
+ },
790
+ 'er25_collets_coolant_through_metric.csv': {
791
+ catalogName: 'Kennametal ER25 Collets Coolant-Through Metric',
792
+ rows: 11,
793
+ familyCode: '109333627',
794
+ facts: { style: ER_SEALED, unit: METRIC_CATALOG },
795
+ },
796
+ 'er32_collets_coolant_through_metric.csv': {
797
+ catalogName: 'Kennametal ER32 Collets Coolant-Through Metric',
798
+ rows: 15,
799
+ familyCode: '109333626',
800
+ facts: { style: ER_SEALED, unit: METRIC_CATALOG },
801
+ },
802
+ 'er40_collets_coolant_through_metric.csv': {
803
+ catalogName: 'Kennametal ER40 Collets Coolant-Through Metric',
804
+ rows: 19,
805
+ familyCode: '109321468',
806
+ facts: { style: ER_SEALED, unit: METRIC_CATALOG },
807
+ },
808
+ 'er11_collets_coolant_through_inch.csv': {
809
+ catalogName: 'Kennametal ER11 Collets Coolant-Through Inch',
810
+ rows: 4,
811
+ familyCode: '109333978',
812
+ facts: { style: ER_SEALED, unit: INCH_CATALOG },
651
813
  },
652
- // Sealed coolant-through collets. `style` separates them from the standard
653
- // ones because they behave differently in a way the numbers alone show but
654
- // a label should not hide: CCCX == CCCN on every row, so each clamps one
655
- // exact size (Kennametal specs H6 shank tolerance) rather than a 1 mm band.
656
- // No special case is needed anywhere — a zero-width range is still a range
657
- // — but a user choosing one deserves to be told which kind it is.
658
814
  'er16_collets_coolant_through_inch.csv': {
659
815
  catalogName: 'Kennametal ER16 Collets Coolant-Through Inch',
660
816
  rows: 10,
661
- facts: {
662
- style: {
663
- value: 'er-sealed',
664
- source: 'vendor-stated',
665
- cite: 'CCCX == CCCN == D1 on every row; Kennametal specs an H6 shank, so it clamps one exact size',
666
- },
667
- unit: INCH_CATALOG,
668
- },
817
+ familyCode: '109333975',
818
+ facts: { style: ER_SEALED, unit: INCH_CATALOG },
819
+ },
820
+ 'er20_collets_coolant_through_inch.csv': {
821
+ catalogName: 'Kennametal ER20 Collets Coolant-Through Inch',
822
+ rows: 13,
823
+ familyCode: '109333974',
824
+ facts: { style: ER_SEALED, unit: INCH_CATALOG },
825
+ },
826
+ 'er25_collets_coolant_through_inch.csv': {
827
+ catalogName: 'Kennametal ER25 Collets Coolant-Through Inch',
828
+ rows: 14,
829
+ familyCode: '109333628',
830
+ facts: { style: ER_SEALED, unit: INCH_CATALOG },
831
+ },
832
+ 'er32_collets_coolant_through_inch.csv': {
833
+ catalogName: 'Kennametal ER32 Collets Coolant-Through Inch',
834
+ rows: 17,
835
+ familyCode: '109333625',
836
+ facts: { style: ER_SEALED, unit: INCH_CATALOG },
837
+ },
838
+ 'er40_collets_coolant_through_inch.csv': {
839
+ catalogName: 'Kennametal ER40 Collets Coolant-Through Inch',
840
+ rows: 12,
841
+ familyCode: '109321469',
842
+ facts: { style: ER_SEALED, unit: INCH_CATALOG },
843
+ },
844
+ // ── ER tap collets (JG 2026-09-08) ───────────────────────────────────────
845
+ // Two families, split by thread system rather than by series: each holds
846
+ // ER16 through ER40. Neither publishes `CCCN`/`CCCX` at all — see
847
+ // `vendors/kennametal/holding.ts`'s `colletCapacity` for why that is a
848
+ // zero-width band at `D1` rather than an incomplete row.
849
+ //
850
+ // **`unit` is decided by which column carries the exact value**, the same
851
+ // test the TT HPV split above uses. `100000434` publishes `16ERTC10`'s `D1`
852
+ // as 0.194 in and 4.928 mm — 0.194 in is the ANSI shank of a #10 tap, and
853
+ // 4.928 is it rounded — while `100000435` publishes `16ERTC045034M`'s as
854
+ // 4.5 mm and 0.1772 in. So the ANSI family is inch-native and the DIN/ISO
855
+ // one metric-native, which also decides which of the two `Tap Range` columns
856
+ // a record carries: `#14 & 1/4` on one, `M6 & M6.3` on the other.
857
+ 'er_tap_collets_ansi.csv': {
858
+ catalogName: 'Kennametal ER Standard Tap Collets ANSI',
859
+ rows: 47,
860
+ familyCode: '100000434',
861
+ facts: { style: ER_TAP, unit: INCH_CATALOG },
862
+ },
863
+ 'er_tap_collets_din_iso.csv': {
864
+ catalogName: 'Kennametal ER Standard Tap Collets DIN and ISO',
865
+ rows: 49,
866
+ familyCode: '100000435',
867
+ facts: { style: ER_TAP, unit: METRIC_CATALOG },
669
868
  },
670
869
  };
@@ -46,6 +46,37 @@
46
46
  *
47
47
  * ## What is not scraped
48
48
  *
49
+ * **The taps are not scraped, and the evidence for when they are is here.**
50
+ * MariTool sells taps under `c78_148`, and its tree already answers the one
51
+ * question a tap record needs that no vendor of ours states in a column —
52
+ * whether the tap cuts its thread or forms it. Only the forming branch carries
53
+ * the word, so the leaf name settles it (JG 2026-09-07):
54
+ *
55
+ * | Leaf | cPath | Method |
56
+ * | ----------------------- | ------------------ | --------- |
57
+ * | Thread Forming Taps | `78_148_274` | `forming` |
58
+ * | — Plug Form Tap | `78_148_274_275` | `forming` |
59
+ * | — Bottoming Form Tap | `78_148_274_276` | `forming` |
60
+ * | Spiral Flute Taps | `78_148_149` | `cutting` |
61
+ * | Spiral Point Taps | `78_148_224` | `cutting` |
62
+ * | Taps for Aluminum | `78_148_271` | `cutting` |
63
+ * | Taper Pipe Taps | `78_148_283` | `cutting` |
64
+ * | DIN Length HPT Taps | `78_148_284` | `cutting` |
65
+ *
66
+ * **Checked into a table when it is built, not run as a filter**, for the
67
+ * reason the ER leaves above already give: a rule matching a name drops
68
+ * silently when the vendor renames a category, and three leaves here are
69
+ * already named something other than what they hold. `maritool --catalog` walks
70
+ * the tree, which is how the rule gets re-checked rather than trusted.
71
+ *
72
+ * **What still has to be decided before a tap family lands.** A MariTool tap
73
+ * page publishes `Shank Size`, `Total Length`, `Thread Length` and `Size` —
74
+ * enough for `SFDM`, `OAL`, `LCF` and a designation `thread.ts` can already
75
+ * parse — but a metric tap states `Size: M3` and `Pitch Diameter: 0.5` against
76
+ * a body in inches (`Shank Size: 0.141`, `Total Length: 1 15/16`). No family in
77
+ * this package is mixed that way and `ToolRecord.unit` is one value, so that is
78
+ * a decision on its own merits and not a widening of this table.
79
+ *
49
80
  * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill
50
81
  * holders, which is out of scope; ISO30 has three ER parts and none of them
51
82
  * publishes a spec table, so the family would be a receipt of nothing.
@@ -46,6 +46,37 @@
46
46
  *
47
47
  * ## What is not scraped
48
48
  *
49
+ * **The taps are not scraped, and the evidence for when they are is here.**
50
+ * MariTool sells taps under `c78_148`, and its tree already answers the one
51
+ * question a tap record needs that no vendor of ours states in a column —
52
+ * whether the tap cuts its thread or forms it. Only the forming branch carries
53
+ * the word, so the leaf name settles it (JG 2026-09-07):
54
+ *
55
+ * | Leaf | cPath | Method |
56
+ * | ----------------------- | ------------------ | --------- |
57
+ * | Thread Forming Taps | `78_148_274` | `forming` |
58
+ * | — Plug Form Tap | `78_148_274_275` | `forming` |
59
+ * | — Bottoming Form Tap | `78_148_274_276` | `forming` |
60
+ * | Spiral Flute Taps | `78_148_149` | `cutting` |
61
+ * | Spiral Point Taps | `78_148_224` | `cutting` |
62
+ * | Taps for Aluminum | `78_148_271` | `cutting` |
63
+ * | Taper Pipe Taps | `78_148_283` | `cutting` |
64
+ * | DIN Length HPT Taps | `78_148_284` | `cutting` |
65
+ *
66
+ * **Checked into a table when it is built, not run as a filter**, for the
67
+ * reason the ER leaves above already give: a rule matching a name drops
68
+ * silently when the vendor renames a category, and three leaves here are
69
+ * already named something other than what they hold. `maritool --catalog` walks
70
+ * the tree, which is how the rule gets re-checked rather than trusted.
71
+ *
72
+ * **What still has to be decided before a tap family lands.** A MariTool tap
73
+ * page publishes `Shank Size`, `Total Length`, `Thread Length` and `Size` —
74
+ * enough for `SFDM`, `OAL`, `LCF` and a designation `thread.ts` can already
75
+ * parse — but a metric tap states `Size: M3` and `Pitch Diameter: 0.5` against
76
+ * a body in inches (`Shank Size: 0.141`, `Total Length: 1 15/16`). No family in
77
+ * this package is mixed that way and `ToolRecord.unit` is one value, so that is
78
+ * a decision on its own merits and not a widening of this table.
79
+ *
49
80
  * **BT50 and ISO30 produce no CSV.** MariTool's only BT50 category is end mill
50
81
  * holders, which is out of scope; ISO30 has three ER parts and none of them
51
82
  * publishes a spec table, so the family would be a receipt of nothing.
package/dist/family.d.ts CHANGED
@@ -20,12 +20,12 @@ import type { UnitSystem } from './conventions.js';
20
20
  import type { BrandName } from './identity.js';
21
21
  import type { HoldingMapper, ToolholdingKind } from './holding.js';
22
22
  import type { Fact } from './provenance.js';
23
- import type { ColumnMap, ToolKind, ToolRecord } from './records.js';
23
+ import type { ColumnMap, ThreadMethod, ToolKind, ToolRecord } from './records.js';
24
24
  import type { MapperOptions, ScrapedRow } from './scrape.js';
25
25
  /**
26
26
  * The per-family constants a fact can carry, and their types.
27
27
  *
28
- * Eleven keys, which is the whole vocabulary the catalog uses. Naming them rather
28
+ * Twelve keys, which is the whole vocabulary the catalog uses. Naming them rather
29
29
  * than accepting any string is what lets a mapper read `family.coolantThrough`
30
30
  * as a `boolean` instead of casting an `unknown` out of a bag — and what makes
31
31
  * a fact whose value is the wrong type a compile error where the family is
@@ -49,6 +49,20 @@ export interface FamilyFacts {
49
49
  */
50
50
  profile?: string;
51
51
  coolantThrough?: boolean;
52
+ /**
53
+ * How a tap makes its thread — `cutting` or `forming`.
54
+ *
55
+ * A fact and not a column because neither vendor publishes it per part: it is
56
+ * Kennametal's `newTapType` facet, which the variant table does not carry,
57
+ * and EMUGE's category, which partitions its taps into two. Every declaration
58
+ * in `families/` cites the index it was read off, and each is one value for a
59
+ * whole family — checked against the vendor rather than assumed, which is why
60
+ * they are `vendor-stated`.
61
+ *
62
+ * Every `kind: 'tap'` family must state it: `tapRecord` reads it through
63
+ * {@link fact}, and `records.toolRecord` refuses a tap record without one.
64
+ */
65
+ threadMethod?: ThreadMethod;
52
66
  flutes?: number;
53
67
  /** Degrees included. */
54
68
  pointAngle?: number;
@@ -114,6 +128,18 @@ export interface FamilyDefinition extends CommonDefinition {
114
128
  export interface ToolholdingDefinition extends CommonDefinition {
115
129
  /** What a human calls this family. Holders and collets have no `id`. */
116
130
  readonly catalogName: string;
131
+ /**
132
+ * The vendor's own code for the family, where the scrape target is a family
133
+ * page — {@link FamilyDefinition.familyCode}'s counterpart.
134
+ *
135
+ * Absent where the vendor has no such page: REGO-FIX and MariTool families
136
+ * are a set of index filters and name none. It was absent on Kennametal's
137
+ * toolholding too until 2026-09-08, because those families were scraped by
138
+ * hand from a code read off the page at the time — which made a re-scrape a
139
+ * trip back to the browser. Recording it is what makes one re-runnable, and
140
+ * it is what `kennametal --collets` reconciles its walk against.
141
+ */
142
+ readonly familyCode?: string;
117
143
  }
118
144
  /** A cutting-tool family after {@link FamilyDefinition} has been validated. */
119
145
  export type BoundFamily = Omit<FamilyDefinition, 'columns'> & FamilyFacts & {
package/dist/holding.d.ts CHANGED
@@ -211,6 +211,41 @@ export interface ColletRecord extends HoldingIdentity {
211
211
  readonly functionalLength: number | null;
212
212
  /** `L` — overall length. */
213
213
  readonly overallLength: number | null;
214
+ /**
215
+ * `L9` — how deep the clamping bore is, and therefore how much shank the
216
+ * collet actually holds.
217
+ *
218
+ * Twinned in millimetres because it is **compared and not only displayed**:
219
+ * it is `@toolpath/tool-support`'s `Collet.clampLength`, the one input to
220
+ * `maxStickout`, which answers `null` for every collet without it. That is
221
+ * the rule this module states for `bore`, `gaugeLength` and the capacity
222
+ * pair, and this is the fourth dimension to earn it.
223
+ *
224
+ * Not a second overall length. Kennametal's `109321468` publishes both: `L`
225
+ * is 46 mm down all nineteen ER40 rows while `L9` runs 22 / 28 / 46 by size.
226
+ */
227
+ readonly clampingLength: number | null;
228
+ readonly clampingLengthMm: number | null;
229
+ /**
230
+ * The vendor's own designation of the taps this collet is for, verbatim —
231
+ * `"M6 & M6.3"`, `"#14 & 1/4"`.
232
+ *
233
+ * **Text, and deliberately not parsed.** It names two thread designations in
234
+ * the vendor's own words and this package does not author tool data; what a
235
+ * fit rule compares is {@link ColletRecord.clampMin}/{@link ColletRecord.clampMax},
236
+ * which are numbers. `null` on every collet whose vendor publishes none.
237
+ */
238
+ readonly tapRange: string | null;
239
+ /**
240
+ * `S10` — the square drive the bore carries, across flats.
241
+ *
242
+ * The fact that makes a tap collet a tap collet: its bore is not round, so
243
+ * the part it takes is a tap and nothing else. A shank test alone would say
244
+ * yes to an end mill of the same diameter, held by nothing —
245
+ * `@toolpath/tool-support`'s `holderTakesTool` reads this to refuse that.
246
+ * `null` where the vendor publishes no square, which is every round collet.
247
+ */
248
+ readonly squareSize: number | null;
214
249
  }
215
250
  /** Either toolholding record. Narrow on {@link HoldingIdentity.kind}. */
216
251
  export type HoldingRecord = HolderRecord | ColletRecord;
@@ -295,6 +330,29 @@ export declare function millimeters(value: number | null, unit: UnitSystem): num
295
330
  * for a machinist's number and refuses a range rather than summing it.
296
331
  */
297
332
  export declare function dim(row: ScrapedRow, label: string, unit: UnitSystem): number | null;
333
+ /**
334
+ * How far a collet's *designation* may sit from its measured capacity, as a
335
+ * fraction of that capacity.
336
+ *
337
+ * {@link ColletRecord.nominal} is the size the vendor **designates** the collet
338
+ * by; {@link ColletRecord.clampMin}/{@link ColletRecord.clampMax} is what it
339
+ * measures. They are not two readings of one number, and on a sealed collet
340
+ * they are routinely different: Kennametal's `40ERSS1000` is designated 1 inch
341
+ * and clamps 0.9938, `40ERSS0500` is designated 1/2 and clamps 0.4943. Both
342
+ * unit columns agree on both figures — 25.4 against 25.243 mm, 12.7 against
343
+ * 12.556 — so this is the vendor stating an undersized capacity for a collet it
344
+ * names by the fraction, not a cell in the wrong column.
345
+ *
346
+ * **Relative rather than absolute, because the gap scales with the collet.**
347
+ * Across the 443-part collet corpus fourteen rows sit outside their own band.
348
+ * The widest is `40ERSS0812` at 0.193 mm, which is 0.94 % of its 20.4 mm
349
+ * capacity; the widest by fraction is `40ERSS0500` at 1.14 %. The error this
350
+ * must still refuse is a cell in the wrong unit system, which is 96 % out — the
351
+ * shape `16ERSS0312` has, where `D1`'s metric cell holds the inch value. This
352
+ * sits 4.4x above the first and 19x below the second. A row that lands in
353
+ * between is a finding to investigate rather than a number to widen.
354
+ */
355
+ export declare const NOMINAL_SLACK = 0.05;
298
356
  /**
299
357
  * Report where a vendor's own millimetre and inch cells disagree.
300
358
  *
@@ -387,10 +445,10 @@ type HolderFields = Omit<HolderRecord, 'kind' | 'guid' | 'vendor' | 'productLink
387
445
  * mutated one would be reaching back across the seam this type exists to draw.
388
446
  */
389
447
  export declare function holderRecord(fields: HolderFields): HolderRecord;
390
- /** The same, for a collet. REGO-FIX publishes none of these four. */
391
- type OptionalColletFields = 'nominal' | 'bodyDiameter' | 'functionalLength' | 'overallLength';
448
+ /** The same, for a collet. REGO-FIX publishes none of these seven. */
449
+ type OptionalColletFields = 'nominal' | 'bodyDiameter' | 'functionalLength' | 'overallLength' | 'clampingLength' | 'tapRange' | 'squareSize';
392
450
  /** What a mapper supplies to build a collet. */
393
- type ColletFields = Omit<ColletRecord, 'kind' | 'guid' | 'vendor' | 'productLink' | 'clampMinMm' | 'clampMaxMm' | OptionalColletFields> & Partial<Pick<ColletRecord, OptionalColletFields>>;
451
+ type ColletFields = Omit<ColletRecord, 'kind' | 'guid' | 'vendor' | 'productLink' | 'clampMinMm' | 'clampMaxMm' | 'clampingLengthMm' | OptionalColletFields> & Partial<Pick<ColletRecord, OptionalColletFields>>;
394
452
  /** Build a {@link ColletRecord}, on the same terms as {@link holderRecord}. */
395
453
  export declare function colletRecord(fields: ColletFields): ColletRecord;
396
454
  export {};
package/dist/holding.js CHANGED
@@ -163,6 +163,29 @@ function halfUlp(raw) {
163
163
  const fraction = raw.includes('.') ? raw.slice(raw.indexOf('.') + 1) : '';
164
164
  return 0.5 * 10 ** -fraction.length;
165
165
  }
166
+ /**
167
+ * How far a collet's *designation* may sit from its measured capacity, as a
168
+ * fraction of that capacity.
169
+ *
170
+ * {@link ColletRecord.nominal} is the size the vendor **designates** the collet
171
+ * by; {@link ColletRecord.clampMin}/{@link ColletRecord.clampMax} is what it
172
+ * measures. They are not two readings of one number, and on a sealed collet
173
+ * they are routinely different: Kennametal's `40ERSS1000` is designated 1 inch
174
+ * and clamps 0.9938, `40ERSS0500` is designated 1/2 and clamps 0.4943. Both
175
+ * unit columns agree on both figures — 25.4 against 25.243 mm, 12.7 against
176
+ * 12.556 — so this is the vendor stating an undersized capacity for a collet it
177
+ * names by the fraction, not a cell in the wrong column.
178
+ *
179
+ * **Relative rather than absolute, because the gap scales with the collet.**
180
+ * Across the 443-part collet corpus fourteen rows sit outside their own band.
181
+ * The widest is `40ERSS0812` at 0.193 mm, which is 0.94 % of its 20.4 mm
182
+ * capacity; the widest by fraction is `40ERSS0500` at 1.14 %. The error this
183
+ * must still refuse is a cell in the wrong unit system, which is 96 % out — the
184
+ * shape `16ERSS0312` has, where `D1`'s metric cell holds the inch value. This
185
+ * sits 4.4x above the first and 19x below the second. A row that lands in
186
+ * between is a finding to investigate rather than a number to widen.
187
+ */
188
+ export const NOMINAL_SLACK = 0.05;
166
189
  /**
167
190
  * Report where a vendor's own millimetre and inch cells disagree.
168
191
  *
@@ -298,11 +321,25 @@ export function checkCollet(record) {
298
321
  // In the native unit, which is the gate with teeth: these are the values a
299
322
  // consumer compares, and the contradictory cells this catalog knows about all
300
323
  // sit in the column `dim` ignores.
324
+ //
325
+ // The slack is {@link NOMINAL_SLACK}, because a designation is not a
326
+ // measurement — see that constant for the fourteen rows it exists for and the
327
+ // margin either side of it. Unit-free, so it needs no conversion.
301
328
  if (record.nominal !== null &&
302
- (record.nominal < record.clampMin || record.nominal > record.clampMax)) {
329
+ (record.nominal < record.clampMin * (1 - NOMINAL_SLACK) ||
330
+ record.nominal > record.clampMax * (1 + NOMINAL_SLACK))) {
303
331
  throw new VendorResponseError(what, `nominal ${record.nominal} is outside its own capacity ` +
304
332
  `${record.clampMin}-${record.clampMax}`);
305
333
  }
334
+ // A tap's square across flats is inscribed in its shank, so it is smaller
335
+ // than the bore that takes it — true on all 96 rows of both Kennametal tap
336
+ // families. A square at or past the clamping diameter is the inch cell in the
337
+ // metric column or two labels swapped, which is the failure `dim`'s native
338
+ // read cannot see and `checkUnitAgreement` only reports.
339
+ if (record.squareSize !== null && record.squareSize >= record.clampMax) {
340
+ throw new VendorResponseError(what, `square size ${record.squareSize} is not smaller than the ` +
341
+ `${record.clampMax} it clamps — a square is inscribed in the shank`);
342
+ }
306
343
  }
307
344
  /**
308
345
  * Build a {@link HolderRecord}: mint its guid, derive its millimetre twins, and
@@ -354,6 +391,10 @@ export function colletRecord(fields) {
354
391
  bodyDiameter: fields.bodyDiameter ?? null,
355
392
  functionalLength: fields.functionalLength ?? null,
356
393
  overallLength: fields.overallLength ?? null,
394
+ clampingLength: fields.clampingLength ?? null,
395
+ clampingLengthMm: millimeters(fields.clampingLength ?? null, fields.unit),
396
+ tapRange: fields.tapRange ?? null,
397
+ squareSize: fields.squareSize ?? null,
357
398
  });
358
399
  checkCollet(record);
359
400
  return record;
@@ -75,7 +75,7 @@ export declare const BRANDS: {
75
75
  readonly host: "destinytool.com";
76
76
  readonly home: "https://destinytool.com";
77
77
  readonly vendor: "Destiny Tool";
78
- readonly productLink: "https://destinytool.com/products?search={material}";
78
+ readonly productLink: "https://destinytool.com/products/{material}";
79
79
  };
80
80
  readonly harvey: {
81
81
  readonly host: "harveytool.com";
package/dist/identity.js CHANGED
@@ -54,16 +54,13 @@ export const BRANDS = {
54
54
  productLink: 'https://us.rego-fix.com/en/productfinder?q={material}',
55
55
  },
56
56
  // Destiny Tool is a Next.js SPA with no product data in the HTML at all — it
57
- // reads live from a Firestore database. Like REGO-FIX there is no per-part
58
- // page to link to, only a client-rendered products list; unlike REGO-FIX's
59
- // ProductFinder, nothing here has confirmed a search query parameter the SPA
60
- // actually reads, so this is a best-effort link to the listing page rather
61
- // than a verified deep link (JG 2026-08-19).
57
+ // reads live from a Firestore database. Its client-rendered product page is
58
+ // addressable by the vendor's item number in the path.
62
59
  destinytool: {
63
60
  host: 'destinytool.com',
64
61
  home: 'https://destinytool.com',
65
62
  vendor: 'Destiny Tool',
66
- productLink: 'https://destinytool.com/products?search={material}',
63
+ productLink: 'https://destinytool.com/products/{material}',
67
64
  },
68
65
  // Harvey Tool is the first brand here whose per-part link is a real page the
69
66
  // vendor already publishes: every tool number in a product table is rendered