Merge branch 'feat/windows-pwsh-default' into feat/windows-acl-sandbox

# Conflicts:
#	.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.i18n.yaml
#	.agents/notes/implemented/feature/2026-08-01-pwsh-tool-and-executor.zh.md
#	packages/bash/tool-pwsh/README.i18n.yaml
#	packages/bash/tool-pwsh/README.zh.md
This commit is contained in:
Huanqi Cao
2026-08-09 16:59:52 +08:00
1478 changed files with 15863 additions and 4560 deletions

View File

@@ -9,41 +9,54 @@ import { globSync, readFileSync } from 'node:fs'
import { resolve, sep } from 'node:path'
import ts from 'typescript'
/** Cheap textual prefilter for a cordis module merge, quote-style agnostic
* (the AST match below reads `stmt.name.text` and never sees the quotes). */
const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/
/**
* Parse every file matching `pattern` (repo-relative, sorted, `/`-normalized)
* that textually mentions `interface Context`, yielding each file's cordis
* module-merge body. Files without a merge are skipped.
* @param scanRoot - Repository root the pattern is resolved against.
* @param pattern - Glob selecting the TypeScript files to scan.
* @returns One entry per file with a cordis module merge, in path order.
* Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized)
* that textually contains a cordis module merge, yielding one entry per merge
* BLOCK — a file may legally hold several `declare module 'cordis'` blocks
* (the Typert analyzer reads them all), so the exhaustiveness scan must too.
* Files without a merge are skipped.
* @param scanRoot - Repository root the patterns are resolved against.
* @param patterns - Glob(s) selecting the TypeScript files to scan.
* @returns One entry per cordis module block, in path then source order.
*/
export function contextMergeFiles(
scanRoot: string,
pattern: string,
patterns: string | readonly string[],
): { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] {
const out: { rel: string; sf: ts.SourceFile; text: string; body: ts.ModuleBlock }[] = []
for (const rel of globSync(pattern, { cwd: scanRoot }).map(s => s.split(sep).join('/')).sort()) {
const rels = [...new Set(globSync(patterns as string | string[], { cwd: scanRoot }).map(s => s.split(sep).join('/')))].sort()
for (const rel of rels) {
const abs = resolve(scanRoot, rel)
const text = readFileSync(abs, 'utf8')
if (!text.includes('interface Context')) continue
if (!MERGE_HEAD.test(text)) continue
const sf = ts.createSourceFile(abs, text, ts.ScriptTarget.Latest, true)
const body = cordisModuleBody(sf)
if (!body) continue
out.push({ rel, sf, text, body })
for (const body of cordisModuleBodies(sf)) out.push({ rel, sf, text, body })
}
return out
}
/** The body of the cordis module merge in `sf`: `declare module 'cordis'`
* (harness packages) or `declare module './context.ts'` (vendor core), or
* null when the file has neither. */
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness
* packages) or `declare module './context.ts'` (vendor core), in source order.
* Module-local: consumers walk blocks through {@link contextMergeFiles}. */
function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] {
const bodies: ts.ModuleBlock[] = []
for (const stmt of sf.statements) {
if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue
if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue
if (stmt.body && ts.isModuleBlock(stmt.body)) return stmt.body
if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body)
}
return null
return bodies
}
/** The FIRST cordis module-merge body in `sf`, or null without one — for the
* vendor core-API renderer whose input files carry exactly one merge; the
* exhaustiveness scan uses {@link cordisModuleBodies} to read them all. */
export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null {
return cordisModuleBodies(sf)[0] ?? null
}
/**
@@ -64,3 +77,26 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map<stri
}
return keyToType
}
/**
* Every event name a `declare module 'cordis'` Events merge declares in one
* module body. Names are the literal member keys (`'agent/created'`), read
* from method and property members alike so a declaration shape the projector
* would reject still enters the exhaustiveness scan.
* @param body - The cordis module augmentation block.
* @param sf - Owning source file (for computed-name text extraction).
* @returns Declared event names, in declaration order.
*/
export function eventNameList(body: ts.ModuleBlock, sf: ts.SourceFile): string[] {
const names: string[] = []
for (const stmt of body.statements) {
if (!ts.isInterfaceDeclaration(stmt) || stmt.name.text !== 'Events') continue
for (const member of stmt.members) {
if (!member.name) continue
names.push(ts.isStringLiteral(member.name) || ts.isIdentifier(member.name)
? member.name.text
: member.name.getText(sf))
}
}
return names
}

View File

@@ -0,0 +1,211 @@
/**
* Acceptance-path coverage for the cordis-surface partition backstops
* (`walkPartitionProblems` + the AST scan helpers): a declared Context key or
* Events member the rendering projection cannot see must carry a named walk
* exemption, an exemption must stay live in both directions, and the scan
* itself must reach nested (`src/**`) and Events-only merge files.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import ts from 'typescript'
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
import { walkPartitionProblems } from './gen-cordis-catalog.ts'
import type { WalkPartitionInput, WalkPartitionMaps } from './gen-cordis-catalog.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
/** A consistent baseline the red cases mutate one facet at a time. */
function baseline(): { input: WalkPartitionInput; maps: WalkPartitionMaps } {
return {
input: {
renderedKeys: new Map([['llm', 'packages/llm/llm/src/index.ts:10']]),
renderedScopes: new Set(['llm']),
renderedEventNames: new Set(['llm/request']),
declaredKeys: new Map([
['llm', 'packages/llm/llm/src/index.ts'],
['theme', 'packages/client/ui-theme/src/client/index.ts'],
]),
declaredEvents: new Map([
['llm/request', 'packages/llm/llm/src/index.ts'],
['theme/change', 'packages/client/ui-theme/src/client/index.ts'],
]),
},
maps: {
servicePage: { llm: 'llm-streaming.md' },
serviceWalkExemptions: { theme: 'client-side — packages/client/ui-theme/README.md owns the surface' },
eventScopePage: { llm: 'llm-streaming.md' },
eventWalkExemptions: { 'theme/change': 'client-face — packages/client/ui-theme/README.md owns the surface' },
},
}
}
describe('walkPartitionProblems', () => {
it('accepts a partition where every declared key and event is rendered or exempted', () => {
const { input, maps } = baseline()
expect(walkPartitionProblems(input, maps)).toEqual([])
})
it('rejects a declared event that is neither rendered nor exempted, naming its file', () => {
const { input, maps } = baseline()
const problems = walkPartitionProblems(input, { ...maps, eventWalkExemptions: {} })
expect(problems).toEqual([
expect.stringContaining("event 'theme/change' (packages/client/ui-theme/src/client/index.ts) is declared in an Events merge but invisible"),
])
})
it('rejects an event exemption whose event the projection renders', () => {
const { input, maps } = baseline()
// A projection that renders theme/change necessarily renders the theme
// scope too; the fixture models that and maps the scope so the only
// violation is the stale exemption.
const rendered = {
...input,
renderedScopes: new Set(['llm', 'theme']),
renderedEventNames: new Set(['llm/request', 'theme/change']),
}
const mapped = { ...maps, eventScopePage: { llm: 'llm-streaming.md', theme: 'client-modules.md' } }
expect(walkPartitionProblems(rendered, mapped)).toEqual([
expect.stringContaining("event 'theme/change' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS"),
])
})
it('rejects rendered surface the independent scan cannot see, naming the scan as the defect', () => {
const { input, maps } = baseline()
const blind = {
...input,
declaredKeys: new Map([['theme', 'packages/client/ui-theme/src/client/index.ts']]),
declaredEvents: new Map([['theme/change', 'packages/client/ui-theme/src/client/index.ts']]),
}
expect(walkPartitionProblems(blind, maps)).toEqual([
expect.stringContaining('ctx.llm is rendered by the projection but the independent scan finds no Context merge declaring it'),
expect.stringContaining("event 'llm/request' is rendered by the projection but the independent scan finds no Events merge declaring it"),
])
})
it('rejects an event exemption no Events merge declares', () => {
const { input, maps } = baseline()
const stale = { ...maps, eventWalkExemptions: { ...maps.eventWalkExemptions, 'gone/away': 'nothing owns this' } }
expect(walkPartitionProblems(input, stale)).toEqual([
expect.stringContaining("EVENT_WALK_EXEMPTIONS names 'gone/away' but no Events merge declares it"),
])
})
it('rejects a declared Context key that is neither rendered nor exempted', () => {
const { input, maps } = baseline()
const problems = walkPartitionProblems(input, { ...maps, serviceWalkExemptions: {} })
expect(problems).toEqual([
expect.stringContaining('ctx.theme (packages/client/ui-theme/src/client/index.ts) is declared in a Context merge but invisible'),
])
})
it('rejects an unmapped rendered service with its source pointer, and stale page maps both ways', () => {
const { input, maps } = baseline()
const problems = walkPartitionProblems(input, {
...maps,
servicePage: { ghost: 'core.md' },
eventScopePage: { specter: 'core.md' },
})
expect(problems).toEqual(expect.arrayContaining([
expect.stringContaining('service ctx.llm (packages/llm/llm/src/index.ts:10) has no SERVICE_PAGE entry'),
expect.stringContaining("event scope 'llm/*' has no EVENT_SCOPE_PAGE entry"),
expect.stringContaining("SERVICE_PAGE maps 'ctx.ghost' but the projection discovers no such service"),
expect.stringContaining("EVENT_SCOPE_PAGE maps 'specter/*' but the projection discovers no such scope"),
]))
expect(problems).toHaveLength(4)
})
})
describe('cordis-walk scan reach', () => {
it('finds Context keys and Events names in nested Events-only merge files', () => {
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
roots.push(root)
const dir = join(root, 'packages/client/ui-x/src/client')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'index.ts'), [
"declare module 'cordis' {",
' interface Events {',
" 'x/changed'(): void",
' }',
'}',
'export {}',
'',
].join('\n'))
const merges = contextMergeFiles(root, 'packages/*/*/src/**/*.ts')
expect(merges.map(m => m.rel)).toEqual(['packages/client/ui-x/src/client/index.ts'])
const only = merges[0]
if (!only) throw new Error('scan returned no merge')
expect(eventNameList(only.body, only.sf)).toEqual(['x/changed'])
expect([...contextKeyMap(only.body, only.sf).keys()]).toEqual([])
})
it('yields every merge block of a multi-block file, double-quoted heads, and .tsx sources', () => {
const root = mkdtempSync(join(tmpdir(), 'cordis-walk-'))
roots.push(root)
const dir = join(root, 'packages/client/ui-x/src')
mkdirSync(dir, { recursive: true })
// The Typert analyzer reads every cordis module block in a file; the
// backstop must not stop at the first one, skip the double-quoted legal
// form, or ignore .tsx sources.
writeFileSync(join(dir, 'split.ts'), [
"declare module 'cordis' {",
' interface Context {',
' first: FirstService',
' }',
'}',
'declare module "cordis" {',
' interface Events {',
" 'second/changed'(): void",
' }',
'}',
'export {}',
'',
].join('\n'))
writeFileSync(join(dir, 'view.tsx'), [
"declare module 'cordis' {",
' interface Context {',
' fromTsx: TsxService',
' }',
'}',
'export {}',
'',
].join('\n'))
const merges = contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])
expect(merges.map(m => m.rel)).toEqual([
'packages/client/ui-x/src/split.ts',
'packages/client/ui-x/src/split.ts',
'packages/client/ui-x/src/view.tsx',
])
const keys = merges.flatMap(m => [...contextKeyMap(m.body, m.sf).keys()])
const events = merges.flatMap(m => eventNameList(m.body, m.sf))
expect(keys).toEqual(['first', 'fromTsx'])
expect(events).toEqual(['second/changed'])
})
it('reads string-literal and identifier member names from an Events merge', () => {
const sf = ts.createSourceFile('x.ts', [
"declare module 'cordis' {",
' interface Events {',
" 'scope/list'(items: string[]): void",
' plain(): void',
' }',
' interface Context {',
' thing: ThingService',
' }',
'}',
'',
].join('\n'), ts.ScriptTarget.Latest, true)
const body = sf.statements[0] && ts.isModuleDeclaration(sf.statements[0]) && sf.statements[0].body
&& ts.isModuleBlock(sf.statements[0].body)
? sf.statements[0].body
: null
if (!body) throw new Error('fixture did not parse to a module block')
expect(eventNameList(body, sf)).toEqual(['scope/list', 'plain'])
expect([...contextKeyMap(body, sf)]).toEqual([['thing', 'ThingService']])
})
})

View File

@@ -21,7 +21,7 @@ import {
} from '@deepseek-ai/dsh-typert-generator'
import type { CordisCatalogPolicy } from '@deepseek-ai/dsh-typert-generator'
import { renderCordisCoreApiPages } from './cordis-core-api.ts'
import { contextKeyMap, contextMergeFiles } from './cordis-walk.ts'
import { contextKeyMap, contextMergeFiles, eventNameList } from './cordis-walk.ts'
import {
blobHash,
parsePairMeta,
@@ -44,6 +44,7 @@ export { REGION_BEGIN, REGION_END }
*/
export const SERVICE_PAGE: Record<string, string> = {
agentLoop: 'core.md',
agentDefaultModel: 'core.md',
agents: 'core.md',
approval: 'approval.md',
bash: 'bash.md',
@@ -97,10 +98,11 @@ export const SERVICE_PAGE: Record<string, string> = {
* Context keys declared in `interface Context` merges that the rendering
* projection cannot see, each with the reason and its documentation owner.
* The scan that enforces this list reads EVERY `declare module 'cordis'`
* Context merge under `packages/x/x/src/*.ts` — not only root `index.ts`
* files with a same-named service class — so a new service can never silently
* join this blind spot: it either enters {@link SERVICE_PAGE} or names itself
* here.
* Context merge under `packages/x/x/src/**` — any depth, not only root
* `index.ts` files with a same-named service class — so a new service can
* never silently join this blind spot: it either enters {@link SERVICE_PAGE}
* or names itself here. Client-face keys (the projection analyzes the host
* face only) name the package README that owns their surface.
* TODO(cordis-catalog-interface-services): the interface-typed and
* non-index-declared entries would all render once the projection resolves a
* Context key through its declaring file's imports to the class declaration.
@@ -116,14 +118,27 @@ export const SERVICE_WALK_EXEMPTIONS: Record<string, string> = {
apiProxy: 'interface-typed (ApiProxy) with the class in api-proxy.ts, not index.ts — packages/host/apiproxy/README.md owns the surface',
appShell: 'client-side interface-typed browser service — packages/client/web/README.md owns the surface',
connection: 'client-side interface-typed browser service — packages/client/connection/README.md owns the surface',
chatFileMentions: 'client-side slot-contract accessor (ChatFileMentions) — packages/client/ui-conversation/README.md owns the surface',
command: 'client-side interface-typed browser service — packages/client/ui-command/README.md owns the surface',
conversation: 'client-side interface-typed browser service — packages/client/ui-conversation/README.md owns the surface',
layout: 'client-side interface-typed browser service — packages/client/ui-layout/README.md owns the surface',
locale: 'client-side interface-typed browser service — packages/client/locale/README.md owns the surface',
models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface',
modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface',
remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface',
sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface',
slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface',
workspaces: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface',
}
/**
* The owning subsystems page for every harness event scope (the segment
* before the first `/`). Fail-closed exactly like {@link SERVICE_PAGE}.
* `slash` lives with the human-command surface: the client slash-input
* protocol parses toward command invocation and `dsh-ui-slash` owns the
* declarations, but commands.md owns the cross-package command story.
* before the first `/`) the projection renders. Fail-closed exactly like
* {@link SERVICE_PAGE}. Client-face events (`slash/*`, `theme/change`, …) are
* invisible to the host-face projection and therefore never reach this map;
* {@link EVENT_WALK_EXEMPTIONS} names each one with its documentation owner.
*/
export const EVENT_SCOPE_PAGE: Record<string, string> = {
'agent': 'core.md',
@@ -145,6 +160,32 @@ export const EVENT_SCOPE_PAGE: Record<string, string> = {
'workflow': 'workflow.md',
}
/**
* Event names declared in `interface Events` merges that the rendering
* projection cannot see, each with the reason and its documentation owner.
* The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent
* scan reads EVERY `declare module 'cordis'` Events merge under
* `packages/x/x/src/**`, so a declared event either renders onto a subsystems
* page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes
* silently. Keys are full event names, not scopes: client-face events share
* scopes with rendered host events (`commands/changed` beside `commands/*`),
* so a scope-level exemption would mask a host-face regression.
*/
export const EVENT_WALK_EXEMPTIONS: Record<string, string> = {
'commands/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
'connection/reset': 'client-face transport signal — packages/client/runtime/README.md owns the surface',
'credentials/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
'locale/change': 'client-face locale switch signal — packages/client/locale/README.md owns the surface',
'models/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
'settings/changed': 'client-face registry invalidation signal — packages/client/runtime/README.md owns the surface',
'slash/input-begin-command': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
'slash/input-consume-token': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
'slash/input-insert-reference': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
'slash/input-insert-text': 'client-face slash-input protocol — packages/client/ui-slash/README.md owns the surface',
'slots/changed': 'client-face slot invalidation signal — packages/client/runtime/README.md owns the surface',
'theme/change': 'client-face theme switch signal — packages/client/ui-theme/README.md owns the surface',
}
/**
* One primary subsystems page per project type used by a generated
* signature. This stays curated because union names intentionally do not
@@ -156,6 +197,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
AgentCancelCause: 'core.md',
AgentFactory: 'core.md',
AgentHandle: 'core.md',
ModelSelection: 'core.md',
AgentOptions: 'core.md',
AgentStatus: 'core.md',
ContentBlock: 'llm-streaming.md',
@@ -468,7 +510,7 @@ export const CORDIS_CATALOG_POLICY: CordisCatalogPolicy = {
],
inheritedServices: [
{ name: 'ctx.on / ctx.once', summary: 'Register an event listener (disposable).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / veto-chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.emit / ctx.parallel / ctx.serial / ctx.bail / ctx.waterfall', summary: 'Dispatch an event (sync / awaited / first-bail / short-circuit chain).', source: 'vendor/cordis/src/events.ts:34' },
{ name: 'ctx.plugin / ctx.inject', summary: 'Load a plugin / declare required services.', source: 'vendor/cordis/src/registry.ts:164' },
{ name: 'ctx.effect', summary: 'Register a disposable side effect tied to the fiber.', source: 'vendor/cordis/src/fiber.ts:9' },
{ name: 'ctx.get / ctx.set / ctx.provide / ctx.accessor / ctx.mixin', summary: 'Low-level service-store access and binding.', source: 'vendor/cordis/src/reflect.ts:7' },
@@ -506,56 +548,135 @@ export function spliceRegion(content: string, region: string): string {
return [...lines.slice(0, begin), ...region.split('\n'), ...lines.slice(end + 1)].join('\n')
}
/** The declared-vs-rendered inputs {@link walkPartitionProblems} judges. */
export interface WalkPartitionInput {
/** Service key → source pointer, as the rendering projection produced them. */
readonly renderedKeys: ReadonlyMap<string, string>
/** Event scopes the rendering projection produced. */
readonly renderedScopes: ReadonlySet<string>
/** Event names the rendering projection produced. */
readonly renderedEventNames: ReadonlySet<string>
/** Context key → first declaring file, from the independent AST scan. */
readonly declaredKeys: ReadonlyMap<string, string>
/** Event name → first declaring file, from the independent AST scan. */
readonly declaredEvents: ReadonlyMap<string, string>
}
/** The curated partition maps {@link walkPartitionProblems} enforces. */
export interface WalkPartitionMaps {
readonly servicePage: Readonly<Record<string, string>>
readonly serviceWalkExemptions: Readonly<Record<string, string>>
readonly eventScopePage: Readonly<Record<string, string>>
readonly eventWalkExemptions: Readonly<Record<string, string>>
}
/**
* Judge the rendered surface and the independent AST scan against the curated
* partition maps, fail-closed in both directions for services AND events: a
* rendered key/scope must be mapped to a page, a mapped key/scope must still
* render, and — the backstop — a DECLARED key/event the projection cannot see
* must carry a named walk exemption (a rendered one must not). A third
* direction guards the scan itself: everything rendered must also be declared
* to the scan, so a scan blind spot cannot decay silently. Pure so the
* acceptance paths are provable without running the projection.
* @param input - rendered surface plus the declared-key/event scans.
* @param maps - the curated page maps and walk exemptions.
* @returns one message per violation, empty when the partition holds.
*/
export function walkPartitionProblems(input: WalkPartitionInput, maps: WalkPartitionMaps): string[] {
const problems: string[] = []
for (const [key, source] of input.renderedKeys) {
if (!Object.hasOwn(maps.servicePage, key)) problems.push(`service ctx.${key} (${source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
}
for (const scope of [...input.renderedScopes].sort()) {
if (!Object.hasOwn(maps.eventScopePage, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
}
for (const key of Object.keys(maps.servicePage)) {
if (!input.renderedKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
}
for (const scope of Object.keys(maps.eventScopePage)) {
if (!input.renderedScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
}
// The rendering projection only sees a Context key it can resolve to a
// documented service class. The independent scan reads EVERY Context merge
// so a key the projection cannot render must either be rendered (mapped) or
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
for (const [key, rel] of input.declaredKeys) {
const rendered = input.renderedKeys.has(key)
const exempt = Object.hasOwn(maps.serviceWalkExemptions, key)
if (!rendered && !exempt) {
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
}
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
}
for (const key of Object.keys(maps.serviceWalkExemptions)) {
if (!input.declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
}
// The event mirror of the service backstop: the projection walks only files
// reachable from host-face package exports, so a client-face or unreachable
// Events merge would otherwise vanish without a trace.
for (const [name, rel] of input.declaredEvents) {
const rendered = input.renderedEventNames.has(name)
const exempt = Object.hasOwn(maps.eventWalkExemptions, name)
if (!rendered && !exempt) {
problems.push(`event '${name}' (${rel}) is declared in an Events merge but invisible to the rendering projection; make it renderable (mapped via EVENT_SCOPE_PAGE) or name it in EVENT_WALK_EXEMPTIONS with its documentation owner.`)
}
if (rendered && exempt) problems.push(`event '${name}' is rendered by the projection but still listed in EVENT_WALK_EXEMPTIONS; remove the stale exemption.`)
}
for (const name of Object.keys(maps.eventWalkExemptions)) {
if (!input.declaredEvents.has(name)) problems.push(`EVENT_WALK_EXEMPTIONS names '${name}' but no Events merge declares it; remove the stale exemption.`)
}
// Self-check the scan itself: everything the projection renders is declared
// in a Context/Events merge the scan must also reach, so a rendered key or
// event the scan cannot see means the SCAN regressed (glob, prefilter, or
// block walk) — a partial blind spot that exemption staleness alone would
// never surface.
for (const key of input.renderedKeys.keys()) {
if (!input.declaredKeys.has(key)) problems.push(`ctx.${key} is rendered by the projection but the independent scan finds no Context merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
}
for (const name of input.renderedEventNames) {
if (!input.declaredEvents.has(name)) problems.push(`event '${name}' is rendered by the projection but the independent scan finds no Events merge declaring it; the scan has a blind spot (glob, prefilter, or module-block walk) — fix the scan, not the maps.`)
}
return problems
}
/**
* Compute every generated artifact: the inherited-tier page, the model-facing
* runtime API module, plus, per mapped subsystems page, the pair's two updated
* documents with the injected region. Fail-loud partition checks live here: an
* unmapped service/event scope, a mapping whose page file does not exist, a
* curated entry whose key/scope the projection no longer discovers, and a
* mapped page missing its markers are all aggregated errors.
* curated entry whose key/scope the projection no longer discovers, a declared
* Context key or Events member the projection cannot see without a named walk
* exemption, and a mapped page missing its markers are all aggregated errors.
* @returns `[repo-relative path, exact content]` for every generated artifact.
*/
export function computeOutputs(): [string, string][] {
const { projector, model } = projectCordisCatalog(root, CORDIS_CATALOG_POLICY)
const services = [...model.services]
const events = [...model.events]
const problems: string[] = []
const discoveredKeys = new Set(services.map(s => s.key))
const discoveredScopes = new Set(events.map(e => e.scope))
for (const s of services) {
if (!Object.hasOwn(SERVICE_PAGE, s.key)) problems.push(`service ctx.${s.key} (${s.source}) has no SERVICE_PAGE entry; every service maps to exactly one subsystems page.`)
}
for (const scope of discoveredScopes) {
if (!Object.hasOwn(EVENT_SCOPE_PAGE, scope)) problems.push(`event scope '${scope}/*' has no EVENT_SCOPE_PAGE entry; every event scope maps to exactly one subsystems page.`)
}
for (const key of Object.keys(SERVICE_PAGE)) {
if (!discoveredKeys.has(key)) problems.push(`SERVICE_PAGE maps 'ctx.${key}' but the projection discovers no such service; remove the stale entry.`)
}
for (const scope of Object.keys(EVENT_SCOPE_PAGE)) {
if (!discoveredScopes.has(scope)) problems.push(`EVENT_SCOPE_PAGE maps '${scope}/*' but the projection discovers no such scope; remove the stale entry.`)
}
// The rendering projection only sees a Context key it can resolve to a
// documented service class. This independent scan reads EVERY Context merge
// so a key the projection cannot render must either be rendered (mapped) or
// carry a named SERVICE_WALK_EXEMPTIONS reason — never vanish silently.
const declaredKeys = new Map<string, string>()
for (const { rel, sf, body } of contextMergeFiles(root, 'packages/*/*/src/*.ts')) {
const declaredEvents = new Map<string, string>()
for (const { rel, sf, body } of contextMergeFiles(root, ['packages/*/*/src/**/*.ts', 'packages/*/*/src/**/*.tsx'])) {
for (const key of contextKeyMap(body, sf).keys()) {
if (!declaredKeys.has(key)) declaredKeys.set(key, rel)
}
}
for (const [key, rel] of declaredKeys) {
const rendered = discoveredKeys.has(key)
const exempt = Object.hasOwn(SERVICE_WALK_EXEMPTIONS, key)
if (!rendered && !exempt) {
problems.push(`ctx.${key} (${rel}) is declared in a Context merge but invisible to the rendering projection; map it in SERVICE_PAGE (after making it renderable) or name it in SERVICE_WALK_EXEMPTIONS with its documentation owner.`)
for (const name of eventNameList(body, sf)) {
if (!declaredEvents.has(name)) declaredEvents.set(name, rel)
}
if (rendered && exempt) problems.push(`ctx.${key} is rendered by the projection but still listed in SERVICE_WALK_EXEMPTIONS; remove the stale exemption.`)
}
for (const key of Object.keys(SERVICE_WALK_EXEMPTIONS)) {
if (!declaredKeys.has(key)) problems.push(`SERVICE_WALK_EXEMPTIONS names 'ctx.${key}' but no Context merge declares it; remove the stale exemption.`)
}
const problems = walkPartitionProblems({
renderedKeys: new Map(services.map(s => [s.key, s.source])),
renderedScopes: new Set(events.map(e => e.scope)),
renderedEventNames: new Set(events.map(e => e.name)),
declaredKeys,
declaredEvents,
}, {
servicePage: SERVICE_PAGE,
serviceWalkExemptions: SERVICE_WALK_EXEMPTIONS,
eventScopePage: EVENT_SCOPE_PAGE,
eventWalkExemptions: EVENT_WALK_EXEMPTIONS,
})
if (problems.length > 0) throw new Error(`gen-cordis-catalog: ${problems.length} partition violation(s):\n${problems.map(p => ` ${p}`).join('\n')}`)
const pages = [...new Set([...Object.values(SERVICE_PAGE), ...Object.values(EVENT_SCOPE_PAGE)])].sort()

View File

@@ -307,6 +307,14 @@ const SERVICE_ROLES: ServiceRole[] = [
consumers: ['agent-loop', 'acp', 'subagent-inprocess'],
note: 'Owns live Agent handles, the create/resume factory seam, and process-local initiator propagation.',
},
{
key: 'agentDefaultModel',
pkg: 'agent-default-model',
title: 'Default Agent model selection',
mode: 'core',
consumers: ['headless', 'host-apiproxy'],
note: 'Layers the default ModelSelection through settings so direct and Host-backed Agent front doors share one state owner.',
},
{
key: 'agentLoop',
pkg: 'agent-loop',
@@ -1154,7 +1162,7 @@ function renderLifecycle(): string {
const maintenance = 'curated Mermaid sequence; exact event signatures live in the generated Cordis catalog'
return [
...generatedHeader('Agent Turn And Step Lifecycle'),
'This sequence is the visual companion to [architecture.md](architecture.md#loop-lifecycle-session--turn--step). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
'This sequence is the visual companion to [architecture.md](architecture.md#default-loop-lifecycle). It keeps durable replay facts on `session/event` and live control/status on `agent/*`.',
'',
'```mermaid',
'sequenceDiagram',

View File

@@ -85,7 +85,7 @@ describe('Oxlint repository rule fingerprint', () => {
const overrides: readonly unknown[] = parsed.overrides
it('pins the complete override shape', () => {
expect(overrides).toHaveLength(6)
expect(overrides).toHaveLength(8)
})
it.each(Object.entries(profiles))('pins the %s rule profile', (_name, profile) => {

View File

@@ -1,14 +1,15 @@
import { spawnSync } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { existsSync } from 'node:fs'
import { mkdir, readFile, rm, writeFile } from 'node:fs/promises'
import { join, relative } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { fileURLToPath } from 'node:url'
import { flattenDiagnosticMessageText, parseConfigFileTextToJson } from 'typescript'
import { describe, expect, it } from 'vitest'
const repositoryRoot = fileURLToPath(new URL('..', import.meta.url))
const eslintCli = fileURLToPath(new URL('../node_modules/eslint/bin/eslint.js', import.meta.url))
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
const tsxCli = fileURLToPath(new URL('../node_modules/tsx/dist/cli.mjs', import.meta.url))
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === 'object' && value !== null && !Array.isArray(value)
@@ -18,11 +19,11 @@ function isUnknownArray(value: unknown): value is unknown[] {
return Array.isArray(value)
}
function runStagedFormatter(paths: readonly string[]) {
return spawnSync(process.execPath, [eslintCli, '--config', 'eslint.format.config.mjs', '--fix', '--no-warn-ignored', ...paths], {
function runRepositoryOxlint(args: readonly string[], env: NodeJS.ProcessEnv = {}) {
return spawnSync(process.execPath, [tsxCli, 'scripts/run-oxlint.ts', ...args], {
cwd: repositoryRoot,
encoding: 'utf8',
env: { ...process.env, NO_COLOR: '1' },
env: { ...process.env, NO_COLOR: '1', ...env },
})
}
@@ -150,7 +151,7 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
}
}, 20_000)
it('keeps formatter rules aligned with Oxlint validation', async () => {
it('keeps the complete stylistic contract in Oxlint', async () => {
const oxlintPath = join(repositoryRoot, '.oxlintrc.json')
const result = parseConfigFileTextToJson(oxlintPath, await readFile(oxlintPath, 'utf8'))
if (result.error !== undefined) {
@@ -160,27 +161,67 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
if (!isRecord(parsed) || !isUnknownArray(parsed.overrides)) {
throw new Error('.oxlintrc.json must contain an overrides array')
}
expect(parsed.ignorePatterns).toEqual(expect.arrayContaining([
'packages/typert/generator/tests/fixtures/type-model/**',
]))
const stylisticOverride = parsed.overrides.find((value: unknown) =>
isRecord(value) && isRecord(value.rules) && '@stylistic/max-len' in value.rules)
if (!isRecord(stylisticOverride) || !isRecord(stylisticOverride.rules)) {
throw new Error('.oxlintrc.json must contain the @stylistic validator override')
}
const validatorRules = { ...stylisticOverride.rules }
const maxLen = validatorRules['@stylistic/max-len']
delete validatorRules['@stylistic/max-len']
expect(stylisticOverride.rules).toMatchObject({
'@stylistic/indent': ['error', 2],
'@stylistic/semi': ['error', 'never'],
'@stylistic/quotes': ['error', 'single', { avoidEscape: true }],
'@stylistic/comma-dangle': ['error', 'always-multiline'],
'@stylistic/eol-last': ['error', 'always'],
'@stylistic/no-trailing-spaces': 'error',
'@stylistic/object-curly-spacing': ['error', 'always'],
'@stylistic/arrow-parens': ['error', 'as-needed', { requireForBlockBody: true }],
'@stylistic/member-delimiter-style': ['error', {
multiline: { delimiter: 'none' },
singleline: { delimiter: 'semi', requireLast: false },
}],
'@stylistic/max-len': ['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }],
})
const typeGraphOverride = parsed.overrides.find((value: unknown) =>
isRecord(value)
&& isUnknownArray(value.files)
&& value.files.includes('packages/typert/generator/tests/fixtures/type-model/packages/host/src/models.ts'))
expect(typeGraphOverride).toMatchObject({
rules: { '@stylistic/quotes': 'off' },
})
})
const formatterUrl = pathToFileURL(join(repositoryRoot, 'eslint.format.config.mjs')).href
const formatterModule = await import(formatterUrl) as unknown
if (!isRecord(formatterModule) || !isUnknownArray(formatterModule.default)) {
throw new Error('eslint.format.config.mjs must default-export a config array')
}
const formatterOverride = formatterModule.default.find((value: unknown) => isRecord(value) && isRecord(value.rules))
if (!isRecord(formatterOverride) || !isRecord(formatterOverride.rules)) {
throw new Error('eslint.format.config.mjs must contain a rules object')
it('checks preserved TypeGraph syntax without type-aware analysis', () => {
const result = runOxlint([
'--config',
'.oxlintrc.staged.json',
'packages/typert/generator/tests/fixtures/type-model',
])
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
})
it('keeps repository lint workflows Oxlint-only', async () => {
const packageJson = JSON.parse(await readFile(join(repositoryRoot, 'package.json'), 'utf8')) as unknown
if (!isRecord(packageJson) || !isRecord(packageJson.scripts) || !isRecord(packageJson.devDependencies)) {
throw new Error('package.json must contain scripts and devDependencies objects')
}
expect(validatorRules).toStrictEqual(formatterOverride.rules)
expect(maxLen).toStrictEqual(['error', { code: 140, ignoreUrls: true, ignoreStrings: true, ignoreTemplateLiterals: true }])
expect(packageJson.scripts['lint:contracts-ready']).toBe('tsx scripts/run-oxlint.ts .')
expect(packageJson.scripts['lint:fix:contracts-ready']).toBe(
'tsx scripts/run-oxlint.ts --config .oxlintrc.staged.json packages/typert/generator/tests/fixtures/type-model --fix && tsx scripts/run-oxlint.ts . --fix',
)
expect(packageJson.devDependencies).not.toHaveProperty('eslint')
expect(packageJson.devDependencies).not.toHaveProperty('@typescript-eslint/parser')
expect(existsSync(join(repositoryRoot, 'eslint.format.config.mjs'))).toBe(false)
const lefthook = await readFile(join(repositoryRoot, 'lefthook.yml'), 'utf8')
expect(lefthook).toContain('scripts/run-oxlint.ts --config .oxlintrc.staged.json --fix')
expect(lefthook).not.toContain('node_modules/.bin/eslint')
expect(lefthook).not.toContain('eslint.format.config.mjs')
})
it('reports an unused suppression', async () => {
@@ -227,10 +268,13 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
if (result.error !== undefined) {
throw new Error(flattenDiagnosticMessageText(result.error.messageText, '\n'))
}
expect(result.config).toMatchObject({
const stagedConfig = result.config as unknown
if (!isRecord(stagedConfig)) throw new Error('.oxlintrc.staged.json must contain a config object')
expect(stagedConfig).toMatchObject({
extends: ['./.oxlintrc.json'],
options: { typeAware: false },
})
expect(stagedConfig.ignorePatterns).not.toContain('packages/typert/generator/tests/fixtures/type-model/**')
const suffix = randomUUID()
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
@@ -254,30 +298,76 @@ export const longProbe = 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 + 1 +
}
})
it('applies staged stylistic fixes before Oxlint validation', async () => {
it('preserves successful fix output channels', async () => {
const suffix = randomUUID()
const configPath = await writeContractConfig(suffix)
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
const path = join(directory, 'fix.ts')
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
try {
await mkdir(directory, { recursive: true })
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
const relativePath = relative(repositoryRoot, path)
const formatResult = runStagedFormatter([relativePath])
const lintResult = runOxlint(['--config', relative(repositoryRoot, configPath), '--fix', relativePath])
expect(formatResult.error).toBeUndefined()
expect(formatResult.status, normalizedOutput(formatResult)).toBe(0)
expect(lintResult.error).toBeUndefined()
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
} finally {
await Promise.all([
rm(directory, { recursive: true, force: true }),
rm(configPath, { force: true }),
await writeFile(path, '// oxlint-disable-next-line no-console\nexport const value = 1\n')
const result = runRepositoryOxlint([
'--config',
'.oxlintrc.staged.json',
'--format',
'unix',
'--fix',
relative(repositoryRoot, path),
])
expect(result.error).toBeUndefined()
expect(result.status, normalizedOutput(result)).toBe(0)
expect(result.stdout).toContain('Unused oxlint-disable directive')
expect(result.stderr).toBe('')
} finally {
await rm(path, { force: true })
}
}, 20_000)
})
it('prints only the final diagnostics when a fix retry still fails', async () => {
const suffix = randomUUID()
const path = join(repositoryRoot, 'scripts', `staged-lint-probe-${suffix}.ts`)
try {
await writeFile(path, `export const longProbe = ${'1 + '.repeat(80)}1\n`)
const result = runRepositoryOxlint([
'--config',
'.oxlintrc.staged.json',
'--format',
'unix',
'--fix',
relative(repositoryRoot, path),
])
const output = normalizedOutput(result)
expect(result.error).toBeUndefined()
expect(result.status, output).toBe(1)
expect(output.match(/@stylistic\(max-len\)/g)).toHaveLength(1)
} finally {
await rm(path, { force: true })
}
})
it.each(['--fix', '--fix-suggestions', '--fix-dangerously'])(
'converges overlapping staged stylistic fixes through Oxlint under %s',
async (fixFlag) => {
const suffix = randomUUID()
const directory = join(repositoryRoot, 'scripts', `.oxlint-contract-${suffix}`)
const path = join(directory, 'fix.ts')
try {
await mkdir(directory, { recursive: true })
await writeFile(path, 'const value={answer:1}; \nconsole.log(value)\n')
const relativePath = relative(repositoryRoot, path)
const lintResult = runRepositoryOxlint(['--config', '.oxlintrc.staged.json', fixFlag, relativePath])
expect(lintResult.error).toBeUndefined()
expect(lintResult.status, normalizedOutput(lintResult)).toBe(0)
expect(normalizedOutput(lintResult)).not.toContain('@stylistic')
await expect(readFile(path, 'utf8')).resolves.toBe('const value={ answer:1 }\nconsole.log(value)\n')
} finally {
await rm(directory, { recursive: true, force: true })
}
},
20_000,
)
})

View File

@@ -299,22 +299,55 @@ describe('docsPages locale routes', () => {
})
it('publishes the Cordis core API under matching locale structures', () => {
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md', 'inherited.md']
const files = ['context.md', 'events.md', 'fiber.md', 'registry.md', 'service.md']
for (const file of files) {
const root = docsPages.find(page => page.route === `reference/cordis-api/${file}`)
const english = docsPages.find(page => page.route === `en/reference/cordis-api/${file}`)
expect(root?.source).toBe(`docs/cordis-api/${file}`)
expect(root?.source).toBe(`docs/cordis-api/${file.replace(/\.md$/, '.zh.md')}`)
expect(root?.contentLocale).toBe('zh-CN')
expect(root?.section).toBe('Cordis API')
expect(english?.source).toBe(root?.source)
expect(english?.source).toBe(`docs/cordis-api/${file}`)
expect(english?.contentLocale).toBe('en-US')
expect(english?.section).toBe('Cordis Core API')
}
})
it('includes persistence event headings in both locale outlines', () => {
const pages = docsPages.filter(page => page.source === 'docs/persistence-catalog.md')
it('keeps Cordis inherited on the English fallback in both locales', () => {
const pages = docsPages.filter(page => page.route.endsWith('reference/cordis-api/inherited.md'))
expect(pages).toHaveLength(2)
expect(pages.every(page => page.source === 'docs/cordis-api/inherited.md')).toBe(true)
expect(pages.every(page => page.contentLocale === 'en-US')).toBe(true)
})
it('includes persistence event headings in both locale outlines', () => {
const pages = docsPages.filter(page => page.route.endsWith('reference/persistence-catalog.md'))
expect(pages).toHaveLength(2)
expect(pages.map(page => page.source).sort()).toEqual([
'docs/persistence-catalog.md',
'docs/persistence-catalog.zh.md',
])
expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
})
it('projects reviewed generated counterparts into root locale routes', () => {
// module-graph, event-producer-consumer, and graph-atlas are paired but intentionally unpublished.
const routes = [
'reference/capability-seams.md',
'reference/agent-lifecycle.md',
'reference/tool-execution-pipeline.md',
'reference/config-catalog.md',
'reference/tool-catalog.md',
'reference/persistence-catalog.md',
'reference/cordis-api/context.md',
'reference/cordis-api/events.md',
'reference/cordis-api/fiber.md',
'reference/cordis-api/registry.md',
'reference/cordis-api/service.md',
]
const pages = routes.map(route => docsPages.find(page => page.route === route))
expect(pages.every(page => page?.contentLocale === 'zh-CN')).toBe(true)
expect(pages.every(page => page?.source.endsWith('.zh.md'))).toBe(true)
})
})
describe('addProjectionFrontmatter', () => {

View File

@@ -218,7 +218,7 @@ describe('Node 24 lane ownership', () => {
const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers'))
expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({
workers: 10,
workers: 11,
source: 'ci-consumers gate count',
})
expect(subject.map(item => item.id)).toEqual([
@@ -232,11 +232,19 @@ describe('Node 24 lane ownership', () => {
'doc-typecheck',
'node-next-types',
'built-bin-smoke',
'github-repository-plugin-e2e',
])
expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build'])
expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint'])
expect(subject.find(item => item.id === 'lint-and-duplication')?.needs).toEqual(['built-package-invariants'])
for (const id of ['snapshot', 'web-snapshot', 'doc-typecheck', 'node-next-types', 'built-bin-smoke']) {
for (const id of [
'snapshot',
'web-snapshot',
'doc-typecheck',
'node-next-types',
'built-bin-smoke',
'github-repository-plugin-e2e',
]) {
expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants'])
}
expect(subject.find(item => item.id === 'snapshot')?.env).toEqual({ DSH_EXAMPLE_MODE: 'lib' })
@@ -249,6 +257,16 @@ describe('Node 24 lane ownership', () => {
'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts',
]),
)
const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e')
expect(githubRepositoryPlugin).toMatchObject({
label: 'GitHub repository Plugin dsh run',
env: {
DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1',
},
})
expect(githubRepositoryPlugin?.args).toEqual(
expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']),
)
expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({
displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built',
env: { DSH_SNAPSHOT: 'replay' },

View File

@@ -406,6 +406,7 @@ function ciConsumerGates(): Gate[] {
needs: validatedBuild,
}),
builtBinSmokeGate(validatedBuild),
githubRepositoryPluginE2eGate(validatedBuild),
]
}
@@ -636,6 +637,20 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate {
})
}
function githubRepositoryPluginE2eGate(needs: string[]): Gate {
return pnpmExec('github-repository-plugin-e2e', [
'vitest',
'run',
'--config',
'vitest.e2e.config.ts',
'apps/cli/tests/github-repository-plugin.built.e2e.ts',
], {
label: 'GitHub repository Plugin dsh run',
needs,
env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' },
})
}
/**
* Reject a gate list whose graph cannot be executed unambiguously.
* @param gates - complete aggregate to validate.

View File

@@ -3,6 +3,12 @@ import { resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
const oxlintCli = fileURLToPath(new URL('../node_modules/oxlint/bin/oxlint', import.meta.url))
const MAX_CAPTURED_OUTPUT_BYTES = 64 * 1024 * 1024
const FIX_FLAGS = new Set(['--fix', '--fix-dangerously', '--fix-suggestions'])
function isFixInvocation(args: readonly string[]): boolean {
return args.some(arg => FIX_FLAGS.has(arg))
}
/** Complete Oxlint child-process arguments and environment. */
export interface OxlintInvocation {
@@ -32,14 +38,50 @@ export function resolveOxlintInvocation(args: readonly string[], env: NodeJS.Pro
}
}
function completeFrom(result: { readonly signal: NodeJS.Signals | null; readonly status: number | null }): void {
if (result.signal !== null) {
process.kill(process.pid, result.signal)
return
}
process.exitCode = result.status ?? 1
}
function main(): void {
const invocation = resolveOxlintInvocation(process.argv.slice(2), process.env)
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
if (!isFixInvocation(invocation.args)) {
const result = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
env: invocation.env,
stdio: 'inherit',
})
if (result.error !== undefined) throw result.error
completeFrom(result)
return
}
const first = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
encoding: 'utf8',
env: invocation.env,
maxBuffer: MAX_CAPTURED_OUTPUT_BYTES,
})
if (first.error !== undefined) throw first.error
if (first.signal !== null) {
completeFrom(first)
return
}
if (first.status === 0) {
process.stdout.write(first.stdout)
process.stderr.write(first.stderr)
process.exitCode = 0
return
}
// Overlapping JS-plugin fixes can expose one more fixable diagnostic after the first pass.
const second = spawnSync(process.execPath, [oxlintCli, ...invocation.args], {
env: invocation.env,
stdio: 'inherit',
})
if (result.error !== undefined) throw result.error
process.exitCode = result.status ?? 1
if (second.error !== undefined) throw second.error
completeFrom(second)
}
const entrypoint = process.argv[1]

File diff suppressed because one or more lines are too long

View File

@@ -119,6 +119,12 @@ const otherSource = baseSource.replace('Beta base.', 'Beta other.')
const otherZh = baseZh.replace('乙基础。', '乙对侧。')
const mergedSource = currentSource.replace('Beta base.', 'Beta other.')
const mergedZh = currentZh.replace('乙基础。', '乙对侧。')
const generatedBaseSource = '# Module graph\n\nAlpha base.\n\nBeta base.\n'
const generatedBaseZh = '# 模块图\n\n[English](module-graph.md) | 中文\n\n甲基础。\n\n乙基础。\n'
const generatedCurrentSource = generatedBaseSource.replace('Alpha base.', 'Alpha current.')
const generatedCurrentZh = generatedBaseZh.replace('甲基础。', '甲当前。')
const generatedOtherSource = generatedBaseSource.replace('Beta base.', 'Beta other.')
const generatedOtherZh = generatedBaseZh.replace('乙基础。', '乙对侧。')
const manualBaseSource = baseSource.replace('guide.zh.md', 'manual.zh.md')
const manualBaseZh = baseZh.replace('guide.md', 'manual.md')
const manualCurrentSource = manualBaseSource.replace('Alpha base.', 'Alpha current.')
@@ -257,6 +263,60 @@ describe('translation pairing merge composition', () => {
expect(result.zhHash).toBe(gitBlobHash(Buffer.from(mergedZh)))
})
it('merges a generated source without an English language switcher', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, generatedBaseZh)
const current = record(fixture.root, 'docs/module-graph.md', generatedCurrentSource, generatedCurrentZh)
const other = record(fixture.root, 'docs/module-graph.md', generatedOtherSource, generatedOtherZh)
const result = mergeTranslationPairingRecords(
fixture.root,
'docs/module-graph.i18n.yaml',
ancestor,
current,
other,
)
expect(result.sourceContent.toString('utf8')).toBe(
generatedCurrentSource.replace('Beta base.', 'Beta other.'),
)
expect(result.zhContent.toString('utf8')).toBe(generatedCurrentZh.replace('乙基础。', '乙对侧。'))
})
it('rejects an authored source without an English language switcher', () => {
const fixture = createFixture(false)
const source = baseSource.replace('English | [中文](guide.zh.md)\n\n', '')
const ancestor = record(fixture.root, 'docs/guide.md', source, baseZh)
const current = record(fixture.root, 'docs/guide.md', source, baseZh)
const other = record(fixture.root, 'docs/guide.md', source, baseZh)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/guide.i18n.yaml',
ancestor,
current,
other,
)).toThrow('docs/guide.md clean merge lost its language-switcher link to guide.zh.md')
})
it('rejects generated Chinese content without its English backlink', () => {
const fixture = createFixture(false)
const zh = generatedBaseZh.replace('[English](module-graph.md) | 中文\n\n', '')
const ancestor = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
const current = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
const other = record(fixture.root, 'docs/module-graph.md', generatedBaseSource, zh)
expect(() => mergeTranslationPairingRecords(
fixture.root,
'docs/module-graph.i18n.yaml',
ancestor,
current,
other,
)).toThrow(
'docs/module-graph.zh.md clean merge lost its language-switcher link to module-graph.md',
)
})
it('leaves owner-content conflicts for a human', () => {
const fixture = createFixture(false)
const ancestor = record(fixture.root, 'docs/guide.md', baseSource, baseZh)

View File

@@ -15,6 +15,7 @@ import {
linksTo,
isTranslationScopeFile,
parseTranslationMarkdown,
requiresSourceLanguageSwitcher,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
@@ -163,7 +164,7 @@ function loadRecordOwners(
function assertMergedPairStructure(paths: TranslationPairPaths, source: Buffer, zh: Buffer): void {
const sourceTree = parseTranslationMarkdown(source.toString('utf8'))
const zhTree = parseTranslationMarkdown(zh.toString('utf8'))
if (!linksTo(sourceTree, basename(paths.zh))) {
if (requiresSourceLanguageSwitcher(paths.source) && !linksTo(sourceTree, basename(paths.zh))) {
throw new Error(`${paths.source} clean merge lost its language-switcher link to ${basename(paths.zh)}`)
}
if (!linksTo(zhTree, basename(paths.source))) {

View File

@@ -4,18 +4,9 @@
".agents/notes/implemented/AGENTS.md",
".agents/notes/implemented/CLAUDE.md",
"docs/AGENTS.md",
"docs/agent-lifecycle.md",
"docs/capability-seams.md",
"docs/config-catalog.md",
"docs/cordis-api/",
"docs/event-producer-consumer.md",
"docs/graph-atlas.md",
"docs/cordis-api/inherited.md",
"docs/i18n/style-samples.md",
"docs/i18n/terminology.md",
"docs/i18n/translation-prompt.md",
"docs/module-graph.md",
"docs/persistence-catalog.md",
"docs/tool-catalog.md",
"docs/tool-execution-pipeline.md"
"docs/i18n/translation-prompt.md"
]
}

View File

@@ -19,6 +19,7 @@ import {
parseTranslationPairingCliArgs,
parseTranslationPairingManifest,
partitionGeneratedRegions,
requiresSourceLanguageSwitcher,
translationStructureDiff,
translationStructureSignature,
} from './translation-pairing.ts'
@@ -142,6 +143,16 @@ describe('translation pairing manifest', () => {
})
})
describe('translation pairing switchers', () => {
it('exempts only paired generated English sources from reciprocal switchers', () => {
expect(requiresSourceLanguageSwitcher('docs/config-catalog.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/cordis-api/context.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/cordis-api/inherited.md')).toBe(false)
expect(requiresSourceLanguageSwitcher('docs/architecture.md')).toBe(true)
expect(requiresSourceLanguageSwitcher('packages/core/session/README.md')).toBe(true)
})
})
describe('translation pairing records', () => {
const paths = translationPairPaths('docs/foo.md')
const record = {

View File

@@ -311,6 +311,28 @@ export function linksTo(tree: Nodes, target: string): boolean {
return found
}
/** Generated English sources cannot carry a switcher without making their generator stale. */
export function requiresSourceLanguageSwitcher(source: string): boolean {
return ![
'docs/agent-lifecycle.md',
'docs/capability-seams.md',
'docs/config-catalog.md',
'docs/cordis-api/context.md',
'docs/cordis-api/events.md',
'docs/cordis-api/fiber.md',
// Excluded from pairing, but kept here for generated-category completeness and direct spec coverage.
'docs/cordis-api/inherited.md',
'docs/cordis-api/registry.md',
'docs/cordis-api/service.md',
'docs/event-producer-consumer.md',
'docs/graph-atlas.md',
'docs/module-graph.md',
'docs/persistence-catalog.md',
'docs/tool-catalog.md',
'docs/tool-execution-pipeline.md',
].includes(source)
}
/** Collect the ordered structural signature, skipping one switcher target. */
export function translationStructureSignature(tree: Nodes, switcherTarget: string): TranslationStructureSignature {
const sig: TranslationStructureSignature = { headings: [], code: [], tables: [], lists: [], links: [] }

View File

@@ -0,0 +1,108 @@
/**
* Acceptance-path coverage for fragment validation in `verify-md-links`: a
* `#fragment` onto a Markdown target — same-file anchors included — must name
* a real heading slug or explicit `<a id>`, while non-Markdown fragments and
* external targets stay out of scope.
*/
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterEach, describe, expect, it } from 'vitest'
import { anchorCache, documentAnchors, findViolations, githubSlug } from './verify-md-links.ts'
const roots: string[] = []
afterEach(() => {
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true })
})
function layout(files: Record<string, string>): string {
const root = mkdtempSync(join(tmpdir(), 'md-links-'))
roots.push(root)
for (const [rel, content] of Object.entries(files)) {
mkdirSync(join(root, rel, '..'), { recursive: true })
writeFileSync(join(root, rel), content)
}
return root
}
function violationsIn(root: string, rel: string): { url: string; reason: string }[] {
return findViolations(join(root, rel), anchorCache(), root).map(({ url, reason }) => ({ url, reason }))
}
describe('documentAnchors', () => {
it('slugs rendered heading text, suffixes repeats, and reads explicit <a id> anchors', () => {
const anchors = documentAnchors([
'# My Doc',
'## Live `events` — mode!',
'## Repeat',
'## Repeat',
'<a id="hand-anchor"></a>',
'',
].join('\n'))
expect(anchors).toEqual(new Set(['my-doc', 'live-events--mode', 'repeat', 'repeat-1', 'hand-anchor']))
expect(githubSlug('Security and authority are non-goals')).toBe('security-and-authority-are-non-goals')
})
it('keeps underscores the way GitHub does', () => {
expect(githubSlug('Showcase: web_fetch')).toBe('showcase-web_fetch')
expect(documentAnchors('## Showcase: web_fetch\n')).toEqual(new Set(['showcase-web_fetch']))
})
it('slugs a heading containing a link from its rendered text', () => {
expect(documentAnchors('## [Install](setup.md)\n')).toEqual(new Set(['install']))
})
it('bumps repeat suffixes past occupied slugs, matching GitHub', () => {
const anchors = documentAnchors(['## Repeat', '## Repeat-1', '## Repeat', ''].join('\n'))
expect(anchors).toEqual(new Set(['repeat', 'repeat-1', 'repeat-2']))
})
it('ignores <a id> inside code fences, inline code, and HTML comments', () => {
const anchors = documentAnchors([
'# Doc',
'```md',
'<a id="fenced"></a>',
'```',
'Inline `<a id="inline"></a>` sample.',
'<!-- <a id="commented"></a> -->',
'<a id="real"></a>',
'',
].join('\n'))
expect(anchors).toEqual(new Set(['doc', 'real']))
})
})
describe('findViolations fragments', () => {
it('accepts resolving same-file and cross-file fragments, non-md fragments, and externals', () => {
const root = layout({
'a.md': '# A\n\n## Deferred work\n\n[self](#deferred-work) [b](b.md#part-two) [code](x.ts#L10) [ext](https://x.example/#frag)\n',
'b.md': '# B\n\n## Part two\n',
'x.ts': 'export {}\n',
})
expect(violationsIn(root, 'a.md')).toEqual([])
})
it('rejects a same-file fragment that names no heading or <a id>', () => {
const root = layout({ 'a.md': '# A\n\n[gone](#deferred-work)\n' })
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#deferred-work', reason: 'anchor' }])
})
it('rejects a case-variant fragment: element ids are case-sensitive', () => {
const root = layout({ 'a.md': '# A\n\n## Default Loop\n\n[case](#Default-Loop)\n' })
expect(violationsIn(root, 'a.md')).toEqual([{ url: '#Default-Loop', reason: 'anchor' }])
})
it('rejects a cross-file fragment missing from the target document', () => {
const root = layout({
'a.md': '# A\n\n[stale](b.md#old-heading)\n',
'b.md': '# B\n\n## New heading\n',
})
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'b.md#old-heading', reason: 'anchor' }])
})
it('still rejects a missing target file, reported as target not anchor', () => {
const root = layout({ 'a.md': '# A\n\n[ghost](missing.md#anything)\n' })
expect(violationsIn(root, 'a.md')).toEqual([{ url: 'missing.md#anything', reason: 'target' }])
})
})

View File

@@ -1,14 +1,16 @@
/**
* Verify that relative Markdown links, images, and definitions resolve. URL,
* root-absolute, and in-page targets are excluded; query strings and fragments
* do not affect resolution against the source file. The checker never rewrites,
* and symlinked instruction files are deduped.
* Verify that relative Markdown links, images, and definitions resolve — the
* target file must exist AND a `#fragment` onto a Markdown target (including
* a same-file `#anchor`) must name a real heading slug or explicit `<a id>`.
* URL and root-absolute targets are excluded; query strings do not affect
* resolution against the source file. The checker never rewrites, and
* symlinked instruction files are deduped.
*/
import { existsSync, readFileSync } from 'node:fs'
import { dirname, relative, resolve } from 'node:path'
import type { Nodes } from 'mdast'
import { parseMarkdown, visitMarkdown } from './markdown.ts'
import { markdownHeadingLines, parseMarkdown, visitMarkdown } from './markdown.ts'
import { isArchivedAgentNotePath, uniqueRepoFiles } from './repo-files.ts'
const root = resolve(import.meta.dirname, '..')
@@ -28,21 +30,22 @@ const PATTERNS = [
'skills/**/*.md',
]
/** A broken relative link: a target path that does not resolve to a file. */
/** A broken relative link: a missing target path or a missing anchor on it. */
interface Violation {
file: string
/** 1-based line where the link/image/definition node starts. */
line: number
url: string
/** What failed: the target file or the fragment onto it. */
reason: 'target' | 'anchor'
}
/**
* True for targets this gate must NOT check: scheme-qualified URLs (`https:`,
* `mailto:`, …), protocol-relative (`//host`), root-absolute (`/path`), and
* pure in-page anchors (`#frag`). Everything else is a relative path we own.
* `mailto:`, …), protocol-relative (`//host`), and root-absolute (`/path`).
* Pure in-page anchors (`#frag`) ARE checked, against the source file itself.
*/
function isExternalOrAnchor(url: string): boolean {
if (url.startsWith('#')) return true
function isExternal(url: string): boolean {
if (url.startsWith('//')) return true
if (url.startsWith('/')) return true
// A scheme like `https:` / `mailto:` — a colon before any slash, dot, or hash.
@@ -69,22 +72,119 @@ function pathPart(url: string): string {
}
}
/** Find every broken relative cross-link in one Markdown file via its AST. */
function findViolations(absPath: string): Violation[] {
const file = relative(root, absPath)
/** The percent-decoded `#fragment` of a link target, or null when it has none. */
function fragmentPart(url: string): string | null {
const hash = url.indexOf('#')
if (hash === -1) return null
const raw = url.slice(hash + 1).replace(/\?.*$/, '')
try {
return decodeURIComponent(raw)
} catch {
// Same stance as pathPart: a malformed escape names no anchor anyone
// meant, so the raw text flows into the lookup and is reported missing.
return raw
}
}
/**
* GitHub's heading-slug algorithm (lowercase; drop everything but letters,
* numbers, underscores, spaces, hyphens; spaces become hyphens). Underscores
* survive (`## Showcase: web_fetch` → `#showcase-web_fetch`), unlike
* `gen-cordis-catalog`'s region-anchor slugs — the generator's headings are
* always reachable through its explicit `<a id>` anchors, so the two need not
* share one rule.
* @param heading - the RENDERED heading text (Markdown syntax already gone).
* @returns the anchor GitHub assigns the first occurrence of the heading.
*/
export function githubSlug(heading: string): string {
return heading.toLowerCase().replace(/[^\p{L}\p{N}_ -]/gu, '').replaceAll(' ', '-')
}
/**
* Every anchor one Markdown document exposes: each heading's GitHub slug —
* computed from the RENDERED heading text, so links, images, inline code, and
* emphasis inside a heading slug the way GitHub renders them — plus every
* explicit `<a id="…">` that appears in real HTML flow (a fenced or inline
* code sample and a commented-out anchor register nothing). Repeated slugs
* get GitHub's occupied-set `-1`, `-2`, … suffixes: each collision bumps the
* ORIGINAL slug's counter until a free name is found, so `Repeat`, `Repeat-1`,
* `Repeat` yields `repeat`, `repeat-1`, `repeat-2`. Matching is exact —
* element ids are case-sensitive.
* @param source - the document's full Markdown text.
* @returns the set of valid fragments for links into this document.
*/
export function documentAnchors(source: string): Set<string> {
const anchors = new Set<string>()
const occurrences = new Map<string, number>()
for (const heading of markdownHeadingLines(source)) {
const base = githubSlug(heading.text)
let result = base
let bump = occurrences.get(base) ?? 0
while (anchors.has(result)) {
bump += 1
result = `${base}-${bump}`
}
occurrences.set(base, bump)
anchors.add(result)
}
visitMarkdown(parseMarkdown(source), (node: Nodes): void => {
if (node.type !== 'html') return
const html = node.value.replace(/<!--[\s\S]*?-->/g, '')
for (const match of html.matchAll(/<a id="([^"]+)"/g)) anchors.add(match[1] ?? '')
})
return anchors
}
/**
* Lazily collect and cache the anchor set of any existing Markdown file —
* shared across all scanned sources so a target parses once.
* @returns the memoized absolute-path → anchor-set lookup.
*/
export function anchorCache(): (absPath: string) => Set<string> {
const cache = new Map<string, Set<string>>()
return (absPath) => {
const hit = cache.get(absPath)
if (hit) return hit
const anchors = documentAnchors(readFileSync(absPath, 'utf8'))
cache.set(absPath, anchors)
return anchors
}
}
/**
* Find every broken relative cross-link in one Markdown file via its AST: a
* relative target that does not exist, or a fragment onto a Markdown file
* (same-file `#anchor` links included) that names no heading slug or explicit
* `<a id>` there. Fragments onto non-Markdown targets (`file.ts#L10`) carry
* renderer-owned semantics and are not judged.
* @param absPath - absolute path of the Markdown source to scan.
* @param anchorsOf - anchor lookup shared across files for cross-link checks.
* @param scanRoot - repository root violations are reported relative to.
* @returns one entry per broken link, in document order.
*/
export function findViolations(
absPath: string,
anchorsOf: (abs: string) => Set<string>,
scanRoot: string = root,
): Violation[] {
const file = relative(scanRoot, absPath)
const dir = dirname(absPath)
const source = readFileSync(absPath, 'utf8')
const tree = parseMarkdown(source)
const out: Violation[] = []
const check = (url: string, node: Nodes): void => {
if (isExternalOrAnchor(url)) return
if (isExternal(url)) return
const target = pathPart(url)
// A bare `#anchor` reduced to empty path is a same-file anchor — skip.
if (target === '') return
const resolved = resolve(dir, target)
const resolved = target === '' ? absPath : resolve(dir, target)
if (!existsSync(resolved)) {
out.push({ file, line: node.position?.start.line ?? 0, url })
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'target' })
return
}
const fragment = fragmentPart(url)
if (fragment === null || !resolved.endsWith('.md')) return
if (!anchorsOf(resolved).has(fragment)) {
out.push({ file, line: node.position?.start.line ?? 0, url, reason: 'anchor' })
}
}
@@ -96,18 +196,22 @@ function findViolations(absPath: string): Violation[] {
return out
}
// Archived notes remain valid link targets, but their historical outbound links are frozen.
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
const all = files.flatMap(file => findViolations(file.abs))
const checked = files.length
// Run only when invoked as a script, not when imported by the spec.
if (process.argv[1] && import.meta.filename === resolve(process.argv[1])) {
// Archived notes remain valid link targets, but their historical outbound links are frozen.
const files = uniqueRepoFiles(root, PATTERNS, isArchivedAgentNotePath)
const anchorsOf = anchorCache()
const all = files.flatMap(file => findViolations(file.abs, anchorsOf))
const checked = files.length
if (all.length === 0) {
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links resolve.`)
process.exit(0)
}
if (all.length === 0) {
console.log(`verify-md-links: ${checked} file(s) checked, all relative cross-links and fragments resolve.`)
process.exit(0)
}
console.error('verify-md-links: broken relative cross-links found (target does not exist):')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.url}`)
console.error('verify-md-links: broken relative cross-links found:')
for (const v of all) {
console.error(` ${v.file}:${v.line} ${v.url} (${v.reason === 'target' ? 'target does not exist' : 'no such anchor in target'})`)
}
process.exit(1)
}
process.exit(1)

View File

@@ -48,6 +48,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/bash/pwsh-local': { kind: 'indirect', reason: 'The executor backend delegates model rendering to dsh-tool-pwsh.' },
'packages/code-runtime/code-runtime': { kind: 'indirect', reason: 'The service interface delegates model rendering to Code Mode in dsh-tools.' },
'packages/code-runtime/code-runtime-worker': { kind: 'indirect', reason: 'The worker backend delegates model rendering to Code Mode in dsh-tools.' },
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
'packages/typert/registry': { kind: 'none', reason: 'Runtime type registry; consumers (cordis_inspect, wire faces, gates) own any model-visible projection of registry contents.' },
'packages/typert/loader': { kind: 'none', reason: 'Loader integration only registers generated artifacts; consumers own any model-visible projection.' },
'packages/e2b/e2b': { kind: 'none', reason: 'The shared remote-runtime owner registers no model context; provider adapters and consumers own rendered effects.' },
@@ -68,7 +69,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' },
'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' },
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the host snapshots the target at the next prompt-assembly boundary and owns the model-visible effect.' },
'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' },
'packages/client/ui-goal': { kind: 'indirect', reason: 'The strip verbs route goal.* mutations; the host GoalService owns the model-visible goal/change context message.' },
'packages/client/ui-permission': { kind: 'indirect', reason: 'The picker submits the host /permission command; the knob events it appends own the model-visible effect through the sandbox/approval consumers.' },
'packages/client/ui-plan': { kind: 'indirect', reason: 'The chip dispatches /plan off; dsh-plan-mode owns the model-visible policy, exit tool, and logged state.' },
@@ -94,7 +95,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
'packages/host/webserver': { kind: 'none', reason: 'The HTTP carrier bridges browser and API handler and registers no model surface.' },
'packages/host/frontend-static': { kind: 'none', reason: 'The SPA dist server answers browser asset requests and registers no model surface.' },
'packages/bundle/base': { kind: 'indirect', reason: 'The bundle is a patch-list carrier; each inserted row\'s package owns its model surface.' },
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base/web bundles.' },
'packages/bundle/headless': { kind: 'none', reason: 'The one-shot runner submits the task as an ordinary user message; prompts and tools belong to the composed base and headless bundles.' },
'packages/llm/llm': { kind: 'none', reason: 'The adapter registry forwards already-assembled requests unchanged.' },
'packages/llm/token-meter': { kind: 'indirect', reason: 'The measurement service leaves model-visible changes to its consumers.' },
'packages/lsp/lsp': { kind: 'indirect', reason: 'The provider registry delegates model rendering to dsh-tool-lsp.' },

View File

@@ -24,6 +24,7 @@ import {
parseTranslationPairingCliArgs,
parseTranslationPairingManifest,
partitionGeneratedRegions,
requiresSourceLanguageSwitcher,
isTranslationScopeFile,
TRANSLATION_SCOPE_GLOB_EXCLUDES,
translationStructureDiff,
@@ -254,7 +255,7 @@ for (const source of [...pairAnchors].sort()) {
if (!linksTo(zhTree, basename(source))) {
errors.push(`${zh}: missing language switcher — no link to ${basename(source)}`)
}
if (!linksTo(sourceTree, basename(zh))) {
if (requiresSourceLanguageSwitcher(source) && !linksTo(sourceTree, basename(zh))) {
errors.push(`${source}: missing language switcher — no link back to ${basename(zh)}`)
}
for (const divergence of translationStructureDiff(