@springfield/ham-radio-utils 4.16.1 → 4.18.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.
@@ -0,0 +1,120 @@
1
+ /**
2
+ * Transitive relative `$ref` closure for one radio config.
3
+ * Matches the file set `pack:release` zips for that model: shared schemas and
4
+ * memory maps the config reaches, and not sibling radios.
5
+ */
6
+ import { existsSync, readFileSync } from 'node:fs';
7
+ import { dirname, join, relative, resolve, sep } from 'node:path';
8
+
9
+ /**
10
+ * @returns Sorted repo-relative paths, including `startRelative`
11
+ */
12
+ export function collectRefClosure(moduleRoot: string, startRelative: string): string[] {
13
+ const included = new Set<string>([startRelative]);
14
+ const pending = [startRelative];
15
+
16
+ while (pending.length > 0) {
17
+ const current = pending.pop();
18
+
19
+ if (!current) {
20
+ continue;
21
+ }
22
+
23
+ const text = readWorkingTreeText(moduleRoot, current);
24
+
25
+ if (text === undefined) {
26
+ continue;
27
+ }
28
+
29
+ let parsed: unknown;
30
+
31
+ try {
32
+ parsed = JSON.parse(text) as unknown;
33
+ } catch {
34
+ continue;
35
+ }
36
+
37
+ const fromDirectory = dirname(join(moduleRoot, current));
38
+
39
+ for (const refPath of localRefTargets(parsed)) {
40
+ const absolutePath = resolve(fromDirectory, refPath);
41
+ const relativePath = toRepoRelative(moduleRoot, absolutePath);
42
+
43
+ if (relativePath === undefined) {
44
+ throw new Error(`${current} $ref escapes the repository: ${refPath}`);
45
+ }
46
+
47
+ if (!included.has(relativePath)) {
48
+ included.add(relativePath);
49
+ pending.push(relativePath);
50
+ }
51
+ }
52
+ }
53
+
54
+ return [...included].sort();
55
+ }
56
+
57
+ export function readWorkingTreeText(moduleRoot: string, relativePath: string): string | undefined {
58
+ const absolutePath = join(moduleRoot, relativePath);
59
+
60
+ if (!existsSync(absolutePath)) {
61
+ return undefined;
62
+ }
63
+
64
+ return readFileSync(absolutePath, 'utf8');
65
+ }
66
+
67
+ export function localRefTargets(value: unknown, into: string[] = []): string[] {
68
+ if (Array.isArray(value)) {
69
+ for (const item of value) {
70
+ localRefTargets(item, into);
71
+ }
72
+
73
+ return into;
74
+ }
75
+
76
+ if (!value || typeof value !== 'object') {
77
+ return into;
78
+ }
79
+
80
+ const record = value as Record<string, unknown>;
81
+
82
+ if (typeof record.$ref === 'string') {
83
+ const refPath = filePathFromRef(record.$ref);
84
+
85
+ if (refPath) {
86
+ into.push(refPath);
87
+ }
88
+ }
89
+
90
+ for (const child of Object.values(record)) {
91
+ localRefTargets(child, into);
92
+ }
93
+
94
+ return into;
95
+ }
96
+
97
+ /**
98
+ * Path portion of a relative file `$ref`, without a JSON pointer.
99
+ * Fragment-only refs and absolute URIs are ignored.
100
+ */
101
+ export function filePathFromRef(ref: string): string | undefined {
102
+ if (ref.length === 0 || ref.startsWith('#') || /^[a-z][a-z0-9+.-]*:/i.test(ref)) {
103
+ return undefined;
104
+ }
105
+
106
+ const hashIndex = ref.indexOf('#');
107
+ const refPath = hashIndex === -1 ? ref : ref.slice(0, hashIndex);
108
+
109
+ return refPath.length > 0 ? refPath : undefined;
110
+ }
111
+
112
+ function toRepoRelative(rootDirectory: string, absolutePath: string): string | undefined {
113
+ const relativePath = relative(rootDirectory, absolutePath);
114
+
115
+ if (relativePath === '' || relativePath.startsWith('..') || relativePath.includes(`${sep}..`)) {
116
+ return undefined;
117
+ }
118
+
119
+ return relativePath.split(sep).join('/');
120
+ }
@@ -99,6 +99,8 @@
99
99
  "properties": {
100
100
  "group": { "type": "string" },
101
101
  "subgroup": { "type": "string", "minLength": 1 },
102
+ "section": { "type": "string", "minLength": 1 },
103
+ "sectionOrder": { "type": "integer", "minimum": 0 },
102
104
  "label": { "type": "string" },
103
105
  "widget": {
104
106
  "type": "string",