@rightkit/release 0.4.0 → 0.4.1
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/README.md +49 -6
- package/adoption-evidence.mjs +650 -0
- package/apple-profile-create.mjs +113 -10
- package/bin/darwin/rightkit-swap +0 -0
- package/binary-license-notices.mjs +161 -0
- package/cargo-license-notices.mjs +111 -0
- package/cli/right-release.mjs +10 -0
- package/local-publish.mjs +133 -19
- package/ownership-dependency-allowlist.json +2 -0
- package/ownership-mandatory-set.json +117 -67
- package/ownership-mandatory-set.mjs +75 -5
- package/package.json +1 -1
- package/pipeline-admission.mjs +375 -0
- package/pipeline-config.mjs +100 -0
- package/pipeline-generators.json +4 -0
- package/pipeline-generators.mjs +137 -0
- package/pipeline-scan.mjs +285 -0
- package/pipeline-tools.mjs +171 -0
- package/production-mode.mjs +42 -0
- package/publication-gate.mjs +83 -32
- package/publish-cargo.mjs +11 -6
- package/publish-swift.mjs +12 -3
- package/qa-control-scan.mjs +4 -0
- package/release-config.mjs +330 -0
- package/release-switch-scan.mjs +44 -5
- package/release.mjs +14 -4
- package/rightkit-sync.mjs +8 -0
- package/rightkit-versions.json +132 -88
- package/runtime-budget-crates.mjs +14 -0
- package/runtime-budgets.mjs +128 -31
- package/stage-sidecar.mjs +47 -8
- package/swap-helper-bundle.mjs +7 -3
- package/bin/darwin/rightkit-swap.sign.json +0 -6
|
@@ -0,0 +1,285 @@
|
|
|
1
|
+
// M24 `--enforce` inventory: find every way an app can own, or indirectly start, a build, sign, notarize, package or upload step.
|
|
2
|
+
//
|
|
3
|
+
// Sources read (R2 framework hooks, R3 tool inventory, R1 alias traversal):
|
|
4
|
+
// - the release config (`right-release.config.json`: every command admitted by R1; a legacy `.mjs` config is a finding and is never opened),
|
|
5
|
+
// - package.json scripts and npm lifecycle hooks, with aliases followed to the helper files they reach,
|
|
6
|
+
// - helper and pipeline scripts under scripts/, tools/, ci/, build/, bin/, packaging/ and the app root,
|
|
7
|
+
// - GitHub workflows (a `# Managed by right-git` workflow is RightKit-owned and only inventoried),
|
|
8
|
+
// - Tauri before-dev / before-build / before-bundle hooks and signing keys in tauri.conf.json,
|
|
9
|
+
// - Cargo `build.rs` (signing or upload is forbidden; spawning an interpreter or RightKit is a generator that must be admitted),
|
|
10
|
+
// - Xcode script phases (`project.pbxproj`, XcodeGen `project.yml`).
|
|
11
|
+
// A finding names its file, line, stage and ownership (`app-owned` when the app wrote the pipeline step, `unknown` when ownership could
|
|
12
|
+
// not be established, which blocks exactly like an app-owned step). Every finding blocks `--enforce`. This is a static scan: it cannot
|
|
13
|
+
// prove native behaviour, and ordinary product runtime code (src/, crates) is never read except build.rs.
|
|
14
|
+
import { existsSync, lstatSync, readdirSync, readFileSync } from "node:fs";
|
|
15
|
+
import path from "node:path";
|
|
16
|
+
import { admitHookString, admitTauriHook } from "./pipeline-admission.mjs";
|
|
17
|
+
import { loadGeneratorRegistry, makeGeneratorAdmitter } from "./pipeline-generators.mjs";
|
|
18
|
+
import { ownershipOfCode, ruleOfCode, validateConfigPipeline } from "./pipeline-config.mjs";
|
|
19
|
+
import { classifyAction, dispatchesRightKit, scanTextForPipelineTools, segmentTokens, splitSegments } from "./pipeline-tools.mjs";
|
|
20
|
+
import { LEGACY_RELEASE_CONFIG_FILE, RELEASE_CONFIG_FILE, loadReleaseConfigSync } from "./release-config.mjs";
|
|
21
|
+
import { PIPELINE_DISPATCH_SUBCOMMANDS } from "./pipeline-admission.mjs";
|
|
22
|
+
|
|
23
|
+
export const PIPELINE_SCAN_SCHEMA = 1;
|
|
24
|
+
const MAX_FILE_BYTES = 2 * 1024 * 1024;
|
|
25
|
+
const MAX_FILES = 5000;
|
|
26
|
+
const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".git", ".cache", ".right-release", ".pnpm-store", "coverage", "Pods", "DerivedData", ".build", ".swiftpm"]);
|
|
27
|
+
const SCRIPT_DIRS = ["scripts", "script", "tools", "tool", "ci", ".ci", "build", "bin", "packaging", "installer", "installers", "release", "deploy", ".husky", ".githooks"];
|
|
28
|
+
const SHELL_EXT = /\.(?:sh|ps1|bat|cmd|command|bash|zsh)$/i;
|
|
29
|
+
const CODE_EXT = /\.(?:mjs|cjs|js|ts|mts|py|rb)$/i;
|
|
30
|
+
const HELPER_REF = /\.(?:sh|ps1|bat|cmd|command|bash|zsh|mjs|cjs|js|py)$/i;
|
|
31
|
+
const MANAGED_MARKER = /#\s*Managed by right-git/i;
|
|
32
|
+
const AUTO_LIFECYCLE = new Set(["preinstall", "install", "postinstall", "prepublish", "preprepare", "prepare", "postprepare", "prepack", "postpack", "prepublishOnly", "publish", "postpublish"]);
|
|
33
|
+
const posix = (value) => value.split(path.sep).join("/");
|
|
34
|
+
|
|
35
|
+
const defaultFs = {
|
|
36
|
+
readText: (file) => readFileSync(file, "utf8"),
|
|
37
|
+
exists: (file) => existsSync(file),
|
|
38
|
+
readdir: (dir) => readdirSync(dir, { withFileTypes: true }).map((entry) => ({ name: entry.name, isDirectory: entry.isDirectory(), isSymlink: entry.isSymbolicLink(), isFile: entry.isFile() })),
|
|
39
|
+
size: (file) => lstatSync(file).size,
|
|
40
|
+
};
|
|
41
|
+
|
|
42
|
+
const lineAt = (text, index) => { let line = 1; for (let i = 0; i < index && i < text.length; i += 1) if (text[i] === "\n") line += 1; return line; };
|
|
43
|
+
|
|
44
|
+
/** Run the inventory for one app. `fs` is injectable. Returns `{ schema, app, root, findings, inventory, scanned }`. */
|
|
45
|
+
export function scanApp({ appRoot, registry, fs = defaultFs } = {}) {
|
|
46
|
+
const root = path.resolve(appRoot);
|
|
47
|
+
const findings = [];
|
|
48
|
+
const inventory = [];
|
|
49
|
+
const visited = new Set();
|
|
50
|
+
let budget = MAX_FILES;
|
|
51
|
+
const rel = (file) => posix(path.relative(root, file));
|
|
52
|
+
const add = (finding) => findings.push({ severity: "block", ownership: ownershipOfCode(finding.code), rule: ruleOfCode(finding.code), line: 1, ...finding });
|
|
53
|
+
const read = (file, label = rel(file)) => {
|
|
54
|
+
if (budget <= 0) { add({ code: "R0-too-many-files", file: label, stage: "scan", message: `more than ${MAX_FILES} files reached; the inventory is incomplete and unknown ownership blocks` }); return null; }
|
|
55
|
+
budget -= 1;
|
|
56
|
+
try {
|
|
57
|
+
if (fs.size && fs.size(file) > MAX_FILE_BYTES) { add({ code: "R0-unreadable", file: label, stage: "scan", message: `${label} is larger than ${MAX_FILE_BYTES} bytes and cannot be inspected` }); return null; }
|
|
58
|
+
return fs.readText(file);
|
|
59
|
+
} catch (error) {
|
|
60
|
+
add({ code: "R0-unreadable", file: label, stage: "scan", message: `${label} cannot be read (${error.message})` });
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
// ---- package.json ------------------------------------------------------------------------------------------------------------
|
|
66
|
+
let scripts;
|
|
67
|
+
let packageText = "";
|
|
68
|
+
const packageFile = path.join(root, "package.json");
|
|
69
|
+
if (fs.exists(packageFile)) {
|
|
70
|
+
packageText = read(packageFile) ?? "";
|
|
71
|
+
try {
|
|
72
|
+
const parsed = JSON.parse(packageText || "{}");
|
|
73
|
+
if (parsed.scripts && typeof parsed.scripts === "object" && !Array.isArray(parsed.scripts)) scripts = parsed.scripts;
|
|
74
|
+
} catch (error) { add({ code: "R0-package-json", file: "package.json", stage: "scan", message: `package.json is not valid JSON (${error.message}); script ownership is unknown` }); }
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// ---- release config (R1) -----------------------------------------------------------------------------------------------------
|
|
78
|
+
const configFile = path.join(root, RELEASE_CONFIG_FILE);
|
|
79
|
+
const legacyFile = path.join(root, LEGACY_RELEASE_CONFIG_FILE);
|
|
80
|
+
let config;
|
|
81
|
+
if (fs.exists(legacyFile)) {
|
|
82
|
+
add({ code: "R0-legacy-config", file: LEGACY_RELEASE_CONFIG_FILE, stage: "config", ownership: "app-owned", message: `${LEGACY_RELEASE_CONFIG_FILE} is an executable release config; it is never imported, evaluated or converted. Replace it with ${RELEASE_CONFIG_FILE}.` });
|
|
83
|
+
inventory.push({ kind: "config", file: LEGACY_RELEASE_CONFIG_FILE, ownership: "app-owned-executable" });
|
|
84
|
+
}
|
|
85
|
+
let admitGenerator;
|
|
86
|
+
const useRegistry = registry ?? (() => { try { return loadGeneratorRegistry(); } catch { return undefined; } })();
|
|
87
|
+
if (fs.exists(configFile)) {
|
|
88
|
+
inventory.push({ kind: "config", file: RELEASE_CONFIG_FILE, ownership: "rightkit-schema" });
|
|
89
|
+
try {
|
|
90
|
+
const loaded = loadReleaseConfigSync(configFile, { validatePipeline: false, readText: (target) => fs.readText(target) });
|
|
91
|
+
config = loaded.config;
|
|
92
|
+
for (const finding of validateConfigPipeline(config, { scripts, registry: useRegistry, appRoot: root, lineOf: loaded.lineOf })) {
|
|
93
|
+
add({ code: finding.code, file: RELEASE_CONFIG_FILE, line: finding.line, stage: finding.stage, ownership: finding.ownership, message: `${finding.pointer}: ${finding.message}` });
|
|
94
|
+
}
|
|
95
|
+
} catch (error) {
|
|
96
|
+
add({ code: `R0-${String(error.code ?? "config-invalid").toLowerCase().replace(/^release_config_/, "config-").replaceAll("_", "-")}`, file: RELEASE_CONFIG_FILE, line: error.line ?? 1, stage: "config", ownership: "unknown", message: error.message });
|
|
97
|
+
}
|
|
98
|
+
} else if (!fs.exists(legacyFile)) {
|
|
99
|
+
add({ code: "R0-config-missing", file: RELEASE_CONFIG_FILE, stage: "config", ownership: "unknown", message: `${RELEASE_CONFIG_FILE} is missing: the app's pipeline ownership cannot be established` });
|
|
100
|
+
}
|
|
101
|
+
if (useRegistry) admitGenerator = makeGeneratorAdmitter({ registry: useRegistry, app: config?.app ?? path.basename(root), appRoot: root, readBytes: (file) => Buffer.from(fs.readText(file), "utf8") });
|
|
102
|
+
const aliasOptions = { scripts, admitGenerator };
|
|
103
|
+
|
|
104
|
+
// ---- helper queue (scripts reachable from pipeline entries, scripts directories) ----------------------------------------------
|
|
105
|
+
const queue = [];
|
|
106
|
+
const enqueue = (file, via) => { const key = path.resolve(file); if (!visited.has(key)) { visited.add(key); queue.push({ file: key, via }); } };
|
|
107
|
+
const refsIn = (text) => {
|
|
108
|
+
const refs = new Set();
|
|
109
|
+
for (const line of String(text).split(/\r?\n/)) {
|
|
110
|
+
for (const segment of splitSegments(line)) {
|
|
111
|
+
for (const token of segmentTokens(segment)) {
|
|
112
|
+
if (!HELPER_REF.test(token) || token.startsWith("-") || /^[a-z]+:\/\//i.test(token)) continue;
|
|
113
|
+
const normalized = token.replaceAll("\\", "/").replace(/^(?:\.\/)+/, "");
|
|
114
|
+
if (path.posix.isAbsolute(normalized) || /^[A-Za-z]:/.test(normalized) || normalized.split("/").includes("..") || normalized.includes("$") || normalized.includes("*")) continue;
|
|
115
|
+
refs.add(normalized);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
return [...refs];
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
if (scripts) {
|
|
123
|
+
const scriptNames = Object.keys(scripts);
|
|
124
|
+
for (const name of scriptNames) {
|
|
125
|
+
const body = scripts[name];
|
|
126
|
+
const lineOfScript = () => { const at = packageText.search(new RegExp(`"${name.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}"\\s*:`)); return at >= 0 ? lineAt(packageText, at) : 1; };
|
|
127
|
+
if (typeof body !== "string") { add({ code: "R1-alias-unknown", file: "package.json", line: lineOfScript(), stage: "script", message: `package script "${name}" is not a string` }); continue; }
|
|
128
|
+
inventory.push({ kind: "package-script", file: "package.json", name, ownership: "app-script" });
|
|
129
|
+
for (const hit of scanTextForPipelineTools(body, { kind: "shell" })) {
|
|
130
|
+
add({ code: `R3-${hit.id}`, file: "package.json", line: lineOfScript(), stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `script "${name}" runs ${hit.tool} (${hit.stage}); build, sign, notarize, package and upload run only inside RightKit (right-release)` });
|
|
131
|
+
}
|
|
132
|
+
const lifecycle = AUTO_LIFECYCLE.has(name) || (/^(?:pre|post)(.+)$/.test(name) && scriptNames.includes(name.replace(/^(?:pre|post)/, "")));
|
|
133
|
+
if (lifecycle) {
|
|
134
|
+
const admitted = admitHookString(body, aliasOptions, [name]);
|
|
135
|
+
if (!admitted.admitted) add({ code: admitted.code.replace(/^R\d/, "R2"), file: "package.json", line: lineOfScript(), stage: "hook", message: `npm lifecycle hook "${name}": ${admitted.reason}` });
|
|
136
|
+
}
|
|
137
|
+
for (const ref of refsIn(body)) enqueue(path.join(root, ref), `package.json scripts.${name}`);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// ---- script directories and root-level scripts -------------------------------------------------------------------------------
|
|
142
|
+
const walkDir = (dir, depth) => {
|
|
143
|
+
let entries;
|
|
144
|
+
try { entries = fs.readdir(dir); } catch { return; }
|
|
145
|
+
for (const entry of entries) {
|
|
146
|
+
const full = path.join(dir, entry.name);
|
|
147
|
+
if (entry.isDirectory) { if (depth > 0 && !SKIP_DIRS.has(entry.name)) walkDir(full, depth - 1); continue; }
|
|
148
|
+
if (!(SHELL_EXT.test(entry.name) || CODE_EXT.test(entry.name) || /^(?:Makefile|justfile)$/i.test(entry.name))) continue;
|
|
149
|
+
if (entry.isSymlink) { add({ code: "R0-symlink", file: rel(full), stage: "scan", message: `${rel(full)} is a symlink; a linked script's owner cannot be established` }); continue; }
|
|
150
|
+
enqueue(full, `pipeline directory ${rel(dir) || "."}`);
|
|
151
|
+
}
|
|
152
|
+
};
|
|
153
|
+
for (const dir of SCRIPT_DIRS) if (fs.exists(path.join(root, dir))) walkDir(path.join(root, dir), 4);
|
|
154
|
+
try {
|
|
155
|
+
for (const entry of fs.readdir(root)) {
|
|
156
|
+
if (entry.isDirectory || entry.isSymlink) continue;
|
|
157
|
+
if (SHELL_EXT.test(entry.name) || /^(?:Makefile|justfile)$/i.test(entry.name) || /^(?:package|build|release|sign|notarize|install|deploy)[-_.a-z0-9]*\.(?:mjs|cjs|js|py)$/i.test(entry.name)) enqueue(path.join(root, entry.name), "app root");
|
|
158
|
+
}
|
|
159
|
+
} catch { /* an unreadable root is reported by the reads above */ }
|
|
160
|
+
|
|
161
|
+
// The queue grows while it drains: a helper's own references join it.
|
|
162
|
+
for (let index = 0; index < queue.length; index += 1) {
|
|
163
|
+
const { file, via } = queue[index];
|
|
164
|
+
const label = rel(file);
|
|
165
|
+
if (!fs.exists(file)) { add({ code: "R1-helper-missing", file: label, stage: "script", ownership: "unknown", message: `${label} is reached from ${via} but does not exist; an unknown reachable script blocks` }); continue; }
|
|
166
|
+
const text = read(file, label);
|
|
167
|
+
if (text === null) continue;
|
|
168
|
+
const isShell = SHELL_EXT.test(file) || /(?:Makefile|justfile)$/i.test(file);
|
|
169
|
+
inventory.push({ kind: "helper-script", file: label, via, ownership: "app-owned" });
|
|
170
|
+
for (const hit of scanTextForPipelineTools(text, { kind: isShell ? "shell" : "code" })) {
|
|
171
|
+
add({ code: `R3-${hit.id}`, file: label, line: hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `${label} (from ${via}) runs ${hit.tool} (${hit.stage}); this is an app-owned pipeline script` });
|
|
172
|
+
}
|
|
173
|
+
if (isShell) for (const ref of refsIn(text)) enqueue(path.resolve(path.dirname(file), ref).startsWith(root) && fs.exists(path.resolve(path.dirname(file), ref)) ? path.resolve(path.dirname(file), ref) : path.join(root, ref), `${label}`);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ---- GitHub workflows --------------------------------------------------------------------------------------------------------
|
|
177
|
+
const workflowDir = path.join(root, ".github", "workflows");
|
|
178
|
+
if (fs.exists(workflowDir)) {
|
|
179
|
+
for (const entry of fs.readdir(workflowDir)) {
|
|
180
|
+
if (entry.isDirectory || !/\.ya?ml$/i.test(entry.name)) continue;
|
|
181
|
+
const file = path.join(workflowDir, entry.name);
|
|
182
|
+
const label = rel(file);
|
|
183
|
+
const text = read(file, label);
|
|
184
|
+
if (text === null) continue;
|
|
185
|
+
const managed = MANAGED_MARKER.test(text.split(/\r?\n/).slice(0, 10).join("\n"));
|
|
186
|
+
inventory.push({ kind: "workflow", file: label, ownership: managed ? "rightkit-managed" : "app-owned" });
|
|
187
|
+
if (managed) continue;
|
|
188
|
+
for (const hit of scanTextForPipelineTools(text, { kind: "shell" })) {
|
|
189
|
+
add({ code: `R3-${hit.id}`, file: label, line: hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `workflow ${label} runs ${hit.tool} (${hit.stage}); pipeline workflows are generated and owned by right-git (carry the "Managed by right-git" marker)` });
|
|
190
|
+
}
|
|
191
|
+
text.split(/\r?\n/).forEach((lineText, i) => {
|
|
192
|
+
const uses = /^\s*(?:-\s+)?uses\s*:\s*(\S+)/.exec(lineText);
|
|
193
|
+
const hit = uses ? classifyAction(uses[1]) : null;
|
|
194
|
+
if (hit) add({ code: `R3-${hit.id}`, file: label, line: i + 1, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `workflow ${label} uses ${hit.tool}, an action that ${hit.stage}s on its own` });
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---- framework hooks: Tauri, build.rs, Xcode --------------------------------------------------------------------------------
|
|
200
|
+
const projectFiles = [];
|
|
201
|
+
const find = (dir, depth) => {
|
|
202
|
+
let entries;
|
|
203
|
+
try { entries = fs.readdir(dir); } catch { return; }
|
|
204
|
+
for (const entry of entries) {
|
|
205
|
+
const full = path.join(dir, entry.name);
|
|
206
|
+
if (entry.isDirectory) {
|
|
207
|
+
if (/\.xcodeproj$/i.test(entry.name)) { projectFiles.push({ kind: "xcode", file: path.join(full, "project.pbxproj") }); continue; }
|
|
208
|
+
if (depth > 0 && !SKIP_DIRS.has(entry.name) && !entry.name.startsWith(".")) find(full, depth - 1);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
if (/^tauri(?:\.[A-Za-z0-9_-]+)?\.conf\.json$/i.test(entry.name)) projectFiles.push({ kind: "tauri", file: full });
|
|
212
|
+
else if (entry.name === "build.rs") projectFiles.push({ kind: "build-rs", file: full });
|
|
213
|
+
else if (entry.name === "project.yml" || entry.name === "project.yaml") projectFiles.push({ kind: "xcodegen", file: full });
|
|
214
|
+
}
|
|
215
|
+
};
|
|
216
|
+
find(root, 4);
|
|
217
|
+
for (const item of projectFiles) {
|
|
218
|
+
if (!fs.exists(item.file)) continue;
|
|
219
|
+
const label = rel(item.file);
|
|
220
|
+
const text = read(item.file, label);
|
|
221
|
+
if (text === null) continue;
|
|
222
|
+
if (item.kind === "tauri") scanTauriConf(text, label);
|
|
223
|
+
else if (item.kind === "build-rs") scanBuildRs(text, label);
|
|
224
|
+
else scanXcode(text, label, item.kind);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
function scanTauriConf(text, label) {
|
|
228
|
+
inventory.push({ kind: "tauri-config", file: label, ownership: "framework-config" });
|
|
229
|
+
let parsed;
|
|
230
|
+
try { parsed = JSON.parse(text); } catch (error) { add({ code: "R0-unreadable", file: label, stage: "hook", message: `${label} is not valid JSON (${error.message}); its hooks are unknown` }); return; }
|
|
231
|
+
const keyLine = (key) => { const at = text.search(new RegExp(`"${key}"\\s*:`)); return at >= 0 ? lineAt(text, at) : 1; };
|
|
232
|
+
for (const key of ["beforeDevCommand", "beforeBuildCommand", "beforeBundleCommand"]) {
|
|
233
|
+
const entry = parsed?.build?.[key];
|
|
234
|
+
if (entry === undefined || entry === null || entry === "") continue;
|
|
235
|
+
const problem = admitTauriHook(entry, aliasOptions, `${label} build.${key}`);
|
|
236
|
+
if (problem) add({ code: problem.code.replace(/^R\d/, "R2"), file: label, line: keyLine(key), stage: "hook", message: problem.reason });
|
|
237
|
+
}
|
|
238
|
+
const windows = parsed?.bundle?.windows ?? {};
|
|
239
|
+
if (windows.signCommand !== undefined && windows.signCommand !== null) add({ code: "R3-tauri-sign-hook", file: label, line: keyLine("signCommand"), stage: "sign", ownership: "app-owned", message: `${label} bundle.windows.signCommand is a sign hook; signing is RightKit's` });
|
|
240
|
+
if (typeof windows.certificateThumbprint === "string" && windows.certificateThumbprint) add({ code: "R3-tauri-sign-config", file: label, line: keyLine("certificateThumbprint"), stage: "sign", ownership: "app-owned", message: `${label} bundle.windows.certificateThumbprint selects a signing identity; signing is RightKit's` });
|
|
241
|
+
const identity = parsed?.bundle?.macOS?.signingIdentity;
|
|
242
|
+
if (typeof identity === "string" && identity !== "-" && identity !== "") add({ code: "R3-tauri-sign-config", file: label, line: keyLine("signingIdentity"), stage: "sign", ownership: "app-owned", message: `${label} bundle.macOS.signingIdentity selects a signing identity; signing is RightKit's` });
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function scanBuildRs(text, label) {
|
|
246
|
+
inventory.push({ kind: "build-rs", file: label, ownership: "product-compile" });
|
|
247
|
+
for (const hit of scanTextForPipelineTools(text, { kind: "code" })) {
|
|
248
|
+
add({ code: `R2-build-rs-${hit.id}`, file: label, line: hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `${label} runs ${hit.tool} (${hit.stage}); signing, packaging or upload from build.rs is forbidden` });
|
|
249
|
+
}
|
|
250
|
+
text.split(/\r?\n/).forEach((lineText, i) => {
|
|
251
|
+
if (/^\s*\/\//.test(lineText)) return;
|
|
252
|
+
const spawn = /Command::new\(\s*"([^"]+)"/.exec(lineText);
|
|
253
|
+
if (!spawn) return;
|
|
254
|
+
const name = path.basename(spawn[1]).toLowerCase().replace(/\.(?:exe|cmd|bat)$/, "");
|
|
255
|
+
if (name === "rightkit" || name === "right-release") add({ code: "R2-build-rs-dispatch", file: label, line: i + 1, stage: "hook", ownership: "app-owned", message: `${label} starts ${name} from build.rs; a generator cannot dispatch another pipeline` });
|
|
256
|
+
else if (["node", "python", "python3", "py", "bash", "sh", "zsh", "pwsh", "powershell", "cmd", "pnpm", "npm", "npx", "yarn", "deno", "bun", "ruby", "perl"].includes(name)) add({ code: "R4-build-rs-generator", file: label, line: i + 1, stage: "generator", ownership: "unknown", message: `${label} runs ${name} from build.rs; a pipeline generator must be an admitted shared step (pipeline-generators.json), not app code` });
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
function scanXcode(text, label, kind) {
|
|
261
|
+
inventory.push({ kind: kind === "xcode" ? "xcode-project" : "xcodegen-spec", file: label, ownership: "framework-config" });
|
|
262
|
+
let bodies;
|
|
263
|
+
if (kind === "xcode") {
|
|
264
|
+
bodies = [...text.matchAll(/shellScript\s*=\s*"((?:[^"\\]|\\.)*)"\s*;/g)].map((match) => ({ line: lineAt(text, match.index), script: match[1].replace(/\\n/g, "\n").replace(/\\t/g, "\t").replace(/\\"/g, '"').replace(/\\\\/g, "\\") }));
|
|
265
|
+
} else bodies = [{ line: 1, script: text }];
|
|
266
|
+
for (const body of bodies) {
|
|
267
|
+
for (const hit of scanTextForPipelineTools(body.script, { kind: "shell" })) {
|
|
268
|
+
add({ code: `R2-xcode-script-${hit.id}`, file: label, line: kind === "xcode" ? body.line : hit.line, stage: hit.stage, ownership: "app-owned", tool: hit.tool, message: `${label} script phase runs ${hit.tool} (${hit.stage}); archive, export, signing and upload belong to RightKit` });
|
|
269
|
+
}
|
|
270
|
+
for (const segment of body.script.split(/\r?\n/).flatMap(splitSegments)) {
|
|
271
|
+
const tokens = segmentTokens(segment);
|
|
272
|
+
if (dispatchesRightKit(tokens) && (tokens[0] === "rightkit" || PIPELINE_DISPATCH_SUBCOMMANDS.has(tokens[1]))) add({ code: "R2-xcode-script-dispatch", file: label, line: kind === "xcode" ? body.line : 1, stage: "hook", ownership: "app-owned", message: `${label} script phase starts a RightKit pipeline run; a hook cannot dispatch another pipeline` });
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
findings.sort((a, b) => (a.file < b.file ? -1 : a.file > b.file ? 1 : a.line - b.line));
|
|
278
|
+
return { schema: PIPELINE_SCAN_SCHEMA, app: config?.app ?? path.basename(root), root, findings, inventory, scanned: visited.size };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function formatScan(report) {
|
|
282
|
+
const lines = [`M24 pipeline scan: ${report.app} (${report.root}) - ${report.findings.length} blocking finding(s), ${report.inventory.length} inventoried entrypoint(s)`];
|
|
283
|
+
for (const finding of report.findings) lines.push(` ${finding.file}:${finding.line} [${finding.code}] ${finding.stage} (${finding.ownership}) ${finding.message}`);
|
|
284
|
+
return `${lines.join("\n")}\n`;
|
|
285
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
// M24 R3: the build, sign, notarize, package and upload tool inventory, and the text scanner that finds those tools in a pipeline
|
|
2
|
+
// context (an app script, a package hook, a workflow step, a framework hook, a build.rs). Pure functions: no file access.
|
|
3
|
+
//
|
|
4
|
+
// Scope (Astra, M24 R3): the tools below are tracked when a pipeline context or a helper reachable from one runs them. Read-only
|
|
5
|
+
// forms (`gh release view`, `codesign --verify`, `signtool verify`, `stapler validate`, `aws s3 ls`, ...) are not an upload or a
|
|
6
|
+
// signature and are never reported. This is a static text scan: it proves nothing about what a native tool does, and ordinary
|
|
7
|
+
// product runtime code (src/, crates) is never scanned by it, so there is no blanket ban on a product that happens to sign
|
|
8
|
+
// or package something at runtime.
|
|
9
|
+
|
|
10
|
+
/** The tool ids R3 inventories, with the pipeline stage each one acts in. */
|
|
11
|
+
export const PIPELINE_TOOL_IDS = Object.freeze([
|
|
12
|
+
"codesign", "notarytool", "altool", "stapler", "signtool", "azuresigntool", "productbuild", "pkgbuild", "hdiutil", "create-dmg",
|
|
13
|
+
"makensis", "iscc", "wix", "wrangler", "rclone", "aws-s3", "gh-release", "cargo-publish", "npm-publish", "pnpm-publish",
|
|
14
|
+
"tauri-build", "xcode-archive",
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
const READ_ONLY = (list) => (rest) => list.includes(String(rest[0] ?? "").toLowerCase());
|
|
18
|
+
|
|
19
|
+
/** name (lowercased basename without a script or exe extension) -> classifier of the remaining argument tokens. */
|
|
20
|
+
const RULES = [
|
|
21
|
+
{ id: "codesign", stage: "sign", names: ["codesign"], flagged: (rest) => {
|
|
22
|
+
const flags = rest.filter((arg) => arg.startsWith("-"));
|
|
23
|
+
const signs = flags.some((arg) => /^(?:-s|--sign|-f|--force|--remove-signature)$/.test(arg));
|
|
24
|
+
const readsOnly = flags.some((arg) => /^(?:--verify|-v+|-d+v*|--display|--test-requirement|-r+-?|-h|--help)$/.test(arg));
|
|
25
|
+
return signs || !readsOnly;
|
|
26
|
+
} },
|
|
27
|
+
{ id: "notarytool", stage: "notarize", names: ["notarytool"], flagged: (rest) => !READ_ONLY(["info", "log", "history", "--help", "--version", "help"])(rest) },
|
|
28
|
+
{ id: "altool", stage: "notarize", names: ["altool"], flagged: () => true },
|
|
29
|
+
{ id: "stapler", stage: "notarize", names: ["stapler"], flagged: (rest) => !READ_ONLY(["validate", "--help", "help"])(rest) },
|
|
30
|
+
{ id: "signtool", stage: "sign", names: ["signtool"], flagged: (rest) => !READ_ONLY(["verify", "/?", "-?", "--help"])(rest) },
|
|
31
|
+
{ id: "azuresigntool", stage: "sign", names: ["azuresigntool", "trusted-signing-cli"], flagged: () => true },
|
|
32
|
+
{ id: "productbuild", stage: "package", names: ["productbuild", "productsign"], flagged: () => true },
|
|
33
|
+
{ id: "pkgbuild", stage: "package", names: ["pkgbuild"], flagged: () => true },
|
|
34
|
+
{ id: "hdiutil", stage: "package", names: ["hdiutil"], flagged: (rest) => READ_ONLY(["create", "convert", "makehybrid", "resize", "compact", "burn"])(rest) },
|
|
35
|
+
{ id: "create-dmg", stage: "package", names: ["create-dmg"], flagged: () => true },
|
|
36
|
+
{ id: "makensis", stage: "package", names: ["makensis"], flagged: () => true },
|
|
37
|
+
{ id: "iscc", stage: "package", names: ["iscc"], flagged: () => true },
|
|
38
|
+
{ id: "wix", stage: "package", names: ["candle"], flagged: () => true },
|
|
39
|
+
{ id: "wix", stage: "package", names: ["wix"], flagged: (rest) => READ_ONLY(["build", "msi", "burn", "convert", "extension"])(rest) },
|
|
40
|
+
{ id: "wix", stage: "package", names: ["light"], flagged: (rest) => rest.some((arg) => /\.(?:wixobj|wxs|wixlib)$/i.test(arg) || /^-out$/i.test(arg)) },
|
|
41
|
+
{ id: "wrangler", stage: "upload", names: ["wrangler"], flagged: (rest) => !READ_ONLY(["--version", "-v", "whoami", "--help", "-h"])(rest) },
|
|
42
|
+
{ id: "rclone", stage: "upload", names: ["rclone"], flagged: (rest) => !READ_ONLY(["ls", "lsf", "lsd", "lsl", "lsjson", "size", "check", "version", "listremotes", "about", "cat", "--version", "--help"])(rest) },
|
|
43
|
+
{ id: "aws-s3", stage: "upload", names: ["aws"], flagged: (rest) => (rest[0] === "s3" && !["ls", "help"].includes(String(rest[1] ?? "")))
|
|
44
|
+
|| (rest[0] === "s3api" && /^(?:put-|create-multipart-upload|upload-part|complete-multipart-upload|copy-object|delete-)/.test(String(rest[1] ?? ""))) },
|
|
45
|
+
{ id: "gh-release", stage: "upload", names: ["gh"], flagged: (rest) => rest[0] === "release" && ["create", "upload", "edit", "delete", "delete-asset"].includes(String(rest[1] ?? "")) },
|
|
46
|
+
{ id: "cargo-publish", stage: "upload", names: ["cargo"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "tauri" && ["build", "bundle", "signer"].includes(String(rest[1] ?? ""))) },
|
|
47
|
+
{ id: "tauri-build", stage: "build", names: ["cargo-tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
|
|
48
|
+
{ id: "npm-publish", stage: "upload", names: ["npm"], flagged: (rest) => rest[0] === "publish" },
|
|
49
|
+
{ id: "pnpm-publish", stage: "upload", names: ["pnpm"], flagged: (rest) => rest[0] === "publish" },
|
|
50
|
+
{ id: "npm-publish", stage: "upload", names: ["yarn"], flagged: (rest) => rest[0] === "publish" || (rest[0] === "npm" && rest[1] === "publish") },
|
|
51
|
+
{ id: "tauri-build", stage: "build", names: ["tauri"], flagged: (rest) => ["build", "bundle", "signer"].includes(String(rest[0] ?? "")) },
|
|
52
|
+
{ id: "xcode-archive", stage: "package", names: ["xcodebuild"], flagged: (rest) => rest.some((arg) => /^(?:archive|-exportArchive|-archivePath|-exportOptionsPlist)$/.test(arg)) },
|
|
53
|
+
];
|
|
54
|
+
|
|
55
|
+
/** `rightkit cargo publish` reaches the same crates.io upload through the managed client. */
|
|
56
|
+
const MANAGED_CLIENT_RULE = { id: "cargo-publish", stage: "upload" };
|
|
57
|
+
|
|
58
|
+
/** Wrapper words that run another command: skipped so the tool behind them is the one classified. */
|
|
59
|
+
const WRAPPERS = new Set(["sudo", "env", "time", "command", "exec", "call", "start", "nohup", "xcrun", "npx", "corepack", "pwsh", "powershell", "bash", "sh", "zsh", "cmd", "node", "python", "python3", "py"]);
|
|
60
|
+
|
|
61
|
+
const stripQuotes = (token) => String(token).replace(/^[\s"'`@&]+|[\s"'`,;]+$/g, "");
|
|
62
|
+
export const commandBaseName = (token) => stripQuotes(token).split(/[\\/]/).pop().toLowerCase().replace(/\.(?:exe|cmd|bat|ps1|sh|com|command)$/i, "");
|
|
63
|
+
|
|
64
|
+
/** Split one script line into command segments at the shell operators. */
|
|
65
|
+
export function splitSegments(line) {
|
|
66
|
+
return String(line).split(/&&|\|\||\$\(|[;|&`()]/).map((part) => part.trim()).filter(Boolean);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Tokens of one segment after the leading VAR=value assignments and wrapper words. */
|
|
70
|
+
export function segmentTokens(segment) {
|
|
71
|
+
let tokens = segment.match(/"[^"]*"|'[^']*'|\S+/g) ?? [];
|
|
72
|
+
tokens = tokens.map((token) => token.replace(/^["']|["']$/g, ""));
|
|
73
|
+
while (tokens.length && /^[A-Za-z_][A-Za-z0-9_]*=/.test(tokens[0])) tokens = tokens.slice(1);
|
|
74
|
+
for (let guard = 0; guard < 8 && tokens.length; guard += 1) {
|
|
75
|
+
const name = commandBaseName(tokens[0]);
|
|
76
|
+
if (name === "pnpm" && (tokens[1] === "exec" || tokens[1] === "dlx")) { tokens = tokens.slice(2); continue; }
|
|
77
|
+
if (name === "npm" && tokens[1] === "exec") { const dash = tokens.indexOf("--"); tokens = dash >= 0 ? tokens.slice(dash + 1) : tokens.slice(2); continue; }
|
|
78
|
+
if (WRAPPERS.has(name) && tokens.length > 1) {
|
|
79
|
+
// Interpreters and shells run a script, not another tool, unless they take -c/-Command text: keep scanning the rest.
|
|
80
|
+
tokens = tokens.slice(1).filter((token, index) => !(index === 0 && /^-+(?:c|command|noprofile|nologo|noninteractive|executionpolicy|file|f|e|s)$/i.test(token)));
|
|
81
|
+
// `bash -c "tool args"` and `powershell -Command "tool args"`: the quoted text is the command line.
|
|
82
|
+
if (tokens.length && /\s/.test(tokens[0])) tokens = [...tokens[0].trim().split(/\s+/), ...tokens.slice(1)];
|
|
83
|
+
continue;
|
|
84
|
+
}
|
|
85
|
+
break;
|
|
86
|
+
}
|
|
87
|
+
return tokens;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/** Classify one command's tokens against the R3 inventory: `{ id, stage, tool }` for a pipeline tool run in a write form, else null. */
|
|
91
|
+
export function classifyTokens(tokens) {
|
|
92
|
+
if (!tokens.length) return null;
|
|
93
|
+
const name = commandBaseName(tokens[0]);
|
|
94
|
+
const rest = tokens.slice(1);
|
|
95
|
+
if (name === "rightkit" && rest[0] === "cargo" && rest[1] === "publish") return { ...MANAGED_CLIENT_RULE, tool: "rightkit cargo publish" };
|
|
96
|
+
for (const rule of RULES) {
|
|
97
|
+
if (rule.names.includes(name) && rule.flagged(rest)) return { id: rule.id, stage: rule.stage, tool: [name, ...rest.slice(0, 2)].join(" ").trim() };
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/** True when the tokens run RightKit's own pipeline CLI or managed client (a generator must never dispatch another pipeline). */
|
|
103
|
+
export function dispatchesRightKit(tokens) {
|
|
104
|
+
if (!tokens.length) return false;
|
|
105
|
+
const name = commandBaseName(tokens[0]);
|
|
106
|
+
if (name === "rightkit" || name === "right-release") return true;
|
|
107
|
+
return tokens.some((token) => /(?:^|[\\/])(?:@rightkit[\\/]release[\\/]cli[\\/]right-release|right-release)(?:\.mjs)?$/i.test(token));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
const YAML_RUN_PREFIX = /^\s*(?:-\s+)?(?:run|script|command)\s*:\s*[|>]?[-+]?\s*/;
|
|
111
|
+
const COMMENT_LINE = /^\s*(?:#|\/\/|::|rem\s|@rem\s)/i;
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Pipeline tool matches in the text of a shell-like source (a script, a hook, a workflow). `kind`:
|
|
115
|
+
* - "shell": every line is read as a command line (sh, ps1, bat, cmd, command, yml run steps, a package.json script value);
|
|
116
|
+
* - "code": quoted tool names in a js/mjs/py/rs source (`spawnSync("codesign", ...)`), qualified by the next literals.
|
|
117
|
+
* Returns `[{ line, id, stage, tool, text }]` (1-based lines).
|
|
118
|
+
*/
|
|
119
|
+
export function scanTextForPipelineTools(text, { kind = "shell" } = {}) {
|
|
120
|
+
const found = [];
|
|
121
|
+
const lines = String(text).split(/\r?\n/);
|
|
122
|
+
if (kind === "code") {
|
|
123
|
+
const names = new Map();
|
|
124
|
+
for (const rule of RULES) for (const name of rule.names) names.set(name, rule);
|
|
125
|
+
lines.forEach((line, index) => {
|
|
126
|
+
if (COMMENT_LINE.test(line)) return;
|
|
127
|
+
for (const literal of line.matchAll(/["'`]([^"'`\n]{1,200})["'`]/g)) {
|
|
128
|
+
const tokens = literal[1].trim().split(/\s+/);
|
|
129
|
+
const first = commandBaseName(tokens[0]);
|
|
130
|
+
const rule = names.get(first) ?? (first === "rightkit" ? MANAGED_CLIENT_RULE : null);
|
|
131
|
+
if (!rule) continue;
|
|
132
|
+
// The arguments are the rest of this literal, then the next few literals of the same call (an argv array).
|
|
133
|
+
const tail = line.slice(literal.index + literal[0].length, literal.index + literal[0].length + 240);
|
|
134
|
+
const following = [...tail.matchAll(/["'`]([^"'`\n]{0,120})["'`]/g)].map((match) => match[1]);
|
|
135
|
+
const rest = [...tokens.slice(1), ...following].map((arg) => arg.trim()).filter(Boolean);
|
|
136
|
+
const hit = first === "rightkit" ? classifyTokens([first, ...rest]) : rule.flagged(rest) ? { id: rule.id, stage: rule.stage, tool: first } : null;
|
|
137
|
+
if (hit) found.push({ line: index + 1, ...hit, text: line.trim().slice(0, 160) });
|
|
138
|
+
}
|
|
139
|
+
});
|
|
140
|
+
return found;
|
|
141
|
+
}
|
|
142
|
+
lines.forEach((line, index) => {
|
|
143
|
+
if (COMMENT_LINE.test(line)) return;
|
|
144
|
+
const body = line.replace(YAML_RUN_PREFIX, "");
|
|
145
|
+
for (const segment of splitSegments(body)) {
|
|
146
|
+
const hit = classifyTokens(segmentTokens(segment));
|
|
147
|
+
if (hit) found.push({ line: index + 1, ...hit, text: line.trim().slice(0, 160) });
|
|
148
|
+
}
|
|
149
|
+
});
|
|
150
|
+
return found;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** GitHub Actions that build, sign or upload on their own (R3: the same tools behind an action). */
|
|
154
|
+
export const PIPELINE_ACTIONS = Object.freeze([
|
|
155
|
+
{ pattern: /^tauri-apps\/tauri-action(?:@|$)/i, id: "tauri-build", stage: "build" },
|
|
156
|
+
{ pattern: /^apple-actions\//i, id: "codesign", stage: "sign" },
|
|
157
|
+
{ pattern: /^azure\/(?:trusted-signing-action|artifact-signing-action)(?:@|$)/i, id: "azuresigntool", stage: "sign" },
|
|
158
|
+
{ pattern: /^cloudflare\/wrangler-action(?:@|$)/i, id: "wrangler", stage: "upload" },
|
|
159
|
+
{ pattern: /^softprops\/action-gh-release(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
160
|
+
{ pattern: /^ncipollo\/release-action(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
161
|
+
{ pattern: /^svenstaro\/upload-release-action(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
162
|
+
{ pattern: /^actions\/upload-release-asset(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
163
|
+
{ pattern: /^shogo82148\/actions-upload-release-asset(?:@|$)/i, id: "gh-release", stage: "upload" },
|
|
164
|
+
{ pattern: /^jakejarvis\/s3-sync-action(?:@|$)/i, id: "aws-s3", stage: "upload" },
|
|
165
|
+
]);
|
|
166
|
+
|
|
167
|
+
export function classifyAction(uses) {
|
|
168
|
+
const value = String(uses).trim().replace(/^["']|["']$/g, "");
|
|
169
|
+
for (const entry of PIPELINE_ACTIONS) if (entry.pattern.test(value)) return { id: entry.id, stage: entry.stage, tool: value };
|
|
170
|
+
return null;
|
|
171
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// The release frontend build always resolves in production mode (M12: `@rightkit/license/testing` and
|
|
2
|
+
// other development-only conditional exports must not resolve into a release bundle). Release build
|
|
3
|
+
// children get NODE_ENV=production, which Vite's build honours ahead of a `.env` NODE_ENV and of any
|
|
4
|
+
// custom `--mode` (vite.dev/guide/env-and-mode, "NODE_ENV and Modes"). What the pin cannot override is
|
|
5
|
+
// a config that lists the `development` resolve condition explicitly, so the preflight refuses that.
|
|
6
|
+
// Package installs keep the caller's NODE_ENV (production would drop devDependencies).
|
|
7
|
+
import { existsSync, readdirSync, readFileSync, statSync } from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
|
|
10
|
+
const SKIP_DIRS = new Set(["node_modules", "target", "dist", ".git", "src-tauri", "build", "out"]);
|
|
11
|
+
const VITE_CONFIG = /^vite\.config\.(?:[cm]?[jt]s)$/;
|
|
12
|
+
|
|
13
|
+
export function productionBuildEnv(env) {
|
|
14
|
+
return { ...env, NODE_ENV: "production" };
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
function walk(dir, depth, visit) {
|
|
18
|
+
let names;
|
|
19
|
+
try { names = readdirSync(dir); } catch { return; }
|
|
20
|
+
for (const name of names) {
|
|
21
|
+
const full = path.join(dir, name);
|
|
22
|
+
let stat;
|
|
23
|
+
try { stat = statSync(full); } catch { continue; }
|
|
24
|
+
if (stat.isDirectory()) {
|
|
25
|
+
if (depth > 0 && !SKIP_DIRS.has(name) && !name.startsWith(".")) walk(full, depth - 1, visit);
|
|
26
|
+
} else visit(name, full);
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Vite configs that would resolve development-only exports even in a production build. A text scan:
|
|
31
|
+
* a computed conditions array is not seen (the post-build web-bundle marker scan is the backstop). */
|
|
32
|
+
export function productionModeProblems({ appRoot, depth = 3 }) {
|
|
33
|
+
const problems = [];
|
|
34
|
+
if (!appRoot || !existsSync(appRoot)) return problems;
|
|
35
|
+
walk(appRoot, depth, (name, full) => {
|
|
36
|
+
if (!VITE_CONFIG.test(name)) return;
|
|
37
|
+
if (/conditions\s*:\s*\[[^\]]*["'`]development["'`]/.test(readFileSync(full, "utf8"))) {
|
|
38
|
+
problems.push(`${path.relative(appRoot, full)} lists a "development" resolve condition; a release build must not resolve development-only exports`);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
return problems;
|
|
42
|
+
}
|