@cosmicdrift/kumiko-guards 0.386.0 → 0.388.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/LICENSE +21 -57
- package/package.json +4 -4
- package/src/_lib/agent-docs.ts +12 -7
- package/src/_lib/comment-trivia.ts +148 -0
- package/src/_lib/guard-kit.ts +2 -2
- package/src/_lib/guards-doc.ts +4 -1
- package/src/_lib/roots.ts +9 -5
- package/src/_lib/run-slots.ts +39 -10
- package/src/_lib/scan-scope.ts +1 -1
- package/src/_lib/security-audit.ts +15 -8
- package/src/_lib/sql-inventory.ts +2 -2
- package/src/changes.json +45 -0
- package/src/check-as-casts.ts +6 -7
- package/src/check-complexity.ts +86 -29
- package/src/check-licenses.ts +23 -6
- package/src/check-real-provider-isolation.ts +1 -1
- package/src/check-runtime-isolation.ts +2 -2
- package/src/check-security.ts +2 -1
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/dependency-license-exceptions.json +0 -24
- package/src/guard-admin-api.ts +5 -6
- package/src/guard-app-feature-structure.ts +9 -9
- package/src/guard-comment-issue-ref-only.ts +223 -0
- package/src/guard-comment-lang.ts +10 -141
- package/src/guard-direct-entity-writes.ts +43 -31
- package/src/guard-direct-fetch.ts +3 -3
- package/src/guard-feature-integration-tests.ts +1 -1
- package/src/guard-file-size.ts +2 -2
- package/src/guard-i18n-locale-mount.ts +4 -4
- package/src/guard-i18n-locale-terminology.ts +3 -3
- package/src/guard-i18n-ui-strings.ts +5 -5
- package/src/guard-import-extensions.ts +34 -9
- package/src/guard-no-custom-primitives.ts +2 -4
- package/src/guard-no-direct-fs.ts +1 -1
- package/src/guard-no-direct-process-env.ts +2 -2
- package/src/guard-no-inline-styles.ts +1 -1
- package/src/guard-no-raw-hooks.ts +0 -2
- package/src/guard-primitives-discipline.ts +1 -1
- package/src/guard-public-exports-changelog.ts +8 -8
- package/src/guard-raw-classname.ts +1 -1
- package/src/guard-raw-interactive-elements.ts +1 -1
- package/src/guard-renderer-boundaries.ts +61 -9
- package/src/guard-restricted-symbols.ts +2 -3
- package/src/guard-tailwind-scan-surface.ts +28 -29
- package/src/guard-tenant-escalation.ts +3 -3
- package/src/guard-test-template-drift.ts +1 -1
- package/src/guard-test-timeouts.ts +12 -4
- package/src/guard-thin-wrappers.ts +35 -0
- package/src/guard-write-handler-qns.ts +1 -1
- package/src/pre-push.sh +22 -6
- package/src/repo-manifest/app-ports/static-app-config.ts +15 -11
- package/src/run-guards.ts +11 -4
- package/src/run-ui-guards.ts +1 -1
- package/src/runtime-isolation-classify.ts +3 -2
package/src/check-complexity.ts
CHANGED
|
@@ -21,14 +21,14 @@
|
|
|
21
21
|
*
|
|
22
22
|
* Opt-out: `// kumiko-lint-ignore complexity-budget <reason>` on the function
|
|
23
23
|
* line, the line above, or (for JSDoc-documented functions) the line above
|
|
24
|
-
* the JSDoc block.
|
|
24
|
+
* the JSDoc block. The opt-out does NOT hold above HARD_COMPLEXITY_CAP: such a
|
|
25
|
+
* function in the repo's own files always fails, regardless of baseline or tag.
|
|
25
26
|
*
|
|
26
27
|
* Usage:
|
|
27
28
|
* bun guards/check-complexity.ts # compare against baseline
|
|
28
29
|
* bun guards/check-complexity.ts --write-baseline # rewrite baseline
|
|
29
30
|
* bun guards/check-complexity.ts --no-baseline # skip comparison
|
|
30
|
-
*
|
|
31
|
-
* Rule: issue kumiko-framework#1282
|
|
31
|
+
* bun guards/check-complexity.ts --all # list every hotspot, not the first 30
|
|
32
32
|
*/
|
|
33
33
|
|
|
34
34
|
import * as path from "node:path";
|
|
@@ -60,6 +60,7 @@ const EXCLUDE = /(__tests__|\.test\.ts$|\.integration\.ts$|\.d\.ts$)/;
|
|
|
60
60
|
|
|
61
61
|
const COMPLEXITY_THRESHOLD = 15;
|
|
62
62
|
const MAX_REPORTED = 30;
|
|
63
|
+
export const HARD_COMPLEXITY_CAP = 40;
|
|
63
64
|
const BUDGET_TAG = "kumiko-lint-ignore complexity-budget";
|
|
64
65
|
|
|
65
66
|
const FUNCTION_KINDS = [
|
|
@@ -154,26 +155,49 @@ export function computeComplexity(fn: Node): number {
|
|
|
154
155
|
return complexity;
|
|
155
156
|
}
|
|
156
157
|
|
|
157
|
-
export
|
|
158
|
+
export interface FunctionMetric extends Hotspot {
|
|
159
|
+
budgetTagged: boolean;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Single walk per file: complexity is computed once and feeds both the
|
|
163
|
+
// hotspot list and the hard cap.
|
|
164
|
+
export function collectFunctionMetrics(
|
|
158
165
|
sf: SourceFile,
|
|
159
166
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
160
|
-
):
|
|
167
|
+
): FunctionMetric[] {
|
|
161
168
|
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
162
|
-
const
|
|
169
|
+
const metrics: FunctionMetric[] = [];
|
|
163
170
|
for (const kind of FUNCTION_KINDS) {
|
|
164
171
|
for (const fn of sf.getDescendantsOfKind(kind)) {
|
|
165
172
|
const complexity = computeComplexity(fn);
|
|
166
173
|
if (complexity < COMPLEXITY_THRESHOLD) continue;
|
|
167
|
-
|
|
168
|
-
hotspots.push({
|
|
174
|
+
metrics.push({
|
|
169
175
|
file,
|
|
170
176
|
line: fn.getStartLineNumber(),
|
|
171
177
|
name: functionName(fn),
|
|
172
178
|
complexity,
|
|
179
|
+
budgetTagged: hasBudgetTag(fn),
|
|
173
180
|
});
|
|
174
181
|
}
|
|
175
182
|
}
|
|
176
|
-
return
|
|
183
|
+
return metrics;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export function hotspotsOf(metrics: readonly FunctionMetric[]): Hotspot[] {
|
|
187
|
+
return metrics
|
|
188
|
+
.filter((m) => !m.budgetTagged)
|
|
189
|
+
.map(({ file, line, name, complexity }) => ({ file, line, name, complexity }));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
export function capBreachesOf(metrics: readonly FunctionMetric[]): FunctionMetric[] {
|
|
193
|
+
return metrics.filter((m) => m.complexity > HARD_COMPLEXITY_CAP && isLocalFinding(m));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
export function collectHotspots(
|
|
197
|
+
sf: SourceFile,
|
|
198
|
+
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
199
|
+
): Hotspot[] {
|
|
200
|
+
return hotspotsOf(collectFunctionMetrics(sf, roots));
|
|
177
201
|
}
|
|
178
202
|
|
|
179
203
|
const BASELINE_FILE = ".kumiko-complexity-baseline.json";
|
|
@@ -201,26 +225,37 @@ function scan(
|
|
|
201
225
|
roots: readonly RepoRoot[] = resolveRepoRoots(),
|
|
202
226
|
): {
|
|
203
227
|
hotspots: Hotspot[];
|
|
228
|
+
capBreaches: FunctionMetric[];
|
|
204
229
|
scanned: number;
|
|
205
230
|
} {
|
|
206
|
-
const
|
|
231
|
+
const metrics: FunctionMetric[] = [];
|
|
207
232
|
let scanned = 0;
|
|
208
233
|
for (const sf of files) {
|
|
209
234
|
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
210
235
|
scanned++;
|
|
211
|
-
|
|
236
|
+
metrics.push(...collectFunctionMetrics(sf, roots));
|
|
212
237
|
}
|
|
213
|
-
hotspots.sort((a, b) => b.complexity - a.complexity);
|
|
214
|
-
|
|
238
|
+
const hotspots = hotspotsOf(metrics).sort((a, b) => b.complexity - a.complexity);
|
|
239
|
+
const capBreaches = capBreachesOf(metrics).sort((a, b) => b.complexity - a.complexity);
|
|
240
|
+
return { hotspots, capBreaches, scanned };
|
|
215
241
|
}
|
|
216
242
|
|
|
217
|
-
function report(
|
|
243
|
+
function report(
|
|
244
|
+
hotspots: readonly Hotspot[],
|
|
245
|
+
scanned: number,
|
|
246
|
+
capBreaches: readonly FunctionMetric[],
|
|
247
|
+
reportLimit: number,
|
|
248
|
+
): void {
|
|
218
249
|
console.log(`Complexity Check: ${scanned} files checked.`);
|
|
219
250
|
console.log(` Hotspots (complexity >= ${COMPLEXITY_THRESHOLD}): ${hotspots.length}`);
|
|
251
|
+
console.log(` Above hard cap (complexity > ${HARD_COMPLEXITY_CAP}): ${capBreaches.length}`);
|
|
252
|
+
for (const b of capBreaches) {
|
|
253
|
+
console.log(` CAP ${b.file}:${b.line} ${b.name}() complexity=${b.complexity}`);
|
|
254
|
+
}
|
|
220
255
|
if (hotspots.length === 0) {
|
|
221
256
|
console.log(" Nothing to report.");
|
|
222
257
|
} else {
|
|
223
|
-
const shown = hotspots.slice(0,
|
|
258
|
+
const shown = hotspots.slice(0, reportLimit);
|
|
224
259
|
for (const h of shown) {
|
|
225
260
|
console.log(` ${h.file}:${h.line} ${h.name}() complexity=${h.complexity}`);
|
|
226
261
|
}
|
|
@@ -233,12 +268,25 @@ function report(hotspots: readonly Hotspot[], scanned: number): void {
|
|
|
233
268
|
}
|
|
234
269
|
}
|
|
235
270
|
|
|
271
|
+
type Ratchet = ReturnType<typeof baselineRatchet>;
|
|
272
|
+
|
|
236
273
|
const complexityBaseline = baselineRatchet({
|
|
237
274
|
file: path.join(ROOT, BASELINE_FILE),
|
|
238
275
|
formatVersion: BASELINE_FORMAT_VERSION,
|
|
239
276
|
unit: "Complexity-Hotspot(s)",
|
|
240
277
|
});
|
|
241
278
|
|
|
279
|
+
function capViolations(capBreaches: readonly FunctionMetric[]): GuardViolation[] {
|
|
280
|
+
return capBreaches.map((b) => ({
|
|
281
|
+
file: b.file,
|
|
282
|
+
line: b.line,
|
|
283
|
+
neverFrozen: true,
|
|
284
|
+
message:
|
|
285
|
+
`${b.name}() has complexity ${b.complexity}, above the hard cap of ${HARD_COMPLEXITY_CAP}. ` +
|
|
286
|
+
`A "${BUDGET_TAG}" tag and the baseline do not apply above the cap — split the function.`,
|
|
287
|
+
}));
|
|
288
|
+
}
|
|
289
|
+
|
|
242
290
|
// `scan` sorts by complexity descending, so the first hit for a file is its
|
|
243
291
|
// worst function — the one the violation should point at, not whichever
|
|
244
292
|
// hotspot happens to come first in the file.
|
|
@@ -246,9 +294,9 @@ export function resolveHotspotLine(hotspots: readonly Hotspot[], file: string):
|
|
|
246
294
|
return hotspots.find((h) => h.file === file)?.line ?? 1;
|
|
247
295
|
}
|
|
248
296
|
|
|
249
|
-
function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
|
|
297
|
+
function checkBaseline(scanned: readonly Hotspot[], ratchet: Ratchet): GuardViolation[] {
|
|
250
298
|
const hotspots = localHotspots(scanned);
|
|
251
|
-
return
|
|
299
|
+
return ratchet.check(
|
|
252
300
|
countHotspotsByFile(hotspots),
|
|
253
301
|
`Split the function, or allow it with "// ${BUDGET_TAG} <reason>".`,
|
|
254
302
|
{
|
|
@@ -258,18 +306,21 @@ function checkBaseline(scanned: readonly Hotspot[]): GuardViolation[] {
|
|
|
258
306
|
);
|
|
259
307
|
}
|
|
260
308
|
|
|
261
|
-
function analyse(
|
|
309
|
+
export function analyse(
|
|
262
310
|
files: readonly SourceFile[],
|
|
263
311
|
roots: readonly RepoRoot[],
|
|
264
312
|
compareBaseline: boolean,
|
|
313
|
+
options: { ratchet?: Ratchet; reportLimit?: number } = {},
|
|
265
314
|
): GuardOutcome {
|
|
266
|
-
const {
|
|
267
|
-
|
|
315
|
+
const { ratchet = complexityBaseline, reportLimit = MAX_REPORTED } = options;
|
|
316
|
+
const { hotspots, capBreaches, scanned } = scan(files, roots);
|
|
317
|
+
report(hotspots, scanned, capBreaches, reportLimit);
|
|
318
|
+
const hardViolations = capViolations(capBreaches);
|
|
268
319
|
if (!compareBaseline) {
|
|
269
320
|
console.log(" Baseline comparison skipped (--no-baseline).");
|
|
270
|
-
return { violations:
|
|
321
|
+
return { violations: hardViolations };
|
|
271
322
|
}
|
|
272
|
-
return { violations: checkBaseline(hotspots) };
|
|
323
|
+
return { violations: [...hardViolations, ...checkBaseline(hotspots, ratchet)] };
|
|
273
324
|
}
|
|
274
325
|
|
|
275
326
|
export const guard: AstGuard = {
|
|
@@ -281,8 +332,8 @@ export const guard: AstGuard = {
|
|
|
281
332
|
hint: 'after a deliberate change: `kumiko-guards guards --write-baseline --guard="Complexity Check"`',
|
|
282
333
|
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true),
|
|
283
334
|
writeBaseline: (files) => {
|
|
284
|
-
const { hotspots, scanned } = scan(files);
|
|
285
|
-
report(hotspots, scanned);
|
|
335
|
+
const { hotspots, capBreaches, scanned } = scan(files);
|
|
336
|
+
report(hotspots, scanned, capBreaches, MAX_REPORTED);
|
|
286
337
|
complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
|
|
287
338
|
},
|
|
288
339
|
};
|
|
@@ -292,17 +343,23 @@ export const guard: AstGuard = {
|
|
|
292
343
|
// silently rewrite the baseline.
|
|
293
344
|
if (import.meta.main) {
|
|
294
345
|
const args = process.argv.slice(2);
|
|
346
|
+
const reportLimit = args.includes("--all") ? Number.POSITIVE_INFINITY : MAX_REPORTED;
|
|
295
347
|
if (args.includes("--write-baseline")) {
|
|
296
348
|
const project = buildSharedProject([guard]);
|
|
297
|
-
const { hotspots, scanned } = scan(filesForGuard(project, guard));
|
|
298
|
-
report(hotspots, scanned);
|
|
349
|
+
const { hotspots, capBreaches, scanned } = scan(filesForGuard(project, guard));
|
|
350
|
+
report(hotspots, scanned, capBreaches, reportLimit);
|
|
299
351
|
complexityBaseline.write(countHotspotsByFile(localHotspots(hotspots)));
|
|
300
352
|
process.exit(0);
|
|
301
353
|
}
|
|
302
354
|
if (args.includes("--no-baseline")) {
|
|
303
355
|
const project = buildSharedProject([guard]);
|
|
304
|
-
analyse(filesForGuard(project, guard), resolveRepoRoots(), false
|
|
305
|
-
|
|
356
|
+
const { violations } = analyse(filesForGuard(project, guard), resolveRepoRoots(), false, {
|
|
357
|
+
reportLimit,
|
|
358
|
+
});
|
|
359
|
+
process.exit(violations.length > 0 ? 1 : 0);
|
|
306
360
|
}
|
|
307
|
-
runStandalone(
|
|
361
|
+
runStandalone({
|
|
362
|
+
...guard,
|
|
363
|
+
run: (files, roots = resolveRepoRoots()) => analyse(files, roots, true, { reportLimit }),
|
|
364
|
+
});
|
|
308
365
|
}
|
package/src/check-licenses.ts
CHANGED
|
@@ -107,14 +107,30 @@ export function loadLicenseExceptions(
|
|
|
107
107
|
});
|
|
108
108
|
}
|
|
109
109
|
|
|
110
|
+
function normalizeLicenseId(license: string): string {
|
|
111
|
+
return license.replace(/[()]/g, "").trim().toUpperCase();
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function exceptionMatchesPackage(e: LicenseException, pkg: InstalledPackage): boolean {
|
|
115
|
+
if (!matchesPackageName(e.package, pkg.name)) return false;
|
|
116
|
+
// A package without a license field has nothing to contradict the recorded license.
|
|
117
|
+
if (pkg.license !== null && normalizeLicenseId(e.license) !== normalizeLicenseId(pkg.license)) {
|
|
118
|
+
return false;
|
|
119
|
+
}
|
|
120
|
+
return (
|
|
121
|
+
e.version === "*" || e.version === pkg.version || Bun.semver.satisfies(pkg.version, e.version)
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// A renewed repo exception must win over an expired bundled one for the same package, so prefer a
|
|
126
|
+
// still-valid match and only fall back to an expired one for the "expired" hint.
|
|
110
127
|
function findException(
|
|
111
128
|
pkg: InstalledPackage,
|
|
112
129
|
exceptions: readonly LicenseException[],
|
|
130
|
+
nowMs: number,
|
|
113
131
|
): LicenseException | undefined {
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
return e.version === "*" || e.version === pkg.version || /^[\^~]/.test(e.version);
|
|
117
|
-
});
|
|
132
|
+
const matching = exceptions.filter((e) => exceptionMatchesPackage(e, pkg));
|
|
133
|
+
return matching.find((e) => isExceptionValid(e.expires, nowMs).valid) ?? matching[0];
|
|
118
134
|
}
|
|
119
135
|
|
|
120
136
|
function splitExpression(expr: string, operator: "OR" | "AND"): string[] {
|
|
@@ -132,9 +148,10 @@ function matchesDenylist(license: string): boolean {
|
|
|
132
148
|
export function judgeLicense(
|
|
133
149
|
pkg: InstalledPackage,
|
|
134
150
|
exceptions: readonly LicenseException[],
|
|
151
|
+
nowMs: number = Date.now(),
|
|
135
152
|
): Verdict {
|
|
136
153
|
const license = pkg.license;
|
|
137
|
-
const exception = findException(pkg, exceptions);
|
|
154
|
+
const exception = findException(pkg, exceptions, nowMs);
|
|
138
155
|
if (!license) return { kind: "unknown", raw: null, exception };
|
|
139
156
|
|
|
140
157
|
const andParts = splitExpression(license, "AND");
|
|
@@ -181,7 +198,7 @@ export function evaluateLicenses(
|
|
|
181
198
|
exceptions: readonly LicenseException[],
|
|
182
199
|
nowMs: number = Date.now(),
|
|
183
200
|
): LicenseEvaluation {
|
|
184
|
-
const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions) }));
|
|
201
|
+
const judged = packages.map((pkg) => ({ pkg, verdict: judgeLicense(pkg, exceptions, nowMs) }));
|
|
185
202
|
const blocked = judged.filter(
|
|
186
203
|
({ verdict }) => verdict.kind !== "ok" && !hasValidException(verdict, nowMs),
|
|
187
204
|
);
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Real-Provider-Isolation-Guard (stack-
|
|
3
|
+
* Real-Provider-Isolation-Guard (stack-agnostic).
|
|
4
4
|
*
|
|
5
5
|
* Real-provider specs (`*.real.test.ts`, `*.real.spec.ts`) hit live third-party
|
|
6
6
|
* services and must only ever run opt-in (manually or scheduled) via `bun run test:real` /
|
|
@@ -43,8 +43,8 @@
|
|
|
43
43
|
// A file/import edge classified "client" importing "runtime" can be a real
|
|
44
44
|
// finding this guard needs to see, but can also be a known, reviewed
|
|
45
45
|
// exception (e.g. samples/recipes are worked examples, not the framework's
|
|
46
|
-
// own production surface —
|
|
47
|
-
//
|
|
46
|
+
// own production surface — one exception is currently frozen here).
|
|
47
|
+
// Exceptions are tracked via a per-repo baseline file
|
|
48
48
|
// (`.kumiko-runtime-isolation-baseline.json`, same `baselineRatchet`
|
|
49
49
|
// mechanism as check-complexity/etc.), not a
|
|
50
50
|
// hardcoded list in this source file: a consumer repo can freeze or clear
|
package/src/check-security.ts
CHANGED
|
@@ -157,7 +157,8 @@ export async function runSecurityCheck(options: {
|
|
|
157
157
|
const advisories: AuditAdvisory[] = parseAuditReport(raw);
|
|
158
158
|
|
|
159
159
|
const evaluation = evaluateSecurity(advisories, exceptions, nowMs);
|
|
160
|
-
|
|
160
|
+
// Only exceptions that suppressed an advisory in this run block; unused bundled ones must not break consumer CI.
|
|
161
|
+
const expiringUrgent = findExpiringSoon(evaluation.usedExceptions, EXPIRY_BLOCK_DAYS, nowMs);
|
|
161
162
|
printReport(evaluation, exceptions, expiringUrgent, nowMs);
|
|
162
163
|
|
|
163
164
|
if (options.reportOnly) return 0;
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
/**
|
|
3
|
-
* Single-Runtime-Instance Guard
|
|
3
|
+
* Single-Runtime-Instance Guard.
|
|
4
4
|
*
|
|
5
5
|
* A Kumiko runtime library resolved at two versions in one bun.lock (a nested
|
|
6
6
|
* copy under a feature package next to the top-level install) loads twice in
|
|
@@ -41,30 +41,6 @@
|
|
|
41
41
|
"reviewedBy": "Marc, 2026-05-06",
|
|
42
42
|
"expires": "2027-05-06"
|
|
43
43
|
},
|
|
44
|
-
{
|
|
45
|
-
"package": "@cosmicdriftgamestudio/*",
|
|
46
|
-
"version": "*",
|
|
47
|
-
"license": "UNLICENSED",
|
|
48
|
-
"reason": "Eigene private CDGS-Workspace-Packages (kumiko-guards, später kumiko-ai-*, kumiko-designer etc.). UNLICENSED = proprietary, kein Public-License-Risk weil intern in der Org bleibt (GH-Packages access:restricted).",
|
|
49
|
-
"reviewedBy": "Marc, 2026-05-06",
|
|
50
|
-
"expires": "2030-05-06"
|
|
51
|
-
},
|
|
52
|
-
{
|
|
53
|
-
"package": "@cosmicdrift/*",
|
|
54
|
-
"version": "*",
|
|
55
|
-
"license": "BUSL-1.1",
|
|
56
|
-
"reason": "Eigene Public-Workspace-Packages (kumiko-framework + 6 weitere). Wir sind der Licensor — BUSL-1.1 ist unser eigener Lizenz-Choice für die public NPM-Pakete. Der License-Check denied BUSL-1.1 als generic-Pattern (für Third-Party-Deps korrekt), aber für eigene Packages ist's der designierte Lizenz.",
|
|
57
|
-
"reviewedBy": "Marc, 2026-05-06",
|
|
58
|
-
"expires": "2030-05-06"
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
"package": "create-kumiko-app",
|
|
62
|
-
"version": "*",
|
|
63
|
-
"license": "BUSL-1.1",
|
|
64
|
-
"reason": "Unscoped Workspace-Paket — `bun create kumiko-app` resolved zu `bunx create-kumiko-app`, daher KEIN @cosmicdrift/-Scope möglich. Analog zur @cosmicdrift/*-Exception unser eigener Lizenz-Choice; eigene Eintrag weil Wildcard-Pattern unscoped Pakete nicht matched.",
|
|
65
|
-
"reviewedBy": "Marc, 2026-06-21",
|
|
66
|
-
"expires": "2030-06-21"
|
|
67
|
-
},
|
|
68
44
|
{
|
|
69
45
|
"package": "khroma",
|
|
70
46
|
"version": "*",
|
package/src/guard-admin-api.ts
CHANGED
|
@@ -3,11 +3,10 @@
|
|
|
3
3
|
* Guard: blocks calls to the event-store admin API (`appendRaw`/`appendRawBatch`)
|
|
4
4
|
* outside allowed paths.
|
|
5
5
|
*
|
|
6
|
-
* The admin API is a Marten bypass for legacy data imports
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* next projection rebuild, or never).
|
|
6
|
+
* The admin API is a Marten bypass for legacy data imports. It BYPASSES the
|
|
7
|
+
* pipeline — no projections, no postSave hooks, no SSE/search/audit. Used
|
|
8
|
+
* accidentally from application code, that causes state inconsistencies that
|
|
9
|
+
* surface very late (at the next projection rebuild, or never).
|
|
11
10
|
*
|
|
12
11
|
* Second line of defense next to the deep-import path: even if someone pulls
|
|
13
12
|
* `@kubiko/framework/event-store/admin-api` directly, this guard catches it
|
|
@@ -114,7 +113,7 @@ function findEnclosingName(call: CallExpression): string {
|
|
|
114
113
|
export const guard: AstGuard = {
|
|
115
114
|
name: "Admin-API Guard",
|
|
116
115
|
scan: SCAN,
|
|
117
|
-
// App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline
|
|
116
|
+
// App repos are not exempt — appendRaw/appendRawBatch bypasses the pipeline.
|
|
118
117
|
security: true,
|
|
119
118
|
hint: "Admin API (appendRaw/appendRawBatch) bypasses the pipeline — only allowed in samples/*/migration/ or scripts/migrations/. For domain events: ctx.appendEvent / write handler.",
|
|
120
119
|
run(files, roots: readonly RepoRoot[] = resolveRepoRoots()) {
|
|
@@ -13,8 +13,6 @@
|
|
|
13
13
|
// ponytail: the handler file convention (*.query.ts/*.write.ts under handlers/)
|
|
14
14
|
// is not enforced yet — add it once the registration API shapes are
|
|
15
15
|
// inventoried and stable.
|
|
16
|
-
//
|
|
17
|
-
// Part of App-Mounting 2.0 (infra#208).
|
|
18
16
|
|
|
19
17
|
import * as path from "node:path";
|
|
20
18
|
import { type SourceFile, SyntaxKind } from "ts-morph";
|
|
@@ -118,6 +116,8 @@ function checkCustomScreens(sf: SourceFile, violations: GuardViolation[]): void
|
|
|
118
116
|
const DIRECTIVE_HEAD_CHARS = 600;
|
|
119
117
|
const FEATURE_RELATIVE_PATH = /(?:src\/features|packages\/bundled-features\/src)\/[^/]+\/(.+)$/;
|
|
120
118
|
|
|
119
|
+
const PATH_IMPLIED_RUNTIME_DIRS = ["dev/", "scripts/"];
|
|
120
|
+
|
|
121
121
|
// 4. runtime-client marker instead of the path
|
|
122
122
|
function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
|
|
123
123
|
const filePath = sf.getFilePath();
|
|
@@ -127,13 +127,13 @@ function clientMarkerViolation(sf: SourceFile): GuardViolation | undefined {
|
|
|
127
127
|
directiveInHead(sf.getFullText().slice(0, DIRECTIVE_HEAD_CHARS)) === "client";
|
|
128
128
|
if (!hasClientMarker || isIgnoredFile(sf)) return undefined;
|
|
129
129
|
const underWeb = featureRelative.startsWith("web/") || /^web\.tsx?$/.test(featureRelative);
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
? "
|
|
135
|
-
: "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)"
|
|
136
|
-
};
|
|
130
|
+
const runtimeFromPath = PATH_IMPLIED_RUNTIME_DIRS.some((dir) => featureRelative.startsWith(dir));
|
|
131
|
+
const message = underWeb
|
|
132
|
+
? "redundant @runtime client marker — files under web/ are client by path"
|
|
133
|
+
: runtimeFromPath
|
|
134
|
+
? "@runtime client marker under dev/ or scripts/ — the path already sets the runtime; remove the marker (bun kumiko codemod feature-layout . --write)"
|
|
135
|
+
: "client code outside web/ — move it under web/ (bun kumiko codemod feature-layout . --write)";
|
|
136
|
+
return { file: filePath, line: 1, message };
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
export const guard: AstGuard = {
|
|
@@ -0,0 +1,223 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Comment Issue-Ref Guard: a comment that holds only an issue or PR reference or a planning
|
|
4
|
+
* label (`#1234`, `fw#1234`, `Phase 3`, `S2.H1`) and no explanatory word is a violation.
|
|
5
|
+
* The number belongs in the commit message; the comment has to carry the reason. Existing
|
|
6
|
+
* hits are frozen per file in `.kumiko-comment-issue-ref-baseline.json` and may not grow;
|
|
7
|
+
* without a baseline file (consumer repos) the guard only warns.
|
|
8
|
+
*
|
|
9
|
+
* Consecutive own-line `//` comments count as one paragraph, so a reference on the last
|
|
10
|
+
* line of an explaining paragraph is not a hit.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import path from "node:path";
|
|
14
|
+
import type { SourceFile } from "ts-morph";
|
|
15
|
+
import { COMMENT_KIND_LINE, type CommentTrivia, scanCommentTrivia } from "./_lib/comment-trivia";
|
|
16
|
+
import {
|
|
17
|
+
ALL_REPO_KINDS,
|
|
18
|
+
type AstGuard,
|
|
19
|
+
baselineRatchet,
|
|
20
|
+
buildSharedProject,
|
|
21
|
+
filesForGuard,
|
|
22
|
+
isLocalFinding,
|
|
23
|
+
relFromRepoRoot,
|
|
24
|
+
runStandalone,
|
|
25
|
+
type ScanSpec,
|
|
26
|
+
} from "./_lib/guard-kit";
|
|
27
|
+
import { lineHasIgnoreTag } from "./_lib/ignore-tag";
|
|
28
|
+
import { resolveRepoRoots } from "./_lib/roots";
|
|
29
|
+
|
|
30
|
+
const ROOT = process.cwd();
|
|
31
|
+
const GUARD_NAME = "comment-issue-ref-only";
|
|
32
|
+
const IGNORE_TAG = "kumiko-lint-ignore comment-issue-ref-only";
|
|
33
|
+
|
|
34
|
+
const SCAN: ScanSpec = {
|
|
35
|
+
scope: "source",
|
|
36
|
+
kinds: ALL_REPO_KINDS,
|
|
37
|
+
extensions: ["ts", "tsx"],
|
|
38
|
+
frameworkWithin: ["packages/*/src/**"],
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
const EXCLUDE = /(\.d\.(ts|tsx)$|\/dist\/)/;
|
|
42
|
+
|
|
43
|
+
// `fw#12`, `#1234`, `kumiko-framework#3`, `oven-sh/bun#5`; `'` is an HTML entity.
|
|
44
|
+
const REFERENCE_RE = /(?<!&)[\w./-]*#\d+(?![\w])/g;
|
|
45
|
+
// Not preceded by a path/word character and not followed by `/`, `-` or `.ext`, so file names such
|
|
46
|
+
// as `round2.ts` or `phase1.sql` are not planning labels, while a sentence-final "Phase 2." is.
|
|
47
|
+
const PLANNING_WORD_RE =
|
|
48
|
+
/(?<![\w./-])(?:phase|round|welle|wave|sprint)[- ]?\d+(?:\.\d+)*[a-z]?(?![\w/-]|\.\w)|(?<![\w./-])bug-bash(?:[- ]\d+)?(?![\w/-]|\.\w)/gi;
|
|
49
|
+
// Dotted plan labels such as S2.H1, T1.5c, B1.5. Case-sensitive and without V so
|
|
50
|
+
// that version numbers (v1.2, V20.1) never count.
|
|
51
|
+
const DOTTED_LABEL_RE = /(?<![\w./-])[A-UW-Z]\d{1,2}(?:\.(?:\d+[a-z]?|[A-Z]\d+))+\b/g;
|
|
52
|
+
const WORD_RE = /[\p{L}_$][\p{L}\p{N}_$'-]*/gu;
|
|
53
|
+
const MIN_EXPLANATORY_WORD_LENGTH = 3;
|
|
54
|
+
|
|
55
|
+
// Words that only frame a reference ("see #12", "fix for fw#3", "part of Phase 2").
|
|
56
|
+
const FRAMING_WORDS: ReadonlySet<string> = new Set([
|
|
57
|
+
"see",
|
|
58
|
+
"siehe",
|
|
59
|
+
"vgl",
|
|
60
|
+
"ref",
|
|
61
|
+
"refs",
|
|
62
|
+
"issue",
|
|
63
|
+
"issues",
|
|
64
|
+
"fix",
|
|
65
|
+
"fixes",
|
|
66
|
+
"fixed",
|
|
67
|
+
"for",
|
|
68
|
+
"from",
|
|
69
|
+
"per",
|
|
70
|
+
"part",
|
|
71
|
+
"the",
|
|
72
|
+
"and",
|
|
73
|
+
"und",
|
|
74
|
+
"via",
|
|
75
|
+
"follow",
|
|
76
|
+
"followup",
|
|
77
|
+
"follow-up",
|
|
78
|
+
"tracking",
|
|
79
|
+
"tracked",
|
|
80
|
+
"todo",
|
|
81
|
+
"fixme",
|
|
82
|
+
"closes",
|
|
83
|
+
"resolves",
|
|
84
|
+
"related",
|
|
85
|
+
"also",
|
|
86
|
+
"nach",
|
|
87
|
+
"aus",
|
|
88
|
+
"laut",
|
|
89
|
+
]);
|
|
90
|
+
|
|
91
|
+
function commentBody(text: string): string {
|
|
92
|
+
return text
|
|
93
|
+
.replace(/^\/\*\*?|\*\/$/g, "")
|
|
94
|
+
.split("\n")
|
|
95
|
+
.map((line) => line.replace(/^\s*(?:\/\/+|\*)?/, ""))
|
|
96
|
+
.join("\n");
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function referenceTokensIn(body: string): number {
|
|
100
|
+
return (
|
|
101
|
+
(body.match(REFERENCE_RE)?.length ?? 0) +
|
|
102
|
+
(body.match(PLANNING_WORD_RE)?.length ?? 0) +
|
|
103
|
+
(body.match(DOTTED_LABEL_RE)?.length ?? 0)
|
|
104
|
+
);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function isExplanatoryWord(word: string): boolean {
|
|
108
|
+
return word.length >= MIN_EXPLANATORY_WORD_LENGTH && !FRAMING_WORDS.has(word.toLowerCase());
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** True when the comment carries a reference or planning label and no explanatory word besides it. */
|
|
112
|
+
export function isIssueRefOnlyComment(commentText: string): boolean {
|
|
113
|
+
if (lineHasIgnoreTag(commentText, IGNORE_TAG)) return false;
|
|
114
|
+
const body = commentBody(commentText);
|
|
115
|
+
if (referenceTokensIn(body) === 0) return false;
|
|
116
|
+
const rest = body
|
|
117
|
+
.replace(REFERENCE_RE, " ")
|
|
118
|
+
.replace(PLANNING_WORD_RE, " ")
|
|
119
|
+
.replace(DOTTED_LABEL_RE, " ");
|
|
120
|
+
return !(rest.match(WORD_RE) ?? []).some(isExplanatoryWord);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
type CommentParagraph = { readonly start: number; readonly text: string };
|
|
124
|
+
|
|
125
|
+
function startsOwnLine(fullText: string, start: number): boolean {
|
|
126
|
+
const lineStart = fullText.lastIndexOf("\n", start - 1) + 1;
|
|
127
|
+
return fullText.slice(lineStart, start).trim() === "";
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function continuesLineParagraph(
|
|
131
|
+
fullText: string,
|
|
132
|
+
previous: CommentTrivia,
|
|
133
|
+
next: CommentTrivia,
|
|
134
|
+
): boolean {
|
|
135
|
+
if (previous.kind !== COMMENT_KIND_LINE || next.kind !== COMMENT_KIND_LINE) return false;
|
|
136
|
+
if (!startsOwnLine(fullText, previous.start) || !startsOwnLine(fullText, next.start))
|
|
137
|
+
return false;
|
|
138
|
+
return /^[ \t]*\r?\n[ \t]*$/.test(
|
|
139
|
+
fullText.slice(previous.start + previous.text.length, next.start),
|
|
140
|
+
);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
export function commentParagraphs(fullText: string): CommentParagraph[] {
|
|
144
|
+
const paragraphs: CommentParagraph[] = [];
|
|
145
|
+
let current: { start: number; parts: string[]; last: CommentTrivia } | undefined;
|
|
146
|
+
for (const comment of scanCommentTrivia(fullText)) {
|
|
147
|
+
if (current && continuesLineParagraph(fullText, current.last, comment)) {
|
|
148
|
+
current.parts.push(comment.text);
|
|
149
|
+
current.last = comment;
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (current) paragraphs.push({ start: current.start, text: current.parts.join("\n") });
|
|
153
|
+
current = { start: comment.start, parts: [comment.text], last: comment };
|
|
154
|
+
}
|
|
155
|
+
if (current) paragraphs.push({ start: current.start, text: current.parts.join("\n") });
|
|
156
|
+
return paragraphs;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
export type IssueRefOnlySite = { readonly line: number; readonly text: string };
|
|
160
|
+
|
|
161
|
+
export function findIssueRefOnlyComments(fullText: string): IssueRefOnlySite[] {
|
|
162
|
+
return commentParagraphs(fullText)
|
|
163
|
+
.filter((paragraph) => isIssueRefOnlyComment(paragraph.text))
|
|
164
|
+
.map((paragraph) => ({
|
|
165
|
+
line: fullText.slice(0, paragraph.start).split("\n").length,
|
|
166
|
+
text: paragraph.text,
|
|
167
|
+
}));
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function issueRefOnlyCounts(
|
|
171
|
+
files: readonly SourceFile[],
|
|
172
|
+
roots: ReadonlyArray<{ readonly absPath: string }> = resolveRepoRoots(),
|
|
173
|
+
): { counts: Record<string, number>; firstLine: Record<string, number> } {
|
|
174
|
+
const counts: Record<string, number> = {};
|
|
175
|
+
const firstLine: Record<string, number> = {};
|
|
176
|
+
for (const sf of files) {
|
|
177
|
+
if (EXCLUDE.test(sf.getFilePath())) continue;
|
|
178
|
+
const sites = findIssueRefOnlyComments(sf.getFullText());
|
|
179
|
+
const first = sites[0];
|
|
180
|
+
if (first === undefined) continue;
|
|
181
|
+
const file = relFromRepoRoot(sf.getFilePath(), roots);
|
|
182
|
+
if (!isLocalFinding({ file })) continue;
|
|
183
|
+
counts[file] = sites.length;
|
|
184
|
+
firstLine[file] = first.line;
|
|
185
|
+
}
|
|
186
|
+
return { counts, firstLine };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const issueRefBaseline = baselineRatchet({
|
|
190
|
+
file: path.join(ROOT, ".kumiko-comment-issue-ref-baseline.json"),
|
|
191
|
+
formatVersion: 1,
|
|
192
|
+
unit: "issue-reference-only comment(s)",
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
const REMEDIATION =
|
|
196
|
+
"Replace the reference with the reason it stands for, or delete the comment; the number belongs in the commit message.";
|
|
197
|
+
|
|
198
|
+
export const guard: AstGuard = {
|
|
199
|
+
name: GUARD_NAME,
|
|
200
|
+
scan: SCAN,
|
|
201
|
+
hint: `after removing hits: \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\`; deliberate exception: // ${IGNORE_TAG} <reason>`,
|
|
202
|
+
run: (files, roots = resolveRepoRoots()) => {
|
|
203
|
+
const { counts, firstLine } = issueRefOnlyCounts(files, roots);
|
|
204
|
+
return {
|
|
205
|
+
violations: issueRefBaseline.check(counts, REMEDIATION, {
|
|
206
|
+
formatDriftRemediation: `Run \`kumiko-guards guards --write-baseline --guard=${GUARD_NAME}\` once.`,
|
|
207
|
+
resolveLine: (file) => firstLine[file] ?? 1,
|
|
208
|
+
}),
|
|
209
|
+
};
|
|
210
|
+
},
|
|
211
|
+
writeBaseline: (files, roots = resolveRepoRoots()) => {
|
|
212
|
+
issueRefBaseline.write(issueRefOnlyCounts(files, roots).counts);
|
|
213
|
+
},
|
|
214
|
+
};
|
|
215
|
+
|
|
216
|
+
if (import.meta.main) {
|
|
217
|
+
if (process.argv.slice(2).includes("--write-baseline")) {
|
|
218
|
+
const project = buildSharedProject([guard]);
|
|
219
|
+
guard.writeBaseline?.(filesForGuard(project, guard));
|
|
220
|
+
process.exit(0);
|
|
221
|
+
}
|
|
222
|
+
runStandalone(guard);
|
|
223
|
+
}
|