@rulecad/versions 0.0.0-stage → 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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 JoniBach and contributors
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -1,3 +1,30 @@
1
- # Temporary Holding Version
1
+ # @rulecad/versions
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ Semver for physical parts. For software, a major version means "your code may break". For a printed part it means
4
+ **"an earlier print may no longer fit"**. So the version follows the part's *interface*: the measurements other parts
5
+ depend on, like a keycap's stem slot or a case's screw holes.
6
+
7
+ | Change | Bump |
8
+ |---|---|
9
+ | An interface value changed or went away; a named variant changed | **major**: old prints may not fit |
10
+ | A new interface value, option, variant or tweak range | **minor**: nothing that fitted stops fitting |
11
+ | Anything else: tolerances that aren't interface, the shape's code, a rule's value, print settings | **patch** |
12
+
13
+ ```js
14
+ import { compare, bump } from '@rulecad/versions';
15
+ import { resolve } from '@rulecad/variables';
16
+
17
+ const snapshot = (object, js) => ({ object, entries: resolve(object).entries, js });
18
+ const before = snapshot({ variables: { slot: { value: 4.2, interface: true } } }, shapeCode);
19
+ const after = snapshot({ variables: { slot: { value: 4.25, interface: true } } }, shapeCode);
20
+
21
+ compare(before, after) // { kind: 'major', reasons: [{ kind: 'major', text: 'interface slot 4.2 → 4.25' }] }
22
+ bump('1.4.2', 'major') // '2.0.0'
23
+ ```
24
+
25
+ Every reason is a short line you can put straight into a changelog. Where releases are kept is up to you (git tags,
26
+ files, a database): this package only reads and compares.
27
+
28
+ `satisfies(version, range)` handles `^1.2.0`, `~1.2.0` and `1.2.0`, so an assembly can say which versions of a part it
29
+ takes. One difference from npm: `^0.2.0` accepts `0.3.0`, because here a minor release never breaks a fit, even before
30
+ 1.0. Also exported: `parse`, `newer` and `bigger`.
package/package.json CHANGED
@@ -1,6 +1,36 @@
1
1
  {
2
2
  "name": "@rulecad/versions",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "0.1.0",
4
+ "description": "Semver for physical parts: a change to what other parts fit against is major, so you know when an old print stops fitting.",
5
+ "type": "module",
6
+ "exports": "./versions.mjs",
7
+ "files": [
8
+ "versions.mjs",
9
+ "README.md",
10
+ "LICENSE"
11
+ ],
12
+ "license": "MIT",
13
+ "keywords": [
14
+ "semver",
15
+ "versioning",
16
+ "3d-printing",
17
+ "cad",
18
+ "interface"
19
+ ],
20
+ "engines": {
21
+ "node": ">=18"
22
+ },
23
+ "sideEffects": false,
24
+ "repository": {
25
+ "type": "git",
26
+ "url": "git+https://github.com/JoniBach/rulecad.git",
27
+ "directory": "packages/versions"
28
+ },
29
+ "scripts": {
30
+ "test": "node --test"
31
+ },
32
+ "dependencies": {
33
+ "@rulecad/variables": "^0.1.0",
34
+ "@rulecad/keyboard-layout": "^0.1.0"
35
+ }
36
+ }
package/versions.mjs ADDED
@@ -0,0 +1,97 @@
1
+ // Versions for physical parts: semver where the interface is what fits. A part's interface variables are the
2
+ // measurements other parts depend on (a keycap's stem slot), so:
3
+ // major: an earlier print may no longer fit: an interface variable changed or went away, a variant changed
4
+ // minor: something added that doesn't break fits: a new interface variable, option, variant or tweak range
5
+ // patch: anything else (print quality, tolerances that aren't interface, the shape's code, the rules it uses)
6
+ // compare(before, after) reads two snapshots of a design and says which, with a reason for each change. Releases can
7
+ // then be git tags, files, anything: this module only reads and compares, it stores nothing. No files, browsers too.
8
+ import { fmt } from '@rulecad/variables';
9
+ import { project } from '@rulecad/keyboard-layout';
10
+
11
+ // ---------- semver ----------
12
+ const KINDS = ['patch', 'minor', 'major'];
13
+ export const parse = (v) => { const m = String(v).match(/^(\d+)\.(\d+)\.(\d+)$/); if (!m) throw new Error(`"${v}" isn't a version like 1.2.0`); return m.slice(1).map(Number); };
14
+ export function bump(v, kind) {
15
+ const [a, b, c] = parse(v);
16
+ return kind === 'major' ? `${a + 1}.0.0` : kind === 'minor' ? `${a}.${b + 1}.0` : `${a}.${b}.${c + 1}`;
17
+ }
18
+ export const newer = (x, y) => { const [p, q] = [parse(x), parse(y)]; for (let i = 0; i < 3; i++) if (p[i] !== q[i]) return p[i] > q[i]; return false; };
19
+ export const bigger = (k1, k2) => (KINDS.indexOf(k1) >= KINDS.indexOf(k2) ? k1 : k2);
20
+
21
+
22
+ // satisfies('0.2.1', '^0.2.0') -> true
23
+ export function satisfies(version, range) {
24
+ const m = String(range).match(/^([~^]?)(\d+\.\d+\.\d+)$/);
25
+ if (!m) throw new Error(`"${range}" isn't a version range like ^1.2.0, ~1.2.0 or 1.2.0`);
26
+ const [a, b, c] = parse(version), [x, y, z] = parse(m[2]), atLeast = a > x || (a === x && (b > y || (b === y && c >= z)));
27
+ if (m[1] === '^') return a === x && atLeast; // same major: a minor release never breaks a fit, 0.x included
28
+ if (m[1] === '~') return a === x && b === y && c >= z;
29
+ return a === x && b === y && c === z;
30
+ }
31
+
32
+ // ---------- what changed ----------
33
+ // compare(before, after) -> { kind: 'major'|'minor'|'patch'|null, reasons }
34
+ // before/after: { object, entries, js } (entries from resolve(), js the design's source).
35
+ export function compare(before, after) {
36
+ const reasons = [], add = (kind, text) => reasons.push({ kind, text });
37
+ const val = (r, name) => r.entries.find((e) => e.name === name);
38
+ const v = (r, name) => fmt(val(r, name).value); // for reading: 4.2, not 4.199999999999999
39
+ const vars = (r) => r.object.variables || {};
40
+ const same = (a, b) => (typeof a === 'number' && typeof b === 'number' ? Math.abs(a - b) < 1e-9 : a === b); // numbers, or choices
41
+
42
+ for (const [name, d] of Object.entries(vars(before))) {
43
+ const now = vars(after)[name];
44
+ if (d.interface) {
45
+ if (!now) add('major', `interface ${name} removed`);
46
+ else if (!now.interface) add('major', `${name} is no longer interface`);
47
+ else if (!same(val(before, name).value, val(after, name).value)) add('major', `interface ${name} ${v(before, name)} → ${v(after, name)}`);
48
+ } else if (!now) add('patch', `${name} removed`);
49
+ else if (!same(val(before, name).value, val(after, name).value)) add('patch', `${name} ${v(before, name)} → ${v(after, name)}`);
50
+ }
51
+ for (const [name, d] of Object.entries(vars(after))) {
52
+ const was = vars(before)[name];
53
+ if (d.interface && !was?.interface && was) continue; // reported above as a change of role
54
+ if (d.interface && !was) add('minor', `interface ${name} added (${v(after, name)})`);
55
+ else if (!was) add('patch', `${name} added`);
56
+ if ((d.type === 'text' || d.type === 'anchor') && !was?.type) add('minor', `${name} can now be set (${d.type})`); // a new input
57
+ if (d.choice) { // a choice: options added are new ways to build it; options taken away break what used them
58
+ for (const c of d.choice) if (!was?.choice?.includes(c)) add('minor', `${name} can now be ${c}`);
59
+ for (const c of was?.choice || []) if (!d.choice.includes(c)) add(d.interface ? 'major' : 'patch', `${name} can no longer be ${c}`);
60
+ }
61
+ if (d.tweak && !was?.tweak) add('minor', `${name} can now be set (${d.tweak.min} to ${d.tweak.max})`);
62
+ else if (d.tweak && was?.tweak && (d.tweak.min !== was.tweak.min || d.tweak.max !== was.tweak.max)) add('patch', `${name} range now ${d.tweak.min} to ${d.tweak.max}`);
63
+ }
64
+ // Values the design builds from that changed without any variable changing: a rule's value
65
+ for (const e of after.entries) if (e.name.includes('.')) { const b = val(before, e.name); if (b && !same(b.value, e.value)) add('patch', `rule ${e.name} ${fmt(b.value)} → ${fmt(e.value)}`); }
66
+ // Layouts kept in an object's .json (older designs): moving one into a layout file is no change, if its keys match
67
+ const lay = (r) => r.object.layouts || {}, fields = (r, n) => Object.values(r.object.variables || {}).find((v) => v.type === 'layout' && (v.choice || []).includes(n))?.fields;
68
+ for (const [k, l] of Object.entries(lay(before))) {
69
+ const now = lay(after)[k] ?? after.layouts?.[k];
70
+ if (!now) add('major', `layout ${k} removed`);
71
+ else if (JSON.stringify(project(l, fields(after, k))) !== JSON.stringify(project(now, fields(after, k)))) add('major', `layout ${k} changed`);
72
+ else if (!lay(after)[k]) add('patch', `layout ${k} moved to its own file`);
73
+ }
74
+ for (const k of Object.keys(lay(after))) if (!lay(before)[k]) add('minor', `layout ${k} added`);
75
+ if (before.js.trimEnd() !== after.js.trimEnd()) add('patch', 'shape code changed');
76
+ if (before.engine != null && after.engine != null && before.engine.trimEnd() !== after.engine.trimEnd()) add('patch', 'board engine changed (footprints, routing)');
77
+ for (const k of ['material', 'orientation', 'title']) if (before.object[k] !== after.object[k]) add('patch', `${k} changed`);
78
+ const shown = (o) => JSON.stringify(o.display || o.mirror || {}); // on-screen settings ("display", once "mirror")
79
+ if (shown(before.object) !== shown(after.object)) add('patch', 'display settings changed');
80
+ // Variants: a new one is a new way to build it; changing or removing one changes what was built under that name
81
+ const vb = before.object.variants || {}, va = after.object.variants || {};
82
+ for (const [k, v] of Object.entries(vb)) {
83
+ if (!va[k]) add('major', `variant ${k} removed`);
84
+ else if (JSON.stringify(v) !== JSON.stringify(va[k])) add('major', `variant ${k} changed`);
85
+ }
86
+ for (const k of Object.keys(va)) if (!vb[k]) add('minor', `variant ${k} added`);
87
+ // Layout files: compared only on what this object reads of each key (its layout variable's fields), plus positions
88
+ for (const [name, v] of Object.entries(after.object.variables || {})) if (v.type === 'layout') {
89
+ for (const n of v.choice || [v.value]) {
90
+ const b = before.layouts?.[n], a = after.layouts?.[n];
91
+ if (!a || !b) continue; // new choices are reported above; a layout moved out of the .json just above
92
+ if (JSON.stringify(project(b, v.fields)) !== JSON.stringify(project(a, v.fields))) add('major', `layout ${n} changed (${['positions', ...(v.fields || [])].join(', ')})`);
93
+ }
94
+ }
95
+ const kind = reasons.reduce((k, r) => (k ? bigger(k, r.kind) : r.kind), null);
96
+ return { kind, reasons };
97
+ }