dsh-tower 2.6.0 → 2.7.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 +82 -0
- package/COMPATIBILITY.md +1 -0
- package/CONTRIBUTING.md +1 -1
- package/README.en.md +2 -1
- package/README.md +6 -1
- package/adapters/leases.js +20 -6
- package/adapters/legacy.js +149 -37
- package/adapters/stateFile.js +11 -1
- package/app/archive.js +30 -5
- package/app/briefing.js +95 -12
- package/app/checkCompletion.js +107 -18
- package/app/enrichedMissions.js +1 -1
- package/app/freshness.js +77 -0
- package/app/gate.js +26 -12
- package/app/merge.js +7 -4
- package/app/mission.js +24 -1
- package/app/missionContext.js +2 -1
- package/app/plan.js +64 -1
- package/app/provision.js +133 -22
- package/app/read.js +38 -5
- package/app/rebase.js +10 -8
- package/app/review.js +97 -61
- package/app/reviewCheckout.js +133 -0
- package/app/sandbox.js +121 -0
- package/app/sharedCheckout.js +90 -0
- package/app/spawn-prechecks.js +25 -24
- package/app/spawn-support.js +59 -8
- package/app/spawn.js +3 -0
- package/app/teardown.js +119 -2
- package/app/tower.js +29 -1
- package/cordis.patch.yml +50 -0
- package/docs/adr/0006-mission-shapes.md +306 -0
- package/docs/architecture/overview.md +6 -4
- package/docs/guide/install-upgrade-migration.md +26 -0
- package/docs/operations/runbook.md +1 -1
- package/docs/reference/blockers.md +8 -4
- package/docs/reference/component-surface.md +7 -2
- package/docs/reference/config.generated.md +6 -4
- package/docs/reference/config.md +1 -2
- package/docs/reference/doctor.md +7 -7
- package/docs/reference/operations.md +1 -1
- package/docs/reference/shapes.md +123 -0
- package/docs/reference/surface-budget.md +33 -1
- package/docs/reference/testing.md +4 -2
- package/docs/reference/tools.generated.md +5 -5
- package/docs/reference/tools.md +2 -2
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/config.md +1 -2
- package/docs/zh/reference/testing.md +3 -2
- package/domain/batch.js +75 -105
- package/domain/collab/batch.js +30 -10
- package/domain/collab/spawn.js +53 -16
- package/domain/config/schema.js +47 -17
- package/domain/contracts/outputs.js +0 -1
- package/domain/contracts/tool-inputs/missions.js +9 -1
- package/domain/evidence.js +283 -0
- package/domain/gate.js +11 -31
- package/domain/integration/gate.js +17 -43
- package/domain/integration/init.js +3 -2
- package/domain/integration/provision.js +51 -0
- package/domain/integration/rebase.js +16 -1
- package/domain/mission.js +16 -33
- package/domain/missions/completion.js +46 -8
- package/domain/missions/findings.js +2 -1
- package/domain/missions/lifecycle.js +131 -35
- package/domain/missions/plan.js +30 -6
- package/domain/missions/review.js +2 -18
- package/domain/planning.js +5 -5
- package/domain/read/missionContext.js +17 -3
- package/domain/read/views.js +5 -1
- package/domain/roles.js +222 -0
- package/domain/roster.js +9 -3
- package/domain/scope.js +51 -0
- package/domain/shapes/build.js +254 -0
- package/domain/shapes/index.js +171 -0
- package/domain/shapes/survey.js +445 -0
- package/domain/state/archive.js +28 -4
- package/domain/state/migrations.js +111 -4
- package/domain/state/schema.js +69 -7
- package/domain/state/semantics.js +93 -13
- package/domain/state.js +5 -1
- package/domain/summary.js +5 -11
- package/domain/vocabulary.js +15 -33
- package/host/capability-registry.js +25 -9
- package/host/doctor.js +2 -1
- package/host/preset-survey-sandbox.js +19 -0
- package/host/preset-survey.js +18 -0
- package/host/presets.js +8 -3
- package/host/tool-registry.js +14 -12
- package/locale/en.json +16 -1
- package/locale/preset-survey/en.json +6 -0
- package/locale/preset-survey/zh.json +6 -0
- package/locale/preset-survey-sandbox/en.json +6 -0
- package/locale/preset-survey-sandbox/zh.json +6 -0
- package/locale/zh.json +16 -1
- package/package.json +13 -2
- package/ports/state.js +8 -1
- package/scripts/architecture-waivers.json +3 -3
- package/scripts/check-docs.mjs +1 -1
- package/scripts/check-pack.mjs +2 -0
- package/scripts/component-surface.json +68 -20
- package/scripts/generate-capability-locale.mjs +5 -0
- package/scripts/generate-shapes-reference.mjs +171 -0
- package/scripts/mount-smoke/surface.mjs +3 -0
- package/scripts/pack-manifest.txt +21 -0
- package/scripts/shape-audit.mjs +361 -0
- package/scripts/surface-baseline.json +68 -4
- package/survey-sandbox.js +48 -0
- package/survey.js +40 -0
- package/tools/complete.js +5 -5
- package/tools/diff.js +5 -3
- package/tools/plan.js +1 -1
- package/tools/preflight.js +3 -2
- package/tools/spawn.js +47 -49
- package/tools/test.js +2 -1
- package/types/index.d.ts +66 -5
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// Generate the shape reference from the one shape registry (#149).
|
|
2
|
+
//
|
|
3
|
+
// Every fact in docs/reference/shapes.md comes from domain/shapes/index.js and
|
|
4
|
+
// the two shape modules, so documentation cannot drift from the policy the
|
|
5
|
+
// generic layers actually resolve. `--check` is wired into `npm run check`.
|
|
6
|
+
import fs from 'node:fs'
|
|
7
|
+
import path from 'node:path'
|
|
8
|
+
import { fileURLToPath } from 'node:url'
|
|
9
|
+
import { SHAPE_KINDS, shapeFor } from '../domain/shapes/index.js'
|
|
10
|
+
import { RULES } from './shape-audit.mjs'
|
|
11
|
+
|
|
12
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
13
|
+
const target = path.join(root, 'docs', 'reference', 'shapes.md')
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Resolve an audit rule by id. A verdict that names a rule the audit does not
|
|
17
|
+
* run fails generation, so the reference can never describe an unenforced rule.
|
|
18
|
+
* @param {string} id
|
|
19
|
+
*/
|
|
20
|
+
function fitness(id) {
|
|
21
|
+
const rule = RULES.find((entry) => entry.id === id)
|
|
22
|
+
if (!rule) throw new Error(`unknown shape-audit rule "${id}" named by the shapes reference generator`)
|
|
23
|
+
return `\`${rule.id}\` — ${rule.rule}`
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** Rule ids as code spans; throws if a verdict names a rule the audit does not run. */
|
|
27
|
+
function rules(...ids) {
|
|
28
|
+
for (const id of ids) {
|
|
29
|
+
if (!RULES.some((entry) => entry.id === id)) {
|
|
30
|
+
throw new Error(`unknown shape-audit rule "${id}" named by the shapes reference generator`)
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
return ids.map((id) => `\`${id}\``).join(', ')
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const fitnessRules = RULES.map((rule) => `| \`${rule.id}\` | \`${rule.owner}\` | ${rule.rule} |`)
|
|
37
|
+
|
|
38
|
+
const rows = SHAPE_KINDS.map((kind) => {
|
|
39
|
+
const shape = shapeFor(kind)
|
|
40
|
+
const hasTerminal = shape.statuses.some((status) => shape.isTerminal(status))
|
|
41
|
+
// #148 W2: a shape with a sandbox dimension resolves its placement from
|
|
42
|
+
// tuning config, so the reference names both placements instead of the static
|
|
43
|
+
// default only. Both placements render as text: a raw `|` would split the cell.
|
|
44
|
+
const provisioning = shape.fields.sandbox ? '`shared` / `detached`' : `\`${shape.provisioning}\``
|
|
45
|
+
return `| \`${kind}\` | \`${shape.statuses.join('`, `')}\` | ${hasTerminal ? 'yes' : 'no'} | ${provisioning} | ${shape.canMerge} | ${shape.canRebase} | ${shape.reach.ownsPaths} / \`${shape.reach.fence}\` | ${shape.requiredComponent ?? '—'} | ${shape.acceptsResult} |`
|
|
46
|
+
})
|
|
47
|
+
|
|
48
|
+
const transitions = SHAPE_KINDS.map((kind) => {
|
|
49
|
+
const shape = shapeFor(kind)
|
|
50
|
+
const body = shape.statuses
|
|
51
|
+
.map((status) => {
|
|
52
|
+
const targets = shape.transitions[status] ?? []
|
|
53
|
+
return `| \`${status}\` | ${shape.isTerminal(status) ? 'terminal' : targets.map((target) => `\`${target}\``).join(', ') || '—'} |`
|
|
54
|
+
})
|
|
55
|
+
.join('\n')
|
|
56
|
+
return `### ${kind}\n\n| From | To |\n|---|---|\n${body}`
|
|
57
|
+
})
|
|
58
|
+
|
|
59
|
+
const invariants = SHAPE_KINDS.map((kind) => {
|
|
60
|
+
const { completion } = shapeFor(kind)
|
|
61
|
+
const flags = Object.entries(completion)
|
|
62
|
+
.filter(([key]) => key.startsWith('requires'))
|
|
63
|
+
.map(([key, value]) => `${key}=${value}`)
|
|
64
|
+
.join(', ')
|
|
65
|
+
return `| \`${kind}\` | ${flags} | \`${completion.check.name}\` |`
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
const expected = `# Mission shapes (generated)
|
|
69
|
+
|
|
70
|
+
> Generated by \`npm run docs:generate:shapes\` from \`domain/shapes/index.js\`. Do not edit by hand.
|
|
71
|
+
|
|
72
|
+
A mission shape is the single place its kind's contract lives (#148/#149). Generic
|
|
73
|
+
layers resolve \`shapeFor(mission.kind)\` and never branch on a kind literal; the
|
|
74
|
+
shape audit (\`npm run shape:audit\`) refuses kind and role comparisons outside
|
|
75
|
+
\`domain/shapes/\`, the global \`TERMINAL_STATUSES\`/\`TRANSITIONS\` vocabulary,
|
|
76
|
+
one-argument \`isTerminal\`/\`canTransition\` calls, and the dimension regressions
|
|
77
|
+
recorded in the inventory below (review tip, batch status, lease ledger, blocked
|
|
78
|
+
writes, evidence reads, legacy vocabulary, path permission and role/shape
|
|
79
|
+
conflation).
|
|
80
|
+
|
|
81
|
+
## Kinds
|
|
82
|
+
|
|
83
|
+
| Kind | Statuses | Has terminal | Provisioning | Can merge | Can rebase | Reach (owns / fence) | Component | Accepts result |
|
|
84
|
+
|---|---|---|---|---|---|---|---|---|
|
|
85
|
+
${rows.join('\n')}
|
|
86
|
+
|
|
87
|
+
## Transitions
|
|
88
|
+
|
|
89
|
+
${transitions.join('\n\n')}
|
|
90
|
+
|
|
91
|
+
## Survey placement (#148 W2)
|
|
92
|
+
|
|
93
|
+
\`survey.sandbox\` is tuning, not a component: the \`tower-survey\` row stays the
|
|
94
|
+
switch. The shape resolves both the placement and the preset, so generic layers
|
|
95
|
+
never read the raw key or compare a kind.
|
|
96
|
+
|
|
97
|
+
The decision is made once, at provisioning time. A mission with a recorded
|
|
98
|
+
\`mission.sandbox\` keeps the detached placement and its completion invariants
|
|
99
|
+
even if \`survey.sandbox\` later changes — a config flip can neither skip the
|
|
100
|
+
sandbox HEAD/zero-commit checks nor strand the disposable checkout. A survey
|
|
101
|
+
that never got a sandbox keeps the shared invariants for the same reason.
|
|
102
|
+
|
|
103
|
+
| \`survey.sandbox\` | Provisioning | Preset | Completion invariants |
|
|
104
|
+
|---|---|---|---|
|
|
105
|
+
| \`none\` (default) | \`shared\`: no checkout, the agent reads the main checkout | \`tower-survey\` (strictly read-only: no write, edit, or shell tool) | HEAD equals \`readCommit\`, tracked-status fingerprint unchanged, zero commits |
|
|
106
|
+
| \`detached\` | \`detached\`: \`git worktree add --detach <worktreeRoot>/<id>-sandbox <readCommit>\` (no branch, no \`baseCommit\`, never merged) | \`tower-survey-sandbox\` (read/write/edit/pwsh plus the mission tools; spawn/merge/rebase/review/diff still absent) | sandbox HEAD equals \`readCommit\`, zero commits; disposable write/shell side effects are ignored; sandbox removed on completion, abandon or teardown with a bounded retry |
|
|
107
|
+
|
|
108
|
+
## Completion invariants
|
|
109
|
+
|
|
110
|
+
| Kind | Invariants | Check |
|
|
111
|
+
|---|---|---|
|
|
112
|
+
${invariants.join('\n')}
|
|
113
|
+
|
|
114
|
+
## Role
|
|
115
|
+
|
|
116
|
+
| Contract | Owner |
|
|
117
|
+
|---|---|
|
|
118
|
+
| Status, transitions, terminality, merge/rebase, provisioning, reach, plan fields, completion, dependencies, briefing, summary | \`domain/shapes/<kind>.js\` |
|
|
119
|
+
| Kind resolution and the action guard | \`domain/shapes/index.js\` |
|
|
120
|
+
| Role vocabulary and the role contract (preset, model, placement, briefing, prerequisites) | \`domain/roles.js\` |
|
|
121
|
+
| Generated reference and the audit | \`scripts/generate-shapes-reference.mjs\`, \`scripts/shape-audit.mjs\` |
|
|
122
|
+
|
|
123
|
+
## Dimension inventory (#149)
|
|
124
|
+
|
|
125
|
+
Every dimension the registry audit covers. A **shape-owned** verdict lives in
|
|
126
|
+
\`domain/shapes/<kind>.js\`; a **correctly generic** verdict has one owner module
|
|
127
|
+
outside the shapes; a **redesigned** verdict replaced an earlier ambiguous
|
|
128
|
+
contract. The fitness rule is the mechanical check the audit (or the generated
|
|
129
|
+
invariant) enforces.
|
|
130
|
+
|
|
131
|
+
| Dimension | Verdict | Owner module | Fitness rule |
|
|
132
|
+
|---|---|---|---|
|
|
133
|
+
| Mission lifecycle | **Shape-owned.** Statuses, transitions and terminality are per kind. | \`domain/shapes/<kind>.js\` | ${rules('kind-literal', 'global-status-vocabulary', 'shape-entry-point')} |
|
|
134
|
+
| Role vocabulary | **Correctly generic.** Roles are actors, not mission kinds. | \`domain/roles.js\` | ${rules('role-literal')} |
|
|
135
|
+
| Provisioning | **Shape-owned.** A build gets a branch worktree; a survey is shared or a detached sandbox. | \`domain/shapes/<kind>.js\` | Generated invariant: only a build creates a branch/worktree; a recorded \`mission.sandbox\` decides a survey's placement. |
|
|
136
|
+
| Read-only completion | **Shape-owned.** \`resolveCompletion\` derives invariants from the recorded placement. | \`domain/shapes/survey.js\` | Generated invariant: shared survey keeps HEAD and tracked status; detached survey keeps HEAD pinned; both make zero commits. |
|
|
137
|
+
| Dependency | **Shape-owned.** \`dependencySatisfied\` names the terminal statuses that satisfy a dependent. | \`domain/shapes/<kind>.js\` | Generated invariant: a completed survey satisfies a dependent build; a merged build does too. |
|
|
138
|
+
| Archive / reanchor / planning | **Shape-owned.** Terminality is the one archive/reanchor predicate. | \`domain/shapes/<kind>.js\` | Generated invariant: a terminal shape leaves open work and never blocks reanchor. |
|
|
139
|
+
| Completion result | **Shape-owned.** Only a survey may record a completion result. | \`domain/shapes/<kind>.js\` | Generated invariant: \`shape.acceptsResult\`. |
|
|
140
|
+
| Required component | **Shape-owned.** A survey needs the \`tower-survey\` row. | \`domain/shapes/<kind>.js\` | Generated invariant: planning refuses with an actionable code while the required component is off. |
|
|
141
|
+
| Review | **Redesigned.** Commit-stamped evidence: a reviewer reads a disposable checkout at the recorded review commit; only a shape with a branch is reviewable. | \`app/review.js\`, \`app/reviewCheckout.js\`, \`domain/missions/review.js\` | ${fitness('review-tip-read')} |
|
|
142
|
+
| Batch | **Correctly generic second lifecycle.** A batch is finite evidence over missions, not a mission kind; no shape knows about batches. | \`domain/batch.js\`, \`domain/collab/batch.js\`, \`app/batch.js\` | ${fitness('batch-status-literal')} |
|
|
143
|
+
| Leases | **Correctly generic.** One fail-closed admission point; expiry is recovered only by an explicit \`force\` release. | \`adapters/leases.js\`, \`app/spawn-prechecks.js\` | ${fitness('lease-ledger')} |
|
|
144
|
+
| Paused vs blocked | **Redesigned.** \`blocked\` is derived machine state; \`paused\` is a deliberate stop with a cause. | \`domain/missions/lifecycle.js\`, \`domain/state/semantics.js\` | ${fitness('blocked-status-write')} |
|
|
145
|
+
| Test receipts / evidence | **Redesigned — one reader.** Completion checks and discovery receipts are one normalized evidence contract in \`domain/evidence.js\`; the shape declares whether a receipt is tip-bound. \`tower_complete\` no longer copies a synthetic \`tests\` check into the report. | \`domain/evidence.js\`; \`domain/shapes/<kind>.js\` declares \`evidence\` | ${fitness('evidence-single-read')} |
|
|
146
|
+
| Legacy import | **Redesigned — strict adapter.** One payload is one declared format; status remaps, assumed kinds and non-carried fields are reported as \`remapped\`/\`unresolved\`/\`dropped\` instead of approximated. Import stays read-only and never resumes a paused mission. | \`adapters/legacy.js\`; \`domain/batch.js\` reads only the canonical \`pausedMissionIds\` | ${fitness('legacy-vocabulary')} |
|
|
147
|
+
| Scope ownership | **Redesigned — shape.reach.** The shape owns path permission (\`reach.ownsPaths\`, \`reach.fence\`); the gate, planner, lifecycle and auto-deps derive from it through one fence. | \`domain/scope.js\` + \`shape.reach\` in \`domain/shapes/<kind>.js\` | ${fitness('scope-reach')} |
|
|
148
|
+
| Role vs kind | **Redesigned — orthogonal role contract.** Spawn, preset, model, placement, briefing, roster budget and gates resolve the role through \`domain/roles.js\`; the shape only supplies facts. | \`domain/roles.js\`; callers \`tools/spawn.js\`, \`app/spawn-prechecks.js\`, \`app/spawn-support.js\`, \`domain/roster.js\`, \`host/presets.js\` | ${fitness('role-shape-conflation')} |
|
|
149
|
+
|
|
150
|
+
## Fitness rules
|
|
151
|
+
|
|
152
|
+
Generated from \`scripts/shape-audit.mjs\` \`RULES\`; the audit is wired into \`npm run check\`.
|
|
153
|
+
|
|
154
|
+
| Rule | Owner module | Contract |
|
|
155
|
+
|---|---|---|
|
|
156
|
+
${fitnessRules.join('\n')}
|
|
157
|
+
`
|
|
158
|
+
|
|
159
|
+
if (process.argv.includes('--check')) {
|
|
160
|
+
const actual = fs.existsSync(target) ? fs.readFileSync(target, 'utf8') : ''
|
|
161
|
+
if (actual !== expected) {
|
|
162
|
+
console.error('shapes reference is stale; run npm run docs:generate:shapes')
|
|
163
|
+
process.exit(1)
|
|
164
|
+
}
|
|
165
|
+
console.log(`shapes reference: ${SHAPE_KINDS.length} shapes in sync`)
|
|
166
|
+
} else {
|
|
167
|
+
fs.mkdirSync(path.dirname(target), { recursive: true })
|
|
168
|
+
fs.writeFileSync(target, expected)
|
|
169
|
+
console.log(`wrote ${path.relative(root, target)} (${SHAPE_KINDS.length} shapes)`)
|
|
170
|
+
}
|
|
171
|
+
process.exit(0)
|
|
@@ -36,6 +36,7 @@ export const OPTIONAL_ROW_IDS = [
|
|
|
36
36
|
'tower-import',
|
|
37
37
|
'tower-leases',
|
|
38
38
|
'tower-skills',
|
|
39
|
+
'tower-survey',
|
|
39
40
|
'tower-tests',
|
|
40
41
|
]
|
|
41
42
|
|
|
@@ -67,6 +68,8 @@ export function defaultOffToolIds() {
|
|
|
67
68
|
export const PRESET_ROWS = Object.freeze([
|
|
68
69
|
Object.freeze({ id: 'tower-worker', specifier: 'preset-worker', name: 'Tower Worker', order: 60 }),
|
|
69
70
|
Object.freeze({ id: 'tower-reviewer', specifier: 'preset-reviewer', name: 'Tower Reviewer', order: 61 }),
|
|
71
|
+
Object.freeze({ id: 'tower-survey', specifier: 'preset-survey', name: 'Tower Survey', order: 62 }),
|
|
72
|
+
Object.freeze({ id: 'tower-survey-sandbox', specifier: 'preset-survey-sandbox', name: 'Tower Survey Sandbox', order: 63 }),
|
|
70
73
|
])
|
|
71
74
|
|
|
72
75
|
/** Presets the bundle declares for its worker and reviewer roles. */
|
|
@@ -28,6 +28,7 @@ app/briefing.js
|
|
|
28
28
|
app/checkCompletion.js
|
|
29
29
|
app/enrichedMissions.js
|
|
30
30
|
app/findings.js
|
|
31
|
+
app/freshness.js
|
|
31
32
|
app/gate.js
|
|
32
33
|
app/init.js
|
|
33
34
|
app/merge.js
|
|
@@ -40,7 +41,10 @@ app/read.js
|
|
|
40
41
|
app/rebase.js
|
|
41
42
|
app/recording.js
|
|
42
43
|
app/review.js
|
|
44
|
+
app/reviewCheckout.js
|
|
43
45
|
app/roster.js
|
|
46
|
+
app/sandbox.js
|
|
47
|
+
app/sharedCheckout.js
|
|
44
48
|
app/spawn-prechecks.js
|
|
45
49
|
app/spawn-support.js
|
|
46
50
|
app/spawn.js
|
|
@@ -54,6 +58,7 @@ docs/adr/0002-single-state-json.md
|
|
|
54
58
|
docs/adr/0003-allow-agent-control-default-true.md
|
|
55
59
|
docs/adr/0004-surface-budget.md
|
|
56
60
|
docs/adr/0005-component-surface.md
|
|
61
|
+
docs/adr/0006-mission-shapes.md
|
|
57
62
|
docs/adr/README.md
|
|
58
63
|
docs/agents/domain.md
|
|
59
64
|
docs/agents/issue-tracker.md
|
|
@@ -80,6 +85,7 @@ docs/reference/errors.generated.md
|
|
|
80
85
|
docs/reference/errors.md
|
|
81
86
|
docs/reference/events.md
|
|
82
87
|
docs/reference/operations.md
|
|
88
|
+
docs/reference/shapes.md
|
|
83
89
|
docs/reference/surface-budget.md
|
|
84
90
|
docs/reference/testing.md
|
|
85
91
|
docs/reference/tools.generated.md
|
|
@@ -116,6 +122,7 @@ domain/contracts/tool-inputs/missions.js
|
|
|
116
122
|
domain/contracts/tool-inputs/ops.js
|
|
117
123
|
domain/contracts/tool-inputs/shared.js
|
|
118
124
|
domain/errors.js
|
|
125
|
+
domain/evidence.js
|
|
119
126
|
domain/events.js
|
|
120
127
|
domain/gate.js
|
|
121
128
|
domain/integration/gate.js
|
|
@@ -138,8 +145,12 @@ domain/read/enrichedMissions.js
|
|
|
138
145
|
domain/read/missionContext.js
|
|
139
146
|
domain/read/views.js
|
|
140
147
|
domain/report.js
|
|
148
|
+
domain/roles.js
|
|
141
149
|
domain/roster.js
|
|
142
150
|
domain/scope.js
|
|
151
|
+
domain/shapes/build.js
|
|
152
|
+
domain/shapes/index.js
|
|
153
|
+
domain/shapes/survey.js
|
|
143
154
|
domain/state.js
|
|
144
155
|
domain/state/archive.js
|
|
145
156
|
domain/state/migrations.js
|
|
@@ -163,6 +174,8 @@ host/hmr.js
|
|
|
163
174
|
host/modeState.js
|
|
164
175
|
host/modeStore.js
|
|
165
176
|
host/preset-reviewer.js
|
|
177
|
+
host/preset-survey-sandbox.js
|
|
178
|
+
host/preset-survey.js
|
|
166
179
|
host/preset-worker.js
|
|
167
180
|
host/presets.js
|
|
168
181
|
host/tool-registry.js
|
|
@@ -184,6 +197,10 @@ locale/leases/en.json
|
|
|
184
197
|
locale/leases/zh.json
|
|
185
198
|
locale/preset-reviewer/en.json
|
|
186
199
|
locale/preset-reviewer/zh.json
|
|
200
|
+
locale/preset-survey-sandbox/en.json
|
|
201
|
+
locale/preset-survey-sandbox/zh.json
|
|
202
|
+
locale/preset-survey/en.json
|
|
203
|
+
locale/preset-survey/zh.json
|
|
187
204
|
locale/preset-worker/en.json
|
|
188
205
|
locale/preset-worker/zh.json
|
|
189
206
|
locale/skills/en.json
|
|
@@ -219,6 +236,7 @@ scripts/generate-component-surface.mjs
|
|
|
219
236
|
scripts/generate-config-reference.mjs
|
|
220
237
|
scripts/generate-error-reference.mjs
|
|
221
238
|
scripts/generate-events-reference.mjs
|
|
239
|
+
scripts/generate-shapes-reference.mjs
|
|
222
240
|
scripts/generate-tools-reference.mjs
|
|
223
241
|
scripts/generate-types.mjs
|
|
224
242
|
scripts/issue-audit.mjs
|
|
@@ -235,6 +253,7 @@ scripts/pack-manifest.txt
|
|
|
235
253
|
scripts/regression-audit.mjs
|
|
236
254
|
scripts/release-check.mjs
|
|
237
255
|
scripts/release-notes.mjs
|
|
256
|
+
scripts/shape-audit.mjs
|
|
238
257
|
scripts/surface-baseline.json
|
|
239
258
|
scripts/surface-baseline.mjs
|
|
240
259
|
scripts/types-coverage.mjs
|
|
@@ -244,6 +263,8 @@ skills.js
|
|
|
244
263
|
skills/tower-recovery.md
|
|
245
264
|
skills/tower-review.md
|
|
246
265
|
skills/tower-worker-protocol.md
|
|
266
|
+
survey-sandbox.js
|
|
267
|
+
survey.js
|
|
247
268
|
tools/adopt.js
|
|
248
269
|
tools/batch.js
|
|
249
270
|
tools/complete.js
|
|
@@ -0,0 +1,361 @@
|
|
|
1
|
+
// Shape audit (#149): one shape registry means generic layers must not branch on
|
|
2
|
+
// a kind or a role, and the old global status vocabulary must not come back.
|
|
3
|
+
//
|
|
4
|
+
// The audit is the ongoing mechanism, not a one-off cleanup: it fails on
|
|
5
|
+
// - a comparison against a kind literal ('build' | 'survey'),
|
|
6
|
+
// - a comparison against a role literal ('worker' | 'reviewer' | 'tower'),
|
|
7
|
+
// - the global TERMINAL_STATUSES / TRANSITIONS identifiers,
|
|
8
|
+
// - a one-argument isTerminal(...) or one-argument canTransition(...) call,
|
|
9
|
+
// - reading a review tip from a live worktree HEAD (review evidence is a commit),
|
|
10
|
+
// - comparing a batch status against a literal outside the batch owner module,
|
|
11
|
+
// - naming the lease ledger outside the lease adapter,
|
|
12
|
+
// - reading completion checks / test receipts outside domain/evidence.js,
|
|
13
|
+
// - the legacy format/status vocabulary outside adapters/legacy.js,
|
|
14
|
+
// - owning or re-implementing path permission instead of shape.reach + domain/scope.js,
|
|
15
|
+
// - combining a role decision with a shape decision outside domain/roles.js.
|
|
16
|
+
//
|
|
17
|
+
// Exemptions live in scripts/architecture-waivers.json under `shapeWaivers` and
|
|
18
|
+
// require a reason plus an owner or a ref, exactly like the architecture gate.
|
|
19
|
+
// A waiver with no reason is a contract failure, and a waiver for a path that no
|
|
20
|
+
// longer violates anything is reported as stale.
|
|
21
|
+
//
|
|
22
|
+
// Usage: node scripts/shape-audit.mjs [--root <path>] [--json]
|
|
23
|
+
import fs from 'node:fs'
|
|
24
|
+
import path from 'node:path'
|
|
25
|
+
import { fileURLToPath } from 'node:url'
|
|
26
|
+
|
|
27
|
+
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
|
|
28
|
+
export const WAIVER_FILE = 'scripts/architecture-waivers.json'
|
|
29
|
+
export const WAIVER_KEY = 'shapeWaivers'
|
|
30
|
+
export const SCAN_DIRS = ['domain', 'app', 'tools', 'host', 'adapters', 'ports']
|
|
31
|
+
export const SHAPE_DIR = 'domain/shapes'
|
|
32
|
+
/**
|
|
33
|
+
* The fitness rules this audit enforces. `docs/reference/shapes.md` is generated
|
|
34
|
+
* from this list, so a verdict cannot name a rule the audit does not run.
|
|
35
|
+
*/
|
|
36
|
+
export const RULES = [
|
|
37
|
+
{
|
|
38
|
+
id: 'kind-literal',
|
|
39
|
+
rule: 'Generic layers never compare mission.kind with a literal.',
|
|
40
|
+
owner: 'domain/shapes/<kind>.js',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
id: 'role-literal',
|
|
44
|
+
rule: 'Generic layers use the vocabulary role predicates, never a role literal.',
|
|
45
|
+
owner: 'domain/roles.js',
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
id: 'global-status-vocabulary',
|
|
49
|
+
rule: 'No module reaches for the global TERMINAL_STATUSES/TRANSITIONS tables.',
|
|
50
|
+
owner: 'domain/shapes/<kind>.js',
|
|
51
|
+
},
|
|
52
|
+
{
|
|
53
|
+
id: 'shape-entry-point',
|
|
54
|
+
rule: 'isTerminal(kind, status) and canTransition(kind, from, to) are the only entry points.',
|
|
55
|
+
owner: 'domain/shapes/index.js',
|
|
56
|
+
},
|
|
57
|
+
{
|
|
58
|
+
id: 'review-tip-read',
|
|
59
|
+
rule: 'A review never reads a live worktree HEAD; verdicts are stamped with a recorded commit.',
|
|
60
|
+
owner: 'app/reviewCheckout.js',
|
|
61
|
+
},
|
|
62
|
+
{
|
|
63
|
+
id: 'batch-status-literal',
|
|
64
|
+
rule: 'Batch status literals live in domain/batch.js; every other layer uses its constants.',
|
|
65
|
+
owner: 'domain/batch.js',
|
|
66
|
+
},
|
|
67
|
+
{
|
|
68
|
+
id: 'lease-ledger',
|
|
69
|
+
rule: 'Only adapters/leases.js reads or writes the lease ledger.',
|
|
70
|
+
owner: 'adapters/leases.js',
|
|
71
|
+
},
|
|
72
|
+
{
|
|
73
|
+
id: 'blocked-status-write',
|
|
74
|
+
rule: 'blocked is derived from open blockers; only domain/missions/lifecycle.js writes it.',
|
|
75
|
+
owner: 'domain/missions/lifecycle.js',
|
|
76
|
+
},
|
|
77
|
+
{
|
|
78
|
+
id: 'evidence-single-read',
|
|
79
|
+
rule: 'Completion checks and test receipts have one reader: domain/evidence.js.',
|
|
80
|
+
owner: 'domain/evidence.js',
|
|
81
|
+
},
|
|
82
|
+
{
|
|
83
|
+
id: 'legacy-vocabulary',
|
|
84
|
+
rule: 'One live vocabulary: legacy formats/statuses are read only by adapters/legacy.js.',
|
|
85
|
+
owner: 'adapters/legacy.js',
|
|
86
|
+
},
|
|
87
|
+
{
|
|
88
|
+
id: 'scope-reach',
|
|
89
|
+
rule: 'Path permission derives from shape.reach through domain/scope.js; no module owns or re-implements it.',
|
|
90
|
+
owner: 'domain/scope.js',
|
|
91
|
+
},
|
|
92
|
+
{
|
|
93
|
+
id: 'role-shape-conflation',
|
|
94
|
+
rule: 'A role decision never combines with a shape decision; role facts live in domain/roles.js.',
|
|
95
|
+
owner: 'domain/roles.js',
|
|
96
|
+
},
|
|
97
|
+
]
|
|
98
|
+
/** Rules whose implementation module necessarily names the vocabulary it owns. */
|
|
99
|
+
export const RULE_EXEMPT_PATHS = {
|
|
100
|
+
'batch-status-literal': ['domain/batch.js'],
|
|
101
|
+
'lease-ledger': ['adapters/leases.js'],
|
|
102
|
+
'blocked-status-write': ['domain/missions/lifecycle.js'],
|
|
103
|
+
'evidence-single-read': ['domain/evidence.js', 'app/recording.js', 'domain/summary.js', 'domain/state/schema.js'],
|
|
104
|
+
'legacy-vocabulary': ['adapters/legacy.js'],
|
|
105
|
+
'scope-reach': ['domain/scope.js'],
|
|
106
|
+
'role-shape-conflation': ['domain/roles.js'],
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const KIND_PATTERN = /(?:\bkind\b\s*[!=]==?\s*['"](?:build|survey)['"])|(?:['"](?:build|survey)['"]\s*[!=]==?\s*\bkind\b)/
|
|
110
|
+
const ROLE_PATTERN = /(?:\brole\b\s*[!=]==?\s*['"](?:worker|reviewer|tower)['"])|(?:['"](?:worker|reviewer|tower)['"]\s*[!=]==?\s*\brole\b)/
|
|
111
|
+
const GLOBAL_VOCAB_PATTERN = /\b(?:TERMINAL_STATUSES|TRANSITIONS)\b/
|
|
112
|
+
const CALL_PATTERN = /(?<!\.)\b(isTerminal|canTransition)\s*\(([^()]*)\)/g
|
|
113
|
+
const REVIEW_TIP_PATTERN = /\brevParse\s*\(\s*[^)]*worktree\.path\s*,\s*['"]HEAD['"]\s*\)/
|
|
114
|
+
const BATCH_STATUS_PATTERN =
|
|
115
|
+
/(?:\bbatch\.status\s*[!=]==?\s*['"](?:open|closed|abandoned|created|active|pending)['"])|(?:['"](?:open|closed|abandoned|created|active|pending)['"]\s*[!=]==?\s*\bbatch\.status\b)/
|
|
116
|
+
const LEASE_LEDGER_PATTERN = /['"](?:leases\.json|leases\.json\.lock|leases\.lock)['"]/
|
|
117
|
+
const BLOCKED_WRITE_PATTERN = /(?:\bstatus\s*=\s*['"]blocked['"])|(?:['"]status['"]\s*:\s*['"]blocked['"])/
|
|
118
|
+
const EVIDENCE_READ_PATTERN = /\bmission\.testRuns\b|\btestRuns\s*[.[]|completionReport\?\.checks|completionReport\.checks/
|
|
119
|
+
const LEGACY_VOCABULARY_PATTERN = /\btower-codex\b|\bpausedIds\b|\bCODEX_(?:STATUSES|ID_RE)\b/
|
|
120
|
+
const SCOPE_FENCE_PATTERN = /\bownsScope\b|\bfileInScope\s*\(/
|
|
121
|
+
const ROLE_SHAPE_PATTERN = /\b(?:isWorkerRole|isReviewerRole|isTowerRole)\s*\(|\bROLE\.(?:worker|reviewer|tower)\b/
|
|
122
|
+
const SHAPE_DECISION_PATTERN = /\bshapeFor\s*\(|\bshape\s*[.[]/
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Count top-level arguments in a call's argument text.
|
|
126
|
+
* @param {string} args
|
|
127
|
+
* @returns {number}
|
|
128
|
+
*/
|
|
129
|
+
function argumentCount(args) {
|
|
130
|
+
const trimmed = args.trim()
|
|
131
|
+
if (trimmed.length === 0) return 0
|
|
132
|
+
let depth = 0
|
|
133
|
+
let count = 1
|
|
134
|
+
for (const char of trimmed) {
|
|
135
|
+
if (char === '(' || char === '[' || char === '{') depth += 1
|
|
136
|
+
else if (char === ')' || char === ']' || char === '}') depth -= 1
|
|
137
|
+
else if (char === ',' && depth === 0) count += 1
|
|
138
|
+
}
|
|
139
|
+
return count
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* @param {string} source
|
|
144
|
+
* @returns {Array<{ rule: string, detail: string }>}
|
|
145
|
+
*/
|
|
146
|
+
export function inspectSource(source) {
|
|
147
|
+
const violations = []
|
|
148
|
+
const lines = source.split('\n')
|
|
149
|
+
lines.forEach((line, index) => {
|
|
150
|
+
const at = `line ${index + 1}`
|
|
151
|
+
const trimmed = line.trim()
|
|
152
|
+
// Documentation may quote the vocabulary it forbids; only code counts.
|
|
153
|
+
if (trimmed.startsWith('//') || trimmed.startsWith('*') || trimmed.startsWith('/*')) return
|
|
154
|
+
if (KIND_PATTERN.test(line)) {
|
|
155
|
+
violations.push({ rule: 'kind-literal', detail: `${at}: ${line.trim()} — resolve shapeFor(mission.kind) instead` })
|
|
156
|
+
}
|
|
157
|
+
if (ROLE_PATTERN.test(line)) {
|
|
158
|
+
violations.push({ rule: 'role-literal', detail: `${at}: ${line.trim()} — use the vocabulary role predicates instead` })
|
|
159
|
+
}
|
|
160
|
+
if (GLOBAL_VOCAB_PATTERN.test(line)) {
|
|
161
|
+
violations.push({ rule: 'global-status-vocabulary', detail: `${at}: ${line.trim()} — use shape.isTerminal(status) / shape.transitions` })
|
|
162
|
+
}
|
|
163
|
+
if (REVIEW_TIP_PATTERN.test(line)) {
|
|
164
|
+
violations.push({
|
|
165
|
+
rule: 'review-tip-read',
|
|
166
|
+
detail: `${at}: ${line.trim()} — stamp reviews with a recorded commit (tower.branchTip(mission)) instead of a live worktree HEAD`,
|
|
167
|
+
})
|
|
168
|
+
}
|
|
169
|
+
if (BATCH_STATUS_PATTERN.test(line)) {
|
|
170
|
+
violations.push({
|
|
171
|
+
rule: 'batch-status-literal',
|
|
172
|
+
detail: `${at}: ${line.trim()} — use BATCH_STATUS / canTransitionBatch from domain/batch.js`,
|
|
173
|
+
})
|
|
174
|
+
}
|
|
175
|
+
if (LEASE_LEDGER_PATTERN.test(line)) {
|
|
176
|
+
violations.push({
|
|
177
|
+
rule: 'lease-ledger',
|
|
178
|
+
detail: `${at}: ${line.trim()} — the lease ledger is owned by adapters/leases.js`,
|
|
179
|
+
})
|
|
180
|
+
}
|
|
181
|
+
if (BLOCKED_WRITE_PATTERN.test(line)) {
|
|
182
|
+
violations.push({
|
|
183
|
+
rule: 'blocked-status-write',
|
|
184
|
+
detail: `${at}: ${line.trim()} — blocked is derived from open blockers: use markBlocked(mission, text)`,
|
|
185
|
+
})
|
|
186
|
+
}
|
|
187
|
+
if (EVIDENCE_READ_PATTERN.test(line)) {
|
|
188
|
+
violations.push({
|
|
189
|
+
rule: 'evidence-single-read',
|
|
190
|
+
detail: `${at}: ${line.trim()} — read evidence through domain/evidence.js (findEvidence/passingDiscovery/hasCheckEvidence)`,
|
|
191
|
+
})
|
|
192
|
+
}
|
|
193
|
+
if (LEGACY_VOCABULARY_PATTERN.test(line)) {
|
|
194
|
+
violations.push({
|
|
195
|
+
rule: 'legacy-vocabulary',
|
|
196
|
+
detail: `${at}: ${line.trim()} — legacy formats/statuses are normalised by adapters/legacy.js; do not introduce a second vocabulary`,
|
|
197
|
+
})
|
|
198
|
+
}
|
|
199
|
+
if (SCOPE_FENCE_PATTERN.test(line)) {
|
|
200
|
+
violations.push({
|
|
201
|
+
rule: 'scope-reach',
|
|
202
|
+
detail: `${at}: ${line.trim()} — path permission is shape.reach through scopeFenceBlocker(); no ownsScope flag or direct matcher`,
|
|
203
|
+
})
|
|
204
|
+
}
|
|
205
|
+
if (ROLE_SHAPE_PATTERN.test(line) && SHAPE_DECISION_PATTERN.test(line)) {
|
|
206
|
+
violations.push({
|
|
207
|
+
rule: 'role-shape-conflation',
|
|
208
|
+
detail: `${at}: ${line.trim()} — resolve the role through domain/roles.js; the shape supplies facts, not role branches`,
|
|
209
|
+
})
|
|
210
|
+
}
|
|
211
|
+
for (const match of line.matchAll(CALL_PATTERN)) {
|
|
212
|
+
const expected = match[1] === 'isTerminal' ? 2 : 3
|
|
213
|
+
if (argumentCount(match[2]) < expected) {
|
|
214
|
+
violations.push({
|
|
215
|
+
rule: 'shape-entry-point',
|
|
216
|
+
detail: `${at}: ${match[0]} — the only entry points are isTerminal(kind, status) and canTransition(kind, from, to)`,
|
|
217
|
+
})
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
})
|
|
221
|
+
return violations
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* @param {string} start
|
|
226
|
+
* @returns {string[]}
|
|
227
|
+
*/
|
|
228
|
+
function walk(start) {
|
|
229
|
+
const files = []
|
|
230
|
+
if (!fs.existsSync(start)) return files
|
|
231
|
+
for (const entry of fs.readdirSync(start, { withFileTypes: true })) {
|
|
232
|
+
const full = path.join(start, entry.name)
|
|
233
|
+
if (entry.isDirectory()) {
|
|
234
|
+
if (entry.name === 'node_modules' || entry.name.startsWith('.')) continue
|
|
235
|
+
files.push(...walk(full))
|
|
236
|
+
} else if (entry.name.endsWith('.js') || entry.name.endsWith('.mjs')) {
|
|
237
|
+
files.push(full)
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return files
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* @param {string} repoRoot
|
|
245
|
+
* @returns {{ path: string, rule: string, detail: string }[]}
|
|
246
|
+
*/
|
|
247
|
+
export function scan(repoRoot) {
|
|
248
|
+
const results = []
|
|
249
|
+
for (const dir of SCAN_DIRS) {
|
|
250
|
+
for (const file of walk(path.join(repoRoot, dir))) {
|
|
251
|
+
const relative = path.relative(repoRoot, file).split(path.sep).join('/')
|
|
252
|
+
if (relative.startsWith(`${SHAPE_DIR}/`)) continue
|
|
253
|
+
const source = fs.readFileSync(file, 'utf8')
|
|
254
|
+
for (const violation of inspectSource(source)) {
|
|
255
|
+
const exempt = RULE_EXEMPT_PATHS[violation.rule] ?? []
|
|
256
|
+
if (exempt.includes(relative)) continue
|
|
257
|
+
results.push({ path: relative, ...violation })
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
return results
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* @param {string} repoRoot
|
|
266
|
+
* @returns {{ waivers: Array<{ rule?: string, path?: string, reason?: string, owner?: string, ref?: string, match?: string }>, errors: string[] }}
|
|
267
|
+
*/
|
|
268
|
+
export function loadShapeWaivers(repoRoot) {
|
|
269
|
+
const file = path.join(repoRoot, WAIVER_FILE)
|
|
270
|
+
if (!fs.existsSync(file)) return { waivers: [], errors: [`${WAIVER_FILE}: missing`] }
|
|
271
|
+
let document
|
|
272
|
+
try {
|
|
273
|
+
document = JSON.parse(fs.readFileSync(file, 'utf8'))
|
|
274
|
+
} catch (error) {
|
|
275
|
+
return { waivers: [], errors: [`${WAIVER_FILE}: not valid JSON (${String(error?.message ?? error)})`] }
|
|
276
|
+
}
|
|
277
|
+
const entries = document?.[WAIVER_KEY] ?? []
|
|
278
|
+
if (!Array.isArray(entries)) return { waivers: [], errors: [`${WAIVER_FILE}: ${WAIVER_KEY} must be an array`] }
|
|
279
|
+
const errors = []
|
|
280
|
+
for (const [index, entry] of entries.entries()) {
|
|
281
|
+
const where = `${WAIVER_FILE} ${WAIVER_KEY}[${index}]`
|
|
282
|
+
if (!entry || typeof entry !== 'object') {
|
|
283
|
+
errors.push(`${where}: must be an object`)
|
|
284
|
+
continue
|
|
285
|
+
}
|
|
286
|
+
if (typeof entry.rule !== 'string' || entry.rule.length === 0) errors.push(`${where}: rule is required`)
|
|
287
|
+
if (typeof entry.path !== 'string' || entry.path.length === 0) errors.push(`${where}: path is required`)
|
|
288
|
+
if (typeof entry.reason !== 'string' || entry.reason.length === 0) errors.push(`${where}: reason is required`)
|
|
289
|
+
if (!entry.owner && !entry.ref) errors.push(`${where}: owner or ref is required`)
|
|
290
|
+
}
|
|
291
|
+
return { waivers: errors.length === 0 ? entries : [], errors }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* @param {{ path: string, rule: string, detail: string }[]} violations
|
|
296
|
+
* @param {Array<{ rule?: string, path?: string, match?: string }>} waivers
|
|
297
|
+
* @returns {{ violations: typeof violations, waived: typeof violations, unused: Array<{ rule?: string, path?: string }> }}
|
|
298
|
+
*/
|
|
299
|
+
export function applyWaivers(violations, waivers) {
|
|
300
|
+
const waived = []
|
|
301
|
+
const used = new Set()
|
|
302
|
+
const remaining = []
|
|
303
|
+
for (const violation of violations) {
|
|
304
|
+
const index = waivers.findIndex((waiver) => {
|
|
305
|
+
if (waiver.path !== violation.path) return false
|
|
306
|
+
if (waiver.rule !== violation.rule) return false
|
|
307
|
+
if (waiver.match && !violation.detail.includes(waiver.match)) return false
|
|
308
|
+
return true
|
|
309
|
+
})
|
|
310
|
+
if (index >= 0) {
|
|
311
|
+
used.add(index)
|
|
312
|
+
waived.push(violation)
|
|
313
|
+
} else {
|
|
314
|
+
remaining.push(violation)
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
const unused = waivers.filter((_, index) => !used.has(index))
|
|
318
|
+
return { violations: remaining, waived, unused }
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
/**
|
|
322
|
+
* @param {string} repoRoot
|
|
323
|
+
* @returns {{ ok: boolean, violations: any[], waived: any[], errors: string[], unused: any[] }}
|
|
324
|
+
*/
|
|
325
|
+
export function evaluator(repoRoot = root) {
|
|
326
|
+
const reported = []
|
|
327
|
+
const waivers = loadShapeWaivers(repoRoot)
|
|
328
|
+
if (waivers.errors.length > 0) return { ok: false, violations: [], waived: [], errors: waivers.errors, unused: [] }
|
|
329
|
+
const applied = applyWaivers(scan(repoRoot), waivers.waivers)
|
|
330
|
+
for (const violation of applied.violations) {
|
|
331
|
+
reported.push(`shape-audit error: [${violation.rule}] ${violation.path} ${violation.detail}`)
|
|
332
|
+
}
|
|
333
|
+
if (applied.unused.length > 0) {
|
|
334
|
+
for (const waiver of applied.unused) {
|
|
335
|
+
reported.push(`shape-audit error: [stale-waiver] ${waiver.path} ${waiver.rule} no longer matches a violation; remove it`)
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
ok: reported.length === 0,
|
|
340
|
+
violations: applied.violations,
|
|
341
|
+
waived: applied.waived,
|
|
342
|
+
errors: reported,
|
|
343
|
+
unused: applied.unused,
|
|
344
|
+
}
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function main(argv) {
|
|
348
|
+
const rootIndex = argv.indexOf('--root')
|
|
349
|
+
const repoRoot = rootIndex >= 0 ? path.resolve(argv[rootIndex + 1]) : root
|
|
350
|
+
const result = evaluator(repoRoot)
|
|
351
|
+
if (argv.includes('--json')) {
|
|
352
|
+
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`)
|
|
353
|
+
} else if (result.ok) {
|
|
354
|
+
process.stdout.write(`shape-audit: ok (waived: ${result.waived.length})\n`)
|
|
355
|
+
} else {
|
|
356
|
+
for (const line of result.errors) process.stderr.write(`${line}\n`)
|
|
357
|
+
}
|
|
358
|
+
process.exitCode = result.ok ? 0 : 1
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) main(process.argv.slice(2))
|