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 788d45aa37 [ZEPPELIN-6670] Add Shared Notebook Core mount contract
788d45aa37 is described below

commit 788d45aa372dcdeb35fa7903fbf2a2f267201e96
Author: YONGJAE LEE (이용재) <[email protected]>
AuthorDate: Sun Sep 13 17:17:27 2026 +0900

    [ZEPPELIN-6670] Add Shared Notebook Core mount contract
    
    ### What is this PR for?
    
    Add `<at>zeppelin/notebook-core` as a shared package for the Angular host 
and React remote.
    
    Define common types for the read-only snapshot, `getSnapshot`, `subscribe`, 
and mount props. Add dependency-boundary checks and connect the package build, 
typecheck, and unit tests to the existing Maven/CI lifecycle.
    
    This change does not implement the Core runtime. Separate-build object 
identity and notebook route integration will be verified in ZEPPELIN-6674 and 
ZEPPELIN-6675.
    
    ### What type of PR is it?
    
    Improvement
    
    ### What is the Jira issue?
    
    ZEPPELIN-6670
    
    ### How should this be tested?
    
    The following checks passed locally in `zeppelin-web-angular`:
    
    * `npm run test:notebook-core`: 103 tests passed
    * `npm run typecheck:notebook-core`: passed
    * `npm run test:shell`: 49 tests passed
    * `npm run check:notebook-parity-scenarios`: 21 tests and the registry 
check passed
    * ESLint and Prettier checks for the changed code: passed
    
    ### Screenshots (if appropriate)
    
    No UI changes
    
    ### Questions:
    
    * Does the license files need to update? No
    * Is there breaking changes for older versions? No
    * Does this needs documentation? Updated README and AGENTS.md with the 
check commands and file locations.
    
    
    Closes #5469 from voidmatcha/ZEPPELIN-6670-shared-core-contract.
    
    Signed-off-by: ChanHo Lee <[email protected]>
---
 zeppelin-web-angular/AGENTS.md                     |  15 +-
 zeppelin-web-angular/README.md                     |   4 +
 zeppelin-web-angular/angular.json                  |  21 +
 zeppelin-web-angular/eslint.config.js              |  10 +-
 zeppelin-web-angular/package.json                  |   5 +-
 zeppelin-web-angular/pom.xml                       |  22 +
 .../zeppelin-notebook-core/ng-package.json         |   7 +
 .../projects/zeppelin-notebook-core/package.json   |   8 +
 .../src/host-remote-contract.spec.ts               |  78 +++
 .../src/host-remote-contract.ts}                   |  20 +-
 .../src/public-api.ts}                             |   4 +-
 .../projects/zeppelin-notebook-core/tsconfig.json  |  13 +
 .../zeppelin-notebook-core/tsconfig.spec.json      |   9 +
 .../projects/zeppelin-react/package-lock.json      |  12 +
 .../projects/zeppelin-react/package.json           |   1 +
 .../projects/zeppelin-react/src/main.ts            |   1 +
 .../src/{main.ts => notebookCoreContract.ts}       |   4 +-
 .../projects/zeppelin-react/tsconfig.json          |   1 +
 .../tsconfig.notebook-core.dist.json               |   8 +
 .../zeppelin-react/tsconfig.notebook-core.json     |   5 +
 .../projects/zeppelin-react/vitest.config.mts      |  14 +-
 .../projects/zeppelin-react/webpack.config.js      |   1 +
 zeppelin-web-angular/src/tsconfig.spec.json        |   2 +
 .../notebook-core/compiler-fixture.ts}             |  12 +-
 .../test/notebook-core/import-boundary.spec.ts     | 611 +++++++++++++++++++++
 .../test/notebook-core/import-boundary.ts          | 521 ++++++++++++++++++
 zeppelin-web-angular/tsconfig.base.json            |   1 +
 .../main.ts => vitest.notebook-core.config.mts}    |  11 +-
 zeppelin-web-angular/vitest.shell.config.mts       |   9 +-
 29 files changed, 1400 insertions(+), 30 deletions(-)

diff --git a/zeppelin-web-angular/AGENTS.md b/zeppelin-web-angular/AGENTS.md
index 92b08f597a..29aab36887 100644
--- a/zeppelin-web-angular/AGENTS.md
+++ b/zeppelin-web-angular/AGENTS.md
@@ -17,7 +17,7 @@ limitations under the License.
 
 # AGENTS.md
 
-Unit test conventions for this package. They apply to the Angular shell in 
`src/`, package-level infrastructure specs under `test/`, and the libraries 
under `projects/` that have no file of their own: `zeppelin-sdk`, which is 
framework-neutral, and `zeppelin-visualization`, which is mostly so apart from 
one `@Component` base class.
+Unit test conventions for this package. They apply to the Angular shell in 
`src/`, package-level infrastructure specs under `test/`, and the libraries 
under `projects/` that have no file of their own: `zeppelin-notebook-core` and 
`zeppelin-sdk`, which are framework-neutral, and `zeppelin-visualization`, 
which is mostly so apart from one `@Component` base class.
 
 Two subtrees override this file: [`e2e/AGENTS.md`](e2e/AGENTS.md) for the 
Playwright suite, and 
[`projects/zeppelin-react/AGENTS.md`](projects/zeppelin-react/AGENTS.md) for 
the React remote, which has a different CI status and one exception of its own.
 
@@ -26,8 +26,9 @@ The repository root `AGENTS.md` asks every change to include 
unit tests. This fi
 ## Layout
 
 - A product-code spec lives next to its source: `foo.ts` / `foo.spec.ts`. 
Specs for package-level test and reporting infrastructure live under `test/`.
-- The runner is Vitest on jsdom. There is no Karma. `test/test-setup.ts` loads 
`zone.js` and reflection metadata, initializes Angular `TestBed`, and resets 
the test environment after each spec.
+- The shell runner is Vitest on jsdom. There is no Karma. `test/test-setup.ts` 
loads `zone.js` and reflection metadata, initializes Angular `TestBed`, and 
resets the test environment after each spec.
 - `npm run test:shell` covers `src/`, package-level specs under `test/`, 
`projects/zeppelin-sdk` and `projects/zeppelin-visualization`. The two 
libraries have no runner of their own; they ride on the shell config because 
their code needs nothing extra. `projects/zeppelin-react` is separate. It has 
its own Vitest config and its own file here.
+- `projects/zeppelin-notebook-core` has a dedicated Vitest config 
(`vitest.notebook-core.config.mts`) using Node without Angular test setup. It 
covers the package's contract specs and the dependency-boundary checks under 
`test/notebook-core`. These specs are not part of the shell or React suites. 
The core compiler excludes DOM libraries and application path aliases.
 
 ## Running
 
@@ -36,8 +37,10 @@ The repository root `AGENTS.md` asks every change to include 
unit tests. This fi
 | `npm run test:shell` | Run the unit tests for `src/`, `test/` and the two 
libraries |
 | `npm run test:shell -- --coverage` | Same, with a coverage report |
 | `npm run test:shell -- foo.spec.ts` | Run one file |
+| `npm run test:notebook-core` | Run the dedicated notebook-core Node suite |
+| `npm run typecheck:notebook-core` | Check core source and specs, rebuild the 
package, and check the React type-only contract against built declarations and 
the same core source |
 
-`test:shell` is bound to the Maven `test` phase (`pom.xml`), so a spec added 
here starts running in CI the day it merges. It does not run where you would 
expect. `frontend.yml` builds this module with `-DskipTests`, which 
frontend-maven-plugin honours by skipping `test`-phase executions, so the run 
that counts is `mvnw verify -Pweb-e2e` inside the `run-playwright-e2e-tests` 
job. A failing spec surfaces there, under an e2e job name. Giving the unit 
tests a step of their own is [ZEPPELIN-6 [...]
+`test:shell`, `test:notebook-core`, and `typecheck:notebook-core` are bound to 
the Maven `test` phase (`pom.xml`), so a spec added here starts running in CI 
the day it merges. It does not run where you would expect. `frontend.yml` 
builds this module with `-DskipTests`, which frontend-maven-plugin honours by 
skipping `test`-phase executions, so the run that counts is `mvnw verify 
-Pweb-e2e` inside the `run-playwright-e2e-tests` job. A failing spec surfaces 
there, under an e2e job name. Gi [...]
 
 ## Where a test belongs
 
@@ -45,13 +48,15 @@ The frontend has two test layers, not three. There is no 
integration tier.
 
 | Layer | Runner | Answers |
 | --- | --- | --- |
-| Unit | Vitest + jsdom | Is the judgement we wrote correct? |
+| Unit | Vitest + jsdom (shell/React) or Node (notebook-core) | Is the 
judgement we wrote correct? |
 | E2E | Playwright | Does the page actually work in a browser? |
 
 Prefer a unit test when the question can be answered without a browser. Reach 
for e2e when the answer depends on wiring: routing, mounting a federated 
remote, authentication, or anything a user would have to click.
 
 The two layers do not substitute for each other, and neither replaces the 
cross-framework parity checks the React migration needs.
 
+The notebook-core host/remote contract is currently a type-only scaffold; it 
does not implement a notebook runtime.
+
 A third kind is planned but does not exist yet: contract specs that replay 
captured WebSocket traffic against the notebook runtime, arriving with 
[ZEPPELIN-6627](https://issues.apache.org/jira/browse/ZEPPELIN-6627). Those 
will run on Vitest as well and live under `test/contract/`, with the captured 
traffic beside them. Conventions for them are added once they exist.
 
 ## What to test
@@ -139,4 +144,4 @@ This is a different measurement from 
`e2e/reporter.coverage.ts`, which counts an
 3. Import from `vitest` (`describe`, `expect`, `it`), not from Jasmine or Jest.
    Check the target has callers before you invest in it. 
`get-keyword-positions.spec.ts` is a worked example of a function that turned 
out to have none.
 4. Construct the class directly unless the behavior depends on Angular wiring; 
use `TestBed` when it does.
-5. Run `npm run test:shell` and confirm it passes before opening a PR.
+5. Run `npm run test:shell` for shell/SDK/visualization changes. For 
notebook-core changes, run `npm run test:notebook-core` and `npm run 
typecheck:notebook-core`. Confirm the relevant checks pass before opening a PR.
diff --git a/zeppelin-web-angular/README.md b/zeppelin-web-angular/README.md
index f90c26f32b..ddd3350278 100644
--- a/zeppelin-web-angular/README.md
+++ b/zeppelin-web-angular/README.md
@@ -55,6 +55,10 @@ Run `npm run build` to build the project. The build 
artifacts will be stored in
 
 Run `npm run test:shell` to execute the Angular shell unit tests via 
[Vitest](https://vitest.dev).
 
+Run `npm run test:notebook-core` for the dedicated Node-based Vitest suite. 
Contract specs live in `projects/zeppelin-notebook-core`; dependency-boundary 
checks live in `test/notebook-core`. Run `npm run typecheck:notebook-core` to 
check the core and its test infrastructure, rebuild the core package, and check 
the React contract against both built declarations and the same core source. 
The React contract is a type-only scaffold; it does not mount a notebook or 
implement a runtime.
+
+The React remote has a separate jsdom suite: run `npm test --prefix 
projects/zeppelin-react`. The shell and React suites do not include the 
notebook-core suite.
+
 ## Implementation Progress
 
 ### Pages
diff --git a/zeppelin-web-angular/angular.json 
b/zeppelin-web-angular/angular.json
index 2dc47a4154..d1595667a7 100644
--- a/zeppelin-web-angular/angular.json
+++ b/zeppelin-web-angular/angular.json
@@ -177,6 +177,27 @@
         }
       }
     },
+    "zeppelin-notebook-core": {
+      "projectType": "library",
+      "root": "projects/zeppelin-notebook-core",
+      "sourceRoot": "projects/zeppelin-notebook-core/src",
+      "prefix": "lib",
+      "architect": {
+        "build": {
+          "builder": "@angular-devkit/build-angular:ng-packagr",
+          "options": {
+            "tsConfig": "projects/zeppelin-notebook-core/tsconfig.json",
+            "project": "projects/zeppelin-notebook-core/ng-package.json"
+          }
+        },
+        "lint": {
+          "builder": "@angular-eslint/builder:lint",
+          "options": {
+            "lintFilePatterns": ["projects/zeppelin-notebook-core/**/*.ts"]
+          }
+        }
+      }
+    },
     "zeppelin-sdk": {
       "projectType": "library",
       "root": "projects/zeppelin-sdk",
diff --git a/zeppelin-web-angular/eslint.config.js 
b/zeppelin-web-angular/eslint.config.js
index cca475fe7c..239a119789 100644
--- a/zeppelin-web-angular/eslint.config.js
+++ b/zeppelin-web-angular/eslint.config.js
@@ -187,9 +187,11 @@ module.exports = tseslint.config(
     // *.spec.ts. Point type-aware linting at the spec program explicitly.
     files: [
       'src/**/*.spec.ts',
-      'projects/zeppelin-{sdk,visualization}/**/*.spec.ts',
+      'projects/zeppelin-{notebook-core,sdk,visualization}/**/*.spec.ts',
       'test/**/*.spec.ts',
       'test/test-setup.ts',
+      'test/notebook-core/**/*.ts',
+      'vitest.notebook-core.config.mts',
       'vitest.shell.config.mts'
     ],
     languageOptions: {
@@ -201,7 +203,11 @@ module.exports = tseslint.config(
   },
   {
     // Catch specs that cannot fail, as eslint-plugin-playwright does for e2e.
-    files: ['src/**/*.spec.ts', 
'projects/zeppelin-{sdk,visualization}/**/*.spec.ts', 'test/**/*.spec.ts'],
+    files: [
+      'src/**/*.spec.ts',
+      'projects/zeppelin-{notebook-core,sdk,visualization}/**/*.spec.ts',
+      'test/**/*.spec.ts'
+    ],
     plugins: { vitest },
     rules: {
       'vitest/expect-expect': 'error',
diff --git a/zeppelin-web-angular/package.json 
b/zeppelin-web-angular/package.json
index 8c72a7eb2c..c5e994ea0a 100644
--- a/zeppelin-web-angular/package.json
+++ b/zeppelin-web-angular/package.json
@@ -11,7 +11,8 @@
     "build": "npm run build:projects && npm run build:react && npm run 
build:angular",
     "build:angular": "ng build --configuration production",
     "build:react": "cd projects/zeppelin-react && npm run build",
-    "build:projects": "npm run build-project:sdk && npm run build-project:vis",
+    "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",
     "build-project:sdk": "ng build --project zeppelin-sdk",
     "check:websocket-contract": "node --test 
scripts/check-websocket-contract.test.js && node 
scripts/check-websocket-contract.js",
     "generate:notebook-parity-scenarios": "node 
scripts/generate-notebook-parity-scenarios.mjs",
@@ -21,6 +22,8 @@
     "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",
+    "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",
diff --git a/zeppelin-web-angular/pom.xml b/zeppelin-web-angular/pom.xml
index b31d8825c9..9c62c32d14 100644
--- a/zeppelin-web-angular/pom.xml
+++ b/zeppelin-web-angular/pom.xml
@@ -155,6 +155,28 @@
             </configuration>
           </execution>
 
+          <execution>
+            <id>npm typecheck notebook core</id>
+            <goals>
+              <goal>npm</goal>
+            </goals>
+            <phase>test</phase>
+            <configuration>
+              <arguments>run typecheck:notebook-core</arguments>
+            </configuration>
+          </execution>
+
+          <execution>
+            <id>npm test notebook core</id>
+            <goals>
+              <goal>npm</goal>
+            </goals>
+            <phase>test</phase>
+            <configuration>
+              <arguments>run test:notebook-core</arguments>
+            </configuration>
+          </execution>
+
           <execution>
             <id>npm e2e</id>
             <goals>
diff --git 
a/zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json
new file mode 100644
index 0000000000..b7ef11c43a
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/ng-package.json
@@ -0,0 +1,7 @@
+{
+  "$schema": "../../node_modules/ng-packagr/ng-package.schema.json",
+  "dest": "../../dist/zeppelin-notebook-core",
+  "lib": {
+    "entryFile": "src/public-api.ts"
+  }
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/package.json 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/package.json
new file mode 100644
index 0000000000..5431edbe97
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/package.json
@@ -0,0 +1,8 @@
+{
+  "name": "@zeppelin/notebook-core",
+  "version": "0.0.1",
+  "sideEffects": false,
+  "dependencies": {
+    "tslib": "^2.0.0"
+  }
+}
diff --git 
a/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts
 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts
new file mode 100644
index 0000000000..b3e3ee86c8
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.spec.ts
@@ -0,0 +1,78 @@
+// @vitest-environment node
+
+/*
+ * 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 { assertType, describe, expect, it } from 'vitest';
+
+import type {
+  NotebookCorePort,
+  NotebookCoreRemoteProps,
+  NotebookCoreSnapshot,
+  NotebookCoreSnapshotListener
+} from './public-api';
+
+// assertType does not invoke this callback; tsc checks the rejected mutations.
+assertType<(props: NotebookCoreRemoteProps) => void>(props => {
+  const snapshot = props.core.getSnapshot();
+
+  // @ts-expect-error Snapshot note IDs are readonly.
+  snapshot.noteId = 'another-note';
+  // @ts-expect-error Snapshot revision IDs are readonly.
+  snapshot.revisionId = 'another-revision';
+  // @ts-expect-error The remote cannot replace the shared core port.
+  props.core = { ...props.core };
+  // @ts-expect-error The core snapshot reader is readonly.
+  props.core.getSnapshot = () => snapshot;
+  // @ts-expect-error The core subscription method is readonly.
+  props.core.subscribe = () => () => undefined;
+});
+
+const fakeCorePort = (initialSnapshot: NotebookCoreSnapshot) => {
+  let snapshot = initialSnapshot;
+  const listeners = new Set<NotebookCoreSnapshotListener>();
+  const core: NotebookCorePort = {
+    getSnapshot: () => snapshot,
+    subscribe: listener => {
+      listeners.add(listener);
+      return () => listeners.delete(listener);
+    }
+  };
+
+  return {
+    core,
+    publish: (nextSnapshot: NotebookCoreSnapshot) => {
+      snapshot = nextSnapshot;
+      for (const listener of listeners) {
+        listener();
+      }
+    }
+  };
+};
+
+describe('notebook core host and remote contract', () => {
+  it('demonstrates snapshot subscription and cleanup with a fake host-owned 
port', () => {
+    const host = fakeCorePort({ noteId: '2A94M5J1Z', revisionId: null });
+    const remoteProps: NotebookCoreRemoteProps = { core: host.core };
+    const snapshots: unknown[] = [];
+
+    expect(remoteProps.core).toBe(host.core);
+
+    const unsubscribe = remoteProps.core.subscribe(() => 
snapshots.push(remoteProps.core.getSnapshot()));
+    host.publish({ noteId: '2A94M5J1Z', revisionId: 'rev-1' });
+    unsubscribe();
+    host.publish({ noteId: '2A94M5J1Z', revisionId: 'rev-2' });
+
+    expect(snapshots).toEqual([{ noteId: '2A94M5J1Z', revisionId: 'rev-1' }]);
+    expect(remoteProps.core.getSnapshot()).toEqual({ noteId: '2A94M5J1Z', 
revisionId: 'rev-2' });
+  });
+});
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
similarity index 54%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to 
zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
index ce7edc883f..9a62c63e67 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/host-remote-contract.ts
@@ -10,6 +10,20 @@
  * limitations under the License.
  */
 
-export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
+export type NotebookCoreSnapshot = Readonly<{
+  noteId: string;
+  revisionId: string | null;
+}>;
+
+export type NotebookCoreUnsubscribe = () => void;
+
+export type NotebookCoreSnapshotListener = () => void;
+
+export type NotebookCorePort = Readonly<{
+  getSnapshot: () => NotebookCoreSnapshot;
+  subscribe: (listener: NotebookCoreSnapshotListener) => 
NotebookCoreUnsubscribe;
+}>;
+
+export type NotebookCoreRemoteProps = Readonly<{
+  core: NotebookCorePort;
+}>;
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
similarity index 66%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
index ce7edc883f..0d82f21dde 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/src/public-api.ts
@@ -10,6 +10,4 @@
  * limitations under the License.
  */
 
-export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
+export * from './host-remote-contract';
diff --git a/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json
new file mode 100644
index 0000000000..fcfd714eac
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.json
@@ -0,0 +1,13 @@
+{
+  "extends": "../../tsconfig.base.json",
+  "compilerOptions": {
+    "outDir": "../../out-tsc/lib",
+    "target": "es2020",
+    "lib": ["es2020"],
+    "paths": {},
+    "declaration": true,
+    "inlineSources": true,
+    "types": []
+  },
+  "exclude": ["**/*.spec.ts"]
+}
diff --git 
a/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json 
b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json
new file mode 100644
index 0000000000..b491576100
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-notebook-core/tsconfig.spec.json
@@ -0,0 +1,9 @@
+{
+  "extends": "./tsconfig.json",
+  "compilerOptions": {
+    "outDir": "../../out-tsc/spec/notebook-core",
+    "types": ["node"]
+  },
+  "include": ["src/**/*.spec.ts", "../../test/notebook-core/**/*.ts"],
+  "exclude": []
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json 
b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
index 8d4f3da1cf..11ce482ea1 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/package-lock.json
@@ -9,6 +9,7 @@
       "version": "0.0.1",
       "dependencies": {
         "@ant-design/icons": "5.4.0",
+        "@zeppelin/notebook-core": "file:../zeppelin-notebook-core",
         "@zeppelin/sdk": "file:../zeppelin-sdk",
         "ansi-to-react": "6.2.6",
         "antd": "5.21.0",
@@ -51,6 +52,13 @@
         "webpack-dev-server": "6.0.0"
       }
     },
+    "../zeppelin-notebook-core": {
+      "name": "@zeppelin/notebook-core",
+      "version": "0.0.1",
+      "dependencies": {
+        "tslib": "^2.0.0"
+      }
+    },
     "../zeppelin-sdk": {
       "name": "@zeppelin/sdk",
       "version": "0.0.1",
@@ -2698,6 +2706,10 @@
       "dev": true,
       "license": "Apache-2.0"
     },
+    "node_modules/@zeppelin/notebook-core": {
+      "resolved": "../zeppelin-notebook-core",
+      "link": true
+    },
     "node_modules/@zeppelin/sdk": {
       "resolved": "../zeppelin-sdk",
       "link": true
diff --git a/zeppelin-web-angular/projects/zeppelin-react/package.json 
b/zeppelin-web-angular/projects/zeppelin-react/package.json
index 8f4d609441..6905d5001d 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/package.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/package.json
@@ -15,6 +15,7 @@
   },
   "dependencies": {
     "@ant-design/icons": "5.4.0",
+    "@zeppelin/notebook-core": "file:../zeppelin-notebook-core",
     "@zeppelin/sdk": "file:../zeppelin-sdk",
     "ansi-to-react": "6.2.6",
     "highlight.js": "^10.7.3",
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
index ce7edc883f..66f7f95a0a 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
@@ -13,3 +13,4 @@
 export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
 export { PublishedParagraph, mount } from './pages/PublishedParagraph';
 export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
+export type { NotebookCoreRemoteProps } from './notebookCoreContract';
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
similarity index 66%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
index ce7edc883f..1eceaf420c 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/projects/zeppelin-react/src/notebookCoreContract.ts
@@ -10,6 +10,4 @@
  * limitations under the License.
  */
 
-export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
+export type { NotebookCoreRemoteProps } from '@zeppelin/notebook-core';
diff --git a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json 
b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
index 3dba52c585..ea736257f2 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
+++ b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.json
@@ -19,6 +19,7 @@
     "baseUrl": "src",
     "paths": {
       "@/*": ["./*"],
+      "@zeppelin/notebook-core": 
["../../zeppelin-notebook-core/src/public-api.ts"],
       "@zeppelin/sdk": ["../../zeppelin-sdk/src"],
       "@zeppelin/sdk/*": ["../../zeppelin-sdk/src/*"]
     }
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json 
b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json
new file mode 100644
index 0000000000..12ee26106c
--- /dev/null
+++ 
b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.dist.json
@@ -0,0 +1,8 @@
+{
+  "extends": "./tsconfig.notebook-core.json",
+  "compilerOptions": {
+    "paths": {
+      "@zeppelin/notebook-core": ["../../../dist/zeppelin-notebook-core"]
+    }
+  }
+}
diff --git 
a/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json 
b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json
new file mode 100644
index 0000000000..8516d2cf5f
--- /dev/null
+++ b/zeppelin-web-angular/projects/zeppelin-react/tsconfig.notebook-core.json
@@ -0,0 +1,5 @@
+{
+  "extends": "./tsconfig.json",
+  "include": ["src/notebookCoreContract.ts"],
+  "exclude": []
+}
diff --git a/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts 
b/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts
index e2ebae3144..c21ab1ab78 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts
+++ b/zeppelin-web-angular/projects/zeppelin-react/vitest.config.mts
@@ -15,12 +15,16 @@ import { fileURLToPath } from 'node:url';
 import { defineConfig } from 'vitest/config';
 
 export default defineConfig({
-  // Kept in sync with the `resolve.alias` block in webpack.config.js.
+  // Source aliases mirror tsconfig.json; the SDK alias also matches 
webpack.config.js.
   resolve: {
-    alias: {
-      '@': fileURLToPath(new URL('./src', import.meta.url)),
-      '@zeppelin/sdk': fileURLToPath(new URL('../zeppelin-sdk/src', 
import.meta.url))
-    }
+    alias: [
+      {
+        find: /^@zeppelin\/notebook-core$/,
+        replacement: fileURLToPath(new 
URL('../zeppelin-notebook-core/src/public-api.ts', import.meta.url))
+      },
+      { find: '@', replacement: fileURLToPath(new URL('./src', 
import.meta.url)) },
+      { find: '@zeppelin/sdk', replacement: fileURLToPath(new 
URL('../zeppelin-sdk/src', import.meta.url)) }
+    ]
   },
   test: {
     environment: 'jsdom',
diff --git a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js 
b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
index d7de57b3d9..aa73dc1647 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
+++ b/zeppelin-web-angular/projects/zeppelin-react/webpack.config.js
@@ -39,6 +39,7 @@ module.exports = (_env, argv) => {
       modules: ['node_modules', path.resolve(__dirname, '../../node_modules')],
       alias: {
         '@': path.resolve(__dirname, 'src'),
+        '@zeppelin/notebook-core$': path.resolve(__dirname, 
'../zeppelin-notebook-core/src/public-api.ts'),
         '@zeppelin/sdk': path.resolve(__dirname, '../zeppelin-sdk/src')
       }
     },
diff --git a/zeppelin-web-angular/src/tsconfig.spec.json 
b/zeppelin-web-angular/src/tsconfig.spec.json
index 409fd8b367..07cf05eef6 100644
--- a/zeppelin-web-angular/src/tsconfig.spec.json
+++ b/zeppelin-web-angular/src/tsconfig.spec.json
@@ -6,10 +6,12 @@
   },
   "include": [
     "**/*.spec.ts",
+    "../projects/zeppelin-notebook-core/**/*.spec.ts",
     "../projects/zeppelin-sdk/**/*.spec.ts",
     "../projects/zeppelin-visualization/**/*.spec.ts",
     "../test/**/*.spec.ts",
     "../test/test-setup.ts",
+    "../vitest.notebook-core.config.mts",
     "../vitest.shell.config.mts"
   ],
   "exclude": []
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
similarity index 59%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
index ce7edc883f..7b1ad5f998 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/test/notebook-core/compiler-fixture.ts
@@ -10,6 +10,12 @@
  * limitations under the License.
  */
 
-export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
+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;
+};
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts 
b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
new file mode 100644
index 0000000000..be3fc00f56
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.spec.ts
@@ -0,0 +1,611 @@
+/*
+ * 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 { readFileSync } from 'node:fs';
+import { dirname, resolve } from 'node:path';
+
+import ts from 'typescript';
+import { describe, expect, it } from 'vitest';
+
+import { createFixtureHost } from './compiler-fixture';
+import {
+  sourceRoot,
+  zeppelinWebAngularRoot,
+  reactNotebookCoreBoundaryFiles,
+  forbiddenModulePrefixes,
+  forbiddenReactNotebookCoreConsumerModulePrefixes,
+  sourceFiles,
+  findReactNotebookConsumerViolations,
+  findNotebookContractViolations,
+  findViolations,
+  formatViolations,
+  readCompilerOptions
+} from './import-boundary';
+
+describe('notebook core import boundary', () => {
+  it('resolves the React public contract without exposing source subpaths', () 
=> {
+    const path = reactNotebookCoreBoundaryFiles[1];
+    const options = readCompilerOptions(resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/tsconfig.json'));
+
+    expect(
+      ts.resolveModuleName('@zeppelin/notebook-core', path, options, 
ts.sys).resolvedModule?.resolvedFileName
+    ).toBe(resolve(sourceRoot, 'public-api.ts'));
+    expect(
+      ts.resolveModuleName('@zeppelin/notebook-core/host-remote-contract', 
path, options, ts.sys).resolvedModule
+    ).toBeUndefined();
+  });
+
+  it.each(['http', 'https', 'node:http', 'node:https', 'http2', 'node:http2'])(
+    'rejects Node HTTP transport %s in core and consumer',
+    specifier => {
+      for (const [path, prefixes] of [
+        [resolve(sourceRoot, 'host-remote-contract.ts'), 
forbiddenModulePrefixes],
+        [reactNotebookCoreBoundaryFiles[1], 
forbiddenReactNotebookCoreConsumerModulePrefixes]
+      ] as const) {
+        expect(findViolations(path, `export * from '${specifier}';`, 
prefixes)).toEqual([
+          `${path}: import ${specifier}`
+        ]);
+      }
+    }
+  );
+
+  it('rejects a compiler-valid Node HTTP handle in the snapshot', () => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const source = `/// <reference types="node" />\nimport type { 
ClientRequest } from 'node:http';\n${readFileSync(
+      path,
+      'utf8'
+    ).replace('noteId: string;', 'noteId: string; transport?: 
ClientRequest;')}`;
+    const options = readCompilerOptions(resolve(sourceRoot, 
'../tsconfig.json'));
+    const host = createFixtureHost(options, new Map([[path, source]]));
+
+    expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, 
host))).toEqual([]);
+    expect(findViolations(path, source)).toEqual([`${path}: reference node`, 
`${path}: import node:http`]);
+  });
+
+  it('rejects a compiler-valid framework type exposed by an external wrapper', 
() => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const source = readFileSync(path, 'utf8').replace(
+      'noteId: string;',
+      "noteId: string; button?: 
import('ng-zorro-antd/button').NzButtonComponent;"
+    );
+    const options = readCompilerOptions(resolve(sourceRoot, 
'../tsconfig.json'));
+    const host = createFixtureHost(options, new Map([[path, source]]));
+    expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, 
host))).toEqual([]);
+    expect(findViolations(path, source)).toEqual([`${path}: import 
ng-zorro-antd/button`]);
+  });
+
+  it('allows only checked local sources and the reviewed tslib dependency in 
core', () => {
+    const path = resolve(sourceRoot, 'public-api.ts');
+    expect(findViolations(path, "export * from 
'./host-remote-contract';")).toEqual([]);
+    expect(findViolations(path, "export { __assign } from 
'tslib';")).toEqual([]);
+    expect(findViolations(path, "export * from 
'unknown-wrapper';")).toEqual([`${path}: import unknown-wrapper`]);
+  });
+
+  it('stays framework-neutral and transport-neutral', () => {
+    const violations = sourceFiles(sourceRoot).flatMap(path => {
+      const source = readFileSync(path, 'utf8');
+      return findViolations(path, source);
+    });
+
+    expect(violations).toEqual([]);
+  });
+
+  it.each(['import', 'alias', 'reference'])(
+    'rejects a compiler-valid declaration outside the scanned core source 
through %s',
+    form => {
+      const path = resolve(sourceRoot, 'host-remote-contract.ts');
+      const helper = resolve(sourceRoot, '../framework.d.ts');
+      const options = {
+        ...readCompilerOptions(resolve(sourceRoot, '../tsconfig.json')),
+        rootDir: sourceRoot,
+        baseUrl: sourceRoot,
+        paths: { '@contract-helper': ['../framework.d.ts'] }
+      };
+      const specifier = form === 'alias' ? '@contract-helper' : '../framework';
+      const declaration =
+        form === 'reference'
+          ? "type AngularHandle = import('@angular/core').Provider;"
+          : "export type { Provider as AngularHandle } from '@angular/core';";
+      const field = form === 'reference' ? 'AngularHandle' : 
`import('${specifier}').AngularHandle`;
+      const source = `${form === 'reference' ? '/// <reference 
path="../framework.d.ts" />\n' : ''}${readFileSync(
+        path,
+        'utf8'
+      ).replace('noteId: string;', `noteId: string; framework?: ${field};`)}`;
+      const files = new Map([
+        [path, source],
+        [helper, declaration]
+      ]);
+      const host = createFixtureHost(options, files);
+      const program = ts.createProgram([path], options, host);
+
+      expect(ts.getPreEmitDiagnostics(program)).toEqual([]);
+      expect(program.getSourceFile(helper)).toBeDefined();
+      expect(findViolations(path, source, forbiddenModulePrefixes, options, 
host)).toEqual([
+        `${path}: ${form === 'reference' ? 'reference ../framework.d.ts' : 
`import ${specifier}`}`
+      ]);
+    }
+  );
+
+  it('rejects direct transport imports in the React entry point and notebook 
core contract', () => {
+    const violations = reactNotebookCoreBoundaryFiles.flatMap(path => {
+      const source = readFileSync(path, 'utf8');
+      return findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes);
+    });
+
+    expect(formatViolations(violations)).toEqual([]);
+  });
+
+  // This builds and walks a TypeScript program over the whole React source 
tree, which runs past
+  // Vitest's default 5s timeout on slower machines/CI runners even with no 
violations.
+  it('checks all production React consumers of the notebook contract', () => {
+    expect(findReactNotebookConsumerViolations()).toEqual([]);
+  }, 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 => {
+      const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
+      const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+      const helper = resolve(
+        root,
+        form === 'javascript'
+          ? 'transportFixture.js'
+          : form === 'require-outside'
+            ? '../transportFixture.ts'
+            : 'transportFixture.ts'
+      );
+      const route = resolve(root, 'NotebookRouteFixture.tsx');
+      const barrel = resolve(root, 'contractFixture.ts');
+      const transport = "export { webSocket } from 'rxjs/webSocket';";
+      const files = new Map<string, string>([
+        [
+          consumer,
+          `import type { NotebookCoreRemoteProps } from '${form === 'barrel' ? 
'./contractFixture' : './notebookCoreContract'}';
+          export const read = (props: NotebookCoreRemoteProps) => 
props.core.getSnapshot();
+          ${['helper', 'javascript', 'cycle'].includes(form) ? "export * from 
'@/transportFixture';" : ''}
+          ${form === 'direct' || form === 'barrel' ? transport : ''}
+          ${form === 'require-outside' ? "export const transport = 
require('../transportFixture');" : ''}
+          ${form === 'computed' ? "export const load = () => import('rxjs/' + 
'webSocket');" : ''}`
+        ],
+        [helper, `${transport} ${form === 'cycle' ? "export { read } from 
'./NotebookBoundaryFixture';" : ''}`],
+        [route, `export { read } from './NotebookBoundaryFixture'; ${form === 
'route' ? transport : ''}`],
+        [barrel, "export type { NotebookCoreRemoteProps } from 
'./notebookCoreContract';"]
+      ]);
+      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([]);
+      const violations = findReactNotebookConsumerViolations(roots, options, 
host);
+      const offender = ['helper', 'javascript', 'cycle', 
'require-outside'].includes(form)
+        ? helper
+        : form === 'route'
+          ? route
+          : consumer;
+      expect(violations).toContain(
+        `${offender}: import ${form === 'computed' ? '<computed module 
dependency>' : 'rxjs/webSocket'}`
+      );
+    }
+  );
+
+  it.each([
+    ["export type { Message } from '@zeppelin/sdk';", 'import @zeppelin/sdk'],
+    ["export { request } from 'node:http';", 'import node:http'],
+    ["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';
+      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}`);
+  });
+
+  it('rejects the mixed public aggregator as an internal notebook consumer 
dependency', () => {
+    const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
+    const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+    const source = "export type { NotebookCoreRemoteProps } from './main';";
+    const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+    const host = createFixtureHost(options, new Map([[consumer, source]]));
+    expect(findReactNotebookConsumerViolations([consumer], options, 
host)).toContain(
+      `${consumer}: notebook consumer must use the contract bridge instead of 
the public aggregator`
+    );
+  });
+
+  it('allows a neutral notebook consumer without traversing unrelated legacy 
page exports', () => {
+    const root = resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src');
+    const consumer = resolve(root, 'NotebookBoundaryFixture.tsx');
+    const source =
+      "import type { NotebookCoreRemoteProps } from './notebookCoreContract'; 
export const read = (props: NotebookCoreRemoteProps) => 
props.core.getSnapshot();";
+    const options = readCompilerOptions(resolve(root, '../tsconfig.json'));
+    const host = createFixtureHost(options, new Map([[consumer, source]]));
+    
expect(findReactNotebookConsumerViolations([...reactNotebookCoreBoundaryFiles, 
consumer], options, host)).toEqual(
+      []
+    );
+  });
+
+  it('keeps the React contract dependent only on the public core entry point', 
() => {
+    const path = reactNotebookCoreBoundaryFiles[1];
+    expect(findNotebookContractViolations(path, readFileSync(path, 
'utf8'))).toEqual([]);
+  });
+
+  it('rejects a compiler-valid transport re-export through a local helper', () 
=> {
+    const path = reactNotebookCoreBoundaryFiles[1];
+    const helper = resolve(dirname(path), 'notebookTransport.ts');
+    const source = `${readFileSync(path, 'utf8')}\nexport { webSocket } from 
'./notebookTransport';`;
+    const files = new Map([
+      [path, source],
+      [helper, "export { webSocket } from 'rxjs/webSocket';"]
+    ]);
+    const options = readCompilerOptions(resolve(dirname(path), 
'../tsconfig.json'));
+    const host = createFixtureHost(options, files);
+
+    expect(ts.getPreEmitDiagnostics(ts.createProgram([path], options, 
host))).toEqual([]);
+    expect(findNotebookContractViolations(path, source)).toEqual([
+      `${path}: notebook contract dependency ./notebookTransport`
+    ]);
+  });
+
+  it.each(["import(('rxjs/webSocket'))", "import('rxjs/' + 'webSocket')"])(
+    'rejects wrapped or computed dynamic dependencies: %s',
+    expression => {
+      const source = `export const load = () => ${expression};`;
+      expect(findNotebookContractViolations(reactNotebookCoreBoundaryFiles[1], 
source).length).toBeGreaterThan(0);
+      expect(findViolations(resolve(sourceRoot, 'public-api.ts'), 
source).length).toBeGreaterThan(0);
+    }
+  );
+
+  it.each([
+    "import type { Port } from './helper';",
+    "export type { Port } from '@/helper';",
+    "export type Port = import('./helper').Port;",
+    "export const load = () => import('./helper');"
+  ])('rejects other dependency forms in the type-only contract: %s', source => 
{
+    expect(findNotebookContractViolations(reactNotebookCoreBoundaryFiles[1], 
source)).toHaveLength(1);
+  });
+
+  it.each(['import(`rxjs/webSocket`)', "import('rxjs/webSocket', {})"])(
+    'rejects dynamic transport syntax %s',
+    expression => {
+      const path = reactNotebookCoreBoundaryFiles[1];
+      expect(
+        findViolations(
+          path,
+          `export const load = () => ${expression};`,
+          forbiddenReactNotebookCoreConsumerModulePrefixes
+        )
+      ).toEqual([`${path}: import rxjs/webSocket`]);
+    }
+  );
+
+  it.each([
+    ['path', '../../zeppelin-react/node_modules/@types/react/index.d.ts'],
+    ['types', 'react'],
+    ['types', 'ws']
+  ])('rejects forbidden reference %s=%s', (kind, target) => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    expect(findViolations(path, `/// <reference ${kind}="${target}" 
/>`)).toEqual([`${path}: reference ${target}`]);
+  });
+
+  it.each(['dom', 'dom.iterable', 'webworker', 'webworker.importscripts'])(
+    'rejects browser library reference %s',
+    lib => {
+      expect(findViolations('library.ts', `/// <reference lib="${lib}" 
/>`)).toEqual([`library.ts: library ${lib}`]);
+    }
+  );
+
+  it.each(['globalThis', 'window', 'self'])('rejects indexed transport types 
on %s', owner => {
+    expect(findViolations('indexed.ts', `export type Socket = 
InstanceType<(typeof ${owner})["WebSocket"]>;`)).toEqual([
+      'indexed.ts: global WebSocket'
+    ]);
+    expect(findViolations('indexed.ts', `export type Request = (typeof 
${owner})['fetch'];`)).toEqual([
+      'indexed.ts: global fetch'
+    ]);
+  });
+
+  it('rejects template-literal global transport access', () => {
+    expect(findViolations('template.ts', 'export const request = 
window[`fetch`];')).toEqual([
+      'template.ts: global fetch'
+    ]);
+  });
+
+  it('allows standard libraries and local indexed properties', () => {
+    expect(findViolations('local.ts', '/// <reference lib="es2020" 
/>')).toEqual([]);
+    expect(findViolations('local.ts', 'type Local = { fetch: string }; export 
type Value = Local["fetch"];')).toEqual(
+      []
+    );
+    expect(findViolations('local.ts', 'export type Parse = (typeof 
globalThis)["parseInt"];')).toEqual([]);
+  });
+
+  it('rejects a compiler-valid WebSocket leak in the actual snapshot 
contract', () => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const source = `/// <reference lib="dom" />\n${readFileSync(path, 
'utf8').replace(
+      'noteId: string;',
+      'noteId: string; socket?: InstanceType<(typeof 
globalThis)["WebSocket"]>;'
+    )}`;
+    const options = readCompilerOptions(resolve(sourceRoot, 
'../tsconfig.json'));
+    const host = createFixtureHost(options, new Map([[path, source]]));
+    const program = ts.createProgram([path], options, host);
+
+    expect(ts.getPreEmitDiagnostics(program)).toEqual([]);
+    expect(findViolations(path, source)).toEqual([`${path}: library dom`, 
`${path}: global WebSocket`]);
+  });
+
+  it.each([
+    "export type Leak = import('./host-remote-contract.spec').AngularHandle;",
+    "export * from './host-remote-contract.spec';",
+    "export const load = () => import('./host-remote-contract.spec');"
+  ])('rejects a production dependency on an excluded spec: %s', source => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    expect(findViolations(path, source)).toEqual([`${path}: import 
./host-remote-contract.spec`]);
+  });
+
+  it('rejects an alias and reference directive resolving to an excluded spec', 
() => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const options = {
+      ...readCompilerOptions(resolve(sourceRoot, '../tsconfig.json')),
+      baseUrl: sourceRoot,
+      paths: { '@test-contract': ['host-remote-contract.spec.ts'] }
+    };
+    expect(findViolations(path, "export * from '@test-contract';", 
forbiddenModulePrefixes, options)).toEqual([
+      `${path}: import @test-contract`
+    ]);
+    expect(findViolations(path, '/// <reference 
path="./host-remote-contract.spec.ts" />')).toEqual([
+      `${path}: reference ./host-remote-contract.spec.ts`
+    ]);
+    expect(findViolations(path, '/// <reference 
types="./host-remote-contract.spec.ts" />')).toEqual([
+      `${path}: reference ./host-remote-contract.spec.ts`
+    ]);
+  });
+
+  it('rejects a compiler-valid framework leak through an imported spec', () => 
{
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const spec = resolve(sourceRoot, 'host-remote-contract.spec.ts');
+    const source = readFileSync(path, 'utf8').replace(
+      'noteId: string;',
+      "noteId: string; framework?: 
import('./host-remote-contract.spec').AngularHandle;"
+    );
+    const files = new Map([
+      [path, source],
+      [spec, `${readFileSync(spec, 'utf8')}\nexport type { Provider as 
AngularHandle } from '@angular/core';`]
+    ]);
+    const options = readCompilerOptions(resolve(sourceRoot, 
'../tsconfig.json'));
+    const host = createFixtureHost(options, files);
+    const program = ts.createProgram([resolve(sourceRoot, 'public-api.ts')], 
options, host);
+
+    expect(ts.getPreEmitDiagnostics(program)).toEqual([]);
+    expect(program.getSourceFile(spec)).toBeDefined();
+    expect(findViolations(path, source)).toEqual([`${path}: import 
./host-remote-contract.spec`]);
+  });
+
+  it('allows a local reference and a core dynamic import', () => {
+    const path = resolve(sourceRoot, 'public-api.ts');
+    expect(findViolations(path, '/// <reference 
path="./host-remote-contract.ts" />')).toEqual([]);
+    expect(
+      findViolations(
+        reactNotebookCoreBoundaryFiles[1],
+        'export const load = () => import(`@zeppelin/notebook-core`);',
+        forbiddenReactNotebookCoreConsumerModulePrefixes
+      )
+    ).toEqual([]);
+  });
+
+  it.each(['ws', '@types/ws', 'isomorphic-ws', 'sockjs-client', 
'@types/sockjs-client'])(
+    'rejects WebSocket implementation %s in core and consumer',
+    specifier => {
+      for (const [path, prefixes] of [
+        [resolve(sourceRoot, 'host-remote-contract.ts'), 
forbiddenModulePrefixes],
+        [reactNotebookCoreBoundaryFiles[1], 
forbiddenReactNotebookCoreConsumerModulePrefixes]
+      ] as const) {
+        expect(findViolations(path, `export * from '${specifier}';`, 
prefixes)).toEqual([
+          `${path}: import ${specifier}`
+        ]);
+      }
+    }
+  );
+
+  it('rejects worker-global transport access', () => {
+    expect(
+      findViolations(
+        reactNotebookCoreBoundaryFiles[1],
+        'export const request = self.fetch;',
+        forbiddenReactNotebookCoreConsumerModulePrefixes
+      )
+    ).toEqual([`${reactNotebookCoreBoundaryFiles[1]}: global fetch`]);
+  });
+
+  it.each([
+    ['const browser = window; export const request = browser.fetch;', 
'window'],
+    ['const browser = (globalThis); export const request = browser.fetch;', 
'globalThis'],
+    ['let browser: Window; browser = window; export const request = 
browser.fetch;', 'window'],
+    ['const browser = self as typeof self; export const socket = 
browser.WebSocket;', 'self']
+  ])('rejects capturing a transport global in a local alias: %s', (source, 
owner) => {
+    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}: global object alias ${owner}`
+    );
+  });
+
+  it.each([
+    ['export const request = (window).fetch;', 'global fetch'],
+    ['export const request = (window as Window).fetch;', 'global fetch'],
+    ['export const request = (globalThis)["fetch"];', 'global fetch'],
+    ['export const request = (self as typeof self)["fetch"];', 'global fetch'],
+    ['const { fetch } = window; export const request = fetch;', 'global 
fetch'],
+    ['const { fetch: request } = (window); export { request };', 'global 
fetch'],
+    ['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}`
+    );
+  });
+
+  it.each([
+    "export const fetch = () => 'cached'; export const result = fetch();",
+    'export const read = (fetch: () => string) => fetch();',
+    "const window = { fetch: () => 'cached' }; const browser = window; export 
const result = (window).fetch() + browser.fetch();",
+    'export function read({ fetch }: { fetch: () => string }) { return 
fetch(); }',
+    "const fetch = () => 'cached'; export const local = { fetch };",
+    "const local = { fetch: () => 'cached' }; const { fetch: read } = local; 
export const result = read();"
+  ])('allows lexical local bindings with transport-like names: %s', source => {
+    expect(
+      findViolations(reactNotebookCoreBoundaryFiles[1], source, 
forbiddenReactNotebookCoreConsumerModulePrefixes)
+    ).toEqual([]);
+  });
+
+  it('does not let a nested local binding hide an outer transport global', () 
=> {
+    const source = 'export function read(fetch: () => string) { return 
fetch(); } export const request = fetch;';
+    const path = reactNotebookCoreBoundaryFiles[1];
+    expect(findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes)).toEqual([
+      `${path}: global fetch`
+    ]);
+  });
+
+  it('allows direct non-transport browser properties and ordinary local 
objects', () => {
+    const source = 'const browser = { innerWidth: 42 }; export const width = 
browser.innerWidth + window.innerWidth;';
+    expect(
+      findViolations(reactNotebookCoreBoundaryFiles[1], source, 
forbiddenReactNotebookCoreConsumerModulePrefixes)
+    ).toEqual([]);
+  });
+
+  it('ignores forbidden words in comments and string values', () => {
+    const source = `// React may render this later.\nexport const note = 
'fetch over WebSocket';`;
+
+    expect(findViolations('comment-fixture.ts', source)).toEqual([]);
+  });
+
+  it.each([
+    '../../zeppelin-sdk/src/public-api',
+    '../../zeppelin-react/node_modules/@types/react',
+    '../../../node_modules/rxjs/dist/types/index',
+    '@zeppelin/services/interpreter.service'
+  ])('rejects forbidden dependencies through %s', moduleSpecifier => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const source = `export type { Leaked } from '${moduleSpecifier}';`;
+
+    expect(findViolations(path, source)).toEqual([`${path}: import 
${moduleSpecifier}`]);
+  });
+
+  it('rejects a compiler alias to the SDK even when its spelling is neutral', 
() => {
+    const path = resolve(sourceRoot, 'host-remote-contract.ts');
+    const options = {
+      ...readCompilerOptions(resolve(sourceRoot, '../tsconfig.json')),
+      baseUrl: zeppelinWebAngularRoot,
+      paths: { '@mount-data': ['projects/zeppelin-sdk/src/public-api.ts'] }
+    };
+
+    expect(
+      findViolations(path, "export type { Message } from '@mount-data';", 
forbiddenModulePrefixes, options)
+    ).toEqual([`${path}: import @mount-data`]);
+  });
+
+  it('rejects a relative SDK import from the React contract consumer', () => {
+    const path = reactNotebookCoreBoundaryFiles[1];
+    const source = `export type { Message } from 
'../../zeppelin-sdk/src/public-api';`;
+
+    expect(findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes)).toEqual([
+      `${path}: import ../../zeppelin-sdk/src/public-api`
+    ]);
+  });
+
+  it.each([
+    '../../../src/app/services/message.service',
+    '../../../src/app/services/notebook.service',
+    '../../../src/app/services/base-rest',
+    '../../../node_modules/rxjs/dist/types/webSocket/index'
+  ])('rejects direct shell and WebSocket transport imports through %s', 
moduleSpecifier => {
+    const path = reactNotebookCoreBoundaryFiles[1];
+
+    expect(
+      findViolations(path, `export * from '${moduleSpecifier}';`, 
forbiddenReactNotebookCoreConsumerModulePrefixes)
+    ).toEqual([`${path}: import ${moduleSpecifier}`]);
+  });
+
+  it('rejects transport types and computed global transport access', () => {
+    const source = [
+      'export type Socket = WebSocket;',
+      'export type Request = XMLHttpRequest;',
+      "export const request = globalThis['fetch'];",
+      "export const socket = new window['WebSocket']('/ws');"
+    ].join('\n');
+
+    expect(findViolations('transport-types.ts', source)).toEqual([
+      'transport-types.ts: global WebSocket',
+      'transport-types.ts: global XMLHttpRequest',
+      'transport-types.ts: global fetch',
+      'transport-types.ts: global WebSocket'
+    ]);
+  });
+
+  it('allows transport-like property names without permitting transport 
types', () => {
+    const source = [
+      'type Port = { fetch: () => string; WebSocket(): string };',
+      'interface OtherPort { XMLHttpRequest: string; fetch(): string }',
+      'const port = { fetch: () => "local" }; port.fetch();'
+    ].join('\n');
+
+    expect(findViolations('property-names.ts', source)).toEqual([]);
+  });
+
+  it('allows local contract re-exports and the React shared-core type import', 
() => {
+    const path = resolve(sourceRoot, 'public-api.ts');
+
+    expect(findViolations(path, "export * from 
'./host-remote-contract';")).toEqual([]);
+    expect(
+      findViolations(
+        reactNotebookCoreBoundaryFiles[1],
+        "export type { NotebookCoreRemoteProps } from 
'@zeppelin/notebook-core';",
+        forbiddenReactNotebookCoreConsumerModulePrefixes
+      )
+    ).toEqual([]);
+  });
+
+  it('rejects static, dynamic and direct transport dependencies', () => {
+    const source = [
+      `import type { OP } from '@zeppelin/sdk';`,
+      `export { useMemo } from 'react';`,
+      `type LeakedMessage = import('@zeppelin/sdk').Message;`,
+      `import { createRoot } from 'react-dom/client';`,
+      `import { Provider } from 'react-redux';`,
+      `const router = () => import('react-router-dom');`,
+      `const load = () => import('rxjs/operators');`,
+      `const request = () => fetch('/api/notebook');`,
+      `const socket = new globalThis.WebSocket('/ws');`,
+      `const xhr = new window.XMLHttpRequest();`
+    ].join('\n');
+
+    expect(findViolations('violation-fixture.ts', source)).toEqual([
+      'violation-fixture.ts: import @zeppelin/sdk',
+      'violation-fixture.ts: import react',
+      'violation-fixture.ts: import @zeppelin/sdk',
+      'violation-fixture.ts: import react-dom/client',
+      'violation-fixture.ts: import react-redux',
+      'violation-fixture.ts: import react-router-dom',
+      'violation-fixture.ts: import rxjs/operators',
+      'violation-fixture.ts: global fetch',
+      'violation-fixture.ts: global WebSocket',
+      'violation-fixture.ts: global XMLHttpRequest'
+    ]);
+  });
+});
diff --git a/zeppelin-web-angular/test/notebook-core/import-boundary.ts 
b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
new file mode 100644
index 0000000000..e79389112e
--- /dev/null
+++ b/zeppelin-web-angular/test/notebook-core/import-boundary.ts
@@ -0,0 +1,521 @@
+/*
+ * 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 { readdirSync, statSync } from 'node:fs';
+import { dirname, isAbsolute, join, relative, resolve } from 'node:path';
+import { fileURLToPath } from 'node:url';
+
+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 reactNotebookCoreBoundaryFiles = [
+  resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src/main.ts'),
+  resolve(zeppelinWebAngularRoot, 
'projects/zeppelin-react/src/notebookCoreContract.ts')
+];
+const forbiddenWebSocketModulePrefixes = ['ws', '@types/ws', 'isomorphic-ws', 
'sockjs-client', '@types/sockjs-client'];
+const forbiddenNodeHttpModulePrefixes = ['http', 'https', 'http2', 
'node:http', 'node:https', 'node:http2'];
+export const forbiddenModulePrefixes = [
+  ...forbiddenWebSocketModulePrefixes,
+  ...forbiddenNodeHttpModulePrefixes,
+  '@angular/',
+  '@zeppelin/',
+  '@types/react',
+  '@types/react-dom',
+  'react',
+  'react-dom',
+  'react-redux',
+  'react-router',
+  'react-router-dom',
+  'rxjs',
+  'axios'
+];
+export const forbiddenReactNotebookCoreConsumerModulePrefixes = [
+  ...forbiddenWebSocketModulePrefixes,
+  ...forbiddenNodeHttpModulePrefixes,
+  '@angular/common/http',
+  '@zeppelin/sdk',
+  '@zeppelin/src/',
+  '@zeppelin/services/',
+  'axios',
+  'rxjs'
+];
+const forbiddenGlobals = new Set(['fetch', 'WebSocket', 'XMLHttpRequest']);
+const transportGlobalOwners = new Set(['globalThis', 'window', 'self']);
+
+export const sourceFiles = (dir: string, accepts = isCheckedSourceFile): 
string[] =>
+  readdirSync(dir).flatMap(entry => {
+    const path = join(dir, entry);
+    if (statSync(path).isDirectory()) {
+      return sourceFiles(path, accepts);
+    }
+    return accepts(path) ? [path] : [];
+  });
+
+const checkedSourceExtensions = ['.ts', '.tsx', '.mts', '.cts'];
+
+const isSpecSourceFile = (path: string): boolean => 
/\.spec\.[cm]?[jt]sx?$/.test(path);
+
+const isCheckedSourceFile = (path: string): boolean => {
+  return checkedSourceExtensions.some(extension => path.endsWith(extension)) 
&& !isSpecSourceFile(path);
+};
+
+export const findReactNotebookConsumerViolations = (
+  roots: string[] = sourceFiles(
+    resolve(zeppelinWebAngularRoot, 'projects/zeppelin-react/src'),
+    path => /\.[cm]?[jt]sx?$/.test(path) && !isSpecSourceFile(path)
+  ),
+  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 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 (target) {
+          dependencies.add(target);
+        }
+      }
+      ts.forEachChild(node, visit);
+    };
+    visit(file);
+    for (const reference of file.referencedFiles) {
+      dependencies.add(resolve(dirname(file.fileName), reference.fileName));
+    }
+    for (const reference of file.typeReferenceDirectives) {
+      const target = ts.resolveTypeReferenceDirective(reference.fileName, 
file.fileName, options, host)
+        .resolvedTypeReferenceDirective?.resolvedFileName;
+      if (target) {
+        dependencies.add(target);
+      }
+    }
+    modules.set(file.fileName, { source: file.text, dependencies });
+    // 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) {
+      if (!modules.has(target) && !target.includes('/node_modules/')) {
+        const source = host.readFile(target);
+        if (source !== undefined) {
+          pending.push(ts.createSourceFile(target, source, 
ts.ScriptTarget.Latest, true, getScriptKind(target)));
+        }
+      }
+    }
+  }
+
+  // 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,
+    ...[...modules.keys()].filter(path => path.startsWith(sourceRoot))
+  ]);
+  let changed = true;
+  while (changed) {
+    changed = false;
+    for (const [path, module] of modules) {
+      if (!consumers.has(path) && [...module.dependencies].some(target => 
consumers.has(target))) {
+        consumers.add(path);
+        changed = true;
+      }
+    }
+  }
+
+  const violations: string[] = [];
+  const checked = new Set<string>();
+  const main = reactNotebookCoreBoundaryFiles[0];
+  const check = (path: string): void => {
+    if (checked.has(path) || path.startsWith(sourceRoot)) {
+      return;
+    }
+    checked.add(path);
+    const module = modules.get(path);
+    if (!module) {
+      if (!path.includes('/node_modules/')) {
+        violations.push(`${path}: cannot inspect local notebook dependency`);
+      }
+      return;
+    }
+    violations.push(
+      ...findViolations(path, module.source, 
forbiddenReactNotebookCoreConsumerModulePrefixes, 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) {
+      return;
+    }
+    for (const target of module.dependencies) {
+      if (target === main) {
+        violations.push(`${path}: notebook consumer must use the contract 
bridge instead of the public aggregator`);
+      } else if (consumers.has(target)) {
+        // Only walk further into files the upward propagation above already 
marked as
+        // consumers. Otherwise a shared route/aggregator file that imports 
both a real
+        // notebook-core consumer and an unrelated pre-existing SDK-backed 
page would drag
+        // that page's own dependency subtree into this scan, flagging its 
legitimate
+        // `@zeppelin/sdk`/`rxjs` imports as boundary violations.
+        check(target);
+      }
+    }
+  };
+  consumers.forEach(check);
+  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[] => {
+  const violations = findViolations(path, source, 
forbiddenReactNotebookCoreConsumerModulePrefixes);
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, 
true, getScriptKind(path));
+  const visit = (node: ts.Node): void => {
+    const specifier = getModuleSpecifier(node);
+    if (specifier && specifier !== '@zeppelin/notebook-core') {
+      violations.push(`${path}: notebook contract dependency ${specifier}`);
+    }
+    ts.forEachChild(node, visit);
+  };
+  visit(sourceFile);
+  return violations;
+};
+
+export const findViolations = (
+  path: string,
+  source: string,
+  forbiddenPrefixes: readonly string[] = forbiddenModulePrefixes,
+  compilerOptions = readCompilerOptions(
+    resolve(
+      zeppelinWebAngularRoot,
+      'projects',
+      path.includes('zeppelin-react') ? 'zeppelin-react' : 
'zeppelin-notebook-core',
+      'tsconfig.json'
+    )
+  ),
+  resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+  const sourceFile = ts.createSourceFile(path, source, ts.ScriptTarget.Latest, 
true, getScriptKind(path));
+  const violations: string[] = [];
+
+  // Bind lexical declarations without loading libraries or following imports.
+  // Import provenance is checked separately; unresolved reserved names here
+  // represent globals, while parameters, imports and local declarations do 
not.
+  let bindingChecker: ts.TypeChecker | undefined;
+  const isGlobalIdentifier = (node: ts.Identifier): boolean => {
+    if (!bindingChecker) {
+      const options: ts.CompilerOptions = { noLib: true, noResolve: true, 
types: [], allowJs: true };
+      const host = ts.createCompilerHost(options);
+      host.getSourceFile = file => (file === path ? sourceFile : undefined);
+      bindingChecker = ts.createProgram([path], options, 
host).getTypeChecker();
+    }
+    const symbol = ts.isShorthandPropertyAssignment(node.parent)
+      ? bindingChecker.getShorthandAssignmentValueSymbol(node.parent)
+      : bindingChecker.getSymbolAtLocation(node);
+    return !symbol?.declarations?.length;
+  };
+
+  // Only tslib is a reviewed external dependency of this scaffold. Resolve it
+  // without project aliases so a package name cannot disguise another target.
+  const tslibFile = ts.resolveModuleName(
+    'tslib',
+    path,
+    {
+      ...compilerOptions,
+      baseUrl: undefined,
+      paths: {}
+    },
+    resolutionHost
+  ).resolvedModule?.resolvedFileName;
+  const isForbidden = (identities: readonly string[]): boolean => {
+    if (path.startsWith(sourceRoot)) {
+      const target = identities.filter(isAbsolute).pop();
+      const checkedLocalSource =
+        target !== undefined &&
+        target.startsWith(sourceRoot) &&
+        !target.includes('/node_modules/') &&
+        isCheckedSourceFile(target) &&
+        resolutionHost.fileExists(target);
+      if (!checkedLocalSource && !(target !== undefined && target === 
tslibFile)) {
+        return true;
+      }
+    }
+    return identities.some(
+      identity =>
+        identity === '<computed module dependency>' ||
+        isSpecSourceFile(identity) ||
+        (!matchesModulePrefix(identity, '@zeppelin/notebook-core') &&
+          forbiddenPrefixes.some(prefix => matchesModulePrefix(identity, 
prefix)))
+    );
+  };
+
+  // Reference directives are source-file metadata, not import AST nodes.
+  for (const reference of sourceFile.libReferenceDirectives) {
+    if (/^(?:dom|webworker)(?:\.|$)/i.test(reference.fileName)) {
+      violations.push(`${path}: library ${reference.fileName}`);
+    }
+  }
+  for (const reference of sourceFile.referencedFiles) {
+    const target = resolve(dirname(path), reference.fileName);
+    if (isForbidden(moduleIdentities(target, path, compilerOptions, 
resolutionHost))) {
+      violations.push(`${path}: reference ${reference.fileName}`);
+    }
+  }
+  for (const reference of sourceFile.typeReferenceDirectives) {
+    const resolved = ts.resolveTypeReferenceDirective(
+      reference.fileName,
+      path,
+      compilerOptions,
+      resolutionHost
+    ).resolvedTypeReferenceDirective;
+    const identities = [reference.fileName];
+    if (resolved?.resolvedFileName) {
+      identities.push(...moduleIdentities(resolved.resolvedFileName, path, 
compilerOptions, resolutionHost));
+    }
+    if (isForbidden(identities)) {
+      violations.push(`${path}: reference ${reference.fileName}`);
+    }
+  }
+
+  const visit = (node: ts.Node): void => {
+    const moduleSpecifier = getModuleSpecifier(node);
+    if (moduleSpecifier && isForbidden(moduleIdentities(moduleSpecifier, path, 
compilerOptions, resolutionHost))) {
+      violations.push(`${path}: import ${moduleSpecifier}`);
+    }
+
+    const forbiddenGlobal = getForbiddenTransportGlobalName(node, 
isGlobalIdentifier);
+    if (forbiddenGlobal) {
+      violations.push(`${path}: global ${forbiddenGlobal}`);
+    }
+    const globalAlias = getGlobalObjectAlias(node, isGlobalIdentifier);
+    if (globalAlias) {
+      violations.push(`${path}: global object alias ${globalAlias}`);
+    }
+
+    ts.forEachChild(node, visit);
+  };
+
+  visit(sourceFile);
+  return violations;
+};
+
+export const formatViolations = (violations: readonly string[]): string[] => {
+  return violations.map(violation => 
violation.replace(`${zeppelinWebAngularRoot}/`, ''));
+};
+
+const getScriptKind = (path: string): ts.ScriptKind => {
+  if (path.endsWith('.tsx')) {
+    return ts.ScriptKind.TSX;
+  }
+  if (path.endsWith('.jsx')) {
+    return ts.ScriptKind.JSX;
+  }
+  if (/\.[cm]?js$/.test(path)) {
+    return ts.ScriptKind.JS;
+  }
+  return ts.ScriptKind.TS;
+};
+
+const getModuleSpecifier = (node: ts.Node): string | null => {
+  if ((ts.isImportDeclaration(node) || ts.isExportDeclaration(node)) && 
node.moduleSpecifier) {
+    return ts.isStringLiteral(node.moduleSpecifier) ? 
node.moduleSpecifier.text : null;
+  }
+  if (ts.isImportTypeNode(node) && ts.isLiteralTypeNode(node.argument) && 
ts.isStringLiteral(node.argument.literal)) {
+    return node.argument.literal.text;
+  }
+  if (ts.isImportEqualsDeclaration(node) && 
ts.isExternalModuleReference(node.moduleReference)) {
+    const expression = node.moduleReference.expression;
+    return expression && ts.isStringLiteral(expression) ? expression.text : 
null;
+  }
+  if (
+    ts.isCallExpression(node) &&
+    node.arguments.length >= 1 &&
+    (node.expression.kind === ts.SyntaxKind.ImportKeyword ||
+      (ts.isIdentifier(node.expression) && node.expression.text === 'require'))
+  ) {
+    let argument = node.arguments[0];
+    while (ts.isParenthesizedExpression(argument)) {
+      argument = argument.expression;
+    }
+    // Restricted core/bridge dependencies must be statically identifiable.
+    return ts.isStringLiteral(argument) || 
ts.isNoSubstitutionTemplateLiteral(argument)
+      ? argument.text
+      : '<computed module dependency>';
+  }
+  return null;
+};
+
+export const readCompilerOptions = (path: string): ts.CompilerOptions => {
+  const config = ts.readConfigFile(path, ts.sys.readFile);
+  if (config.error) {
+    throw new Error(ts.flattenDiagnosticMessageText(config.error.messageText, 
'\n'));
+  }
+  const parsed = ts.parseJsonConfigFileContent(config.config, ts.sys, 
dirname(path));
+  if (parsed.errors.length > 0) {
+    throw new Error(
+      ts.formatDiagnostics(parsed.errors, {
+        getCanonicalFileName: file => file,
+        getCurrentDirectory: () => zeppelinWebAngularRoot,
+        getNewLine: () => '\n'
+      })
+    );
+  }
+  return parsed.options;
+};
+
+// Resolve aliases and relative paths before comparing package identities. 
Checking
+// the import spelling alone lets the same forbidden dependency cross the 
boundary.
+const moduleIdentities = (
+  specifier: string,
+  path: string,
+  compilerOptions: ts.CompilerOptions,
+  resolutionHost: ts.ModuleResolutionHost = ts.sys
+): string[] => {
+  const identities = [specifier];
+  const resolved = ts.resolveModuleName(specifier, path, compilerOptions, 
resolutionHost).resolvedModule;
+  if (!resolved) {
+    return identities;
+  }
+  if (resolved.packageId) {
+    identities.push(resolved.packageId.name, 
`${resolved.packageId.name}/${resolved.packageId.subModuleName}`);
+  }
+  const file = resolved.resolvedFileName.replace(/\\/g, '/');
+  // Keep the resolved filename so aliases cannot hide excluded test sources.
+  identities.push(file);
+  const nodeModules = file.lastIndexOf('/node_modules/');
+  if (nodeModules !== -1) {
+    identities.push(file.slice(nodeModules + '/node_modules/'.length));
+  }
+  const localPath = relative(zeppelinWebAngularRoot, file).replace(/\\/g, '/');
+  const project = 
/^(?:projects|dist)\/zeppelin-([^/]+)(\/.*)?$/.exec(localPath);
+  if (project) {
+    identities.push(`@zeppelin/${project[1]}${project[2] ?? ''}`);
+  }
+  if (/^src\/(?:app|environments)\//.test(localPath)) {
+    identities.push(`@zeppelin/${localPath}`);
+  }
+  return identities;
+};
+
+const matchesModulePrefix = (moduleSpecifier: string, prefix: string): boolean 
=> {
+  return moduleSpecifier === prefix || 
moduleSpecifier.startsWith(prefix.endsWith('/') ? prefix : `${prefix}/`);
+};
+
+const isReferenceIdentifier = (node: ts.Identifier): boolean => {
+  const parent = node.parent;
+  return !(
+    (ts.isPropertyAccessExpression(parent) && parent.name === node) ||
+    (ts.isBindingElement(parent) && parent.propertyName === node) ||
+    (ts.isPropertyAssignment(parent) && parent.name === node) ||
+    (ts.isMethodDeclaration(parent) && parent.name === node) ||
+    (ts.isPropertyDeclaration(parent) && parent.name === node) ||
+    (ts.isPropertySignature(parent) && parent.name === node) ||
+    (ts.isMethodSignature(parent) && parent.name === node)
+  );
+};
+
+// Keep direct, non-transport browser properties usable, but do not capture the
+// entire global object in a local alias that hides later transport access.
+// This is a declaration/assignment rule, not general alias or dataflow 
analysis.
+const unwrapExpression = (input: ts.Expression): ts.Expression => {
+  let expression = input;
+  while (
+    ts.isParenthesizedExpression(expression) ||
+    ts.isAsExpression(expression) ||
+    ts.isTypeAssertionExpression(expression) ||
+    ts.isNonNullExpression(expression) ||
+    ts.isSatisfiesExpression(expression)
+  ) {
+    expression = expression.expression;
+  }
+  return expression;
+};
+
+const getGlobalObjectAlias = (node: ts.Node, isGlobal: (node: ts.Identifier) 
=> boolean): string | null => {
+  const initializer =
+    ts.isVariableDeclaration(node) && ts.isIdentifier(node.name)
+      ? node.initializer
+      : ts.isBinaryExpression(node) &&
+          node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+          ts.isIdentifier(node.left)
+        ? node.right
+        : undefined;
+  const value = initializer && unwrapExpression(initializer);
+  return value && ts.isIdentifier(value) && 
transportGlobalOwners.has(value.text) && isGlobal(value)
+    ? value.text
+    : null;
+};
+
+const getForbiddenTransportGlobalName = (node: ts.Node, isGlobal: (node: 
ts.Identifier) => boolean): string | null => {
+  const destructuring =
+    ts.isVariableDeclaration(node) && ts.isObjectBindingPattern(node.name) && 
node.initializer
+      ? { elements: node.name.elements, value: node.initializer }
+      : ts.isBinaryExpression(node) &&
+          node.operatorToken.kind === ts.SyntaxKind.EqualsToken &&
+          ts.isObjectLiteralExpression(node.left)
+        ? { elements: node.left.properties, value: node.right }
+        : undefined;
+  if (destructuring) {
+    const owner = unwrapExpression(destructuring.value);
+    if (ts.isIdentifier(owner) && transportGlobalOwners.has(owner.text) && 
isGlobal(owner)) {
+      for (const element of destructuring.elements) {
+        const name = ts.isBindingElement(element)
+          ? (element.propertyName ?? element.name)
+          : ts.isShorthandPropertyAssignment(element) || 
ts.isPropertyAssignment(element)
+            ? element.name
+            : undefined;
+        if (name && (ts.isIdentifier(name) || ts.isStringLiteral(name)) && 
forbiddenGlobals.has(name.text)) {
+          return name.text;
+        }
+      }
+    }
+  }
+  if (ts.isIndexedAccessTypeNode(node) && 
ts.isLiteralTypeNode(node.indexType)) {
+    const index = node.indexType.literal;
+    let owner = node.objectType;
+    while (ts.isParenthesizedTypeNode(owner)) {
+      owner = owner.type;
+    }
+    if (
+      (ts.isStringLiteral(index) || ts.isNoSubstitutionTemplateLiteral(index)) 
&&
+      forbiddenGlobals.has(index.text) &&
+      ts.isTypeQueryNode(owner) &&
+      ts.isIdentifier(owner.exprName) &&
+      transportGlobalOwners.has(owner.exprName.text) &&
+      isGlobal(owner.exprName)
+    ) {
+      return index.text;
+    }
+  }
+  if (
+    ts.isElementAccessExpression(node) &&
+    (ts.isStringLiteral(node.argumentExpression) || 
ts.isNoSubstitutionTemplateLiteral(node.argumentExpression)) &&
+    forbiddenGlobals.has(node.argumentExpression.text)
+  ) {
+    const owner = unwrapExpression(node.expression);
+    if (ts.isIdentifier(owner) && transportGlobalOwners.has(owner.text) && 
isGlobal(owner)) {
+      return node.argumentExpression.text;
+    }
+  }
+  if (ts.isIdentifier(node) && forbiddenGlobals.has(node.text) && 
isReferenceIdentifier(node) && isGlobal(node)) {
+    return node.text;
+  }
+  if (ts.isPropertyAccessExpression(node) && ts.isIdentifier(node.name) && 
forbiddenGlobals.has(node.name.text)) {
+    const owner = unwrapExpression(node.expression);
+    if (ts.isIdentifier(owner) && transportGlobalOwners.has(owner.text) && 
isGlobal(owner)) {
+      return node.name.text;
+    }
+  }
+  return null;
+};
diff --git a/zeppelin-web-angular/tsconfig.base.json 
b/zeppelin-web-angular/tsconfig.base.json
index 7fee7c8e79..61c7c1ac8f 100644
--- a/zeppelin-web-angular/tsconfig.base.json
+++ b/zeppelin-web-angular/tsconfig.base.json
@@ -6,6 +6,7 @@
       "@zeppelin/*": ["./src/app/*", "./src/environments/*"],
       "@zeppelin/visualization": ["dist/zeppelin-visualization"],
       "@zeppelin/visualization/*": ["dist/zeppelin-visualization/*"],
+      "@zeppelin/notebook-core": ["dist/zeppelin-notebook-core"],
       "@zeppelin/sdk": ["dist/zeppelin-sdk"],
       "@zeppelin/sdk/*": ["dist/zeppelin-sdk/*"]
     },
diff --git a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts 
b/zeppelin-web-angular/vitest.notebook-core.config.mts
similarity index 66%
copy from zeppelin-web-angular/projects/zeppelin-react/src/main.ts
copy to zeppelin-web-angular/vitest.notebook-core.config.mts
index ce7edc883f..d4da29c1a6 100644
--- a/zeppelin-web-angular/projects/zeppelin-react/src/main.ts
+++ b/zeppelin-web-angular/vitest.notebook-core.config.mts
@@ -10,6 +10,11 @@
  * limitations under the License.
  */
 
-export { ConfigurationTable, mount as mountConfigurationTable } from 
'./pages/ConfigurationTable';
-export { PublishedParagraph, mount } from './pages/PublishedParagraph';
-export { ParagraphFooter, mount as mountParagraphFooter } from 
'./components/paragraph/ParagraphFooter';
+import { defineConfig } from 'vitest/config';
+
+export default defineConfig({
+  test: {
+    environment: 'node',
+    include: ['projects/zeppelin-notebook-core/**/*.spec.ts', 
'test/notebook-core/**/*.spec.ts']
+  }
+});
diff --git a/zeppelin-web-angular/vitest.shell.config.mts 
b/zeppelin-web-angular/vitest.shell.config.mts
index c6b35960aa..c7174a4943 100644
--- a/zeppelin-web-angular/vitest.shell.config.mts
+++ b/zeppelin-web-angular/vitest.shell.config.mts
@@ -12,11 +12,11 @@
 
 // vite is pinned in package.json: the React remote keeps its own lockfile and 
drifted to a different minor.
 import { fileURLToPath } from 'node:url';
-import { defineConfig } from 'vitest/config';
+import { configDefaults, defineConfig } from 'vitest/config';
 
 export default defineConfig({
   // Mirrors the `paths` block in tsconfig.base.json, which Vite does not read.
-  // The two library aliases resolve to source, not `dist/`, so a unit run 
does not wait on a build.
+  // The library aliases resolve to source, not `dist/`, so a unit run does 
not wait on a build.
   resolve: {
     alias: [
       // monaco-editor ships no `main` and no `exports` 
(microsoft/monaco-editor#4848).
@@ -39,6 +39,10 @@ export default defineConfig({
         find: /^@zeppelin\/visualization\/(.*)$/,
         replacement: `${fileURLToPath(new 
URL('./projects/zeppelin-visualization/src', import.meta.url))}/$1`
       },
+      {
+        find: /^@zeppelin\/notebook-core$/,
+        replacement: fileURLToPath(new 
URL('./projects/zeppelin-notebook-core/src/public-api.ts', import.meta.url))
+      },
       // `@zeppelin/*` falls back to src/environments in tsconfig; Vite 
aliases do not.
       {
         find: /^@zeppelin\/environment$/,
@@ -64,6 +68,7 @@ export default defineConfig({
       'projects/zeppelin-visualization/**/*.spec.ts',
       'test/**/*.spec.ts'
     ],
+    exclude: [...configDefaults.exclude, 'test/notebook-core/**'],
     setupFiles: ['./test/test-setup.ts'],
     coverage: {
       provider: 'v8',

Reply via email to