dsh-tower 2.1.0-beta.1 → 2.3.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.
- package/CHANGELOG.md +154 -0
- package/COMPATIBILITY.md +6 -1
- package/CONTRIBUTING.md +71 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +14 -5
- package/README.md +14 -5
- package/SECURITY.md +6 -0
- package/adapters/archive.js +279 -0
- package/adapters/caseSensitivity.js +66 -0
- package/adapters/default.js +51 -8
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +15 -6
- package/{lib → adapters}/legacy.js +20 -6
- package/adapters/persistence.js +543 -0
- package/adapters/stateFile.js +99 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +15 -7
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +18 -7
- package/app/archive.js +188 -0
- package/app/batch.js +9 -10
- package/app/board.js +4 -4
- package/app/checkCompletion.js +23 -11
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +32 -15
- package/app/init.js +59 -22
- package/app/merge.js +64 -51
- package/app/messages.js +7 -8
- package/app/mission.js +7 -5
- package/app/plan.js +20 -9
- package/app/provision.js +12 -14
- package/app/read.js +65 -6
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +17 -8
- package/app/roster.js +14 -15
- package/{lib → app}/spawn-prechecks.js +4 -3
- package/app/spawn.js +12 -13
- package/app/teardown.js +28 -8
- package/app/tower.js +121 -39
- package/docs/README.md +6 -2
- package/docs/adr/0001-ports-adapters-as-data.md +49 -0
- package/docs/adr/0002-single-state-json.md +50 -0
- package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
- package/docs/adr/0004-surface-budget.md +50 -0
- package/docs/adr/README.md +51 -0
- package/docs/agents/domain.md +38 -0
- package/docs/agents/issue-tracker.md +46 -0
- package/docs/agents/triage-labels.md +15 -0
- package/docs/agents/workflow.md +51 -0
- package/docs/architecture/overview.md +170 -37
- package/docs/guide/hot-reload.md +1 -1
- package/docs/guide/install-upgrade-migration.md +18 -1
- package/docs/guide/release.md +1 -1
- package/docs/operations/incident.md +82 -0
- package/docs/operations/runbook.md +12 -3
- package/docs/reference/blockers.md +3 -3
- package/docs/reference/config.generated.md +5 -0
- package/docs/reference/config.md +4 -0
- package/docs/reference/doctor.md +104 -8
- package/docs/reference/errors.generated.md +41 -35
- package/docs/reference/errors.md +54 -40
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +9 -8
- package/docs/reference/tools.md +9 -8
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/guide/install-upgrade-migration.md +13 -1
- package/docs/zh/reference/blockers.md +3 -3
- package/docs/zh/reference/config.md +4 -0
- package/docs/zh/reference/doctor.md +63 -2
- package/docs/zh/reference/errors.md +4 -2
- package/docs/zh/reference/tools.md +6 -6
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +51 -4
- package/domain/contracts/outputs.js +33 -3
- package/domain/contracts/tool-inputs/missions.js +2 -1
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -40
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +41 -12
- package/domain/integration/init.js +40 -4
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/mission.js +1 -1
- package/domain/missions/completion.js +72 -5
- package/domain/missions/lifecycle.js +11 -4
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/planning.js +6 -3
- package/domain/read/views.js +17 -1
- package/domain/report.js +9 -1
- package/domain/scope.js +83 -11
- package/domain/state/archive.js +257 -0
- package/domain/state/migrations.js +145 -0
- package/domain/state/schema.js +6 -0
- package/domain/state.js +154 -4
- package/domain/summary.js +2 -0
- package/domain/surface.js +248 -0
- package/host/doctor.js +555 -0
- package/{lib → host}/modeStore.js +1 -1
- package/{lib → host}/tool-registry.js +106 -31
- package/host/tower.js +32 -0
- package/index.js +12 -9
- package/package.json +10 -3
- package/ports/archive.js +18 -0
- package/ports/caseSensitivity.js +15 -0
- package/ports/clock.js +10 -2
- package/ports/config.js +19 -0
- package/ports/contract.js +38 -0
- package/ports/events.js +24 -0
- package/ports/ids.js +10 -2
- package/ports/index.js +54 -2
- package/ports/ownership.js +17 -0
- package/ports/state.js +16 -0
- package/ports/store.js +52 -3
- package/ports/vcs.js +25 -4
- package/scripts/architecture-waivers.json +19 -0
- package/scripts/architecture.mjs +634 -23
- package/scripts/check-docs.mjs +8 -35
- package/scripts/check-pack.mjs +68 -39
- package/scripts/check-philosophy.mjs +266 -0
- package/scripts/domain-metrics.mjs +13 -4
- package/scripts/generate-config-reference.mjs +14 -1
- package/scripts/generate-error-reference.mjs +68 -14
- package/scripts/generate-events-reference.mjs +83 -0
- package/scripts/generate-tools-reference.mjs +11 -0
- package/scripts/issue-audit.mjs +2 -2
- package/scripts/lib/surface.mjs +87 -0
- package/scripts/pack-manifest.txt +64 -19
- package/scripts/regression-audit.mjs +164 -0
- package/scripts/release-check.mjs +1 -1
- package/scripts/surface-baseline.json +21 -0
- package/scripts/surface-baseline.mjs +120 -0
- package/tools/adopt.js +2 -3
- package/tools/batch.js +2 -3
- package/tools/complete.js +3 -4
- package/tools/diff.js +3 -3
- package/tools/doctor.js +7 -10
- package/tools/finding.js +2 -3
- package/tools/findings.js +3 -3
- package/tools/import-legacy.js +3 -5
- package/tools/inbox.js +3 -3
- package/tools/index.js +7 -7
- package/tools/init.js +4 -7
- package/tools/interrupt.js +2 -3
- package/tools/legacy-map.js +3 -5
- package/tools/merge.js +3 -3
- package/tools/mission.js +2 -3
- package/tools/plan.js +7 -6
- package/tools/preflight.js +3 -3
- package/tools/rebase.js +3 -3
- package/tools/recover.js +38 -13
- package/tools/reload.js +3 -6
- package/tools/resource.js +3 -5
- package/tools/review.js +3 -4
- package/tools/roster.js +2 -3
- package/tools/send.js +2 -2
- package/tools/spawn.js +8 -5
- package/tools/status.js +16 -8
- package/tools/teardown.js +3 -4
- package/tools/test.js +2 -2
- package/types/index.d.ts +35 -1
- package/ui-bundle/README.md +37 -3
- package/ui-bundle/client.cards.js +356 -0
- package/ui-bundle/client.denied.js +142 -0
- package/ui-bundle/client.dock.js +333 -0
- package/ui-bundle/client.helpers.js +271 -0
- package/ui-bundle/client.js +79 -1747
- package/ui-bundle/client.locales.js +247 -0
- package/ui-bundle/client.mode.js +206 -0
- package/ui-bundle/client.overlay.js +64 -0
- package/ui-bundle/client.styles.js +151 -0
- package/ui-bundle/client.toolview.js +82 -0
- package/ui-bundle/package.json +1 -0
- package/lib/doctor.js +0 -270
- package/lib/persistence.js +0 -349
- package/lib/store.js +0 -118
- /package/{lib → app}/briefing.js +0 -0
- /package/{lib → app}/spawn-support.js +0 -0
- /package/{lib → host}/compatibility.js +0 -0
- /package/{lib → host}/config.js +0 -0
- /package/{lib → host}/hmr.js +0 -0
- /package/{lib → host}/modeState.js +0 -0
- /package/{lib → host}/presets.js +0 -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)
|
|
4
|
-
// checks in `state/semantics.js
|
|
5
|
-
//
|
|
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 `adapters/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
|
}
|
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
// Concept budget: surface metrics, the committed baseline and its classifications (#84).
|
|
2
|
+
//
|
|
3
|
+
// The repository does not measure "how many concepts a user has to hold" by
|
|
4
|
+
// counting lines. It measures the surface — config leaf keys, config sections and
|
|
5
|
+
// registered tools — and keeps a committed baseline. Growth is refused by
|
|
6
|
+
// default: a baseline decision that raises a count must name either an ADR (why
|
|
7
|
+
// the surface has to grow) or deletions that are provably gone from the live
|
|
8
|
+
// surface (the added concept replaced a removed one).
|
|
9
|
+
//
|
|
10
|
+
// This module is pure: no fs, no git. `scripts/surface-baseline.mjs` supplies
|
|
11
|
+
// the live counts and the file reads; `scripts/lib/surface.mjs` wraps them.
|
|
12
|
+
export const SURFACE_BASELINE_FILE = 'scripts/surface-baseline.json'
|
|
13
|
+
export const SURFACE_CLASSIFICATION_FILE = 'docs/reference/surface-budget.md'
|
|
14
|
+
export const SURFACE_KEYS = ['configKeys', 'sections', 'tools']
|
|
15
|
+
export const CONFIG_CLASSES = ['core', 'tuning', 'niche']
|
|
16
|
+
export const TOOL_CLASSES = ['core', 'optional']
|
|
17
|
+
export const SURFACE_SCHEMA_VERSION = 1
|
|
18
|
+
|
|
19
|
+
const ISO_DATE = /^\d{4}-\d{2}-\d{2}$/
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The human-readable count line shared by `tower_doctor` and the generated
|
|
23
|
+
* references: `config keys: N · sections: N · tools: N`.
|
|
24
|
+
* @param {{ configKeys?: number, sections?: number, tools?: number }} counts
|
|
25
|
+
*/
|
|
26
|
+
export function surfaceLine(counts = {}) {
|
|
27
|
+
const value = (key) => (Number.isInteger(counts[key]) ? String(counts[key]) : '?')
|
|
28
|
+
return `config keys: ${value('configKeys')} · sections: ${value('sections')} · tools: ${value('tools')}`
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function isPlainObject(value) {
|
|
32
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function surfaceError(message) {
|
|
36
|
+
return new Error(message)
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Parse and validate `scripts/surface-baseline.json`. Throws with an actionable
|
|
41
|
+
* message when the file is malformed; returns a normalized copy otherwise.
|
|
42
|
+
* @param {string | object} source
|
|
43
|
+
*/
|
|
44
|
+
export function parseBaseline(source) {
|
|
45
|
+
let value
|
|
46
|
+
if (typeof source === 'string') {
|
|
47
|
+
try {
|
|
48
|
+
value = JSON.parse(source)
|
|
49
|
+
} catch (error) {
|
|
50
|
+
throw surfaceError(`surface baseline is not valid JSON: ${error?.message ?? error}`)
|
|
51
|
+
}
|
|
52
|
+
} else {
|
|
53
|
+
value = source
|
|
54
|
+
}
|
|
55
|
+
if (!isPlainObject(value)) throw surfaceError('surface baseline must be a JSON object')
|
|
56
|
+
if (value.schema !== undefined && value.schema !== SURFACE_SCHEMA_VERSION) {
|
|
57
|
+
throw surfaceError(`surface baseline schema ${value.schema} is not supported (expected ${SURFACE_SCHEMA_VERSION})`)
|
|
58
|
+
}
|
|
59
|
+
if (typeof value.date !== 'string' || !ISO_DATE.test(value.date)) {
|
|
60
|
+
throw surfaceError(`surface baseline needs a date (YYYY-MM-DD), got ${JSON.stringify(value.date)}`)
|
|
61
|
+
}
|
|
62
|
+
if (!isPlainObject(value.surfaces)) throw surfaceError('surface baseline needs a "surfaces" object')
|
|
63
|
+
const surfaces = {}
|
|
64
|
+
for (const key of SURFACE_KEYS) {
|
|
65
|
+
const entry = value.surfaces[key]
|
|
66
|
+
if (!isPlainObject(entry)) throw surfaceError(`surface baseline misses the "${key}" entry`)
|
|
67
|
+
if (!Number.isInteger(entry.seed) || entry.seed < 0) {
|
|
68
|
+
throw surfaceError(`surface baseline ${key}.seed must be a non-negative integer`)
|
|
69
|
+
}
|
|
70
|
+
if (!Number.isInteger(entry.value) || entry.value < 0) {
|
|
71
|
+
throw surfaceError(`surface baseline ${key}.value must be a non-negative integer`)
|
|
72
|
+
}
|
|
73
|
+
if (!Array.isArray(entry.decisions)) throw surfaceError(`surface baseline ${key}.decisions must be an array`)
|
|
74
|
+
surfaces[key] = {
|
|
75
|
+
seed: entry.seed,
|
|
76
|
+
value: entry.value,
|
|
77
|
+
decisions: entry.decisions.map((decision, index) => {
|
|
78
|
+
if (!isPlainObject(decision)) {
|
|
79
|
+
throw surfaceError(`surface baseline ${key}.decisions[${index}] must be an object`)
|
|
80
|
+
}
|
|
81
|
+
if (!Number.isInteger(decision.from) || decision.from < 0) {
|
|
82
|
+
throw surfaceError(`surface baseline ${key}.decisions[${index}].from must be a non-negative integer`)
|
|
83
|
+
}
|
|
84
|
+
if (!Number.isInteger(decision.to) || decision.to < 0) {
|
|
85
|
+
throw surfaceError(`surface baseline ${key}.decisions[${index}].to must be a non-negative integer`)
|
|
86
|
+
}
|
|
87
|
+
return { ...decision }
|
|
88
|
+
}),
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return { schema: SURFACE_SCHEMA_VERSION, date: value.date, surfaces }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* The rows of the classification table, already parsed. `kind` is `config` or
|
|
96
|
+
* `tools`, `name` is the key/tool name, `className` is the declared class.
|
|
97
|
+
* @param {string} markdown
|
|
98
|
+
*/
|
|
99
|
+
export function parseClassification(markdown) {
|
|
100
|
+
const text = String(markdown ?? '')
|
|
101
|
+
const dateMatch = /^\s*(?:[-*]\s*)?Decision date:\s*(\d{4}-\d{2}-\d{2})\s*$/im.exec(text)
|
|
102
|
+
const entries = []
|
|
103
|
+
let kind = null
|
|
104
|
+
for (const rawLine of text.split(/\r?\n/)) {
|
|
105
|
+
const heading = /^#{1,6}\s+(.+?)\s*$/.exec(rawLine)
|
|
106
|
+
if (heading) {
|
|
107
|
+
const title = heading[1].toLowerCase()
|
|
108
|
+
kind = title.includes('config') ? 'config' : title.includes('tool') ? 'tools' : null
|
|
109
|
+
continue
|
|
110
|
+
}
|
|
111
|
+
if (!kind) continue
|
|
112
|
+
const match = /^\|\s*`([^`]+)`\s*\|\s*([a-z-]+)\s*\|\s*(.*?)\s*\|\s*$/.exec(rawLine)
|
|
113
|
+
if (!match) continue
|
|
114
|
+
entries.push({ kind, name: match[1].trim(), className: match[2].trim().toLowerCase(), note: match[3].trim() })
|
|
115
|
+
}
|
|
116
|
+
return { date: dateMatch ? dateMatch[1] : null, entries }
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Validate the classification table against the live surface. Every live key and
|
|
121
|
+
* tool must appear exactly once with a legal class; every `niche` row must carry
|
|
122
|
+
* a removal note, so "we will remove it later" is a written promise rather than
|
|
123
|
+
* an implication.
|
|
124
|
+
* @param {string} markdown
|
|
125
|
+
* @param {{ configKeys: string[], sections: string[], tools: string[] }} liveNames
|
|
126
|
+
* @param {{ baselineDate?: string }} [options]
|
|
127
|
+
* @returns {{ date: string | null, entries: Array<{ kind: string, name: string, className: string, note: string }>, findings: Array<{ code: string, level: string, message: string }> }}
|
|
128
|
+
*/
|
|
129
|
+
export function validateClassification(markdown, liveNames, options = {}) {
|
|
130
|
+
const parsed = parseClassification(markdown)
|
|
131
|
+
const findings = []
|
|
132
|
+
const text = String(markdown ?? '')
|
|
133
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
134
|
+
if (!parsed.date) {
|
|
135
|
+
record('classification-date', 'classification table needs a "Decision date: YYYY-MM-DD" line')
|
|
136
|
+
} else if (options.baselineDate && parsed.date !== options.baselineDate) {
|
|
137
|
+
record(
|
|
138
|
+
'classification-date',
|
|
139
|
+
`classification date ${parsed.date} does not match the baseline date ${options.baselineDate}`,
|
|
140
|
+
)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const checkRows = (kind, expected, legal) => {
|
|
144
|
+
const rows = parsed.entries.filter((entry) => entry.kind === kind)
|
|
145
|
+
const seen = new Map()
|
|
146
|
+
for (const row of rows) {
|
|
147
|
+
if (seen.has(row.name)) record('classification-duplicate', `${kind === 'config' ? 'config key' : 'tool'} ${row.name} is classified twice`)
|
|
148
|
+
seen.set(row.name, row)
|
|
149
|
+
if (!legal.includes(row.className)) {
|
|
150
|
+
record(
|
|
151
|
+
'classification-class',
|
|
152
|
+
`${kind === 'config' ? 'config key' : 'tool'} ${row.name} has class "${row.className}" (expected ${legal.join('/')})`,
|
|
153
|
+
)
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
for (const name of expected) {
|
|
157
|
+
if (!seen.has(name)) record('classification-missing', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not classified`)
|
|
158
|
+
}
|
|
159
|
+
const expectedSet = new Set(expected)
|
|
160
|
+
for (const name of seen.keys()) {
|
|
161
|
+
if (!expectedSet.has(name)) record('classification-unknown', `${kind === 'config' ? 'config key' : 'tool'} ${name} is not in the live surface`)
|
|
162
|
+
}
|
|
163
|
+
return rows
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const configRows = checkRows('config', liveNames.configKeys, CONFIG_CLASSES)
|
|
167
|
+
checkRows('tools', liveNames.tools, TOOL_CLASSES)
|
|
168
|
+
const nicheRows = configRows.filter((row) => row.className === 'niche')
|
|
169
|
+
for (const row of nicheRows) {
|
|
170
|
+
if (!/removal note:/i.test(row.note)) {
|
|
171
|
+
record('niche-removal-note', `niche config key ${row.name} needs a "Removal note:" in its table row`)
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
if (nicheRows.length === 0 && !/no key qualifies as niche/i.test(text)) {
|
|
175
|
+
record('niche-removal-note', 'no niche config key has a removal note and the table does not say "no key qualifies as niche"')
|
|
176
|
+
}
|
|
177
|
+
return { date: parsed.date, entries: parsed.entries, findings }
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Validate a committed baseline against the live counts and its own decision
|
|
182
|
+
* chain. This is the brake: `current[surface] !== entry.value` fails, and every
|
|
183
|
+
* decision that grows a count must name an existing ADR or deletions that are
|
|
184
|
+
* provably absent from the live surface.
|
|
185
|
+
*
|
|
186
|
+
* @param {ReturnType<typeof parseBaseline>} baseline
|
|
187
|
+
* @param {{ configKeys: number, sections: number, tools: number, liveNames: { configKeys: string[], sections: string[], tools: string[] } }} current
|
|
188
|
+
* @param {{ exists?: (relativePath: string) => boolean }} [options]
|
|
189
|
+
* @returns {Array<{ code: string, level: string, message: string }>}
|
|
190
|
+
*/
|
|
191
|
+
export function validateBaseline(baseline, current, options = {}) {
|
|
192
|
+
const exists = options.exists ?? (() => false)
|
|
193
|
+
const findings = []
|
|
194
|
+
const record = (code, message) => findings.push({ code, level: 'error', message })
|
|
195
|
+
|
|
196
|
+
for (const key of SURFACE_KEYS) {
|
|
197
|
+
const entry = baseline.surfaces[key]
|
|
198
|
+
const live = current[key]
|
|
199
|
+
if (!Number.isInteger(live)) {
|
|
200
|
+
record('surface-missing', `live ${key} count is unavailable`)
|
|
201
|
+
continue
|
|
202
|
+
}
|
|
203
|
+
if (live !== entry.value) {
|
|
204
|
+
const direction = live > entry.value ? 'grew' : 'shrank'
|
|
205
|
+
record(
|
|
206
|
+
'stale-baseline',
|
|
207
|
+
`${key} ${direction} from the committed baseline ${entry.value} to ${live}; update ${SURFACE_BASELINE_FILE} with an ADR or a deletion record before landing`,
|
|
208
|
+
)
|
|
209
|
+
}
|
|
210
|
+
let running = entry.seed
|
|
211
|
+
entry.decisions.forEach((decision, index) => {
|
|
212
|
+
const where = `${key}.decisions[${index}]`
|
|
213
|
+
if (decision.from !== running) {
|
|
214
|
+
record('broken-chain', `${where}.from ${decision.from} does not continue from ${running}`)
|
|
215
|
+
}
|
|
216
|
+
if (decision.date !== undefined && !ISO_DATE.test(String(decision.date))) {
|
|
217
|
+
record('broken-chain', `${where}.date is not YYYY-MM-DD`)
|
|
218
|
+
}
|
|
219
|
+
const growth = decision.to - decision.from
|
|
220
|
+
if (growth > 0) {
|
|
221
|
+
const deletions = Array.isArray(decision.deletions)
|
|
222
|
+
? decision.deletions.filter((name) => typeof name === 'string' && name.trim()).map((name) => name.trim())
|
|
223
|
+
: []
|
|
224
|
+
const adr = typeof decision.adr === 'string' && decision.adr.trim() ? decision.adr.trim().replace(/\\/g, '/') : null
|
|
225
|
+
const adrExists = Boolean(adr) && adr.startsWith('docs/adr/') && adr.endsWith('.md') && exists(adr)
|
|
226
|
+
const liveNames = new Set(current.liveNames?.[key] ?? [])
|
|
227
|
+
const stillLive = deletions.filter((name) => liveNames.has(name))
|
|
228
|
+
const deletionEvidence = deletions.length >= growth && stillLive.length === 0
|
|
229
|
+
if (!adrExists && !deletionEvidence) {
|
|
230
|
+
const reasons = []
|
|
231
|
+
if (adr && !adrExists) reasons.push(`ADR ${adr} does not exist under docs/adr/`)
|
|
232
|
+
if (deletions.length === 0) reasons.push('no deletions recorded')
|
|
233
|
+
else if (stillLive.length > 0) reasons.push(`deleted name(s) are still in the live surface: ${stillLive.join(', ')}`)
|
|
234
|
+
else if (deletions.length < growth) reasons.push(`only ${deletions.length} deletion(s) for a growth of ${growth}`)
|
|
235
|
+
record('growth-without-evidence', `${where} grows ${key} by ${growth} without an ADR or a matching deletion (${reasons.join('; ')})`)
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
running = decision.to
|
|
239
|
+
})
|
|
240
|
+
if (running !== entry.value && !(entry.decisions.length === 0 && entry.value < running)) {
|
|
241
|
+
record(
|
|
242
|
+
'broken-chain',
|
|
243
|
+
`${key} value ${entry.value} does not match the last decision (${running}); every count change needs a decision record`,
|
|
244
|
+
)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
return findings
|
|
248
|
+
}
|