@bongos/core 1.19.691 → 1.19.693
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 +29 -24
- package/docs/copy-inventory.md +29 -31
- package/docs/copy-registry.json +30 -48
- package/docs/module-api-changelog.md +4 -0
- package/modules/hall-ui/public/dom-utils.js +43 -0
- package/modules/hall-ui/public/goals-page.js +10 -3
- package/modules/hall-ui/public/ideas.html +6 -9
- package/modules/hall-ui/public/ideas.js +15 -0
- package/modules/hall-ui/public/work.js +7 -7
- package/modules/lifecycle/lifecycle.js +6 -0
- package/modules/lifecycle/routes/tasks.js +10 -1
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/scripts/gds/ship-grade.js +42 -4
- package/src/bongos/routes/me.js +8 -0
- package/src/bongos/routes.js +9 -1
- package/src/module-api.js +1 -1
- package/tests/grade_body_limit.mjs +154 -0
- package/tests/task_classifier_vocab.mjs +54 -0
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
// tests/grade_body_limit.mjs — the grade body is not capped at 64 KB (task 1003850).
|
|
2
|
+
//
|
|
3
|
+
// WHAT BROKE. A 1,141-file rename produced a grade body over the global 64 KB JSON cap,
|
|
4
|
+
// so POST /tasks/:id/grade came back 413 payload_too_large and fully verified work was
|
|
5
|
+
// stranded at 'completed' with no way to record its grade. --regrade fails identically:
|
|
6
|
+
// the size is deterministic, not transient.
|
|
7
|
+
//
|
|
8
|
+
// WHY THE BODY CANNOT SIMPLY BE TRIMMED. committed_files carries EVERY changed path, and
|
|
9
|
+
// it is not decoration: modules/grading/grader-score.js runs the server-side
|
|
10
|
+
// permission-path pre-pass against that exact list (SR-9 / SEC #863), so a shortened list
|
|
11
|
+
// would quietly weaken a security check rather than just lose detail. The body gets its
|
|
12
|
+
// own parser instead — the same shape memory/sync, sessions, publish-branch and the task
|
|
13
|
+
// visual already use.
|
|
14
|
+
//
|
|
15
|
+
// Two halves, and BOTH are required: the global parser must SKIP this path (otherwise it
|
|
16
|
+
// 413s before the route-scoped one ever runs — the comment in routes.js says exactly
|
|
17
|
+
// this), and the route must mount a larger parser of its own.
|
|
18
|
+
|
|
19
|
+
import test from 'node:test';
|
|
20
|
+
import assert from 'node:assert/strict';
|
|
21
|
+
import fs from 'node:fs';
|
|
22
|
+
import path from 'node:path';
|
|
23
|
+
import { fileURLToPath } from 'node:url';
|
|
24
|
+
|
|
25
|
+
const ROOT = path.join(path.dirname(fileURLToPath(import.meta.url)), '..');
|
|
26
|
+
const read = (rel) => fs.readFileSync(path.join(ROOT, rel), 'utf8');
|
|
27
|
+
|
|
28
|
+
const GRADE_PATH = '/tasks/1003850/grade';
|
|
29
|
+
|
|
30
|
+
test('the global 64 KB parser SKIPS the grade path', () => {
|
|
31
|
+
const src = read('src/bongos/routes.js');
|
|
32
|
+
|
|
33
|
+
// The cap itself is still 64 KB for everything else — this test is about the exemption,
|
|
34
|
+
// not about loosening the default.
|
|
35
|
+
assert.match(src, /express\.json\(\{ limit: '64kb' \}\)/,
|
|
36
|
+
'the global cap should stay 64 KB; only named paths are exempt');
|
|
37
|
+
|
|
38
|
+
// Pull the skip predicate's path patterns straight out of the source and run them, so
|
|
39
|
+
// this asserts the REAL routing decision rather than the presence of a comment.
|
|
40
|
+
const block = src.slice(src.indexOf('const globalJson'), src.indexOf('// Audit middleware'));
|
|
41
|
+
// Regex LITERALS of the form /^...$/ — the body may contain escaped slashes, so the
|
|
42
|
+
// scan has to allow `\.` rather than stopping at the first `/`.
|
|
43
|
+
const patterns = [...block.matchAll(/\/\^(?:\\.|[^/\n])*\$\//g)].map((m) => m[0]);
|
|
44
|
+
assert.ok(patterns.length >= 3, `expected the skip list to carry several path regexes, saw ${patterns.length}`);
|
|
45
|
+
|
|
46
|
+
const matched = patterns.some((lit) => {
|
|
47
|
+
// eslint-disable-next-line no-new-func -- the literal comes from our own source
|
|
48
|
+
const re = new Function(`return ${lit}`)();
|
|
49
|
+
return re.test(GRADE_PATH);
|
|
50
|
+
});
|
|
51
|
+
assert.ok(matched, `no skip-list pattern matches ${GRADE_PATH} — the global parser would 413 the body before the route's own parser runs`);
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
test('the grade route mounts its own, larger parser', () => {
|
|
55
|
+
const src = read('modules/lifecycle/routes/tasks.js');
|
|
56
|
+
|
|
57
|
+
const m = /const GRADE_BODY_LIMIT = '(\d+)(kb|mb)';/.exec(src);
|
|
58
|
+
assert.ok(m, 'GRADE_BODY_LIMIT is gone — the route would fall back to whatever parser ran first');
|
|
59
|
+
|
|
60
|
+
const bytes = Number(m[1]) * (m[2] === 'mb' ? 1024 * 1024 : 1024);
|
|
61
|
+
assert.ok(bytes > 64 * 1024,
|
|
62
|
+
`GRADE_BODY_LIMIT (${m[1]}${m[2]}) must exceed the 64 KB global cap or the exemption buys nothing`);
|
|
63
|
+
|
|
64
|
+
// Mounted ON the route, ahead of the handler.
|
|
65
|
+
assert.match(src, /const gradeUpload = express\.json\(\{ limit: GRADE_BODY_LIMIT \}\);/,
|
|
66
|
+
'the route-scoped parser is not built from GRADE_BODY_LIMIT');
|
|
67
|
+
// Mounted ON the route. The ORDER (auth first) is asserted separately below; pinning it
|
|
68
|
+
// here too would mean changing the same fact in two places.
|
|
69
|
+
assert.match(src, /router\.post\('\/tasks\/:id\/grade',[^)]*gradeUpload,/,
|
|
70
|
+
'the grade route must run its own parser before the handler');
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
test('a realistic mass-change body fits the new limit', () => {
|
|
74
|
+
// The shape that broke it: one path per changed file, plus the panel's findings. 1,141
|
|
75
|
+
// paths measured ~38 KB of JSON on their own — comfortably over 64 KB once the grade
|
|
76
|
+
// output is added, and comfortably under 1 MB.
|
|
77
|
+
const files = Array.from({ length: 1200 }, (_, i) => `scripts/gds/some-fairly-long-script-name-${i}.js`);
|
|
78
|
+
const body = JSON.stringify({
|
|
79
|
+
committed_files: files,
|
|
80
|
+
subagent_output: { functional_fidelity: 9, code_quality: 9, notes: 'x'.repeat(20_000) },
|
|
81
|
+
});
|
|
82
|
+
assert.ok(body.length > 64 * 1024,
|
|
83
|
+
'this fixture is meant to EXCEED the old cap — if it does not, it is not reproducing the bug');
|
|
84
|
+
assert.ok(body.length < 1024 * 1024,
|
|
85
|
+
`a mass-change body (${body.length} bytes) must fit the route limit`);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
// ── the two findings the panel raised on the first round ────────────────────────────────
|
|
89
|
+
|
|
90
|
+
test('the LARGER parser runs AFTER auth, like every other exempted route here', () => {
|
|
91
|
+
// Raised by the panel, round 1. Mounting the 1 MB parser before requireBuilder lets an
|
|
92
|
+
// UNAUTHENTICATED caller make the server parse up to 1 MB — sixteen times the global cap
|
|
93
|
+
// — before it is rejected. This file already had the right shape twice over; the new
|
|
94
|
+
// route simply diverged from it.
|
|
95
|
+
const src = read('modules/lifecycle/routes/tasks.js');
|
|
96
|
+
|
|
97
|
+
assert.match(src, /router\.post\('\/tasks\/:id\/grade', auth\.requireBuilder, gradeUpload,/,
|
|
98
|
+
'the grade route must authenticate BEFORE parsing a larger body');
|
|
99
|
+
|
|
100
|
+
// The convention this follows, asserted so the three stay in step.
|
|
101
|
+
assert.match(src, /router\.post\('\/tasks\/:id\/publish-branch', auth\.requireBuilder, publishUpload,/,
|
|
102
|
+
'publish-branch is the precedent for auth-then-parser');
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
test('computeRawDiffHash does not collapse an uncapturable diff to hash("")', () => {
|
|
106
|
+
// The worse half of the same buffer bug, and the panel caught it: computeDiffText was
|
|
107
|
+
// fixed while its sibling was not. At the default 1 MB buffer a large diff returned null
|
|
108
|
+
// and the old code hashed the EMPTY STRING — one fixed SHA-256 shared by every large
|
|
109
|
+
// diff. shouldReuseGrade compares that hash to the stored grade's, so two different large
|
|
110
|
+
// diffs on one task collided and a stale PASS could be reused on changed code, skipping
|
|
111
|
+
// the panel entirely.
|
|
112
|
+
const src = read('scripts/gds/ship-grade.js');
|
|
113
|
+
|
|
114
|
+
const fn = src.slice(src.indexOf('function computeRawDiffHash'), src.indexOf('function shouldReuseGrade'));
|
|
115
|
+
assert.ok(fn.length > 0, 'computeRawDiffHash is gone');
|
|
116
|
+
assert.match(fn, /maxBuffer: DIFF_CAPTURE_MAX_BYTES/,
|
|
117
|
+
'computeRawDiffHash must capture at the same size as computeDiffText');
|
|
118
|
+
assert.match(fn, /if \(raw === null\) return null;/,
|
|
119
|
+
'a failed capture must be "unknown" (null), never hash(\'\')');
|
|
120
|
+
assert.doesNotMatch(fn, /diffHash\(raw \|\| ''\)/,
|
|
121
|
+
'hashing `raw || \'\'` is the bug: every uncapturable diff hashes identically');
|
|
122
|
+
|
|
123
|
+
// And "unknown" must actually refuse reuse, which is what makes null the safe answer.
|
|
124
|
+
const reuse = src.slice(src.indexOf('function shouldReuseGrade'), src.indexOf('function shouldReuseGrade') + 400);
|
|
125
|
+
assert.match(reuse, /!currentDiffHash/, 'shouldReuseGrade must refuse a null/absent current hash');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
test('both diff captures use one shared size, not two drifting numbers', () => {
|
|
129
|
+
// The two call sites are the same decision; a second literal is how one gets fixed and
|
|
130
|
+
// the other does not — which is exactly what happened here.
|
|
131
|
+
const src = read('scripts/gds/ship-grade.js');
|
|
132
|
+
assert.match(src, /const DIFF_CAPTURE_MAX_BYTES = /, 'the capture size must be named once');
|
|
133
|
+
const uses = (src.match(/maxBuffer: DIFF_CAPTURE_MAX_BYTES/g) || []).length;
|
|
134
|
+
assert.equal(uses, 2, `both diff captures must share the constant, saw ${uses}`);
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
test('the grade endpoint keeps its full generated description', () => {
|
|
138
|
+
// Panel finding, round 1 — and I doubted it before measuring, which was the wrong
|
|
139
|
+
// instinct. gen-api-docs' commentBlockAbove reads only the `//` lines IMMEDIATELY above
|
|
140
|
+
// `router.post(`, so declaring the parser between the doc-comment and the route collapses
|
|
141
|
+
// this endpoint's description from 1251 chars to 94: the rank line survives and every
|
|
142
|
+
// word about what the endpoint does, and how it fails, is dropped from BOTH
|
|
143
|
+
// docs/api-reference.md and openapi.json. Measured both ways before believing it.
|
|
144
|
+
//
|
|
145
|
+
// Pinned on LENGTH rather than exact prose so ordinary edits to the comment do not red
|
|
146
|
+
// this, while the collapse (which is an order-of-magnitude drop) still does.
|
|
147
|
+
const spec = JSON.parse(read('docs/api/openapi.json'));
|
|
148
|
+
const desc = spec.paths['/tasks/{id}/grade']?.post?.description || '';
|
|
149
|
+
assert.ok(desc.length > 400,
|
|
150
|
+
`the grade endpoint's generated description collapsed to ${desc.length} chars — `
|
|
151
|
+
+ 'something now sits between its doc-comment and router.post(');
|
|
152
|
+
assert.match(desc, /[Ss]ubagent grader/,
|
|
153
|
+
'the description should still explain what the endpoint is');
|
|
154
|
+
});
|
|
@@ -5,8 +5,12 @@
|
|
|
5
5
|
// drive the pure builder + the generic core rule with no disk I/O.
|
|
6
6
|
import assert from 'node:assert/strict';
|
|
7
7
|
import { test } from 'node:test';
|
|
8
|
+
import { readFileSync, readdirSync } from 'node:fs';
|
|
9
|
+
import { createRequire } from 'node:module';
|
|
8
10
|
import classifier from '../modules/lifecycle/task-classifier.js';
|
|
9
11
|
|
|
12
|
+
const require = createRequire(import.meta.url);
|
|
13
|
+
|
|
10
14
|
const { classifyTaskKind, buildBlockerVendorRe } = classifier;
|
|
11
15
|
|
|
12
16
|
test('buildBlockerVendorRe: empty / non-array → null (vanilla has no vendors)', () => {
|
|
@@ -39,3 +43,53 @@ test('generic blocker terms still classify without any vendor config', () => {
|
|
|
39
43
|
assert.equal(classifyTaskKind('Unblock the release', '').kind, 'blocker-resolution');
|
|
40
44
|
assert.equal(classifyTaskKind('Rotate the API key', '').kind, 'blocker-resolution');
|
|
41
45
|
});
|
|
46
|
+
|
|
47
|
+
// ---- the kind vocabulary is ONE list, and everything reads it (task 1003453) --
|
|
48
|
+
//
|
|
49
|
+
// The board's Kind filter hardcoded its options and had gone stale: 'verify' was
|
|
50
|
+
// added to VALID_KINDS (and to the DB CHECK by migration 187) but never to the
|
|
51
|
+
// filter, so verify tasks existed, were claimable, and could not be filtered for.
|
|
52
|
+
// The goals page carried a second copy of the same stale list. Both now build the
|
|
53
|
+
// lens from the vocabulary the server serves, and these pin every hop of that.
|
|
54
|
+
|
|
55
|
+
test('the DB CHECK constraint lists exactly VALID_KINDS', () => {
|
|
56
|
+
// Migration 187's own header records this drift in the other direction — the
|
|
57
|
+
// app gained 'verify', the CHECK did not, and POST /tasks 500'd for months.
|
|
58
|
+
// Comparing the two is the guard neither side had.
|
|
59
|
+
const sql = readFileSync(new URL('../migrations/187_tasks_kind_check_add_verify.sql', import.meta.url), 'utf8');
|
|
60
|
+
const inClause = /CHECK \(kind IN \(([\s\S]*?)\)\)/.exec(sql);
|
|
61
|
+
assert.ok(inClause, 'the CHECK constraint is still written as `kind IN (...)`');
|
|
62
|
+
const fromSql = [...inClause[1].matchAll(/'([a-z-]+)'/g)].map((m) => m[1]);
|
|
63
|
+
assert.deepEqual([...fromSql].sort(), [...classifier.VALID_KINDS].sort(),
|
|
64
|
+
'the DB CHECK and VALID_KINDS must name the same kinds — this is the drift migration 187 exists to record');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('the lifecycle port serves the vocabulary as a JSON-safe array', () => {
|
|
68
|
+
const lifecycle = require('../modules/lifecycle/lifecycle.js');
|
|
69
|
+
const kinds = lifecycle.TASK_KINDS;
|
|
70
|
+
assert.ok(Array.isArray(kinds), 'an array — a Set does not survive JSON.stringify, which is how it reaches the hall');
|
|
71
|
+
assert.deepEqual([...kinds].sort(), [...classifier.VALID_KINDS].sort());
|
|
72
|
+
assert.ok(kinds.includes('verify'), 'the kind this whole task is about');
|
|
73
|
+
assert.notEqual(kinds, lifecycle.TASK_KINDS, 'a fresh copy each read — a caller must not be able to mutate the vocabulary');
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
// Scans the WHOLE surface — every .js AND .html — because the first cut of this
|
|
77
|
+
// test read .js only and matched a JS array literal, so it could not see the third
|
|
78
|
+
// copy: a static <option> list in ideas.html carrying the identical stale
|
|
79
|
+
// vocabulary. A guard that cannot see one of the places the bug lives is worse
|
|
80
|
+
// than no guard, because it is read as coverage. The grader caught that; this is
|
|
81
|
+
// the shape that would have caught it first.
|
|
82
|
+
test('no hall page carries its own copy of the kind list', () => {
|
|
83
|
+
const dir = new URL('../modules/hall-ui/public/', import.meta.url);
|
|
84
|
+
const pages = readdirSync(dir).filter((f) => f.endsWith('.js') || f.endsWith('.html'));
|
|
85
|
+
assert.ok(pages.length > 20, 'the surface was found — an empty scan must not pass silently');
|
|
86
|
+
// Three kinds in sequence is the signature of a spelled-out vocabulary, in a JS
|
|
87
|
+
// array ('cleanup', 'decision', …) or in markup (value="cleanup" … value="decision").
|
|
88
|
+
const SPELLED = /(['"])cleanup\1[\s\S]{0,80}?(['"])decision\2[\s\S]{0,120}?(['"])blocker-resolution\3/;
|
|
89
|
+
for (const page of pages) {
|
|
90
|
+
if (page === 'dom-utils.js') continue; // the one filler is allowed to name them
|
|
91
|
+
const src = readFileSync(new URL(page, dir), 'utf8');
|
|
92
|
+
assert.doesNotMatch(src, SPELLED,
|
|
93
|
+
`${page} spells out the kind vocabulary — fill it from /me task_kinds via OTB.fillKindFilter instead`);
|
|
94
|
+
}
|
|
95
|
+
});
|