dsh-tower 2.1.0 → 2.4.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 (203) hide show
  1. package/CHANGELOG.md +137 -0
  2. package/COMPATIBILITY.md +6 -1
  3. package/CONTRIBUTING.md +78 -5
  4. package/PRINCIPLES.md +163 -0
  5. package/README.en.md +35 -19
  6. package/README.md +28 -18
  7. package/SECURITY.md +6 -0
  8. package/{lib → adapters}/archive.js +1 -1
  9. package/{lib → adapters}/caseSensitivity.js +1 -1
  10. package/adapters/default.js +51 -10
  11. package/{lib → adapters}/git.js +1 -1
  12. package/{lib → adapters}/leases.js +1 -1
  13. package/{lib → adapters}/legacy.js +1 -1
  14. package/adapters/persistence.js +543 -0
  15. package/adapters/store-fs.js +28 -1
  16. package/adapters/store.js +204 -0
  17. package/{lib → adapters}/tests.js +9 -5
  18. package/adapters/vcs-git.js +15 -1
  19. package/{lib → adapters}/workspaceLock.js +1 -1
  20. package/{lib → adapters}/worktreeOwnership.js +1 -1
  21. package/app/archive.js +4 -6
  22. package/app/batch.js +9 -10
  23. package/app/board.js +2 -4
  24. package/app/checkCompletion.js +9 -10
  25. package/app/enrichedMissions.js +2 -4
  26. package/app/findings.js +6 -7
  27. package/app/gate.js +1 -2
  28. package/app/init.js +24 -29
  29. package/app/merge.js +58 -51
  30. package/app/messages.js +7 -8
  31. package/app/mission.js +5 -5
  32. package/app/plan.js +8 -5
  33. package/app/provision.js +12 -14
  34. package/app/read.js +6 -3
  35. package/app/rebase.js +14 -16
  36. package/app/recording.js +7 -8
  37. package/app/review.js +15 -8
  38. package/app/roster.js +14 -15
  39. package/{lib → app}/spawn-prechecks.js +4 -3
  40. package/app/spawn.js +12 -13
  41. package/app/teardown.js +8 -9
  42. package/app/tower.js +89 -35
  43. package/cordis.patch.yml +56 -28
  44. package/docs/README.md +7 -2
  45. package/docs/adr/0001-ports-adapters-as-data.md +49 -0
  46. package/docs/adr/0002-single-state-json.md +50 -0
  47. package/docs/adr/0003-allow-agent-control-default-true.md +48 -0
  48. package/docs/adr/0004-surface-budget.md +70 -0
  49. package/docs/adr/0005-component-surface.md +130 -0
  50. package/docs/adr/README.md +52 -0
  51. package/docs/agents/workflow.md +51 -0
  52. package/docs/architecture/overview.md +192 -43
  53. package/docs/guide/hot-reload.md +5 -5
  54. package/docs/guide/install-upgrade-migration.md +13 -0
  55. package/docs/guide/release.md +1 -1
  56. package/docs/operations/incident.md +82 -0
  57. package/docs/operations/runbook.md +12 -3
  58. package/docs/operations/security.md +1 -1
  59. package/docs/reference/blockers.md +2 -2
  60. package/docs/reference/component-surface.md +135 -0
  61. package/docs/reference/config.generated.md +2 -9
  62. package/docs/reference/config.md +11 -9
  63. package/docs/reference/doctor.md +53 -2
  64. package/docs/reference/errors.generated.md +41 -37
  65. package/docs/reference/errors.md +53 -41
  66. package/docs/reference/events.md +38 -6
  67. package/docs/reference/surface-budget.md +157 -0
  68. package/docs/reference/tools.generated.md +8 -5
  69. package/docs/reference/tools.md +10 -7
  70. package/docs/zh/README.md +1 -1
  71. package/docs/zh/guide/hot-reload.md +4 -4
  72. package/docs/zh/guide/install-upgrade-migration.md +10 -0
  73. package/docs/zh/operations/security.md +1 -1
  74. package/docs/zh/reference/blockers.md +2 -2
  75. package/docs/zh/reference/config.md +9 -9
  76. package/docs/zh/reference/doctor.md +29 -1
  77. package/docs/zh/reference/tools.md +10 -8
  78. package/domain/batch.js +2 -2
  79. package/domain/components.js +641 -0
  80. package/domain/config/schema.js +62 -28
  81. package/domain/contracts/outputs.js +16 -2
  82. package/domain/contracts/tool-inputs/ops.js +1 -0
  83. package/domain/errors.js +308 -43
  84. package/domain/events.js +207 -0
  85. package/domain/integration/gate.js +17 -2
  86. package/domain/integration/merge.js +35 -0
  87. package/domain/lockBudget.js +30 -0
  88. package/domain/mission.js +12 -1
  89. package/domain/missions/completion.js +14 -0
  90. package/domain/missions/lifecycle.js +9 -3
  91. package/domain/missions/plan.js +14 -0
  92. package/domain/missions/review.js +90 -7
  93. package/domain/read/views.js +3 -0
  94. package/domain/state/archive.js +1 -1
  95. package/domain/state.js +1 -1
  96. package/domain/surface.js +347 -0
  97. package/host/capability-batch.js +15 -0
  98. package/host/capability-hmr.js +30 -0
  99. package/host/capability-import.js +11 -0
  100. package/host/capability-leases.js +11 -0
  101. package/host/capability-registry.js +149 -0
  102. package/host/capability-tests.js +16 -0
  103. package/{lib → host}/doctor.js +157 -18
  104. package/{lib → host}/hmr.js +2 -4
  105. package/{lib → host}/modeStore.js +1 -1
  106. package/host/presets.js +77 -0
  107. package/{lib → host}/tool-registry.js +235 -44
  108. package/host/tower.js +32 -0
  109. package/index.js +40 -25
  110. package/locale/en.json +31 -11
  111. package/locale/zh.json +30 -10
  112. package/package.json +24 -4
  113. package/ports/archive.js +18 -0
  114. package/ports/caseSensitivity.js +15 -0
  115. package/ports/clock.js +10 -2
  116. package/ports/config.js +19 -0
  117. package/ports/contract.js +38 -0
  118. package/ports/events.js +24 -0
  119. package/ports/ids.js +10 -2
  120. package/ports/index.js +54 -2
  121. package/ports/ownership.js +17 -0
  122. package/ports/state.js +16 -0
  123. package/ports/store.js +52 -3
  124. package/ports/vcs.js +25 -4
  125. package/scripts/architecture-waivers.json +19 -0
  126. package/scripts/architecture.mjs +634 -23
  127. package/scripts/check-components.mjs +235 -0
  128. package/scripts/check-docs.mjs +8 -35
  129. package/scripts/check-pack.mjs +68 -39
  130. package/scripts/check-philosophy.mjs +266 -0
  131. package/scripts/component-surface.json +395 -0
  132. package/scripts/domain-metrics.mjs +13 -4
  133. package/scripts/generate-component-surface.mjs +59 -0
  134. package/scripts/generate-config-reference.mjs +14 -1
  135. package/scripts/generate-error-reference.mjs +68 -14
  136. package/scripts/generate-events-reference.mjs +83 -0
  137. package/scripts/generate-tools-reference.mjs +18 -25
  138. package/scripts/generate-types.mjs +127 -0
  139. package/scripts/issue-audit.mjs +2 -2
  140. package/scripts/lib/surface.mjs +87 -0
  141. package/scripts/lib/tool-surface.mjs +85 -0
  142. package/scripts/mount-smoke/fixtures.mjs +3 -3
  143. package/scripts/mount-smoke/surface.mjs +96 -27
  144. package/scripts/mount-smoke.mjs +3 -1
  145. package/scripts/pack-manifest.txt +75 -22
  146. package/scripts/regression-audit.mjs +164 -0
  147. package/scripts/release-check.mjs +1 -1
  148. package/scripts/surface-baseline.json +52 -0
  149. package/scripts/surface-baseline.mjs +118 -0
  150. package/scripts/types-coverage.mjs +184 -0
  151. package/scripts/types-strict-manifest.txt +10 -0
  152. package/scripts/types-strict.mjs +141 -0
  153. package/skills.js +8 -1
  154. package/tools/adopt.js +2 -3
  155. package/tools/batch.js +2 -3
  156. package/tools/complete.js +2 -3
  157. package/tools/diff.js +3 -3
  158. package/tools/doctor.js +7 -10
  159. package/tools/finding.js +2 -3
  160. package/tools/findings.js +3 -3
  161. package/tools/import-legacy.js +3 -5
  162. package/tools/inbox.js +3 -3
  163. package/tools/index.js +9 -8
  164. package/tools/init.js +3 -6
  165. package/tools/interrupt.js +2 -3
  166. package/tools/legacy-map.js +3 -5
  167. package/tools/merge.js +3 -3
  168. package/tools/mission.js +2 -3
  169. package/tools/plan.js +6 -5
  170. package/tools/preflight.js +2 -3
  171. package/tools/rebase.js +3 -3
  172. package/tools/recover.js +38 -13
  173. package/tools/reload.js +36 -40
  174. package/tools/resource.js +3 -5
  175. package/tools/review.js +3 -4
  176. package/tools/roster.js +2 -3
  177. package/tools/send.js +2 -2
  178. package/tools/spawn.js +8 -5
  179. package/tools/status.js +11 -4
  180. package/tools/teardown.js +2 -3
  181. package/tools/test.js +2 -2
  182. package/types/index.d.ts +29 -13
  183. package/ui-bundle/README.md +37 -3
  184. package/ui-bundle/client.cards.js +356 -0
  185. package/ui-bundle/client.denied.js +142 -0
  186. package/ui-bundle/client.dock.js +333 -0
  187. package/ui-bundle/client.helpers.js +271 -0
  188. package/ui-bundle/client.js +79 -1747
  189. package/ui-bundle/client.locales.js +247 -0
  190. package/ui-bundle/client.mode.js +206 -0
  191. package/ui-bundle/client.overlay.js +64 -0
  192. package/ui-bundle/client.styles.js +151 -0
  193. package/ui-bundle/client.toolview.js +82 -0
  194. package/ui-bundle/package.json +1 -0
  195. package/lib/persistence.js +0 -356
  196. package/lib/presets.js +0 -35
  197. package/lib/store.js +0 -133
  198. /package/{lib → adapters}/stateFile.js +0 -0
  199. /package/{lib → app}/briefing.js +0 -0
  200. /package/{lib → app}/spawn-support.js +0 -0
  201. /package/{lib → host}/compatibility.js +0 -0
  202. /package/{lib → host}/config.js +0 -0
  203. /package/{lib → host}/modeState.js +0 -0
@@ -3,25 +3,451 @@
3
3
  // Rules are intentionally staged: rules for not-yet-migrated areas are
4
4
  // warnings (printed, exit 0) and flip to errors in the PR that makes them true.
5
5
  // The evaluator is exported so tests can seed one fixture violation per rule.
6
+ //
7
+ // Exemptions are justified data, not bare sets (#80). Every waiver lives in
8
+ // scripts/architecture-waivers.json and carries a reason plus an owner or
9
+ // reference; an unexplained waiver is itself a blocking `waiver-contract`
10
+ // violation, so a bare path can no longer turn the gate green. Two rules are
11
+ // load-bearing and cannot be waived at all: `domain-pure` and, since #70,
12
+ // `host-scope` (ambient state is a host-only privilege) and
13
+ // `no-lib-from-core` (the retired lib/ layer may never be imported by app/
14
+ // or domain/). The scan is statement-aware: it records every binding of every
15
+ // top-level `let` declaration, so a new top-level `let` in app/ or domain/ is
16
+ // a violation even as a second declarator or a second statement on one line.
17
+ // The green summary prints the number of waivers applied.
18
+ //
19
+ // Since #71 the optional Web client (`ui-bundle/`) is its own tier, not host
20
+ // code: `client-no-node` keeps Host capabilities behind the plugin's own
21
+ // registration surface, `client-size` caps any single client file at 800
22
+ // lines, and `client-ambient` applies the app/ top-level-`let` rule to the
23
+ // browser half (its mutable state must live in a create* closure).
6
24
  import fs from 'node:fs'
7
25
  import path from 'node:path'
8
26
  import { fileURLToPath } from 'node:url'
9
27
 
10
- const PACKAGE_ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..')
11
- const ROOTS = ['lib', 'app', 'domain', 'tools', 'adapters', 'ports']
12
- const CHILD_PROCESS_ALLOWED = new Set(['adapters/vcs-git.js'])
13
- const FS_ALLOWED = new Set(['adapters/store-fs.js'])
14
- // Composition roots own the only intentional process-wide mutable state.
15
- const AMBIENT_ALLOWED = new Set(['lib/config.js', 'lib/git.js'])
16
- // The registration aggregator wires tool modules together; the thinness rule
17
- // applies to the individual modules under tools/.
18
- const TOOLS_REGISTRATION = new Set(['tools/index.js'])
19
- // The registration aggregator is the only tools/ module above the 200-line
20
- // per-tool ceiling: it wires the individual modules together. P3c split
21
- // tower_spawn, so every tool module now enforces the ceiling.
22
- const TOOLS_SIZE_ALLOWED = new Set(['tools/index.js'])
28
+ const MODULE_DIR = path.dirname(fileURLToPath(import.meta.url))
29
+ const PACKAGE_ROOT = path.resolve(MODULE_DIR, '..')
30
+ const WAIVER_FILE = path.join(MODULE_DIR, 'architecture-waivers.json')
31
+ const WAIVER_LABEL = 'scripts/architecture-waivers.json'
32
+ const ROOTS = ['host', 'app', 'domain', 'tools', 'adapters', 'ports', 'ui-bundle']
33
+ const RULES = new Set([
34
+ 'domain-pure',
35
+ 'no-lib-from-core',
36
+ 'child-process-surface',
37
+ 'fs-surface',
38
+ 'app-ports-only',
39
+ 'composition-root',
40
+ 'port-shape',
41
+ 'tools-thin',
42
+ 'tools-vocabulary',
43
+ 'aggregate-size',
44
+ 'host-scope',
45
+ 'client-no-node',
46
+ 'client-size',
47
+ 'client-ambient',
48
+ ])
49
+ // #71: the Web client is a browser artifact. It may not reach a Host
50
+ // capability (fs, child_process, os, process) through a `node:` import; DOM
51
+ // globals are not Host capabilities, and the only module request a client
52
+ // bundle may make is a browser module-table word such as `react`.
53
+ const CLIENT_ROOT = 'ui-bundle/'
54
+ const CLIENT_NODE_SPEC = /^node:/
55
+ // #71: 800-line ceiling for any single client file. The split already keeps
56
+ // every surface well below it; a documented waiver is still possible through
57
+ // scripts/architecture-waivers.json if a single file ever has to carry more.
58
+ const CLIENT_SIZE_CEILING = 800
59
+ // #69: the app layer may import only these. App siblings (`./x.js`), the pure
60
+ // domain, and the explicit ports are the whole dependency surface.
61
+ const APP_ALLOWED_PREFIXES = ['./', '../domain/', '../ports/']
62
+ const APP_ALLOWED_EXACT = new Set(['node:path'])
63
+ // #70: app/ and domain/ may never reach back into the retired lib/ layer. A
64
+ // seeded fixture proves the rule: `import ... from '../lib/store.js'` fails
65
+ // even if a future change accidentally recreates the directory.
66
+ const LIB_IMPORT = /^(?:\.{1,2}\/)*lib(?:\/|$)/
67
+ // #69: the host layer is the composition root: it may assemble adapters and
68
+ // hold the process/session state the app and domain are denied. The host
69
+ // entries (index.js/worker.js/reviewer.js, outside this scan set) and the
70
+ // adapters may import adapters; nothing in app/domain/tools/ports may.
71
+ const COMPOSITION_ROOT_EXEMPT_PREFIXES = ['host/']
72
+ const ADAPTER_IMPORT = /^(?:\.\.\/)?adapters(?:\/|$)/
73
+ // #69: a port must be a runtime contract, not a comment plus `export {}`.
74
+ const RUNTIME_PORT_EXPORT = /^export\s+(?:const|function|class)\s/m
75
+ // #69: port modules must not reach into an implementation.
76
+ const PORT_IMPLEMENTATION_IMPORT = /^(?:\.\.\/)?(?:adapters|lib|app|tools)(?:\/|$)/
23
77
  const LITERAL_PATTERN = /enum:\s*\[\s*'(?:planned|active|completed|blocked|paused|abandoned|worker|reviewer|bug|improve|vuln|idea|low|medium|high|critical|open|triaged|fixed|wontfix)'/
24
78
  const TOOL_LITERAL_PATTERN = /(?:===|!==|==|!=|\.includes\(|case\s+)'?(?:planned|active|completed|blocked|paused|abandoned|worker|reviewer|bug|improve|vuln|idea|low|medium|high|critical|open|triaged|fixed|wontfix|create|abandon|close|status|merge|fix-then-merge|hold|acquire|release|map|user|failure|resource|dependency|json|markdown)'/
79
+ // #80/#70: host-scope is per binding, and the scan is statement-aware rather
80
+ // than line-based. A top-level `let` is a declaration only at module top level
81
+ // (brace, bracket and paren depths all zero) when it starts a statement: the
82
+ // first token of a line, the start of the file, or a token after `;`, `}` or
83
+ // `export`. Every declarator is recorded, so both `let a = 1, b = 2` and
84
+ // `let a = 1; let b = 2` need a waiver for `b`; only the named binding is
85
+ // exempt. Strings, comments, template literals and regex literals are skipped so
86
+ // their contents can neither hide nor fake a binding.
87
+ const REGEX_ALLOWED_PUNCT = new Set([
88
+ '(', ',', '=', ':', '[', '!', '&', '|', '?', '{', '}', ';', '+', '-', '*', '/', '%',
89
+ '<', '>', '^', '~', '=>', '...', '==', '===', '!=', '!==', '<=', '>=', '&&', '||', '??',
90
+ '?.', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**', '<<', '>>', '>>>', '**=', '<<=', '>>=', '>>>=',
91
+ ])
92
+ const REGEX_ALLOWED_WORDS = new Set([
93
+ 'return', 'typeof', 'instanceof', 'in', 'of', 'new', 'delete', 'void', 'throw',
94
+ 'case', 'do', 'else', 'yield', 'await', 'export', 'default',
95
+ ])
96
+ // A `/` after `)` is division unless that `)` closes the parenthesized
97
+ // condition of a control statement: in `if (x) /{/.test(s)` the body is a new
98
+ // statement, so the slash starts a regex literal. Without this the brace inside
99
+ // the regex stays unbalanced and the scan would silently skip every following
100
+ // top-level `let` (F3). `for await (...)` is the only two-word condition form.
101
+ const CONTROL_CONDITION_WORDS = new Set(['if', 'while', 'for', 'with', 'switch', 'catch'])
102
+ const PUNCT_OPERATORS = [
103
+ '>>>=', '...', '===', '!==', '**=', '>>=', '<<=', '>>>', '=>', '==', '!=', '<=', '>=',
104
+ '&&', '||', '??', '?.', '++', '--', '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**', '<<', '>>',
105
+ ]
106
+ // A `let` initializer ends at `;`, at a top-level `,`, or at automatic semicolon
107
+ // insertion: a newline before a token that cannot continue the expression.
108
+ // Without that stop a single-declarator initializer would run on into the next
109
+ // statement and duplicate or invent bindings.
110
+ const CONTINUATION_PUNCT = new Set([
111
+ '.', '?.', '(', '[', '+', '-', '*', '/', '%', '**', '&&', '||', '??', '?', ':', '=', '=>',
112
+ '==', '===', '!=', '!==', '<', '>', '<=', '>=', '&', '|', '^', '<<', '>>', '>>>',
113
+ '+=', '-=', '*=', '/=', '%=', '&=', '|=', '^=', '**=', '<<=', '>>=', '>>>=',
114
+ ])
115
+ const CONTINUATION_WORDS = new Set(['instanceof', 'in', 'of'])
116
+
117
+ function endsExpression(token) {
118
+ if (!token) return false
119
+ if (token.type === 'word') return !REGEX_ALLOWED_WORDS.has(token.value)
120
+ if (token.type === 'punct') {
121
+ return token.value === ')' || token.value === ']' || token.value === '}' || token.value === '++' || token.value === '--'
122
+ }
123
+ return true // string, number, template or regex literal
124
+ }
125
+
126
+ function continuesExpression(token) {
127
+ if (!token) return false
128
+ if (token.type === 'punct') return CONTINUATION_PUNCT.has(token.value)
129
+ if (token.type === 'word') return CONTINUATION_WORDS.has(token.value)
130
+ return token.type === 'template' // tagged template
131
+ }
132
+
133
+ function isIdentifierStart(code) {
134
+ return (code >= 65 && code <= 90) || (code >= 97 && code <= 122) || code === 95 || code === 36
135
+ }
136
+
137
+ function isIdentifierPart(code) {
138
+ return isIdentifierStart(code) || (code >= 48 && code <= 57)
139
+ }
140
+
141
+ function isDigit(code) {
142
+ return code >= 48 && code <= 57
143
+ }
144
+
145
+ function skipLineComment(source, start) {
146
+ let index = start
147
+ while (index < source.length && source[index] !== '\n') index += 1
148
+ return index
149
+ }
150
+
151
+ function skipBlockComment(source, start) {
152
+ const end = source.indexOf('*/', start + 2)
153
+ return end === -1 ? source.length : end + 2
154
+ }
155
+
156
+ function skipQuoted(source, start, quote) {
157
+ let index = start + 1
158
+ while (index < source.length) {
159
+ if (source[index] === '\\') index += 2
160
+ else if (source[index] === quote) return index + 1
161
+ else index += 1
162
+ }
163
+ return source.length
164
+ }
165
+
166
+ function skipTemplate(source, start) {
167
+ let index = start + 1
168
+ while (index < source.length) {
169
+ const char = source[index]
170
+ if (char === '\\') index += 2
171
+ else if (char === '`') return index + 1
172
+ else if (char === '$' && source[index + 1] === '{') index = skipInterpolation(source, index + 2)
173
+ else index += 1
174
+ }
175
+ return source.length
176
+ }
177
+
178
+ function skipInterpolation(source, start) {
179
+ let depth = 1
180
+ let index = start
181
+ while (index < source.length) {
182
+ const char = source[index]
183
+ if (char === "'" || char === '"') index = skipQuoted(source, index, char)
184
+ else if (char === '`') index = skipTemplate(source, index)
185
+ else if (char === '/' && source[index + 1] === '/') index = skipLineComment(source, index)
186
+ else if (char === '/' && source[index + 1] === '*') index = skipBlockComment(source, index)
187
+ else if (char === '{') { depth += 1; index += 1 }
188
+ else if (char === '}') {
189
+ depth -= 1
190
+ index += 1
191
+ if (depth === 0) return index
192
+ } else index += 1
193
+ }
194
+ return source.length
195
+ }
196
+
197
+ function skipRegex(source, start) {
198
+ let index = start + 1
199
+ let inClass = false
200
+ while (index < source.length) {
201
+ const char = source[index]
202
+ if (char === '\\') index += 2
203
+ else if (char === '\n') return -1
204
+ else if (char === '[') { inClass = true; index += 1 }
205
+ else if (char === ']') { inClass = false; index += 1 }
206
+ else if (char === '/' && !inClass) {
207
+ index += 1
208
+ while (index < source.length && /[a-z]/i.test(source[index])) index += 1
209
+ return index
210
+ } else index += 1
211
+ }
212
+ return -1
213
+ }
214
+
215
+ function regexAllowed(previous) {
216
+ if (!previous) return true
217
+ // F3: the `)` that closes a control-flow condition ends a statement, so the
218
+ // next token starts a statement and a slash there is a regex, not division.
219
+ if (previous.closesControl) return true
220
+ if (previous.type === 'word') return REGEX_ALLOWED_WORDS.has(previous.value)
221
+ if (previous.type === 'punct') return REGEX_ALLOWED_PUNCT.has(previous.value)
222
+ return false
223
+ }
224
+
225
+ function tokenize(source) {
226
+ const tokens = []
227
+ // One entry per open `(`: true when that group is a control-flow condition.
228
+ const parenStack = []
229
+ let index = 0
230
+ let newline = false
231
+ const push = (type, value, extra) => {
232
+ tokens.push(extra ? { type, value, newlineBefore: newline, ...extra } : { type, value, newlineBefore: newline })
233
+ newline = false
234
+ }
235
+ while (index < source.length) {
236
+ const code = source.charCodeAt(index)
237
+ if (code === 10) { newline = true; index += 1; continue }
238
+ if (code === 13 || code === 32 || code === 9 || code === 11 || code === 12 || code === 0xa0 || code === 0xfeff) { index += 1; continue }
239
+ const char = source[index]
240
+ if (char === '#' && source[index + 1] === '!') {
241
+ index = skipLineComment(source, index)
242
+ newline = true
243
+ continue
244
+ }
245
+ if (char === '/' && source[index + 1] === '/') {
246
+ index = skipLineComment(source, index)
247
+ continue
248
+ }
249
+ if (char === '/' && source[index + 1] === '*') {
250
+ const end = skipBlockComment(source, index)
251
+ if (source.slice(index, end).includes('\n')) newline = true
252
+ index = end
253
+ continue
254
+ }
255
+ if (char === "'" || char === '"') {
256
+ const end = skipQuoted(source, index, char)
257
+ push('string', source.slice(index, end))
258
+ index = end
259
+ continue
260
+ }
261
+ if (char === '`') {
262
+ const end = skipTemplate(source, index)
263
+ push('template', source.slice(index, end))
264
+ index = end
265
+ continue
266
+ }
267
+ if (char === '/' && regexAllowed(tokens[tokens.length - 1])) {
268
+ const end = skipRegex(source, index)
269
+ if (end !== -1) {
270
+ push('regex', source.slice(index, end))
271
+ index = end
272
+ continue
273
+ }
274
+ }
275
+ if (isIdentifierStart(code)) {
276
+ let end = index + 1
277
+ while (end < source.length && isIdentifierPart(source.charCodeAt(end))) end += 1
278
+ push('word', source.slice(index, end))
279
+ index = end
280
+ continue
281
+ }
282
+ if (isDigit(code)) {
283
+ let end = index + 1
284
+ while (end < source.length && (isIdentifierPart(source.charCodeAt(end)) || source[end] === '.')) end += 1
285
+ push('number', source.slice(index, end))
286
+ index = end
287
+ continue
288
+ }
289
+ let operator = char
290
+ for (const candidate of PUNCT_OPERATORS) {
291
+ if (source.startsWith(candidate, index)) { operator = candidate; break }
292
+ }
293
+ let extra
294
+ if (operator === '(') {
295
+ const previous = tokens[tokens.length - 1]
296
+ const beforePrevious = tokens[tokens.length - 2]
297
+ const isMember = beforePrevious?.value === '.' || beforePrevious?.value === '?.'
298
+ // A reserved word can also be a property name (`helper.if(x)`); only a
299
+ // bare keyword introduces a control-flow condition.
300
+ const isControl =
301
+ !isMember &&
302
+ previous?.type === 'word' &&
303
+ (CONTROL_CONDITION_WORDS.has(previous.value) ||
304
+ (previous.value === 'await' && beforePrevious?.value === 'for'))
305
+ parenStack.push(isControl)
306
+ } else if (operator === ')') {
307
+ if (parenStack.pop() === true) extra = { closesControl: true }
308
+ }
309
+ push('punct', operator, extra)
310
+ index += operator.length
311
+ }
312
+ return tokens
313
+ }
314
+
315
+ function skipBalanced(tokens, start) {
316
+ const open = tokens[start].value
317
+ const close = open === '{' ? '}' : open === '[' ? ']' : ')'
318
+ let depth = 0
319
+ for (let index = start; index < tokens.length; index += 1) {
320
+ if (tokens[index].value === open) depth += 1
321
+ else if (tokens[index].value === close) {
322
+ depth -= 1
323
+ if (depth === 0) return index + 1
324
+ }
325
+ }
326
+ return -1
327
+ }
328
+
329
+ function skipExpression(tokens, start, stops) {
330
+ let depth = 0
331
+ for (let index = start; index < tokens.length; index += 1) {
332
+ const token = tokens[index]
333
+ const value = token.value
334
+ if (depth === 0) {
335
+ if (stops.has(value)) return index
336
+ if (token.newlineBefore && endsExpression(tokens[index - 1]) && !continuesExpression(token)) {
337
+ // Automatic semicolon insertion ends the declaration here.
338
+ return index
339
+ }
340
+ }
341
+ if (value === '(' || value === '[' || value === '{') depth += 1
342
+ else if (value === ')' || value === ']' || value === '}') {
343
+ if (depth === 0) return index
344
+ depth -= 1
345
+ }
346
+ }
347
+ return tokens.length
348
+ }
349
+
350
+ function readPattern(tokens, start) {
351
+ const isObject = tokens[start].value === '{'
352
+ const close = isObject ? '}' : ']'
353
+ const names = []
354
+ let index = start + 1
355
+ while (index < tokens.length) {
356
+ const token = tokens[index]
357
+ if (token.value === close) return { names, next: index + 1 }
358
+ if (token.value === ',') { index += 1; continue }
359
+ if (token.value === '...') {
360
+ const target = readBindingTarget(tokens, index + 1)
361
+ if (!target) return { names, next: index + 1 }
362
+ names.push(...target.names)
363
+ index = target.next
364
+ } else if (!isObject) {
365
+ const target = readBindingTarget(tokens, index)
366
+ if (!target) { index += 1; continue }
367
+ names.push(...target.names)
368
+ index = target.next
369
+ } else if (token.value === '[') {
370
+ // A computed key is an expression, not a binding.
371
+ const next = skipBalanced(tokens, index)
372
+ if (next === -1) return { names, next: index + 1 }
373
+ index = next
374
+ if (tokens[index]?.value !== ':') continue
375
+ index += 1
376
+ const target = readBindingTarget(tokens, index)
377
+ if (!target) return { names, next: index }
378
+ names.push(...target.names)
379
+ index = target.next
380
+ } else if (token.type === 'word' && tokens[index + 1]?.value === ':') {
381
+ index += 2
382
+ const target = readBindingTarget(tokens, index)
383
+ if (!target) return { names, next: index }
384
+ names.push(...target.names)
385
+ index = target.next
386
+ } else if (token.type === 'word') {
387
+ names.push(token.value) // shorthand `{ name }`
388
+ index += 1
389
+ } else {
390
+ index += 1
391
+ continue
392
+ }
393
+ if (tokens[index]?.value === '=') index = skipExpression(tokens, index + 1, new Set([',', close]))
394
+ }
395
+ return { names, next: index }
396
+ }
397
+
398
+ function readBindingTarget(tokens, index) {
399
+ const token = tokens[index]
400
+ if (!token) return null
401
+ if (token.type === 'word') return { names: [token.value], next: index + 1 }
402
+ if (token.value === '{' || token.value === '[') return readPattern(tokens, index)
403
+ return null
404
+ }
405
+
406
+ function readLetBindings(tokens, start) {
407
+ const names = []
408
+ let index = start + 1
409
+ while (index < tokens.length) {
410
+ const target = readBindingTarget(tokens, index)
411
+ if (!target) break
412
+ names.push(...target.names)
413
+ index = target.next
414
+ if (tokens[index]?.value === '=') index = skipExpression(tokens, index + 1, new Set([',', ';']))
415
+ if (tokens[index]?.value !== ',') break
416
+ index += 1
417
+ }
418
+ return names
419
+ }
420
+
421
+ function scanAmbientBindings(source) {
422
+ const tokens = tokenize(source)
423
+ const bindings = []
424
+ let braces = 0
425
+ let brackets = 0
426
+ let parens = 0
427
+ for (let index = 0; index < tokens.length; index += 1) {
428
+ const token = tokens[index]
429
+ if (token.type === 'punct') {
430
+ if (token.value === '{') braces += 1
431
+ else if (token.value === '}') braces = Math.max(0, braces - 1)
432
+ else if (token.value === '[') brackets += 1
433
+ else if (token.value === ']') brackets = Math.max(0, brackets - 1)
434
+ else if (token.value === '(') parens += 1
435
+ else if (token.value === ')') parens = Math.max(0, parens - 1)
436
+ }
437
+ if (braces !== 0 || brackets !== 0 || parens !== 0) continue
438
+ if (token.type !== 'word' || token.value !== 'let') continue
439
+ const previous = tokens[index - 1]
440
+ const startsStatement =
441
+ !previous ||
442
+ token.newlineBefore ||
443
+ previous.value === ';' ||
444
+ previous.value === '}' ||
445
+ (previous.type === 'word' && previous.value === 'export')
446
+ if (!startsStatement) continue
447
+ bindings.push(...readLetBindings(tokens, index))
448
+ }
449
+ return bindings
450
+ }
25
451
 
26
452
  function walk(root, relative = '') {
27
453
  const dir = path.join(root, relative)
@@ -35,10 +461,93 @@ function walk(root, relative = '') {
35
461
  return out
36
462
  }
37
463
 
38
- function evaluator(root) {
464
+ function loadWaivers(file = WAIVER_FILE) {
465
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'))
466
+ const waivers = Array.isArray(parsed) ? parsed : parsed?.waivers
467
+ if (!Array.isArray(waivers)) throw new Error('waivers must be an array or an object with a waivers array')
468
+ return waivers
469
+ }
470
+
471
+ function evaluator(root, options = {}) {
39
472
  const files = ROOTS.flatMap((dir) => walk(root, dir)).filter((file) => !file.includes('/node_modules/'))
40
473
  const violations = []
41
- const record = (rule, file, detail, level) => violations.push({ rule, file, detail, level })
474
+ const record = (rule, file, detail, level, match) => {
475
+ violations.push(match === undefined ? { rule, file, detail, level } : { rule, file, detail, level, match })
476
+ }
477
+
478
+ let waivers
479
+ if (options.waivers !== undefined) {
480
+ waivers = options.waivers
481
+ if (!Array.isArray(waivers)) {
482
+ record('waiver-contract', WAIVER_LABEL, 'waivers must be an array', 'error')
483
+ waivers = []
484
+ }
485
+ } else {
486
+ try {
487
+ waivers = loadWaivers(options.waiverFile)
488
+ } catch (error) {
489
+ record('waiver-contract', WAIVER_LABEL, `cannot read waivers: ${error?.message ?? error}`, 'error')
490
+ waivers = []
491
+ }
492
+ }
493
+
494
+ // Every waiver is validated before it is allowed to suppress a violation.
495
+ // An invalid entry names its rule and path in the failure so the reviewer can
496
+ // see exactly which decision is missing.
497
+ const validWaivers = []
498
+ waivers.forEach((waiver, index) => {
499
+ const label = `waiver[${index}]`
500
+ if (waiver === null || typeof waiver !== 'object' || Array.isArray(waiver)) {
501
+ record('waiver-contract', WAIVER_LABEL, `${label} is not an object`, 'error')
502
+ return
503
+ }
504
+ const rule = typeof waiver.rule === 'string' ? waiver.rule.trim() : ''
505
+ const file = typeof waiver.path === 'string' ? waiver.path.trim() : ''
506
+ const reason = typeof waiver.reason === 'string' ? waiver.reason.trim() : ''
507
+ const owner = typeof waiver.owner === 'string' ? waiver.owner.trim() : ''
508
+ const ref = typeof waiver.ref === 'string' ? waiver.ref.trim() : ''
509
+ const match = typeof waiver.match === 'string' ? waiver.match.trim() : ''
510
+ const where = file || '<unknown path>'
511
+ let valid = true
512
+ if (!rule) {
513
+ record('waiver-contract', where, `${label} has no rule${file ? ` for ${file}` : ''}`, 'error')
514
+ valid = false
515
+ } else if (!RULES.has(rule)) {
516
+ record('waiver-contract', where, `${label} for ${where} names unknown rule "${rule}"`, 'error')
517
+ valid = false
518
+ }
519
+ if (!file) {
520
+ record('waiver-contract', where, `${label}${rule ? ` for rule "${rule}"` : ''} has no path`, 'error')
521
+ valid = false
522
+ } else if (path.isAbsolute(file) || file.includes('\\') || file.split('/').includes('..')) {
523
+ record(
524
+ 'waiver-contract',
525
+ where,
526
+ `${label} for rule "${rule}" uses a repo-relative path with forward slashes: ${file}`,
527
+ 'error',
528
+ )
529
+ valid = false
530
+ }
531
+ if (!reason) {
532
+ record('waiver-contract', where, `${label} for rule "${rule}" on ${where} has no reason`, 'error')
533
+ valid = false
534
+ }
535
+ if (!owner && !ref) {
536
+ record('waiver-contract', where, `${label} for rule "${rule}" on ${where} has no owner or reference`, 'error')
537
+ valid = false
538
+ }
539
+ // domain-pure, host-scope and no-lib-from-core are load-bearing: there is
540
+ // no exemption path for them, not even a justified one (#80, #70).
541
+ if (rule === 'domain-pure' || rule === 'host-scope' || rule === 'no-lib-from-core') {
542
+ record('waiver-contract', where, `${label} for rule "${rule}" on ${where} cannot be waived`, 'error')
543
+ valid = false
544
+ }
545
+ if (file.startsWith('domain/')) {
546
+ record('waiver-contract', where, `${label} for rule "${rule}" on ${where} cannot waive domain/**`, 'error')
547
+ valid = false
548
+ }
549
+ if (valid) validWaivers.push({ rule, path: file, match: match || undefined, reason, owner: owner || undefined, ref: ref || undefined })
550
+ })
42
551
 
43
552
  for (const file of files) {
44
553
  const text = fs.readFileSync(path.join(root, file), 'utf8')
@@ -47,12 +556,57 @@ function evaluator(root) {
47
556
  if (file.startsWith('domain/') && imports.some((spec) => /^(node:fs|node:fs\/promises|node:child_process|\.\.\/adapters|adapters\/)/.test(spec))) {
48
557
  record('domain-pure', file, 'domain imports I/O or adapters', 'error')
49
558
  }
50
- if (imports.includes('node:child_process') && !CHILD_PROCESS_ALLOWED.has(file)) {
559
+ // #70: the retired lib/ layer is not a dependency surface for the core.
560
+ if (file.startsWith('domain/') || file.startsWith('app/')) {
561
+ for (const spec of imports) {
562
+ if (LIB_IMPORT.test(spec)) {
563
+ record('no-lib-from-core', file, `imports ${spec}; app/** and domain/** may not import lib/**`, 'error')
564
+ }
565
+ }
566
+ }
567
+ if (imports.includes('node:child_process')) {
51
568
  record('child-process-surface', file, 'only adapters/** may spawn processes', 'error')
52
569
  }
53
- if (imports.some((spec) => spec === 'node:fs' || spec === 'node:fs/promises') && !FS_ALLOWED.has(file)) {
570
+ if (imports.some((spec) => spec === 'node:fs' || spec === 'node:fs/promises')) {
54
571
  record('fs-surface', file, 'only adapters/** may use fs', 'error')
55
572
  }
573
+ // #69 app-ports-only: the app depends on ports, the pure domain, and Node's
574
+ // path module. Adapters and host/ infrastructure are reached through deps.
575
+ if (file.startsWith('app/')) {
576
+ for (const spec of imports) {
577
+ const allowed =
578
+ APP_ALLOWED_EXACT.has(spec) ||
579
+ APP_ALLOWED_PREFIXES.some((prefix) => spec.startsWith(prefix))
580
+ if (!allowed) {
581
+ record(
582
+ 'app-ports-only',
583
+ file,
584
+ `imports ${spec}; app/** may import only app siblings, domain/**, ports/** and node:path`,
585
+ 'error',
586
+ )
587
+ }
588
+ }
589
+ }
590
+ // #69 composition-root: only host/** and the adapters may import an adapter.
591
+ if (!COMPOSITION_ROOT_EXEMPT_PREFIXES.some((prefix) => file.startsWith(prefix)) && !file.startsWith('adapters/')) {
592
+ for (const spec of imports) {
593
+ if (ADAPTER_IMPORT.test(spec)) {
594
+ record('composition-root', file, `imports ${spec}; adapters are composed once in host/**`, 'error')
595
+ }
596
+ }
597
+ }
598
+ // #69 port-shape: a port module exports a runtime contract and imports no
599
+ // implementation. `export {}` alone is a comment, not a seam.
600
+ if (file.startsWith('ports/')) {
601
+ if (!RUNTIME_PORT_EXPORT.test(text)) {
602
+ record('port-shape', file, 'port modules must export a runtime contract (const/function/class)', 'error')
603
+ }
604
+ for (const spec of imports) {
605
+ if (PORT_IMPLEMENTATION_IMPORT.test(spec)) {
606
+ record('port-shape', file, `imports ${spec}; ports must not depend on an implementation`, 'error')
607
+ }
608
+ }
609
+ }
56
610
  if (file.startsWith('tools/')) {
57
611
  if (imports.some((spec) => spec.startsWith('node:') && spec !== 'node:path')) {
58
612
  record('tools-thin', file, 'tools/** may import only node:path', 'error')
@@ -63,14 +617,71 @@ function evaluator(root) {
63
617
  if (lines.length < 40 || lines.length > 200) {
64
618
  record('aggregate-size', file, `${lines.length} lines outside 40-200`, 'error')
65
619
  }
66
- } else if (lines.length > 800) {
620
+ } else if (!file.startsWith(CLIENT_ROOT) && lines.length > 800) {
67
621
  record('aggregate-size', file, `${lines.length} lines exceeds 800`, 'error')
68
622
  }
69
- if (/^(?:export\s+)?let\s+/m.test(text) && !AMBIENT_ALLOWED.has(file) && (file.startsWith('lib/') || file.startsWith('app/') || file.startsWith('domain/'))) {
70
- record('ambient-state', file, 'top-level let outside an allow-list', 'error')
623
+ // #70 host-scope: process/session state is a host privilege. The app and
624
+ // domain layers must stay stateless at module scope; host/** may hold the
625
+ // ambient bindings the composition root owns. The scan is statement-aware.
626
+ if (file.startsWith('app/') || file.startsWith('domain/')) {
627
+ for (const binding of scanAmbientBindings(text)) {
628
+ record('host-scope', file, `top-level let '${binding}' is only allowed under host/**`, 'error', binding)
629
+ }
630
+ }
631
+ // #71: the client tier. A browser bundle has no Host capability and no
632
+ // module-level mutable state; its state lives in the create* closure the
633
+ // entry hands each surface chunk. The ambient scan is the same statement-
634
+ // aware one app/ uses, so a second declarator or same-line statement is
635
+ // caught too.
636
+ if (file.startsWith(CLIENT_ROOT)) {
637
+ for (const spec of imports) {
638
+ if (CLIENT_NODE_SPEC.test(spec)) {
639
+ record(
640
+ 'client-no-node',
641
+ file,
642
+ `imports ${spec}; client bundles may not import node:* (fs, fs/promises, child_process, os, process)`,
643
+ 'error',
644
+ )
645
+ }
646
+ }
647
+ if (lines.length > CLIENT_SIZE_CEILING) {
648
+ record('client-size', file, `${lines.length} lines exceeds ${CLIENT_SIZE_CEILING}`, 'error')
649
+ }
650
+ for (const binding of scanAmbientBindings(text)) {
651
+ record(
652
+ 'client-ambient',
653
+ file,
654
+ `top-level let '${binding}' under ui-bundle/**; keep client state inside its create* closure`,
655
+ 'error',
656
+ binding,
657
+ )
658
+ }
659
+ }
660
+ }
661
+
662
+ // A waiver only suppresses the violation it names. Path-only waivers cover
663
+ // per-file rules; `match` narrows one to a single violation when present.
664
+ const remaining = []
665
+ let waived = 0
666
+ for (const violation of violations) {
667
+ const waiver = validWaivers.find(
668
+ (entry) =>
669
+ entry.rule === violation.rule &&
670
+ entry.path === violation.file &&
671
+ (entry.match === undefined || entry.match === violation.match),
672
+ )
673
+ if (waiver && violation.level === 'error') {
674
+ waived += 1
675
+ continue
71
676
  }
677
+ remaining.push(violation)
678
+ }
679
+ return {
680
+ ok: remaining.every((violation) => violation.level !== 'error'),
681
+ violations: remaining,
682
+ waivers: validWaivers.length,
683
+ waived,
72
684
  }
73
- return { ok: violations.every((violation) => violation.level !== 'error'), violations }
74
685
  }
75
686
 
76
687
  const rootArg = process.argv.indexOf('--root')
@@ -86,9 +697,9 @@ if (isMain) {
86
697
  if (violation.level === 'error') console.error(line)
87
698
  else console.warn(line)
88
699
  }
89
- if (result.violations.length === 0) console.log('architecture: ok')
700
+ if (result.violations.length === 0) console.log(`architecture: ok (waivers: ${result.waived})`)
90
701
  }
91
702
  process.exit(result.ok ? 0 : 1)
92
703
  }
93
704
 
94
- export { evaluator }
705
+ export { evaluator, loadWaivers, WAIVER_FILE }