@bongos/core 1.19.339 → 1.19.341
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 +41 -26
- package/docs/adr/0188-patrol-runs-on-ci-not-the-server.md +162 -0
- package/docs/adr/README.md +1 -0
- package/docs/copy-inventory.md +153 -151
- package/docs/copy-registry.json +212 -194
- package/docs/file-map.md +1 -1
- package/docs/recipes/redteam-patrol.md +57 -6
- package/package-lock.json +2 -2
- package/package.json +1 -1
- package/public-landing/assets/cosmos.css +18 -3
- package/public-landing/projects.html +191 -51
- package/scripts/gds/control-manifest.js +10 -3
- package/scripts/gds/fitness.js +1 -0
- package/scripts/gds/redteam-patrol.js +78 -0
- package/scripts/gds/routine-schedule.js +231 -0
- package/scripts/gds/run-routine.js +14 -5
- package/src/module-api.js +5 -1
- package/tests/projects_hub.mjs +1 -1
- package/tests/projects_hub_pre_uat.mjs +85 -4
- package/tests/redteam_patrol.mjs +63 -0
- package/tests/routine_schedule.mjs +176 -0
- package/tests/run_routine.mjs +5 -2
|
@@ -24,6 +24,24 @@
|
|
|
24
24
|
// to /api/gds/security/reports. --canary proves the end-to-end pipe: plants a
|
|
25
25
|
// constructed token, asserts the scanner catches it, files a low "[canary]"
|
|
26
26
|
// report, and dispute-closes it so the queue stays clean.
|
|
27
|
+
//
|
|
28
|
+
// UNATTENDED RUNS (task 1003209). The scheduler is .github/workflows/redteam-patrol.yml
|
|
29
|
+
// — CI, not the always-on server, because all three checks read things only the
|
|
30
|
+
// REPO has: full git history, .github/workflows/, and a package-lock npm can audit
|
|
31
|
+
// (ADR 0188). A CI runner has no GDS session, so --file would otherwise hit
|
|
32
|
+
// cli-lib's interactive re-auth wall and print "run /builder-reauth" into a cron
|
|
33
|
+
// log. Instead --file preflights the session and, when there is none, follows the
|
|
34
|
+
// main-audit.js --require-roster doctrine: report LOUDLY rather than green-and-
|
|
35
|
+
// vacuous. The exit code says whether a finding was LOST, not whether findings
|
|
36
|
+
// exist — findings are advisory (ADR 0162) and a patrol that goes red merely for
|
|
37
|
+
// doing its job trains everyone to ignore it:
|
|
38
|
+
//
|
|
39
|
+
// ran + filed → 0
|
|
40
|
+
// ran, nothing to file → 0 (with or without a session)
|
|
41
|
+
// ran, has findings, cannot file → 2 — the one state where evidence evaporates
|
|
42
|
+
//
|
|
43
|
+
// Findings are ALSO appended to $GITHUB_STEP_SUMMARY when set, so a scheduled run
|
|
44
|
+
// is readable in the Actions UI without opening the log.
|
|
27
45
|
|
|
28
46
|
'use strict';
|
|
29
47
|
|
|
@@ -211,6 +229,45 @@ function buildClassReport(className, findings, notes = []) {
|
|
|
211
229
|
};
|
|
212
230
|
}
|
|
213
231
|
|
|
232
|
+
// Append markdown to the Actions job summary when running under GitHub Actions.
|
|
233
|
+
// Best-effort and inert everywhere else — a patrol run must never fail because a
|
|
234
|
+
// log surface was unavailable.
|
|
235
|
+
function stepSummary(md) {
|
|
236
|
+
const dest = process.env.GITHUB_STEP_SUMMARY;
|
|
237
|
+
if (!dest) return;
|
|
238
|
+
try { fs.appendFileSync(dest, `${md}\n`); } catch { /* never fatal */ }
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
// Pure: what a completed sweep should DO with its findings, and the exit code
|
|
242
|
+
// that disposition deserves. Extracted so the honest-exit contract is a table a
|
|
243
|
+
// test can enumerate rather than branching buried in main().
|
|
244
|
+
//
|
|
245
|
+
// The load-bearing row is the last one. Findings are ADVISORY (ADR 0162), so a
|
|
246
|
+
// run that finds three things and files them is a SUCCESS — going red merely for
|
|
247
|
+
// doing its job is how a weekly lane becomes noise everyone filters. Red is
|
|
248
|
+
// reserved for the single state where evidence EVAPORATES: findings exist,
|
|
249
|
+
// filing was asked for, and it cannot happen.
|
|
250
|
+
function disposition({ findingClasses = 0, doFile = false, blocker = null } = {}) {
|
|
251
|
+
if (!findingClasses) return { action: 'clean', exitCode: 0 };
|
|
252
|
+
if (!doFile) return { action: 'dry-run', exitCode: 0 };
|
|
253
|
+
if (blocker) return { action: 'not-filed', exitCode: 2 };
|
|
254
|
+
return { action: 'file', exitCode: 0 };
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Is there a GDS session this run could file with? Returns the reason it cannot,
|
|
258
|
+
// or null when filing is possible. Read directly (readSessionToken) rather than
|
|
259
|
+
// probing with apiCall, because apiCall's 401 path exits the process with
|
|
260
|
+
// interactive re-auth guidance — useless in a cron log and indistinguishable from
|
|
261
|
+
// a crash.
|
|
262
|
+
function filingBlocker() {
|
|
263
|
+
try {
|
|
264
|
+
const { readSessionToken } = require('./cli-lib.js');
|
|
265
|
+
return readSessionToken() ? null : 'no GDS session on this runner';
|
|
266
|
+
} catch (e) {
|
|
267
|
+
return `session unreadable: ${e && e.message}`;
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
214
271
|
async function fileReport(report) {
|
|
215
272
|
const { apiCall } = require('./cli-lib.js');
|
|
216
273
|
const r = await apiCall('POST', '/api/gds/security/reports', report);
|
|
@@ -283,10 +340,30 @@ async function main() {
|
|
|
283
340
|
const pins = actionPinCheck();
|
|
284
341
|
if (pins.findings.length) classes.push(buildClassReport('action-pins', pins.findings));
|
|
285
342
|
|
|
343
|
+
if (secrets.degraded) stepSummary(`> **DEGRADED** — ${secrets.degraded}`);
|
|
344
|
+
|
|
286
345
|
if (!classes.length) {
|
|
287
346
|
console.log('patrol: clean sweep — nothing to file.');
|
|
347
|
+
stepSummary('## red-team patrol\n\nClean sweep — no findings to file.');
|
|
288
348
|
return;
|
|
289
349
|
}
|
|
350
|
+
|
|
351
|
+
stepSummary(['## red-team patrol', ...classes.map((c) =>
|
|
352
|
+
`### ${c.title}\n\nseverity: **${c.severity}**\n\n${c.repro_steps_md}`)].join('\n\n'));
|
|
353
|
+
|
|
354
|
+
// Preflight the session ONCE, before the first POST: a half-filed sweep (class
|
|
355
|
+
// one lands, class two hits the auth wall and exits) is worse than not starting.
|
|
356
|
+
const blocker = doFile ? filingBlocker() : null;
|
|
357
|
+
const { action, exitCode } = disposition({ findingClasses: classes.length, doFile, blocker });
|
|
358
|
+
if (action === 'not-filed') {
|
|
359
|
+
console.error(`patrol: DID NOT FILE — ${blocker}. ${classes.length} finding class(es) above are NOT in the adjudication queue.`);
|
|
360
|
+
console.error('patrol: sign in on this runner (or set the scheduler\'s session secret) and re-run — see docs/recipes/redteam-patrol.md.');
|
|
361
|
+
for (const report of classes) console.error(` [${report.severity}] ${report.title}\n${report.repro_steps_md}`);
|
|
362
|
+
stepSummary(`\n> **NOT FILED** — ${blocker}. These findings exist only in this run's log.`);
|
|
363
|
+
process.exitCode = exitCode;
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
|
|
290
367
|
for (const report of classes) {
|
|
291
368
|
if (doFile) {
|
|
292
369
|
const id = await fileReport(report);
|
|
@@ -305,4 +382,5 @@ if (require.main === module) {
|
|
|
305
382
|
module.exports = {
|
|
306
383
|
parseGitleaksReport, unpinnedUses, ageDays, mapSeverity, maxSeverity,
|
|
307
384
|
buildClassReport, lockedVersion, COOLDOWN_DAYS, YOUNG_PKG_DAYS,
|
|
385
|
+
stepSummary, filingBlocker, disposition,
|
|
308
386
|
};
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// scripts/gds/routine-schedule.js — a declared cadence must have something that
|
|
3
|
+
// fires it (task 1003209, goal 1000079; docs/audits/ai-dev-redteam-followup.md §7).
|
|
4
|
+
//
|
|
5
|
+
// THE GAP THIS CLOSES. Goal 1000076 shipped the redteam-weekly patrol, wired it
|
|
6
|
+
// into the control manifest, and documented it — and then nothing ever ran it.
|
|
7
|
+
// The follow-up audit's §7 named all three halves of the failure:
|
|
8
|
+
//
|
|
9
|
+
// 1. no committed scheduler for .claude/scheduled-tasks/ at all;
|
|
10
|
+
// 2. the SKILL.md declared `mode` and `steps` but no cadence — "nothing for a
|
|
11
|
+
// scheduler to read even if one looked";
|
|
12
|
+
// 3. control-manifest.js called the patrol "wired" on a substring (the SKILL.md
|
|
13
|
+
// mentions its own scanner), so the manifest was GREEN while the patrol was
|
|
14
|
+
// dormant.
|
|
15
|
+
//
|
|
16
|
+
// (2) is now a real frontmatter field (run-routine.js parseRoutineFrontmatter),
|
|
17
|
+
// (1) is .github/workflows/redteam-patrol.yml, and THIS file is (3): the check
|
|
18
|
+
// that refuses the combination of a declared cadence and nothing firing it.
|
|
19
|
+
//
|
|
20
|
+
// WHY A DEDICATED CHECK RATHER THAN ANOTHER MANIFEST ROW. The manifest's
|
|
21
|
+
// predicates are deliberately shallow — file exists, one wiring substring. That
|
|
22
|
+
// shape is exactly what let the patrol read as wired while dormant, and this goal
|
|
23
|
+
// has now hit the same failure five times: an enforcement surface that NAMES a
|
|
24
|
+
// file goes VACUOUS, not red, when the thing it names moves or stops. So the
|
|
25
|
+
// predicate here is relational, not existential: it reads the cadence each
|
|
26
|
+
// routine declares, reads the cron each scheduler actually carries, and fails
|
|
27
|
+
// when the two disagree in EITHER direction —
|
|
28
|
+
//
|
|
29
|
+
// • a routine declares `cadence:` and no scheduler fires it → red
|
|
30
|
+
// • a scheduler fires a routine that no longer exists → red (rename rot)
|
|
31
|
+
// • the cron's real period differs from the declared cadence → red
|
|
32
|
+
//
|
|
33
|
+
// The third is the one a substring check can never catch: edit the cron from
|
|
34
|
+
// weekly to yearly and every "is it wired?" predicate stays green.
|
|
35
|
+
//
|
|
36
|
+
// TWO-REPO TOLERANCE. Scheduled routines are TOPOLOGY — `bongos init/upgrade`
|
|
37
|
+
// materializes .claude/scheduled-tasks/ into each instance (claude-materialize.js),
|
|
38
|
+
// but .github/workflows/ is the CORE repo's and does not travel. So an instance
|
|
39
|
+
// checkout legitimately has routines and no workflows directory. There, the
|
|
40
|
+
// unscheduled routines are reported as WARNINGS (the instance's operator owns its
|
|
41
|
+
// own scheduler); in a repo that HAS workflows, they are violations. That keeps
|
|
42
|
+
// the check honest in the core without red-building every instance.
|
|
43
|
+
|
|
44
|
+
'use strict';
|
|
45
|
+
|
|
46
|
+
const fs = require('fs');
|
|
47
|
+
const path = require('path');
|
|
48
|
+
|
|
49
|
+
const ROOT = path.resolve(__dirname, '..', '..');
|
|
50
|
+
const TASKS_DIR = path.join(ROOT, '.claude', 'scheduled-tasks');
|
|
51
|
+
const WORKFLOWS_DIR = path.join(ROOT, '.github', 'workflows');
|
|
52
|
+
|
|
53
|
+
const { parseRoutineFrontmatter } = require('./run-routine.js');
|
|
54
|
+
|
|
55
|
+
// The cadences a routine may declare. The value is the period a cron expression
|
|
56
|
+
// must resolve to for the two to agree. Add a name here and cronCadence() below
|
|
57
|
+
// must be able to produce it, or the pair can never match.
|
|
58
|
+
const CADENCES = new Set(['hourly', 'daily', 'weekly', 'monthly']);
|
|
59
|
+
|
|
60
|
+
// Pure: classify a 5-field cron expression into the cadence it actually realizes.
|
|
61
|
+
// Returns null for anything this vocabulary cannot describe (step values, lists,
|
|
62
|
+
// ranges) — a cadence can then never "match" it, which is the safe direction: an
|
|
63
|
+
// exotic cron must be described some other way rather than silently accepted.
|
|
64
|
+
//
|
|
65
|
+
// min hour dom mon dow
|
|
66
|
+
// 17 6 * * 1 → weekly (a specific day-of-week)
|
|
67
|
+
// 17 6 * * * → daily (a specific hour, every day)
|
|
68
|
+
// 17 6 3 * * → monthly (a specific day-of-month)
|
|
69
|
+
// 17 * * * * → hourly (a specific minute, every hour)
|
|
70
|
+
function cronCadence(expr) {
|
|
71
|
+
const f = String(expr == null ? '' : expr).trim().split(/\s+/);
|
|
72
|
+
if (f.length !== 5) return null;
|
|
73
|
+
const [min, hour, dom, mon, dow] = f;
|
|
74
|
+
const fixed = (v) => /^\d+$/.test(v);
|
|
75
|
+
const any = (v) => v === '*';
|
|
76
|
+
if (!fixed(min)) return null; // every cadence below pins the minute
|
|
77
|
+
if (!any(mon)) return null; // a specific month is a yearly shape
|
|
78
|
+
if (any(hour)) return any(dom) && any(dow) ? 'hourly' : null;
|
|
79
|
+
if (!fixed(hour)) return null;
|
|
80
|
+
if (fixed(dow) && any(dom)) return 'weekly';
|
|
81
|
+
if (fixed(dom) && any(dow)) return 'monthly';
|
|
82
|
+
if (any(dom) && any(dow)) return 'daily';
|
|
83
|
+
return null;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
// Pure: every cron expression a workflow file declares under `on: schedule:`.
|
|
87
|
+
// A focused subset of YAML (the repo has no js-yaml dep and the parser in
|
|
88
|
+
// run-routine.js is the same shape): the `cron:` keys are unambiguous enough to
|
|
89
|
+
// read directly, and a workflow that hides one behind an anchor would simply
|
|
90
|
+
// read as unscheduled — the safe direction.
|
|
91
|
+
function workflowCrons(yamlText) {
|
|
92
|
+
const out = [];
|
|
93
|
+
for (const line of String(yamlText == null ? '' : yamlText).split('\n')) {
|
|
94
|
+
const m = /^\s*-?\s*cron:\s*['"]?([^'"#\n]+?)['"]?\s*(?:#.*)?$/.exec(line);
|
|
95
|
+
if (m) out.push(m[1].trim());
|
|
96
|
+
}
|
|
97
|
+
return out;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Pure: does `yamlText` actually invoke routine `name`? Either the canonical
|
|
101
|
+
// dispatcher form (`run-routine.js <name>`) or one of the routine's own step
|
|
102
|
+
// commands verbatim. Both are real invocations; a mere mention in prose is not,
|
|
103
|
+
// which is the whole point — the substring predicate this replaces was satisfied
|
|
104
|
+
// by a comment.
|
|
105
|
+
function invokesRoutine(yamlText, name, steps = []) {
|
|
106
|
+
const hay = String(yamlText == null ? '' : yamlText);
|
|
107
|
+
const dispatched = new RegExp(`run-routine\\.js\\s+(?:--\\S+\\s+)*${name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}(?![\\w-])`);
|
|
108
|
+
if (dispatched.test(hay)) return true;
|
|
109
|
+
return steps.some((s) => s.command && hay.includes(s.command));
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Read every routine that declares a cadence, plus the ones that don't.
|
|
113
|
+
function readRoutines(dir = TASKS_DIR) {
|
|
114
|
+
const withCadence = [];
|
|
115
|
+
const withoutCadence = [];
|
|
116
|
+
let names = [];
|
|
117
|
+
try {
|
|
118
|
+
names = fs.readdirSync(dir, { withFileTypes: true }).filter((d) => d.isDirectory()).map((d) => d.name);
|
|
119
|
+
} catch (_) { return { withCadence, withoutCadence }; }
|
|
120
|
+
for (const name of names.sort()) {
|
|
121
|
+
let md;
|
|
122
|
+
try { md = fs.readFileSync(path.join(dir, name, 'SKILL.md'), 'utf8'); } catch (_) { continue; }
|
|
123
|
+
const { cadence, steps } = parseRoutineFrontmatter(md);
|
|
124
|
+
if (cadence) withCadence.push({ name, cadence, steps });
|
|
125
|
+
else withoutCadence.push({ name });
|
|
126
|
+
}
|
|
127
|
+
return { withCadence, withoutCadence };
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function readWorkflows(dir = WORKFLOWS_DIR) {
|
|
131
|
+
let files = [];
|
|
132
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.yml') || f.endsWith('.yaml')); }
|
|
133
|
+
catch (_) { return null; } // null = no workflows directory at all (instance repo)
|
|
134
|
+
return files.sort().map((f) => ({ file: f, text: fs.readFileSync(path.join(dir, f), 'utf8') }));
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Pure evaluation — exported for the test, which drives it with fixtures.
|
|
138
|
+
// `workflows === null` means "this checkout has no CI to schedule with", which
|
|
139
|
+
// downgrades unscheduled routines from violations to warnings (see the two-repo
|
|
140
|
+
// note in the header).
|
|
141
|
+
function evaluate({ withCadence, withoutCadence }, workflows) {
|
|
142
|
+
const violations = [];
|
|
143
|
+
const warnings = [];
|
|
144
|
+
const hasCI = workflows !== null;
|
|
145
|
+
const wf = workflows || [];
|
|
146
|
+
|
|
147
|
+
for (const r of withCadence) {
|
|
148
|
+
if (!CADENCES.has(r.cadence)) {
|
|
149
|
+
violations.push(`routine '${r.name}': unknown cadence "${r.cadence}" (want ${[...CADENCES].join('|')})`);
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
const firing = wf.filter((w) => invokesRoutine(w.text, r.name, r.steps));
|
|
153
|
+
if (!firing.length) {
|
|
154
|
+
const msg = `routine '${r.name}' declares cadence: ${r.cadence} but NOTHING fires it — no scheduled workflow invokes it`;
|
|
155
|
+
(hasCI ? violations : warnings).push(msg);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// It is invoked somewhere. At least one of those invocations must be on a
|
|
159
|
+
// schedule whose real period is the declared cadence. A workflow_dispatch-only
|
|
160
|
+
// lane is a human running it — exactly the state §7 called "decorative".
|
|
161
|
+
const seen = [];
|
|
162
|
+
let matched = false;
|
|
163
|
+
for (const w of firing) {
|
|
164
|
+
for (const expr of workflowCrons(w.text)) {
|
|
165
|
+
const actual = cronCadence(expr);
|
|
166
|
+
seen.push(`${w.file}: "${expr}" → ${actual || 'unclassifiable'}`);
|
|
167
|
+
if (actual === r.cadence) matched = true;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
if (!matched) {
|
|
171
|
+
violations.push(
|
|
172
|
+
seen.length
|
|
173
|
+
? `routine '${r.name}' declares cadence: ${r.cadence} but no scheduler fires it at that period — ${seen.join('; ')}`
|
|
174
|
+
: `routine '${r.name}' declares cadence: ${r.cadence} but ${firing.map((w) => w.file).join(', ')} carries no 'on: schedule:' cron (manual dispatch only)`
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// Rename rot, the other direction: a scheduler that dispatches a routine name
|
|
180
|
+
// no longer on disk. run-routine.js exits 2 on a missing SKILL.md, so this
|
|
181
|
+
// would be a weekly red job nobody watches; catch it at merge time instead.
|
|
182
|
+
// Deduped: a workflow legitimately names its own invocation more than once (the
|
|
183
|
+
// step itself plus the header comment explaining why it dispatches rather than
|
|
184
|
+
// calling the script), and one rename should read as one problem.
|
|
185
|
+
const known = new Set([...withCadence, ...withoutCadence].map((r) => r.name));
|
|
186
|
+
const dangling = new Set();
|
|
187
|
+
for (const w of wf) {
|
|
188
|
+
for (const m of w.text.matchAll(/run-routine\.js\s+(?:--\S+\s+)*([A-Za-z0-9_-]+)/g)) {
|
|
189
|
+
if (!known.has(m[1])) dangling.add(`${w.file} dispatches routine '${m[1]}', which has no .claude/scheduled-tasks/${m[1]}/SKILL.md`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
violations.push(...dangling);
|
|
193
|
+
|
|
194
|
+
for (const r of withoutCadence) {
|
|
195
|
+
warnings.push(`routine '${r.name}' declares no cadence: — it can only ever be run by hand`);
|
|
196
|
+
}
|
|
197
|
+
return { violations, warnings };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// The fitness.js check.
|
|
201
|
+
function checkRoutineCadencesScheduled() {
|
|
202
|
+
const routines = readRoutines();
|
|
203
|
+
const workflows = readWorkflows();
|
|
204
|
+
const { violations, warnings } = evaluate(routines, workflows);
|
|
205
|
+
const scheduled = routines.withCadence.length - violations.filter((v) => v.startsWith("routine '")).length;
|
|
206
|
+
return {
|
|
207
|
+
name: 'a declared cron cadence has something that fires it (task 1003209)',
|
|
208
|
+
ok: violations.length === 0,
|
|
209
|
+
hardFail: violations.length > 0,
|
|
210
|
+
violations,
|
|
211
|
+
warnings,
|
|
212
|
+
note: violations.length
|
|
213
|
+
? 'a scheduled routine is declared but dormant — add/repair the scheduler, or drop the cadence: field and stop claiming it runs'
|
|
214
|
+
: `${scheduled}/${routines.withCadence.length} cadence-declaring routine(s) fired by a committed scheduler at the declared period; ${routines.withoutCadence.length} manual-only.`,
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function main() {
|
|
219
|
+
const r = checkRoutineCadencesScheduled();
|
|
220
|
+
for (const v of r.violations) console.error(`✗ ${v}`);
|
|
221
|
+
for (const w of r.warnings) console.warn(`⚠ ${w}`);
|
|
222
|
+
console.log(`${r.hardFail ? 'FAIL' : 'PASS'} ${r.note}`);
|
|
223
|
+
process.exit(r.hardFail ? 1 : 0);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (require.main === module) main();
|
|
227
|
+
|
|
228
|
+
module.exports = {
|
|
229
|
+
checkRoutineCadencesScheduled, evaluate, cronCadence, workflowCrons,
|
|
230
|
+
invokesRoutine, readRoutines, readWorkflows, CADENCES,
|
|
231
|
+
};
|
|
@@ -47,12 +47,19 @@ function unquote(s) {
|
|
|
47
47
|
return t;
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
-
// Pure: parse mode + steps[] from SKILL.md frontmatter. A focused YAML
|
|
51
|
-
// (no js-yaml dep): scalar `mode:`/`description:` and a `steps:`
|
|
52
|
-
// `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
|
|
50
|
+
// Pure: parse mode + cadence + steps[] from SKILL.md frontmatter. A focused YAML
|
|
51
|
+
// subset (no js-yaml dep): scalar `mode:`/`cadence:`/`description:` and a `steps:`
|
|
52
|
+
// list of `- command: …` / `sink: …` items. Unknown keys are ignored. Exported + tested.
|
|
53
|
+
//
|
|
54
|
+
// `cadence:` (task 1003209) is the field the follow-up audit found missing — the
|
|
55
|
+
// routines declared HOW to run but never HOW OFTEN, so "there was nothing for a
|
|
56
|
+
// scheduler to read even if one looked." It is declarative only: run-routine.js
|
|
57
|
+
// still runs a routine exactly once per invocation. The cadence is the contract a
|
|
58
|
+
// committed scheduler must honour, and scripts/gds/routine-schedule.js is the check
|
|
59
|
+
// that refuses a cadence nothing fires.
|
|
53
60
|
function parseRoutineFrontmatter(skillMd) {
|
|
54
61
|
const norm = String(skillMd == null ? '' : skillMd).replace(/\r\n/g, '\n');
|
|
55
|
-
const empty = { mode: null, steps: [], description: null };
|
|
62
|
+
const empty = { mode: null, cadence: null, steps: [], description: null };
|
|
56
63
|
if (!norm.startsWith('---\n')) return empty;
|
|
57
64
|
const lines = norm.split('\n');
|
|
58
65
|
let close = -1;
|
|
@@ -62,6 +69,7 @@ function parseRoutineFrontmatter(skillMd) {
|
|
|
62
69
|
if (close === -1) return empty;
|
|
63
70
|
|
|
64
71
|
let mode = null;
|
|
72
|
+
let cadence = null;
|
|
65
73
|
let description = null;
|
|
66
74
|
const steps = [];
|
|
67
75
|
let inSteps = false;
|
|
@@ -96,12 +104,13 @@ function parseRoutineFrontmatter(skillMd) {
|
|
|
96
104
|
|
|
97
105
|
if (!inSteps && isTopLevel && topKv) {
|
|
98
106
|
if (topKv[1] === 'mode') mode = unquote(topKv[2]) || null;
|
|
107
|
+
else if (topKv[1] === 'cadence') cadence = unquote(topKv[2]) || null;
|
|
99
108
|
else if (topKv[1] === 'description') description = topKv[2].trim();
|
|
100
109
|
else if (topKv[1] === 'steps' && topKv[2].trim() === '') inSteps = true;
|
|
101
110
|
}
|
|
102
111
|
}
|
|
103
112
|
pushCur();
|
|
104
|
-
return { mode, steps, description };
|
|
113
|
+
return { mode, cadence, steps, description };
|
|
105
114
|
}
|
|
106
115
|
|
|
107
116
|
// Pure: split a repo-authored command string into argv, honoring simple quotes.
|
package/src/module-api.js
CHANGED
|
@@ -1108,8 +1108,12 @@ const { validateOrRespond, LIMITS, parseId, asyncHandler, corsPublicGet, parsePa
|
|
|
1108
1108
|
// landed since 1.19.337 with no explicit bump. run 32796733697. (task 1002620)
|
|
1109
1109
|
// 1.19.339 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1110
1110
|
// landed since 1.19.338 with no explicit bump. run 32797325535. (task 1002620)
|
|
1111
|
+
// 1.19.340 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1112
|
+
// landed since 1.19.339 with no explicit bump. run 32798342738. (task 1002620)
|
|
1113
|
+
// 1.19.341 — CI auto-patch (publish-on-merge, ADR 0161): carrier for merges
|
|
1114
|
+
// landed since 1.19.340 with no explicit bump. run 32798507686. (task 1002620)
|
|
1111
1115
|
// ---------------------------------------------------------------------------
|
|
1112
|
-
const CORE_VERSION = '1.19.
|
|
1116
|
+
const CORE_VERSION = '1.19.341'; // CI auto-patch carrier (ADR 0161); see changelog above
|
|
1113
1117
|
|
|
1114
1118
|
// A namespaced logger so a module's log lines are attributable + consistent.
|
|
1115
1119
|
// Usage: const log = api.logger('dev-box'); log.info('mounted');
|
package/tests/projects_hub.mjs
CHANGED
|
@@ -293,7 +293,7 @@ test('the new view balances its own tree — the map-only walkers never covered
|
|
|
293
293
|
// lives there too; a lopsided div in the wizard block would be caught by
|
|
294
294
|
// nothing else while silently reparenting the footer.
|
|
295
295
|
const start = HUB.indexOf('<section id="view-new"');
|
|
296
|
-
const end = HUB.indexOf('<
|
|
296
|
+
const end = HUB.indexOf('<footer class="foot"');
|
|
297
297
|
assert.ok(start !== -1 && end > start, 'need the view-new span');
|
|
298
298
|
const wiz = HUB.slice(start, end);
|
|
299
299
|
assert.equal((wiz.match(/<div\b/g) || []).length, (wiz.match(/<\/div>/g) || []).length,
|
|
@@ -69,14 +69,14 @@ test('a queued standup lights "Request filed", not "Standing it up" — on the c
|
|
|
69
69
|
const strip = fn('stripFor');
|
|
70
70
|
assert.match(strip, /if \(s === 'requested'\) \{[\s\S]*?stripMarkup\(revive \? 'revive' : 'standup', 0, 0,/, 'card: requested → stage 0 current');
|
|
71
71
|
assert.match(strip, /if \(s === 'provisioning'\) \{[\s\S]*?stripMarkup\(revive \? 'revive' : 'standup', 1, 1,/, 'card: provisioning → stage 1 current');
|
|
72
|
-
assert.match(SCRIPT, /if \(s === 'requested'\) \{\n\s*
|
|
72
|
+
assert.match(SCRIPT, /if \(s === 'requested'\) \{\n\s*setProgress\(progressMarkup\(0, 0,/, 'done panel: requested → stage 0 current');
|
|
73
73
|
assert.doesNotMatch(SCRIPT, /Waiting for a runner/, '"runner" is operator vocabulary');
|
|
74
74
|
assert.doesNotMatch(SCRIPT, /a runner picks it up/, '"runner" is operator vocabulary (retry message)');
|
|
75
75
|
});
|
|
76
76
|
|
|
77
77
|
/* ── the done panel never lies at the end of its watch, and never opens a door early ── */
|
|
78
78
|
test('the 33-minute hand-off keeps a snag honest and keeps its flow', () => {
|
|
79
|
-
assert.match(SCRIPT, /if \(ticks > 400\) \{[\s\S]*?if \(live\.instStatus !== 'error'\) \{[\s\S]*?Still working[\s\S]*?watchFlow\)
|
|
79
|
+
assert.match(SCRIPT, /if \(ticks > 400\) \{[\s\S]*?if \(live\.instStatus !== 'error'\) \{[\s\S]*?Still working[\s\S]*?watchFlow\)/, 'no "still building" over an error; the strip keeps its vocabulary');
|
|
80
80
|
assert.doesNotMatch(SCRIPT, /Still building — this tab will stop watching now/);
|
|
81
81
|
assert.match(SCRIPT, /progressMarkup\(1, 1, m\.slice\(5\), 'isErr', watchFlow\)/, 'the session-expiry message keeps its flow too');
|
|
82
82
|
});
|
|
@@ -190,7 +190,7 @@ test('a live private-repo project is not told it is broken', () => {
|
|
|
190
190
|
test('Back moves between views — a deliberate view change is a history entry', () => {
|
|
191
191
|
assert.match(SCRIPT, /history\.pushState\(null, '', '\?view=' \+ v \+ idPart\)/);
|
|
192
192
|
assert.match(SCRIPT, /history\.replaceState\(null, '', '\?view=' \+ v \+ idPart\)/, 'boot + background settles still rewrite in place');
|
|
193
|
-
assert.match(SCRIPT, /window\.addEventListener\('popstate', function \(\) \{ setView\(viewFromUrl\(\)\); \}\)/);
|
|
193
|
+
assert.match(SCRIPT, /window\.addEventListener\('popstate', function \(\) \{ closeCard\(\); setView\(viewFromUrl\(\)\); \}\)/);
|
|
194
194
|
assert.match(SCRIPT, /else if \(v === 'manage' && a\.dataset\.nav === 'mine'\) a\.setAttribute\('aria-current', 'page'\)/, 'manage keeps the Your-projects pill lit');
|
|
195
195
|
});
|
|
196
196
|
|
|
@@ -300,7 +300,7 @@ test('decision 9 — only mouse-driven focus on a wizard heading goes ringless',
|
|
|
300
300
|
|
|
301
301
|
test('decision 10 — both footers carry the open-source (AGPL-3.0) link, in link ink', () => {
|
|
302
302
|
const AGPL = /<a href="https:\/\/www\.gnu\.org\/licenses\/agpl-3\.0\.html" target="_blank" rel="noopener">Open source \(AGPL-3\.0\)<\/a>\./;
|
|
303
|
-
const hubFoot = (HUB.match(/<
|
|
303
|
+
const hubFoot = (HUB.match(/<footer class="foot">[\s\S]*?<\/footer>/) || [''])[0];
|
|
304
304
|
const indexFoot = (INDEX.match(/<div class="foot">[\s\S]*?<\/div>/) || [''])[0];
|
|
305
305
|
assert.match(hubFoot, AGPL, 'projects.html');
|
|
306
306
|
assert.match(indexFoot, AGPL, 'index.html');
|
|
@@ -362,3 +362,84 @@ test('card action results announce, nothing under the pointer is deleted, and fo
|
|
|
362
362
|
assert.match(fn('focusKeyOf'), /data-teardown-yes'\) \|\| el\.hasAttribute\('data-teardown-no'\)\) return '\[data-teardown\]'/, 'Yes / Keep land back on the door that opened them');
|
|
363
363
|
assert.match(fn('focusCardControl'), /li\.querySelector\('\.actions a, \.actions button'\) \|\|\n\s*li\.querySelector\('details\.attachFold summary'\)/, 'nearest control as the fallback');
|
|
364
364
|
});
|
|
365
|
+
|
|
366
|
+
/* ── accessibility follow-ups (task 1003246) ── */
|
|
367
|
+
test('the done panel’s progress region is stable, change-only, and says each stage’s state', () => {
|
|
368
|
+
assert.match(HUB, /<div id="liveProgress" role="status" aria-live="polite"><\/div>/, 'the live region is the static wrapper');
|
|
369
|
+
assert.doesNotMatch(fn('progressMarkup'), /aria-live/, 'the rebuilt strip carries no live-region attributes');
|
|
370
|
+
assert.match(fn('progressMarkup'), /\(c === 'now' \? ' aria-current="step"' : ''\)/, 'the current stage is marked');
|
|
371
|
+
assert.match(fn('progressMarkup'), /<span class="vh">, ' \+ word \+ '<\/span>/, 'each stage says its state to a screen reader');
|
|
372
|
+
assert.match(fn('setProgress'), /if \(!el \|\| html === lastProgHtml\) return;/, 'an identical tick performs no DOM write');
|
|
373
|
+
assert.doesNotMatch(SCRIPT, /\$\('liveProgress'\)\.innerHTML =/, 'every write goes through setProgress');
|
|
374
|
+
assert.doesNotMatch(fn('renderProgress'), /el\.innerHTML =/, 'renderProgress included');
|
|
375
|
+
assert.match(SCRIPT, /lastStepsHtml = '';\n\s*lastProgHtml = '';/, 'starting another project resets both guards');
|
|
376
|
+
assert.match(COSMOS, /\.vh\{position:absolute;width:1px;height:1px;overflow:hidden;clip-path:inset\(50%\);white-space:nowrap;\}/, 'the visually-hidden utility is in cosmos');
|
|
377
|
+
assert.match(fn('stripMarkup'), /role="status" aria-live="polite"/, 'the mine-card strip is left alone this pass');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('the map table sorts from the keyboard and an owned row’s name is a real link', () => {
|
|
381
|
+
assert.match(HUB, /<th class="colName" data-sort="name" aria-sort="ascending"><button type="button" class="thSort">Project<\/button><\/th>/);
|
|
382
|
+
assert.match(HUB, /<th class="right" data-sort="builders"><button type="button" class="thSort">Builders<\/button><\/th>/);
|
|
383
|
+
assert.match(SCRIPT, /var btn = th\.querySelector\('button'\);\n\s*if \(!btn\) return;\n\s*btn\.addEventListener\('click'/, 'the listener sits on the button, not the th');
|
|
384
|
+
assert.doesNotMatch(SCRIPT, /th\.style\.cursor/, 'no JS-set cursor on the th');
|
|
385
|
+
assert.match(COSMOS, /\.tbl th button\{all:unset;cursor:pointer;\}/);
|
|
386
|
+
assert.match(COSMOS, /\.tbl th button:focus-visible\{outline:2px solid var\(--accent\);outline-offset:2px;\}/, 'keyboard focus on the sort button is visible');
|
|
387
|
+
const generated = (SCRIPT.match(/return '<tr data-generated([\s\S]*?)'<\/tr>';/) || [, ''])[1];
|
|
388
|
+
assert.match(generated, /<a class="ptitle" data-manage="' \+ esc\(String\(p\._inst\.id\)\) \+ '" href="\?view=manage&id=' \+ esc\(String\(p\._inst\.id\)\) \+ '">/, 'an owned row’s title is a manage anchor');
|
|
389
|
+
const rows = SCRIPT.slice(SCRIPT.indexOf("$('mapRows').addEventListener('click'"));
|
|
390
|
+
const branch = rows.indexOf("closest('a[data-manage]')");
|
|
391
|
+
const bail = rows.indexOf("if (ev.target.closest && ev.target.closest('a')) return;");
|
|
392
|
+
assert.ok(branch > 0 && bail > branch, 'the manage branch runs before the anchor bail-out');
|
|
393
|
+
assert.match(HUB, /Sort by Project or Builders\./, 'the hint names the two sortable columns');
|
|
394
|
+
assert.match(COSMOS, /\.tbl \.ptitle\{[^}]*text-decoration:none;\}/, 'the link wears the title’s ink, not the UA underline');
|
|
395
|
+
});
|
|
396
|
+
|
|
397
|
+
test('the hub has one main landmark, a heading for the map, a footer, and a skip link', () => {
|
|
398
|
+
const main = HUB.indexOf('<main id="hubMain">');
|
|
399
|
+
const map = HUB.indexOf('<section id="view-map">');
|
|
400
|
+
const close = HUB.indexOf('\n</main>');
|
|
401
|
+
const foot = HUB.indexOf('<footer class="foot">');
|
|
402
|
+
assert.ok(main > 0 && main < map && close > HUB.indexOf('<section id="view-new"') && foot > close, 'the four views sit inside #hubMain, the footer after it');
|
|
403
|
+
assert.equal((HUB.match(/<main\b/g) || []).length, 1, 'exactly one main');
|
|
404
|
+
assert.match(HUB, /<section id="view-map">\n\s*<h1 class="vh">The project map<\/h1>/, 'the map view has a heading');
|
|
405
|
+
assert.match(HUB, /<a class="skip vh" href="#hubMain">Skip to content<\/a>\n<header class="shell">/, 'the skip link is the first thing before the shell');
|
|
406
|
+
assert.match(HUB, /\.skip:focus\{position:fixed;top:8px;left:8px;/, 'it becomes a real pill on focus');
|
|
407
|
+
assert.doesNotMatch(HUB, /<main class="card">/, 'the old inner main is a div');
|
|
408
|
+
});
|
|
409
|
+
|
|
410
|
+
test('completed rail steps are a way back, the repo picker is honest ARIA, the card inerts the page', () => {
|
|
411
|
+
assert.match(fn('paintRail'), /if \(step < 6 && n < cur\) \{\n\s*li\.setAttribute\('role', 'button'\);\n\s*li\.setAttribute\('tabindex', '0'\);\n\s*li\.setAttribute\('data-goto', String\(n\)\);/, 'only answered steps become buttons, and none once the project is filed');
|
|
412
|
+
assert.match(SCRIPT, /railEl\.addEventListener\('click'/);
|
|
413
|
+
assert.match(SCRIPT, /railEl\.addEventListener\('keydown'/);
|
|
414
|
+
assert.match(SCRIPT, /show\(Number\(li\.getAttribute\('data-goto'\)\), true\)/, 'a deliberate step: focus moves');
|
|
415
|
+
assert.match(COSMOS, /\.steps li\[role=button\]\{cursor:pointer;\}/);
|
|
416
|
+
assert.match(COSMOS, /\.steps li\[role=button\]:focus-visible\{outline:2px solid var\(--accent\);/);
|
|
417
|
+
assert.doesNotMatch(HUB, /role="listbox"/);
|
|
418
|
+
assert.doesNotMatch(SCRIPT, /role="option"/);
|
|
419
|
+
assert.match(HUB, /<div id="ghList" class="ghlist" role="group" aria-label="Your repositories"><\/div>/, 'the label survives the listbox role');
|
|
420
|
+
assert.match(SCRIPT, /class="ghrow" data-full="' \+ esc\(r\.full_name\) \+ '" aria-current="' \+ \(r\.full_name === sel\) \+ '"/, 'the chosen row is aria-current');
|
|
421
|
+
assert.match(HUB, /\.ghrow\[aria-current=true\]\{/, 'and painted through it');
|
|
422
|
+
assert.doesNotMatch(HUB, /aria-selected/, 'aria-selected went with the option role');
|
|
423
|
+
assert.match(SCRIPT, /if \(ev\.key !== 'Escape' \|\| \$\('ghList'\)\.hidden\) return;\n\s*ev\.stopPropagation\(\);\n\s*\$\('ghSearch'\)\.focus\(\);\n\s*closeRepoList\(\);/, 'Escape closes the list after handing focus back');
|
|
424
|
+
assert.match(SCRIPT, /if \(ev\.relatedTarget && !\$\('ghAdopt'\)\.contains\(ev\.relatedTarget\)\) closeRepoList\(\);/, 'focus leaving the picker closes it; a null relatedTarget is left alone');
|
|
425
|
+
assert.match(fn('openCard'), /\$\('cardScrim'\)\.hidden = false;\n\s*pageInert\(true\);\n\s*\$\('cardClose'\)\.focus\(\);/);
|
|
426
|
+
assert.match(fn('closeCard'), /pageInert\(false\);[^\n]*\n\s*if \(cardReturn && cardReturn\.focus\) cardReturn\.focus\(\);/, 'inert is lifted before focus is handed back');
|
|
427
|
+
assert.match(fn('pageInert'), /'a\.skip, header\.shell, footer\.foot, #view-mine, #view-new, #view-manage, #view-map > :not\(#cardScrim\)'/, 'the map is inerted a child at a time — the card is its child; the skip link sits outside the shell and goes too');
|
|
428
|
+
assert.match(SCRIPT, /'popstate', function \(\) \{ closeCard\(\); setView\(viewFromUrl\(\)\); \}/, 'Back closes the card, so the page is never left inert');
|
|
429
|
+
assert.equal((HUB.match(/aria-controls="projectsList"/g) || []).length, 5, 'the five state filters name the list they filter');
|
|
430
|
+
});
|
|
431
|
+
|
|
432
|
+
test('control nits: orb cursor, caret under reduced motion, mode label, home label, held reduce-motion query, target sizes', () => {
|
|
433
|
+
assert.doesNotMatch(COSMOS, /\.orb\{flex:0 0 54px;[^}]*cursor:pointer/, 'the base orb is not a pointer');
|
|
434
|
+
assert.match(COSMOS, /\na\.orb\{cursor:pointer;\}/, 'only the linked orb is');
|
|
435
|
+
assert.doesNotMatch(COSMOS, /\.orb\.ghost\{[^}]*cursor:default/);
|
|
436
|
+
assert.match(HUB, /@media \(prefers-reduced-motion:reduce\)\{\.shell \.caret\{display:none;\}\}/, 'the caret hides instead of freezing solid');
|
|
437
|
+
assert.match(fn('paintModeToggle'), /el\.setAttribute\('aria-label', dark \? 'Switch to light mode' : 'Switch to dark mode'\);/);
|
|
438
|
+
assert.doesNotMatch(fn('paintModeToggle'), /aria-pressed/);
|
|
439
|
+
assert.match(SCRIPT, /\$\('markLeft'\)\.parentNode\.setAttribute\('aria-label', productName \+ ' — home'\);\n\s*if \(REDUCED \|\|/, 'the home link is labelled before the typing loop’s early return');
|
|
440
|
+
assert.match(SCRIPT, /var REDUCED_MQ = window\.matchMedia \? window\.matchMedia\('\(prefers-reduced-motion: reduce\)'\) : null;/);
|
|
441
|
+
assert.match(SCRIPT, /if \(REDUCED\) stopSky\(\); else startSky\(\);/, 'a mid-session flip stops or restarts the sky');
|
|
442
|
+
assert.match(SCRIPT, /REDUCED_MQ\.addEventListener\('change', onReducedChange\)/);
|
|
443
|
+
assert.match(HUB, /\.again\{[^}]*padding:6px 2px;min-height:24px;/);
|
|
444
|
+
assert.match(HUB, /\nsummary\{min-height:24px;padding:3px 0;\}/, 'every fold is a 24px target; the disclosure marker stays');
|
|
445
|
+
});
|
package/tests/redteam_patrol.mjs
CHANGED
|
@@ -65,3 +65,66 @@ test('buildClassReport caps the item list, counts the remainder, and takes max s
|
|
|
65
65
|
assert.match(r.repro_steps_md, /…and 3 more/);
|
|
66
66
|
assert.match(r.repro_steps_md, /Notes:/);
|
|
67
67
|
});
|
|
68
|
+
|
|
69
|
+
// ---------------------------------------------------------------------------
|
|
70
|
+
// Unattended-run behaviour (task 1003209): the patrol now runs on CI, which has
|
|
71
|
+
// no GDS session. These pin the two things that turns on — an exit code that
|
|
72
|
+
// means "a finding was LOST" rather than "a finding exists", and a job-summary
|
|
73
|
+
// surface so a scheduled run is readable without opening the log.
|
|
74
|
+
// ---------------------------------------------------------------------------
|
|
75
|
+
import fs from 'node:fs';
|
|
76
|
+
import os from 'node:os';
|
|
77
|
+
import { spawnSync } from 'node:child_process';
|
|
78
|
+
|
|
79
|
+
test('disposition: red is reserved for findings that could not be recorded', () => {
|
|
80
|
+
// A clean sweep is green even if filing was impossible — nothing was lost.
|
|
81
|
+
assert.deepEqual(p.disposition({ findingClasses: 0, doFile: true, blocker: 'no session' }),
|
|
82
|
+
{ action: 'clean', exitCode: 0 });
|
|
83
|
+
// A dry run prints; it never claims to have filed, so it never fails.
|
|
84
|
+
assert.deepEqual(p.disposition({ findingClasses: 3, doFile: false, blocker: null }),
|
|
85
|
+
{ action: 'dry-run', exitCode: 0 });
|
|
86
|
+
// Findings filed for adjudication = the patrol working as designed (ADR 0162).
|
|
87
|
+
// If this ever became a failure, a weekly red would train everyone to ignore it.
|
|
88
|
+
assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: null }),
|
|
89
|
+
{ action: 'file', exitCode: 0 });
|
|
90
|
+
// The ONE red state: findings exist, filing was asked for, and it cannot happen.
|
|
91
|
+
assert.deepEqual(p.disposition({ findingClasses: 3, doFile: true, blocker: 'no GDS session on this runner' }),
|
|
92
|
+
{ action: 'not-filed', exitCode: 2 });
|
|
93
|
+
assert.deepEqual(p.disposition(), { action: 'clean', exitCode: 0 });
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
test('stepSummary appends markdown for the Actions UI, and is inert off-CI', () => {
|
|
97
|
+
const f = path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-')), 'summary.md');
|
|
98
|
+
const prev = process.env.GITHUB_STEP_SUMMARY;
|
|
99
|
+
try {
|
|
100
|
+
process.env.GITHUB_STEP_SUMMARY = f;
|
|
101
|
+
p.stepSummary('## one');
|
|
102
|
+
p.stepSummary('## two');
|
|
103
|
+
assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
|
|
104
|
+
delete process.env.GITHUB_STEP_SUMMARY;
|
|
105
|
+
p.stepSummary('## three'); // must not throw, must not write anywhere
|
|
106
|
+
assert.equal(fs.readFileSync(f, 'utf8'), '## one\n## two\n');
|
|
107
|
+
} finally {
|
|
108
|
+
if (prev === undefined) delete process.env.GITHUB_STEP_SUMMARY;
|
|
109
|
+
else process.env.GITHUB_STEP_SUMMARY = prev;
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
test('filingBlocker reports a sessionless runner instead of exiting with re-auth guidance', () => {
|
|
114
|
+
// The regression guarded here: cli-lib's apiCall answers 401 by PRINTING
|
|
115
|
+
// "run /builder-reauth" and calling process.exit(2). In a cron log that is
|
|
116
|
+
// indistinguishable from a crash, and it kills the run before the findings are
|
|
117
|
+
// ever printed. A subprocess with an empty HOME is the only honest way to
|
|
118
|
+
// simulate a runner with no session file — the suite's own machine has one.
|
|
119
|
+
const home = fs.mkdtempSync(path.join(os.tmpdir(), 'patrol-home-'));
|
|
120
|
+
const r = spawnSync(process.execPath, ['-e',
|
|
121
|
+
`const p = require(${JSON.stringify(path.join(ROOT, 'scripts', 'gds', 'redteam-patrol.js'))});
|
|
122
|
+
console.log(JSON.stringify({ blocker: p.filingBlocker() }));`,
|
|
123
|
+
], { env: { ...process.env, HOME: home, USERPROFILE: home }, encoding: 'utf8' });
|
|
124
|
+
assert.equal(r.status, 0, `expected a clean return, not an exit: ${r.stderr}`);
|
|
125
|
+
const out = JSON.parse(r.stdout.trim().split('\n').pop());
|
|
126
|
+
assert.equal(typeof out.blocker, 'string');
|
|
127
|
+
assert.match(out.blocker, /no GDS session|session unreadable/);
|
|
128
|
+
assert.doesNotMatch(r.stdout + r.stderr, /builder-reauth/,
|
|
129
|
+
'the sessionless path must not print interactive re-auth guidance into a cron log');
|
|
130
|
+
});
|