doc-standard.spec.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270
  1. /**
  2. * Quick comprehensive documentation-standard tests: the reference example
  3. * stays valid, the consolidated `dsh-doc` skill carries no stale copied
  4. * website values or prototype-era language, and the kind system maps each
  5. * label to exactly one skill template. These run in `pnpm run test` and
  6. * `pnpm run test:docs` to guard the standard between heavier corpus gates.
  7. * @module scripts/doc-standard.spec
  8. */
  9. import { existsSync, globSync, readFileSync } from 'node:fs'
  10. import { resolve, sep } from 'node:path'
  11. import { load } from 'js-yaml'
  12. import { describe, expect, it } from 'vitest'
  13. const root = resolve(import.meta.dirname, '..')
  14. const PACKAGE_README_GLOBS = [
  15. 'packages/README.md',
  16. 'packages/README.zh.md',
  17. 'packages/*/README.md',
  18. 'packages/*/README.zh.md',
  19. 'packages/*/*/README.md',
  20. 'packages/*/*/README.zh.md',
  21. ] as const
  22. function packageReadmes(): string[] {
  23. return PACKAGE_README_GLOBS
  24. .flatMap(pattern => globSync(pattern, { cwd: root, exclude: ['**/node_modules/**'] }))
  25. .map(file => file.replaceAll('\\', '/'))
  26. .sort()
  27. }
  28. /**
  29. * The kind system: each label maps to exactly one template in the dsh-doc
  30. * skill. The check derives the expected kind from the same mechanical facts
  31. * the skill documents; a kind without a template, a template without a kind,
  32. * or a document whose kind does not match its position fails here.
  33. */
  34. const KIND_TEMPLATES: Readonly<Record<string, string>> = {
  35. 'package-group': '.agents/skills/dsh-doc/templates/package-group.md',
  36. 'package-reference': '.agents/skills/dsh-doc/templates/package-reference.md',
  37. 'package-library': '.agents/skills/dsh-doc/templates/package-library.md',
  38. 'package-bundle': '.agents/skills/dsh-doc/templates/package-bundle.md',
  39. }
  40. /**
  41. * Audited packages whose entry is a plain module API rather than a Cordis
  42. * plugin (`apply` export or a default service export) or an installable
  43. * bundle (`dsh.bundle.patch`). Each entry names why the package is a
  44. * library; the check re-derives the entry shape so a stale entry fails loud.
  45. */
  46. const PACKAGE_LIBRARIES: Readonly<Record<string, string>> = {
  47. 'packages/boot/app-boot': 'Boot library the app bins import; plain helper exports.',
  48. 'packages/boot/cmdline': 'Command-line library the app bins import; plain module exports.',
  49. 'packages/client/store': 'Browser-side state primitives; plain function/type exports.',
  50. 'packages/client/ui-primitives': 'Browser-side UI component library; plain component exports.',
  51. 'packages/client/ui-slots': 'Browser-side slot-map declarations; plain type exports.',
  52. 'packages/client/web': 'Browser application boot library; exports the app entry and static module table.',
  53. 'packages/core/scope': 'Scoped-context primitives; exports functions and types without a plugin entry.',
  54. 'packages/experimental/webworker-packer': 'Build-time VFS image packer and command library.',
  55. 'packages/experimental/webworker-runtime': 'Browser worker runtime library with explicit host entry points.',
  56. 'packages/hooks/hook-protocol': 'Shared wire-protocol library between the hook bridges.',
  57. 'packages/identity/anonymous-user-id': 'Harness-home identity helper with no plugin registration.',
  58. 'packages/sandbox/sandbox-windows-acl': 'Windows ACL sandbox library consumed by sandbox-local.',
  59. 'packages/sdk/client': 'Client-process library; the spawned runtime owns plugin behavior.',
  60. 'packages/sdk/protocol': 'Wire-protocol library with type declarations only.',
  61. 'packages/session/session-format': 'Pure Session format planning, codec dispatch, and lossless JSON library.',
  62. 'packages/session/session-format-catalog': 'Generated build-static Session format inventory with no plugin registration.',
  63. 'packages/session/session-format-v0-to-v1': 'Pure released-v0 codec and adjacent migration library.',
  64. 'packages/session/session-telemetry': 'Telemetry Service Definition and capture library; providers mount the backend.',
  65. 'packages/session/session-title-llm': 'Shared LLM title-provider registration and request policy.',
  66. 'packages/subagent/subagent-in-process-driver': 'Shared one-shot child-agent driver used by provider plugins.',
  67. 'packages/subprocess/win32-process': 'Low-level Win32 process and Job Object primitives.',
  68. 'packages/test-support/session-snapshot': 'Test infrastructure; mounts nothing into a product composition.',
  69. 'packages/test-support/agent-loop-testkit': 'Test helper library; mounts nothing into a product composition.',
  70. 'packages/test-support/client-runtime': 'Browser-side test infrastructure.',
  71. 'packages/test-support/llm-mock-server': 'Test server library; substitutes provider wire behavior.',
  72. 'packages/test-support/loader-smoke': 'Test harness library; mounts nothing into a product composition.',
  73. 'packages/typert/generator': 'Build-time generator run outside any agent runtime.',
  74. 'packages/typert/protocol': 'Compiler-independent protocol declarations.',
  75. 'packages/util/atomic-write': 'Zero-dependency filesystem write utility.',
  76. 'packages/util/brand': 'Stateless nominal-string and canonical-key constructors.',
  77. 'packages/util/crypto': 'Zero-dependency identifier minting utility.',
  78. 'packages/util/deque': 'Zero-dependency circular deque utility.',
  79. 'packages/util/home-paths': 'Zero-dependency harness-home path resolver.',
  80. 'packages/util/launch-environment': 'Zero-dependency environment resolver.',
  81. 'packages/util/native-command': 'Host-side subprocess runner utility.',
  82. 'packages/util/output-retention': 'Zero-dependency retention utility.',
  83. 'packages/util/time': 'Zero-dependency time-zone canonicalization utility.',
  84. 'packages/util/timeout': 'Zero-dependency timeout utility.',
  85. 'packages/util/values': 'Stateless lossless-JSON and immutable-value helpers.',
  86. 'packages/util/workspace-path': 'Zero-dependency Workspace path formatter.',
  87. }
  88. function readFrontmatter(file: string): Record<string, unknown> {
  89. const source = readFileSync(resolve(root, file), 'utf8')
  90. const match = /^---\n([\s\S]*?)\n---\n/u.exec(source)
  91. expect(match, `${file}: YAML frontmatter`).not.toBeNull()
  92. const metadata = load(match?.[1] ?? '')
  93. expect(metadata, `${file}: frontmatter object`).toBeTypeOf('object')
  94. expect(Array.isArray(metadata), `${file}: frontmatter object`).toBe(false)
  95. return metadata as Record<string, unknown>
  96. }
  97. function packageDir(file: string): string {
  98. return file.replaceAll('\\', '/').replace(/\/README\.zh\.md$/, '').replace(/\/README\.md$/, '')
  99. }
  100. /** Whether the package manifest declares `dsh.bundle.patch`. */
  101. function declaresBundle(dir: string): boolean {
  102. const manifest = resolve(root, dir, 'package.json')
  103. if (!existsSync(manifest)) return false
  104. const metadata = JSON.parse(readFileSync(manifest, 'utf8')) as { dsh?: { bundle?: { patch?: string } } }
  105. return metadata.dsh?.bundle?.patch !== undefined
  106. }
  107. /** The expected kind for one package README, from the facts the skill documents. */
  108. function expectedKind(file: string): string {
  109. const normalized = file.replaceAll('\\', '/')
  110. if (normalized.split('/').length <= 3) return 'package-group'
  111. const dir = packageDir(normalized)
  112. if (declaresBundle(dir)) return 'package-bundle'
  113. if (Object.hasOwn(PACKAGE_LIBRARIES, dir)) return 'package-library'
  114. return 'package-reference'
  115. }
  116. function packageReadmeMetadataErrors(file: string, metadata: Record<string, unknown>): string[] {
  117. const errors: string[] = []
  118. if (metadata.kind !== expectedKind(file)) errors.push(`kind must be ${expectedKind(file)}`)
  119. if (typeof metadata.description !== 'string' || metadata.description.trim() === '') {
  120. errors.push('description must be a non-empty string')
  121. }
  122. for (const field of ['name', 'audience', 'tags', 'i18n']) {
  123. if (field in metadata) errors.push(`${field} is redundant or has no governed consumer`)
  124. }
  125. return errors
  126. }
  127. function packageReadmeStructureErrors(file: string, source: string): string[] {
  128. const chinese = file.endsWith('.zh.md')
  129. const required = chinese
  130. ? [[/^## 概述$/m, '概述'], [/^## 目录$/m, '目录'], [/^#{2,3} 开发备注$/m, '开发备注']] as const
  131. : [[/^## Summary$/m, 'Summary'], [/^## Table of Contents$/m, 'Table of Contents'], [/^#{2,3} Dev Note$/m, 'Dev Note']] as const
  132. return required.flatMap(([pattern, label]) => pattern.test(source) ? [] : [`missing ${label}`])
  133. }
  134. describe('dsh-doc skill consolidation', () => {
  135. it('carries no prototype-era language', () => {
  136. const files = [
  137. '.agents/skills/dsh-doc/SKILL.md',
  138. '.agents/skills/dsh-doc/references/metadata-links-i18n.md',
  139. '.agents/skills/dsh-doc/references/structure-hierarchy.md',
  140. '.agents/skills/dsh-doc/references/style.md',
  141. '.agents/skills/dsh-doc/references/review.md',
  142. '.agents/skills/dsh-doc/references/website-sync.md',
  143. ]
  144. for (const file of files) {
  145. const source = readFileSync(resolve(root, file), 'utf8')
  146. expect(source, file).not.toMatch(/\bprototype\b/i)
  147. }
  148. })
  149. it('copies no stale website sidebar or section-owner values', () => {
  150. const source = readFileSync(resolve(root, '.agents/skills/dsh-doc/references/website-sync.md'), 'utf8')
  151. expect(source).not.toContain('en-docs')
  152. expect(source).not.toContain('sectionOrder')
  153. })
  154. it('keeps the reference example linked from the skill', () => {
  155. const skill = readFileSync(resolve(root, '.agents/skills/dsh-doc/SKILL.md'), 'utf8')
  156. expect(skill).toContain('session-persistence-jsonl/README.md')
  157. expect(skill).toContain('session-persistence-jsonl/README.zh.md')
  158. })
  159. it('defines controlled English as a precision-preserving review discipline', () => {
  160. const skill = readFileSync(resolve(root, '.agents/skills/dsh-doc/SKILL.md'), 'utf8')
  161. const style = readFileSync(resolve(root, '.agents/skills/dsh-doc/references/style.md'), 'utf8')
  162. expect(skill).toContain('references/style.md#controlled-technical-english')
  163. expect(style).toContain('not certified ASD-STE100 compliance')
  164. expect(style).toContain('review prompts, not mechanical gates')
  165. expect(style).toContain('Never remove or strengthen `must`, `may`, `never`')
  166. })
  167. it('maps every kind label to exactly one skill template that exists', () => {
  168. const templateFiles = globSync('.agents/skills/dsh-doc/templates/*.md', { cwd: root }).map(path => path.split(sep).join('/')).sort()
  169. const registered = Object.values(KIND_TEMPLATES).sort()
  170. expect(templateFiles).toEqual(registered)
  171. for (const [kind, template] of Object.entries(KIND_TEMPLATES)) {
  172. expect(existsSync(resolve(root, template)), `${kind}: template ${template}`).toBe(true)
  173. }
  174. })
  175. it('maps package README kinds to their documentation standards', () => {
  176. const files = packageReadmes()
  177. expect(files.length).toBeGreaterThan(0)
  178. for (const file of files) {
  179. const metadata = readFrontmatter(file)
  180. expect(packageReadmeMetadataErrors(file, metadata), file).toEqual([])
  181. }
  182. })
  183. it('keeps the audited library registry accurate: every entry has a plain module entry and no bundle declaration', () => {
  184. for (const [dir, reason] of Object.entries(PACKAGE_LIBRARIES)) {
  185. expect(reason.trim().length, `${dir}: library justification`).toBeGreaterThan(0)
  186. expect(declaresBundle(dir), `${dir}: a bundle declaration makes this package-bundle, not a library`).toBe(false)
  187. const entry = resolve(root, dir, 'src/index.ts')
  188. expect(existsSync(entry), `${dir}: library entry`).toBe(true)
  189. const source = readFileSync(entry, 'utf8')
  190. expect(source, `${dir}: entry must be a plain module, not a plugin`).not.toMatch(/export (?:default|\{[^}]*default[^}]*\} from)/u)
  191. expect(source, `${dir}: entry must be a plain module, not a plugin`).not.toMatch(/export (?:async )?(?:function|const) apply\b/u)
  192. }
  193. })
  194. it('keeps every package README on the summary, contents, and Dev Note skeleton', () => {
  195. for (const file of packageReadmes().filter(file => file.split('/').length === 4)) {
  196. const source = readFileSync(resolve(root, file), 'utf8')
  197. expect(packageReadmeStructureErrors(file, source), file).toEqual([])
  198. }
  199. })
  200. it('rejects redundant fields and a kind that does not match the README position', () => {
  201. expect(packageReadmeMetadataErrors('packages/example/README.md', {
  202. description: 'Example group.',
  203. kind: 'package-reference',
  204. name: 'example',
  205. audience: ['developer'],
  206. tags: ['example'],
  207. i18n: { counterpart: 'README.zh.md' },
  208. })).toEqual([
  209. 'kind must be package-group',
  210. 'name is redundant or has no governed consumer',
  211. 'audience is redundant or has no governed consumer',
  212. 'tags is redundant or has no governed consumer',
  213. 'i18n is redundant or has no governed consumer',
  214. ])
  215. expect(packageReadmeMetadataErrors('packages\\example\\package\\README.md', {
  216. description: 'Example package.',
  217. kind: 'package-reference',
  218. })).toEqual([])
  219. })
  220. it('rejects README-local i18n metadata', () => {
  221. expect(packageReadmeMetadataErrors('packages\\example\\package\\README.md', {
  222. description: 'Example package.',
  223. kind: 'package-reference',
  224. i18n: {
  225. 'counterpart': 'packages/example/package/README.zh.md',
  226. 'line-aligned': true,
  227. },
  228. })).toEqual([
  229. 'i18n is redundant or has no governed consumer',
  230. ])
  231. })
  232. })
  233. describe('reference-example README pair', () => {
  234. const dir = 'packages/session/session-persistence-jsonl'
  235. it('keeps exact English/Chinese physical line alignment', () => {
  236. const sourceLines = readFileSync(resolve(root, dir, 'README.md'), 'utf8').split('\n').length
  237. const zhLines = readFileSync(resolve(root, dir, 'README.zh.md'), 'utf8').split('\n').length
  238. expect(sourceLines).toBe(zhLines)
  239. })
  240. it('keeps the sidecar consistency record present', () => {
  241. const sidecar = readFileSync(resolve(root, dir, 'README.i18n.yaml'), 'utf8')
  242. expect(sidecar).toMatch(/^README\.md: [0-9a-f]{40}$/m)
  243. expect(sidecar).toMatch(/^README\.zh\.md: [0-9a-f]{40}$/m)
  244. })
  245. })