@cosmicdrift/kumiko-guards 0.401.0 → 0.402.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -4
- package/src/_lib/agent-docs-boot-rules.ts +142 -0
- package/src/_lib/agent-docs.ts +2 -0
- package/src/changes.json +6 -0
- package/src/guard-boot-raw-throws.ts +101 -0
- package/src/run-guards.ts +2 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.402.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -36,7 +36,7 @@
|
|
|
36
36
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
|
-
"@cosmicdrift/kumiko-types": "0.
|
|
39
|
+
"@cosmicdrift/kumiko-types": "0.402.0",
|
|
40
40
|
"ts-morph": "^28.0.0",
|
|
41
41
|
"zod": "^4.6.7"
|
|
42
42
|
},
|
|
@@ -52,7 +52,7 @@
|
|
|
52
52
|
"LICENSE"
|
|
53
53
|
],
|
|
54
54
|
"peerDependencies": {
|
|
55
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
55
|
+
"@cosmicdrift/kumiko-framework": "0.402.0"
|
|
56
56
|
},
|
|
57
57
|
"peerDependenciesMeta": {
|
|
58
58
|
"@cosmicdrift/kumiko-framework": {
|
|
@@ -60,6 +60,6 @@
|
|
|
60
60
|
}
|
|
61
61
|
},
|
|
62
62
|
"devDependencies": {
|
|
63
|
-
"@cosmicdrift/kumiko-framework": "0.
|
|
63
|
+
"@cosmicdrift/kumiko-framework": "0.402.0"
|
|
64
64
|
}
|
|
65
65
|
}
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { relative } from "node:path";
|
|
2
|
+
import { Glob } from "bun";
|
|
3
|
+
import { Node, type ObjectLiteralExpression, type Project } from "ts-morph";
|
|
4
|
+
import { compareCodepoint, finishFile, table } from "./agent-docs-shared";
|
|
5
|
+
|
|
6
|
+
const RULE_FILE_GLOBS = [
|
|
7
|
+
"packages/framework/src/engine/boot-validator/*-rules.ts",
|
|
8
|
+
"packages/bundled-features/src/*/boot-rules.ts",
|
|
9
|
+
];
|
|
10
|
+
const RULE_TABLE_TYPE = "BootRuleTable";
|
|
11
|
+
const RULE_ID_RE = /^[a-z0-9]+(-[a-z0-9]+)*\/[a-z0-9]+(-[a-z0-9]+)*$/;
|
|
12
|
+
const RULE_FIELDS = ["id", "summary", "fix"] as const;
|
|
13
|
+
|
|
14
|
+
type DocumentedRule = {
|
|
15
|
+
readonly id: string;
|
|
16
|
+
readonly summary: string;
|
|
17
|
+
readonly fix: string;
|
|
18
|
+
readonly source: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
// Must match bootRuleDocLink in packages/framework/src/engine/boot-validator/boot-rule.ts.
|
|
22
|
+
function anchorOf(ruleId: string): string {
|
|
23
|
+
return ruleId.replaceAll("/", "-");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function literalText(node: Node | undefined): string | undefined {
|
|
27
|
+
if (node === undefined) return undefined;
|
|
28
|
+
if (Node.isStringLiteral(node) || Node.isNoSubstitutionTemplateLiteral(node)) {
|
|
29
|
+
return node.getLiteralText();
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readRule(source: string, key: string, rule: Node): DocumentedRule {
|
|
35
|
+
if (!Node.isObjectLiteralExpression(rule)) {
|
|
36
|
+
throw new Error(`${source}: rule "${key}" must be an object literal`);
|
|
37
|
+
}
|
|
38
|
+
const values = RULE_FIELDS.map((field) => {
|
|
39
|
+
const property = rule.getProperty(field);
|
|
40
|
+
const text = Node.isPropertyAssignment(property)
|
|
41
|
+
? literalText(property.getInitializer())
|
|
42
|
+
: undefined;
|
|
43
|
+
if (text === undefined) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
`${source}: rule "${key}" property "${field}" must be a plain string literal`,
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
return text;
|
|
49
|
+
});
|
|
50
|
+
const [id = "", summary = "", fix = ""] = values;
|
|
51
|
+
if (!RULE_ID_RE.test(id)) {
|
|
52
|
+
throw new Error(`${source}: rule "${key}" has id "${id}" which does not match ${RULE_ID_RE}`);
|
|
53
|
+
}
|
|
54
|
+
return { id, summary, fix, source };
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function tableLiteralOf(initializer: Node | undefined): ObjectLiteralExpression | undefined {
|
|
58
|
+
if (initializer === undefined || !Node.isSatisfiesExpression(initializer)) return undefined;
|
|
59
|
+
if (initializer.getTypeNode()?.getText() !== RULE_TABLE_TYPE) return undefined;
|
|
60
|
+
const inner = initializer.getExpression();
|
|
61
|
+
const unwrapped = Node.isAsExpression(inner) ? inner.getExpression() : inner;
|
|
62
|
+
return Node.isObjectLiteralExpression(unwrapped) ? unwrapped : undefined;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function readRulesOfFile(project: Project, repoRoot: string, absPath: string): DocumentedRule[] {
|
|
66
|
+
const source = relative(repoRoot, absPath).split("\\").join("/");
|
|
67
|
+
const rules: DocumentedRule[] = [];
|
|
68
|
+
for (const declaration of project.addSourceFileAtPath(absPath).getVariableDeclarations()) {
|
|
69
|
+
if (!declaration.isExported()) continue;
|
|
70
|
+
const literal = tableLiteralOf(declaration.getInitializer());
|
|
71
|
+
if (literal === undefined) continue;
|
|
72
|
+
for (const property of literal.getProperties()) {
|
|
73
|
+
if (!Node.isPropertyAssignment(property)) {
|
|
74
|
+
throw new Error(`${source}: ${declaration.getName()} may only hold plain properties`);
|
|
75
|
+
}
|
|
76
|
+
const initializer = property.getInitializer();
|
|
77
|
+
if (initializer === undefined) continue;
|
|
78
|
+
rules.push(readRule(source, property.getName(), initializer));
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
return rules;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function ruleFilesOf(repoRoot: string): string[] {
|
|
85
|
+
const found = new Set<string>();
|
|
86
|
+
for (const pattern of RULE_FILE_GLOBS) {
|
|
87
|
+
for (const rel of new Glob(pattern).scanSync({ cwd: repoRoot, onlyFiles: true })) {
|
|
88
|
+
found.add(`${repoRoot}/${rel}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return [...found].sort(compareCodepoint);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function assertUnique(rules: readonly DocumentedRule[]): void {
|
|
95
|
+
const idSources = new Map<string, string>();
|
|
96
|
+
const anchorIds = new Map<string, string>();
|
|
97
|
+
for (const rule of rules) {
|
|
98
|
+
const knownSource = idSources.get(rule.id);
|
|
99
|
+
if (knownSource !== undefined) {
|
|
100
|
+
throw new Error(`Duplicate boot rule id "${rule.id}" in ${rule.source} and ${knownSource}`);
|
|
101
|
+
}
|
|
102
|
+
idSources.set(rule.id, rule.source);
|
|
103
|
+
const anchor = anchorOf(rule.id);
|
|
104
|
+
const knownId = anchorIds.get(anchor);
|
|
105
|
+
if (knownId !== undefined) {
|
|
106
|
+
throw new Error(`Boot rule ids "${rule.id}" and "${knownId}" share the anchor "${anchor}"`);
|
|
107
|
+
}
|
|
108
|
+
anchorIds.set(anchor, rule.id);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Ids are validated against RULE_ID_RE, so the anchor cannot carry markup.
|
|
113
|
+
function ruleCell(ruleId: string): string {
|
|
114
|
+
return ['<a id="', anchorOf(ruleId), '"></a>`', ruleId, "`"].join("");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const INTRO = [
|
|
118
|
+
"`validateBoot` reports every violation as `- [<id>] <message>`, followed by a `Fix:` and a `Rule:` line that links the entry below.",
|
|
119
|
+
"Violations with an `unclassified/<check>` id have no entry yet.",
|
|
120
|
+
"`kumiko-schema validate --json` returns the same ids.",
|
|
121
|
+
].join(" ");
|
|
122
|
+
|
|
123
|
+
export function renderBootRules(repoRoot: string, project: Project): string {
|
|
124
|
+
const rules = ruleFilesOf(repoRoot).flatMap((file) => readRulesOfFile(project, repoRoot, file));
|
|
125
|
+
assertUnique(rules);
|
|
126
|
+
const source = "boot rule tables (`*-rules.ts`, `boot-rules.ts`)";
|
|
127
|
+
if (rules.length === 0) {
|
|
128
|
+
return finishFile(source, `# Boot rules\n\n${INTRO}\n\nNo boot rules are registered yet.`);
|
|
129
|
+
}
|
|
130
|
+
const byArea = new Map<string, DocumentedRule[]>();
|
|
131
|
+
for (const rule of rules) {
|
|
132
|
+
const area = rule.id.split("/", 1)[0] ?? rule.id;
|
|
133
|
+
byArea.set(area, [...(byArea.get(area) ?? []), rule]);
|
|
134
|
+
}
|
|
135
|
+
const sections = [...byArea.keys()].sort(compareCodepoint).map((area) => {
|
|
136
|
+
const rows = (byArea.get(area) ?? [])
|
|
137
|
+
.sort((left, right) => compareCodepoint(left.id, right.id))
|
|
138
|
+
.map((rule) => [ruleCell(rule.id), rule.summary, rule.fix, `\`${rule.source}\``]);
|
|
139
|
+
return `## ${area}\n\n${table(["Rule", "Summary", "Fix", "Source"], rows)}`;
|
|
140
|
+
});
|
|
141
|
+
return finishFile(source, `# Boot rules\n\n${INTRO}\n\n${sections.join("\n\n")}`);
|
|
142
|
+
}
|
package/src/_lib/agent-docs.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
import { dirname, join } from "node:path";
|
|
12
12
|
import { type ExportedDeclarations, type JSDocableNode, Node, type Project } from "ts-morph";
|
|
13
13
|
import { renderApiDocs } from "./agent-docs-api";
|
|
14
|
+
import { renderBootRules } from "./agent-docs-boot-rules";
|
|
14
15
|
import { renderFeatureDocs } from "./agent-docs-features";
|
|
15
16
|
import {
|
|
16
17
|
compareCodepoint,
|
|
@@ -197,6 +198,7 @@ export function renderAgentDocs(repoRoot: string): ReadonlyMap<string, string> {
|
|
|
197
198
|
files.set("features-index.md", featureDocs.featuresIndex);
|
|
198
199
|
files.set("features.md", featureDocs.features);
|
|
199
200
|
files.set("widgets.md", renderWidgets(repoRoot, project));
|
|
201
|
+
files.set("boot-rules.md", renderBootRules(repoRoot, project));
|
|
200
202
|
for (const [rel, content] of renderApiDocs(repoRoot, project)) files.set(rel, content);
|
|
201
203
|
files.set("examples.md", renderExamplesIndex(recipes));
|
|
202
204
|
for (const recipe of recipes) files.set(`examples/${recipe.slug}.md`, renderRecipe(recipe));
|
package/src/changes.json
CHANGED
|
@@ -1,4 +1,10 @@
|
|
|
1
1
|
[
|
|
2
|
+
{
|
|
3
|
+
"version": "0.402.0",
|
|
4
|
+
"type": "improvement",
|
|
5
|
+
"title": "New guard boot-raw-throws",
|
|
6
|
+
"detail": "Freezes the count of raw `throw new Error` in `engine/boot-validator` and bundled `boot-checks.ts` per file in `.kumiko-boot-raw-throws-baseline.json`. New boot checks throw `bootRuleError(RULE, { message })` with a rule table. Only the framework repo is scanned."
|
|
7
|
+
},
|
|
2
8
|
{
|
|
3
9
|
"version": "0.396.0",
|
|
4
10
|
"type": "improvement",
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Boot Raw Throws Guard: freezes raw `throw new Error` in boot validation per file, because new boot rules use `throw bootRuleError(RULE, { message })` with a rule table in `<validator>-rules.ts`; the guard and its baseline `.kumiko-boot-raw-throws-baseline.json` are deleted once every file is at 0.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
8
|
+
import {
|
|
9
|
+
type AstGuard,
|
|
10
|
+
baselineRatchet,
|
|
11
|
+
buildSharedProject,
|
|
12
|
+
filesForGuard,
|
|
13
|
+
isLocalFinding,
|
|
14
|
+
relFromRepoRoot,
|
|
15
|
+
runStandalone,
|
|
16
|
+
type ScanSpec,
|
|
17
|
+
} from "./_lib/guard-kit";
|
|
18
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
19
|
+
|
|
20
|
+
const ROOT = process.cwd();
|
|
21
|
+
const GUARD_NAME = "boot-raw-throws";
|
|
22
|
+
|
|
23
|
+
const SCAN: ScanSpec = {
|
|
24
|
+
scope: "source",
|
|
25
|
+
kinds: ["framework"],
|
|
26
|
+
extensions: ["ts"],
|
|
27
|
+
frameworkWithin: [
|
|
28
|
+
"packages/framework/src/engine/boot-validator/**",
|
|
29
|
+
"packages/bundled-features/src/*/boot-checks.ts",
|
|
30
|
+
],
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const EXCLUDE = /(__tests__|\.test\.ts$|\.d\.ts$)/;
|
|
34
|
+
|
|
35
|
+
export function rawErrorThrowLines(file: SourceFile): number[] {
|
|
36
|
+
return file
|
|
37
|
+
.getDescendantsOfKind(SyntaxKind.ThrowStatement)
|
|
38
|
+
.filter((statement) => {
|
|
39
|
+
const thrown = statement.getExpression();
|
|
40
|
+
return (
|
|
41
|
+
thrown.isKind(SyntaxKind.NewExpression) && thrown.getExpression().getText() === "Error"
|
|
42
|
+
);
|
|
43
|
+
})
|
|
44
|
+
.map((statement) => statement.getStartLineNumber());
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function rawThrowCounts(
|
|
48
|
+
files: readonly SourceFile[],
|
|
49
|
+
roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
|
|
50
|
+
): { counts: Record<string, number>; firstLine: Record<string, number> } {
|
|
51
|
+
const counts: Record<string, number> = {};
|
|
52
|
+
const firstLine: Record<string, number> = {};
|
|
53
|
+
for (const sf of files) {
|
|
54
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
55
|
+
const lines = rawErrorThrowLines(sf);
|
|
56
|
+
const first = lines[0];
|
|
57
|
+
if (first === undefined) continue;
|
|
58
|
+
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
59
|
+
if (!isLocalFinding({ file })) continue;
|
|
60
|
+
counts[file] = lines.length;
|
|
61
|
+
firstLine[file] = first;
|
|
62
|
+
}
|
|
63
|
+
return { counts, firstLine };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const rawThrowBaseline = baselineRatchet({
|
|
67
|
+
file: path.join(ROOT, ".kumiko-boot-raw-throws-baseline.json"),
|
|
68
|
+
formatVersion: 1,
|
|
69
|
+
unit: "raw throw new Error in boot validation",
|
|
70
|
+
failClosed: true,
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const REMEDIATION =
|
|
74
|
+
"Declare the rule in the validator's `<validator>-rules.ts` table and throw `bootRuleError(RULES.name, { message })` instead of `new Error(...)`.";
|
|
75
|
+
|
|
76
|
+
export const guard: AstGuard = {
|
|
77
|
+
name: GUARD_NAME,
|
|
78
|
+
scan: SCAN,
|
|
79
|
+
hint: `after fixing hits: \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\``,
|
|
80
|
+
run: (files, roots = resolveRepoRoots()) => {
|
|
81
|
+
const { counts, firstLine } = rawThrowCounts(files, roots);
|
|
82
|
+
return {
|
|
83
|
+
violations: rawThrowBaseline.check(counts, REMEDIATION, {
|
|
84
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\` once.`,
|
|
85
|
+
resolveLine: (file) => firstLine[file] ?? 1,
|
|
86
|
+
}),
|
|
87
|
+
};
|
|
88
|
+
},
|
|
89
|
+
writeBaseline: (files, roots = resolveRepoRoots()) => {
|
|
90
|
+
rawThrowBaseline.write(rawThrowCounts(files, roots).counts);
|
|
91
|
+
},
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
if (import.meta.main) {
|
|
95
|
+
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
96
|
+
const project = buildSharedProject([guard]);
|
|
97
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
98
|
+
process.exit(0);
|
|
99
|
+
}
|
|
100
|
+
runStandalone(guard);
|
|
101
|
+
}
|
package/src/run-guards.ts
CHANGED
|
@@ -25,6 +25,7 @@ import { guard as complexity } from "./check-complexity";
|
|
|
25
25
|
import { guard as predicateExtraction } from "./check-predicates";
|
|
26
26
|
import { guard as adminApi } from "./guard-admin-api";
|
|
27
27
|
import { guard as appFeatureStructure } from "./guard-app-feature-structure";
|
|
28
|
+
import { guard as bootRawThrows } from "./guard-boot-raw-throws";
|
|
28
29
|
import { guard as commentIssueRefOnly } from "./guard-comment-issue-ref-only";
|
|
29
30
|
import { guard as crossFeatureImports } from "./guard-cross-feature-imports";
|
|
30
31
|
import { guard as directEntityWrites } from "./guard-direct-entity-writes";
|
|
@@ -96,6 +97,7 @@ export const GUARDS = [
|
|
|
96
97
|
commentIssueRefOnly,
|
|
97
98
|
testTitle,
|
|
98
99
|
ignoreTagReason,
|
|
100
|
+
bootRawThrows,
|
|
99
101
|
];
|
|
100
102
|
|
|
101
103
|
export const GUARD_FLAGS = [
|