@withtruing/companion 0.13.2 → 0.14.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/README.md +25 -26
- package/dist/anthropic-output-schema.js +41 -0
- package/dist/api-key-caller.js +9 -2
- package/dist/claude-cli-caller.js +107 -8
- package/dist/cli.js +837 -207
- package/dist/codex-binary.js +96 -0
- package/dist/codex-cli-caller.js +90 -16
- package/dist/config.js +177 -2
- package/dist/git-range.js +88 -46
- package/dist/http-client.js +95 -10
- package/dist/investigation-reader.js +12 -6
- package/dist/managed-repo.js +132 -0
- package/dist/observed-sources.js +132 -6
- package/dist/reader-result-contract.js +296 -2
- package/dist/repository-provider.js +608 -0
- package/dist/schema-entities.js +18 -18
- package/dist/service.js +7 -2
- package/dist/surface-mapper.js +53 -11
- package/dist/text-model.js +91 -8
- package/dist/whole-read/archive.js +196 -0
- package/dist/whole-read/checks.js +297 -0
- package/dist/whole-read/claims.js +196 -0
- package/dist/whole-read/coverage-ledger.js +51 -0
- package/dist/whole-read/decode.js +234 -0
- package/dist/whole-read/drafting.js +502 -0
- package/dist/whole-read/generated-rules.js +68 -0
- package/dist/whole-read/go-proof.js +255 -0
- package/dist/whole-read/grouping-prompt.js +19 -0
- package/dist/whole-read/grouping.js +247 -0
- package/dist/whole-read/identity.js +103 -0
- package/dist/whole-read/import-graph.js +293 -0
- package/dist/whole-read/inventory-collector.js +55 -0
- package/dist/whole-read/inventory-rules.js +141 -0
- package/dist/whole-read/lockfiles.js +947 -0
- package/dist/whole-read/notes.js +216 -0
- package/dist/whole-read/npm-proof.js +272 -0
- package/dist/whole-read/owner-decision-detectors.js +270 -0
- package/dist/whole-read/packing.js +612 -0
- package/dist/whole-read/pages.js +429 -0
- package/dist/whole-read/phase-policy.js +54 -0
- package/dist/whole-read/prd-build.js +279 -0
- package/dist/whole-read/prd-fidelity.js +14 -0
- package/dist/whole-read/python-proof.js +489 -0
- package/dist/whole-read/read-prompt.js +67 -0
- package/dist/whole-read/redaction.js +411 -0
- package/dist/whole-read/relationship-mapping.js +98 -0
- package/dist/whole-read/sanitize.js +266 -0
- package/dist/whole-read/secret-rules.js +26 -0
- package/dist/whole-read/structure.js +300 -0
- package/dist/whole-read/types.js +2 -0
- package/dist/whole-read-claims.js +12 -0
- package/dist/whole-read-git.js +340 -0
- package/dist/whole-read-pool.js +80 -0
- package/dist/whole-read-runner.js +945 -0
- package/dist/whole-read-shared-source.js +194 -0
- package/dist/whole-read-temp.js +479 -0
- package/package.json +8 -2
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { validateNoSecretValues } from './redaction.js';
|
|
2
|
+
const SAFE_KEY = /^[A-Za-z][A-Za-z0-9_-]{0,127}$/;
|
|
3
|
+
const SAFE_ID = /^[A-Za-z0-9][A-Za-z0-9._:-]{0,159}$/;
|
|
4
|
+
const ABSOLUTE_HOST_PATH = /(?:^|[\s"'(])(?:[A-Za-z]:[\\/]|\\\\|\/(?:home|Users|tmp|var\/tmp|private\/tmp)\/)/;
|
|
5
|
+
const FORBIDDEN_FIELDS = new Set([
|
|
6
|
+
'prompt',
|
|
7
|
+
'rawprompt',
|
|
8
|
+
'stdout',
|
|
9
|
+
'stderr',
|
|
10
|
+
'rawoutput',
|
|
11
|
+
'rawmodeloutput',
|
|
12
|
+
'sourceexcerpt',
|
|
13
|
+
]);
|
|
14
|
+
const BUILD_FAILURE_CODES = new Set([
|
|
15
|
+
'usage_limit',
|
|
16
|
+
'provider_rate_limit',
|
|
17
|
+
'provider_auth',
|
|
18
|
+
'provider_unavailable',
|
|
19
|
+
'provider_request_rejected',
|
|
20
|
+
'billing_exhausted',
|
|
21
|
+
'capability_failure',
|
|
22
|
+
'model_declined',
|
|
23
|
+
'output_truncated',
|
|
24
|
+
'companion_offline',
|
|
25
|
+
'github_rate_limit',
|
|
26
|
+
'consent_invalidated',
|
|
27
|
+
'allowance_reached',
|
|
28
|
+
'snapshot_unavailable',
|
|
29
|
+
'source_acquisition_required',
|
|
30
|
+
'cleanup_pending',
|
|
31
|
+
'read_result_invalid',
|
|
32
|
+
'draft_attempt_invalid',
|
|
33
|
+
'draft_input_invalid',
|
|
34
|
+
'draft_note_invalid',
|
|
35
|
+
'check_attempt_invalid',
|
|
36
|
+
'check_input_invalid',
|
|
37
|
+
'check_claim_invalid',
|
|
38
|
+
'check_note_use_invalid',
|
|
39
|
+
'check_result_invalid',
|
|
40
|
+
'claim_check_input_invalid',
|
|
41
|
+
'claim_check_citation_invalid',
|
|
42
|
+
'claim_check_result_invalid',
|
|
43
|
+
'grouping_attempt_invalid',
|
|
44
|
+
'grouping_input_invalid',
|
|
45
|
+
'relationship_attempt_invalid',
|
|
46
|
+
'relationship_input_invalid',
|
|
47
|
+
'relationship_result_invalid',
|
|
48
|
+
'canonical_source_invalid',
|
|
49
|
+
'internal_error',
|
|
50
|
+
]);
|
|
51
|
+
const BUILD_FAILURE_SOURCES = new Set([
|
|
52
|
+
'claude_cli',
|
|
53
|
+
'codex',
|
|
54
|
+
'anthropic_api',
|
|
55
|
+
'github',
|
|
56
|
+
'server',
|
|
57
|
+
'companion',
|
|
58
|
+
]);
|
|
59
|
+
const DIAGNOSTIC_OPERATIONS = new Set([
|
|
60
|
+
'collection_page',
|
|
61
|
+
'github_archive',
|
|
62
|
+
'github_blob',
|
|
63
|
+
'github_tree',
|
|
64
|
+
'git_blob',
|
|
65
|
+
'git_tree',
|
|
66
|
+
'model_request',
|
|
67
|
+
'registry_lookup',
|
|
68
|
+
]);
|
|
69
|
+
const CLEANUP_ERROR_CODES = new Set([
|
|
70
|
+
'cleanup_delete_failed',
|
|
71
|
+
'cleanup_reader_cancel_failed',
|
|
72
|
+
'cleanup_path_outside_root',
|
|
73
|
+
'cleanup_unsafe_link',
|
|
74
|
+
'cleanup_marker_invalid',
|
|
75
|
+
]);
|
|
76
|
+
const isRecord = (value) => value !== null && typeof value === 'object' && !Array.isArray(value)
|
|
77
|
+
&& (Object.getPrototypeOf(value) === Object.prototype || Object.getPrototypeOf(value) === null);
|
|
78
|
+
const exactFields = (value, required, optional = []) => {
|
|
79
|
+
const fields = new Set([...required, ...optional]);
|
|
80
|
+
return required.every((field) => Object.hasOwn(value, field))
|
|
81
|
+
&& Object.keys(value).every((field) => fields.has(field));
|
|
82
|
+
};
|
|
83
|
+
const isSafeId = (value) => typeof value === 'string' && SAFE_ID.test(value);
|
|
84
|
+
const isCount = (value) => typeof value === 'number' && Number.isSafeInteger(value) && value >= 0;
|
|
85
|
+
const isIsoTime = (value) => {
|
|
86
|
+
if (typeof value !== 'string')
|
|
87
|
+
return false;
|
|
88
|
+
const time = Date.parse(value);
|
|
89
|
+
return Number.isFinite(time) && new Date(time).toISOString() === value;
|
|
90
|
+
};
|
|
91
|
+
const containsHostPath = (value) => ABSOLUTE_HOST_PATH.test(value);
|
|
92
|
+
function containsCodeQuote(value) {
|
|
93
|
+
const characters = Array.from(value);
|
|
94
|
+
if (characters.length <= 120)
|
|
95
|
+
return false;
|
|
96
|
+
const tokens = [];
|
|
97
|
+
for (let index = 0; index < characters.length; index += 1) {
|
|
98
|
+
const character = characters[index];
|
|
99
|
+
if (character === '=' && characters[index + 1] === '>') {
|
|
100
|
+
tokens.push({ start: index, end: index + 2 });
|
|
101
|
+
index += 1;
|
|
102
|
+
}
|
|
103
|
+
else if (character === '{' || character === '}' || character === ';'
|
|
104
|
+
|| character === '(' || character === ')') {
|
|
105
|
+
tokens.push({ start: index, end: index + 1 });
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
for (let index = 0; index + 2 < tokens.length; index += 1) {
|
|
109
|
+
if (tokens[index + 2].end - tokens[index].start <= 40)
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
return false;
|
|
113
|
+
}
|
|
114
|
+
function inspectString(value) {
|
|
115
|
+
if (validateNoSecretValues(value).ok === false)
|
|
116
|
+
return 'secret_value_detected';
|
|
117
|
+
return containsCodeQuote(value) ? 'code_quote_detected' : null;
|
|
118
|
+
}
|
|
119
|
+
function cloneMetadata(value, allowCleanupPath, field, ancestors = new Set(), depth = 0) {
|
|
120
|
+
if (depth > 64)
|
|
121
|
+
return { ok: false, code: 'metadata_schema_invalid' };
|
|
122
|
+
if (value === null || typeof value === 'boolean')
|
|
123
|
+
return { ok: true, value };
|
|
124
|
+
if (typeof value === 'number') {
|
|
125
|
+
return Number.isFinite(value)
|
|
126
|
+
? { ok: true, value }
|
|
127
|
+
: { ok: false, code: 'metadata_schema_invalid' };
|
|
128
|
+
}
|
|
129
|
+
if (typeof value === 'string') {
|
|
130
|
+
const pathField = field?.toLowerCase().endsWith('path') === true;
|
|
131
|
+
if (!allowCleanupPath && containsHostPath(value)) {
|
|
132
|
+
return { ok: false, code: 'cleanup_path_forbidden' };
|
|
133
|
+
}
|
|
134
|
+
if (pathField && allowCleanupPath) {
|
|
135
|
+
return validateNoSecretValues(value).ok
|
|
136
|
+
? { ok: true, value }
|
|
137
|
+
: { ok: false, code: 'secret_value_detected' };
|
|
138
|
+
}
|
|
139
|
+
const unsafe = inspectString(value);
|
|
140
|
+
return unsafe ? { ok: false, code: unsafe } : { ok: true, value };
|
|
141
|
+
}
|
|
142
|
+
if (!value || typeof value !== 'object' || ancestors.has(value)) {
|
|
143
|
+
return { ok: false, code: 'metadata_schema_invalid' };
|
|
144
|
+
}
|
|
145
|
+
ancestors.add(value);
|
|
146
|
+
if (Array.isArray(value)) {
|
|
147
|
+
const result = [];
|
|
148
|
+
for (const item of value) {
|
|
149
|
+
const safe = cloneMetadata(item, allowCleanupPath, undefined, ancestors, depth + 1);
|
|
150
|
+
if (!safe.ok)
|
|
151
|
+
return safe;
|
|
152
|
+
result.push(safe.value);
|
|
153
|
+
}
|
|
154
|
+
ancestors.delete(value);
|
|
155
|
+
return { ok: true, value: result };
|
|
156
|
+
}
|
|
157
|
+
if (!isRecord(value))
|
|
158
|
+
return { ok: false, code: 'metadata_schema_invalid' };
|
|
159
|
+
const result = {};
|
|
160
|
+
for (const [key, item] of Object.entries(value)) {
|
|
161
|
+
const normalizedKey = key.toLowerCase().replace(/[_-]/g, '');
|
|
162
|
+
if (!SAFE_KEY.test(key) || FORBIDDEN_FIELDS.has(normalizedKey)) {
|
|
163
|
+
return { ok: false, code: 'metadata_schema_invalid' };
|
|
164
|
+
}
|
|
165
|
+
const safeKey = inspectString(key);
|
|
166
|
+
if (safeKey)
|
|
167
|
+
return { ok: false, code: safeKey };
|
|
168
|
+
const safe = cloneMetadata(item, allowCleanupPath, key, ancestors, depth + 1);
|
|
169
|
+
if (!safe.ok)
|
|
170
|
+
return safe;
|
|
171
|
+
result[key] = safe.value;
|
|
172
|
+
}
|
|
173
|
+
ancestors.delete(value);
|
|
174
|
+
return { ok: true, value: result };
|
|
175
|
+
}
|
|
176
|
+
function validBuildFailure(value) {
|
|
177
|
+
if (!isRecord(value) || !exactFields(value, ['code', 'source'], ['resetAt', 'retryAfterMs', 'category']))
|
|
178
|
+
return false;
|
|
179
|
+
if (typeof value.code !== 'string' || !BUILD_FAILURE_CODES.has(value.code)
|
|
180
|
+
|| typeof value.source !== 'string' || !BUILD_FAILURE_SOURCES.has(value.source))
|
|
181
|
+
return false;
|
|
182
|
+
if (value.resetAt !== undefined && !isIsoTime(value.resetAt))
|
|
183
|
+
return false;
|
|
184
|
+
if (value.category !== undefined && value.category !== null
|
|
185
|
+
&& (typeof value.category !== 'string' || !SAFE_ID.test(value.category)))
|
|
186
|
+
return false;
|
|
187
|
+
return value.retryAfterMs === undefined
|
|
188
|
+
|| (typeof value.retryAfterMs === 'number' && Number.isFinite(value.retryAfterMs)
|
|
189
|
+
&& value.retryAfterMs >= 0);
|
|
190
|
+
}
|
|
191
|
+
function validDiagnostic(value) {
|
|
192
|
+
if (!isRecord(value)
|
|
193
|
+
|| !exactFields(value, ['operation'], ['objectId', 'count', 'retryAt', 'retryAfterMs']))
|
|
194
|
+
return false;
|
|
195
|
+
if (typeof value.operation !== 'string' || !DIAGNOSTIC_OPERATIONS.has(value.operation))
|
|
196
|
+
return false;
|
|
197
|
+
if (value.objectId !== undefined && !isSafeId(value.objectId))
|
|
198
|
+
return false;
|
|
199
|
+
if (value.count !== undefined && !isCount(value.count))
|
|
200
|
+
return false;
|
|
201
|
+
if (value.retryAt !== undefined && !isIsoTime(value.retryAt))
|
|
202
|
+
return false;
|
|
203
|
+
return value.retryAfterMs === undefined
|
|
204
|
+
|| (typeof value.retryAfterMs === 'number' && Number.isFinite(value.retryAfterMs)
|
|
205
|
+
&& value.retryAfterMs >= 0);
|
|
206
|
+
}
|
|
207
|
+
function validResultReference(value) {
|
|
208
|
+
if (!isRecord(value))
|
|
209
|
+
return false;
|
|
210
|
+
if (Object.keys(value).length === 0)
|
|
211
|
+
return true;
|
|
212
|
+
if (value.type !== 'sealed_build_collection') {
|
|
213
|
+
return ['collection', 'ref', 'publishedLane'].some((field) => exactFields(value, [field]) && isSafeId(value[field]));
|
|
214
|
+
}
|
|
215
|
+
return exactFields(value, ['type', 'protocolVersion', 'collectionId', 'kind', 'digest'])
|
|
216
|
+
&& value.protocolVersion === 'whole-read-pages@1'
|
|
217
|
+
&& isSafeId(value.collectionId)
|
|
218
|
+
&& ['inventory', 'notes', 'groups', 'claims', 'progress'].includes(String(value.kind))
|
|
219
|
+
&& typeof value.digest === 'string'
|
|
220
|
+
&& /^sha256:[0-9a-f]{64}$/.test(value.digest);
|
|
221
|
+
}
|
|
222
|
+
function validCleanupRecord(value) {
|
|
223
|
+
if (!isRecord(value) || !exactFields(value, [
|
|
224
|
+
'workspaceId',
|
|
225
|
+
'buildId',
|
|
226
|
+
'attemptId',
|
|
227
|
+
'hostId',
|
|
228
|
+
'leaseExpiresAt',
|
|
229
|
+
'locationId',
|
|
230
|
+
'path',
|
|
231
|
+
'state',
|
|
232
|
+
'cleanupAttempts',
|
|
233
|
+
'errorCode',
|
|
234
|
+
'deletedAt',
|
|
235
|
+
]))
|
|
236
|
+
return false;
|
|
237
|
+
return isSafeId(value.workspaceId)
|
|
238
|
+
&& isSafeId(value.buildId)
|
|
239
|
+
&& isSafeId(value.attemptId)
|
|
240
|
+
&& isSafeId(value.hostId)
|
|
241
|
+
&& isIsoTime(value.leaseExpiresAt)
|
|
242
|
+
&& isSafeId(value.locationId)
|
|
243
|
+
&& typeof value.path === 'string'
|
|
244
|
+
&& value.path.length > 0
|
|
245
|
+
&& value.path.length <= 4096
|
|
246
|
+
&& !value.path.includes('\0')
|
|
247
|
+
&& (value.state === 'deleted' || value.state === 'cleanup_pending')
|
|
248
|
+
&& isCount(value.cleanupAttempts)
|
|
249
|
+
&& (value.errorCode === null
|
|
250
|
+
|| (typeof value.errorCode === 'string' && CLEANUP_ERROR_CODES.has(value.errorCode)))
|
|
251
|
+
&& (value.deletedAt === null || isIsoTime(value.deletedAt));
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Copies JSON metadata only after recursively checking every string. Unsafe records are rejected;
|
|
255
|
+
* source-like or secret-bearing values are never shortened into apparently valid metadata.
|
|
256
|
+
*/
|
|
257
|
+
export function sanitizeBuildMetadata(value, schema = 'metadata') {
|
|
258
|
+
const schemaValid = schema === 'metadata'
|
|
259
|
+
|| (schema === 'build_failure' && validBuildFailure(value))
|
|
260
|
+
|| (schema === 'diagnostic' && validDiagnostic(value))
|
|
261
|
+
|| (schema === 'result_reference' && validResultReference(value))
|
|
262
|
+
|| (schema === 'owner_scoped_cleanup' && validCleanupRecord(value));
|
|
263
|
+
if (!schemaValid)
|
|
264
|
+
return { ok: false, code: 'metadata_schema_invalid' };
|
|
265
|
+
return cloneMetadata(value, schema === 'owner_scoped_cleanup');
|
|
266
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
/** Closed, deliberately conservative secret-rules@1 matcher table. */
|
|
2
|
+
export const SECRET_RULES_VERSION = 'secret-rules@1';
|
|
3
|
+
export const SECRET_RULES = [
|
|
4
|
+
{ id: 'private-key', priority: 100, matcher: /-----BEGIN [A-Z0-9 ]*PRIVATE KEY-----/g,
|
|
5
|
+
terminator: /-----END [A-Z0-9 ]*PRIVATE KEY-----/g, capture: 'private-key' },
|
|
6
|
+
// Anchor at the maximal scheme run so a failed URL cannot rescan a long run from every letter.
|
|
7
|
+
// The url capture starts at its first letter, preserving the previous match boundary exactly.
|
|
8
|
+
{ id: 'url-credentials', priority: 95, matcher: /(?<![a-z0-9+.-])(?=[a-z0-9+.-]*[a-z])[a-z0-9+.-]+:\/\/[^\s/:@]+:[^\s/@]+@[^\s/]+[^\s]*/gi, capture: 'url' },
|
|
9
|
+
// The scanner checks each maximal JWT segment once; \b can also occur after every hyphen in a run.
|
|
10
|
+
{ id: 'jwt', priority: 90, matcher: /[a-zA-Z0-9_-]+/g, capture: 'jwt' },
|
|
11
|
+
{ id: 'aws-access-key', priority: 80, matcher: /\b(?:AKIA|ASIA|AIDA|AROA)[A-Z0-9]{16}\b/g, capture: 'whole' },
|
|
12
|
+
{ id: 'aws-secret-key', priority: 80, matcher: /\baws_secret_access_key\s*[=:]\s*([A-Za-z0-9/+=]{40})\b/gi, capture: 'aws-secret' },
|
|
13
|
+
{ id: 'gcp-api-key', priority: 80, matcher: /\bAIza[0-9A-Za-z_-]{35}\b/g, capture: 'whole' },
|
|
14
|
+
{ id: 'azure-storage-connection-string', priority: 80, matcher: /\bDefaultEndpointsProtocol=https?;AccountName=[A-Za-z0-9-]+;AccountKey=[A-Za-z0-9+/=]+(?:;EndpointSuffix=[A-Za-z0-9.-]+)?\b/g, capture: 'whole' },
|
|
15
|
+
// The scanner parses each maximal query run once; the former expression could rescan it at every ?sv=.
|
|
16
|
+
{ id: 'azure-sas-token', priority: 80, matcher: /[^\s#]+/g, capture: 'azure-sas' },
|
|
17
|
+
{ id: 'github-token', priority: 80, matcher: /\b(?:gh[pousr]_[A-Za-z0-9]{36,255}|github_pat_[A-Za-z0-9_]{22,255})\b/g, capture: 'whole' },
|
|
18
|
+
{ id: 'gitlab-token', priority: 80, matcher: /\bglpat-[A-Za-z0-9_-]{20,255}\b/g, capture: 'whole' },
|
|
19
|
+
{ id: 'slack-token', priority: 80, matcher: /\bxox[baprs]-[A-Za-z0-9-]{10,255}\b/g, capture: 'whole' },
|
|
20
|
+
{ id: 'stripe-key', priority: 80, matcher: /\b(?:sk|rk)_(?:live|test)_[A-Za-z0-9]{16,255}\b/g, capture: 'whole' },
|
|
21
|
+
{ id: 'openai-token', priority: 80, matcher: /\bsk-(?:proj-)?[A-Za-z0-9_-]{20,255}\b/g, capture: 'whole' },
|
|
22
|
+
{ id: 'anthropic-token', priority: 80, matcher: /\bsk-ant-[A-Za-z0-9_-]{20,255}\b/g, capture: 'whole' },
|
|
23
|
+
// Match each maximal assignment name once; the scanner applies the exact keyword/boundary and value rules.
|
|
24
|
+
{ id: 'named-high-entropy-assignment', priority: 10, matcher: /(?<![A-Za-z0-9_.-])[A-Za-z0-9_.-]+\s*[=:]/g,
|
|
25
|
+
keywords: ['KEY', 'SECRET', 'TOKEN', 'PASSWORD'], capture: 'assignment' },
|
|
26
|
+
];
|
|
@@ -0,0 +1,300 @@
|
|
|
1
|
+
import { structureDigest } from './identity.js';
|
|
2
|
+
const COLORS = ['#2563eb', '#7c3aed', '#db2777', '#ea580c', '#ca8a04', '#16a34a', '#0891b2', '#4f46e5'];
|
|
3
|
+
const METADATA_NAMES = new Set([
|
|
4
|
+
'package.json', 'package-lock.json', 'npm-shrinkwrap.json', 'pnpm-lock.yaml', 'yarn.lock',
|
|
5
|
+
'bun.lock', 'bun.lockb', 'cargo.toml', 'cargo.lock', 'go.mod', 'go.sum', 'go.work',
|
|
6
|
+
'pyproject.toml', 'setup.py', 'pom.xml', 'build.gradle', 'build.gradle.kts', 'gemfile',
|
|
7
|
+
'gemfile.lock', 'composer.json', 'composer.lock', 'pubspec.yaml', 'package.swift', 'mix.exs',
|
|
8
|
+
'readme', 'readme.md', 'readme.txt', 'license', 'license.md', 'notice', '.gitignore',
|
|
9
|
+
'.gitattributes', '.npmignore',
|
|
10
|
+
]);
|
|
11
|
+
const SOURCE_EXTENSIONS = new Set([
|
|
12
|
+
'.c', '.cc', '.cpp', '.cs', '.css', '.dart', '.ex', '.exs', '.go', '.h', '.hpp', '.html',
|
|
13
|
+
'.java', '.js', '.jsx', '.kt', '.kts', '.m', '.mm', '.php', '.py', '.rb', '.rs', '.scala',
|
|
14
|
+
'.scss', '.sh', '.sql', '.swift', '.ts', '.tsx', '.vue', '.xml', '.yaml', '.yml',
|
|
15
|
+
]);
|
|
16
|
+
const invalid = () => { throw new TypeError('structure_invalid'); };
|
|
17
|
+
const pathParts = (path) => path.split('/').filter(Boolean);
|
|
18
|
+
const directory = (path) => {
|
|
19
|
+
const parts = pathParts(path);
|
|
20
|
+
parts.pop();
|
|
21
|
+
return parts.join('/');
|
|
22
|
+
};
|
|
23
|
+
const baseName = (path) => pathParts(path).at(-1) ?? '';
|
|
24
|
+
const isUnder = (path, root) => root === '' || path === root || path.startsWith(`${root}/`);
|
|
25
|
+
const depth = (root) => root === '' ? 0 : pathParts(root).length;
|
|
26
|
+
const entryKey = (entry) => `${entry.sourceId}\u0000${entry.pathBase64}`;
|
|
27
|
+
const candidateKey = (sourceId, root) => `${sourceId}\u0000${root}`;
|
|
28
|
+
const laneId = (sourceId, root) => `lane-${structureDigest({ root, sourceId }).slice('sha256:'.length, 'sha256:'.length + 24)}`;
|
|
29
|
+
const proofFor = (entry) => ({
|
|
30
|
+
entryId: entry.id, sourceId: entry.sourceId, blobId: entry.blobId,
|
|
31
|
+
pathBase64: entry.pathBase64, pathDisplay: entry.pathDisplay,
|
|
32
|
+
});
|
|
33
|
+
function text(bytes) {
|
|
34
|
+
if (!bytes)
|
|
35
|
+
return undefined;
|
|
36
|
+
try {
|
|
37
|
+
return new TextDecoder('utf-8', { fatal: true }).decode(bytes);
|
|
38
|
+
}
|
|
39
|
+
catch {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
function packageInfo(bytes) {
|
|
44
|
+
const source = text(bytes);
|
|
45
|
+
if (source === undefined)
|
|
46
|
+
return { workspace: false, uncertain: true };
|
|
47
|
+
try {
|
|
48
|
+
const parsed = JSON.parse(source);
|
|
49
|
+
return {
|
|
50
|
+
name: typeof parsed.name === 'string' && parsed.name.trim() ? parsed.name : undefined,
|
|
51
|
+
workspace: parsed.workspaces !== undefined,
|
|
52
|
+
uncertain: false,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
catch {
|
|
56
|
+
return { workspace: false, uncertain: true };
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
function cargoInfo(bytes) {
|
|
60
|
+
const source = text(bytes);
|
|
61
|
+
if (source === undefined)
|
|
62
|
+
return { package: false, workspace: false, uncertain: true };
|
|
63
|
+
return {
|
|
64
|
+
package: /(?:^|\n)\s*\[package\]\s*(?:#.*)?(?:\n|$)/.test(source),
|
|
65
|
+
workspace: /(?:^|\n)\s*\[workspace\]\s*(?:#.*)?(?:\n|$)/.test(source),
|
|
66
|
+
uncertain: false,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
function manifestKind(name) {
|
|
70
|
+
return name === 'go.mod' || name === 'pyproject.toml' || name === 'setup.py' || name === 'pom.xml'
|
|
71
|
+
|| name === 'build.gradle' || name === 'build.gradle.kts' || name === 'gemfile'
|
|
72
|
+
|| name === 'composer.json' || name === 'pubspec.yaml' || name === 'package.swift'
|
|
73
|
+
|| name === 'mix.exs' || name.endsWith('.csproj');
|
|
74
|
+
}
|
|
75
|
+
function isKnownNonSource(entry) {
|
|
76
|
+
const name = baseName(entry.pathDisplay).toLowerCase();
|
|
77
|
+
return METADATA_NAMES.has(name) || name.endsWith('.csproj');
|
|
78
|
+
}
|
|
79
|
+
function sourceStatus(entry) {
|
|
80
|
+
if (entry.mode !== '100644' && entry.mode !== '100755')
|
|
81
|
+
return 'non-source';
|
|
82
|
+
if (isKnownNonSource(entry))
|
|
83
|
+
return 'non-source';
|
|
84
|
+
const name = baseName(entry.pathDisplay).toLowerCase();
|
|
85
|
+
const dot = name.lastIndexOf('.');
|
|
86
|
+
if (dot >= 0 && SOURCE_EXTENSIONS.has(name.slice(dot)))
|
|
87
|
+
return 'source';
|
|
88
|
+
return 'uncertain';
|
|
89
|
+
}
|
|
90
|
+
function defaultName(root) {
|
|
91
|
+
return root === '' ? 'Repository root' : root;
|
|
92
|
+
}
|
|
93
|
+
function canonicalRevision(inventoryRevision, lanes, assignments, merges) {
|
|
94
|
+
const value = {
|
|
95
|
+
inventoryRevision,
|
|
96
|
+
lanes: lanes.map(({ id, name, color, order }) => ({ id, name, color, order })),
|
|
97
|
+
assignments: [...assignments].sort((left, right) => left.entryId.localeCompare(right.entryId)),
|
|
98
|
+
merges: merges.map((merge) => ({ fromLaneIds: [...merge.fromLaneIds].sort(), intoLaneId: merge.intoLaneId })),
|
|
99
|
+
};
|
|
100
|
+
return { buildId: '', ...value, structureDigest: structureDigest(value) };
|
|
101
|
+
}
|
|
102
|
+
/**
|
|
103
|
+
* Mechanically derives lanes from an already-sealed inventory. Manifest contents are parsed as
|
|
104
|
+
* bytes only; no manifest is loaded, imported, or executed.
|
|
105
|
+
*/
|
|
106
|
+
export function deriveStructure(input) {
|
|
107
|
+
if (!Number.isSafeInteger(input.inventoryRevision) || input.inventoryRevision < 1)
|
|
108
|
+
invalid();
|
|
109
|
+
const entries = [...input.entries];
|
|
110
|
+
const seenEntries = new Set();
|
|
111
|
+
const seenIds = new Set();
|
|
112
|
+
for (const entry of entries) {
|
|
113
|
+
if (!entry.id || !entry.sourceId || !entry.pathBase64 || !entry.pathDisplay || !entry.blobId
|
|
114
|
+
|| seenEntries.has(entryKey(entry)) || seenIds.has(entry.id))
|
|
115
|
+
invalid();
|
|
116
|
+
seenEntries.add(entryKey(entry));
|
|
117
|
+
seenIds.add(entry.id);
|
|
118
|
+
}
|
|
119
|
+
const candidates = new Map();
|
|
120
|
+
const add = (entry, name, workspaceAggregator = false, uncertain = false) => {
|
|
121
|
+
const root = directory(entry.pathDisplay);
|
|
122
|
+
const key = candidateKey(entry.sourceId, root);
|
|
123
|
+
const existing = candidates.get(key);
|
|
124
|
+
if (existing) {
|
|
125
|
+
existing.proofs.push(proofFor(entry));
|
|
126
|
+
existing.workspaceAggregator ||= workspaceAggregator;
|
|
127
|
+
existing.uncertain ||= uncertain;
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
candidates.set(key, { sourceId: entry.sourceId, root, name, proofs: [proofFor(entry)], workspaceAggregator, uncertain });
|
|
131
|
+
};
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
if (entry.mode !== '100644' && entry.mode !== '100755')
|
|
134
|
+
continue;
|
|
135
|
+
const name = baseName(entry.pathDisplay);
|
|
136
|
+
const lower = name.toLowerCase();
|
|
137
|
+
if (lower === 'package.json') {
|
|
138
|
+
const info = packageInfo(input.readCanonicalBytes(entry));
|
|
139
|
+
if (info.name)
|
|
140
|
+
add(entry, info.name, info.workspace);
|
|
141
|
+
else if (info.uncertain)
|
|
142
|
+
add(entry, defaultName(directory(entry.pathDisplay)), true, true);
|
|
143
|
+
}
|
|
144
|
+
else if (lower === 'cargo.toml') {
|
|
145
|
+
const info = cargoInfo(input.readCanonicalBytes(entry));
|
|
146
|
+
if (info.package || info.uncertain)
|
|
147
|
+
add(entry, defaultName(directory(entry.pathDisplay)), info.workspace && !info.package, info.uncertain);
|
|
148
|
+
}
|
|
149
|
+
else if (manifestKind(lower)) {
|
|
150
|
+
add(entry, defaultName(directory(entry.pathDisplay)));
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
const candidateList = [...candidates.values()];
|
|
154
|
+
const kept = candidateList.filter((candidate) => {
|
|
155
|
+
if (!candidate.workspaceAggregator || candidate.uncertain)
|
|
156
|
+
return true;
|
|
157
|
+
for (const entry of entries) {
|
|
158
|
+
if (entry.sourceId !== candidate.sourceId || !isUnder(entry.pathDisplay, candidate.root))
|
|
159
|
+
continue;
|
|
160
|
+
const deeper = candidateList.some((other) => other !== candidate && other.sourceId === candidate.sourceId
|
|
161
|
+
&& depth(other.root) > depth(candidate.root) && isUnder(entry.pathDisplay, other.root));
|
|
162
|
+
if (!deeper && sourceStatus(entry) !== 'non-source')
|
|
163
|
+
return true;
|
|
164
|
+
}
|
|
165
|
+
return false;
|
|
166
|
+
}).sort((left, right) => left.sourceId.localeCompare(right.sourceId) || left.root.localeCompare(right.root));
|
|
167
|
+
const lanes = kept.map((candidate, index) => ({
|
|
168
|
+
id: laneId(candidate.sourceId, candidate.root), name: candidate.name,
|
|
169
|
+
color: COLORS[index % COLORS.length], order: index,
|
|
170
|
+
manifestProofs: [...candidate.proofs].sort((left, right) => left.pathBase64.localeCompare(right.pathBase64)),
|
|
171
|
+
}));
|
|
172
|
+
const laneByCandidate = new Map(kept.map((candidate, index) => [candidateKey(candidate.sourceId, candidate.root), lanes[index]]));
|
|
173
|
+
const assignments = [];
|
|
174
|
+
const rootEntries = [];
|
|
175
|
+
for (const entry of entries) {
|
|
176
|
+
const owner = kept.filter((candidate) => candidate.sourceId === entry.sourceId && isUnder(entry.pathDisplay, candidate.root))
|
|
177
|
+
.sort((left, right) => depth(right.root) - depth(left.root))[0];
|
|
178
|
+
const lane = owner ? laneByCandidate.get(candidateKey(owner.sourceId, owner.root)) : undefined;
|
|
179
|
+
if (lane)
|
|
180
|
+
assignments.push({ entryId: entry.id, laneId: lane.id });
|
|
181
|
+
else
|
|
182
|
+
rootEntries.push(entry);
|
|
183
|
+
}
|
|
184
|
+
if (rootEntries.length > 0) {
|
|
185
|
+
const rootLane = {
|
|
186
|
+
id: 'repository-root', name: 'Repository root', color: COLORS[lanes.length % COLORS.length],
|
|
187
|
+
order: lanes.length, manifestProofs: [],
|
|
188
|
+
};
|
|
189
|
+
lanes.push(rootLane);
|
|
190
|
+
assignments.push(...rootEntries.map((entry) => ({ entryId: entry.id, laneId: rootLane.id })));
|
|
191
|
+
}
|
|
192
|
+
const revision = canonicalRevision(input.inventoryRevision, lanes, assignments, []);
|
|
193
|
+
return { ...revision, lanes };
|
|
194
|
+
}
|
|
195
|
+
/** Includes declared generator inputs without implicitly selecting or publishing their lanes. */
|
|
196
|
+
export function deriveSelectedLaneReadPlan(input) {
|
|
197
|
+
const laneIds = new Set(input.structure.lanes.map((lane) => lane.id));
|
|
198
|
+
const publishedLaneIds = [...input.selectedLaneIds];
|
|
199
|
+
if (publishedLaneIds.length === 0 || new Set(publishedLaneIds).size !== publishedLaneIds.length
|
|
200
|
+
|| publishedLaneIds.some((laneId) => !laneIds.has(laneId)))
|
|
201
|
+
invalid();
|
|
202
|
+
const laneByEntry = new Map();
|
|
203
|
+
for (const assignment of input.structure.assignments) {
|
|
204
|
+
if (!assignment.entryId || laneByEntry.has(assignment.entryId) || !laneIds.has(assignment.laneId))
|
|
205
|
+
invalid();
|
|
206
|
+
laneByEntry.set(assignment.entryId, assignment.laneId);
|
|
207
|
+
}
|
|
208
|
+
const dependencyKeys = new Set();
|
|
209
|
+
for (const dependency of input.generatedSourceDependencies) {
|
|
210
|
+
const key = `${dependency.generatedEntryId}\0${dependency.sourceEntryId}`;
|
|
211
|
+
if (!laneByEntry.has(dependency.generatedEntryId) || !laneByEntry.has(dependency.sourceEntryId)
|
|
212
|
+
|| dependency.generatedEntryId === dependency.sourceEntryId || dependencyKeys.has(key))
|
|
213
|
+
invalid();
|
|
214
|
+
dependencyKeys.add(key);
|
|
215
|
+
}
|
|
216
|
+
const selected = new Set(publishedLaneIds);
|
|
217
|
+
const included = new Set();
|
|
218
|
+
const requiredBy = new Map();
|
|
219
|
+
for (const [entryId, laneId] of laneByEntry) {
|
|
220
|
+
if (!selected.has(laneId))
|
|
221
|
+
continue;
|
|
222
|
+
included.add(entryId);
|
|
223
|
+
requiredBy.set(entryId, laneId);
|
|
224
|
+
}
|
|
225
|
+
const recorded = [];
|
|
226
|
+
let changed = true;
|
|
227
|
+
while (changed) {
|
|
228
|
+
changed = false;
|
|
229
|
+
for (const dependency of input.generatedSourceDependencies) {
|
|
230
|
+
if (!included.has(dependency.generatedEntryId))
|
|
231
|
+
continue;
|
|
232
|
+
const sourceLaneId = laneByEntry.get(dependency.sourceEntryId);
|
|
233
|
+
const requiredByLaneId = requiredBy.get(dependency.generatedEntryId);
|
|
234
|
+
if (!recorded.some((value) => value.generatedEntryId === dependency.generatedEntryId
|
|
235
|
+
&& value.sourceEntryId === dependency.sourceEntryId)) {
|
|
236
|
+
recorded.push({ ...dependency, sourceLaneId, requiredByLaneId });
|
|
237
|
+
}
|
|
238
|
+
if (!included.has(dependency.sourceEntryId)) {
|
|
239
|
+
included.add(dependency.sourceEntryId);
|
|
240
|
+
requiredBy.set(dependency.sourceEntryId, requiredByLaneId);
|
|
241
|
+
changed = true;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
return {
|
|
246
|
+
publishedLaneIds,
|
|
247
|
+
entryIds: [...included].sort(),
|
|
248
|
+
generatedSourceDependencies: recorded.sort((left, right) => left.generatedEntryId.localeCompare(right.generatedEntryId) || left.sourceEntryId.localeCompare(right.sourceEntryId)),
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
export function reviseStructureRevision(current, edits) {
|
|
252
|
+
const lanes = current.lanes.map((lane) => ({ ...lane }));
|
|
253
|
+
const assignments = current.assignments.map((assignment) => ({ ...assignment }));
|
|
254
|
+
const merges = current.merges.map((merge) => ({ fromLaneIds: [...merge.fromLaneIds], intoLaneId: merge.intoLaneId }));
|
|
255
|
+
const requireLane = (id) => {
|
|
256
|
+
const lane = lanes.find((candidate) => candidate.id === id);
|
|
257
|
+
if (!lane)
|
|
258
|
+
throw new TypeError('structure_edit_invalid');
|
|
259
|
+
return lane;
|
|
260
|
+
};
|
|
261
|
+
for (const edit of edits) {
|
|
262
|
+
if (edit.kind === 'rename') {
|
|
263
|
+
if (!edit.name.trim())
|
|
264
|
+
throw new TypeError('structure_edit_invalid');
|
|
265
|
+
requireLane(edit.laneId).name = edit.name;
|
|
266
|
+
}
|
|
267
|
+
else if (edit.kind === 'recolor') {
|
|
268
|
+
if (!/^#[0-9a-f]{6}$/i.test(edit.color))
|
|
269
|
+
throw new TypeError('structure_edit_invalid');
|
|
270
|
+
requireLane(edit.laneId).color = edit.color;
|
|
271
|
+
}
|
|
272
|
+
else if (edit.kind === 'reorder') {
|
|
273
|
+
if (edit.laneIds.length !== lanes.length || new Set(edit.laneIds).size !== edit.laneIds.length
|
|
274
|
+
|| edit.laneIds.some((id) => !lanes.some((lane) => lane.id === id)))
|
|
275
|
+
throw new TypeError('structure_edit_invalid');
|
|
276
|
+
edit.laneIds.forEach((id, index) => { requireLane(id).order = index; });
|
|
277
|
+
}
|
|
278
|
+
else if (edit.kind === 'merge') {
|
|
279
|
+
if (edit.fromLaneIds.length === 0 || new Set(edit.fromLaneIds).size !== edit.fromLaneIds.length
|
|
280
|
+
|| edit.fromLaneIds.includes(edit.intoLaneId))
|
|
281
|
+
throw new TypeError('structure_edit_invalid');
|
|
282
|
+
const into = requireLane(edit.intoLaneId);
|
|
283
|
+
edit.fromLaneIds.forEach(requireLane);
|
|
284
|
+
for (const assignment of assignments) {
|
|
285
|
+
if (edit.fromLaneIds.includes(assignment.laneId))
|
|
286
|
+
assignment.laneId = into.id;
|
|
287
|
+
}
|
|
288
|
+
for (const laneId of edit.fromLaneIds)
|
|
289
|
+
lanes.splice(lanes.findIndex((lane) => lane.id === laneId), 1);
|
|
290
|
+
merges.push({ fromLaneIds: [...edit.fromLaneIds], intoLaneId: into.id });
|
|
291
|
+
}
|
|
292
|
+
else {
|
|
293
|
+
throw new TypeError('structure_edit_invalid');
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
lanes.sort((left, right) => left.order - right.order || left.id.localeCompare(right.id));
|
|
297
|
+
lanes.forEach((lane, index) => { lane.order = index; });
|
|
298
|
+
const result = canonicalRevision(current.inventoryRevision, lanes, assignments, merges);
|
|
299
|
+
return { ...result, buildId: current.buildId };
|
|
300
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/** Capabilities the companion advertises only when their whole-read runners exist. Kept apart from the
|
|
2
|
+
* runner so the HTTP client (also imported by server tests) does not pull in process-spawning code. */
|
|
3
|
+
export const WHOLE_READ_COMPANION_CAPABILITIES = [
|
|
4
|
+
'whole-read-inventory@1',
|
|
5
|
+
'whole-read-plan@1',
|
|
6
|
+
'whole-read-units@1',
|
|
7
|
+
'whole-read-grouping@1',
|
|
8
|
+
'whole-read-drafting@1',
|
|
9
|
+
'whole-read-checks@1',
|
|
10
|
+
'whole-read-prd@1',
|
|
11
|
+
'whole-read-relationships@1',
|
|
12
|
+
];
|