Shell/core import law: public API barrels + ESLint enforcement (stage 2)

The shell now imports through exactly two barrels, enforced by
import/no-restricted-paths (npm run lint):

  components/**  ->  "@/src/core"      (pure core API, browser-safe)
  app/**         ->  "@/src/core" + "@/src/server" (server entries)

Zone violations are lint errors with architecture-pointer messages; the
pure-core modules (contracts, question-core, plan-tree, phases, pending,
math-delims, ui-message) may never import agent/server/store.

Supporting moves (direction: IO layers -> core):
- core/index.ts: the core public API barrel
- core/ui-message.ts: moved from src/agent (it was shell-facing all along)
- quiz/ask input zod schemas moved agent/tools -> core/question-core
  (the question contract, alongside the grading logic)
- ChatRecord/ChatSummary moved store/chats -> core/contracts (single schema)
- GraphSnapshot/NodeStateClass moved core/graph -> core/contracts (the
  /api/graph payload IS a contract; the pure renderer needs only these)
- src/server/{chats,graph,jobs}.ts + index.ts: thin server entries for the
  transport layer; GET /api/graph's fold side-effect now lives behind
  graphView() instead of inline in the route

Gates: tsc clean, 190/190 tests, eslint clean (law verified to fire on
store/agent/subpath violations), production build clean.

(Repo repair en route: a git object (app/chat/[id] tree) was missing from
the object store — reconstructed byte-exactly via mktree from its blob;
the broken node_modules that stalled the build was cleanly reinstalled.)
This commit is contained in:
Amos
2026-08-31 19:51:17 +03:00
parent bfb31568fc
commit e92140f993
37 changed files with 4506 additions and 171 deletions
+1 -1
View File
@@ -36,7 +36,7 @@ import {
type PluginConfig,
} from "streamdown";
import { MermaidError } from "@/components/mermaid-error.tsx";
import { transformMathDelimiters } from "@/src/core/math-delims.ts";
import { transformMathDelimiters } from "@/src/core";
export type MessageProps = HTMLAttributes<HTMLDivElement> & {
from: UIMessage["role"];
+1 -1
View File
@@ -21,7 +21,7 @@ import {
} from "react";
import { Streamdown } from "streamdown";
import { transformMathDelimiters } from "@/src/core/math-delims";
import { transformMathDelimiters } from "@/src/core";
import { mermaidOptions, streamdownPlugins } from "./message";
import { Shimmer } from "./shimmer";
+1 -1
View File
@@ -2,7 +2,7 @@
import { cn } from "@/lib/utils";
import { MessageResponse } from "@/components/ai-elements/message";
import type { QuestionAnswerData } from "@/src/core/question-core";
import type { QuestionAnswerData } from "@/src/core";
/**
* Static rendering of an answered question (tool part in `output-available`).
+1 -1
View File
@@ -2,7 +2,7 @@
import { useContext, useEffect, useMemo, useRef, useState } from "react";
import { ChatActionsContext } from "@/components/chat";
import type { ArtifactPartProps, DemoResult, JobRecord } from "@/src/core/contracts";
import type { ArtifactPartProps, DemoResult, JobRecord } from "@/src/core";
/**
* Demo artifact card (W6): while the part is input-available, a job activity
+1 -1
View File
@@ -1,5 +1,5 @@
import type { ComponentType } from "react";
import type { ArtifactPartProps } from "@/src/core/contracts";
import type { ArtifactPartProps } from "@/src/core";
import { DemoCard } from "@/components/artifacts/demo-card";
import { JobCard } from "@/components/artifacts/job-card";
import { PlanDivider } from "@/components/artifacts/plan-divider";
+1 -1
View File
@@ -3,7 +3,7 @@
import { useContext, useEffect, useRef, useState } from "react";
import { MessageResponse } from "@/components/ai-elements/message";
import { ChatActionsContext } from "@/components/chat";
import type { ArtifactPartProps, JobRecord, ResearchResult } from "@/src/core/contracts";
import type { ArtifactPartProps, JobRecord, ResearchResult } from "@/src/core";
/**
* The research job card (contracts.ts "THE ASYNC PROTOCOL" §4).
+1 -1
View File
@@ -1,4 +1,4 @@
import type { ArtifactPartProps, PlanToolResult } from "@/src/core/contracts";
import type { ArtifactPartProps, PlanToolResult } from "@/src/core";
/**
* The probe→teach divider (vision §2 "Two phases, clearly separated").
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { ArtifactPartProps, VisualResult } from "@/src/core/contracts";
import type { ArtifactPartProps, VisualResult } from "@/src/core";
import { JobActivityCard } from "@/components/artifacts/demo-card";
/**
+4 -7
View File
@@ -20,11 +20,8 @@ import { Composer } from "@/components/composer";
import { QuestionDock } from "@/components/question-dock";
import { SidebarToggle } from "@/components/sidebar";
import { TreePanel } from "@/components/tree-panel";
import type { TeacherUIMessage } from "@/src/agent/ui-message";
import type { ArtifactPartProps } from "@/src/core/contracts";
import type { QuestionAnswerData } from "@/src/core/question-core";
import { findPendingJobCall, findPendingToolCall } from "@/src/core/pending";
import { phaseAt } from "@/src/core/phases";
import type { ArtifactPartProps, QuestionAnswerData, TeacherUIMessage } from "@/src/core";
import { findPendingJobCall, findPendingToolCall, phaseAt } from "@/src/core";
/** One width for everything: transcript, cards, and the bottom blob share this column. */
const COLUMN = "mx-auto w-full max-w-3xl px-4";
@@ -101,7 +98,7 @@ export function Chat({
) {
void sendMessage();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- mount-only by design
// mount-only by design
}, []);
const pending = findPendingToolCall(messages);
@@ -318,7 +315,7 @@ function DockOverlay({
setExpanded(isAtBottom);
if (isAtBottom) void scrollToBottom();
}
// eslint-disable-next-line react-hooks/exhaustive-deps -- fire only when a NEW question lands
// fire only when a NEW question lands
}, [pendingId]);
// ── Collapse state machine with hysteresis ──
+1 -3
View File
@@ -20,9 +20,7 @@ import Link from "next/link";
import { WaypointsIcon } from "lucide-react";
import Graph from "graphology";
import forceAtlas2 from "graphology-layout-forceatlas2";
import type { GraphNode } from "@/src/core/contracts";
import type { GraphSnapshot, NodeStateClass } from "@/src/core/graph";
import { seededRandom } from "@/src/core/question-core";
import { seededRandom, type GraphNode, type GraphSnapshot, type NodeStateClass } from "@/src/core";
/** Per-state visuals: color token + node size (WebGL wants hex, not oklch). */
const STATE_TOKENS: Record<NodeStateClass, { cssVar: string; size: number }> = {
+3 -2
View File
@@ -5,7 +5,6 @@ import { ArrowDownIcon, ChevronRightIcon, PlayIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { MessageResponse } from "@/components/ai-elements/message";
import { cn } from "@/lib/utils";
import type { AskToolInput, QuizToolInput } from "@/src/agent/tools";
import {
buildAnswer,
coerceCorrectAnswer,
@@ -14,10 +13,12 @@ import {
seededRandom,
shuffleOptions,
type AnswerRequest,
type AskToolInput,
type QuestionAnswerData,
type QuestionData,
type QuestionMode,
} from "@/src/core/question-core";
type QuizToolInput,
} from "@/src/core";
/**
* The question dock — a pending quiz/ask REPLACES the chat composer: the
+1 -1
View File
@@ -6,7 +6,7 @@ import { usePathname, useRouter } from "next/navigation";
import { PanelLeftCloseIcon, PanelLeftOpenIcon } from "lucide-react";
import { Button } from "@/components/ui/button";
import { cn } from "@/lib/utils";
import type { ChatSummary } from "@/src/store/chats";
import type { ChatSummary } from "@/src/core";
/**
* Toggleable sidebar — NOT a modal drawer: no backdrop, no focus theft. It
+1 -6
View File
@@ -3,12 +3,7 @@
import { useEffect, useMemo, useState } from "react";
import { CheckIcon, ChevronsRightIcon, ListTreeIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import {
planNodeStatus,
type PlanNode,
type PlanNodeStatus,
type PlanTree,
} from "@/src/core/contracts";
import { planNodeStatus, type PlanNode, type PlanNodeStatus, type PlanTree } from "@/src/core";
/**
* The persistent plan-tree panel (vision §2 "Persistent tree view", W7).