@ikenga/contract 0.18.1 → 0.19.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/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/dist/manifest.d.ts +1803 -37
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +125 -3
- package/dist/manifest.js.map +1 -1
- package/dist/ngwa.d.ts +2 -2
- package/dist/registry.d.ts +1307 -35
- package/dist/registry.d.ts.map +1 -1
- package/dist/window.d.ts +5 -5
- package/package.json +1 -1
- package/schemas/registry/pkg-detail-v1.json +532 -11
- package/src/__fixtures__/manifest-v5/README.md +23 -0
- package/src/__fixtures__/manifest-v5/alias/nav-only.json +16 -0
- package/src/__fixtures__/manifest-v5/invalid/companion-panel-missing-route.json +11 -0
- package/src/__fixtures__/manifest-v5/invalid/context-action-bad-run.json +16 -0
- package/src/__fixtures__/manifest-v5/invalid/context-selector-bad-kind.json +16 -0
- package/src/__fixtures__/manifest-v5/invalid/explorer-section-missing-data-route.json +11 -0
- package/src/__fixtures__/manifest-v5/invalid/side-pane-viewers-empty.json +9 -0
- package/src/__fixtures__/manifest-v5/invalid/side-pane-viewers.json +14 -0
- package/src/__fixtures__/manifest-v5/invalid/view-route-no-routes.json +11 -0
- package/src/__fixtures__/manifest-v5/invalid/view-route-undeclared.json +15 -0
- package/src/__fixtures__/manifest-v5/invalid/view-unknown-field.json +14 -0
- package/src/__fixtures__/manifest-v5/invalid/widget-bad-span.json +14 -0
- package/src/__fixtures__/manifest-v5/valid/companion-panels.json +26 -0
- package/src/__fixtures__/manifest-v5/valid/context-actions.json +41 -0
- package/src/__fixtures__/manifest-v5/valid/explorer-sections.json +31 -0
- package/src/__fixtures__/manifest-v5/valid/full.json +42 -0
- package/src/__fixtures__/manifest-v5/valid/minimal.json +6 -0
- package/src/__fixtures__/manifest-v5/valid/nav-and-views.json +17 -0
- package/src/__fixtures__/manifest-v5/valid/v4-manifest.json +20 -0
- package/src/__fixtures__/manifest-v5/valid/views.json +26 -0
- package/src/__fixtures__/manifest-v5/valid/widgets.json +18 -0
- package/src/index.ts +1 -1
- package/src/manifest.test.ts +350 -2
- package/src/manifest.ts +143 -3
package/src/manifest.test.ts
CHANGED
|
@@ -1,15 +1,25 @@
|
|
|
1
1
|
import { test } from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
3
4
|
|
|
4
5
|
import {
|
|
6
|
+
CompanionPanelEntrySchema,
|
|
7
|
+
ContextActionEntrySchema,
|
|
8
|
+
ContextActionRunSchema,
|
|
9
|
+
ContextSelectorSchema,
|
|
10
|
+
ExplorerSectionDataSchema,
|
|
11
|
+
ExplorerSectionEntrySchema,
|
|
5
12
|
HttpCapabilitySchema,
|
|
6
13
|
IKENGA_API_VERSION,
|
|
7
14
|
InvokeCapabilitySchema,
|
|
15
|
+
isCompatible,
|
|
8
16
|
ManifestSchema,
|
|
9
17
|
NamedSecretSchema,
|
|
10
18
|
RequiresEntrySchema,
|
|
11
19
|
RequireSourceSchema,
|
|
12
20
|
SecretsCapabilitySchema,
|
|
21
|
+
ViewEntrySchema,
|
|
22
|
+
WidgetEntrySchema,
|
|
13
23
|
} from './manifest.js';
|
|
14
24
|
|
|
15
25
|
// ─── WP-11 — `requires` field (ADR-015 §3) ──────────────────────────────────
|
|
@@ -89,8 +99,8 @@ test('Manifest: requires defaults to [] when absent (pre-Phase-4 manifest)', ()
|
|
|
89
99
|
|
|
90
100
|
// ─── WP-01 — trusted-cap tier (ADR-017): http / secrets / invoke + signature ─
|
|
91
101
|
|
|
92
|
-
test('IKENGA_API_VERSION is
|
|
93
|
-
assert.equal(IKENGA_API_VERSION,
|
|
102
|
+
test('IKENGA_API_VERSION is 5 (WP-27 Manifest v5)', () => {
|
|
103
|
+
assert.equal(IKENGA_API_VERSION, 5);
|
|
94
104
|
});
|
|
95
105
|
|
|
96
106
|
test('HttpCapability: auth_header defaults to Authorization; auth_secret optional', () => {
|
|
@@ -262,3 +272,341 @@ test('Manifest: capabilities.sqlite accepts object config', () => {
|
|
|
262
272
|
});
|
|
263
273
|
assert.deepEqual(m.capabilities?.sqlite, { db: 'custom.db' });
|
|
264
274
|
});
|
|
275
|
+
|
|
276
|
+
// ─── WP-27 — manifest v5 contribution blocks (G-MANIFEST-V5, frozen §2) ─────
|
|
277
|
+
|
|
278
|
+
test('isCompatible: window is [1, 5] after the v5 bump', () => {
|
|
279
|
+
assert.equal(isCompatible('1'), true);
|
|
280
|
+
assert.equal(isCompatible('4'), true);
|
|
281
|
+
assert.equal(isCompatible('5'), true);
|
|
282
|
+
assert.equal(isCompatible('6'), false);
|
|
283
|
+
assert.equal(isCompatible('0'), false);
|
|
284
|
+
assert.equal(isCompatible('abc'), false);
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
test('ViewEntry: full shape parses; pin_on_install defaults false', () => {
|
|
288
|
+
const v = ViewEntrySchema.parse({
|
|
289
|
+
id: 'grid',
|
|
290
|
+
title: 'Grid',
|
|
291
|
+
icon: 'layout-grid',
|
|
292
|
+
route: '/grid',
|
|
293
|
+
pin_on_install: true,
|
|
294
|
+
});
|
|
295
|
+
assert.equal(v.pin_on_install, true);
|
|
296
|
+
const minimal = ViewEntrySchema.parse({ id: 'd', title: 'D', route: '/d' });
|
|
297
|
+
assert.equal(minimal.pin_on_install, false);
|
|
298
|
+
assert.equal(minimal.icon, undefined);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
test('ViewEntry: .strict rejects unknown field (mirrors Rust deny_unknown_fields)', () => {
|
|
302
|
+
assert.throws(() =>
|
|
303
|
+
ViewEntrySchema.parse({ id: 'x', title: 'X', route: '/x', label: 'nope' }),
|
|
304
|
+
);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
test('ExplorerSectionEntry: full shape parses; order optional int', () => {
|
|
308
|
+
const e = ExplorerSectionEntrySchema.parse({
|
|
309
|
+
id: 'open-tasks',
|
|
310
|
+
title: 'Open Tasks',
|
|
311
|
+
icon: 'check-circle',
|
|
312
|
+
order: 10,
|
|
313
|
+
data_route: '/pkg/com.ikenga.x/sections/open-tasks',
|
|
314
|
+
});
|
|
315
|
+
assert.equal(e.order, 10);
|
|
316
|
+
const minimal = ExplorerSectionEntrySchema.parse({
|
|
317
|
+
id: 's',
|
|
318
|
+
title: 'S',
|
|
319
|
+
data_route: '/pkg/com.ikenga.x/s',
|
|
320
|
+
});
|
|
321
|
+
assert.equal(minimal.order, undefined);
|
|
322
|
+
assert.equal(minimal.icon, undefined);
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('ExplorerSectionEntry: missing data_route fails; .strict rejects extras; non-int order fails', () => {
|
|
326
|
+
assert.throws(() =>
|
|
327
|
+
ExplorerSectionEntrySchema.parse({ id: 's', title: 'S' }),
|
|
328
|
+
);
|
|
329
|
+
assert.throws(() =>
|
|
330
|
+
ExplorerSectionEntrySchema.parse({ id: 's', title: 'S', data_route: '/p', bogus: 1 }),
|
|
331
|
+
);
|
|
332
|
+
assert.throws(() =>
|
|
333
|
+
ExplorerSectionEntrySchema.parse({ id: 's', title: 'S', data_route: '/p', order: 1.5 }),
|
|
334
|
+
);
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('ExplorerSectionData: rows default []; full wire shape parses', () => {
|
|
338
|
+
const empty = ExplorerSectionDataSchema.parse({});
|
|
339
|
+
assert.deepEqual(empty.rows, []);
|
|
340
|
+
const d = ExplorerSectionDataSchema.parse({
|
|
341
|
+
rows: [
|
|
342
|
+
{
|
|
343
|
+
id: 'r1',
|
|
344
|
+
label: 'Row 1',
|
|
345
|
+
badge: { count: 3, tooltip: 'three' },
|
|
346
|
+
open: 'pkg://com.ikenga.x/detail',
|
|
347
|
+
},
|
|
348
|
+
{ id: 'r2', label: 'Row 2' },
|
|
349
|
+
],
|
|
350
|
+
as_of_ms: 1727000000000,
|
|
351
|
+
});
|
|
352
|
+
assert.equal(d.rows.length, 2);
|
|
353
|
+
assert.equal(d.rows[0]?.badge?.count, 3);
|
|
354
|
+
assert.equal(d.as_of_ms, 1727000000000);
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
test('ExplorerSectionData: .strict rejects unknown top-level field', () => {
|
|
358
|
+
assert.throws(() => ExplorerSectionDataSchema.parse({ rows: [], bogus: true }));
|
|
359
|
+
});
|
|
360
|
+
|
|
361
|
+
test('CompanionPanelEntry: full shape parses; session_scoped defaults false', () => {
|
|
362
|
+
const p = CompanionPanelEntrySchema.parse({
|
|
363
|
+
id: 'state',
|
|
364
|
+
title: 'State',
|
|
365
|
+
icon: 'activity',
|
|
366
|
+
route: '/state',
|
|
367
|
+
session_scoped: true,
|
|
368
|
+
});
|
|
369
|
+
assert.equal(p.session_scoped, true);
|
|
370
|
+
const minimal = CompanionPanelEntrySchema.parse({ id: 's', title: 'S', route: '/s' });
|
|
371
|
+
assert.equal(minimal.session_scoped, false);
|
|
372
|
+
});
|
|
373
|
+
|
|
374
|
+
test('CompanionPanelEntry: missing route fails; .strict rejects extras', () => {
|
|
375
|
+
assert.throws(() =>
|
|
376
|
+
CompanionPanelEntrySchema.parse({ id: 's', title: 'S' }),
|
|
377
|
+
);
|
|
378
|
+
assert.throws(() =>
|
|
379
|
+
CompanionPanelEntrySchema.parse({ id: 's', title: 'S', route: '/s', bogus: 1 }),
|
|
380
|
+
);
|
|
381
|
+
});
|
|
382
|
+
|
|
383
|
+
test('ContextSelector: all four kinds parse; unknown kind fails', () => {
|
|
384
|
+
assert.equal(ContextSelectorSchema.parse({ kind: 'file' }).kind, 'file');
|
|
385
|
+
assert.equal(
|
|
386
|
+
ContextSelectorSchema.parse({ kind: 'file', glob: '*.md' }).glob,
|
|
387
|
+
'*.md',
|
|
388
|
+
);
|
|
389
|
+
assert.equal(ContextSelectorSchema.parse({ kind: 'artifact' }).kind, 'artifact');
|
|
390
|
+
assert.equal(ContextSelectorSchema.parse({ kind: 'session' }).kind, 'session');
|
|
391
|
+
const n = ContextSelectorSchema.parse({ kind: 'ngwa-item', kinds: ['task'] });
|
|
392
|
+
assert.equal(n.kind, 'ngwa-item');
|
|
393
|
+
assert.throws(() => ContextSelectorSchema.parse({ kind: 'project' }));
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test('ContextActionRun: dispatch + view variants parse; unknown kind fails', () => {
|
|
397
|
+
const d = ContextActionRunSchema.parse({
|
|
398
|
+
kind: 'dispatch',
|
|
399
|
+
prompt: 'Review {{file.path}}',
|
|
400
|
+
target: 'chi',
|
|
401
|
+
});
|
|
402
|
+
assert.equal(d.kind, 'dispatch');
|
|
403
|
+
const dMinimal = ContextActionRunSchema.parse({ kind: 'dispatch', prompt: 'p' });
|
|
404
|
+
assert.equal(dMinimal.target, undefined);
|
|
405
|
+
const v = ContextActionRunSchema.parse({ kind: 'view', route: '/review' });
|
|
406
|
+
assert.equal(v.kind, 'view');
|
|
407
|
+
assert.throws(() =>
|
|
408
|
+
ContextActionRunSchema.parse({ kind: 'shell', command: 'x' }),
|
|
409
|
+
);
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test('ContextActionEntry: full shape parses; missing when/run fails; .strict rejects extras', () => {
|
|
413
|
+
const a = ContextActionEntrySchema.parse({
|
|
414
|
+
id: 'hand-to-chi',
|
|
415
|
+
label: 'Hand to Chi',
|
|
416
|
+
when: { kind: 'file', glob: '*.md' },
|
|
417
|
+
run: { kind: 'dispatch', prompt: 'Handle {{file.path}}' },
|
|
418
|
+
});
|
|
419
|
+
assert.equal(a.id, 'hand-to-chi');
|
|
420
|
+
assert.throws(() =>
|
|
421
|
+
ContextActionEntrySchema.parse({
|
|
422
|
+
id: 'x',
|
|
423
|
+
label: 'X',
|
|
424
|
+
run: { kind: 'view', route: '/x' },
|
|
425
|
+
}),
|
|
426
|
+
);
|
|
427
|
+
assert.throws(() =>
|
|
428
|
+
ContextActionEntrySchema.parse({
|
|
429
|
+
id: 'x',
|
|
430
|
+
label: 'X',
|
|
431
|
+
when: { kind: 'session' },
|
|
432
|
+
run: { kind: 'view', route: '/x' },
|
|
433
|
+
bogus: true,
|
|
434
|
+
}),
|
|
435
|
+
);
|
|
436
|
+
});
|
|
437
|
+
|
|
438
|
+
test('WidgetEntry: span defaults medium; small|medium|wide parse; other spans fail', () => {
|
|
439
|
+
const w = WidgetEntrySchema.parse({ id: 'w', title: 'W', route: '/w' });
|
|
440
|
+
assert.equal(w.span, 'medium');
|
|
441
|
+
for (const span of ['small', 'medium', 'wide'] as const) {
|
|
442
|
+
assert.equal(
|
|
443
|
+
WidgetEntrySchema.parse({ id: 'w', title: 'W', route: '/w', span }).span,
|
|
444
|
+
span,
|
|
445
|
+
);
|
|
446
|
+
}
|
|
447
|
+
assert.throws(() =>
|
|
448
|
+
WidgetEntrySchema.parse({ id: 'w', title: 'W', route: '/w', span: 'large' }),
|
|
449
|
+
);
|
|
450
|
+
assert.throws(() =>
|
|
451
|
+
WidgetEntrySchema.parse({ id: 'w', title: 'W', route: '/w', bogus: 1 }),
|
|
452
|
+
);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('Manifest v5: ui gains all five contribution blocks with [] defaults', () => {
|
|
456
|
+
const m = ManifestSchema.parse({ ...BASE, ikenga_api: '5' });
|
|
457
|
+
assert.deepEqual(m.ui.views, []);
|
|
458
|
+
assert.deepEqual(m.ui.explorer_sections, []);
|
|
459
|
+
assert.deepEqual(m.ui.companion_panels, []);
|
|
460
|
+
assert.deepEqual(m.ui.context_actions, []);
|
|
461
|
+
assert.deepEqual(m.ui.widgets, []);
|
|
462
|
+
});
|
|
463
|
+
|
|
464
|
+
test('Manifest v5: ui.side_pane_viewers fails to parse (DEC-34 Q1 hard-retire)', () => {
|
|
465
|
+
assert.throws(() =>
|
|
466
|
+
ManifestSchema.parse({
|
|
467
|
+
...BASE,
|
|
468
|
+
ui: {
|
|
469
|
+
routes: [{ path: '/v', kind: 'iframe', source: 'v.html' }],
|
|
470
|
+
side_pane_viewers: [{ id: 'v', label: 'V', route: '/v' }],
|
|
471
|
+
},
|
|
472
|
+
}),
|
|
473
|
+
);
|
|
474
|
+
// Even an empty array is a declaration — the field itself is retired.
|
|
475
|
+
assert.throws(() =>
|
|
476
|
+
ManifestSchema.parse({ ...BASE, ui: { side_pane_viewers: [] } }),
|
|
477
|
+
);
|
|
478
|
+
});
|
|
479
|
+
|
|
480
|
+
test('Manifest v5: views[].route not in ui.routes[] fails via superRefine, naming the path', () => {
|
|
481
|
+
const r = ManifestSchema.safeParse({
|
|
482
|
+
...BASE,
|
|
483
|
+
ui: {
|
|
484
|
+
routes: [{ path: '/grid', kind: 'iframe', source: 'grid.html' }],
|
|
485
|
+
views: [
|
|
486
|
+
{ id: 'grid', title: 'Grid', route: '/grid' },
|
|
487
|
+
{ id: 'missing', title: 'Missing', route: '/not-declared' },
|
|
488
|
+
],
|
|
489
|
+
},
|
|
490
|
+
});
|
|
491
|
+
assert.equal(r.success, false);
|
|
492
|
+
if (!r.success) {
|
|
493
|
+
const issue = r.error.issues.find((i) => i.code === 'custom');
|
|
494
|
+
assert.ok(issue, 'expected a custom (superRefine) issue');
|
|
495
|
+
assert.deepEqual(issue.path, ['ui', 'views', 1, 'route']);
|
|
496
|
+
assert.ok(issue.message.includes('/not-declared'));
|
|
497
|
+
}
|
|
498
|
+
});
|
|
499
|
+
|
|
500
|
+
test('Manifest v5: views[] referencing every declared route parses (§2b)', () => {
|
|
501
|
+
const m = ManifestSchema.parse({
|
|
502
|
+
...BASE,
|
|
503
|
+
ui: {
|
|
504
|
+
routes: [
|
|
505
|
+
{ path: '/a', kind: 'iframe', source: 'a.html' },
|
|
506
|
+
{ path: '/b', kind: 'iframe', source: 'b.html' },
|
|
507
|
+
],
|
|
508
|
+
views: [
|
|
509
|
+
{ id: 'a', title: 'A', route: '/a', pin_on_install: true },
|
|
510
|
+
{ id: 'b', title: 'B', route: '/b' },
|
|
511
|
+
],
|
|
512
|
+
},
|
|
513
|
+
});
|
|
514
|
+
assert.equal(m.ui.views.length, 2);
|
|
515
|
+
assert.equal(m.ui.views[0]?.pin_on_install, true);
|
|
516
|
+
});
|
|
517
|
+
|
|
518
|
+
test('Manifest v5: companion-panel and widget routes are NOT reference-checked (§2b scopes to views)', () => {
|
|
519
|
+
const m = ManifestSchema.parse({
|
|
520
|
+
...BASE,
|
|
521
|
+
ui: {
|
|
522
|
+
routes: [],
|
|
523
|
+
companion_panels: [{ id: 'p', title: 'P', route: '/unlisted' }],
|
|
524
|
+
widgets: [{ id: 'w', title: 'W', route: '/also-unlisted' }],
|
|
525
|
+
},
|
|
526
|
+
});
|
|
527
|
+
assert.equal(m.ui.companion_panels.length, 1);
|
|
528
|
+
assert.equal(m.ui.widgets.length, 1);
|
|
529
|
+
});
|
|
530
|
+
|
|
531
|
+
test('Manifest v5 alias: ui.nav-only manifest parses; nav kept, views default []', () => {
|
|
532
|
+
const m = ManifestSchema.parse({
|
|
533
|
+
...BASE,
|
|
534
|
+
ikenga_api: '1',
|
|
535
|
+
ui: {
|
|
536
|
+
routes: [{ path: '/home', kind: 'iframe', source: 'home.html' }],
|
|
537
|
+
nav: [{ id: 'home', label: 'Home', icon: 'home', route: '/home' }],
|
|
538
|
+
},
|
|
539
|
+
});
|
|
540
|
+
assert.equal(m.ui.nav.length, 1);
|
|
541
|
+
assert.equal(m.ui.nav[0]?.label, 'Home');
|
|
542
|
+
assert.deepEqual(m.ui.views, []);
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('Manifest v5 alias: nav + views both declared still parses (§4 — nav ignored shell-side)', () => {
|
|
546
|
+
const m = ManifestSchema.parse({
|
|
547
|
+
...BASE,
|
|
548
|
+
ui: {
|
|
549
|
+
routes: [{ path: '/grid', kind: 'iframe', source: 'grid.html' }],
|
|
550
|
+
nav: [{ id: 'legacy', label: 'Legacy', route: '/grid' }],
|
|
551
|
+
views: [{ id: 'grid', title: 'Grid', route: '/grid' }],
|
|
552
|
+
},
|
|
553
|
+
});
|
|
554
|
+
assert.equal(m.ui.nav.length, 1);
|
|
555
|
+
assert.equal(m.ui.views.length, 1);
|
|
556
|
+
});
|
|
557
|
+
|
|
558
|
+
// ─── WP-27 — manifest-v5 fixture sweep (PRODUCES: src/__fixtures__/manifest-v5) ─
|
|
559
|
+
// Verdict-by-folder contract consumed by WP-28's Rust parity test:
|
|
560
|
+
// valid/ parses · invalid/ fails · alias/ parses (shell applies the §4 mapping).
|
|
561
|
+
|
|
562
|
+
const V5_FIXTURES = new URL('./__fixtures__/manifest-v5/', import.meta.url);
|
|
563
|
+
|
|
564
|
+
function fixtureNames(dir: 'valid' | 'invalid' | 'alias'): string[] {
|
|
565
|
+
return readdirSync(new URL(`${dir}/`, V5_FIXTURES))
|
|
566
|
+
.filter((f) => f.endsWith('.json'))
|
|
567
|
+
.sort();
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
function readFixture(dir: 'valid' | 'invalid' | 'alias', name: string): unknown {
|
|
571
|
+
return JSON.parse(readFileSync(new URL(`${dir}/${name}`, V5_FIXTURES), 'utf8'));
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
test('manifest-v5 fixtures: every valid/* parses', () => {
|
|
575
|
+
const names = fixtureNames('valid');
|
|
576
|
+
assert.ok(names.length > 0, 'expected at least one valid fixture');
|
|
577
|
+
for (const name of names) {
|
|
578
|
+
const r = ManifestSchema.safeParse(readFixture('valid', name));
|
|
579
|
+
assert.ok(
|
|
580
|
+
r.success,
|
|
581
|
+
`valid/${name} should parse: ${r.success ? '' : JSON.stringify(r.error.issues)}`,
|
|
582
|
+
);
|
|
583
|
+
}
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test('manifest-v5 fixtures: every invalid/* fails to parse', () => {
|
|
587
|
+
const names = fixtureNames('invalid');
|
|
588
|
+
assert.ok(names.length > 0, 'expected at least one invalid fixture');
|
|
589
|
+
for (const name of names) {
|
|
590
|
+
const r = ManifestSchema.safeParse(readFixture('invalid', name));
|
|
591
|
+
assert.equal(r.success, false, `invalid/${name} should fail to parse`);
|
|
592
|
+
}
|
|
593
|
+
});
|
|
594
|
+
|
|
595
|
+
test('manifest-v5 fixtures: every alias/* parses (nav→views is shell-side, §4)', () => {
|
|
596
|
+
const names = fixtureNames('alias');
|
|
597
|
+
assert.ok(names.length > 0, 'expected at least one alias fixture');
|
|
598
|
+
for (const name of names) {
|
|
599
|
+
const r = ManifestSchema.safeParse(readFixture('alias', name));
|
|
600
|
+
assert.ok(
|
|
601
|
+
r.success,
|
|
602
|
+
`alias/${name} should parse: ${r.success ? '' : JSON.stringify(r.error.issues)}`,
|
|
603
|
+
);
|
|
604
|
+
}
|
|
605
|
+
});
|
|
606
|
+
|
|
607
|
+
test('manifest-v5 fixtures: alias/nav-only keeps nav populated for the shell-side mapping', () => {
|
|
608
|
+
const m = ManifestSchema.parse(readFixture('alias', 'nav-only.json'));
|
|
609
|
+
assert.equal(m.ui.nav.length, 2);
|
|
610
|
+
assert.equal(m.ui.nav[0]?.route, '/home');
|
|
611
|
+
assert.deepEqual(m.ui.views, []);
|
|
612
|
+
});
|
package/src/manifest.ts
CHANGED
|
@@ -17,7 +17,14 @@ import { BrowserEngineSchema } from './browser.js';
|
|
|
17
17
|
// v3 (ADR-017): added capabilities.http / .secrets / .invoke (trusted-cap tier)
|
|
18
18
|
// + top-level optional `signature`. All additive; api=1/2 manifests parse
|
|
19
19
|
// unchanged. Elevated caps are inert unless the pkg is trusted.
|
|
20
|
-
|
|
20
|
+
// v5 (WP-27, G-MANIFEST-V5 — plans/shell-ux-rearchitecture/drafts/g-manifest-v5.md,
|
|
21
|
+
// frozen 2026-09-22 Round 24): added ui.views[] / ui.explorer_sections[] (+ the
|
|
22
|
+
// ExplorerSectionData wire shape) / ui.companion_panels[] / ui.context_actions[] /
|
|
23
|
+
// ui.widgets[]; ui.nav marked a deprecated alias (shell-side mapping, one-release
|
|
24
|
+
// lifetime, §4); ui.side_pane_viewers hard-retired — declaring it now fails
|
|
25
|
+
// validation. All additions are optional-with-default, so api=1..4 manifests
|
|
26
|
+
// parse unchanged; the support window stays [MIN_SUPPORTED, CURRENT].
|
|
27
|
+
export const IKENGA_API_VERSION = 5 as const;
|
|
21
28
|
export const IKENGA_API_MIN_SUPPORTED = 1 as const;
|
|
22
29
|
|
|
23
30
|
// ---------- Sub-schemas ----------
|
|
@@ -123,17 +130,150 @@ export const ManifestUiSessionSchema = z.object({
|
|
|
123
130
|
});
|
|
124
131
|
export type ManifestUiSession = z.infer<typeof ManifestUiSessionSchema>;
|
|
125
132
|
|
|
133
|
+
// ---------- Manifest v5 contribution blocks ----------
|
|
134
|
+
// Frozen type block from G-MANIFEST-V5 §2
|
|
135
|
+
// (plans/shell-ux-rearchitecture/drafts/g-manifest-v5.md, frozen 2026-09-22,
|
|
136
|
+
// Round 24 / DEC-34). Copied verbatim — do not reinterpret. `UiBlock` gains
|
|
137
|
+
// `views`, `explorer_sections`, `companion_panels`, `context_actions`,
|
|
138
|
+
// `widgets` (all optional-with-default); `nav` keeps its field and type.
|
|
139
|
+
|
|
140
|
+
// ── ui.views[] — replaces ui.nav (alias window §4) ────────────────────────
|
|
141
|
+
export const ViewEntrySchema = z.object({
|
|
142
|
+
id: z.string(),
|
|
143
|
+
title: z.string(),
|
|
144
|
+
icon: z.string().optional(), // lucide name; same vocabulary as pins
|
|
145
|
+
/** A pkg UI namespace path, e.g. `/grid` → pane route `pkg://<id>/grid`.
|
|
146
|
+
* Must match a declared `ui.routes[]` path (§6 Q3). */
|
|
147
|
+
route: z.string(),
|
|
148
|
+
/** Honoured ONCE, at first install (Round 2 Q1). Updates never re-pin;
|
|
149
|
+
* unpin is permanent. Kernel "first install" = no prior install row. */
|
|
150
|
+
pin_on_install: z.boolean().default(false),
|
|
151
|
+
}).strict();
|
|
152
|
+
export type ViewEntry = z.infer<typeof ViewEntrySchema>;
|
|
153
|
+
|
|
154
|
+
// ── ui.explorer_sections[] — Project Explorer sections (G-STATE §1 id rule) ─
|
|
155
|
+
export const ExplorerSectionEntrySchema = z.object({
|
|
156
|
+
id: z.string(), // pkg-local; state id = `${pkg_id}:${id}`
|
|
157
|
+
title: z.string(),
|
|
158
|
+
icon: z.string().optional(),
|
|
159
|
+
order: z.number().int().optional(), // default: declaration order; ties by pkg id
|
|
160
|
+
/** GET iyke route under `/pkg/<id>/` returning ExplorerSectionData. */
|
|
161
|
+
data_route: z.string(),
|
|
162
|
+
}).strict();
|
|
163
|
+
export type ExplorerSectionEntry = z.infer<typeof ExplorerSectionEntrySchema>;
|
|
164
|
+
|
|
165
|
+
/** The JSON a `data_route` returns. The shell renders it natively — the
|
|
166
|
+
* section is data, never an embedded iframe (P4 fix, discussion §3.2). */
|
|
167
|
+
export const ExplorerSectionDataSchema = z.object({
|
|
168
|
+
rows: z.array(z.object({
|
|
169
|
+
id: z.string(),
|
|
170
|
+
label: z.string(),
|
|
171
|
+
badge: z.object({
|
|
172
|
+
count: z.number().int().optional(),
|
|
173
|
+
tooltip: z.string().optional(),
|
|
174
|
+
}).optional(),
|
|
175
|
+
/** Pane target on click: a pkg route (`pkg://...`) or a shell route (`/...`). */
|
|
176
|
+
open: z.string().optional(),
|
|
177
|
+
})).default([]),
|
|
178
|
+
as_of_ms: z.number().optional(),
|
|
179
|
+
}).strict();
|
|
180
|
+
export type ExplorerSectionData = z.infer<typeof ExplorerSectionDataSchema>;
|
|
181
|
+
|
|
182
|
+
// ── ui.companion_panels[] — Companion state panels (ADR-021: state only) ───
|
|
183
|
+
export const CompanionPanelEntrySchema = z.object({
|
|
184
|
+
id: z.string(),
|
|
185
|
+
title: z.string(),
|
|
186
|
+
icon: z.string().optional(),
|
|
187
|
+
/** Pane route rendered in the panel slot (an iframe view). Must render
|
|
188
|
+
* state, never model prose — ADR-021 checklist applies. */
|
|
189
|
+
route: z.string(),
|
|
190
|
+
/** When true the shell threads `panelScopeSessionId` (the selected
|
|
191
|
+
* Companion session tab) through the AppBridge hostContext. */
|
|
192
|
+
session_scoped: z.boolean().default(false),
|
|
193
|
+
}).strict();
|
|
194
|
+
export type CompanionPanelEntry = z.infer<typeof CompanionPanelEntrySchema>;
|
|
195
|
+
|
|
196
|
+
// ── ui.context_actions[] — selector-scoped menu contributions ─────────────
|
|
197
|
+
export const ContextSelectorSchema = z.discriminatedUnion('kind', [
|
|
198
|
+
z.object({ kind: z.literal('file'), glob: z.string().optional() }),
|
|
199
|
+
z.object({ kind: z.literal('artifact') }),
|
|
200
|
+
z.object({ kind: z.literal('session') }),
|
|
201
|
+
z.object({ kind: z.literal('ngwa-item'), kinds: z.array(z.string()).optional() }),
|
|
202
|
+
]);
|
|
203
|
+
export type ContextSelector = z.infer<typeof ContextSelectorSchema>;
|
|
204
|
+
|
|
205
|
+
export const ContextActionRunSchema = z.discriminatedUnion('kind', [
|
|
206
|
+
/** "Hand to Chi" — fills the Companion dispatch bar (spec §5.3 resolveTarget).
|
|
207
|
+
* `prompt` is a template with {{file.path}}, {{selection}}, {{project.root}},
|
|
208
|
+
* {{pane.url}}, {{branch}} — the D-06 variable set. */
|
|
209
|
+
z.object({ kind: z.literal('dispatch'), prompt: z.string(), target: z.string().optional() }),
|
|
210
|
+
z.object({ kind: z.literal('view'), route: z.string() }),
|
|
211
|
+
]);
|
|
212
|
+
export type ContextActionRun = z.infer<typeof ContextActionRunSchema>;
|
|
213
|
+
|
|
214
|
+
export const ContextActionEntrySchema = z.object({
|
|
215
|
+
id: z.string(),
|
|
216
|
+
label: z.string(),
|
|
217
|
+
when: ContextSelectorSchema,
|
|
218
|
+
run: ContextActionRunSchema,
|
|
219
|
+
}).strict();
|
|
220
|
+
export type ContextActionEntry = z.infer<typeof ContextActionEntrySchema>;
|
|
221
|
+
|
|
222
|
+
// ── ui.widgets[] — project-dashboard widgets (formalised home canvas) ──────
|
|
223
|
+
export const WidgetEntrySchema = z.object({
|
|
224
|
+
id: z.string(),
|
|
225
|
+
title: z.string(),
|
|
226
|
+
route: z.string(), // iframe view rendered in the dashboard grid
|
|
227
|
+
span: z.enum(['small', 'medium', 'wide']).default('medium'),
|
|
228
|
+
}).strict();
|
|
229
|
+
export type WidgetEntry = z.infer<typeof WidgetEntrySchema>;
|
|
230
|
+
|
|
126
231
|
export const UiBlockSchema = z.object({
|
|
232
|
+
/** @deprecated v5 alias (G-MANIFEST-V5 §4): the shell reads `nav[i]` as
|
|
233
|
+
* `views[i]` = `{id, title: label, icon, route, pin_on_install: i === 0}`
|
|
234
|
+
* for exactly one shell release, then deletes the mapping. Field and type
|
|
235
|
+
* are unchanged; a manifest declaring both `nav` and `views` still parses
|
|
236
|
+
* (`nav` is ignored with a shell-side warning). New manifests declare
|
|
237
|
+
* `views` instead. */
|
|
127
238
|
nav: z.array(NavEntrySchema).default([]),
|
|
128
239
|
routes: z.array(UiRouteSchema).default([]),
|
|
129
240
|
command_palette: z.array(CommandPaletteEntrySchema).default([]),
|
|
130
|
-
|
|
241
|
+
/** v5 hard-retire (G-MANIFEST-V5 §8 Q1 / DEC-34): `side_pane_viewers` was
|
|
242
|
+
* removed from the block; declaring it fails validation outright.
|
|
243
|
+
* `z.never()` is the contract-side mirror of the Rust canonical rejection
|
|
244
|
+
* (skills/commands-bundling precedent). `SidePaneViewerSchema` stays
|
|
245
|
+
* exported for tooling that reads historical manifests. */
|
|
246
|
+
side_pane_viewers: z.never().optional(),
|
|
247
|
+
// ── v5 contribution blocks (G-MANIFEST-V5 §2; all optional-with-default) ──
|
|
248
|
+
views: z.array(ViewEntrySchema).default([]),
|
|
249
|
+
explorer_sections: z.array(ExplorerSectionEntrySchema).default([]),
|
|
250
|
+
companion_panels: z.array(CompanionPanelEntrySchema).default([]),
|
|
251
|
+
context_actions: z.array(ContextActionEntrySchema).default([]),
|
|
252
|
+
widgets: z.array(WidgetEntrySchema).default([]),
|
|
131
253
|
/** Per-directive CSP overrides for the iframe content. */
|
|
132
254
|
csp: z.record(z.array(z.string())).optional(),
|
|
133
255
|
/** Per-directive Permission-Policy values. */
|
|
134
256
|
permissions: z.record(z.array(z.string())).optional(),
|
|
135
257
|
session: ManifestUiSessionSchema.optional(),
|
|
136
|
-
})
|
|
258
|
+
})
|
|
259
|
+
.superRefine((ui, ctx) => {
|
|
260
|
+
// G-MANIFEST-V5 §2b / DEC-34 Q2: a `ViewEntry.route` must reference a path
|
|
261
|
+
// declared in the same manifest's `ui.routes[]`. Companion-panel and widget
|
|
262
|
+
// routes are deliberately NOT reference-checked (§2b scopes the rule to
|
|
263
|
+
// views only).
|
|
264
|
+
const routePaths = new Set(ui.routes.map((r) => r.path));
|
|
265
|
+
ui.views.forEach((view, i) => {
|
|
266
|
+
if (!routePaths.has(view.route)) {
|
|
267
|
+
ctx.addIssue({
|
|
268
|
+
code: z.ZodIssueCode.custom,
|
|
269
|
+
path: ['views', i, 'route'],
|
|
270
|
+
message: `ui.views[${i}].route "${view.route}" does not match any declared ui.routes[] path`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
});
|
|
274
|
+
})
|
|
275
|
+
.default({});
|
|
276
|
+
export type UiBlock = z.infer<typeof UiBlockSchema>;
|
|
137
277
|
|
|
138
278
|
export const SettingsFieldSchema = z.object({
|
|
139
279
|
key: z.string(),
|