@cat-factory/orchestration 0.296.0 → 0.298.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/dist/container/dependencies.d.ts +12 -1
- package/dist/container/dependencies.d.ts.map +1 -1
- package/dist/container/environmentsModule.factory.d.ts.map +1 -1
- package/dist/container/environmentsModule.factory.js +3 -0
- package/dist/container/environmentsModule.factory.js.map +1 -1
- package/dist/modules/bootstrap/BootstrapService.d.ts.map +1 -1
- package/dist/modules/bootstrap/BootstrapService.js +17 -1
- package/dist/modules/bootstrap/BootstrapService.js.map +1 -1
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.d.ts.map +1 -1
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js +30 -4
- package/dist/modules/bootstrap/MonorepoAdoptionAdvisorService.js.map +1 -1
- package/dist/modules/bootstrap/MonorepoBootstrapController.d.ts.map +1 -1
- package/dist/modules/bootstrap/MonorepoBootstrapController.js +55 -20
- package/dist/modules/bootstrap/MonorepoBootstrapController.js.map +1 -1
- package/dist/modules/bootstrap/monorepoSurvey.d.ts +202 -18
- package/dist/modules/bootstrap/monorepoSurvey.d.ts.map +1 -1
- package/dist/modules/bootstrap/monorepoSurvey.js +641 -188
- package/dist/modules/bootstrap/monorepoSurvey.js.map +1 -1
- package/package.json +11 -11
|
@@ -1,26 +1,41 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { MAX_ADOPTION_READ_PATH, MAX_ADOPTION_READS } from '@cat-factory/contracts';
|
|
2
|
+
import { getErrorMessage, redactSecrets } from '@cat-factory/kernel';
|
|
2
3
|
// ---------------------------------------------------------------------------
|
|
3
|
-
//
|
|
4
|
-
//
|
|
4
|
+
// A monorepo bootstrap's SURVEY: what the two sides actually contain, read through the
|
|
5
|
+
// checkout-free `RepoFiles` port.
|
|
5
6
|
//
|
|
6
|
-
// The
|
|
7
|
-
//
|
|
8
|
-
//
|
|
9
|
-
//
|
|
7
|
+
// The read is in two halves, and the split is the whole design. The platform SEEDS an opening
|
|
8
|
+
// context (each side's root listing and the convention files it really holds, whichever CI
|
|
9
|
+
// declaration its provider uses, and the listing of every sibling that looks like a service),
|
|
10
|
+
// because there is no reason to spend model calls rediscovering `package.json` and it keeps the
|
|
11
|
+
// cheap case cheap. The MODEL then widens it: it asks for the CI workflow that will actually gate
|
|
12
|
+
// the pull request, follows a dependency into the shared package that says what adopting it
|
|
13
|
+
// entails, and opens a second and a third sibling when the first two disagree. None of that is
|
|
14
|
+
// enumerable in advance, which is why the previous declared list decided what the survey could
|
|
15
|
+
// not see before it looked.
|
|
10
16
|
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
17
|
+
// What the platform keeps is the BOOKKEEPING. Every read, seeded or model-chosen, is budgeted,
|
|
18
|
+
// scrubbed and appended to ONE transcript, and that transcript is what the plan carries, so a
|
|
19
|
+
// recommendation stays checkable against a record the model could not write
|
|
20
|
+
// (`parseAdoptionDecisions` drops a citation naming anything the transcript does not hold as
|
|
21
|
+
// READ). The bound is a call ceiling plus a character ceiling rather than a declared path list,
|
|
22
|
+
// so the cost of a survey still does not scale with the size of the monorepo it lands in.
|
|
23
|
+
//
|
|
24
|
+
// Every read that FAILS is recorded as unreadable rather than skipped, and one the platform
|
|
25
|
+
// declines is recorded as refused: a plan built without the monorepo's CI is materially weaker
|
|
26
|
+
// than one built with it, and only the transcript can say which of the two a reviewer has.
|
|
16
27
|
// ---------------------------------------------------------------------------
|
|
17
28
|
/**
|
|
18
29
|
* The root-level files that carry a repository's conventions, in priority order.
|
|
19
30
|
*
|
|
20
31
|
* Cross-ecosystem on purpose: the flow is not JS-specific, and a Go or JVM monorepo whose
|
|
21
|
-
* conventions this list cannot see would produce a
|
|
32
|
+
* conventions this list cannot see would produce a seed that silently found "nothing", the
|
|
22
33
|
* failure mode this whole module exists to avoid. Probed by INTERSECTION with a directory
|
|
23
34
|
* listing, so naming a file no repository has costs nothing.
|
|
35
|
+
*
|
|
36
|
+
* Still a declared list, and deliberately so: it is the OPENING context and the test a candidate
|
|
37
|
+
* directory has to pass to count as a service, not the boundary of what the survey can see.
|
|
38
|
+
* Anything it misses, the model can go and read.
|
|
24
39
|
*/
|
|
25
40
|
const CONVENTION_FILES = [
|
|
26
41
|
'package.json',
|
|
@@ -58,41 +73,156 @@ const CONVENTION_FILES = [
|
|
|
58
73
|
'CONTRIBUTING.md',
|
|
59
74
|
'README.md',
|
|
60
75
|
];
|
|
61
|
-
/** Directory listings probed on the monorepo root for its CI wiring. */
|
|
62
|
-
const CI_DIRECTORIES = ['.github/workflows', '.gitlab-ci.yml', '.circleci'];
|
|
63
76
|
/**
|
|
64
|
-
*
|
|
77
|
+
* The CI directories the seed LISTS (never reads), each gated on the root entry that holds it.
|
|
78
|
+
*
|
|
79
|
+
* Listing rather than reading is the correction the tool loop makes possible: a monorepo with
|
|
80
|
+
* reusable workflows plus thirty per-service ones used to contribute an arbitrary two, and what
|
|
81
|
+
* a new directory is actually REQUIRED to satisfy (a path filter, a required check) was likely
|
|
82
|
+
* in one of the twenty-eight nobody read. The listing is the menu; the model picks off it.
|
|
83
|
+
*
|
|
84
|
+
* Every provider the platform pushes to is named, for the same reason {@link CONVENTION_FILES}
|
|
85
|
+
* is cross-ecosystem: a GitLab-hosted monorepo has no `.github` at all, so a seed that knows
|
|
86
|
+
* only GitHub hands the model an opening context with NO CI in it and leaves the `ci` area with
|
|
87
|
+
* nothing citable on exactly the deployments this platform supports as first-class. Probed by
|
|
88
|
+
* intersection with the root listing, so naming a provider no repository here uses costs nothing.
|
|
89
|
+
*/
|
|
90
|
+
const CI_DIRECTORIES = [
|
|
91
|
+
{ rootEntry: '.github', path: '.github/workflows' },
|
|
92
|
+
{ rootEntry: '.circleci', path: '.circleci' },
|
|
93
|
+
];
|
|
94
|
+
/**
|
|
95
|
+
* CI declarations that are a single root FILE, so there is no directory to offer as a menu.
|
|
96
|
+
*
|
|
97
|
+
* Read outside the {@link MAX_ROOT_FILES} convention cap deliberately: this is the whole of what
|
|
98
|
+
* its provider says about CI, and losing it to fourteen manifests would leave the `ci` area
|
|
99
|
+
* unevidenced on a repository that states its pipeline perfectly clearly.
|
|
100
|
+
*/
|
|
101
|
+
const CI_FILES = ['.gitlab-ci.yml'];
|
|
102
|
+
/**
|
|
103
|
+
* How many sibling directories are probed as candidate worked examples.
|
|
65
104
|
*
|
|
66
|
-
*
|
|
67
|
-
*
|
|
68
|
-
*
|
|
69
|
-
* a
|
|
70
|
-
|
|
105
|
+
* Higher than the old pick-one probe because the seed no longer reads their files: a candidate
|
|
106
|
+
* costs one listing, and offering several is what makes a monorepo whose services DISAGREE
|
|
107
|
+
* representable at all. Dot-entries are excluded outright, and a candidate qualifies only by
|
|
108
|
+
* holding a convention file of its own.
|
|
109
|
+
*/
|
|
110
|
+
const MAX_SIBLING_CANDIDATES = 6;
|
|
111
|
+
/**
|
|
112
|
+
* Per-BODY content cap, on a directory listing as much as on a file.
|
|
113
|
+
*
|
|
114
|
+
* A convention is legible from its opening and a lockfile-sized read is not, and the same bound
|
|
115
|
+
* has to reach a listing: a generated or vendored directory with five thousand entries renders a
|
|
116
|
+
* body two budgets wide, which the exploration charge can only answer by refusing, latching
|
|
117
|
+
* `exhausted` and reporting a survey that spent almost nothing as one that ran out of content.
|
|
71
118
|
*/
|
|
72
|
-
const MAX_SIBLING_CANDIDATES = 4;
|
|
73
|
-
/** Per-file content cap. A convention is legible from its opening; a lockfile-sized read is not. */
|
|
74
119
|
const MAX_FILE_CHARS = 6_000;
|
|
75
|
-
/** How many root files one side contributes, most-conventional first. */
|
|
120
|
+
/** How many root files one side contributes to the opening context, most-conventional first. */
|
|
76
121
|
const MAX_ROOT_FILES = 14;
|
|
77
|
-
/**
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
122
|
+
/**
|
|
123
|
+
* The opening context's TOTAL character budget, split into an equal reservation per side.
|
|
124
|
+
*
|
|
125
|
+
* Spent in key order it would not be a bound at all but a handover to whichever side sorts
|
|
126
|
+
* first, and `monorepo:` sorts before `template:` for every key: a large monorepo would spend
|
|
127
|
+
* the whole allowance and the template would land entirely refused, which is exactly the
|
|
128
|
+
* crowding-out this exists to prevent. So a run with no template reserves all of it for the
|
|
129
|
+
* monorepo and a run with one gives each side half, spent in its own priority order; whatever a
|
|
130
|
+
* side leaves unspent carries to the next, which makes the reservation a floor rather than a cap.
|
|
131
|
+
*/
|
|
132
|
+
const MAX_SEED_CHARS = 36_000;
|
|
133
|
+
/** How many reads the MODEL may ask for. The loop's hard ceiling; see {@link AdoptionExploration}. */
|
|
134
|
+
const MAX_EXPLORATION_CALLS = 24;
|
|
135
|
+
/** How many characters the model's own reads may spend, on top of the seed's reservation. */
|
|
136
|
+
const MAX_EXPLORATION_CHARS = 54_000;
|
|
137
|
+
const DEFAULT_LIMITS = {
|
|
138
|
+
maxExplorationCalls: MAX_EXPLORATION_CALLS,
|
|
139
|
+
maxExplorationChars: MAX_EXPLORATION_CHARS,
|
|
140
|
+
maxSeedChars: MAX_SEED_CHARS,
|
|
141
|
+
maxFileChars: MAX_FILE_CHARS,
|
|
142
|
+
};
|
|
81
143
|
/** The parent directory a new service's siblings live in, or `''` for a root-level service. */
|
|
82
144
|
export function parentDirectoryOf(directory) {
|
|
83
145
|
const segments = directory.split('/').filter(Boolean);
|
|
84
146
|
return segments.slice(0, -1).join('/');
|
|
85
147
|
}
|
|
148
|
+
/**
|
|
149
|
+
* The longest raw path a model may ask for.
|
|
150
|
+
*
|
|
151
|
+
* Derived from the contract rather than restated: what the transcript records is the PREFIXED
|
|
152
|
+
* key, so the longest side name and a listing's trailing slash both have to fit inside
|
|
153
|
+
* {@link MAX_ADOPTION_READ_PATH} beside the path itself. Restating 400 here emitted a row the
|
|
154
|
+
* schema calls too long for any path over 390.
|
|
155
|
+
*/
|
|
156
|
+
const MAX_SURVEY_PATH = MAX_ADOPTION_READ_PATH - 'template:'.length - 1;
|
|
157
|
+
/**
|
|
158
|
+
* A repository-relative path the platform is willing to fetch, or the reason it will not.
|
|
159
|
+
*
|
|
160
|
+
* The path is MODEL-AUTHORED and is interpolated into the VCS contents API's URL, so it is
|
|
161
|
+
* validated for magic rather than only for traversal, and both halves of that bite:
|
|
162
|
+
*
|
|
163
|
+
* - a control character or a backslash means the model is guessing at a shell or a Windows
|
|
164
|
+
* path, and answering "not found" would tell it the repository lacks a file it never actually
|
|
165
|
+
* asked for;
|
|
166
|
+
* - `?`, `#` and `%` are the URL's own syntax, and the caller appends its `?ref=` AFTER this
|
|
167
|
+
* path. A `#` truncates the request to a DIFFERENT file while the transcript records the whole
|
|
168
|
+
* string as read, so a citation lands on the plan pointing at a path no reviewer can open; a
|
|
169
|
+
* `?ref=` of the model's own is honoured over the branch the survey believes it is reading;
|
|
170
|
+
* and a percent escape puts the traversal check below on the wrong side of the decoding the
|
|
171
|
+
* host, not this process, performs.
|
|
172
|
+
*
|
|
173
|
+
* A refusal is REPORTED (it lands on the transcript and the model is told), never a silent
|
|
174
|
+
* shortening.
|
|
175
|
+
*/
|
|
176
|
+
export function normalizeSurveyPath(raw) {
|
|
177
|
+
const trimmed = raw.trim().replace(/^\.\//, '').replace(/^\/+/, '').replace(/\/+$/, '');
|
|
178
|
+
if (trimmed.length > MAX_SURVEY_PATH) {
|
|
179
|
+
return { refused: 'the path is too long to be a repository path' };
|
|
180
|
+
}
|
|
181
|
+
if (trimmed.includes('\\') || [...trimmed].some((ch) => ch.charCodeAt(0) < 0x20)) {
|
|
182
|
+
return { refused: 'the path contains characters that are not part of a repository path' };
|
|
183
|
+
}
|
|
184
|
+
if (/[?#%]/.test(trimmed)) {
|
|
185
|
+
return {
|
|
186
|
+
refused: 'the path contains URL syntax (? # or %), which is not part of a repository path',
|
|
187
|
+
};
|
|
188
|
+
}
|
|
189
|
+
const segments = trimmed.split('/');
|
|
190
|
+
if (segments.some((segment) => segment === '..')) {
|
|
191
|
+
return { refused: 'the path leaves the repository; give a path relative to its root' };
|
|
192
|
+
}
|
|
193
|
+
return { path: segments.filter((segment) => segment !== '.').join('/') };
|
|
194
|
+
}
|
|
195
|
+
/** The citable key one read produces: `<side>:<path>`, with a trailing `/` marking a listing. */
|
|
196
|
+
function keyFor(side, kind, path) {
|
|
197
|
+
if (kind === 'read')
|
|
198
|
+
return `${side}:${path}`;
|
|
199
|
+
// `./` for the root, so a listing always reads as one and no key ends in a bare colon.
|
|
200
|
+
return `${side}:${path ? `${path}/` : './'}`;
|
|
201
|
+
}
|
|
86
202
|
/** Clip one file's content to the per-file cap, stating the clip rather than hiding it. */
|
|
87
|
-
function clip(content) {
|
|
88
|
-
if (content.length <=
|
|
203
|
+
function clip(content, max) {
|
|
204
|
+
if (content.length <= max)
|
|
89
205
|
return content;
|
|
90
|
-
return `${content.slice(0,
|
|
206
|
+
return `${content.slice(0, max)}\n…[truncated: ${content.length - max} more characters not shown]`;
|
|
207
|
+
}
|
|
208
|
+
/**
|
|
209
|
+
* Render a directory's entries as the body a decision can cite for layout, under the same
|
|
210
|
+
* per-body cap a file read answers to.
|
|
211
|
+
*
|
|
212
|
+
* Uncapped, one listing of a generated directory could be wider than the whole exploration
|
|
213
|
+
* budget, and the only answer a charge has to a body it cannot fit is to refuse it and latch
|
|
214
|
+
* `exhausted`, reporting a content budget that ran out when nothing had been spent.
|
|
215
|
+
*/
|
|
216
|
+
function renderListing(entries, max) {
|
|
217
|
+
return clip(entries
|
|
218
|
+
.map((entry) => `${entry.name}${entry.type === 'dir' ? '/' : ''}`)
|
|
219
|
+
.sort()
|
|
220
|
+
.join('\n'), max);
|
|
91
221
|
}
|
|
92
222
|
/**
|
|
93
|
-
* One side's reader
|
|
223
|
+
* One side's raw reader: performs the IO, hands bodies and failures to the ledger.
|
|
94
224
|
*
|
|
95
|
-
* `unreadable` is the load-bearing
|
|
225
|
+
* `unreadable` is the load-bearing distinction. A `getFile` that THROWS is a provider failure (a
|
|
96
226
|
* revoked token, a rate limit, an outage), and it is not the same fact as the file being absent
|
|
97
227
|
* (which `getFile` reports as `null`). Collapsing the two would let a survey blinded by an
|
|
98
228
|
* expired installation token present itself as a monorepo with no conventions.
|
|
@@ -100,190 +230,513 @@ function clip(content) {
|
|
|
100
230
|
class SideReader {
|
|
101
231
|
side;
|
|
102
232
|
prefix;
|
|
103
|
-
|
|
104
|
-
read = [];
|
|
105
|
-
unreadable = [];
|
|
106
|
-
contents = {};
|
|
107
|
-
constructor(side, prefix, logger) {
|
|
233
|
+
constructor(side, prefix) {
|
|
108
234
|
this.side = side;
|
|
109
235
|
this.prefix = prefix;
|
|
110
|
-
this.logger = logger;
|
|
111
236
|
}
|
|
112
|
-
/** Entry names of a directory, or
|
|
237
|
+
/** Entry names of a directory, or the failure that stopped the listing. */
|
|
113
238
|
async list(path) {
|
|
114
239
|
try {
|
|
115
|
-
return await this.side.files.listDirectory(path, this.side.gitRef);
|
|
240
|
+
return { entries: await this.side.files.listDirectory(path, this.side.gitRef) };
|
|
116
241
|
}
|
|
117
242
|
catch (error) {
|
|
118
|
-
|
|
119
|
-
this.logger?.warn('monorepo survey: directory listing failed', {
|
|
120
|
-
side: this.prefix,
|
|
121
|
-
path,
|
|
122
|
-
err: getErrorMessage(error),
|
|
123
|
-
});
|
|
124
|
-
return null;
|
|
243
|
+
return { failed: getErrorMessage(error) };
|
|
125
244
|
}
|
|
126
245
|
}
|
|
127
|
-
/**
|
|
128
|
-
async
|
|
246
|
+
/** One file's content, `null` when it is simply absent, or the failure that stopped the read. */
|
|
247
|
+
async read(path) {
|
|
129
248
|
try {
|
|
130
249
|
const file = await this.side.files.getFile(path, this.side.gitRef);
|
|
131
|
-
|
|
132
|
-
return;
|
|
133
|
-
this.read.push(path);
|
|
134
|
-
this.contents[`${this.prefix}:${path}`] = clip(file.content);
|
|
250
|
+
return { content: file ? file.content : null };
|
|
135
251
|
}
|
|
136
252
|
catch (error) {
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
253
|
+
return { failed: getErrorMessage(error) };
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
/**
|
|
258
|
+
* A monorepo survey in progress: the transcript, the budgets, and the reader the model widens it
|
|
259
|
+
* through.
|
|
260
|
+
*
|
|
261
|
+
* Created before the model is asked anything, seeded once, then handed to the advisor. The
|
|
262
|
+
* caller re-reads {@link survey} AFTER the advisor returns, because the transcript is what the
|
|
263
|
+
* model actually fetched rather than what the platform predicted it would need.
|
|
264
|
+
*/
|
|
265
|
+
export class MonorepoSurveySession {
|
|
266
|
+
request;
|
|
267
|
+
limits;
|
|
268
|
+
readers;
|
|
269
|
+
log;
|
|
270
|
+
reads = [];
|
|
271
|
+
bodies = {};
|
|
272
|
+
/**
|
|
273
|
+
* Seed bodies that were FETCHED but did not fit the opening context's reservation.
|
|
274
|
+
*
|
|
275
|
+
* Held rather than dropped: the prompt names them and tells the model to ask, so throwing the
|
|
276
|
+
* bytes away buys a second contents-API round trip for content this process is already
|
|
277
|
+
* holding. Kept OUT of {@link bodies} so nothing is citable before it has actually been served,
|
|
278
|
+
* and out of {@link seedKeys} so it stays out of the opening prompt.
|
|
279
|
+
*/
|
|
280
|
+
withheld = {};
|
|
281
|
+
seedKeys = new Set();
|
|
282
|
+
siblings = [];
|
|
283
|
+
calls = 0;
|
|
284
|
+
explorationChars = 0;
|
|
285
|
+
recordsDropped = 0;
|
|
286
|
+
exhausted = null;
|
|
287
|
+
constructor(request) {
|
|
288
|
+
this.request = request;
|
|
289
|
+
this.limits = { ...DEFAULT_LIMITS, ...request.limits };
|
|
290
|
+
this.log = request.logger;
|
|
291
|
+
this.readers = {
|
|
292
|
+
monorepo: new SideReader(request.monorepo, 'monorepo'),
|
|
293
|
+
...(request.template ? { template: new SideReader(request.template, 'template') } : {}),
|
|
294
|
+
};
|
|
295
|
+
}
|
|
296
|
+
get sides() {
|
|
297
|
+
return Object.keys(this.readers);
|
|
298
|
+
}
|
|
299
|
+
/** The transcript, the siblings offered, and what the exploration spent, as of right now. */
|
|
300
|
+
survey() {
|
|
301
|
+
return {
|
|
302
|
+
reads: [...this.reads],
|
|
303
|
+
siblingServices: [...this.siblings],
|
|
304
|
+
exploration: {
|
|
305
|
+
calls: this.calls,
|
|
306
|
+
maxCalls: this.limits.maxExplorationCalls,
|
|
307
|
+
chars: this.explorationChars,
|
|
308
|
+
maxChars: this.limits.maxExplorationChars,
|
|
309
|
+
exhausted: this.exhausted,
|
|
310
|
+
recordsDropped: this.recordsDropped,
|
|
311
|
+
},
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* The bodies rendered into the model's OPENING prompt.
|
|
316
|
+
*
|
|
317
|
+
* Only the seeded ones: everything the model fetches afterwards reaches it as that tool call's
|
|
318
|
+
* own result, and folding those into the prompt too would send each body twice.
|
|
319
|
+
*/
|
|
320
|
+
seedFiles() {
|
|
321
|
+
const seeded = {};
|
|
322
|
+
for (const key of this.seedKeys) {
|
|
323
|
+
const body = this.bodies[key];
|
|
324
|
+
if (body !== undefined)
|
|
325
|
+
seeded[key] = body;
|
|
326
|
+
}
|
|
327
|
+
return seeded;
|
|
328
|
+
}
|
|
329
|
+
/**
|
|
330
|
+
* Record something the platform could not even attempt, so it does not read as an absence.
|
|
331
|
+
*
|
|
332
|
+
* The one caller is a reference template the workspace has not LINKED: unreadable from here
|
|
333
|
+
* even though the apply phase's container can still clone it. "The template ships nothing for
|
|
334
|
+
* this area" and "nobody looked at the template" lead a reviewer to opposite conclusions.
|
|
335
|
+
*/
|
|
336
|
+
noteUnavailable(side, path, note) {
|
|
337
|
+
this.record({ path: `${side}:${path}`, origin: 'seed', outcome: 'unreadable', chars: 0, note });
|
|
338
|
+
}
|
|
339
|
+
/**
|
|
340
|
+
* Read the opening context: each side's root, the CI directory, and every sibling that looks
|
|
341
|
+
* like a service.
|
|
342
|
+
*
|
|
343
|
+
* The sibling probe is the read no root file can stand in for, and it is a LIST rather than a
|
|
344
|
+
* pick. One sibling is a sample of size one, so a monorepo with a six-year-old Java service
|
|
345
|
+
* beside three new TypeScript ones has no single house convention, and naming whichever
|
|
346
|
+
* directory sorted first reports a disagreement as though it were the answer. Dot-entries are
|
|
347
|
+
* excluded (`.github` sorts below every letter, so an alphabetical pick landed on a workflows
|
|
348
|
+
* folder for any root-level target) and a candidate qualifies only by holding a convention file
|
|
349
|
+
* of its own.
|
|
350
|
+
*/
|
|
351
|
+
async seed() {
|
|
352
|
+
const harvests = [];
|
|
353
|
+
const mono = this.readers.monorepo;
|
|
354
|
+
if (mono)
|
|
355
|
+
harvests.push({ reader: mono, harvest: await this.seedMonorepo(mono) });
|
|
356
|
+
const template = this.readers.template;
|
|
357
|
+
if (template)
|
|
358
|
+
harvests.push({ reader: template, harvest: await this.seedTemplate(template) });
|
|
359
|
+
this.commitSeed(harvests.map((entry) => entry.harvest));
|
|
360
|
+
}
|
|
361
|
+
async seedMonorepo(reader) {
|
|
362
|
+
const candidates = [];
|
|
363
|
+
const root = await this.seedRoot(reader, candidates);
|
|
364
|
+
const parent = parentDirectoryOf(this.request.directory);
|
|
365
|
+
// Re-uses the root listing for a root-level target rather than asking for it twice. The
|
|
366
|
+
// parent's own listing contributes no citable entry (the empty candidate sink): it is the
|
|
367
|
+
// MENU the sibling probe reads, and each qualifying sibling's listing carries the layout
|
|
368
|
+
// evidence. A failure to list it is still recorded, because "no siblings" and "could not see
|
|
369
|
+
// whether there are siblings" are opposite facts.
|
|
370
|
+
const siblingEntries = parent === '' ? root : await this.listInto(reader, parent, []);
|
|
371
|
+
const siblings = await this.probeSiblings(reader, siblingEntries, candidates);
|
|
372
|
+
return { candidates, siblings };
|
|
373
|
+
}
|
|
374
|
+
async seedTemplate(reader) {
|
|
375
|
+
const candidates = [];
|
|
376
|
+
await this.seedRoot(reader, candidates);
|
|
377
|
+
return { candidates, siblings: [] };
|
|
378
|
+
}
|
|
379
|
+
/**
|
|
380
|
+
* What BOTH sides contribute: the root listing, the conventions it holds, and its CI.
|
|
381
|
+
*
|
|
382
|
+
* Shared rather than the monorepo's alone, because `ci` is a decision BETWEEN the two sides and
|
|
383
|
+
* a seed that evidences only one of them biases it in the direction the prompt spends a
|
|
384
|
+
* paragraph forbidding: an area nothing was read about is not an area the other side wins.
|
|
385
|
+
*/
|
|
386
|
+
async seedRoot(reader, candidates) {
|
|
387
|
+
const root = await this.listInto(reader, '', candidates);
|
|
388
|
+
if (!root)
|
|
389
|
+
return null;
|
|
390
|
+
await this.takeRootFiles(reader, root, candidates);
|
|
391
|
+
for (const ci of CI_DIRECTORIES) {
|
|
392
|
+
if (root.some((entry) => entry.name === ci.rootEntry)) {
|
|
393
|
+
await this.listInto(reader, ci.path, candidates);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
return root;
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* List a directory, recording the listing as a citable seed entry.
|
|
400
|
+
*
|
|
401
|
+
* The listing costs no extra request (it is the one the seed already needed) and it is the only
|
|
402
|
+
* evidence either side offers about source layout and module structure: no root manifest states
|
|
403
|
+
* where a service puts its code, its tests or its entry point. Without it, a `source-layout`
|
|
404
|
+
* recommendation cites nothing and is dropped upstream as invention, so `template` was the only
|
|
405
|
+
* answer the model could legitimately give for that area on every monorepo.
|
|
406
|
+
*/
|
|
407
|
+
async listInto(reader, path, candidates) {
|
|
408
|
+
const key = keyFor(reader.prefix, 'list', path);
|
|
409
|
+
const result = await reader.list(path);
|
|
410
|
+
if ('failed' in result) {
|
|
411
|
+
this.noteListingFailure(reader, path, result.failed);
|
|
412
|
+
return null;
|
|
413
|
+
}
|
|
414
|
+
if (result.entries.length === 0) {
|
|
415
|
+
this.record({
|
|
416
|
+
path: key,
|
|
417
|
+
origin: 'seed',
|
|
418
|
+
outcome: 'absent',
|
|
419
|
+
chars: 0,
|
|
420
|
+
note: 'the directory is empty or does not exist',
|
|
142
421
|
});
|
|
422
|
+
return result.entries;
|
|
143
423
|
}
|
|
424
|
+
candidates.push({ key, body: renderListing(result.entries, this.limits.maxFileChars) });
|
|
425
|
+
return result.entries;
|
|
144
426
|
}
|
|
145
427
|
/**
|
|
146
|
-
*
|
|
428
|
+
* A seed listing that FAILED: warned and recorded as unreadable, never skipped.
|
|
147
429
|
*
|
|
148
|
-
*
|
|
149
|
-
*
|
|
150
|
-
*
|
|
151
|
-
*
|
|
152
|
-
*
|
|
153
|
-
* about. Recorded under the directory path with a trailing slash, so the key a decision cites
|
|
154
|
-
* is visibly a listing rather than a file.
|
|
430
|
+
* Shared with the sibling probe, which is the read where skipping costs the most. "No sibling
|
|
431
|
+
* service" is the strongest claim the opening context makes about a monorepo (it tells the
|
|
432
|
+
* model there is no worked example and it tells the reviewer the survey saw root conventions
|
|
433
|
+
* only), so a probe blinded by a revoked token or a rate limit has to say so rather than
|
|
434
|
+
* produce the sentence a genuinely flat repository produces.
|
|
155
435
|
*/
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
this.
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
436
|
+
noteListingFailure(reader, path, cause) {
|
|
437
|
+
this.log?.warn('monorepo survey: directory listing failed', {
|
|
438
|
+
side: reader.prefix,
|
|
439
|
+
path,
|
|
440
|
+
err: cause,
|
|
441
|
+
});
|
|
442
|
+
this.record({
|
|
443
|
+
path: keyFor(reader.prefix, 'list', path),
|
|
444
|
+
origin: 'seed',
|
|
445
|
+
outcome: 'unreadable',
|
|
446
|
+
chars: 0,
|
|
447
|
+
note: cause,
|
|
448
|
+
});
|
|
166
449
|
}
|
|
167
450
|
/**
|
|
168
|
-
*
|
|
169
|
-
*
|
|
170
|
-
*
|
|
451
|
+
* The root files one side contributes: its conventions, capped in priority order, plus any
|
|
452
|
+
* single-file CI declaration.
|
|
453
|
+
*
|
|
454
|
+
* The two lists are read together but capped apart. A CI file competing for the convention cap
|
|
455
|
+
* would be crowded out by fourteen manifests on exactly the repositories whose CI is a single
|
|
456
|
+
* file, which is the `ci` area losing its only evidence to a tie-break nobody chose.
|
|
171
457
|
*/
|
|
172
|
-
async
|
|
458
|
+
async takeRootFiles(reader, entries, candidates) {
|
|
173
459
|
const present = new Set(entries.filter((entry) => entry.type === 'file').map((entry) => entry.name));
|
|
174
|
-
|
|
175
|
-
|
|
460
|
+
await this.takeFiles(reader, '', [
|
|
461
|
+
...CONVENTION_FILES.filter((name) => present.has(name)).slice(0, MAX_ROOT_FILES),
|
|
462
|
+
...CI_FILES.filter((name) => present.has(name)),
|
|
463
|
+
], candidates);
|
|
176
464
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
465
|
+
/** Read a named set of files a directory actually holds, in the order they were named. */
|
|
466
|
+
async takeFiles(reader, dir, wanted, candidates) {
|
|
467
|
+
// One fixed fan-out: the set is bounded and declared, so this never grows with the repository.
|
|
468
|
+
const results = await Promise.all(wanted.map(async (name) => {
|
|
469
|
+
const path = dir ? `${dir}/${name}` : name;
|
|
470
|
+
return { path, result: await reader.read(path) };
|
|
471
|
+
}));
|
|
472
|
+
for (const { path, result } of results) {
|
|
473
|
+
const key = keyFor(reader.prefix, 'read', path);
|
|
474
|
+
if ('failed' in result) {
|
|
475
|
+
this.log?.warn('monorepo survey: file read failed', {
|
|
476
|
+
side: reader.prefix,
|
|
477
|
+
path,
|
|
478
|
+
err: result.failed,
|
|
479
|
+
});
|
|
480
|
+
this.record({
|
|
481
|
+
path: key,
|
|
482
|
+
origin: 'seed',
|
|
483
|
+
outcome: 'unreadable',
|
|
484
|
+
chars: 0,
|
|
485
|
+
note: result.failed,
|
|
486
|
+
});
|
|
487
|
+
continue;
|
|
488
|
+
}
|
|
489
|
+
// Absent is silent here: the set was intersected with a real listing, so a null means the
|
|
490
|
+
// entry vanished between the two calls, which says nothing a reviewer needs.
|
|
491
|
+
if (result.content === null)
|
|
492
|
+
continue;
|
|
493
|
+
candidates.push({ key, body: clip(this.scrub(result.content), this.limits.maxFileChars) });
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
/**
|
|
497
|
+
* List every plausible sibling service, keeping the ones that hold a convention file.
|
|
498
|
+
*
|
|
499
|
+
* Excludes the target itself and every dot-entry (which is also what keeps the CI folder out,
|
|
500
|
+
* since a listing's entries are one level deep): a directory that says nothing about how a
|
|
501
|
+
* service here is built is worse than no example, because "no sibling" is a fact the plan
|
|
502
|
+
* REPORTS while a bad sibling is one it asserts.
|
|
503
|
+
*
|
|
504
|
+
* One bounded fan-out rather than a loop of awaits, the shape {@link takeFiles} already uses:
|
|
505
|
+
* the candidate set is capped above, so six sequential round trips to the VCS host sat in the
|
|
506
|
+
* opening context's critical path for nothing.
|
|
507
|
+
*/
|
|
508
|
+
async probeSiblings(reader, entries, candidates) {
|
|
509
|
+
if (!entries)
|
|
510
|
+
return [];
|
|
511
|
+
const target = this.request.directory;
|
|
512
|
+
const probes = entries
|
|
513
|
+
.filter((entry) => entry.type === 'dir' && entry.path !== target && !entry.name.startsWith('.'))
|
|
514
|
+
.map((entry) => entry.path)
|
|
515
|
+
.sort()
|
|
516
|
+
.slice(0, MAX_SIBLING_CANDIDATES);
|
|
517
|
+
const listings = await Promise.all(probes.map(async (path) => ({ path, result: await reader.list(path) })));
|
|
518
|
+
const qualifying = [];
|
|
519
|
+
for (const { path, result } of listings) {
|
|
520
|
+
if ('failed' in result) {
|
|
521
|
+
this.noteListingFailure(reader, path, result.failed);
|
|
522
|
+
continue;
|
|
523
|
+
}
|
|
524
|
+
const names = new Set(result.entries.filter((e) => e.type === 'file').map((e) => e.name));
|
|
525
|
+
if (!CONVENTION_FILES.some((name) => names.has(name)))
|
|
526
|
+
continue;
|
|
527
|
+
qualifying.push(path);
|
|
528
|
+
candidates.push({
|
|
529
|
+
key: keyFor(reader.prefix, 'list', path),
|
|
530
|
+
body: renderListing(result.entries, this.limits.maxFileChars),
|
|
531
|
+
});
|
|
532
|
+
}
|
|
533
|
+
return qualifying;
|
|
534
|
+
}
|
|
535
|
+
/**
|
|
536
|
+
* Apply the per-side reservation to everything the seed fetched, and record the result.
|
|
537
|
+
*
|
|
538
|
+
* A body that does not fit is recorded `refused` rather than dropped, for the same reason the
|
|
539
|
+
* survey reports what it could not read: the model must not treat a file it was never shown as
|
|
540
|
+
* a file that does not exist, and the reviewer sees the same list. The BYTES are kept even so
|
|
541
|
+
* (see {@link withheld}), because the note invites the model to ask for them and re-fetching
|
|
542
|
+
* what is already in memory is a round trip for nothing. Whatever an earlier side leaves
|
|
543
|
+
* unspent carries forward, so the reservation is a floor rather than a ceiling.
|
|
544
|
+
*/
|
|
545
|
+
commitSeed(harvests) {
|
|
546
|
+
const share = Math.floor(this.limits.maxSeedChars / Math.max(1, harvests.length));
|
|
547
|
+
let spare = this.limits.maxSeedChars - share * harvests.length;
|
|
548
|
+
for (const harvest of harvests) {
|
|
549
|
+
this.siblings = [...this.siblings, ...harvest.siblings];
|
|
550
|
+
let budget = share + spare;
|
|
551
|
+
for (const candidate of harvest.candidates) {
|
|
552
|
+
if (candidate.body.length > budget) {
|
|
553
|
+
this.withheld[candidate.key] = candidate.body;
|
|
554
|
+
this.record({
|
|
555
|
+
path: candidate.key,
|
|
556
|
+
origin: 'seed',
|
|
557
|
+
outcome: 'refused',
|
|
558
|
+
chars: 0,
|
|
559
|
+
note: 'read, but it did not fit the opening context; ask for it if you need it',
|
|
560
|
+
});
|
|
561
|
+
continue;
|
|
562
|
+
}
|
|
563
|
+
budget -= candidate.body.length;
|
|
564
|
+
this.bodies[candidate.key] = candidate.body;
|
|
565
|
+
this.seedKeys.add(candidate.key);
|
|
566
|
+
this.record({
|
|
567
|
+
path: candidate.key,
|
|
568
|
+
origin: 'seed',
|
|
569
|
+
outcome: 'read',
|
|
570
|
+
chars: candidate.body.length,
|
|
571
|
+
note: null,
|
|
572
|
+
});
|
|
573
|
+
}
|
|
574
|
+
spare = budget;
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* One model-chosen read, charged against the exploration budget.
|
|
579
|
+
*
|
|
580
|
+
* The call is counted BEFORE anything else, refusals included: a model emitting nonsense paths
|
|
581
|
+
* would otherwise loop until the step cap fired, having read nothing. Budget exhaustion is
|
|
582
|
+
* answered rather than thrown, so the model is told what it has left and can produce a plan
|
|
583
|
+
* that says which areas it ran short on, instead of the loop ending with no reply at all.
|
|
584
|
+
*/
|
|
585
|
+
async explore(request) {
|
|
586
|
+
this.calls += 1;
|
|
587
|
+
const reader = this.readers[request.side];
|
|
588
|
+
if (!reader) {
|
|
589
|
+
return this.refuse(request, `there is no ${request.side} repository in this run to read`);
|
|
590
|
+
}
|
|
591
|
+
if (this.calls > this.limits.maxExplorationCalls) {
|
|
592
|
+
this.exhausted = 'calls';
|
|
593
|
+
return this.refuse(request, `the exploration budget is spent (${this.limits.maxExplorationCalls} reads). Answer from ` +
|
|
594
|
+
`what you have already seen, and say in the rationale which areas you could not check`);
|
|
595
|
+
}
|
|
596
|
+
const normalized = normalizeSurveyPath(request.path);
|
|
597
|
+
if ('refused' in normalized)
|
|
598
|
+
return this.refuse(request, normalized.refused);
|
|
599
|
+
const path = normalized.path;
|
|
600
|
+
if (request.kind === 'read' && path === '') {
|
|
601
|
+
return this.refuse(request, 'name the file to read, relative to the repository root');
|
|
602
|
+
}
|
|
603
|
+
// The new service does not exist yet, but a RETRY surveying after a partial run would find
|
|
604
|
+
// whatever the previous attempt left there. Reading it back as the monorepo's established
|
|
605
|
+
// convention is the platform citing its own draft to itself.
|
|
606
|
+
if (request.side === 'monorepo' && this.isInsideTarget(path)) {
|
|
607
|
+
return this.refuse(request, `${this.request.directory} is the service being created, not an existing one; read a sibling instead`);
|
|
608
|
+
}
|
|
609
|
+
const key = keyFor(request.side, request.kind, path);
|
|
610
|
+
const cached = this.bodies[key];
|
|
611
|
+
// Already fetched (seeded, or asked for twice): answer from what was read rather than
|
|
612
|
+
// spending a second read of the same bytes on a bounded budget. No new transcript row, since
|
|
613
|
+
// it names a read already recorded; the CALL is still counted, so a model re-requesting the
|
|
614
|
+
// same file cannot buy itself an unbounded loop.
|
|
615
|
+
if (cached !== undefined) {
|
|
616
|
+
return { outcome: 'read', body: cached, note: null, key };
|
|
617
|
+
}
|
|
618
|
+
// Fetched during the seed but never shown, so the model taking the prompt's advice and asking
|
|
619
|
+
// for it costs no round trip. CHARGED all the same: the bytes enter the model's context now,
|
|
620
|
+
// which is what the exploration budget bounds, and it takes a `read` row of its own beside the
|
|
621
|
+
// seed's `refused` one, because the citation check upstream keys on the OUTCOME.
|
|
622
|
+
const withheld = this.withheld[key];
|
|
623
|
+
if (withheld !== undefined) {
|
|
624
|
+
const answer = this.charge(key, withheld);
|
|
625
|
+
// Dropped only once it is SERVED: a charge refused for an exhausted budget must leave the
|
|
626
|
+
// body where it is, so a later call with room can still answer it without a second fetch.
|
|
627
|
+
if (answer.outcome === 'read')
|
|
628
|
+
delete this.withheld[key];
|
|
629
|
+
return answer;
|
|
630
|
+
}
|
|
631
|
+
return request.kind === 'list'
|
|
632
|
+
? await this.exploreList(reader, path, key)
|
|
633
|
+
: await this.exploreRead(reader, path, key);
|
|
634
|
+
}
|
|
635
|
+
async exploreList(reader, path, key) {
|
|
636
|
+
const result = await reader.list(path);
|
|
637
|
+
if ('failed' in result)
|
|
638
|
+
return this.fail(key, result.failed);
|
|
639
|
+
if (result.entries.length === 0) {
|
|
640
|
+
this.record({
|
|
641
|
+
path: key,
|
|
642
|
+
origin: 'model',
|
|
643
|
+
outcome: 'absent',
|
|
644
|
+
chars: 0,
|
|
645
|
+
note: 'no such directory, or it is empty',
|
|
646
|
+
});
|
|
647
|
+
return { outcome: 'absent', body: '', note: 'no such directory, or it is empty', key: null };
|
|
648
|
+
}
|
|
649
|
+
return this.charge(key, renderListing(result.entries, this.limits.maxFileChars));
|
|
650
|
+
}
|
|
651
|
+
async exploreRead(reader, path, key) {
|
|
652
|
+
const result = await reader.read(path);
|
|
653
|
+
if ('failed' in result)
|
|
654
|
+
return this.fail(key, result.failed);
|
|
655
|
+
if (result.content === null) {
|
|
656
|
+
this.record({ path: key, origin: 'model', outcome: 'absent', chars: 0, note: 'no such file' });
|
|
657
|
+
return { outcome: 'absent', body: '', note: 'no such file in this repository', key: null };
|
|
658
|
+
}
|
|
659
|
+
return this.charge(key, clip(this.scrub(result.content), this.limits.maxFileChars));
|
|
660
|
+
}
|
|
661
|
+
/** Spend a model read's characters, or refuse it because the content budget is gone. */
|
|
662
|
+
charge(key, body) {
|
|
663
|
+
const remaining = this.limits.maxExplorationChars - this.explorationChars;
|
|
664
|
+
if (body.length > remaining) {
|
|
665
|
+
this.exhausted = 'chars';
|
|
666
|
+
const note = `the exploration content budget is spent (${this.limits.maxExplorationChars} characters). ` +
|
|
667
|
+
`Answer from what you have already seen, and say in the rationale which areas you could ` +
|
|
668
|
+
`not check`;
|
|
669
|
+
this.record({ path: key, origin: 'model', outcome: 'refused', chars: 0, note });
|
|
670
|
+
return { outcome: 'refused', body: '', note, key: null };
|
|
671
|
+
}
|
|
672
|
+
this.explorationChars += body.length;
|
|
673
|
+
this.bodies[key] = body;
|
|
674
|
+
this.record({ path: key, origin: 'model', outcome: 'read', chars: body.length, note: null });
|
|
675
|
+
return { outcome: 'read', body, note: null, key };
|
|
676
|
+
}
|
|
677
|
+
/** A provider failure on a model read: recorded, and stated to the model as UNKNOWN. */
|
|
678
|
+
fail(key, cause) {
|
|
679
|
+
this.log?.warn('monorepo survey: model-requested read failed', { key, err: cause });
|
|
680
|
+
this.record({ path: key, origin: 'model', outcome: 'unreadable', chars: 0, note: cause });
|
|
681
|
+
return {
|
|
682
|
+
outcome: 'unreadable',
|
|
683
|
+
body: '',
|
|
684
|
+
note: `that read failed (${cause}); treat what it would have said as UNKNOWN, not as absent`,
|
|
685
|
+
key: null,
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
/** A read the platform declines: recorded on the transcript, and the reason given to the model. */
|
|
689
|
+
refuse(request, note) {
|
|
690
|
+
this.record({
|
|
691
|
+
path: keyFor(request.side, request.kind, request.path.slice(0, 200) || '.'),
|
|
692
|
+
origin: 'model',
|
|
693
|
+
outcome: 'refused',
|
|
694
|
+
chars: 0,
|
|
695
|
+
note,
|
|
696
|
+
});
|
|
697
|
+
return { outcome: 'refused', body: '', note, key: null };
|
|
698
|
+
}
|
|
699
|
+
/** Whether a path is the new service's own directory or something under it. */
|
|
700
|
+
isInsideTarget(path) {
|
|
701
|
+
const target = this.request.directory.replace(/\/+$/, '');
|
|
702
|
+
return target !== '' && (path === target || path.startsWith(`${target}/`));
|
|
703
|
+
}
|
|
704
|
+
/**
|
|
705
|
+
* Scrub at READ time, so every body is scrubbed once and nothing can reach a model through a
|
|
706
|
+
* path that forgot to. A prompt built from an unscrubbed body is strictly more exposed than the
|
|
707
|
+
* transcript, and the exploration half has no compose step a caller could scrub at.
|
|
708
|
+
*/
|
|
709
|
+
scrub(body) {
|
|
710
|
+
return redactSecrets(body) ?? '';
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Append to the transcript, up to the cap.
|
|
714
|
+
*
|
|
715
|
+
* Past {@link MAX_ADOPTION_READS} the entry is COUNTED and not recorded: one model turn can emit
|
|
716
|
+
* any number of tool calls, so the array needs a bound the call budget does not give it, and
|
|
717
|
+
* that count is what states the truncation to the reviewer. The gap between `calls` and the
|
|
718
|
+
* array's length cannot: the seed adds rows without adding calls, and a call answered from what
|
|
719
|
+
* was already read adds a call without a row.
|
|
720
|
+
*/
|
|
721
|
+
record(read) {
|
|
722
|
+
if (this.reads.length >= MAX_ADOPTION_READS) {
|
|
723
|
+
this.recordsDropped += 1;
|
|
724
|
+
return;
|
|
725
|
+
}
|
|
726
|
+
this.reads.push(read);
|
|
220
727
|
}
|
|
221
|
-
return null;
|
|
222
728
|
}
|
|
223
729
|
/**
|
|
224
|
-
*
|
|
225
|
-
* among, and what the reference template ships for the same areas.
|
|
730
|
+
* Open a survey of both sides of a monorepo bootstrap and read its opening context.
|
|
226
731
|
*
|
|
227
|
-
*
|
|
228
|
-
* config says what the repository declares, the
|
|
229
|
-
*
|
|
230
|
-
*
|
|
231
|
-
* beside the target qualifies as a sibling service, that is reported (`siblingService: null`)
|
|
232
|
-
* rather than filled with the first directory found, which is a claim the survey cannot support.
|
|
233
|
-
*
|
|
234
|
-
* Each side also contributes its own SHAPE (`noteLayout`), because the shape is the only evidence
|
|
235
|
-
* either side offers about source layout and module structure. Both are read off listings the
|
|
236
|
-
* survey already needed, so neither costs a request.
|
|
732
|
+
* Two reads make up the monorepo half and the second is the one that matters most: the root
|
|
733
|
+
* config says what the repository declares, and the sibling services beside the target say what a
|
|
734
|
+
* service in this repository actually looks like, which is the thing a new service has to match
|
|
735
|
+
* and the thing no root file states. Everything past that is the model's to ask for.
|
|
237
736
|
*/
|
|
238
737
|
export async function surveyMonorepo(request) {
|
|
239
|
-
const
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
? new SideReader(request.template, 'template', logger)
|
|
243
|
-
: undefined;
|
|
244
|
-
// ---- the monorepo's root conventions -------------------------------------
|
|
245
|
-
const rootEntries = await mono.list('');
|
|
246
|
-
if (rootEntries)
|
|
247
|
-
await mono.takeConventionFiles('', rootEntries, MAX_ROOT_FILES);
|
|
248
|
-
// ---- what it enforces in CI ---------------------------------------------
|
|
249
|
-
for (const ciPath of CI_DIRECTORIES) {
|
|
250
|
-
if (ciPath.endsWith('.yml')) {
|
|
251
|
-
await mono.take(ciPath);
|
|
252
|
-
continue;
|
|
253
|
-
}
|
|
254
|
-
const entries = await mono.list(ciPath);
|
|
255
|
-
if (!entries)
|
|
256
|
-
continue;
|
|
257
|
-
const workflows = entries
|
|
258
|
-
.filter((entry) => entry.type === 'file')
|
|
259
|
-
.slice(0, MAX_CI_FILES)
|
|
260
|
-
.map((entry) => entry.path);
|
|
261
|
-
await Promise.all(workflows.map((path) => mono.take(path)));
|
|
262
|
-
}
|
|
263
|
-
// ---- the nearest existing sibling service (the worked example) -----------
|
|
264
|
-
const parent = parentDirectoryOf(directory);
|
|
265
|
-
// Re-uses the root listing for a root-level target rather than asking for it twice.
|
|
266
|
-
const siblingEntries = parent === '' ? rootEntries : await mono.list(parent);
|
|
267
|
-
const sibling = await pickSiblingService(mono, siblingEntries, directory);
|
|
268
|
-
// ---- the reference template ---------------------------------------------
|
|
269
|
-
// Its SHAPE is recorded beside its files for the same reason the sibling's is: a layout
|
|
270
|
-
// recommendation needs evidence on both sides, or the one side that has any wins by default.
|
|
271
|
-
if (template) {
|
|
272
|
-
const entries = await template.list('');
|
|
273
|
-
if (entries) {
|
|
274
|
-
template.noteLayout('', entries);
|
|
275
|
-
await template.takeConventionFiles('', entries, MAX_ROOT_FILES);
|
|
276
|
-
}
|
|
277
|
-
}
|
|
278
|
-
const survey = {
|
|
279
|
-
monorepoPaths: mono.read,
|
|
280
|
-
templatePaths: template?.read ?? [],
|
|
281
|
-
unreadablePaths: [
|
|
282
|
-
...mono.unreadable.map((path) => `monorepo:${path}`),
|
|
283
|
-
...(template?.unreadable ?? []).map((path) => `template:${path}`),
|
|
284
|
-
],
|
|
285
|
-
siblingService: sibling,
|
|
286
|
-
};
|
|
287
|
-
return { survey, files: { ...mono.contents, ...template?.contents } };
|
|
738
|
+
const session = new MonorepoSurveySession(request);
|
|
739
|
+
await session.seed();
|
|
740
|
+
return session;
|
|
288
741
|
}
|
|
289
742
|
//# sourceMappingURL=monorepoSurvey.js.map
|