dsh-tower 1.3.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +109 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +44 -8
- package/README.md +42 -8
- package/cordis.patch.yml +11 -1
- package/docs/README.md +10 -5
- package/docs/architecture/overview.md +13 -9
- package/docs/faq.md +45 -2
- package/docs/guide/hot-reload.md +189 -0
- package/docs/guide/install-upgrade-migration.md +11 -0
- package/docs/guide/quickstart.md +33 -1
- package/docs/guide/scenarios-and-philosophy.md +309 -0
- package/docs/operations/runbook.md +23 -0
- package/docs/operations/security.md +23 -0
- package/docs/reference/blockers.md +10 -2
- package/docs/reference/config.md +65 -2
- package/docs/reference/doctor.md +33 -0
- package/docs/reference/errors.md +50 -0
- package/docs/reference/events.md +45 -0
- package/docs/reference/operations.md +55 -0
- package/docs/reference/testing.md +61 -0
- package/docs/reference/tools.md +65 -23
- package/docs/zh/README.md +60 -0
- package/docs/zh/faq.md +81 -0
- package/docs/zh/guide/hot-reload.md +178 -0
- package/docs/zh/guide/install-upgrade-migration.md +57 -0
- package/docs/zh/guide/quickstart.md +96 -0
- package/docs/zh/guide/scenarios-and-philosophy.md +211 -0
- package/docs/zh/operations/security.md +42 -0
- package/docs/zh/reference/blockers.md +62 -0
- package/docs/zh/reference/config.md +113 -0
- package/docs/zh/reference/doctor.md +31 -0
- package/docs/zh/reference/errors.md +52 -0
- package/docs/zh/reference/events.md +46 -0
- package/docs/zh/reference/operations.md +50 -0
- package/docs/zh/reference/testing.md +60 -0
- package/docs/zh/reference/tools.md +75 -0
- package/index.js +261 -13
- package/lib/batch.js +578 -0
- package/lib/config.js +275 -9
- package/lib/doctor.js +171 -0
- package/lib/errors.js +111 -0
- package/lib/hmr.js +196 -0
- package/lib/leases.js +317 -0
- package/lib/legacy.js +413 -0
- package/lib/modeStore.js +28 -1
- package/lib/store.js +24 -1
- package/lib/tests.js +628 -0
- package/lib/tools.js +1566 -184
- package/lib/tower.js +986 -103
- package/lib/workspaceLock.js +117 -0
- package/lib/worktreeOwnership.js +166 -0
- package/package.json +4 -1
- package/reviewer.js +1 -0
- package/scripts/check-docs.mjs +135 -0
- package/scripts/enable-dev-hmr.mjs +347 -0
- package/skills/tower-review.md +4 -2
- package/skills/tower-worker-protocol.md +3 -1
- package/worker.js +4 -2
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// Advisory repository workspace lock: one Tower session at a time when
|
|
2
|
+
// `mode.workspaceLock` is enabled. Same-user trusted model, like the rest of
|
|
3
|
+
// the Tower store: it prevents accidental double control, not a malicious
|
|
4
|
+
// writer.
|
|
5
|
+
import fsp from 'node:fs/promises'
|
|
6
|
+
import path from 'node:path'
|
|
7
|
+
import crypto from 'node:crypto'
|
|
8
|
+
|
|
9
|
+
export function workspaceLockPath(commonDir) {
|
|
10
|
+
return path.join(commonDir, 'dsh-tower', 'tower.lock')
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function workspaceLockToken() {
|
|
14
|
+
return crypto.randomUUID()
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
async function readRecord(commonDir) {
|
|
18
|
+
const file = workspaceLockPath(commonDir)
|
|
19
|
+
try {
|
|
20
|
+
const text = await fsp.readFile(file, 'utf8')
|
|
21
|
+
const parsed = JSON.parse(text)
|
|
22
|
+
if (!parsed || typeof parsed !== 'object') return { file, record: null, corrupt: true }
|
|
23
|
+
return { file, record: parsed, corrupt: false }
|
|
24
|
+
} catch (error) {
|
|
25
|
+
if (error?.code === 'ENOENT') return { file, record: null, corrupt: false }
|
|
26
|
+
return { file, record: null, corrupt: true }
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function removeLock(file) {
|
|
31
|
+
for (let attempt = 0; attempt < 5; attempt += 1) {
|
|
32
|
+
try {
|
|
33
|
+
await fsp.unlink(file)
|
|
34
|
+
return
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error?.code === 'ENOENT') return
|
|
37
|
+
if (!['EPERM', 'EBUSY', 'EACCES'].includes(error?.code) || attempt === 4) throw error
|
|
38
|
+
await new Promise((resolve) => setTimeout(resolve, 10 * (attempt + 1)))
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Try to become the repository's Tower session.
|
|
45
|
+
* @returns {{acquired:boolean, record?:object, holder?:object|null, tookOver?:boolean, corrupt?:boolean}}
|
|
46
|
+
*/
|
|
47
|
+
export async function acquireWorkspaceLock(commonDir, options) {
|
|
48
|
+
const { token, sessionId, agentId, staleMs = 300000, now = Date.now() } = options
|
|
49
|
+
await fsp.mkdir(path.dirname(workspaceLockPath(commonDir)), { recursive: true })
|
|
50
|
+
for (let attempt = 0; attempt < 3; attempt += 1) {
|
|
51
|
+
const record = { token, sessionId, agentId, pid: process.pid, at: now }
|
|
52
|
+
try {
|
|
53
|
+
await fsp.writeFile(workspaceLockPath(commonDir), JSON.stringify(record), { flag: 'wx' })
|
|
54
|
+
return { acquired: true, record, tookOver: attempt > 0 }
|
|
55
|
+
} catch (error) {
|
|
56
|
+
if (error?.code !== 'EEXIST') throw error
|
|
57
|
+
const { record: current, corrupt, file } = await readRecord(commonDir)
|
|
58
|
+
if (corrupt || !current) {
|
|
59
|
+
// A corrupt lock is treated like an unknown holder; only a stale one
|
|
60
|
+
// is removed, so a concurrent writer is never silently overwritten.
|
|
61
|
+
let stale = false
|
|
62
|
+
try {
|
|
63
|
+
const stat = await fsp.stat(file)
|
|
64
|
+
stale = now - stat.mtimeMs > staleMs
|
|
65
|
+
} catch {
|
|
66
|
+
stale = true
|
|
67
|
+
}
|
|
68
|
+
if (stale) {
|
|
69
|
+
await removeLock(file)
|
|
70
|
+
continue
|
|
71
|
+
}
|
|
72
|
+
return { acquired: false, holder: null, corrupt: true }
|
|
73
|
+
}
|
|
74
|
+
if (current.sessionId === sessionId && current.agentId === agentId) {
|
|
75
|
+
await fsp.writeFile(workspaceLockPath(commonDir), JSON.stringify({ ...record, token: current.token }))
|
|
76
|
+
return { acquired: true, record: { ...record, token: current.token }, tookOver: false }
|
|
77
|
+
}
|
|
78
|
+
if (now - Number(current.at ?? 0) > staleMs) {
|
|
79
|
+
await removeLock(file)
|
|
80
|
+
continue
|
|
81
|
+
}
|
|
82
|
+
return { acquired: false, holder: current }
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
return { acquired: false, holder: null, corrupt: true }
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** Refresh the timestamp only when the lock still belongs to this session. */
|
|
89
|
+
export async function touchWorkspaceLock(commonDir, options) {
|
|
90
|
+
const { token, now = Date.now() } = options
|
|
91
|
+
const { record, file } = await readRecord(commonDir)
|
|
92
|
+
if (!record || record.token !== token) return false
|
|
93
|
+
await fsp.writeFile(file, JSON.stringify({ ...record, at: now }))
|
|
94
|
+
return true
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Release the lock only when this session owns it. */
|
|
98
|
+
export async function releaseWorkspaceLock(commonDir, options) {
|
|
99
|
+
const { token } = options
|
|
100
|
+
const { record, file } = await readRecord(commonDir)
|
|
101
|
+
if (!record || (token && record.token !== token)) return false
|
|
102
|
+
await removeLock(file)
|
|
103
|
+
return true
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function inspectWorkspaceLock(commonDir, options = {}) {
|
|
107
|
+
const { sessionId, staleMs = 300000, now = Date.now() } = options
|
|
108
|
+
const { record, corrupt } = await readRecord(commonDir)
|
|
109
|
+
if (!record) return { held: false, corrupt, self: false, holder: null, stale: false }
|
|
110
|
+
return {
|
|
111
|
+
held: true,
|
|
112
|
+
corrupt,
|
|
113
|
+
self: sessionId !== undefined && record.sessionId === sessionId,
|
|
114
|
+
holder: record,
|
|
115
|
+
stale: now - Number(record.at ?? 0) > staleMs,
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
// Worktree ownership verification.
|
|
2
|
+
//
|
|
3
|
+
// A stored worktree path is an identity claim, not a sandbox: before Tower
|
|
4
|
+
// trusts it (preflight/merge/review/diff), the path must still resolve to the
|
|
5
|
+
// same real directory, must not have been replaced by a symlink or junction,
|
|
6
|
+
// and must still be the Git worktree of the recorded branch in the recorded
|
|
7
|
+
// repository. Every mismatch is rejected with an actionable message; nothing
|
|
8
|
+
// is repaired or recreated here.
|
|
9
|
+
import fsp from 'node:fs/promises'
|
|
10
|
+
import path from 'node:path'
|
|
11
|
+
|
|
12
|
+
import { git } from './git.js'
|
|
13
|
+
|
|
14
|
+
function fail(code, message) {
|
|
15
|
+
const error = new Error(message)
|
|
16
|
+
error.code = code
|
|
17
|
+
throw error
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function isNonEmptyString(value) {
|
|
21
|
+
return typeof value === 'string' && value.trim().length > 0
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function compareKey(target) {
|
|
25
|
+
const resolved = path.resolve(target)
|
|
26
|
+
return process.platform === 'win32' ? resolved.toLowerCase() : resolved
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function samePath(a, b) {
|
|
30
|
+
return compareKey(a) === compareKey(b)
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async function realpathOrResolve(target) {
|
|
34
|
+
try {
|
|
35
|
+
return await fsp.realpath(target)
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error?.code === 'ENOENT') return path.resolve(target)
|
|
38
|
+
throw error
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Resolve a path to its canonical realpath. Requires an absolute path and an
|
|
44
|
+
* existing target; a dangling target is reported as a missing path.
|
|
45
|
+
*/
|
|
46
|
+
export async function canonicalPath(target) {
|
|
47
|
+
if (!isNonEmptyString(target)) fail('invalid-argument', 'path is required')
|
|
48
|
+
if (!path.isAbsolute(target)) fail('invalid-argument', `path must be an absolute path: ${target}`)
|
|
49
|
+
try {
|
|
50
|
+
return await fsp.realpath(target)
|
|
51
|
+
} catch (error) {
|
|
52
|
+
if (error?.code === 'ENOENT') fail('invalid-transition', `path does not exist: ${path.resolve(target)}`)
|
|
53
|
+
throw error
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function gitTop(resolved, label) {
|
|
58
|
+
try {
|
|
59
|
+
return await git(resolved, ['rev-parse', '--show-toplevel'])
|
|
60
|
+
} catch (error) {
|
|
61
|
+
fail('invalid-transition', `${label} ${resolved} is not a Git worktree: ${error.message}`)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* Verify that a stored worktree still belongs to this repository, branch, and
|
|
67
|
+
* mission.
|
|
68
|
+
*
|
|
69
|
+
* @param {object} input
|
|
70
|
+
* @param {string} input.storedPath absolute worktree path recorded in state
|
|
71
|
+
* @param {string} [input.storedRealPath] realpath recorded when the worktree was created
|
|
72
|
+
* @param {string} input.branch expected branch
|
|
73
|
+
* @param {string} input.commonDir expected Git common dir (shared by worktrees)
|
|
74
|
+
* @param {string} [input.missionId] mission id, used in error messages
|
|
75
|
+
* @returns {Promise<{path:string, realPath:string}>}
|
|
76
|
+
*/
|
|
77
|
+
export async function assertOwnership(input = {}) {
|
|
78
|
+
const storedPath = input?.storedPath
|
|
79
|
+
const storedRealPath = input?.storedRealPath
|
|
80
|
+
const branch = input?.branch
|
|
81
|
+
const commonDir = input?.commonDir
|
|
82
|
+
const missionId = input?.missionId
|
|
83
|
+
|
|
84
|
+
if (!isNonEmptyString(storedPath)) fail('invalid-argument', 'storedPath is required')
|
|
85
|
+
if (!path.isAbsolute(storedPath)) fail('invalid-argument', `storedPath must be an absolute path: ${storedPath}`)
|
|
86
|
+
if (!isNonEmptyString(branch)) fail('invalid-argument', 'branch is required')
|
|
87
|
+
if (!isNonEmptyString(commonDir)) fail('invalid-argument', 'commonDir is required')
|
|
88
|
+
if (!path.isAbsolute(commonDir)) fail('invalid-argument', `commonDir must be an absolute path: ${commonDir}`)
|
|
89
|
+
if (missionId !== undefined && !isNonEmptyString(missionId)) {
|
|
90
|
+
fail('invalid-argument', `missionId must be a non-empty string: ${JSON.stringify(missionId)}`)
|
|
91
|
+
}
|
|
92
|
+
if (storedRealPath !== undefined && storedRealPath !== null && storedRealPath !== '') {
|
|
93
|
+
if (!isNonEmptyString(storedRealPath) || !path.isAbsolute(storedRealPath)) {
|
|
94
|
+
fail('invalid-argument', `storedRealPath must be an absolute path: ${storedRealPath}`)
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const label = missionId ? `mission ${missionId} worktree` : 'worktree'
|
|
99
|
+
const resolved = path.resolve(storedPath)
|
|
100
|
+
|
|
101
|
+
let linkStat
|
|
102
|
+
try {
|
|
103
|
+
linkStat = await fsp.lstat(resolved)
|
|
104
|
+
} catch (error) {
|
|
105
|
+
if (error?.code === 'ENOENT') fail('invalid-transition', `${label} is missing: ${resolved}`)
|
|
106
|
+
throw error
|
|
107
|
+
}
|
|
108
|
+
if (linkStat.isSymbolicLink()) {
|
|
109
|
+
fail(
|
|
110
|
+
'invalid-transition',
|
|
111
|
+
`${label} ${resolved} is a symlink or junction; refusing to treat a redirected path as the owned worktree`,
|
|
112
|
+
)
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const realPath = await canonicalPath(resolved)
|
|
116
|
+
const stat = await fsp.stat(realPath)
|
|
117
|
+
if (!stat.isDirectory()) fail('invalid-transition', `${label} is not a directory: ${resolved}`)
|
|
118
|
+
|
|
119
|
+
if (storedRealPath !== undefined && storedRealPath !== null && storedRealPath !== '' && !samePath(storedRealPath, realPath)) {
|
|
120
|
+
fail(
|
|
121
|
+
'invalid-transition',
|
|
122
|
+
`${label} real path changed: expected ${path.resolve(storedRealPath)}, found ${realPath}; refusing to use a replaced worktree`,
|
|
123
|
+
)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const topRaw = await gitTop(resolved, label)
|
|
127
|
+
const top = await canonicalPath(topRaw)
|
|
128
|
+
if (!samePath(top, realPath)) {
|
|
129
|
+
fail(
|
|
130
|
+
'invalid-transition',
|
|
131
|
+
`${label} path ${resolved} is not the Git worktree root (top-level is ${top}); refusing to trust a nested or redirected path`,
|
|
132
|
+
)
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
let head
|
|
136
|
+
try {
|
|
137
|
+
head = await git(resolved, ['rev-parse', '--abbrev-ref', 'HEAD'])
|
|
138
|
+
} catch (error) {
|
|
139
|
+
fail('invalid-transition', `${label} ${resolved} has no readable HEAD: ${error.message}`)
|
|
140
|
+
}
|
|
141
|
+
const actualBranch = head === 'HEAD' ? null : head.trim()
|
|
142
|
+
if (actualBranch !== branch) {
|
|
143
|
+
fail(
|
|
144
|
+
'invalid-transition',
|
|
145
|
+
`${label} ${resolved} is on ${actualBranch ?? 'detached HEAD'}, expected ${branch}`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
let commonRaw
|
|
150
|
+
try {
|
|
151
|
+
commonRaw = await git(resolved, ['rev-parse', '--git-common-dir'])
|
|
152
|
+
} catch (error) {
|
|
153
|
+
fail('invalid-transition', `${label} ${resolved} has no readable Git common dir: ${error.message}`)
|
|
154
|
+
}
|
|
155
|
+
const actualCommon = path.isAbsolute(commonRaw) ? path.resolve(commonRaw) : path.resolve(resolved, commonRaw)
|
|
156
|
+
const expectedCommon = await realpathOrResolve(path.resolve(commonDir))
|
|
157
|
+
const actualCommonReal = await realpathOrResolve(actualCommon)
|
|
158
|
+
if (!samePath(actualCommonReal, expectedCommon)) {
|
|
159
|
+
fail(
|
|
160
|
+
'invalid-transition',
|
|
161
|
+
`${label} ${resolved} belongs to a different repository (common dir ${actualCommonReal}, expected ${expectedCommon})`,
|
|
162
|
+
)
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
return { path: resolved, realPath }
|
|
166
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-tower",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "2.0.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "DSH-native Tower: mission planning, worktree isolation, review-gated merges, and a human-owned Tower mode.",
|
|
6
6
|
"license": "MIT",
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
"skills/",
|
|
43
43
|
"lib/*.js",
|
|
44
44
|
"cordis.patch.yml",
|
|
45
|
+
"scripts/",
|
|
45
46
|
"locale/*.json",
|
|
46
47
|
"icon.svg",
|
|
47
48
|
"README.md",
|
|
@@ -60,6 +61,8 @@
|
|
|
60
61
|
"scripts": {
|
|
61
62
|
"test": "node --test tests/*.test.mjs",
|
|
62
63
|
"test:watch": "node --test --watch tests/*.test.mjs",
|
|
64
|
+
"test:coverage": "node --test --experimental-test-coverage tests/*.test.mjs",
|
|
65
|
+
"dev:hmr": "node scripts/enable-dev-hmr.mjs",
|
|
63
66
|
"docs:check": "node scripts/check-docs.mjs",
|
|
64
67
|
"check": "npm test && npm run docs:check"
|
|
65
68
|
},
|
package/reviewer.js
CHANGED
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
// Documentation consistency check: tool names, blocker codes, error codes and
|
|
2
|
+
// release numbers must not drift from the implementation. The Chinese
|
|
3
|
+
// reference pages must cover the same names. Run: node scripts/check-docs.mjs
|
|
4
|
+
import fs from 'node:fs'
|
|
5
|
+
import path from 'node:path'
|
|
6
|
+
import { fileURLToPath } from 'node:url'
|
|
7
|
+
|
|
8
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
9
|
+
const read = (file) => fs.readFileSync(path.join(root, file), 'utf8')
|
|
10
|
+
const exists = (file) => fs.existsSync(path.join(root, file))
|
|
11
|
+
|
|
12
|
+
const toolsSource = read('lib/tools.js') + read('index.js')
|
|
13
|
+
const documentedTools = read('docs/reference/tools.md')
|
|
14
|
+
const toolNames = [...new Set([...toolsSource.matchAll(/name: '(tower_[a-z_]+)'/g)].map((match) => match[1]))]
|
|
15
|
+
.filter((name) => name !== 'tower-control')
|
|
16
|
+
.sort()
|
|
17
|
+
const missingTools = toolNames.filter((name) => !documentedTools.includes(`\`${name}\``))
|
|
18
|
+
|
|
19
|
+
const towerSource = read('lib/tower.js')
|
|
20
|
+
const blockersSource = read('docs/reference/blockers.md')
|
|
21
|
+
const blockerCodes = new Set()
|
|
22
|
+
for (const match of towerSource.matchAll(/push\(\s*'([a-z][a-z-]+)'/g)) blockerCodes.add(match[1])
|
|
23
|
+
for (const code of [
|
|
24
|
+
'mission-missing',
|
|
25
|
+
'mission-not-completed',
|
|
26
|
+
'tasks-open',
|
|
27
|
+
'blockers-present',
|
|
28
|
+
'branch-missing',
|
|
29
|
+
'stale-base',
|
|
30
|
+
'read-only-survey',
|
|
31
|
+
'out-of-scope',
|
|
32
|
+
'base-dirty',
|
|
33
|
+
'deps-unmerged',
|
|
34
|
+
'no-review',
|
|
35
|
+
'review-mission-mismatch',
|
|
36
|
+
'not-clean',
|
|
37
|
+
'review-hold',
|
|
38
|
+
'review-not-approved',
|
|
39
|
+
'tip-moved',
|
|
40
|
+
'worker-busy',
|
|
41
|
+
'base-wrong-branch',
|
|
42
|
+
'branch-owned-by-closed-missions',
|
|
43
|
+
'merge-conflict',
|
|
44
|
+
'merge-hook-failed',
|
|
45
|
+
'merge-shape',
|
|
46
|
+
'rebase-conflict',
|
|
47
|
+
'worktree-dirty',
|
|
48
|
+
'no-check-evidence',
|
|
49
|
+
'worker-stale',
|
|
50
|
+
'worker-status-unknown',
|
|
51
|
+
'tests-missing',
|
|
52
|
+
'tests-failed',
|
|
53
|
+
'review-exemption-invalid',
|
|
54
|
+
'merge-intent-pending',
|
|
55
|
+
'batch-invalid',
|
|
56
|
+
]) blockerCodes.add(code)
|
|
57
|
+
const missingBlockers = [...blockerCodes].filter((code) => !blockersSource.includes(`\`${code}\``)).sort()
|
|
58
|
+
|
|
59
|
+
const eventNames = [
|
|
60
|
+
'init',
|
|
61
|
+
'plan',
|
|
62
|
+
'mission',
|
|
63
|
+
'spawn',
|
|
64
|
+
'complete',
|
|
65
|
+
'review',
|
|
66
|
+
'merge',
|
|
67
|
+
'rebase',
|
|
68
|
+
'teardown',
|
|
69
|
+
'finding',
|
|
70
|
+
'findings',
|
|
71
|
+
'send',
|
|
72
|
+
'inbox',
|
|
73
|
+
'interrupt',
|
|
74
|
+
'diff',
|
|
75
|
+
'adopt',
|
|
76
|
+
'roster',
|
|
77
|
+
'status',
|
|
78
|
+
'preflight',
|
|
79
|
+
'doctor',
|
|
80
|
+
'test',
|
|
81
|
+
'batch',
|
|
82
|
+
'resource',
|
|
83
|
+
'import_legacy',
|
|
84
|
+
'legacy_map',
|
|
85
|
+
]
|
|
86
|
+
const documentedEvents = read('docs/reference/events.md')
|
|
87
|
+
const missingEvents = eventNames.filter((name) => !documentedEvents.includes(`\`tower/${name}\``))
|
|
88
|
+
|
|
89
|
+
const errorsSource = read('lib/errors.js')
|
|
90
|
+
const documentedErrors = read('docs/reference/errors.md')
|
|
91
|
+
const errorCodes = [...new Set([...errorsSource.matchAll(/^ {2}'([a-z][a-z-]+)':/gm)].map((match) => match[1]))].sort()
|
|
92
|
+
const missingErrors = errorCodes.filter((code) => !documentedErrors.includes(`\`${code}\``))
|
|
93
|
+
|
|
94
|
+
const packageJson = JSON.parse(read('package.json'))
|
|
95
|
+
const changelog = read('CHANGELOG.md')
|
|
96
|
+
const missingChangelog = !changelog.includes(`## ${packageJson.version}`)
|
|
97
|
+
|
|
98
|
+
const problems = []
|
|
99
|
+
if (missingTools.length > 0) problems.push(`docs/reference/tools.md misses tools: ${missingTools.join(', ')}`)
|
|
100
|
+
if (missingBlockers.length > 0) problems.push(`docs/reference/blockers.md misses codes: ${missingBlockers.join(', ')}`)
|
|
101
|
+
if (missingErrors.length > 0) problems.push(`docs/reference/errors.md misses codes: ${missingErrors.join(', ')}`)
|
|
102
|
+
if (missingEvents.length > 0) problems.push(`docs/reference/events.md misses events: ${missingEvents.join(', ')}`)
|
|
103
|
+
if (missingChangelog) problems.push(`CHANGELOG.md misses version ${packageJson.version}`)
|
|
104
|
+
|
|
105
|
+
// Chinese parity: when the translation tree exists it must cover the same
|
|
106
|
+
// reference names, so a tool or code cannot be added only in English.
|
|
107
|
+
if (exists('docs/zh')) {
|
|
108
|
+
const zhPages = {
|
|
109
|
+
'docs/zh/README.md': null,
|
|
110
|
+
'docs/zh/reference/tools.md': (text) => toolNames.filter((name) => !text.includes(`\`${name}\``)),
|
|
111
|
+
'docs/zh/reference/blockers.md': (text) => [...blockerCodes].filter((code) => !text.includes(`\`${code}\``)),
|
|
112
|
+
'docs/zh/reference/errors.md': (text) => errorCodes.filter((code) => !text.includes(`\`${code}\``)),
|
|
113
|
+
'docs/zh/reference/events.md': (text) => eventNames.filter((name) => !text.includes(`\`tower/${name}\``)),
|
|
114
|
+
'docs/zh/reference/config.md': null,
|
|
115
|
+
'docs/zh/guide/hot-reload.md': null,
|
|
116
|
+
}
|
|
117
|
+
for (const [page, check] of Object.entries(zhPages)) {
|
|
118
|
+
if (!exists(page)) {
|
|
119
|
+
problems.push(`${page} is missing (Chinese docs parity)`)
|
|
120
|
+
continue
|
|
121
|
+
}
|
|
122
|
+
if (!check) continue
|
|
123
|
+
const missing = check(read(page))
|
|
124
|
+
if (missing.length > 0) problems.push(`${page} misses: ${missing.join(', ')}`)
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
if (problems.length > 0) {
|
|
129
|
+
console.error('documentation check failed:')
|
|
130
|
+
for (const problem of problems) console.error(`- ${problem}`)
|
|
131
|
+
process.exit(1)
|
|
132
|
+
}
|
|
133
|
+
console.log(
|
|
134
|
+
`docs ok: ${toolNames.length} tools, ${blockerCodes.size} blocker codes, ${errorCodes.length} error codes, ${eventNames.length} events, version ${packageJson.version}`,
|
|
135
|
+
)
|