@ceralive/modem-control 0.2.0 → 1.0.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/package.json +1 -1
- package/src/backend/index.ts +15 -0
- package/src/backend/uhubctl-power-hook.test.ts +274 -0
- package/src/backend/uhubctl-power-hook.ts +377 -0
- package/src/backend/usage/index.ts +23 -0
- package/src/backend/usage/policy-store.test.ts +164 -0
- package/src/backend/usage/policy-store.ts +216 -0
- package/src/backend/usage/policy-write.test.ts +198 -0
- package/src/backend/usage/policy-write.ts +207 -0
- package/src/backend/usage/sampler.test.ts +108 -0
- package/src/backend/usage/sampler.ts +57 -3
- package/src/usb-mode/index.ts +32 -1
- package/src/usb-mode/ingestion.test.ts +268 -0
- package/src/usb-mode/ingestion.ts +297 -0
- package/src/usb-mode/promotion-review.ts +117 -0
- package/src/usb-mode/usb-devices-parse.ts +196 -0
|
@@ -0,0 +1,268 @@
|
|
|
1
|
+
// The ingestion seam's contract: a bundle round-trips into a schema-valid catalog entry
|
|
2
|
+
// and a real-shaped classifier fixture, the sha256 links the two, and a synthetic bundle
|
|
3
|
+
// is REFUSED for catalog promotion with a typed reason rather than silently accepted.
|
|
4
|
+
|
|
5
|
+
import { describe, expect, test } from 'bun:test';
|
|
6
|
+
import { classifyDevice, detectUsbMode } from '../backend/device-classifier';
|
|
7
|
+
import { catalogEntrySchema } from './catalog-schema';
|
|
8
|
+
import {
|
|
9
|
+
buildCatalogEntryCandidate,
|
|
10
|
+
buildClassifierFixture,
|
|
11
|
+
type IngestionRequest,
|
|
12
|
+
parseIngestionRequest,
|
|
13
|
+
} from './ingestion';
|
|
14
|
+
import { renderPromotionReview } from './promotion-review';
|
|
15
|
+
import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse';
|
|
16
|
+
|
|
17
|
+
const SHA = 'a'.repeat(64);
|
|
18
|
+
|
|
19
|
+
/** Verbatim-shaped `usb-devices` output for a QMI stick plus one unrelated hub. */
|
|
20
|
+
const USB_DEVICES = `
|
|
21
|
+
T: Bus=04 Lev=01 Prnt=01 Port=00 Cnt=01 Dev#= 2 Spd=5000 MxCh= 4
|
|
22
|
+
D: Ver= 3.20 Cls=09(hub ) Sub=00 Prot=03 MxPS= 9 #Cfgs= 1
|
|
23
|
+
P: Vendor=0bda ProdID=0411 Rev=01.01
|
|
24
|
+
S: Manufacturer=Generic
|
|
25
|
+
S: Product=USB3.2 Hub
|
|
26
|
+
I: If#= 0 Alt= 0 #EPs= 1 Cls=09(hub ) Sub=00 Prot=00 Driver=hub
|
|
27
|
+
|
|
28
|
+
T: Bus=04 Lev=03 Prnt=04 Port=03 Cnt=01 Dev#= 7 Spd=480 MxCh= 0
|
|
29
|
+
D: Ver= 2.00 Cls=00(>ifc ) Sub=00 Prot=00 MxPS=64 #Cfgs= 1
|
|
30
|
+
P: Vendor=2c7c ProdID=0125 Rev=03.18
|
|
31
|
+
S: Manufacturer=Quectel
|
|
32
|
+
S: Product=SYNTHETIC-BENCH-STICK
|
|
33
|
+
I: If#= 2 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=00 Prot=00 Driver=option
|
|
34
|
+
I: If#= 4 Alt= 0 #EPs= 3 Cls=ff(vend.) Sub=ff Prot=ff Driver=qmi_wwan
|
|
35
|
+
`;
|
|
36
|
+
|
|
37
|
+
function bundle(overrides: Record<string, unknown> = {}): Record<string, unknown> {
|
|
38
|
+
return {
|
|
39
|
+
schemaVersion: 1,
|
|
40
|
+
synthetic: false,
|
|
41
|
+
capturedAtMs: 1_760_000_000_000,
|
|
42
|
+
slot: 'Modem/2',
|
|
43
|
+
sku: {
|
|
44
|
+
vidPid: '2c7c:0125',
|
|
45
|
+
model: 'CERALIVE-SYNTHETIC-TEST-SKU',
|
|
46
|
+
firmwarePrefix: 'SYNTHETICFW01',
|
|
47
|
+
},
|
|
48
|
+
usb: {
|
|
49
|
+
usbDevices: USB_DEVICES,
|
|
50
|
+
udevProperties: {
|
|
51
|
+
ID_PATH: 'platform-xhci-hcd.0.auto-usb-0:1.4.4',
|
|
52
|
+
ID_VENDOR_ID: '2c7c',
|
|
53
|
+
ID_MODEL_ID: '0125',
|
|
54
|
+
INTERFACE: 'wwan0',
|
|
55
|
+
},
|
|
56
|
+
},
|
|
57
|
+
// Fields the real bundle carries and the ingestion VIEW deliberately ignores.
|
|
58
|
+
usbExtra: { lsusb: 'Device Descriptor:' },
|
|
59
|
+
modemManager: { mmcliKeyfile: {}, managedObjects: {}, signalWindow: [] },
|
|
60
|
+
...overrides,
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const request = (overrides: Record<string, unknown> = {}, sha = SHA): IngestionRequest => ({
|
|
65
|
+
bundle: bundle(overrides),
|
|
66
|
+
bundleSha256: sha,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
const TRANSITION = {
|
|
70
|
+
from: 'qmi',
|
|
71
|
+
to: 'mbim',
|
|
72
|
+
atCommand: 'AT+QCFG="usbnet",2',
|
|
73
|
+
expectedResponse: 'OK',
|
|
74
|
+
expectsPortDrop: true,
|
|
75
|
+
afterDescriptors: {
|
|
76
|
+
deviceClass: 0,
|
|
77
|
+
interfaces: [
|
|
78
|
+
{ interfaceClass: 2, interfaceSubClass: 14, interfaceProtocol: 0 },
|
|
79
|
+
{ interfaceClass: 10, interfaceSubClass: 0, interfaceProtocol: 2 },
|
|
80
|
+
],
|
|
81
|
+
},
|
|
82
|
+
timeline: [{ event: 'command-sent', atMs: 1 }],
|
|
83
|
+
};
|
|
84
|
+
|
|
85
|
+
describe('parseUsbDevices — the descriptor source a base bundle actually carries', () => {
|
|
86
|
+
test('parses every device, its bDeviceClass, and each interface driver', () => {
|
|
87
|
+
const devices = parseUsbDevices(USB_DEVICES);
|
|
88
|
+
expect(devices).toHaveLength(2);
|
|
89
|
+
const stick = devices[1];
|
|
90
|
+
expect(stick?.vidPid).toBe('2c7c:0125');
|
|
91
|
+
expect(stick?.bDeviceClass).toBe(0);
|
|
92
|
+
expect(stick?.product).toBe('SYNTHETIC-BENCH-STICK');
|
|
93
|
+
expect(stick?.interfaces).toEqual([
|
|
94
|
+
{ interfaceClass: 0xff, interfaceSubClass: 0x00, interfaceProtocol: 0x00, driver: 'option' },
|
|
95
|
+
{
|
|
96
|
+
interfaceClass: 0xff,
|
|
97
|
+
interfaceSubClass: 0xff,
|
|
98
|
+
interfaceProtocol: 0xff,
|
|
99
|
+
driver: 'qmi_wwan',
|
|
100
|
+
},
|
|
101
|
+
]);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
test('a block with no P: line yields no record — identity is never invented', () => {
|
|
105
|
+
expect(parseUsbDevices('T: Bus=01\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=x')).toEqual([]);
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
test('a duplicate VID:PID is AMBIGUOUS, not first-wins (the Huawei HiLink pair)', () => {
|
|
109
|
+
const pair = `${USB_DEVICES}\nT: Bus=01 Lev=01\nD: Cls=00\nP: Vendor=2c7c ProdID=0125 Rev=03.18\nI: If#= 0 Cls=ff Sub=ff Prot=ff Driver=qmi_wwan\n`;
|
|
110
|
+
const selected = selectUniqueDevice(parseUsbDevices(pair), '2c7c:0125');
|
|
111
|
+
expect(selected).toEqual({ ambiguousMatches: 2 });
|
|
112
|
+
});
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
describe('buildClassifierFixture — the real udev shape, not a hand-typed approximation', () => {
|
|
116
|
+
test('produces a snapshot the REAL classifier classifies correctly', () => {
|
|
117
|
+
const outcome = buildClassifierFixture(request());
|
|
118
|
+
expect(outcome.ok).toBe(true);
|
|
119
|
+
if (!outcome.ok) {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const { snapshot, provenance } = outcome.value;
|
|
123
|
+
expect(snapshot.vendorId).toBe('2c7c');
|
|
124
|
+
expect(snapshot.productId).toBe('0125');
|
|
125
|
+
expect(snapshot.model).toBe('CERALIVE-SYNTHETIC-TEST-SKU');
|
|
126
|
+
expect(snapshot.firmwareRevision).toBe('SYNTHETICFW01');
|
|
127
|
+
expect(snapshot.physicalUid).toBe('platform-xhci-hcd.0.auto-usb-0:1.4.4');
|
|
128
|
+
expect(snapshot.ifname).toBe('wwan0');
|
|
129
|
+
// The whole point of deriving from real capture text: the fixture must survive
|
|
130
|
+
// the production classifier, not merely typecheck.
|
|
131
|
+
expect(classifyDevice(snapshot).deviceClass).toBe('mm-managed');
|
|
132
|
+
expect(detectUsbMode(snapshot)).toBe('qmi');
|
|
133
|
+
expect(provenance.bundleSha256).toBe(SHA);
|
|
134
|
+
expect(provenance.synthetic).toBe(false);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('a SYNTHETIC bundle still yields a fixture, stamped synthetic in provenance', () => {
|
|
138
|
+
const outcome = buildClassifierFixture(request({ synthetic: true }));
|
|
139
|
+
expect(outcome.ok).toBe(true);
|
|
140
|
+
if (outcome.ok) {
|
|
141
|
+
expect(outcome.value.provenance.synthetic).toBe(true);
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test('refuses a bundle with no sku (blocker B2 shape) rather than inventing one', () => {
|
|
146
|
+
const outcome = buildClassifierFixture(request({ sku: undefined }));
|
|
147
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'sku-missing' });
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
test('refuses when the SKU is absent from the usb-devices capture', () => {
|
|
151
|
+
const outcome = buildClassifierFixture(
|
|
152
|
+
request({ sku: { ...(bundle().sku as object), vidPid: '1199:9071' } }),
|
|
153
|
+
);
|
|
154
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'device-not-in-capture' });
|
|
155
|
+
});
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
describe('buildCatalogEntryCandidate — schema round-trip and sha linkage', () => {
|
|
159
|
+
test('a stage-1 bundle yields an entry with NO permitted transitions', () => {
|
|
160
|
+
const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'qmi' });
|
|
161
|
+
expect(outcome.ok).toBe(true);
|
|
162
|
+
if (!outcome.ok) {
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
expect(outcome.value.permittedTransitions).toEqual([]);
|
|
166
|
+
// Round-trip through the AUTHORITATIVE schema, not the builder's own view.
|
|
167
|
+
expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
test('a stage-2 bundle links the transition to THIS bundle sha256', () => {
|
|
171
|
+
const sha = 'b'.repeat(64);
|
|
172
|
+
const outcome = buildCatalogEntryCandidate(
|
|
173
|
+
{ bundle: bundle({ transition: TRANSITION }), bundleSha256: sha },
|
|
174
|
+
{ canonicalMode: 'qmi' },
|
|
175
|
+
);
|
|
176
|
+
expect(outcome.ok).toBe(true);
|
|
177
|
+
if (!outcome.ok) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
const [transition] = outcome.value.permittedTransitions;
|
|
181
|
+
expect(transition?.evidenceBundleSha256).toBe(sha);
|
|
182
|
+
expect(transition?.expectedDescriptors).toEqual(TRANSITION.afterDescriptors);
|
|
183
|
+
expect(transition?.atCommand).toBe('AT+QCFG="usbnet",2');
|
|
184
|
+
expect(catalogEntrySchema.parse(outcome.value)).toEqual(outcome.value);
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
test('REFUSES a synthetic:true bundle for catalog promotion — typed, not silent', () => {
|
|
188
|
+
const outcome = buildCatalogEntryCandidate(request({ synthetic: true }), {
|
|
189
|
+
canonicalMode: 'qmi',
|
|
190
|
+
});
|
|
191
|
+
expect(outcome.ok).toBe(false);
|
|
192
|
+
if (outcome.ok) {
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
expect(outcome.reason).toBe('synthetic-bundle');
|
|
196
|
+
expect(outcome.detail).toContain('synthetic:true');
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
test('refuses a claimed mode that contradicts the captured transition.from', () => {
|
|
200
|
+
const outcome = buildCatalogEntryCandidate(
|
|
201
|
+
{ bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA },
|
|
202
|
+
{ canonicalMode: 'mbim' },
|
|
203
|
+
);
|
|
204
|
+
expect(outcome).toMatchObject({ ok: false, reason: 'transition-mode-mismatch' });
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
test('refuses a router-mode SKU that carries a transition (schema invariant)', () => {
|
|
208
|
+
const outcome = buildCatalogEntryCandidate(
|
|
209
|
+
{ bundle: bundle({ transition: TRANSITION }), bundleSha256: SHA },
|
|
210
|
+
{ canonicalMode: 'router-ethernet' },
|
|
211
|
+
);
|
|
212
|
+
// The mode cross-check fires first; either refusal is correct, neither is an accept.
|
|
213
|
+
expect(outcome.ok).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
test('accepts a router-mode SKU with no transitions (the RB-15 shape)', () => {
|
|
217
|
+
const outcome = buildCatalogEntryCandidate(request(), { canonicalMode: 'router-ethernet' });
|
|
218
|
+
expect(outcome.ok).toBe(true);
|
|
219
|
+
if (outcome.ok) {
|
|
220
|
+
expect(outcome.value.canonicalMode).toBe('router-ethernet');
|
|
221
|
+
expect(outcome.value.permittedTransitions).toEqual([]);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test('refuses a malformed sha256 before reading the bundle at all', () => {
|
|
226
|
+
expect(parseIngestionRequest({ bundle: bundle(), bundleSha256: 'nope' })).toMatchObject({
|
|
227
|
+
ok: false,
|
|
228
|
+
reason: 'sha256-malformed',
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('refuses a bundle that fails the view schema', () => {
|
|
233
|
+
expect(
|
|
234
|
+
parseIngestionRequest({ bundle: { schemaVersion: 2 }, bundleSha256: SHA }),
|
|
235
|
+
).toMatchObject({ ok: false, reason: 'bundle-malformed' });
|
|
236
|
+
});
|
|
237
|
+
});
|
|
238
|
+
|
|
239
|
+
describe('renderPromotionReview — the review artifact, including for refusals', () => {
|
|
240
|
+
test('renders the entry, the fixture, and a checklist on success', () => {
|
|
241
|
+
const req = request();
|
|
242
|
+
const comment = renderPromotionReview({
|
|
243
|
+
context: { runbook: 'RB-11', evidencePath: 'test-results/modem-phase-b/08/x/bundle.json' },
|
|
244
|
+
entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }),
|
|
245
|
+
fixture: buildClassifierFixture(req),
|
|
246
|
+
});
|
|
247
|
+
expect(comment).toContain('Proposed `certified-catalog.json` entry');
|
|
248
|
+
expect(comment).toContain('Proposed classifier fixture');
|
|
249
|
+
expect(comment).toContain('Reviewer checklist');
|
|
250
|
+
expect(comment).toContain('RB-11');
|
|
251
|
+
expect(comment).toContain('**This comment promotes');
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
test('a refused promotion renders the refusal and NO checklist', () => {
|
|
255
|
+
const req = request({ synthetic: true });
|
|
256
|
+
const comment = renderPromotionReview({
|
|
257
|
+
context: { runbook: 'RB-11', evidencePath: 'x.json' },
|
|
258
|
+
entry: buildCatalogEntryCandidate(req, { canonicalMode: 'qmi' }),
|
|
259
|
+
fixture: buildClassifierFixture(req),
|
|
260
|
+
});
|
|
261
|
+
expect(comment).toContain('Catalog entry — REFUSED');
|
|
262
|
+
expect(comment).toContain('`synthetic-bundle`');
|
|
263
|
+
expect(comment).toContain('### No checklist');
|
|
264
|
+
expect(comment).not.toContain('Reviewer checklist');
|
|
265
|
+
// The fixture half still renders — synthetic fixtures are legitimate test data.
|
|
266
|
+
expect(comment).toContain('Derived from a **synthetic** bundle');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
// The evidence-bundle ingestion seam — turning ONE real `certify` bundle into (i) a
|
|
2
|
+
// classifier test fixture in the real udev shape and (ii) a candidate catalog entry.
|
|
3
|
+
//
|
|
4
|
+
// This is the documented path between the bench and the catalog. It is deliberately a
|
|
5
|
+
// PURE TRANSFORM that produces a REVIEW ARTIFACT: nothing here writes a file, mutates
|
|
6
|
+
// `certified-catalog.json`, or promotes anything. A catalog addition stays what Phase A
|
|
7
|
+
// made it — a human-reviewed commit — and this seam only removes the hand-transcription
|
|
8
|
+
// step between the bundle and that commit.
|
|
9
|
+
//
|
|
10
|
+
// THE ONE RULE THE CODE ENFORCES, NOT THE REVIEWER:
|
|
11
|
+
// A bundle marked `synthetic: true` is REFUSED for catalog promotion, with a typed
|
|
12
|
+
// reason. Synthetic bundles are legitimate test data — `buildClassifierFixture` accepts
|
|
13
|
+
// them and stamps the fixture's provenance with `synthetic: true` — but a catalog entry
|
|
14
|
+
// asserts a certified hardware fact, and no synthetic capture may ever back one.
|
|
15
|
+
//
|
|
16
|
+
// SHAPE COMPATIBILITY, not shape duplication: the bundle is validated here through a
|
|
17
|
+
// deliberately NON-strict VIEW schema. The authoritative bundle schema lives beside the
|
|
18
|
+
// `certify` command in the CLI, which depends on this package and not the reverse, so
|
|
19
|
+
// this file describes only the subset ingestion reads and ignores the rest (`lsusb`,
|
|
20
|
+
// `modemManager`, the transition timeline). Adding a field to the bundle can therefore
|
|
21
|
+
// never break ingestion — which is the point of a view.
|
|
22
|
+
|
|
23
|
+
import { z } from 'zod';
|
|
24
|
+
import type { UsbDeviceSnapshot } from '../backend/device-classifier';
|
|
25
|
+
import {
|
|
26
|
+
CANONICAL_USB_MODES,
|
|
27
|
+
type CanonicalUsbMode,
|
|
28
|
+
type CatalogEntry,
|
|
29
|
+
catalogEntrySchema,
|
|
30
|
+
expectedDescriptorsSchema,
|
|
31
|
+
MM_USB_MODES,
|
|
32
|
+
} from './catalog-schema';
|
|
33
|
+
import { parseUsbDevices, selectUniqueDevice } from './usb-devices-parse';
|
|
34
|
+
|
|
35
|
+
const mmMode = z.enum(MM_USB_MODES);
|
|
36
|
+
|
|
37
|
+
/** The ingestion VIEW of a certification bundle — non-strict on purpose (see header). */
|
|
38
|
+
export const evidenceBundleViewSchema = z.object({
|
|
39
|
+
schemaVersion: z.literal(1),
|
|
40
|
+
synthetic: z.boolean(),
|
|
41
|
+
capturedAtMs: z.number(),
|
|
42
|
+
slot: z.string().min(1),
|
|
43
|
+
sku: z
|
|
44
|
+
.object({
|
|
45
|
+
vidPid: z.string().regex(/^[0-9a-f]{4}:[0-9a-f]{4}$/),
|
|
46
|
+
model: z.string().min(1),
|
|
47
|
+
firmwarePrefix: z.string().min(1),
|
|
48
|
+
})
|
|
49
|
+
.optional(),
|
|
50
|
+
usb: z.object({
|
|
51
|
+
usbDevices: z.string().min(1),
|
|
52
|
+
udevProperties: z.record(z.string(), z.string()),
|
|
53
|
+
}),
|
|
54
|
+
transition: z
|
|
55
|
+
.object({
|
|
56
|
+
from: mmMode,
|
|
57
|
+
to: mmMode,
|
|
58
|
+
atCommand: z.string().min(1),
|
|
59
|
+
expectedResponse: z.string().min(1),
|
|
60
|
+
expectsPortDrop: z.boolean(),
|
|
61
|
+
afterDescriptors: expectedDescriptorsSchema,
|
|
62
|
+
})
|
|
63
|
+
.optional(),
|
|
64
|
+
});
|
|
65
|
+
export type EvidenceBundleView = z.infer<typeof evidenceBundleViewSchema>;
|
|
66
|
+
|
|
67
|
+
/** Every way ingestion can refuse. Each is a named, actionable condition — never a throw. */
|
|
68
|
+
export type IngestionRefusalReason =
|
|
69
|
+
| 'bundle-malformed'
|
|
70
|
+
| 'sha256-malformed'
|
|
71
|
+
| 'sku-missing'
|
|
72
|
+
| 'device-not-in-capture'
|
|
73
|
+
| 'device-ambiguous'
|
|
74
|
+
| 'no-interfaces-captured'
|
|
75
|
+
| 'synthetic-bundle'
|
|
76
|
+
| 'transition-mode-mismatch'
|
|
77
|
+
| 'entry-schema-invalid';
|
|
78
|
+
|
|
79
|
+
/** A typed refusal. `detail` is for a human reviewer; `reason` is for a machine. */
|
|
80
|
+
export interface IngestionRefusal {
|
|
81
|
+
readonly ok: false;
|
|
82
|
+
readonly reason: IngestionRefusalReason;
|
|
83
|
+
readonly detail: string;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** A refusal or a value — ingestion never throws and never returns a partial result. */
|
|
87
|
+
export type IngestionOutcome<T> = { readonly ok: true; readonly value: T } | IngestionRefusal;
|
|
88
|
+
|
|
89
|
+
const refuse = (reason: IngestionRefusalReason, detail: string): IngestionRefusal => ({
|
|
90
|
+
ok: false,
|
|
91
|
+
reason,
|
|
92
|
+
detail,
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
/** Where a fixture came from — stamped onto every fixture, honest about synthetic input. */
|
|
96
|
+
export interface FixtureProvenance {
|
|
97
|
+
readonly bundleSha256: string;
|
|
98
|
+
/** `true` when the source bundle was synthetic — such a fixture is test data only. */
|
|
99
|
+
readonly synthetic: boolean;
|
|
100
|
+
readonly slot: string;
|
|
101
|
+
readonly capturedAtMs: number;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** A classifier fixture: the snapshot `classifyDevice` consumes, plus its provenance. */
|
|
105
|
+
export interface ClassifierFixture {
|
|
106
|
+
readonly snapshot: UsbDeviceSnapshot;
|
|
107
|
+
readonly provenance: FixtureProvenance;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** One bundle plus the sha256 `certify` printed for it — the two halves are inseparable. */
|
|
111
|
+
export interface IngestionRequest {
|
|
112
|
+
/** The bundle JSON, already `JSON.parse`d. Validated here against the view schema. */
|
|
113
|
+
readonly bundle: unknown;
|
|
114
|
+
/** The `CERTIFY OK: sha256=…` value. Becomes the entry's `evidenceBundleSha256`. */
|
|
115
|
+
readonly bundleSha256: string;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The claim a REVIEWER makes about the SKU. `canonicalMode` is stated, never inferred:
|
|
120
|
+
* a machine reading descriptors could guess it, but a catalog entry is an assertion a
|
|
121
|
+
* human signs, and a stage-2 bundle's `transition.from` is cross-checked against it.
|
|
122
|
+
*/
|
|
123
|
+
export interface CatalogClaim {
|
|
124
|
+
readonly canonicalMode: CanonicalUsbMode;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const SHA256_RE = /^[0-9a-f]{64}$/;
|
|
128
|
+
|
|
129
|
+
/** Validate a request's bundle + sha, or refuse with a precise reason. */
|
|
130
|
+
export function parseIngestionRequest(
|
|
131
|
+
request: IngestionRequest,
|
|
132
|
+
): IngestionOutcome<EvidenceBundleView> {
|
|
133
|
+
if (!SHA256_RE.test(request.bundleSha256)) {
|
|
134
|
+
return refuse(
|
|
135
|
+
'sha256-malformed',
|
|
136
|
+
`bundle sha256 must be 64 lowercase hex characters, got '${request.bundleSha256}'`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
const parsed = evidenceBundleViewSchema.safeParse(request.bundle);
|
|
140
|
+
if (!parsed.success) {
|
|
141
|
+
return refuse('bundle-malformed', z.prettifyError(parsed.error));
|
|
142
|
+
}
|
|
143
|
+
return { ok: true, value: parsed.data };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Build a classifier test fixture from a bundle — the real udev shape, not a hand-typed
|
|
148
|
+
* approximation. Descriptors and per-interface DRIVERS come from the bundle's
|
|
149
|
+
* `usb-devices` text (the only structured descriptor source a base bundle carries);
|
|
150
|
+
* identity comes from the bundle's SKU; `physicalUid` / `ifname` come from the captured
|
|
151
|
+
* udev properties. A synthetic bundle is ACCEPTED here and the provenance says so.
|
|
152
|
+
*/
|
|
153
|
+
export function buildClassifierFixture(
|
|
154
|
+
request: IngestionRequest,
|
|
155
|
+
): IngestionOutcome<ClassifierFixture> {
|
|
156
|
+
const parsed = parseIngestionRequest(request);
|
|
157
|
+
if (!parsed.ok) {
|
|
158
|
+
return parsed;
|
|
159
|
+
}
|
|
160
|
+
const bundle = parsed.value;
|
|
161
|
+
const sku = bundle.sku;
|
|
162
|
+
if (sku === undefined) {
|
|
163
|
+
// Blocker B2 in `docs/BENCH.md` produces exactly this: an unmatched USB device
|
|
164
|
+
// yields a bundle with no SKU at all.
|
|
165
|
+
return refuse(
|
|
166
|
+
'sku-missing',
|
|
167
|
+
'bundle carries no `sku` — the capture did not match a USB device to the slot',
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
const selected = selectUniqueDevice(parseUsbDevices(bundle.usb.usbDevices), sku.vidPid);
|
|
172
|
+
if (!('device' in selected)) {
|
|
173
|
+
return selected.ambiguousMatches === 0
|
|
174
|
+
? refuse(
|
|
175
|
+
'device-not-in-capture',
|
|
176
|
+
`no device with vidPid ${sku.vidPid} in the bundle's usb-devices capture`,
|
|
177
|
+
)
|
|
178
|
+
: refuse(
|
|
179
|
+
'device-ambiguous',
|
|
180
|
+
`${selected.ambiguousMatches} devices share vidPid ${sku.vidPid} in this capture; a fixture must name one physical device`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
const device = selected.device;
|
|
184
|
+
if (device.interfaces.length === 0) {
|
|
185
|
+
return refuse(
|
|
186
|
+
'no-interfaces-captured',
|
|
187
|
+
`device ${sku.vidPid} has no parsed interface lines; a classifier fixture with no interfaces classifies nothing`,
|
|
188
|
+
);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const [vendorId, productId] = sku.vidPid.split(':') as [string, string];
|
|
192
|
+
const props = bundle.usb.udevProperties;
|
|
193
|
+
const physicalUid = props.ID_PATH;
|
|
194
|
+
const ifname = props.INTERFACE;
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
ok: true,
|
|
198
|
+
value: {
|
|
199
|
+
snapshot: {
|
|
200
|
+
vendorId,
|
|
201
|
+
productId,
|
|
202
|
+
model: sku.model,
|
|
203
|
+
firmwareRevision: sku.firmwarePrefix,
|
|
204
|
+
bDeviceClass: device.bDeviceClass,
|
|
205
|
+
interfaces: device.interfaces,
|
|
206
|
+
udevProperties: props,
|
|
207
|
+
...(physicalUid !== undefined ? { physicalUid } : {}),
|
|
208
|
+
...(ifname !== undefined ? { ifname } : {}),
|
|
209
|
+
},
|
|
210
|
+
provenance: {
|
|
211
|
+
bundleSha256: request.bundleSha256,
|
|
212
|
+
synthetic: bundle.synthetic,
|
|
213
|
+
slot: bundle.slot,
|
|
214
|
+
capturedAtMs: bundle.capturedAtMs,
|
|
215
|
+
},
|
|
216
|
+
},
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/**
|
|
221
|
+
* Build a CANDIDATE catalog entry from a bundle. The entry is a review artifact: it is
|
|
222
|
+
* returned, never written.
|
|
223
|
+
*
|
|
224
|
+
* REFUSES a `synthetic: true` bundle — a catalog entry asserts a certified hardware
|
|
225
|
+
* fact, so synthetic evidence can never back one (`docs/BENCH.md` Must-NOT-Have 7).
|
|
226
|
+
*
|
|
227
|
+
* A stage-1 (base) bundle yields `permittedTransitions: []`. A stage-2 bundle — one
|
|
228
|
+
* captured with `certify --transition` — yields exactly ONE permitted transition, whose
|
|
229
|
+
* `expectedDescriptors` is the captured `afterDescriptors` and whose
|
|
230
|
+
* `evidenceBundleSha256` is this bundle's hash. The reviewer's stated `canonicalMode`
|
|
231
|
+
* must equal the captured `transition.from`; a mismatch is refused rather than silently
|
|
232
|
+
* resolved in either direction.
|
|
233
|
+
*/
|
|
234
|
+
export function buildCatalogEntryCandidate(
|
|
235
|
+
request: IngestionRequest,
|
|
236
|
+
claim: CatalogClaim,
|
|
237
|
+
): IngestionOutcome<CatalogEntry> {
|
|
238
|
+
const parsed = parseIngestionRequest(request);
|
|
239
|
+
if (!parsed.ok) {
|
|
240
|
+
return parsed;
|
|
241
|
+
}
|
|
242
|
+
const bundle = parsed.value;
|
|
243
|
+
if (bundle.synthetic) {
|
|
244
|
+
return refuse(
|
|
245
|
+
'synthetic-bundle',
|
|
246
|
+
`bundle for slot '${bundle.slot}' is marked synthetic:true; a catalog entry requires a real capture (synthetic:false)`,
|
|
247
|
+
);
|
|
248
|
+
}
|
|
249
|
+
const sku = bundle.sku;
|
|
250
|
+
if (sku === undefined) {
|
|
251
|
+
return refuse(
|
|
252
|
+
'sku-missing',
|
|
253
|
+
'bundle carries no `sku` — a catalog entry needs all three discriminators (vidPid, model, firmwarePrefix)',
|
|
254
|
+
);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
const transition = bundle.transition;
|
|
258
|
+
if (transition !== undefined && transition.from !== claim.canonicalMode) {
|
|
259
|
+
return refuse(
|
|
260
|
+
'transition-mode-mismatch',
|
|
261
|
+
`claimed canonicalMode '${claim.canonicalMode}' contradicts the captured transition.from '${transition.from}'`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const candidate = {
|
|
266
|
+
vidPid: sku.vidPid,
|
|
267
|
+
model: sku.model,
|
|
268
|
+
firmwarePrefix: sku.firmwarePrefix,
|
|
269
|
+
canonicalMode: claim.canonicalMode,
|
|
270
|
+
permittedTransitions:
|
|
271
|
+
transition === undefined
|
|
272
|
+
? []
|
|
273
|
+
: [
|
|
274
|
+
{
|
|
275
|
+
from: transition.from,
|
|
276
|
+
to: transition.to,
|
|
277
|
+
atCommand: transition.atCommand,
|
|
278
|
+
expectedResponse: transition.expectedResponse,
|
|
279
|
+
expectsPortDrop: transition.expectsPortDrop,
|
|
280
|
+
expectedDescriptors: transition.afterDescriptors,
|
|
281
|
+
evidenceBundleSha256: request.bundleSha256,
|
|
282
|
+
},
|
|
283
|
+
],
|
|
284
|
+
};
|
|
285
|
+
|
|
286
|
+
// The candidate is re-validated through the AUTHORITATIVE entry schema, so an
|
|
287
|
+
// impossible combination (a router-mode SKU declaring a transition, say) is refused
|
|
288
|
+
// here rather than at review time.
|
|
289
|
+
const entry = catalogEntrySchema.safeParse(candidate);
|
|
290
|
+
if (!entry.success) {
|
|
291
|
+
return refuse('entry-schema-invalid', z.prettifyError(entry.error));
|
|
292
|
+
}
|
|
293
|
+
return { ok: true, value: entry.data };
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
/** The canonical-mode vocabulary a reviewer's claim may use — re-exported for callers. */
|
|
297
|
+
export const CLAIMABLE_CANONICAL_MODES = CANONICAL_USB_MODES;
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Rendering the REVIEW ARTIFACT for a catalog promotion — the PR-comment template.
|
|
2
|
+
//
|
|
3
|
+
// Catalog additions are human-reviewed commits (Phase-A rule). This module renders what
|
|
4
|
+
// a reviewer reads: the proposed entry, the classifier fixture derived from the same
|
|
5
|
+
// bundle, and a checklist whose boxes a machine cannot tick. It renders a REFUSAL with
|
|
6
|
+
// equal prominence — a refused promotion produces a comment that says so, never silence,
|
|
7
|
+
// because a silently-absent comment is indistinguishable from a forgotten run.
|
|
8
|
+
//
|
|
9
|
+
// Nothing here writes a file or opens a PR. The output is text.
|
|
10
|
+
|
|
11
|
+
import type { CatalogEntry } from './catalog-schema';
|
|
12
|
+
import type { ClassifierFixture, IngestionOutcome, IngestionRefusal } from './ingestion';
|
|
13
|
+
|
|
14
|
+
/** Everything the rendered comment needs beyond the two ingestion outcomes. */
|
|
15
|
+
export interface PromotionContext {
|
|
16
|
+
/** The runbook that captured the bundle (`RB-11` …) — the evidence's provenance. */
|
|
17
|
+
readonly runbook: string;
|
|
18
|
+
/** The repo-local evidence path the bundle was written to. */
|
|
19
|
+
readonly evidencePath: string;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A promotion request: the two ingestion outcomes plus where the evidence came from. */
|
|
23
|
+
export interface PromotionRequest {
|
|
24
|
+
readonly context: PromotionContext;
|
|
25
|
+
readonly entry: IngestionOutcome<CatalogEntry>;
|
|
26
|
+
readonly fixture: IngestionOutcome<ClassifierFixture>;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function refusalBlock(what: string, refusal: IngestionRefusal): string {
|
|
30
|
+
return [
|
|
31
|
+
`### ❌ ${what} — REFUSED`,
|
|
32
|
+
'',
|
|
33
|
+
`**Reason:** \`${refusal.reason}\``,
|
|
34
|
+
'',
|
|
35
|
+
`> ${refusal.detail}`,
|
|
36
|
+
'',
|
|
37
|
+
'This is a typed refusal from the ingestion seam, not a review opinion. Fix the',
|
|
38
|
+
'capture and re-run the runbook; do not hand-author the artifact around it.',
|
|
39
|
+
].join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function entryBlock(entry: CatalogEntry): string {
|
|
43
|
+
return [
|
|
44
|
+
'### Proposed `certified-catalog.json` entry',
|
|
45
|
+
'',
|
|
46
|
+
'```json',
|
|
47
|
+
JSON.stringify(entry, null, 2),
|
|
48
|
+
'```',
|
|
49
|
+
].join('\n');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function fixtureBlock(fixture: ClassifierFixture): string {
|
|
53
|
+
const { snapshot, provenance } = fixture;
|
|
54
|
+
const syntheticNote = provenance.synthetic
|
|
55
|
+
? '> ⚠️ Derived from a **synthetic** bundle — valid as test data, never as certification evidence.'
|
|
56
|
+
: `> Derived from a real capture, bundle sha256 \`${provenance.bundleSha256}\`.`;
|
|
57
|
+
return [
|
|
58
|
+
'### Proposed classifier fixture (`control/src/backend/device-classifier.test.ts`)',
|
|
59
|
+
'',
|
|
60
|
+
syntheticNote,
|
|
61
|
+
'',
|
|
62
|
+
'```ts',
|
|
63
|
+
`const FIXTURE: UsbDeviceSnapshot = ${JSON.stringify(snapshot, null, 2)};`,
|
|
64
|
+
'```',
|
|
65
|
+
].join('\n');
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function checklistBlock(context: PromotionContext, entry: CatalogEntry): string {
|
|
69
|
+
const transitions = entry.permittedTransitions.length;
|
|
70
|
+
return [
|
|
71
|
+
'### Reviewer checklist (every box is a human judgement)',
|
|
72
|
+
'',
|
|
73
|
+
`- [ ] The bundle at \`${context.evidencePath}\` was captured by **${context.runbook}** on real hardware, and its \`CERTIFY OK\` line reads \`synthetic=false\`.`,
|
|
74
|
+
'- [ ] The bundle sha256 in the entry matches the sha256 the capture printed — recomputed, not copied from this comment.',
|
|
75
|
+
`- [ ] \`canonicalMode: "${entry.canonicalMode}"\` is the mode the device was actually observed in, not the mode it was expected to be in.`,
|
|
76
|
+
transitions === 0
|
|
77
|
+
? '- [ ] `permittedTransitions: []` is correct for this stage — a stage-1 entry never declares a transition.'
|
|
78
|
+
: '- [ ] The declared transition was OBSERVED end to end: the AT command executed, the port dropped if `expectsPortDrop`, and the device re-enumerated presenting `expectedDescriptors`.',
|
|
79
|
+
'- [ ] No claim in `docs/MODEM-SUPPORT-MATRIX.md` is being changed by this commit without its own evidence.',
|
|
80
|
+
].join('\n');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Render the review comment for a promotion request. Always returns a comment: a
|
|
85
|
+
* refusal renders a refusal block, so a run that produced nothing promotable still
|
|
86
|
+
* leaves a visible, auditable trace.
|
|
87
|
+
*/
|
|
88
|
+
export function renderPromotionReview(request: PromotionRequest): string {
|
|
89
|
+
const { context, entry, fixture } = request;
|
|
90
|
+
const parts: string[] = [
|
|
91
|
+
`## Catalog promotion review — ${context.runbook}`,
|
|
92
|
+
'',
|
|
93
|
+
`Evidence: \`${context.evidencePath}\``,
|
|
94
|
+
'',
|
|
95
|
+
'Generated by the `control/src/usb-mode/` ingestion seam. **This comment promotes',
|
|
96
|
+
'nothing** — the promotion is the human-reviewed commit that follows it.',
|
|
97
|
+
'',
|
|
98
|
+
];
|
|
99
|
+
|
|
100
|
+
parts.push(entry.ok ? entryBlock(entry.value) : refusalBlock('Catalog entry', entry));
|
|
101
|
+
parts.push('');
|
|
102
|
+
parts.push(
|
|
103
|
+
fixture.ok ? fixtureBlock(fixture.value) : refusalBlock('Classifier fixture', fixture),
|
|
104
|
+
);
|
|
105
|
+
parts.push('');
|
|
106
|
+
if (entry.ok) {
|
|
107
|
+
parts.push(checklistBlock(context, entry.value));
|
|
108
|
+
} else {
|
|
109
|
+
parts.push(
|
|
110
|
+
'### No checklist',
|
|
111
|
+
'',
|
|
112
|
+
'The catalog entry was refused, so there is nothing to review. A checklist here',
|
|
113
|
+
'would invite a reviewer to approve an artifact that does not exist.',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
return `${parts.join('\n')}\n`;
|
|
117
|
+
}
|