Implements the gate-consolidation Agent Note from the NIH dependency audit: - Shared markdownFences helper in scripts/markdown.ts (mdast code-node visit); doc-typecheck and verify-type-equiv extract fences through it; md-fences.ts and the duplicated extractEquivBlocks regex scanner are deleted; markdownProseLines derives fenced lines from parsed code-node positions instead of a second fence regex. - publint-all.ts and verify-built-package-invariants.mjs parse argv with node:util parseArgs instead of hand-stepped parseOptions copies. - Five straggler readdirSync walks become globSync: verify-runtime-closure, dev-web discoverPluginDirs, verify-package-paths realPackageNames, verify-client-domain-graph listSources, publint-all addPath. The dirent-diagnostic walks in check-workspace-constraints.ts and clean.ts stay. Behavior parity verified: pnpm run doc-sync and every rewritten gate produce byte-identical output before and after on this tree. Moves the owning Agent Note proposed -> implemented and re-records its pair.
99 lines
4.1 KiB
TypeScript
99 lines
4.1 KiB
TypeScript
/**
|
|
* Enforce intra-package domain layering inside `packages/client/*\/src/client/`.
|
|
* verify-module-graph covers package-level edges; this gate covers the
|
|
* directory level the future package split will land on: domain directories
|
|
* may import `contract/` and never each other, and only the assembly point
|
|
* (`apply.ts` / `index.ts`) may import across domains.
|
|
*
|
|
* Layer model (lower may not import higher):
|
|
* 0 contract/ shared contract surface (types + slot declarations)
|
|
* 1 <domain>/ + service domain implementations (skeleton/, chat/, ...)
|
|
* 2 apply.ts, index.ts assembly point and re-export shell
|
|
*
|
|
* Not yet wired into the gate sequence (loose-gate window); run directly:
|
|
* pnpm exec tsx scripts/verify-client-domain-graph.ts
|
|
*/
|
|
|
|
import { globSync, readdirSync, readFileSync, statSync } from 'node:fs'
|
|
import { join, resolve, sep } from 'node:path'
|
|
|
|
const root = resolve(import.meta.dirname, '..')
|
|
const CLIENT_DIR = join(root, 'packages/client')
|
|
|
|
/** Directory names treated as the shared contract layer (importable by all). */
|
|
const CONTRACT_DIRS = new Set(['contract'])
|
|
/** Top-level client files allowed to import across domains (assembly layer). */
|
|
const ASSEMBLY_FILES = new Set(['apply.ts', 'index.ts', 'index.tsx'])
|
|
|
|
interface Violation { file: string; imported: string; reason: string }
|
|
|
|
/** Recursively list .ts/.tsx files under dir (relative paths). */
|
|
function listSources(dir: string): string[] {
|
|
return globSync('**/*.{ts,tsx}', { cwd: dir })
|
|
.map(rel => rel.split(sep).join('/'))
|
|
.filter(rel => !/\.legacy\./.test(rel.slice(rel.lastIndexOf('/') + 1)))
|
|
.sort()
|
|
}
|
|
|
|
/** First path segment of a client-relative file, or '' for top-level files. */
|
|
function domainOf(rel: string): string {
|
|
const ix = rel.indexOf('/')
|
|
return ix === -1 ? '' : rel.slice(0, ix)
|
|
}
|
|
|
|
function checkPackage(pkgName: string, clientDir: string): Violation[] {
|
|
const violations: Violation[] = []
|
|
const files = listSources(clientDir)
|
|
for (const rel of files) {
|
|
const fromDomain = domainOf(rel)
|
|
const isAssembly = fromDomain === '' && ASSEMBLY_FILES.has(rel)
|
|
if (isAssembly) continue
|
|
const source = readFileSync(join(clientDir, rel), 'utf8')
|
|
for (const match of source.matchAll(/from\s+['"](\.[^'"]+)['"]/g)) {
|
|
const spec = match[1]
|
|
if (spec === undefined) continue
|
|
// Resolve the relative specifier against the importing file's directory
|
|
// to a client-dir-relative path.
|
|
const fromDir = rel.includes('/') ? rel.slice(0, rel.lastIndexOf('/')) : ''
|
|
const parts = (fromDir ? fromDir.split('/') : [])
|
|
for (const seg of spec.split('/')) {
|
|
if (seg === '.') continue
|
|
if (seg === '..') parts.pop()
|
|
else parts.push(seg)
|
|
}
|
|
const target = parts.join('/')
|
|
if (target.startsWith('..')) continue // out of client dir (package root) — package-level rules govern
|
|
const toDomain = domainOf(target)
|
|
if (toDomain === '' || CONTRACT_DIRS.has(toDomain)) continue // top-level shared file or contract layer
|
|
if (fromDomain === toDomain) continue // inside one domain
|
|
violations.push({
|
|
file: `${pkgName}/src/client/${rel}`,
|
|
imported: spec,
|
|
reason: fromDomain === ''
|
|
? `top-level non-assembly file imports domain "${toDomain}" (only apply/index may assemble)`
|
|
: `domain "${fromDomain}" imports sibling domain "${toDomain}" (route shared surface through contract/)`,
|
|
})
|
|
}
|
|
}
|
|
return violations
|
|
}
|
|
|
|
const violations: Violation[] = []
|
|
for (const pkg of readdirSync(CLIENT_DIR)) {
|
|
const clientDir = join(CLIENT_DIR, pkg, 'src/client')
|
|
try {
|
|
if (!statSync(clientDir).isDirectory()) continue
|
|
} catch {
|
|
// No client half in this package — nothing to layer-check.
|
|
continue
|
|
}
|
|
violations.push(...checkPackage(pkg, clientDir))
|
|
}
|
|
|
|
if (violations.length > 0) {
|
|
console.error(`verify-client-domain-graph: ${violations.length} violation(s):`)
|
|
for (const v of violations) console.error(` ${v.file} -> ${v.imported}\n ${v.reason}`)
|
|
process.exit(1)
|
|
}
|
|
console.log('verify-client-domain-graph: client domain layering clean.')
|