@renzrollon/interlock 1.4.2 → 1.5.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/.claude-plugin/plugin.json +2 -2
- package/CHANGELOG.md +41 -0
- package/README.md +28 -3
- package/bin/interlock +16 -5
- package/lib/graph/cli.mjs +93 -15
- package/lib/graph/extract/bicep.mjs +67 -0
- package/lib/graph/extract/create-extractor.mjs +15 -0
- package/lib/graph/extract/csharp.mjs +206 -0
- package/lib/graph/extract/csproj.mjs +42 -0
- package/lib/graph/extract/memory.mjs +9 -4
- package/lib/graph/extract/openspec.mjs +9 -5
- package/lib/graph/extract/walk.mjs +37 -12
- package/lib/graph/graph/build.mjs +77 -13
- package/lib/graph/graph/link-csharp.mjs +210 -0
- package/lib/graph/graph/query.mjs +2 -0
- package/lib/graph/graph/report.mjs +11 -1
- package/lib/graph/graph/update.mjs +56 -16
- package/lib/graph/paths.mjs +56 -4
- package/lib/graph/retrieve/context.mjs +2 -0
- package/lib/graph/retrieve/docs-index.mjs +9 -10
- package/lib/graph/retrieve/markdown.mjs +8 -8
- package/lib/limits.mjs +11 -3
- package/lib/outcomes.mjs +8 -1
- package/lib/prompts/implementer.mjs +28 -3
- package/lib/prompts/index.mjs +37 -1
- package/lib/prompts/rejected.mjs +24 -0
- package/lib/prompts/review.mjs +38 -1
- package/lib/receipt.mjs +21 -1
- package/lib/resume-card.mjs +19 -0
- package/lib/run-log.mjs +30 -2
- package/lib/run.mjs +198 -25
- package/lib/verify.mjs +32 -1
- package/lib/waves.mjs +161 -19
- package/package.json +2 -2
- package/shared/TEST-PROFILE.md +1 -1
- package/skills/bootstrap/SKILL.md +11 -4
- package/skills/fix-tests/SKILL.md +1 -0
- package/skills/graph/SKILL.md +12 -1
- package/workflows/ship.js +2 -1
|
@@ -0,0 +1,210 @@
|
|
|
1
|
+
// C# is linked in a pass after the per-file loop: a `using` names a namespace, and a
|
|
2
|
+
// namespace is declared across many files, so it cannot be resolved against a set of
|
|
3
|
+
// paths. This module is pure (no `node:` import) -- it takes every `extractCsharp`
|
|
4
|
+
// record and returns `{ nodes, edges }`, the shape the OpenSpec and memory overlays
|
|
5
|
+
// already return.
|
|
6
|
+
|
|
7
|
+
const INFERRED_REFERENCE_WEIGHT = 0.5;
|
|
8
|
+
|
|
9
|
+
function nameOf(entry) {
|
|
10
|
+
return typeof entry === 'string' ? entry : entry?.name;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function lineOf(entry, fallback = 1) {
|
|
14
|
+
const line = entry && typeof entry === 'object' ? entry.line : undefined;
|
|
15
|
+
return Number.isInteger(line) && line > 0 ? line : fallback;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function ascending(lines) {
|
|
19
|
+
if (!Array.isArray(lines)) return [];
|
|
20
|
+
return lines.filter((n) => Number.isInteger(n)).sort((a, b) => a - b);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** `A.B.C` -> ['A.B.C', 'A.B', 'A'] */
|
|
24
|
+
function dotPrefixes(name) {
|
|
25
|
+
const parts = name.split('.');
|
|
26
|
+
const out = [];
|
|
27
|
+
for (let i = parts.length; i > 0; i -= 1) out.push(parts.slice(0, i).join('.'));
|
|
28
|
+
return out;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function compare(a, b) {
|
|
32
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Link C# records into namespace nodes, test nodes and their edges.
|
|
37
|
+
*
|
|
38
|
+
* @param {Array<{filePath: string, csharp?: object}>} records `extractCsharp` results
|
|
39
|
+
* @returns {{ nodes: object[], edges: object[] }}
|
|
40
|
+
*/
|
|
41
|
+
export function linkCsharp(records) {
|
|
42
|
+
const files = (Array.isArray(records) ? records : [])
|
|
43
|
+
.filter((r) => r && typeof r.filePath === 'string' && r.csharp)
|
|
44
|
+
.sort((a, b) => compare(a.filePath, b.filePath));
|
|
45
|
+
|
|
46
|
+
const nodes = [];
|
|
47
|
+
const edges = [];
|
|
48
|
+
const nodeIds = new Set();
|
|
49
|
+
const edgeKeys = new Set();
|
|
50
|
+
|
|
51
|
+
function addNode(node) {
|
|
52
|
+
if (nodeIds.has(node.id)) return;
|
|
53
|
+
nodeIds.add(node.id);
|
|
54
|
+
nodes.push(node);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function addEdge(edge) {
|
|
58
|
+
const key = `${edge.source}\u0000${edge.target}\u0000${edge.relation}\u0000${edge.source_location}`;
|
|
59
|
+
if (edgeKeys.has(key)) return;
|
|
60
|
+
edgeKeys.add(key);
|
|
61
|
+
edges.push(edge);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Pass 1: every namespace the repository declares, and the types each one holds.
|
|
65
|
+
const declared = new Set();
|
|
66
|
+
const typesByNamespace = new Map(); // namespace -> Map(typeName -> [declFile])
|
|
67
|
+
for (const rec of files) {
|
|
68
|
+
for (const ns of rec.csharp.namespaces || []) {
|
|
69
|
+
const name = nameOf(ns);
|
|
70
|
+
if (!name) continue;
|
|
71
|
+
declared.add(name);
|
|
72
|
+
const id = `namespace:${name}`;
|
|
73
|
+
if (!nodeIds.has(id)) {
|
|
74
|
+
// First declaring file in path order, its first declaration.
|
|
75
|
+
const first = (rec.csharp.namespaces || [])
|
|
76
|
+
.filter((n) => nameOf(n) === name)
|
|
77
|
+
.reduce((best, n) => Math.min(best, lineOf(n)), Infinity);
|
|
78
|
+
addNode({
|
|
79
|
+
id,
|
|
80
|
+
type: 'namespace',
|
|
81
|
+
label: name,
|
|
82
|
+
source_file: rec.filePath,
|
|
83
|
+
source_location: `L${first}`,
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
for (const type of rec.csharp.types || []) {
|
|
88
|
+
const name = nameOf(type);
|
|
89
|
+
if (!name) continue;
|
|
90
|
+
const ns = type && typeof type === 'object' && typeof type.namespace === 'string' ? type.namespace : '';
|
|
91
|
+
if (!typesByNamespace.has(ns)) typesByNamespace.set(ns, new Map());
|
|
92
|
+
const byName = typesByNamespace.get(ns);
|
|
93
|
+
if (!byName.has(name)) byName.set(name, []);
|
|
94
|
+
if (!byName.get(name).includes(rec.filePath)) byName.get(name).push(rec.filePath);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
const declaredByLength = [...declared].sort((a, b) => b.length - a.length || compare(a, b));
|
|
99
|
+
function resolveNamespace(name) {
|
|
100
|
+
return declaredByLength.find((d) => name === d || name.startsWith(`${d}.`)) || null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// Pass 2: edges, per file in path order.
|
|
104
|
+
for (const rec of files) {
|
|
105
|
+
const fileId = `file:${rec.filePath}`;
|
|
106
|
+
const cs = rec.csharp;
|
|
107
|
+
const visible = new Set();
|
|
108
|
+
|
|
109
|
+
for (const ns of cs.namespaces || []) {
|
|
110
|
+
const name = nameOf(ns);
|
|
111
|
+
if (!name) continue;
|
|
112
|
+
addEdge({
|
|
113
|
+
source: fileId,
|
|
114
|
+
target: `namespace:${name}`,
|
|
115
|
+
relation: 'declares',
|
|
116
|
+
confidence: 'EXTRACTED',
|
|
117
|
+
source_file: rec.filePath,
|
|
118
|
+
source_location: `L${lineOf(ns)}`,
|
|
119
|
+
weight: 1,
|
|
120
|
+
});
|
|
121
|
+
for (const p of dotPrefixes(name)) visible.add(p);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const using of cs.usings || []) {
|
|
125
|
+
const name = nameOf(using);
|
|
126
|
+
if (!name) continue;
|
|
127
|
+
const hit = resolveNamespace(name);
|
|
128
|
+
if (hit) visible.add(hit);
|
|
129
|
+
addEdge({
|
|
130
|
+
source: fileId,
|
|
131
|
+
target: hit ? `namespace:${hit}` : `external:${name}`,
|
|
132
|
+
relation: 'imports',
|
|
133
|
+
confidence: 'EXTRACTED',
|
|
134
|
+
source_file: rec.filePath,
|
|
135
|
+
source_location: `L${lineOf(using)}`,
|
|
136
|
+
weight: 1,
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tests = [];
|
|
141
|
+
for (const t of cs.tests || []) {
|
|
142
|
+
if (!t || !t.name) continue;
|
|
143
|
+
const label = `${t.type}.${t.name}`;
|
|
144
|
+
const id = `test:${rec.filePath}#${label}`;
|
|
145
|
+
addNode({
|
|
146
|
+
id,
|
|
147
|
+
type: 'test',
|
|
148
|
+
label,
|
|
149
|
+
source_file: rec.filePath,
|
|
150
|
+
source_location: `L${lineOf(t)}`,
|
|
151
|
+
});
|
|
152
|
+
addEdge({
|
|
153
|
+
source: fileId,
|
|
154
|
+
target: id,
|
|
155
|
+
relation: 'exports',
|
|
156
|
+
confidence: 'EXTRACTED',
|
|
157
|
+
source_file: rec.filePath,
|
|
158
|
+
source_location: `L${lineOf(t)}`,
|
|
159
|
+
weight: 1,
|
|
160
|
+
});
|
|
161
|
+
tests.push({
|
|
162
|
+
id,
|
|
163
|
+
start: Number.isInteger(t.start) ? t.start : lineOf(t),
|
|
164
|
+
end: Number.isInteger(t.end) ? t.end : Infinity,
|
|
165
|
+
});
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
const ownTypes = new Set((cs.types || []).map(nameOf).filter(Boolean));
|
|
169
|
+
const identifiers = cs.identifiers || {};
|
|
170
|
+
for (const ident of Object.keys(identifiers).sort()) {
|
|
171
|
+
if (ownTypes.has(ident)) continue;
|
|
172
|
+
const lines = ascending(identifiers[ident]);
|
|
173
|
+
if (lines.length === 0) continue;
|
|
174
|
+
const targets = new Set();
|
|
175
|
+
for (const ns of visible) {
|
|
176
|
+
for (const declFile of typesByNamespace.get(ns)?.get(ident) || []) {
|
|
177
|
+
targets.add(`symbol:${declFile}#${ident}`);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
for (const target of [...targets].sort()) {
|
|
181
|
+
addEdge({
|
|
182
|
+
source: fileId,
|
|
183
|
+
target,
|
|
184
|
+
relation: 'references',
|
|
185
|
+
confidence: 'INFERRED',
|
|
186
|
+
source_file: rec.filePath,
|
|
187
|
+
source_location: `L${lines[0]}`,
|
|
188
|
+
weight: INFERRED_REFERENCE_WEIGHT,
|
|
189
|
+
name: ident,
|
|
190
|
+
});
|
|
191
|
+
for (const test of tests) {
|
|
192
|
+
const at = lines.find((n) => n >= test.start && n <= test.end);
|
|
193
|
+
if (at === undefined) continue;
|
|
194
|
+
addEdge({
|
|
195
|
+
source: test.id,
|
|
196
|
+
target,
|
|
197
|
+
relation: 'references',
|
|
198
|
+
confidence: 'INFERRED',
|
|
199
|
+
source_file: rec.filePath,
|
|
200
|
+
source_location: `L${at}`,
|
|
201
|
+
weight: INFERRED_REFERENCE_WEIGHT,
|
|
202
|
+
name: ident,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
return { nodes, edges };
|
|
210
|
+
}
|
|
@@ -134,6 +134,8 @@ export function consumers(graph, target) {
|
|
|
134
134
|
);
|
|
135
135
|
if (src?.type === 'file') {
|
|
136
136
|
lines.push(` consumer_file: ${src.source_file}`);
|
|
137
|
+
} else if (src?.type === 'test') {
|
|
138
|
+
lines.push(` consumer_test: ${src.source_file}:${src.source_location} ${src.label}`);
|
|
137
139
|
}
|
|
138
140
|
}
|
|
139
141
|
}
|
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Generate GRAPH_REPORT.md content from a graph.
|
|
3
|
+
*
|
|
4
|
+
* `skipped` is the build's list of paths it could not read (`[{ path, code }]`).
|
|
5
|
+
* They are in no node, so the report names them in a section of their own; with
|
|
6
|
+
* none, the report is what it was before the section existed.
|
|
3
7
|
*/
|
|
4
|
-
export function writeReport(_root, graph, stats) {
|
|
8
|
+
export function writeReport(_root, graph, stats, skipped = []) {
|
|
5
9
|
const degree = new Map();
|
|
6
10
|
for (const n of graph.nodes) degree.set(n.id, { in: 0, out: 0, node: n });
|
|
7
11
|
for (const e of graph.links) {
|
|
@@ -60,6 +64,12 @@ export function writeReport(_root, graph, stats) {
|
|
|
60
64
|
lines.push(`- Symbols: ${stats.symbols}`);
|
|
61
65
|
lines.push(`- Modules: ${stats.modules}`);
|
|
62
66
|
lines.push('');
|
|
67
|
+
if (Array.isArray(skipped) && skipped.length > 0) {
|
|
68
|
+
lines.push('## Not indexed (unreadable)');
|
|
69
|
+
lines.push('');
|
|
70
|
+
for (const entry of skipped) lines.push(`- ${entry.path} (${entry.code})`);
|
|
71
|
+
lines.push('');
|
|
72
|
+
}
|
|
63
73
|
lines.push('## God nodes (highest degree)');
|
|
64
74
|
lines.push('');
|
|
65
75
|
if (hubs.length === 0) {
|
|
@@ -1,6 +1,7 @@
|
|
|
1
|
-
import
|
|
1
|
+
import { extractMemory } from '../extract/memory.mjs';
|
|
2
|
+
import { extractOpenspec } from '../extract/openspec.mjs';
|
|
2
3
|
import { walkSourceFiles } from '../extract/walk.mjs';
|
|
3
|
-
import { loadJson, manifestPath,
|
|
4
|
+
import { createSkips, loadJson, manifestPath, readIndexed, rel } from '../paths.mjs';
|
|
4
5
|
import { buildGraph, writeGraphArtifacts } from './build.mjs';
|
|
5
6
|
|
|
6
7
|
/**
|
|
@@ -8,40 +9,79 @@ import { buildGraph, writeGraphArtifacts } from './build.mjs';
|
|
|
8
9
|
* (v1 keeps it simple — detect staleness, then full rebuild of affected set + dependents
|
|
9
10
|
* is complex; we do full rebuild when any hash differs, which is still fast for mid-size repos).
|
|
10
11
|
*
|
|
11
|
-
*
|
|
12
|
+
* Readability is compared as well as content. A path that was skipped by the
|
|
13
|
+
* recorded build and is still unreadable is not a change: it can never be
|
|
14
|
+
* hashed, so without the manifest's record it would look changed on every run.
|
|
15
|
+
* A path that was indexed and can no longer be read, and one that was skipped
|
|
16
|
+
* and can now be read, each rebuild.
|
|
17
|
+
*
|
|
18
|
+
* `skips` is the command's collector (`createSkips`). It holds the currently
|
|
19
|
+
* unreadable paths when this returns, whether or not the graph was rebuilt.
|
|
20
|
+
*
|
|
21
|
+
* Returns { updated: boolean, stats, reason, skipped }.
|
|
12
22
|
*/
|
|
13
|
-
export function updateGraph(root) {
|
|
23
|
+
export function updateGraph(root, { skips = createSkips(root) } = {}) {
|
|
14
24
|
const manifest = loadJson(manifestPath(root), null);
|
|
15
25
|
if (!manifest?.files) {
|
|
16
|
-
const result = buildGraph(root);
|
|
26
|
+
const result = buildGraph(root, { skips });
|
|
17
27
|
writeGraphArtifacts(root, result);
|
|
18
|
-
return { updated: true, stats: result.stats, reason: 'no-manifest' };
|
|
28
|
+
return { updated: true, stats: result.stats, reason: 'no-manifest', skipped: result.skipped };
|
|
19
29
|
}
|
|
20
30
|
|
|
21
|
-
const currentFiles = walkSourceFiles(root).map((f) => rel(root, f));
|
|
22
31
|
const prevFiles = new Set(Object.keys(manifest.files));
|
|
23
32
|
const changed = [];
|
|
24
33
|
|
|
25
|
-
for (const
|
|
26
|
-
const
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
34
|
+
for (const abs of walkSourceFiles(root, { skips })) {
|
|
35
|
+
const f = rel(root, abs);
|
|
36
|
+
const got = readIndexed(abs);
|
|
37
|
+
if (!got.ok) {
|
|
38
|
+
// Unreadable now, not removed. Whether that is news is decided below,
|
|
39
|
+
// against the recorded skips.
|
|
40
|
+
skips.add(abs, got.code);
|
|
41
|
+
prevFiles.delete(f);
|
|
42
|
+
continue;
|
|
43
|
+
}
|
|
44
|
+
if (manifest.files[f] !== got.sha256) changed.push(f);
|
|
30
45
|
prevFiles.delete(f);
|
|
31
46
|
}
|
|
32
47
|
const removed = [...prevFiles];
|
|
33
48
|
|
|
34
|
-
|
|
35
|
-
|
|
49
|
+
// The overlays read prose the hashes above do not cover, and a build records
|
|
50
|
+
// what they could not read. Run the same readers so the two sets are compared
|
|
51
|
+
// like for like; what they extract is not used here.
|
|
52
|
+
extractOpenspec(root, new Set(), new Set(), { skips });
|
|
53
|
+
extractMemory(root, new Set(), { skips });
|
|
54
|
+
|
|
55
|
+
// A manifest written before skips were recorded reads as one with none.
|
|
56
|
+
const recorded = new Set(
|
|
57
|
+
(Array.isArray(manifest.skipped) ? manifest.skipped : []).map((entry) => entry?.path).filter(Boolean),
|
|
58
|
+
);
|
|
59
|
+
const current = skips.list();
|
|
60
|
+
const unreadableNow = current.filter((entry) => !recorded.has(entry.path)).map((entry) => entry.path);
|
|
61
|
+
const currentPaths = new Set(current.map((entry) => entry.path));
|
|
62
|
+
const readableNow = [...recorded].filter((p) => !currentPaths.has(p));
|
|
63
|
+
|
|
64
|
+
if (
|
|
65
|
+
changed.length === 0 &&
|
|
66
|
+
removed.length === 0 &&
|
|
67
|
+
unreadableNow.length === 0 &&
|
|
68
|
+
readableNow.length === 0
|
|
69
|
+
) {
|
|
70
|
+
return { updated: false, stats: manifest.stats, reason: 'up-to-date', skipped: current };
|
|
36
71
|
}
|
|
37
72
|
|
|
38
|
-
const result = buildGraph(root);
|
|
73
|
+
const result = buildGraph(root, { skips });
|
|
39
74
|
writeGraphArtifacts(root, result);
|
|
75
|
+
const readability =
|
|
76
|
+
unreadableNow.length || readableNow.length
|
|
77
|
+
? ` unreadable=${unreadableNow.length} readable=${readableNow.length}`
|
|
78
|
+
: '';
|
|
40
79
|
return {
|
|
41
80
|
updated: true,
|
|
42
81
|
stats: result.stats,
|
|
43
|
-
reason: `changed=${changed.length} removed=${removed.length}`,
|
|
82
|
+
reason: `changed=${changed.length} removed=${removed.length}${readability}`,
|
|
44
83
|
changed,
|
|
45
84
|
removed,
|
|
85
|
+
skipped: result.skipped,
|
|
46
86
|
};
|
|
47
87
|
}
|
package/lib/graph/paths.mjs
CHANGED
|
@@ -49,10 +49,62 @@ export function abs(root, relPath) {
|
|
|
49
49
|
return path.join(root, ...relPath.split('/'));
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
52
|
+
/** Marker on every stderr line naming a path an indexing command could not read. */
|
|
53
|
+
export const SKIP_MARKER = 'GRAPH SKIPPED UNREADABLE';
|
|
54
|
+
|
|
55
|
+
/** Marker on the one stderr line a query prints when its graph was built past skipped paths. */
|
|
56
|
+
export const PARTIAL_MARKER = 'GRAPH PARTIAL';
|
|
57
|
+
|
|
58
|
+
function codeOf(errorOrCode) {
|
|
59
|
+
if (typeof errorOrCode === 'string' && errorOrCode) return errorOrCode;
|
|
60
|
+
const code = errorOrCode && typeof errorOrCode === 'object' ? errorOrCode.code : undefined;
|
|
61
|
+
return typeof code === 'string' && code ? code : 'UNKNOWN';
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/**
|
|
65
|
+
* Read a file once. Returns its text and the sha256 of the same bytes, or the
|
|
66
|
+
* error code the read failed with. Never throws and never prints: the caller
|
|
67
|
+
* decides what a failed read means (usually: `skips.add`).
|
|
68
|
+
*/
|
|
69
|
+
export function readIndexed(absPath) {
|
|
70
|
+
let bytes;
|
|
71
|
+
try {
|
|
72
|
+
bytes = fs.readFileSync(absPath);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
return { ok: false, code: codeOf(error) };
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
ok: true,
|
|
78
|
+
text: bytes.toString('utf8'),
|
|
79
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Collector for paths that could not be read. One entry per path, root-relative
|
|
85
|
+
* with forward slashes, listed sorted by path.
|
|
86
|
+
*/
|
|
87
|
+
export function createSkips(root) {
|
|
88
|
+
const byPath = new Map();
|
|
89
|
+
return {
|
|
90
|
+
add(absPath, errorOrCode, note) {
|
|
91
|
+
const p = rel(root, absPath);
|
|
92
|
+
const entry = { path: p, code: codeOf(errorOrCode) };
|
|
93
|
+
if (note) entry.note = note;
|
|
94
|
+
const prior = byPath.get(p);
|
|
95
|
+
if (prior && (prior.note || !entry.note)) return;
|
|
96
|
+
byPath.set(p, entry);
|
|
97
|
+
},
|
|
98
|
+
list() {
|
|
99
|
+
return [...byPath.values()].sort((a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0));
|
|
100
|
+
},
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** `GRAPH SKIPPED UNREADABLE: <path> (<code>)`, plus ` — <note>` when there is one. */
|
|
105
|
+
export function formatSkip(entry) {
|
|
106
|
+
const note = entry.note ? ` \u2014 ${entry.note}` : '';
|
|
107
|
+
return `${SKIP_MARKER}: ${entry.path} (${entry.code})${note}`;
|
|
56
108
|
}
|
|
57
109
|
|
|
58
110
|
export function loadJson(filePath, fallback = null) {
|
|
@@ -21,6 +21,7 @@ export function retrieveContext(root, {
|
|
|
21
21
|
graphBudget = null,
|
|
22
22
|
docsBudget = null,
|
|
23
23
|
docsDirs = null,
|
|
24
|
+
skips,
|
|
24
25
|
} = {}) {
|
|
25
26
|
const terms = deriveTerms({ query, changedFiles, commitSubject });
|
|
26
27
|
const searchQuery = query || terms.slice(0, 8).join(' ');
|
|
@@ -64,6 +65,7 @@ export function retrieveContext(root, {
|
|
|
64
65
|
changedFiles,
|
|
65
66
|
dirs: docsDirs ?? undefined,
|
|
66
67
|
budget: docsShare,
|
|
68
|
+
skips,
|
|
67
69
|
});
|
|
68
70
|
|
|
69
71
|
parts.push(docsResult.text.trimEnd());
|
|
@@ -1,10 +1,9 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import {
|
|
3
2
|
docsDigestPath,
|
|
4
3
|
docsIndexPath,
|
|
5
4
|
ensureGraphDir,
|
|
5
|
+
readIndexed,
|
|
6
6
|
rel,
|
|
7
|
-
sha256File,
|
|
8
7
|
writeJson,
|
|
9
8
|
} from '../paths.mjs';
|
|
10
9
|
import { walkMarkdown, resolveDocsDirs } from '../extract/walk.mjs';
|
|
@@ -41,21 +40,21 @@ export function extractBlurb(content, blurbTokens = DEFAULT_BLURB_TOKENS) {
|
|
|
41
40
|
export function buildDocsIndex(root, {
|
|
42
41
|
dirs: requestedDirs,
|
|
43
42
|
blurbTokens = DEFAULT_BLURB_TOKENS,
|
|
43
|
+
skips,
|
|
44
44
|
} = {}) {
|
|
45
45
|
const dirs = resolveDocsDirs(root, requestedDirs);
|
|
46
46
|
const files = [];
|
|
47
47
|
let totalSections = 0;
|
|
48
48
|
let totalTokens = 0;
|
|
49
49
|
|
|
50
|
-
for (const absPath of walkMarkdown(root, dirs)) {
|
|
50
|
+
for (const absPath of walkMarkdown(root, dirs, { skips })) {
|
|
51
51
|
const filePath = rel(root, absPath);
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
} catch {
|
|
52
|
+
const read = readIndexed(absPath);
|
|
53
|
+
if (!read.ok) {
|
|
54
|
+
if (skips) skips.add(absPath, read.code);
|
|
56
55
|
continue;
|
|
57
56
|
}
|
|
58
|
-
const sha256 =
|
|
57
|
+
const { text, sha256 } = read;
|
|
59
58
|
const sections = parseMarkdownSections(filePath, text).map((s) => {
|
|
60
59
|
const tokens_est = estimateTokens(s.content);
|
|
61
60
|
return {
|
|
@@ -97,8 +96,8 @@ export function buildDocsIndex(root, {
|
|
|
97
96
|
* Returns { index, path, written }.
|
|
98
97
|
*/
|
|
99
98
|
export function writeDocsIndex(root, options = {}) {
|
|
100
|
-
const { write = true, dirs, blurbTokens } = options;
|
|
101
|
-
const index = buildDocsIndex(root, { dirs, blurbTokens });
|
|
99
|
+
const { write = true, dirs, blurbTokens, skips } = options;
|
|
100
|
+
const index = buildDocsIndex(root, { dirs, blurbTokens, skips });
|
|
102
101
|
const outPath = docsIndexPath(root);
|
|
103
102
|
if (write) {
|
|
104
103
|
ensureGraphDir(root);
|
|
@@ -1,6 +1,5 @@
|
|
|
1
|
-
import fs from 'node:fs';
|
|
2
1
|
import path from 'node:path';
|
|
3
|
-
import { rel } from '../paths.mjs';
|
|
2
|
+
import { readIndexed, rel } from '../paths.mjs';
|
|
4
3
|
import { walkMarkdown, resolveDocsDirs } from '../extract/walk.mjs';
|
|
5
4
|
import { estimateTokens } from '../format.mjs';
|
|
6
5
|
|
|
@@ -150,6 +149,7 @@ export function retrieveMarkdownSections(root, {
|
|
|
150
149
|
perSectionLines = DEFAULT_PER_SECTION_LINES,
|
|
151
150
|
perSectionTokens = DEFAULT_PER_SECTION_TOKENS,
|
|
152
151
|
minScore = 1,
|
|
152
|
+
skips,
|
|
153
153
|
} = {}) {
|
|
154
154
|
const terms = explicitTerms ?? deriveTerms({ query, changedFiles });
|
|
155
155
|
const changedPaths = changedFiles.map((f) => String(f).replace(/\\/g, '/'));
|
|
@@ -157,19 +157,19 @@ export function retrieveMarkdownSections(root, {
|
|
|
157
157
|
// Fall back to probing only when the caller did not name dirs AND the
|
|
158
158
|
// preferred roots are empty on this repo.
|
|
159
159
|
let dirs = requestedDirs ?? DEFAULT_DIRS;
|
|
160
|
-
if (!requestedDirs && walkMarkdown(root, DEFAULT_DIRS).length === 0) {
|
|
160
|
+
if (!requestedDirs && walkMarkdown(root, DEFAULT_DIRS, { skips }).length === 0) {
|
|
161
161
|
dirs = resolveDocsDirs(root, null);
|
|
162
162
|
}
|
|
163
163
|
|
|
164
164
|
const allSections = [];
|
|
165
|
-
for (const absPath of walkMarkdown(root, dirs)) {
|
|
165
|
+
for (const absPath of walkMarkdown(root, dirs, { skips })) {
|
|
166
166
|
const filePath = rel(root, absPath);
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
} catch {
|
|
167
|
+
const read = readIndexed(absPath);
|
|
168
|
+
if (!read.ok) {
|
|
169
|
+
if (skips) skips.add(absPath, read.code);
|
|
171
170
|
continue;
|
|
172
171
|
}
|
|
172
|
+
const text = read.text;
|
|
173
173
|
for (const section of parseMarkdownSections(filePath, text)) {
|
|
174
174
|
const s = scoreSection(section, terms, changedPaths);
|
|
175
175
|
if (s >= minScore) {
|
package/lib/limits.mjs
CHANGED
|
@@ -87,6 +87,12 @@ export const LIMITS = {
|
|
|
87
87
|
/**
|
|
88
88
|
* Soft budget for inter-wave verification. Past this, drop to typecheck only
|
|
89
89
|
* rather than letting the checks outweigh the work they guard.
|
|
90
|
+
*
|
|
91
|
+
* This is a floor, not the budget every run gets: a test profile whose
|
|
92
|
+
* `unit.timeout_ms` is a positive integer raises it to `interWaveVerifications`
|
|
93
|
+
* times that allowance (`interWaveVerifyBudget` in `lib/verify.mjs`), and
|
|
94
|
+
* nothing a profile says can lower it. A caller's explicit `--budget-ms` still
|
|
95
|
+
* overrides both.
|
|
90
96
|
*/
|
|
91
97
|
interWaveVerifyBudgetMs: 60_000,
|
|
92
98
|
|
|
@@ -115,9 +121,11 @@ export const LIMITS = {
|
|
|
115
121
|
/**
|
|
116
122
|
* Character budget for one task's wave handoff — `summary`, `next`,
|
|
117
123
|
* `blocker` and the joined `evidence` locators added together. A packet over
|
|
118
|
-
* this
|
|
124
|
+
* this is rejected whole rather than truncated: silent truncation is the
|
|
119
125
|
* degradation the schema exists to forbid, and a next wave reading half a
|
|
120
|
-
* sentence is worse off than one told the report was rejected.
|
|
126
|
+
* sentence is worse off than one told the report was rejected. The task that
|
|
127
|
+
* filed it stays done; the paths a rejected report hands on are held to this
|
|
128
|
+
* same cap.
|
|
121
129
|
* See add-wave-handoff-and-prompt-snapshots design.md §2.
|
|
122
130
|
*/
|
|
123
131
|
maxHandoffChars: 2000,
|
|
@@ -559,7 +567,7 @@ export function formatLimits({ runtimeObserved } = {}) {
|
|
|
559
567
|
['root-cause iterations (per run)', LIMITS.rootCauseIterations],
|
|
560
568
|
['task failures tolerated', LIMITS.taskFailureHalt],
|
|
561
569
|
['run steps (per run)', LIMITS.maxRunSteps],
|
|
562
|
-
['inter-wave verify budget', `${LIMITS.interWaveVerifyBudgetMs / 1000}s`],
|
|
570
|
+
['inter-wave verify budget (floor)', `${LIMITS.interWaveVerifyBudgetMs / 1000}s`],
|
|
563
571
|
['inter-wave verifications (per run)', LIMITS.interWaveVerifications],
|
|
564
572
|
['verify spill threshold (bytes)', LIMITS.verifySpillBytes],
|
|
565
573
|
['verify preview budget (chars)', LIMITS.verifyPreviewChars],
|
package/lib/outcomes.mjs
CHANGED
|
@@ -177,7 +177,14 @@ function waveTallies(value) {
|
|
|
177
177
|
wave: nullableCount(w.wave),
|
|
178
178
|
ok: nullableCount(w.ok),
|
|
179
179
|
failed: nullableCount(w.failed),
|
|
180
|
-
notAttempted: nullableStringArray(w.notAttempted, MAX_NAME) || []
|
|
180
|
+
notAttempted: nullableStringArray(w.notAttempted, MAX_NAME) || [],
|
|
181
|
+
// Task ids whose handoff packet was rejected though the task reported success.
|
|
182
|
+
// Carried only when the row holds the key: a row from before the field
|
|
183
|
+
// existed keeps the shape it had, and an absent key reads as unobserved --
|
|
184
|
+
// never `[]`, which would say the run looked and found none.
|
|
185
|
+
...(w.reportRejected !== undefined
|
|
186
|
+
? { reportRejected: nullableStringArray(w.reportRejected, MAX_NAME) }
|
|
187
|
+
: {})
|
|
181
188
|
}
|
|
182
189
|
})
|
|
183
190
|
}
|
|
@@ -11,7 +11,17 @@
|
|
|
11
11
|
// assembled text has to be a deliberate act that updates a file, not a side
|
|
12
12
|
// effect of editing nearby control flow.
|
|
13
13
|
|
|
14
|
-
|
|
14
|
+
import { renderRejectedReport } from './rejected.mjs'
|
|
15
|
+
|
|
16
|
+
export function assembleImplementerPrompt({
|
|
17
|
+
change,
|
|
18
|
+
lane,
|
|
19
|
+
task,
|
|
20
|
+
previousHandoffs,
|
|
21
|
+
previousRejectedReports,
|
|
22
|
+
isolateWaves,
|
|
23
|
+
solo
|
|
24
|
+
}) {
|
|
15
25
|
// A lane is the unit now; a bare `task` is still accepted and means a lane of
|
|
16
26
|
// one. That is not politeness to old callers — a one-task lane MUST assemble
|
|
17
27
|
// byte-identically to the pre-lane prompt, and sharing one code path is the
|
|
@@ -54,6 +64,19 @@ export function assembleImplementerPrompt({ change, lane, task, previousHandoffs
|
|
|
54
64
|
`\n`
|
|
55
65
|
: ''
|
|
56
66
|
|
|
67
|
+
// A previous-wave task that said it succeeded but whose packet did not validate.
|
|
68
|
+
// It is not a failure and has no packet to quote, so it gets its own block: the
|
|
69
|
+
// reason the packet was rejected and the paths it claimed, to be read, not trusted.
|
|
70
|
+
// Empty or absent renders nothing, so every existing prompt stays byte-identical.
|
|
71
|
+
const rejectedEntries = (Array.isArray(previousRejectedReports) ? previousRejectedReports : []).filter(
|
|
72
|
+
r => r && typeof r === 'object'
|
|
73
|
+
)
|
|
74
|
+
const rejected = rejectedEntries.length
|
|
75
|
+
? `\nNO VALIDATED REPORT (the packet was rejected; read the paths, assume nothing):\n` +
|
|
76
|
+
rejectedEntries.map(renderRejectedReport).join('\n') +
|
|
77
|
+
`\n`
|
|
78
|
+
: ''
|
|
79
|
+
|
|
57
80
|
// A lane of one renders exactly the text it rendered before lanes existed —
|
|
58
81
|
// the single-task fixtures are the pin that keeps that honest.
|
|
59
82
|
const single = tasks.length === 1
|
|
@@ -137,12 +160,14 @@ export function assembleImplementerPrompt({ change, lane, task, previousHandoffs
|
|
|
137
160
|
? `- If your tier is 1 or 2: after typecheck/lint pass, stop. Do not refactor or polish.\n`
|
|
138
161
|
: '') +
|
|
139
162
|
previous +
|
|
163
|
+
rejected +
|
|
140
164
|
isolation +
|
|
141
165
|
handoff +
|
|
142
166
|
`- status "ok" means blocker is null; "blocked" and "partial" need a non-empty blocker.\n` +
|
|
143
167
|
`- evidence is at most 8 locators (path, path:line, path:start-end) — never file bodies.\n` +
|
|
144
|
-
`- Keep it terse. A packet over the character cap \`interlock limits\` publishes
|
|
145
|
-
`
|
|
168
|
+
`- Keep it terse. A packet over the character cap \`interlock limits\` publishes is rejected ` +
|
|
169
|
+
`whole — it is never truncated for you, and your task is recorded as having filed no usable ` +
|
|
170
|
+
`report.\n\n` +
|
|
146
171
|
(single
|
|
147
172
|
? `Report ok:false if you could not complete the task, with what blocked you.`
|
|
148
173
|
: `Report outcome "failed" for the task that blocked you, with what blocked you.`)
|