unitbob 0.2.8 → 0.3.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.
@@ -0,0 +1,240 @@
1
+ import { readHostSuiteOutputsPerBranch, readSuiteBuildRequest, } from "../files/suiteBuild.js";
2
+ // The assignment, reduced to what a local check can compare against. Ids are
3
+ // recovered from `contract_key`, which the server derives as `contract:<id>` and
4
+ // both sides copy verbatim — so nothing here has to know whether this branch's
5
+ // ids are called `interface_id` or `capability_id`.
6
+ const CONTRACT_PREFIX = 'contract:';
7
+ export function collectBuildProblems(request, outputs) {
8
+ const problems = [];
9
+ const branchFor = new Map(request.branches.map((branch) => [branch.suite_kind, branch]));
10
+ for (const output of outputs) {
11
+ // The host said plainly that it could not build this one. That is an answer,
12
+ // not a malformed answer, and the server records it as such.
13
+ if (output.build_error)
14
+ continue;
15
+ const branch = branchFor.get(output.suite_kind);
16
+ if (!branch)
17
+ continue; // reading the answer already refused this one
18
+ const add = (message) => { problems.push({ branch: output.suite_kind, message }); };
19
+ checkRunnerManifest(branch, output, add);
20
+ checkAssignment(branch, output, add);
21
+ }
22
+ return problems;
23
+ }
24
+ // After spec 32-5 the envelope comes down from the server inside the request, so
25
+ // there is nothing here to derive — only to confirm the host copied it. This is
26
+ // the field most likely to be rejected after all the work is done, which is
27
+ // exactly why it is worth a second of checking beforehand.
28
+ function checkRunnerManifest(branch, output, add) {
29
+ if (branch.runner_manifest === undefined)
30
+ return;
31
+ if (!sameJson(branch.runner_manifest, output.runner_manifest)) {
32
+ add('runner_manifest does not match the one the request issued. Copy it verbatim — the server ' +
33
+ 'accepts only the exact combinations it named.\n' +
34
+ ` issued: ${stableJson(branch.runner_manifest)}\n` +
35
+ ` answered: ${stableJson(output.runner_manifest)}`);
36
+ }
37
+ }
38
+ // Every assigned id accounted for exactly once, and every marker the one the
39
+ // server minted. A marker the host invented or edited severs the only join
40
+ // between a runner's output and the map, so it cannot be allowed to travel.
41
+ function checkAssignment(branch, output, add) {
42
+ const assigned = assignedCases(branch.assignment);
43
+ if (assigned.length === 0) {
44
+ // An assignment with no cases in it is normal — a map with nothing to guard
45
+ // yet. An assignment that has content this walker could not read is not: the
46
+ // check would pass everything from then on and never say why. Fail open, but
47
+ // never fail open quietly.
48
+ if (hasContent(branch.assignment)) {
49
+ add('this branch\'s assignment could not be read, so its coverage was not checked here. ' +
50
+ 'The server still checks it; if this persists the connector is older than the assignment format.');
51
+ }
52
+ return;
53
+ }
54
+ const metadata = output.test_metadata;
55
+ const entries = Array.isArray(metadata?.capabilities) ? metadata.capabilities : null;
56
+ if (!entries) {
57
+ add('test_metadata must carry a capabilities array, one entry per assigned id.');
58
+ return;
59
+ }
60
+ const byId = new Map(assigned.map((entry) => [entry.id, entry]));
61
+ const idKey = idKeyOf(branch.assignment, assigned);
62
+ const seen = new Map();
63
+ const suiteText = suiteBytes(output);
64
+ for (const entry of entries) {
65
+ const row = (entry ?? {});
66
+ const id = String(idKey ? row[idKey] ?? '' : '');
67
+ const expected = byId.get(id);
68
+ if (!expected) {
69
+ add(`test_metadata names "${id || '(no id)'}", which is not in this branch's assignment.`);
70
+ continue;
71
+ }
72
+ seen.set(id, (seen.get(id) ?? 0) + 1);
73
+ checkOneCase(row, id, expected, suiteText, add);
74
+ }
75
+ for (const [id, count] of seen) {
76
+ if (count > 1)
77
+ add(`${id} is answered ${count} times — every assigned id is answered exactly once.`);
78
+ }
79
+ const missing = assigned.filter((entry) => !seen.has(entry.id)).map((entry) => entry.id).sort();
80
+ if (missing.length > 0) {
81
+ add(`no answer for ${missing.length} assigned id(s): ${missing.join(', ')}.`);
82
+ }
83
+ }
84
+ function checkOneCase(row, id, expected, suiteText, add) {
85
+ const status = String(row.status ?? '');
86
+ if (status === 'unguarded') {
87
+ if (!String(row.reason ?? '').trim()) {
88
+ add(`${id} is unguarded but gives no business reason for it.`);
89
+ }
90
+ return;
91
+ }
92
+ if (status !== 'covered') {
93
+ add(`${id} must be answered "covered" or "unguarded" (got ${JSON.stringify(status)}).`);
94
+ return;
95
+ }
96
+ if (String(row.contract_key ?? '') !== expected.contract_key) {
97
+ add(`${id} carries contract_key ${JSON.stringify(row.contract_key)} — it must be copied verbatim as "${expected.contract_key}".`);
98
+ }
99
+ if (String(row.case_marker ?? '') !== expected.case_marker) {
100
+ add(`${id} carries case_marker ${JSON.stringify(row.case_marker)} — it must be copied verbatim as "${expected.case_marker}". Markers are never minted or edited locally.`);
101
+ return;
102
+ }
103
+ // Declared covered, but the marker never made it into a test name or a
104
+ // Gherkin tag. The server refuses this, and rightly: without the marker in the
105
+ // suite there is nothing to join a result to, so the capability would report
106
+ // as a mismatch rather than as the green it claims.
107
+ if (suiteText && !suiteText.includes(expected.case_marker)) {
108
+ add(`${id} is answered "covered", but its marker ${expected.case_marker} appears nowhere in the suite files.`);
109
+ }
110
+ }
111
+ // Which field names the id. Read off the *assignment*, where the answer is
112
+ // exact: the id is already known (it is `contract_key` minus its prefix), so the
113
+ // field holding it can be identified rather than guessed.
114
+ //
115
+ // An earlier version searched the host's answer for any string field whose value
116
+ // happened to be an assigned id. That usually landed on the right key and could
117
+ // just as well have landed on a `headline` that echoed the id. Neither branch's
118
+ // key name is written down here either way — `interface_id` and `capability_id`
119
+ // stay the server's business.
120
+ function idKeyOf(assignment, cases) {
121
+ const ids = new Set(cases.map((entry) => entry.id));
122
+ let found = null;
123
+ const walk = (value) => {
124
+ if (found)
125
+ return;
126
+ if (Array.isArray(value)) {
127
+ value.forEach(walk);
128
+ return;
129
+ }
130
+ if (!value || typeof value !== 'object')
131
+ return;
132
+ const row = value;
133
+ if (typeof row.contract_key === 'string') {
134
+ const id = row.contract_key.slice(CONTRACT_PREFIX.length);
135
+ for (const [key, candidate] of Object.entries(row)) {
136
+ if (key !== 'contract_key' && candidate === id && ids.has(id)) {
137
+ found = key;
138
+ return;
139
+ }
140
+ }
141
+ }
142
+ Object.values(row).forEach(walk);
143
+ };
144
+ walk(assignment);
145
+ return found;
146
+ }
147
+ // Does the assignment carry anything at all? Distinguishes "nothing to guard"
148
+ // from "we could not read what was there".
149
+ function hasContent(assignment) {
150
+ if (Array.isArray(assignment))
151
+ return assignment.length > 0;
152
+ if (!assignment || typeof assignment !== 'object')
153
+ return false;
154
+ return Object.values(assignment).some(hasContent);
155
+ }
156
+ // The assignment is an opaque body the server composed, so it is walked rather
157
+ // than destructured: every object carrying a `contract_key` is one assigned
158
+ // case, wherever the shape happens to nest it.
159
+ function assignedCases(assignment) {
160
+ const found = [];
161
+ const walk = (value) => {
162
+ if (Array.isArray(value)) {
163
+ value.forEach(walk);
164
+ return;
165
+ }
166
+ if (!value || typeof value !== 'object')
167
+ return;
168
+ const row = value;
169
+ const key = row.contract_key;
170
+ const marker = row.case_marker;
171
+ if (typeof key === 'string' && key.startsWith(CONTRACT_PREFIX) && typeof marker === 'string') {
172
+ found.push({ id: key.slice(CONTRACT_PREFIX.length), contract_key: key, case_marker: marker });
173
+ }
174
+ Object.values(row).forEach(walk);
175
+ };
176
+ walk(assignment);
177
+ return found;
178
+ }
179
+ // Every byte of the branch's suite, main file and support files together, for
180
+ // the "is the marker actually in there" check.
181
+ function suiteBytes(output) {
182
+ const file = output.suite_file;
183
+ if (!file)
184
+ return '';
185
+ return [file.content, ...(Array.isArray(file.support_files) ? file.support_files.map((f) => f.content) : [])]
186
+ .filter((content) => typeof content === 'string')
187
+ .join('\n');
188
+ }
189
+ function sameJson(a, b) {
190
+ return stableJson(a) === stableJson(b);
191
+ }
192
+ function stableJson(value) {
193
+ if (Array.isArray(value))
194
+ return `[${value.map(stableJson).join(',')}]`;
195
+ if (value && typeof value === 'object') {
196
+ const object = value;
197
+ return `{${Object.keys(object).sort().map((key) => `${JSON.stringify(key)}:${stableJson(object[key])}`).join(',')}}`;
198
+ }
199
+ return JSON.stringify(value) ?? 'null';
200
+ }
201
+ // Reads the task and the answer and reports every problem it can see. Reading
202
+ // the answer is itself a check — safe paths, files that exist, a parseable
203
+ // envelope — and it is done branch by branch, so a bad entry in one contributes
204
+ // its problem and the other is still examined. Only the answer file as a whole
205
+ // can stop the pass, because then there is no document left to read.
206
+ export function validateBuildProblems(config) {
207
+ const request = readSuiteBuildRequest(config.projectRoot);
208
+ const { outputs, unreadable } = readHostSuiteOutputsPerBranch(request.output_path, request);
209
+ return [
210
+ ...unreadable.map((entry) => ({ branch: entry.suite_kind, message: entry.message })),
211
+ ...collectBuildProblems(request, outputs),
212
+ ];
213
+ }
214
+ // One branch's problems, for the line that reports it unpublished alongside its
215
+ // peer. `put-suite-build` blocks per branch, so its message is per branch too.
216
+ export function formatBranchProblems(messages) {
217
+ if (messages.length === 1)
218
+ return messages[0];
219
+ return `${messages.length} problems in this branch's answer:\n${messages.map((m) => ` - ${m}`).join('\n')}`;
220
+ }
221
+ // One report, not a queue of one-at-a-time discoveries. Fixing one thing to be
222
+ // told the next costs a full round trip each time, and the round trip is the
223
+ // expensive part.
224
+ export function formatProblems(problems) {
225
+ const lines = problems.map((problem) => ` ${problem.branch}: ${problem.message}`);
226
+ return (`Your suite answer has ${problems.length} problem${problems.length === 1 ? '' : 's'}:\n` +
227
+ `${lines.join('\n')}\n` +
228
+ 'Fix all of them, then answer again. The Unitbob server has the last word on ' +
229
+ 'what it accepts; this check just finds the common problems in seconds instead ' +
230
+ 'of after the whole build.\n');
231
+ }
232
+ export async function validateBuild(config, _args = [], deps) {
233
+ const stdout = deps?.stdout ?? process.stdout;
234
+ const problems = validateBuildProblems(config);
235
+ if (problems.length === 0) {
236
+ stdout.write('Your suite answer looks well-formed. Run `unitbob put-suite-build` to publish it.\n');
237
+ return;
238
+ }
239
+ throw new Error(formatProblems(problems));
240
+ }
package/dist/wire.js CHANGED
@@ -4,7 +4,12 @@ export class WireError extends Error {
4
4
  }
5
5
  // POST /repos/register — the linking bootstrap (spec 28). A standalone function
6
6
  // rather than a Wire method because at link time there is no Config yet: only a
7
- // server URL and the project's folder name. Idempotent on the server.
7
+ // server URL and the project's folder name.
8
+ //
9
+ // It is no longer idempotent, and must not be called on an already-linked
10
+ // project: since spec 33 every call mints a brand-new project. Looking one up by
11
+ // folder name is exactly what used to let two people with a `myapp` write into
12
+ // the same repository.
8
13
  export async function registerRepo(server, name) {
9
14
  const url = `${server}/repos/register`;
10
15
  let res;
@@ -33,7 +38,11 @@ export async function registerRepo(server, name) {
33
38
  if (typeof payload.id !== 'number' || !Number.isInteger(payload.id)) {
34
39
  throw new WireError(`POST ${url} returned a malformed payload: expected an integer id.`);
35
40
  }
36
- return payload.id;
41
+ if (typeof payload.token !== 'string' || payload.token.length === 0) {
42
+ throw new WireError(`POST ${url} returned no project token — this Unitbob server is older than this connector. ` +
43
+ 'Upgrade the server, or pin an older unitbob version.');
44
+ }
45
+ return { id: payload.id, token: payload.token };
37
46
  }
38
47
  export class Wire {
39
48
  config;
@@ -188,11 +197,16 @@ export class Wire {
188
197
  }
189
198
  return suite;
190
199
  }
200
+ // Every wire call carries the project's token (spec 33). Without it the brain
201
+ // answers 404 — never 403, which would confirm the project exists.
191
202
  async send(method, url, body) {
203
+ const headers = { authorization: `Bearer ${this.config.token}` };
204
+ if (body !== undefined)
205
+ headers['content-type'] = 'application/json';
192
206
  try {
193
207
  return await fetch(url, {
194
208
  method,
195
- headers: body === undefined ? undefined : { 'content-type': 'application/json' },
209
+ headers,
196
210
  body: body === undefined ? undefined : JSON.stringify(body),
197
211
  });
198
212
  }
@@ -205,6 +219,14 @@ export class Wire {
205
219
  async ensureOk(res, what) {
206
220
  if (res.ok)
207
221
  return;
222
+ // A 404 on the wire means one of two things and there is no way to tell them
223
+ // apart from here — by design, since telling them apart is what would let a
224
+ // stranger discover which projects exist. Both have the same cure.
225
+ if (res.status === 404) {
226
+ throw new WireError(`This project is linked to a repository the server at ${this.config.server} does not have, ` +
227
+ 'or the token in .unitbob.json does not open it. Delete .unitbob.json to link again ' +
228
+ '(the old project, along with its map and checks, stays where it is).');
229
+ }
208
230
  let detail = '';
209
231
  try {
210
232
  detail = (await res.text()).slice(0, 500);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "unitbob",
3
- "version": "0.2.8",
3
+ "version": "0.3.0",
4
4
  "description": "Unitbob connector — thin local hands for the Unitbob Rails brain. Owns no domain logic: it runs tools, relays bytes over the wire, and prints what the server returns.",
5
5
  "type": "module",
6
6
  "bin": {