@mmnto/totem 1.103.0 → 1.104.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.
@@ -2027,4 +2027,599 @@ function isDirectory(p) {
2027
2027
  return false;
2028
2028
  }
2029
2029
  }
2030
+ /**
2031
+ * The pinned squash-merge body/title posture (row-1). GitHub encodes these as
2032
+ * enums; the ruled values are a BLANK squash body + a PR_TITLE squash title.
2033
+ */
2034
+ const EXPECTED_SQUASH_MESSAGE = 'BLANK';
2035
+ const EXPECTED_SQUASH_TITLE = 'PR_TITLE';
2036
+ /** The symbolic ref-name includes that mark a ruleset as targeting the default branch. */
2037
+ const DEFAULT_BRANCH_INCLUDES = new Set(['~DEFAULT_BRANCH', '~ALL']);
2038
+ /**
2039
+ * The ruleset rule types that gate writes to a branch (the "push/PR-class" set,
2040
+ * row-3). Any active ruleset carrying one of these for the default branch must
2041
+ * itself be un-bypassable — a permissive/bypassable ruleset must not undercut
2042
+ * classic branch protection.
2043
+ */
2044
+ const PUSH_PR_RULE_TYPES = new Set([
2045
+ 'pull_request',
2046
+ 'non_fast_forward',
2047
+ 'deletion',
2048
+ 'creation',
2049
+ 'update',
2050
+ 'required_linear_history',
2051
+ 'required_signatures',
2052
+ 'required_status_checks',
2053
+ 'required_deployments',
2054
+ 'merge_queue',
2055
+ ]);
2056
+ // ── Boundary Zod schemas (untrusted fetched JSON; max-tolerance) ──
2057
+ /** Row-1 repo-settings posture fields. A 200 missing any of these is auth-class (`unknown`). */
2058
+ const RepoMergeSettingsSchema = z.object({
2059
+ allow_squash_merge: z.boolean(),
2060
+ allow_merge_commit: z.boolean(),
2061
+ allow_rebase_merge: z.boolean(),
2062
+ squash_merge_commit_message: z.string(),
2063
+ squash_merge_commit_title: z.string(),
2064
+ });
2065
+ /** One ruleset rule (`{ type, parameters }`) — parameters stay `unknown` until a per-type narrow. */
2066
+ const RulesetRuleSchema = z.object({
2067
+ type: z.string(),
2068
+ parameters: z.unknown().optional(),
2069
+ });
2070
+ /** One ruleset detail object (max-tolerance — every field optional so a slim payload still narrows). */
2071
+ const RulesetSchema = z.object({
2072
+ id: z.union([z.number(), z.string()]).optional(),
2073
+ name: z.string().optional(),
2074
+ enforcement: z.string().optional(),
2075
+ target: z.string().optional(),
2076
+ conditions: z
2077
+ .object({
2078
+ ref_name: z
2079
+ .object({
2080
+ include: z.array(z.string()).optional(),
2081
+ exclude: z.array(z.string()).optional(),
2082
+ })
2083
+ .optional(),
2084
+ })
2085
+ .optional(),
2086
+ bypass_actors: z.array(z.unknown()).optional(),
2087
+ rules: z.array(RulesetRuleSchema).optional(),
2088
+ });
2089
+ /** The rulesets surface payload is an array of ruleset details. */
2090
+ const RulesetsArraySchema = z.array(RulesetSchema);
2091
+ /** Parameters of a `required_status_checks` rule (row-2 union + strict-policy read). */
2092
+ const StatusCheckParamsSchema = z.object({
2093
+ required_status_checks: z.array(z.object({ context: z.string() })).optional(),
2094
+ strict_required_status_checks_policy: z.boolean().optional(),
2095
+ });
2096
+ /** A classic branch-protection `{ enabled }` toggle. */
2097
+ const ProtectionToggleSchema = z.object({ enabled: z.boolean() });
2098
+ /**
2099
+ * Row-3 classic branch-protection posture. The three toggles are ALWAYS present
2100
+ * in a full admin read, so their absence marks an under-privileged 200
2101
+ * (auth-class `unknown`). `required_pull_request_reviews` is legitimately absent
2102
+ * when PR review is not required — that absence is real drift, not auth-class.
2103
+ */
2104
+ const BranchProtectionSchema = z.object({
2105
+ required_pull_request_reviews: z
2106
+ .object({ required_approving_review_count: z.number().optional() })
2107
+ .optional(),
2108
+ enforce_admins: ProtectionToggleSchema,
2109
+ allow_force_pushes: ProtectionToggleSchema,
2110
+ allow_deletions: ProtectionToggleSchema,
2111
+ });
2112
+ /**
2113
+ * The totem-side canonical ruleset declaration (`.totem/rulesets/main.json`,
2114
+ * schema-version 1). The canonical required-checks list + pinned strict policy
2115
+ * come from `required_status_checks`; the surface is compared against THIS, never
2116
+ * against itself (Tenet 20).
2117
+ */
2118
+ const RulesetDeclarationSchema = z.object({
2119
+ 'schema-version': z.number(),
2120
+ 'ruleset-name': z.string().optional(),
2121
+ enforcement: z.string().optional(),
2122
+ bypass_actors: z.array(z.unknown()).optional(),
2123
+ required_status_checks: z
2124
+ .object({
2125
+ strict_required_status_checks_policy: z.boolean().optional(),
2126
+ contexts: z.array(z.string()).optional(),
2127
+ })
2128
+ .optional(),
2129
+ });
2130
+ /**
2131
+ * Sense the three Prop 296 §14 network-read-only posture rows against pre-fetched
2132
+ * snapshots. Returns an ARRAY of per-repo verdict lines (the {@link LockContentLine}
2133
+ * pattern — the CLI's flatMap render + R2 contract-counting already support
2134
+ * multi-line rows). NEVER networks (the fetches ran at the CLI edge), NEVER
2135
+ * throws (every read/parse failure degrades to a verdict), NEVER emits `fail`
2136
+ * (the CLI edge owns `--strict` promotion) and NEVER a drift verdict on an
2137
+ * auth/transport failure (§14 clause 2).
2138
+ *
2139
+ * Applicability: the row's `consumers` scope is applied PER-REPO against each
2140
+ * snapshot's `repoId` (verbatim with {@link detectLockContentContract}). A row
2141
+ * scoped `consumers: [totem]` senses only the roster repos whose id is `totem`;
2142
+ * an empty in-scope set yields one honest-absent `skip`.
2143
+ */
2144
+ export function detectNetworkPostureContract(contract, ctx) {
2145
+ const inScope = ctx.repos.filter((r) => repoInConsumerScope(contract, r.repoId));
2146
+ if (inScope.length === 0) {
2147
+ return [
2148
+ {
2149
+ lineName: `Parity: ${contract.id}`,
2150
+ verdict: {
2151
+ status: 'skip',
2152
+ message: contract.consumers !== undefined
2153
+ ? `cohort permits absence here (no roster repo in consumers [${contract.consumers.join(', ')}])`
2154
+ : 'no roster repo resolved to probe (current-repo slug unresolvable)',
2155
+ },
2156
+ },
2157
+ ];
2158
+ }
2159
+ switch (ctx.row) {
2160
+ case 'repo-merge-posture':
2161
+ return inScope.map((repo) => mergePostureLine(contract, repo));
2162
+ case 'repo-required-checks-posture':
2163
+ return requiredChecksLines(contract, ctx, inScope);
2164
+ case 'repo-branch-protection-posture':
2165
+ return inScope.flatMap((repo) => branchProtectionLines(contract, repo));
2166
+ default:
2167
+ // Defensive: an unrecognized row degrades to a single honest-absent skip
2168
+ // rather than darking the sensor (mirrors the manifestation fail-loud).
2169
+ return [
2170
+ {
2171
+ lineName: `Parity: ${contract.id}`,
2172
+ verdict: { status: 'skip', message: `network-posture row unrecognized by this doctor` },
2173
+ },
2174
+ ];
2175
+ }
2176
+ }
2177
+ /** True when `repoId` is inside the contract's `consumers` scope (undefined = applies to all). */
2178
+ function repoInConsumerScope(contract, repoId) {
2179
+ return contract.consumers === undefined || contract.consumers.includes(repoId);
2180
+ }
2181
+ /** Append ` (detail)` to a message when the snapshot carries render detail. */
2182
+ function detailSuffix(surface) {
2183
+ return surface.detail !== undefined && surface.detail.length > 0 ? ` (${surface.detail})` : '';
2184
+ }
2185
+ /**
2186
+ * Map a non-`ok` (or absent) surface to its cannot-verify verdict, or `undefined`
2187
+ * when the surface is `ok` and the caller should inspect the payload. §14 clause
2188
+ * 2/4: `no-transport` → `skip` (honest-absent), every other failure → `unknown`
2189
+ * (never a drift verdict).
2190
+ */
2191
+ function surfaceCannotVerify(surface, surfaceLabel) {
2192
+ if (surface === undefined) {
2193
+ return { status: 'unknown', message: `${surfaceLabel}: not probed — cannot verify` };
2194
+ }
2195
+ switch (surface.outcome) {
2196
+ case 'ok':
2197
+ return undefined;
2198
+ case 'no-transport':
2199
+ return {
2200
+ status: 'skip',
2201
+ message: `${surfaceLabel}: no transport (gh unavailable / offline) — honest-absent per §14 clause 4${detailSuffix(surface)}`,
2202
+ };
2203
+ case 'auth':
2204
+ return {
2205
+ status: 'unknown',
2206
+ message: `${surfaceLabel}: auth-class — cannot verify (missing / under-privileged token; never posture-false per §14 clause 2)${detailSuffix(surface)}`,
2207
+ };
2208
+ case 'not-found':
2209
+ return {
2210
+ status: 'unknown',
2211
+ message: `${surfaceLabel}: 404 on a governed surface — indistinguishable from under-privilege; cannot verify per §14 clause 2${detailSuffix(surface)}`,
2212
+ };
2213
+ case 'error':
2214
+ return {
2215
+ status: 'unknown',
2216
+ message: `${surfaceLabel}: transient / unreachable — cannot verify${detailSuffix(surface)}`,
2217
+ };
2218
+ default:
2219
+ return {
2220
+ status: 'unknown',
2221
+ message: `${surfaceLabel}: unrecognized outcome — cannot verify`,
2222
+ };
2223
+ }
2224
+ }
2225
+ // ── Row 1: repo-merge-posture ──
2226
+ /**
2227
+ * One repo's merge-posture line: `GET /repos/{owner}/{repo}` must report
2228
+ * squash-only merges + a BLANK squash body + a PR_TITLE squash title. A 200
2229
+ * missing those fields is auth-class (`unknown`); a read mismatch is drift
2230
+ * (`warn`). Silent by choice on `delete_branch_on_merge`.
2231
+ */
2232
+ function mergePostureLine(contract, repo) {
2233
+ const lineName = `Parity: ${contract.id} [${repo.repoSlug}]`;
2234
+ const surface = repo.surfaces.repoSettings;
2235
+ const cannot = surfaceCannotVerify(surface, 'repo settings');
2236
+ if (cannot !== undefined)
2237
+ return { lineName, verdict: cannot };
2238
+ const parsed = RepoMergeSettingsSchema.safeParse(surface?.data);
2239
+ if (!parsed.success) {
2240
+ return {
2241
+ lineName,
2242
+ verdict: {
2243
+ status: 'unknown',
2244
+ message: 'repo settings: 200 without the merge-posture fields — auth-class (under-privileged token), never posture-false (§14 clause 2)',
2245
+ },
2246
+ };
2247
+ }
2248
+ const s = parsed.data;
2249
+ const drift = [];
2250
+ if (s.allow_squash_merge !== true)
2251
+ drift.push('allow_squash_merge≠true');
2252
+ if (s.allow_merge_commit !== false)
2253
+ drift.push('allow_merge_commit≠false');
2254
+ if (s.allow_rebase_merge !== false)
2255
+ drift.push('allow_rebase_merge≠false');
2256
+ if (s.squash_merge_commit_message !== EXPECTED_SQUASH_MESSAGE)
2257
+ drift.push(`squash_merge_commit_message=${s.squash_merge_commit_message}≠${EXPECTED_SQUASH_MESSAGE}`);
2258
+ if (s.squash_merge_commit_title !== EXPECTED_SQUASH_TITLE)
2259
+ drift.push(`squash_merge_commit_title=${s.squash_merge_commit_title}≠${EXPECTED_SQUASH_TITLE}`);
2260
+ if (drift.length > 0) {
2261
+ return {
2262
+ lineName,
2263
+ verdict: {
2264
+ status: 'warn',
2265
+ message: `merge posture drifted: ${drift.join(', ')}`,
2266
+ remediation: 'Restore squash-only merges with a BLANK squash body + PR_TITLE title in the repo Settings → General → Pull Requests.',
2267
+ },
2268
+ };
2269
+ }
2270
+ return {
2271
+ lineName,
2272
+ verdict: { status: 'pass', message: 'squash-only + BLANK squash body + PR_TITLE title' },
2273
+ };
2274
+ }
2275
+ // ── Row 2: repo-required-checks-posture ──
2276
+ /**
2277
+ * The required-checks lines: the active-ruleset UNION of `required_status_checks`
2278
+ * must set-equal the canonical list (BOTH directions), AND every ruleset
2279
+ * contributing a canonical check must itself hold enforcement=active,
2280
+ * target ~DEFAULT_BRANCH, `bypass_actors=[]`, and the pinned
2281
+ * `strict_required_status_checks_policy` — the union must not hide a bypassable
2282
+ * contributing ruleset. Canonical + pin come from `.totem/rulesets/main.json`;
2283
+ * an absent declaration is honest-absent `skip`.
2284
+ */
2285
+ function requiredChecksLines(contract, ctx, inScope) {
2286
+ // ── Canonical declaration (read ONCE; absent → honest-absent skip) ──
2287
+ const readFile = ctx.readFile ?? readFileText;
2288
+ const declPath = ctx.declarationPath;
2289
+ const declRaw = declPath !== undefined ? safeReadFile(readFile, declPath) : undefined;
2290
+ if (declRaw === undefined) {
2291
+ return inScope.map((repo) => ({
2292
+ lineName: `Parity: ${contract.id} [${repo.repoSlug}]`,
2293
+ verdict: {
2294
+ status: 'skip',
2295
+ message: 'canonical ruleset declaration (.totem/rulesets/main.json) not yet committed — honest-absent (interim canonical is prose, never parser input)',
2296
+ },
2297
+ }));
2298
+ }
2299
+ const canonical = parseRulesetDeclaration(declRaw);
2300
+ if (canonical === undefined) {
2301
+ // Malformed / unsupported canonical: cannot prove drift NOR currency (the
2302
+ // Stale-Doctor-Paradox) → unknown, never a fabricated pass/warn.
2303
+ return inScope.map((repo) => ({
2304
+ lineName: `Parity: ${contract.id} [${repo.repoSlug}]`,
2305
+ verdict: {
2306
+ status: 'unknown',
2307
+ message: '.totem/rulesets/main.json is unparseable / missing required_status_checks.contexts — canonical list underivable, cannot verify',
2308
+ },
2309
+ }));
2310
+ }
2311
+ return inScope.map((repo) => requiredChecksLine(contract, repo, canonical));
2312
+ }
2313
+ /** Parse + narrow the declaration; undefined when unparseable / unsupported / context-less. */
2314
+ function parseRulesetDeclaration(raw) {
2315
+ let doc;
2316
+ try {
2317
+ doc = JSON.parse(raw);
2318
+ // totem-context: a malformed canonical declaration is a first-class "canonical underivable" signal (→ unknown), not a throw — the sensor must never crash on a mis-authored totem-side file.
2319
+ }
2320
+ catch {
2321
+ return undefined;
2322
+ }
2323
+ const parsed = RulesetDeclarationSchema.safeParse(doc);
2324
+ if (!parsed.success)
2325
+ return undefined;
2326
+ if (parsed.data['schema-version'] !== 1)
2327
+ return undefined;
2328
+ const contexts = parsed.data.required_status_checks?.contexts;
2329
+ if (contexts === undefined || contexts.length === 0)
2330
+ return undefined;
2331
+ return {
2332
+ contexts: new Set(contexts),
2333
+ // Pinned posture; the row's expected value is `false`, but the PIN is whatever
2334
+ // the canonical declares (derive-not-hardcode, Tenet 20).
2335
+ strictPolicy: parsed.data.required_status_checks?.strict_required_status_checks_policy ?? false,
2336
+ };
2337
+ }
2338
+ /** One repo's required-checks line (union set-compare + per-contributing-ruleset enforcement). */
2339
+ function requiredChecksLine(contract, repo, canonical) {
2340
+ const lineName = `Parity: ${contract.id} [${repo.repoSlug}]`;
2341
+ const surface = repo.surfaces.rulesets;
2342
+ const cannot = surfaceCannotVerify(surface, 'rulesets');
2343
+ if (cannot !== undefined)
2344
+ return { lineName, verdict: cannot };
2345
+ const parsed = RulesetsArraySchema.safeParse(surface?.data);
2346
+ if (!parsed.success) {
2347
+ return {
2348
+ lineName,
2349
+ verdict: {
2350
+ status: 'unknown',
2351
+ message: 'rulesets: 200 with an unparseable body — cannot verify (§14 clause 2)',
2352
+ },
2353
+ };
2354
+ }
2355
+ // Every ruleset targeting the default branch (any enforcement mode) is
2356
+ // considered for the union so a check supplied ONLY by an evaluate-mode ruleset
2357
+ // still appears in the set-compare — the per-contributing-ruleset gate below is
2358
+ // what catches the bypassable/evaluate case with a precise reason, rather than a
2359
+ // blunt "missing" (round finding + greptile P1 on strategy#962).
2360
+ const defaultRulesets = parsed.data.filter(rulesetTargetsDefault);
2361
+ const union = new Set();
2362
+ const contributing = [];
2363
+ for (const ruleset of defaultRulesets) {
2364
+ const contexts = statusCheckContexts(ruleset);
2365
+ if (contexts.length === 0)
2366
+ continue; // a zero-rule / copilot-class ruleset never satisfies presence
2367
+ for (const c of contexts)
2368
+ union.add(c);
2369
+ contributing.push({ ruleset, contexts });
2370
+ }
2371
+ const drift = [];
2372
+ // ── Set-compare BOTH directions ──
2373
+ const missing = [...canonical.contexts].filter((c) => !union.has(c));
2374
+ const extra = [...union].filter((c) => !canonical.contexts.has(c));
2375
+ if (missing.length > 0)
2376
+ drift.push(`missing required check(s): ${missing.join(', ')} (re-opens the gated vector)`);
2377
+ if (extra.length > 0)
2378
+ drift.push(`stale extra required check(s): ${extra.join(', ')} (silent merge-block)`);
2379
+ // ── Per-contributing-ruleset enforcement posture ──
2380
+ const unobserved = [];
2381
+ for (const { ruleset, contexts } of contributing) {
2382
+ const read = enforcementProblems(ruleset, canonical.strictPolicy);
2383
+ const name = ruleset.name ?? String(ruleset.id ?? '(unnamed)');
2384
+ if (read.problems.length > 0) {
2385
+ drift.push(`contributing ruleset '${name}' (supplies ${contexts.join(', ')}) is ${read.problems.join(' / ')}`);
2386
+ }
2387
+ if (read.unobserved.length > 0) {
2388
+ unobserved.push(`contributing ruleset '${name}' omitted ${read.unobserved.join(', ')}`);
2389
+ }
2390
+ }
2391
+ if (drift.length > 0) {
2392
+ return {
2393
+ lineName,
2394
+ verdict: {
2395
+ status: 'warn',
2396
+ message: `required-checks posture drifted: ${drift.join('; ')}`,
2397
+ remediation: 'Align the default-branch ruleset union to the canonical required-checks list and make every contributing ruleset enforcement=active with no bypass actors.',
2398
+ },
2399
+ };
2400
+ }
2401
+ // Observed drift outranks an observability gap (a definite finding is never
2402
+ // hidden behind unknown); with no drift, a field-shy contributing ruleset is
2403
+ // auth-class — never a silent pass (§14 clause 2).
2404
+ if (unobserved.length > 0) {
2405
+ return {
2406
+ lineName,
2407
+ verdict: {
2408
+ status: 'unknown',
2409
+ message: `rulesets detail field-shy — cannot certify enforcement posture: ${unobserved.join('; ')} (auth-class, §14 clause 2)`,
2410
+ },
2411
+ };
2412
+ }
2413
+ return {
2414
+ lineName,
2415
+ verdict: {
2416
+ status: 'pass',
2417
+ message: `active-ruleset union == canonical required checks (${canonical.contexts.size}); every contributing ruleset un-bypassable`,
2418
+ },
2419
+ };
2420
+ }
2421
+ function enforcementProblems(ruleset, pinnedStrict) {
2422
+ const problems = [];
2423
+ const unobserved = [];
2424
+ if (ruleset.enforcement === undefined)
2425
+ unobserved.push('enforcement');
2426
+ else if (ruleset.enforcement !== 'active')
2427
+ problems.push(`enforcement=${ruleset.enforcement}`);
2428
+ if (ruleset.bypass_actors === undefined)
2429
+ unobserved.push('bypass_actors');
2430
+ else if (ruleset.bypass_actors.length > 0)
2431
+ problems.push('bypassable (bypass_actors non-empty)');
2432
+ // Row-3 callers omit the pin: protection rulesets are judged on enforcement +
2433
+ // bypass only — the strict policy is a required-checks (row-2) concern.
2434
+ if (pinnedStrict === undefined)
2435
+ return { problems, unobserved };
2436
+ const strict = strictPolicy(ruleset);
2437
+ if (strict === undefined)
2438
+ unobserved.push('strict_required_status_checks_policy');
2439
+ else if (strict !== pinnedStrict)
2440
+ problems.push(`strict_required_status_checks_policy=${strict}≠${pinnedStrict}`);
2441
+ return { problems, unobserved };
2442
+ }
2443
+ // ── Row 3: repo-branch-protection-posture ──
2444
+ /**
2445
+ * One repo's TWO branch-protection lines — classic branch protection AND the
2446
+ * rulesets surface (reading one senses a subset). Classic: PR-required with a
2447
+ * ruled `required_approving_review_count=0`, `enforce_admins=true`, force pushes
2448
+ * + deletions disallowed. Rulesets: any active ruleset carrying push/PR-class
2449
+ * rules for the default branch must be enforcement=active with `bypass_actors=[]`.
2450
+ */
2451
+ function branchProtectionLines(contract, repo) {
2452
+ return [classicProtectionLine(contract, repo), rulesetProtectionLine(contract, repo)];
2453
+ }
2454
+ /** The classic-branch-protection line (row-3 surface 1). */
2455
+ function classicProtectionLine(contract, repo) {
2456
+ const lineName = `Parity: ${contract.id} [${repo.repoSlug} · classic]`;
2457
+ const surface = repo.surfaces.branchProtection;
2458
+ const cannot = surfaceCannotVerify(surface, 'classic branch protection');
2459
+ if (cannot !== undefined)
2460
+ return { lineName, verdict: cannot };
2461
+ const parsed = BranchProtectionSchema.safeParse(surface?.data);
2462
+ if (!parsed.success) {
2463
+ // The three toggles are always present in a full admin read; their absence
2464
+ // marks an under-privileged 200 → auth-class, never posture-false.
2465
+ return {
2466
+ lineName,
2467
+ verdict: {
2468
+ status: 'unknown',
2469
+ message: 'classic branch protection: 200 without the enforce_admins/force-push/deletion toggles — auth-class (§14 clause 2)',
2470
+ },
2471
+ };
2472
+ }
2473
+ const p = parsed.data;
2474
+ const drift = [];
2475
+ if (p.required_pull_request_reviews === undefined) {
2476
+ drift.push('required_pull_request_reviews absent (PR not required — direct-push vector open)');
2477
+ }
2478
+ else {
2479
+ const count = p.required_pull_request_reviews.required_approving_review_count;
2480
+ if (count === undefined) {
2481
+ // reviews object present but count field shy → auth-class read.
2482
+ return {
2483
+ lineName,
2484
+ verdict: {
2485
+ status: 'unknown',
2486
+ message: 'classic branch protection: reviews object present without required_approving_review_count — auth-class (§14 clause 2)',
2487
+ },
2488
+ };
2489
+ }
2490
+ if (count !== 0)
2491
+ drift.push(`required_approving_review_count=${count}≠0 (ruled posture — nonzero deadlocks the solo-operator merge)`);
2492
+ }
2493
+ if (p.enforce_admins.enabled !== true)
2494
+ drift.push('enforce_admins≠true');
2495
+ if (p.allow_force_pushes.enabled !== false)
2496
+ drift.push('allow_force_pushes≠false');
2497
+ if (p.allow_deletions.enabled !== false)
2498
+ drift.push('allow_deletions≠false');
2499
+ if (drift.length > 0) {
2500
+ return {
2501
+ lineName,
2502
+ verdict: {
2503
+ status: 'warn',
2504
+ message: `classic branch protection drifted: ${drift.join(', ')}`,
2505
+ remediation: 'Restore the default-branch protection: PR required with required_approving_review_count=0, enforce_admins on, force pushes + deletions off.',
2506
+ },
2507
+ };
2508
+ }
2509
+ return {
2510
+ lineName,
2511
+ verdict: {
2512
+ status: 'pass',
2513
+ message: 'PR required (approving-count 0), enforce_admins on, force pushes + deletions off',
2514
+ },
2515
+ };
2516
+ }
2517
+ /** The rulesets-surface line for row-3 (surface 2 — a bypassable protection ruleset must not undercut classic). */
2518
+ function rulesetProtectionLine(contract, repo) {
2519
+ const lineName = `Parity: ${contract.id} [${repo.repoSlug} · rulesets]`;
2520
+ const surface = repo.surfaces.rulesets;
2521
+ const cannot = surfaceCannotVerify(surface, 'rulesets');
2522
+ if (cannot !== undefined)
2523
+ return { lineName, verdict: cannot };
2524
+ const parsed = RulesetsArraySchema.safeParse(surface?.data);
2525
+ if (!parsed.success) {
2526
+ return {
2527
+ lineName,
2528
+ verdict: {
2529
+ status: 'unknown',
2530
+ message: 'rulesets: 200 with an unparseable body — cannot verify (§14 clause 2)',
2531
+ },
2532
+ };
2533
+ }
2534
+ const protectionRulesets = parsed.data
2535
+ .filter(rulesetTargetsDefault)
2536
+ .filter((r) => (r.rules ?? []).some((rule) => PUSH_PR_RULE_TYPES.has(rule.type)));
2537
+ const drift = [];
2538
+ const unobserved = [];
2539
+ for (const ruleset of protectionRulesets) {
2540
+ const name = ruleset.name ?? String(ruleset.id ?? '(unnamed)');
2541
+ const read = enforcementProblems(ruleset);
2542
+ if (read.problems.length > 0)
2543
+ drift.push(`protection ruleset '${name}' is ${read.problems.join(' / ')}`);
2544
+ if (read.unobserved.length > 0)
2545
+ unobserved.push(`protection ruleset '${name}' omitted ${read.unobserved.join(', ')}`);
2546
+ }
2547
+ if (drift.length > 0) {
2548
+ return {
2549
+ lineName,
2550
+ verdict: {
2551
+ status: 'warn',
2552
+ message: `ruleset protection drifted: ${drift.join('; ')}`,
2553
+ remediation: 'Make every default-branch push/PR ruleset enforcement=active with no bypass actors so it cannot undercut classic protection.',
2554
+ },
2555
+ };
2556
+ }
2557
+ // Same precedence as row 2: a field-shy protection ruleset is auth-class —
2558
+ // never certified un-bypassable without observing the field (§14 clause 2).
2559
+ if (unobserved.length > 0) {
2560
+ return {
2561
+ lineName,
2562
+ verdict: {
2563
+ status: 'unknown',
2564
+ message: `rulesets detail field-shy — cannot certify protection posture: ${unobserved.join('; ')} (auth-class, §14 clause 2)`,
2565
+ },
2566
+ };
2567
+ }
2568
+ return {
2569
+ lineName,
2570
+ verdict: {
2571
+ status: 'pass',
2572
+ message: protectionRulesets.length === 0
2573
+ ? 'no default-branch push/PR ruleset present to undercut classic protection'
2574
+ : `${protectionRulesets.length} default-branch push/PR ruleset(s) active + un-bypassable`,
2575
+ },
2576
+ };
2577
+ }
2578
+ // ── Shared ruleset helpers ──
2579
+ /** True when a ruleset's ref-name conditions target the default branch (and don't exclude it). */
2580
+ function rulesetTargetsDefault(ruleset) {
2581
+ const refName = ruleset.conditions?.ref_name;
2582
+ if (refName === undefined)
2583
+ return false;
2584
+ const include = refName.include ?? [];
2585
+ const exclude = refName.exclude ?? [];
2586
+ const included = include.some((r) => DEFAULT_BRANCH_INCLUDES.has(r));
2587
+ const excluded = exclude.some((r) => DEFAULT_BRANCH_INCLUDES.has(r));
2588
+ return included && !excluded;
2589
+ }
2590
+ /** The `required_status_checks` contexts a ruleset supplies (empty when it carries no such rule). */
2591
+ function statusCheckContexts(ruleset) {
2592
+ const contexts = [];
2593
+ for (const rule of ruleset.rules ?? []) {
2594
+ if (rule.type !== 'required_status_checks')
2595
+ continue;
2596
+ const params = StatusCheckParamsSchema.safeParse(rule.parameters);
2597
+ if (!params.success)
2598
+ continue;
2599
+ for (const check of params.data.required_status_checks ?? [])
2600
+ contexts.push(check.context);
2601
+ }
2602
+ return contexts;
2603
+ }
2604
+ /** The `strict_required_status_checks_policy` a ruleset pins, or undefined when it carries no such rule. */
2605
+ function strictPolicy(ruleset) {
2606
+ for (const rule of ruleset.rules ?? []) {
2607
+ if (rule.type !== 'required_status_checks')
2608
+ continue;
2609
+ const params = StatusCheckParamsSchema.safeParse(rule.parameters);
2610
+ if (params.success)
2611
+ return params.data.strict_required_status_checks_policy;
2612
+ }
2613
+ return undefined;
2614
+ }
2615
+ /** Read a file through the injected seam, swallowing a throwing reader to undefined (honest-absent). */
2616
+ function safeReadFile(readFile, absPath) {
2617
+ try {
2618
+ return readFile(absPath);
2619
+ // totem-context: a throwing injected reader is the honest-absent signal (declaration file unreadable → skip); rethrowing would break the never-throws contract for a routine absence.
2620
+ }
2621
+ catch {
2622
+ return undefined;
2623
+ }
2624
+ }
2030
2625
  //# sourceMappingURL=parity-detect.js.map