@biffo/cli 0.265.2 → 0.265.3

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.
Files changed (2) hide show
  1. package/dist/index.js +1057 -54
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -9853,8 +9853,342 @@ async function runBranchProtectionCheck(explicitRepo, options = {}) {
9853
9853
  console.log(`\u2713 branch-protection guard: ${owner}/${repo} (${audited.join(", ")}) OK`);
9854
9854
  }
9855
9855
 
9856
- // src/scripts/check-core-ownership.ts
9856
+ // src/scripts/check-core-direct-paths.ts
9857
+ import { join as join37 } from "path";
9857
9858
  import { execa as execa7 } from "execa";
9859
+
9860
+ // src/lib/core-direct-paths-audit.ts
9861
+ import { readFileSync as readFileSync27, readdirSync as readdirSync14, statSync as statSync7 } from "fs";
9862
+ import { join as join36 } from "path";
9863
+ var EXTERNAL_BASE_IDENTIFIERS = ["CORE_API_URL"];
9864
+ var API_ROUTE_PREFIX = "/api/v1";
9865
+ var TEST_FILE_SUFFIXES = [".test.ts", ".test.tsx", ".spec.ts", ".spec.tsx"];
9866
+ var LEADING_INTERPOLATION = /^\$\{\s*([A-Za-z_$][\w$]*)\s*\}/;
9867
+ var COMMENT = /\/\*[\s\S]*?\*\/|(?:^|(?<=\s))\/\/[^\n]*/gm;
9868
+ function stripComments(text) {
9869
+ return text.replace(COMMENT, (m) => "\n".repeat((m.match(/\n/g) ?? []).length));
9870
+ }
9871
+ function* templateLiterals(text) {
9872
+ let i = 0;
9873
+ const n = text.length;
9874
+ while (i < n) {
9875
+ if (text[i] !== "`") {
9876
+ i += 1;
9877
+ continue;
9878
+ }
9879
+ const start = i + 1;
9880
+ let j = start;
9881
+ let depth = 0;
9882
+ let terminated = false;
9883
+ while (j < n) {
9884
+ const ch = text[j];
9885
+ if (ch === "\\") {
9886
+ j += 2;
9887
+ continue;
9888
+ }
9889
+ if (depth === 0 && ch === "`") {
9890
+ yield { content: text.slice(start, j), start };
9891
+ terminated = true;
9892
+ break;
9893
+ }
9894
+ if (ch === "$" && j + 1 < n && text[j + 1] === "{") {
9895
+ depth += 1;
9896
+ j += 2;
9897
+ continue;
9898
+ }
9899
+ if (depth > 0) {
9900
+ if (ch === "{") depth += 1;
9901
+ else if (ch === "}") depth -= 1;
9902
+ }
9903
+ j += 1;
9904
+ }
9905
+ if (!terminated) return;
9906
+ i = j + 1;
9907
+ }
9908
+ }
9909
+ function lineNumber(text, index) {
9910
+ let count = 1;
9911
+ for (let i = 0; i < index && i < text.length; i++) {
9912
+ if (text[i] === "\n") count += 1;
9913
+ }
9914
+ return count;
9915
+ }
9916
+ function touchesConcatenation(text, start, end) {
9917
+ const window = 40;
9918
+ const after = text.slice(end, end + window).replace(/^\s+/, "");
9919
+ const before = text.slice(Math.max(0, start - window), start).replace(/\s+$/, "");
9920
+ return after.startsWith("+") || before.endsWith("+");
9921
+ }
9922
+ function resolveInterpolations(raw) {
9923
+ let out = "";
9924
+ let i = 0;
9925
+ const n = raw.length;
9926
+ while (i < n) {
9927
+ if (raw[i] === "$" && i + 1 < n && raw[i + 1] === "{") {
9928
+ let depth = 1;
9929
+ let j = i + 2;
9930
+ while (j < n && depth > 0) {
9931
+ if (raw[j] === "{") depth += 1;
9932
+ else if (raw[j] === "}") depth -= 1;
9933
+ j += 1;
9934
+ }
9935
+ if (depth > 0) {
9936
+ out += raw.slice(i);
9937
+ break;
9938
+ }
9939
+ out += "{param}";
9940
+ i = j;
9941
+ continue;
9942
+ }
9943
+ out += raw[i];
9944
+ i += 1;
9945
+ }
9946
+ return out;
9947
+ }
9948
+ function isNestedTemplateLiteralArtifact(raw) {
9949
+ const resolved = resolveInterpolations(raw);
9950
+ if (resolved.includes("${")) return true;
9951
+ return resolved.includes("\n");
9952
+ }
9953
+ function stripQueryString(raw) {
9954
+ const idx = raw.indexOf("?");
9955
+ return idx === -1 ? raw : raw.slice(0, idx);
9956
+ }
9957
+ function externalBaseMatch(raw, externalBases) {
9958
+ const match = LEADING_INTERPOLATION.exec(raw);
9959
+ LEADING_INTERPOLATION.lastIndex = 0;
9960
+ if (!match) return null;
9961
+ const name = match[1];
9962
+ if (!externalBases.includes(name)) return null;
9963
+ return { name, rest: raw.slice(match[0].length) };
9964
+ }
9965
+ function extractCoreDirectPaths(rawText, file, externalBases = EXTERNAL_BASE_IDENTIFIERS) {
9966
+ const text = stripComments(rawText);
9967
+ const found = [];
9968
+ for (const { content, start } of templateLiterals(text)) {
9969
+ const raw = content;
9970
+ const baseMatch = externalBaseMatch(raw, externalBases);
9971
+ if (baseMatch === null) continue;
9972
+ const { name: base, rest } = baseMatch;
9973
+ const end = start + content.length;
9974
+ if (touchesConcatenation(text, start - 1, end + 1)) {
9975
+ found.push({
9976
+ file,
9977
+ line: lineNumber(text, start),
9978
+ raw,
9979
+ normalized: null,
9980
+ unresolvedReason: "built with string concatenation (+) \u2014 the extracted literal is a fragment, not the whole path",
9981
+ externalBase: base
9982
+ });
9983
+ continue;
9984
+ }
9985
+ if (isNestedTemplateLiteralArtifact(rest)) {
9986
+ found.push({
9987
+ file,
9988
+ line: lineNumber(text, start),
9989
+ raw,
9990
+ normalized: null,
9991
+ unresolvedReason: "contains a template literal nested inside its own interpolation \u2014 this extractor's scanner would otherwise truncate at the inner backtick, producing a corrupted path rather than a real mismatch",
9992
+ externalBase: base
9993
+ });
9994
+ continue;
9995
+ }
9996
+ const normalized = stripQueryString(resolveInterpolations(rest));
9997
+ found.push({
9998
+ file,
9999
+ line: lineNumber(text, start),
10000
+ raw,
10001
+ normalized,
10002
+ unresolvedReason: null,
10003
+ externalBase: base
10004
+ });
10005
+ }
10006
+ return found;
10007
+ }
10008
+ function countRawExternalOccurrences(rawText, externalBases = EXTERNAL_BASE_IDENTIFIERS) {
10009
+ let total = 0;
10010
+ for (const base of externalBases) {
10011
+ const needle = `\${${base}}`;
10012
+ total += rawText.split(needle).length - 1;
10013
+ }
10014
+ return total;
10015
+ }
10016
+ function walkFiles(root, accept, skipDir) {
10017
+ const out = [];
10018
+ const walk = (dir) => {
10019
+ let entries;
10020
+ try {
10021
+ entries = readdirSync14(dir);
10022
+ } catch {
10023
+ return;
10024
+ }
10025
+ for (const entry of entries) {
10026
+ const p = join36(dir, entry);
10027
+ let st;
10028
+ try {
10029
+ st = statSync7(p);
10030
+ } catch {
10031
+ continue;
10032
+ }
10033
+ if (st.isDirectory()) {
10034
+ if (skipDir(entry)) continue;
10035
+ walk(p);
10036
+ continue;
10037
+ }
10038
+ if (accept(entry)) out.push(p);
10039
+ }
10040
+ };
10041
+ walk(root);
10042
+ return out.sort();
10043
+ }
10044
+ function frontendSourceFiles(frontendSrcDir) {
10045
+ return walkFiles(
10046
+ frontendSrcDir,
10047
+ (entry) => /\.(ts|tsx)$/.test(entry) && !TEST_FILE_SUFFIXES.some((s) => entry.endsWith(s)),
10048
+ (entry) => entry === "node_modules" || entry === ".next"
10049
+ );
10050
+ }
10051
+ function auditFrontendExtraction(frontendSrcDir, externalBases = EXTERNAL_BASE_IDENTIFIERS) {
10052
+ const files = frontendSourceFiles(frontendSrcDir);
10053
+ const extracted = [];
10054
+ let rawTotal = 0;
10055
+ for (const file of files) {
10056
+ const text = readFileSync27(file, "utf8");
10057
+ rawTotal += countRawExternalOccurrences(text, externalBases);
10058
+ extracted.push(...extractCoreDirectPaths(text, file, externalBases));
10059
+ }
10060
+ return { files, extracted, rawTotal };
10061
+ }
10062
+ function balancedParenSpan(text, openIndex) {
10063
+ let depth = 0;
10064
+ for (let i = openIndex; i < text.length; i++) {
10065
+ if (text[i] === "(") depth += 1;
10066
+ else if (text[i] === ")") {
10067
+ depth -= 1;
10068
+ if (depth === 0) return i;
10069
+ }
10070
+ }
10071
+ return null;
10072
+ }
10073
+ function extractCoreRoutePrefixes(pyText) {
10074
+ const prefixes = [];
10075
+ let rawApiRouterCount = 0;
10076
+ const callSite = /APIRouter\s*\(/g;
10077
+ let m;
10078
+ while ((m = callSite.exec(pyText)) !== null) {
10079
+ rawApiRouterCount += 1;
10080
+ const openParenIndex = m.index + m[0].length - 1;
10081
+ const closeIndex = balancedParenSpan(pyText, openParenIndex);
10082
+ if (closeIndex === null) continue;
10083
+ const inner = pyText.slice(openParenIndex + 1, closeIndex);
10084
+ const prefixMatch = /prefix\s*=\s*["']([^"']*)["']/.exec(inner);
10085
+ if (prefixMatch) prefixes.push(prefixMatch[1]);
10086
+ }
10087
+ return { prefixes, rawApiRouterCount };
10088
+ }
10089
+ function normalizePrefix(prefix) {
10090
+ if (prefix === "") return "";
10091
+ return prefix.startsWith("/") ? prefix : `/${prefix}`;
10092
+ }
10093
+ function coreSourceFiles(apiSrcDir) {
10094
+ return walkFiles(
10095
+ apiSrcDir,
10096
+ (entry) => entry.endsWith(".py") && !entry.startsWith("test_") && !entry.endsWith("_test.py"),
10097
+ (entry) => entry === "tests" || entry === "__pycache__"
10098
+ );
10099
+ }
10100
+ function auditCoreRouteExtraction(apiSrcDir) {
10101
+ const files = coreSourceFiles(apiSrcDir);
10102
+ const prefixSet = /* @__PURE__ */ new Set();
10103
+ let rawApiRouterCount = 0;
10104
+ for (const file of files) {
10105
+ const text = readFileSync27(file, "utf8");
10106
+ const extraction = extractCoreRoutePrefixes(text);
10107
+ rawApiRouterCount += extraction.rawApiRouterCount;
10108
+ for (const p of extraction.prefixes) prefixSet.add(normalizePrefix(p));
10109
+ }
10110
+ return { files, prefixes: [...prefixSet].sort(), rawApiRouterCount };
10111
+ }
10112
+ function pathMatchesAnyCorePrefix(normalized, corePrefixes, apiRoutePrefix = API_ROUTE_PREFIX) {
10113
+ for (const prefix of corePrefixes) {
10114
+ const full = `${apiRoutePrefix}${prefix}`;
10115
+ if (normalized === full || normalized.startsWith(`${full}/`)) return true;
10116
+ }
10117
+ return false;
10118
+ }
10119
+ function auditSiblingCoreDirectPaths(params) {
10120
+ const externalBases = params.externalBases ?? EXTERNAL_BASE_IDENTIFIERS;
10121
+ const frontend = auditFrontendExtraction(params.frontendSrcDir, externalBases);
10122
+ const core = auditCoreRouteExtraction(params.coreApiSrcDir);
10123
+ const frontendBlind = frontend.rawTotal > 0 && frontend.extracted.length === 0;
10124
+ const coreBlind = core.rawApiRouterCount > 0 && core.prefixes.length === 0;
10125
+ const resolved = frontend.extracted.filter((p) => p.normalized !== null);
10126
+ const unresolved = frontend.extracted.filter((p) => p.normalized === null);
10127
+ const unmatched = resolved.filter(
10128
+ (p) => !pathMatchesAnyCorePrefix(p.normalized, core.prefixes)
10129
+ );
10130
+ const matchedCount = resolved.length - unmatched.length;
10131
+ const ok = !frontendBlind && !coreBlind && unmatched.length === 0 && unresolved.length === 0;
10132
+ const summary = `${params.sibling}: ${frontend.extracted.length} core-direct call site(s) found under ${params.frontendSrcDir} (${frontend.files.length} file(s) scanned); ${matchedCount} matched a route prefix core registers (${core.prefixes.length} prefix(es) from ${core.files.length} file(s) under ${params.coreApiSrcDir}), ${unmatched.length} did not, ${unresolved.length} could not be resolved at all.`;
10133
+ return {
10134
+ sibling: params.sibling,
10135
+ frontendSrcDir: params.frontendSrcDir,
10136
+ coreApiSrcDir: params.coreApiSrcDir,
10137
+ frontendFiles: frontend.files.length,
10138
+ coreFiles: core.files.length,
10139
+ extractedCount: frontend.extracted.length,
10140
+ matchedCount,
10141
+ unmatched,
10142
+ unresolved,
10143
+ corePrefixCount: core.prefixes.length,
10144
+ frontendBlind,
10145
+ coreBlind,
10146
+ ok,
10147
+ summary
10148
+ };
10149
+ }
10150
+
10151
+ // src/scripts/check-core-direct-paths.ts
10152
+ async function runCoreDirectPathsCheck(opts = {}) {
10153
+ const root = (await execa7("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10154
+ const sibling = opts.sibling ?? "sibling-template (self-check)";
10155
+ const frontendSrcDir = opts.frontendSrc ?? join37(root, "_skeletons", "sibling-template", "apps", "frontend", "src");
10156
+ const coreApiSrcDir = opts.coreSrc ?? join37(root, "services", "api", "src");
10157
+ const report = auditSiblingCoreDirectPaths({ sibling, frontendSrcDir, coreApiSrcDir });
10158
+ console.log(
10159
+ `audited ${report.extractedCount} core-direct call site(s) across ${report.frontendFiles} frontend file(s) under ${frontendSrcDir}, against ${report.corePrefixCount} route prefix(es) from ${report.coreFiles} core file(s) under ${coreApiSrcDir}`
10160
+ );
10161
+ if (!report.ok) {
10162
+ console.error(`\u2717 core-direct-paths guard (${sibling}): unmatched or unresolved call site(s)
10163
+ `);
10164
+ if (report.frontendBlind) {
10165
+ console.error(
10166
+ " BLIND (frontend): raw source contains external-base interpolations but the extractor found none \u2014 the extractor broke, this is not evidence of a clean tree."
10167
+ );
10168
+ }
10169
+ if (report.coreBlind) {
10170
+ console.error(
10171
+ " BLIND (core): raw source contains APIRouter(...) call sites but no prefixes were extracted \u2014 the extractor broke, this is not evidence core registers nothing."
10172
+ );
10173
+ }
10174
+ for (const p of report.unmatched) {
10175
+ console.error(
10176
+ ` UNMATCHED ${p.file}:${p.line} ${JSON.stringify(p.raw)} -> normalised ${JSON.stringify(p.normalized)}, no core route prefix matches`
10177
+ );
10178
+ }
10179
+ for (const p of report.unresolved) {
10180
+ console.error(
10181
+ ` UNRESOLVED ${p.file}:${p.line} ${JSON.stringify(p.raw)} -> ${p.unresolvedReason}`
10182
+ );
10183
+ }
10184
+ console.error("\nSee biffo-template#1377.");
10185
+ process.exit(1);
10186
+ }
10187
+ console.log(`\u2713 core-direct-paths guard: ${report.summary}`);
10188
+ }
10189
+
10190
+ // src/scripts/check-core-ownership.ts
10191
+ import { execa as execa8 } from "execa";
9858
10192
  var BOLD = "\x1B[1m";
9859
10193
  var DIM = "\x1B[2m";
9860
10194
  var RED = "\x1B[31m";
@@ -9865,7 +10199,7 @@ async function runOwnershipCheck(argv) {
9865
10199
  const stagedFlag = args.indexOf("--staged");
9866
10200
  const staged = stagedFlag !== -1;
9867
10201
  const messageFile = staged ? args[stagedFlag + 1] : void 0;
9868
- const root = (await execa7("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10202
+ const root = (await execa8("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
9869
10203
  const ownership = classifyRepoOwnership(root);
9870
10204
  if (ownership === "template") {
9871
10205
  console.log("\u2713 core ownership guard: skipped \u2014 this is the template, which owns these paths.");
@@ -9881,11 +10215,11 @@ async function runOwnershipCheck(argv) {
9881
10215
  let deletedFiles = [];
9882
10216
  let commitMessage = "";
9883
10217
  if (staged) {
9884
- const { stdout } = await execa7("git", ["diff", "--cached", "--name-status"], { cwd: root });
10218
+ const { stdout } = await execa8("git", ["diff", "--cached", "--name-status"], { cwd: root });
9885
10219
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
9886
10220
  if (messageFile) {
9887
- const { readFileSync: readFileSync29, existsSync: existsSync41 } = await import("fs");
9888
- if (existsSync41(messageFile)) commitMessage = readFileSync29(messageFile, "utf8");
10221
+ const { readFileSync: readFileSync32, existsSync: existsSync42 } = await import("fs");
10222
+ if (existsSync42(messageFile)) commitMessage = readFileSync32(messageFile, "utf8");
9889
10223
  }
9890
10224
  } else {
9891
10225
  const base = process.env["GITHUB_BASE_REF"] ?? args[0];
@@ -9893,18 +10227,18 @@ async function runOwnershipCheck(argv) {
9893
10227
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
9894
10228
  process.exit(2);
9895
10229
  }
9896
- await execa7("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
9897
- const { stdout } = await execa7("git", ["diff", "--name-status", `origin/${base}...HEAD`], {
10230
+ await execa8("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
10231
+ const { stdout } = await execa8("git", ["diff", "--name-status", `origin/${base}...HEAD`], {
9898
10232
  cwd: root
9899
10233
  });
9900
10234
  ({ changed: changedFiles, deleted: deletedFiles } = parseNameStatus(stdout));
9901
- const { stdout: log2 } = await execa7("git", ["log", "--format=%B", `origin/${base}..HEAD`], {
10235
+ const { stdout: log2 } = await execa8("git", ["log", "--format=%B", `origin/${base}..HEAD`], {
9902
10236
  cwd: root,
9903
10237
  reject: false
9904
10238
  });
9905
10239
  commitMessage = log2;
9906
10240
  }
9907
- const { stdout: gitBranch } = await execa7("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
10241
+ const { stdout: gitBranch } = await execa8("git", ["rev-parse", "--abbrev-ref", "HEAD"], {
9908
10242
  cwd: root,
9909
10243
  reject: false
9910
10244
  });
@@ -9985,35 +10319,249 @@ ${BOLD}If the divergence is deliberate${OFF}
9985
10319
  process.exit(1);
9986
10320
  }
9987
10321
 
10322
+ // src/scripts/check-eventbridge-log-permissions.ts
10323
+ import { execa as execa9 } from "execa";
10324
+
10325
+ // src/lib/eventbridge-log-permission-guard.ts
10326
+ import { readFileSync as readFileSync28, readdirSync as readdirSync15, statSync as statSync8 } from "fs";
10327
+ import { join as join38 } from "path";
10328
+ var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".terraform", ".worktrees", "dist"]);
10329
+ var EVENT_TARGET_TYPE = "aws_cloudwatch_event_target";
10330
+ var LOG_RESOURCE_POLICY_TYPE = "aws_cloudwatch_log_resource_policy";
10331
+ var LOG_GROUP_ARN_REF = /aws_cloudwatch_log_group\.([\w-]+)(?:\[[^\]]*\])?\.arn/g;
10332
+ var EVENTBRIDGE_PRINCIPAL = "events.amazonaws.com";
10333
+ var SERVICE_PRINCIPAL_ASSIGNMENT = /Service\s*=\s*(\[[^\]]*\]|"[^"]*")/g;
10334
+ function servicePrincipalsIn(body) {
10335
+ const values = [];
10336
+ SERVICE_PRINCIPAL_ASSIGNMENT.lastIndex = 0;
10337
+ let m;
10338
+ while ((m = SERVICE_PRINCIPAL_ASSIGNMENT.exec(body)) !== null) {
10339
+ const raw = m[1];
10340
+ for (const sm of raw.matchAll(/"([^"]*)"/g)) {
10341
+ values.push(sm[1]);
10342
+ }
10343
+ }
10344
+ return values;
10345
+ }
10346
+ function grantsPrincipal(body, principal) {
10347
+ return servicePrincipalsIn(body).includes(principal);
10348
+ }
10349
+ function lineNumber2(text, index) {
10350
+ let count = 1;
10351
+ for (let i = 0; i < index && i < text.length; i++) {
10352
+ if (text[i] === "\n") count += 1;
10353
+ }
10354
+ return count;
10355
+ }
10356
+ function balancedBraceSpan(text, openIndex) {
10357
+ let depth = 0;
10358
+ for (let i = openIndex; i < text.length; i++) {
10359
+ if (text[i] === "{") depth += 1;
10360
+ else if (text[i] === "}") {
10361
+ depth -= 1;
10362
+ if (depth === 0) return i;
10363
+ }
10364
+ }
10365
+ return null;
10366
+ }
10367
+ function findResourceBlocks(text, file, resourceType) {
10368
+ const blocks = [];
10369
+ const pattern = new RegExp(`resource\\s+"${resourceType}"\\s+"([\\w-]+)"\\s*\\{`, "g");
10370
+ let m;
10371
+ while ((m = pattern.exec(text)) !== null) {
10372
+ const name = m[1];
10373
+ const line = lineNumber2(text, m.index);
10374
+ const openIndex = m.index + m[0].length - 1;
10375
+ const closeIndex = balancedBraceSpan(text, openIndex);
10376
+ if (closeIndex === null) {
10377
+ blocks.push({ file, line, type: resourceType, name, body: null });
10378
+ continue;
10379
+ }
10380
+ blocks.push({
10381
+ file,
10382
+ line,
10383
+ type: resourceType,
10384
+ name,
10385
+ body: text.slice(openIndex + 1, closeIndex)
10386
+ });
10387
+ }
10388
+ return blocks;
10389
+ }
10390
+ function countRawResourceDeclarations(text, resourceType) {
10391
+ const needle = `resource "${resourceType}"`;
10392
+ return text.split(needle).length - 1;
10393
+ }
10394
+ function walkTerraformFiles(root) {
10395
+ const out = [];
10396
+ const walk = (dir) => {
10397
+ let entries;
10398
+ try {
10399
+ entries = readdirSync15(dir);
10400
+ } catch {
10401
+ return;
10402
+ }
10403
+ for (const entry of entries) {
10404
+ const p = join38(dir, entry);
10405
+ let st;
10406
+ try {
10407
+ st = statSync8(p);
10408
+ } catch {
10409
+ continue;
10410
+ }
10411
+ if (st.isDirectory()) {
10412
+ if (SKIP_DIRS.has(entry)) continue;
10413
+ walk(p);
10414
+ continue;
10415
+ }
10416
+ if (entry.endsWith(".tf")) out.push(p);
10417
+ }
10418
+ };
10419
+ walk(root);
10420
+ return out.sort();
10421
+ }
10422
+ function resolveEventTargetLogGroup(body) {
10423
+ const m = /arn\s*=\s*aws_cloudwatch_log_group\.([\w-]+)(?:\[[^\]]*\])?\.arn/.exec(body);
10424
+ return m ? m[1] : null;
10425
+ }
10426
+ function logGroupNamesReferencedIn(body) {
10427
+ const names = /* @__PURE__ */ new Set();
10428
+ LOG_GROUP_ARN_REF.lastIndex = 0;
10429
+ let m;
10430
+ while ((m = LOG_GROUP_ARN_REF.exec(body)) !== null) {
10431
+ names.add(m[1]);
10432
+ }
10433
+ return names;
10434
+ }
10435
+ function auditEventBridgeLogPermissions(root) {
10436
+ const files = walkTerraformFiles(root);
10437
+ if (files.length === 0) {
10438
+ throw new Error(
10439
+ `auditEventBridgeLogPermissions: no .tf files found under ${root} \u2014 the guard cannot verify anything against input it cannot see. This is a hard failure, not "0 violations": fix the path, do not treat an empty scan as a clean pass.`
10440
+ );
10441
+ }
10442
+ const eventTargetBlocks = [];
10443
+ const logResourcePolicyBlocks = [];
10444
+ let rawEventTargetCount = 0;
10445
+ let rawLogPolicyCount = 0;
10446
+ for (const file of files) {
10447
+ const text = readFileSync28(file, "utf8");
10448
+ rawEventTargetCount += countRawResourceDeclarations(text, EVENT_TARGET_TYPE);
10449
+ rawLogPolicyCount += countRawResourceDeclarations(text, LOG_RESOURCE_POLICY_TYPE);
10450
+ eventTargetBlocks.push(...findResourceBlocks(text, file, EVENT_TARGET_TYPE));
10451
+ logResourcePolicyBlocks.push(...findResourceBlocks(text, file, LOG_RESOURCE_POLICY_TYPE));
10452
+ }
10453
+ const unterminatedBlocks = [...eventTargetBlocks, ...logResourcePolicyBlocks].filter(
10454
+ (b) => b.body === null
10455
+ );
10456
+ const eventTargetBlind = rawEventTargetCount > 0 && eventTargetBlocks.length === 0;
10457
+ const logPolicyBlind = rawLogPolicyCount > 0 && logResourcePolicyBlocks.length === 0;
10458
+ const logTargets = [];
10459
+ for (const block of eventTargetBlocks) {
10460
+ if (block.body === null) continue;
10461
+ const logGroupName = resolveEventTargetLogGroup(block.body);
10462
+ if (logGroupName === null) continue;
10463
+ logTargets.push({ file: block.file, line: block.line, targetName: block.name, logGroupName });
10464
+ }
10465
+ const grantedLogGroups = /* @__PURE__ */ new Set();
10466
+ for (const block of logResourcePolicyBlocks) {
10467
+ if (block.body === null) continue;
10468
+ if (!grantsPrincipal(block.body, EVENTBRIDGE_PRINCIPAL)) continue;
10469
+ for (const name of logGroupNamesReferencedIn(block.body)) grantedLogGroups.add(name);
10470
+ }
10471
+ const violations = [];
10472
+ for (const target of logTargets) {
10473
+ if (grantedLogGroups.has(target.logGroupName)) continue;
10474
+ violations.push({
10475
+ file: target.file,
10476
+ line: target.line,
10477
+ targetName: target.targetName,
10478
+ logGroupName: target.logGroupName,
10479
+ reason: `aws_cloudwatch_event_target.${target.targetName} writes to aws_cloudwatch_log_group.${target.logGroupName}, but no aws_cloudwatch_log_resource_policy grants ${EVENTBRIDGE_PRINCIPAL} access to that group \u2014 terraform apply will succeed and the rule will report ENABLED, but \`put-events\` will accept every event while the log group receives none (#1356).`
10480
+ });
10481
+ }
10482
+ const ok = !eventTargetBlind && !logPolicyBlind && unterminatedBlocks.length === 0 && violations.length === 0;
10483
+ const summary = `${files.length} .tf file(s) scanned under ${root}; ${eventTargetBlocks.length} ${EVENT_TARGET_TYPE} block(s) found (${logTargets.length} targeting a log group), ${logResourcePolicyBlocks.length} ${LOG_RESOURCE_POLICY_TYPE} block(s) found; ${violations.length} unpermissioned, ${unterminatedBlocks.length} unterminated.`;
10484
+ return {
10485
+ filesScanned: files.length,
10486
+ eventTargetBlocksFound: eventTargetBlocks.length,
10487
+ logResourcePolicyBlocksFound: logResourcePolicyBlocks.length,
10488
+ logTargets,
10489
+ unterminatedBlocks,
10490
+ violations,
10491
+ eventTargetBlind,
10492
+ logPolicyBlind,
10493
+ ok,
10494
+ summary
10495
+ };
10496
+ }
10497
+
10498
+ // src/scripts/check-eventbridge-log-permissions.ts
10499
+ async function runEventBridgeLogPermissionCheck() {
10500
+ const root = (await execa9("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10501
+ let report;
10502
+ try {
10503
+ report = auditEventBridgeLogPermissions(root);
10504
+ } catch (err) {
10505
+ console.error("\u2717 EventBridge log permission guard: could not run\n");
10506
+ console.error(err instanceof Error ? err.message : String(err));
10507
+ process.exit(1);
10508
+ }
10509
+ console.log(`audited ${report.filesScanned} .tf file(s) under ${root}`);
10510
+ if (!report.ok) {
10511
+ console.error("\u2717 EventBridge log permission guard: unpermissioned target(s) found\n");
10512
+ if (report.eventTargetBlind) {
10513
+ console.error(
10514
+ " BLIND (aws_cloudwatch_event_target): raw source contains this resource type but the block extractor found none \u2014 the extractor broke, this is not evidence there are no event targets (#1374 was exactly this shape on an adjacent guard)."
10515
+ );
10516
+ }
10517
+ if (report.logPolicyBlind) {
10518
+ console.error(
10519
+ " BLIND (aws_cloudwatch_log_resource_policy): raw source contains this resource type but the block extractor found none \u2014 the extractor broke, this is not evidence there are no log resource policies."
10520
+ );
10521
+ }
10522
+ for (const b of report.unterminatedBlocks) {
10523
+ console.error(
10524
+ ` UNTERMINATED ${b.file}:${b.line} resource "${b.type}" "${b.name}" never closes \u2014 cannot verify what it grants or targets.`
10525
+ );
10526
+ }
10527
+ for (const v of report.violations) {
10528
+ console.error(` UNPERMISSIONED ${v.file}:${v.line} ${v.reason}`);
10529
+ }
10530
+ console.error("\nSee biffo-template#1356.");
10531
+ process.exit(1);
10532
+ }
10533
+ console.log(`\u2713 EventBridge log permission guard: ${report.summary}`);
10534
+ }
10535
+
9988
10536
  // src/scripts/check-plugin-collisions.ts
9989
10537
  import { existsSync as existsSync37 } from "fs";
9990
- import { join as join37 } from "path";
9991
- import { execa as execa8 } from "execa";
10538
+ import { join as join40 } from "path";
10539
+ import { execa as execa10 } from "execa";
9992
10540
 
9993
10541
  // src/lib/plugin-collision-guard.ts
9994
- import { existsSync as existsSync36, readdirSync as readdirSync14, statSync as statSync7 } from "fs";
9995
- import { join as join36 } from "path";
10542
+ import { existsSync as existsSync36, readdirSync as readdirSync16, statSync as statSync9 } from "fs";
10543
+ import { join as join39 } from "path";
9996
10544
  var PYTEST_SPECIAL = /* @__PURE__ */ new Set(["conftest.py"]);
9997
10545
  var IGNORED_DIRS = /* @__PURE__ */ new Set([".venv", "node_modules", "__pycache__", ".git", "dist", "build"]);
9998
10546
  function subdirectories(dir) {
9999
10547
  if (!existsSync36(dir)) return [];
10000
- return readdirSync14(dir).filter((entry) => {
10548
+ return readdirSync16(dir).filter((entry) => {
10001
10549
  if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) return false;
10002
10550
  try {
10003
- return statSync7(join36(dir, entry)).isDirectory();
10551
+ return statSync9(join39(dir, entry)).isDirectory();
10004
10552
  } catch {
10005
10553
  return false;
10006
10554
  }
10007
10555
  });
10008
10556
  }
10009
10557
  function regularPackagesOf(pluginDir2) {
10010
- return subdirectories(pluginDir2).filter((name) => existsSync36(join36(pluginDir2, name, "__init__.py"))).sort();
10558
+ return subdirectories(pluginDir2).filter((name) => existsSync36(join39(pluginDir2, name, "__init__.py"))).sort();
10011
10559
  }
10012
10560
  function bareTestModulesOf(pluginDir2) {
10013
- const testsDir = join36(pluginDir2, "tests");
10561
+ const testsDir = join39(pluginDir2, "tests");
10014
10562
  if (!existsSync36(testsDir)) return [];
10015
- if (existsSync36(join36(testsDir, "__init__.py"))) return [];
10016
- return readdirSync14(testsDir).filter((f) => f.endsWith(".py") && !PYTEST_SPECIAL.has(f)).sort();
10563
+ if (existsSync36(join39(testsDir, "__init__.py"))) return [];
10564
+ return readdirSync16(testsDir).filter((f) => f.endsWith(".py") && !PYTEST_SPECIAL.has(f)).sort();
10017
10565
  }
10018
10566
  function findCollisions(servicesDir, pluginDirs) {
10019
10567
  const plugins = (pluginDirs ?? subdirectories(servicesDir)).filter((name) => !name.startsWith("_")).filter((name) => name !== "api").sort();
@@ -10021,7 +10569,7 @@ function findCollisions(servicesDir, pluginDirs) {
10021
10569
  const gather = (kind, namesOf) => {
10022
10570
  const claims = /* @__PURE__ */ new Map();
10023
10571
  for (const plugin of plugins) {
10024
- for (const name of namesOf(join36(servicesDir, plugin))) {
10572
+ for (const name of namesOf(join39(servicesDir, plugin))) {
10025
10573
  claims.set(name, [...claims.get(name) ?? [], plugin]);
10026
10574
  }
10027
10575
  }
@@ -10058,8 +10606,8 @@ function formatCollisions(collisions) {
10058
10606
 
10059
10607
  // src/scripts/check-plugin-collisions.ts
10060
10608
  async function runPluginCollisionCheck() {
10061
- const root = (await execa8("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10062
- const servicesDir = join37(root, "services");
10609
+ const root = (await execa10("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10610
+ const servicesDir = join40(root, "services");
10063
10611
  if (!existsSync37(servicesDir)) {
10064
10612
  console.log("\u2713 plugin collision guard: no services/ directory \u2014 nothing to compare");
10065
10613
  return;
@@ -10077,28 +10625,28 @@ async function runPluginCollisionCheck() {
10077
10625
  }
10078
10626
 
10079
10627
  // src/scripts/check-plugin-terraform.ts
10080
- import { execa as execa9 } from "execa";
10628
+ import { execa as execa11 } from "execa";
10081
10629
 
10082
10630
  // src/lib/plugin-terraform-guard.ts
10083
- import { existsSync as existsSync38, readFileSync as readFileSync27, readdirSync as readdirSync15 } from "fs";
10084
- import { dirname as dirname9, join as join38, relative as relative6, sep as sep3 } from "path";
10085
- var SKIP_DIRS = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
10631
+ import { existsSync as existsSync38, readFileSync as readFileSync29, readdirSync as readdirSync17 } from "fs";
10632
+ import { dirname as dirname9, join as join41, relative as relative6, sep as sep3 } from "path";
10633
+ var SKIP_DIRS2 = /* @__PURE__ */ new Set(["node_modules", ".git", ".worktrees", "dist", ".venv", "__pycache__"]);
10086
10634
  var PLUGIN_MANIFEST_FILE2 = "biffo.plugin.json";
10087
10635
  function findPluginManifests(root) {
10088
10636
  const found = [];
10089
10637
  const walk = (dir) => {
10090
10638
  let entries;
10091
10639
  try {
10092
- entries = readdirSync15(dir, { withFileTypes: true });
10640
+ entries = readdirSync17(dir, { withFileTypes: true });
10093
10641
  } catch {
10094
10642
  return;
10095
10643
  }
10096
10644
  for (const entry of entries) {
10097
10645
  if (entry.isDirectory()) {
10098
- if (SKIP_DIRS.has(entry.name)) continue;
10099
- walk(join38(dir, entry.name));
10646
+ if (SKIP_DIRS2.has(entry.name)) continue;
10647
+ walk(join41(dir, entry.name));
10100
10648
  } else if (entry.isFile() && entry.name === PLUGIN_MANIFEST_FILE2) {
10101
- found.push(relative6(root, join38(dir, entry.name)).split(sep3).join("/"));
10649
+ found.push(relative6(root, join41(dir, entry.name)).split(sep3).join("/"));
10102
10650
  }
10103
10651
  }
10104
10652
  };
@@ -10108,7 +10656,7 @@ function findPluginManifests(root) {
10108
10656
  function readSubscriptions(absManifestPath) {
10109
10657
  let parsed;
10110
10658
  try {
10111
- parsed = JSON.parse(readFileSync27(absManifestPath, "utf8"));
10659
+ parsed = JSON.parse(readFileSync29(absManifestPath, "utf8"));
10112
10660
  } catch {
10113
10661
  return null;
10114
10662
  }
@@ -10123,14 +10671,14 @@ function readSubscriptions(absManifestPath) {
10123
10671
  }
10124
10672
  function checkPluginTerraform(root) {
10125
10673
  const violations = [];
10126
- const coreManifest = existsSync38(join38(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
10674
+ const coreManifest = existsSync38(join41(root, CORE_MANIFEST_FILE)) ? readCoreManifest(root) : null;
10127
10675
  for (const manifest of findPluginManifests(root)) {
10128
10676
  if (coreManifest && !isTemplateOwned(manifest, coreManifest)) continue;
10129
- const absManifest = join38(root, manifest);
10677
+ const absManifest = join41(root, manifest);
10130
10678
  const subscriptions = readSubscriptions(absManifest);
10131
10679
  if (subscriptions === null) continue;
10132
10680
  const pluginDir2 = dirname9(absManifest);
10133
- if (existsSync38(join38(pluginDir2, "terraform"))) continue;
10681
+ if (existsSync38(join41(pluginDir2, "terraform"))) continue;
10134
10682
  const relPluginDir = relative6(root, pluginDir2).split(sep3).join("/");
10135
10683
  violations.push({
10136
10684
  manifest,
@@ -10150,7 +10698,7 @@ function formatViolations(violations) {
10150
10698
 
10151
10699
  // src/scripts/check-plugin-terraform.ts
10152
10700
  async function runPluginTerraformCheck() {
10153
- const root = (await execa9("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10701
+ const root = (await execa11("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10154
10702
  const violations = checkPluginTerraform(root);
10155
10703
  if (violations.length > 0) {
10156
10704
  console.error("\u2717 plugin Terraform guard: event subscriptions with no infrastructure\n");
@@ -10160,8 +10708,448 @@ async function runPluginTerraformCheck() {
10160
10708
  console.log("\u2713 plugin Terraform guard: OK");
10161
10709
  }
10162
10710
 
10711
+ // src/scripts/check-plugin-tool-supply.ts
10712
+ import { existsSync as existsSync39 } from "fs";
10713
+ import { join as join43 } from "path";
10714
+ import { execa as execa12 } from "execa";
10715
+
10716
+ // src/lib/plugin-tool-supply-audit.ts
10717
+ import { readFileSync as readFileSync30, readdirSync as readdirSync18, statSync as statSync10 } from "fs";
10718
+ import { join as join42 } from "path";
10719
+ function listDirs(root) {
10720
+ let entries;
10721
+ try {
10722
+ entries = readdirSync18(root);
10723
+ } catch {
10724
+ return [];
10725
+ }
10726
+ return entries.filter((e) => {
10727
+ try {
10728
+ return statSync10(join42(root, e)).isDirectory();
10729
+ } catch {
10730
+ return false;
10731
+ }
10732
+ }).sort();
10733
+ }
10734
+ function walkFiles2(root, accept, skipDir) {
10735
+ const out = [];
10736
+ const walk = (dir) => {
10737
+ let entries;
10738
+ try {
10739
+ entries = readdirSync18(dir);
10740
+ } catch {
10741
+ return;
10742
+ }
10743
+ for (const entry of entries) {
10744
+ const p = join42(dir, entry);
10745
+ let st;
10746
+ try {
10747
+ st = statSync10(p);
10748
+ } catch {
10749
+ continue;
10750
+ }
10751
+ if (st.isDirectory()) {
10752
+ if (skipDir(entry)) continue;
10753
+ walk(p);
10754
+ continue;
10755
+ }
10756
+ if (accept(entry)) out.push(p);
10757
+ }
10758
+ };
10759
+ walk(root);
10760
+ return out.sort();
10761
+ }
10762
+ function pluginPythonFiles(pluginDir2) {
10763
+ return walkFiles2(
10764
+ pluginDir2,
10765
+ (entry) => entry.endsWith(".py") && !entry.startsWith("test_") && !entry.endsWith("_test.py"),
10766
+ (entry) => entry === "tests" || entry === "__pycache__" || entry === "terraform"
10767
+ );
10768
+ }
10769
+ function pluginTerraformFiles(pluginDir2) {
10770
+ const tfDir = join42(pluginDir2, "terraform");
10771
+ let entries;
10772
+ try {
10773
+ entries = readdirSync18(tfDir);
10774
+ } catch {
10775
+ return [];
10776
+ }
10777
+ return entries.filter((e) => e.endsWith(".tf")).map((e) => join42(tfDir, e)).sort();
10778
+ }
10779
+ function extractManifestTools(manifestText) {
10780
+ let parsed;
10781
+ try {
10782
+ parsed = JSON.parse(manifestText);
10783
+ } catch (exc) {
10784
+ return { tools: [], parseError: `invalid JSON: ${exc.message}` };
10785
+ }
10786
+ if (typeof parsed !== "object" || parsed === null) {
10787
+ return { tools: [], parseError: "manifest is not a JSON object" };
10788
+ }
10789
+ const raw = parsed.tools;
10790
+ if (raw === void 0) return { tools: [], parseError: null };
10791
+ if (!Array.isArray(raw))
10792
+ return { tools: [], parseError: '"tools" is present but is not an array' };
10793
+ const tools = [];
10794
+ for (const entry of raw) {
10795
+ if (typeof entry === "string" && entry.trim()) {
10796
+ tools.push(entry.trim());
10797
+ continue;
10798
+ }
10799
+ if (entry && typeof entry === "object" && typeof entry.name === "string" && entry.name.trim()) {
10800
+ tools.push(entry.name.trim());
10801
+ continue;
10802
+ }
10803
+ return {
10804
+ tools: [],
10805
+ parseError: `a "tools" entry has no resolvable string name: ${JSON.stringify(entry)}`
10806
+ };
10807
+ }
10808
+ return { tools, parseError: null };
10809
+ }
10810
+ var ASSIGNMENT = /^[ \t]*([A-Za-z_][A-Za-z0-9_]*)\s*(?::\s*[A-Za-z_][\w.[\], ]*)?\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*')/gm;
10811
+ function unquote2(literal) {
10812
+ return literal.slice(1, -1);
10813
+ }
10814
+ function isStringLiteral(token) {
10815
+ return /^"(?:[^"\\]|\\.)*"$/.test(token) || /^'(?:[^'\\]|\\.)*'$/.test(token);
10816
+ }
10817
+ var SymbolResolver = class {
10818
+ constructor(perFile, merged) {
10819
+ this.perFile = perFile;
10820
+ this.merged = merged;
10821
+ }
10822
+ perFile;
10823
+ merged;
10824
+ resolve(token, file) {
10825
+ const trimmed = token.trim();
10826
+ if (isStringLiteral(trimmed)) return unquote2(trimmed);
10827
+ if (!/^[A-Za-z_][A-Za-z0-9_]*$/.test(trimmed)) return null;
10828
+ const local = this.perFile.get(file);
10829
+ if (local?.has(trimmed)) return local.get(trimmed);
10830
+ const cross = this.merged.get(trimmed);
10831
+ return typeof cross === "string" ? cross : null;
10832
+ }
10833
+ };
10834
+ function buildSymbolResolver(sources) {
10835
+ const perFile = /* @__PURE__ */ new Map();
10836
+ const merged = /* @__PURE__ */ new Map();
10837
+ for (const { file, text } of sources) {
10838
+ const table = /* @__PURE__ */ new Map();
10839
+ ASSIGNMENT.lastIndex = 0;
10840
+ let m;
10841
+ while ((m = ASSIGNMENT.exec(text)) !== null) {
10842
+ const [ident, literal] = [m[1], m[2]];
10843
+ if (!table.has(ident)) table.set(ident, unquote2(literal));
10844
+ }
10845
+ perFile.set(file, table);
10846
+ for (const [name, value] of table) {
10847
+ if (!merged.has(name)) merged.set(name, value);
10848
+ else if (merged.get(name) !== value) merged.set(name, null);
10849
+ }
10850
+ }
10851
+ return new SymbolResolver(perFile, merged);
10852
+ }
10853
+ function balancedSpanFrom(text, openIndex, openCh, closeCh) {
10854
+ let depth = 0;
10855
+ for (let i = openIndex; i < text.length; i++) {
10856
+ if (text[i] === openCh) depth += 1;
10857
+ else if (text[i] === closeCh) {
10858
+ depth -= 1;
10859
+ if (depth === 0) return i;
10860
+ }
10861
+ }
10862
+ return null;
10863
+ }
10864
+ var NAME_KWARG = /\bname\s*=\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[A-Za-z_][A-Za-z0-9_]*)\s*,/;
10865
+ var IS_AVAILABLE_KWARG = /\bis_available\s*=\s*([A-Za-z_][A-Za-z0-9_.]*)\s*,?/;
10866
+ function extractToolRegistryEntries(sources, resolver) {
10867
+ const entries = [];
10868
+ let rawToolDefinitionCount = 0;
10869
+ for (const { file, text } of sources) {
10870
+ const callSite = /ToolDefinition\s*\(/g;
10871
+ let m;
10872
+ while ((m = callSite.exec(text)) !== null) {
10873
+ rawToolDefinitionCount += 1;
10874
+ const openIdx = m.index + m[0].length - 1;
10875
+ const closeIdx = balancedSpanFrom(text, openIdx, "(", ")");
10876
+ if (closeIdx === null) continue;
10877
+ const inner = text.slice(openIdx + 1, closeIdx);
10878
+ const nameMatch = NAME_KWARG.exec(inner);
10879
+ if (!nameMatch) {
10880
+ entries.push({
10881
+ name: null,
10882
+ predicate: null,
10883
+ unresolvedReason: `ToolDefinition(...) call in ${file} has no resolvable \`name=\` kwarg`
10884
+ });
10885
+ continue;
10886
+ }
10887
+ const resolvedName = resolver.resolve(nameMatch[1], file);
10888
+ if (resolvedName === null) {
10889
+ entries.push({
10890
+ name: null,
10891
+ predicate: null,
10892
+ unresolvedReason: `name=${nameMatch[1]} in ${file} is an identifier absent from (or ambiguous in) the symbol table`
10893
+ });
10894
+ continue;
10895
+ }
10896
+ const availMatch = IS_AVAILABLE_KWARG.exec(inner);
10897
+ if (!availMatch) {
10898
+ entries.push({ name: resolvedName, predicate: null, unresolvedReason: null });
10899
+ continue;
10900
+ }
10901
+ const dotted = availMatch[1];
10902
+ const predicate = dotted.includes(".") ? dotted.split(".").pop() : dotted;
10903
+ entries.push({ name: resolvedName, predicate, unresolvedReason: null });
10904
+ }
10905
+ }
10906
+ return { entries, rawToolDefinitionCount };
10907
+ }
10908
+ var ENV_READ = /\bos\.(?:environ\.get|getenv)\(\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[A-Za-z_][A-Za-z0-9_]*)|\bos\.environ\[\s*("(?:[^"\\]|\\.)*"|'(?:[^'\\]|\\.)*'|[A-Za-z_][A-Za-z0-9_]*)\s*\]/g;
10909
+ function extractPredicateEnvVars(sources, predicate, resolver) {
10910
+ const defPattern = new RegExp(`^def\\s+${predicate}\\s*\\(`, "m");
10911
+ let found = null;
10912
+ for (const { file, text } of sources) {
10913
+ const defMatch = defPattern.exec(text);
10914
+ if (!defMatch) continue;
10915
+ const bodyStart = defMatch.index + defMatch[0].length;
10916
+ const rest = text.slice(bodyStart);
10917
+ const nextTopLevel = /^(?:def |class )/m.exec(rest);
10918
+ found = { file, body: nextTopLevel ? rest.slice(0, nextTopLevel.index) : rest };
10919
+ break;
10920
+ }
10921
+ if (!found) return { predicateFound: false, envVars: [], unresolvedTokens: [] };
10922
+ const envVars = /* @__PURE__ */ new Set();
10923
+ const unresolvedTokens = /* @__PURE__ */ new Set();
10924
+ ENV_READ.lastIndex = 0;
10925
+ let m;
10926
+ while ((m = ENV_READ.exec(found.body)) !== null) {
10927
+ const token = m[1] ?? m[2];
10928
+ const resolved = resolver.resolve(token, found.file);
10929
+ if (resolved === null) unresolvedTokens.add(token);
10930
+ else envVars.add(resolved);
10931
+ }
10932
+ return {
10933
+ predicateFound: true,
10934
+ envVars: [...envVars].sort(),
10935
+ unresolvedTokens: [...unresolvedTokens].sort()
10936
+ };
10937
+ }
10938
+ function extractTerraformEnvKeys(tfText) {
10939
+ const keys = /* @__PURE__ */ new Set();
10940
+ let resolvedBlockCount = 0;
10941
+ let rawMarkerCount = 0;
10942
+ const marker = /environment_variables\s*=\s*/g;
10943
+ let m;
10944
+ while ((m = marker.exec(tfText)) !== null) {
10945
+ rawMarkerCount += 1;
10946
+ let openIdx = m.index + m[0].length;
10947
+ while (openIdx < tfText.length) {
10948
+ const ch = tfText.charAt(openIdx);
10949
+ if (ch === "{" || ch === "(") break;
10950
+ if (ch === "\n" || !/[\s.A-Za-z0-9_]/.test(ch)) {
10951
+ openIdx = -1;
10952
+ break;
10953
+ }
10954
+ openIdx += 1;
10955
+ }
10956
+ if (openIdx === -1 || openIdx >= tfText.length) continue;
10957
+ const openCh = tfText.charAt(openIdx);
10958
+ const closeCh = openCh === "{" ? "}" : ")";
10959
+ const endIdx = balancedSpanFrom(tfText, openIdx, openCh, closeCh);
10960
+ if (endIdx === null) continue;
10961
+ resolvedBlockCount += 1;
10962
+ const inner = tfText.slice(openIdx, endIdx + 1);
10963
+ const keyPattern = /^\s*([A-Z][A-Z0-9_]*)\s*=/gm;
10964
+ let km;
10965
+ while ((km = keyPattern.exec(inner)) !== null) {
10966
+ keys.add(km[1]);
10967
+ }
10968
+ }
10969
+ return { keys: [...keys].sort(), rawMarkerCount, resolvedBlockCount };
10970
+ }
10971
+ function discoverPluginDirs(pluginsRoot) {
10972
+ return listDirs(pluginsRoot).filter((name) => {
10973
+ try {
10974
+ return statSync10(join42(pluginsRoot, name, "biffo.plugin.json")).isFile();
10975
+ } catch {
10976
+ return false;
10977
+ }
10978
+ });
10979
+ }
10980
+ function auditPluginToolSupply(pluginsRoot) {
10981
+ const pluginNames = discoverPluginDirs(pluginsRoot);
10982
+ const findings = [];
10983
+ let registryBlind = false;
10984
+ let terraformBlind = false;
10985
+ let totalDeclaredTools = 0;
10986
+ for (const name of pluginNames) {
10987
+ const pluginDir2 = join42(pluginsRoot, name);
10988
+ const manifestText = readFileSync30(join42(pluginDir2, "biffo.plugin.json"), "utf8");
10989
+ const manifest = extractManifestTools(manifestText);
10990
+ if (manifest.parseError) {
10991
+ findings.push({
10992
+ plugin: name,
10993
+ tool: "(manifest)",
10994
+ predicate: null,
10995
+ requiredEnvVars: [],
10996
+ missingEnvVars: [],
10997
+ status: "unresolved-registry",
10998
+ detail: `manifest could not be read: ${manifest.parseError}`
10999
+ });
11000
+ continue;
11001
+ }
11002
+ if (manifest.tools.length === 0) continue;
11003
+ totalDeclaredTools += manifest.tools.length;
11004
+ const pySources = pluginPythonFiles(pluginDir2).map((f) => ({
11005
+ file: f,
11006
+ text: readFileSync30(f, "utf8")
11007
+ }));
11008
+ const resolver = buildSymbolResolver(pySources);
11009
+ const registry = extractToolRegistryEntries(pySources, resolver);
11010
+ if (registry.rawToolDefinitionCount > 0 && registry.entries.length === 0) registryBlind = true;
11011
+ const tfFiles = pluginTerraformFiles(pluginDir2);
11012
+ const tfText = tfFiles.map((f) => readFileSync30(f, "utf8")).join("\n");
11013
+ const terraform = extractTerraformEnvKeys(tfText);
11014
+ if (terraform.rawMarkerCount > 0 && terraform.resolvedBlockCount === 0) terraformBlind = true;
11015
+ for (const toolName of manifest.tools) {
11016
+ const entry = registry.entries.find((e) => e.name === toolName);
11017
+ if (!entry) {
11018
+ findings.push({
11019
+ plugin: name,
11020
+ tool: toolName,
11021
+ predicate: null,
11022
+ requiredEnvVars: [],
11023
+ missingEnvVars: [],
11024
+ status: "unresolved-registry",
11025
+ detail: `manifest declares "${toolName}" but no ToolDefinition in ${pluginDir2} resolves to that name`
11026
+ });
11027
+ continue;
11028
+ }
11029
+ if (entry.predicate === null) {
11030
+ findings.push({
11031
+ plugin: name,
11032
+ tool: toolName,
11033
+ predicate: null,
11034
+ requiredEnvVars: [],
11035
+ missingEnvVars: [],
11036
+ status: "ok",
11037
+ detail: "registered with no is_available gate (unconditionally available)"
11038
+ });
11039
+ continue;
11040
+ }
11041
+ const envResult = extractPredicateEnvVars(pySources, entry.predicate, resolver);
11042
+ if (!envResult.predicateFound) {
11043
+ findings.push({
11044
+ plugin: name,
11045
+ tool: toolName,
11046
+ predicate: entry.predicate,
11047
+ requiredEnvVars: [],
11048
+ missingEnvVars: [],
11049
+ status: "unresolved-predicate",
11050
+ detail: `is_available=${entry.predicate} but no "def ${entry.predicate}(" was found anywhere under ${pluginDir2}`
11051
+ });
11052
+ continue;
11053
+ }
11054
+ if (envResult.unresolvedTokens.length > 0) {
11055
+ findings.push({
11056
+ plugin: name,
11057
+ tool: toolName,
11058
+ predicate: entry.predicate,
11059
+ requiredEnvVars: envResult.envVars,
11060
+ missingEnvVars: [],
11061
+ status: "unresolved-env-token",
11062
+ detail: `${entry.predicate}() reads os.environ token(s) ${JSON.stringify(envResult.unresolvedTokens)} that are identifiers absent from the symbol table`
11063
+ });
11064
+ continue;
11065
+ }
11066
+ if (envResult.envVars.length === 0) {
11067
+ findings.push({
11068
+ plugin: name,
11069
+ tool: toolName,
11070
+ predicate: entry.predicate,
11071
+ requiredEnvVars: [],
11072
+ missingEnvVars: [],
11073
+ status: "ok",
11074
+ detail: `${entry.predicate}() gates availability but reads no os.environ variable this guard can cross-check`
11075
+ });
11076
+ continue;
11077
+ }
11078
+ const anyWired = envResult.envVars.some((v) => terraform.keys.includes(v));
11079
+ findings.push({
11080
+ plugin: name,
11081
+ tool: toolName,
11082
+ predicate: entry.predicate,
11083
+ requiredEnvVars: envResult.envVars,
11084
+ missingEnvVars: anyWired ? [] : envResult.envVars,
11085
+ status: anyWired ? "ok" : "missing-env",
11086
+ detail: anyWired ? `${entry.predicate}() is satisfiable: at least one of ${JSON.stringify(envResult.envVars)} is wired in Terraform` : `${entry.predicate}() reads ${JSON.stringify(envResult.envVars)} \u2014 NONE of these are wired by any environment_variables block under ${join42(pluginDir2, "terraform")}, so this deployment can never supply it`
11087
+ });
11088
+ }
11089
+ }
11090
+ const noPluginsFound = pluginNames.length === 0;
11091
+ const noToolsDeclaredAnywhere = !noPluginsFound && totalDeclaredTools === 0;
11092
+ const badFindings = findings.filter((f) => f.status !== "ok");
11093
+ const ok = !noPluginsFound && !registryBlind && !terraformBlind && badFindings.length === 0;
11094
+ const summaryParts = [
11095
+ `${pluginNames.length} plugin dir(s) under ${pluginsRoot}`,
11096
+ `${totalDeclaredTools} declared tool(s)`,
11097
+ `${findings.length} cross-checked, ${badFindings.length} not ok`
11098
+ ];
11099
+ if (noPluginsFound) summaryParts.push("NO PLUGINS FOUND \u2014 cannot evaluate an empty world");
11100
+ if (registryBlind) summaryParts.push("REGISTRY BLIND");
11101
+ if (terraformBlind) summaryParts.push("TERRAFORM BLIND");
11102
+ return {
11103
+ pluginsRoot,
11104
+ plugins: pluginNames,
11105
+ findings,
11106
+ registryBlind,
11107
+ terraformBlind,
11108
+ noPluginsFound,
11109
+ noToolsDeclaredAnywhere,
11110
+ ok,
11111
+ summary: summaryParts.join("; ")
11112
+ };
11113
+ }
11114
+
11115
+ // src/scripts/check-plugin-tool-supply.ts
11116
+ async function runPluginToolSupplyCheck() {
11117
+ const root = (await execa12("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11118
+ const pluginsRoot = join43(root, "services", "_plugins");
11119
+ if (!existsSync39(pluginsRoot)) {
11120
+ console.log("\u2713 plugin tool-supply guard: no services/_plugins/ \u2014 nothing to audit");
11121
+ return;
11122
+ }
11123
+ const report = auditPluginToolSupply(pluginsRoot);
11124
+ console.log(
11125
+ `audited ${report.plugins.length} plugin dir(s), ${report.findings.length} declared tool(s) cross-checked, under ${pluginsRoot}`
11126
+ );
11127
+ if (!report.ok) {
11128
+ console.error("\u2717 plugin tool-supply guard: an unsatisfiable tool grant found\n");
11129
+ if (report.noPluginsFound) {
11130
+ console.error(` NO PLUGINS FOUND under ${pluginsRoot} \u2014 cannot evaluate an empty world.`);
11131
+ }
11132
+ if (report.registryBlind) {
11133
+ console.error(
11134
+ " REGISTRY BLIND: raw source contains ToolDefinition( call site(s) but the extractor resolved none \u2014 the extractor broke, this is not evidence a plugin registers nothing."
11135
+ );
11136
+ }
11137
+ if (report.terraformBlind) {
11138
+ console.error(
11139
+ " TERRAFORM BLIND: raw source contains environment_variables but the extractor resolved no keys \u2014 the extractor broke, this is not evidence of an empty Lambda."
11140
+ );
11141
+ }
11142
+ for (const f of report.findings.filter((f2) => f2.status !== "ok")) {
11143
+ console.error(` ${f.status.toUpperCase()} ${f.plugin}/${f.tool} ${f.detail}`);
11144
+ }
11145
+ console.error("\nSee biffo-template#822.");
11146
+ process.exit(1);
11147
+ }
11148
+ console.log(`\u2713 plugin tool-supply guard: ${report.summary}`);
11149
+ }
11150
+
10163
11151
  // src/scripts/check-release-subject.ts
10164
- import { execa as execa10 } from "execa";
11152
+ import { execa as execa13 } from "execa";
10165
11153
 
10166
11154
  // src/lib/release-version.ts
10167
11155
  var MINOR_TYPES = /* @__PURE__ */ new Set(["feat"]);
@@ -10198,7 +11186,7 @@ async function fetchPrTitleViaGh({
10198
11186
  PR_NUMBER,
10199
11187
  GH_REPO
10200
11188
  }) {
10201
- const { stdout } = await execa10(
11189
+ const { stdout } = await execa13(
10202
11190
  "gh",
10203
11191
  ["pr", "view", PR_NUMBER, "--repo", GH_REPO, "--json", "title", "--jq", ".title"],
10204
11192
  { env: { ...process.env, GH_TOKEN } }
@@ -10234,7 +11222,7 @@ async function resolveReleaseSubject({
10234
11222
  );
10235
11223
  }
10236
11224
  }
10237
- return (await execa10("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
11225
+ return (await execa13("git", ["log", "-1", "--format=%s"], { cwd })).stdout.trim();
10238
11226
  }
10239
11227
  async function runReleaseSubjectCheck(argv) {
10240
11228
  const base = process.env["GITHUB_BASE_REF"] ?? argv[0];
@@ -10242,9 +11230,9 @@ async function runReleaseSubjectCheck(argv) {
10242
11230
  console.error("No base ref: set GITHUB_BASE_REF or pass a base branch as the first argument.");
10243
11231
  process.exit(2);
10244
11232
  }
10245
- const root = (await execa10("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
10246
- await execa10("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
10247
- const { stdout } = await execa10("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
11233
+ const root = (await execa13("git", ["rev-parse", "--show-toplevel"])).stdout.trim();
11234
+ await execa13("git", ["fetch", "--quiet", "origin", base], { cwd: root, reject: false });
11235
+ const { stdout } = await execa13("git", ["diff", "--name-only", `origin/${base}...HEAD`], {
10248
11236
  cwd: root
10249
11237
  });
10250
11238
  const changedFiles = stdout.split("\n").map((s) => s.trim()).filter(Boolean);
@@ -10293,7 +11281,7 @@ async function runReleaseSubjectCheck(argv) {
10293
11281
 
10294
11282
  // src/commands/check.ts
10295
11283
  var checkCommand = new Command23("check").description(
10296
- "Repo guards (ownership, release subject, plugin terraform, plugin collisions) run in CI and git hooks, plus out-of-band audits (branch protection)"
11284
+ "Repo guards (ownership, release subject, plugin terraform, plugin collisions, eventbridge-log-permissions, plugin-tool-supply, core-direct-paths) run in CI and git hooks, plus out-of-band audits (branch protection)"
10297
11285
  );
10298
11286
  checkCommand.command("ownership").description("Refuse changes to template-owned paths in an instance (#370)").argument("[base]", "Base branch to diff against; defaults to $GITHUB_BASE_REF").option("--staged <messageFile>", "Check staged changes instead of a branch diff (commit hook)").allowExcessArguments(true).action(async () => {
10299
11287
  await runOwnershipCheck(rawArgsAfter("ownership"));
@@ -10312,6 +11300,21 @@ checkCommand.command("adr-numbering").description(
10312
11300
  ).action(async () => {
10313
11301
  await runAdrNumberingCheck();
10314
11302
  });
11303
+ checkCommand.command("eventbridge-log-permissions").description(
11304
+ "Refuse an EventBridge target writing to a CloudWatch Logs group no resource policy grants it access to (#1356) \u2014 terraform apply succeeds and the rule reports ENABLED on the broken shape, so this is the only signal available before either exists"
11305
+ ).action(async () => {
11306
+ await runEventBridgeLogPermissionCheck();
11307
+ });
11308
+ checkCommand.command("plugin-tool-supply").description(
11309
+ "Refuse a plugin manifest declaring a tool whose is_available predicate reads an env var no Terraform environment_variables block ever wires (#822) \u2014 the shape that left web_search silently unavailable in every environment, forever"
11310
+ ).action(async () => {
11311
+ await runPluginToolSupplyCheck();
11312
+ });
11313
+ checkCommand.command("core-direct-paths").description(
11314
+ "Refuse a frontend's core-direct call site (bypassing its own BFF) naming a route prefix core does not register (#1377). Defaults to a self-check of the sibling skeleton against this repo's own services/api/src; --sibling/--frontend-src/--core-src point it at a real checked-out sibling instead"
11315
+ ).option("--sibling <name>", "Label for the report").option("--frontend-src <dir>", "Sibling's frontend source directory to scan").option("--core-src <dir>", "Core API's source directory (ground truth for route prefixes)").action(async (opts) => {
11316
+ await runCoreDirectPathsCheck(opts);
11317
+ });
10315
11318
  checkCommand.command("branch-protection").description(
10316
11319
  "Verify dev/staging/main are actually protected \u2014 scaffolding skips this on a 403 (#715)"
10317
11320
  ).option("--repo <owner/name>", "Repo to audit; defaults to this checkout's origin remote").option(
@@ -10326,8 +11329,8 @@ function rawArgsAfter(subcommand) {
10326
11329
  }
10327
11330
 
10328
11331
  // src/commands/doctor.ts
10329
- import { existsSync as existsSync39, readFileSync as readFileSync28 } from "fs";
10330
- import { join as join39, resolve as resolve18 } from "path";
11332
+ import { existsSync as existsSync40, readFileSync as readFileSync31 } from "fs";
11333
+ import { join as join44, resolve as resolve18 } from "path";
10331
11334
  import chalk21 from "chalk";
10332
11335
  import { Command as Command24 } from "commander";
10333
11336
 
@@ -10502,10 +11505,10 @@ async function runDoctor(options, deps = { git: new GitAdapter() }) {
10502
11505
  return runDoctorChecks(facts);
10503
11506
  }
10504
11507
  function readLocalCoreVersion(cwd) {
10505
- const path = join39(cwd, INSTANCE_CORE_FILE);
10506
- if (!existsSync39(path)) return null;
11508
+ const path = join44(cwd, INSTANCE_CORE_FILE);
11509
+ if (!existsSync40(path)) return null;
10507
11510
  try {
10508
- return parseCoreRecord(readFileSync28(path, "utf8"));
11511
+ return parseCoreRecord(readFileSync31(path, "utf8"));
10509
11512
  } catch {
10510
11513
  return null;
10511
11514
  }
@@ -10520,10 +11523,10 @@ function parseCoreRecord(contents) {
10520
11523
  }
10521
11524
  }
10522
11525
  function readFossil(cwd) {
10523
- const path = join39(cwd, CORE_VERSION_FILE);
10524
- if (!existsSync39(path)) return null;
11526
+ const path = join44(cwd, CORE_VERSION_FILE);
11527
+ if (!existsSync40(path)) return null;
10525
11528
  try {
10526
- const value = readFileSync28(path, "utf8").trim();
11529
+ const value = readFileSync31(path, "utf8").trim();
10527
11530
  return value === "" ? null : value;
10528
11531
  } catch {
10529
11532
  return null;
@@ -10972,13 +11975,13 @@ import { fileURLToPath as fileURLToPath6 } from "url";
10972
11975
  import { Command as Command26 } from "commander";
10973
11976
 
10974
11977
  // src/lib/packaged-scripts.ts
10975
- import { existsSync as existsSync40 } from "fs";
10976
- import { dirname as dirname10, join as join40 } from "path";
11978
+ import { existsSync as existsSync41 } from "fs";
11979
+ import { dirname as dirname10, join as join45 } from "path";
10977
11980
  function findPackagedScript(startDir, relativePath) {
10978
11981
  let dir = startDir;
10979
11982
  for (; ; ) {
10980
- const candidate = join40(dir, relativePath);
10981
- if (existsSync40(candidate)) return candidate;
11983
+ const candidate = join45(dir, relativePath);
11984
+ if (existsSync41(candidate)) return candidate;
10982
11985
  const parent = dirname10(dir);
10983
11986
  if (parent === dir) return null;
10984
11987
  dir = parent;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@biffo/cli",
3
- "version": "0.265.2",
3
+ "version": "0.265.3",
4
4
  "description": "Biffo project scaffolding CLI",
5
5
  "license": "MIT",
6
6
  "type": "module",