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.
- package/CHANGELOG.md +142 -0
- package/README.md +7 -5
- package/bin/ark-check-runtime.mjs +244 -25
- package/bin/ark-check.mjs +10 -1
- package/bin/ark-layer-match.mjs +80 -5
- package/bin/ark-shared.mjs +170 -9
- package/bin/ark.mjs +52 -5
- package/bin/lib/adapter-contract.mjs +7 -1
- package/bin/lib/agent-gates.mjs +2 -0
- package/bin/lib/analysis-engine.mjs +6 -6
- package/bin/lib/arkrules-sensors.mjs +63 -22
- package/bin/lib/ci-and-commands.mjs +148 -8
- package/bin/lib/core-ratchet.mjs +9 -4
- package/bin/lib/doctor-advisories.mjs +8 -1
- package/bin/lib/doctor-plan.mjs +277 -59
- package/bin/lib/enforcement-honesty.mjs +351 -26
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/field-install.mjs +35 -2
- package/bin/lib/graph-blind.mjs +1 -1
- package/bin/lib/html-report-advisories.mjs +8 -25
- package/bin/lib/html-report-depth.mjs +167 -3
- package/bin/lib/html-report.mjs +12 -5
- package/bin/lib/install-migrate.mjs +109 -6
- package/bin/lib/managed-upgrade.mjs +100 -1
- package/bin/lib/presets.mjs +314 -46
- package/bin/lib/project-root.mjs +268 -0
- package/bin/lib/remediation.mjs +12 -11
- package/bin/lib/rules-inventory.mjs +71 -29
- package/bin/lib/rules-under-contract.mjs +389 -5
- package/bin/lib/start-preview.mjs +48 -14
- package/bin/lib/suggestions.mjs +118 -3
- package/bin/lib/unavailable-analysis.mjs +2 -0
- package/bin/lib/upgrade-command.mjs +325 -14
- package/bin/lib/write-path-capabilities.mjs +38 -9
- package/dist/eslint/index.cjs +2 -2
- package/dist/eslint/index.d.ts +27 -2
- package/dist/eslint/index.js +2 -2
- package/dist/index.cjs +16 -14
- package/dist/index.d.ts +3 -1
- package/dist/index.js +16 -14
- package/docs/README.md +3 -2
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +38 -0
- package/docs/configuration.md +59 -7
- package/docs/package-surface.md +3 -2
- package/docs/product-voice.md +10 -1
- package/docs/typescript-support.md +9 -5
- package/docs/use.md +7 -5
- package/package.json +3 -1
- package/server.json +3 -3
- package/templates/architecture-playbook.json +3 -0
- package/templates/layers/shared-types.starter.json +29 -0
- package/templates/skills/ark-adopt.md +2 -0
- package/templates/skills/ark-explain.md +23 -5
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
- 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
|
+
}
|
package/bin/lib/remediation.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
|
129
|
-
//
|
|
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
|
|
19
|
-
|
|
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
|
-
|
|
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 ${
|
|
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-${
|
|
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
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
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
|
}
|