arkgate 4.0.0 → 4.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/CHANGELOG.md +142 -0
  2. package/README.md +7 -5
  3. package/bin/ark-check-runtime.mjs +244 -25
  4. package/bin/ark-check.mjs +10 -1
  5. package/bin/ark-layer-match.mjs +80 -5
  6. package/bin/ark-shared.mjs +170 -9
  7. package/bin/ark.mjs +52 -5
  8. package/bin/lib/adapter-contract.mjs +7 -1
  9. package/bin/lib/agent-gates.mjs +2 -0
  10. package/bin/lib/analysis-engine.mjs +6 -6
  11. package/bin/lib/arkrules-sensors.mjs +63 -22
  12. package/bin/lib/ci-and-commands.mjs +148 -8
  13. package/bin/lib/core-ratchet.mjs +9 -4
  14. package/bin/lib/doctor-advisories.mjs +8 -1
  15. package/bin/lib/doctor-plan.mjs +277 -59
  16. package/bin/lib/enforcement-honesty.mjs +351 -26
  17. package/bin/lib/enforcement-state.mjs +1 -1
  18. package/bin/lib/field-install.mjs +35 -2
  19. package/bin/lib/graph-blind.mjs +1 -1
  20. package/bin/lib/html-report-advisories.mjs +8 -25
  21. package/bin/lib/html-report-depth.mjs +167 -3
  22. package/bin/lib/html-report.mjs +12 -5
  23. package/bin/lib/install-migrate.mjs +109 -6
  24. package/bin/lib/managed-upgrade.mjs +100 -1
  25. package/bin/lib/presets.mjs +314 -46
  26. package/bin/lib/project-root.mjs +268 -0
  27. package/bin/lib/remediation.mjs +12 -11
  28. package/bin/lib/rules-inventory.mjs +71 -29
  29. package/bin/lib/rules-under-contract.mjs +389 -5
  30. package/bin/lib/start-preview.mjs +48 -14
  31. package/bin/lib/suggestions.mjs +118 -3
  32. package/bin/lib/unavailable-analysis.mjs +2 -0
  33. package/bin/lib/upgrade-command.mjs +325 -14
  34. package/bin/lib/write-path-capabilities.mjs +38 -9
  35. package/dist/eslint/index.cjs +2 -2
  36. package/dist/eslint/index.d.ts +27 -2
  37. package/dist/eslint/index.js +2 -2
  38. package/dist/index.cjs +16 -14
  39. package/dist/index.d.ts +3 -1
  40. package/dist/index.js +16 -14
  41. package/docs/README.md +3 -2
  42. package/docs/ai-gates.md +15 -11
  43. package/docs/brownfield-adoption.md +38 -0
  44. package/docs/configuration.md +59 -7
  45. package/docs/package-surface.md +3 -2
  46. package/docs/product-voice.md +10 -1
  47. package/docs/typescript-support.md +9 -5
  48. package/docs/use.md +7 -5
  49. package/package.json +3 -1
  50. package/server.json +3 -3
  51. package/templates/architecture-playbook.json +3 -0
  52. package/templates/layers/shared-types.starter.json +29 -0
  53. package/templates/skills/ark-adopt.md +2 -0
  54. package/templates/skills/ark-explain.md +23 -5
  55. package/templates/skills/ark-explore.md +21 -1
  56. package/templates/skills/ark-fix.md +16 -5
  57. package/templates/skills/ark-upgrade.md +57 -11
@@ -0,0 +1,268 @@
1
+ /**
2
+ * Effective project root for ark-check / doctor.
3
+ *
4
+ * Monorepo honesty (NEW-MONOREPO-CWD-WALKUP): when cwd (or --root) has no
5
+ * ark.config.json, walk parent directories until one is found. Never invent a
6
+ * silent 11-layer / empty ADAPT world while a parent monorepo contract exists.
7
+ *
8
+ * Security (S0 walk-up review):
9
+ * - Split **config discovery root** from **write root**.
10
+ * - Walk-up is for read/doctor/check by default.
11
+ * - Mutating commands stay on explicit --root/cwd unless --follow-config-root.
12
+ * - Walk is bounded by git root, workspaces package root, and max depth.
13
+ * - Config write paths must stay under the write root (no --config ../outside).
14
+ */
15
+ import fs from 'node:fs';
16
+ import path from 'node:path';
17
+
18
+ /** Safety cap so a pathological tree cannot walk forever. */
19
+ export const MAX_CONFIG_WALK_DEPTH = 32;
20
+
21
+ /**
22
+ * True when dir looks like a package-manager workspaces / monorepo root.
23
+ * Used as an upper bound for walk-up (config at this root is accepted; parents are not).
24
+ * @param {string} dir
25
+ */
26
+ export function isWorkspacesPackageRoot(dir) {
27
+ const pkgPath = path.join(dir, 'package.json');
28
+ try {
29
+ if (!fs.statSync(pkgPath, { throwIfNoEntry: false })?.isFile()) {
30
+ // still check workspace marker files below
31
+ } else {
32
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
33
+ if (
34
+ pkg &&
35
+ (Array.isArray(pkg.workspaces) ||
36
+ (pkg.workspaces && typeof pkg.workspaces === 'object'))
37
+ ) {
38
+ return true;
39
+ }
40
+ }
41
+ } catch {
42
+ // ignore
43
+ }
44
+ for (const marker of ['pnpm-workspace.yaml', 'lerna.json', 'rush.json', 'nx.json']) {
45
+ try {
46
+ if (fs.statSync(path.join(dir, marker), { throwIfNoEntry: false })?.isFile()) return true;
47
+ } catch {
48
+ // ignore
49
+ }
50
+ }
51
+ return false;
52
+ }
53
+
54
+ /**
55
+ * True when dir is a git worktree root (.git file or directory).
56
+ * @param {string} dir
57
+ */
58
+ export function isGitRoot(dir) {
59
+ try {
60
+ const git = path.join(dir, '.git');
61
+ const st = fs.statSync(git, { throwIfNoEntry: false });
62
+ return Boolean(st && (st.isDirectory() || st.isFile()));
63
+ } catch {
64
+ return false;
65
+ }
66
+ }
67
+
68
+ /**
69
+ * Resolve config path and require it is under projectRoot (or equal).
70
+ * Used by mutative paths (migrate-contract --write, init --force, etc.).
71
+ *
72
+ * @param {string} projectRoot
73
+ * @param {string} configPathOrName absolute path or relative name
74
+ * @returns {{ ok: true, configPath: string } | { ok: false, error: string, configPath: string }}
75
+ */
76
+ export function resolveConfigPathWithinRoot(projectRoot, configPathOrName) {
77
+ const root = path.resolve(projectRoot || process.cwd());
78
+ const raw = configPathOrName || 'ark.config.json';
79
+ const configPath = path.isAbsolute(raw) ? path.resolve(raw) : path.resolve(root, raw);
80
+ const rel = path.relative(root, configPath);
81
+ if (rel.startsWith('..') || path.isAbsolute(rel)) {
82
+ return {
83
+ ok: false,
84
+ configPath,
85
+ error: `Refusing config path outside project root: ${configPath} (root ${root}). Pass a path under --root, or only use --follow-config-root when intentional monorepo writes are required.`,
86
+ };
87
+ }
88
+ return { ok: true, configPath };
89
+ }
90
+
91
+ /**
92
+ * Walk parents from startDir looking for configName (default ark.config.json).
93
+ * Bounds: filesystem root, max depth, git root, workspaces package root.
94
+ * Config found at a bound root is accepted; walking above a bound is refused.
95
+ *
96
+ * @param {string} startDir
97
+ * @param {string} [configName='ark.config.json']
98
+ * @param {{ maxDepth?: number, boundAtGitRoot?: boolean, boundAtWorkspacesRoot?: boolean }} [opts]
99
+ * @returns {{ root: string, configPath: string, walkedUp: boolean } | null}
100
+ */
101
+ export function findNearestArkConfig(startDir, configName = 'ark.config.json', opts = {}) {
102
+ const maxDepth = Number.isFinite(opts.maxDepth) ? opts.maxDepth : MAX_CONFIG_WALK_DEPTH;
103
+ const boundAtGit = opts.boundAtGitRoot !== false;
104
+ const boundAtWorkspaces = opts.boundAtWorkspacesRoot !== false;
105
+
106
+ if (typeof configName === 'string' && path.isAbsolute(configName)) {
107
+ if (fs.existsSync(configName)) {
108
+ const root = path.dirname(configName);
109
+ const start = path.resolve(startDir || process.cwd());
110
+ return { root, configPath: configName, walkedUp: path.resolve(root) !== start };
111
+ }
112
+ return null;
113
+ }
114
+
115
+ let dir = path.resolve(startDir || process.cwd());
116
+ const start = dir;
117
+ const name = configName || 'ark.config.json';
118
+ let depth = 0;
119
+ for (;;) {
120
+ const candidate = path.join(dir, name);
121
+ try {
122
+ if (fs.statSync(candidate, { throwIfNoEntry: false })?.isFile()) {
123
+ return {
124
+ root: dir,
125
+ configPath: candidate,
126
+ walkedUp: dir !== start,
127
+ };
128
+ }
129
+ } catch {
130
+ // unreadable — keep walking
131
+ }
132
+
133
+ const parent = path.dirname(dir);
134
+ if (parent === dir) return null;
135
+ depth += 1;
136
+ if (depth > maxDepth) return null;
137
+
138
+ // Do not walk above git / workspaces roots (config at this dir already checked).
139
+ if (boundAtGit && isGitRoot(dir)) return null;
140
+ if (boundAtWorkspaces && isWorkspacesPackageRoot(dir)) return null;
141
+
142
+ dir = parent;
143
+ }
144
+ }
145
+
146
+ /**
147
+ * Resolve config discovery vs write roots for CLI invocation.
148
+ *
149
+ * - If config exists at startRoot → use startRoot for both.
150
+ * - Else walk parents for config (bounded) → configRoot may differ from writeRoot.
151
+ * - writeMode without followConfigRoot: keep writeRoot/start as `root` (do not rewrite parent).
152
+ * - writeMode + followConfigRoot (or read mode): adopt walked config root as `root`.
153
+ *
154
+ * @param {string} startRoot
155
+ * @param {{
156
+ * configName?: string,
157
+ * writeMode?: boolean,
158
+ * followConfigRoot?: boolean,
159
+ * maxDepth?: number,
160
+ * }} [opts]
161
+ * @returns {{
162
+ * root: string,
163
+ * writeRoot: string,
164
+ * config: string,
165
+ * configPath: string,
166
+ * configRoot: string,
167
+ * walkedUp: boolean,
168
+ * configFound: boolean,
169
+ * writeRootFollowedConfig: boolean,
170
+ * }}
171
+ */
172
+ export function resolveEffectiveProjectRoot(startRoot, opts = {}) {
173
+ const configName = opts.configName || 'ark.config.json';
174
+ const start = path.resolve(startRoot || process.cwd());
175
+ const writeMode = opts.writeMode === true;
176
+ const followConfigRoot = opts.followConfigRoot === true;
177
+ // Writes adopt walked config root only with explicit opt-in.
178
+ const adoptWalkedRoot = !writeMode || followConfigRoot;
179
+
180
+ if (typeof configName === 'string' && path.isAbsolute(configName)) {
181
+ const found = findNearestArkConfig(start, configName, opts);
182
+ if (found) {
183
+ const root = adoptWalkedRoot ? found.root : start;
184
+ return {
185
+ root,
186
+ writeRoot: start,
187
+ config: configName,
188
+ configPath: found.configPath,
189
+ configRoot: found.root,
190
+ walkedUp: found.walkedUp,
191
+ configFound: true,
192
+ writeRootFollowedConfig: adoptWalkedRoot && found.walkedUp,
193
+ };
194
+ }
195
+ return {
196
+ root: start,
197
+ writeRoot: start,
198
+ config: configName,
199
+ configPath: configName,
200
+ configRoot: start,
201
+ walkedUp: false,
202
+ configFound: false,
203
+ writeRootFollowedConfig: false,
204
+ };
205
+ }
206
+
207
+ const localPath = path.join(start, configName);
208
+ try {
209
+ if (fs.statSync(localPath, { throwIfNoEntry: false })?.isFile()) {
210
+ return {
211
+ root: start,
212
+ writeRoot: start,
213
+ config: configName,
214
+ configPath: localPath,
215
+ configRoot: start,
216
+ walkedUp: false,
217
+ configFound: true,
218
+ writeRootFollowedConfig: false,
219
+ };
220
+ }
221
+ } catch {
222
+ // fall through to walk-up
223
+ }
224
+
225
+ const found = findNearestArkConfig(start, configName, opts);
226
+ if (found) {
227
+ const root = adoptWalkedRoot ? found.root : start;
228
+ return {
229
+ root,
230
+ writeRoot: start,
231
+ config: configName,
232
+ configPath: found.configPath,
233
+ configRoot: found.root,
234
+ walkedUp: true,
235
+ configFound: true,
236
+ writeRootFollowedConfig: adoptWalkedRoot && found.walkedUp,
237
+ };
238
+ }
239
+
240
+ return {
241
+ root: start,
242
+ writeRoot: start,
243
+ config: configName,
244
+ configPath: localPath,
245
+ configRoot: start,
246
+ walkedUp: false,
247
+ configFound: false,
248
+ writeRootFollowedConfig: false,
249
+ };
250
+ }
251
+
252
+ /**
253
+ * Commands that mutate project files under --root.
254
+ * Walk-up must not rewrite a parent monorepo unless --follow-config-root.
255
+ * @param {Record<string, unknown>} args
256
+ */
257
+ export function isMutatingCliCommand(args = {}) {
258
+ if (args.installAgentGates) return true;
259
+ if (args.init) return true;
260
+ if (args.applyPolicyPack) return true;
261
+ if (args.migrateContract && args.write) return true;
262
+ if (args.adoptContract && args.write) return true;
263
+ if (args.updateBaseline) return true;
264
+ if (args.ratchetCores) return true;
265
+ if (args.migrateCommands) return true;
266
+ if (args.suggestInclude && args.write) return true;
267
+ return false;
268
+ }
@@ -109,14 +109,7 @@ export function classifyRemediation(violation) {
109
109
  rationale: 'Whole source file is type-only surface (no runtime statements) with a type-only cross-layer edge: relocate the file to the owning layer (or extract the type there). Behavior-preserving; gate verifies.',
110
110
  };
111
111
  }
112
- if (violation?.typeOnly) {
113
- return {
114
- class: 'mechanical-safe',
115
- confidence: 0.9,
116
- remediationKind: 'type-only-import-move',
117
- rationale: 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
118
- };
119
- }
112
+ // Pure type-only *module* first (target has no value exports) — convert to import type.
120
113
  if (violation?.targetTypeOnlyExports) {
121
114
  return {
122
115
  class: 'mechanical-safe',
@@ -125,9 +118,9 @@ export function classifyRemediation(violation) {
125
118
  rationale: 'Static import targets a pure type-only module: convert to `import type` (erased at runtime) and place the type in a shared/owning layer. No runtime coupling; gate verifies.',
126
119
  };
127
120
  }
128
- // R6: value-syntax named import/export of type-only exports from a mixed module.
129
- // Only set when scan proves no dual-space value export and no top-level side effects.
130
- if (violation?.namedBindingsTypeOnly) {
121
+ // R6: value-syntax named import of type-only exports from a *mixed* module.
122
+ // When the edge is already `import type`, prefer relocate (type-only-import-move) below.
123
+ if (violation?.namedBindingsTypeOnly && !violation?.typeOnly) {
131
124
  return {
132
125
  class: 'mechanical-safe',
133
126
  confidence: 0.86,
@@ -135,6 +128,14 @@ export function classifyRemediation(violation) {
135
128
  rationale: 'Named bindings are type-only exports of the target module (even if the file also exports values): convert to `import type` / `export type` (erased at runtime). Gate verifies.',
136
129
  };
137
130
  }
131
+ if (violation?.typeOnly) {
132
+ return {
133
+ class: 'mechanical-safe',
134
+ confidence: 0.9,
135
+ remediationKind: 'type-only-import-move',
136
+ rationale: 'Type-only import (erased at runtime): move the type to the layer that owns it and re-export for back-compat. Behavior-preserving, and the gate verifies it.',
137
+ };
138
+ }
138
139
  // W6: port-proof inject is a *suggested* shape when proof holds, but always judgment
139
140
  // for auto-apply — adding a required parameter breaks external call sites.
140
141
  if (violation?.portProofEligible &&
@@ -15,11 +15,24 @@ export function buildRulesInventory(input) {
15
15
  const candidates = [];
16
16
  let seq = 0;
17
17
  for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
18
- const isController = /controller|route|handler|resolver/i.test(file) ||
19
- /@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content);
18
+ const posix = file.replace(/\\/g, '/');
19
+ // P2-N — clear UI bags only (components/theme/styles). Do NOT blanket-skip all
20
+ // app/pages (server actions / route handlers live there and stay inventoriable).
21
+ const isUiChrome = /(?:^|\/)(?:components|ui|layouts|styles|hooks|theme|tokens|i18n|locales?)(?:\/|$)/i.test(posix) ||
22
+ /(?:^|\/)(?:src\/)?(?:app|pages)\/.+\.(?:tsx|jsx)$/i.test(posix) &&
23
+ /(?:page|layout|loading|error|template|default)\.(?:tsx|jsx)$/i.test(posix);
24
+ const isApiRoute = /(?:^|\/)(?:app|pages)(?:\/[^/]+)*\/api(?:\/|$)/i.test(posix);
25
+ const isServerAction = /(?:^|\/)actions?(?:\/|\.|$)/i.test(posix) || /['"]use server['"]/.test(content);
26
+ const isController = /controller|handler|resolver/i.test(file) ||
27
+ isApiRoute ||
28
+ isServerAction ||
29
+ (/route\.(?:ts|js|tsx|jsx)$/i.test(posix) && !isUiChrome) ||
30
+ /@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content) ||
31
+ /\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b/.test(content) ||
32
+ /\bexport\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=/.test(content);
20
33
  const isDomain = /domain|entity|aggregate|model/i.test(file);
21
- // validation-in-controller
22
- if (isController) {
34
+ // validation-in-controller (API/Nest/server-action handlers — not pure UI chrome)
35
+ if (isController && !isUiChrome) {
23
36
  const valRe = /\b(if\s*\([^)]{0,80}(amount|total|price|qty|quantity|balance)[^)]{0,40}\)|throw new (Error|BadRequest|ValidationError)|z\.object\(|yup\.|class-validator|@Is[A-Z])/g;
24
37
  let m;
25
38
  while ((m = valRe.exec(content)) !== null) {
@@ -40,11 +53,32 @@ export function buildRulesInventory(input) {
40
53
  });
41
54
  }
42
55
  }
43
- // magic business constants (heuristic)
56
+ // magic business constants (heuristic) — quiet UI labels via anchored prefixes/tokens
44
57
  const magicRe = /\b(const|let)\s+([A-Z][A-Z0-9_]{2,})\s*=\s*(\d{2,}|['"][^'"]{8,}['"])/g;
45
58
  let magic;
59
+ // Wave-2 (P2N residual): infra / I/O / storage noise without swallowing domain seeds
60
+ // like MAX_CART_SIZE, ORDER_STATUS_OPEN, MAX_PROPERTY_LIMIT, MAX_MEDIA_PER_PROPERTY,
61
+ // DEFAULT_ORDER_LIMIT (narrow DEFAULT_/REQUEST_/STORAGE_ — do not drop all DEFAULT_*).
62
+ const isInfraMagicName = (name) => /^(?:TEST|SPEC|TIMEOUT|PORT|VERSION|MAX_RETRY|MIN_RETRY|TTL|CACHE|HEADER|COOKIE|MIME|CONTENT_TYPE|HTTP_STATUS|NODE_ENV|LOG_LEVEL|FEATURE_FLAG|ID_PREFIX|Z_INDEX)(?:_|$)/i.test(name) ||
63
+ /^(?:ROUTE|PATH|LABEL|TITLE|HEADING|CLASS|STYLE|COLOR|THEME|BREAKPOINT|QUERY|PARAM|ICON|ARIA|MSG|COPY|I18N|LOCALE|PAGE|NAV|MENU|TAB|BTN|BUTTON|PLACEHOLDER|TOOLTIP|SHADOW|RADIUS|GAP|PADDING|MARGIN|FONT|WIDTH|HEIGHT|OPACITY|DURATION|EASE|ANIM)_/i.test(name) ||
64
+ /_(?:ROUTE|PATH|LABEL|TITLE|COLOR|THEME|CLASS|STYLE|ICON|ARIA|MSG|COPY|TIMEOUT|PORT|VERSION|RETRY|DELAY|INTERVAL|TTL|CACHE)$/i.test(name) ||
65
+ // ms/timeout/bytes/storage/bucket/url infra suffixes (predial residual)
66
+ /_(?:TIMEOUT(?:_MS)?|MS|BYTES|BUCKET|STORAGE_KEY|WINDOW_MS)$/i.test(name) ||
67
+ // Narrow DEFAULT_/REQUEST_/STORAGE_ — only known infra tokens, not all DEFAULT_* seeds
68
+ /^(?:DEFAULT_(?:BASE_URL|TIMEOUT(?:_MS)?|RETRY|PORT|HOST|HEADERS?|CACHE|TTL|MS|LOCALE|LANG|TIMEZONE|TZ)|REQUEST_(?:TIMEOUT(?:_MS)?|HEADERS?|RETRY|ID_PREFIX)|STORAGE_(?:KEY|PREFIX|BUCKET)|DAY_MS$|APP_DOMAIN$|BASE_URL$)$/i.test(name) ||
69
+ // Known I/O bag prefixes that are never domain seeds in field clones
70
+ /^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name);
46
71
  while ((magic = magicRe.exec(content)) !== null) {
47
- if (/TEST|SPEC|TIMEOUT|PORT|VERSION|MAX_RETRY/i.test(magic[2]))
72
+ const name = magic[2];
73
+ if (isInfraMagicName(name))
74
+ continue;
75
+ // P2-N: skip remaining ALL_CAPS noise only on clear UI chrome (not all of app/).
76
+ if (isUiChrome && !isDomain)
77
+ continue;
78
+ // Wave-2: integrations / repos / clients are usually I/O constants, not Domain seeds.
79
+ // Keep Domain/controller paths so spaghetti magic limits still surface.
80
+ const isIoSurface = /(?:^|\/)(?:integrations?|repos?|clients?|infra(?:structure)?|adapters?)(?:\/|$)/i.test(posix) && !isDomain;
81
+ if (isIoSurface)
48
82
  continue;
49
83
  seq += 1;
50
84
  candidates.push({
@@ -52,9 +86,9 @@ export function buildRulesInventory(input) {
52
86
  kind: 'magic-business-constant',
53
87
  file,
54
88
  line: lineOf(content, magic.index),
55
- message: `Magic business constant ${magic[2]} may belong in a Domain policy or invariant catalog.`,
89
+ message: `Magic business constant ${name} may belong in a Domain policy or invariant catalog.`,
56
90
  confidence: 'heuristic',
57
- suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${magic[2]}` },
91
+ suggestedArkRule: { layer: 'DomainModel', invariantId: `INV-${name}` },
58
92
  neverMechanicalSafe: true,
59
93
  });
60
94
  }
@@ -87,27 +121,35 @@ export function buildRulesInventory(input) {
87
121
  }
88
122
  // mutation without guard in domain
89
123
  if (isDomain) {
90
- const mutRe = /this\.\w+\s*=/g;
91
- let mut;
92
- while ((mut = mutRe.exec(content)) !== null) {
93
- const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
94
- if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
95
- seq += 1;
96
- candidates.push({
97
- id: `inv-mut-${seq}`,
98
- kind: 'mutation-without-guard',
99
- file,
100
- line: lineOf(content, mut.index),
101
- message: 'Domain field mutation without nearby guard/publish call.',
102
- confidence: 'heuristic',
103
- suggestedArkRule: {
104
- layer: 'DomainModel',
105
- structureId: 'events-on-mutation',
106
- sensor: 'domain-event-on-mutation',
107
- },
108
- neverMechanicalSafe: true,
109
- });
110
- break; // one per file is enough for inventory ranking
124
+ // Wave-2: *Error / *access.error bags are not aggregate mutators (propia residual).
125
+ // Narrow: path-based error modules only — do not skip whole domain files that
126
+ // merely export an Error class alongside aggregates.
127
+ const isErrorBag = /\.error\.(?:ts|js|tsx|jsx)$/i.test(posix) ||
128
+ /(?:^|\/)[^/]*(?:-access)?\.error\./i.test(posix) ||
129
+ /(?:^|\/)errors?(?:\/|$)/i.test(posix);
130
+ if (!isErrorBag) {
131
+ const mutRe = /this\.\w+\s*=/g;
132
+ let mut;
133
+ while ((mut = mutRe.exec(content)) !== null) {
134
+ const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
135
+ if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
136
+ seq += 1;
137
+ candidates.push({
138
+ id: `inv-mut-${seq}`,
139
+ kind: 'mutation-without-guard',
140
+ file,
141
+ line: lineOf(content, mut.index),
142
+ message: 'Domain field mutation without nearby guard/publish call.',
143
+ confidence: 'heuristic',
144
+ suggestedArkRule: {
145
+ layer: 'DomainModel',
146
+ structureId: 'events-on-mutation',
147
+ sensor: 'domain-event-on-mutation',
148
+ },
149
+ neverMechanicalSafe: true,
150
+ });
151
+ break; // one per file is enough for inventory ranking
152
+ }
111
153
  }
112
154
  }
113
155
  }