feat(sdk): P2.6 @insignia/iios-message-web (React hooks) + allowlist boundary
MessageProvider/useThread/useMessages over the kernel-client socket (live append, typing, dedupe). Reconnect test (re-opens active thread). Rewrote import-boundary as an allowlist so SDK<->service sibling imports are forbidden both ways. 28 tests. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,51 @@
|
|||||||
|
import { describe, it, expect } from 'vitest';
|
||||||
|
import { MessageSocket } from './message-socket';
|
||||||
|
import type { SocketLike } from './types';
|
||||||
|
|
||||||
|
class FakeSocket implements SocketLike {
|
||||||
|
handlers = new Map<string, Array<(...a: unknown[]) => void>>();
|
||||||
|
acks: Array<{ event: string; payload: unknown }> = [];
|
||||||
|
ackReturn: unknown = { threadId: 't5', status: 'OPEN', history: [] };
|
||||||
|
|
||||||
|
on(event: string, handler: (...a: unknown[]) => void): unknown {
|
||||||
|
const list = this.handlers.get(event) ?? [];
|
||||||
|
list.push(handler);
|
||||||
|
this.handlers.set(event, list);
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
off(): unknown {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
emit(): unknown {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
async emitWithAck(event: string, ...args: unknown[]): Promise<unknown> {
|
||||||
|
this.acks.push({ event, payload: args[0] });
|
||||||
|
return this.ackReturn;
|
||||||
|
}
|
||||||
|
connect(): unknown {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
disconnect(): unknown {
|
||||||
|
return this;
|
||||||
|
}
|
||||||
|
trigger(event: string, ...args: unknown[]): void {
|
||||||
|
(this.handlers.get(event) ?? []).forEach((h) => h(...args));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe('MessageSocket reconnect resilience', () => {
|
||||||
|
it('re-opens the active thread when the socket reconnects (no lost subscription)', async () => {
|
||||||
|
const fake = new FakeSocket();
|
||||||
|
const ms = new MessageSocket({ serviceUrl: 'http://localhost:3200', token: 'tok' }, fake);
|
||||||
|
|
||||||
|
await ms.openThread('t5');
|
||||||
|
const before = fake.acks.filter((a) => a.event === 'open_thread').length;
|
||||||
|
|
||||||
|
fake.trigger('connect'); // simulate a reconnect
|
||||||
|
|
||||||
|
const opens = fake.acks.filter((a) => a.event === 'open_thread');
|
||||||
|
expect(opens.length).toBe(before + 1);
|
||||||
|
expect(opens.at(-1)?.payload).toMatchObject({ threadId: 't5' });
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"name": "@insignia/iios-message-web",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"main": "dist/index.js",
|
||||||
|
"module": "dist/index.js",
|
||||||
|
"types": "dist/index.d.ts",
|
||||||
|
"exports": { ".": { "types": "./dist/index.d.ts", "import": "./dist/index.js" } },
|
||||||
|
"files": ["dist"],
|
||||||
|
"scripts": {
|
||||||
|
"build": "tsup",
|
||||||
|
"typecheck": "tsc --noEmit"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@insignia/iios-kernel-client": "workspace:*"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": ">=18"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@types/react": "^19.0.0",
|
||||||
|
"react": "^19.0.0",
|
||||||
|
"tsup": "^8.3.5",
|
||||||
|
"typescript": "^5.7.3"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
export { MessageProvider, useThread, useMessages } from './react';
|
||||||
|
export type { Message } from '@insignia/iios-kernel-client';
|
||||||
@@ -0,0 +1,101 @@
|
|||||||
|
import React, { createContext, useContext, useEffect, useMemo, useRef, useState } from 'react';
|
||||||
|
import { MessageSocket, type Message } from '@insignia/iios-kernel-client';
|
||||||
|
|
||||||
|
interface MessageContextValue {
|
||||||
|
socket: MessageSocket;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MessageContext = createContext<MessageContextValue | null>(null);
|
||||||
|
|
||||||
|
/** Wraps a MessageSocket and provides it to the message hooks. */
|
||||||
|
export function MessageProvider({
|
||||||
|
serviceUrl,
|
||||||
|
token,
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
serviceUrl: string;
|
||||||
|
token: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}): React.ReactElement {
|
||||||
|
const socket = useMemo(() => new MessageSocket({ serviceUrl, token }), [serviceUrl, token]);
|
||||||
|
useEffect(() => () => socket.disconnect(), [socket]);
|
||||||
|
return <MessageContext.Provider value={{ socket }}>{children}</MessageContext.Provider>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function useSocket(): MessageSocket {
|
||||||
|
const ctx = useContext(MessageContext);
|
||||||
|
if (!ctx) throw new Error('useThread/useMessages must be used within <MessageProvider>');
|
||||||
|
return ctx.socket;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Open or create a thread; returns the resolved thread id + status. */
|
||||||
|
export function useThread(): {
|
||||||
|
threadId: string | null;
|
||||||
|
status: string;
|
||||||
|
open: (id?: string) => Promise<string>;
|
||||||
|
} {
|
||||||
|
const socket = useSocket();
|
||||||
|
const [threadId, setThreadId] = useState<string | null>(null);
|
||||||
|
const [status, setStatus] = useState('OPEN');
|
||||||
|
|
||||||
|
const open = async (id?: string): Promise<string> => {
|
||||||
|
const r = await socket.openThread(id);
|
||||||
|
setThreadId(r.threadId);
|
||||||
|
setStatus(r.status);
|
||||||
|
return r.threadId;
|
||||||
|
};
|
||||||
|
|
||||||
|
return { threadId, status, open };
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live messages for a thread + send/read/typing actions. */
|
||||||
|
export function useMessages(threadId: string | null): {
|
||||||
|
messages: Message[];
|
||||||
|
send: (content: string, opts?: { contentRef?: string }) => Promise<void>;
|
||||||
|
markRead: (interactionId: string) => Promise<void>;
|
||||||
|
typing: () => void;
|
||||||
|
typingUsers: string[];
|
||||||
|
} {
|
||||||
|
const socket = useSocket();
|
||||||
|
const [messages, setMessages] = useState<Message[]>([]);
|
||||||
|
const [typingUsers, setTypingUsers] = useState<string[]>([]);
|
||||||
|
const seen = useRef<Set<string>>(new Set());
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!threadId) return;
|
||||||
|
seen.current = new Set();
|
||||||
|
|
||||||
|
void socket.openThread(threadId).then((r) => {
|
||||||
|
seen.current = new Set(r.history.map((m) => m.id));
|
||||||
|
setMessages(r.history);
|
||||||
|
});
|
||||||
|
|
||||||
|
const offMessage = socket.on('message', (m) => {
|
||||||
|
if (m.threadId !== threadId || seen.current.has(m.id)) return;
|
||||||
|
seen.current.add(m.id);
|
||||||
|
setMessages((prev) => [...prev, m]);
|
||||||
|
});
|
||||||
|
const offTyping = socket.on('typing', (e) => {
|
||||||
|
if (e.threadId !== threadId) return;
|
||||||
|
setTypingUsers((u) => (u.includes(e.userId) ? u : [...u, e.userId]));
|
||||||
|
setTimeout(() => setTypingUsers((u) => u.filter((x) => x !== e.userId)), 2500);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
offMessage();
|
||||||
|
offTyping();
|
||||||
|
};
|
||||||
|
}, [socket, threadId]);
|
||||||
|
|
||||||
|
const send = async (content: string, opts?: { contentRef?: string }): Promise<void> => {
|
||||||
|
if (threadId) await socket.sendMessage(threadId, content, opts);
|
||||||
|
};
|
||||||
|
const markRead = async (interactionId: string): Promise<void> => {
|
||||||
|
if (threadId) await socket.markRead(threadId, interactionId);
|
||||||
|
};
|
||||||
|
const typing = (): void => {
|
||||||
|
if (threadId) socket.typing(threadId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { messages, send, markRead, typing, typingUsers };
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"extends": "../../tsconfig.base.json",
|
||||||
|
"compilerOptions": {
|
||||||
|
"outDir": "./dist",
|
||||||
|
"rootDir": "./src",
|
||||||
|
"module": "ESNext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"lib": ["ES2022", "DOM"],
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
"types": ["react"]
|
||||||
|
},
|
||||||
|
"include": ["src/**/*.ts", "src/**/*.tsx"],
|
||||||
|
"exclude": ["src/**/*.test.ts", "src/**/*.test.tsx"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import { defineConfig } from 'tsup';
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
entry: ['src/index.ts'],
|
||||||
|
format: ['esm'],
|
||||||
|
dts: true,
|
||||||
|
clean: true,
|
||||||
|
external: ['react', 'react-dom', '@insignia/iios-kernel-client'],
|
||||||
|
});
|
||||||
Generated
+37
@@ -36,6 +36,25 @@ importers:
|
|||||||
specifier: ^5.7.3
|
specifier: ^5.7.3
|
||||||
version: 5.9.3
|
version: 5.9.3
|
||||||
|
|
||||||
|
packages/iios-message-web:
|
||||||
|
dependencies:
|
||||||
|
'@insignia/iios-kernel-client':
|
||||||
|
specifier: workspace:*
|
||||||
|
version: link:../iios-kernel-client
|
||||||
|
devDependencies:
|
||||||
|
'@types/react':
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.17
|
||||||
|
react:
|
||||||
|
specifier: ^19.0.0
|
||||||
|
version: 19.2.7
|
||||||
|
tsup:
|
||||||
|
specifier: ^8.3.5
|
||||||
|
version: 8.5.1(jiti@2.7.0)(postcss@8.5.16)(typescript@5.9.3)
|
||||||
|
typescript:
|
||||||
|
specifier: ^5.7.3
|
||||||
|
version: 5.9.3
|
||||||
|
|
||||||
packages/iios-service:
|
packages/iios-service:
|
||||||
dependencies:
|
dependencies:
|
||||||
'@insignia/iios-contracts':
|
'@insignia/iios-contracts':
|
||||||
@@ -929,6 +948,9 @@ packages:
|
|||||||
'@types/range-parser@1.2.7':
|
'@types/range-parser@1.2.7':
|
||||||
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==}
|
||||||
|
|
||||||
|
'@types/react@19.2.17':
|
||||||
|
resolution: {integrity: sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==}
|
||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||||
|
|
||||||
@@ -1318,6 +1340,9 @@ packages:
|
|||||||
typescript:
|
typescript:
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
|
csstype@3.2.3:
|
||||||
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1992,6 +2017,10 @@ packages:
|
|||||||
rc9@2.1.2:
|
rc9@2.1.2:
|
||||||
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
resolution: {integrity: sha512-btXCnMmRIBINM2LDZoEmOogIZU7Qe7zn4BpomSKZ/ykbLObuBdvG+mFq11DL6fjH1DRwHhrlgtYWG96bJiC7Cg==}
|
||||||
|
|
||||||
|
react@19.2.7:
|
||||||
|
resolution: {integrity: sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==}
|
||||||
|
engines: {node: '>=0.10.0'}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==}
|
||||||
engines: {node: '>= 6'}
|
engines: {node: '>= 6'}
|
||||||
@@ -3184,6 +3213,10 @@ snapshots:
|
|||||||
|
|
||||||
'@types/range-parser@1.2.7': {}
|
'@types/range-parser@1.2.7': {}
|
||||||
|
|
||||||
|
'@types/react@19.2.17':
|
||||||
|
dependencies:
|
||||||
|
csstype: 3.2.3
|
||||||
|
|
||||||
'@types/send@1.2.1':
|
'@types/send@1.2.1':
|
||||||
dependencies:
|
dependencies:
|
||||||
'@types/node': 26.0.1
|
'@types/node': 26.0.1
|
||||||
@@ -3600,6 +3633,8 @@ snapshots:
|
|||||||
optionalDependencies:
|
optionalDependencies:
|
||||||
typescript: 5.9.3
|
typescript: 5.9.3
|
||||||
|
|
||||||
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
@@ -4300,6 +4335,8 @@ snapshots:
|
|||||||
defu: 6.1.7
|
defu: 6.1.7
|
||||||
destr: 2.0.5
|
destr: 2.0.5
|
||||||
|
|
||||||
|
react@19.2.7: {}
|
||||||
|
|
||||||
readable-stream@3.6.2:
|
readable-stream@3.6.2:
|
||||||
dependencies:
|
dependencies:
|
||||||
inherits: 2.0.4
|
inherits: 2.0.4
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
/**
|
/**
|
||||||
* Import-boundary law (Bottom-Up §06 dependency law; Critics §12).
|
* Import-boundary law (Bottom-Up §06 dependency law; Critics §12).
|
||||||
*
|
*
|
||||||
* Layers, low → high: contracts < testkit < service. A LOWER layer must never
|
* Each package declares exactly which other @insignia/iios-* packages it MAY
|
||||||
* import a HIGHER one. This guards the "no specialization leaks into the kernel"
|
* import. Anything else is a violation. This is an allowlist, not a ladder —
|
||||||
* rule from day one. Runnable as `pnpm boundary` (CI) and imported by a test.
|
* it correctly forbids sibling imports (e.g. the SDK must never import the
|
||||||
|
* backend service, and vice-versa). Runnable as `pnpm boundary` (CI) + a test.
|
||||||
*/
|
*/
|
||||||
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
import { readFileSync, readdirSync, statSync, existsSync } from 'node:fs';
|
||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
@@ -11,12 +12,15 @@ import { dirname, join, resolve } from 'node:path';
|
|||||||
|
|
||||||
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), '..');
|
||||||
|
|
||||||
/** Lower rank = lower layer. A package may only import packages of <= its rank. */
|
/** package -> the @insignia/iios-* packages it is allowed to import. */
|
||||||
const RANK = {
|
const ALLOWED = {
|
||||||
'@insignia/iios-contracts': 0,
|
'@insignia/iios-contracts': [],
|
||||||
'@insignia/iios-testkit': 1,
|
'@insignia/iios-testkit': ['@insignia/iios-contracts'],
|
||||||
'@insignia/iios-service': 2,
|
'@insignia/iios-kernel-client': ['@insignia/iios-contracts'],
|
||||||
|
'@insignia/iios-service': ['@insignia/iios-contracts', '@insignia/iios-testkit'],
|
||||||
|
'@insignia/iios-message-web': ['@insignia/iios-contracts', '@insignia/iios-kernel-client'],
|
||||||
};
|
};
|
||||||
|
const KNOWN = Object.keys(ALLOWED);
|
||||||
|
|
||||||
function listTsFiles(dir) {
|
function listTsFiles(dir) {
|
||||||
if (!existsSync(dir)) return [];
|
if (!existsSync(dir)) return [];
|
||||||
@@ -25,13 +29,13 @@ function listTsFiles(dir) {
|
|||||||
if (entry === 'node_modules' || entry === 'dist') continue;
|
if (entry === 'node_modules' || entry === 'dist') continue;
|
||||||
const full = join(dir, entry);
|
const full = join(dir, entry);
|
||||||
if (statSync(full).isDirectory()) out.push(...listTsFiles(full));
|
if (statSync(full).isDirectory()) out.push(...listTsFiles(full));
|
||||||
else if (full.endsWith('.ts')) out.push(full);
|
else if (full.endsWith('.ts') || full.endsWith('.tsx')) out.push(full);
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}
|
}
|
||||||
|
|
||||||
function packageOf(importPath) {
|
function packageOf(importPath) {
|
||||||
return Object.keys(RANK).find((k) => importPath === k || importPath.startsWith(k + '/'));
|
return KNOWN.find((k) => importPath === k || importPath.startsWith(k + '/'));
|
||||||
}
|
}
|
||||||
|
|
||||||
export function findBoundaryViolations() {
|
export function findBoundaryViolations() {
|
||||||
@@ -43,11 +47,9 @@ export function findBoundaryViolations() {
|
|||||||
const pkgJsonPath = join(pkgsDir, pkg, 'package.json');
|
const pkgJsonPath = join(pkgsDir, pkg, 'package.json');
|
||||||
if (!existsSync(pkgJsonPath)) continue;
|
if (!existsSync(pkgJsonPath)) continue;
|
||||||
const name = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).name;
|
const name = JSON.parse(readFileSync(pkgJsonPath, 'utf8')).name;
|
||||||
const ownRank = RANK[name];
|
const allowed = ALLOWED[name];
|
||||||
if (ownRank === undefined) continue;
|
if (!allowed) continue; // not a governed package
|
||||||
|
|
||||||
// Catch every module-specifier form: `from '…'`, side-effect `import '…'`,
|
|
||||||
// dynamic `import('…')`, and `require('…')`.
|
|
||||||
const patterns = [
|
const patterns = [
|
||||||
/from\s*['"](@insignia\/iios-[^'"]+)['"]/g,
|
/from\s*['"](@insignia\/iios-[^'"]+)['"]/g,
|
||||||
/import\s*['"](@insignia\/iios-[^'"]+)['"]/g,
|
/import\s*['"](@insignia\/iios-[^'"]+)['"]/g,
|
||||||
@@ -59,9 +61,9 @@ export function findBoundaryViolations() {
|
|||||||
for (const re of patterns) {
|
for (const re of patterns) {
|
||||||
let m;
|
let m;
|
||||||
while ((m = re.exec(content))) {
|
while ((m = re.exec(content))) {
|
||||||
const targetPkg = packageOf(m[1]);
|
const target = packageOf(m[1]);
|
||||||
if (targetPkg && RANK[targetPkg] > ownRank) {
|
if (target && target !== name && !allowed.includes(target)) {
|
||||||
violations.push({ file: file.replace(ROOT + '/', ''), from: name, imports: targetPkg });
|
violations.push({ file: file.replace(ROOT + '/', ''), from: name, imports: target });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -73,9 +75,9 @@ export function findBoundaryViolations() {
|
|||||||
if (import.meta.url === `file://${process.argv[1]}`) {
|
if (import.meta.url === `file://${process.argv[1]}`) {
|
||||||
const violations = findBoundaryViolations();
|
const violations = findBoundaryViolations();
|
||||||
if (violations.length > 0) {
|
if (violations.length > 0) {
|
||||||
console.error('✗ import-boundary violations (a lower layer imports a higher one):');
|
console.error('✗ import-boundary violations (a package imports a forbidden dependency):');
|
||||||
for (const v of violations) console.error(` ${v.file}: ${v.from} → ${v.imports}`);
|
for (const v of violations) console.error(` ${v.file}: ${v.from} → ${v.imports}`);
|
||||||
process.exit(1);
|
process.exit(1);
|
||||||
}
|
}
|
||||||
console.log('✓ import-boundary: OK (no lower→higher imports)');
|
console.log('✓ import-boundary: OK');
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user