@cosmicdrift/kumiko-guards 0.363.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/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/security-audit.ts +390 -0
- package/src/changes.json +35 -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 +26 -9
- 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-pre-es-patterns.ts +3 -0
- package/src/guard-public-exports-changelog.ts +22 -9
- 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 +19 -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
|
|
@@ -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);
|
|
@@ -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
|
}
|
|
@@ -21,6 +21,7 @@
|
|
|
21
21
|
* bun guard-upgrade-state.ts
|
|
22
22
|
*/
|
|
23
23
|
|
|
24
|
+
import { createHash } from "node:crypto";
|
|
24
25
|
import { existsSync, readFileSync } from "node:fs";
|
|
25
26
|
import { join } from "node:path";
|
|
26
27
|
import {
|
|
@@ -84,19 +85,45 @@ export function isUpgradeJson(value: unknown): value is UpgradeJson {
|
|
|
84
85
|
);
|
|
85
86
|
}
|
|
86
87
|
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
);
|
|
88
|
+
// Same id scheme as manualEntryId in @cosmicdrift/kumiko-framework/upgrade-cli. Duplicated because
|
|
89
|
+
// this package must stay installable without the framework; a test pins both to the same value.
|
|
90
|
+
const MANUAL_ID_HASH_LENGTH = 8;
|
|
91
|
+
|
|
92
|
+
export function manualEntryId(version: string, title: string): string {
|
|
93
|
+
const hash = createHash("sha256").update(title).digest("hex").slice(0, MANUAL_ID_HASH_LENGTH);
|
|
94
|
+
return `${version}:${hash}`;
|
|
95
95
|
}
|
|
96
96
|
|
|
97
|
-
|
|
97
|
+
/** Markers written before entries carried an `id` get the id the CLI derives for them. */
|
|
98
|
+
export function parsePendingManualStep(value: unknown): PendingManualStep | null {
|
|
99
|
+
if (!isRecord(value)) return null;
|
|
100
|
+
const { id, version, title } = value;
|
|
101
|
+
if (typeof version !== "string" || typeof title !== "string") return null;
|
|
102
|
+
return {
|
|
103
|
+
id: typeof id === "string" && id.length > 0 ? id : manualEntryId(version, title),
|
|
104
|
+
version,
|
|
105
|
+
title,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function readPendingManual(
|
|
110
|
+
parsed: unknown,
|
|
111
|
+
): { readonly steps: readonly PendingManualStep[] } | { readonly error: string } {
|
|
98
112
|
const raw = isRecord(parsed) ? parsed["pendingManual"] : undefined;
|
|
99
|
-
|
|
113
|
+
if (raw === undefined) return { steps: [] };
|
|
114
|
+
if (!Array.isArray(raw))
|
|
115
|
+
return { error: `${MARKER_REL} has a "pendingManual" that is not an array` };
|
|
116
|
+
const steps: PendingManualStep[] = [];
|
|
117
|
+
for (const [index, entry] of raw.entries()) {
|
|
118
|
+
const step = parsePendingManualStep(entry);
|
|
119
|
+
if (step === null) {
|
|
120
|
+
return {
|
|
121
|
+
error: `${MARKER_REL} has a malformed pendingManual entry at index ${index} (needs string "version" and "title")`,
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
steps.push(step);
|
|
125
|
+
}
|
|
126
|
+
return { steps };
|
|
100
127
|
}
|
|
101
128
|
|
|
102
129
|
export function readMarker(root: string): UpgradeMarker | { error: string } {
|
|
@@ -118,7 +145,9 @@ export function readMarker(root: string): UpgradeMarker | { error: string } {
|
|
|
118
145
|
error: `${MARKER_REL} is missing a valid "version" field (expected semver x.y.z[-pre][+build]) — broken marker file`,
|
|
119
146
|
};
|
|
120
147
|
}
|
|
121
|
-
|
|
148
|
+
const pendingManual = readPendingManual(parsed);
|
|
149
|
+
if ("error" in pendingManual) return pendingManual;
|
|
150
|
+
return { version, pendingManual: pendingManual.steps };
|
|
122
151
|
}
|
|
123
152
|
|
|
124
153
|
/** Pending changelog entries turned into guard violations — pure, testable without a subprocess. */
|
package/src/index.ts
CHANGED
package/src/pre-push.sh
CHANGED
|
@@ -18,6 +18,9 @@
|
|
|
18
18
|
# sub-repo's own package.json `test` script — refuses the push if that
|
|
19
19
|
# script is missing.
|
|
20
20
|
#
|
|
21
|
+
# Before the checks below, the hook runs a gitleaks scan of origin/main..HEAD
|
|
22
|
+
# when the binary is installed.
|
|
23
|
+
#
|
|
21
24
|
# Extension point: a tracked, executable scripts/pre-push-extra.sh runs
|
|
22
25
|
# before the main check in every branch above, with KUMIKO_PUSH_REPO_ROOT
|
|
23
26
|
# and KUMIKO_PUSH_PARENT_DIR available inline (never exported).
|
|
@@ -118,6 +121,22 @@ if [ -f "$REPO_ROOT/.kumiko/upgrade-state.json" ]; then
|
|
|
118
121
|
fi
|
|
119
122
|
fi
|
|
120
123
|
|
|
124
|
+
# Secret scan: runs before check-wt.sh/bun check so a leaked credential is
|
|
125
|
+
# caught in seconds, not after the long checks. A missing binary or a missing
|
|
126
|
+
# origin/main only warns, so a machine without gitleaks is not locked out.
|
|
127
|
+
# No --config: gitleaks reads a .gitleaks.toml in the repo root itself.
|
|
128
|
+
if ! command -v gitleaks >/dev/null 2>&1; then
|
|
129
|
+
echo "[pre-push] gitleaks not installed — secret scan skipped. Install: brew install gitleaks" >&2
|
|
130
|
+
elif ! git -C "$REPO_ROOT" rev-parse --verify --quiet origin/main >/dev/null 2>&1; then
|
|
131
|
+
echo "[pre-push] origin/main not found — secret scan skipped" >&2
|
|
132
|
+
else
|
|
133
|
+
echo "[pre-push] gitleaks git origin/main..HEAD"
|
|
134
|
+
if ! (cd "$REPO_ROOT" && gitleaks git --no-banner --redact --log-opts="origin/main..HEAD" .); then
|
|
135
|
+
echo "[pre-push] gitleaks found secrets — refusing push" >&2
|
|
136
|
+
exit 1
|
|
137
|
+
fi
|
|
138
|
+
fi
|
|
139
|
+
|
|
121
140
|
# infra#559/#569: from a worktree, `--git-common-dir` resolves to the main
|
|
122
141
|
# checkout's .git, so the branch below would scope `bun check` to the
|
|
123
142
|
# canonical sibling path instead of this worktree — checking the wrong
|
package/src/run-repo-checks.ts
CHANGED
|
@@ -7,6 +7,7 @@ import { check as realProviderIsolation } from "./check-real-provider-isolation"
|
|
|
7
7
|
import { check as runtimeIsolation } from "./check-runtime-isolation";
|
|
8
8
|
import { check as secretLiterals } from "./check-secret-literals";
|
|
9
9
|
import { check as singleRuntimeInstance } from "./check-single-runtime-instance";
|
|
10
|
+
import { check as agentDocsDrift } from "./guard-agent-docs-drift";
|
|
10
11
|
import { check as featureIntegrationTests } from "./guard-feature-integration-tests";
|
|
11
12
|
import { check as noDirectProcessEnv } from "./guard-no-direct-process-env";
|
|
12
13
|
import { check as primitivesDiscipline } from "./guard-primitives-discipline";
|
|
@@ -29,6 +30,7 @@ export const REPO_CHECKS = [
|
|
|
29
30
|
upgradeState,
|
|
30
31
|
realProviderIsolation,
|
|
31
32
|
singleRuntimeInstance,
|
|
33
|
+
agentDocsDrift,
|
|
32
34
|
];
|
|
33
35
|
|
|
34
36
|
export const REPO_CHECK_FLAGS: readonly string[] = ["--write-baseline"];
|