Skip to content

Commit 12845fe

Browse files
Separate public machine contracts (#78)
1 parent 2d6a76e commit 12845fe

13 files changed

Lines changed: 5083 additions & 3305 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@typeonce/effect-machine": patch
3+
---
4+
5+
Separate the public Machine, MachineTest, AtomMachine, and ClusterMachine contracts from their internal implementations. Enforce designated implementation seams and explicit public function signatures through the architecture check without changing the package API.

scripts/check-architecture.mjs

Lines changed: 79 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,14 +4,16 @@ import ts from "typescript"
44

55
const ruleDescriptions = {
66
ARCH001: "Public entrypoints may only expose public modules",
7+
ARCH002: "Public modules may only reach internals through their designated implementation seam",
78
ARCH003: "Core internals may only refer back to Machine through type-only imports",
89
ARCH004: "The planner may not depend on process or runtime execution",
910
ARCH006: "The runtime may not depend on machine semantics or process orchestration",
1011
ARCH007: "Production modules may not depend on testing internals",
1112
ARCH008: "Black-box tests may not depend on implementation internals",
1213
ARCH009: "Production runtime imports must be acyclic",
1314
ARCH011: "Internal directories may not use barrel modules",
14-
ARCH012: "Internal filenames must describe their responsibility without a machine prefix"
15+
ARCH012: "Internal filenames must describe their responsibility without a machine prefix",
16+
ARCH013: "Public implementation bindings must declare their API signature"
1517
}
1618

1719
const normalizePath = (path) => path.split(sep).join("/")
@@ -211,6 +213,12 @@ export const checkArchitecture = ({
211213
"src/unstable/cluster/index.ts",
212214
"src/unstable/reactivity/index.ts"
213215
])
216+
const implementationSeams = new Map([
217+
["src/Machine.ts", "src/internal/machine/machine.ts"],
218+
["src/testing/MachineTest.ts", "src/internal/testing/machine/verification.ts"],
219+
["src/unstable/reactivity/AtomMachine.ts", "src/internal/machine/atom.ts"],
220+
["src/unstable/cluster/ClusterMachine.ts", "src/internal/machine/cluster.ts"]
221+
])
214222

215223
for (const edge of edges) {
216224
if (entrypoints.has(edge.source) && edge.target.includes("/internal/")) {
@@ -222,6 +230,21 @@ export const checkArchitecture = ({
222230
`Public entrypoint imports internal module ${edge.target}`
223231
))
224232
}
233+
const implementationSeam = implementationSeams.get(edge.source)
234+
if (
235+
implementationSeam !== undefined &&
236+
!edge.typeOnly &&
237+
edge.target.includes("/internal/") &&
238+
edge.target !== implementationSeam
239+
) {
240+
diagnostics.push(diagnostic(
241+
"ARCH002",
242+
edge.sourceFile,
243+
edge.node,
244+
edge.source,
245+
`Public module bypasses its implementation seam through ${edge.target}`
246+
))
247+
}
225248
if (
226249
edge.source.startsWith("src/internal/machine/") &&
227250
edge.target === "src/Machine.ts" &&
@@ -295,6 +318,61 @@ export const checkArchitecture = ({
295318

296319
for (const sourceFile of program.getSourceFiles()) {
297320
const path = projectPath(root, sourceFile.fileName)
321+
const implementationSeam = implementationSeams.get(path)
322+
if (implementationSeam !== undefined) {
323+
const implementationNamespaces = new Set()
324+
const implementationValues = new Set()
325+
for (const statement of sourceFile.statements) {
326+
if (
327+
!ts.isImportDeclaration(statement) ||
328+
statement.importClause === undefined ||
329+
statement.importClause.isTypeOnly ||
330+
!ts.isStringLiteral(statement.moduleSpecifier) ||
331+
resolveProjectModule(
332+
statement.moduleSpecifier.text,
333+
sourceFile,
334+
program.getCompilerOptions(),
335+
root
336+
) !== implementationSeam
337+
) continue
338+
if (statement.importClause.name !== undefined) {
339+
implementationValues.add(statement.importClause.name.text)
340+
}
341+
const bindings = statement.importClause.namedBindings
342+
if (bindings === undefined) continue
343+
if (ts.isNamespaceImport(bindings)) {
344+
implementationNamespaces.add(bindings.name.text)
345+
} else {
346+
for (const element of bindings.elements) {
347+
if (!element.isTypeOnly) implementationValues.add(element.name.text)
348+
}
349+
}
350+
}
351+
for (const statement of sourceFile.statements) {
352+
if (
353+
!ts.isVariableStatement(statement) ||
354+
!statement.modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword)
355+
) continue
356+
for (const declaration of statement.declarationList.declarations) {
357+
if (declaration.initializer === undefined) continue
358+
const bindsImplementation =
359+
(ts.isPropertyAccessExpression(declaration.initializer) &&
360+
ts.isIdentifier(declaration.initializer.expression) &&
361+
implementationNamespaces.has(declaration.initializer.expression.text)) ||
362+
(ts.isIdentifier(declaration.initializer) && implementationValues.has(declaration.initializer.text))
363+
if (!bindsImplementation) continue
364+
if (declaration.type === undefined) {
365+
diagnostics.push(diagnostic(
366+
"ARCH013",
367+
sourceFile,
368+
declaration,
369+
path,
370+
"Public implementation binding relies on an inferred internal signature"
371+
))
372+
}
373+
}
374+
}
375+
}
298376
if (!path.startsWith("src/internal/")) continue
299377
if (basename(path) === "index.ts") {
300378
diagnostics.push({

scripts/check-architecture.test.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,24 @@ const rules = (root) => checkArchitecture({ rootDirectory: root }).map((diagnost
4343
test("accepts Effect-shaped modules and ignores type-only back-edges", () => {
4444
const root = makeProject({
4545
"src/index.ts": 'export * as Machine from "./Machine.js"',
46-
"src/Machine.ts": 'import * as Model from "./internal/machine/model.js"\nexport interface Machine {}\nexport const model = Model.value',
46+
"src/Machine.ts": 'import * as internal from "./internal/machine/machine.js"\nimport type * as Model from "./internal/machine/model.js"\nexport interface Machine { readonly model: typeof Model.value }\nexport const make: () => Machine = internal.make',
47+
"src/internal/machine/machine.ts": 'import type { Machine } from "../../Machine.js"\nexport const make = (): Machine => ({ model: 1 })',
4748
"src/internal/machine/model.ts": 'import type { Machine } from "../../Machine.js"\nexport const value = 1',
4849
"test/machine/Machine.test.ts": 'import { Machine } from "../../src/index.js"\nvoid Machine',
4950
"test/internal/machine/model.test.ts": 'import { value } from "../../../src/internal/machine/model.js"\nvoid value'
5051
})
5152
assert.deepEqual(rules(root), [])
5253
})
5354

55+
test("rejects public implementation bypasses and inferred internal signatures", () => {
56+
const root = makeProject({
57+
"src/Machine.ts": 'import * as internal from "./internal/machine/machine.js"\nimport { value } from "./internal/machine/model.js"\nexport const make = internal.make\nexport const model = value',
58+
"src/internal/machine/machine.ts": "export const make = () => 1",
59+
"src/internal/machine/model.ts": "export const value = 1"
60+
})
61+
assert.deepEqual(rules(root), ["ARCH002", "ARCH013"])
62+
})
63+
5464
test("rejects entrypoint leaks, black-box internal imports, barrels, and legacy filenames", () => {
5565
const root = makeProject({
5666
"src/index.ts": 'export { value } from "./internal/machine/model.js"',

0 commit comments

Comments
 (0)