@withtruing/companion 0.0.0-stage → 0.13.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/LICENSE +21 -0
- package/README.md +329 -3
- package/dist/api-key-caller.js +388 -0
- package/dist/claude-binary.js +122 -0
- package/dist/claude-cli-caller.js +344 -0
- package/dist/cli-arguments.js +11 -0
- package/dist/cli.js +1486 -0
- package/dist/codex-cli-caller.js +536 -0
- package/dist/config.js +119 -0
- package/dist/design-context.js +54 -0
- package/dist/git-range.js +291 -0
- package/dist/http-client.js +214 -0
- package/dist/import-resolver.js +644 -0
- package/dist/investigation-reader.js +1303 -0
- package/dist/local-scan.js +243 -0
- package/dist/managed-repo.js +408 -0
- package/dist/observed-sources.js +85 -0
- package/dist/reader-result-contract.js +467 -0
- package/dist/sandbox.js +215 -0
- package/dist/schema-entities.js +755 -0
- package/dist/service.js +710 -0
- package/dist/shipment.js +115 -0
- package/dist/strict-schema.js +187 -0
- package/dist/surface-mapper.js +357 -0
- package/dist/text-model.js +121 -0
- package/dist/types.js +11 -0
- package/dist/usage-limit.js +130 -0
- package/package.json +50 -6
package/dist/shipment.js
ADDED
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The analyse_shipment task kind's own local-git work — reading a commit the server only knows
|
|
3
|
+
* by sha/title/message (see server/drift.ts's own comment on why the diff never crosses the
|
|
4
|
+
* wire to this package: the server doesn't fetch it at all for a local connection, same
|
|
5
|
+
* structural discipline every other dispatch-guarded function keeps). Shared by both backends
|
|
6
|
+
* (api-key-caller.ts, claude-cli-caller.ts) since neither can skip this step — even the CLI
|
|
7
|
+
* backend has no Bash/git tool available to it (`--restricted --tools "Read,Grep,Glob"`, see
|
|
8
|
+
* claude-cli-caller.ts), so "read this commit's diff" has to happen here, in this package's own
|
|
9
|
+
* trusted code, not inside the model's own tool-use loop the way file exploration does for
|
|
10
|
+
* every other task kind.
|
|
11
|
+
*/
|
|
12
|
+
import { execFile } from 'node:child_process';
|
|
13
|
+
import { promisify } from 'node:util';
|
|
14
|
+
import { managedFetchEnvironment, reportBackgroundFetch } from './managed-repo.js';
|
|
15
|
+
const execFileAsync = promisify(execFile);
|
|
16
|
+
/** Copied verbatim from server/drift.ts's own looksTrivial (path-pattern only) — see that
|
|
17
|
+
* file's comment for why this one travels with the companion while looksIrrelevant (vocabulary-
|
|
18
|
+
* based, scored against the graph's own structured spec data) does not: the dispatch only
|
|
19
|
+
* sends rendered prose text (spec.stableText), not a structured Graph object, so there's
|
|
20
|
+
* nothing for a local vocabulary-overlap check to run against. This one needs only the diff's
|
|
21
|
+
* own changed-paths, which the companion reads directly — a real, if partial, way to keep goal
|
|
22
|
+
* 4 (reduce AI calls) alive on this path: a lockfile-only or CI-only commit still costs zero AI
|
|
23
|
+
* spend even though the fuller server-side filter doesn't run out here. */
|
|
24
|
+
const TRIVIAL_PATH_PATTERNS = [
|
|
25
|
+
/^(pnpm-lock\.yaml|package-lock\.json|yarn\.lock|Gemfile\.lock|poetry\.lock|Cargo\.lock)$/,
|
|
26
|
+
/^\.github\/workflows\//,
|
|
27
|
+
/^\.(eslintrc|prettierrc|editorconfig)/i,
|
|
28
|
+
/^(dist|build|coverage|node_modules)\//,
|
|
29
|
+
];
|
|
30
|
+
function looksTrivial(changedPaths) {
|
|
31
|
+
if (!changedPaths.length)
|
|
32
|
+
return true;
|
|
33
|
+
return changedPaths.every((p) => TRIVIAL_PATH_PATTERNS.some((re) => re.test(p)));
|
|
34
|
+
}
|
|
35
|
+
/** Confirms the commit exists in the local checkout, attempting one `git fetch` first if it
|
|
36
|
+
* doesn't — using whatever git credentials/remotes are already configured locally (the
|
|
37
|
+
* customer's own, e.g. an SSH key or credential helper). A watched managed copy instead asks
|
|
38
|
+
* the Console for short-lived GitHub access. A webhook can fire before a customer's local
|
|
39
|
+
* checkout has pulled the commit it's about; this is the one real recovery attempt before
|
|
40
|
+
* giving up (see the plan's own edge-case table: "posts reason:'sha_not_local' rather than
|
|
41
|
+
* silently analysing the wrong tree state"). */
|
|
42
|
+
async function ensureCommitLocal(repoPath, sha) {
|
|
43
|
+
const hasCommit = async () => {
|
|
44
|
+
try {
|
|
45
|
+
await execFileAsync('git', ['cat-file', '-e', `${sha}^{commit}`], { cwd: repoPath, windowsHide: true });
|
|
46
|
+
return true;
|
|
47
|
+
}
|
|
48
|
+
catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
if (await hasCommit())
|
|
53
|
+
return true;
|
|
54
|
+
try {
|
|
55
|
+
await execFileAsync('git', ['fetch'], { cwd: repoPath, timeout: 60_000, env: await managedFetchEnvironment(repoPath), windowsHide: true });
|
|
56
|
+
reportBackgroundFetch(repoPath, { ok: true });
|
|
57
|
+
}
|
|
58
|
+
catch (error) {
|
|
59
|
+
const stderr = error.stderr;
|
|
60
|
+
reportBackgroundFetch(repoPath, { ok: false, stderr: typeof stderr === 'string' ? stderr : String(error) });
|
|
61
|
+
// Best-effort — network trouble, no remote configured, etc. hasCommit() below is the real
|
|
62
|
+
// verdict either way; a failed fetch just means it stays whatever it already was.
|
|
63
|
+
}
|
|
64
|
+
return hasCommit();
|
|
65
|
+
}
|
|
66
|
+
/** `git show <sha>` rather than `git diff <sha>~1 <sha>` — the latter fails outright on a root
|
|
67
|
+
* commit (no parent to diff against), which is a real case a webhook-triggered check could
|
|
68
|
+
* plausibly hit for a freshly-initialized repo; `git show` handles it correctly (diffs against
|
|
69
|
+
* an empty tree). Accepts the minor redundancy of `git show`'s own commit-metadata header
|
|
70
|
+
* duplicating what the server already sent in spec.volatileText (title/message) rather than
|
|
71
|
+
* trade away that robustness for a marginally cleaner prompt.
|
|
72
|
+
* `git show --name-only --pretty=format:` suppresses the header for the paths-only call, so
|
|
73
|
+
* changedPaths is exactly the file list, nothing else. No MAX_FILE_BYTES-style read cap here —
|
|
74
|
+
* unlike a whole file, a single commit's diff is essentially never pathologically large; the
|
|
75
|
+
* caller (api-key-caller.ts / claude-cli-caller.ts) still degrades gracefully past
|
|
76
|
+
* MAX_LOCAL_DIFF_CHARS the same way the server's own diff-size handling already does. */
|
|
77
|
+
async function readCommitDiff(repoPath, sha) {
|
|
78
|
+
const [{ stdout: diffText }, { stdout: pathsOut }] = await Promise.all([
|
|
79
|
+
execFileAsync('git', ['show', sha], { cwd: repoPath, maxBuffer: 64 * 1024 * 1024, windowsHide: true }),
|
|
80
|
+
execFileAsync('git', ['show', '--name-only', '--pretty=format:', sha], { cwd: repoPath, maxBuffer: 8 * 1024 * 1024, windowsHide: true }),
|
|
81
|
+
]);
|
|
82
|
+
const changedPaths = pathsOut
|
|
83
|
+
.split('\n')
|
|
84
|
+
.map((s) => s.trim())
|
|
85
|
+
.filter(Boolean);
|
|
86
|
+
return { diffText, changedPaths };
|
|
87
|
+
}
|
|
88
|
+
/** The one shared entry point both backends call before ever spending an AI call — ensure the
|
|
89
|
+
* commit is actually here, read its real diff, and apply the same cheap deterministic filter
|
|
90
|
+
* the server runs for a server-execution connection (see poller.ts's own comment on why it
|
|
91
|
+
* can't run server-side for a local one). */
|
|
92
|
+
export async function prepareShipmentAnalysis(repoPath, sha) {
|
|
93
|
+
if (!(await ensureCommitLocal(repoPath, sha)))
|
|
94
|
+
return { outcome: 'sha_not_local' };
|
|
95
|
+
const { diffText, changedPaths } = await readCommitDiff(repoPath, sha);
|
|
96
|
+
if (looksTrivial(changedPaths))
|
|
97
|
+
return { outcome: 'trivial' };
|
|
98
|
+
return { outcome: 'proceed', diffText, changedPaths };
|
|
99
|
+
}
|
|
100
|
+
/** Diffs above this are too expensive/noisy to embed verbatim — mirrors server/drift.ts's own
|
|
101
|
+
* MAX_DIFF_CHARS, just a much higher ceiling: local reads aren't rate-limited or GitHub-API-
|
|
102
|
+
* metered the way the server's own diff fetch is (goal 1), so this is a sanity backstop against
|
|
103
|
+
* a pathological single commit (a vendored/generated file committed whole), not a real-world
|
|
104
|
+
* common-case constraint the way the server's 40,000-char cap is. */
|
|
105
|
+
const MAX_LOCAL_DIFF_CHARS = 300_000;
|
|
106
|
+
/** Embeds a locally-read diff into spec.volatileText, degrading the same way
|
|
107
|
+
* server/drift.ts's own sizeNote does for an oversized diff — changed-files-only, not silently
|
|
108
|
+
* truncated mid-patch. Shared by both backends so the size-aware wording only needs writing
|
|
109
|
+
* once. */
|
|
110
|
+
export function appendDiffToVolatile(spec, diffText, changedPaths) {
|
|
111
|
+
const diffSection = diffText.length <= MAX_LOCAL_DIFF_CHARS
|
|
112
|
+
? `# The diff\n\n${diffText}`
|
|
113
|
+
: `# The diff\n\n(${diffText.length} characters — too large to include in full; going on the changed files list only)\n\nChanged files:\n${changedPaths.join('\n')}`;
|
|
114
|
+
return { ...spec, volatileText: `${spec.volatileText}\n\n${diffSection}` };
|
|
115
|
+
}
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
const RESTRICTED_KEYWORDS = [
|
|
2
|
+
'allOf',
|
|
3
|
+
'oneOf',
|
|
4
|
+
'not',
|
|
5
|
+
'if',
|
|
6
|
+
'then',
|
|
7
|
+
'else',
|
|
8
|
+
];
|
|
9
|
+
function isSchema(value) {
|
|
10
|
+
return value !== null && typeof value === 'object' && !Array.isArray(value);
|
|
11
|
+
}
|
|
12
|
+
function cloneSchema(schema) {
|
|
13
|
+
return structuredClone(schema);
|
|
14
|
+
}
|
|
15
|
+
function propertyPath(path, property) {
|
|
16
|
+
return `${path}.properties[${JSON.stringify(property)}]`;
|
|
17
|
+
}
|
|
18
|
+
function isNullable(schema) {
|
|
19
|
+
const type = schema.type;
|
|
20
|
+
if (type === 'null' || (Array.isArray(type) && type.includes('null')))
|
|
21
|
+
return true;
|
|
22
|
+
if (Array.isArray(schema.enum) && schema.enum.includes(null))
|
|
23
|
+
return true;
|
|
24
|
+
if (Array.isArray(schema.anyOf)) {
|
|
25
|
+
return schema.anyOf.some((alternative) => isSchema(alternative) && isNullable(alternative));
|
|
26
|
+
}
|
|
27
|
+
return false;
|
|
28
|
+
}
|
|
29
|
+
function makeNullable(schema) {
|
|
30
|
+
const type = schema.type;
|
|
31
|
+
const objectOrArray = type === 'object' || type === 'array' || 'properties' in schema || 'items' in schema;
|
|
32
|
+
if (objectOrArray) {
|
|
33
|
+
return { anyOf: [schema, { type: 'null' }] };
|
|
34
|
+
}
|
|
35
|
+
if (typeof type === 'string') {
|
|
36
|
+
schema.type = [type, 'null'];
|
|
37
|
+
}
|
|
38
|
+
else if (Array.isArray(type)) {
|
|
39
|
+
schema.type = [...type, 'null'];
|
|
40
|
+
}
|
|
41
|
+
else if (!Array.isArray(schema.enum)) {
|
|
42
|
+
return { anyOf: [schema, { type: 'null' }] };
|
|
43
|
+
}
|
|
44
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(null)) {
|
|
45
|
+
schema.enum = [...schema.enum, null];
|
|
46
|
+
}
|
|
47
|
+
return schema;
|
|
48
|
+
}
|
|
49
|
+
function transformSchema(schema, schemaPath, valuePath, context) {
|
|
50
|
+
for (const keyword of RESTRICTED_KEYWORDS) {
|
|
51
|
+
if (!(keyword in schema))
|
|
52
|
+
continue;
|
|
53
|
+
if (!context.enforcedAfter.has(keyword)) {
|
|
54
|
+
throw new Error(`Codex rejects JSON Schema keyword ${keyword} at ${schemaPath}; ` +
|
|
55
|
+
`it may only be removed when the caller lists ${keyword} in enforcedAfter and re-checks it after the reply.`);
|
|
56
|
+
}
|
|
57
|
+
delete schema[keyword];
|
|
58
|
+
context.record.removedKeywords.push({ path: schemaPath, keyword });
|
|
59
|
+
}
|
|
60
|
+
if (Array.isArray(schema.anyOf)) {
|
|
61
|
+
schema.anyOf = schema.anyOf.map((alternative, index) => isSchema(alternative)
|
|
62
|
+
? transformSchema(alternative, `${schemaPath}.anyOf[${index}]`, valuePath, context)
|
|
63
|
+
: alternative);
|
|
64
|
+
}
|
|
65
|
+
const objectSchema = schema.type === 'object' || isSchema(schema.properties);
|
|
66
|
+
if (objectSchema) {
|
|
67
|
+
if (!('additionalProperties' in schema)) {
|
|
68
|
+
schema.additionalProperties = false;
|
|
69
|
+
context.record.additionalPropertiesFalseAdded.push(schemaPath);
|
|
70
|
+
}
|
|
71
|
+
else if (schema.additionalProperties !== false) {
|
|
72
|
+
schema.additionalProperties = false;
|
|
73
|
+
context.record.additionalPropertiesChangedToFalse.push(schemaPath);
|
|
74
|
+
}
|
|
75
|
+
const properties = isSchema(schema.properties) ? schema.properties : {};
|
|
76
|
+
const originallyRequired = new Set(Array.isArray(schema.required)
|
|
77
|
+
? schema.required.filter((entry) => typeof entry === 'string')
|
|
78
|
+
: []);
|
|
79
|
+
const propertyNames = Object.keys(properties);
|
|
80
|
+
schema.required = propertyNames;
|
|
81
|
+
const transformedProperties = {};
|
|
82
|
+
for (const property of propertyNames) {
|
|
83
|
+
const propertySchema = properties[property];
|
|
84
|
+
if (!isSchema(propertySchema)) {
|
|
85
|
+
transformedProperties[property] = propertySchema;
|
|
86
|
+
continue;
|
|
87
|
+
}
|
|
88
|
+
const childSchemaPath = propertyPath(schemaPath, property);
|
|
89
|
+
const childValuePath = [...valuePath, property];
|
|
90
|
+
let transformed = transformSchema(propertySchema, childSchemaPath, childValuePath, context);
|
|
91
|
+
if (!originallyRequired.has(property)) {
|
|
92
|
+
context.record.requiredPropertiesAdded.push(childSchemaPath);
|
|
93
|
+
if (!isNullable(transformed)) {
|
|
94
|
+
transformed = makeNullable(transformed);
|
|
95
|
+
context.record.introducedNullableProperties.push({
|
|
96
|
+
schemaPath: childSchemaPath,
|
|
97
|
+
valuePath: childValuePath,
|
|
98
|
+
});
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
transformedProperties[property] = transformed;
|
|
102
|
+
}
|
|
103
|
+
schema.properties = transformedProperties;
|
|
104
|
+
}
|
|
105
|
+
if (isSchema(schema.items)) {
|
|
106
|
+
schema.items = transformSchema(schema.items, `${schemaPath}.items`, [...valuePath, { arrayItem: true }], context);
|
|
107
|
+
}
|
|
108
|
+
else if (Array.isArray(schema.items)) {
|
|
109
|
+
schema.items = schema.items.map((item, index) => isSchema(item)
|
|
110
|
+
? transformSchema(item, `${schemaPath}.items[${index}]`, [...valuePath, { arrayItem: true }], context)
|
|
111
|
+
: item);
|
|
112
|
+
}
|
|
113
|
+
for (const keyword of ['$defs', 'definitions', 'dependentSchemas']) {
|
|
114
|
+
const schemas = schema[keyword];
|
|
115
|
+
if (!isSchema(schemas))
|
|
116
|
+
continue;
|
|
117
|
+
const transformed = {};
|
|
118
|
+
for (const [name, child] of Object.entries(schemas)) {
|
|
119
|
+
transformed[name] = isSchema(child)
|
|
120
|
+
? transformSchema(child, `${schemaPath}.${keyword}[${JSON.stringify(name)}]`, valuePath, context)
|
|
121
|
+
: child;
|
|
122
|
+
}
|
|
123
|
+
schema[keyword] = transformed;
|
|
124
|
+
}
|
|
125
|
+
for (const keyword of ['contains', 'propertyNames']) {
|
|
126
|
+
if (isSchema(schema[keyword])) {
|
|
127
|
+
schema[keyword] = transformSchema(schema[keyword], `${schemaPath}.${keyword}`, [...valuePath, ...(keyword === 'contains' ? [{ arrayItem: true }] : [])], context);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
if (Array.isArray(schema.prefixItems)) {
|
|
131
|
+
schema.prefixItems = schema.prefixItems.map((item, index) => isSchema(item)
|
|
132
|
+
? transformSchema(item, `${schemaPath}.prefixItems[${index}]`, [...valuePath, { arrayItem: true }], context)
|
|
133
|
+
: item);
|
|
134
|
+
}
|
|
135
|
+
return schema;
|
|
136
|
+
}
|
|
137
|
+
/** Convert a conventional object-result schema to Codex/OpenAI strict structured-output form.
|
|
138
|
+
* Restricted conditional/composition keywords are never weakened silently: callers must name
|
|
139
|
+
* each rule that their own post-response validation will enforce. */
|
|
140
|
+
export function toCodexStrictSchema(input, options = {}) {
|
|
141
|
+
if (!isSchema(input) || input.type !== 'object' || 'anyOf' in input) {
|
|
142
|
+
throw new Error('Codex structured output requires a single object schema at the root; root anyOf and non-object roots are not supported.');
|
|
143
|
+
}
|
|
144
|
+
const record = {
|
|
145
|
+
requiredPropertiesAdded: [],
|
|
146
|
+
introducedNullableProperties: [],
|
|
147
|
+
additionalPropertiesFalseAdded: [],
|
|
148
|
+
additionalPropertiesChangedToFalse: [],
|
|
149
|
+
removedKeywords: [],
|
|
150
|
+
};
|
|
151
|
+
return {
|
|
152
|
+
schema: transformSchema(cloneSchema(input), '$', [], {
|
|
153
|
+
enforcedAfter: new Set(options.enforcedAfter ?? []),
|
|
154
|
+
record,
|
|
155
|
+
}),
|
|
156
|
+
record,
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
function stripAtPath(value, path, index = 0) {
|
|
160
|
+
if (value === null || typeof value !== 'object' || index >= path.length)
|
|
161
|
+
return;
|
|
162
|
+
const segment = path[index];
|
|
163
|
+
if (typeof segment !== 'string') {
|
|
164
|
+
if (!Array.isArray(value))
|
|
165
|
+
return;
|
|
166
|
+
for (const item of value)
|
|
167
|
+
stripAtPath(item, path, index + 1);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (Array.isArray(value))
|
|
171
|
+
return;
|
|
172
|
+
const object = value;
|
|
173
|
+
if (index === path.length - 1) {
|
|
174
|
+
if (object[segment] === null)
|
|
175
|
+
delete object[segment];
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
stripAtPath(object[segment], path, index + 1);
|
|
179
|
+
}
|
|
180
|
+
/** Restore the absent-property shape used by the Claude path. Only nullability introduced by
|
|
181
|
+
* this transform is stripped; null accepted by the original schema remains intact. */
|
|
182
|
+
export function stripIntroducedNulls(value, record) {
|
|
183
|
+
for (const introduced of record.introducedNullableProperties) {
|
|
184
|
+
stripAtPath(value, introduced.valuePath);
|
|
185
|
+
}
|
|
186
|
+
return value;
|
|
187
|
+
}
|
|
@@ -0,0 +1,357 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { execFile } from 'node:child_process';
|
|
3
|
+
import { statSync } from 'node:fs';
|
|
4
|
+
import { promisify } from 'node:util';
|
|
5
|
+
import { hasLinkedPathComponent } from './investigation-reader.js';
|
|
6
|
+
import { DEFAULT_MAPPER_SETTINGS, enumerateTrackedFiles, expandImports, isSupportedMapperVersion, MappingFailure, trackedNamedPathStatus, } from './import-resolver.js';
|
|
7
|
+
import { repositoryMissingReason, spawnFailureCode } from './local-scan.js';
|
|
8
|
+
import { createSandbox } from './sandbox.js';
|
|
9
|
+
export const SURFACE_SEED_PROMPT_VERSION = 'map_module_seeds@1';
|
|
10
|
+
export const SURFACE_SEED_MODEL = 'claude-sonnet-5-5';
|
|
11
|
+
const execFileAsync = promisify(execFile);
|
|
12
|
+
const MODEL_ROLES = [
|
|
13
|
+
'implementation',
|
|
14
|
+
'schema_or_migration',
|
|
15
|
+
'ui',
|
|
16
|
+
'registration',
|
|
17
|
+
'contract',
|
|
18
|
+
'config',
|
|
19
|
+
];
|
|
20
|
+
const MODEL_ROLE_SET = new Set(MODEL_ROLES);
|
|
21
|
+
const compareDiagnostic = (a, b) => a.code.localeCompare(b.code) || a.path.localeCompare(b.path) || a.detail.localeCompare(b.detail);
|
|
22
|
+
function failureDetail(errorOrReason) {
|
|
23
|
+
if (typeof errorOrReason === 'string')
|
|
24
|
+
return errorOrReason;
|
|
25
|
+
if (errorOrReason && typeof errorOrReason === 'object') {
|
|
26
|
+
const candidate = errorOrReason;
|
|
27
|
+
if (typeof candidate.reason === 'string')
|
|
28
|
+
return candidate.reason;
|
|
29
|
+
if (typeof candidate.message === 'string')
|
|
30
|
+
return candidate.message;
|
|
31
|
+
}
|
|
32
|
+
return String(errorOrReason);
|
|
33
|
+
}
|
|
34
|
+
function hasUnavailableSpawnCode(error, seen = new Set()) {
|
|
35
|
+
if (!error || typeof error !== 'object' || seen.has(error))
|
|
36
|
+
return false;
|
|
37
|
+
seen.add(error);
|
|
38
|
+
const candidate = error;
|
|
39
|
+
if (candidate.code === 'ENOENT' || candidate.code === 'EACCES')
|
|
40
|
+
return true;
|
|
41
|
+
if (hasUnavailableSpawnCode(candidate.cause, seen))
|
|
42
|
+
return true;
|
|
43
|
+
return Array.isArray(candidate.errors)
|
|
44
|
+
&& candidate.errors.some((nested) => hasUnavailableSpawnCode(nested, seen));
|
|
45
|
+
}
|
|
46
|
+
function repositoryPathIsMissing(repoPath) {
|
|
47
|
+
try {
|
|
48
|
+
return !statSync(repoPath).isDirectory();
|
|
49
|
+
}
|
|
50
|
+
catch (error) {
|
|
51
|
+
const code = spawnFailureCode(error);
|
|
52
|
+
return code === 'ENOENT' || code === 'ENOTDIR';
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
/** Central authority for deciding whether failure to obtain an immutable repository sandbox can
|
|
56
|
+
* consume the retry budget or is a genuinely missing commit. */
|
|
57
|
+
export function classifySandboxFailure(errorOrReason, repoPath) {
|
|
58
|
+
if (repoPath && repositoryPathIsMissing(repoPath)) {
|
|
59
|
+
return { kind: 'transient', code: 'repository_unavailable', detail: repositoryMissingReason(repoPath) };
|
|
60
|
+
}
|
|
61
|
+
const detail = failureDetail(errorOrReason);
|
|
62
|
+
const spawnUnavailable = hasUnavailableSpawnCode(errorOrReason) || /spawn git(?:\.exe)?\b/i.test(detail);
|
|
63
|
+
const repositoryInvalid = /not a git repository|doesn't look like a git repository|cannot change to|no such file or directory/i.test(detail);
|
|
64
|
+
const unavailable = errorOrReason && typeof errorOrReason === 'object'
|
|
65
|
+
? errorOrReason
|
|
66
|
+
: undefined;
|
|
67
|
+
const fetchAttempted = unavailable?.fetchOk === true || unavailable?.fetchOk === false
|
|
68
|
+
|| /after fetch (?:succeeded|failed)/i.test(detail);
|
|
69
|
+
const unresolvedCommit = /commit [0-9a-f]+ could not be resolved/i.test(detail);
|
|
70
|
+
if (!spawnUnavailable && !repositoryInvalid && fetchAttempted && unresolvedCommit) {
|
|
71
|
+
return { kind: 'stable', code: 'commit_unavailable', detail };
|
|
72
|
+
}
|
|
73
|
+
return { kind: 'transient', code: 'repository_unavailable', detail };
|
|
74
|
+
}
|
|
75
|
+
const seedSchema = {
|
|
76
|
+
type: 'object',
|
|
77
|
+
additionalProperties: false,
|
|
78
|
+
required: ['seeds', 'roles'],
|
|
79
|
+
properties: {
|
|
80
|
+
seeds: {
|
|
81
|
+
type: 'array', minItems: 1, maxItems: 12,
|
|
82
|
+
items: {
|
|
83
|
+
type: 'object', additionalProperties: false, required: ['path', 'role', 'reason'],
|
|
84
|
+
properties: {
|
|
85
|
+
path: { type: 'string' },
|
|
86
|
+
role: { enum: [...MODEL_ROLES] },
|
|
87
|
+
reason: { type: 'string' },
|
|
88
|
+
},
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
roles: {
|
|
92
|
+
type: 'array', minItems: MODEL_ROLES.length, maxItems: MODEL_ROLES.length,
|
|
93
|
+
items: {
|
|
94
|
+
type: 'object', additionalProperties: false, required: ['role', 'status', 'note'],
|
|
95
|
+
properties: {
|
|
96
|
+
role: { enum: [...MODEL_ROLES] },
|
|
97
|
+
status: { enum: ['seeded', 'none_identified'] },
|
|
98
|
+
note: { type: 'string' },
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
};
|
|
104
|
+
function safeRepoPath(path) {
|
|
105
|
+
return path.length > 0
|
|
106
|
+
&& !path.startsWith('/')
|
|
107
|
+
&& !path.includes('\\')
|
|
108
|
+
&& !/^[A-Za-z]:/.test(path)
|
|
109
|
+
&& !path.split('/').some((segment) => !segment || segment === '.' || segment === '..');
|
|
110
|
+
}
|
|
111
|
+
function parseNameStatus(output) {
|
|
112
|
+
if (output === '')
|
|
113
|
+
return [];
|
|
114
|
+
if (!output.endsWith('\0'))
|
|
115
|
+
return null;
|
|
116
|
+
const fields = output.slice(0, -1).split('\0');
|
|
117
|
+
const changes = [];
|
|
118
|
+
for (let index = 0; index < fields.length;) {
|
|
119
|
+
const rawStatus = fields[index++];
|
|
120
|
+
const scored = /^([RC])([0-9]{1,3})$/.exec(rawStatus);
|
|
121
|
+
const status = scored?.[1] ?? rawStatus;
|
|
122
|
+
if (!['A', 'D', 'M', 'R', 'C', 'T'].includes(status)
|
|
123
|
+
|| (scored && Number(scored[2]) > 100))
|
|
124
|
+
return null;
|
|
125
|
+
if (status === 'R' || status === 'C') {
|
|
126
|
+
const fromPath = fields[index++];
|
|
127
|
+
const path = fields[index++];
|
|
128
|
+
if (!fromPath || !path || !safeRepoPath(fromPath) || !safeRepoPath(path))
|
|
129
|
+
return null;
|
|
130
|
+
changes.push({ status, fromPath, path });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
const path = fields[index++];
|
|
134
|
+
if (!path || !safeRepoPath(path))
|
|
135
|
+
return null;
|
|
136
|
+
changes.push({ status: status, path });
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return changes.sort((left, right) => left.path.localeCompare(right.path)
|
|
140
|
+
|| left.status.localeCompare(right.status));
|
|
141
|
+
}
|
|
142
|
+
/** Reads the exact tree change offered by the server. A failed or malformed diff simply declines
|
|
143
|
+
* reuse; a fresh model seed stage remains available. */
|
|
144
|
+
export async function readReuseChangeSet(sandboxPath, candidateCommit, headCommit) {
|
|
145
|
+
try {
|
|
146
|
+
const { stdout } = await execFileAsync('git', ['--literal-pathspecs', 'diff', '--no-color', '--name-status', '-z', '-M', candidateCommit, headCommit, '--'], { cwd: sandboxPath, encoding: 'utf8', maxBuffer: 64 * 1024 * 1024, windowsHide: true });
|
|
147
|
+
return parseNameStatus(stdout);
|
|
148
|
+
}
|
|
149
|
+
catch {
|
|
150
|
+
return null;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
function isRecord(value) {
|
|
154
|
+
return !!value && typeof value === 'object' && !Array.isArray(value);
|
|
155
|
+
}
|
|
156
|
+
function validateModelReply(raw) {
|
|
157
|
+
if (!isRecord(raw) || !Array.isArray(raw.seeds) || !Array.isArray(raw.roles)
|
|
158
|
+
|| raw.seeds.length < 1 || raw.seeds.length > 12 || raw.roles.length !== MODEL_ROLES.length) {
|
|
159
|
+
throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model returned the wrong shape');
|
|
160
|
+
}
|
|
161
|
+
const seeds = raw.seeds.map((value) => {
|
|
162
|
+
if (!isRecord(value) || typeof value.path !== 'string' || !MODEL_ROLE_SET.has(String(value.role))
|
|
163
|
+
|| typeof value.reason !== 'string' || !value.reason.trim()) {
|
|
164
|
+
throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model returned a malformed seed');
|
|
165
|
+
}
|
|
166
|
+
return { path: value.path, role: value.role, reason: value.reason };
|
|
167
|
+
});
|
|
168
|
+
const roles = raw.roles.map((value) => {
|
|
169
|
+
if (!isRecord(value) || !MODEL_ROLE_SET.has(String(value.role))
|
|
170
|
+
|| (value.status !== 'seeded' && value.status !== 'none_identified')
|
|
171
|
+
|| typeof value.note !== 'string') {
|
|
172
|
+
throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model returned malformed role accounting');
|
|
173
|
+
}
|
|
174
|
+
return {
|
|
175
|
+
role: value.role,
|
|
176
|
+
status: value.status,
|
|
177
|
+
note: value.note,
|
|
178
|
+
};
|
|
179
|
+
});
|
|
180
|
+
if (new Set(roles.map((entry) => entry.role)).size !== MODEL_ROLES.length
|
|
181
|
+
|| MODEL_ROLES.some((role) => !roles.some((entry) => entry.role === role))) {
|
|
182
|
+
throw new MappingFailure('transient', 'seed_model_invalid', 'Seed model must account for every role exactly once');
|
|
183
|
+
}
|
|
184
|
+
return { seeds, roles };
|
|
185
|
+
}
|
|
186
|
+
function seedPrompt(claim, trackedPaths) {
|
|
187
|
+
return `# Module\n\n${JSON.stringify({
|
|
188
|
+
title: claim.seedInput.module.title,
|
|
189
|
+
specText: claim.seedInput.module.specText,
|
|
190
|
+
}, null, 2)}\n\n# Statements\n\n${JSON.stringify(claim.seedInput.statements.map((statement) => statement.text), null, 2)}\n\n# Tracked repository paths\n\n${trackedPaths.join('\n')}\n\n# Task\n\nChoose 1–12 tracked files most likely to implement this module. Account for every required role. A none_identified role records uncertainty; it is not evidence that the role is absent.`;
|
|
191
|
+
}
|
|
192
|
+
export async function mapSurfaceSnapshot(input) {
|
|
193
|
+
const { claim, sandboxPath, tree } = input;
|
|
194
|
+
if (!isSupportedMapperVersion(claim.mapperVersion)) {
|
|
195
|
+
throw new MappingFailure('stable', 'mapper_version_unsupported', `Unsupported mapper version ${String(claim.mapperVersion)}`);
|
|
196
|
+
}
|
|
197
|
+
const diagnostics = [];
|
|
198
|
+
const namedPaths = [];
|
|
199
|
+
const namedSeeds = [];
|
|
200
|
+
for (const path of [...claim.seedInput.namedPaths].sort((left, right) => left.localeCompare(right))) {
|
|
201
|
+
let status = trackedNamedPathStatus(tree, path);
|
|
202
|
+
if (status !== 'unverifiable' && await hasLinkedPathComponent(sandboxPath, path))
|
|
203
|
+
status = 'unverifiable';
|
|
204
|
+
namedPaths.push({ path, present: status === 'present' });
|
|
205
|
+
if (status === 'present')
|
|
206
|
+
namedSeeds.push({ path, role: 'named', source: 'named' });
|
|
207
|
+
else if (status === 'missing')
|
|
208
|
+
diagnostics.push({
|
|
209
|
+
code: 'named_path_missing', path, detail: 'absent from complete tracked-file enumeration',
|
|
210
|
+
});
|
|
211
|
+
else
|
|
212
|
+
diagnostics.push({
|
|
213
|
+
code: 'named_path_unverifiable', path, detail: 'not a safe tracked regular file',
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
const regularPaths = [...tree.entries.values()]
|
|
217
|
+
.filter((entry) => (entry.mode === '100644' || entry.mode === '100755') && entry.type === 'blob')
|
|
218
|
+
.map((entry) => entry.path)
|
|
219
|
+
.sort((left, right) => left.localeCompare(right));
|
|
220
|
+
const regularSet = new Set(regularPaths);
|
|
221
|
+
let promptVersion;
|
|
222
|
+
let backend;
|
|
223
|
+
let model;
|
|
224
|
+
let modelSeeds;
|
|
225
|
+
let roles;
|
|
226
|
+
if (input.reuse) {
|
|
227
|
+
({ promptVersion, backend, model, modelSeeds, roles } = input.reuse.candidate.modelJudgement);
|
|
228
|
+
modelSeeds = modelSeeds.map((seed) => ({ ...seed }));
|
|
229
|
+
roles = roles.map((role) => ({ ...role }));
|
|
230
|
+
}
|
|
231
|
+
else {
|
|
232
|
+
let raw;
|
|
233
|
+
try {
|
|
234
|
+
raw = await input.callModel({
|
|
235
|
+
model: SURFACE_SEED_MODEL,
|
|
236
|
+
effort: 'medium',
|
|
237
|
+
maxTokens: 4_096,
|
|
238
|
+
system: 'Select plausible module seed files only from the supplied tracked path list. Return strict JSON matching the schema.',
|
|
239
|
+
prompt: seedPrompt(claim, regularPaths),
|
|
240
|
+
schema: seedSchema,
|
|
241
|
+
});
|
|
242
|
+
}
|
|
243
|
+
catch (error) {
|
|
244
|
+
if (error instanceof MappingFailure)
|
|
245
|
+
throw error;
|
|
246
|
+
throw new MappingFailure('transient', 'seed_model_failed', error instanceof Error ? error.message : String(error));
|
|
247
|
+
}
|
|
248
|
+
const reply = validateModelReply(raw);
|
|
249
|
+
promptVersion = SURFACE_SEED_PROMPT_VERSION;
|
|
250
|
+
backend = input.execution.backend;
|
|
251
|
+
model = input.execution.model;
|
|
252
|
+
modelSeeds = reply.seeds.map((seed) => ({ ...seed, valid: false }));
|
|
253
|
+
roles = reply.roles.sort((left, right) => left.role.localeCompare(right.role));
|
|
254
|
+
}
|
|
255
|
+
const validModelSeeds = [];
|
|
256
|
+
for (const seed of modelSeeds) {
|
|
257
|
+
const validAtCommit = regularSet.has(seed.path) && !(await hasLinkedPathComponent(sandboxPath, seed.path));
|
|
258
|
+
if (!input.reuse)
|
|
259
|
+
seed.valid = validAtCommit;
|
|
260
|
+
const valid = seed.valid && validAtCommit;
|
|
261
|
+
if (valid)
|
|
262
|
+
validModelSeeds.push({ path: seed.path, role: seed.role, source: 'model' });
|
|
263
|
+
else
|
|
264
|
+
diagnostics.push({ code: 'model_seed_invalid', path: seed.path, detail: seed.reason });
|
|
265
|
+
}
|
|
266
|
+
const seedByPath = new Map();
|
|
267
|
+
for (const seed of namedSeeds)
|
|
268
|
+
seedByPath.set(seed.path, seed);
|
|
269
|
+
for (const seed of validModelSeeds)
|
|
270
|
+
if (!seedByPath.has(seed.path))
|
|
271
|
+
seedByPath.set(seed.path, seed);
|
|
272
|
+
const seeds = [...seedByPath.values()].sort((a, b) => a.path.localeCompare(b.path));
|
|
273
|
+
if (seeds.length === 0)
|
|
274
|
+
throw new MappingFailure('stable', 'no_seeds', 'No present named path or valid model seed');
|
|
275
|
+
const expanded = await expandImports({
|
|
276
|
+
sandboxPath,
|
|
277
|
+
commit: claim.commit,
|
|
278
|
+
tree,
|
|
279
|
+
seeds,
|
|
280
|
+
settings: claim.mapperSettings,
|
|
281
|
+
mapperVersion: claim.mapperVersion,
|
|
282
|
+
});
|
|
283
|
+
const expansion = diagnostics.some((diagnostic) => diagnostic.code === 'named_path_unverifiable')
|
|
284
|
+
? { ...expanded, status: 'incomplete' }
|
|
285
|
+
: expanded;
|
|
286
|
+
return {
|
|
287
|
+
format: 'auto-surface-manifest@1',
|
|
288
|
+
generationId: claim.generationId,
|
|
289
|
+
workspaceId: claim.workspaceId,
|
|
290
|
+
moduleId: claim.moduleId,
|
|
291
|
+
connectionId: claim.connectionId,
|
|
292
|
+
commit: claim.commit,
|
|
293
|
+
inputDigest: claim.inputDigest,
|
|
294
|
+
seedStage: {
|
|
295
|
+
promptVersion,
|
|
296
|
+
backend,
|
|
297
|
+
model,
|
|
298
|
+
namedPaths,
|
|
299
|
+
modelSeeds: input.reuse
|
|
300
|
+
? modelSeeds
|
|
301
|
+
: modelSeeds.sort((a, b) => a.path.localeCompare(b.path) || a.role.localeCompare(b.role)),
|
|
302
|
+
roles,
|
|
303
|
+
seeds,
|
|
304
|
+
diagnostics: diagnostics.sort(compareDiagnostic),
|
|
305
|
+
...(input.reuse
|
|
306
|
+
? { reusedFrom: input.reuse.candidate.generationId, changeSet: input.reuse.changeSet }
|
|
307
|
+
: {}),
|
|
308
|
+
},
|
|
309
|
+
expansion,
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
export async function runSurfaceMapping(input) {
|
|
313
|
+
let sandbox;
|
|
314
|
+
try {
|
|
315
|
+
sandbox = await createSandbox(input.repoPath, input.claim.commit);
|
|
316
|
+
}
|
|
317
|
+
catch (error) {
|
|
318
|
+
const failure = classifySandboxFailure(error, input.repoPath);
|
|
319
|
+
throw new MappingFailure(failure.kind, failure.code, failure.detail);
|
|
320
|
+
}
|
|
321
|
+
if (sandbox.obtainedBy === 'unavailable') {
|
|
322
|
+
const failure = classifySandboxFailure(sandbox, input.repoPath);
|
|
323
|
+
throw new MappingFailure(failure.kind, failure.code, failure.detail);
|
|
324
|
+
}
|
|
325
|
+
try {
|
|
326
|
+
const tree = await enumerateTrackedFiles(sandbox.path, input.claim.commit);
|
|
327
|
+
const candidate = input.claim.reuseCandidate;
|
|
328
|
+
const changeSet = candidate
|
|
329
|
+
? await readReuseChangeSet(sandbox.path, candidate.commit, input.claim.commit)
|
|
330
|
+
: null;
|
|
331
|
+
const expansionFiles = new Set(candidate?.expansionFiles ?? []);
|
|
332
|
+
const reuse = candidate && changeSet
|
|
333
|
+
&& changeSet.every((change) => change.status === 'M' && expansionFiles.has(change.path))
|
|
334
|
+
? { candidate, changeSet }
|
|
335
|
+
: undefined;
|
|
336
|
+
return await mapSurfaceSnapshot({ ...input, sandboxPath: sandbox.path, tree, ...(reuse ? { reuse } : {}) });
|
|
337
|
+
}
|
|
338
|
+
finally {
|
|
339
|
+
await sandbox.cleanup();
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
export function canonicalManifestJson(manifest) {
|
|
343
|
+
const canonical = (value) => {
|
|
344
|
+
if (Array.isArray(value))
|
|
345
|
+
return value.map(canonical);
|
|
346
|
+
if (value && typeof value === 'object') {
|
|
347
|
+
return Object.fromEntries(Object.keys(value).sort()
|
|
348
|
+
.map((key) => [key, canonical(value[key])]));
|
|
349
|
+
}
|
|
350
|
+
return value;
|
|
351
|
+
};
|
|
352
|
+
return JSON.stringify(canonical(manifest));
|
|
353
|
+
}
|
|
354
|
+
export function manifestHash(manifest) {
|
|
355
|
+
return `sha256:${createHash('sha256').update(canonicalManifestJson(manifest)).digest('hex')}`;
|
|
356
|
+
}
|
|
357
|
+
export { DEFAULT_MAPPER_SETTINGS, MappingFailure };
|