@bongos/core 1.19.1037 → 1.19.1039
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/.bongos-core.json +52 -22
- package/docs/adr/0089-modular-multi-model-adversarial-grader.md +2 -0
- package/docs/module-api-changelog.md +4 -0
- package/docs/recipes/core-release-pipeline.md +1 -1
- package/docs/recipes/private-npm-distribution.md +1 -1
- package/modules/government/protected-surfaces.json +2 -0
- package/modules/grading/grader-model.js +50 -5
- package/modules/grading/grader-providers.neutral.json +4 -0
- package/modules/grading/grader-rubric.json +5 -0
- package/modules/grading/grader-subagent.js +17 -1
- package/modules/grading/model-provider.js +257 -0
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/release-notes.json +16 -0
- package/scripts/gds/plain-cards.js +5 -0
- package/scripts/gds/publish-integration-gate.js +142 -0
- package/scripts/gds/ship-land.js +72 -6
- package/src/module-api.js +1 -1
- package/tests/government_protected_surfaces.mjs +14 -0
- package/tests/grade_model_provider.mjs +217 -0
- package/tests/publish_integration_gate.mjs +198 -0
- package/tests/ship_land_ci_not_started.mjs +168 -0
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
// modules/grading/model-provider.js — the ModelProvider port (ADR 0089 §3,
|
|
2
|
+
// task 1002840): how a judge's model is called, as an adapter behind one seam
|
|
3
|
+
// instead of a hard-coded `claude` spawn.
|
|
4
|
+
//
|
|
5
|
+
// WHY. Every platform LLM call funnels through runSubagent (grader-subagent.js):
|
|
6
|
+
// the panel workers, the no-artifact shots, architect-audit, and the agents
|
|
7
|
+
// module through the grade port. Until this file that function WAS the Claude
|
|
8
|
+
// CLI spawn, so a judge from another model family — the property ADR 0089's
|
|
9
|
+
// ensemble exists for — had no way in. Now a call names a provider and the
|
|
10
|
+
// provider makes it. Every provider answers in the SAME shape the Claude spawn
|
|
11
|
+
// always has: a `claude -p --output-format json`-style envelope on stdout and the
|
|
12
|
+
// same error codes (SUBAGENT_SPAWN_FAILED / SUBAGENT_TIMEOUT /
|
|
13
|
+
// SUBAGENT_NONZERO_EXIT). So nothing downstream changes — runWorker's retry, the
|
|
14
|
+
// outage detection, extractAssistantResult / extractCostUsd, the LLM cache.
|
|
15
|
+
//
|
|
16
|
+
// THE MODEL REFERENCE. A model is still ONE STRING everywhere it travels
|
|
17
|
+
// (signals.workers[].model, grader_model, the cache key). A string with no
|
|
18
|
+
// provider prefix is an anthropic-cli model — every id this project uses today —
|
|
19
|
+
// so today's calls are byte-identical. `<provider>:<model>` routes to a
|
|
20
|
+
// REGISTERED provider (`deepinfra:deepseek-ai/DeepSeek-V3.1`,
|
|
21
|
+
// `ollama:qwen2.5-coder:32b`). Only a prefix that names a registered provider is
|
|
22
|
+
// split off; anything else stays a Claude model id, so a colon inside an id can
|
|
23
|
+
// never re-route a call by accident.
|
|
24
|
+
//
|
|
25
|
+
// PROVIDERS.
|
|
26
|
+
// anthropic-cli the `claude -p` spawn (grader-subagent.js registers it).
|
|
27
|
+
// The default, and the only provider shipped config uses.
|
|
28
|
+
// openai-compatible one adapter for DeepInfra / OpenRouter / Together /
|
|
29
|
+
// Fireworks / Groq / vLLM / Ollama — POST
|
|
30
|
+
// <base_url>/chat/completions with a BYOK key read from the
|
|
31
|
+
// env var the instance names. PROMPT-ONLY: it has no Read
|
|
32
|
+
// tool, so such a judge sees exactly what its prompt carries.
|
|
33
|
+
//
|
|
34
|
+
// INSTANCE CONFIG (the ADR 0062 host-owned pattern). Providers are declared in
|
|
35
|
+
// config/grader-providers.json at the instance root; the core ships
|
|
36
|
+
// grader-providers.neutral.json beside this file with NO providers, so a vanilla
|
|
37
|
+
// instance is Claude-only. A key never lives in a config file — only the NAME
|
|
38
|
+
// of the env var holding it. Declaring a provider is an owner decision: it sends
|
|
39
|
+
// the diff under review to that vendor and it costs money per call (ADR 0089 §3).
|
|
40
|
+
|
|
41
|
+
'use strict';
|
|
42
|
+
|
|
43
|
+
const fs = require('node:fs');
|
|
44
|
+
const path = require('node:path');
|
|
45
|
+
const api = require('../../src/module-api');
|
|
46
|
+
|
|
47
|
+
const DEFAULT_PROVIDER = 'anthropic-cli';
|
|
48
|
+
const PROVIDER_NAME_RE = /^[a-z][a-z0-9-]{1,39}$/;
|
|
49
|
+
const API_KEY_ENV_RE = /^[A-Z][A-Z0-9_]{1,63}$/;
|
|
50
|
+
const KINDS = new Set(['openai-compatible']);
|
|
51
|
+
const DEFAULT_TIMEOUT_MS = 240_000;
|
|
52
|
+
const ERROR_BODY_MAX = 2000;
|
|
53
|
+
const LOOPBACK_HOSTS = new Set(['localhost', '127.0.0.1', '[::1]', '::1']);
|
|
54
|
+
|
|
55
|
+
const providers = new Map(); // name -> { name, kind, run }
|
|
56
|
+
|
|
57
|
+
// One place a refusal is reported, so a malformed instance config is loud
|
|
58
|
+
// without scattering console calls (the fitness console ratchet counts them).
|
|
59
|
+
function refuse(msg) {
|
|
60
|
+
console.error(`grade model-provider: ${msg}`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function registerModelProvider(provider) {
|
|
64
|
+
if (!provider || typeof provider.run !== 'function' || typeof provider.name !== 'string' || !provider.name) {
|
|
65
|
+
throw new TypeError('registerModelProvider: a provider needs a name and a run({ prompt, opts }) function');
|
|
66
|
+
}
|
|
67
|
+
const held = providers.get(provider.name);
|
|
68
|
+
if (held && held !== provider) throw new Error(`model provider '${provider.name}' is already registered`);
|
|
69
|
+
providers.set(provider.name, provider);
|
|
70
|
+
return provider;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function resolveModelProvider(name = DEFAULT_PROVIDER) {
|
|
74
|
+
const p = providers.get(name);
|
|
75
|
+
if (!p) throw new Error(`model provider '${name}' is not registered`);
|
|
76
|
+
return p;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function listModelProviders() {
|
|
80
|
+
return [...providers.keys()].sort();
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `<provider>:<model>` → { provider, model }, split ONLY when the prefix names a
|
|
84
|
+
// registered provider other than the default; everything else is one Claude id.
|
|
85
|
+
function parseModelRef(ref) {
|
|
86
|
+
const s = String(ref == null ? '' : ref);
|
|
87
|
+
const i = s.indexOf(':');
|
|
88
|
+
if (i > 0) {
|
|
89
|
+
const name = s.slice(0, i);
|
|
90
|
+
if (name !== DEFAULT_PROVIDER && providers.has(name)) return { provider: name, model: s.slice(i + 1) };
|
|
91
|
+
}
|
|
92
|
+
return { provider: DEFAULT_PROVIDER, model: s };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function codeError(code, message, extra = {}) {
|
|
96
|
+
return Object.assign(new Error(message), { code, ...extra });
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
// base_url must be https, or plain http to THIS machine only (a local vLLM /
|
|
100
|
+
// Ollama): a key must never cross a network in cleartext.
|
|
101
|
+
function checkBaseUrl(raw) {
|
|
102
|
+
let u;
|
|
103
|
+
try { u = new URL(String(raw)); } catch { return null; }
|
|
104
|
+
const loopback = LOOPBACK_HOSTS.has(u.hostname);
|
|
105
|
+
if (u.protocol !== 'https:' && !(u.protocol === 'http:' && loopback)) return null;
|
|
106
|
+
if (u.username || u.password || u.search || u.hash) return null;
|
|
107
|
+
return { url: u.toString().replace(/\/+$/, ''), loopback };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Validate one declared provider: { def } normalised, or { error } saying why
|
|
111
|
+
// not — never throws, so one bad entry cannot take the grader down.
|
|
112
|
+
function validateProviderDef(name, def) {
|
|
113
|
+
if (!PROVIDER_NAME_RE.test(name) || name === DEFAULT_PROVIDER) return { error: `provider name '${name}' must match ${PROVIDER_NAME_RE} and not be '${DEFAULT_PROVIDER}'` };
|
|
114
|
+
if (!def || typeof def !== 'object') return { error: `provider '${name}' must be an object` };
|
|
115
|
+
if (!KINDS.has(def.kind)) return { error: `provider '${name}': kind must be one of ${[...KINDS].join(', ')}` };
|
|
116
|
+
const base = checkBaseUrl(def.base_url);
|
|
117
|
+
if (!base) return { error: `provider '${name}': base_url must be https:// (http:// only to localhost), with no credentials or query` };
|
|
118
|
+
const keyEnv = def.api_key_env == null ? null : String(def.api_key_env);
|
|
119
|
+
if (keyEnv != null && !API_KEY_ENV_RE.test(keyEnv)) return { error: `provider '${name}': api_key_env must be an env var NAME (${API_KEY_ENV_RE}) — never the key itself` };
|
|
120
|
+
if (keyEnv == null && !base.loopback) return { error: `provider '${name}': a remote provider needs api_key_env` };
|
|
121
|
+
const prices = {};
|
|
122
|
+
for (const [model, p] of Object.entries(def.price_per_mtok || {})) {
|
|
123
|
+
const input = Number(p && p.input);
|
|
124
|
+
const output = Number(p && p.output);
|
|
125
|
+
if (!(Number.isFinite(input) && input >= 0 && Number.isFinite(output) && output >= 0)) {
|
|
126
|
+
return { error: `provider '${name}': price_per_mtok['${model}'] needs non-negative input and output` };
|
|
127
|
+
}
|
|
128
|
+
prices[model] = { input, output };
|
|
129
|
+
}
|
|
130
|
+
return { def: { name, kind: def.kind, baseUrl: base.url, apiKeyEnv: keyEnv, prices } };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// USD for one call from the reply's token usage, or null when the model has no
|
|
134
|
+
// declared price — a cost signal never fabricates a number it does not have.
|
|
135
|
+
function priceCall(prices, model, usage) {
|
|
136
|
+
const p = prices[model];
|
|
137
|
+
if (!p || !usage) return null;
|
|
138
|
+
const inTok = Number(usage.prompt_tokens);
|
|
139
|
+
const outTok = Number(usage.completion_tokens);
|
|
140
|
+
if (!Number.isFinite(inTok) || !Number.isFinite(outTok)) return null;
|
|
141
|
+
return (inTok * p.input + outTok * p.output) / 1_000_000;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// The openai-compatible adapter. `fetchImpl` and `env` are injectable so the
|
|
145
|
+
// whole adapter is testable against a stub with no key and no network.
|
|
146
|
+
function createOpenAICompatibleProvider(def, { fetchImpl = globalThis.fetch, env = process.env } = {}) {
|
|
147
|
+
const run = async ({ prompt, opts = {} }) => {
|
|
148
|
+
const started = Date.now();
|
|
149
|
+
const model = String(opts.model || '');
|
|
150
|
+
const timeoutMs = opts.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
151
|
+
const key = def.apiKeyEnv ? env[def.apiKeyEnv] : null;
|
|
152
|
+
// Refused BEFORE any request: no key means nothing is sent anywhere.
|
|
153
|
+
if (def.apiKeyEnv && !key) {
|
|
154
|
+
throw codeError('SUBAGENT_SPAWN_FAILED', `provider ${def.name}: no API key — set ${def.apiKeyEnv} (bring-your-own key)`, { run_ms: 0, stdout: '', stderr: '' });
|
|
155
|
+
}
|
|
156
|
+
const scrub = (t) => (key ? String(t).split(key).join('[redacted]') : String(t));
|
|
157
|
+
const controller = new AbortController();
|
|
158
|
+
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
|
159
|
+
let res;
|
|
160
|
+
let text;
|
|
161
|
+
try {
|
|
162
|
+
res = await fetchImpl(`${def.baseUrl}/chat/completions`, {
|
|
163
|
+
method: 'POST',
|
|
164
|
+
headers: { 'content-type': 'application/json', ...(key ? { authorization: `Bearer ${key}` } : {}) },
|
|
165
|
+
body: JSON.stringify({ model, messages: [{ role: 'user', content: String(prompt) }] }),
|
|
166
|
+
signal: controller.signal,
|
|
167
|
+
});
|
|
168
|
+
text = await res.text();
|
|
169
|
+
} catch (err) {
|
|
170
|
+
const run_ms = Date.now() - started;
|
|
171
|
+
if (controller.signal.aborted) {
|
|
172
|
+
throw codeError('SUBAGENT_TIMEOUT', `provider ${def.name}: timed out after ${timeoutMs}ms`, { run_ms, stdout: '', stderr: '' });
|
|
173
|
+
}
|
|
174
|
+
throw codeError('SUBAGENT_SPAWN_FAILED', `provider ${def.name}: request failed: ${scrub(err && err.message)}`, { run_ms, stdout: '', stderr: scrub(err && err.message) });
|
|
175
|
+
} finally {
|
|
176
|
+
clearTimeout(timer);
|
|
177
|
+
}
|
|
178
|
+
const run_ms = Date.now() - started;
|
|
179
|
+
if (!res.ok) {
|
|
180
|
+
throw codeError('SUBAGENT_NONZERO_EXIT', `provider ${def.name}: HTTP ${res.status}`, {
|
|
181
|
+
exit_code: res.status, run_ms, stdout: '', stderr: scrub(text).slice(0, ERROR_BODY_MAX),
|
|
182
|
+
});
|
|
183
|
+
}
|
|
184
|
+
let body = null;
|
|
185
|
+
try { body = JSON.parse(text); } catch { /* an unparseable 200 → empty result → the worker's parse_error path */ }
|
|
186
|
+
const content = body && body.choices && body.choices[0] && body.choices[0].message
|
|
187
|
+
? body.choices[0].message.content : null;
|
|
188
|
+
const usage = body && body.usage ? body.usage : null;
|
|
189
|
+
const cost = priceCall(def.prices, model, usage);
|
|
190
|
+
const envelope = {
|
|
191
|
+
type: 'result',
|
|
192
|
+
result: typeof content === 'string' ? content : '',
|
|
193
|
+
...(cost != null ? { total_cost_usd: cost } : {}),
|
|
194
|
+
provider: def.name,
|
|
195
|
+
model,
|
|
196
|
+
usage,
|
|
197
|
+
};
|
|
198
|
+
return { stdout: JSON.stringify(envelope), stderr: '', run_ms, exit_code: 0, cost_usd: cost };
|
|
199
|
+
};
|
|
200
|
+
return { name: def.name, kind: def.kind, run };
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
// ---------- instance config ----------
|
|
204
|
+
//
|
|
205
|
+
// The host's config/grader-providers.json, else the neutral starter beside this
|
|
206
|
+
// file (which declares none). A missing instance file is the ordinary case; one
|
|
207
|
+
// that does not parse falls back to the neutral starter — fail to vanilla, never
|
|
208
|
+
// to dead — and each invalid entry is refused by name while the rest load.
|
|
209
|
+
const NEUTRAL_PATH = path.join(__dirname, 'grader-providers.neutral.json');
|
|
210
|
+
const INSTANCE_PATH = path.join(api.resolveInstanceRoot(), 'config', 'grader-providers.json');
|
|
211
|
+
|
|
212
|
+
function readPack(file) {
|
|
213
|
+
let raw;
|
|
214
|
+
try { raw = fs.readFileSync(file, 'utf8'); } catch { return null; }
|
|
215
|
+
try {
|
|
216
|
+
const parsed = JSON.parse(raw);
|
|
217
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed) ? parsed : null;
|
|
218
|
+
} catch { return null; }
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
function loadProviderConfig({ instancePath = INSTANCE_PATH, neutralPath = NEUTRAL_PATH } = {}) {
|
|
222
|
+
const instance = readPack(instancePath);
|
|
223
|
+
const pack = instance || readPack(neutralPath) || {};
|
|
224
|
+
const defs = [];
|
|
225
|
+
const refused = [];
|
|
226
|
+
const declared = pack.providers && typeof pack.providers === 'object' && !Array.isArray(pack.providers) ? pack.providers : {};
|
|
227
|
+
for (const [name, def] of Object.entries(declared)) {
|
|
228
|
+
const v = validateProviderDef(name, def);
|
|
229
|
+
if (v.error) refused.push(v.error); else defs.push(v.def);
|
|
230
|
+
}
|
|
231
|
+
return { source: instance ? instancePath : neutralPath, defs, refused };
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Register every provider the instance declares. Idempotent: an already
|
|
235
|
+
// registered name is left alone. Returns the provider names now resolvable.
|
|
236
|
+
function registerConfiguredProviders(opts = {}) {
|
|
237
|
+
const { defs, refused } = loadProviderConfig(opts);
|
|
238
|
+
for (const reason of refused) refuse(`${reason} — refused, grading continues without it`);
|
|
239
|
+
for (const def of defs) {
|
|
240
|
+
if (!providers.has(def.name)) registerModelProvider(createOpenAICompatibleProvider(def, opts));
|
|
241
|
+
}
|
|
242
|
+
return listModelProviders();
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
registerConfiguredProviders();
|
|
246
|
+
|
|
247
|
+
module.exports = {
|
|
248
|
+
DEFAULT_PROVIDER,
|
|
249
|
+
createOpenAICompatibleProvider,
|
|
250
|
+
listModelProviders,
|
|
251
|
+
loadProviderConfig,
|
|
252
|
+
parseModelRef,
|
|
253
|
+
registerConfiguredProviders,
|
|
254
|
+
registerModelProvider,
|
|
255
|
+
resolveModelProvider,
|
|
256
|
+
validateProviderDef,
|
|
257
|
+
};
|
package/package-lock.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1039",
|
|
4
4
|
"lockfileVersion": 3,
|
|
5
5
|
"requires": true,
|
|
6
6
|
"packages": {
|
|
7
7
|
"": {
|
|
8
8
|
"name": "@bongos/core",
|
|
9
|
-
"version": "1.19.
|
|
9
|
+
"version": "1.19.1039",
|
|
10
10
|
"license": "AGPL-3.0-or-later",
|
|
11
11
|
"dependencies": {
|
|
12
12
|
"express": "^4.21.2",
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@bongos/core",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.1039",
|
|
4
4
|
"description": "Cloud Bongos — the AI-first build platform core (GDS + platform surfaces + module system), installed as a versioned dependency (ADR 0108).",
|
|
5
5
|
"license": "AGPL-3.0-or-later",
|
|
6
6
|
"main": "src/platform-server.js",
|
package/release-notes.json
CHANGED
|
@@ -7507,5 +7507,21 @@
|
|
|
7507
7507
|
"id": "1004280",
|
|
7508
7508
|
"text": "The project's own notes no longer claim a past decision banned all new checks; each one now gives its real reason, and a test stops the wrong claim coming back."
|
|
7509
7509
|
}
|
|
7510
|
+
],
|
|
7511
|
+
"1.19.1038": [
|
|
7512
|
+
{
|
|
7513
|
+
"id": "1004289",
|
|
7514
|
+
"text": "When the code host refuses to run the automatic tests at all, for example because the account's allowance ran out, the shipping tool now says so plainly and names the owner's fix, instead of telling the builder to fix code tha"
|
|
7515
|
+
},
|
|
7516
|
+
{
|
|
7517
|
+
"id": "1002840",
|
|
7518
|
+
"text": "The grader can now call AI models from other companies through one plug-in point, ready for a second-opinion reviewer; nothing changes today — it still uses only Claude and spends nothing new."
|
|
7519
|
+
}
|
|
7520
|
+
],
|
|
7521
|
+
"1.19.1039": [
|
|
7522
|
+
{
|
|
7523
|
+
"id": "1004290",
|
|
7524
|
+
"text": "A new release of the core is now published only after the check that it can set up a brand-new empty database has passed, so a release that cannot install cleanly can no longer reach projects."
|
|
7525
|
+
}
|
|
7510
7526
|
]
|
|
7511
7527
|
}
|
|
@@ -169,6 +169,11 @@ const CARDS = Object.freeze({
|
|
|
169
169
|
'An automatic test failed on your work, so it has not been added to the project.',
|
|
170
170
|
'I will run that test here, fix what it found, and send the work again. Sending the same work again would fail the same way.',
|
|
171
171
|
null, FAULT),
|
|
172
|
+
// task 1004289: the tests never ran at all (the account cannot run them right now).
|
|
173
|
+
ci_not_started: card(
|
|
174
|
+
'The automatic tests never ran on your work: the code host did not start them, because the account has run out of test time or has a payment problem. Nothing was tested, so nothing needs fixing.',
|
|
175
|
+
'I will not change the work. Once the owner restores the test time, I will start the tests again on the same work and it will be added on its own.',
|
|
176
|
+
'The project owner, to restore the account\'s test time on the code host.', FAULT),
|
|
172
177
|
non_required_check_failing: card(
|
|
173
178
|
'An optional automatic test failed on your work.',
|
|
174
179
|
'I will look at what it found. It does not stop the work being added, but it is usually worth fixing.',
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/publish-integration-gate.js — the publish lane waits for the integration
|
|
3
|
+
// lane's verdict on the exact commit it is about to release (task 1004290).
|
|
4
|
+
//
|
|
5
|
+
// WHY. publish.yml fires on the `unit` workflow's success, and `unit` is DB-free by
|
|
6
|
+
// design. The proof that the packaged core can set up an EMPTY database and serve a
|
|
7
|
+
// DB-backed route — tests/consumer_layout_boot.mjs, task 1002863 — runs in the
|
|
8
|
+
// `integration` workflow, on the same main push. Without this gate that result arrived
|
|
9
|
+
// after the publish and gated nothing: a core that installs and boots but cannot migrate
|
|
10
|
+
// its own database still reached npm, and from there the nightly tenant upgrade sweep.
|
|
11
|
+
//
|
|
12
|
+
// WHAT IT COSTS, MEASURED 2026-09-26 (not assumed). Both lanes start on the same push, and
|
|
13
|
+
// integration is the SHORTER one: on the two commits right after task 1002863 landed,
|
|
14
|
+
// integration took 247s and 245s against unit's 351s and 335s. So when unit's success fires
|
|
15
|
+
// the publish, integration has usually already answered and this gate is a single API
|
|
16
|
+
// read. It runs inside the existing publish job — no new job — and it polls only in the
|
|
17
|
+
// rare case integration is still going. Over 299 main pushes (2026-09-20 → 26) integration
|
|
18
|
+
// was green 254 times, cancelled 41 times (a newer merge superseded it) and red 3 times,
|
|
19
|
+
// all three the Actions-budget outage of 2026-09-25 — so it is not a flaky lane that would
|
|
20
|
+
// stall every release.
|
|
21
|
+
//
|
|
22
|
+
// THE VERDICTS (decideIntegrationGate, pure):
|
|
23
|
+
// go the newest push-event run on this commit succeeded → publish
|
|
24
|
+
// wait no run yet, or it is still going → poll
|
|
25
|
+
// superseded it was cancelled — almost always a newer merge's run replacing it under
|
|
26
|
+
// integration.yml's cancel-in-progress group. Nothing is published; that
|
|
27
|
+
// newer merge fires its own publish, which asks about its own commit.
|
|
28
|
+
// red anything else (failure, timed_out, startup_failure …) → refuse, loudly
|
|
29
|
+
// Only PUSH-event runs count: the weekly scheduled run on main carries HEAD's sha but runs
|
|
30
|
+
// only the parked-set ratchet, so its conclusion says nothing about this commit.
|
|
31
|
+
//
|
|
32
|
+
// OUTPUT: `go=true|false` to $GITHUB_OUTPUT. Exit 0 for go and superseded, 1 for red,
|
|
33
|
+
// timeout or an unreadable API (a refusal that must be seen), 2 for bad arguments.
|
|
34
|
+
//
|
|
35
|
+
// node scripts/gds/publish-integration-gate.js --sha <40-hex> --repo <owner/name>
|
|
36
|
+
// [--timeout-min 12] [--interval-s 20] token: GH_TOKEN or GITHUB_TOKEN
|
|
37
|
+
|
|
38
|
+
'use strict';
|
|
39
|
+
|
|
40
|
+
const fs = require('node:fs');
|
|
41
|
+
|
|
42
|
+
const WORKFLOW_FILE = 'integration.yml';
|
|
43
|
+
const DEFAULT_TIMEOUT_MIN = 12; // inside publish.yml's 20-minute job, leaving room to pack + publish
|
|
44
|
+
const DEFAULT_INTERVAL_S = 20;
|
|
45
|
+
const SHA_RE = /^[0-9a-f]{40}$/;
|
|
46
|
+
const REPO_RE = /^[A-Za-z0-9_.-]+\/[A-Za-z0-9_.-]+$/;
|
|
47
|
+
|
|
48
|
+
// decideIntegrationGate — PURE. `runs` is list-workflow-runs' `workflow_runs` for one
|
|
49
|
+
// head sha and the push event. The newest run decides (a re-push of the same sha, or a
|
|
50
|
+
// re-run, supersedes an older verdict).
|
|
51
|
+
function decideIntegrationGate(runs) {
|
|
52
|
+
const list = Array.isArray(runs) ? runs.filter((r) => r && typeof r === 'object') : [];
|
|
53
|
+
if (!list.length) return { verdict: 'wait', run: null };
|
|
54
|
+
const newest = list.slice().sort((a, b) =>
|
|
55
|
+
(Number(b.run_number) || 0) - (Number(a.run_number) || 0)
|
|
56
|
+
|| String(b.created_at || '').localeCompare(String(a.created_at || '')))[0];
|
|
57
|
+
if (newest.status !== 'completed') return { verdict: 'wait', run: newest };
|
|
58
|
+
if (newest.conclusion === 'success') return { verdict: 'go', run: newest };
|
|
59
|
+
if (newest.conclusion === 'cancelled') return { verdict: 'superseded', run: newest };
|
|
60
|
+
return { verdict: 'red', run: newest };
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function runsUrl(repo, sha) {
|
|
64
|
+
return `https://api.github.com/repos/${repo}/actions/workflows/${WORKFLOW_FILE}/runs?head_sha=${sha}&event=push&per_page=20`;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// waitForIntegration — poll until the gate has a verdict or the time is up. A 401/403/404
|
|
68
|
+
// is not something waiting fixes (a missing `actions: read`, a renamed workflow), so it
|
|
69
|
+
// ends the wait at once as 'unreadable'. Any other failed read is retried. Everything that
|
|
70
|
+
// touches the world is injectable for tests/publish_integration_gate.mjs.
|
|
71
|
+
async function waitForIntegration({
|
|
72
|
+
sha, repo, token, timeoutMs, intervalMs,
|
|
73
|
+
fetchImpl = globalThis.fetch, sleep = (ms) => new Promise((r) => setTimeout(r, ms)), now = Date.now,
|
|
74
|
+
}) {
|
|
75
|
+
const deadline = now() + timeoutMs;
|
|
76
|
+
let last = { verdict: 'wait', run: null, why: 'no read yet' };
|
|
77
|
+
for (;;) {
|
|
78
|
+
try {
|
|
79
|
+
const res = await fetchImpl(runsUrl(repo, sha), {
|
|
80
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28' },
|
|
81
|
+
});
|
|
82
|
+
if ([401, 403, 404].includes(res.status)) return { verdict: 'unreadable', run: null, why: `HTTP ${res.status}` };
|
|
83
|
+
if (res.ok) {
|
|
84
|
+
const body = await res.json();
|
|
85
|
+
last = { ...decideIntegrationGate(body && body.workflow_runs), why: null };
|
|
86
|
+
if (last.verdict !== 'wait') return last;
|
|
87
|
+
} else {
|
|
88
|
+
last = { ...last, why: `HTTP ${res.status}` };
|
|
89
|
+
}
|
|
90
|
+
} catch (e) {
|
|
91
|
+
last = { ...last, why: (e && e.message) || String(e) };
|
|
92
|
+
}
|
|
93
|
+
if (now() + intervalMs > deadline) return { verdict: 'timeout', run: last.run, why: last.why };
|
|
94
|
+
await sleep(intervalMs);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// The one line per outcome the Actions log shows, and what the rest of the lane reads.
|
|
99
|
+
function reportLines(verdict, { sha, run, why, timeoutMin }) {
|
|
100
|
+
const short = String(sha).slice(0, 8);
|
|
101
|
+
const url = run && run.html_url ? ` ${run.html_url}` : '';
|
|
102
|
+
switch (verdict) {
|
|
103
|
+
case 'go':
|
|
104
|
+
return { go: true, code: 0, line: `integration is green on ${short}${url} — publishing.` };
|
|
105
|
+
case 'superseded':
|
|
106
|
+
return { go: false, code: 0, line: `::notice title=not published — integration was cancelled on ${short}::Usually a newer merge's run replaced it; that merge fires its own publish, which checks its own commit.${url}` };
|
|
107
|
+
case 'red':
|
|
108
|
+
return { go: false, code: 1, line: `::error title=not published — integration is red on ${short}::The integration lane (${run && run.conclusion}) runs the proof that the packaged core can migrate an empty database (task 1002863). A core that fails it must not reach npm. Fix main; the next merge publishes everything.${url}` };
|
|
109
|
+
case 'unreadable':
|
|
110
|
+
return { go: false, code: 1, line: `::error title=not published — cannot read the integration lane::${why}. publish.yml needs 'permissions: actions: read', and the workflow file must still be ${WORKFLOW_FILE}.` };
|
|
111
|
+
default:
|
|
112
|
+
return { go: false, code: 1, line: `::error title=not published — integration had no verdict on ${short} after ${timeoutMin} min::${why ? `Last read: ${why}. ` : ''}Re-run this workflow once integration finishes, or let the next merge publish.${url}` };
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function argValue(argv, flag) {
|
|
117
|
+
const i = argv.indexOf(flag);
|
|
118
|
+
return i >= 0 && i + 1 < argv.length ? argv[i + 1] : null;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async function main(argv = process.argv.slice(2), env = process.env) {
|
|
122
|
+
const sha = String(argValue(argv, '--sha') || '').toLowerCase();
|
|
123
|
+
const repo = String(argValue(argv, '--repo') || env.GITHUB_REPOSITORY || '');
|
|
124
|
+
const token = env.GH_TOKEN || env.GITHUB_TOKEN || '';
|
|
125
|
+
const timeoutMin = Number(argValue(argv, '--timeout-min') || DEFAULT_TIMEOUT_MIN);
|
|
126
|
+
const intervalS = Number(argValue(argv, '--interval-s') || DEFAULT_INTERVAL_S);
|
|
127
|
+
if (!SHA_RE.test(sha) || !REPO_RE.test(repo) || !token || !(timeoutMin > 0) || !(intervalS > 0)) {
|
|
128
|
+
console.error('usage: publish-integration-gate.js --sha <40-hex> --repo <owner/name> [--timeout-min N] [--interval-s N] (token: GH_TOKEN or GITHUB_TOKEN)');
|
|
129
|
+
return 2;
|
|
130
|
+
}
|
|
131
|
+
const result = await waitForIntegration({ sha, repo, token, timeoutMs: timeoutMin * 60000, intervalMs: intervalS * 1000 });
|
|
132
|
+
const out = reportLines(result.verdict, { sha, run: result.run, why: result.why, timeoutMin });
|
|
133
|
+
console.log(out.line);
|
|
134
|
+
if (env.GITHUB_OUTPUT) fs.appendFileSync(env.GITHUB_OUTPUT, `go=${out.go}\n`);
|
|
135
|
+
return out.code;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
if (require.main === module) {
|
|
139
|
+
main().then((code) => { process.exitCode = code; }, (e) => { console.error(e); process.exitCode = 1; });
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = { decideIntegrationGate, waitForIntegration, reportLines, main, WORKFLOW_FILE, runsUrl };
|
package/scripts/gds/ship-land.js
CHANGED
|
@@ -291,7 +291,17 @@ function strandNextStep(taskId, via) {
|
|
|
291
291
|
// task 1003764: `red` — the PR was RED on the poll's last read. "NOT a failure" is then
|
|
292
292
|
// false: a required check that failed does not pass by waiting, so the banner says the
|
|
293
293
|
// land is stuck on it and defers to the red lines above for the remedy.
|
|
294
|
-
function landPendingLines(taskId, { merged, red } = {}) {
|
|
294
|
+
function landPendingLines(taskId, { merged, red, notStarted } = {}) {
|
|
295
|
+
// task 1004289: CI never RAN (the account cannot run Actions). Not a failure of the
|
|
296
|
+
// work, and not something waiting fixes either: the checks have to be started again.
|
|
297
|
+
if (notStarted) {
|
|
298
|
+
return [
|
|
299
|
+
' ci-land: Published, but CI never started on the PR (GitHub ran none of its checks), so it has NOT merged',
|
|
300
|
+
' and cannot until they run. The task stays at \'confirmed\'. Nothing in the work needs to change: once the',
|
|
301
|
+
` owner restores GitHub Actions, re-run the failed jobs (lines above), then re-run \`node scripts/gds/ship.js ${taskId}\``,
|
|
302
|
+
' to watch the land — it resumes from here (task 941).',
|
|
303
|
+
];
|
|
304
|
+
}
|
|
295
305
|
if (red) {
|
|
296
306
|
return [
|
|
297
307
|
' ci-land: Published, but the PR is RED (the check named above), so it has NOT merged and will not',
|
|
@@ -314,8 +324,9 @@ function printLandPending(taskId, opts = {}) {
|
|
|
314
324
|
console.log('');
|
|
315
325
|
for (const line of landPendingLines(taskId, opts)) console.log(line);
|
|
316
326
|
// The land_pending card says there is nothing to do, which a red PR makes untrue; the
|
|
317
|
-
// red line already printed the failing_check card (task 1003764)
|
|
318
|
-
|
|
327
|
+
// red line already printed the failing_check card (task 1003764), or the
|
|
328
|
+
// ci_not_started one (task 1004289).
|
|
329
|
+
if (!opts.red && !opts.notStarted) for (const line of plainCardLines('land_pending')) console.log(line);
|
|
319
330
|
}
|
|
320
331
|
|
|
321
332
|
// task 1002645 — the honest close-out when the PR MERGED but the server refused the
|
|
@@ -351,6 +362,11 @@ function prAssigneeLine(login) {
|
|
|
351
362
|
// exported so the wording is pinned by tests/unit_report_status.mjs.
|
|
352
363
|
function redPrLine(md) {
|
|
353
364
|
const reason = (md && md.reason) || '';
|
|
365
|
+
if (md && md.not_started) {
|
|
366
|
+
const job = (/^ci_not_started:(.+)$/.exec(reason) || [])[1];
|
|
367
|
+
return ` ci-land: CI NEVER RAN on this PR — GitHub did not start ${job ? `the \`${job}\` job` : 'its checks'} ("The job was not started because …", `
|
|
368
|
+
+ 'an Actions budget or billing stop on the account). Nothing in the work was tested, so there is nothing in it to fix.';
|
|
369
|
+
}
|
|
354
370
|
const fix = 'fix it, commit, and re-run the ship — re-pushing unchanged work cannot turn it green.';
|
|
355
371
|
if (md && md.failing_tests) {
|
|
356
372
|
return ` ci-land: the PR is RED — ${md.failing_tests}. Reproduce with \`node tests/<name>.mjs\`, ${fix}`;
|
|
@@ -369,9 +385,21 @@ function redPrLine(md) {
|
|
|
369
385
|
// task 1003764: the merge trap sits between them. `opts` = { taskId, via } for its wording.
|
|
370
386
|
function redPrLines(md, opts = {}) {
|
|
371
387
|
const red = redPrLine(md);
|
|
388
|
+
if (red && md.not_started) return [red, ...notStartedLines(md), ...plainCardLines('ci_not_started')];
|
|
372
389
|
return red ? [red, ...mergeTrapLines(md, opts), ...plainCardLines((md && md.reason) || 'failing_check')] : [];
|
|
373
390
|
}
|
|
374
391
|
|
|
392
|
+
// notStartedLines — task 1004289. What to do when CI never ran, instead of the merge trap
|
|
393
|
+
// (there is no failure to reproduce). Re-running is the remedy, not re-pushing: the same
|
|
394
|
+
// commit is fine, it simply was never tested. Pure + exported.
|
|
395
|
+
function notStartedLines(md) {
|
|
396
|
+
const rerun = md && /^\d+$/.test(String(md.run_id || '')) ? `\`gh run rerun ${md.run_id} --failed\`` : '"Re-run failed jobs" on the PR';
|
|
397
|
+
return [
|
|
398
|
+
' ci-land: this is the repo OWNER\'s to fix, on GitHub: the account\'s Actions budget / spending limit or payment method.',
|
|
399
|
+
` ci-land: once Actions can run again, ${rerun} starts the checks on the SAME commit — do not re-push or edit the work.`,
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
|
|
375
403
|
// The unit tests that gate GENERATED files — each compares a committed artifact with a
|
|
376
404
|
// fresh build of it. `fitness` holds the combined gate (fitness.js "generated artifacts
|
|
377
405
|
// fresh": copy registry, API docs, file map, nested CLAUDE.md blocks, the §13 snippet).
|
|
@@ -439,16 +467,42 @@ const RED_CONCLUSIONS = new Set(['FAILURE', 'ERROR', 'TIMED_OUT', 'CANCELLED', '
|
|
|
439
467
|
// poll paths hand redPrLines the same thing. `failingTestsFor(sha)` reads the unit-report
|
|
440
468
|
// status (gh pr view omits a status's description) and is asked only once that status is
|
|
441
469
|
// itself red, i.e. posted. Null when nothing is red. Pure + exported.
|
|
442
|
-
|
|
470
|
+
// task 1004289 — `neverStarted(rawCheck)` answers "did this red job never run at all?"
|
|
471
|
+
// (jobNeverStarted below). When EVERY red check is one, nothing was tested, and the
|
|
472
|
+
// answer is { reason: 'ci_not_started:<name>', not_started: true, run_id } rather than
|
|
473
|
+
// a failing check the builder is told to fix.
|
|
474
|
+
function localMergeDriver(pr, failingTestsFor = () => null, neverStarted = () => false) {
|
|
443
475
|
if (!pr || pr.state !== 'OPEN' || !Array.isArray(pr.statusCheckRollup)) return null;
|
|
444
476
|
const { checks } = readCheckRollup({ contexts: { nodes: pr.statusCheckRollup } });
|
|
445
477
|
const red = checks.filter((c) => RED_CONCLUSIONS.has(String(c.conclusion || '').toUpperCase()));
|
|
446
478
|
if (!red.length) return null;
|
|
479
|
+
const redRaw = pr.statusCheckRollup.filter((c) => c && RED_CONCLUSIONS.has(String(c.conclusion || c.state || '').toUpperCase()));
|
|
480
|
+
if (redRaw.length && redRaw.every((c) => neverStarted(c))) {
|
|
481
|
+
const run = /\/runs\/(\d+)\//.exec(String(redRaw[0].detailsUrl || ''));
|
|
482
|
+
return { reason: `ci_not_started:${red[0].name}`, not_started: true, failing_tests: null, run_id: run ? run[1] : null };
|
|
483
|
+
}
|
|
447
484
|
const kind = pr.mergeStateStatus === 'UNSTABLE' ? 'non_required_check_failing' : 'failing_check';
|
|
448
485
|
const reported = red.some((c) => c.name === UNIT_REPORT_CONTEXT);
|
|
449
486
|
return { reason: `${kind}:${red[0].name}`, failing_tests: (reported && failingTestsFor(pr.headRefOid)) || null };
|
|
450
487
|
}
|
|
451
488
|
|
|
489
|
+
// jobNeverStarted — task 1004289. When the account cannot run Actions (its budget is
|
|
490
|
+
// spent, or a payment failed), GitHub still marks every job FAILED seconds after it was
|
|
491
|
+
// queued, with no steps and no runner, and one annotation saying why: "The job was not
|
|
492
|
+
// started because an Actions budget is preventing further use." (PR 1211, 2026-09-25).
|
|
493
|
+
// Read as an ordinary red check, that told the builder to fix their code — advice that
|
|
494
|
+
// cannot help, where each retry is another failed run. An Actions job's id (the tail of
|
|
495
|
+
// its detailsUrl) is its check-run id, so its annotations are one read away. FAIL-OPEN:
|
|
496
|
+
// anything unreadable is "no", which keeps the ordinary red wording. `gh` is injectable.
|
|
497
|
+
const NOT_STARTED_RE = /^\s*The job was not started because\b/i;
|
|
498
|
+
function jobNeverStarted(check, gh = ghJson) {
|
|
499
|
+
if (!check || (check.__typename && check.__typename !== 'CheckRun')) return false;
|
|
500
|
+
const m = /\/job\/(\d+)(?:[/?#]|$)/.exec(String(check.detailsUrl || ''));
|
|
501
|
+
if (!m) return false;
|
|
502
|
+
const notes = gh(['api', `repos/{owner}/{repo}/check-runs/${m[1]}/annotations`]);
|
|
503
|
+
return Array.isArray(notes) && notes.some((a) => a && NOT_STARTED_RE.test(String(a.message || '')));
|
|
504
|
+
}
|
|
505
|
+
|
|
452
506
|
// unitReportFor — the failing-test line the `unit` workflow posted on `sha`, read with this
|
|
453
507
|
// machine's gh through the same parser the server uses. Null when unreadable; the red line
|
|
454
508
|
// then names the check instead, as it did before the report existed. `gh` is injectable.
|
|
@@ -921,6 +975,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
|
921
975
|
if (sha !== report.sha) report = { sha, text: unitReportFor(sha, readJson) };
|
|
922
976
|
return report.text;
|
|
923
977
|
};
|
|
978
|
+
// task 1004289: a finished job's annotations never change, so each is read once.
|
|
979
|
+
let notStarted = false;
|
|
980
|
+
const startMemo = new Map();
|
|
981
|
+
const neverStarted = (c) => {
|
|
982
|
+
const key = String((c && c.detailsUrl) || '');
|
|
983
|
+
if (!startMemo.has(key)) startMemo.set(key, jobNeverStarted(c, readJson));
|
|
984
|
+
return startMemo.get(key);
|
|
985
|
+
};
|
|
924
986
|
for (let i = 0; i < MERGE_TRIES; i++) {
|
|
925
987
|
// task 1003499: mergeable + mergeStateStatus ride the same read, so a conflict is
|
|
926
988
|
// named on the poll it appears; the two-field read is the fallback for a gh that
|
|
@@ -937,13 +999,14 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
|
937
999
|
return landBailed('the PR was closed without merging (a check failed, or it was closed manually)', `node scripts/gds/ship.js ${taskId}`);
|
|
938
1000
|
}
|
|
939
1001
|
if (pr) {
|
|
940
|
-
const md = localMergeDriver(pr, failingTestsFor);
|
|
1002
|
+
const md = localMergeDriver(pr, failingTestsFor, neverStarted);
|
|
941
1003
|
const red = redPrLine(md);
|
|
942
1004
|
if (red && red !== redLine) {
|
|
943
1005
|
redLine = red;
|
|
944
1006
|
for (const line of redPrLines(md, { taskId, via: 'local' })) console.log(line);
|
|
945
1007
|
}
|
|
946
1008
|
stuckRed = !!red && redBlocksMerge(md);
|
|
1009
|
+
notStarted = !!(md && md.not_started);
|
|
947
1010
|
}
|
|
948
1011
|
conflicting = noteConflict(taskId, pr, conflicting);
|
|
949
1012
|
pause();
|
|
@@ -954,7 +1017,7 @@ function pollCiDeploy(taskId, branch, headSha, deps = {}) {
|
|
|
954
1017
|
// CONFLICTING (task 1003499): then nothing is still in CI, and it never merges.
|
|
955
1018
|
// Or RED (task 1003764): then the banner says the land is stuck on that check.
|
|
956
1019
|
if (conflicting) return landConflicted(taskId);
|
|
957
|
-
printLandPending(taskId, { red: stuckRed });
|
|
1020
|
+
printLandPending(taskId, { red: stuckRed, notStarted });
|
|
958
1021
|
return landPending();
|
|
959
1022
|
}
|
|
960
1023
|
console.log(` ci-land: merged to main (${mergeSha.slice(0, 8)}). Waiting for the prod deploy...`);
|
|
@@ -1036,6 +1099,9 @@ module.exports = {
|
|
|
1036
1099
|
redBlocksMerge,
|
|
1037
1100
|
localMergeDriver,
|
|
1038
1101
|
unitReportFor,
|
|
1102
|
+
// task 1004289: CI that never ran is said as such, not as a failure to fix.
|
|
1103
|
+
jobNeverStarted,
|
|
1104
|
+
notStartedLines,
|
|
1039
1105
|
reconciledCardStatus,
|
|
1040
1106
|
strandCommand,
|
|
1041
1107
|
strandNextStep,
|
package/src/module-api.js
CHANGED
|
@@ -71,7 +71,7 @@ const { responsibilityFor, ROLE_RESPONSIBILITIES } = require('./role-responsibil
|
|
|
71
71
|
// there. scripts/gds/bump-version.js still rewrites the literal below; it appends
|
|
72
72
|
// the entry to that file. Look for a version's history there, not here.
|
|
73
73
|
// ---------------------------------------------------------------------------
|
|
74
|
-
const CORE_VERSION = '1.19.
|
|
74
|
+
const CORE_VERSION = '1.19.1039'; // CI auto-patch carrier (ADR 0161); changelog: docs/module-api-changelog.md
|
|
75
75
|
|
|
76
76
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
77
77
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
|
@@ -305,6 +305,20 @@ const ADDED_SINCE_R101 = [
|
|
|
305
305
|
+ 'deterministic veto, so an edit here can switch off the protected-path gate '
|
|
306
306
|
+ 'for every grade at once.',
|
|
307
307
|
},
|
|
308
|
+
// task 1002840 (ADR 0089 §3): the ModelProvider port and its neutral config.
|
|
309
|
+
{
|
|
310
|
+
after: 'modules/grading/grade-engine.js',
|
|
311
|
+
glob: 'modules/grading/model-provider.js',
|
|
312
|
+
why: 'the ModelProvider port every grader model call goes through. It decides which '
|
|
313
|
+
+ 'vendor receives the diff under review and with which key, so an edit here can '
|
|
314
|
+
+ 're-route or blind the whole panel.',
|
|
315
|
+
},
|
|
316
|
+
{
|
|
317
|
+
after: 'modules/grading/model-provider.js',
|
|
318
|
+
glob: 'modules/grading/grader-providers.neutral.json',
|
|
319
|
+
why: 'the core neutral provider set, which declares none. An entry added here would '
|
|
320
|
+
+ 'send every instance\'s diffs to a vendor by default.',
|
|
321
|
+
},
|
|
308
322
|
];
|
|
309
323
|
|
|
310
324
|
// Surfaces RENAMED since R101 — still protected, under a new path. Distinct from
|