@biffo/cli 0.265.1 → 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.
- package/dist/index.js +1057 -54
- package/package.json +1 -1
- package/scripts/claim.sh +54 -7
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-
|
|
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
|
|
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
|
|
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:
|
|
9888
|
-
if (
|
|
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
|
|
9897
|
-
const { stdout } = await
|
|
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
|
|
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
|
|
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
|
|
9991
|
-
import { execa as
|
|
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
|
|
9995
|
-
import { join as
|
|
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
|
|
10548
|
+
return readdirSync16(dir).filter((entry) => {
|
|
10001
10549
|
if (IGNORED_DIRS.has(entry) || entry.startsWith(".")) return false;
|
|
10002
10550
|
try {
|
|
10003
|
-
return
|
|
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(
|
|
10558
|
+
return subdirectories(pluginDir2).filter((name) => existsSync36(join39(pluginDir2, name, "__init__.py"))).sort();
|
|
10011
10559
|
}
|
|
10012
10560
|
function bareTestModulesOf(pluginDir2) {
|
|
10013
|
-
const testsDir =
|
|
10561
|
+
const testsDir = join39(pluginDir2, "tests");
|
|
10014
10562
|
if (!existsSync36(testsDir)) return [];
|
|
10015
|
-
if (existsSync36(
|
|
10016
|
-
return
|
|
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(
|
|
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
|
|
10062
|
-
const servicesDir =
|
|
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
|
|
10628
|
+
import { execa as execa11 } from "execa";
|
|
10081
10629
|
|
|
10082
10630
|
// src/lib/plugin-terraform-guard.ts
|
|
10083
|
-
import { existsSync as existsSync38, readFileSync as
|
|
10084
|
-
import { dirname as dirname9, join as
|
|
10085
|
-
var
|
|
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 =
|
|
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 (
|
|
10099
|
-
walk(
|
|
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,
|
|
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(
|
|
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(
|
|
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 =
|
|
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(
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
10246
|
-
await
|
|
10247
|
-
const { stdout } = await
|
|
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
|
|
10330
|
-
import { join as
|
|
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 =
|
|
10506
|
-
if (!
|
|
11508
|
+
const path = join44(cwd, INSTANCE_CORE_FILE);
|
|
11509
|
+
if (!existsSync40(path)) return null;
|
|
10507
11510
|
try {
|
|
10508
|
-
return parseCoreRecord(
|
|
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 =
|
|
10524
|
-
if (!
|
|
11526
|
+
const path = join44(cwd, CORE_VERSION_FILE);
|
|
11527
|
+
if (!existsSync40(path)) return null;
|
|
10525
11528
|
try {
|
|
10526
|
-
const value =
|
|
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
|
|
10976
|
-
import { dirname as dirname10, join as
|
|
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 =
|
|
10981
|
-
if (
|
|
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;
|