dsh-tower 2.1.0-beta.1 → 2.1.0

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.
Files changed (75) hide show
  1. package/CHANGELOG.md +51 -0
  2. package/COMPATIBILITY.md +1 -0
  3. package/CONTRIBUTING.md +8 -1
  4. package/README.en.md +8 -2
  5. package/README.md +8 -2
  6. package/adapters/default.js +2 -0
  7. package/app/archive.js +190 -0
  8. package/app/board.js +3 -1
  9. package/app/checkCompletion.js +15 -2
  10. package/app/gate.js +31 -13
  11. package/app/init.js +45 -3
  12. package/app/merge.js +6 -0
  13. package/app/mission.js +2 -0
  14. package/app/plan.js +12 -4
  15. package/app/read.js +59 -3
  16. package/app/review.js +2 -0
  17. package/app/teardown.js +22 -1
  18. package/app/tower.js +34 -6
  19. package/docs/agents/domain.md +38 -0
  20. package/docs/agents/issue-tracker.md +46 -0
  21. package/docs/agents/triage-labels.md +15 -0
  22. package/docs/architecture/overview.md +3 -1
  23. package/docs/guide/install-upgrade-migration.md +18 -1
  24. package/docs/reference/blockers.md +1 -1
  25. package/docs/reference/config.generated.md +4 -0
  26. package/docs/reference/config.md +4 -0
  27. package/docs/reference/doctor.md +58 -8
  28. package/docs/reference/errors.generated.md +2 -0
  29. package/docs/reference/errors.md +4 -2
  30. package/docs/reference/tools.generated.md +6 -6
  31. package/docs/reference/tools.md +7 -6
  32. package/docs/zh/guide/install-upgrade-migration.md +13 -1
  33. package/docs/zh/reference/blockers.md +1 -1
  34. package/docs/zh/reference/config.md +4 -0
  35. package/docs/zh/reference/doctor.md +35 -2
  36. package/docs/zh/reference/errors.md +4 -2
  37. package/docs/zh/reference/tools.md +3 -3
  38. package/domain/config/schema.js +26 -3
  39. package/domain/contracts/outputs.js +27 -1
  40. package/domain/contracts/tool-inputs/missions.js +2 -1
  41. package/domain/errors.js +2 -0
  42. package/domain/integration/gate.js +25 -11
  43. package/domain/integration/init.js +40 -4
  44. package/domain/mission.js +1 -1
  45. package/domain/missions/completion.js +58 -5
  46. package/domain/missions/lifecycle.js +2 -1
  47. package/domain/planning.js +6 -3
  48. package/domain/read/views.js +14 -1
  49. package/domain/report.js +9 -1
  50. package/domain/scope.js +83 -11
  51. package/domain/state/archive.js +257 -0
  52. package/domain/state/migrations.js +145 -0
  53. package/domain/state/schema.js +6 -0
  54. package/domain/state.js +154 -4
  55. package/domain/summary.js +2 -0
  56. package/lib/archive.js +279 -0
  57. package/lib/caseSensitivity.js +66 -0
  58. package/lib/doctor.js +178 -0
  59. package/lib/leases.js +14 -5
  60. package/lib/legacy.js +19 -5
  61. package/lib/persistence.js +12 -5
  62. package/lib/stateFile.js +99 -0
  63. package/lib/store.js +15 -0
  64. package/lib/tests.js +6 -2
  65. package/lib/worktreeOwnership.js +18 -7
  66. package/package.json +1 -1
  67. package/scripts/pack-manifest.txt +9 -0
  68. package/tools/complete.js +1 -1
  69. package/tools/doctor.js +1 -1
  70. package/tools/init.js +1 -1
  71. package/tools/plan.js +1 -1
  72. package/tools/preflight.js +1 -0
  73. package/tools/status.js +14 -6
  74. package/tools/teardown.js +1 -1
  75. package/types/index.d.ts +34 -0
package/domain/state.js CHANGED
@@ -1,17 +1,123 @@
1
- // Pure state-file validation (#56 P4).
1
+ // Pure state-file validation and migration decisions (#56 P4, #72).
2
2
  //
3
- // The structural contract lives in `state/schema.js` (TypeBox) and the policy
4
- // checks in `state/semantics.js`. This module stays the public entry point so
5
- // every app/tool import of `domain/state.js` is unchanged.
3
+ // The structural contract lives in `state/schema.js` (TypeBox), the policy
4
+ // checks in `state/semantics.js`, and the version registry in
5
+ // `state/migrations.js`. This module stays the public entry point so every
6
+ // app/tool import of `domain/state.js` is unchanged.
7
+ //
8
+ // `validateState` proves a document matches the *current* schema.
9
+ // `prepareState` is the load-path decision: it inspects the version, applies the
10
+ // ordered migration registry, then validates the target schema. Both are pure;
11
+ // the side effects (backup, write, activity) live in `lib/stateFile.js`.
6
12
  // @ts-check
7
13
  import { TowerToolError } from './errors.js'
14
+ import { SCHEMA_VERSION } from './mission.js'
15
+ import { migrationChain, migrateState } from './state/migrations.js'
8
16
  import { stateShapeError } from './state/schema.js'
9
17
  import { assertStateSemantics } from './state/semantics.js'
10
18
 
11
19
  /** @typedef {import('../types/index.js').TowerState} TowerState */
20
+ /** @typedef {{ from: number, to: number }} MigrationStep */
21
+ /** @typedef {import('./state/migrations.js').StateMigration} StateMigration */
12
22
 
13
23
  export { StateSchema, stateShapeError } from './state/schema.js'
14
24
 
25
+ /**
26
+ * @param {unknown} value
27
+ * @returns {boolean}
28
+ */
29
+ function isPlainObject(value) {
30
+ return value !== null && typeof value === 'object' && !Array.isArray(value)
31
+ }
32
+
33
+ /**
34
+ * Classify a document's `schemaVersion` without throwing. This is the read path
35
+ * doctor uses, so it must stay usable on a repository that cannot load.
36
+ * @param {unknown} state
37
+ * @param {string} [statePath]
38
+ * @param {readonly StateMigration[]} [migrations] registry to classify against
39
+ * @returns {{
40
+ * status: 'current'|'migratable'|'newer'|'unmigratable'|'invalid',
41
+ * currentVersion: number|null,
42
+ * supportedVersion: number,
43
+ * migrations: MigrationStep[],
44
+ * message?: string,
45
+ * }}
46
+ */
47
+ export function inspectStateVersion(state, statePath, migrations) {
48
+ const where = statePath ? ` at ${statePath}` : ''
49
+ if (!isPlainObject(state)) {
50
+ return {
51
+ status: 'invalid',
52
+ currentVersion: null,
53
+ supportedVersion: SCHEMA_VERSION,
54
+ migrations: [],
55
+ message: `tower state is not an object${where}`,
56
+ }
57
+ }
58
+ const version = /** @type {Record<string, any>} */ (state).schemaVersion
59
+ if (!Number.isInteger(version) || version < 1) {
60
+ return {
61
+ status: 'invalid',
62
+ currentVersion: null,
63
+ supportedVersion: SCHEMA_VERSION,
64
+ migrations: [],
65
+ message: `tower state has no usable schemaVersion (${JSON.stringify(version)})${where}`,
66
+ }
67
+ }
68
+ if (version === SCHEMA_VERSION) {
69
+ return { status: 'current', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
70
+ }
71
+ if (version > SCHEMA_VERSION) {
72
+ return { status: 'newer', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
73
+ }
74
+ const chain = migrationChain(version, SCHEMA_VERSION, migrations)
75
+ if (!chain) {
76
+ return { status: 'unmigratable', currentVersion: version, supportedVersion: SCHEMA_VERSION, migrations: [] }
77
+ }
78
+ return {
79
+ status: 'migratable',
80
+ currentVersion: version,
81
+ supportedVersion: SCHEMA_VERSION,
82
+ migrations: chain.map((step) => ({ from: step.from, to: step.to })),
83
+ }
84
+ }
85
+
86
+ /**
87
+ * @param {{ status: string, currentVersion: number|null, supportedVersion: number, message?: string }} info
88
+ * @param {string} [statePath]
89
+ * @returns {TowerToolError}
90
+ */
91
+ function versionError(info, statePath) {
92
+ const where = statePath ? ` at ${statePath}` : ''
93
+ if (info.status === 'newer') {
94
+ return new TowerToolError(
95
+ 'state-version-unsupported',
96
+ `tower state${where} has schemaVersion ${info.currentVersion}, but this build supports ${info.supportedVersion}: it was written by a newer dsh-tower and will not be downgraded`,
97
+ {
98
+ nextAction: `upgrade dsh-tower to a release that supports state schemaVersion ${info.currentVersion}; the file was left untouched`,
99
+ },
100
+ )
101
+ }
102
+ if (info.status === 'migratable') {
103
+ return new TowerToolError(
104
+ 'state-version-unsupported',
105
+ `tower state${where} has schemaVersion ${info.currentVersion}; load it through the state loader to migrate it to ${info.supportedVersion}`,
106
+ { nextAction: 'call any tower tool; mutations apply the migration under the state lock' },
107
+ )
108
+ }
109
+ if (info.status === 'unmigratable') {
110
+ return new TowerToolError(
111
+ 'state-version-unsupported',
112
+ `tower state${where} has schemaVersion ${info.currentVersion}, which has no migration to ${info.supportedVersion}`,
113
+ {
114
+ nextAction: `restore a state.json written by dsh-tower ${info.supportedVersion}; this build will not guess at an unknown schema`,
115
+ },
116
+ )
117
+ }
118
+ return new TowerToolError('state-corrupt', info.message ?? `tower state is not usable${where}`)
119
+ }
120
+
15
121
  /**
16
122
  * @param {string} message
17
123
  * @returns {never}
@@ -21,13 +127,57 @@ function fail(message) {
21
127
  }
22
128
 
23
129
  /**
130
+ * Prove a document matches the current schema. Any other version is rejected
131
+ * with a version error rather than corruption; the load path migrates first.
24
132
  * @param {unknown} state
25
133
  * @param {string} [statePath]
26
134
  * @returns {TowerState}
27
135
  */
28
136
  export function validateState(state, statePath) {
137
+ const info = inspectStateVersion(state, statePath)
138
+ if (info.status !== 'current') throw versionError(info, statePath)
29
139
  const where = statePath ? ` at ${statePath}` : ''
30
140
  const shapeError = stateShapeError(state, where)
31
141
  if (shapeError) fail(shapeError)
32
142
  return assertStateSemantics(state, where)
33
143
  }
144
+
145
+ /**
146
+ * The pure load-path decision: migrate an older document through the registry,
147
+ * validate the result against the target schema, then report what happened. The
148
+ * returned state is the validated migrated document; the caller decides whether
149
+ * to persist it (only while holding the state lock).
150
+ *
151
+ * @param {unknown} state
152
+ * @param {string} [statePath]
153
+ * @param {{ migrations?: readonly StateMigration[] }} [options]
154
+ * @returns {{ state: TowerState, from: number, to: number, migrated: MigrationStep[], changed: boolean }}
155
+ */
156
+ export function prepareState(state, statePath, options = {}) {
157
+ const info = inspectStateVersion(state, statePath, options.migrations)
158
+ if (info.status === 'current') {
159
+ return {
160
+ state: validateState(state, statePath),
161
+ from: SCHEMA_VERSION,
162
+ to: SCHEMA_VERSION,
163
+ migrated: [],
164
+ changed: false,
165
+ }
166
+ }
167
+ if (info.status !== 'migratable') throw versionError(info, statePath)
168
+ const outcome = migrateState(/** @type {Record<string, any>} */ (state), {
169
+ statePath,
170
+ to: info.supportedVersion,
171
+ migrations: options.migrations,
172
+ })
173
+ // Validation runs on the *target* document: a shape that was invalid under
174
+ // the old schema (a missing `tasks` array, say) may be valid after a backfill,
175
+ // and validating the target is what proves the migration did its job.
176
+ return {
177
+ state: validateState(outcome.state, statePath),
178
+ from: outcome.from,
179
+ to: outcome.to,
180
+ migrated: outcome.steps,
181
+ changed: true,
182
+ }
183
+ }
package/domain/summary.js CHANGED
@@ -1,6 +1,7 @@
1
1
  // Pure mission/finding summaries.
2
2
  // @ts-check
3
3
  import { FINDING_STATUSES } from './mission.js'
4
+ import { completionReportBytes } from './missions/completion.js'
4
5
 
5
6
  /** @typedef {import('../types/index.js').Finding} Finding */
6
7
  /** @typedef {import('../types/index.js').FindingSummary} FindingSummary */
@@ -53,6 +54,7 @@ export function missionSummary(mission) {
53
54
  reviewPolicy: mission.reviewPolicy ?? 'required-clean',
54
55
  mergedCommit: mission.mergedCommit ?? null,
55
56
  mergedAt: mission.mergedAt ?? null,
57
+ reportBytes: completionReportBytes(mission.completionReport),
56
58
  updatedAt: mission.updatedAt,
57
59
  }
58
60
  }
package/lib/archive.js ADDED
@@ -0,0 +1,279 @@
1
+ // Terminal-mission archive storage (#73).
2
+ //
3
+ // Layout, under the Tower state directory:
4
+ //
5
+ // archive/index.json compact entries; tower_status pages from here
6
+ // archive/<year>.json full missions plus their linked reviews/findings
7
+ //
8
+ // Both files carry `archiveVersion` (independent of the state SCHEMA_VERSION).
9
+ // Reads are deliberately tolerant: a truncated or foreign archive returns a
10
+ // diagnostic result and never throws, so `tower_status` still renders the hot
11
+ // state. Writes happen before the state document is compacted, and dedupe by
12
+ // mission id, so a crash between the two files is recoverable on the next
13
+ // mutation instead of losing a mission.
14
+ import path from 'node:path'
15
+ import { fsp } from '../adapters/store-fs.js'
16
+ import {
17
+ ARCHIVE_VERSION,
18
+ archiveDocumentError,
19
+ dedupeById,
20
+ indexEntryFromArchived,
21
+ } from '../domain/state/archive.js'
22
+
23
+ /** @typedef {import('../types/index.js').Mission} Mission */
24
+
25
+ /**
26
+ * @param {{ dir: string }} paths
27
+ * @returns {{ dir: string, index: string, year: (year: number) => string }}
28
+ */
29
+ export function archivePaths(paths) {
30
+ const dir = path.join(paths.dir, 'archive')
31
+ return { dir, index: path.join(dir, 'index.json'), year: (year) => path.join(dir, `${year}.json`) }
32
+ }
33
+
34
+ /**
35
+ * @param {any} error
36
+ * @returns {boolean}
37
+ */
38
+ function isMissing(error) {
39
+ return error?.code === 'ENOENT'
40
+ }
41
+
42
+ /**
43
+ * @param {string} where
44
+ * @param {string} message
45
+ * @returns {string}
46
+ */
47
+ function diagnostic(where, message) {
48
+ return `tower archive is unreadable at ${where}: ${message}`
49
+ }
50
+
51
+ /**
52
+ * Read `archive/index.json` without throwing. A missing index means "no
53
+ * archive yet"; a corrupt one is reported as `ok:false` with the diagnostic.
54
+ * @param {{ readJson: (file: string) => Promise<any> }} store
55
+ * @param {{ dir: string }} paths
56
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
57
+ */
58
+ export async function readArchiveIndex(store, paths) {
59
+ const file = archivePaths(paths).index
60
+ let document
61
+ try {
62
+ document = await store.readJson(file)
63
+ } catch (error) {
64
+ if (isMissing(error)) return { ok: true, file, missions: [], exists: false }
65
+ return { ok: false, file, missions: [], error: diagnostic(file, String(error?.message ?? error)), exists: true }
66
+ }
67
+ const error = archiveDocumentError(document, 'index', ` at ${file}`)
68
+ if (error) return { ok: false, file, missions: [], error, exists: true }
69
+ return { ok: true, file, missions: document.missions, exists: true }
70
+ }
71
+
72
+ /**
73
+ * @param {{ readJson: (file: string) => Promise<any> }} store
74
+ * @param {{ dir: string }} paths
75
+ * @param {number} year
76
+ * @returns {Promise<{ ok: boolean, file: string, missions: any[], error?: string, exists: boolean }>}
77
+ */
78
+ export async function readArchiveYear(store, paths, year) {
79
+ const file = archivePaths(paths).year(year)
80
+ let document
81
+ try {
82
+ document = await store.readJson(file)
83
+ } catch (error) {
84
+ if (isMissing(error)) return { ok: true, file, missions: [], exists: false }
85
+ return { ok: false, file, missions: [], error: diagnostic(file, String(error?.message ?? error)), exists: true }
86
+ }
87
+ const error = archiveDocumentError(document, 'year', ` at ${file}`)
88
+ if (error) return { ok: false, file, missions: [], error, exists: true }
89
+ return { ok: true, file, missions: document.missions, exists: true }
90
+ }
91
+
92
+ /**
93
+ * Append full entries to their year files, then to the index. Returns
94
+ * `ok:false` (and writes nothing for the index) when an existing archive file
95
+ * is unreadable, so a corrupt archive is never silently replaced.
96
+ * @param {{ readJson: (file: string) => Promise<any>, writeJsonAtomic: (file: string, value: unknown) => Promise<void>, ensureDir: (dir: string) => Promise<void> }} store
97
+ * @param {{ dir: string }} paths
98
+ * @param {{ entries: Array<Record<string, any>>, now: number }} input
99
+ * @returns {Promise<{ ok: boolean, count: number, files: string[], error?: string }>}
100
+ */
101
+ export async function writeArchiveEntries(store, paths, { entries, now }) {
102
+ if (entries.length === 0) return { ok: true, count: 0, files: [] }
103
+ const target = archivePaths(paths)
104
+
105
+ // Read the index first: if it is corrupt, refusing to write keeps the old
106
+ // history byte-identical and leaves the state missions intact for a retry.
107
+ const index = await readArchiveIndex(store, paths)
108
+ if (!index.ok) return { ok: false, count: 0, files: [], error: index.error }
109
+
110
+ /** @type {Map<number, Array<Record<string, any>>>} */
111
+ const byYear = new Map()
112
+ for (const entry of entries) {
113
+ const year = entry.archivedYear
114
+ if (!Number.isInteger(year)) return { ok: false, count: 0, files: [], error: `archive entry ${entry.id} has no archivedYear` }
115
+ const bucket = byYear.get(year) ?? []
116
+ bucket.push(entry)
117
+ byYear.set(year, bucket)
118
+ }
119
+
120
+ /** @type {Array<{ year: number, file: string, missions: any[] }>} */
121
+ const yearDocuments = []
122
+ for (const [year, batch] of [...byYear.entries()].sort((left, right) => left[0] - right[0])) {
123
+ const existing = await readArchiveYear(store, paths, year)
124
+ if (!existing.ok) return { ok: false, count: 0, files: [], error: existing.error }
125
+ yearDocuments.push({
126
+ year,
127
+ file: existing.file,
128
+ missions: dedupeById([...existing.missions, ...batch]),
129
+ })
130
+ }
131
+
132
+ await store.ensureDir(target.dir)
133
+ for (const document of yearDocuments) {
134
+ await store.writeJsonAtomic(document.file, {
135
+ archiveVersion: ARCHIVE_VERSION,
136
+ year: document.year,
137
+ updatedAt: now,
138
+ missions: document.missions,
139
+ })
140
+ }
141
+
142
+ const indexed = dedupeById([...index.missions, ...entries.map(indexEntryFromArchived)])
143
+ await store.writeJsonAtomic(target.index, {
144
+ archiveVersion: ARCHIVE_VERSION,
145
+ updatedAt: now,
146
+ missions: indexed,
147
+ })
148
+
149
+ return {
150
+ ok: true,
151
+ count: entries.length,
152
+ files: [...yearDocuments.map((document) => document.file), target.index],
153
+ }
154
+ }
155
+
156
+ /**
157
+ * Load one archived mission with its linked reviews/findings.
158
+ * @param {{ readJson: (file: string) => Promise<any> }} store
159
+ * @param {{ dir: string }} paths
160
+ * @param {string} missionId
161
+ * @returns {Promise<{ ok: boolean, entry?: Record<string, any>, unreadable?: boolean, missing?: boolean, error?: string }>}
162
+ */
163
+ export async function readArchivedEntry(store, paths, missionId) {
164
+ const index = await readArchiveIndex(store, paths)
165
+ if (!index.ok) return { ok: false, unreadable: true, error: index.error }
166
+ const indexEntry = index.missions.find((entry) => entry.id === missionId)
167
+ if (!indexEntry) return { ok: false, missing: true }
168
+ const year = await readArchiveYear(store, paths, indexEntry.archivedYear)
169
+ if (!year.ok) return { ok: false, unreadable: true, error: year.error }
170
+ const entry = year.missions.find((candidate) => candidate.id === missionId)
171
+ if (!entry) {
172
+ return {
173
+ ok: false,
174
+ unreadable: true,
175
+ error: `tower archive index lists ${missionId} in ${year.file}, but the year file has no such entry`,
176
+ }
177
+ }
178
+ return { ok: true, entry }
179
+ }
180
+
181
+ /**
182
+ * @param {string} file
183
+ * @param {string} kind
184
+ * @returns {Promise<{ file: string, kind: string, ok: boolean, bytes: number, missions: number, error?: string, document?: any }>}
185
+ */
186
+ async function inspectFile(file, kind) {
187
+ let text
188
+ try {
189
+ text = await fsp.readFile(file, 'utf8')
190
+ } catch (error) {
191
+ return { file, kind, ok: false, bytes: 0, missions: 0, error: diagnostic(file, String(error?.message ?? error)) }
192
+ }
193
+ const bytes = Buffer.byteLength(text)
194
+ let document
195
+ try {
196
+ document = JSON.parse(text)
197
+ } catch (error) {
198
+ return { file, kind, ok: false, bytes, missions: 0, error: diagnostic(file, `invalid JSON (${error.message})`) }
199
+ }
200
+ const error = archiveDocumentError(document, /** @type {'index'|'year'} */ (kind), ` at ${file}`)
201
+ if (error) return { file, kind, ok: false, bytes, missions: 0, error, document }
202
+ return { file, kind, ok: true, bytes, missions: document.missions.length, document }
203
+ }
204
+
205
+ /**
206
+ * Read-only inventory for `tower_doctor`: file sizes, mission counts, year
207
+ * files and every unreadable file's diagnostic. A missing archive directory is
208
+ * a healthy empty archive, not an error.
209
+ * @param {{ dir: string, state?: string }} paths
210
+ * @returns {Promise<{
211
+ * dir: string, exists: boolean, bytes: number, missions: number, years: number[],
212
+ * files: Array<Record<string, any>>, unreadable: string[],
213
+ * index: { ok: boolean, count: number, error?: string } | null,
214
+ * }>}
215
+ */
216
+ export async function inspectArchive(paths) {
217
+ const target = archivePaths(/** @type {{ dir: string }} */ (paths))
218
+ let names
219
+ try {
220
+ names = await fsp.readdir(target.dir)
221
+ } catch (error) {
222
+ if (isMissing(error)) {
223
+ return { dir: target.dir, exists: false, bytes: 0, missions: 0, years: [], files: [], unreadable: [], index: null }
224
+ }
225
+ return {
226
+ dir: target.dir,
227
+ exists: true,
228
+ bytes: 0,
229
+ missions: 0,
230
+ years: [],
231
+ files: [],
232
+ unreadable: [diagnostic(target.dir, String(error?.message ?? error))],
233
+ index: null,
234
+ }
235
+ }
236
+
237
+ const jsonFiles = names.filter((name) => name.endsWith('.json')).sort()
238
+ const files = []
239
+ let bytes = 0
240
+ let index = null
241
+ const yearIds = new Set()
242
+ for (const name of jsonFiles) {
243
+ const file = path.join(target.dir, name)
244
+ const kind = name === 'index.json' ? 'index' : 'year'
245
+ const inspected = await inspectFile(file, kind)
246
+ files.push({
247
+ file,
248
+ kind,
249
+ ok: inspected.ok,
250
+ bytes: inspected.bytes,
251
+ missions: inspected.missions,
252
+ ...(inspected.error ? { error: inspected.error } : {}),
253
+ })
254
+ bytes += inspected.bytes
255
+ if (kind === 'index') {
256
+ index = { ok: inspected.ok, count: inspected.missions, ...(inspected.error ? { error: inspected.error } : {}) }
257
+ } else if (inspected.ok) {
258
+ for (const entry of inspected.document.missions) yearIds.add(entry.id)
259
+ }
260
+ }
261
+ const unreadable = files
262
+ .filter((file) => !file.ok && typeof file.error === 'string')
263
+ .map((file) => /** @type {string} */ (file.error))
264
+ const years = files
265
+ .filter((file) => file.kind === 'year' && file.ok)
266
+ .map((file) => Number(path.basename(file.file, '.json')))
267
+ .filter((year) => Number.isInteger(year))
268
+ .sort((left, right) => left - right)
269
+ return {
270
+ dir: target.dir,
271
+ exists: true,
272
+ bytes,
273
+ missions: index?.ok ? index.count : yearIds.size,
274
+ years,
275
+ files,
276
+ unreadable,
277
+ index,
278
+ }
279
+ }
@@ -0,0 +1,66 @@
1
+ // Case-insensitive-filesystem detection (#90).
2
+ //
3
+ // On Windows and macOS the checkout's filesystem folds case, while Git reports
4
+ // the casing stored in the index. Scope matching has to know which world it is
5
+ // in, so `tower_init` probes once and records the answer in state.
6
+ //
7
+ // The probe creates one uniquely-named mixed-case file in the checkout root
8
+ // (the filesystem scope matching actually compares) and looks its lower-cased
9
+ // name up. It returns:
10
+ // true the lookup found the file: the filesystem folds case;
11
+ // false the lookup was ENOENT: the filesystem is case-sensitive;
12
+ // null the probe could not run (read-only checkout, EPERM, an unexpected
13
+ // stat failure). "Unknown" is deliberately not "case-sensitive": init
14
+ // keeps working and `tower_doctor` reports the safe default.
15
+ import path from 'node:path'
16
+ import { fsp } from '../adapters/store-fs.js'
17
+
18
+ /** Prefix is public so tests and support code can recognise a leaked probe. */
19
+ export const CASE_PROBE_PREFIX = '.tower-case-probe-'
20
+
21
+ /**
22
+ * A name that is guaranteed to differ from its lower-cased form, so the lookup
23
+ * actually distinguishes the two filesystem behaviours.
24
+ * @param {() => number} random
25
+ * @returns {string}
26
+ */
27
+ function probeName(random) {
28
+ return `${CASE_PROBE_PREFIX}Z${random().toString(36).slice(2, 10)}`
29
+ }
30
+
31
+ /**
32
+ * @param {string} dir directory on the filesystem under test
33
+ * @param {{ fs?: typeof fsp, name?: string, random?: () => number }} [options]
34
+ * `fs` is a seam for the failure branches, which cannot be produced portably
35
+ * by permissions on every OS (Windows does not honour chmod the way POSIX does).
36
+ * @returns {Promise<boolean | null>}
37
+ */
38
+ export async function detectCaseSensitivity(dir, options = {}) {
39
+ const fs = options.fs ?? fsp
40
+ const name = options.name ?? probeName(options.random ?? Math.random)
41
+ const probePath = path.join(dir, name)
42
+ const lowerPath = path.join(dir, name.toLowerCase())
43
+ if (lowerPath === probePath) return null
44
+
45
+ try {
46
+ await fs.writeFile(probePath, '')
47
+ } catch {
48
+ return null
49
+ }
50
+
51
+ try {
52
+ await fs.stat(lowerPath)
53
+ return true
54
+ } catch (error) {
55
+ const code = /** @type {{ code?: string }} */ (error)?.code
56
+ return code === 'ENOENT' ? false : null
57
+ } finally {
58
+ // Best effort: a leaked probe would dirty `git status` of the state dir and
59
+ // the detection result is already known.
60
+ try {
61
+ await fs.unlink(probePath)
62
+ } catch {
63
+ // the probe file may already be gone (or the failure branch never wrote it)
64
+ }
65
+ }
66
+ }