@event4u/agent-config 14.0.0 → 14.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.claude-plugin/marketplace.json +1 -1
- package/CHANGELOG.md +95 -0
- package/dist/agent-src/templates/agents/agent-project-settings.example.yml +1 -1
- package/dist/cli-delegate/cmd_conformance.js +112 -8
- package/dist/discovery/deprecation-report.md +1 -1
- package/dist/discovery/discovery-manifest.json +1 -1
- package/dist/discovery/discovery-manifest.json.sha256 +1 -1
- package/dist/discovery/discovery-manifest.summary.md +1 -1
- package/dist/discovery/dormancy-report.md +1 -1
- package/dist/discovery/orphan-report.md +1 -1
- package/dist/discovery/packs.json +1 -1
- package/dist/discovery/trust-report.md +1 -1
- package/dist/discovery/workspaces.json +1 -1
- package/dist/hooks/dispatch.js +199 -18
- package/dist/mcp/registry-manifest.json +1 -1
- package/dist/mcp/server.json +2 -2
- package/dist/mcp/server.mjs +114 -9
- package/package.json +1 -1
- package/src/config/gate-violation-baselines.json +3 -3
- package/src/config/hook-token-budget.json +6 -0
- package/src/scripts/_lib/gate_ledger.ts +5 -1
- package/src/scripts/_lib/skill_catalogue_series.ts +91 -0
- package/src/scripts/build_archive_index.ts +62 -3
- package/src/scripts/check_gate_coverage.ts +93 -0
- package/src/scripts/check_pr_ci_current.ts +86 -3
- package/src/scripts/dispatch_r2_reviewer.ts +169 -15
- package/src/scripts/git_authorization_hook.ts +186 -1
- package/src/scripts/hooks/block_unauthorized_git.ts +62 -6
- package/src/scripts/hooks/skill_route_hook.ts +172 -11
- package/src/scripts/install-hooks.sh +26 -0
|
@@ -409,3 +409,94 @@ export function formatPointableBare(
|
|
|
409
409
|
}
|
|
410
410
|
return lines.join('\n');
|
|
411
411
|
}
|
|
412
|
+
|
|
413
|
+
/* ------------------------------------------------------------------ *
|
|
414
|
+
* The delivery filter — what the runtime router must not name.
|
|
415
|
+
* ------------------------------------------------------------------ */
|
|
416
|
+
|
|
417
|
+
/**
|
|
418
|
+
* The bare names one host is KNOWN to have delivered, or `null` when nothing
|
|
419
|
+
* about that host's per-entry delivery was ever enumerated.
|
|
420
|
+
*
|
|
421
|
+
* `joinPointableBare` above answers "how large is the D-4 divergence" for an
|
|
422
|
+
* operator report. This answers the runtime question the same log can settle:
|
|
423
|
+
* given this host, which skill names must a pointer line not name? It is the
|
|
424
|
+
* reducer `skill_route_hook` consumes, and it lives here rather than in that
|
|
425
|
+
* hook for one reason — every guard below is already argued in this module's
|
|
426
|
+
* prose, and a second reducer over the same log is how two readers start
|
|
427
|
+
* breaking the same tie in opposite directions (`_supersedes`, in the parent
|
|
428
|
+
* module, exists because that happened once and printed two drop counts for one
|
|
429
|
+
* host on one day).
|
|
430
|
+
*
|
|
431
|
+
* THE THREE-WAY DISTINCTION IS THE WHOLE POINT, and collapsing any two of them
|
|
432
|
+
* is the failure:
|
|
433
|
+
*
|
|
434
|
+
* - **`null` — nothing was enumerated.** No record for this host, or none of
|
|
435
|
+
* its records is a `per-entry` observation, or the latest one carries no
|
|
436
|
+
* `bare_names` array. The consumer must fail OPEN: filtering on this would
|
|
437
|
+
* be a zero inferred from silence, and a filter that quietly narrows on
|
|
438
|
+
* missing data is worse than the divergence it treats.
|
|
439
|
+
* - **An EMPTY set — enumerated, and nothing was bare.** A legitimate answer,
|
|
440
|
+
* and behaviourally identical to `null` at today's only consumer. It is
|
|
441
|
+
* still returned distinctly, because "measured clean" and "never measured"
|
|
442
|
+
* are different facts and this module refuses to let them look alike.
|
|
443
|
+
* - **A NON-EMPTY set — these names went dark.** The pointer must not name
|
|
444
|
+
* them.
|
|
445
|
+
*
|
|
446
|
+
* A `budget-strip-and-drop` host is therefore always `null` here, never an
|
|
447
|
+
* empty set: it publishes no per-entry list at all, so its empty `bare_names`
|
|
448
|
+
* records that nothing was counted. That is the same skip `joinPointableBare`
|
|
449
|
+
* makes, for the same reason, and it is why a codex install gets no filtering
|
|
450
|
+
* rather than a filter that believes nothing was degraded.
|
|
451
|
+
*
|
|
452
|
+
* LATEST WINS, per host, by `observed_at` — the `latestPointableBarePerHost`
|
|
453
|
+
* tie-break rather than a union across the series. A union would let a
|
|
454
|
+
* superseded observation keep suppressing a skill the host now delivers fine,
|
|
455
|
+
* which is a filter that only ever narrows; the whole series is available to an
|
|
456
|
+
* operator report, and a runtime filter wants the current reading.
|
|
457
|
+
*
|
|
458
|
+
* `bare_names` is guarded with `Array.isArray`, and `observed_at` with a
|
|
459
|
+
* `typeof` check, because `readObservationLog` produces records by an unchecked
|
|
460
|
+
* `JSON.parse … as` over an append-only log with more than one producer — the
|
|
461
|
+
* same guard, for the same recorded reason, that `joinPointableBare` and
|
|
462
|
+
* `knownHostLimits` carry. Both guards resolve to `null`, never to an older
|
|
463
|
+
* reading: a malformed LATEST record means the current state is unknown, and
|
|
464
|
+
* falling back to a superseded one would be the stale-suppression failure this
|
|
465
|
+
* function's own tie-break exists to prevent.
|
|
466
|
+
*/
|
|
467
|
+
export function knownBareNames(
|
|
468
|
+
records: readonly ObservationRecord[],
|
|
469
|
+
host: string,
|
|
470
|
+
): Set<string> | null {
|
|
471
|
+
// The latest record for the host decides, and the mode is read OFF that
|
|
472
|
+
// record rather than used to select it. R2 finding 2: filtering `per-entry`
|
|
473
|
+
// first meant a NEWER `budget-strip-and-drop` record could never supersede
|
|
474
|
+
// an older `per-entry` one, so a host that changed truncation mode kept
|
|
475
|
+
// being filtered against a bare set from before the change — the precise
|
|
476
|
+
// failure the LATEST-WINS paragraph above claims to prevent.
|
|
477
|
+
//
|
|
478
|
+
// `headlineRecordPerHost` rather than a fourth inline tie-break (R2 finding
|
|
479
|
+
// 7): this module already imports it, already defines
|
|
480
|
+
// `latestPointableBarePerHost`, and its own comment cites `_supersedes` as
|
|
481
|
+
// the reason duplicate reducers are dangerous. Restating the rule here would
|
|
482
|
+
// have meant the runtime filter silently not inheriting any precedence the
|
|
483
|
+
// parent adds later.
|
|
484
|
+
//
|
|
485
|
+
// The `observed_at` pre-filter is R2 finding 6, and it is a pre-filter
|
|
486
|
+
// rather than a change to `_supersedes` because that reducer is shared.
|
|
487
|
+
// `_supersedes` compares dates with `!==` then `>`, and BOTH
|
|
488
|
+
// `undefined > "2026-08-12"` and `"2026-08-12" > undefined` are false — so a
|
|
489
|
+
// record with a missing or non-string date, once it becomes the incumbent,
|
|
490
|
+
// can never be displaced by a well-formed later one. One malformed line in
|
|
491
|
+
// an append-only log with two producers would pin the filter to it forever.
|
|
492
|
+
// Feeding the shared reducer only well-dated records avoids that without
|
|
493
|
+
// changing behaviour for its other callers.
|
|
494
|
+
const dated = records.filter(
|
|
495
|
+
(record) => record.host === host && typeof record.observed_at === 'string',
|
|
496
|
+
);
|
|
497
|
+
const latest = headlineRecordPerHost(dated).get(host);
|
|
498
|
+
if (latest === undefined) return null;
|
|
499
|
+
if (truncationModeOf(latest) !== 'per-entry') return null;
|
|
500
|
+
if (!Array.isArray(latest.bare_names)) return null;
|
|
501
|
+
return new Set(latest.bare_names.filter((name): name is string => typeof name === 'string'));
|
|
502
|
+
}
|
|
@@ -45,6 +45,7 @@ import * as path from 'node:path';
|
|
|
45
45
|
import process from 'node:process';
|
|
46
46
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
47
47
|
|
|
48
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
48
49
|
import { DeadScopeError, reportScanned } from './_lib/scan_scope.js';
|
|
49
50
|
import { parse_frontmatter } from './validate_frontmatter.js';
|
|
50
51
|
|
|
@@ -217,12 +218,24 @@ export function archiveFiles(dir: string): string[] {
|
|
|
217
218
|
.sort();
|
|
218
219
|
}
|
|
219
220
|
|
|
220
|
-
|
|
221
|
-
|
|
221
|
+
/**
|
|
222
|
+
* Build entries from an enumeration the CALLER already performed.
|
|
223
|
+
*
|
|
224
|
+
* Split out so the ledger's plan and the results can come from ONE walk. With
|
|
225
|
+
* two walks, a file created between them reports as a skip that never happened
|
|
226
|
+
* and a file deleted between them yields an entry that was never planned — the
|
|
227
|
+
* plan would describe a different directory state than the results.
|
|
228
|
+
*/
|
|
229
|
+
export function buildIndexFrom(dir: string, names: readonly string[]): ArchiveEntry[] {
|
|
230
|
+
return names.map((name) =>
|
|
222
231
|
buildEntry(name.replace(/\.md$/, ''), fs.readFileSync(path.join(dir, name), 'utf8')),
|
|
223
232
|
);
|
|
224
233
|
}
|
|
225
234
|
|
|
235
|
+
export function buildIndex(dir: string): ArchiveEntry[] {
|
|
236
|
+
return buildIndexFrom(dir, archiveFiles(dir));
|
|
237
|
+
}
|
|
238
|
+
|
|
226
239
|
/** Table-cell text: pipes escaped, newlines flattened, never empty. */
|
|
227
240
|
function _cell(value: string | null): string {
|
|
228
241
|
if (value === null) {
|
|
@@ -309,7 +322,44 @@ export function main(argv: readonly string[]): number {
|
|
|
309
322
|
const quiet = argv.includes('--quiet');
|
|
310
323
|
const dir = path.join(ROOT, ARCHIVE_REL);
|
|
311
324
|
|
|
312
|
-
|
|
325
|
+
// ONE walk feeds both the plan and the results.
|
|
326
|
+
//
|
|
327
|
+
// Planning the result set and completing it in the next statement would make
|
|
328
|
+
// `planned === completed, skipped=0` true by construction — an accounting
|
|
329
|
+
// line that can never report anything. Planning a SECOND walk is only
|
|
330
|
+
// marginally better: it describes a directory state the results did not come
|
|
331
|
+
// from. Enumerating once and passing that list into the builder makes the
|
|
332
|
+
// gap real — the natural future fix for an unreadable archive file is a
|
|
333
|
+
// `try/catch { continue }` in the builder, and that file then shows up here
|
|
334
|
+
// as a skip instead of vanishing.
|
|
335
|
+
//
|
|
336
|
+
// `not-extractable` is a real reading rather than a skip and therefore
|
|
337
|
+
// completes — but a frontmatter PARSE failure is swallowed by `_frontmatter`
|
|
338
|
+
// and zeroes title/verdict/status, which is a genuine per-target degrade the
|
|
339
|
+
// disposition tally cannot show. Both are visible here as the difference
|
|
340
|
+
// between the planned and completed sets.
|
|
341
|
+
const walked = archiveFiles(dir);
|
|
342
|
+
const entries = buildIndexFrom(dir, walked);
|
|
343
|
+
const planned = walked.map((f) => path.basename(f, '.md'));
|
|
344
|
+
const produced = new Set(entries.map((e) => e.slug));
|
|
345
|
+
const ledger = new GateLedger('build_archive_index');
|
|
346
|
+
ledger.plan(planned);
|
|
347
|
+
for (const slug of planned) {
|
|
348
|
+
// `check_did_not_run`, not `no_applicable_files`: the file IS one the
|
|
349
|
+
// check applies to — the entry build did not produce a reading for it.
|
|
350
|
+
if (produced.has(slug)) ledger.complete(slug);
|
|
351
|
+
else ledger.skip(slug, 'check_did_not_run');
|
|
352
|
+
}
|
|
353
|
+
// A produced entry outside the plan means the two sets came apart despite
|
|
354
|
+
// the single walk; the ledger would under-state the denominator silently.
|
|
355
|
+
const unplanned = [...produced].filter((s) => !planned.includes(s));
|
|
356
|
+
if (unplanned.length > 0) {
|
|
357
|
+
throw new Error(
|
|
358
|
+
`build_archive_index: ${String(unplanned.length)} entry/entries were produced but never ` +
|
|
359
|
+
`planned (${unplanned.slice(0, 5).join(', ')}) — the ledger denominator is wrong.`,
|
|
360
|
+
);
|
|
361
|
+
}
|
|
362
|
+
|
|
313
363
|
try {
|
|
314
364
|
reportScanned({
|
|
315
365
|
gate: 'build_archive_index',
|
|
@@ -325,6 +375,15 @@ export function main(argv: readonly string[]): number {
|
|
|
325
375
|
throw err;
|
|
326
376
|
}
|
|
327
377
|
|
|
378
|
+
// Reported before the `--check` and write paths branch, so both publish the
|
|
379
|
+
// same accounting. NOT on all three: the earliest exit — `reportScanned`
|
|
380
|
+
// throwing `DeadScopeError` on an empty walk — returns above this line and
|
|
381
|
+
// reports nothing, which is the one exit where the accounting would say the
|
|
382
|
+
// most. Left as-is rather than papered over: the ledger has no targets to
|
|
383
|
+
// report there either, and moving it above the scope assertion would publish
|
|
384
|
+
// a plan for a root the assertion is about to reject.
|
|
385
|
+
ledger.report();
|
|
386
|
+
|
|
328
387
|
const mdPath = path.join(dir, INDEX_MD);
|
|
329
388
|
const jsonPath = path.join(dir, INDEX_JSON);
|
|
330
389
|
const md = renderMarkdown(entries);
|
|
@@ -50,6 +50,7 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
|
50
50
|
|
|
51
51
|
import * as yaml from 'js-yaml';
|
|
52
52
|
import { checkRatchet } from './_lib/gate_baseline.js';
|
|
53
|
+
import { GateLedger, type GateSkipReason } from './_lib/gate_ledger.js';
|
|
53
54
|
import { runCountedProbe } from './_lib/counted_probe.js';
|
|
54
55
|
import { listGateScripts } from './_lib/gate_population.js';
|
|
55
56
|
import { describeOutcome, namesEstateInvalidatingError } from './_lib/gate_result.js';
|
|
@@ -187,6 +188,59 @@ export function parse_scanned(output: string): number | null {
|
|
|
187
188
|
return m === null ? null : Number(m[1]);
|
|
188
189
|
}
|
|
189
190
|
|
|
191
|
+
/**
|
|
192
|
+
* Map a gate verdict onto its ledger outcome.
|
|
193
|
+
*
|
|
194
|
+
* Extracted and exported so the mapping can be PINNED — it was inline, and the
|
|
195
|
+
* completion review's three sharpest findings were all mis-mappings that a
|
|
196
|
+
* discrimination test would have caught in one line each.
|
|
197
|
+
*
|
|
198
|
+
* The split is INSPECTED vs NOT INSPECTED, never pass vs fail. `fail` counts
|
|
199
|
+
* into the ledger's `scanned=` (`report()` sets `checked = completed + failed`),
|
|
200
|
+
* so a target that could not be READ must never land there — that inflates the
|
|
201
|
+
* exact number the library exists to make trustworthy. This file's own comment
|
|
202
|
+
* already says a gate that could not MEASURE "is not a gate that found
|
|
203
|
+
* violations, and it is not a gate that passed either"; this agrees with it.
|
|
204
|
+
*/
|
|
205
|
+
export function ledgerOutcomeFor(
|
|
206
|
+
verdict: GateResult['verdict'],
|
|
207
|
+
): 'complete' | 'fail' | GateSkipReason {
|
|
208
|
+
switch (verdict) {
|
|
209
|
+
case 'pending':
|
|
210
|
+
return 'disabled_by_configuration';
|
|
211
|
+
case 'unavailable':
|
|
212
|
+
// NOT `missing_credentials`. The only manifest row carrying
|
|
213
|
+
// `unavailable_exit` is unavailable for an unbuilt `site/dist`, and the
|
|
214
|
+
// skip sentence IS the audit surface — naming a credential would send the
|
|
215
|
+
// reader hunting an unset token instead of running the build.
|
|
216
|
+
return 'no_applicable_files';
|
|
217
|
+
case 'crashed':
|
|
218
|
+
// NOT `dead_scan_root` — a gate that threw has no dead root, and the skip
|
|
219
|
+
// sentence would send the reader to check a scan path that is fine. The
|
|
220
|
+
// vocabulary was extended rather than approximated, same reasoning as the
|
|
221
|
+
// `unavailable` case above.
|
|
222
|
+
return 'check_did_not_run';
|
|
223
|
+
case 'estate_invalid':
|
|
224
|
+
// Nothing was read here either. `crashed` still reds the build through
|
|
225
|
+
// this gate's own filter and `estate_invalid` deliberately does not, but
|
|
226
|
+
// neither belongs in the inspected count.
|
|
227
|
+
return 'dead_scan_root';
|
|
228
|
+
case 'silent':
|
|
229
|
+
case 'below_floor':
|
|
230
|
+
return 'fail';
|
|
231
|
+
case 'ok':
|
|
232
|
+
return 'complete';
|
|
233
|
+
default: {
|
|
234
|
+
// Exhaustiveness guard. A `Verdict` member added later must be classified
|
|
235
|
+
// deliberately: without this, a new member would silently count as
|
|
236
|
+
// INSPECTED and both the switch and its test would stay green —
|
|
237
|
+
// re-creating the over-report this function was extracted to pin.
|
|
238
|
+
const unhandled: never = verdict;
|
|
239
|
+
throw new Error(`ledgerOutcomeFor: unhandled verdict ${String(unhandled)}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
|
|
190
244
|
/** Classify one gate's run against its declared floor. Pure — testable without
|
|
191
245
|
* spawning anything. */
|
|
192
246
|
export function classify(
|
|
@@ -323,6 +377,31 @@ export function main(argv: readonly string[]): number {
|
|
|
323
377
|
return 0;
|
|
324
378
|
}
|
|
325
379
|
|
|
380
|
+
// Per-target accounting over the manifest rows. The gate that audits gate
|
|
381
|
+
// coverage was itself unaudited.
|
|
382
|
+
//
|
|
383
|
+
// Stated precisely, because the obvious justification overstates it: this gate
|
|
384
|
+
// ALREADY prints counted `pending N · unavailable N` warnings, so those two
|
|
385
|
+
// classes were not invisible. What the ledger adds is that they are expressed
|
|
386
|
+
// in the estate-wide vocabulary every other adopter uses, so a reader
|
|
387
|
+
// comparing gates does not have to learn this one's private status names — and
|
|
388
|
+
// that `crashed` and `estate_invalid`, which had no counted line at all, stop
|
|
389
|
+
// being counted as inspected.
|
|
390
|
+
//
|
|
391
|
+
// Note the two numbers this produces in one run: the ledger's `scanned=` (what
|
|
392
|
+
// was inspected) and the contract line `scanned:` (manifest rows read). They
|
|
393
|
+
// differ by the skips and that is correct, but only the latter is what
|
|
394
|
+
// `SCANNED_RE` parses.
|
|
395
|
+
const ledger = new GateLedger('check_gate_coverage');
|
|
396
|
+
// Deduplicated on purpose. `load_manifest` does not check id uniqueness, and a
|
|
397
|
+
// copy-pasted row with a forgotten rename would otherwise make `plan()` throw
|
|
398
|
+
// an uncaught LedgerUsageError — surfacing a manifest mistake as a stack trace
|
|
399
|
+
// and Node's exit 1, which this file's own contract reads as "a gate is blind,
|
|
400
|
+
// collapsed, or silent" rather than as the exit 2 a malformed manifest earns.
|
|
401
|
+
// The duplicate still runs twice and still prints twice; only the accounting
|
|
402
|
+
// refuses to double-count it.
|
|
403
|
+
ledger.plan([...new Set(specs.map((s) => s.id))]);
|
|
404
|
+
|
|
326
405
|
const results = specs.map((s) => {
|
|
327
406
|
if (spec_is_pending(s)) {
|
|
328
407
|
const { scanned, crashed } = probe_pending(s);
|
|
@@ -332,6 +411,20 @@ export function main(argv: readonly string[]): number {
|
|
|
332
411
|
return classify(s, scanned, crashed, exit_code, output);
|
|
333
412
|
});
|
|
334
413
|
|
|
414
|
+
// `recorded` pairs with the deduplicated plan above: a duplicated manifest id
|
|
415
|
+
// must be accounted once, and recording it twice would raise the same
|
|
416
|
+
// LedgerUsageError the dedupe exists to avoid.
|
|
417
|
+
const recorded = new Set<string>();
|
|
418
|
+
for (const r of results) {
|
|
419
|
+
if (recorded.has(r.id)) continue;
|
|
420
|
+
recorded.add(r.id);
|
|
421
|
+
const outcome = ledgerOutcomeFor(r.verdict);
|
|
422
|
+
if (outcome === 'complete') ledger.complete(r.id);
|
|
423
|
+
else if (outcome === 'fail') ledger.fail(r.id, r.message);
|
|
424
|
+
else ledger.skip(r.id, outcome);
|
|
425
|
+
}
|
|
426
|
+
ledger.report();
|
|
427
|
+
|
|
335
428
|
if (wantJson) {
|
|
336
429
|
process.stdout.write(`${JSON.stringify({ generated_by: 'check_gate_coverage', results }, null, 2)}\n`);
|
|
337
430
|
} else {
|
|
@@ -34,12 +34,29 @@
|
|
|
34
34
|
* Exit codes (contract §6): 0 = current, or unobservable-and-said-so ·
|
|
35
35
|
* 1 = the remote answered and the verdict is not about the branch head ·
|
|
36
36
|
* 2 = internal error. `scanned:` is emitted on EVERY exit path.
|
|
37
|
+
*
|
|
38
|
+
* ## The per-target ledger, and the exemption that was withdrawn
|
|
39
|
+
*
|
|
40
|
+
* This gate first shipped with a `// ledger-exempt:` marker arguing it had "no
|
|
41
|
+
* target population to account for — a short-circuiting chain of 0-3 remote
|
|
42
|
+
* facts". Its completion review refuted that from the file itself: the check
|
|
43
|
+
* rows ARE a population, and the parse below **drops** every row whose `state`
|
|
44
|
+
* is not a string. A dropped row could be the failing one, so the gate could
|
|
45
|
+
* print *"green on its own head"* over a check it never read — the precise
|
|
46
|
+
* "absence of scanning looks like absence of findings" shape the ledger exists
|
|
47
|
+
* to count.
|
|
48
|
+
*
|
|
49
|
+
* The exemption's secondary claim was accurate — every `main()` exit already
|
|
50
|
+
* self-reports through `scanReport` with an explicit `allowEmpty` reason — and
|
|
51
|
+
* that is exactly why the first argument was easy to believe. It was still
|
|
52
|
+
* resting on the false half.
|
|
37
53
|
*/
|
|
38
54
|
|
|
39
55
|
import * as path from 'node:path';
|
|
40
56
|
import { spawnSync } from 'node:child_process';
|
|
41
57
|
import { fileURLToPath, pathToFileURL } from 'node:url';
|
|
42
58
|
|
|
59
|
+
import { GateLedger } from './_lib/gate_ledger.js';
|
|
43
60
|
import { reportScanned } from './_lib/scan_scope.js';
|
|
44
61
|
|
|
45
62
|
const NETWORK_TIMEOUT_MS = 8_000;
|
|
@@ -117,6 +134,12 @@ export interface Facts {
|
|
|
117
134
|
/** The head SHA the reported checks ran against. */
|
|
118
135
|
checksHead: string | null;
|
|
119
136
|
rows: readonly CheckRow[];
|
|
137
|
+
/**
|
|
138
|
+
* Names of check rows the parse DROPPED because their `state` was not a
|
|
139
|
+
* string. Carried out rather than swallowed: a dropped row could be the
|
|
140
|
+
* failing one, so it is a skip the ledger must count, not a non-event.
|
|
141
|
+
*/
|
|
142
|
+
droppedRows?: readonly string[];
|
|
120
143
|
/** Set when something could not be observed; forces the degrade path. */
|
|
121
144
|
unobservable: string | null;
|
|
122
145
|
/**
|
|
@@ -235,6 +258,51 @@ export function decide(f: Facts, opts: DecideOptions = {}): Verdict {
|
|
|
235
258
|
};
|
|
236
259
|
}
|
|
237
260
|
|
|
261
|
+
/**
|
|
262
|
+
* Whether {@link decide} reached the check rows at all.
|
|
263
|
+
*
|
|
264
|
+
* Every branch above the row logic — unobservable, no open PR, behind/diverged,
|
|
265
|
+
* ahead — settles the verdict WITHOUT reading a single row state. Recording
|
|
266
|
+
* those rows as `complete` would publish `scanned=N` for a run that inspected
|
|
267
|
+
* none of them: the absence-of-scanning-reads-as-absence-of-findings inflation
|
|
268
|
+
* the ledger exists to count, in the gate that adopted it. Under `--pre-push`,
|
|
269
|
+
* `ahead` is the NORMAL state, so this is the common path, not the exotic one.
|
|
270
|
+
*/
|
|
271
|
+
export function rowsWereEvaluated(f: Facts): boolean {
|
|
272
|
+
if (f.unobservable !== null) return false;
|
|
273
|
+
if (f.pr === null) return false;
|
|
274
|
+
return f.relation !== 'behind' && f.relation !== 'diverged' && f.relation !== 'ahead';
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
/**
|
|
278
|
+
* Publish the row-level accounting for one run.
|
|
279
|
+
*
|
|
280
|
+
* Three populations, three outcomes: rows the parse dropped are skips (a dropped
|
|
281
|
+
* row could be the failing one), rows a row-reading verdict covered are
|
|
282
|
+
* completions, and rows the verdict never reached are out of scope for THIS run.
|
|
283
|
+
*
|
|
284
|
+
* The line is emitted even under `--quiet` — {@link GateLedger.report} writes
|
|
285
|
+
* unconditionally by design, so a `--quiet` run of this gate is one line louder
|
|
286
|
+
* than before. Stated here because it is a change to this gate's own contract.
|
|
287
|
+
*/
|
|
288
|
+
function reportRowLedger(f: Facts): void {
|
|
289
|
+
const ledger = new GateLedger('check_pr_ci_current');
|
|
290
|
+
const dropped = new Set(f.droppedRows ?? []);
|
|
291
|
+
// Duplicate check names are a normal remote condition — a cancelled re-run
|
|
292
|
+
// beside a live one, two workflows sharing a job name, or a dropped row
|
|
293
|
+
// whose name equals a kept row's. `plan` throws on a repeat, and this gate
|
|
294
|
+
// promises to degrade rather than block, so the plan is deduplicated.
|
|
295
|
+
const planned = [...new Set([...f.rows.map((r) => r.name), ...dropped])];
|
|
296
|
+
ledger.plan(planned);
|
|
297
|
+
const evaluated = rowsWereEvaluated(f);
|
|
298
|
+
for (const name of planned) {
|
|
299
|
+
if (dropped.has(name)) ledger.skip(name, 'not_applicable_kind');
|
|
300
|
+
else if (evaluated) ledger.complete(name);
|
|
301
|
+
else ledger.outOfScope(name, 'precondition_unmet');
|
|
302
|
+
}
|
|
303
|
+
ledger.report();
|
|
304
|
+
}
|
|
305
|
+
|
|
238
306
|
/**
|
|
239
307
|
* Ancestry between the local tip and the PR head.
|
|
240
308
|
*
|
|
@@ -261,7 +329,7 @@ export function relate(
|
|
|
261
329
|
/** Gather the facts `decide` consumes. Every failure becomes `unobservable`. */
|
|
262
330
|
export function gather(repo: string): Facts {
|
|
263
331
|
const empty: Facts = {
|
|
264
|
-
pr: null, prHead: null, localHead: null, checksHead: null, rows: [], unobservable: null, relation: 'unknown',
|
|
332
|
+
pr: null, prHead: null, localHead: null, checksHead: null, rows: [], droppedRows: [], unobservable: null, relation: 'unknown',
|
|
265
333
|
};
|
|
266
334
|
|
|
267
335
|
const branch = run('git', ['rev-parse', '--abbrev-ref', 'HEAD'], repo);
|
|
@@ -296,9 +364,19 @@ export function gather(repo: string): Facts {
|
|
|
296
364
|
// with parseable stdout is DATA, not an error. Treating it as unobservable
|
|
297
365
|
// would make the gate blind in exactly the red case it exists for.
|
|
298
366
|
let rows: CheckRow[] = [];
|
|
367
|
+
// A row whose `state` is not a string is DROPPED, and that drop is the one
|
|
368
|
+
// thing in this gate worth counting: a dropped row could be the failing one,
|
|
369
|
+
// and without accounting the gate prints "green on its own head" over a
|
|
370
|
+
// check it never read. `dropped` is surfaced by the caller through the
|
|
371
|
+
// ledger rather than swallowed here.
|
|
372
|
+
let dropped: string[] = [];
|
|
299
373
|
if (checks.stdout.trim() !== '') {
|
|
300
374
|
try {
|
|
301
|
-
|
|
375
|
+
const parsed = JSON.parse(checks.stdout) as CheckRow[];
|
|
376
|
+
rows = parsed.filter((r) => typeof r.state === 'string');
|
|
377
|
+
dropped = parsed
|
|
378
|
+
.filter((r) => typeof r.state !== 'string')
|
|
379
|
+
.map((r, i) => (typeof r.name === 'string' ? r.name : `row-${String(i)}`));
|
|
302
380
|
} catch {
|
|
303
381
|
rows = [];
|
|
304
382
|
}
|
|
@@ -329,6 +407,7 @@ export function gather(repo: string): Facts {
|
|
|
329
407
|
localHead: localSha,
|
|
330
408
|
checksHead: runsHead.ok && runsHead.stdout.trim() !== '' ? runsHead.stdout.trim() : null,
|
|
331
409
|
rows,
|
|
410
|
+
droppedRows: dropped,
|
|
332
411
|
unobservable: null,
|
|
333
412
|
relation: relate(repo, localSha, prSha),
|
|
334
413
|
};
|
|
@@ -374,7 +453,11 @@ export function main(argv?: readonly string[]): number {
|
|
|
374
453
|
|
|
375
454
|
let verdict: Verdict;
|
|
376
455
|
try {
|
|
377
|
-
|
|
456
|
+
const facts = gather(repo);
|
|
457
|
+
// The verdict comes FIRST: whether the rows were inspected at all is a
|
|
458
|
+
// property of the verdict's path, not of the parse (see reportRowLedger).
|
|
459
|
+
verdict = decide(facts, { prePush });
|
|
460
|
+
reportRowLedger(facts);
|
|
378
461
|
} catch (exc) {
|
|
379
462
|
scanReport(0, 'internal error — nothing was compared');
|
|
380
463
|
process.stderr.write(
|