arkgate 3.7.0 → 3.8.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 +76 -1145
- package/README.md +59 -19
- package/bin/ark-check-runtime.mjs +1598 -0
- package/bin/ark-check.mjs +32 -1565
- package/bin/ark-mcp-runtime.mjs +1976 -0
- package/bin/ark-mcp.mjs +84 -1495
- package/bin/ark-shared.mjs +34 -38
- package/bin/ark.mjs +33 -66
- package/bin/lib/adapter-contract.mjs +161 -9
- package/bin/lib/agent-gates.mjs +1 -0
- package/bin/lib/analysis-completeness.mjs +28 -0
- package/bin/lib/analysis-engine.mjs +8 -8
- package/bin/lib/analysis-policy.mjs +27 -0
- package/bin/lib/architecture-scan.mjs +70 -357
- package/bin/lib/auto-patch.mjs +76 -8
- package/bin/lib/ci-and-commands.mjs +1 -1
- package/bin/lib/codex-home.mjs +43 -16
- package/bin/lib/design-delta.mjs +4 -0
- package/bin/lib/doctor-advisories.mjs +4 -3
- package/bin/lib/doctor-plan.mjs +40 -41
- package/bin/lib/enforcement-state.mjs +2 -0
- package/bin/lib/github-enforcement.mjs +443 -0
- package/bin/lib/hook-templates.mjs +12 -148
- package/bin/lib/html-report-advisories.mjs +1 -1
- package/bin/lib/html-report-depth.mjs +9 -0
- package/bin/lib/html-report.mjs +5 -5
- package/bin/lib/install-migrate.mjs +83 -79
- package/bin/lib/managed-upgrade.mjs +622 -0
- package/bin/lib/mcp-adoption.mjs +3 -1
- package/bin/lib/parse-health.mjs +6 -5
- package/bin/lib/port-proof.mjs +2 -2
- package/bin/lib/prepare-change.mjs +68 -38
- package/bin/lib/prepare-write.mjs +7 -1
- package/bin/lib/resident-doctor-client.mjs +55 -0
- package/bin/lib/resident-hook.mjs +247 -0
- package/bin/lib/resolved-candidate-facts.mjs +1160 -0
- package/bin/lib/scan-files.mjs +19 -6
- package/bin/lib/snippet-analysis.mjs +119 -0
- package/bin/lib/source-policy.mjs +24 -0
- package/bin/lib/typescript-host.mjs +15 -18
- package/bin/lib/unavailable-analysis.mjs +76 -0
- package/bin/lib/upgrade-command.mjs +115 -0
- package/bin/lib/weakest-link.mjs +21 -179
- package/bin/lib/write-path-capabilities.mjs +167 -16
- package/bin/lib/write-path-detect.mjs +3 -2
- package/dist/eslint/index.cjs +3 -3
- package/dist/eslint/index.d.ts +3 -0
- package/dist/eslint/index.js +3 -3
- package/dist/index.cjs +7 -7
- package/dist/index.d.ts +1073 -141
- package/dist/index.js +7 -7
- package/docs/agent-guide.md +103 -59
- package/docs/ai-gates.md +97 -16
- package/docs/demos/01-write-gate-self-correction.md +2 -2
- package/docs/enthusiast/README.md +10 -10
- package/docs/enthusiast/how-to-gallery-starter.md +2 -2
- package/docs/enthusiast/reference-commands.md +18 -1
- package/docs/enthusiast/tutorial-first-project.md +2 -2
- package/docs/package-surface.md +98 -12
- package/docs/typescript-support.md +109 -37
- package/package.json +32 -4
- package/schemas/ark.analysis-result.schema.json +159 -2
- package/schemas/ark.design-delta.schema.json +1 -0
- package/schemas/ark.enforcement-state.schema.json +84 -0
- package/schemas/ark.resolved-candidate-facts.schema.json +1 -0
- package/server.json +2 -2
- package/templates/skills/ark-explore.md +5 -5
- package/templates/skills/ark-fix.md +1 -1
- package/templates/skills/ark-runtime.md +15 -8
- package/templates/skills/ark-upgrade.md +122 -182
- package/bin/lib/ai-velocity.mjs +0 -293
- package/bin/lib/graph-cycles.mjs +0 -6
- package/bin/lib/safety-diagnostics.mjs +0 -284
- package/bin/lib/ts-resolve.mjs +0 -228
- package/dist/configTypes-DAPvBqK6.d.cts +0 -61
- package/dist/eslint/index.d.cts +0 -146
- package/dist/index.d.cts +0 -986
|
@@ -1,9 +1,18 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
|
-
import {
|
|
3
|
+
import {
|
|
4
|
+
loadArchitectureChangeMap,
|
|
5
|
+
loadContract,
|
|
6
|
+
preflightResolvedChange,
|
|
7
|
+
} from './analysis-engine.mjs';
|
|
4
8
|
import { createAdapterResult } from './adapter-contract.mjs';
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
9
|
+
import { isGovernableSourceFile } from './scan-files.mjs';
|
|
10
|
+
import {
|
|
11
|
+
canonicalizeCandidateChanges,
|
|
12
|
+
resolveCandidateFacts,
|
|
13
|
+
} from './resolved-candidate-facts.mjs';
|
|
14
|
+
import { effectiveAnalysisConfig } from './analysis-policy.mjs';
|
|
15
|
+
import { isScanExcludedRelative } from '../ark-shared.mjs';
|
|
7
16
|
|
|
8
17
|
function candidatePath(value) {
|
|
9
18
|
if (typeof value !== 'string' || value.trim() === '') {
|
|
@@ -27,16 +36,21 @@ function isIncluded(relativePath, include) {
|
|
|
27
36
|
});
|
|
28
37
|
}
|
|
29
38
|
|
|
39
|
+
export function isCandidateSourceInScope(config, relativePath) {
|
|
40
|
+
return (
|
|
41
|
+
isGovernableSourceFile(path.basename(relativePath)) &&
|
|
42
|
+
isIncluded(relativePath, config.include) &&
|
|
43
|
+
!isScanExcludedRelative(relativePath, config)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
30
47
|
function assertGovernedSource(config, relativePath) {
|
|
31
48
|
if (!isGovernableSourceFile(path.basename(relativePath))) {
|
|
32
49
|
throw new Error(`Atomic preflight only accepts governed production source files: ${relativePath}`);
|
|
33
50
|
}
|
|
34
|
-
if (!
|
|
51
|
+
if (!isCandidateSourceInScope(config, relativePath)) {
|
|
35
52
|
throw new Error(`Change path is outside the configured source scope: ${relativePath}`);
|
|
36
53
|
}
|
|
37
|
-
if (!layerForRelativePath(relativePath, config.layers)) {
|
|
38
|
-
throw new Error(`Change path is not assigned to an architecture layer: ${relativePath}`);
|
|
39
|
-
}
|
|
40
54
|
}
|
|
41
55
|
|
|
42
56
|
function assertInsideProject(root, relativePath) {
|
|
@@ -73,22 +87,6 @@ export function normalizeChangeSet(input) {
|
|
|
73
87
|
});
|
|
74
88
|
}
|
|
75
89
|
|
|
76
|
-
function baseFilesForChange(root, config, changes) {
|
|
77
|
-
const byPath = new Map(
|
|
78
|
-
collectGovernedFiles(root, config).map((absolute) => [
|
|
79
|
-
normalize(path.relative(root, absolute)),
|
|
80
|
-
{ path: normalize(path.relative(root, absolute)), content: fs.readFileSync(absolute, 'utf8') },
|
|
81
|
-
])
|
|
82
|
-
);
|
|
83
|
-
for (const change of changes) {
|
|
84
|
-
if (byPath.has(change.path) || !isGovernableSourceFile(path.basename(change.path))) continue;
|
|
85
|
-
const absolute = path.join(root, change.path);
|
|
86
|
-
if (!fs.existsSync(absolute) || !fs.statSync(absolute).isFile()) continue;
|
|
87
|
-
byPath.set(change.path, { path: change.path, content: fs.readFileSync(absolute, 'utf8') });
|
|
88
|
-
}
|
|
89
|
-
return [...byPath.values()].sort((left, right) => left.path.localeCompare(right.path));
|
|
90
|
-
}
|
|
91
|
-
|
|
92
90
|
export function prepareChangeFromRoot({
|
|
93
91
|
root,
|
|
94
92
|
config,
|
|
@@ -96,33 +94,65 @@ export function prepareChangeFromRoot({
|
|
|
96
94
|
changes,
|
|
97
95
|
changeMap,
|
|
98
96
|
changeMapSource,
|
|
99
|
-
|
|
97
|
+
ts,
|
|
98
|
+
tsconfig,
|
|
99
|
+
manifest,
|
|
100
|
+
overlayChanges,
|
|
100
101
|
}) {
|
|
101
102
|
const normalizedChanges = normalizeChangeSet(changes);
|
|
103
|
+
const normalizedOverlayChanges =
|
|
104
|
+
overlayChanges === undefined ? normalizedChanges : normalizeChangeSet(overlayChanges);
|
|
105
|
+
const effectiveConfig = effectiveAnalysisConfig(config, manifest);
|
|
102
106
|
for (const change of normalizedChanges) {
|
|
103
107
|
assertInsideProject(root, change.path);
|
|
104
|
-
assertGovernedSource(config, change.path);
|
|
105
108
|
}
|
|
106
|
-
const contract = loadContract(
|
|
109
|
+
const contract = loadContract(
|
|
110
|
+
effectiveConfig,
|
|
111
|
+
configSource ?? path.join(root, 'ark.config.json')
|
|
112
|
+
);
|
|
107
113
|
const loadedChangeMap =
|
|
108
114
|
changeMap === undefined
|
|
109
115
|
? undefined
|
|
110
116
|
: loadArchitectureChangeMap(changeMap, contract.config, changeMapSource);
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
|
|
117
|
+
const canonicalChanges = canonicalizeCandidateChanges({
|
|
118
|
+
root,
|
|
119
|
+
config: contract.config,
|
|
114
120
|
changes: normalizedChanges,
|
|
121
|
+
});
|
|
122
|
+
for (const change of canonicalChanges) assertGovernedSource(effectiveConfig, change.path);
|
|
123
|
+
const baseFacts = resolveCandidateFacts({
|
|
124
|
+
root,
|
|
125
|
+
config: contract.config,
|
|
126
|
+
ts,
|
|
127
|
+
...(tsconfig ? { tsconfig } : {}),
|
|
128
|
+
});
|
|
129
|
+
const candidateFacts = resolveCandidateFacts({
|
|
130
|
+
root,
|
|
131
|
+
config: contract.config,
|
|
132
|
+
ts,
|
|
133
|
+
changes: normalizedOverlayChanges,
|
|
134
|
+
...(tsconfig ? { tsconfig } : {}),
|
|
135
|
+
});
|
|
136
|
+
const result = preflightResolvedChange({
|
|
137
|
+
contract,
|
|
138
|
+
baseFacts,
|
|
139
|
+
candidateFacts,
|
|
140
|
+
changes: canonicalChanges,
|
|
115
141
|
...(loadedChangeMap ? { changeMap: loadedChangeMap } : {}),
|
|
116
|
-
...(compilerOptions ? { compilerOptions } : {}),
|
|
117
142
|
});
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
143
|
+
const completeness =
|
|
144
|
+
result.baseCompleteness === 'unavailable' || result.candidateCompleteness === 'unavailable'
|
|
145
|
+
? 'unavailable'
|
|
146
|
+
: result.baseCompleteness === 'partial' || result.candidateCompleteness === 'partial'
|
|
147
|
+
? 'partial'
|
|
148
|
+
: 'complete';
|
|
149
|
+
const { diagnostics } = createAdapterResult({
|
|
150
|
+
valid: result.valid,
|
|
151
|
+
completeness,
|
|
152
|
+
violations: result.violations,
|
|
153
|
+
warnings: result.warnings,
|
|
154
|
+
});
|
|
155
|
+
return { ...result, diagnostics };
|
|
126
156
|
}
|
|
127
157
|
|
|
128
158
|
export function renderChangePreflight(result) {
|
|
@@ -73,7 +73,7 @@ export function buildJudgmentBrief(violations) {
|
|
|
73
73
|
* placement: object,
|
|
74
74
|
* root: string,
|
|
75
75
|
* ts: object,
|
|
76
|
-
* validate: (source: string) => { valid: boolean, violations?: any[] },
|
|
76
|
+
* validate: (source: string) => { valid: boolean, completeness?: string, violations?: any[] },
|
|
77
77
|
* resolveTargetAbs?: Function,
|
|
78
78
|
* }} opts
|
|
79
79
|
*/
|
|
@@ -117,7 +117,13 @@ export function composePrepareWrite(opts) {
|
|
|
117
117
|
...(placement?.description ? { description: placement.description } : {}),
|
|
118
118
|
// Q03: pass through golden pattern from ark_place (advisory; absent is normal).
|
|
119
119
|
...(placement?.goldenPattern ? { goldenPattern: placement.goldenPattern } : {}),
|
|
120
|
+
mode: gate.mode,
|
|
120
121
|
valid: gate.valid,
|
|
122
|
+
lexicalValid: gate.lexicalValid,
|
|
123
|
+
...(gate.completeness ? { completeness: gate.completeness } : {}),
|
|
124
|
+
...(Array.isArray(gate.completenessReasons)
|
|
125
|
+
? { completenessReasons: gate.completenessReasons }
|
|
126
|
+
: {}),
|
|
121
127
|
violations: gate.violations,
|
|
122
128
|
...(gate.autoPatch ? { autoPatch: gate.autoPatch } : {}),
|
|
123
129
|
...(judgment ? { judgmentBrief: judgment } : {}),
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import path from 'node:path';
|
|
2
|
+
import { fileURLToPath } from 'node:url';
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
RESIDENT_HOOK_PROTOCOL_VERSION,
|
|
6
|
+
requestResidentHook,
|
|
7
|
+
residentDoctorEnvironment,
|
|
8
|
+
residentHookEndpoint,
|
|
9
|
+
} from './resident-hook.mjs';
|
|
10
|
+
|
|
11
|
+
const arkMcpLauncher = fileURLToPath(new URL('../ark-mcp.mjs', import.meta.url));
|
|
12
|
+
|
|
13
|
+
/** Try the opt-in resident doctor and let the caller continue through the cold fallback. */
|
|
14
|
+
export async function tryResidentDoctor(args) {
|
|
15
|
+
if (!args.resident) return false;
|
|
16
|
+
if (!args.doctor || !args.json) throw new Error('--resident requires --doctor --json.');
|
|
17
|
+
const endpoint = residentHookEndpoint({
|
|
18
|
+
root: args.root,
|
|
19
|
+
config: args.config,
|
|
20
|
+
manifest: args.manifest,
|
|
21
|
+
tsconfig: args.tsconfig,
|
|
22
|
+
launcher: arkMcpLauncher,
|
|
23
|
+
});
|
|
24
|
+
const configuredTimeout = Number(process.env.ARK_RESIDENT_DOCTOR_TIMEOUT_MS);
|
|
25
|
+
const response = await requestResidentHook({
|
|
26
|
+
socket: endpoint.socket,
|
|
27
|
+
timeoutMs:
|
|
28
|
+
Number.isFinite(configuredTimeout) && configuredTimeout > 0 ? configuredTimeout : 500,
|
|
29
|
+
request: {
|
|
30
|
+
protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
|
|
31
|
+
kind: 'doctor',
|
|
32
|
+
root: path.resolve(args.root),
|
|
33
|
+
config: args.config,
|
|
34
|
+
manifest: args.manifest ?? null,
|
|
35
|
+
tsconfig: args.tsconfig ?? null,
|
|
36
|
+
environment: residentDoctorEnvironment(),
|
|
37
|
+
},
|
|
38
|
+
});
|
|
39
|
+
if (
|
|
40
|
+
!response ||
|
|
41
|
+
response.fallback === true ||
|
|
42
|
+
!Number.isInteger(response.status) ||
|
|
43
|
+
typeof response.stdout !== 'string' ||
|
|
44
|
+
typeof response.stderr !== 'string'
|
|
45
|
+
) {
|
|
46
|
+
if (process.env.ARK_RESIDENT_DOCTOR_REQUIRED === '1') {
|
|
47
|
+
throw new Error('Resident doctor was required but unavailable.');
|
|
48
|
+
}
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
if (response.stdout) process.stdout.write(response.stdout);
|
|
52
|
+
if (response.stderr) process.stderr.write(response.stderr);
|
|
53
|
+
process.exitCode = response.status;
|
|
54
|
+
return true;
|
|
55
|
+
}
|
|
@@ -0,0 +1,247 @@
|
|
|
1
|
+
/** Z07 transport only; ark-mcp-runtime owns every hook decision. */
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import net from 'node:net';
|
|
5
|
+
import os from 'node:os';
|
|
6
|
+
import path from 'node:path';
|
|
7
|
+
|
|
8
|
+
export const RESIDENT_HOOK_PROTOCOL_VERSION = 1;
|
|
9
|
+
const RESIDENT_DOCTOR_ENV = [
|
|
10
|
+
'ARK_ACTIVE_HOST',
|
|
11
|
+
'ARK_DOCTOR_GITHUB',
|
|
12
|
+
'CODEX_HOME',
|
|
13
|
+
'CODEX_CI',
|
|
14
|
+
'CODEX_SANDBOX',
|
|
15
|
+
'CODEX_SESSION_ID',
|
|
16
|
+
'CODEX_THREAD_ID',
|
|
17
|
+
'CLAUDE_CODE',
|
|
18
|
+
'CLAUDE_CODE_ENTRYPOINT',
|
|
19
|
+
'CLAUDE_PROJECT_DIR',
|
|
20
|
+
'CLAUDECODE',
|
|
21
|
+
'CURSOR_AGENT',
|
|
22
|
+
'CURSOR_AGENT_CLI',
|
|
23
|
+
'CURSOR_TRACE_ID',
|
|
24
|
+
'GH_HOST',
|
|
25
|
+
'GH_TOKEN',
|
|
26
|
+
'GITHUB_TOKEN',
|
|
27
|
+
'GROK_BUILD',
|
|
28
|
+
'GROK_AGENT',
|
|
29
|
+
'GROK_SESSION_ID',
|
|
30
|
+
'GROK_WORKSPACE_ROOT',
|
|
31
|
+
'HOME',
|
|
32
|
+
'HOMEDRIVE',
|
|
33
|
+
'HOMEPATH',
|
|
34
|
+
'PATH',
|
|
35
|
+
'USERPROFILE',
|
|
36
|
+
'XAI_GROK',
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export function residentDoctorEnvironment(env = process.env) {
|
|
40
|
+
const hash = createHash('sha256');
|
|
41
|
+
for (const name of RESIDENT_DOCTOR_ENV) {
|
|
42
|
+
hash.update(name).update('\0').update(env[name] ?? '').update('\0');
|
|
43
|
+
}
|
|
44
|
+
return { digest: `sha256:${hash.digest('hex')}` };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function residentRuntimeIdentity(launcher) {
|
|
48
|
+
const hash = createHash('sha256');
|
|
49
|
+
const packageRoot = path.dirname(path.dirname(launcher));
|
|
50
|
+
const files = [path.join(packageRoot, 'package.json')];
|
|
51
|
+
const visit = (directory) => {
|
|
52
|
+
try {
|
|
53
|
+
for (const entry of fs.readdirSync(directory, { withFileTypes: true })) {
|
|
54
|
+
const absolute = path.join(directory, entry.name);
|
|
55
|
+
if (entry.isDirectory()) visit(absolute);
|
|
56
|
+
else if (/\.(?:[cm]?js|json)$/.test(entry.name)) files.push(absolute);
|
|
57
|
+
}
|
|
58
|
+
} catch { files.push(directory); }
|
|
59
|
+
};
|
|
60
|
+
visit(path.join(packageRoot, 'bin'));
|
|
61
|
+
visit(path.join(packageRoot, 'dist'));
|
|
62
|
+
for (const file of [...new Set(files)].sort()) {
|
|
63
|
+
hash.update(path.relative(packageRoot, file)).update('\0');
|
|
64
|
+
try { hash.update(fs.readFileSync(file)); } catch { hash.update('missing'); }
|
|
65
|
+
hash.update('\0');
|
|
66
|
+
}
|
|
67
|
+
return hash.digest('hex');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function residentHookEndpoint({ root, config, manifest, tsconfig, launcher }) {
|
|
71
|
+
let realRoot;
|
|
72
|
+
try { realRoot = fs.realpathSync(root); } catch { realRoot = path.resolve(root); }
|
|
73
|
+
const uid = typeof process.getuid === 'function' ? process.getuid() : 'user';
|
|
74
|
+
const directory = path.join(os.tmpdir(), `arkgate-${uid}`);
|
|
75
|
+
const digest = createHash('sha256').update(JSON.stringify({
|
|
76
|
+
root: realRoot,
|
|
77
|
+
config: path.resolve(root, config),
|
|
78
|
+
manifest: manifest ? path.resolve(root, manifest) : null,
|
|
79
|
+
tsconfig: tsconfig ? path.resolve(root, tsconfig) : null,
|
|
80
|
+
launcher: path.resolve(launcher),
|
|
81
|
+
executable: process.execPath,
|
|
82
|
+
protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION,
|
|
83
|
+
runtimeIdentity: residentRuntimeIdentity(path.resolve(launcher)),
|
|
84
|
+
})).digest('hex').slice(0, 24);
|
|
85
|
+
return {
|
|
86
|
+
directory,
|
|
87
|
+
socket: process.platform === 'win32'
|
|
88
|
+
? `\\\\.\\pipe\\arkgate-z07-${uid}-${digest}`
|
|
89
|
+
: path.join(directory, `${digest}.sock`),
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
export function residentEnvironmentIdentity(paths, tokens = []) {
|
|
94
|
+
const hash = createHash('sha256');
|
|
95
|
+
for (const token of [...tokens].map(String).sort()) hash.update('token\0').update(token).update('\0');
|
|
96
|
+
for (const file of [...new Set(paths.map((entry) => path.resolve(entry)))].sort()) {
|
|
97
|
+
hash.update('path\0').update(file).update('\0');
|
|
98
|
+
try {
|
|
99
|
+
const stat = fs.lstatSync(file);
|
|
100
|
+
if (stat.isSymbolicLink()) {
|
|
101
|
+
hash.update('link\0').update(fs.readlinkSync(file)).update('\0');
|
|
102
|
+
hash.update(fs.realpathSync(file)).update('\0');
|
|
103
|
+
const target = fs.statSync(file);
|
|
104
|
+
if (target.isFile()) hash.update('target-file\0').update(fs.readFileSync(file));
|
|
105
|
+
else hash.update('target-other\0');
|
|
106
|
+
}
|
|
107
|
+
else if (stat.isFile()) hash.update('file\0').update(fs.readFileSync(file));
|
|
108
|
+
else hash.update('other\0');
|
|
109
|
+
} catch { hash.update('missing\0'); }
|
|
110
|
+
hash.update('\0');
|
|
111
|
+
}
|
|
112
|
+
return `sha256:${hash.digest('hex')}`;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function inputState(file) { // Stryker disable StringLiteral: opaque state tags have no public meaning
|
|
116
|
+
const stat = fs.lstatSync(file, { bigint: true, throwIfNoEntry: false });
|
|
117
|
+
if (!stat) return 'missing';
|
|
118
|
+
const metadata = [stat.dev, stat.ino, stat.mode, stat.size, stat.mtimeNs, stat.ctimeNs]
|
|
119
|
+
.map(String)
|
|
120
|
+
.join(':');
|
|
121
|
+
if (stat.isSymbolicLink()) {
|
|
122
|
+
const target = fs.realpathSync(file);
|
|
123
|
+
const targetStat = fs.statSync(file, { bigint: true });
|
|
124
|
+
return `link:${metadata}:${fs.readlinkSync(file)}:${target}:${[
|
|
125
|
+
targetStat.dev,
|
|
126
|
+
targetStat.ino,
|
|
127
|
+
targetStat.mode,
|
|
128
|
+
targetStat.size,
|
|
129
|
+
targetStat.mtimeNs,
|
|
130
|
+
targetStat.ctimeNs,
|
|
131
|
+
].map(String).join(':')}`;
|
|
132
|
+
}
|
|
133
|
+
if (stat.isDirectory()) {
|
|
134
|
+
const entries = fs.readdirSync(file, { withFileTypes: true })
|
|
135
|
+
.map((entry) => `${entry.name}:${entry.isDirectory() ? 'd' : entry.isFile() ? 'f' : 'o'}`)
|
|
136
|
+
.sort();
|
|
137
|
+
return `dir:${metadata}:${entries.join('\0')}`;
|
|
138
|
+
}
|
|
139
|
+
return `${stat.isFile() ? 'file' : 'other'}:${metadata}`;
|
|
140
|
+
} // Stryker restore StringLiteral
|
|
141
|
+
|
|
142
|
+
/** Process-local, metadata-based invalidation ledger; never a persisted result cache. */
|
|
143
|
+
export function createResidentInputLedger(paths) {
|
|
144
|
+
const normalized = [...new Set(paths.map((entry) => path.resolve(entry)))].sort();
|
|
145
|
+
const states = new Map(normalized.map((file) => [file, inputState(file)]));
|
|
146
|
+
return {
|
|
147
|
+
paths: normalized,
|
|
148
|
+
matches(candidatePaths = normalized) {
|
|
149
|
+
try {
|
|
150
|
+
const candidate = [...new Set(candidatePaths.map((entry) => path.resolve(entry)))].sort();
|
|
151
|
+
return (
|
|
152
|
+
candidate.length === normalized.length &&
|
|
153
|
+
candidate.every((file, index) =>
|
|
154
|
+
file === normalized[index] && inputState(file) === states.get(file)
|
|
155
|
+
)
|
|
156
|
+
);
|
|
157
|
+
} catch {
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
export function requestResidentHook({ socket, request, timeoutMs = 75 }) {
|
|
165
|
+
return new Promise((resolve) => {
|
|
166
|
+
let done = false;
|
|
167
|
+
let buffered = '';
|
|
168
|
+
const client = net.createConnection(socket);
|
|
169
|
+
const finish = (value) => {
|
|
170
|
+
if (done) return;
|
|
171
|
+
done = true;
|
|
172
|
+
clearTimeout(timer);
|
|
173
|
+
client.destroy();
|
|
174
|
+
resolve(value);
|
|
175
|
+
};
|
|
176
|
+
const timer = setTimeout(() => finish(null), timeoutMs);
|
|
177
|
+
client.setEncoding('utf8');
|
|
178
|
+
client.once('connect', () => client.write(`${JSON.stringify(request)}\n`));
|
|
179
|
+
client.on('data', (chunk) => {
|
|
180
|
+
buffered += chunk;
|
|
181
|
+
if (buffered.length > 24 * 1024 * 1024) return finish(null);
|
|
182
|
+
const newline = buffered.indexOf('\n');
|
|
183
|
+
if (newline < 0) return;
|
|
184
|
+
try {
|
|
185
|
+
const parsed = JSON.parse(buffered.slice(0, newline));
|
|
186
|
+
finish(parsed?.protocolVersion === RESIDENT_HOOK_PROTOCOL_VERSION ? parsed : null);
|
|
187
|
+
} catch { finish(null); }
|
|
188
|
+
});
|
|
189
|
+
client.once('error', () => finish(null));
|
|
190
|
+
client.once('end', () => finish(null));
|
|
191
|
+
});
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function secureDirectory(directory) {
|
|
195
|
+
if (process.platform === 'win32') return true;
|
|
196
|
+
try {
|
|
197
|
+
fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
|
|
198
|
+
const stat = fs.lstatSync(directory);
|
|
199
|
+
if (!stat.isDirectory() || stat.isSymbolicLink()) return false;
|
|
200
|
+
if (typeof process.getuid === 'function' && stat.uid !== process.getuid()) return false;
|
|
201
|
+
fs.chmodSync(directory, 0o700);
|
|
202
|
+
return true;
|
|
203
|
+
} catch { return false; }
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
export async function startResidentHookServer({ endpoint, handle }) {
|
|
207
|
+
if (!secureDirectory(endpoint.directory)) return null;
|
|
208
|
+
const server = net.createServer((client) => {
|
|
209
|
+
client.setEncoding('utf8');
|
|
210
|
+
let buffered = '';
|
|
211
|
+
let handled = false;
|
|
212
|
+
client.on('data', async (chunk) => {
|
|
213
|
+
if (handled) return;
|
|
214
|
+
buffered += chunk;
|
|
215
|
+
if (buffered.length > 12 * 1024 * 1024) return client.destroy();
|
|
216
|
+
const newline = buffered.indexOf('\n');
|
|
217
|
+
if (newline < 0) return;
|
|
218
|
+
handled = true;
|
|
219
|
+
let response;
|
|
220
|
+
try { response = await handle(JSON.parse(buffered.slice(0, newline))); }
|
|
221
|
+
catch { response = { protocolVersion: RESIDENT_HOOK_PROTOCOL_VERSION, fallback: true }; }
|
|
222
|
+
if (!client.destroyed) client.end(`${JSON.stringify(response)}\n`);
|
|
223
|
+
});
|
|
224
|
+
});
|
|
225
|
+
const listening = await new Promise((resolve) => {
|
|
226
|
+
const failed = () => resolve(false);
|
|
227
|
+
server.once('error', failed);
|
|
228
|
+
server.listen(endpoint.socket, () => {
|
|
229
|
+
server.off('error', failed);
|
|
230
|
+
resolve(true);
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
if (!listening) {
|
|
234
|
+
try { server.close(); } catch { /* The endpoint is already owned. */ }
|
|
235
|
+
return null;
|
|
236
|
+
}
|
|
237
|
+
let cleaned = false;
|
|
238
|
+
const cleanup = () => {
|
|
239
|
+
if (cleaned) return;
|
|
240
|
+
cleaned = true;
|
|
241
|
+
try { server.close(); } catch { /* Already closed. */ }
|
|
242
|
+
if (process.platform !== 'win32') {
|
|
243
|
+
try { fs.unlinkSync(endpoint.socket); } catch { /* Exact endpoint already absent. */ }
|
|
244
|
+
}
|
|
245
|
+
};
|
|
246
|
+
return { cleanup };
|
|
247
|
+
}
|