arkgate 4.1.0 → 4.2.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 +124 -2
- package/README.md +24 -14
- package/bin/ark-check-runtime.mjs +16 -5
- package/bin/ark-mcp-runtime.mjs +766 -64
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/ark-gitignore.mjs +88 -0
- package/bin/lib/ci-and-commands.mjs +33 -8
- package/bin/lib/codex-home.mjs +90 -8
- package/bin/lib/design-smells.mjs +71 -9
- package/bin/lib/doctor-plan.mjs +47 -39
- package/bin/lib/effective-contract-load.mjs +73 -9
- package/bin/lib/enforcement-honesty.mjs +78 -22
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/gate-files.mjs +441 -9
- package/bin/lib/github-enforcement.mjs +168 -7
- package/bin/lib/hook-templates.mjs +12 -11
- package/bin/lib/host-support-matrix.mjs +91 -17
- package/bin/lib/html-report-depth.mjs +13 -2
- package/bin/lib/html-report-evolution.mjs +114 -0
- package/bin/lib/html-report.mjs +18 -97
- package/bin/lib/import-resolve.mjs +33 -11
- package/bin/lib/install-activation.mjs +87 -0
- package/bin/lib/install-migrate.mjs +66 -50
- package/bin/lib/managed-upgrade.mjs +10 -41
- package/bin/lib/mcp-adoption.mjs +15 -5
- package/bin/lib/pilot-loop.mjs +25 -8
- package/bin/lib/project-identity.mjs +103 -0
- package/bin/lib/report-snapshot-context.mjs +28 -0
- package/bin/lib/resident-hook.mjs +33 -9
- package/bin/lib/rules-inventory.mjs +100 -8
- package/bin/lib/skill-install.mjs +272 -22
- package/bin/lib/skill-write.mjs +899 -0
- package/bin/lib/start-preview.mjs +84 -1
- package/bin/lib/upgrade-command.mjs +2 -5
- package/bin/lib/write-path-detect.mjs +2 -2
- package/dist/index.cjs +13 -13
- package/dist/index.d.ts +194 -2
- package/dist/index.js +13 -13
- package/docs/README.md +6 -4
- package/docs/agent-guide.md +115 -17
- package/docs/ai-gates.md +133 -25
- package/docs/assets/ark-write-gate.svg +2 -2
- package/docs/develop.md +16 -6
- package/docs/enthusiast/how-to-agent-gates.md +6 -0
- package/docs/package-surface.md +16 -9
- package/docs/product-voice.md +22 -4
- package/docs/use.md +3 -1
- package/package.json +3 -1
- package/schemas/ark.project-identity.schema.json +116 -0
- package/server.json +2 -2
- package/templates/skills/ark-adopt.md +9 -0
- package/templates/skills/ark-architect.md +12 -2
- package/templates/skills/ark-autopilot.md +9 -0
- package/templates/skills/ark-contract.md +11 -1
- package/templates/skills/ark-coverage.md +9 -0
- package/templates/skills/ark-explain.md +13 -1
- package/templates/skills/ark-explore.md +9 -0
- package/templates/skills/ark-fix.md +10 -1
- package/templates/skills/ark-loop.md +11 -2
- package/templates/skills/ark-place.md +17 -6
- package/templates/skills/ark-runtime.md +8 -0
- package/templates/skills/ark-think.md +14 -2
- package/templates/skills/ark-upgrade.md +9 -0
package/bin/lib/mcp-adoption.mjs
CHANGED
|
@@ -16,7 +16,7 @@ import { detectWritePathCapabilities } from './write-path-detect.mjs';
|
|
|
16
16
|
import { detectActiveAgentHost, skillTemplateNames } from './skill-install.mjs';
|
|
17
17
|
import { detectDeployPathQuality } from './deploy-path.mjs';
|
|
18
18
|
import { collectWeakestLinkGaps } from './weakest-link.mjs';
|
|
19
|
-
import { withCiProviderEvidence } from './enforcement-state.mjs';
|
|
19
|
+
import { codexRuntimeActivation, withCiProviderEvidence } from './enforcement-state.mjs';
|
|
20
20
|
|
|
21
21
|
export { detectDeployPathQuality };
|
|
22
22
|
|
|
@@ -120,7 +120,7 @@ export function brokenMcpGateFiles(root) {
|
|
|
120
120
|
|
|
121
121
|
/**
|
|
122
122
|
* Adoption completeness (separate from 0–100 fitness). Pure-ish: filesystem + config.
|
|
123
|
-
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object }}
|
|
123
|
+
* @returns {{ gaps: object[], hosts: object[], mcp: object, codexHome: object|null, coreOptional: object[], originReport: object, baseline: object, layerBalance: object|null, deployPath: object|null, writePath: object, runtimeActivation: object }}
|
|
124
124
|
*/
|
|
125
125
|
export function collectAdoptionGaps(root, config, coverage) {
|
|
126
126
|
const gaps = [];
|
|
@@ -259,7 +259,11 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
259
259
|
return false;
|
|
260
260
|
}
|
|
261
261
|
})();
|
|
262
|
-
|
|
262
|
+
const runtimeActivation = codexRuntimeActivation({
|
|
263
|
+
configuredOnDisk: codexProjectMcp,
|
|
264
|
+
restartRequired: codexProjectMcp,
|
|
265
|
+
});
|
|
266
|
+
if (adopted && !isProducer) {
|
|
263
267
|
const codexFile = codexConfigPath();
|
|
264
268
|
let toml = '';
|
|
265
269
|
try {
|
|
@@ -278,6 +282,8 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
278
282
|
needsRewrite: assessed.needsRewrite,
|
|
279
283
|
multiProject: assessed.multiProject,
|
|
280
284
|
scopedTable: assessed.scopedTable,
|
|
285
|
+
projectConfiguredOnDisk: codexProjectMcp,
|
|
286
|
+
runtimeIdentityVerified: false,
|
|
281
287
|
};
|
|
282
288
|
if (assessed.gap) {
|
|
283
289
|
// Temp/upgrade MCP roots stay urgent (fail-closed). Non-temp Codex-home debt
|
|
@@ -288,9 +294,12 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
288
294
|
const deferred =
|
|
289
295
|
!tempUrgent && activeHost != null && activeHost !== 'codex';
|
|
290
296
|
const severity = deferred ? 'info' : assessed.gap.severity;
|
|
297
|
+
const localRisk = codexProjectMcp
|
|
298
|
+
? 'Project config exists on disk, but the active runtime identity is unverified. '
|
|
299
|
+
: '';
|
|
291
300
|
const message = deferred
|
|
292
|
-
? `Deferred (fix when using Codex): ${assessed.gap.message}`
|
|
293
|
-
: assessed.gap.message
|
|
301
|
+
? `Deferred (fix when using Codex): ${localRisk}${assessed.gap.message}`
|
|
302
|
+
: `${localRisk}${assessed.gap.message}`;
|
|
294
303
|
gaps.push({
|
|
295
304
|
id: assessed.gap.id,
|
|
296
305
|
severity,
|
|
@@ -526,6 +535,7 @@ export function collectAdoptionGaps(root, config, coverage) {
|
|
|
526
535
|
deployPath,
|
|
527
536
|
contractFalseGreen,
|
|
528
537
|
writePath,
|
|
538
|
+
runtimeActivation,
|
|
529
539
|
enforcement: {
|
|
530
540
|
ci: weakest.ci,
|
|
531
541
|
preCommit: weakest.preCommit,
|
package/bin/lib/pilot-loop.mjs
CHANGED
|
@@ -5,7 +5,10 @@
|
|
|
5
5
|
* extraction-card payload, and compares residual after re-doctor on pilot paths.
|
|
6
6
|
* Judgment only — never mechanical-safe; never multi-pilot batch apply.
|
|
7
7
|
*/
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
buildPatternBetsFromSmells,
|
|
10
|
+
isNonProductionPilotPath,
|
|
11
|
+
} from './design-smells.mjs';
|
|
9
12
|
|
|
10
13
|
/** Stable product id for JSON / tests. */
|
|
11
14
|
export const PILOT_LOOP_ID = 'one-pilot-redoctor';
|
|
@@ -44,14 +47,28 @@ export function fileEvidencePaths(evidence = []) {
|
|
|
44
47
|
);
|
|
45
48
|
}
|
|
46
49
|
|
|
50
|
+
function pilotFilesForBet(bet, preferredFiles) {
|
|
51
|
+
const rawFiles = preferredFiles?.length
|
|
52
|
+
? preferredFiles
|
|
53
|
+
: fileEvidencePaths(bet?.evidence);
|
|
54
|
+
if (bet?.smellId !== 'god-module') return rawFiles;
|
|
55
|
+
const files = rawFiles.filter((file) => !isNonProductionPilotPath(file));
|
|
56
|
+
const excludedPilot =
|
|
57
|
+
rawFiles.length === 0 &&
|
|
58
|
+
typeof bet.pilot === 'string' &&
|
|
59
|
+
isNonProductionPilotPath(bet.pilot);
|
|
60
|
+
return (rawFiles.length > 0 && files.length === 0) || excludedPilot
|
|
61
|
+
? null
|
|
62
|
+
: files;
|
|
63
|
+
}
|
|
64
|
+
|
|
47
65
|
/**
|
|
48
66
|
* Score a pattern bet for "do this pilot first".
|
|
49
67
|
* Prefers concrete src/ files and higher-impact smell ids.
|
|
50
68
|
* @param {object} bet
|
|
51
69
|
* @param {number} index
|
|
52
70
|
*/
|
|
53
|
-
function scoreBet(bet, index) {
|
|
54
|
-
const files = fileEvidencePaths(bet?.evidence);
|
|
71
|
+
function scoreBet(bet, index, files = fileEvidencePaths(bet?.evidence)) {
|
|
55
72
|
const smellPri = SMELL_PRIORITY[bet?.smellId] ?? 50;
|
|
56
73
|
// Higher score wins; concrete files dominate; then smell priority; stable by index.
|
|
57
74
|
return files.length * 100 - smellPri * 10 - index;
|
|
@@ -64,9 +81,8 @@ function scoreBet(bet, index) {
|
|
|
64
81
|
*/
|
|
65
82
|
export function extractionCardFromBet(bet, preferredFiles) {
|
|
66
83
|
if (!bet || typeof bet !== 'object') return null;
|
|
67
|
-
const files = preferredFiles
|
|
68
|
-
|
|
69
|
-
: fileEvidencePaths(bet.evidence);
|
|
84
|
+
const files = pilotFilesForBet(bet, preferredFiles);
|
|
85
|
+
if (files === null) return null;
|
|
70
86
|
const evidence = files.length ? files : (bet.evidence || []).slice(0, 8);
|
|
71
87
|
const pilotTarget =
|
|
72
88
|
files[0] ||
|
|
@@ -123,8 +139,9 @@ export function selectNextPilot(patternBets, options = {}) {
|
|
|
123
139
|
if (!bet || bet.neverMechanicalSafe === false) continue;
|
|
124
140
|
// Skip anything that claims mechanical-safe (honesty).
|
|
125
141
|
if (bet.class === 'mechanical-safe') continue;
|
|
126
|
-
const files =
|
|
127
|
-
|
|
142
|
+
const files = pilotFilesForBet(bet);
|
|
143
|
+
if (files === null) continue;
|
|
144
|
+
const sc = scoreBet(bet, i, files);
|
|
128
145
|
if (sc > bestScore) {
|
|
129
146
|
bestScore = sc;
|
|
130
147
|
best = { bet, files };
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* GENERATED FILE — do not edit by hand.
|
|
3
|
+
*
|
|
4
|
+
* Canonical algorithm: src/domain/projectIdentity.ts
|
|
5
|
+
* Regenerate: node scripts/generate-cli-pure.mjs
|
|
6
|
+
* Drift check: node scripts/generate-cli-pure.mjs --check
|
|
7
|
+
*
|
|
8
|
+
* Pure CLI helper (bin/lib/project-identity.mjs). Zero Node I/O.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
export const ARK_PROJECT_IDENTITY_SCHEMA_VERSION = '1.0';
|
|
12
|
+
export const ARK_PROJECT_IDENTITY_SCHEMA_URL = 'https://unpkg.com/arkgate@4/schemas/ark.project-identity.schema.json';
|
|
13
|
+
const sha256Pattern = '^sha256:[a-f0-9]{64}$';
|
|
14
|
+
export const PROJECT_EXPECTATION_SCHEMA = {
|
|
15
|
+
type: 'object',
|
|
16
|
+
additionalProperties: false,
|
|
17
|
+
properties: {
|
|
18
|
+
expectedRoot: {
|
|
19
|
+
type: 'string',
|
|
20
|
+
minLength: 1,
|
|
21
|
+
description: 'Absolute expected workspace/project directory. The initial authoritative handshake ' +
|
|
22
|
+
'requires the exact project root; descendant calls also require expectedProjectId.',
|
|
23
|
+
},
|
|
24
|
+
expectedProjectId: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
pattern: sha256Pattern,
|
|
27
|
+
description: 'Project id previously returned by ark_identity or ark_manifest.',
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
};
|
|
31
|
+
export const PROJECT_BINDING_SCHEMA = {
|
|
32
|
+
type: 'object',
|
|
33
|
+
additionalProperties: false,
|
|
34
|
+
required: ['status', 'authoritative'],
|
|
35
|
+
properties: {
|
|
36
|
+
status: { enum: ['matched', 'unverified', 'mismatch'] },
|
|
37
|
+
authoritative: { type: 'boolean' },
|
|
38
|
+
expectedRoot: { type: 'string', minLength: 1 },
|
|
39
|
+
expectedProjectId: { type: 'string', pattern: sha256Pattern },
|
|
40
|
+
code: {
|
|
41
|
+
enum: [
|
|
42
|
+
'PROJECT_ROOT_MISMATCH',
|
|
43
|
+
'PROJECT_ID_MISMATCH',
|
|
44
|
+
'INVALID_PROJECT_EXPECTATION',
|
|
45
|
+
],
|
|
46
|
+
},
|
|
47
|
+
message: { type: 'string', minLength: 1 },
|
|
48
|
+
},
|
|
49
|
+
};
|
|
50
|
+
export const ARK_PROJECT_IDENTITY_SCHEMA = {
|
|
51
|
+
$schema: 'https://json-schema.org/draft/2020-12/schema',
|
|
52
|
+
$id: ARK_PROJECT_IDENTITY_SCHEMA_URL,
|
|
53
|
+
title: 'ArkGate MCP project identity',
|
|
54
|
+
description: 'Stable project binding plus separate runtime and architecture-contract evidence.',
|
|
55
|
+
type: 'object',
|
|
56
|
+
additionalProperties: false,
|
|
57
|
+
required: [
|
|
58
|
+
'schemaVersion',
|
|
59
|
+
'projectId',
|
|
60
|
+
'resolvedRoot',
|
|
61
|
+
'resolvedConfigPath',
|
|
62
|
+
'arkgateVersion',
|
|
63
|
+
'contractHash',
|
|
64
|
+
'contractSource',
|
|
65
|
+
'runtimeId',
|
|
66
|
+
'processStartedAt',
|
|
67
|
+
],
|
|
68
|
+
properties: {
|
|
69
|
+
schemaVersion: { const: ARK_PROJECT_IDENTITY_SCHEMA_VERSION },
|
|
70
|
+
projectId: { type: 'string', pattern: sha256Pattern },
|
|
71
|
+
resolvedRoot: { type: 'string', minLength: 1 },
|
|
72
|
+
resolvedConfigPath: { type: 'string', minLength: 1 },
|
|
73
|
+
arkgateVersion: { type: 'string', minLength: 1 },
|
|
74
|
+
contractHash: { type: 'string', pattern: sha256Pattern },
|
|
75
|
+
contractSource: { enum: ['project', 'default-profile', 'manifest'] },
|
|
76
|
+
runtimeId: { type: 'string', minLength: 1 },
|
|
77
|
+
processStartedAt: { type: 'string', format: 'date-time' },
|
|
78
|
+
},
|
|
79
|
+
$defs: {
|
|
80
|
+
expectation: PROJECT_EXPECTATION_SCHEMA,
|
|
81
|
+
binding: PROJECT_BINDING_SCHEMA,
|
|
82
|
+
},
|
|
83
|
+
};
|
|
84
|
+
/**
|
|
85
|
+
* Stable identity: contract edits and MCP restarts must not change which project
|
|
86
|
+
* this is. Callers must pass canonical real paths and a SHA-256 hex function.
|
|
87
|
+
*/
|
|
88
|
+
export function createProjectId(resolvedRoot, resolvedConfigPath, sha256Hex) {
|
|
89
|
+
if (!resolvedRoot || !resolvedConfigPath) {
|
|
90
|
+
throw new Error('Project identity requires resolvedRoot and resolvedConfigPath.');
|
|
91
|
+
}
|
|
92
|
+
const digest = sha256Hex(JSON.stringify({ resolvedRoot, resolvedConfigPath })).toLowerCase();
|
|
93
|
+
if (!/^[a-f0-9]{64}$/.test(digest)) {
|
|
94
|
+
throw new Error('Project identity hash adapter must return 64 hexadecimal SHA-256 characters.');
|
|
95
|
+
}
|
|
96
|
+
return `sha256:${digest}`;
|
|
97
|
+
}
|
|
98
|
+
export function createProjectIdentity(input) {
|
|
99
|
+
return {
|
|
100
|
+
schemaVersion: ARK_PROJECT_IDENTITY_SCHEMA_VERSION,
|
|
101
|
+
...input,
|
|
102
|
+
};
|
|
103
|
+
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
import { execFileSync } from 'node:child_process';
|
|
2
|
+
|
|
3
|
+
/** Best-effort, shell-free Git/worktree evidence for report snapshots. */
|
|
4
|
+
export function captureGitSnapshot(root) {
|
|
5
|
+
const run = (args) =>
|
|
6
|
+
execFileSync('git', ['-C', root, ...args], {
|
|
7
|
+
encoding: 'utf8',
|
|
8
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
9
|
+
}).trim();
|
|
10
|
+
try {
|
|
11
|
+
const headSha = run(['rev-parse', '--verify', 'HEAD']);
|
|
12
|
+
let branch = null;
|
|
13
|
+
try {
|
|
14
|
+
branch = run(['symbolic-ref', '--quiet', '--short', 'HEAD']) || null;
|
|
15
|
+
} catch {
|
|
16
|
+
// Detached HEAD is valid release/report evidence.
|
|
17
|
+
}
|
|
18
|
+
let dirty = null;
|
|
19
|
+
try {
|
|
20
|
+
dirty = run(['status', '--porcelain=v1', '--untracked-files=normal']).length > 0;
|
|
21
|
+
} catch {
|
|
22
|
+
// Keep the commit identity even when worktree state is unavailable.
|
|
23
|
+
}
|
|
24
|
+
return { available: true, headSha, branch, dirty };
|
|
25
|
+
} catch {
|
|
26
|
+
return { available: false, headSha: null, branch: null, dirty: null };
|
|
27
|
+
}
|
|
28
|
+
}
|
|
@@ -67,20 +67,44 @@ function residentRuntimeIdentity(launcher) {
|
|
|
67
67
|
return hash.digest('hex');
|
|
68
68
|
}
|
|
69
69
|
|
|
70
|
+
function realpathOrResolve(value) {
|
|
71
|
+
const resolved = path.resolve(value);
|
|
72
|
+
try { return fs.realpathSync(resolved); } catch { return resolved; }
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function residentInvocationIdentity({ root, config, manifest, tsconfig }) {
|
|
76
|
+
const lexicalRoot = path.resolve(root);
|
|
77
|
+
const realRoot = realpathOrResolve(lexicalRoot);
|
|
78
|
+
const projectPath = (value) => {
|
|
79
|
+
if (!value) return null;
|
|
80
|
+
const absolute = path.isAbsolute(value)
|
|
81
|
+
? path.resolve(value)
|
|
82
|
+
: path.resolve(lexicalRoot, value);
|
|
83
|
+
const relative = path.relative(lexicalRoot, absolute);
|
|
84
|
+
const contained =
|
|
85
|
+
relative === '' ||
|
|
86
|
+
(!path.isAbsolute(relative) && relative !== '..' && !relative.startsWith(`..${path.sep}`));
|
|
87
|
+
return realpathOrResolve(contained ? path.resolve(realRoot, relative) : absolute);
|
|
88
|
+
};
|
|
89
|
+
return {
|
|
90
|
+
root: realRoot,
|
|
91
|
+
config: projectPath(config),
|
|
92
|
+
manifest: projectPath(manifest),
|
|
93
|
+
tsconfig: projectPath(tsconfig),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
|
|
70
97
|
export function residentHookEndpoint({ root, config, manifest, tsconfig, launcher }) {
|
|
71
|
-
|
|
72
|
-
|
|
98
|
+
const invocation = residentInvocationIdentity({ root, config, manifest, tsconfig });
|
|
99
|
+
const realLauncher = realpathOrResolve(launcher);
|
|
73
100
|
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
74
101
|
const directory = path.join(os.tmpdir(), `arkgate-${uid}`);
|
|
75
102
|
const digest = createHash('sha256').update(JSON.stringify({
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
tsconfig: tsconfig ? path.resolve(root, tsconfig) : null,
|
|
80
|
-
launcher: path.resolve(launcher),
|
|
81
|
-
executable: process.execPath,
|
|
103
|
+
...invocation,
|
|
104
|
+
launcher: realLauncher,
|
|
105
|
+
executable: realpathOrResolve(process.execPath),
|
|
82
106
|
protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
|
|
83
|
-
runtimeIdentity: residentRuntimeIdentity(
|
|
107
|
+
runtimeIdentity: residentRuntimeIdentity(realLauncher),
|
|
84
108
|
})).digest('hex').slice(0, 24);
|
|
85
109
|
return {
|
|
86
110
|
directory,
|
|
@@ -11,11 +11,63 @@
|
|
|
11
11
|
function lineOf(content, index) {
|
|
12
12
|
return content.slice(0, index).split('\n').length;
|
|
13
13
|
}
|
|
14
|
+
function normalizeInventoryPath(file) {
|
|
15
|
+
return file.replace(/\\/g, '/').replace(/^\.\//, '');
|
|
16
|
+
}
|
|
17
|
+
function ownsIntent(intentPrefixes, intentRoots) {
|
|
18
|
+
return intentPrefixes.some((prefix) => {
|
|
19
|
+
const normalized = prefix.trim().replace(/\.+$/, '');
|
|
20
|
+
return intentRoots.some((root) => normalized === root || normalized.startsWith(`${root}.`));
|
|
21
|
+
});
|
|
22
|
+
}
|
|
23
|
+
function isDomainLayer(layer, intentPrefixes = []) {
|
|
24
|
+
return (/domain|entity|aggregate|model/i.test(layer) ||
|
|
25
|
+
ownsIntent(intentPrefixes, ['Domain']));
|
|
26
|
+
}
|
|
27
|
+
function isControllerEligibleLayer(layer, intentPrefixes = []) {
|
|
28
|
+
return (/application|orchestration|presentation|adapter|framework|interface|delivery|transport|inbound|controller/i.test(layer) ||
|
|
29
|
+
ownsIntent(intentPrefixes, [
|
|
30
|
+
'Application',
|
|
31
|
+
'Orchestration',
|
|
32
|
+
'Presentation',
|
|
33
|
+
'Adapter',
|
|
34
|
+
'Interface',
|
|
35
|
+
'Delivery',
|
|
36
|
+
'Transport',
|
|
37
|
+
]));
|
|
38
|
+
}
|
|
39
|
+
function isNonPilotSurface(file) {
|
|
40
|
+
return (/(?:^|\/)(?:tests?|__tests__|fixtures?|testdata|mocks?|stubs?|examples?|samples?|seeds?|seeders?|migrations?|excluded|exclusions?)(?:\/|$)/i.test(file) ||
|
|
41
|
+
/(?:^|\/)[^/]*\.(?:test|spec|fixture|mock|stub|seed|seeder)\.[^/]+$/i.test(file) ||
|
|
42
|
+
/(?:^|\/)(?:seed|seeder|fixture|mock|stub)\.[^/]+$/i.test(file));
|
|
43
|
+
}
|
|
14
44
|
export function buildRulesInventory(input) {
|
|
15
45
|
const candidates = [];
|
|
16
46
|
let seq = 0;
|
|
47
|
+
const fileLayers = new Map(Object.entries(input.fileLayers ?? {}).map(([file, layer]) => [
|
|
48
|
+
normalizeInventoryPath(file),
|
|
49
|
+
layer,
|
|
50
|
+
]));
|
|
51
|
+
const layerIntentPrefixes = new Map((input.layerContexts ?? []).map((layer) => [
|
|
52
|
+
layer.name,
|
|
53
|
+
layer.intentPrefixes ?? [],
|
|
54
|
+
]));
|
|
55
|
+
const domainLayer = (input.layerContexts ?? []).find((layer) => isDomainLayer(layer.name, layer.intentPrefixes))?.name ?? 'DomainModel';
|
|
17
56
|
for (const [file, content] of Object.entries(input.fileContents).sort(([a], [b]) => a.localeCompare(b))) {
|
|
18
|
-
const posix = file
|
|
57
|
+
const posix = normalizeInventoryPath(file);
|
|
58
|
+
// Test data, fixtures, seeds, migrations, and explicit exclusions may retain
|
|
59
|
+
// representative smells, but are not production extraction pilots.
|
|
60
|
+
if (isNonPilotSurface(posix))
|
|
61
|
+
continue;
|
|
62
|
+
// Generated mirrors are evidence for their canonical source, not a second
|
|
63
|
+
// extraction candidate.
|
|
64
|
+
if (/GENERATED FILE\s+[—-]\s+do not edit by hand/i.test(content.slice(0, 320)))
|
|
65
|
+
continue;
|
|
66
|
+
const hasGovernedLayer = fileLayers.has(posix);
|
|
67
|
+
const governedLayer = fileLayers.get(posix);
|
|
68
|
+
const governedIntentPrefixes = governedLayer
|
|
69
|
+
? layerIntentPrefixes.get(governedLayer) ?? []
|
|
70
|
+
: [];
|
|
19
71
|
// P2-N — clear UI bags only (components/theme/styles). Do NOT blanket-skip all
|
|
20
72
|
// app/pages (server actions / route handlers live there and stay inventoriable).
|
|
21
73
|
const isUiChrome = /(?:^|\/)(?:components|ui|layouts|styles|hooks|theme|tokens|i18n|locales?)(?:\/|$)/i.test(posix) ||
|
|
@@ -23,14 +75,22 @@ export function buildRulesInventory(input) {
|
|
|
23
75
|
/(?:page|layout|loading|error|template|default)\.(?:tsx|jsx)$/i.test(posix);
|
|
24
76
|
const isApiRoute = /(?:^|\/)(?:app|pages)(?:\/[^/]+)*\/api(?:\/|$)/i.test(posix);
|
|
25
77
|
const isServerAction = /(?:^|\/)actions?(?:\/|\.|$)/i.test(posix) || /['"]use server['"]/.test(content);
|
|
26
|
-
const
|
|
78
|
+
const controllerShape = /controller|handler|resolver/i.test(file) ||
|
|
27
79
|
isApiRoute ||
|
|
28
80
|
isServerAction ||
|
|
29
81
|
(/route\.(?:ts|js|tsx|jsx)$/i.test(posix) && !isUiChrome) ||
|
|
30
82
|
/@(Controller|Get|Post|Put|Delete|Patch)\b/.test(content) ||
|
|
31
83
|
/\bexport\s+(?:async\s+)?function\s+(?:GET|POST|PUT|DELETE|PATCH)\b/.test(content) ||
|
|
32
84
|
/\bexport\s+const\s+(?:GET|POST|PUT|DELETE|PATCH)\s*=/.test(content);
|
|
33
|
-
const
|
|
85
|
+
const isController = hasGovernedLayer
|
|
86
|
+
? Boolean(governedLayer &&
|
|
87
|
+
isControllerEligibleLayer(governedLayer, governedIntentPrefixes) &&
|
|
88
|
+
controllerShape)
|
|
89
|
+
: controllerShape;
|
|
90
|
+
const isDomain = hasGovernedLayer
|
|
91
|
+
? Boolean(governedLayer && isDomainLayer(governedLayer, governedIntentPrefixes))
|
|
92
|
+
: /domain|entity|aggregate|model/i.test(file);
|
|
93
|
+
const magicConstantEligible = !hasGovernedLayer || isDomain || isController;
|
|
34
94
|
// validation-in-controller (API/Nest/server-action handlers — not pure UI chrome)
|
|
35
95
|
if (isController && !isUiChrome) {
|
|
36
96
|
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;
|
|
@@ -44,8 +104,9 @@ export function buildRulesInventory(input) {
|
|
|
44
104
|
line: lineOf(content, m.index),
|
|
45
105
|
message: 'Business validation appears in a controller/handler — extract an invariant or Domain rule.',
|
|
46
106
|
confidence: 'direct-evidence',
|
|
107
|
+
governedLayer,
|
|
47
108
|
suggestedArkRule: {
|
|
48
|
-
layer:
|
|
109
|
+
layer: domainLayer,
|
|
49
110
|
invariantId: `INV-EXTRACT-${seq}`,
|
|
50
111
|
sensor: 'invariant-coverage',
|
|
51
112
|
},
|
|
@@ -67,9 +128,21 @@ export function buildRulesInventory(input) {
|
|
|
67
128
|
// Narrow DEFAULT_/REQUEST_/STORAGE_ — only known infra tokens, not all DEFAULT_* seeds
|
|
68
129
|
/^(?: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
130
|
// 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)
|
|
131
|
+
/^(?:FAVORITES_STORAGE|LISTINGS_CACHE|DOCS_PATH|METRICS_INTERVAL)/i.test(name) ||
|
|
132
|
+
// Development identities and PostgreSQL type OIDs are technical wiring, not
|
|
133
|
+
// business literals. Keep this narrow so Domain limits/status seeds still surface.
|
|
134
|
+
/^(?:DEV|DEMO|SEED|FIXTURE)_[A-Z0-9_]+$/i.test(name) ||
|
|
135
|
+
/^(?:PG|POSTGRES|OID)_[A-Z0-9_]+$/i.test(name) ||
|
|
136
|
+
/_(?:OID|OIDS)$/i.test(name) ||
|
|
137
|
+
/^(?:INT2|INT4|INT8|FLOAT4|FLOAT8|NUMERIC|DATE|TIME|TIMESTAMP|TIMESTAMPTZ|JSON|JSONB|UUID)OID$/i.test(name) ||
|
|
138
|
+
/(?:^|_)(?:SCHEMA|PROTOCOL|RESOLVER|FORMAT)_(?:URL|URI|VERSION|ID|IDENTITY)$/i.test(name);
|
|
71
139
|
while ((magic = magicRe.exec(content)) !== null) {
|
|
72
140
|
const name = magic[2];
|
|
141
|
+
// With governed layer evidence, generic Tooling/Kernel constants are not
|
|
142
|
+
// business-rule candidates. Controller-shaped boundaries stay eligible
|
|
143
|
+
// because business policy can leak into them.
|
|
144
|
+
if (!magicConstantEligible)
|
|
145
|
+
continue;
|
|
73
146
|
if (isInfraMagicName(name))
|
|
74
147
|
continue;
|
|
75
148
|
// P2-N: skip remaining ALL_CAPS noise only on clear UI chrome (not all of app/).
|
|
@@ -88,7 +161,8 @@ export function buildRulesInventory(input) {
|
|
|
88
161
|
line: lineOf(content, magic.index),
|
|
89
162
|
message: `Magic business constant ${name} may belong in a Domain policy or invariant catalog.`,
|
|
90
163
|
confidence: 'heuristic',
|
|
91
|
-
|
|
164
|
+
governedLayer,
|
|
165
|
+
suggestedArkRule: { layer: domainLayer, invariantId: `INV-${name}` },
|
|
92
166
|
neverMechanicalSafe: true,
|
|
93
167
|
});
|
|
94
168
|
}
|
|
@@ -109,8 +183,9 @@ export function buildRulesInventory(input) {
|
|
|
109
183
|
line: lineOf(content, c.index),
|
|
110
184
|
message: `Class ${c[1]} looks anemic (data-heavy, few behaviors).`,
|
|
111
185
|
confidence: 'heuristic',
|
|
186
|
+
governedLayer,
|
|
112
187
|
suggestedArkRule: {
|
|
113
|
-
layer:
|
|
188
|
+
layer: domainLayer,
|
|
114
189
|
structureId: 'no-anemic-model',
|
|
115
190
|
sensor: 'no-anemic-model',
|
|
116
191
|
},
|
|
@@ -131,6 +206,16 @@ export function buildRulesInventory(input) {
|
|
|
131
206
|
const mutRe = /this\.\w+\s*=/g;
|
|
132
207
|
let mut;
|
|
133
208
|
while ((mut = mutRe.exec(content)) !== null) {
|
|
209
|
+
const classStart = content.lastIndexOf('class ', mut.index);
|
|
210
|
+
const classHeaderEnd = classStart >= 0 ? content.indexOf('{', classStart) : -1;
|
|
211
|
+
const classHeader = classStart >= 0 && classHeaderEnd >= classStart && classHeaderEnd < mut.index
|
|
212
|
+
? content.slice(classStart, classHeaderEnd)
|
|
213
|
+
: '';
|
|
214
|
+
// Error metadata assignment is constructor wiring, not aggregate
|
|
215
|
+
// mutation. Keep the exclusion local to the containing class header.
|
|
216
|
+
if (/\bextends\s+(?:Error|[A-Za-z_$][A-Za-z0-9_$]*Error)\b/.test(classHeader)) {
|
|
217
|
+
continue;
|
|
218
|
+
}
|
|
134
219
|
const window = content.slice(Math.max(0, mut.index - 200), mut.index + 200);
|
|
135
220
|
if (!/\b(ensureInvariants|assertInvariants|validate|publish|emit)\b/.test(window)) {
|
|
136
221
|
seq += 1;
|
|
@@ -141,8 +226,9 @@ export function buildRulesInventory(input) {
|
|
|
141
226
|
line: lineOf(content, mut.index),
|
|
142
227
|
message: 'Domain field mutation without nearby guard/publish call.',
|
|
143
228
|
confidence: 'heuristic',
|
|
229
|
+
governedLayer,
|
|
144
230
|
suggestedArkRule: {
|
|
145
|
-
layer:
|
|
231
|
+
layer: domainLayer,
|
|
146
232
|
structureId: 'events-on-mutation',
|
|
147
233
|
sensor: 'domain-event-on-mutation',
|
|
148
234
|
},
|
|
@@ -155,6 +241,12 @@ export function buildRulesInventory(input) {
|
|
|
155
241
|
}
|
|
156
242
|
}
|
|
157
243
|
const contracted = new Set(input.contractedRuleIds ?? []);
|
|
244
|
+
candidates.sort((a, b) => Number(b.confidence === 'direct-evidence') -
|
|
245
|
+
Number(a.confidence === 'direct-evidence') ||
|
|
246
|
+
a.file.localeCompare(b.file) ||
|
|
247
|
+
a.line - b.line ||
|
|
248
|
+
a.kind.localeCompare(b.kind) ||
|
|
249
|
+
a.id.localeCompare(b.id));
|
|
158
250
|
const underContract = candidates.filter((c) => (c.suggestedArkRule?.invariantId && contracted.has(c.suggestedArkRule.invariantId)) ||
|
|
159
251
|
(c.suggestedArkRule?.structureId && contracted.has(c.suggestedArkRule.structureId))).length;
|
|
160
252
|
return {
|