@agent-relay/factory 0.1.49 → 0.1.51
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/README.md +93 -11
- package/dist/cli/fleet.d.ts +4 -1
- package/dist/cli/fleet.d.ts.map +1 -1
- package/dist/cli/fleet.js +87 -42
- package/dist/cli/fleet.js.map +1 -1
- package/dist/config/schema.d.ts +1671 -10
- package/dist/config/schema.d.ts.map +1 -1
- package/dist/config/schema.js +83 -0
- package/dist/config/schema.js.map +1 -1
- package/dist/dispatch/templates.d.ts +6 -0
- package/dist/dispatch/templates.d.ts.map +1 -1
- package/dist/dispatch/templates.js +16 -0
- package/dist/dispatch/templates.js.map +1 -1
- package/dist/environments/connection-registry.d.ts +617 -0
- package/dist/environments/connection-registry.d.ts.map +1 -0
- package/dist/environments/connection-registry.js +174 -0
- package/dist/environments/connection-registry.js.map +1 -0
- package/dist/environments/index.d.ts +21 -0
- package/dist/environments/index.d.ts.map +1 -0
- package/dist/environments/index.js +11 -0
- package/dist/environments/index.js.map +1 -0
- package/dist/environments/k6-job.d.ts +64 -0
- package/dist/environments/k6-job.d.ts.map +1 -0
- package/dist/environments/k6-job.js +376 -0
- package/dist/environments/k6-job.js.map +1 -0
- package/dist/environments/kubernetes-client.d.ts +72 -0
- package/dist/environments/kubernetes-client.d.ts.map +1 -0
- package/dist/environments/kubernetes-client.js +337 -0
- package/dist/environments/kubernetes-client.js.map +1 -0
- package/dist/environments/kubernetes-command.d.ts +30 -0
- package/dist/environments/kubernetes-command.d.ts.map +1 -0
- package/dist/environments/kubernetes-command.js +108 -0
- package/dist/environments/kubernetes-command.js.map +1 -0
- package/dist/environments/kubernetes-environment.d.ts +38 -0
- package/dist/environments/kubernetes-environment.d.ts.map +1 -0
- package/dist/environments/kubernetes-environment.js +333 -0
- package/dist/environments/kubernetes-environment.js.map +1 -0
- package/dist/environments/kubernetes-provider.d.ts +83 -0
- package/dist/environments/kubernetes-provider.d.ts.map +1 -0
- package/dist/environments/kubernetes-provider.js +956 -0
- package/dist/environments/kubernetes-provider.js.map +1 -0
- package/dist/environments/load-harness.d.ts +199 -0
- package/dist/environments/load-harness.d.ts.map +1 -0
- package/dist/environments/load-harness.js +259 -0
- package/dist/environments/load-harness.js.map +1 -0
- package/dist/environments/load-profile.d.ts +294 -0
- package/dist/environments/load-profile.d.ts.map +1 -0
- package/dist/environments/load-profile.js +125 -0
- package/dist/environments/load-profile.js.map +1 -0
- package/dist/environments/stack-deployer.d.ts +22 -0
- package/dist/environments/stack-deployer.d.ts.map +1 -0
- package/dist/environments/stack-deployer.js +27 -0
- package/dist/environments/stack-deployer.js.map +1 -0
- package/dist/environments/stack-descriptor.d.ts +243 -0
- package/dist/environments/stack-descriptor.d.ts.map +1 -0
- package/dist/environments/stack-descriptor.js +88 -0
- package/dist/environments/stack-descriptor.js.map +1 -0
- package/dist/environments/verification-pipeline.d.ts +117 -0
- package/dist/environments/verification-pipeline.d.ts.map +1 -0
- package/dist/environments/verification-pipeline.js +643 -0
- package/dist/environments/verification-pipeline.js.map +1 -0
- package/dist/environments/verification-stack-deployer.d.ts +73 -0
- package/dist/environments/verification-stack-deployer.d.ts.map +1 -0
- package/dist/environments/verification-stack-deployer.js +484 -0
- package/dist/environments/verification-stack-deployer.js.map +1 -0
- package/dist/environments/verification-stack-descriptor.d.ts +1134 -0
- package/dist/environments/verification-stack-descriptor.d.ts.map +1 -0
- package/dist/environments/verification-stack-descriptor.js +275 -0
- package/dist/environments/verification-stack-descriptor.js.map +1 -0
- package/dist/environments/verification-stack.d.ts +29 -0
- package/dist/environments/verification-stack.d.ts.map +1 -0
- package/dist/environments/verification-stack.js +43 -0
- package/dist/environments/verification-stack.js.map +1 -0
- package/dist/featuremap/generate.d.ts +72 -0
- package/dist/featuremap/generate.d.ts.map +1 -0
- package/dist/featuremap/generate.js +854 -0
- package/dist/featuremap/generate.js.map +1 -0
- package/dist/featuremap/index.d.ts +2 -0
- package/dist/featuremap/index.d.ts.map +1 -1
- package/dist/featuremap/index.js +1 -0
- package/dist/featuremap/index.js.map +1 -1
- package/dist/featuremap/validate.d.ts +6 -0
- package/dist/featuremap/validate.d.ts.map +1 -1
- package/dist/featuremap/validate.js +95 -13
- package/dist/featuremap/validate.js.map +1 -1
- package/dist/fleet/create-fleet.d.ts +2 -0
- package/dist/fleet/create-fleet.d.ts.map +1 -1
- package/dist/fleet/create-fleet.js +1 -0
- package/dist/fleet/create-fleet.js.map +1 -1
- package/dist/fleet/internal-fleet-client.d.ts +8 -1
- package/dist/fleet/internal-fleet-client.d.ts.map +1 -1
- package/dist/fleet/internal-fleet-client.js +31 -6
- package/dist/fleet/internal-fleet-client.js.map +1 -1
- package/dist/fleet/relay-fleet-client.d.ts +4 -1
- package/dist/fleet/relay-fleet-client.d.ts.map +1 -1
- package/dist/fleet/relay-fleet-client.js +142 -1
- package/dist/fleet/relay-fleet-client.js.map +1 -1
- package/dist/git/agent-worktree.d.ts +4 -1
- package/dist/git/agent-worktree.d.ts.map +1 -1
- package/dist/git/agent-worktree.js +151 -8
- package/dist/git/agent-worktree.js.map +1 -1
- package/dist/index.d.ts +32 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +15 -3
- package/dist/index.js.map +1 -1
- package/dist/mount/local-mount-preflight.d.ts.map +1 -1
- package/dist/mount/local-mount-preflight.js +6 -2
- package/dist/mount/local-mount-preflight.js.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.d.ts +8 -2
- package/dist/mount/relayfile-cloud-mount-client.d.ts.map +1 -1
- package/dist/mount/relayfile-cloud-mount-client.js +11 -1
- package/dist/mount/relayfile-cloud-mount-client.js.map +1 -1
- package/dist/node/factory-node.d.ts +2 -0
- package/dist/node/factory-node.d.ts.map +1 -1
- package/dist/node/factory-node.js +107 -2
- package/dist/node/factory-node.js.map +1 -1
- package/dist/node/preview-process.d.ts +65 -0
- package/dist/node/preview-process.d.ts.map +1 -0
- package/dist/node/preview-process.js +431 -0
- package/dist/node/preview-process.js.map +1 -0
- package/dist/node/tailscale-preview.d.ts +45 -0
- package/dist/node/tailscale-preview.d.ts.map +1 -0
- package/dist/node/tailscale-preview.js +748 -0
- package/dist/node/tailscale-preview.js.map +1 -0
- package/dist/observability/events.d.ts +1838 -28
- package/dist/observability/events.d.ts.map +1 -1
- package/dist/observability/events.js +52 -0
- package/dist/observability/events.js.map +1 -1
- package/dist/orchestrator/factory.d.ts.map +1 -1
- package/dist/orchestrator/factory.js +1416 -78
- package/dist/orchestrator/factory.js.map +1 -1
- package/dist/orchestrator/index.d.ts +2 -1
- package/dist/orchestrator/index.d.ts.map +1 -1
- package/dist/orchestrator/index.js +1 -1
- package/dist/orchestrator/index.js.map +1 -1
- package/dist/orchestrator/reaper.d.ts +24 -0
- package/dist/orchestrator/reaper.d.ts.map +1 -1
- package/dist/orchestrator/reaper.js +117 -0
- package/dist/orchestrator/reaper.js.map +1 -1
- package/dist/ports/environment.d.ts +83 -0
- package/dist/ports/environment.d.ts.map +1 -0
- package/dist/ports/environment.js +2 -0
- package/dist/ports/environment.js.map +1 -0
- package/dist/ports/fleet.d.ts +68 -1
- package/dist/ports/fleet.d.ts.map +1 -1
- package/dist/ports/index.d.ts +3 -2
- package/dist/ports/index.d.ts.map +1 -1
- package/dist/ports/mount.d.ts +9 -0
- package/dist/ports/mount.d.ts.map +1 -1
- package/dist/ports/state.d.ts +17 -1
- package/dist/ports/state.d.ts.map +1 -1
- package/dist/ports/worktree.d.ts +14 -0
- package/dist/ports/worktree.d.ts.map +1 -1
- package/dist/state/file-state-store.js +28 -1
- package/dist/state/file-state-store.js.map +1 -1
- package/dist/subscriptions/index.d.ts +2 -0
- package/dist/subscriptions/index.d.ts.map +1 -1
- package/dist/subscriptions/index.js +1 -0
- package/dist/subscriptions/index.js.map +1 -1
- package/dist/subscriptions/resource-subscriptions.d.ts +77 -0
- package/dist/subscriptions/resource-subscriptions.d.ts.map +1 -0
- package/dist/subscriptions/resource-subscriptions.js +91 -0
- package/dist/subscriptions/resource-subscriptions.js.map +1 -0
- package/dist/testing/fakes.d.ts +9 -1
- package/dist/testing/fakes.d.ts.map +1 -1
- package/dist/testing/fakes.js +40 -0
- package/dist/testing/fakes.js.map +1 -1
- package/dist/triage/schema.d.ts +32 -32
- package/dist/types.d.ts +4 -1
- package/dist/types.d.ts.map +1 -1
- package/package.json +18 -4
- package/schemas/kubernetes-environment-stack.schema.json +86 -0
- package/schemas/verification-stack.schema.json +292 -0
|
@@ -0,0 +1,854 @@
|
|
|
1
|
+
import { createHash, randomUUID } from 'node:crypto';
|
|
2
|
+
import { mkdir, readFile, readdir, realpath, rename, rm, stat, writeFile, } from 'node:fs/promises';
|
|
3
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
4
|
+
import { setTimeout as delay } from 'node:timers/promises';
|
|
5
|
+
import lockfile from 'proper-lockfile';
|
|
6
|
+
import { FEATURE_MAP_MANIFEST_PATH, validateFeatureManifest, } from './validate.js';
|
|
7
|
+
export { FEATURE_MAP_MANIFEST_PATH } from './validate.js';
|
|
8
|
+
const GENERATED_CATEGORY_ID = 'generated-touched-surfaces';
|
|
9
|
+
const DEFAULT_TIMEOUT_MS = 2_000;
|
|
10
|
+
const DEFAULT_MAX_NEW_ENTRIES = 25;
|
|
11
|
+
const DEFAULT_MAX_MANIFEST_FEATURES = 250;
|
|
12
|
+
const DEFAULT_MAX_TOUCHED_FILES = 100;
|
|
13
|
+
const DEFAULT_MAX_SCANNED_PATHS = 2_000;
|
|
14
|
+
const DEFAULT_MAX_SOURCE_BYTES = 128 * 1024;
|
|
15
|
+
const DEFAULT_MAX_MANIFEST_BYTES = 2 * 1024 * 1024;
|
|
16
|
+
const DEFAULT_MAX_STALE_CHECKS = 500;
|
|
17
|
+
const MANIFEST_LOCK_STALE_MS = 30_000;
|
|
18
|
+
const MANIFEST_LOCK_RETRY_MS = 10;
|
|
19
|
+
const SKIPPED_DIRECTORIES = new Set([
|
|
20
|
+
'.git',
|
|
21
|
+
'.hg',
|
|
22
|
+
'.svn',
|
|
23
|
+
'.next',
|
|
24
|
+
'build',
|
|
25
|
+
'coverage',
|
|
26
|
+
'dist',
|
|
27
|
+
'node_modules',
|
|
28
|
+
'vendor',
|
|
29
|
+
]);
|
|
30
|
+
class GenerationTimeoutError extends Error {
|
|
31
|
+
constructor() {
|
|
32
|
+
super('Feature map generation exceeded its time budget');
|
|
33
|
+
this.name = 'GenerationTimeoutError';
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
class Deadline {
|
|
37
|
+
#expiresAt;
|
|
38
|
+
constructor(timeoutMs) {
|
|
39
|
+
this.#expiresAt = Date.now() + timeoutMs;
|
|
40
|
+
}
|
|
41
|
+
check() {
|
|
42
|
+
if (Date.now() >= this.#expiresAt)
|
|
43
|
+
throw new GenerationTimeoutError();
|
|
44
|
+
}
|
|
45
|
+
async run(operation) {
|
|
46
|
+
this.check();
|
|
47
|
+
const remainingMs = this.#expiresAt - Date.now();
|
|
48
|
+
const controller = new AbortController();
|
|
49
|
+
let timer;
|
|
50
|
+
const timeout = new Promise((_resolve, reject) => {
|
|
51
|
+
timer = setTimeout(() => {
|
|
52
|
+
controller.abort();
|
|
53
|
+
reject(new GenerationTimeoutError());
|
|
54
|
+
}, remainingMs);
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
return await Promise.race([operation(controller.signal), timeout]);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
if (controller.signal.aborted)
|
|
61
|
+
throw new GenerationTimeoutError();
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
finally {
|
|
65
|
+
if (timer)
|
|
66
|
+
clearTimeout(timer);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
remainingMs() {
|
|
70
|
+
return Math.max(0, this.#expiresAt - Date.now());
|
|
71
|
+
}
|
|
72
|
+
async pause(maximumMs) {
|
|
73
|
+
await this.run(async (signal) => {
|
|
74
|
+
await delay(Math.min(maximumMs, this.remainingMs()), undefined, { signal });
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
export async function generateFeatureMap(inputOrRepoPath, positionalGlobs = [], positionalOptions = {}) {
|
|
79
|
+
const input = normalizeInput(inputOrRepoPath, positionalGlobs, positionalOptions);
|
|
80
|
+
const unresolvedManifestPath = join(resolve(input.repoPath), FEATURE_MAP_MANIFEST_PATH);
|
|
81
|
+
const baseResult = {
|
|
82
|
+
manifestPath: unresolvedManifestPath,
|
|
83
|
+
added: [],
|
|
84
|
+
staleLocations: [],
|
|
85
|
+
touchedFiles: [],
|
|
86
|
+
warnings: [],
|
|
87
|
+
};
|
|
88
|
+
try {
|
|
89
|
+
const limits = generationLimits(input.options);
|
|
90
|
+
const deadline = new Deadline(limits.timeoutMs);
|
|
91
|
+
const repoPath = await deadline.run(() => realpath(resolve(input.repoPath)));
|
|
92
|
+
const repoStats = await deadline.run(() => stat(repoPath));
|
|
93
|
+
if (!repoStats.isDirectory())
|
|
94
|
+
throw new Error(`Target repository path is not a directory: ${repoPath}`);
|
|
95
|
+
const manifestPath = join(repoPath, FEATURE_MAP_MANIFEST_PATH);
|
|
96
|
+
const releaseLock = await acquireManifestLock(manifestPath, deadline);
|
|
97
|
+
try {
|
|
98
|
+
const existingRaw = await readExistingManifest(manifestPath, limits.maxManifestBytes, deadline);
|
|
99
|
+
const existing = existingRaw === undefined ? undefined : parseFeatureMapManifest(existingRaw);
|
|
100
|
+
const warnings = [];
|
|
101
|
+
const staleLocations = existing
|
|
102
|
+
? await findStaleLocations(repoPath, existing.features, limits.maxStaleChecks, warnings, deadline)
|
|
103
|
+
: [];
|
|
104
|
+
const touchedFiles = await resolveTouchedFiles(repoPath, input.touchedFileGlobs, limits, warnings, deadline);
|
|
105
|
+
const coveredLocations = new Set(existing?.features.flatMap((feature) => splitLocations(feature.location).map(normalizeRelativePath)) ?? []);
|
|
106
|
+
const uncovered = touchedFiles.filter((candidate) => !coveredLocations.has(candidate.location));
|
|
107
|
+
const existingFeatureCount = existing?.features.length ?? 0;
|
|
108
|
+
const remainingCapacity = Math.max(0, limits.maxManifestFeatures - existingFeatureCount);
|
|
109
|
+
const additionLimit = Math.min(limits.maxNewEntries, remainingCapacity);
|
|
110
|
+
if (uncovered.length > additionLimit) {
|
|
111
|
+
warnings.push(`Feature map growth capped at ${additionLimit} new entries; ${uncovered.length - additionLimit} touched files were deferred.`);
|
|
112
|
+
}
|
|
113
|
+
if (remainingCapacity === 0 && uncovered.length > 0) {
|
|
114
|
+
warnings.push(`Feature map already contains the configured maximum of ${limits.maxManifestFeatures} features.`);
|
|
115
|
+
}
|
|
116
|
+
const existingIds = new Set(existing?.features.map((feature) => feature.id) ?? []);
|
|
117
|
+
const additions = [];
|
|
118
|
+
for (const candidate of uncovered.slice(0, additionLimit)) {
|
|
119
|
+
deadline.check();
|
|
120
|
+
additions.push(await inferFeature(candidate, existingIds, limits.maxSourceBytes, warnings, deadline));
|
|
121
|
+
}
|
|
122
|
+
if (additions.length === 0) {
|
|
123
|
+
reportWarnings(input.options.logger, staleLocations, warnings);
|
|
124
|
+
return {
|
|
125
|
+
ok: true,
|
|
126
|
+
status: 'unchanged',
|
|
127
|
+
manifestPath,
|
|
128
|
+
added: [],
|
|
129
|
+
staleLocations,
|
|
130
|
+
touchedFiles: touchedFiles.map((file) => file.location),
|
|
131
|
+
warnings,
|
|
132
|
+
};
|
|
133
|
+
}
|
|
134
|
+
const updated = formatIsoDate(input.options.now?.() ?? new Date());
|
|
135
|
+
const nextRaw = existingRaw === undefined
|
|
136
|
+
? renderBootstrapManifest(additions, updated)
|
|
137
|
+
: extendManifest(existingRaw, existing, additions, updated);
|
|
138
|
+
parseFeatureMapManifest(nextRaw);
|
|
139
|
+
await persistManifest(manifestPath, nextRaw, deadline);
|
|
140
|
+
reportWarnings(input.options.logger, staleLocations, warnings);
|
|
141
|
+
return {
|
|
142
|
+
ok: true,
|
|
143
|
+
status: existingRaw === undefined ? 'created' : 'updated',
|
|
144
|
+
manifestPath,
|
|
145
|
+
added: additions,
|
|
146
|
+
staleLocations,
|
|
147
|
+
touchedFiles: touchedFiles.map((file) => file.location),
|
|
148
|
+
warnings,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
finally {
|
|
152
|
+
await releaseLock().catch((error) => {
|
|
153
|
+
safeWarn(input.options.logger, 'Could not release the feature map generation lock.', {
|
|
154
|
+
error: errorMessage(error),
|
|
155
|
+
manifestPath,
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
catch (error) {
|
|
161
|
+
const timedOut = error instanceof GenerationTimeoutError;
|
|
162
|
+
const message = errorMessage(error);
|
|
163
|
+
safeWarn(input.options.logger, timedOut ? 'Feature map generation timed out; dispatch may continue.' : 'Feature map generation failed; dispatch may continue.', { error: message, repoPath: input.repoPath });
|
|
164
|
+
return {
|
|
165
|
+
ok: false,
|
|
166
|
+
status: timedOut ? 'timed-out' : 'failed',
|
|
167
|
+
...baseResult,
|
|
168
|
+
error: message,
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
async function acquireManifestLock(manifestPath, deadline) {
|
|
173
|
+
await deadline.run(() => mkdir(dirname(manifestPath), { recursive: true }));
|
|
174
|
+
while (true) {
|
|
175
|
+
deadline.check();
|
|
176
|
+
try {
|
|
177
|
+
const release = await lockfile.lock(manifestPath, {
|
|
178
|
+
realpath: false,
|
|
179
|
+
stale: MANIFEST_LOCK_STALE_MS,
|
|
180
|
+
update: MANIFEST_LOCK_STALE_MS / 2,
|
|
181
|
+
retries: 0,
|
|
182
|
+
});
|
|
183
|
+
try {
|
|
184
|
+
deadline.check();
|
|
185
|
+
return release;
|
|
186
|
+
}
|
|
187
|
+
catch (error) {
|
|
188
|
+
await release().catch(() => undefined);
|
|
189
|
+
throw error;
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
if (!isLockUnavailableError(error))
|
|
194
|
+
throw error;
|
|
195
|
+
await deadline.pause(MANIFEST_LOCK_RETRY_MS);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** Parse through the shared validator consumed by the feature guardian and CI. */
|
|
200
|
+
export function parseFeatureMapManifest(raw) {
|
|
201
|
+
const validation = validateFeatureManifest(raw);
|
|
202
|
+
return {
|
|
203
|
+
version: validation.version,
|
|
204
|
+
categoryIds: validation.categoryIds,
|
|
205
|
+
categoryProcedures: validation.categoryProcedures,
|
|
206
|
+
features: validation.features.map(toFeatureMapFeature),
|
|
207
|
+
};
|
|
208
|
+
}
|
|
209
|
+
function toFeatureMapFeature(feature) {
|
|
210
|
+
return {
|
|
211
|
+
id: feature.id,
|
|
212
|
+
name: feature.name,
|
|
213
|
+
...(feature.cli ? { cli: feature.cli } : {}),
|
|
214
|
+
...(feature.api ? { api: feature.api } : {}),
|
|
215
|
+
description: feature.desc,
|
|
216
|
+
location: feature.location,
|
|
217
|
+
verifyTier: feature.tier,
|
|
218
|
+
criticality: feature.criticality,
|
|
219
|
+
};
|
|
220
|
+
}
|
|
221
|
+
function normalizeInput(inputOrRepoPath, positionalGlobs, positionalOptions) {
|
|
222
|
+
if (typeof inputOrRepoPath === 'string') {
|
|
223
|
+
return { repoPath: inputOrRepoPath, touchedFileGlobs: positionalGlobs, options: positionalOptions };
|
|
224
|
+
}
|
|
225
|
+
const { repoPath, touchedFileGlobs, ...options } = inputOrRepoPath;
|
|
226
|
+
return { repoPath, touchedFileGlobs, options };
|
|
227
|
+
}
|
|
228
|
+
function generationLimits(options) {
|
|
229
|
+
return {
|
|
230
|
+
timeoutMs: boundedInteger(options.timeoutMs, DEFAULT_TIMEOUT_MS, 0),
|
|
231
|
+
maxNewEntries: boundedInteger(options.maxNewEntries, DEFAULT_MAX_NEW_ENTRIES, 0),
|
|
232
|
+
maxManifestFeatures: boundedInteger(options.maxManifestFeatures, DEFAULT_MAX_MANIFEST_FEATURES, 0),
|
|
233
|
+
maxTouchedFiles: boundedInteger(options.maxTouchedFiles, DEFAULT_MAX_TOUCHED_FILES, 0),
|
|
234
|
+
maxScannedPaths: boundedInteger(options.maxScannedPaths, DEFAULT_MAX_SCANNED_PATHS, 0),
|
|
235
|
+
maxSourceBytes: boundedInteger(options.maxSourceBytes, DEFAULT_MAX_SOURCE_BYTES, 0),
|
|
236
|
+
maxManifestBytes: boundedInteger(options.maxManifestBytes, DEFAULT_MAX_MANIFEST_BYTES, 1),
|
|
237
|
+
maxStaleChecks: boundedInteger(options.maxStaleChecks, DEFAULT_MAX_STALE_CHECKS, 0),
|
|
238
|
+
};
|
|
239
|
+
}
|
|
240
|
+
function boundedInteger(value, fallback, minimum) {
|
|
241
|
+
return value === undefined || !Number.isSafeInteger(value) || value < minimum ? fallback : value;
|
|
242
|
+
}
|
|
243
|
+
async function readExistingManifest(manifestPath, maxBytes, deadline) {
|
|
244
|
+
try {
|
|
245
|
+
const metadata = await deadline.run(() => stat(manifestPath));
|
|
246
|
+
if (metadata.size > maxBytes) {
|
|
247
|
+
throw new Error(`Existing feature manifest exceeds the ${maxBytes}-byte read budget`);
|
|
248
|
+
}
|
|
249
|
+
return await deadline.run((signal) => readFile(manifestPath, { encoding: 'utf8', signal }));
|
|
250
|
+
}
|
|
251
|
+
catch (error) {
|
|
252
|
+
if (isMissingFileError(error))
|
|
253
|
+
return undefined;
|
|
254
|
+
throw error;
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
async function findStaleLocations(repoPath, features, maxChecks, warnings, deadline) {
|
|
258
|
+
const stale = [];
|
|
259
|
+
const locations = features.flatMap((feature) => splitLocations(feature.location).map((location) => ({
|
|
260
|
+
featureId: feature.id,
|
|
261
|
+
location,
|
|
262
|
+
})));
|
|
263
|
+
if (locations.length > maxChecks) {
|
|
264
|
+
warnings.push(`Stale-location checks capped at ${maxChecks}; ${locations.length - maxChecks} locations were not checked.`);
|
|
265
|
+
}
|
|
266
|
+
for (const entry of locations.slice(0, maxChecks)) {
|
|
267
|
+
deadline.check();
|
|
268
|
+
const absolutePath = resolve(repoPath, entry.location);
|
|
269
|
+
if (!pathIsWithin(repoPath, absolutePath)) {
|
|
270
|
+
stale.push({ ...entry, reason: 'outside-repo' });
|
|
271
|
+
continue;
|
|
272
|
+
}
|
|
273
|
+
try {
|
|
274
|
+
await deadline.run(() => stat(absolutePath));
|
|
275
|
+
}
|
|
276
|
+
catch (error) {
|
|
277
|
+
if (isMissingFileError(error))
|
|
278
|
+
stale.push({ ...entry, reason: 'missing' });
|
|
279
|
+
else
|
|
280
|
+
warnings.push(`Could not check feature location ${entry.location}: ${errorMessage(error)}`);
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
return stale;
|
|
284
|
+
}
|
|
285
|
+
async function resolveTouchedFiles(repoPath, rawPatterns, limits, warnings, deadline) {
|
|
286
|
+
const patterns = rawPatterns
|
|
287
|
+
.map((pattern) => normalizeTouchedPattern(repoPath, pattern))
|
|
288
|
+
.filter((pattern) => pattern !== undefined);
|
|
289
|
+
const negativeMatchers = patterns
|
|
290
|
+
.filter((pattern) => pattern.startsWith('!'))
|
|
291
|
+
.flatMap((pattern) => compileGlob(pattern.slice(1)));
|
|
292
|
+
const positivePatterns = patterns.filter((pattern) => !pattern.startsWith('!'));
|
|
293
|
+
const matches = new Map();
|
|
294
|
+
const directoryCache = new Map();
|
|
295
|
+
const scanBudget = { paths: 0, capped: false };
|
|
296
|
+
const traversalDepths = new Map();
|
|
297
|
+
for (const pattern of positivePatterns) {
|
|
298
|
+
if (!hasGlobMagic(pattern))
|
|
299
|
+
continue;
|
|
300
|
+
const base = globStaticBase(pattern);
|
|
301
|
+
const depth = globTraversalDepth(pattern, base);
|
|
302
|
+
traversalDepths.set(base, Math.max(traversalDepths.get(base) ?? 0, depth));
|
|
303
|
+
}
|
|
304
|
+
for (const pattern of positivePatterns) {
|
|
305
|
+
deadline.check();
|
|
306
|
+
if (matches.size >= limits.maxTouchedFiles)
|
|
307
|
+
break;
|
|
308
|
+
if (!hasGlobMagic(pattern)) {
|
|
309
|
+
const candidate = await exactCandidate(repoPath, pattern, warnings, deadline);
|
|
310
|
+
if (candidate && !negativeMatchers.some((matcher) => matcher.test(candidate.location))) {
|
|
311
|
+
matches.set(candidate.location, candidate);
|
|
312
|
+
}
|
|
313
|
+
continue;
|
|
314
|
+
}
|
|
315
|
+
const matchers = compileGlob(pattern);
|
|
316
|
+
const base = globStaticBase(pattern);
|
|
317
|
+
let files = directoryCache.get(base);
|
|
318
|
+
if (!files) {
|
|
319
|
+
files = await walkFiles(repoPath, base, traversalDepths.get(base) ?? Number.POSITIVE_INFINITY, limits.maxScannedPaths, scanBudget, warnings, deadline);
|
|
320
|
+
directoryCache.set(base, files);
|
|
321
|
+
}
|
|
322
|
+
for (const location of files) {
|
|
323
|
+
if (!matchers.some((matcher) => matcher.test(location)))
|
|
324
|
+
continue;
|
|
325
|
+
if (negativeMatchers.some((matcher) => matcher.test(location)))
|
|
326
|
+
continue;
|
|
327
|
+
matches.set(location, { absolutePath: join(repoPath, ...location.split('/')), location });
|
|
328
|
+
if (matches.size >= limits.maxTouchedFiles)
|
|
329
|
+
break;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
if (matches.size >= limits.maxTouchedFiles && positivePatterns.length > 0) {
|
|
333
|
+
warnings.push(`Touched-file discovery capped at ${limits.maxTouchedFiles} files.`);
|
|
334
|
+
}
|
|
335
|
+
return [...matches.values()].sort((left, right) => left.location.localeCompare(right.location));
|
|
336
|
+
}
|
|
337
|
+
function normalizeTouchedPattern(repoPath, rawPattern) {
|
|
338
|
+
const trimmed = rawPattern.trim();
|
|
339
|
+
if (!trimmed)
|
|
340
|
+
return undefined;
|
|
341
|
+
const negative = trimmed.startsWith('!');
|
|
342
|
+
const value = negative ? trimmed.slice(1) : trimmed;
|
|
343
|
+
const repoRelative = isAbsolute(value) ? relative(repoPath, value) : value;
|
|
344
|
+
const normalized = normalizeRelativePath(repoRelative);
|
|
345
|
+
if (!normalized || normalized === '.' || normalized === '..' || normalized.startsWith('../') || isAbsolute(normalized)) {
|
|
346
|
+
return undefined;
|
|
347
|
+
}
|
|
348
|
+
return negative ? `!${normalized}` : normalized;
|
|
349
|
+
}
|
|
350
|
+
async function exactCandidate(repoPath, location, warnings, deadline) {
|
|
351
|
+
const requestedPath = join(repoPath, ...location.split('/'));
|
|
352
|
+
try {
|
|
353
|
+
const actualPath = await deadline.run(() => realpath(requestedPath));
|
|
354
|
+
if (!pathIsWithin(repoPath, actualPath)) {
|
|
355
|
+
warnings.push(`Ignored touched path outside the target repository: ${location}`);
|
|
356
|
+
return undefined;
|
|
357
|
+
}
|
|
358
|
+
const metadata = await deadline.run(() => stat(actualPath));
|
|
359
|
+
if (!metadata.isFile()) {
|
|
360
|
+
warnings.push(`Ignored touched path that is not a file: ${location}`);
|
|
361
|
+
return undefined;
|
|
362
|
+
}
|
|
363
|
+
if (normalizeRelativePath(relative(repoPath, actualPath)) === FEATURE_MAP_MANIFEST_PATH)
|
|
364
|
+
return undefined;
|
|
365
|
+
return { absolutePath: actualPath, location: normalizeRelativePath(relative(repoPath, actualPath)) };
|
|
366
|
+
}
|
|
367
|
+
catch (error) {
|
|
368
|
+
if (isMissingFileError(error))
|
|
369
|
+
warnings.push(`Touched file no longer exists: ${location}`);
|
|
370
|
+
else
|
|
371
|
+
warnings.push(`Could not inspect touched file ${location}: ${errorMessage(error)}`);
|
|
372
|
+
return undefined;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
async function walkFiles(repoPath, base, maxDepth, maxScannedPaths, budget, warnings, deadline) {
|
|
376
|
+
const requestedBase = join(repoPath, ...base.split('/'));
|
|
377
|
+
let actualBase;
|
|
378
|
+
try {
|
|
379
|
+
actualBase = await deadline.run(() => realpath(requestedBase));
|
|
380
|
+
}
|
|
381
|
+
catch (error) {
|
|
382
|
+
if (isMissingFileError(error))
|
|
383
|
+
warnings.push(`Touched glob base does not exist: ${base}`);
|
|
384
|
+
else
|
|
385
|
+
warnings.push(`Could not inspect touched glob base ${base}: ${errorMessage(error)}`);
|
|
386
|
+
return [];
|
|
387
|
+
}
|
|
388
|
+
if (!pathIsWithin(repoPath, actualBase)) {
|
|
389
|
+
warnings.push(`Ignored touched glob base outside the target repository: ${base}`);
|
|
390
|
+
return [];
|
|
391
|
+
}
|
|
392
|
+
const files = [];
|
|
393
|
+
const queue = [{ directory: actualBase, depth: 0 }];
|
|
394
|
+
let truncatedDirectory = false;
|
|
395
|
+
while (queue.length > 0 && budget.paths < maxScannedPaths) {
|
|
396
|
+
deadline.check();
|
|
397
|
+
const { directory, depth } = queue.shift();
|
|
398
|
+
let entries;
|
|
399
|
+
try {
|
|
400
|
+
entries = await deadline.run(() => readdir(directory, { withFileTypes: true }));
|
|
401
|
+
}
|
|
402
|
+
catch (error) {
|
|
403
|
+
warnings.push(`Could not read touched glob directory ${normalizeRelativePath(relative(repoPath, directory))}: ${errorMessage(error)}`);
|
|
404
|
+
continue;
|
|
405
|
+
}
|
|
406
|
+
entries.sort((left, right) => left.name.localeCompare(right.name));
|
|
407
|
+
for (const entry of entries) {
|
|
408
|
+
if (budget.paths >= maxScannedPaths) {
|
|
409
|
+
truncatedDirectory = true;
|
|
410
|
+
break;
|
|
411
|
+
}
|
|
412
|
+
budget.paths += 1;
|
|
413
|
+
const absolutePath = join(directory, entry.name);
|
|
414
|
+
const location = normalizeRelativePath(relative(repoPath, absolutePath));
|
|
415
|
+
if (location === FEATURE_MAP_MANIFEST_PATH)
|
|
416
|
+
continue;
|
|
417
|
+
if (entry.isDirectory()) {
|
|
418
|
+
if (!SKIPPED_DIRECTORIES.has(entry.name) && depth + 1 < maxDepth) {
|
|
419
|
+
queue.push({ directory: absolutePath, depth: depth + 1 });
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
else if (entry.isFile()) {
|
|
423
|
+
files.push(location);
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
if ((queue.length > 0 || truncatedDirectory) && !budget.capped) {
|
|
428
|
+
budget.capped = true;
|
|
429
|
+
warnings.push(`Touched-glob traversal capped after inspecting ${maxScannedPaths} paths.`);
|
|
430
|
+
}
|
|
431
|
+
return files;
|
|
432
|
+
}
|
|
433
|
+
function globStaticBase(pattern) {
|
|
434
|
+
const segments = pattern.split('/');
|
|
435
|
+
const firstMagic = segments.findIndex(hasGlobMagic);
|
|
436
|
+
if (firstMagic <= 0)
|
|
437
|
+
return '.';
|
|
438
|
+
return segments.slice(0, firstMagic).join('/');
|
|
439
|
+
}
|
|
440
|
+
function globTraversalDepth(pattern, base) {
|
|
441
|
+
const baseSegments = base === '.' ? 0 : base.split('/').length;
|
|
442
|
+
const remainingSegments = pattern.split('/').slice(baseSegments);
|
|
443
|
+
return remainingSegments.some((segment) => segment.includes('**'))
|
|
444
|
+
? Number.POSITIVE_INFINITY
|
|
445
|
+
: remainingSegments.length;
|
|
446
|
+
}
|
|
447
|
+
function hasGlobMagic(value) {
|
|
448
|
+
return /[*?\[\]{}]/u.test(value);
|
|
449
|
+
}
|
|
450
|
+
function compileGlob(pattern) {
|
|
451
|
+
return expandBraces(pattern).map((expanded) => {
|
|
452
|
+
let source = '^';
|
|
453
|
+
for (let index = 0; index < expanded.length; index += 1) {
|
|
454
|
+
const character = expanded[index];
|
|
455
|
+
if (character === '*') {
|
|
456
|
+
if (expanded[index + 1] === '*') {
|
|
457
|
+
index += 1;
|
|
458
|
+
if (expanded[index + 1] === '/') {
|
|
459
|
+
index += 1;
|
|
460
|
+
source += '(?:.*/)?';
|
|
461
|
+
}
|
|
462
|
+
else {
|
|
463
|
+
source += '.*';
|
|
464
|
+
}
|
|
465
|
+
}
|
|
466
|
+
else {
|
|
467
|
+
source += '[^/]*';
|
|
468
|
+
}
|
|
469
|
+
}
|
|
470
|
+
else if (character === '?') {
|
|
471
|
+
source += '[^/]';
|
|
472
|
+
}
|
|
473
|
+
else if (character === '[') {
|
|
474
|
+
const end = expanded.indexOf(']', index + 1);
|
|
475
|
+
if (end === -1) {
|
|
476
|
+
source += '\\[';
|
|
477
|
+
}
|
|
478
|
+
else {
|
|
479
|
+
const rawClass = expanded.slice(index + 1, end);
|
|
480
|
+
const negated = rawClass.startsWith('!') ? `^${rawClass.slice(1)}` : rawClass;
|
|
481
|
+
source += `[${negated.replaceAll('\\', '\\\\')}]`;
|
|
482
|
+
index = end;
|
|
483
|
+
}
|
|
484
|
+
}
|
|
485
|
+
else {
|
|
486
|
+
source += escapeRegExp(character);
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
return new RegExp(`${source}$`, 'u');
|
|
490
|
+
});
|
|
491
|
+
}
|
|
492
|
+
function expandBraces(pattern) {
|
|
493
|
+
const open = pattern.indexOf('{');
|
|
494
|
+
if (open === -1)
|
|
495
|
+
return [pattern];
|
|
496
|
+
const close = pattern.indexOf('}', open + 1);
|
|
497
|
+
if (close === -1)
|
|
498
|
+
return [pattern];
|
|
499
|
+
const choices = pattern.slice(open + 1, close).split(',').filter(Boolean);
|
|
500
|
+
if (choices.length < 2 || choices.length > 16)
|
|
501
|
+
return [pattern];
|
|
502
|
+
return choices.flatMap((choice) => expandBraces(`${pattern.slice(0, open)}${choice}${pattern.slice(close + 1)}`)).slice(0, 32);
|
|
503
|
+
}
|
|
504
|
+
function escapeRegExp(character) {
|
|
505
|
+
return /[\\^$.*+?()[\]{}|]/u.test(character) ? `\\${character}` : character;
|
|
506
|
+
}
|
|
507
|
+
async function inferFeature(candidate, existingIds, maxSourceBytes, warnings, deadline) {
|
|
508
|
+
let source = '';
|
|
509
|
+
try {
|
|
510
|
+
const metadata = await deadline.run(() => stat(candidate.absolutePath));
|
|
511
|
+
if (metadata.size <= maxSourceBytes) {
|
|
512
|
+
source = await deadline.run((signal) => readFile(candidate.absolutePath, { encoding: 'utf8', signal }));
|
|
513
|
+
if (source.includes('\0'))
|
|
514
|
+
source = '';
|
|
515
|
+
}
|
|
516
|
+
else {
|
|
517
|
+
warnings.push(`Skipped source inspection for ${candidate.location}; file exceeds ${maxSourceBytes} bytes.`);
|
|
518
|
+
}
|
|
519
|
+
}
|
|
520
|
+
catch (error) {
|
|
521
|
+
if (error instanceof GenerationTimeoutError)
|
|
522
|
+
throw error;
|
|
523
|
+
warnings.push(`Could not read ${candidate.location}; using a path-derived feature: ${errorMessage(error)}`);
|
|
524
|
+
}
|
|
525
|
+
const surface = inferSurface(candidate.location, source);
|
|
526
|
+
const surfaceValue = surface.cli ?? surface.api ?? candidate.location;
|
|
527
|
+
const baseId = `generated-${slugify(surfaceValue) || slugify(candidate.location) || 'surface'}`;
|
|
528
|
+
const fingerprint = createHash('sha256').update(`${candidate.location}\0${surfaceValue}`).digest('hex').slice(0, 8);
|
|
529
|
+
let id = `${baseId}-${fingerprint}`;
|
|
530
|
+
let collision = 2;
|
|
531
|
+
while (existingIds.has(id)) {
|
|
532
|
+
id = `${baseId}-${fingerprint}-${collision}`;
|
|
533
|
+
collision += 1;
|
|
534
|
+
}
|
|
535
|
+
existingIds.add(id);
|
|
536
|
+
return {
|
|
537
|
+
id,
|
|
538
|
+
name: surface.name,
|
|
539
|
+
...(surface.cli ? { cli: surface.cli } : { api: surface.api ?? `module:${candidate.location}` }),
|
|
540
|
+
description: surface.description
|
|
541
|
+
?? `Represent the public behavior implemented in ${candidate.location}.`,
|
|
542
|
+
location: candidate.location,
|
|
543
|
+
verifyTier: inferVerifyTier(candidate.location, source, surface),
|
|
544
|
+
criticality: 'standard',
|
|
545
|
+
};
|
|
546
|
+
}
|
|
547
|
+
function inferSurface(location, source) {
|
|
548
|
+
const cli = firstMatch(source, [
|
|
549
|
+
/\.command\s*\(\s*['"`]([^'"`\r\n]+)['"`]/u,
|
|
550
|
+
/\badd_parser\s*\(\s*['"`]([^'"`\r\n]+)['"`]/u,
|
|
551
|
+
]);
|
|
552
|
+
if (cli) {
|
|
553
|
+
const command = cli.match[1].trim();
|
|
554
|
+
return surfaceWithComment({
|
|
555
|
+
name: `${titleCase(command.replace(/[<[].*$/u, '').trim() || command)} CLI Command`,
|
|
556
|
+
cli: command,
|
|
557
|
+
matchIndex: cli.index,
|
|
558
|
+
}, source, location);
|
|
559
|
+
}
|
|
560
|
+
const route = firstMatch(source, [
|
|
561
|
+
/\b(?:app|router|server)\s*\.\s*(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`\r\n]+)['"`]/iu,
|
|
562
|
+
/@(?:app|router)\s*\.\s*(get|post|put|patch|delete|options|head)\s*\(\s*['"`]([^'"`\r\n]+)['"`]/iu,
|
|
563
|
+
]);
|
|
564
|
+
if (route) {
|
|
565
|
+
const method = route.match[1].toUpperCase();
|
|
566
|
+
const path = route.match[2];
|
|
567
|
+
return surfaceWithComment({
|
|
568
|
+
name: `${method} ${path}`,
|
|
569
|
+
api: `${method} ${path}`,
|
|
570
|
+
matchIndex: route.index,
|
|
571
|
+
}, source, location);
|
|
572
|
+
}
|
|
573
|
+
const publicApi = firstMatch(source, [
|
|
574
|
+
/\bexport\s+(?:default\s+)?(?:async\s+)?function\s+([A-Za-z_$][\w$]*)/u,
|
|
575
|
+
/\bexport\s+(?:default\s+)?class\s+([A-Za-z_$][\w$]*)/u,
|
|
576
|
+
/\bexport\s+(?:const|let|var)\s+([A-Za-z_$][\w$]*)/u,
|
|
577
|
+
/\bpub\s+(?:async\s+)?fn\s+([A-Za-z_][\w]*)/u,
|
|
578
|
+
/^\s*(?:async\s+)?def\s+([A-Za-z][\w]*)\s*\(/mu,
|
|
579
|
+
/^\s*func\s+([A-Z][\w]*)\s*\(/mu,
|
|
580
|
+
/\bpublic\s+(?:static\s+)?(?:class|interface|[A-Za-z_$][\w$<>?,.[\] ]*)\s+([A-Za-z_$][\w$]*)\s*(?:\(|\{)/u,
|
|
581
|
+
/\bpublic\s+function\s+([A-Za-z_][\w]*)\s*\(/u,
|
|
582
|
+
/\b(?:module\.)?exports\.([A-Za-z_$][\w$]*)\s*=/u,
|
|
583
|
+
]);
|
|
584
|
+
if (publicApi) {
|
|
585
|
+
const identifier = publicApi.match[1];
|
|
586
|
+
const matchedText = publicApi.match[0];
|
|
587
|
+
const callable = /function|\bfn\b|\bdef\b|^\s*func|\($/u.test(matchedText);
|
|
588
|
+
return surfaceWithComment({
|
|
589
|
+
name: titleCase(identifier),
|
|
590
|
+
api: callable ? `${identifier}()` : identifier,
|
|
591
|
+
matchIndex: publicApi.index,
|
|
592
|
+
}, source, location);
|
|
593
|
+
}
|
|
594
|
+
const stem = location.split('/').pop()?.replace(/\.[^.]+$/u, '') ?? location;
|
|
595
|
+
return {
|
|
596
|
+
name: titleCase(stem),
|
|
597
|
+
api: `module:${location}`,
|
|
598
|
+
matchIndex: 0,
|
|
599
|
+
};
|
|
600
|
+
}
|
|
601
|
+
function firstMatch(source, expressions) {
|
|
602
|
+
let first;
|
|
603
|
+
for (const expression of expressions) {
|
|
604
|
+
const match = expression.exec(source);
|
|
605
|
+
if (match && (!first || match.index < first.index))
|
|
606
|
+
first = { match, index: match.index };
|
|
607
|
+
}
|
|
608
|
+
return first;
|
|
609
|
+
}
|
|
610
|
+
function surfaceWithComment(surface, source, location) {
|
|
611
|
+
const description = precedingComment(source, surface.matchIndex);
|
|
612
|
+
if (description)
|
|
613
|
+
return { ...surface, description };
|
|
614
|
+
if (surface.cli) {
|
|
615
|
+
return { ...surface, description: `Expose the ${surface.cli} command defined in ${location}.` };
|
|
616
|
+
}
|
|
617
|
+
if (surface.api?.match(/^(GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD) /u)) {
|
|
618
|
+
return { ...surface, description: `Handle ${surface.api} requests through ${location}.` };
|
|
619
|
+
}
|
|
620
|
+
return { ...surface, description: `Provide the ${surface.api} public API from ${location}.` };
|
|
621
|
+
}
|
|
622
|
+
function precedingComment(source, matchIndex) {
|
|
623
|
+
const prefix = source.slice(Math.max(0, matchIndex - 1_500), matchIndex);
|
|
624
|
+
const block = /\/\*\*?([\s\S]*?)\*\/\s*(?:@[A-Za-z_$][^\r\n]*\s*)*$/u.exec(prefix);
|
|
625
|
+
if (block)
|
|
626
|
+
return cleanComment(block[1]);
|
|
627
|
+
const lines = /(?:^|\r?\n)((?:(?:[ \t]*(?:\/\/|#)[^\r\n]*)(?:\r?\n|$))+)[ \t]*$/u.exec(prefix);
|
|
628
|
+
return lines ? cleanComment(lines[1]) : undefined;
|
|
629
|
+
}
|
|
630
|
+
function cleanComment(value) {
|
|
631
|
+
const cleaned = value
|
|
632
|
+
.split(/\r?\n/u)
|
|
633
|
+
.map((line) => line.replace(/^\s*(?:\*|\/\/|#)\s?/u, '').trim())
|
|
634
|
+
.filter((line) => line && !line.startsWith('@'))
|
|
635
|
+
.join(' ')
|
|
636
|
+
.replace(/\s+/gu, ' ')
|
|
637
|
+
.slice(0, 280)
|
|
638
|
+
.trim();
|
|
639
|
+
return cleaned || undefined;
|
|
640
|
+
}
|
|
641
|
+
function inferVerifyTier(location, source, surface) {
|
|
642
|
+
const signal = `${location}\n${source}`.toLowerCase();
|
|
643
|
+
if (/\bmanual(?:ly)?\b[\s\S]{0,40}\b(?:issue|pull request|pr)\b|\blive (?:issue|pull request|pr)\b/u.test(signal))
|
|
644
|
+
return 6;
|
|
645
|
+
if (/\bcloud auth\b|\bwritable (?:relayfile )?mount\b|\bcloud credentials?\b/u.test(signal))
|
|
646
|
+
return 5;
|
|
647
|
+
if (/\bfleet\b|\bbroker\b|\bdaemon\b|\bspawn(?:ing|ed)?\b|\bchild_process\b/u.test(signal))
|
|
648
|
+
return 4;
|
|
649
|
+
const isHttpRoute = /^(?:GET|POST|PUT|PATCH|DELETE|OPTIONS|HEAD)\s/u.test(surface.api ?? '');
|
|
650
|
+
const needsNetworkOrPersistence = /\bfetch\s*\(|\baxios\b|\bhttps?:\/\/|\bnetwork\b|\bdatabase\b|\bdb\b|\bpostgres\b|\bmysql\b|\bsqlite\b|\bredis\b|\boauth\b|\bauth(?:entication)?\b|\bauthori[sz]ation\b|\bcredentials?\b/u.test(signal);
|
|
651
|
+
if (isHttpRoute || needsNetworkOrPersistence)
|
|
652
|
+
return 3;
|
|
653
|
+
if (/\bprocess\.env\b|\bconfig(?:uration)?\b|\bwritefile\b|\breadfile\b|\bfilesystem\b|\bintegration\b/u.test(signal))
|
|
654
|
+
return 2;
|
|
655
|
+
return 1;
|
|
656
|
+
}
|
|
657
|
+
function renderBootstrapManifest(features, updated) {
|
|
658
|
+
const tiers = tierCounts(features);
|
|
659
|
+
return [
|
|
660
|
+
"version: '1.0'",
|
|
661
|
+
`updated: ${yamlScalar(updated)}`,
|
|
662
|
+
'catalog:',
|
|
663
|
+
' category_count: 1',
|
|
664
|
+
` feature_count: ${features.length}`,
|
|
665
|
+
' tier_counts:',
|
|
666
|
+
...[1, 2, 3, 4, 5, 6].map((tier) => ` ${tier}: ${tiers[tier]}`),
|
|
667
|
+
'',
|
|
668
|
+
'categories:',
|
|
669
|
+
` ${GENERATED_CATEGORY_ID}:`,
|
|
670
|
+
' name: Generated Touched Surfaces',
|
|
671
|
+
' description: Public surfaces discovered incrementally from files touched by dispatched work',
|
|
672
|
+
' criticality: standard',
|
|
673
|
+
' features:',
|
|
674
|
+
renderFeatureEntries(features, '\n').trimEnd(),
|
|
675
|
+
'',
|
|
676
|
+
].join('\n');
|
|
677
|
+
}
|
|
678
|
+
function extendManifest(raw, parsed, additions, updated) {
|
|
679
|
+
const eol = raw.includes('\r\n') ? '\r\n' : '\n';
|
|
680
|
+
const categoriesStart = /^categories:\s*$/mu.exec(raw);
|
|
681
|
+
if (!categoriesStart)
|
|
682
|
+
throw new Error('Feature manifest is missing categories');
|
|
683
|
+
const categoriesOffset = categoriesStart.index + categoriesStart[0].length;
|
|
684
|
+
const trailingRootKey = /^[^\s#][^:\r\n]*:\s*/gmu.exec(raw.slice(categoriesOffset));
|
|
685
|
+
const categoriesEnd = trailingRootKey
|
|
686
|
+
? categoriesOffset + trailingRootKey.index
|
|
687
|
+
: raw.length;
|
|
688
|
+
const categoryExpression = /^ (?:(?:"([^"]+)"|'([^']+)'|([^\s"':][^:\r\n]*))):\s*(?:#.*)?$/gmu;
|
|
689
|
+
const matches = [...raw.slice(categoriesOffset, categoriesEnd).matchAll(categoryExpression)]
|
|
690
|
+
.map((match) => ({
|
|
691
|
+
id: (match[1] ?? match[2] ?? match[3]).trim(),
|
|
692
|
+
index: categoriesOffset + (match.index ?? 0),
|
|
693
|
+
}));
|
|
694
|
+
const generatedIndex = matches.findIndex((match) => match.id === GENERATED_CATEGORY_ID);
|
|
695
|
+
let next = raw;
|
|
696
|
+
let addedCategory = false;
|
|
697
|
+
if (generatedIndex >= 0) {
|
|
698
|
+
const category = matches[generatedIndex];
|
|
699
|
+
const end = matches[generatedIndex + 1]?.index ?? categoriesEnd;
|
|
700
|
+
const section = raw.slice(category.index, end);
|
|
701
|
+
if (!/^ features:\s*$/mu.test(section)) {
|
|
702
|
+
throw new Error(`Generated category ${GENERATED_CATEGORY_ID} is missing features`);
|
|
703
|
+
}
|
|
704
|
+
const insertion = renderFeatureEntries(additions, eol);
|
|
705
|
+
const prefix = raw.slice(0, end);
|
|
706
|
+
next = `${prefix}${prefix.endsWith(eol) ? '' : eol}${insertion}${raw.slice(end)}`;
|
|
707
|
+
}
|
|
708
|
+
else {
|
|
709
|
+
if (parsed.categoryIds.includes(GENERATED_CATEGORY_ID)) {
|
|
710
|
+
throw new Error(`Could not safely locate generated category ${GENERATED_CATEGORY_ID}`);
|
|
711
|
+
}
|
|
712
|
+
addedCategory = true;
|
|
713
|
+
const block = [
|
|
714
|
+
` ${GENERATED_CATEGORY_ID}:`,
|
|
715
|
+
' name: Generated Touched Surfaces',
|
|
716
|
+
' description: Public surfaces discovered incrementally from files touched by dispatched work',
|
|
717
|
+
' criticality: standard',
|
|
718
|
+
' features:',
|
|
719
|
+
renderFeatureEntries(additions, eol).trimEnd(),
|
|
720
|
+
'',
|
|
721
|
+
].join(eol);
|
|
722
|
+
const prefix = raw.slice(0, categoriesEnd);
|
|
723
|
+
next = `${prefix}${prefix.endsWith(eol) ? '' : eol}${block}${raw.slice(categoriesEnd)}`;
|
|
724
|
+
if (parsed.version === '1.1') {
|
|
725
|
+
next = addGeneratedVerificationRoute(next, parsed.categoryProcedures, eol);
|
|
726
|
+
}
|
|
727
|
+
}
|
|
728
|
+
const allFeatures = [...parsed.features, ...additions];
|
|
729
|
+
const tiers = tierCounts(allFeatures);
|
|
730
|
+
next = replaceRequired(next, /^updated:\s*.*$/mu, `updated: ${yamlScalar(updated)}`, 'updated');
|
|
731
|
+
next = replaceRequired(next, /^ category_count:\s*\d+\s*$/mu, ` category_count: ${parsed.categoryIds.length + (addedCategory ? 1 : 0)}`, 'category_count');
|
|
732
|
+
next = replaceRequired(next, /^ feature_count:\s*\d+\s*$/mu, ` feature_count: ${allFeatures.length}`, 'feature_count');
|
|
733
|
+
for (let tier = 1; tier <= 6; tier += 1) {
|
|
734
|
+
next = replaceRequired(next, new RegExp(`^ ${tier}:\\s*\\d+\\s*$`, 'mu'), ` ${tier}: ${tiers[tier]}`, `tier ${tier}`);
|
|
735
|
+
}
|
|
736
|
+
return next;
|
|
737
|
+
}
|
|
738
|
+
/** Route a generated v1.1 category through an already-declared procedure. */
|
|
739
|
+
function addGeneratedVerificationRoute(raw, categoryProcedures, eol) {
|
|
740
|
+
const procedure = categoryProcedures['programmatic-api'] ??
|
|
741
|
+
Object.values(categoryProcedures).find((candidate) => candidate === 'public-api') ??
|
|
742
|
+
Object.values(categoryProcedures)[0];
|
|
743
|
+
if (!procedure) {
|
|
744
|
+
throw new Error('Manifest version 1.1 has no verification procedure available for generated features');
|
|
745
|
+
}
|
|
746
|
+
const categories = /^categories:\s*$/mu.exec(raw);
|
|
747
|
+
if (!categories)
|
|
748
|
+
throw new Error('Feature manifest is missing categories');
|
|
749
|
+
const prefix = raw.slice(0, categories.index);
|
|
750
|
+
if (!/^verification:\s*$/mu.test(prefix) || !/^ categories:\s*$/mu.test(prefix)) {
|
|
751
|
+
throw new Error('Manifest version 1.1 is missing verification routing');
|
|
752
|
+
}
|
|
753
|
+
if (new RegExp(`^ ${GENERATED_CATEGORY_ID}:`, 'mu').test(prefix)) {
|
|
754
|
+
throw new Error(`Manifest already routes generated category ${GENERATED_CATEGORY_ID}`);
|
|
755
|
+
}
|
|
756
|
+
const routedPrefix = prefix.replace(/\s*$/u, '');
|
|
757
|
+
return `${routedPrefix}${eol} ${GENERATED_CATEGORY_ID}: ${procedure}${eol}${eol}${raw.slice(categories.index)}`;
|
|
758
|
+
}
|
|
759
|
+
function renderFeatureEntries(features, eol) {
|
|
760
|
+
return features.map((feature) => [
|
|
761
|
+
` - id: ${yamlScalar(feature.id)}`,
|
|
762
|
+
` name: ${yamlScalar(feature.name)}`,
|
|
763
|
+
...(feature.cli ? [` cli: ${yamlScalar(feature.cli)}`] : []),
|
|
764
|
+
...(feature.api ? [` api: ${yamlScalar(feature.api)}`] : []),
|
|
765
|
+
` description: ${yamlScalar(feature.description)}`,
|
|
766
|
+
` location: ${yamlScalar(feature.location)}`,
|
|
767
|
+
` verify_tier: ${feature.verifyTier}`,
|
|
768
|
+
'',
|
|
769
|
+
].join(eol)).join('');
|
|
770
|
+
}
|
|
771
|
+
async function persistManifest(manifestPath, content, deadline) {
|
|
772
|
+
await deadline.run(() => mkdir(dirname(manifestPath), { recursive: true }));
|
|
773
|
+
const temporaryPath = `${manifestPath}.${process.pid}.${randomUUID()}.tmp`;
|
|
774
|
+
try {
|
|
775
|
+
await deadline.run((signal) => writeFile(temporaryPath, content, { encoding: 'utf8', flag: 'wx', signal }));
|
|
776
|
+
deadline.check();
|
|
777
|
+
await deadline.run(() => rename(temporaryPath, manifestPath));
|
|
778
|
+
}
|
|
779
|
+
finally {
|
|
780
|
+
await rm(temporaryPath, { force: true }).catch(() => undefined);
|
|
781
|
+
}
|
|
782
|
+
}
|
|
783
|
+
function replaceRequired(raw, expression, replacement, label) {
|
|
784
|
+
if (!expression.test(raw))
|
|
785
|
+
throw new Error(`Feature manifest is missing ${label}`);
|
|
786
|
+
expression.lastIndex = 0;
|
|
787
|
+
return raw.replace(expression, replacement);
|
|
788
|
+
}
|
|
789
|
+
function tierCounts(features) {
|
|
790
|
+
return Object.fromEntries([1, 2, 3, 4, 5, 6].map((tier) => [
|
|
791
|
+
tier,
|
|
792
|
+
features.filter((feature) => feature.verifyTier === tier).length,
|
|
793
|
+
]));
|
|
794
|
+
}
|
|
795
|
+
function yamlScalar(value) {
|
|
796
|
+
return JSON.stringify(value);
|
|
797
|
+
}
|
|
798
|
+
function splitLocations(value) {
|
|
799
|
+
return value.split(',').map((location) => location.trim()).filter(Boolean);
|
|
800
|
+
}
|
|
801
|
+
function normalizeRelativePath(value) {
|
|
802
|
+
const normalized = value.split(sep).join('/').replace(/^\.\//u, '');
|
|
803
|
+
return normalized || '.';
|
|
804
|
+
}
|
|
805
|
+
function pathIsWithin(root, candidate) {
|
|
806
|
+
const child = relative(root, candidate);
|
|
807
|
+
return child === '' || (!child.startsWith(`..${sep}`) && child !== '..' && !isAbsolute(child));
|
|
808
|
+
}
|
|
809
|
+
function slugify(value) {
|
|
810
|
+
return value
|
|
811
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1-$2')
|
|
812
|
+
.toLowerCase()
|
|
813
|
+
.replace(/[^a-z0-9]+/gu, '-')
|
|
814
|
+
.replace(/^-+|-+$/gu, '')
|
|
815
|
+
.slice(0, 64);
|
|
816
|
+
}
|
|
817
|
+
function titleCase(value) {
|
|
818
|
+
const words = value
|
|
819
|
+
.replace(/([a-z0-9])([A-Z])/gu, '$1 $2')
|
|
820
|
+
.replace(/[_./:-]+/gu, ' ')
|
|
821
|
+
.replace(/\s+/gu, ' ')
|
|
822
|
+
.trim();
|
|
823
|
+
return words ? words.replace(/\b\w/gu, (character) => character.toUpperCase()) : 'Touched Surface';
|
|
824
|
+
}
|
|
825
|
+
function formatIsoDate(value) {
|
|
826
|
+
if (Number.isNaN(value.getTime()))
|
|
827
|
+
throw new Error('Feature map generation received an invalid date');
|
|
828
|
+
return value.toISOString().slice(0, 10);
|
|
829
|
+
}
|
|
830
|
+
function reportWarnings(logger, staleLocations, warnings) {
|
|
831
|
+
if (staleLocations.length > 0) {
|
|
832
|
+
safeWarn(logger, 'Feature map contains stale locations; entries were preserved for drift review.', { staleLocations });
|
|
833
|
+
}
|
|
834
|
+
for (const warning of warnings)
|
|
835
|
+
safeWarn(logger, warning);
|
|
836
|
+
}
|
|
837
|
+
function safeWarn(logger, message, metadata) {
|
|
838
|
+
try {
|
|
839
|
+
logger?.warn?.(message, ...(metadata === undefined ? [] : [metadata]));
|
|
840
|
+
}
|
|
841
|
+
catch {
|
|
842
|
+
// Diagnostics must never turn optional feature-map generation into a dispatch failure.
|
|
843
|
+
}
|
|
844
|
+
}
|
|
845
|
+
function isMissingFileError(error) {
|
|
846
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ENOENT';
|
|
847
|
+
}
|
|
848
|
+
function isLockUnavailableError(error) {
|
|
849
|
+
return typeof error === 'object' && error !== null && 'code' in error && error.code === 'ELOCKED';
|
|
850
|
+
}
|
|
851
|
+
function errorMessage(error) {
|
|
852
|
+
return error instanceof Error ? error.message : String(error);
|
|
853
|
+
}
|
|
854
|
+
//# sourceMappingURL=generate.js.map
|