This is an automated email from the ASF dual-hosted git repository.

tbonelee pushed a commit to branch master
in repository https://gitbox.apache.org/repos/asf/zeppelin.git


The following commit(s) were added to refs/heads/master by this push:
     new de33e02651 [ZEPPELIN-6674] Prove Shared Notebook Core port identity
de33e02651 is described below

commit de33e026519cfa5b67a9cf45f79637cecb190a68
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Sep 27 15:53:03 2026 +0900

    [ZEPPELIN-6674] Prove Shared Notebook Core port identity
    
    ### What is this PR for?
    Prove that separately built Angular host and React remote code receive the 
exact same host-owned NotebookCorePort object. The build also rejects a React 
remote that bundles its own Shared Notebook Core runtime.
    
    ### What type of PR is it?
    Improvement
    
    ### Todos
    * [x] Add the Angular host and React remote proof
    * [x] Reject duplicate Core runtime bundles
    * [x] Add the proof to the frontend build
    
    ### What is the Jira issue?
    ZEPPELIN-6674
    
    ### How should this be tested?
    ```bash
    cd zeppelin-web-angular
    npm run typecheck:notebook-core
    npm run build:notebook-core-port-proof
    npm run test:notebook-core-port-identity
    ```
    
    ### Screenshots (if appropriate)
    Not applicable.
    
    ### Questions:
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? No
    
    
    Closes #5481 from voidmatcha/ZEPPELIN-6674-shared-core-port-identity.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 .github/workflows/frontend.yml                     |   4 +-
 zeppelin-web-angular/angular.json                  |  46 ++++
 .../e2e/core-contract/angular-host/index.html      |  29 ++
 .../e2e/core-contract/angular-host/main.ts         |  85 ++++++
 .../e2e/core-contract/angular-host/tsconfig.json   |  13 +
 .../notebook-core-port-identity.test.mjs           | 149 +++++++++++
 .../react-remote/NotebookCorePortProbe.tsx         |  90 +++++++
 .../core-contract/react-remote/empty.ts}           |  10 +-
 .../e2e/core-contract/react-remote/tsconfig.json   |  16 ++
 .../core-contract/react-remote/webpack.config.js   |  91 +++++++
 .../reject-notebook-core-runtime-plugin.test.mjs   |  67 +++++
 zeppelin-web-angular/package.json                  |   7 +-
 zeppelin-web-angular/pom.xml                       |  27 ++
 .../test/notebook-core/compiler-fixture.ts         |   5 +-
 .../test/notebook-core/import-boundary.spec.ts     | 295 +++++++++++++++++++--
 .../test/notebook-core/import-boundary.ts          | 233 ++++++++++++++--
 16 files changed, 1106 insertions(+), 61 deletions(-)

diff --git a/.github/workflows/frontend.yml b/.github/workflows/frontend.yml
index e7da04d72d..3e6f4ba206 100644
--- a/.github/workflows/frontend.yml
+++ b/.github/workflows/frontend.yml
@@ -119,8 +119,8 @@ jobs:
           mkdir -p $ZEPPELIN_E2E_TEST_NOTEBOOK_DIR
           echo "Created test notebook directory: 
$ZEPPELIN_E2E_TEST_NOTEBOOK_DIR"
       - name: Run headless E2E test with Maven
-        # Classic UI e2e runs only on the anonymous leg, like the legacy 
Protractor suite
-        run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" 
./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{ 
matrix.mode != 'anonymous' }} ${MAVEN_ARGS}
+        # Classic UI e2e and the notebook core port proof run only on the 
anonymous leg
+        run: xvfb-run --auto-servernum --server-args="-screen 0 1024x768x24" 
./mvnw verify -pl zeppelin-web-angular -Pweb-e2e -Dweb.e2e.classic.disabled=${{ 
matrix.mode != 'anonymous' }} -Dweb.e2e.core.port.proof.disabled=${{ 
matrix.mode != 'anonymous' }} ${MAVEN_ARGS}
       - name: Run revision isolation E2E test with Git storage
         env:
           CI: 'true'
diff --git a/zeppelin-web-angular/angular.json 
b/zeppelin-web-angular/angular.json
index d1595667a7..9062ad69a8 100644
--- a/zeppelin-web-angular/angular.json
+++ b/zeppelin-web-angular/angular.json
@@ -156,6 +156,52 @@
         }
       }
     },
+    "notebook-core-port-proof": {
+      "root": "e2e/core-contract/angular-host",
+      "sourceRoot": "e2e/core-contract/angular-host",
+      "projectType": "application",
+      "prefix": "zeppelin",
+      "architect": {
+        "build": {
+          "builder": "@angular-devkit/build-angular:browser",
+          "options": {
+            "outputPath": "dist/notebook-core-port-proof",
+            "index": "e2e/core-contract/angular-host/index.html",
+            "main": "e2e/core-contract/angular-host/main.ts",
+            "polyfills": ["zone.js"],
+            "tsConfig": "e2e/core-contract/angular-host/tsconfig.json",
+            "assets": [
+              {
+                "glob": "**/*",
+                "input": "./e2e/core-contract/react-remote/dist",
+                "output": "/assets/react/"
+              }
+            ],
+            "styles": [],
+            "scripts": []
+          },
+          "configurations": {
+            "production": {
+              "fileReplacements": [
+                {
+                  "replace": "src/environments/environment.ts",
+                  "with": "src/environments/environment.prod.ts"
+                }
+              ],
+              "optimization": true,
+              "outputHashing": "none",
+              "sourceMap": false,
+              "namedChunks": false,
+              "aot": true,
+              "extractLicenses": true,
+              "vendorChunk": false,
+              "buildOptimizer": false
+            }
+          },
+          "defaultConfiguration": "production"
+        }
+      }
+    },
     "zeppelin-visualization": {
       "projectType": "library",
       "root": "projects/zeppelin-visualization",
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/index.html 
b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
new file mode 100644
index 0000000000..34aa7aed45
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/index.html
@@ -0,0 +1,29 @@
+<!--
+Licensed to the Apache Software Foundation (ASF) under one or more
+contributor license agreements.  See the NOTICE file distributed with
+this work for additional information regarding copyright ownership.
+The ASF licenses this file to You under the Apache License, Version 2.0
+(the "License"); you may not use this file except in compliance with
+the License.  You may obtain a copy of the License at
+
+   http://www.apache.org/licenses/LICENSE-2.0
+
+Unless required by applicable law or agreed to in writing, software
+distributed under the License is distributed on an "AS IS" BASIS,
+WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+See the License for the specific language governing permissions and
+limitations under the License.
+-->
+
+<!doctype html>
+<html lang="en">
+  <head>
+    <meta charset="utf-8" />
+    <title>Notebook core port proof</title>
+    <base href="/" />
+    <meta name="viewport" content="width=device-width, initial-scale=1" />
+  </head>
+  <body>
+    <zeppelin-notebook-core-port-proof></zeppelin-notebook-core-port-proof>
+  </body>
+</html>
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts 
b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts
new file mode 100644
index 0000000000..447d1cccd3
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/main.ts
@@ -0,0 +1,85 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { CommonModule } from '@angular/common';
+import { Component, NgModule } from '@angular/core';
+import { BrowserModule } from '@angular/platform-browser';
+import { platformBrowserDynamic } from '@angular/platform-browser-dynamic';
+import { ReactMountDirective } from '@zeppelin/share/react-mount';
+import type { NotebookCorePort, NotebookCoreSnapshot } from 
'@zeppelin/notebook-core';
+
+declare global {
+  interface Window {
+    __zeppelinNotebookCorePortProof?: {
+      hostCore: NotebookCorePort;
+      proofs: unknown[];
+      receivedCore?: NotebookCorePort;
+    };
+  }
+}
+
+@Component({
+  selector: 'zeppelin-notebook-core-port-proof',
+  standalone: false,
+  template: `
+    <button type="button" data-testid="publish-notebook-core-revision" 
(click)="publishRevision()">
+      publish revision
+    </button>
+    <div [zeppelin-react-mount]="'./NotebookCorePortProbe'" 
[reactProps]="reactProps"></div>
+  `
+})
+export class NotebookCorePortProofComponent {
+  readonly core: NotebookCorePort = Object.freeze({
+    getSnapshot: () => this.snapshot,
+    subscribe: listener => {
+      this.listeners.add(listener);
+      return () => this.listeners.delete(listener);
+    }
+  });
+
+  readonly reactProps = {
+    core: this.core,
+    expectedCore: this.core,
+    onProof: (proof: unknown) => {
+      window.__zeppelinNotebookCorePortProof?.proofs.push(proof);
+    },
+    onReceivedCore: (receivedCore: NotebookCorePort) => {
+      window.__zeppelinNotebookCorePortProof!.receivedCore = receivedCore;
+    }
+  };
+
+  private snapshot: NotebookCoreSnapshot = { noteId: 'note-host-owned', 
revisionId: null };
+  private readonly listeners = new Set<() => void>();
+
+  constructor() {
+    window.__zeppelinNotebookCorePortProof = {
+      hostCore: this.core,
+      proofs: []
+    };
+  }
+
+  publishRevision(): void {
+    this.snapshot = { noteId: 'note-host-owned', revisionId: 
'revision-from-angular-host' };
+    for (const listener of this.listeners) {
+      listener();
+    }
+  }
+}
+
+@NgModule({
+  bootstrap: [NotebookCorePortProofComponent],
+  declarations: [NotebookCorePortProofComponent, ReactMountDirective],
+  imports: [BrowserModule, CommonModule]
+})
+export class NotebookCorePortProofModule {}
+
+void platformBrowserDynamic().bootstrapModule(NotebookCorePortProofModule);
diff --git a/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json 
b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json
new file mode 100644
index 0000000000..e637f8f629
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/angular-host/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "extends": "../../../tsconfig.base.json",
+  "compilerOptions": {
+    "ignoreDeprecations": "5.0",
+    "outDir": "../../../out-tsc/notebook-core-port-proof",
+    "types": []
+  },
+  "files": ["main.ts"],
+  "angularCompilerOptions": {
+    "strictInjectionParameters": true,
+    "strictTemplates": true
+  }
+}
diff --git 
a/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs 
b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs
new file mode 100644
index 0000000000..f75c774dfa
--- /dev/null
+++ 
b/zeppelin-web-angular/e2e/core-contract/notebook-core-port-identity.test.mjs
@@ -0,0 +1,149 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { createReadStream, existsSync, statSync } from 'node:fs';
+import { createServer } from 'node:http';
+import { extname, isAbsolute, join, relative, resolve } from 'node:path';
+import { after, before, test } from 'node:test';
+import { pathToFileURL } from 'node:url';
+
+import { chromium, expect } from '@playwright/test';
+
+const angularDistRoot = resolve('dist/notebook-core-port-proof');
+const angularIndexPath = join(angularDistRoot, 'index.html');
+const remoteEntryPath = join(angularDistRoot, 'assets/react/remoteEntry.js');
+
+let browser;
+let server;
+let baseUrl;
+
+const contentTypes = new Map([
+  ['.css', 'text/css; charset=utf-8'],
+  ['.html', 'text/html; charset=utf-8'],
+  ['.js', 'text/javascript; charset=utf-8']
+]);
+
+function resolveInsideAngularDist(requestPath) {
+  const decodedPath = decodeURIComponent(requestPath.replace(/^\//, ''));
+  const filePath = resolve(angularDistRoot, decodedPath);
+  const rootRelativePath = relative(angularDistRoot, filePath);
+
+  if (rootRelativePath.startsWith('..') || isAbsolute(rootRelativePath)) {
+    return null;
+  }
+
+  return filePath;
+}
+
+function isFile(filePath) {
+  return statSync(filePath, { throwIfNoEntry: false })?.isFile() ?? false;
+}
+
+function serveStaticFile(response, requestPath) {
+  const filePath = resolveInsideAngularDist(requestPath);
+
+  if (!filePath || !isFile(filePath)) {
+    response.writeHead(404);
+    response.end('not found');
+    return;
+  }
+
+  response.writeHead(200, {
+    'cache-control': 'no-store',
+    'content-type': contentTypes.get(extname(filePath)) ?? 
'application/octet-stream'
+  });
+  createReadStream(filePath).pipe(response);
+}
+
+before(async () => {
+  assert.ok(
+    existsSync(angularIndexPath),
+    `Angular host build output is missing: run "npm run 
build:notebook-core-port-proof" before this proof (${pathToFileURL(
+      angularIndexPath
+    )})`
+  );
+  assert.ok(
+    existsSync(remoteEntryPath),
+    `React remote asset is missing: run "npm run 
build:notebook-core-port-proof" before this proof (${pathToFileURL(
+      remoteEntryPath
+    )})`
+  );
+
+  server = createServer((request, response) => {
+    const requestPath = request.url?.split('?')[0] ?? '/';
+    if (requestPath === '/') {
+      response.writeHead(200, {
+        'cache-control': 'no-store',
+        'content-type': 'text/html; charset=utf-8'
+      });
+      createReadStream(angularIndexPath).pipe(response);
+      return;
+    }
+
+    if (isFile(resolveInsideAngularDist(requestPath) ?? '')) {
+      serveStaticFile(response, requestPath);
+      return;
+    }
+
+    response.writeHead(404, { 'cache-control': 'no-store' });
+    response.end();
+  });
+
+  await new Promise(resolveListen => {
+    server.listen(0, '127.0.0.1', resolveListen);
+  });
+  const address = server.address();
+  assert.ok(address && typeof address === 'object');
+  baseUrl = `http://127.0.0.1:${address.port}`;
+  browser = await chromium.launch();
+});
+
+after(async () => {
+  await browser?.close();
+  await new Promise(resolveClose => server?.close(resolveClose));
+});
+
+test('React remote receives the exact host-owned NotebookCorePort object', 
async () => {
+  const page = await browser.newPage();
+
+  await page.goto(baseUrl);
+
+  const probe = page.getByTestId('notebook-core-port-probe');
+  await expect(probe).toHaveAttribute('data-same-identity', 'true', { timeout: 
15_000 });
+  await expect(probe).toHaveAttribute('data-note-id', 'note-host-owned');
+  await expect(probe).toHaveAttribute('data-update-count', '0');
+
+  await page.waitForFunction(() => 
globalThis.__zeppelinNotebookCorePortProof?.receivedCore !== undefined);
+  const hostIdentity = await page.evaluate(() =>
+    Object.is(
+      globalThis.__zeppelinNotebookCorePortProof.hostCore,
+      globalThis.__zeppelinNotebookCorePortProof.receivedCore
+    )
+  );
+  assert.equal(hostIdentity, true);
+
+  await page.getByTestId('publish-notebook-core-revision').click();
+
+  await expect(probe).toHaveAttribute('data-revision-id', 
'revision-from-angular-host');
+  await expect(probe).toHaveAttribute('data-update-count', '1');
+
+  await expect
+    .poll(() => page.evaluate(() => 
globalThis.__zeppelinNotebookCorePortProof.proofs.at(-1)))
+    .toEqual({
+      sameIdentity: true,
+      snapshot: { noteId: 'note-host-owned', revisionId: 
'revision-from-angular-host' },
+      updateCount: 1
+    });
+
+  await page.close();
+});
diff --git 
a/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx 
b/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx
new file mode 100644
index 0000000000..43b304b917
--- /dev/null
+++ 
b/zeppelin-web-angular/e2e/core-contract/react-remote/NotebookCorePortProbe.tsx
@@ -0,0 +1,90 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { useEffect, useState } from 'react';
+import { createRoot, Root } from 'react-dom/client';
+import type { NotebookCorePort, NotebookCoreRemoteProps, NotebookCoreSnapshot 
} from '@zeppelin/notebook-core';
+
+export type NotebookCorePortProbeProps = NotebookCoreRemoteProps &
+  Readonly<{
+    expectedCore?: NotebookCorePort;
+    onReceivedCore?: (core: NotebookCorePort) => void;
+    onProof?: (proof: NotebookCorePortProbeProof) => void;
+  }>;
+
+export type NotebookCorePortProbeProof = Readonly<{
+  sameIdentity: boolean;
+  snapshot: NotebookCoreSnapshot;
+  updateCount: number;
+}>;
+
+export const NotebookCorePortProbe = ({ core, expectedCore, onProof, 
onReceivedCore }: NotebookCorePortProbeProps) => {
+  const [snapshot, setSnapshot] = useState(() => core.getSnapshot());
+  const [updateCount, setUpdateCount] = useState(0);
+  const sameIdentity = Object.is(core, expectedCore);
+
+  useEffect(() => {
+    onProof?.({ sameIdentity, snapshot, updateCount });
+    onReceivedCore?.(core);
+  }, [core, onProof, onReceivedCore, sameIdentity, snapshot, updateCount]);
+
+  useEffect(() => {
+    return core.subscribe(() => {
+      setSnapshot(core.getSnapshot());
+      setUpdateCount(value => value + 1);
+    });
+  }, [core]);
+
+  return (
+    <section
+      data-testid="notebook-core-port-probe"
+      data-same-identity={sameIdentity ? 'true' : 'false'}
+      data-note-id={snapshot.noteId}
+      data-revision-id={snapshot.revisionId ?? ''}
+      data-update-count={String(updateCount)}
+    >
+      <span>{snapshot.noteId}</span>
+      <span>{snapshot.revisionId ?? 'live'}</span>
+    </section>
+  );
+};
+
+export interface NotebookCorePortProbeMountHandle {
+  update: (props: NotebookCorePortProbeProps) => void;
+  unmount: () => void;
+}
+
+export const mount = (
+  element: HTMLElement,
+  initialProps: NotebookCorePortProbeProps
+): NotebookCorePortProbeMountHandle => {
+  if (!element) {
+    throw new Error('Mount element is required');
+  }
+
+  const root: Root = createRoot(element);
+
+  const renderWith = (props: NotebookCorePortProbeProps) => {
+    root.render(<NotebookCorePortProbe {...props} />);
+  };
+
+  renderWith(initialProps);
+
+  return {
+    update: (newProps: NotebookCorePortProbeProps) => {
+      renderWith(newProps);
+    },
+    unmount: () => {
+      root.unmount();
+    }
+  };
+};
diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts 
b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
similarity index 59%
copy from zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
copy to zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
index 7b1ad5f998..7afbc20c3e 100644
--- a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/empty.ts
@@ -10,12 +10,4 @@
  * limitations under the License.
  */
 
-import ts from 'typescript';
-
-export const createFixtureHost = (options: ts.CompilerOptions, files: 
ReadonlyMap<string, string>): ts.CompilerHost => {
-  const host = ts.createCompilerHost(options);
-  const { readFile, fileExists } = host;
-  host.readFile = file => files.get(file) ?? readFile(file);
-  host.fileExists = file => files.has(file) || fileExists(file);
-  return host;
-};
+export {};
diff --git a/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json 
b/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json
new file mode 100644
index 0000000000..b8084eda2b
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/tsconfig.json
@@ -0,0 +1,16 @@
+{
+  "extends": "../../../projects/zeppelin-react/tsconfig.json",
+  "compilerOptions": {
+    "baseUrl": "../../../",
+    "noEmit": false,
+    "paths": {
+      "@zeppelin/notebook-core": 
["projects/zeppelin-notebook-core/src/public-api.ts"],
+      "@zeppelin/notebook-core/*": ["projects/zeppelin-notebook-core/src/*"],
+      "react": 
["projects/zeppelin-react/node_modules/@types/react/index.d.ts"],
+      "react-dom/client": 
["projects/zeppelin-react/node_modules/@types/react-dom/client.d.ts"],
+      "react/jsx-runtime": 
["projects/zeppelin-react/node_modules/@types/react/jsx-runtime.d.ts"]
+    },
+    "typeRoots": ["projects/zeppelin-react/node_modules/@types", 
"node_modules/@types"]
+  },
+  "include": ["*.ts", "*.tsx"]
+}
diff --git 
a/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js 
b/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js
new file mode 100644
index 0000000000..d5266dd47a
--- /dev/null
+++ b/zeppelin-web-angular/e2e/core-contract/react-remote/webpack.config.js
@@ -0,0 +1,91 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+const path = require('path');
+
+const webRoot = path.resolve(__dirname, '../../..');
+const reactRemoteRoot = path.resolve(webRoot, 'projects/zeppelin-react');
+const ModuleFederationPlugin = require(
+  path.join(reactRemoteRoot, 
'node_modules/webpack/lib/container/ModuleFederationPlugin')
+);
+
+class RejectNotebookCoreRuntimePlugin {
+  apply(compiler) {
+    compiler.hooks.compilation.tap('RejectNotebookCoreRuntimePlugin', 
compilation => {
+      compilation.hooks.finishModules.tap('RejectNotebookCoreRuntimePlugin', 
modules => {
+        const coreRoots = [
+          path.resolve(webRoot, 'projects/zeppelin-notebook-core'),
+          path.resolve(webRoot, 'dist/zeppelin-notebook-core')
+        ];
+        const bundledCoreModules = [...modules]
+          .map(module => module.resource)
+          .filter(
+            resource =>
+              typeof resource === 'string' &&
+              coreRoots.some(coreRoot => 
path.resolve(resource).startsWith(`${coreRoot}${path.sep}`))
+          );
+        if (bundledCoreModules.length > 0) {
+          compilation.errors.push(
+            new Error(`React remote bundled Shared Notebook Core runtime: 
${bundledCoreModules.join(', ')}`)
+          );
+        }
+      });
+    });
+  }
+}
+
+module.exports = {
+  entry: './empty.ts',
+  context: __dirname,
+  resolve: {
+    extensions: ['.tsx', '.ts', '.js', '.jsx'],
+    modules: [path.resolve(reactRemoteRoot, 'node_modules'), 
path.resolve(webRoot, 'node_modules'), 'node_modules'],
+    alias: {
+      '@zeppelin/notebook-core': path.resolve(webRoot, 
'projects/zeppelin-notebook-core/src/public-api.ts')
+    }
+  },
+  resolveLoader: {
+    modules: [path.resolve(reactRemoteRoot, 'node_modules'), 
path.resolve(webRoot, 'node_modules'), 'node_modules']
+  },
+  module: {
+    rules: [
+      {
+        test: /\.tsx?$/,
+        use: {
+          loader: 'ts-loader',
+          options: {
+            configFile: path.resolve(__dirname, 'tsconfig.json'),
+            transpileOnly: true
+          }
+        },
+        exclude: /node_modules/
+      }
+    ]
+  },
+  output: {
+    clean: true,
+    path: path.resolve(__dirname, 'dist'),
+    publicPath: '/assets/react/',
+    scriptType: 'text/javascript',
+    uniqueName: 'notebookCorePortProof'
+  },
+  plugins: [
+    new RejectNotebookCoreRuntimePlugin(),
+    new ModuleFederationPlugin({
+      exposes: {
+        './NotebookCorePortProbe': './NotebookCorePortProbe'
+      },
+      filename: 'remoteEntry.js',
+      name: 'reactApp'
+    })
+  ]
+};
diff --git 
a/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs
 
b/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs
new file mode 100644
index 0000000000..9ea8927a3a
--- /dev/null
+++ 
b/zeppelin-web-angular/e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs
@@ -0,0 +1,67 @@
+/*
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *     http://www.apache.org/licenses/LICENSE-2.0
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import assert from 'node:assert/strict';
+import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
+import { createRequire } from 'node:module';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+import { test } from 'node:test';
+import { fileURLToPath } from 'node:url';
+
+const reactRemoteRoot = fileURLToPath(new 
URL('../../projects/zeppelin-react/', import.meta.url));
+const requireFromReactRemote = createRequire(join(reactRemoteRoot, 
'package.json'));
+const webpack = requireFromReactRemote('webpack');
+const ModuleFederationPlugin = 
requireFromReactRemote('webpack/lib/container/ModuleFederationPlugin');
+const proofConfig = 
createRequire(import.meta.url)('./react-remote/webpack.config.js');
+const coreEntryPoint = fileURLToPath(
+  new URL('../../projects/zeppelin-notebook-core/src/public-api.ts', 
import.meta.url)
+);
+
+test('rejects a React remote that bundles the Shared Notebook Core runtime', 
async () => {
+  const fixtureRoot = mkdtempSync(join(tmpdir(), 
'zeppelin-notebook-core-runtime-'));
+  try {
+    const exposed = join(fixtureRoot, 'BundledCore.ts');
+    writeFileSync(exposed, "export * as notebookCore from 
'@zeppelin/notebook-core';\n");
+    const outputPath = join(fixtureRoot, 'dist');
+    const compiler = webpack({
+      ...proofConfig,
+      mode: 'production',
+      output: { ...proofConfig.output, path: outputPath },
+      plugins: [
+        ...proofConfig.plugins.filter(plugin => !(plugin instanceof 
ModuleFederationPlugin)),
+        new ModuleFederationPlugin({
+          exposes: { './BundledCore': exposed },
+          filename: 'remoteEntry.js',
+          name: 'reactApp'
+        })
+      ]
+    });
+
+    const stats = await new Promise((resolveRun, rejectRun) => {
+      compiler.run((error, result) => {
+        compiler.close(() => (error ? rejectRun(error) : resolveRun(result)));
+      });
+    });
+
+    const errors = stats.compilation.errors.map(error => error.message);
+    assert.ok(
+      errors.some(
+        message => message.includes('bundled Shared Notebook Core runtime') && 
message.includes(coreEntryPoint)
+      ),
+      `expected a Shared Notebook Core rejection naming ${coreEntryPoint}, 
got: ${errors.join('\n')}`
+    );
+    assert.equal(existsSync(join(outputPath, 'remoteEntry.js')), false);
+  } finally {
+    rmSync(fixtureRoot, { recursive: true, force: true });
+  }
+});
diff --git a/zeppelin-web-angular/package.json 
b/zeppelin-web-angular/package.json
index 492b631cb8..0b6cf15458 100644
--- a/zeppelin-web-angular/package.json
+++ b/zeppelin-web-angular/package.json
@@ -10,6 +10,8 @@
     "start:react": "cd projects/zeppelin-react && npm run dev",
     "build": "npm run build:projects && npm run build:react && npm run 
build:angular",
     "build:angular": "ng build --configuration production",
+    "build:notebook-core-port-proof": "npm run 
build:notebook-core-port-proof:react && ng build --project 
notebook-core-port-proof --configuration production",
+    "build:notebook-core-port-proof:react": "cd projects/zeppelin-react && npx 
webpack --config ../../e2e/core-contract/react-remote/webpack.config.js --mode 
production",
     "build:react": "cd projects/zeppelin-react && npm run build",
     "build:projects": "npm run build-project:sdk && npm run 
build-project:notebook-core && npm run build-project:vis",
     "build-project:notebook-core": "ng build --project zeppelin-notebook-core",
@@ -22,13 +24,14 @@
     "lint:fix": "cross-env NODE_OPTIONS='--max-old-space-size=8192' ng lint 
--fix && npm run lint:fix:react && prettier --write 
\"**/*.{ts,tsx,mts,js,mjs,json,css,html}\"",
     "lint:react": "cd projects/zeppelin-react && npm run lint",
     "lint:fix:react": "cd projects/zeppelin-react && npm run lint:fix",
-    "typecheck:notebook-core": "tsc -p 
projects/zeppelin-notebook-core/tsconfig.json --noEmit && tsc -p 
projects/zeppelin-notebook-core/tsconfig.spec.json --noEmit && npm run 
build-project:notebook-core && tsc -p 
projects/zeppelin-react/tsconfig.notebook-core.dist.json --noEmit && tsc -p 
projects/zeppelin-react/tsconfig.notebook-core.json --noEmit",
+    "typecheck:notebook-core": "tsc -p 
projects/zeppelin-notebook-core/tsconfig.json --noEmit && tsc -p 
projects/zeppelin-notebook-core/tsconfig.spec.json --noEmit && npm run 
build-project:notebook-core && tsc -p 
projects/zeppelin-react/tsconfig.notebook-core.dist.json --noEmit && tsc -p 
projects/zeppelin-react/tsconfig.notebook-core.json --noEmit && tsc -p 
e2e/core-contract/react-remote/tsconfig.json --noEmit",
     "typecheck:sdk-contracts": "tsc -p 
projects/zeppelin-sdk/tsconfig.spec.json --noEmit",
+    "test:notebook-core-port-identity": "node --test 
e2e/core-contract/notebook-core-port-identity.test.mjs",
     "test:notebook-core": "vitest run --config 
vitest.notebook-core.config.mts",
     "test:shell": "vitest run --config vitest.shell.config.mts",
     "test:eslint-rules": "node --test eslint-rules/*.test.js",
     "e2e": "playwright test",
-    "check:core-contract-fixtures": "node --test 
e2e/core-contract/notebook-transport-fixture.test.mjs 
e2e/core-contract/playwright-runner.test.mjs",
+    "check:core-contract-fixtures": "node --test 
e2e/core-contract/notebook-transport-fixture.test.mjs 
e2e/core-contract/playwright-runner.test.mjs 
e2e/core-contract/reject-notebook-core-runtime-plugin.test.mjs",
     "check:core-contract-auth": "cross-env ZEPPELIN_RUN_AUTH_SETUP_TEST=1 node 
--test --test-name-pattern='live anonymous setup' 
e2e/core-contract/playwright-runner.test.mjs",
     "check:core-contract-server": "node --test 
e2e/core-contract/capture-server.test.mjs",
     "e2e:core-contract": "playwright test --config 
playwright.core-contract.config.js --project=chromium",
diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml
index 93b28c184e..ad6de57998 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -35,6 +35,8 @@
     <web.e2e.enabled>false</web.e2e.enabled>
     <!-- Classic UI e2e runs opt-in: CI enables it on the anonymous leg only. 
-->
     <web.e2e.classic.disabled>true</web.e2e.classic.disabled>
+    <!-- The port identity proof does not depend on the auth mode, so CI runs 
it on the anonymous leg only. -->
+    
<web.e2e.core.port.proof.disabled>${web.e2e.disabled}</web.e2e.core.port.proof.disabled>
     <zeppelin.daemon.package.base>../bin</zeppelin.daemon.package.base>
     <project.build.sourceEncoding>UTF-8</project.build.sourceEncoding>
 
@@ -188,6 +190,31 @@
             </configuration>
           </execution>
 
+          <execution>
+            <id>npm build notebook core port identity proof</id>
+            <goals>
+              <goal>npm</goal>
+            </goals>
+            <phase>integration-test</phase>
+            <configuration>
+              <!-- Built only where the Chromium proof below runs. -->
+              <skip>${web.e2e.core.port.proof.disabled}</skip>
+              <arguments>run build:notebook-core-port-proof</arguments>
+            </configuration>
+          </execution>
+
+          <execution>
+            <id>npm test notebook core port identity</id>
+            <goals>
+              <goal>npm</goal>
+            </goals>
+            <phase>integration-test</phase>
+            <configuration>
+              <skip>${web.e2e.core.port.proof.disabled}</skip>
+              <arguments>run test:notebook-core-port-identity</arguments>
+            </configuration>
+          </execution>
+
           <execution>
             <id>npm e2e</id>
             <goals>
diff --git a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts 
b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
index 7b1ad5f998..c6f0370361 100644
--- a/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
+++ b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
@@ -14,8 +14,11 @@ import ts from 'typescript';
 
 export const createFixtureHost = (options: ts.CompilerOptions, files: 
ReadonlyMap<string, string>): ts.CompilerHost => {
   const host = ts.createCompilerHost(options);
-  const { readFile, fileExists } = host;
+  const { readFile, fileExists, directoryExists } = host;
   host.readFile = file => files.get(file) ?? readFile(file);
   host.fileExists = file => files.has(file) || fileExists(file);
+  // Module resolution does not probe files in directories it considers 
missing.
+  host.directoryExists = directory =>
+    [...files.keys()].some(file => file.startsWith(`${directory}/`)) || 
(directoryExists?.(directory) ?? true);
   return host;
 };
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts 
b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
index be3fc00f56..e8e957ad47 100644
--- a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
@@ -11,7 +11,7 @@
  */
 
 import { readFileSync } from 'node:fs';
-import { dirname, resolve } from 'node:path';
+import { dirname, relative, resolve } from 'node:path';
 
 import ts from 'typescript';
 import { describe, expect, it } from 'vitest';
@@ -20,11 +20,16 @@ import { createFixtureHost } from './compiler-fixture';
 import {
   sourceRoot,
   zeppelinWebAngularRoot,
+  reactNotebookCoreProofConsumer,
+  reactNotebookCoreProofConsumers,
+  reactNotebookCoreProofRoot,
   reactNotebookCoreBoundaryFiles,
   forbiddenModulePrefixes,
   forbiddenReactNotebookCoreConsumerModulePrefixes,
   sourceFiles,
   findReactNotebookConsumerViolations,
+  findNotebookCoreValueImportViolations,
+  findNotebookRemoteConsumerViolations,
   findNotebookContractViolations,
   findViolations,
   formatViolations,
@@ -32,6 +37,18 @@ import {
 } from './import-boundary';
 
 describe('notebook core import boundary', () => {
+  it('runs the browser-dependent separate-build proof with the browser e2e 
gate', () => {
+    const pom = readFileSync(resolve(zeppelinWebAngularRoot, 'pom.xml'), 
'utf8');
+    expect(pom).toMatch(
+      
/<web\.e2e\.core\.port\.proof\.disabled>\$\{web\.e2e\.disabled\}<\/web\.e2e\.core\.port\.proof\.disabled>/
+    );
+    for (const id of ['npm build notebook core port identity proof', 'npm test 
notebook core port identity']) {
+      const execution = pom.match(new 
RegExp(`<execution>\\s*<id>${id}</id>([\\s\\S]*?)</execution>`))?.[1];
+      expect(execution).toMatch(/<phase>integration-test<\/phase>/);
+      
expect(execution).toMatch(/<skip>\$\{web\.e2e\.core\.port\.proof\.disabled\}<\/skip>/);
+    }
+  });
+
   it('resolves the React public contract without exposing source subpaths', () 
=> {
     const path = reactNotebookCoreBoundaryFiles[1];
     const options = readCompilerOptions(resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/tsconfig.json'));
@@ -135,10 +152,12 @@ describe('notebook core import boundary', () => {
     }
   );
 
-  it('rejects direct transport imports in the React entry point and notebook 
core contract', () => {
-    const violations = reactNotebookCoreBoundaryFiles.flatMap(path => {
+  it('rejects direct transport imports in the React entry point, contract 
bridge and remote probe', () => {
+    const violations = [...reactNotebookCoreBoundaryFiles, 
reactNotebookCoreProofConsumer].flatMap(path => {
       const source = readFileSync(path, 'utf8');
-      return findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes);
+      return path === reactNotebookCoreProofConsumer
+        ? findNotebookRemoteConsumerViolations(path, source)
+        : findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes);
     });
 
     expect(formatViolations(violations)).toEqual([]);
@@ -150,6 +169,217 @@ describe('notebook core import boundary', () => {
     expect(findReactNotebookConsumerViolations()).toEqual([]);
   }, 30_000);
 
+  it('rejects a runtime or factory import in the separate-build remote probe', 
() => {
+    expect(
+      findNotebookRemoteConsumerViolations(
+        reactNotebookCoreProofConsumer,
+        "import { createNotebookCore } from '@zeppelin/notebook-core';"
+      )
+    ).toEqual([
+      `${reactNotebookCoreProofConsumer}: runtime notebook core import`,
+      `${reactNotebookCoreProofConsumer}: remote must import only 
NotebookCorePort contract types`
+    ]);
+    expect(
+      findNotebookCoreValueImportViolations(
+        resolve(zeppelinWebAngularRoot, 
'e2e/core-contract/react-remote/helper.ts'),
+        "export { createNotebookCore } from '@zeppelin/notebook-core';"
+      )
+    ).toEqual([
+      `${resolve(zeppelinWebAngularRoot, 
'e2e/core-contract/react-remote/helper.ts')}: runtime notebook core import`
+    ]);
+  });
+
+  it('rejects require.resolve of the core runtime', () => {
+    expect(
+      findNotebookCoreValueImportViolations(
+        reactNotebookCoreProofConsumer,
+        "export const corePath = require.resolve('@zeppelin/notebook-core');"
+      )
+    ).toEqual([`${reactNotebookCoreProofConsumer}: runtime notebook core 
import`]);
+  });
+
+  it.each([
+    "import '../../../projects/zeppelin-notebook-core/src/public-api';",
+    "export { createNotebookCore } from 
'../../../projects/zeppelin-notebook-core/src/public-api';"
+  ])('rejects a runtime core dependency by its resolved target: %s', source => 
{
+    const path = resolve(zeppelinWebAngularRoot, 
'e2e/core-contract/react-remote/helper.ts');
+    expect(findNotebookCoreValueImportViolations(path, 
source)).toEqual([`${path}: runtime notebook core import`]);
+  });
+
+  it('allows a type-only core dependency by its resolved target and restricts 
its public contract types', () => {
+    const path = reactNotebookCoreProofConsumer;
+    const target = resolve(sourceRoot, 'public-api.ts');
+    const relativeTarget = `./${relative(dirname(path), target).replace(/\\/g, 
'/').replace(/\.ts$/, '')}`;
+    const source = `import type { NotebookCorePort } from 
'${relativeTarget}';\nexport type Port = NotebookCorePort;`;
+
+    expect(findNotebookCoreValueImportViolations(path, source)).toEqual([]);
+    expect(findNotebookRemoteConsumerViolations(path, source)).toEqual([]);
+
+    expect(
+      findNotebookRemoteConsumerViolations(path, `export type Port = 
import('${relativeTarget}').NotebookCorePort;`)
+    ).toEqual([]);
+
+    const invalidSource = `import type { NotebookCoreUnsubscribe } from 
'${relativeTarget}';`;
+    expect(findNotebookRemoteConsumerViolations(path, 
invalidSource)).toContain(
+      `${path}: remote notebook core import NotebookCoreUnsubscribe`
+    );
+    expect(
+      findNotebookRemoteConsumerViolations(
+        path,
+        `export type Unsubscribe = 
import('${relativeTarget}').NotebookCoreUnsubscribe;`
+      )
+    ).toContain(`${path}: remote notebook core import 
NotebookCoreUnsubscribe`);
+  });
+
+  it('enforces the core boundary for built-package aliases and relative 
targets', () => {
+    const path = reactNotebookCoreProofConsumer;
+    const builtDeclaration = resolve(
+      zeppelinWebAngularRoot,
+      'dist/zeppelin-notebook-core/types/zeppelin-notebook-core.d.ts'
+    );
+    const options = {
+      ...readCompilerOptions(resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/tsconfig.json')),
+      baseUrl: zeppelinWebAngularRoot,
+      paths: { '@built-core': 
['dist/zeppelin-notebook-core/types/zeppelin-notebook-core.d.ts'] }
+    };
+    const host = createFixtureHost(
+      options,
+      new Map([
+        [
+          builtDeclaration,
+          'export type NotebookCorePort = Readonly<{}>; export declare const 
createNotebookCore: () => void;'
+        ]
+      ])
+    );
+    const relativeTarget = relative(dirname(path), builtDeclaration)
+      .replace(/\\/g, '/')
+      .replace(/\.d\.ts$/, '');
+
+    for (const source of [
+      "import '@built-core';",
+      "export { createNotebookCore } from '@built-core';",
+      `import '${relativeTarget.startsWith('.') ? relativeTarget : 
`./${relativeTarget}`}';`
+    ]) {
+      expect(findNotebookCoreValueImportViolations(path, source, options, 
host)).toEqual([
+        `${path}: runtime notebook core import`
+      ]);
+    }
+    expect(
+      findNotebookRemoteConsumerViolations(
+        path,
+        "import type { NotebookCorePort } from '@built-core'; export type Port 
= NotebookCorePort;",
+        options,
+        host
+      )
+    ).toEqual([]);
+    expect(
+      findNotebookRemoteConsumerViolations(
+        path,
+        "import type { createNotebookCore } from '@built-core';",
+        options,
+        host
+      )
+    ).toContain(`${path}: remote notebook core import createNotebookCore`);
+  }, 30_000);
+
+  it('rejects a transitive runtime re-export of the core reached through a 
consumer helper', () => {
+    const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
+    const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+    const helper = resolve(root, 'NotebookBoundaryHelper.ts');
+    const publicApi = resolve(sourceRoot, 'public-api.ts');
+    const helperTarget = relative(dirname(helper), publicApi).replace(/\\/g, 
'/').replace(/\.ts$/, '');
+    const files = new Map<string, string>([
+      [
+        consumer,
+        "import type { NotebookCoreRemoteProps } from 
'./notebookCoreContract'; export type Props = NotebookCoreRemoteProps; export * 
from './NotebookBoundaryHelper';"
+      ],
+      [
+        helper,
+        `export { createNotebookCore } from '${helperTarget.startsWith('.') ? 
helperTarget : `./${helperTarget}`}';`
+      ]
+    ]);
+    const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+    const host = createFixtureHost(options, files);
+
+    
expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles, 
consumer], options, host)).toContain(
+      `${helper}: runtime notebook core import`
+    );
+  }, 30_000);
+
+  it('rejects a transitive runtime re-export through a resolved node_modules 
package', () => {
+    const wrapper = resolve(zeppelinWebAngularRoot, 
'node_modules/zeppelin-notebook-boundary-fixture/index.ts');
+    const wrapperSpecifier = relative(dirname(reactNotebookCoreProofConsumer), 
wrapper).replace(/\\/g, '/');
+    const source = `import type { NotebookCorePort } from 
'@zeppelin/notebook-core'; export type Port = NotebookCorePort; export * from 
'${
+      wrapperSpecifier.startsWith('.') ? wrapperSpecifier : 
`./${wrapperSpecifier}`
+    }';`;
+    const options = 
readCompilerOptions(resolve(dirname(reactNotebookCoreProofConsumer), 
'tsconfig.json'));
+    const violationsWithWrapper = (wrapperSource: string): string[] =>
+      findReactNotebookConsumerViolations(
+        [reactNotebookCoreProofConsumer],
+        options,
+        createFixtureHost(
+          options,
+          new Map([
+            [reactNotebookCoreProofConsumer, source],
+            [wrapper, wrapperSource]
+          ])
+        )
+      );
+
+    expect(violationsWithWrapper("export { createNotebookCore } from 
'@zeppelin/notebook-core';\n")).toContain(
+      `${wrapper}: runtime notebook core import`
+    );
+    expect(violationsWithWrapper("export type { NotebookCorePort } from 
'@zeppelin/notebook-core';\n")).toEqual([]);
+  }, 30_000);
+
+  it('restricts notebook core types throughout the separate remote dependency 
graph', () => {
+    const helper = resolve(dirname(reactNotebookCoreProofConsumer), 
'contract-helper.ts');
+    const files = new Map<string, string>([
+      [
+        reactNotebookCoreProofConsumer,
+        "import type { Factory } from './contract-helper'; export type 
RemoteFactory = Factory;"
+      ],
+      [helper, "export type Factory = 
import('@zeppelin/notebook-core').NotebookCoreUnsubscribe;"]
+    ]);
+    const options = 
readCompilerOptions(resolve(dirname(reactNotebookCoreProofConsumer), 
'tsconfig.json'));
+    const host = createFixtureHost(options, files);
+
+    expect(
+      ts
+        
.getPreEmitDiagnostics(ts.createProgram([reactNotebookCoreProofConsumer], 
options, host))
+        .filter(diagnostic => (diagnostic.file ? 
files.has(diagnostic.file.fileName) : false))
+    ).toEqual([]);
+    
expect(findReactNotebookConsumerViolations([reactNotebookCoreProofConsumer], 
options, host)).toContain(
+      `${helper}: remote notebook core import NotebookCoreUnsubscribe`
+    );
+  }, 30_000);
+
+  it('scans every source of the separate-build remote, not only the probe', () 
=> {
+    const consumers = reactNotebookCoreProofConsumers();
+    expect(consumers).toContain(reactNotebookCoreProofConsumer);
+    expect(consumers).toContain(resolve(reactNotebookCoreProofRoot, 
'empty.ts'));
+    expect(
+      consumers.filter(path =>
+        
/^(?:dist|build|node_modules)\/|webpack\.config/.test(relative(reactNotebookCoreProofRoot,
 path))
+      )
+    ).toEqual([]);
+
+    const exposed = resolve(reactNotebookCoreProofRoot, 'SecondProbe.tsx');
+    const options = readCompilerOptions(resolve(reactNotebookCoreProofRoot, 
'tsconfig.json'));
+    const host = createFixtureHost(
+      options,
+      new Map([
+        [
+          exposed,
+          "import type { NotebookCoreUnsubscribe } from 
'@zeppelin/notebook-core'; export type U = NotebookCoreUnsubscribe;"
+        ]
+      ])
+    );
+    expect(findReactNotebookConsumerViolations([exposed], options, 
host)).toContain(
+      `${exposed}: remote notebook core import NotebookCoreUnsubscribe`
+    );
+  }, 30_000);
+
   it.each(['direct', 'helper', 'route', 'barrel', 'javascript', 'cycle', 
'computed', 'require-outside'])(
     'discovers a new notebook consumer and rejects its %s transport 
dependency',
     form => {
@@ -183,7 +413,11 @@ describe('notebook core import boundary', () => {
       const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
       const host = createFixtureHost(options, files);
       const roots = [consumer, route, barrel, 
...reactNotebookCoreBoundaryFiles];
-      expect(ts.getPreEmitDiagnostics(ts.createProgram(roots, options, 
host))).toEqual([]);
+      expect(
+        ts
+          .getPreEmitDiagnostics(ts.createProgram(roots, options, host))
+          .filter(diagnostic => (diagnostic.file ? 
files.has(diagnostic.file.fileName) : false))
+      ).toEqual([]);
       const violations = findReactNotebookConsumerViolations(roots, options, 
host);
       const offender = ['helper', 'javascript', 'cycle', 
'require-outside'].includes(form)
         ? helper
@@ -193,7 +427,8 @@ describe('notebook core import boundary', () => {
       expect(violations).toContain(
         `${offender}: import ${form === 'computed' ? '<computed module 
dependency>' : 'rxjs/webSocket'}`
       );
-    }
+    },
+    30_000
   );
 
   it.each([
@@ -202,16 +437,20 @@ describe('notebook core import boundary', () => {
     ["export type { ClientHttp2Session } from 'node:http2';", 'import 
node:http2'],
     ['export const request = fetch;', 'global fetch'],
     ['export const socket = WebSocket;', 'global WebSocket']
-  ])('rejects transport access in a consumer of the public core API: %s', 
(transport, violation) => {
-    const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
-    const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
-    const source = `import type { NotebookCoreRemoteProps } from 
'@zeppelin/notebook-core';
+  ])(
+    'rejects transport access in a consumer of the public core API: %s',
+    (transport, violation) => {
+      const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
+      const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+      const source = `import type { NotebookCoreRemoteProps } from 
'@zeppelin/notebook-core';
       export const read = (props: NotebookCoreRemoteProps) => 
props.core.getSnapshot(); ${transport}`;
-    const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
-    const host = createFixtureHost(options, new Map([[consumer, source]]));
-    expect(ts.getPreEmitDiagnostics(ts.createProgram([consumer], options, 
host))).toEqual([]);
-    expect(findReactNotebookConsumerViolations([consumer], options, 
host)).toContain(`${consumer}: ${violation}`);
-  });
+      const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+      const host = createFixtureHost(options, new Map([[consumer, source]]));
+      expect(ts.getPreEmitDiagnostics(ts.createProgram([consumer], options, 
host))).toEqual([]);
+      expect(findReactNotebookConsumerViolations([consumer], options, 
host)).toContain(`${consumer}: ${violation}`);
+    },
+    30_000
+  );
 
   it('rejects the mixed public aggregator as an internal notebook consumer 
dependency', () => {
     const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
@@ -222,7 +461,7 @@ describe('notebook core import boundary', () => {
     expect(findReactNotebookConsumerViolations([consumer], options, 
host)).toContain(
       `${consumer}: notebook consumer must use the contract bridge instead of 
the public aggregator`
     );
-  });
+  }, 30_000);
 
   it('allows a neutral notebook consumer without traversing unrelated legacy 
page exports', () => {
     const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
@@ -234,7 +473,7 @@ describe('notebook core import boundary', () => {
     
expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles, 
consumer], options, host)).toEqual(
       []
     );
-  });
+  }, 30_000);
 
   it('keeps the React contract dependent only on the public core entry point', 
() => {
     const path = reactNotebookCoreBoundaryFiles[1];
@@ -451,15 +690,19 @@ describe('notebook core import boundary', () => {
     ['let fetch: typeof window.fetch; ({ fetch } = window); export const 
request = fetch;', 'global fetch'],
     ['export const browser = { fetch };', 'global fetch'],
     ['type fetch = string; export const request = fetch;', 'global fetch']
-  ])('rejects wrapped transport globals: %s', (source, violation) => {
-    const path = reactNotebookCoreBoundaryFiles[1];
-    const options = readCompilerOptions(resolve(dirname(path), 
'../tsconfig.json'));
-    const host = createFixtureHost(options, new Map([[path, source]]));
-    expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, 
host))).toEqual([]);
-    expect(findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes)).toContain(
-      `${path}: ${violation}`
-    );
-  });
+  ])(
+    'rejects wrapped transport globals: %s',
+    (source, violation) => {
+      const path = reactNotebookCoreBoundaryFiles[1];
+      const options = readCompilerOptions(resolve(dirname(path), 
'../tsconfig.json'));
+      const host = createFixtureHost(options, new Map([[path, source]]));
+      expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, 
host))).toEqual([]);
+      expect(findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes)).toContain(
+        `${path}: ${violation}`
+      );
+    },
+    30_000
+  );
 
   it.each([
     "export const fetch = () => 'cached'; export const result = fetch();",
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.ts 
b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
index b99b0a811a..1fcfa4928b 100644
--- a/zeppelin-web-angular/test/notebook-core/import-boundary.ts
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
@@ -18,6 +18,8 @@ import ts from 'typescript';
 
 export const zeppelinWebAngularRoot = resolve(fileURLToPath(new URL('../../', 
import.meta.url)));
 export const sourceRoot = fileURLToPath(new 
URL('../../projects/zeppelin-notebook-core/src/', import.meta.url));
+export const reactNotebookCoreProofRoot = resolve(zeppelinWebAngularRoot, 
'e2e/core-contract/react-remote');
+export const reactNotebookCoreProofConsumer = 
resolve(reactNotebookCoreProofRoot, 'NotebookCorePortProbe.tsx');
 export const reactNotebookCoreBoundaryFiles = [
   resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/main.ts'),
   resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src/notebookCoreContract.ts')
@@ -69,26 +71,50 @@ const isCheckedSourceFile = (path: string): boolean => {
   return checkedSourceExtensions.some(extension => path.endsWith(extension)) 
&& !isSpecSourceFile(path);
 };
 
+const isReactNotebookCoreProofModule = (path: string): boolean =>
+  path.startsWith(`${reactNotebookCoreProofRoot}/`) && 
!path.includes('/node_modules/');
+
+// Any source in the separate-build remote can be exposed, so scan the 
directory
+// instead of one probe. Build output and webpack configuration are not remote 
code.
+export const reactNotebookCoreProofConsumers = (): string[] =>
+  sourceFiles(
+    reactNotebookCoreProofRoot,
+    path =>
+      /\.[cm]?[jt]sx?$/.test(path) &&
+      !isSpecSourceFile(path) &&
+      !/^(?:(?:dist|build|node_modules)\/|webpack\.config\.[cm]?js$)/.test(
+        relative(reactNotebookCoreProofRoot, path).replace(/\\/g, '/')
+      )
+  );
+
 export const findReactNotebookConsumerViolations = (
-  roots: string[] = sourceFiles(
-    resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'),
-    path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path)
-  ),
+  roots: string[] = [
+    ...sourceFiles(
+      resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'),
+      path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path)
+    ),
+    ...reactNotebookCoreProofConsumers()
+  ],
   options = readCompilerOptions(resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/tsconfig.json')),
   host: ts.CompilerHost = ts.createCompilerHost(options)
 ): string[] => {
   const program = ts.createProgram(roots, options, host);
   const modules = new Map<string, { source: string; dependencies: Set<string> 
}>();
-  const pending = [...program.getSourceFiles()];
-  for (const file of pending) {
-    if (modules.has(file.fileName) || 
file.fileName.includes('/node_modules/')) {
-      continue;
+  const loadModule = (path: string, source?: string): { source: string; 
dependencies: Set<string> } | undefined => {
+    const existing = modules.get(path);
+    if (existing) {
+      return existing;
+    }
+    const text = source ?? host.readFile(path);
+    if (text === undefined) {
+      return undefined;
     }
+    const file = ts.createSourceFile(path, text, ts.ScriptTarget.Latest, true, 
getScriptKind(path));
     const dependencies = new Set<string>();
     const visit = (node: ts.Node): void => {
       const specifier = getModuleSpecifier(node);
-      if (specifier) {
-        const target = ts.resolveModuleName(specifier, file.fileName, options, 
host).resolvedModule?.resolvedFileName;
+      if (specifier && specifier !== '<computed module dependency>') {
+        const target = ts.resolveModuleName(specifier, path, options, 
host).resolvedModule?.resolvedFileName;
         if (target) {
           dependencies.add(target);
         }
@@ -97,16 +123,26 @@ export const findReactNotebookConsumerViolations = (
     };
     visit(file);
     for (const reference of file.referencedFiles) {
-      dependencies.add(resolve(dirname(file.fileName), reference.fileName));
+      dependencies.add(resolve(dirname(path), reference.fileName));
     }
     for (const reference of file.typeReferenceDirectives) {
-      const target = ts.resolveTypeReferenceDirective(reference.fileName, 
file.fileName, options, host)
+      const target = ts.resolveTypeReferenceDirective(reference.fileName, 
path, options, host)
         .resolvedTypeReferenceDirective?.resolvedFileName;
       if (target) {
         dependencies.add(target);
       }
     }
-    modules.set(file.fileName, { source: file.text, dependencies });
+    const loaded = { source: text, dependencies };
+    modules.set(path, loaded);
+    return loaded;
+  };
+  const pending = [...program.getSourceFiles()];
+  for (const file of pending) {
+    if (modules.has(file.fileName) || 
file.fileName.includes('/node_modules/')) {
+      continue;
+    }
+    const module = loadModule(file.fileName, file.text);
+    const dependencies = module?.dependencies ?? new Set<string>();
     // TypeScript can resolve require() without adding its target to the 
program.
     // Inspect those local sources too, including helpers outside the root 
list.
     for (const target of dependencies) {
@@ -122,7 +158,8 @@ export const findReactNotebookConsumerViolations = (
   // Discover adapters, re-export barrels and routes from their dependency on 
the
   // shared contract. A new consumer must not require editing a scanner file 
list.
   const consumers = new Set([
-    ...reactNotebookCoreBoundaryFiles,
+    ...reactNotebookCoreBoundaryFiles.filter(path => modules.has(path)),
+    ...[...modules.keys()].filter(isReactNotebookCoreProofModule),
     ...[...modules.keys()].filter(path => path.startsWith(sourceRoot))
   ]);
   let changed = true;
@@ -139,24 +176,42 @@ export const findReactNotebookConsumerViolations = (
   const violations: string[] = [];
   const checked = new Set<string>();
   const main = reactNotebookCoreBoundaryFiles[0];
+  const remoteModules = new Set<string>();
+  const collectRemoteModules = (path: string): void => {
+    if (remoteModules.has(path) || path.startsWith(sourceRoot) || 
path.includes('/node_modules/')) {
+      return;
+    }
+    remoteModules.add(path);
+    loadModule(path)?.dependencies.forEach(collectRemoteModules);
+  };
+  // Start from remote sources in the checked program only; package graphs are 
not remote code.
+  
[...modules.keys()].filter(isReactNotebookCoreProofModule).forEach(collectRemoteModules);
   const check = (path: string): void => {
     if (checked.has(path) || path.startsWith(sourceRoot)) {
       return;
     }
     checked.add(path);
-    const module = modules.get(path);
+    const module = loadModule(path);
     if (!module) {
-      if (!path.includes('/node_modules/')) {
-        violations.push(`${path}: cannot inspect local notebook dependency`);
-      }
+      violations.push(`${path}: cannot inspect notebook dependency`);
       return;
     }
+    // Third-party declarations are only checked for re-exporting the core 
runtime;
+    // consumer transport rules apply to Zeppelin sources, not to package 
internals.
     violations.push(
-      ...findViolations(path, module.source, 
forbiddenReactNotebookCoreConsumerModulePrefixes, options, host)
+      ...(path.includes('/node_modules/')
+        ? findNotebookCoreValueImportViolations(path, module.source, options, 
host)
+        : remoteModules.has(path)
+          ? findNotebookRemoteConsumerViolations(path, module.source, options, 
host)
+          : [
+              ...findViolations(path, module.source, 
forbiddenReactNotebookCoreConsumerModulePrefixes, options, host),
+              ...findNotebookCoreValueImportViolations(path, module.source, 
options, host)
+            ])
     );
     // The public aggregator also exports existing SDK-backed pages. Check its
     // own imports, but do not include those unrelated pages in the core 
boundary.
-    if (path === main) {
+    // Package declarations are checked for their own re-exports only.
+    if (path === main || path.includes('/node_modules/')) {
       return;
     }
     for (const target of module.dependencies) {
@@ -171,6 +226,127 @@ export const findReactNotebookConsumerViolations = (
   return violations;
 };
 
+const notebookRemoteContractTypes = new Set(['NotebookCorePort', 
'NotebookCoreRemoteProps', 'NotebookCoreSnapshot']);
+
+const isTypeOnlyImportDeclaration = (node: ts.ImportDeclaration): boolean => {
+  const clause = node.importClause;
+  const bindings = clause?.namedBindings;
+  return (
+    clause?.isTypeOnly === true ||
+    (clause?.name === undefined &&
+      bindings !== undefined &&
+      ts.isNamedImports(bindings) &&
+      bindings.elements.length > 0 &&
+      bindings.elements.every(binding => binding.isTypeOnly))
+  );
+};
+
+export const findNotebookCoreValueImportViolations = (
+  path: string,
+  source: string,
+  compilerOptions = readCompilerOptions(resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/tsconfig.json')),
+  resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, 
true, getScriptKind(path));
+  const violations: string[] = [];
+  const visit = (node: ts.Node): void => {
+    const specifier = getModuleSpecifier(node);
+    if (specifier && resolvesToNotebookCore(specifier, path, compilerOptions, 
resolutionHost)) {
+      const typeOnlyImport =
+        ts.isImportTypeNode(node) ||
+        (ts.isImportDeclaration(node) && isTypeOnlyImportDeclaration(node)) ||
+        (ts.isExportDeclaration(node) &&
+          (node.isTypeOnly ||
+            (node.exportClause !== undefined &&
+              ts.isNamedExports(node.exportClause) &&
+              node.exportClause.elements.every(binding => 
binding.isTypeOnly))));
+      if (!typeOnlyImport) {
+        violations.push(`${path}: runtime notebook core import`);
+      }
+    }
+    ts.forEachChild(node, visit);
+  };
+
+  visit(sourceFile);
+  return violations;
+};
+
+export const findNotebookRemoteConsumerViolations = (
+  path: string,
+  source: string,
+  compilerOptions = readCompilerOptions(resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/tsconfig.json')),
+  resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+  const violations = findViolations(
+    path,
+    source,
+    forbiddenReactNotebookCoreConsumerModulePrefixes,
+    compilerOptions,
+    resolutionHost
+  );
+  violations.push(...findNotebookCoreValueImportViolations(path, source, 
compilerOptions, resolutionHost));
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, 
true, getScriptKind(path));
+
+  for (const statement of sourceFile.statements) {
+    if (!ts.isImportDeclaration(statement)) {
+      continue;
+    }
+    const specifier = getModuleSpecifier(statement);
+    if (!specifier || !resolvesToNotebookCore(specifier, path, 
compilerOptions, resolutionHost)) {
+      continue;
+    }
+    const clause = statement.importClause;
+    const bindings = clause?.namedBindings;
+
+    if (!isTypeOnlyImportDeclaration(statement) || clause?.name || !bindings 
|| !ts.isNamedImports(bindings)) {
+      violations.push(`${path}: remote must import only NotebookCorePort 
contract types`);
+      continue;
+    }
+    for (const binding of bindings.elements) {
+      const importedName = binding.propertyName?.text ?? binding.name.text;
+      if (!notebookRemoteContractTypes.has(importedName)) {
+        violations.push(`${path}: remote notebook core import 
${importedName}`);
+      }
+    }
+  }
+
+  const visit = (node: ts.Node): void => {
+    const specifier = getModuleSpecifier(node);
+    if (
+      specifier &&
+      !ts.isImportDeclaration(node) &&
+      resolvesToNotebookCore(specifier, path, compilerOptions, resolutionHost)
+    ) {
+      if (ts.isImportTypeNode(node) && node.qualifier && 
ts.isIdentifier(node.qualifier)) {
+        if (!notebookRemoteContractTypes.has(node.qualifier.text)) {
+          violations.push(`${path}: remote notebook core import 
${node.qualifier.text}`);
+        }
+      } else if (
+        ts.isExportDeclaration(node) &&
+        (node.isTypeOnly ||
+          (node.exportClause &&
+            ts.isNamedExports(node.exportClause) &&
+            node.exportClause.elements.every(binding => binding.isTypeOnly))) 
&&
+        node.exportClause &&
+        ts.isNamedExports(node.exportClause)
+      ) {
+        for (const binding of node.exportClause.elements) {
+          const importedName = binding.propertyName?.text ?? binding.name.text;
+          if (!notebookRemoteContractTypes.has(importedName)) {
+            violations.push(`${path}: remote notebook core import 
${importedName}`);
+          }
+        }
+      } else {
+        violations.push(`${path}: remote must import only NotebookCorePort 
contract types`);
+      }
+    }
+    ts.forEachChild(node, visit);
+  };
+  visit(sourceFile);
+
+  return violations;
+};
+
 // This type-only bridge has one dependency. Rejecting other imports also 
prevents
 // local helpers from hiding transport re-exports without restricting existing 
pages.
 export const findNotebookContractViolations = (path: string, source: string): 
string[] => {
@@ -337,7 +513,11 @@ const getModuleSpecifier = (node: ts.Node): string | null 
=> {
     ts.isCallExpression(node) &&
     node.arguments.length >= 1 &&
     (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
-      (ts.isIdentifier(node.expression) && node.expression.text === 'require'))
+      (ts.isIdentifier(node.expression) && node.expression.text === 'require') 
||
+      (ts.isPropertyAccessExpression(node.expression) &&
+        ts.isIdentifier(node.expression.expression) &&
+        node.expression.expression.text === 'require' &&
+        node.expression.name.text === 'resolve'))
   ) {
     let argument = node.arguments[0];
     while (ts.isParenthesizedExpression(argument)) {
@@ -403,6 +583,17 @@ const moduleIdentities = (
   return identities;
 };
 
+const resolvesToNotebookCore = (
+  specifier: string,
+  path: string,
+  compilerOptions: ts.CompilerOptions,
+  resolutionHost: ts.ModuleResolutionHost
+): boolean => {
+  return moduleIdentities(specifier, path, compilerOptions, 
resolutionHost).some(identity =>
+    matchesModulePrefix(identity, '@zeppelin/notebook-core')
+  );
+};
+
 const matchesModulePrefix = (moduleSpecifier: string, prefix: string): boolean 
=> {
   return moduleSpecifier === prefix || 
moduleSpecifier.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
 };

Reply via email to