dsh-tower 2.1.0 → 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 +103 -0
- package/COMPATIBILITY.md +5 -1
- package/CONTRIBUTING.md +64 -5
- package/PRINCIPLES.md +145 -0
- package/README.en.md +7 -4
- package/README.md +7 -4
- package/SECURITY.md +6 -0
- package/{lib → adapters}/archive.js +1 -1
- package/{lib → adapters}/caseSensitivity.js +1 -1
- package/adapters/default.js +51 -10
- package/{lib → adapters}/git.js +1 -1
- package/{lib → adapters}/leases.js +1 -1
- package/{lib → adapters}/legacy.js +1 -1
- package/adapters/persistence.js +543 -0
- package/adapters/store-fs.js +28 -1
- package/adapters/store.js +204 -0
- package/{lib → adapters}/tests.js +9 -5
- package/adapters/vcs-git.js +15 -1
- package/{lib → adapters}/workspaceLock.js +1 -1
- package/{lib → adapters}/worktreeOwnership.js +1 -1
- package/app/archive.js +4 -6
- package/app/batch.js +9 -10
- package/app/board.js +2 -4
- package/app/checkCompletion.js +9 -10
- package/app/enrichedMissions.js +2 -4
- package/app/findings.js +6 -7
- package/app/gate.js +1 -2
- package/app/init.js +24 -29
- package/app/merge.js +58 -51
- package/app/messages.js +7 -8
- package/app/mission.js +5 -5
- package/app/plan.js +8 -5
- package/app/provision.js +12 -14
- package/app/read.js +6 -3
- package/app/rebase.js +14 -16
- package/app/recording.js +7 -8
- package/app/review.js +15 -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 +8 -9
- package/app/tower.js +89 -35
- 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/workflow.md +51 -0
- package/docs/architecture/overview.md +167 -36
- package/docs/guide/hot-reload.md +1 -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 +2 -2
- package/docs/reference/config.generated.md +1 -0
- package/docs/reference/doctor.md +48 -2
- package/docs/reference/errors.generated.md +41 -37
- package/docs/reference/errors.md +53 -41
- package/docs/reference/events.md +38 -6
- package/docs/reference/surface-budget.md +128 -0
- package/docs/reference/tools.generated.md +5 -4
- package/docs/reference/tools.md +4 -4
- package/docs/zh/guide/hot-reload.md +1 -1
- package/docs/zh/reference/blockers.md +2 -2
- package/docs/zh/reference/doctor.md +29 -1
- package/docs/zh/reference/tools.md +4 -4
- package/domain/batch.js +2 -2
- package/domain/config/schema.js +25 -1
- package/domain/contracts/outputs.js +6 -2
- package/domain/contracts/tool-inputs/ops.js +1 -0
- package/domain/errors.js +297 -42
- package/domain/events.js +207 -0
- package/domain/integration/gate.js +17 -2
- package/domain/integration/merge.js +35 -0
- package/domain/lockBudget.js +30 -0
- package/domain/missions/completion.js +14 -0
- package/domain/missions/lifecycle.js +9 -3
- package/domain/missions/plan.js +14 -0
- package/domain/missions/review.js +90 -7
- package/domain/read/views.js +3 -0
- package/domain/state/archive.js +1 -1
- package/domain/state.js +1 -1
- package/domain/surface.js +248 -0
- package/{lib → host}/doctor.js +116 -9
- 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 +58 -22
- 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 +2 -3
- 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 +3 -6
- 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 +6 -5
- package/tools/preflight.js +2 -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 +3 -3
- package/tools/teardown.js +2 -3
- package/tools/test.js +2 -2
- package/types/index.d.ts +1 -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/persistence.js +0 -356
- package/lib/store.js +0 -133
- /package/{lib → adapters}/stateFile.js +0 -0
- /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/scripts/architecture.mjs
CHANGED
|
@@ -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
|
|
11
|
-
const
|
|
12
|
-
const
|
|
13
|
-
const
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
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
|
|
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) =>
|
|
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
|
-
|
|
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')
|
|
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
|
-
|
|
70
|
-
|
|
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(
|
|
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 }
|