@cosmicdrift/kumiko-guards 0.363.0 → 0.365.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/dependency-cli.ts +27 -0
- package/src/_lib/dependency-exceptions.ts +135 -0
- package/src/_lib/direct-dependencies.ts +80 -0
- package/src/_lib/installed-packages.ts +146 -0
- package/src/_lib/npm-registry.ts +168 -0
- package/src/_lib/push-root.ts +5 -3
- package/src/_lib/security-audit.ts +390 -0
- package/src/_lib/security-baseline.ts +14 -3
- package/src/changes.json +47 -5
- 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 +1 -1
- 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-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-lib-test-coverage.ts +1 -1
- package/src/guard-no-direct-fs.ts +0 -1
- package/src/guard-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +159 -24
- package/src/guard-test-timeouts.ts +5 -1
- package/src/guard-thin-wrappers.ts +9 -2
- package/src/guard-upgrade-state.ts +40 -11
- package/src/index.ts +1 -0
- package/src/pre-push.sh +20 -0
- package/src/run-repo-checks.ts +2 -0
|
@@ -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
|
|
@@ -74,7 +74,7 @@ function exportedCallables(sf: SourceFile): Export[] {
|
|
|
74
74
|
return out;
|
|
75
75
|
}
|
|
76
76
|
|
|
77
|
-
// NodeNext
|
|
77
|
+
// NodeNext imports write ".js"/".jsx" although the source is ".ts"/".tsx"
|
|
78
78
|
// (and likewise .mjs/.cjs for .mts/.cts) — the extension must therefore be
|
|
79
79
|
// stripped for all four pair variants when comparing module paths, otherwise
|
|
80
80
|
// the link breaks.
|
|
@@ -121,7 +121,6 @@ const ALLOWLIST: readonly AllowEntry[] = [
|
|
|
121
121
|
{ repo: "kumiko-framework", pattern: /^packages\/create-kumiko-app\/src\/manifest\.ts$/ },
|
|
122
122
|
|
|
123
123
|
// Sample apps: codegen/screenshot-mount helper, no request input.
|
|
124
|
-
{ repo: "kumiko-framework", pattern: /^samples\/apps\/use-all-bundled\/schema\/generate\.ts$/ },
|
|
125
124
|
{
|
|
126
125
|
repo: "kumiko-framework",
|
|
127
126
|
pattern: /^samples\/apps\/showcase\/src\/app\/mount-public-screenshots\.ts$/,
|
|
@@ -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
|
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
1
2
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
2
3
|
import { join, resolve } from "node:path";
|
|
3
4
|
import { pathToFileURL } from "node:url";
|
|
@@ -19,6 +20,12 @@ import { frameworkTsConfigPath, resolveRepoRoots } from "./_lib/roots";
|
|
|
19
20
|
// write is refused.
|
|
20
21
|
// Without an old baseline, `--write-baseline` is a bootstrap freeze.
|
|
21
22
|
//
|
|
23
|
+
// The committed baseline is a file the PR itself can edit (or delete and
|
|
24
|
+
// bootstrap-freeze), so check mode with `--base=<ref>` additionally reads the
|
|
25
|
+
// baseline from the merge-base with <ref> and runs every drop against THAT
|
|
26
|
+
// baseline through the same ratchet gate as `--write-baseline`. Only when the
|
|
27
|
+
// base has no baseline either is there nothing to protect.
|
|
28
|
+
//
|
|
22
29
|
// Scope: only the two packages with a changes.json home. packages/framework
|
|
23
30
|
// has ONE changelog (src/changes.json); packages/bundled-features has one PER
|
|
24
31
|
// FEATURE (src/<feature>/changes.json, feature = first export-key segment).
|
|
@@ -48,6 +55,7 @@ const OUT_OF_SCOPE_PACKAGES = [
|
|
|
48
55
|
"renderer",
|
|
49
56
|
] as const;
|
|
50
57
|
|
|
58
|
+
const BASE_FLAG_PREFIX = "--base=";
|
|
51
59
|
const BASELINE_FILE = ".kumiko-public-exports-baseline.json";
|
|
52
60
|
const REFREEZE_COMMAND = "bun packages/guards/src/cli.ts public-exports-changelog --write-baseline";
|
|
53
61
|
const BASELINE_FORMAT_VERSION = 1;
|
|
@@ -204,6 +212,19 @@ export function compareVersionsLocal(a: string, b: string): number {
|
|
|
204
212
|
return 0;
|
|
205
213
|
}
|
|
206
214
|
|
|
215
|
+
function escapeRegExp(value: string): string {
|
|
216
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Identifier boundaries, not substrings: `getUserById` must not cover a dropped `getUser`.
|
|
220
|
+
export function mentionsExportName(text: string, name: string): boolean {
|
|
221
|
+
return new RegExp(`(?<![A-Za-z0-9_$])${escapeRegExp(name)}(?![A-Za-z0-9_$])`).test(text);
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
function everyNameMentioned(texts: readonly string[], names: readonly string[]): boolean {
|
|
225
|
+
return names.every((name) => texts.some((text) => mentionsExportName(text, name)));
|
|
226
|
+
}
|
|
227
|
+
|
|
207
228
|
export function isCoveredByChangelog(
|
|
208
229
|
entries: readonly ChangelogEntryShape[],
|
|
209
230
|
asOfVersion: string,
|
|
@@ -211,15 +232,16 @@ export function isCoveredByChangelog(
|
|
|
211
232
|
): boolean {
|
|
212
233
|
// >= not >: package.json only bumps at release, so the entry `kumiko changes
|
|
213
234
|
// add` writes in the removing PR carries the SAME version as asOfVersion.
|
|
214
|
-
// With exportNames,
|
|
215
|
-
// a single breaking entry cannot blanket-cover unrelated drops.
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
235
|
+
// With exportNames, every dropped export must be named by some qualifying
|
|
236
|
+
// entry, so a single breaking entry cannot blanket-cover unrelated drops.
|
|
237
|
+
const qualifying = entries.filter(
|
|
238
|
+
(e) => e.type === "breaking" && compareVersionsLocal(e.version, asOfVersion) >= 0,
|
|
239
|
+
);
|
|
240
|
+
if (qualifying.length === 0) return false;
|
|
241
|
+
return everyNameMentioned(
|
|
242
|
+
qualifying.map((e) => `${e.title ?? ""} ${e.detail ?? ""}`),
|
|
243
|
+
exportNames,
|
|
244
|
+
);
|
|
223
245
|
}
|
|
224
246
|
|
|
225
247
|
function toChangelogEntry(e: unknown): ChangelogEntryShape[] {
|
|
@@ -241,7 +263,10 @@ function readChangelogEntries(absChangelogPath: string): ChangelogEntryShape[] {
|
|
|
241
263
|
let raw: unknown;
|
|
242
264
|
try {
|
|
243
265
|
raw = JSON.parse(readFileSync(absChangelogPath, "utf-8"));
|
|
244
|
-
} catch {
|
|
266
|
+
} catch (e) {
|
|
267
|
+
console.error(
|
|
268
|
+
` ${absChangelogPath}: changelog unreadable (${e instanceof Error ? e.message : String(e)}); its entries cannot cover a dropped export until the JSON is fixed.`,
|
|
269
|
+
);
|
|
245
270
|
return [];
|
|
246
271
|
}
|
|
247
272
|
return Array.isArray(raw) ? raw.flatMap(toChangelogEntry) : [];
|
|
@@ -270,7 +295,7 @@ function scanPackage(cwd: string, project: Project, pkg: PackageName): PackageSc
|
|
|
270
295
|
pkgJson = JSON.parse(readFileSync(pkgJsonPath, "utf-8"));
|
|
271
296
|
} catch (e) {
|
|
272
297
|
console.error(
|
|
273
|
-
` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}).`,
|
|
298
|
+
` ${pkg}: package.json unreadable (${e instanceof Error ? e.message : String(e)}). Fix the file so it is valid JSON.`,
|
|
274
299
|
);
|
|
275
300
|
return null;
|
|
276
301
|
}
|
|
@@ -320,7 +345,9 @@ export type EvaluateResult = {
|
|
|
320
345
|
|
|
321
346
|
function buildFinding(missing: MissingExport, version: string): UncoveredFinding {
|
|
322
347
|
if (!isPackageName(missing.pkg)) {
|
|
323
|
-
throw new Error(
|
|
348
|
+
throw new Error(
|
|
349
|
+
`baseline package key not in scope: ${missing.pkg}. Remove the key from the baseline or re-freeze it with --write-baseline.`,
|
|
350
|
+
);
|
|
324
351
|
}
|
|
325
352
|
const pkg = missing.pkg;
|
|
326
353
|
const changelogPath = owningChangelogPath(pkg, missing.key);
|
|
@@ -370,7 +397,7 @@ function findingsAgainstBaseline(
|
|
|
370
397
|
for (const [pkg, keys] of Object.entries(baseline.packages)) {
|
|
371
398
|
if (!isPackageName(pkg)) {
|
|
372
399
|
console.error(
|
|
373
|
-
` Baseline contains unknown package key "${pkg}", ignored (only framework|bundled-features).`,
|
|
400
|
+
` 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
401
|
);
|
|
375
402
|
continue;
|
|
376
403
|
}
|
|
@@ -393,11 +420,16 @@ export function evaluatePublicExportsChangelog(args: {
|
|
|
393
420
|
readonly mode: EvaluateMode;
|
|
394
421
|
/** Tests pass an explicit path; production resolves `<framework root>/.kumiko-public-exports-baseline.json`. */
|
|
395
422
|
readonly baselinePath?: string;
|
|
423
|
+
/** Baseline as committed on the PR's merge-base; its drops go through the ratchet gate. */
|
|
424
|
+
readonly baseBaseline?: BaselinePayload;
|
|
425
|
+
readonly pendingBreakingChanges?: PendingBreakingChanges;
|
|
396
426
|
}): EvaluateResult {
|
|
397
427
|
logOutOfScopeNotice();
|
|
398
428
|
const scans = scanAllPackages(args.cwd, args.project);
|
|
399
429
|
if (scans.length === 0 && args.mode === "check") {
|
|
400
|
-
console.error(
|
|
430
|
+
console.error(
|
|
431
|
+
" No package scanned: the guard would pass vacuously. Run it from a kumiko-framework checkout that contains the framework package.",
|
|
432
|
+
);
|
|
401
433
|
return { scans, baselineStatus: "ok", findings: [vacuousFinding()] };
|
|
402
434
|
}
|
|
403
435
|
|
|
@@ -423,9 +455,74 @@ export function evaluatePublicExportsChangelog(args: {
|
|
|
423
455
|
}
|
|
424
456
|
|
|
425
457
|
const findings = findingsAgainstBaseline(parsed.baseline, scans);
|
|
458
|
+
if (args.baseBaseline) {
|
|
459
|
+
const alreadyReported = new Set(findings.map(findingIdentity));
|
|
460
|
+
const ratchet = uncoveredDrops(
|
|
461
|
+
args.cwd,
|
|
462
|
+
scans,
|
|
463
|
+
args.baseBaseline,
|
|
464
|
+
args.pendingBreakingChanges ?? new Map(),
|
|
465
|
+
);
|
|
466
|
+
findings.push(...ratchet.filter((f) => !alreadyReported.has(findingIdentity(f))));
|
|
467
|
+
}
|
|
426
468
|
return { scans, baselineStatus: "ok", findings };
|
|
427
469
|
}
|
|
428
470
|
|
|
471
|
+
function findingIdentity(f: UncoveredFinding): string {
|
|
472
|
+
return `${f.pkg}|${f.key}|${f.names.join(",")}`;
|
|
473
|
+
}
|
|
474
|
+
|
|
475
|
+
// ── Baseline of the merge-base ───────────────────────────────────────────
|
|
476
|
+
|
|
477
|
+
export type BaseBaselineRead =
|
|
478
|
+
| { readonly kind: "absent" }
|
|
479
|
+
| { readonly kind: "ok"; readonly baseline: BaselinePayload }
|
|
480
|
+
| { readonly kind: "error"; readonly detail: string };
|
|
481
|
+
|
|
482
|
+
function git(args: readonly string[], cwd: string): { ok: true; out: string } | { ok: false } {
|
|
483
|
+
const res = spawnSync("git", args, { cwd, encoding: "utf-8" });
|
|
484
|
+
return res.status === 0 ? { ok: true, out: res.stdout } : { ok: false };
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
export function readBaseBaseline(cwd: string, baseRef: string): BaseBaselineRead {
|
|
488
|
+
if (baseRef === "" || baseRef.startsWith("-")) {
|
|
489
|
+
return {
|
|
490
|
+
kind: "error",
|
|
491
|
+
detail: `--base must be a ref name such as origin/main (got "${baseRef}"); an empty value or a leading "-" would be read as a git option.`,
|
|
492
|
+
};
|
|
493
|
+
}
|
|
494
|
+
const mergeBase = git(["merge-base", baseRef, "HEAD"], cwd);
|
|
495
|
+
if (!mergeBase.ok) {
|
|
496
|
+
return {
|
|
497
|
+
kind: "error",
|
|
498
|
+
detail: `git merge-base ${baseRef} HEAD failed — is ${baseRef} known locally? On shallow checkouts fetch the full history or the ref.`,
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
const commit = mergeBase.out.trim();
|
|
502
|
+
const listed = git(["ls-tree", "--name-only", commit, "--", BASELINE_FILE], cwd);
|
|
503
|
+
if (!listed.ok) return { kind: "error", detail: `git ls-tree ${commit} failed.` };
|
|
504
|
+
if (listed.out.trim() === "") return { kind: "absent" };
|
|
505
|
+
const shown = git(["show", `${commit}:./${BASELINE_FILE}`], cwd);
|
|
506
|
+
if (!shown.ok) return { kind: "error", detail: `git show ${commit}:${BASELINE_FILE} failed.` };
|
|
507
|
+
let raw: unknown;
|
|
508
|
+
try {
|
|
509
|
+
raw = JSON.parse(shown.out);
|
|
510
|
+
} catch (e) {
|
|
511
|
+
console.error(
|
|
512
|
+
` Baseline on ${baseRef} unreadable (${e instanceof Error ? e.message : String(e)}); nothing to ratchet against.`,
|
|
513
|
+
);
|
|
514
|
+
return { kind: "absent" };
|
|
515
|
+
}
|
|
516
|
+
const parsed = parseBaseline(raw);
|
|
517
|
+
if (parsed.kind !== "ok") {
|
|
518
|
+
console.error(
|
|
519
|
+
` Baseline on ${baseRef} is ${parsed.kind} (${parsed.detail}); nothing to ratchet against.`,
|
|
520
|
+
);
|
|
521
|
+
return { kind: "absent" };
|
|
522
|
+
}
|
|
523
|
+
return { kind: "ok", baseline: parsed.baseline };
|
|
524
|
+
}
|
|
525
|
+
|
|
429
526
|
// ── Open changesets ───────────────────────────────────────────────────────
|
|
430
527
|
|
|
431
528
|
type ChangesetParser = (markdown: string, source: string) => readonly unknown[];
|
|
@@ -441,11 +538,16 @@ export async function loadChangesetParser(frameworkRootAbsPath: string): Promise
|
|
|
441
538
|
frameworkRootAbsPath,
|
|
442
539
|
"packages/framework/src/engine/changeset-changes.ts",
|
|
443
540
|
);
|
|
444
|
-
if (!existsSync(parserPath))
|
|
541
|
+
if (!existsSync(parserPath))
|
|
542
|
+
throw new Error(
|
|
543
|
+
`changeset parser not found: ${parserPath}. Run the guard from a complete kumiko-framework checkout.`,
|
|
544
|
+
);
|
|
445
545
|
const loaded: unknown = await import(pathToFileURL(parserPath).href);
|
|
446
546
|
const parse = isRecord(loaded) ? loaded["parseChangesetChanges"] : undefined;
|
|
447
547
|
if (!isChangesetParser(parse)) {
|
|
448
|
-
throw new Error(
|
|
548
|
+
throw new Error(
|
|
549
|
+
`${parserPath} does not export parseChangesetChanges as a function. Export it from that file.`,
|
|
550
|
+
);
|
|
449
551
|
}
|
|
450
552
|
return parse;
|
|
451
553
|
}
|
|
@@ -493,15 +595,15 @@ export async function loadPendingBreakingChanges(
|
|
|
493
595
|
return pendingBreakingChangesIn(join(frameworkRootAbsPath, ".changeset"), parse);
|
|
494
596
|
}
|
|
495
597
|
|
|
496
|
-
// A feature-wide breaking block must not cover unrelated drops:
|
|
497
|
-
// to
|
|
598
|
+
// A feature-wide breaking block must not cover unrelated drops: every dropped
|
|
599
|
+
// export has to be named by some block of the feature.
|
|
498
600
|
export function isCoveredByPendingChangeset(
|
|
499
601
|
pending: PendingBreakingChanges,
|
|
500
602
|
feature: string,
|
|
501
603
|
droppedNames: readonly string[],
|
|
502
604
|
): boolean {
|
|
503
605
|
const texts = pending.get(feature) ?? [];
|
|
504
|
-
return texts.
|
|
606
|
+
return texts.length > 0 && everyNameMentioned(texts, droppedNames);
|
|
505
607
|
}
|
|
506
608
|
|
|
507
609
|
// ── Write baseline ────────────────────────────────────────────────────────
|
|
@@ -560,7 +662,7 @@ export function writeBaseline(
|
|
|
560
662
|
const frameworkScan = scans.find((s) => s.pkg === "framework");
|
|
561
663
|
if (!frameworkScan) {
|
|
562
664
|
console.error(
|
|
563
|
-
" framework package.json missing: asOfVersion undetermined, baseline not written.",
|
|
665
|
+
" framework package.json missing: asOfVersion undetermined, baseline not written. Run it from a kumiko-framework checkout.",
|
|
564
666
|
);
|
|
565
667
|
return 1;
|
|
566
668
|
}
|
|
@@ -644,13 +746,40 @@ function reportViolations(violations: readonly GuardViolation[]): void {
|
|
|
644
746
|
]);
|
|
645
747
|
}
|
|
646
748
|
|
|
749
|
+
// undefined = the ratchet inputs could not be read; the diagnosis is printed already.
|
|
750
|
+
async function baseRatchetFor(
|
|
751
|
+
scanCwd: string,
|
|
752
|
+
mode: EvaluateMode,
|
|
753
|
+
baseRef: string | undefined,
|
|
754
|
+
): Promise<
|
|
755
|
+
{ baseBaseline?: BaselinePayload; pendingBreakingChanges?: PendingBreakingChanges } | undefined
|
|
756
|
+
> {
|
|
757
|
+
if (baseRef === undefined || mode !== "check") return {};
|
|
758
|
+
const base = readBaseBaseline(scanCwd, baseRef);
|
|
759
|
+
if (base.kind === "error") {
|
|
760
|
+
console.error(` ${base.detail}`);
|
|
761
|
+
return undefined;
|
|
762
|
+
}
|
|
763
|
+
if (base.kind === "absent") return {};
|
|
764
|
+
try {
|
|
765
|
+
const pendingBreakingChanges = await loadPendingBreakingChanges(scanCwd);
|
|
766
|
+
return { baseBaseline: base.baseline, pendingBreakingChanges };
|
|
767
|
+
} catch (e) {
|
|
768
|
+
console.error(
|
|
769
|
+
` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
|
|
770
|
+
);
|
|
771
|
+
return undefined;
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
|
|
647
775
|
export async function runPublicExportsChangelogCli(
|
|
648
776
|
argv: readonly string[],
|
|
649
777
|
cwd: string = process.cwd(),
|
|
650
778
|
): Promise<number> {
|
|
779
|
+
const baseArg = argv.find((arg) => arg.startsWith(BASE_FLAG_PREFIX));
|
|
651
780
|
const flagsError = cliFlagsError(
|
|
652
781
|
"public-exports-changelog",
|
|
653
|
-
argv,
|
|
782
|
+
argv.filter((arg) => !arg.startsWith(BASE_FLAG_PREFIX)),
|
|
654
783
|
PUBLIC_EXPORTS_CHANGELOG_FLAGS,
|
|
655
784
|
);
|
|
656
785
|
if (flagsError !== undefined) {
|
|
@@ -665,7 +794,9 @@ export async function runPublicExportsChangelogCli(
|
|
|
665
794
|
}
|
|
666
795
|
const tsConfigFilePath = frameworkTsConfigPath(cwd);
|
|
667
796
|
if (tsConfigFilePath === undefined) {
|
|
668
|
-
console.error(
|
|
797
|
+
console.error(
|
|
798
|
+
" public-exports-changelog: framework tsconfig.json not found. Run it from a complete kumiko-framework checkout.",
|
|
799
|
+
);
|
|
669
800
|
return 1;
|
|
670
801
|
}
|
|
671
802
|
const project = new Project({ tsConfigFilePath, skipAddingFilesFromTsConfig: true });
|
|
@@ -676,14 +807,18 @@ export async function runPublicExportsChangelogCli(
|
|
|
676
807
|
try {
|
|
677
808
|
pending = await loadPendingBreakingChanges(scanCwd);
|
|
678
809
|
} catch (e) {
|
|
679
|
-
console.error(
|
|
810
|
+
console.error(
|
|
811
|
+
` Cannot read open changesets: ${e instanceof Error ? e.message : String(e)}. Fix the changeset file named above.`,
|
|
812
|
+
);
|
|
680
813
|
return 1;
|
|
681
814
|
}
|
|
682
815
|
return writeBaseline(scanCwd, project, join(scanCwd, BASELINE_FILE), pending);
|
|
683
816
|
}
|
|
684
817
|
|
|
685
818
|
const mode: EvaluateMode = argv.includes("--no-baseline") ? "no-baseline" : "check";
|
|
686
|
-
const
|
|
819
|
+
const ratchet = await baseRatchetFor(scanCwd, mode, baseArg?.slice(BASE_FLAG_PREFIX.length));
|
|
820
|
+
if (ratchet === undefined) return 1;
|
|
821
|
+
const result = evaluatePublicExportsChangelog({ cwd: scanCwd, project, mode, ...ratchet });
|
|
687
822
|
report(result);
|
|
688
823
|
const baselineBroken =
|
|
689
824
|
result.baselineStatus === "format-drift" || result.baselineStatus === "missing";
|
|
@@ -177,9 +177,13 @@ function declarationBaseCallee(callee: Node): Node {
|
|
|
177
177
|
return Node.isCallExpression(callee) ? declarationBaseCallee(callee.getExpression()) : callee;
|
|
178
178
|
}
|
|
179
179
|
|
|
180
|
+
// An identifier can be the Playwright test body (`test(title, details, body)`) or an options
|
|
181
|
+
// variable like `{ retry: 2 }`, so it only counts when its type is a number or carries `timeout`.
|
|
180
182
|
function isTimeoutArgument(arg: Node): boolean {
|
|
181
183
|
if (Node.isObjectLiteralExpression(arg)) return hasTimeoutProperty(arg);
|
|
182
|
-
|
|
184
|
+
if (Node.isArrowFunction(arg) || Node.isFunctionExpression(arg)) return false;
|
|
185
|
+
const type = arg.getType();
|
|
186
|
+
return type.isNumber() || type.isNumberLiteral() || type.getProperty("timeout") !== undefined;
|
|
183
187
|
}
|
|
184
188
|
|
|
185
189
|
function declarationTimeoutReason(
|
|
@@ -377,6 +377,9 @@ const SCAN: ScanSpec = {
|
|
|
377
377
|
frameworkWithin: ["packages/*/src/**"],
|
|
378
378
|
};
|
|
379
379
|
|
|
380
|
+
const WRAPPER_FIX =
|
|
381
|
+
"call the delegate directly, or mark a deliberate wrapper with // @wrapper-known <reason>";
|
|
382
|
+
|
|
380
383
|
export const check: RepoCheck = {
|
|
381
384
|
name: "Thin-Wrappers Guard",
|
|
382
385
|
run(roots) {
|
|
@@ -403,12 +406,16 @@ export const check: RepoCheck = {
|
|
|
403
406
|
scanned++;
|
|
404
407
|
for (const f of collectFindings(sf)) {
|
|
405
408
|
if (f.markerReason === null) {
|
|
406
|
-
warnings.push({
|
|
409
|
+
warnings.push({
|
|
410
|
+
file: f.file,
|
|
411
|
+
line: f.line,
|
|
412
|
+
message: `${f.fnName} → ${f.callee} (${WRAPPER_FIX})`,
|
|
413
|
+
});
|
|
407
414
|
} else if (!isKnownReason(f.markerReason)) {
|
|
408
415
|
warnings.push({
|
|
409
416
|
file: f.file,
|
|
410
417
|
line: f.line,
|
|
411
|
-
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)`,
|
|
412
419
|
});
|
|
413
420
|
}
|
|
414
421
|
}
|