@cosmicdrift/kumiko-guards 0.362.0 → 0.364.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/README.md +28 -0
- package/package.json +2 -2
- package/src/_lib/agent-docs.ts +425 -0
- package/src/_lib/bun-lock-packages.ts +1 -1
- package/src/_lib/dependency-cli.ts +27 -0
- package/src/_lib/dependency-exceptions.ts +135 -0
- package/src/_lib/direct-dependencies.ts +80 -0
- package/src/_lib/guard-kit.ts +7 -2
- package/src/_lib/installed-packages.ts +146 -0
- package/src/_lib/npm-registry.ts +168 -0
- package/src/_lib/security-audit.ts +390 -0
- package/src/changes.json +41 -5
- package/src/check-as-casts.ts +93 -47
- package/src/check-complexity.ts +1 -1
- package/src/check-licenses.ts +288 -0
- package/src/check-outdated.ts +234 -0
- package/src/check-runtime-isolation.ts +3 -1
- package/src/check-security.ts +182 -0
- package/src/check-single-runtime-instance.ts +10 -4
- package/src/cli.ts +27 -10
- package/src/dependency-license-exceptions.json +77 -0
- package/src/dependency-security-exceptions.json +4 -0
- package/src/guard-admin-api.ts +3 -3
- package/src/guard-agent-docs-drift.ts +137 -0
- package/src/guard-comment-lang.ts +76 -40
- package/src/guard-direct-entity-writes.ts +3 -0
- package/src/guard-html-escape.ts +2 -2
- package/src/guard-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +22 -9
- package/src/guard-raw-sql.ts +1 -1
- package/src/guard-thin-wrappers.ts +11 -5
- package/src/guard-upgrade-state.ts +55 -31
- package/src/index.ts +1 -0
- package/src/pre-push.sh +19 -0
- package/src/run-repo-checks.ts +2 -0
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Guard: the generated agent docs under `packages/framework/agents/` must
|
|
4
|
+
* match what `bun run gen:agent-docs` renders from the current sources
|
|
5
|
+
* (feature manifest, renderer-web exports, sample recipes), and every
|
|
6
|
+
* `agents/<file>.md` path the hand-written `packages/framework/AGENTS.md`
|
|
7
|
+
* points at must exist in that generated set.
|
|
8
|
+
*
|
|
9
|
+
* Only the framework repo itself has these files; consumer repos are
|
|
10
|
+
* `notApplicable`.
|
|
11
|
+
*
|
|
12
|
+
* Usage:
|
|
13
|
+
* bun guard-agent-docs-drift.ts
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
17
|
+
import { join } from "node:path";
|
|
18
|
+
import { AGENT_DOCS_DIR, readAgentDocsOnDisk, renderAgentDocs } from "./_lib/agent-docs";
|
|
19
|
+
import {
|
|
20
|
+
type GuardViolation,
|
|
21
|
+
type RepoCheck,
|
|
22
|
+
type RepoCheckOutcome,
|
|
23
|
+
reportResults,
|
|
24
|
+
runRepoChecks,
|
|
25
|
+
} from "./_lib/guard-kit";
|
|
26
|
+
import type { RepoRoot } from "./_lib/roots";
|
|
27
|
+
|
|
28
|
+
const FRAMEWORK_PACKAGE_JSON_REL = "packages/framework/package.json";
|
|
29
|
+
const FRAMEWORK_PACKAGE_NAME = "@cosmicdrift/kumiko-framework";
|
|
30
|
+
const AGENTS_MD_REL = "packages/framework/AGENTS.md";
|
|
31
|
+
const AGENTS_REF_RE = /`agents\/([A-Za-z0-9._/-]+\.md)`/g;
|
|
32
|
+
const REGENERATE_HINT = "run `bun run gen:agent-docs` and commit the result";
|
|
33
|
+
|
|
34
|
+
function isFrameworkRepo(root: RepoRoot): boolean {
|
|
35
|
+
const packageJsonPath = join(root.absPath, FRAMEWORK_PACKAGE_JSON_REL);
|
|
36
|
+
if (!existsSync(packageJsonPath)) return false;
|
|
37
|
+
try {
|
|
38
|
+
const parsed: unknown = JSON.parse(readFileSync(packageJsonPath, "utf-8"));
|
|
39
|
+
return (
|
|
40
|
+
typeof parsed === "object" &&
|
|
41
|
+
parsed !== null &&
|
|
42
|
+
"name" in parsed &&
|
|
43
|
+
parsed.name === FRAMEWORK_PACKAGE_NAME
|
|
44
|
+
);
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function firstDifferingLine(expected: string, actual: string): number {
|
|
51
|
+
const expectedLines = expected.split("\n");
|
|
52
|
+
const actualLines = actual.split("\n");
|
|
53
|
+
const length = Math.max(expectedLines.length, actualLines.length);
|
|
54
|
+
for (let index = 0; index < length; index++) {
|
|
55
|
+
if (expectedLines[index] !== actualLines[index]) return index + 1;
|
|
56
|
+
}
|
|
57
|
+
return 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function agentsMdReferenceViolations(
|
|
61
|
+
absPath: string,
|
|
62
|
+
generated: ReadonlyMap<string, string>,
|
|
63
|
+
): GuardViolation[] {
|
|
64
|
+
const agentsMdPath = join(absPath, AGENTS_MD_REL);
|
|
65
|
+
if (!existsSync(agentsMdPath)) return [];
|
|
66
|
+
const violations: GuardViolation[] = [];
|
|
67
|
+
const lines = readFileSync(agentsMdPath, "utf-8").split("\n");
|
|
68
|
+
lines.forEach((text, index) => {
|
|
69
|
+
for (const match of text.matchAll(AGENTS_REF_RE)) {
|
|
70
|
+
const key = match[1];
|
|
71
|
+
if (key === undefined || generated.has(key)) continue;
|
|
72
|
+
violations.push({
|
|
73
|
+
file: AGENTS_MD_REL,
|
|
74
|
+
line: index + 1,
|
|
75
|
+
message: `references agents/${key}, which is not generated. Fix the reference or add the source (an example needs samples/recipes/<slug>/package.json).`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
});
|
|
79
|
+
return violations;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function rootViolations(absPath: string): { violations: GuardViolation[]; fileCount: number } {
|
|
83
|
+
const generated = renderAgentDocs(absPath);
|
|
84
|
+
const onDisk = readAgentDocsOnDisk(absPath);
|
|
85
|
+
const violations: GuardViolation[] = [];
|
|
86
|
+
for (const [rel, expected] of generated) {
|
|
87
|
+
const file = `${AGENT_DOCS_DIR}/${rel}`;
|
|
88
|
+
const actual = onDisk.get(rel);
|
|
89
|
+
if (actual === undefined) {
|
|
90
|
+
violations.push({
|
|
91
|
+
file,
|
|
92
|
+
line: 1,
|
|
93
|
+
message: `generated file is missing; ${REGENERATE_HINT}`,
|
|
94
|
+
});
|
|
95
|
+
} else if (actual !== expected) {
|
|
96
|
+
violations.push({
|
|
97
|
+
file,
|
|
98
|
+
line: firstDifferingLine(expected, actual),
|
|
99
|
+
message: `content differs from the generated output; ${REGENERATE_HINT}`,
|
|
100
|
+
});
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
for (const rel of onDisk.keys()) {
|
|
104
|
+
if (generated.has(rel)) continue;
|
|
105
|
+
violations.push({
|
|
106
|
+
file: `${AGENT_DOCS_DIR}/${rel}`,
|
|
107
|
+
line: 1,
|
|
108
|
+
message: `stale file no longer produced by the generator; ${REGENERATE_HINT}`,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
violations.push(...agentsMdReferenceViolations(absPath, generated));
|
|
112
|
+
return { violations, fileCount: generated.size };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
export const check: RepoCheck = {
|
|
116
|
+
name: "agent-docs-drift",
|
|
117
|
+
hint: "Run `bun run gen:agent-docs` and commit packages/framework/agents/.",
|
|
118
|
+
run(roots): RepoCheckOutcome {
|
|
119
|
+
const frameworkRoots = roots.filter(isFrameworkRepo);
|
|
120
|
+
if (frameworkRoots.length === 0) {
|
|
121
|
+
return { violations: [], matchedFiles: 0, notApplicable: true };
|
|
122
|
+
}
|
|
123
|
+
const violations: GuardViolation[] = [];
|
|
124
|
+
let matchedFiles = 0;
|
|
125
|
+
for (const root of frameworkRoots) {
|
|
126
|
+
const result = rootViolations(root.absPath);
|
|
127
|
+
violations.push(...result.violations);
|
|
128
|
+
matchedFiles += result.fileCount;
|
|
129
|
+
}
|
|
130
|
+
return { violations, matchedFiles, notApplicable: false };
|
|
131
|
+
},
|
|
132
|
+
};
|
|
133
|
+
|
|
134
|
+
if (import.meta.main) {
|
|
135
|
+
const failed = reportResults(await runRepoChecks([check]));
|
|
136
|
+
process.exit(failed > 0 ? 1 : 0);
|
|
137
|
+
}
|
|
@@ -36,12 +36,16 @@ const SCAN: ScanSpec = {
|
|
|
36
36
|
|
|
37
37
|
const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
38
38
|
|
|
39
|
+
// Case-sensitive lower case: an upper-case licence name or a capitalised name
|
|
40
|
+
// must not count, and a single short marker that is also an English word is
|
|
41
|
+
// not enough.
|
|
39
42
|
const DE_MARKER_RE =
|
|
40
|
-
/\b(und|oder|für|wenn|nicht|dann|auch|noch|wird|sind|mit|zum|zur|bei|nach|ohne|über|kein|eine|einen|dieser|diese)\b/
|
|
43
|
+
/\b(und|oder|für|wenn|nicht|dann|auch|noch|wird|sind|mit|zum|zur|bei|nach|ohne|über|kein|eine|einen|dieser|diese)\b/g;
|
|
44
|
+
const MIN_DE_MARKER_HITS = 2;
|
|
41
45
|
const UMLAUT_RE = /[äöüÄÖÜß]/;
|
|
42
46
|
|
|
43
47
|
export function isGermanComment(text: string): boolean {
|
|
44
|
-
return
|
|
48
|
+
return UMLAUT_RE.test(text) || (text.match(DE_MARKER_RE)?.length ?? 0) >= MIN_DE_MARKER_HITS;
|
|
45
49
|
}
|
|
46
50
|
|
|
47
51
|
export type Site = {
|
|
@@ -68,13 +72,8 @@ function germanCommentSite(
|
|
|
68
72
|
};
|
|
69
73
|
}
|
|
70
74
|
|
|
71
|
-
//
|
|
72
|
-
const
|
|
73
|
-
ts.SyntaxKind.Identifier,
|
|
74
|
-
ts.SyntaxKind.ThisKeyword,
|
|
75
|
-
ts.SyntaxKind.TrueKeyword,
|
|
76
|
-
ts.SyntaxKind.FalseKeyword,
|
|
77
|
-
ts.SyntaxKind.NullKeyword,
|
|
75
|
+
// Tokens that end an operand without being identifiers or keywords.
|
|
76
|
+
const OPERAND_END_PUNCTUATION_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
78
77
|
ts.SyntaxKind.NumericLiteral,
|
|
79
78
|
ts.SyntaxKind.BigIntLiteral,
|
|
80
79
|
ts.SyntaxKind.StringLiteral,
|
|
@@ -88,6 +87,42 @@ const DIVISION_PRECEDING_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
|
88
87
|
ts.SyntaxKind.MinusMinusToken,
|
|
89
88
|
]);
|
|
90
89
|
|
|
90
|
+
// Reserved words after which an expression (so possibly a regex) starts.
|
|
91
|
+
const EXPRESSION_INTRODUCING_KEYWORDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
92
|
+
ts.SyntaxKind.ReturnKeyword,
|
|
93
|
+
ts.SyntaxKind.TypeOfKeyword,
|
|
94
|
+
ts.SyntaxKind.InstanceOfKeyword,
|
|
95
|
+
ts.SyntaxKind.InKeyword,
|
|
96
|
+
ts.SyntaxKind.CaseKeyword,
|
|
97
|
+
ts.SyntaxKind.VoidKeyword,
|
|
98
|
+
ts.SyntaxKind.DeleteKeyword,
|
|
99
|
+
ts.SyntaxKind.ThrowKeyword,
|
|
100
|
+
ts.SyntaxKind.NewKeyword,
|
|
101
|
+
ts.SyntaxKind.YieldKeyword,
|
|
102
|
+
ts.SyntaxKind.AwaitKeyword,
|
|
103
|
+
ts.SyntaxKind.ElseKeyword,
|
|
104
|
+
ts.SyntaxKind.DoKeyword,
|
|
105
|
+
ts.SyntaxKind.ExtendsKeyword,
|
|
106
|
+
ts.SyntaxKind.DefaultKeyword,
|
|
107
|
+
]);
|
|
108
|
+
|
|
109
|
+
function isIdentifierOrKeyword(kind: ts.SyntaxKind): boolean {
|
|
110
|
+
return (
|
|
111
|
+
kind === ts.SyntaxKind.Identifier ||
|
|
112
|
+
(kind >= ts.SyntaxKind.FirstKeyword && kind <= ts.SyntaxKind.LastKeyword)
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
// After an operand end a `/` is division, not the start of a regex literal.
|
|
117
|
+
// Contextual keywords (`from`, `type`, `of`, ...) scan as keyword kinds but are
|
|
118
|
+
// valid member and variable names, so every non-introducing keyword counts. A `!`
|
|
119
|
+
// is the non-null postfix only when the token before it ended an operand.
|
|
120
|
+
function isOperandEnd(kind: ts.SyntaxKind, previousEndedOperand: boolean): boolean {
|
|
121
|
+
if (kind === ts.SyntaxKind.ExclamationToken) return previousEndedOperand;
|
|
122
|
+
if (OPERAND_END_PUNCTUATION_KINDS.has(kind)) return true;
|
|
123
|
+
return isIdentifierOrKeyword(kind) && !EXPRESSION_INTRODUCING_KEYWORDS.has(kind);
|
|
124
|
+
}
|
|
125
|
+
|
|
91
126
|
const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
92
127
|
ts.SyntaxKind.SingleLineCommentTrivia,
|
|
93
128
|
ts.SyntaxKind.MultiLineCommentTrivia,
|
|
@@ -101,11 +136,9 @@ const TRIVIA_KINDS: ReadonlySet<ts.SyntaxKind> = new Set([
|
|
|
101
136
|
function rescanSlashAsRegex(
|
|
102
137
|
scanner: ts.Scanner,
|
|
103
138
|
kind: ts.SyntaxKind,
|
|
104
|
-
|
|
139
|
+
previousEndedOperand: boolean,
|
|
105
140
|
): ts.SyntaxKind {
|
|
106
|
-
if (
|
|
107
|
-
return kind;
|
|
108
|
-
}
|
|
141
|
+
if (previousEndedOperand) return kind;
|
|
109
142
|
const reScanned = scanner.reScanSlashToken();
|
|
110
143
|
return reScanned === ts.SyntaxKind.RegularExpressionLiteral ? reScanned : kind;
|
|
111
144
|
}
|
|
@@ -135,7 +168,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
|
135
168
|
|
|
136
169
|
const templateBraceDepths: number[] = [];
|
|
137
170
|
let braceDepth = 0;
|
|
138
|
-
let
|
|
171
|
+
let previousEndedOperand = false;
|
|
139
172
|
|
|
140
173
|
let kind = scanner.scan();
|
|
141
174
|
while (kind !== ts.SyntaxKind.EndOfFileToken) {
|
|
@@ -153,7 +186,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
|
153
186
|
}
|
|
154
187
|
case ts.SyntaxKind.SlashToken:
|
|
155
188
|
case ts.SyntaxKind.SlashEqualsToken:
|
|
156
|
-
kind = rescanSlashAsRegex(scanner, kind,
|
|
189
|
+
kind = rescanSlashAsRegex(scanner, kind, previousEndedOperand);
|
|
157
190
|
break;
|
|
158
191
|
case ts.SyntaxKind.TemplateHead:
|
|
159
192
|
case ts.SyntaxKind.TemplateMiddle:
|
|
@@ -173,7 +206,7 @@ export function scanGermanComments(fullText: string, file: string): Site[] {
|
|
|
173
206
|
default:
|
|
174
207
|
break;
|
|
175
208
|
}
|
|
176
|
-
if (!TRIVIA_KINDS.has(kind))
|
|
209
|
+
if (!TRIVIA_KINDS.has(kind)) previousEndedOperand = isOperandEnd(kind, previousEndedOperand);
|
|
177
210
|
kind = scanner.scan();
|
|
178
211
|
}
|
|
179
212
|
return sites;
|
|
@@ -234,21 +267,20 @@ function gitOrFail(args: readonly string[], cwd: string, message: string): strin
|
|
|
234
267
|
const REWRITE_HINT = `Translate new German comments to English (why, not what), or if deliberate:\n // ${IGNORE_TAG} <reason>`;
|
|
235
268
|
|
|
236
269
|
function runTouchedMode(
|
|
237
|
-
all: readonly Site[],
|
|
238
270
|
argv: readonly string[],
|
|
239
271
|
root: string,
|
|
240
|
-
repoLocalScannedFiles:
|
|
272
|
+
repoLocalScannedFiles: ReadonlySet<string>,
|
|
241
273
|
): number {
|
|
242
274
|
const baseArg = argv.find((arg) => arg.startsWith(BASE_FLAG_PREFIX));
|
|
243
275
|
const baseRef = baseArg ? baseArg.slice(BASE_FLAG_PREFIX.length) : "origin/main";
|
|
244
276
|
if (baseRef.startsWith("-")) {
|
|
245
277
|
throw new CommentLangFailure(
|
|
246
|
-
`--base must not start with "-" (${baseRef}): it would be read as a git option, not a ref.`,
|
|
278
|
+
`--base must not start with "-" (${baseRef}): it would be read as a git option, not a ref. Pass a ref name such as origin/main.`,
|
|
247
279
|
);
|
|
248
280
|
}
|
|
249
281
|
|
|
250
282
|
// No repo-local file scanned at all must not read as clean (vacuous pass).
|
|
251
|
-
if (repoLocalScannedFiles === 0) {
|
|
283
|
+
if (repoLocalScannedFiles.size === 0) {
|
|
252
284
|
console.log(" Touched mode: 0 repo-local files scanned — not applicable, no failure.");
|
|
253
285
|
return 0;
|
|
254
286
|
}
|
|
@@ -256,7 +288,7 @@ function runTouchedMode(
|
|
|
256
288
|
const gitToplevel = gitOrFail(
|
|
257
289
|
["rev-parse", "--show-toplevel"],
|
|
258
290
|
root,
|
|
259
|
-
"Touched mode: no git repository found.",
|
|
291
|
+
"Touched mode: no git repository found. Run it inside a git checkout.",
|
|
260
292
|
).trim();
|
|
261
293
|
const mergeBase = gitOrFail(
|
|
262
294
|
["merge-base", baseRef, "HEAD"],
|
|
@@ -265,35 +297,37 @@ function runTouchedMode(
|
|
|
265
297
|
).trim();
|
|
266
298
|
// -z: NUL-terminated and unquoted, so non-ASCII paths still match the lookup below.
|
|
267
299
|
const touchedFiles = gitOrFail(
|
|
268
|
-
["diff", "--name-only", "-z", "--diff-filter=ACMR", mergeBase],
|
|
300
|
+
["diff", "--name-only", "-z", "--diff-filter=ACMR", mergeBase, "HEAD"],
|
|
269
301
|
gitToplevel,
|
|
270
|
-
"Touched mode: git diff --name-only failed.",
|
|
302
|
+
"Touched mode: git diff --name-only failed. Check the git error below and that the base ref is reachable.",
|
|
271
303
|
)
|
|
272
304
|
.split("\0")
|
|
273
305
|
.filter(Boolean);
|
|
274
306
|
|
|
275
|
-
//
|
|
276
|
-
//
|
|
277
|
-
|
|
278
|
-
for (const site of all.filter(isLocalFinding)) {
|
|
279
|
-
const gitRelPath = path.relative(gitToplevel, path.resolve(root, site.file));
|
|
280
|
-
sitesByGitFile.set(gitRelPath, [...(sitesByGitFile.get(gitRelPath) ?? []), site]);
|
|
281
|
-
}
|
|
282
|
-
|
|
307
|
+
// The commits being pushed are checked, not the working tree: contents come from
|
|
308
|
+
// HEAD so line numbers match the `mergeBase..HEAD` diff. Scan scope is keyed
|
|
309
|
+
// relative to root, git output relative to the toplevel.
|
|
283
310
|
const violations: Array<{ file: string; sites: Site[] }> = [];
|
|
284
311
|
for (const file of touchedFiles) {
|
|
285
|
-
const
|
|
286
|
-
if (!
|
|
312
|
+
const rootRelFile = path.relative(root, path.resolve(gitToplevel, file));
|
|
313
|
+
if (!repoLocalScannedFiles.has(rootRelFile)) continue;
|
|
287
314
|
// --literal-pathspecs: filenames with glob magic must match themselves.
|
|
315
|
+
const headContent = gitOrFail(
|
|
316
|
+
["--literal-pathspecs", "show", `HEAD:${file}`],
|
|
317
|
+
gitToplevel,
|
|
318
|
+
`Touched mode: git show HEAD:${file} failed. Check the git error below.`,
|
|
319
|
+
);
|
|
320
|
+
const candidates = scanGermanComments(headContent, rootRelFile);
|
|
321
|
+
if (candidates.length === 0) continue;
|
|
288
322
|
const addedLines = parseAddedLineRanges(
|
|
289
323
|
gitOrFail(
|
|
290
|
-
["--literal-pathspecs", "diff", "--unified=0", mergeBase, "--", file],
|
|
324
|
+
["--literal-pathspecs", "diff", "--unified=0", mergeBase, "HEAD", "--", file],
|
|
291
325
|
gitToplevel,
|
|
292
|
-
`Touched mode: git diff --unified=0 failed for ${file}.`,
|
|
326
|
+
`Touched mode: git diff --unified=0 failed for ${file}. Check the git error below.`,
|
|
293
327
|
),
|
|
294
328
|
);
|
|
295
329
|
const touchedSites = sitesTouchedByAddedLines(candidates, addedLines);
|
|
296
|
-
if (touchedSites.length > 0) violations.push({ file, sites: touchedSites });
|
|
330
|
+
if (touchedSites.length > 0) violations.push({ file: rootRelFile, sites: touchedSites });
|
|
297
331
|
}
|
|
298
332
|
|
|
299
333
|
if (violations.length === 0) {
|
|
@@ -470,25 +504,27 @@ export function runCommentLangCli(
|
|
|
470
504
|
try {
|
|
471
505
|
const all: Site[] = [];
|
|
472
506
|
let scanned = 0;
|
|
473
|
-
|
|
507
|
+
const repoLocalScanned = new Set<string>();
|
|
474
508
|
for (const absPath of scanFiles(SCAN, resolveRepoRoots(root))) {
|
|
475
509
|
if (EXCLUDE.test(absPath)) continue;
|
|
476
510
|
scanned++;
|
|
477
511
|
const file = path.relative(root, absPath);
|
|
478
|
-
if (isLocalFinding({ file })) repoLocalScanned
|
|
512
|
+
if (isLocalFinding({ file })) repoLocalScanned.add(file);
|
|
479
513
|
all.push(...scanGermanComments(readFileSync(absPath, "utf-8"), file));
|
|
480
514
|
}
|
|
481
515
|
|
|
482
516
|
// Zero scanned files must not read as a clean pass (misresolved root, empty scope).
|
|
483
517
|
if (scanned === 0) {
|
|
484
|
-
throw new CommentLangFailure(
|
|
518
|
+
throw new CommentLangFailure(
|
|
519
|
+
"Scanned 0 files — repo root or scan scope did not resolve. Run it from inside a repo checkout whose kumiko.json declares the source roots.",
|
|
520
|
+
);
|
|
485
521
|
}
|
|
486
522
|
|
|
487
523
|
console.log(
|
|
488
524
|
`Comment-Language Audit: ${scanned} files checked, ${all.length} German comment(s) found.`,
|
|
489
525
|
);
|
|
490
526
|
|
|
491
|
-
if (argv.includes("--touched")) return runTouchedMode(
|
|
527
|
+
if (argv.includes("--touched")) return runTouchedMode(argv, root, repoLocalScanned);
|
|
492
528
|
if (argv.includes("--list")) {
|
|
493
529
|
for (const site of all.slice(0, LIST_LIMIT)) {
|
|
494
530
|
console.log(` ${site.file}:${site.line} ${site.snippet}`);
|
|
@@ -461,6 +461,9 @@ export const guard: AstGuard = {
|
|
|
461
461
|
name: "Direct-Entity-Writes Guard",
|
|
462
462
|
scan: SCAN,
|
|
463
463
|
security: true,
|
|
464
|
+
hint:
|
|
465
|
+
"Write ES-entity tables through the entity write handlers / event-store executor or inside an r.projection apply (tx receiver); a direct insert/update/delete lets the projection row drift from the event stream. " +
|
|
466
|
+
"For a BLOCKED scan, check that the scan still covers the files declaring r.entity / createEventStoreExecutor.",
|
|
464
467
|
run(files) {
|
|
465
468
|
// Merge both collections BEFORE the empty-set check — otherwise a repo
|
|
466
469
|
// that only uses r.entity (no createEventStoreExecutor anymore) would
|
package/src/guard-html-escape.ts
CHANGED
|
@@ -83,7 +83,7 @@ function templateStaticText(tpl: Node): string {
|
|
|
83
83
|
if (tpl.isKind(SyntaxKind.NoSubstitutionTemplateLiteral)) {
|
|
84
84
|
return tpl.getLiteralText();
|
|
85
85
|
}
|
|
86
|
-
const t = tpl
|
|
86
|
+
const t = tpl.asKindOrThrow(SyntaxKind.TemplateExpression);
|
|
87
87
|
return [
|
|
88
88
|
t.getHead().getLiteralText(),
|
|
89
89
|
...t.getTemplateSpans().map((s) => s.getLiteral().getLiteralText()),
|
|
@@ -172,7 +172,7 @@ type LocalFunction = FunctionDeclaration | ArrowFunction | FunctionExpression;
|
|
|
172
172
|
|
|
173
173
|
function localFunctionsOf(callee: Node, sf: SourceFile): LocalFunction[] | undefined {
|
|
174
174
|
if (!callee.isKind(SyntaxKind.Identifier)) return undefined;
|
|
175
|
-
const decls = localDeclarations(callee
|
|
175
|
+
const decls = localDeclarations(callee, sf);
|
|
176
176
|
if (decls.length === 0) return undefined;
|
|
177
177
|
const fns: LocalFunction[] = [];
|
|
178
178
|
for (const d of decls) {
|
|
@@ -163,6 +163,9 @@ const PATTERNS: readonly Pattern[] = [
|
|
|
163
163
|
export const guard: AstGuard = {
|
|
164
164
|
name: "Pre-ES-Patterns Guard",
|
|
165
165
|
scan: SCAN,
|
|
166
|
+
hint:
|
|
167
|
+
"These APIs were removed with the event-sourcing migration. Use the events table with ctx.loadAggregate / queryProjection, ctx.appendEvent on aggregate streams, " +
|
|
168
|
+
'r.multiStreamProjection for cross-aggregate consumers, and configValue.created/updated/deleted events (aggregateType: "configValue") for config changes.',
|
|
166
169
|
run(files) {
|
|
167
170
|
const violations: Array<{ file: string; line: number; message: string }> = [];
|
|
168
171
|
|
|
@@ -270,7 +270,7 @@ function scanPackage(cwd: string, project: Project, pkg: PackageName): PackageSc
|
|
|
270
270
|
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
271
271
|
} catch (e) {
|
|
272
272
|
console.error(
|
|
273
|
-
` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}).`,
|
|
273
|
+
` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}). Fix the file so it is valid JSON.`,
|
|
274
274
|
);
|
|
275
275
|
return null;
|
|
276
276
|
}
|
|
@@ -320,7 +320,9 @@ export type EvaluateResult = {
|
|
|
320
320
|
|
|
321
321
|
function buildFinding(missing: MissingExport, version: string): UncoveredFinding {
|
|
322
322
|
if (!isPackageName(missing.pkg)) {
|
|
323
|
-
throw new Error(
|
|
323
|
+
throw new Error(
|
|
324
|
+
`baseline package key not in scope: ${missing.pkg}. Remove the key from the baseline or re-freeze it with --write-baseline.`,
|
|
325
|
+
);
|
|
324
326
|
}
|
|
325
327
|
const pkg = missing.pkg;
|
|
326
328
|
const changelogPath = owningChangelogPath(pkg, missing.key);
|
|
@@ -370,7 +372,7 @@ function findingsAgainstBaseline(
|
|
|
370
372
|
for (const [pkg, keys] of Object.entries(baseline.packages)) {
|
|
371
373
|
if (!isPackageName(pkg)) {
|
|
372
374
|
console.error(
|
|
373
|
-
` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features).`,
|
|
375
|
+
` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features). Remove the key from the baseline or re-freeze it with --write-baseline.`,
|
|
374
376
|
);
|
|
375
377
|
continue;
|
|
376
378
|
}
|
|
@@ -397,7 +399,9 @@ export function evaluatePublicExportsChangelog(args: {
|
|
|
397
399
|
logOutOfScopeNotice();
|
|
398
400
|
const scans = scanAllPackages(args.cwd, args.project);
|
|
399
401
|
if (scans.length === 0 && args.mode === "check") {
|
|
400
|
-
console.error(
|
|
402
|
+
console.error(
|
|
403
|
+
" No package scanned: the guard would pass vacuously. Run it from a kumiko-framework checkout that contains the framework package.",
|
|
404
|
+
);
|
|
401
405
|
return { scans, baselineStatus: "ok", findings: [vacuousFinding()] };
|
|
402
406
|
}
|
|
403
407
|
|
|
@@ -441,11 +445,16 @@ export async function loadChangesetParser(frameworkRootAbsPath: string): Promise
|
|
|
441
445
|
frameworkRootAbsPath,
|
|
442
446
|
"packages/framework/src/engine/changeset-changes.ts",
|
|
443
447
|
);
|
|
444
|
-
if (!existsSync(parserPath))
|
|
448
|
+
if (!existsSync(parserPath))
|
|
449
|
+
throw new Error(
|
|
450
|
+
`changeset parser not found: ${parserPath}. Run the guard from a complete kumiko-framework checkout.`,
|
|
451
|
+
);
|
|
445
452
|
const loaded: unknown = await import(pathToFileURL(parserPath).href);
|
|
446
453
|
const parse = isRecord(loaded) ? loaded["parseChangesetChanges"] : undefined;
|
|
447
454
|
if (!isChangesetParser(parse)) {
|
|
448
|
-
throw new Error(
|
|
455
|
+
throw new Error(
|
|
456
|
+
`${parserPath} does not export parseChangesetChanges as a function. Export it from that file.`,
|
|
457
|
+
);
|
|
449
458
|
}
|
|
450
459
|
return parse;
|
|
451
460
|
}
|
|
@@ -560,7 +569,7 @@ export function writeBaseline(
|
|
|
560
569
|
const frameworkScan = scans.find((s) => s.pkg === "framework");
|
|
561
570
|
if (!frameworkScan) {
|
|
562
571
|
console.error(
|
|
563
|
-
" framework package.json missing: asOfVersion undetermined, baseline not written.",
|
|
572
|
+
" framework package.json missing: asOfVersion undetermined, baseline not written. Run it from a kumiko-framework checkout.",
|
|
564
573
|
);
|
|
565
574
|
return 1;
|
|
566
575
|
}
|
|
@@ -665,7 +674,9 @@ export async function runPublicExportsChangelogCli(
|
|
|
665
674
|
}
|
|
666
675
|
const tsConfigFilePath = frameworkTsConfigPath(cwd);
|
|
667
676
|
if (tsConfigFilePath === undefined) {
|
|
668
|
-
console.error(
|
|
677
|
+
console.error(
|
|
678
|
+
" public-exports-changelog: framework tsconfig.json not found. Run it from a complete kumiko-framework checkout.",
|
|
679
|
+
);
|
|
669
680
|
return 1;
|
|
670
681
|
}
|
|
671
682
|
const project = new Project({ tsConfigFilePath, skipAddingFilesFromTsConfig: true });
|
|
@@ -676,7 +687,9 @@ export async function runPublicExportsChangelogCli(
|
|
|
676
687
|
try {
|
|
677
688
|
pending = await loadPendingBreakingChanges(scanCwd);
|
|
678
689
|
} catch (e) {
|
|
679
|
-
console.error(
|
|
690
|
+
console.error(
|
|
691
|
+
` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
|
|
692
|
+
);
|
|
680
693
|
return 1;
|
|
681
694
|
}
|
|
682
695
|
return writeBaseline(scanCwd, project, join(scanCwd, BASELINE_FILE), pending);
|
package/src/guard-raw-sql.ts
CHANGED
|
@@ -78,7 +78,7 @@ async function scanAllRepos(roots: readonly RepoRoot[]): Promise<{
|
|
|
78
78
|
for (const hit of report.hits) {
|
|
79
79
|
if (hit.allowed) continue;
|
|
80
80
|
if (hit.file.includes("/__tests__/")) continue;
|
|
81
|
-
if (!(
|
|
81
|
+
if (!BLOCKING_SQL_KINDS.some((kind) => kind === hit.kind)) continue;
|
|
82
82
|
if (hit.markerSuppressed) {
|
|
83
83
|
// One line like `asRawClient(db).unsafe(` yields two hits for one marker.
|
|
84
84
|
if (hit.markerReason !== undefined && hit.markerLine !== undefined) {
|
|
@@ -36,7 +36,6 @@ import {
|
|
|
36
36
|
Project,
|
|
37
37
|
type SourceFile,
|
|
38
38
|
SyntaxKind,
|
|
39
|
-
type VariableDeclaration,
|
|
40
39
|
} from "ts-morph";
|
|
41
40
|
import { type RepoCheck, reportResults, runRepoChecks } from "./_lib/guard-kit";
|
|
42
41
|
import { frameworkTsConfigPath } from "./_lib/roots";
|
|
@@ -62,7 +61,7 @@ export const KNOWN_WRAPPER_REASONS = [
|
|
|
62
61
|
export type WrapperReason = (typeof KNOWN_WRAPPER_REASONS)[number];
|
|
63
62
|
|
|
64
63
|
function isKnownReason(r: string): r is WrapperReason {
|
|
65
|
-
return (
|
|
64
|
+
return KNOWN_WRAPPER_REASONS.some((known) => known === r);
|
|
66
65
|
}
|
|
67
66
|
|
|
68
67
|
// ── Built-in filter ─────────────────────────────────────────────────────
|
|
@@ -222,7 +221,7 @@ function getFnName(node: FnNode): string | undefined {
|
|
|
222
221
|
if (node.isKind(SyntaxKind.FunctionDeclaration)) return node.getName() ?? undefined;
|
|
223
222
|
const parent = node.getParent();
|
|
224
223
|
if (parent?.isKind(SyntaxKind.VariableDeclaration)) {
|
|
225
|
-
return
|
|
224
|
+
return parent.getName();
|
|
226
225
|
}
|
|
227
226
|
if (parent?.isKind(SyntaxKind.PropertyAssignment)) {
|
|
228
227
|
return parent.getFirstChild()?.getText();
|
|
@@ -378,6 +377,9 @@ const SCAN: ScanSpec = {
|
|
|
378
377
|
frameworkWithin: ["packages/*/src/**"],
|
|
379
378
|
};
|
|
380
379
|
|
|
380
|
+
const WRAPPER_FIX =
|
|
381
|
+
"call the delegate directly, or mark a deliberate wrapper with // @wrapper-known <reason>";
|
|
382
|
+
|
|
381
383
|
export const check: RepoCheck = {
|
|
382
384
|
name: "Thin-Wrappers Guard",
|
|
383
385
|
run(roots) {
|
|
@@ -404,12 +406,16 @@ export const check: RepoCheck = {
|
|
|
404
406
|
scanned++;
|
|
405
407
|
for (const f of collectFindings(sf)) {
|
|
406
408
|
if (f.markerReason === null) {
|
|
407
|
-
warnings.push({
|
|
409
|
+
warnings.push({
|
|
410
|
+
file: f.file,
|
|
411
|
+
line: f.line,
|
|
412
|
+
message: `${f.fnName} → ${f.callee} (${WRAPPER_FIX})`,
|
|
413
|
+
});
|
|
408
414
|
} else if (!isKnownReason(f.markerReason)) {
|
|
409
415
|
warnings.push({
|
|
410
416
|
file: f.file,
|
|
411
417
|
line: f.line,
|
|
412
|
-
message: `${f.fnName} → ${f.callee} (reason="${f.markerReason}")`,
|
|
418
|
+
message: `${f.fnName} → ${f.callee} (reason="${f.markerReason}"; use a known reason from the list in this guard)`,
|
|
413
419
|
});
|
|
414
420
|
}
|
|
415
421
|
}
|