Merge remote-tracking branch 'origin/master' into worktree/web-multimodal-image-input
# Conflicts: # packages/client/runtime/README.i18n.yaml # packages/client/ui-conversation/README.i18n.yaml # packages/client/ui-conversation/README.md # packages/client/ui-conversation/README.zh.md # packages/client/ui-conversation/src/client/chat/MessageItem.tsx
This commit is contained in:
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()
|
||||
}
|
||||
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user