@jskit-ai/kernel 0.1.66 → 0.1.67

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.
@@ -483,6 +483,7 @@ function createClientRuntimeApp({
483
483
  profile = "client",
484
484
  app,
485
485
  pinia = null,
486
+ queryClient = null,
486
487
  router,
487
488
  env,
488
489
  logger,
@@ -498,6 +499,7 @@ function createClientRuntimeApp({
498
499
  runtimeApp.instance("jskit.client.router", router || null);
499
500
  runtimeApp.instance("jskit.client.vue.app", app || null);
500
501
  runtimeApp.instance("jskit.client.pinia", pinia);
502
+ runtimeApp.instance("jskit.client.query-client", queryClient);
501
503
  runtimeApp.instance("jskit.client.env", isRecord(env) ? { ...env } : {});
502
504
  runtimeApp.instance("jskit.client.surface.runtime", surfaceRuntime || null);
503
505
  runtimeApp.instance("jskit.client.surface.mode", String(surfaceMode || "").trim());
@@ -510,6 +512,7 @@ async function bootClientModules({
510
512
  clientModules = [],
511
513
  app,
512
514
  pinia = null,
515
+ queryClient = null,
513
516
  router,
514
517
  surfaceRuntime,
515
518
  surfaceMode,
@@ -529,6 +532,7 @@ async function bootClientModules({
529
532
  profile: String(surfaceRuntime.normalizeSurfaceMode(surfaceMode) || "client"),
530
533
  app,
531
534
  pinia,
535
+ queryClient,
532
536
  router,
533
537
  env,
534
538
  logger: log,
@@ -113,6 +113,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
113
113
  const events = [];
114
114
  const loginComponent = {};
115
115
  const pinia = { id: "pinia-instance" };
116
+ const queryClient = { id: "query-client-instance" };
116
117
  const implicitPinia = { id: "implicit-vue-global-pinia" };
117
118
  class ExampleClientProvider {
118
119
  static id = "example.client";
@@ -120,6 +121,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
120
121
  events.push("register");
121
122
  app.instance("example.value", 42);
122
123
  app.instance("example.pinia", app.make("jskit.client.pinia"));
124
+ app.instance("example.queryClient", app.make("jskit.client.query-client"));
123
125
  }
124
126
  boot() {
125
127
  events.push("boot");
@@ -190,6 +192,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
190
192
  }
191
193
  },
192
194
  pinia,
195
+ queryClient,
193
196
  router,
194
197
  surfaceRuntime,
195
198
  surfaceMode: "all",
@@ -206,6 +209,7 @@ test("bootClientModules registers descriptor and clientRoutes with providers onl
206
209
  assert.equal(router.routes[1].component, loginComponent);
207
210
  assert.equal(result.runtimeApp.make("example.value"), 42);
208
211
  assert.equal(result.runtimeApp.make("example.pinia"), pinia);
212
+ assert.equal(result.runtimeApp.make("example.queryClient"), queryClient);
209
213
  assert.notEqual(result.runtimeApp.make("example.pinia"), implicitPinia);
210
214
  });
211
215
 
@@ -109,6 +109,7 @@ async function bootstrapClientShellApp({
109
109
  appConfig = {},
110
110
  appPlugins = [],
111
111
  pinia = null,
112
+ queryClient = null,
112
113
  router,
113
114
  bootClientModules,
114
115
  surfaceRuntime,
@@ -161,6 +162,7 @@ async function bootstrapClientShellApp({
161
162
  const clientBootstrap = await bootClientModules({
162
163
  app,
163
164
  pinia,
165
+ queryClient,
164
166
  router,
165
167
  surfaceRuntime,
166
168
  surfaceMode,
@@ -86,6 +86,7 @@ test("bootstrapClientShellApp boots modules, reinstalls fallback route, and moun
86
86
  const surfaceRuntime = createSurfaceRuntimeFixture();
87
87
  const plugin = { name: "vuetify-like-plugin" };
88
88
  const pinia = { id: "pinia-instance" };
89
+ const queryClient = { id: "query-client-instance" };
89
90
  const fallbackRoute = {
90
91
  name: "not-found",
91
92
  path: "/:pathMatch(.*)*",
@@ -142,11 +143,13 @@ test("bootstrapClientShellApp boots modules, reinstalls fallback route, and moun
142
143
  },
143
144
  appPlugins: [plugin],
144
145
  pinia,
146
+ queryClient,
145
147
  router,
146
148
  bootClientModules: async (context) => {
147
149
  calls.push("bootClientModules");
148
150
  assert.equal(context.app, app);
149
151
  assert.equal(context.pinia, pinia);
152
+ assert.equal(context.queryClient, queryClient);
150
153
  assert.equal(context.router, router);
151
154
  assert.equal(typeof context.logger.debug, "function");
152
155
  return {
@@ -14,6 +14,7 @@ const CLIENT_BOOTSTRAP_VIRTUAL_ID = "virtual:jskit-client-bootstrap";
14
14
  const CLIENT_BOOTSTRAP_RESOLVED_ID = `\0${CLIENT_BOOTSTRAP_VIRTUAL_ID}`;
15
15
  const CLIENT_RUNTIME_DEDUPE_SPECIFIERS = Object.freeze([
16
16
  "@tanstack/vue-query",
17
+ "pinia",
17
18
  "vue",
18
19
  "vue-router",
19
20
  "vuetify"
@@ -404,7 +404,7 @@ test("createJskitClientBootstrapPlugin config excludes installed client package
404
404
  assert.equal(Array.isArray(result?.optimizeDeps?.exclude), true);
405
405
  assert.deepEqual(result.optimizeDeps.exclude, ["already/excluded"]);
406
406
  assert.deepEqual(result.optimizeDeps.include, ["@example/has-client/client", "a"]);
407
- assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "vue", "vue-router", "vuetify"]);
407
+ assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "pinia", "vue", "vue-router", "vuetify"]);
408
408
  } finally {
409
409
  process.chdir(previousCwd);
410
410
  }
@@ -469,7 +469,7 @@ test("createJskitClientBootstrapPlugin config excludes local package roots and c
469
469
  "@example/local-client/shared"
470
470
  ]);
471
471
  assert.deepEqual(result.optimizeDeps.include, ["@example/remote-client/client", "mime-match"]);
472
- assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "vue", "vue-router", "vuetify"]);
472
+ assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "pinia", "vue", "vue-router", "vuetify"]);
473
473
  } finally {
474
474
  process.chdir(previousCwd);
475
475
  }
@@ -500,7 +500,7 @@ test("createJskitClientBootstrapPlugin config preserves user resolve fields and
500
500
  assert.deepEqual(result.resolve.alias, {
501
501
  "@": "/tmp/app/src"
502
502
  });
503
- assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "custom-lib", "vue", "vue-router", "vuetify"]);
503
+ assert.deepEqual(result.resolve.dedupe, ["@tanstack/vue-query", "custom-lib", "pinia", "vue", "vue-router", "vuetify"]);
504
504
  } finally {
505
505
  process.chdir(previousCwd);
506
506
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jskit-ai/kernel",
3
- "version": "0.1.66",
3
+ "version": "0.1.67",
4
4
  "type": "module",
5
5
  "dependencies": {
6
6
  "json-rest-schema": "1.x.x"
@@ -42,6 +42,7 @@
42
42
  "./shared/support/crudListFilters": "./shared/support/crudListFilters.js",
43
43
  "./shared/support/crudFieldContract": "./shared/support/crudFieldContract.js",
44
44
  "./shared/support/crudLookup": "./shared/support/crudLookup.js",
45
+ "./shared/support/generatedUiContract": "./shared/support/generatedUiContract.js",
45
46
  "./shared/support/deepFreeze": "./shared/support/deepFreeze.js",
46
47
  "./shared/support/listenerSet": "./shared/support/listenerSet.js",
47
48
  "./shared/support/shellLayoutTargets": "./shared/support/shellLayoutTargets.js",
@@ -1,5 +1,5 @@
1
1
  import { normalizeObject } from "../../../shared/support/normalize.js";
2
- import { ensureApiErrorHandling } from "../../runtime/fastifyBootstrap.js";
2
+ import { ensureApiErrorHandling, registerBodylessContentTypeNormalizer } from "../../runtime/fastifyBootstrap.js";
3
3
  import { resolveDefaultSurfaceId } from "../../support/appConfig.js";
4
4
  import { RouteRegistrationError } from "./errors.js";
5
5
  import { createRouter } from "./router.js";
@@ -25,6 +25,8 @@ function registerHttpRuntime(app, options = {}) {
25
25
  defaultSurfaceId: routeRegistrationOptions.requestActionDefaultSurface
26
26
  });
27
27
 
28
+ registerBodylessContentTypeNormalizer(fastify);
29
+
28
30
  if (autoRegisterApiErrorHandling !== false) {
29
31
  ensureApiErrorHandling(app, {
30
32
  fastifyToken,
@@ -14,9 +14,13 @@ function createFastifyStub() {
14
14
  setErrorHandlerCalls: 0,
15
15
  errorHandler: null,
16
16
  contentTypeParsers: new Map(),
17
+ hooks: {},
17
18
  route(definition) {
18
19
  routes.push(definition);
19
20
  },
21
+ addHook(name, handler) {
22
+ this.hooks[name] = handler;
23
+ },
20
24
  setErrorHandler(handler) {
21
25
  this.errorHandler = handler;
22
26
  this.setErrorHandlerCalls += 1;
@@ -5,6 +5,7 @@ import { resolveDefaultSurfaceId } from "../support/appConfig.js";
5
5
 
6
6
  const JSON_API_CONTENT_TYPE = "application/vnd.api+json";
7
7
  const JSON_API_CONTENT_TYPE_PARSER_MARKER = Symbol.for("jskit.fastify.jsonApiContentTypeParserRegistered");
8
+ const BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER = Symbol.for("jskit.fastify.bodylessContentTypeNormalizerRegistered");
8
9
 
9
10
  function resolveLoggerLevel({ configuredLevel = "", nodeEnv = "development", allowedLevels = [] } = {}) {
10
11
  const normalizedConfiguredLevel = String(configuredLevel || "")
@@ -83,6 +84,64 @@ function registerJsonApiContentTypeParser(fastify) {
83
84
  return true;
84
85
  }
85
86
 
87
+ function hasHeaderValue(headers = {}, key = "") {
88
+ return String(headers?.[key] ?? "").trim().length > 0;
89
+ }
90
+
91
+ function hasRequestBody(headers = {}) {
92
+ const transferEncoding = String(headers?.["transfer-encoding"] ?? "").trim();
93
+ if (transferEncoding) {
94
+ return true;
95
+ }
96
+
97
+ const contentLengthHeader = String(headers?.["content-length"] ?? "").trim();
98
+ if (!contentLengthHeader) {
99
+ return false;
100
+ }
101
+
102
+ const contentLength = Number(contentLengthHeader);
103
+ if (!Number.isFinite(contentLength)) {
104
+ return true;
105
+ }
106
+
107
+ return contentLength > 0;
108
+ }
109
+
110
+ function normalizeBodylessContentTypeHeader(request = null) {
111
+ const headers = request?.headers;
112
+ if (!headers || typeof headers !== "object" || Array.isArray(headers)) {
113
+ return false;
114
+ }
115
+
116
+ if (!hasHeaderValue(headers, "content-type") && !hasHeaderValue(headers, "Content-Type")) {
117
+ return false;
118
+ }
119
+
120
+ if (hasRequestBody(headers)) {
121
+ return false;
122
+ }
123
+
124
+ delete headers["content-type"];
125
+ delete headers["Content-Type"];
126
+ return true;
127
+ }
128
+
129
+ function registerBodylessContentTypeNormalizer(fastify) {
130
+ if (!fastify || typeof fastify.addHook !== "function") {
131
+ throw new TypeError("registerBodylessContentTypeNormalizer requires a Fastify instance.");
132
+ }
133
+
134
+ if (fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER]) {
135
+ return false;
136
+ }
137
+
138
+ fastify.addHook("onRequest", async (request) => {
139
+ normalizeBodylessContentTypeHeader(request);
140
+ });
141
+ fastify[BODYLESS_CONTENT_TYPE_NORMALIZER_MARKER] = true;
142
+ return true;
143
+ }
144
+
86
145
  function registerRequestLoggingHooks(
87
146
  app,
88
147
  {
@@ -483,6 +542,8 @@ export {
483
542
  resolveLoggerLevel,
484
543
  createFastifyLoggerOptions,
485
544
  registerJsonApiContentTypeParser,
545
+ normalizeBodylessContentTypeHeader,
546
+ registerBodylessContentTypeNormalizer,
486
547
  registerRequestLoggingHooks,
487
548
  registerApiErrorHandler,
488
549
  ensureApiErrorHandling,
@@ -2,7 +2,13 @@ import assert from "node:assert/strict";
2
2
  import test from "node:test";
3
3
 
4
4
  import { AppError, isAppError } from "./errors.js";
5
- import { registerApiErrorHandler, registerJsonApiContentTypeParser, registerRequestLoggingHooks } from "./fastifyBootstrap.js";
5
+ import {
6
+ normalizeBodylessContentTypeHeader,
7
+ registerApiErrorHandler,
8
+ registerBodylessContentTypeNormalizer,
9
+ registerJsonApiContentTypeParser,
10
+ registerRequestLoggingHooks
11
+ } from "./fastifyBootstrap.js";
6
12
 
7
13
  function createFastifyStub() {
8
14
  return {
@@ -275,6 +281,46 @@ test("registerJsonApiContentTypeParser installs the JSON:API media type parser o
275
281
  assert.equal(fastify.contentTypeParsers.size, 1);
276
282
  });
277
283
 
284
+ test("normalizeBodylessContentTypeHeader strips meaningless content type from empty requests", () => {
285
+ const request = {
286
+ headers: {
287
+ "content-type": "text/plain;charset=UTF-8"
288
+ }
289
+ };
290
+
291
+ assert.equal(normalizeBodylessContentTypeHeader(request), true);
292
+ assert.deepEqual(request.headers, {});
293
+ });
294
+
295
+ test("normalizeBodylessContentTypeHeader keeps content type when a body is declared", () => {
296
+ const request = {
297
+ headers: {
298
+ "content-type": "text/plain;charset=UTF-8",
299
+ "content-length": "12"
300
+ }
301
+ };
302
+
303
+ assert.equal(normalizeBodylessContentTypeHeader(request), false);
304
+ assert.equal(request.headers["content-type"], "text/plain;charset=UTF-8");
305
+ });
306
+
307
+ test("registerBodylessContentTypeNormalizer installs the request hook once", async () => {
308
+ const fastify = createFastifyStub();
309
+ assert.equal(registerBodylessContentTypeNormalizer(fastify), true);
310
+ assert.equal(registerBodylessContentTypeNormalizer(fastify), false);
311
+
312
+ const request = {
313
+ headers: {
314
+ "content-type": "text/plain;charset=UTF-8",
315
+ "content-length": "0"
316
+ }
317
+ };
318
+ await fastify.hooks.onRequest(request);
319
+ assert.deepEqual(request.headers, {
320
+ "content-length": "0"
321
+ });
322
+ });
323
+
278
324
  test("registerRequestLoggingHooks uses configured default surface when getSurface is absent", async () => {
279
325
  const fastify = createFastifyStub();
280
326
  let loggedPayload = null;
@@ -1,6 +1,7 @@
1
1
  import path from "node:path";
2
- import { fileURLToPath, pathToFileURL } from "node:url";
2
+ import { fileURLToPath } from "node:url";
3
3
  import { fileExists } from "../../internal/node/fileSystem.js";
4
+ import { importFreshModuleFromAbsolutePath } from "./importFreshModuleFromAbsolutePath.js";
4
5
 
5
6
  const PUBLIC_CONFIG_RELATIVE_PATH = "config/public.js";
6
7
  const SERVER_CONFIG_RELATIVE_PATH = "config/server.js";
@@ -35,7 +36,7 @@ async function loadConfigModuleAtPath(absolutePath) {
35
36
  return {};
36
37
  }
37
38
 
38
- const loadedModule = await import(pathToFileURL(absolutePath).href);
39
+ const loadedModule = await importFreshModuleFromAbsolutePath(absolutePath);
39
40
  return normalizeConfigObject(loadedModule?.config);
40
41
  }
41
42
 
@@ -47,6 +47,21 @@ test("loadAppConfigFromAppRoot merges public and server config", async () => {
47
47
  assert.equal(Object.isFrozen(loaded), true);
48
48
  });
49
49
 
50
+ test("loadAppConfigFromAppRoot re-reads config changes within the same process", async () => {
51
+ const tempRoot = await mkdtemp(path.join(os.tmpdir(), "kernel-app-config-"));
52
+ const appRoot = path.join(tempRoot, "app");
53
+ const publicConfigPath = path.join(appRoot, "config", "public.js");
54
+ await mkdir(path.dirname(publicConfigPath), { recursive: true });
55
+ await writeFile(publicConfigPath, "export const config = { mobile: { enabled: false } };", "utf8");
56
+
57
+ const firstLoaded = await loadAppConfigFromAppRoot({ appRoot });
58
+ await writeFile(publicConfigPath, "export const config = { mobile: { enabled: true } };", "utf8");
59
+ const secondLoaded = await loadAppConfigFromAppRoot({ appRoot });
60
+
61
+ assert.equal(firstLoaded.mobile.enabled, false);
62
+ assert.equal(secondLoaded.mobile.enabled, true);
63
+ });
64
+
50
65
  test("loadAppConfigFromAppRoot requires an explicit appRoot", async () => {
51
66
  await assert.rejects(
52
67
  loadAppConfigFromAppRoot({ appRoot: "" }),
@@ -17,6 +17,7 @@ export {
17
17
  } from "./pageTargets.js";
18
18
  export {
19
19
  discoverPlacementTopologyFromApp,
20
+ discoverShellOutletSourcePathsFromApp,
20
21
  discoverShellOutletTargetsFromApp,
21
22
  resolveSemanticPlacementTargetFromApp,
22
23
  resolveShellOutletPlacementTargetFromApp
@@ -373,8 +373,8 @@ function buildAncestorRouteContexts(pageTarget = {}) {
373
373
 
374
374
  for (let visiblePrefixLength = visibleRouteSegments.length - 1; visiblePrefixLength >= 1; visiblePrefixLength -= 1) {
375
375
  const parentVisibleSegments = visibleRouteSegments.slice(0, visiblePrefixLength);
376
- const actualRouteSegments = [];
377
- let collectedVisibleSegments = 0;
376
+ const actualRouteSegments = [];
377
+ let collectedVisibleSegments = 0;
378
378
 
379
379
  for (const segment of routeSegments) {
380
380
  actualRouteSegments.push(segment);
@@ -585,7 +585,8 @@ function topologyPlacementTargetsConcreteOutlet(placement = {}, target = "") {
585
585
  async function resolveSemanticPlacementTargetForConcreteOutlet({
586
586
  appRoot,
587
587
  concreteTarget = "",
588
- surface = ""
588
+ surface = "",
589
+ context = "page target"
589
590
  } = {}) {
590
591
  const normalizedConcreteTarget = normalizeShellOutletTargetId(concreteTarget);
591
592
  if (!normalizedConcreteTarget) {
@@ -595,16 +596,31 @@ async function resolveSemanticPlacementTargetForConcreteOutlet({
595
596
  const topology = await discoverPlacementTopologyFromApp({ appRoot });
596
597
  const owner = resolveConcreteTargetOwner(normalizedConcreteTarget);
597
598
  const normalizedSurface = normalizeSurfaceId(surface);
598
- return (Array.isArray(topology.placements) ? topology.placements : []).find((placement) => {
599
+ const candidates = (Array.isArray(topology.placements) ? topology.placements : []).filter((placement) => {
599
600
  if (!topologyPlacementTargetsConcreteOutlet(placement, normalizedConcreteTarget)) {
600
601
  return false;
601
602
  }
602
- if (placement.owner && placement.owner !== owner) {
603
- return false;
604
- }
605
603
  const surfaces = Array.isArray(placement.surfaces) ? placement.surfaces : ["*"];
606
604
  return !normalizedSurface || surfaces.includes("*") || surfaces.includes(normalizedSurface);
607
- }) || null;
605
+ });
606
+
607
+ const exactOwnerMatches = candidates.filter((placement) => placement.owner && placement.owner === owner);
608
+ const globalMatches = candidates.filter((placement) => !placement.owner);
609
+ const selectedMatches = exactOwnerMatches.length > 0 ? exactOwnerMatches : globalMatches;
610
+ if (selectedMatches.length < 1) {
611
+ return null;
612
+ }
613
+ if (selectedMatches.length === 1) {
614
+ return selectedMatches[0];
615
+ }
616
+
617
+ const targetLabels = selectedMatches.map((placement) =>
618
+ placement.owner ? `${placement.id} [owner:${placement.owner}]` : placement.id
619
+ );
620
+ throw new Error(
621
+ `${context} found multiple semantic placements mapped to concrete outlet "${normalizedConcreteTarget}": ` +
622
+ `${targetLabels.join(", ")}. Pass --link-placement or make the topology mapping unambiguous.`
623
+ );
608
624
  }
609
625
 
610
626
  function resolveRelativeLinkToFromParent(pageTarget = {}, parentHost = null) {
@@ -388,6 +388,149 @@ test("resolvePageLinkTargetDetails prefers an outlet-declared default link token
388
388
  });
389
389
  });
390
390
 
391
+ test("resolvePageLinkTargetDetails infers owner-scoped placement for sibling file-route children", async () => {
392
+ await withTempApp(async (appRoot) => {
393
+ await writeConfig(
394
+ appRoot,
395
+ `export const config = {
396
+ surfaceDefinitions: {
397
+ home: { id: "home", pagesRoot: "home", enabled: true }
398
+ }
399
+ };
400
+ `
401
+ );
402
+ await writePlacementTopology(appRoot, [
403
+ renderTopologyEntry({
404
+ id: "page.section-nav",
405
+ owner: "home-settings",
406
+ surfaces: ["home"],
407
+ outlet: "home-settings:primary-menu",
408
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
409
+ })
410
+ ]);
411
+ await writeFileInApp(
412
+ appRoot,
413
+ "src/pages/home/settings.vue",
414
+ `<template>
415
+ <section>
416
+ <ShellOutlet target="home-settings:primary-menu" />
417
+ <RouterView />
418
+ </section>
419
+ </template>
420
+ `
421
+ );
422
+
423
+ const details = await resolvePageLinkTargetDetails({
424
+ appRoot,
425
+ targetFile: "home/settings/profile.vue",
426
+ context: "page target"
427
+ });
428
+
429
+ assert.equal(details.parentHost?.id, "home-settings:primary-menu");
430
+ assert.equal(details.placementTarget.id, "page.section-nav");
431
+ assert.equal(details.placementTarget.owner, "home-settings");
432
+ });
433
+ });
434
+
435
+ test("resolvePageLinkTargetDetails prefers owner-scoped topology over a global mapping for the same concrete outlet", async () => {
436
+ await withTempApp(async (appRoot) => {
437
+ await writeConfig(
438
+ appRoot,
439
+ `export const config = {
440
+ surfaceDefinitions: {
441
+ home: { id: "home", pagesRoot: "home", enabled: true }
442
+ }
443
+ };
444
+ `
445
+ );
446
+ await writePlacementTopology(appRoot, [
447
+ renderTopologyEntry({
448
+ id: "page.section-nav",
449
+ surfaces: ["home"],
450
+ outlet: "home-settings:primary-menu",
451
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
452
+ }),
453
+ renderTopologyEntry({
454
+ id: "page.section-nav",
455
+ owner: "home-settings",
456
+ surfaces: ["home"],
457
+ outlet: "home-settings:primary-menu",
458
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
459
+ })
460
+ ]);
461
+ await writeFileInApp(
462
+ appRoot,
463
+ "src/pages/home/settings.vue",
464
+ `<template>
465
+ <section>
466
+ <ShellOutlet target="home-settings:primary-menu" />
467
+ <RouterView />
468
+ </section>
469
+ </template>
470
+ `
471
+ );
472
+
473
+ const details = await resolvePageLinkTargetDetails({
474
+ appRoot,
475
+ targetFile: "home/settings/profile.vue",
476
+ context: "page target"
477
+ });
478
+
479
+ assert.equal(details.placementTarget.id, "page.section-nav");
480
+ assert.equal(details.placementTarget.owner, "home-settings");
481
+ });
482
+ });
483
+
484
+ test("resolvePageLinkTargetDetails rejects ambiguous semantic mappings for the same owner outlet", async () => {
485
+ await withTempApp(async (appRoot) => {
486
+ await writeConfig(
487
+ appRoot,
488
+ `export const config = {
489
+ surfaceDefinitions: {
490
+ home: { id: "home", pagesRoot: "home", enabled: true }
491
+ }
492
+ };
493
+ `
494
+ );
495
+ await writePlacementTopology(appRoot, [
496
+ renderTopologyEntry({
497
+ id: "page.section-nav",
498
+ owner: "home-settings",
499
+ surfaces: ["home"],
500
+ outlet: "home-settings:primary-menu",
501
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
502
+ }),
503
+ renderTopologyEntry({
504
+ id: "page.actions",
505
+ owner: "home-settings",
506
+ surfaces: ["home"],
507
+ outlet: "home-settings:primary-menu",
508
+ linkRenderer: "local.main.ui.surface-aware-menu-link-item"
509
+ })
510
+ ]);
511
+ await writeFileInApp(
512
+ appRoot,
513
+ "src/pages/home/settings.vue",
514
+ `<template>
515
+ <section>
516
+ <ShellOutlet target="home-settings:primary-menu" />
517
+ <RouterView />
518
+ </section>
519
+ </template>
520
+ `
521
+ );
522
+
523
+ await assert.rejects(
524
+ resolvePageLinkTargetDetails({
525
+ appRoot,
526
+ targetFile: "home/settings/profile.vue",
527
+ context: "page target"
528
+ }),
529
+ /found multiple semantic placements mapped to concrete outlet "home-settings:primary-menu": page\.actions \[owner:home-settings\], page\.section-nav \[owner:home-settings\]/
530
+ );
531
+ });
532
+ });
533
+
391
534
  test("resolvePageLinkTargetDetails inherits a file-route parent subpages host", async () => {
392
535
  await withTempApp(async (appRoot) => {
393
536
  await writeConfig(