diff --git a/.agents/notes/implemented/architecture/2026-08-16-desktop-logging.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-16-desktop-logging.i18n.yaml index ad319b04fa..2bdbe59b97 100644 --- a/.agents/notes/implemented/architecture/2026-08-16-desktop-logging.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-16-desktop-logging.i18n.yaml @@ -1,5 +1,5 @@ # Bilingual-pair consistency record: the git blob hash of each side as of the last # confirmed-consistent state. Both languages carry equal authority. Update both files # and re-record their hashes after editing either side. -2026-08-16-desktop-logging.md: 8e1df3e52fcc58688fac242a8688b1d18816edad -2026-08-16-desktop-logging.zh.md: 97346bb31546427cf9e98e9039c6ad6ba33663a5 +2026-08-16-desktop-logging.md: f97287a0b0ed06a08b31aeb0da303c3a23ea5310 +2026-08-16-desktop-logging.zh.md: f1a3b3717c5c59b151768ae4415abeb8b2f1f904 diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e15c4f8ba3..356d99818d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -41,6 +41,10 @@ jobs: HEAD_SHA: ${{ github.event.pull_request.head.sha || github.sha }} run: | set -euo pipefail + node --test scripts/bilingual-docs.test.mjs + node scripts/verify-bilingual-docs.mjs + node --test scripts/market-dependency-direction.test.mjs + node scripts/verify-market-dependency-direction.mjs if [[ "$EVENT_NAME" == "workflow_dispatch" || -z "$BASE_SHA" || "$BASE_SHA" =~ ^0+$ ]]; then product=true else diff --git a/package.json b/package.json index 10533b249d..6a8b560732 100644 --- a/package.json +++ b/package.json @@ -58,7 +58,11 @@ "build": "yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop build", "typecheck": "yarn workspace dsh-plugin-desktop typecheck && yarn workspace dsh-community-market typecheck", "test": "yarn workspace dsh-plugin-desktop test && yarn workspace dsh-community-market test", - "check:layout": "node scripts/verify-layout.mjs", + "test:bilingual-docs": "node --test scripts/bilingual-docs.test.mjs", + "check:bilingual-docs": "yarn test:bilingual-docs && node scripts/verify-bilingual-docs.mjs", + "test:architecture-gates": "node --test scripts/market-dependency-direction.test.mjs", + "check:architecture": "yarn test:architecture-gates && node scripts/verify-market-dependency-direction.mjs", + "check:layout": "yarn check:bilingual-docs && yarn check:architecture && node scripts/verify-layout.mjs", "check": "yarn check:layout && yarn workspace dsh-community-fabric check && yarn workspace dsh-community-market check && yarn workspace dsh-plugin-desktop check", "dev": "yarn workspace dsh-community-market build && yarn workspace dsh-plugin-desktop dev", "start": "yarn workspace dsh-plugin-desktop start", diff --git a/scripts/bilingual-docs.mjs b/scripts/bilingual-docs.mjs new file mode 100644 index 0000000000..95a7f0a682 --- /dev/null +++ b/scripts/bilingual-docs.mjs @@ -0,0 +1,102 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { basename, dirname, isAbsolute, relative, resolve } from 'node:path' + +const HASH_PATTERN = /^[0-9a-f]{40}$/u +const DOCUMENT_PATTERN = /\.mdx?$/u + +function normalizePath(path) { + return path.replaceAll('\\', '/') +} + +function runGit(root, args) { + return execFileSync('git', args, { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) +} + +export function parseBilingualRecord(recordPath, content) { + const entries = [] + for (const [index, line] of content.split(/\r?\n/u).entries()) { + const trimmed = line.trim() + if (trimmed.length === 0 || trimmed.startsWith('#')) continue + const match = /^([^:]+):\s*([0-9a-f]{40})$/u.exec(trimmed) + if (match === null) { + throw new Error(`${recordPath}:${String(index + 1)} is not a filename and Git blob hash`) + } + const filename = match[1]?.trim() ?? '' + const hash = match[2] ?? '' + if (filename !== basename(filename) || isAbsolute(filename) || !DOCUMENT_PATTERN.test(filename)) { + throw new Error(`${recordPath}:${String(index + 1)} must name one Markdown file beside the record`) + } + if (!HASH_PATTERN.test(hash)) { + throw new Error(`${recordPath}:${String(index + 1)} has an invalid Git blob hash`) + } + entries.push({ filename, hash }) + } + if (entries.length !== 2) { + throw new Error(`${recordPath} must contain exactly two bilingual document records`) + } + if (entries[0]?.filename === entries[1]?.filename) { + throw new Error(`${recordPath} must reference two distinct bilingual documents`) + } + return entries +} + +export function verifyBilingualRecords({ root, recordPaths, readText, hashDocument }) { + let documentCount = 0 + const violations = [] + for (const recordPath of recordPaths) { + const normalizedRecord = normalizePath(recordPath) + const recordDirectory = dirname(resolve(root, normalizedRecord)) + let entries + try { + entries = parseBilingualRecord(normalizedRecord, readText(resolve(root, normalizedRecord))) + } catch (cause) { + violations.push(cause instanceof Error ? cause.message : String(cause)) + continue + } + for (const entry of entries) { + const documentPath = resolve(recordDirectory, entry.filename) + const relativeDocument = normalizePath(relative(root, documentPath)) + if (relativeDocument.startsWith('../') || isAbsolute(relativeDocument)) { + violations.push(`${normalizedRecord} references a document outside the repository`) + continue + } + try { + readText(documentPath) + const actual = hashDocument(relativeDocument) + if (actual !== entry.hash) { + violations.push(`${normalizedRecord} is stale for ${relativeDocument}: expected ${actual}, recorded ${entry.hash}`) + } + } catch (cause) { + violations.push(`${normalizedRecord} cannot verify ${relativeDocument}: ${cause instanceof Error ? cause.message : String(cause)}`) + } + documentCount += 1 + } + } + if (violations.length > 0) { + throw new Error(`bilingual documentation gate failed:\n- ${violations.join('\n- ')}`) + } + return { recordCount: recordPaths.length, documentCount } +} + +export function verifyTrackedBilingualRecords(root) { + const recordPaths = runGit(root, ['ls-files', '-z', '--', '*.i18n.yaml']) + .split('\0') + .filter(Boolean) + .map(normalizePath) + return verifyBilingualRecords({ + root, + recordPaths, + readText: path => readFileSync(path, 'utf8'), + hashDocument: relativePath => runGit(root, [ + 'hash-object', + `--path=${relativePath}`, + '--', + relativePath, + ]).trim(), + }) +} diff --git a/scripts/bilingual-docs.test.mjs b/scripts/bilingual-docs.test.mjs new file mode 100644 index 0000000000..ff194c037c --- /dev/null +++ b/scripts/bilingual-docs.test.mjs @@ -0,0 +1,85 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { resolve } from 'node:path' +import { + parseBilingualRecord, + verifyBilingualRecords, +} from './bilingual-docs.mjs' + +test('parses exactly two Markdown blob records', () => { + assert.deepEqual(parseBilingualRecord('docs/topic.i18n.yaml', [ + '# Both languages carry equal authority.', + `topic.md: ${'a'.repeat(40)}`, + `topic.zh.md: ${'b'.repeat(40)}`, + '', + ].join('\n')), [ + { filename: 'topic.md', hash: 'a'.repeat(40) }, + { filename: 'topic.zh.md', hash: 'b'.repeat(40) }, + ]) +}) + +test('rejects malformed, incomplete, and escaping bilingual records', () => { + assert.throws( + () => parseBilingualRecord('docs/topic.i18n.yaml', `topic.md: ${'a'.repeat(40)}\n`), + /exactly two/u, + ) + assert.throws( + () => parseBilingualRecord('docs/topic.i18n.yaml', `topic.md = ${'a'.repeat(40)}\ntopic.zh.md: ${'b'.repeat(40)}\n`), + /not a filename/u, + ) + assert.throws( + () => parseBilingualRecord('docs/topic.i18n.yaml', `../topic.md: ${'a'.repeat(40)}\ntopic.zh.md: ${'b'.repeat(40)}\n`), + /beside the record/u, + ) +}) + +test('verifies every recorded document hash', () => { + const root = resolve('repository') + const recordPath = 'docs/topic.i18n.yaml' + const record = `topic.md: ${'a'.repeat(40)}\ntopic.zh.md: ${'b'.repeat(40)}\n` + const reads = new Map([ + [resolve(root, recordPath), record], + [resolve(root, 'docs/topic.md'), 'English'], + [resolve(root, 'docs/topic.zh.md'), 'Chinese'], + ]) + const result = verifyBilingualRecords({ + root, + recordPaths: [recordPath], + readText: path => { + const value = reads.get(path) + if (value === undefined) throw new Error(`missing ${path}`) + return value + }, + hashDocument: path => path.endsWith('.zh.md') ? 'b'.repeat(40) : 'a'.repeat(40), + }) + assert.deepEqual(result, { recordCount: 1, documentCount: 2 }) + assert.throws(() => verifyBilingualRecords({ + root, + recordPaths: [recordPath], + readText: path => reads.get(path) ?? '', + hashDocument: () => 'c'.repeat(40), + }), /is stale/u) +}) + +test('reports malformed records and missing documents in one failure', () => { + const root = resolve('repository') + const records = new Map([ + [resolve(root, 'docs/broken.i18n.yaml'), 'not a record\n'], + [resolve(root, 'docs/missing.i18n.yaml'), [ + `missing.md: ${'a'.repeat(40)}`, + `missing.zh.md: ${'b'.repeat(40)}`, + '', + ].join('\n')], + ]) + + assert.throws(() => verifyBilingualRecords({ + root, + recordPaths: ['docs/broken.i18n.yaml', 'docs/missing.i18n.yaml'], + readText: path => { + const value = records.get(path) + if (value === undefined) throw new Error(`missing ${path}`) + return value + }, + hashDocument: () => 'a'.repeat(40), + }), /broken\.i18n\.yaml:1[\s\S]*cannot verify docs\/missing\.md[\s\S]*cannot verify docs\/missing\.zh\.md/u) +}) diff --git a/scripts/market-dependency-direction.mjs b/scripts/market-dependency-direction.mjs new file mode 100644 index 0000000000..3ca44c205c --- /dev/null +++ b/scripts/market-dependency-direction.mjs @@ -0,0 +1,41 @@ +import { execFileSync } from 'node:child_process' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' + +const IMPORT_SPECIFIER = /(?:\bfrom\s*|\bimport\s*\(\s*|\brequire\s*\(\s*|\bimport\s*)['"]([^'"]+)['"]/gu +const DESKTOP_MODULE = /(?:^|\/)dsh-plugin-desktop(?:\/|$)/u + +function normalizePath(path) { + return path.replaceAll('\\', '/') +} + +export function verifyMarketDependencyDirection(files) { + const violations = [] + for (const [path, source] of files) { + for (const match of source.matchAll(IMPORT_SPECIFIER)) { + const specifier = match[1] ?? '' + if (DESKTOP_MODULE.test(normalizePath(specifier))) { + violations.push(normalizePath(path)) + break + } + } + } + if (violations.length > 0) { + throw new Error(`Market source must not import Desktop implementation: ${violations.join(', ')}`) + } + return { fileCount: files.length } +} + +export function verifyTrackedMarketDependencyDirection(root) { + const paths = execFileSync('git', ['ls-files', '-z', '--', 'dsh-community-market/src'], { + cwd: root, + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }) + .split('\0') + .filter(path => /\.[cm]?[jt]sx?$/u.test(path)) + .map(normalizePath) + return verifyMarketDependencyDirection( + paths.map(path => [path, readFileSync(resolve(root, path), 'utf8')]), + ) +} diff --git a/scripts/market-dependency-direction.test.mjs b/scripts/market-dependency-direction.test.mjs new file mode 100644 index 0000000000..a1182e818d --- /dev/null +++ b/scripts/market-dependency-direction.test.mjs @@ -0,0 +1,21 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { verifyMarketDependencyDirection } from './market-dependency-direction.mjs' + +test('allows product-name data in Market source', () => { + assert.deepEqual(verifyMarketDependencyDirection([ + ['dsh-community-market/src/install.ts', "const blocked = new Set(['dsh-plugin-desktop'])\n"], + ]), { fileCount: 1 }) +}) + +test('rejects Market imports of Desktop implementation', () => { + assert.throws(() => verifyMarketDependencyDirection([ + ['dsh-community-market/src/index.ts', "import type { DesktopRuntime } from 'dsh-plugin-desktop/runtime'\n"], + ]), /must not import Desktop implementation/u) + assert.throws(() => verifyMarketDependencyDirection([ + ['dsh-community-market/src/index.ts', "const desktop = await import('dsh-plugin-desktop')\n"], + ]), /must not import Desktop implementation/u) + assert.throws(() => verifyMarketDependencyDirection([ + ['dsh-community-market/src/index.ts', "export { runtime } from '../../dsh-plugin-desktop/src/runtime.js'\n"], + ]), /must not import Desktop implementation/u) +}) diff --git a/scripts/verify-bilingual-docs.mjs b/scripts/verify-bilingual-docs.mjs new file mode 100644 index 0000000000..5146526842 --- /dev/null +++ b/scripts/verify-bilingual-docs.mjs @@ -0,0 +1,9 @@ +import { resolve } from 'node:path' +import { verifyTrackedBilingualRecords } from './bilingual-docs.mjs' + +const root = resolve(import.meta.dirname, '..') +const result = verifyTrackedBilingualRecords(root) + +process.stdout.write( + `verify-bilingual-docs: ${String(result.recordCount)} records and ${String(result.documentCount)} documents are consistent\n`, +) diff --git a/scripts/verify-layout.mjs b/scripts/verify-layout.mjs index b72f7b96af..d49b1c2cb9 100644 --- a/scripts/verify-layout.mjs +++ b/scripts/verify-layout.mjs @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' -import { basename, resolve } from 'node:path' +import { resolve } from 'node:path' const root = resolve(import.meta.dirname, '..') const readJson = path => JSON.parse(readFileSync(resolve(root, path), 'utf8')) @@ -17,10 +17,6 @@ const plugin = readJson('dsh-plugin-desktop/package.json') const fabric = readJson('dsh-community-fabric/package.json') const market = readJson('dsh-community-market/package.json') const upstreamPackage = readJson('deepseek-harness/package.json') -const noteDirectory = '.agents/notes/implemented/process' -const noteName = '2026-08-15-pinned-upstream-and-isolated-yarn-workspace' -const notePaths = [`${noteDirectory}/${noteName}.md`, `${noteDirectory}/${noteName}.zh.md`] -const noteRecordPath = `${noteDirectory}/${noteName}.i18n.yaml` if (workspace.packageManager !== 'yarn@4.18.0') { fail('the product workspace must pin yarn@4.18.0') @@ -113,24 +109,4 @@ for (const name of Object.keys(plugin.dependencies).filter(name => name === '@de } } -const noteRecord = readFileSync(resolve(root, noteRecordPath), 'utf8') -for (const notePath of notePaths) { - // Hash the committed blob, not the working tree: checkout line endings - // differ per host, while HEAD: is identical everywhere. - const expected = run('git', ['rev-parse', `HEAD:${notePath}`]) - const recordLine = `${basename(notePath)}: ${expected}` - if (!noteRecord.split(/\r?\n/u).includes(recordLine)) { - fail(`${noteRecordPath} is stale for ${notePath}`) - } -} - -const readmeRecord = readFileSync(resolve(root, 'README.i18n.yaml'), 'utf8') -for (const readmeName of ['README.md', 'README.en.md']) { - const expected = run('git', ['rev-parse', `HEAD:${readmeName}`]) - const recordLine = `${readmeName}: ${expected}` - if (!readmeRecord.split(/\r?\n/u).includes(recordLine)) { - fail(`README.i18n.yaml is stale for ${readmeName}`) - } -} - process.stdout.write(`verify-layout: Yarn workspace and upstream ${upstream.commit.slice(0, 10)} are consistent\n`) diff --git a/scripts/verify-market-dependency-direction.mjs b/scripts/verify-market-dependency-direction.mjs new file mode 100644 index 0000000000..7bfc4bd663 --- /dev/null +++ b/scripts/verify-market-dependency-direction.mjs @@ -0,0 +1,9 @@ +import { resolve } from 'node:path' +import { verifyTrackedMarketDependencyDirection } from './market-dependency-direction.mjs' + +const root = resolve(import.meta.dirname, '..') +const result = verifyTrackedMarketDependencyDirection(root) + +process.stdout.write( + `verify-market-dependency-direction: ${String(result.fileCount)} Market source files are independent of Desktop implementation\n`, +)