Repository navigation
Expand file tree
/
Copy pathsource.mjs
More file actions
274 lines (257 loc) · 10.4 KB
/
Copy pathsource.mjs
File metadata and controls
274 lines (257 loc) · 10.4 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
#!/usr/bin/env node
/**
* scripts/source.mjs — read the part of a generated template you need.
*
* node scripts/source.mjs outline --project <id> --revision <id>
* node scripts/source.mjs symbol <name> --project <id> --revision <id>
* node scripts/source.mjs constants --project <id> --revision <id>
*
* ## Why
*
* Measured across one create run's transcript: `sed` returned **30.3k tokens
* across 17 calls** and `cat` **17.7k across 18** — together more than half of
* everything the model read back from a tool, and more than twice what all nine
* of the harness's deterministic tools returned across ninety calls. The
* expensive reading was not the diffs or the measurements. It was slicing a
* 1,233-line Java file to find one method, repeatedly, because the only way to
* ask for `renderExperience` was to guess its line range.
*
* A generated template has a shape this harness imposes: geometry constants at
* the top, one render method per visible region. That is enough structure to
* answer by name.
*
* ## What stays with the model
*
* Which method to open, and what to change in it. `outline` exists so that
* decision is made against a list of names and sizes rather than against a file.
*
* Exit: 0 answered | 1 no template or no such symbol | 2 usage | 3 no such revision
*/
import fs from "node:fs";
import path from "node:path";
import {
describeWorkspaceLine,
projectDir as workspaceProjectDir,
resolveWorkspace,
} from "./lib/workspace.mjs";
import { constants, declaredType, extract, methodDiff, methods } from "./lib/java-outline.mjs";
const COMMANDS = new Set(["outline", "symbol", "constants", "diff"]);
function usage(code = 0) {
process.stdout.write(
"usage: node scripts/source.mjs <outline|symbol <name>|constants> --project <id> --revision <id>\n\n" +
" outline every method, its line range and its size\n" +
" symbol <name> one method with its Javadoc\n" +
" constants the named constants the geometry derives from\n" +
" diff what this revision changed, method by method, against its parent\n" +
" --against <id> the revision to diff against (default: this one's parent)\n" +
" --context <n> extra lines either side of a symbol (default 0)\n" +
" --file <path> a Java file to read instead of the revision's template\n" +
" --root <dir> workspace override\n" +
" --json machine-readable\n\n" +
"exit: 0 answered | 1 no template or no such symbol | 2 usage | 3 no such revision\n",
);
process.exit(code);
}
function fail(code, message) {
process.stderr.write(`[source] ${message}\n`);
process.exit(code);
}
function parseArgs(argv) {
const out = {
command: null,
name: null,
project: null,
revision: null,
file: null,
root: null,
context: 0,
against: null,
json: false,
};
for (let i = 0; i < argv.length; i += 1) {
const a = argv[i];
if (a === "--help" || a === "-h") usage(0);
else if (a === "--json") out.json = true;
else if (a === "--project" || a === "-p") out.project = argv[++i];
else if (a === "--revision" || a === "-r") out.revision = argv[++i];
else if (a === "--file") out.file = argv[++i];
else if (a === "--root") out.root = argv[++i];
else if (a === "--context") out.context = Number.parseInt(argv[++i], 10) || 0;
else if (a === "--against") out.against = argv[++i];
else if (a.startsWith("--")) {
process.stderr.write(`[source] unknown argument: ${a}\n`);
usage(2);
} else if (!out.command) out.command = a;
else if (!out.name) out.name = a;
}
if (!COMMANDS.has(out.command)) usage(2);
if (out.command === "symbol" && !out.name) {
process.stderr.write("[source] symbol needs a name\n");
usage(2);
}
if (!out.file && (!out.project || !out.revision)) {
process.stderr.write("[source] --project and --revision are required without --file\n");
usage(2);
}
// `diff` is the exception to --file standing in for a revision: it has to
// find the parent, and a parent is a revision in a project. Without this it
// reached the workspace resolver with a null project id and came back as a
// stack trace, which reads like a crash rather than a wrong invocation.
if (out.command === "diff" && (!out.project || !out.revision)) {
process.stderr.write(
"[source] diff needs --project and --revision even with --file: the parent revision it " +
"compares against is looked up in the project. Name the pair with --revision <id> " +
"--against <id>.\n",
);
usage(2);
}
return out;
}
const args = parseArgs(process.argv.slice(2));
/**
* The revision's generated template.
*
* <p>Largest `.java` in the revision, the same rule `check-structural-smells`
* uses: a revision can hold a spec and a provider beside the template, and the
* template is the one with the rendering in it.</p>
*/
function templatePath(revisionId = args.revision) {
if (args.file && revisionId === args.revision) return path.resolve(args.file);
const workspace = resolveWorkspace({ explicitRoot: args.root ?? null });
const revisionDir = path.join(workspaceProjectDir(workspace, args.project), "revisions", revisionId);
if (!fs.existsSync(revisionDir)) fail(3, `no such revision: ${revisionDir}`);
const candidates = fs
.readdirSync(revisionDir)
.filter((name) => name.endsWith(".java") && !/Test\.java$/i.test(name))
.map((name) => ({ name, size: fs.statSync(path.join(revisionDir, name)).size }))
.sort((a, b) => b.size - a.size);
if (candidates.length === 0) fail(1, `no generated template (.java) in ${revisionDir}`);
return path.join(revisionDir, candidates[0].name);
}
/**
* The revision this one came from, unless the caller named another.
*
* <p>Read off `revision.json` rather than inferred from the numbering: a
* revision's parent is recorded, and a chain can skip, branch or be restored.</p>
*/
function parentRevision() {
if (args.against) return args.against;
const workspace = resolveWorkspace({ explicitRoot: args.root ?? null });
const file = path.join(
workspaceProjectDir(workspace, args.project), "revisions", args.revision, "revision.json",
);
try {
const parent = JSON.parse(fs.readFileSync(file, "utf8")).parentRevisionId;
if (!parent) fail(1, `${args.revision} has no parent to diff against — pass --against <id>`);
return parent;
} catch (error) {
fail(1, `cannot read ${file}: ${error.message}`);
return null;
}
}
const file = templatePath();
if (!fs.existsSync(file)) fail(1, `no such file: ${file}`);
const source = fs.readFileSync(file, "utf8");
const totalLines = source.split(/\r?\n/).length;
let result;
if (args.command === "outline") {
const found = methods(source).sort((a, b) => a.line - b.line);
result = {
file: path.basename(file),
type: declaredType(source),
lines: totalLines,
methods: found.map(({ name, line, endLine, lines, signature, balanced }) => ({
name,
line,
endLine,
lines,
signature,
...(balanced ? {} : { balanced: false }),
})),
};
} else if (args.command === "constants") {
result = {
file: path.basename(file),
type: declaredType(source),
lines: totalLines,
constants: constants(source),
};
} else if (args.command === "diff") {
const against = parentRevision();
const before = fs.readFileSync(templatePath(against), "utf8");
const changes = methodDiff(before, source);
result = {
file: path.basename(file),
revision: args.revision,
against,
lines: totalLines,
...changes,
// A share, not a verdict. What counts as a rewrite is a judgement about
// intent; what this can say is how much of the template stopped being the
// one that was reviewed.
rewroteMostOfIt: changes.touchedShare >= 0.5,
};
} else {
const cut = extract(source, args.name, { context: args.context });
if (!cut) {
const names = methods(source).map((m) => m.name);
fail(
1,
`no method named ${JSON.stringify(args.name)} in ${path.basename(file)}. ` +
`Declared: ${names.join(", ") || "(none)"}`,
);
}
result = { file: path.basename(file), lines: totalLines, ...cut };
}
if (args.json) {
process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
process.exit(0);
}
const banner = args.file ? null : describeWorkspaceLine(resolveWorkspace({ explicitRoot: args.root ?? null }));
if (banner) process.stdout.write(`${banner}\n\n`);
if (args.command === "outline") {
process.stdout.write(`${result.file} — ${result.type ?? "?"}, ${result.lines} lines\n\n`);
for (const method of result.methods) {
process.stdout.write(
` ${String(method.line).padStart(5)}-${String(method.endLine).padEnd(5)} ` +
`${String(method.lines).padStart(4)}L ${method.name}` +
`${method.balanced === false ? " (unbalanced — read it whole)" : ""}\n`,
);
}
process.stdout.write(`\n node scripts/source.mjs symbol <name> --project … --revision …\n`);
} else if (args.command === "constants") {
process.stdout.write(`${result.file} — ${result.constants.length} constants\n\n`);
for (const constant of result.constants) {
process.stdout.write(
` ${String(constant.line).padStart(5)} ${constant.name} = ${constant.value}\n`,
);
}
} else if (args.command === "diff") {
process.stdout.write(
`${result.revision} against ${result.against} — ` +
`${Math.round(result.touchedShare * 100)}% of the methods touched\n\n`,
);
const say = (label, names) => {
if (names.length === 0) return;
process.stdout.write(` ${label.padEnd(10)} ${names.join(", ")}\n`);
};
say("changed", result.changed);
say("added", result.added);
say("removed", result.removed);
process.stdout.write(` ${"unchanged".padEnd(10)} ${result.unchanged.length}\n`);
if (result.rewroteMostOfIt) {
process.stdout.write(
"\n Most of the template is not the one that was reviewed. If that was the intent — a " +
"different construction rather than a correction — it belongs in a revision of its own, " +
"said out loud, so the chain shows where the architecture changed.\n",
);
}
} else {
process.stdout.write(`${result.file}:${result.line}-${result.endLine}\n\n${result.text}\n`);
if (result.balanced === false) {
process.stderr.write(
"[source] the closing brace was not found — this cut may be incomplete, so read the file\n",
);
}
}
process.exit(0);