arkgate 4.1.1 → 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 +81 -3
- package/README.md +15 -4
- 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/ci-and-commands.mjs +16 -7
- package/bin/lib/codex-home.mjs +90 -8
- package/bin/lib/design-smells.mjs +71 -9
- package/bin/lib/doctor-plan.mjs +36 -36
- package/bin/lib/effective-contract-load.mjs +73 -9
- package/bin/lib/enforcement-state.mjs +1 -1
- package/bin/lib/gate-files.mjs +441 -9
- package/bin/lib/github-enforcement.mjs +16 -3
- package/bin/lib/hook-templates.mjs +12 -11
- package/bin/lib/html-report-evolution.mjs +114 -0
- package/bin/lib/html-report.mjs +11 -89
- 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/dist/index.cjs +13 -13
- package/dist/index.d.ts +194 -2
- package/dist/index.js +13 -13
- package/docs/README.md +5 -3
- package/docs/agent-guide.md +110 -14
- package/docs/ai-gates.md +103 -18
- package/docs/assets/ark-write-gate.svg +2 -2
- package/docs/enthusiast/how-to-agent-gates.md +6 -0
- package/docs/package-surface.md +14 -9
- package/docs/product-voice.md +13 -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
|
@@ -371,9 +371,9 @@ function summaryFor(assets, manifestChanged) {
|
|
|
371
371
|
const states = {};
|
|
372
372
|
for (const asset of assets) states[asset.state] = (states[asset.state] ?? 0) + 1;
|
|
373
373
|
const applying = assets.filter((asset) => asset.willApply);
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
const metadataRefresh =
|
|
374
|
+
const wouldWrite = applying.length;
|
|
375
|
+
// Public-summary compatibility: stamp-only writes are no longer scheduled.
|
|
376
|
+
const metadataRefresh = 0;
|
|
377
377
|
const customizedPreserved = assets.filter((asset) => asset.state === 'customized').length;
|
|
378
378
|
const fileChanges = applying.length;
|
|
379
379
|
return {
|
|
@@ -385,7 +385,6 @@ function summaryFor(assets, manifestChanged) {
|
|
|
385
385
|
customizedPreserved,
|
|
386
386
|
fileChanges,
|
|
387
387
|
manifestChanged,
|
|
388
|
-
// Full apply count still includes optional stamp refresh + manifest bookkeeping.
|
|
389
388
|
changed: fileChanges + (manifestChanged ? 1 : 0),
|
|
390
389
|
blocked: assets.filter((asset) => asset.blocked).length,
|
|
391
390
|
};
|
|
@@ -447,12 +446,7 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
447
446
|
kind: catalogAsset.kind,
|
|
448
447
|
});
|
|
449
448
|
const accepted = options.acceptConflicts === true;
|
|
450
|
-
const refreshMetadata =
|
|
451
|
-
classified.state === 'current' &&
|
|
452
|
-
catalogAsset.kind === 'skill' &&
|
|
453
|
-
currentScoped !== desiredScoped;
|
|
454
449
|
const canApply =
|
|
455
|
-
refreshMetadata ||
|
|
456
450
|
classified.state === 'stale' ||
|
|
457
451
|
(classified.state === 'missing' && (!recorded || accepted)) ||
|
|
458
452
|
(classified.state === 'conflicted' && accepted);
|
|
@@ -465,7 +459,7 @@ export function planManagedUpgrade(root, options = {}) {
|
|
|
465
459
|
scope: catalogAsset.scope,
|
|
466
460
|
...classified,
|
|
467
461
|
...(unparsedScope ? { reason: 'unparsed managed TOML scope preserved' } : {}),
|
|
468
|
-
action:
|
|
462
|
+
action: canApply ? (currentScoped == null ? 'create' : 'update') : 'none',
|
|
469
463
|
willApply: canApply,
|
|
470
464
|
blocked,
|
|
471
465
|
beforeHash: hash(currentScoped == null ? null : Buffer.from(currentScoped)),
|
|
@@ -615,9 +609,7 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
|
|
|
615
609
|
if (resolvedRoot !== plan.root) throw new Error('managed upgrade plan root mismatch');
|
|
616
610
|
if (plan.summary.blocked > 0) return publicPlan(plan, { blocked: true });
|
|
617
611
|
const wouldWrite = plan.summary.wouldWrite ?? 0;
|
|
618
|
-
const metadataRefresh = plan.summary.metadataRefresh ?? 0;
|
|
619
612
|
// Content already matches: unbound --apply is a no-op (exit success), not a digest error.
|
|
620
|
-
// Optional stamp-only refresh still requires the preview's exact --plan-digest.
|
|
621
613
|
if (!expectedPlanDigest || expectedPlanDigest !== plan.planDigest) {
|
|
622
614
|
if (wouldWrite === 0 && (plan.summary.blocked ?? 0) === 0 && !expectedPlanDigest) {
|
|
623
615
|
return publicPlan(plan, {
|
|
@@ -625,7 +617,6 @@ export function applyManagedUpgrade(root, plan, expectedPlanDigest) {
|
|
|
625
617
|
applied: false,
|
|
626
618
|
blocked: false,
|
|
627
619
|
nothingToApply: true,
|
|
628
|
-
optionalStampRefresh: metadataRefresh,
|
|
629
620
|
});
|
|
630
621
|
}
|
|
631
622
|
throw new Error('managed upgrade plan digest mismatch; run a new preview and use its exact nextCommand');
|
|
@@ -741,31 +732,18 @@ export function renderManagedUpgrade(plan, options = {}) {
|
|
|
741
732
|
const summary = plan.summary;
|
|
742
733
|
const managedAssets = summary.managedAssets ?? summary.total ?? plan.assets.length;
|
|
743
734
|
const wouldWrite = summary.wouldWrite ?? 0;
|
|
744
|
-
const metadataRefresh = summary.metadataRefresh ?? 0;
|
|
745
735
|
const customizedPreserved = summary.customizedPreserved ?? summary.states?.customized ?? 0;
|
|
746
736
|
const blocked = summary.blocked ?? 0;
|
|
747
737
|
console.log(
|
|
748
738
|
`Managed assets: ${managedAssets}; would write: ${wouldWrite}; ` +
|
|
749
|
-
`customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}
|
|
750
|
-
(metadataRefresh > 0 ? `; optional stamp refresh: ${metadataRefresh}` : '') +
|
|
751
|
-
'.'
|
|
739
|
+
`customized preserved: ${customizedPreserved}; blocked conflicts/deletions: ${blocked}.`
|
|
752
740
|
);
|
|
753
741
|
if (plan.applied) {
|
|
754
|
-
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
' (no content body changes).'
|
|
760
|
-
);
|
|
761
|
-
} else {
|
|
762
|
-
console.log(
|
|
763
|
-
`Applied ${wouldWrite} content write(s)` +
|
|
764
|
-
(metadataRefresh > 0 ? `, ${metadataRefresh} stamp refresh(es)` : '') +
|
|
765
|
-
(summary.manifestChanged ? ', managed manifest' : '') +
|
|
766
|
-
'.'
|
|
767
|
-
);
|
|
768
|
-
}
|
|
742
|
+
console.log(
|
|
743
|
+
`Applied ${wouldWrite} content write(s)` +
|
|
744
|
+
(summary.manifestChanged ? ', managed manifest' : '') +
|
|
745
|
+
'.'
|
|
746
|
+
);
|
|
769
747
|
return;
|
|
770
748
|
}
|
|
771
749
|
// Content already matches package templates — do not urge --apply as the primary next step.
|
|
@@ -775,15 +753,6 @@ export function renderManagedUpgrade(plan, options = {}) {
|
|
|
775
753
|
console.log(
|
|
776
754
|
`Nothing to apply — managed content matches ${verLabel} (${customizedPreserved} customized preserved).`
|
|
777
755
|
);
|
|
778
|
-
if (metadataRefresh > 0) {
|
|
779
|
-
console.log(
|
|
780
|
-
`Optional: ${metadataRefresh} skill stamp(s) lag package version while content is already current.`
|
|
781
|
-
);
|
|
782
|
-
const stampCmd = options.optionalStampApply ?? options.next;
|
|
783
|
-
if (stampCmd) {
|
|
784
|
-
console.log(`Optional stamp-only apply (not required): ${stampCmd}`);
|
|
785
|
-
}
|
|
786
|
-
}
|
|
787
756
|
return;
|
|
788
757
|
}
|
|
789
758
|
console.log(`Planned writes: ${wouldWrite}; blocked conflicts/deletions: ${blocked}.`);
|
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 {
|