Merge remote-tracking branch 'origin/master' into xtr/react-loop-simplification
# Conflicts: # .agents/notes/implemented/architecture/2026-06-21-bounded-llm-request-recovery.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.i18n.yaml # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.md # .agents/notes/implemented/feature/2026-06-18-compaction-capability-seam.zh.md # .agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml # .agents/notes/implemented/feature/2026-07-06-sandbox.md # .agents/notes/implemented/feature/2026-07-06-sandbox.zh.md # .agents/notes/implemented/feature/2026-07-27-tmux-location-context.i18n.yaml # .agents/notes/implemented/simplification/2026-06-20-public-agent-stop-surface.i18n.yaml # .agents/notes/implemented/simplification/2026-07-28-remove-synthetic-log-only-turns.i18n.yaml # .agents/notes/implemented/simplification/2026-07-30-private-agent-send.i18n.yaml # docs/architecture.i18n.yaml # docs/architecture.md # docs/architecture.zh.md # docs/config-catalog.md # docs/cordis-catalog/events.md # docs/cordis-catalog/services.md # docs/core-data-structures/compaction.i18n.yaml # docs/core-data-structures/core.i18n.yaml # docs/core-data-structures/core.md # docs/core-data-structures/core.zh.md # docs/core-data-structures/llm-streaming.i18n.yaml # docs/core-data-structures/llm-streaming.md # docs/core-data-structures/llm-streaming.zh.md # docs/core-data-structures/session.i18n.yaml # docs/event-producer-consumer.md # docs/module-graph.md # docs/persistence-catalog.md # examples/acp-agent/tests/goal-snapshots/goal-session/session.expected.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/acp-agent/tests/snapshots/bash-spill/session.jsonl # examples/acp-agent/tests/snapshots/bash-tool-turn/session.jsonl # examples/acp-agent/tests/snapshots/both-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/cancel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/cancel/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-turn/session.jsonl # examples/acp-agent/tests/snapshots/code-mode-workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl # examples/acp-agent/tests/snapshots/empty-response-retry/session.jsonl # examples/acp-agent/tests/snapshots/error-finish/session.jsonl # examples/acp-agent/tests/snapshots/escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/escalation-rejected/session.jsonl # examples/acp-agent/tests/snapshots/fs-edit/session.jsonl # examples/acp-agent/tests/snapshots/fs-escalation-approved/session.jsonl # examples/acp-agent/tests/snapshots/fs-glob-sampling/session.jsonl # examples/acp-agent/tests/snapshots/fs-policy-reject/session.jsonl # examples/acp-agent/tests/snapshots/fs-read-window/session.jsonl # examples/acp-agent/tests/snapshots/fs-read/session.jsonl # examples/acp-agent/tests/snapshots/fs-write-overwrite/session.jsonl # examples/acp-agent/tests/snapshots/fs-write/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-ask/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-pretool-deny/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-cc-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-invalid-matcher/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-posttool-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-pretool-block/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-promptsubmit-context/session.jsonl # examples/acp-agent/tests/snapshots/hook-codex-stop-continue/session.jsonl # examples/acp-agent/tests/snapshots/lsp-definition/session.jsonl # examples/acp-agent/tests/snapshots/multi-turn/session.jsonl # examples/acp-agent/tests/snapshots/packed-chunks/session.jsonl # examples/acp-agent/tests/snapshots/parallel-tool-calls/session.jsonl # examples/acp-agent/tests/snapshots/pty-tools/session.jsonl # examples/acp-agent/tests/snapshots/repeat-tool-guard/session.jsonl # examples/acp-agent/tests/snapshots/session-query-spill/session.jsonl # examples/acp-agent/tests/snapshots/session-sandbox-root/session.jsonl # examples/acp-agent/tests/snapshots/session-title-after-turn/session.jsonl # examples/acp-agent/tests/snapshots/skill-load/session.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-fork/session.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl # examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/acp-agent/tests/snapshots/text-turn/session.jsonl # examples/acp-agent/tests/snapshots/todo-write/session.jsonl # examples/acp-agent/tests/snapshots/tool-call-turn/session.jsonl # examples/acp-agent/tests/snapshots/web-fetch/session.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl # examples/acp-agent/tests/snapshots/workflow-run/session.jsonl # examples/acp-agent/tests/snapshots/workspace-context/session.jsonl # examples/acp-agent/tests/snapshots/workspace-edit/session.jsonl # examples/headless-agent/tests/semantic-checkpoint-snapshots/tool-outcome-unknown/session.expected.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl # examples/headless-agent/tests/snapshots/advanced-toolchain/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/goal-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/missing-credential/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/provider-retry/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/pty-tools/session.jsonl # examples/headless-agent/tests/snapshots/pty-tools/stream-json.expected.jsonl # examples/headless-agent/tests/snapshots/ralph-loop/stream-json.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl # examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/parent.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/bash-tool/session.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/persistent-tools/session.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl # examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/notifications.expected.jsonl # examples/jsonrpc-agent/tests/snapshots/text-turn/session.jsonl # packages/client/runtime/README.i18n.yaml # packages/client/runtime/src/client/sessions/request-inspection.ts # packages/compact/compact-basic/README.i18n.yaml # packages/compact/compact-basic/README.md # packages/compact/compact-basic/README.zh.md # packages/compact/compact-basic/src/index.ts # packages/context/time-context/tests/time-context.spec.ts # packages/context/tmux-context/README.i18n.yaml # packages/context/tmux-context/tests/tmux-context.spec.ts # packages/context/workspace-context/tests/workspace-context.spec.ts # packages/cordis/tool-cordis/src/api-catalog.ts # packages/core/agent-loop/README.i18n.yaml # packages/core/agent-loop/README.md # packages/core/agent-loop/README.zh.md # packages/core/agent-loop/src/agent.ts # packages/core/agent/README.i18n.yaml # packages/core/agent/README.md # packages/core/agent/README.zh.md # packages/core/agent/src/types.ts # packages/core/session/README.i18n.yaml # packages/core/session/README.md # packages/core/session/README.zh.md # packages/fs/tool-str-replace-editor/tests/tools.spec.ts # packages/goal/command-goal/tests/command-goal.spec.ts # packages/goal/goal/tests/goal.spec.ts # packages/host/apiproxy/README.i18n.yaml # packages/host/apiproxy/README.md # packages/host/apiproxy/README.zh.md # packages/host/apiproxy/src/api/index.ts # packages/host/apiproxy/tests/api-proxy-workspace.spec.ts # packages/llm/llm/README.i18n.yaml # packages/llm/llm/README.md # packages/llm/llm/README.zh.md # packages/llm/llm/src/index.ts # packages/pty/pty-local/tests/index.spec.ts # packages/pty/pty-local/tests/local.spec.ts # packages/pty/pty/tests/service.spec.ts # packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts # packages/pty/tool-bash-persistent/tests/tools.spec.ts # packages/pty/tool-pty/tests/loader-composition.spec.ts # packages/pty/tool-pty/tests/tools.spec.ts # packages/session-persistence/session-checkpoint-policy/tests/crash-recovery.e2e.ts # packages/skill/tool-skill/tests/tool-skill.spec.ts # packages/tasks/tasks-local/tests/tasks.spec.ts # packages/ui/tui/README.i18n.yaml # packages/ui/tui/tests/tui.spec.ts # packages/ui/user-approval/src/index.ts # packages/ui/user-approval/tests/approval.spec.ts
This commit is contained in:
@@ -164,6 +164,7 @@ export const LINK_MAP: Readonly<Record<string, string>> = {
|
||||
SubagentService: 'subagent.md',
|
||||
SubagentStartRequest: 'subagent.md',
|
||||
AssembleContext: 'system-prompt.md',
|
||||
PromptContext: 'system-prompt.md',
|
||||
PromptSection: 'system-prompt.md',
|
||||
SystemPrompt: 'system-prompt.md',
|
||||
ToolProviderResult: 'system-prompt.md',
|
||||
@@ -236,6 +237,7 @@ export const TYPE_LINK_EXEMPTIONS: Readonly<Record<string, string>> = {
|
||||
BashEnvContributor: 'service-local extension type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
BashEnvVariableInfo: 'service-local metadata type is owned by packages/bash/tool-bash/src/index.ts',
|
||||
CompactAgentContext: 'compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
ManualCompactAgentContext: 'manual compaction service input is owned by packages/compact/compact/src/index.ts',
|
||||
DirectoryPickerCapability: 'picker interaction contract is owned by packages/host/directory-picker/README.md',
|
||||
CreateAgentOptions: 'agent creation contract is owned by packages/core/agent/README.md',
|
||||
Domain: 'domain interface is owned by packages/storage/storage-domain/README.md',
|
||||
|
||||
208
scripts/gen-third-party-notices.spec.ts
Normal file
208
scripts/gen-third-party-notices.spec.ts
Normal file
@@ -0,0 +1,208 @@
|
||||
import { readdirSync, readFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { collectPythonDependencies, isPermissive, type Manifest, manifestPatterns, parsePyprojectRequirements, parseVendoredRows, render, tierExternalDeps } from './gen-third-party-notices.ts'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
|
||||
describe('THIRD_PARTY_NOTICES.md', () => {
|
||||
// Freshness lives here rather than in its own doc-sync gate: this spec file
|
||||
// already runs in the test lane, so the check costs no extra CI process.
|
||||
// Pre-commit regenerates the file whenever a manifest is staged, so reaching
|
||||
// this assertion means the notices were committed without that hook.
|
||||
it('matches what the generator produces from the current manifests', () => {
|
||||
expect(readFileSync(resolve(root, 'THIRD_PARTY_NOTICES.md'), 'utf8'), 'stale notices — run `pnpm run gen-third-party-notices`').toBe(render())
|
||||
})
|
||||
})
|
||||
|
||||
/** Build the (manifests, names) pair `tierExternalDeps` consumes. */
|
||||
function workspace(entries: Record<string, Manifest>): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const manifests = new Map(Object.entries(entries))
|
||||
const names = new Set<string>()
|
||||
for (const manifest of manifests.values()) {
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
return { manifests, names }
|
||||
}
|
||||
|
||||
describe('tierExternalDeps', () => {
|
||||
it('tiers by declaring area, not by the declaring section name', () => {
|
||||
const { manifests, names } = workspace({
|
||||
// Root tooling and test infrastructure never ship, whichever section declares them.
|
||||
'package.json': { dependencies: { 'root-runtime-looking': '^1' }, devDependencies: { 'lint-tool': '^1' } },
|
||||
'packages/support/loader-smoke/package.json': { name: '@deepseek-ai/dsh-loader-smoke', dependencies: { 'smoke-helper': '^1' } },
|
||||
'packages/client/test-runtime/package.json': { name: '@deepseek-ai/dsh-client-test-runtime', dependencies: { 'test-lib': '^1' } },
|
||||
'website/package.json': { devDependencies: { 'site-tool': '^1' } },
|
||||
// A plugin package's runtime dependency ships even when no app mounts it by default.
|
||||
'packages/mcp/mcp-client/package.json': { name: '@deepseek-ai/dsh-mcp-client', dependencies: { 'protocol-sdk': '^1' }, devDependencies: { 'protocol-fixture-server': '^1' } },
|
||||
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli', dependencies: { 'cli-lib': '^1', '@deepseek-ai/dsh-mcp-client': 'workspace:^' } },
|
||||
})
|
||||
|
||||
expect(tierExternalDeps(manifests, names)).toEqual(new Map([
|
||||
['tsx', true],
|
||||
['root-runtime-looking', false],
|
||||
['lint-tool', false],
|
||||
['smoke-helper', false],
|
||||
['test-lib', false],
|
||||
['site-tool', false],
|
||||
['protocol-sdk', true],
|
||||
['protocol-fixture-server', false],
|
||||
['cli-lib', true],
|
||||
]))
|
||||
})
|
||||
|
||||
it('keeps a package runtime when any shipping area declares it, and excludes workspace links', () => {
|
||||
const { manifests, names } = workspace({
|
||||
'package.json': { devDependencies: { shared: '^1' } },
|
||||
'packages/ui/tui/package.json': { name: '@deepseek-ai/dsh-tui', dependencies: { shared: '^1', '@deepseek-ai/dsh-cli': 'workspace:^' } },
|
||||
'apps/cli/package.json': { name: '@deepseek-ai/dsh-cli' },
|
||||
})
|
||||
|
||||
expect(tierExternalDeps(manifests, names).get('shared')).toBe(true)
|
||||
expect(tierExternalDeps(manifests, names).has('@deepseek-ai/dsh-cli')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('parseVendoredRows', () => {
|
||||
it('reads the committed vendor manifest table', () => {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
|
||||
expect(rows.length).toBeGreaterThan(0)
|
||||
expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' })
|
||||
// The upstream column carries a trailing package path for some rows; it is not part of the URL.
|
||||
expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true)
|
||||
})
|
||||
|
||||
it('yields nothing when the table shape changes, so the generator fails loud', () => {
|
||||
expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([])
|
||||
})
|
||||
|
||||
it('covers every vendored directory, so no package can drop out of the notices', () => {
|
||||
const parsed = new Set(parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')).map(row => row.npmName))
|
||||
const onDisk = readdirSync(resolve(root, 'vendor'), { withFileTypes: true })
|
||||
.filter(entry => entry.isDirectory())
|
||||
.map(entry => (JSON.parse(readFileSync(resolve(root, 'vendor', entry.name, 'package.json'), 'utf8')) as Manifest).name)
|
||||
|
||||
expect([...onDisk].sort()).toEqual([...parsed].sort())
|
||||
})
|
||||
})
|
||||
|
||||
describe('parsePyprojectRequirements', () => {
|
||||
it('reads the committed manifests', () => {
|
||||
expect(parsePyprojectRequirements(readFileSync(resolve(root, 'python/sdk/pyproject.toml'), 'utf8'))).toContain('pydantic')
|
||||
})
|
||||
|
||||
it('locates requirement arrays by TOML table, so author-named groups are not missed', () => {
|
||||
expect(parsePyprojectRequirements([
|
||||
'[build-system]',
|
||||
'requires = ["hatchling>=1.24.0"]',
|
||||
'',
|
||||
'[project]',
|
||||
'name = "not-a-requirement"',
|
||||
'dependencies = ["pydantic>=2.12"]',
|
||||
'',
|
||||
'[project.optional-dependencies]',
|
||||
'cli = ["click"]',
|
||||
'',
|
||||
'[dependency-groups]',
|
||||
'docs = ["sphinx>=7"]',
|
||||
'',
|
||||
'[tool.hatch.build.targets.wheel]',
|
||||
'packages = ["src/deepseek_harness"]',
|
||||
'',
|
||||
'[tool.pytest.ini_options]',
|
||||
'testpaths = ["tests"]',
|
||||
].join('\n'))).toEqual(['hatchling', 'pydantic', 'click', 'sphinx'])
|
||||
})
|
||||
|
||||
it('does not truncate an array at a bracket inside extras', () => {
|
||||
expect(parsePyprojectRequirements('[project]\ndependencies = ["httpx[http2]", "requests"]\n'))
|
||||
.toEqual(['httpx', 'requests'])
|
||||
})
|
||||
|
||||
it('reads names whether or not requirements carry versions, extras, or markers', () => {
|
||||
expect(parsePyprojectRequirements("[project]\ndependencies = [\"pydantic>=2.12\", \"requests\", \"httpx[http2]\", \"tomli ; python_version < '3.11'\", \"hatchling >= 1.24.0\"]\n"))
|
||||
.toEqual(['pydantic', 'requests', 'httpx', 'tomli', 'hatchling'])
|
||||
})
|
||||
|
||||
it('reads single-quoted TOML literals and rejects an unreadable requirement', () => {
|
||||
expect(parsePyprojectRequirements("[project]\ndependencies = ['requests', \"pydantic>=2\"]\n")).toEqual(['requests', 'pydantic'])
|
||||
expect(() => parsePyprojectRequirements('[project]\ndependencies = ["!!broken"]\n')).toThrow(/cannot read a distribution name/)
|
||||
})
|
||||
|
||||
it('reads a multi-line array', () => {
|
||||
expect(parsePyprojectRequirements('[project]\ndependencies = [\n "pydantic>=2.12",\n "typing-extensions",\n]\n'))
|
||||
.toEqual(['pydantic', 'typing-extensions'])
|
||||
})
|
||||
|
||||
it('obeys TOML comments, quoted keys, and escaped strings', () => {
|
||||
expect(parsePyprojectRequirements([
|
||||
'[project] # a legal header comment',
|
||||
'dependencies = [',
|
||||
' "pydantic", # ] does not close the array',
|
||||
' # "old-package" is not a dependency',
|
||||
' "tomli; python_version < \'3.11\'",',
|
||||
']',
|
||||
'',
|
||||
'[dependency-groups]',
|
||||
'"test.docs" = ["pytest"]',
|
||||
].join('\n'))).toEqual(['pydantic', 'tomli', 'pytest'])
|
||||
})
|
||||
|
||||
it('accepts dependency-group includes and rejects unsupported requirement shapes', () => {
|
||||
expect(parsePyprojectRequirements('[dependency-groups]\nbase = ["pytest"]\nall = [{ include-group = "base" }]\n'))
|
||||
.toEqual(['pytest'])
|
||||
expect(() => parsePyprojectRequirements('[project]\ndependencies = "pytest"\n')).toThrow(/must be an array/)
|
||||
expect(() => parsePyprojectRequirements('[dependency-groups]\ntest = [{ unknown = "pytest" }]\n')).toThrow(/unsupported requirement entry/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('collectPythonDependencies', () => {
|
||||
it('excludes normalized local project names without exempting a third-party prefix', () => {
|
||||
const pyprojects = [
|
||||
'[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n',
|
||||
'[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n',
|
||||
]
|
||||
expect(() => collectPythonDependencies(pyprojects)).toThrow(
|
||||
'python dependency deepseek-unrelated is missing from PYTHON_METADATA',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('isPermissive', () => {
|
||||
it('accepts the licenses this project ships and rejects copyleft or unknown ones', () => {
|
||||
expect(['MIT', 'ISC', 'BSD-3-Clause', 'Apache-2.0', 'MIT / Apache-2.0', '(MIT OR CC0-1.0)'].every(isPermissive)).toBe(true)
|
||||
expect(['LGPL-3.0-only', 'MPL-2.0', 'GPL-3.0-or-later', 'SEE LICENSE IN LICENSE'].some(isPermissive)).toBe(false)
|
||||
})
|
||||
|
||||
it('requires every operand of an AND, so a copyleft conjunct cannot ride along', () => {
|
||||
expect(isPermissive('(MIT OR Apache-2.0) AND GPL-3.0-only')).toBe(false)
|
||||
expect(isPermissive('MIT AND ISC')).toBe(true)
|
||||
// An exception clause is not a recognized identifier, so it fails closed.
|
||||
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
|
||||
})
|
||||
|
||||
it('honors grouping and SPDX precedence', () => {
|
||||
expect(isPermissive('MIT OR (GPL-3.0-only AND GPL-2.0-only)')).toBe(true)
|
||||
expect(isPermissive('(MIT OR Apache-2.0) AND ISC')).toBe(true)
|
||||
})
|
||||
|
||||
it('fails closed for malformed expressions, additions, and exceptions', () => {
|
||||
expect(['MIT)', '((MIT', '(MIT OR GPL-3.0-only', 'MIT OR OR GPL-3.0-only'].some(isPermissive)).toBe(false)
|
||||
expect(isPermissive('MIT+')).toBe(false)
|
||||
expect(isPermissive('GPL-2.0-only WITH Classpath-exception-2.0')).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('manifestPatterns', () => {
|
||||
it('derives globs from the declared members, so a new member area is read', () => {
|
||||
expect(manifestPatterns(['packages/*/*', 'tools/*'], ['packages/*'])).toEqual([
|
||||
'package.json',
|
||||
'packages/*/*/package.json',
|
||||
'tools/*/package.json',
|
||||
'examples/*/package.json',
|
||||
'native/landlock-run/package.json',
|
||||
'native/landlock-run/packages/*/package.json',
|
||||
])
|
||||
})
|
||||
})
|
||||
596
scripts/gen-third-party-notices.ts
Normal file
596
scripts/gen-third-party-notices.ts
Normal file
@@ -0,0 +1,596 @@
|
||||
/**
|
||||
* Generate `THIRD_PARTY_NOTICES.md` from the workspace manifests: every
|
||||
* external dependency named by a workspace `package.json`, the vendored-package
|
||||
* manifest in `vendor/README.md`, the Python `pyproject.toml` files, and the
|
||||
* pnpm patch list. License and repository metadata come from the installed
|
||||
* store, so the tree must be installed. `--check` verifies the committed
|
||||
* artifact. Tier policy and ownership live in
|
||||
* `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
|
||||
*/
|
||||
|
||||
import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { resolve } from 'node:path'
|
||||
import * as yaml from 'js-yaml'
|
||||
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
|
||||
import parseSpdx from 'spdx-expression-parse'
|
||||
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
const OUT = 'THIRD_PARTY_NOTICES.md'
|
||||
|
||||
/** Dependency-declaration kinds a consumer resolves at runtime. */
|
||||
const RUNTIME_KINDS = ['dependencies', 'optionalDependencies'] as const
|
||||
/** All manifest sections that name an external package this file must disclose. */
|
||||
const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies'] as const
|
||||
|
||||
/**
|
||||
* Workspace areas that never reach a user: repository tooling and gates (the
|
||||
* root manifest), test infrastructure, the documentation site, the runnable
|
||||
* demo leaves, and the native launcher's build workspace. A runtime
|
||||
* declaration by anything outside these areas is a disclosure-relevant
|
||||
* runtime dependency, because `scripts/install.sh` installs the repository
|
||||
* itself and any plugin package can be mounted from a user's `cordis.yml`.
|
||||
*/
|
||||
const DEV_ONLY_AREAS = [
|
||||
'package.json',
|
||||
'packages/support/',
|
||||
'packages/client/test-runtime/',
|
||||
'website/',
|
||||
'examples/',
|
||||
'native/',
|
||||
] as const
|
||||
|
||||
/**
|
||||
* First-party packages released from sibling repositories under the project's
|
||||
* own license: reachable from workspace manifests but not third-party.
|
||||
*/
|
||||
const FIRST_PARTY = new Set([
|
||||
'node-addon-landlock-run',
|
||||
'node-addon-landlock-run-linux-arm64',
|
||||
'node-addon-landlock-run-linux-x64',
|
||||
])
|
||||
|
||||
/**
|
||||
* Metadata overrides where the installed manifest is wrong or unreachable.
|
||||
* Each entry documents why the store cannot answer.
|
||||
*/
|
||||
const OVERRIDES: Record<string, { license?: string; repo?: string }> = {
|
||||
// Rust workspaces publishing npm bins without `license` in package.json.
|
||||
'oxlint': { license: 'MIT', repo: 'https://github.com/oxc-project/oxc' },
|
||||
'oxlint-tsgolint': { license: 'MIT', repo: 'https://github.com/oxc-project/tsgolint' },
|
||||
// `license: SEE LICENSE IN LICENSE`: the servers repo is mid MIT→Apache-2.0
|
||||
// relicensing, so the effective terms are per-contribution.
|
||||
'@modelcontextprotocol/server-everything': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
|
||||
'@modelcontextprotocol/server-filesystem': { license: 'MIT / Apache-2.0', repo: 'https://github.com/modelcontextprotocol/servers' },
|
||||
// No repository field in the published manifest.
|
||||
'node-addon-require-builtin': { repo: 'https://www.npmjs.com/package/node-addon-require-builtin' },
|
||||
}
|
||||
|
||||
/**
|
||||
* Python dependencies are few and named directly in `pyproject.toml` files
|
||||
* without installed metadata to harvest, so license/repo are recorded here and
|
||||
* the generator fails when a manifest names a package this map misses.
|
||||
*/
|
||||
const PYTHON_METADATA: Record<string, { license: string; repo: string; role: string }> = {
|
||||
pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' },
|
||||
hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' },
|
||||
pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' },
|
||||
}
|
||||
|
||||
type PythonMetadata = typeof PYTHON_METADATA
|
||||
|
||||
/** Tools fetched by scripts at build time, keyed by the pin the script owns. */
|
||||
const BUILD_TIME_TOOLS = [
|
||||
{
|
||||
name: '@yao-pkg/pkg',
|
||||
license: 'MIT',
|
||||
repo: 'https://github.com/yao-pkg/pkg',
|
||||
role: 'invoked by `scripts/build-exe-for-python-sdk.ts` to assemble the single-file SDK runtime executable',
|
||||
pinSource: 'scripts/build-exe-for-python-sdk.ts',
|
||||
},
|
||||
]
|
||||
|
||||
/** The `package.json` fields this generator reads. */
|
||||
export interface Manifest {
|
||||
name?: string
|
||||
private?: boolean
|
||||
license?: string
|
||||
dependencies?: Record<string, string>
|
||||
devDependencies?: Record<string, string>
|
||||
optionalDependencies?: Record<string, string>
|
||||
peerDependencies?: Record<string, string>
|
||||
}
|
||||
|
||||
/** One disclosed external npm dependency. */
|
||||
interface ExternalDep {
|
||||
name: string
|
||||
license: string
|
||||
repo: string
|
||||
/** True when some shipped workspace consumer reaches it through runtime dependency edges. */
|
||||
runtime: boolean
|
||||
}
|
||||
|
||||
/** Read and parse a workspace-relative `package.json`. */
|
||||
function readManifest(rel: string): Manifest {
|
||||
return JSON.parse(readFileSync(resolve(root, rel), 'utf8')) as Manifest
|
||||
}
|
||||
|
||||
/**
|
||||
* Manifest globs, derived from the workspace declarations rather than listed
|
||||
* here, so a new member area (`tools/*`) is read the day it is declared.
|
||||
* @returns one glob per manifest-bearing location, repository-relative.
|
||||
*/
|
||||
export function manifestPatterns(rootMembers: readonly string[], nativeMembers: readonly string[]): string[] {
|
||||
return [
|
||||
'package.json',
|
||||
...rootMembers.map(member => `${member}/package.json`),
|
||||
// The demo leaves join the workspace through `examples/package.json`, so
|
||||
// their own manifests are members of nothing and no glob above reaches them.
|
||||
'examples/*/package.json',
|
||||
// `native/landlock-run` is a nested workspace with its own lock file.
|
||||
'native/landlock-run/package.json',
|
||||
...nativeMembers.map(member => `native/landlock-run/${member}/package.json`),
|
||||
]
|
||||
}
|
||||
|
||||
/** The `packages:` member globs declared by one pnpm workspace file. */
|
||||
function workspaceMembers(rel: string): string[] {
|
||||
const declared = (yaml.load(readFileSync(resolve(root, rel), 'utf8')) as { packages?: unknown }).packages
|
||||
if (!Array.isArray(declared) || declared.length === 0) {
|
||||
throw new Error(`gen-third-party-notices: ${rel} declares no workspace members; the manifest set cannot be derived.`)
|
||||
}
|
||||
return declared.map(member => String(member))
|
||||
}
|
||||
|
||||
/** Every workspace manifest, keyed by path, plus the set of workspace package names. */
|
||||
function loadWorkspaceManifests(): { manifests: Map<string, Manifest>; names: Set<string> } {
|
||||
const patterns = manifestPatterns(workspaceMembers('pnpm-workspace.yaml'), workspaceMembers('native/landlock-run/pnpm-workspace.yaml'))
|
||||
const manifests = new Map<string, Manifest>()
|
||||
const names = new Set<string>()
|
||||
for (const pattern of patterns) {
|
||||
for (const path of globSync(pattern, { cwd: root })) {
|
||||
const manifest = readManifest(path)
|
||||
manifests.set(path, manifest)
|
||||
if (manifest.name !== undefined) names.add(manifest.name)
|
||||
}
|
||||
}
|
||||
if (manifests.size < 100) throw new Error(`gen-third-party-notices: only ${manifests.size} workspace manifests found; the glob set is stale.`)
|
||||
return { manifests, names }
|
||||
}
|
||||
|
||||
/** License and repository URL for an installed external package, from the pnpm store. */
|
||||
function installedMetadata(name: string): { license: string; repo: string } {
|
||||
const override = OVERRIDES[name]
|
||||
let manifest: (Manifest & { license?: string; repository?: string | { url?: string }; homepage?: string }) | undefined
|
||||
// The nested Landlock workspace installs into its own store, so a package
|
||||
// only that workspace depends on is unreachable from the root one.
|
||||
for (const store of ['node_modules', 'native/landlock-run/node_modules']) {
|
||||
const direct = resolve(root, store, name, 'package.json')
|
||||
if (existsSync(direct)) {
|
||||
manifest = JSON.parse(readFileSync(direct, 'utf8')) as typeof manifest
|
||||
break
|
||||
}
|
||||
const virtual = resolve(root, store, '.pnpm')
|
||||
if (!existsSync(virtual)) continue
|
||||
const prefix = `${name.replace('/', '+')}@`
|
||||
const entry = readdirSync(virtual).find(dir => dir.startsWith(prefix))
|
||||
if (entry === undefined) continue
|
||||
manifest = JSON.parse(readFileSync(resolve(virtual, entry, 'node_modules', name, 'package.json'), 'utf8')) as typeof manifest
|
||||
break
|
||||
}
|
||||
const license = override?.license ?? manifest?.license
|
||||
const rawRepo = typeof manifest?.repository === 'string' ? manifest.repository : manifest?.repository?.url ?? manifest?.homepage
|
||||
const repo = override?.repo ?? normalizeRepo(rawRepo)
|
||||
if (license === undefined || repo === undefined) {
|
||||
throw new Error(`gen-third-party-notices: cannot resolve ${license === undefined ? 'license' : 'repository'} for ${name}; run \`pnpm install\` (or, for a Landlock-only dependency, \`pnpm --dir native/landlock-run install\`), or add an OVERRIDES entry.`)
|
||||
}
|
||||
return { license, repo }
|
||||
}
|
||||
|
||||
/** Normalize a manifest repository/homepage value to a browsable https URL. */
|
||||
function normalizeRepo(raw: string | undefined): string | undefined {
|
||||
if (raw === undefined || raw === '') return undefined
|
||||
let url = raw
|
||||
.replace(/^git\+ssh:\/\/git@/, 'https://')
|
||||
.replace(/^git\+/, '')
|
||||
.replace(/^git:\/\//, 'https://')
|
||||
.replace(/^github:/, 'https://github.com/')
|
||||
.replace(/\.git$/, '')
|
||||
if (!url.startsWith('http')) url = `https://github.com/${url}`
|
||||
return url
|
||||
}
|
||||
|
||||
/**
|
||||
* External npm dependencies, tiered by which workspace area declares them at
|
||||
* runtime: a package is runtime when any manifest outside `DEV_ONLY_AREAS`
|
||||
* names it in `dependencies`/`optionalDependencies`. A package declared only
|
||||
* by tooling, test infrastructure, the website, or the demo leaves — whatever
|
||||
* the declaring section is called — is development-only.
|
||||
*/
|
||||
function collectNpmDeps(): ExternalDep[] {
|
||||
const { manifests, names } = loadWorkspaceManifests()
|
||||
return [...tierExternalDeps(manifests, names)]
|
||||
.filter(([name]) => !FIRST_PARTY.has(name))
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.map(([name, runtime]) => ({ name, ...installedMetadata(name), runtime }))
|
||||
}
|
||||
|
||||
/**
|
||||
* Tier every external dependency the workspace declares.
|
||||
* @param manifests - workspace manifests keyed by repository-relative path.
|
||||
* @param names - every workspace package name, which never counts as external.
|
||||
* @returns each external package mapped to whether it is a runtime dependency.
|
||||
*/
|
||||
export function tierExternalDeps(manifests: Map<string, Manifest>, names: Set<string>): Map<string, boolean> {
|
||||
const tiers = new Map<string, boolean>()
|
||||
// `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook.
|
||||
tiers.set('tsx', true)
|
||||
for (const [path, manifest] of manifests) {
|
||||
const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area))
|
||||
for (const kind of ALL_KINDS) {
|
||||
for (const [dep, range] of Object.entries(manifest[kind] ?? {})) {
|
||||
if (names.has(dep) || range.startsWith('workspace:')) continue
|
||||
const runtime = !devOnly && (RUNTIME_KINDS as readonly string[]).includes(kind)
|
||||
tiers.set(dep, (tiers.get(dep) ?? false) || runtime)
|
||||
}
|
||||
}
|
||||
}
|
||||
return tiers
|
||||
}
|
||||
|
||||
/** A vendored package row parsed out of the `vendor/README.md` manifest table. */
|
||||
export interface VendoredRow {
|
||||
npmName: string
|
||||
upstream: string
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the vendored-package manifest table out of `vendor/README.md`.
|
||||
* @param text - the complete `vendor/README.md` contents.
|
||||
* @returns one row per manifest-table entry, in table order.
|
||||
*/
|
||||
export function parseVendoredRows(text: string): VendoredRow[] {
|
||||
const rows: VendoredRow[] = []
|
||||
for (const line of text.split('\n')) {
|
||||
const match = /^\| \x60\S+\/\x60 \| \x60([^\x60]+)\x60 \| \S+ \| (https:\/\/\S+?)(?: \([^)]*\))? \| \x60[0-9a-f]+\x60 \|$/.exec(line)
|
||||
if (match === null) continue
|
||||
const [, npmName, upstream] = match
|
||||
if (npmName === undefined || upstream === undefined) continue
|
||||
rows.push({ npmName, upstream })
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse the vendored manifest table and confirm it accounts for every vendored
|
||||
* directory. The `vendor/` tree — not the table — is the set that must be
|
||||
* disclosed, so a row that stops matching the table format is a hard error
|
||||
* rather than a package that quietly vanishes from the notices.
|
||||
*/
|
||||
function collectVendored(): VendoredRow[] {
|
||||
const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8'))
|
||||
const onDisk = new Map<string, string>()
|
||||
for (const entry of readdirSync(resolve(root, 'vendor'), { withFileTypes: true })) {
|
||||
if (!entry.isDirectory()) continue
|
||||
const manifest = readManifest(`vendor/${entry.name}/package.json`)
|
||||
if (manifest.name !== undefined) onDisk.set(manifest.name, entry.name)
|
||||
}
|
||||
|
||||
const parsed = new Set(rows.map(row => row.npmName))
|
||||
const missing = [...onDisk.keys()].filter(name => !parsed.has(name))
|
||||
if (missing.length > 0) {
|
||||
throw new Error(`gen-third-party-notices: vendor/README.md has no manifest-table row for ${missing.join(', ')}; its table format changed or the sync is incomplete.`)
|
||||
}
|
||||
for (const row of rows) {
|
||||
const dir = onDisk.get(row.npmName)
|
||||
if (dir === undefined) throw new Error(`gen-third-party-notices: vendored package ${row.npmName} from vendor/README.md has no vendor/ directory.`)
|
||||
const license = readManifest(`vendor/${dir}/package.json`).license
|
||||
if (license !== 'MIT') {
|
||||
throw new Error(`gen-third-party-notices: vendored ${row.npmName} declares license ${JSON.stringify(license)}; the vendored section assumes MIT throughout.`)
|
||||
}
|
||||
}
|
||||
return rows
|
||||
}
|
||||
|
||||
/** Whether a parsed TOML value is a table rather than an array or scalar. */
|
||||
function isTomlTable(value: TomlValueWithoutBigInt | undefined): value is TomlTableWithoutBigInt {
|
||||
return value !== undefined && typeof value === 'object' && !Array.isArray(value)
|
||||
}
|
||||
|
||||
/** Parse one PEP 508 requirement string into its distribution name. */
|
||||
function parsePythonRequirement(requirement: string): string {
|
||||
const name = /^\s*([a-zA-Z][a-zA-Z0-9._-]*)\s*(?:\[[^\]]*\])?\s*(?:[<>=!~;@].*)?$/.exec(requirement)?.[1]
|
||||
if (name === undefined) {
|
||||
throw new Error(`gen-third-party-notices: cannot read a distribution name from the requirement ${JSON.stringify(requirement)}.`)
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
/** Add the string requirements from one parsed TOML array. */
|
||||
function collectPythonRequirementArray(
|
||||
names: string[],
|
||||
value: TomlValueWithoutBigInt | undefined,
|
||||
location: string,
|
||||
allowGroupIncludes = false,
|
||||
): void {
|
||||
if (value === undefined) return
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`gen-third-party-notices: ${location} must be an array.`)
|
||||
}
|
||||
for (const item of value) {
|
||||
if (typeof item === 'string') {
|
||||
names.push(parsePythonRequirement(item))
|
||||
continue
|
||||
}
|
||||
if (allowGroupIncludes && isTomlTable(item) && typeof item['include-group'] === 'string' && Object.keys(item).length === 1) {
|
||||
continue
|
||||
}
|
||||
throw new Error(`gen-third-party-notices: ${location} contains an unsupported requirement entry.`)
|
||||
}
|
||||
}
|
||||
|
||||
/** Read an optional TOML table and reject a present value of another shape. */
|
||||
function optionalTomlTable(value: TomlValueWithoutBigInt | undefined, location: string): TomlTableWithoutBigInt | undefined {
|
||||
if (value === undefined || isTomlTable(value)) return value
|
||||
throw new Error(`gen-third-party-notices: ${location} must be a table.`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a `pyproject.toml` project identity and every requirement it declares:
|
||||
* `requires` under
|
||||
* `[build-system]`, `dependencies` under `[project]`, and every key under
|
||||
* `[project.optional-dependencies]` and `[dependency-groups]`. A TOML parser
|
||||
* owns comments, quoted keys, escapes, and array boundaries; unsupported
|
||||
* requirement shapes fail instead of disappearing from the notices.
|
||||
* @param text - the complete `pyproject.toml` contents.
|
||||
* @returns the local project name and declared requirement names.
|
||||
*/
|
||||
function parsePyproject(text: string): { projectName?: string; requirements: string[] } {
|
||||
const names: string[] = []
|
||||
const document = parseToml(text, { integersAsBigInt: false })
|
||||
const buildSystem = optionalTomlTable(document['build-system'], '[build-system]')
|
||||
const project = optionalTomlTable(document.project, '[project]')
|
||||
const projectName = project?.name
|
||||
if (projectName !== undefined && typeof projectName !== 'string') {
|
||||
throw new Error('gen-third-party-notices: [project].name must be a string.')
|
||||
}
|
||||
collectPythonRequirementArray(names, buildSystem?.requires, '[build-system].requires')
|
||||
collectPythonRequirementArray(names, project?.dependencies, '[project].dependencies')
|
||||
|
||||
const optional = optionalTomlTable(project?.['optional-dependencies'], '[project.optional-dependencies]')
|
||||
for (const [group, requirements] of Object.entries(optional ?? {})) {
|
||||
collectPythonRequirementArray(names, requirements, `[project.optional-dependencies].${group}`)
|
||||
}
|
||||
|
||||
const groups = optionalTomlTable(document['dependency-groups'], '[dependency-groups]')
|
||||
for (const [group, requirements] of Object.entries(groups ?? {})) {
|
||||
collectPythonRequirementArray(names, requirements, `[dependency-groups].${group}`, true)
|
||||
}
|
||||
return projectName === undefined
|
||||
? { requirements: names }
|
||||
: { projectName, requirements: names }
|
||||
}
|
||||
|
||||
/**
|
||||
* Read every requirement name declared by one `pyproject.toml`.
|
||||
* @param text - the complete `pyproject.toml` contents.
|
||||
* @returns each declared requirement's distribution name, in file order.
|
||||
*/
|
||||
export function parsePyprojectRequirements(text: string): string[] {
|
||||
return parsePyproject(text).requirements
|
||||
}
|
||||
|
||||
/** Normalize a Python distribution name according to the packaging name rule. */
|
||||
function normalizePythonDistributionName(name: string): string {
|
||||
return name.toLowerCase().replace(/[-_.]+/g, '-')
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve external Python dependencies after excluding local project names.
|
||||
* @param pyprojects - complete local `pyproject.toml` contents.
|
||||
* @param metadata - disclosure metadata for every external dependency.
|
||||
* @returns disclosed dependencies in normalized name order.
|
||||
*/
|
||||
export function collectPythonDependencies(
|
||||
pyprojects: string[],
|
||||
metadata: PythonMetadata = PYTHON_METADATA,
|
||||
): { name: string; license: string; repo: string; role: string }[] {
|
||||
const parsed = pyprojects.map(parsePyproject)
|
||||
const firstParty = new Set(parsed.flatMap(({ projectName }) => (
|
||||
projectName === undefined ? [] : [normalizePythonDistributionName(projectName)]
|
||||
)))
|
||||
const found = new Set(parsed
|
||||
.flatMap(({ requirements }) => requirements.map(normalizePythonDistributionName))
|
||||
.filter(name => !firstParty.has(name)))
|
||||
return [...found].sort((a, b) => a.localeCompare(b)).map((name) => {
|
||||
const entry = metadata[name]
|
||||
if (entry === undefined) throw new Error(`gen-third-party-notices: python dependency ${name} is missing from PYTHON_METADATA.`)
|
||||
return { name, ...entry }
|
||||
})
|
||||
}
|
||||
|
||||
/** Direct Python dependencies named by the `pyproject.toml` manifests under `python/`. */
|
||||
function collectPython(): { name: string; license: string; repo: string; role: string }[] {
|
||||
const manifests = globSync('python/*/pyproject.toml', { cwd: root })
|
||||
if (manifests.length === 0) throw new Error('gen-third-party-notices: no python/*/pyproject.toml found; the Python tree moved.')
|
||||
return collectPythonDependencies(manifests.map(path => readFileSync(resolve(root, path), 'utf8')))
|
||||
}
|
||||
|
||||
/** pnpm-patched external packages, from `pnpm-workspace.yaml`. */
|
||||
function collectPatched(): { spec: string; patch: string }[] {
|
||||
const workspace = yaml.load(readFileSync(resolve(root, 'pnpm-workspace.yaml'), 'utf8')) as { patchedDependencies?: Record<string, string> }
|
||||
return Object.entries(workspace.patchedDependencies ?? {}).map(([spec, patch]) => ({ spec, patch }))
|
||||
}
|
||||
|
||||
/** Verify each build-time tool pin still appears in its owning script. */
|
||||
function verifyBuildTimePins(): void {
|
||||
for (const tool of BUILD_TIME_TOOLS) {
|
||||
const text = readFileSync(resolve(root, tool.pinSource), 'utf8')
|
||||
if (!text.includes(tool.name)) {
|
||||
throw new Error(`gen-third-party-notices: ${tool.pinSource} no longer references ${tool.name}; update BUILD_TIME_TOOLS.`)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** SPDX identifiers this project may ship without further review. */
|
||||
const PERMISSIVE_LICENSES = new Set(['MIT', 'ISC', 'BSD-2-Clause', 'BSD-3-Clause', 'Apache-2.0', '0BSD', 'Unlicense', 'CC0-1.0', 'BlueOak-1.0.0', 'Python-2.0'])
|
||||
|
||||
/** Evaluate a parsed SPDX expression under the repository's license policy. */
|
||||
function isPermissiveSpdx(expression: ReturnType<typeof parseSpdx>): boolean {
|
||||
if ('conjunction' in expression) {
|
||||
return expression.conjunction === 'and'
|
||||
? isPermissiveSpdx(expression.left) && isPermissiveSpdx(expression.right)
|
||||
: isPermissiveSpdx(expression.left) || isPermissiveSpdx(expression.right)
|
||||
}
|
||||
return expression.plus !== true
|
||||
&& expression.exception === undefined
|
||||
&& PERMISSIVE_LICENSES.has(expression.license)
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether an SPDX expression grants terms this project may ship under.
|
||||
* `OR` needs one permissive alternative, because the consumer chooses; `AND`
|
||||
* needs all of them, because every obligation applies. Anything that is not a
|
||||
* recognized permissive identifier — copyleft, an exception clause, or a
|
||||
* license this list has never seen — evaluates to false, so an unfamiliar
|
||||
* expression fails closed rather than passing on a partial match.
|
||||
* @param license - the SPDX expression from the package manifest.
|
||||
* @returns true when the expression's obligations are all permissive.
|
||||
*/
|
||||
export function isPermissive(license: string): boolean {
|
||||
// Some npm manifests use a slash for a choice despite SPDX requiring `OR`.
|
||||
const normalized = license.replace(/\s*\/\s*/g, ' OR ').trim()
|
||||
try {
|
||||
return isPermissiveSpdx(parseSpdx(normalized))
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the sentence that isolates non-permissive development tooling, or
|
||||
* nothing at all when every development dependency is permissive.
|
||||
* @param deps - development dependencies whose license is not permissive.
|
||||
* @returns the paragraph to place after the development table.
|
||||
*/
|
||||
function renderNonPermissiveNote(deps: ExternalDep[]): string {
|
||||
if (deps.length === 0) return ''
|
||||
const named = deps.map(dep => `\`${dep.name}\` (${dep.license})`)
|
||||
const subject = named.length === 1 ? named[0] : `${named.slice(0, -1).join(', ')} and ${named.at(-1)}`
|
||||
return `\n${subject} ${named.length === 1 ? 'runs' : 'run'} only as development tooling; their code is not linked into or distributed with any DeepSeek Harness artifact.\n`
|
||||
}
|
||||
|
||||
/** Render one npm dependency table. */
|
||||
function renderNpmTable(deps: ExternalDep[]): string {
|
||||
const lines = ['| Package | License |', '| --- | --- |']
|
||||
for (const dep of deps) lines.push(`| [\`${dep.name}\`](${dep.repo}) | ${dep.license} |`)
|
||||
return lines.join('\n')
|
||||
}
|
||||
|
||||
/**
|
||||
* Render the complete notices document.
|
||||
* @returns the exact bytes `THIRD_PARTY_NOTICES.md` must hold.
|
||||
*/
|
||||
export function render(): string {
|
||||
verifyBuildTimePins()
|
||||
const npm = collectNpmDeps()
|
||||
const runtimeDeps = npm.filter(dep => dep.runtime)
|
||||
const devDeps = npm.filter(dep => !dep.runtime)
|
||||
const vendored = collectVendored()
|
||||
const python = collectPython()
|
||||
const patched = collectPatched()
|
||||
|
||||
const nonPermissiveDev = devDeps.filter(dep => !isPermissive(dep.license))
|
||||
// A copyleft license reaching a shipped surface is a distribution decision,
|
||||
// not a rendering detail; the notices cannot quietly absorb it.
|
||||
const nonPermissiveRuntime = runtimeDeps.filter(dep => !isPermissive(dep.license))
|
||||
if (nonPermissiveRuntime.length > 0) {
|
||||
throw new Error(`gen-third-party-notices: runtime ${nonPermissiveRuntime.map(dep => `${dep.name} (${dep.license})`).join(', ')} is not a permissive license; review the distribution terms and record the decision before regenerating.`)
|
||||
}
|
||||
const patchedLines = patched.map(({ spec, patch }) => `- \`${spec}\` — [\`${patch}\`](${patch})`)
|
||||
|
||||
return `<!-- Generated by scripts/gen-third-party-notices.ts — do not edit by hand.
|
||||
Run \`pnpm run gen-third-party-notices\` to regenerate. -->
|
||||
|
||||
# Third-Party Notices
|
||||
|
||||
DeepSeek Harness is licensed under [BSD 3-Clause](LICENSE). It depends on the third-party open-source software listed below. Each project remains under its own license; nothing in this file changes those terms.
|
||||
|
||||
This file lists **direct** dependencies declared by the workspace. It is generated from the workspace manifests by \`scripts/gen-third-party-notices.ts\`: a pre-commit hook regenerates it whenever a staged file changes one of its inputs, and \`scripts/gen-third-party-notices.spec.ts\` asserts in the test lane that the committed bytes match. Deleting a manifest runs no hook, so that case is caught by the assertion instead. Run \`pnpm run verify-third-party-notices\` for the standalone check.
|
||||
|
||||
The complete npm transitive closure, with exact pinned versions, is recorded in [\`pnpm-lock.yaml\`](pnpm-lock.yaml) — inspect it with \`pnpm licenses list\`. The Python closure is recorded in [\`python/sdk/uv.lock\`](python/sdk/uv.lock), and the Landlock launcher workspace keeps its own in [\`native/landlock-run/pnpm-lock.yaml\`](native/landlock-run/pnpm-lock.yaml).
|
||||
|
||||
## Vendored source (\`vendor/\`)
|
||||
|
||||
The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream \`LICENSE\` file. Exact upstream commits and local modifications are recorded in [\`vendor/README.md\`](vendor/README.md).
|
||||
|
||||
| Package | Upstream | License |
|
||||
| --- | --- | --- |
|
||||
${vendored.map(row => `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')}
|
||||
|
||||
## Runtime npm dependencies
|
||||
|
||||
External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI/TUI, the Web UI, and the Python SDK runtime load by default.
|
||||
|
||||
${renderNpmTable(runtimeDeps)}
|
||||
|
||||
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
|
||||
|
||||
${patchedLines.join('\n')}
|
||||
|
||||
## Development-only npm dependencies
|
||||
|
||||
External packages **directly declared** only by repository tooling, test infrastructure, the documentation site, the demo leaves, or the native launcher's build workspace. No shipped surface names them itself. A package here may still be pulled in transitively by a runtime dependency — \`pnpm-lock.yaml\` is the authority on the full closure — so this tier records who declares a package, not what a build ultimately bundles.
|
||||
|
||||
${renderNpmTable(devDeps)}
|
||||
${renderNonPermissiveNote(nonPermissiveDev)}
|
||||
## Python SDK dependencies (\`python/\`)
|
||||
|
||||
Direct dependencies of the \`pyproject.toml\` manifests, plus \`uv\` as the development workflow tool.
|
||||
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
${python.map(dep => `| [\`${dep.name}\`](${dep.repo}) | ${dep.license} | ${dep.role} |`).join('\n')}
|
||||
| [\`uv\`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool |
|
||||
|
||||
## Fetched at build time
|
||||
|
||||
| Package | License | Role |
|
||||
| --- | --- | --- |
|
||||
${BUILD_TIME_TOOLS.map(tool => `| [\`${tool.name}\`](${tool.repo}) | ${tool.license} | ${tool.role} |`).join('\n')}
|
||||
|
||||
## First-party sibling releases
|
||||
|
||||
\`node-addon-landlock-run\` (and its platform packages) is released from a DeepSeek Harness sibling repository under BSD 3-Clause. It is listed here for completeness; it is first-party, not third-party.
|
||||
`
|
||||
}
|
||||
|
||||
/** CLI entry: default writes the notices, `--check` fails if the committed copy
|
||||
* is stale. Guarded behind an entry-point check so importing this module for
|
||||
* tests neither regenerates the committed file nor calls process.exit. */
|
||||
function main(): void {
|
||||
const content = render()
|
||||
if (process.argv.includes('--check')) {
|
||||
let committed: string | null = null
|
||||
try {
|
||||
committed = readFileSync(resolve(root, OUT), 'utf8')
|
||||
} catch {
|
||||
// Only ENOENT (not yet generated) is expected; a present-but-unreadable
|
||||
// file is not a state this repo produces, and the remedy is the same.
|
||||
committed = null
|
||||
}
|
||||
if (committed === content) {
|
||||
console.log(`gen-third-party-notices: ${OUT} is up to date.`)
|
||||
process.exit(0)
|
||||
}
|
||||
console.error(`gen-third-party-notices: ${OUT} is stale. Run \`pnpm run gen-third-party-notices\` and commit ${OUT}.`)
|
||||
process.exit(1)
|
||||
}
|
||||
|
||||
writeFileSync(resolve(root, OUT), content)
|
||||
console.log(`gen-third-party-notices: wrote ${OUT}.`)
|
||||
}
|
||||
|
||||
// Run only when invoked as a script, not when imported by a test.
|
||||
if (process.argv[1] !== undefined && import.meta.filename === resolve(process.argv[1])) {
|
||||
main()
|
||||
}
|
||||
@@ -1,6 +1,17 @@
|
||||
#!/usr/bin/env node
|
||||
import { randomUUID } from 'node:crypto'
|
||||
import { existsSync, lstatSync, mkdirSync, readdirSync, readFileSync, unlinkSync, writeFileSync } from 'node:fs'
|
||||
import {
|
||||
closeSync,
|
||||
existsSync,
|
||||
fstatSync,
|
||||
lstatSync,
|
||||
mkdirSync,
|
||||
openSync,
|
||||
readdirSync,
|
||||
readFileSync,
|
||||
unlinkSync,
|
||||
writeFileSync,
|
||||
} from 'node:fs'
|
||||
import { spawnSync } from 'node:child_process'
|
||||
import { dirname, isAbsolute, join, resolve } from 'node:path'
|
||||
import lefthookPackage from 'lefthook/package.json' with { type: 'json' }
|
||||
@@ -12,6 +23,7 @@ const OWNERSHIP_MARKER_VERSION = 1
|
||||
const OWNERSHIP_MARKER_OWNER = 'deepseek-harness worktree-local lefthook hooks'
|
||||
const INSTALL_LOCK = 'dsh-lefthook-install.lock'
|
||||
const INSTALL_LOCK_TIMEOUT_MS = 30_000
|
||||
const INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS = 1_000
|
||||
const INSTALL_LOCK_POLL_MS = 50
|
||||
const ALLOW_HOOKS_PATH_OVERRIDE = 'DSH_LEFTHOOK_ALLOW_HOOKS_PATH_OVERRIDE'
|
||||
const REPOSITORY_EXTENSION_PATTERN = '^extensions\\.'
|
||||
@@ -304,6 +316,11 @@ function parseInstallLock(record) {
|
||||
return Number.isSafeInteger(owner) ? owner : undefined
|
||||
}
|
||||
|
||||
function installLockRecordMayBeIncomplete(record) {
|
||||
// Exclusive creation exposes the inode before its owner record is fully written.
|
||||
return record === '' || (!record.endsWith('\n') && /^[1-9]\d*(?: [0-9a-f-]*)?$/i.test(record))
|
||||
}
|
||||
|
||||
function lockOwnerIsAlive(owner) {
|
||||
try {
|
||||
process.kill(owner, 0)
|
||||
@@ -352,11 +369,29 @@ async function acquireInstallLock(commonDirectory) {
|
||||
const lockPath = join(commonDirectory, INSTALL_LOCK)
|
||||
const deadline = Date.now() + INSTALL_LOCK_TIMEOUT_MS
|
||||
const ownedRecord = `${String(process.pid)} ${randomUUID()}\n`
|
||||
let initializingLock
|
||||
while (true) {
|
||||
try {
|
||||
writeFileSync(lockPath, ownedRecord, { flag: 'wx', mode: 0o600 })
|
||||
const ownedStat = installLockStat(lockPath)
|
||||
if (ownedStat === undefined || !ownedStat.isFile() || ownedStat.isSymbolicLink()) {
|
||||
const lockHandle = openSync(lockPath, 'wx', 0o600)
|
||||
let ownedStat
|
||||
try {
|
||||
ownedStat = fstatSync(lockHandle)
|
||||
const writeDelay = Number(process.env.DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS ?? 0)
|
||||
if (writeDelay > 0) {
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, writeDelay))
|
||||
}
|
||||
writeFileSync(lockHandle, ownedRecord)
|
||||
} finally {
|
||||
closeSync(lockHandle)
|
||||
}
|
||||
const publishedStat = installLockStat(lockPath)
|
||||
if (
|
||||
publishedStat === undefined
|
||||
|| !publishedStat.isFile()
|
||||
|| publishedStat.isSymbolicLink()
|
||||
|| publishedStat.dev !== ownedStat.dev
|
||||
|| publishedStat.ino !== ownedStat.ino
|
||||
) {
|
||||
throw lockOwnershipChangedError(lockPath)
|
||||
}
|
||||
return () => releaseInstallLock(lockPath, ownedRecord, ownedStat)
|
||||
@@ -369,8 +404,36 @@ async function acquireInstallLock(commonDirectory) {
|
||||
}
|
||||
const existingRecord = readInstallLock(lockPath)
|
||||
if (existingRecord === undefined) continue
|
||||
const verifiedStat = installLockStat(lockPath)
|
||||
if (verifiedStat === undefined) continue
|
||||
if (!verifiedStat.isFile() || verifiedStat.isSymbolicLink()) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
if (verifiedStat.dev !== existingStat.dev || verifiedStat.ino !== existingStat.ino) continue
|
||||
const owner = parseInstallLock(existingRecord)
|
||||
if (owner === undefined) throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
if (owner === undefined) {
|
||||
if (!installLockRecordMayBeIncomplete(existingRecord)) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
const now = Date.now()
|
||||
if (
|
||||
initializingLock === undefined
|
||||
|| initializingLock.dev !== existingStat.dev
|
||||
|| initializingLock.ino !== existingStat.ino
|
||||
) {
|
||||
initializingLock = {
|
||||
deadline: now + INSTALL_LOCK_INITIALIZATION_TIMEOUT_MS,
|
||||
dev: existingStat.dev,
|
||||
ino: existingStat.ino,
|
||||
}
|
||||
}
|
||||
if (now >= initializingLock.deadline) {
|
||||
throw manualLockRecoveryError(lockPath, 'invalid')
|
||||
}
|
||||
await new Promise(resolveWait => setTimeout(resolveWait, INSTALL_LOCK_POLL_MS))
|
||||
continue
|
||||
}
|
||||
initializingLock = undefined
|
||||
if (!lockOwnerIsAlive(owner)) throw manualLockRecoveryError(lockPath, 'stale')
|
||||
if (Date.now() >= deadline) {
|
||||
throw new Error(`timed out waiting for Lefthook installer lock ${lockPath}`)
|
||||
|
||||
@@ -199,7 +199,7 @@ function runInstaller(
|
||||
})
|
||||
}
|
||||
|
||||
describe('worktree-local Lefthook installer', () => {
|
||||
describe('worktree-local Lefthook installer', { timeout: 15_000 }, () => {
|
||||
for (const [label, extraEnv] of [
|
||||
['CI', { CI: 'true' }],
|
||||
['GitHub Actions', { GITHUB_ACTIONS: 'true' }],
|
||||
@@ -310,6 +310,22 @@ describe('worktree-local Lefthook installer', () => {
|
||||
expect(existsSync(join(hooksPath(fixture, fixture.main), '.fake-lefthook-running'))).toBe(false)
|
||||
}, MULTI_PROCESS_TEST_TIMEOUT_MS)
|
||||
|
||||
it('waits for a concurrent installer to finish publishing its lock record', async () => {
|
||||
const fixture = createFixture()
|
||||
const lockPath = installLockPath(fixture)
|
||||
const publishing = runInstaller(fixture, fixture.main, {
|
||||
DSH_TEST_LEFTHOOK_LOCK_WRITE_DELAY_MS: '200',
|
||||
})
|
||||
await waitForPath(lockPath)
|
||||
expect(readFileSync(lockPath, 'utf8')).toBe('')
|
||||
|
||||
const waiting = runInstaller(fixture, fixture.linked)
|
||||
const results = await Promise.all([publishing, waiting])
|
||||
|
||||
for (const result of results) expect(result.status, result.stderr).toBe(0)
|
||||
expect(existsSync(lockPath)).toBe(false)
|
||||
})
|
||||
|
||||
it('repairs its owned absolute hook path after the checkout moves', async () => {
|
||||
const fixture = createFixture()
|
||||
const oldRoot = fixture.main
|
||||
|
||||
@@ -217,20 +217,35 @@ describe('docsPages locale routes', () => {
|
||||
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')
|
||||
expect(pages).toHaveLength(2)
|
||||
expect(pages.map(page => page.outline)).toEqual(['deep', 'deep'])
|
||||
})
|
||||
})
|
||||
|
||||
describe('addProjectionFrontmatter', () => {
|
||||
it('adds frontmatter to an ordinary Markdown page', () => {
|
||||
expect(addProjectionFrontmatter('# Guide\n', 'docs/guide.md')).toBe(
|
||||
expect(addProjectionFrontmatter('# Guide\n', { source: 'docs/guide.md' })).toBe(
|
||||
'---\neditSource: "docs/guide.md"\n---\n\n# Guide\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('extends existing VitePress frontmatter', () => {
|
||||
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', 'docs/index.md')).toBe(
|
||||
expect(addProjectionFrontmatter('---\nlayout: home\n---\n', { source: 'docs/index.md' })).toBe(
|
||||
'---\neditSource: "docs/index.md"\nlayout: home\n---\n',
|
||||
)
|
||||
})
|
||||
|
||||
it('adds the page-specific outline depth from the publication manifest', () => {
|
||||
expect(addProjectionFrontmatter('# Catalog\n', {
|
||||
source: 'docs/catalog.md',
|
||||
outline: [2, 4],
|
||||
})).toBe(
|
||||
'---\neditSource: "docs/catalog.md"\noutline: [2,4]\n---\n\n# Catalog\n',
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
describe('projectedPageContent', () => {
|
||||
|
||||
@@ -259,13 +259,16 @@ export function rewriteMarkdown(source: string, options: RewriteMarkdownOptions)
|
||||
* Record the canonical edit target in VitePress frontmatter.
|
||||
*
|
||||
* @param markdown Projected Markdown content.
|
||||
* @param sourcePath Repository-relative canonical source path.
|
||||
* @returns Markdown with an `editSource` frontmatter field.
|
||||
* @param page Publication manifest entry for the content.
|
||||
* @returns Markdown with projection-owned frontmatter fields.
|
||||
*/
|
||||
export function addProjectionFrontmatter(markdown: string, sourcePath: string): string {
|
||||
const field = `editSource: ${JSON.stringify(sourcePath)}`
|
||||
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${field}\n`)
|
||||
return `---\n${field}\n---\n\n${markdown}`
|
||||
export function addProjectionFrontmatter(markdown: string, page: Pick<DocsPage, 'source' | 'outline'>): string {
|
||||
const fields = [
|
||||
`editSource: ${JSON.stringify(page.source)}`,
|
||||
...(page.outline === undefined ? [] : [`outline: ${JSON.stringify(page.outline)}`]),
|
||||
].join('\n')
|
||||
if (markdown.startsWith('---\n')) return markdown.replace('---\n', `---\n${fields}\n`)
|
||||
return `---\n${fields}\n---\n\n${markdown}`
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -317,6 +320,6 @@ export function projectDocs(): void {
|
||||
repoRoot: root,
|
||||
repositoryRef,
|
||||
})
|
||||
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page.source))
|
||||
writeFileSync(output, addProjectionFrontmatter(projectedPageContent(projected, page), page))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +135,23 @@ describe('Oxlint gate', () => {
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node compatibility graph', () => {
|
||||
it('runs the jsdom environment smoke on every advertised Node line', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('node-compat'))
|
||||
|
||||
expect(subject.find(item => item.id === 'vitest-jsdom-smoke')).toMatchObject({
|
||||
label: 'Vitest jsdom smoke',
|
||||
args: [
|
||||
'/private/pnpm.cjs',
|
||||
'exec',
|
||||
'vitest',
|
||||
'run',
|
||||
'scripts/vitest-environment.compat.spec.ts',
|
||||
],
|
||||
})
|
||||
})
|
||||
})
|
||||
|
||||
describe('Node 24 lane ownership', () => {
|
||||
it('keeps the static lane source-only', () => {
|
||||
const subject = withPnpmEntrypoint(() => gatesForMode('ci-static'))
|
||||
|
||||
@@ -270,14 +270,27 @@ function ciPrimaryGates(): Gate[] {
|
||||
}
|
||||
|
||||
function nodeCompatGates(): Gate[] {
|
||||
const typecheck = flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK')
|
||||
? []
|
||||
: [pnpmScript('typecheck', 'typecheck')]
|
||||
if (runningNodeMajor() !== 22) {
|
||||
return [...typecheck, ...nodeCompatSmokeGates()]
|
||||
}
|
||||
return [
|
||||
...flagEnabled('DSH_NODE_COMPAT_SKIP_TYPECHECK') ? [] : [pnpmScript('typecheck', 'typecheck')],
|
||||
...nodeCompatSmokeGates(),
|
||||
...typecheck,
|
||||
pnpmScript('build', 'build', {
|
||||
...typecheck.length === 0 ? {} : { needs: ['typecheck'] },
|
||||
}),
|
||||
pnpmScript('build:web', 'build:web', {
|
||||
label: 'Web frontend build',
|
||||
needs: ['build'],
|
||||
}),
|
||||
...nodeCompatSmokeGates({ cliSmoke: true }),
|
||||
]
|
||||
}
|
||||
|
||||
function nodeCompatSmokeGates(): Gate[] {
|
||||
return [
|
||||
function nodeCompatSmokeGates(options: { cliSmoke?: boolean } = {}): Gate[] {
|
||||
const gates: Gate[] = [
|
||||
pnpmExec('source-worker-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
@@ -293,7 +306,35 @@ function nodeCompatSmokeGates(): Gate[] {
|
||||
'run',
|
||||
'apps/cli/tests/source-launch.compat.spec.ts',
|
||||
], { label: 'dsh source-launch smoke' }),
|
||||
pnpmExec('vitest-jsdom-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'scripts/vitest-environment.compat.spec.ts',
|
||||
], { label: 'Vitest jsdom smoke' }),
|
||||
]
|
||||
if (options.cliSmoke) {
|
||||
gates.push(
|
||||
pnpmExec('cli-lazy-search-startup-smoke', [
|
||||
'vitest',
|
||||
'run',
|
||||
'apps/cli/tests/lazy-search-startup.compat.spec.ts',
|
||||
], {
|
||||
label: 'CLI lazy-search startup smoke',
|
||||
env: { DSH_REQUIRE_BUILT_CLI_SMOKE: '1' },
|
||||
needs: ['build:web'],
|
||||
}),
|
||||
)
|
||||
}
|
||||
return gates
|
||||
}
|
||||
|
||||
/** Active Node major used to scope version-specific compatibility contracts. */
|
||||
function runningNodeMajor(): number {
|
||||
const major = Number.parseInt(process.versions.node.split('.')[0] ?? '', 10)
|
||||
if (!Number.isSafeInteger(major)) {
|
||||
throw new Error(`run-gates: cannot parse Node version ${JSON.stringify(process.versions.node)}.`)
|
||||
}
|
||||
return major
|
||||
}
|
||||
|
||||
function ciStaticGates(options: { ownsBuild: boolean }): Gate[] {
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -1,11 +1,15 @@
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { Context, Service } from 'cordis'
|
||||
import { Context, FiberState, Service, ValidationError } from 'cordis'
|
||||
import Loader from '@cordisjs/plugin-loader'
|
||||
import z from 'schemastery'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants'
|
||||
import { packageInvariantOwners } from './package-invariants.ts'
|
||||
import {
|
||||
TEST_INVARIANT_READY_SERVICE,
|
||||
testInvariantCompanionPaths,
|
||||
testInvariantCompanions,
|
||||
type TestInvariantCompanion,
|
||||
usesManualInvariantTree,
|
||||
} from './test-invariants.ts'
|
||||
|
||||
@@ -21,6 +25,87 @@ class TestInvariantProbe extends Service {
|
||||
}
|
||||
}
|
||||
|
||||
function deferred(): { readonly promise: Promise<void>; readonly resolve: () => void } {
|
||||
let resolve!: () => void
|
||||
const promise = new Promise<void>((done) => {
|
||||
resolve = done
|
||||
})
|
||||
return { promise, resolve }
|
||||
}
|
||||
|
||||
function requiredConfig() {
|
||||
return z.object({
|
||||
requiredValue: z.string().required(),
|
||||
})
|
||||
}
|
||||
|
||||
function queuedReadinessConfig(
|
||||
ctx: Context,
|
||||
onPublished: (dispose: () => void) => void,
|
||||
) {
|
||||
return z.transform(z.any(), () => {
|
||||
queueMicrotask(() => {
|
||||
onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true))
|
||||
})
|
||||
return {}
|
||||
}, true)
|
||||
}
|
||||
|
||||
function invalidConfigApply(): never {
|
||||
throw new Error('invalid plugin apply executed')
|
||||
}
|
||||
|
||||
async function rejectionOf(fiber: ReturnType<Context['plugin']>): Promise<unknown> {
|
||||
return fiber.then(
|
||||
() => undefined,
|
||||
(error: unknown) => error,
|
||||
)
|
||||
}
|
||||
|
||||
function expectRequiredConfigValidation(error: unknown): void {
|
||||
expect(error).toBeInstanceOf(ValidationError)
|
||||
expect(error).toHaveProperty('message', expect.stringMatching(/requiredValue/))
|
||||
}
|
||||
|
||||
async function withFakeCompanions(
|
||||
create: (path: string, index: number) => () => Promise<TestInvariantCompanion>,
|
||||
run: () => Promise<void>,
|
||||
): Promise<void> {
|
||||
const mutable = testInvariantCompanions as Record<string, () => Promise<TestInvariantCompanion>>
|
||||
const originals = Object.entries(mutable)
|
||||
for (const [index, [path]] of originals.entries()) {
|
||||
mutable[path] = create(path, index)
|
||||
}
|
||||
try {
|
||||
await run()
|
||||
} finally {
|
||||
for (const [path, load] of originals) {
|
||||
mutable[path] = load
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function withDelayedFirstCompanion(
|
||||
run: (control: { readonly started: Promise<void>; readonly release: () => void }) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const started = deferred()
|
||||
const release = deferred()
|
||||
await withFakeCompanions(
|
||||
(_path, index) => async () => ({
|
||||
name: `test-invariant-${index}`,
|
||||
inject: ['invariants'],
|
||||
async apply() {
|
||||
if (index === 0) {
|
||||
started.resolve()
|
||||
await release.promise
|
||||
}
|
||||
return () => {}
|
||||
},
|
||||
}),
|
||||
() => run({ started: started.promise, release: release.resolve }),
|
||||
)
|
||||
}
|
||||
|
||||
describe('global test invariant host', () => {
|
||||
it('uses one exhaustive topology to reserve every package name with enabled checks', async () => {
|
||||
const ctx = new Context()
|
||||
@@ -85,4 +170,291 @@ describe('global test invariant host', () => {
|
||||
expect(usesManualInvariantTree('/repo/packages/examples/agent-spine-demo/tests/agent-core.spec.ts')).toBe(true)
|
||||
expect(usesManualInvariantTree('/repo/packages/core/session/tests/session.spec.ts')).toBe(false)
|
||||
})
|
||||
|
||||
it('preserves config validation failures without starting the rejected plugin', async () => {
|
||||
const ctx = new Context()
|
||||
const apply = vi.fn(invalidConfigApply)
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: requiredConfig(),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const firstError = await rejectionOf(fiber)
|
||||
expectRequiredConfigValidation(firstError)
|
||||
await ctx.plugin(TestInvariantProbe)
|
||||
const secondError = await rejectionOf(fiber)
|
||||
expect(secondError).toBe(firstError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const apply = vi.fn(invalidConfigApply)
|
||||
let disposeQueuedReadiness: (() => void) | undefined
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: z.intersect([
|
||||
queuedReadinessConfig(ctx, (dispose) => {
|
||||
disposeQueuedReadiness = dispose
|
||||
}),
|
||||
requiredConfig(),
|
||||
]),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const firstError = await rejectionOf(fiber)
|
||||
expectRequiredConfigValidation(firstError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
|
||||
await started
|
||||
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
|
||||
disposeQueuedReadiness()
|
||||
release()
|
||||
await ctx.plugin(TestInvariantProbe)
|
||||
|
||||
const secondError = await rejectionOf(fiber)
|
||||
expect(secondError).toBe(firstError)
|
||||
expect(fiber.state).toBe(FiberState.DISPOSED)
|
||||
expect(apply).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('retains a valid plugin failure when readiness wins the initial-probe race', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const failure = new Error('valid plugin apply failed')
|
||||
const applied = deferred()
|
||||
const apply = vi.fn(function validConfigApply() {
|
||||
applied.resolve()
|
||||
throw failure
|
||||
})
|
||||
let disposeQueuedReadiness: (() => void) | undefined
|
||||
const plugin = {
|
||||
apply,
|
||||
Config: queuedReadinessConfig(ctx, (dispose) => {
|
||||
disposeQueuedReadiness = dispose
|
||||
}),
|
||||
}
|
||||
|
||||
const fiber = ctx.plugin(plugin, {})
|
||||
const returnedError = rejectionOf(fiber)
|
||||
try {
|
||||
await Promise.all([started, applied.promise])
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
|
||||
if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published')
|
||||
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
|
||||
disposeQueuedReadiness()
|
||||
release()
|
||||
|
||||
expect(await returnedError).toBe(failure)
|
||||
expect(fiber.state).toBe(FiberState.FAILED)
|
||||
expect(apply).toHaveBeenCalledOnce()
|
||||
expect(ctx.registry.has(plugin)).toBe(true)
|
||||
expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1)
|
||||
} finally {
|
||||
Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE)
|
||||
disposeQueuedReadiness?.()
|
||||
release()
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('holds a root plugin until every lazy companion is active, then permits nested startup', async () => {
|
||||
const delayedStarted = deferred()
|
||||
const releaseDelayed = deferred()
|
||||
const order: string[] = []
|
||||
let delayedCompanion: TestInvariantCompanion | undefined
|
||||
const companionNestedApply = vi.fn(function companionNestedApply() {})
|
||||
|
||||
await withFakeCompanions(
|
||||
(path, index) => async () => {
|
||||
const companion: TestInvariantCompanion = {
|
||||
name: `test-invariant-${index}`,
|
||||
inject: ['invariants'],
|
||||
async apply(companionCtx) {
|
||||
order.push(`companion-start:${path}`)
|
||||
if (index === 0) {
|
||||
delayedStarted.resolve()
|
||||
await releaseDelayed.promise
|
||||
}
|
||||
if (index === 1) await companionCtx.plugin(companionNestedApply)
|
||||
order.push(`companion-active:${path}`)
|
||||
return () => {}
|
||||
},
|
||||
}
|
||||
if (index === 0) delayedCompanion = companion
|
||||
return companion
|
||||
},
|
||||
async () => {
|
||||
const ctx = new Context()
|
||||
ctx.provide('testInvariantTargetDependency', true)
|
||||
let nestedFiber: ReturnType<Context['plugin']> | undefined
|
||||
const nestedApply = vi.fn(function nestedApply() {
|
||||
order.push('nested')
|
||||
})
|
||||
const targetApply = Object.assign(vi.fn(function targetApply(targetCtx: Context) {
|
||||
order.push('target')
|
||||
nestedFiber = targetCtx.plugin(nestedApply)
|
||||
}), {
|
||||
inject: ['testInvariantTargetDependency'],
|
||||
})
|
||||
|
||||
const targetFiber = ctx.plugin(targetApply)
|
||||
expect(ctx.registry.get(targetApply)?.callback).toBe(targetApply)
|
||||
expect(targetFiber.inject).toEqual({
|
||||
testInvariantTargetDependency: null,
|
||||
[TEST_INVARIANT_READY_SERVICE]: null,
|
||||
})
|
||||
|
||||
await delayedStarted.promise
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(targetApply).not.toHaveBeenCalled()
|
||||
|
||||
releaseDelayed.resolve()
|
||||
await targetFiber
|
||||
if (nestedFiber === undefined) throw new Error('target did not register its nested plugin')
|
||||
await nestedFiber
|
||||
|
||||
expect(targetFiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(targetApply).toHaveBeenCalledOnce()
|
||||
expect(nestedApply).toHaveBeenCalledOnce()
|
||||
expect(companionNestedApply).toHaveBeenCalledOnce()
|
||||
const targetIndex = order.indexOf('target')
|
||||
expect(targetIndex).toBeGreaterThan(-1)
|
||||
expect(order.slice(0, targetIndex)).toHaveLength(Object.keys(testInvariantCompanions).length * 2)
|
||||
expect(order.at(-1)).toBe('nested')
|
||||
|
||||
if (delayedCompanion === undefined) throw new Error('delayed companion did not load')
|
||||
await ctx.plugin(InvariantService, { enabled: true })
|
||||
await ctx.plugin(delayedCompanion)
|
||||
expect(ctx.registry.get(InvariantService)?.fibers).toHaveLength(1)
|
||||
expect(ctx.registry.get(delayedCompanion)?.fibers).toHaveLength(1)
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('holds plugins registered on a root-derived context until companion readiness', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const rootApply = vi.fn(function rootApply() {})
|
||||
const derivedApply = vi.fn(function derivedApply() {})
|
||||
const derived = ctx.extend()
|
||||
.isolate('testInvariantDerived')
|
||||
.intercept('testInvariantDerived', {})
|
||||
|
||||
const rootFiber = ctx.plugin(rootApply)
|
||||
const derivedFiber = derived.plugin(derivedApply)
|
||||
|
||||
await started
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(rootApply).not.toHaveBeenCalled()
|
||||
expect(derivedApply).not.toHaveBeenCalled()
|
||||
expect(derivedFiber.inject).toEqual({
|
||||
[TEST_INVARIANT_READY_SERVICE]: null,
|
||||
})
|
||||
|
||||
release()
|
||||
await Promise.all([rootFiber, derivedFiber])
|
||||
expect(rootFiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(derivedFiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(rootApply).toHaveBeenCalledOnce()
|
||||
expect(derivedApply).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it('holds a child registered externally on a pending target context', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const targetApply = vi.fn(function targetApply() {})
|
||||
const childApply = vi.fn(function childApply() {})
|
||||
|
||||
const targetFiber = ctx.plugin(targetApply)
|
||||
const childFiber = targetFiber.ctx.plugin(childApply)
|
||||
|
||||
await started
|
||||
await Promise.resolve()
|
||||
await Promise.resolve()
|
||||
expect(targetFiber.state).toBe(FiberState.PENDING)
|
||||
expect(childFiber.state).toBe(FiberState.PENDING)
|
||||
expect(targetApply).not.toHaveBeenCalled()
|
||||
expect(childApply).not.toHaveBeenCalled()
|
||||
expect(childFiber.inject).toEqual({
|
||||
[TEST_INVARIANT_READY_SERVICE]: null,
|
||||
})
|
||||
|
||||
release()
|
||||
await Promise.all([targetFiber, childFiber])
|
||||
expect(targetFiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(childFiber.state).toBe(FiberState.ACTIVE)
|
||||
expect(targetApply).toHaveBeenCalledOnce()
|
||||
expect(childApply).toHaveBeenCalledOnce()
|
||||
},
|
||||
)
|
||||
})
|
||||
|
||||
it.each(['load', 'startup'] as const)(
|
||||
'rejects a target when a lazy companion fails during %s without starting the target',
|
||||
async (phase) => {
|
||||
const failure = new Error(`test invariant companion ${phase} failed`)
|
||||
await withFakeCompanions(
|
||||
(_path, index) => phase === 'load' && index === 0
|
||||
? async () => { throw failure }
|
||||
: async () => ({
|
||||
name: `test-invariant-${index}`,
|
||||
inject: ['invariants'],
|
||||
async apply() {
|
||||
if (phase === 'startup' && index === 0) throw failure
|
||||
return () => {}
|
||||
},
|
||||
}),
|
||||
async () => {
|
||||
const ctx = new Context()
|
||||
const targetApply = vi.fn(function targetApply() {})
|
||||
const targetFiber = ctx.plugin(targetApply)
|
||||
|
||||
await expect(targetFiber).rejects.toBe(failure)
|
||||
expect(targetApply).not.toHaveBeenCalled()
|
||||
expect(targetFiber.state).toBe(FiberState.PENDING)
|
||||
await expect(targetFiber.dispose()).resolves.toBeUndefined()
|
||||
expect(targetFiber.state).toBe(FiberState.DISPOSED)
|
||||
},
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
it('disposes a pending target without waiting for companion readiness', async () => {
|
||||
await withDelayedFirstCompanion(
|
||||
async ({ started, release }) => {
|
||||
const ctx = new Context()
|
||||
const targetApply = vi.fn(function targetApply() {})
|
||||
const targetFiber = ctx.plugin(targetApply)
|
||||
|
||||
await started
|
||||
await expect(targetFiber.dispose()).resolves.toBeUndefined()
|
||||
expect(targetFiber.state).toBe(FiberState.DISPOSED)
|
||||
expect(targetApply).not.toHaveBeenCalled()
|
||||
|
||||
release()
|
||||
await targetFiber
|
||||
expect(targetApply).not.toHaveBeenCalled()
|
||||
},
|
||||
)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
*/
|
||||
|
||||
import { expect } from 'vitest'
|
||||
import { RegistryService } from 'cordis'
|
||||
import { FiberState, Inject, RegistryService } from 'cordis'
|
||||
import type { Context, Plugin } from 'cordis'
|
||||
import InvariantService from '@deepseek-ai/dsh-invariants'
|
||||
|
||||
@@ -25,6 +25,9 @@ export interface TestInvariantCompanion {
|
||||
apply(ctx: Context): Promise<() => void>
|
||||
}
|
||||
|
||||
/** Private service dependency that holds ordinary root plugins until invariant startup completes. */
|
||||
export const TEST_INVARIANT_READY_SERVICE = 'testInvariantReady'
|
||||
|
||||
/**
|
||||
* Every package companion as a lazy loader keyed by glob path. Ordinary tests
|
||||
* load only their owner's module; the exhaustive topology test loads and
|
||||
@@ -43,10 +46,12 @@ const MANUAL_INVARIANT_TEST_EXCEPTIONS = [
|
||||
|
||||
interface InvariantHost {
|
||||
readonly byCallback: ReadonlyMap<unknown, PluginFiber>
|
||||
readonly barrierOwners: WeakSet<Context['fiber']>
|
||||
readonly ready: Promise<void>
|
||||
}
|
||||
|
||||
type PluginFiber = ReturnType<RegistryService['plugin']>
|
||||
type PluginCallback = Plugin.Function | Plugin.Constructor
|
||||
|
||||
const hosts = new WeakMap<Context, InvariantHost>()
|
||||
// oxlint-disable-next-line typescript/unbound-method -- every call below supplies its RegistryService receiver explicitly.
|
||||
@@ -61,14 +66,28 @@ RegistryService.prototype.plugin = function(plugin: Plugin, config?: unknown, ge
|
||||
const callback = this.resolve(plugin)
|
||||
const existing = callback === undefined ? undefined : host.byCallback.get(callback)
|
||||
if (existing !== undefined) {
|
||||
return this.ctx === root ? joinInvariantStartup(existing, host.ready) : existing
|
||||
return hasBarrierOwner(host, this.ctx) ? existing : joinInvariantStartup(existing, host.ready)
|
||||
}
|
||||
|
||||
const fiber = originalPlugin.call(this, plugin, config, getOuterStack)
|
||||
// A root-level await is the test's composition boundary. Nested plugin
|
||||
// fibers must not await their own companion parent through the global host.
|
||||
if (this.ctx !== root) return fiber
|
||||
return joinInvariantStartup(fiber, host.ready)
|
||||
// Causal descendants of a gated target have already crossed the barrier.
|
||||
// Host service and companion descendants also bypass it so their own startup
|
||||
// cannot depend on the readiness they are responsible for providing.
|
||||
if (hasBarrierOwner(host, this.ctx)) {
|
||||
return originalPlugin.call(this, plugin, config, getOuterStack)
|
||||
}
|
||||
if (callback === undefined) {
|
||||
return originalPlugin.call(this, plugin, config, getOuterStack)
|
||||
}
|
||||
|
||||
const fiber = originalPlugin.call(
|
||||
this,
|
||||
withInvariantReadiness(plugin, callback as PluginCallback),
|
||||
config,
|
||||
getOuterStack,
|
||||
)
|
||||
const initiallyPending = fiber.ctx.fiber.state === FiberState.PENDING
|
||||
host.barrierOwners.add(fiber.ctx.fiber)
|
||||
return joinInvariantStartup(fiber, host.ready, initiallyPending)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -108,11 +127,13 @@ export function testInvariantCompanionPaths(testPath: string): string[] {
|
||||
|
||||
function startInvariantHost(root: Context): InvariantHost {
|
||||
const byCallback = new Map<unknown, PluginFiber>()
|
||||
const barrierOwners = new WeakSet<Context['fiber']>()
|
||||
const mount = (plugin: Plugin, config?: unknown): PluginFiber => {
|
||||
const fiber = originalPlugin.call(root.registry, plugin, config)
|
||||
const callback = root.registry.resolve(plugin)
|
||||
if (callback === undefined) throw new Error('test invariants: companion is not a valid Cordis plugin')
|
||||
byCallback.set(callback, fiber)
|
||||
barrierOwners.add(fiber.ctx.fiber)
|
||||
return fiber
|
||||
}
|
||||
|
||||
@@ -126,8 +147,8 @@ function startInvariantHost(root: Context): InvariantHost {
|
||||
const serviceFiber = mount(InvariantService, { enabled: true })
|
||||
const testPath = expect.getState().testPath ?? ''
|
||||
const companionPaths = testInvariantCompanionPaths(testPath)
|
||||
const ready = serviceFiber.await().then(async () => {
|
||||
const companionFibers = await Promise.all(companionPaths.map(async (path) => {
|
||||
const ready = requireActive(serviceFiber, 'invariant service').then(async () => {
|
||||
const companions = await Promise.all(companionPaths.map(async (path) => {
|
||||
const load = testInvariantCompanions[path]
|
||||
if (load === undefined) {
|
||||
throw new Error(`test invariants: selected companion vanished at ${path}`)
|
||||
@@ -136,20 +157,75 @@ function startInvariantHost(root: Context): InvariantHost {
|
||||
if (!companion.inject.includes('invariants')) {
|
||||
throw new Error(`test invariants: ${path} must inject the invariant service`)
|
||||
}
|
||||
return mount(companion)
|
||||
return { companion, path }
|
||||
}))
|
||||
await Promise.all(companionFibers.map(fiber => fiber.await()))
|
||||
const companionFibers = companions.map(({ companion, path }) => ({
|
||||
fiber: mount(companion),
|
||||
path,
|
||||
}))
|
||||
await Promise.all(companionFibers.map(({ fiber, path }) => requireActive(fiber, path)))
|
||||
root.provide(TEST_INVARIANT_READY_SERVICE, true)
|
||||
})
|
||||
const host = { byCallback, ready }
|
||||
const host = { byCallback, barrierOwners, ready }
|
||||
hosts.set(root, host)
|
||||
return host
|
||||
}
|
||||
|
||||
function joinInvariantStartup(fiber: PluginFiber, invariantReady: Promise<void>): PluginFiber {
|
||||
const readiness = fiber.await().then(async (loaded) => {
|
||||
await invariantReady
|
||||
return loaded
|
||||
})
|
||||
function hasBarrierOwner(host: InvariantHost, ctx: Context): boolean {
|
||||
let fiber = ctx.fiber
|
||||
while (true) {
|
||||
if (
|
||||
host.barrierOwners.has(fiber)
|
||||
&& (fiber.state === FiberState.LOADING || fiber.state === FiberState.ACTIVE)
|
||||
) {
|
||||
return true
|
||||
}
|
||||
const parent = fiber.parent.fiber
|
||||
if (parent === fiber) return false
|
||||
fiber = parent
|
||||
}
|
||||
}
|
||||
|
||||
async function requireActive(fiber: PluginFiber, label: string): Promise<void> {
|
||||
await fiber.await()
|
||||
if (fiber.state !== FiberState.ACTIVE) {
|
||||
throw new Error(`test invariants: ${label} settled without becoming active`)
|
||||
}
|
||||
}
|
||||
|
||||
function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugin.Object {
|
||||
return {
|
||||
apply: callback as Plugin.Function,
|
||||
inject: {
|
||||
...Inject.resolve(plugin.inject),
|
||||
[TEST_INVARIANT_READY_SERVICE]: null,
|
||||
},
|
||||
...(plugin.name === undefined ? {} : { name: plugin.name }),
|
||||
...(plugin.Config === undefined ? {} : { Config: plugin.Config }),
|
||||
...(plugin.provide === undefined ? {} : { provide: plugin.provide }),
|
||||
...(plugin.intercept === undefined ? {} : { intercept: plugin.intercept }),
|
||||
}
|
||||
}
|
||||
|
||||
function joinInvariantStartup(
|
||||
fiber: PluginFiber,
|
||||
invariantReady: Promise<void>,
|
||||
disposeInitialFailure = false,
|
||||
): PluginFiber {
|
||||
// RegistryService returns a thenable wrapper whose context still points to
|
||||
// the raw Fiber. Calling inherited await() on the wrapper would return and
|
||||
// assimilate that thenable, accidentally following later plugin startup.
|
||||
const rawFiber = fiber.ctx.fiber
|
||||
const initialized = disposeInitialFailure
|
||||
? rawFiber.await().catch(async (error: unknown) => {
|
||||
// Config validation is the only failure recorded while a gated fiber
|
||||
// is initially PENDING. Dispose it even if queued readiness publication
|
||||
// changes its state before this rejection handler runs.
|
||||
await rawFiber.dispose()
|
||||
throw error
|
||||
})
|
||||
: Promise.resolve()
|
||||
const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await())
|
||||
const joined = Object.create(fiber) as PluginFiber
|
||||
joined.then = readiness.then.bind(readiness)
|
||||
return joined
|
||||
|
||||
@@ -246,6 +246,11 @@
|
||||
"symbol": "AssembleContext",
|
||||
"source": "packages/core/system-prompt/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/system-prompt.md",
|
||||
"symbol": "PromptContext",
|
||||
"source": "packages/core/system-prompt/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/system-prompt.md",
|
||||
"symbol": "PromptSection",
|
||||
@@ -323,6 +328,11 @@
|
||||
"symbol": "EpochHeader",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "RequestContext",
|
||||
"source": "packages/core/session/src/types.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/session.md",
|
||||
"symbol": "TodoItem",
|
||||
@@ -1029,6 +1039,11 @@
|
||||
"symbol": "CompactionTrigger",
|
||||
"source": "packages/compact/compact/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.md",
|
||||
"symbol": "ManualCompactionErrorCode",
|
||||
"source": "packages/compact/compact/src/index.ts"
|
||||
},
|
||||
{
|
||||
"doc": "docs/core-data-structures/compaction.md",
|
||||
"symbol": "PrunedEntry",
|
||||
|
||||
@@ -31,7 +31,10 @@ interface PluginReference {
|
||||
const root = resolve(import.meta.dirname, '..')
|
||||
// These example files are overlays consumed by the built dsh app, so their bare
|
||||
// specifiers resolve from apps/cli rather than the examples workspace.
|
||||
const appOverlayFiles = new Set(['examples/web-cordis/cordis.yml'])
|
||||
const appOverlayFiles = new Set([
|
||||
'examples/web-cordis/cordis.yml',
|
||||
...globSync('examples/mcp-memory/*.cordis.yml', { cwd: root }),
|
||||
])
|
||||
const metadataFields = ['id', 'name', 'group', 'disabled', 'inject', 'intercept', 'isolate'] as const
|
||||
|
||||
/** The adaptive directory-picker chooser package (mounts a backend row at boot). */
|
||||
|
||||
@@ -77,7 +77,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/examples/agent-spine-demo': { kind: 'indirect', reason: 'The bundle only mounts model-facing child plugins.' },
|
||||
'packages/fs/fs': { kind: 'indirect', reason: 'The service interface delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/fs/fs-sandbox': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-fs.' },
|
||||
'packages/hooks/hook-protocol': { kind: 'indirect', reason: 'Only the hook bridge plugins render decoded hook output to a model.' },
|
||||
'packages/host/apiproxy': { kind: 'none', reason: 'The wire contract and fetch carriers move already-composed messages and register no model surface.' },
|
||||
'packages/host/directory-picker': { kind: 'none', reason: 'The GUI-host picking seam registers no model surface.' },
|
||||
@@ -92,7 +91,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly<Record<string, SentenceContract>> = {
|
||||
'packages/subprocess/subprocess': { kind: 'indirect', reason: 'The seam delegates all model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/subprocess/subprocess-local': { kind: 'indirect', reason: 'The spawn backend delegates model rendering to consumer seams such as the bash executor family.' },
|
||||
'packages/sandbox/sandbox-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-bash-sandbox and dsh-tool-bash.' },
|
||||
'packages/sandbox/sandbox-policy': { kind: 'indirect', reason: 'The policy service holds the mode dsh-tool-bash and dsh-tool-fs render in their denial markers.' },
|
||||
'packages/sdk/create-sdk': { kind: 'indirect', reason: 'The initializer only writes project files; selected runtime plugins provide the generated project model surface.' },
|
||||
'packages/sdk/helper': { kind: 'none', reason: 'The project domain edits files and registers no live agent or model surface.' },
|
||||
'packages/sdk/scripts': { kind: 'indirect', reason: 'The launcher delegates model context to the loaded project plugin tree.' },
|
||||
|
||||
14
scripts/vitest-environment.compat.spec.ts
Normal file
14
scripts/vitest-environment.compat.spec.ts
Normal file
@@ -0,0 +1,14 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
describe('Vitest jsdom compatibility', () => {
|
||||
it('provides isolated browser storage instead of Node process storage', () => {
|
||||
if (process.allowedNodeEnvironmentFlags.has('--webstorage')) {
|
||||
expect(process.execArgv.filter(argument => argument === '--no-webstorage')).toHaveLength(1)
|
||||
}
|
||||
localStorage.setItem('dsh-vitest-storage-probe', 'available')
|
||||
|
||||
expect(localStorage.getItem('dsh-vitest-storage-probe')).toBe('available')
|
||||
localStorage.removeItem('dsh-vitest-storage-probe')
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user