arkgate 4.0.1 → 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 +90 -0
- package/README.md +6 -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/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 +99 -0
- 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 +134 -4
- 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/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 -3
- package/docs/ai-gates.md +15 -11
- package/docs/brownfield-adoption.md +36 -0
- package/docs/configuration.md +36 -0
- package/docs/package-surface.md +3 -3
- package/docs/product-voice.md +7 -0
- package/docs/typescript-support.md +9 -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 +5 -0
- package/templates/skills/ark-explore.md +21 -1
- package/templates/skills/ark-fix.md +16 -5
|
@@ -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
|
}
|
|
@@ -16,12 +16,54 @@ const COVERED_SAMPLE_MAX = 24;
|
|
|
16
16
|
const STRUCTURE_CATALOG_MAX = 40;
|
|
17
17
|
const UNCOVERED_CATALOG_MAX = 30;
|
|
18
18
|
|
|
19
|
+
/** Minimum governed % before enforced ArkRules may arm extra merge teeth (P1M / FG-EXTRATEETH). */
|
|
20
|
+
export const EXTRA_MERGE_TEETH_GOVERNED_FLOOR = 50;
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* P1M / extraMergeTeeth: under the classification floor, demote enforced ArkRules
|
|
24
|
+
* structure/invariant findings so merge matches doctor stamp (layer graph only).
|
|
25
|
+
* Unknown classification (null/null) → do not demote (contract-only callers).
|
|
26
|
+
*
|
|
27
|
+
* @param {object[]} violations
|
|
28
|
+
* @param {{ governedPercent?: number|null, populatedLayerCount?: number|null }} classification
|
|
29
|
+
* @returns {object[]}
|
|
30
|
+
*/
|
|
31
|
+
export function demoteArkRuleTeethUnderClassificationFloor(violations, classification = {}) {
|
|
32
|
+
if (!Array.isArray(violations)) return violations;
|
|
33
|
+
const governed =
|
|
34
|
+
typeof classification.governedPercent === 'number' ? classification.governedPercent : null;
|
|
35
|
+
const populated =
|
|
36
|
+
typeof classification.populatedLayerCount === 'number'
|
|
37
|
+
? classification.populatedLayerCount
|
|
38
|
+
: null;
|
|
39
|
+
if (governed == null && populated == null) return violations;
|
|
40
|
+
const allowsTeeth =
|
|
41
|
+
(governed ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR && (populated ?? 0) >= 1;
|
|
42
|
+
if (allowsTeeth) return violations;
|
|
43
|
+
for (const v of violations) {
|
|
44
|
+
const isArkRule =
|
|
45
|
+
v?.arkruleId != null ||
|
|
46
|
+
(typeof v?.ruleId === 'string' &&
|
|
47
|
+
(v.ruleId.startsWith('ARKRULE') || v.ruleId.startsWith('arkrule')));
|
|
48
|
+
if (isArkRule && v.failsStrict !== false) {
|
|
49
|
+
v.failsStrict = false;
|
|
50
|
+
if (v.severity === 'error') v.severity = 'warning';
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
return violations;
|
|
54
|
+
}
|
|
55
|
+
|
|
19
56
|
/**
|
|
20
57
|
* @param {string} root
|
|
21
58
|
* @param {Record<string, unknown>} config
|
|
22
59
|
* @param {{ files?: Array<{ path: string }> }} [facts] optional facts for path set
|
|
60
|
+
* @param {{
|
|
61
|
+
* governedPercent?: number | null,
|
|
62
|
+
* populatedLayerCount?: number | null,
|
|
63
|
+
* classifiedFiles?: number | null,
|
|
64
|
+
* }} [classification] layer-plane coverage (when known)
|
|
23
65
|
*/
|
|
24
|
-
export function summarizeRulesUnderContract(root, config, facts) {
|
|
66
|
+
export function summarizeRulesUnderContract(root, config, facts, classification) {
|
|
25
67
|
if (!config?.arkRules || Object.keys(config.arkRules).length === 0) {
|
|
26
68
|
return {
|
|
27
69
|
active: false,
|
|
@@ -112,12 +154,88 @@ export function summarizeRulesUnderContract(root, config, facts) {
|
|
|
112
154
|
const coveredTruncated = Math.max(0, coveredAll.length - COVERED_SAMPLE_MAX);
|
|
113
155
|
const coveredSample = coveredAll.slice(0, COVERED_SAMPLE_MAX);
|
|
114
156
|
|
|
157
|
+
const structureEnforced = structureAll.filter((s) => s.mode === 'enforced').length;
|
|
158
|
+
const structureAdvisory = structureAll.length - structureEnforced;
|
|
159
|
+
const invariantEnforced = (loaded.arkRules.invariants ?? []).filter(
|
|
160
|
+
(inv) => inv.mode === 'enforced'
|
|
161
|
+
).length;
|
|
162
|
+
const invariantAdvisory = invariants - invariantEnforced;
|
|
163
|
+
const coveredInvariants = coverage.coverage.filter((c) => c.covered).length;
|
|
164
|
+
const uncoveredInvariants = coverage.coverage.filter((c) => !c.covered).length;
|
|
165
|
+
const hasEnforcedTeeth = structureEnforced > 0 || invariantEnforced > 0;
|
|
166
|
+
// P1M-EXTRATEETH-EMPTY-GRAPH / FG-EXTRATEETH-EMPTY-CLASSIFICATION:
|
|
167
|
+
// Do not arm structure/invariant merge teeth when the layer plane is empty or
|
|
168
|
+
// barely classified (e.g. 0% governed). Classification unknown → allow teeth
|
|
169
|
+
// (contract-only callers / unit tests without coverage).
|
|
170
|
+
const governedPercent =
|
|
171
|
+
classification && typeof classification.governedPercent === 'number'
|
|
172
|
+
? classification.governedPercent
|
|
173
|
+
: null;
|
|
174
|
+
const populatedLayerCount =
|
|
175
|
+
classification && typeof classification.populatedLayerCount === 'number'
|
|
176
|
+
? classification.populatedLayerCount
|
|
177
|
+
: classification && typeof classification.classifiedFiles === 'number'
|
|
178
|
+
? classification.classifiedFiles > 0
|
|
179
|
+
? 1
|
|
180
|
+
: 0
|
|
181
|
+
: null;
|
|
182
|
+
const classificationKnown = governedPercent != null || populatedLayerCount != null;
|
|
183
|
+
const classificationAllowsTeeth = !classificationKnown
|
|
184
|
+
? true
|
|
185
|
+
: (governedPercent ?? 0) >= EXTRA_MERGE_TEETH_GOVERNED_FLOOR &&
|
|
186
|
+
(populatedLayerCount ?? 0) >= 1;
|
|
187
|
+
const extraMergeTeeth = hasEnforcedTeeth && classificationAllowsTeeth;
|
|
188
|
+
const teethDeferredForClassification =
|
|
189
|
+
hasEnforcedTeeth && classificationKnown && !classificationAllowsTeeth;
|
|
190
|
+
// P1-M — which plane can fail merge (layers vs enforced structure vs invariants).
|
|
191
|
+
const mergePlanes = {
|
|
192
|
+
layers: {
|
|
193
|
+
role: 'inter-layer-edges',
|
|
194
|
+
alwaysOnGate: true,
|
|
195
|
+
note: 'Import/export layer graph — the default merge plane. Absent arkRules changes nothing here.',
|
|
196
|
+
},
|
|
197
|
+
structureSensors: {
|
|
198
|
+
role: 'intra-layer-heuristics',
|
|
199
|
+
total: structureRules,
|
|
200
|
+
enforced: structureEnforced,
|
|
201
|
+
advisory: structureAdvisory,
|
|
202
|
+
note: 'Structure sensors are heuristics (prefer false negatives). Only mode:enforced fails merge; noisy sensors stay advisory by default. Advisory-only packs never add merge teeth (FG-ARKRULES-ADVISORY-ONLY).',
|
|
203
|
+
},
|
|
204
|
+
invariants: {
|
|
205
|
+
role: 'catalog-plus-coverage',
|
|
206
|
+
total: invariants,
|
|
207
|
+
enforced: invariantEnforced,
|
|
208
|
+
advisory: invariantAdvisory,
|
|
209
|
+
covered: coveredInvariants,
|
|
210
|
+
uncovered: uncoveredInvariants,
|
|
211
|
+
note: 'Invariants are catalog + coverage evidence, not a business runtime. Enforced + proven-uncovered fails merge; absence of enforced rules adds no extra teeth.',
|
|
212
|
+
},
|
|
213
|
+
dualPlaneStamp:
|
|
214
|
+
'Structure = heuristics; invariants = catalog+coverage evidence (not business runtime). The two planes never merge into one architecture score. Advisory ArkRules ≠ merge teeth.',
|
|
215
|
+
extraMergeTeeth,
|
|
216
|
+
...(classificationKnown
|
|
217
|
+
? {
|
|
218
|
+
classificationGate: {
|
|
219
|
+
governedPercent: governedPercent ?? null,
|
|
220
|
+
populatedLayerCount: populatedLayerCount ?? null,
|
|
221
|
+
floorPercent: EXTRA_MERGE_TEETH_GOVERNED_FLOOR,
|
|
222
|
+
allowsTeeth: classificationAllowsTeeth,
|
|
223
|
+
},
|
|
224
|
+
}
|
|
225
|
+
: {}),
|
|
226
|
+
failMergeWhen: extraMergeTeeth
|
|
227
|
+
? 'Layer graph failures plus enforced structure/invariant findings (advisory sensors never fail merge alone).'
|
|
228
|
+
: teethDeferredForClassification
|
|
229
|
+
? `Layer graph only — enforced ArkRules structure/invariant findings are demoted under the teeth floor (need ≥${EXTRA_MERGE_TEETH_GOVERNED_FLOOR}% governed and ≥1 populated layer); they do not merge-block until classification is honest.`
|
|
230
|
+
: 'Layer graph only — no enforced ArkRules structure/invariant teeth on this tree. Advisory packs do not arm merge teeth.',
|
|
231
|
+
};
|
|
232
|
+
|
|
115
233
|
return {
|
|
116
234
|
active: true,
|
|
117
235
|
structureRules,
|
|
118
236
|
invariants,
|
|
119
|
-
coveredInvariants
|
|
120
|
-
uncoveredInvariants
|
|
237
|
+
coveredInvariants,
|
|
238
|
+
uncoveredInvariants,
|
|
121
239
|
partialCoverage: coverage.partial,
|
|
122
240
|
testFilesScanned: coverageInputs.testFiles.length,
|
|
123
241
|
layers,
|
|
@@ -127,8 +245,9 @@ export function summarizeRulesUnderContract(root, config, facts) {
|
|
|
127
245
|
uncoveredTruncated,
|
|
128
246
|
coveredSample,
|
|
129
247
|
coveredTruncated,
|
|
248
|
+
mergePlanes,
|
|
130
249
|
notAScore: true,
|
|
131
|
-
note: 'ArkRules plane (intra-layer) — counts and catalog, never a score. Green with uncovered residual must say so.',
|
|
250
|
+
note: 'ArkRules plane (intra-layer) — counts and catalog, never a score. Green with uncovered residual must say so. Structure sensors are heuristics; invariants are catalog+coverage evidence, not a business runtime.',
|
|
132
251
|
};
|
|
133
252
|
} catch (error) {
|
|
134
253
|
return {
|
|
@@ -155,6 +274,7 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
155
274
|
<h2>Rules under contract <span class="muted">(ArkRules opt-in)</span></h2>
|
|
156
275
|
<p class="dim" style="margin:.15rem 0 .55rem;font-size:.88rem">
|
|
157
276
|
Intra-layer plane (structure sensors + domain invariants as data). Separate from inter-layer import edges.
|
|
277
|
+
Absence of arkRules adds no extra merge teeth beyond the layer graph.
|
|
158
278
|
</p>
|
|
159
279
|
${note}
|
|
160
280
|
</section>`;
|
|
@@ -284,6 +404,15 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
284
404
|
: ''
|
|
285
405
|
}`;
|
|
286
406
|
|
|
407
|
+
const mergePlanes = section.mergePlanes;
|
|
408
|
+
const mergeHtml =
|
|
409
|
+
mergePlanes && typeof mergePlanes === 'object'
|
|
410
|
+
? `<p class="muted" style="margin:.35rem 0 .55rem;font-size:.86rem">
|
|
411
|
+
<b>Merge planes:</b> ${escape(mergePlanes.failMergeWhen || '')}
|
|
412
|
+
${mergePlanes.dualPlaneStamp ? `<br/>${escape(mergePlanes.dualPlaneStamp)}` : ''}
|
|
413
|
+
</p>`
|
|
414
|
+
: '';
|
|
415
|
+
|
|
287
416
|
return `
|
|
288
417
|
<section class="section card" data-advisory="rulesUnderContract">
|
|
289
418
|
<h2>Rules under contract <span class="muted">(ArkRules — not a score)</span></h2>
|
|
@@ -293,6 +422,7 @@ export function formatRulesUnderContractHtml(section, esc) {
|
|
|
293
422
|
<b>Invariants</b> = named policies + coverage evidence (symbol/test), not a business runtime
|
|
294
423
|
and not a fitness score.
|
|
295
424
|
</p>
|
|
425
|
+
${mergeHtml}
|
|
296
426
|
<div class="kpis" style="margin-bottom:.55rem">
|
|
297
427
|
<div class="kpi"><b>${Number(section.structureRules) || 0}</b><span>Structure rules</span></div>
|
|
298
428
|
<div class="kpi"><b>${Number(section.invariants) || 0}</b><span>Invariants</span></div>
|