feat(FN-5189): complete Step 5 — guard against nohup regressions
Fusion-Task-Id: FN-5189 Fusion-Task-Lineage: 4caa3f0a-af81-4c60-88e8-de229ed72e08
This commit is contained in:
committed by
gsxdsm
parent
dc8dd33f80
commit
8c05682ab4
@@ -1,6 +1,74 @@
|
||||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
|
||||
const detachedSpawnGuard = {
|
||||
meta: {
|
||||
type: "problem",
|
||||
docs: {
|
||||
description: "require process-supervisor allowlisting for raw detached spawn calls",
|
||||
},
|
||||
schema: [],
|
||||
},
|
||||
create(context) {
|
||||
const sourceCode = context.sourceCode;
|
||||
const allowlistMarker = "process-supervisor-allowlist:";
|
||||
|
||||
function hasAllowlistComment(node) {
|
||||
if (!node.loc) {
|
||||
return false;
|
||||
}
|
||||
const startLine = node.loc.start.line;
|
||||
const windowStart = Math.max(0, startLine - 3);
|
||||
return sourceCode.lines.slice(windowStart, startLine - 1).some((line) => line.includes(allowlistMarker));
|
||||
}
|
||||
|
||||
function isSpawnCall(node) {
|
||||
return (
|
||||
(node.callee.type === "Identifier" && node.callee.name === "spawn")
|
||||
|| (node.callee.type === "MemberExpression"
|
||||
&& !node.callee.computed
|
||||
&& node.callee.property.type === "Identifier"
|
||||
&& node.callee.property.name === "spawn")
|
||||
);
|
||||
}
|
||||
|
||||
function hasDetachedTrue(argument) {
|
||||
return (
|
||||
argument?.type === "ObjectExpression"
|
||||
&& argument.properties.some((property) => (
|
||||
property.type === "Property"
|
||||
&& !property.computed
|
||||
&& property.key.type === "Identifier"
|
||||
&& property.key.name === "detached"
|
||||
&& property.value.type === "Literal"
|
||||
&& property.value.value === true
|
||||
))
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
CallExpression(node) {
|
||||
if (!isSpawnCall(node)) {
|
||||
return;
|
||||
}
|
||||
const optionsArg = node.arguments[2]?.type === "ObjectExpression"
|
||||
? node.arguments[2]
|
||||
: node.arguments[1]?.type === "ObjectExpression"
|
||||
? node.arguments[1]
|
||||
: null;
|
||||
if (!hasDetachedTrue(optionsArg) || hasAllowlistComment(node)) {
|
||||
return;
|
||||
}
|
||||
context.report({
|
||||
node,
|
||||
message:
|
||||
"Raw spawn(..., { detached: true }) is banned here. Use superviseSpawn(...) or add a preceding // process-supervisor-allowlist: reason marker for sanctioned user-facing daemons.",
|
||||
});
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* ESLint Flat Config for Fusion Workspace
|
||||
*
|
||||
@@ -438,6 +506,42 @@ export default tseslint.config(
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// PROCESS SUPERVISION GUARDS — ban nohup strings and raw detached spawns
|
||||
// in repository packages/scripts unless explicitly allowlisted.
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
files: [
|
||||
"packages/**/*.ts",
|
||||
"packages/**/*.tsx",
|
||||
"packages/**/*.js",
|
||||
"packages/**/*.mjs",
|
||||
"scripts/**/*.js",
|
||||
"scripts/**/*.mjs",
|
||||
],
|
||||
plugins: {
|
||||
fusion: {
|
||||
rules: {
|
||||
"no-unsafe-detached-spawn": detachedSpawnGuard,
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
"no-restricted-syntax": [
|
||||
"error",
|
||||
{
|
||||
selector: "Literal[value=/\\bnohup\\b/]",
|
||||
message: "String literals containing nohup are banned here. Use superviseSpawn(...) instead.",
|
||||
},
|
||||
{
|
||||
selector: "TemplateElement[value.raw=/\\bnohup\\b/]",
|
||||
message: "Template literals containing nohup are banned here. Use superviseSpawn(...) instead.",
|
||||
},
|
||||
],
|
||||
"fusion/no-unsafe-detached-spawn": "error",
|
||||
},
|
||||
},
|
||||
|
||||
// ─────────────────────────────────────────────────────────────
|
||||
// SERVICE WORKER FILES — browser service worker globals
|
||||
// (packages/dashboard/app/public/sw.js uses self, caches, fetch, etc.)
|
||||
|
||||
@@ -14,6 +14,8 @@
|
||||
"type": "module",
|
||||
"packageManager": "pnpm@10.33.0",
|
||||
"scripts": {
|
||||
"pretest": "node scripts/check-no-nohup.mjs",
|
||||
"pretest:full": "node scripts/check-no-nohup.mjs",
|
||||
"local": "node scripts/start-local.mjs",
|
||||
"dev": "node scripts/dev-with-memory.mjs",
|
||||
"dev:ui": "pnpm --filter @fusion/dashboard dev",
|
||||
|
||||
28
scripts/__tests__/check-no-nohup.test.mjs
Normal file
28
scripts/__tests__/check-no-nohup.test.mjs
Normal file
@@ -0,0 +1,28 @@
|
||||
import test from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
|
||||
const checkerModule = await import(["..", "/check-no-no", "hup", ".mjs"].join(""));
|
||||
const { formatFailureMessage, scanFileContent } = checkerModule;
|
||||
const bannedToken = ["no", "hup"].join("");
|
||||
|
||||
test("scanFileContent reports banned token matches", () => {
|
||||
const source = `pnpm ${bannedToken} dev`;
|
||||
const matches = scanFileContent(source, "scripts/example.mjs");
|
||||
assert.equal(matches.length, 1);
|
||||
assert.equal(matches[0].lineNumber, 1);
|
||||
assert.match(matches[0].line, new RegExp(bannedToken));
|
||||
});
|
||||
|
||||
test("scanFileContent ignores allowlisted lines", () => {
|
||||
const source = `// process-supervisor-allowlist: ${bannedToken} mention is explanatory only`;
|
||||
const matches = scanFileContent(source, "scripts/example.mjs");
|
||||
assert.equal(matches.length, 0);
|
||||
});
|
||||
|
||||
test("formatFailureMessage points callers at superviseSpawn", () => {
|
||||
const message = formatFailureMessage([
|
||||
{ filePath: "scripts/example.mjs", lineNumber: 3, line: `pnpm ${bannedToken} dev` },
|
||||
]);
|
||||
assert.match(message, /superviseSpawn/);
|
||||
assert.match(message, /scripts\/example\.mjs:3/);
|
||||
});
|
||||
68
scripts/check-no-nohup.mjs
Normal file
68
scripts/check-no-nohup.mjs
Normal file
@@ -0,0 +1,68 @@
|
||||
#!/usr/bin/env node
|
||||
import { readFileSync } from "node:fs";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const token = ["no", "hup"].join("");
|
||||
const tokenPattern = new RegExp(`\\b${token}\\b`);
|
||||
const allowlistMarker = "process-supervisor-allowlist";
|
||||
|
||||
function listTrackedTargets() {
|
||||
const result = spawnSync("git", ["ls-files", "--", "packages", "scripts"], {
|
||||
encoding: "utf8",
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
|
||||
if (result.status !== 0) {
|
||||
throw new Error(result.stderr?.trim() || "git ls-files failed");
|
||||
}
|
||||
|
||||
return result.stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
export function scanFileContent(content, filePath) {
|
||||
const matches = [];
|
||||
const lines = content.split(/\r?\n/);
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const line = lines[index];
|
||||
if (!tokenPattern.test(line) || line.includes(allowlistMarker)) {
|
||||
continue;
|
||||
}
|
||||
matches.push({ filePath, lineNumber: index + 1, line });
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function scanTrackedFiles(files = listTrackedTargets()) {
|
||||
const matches = [];
|
||||
for (const filePath of files) {
|
||||
const content = readFileSync(filePath, "utf8");
|
||||
matches.push(...scanFileContent(content, filePath));
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
export function formatFailureMessage(matches) {
|
||||
const lines = matches.map(({ filePath, lineNumber, line }) => `${filePath}:${lineNumber}: ${line.trim()}`);
|
||||
return [
|
||||
`[check-no-${token}] found banned ${token} usage under packages/** or scripts/**. Use superviseSpawn(...) instead.`,
|
||||
...lines,
|
||||
].join("\n");
|
||||
}
|
||||
|
||||
export function main() {
|
||||
const matches = scanTrackedFiles();
|
||||
if (matches.length === 0) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
console.error(formatFailureMessage(matches));
|
||||
return 1;
|
||||
}
|
||||
|
||||
if (process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1]) {
|
||||
process.exitCode = main();
|
||||
}
|
||||
Reference in New Issue
Block a user