Merge remote-tracking branch 'origin/stack/agent-profiles-3-wire' into stack/agent-profiles-5-web-ui
# Conflicts: # docs/architecture.i18n.yaml # docs/architecture.zh.md # docs/module-graph.md # packages/host/apiproxy/src/api-proxy.ts # scripts/verify-package-readme-model-experience.ts
This commit is contained in:
@@ -44,6 +44,7 @@ export { REGION_BEGIN, REGION_END }
|
||||
*/
|
||||
export const SERVICE_PAGE: Record<string, string> = {
|
||||
agentLoop: 'core.md',
|
||||
agentDefaultModel: 'core.md',
|
||||
agentPresets: 'core.md',
|
||||
agents: 'core.md',
|
||||
approval: 'approval.md',
|
||||
@@ -197,6 +198,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',
|
||||
@@ -261,6 +263,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
FsEditOutcome: 'filesystem.md',
|
||||
FsEditRequest: 'filesystem.md',
|
||||
FsInfo: 'filesystem.md',
|
||||
FsObservation: 'filesystem.md',
|
||||
FsPathInfo: 'filesystem.md',
|
||||
FsPolicyExec: 'filesystem.md',
|
||||
FsTarget: 'filesystem.md',
|
||||
@@ -511,7 +514,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' },
|
||||
|
||||
@@ -314,6 +314,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',
|
||||
@@ -1229,7 +1237,7 @@ function renderLifecycle(): string {
|
||||
` Driver-->>SDK: ${mermaidCode('agent/status')} idle`,
|
||||
'```',
|
||||
'',
|
||||
'The `assistant/message` edge records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history while the durable anchor retains usage and exact chunk provenance, including an explicit empty source set.',
|
||||
'The `assistant/message` event records every successful provider call, including content-less and `max-tokens` finishes. Empty content stays out of derived history, while the durable event keeps usage and `sourceEventSeqs` listing the exact `assistant/chunk` events, including an explicit empty list.',
|
||||
'',
|
||||
'`dsh-compact-basic` uses `agent/pre-step` for pressure before request derivation and `agent/request-error` only for canonical context overflow. Once either trigger qualifies, optional tool-result pruning runs before summary selection. Recovery works between the closed failed step and failed turn close, and opens a fresh retry turn only when pruning or summarization advances the surface replacement generation; otherwise the original request error remains authoritative.',
|
||||
'',
|
||||
|
||||
@@ -253,7 +253,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-str-replace-editor',
|
||||
source: 'packages/fs/tool-str-replace-editor/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs'],
|
||||
writes: ['tool/call', 'fs/observed after successful file operations', 'tool/result'],
|
||||
writes: ['tool/call', 'fs/observed after view presence/absence, edit absence, or successful mutation', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
await ctx.plugin(LocalFileSystem)
|
||||
await ctx.plugin(ToolStrReplaceEditor)
|
||||
@@ -266,7 +266,7 @@ const TOOL_PACKAGES: ToolPackage[] = [
|
||||
dir: 'tool-fs',
|
||||
source: 'packages/fs/tool-fs/src/index.ts',
|
||||
requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after successful file operations', 'tool/result'],
|
||||
writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'],
|
||||
async mount(ctx) {
|
||||
// The tool needs `fs`; the bare provider is sufficient because policy
|
||||
// changes behavior, not schema shape.
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
})
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))) {
|
||||
|
||||
@@ -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"
|
||||
]
|
||||
}
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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: [] }
|
||||
|
||||
@@ -960,6 +960,11 @@
|
||||
"symbol": "FsVersion",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/filesystem.md",
|
||||
"symbol": "FsObservation",
|
||||
"source": "packages/fs/fs/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/subsystems/filesystem.md",
|
||||
"symbol": "FsInfo",
|
||||
|
||||
@@ -49,6 +49,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'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/client/ui-agent-preset': { kind: 'indirect', reason: 'Browser-side settings row; the preset it selects owns every model-facing effect.' },
|
||||
'packages/core/agent-default-model': { kind: 'indirect', reason: 'The service supplies a ModelSelection; request assembly and adapters own the model-visible request.' },
|
||||
'packages/preset/agent-presets': { kind: 'indirect', reason: 'The mount installs a preset\'s own plugins, which own every model-facing registration it makes visible.' },
|
||||
'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.' },
|
||||
@@ -70,7 +71,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.' },
|
||||
@@ -96,7 +97,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.' },
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user