@cosmicdrift/kumiko-guards 0.356.0 → 0.357.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/_lib/bun-lock-packages.ts +5 -5
- package/src/_lib/guard-kit.ts +180 -131
- package/src/_lib/qn.ts +9 -9
- package/src/_lib/roots.ts +4 -3
- package/src/_lib/security-baseline.ts +193 -116
- package/src/_lib/sql-inventory.ts +34 -32
- package/src/changes.json +6 -0
- package/src/check-as-casts.ts +196 -180
- package/src/check-complexity.ts +68 -69
- package/src/check-predicates.ts +93 -67
- package/src/check-secret-literals.ts +49 -38
- package/src/check-single-runtime-instance.ts +1 -1
- package/src/cli.ts +30 -17
- package/src/guard-access-denied-test.ts +49 -23
- package/src/guard-app-feature-structure.ts +92 -73
- package/src/guard-broker-subscribe.ts +17 -17
- package/src/guard-comment-lang.ts +126 -54
- package/src/guard-direct-entity-writes.ts +58 -45
- package/src/guard-escape-hatch-declared.ts +26 -23
- package/src/guard-html-escape.ts +112 -86
- package/src/guard-i18n-keys.ts +39 -36
- package/src/guard-i18n-locale-mount.ts +47 -40
- package/src/guard-i18n-ui-strings.ts +108 -92
- package/src/guard-lib-test-coverage.ts +37 -37
- package/src/guard-loadall-events.ts +17 -17
- package/src/guard-no-framed-extension-sections.ts +4 -4
- package/src/guard-no-temporal-polyfill-import.ts +19 -1
- package/src/guard-pii-annotations.ts +16 -16
- package/src/guard-primitives-discipline.ts +61 -65
- package/src/guard-screen-conventions.ts +2 -0
- package/src/guard-table-ddl.ts +56 -62
- package/src/guard-tenant-escalation.ts +42 -34
- package/src/guard-test-stack-drift.ts +6 -6
- package/src/guard-test-timeouts.ts +5 -4
- package/src/guard-text-field-stance.ts +22 -22
- package/src/guard-thin-wrappers.ts +42 -45
- package/src/guard-write-handler-qns.ts +29 -29
- package/src/runtime-isolation-classify.ts +4 -3
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@cosmicdrift/kumiko-guards",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.357.0",
|
|
4
4
|
"description": "AST-based security guards for Kumiko repos: direct-fs/fetch, tenant escalation, admin-API, escape hatches and related checks, run over a shared ts-morph project.",
|
|
5
5
|
"license": "BUSL-1.1",
|
|
6
6
|
"author": "Marc Frost <marc@cosmicdriftgamestudio.com>",
|
|
@@ -28,7 +28,7 @@
|
|
|
28
28
|
"kumiko-pre-push": "./src/pre-push.sh"
|
|
29
29
|
},
|
|
30
30
|
"dependencies": {
|
|
31
|
-
"@cosmicdrift/kumiko-repo-manifest": "0.
|
|
31
|
+
"@cosmicdrift/kumiko-repo-manifest": "0.357.0",
|
|
32
32
|
"ts-morph": "^28.0.0"
|
|
33
33
|
},
|
|
34
34
|
"publishConfig": {
|
|
@@ -12,7 +12,7 @@ export type LockPackage = {
|
|
|
12
12
|
|
|
13
13
|
export type LockfileRead =
|
|
14
14
|
| { readonly ok: true; readonly packages: readonly LockPackage[] }
|
|
15
|
-
| { readonly ok: false; readonly
|
|
15
|
+
| { readonly ok: false; readonly detail: string };
|
|
16
16
|
|
|
17
17
|
type ParsedBunLock = { readonly packages?: Record<string, unknown> };
|
|
18
18
|
|
|
@@ -76,19 +76,19 @@ export function readLockfilePackages(root: string): LockfileRead | undefined {
|
|
|
76
76
|
try {
|
|
77
77
|
parsed = JSON.parse(stripTrailingCommas(raw));
|
|
78
78
|
} catch {
|
|
79
|
-
return { ok: false,
|
|
79
|
+
return { ok: false, detail: "not valid JSON" };
|
|
80
80
|
}
|
|
81
|
-
if (!isParsedBunLock(parsed)) return { ok: false,
|
|
81
|
+
if (!isParsedBunLock(parsed)) return { ok: false, detail: "unexpected top-level shape" };
|
|
82
82
|
|
|
83
83
|
const lineOf = createLineLocator(raw);
|
|
84
84
|
const packages: LockPackage[] = [];
|
|
85
85
|
for (const [lockKey, entry] of Object.entries(parsed.packages ?? {})) {
|
|
86
86
|
if (!Array.isArray(entry) || typeof entry[0] !== "string") {
|
|
87
|
-
return { ok: false,
|
|
87
|
+
return { ok: false, detail: `entry "${lockKey}" is not a resolved-package tuple` };
|
|
88
88
|
}
|
|
89
89
|
const nameAndVersion = parseNameAndVersion(entry[0]);
|
|
90
90
|
if (!nameAndVersion) {
|
|
91
|
-
return { ok: false,
|
|
91
|
+
return { ok: false, detail: `entry "${lockKey}" is not readable as name@version` };
|
|
92
92
|
}
|
|
93
93
|
packages.push({ lockKey, ...nameAndVersion, line: lineOf(lockKey) });
|
|
94
94
|
}
|
package/src/_lib/guard-kit.ts
CHANGED
|
@@ -15,7 +15,7 @@
|
|
|
15
15
|
import { existsSync, readFileSync, realpathSync, writeFileSync } from "node:fs";
|
|
16
16
|
import { relative as pathRelative, resolve, sep } from "node:path";
|
|
17
17
|
import { Project, type SourceFile } from "ts-morph";
|
|
18
|
-
import { compareToBaseline, findRepoRootFor } from "./baseline-compare";
|
|
18
|
+
import { type BaselineRegression, compareToBaseline, findRepoRootFor } from "./baseline-compare";
|
|
19
19
|
import {
|
|
20
20
|
explainRepoRoots,
|
|
21
21
|
findLocalRepo,
|
|
@@ -220,13 +220,55 @@ export function baselineRatchet(args: {
|
|
|
220
220
|
const total = Object.values(perFile).reduce((sum, count) => sum + count, 0);
|
|
221
221
|
const payload: BaselinePayload = {
|
|
222
222
|
format: args.formatVersion,
|
|
223
|
-
generated:
|
|
223
|
+
generated: Temporal.Now.instant().toString().slice(0, 10),
|
|
224
224
|
total,
|
|
225
225
|
perFile,
|
|
226
226
|
};
|
|
227
227
|
writeFileSync(args.file, `${JSON.stringify(payload, null, 2)}\n`);
|
|
228
228
|
console.log(` Baseline written: ${args.file} (total ${total})`);
|
|
229
229
|
};
|
|
230
|
+
const regressionViolations = (
|
|
231
|
+
regressions: readonly BaselineRegression[],
|
|
232
|
+
message: (regression: BaselineRegression) => string,
|
|
233
|
+
resolveLine: ((file: string) => number) | undefined,
|
|
234
|
+
): GuardViolation[] =>
|
|
235
|
+
regressions.map((regression) => ({
|
|
236
|
+
file: regression.file,
|
|
237
|
+
line: resolveLine?.(regression.file) ?? 1,
|
|
238
|
+
message: message(regression),
|
|
239
|
+
}));
|
|
240
|
+
const baselineProblem = (message: string): GuardViolation[] => [
|
|
241
|
+
{ file: args.file, line: 1, message },
|
|
242
|
+
];
|
|
243
|
+
const driftMessage = (raw: Partial<BaselinePayload>, driftHint: string): string | undefined => {
|
|
244
|
+
if (raw.format !== args.formatVersion) {
|
|
245
|
+
return `Baseline format drift: expected format=${args.formatVersion}, read format=${raw.format ?? "<missing>"}. ${driftHint}`;
|
|
246
|
+
}
|
|
247
|
+
if (typeof raw.perFile !== "object" || raw.perFile === null) {
|
|
248
|
+
return `Baseline file has no valid "perFile" object. ${driftHint}`;
|
|
249
|
+
}
|
|
250
|
+
return undefined;
|
|
251
|
+
};
|
|
252
|
+
const checkMissingBaseline = (
|
|
253
|
+
current: Readonly<Record<string, number>>,
|
|
254
|
+
remediation: string,
|
|
255
|
+
resolveLine: ((file: string) => number) | undefined,
|
|
256
|
+
): GuardViolation[] => {
|
|
257
|
+
if (args.failClosed === true) {
|
|
258
|
+
return regressionViolations(
|
|
259
|
+
compareToBaseline(current, {}).regressions,
|
|
260
|
+
(regression) =>
|
|
261
|
+
`${args.unit} without a baseline file (${args.file}): ${regression.current} not frozen. ${remediation}`,
|
|
262
|
+
resolveLine,
|
|
263
|
+
);
|
|
264
|
+
}
|
|
265
|
+
// Nothing to freeze: a missing baseline is equivalent to an empty one.
|
|
266
|
+
if (Object.values(current).every((count) => count === 0)) return [];
|
|
267
|
+
console.log(
|
|
268
|
+
` No baseline found (${args.file}). Freeze it first with \`--write-baseline\` — warning until then, no fail.`,
|
|
269
|
+
);
|
|
270
|
+
return [];
|
|
271
|
+
};
|
|
230
272
|
const check = (
|
|
231
273
|
current: Readonly<Record<string, number>>,
|
|
232
274
|
remediation: string,
|
|
@@ -235,49 +277,20 @@ export function baselineRatchet(args: {
|
|
|
235
277
|
readonly resolveLine?: (file: string) => number;
|
|
236
278
|
},
|
|
237
279
|
): GuardViolation[] => {
|
|
238
|
-
if (!existsSync(args.file) && args.failClosed === true) {
|
|
239
|
-
return compareToBaseline(current, {}).regressions.map((regression) => ({
|
|
240
|
-
file: regression.file,
|
|
241
|
-
line: opts?.resolveLine?.(regression.file) ?? 1,
|
|
242
|
-
message: `${args.unit} without a baseline file (${args.file}): ${regression.current} not frozen. ${remediation}`,
|
|
243
|
-
}));
|
|
244
|
-
}
|
|
245
280
|
if (!existsSync(args.file)) {
|
|
246
|
-
|
|
247
|
-
if (Object.values(current).every((count) => count === 0)) return [];
|
|
248
|
-
console.log(
|
|
249
|
-
` No baseline found (${args.file}). Freeze it first with \`--write-baseline\` — warning until then, no fail.`,
|
|
250
|
-
);
|
|
251
|
-
return [];
|
|
281
|
+
return checkMissingBaseline(current, remediation, opts?.resolveLine);
|
|
252
282
|
}
|
|
283
|
+
const driftHint = opts?.formatDriftRemediation ?? remediation;
|
|
253
284
|
let raw: Partial<BaselinePayload>;
|
|
254
285
|
try {
|
|
255
286
|
raw = JSON.parse(readFileSync(args.file, "utf-8"));
|
|
256
287
|
} catch (e) {
|
|
257
|
-
return
|
|
258
|
-
{
|
|
259
|
-
|
|
260
|
-
line: 1,
|
|
261
|
-
message: `Baseline file unreadable (broken JSON, merge marker, aborted write): ${e instanceof Error ? e.message : String(e)}. ${opts?.formatDriftRemediation ?? remediation}`,
|
|
262
|
-
},
|
|
263
|
-
];
|
|
264
|
-
}
|
|
265
|
-
if (
|
|
266
|
-
raw.format !== args.formatVersion ||
|
|
267
|
-
typeof raw.perFile !== "object" ||
|
|
268
|
-
raw.perFile === null
|
|
269
|
-
) {
|
|
270
|
-
return [
|
|
271
|
-
{
|
|
272
|
-
file: args.file,
|
|
273
|
-
line: 1,
|
|
274
|
-
message:
|
|
275
|
-
raw.format !== args.formatVersion
|
|
276
|
-
? `Baseline format drift: expected format=${args.formatVersion}, read format=${raw.format ?? "<missing>"}. ${opts?.formatDriftRemediation ?? remediation}`
|
|
277
|
-
: `Baseline file has no valid "perFile" object. ${opts?.formatDriftRemediation ?? remediation}`,
|
|
278
|
-
},
|
|
279
|
-
];
|
|
288
|
+
return baselineProblem(
|
|
289
|
+
`Baseline file unreadable (broken JSON, merge marker, aborted write): ${e instanceof Error ? e.message : String(e)}. ${driftHint}`,
|
|
290
|
+
);
|
|
280
291
|
}
|
|
292
|
+
const drift = driftMessage(raw, driftHint);
|
|
293
|
+
if (drift !== undefined) return baselineProblem(drift);
|
|
281
294
|
const baseline = raw as BaselinePayload;
|
|
282
295
|
const { regressions, reduced } = compareToBaseline(current, baseline.perFile);
|
|
283
296
|
if (regressions.length === 0) {
|
|
@@ -286,12 +299,14 @@ export function baselineRatchet(args: {
|
|
|
286
299
|
if (reduced > 0) console.log(` ✓ ${reduced} ${args.unit} reduced since baseline.`);
|
|
287
300
|
return [];
|
|
288
301
|
}
|
|
289
|
-
return
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
302
|
+
return regressionViolations(
|
|
303
|
+
regressions,
|
|
304
|
+
(regression) =>
|
|
305
|
+
`${args.unit} over baseline: baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline}). ${remediation}`,
|
|
306
|
+
opts?.resolveLine,
|
|
307
|
+
);
|
|
294
308
|
};
|
|
309
|
+
|
|
295
310
|
const handleCli = (current: Readonly<Record<string, number>>): boolean => {
|
|
296
311
|
const cliArgs = process.argv.slice(2);
|
|
297
312
|
if (cliArgs.includes("--write-baseline")) {
|
|
@@ -360,6 +375,71 @@ export type RunGuardsDeps = {
|
|
|
360
375
|
readonly strictSecurityBaseline?: boolean;
|
|
361
376
|
};
|
|
362
377
|
|
|
378
|
+
function errorText(e: unknown): string {
|
|
379
|
+
return e instanceof Error ? (e.stack ?? e.message) : String(e);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
function applySecurityBaselineFor(
|
|
383
|
+
guard: AstGuard,
|
|
384
|
+
outcome: GuardOutcome,
|
|
385
|
+
roots: readonly RepoRoot[],
|
|
386
|
+
deps: RunGuardsDeps,
|
|
387
|
+
): ReturnType<typeof applySecurityBaseline> | undefined {
|
|
388
|
+
// Security guards get no skip flag: every finding must clear the baseline.
|
|
389
|
+
if (!isSecurityGuard(guard)) return undefined;
|
|
390
|
+
return applySecurityBaseline({
|
|
391
|
+
guardName: guard.name,
|
|
392
|
+
violations: outcome.violations,
|
|
393
|
+
roots,
|
|
394
|
+
cwd: process.cwd(),
|
|
395
|
+
load:
|
|
396
|
+
deps.securityBaseline ??
|
|
397
|
+
((repo) => {
|
|
398
|
+
const root = roots.find((r) => r.name === repo);
|
|
399
|
+
return loadSecurityBaseline(repo, root?.absPath ?? process.cwd());
|
|
400
|
+
}),
|
|
401
|
+
strict: deps.strictSecurityBaseline === true,
|
|
402
|
+
});
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
function runOneGuard(
|
|
406
|
+
guard: AstGuard,
|
|
407
|
+
project: Project,
|
|
408
|
+
roots: readonly RepoRoot[],
|
|
409
|
+
scan: (guard: AstGuard, roots: readonly RepoRoot[]) => readonly RootScan[],
|
|
410
|
+
deps: RunGuardsDeps,
|
|
411
|
+
start: number,
|
|
412
|
+
): RunResult {
|
|
413
|
+
const scans = scan(guard, roots);
|
|
414
|
+
const paths = [...new Set(scans.flatMap((s) => s.files))].sort();
|
|
415
|
+
const files = paths.map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p));
|
|
416
|
+
const outcome = guard.run(files, roots);
|
|
417
|
+
const securityResult = applySecurityBaselineFor(guard, outcome, roots, deps);
|
|
418
|
+
const effectiveOutcome: GuardOutcome = securityResult
|
|
419
|
+
? { violations: securityResult.blocking }
|
|
420
|
+
: outcome;
|
|
421
|
+
const { violatingRoots } = checkRootFloor(guard, scans);
|
|
422
|
+
const notApplicable = scans.length === 0;
|
|
423
|
+
return {
|
|
424
|
+
name: guard.name,
|
|
425
|
+
ok: effectiveOutcome.violations.length === 0 && violatingRoots.length === 0,
|
|
426
|
+
ms: Math.round(performance.now() - start),
|
|
427
|
+
outcome: effectiveOutcome,
|
|
428
|
+
hint: guard.hint,
|
|
429
|
+
notApplicable,
|
|
430
|
+
skipReason: notApplicable
|
|
431
|
+
? roots.length === 0
|
|
432
|
+
? "repos-missing"
|
|
433
|
+
: "outside-kinds"
|
|
434
|
+
: undefined,
|
|
435
|
+
scanKinds: notApplicable ? guard.scan.kinds : undefined,
|
|
436
|
+
matchedFiles: files.length,
|
|
437
|
+
violatingRoots: violatingRoots.length > 0 ? violatingRoots : undefined,
|
|
438
|
+
frozenFindings: securityResult?.frozen,
|
|
439
|
+
security: securityResult !== undefined ? true : undefined,
|
|
440
|
+
};
|
|
441
|
+
}
|
|
442
|
+
|
|
363
443
|
// `project` is injectable so the run/catch wiring can be unit-tested with an
|
|
364
444
|
// in-memory project — buildSharedProject() needs the framework tsconfig, absent
|
|
365
445
|
// in a standalone repo checkout.
|
|
@@ -378,55 +458,13 @@ export function runGuards(
|
|
|
378
458
|
for (const guard of guards) {
|
|
379
459
|
const start = performance.now();
|
|
380
460
|
try {
|
|
381
|
-
|
|
382
|
-
const paths = [...new Set(scans.flatMap((s) => s.files))].sort();
|
|
383
|
-
const files = paths.map((p) => project.getSourceFile(p) ?? project.addSourceFileAtPath(p));
|
|
384
|
-
const outcome = guard.run(files, roots);
|
|
385
|
-
// Security guards get no skip flag: every finding must clear the baseline.
|
|
386
|
-
const securityResult = isSecurityGuard(guard)
|
|
387
|
-
? applySecurityBaseline({
|
|
388
|
-
guardName: guard.name,
|
|
389
|
-
violations: outcome.violations,
|
|
390
|
-
roots,
|
|
391
|
-
cwd: process.cwd(),
|
|
392
|
-
load:
|
|
393
|
-
deps.securityBaseline ??
|
|
394
|
-
((repo) => {
|
|
395
|
-
const root = roots.find((r) => r.name === repo);
|
|
396
|
-
return loadSecurityBaseline(repo, root?.absPath ?? process.cwd());
|
|
397
|
-
}),
|
|
398
|
-
strict: deps.strictSecurityBaseline === true,
|
|
399
|
-
})
|
|
400
|
-
: undefined;
|
|
401
|
-
const effectiveOutcome: GuardOutcome = securityResult
|
|
402
|
-
? { violations: securityResult.blocking }
|
|
403
|
-
: outcome;
|
|
404
|
-
const { violatingRoots } = checkRootFloor(guard, scans);
|
|
405
|
-
const notApplicable = scans.length === 0;
|
|
406
|
-
results.push({
|
|
407
|
-
name: guard.name,
|
|
408
|
-
ok: effectiveOutcome.violations.length === 0 && violatingRoots.length === 0,
|
|
409
|
-
ms: Math.round(performance.now() - start),
|
|
410
|
-
outcome: effectiveOutcome,
|
|
411
|
-
hint: guard.hint,
|
|
412
|
-
notApplicable,
|
|
413
|
-
skipReason: notApplicable
|
|
414
|
-
? roots.length === 0
|
|
415
|
-
? "repos-missing"
|
|
416
|
-
: "outside-kinds"
|
|
417
|
-
: undefined,
|
|
418
|
-
scanKinds: notApplicable ? guard.scan.kinds : undefined,
|
|
419
|
-
matchedFiles: files.length,
|
|
420
|
-
violatingRoots: violatingRoots.length > 0 ? violatingRoots : undefined,
|
|
421
|
-
frozenFindings: securityResult?.frozen,
|
|
422
|
-
security: securityResult !== undefined ? true : undefined,
|
|
423
|
-
});
|
|
461
|
+
results.push(runOneGuard(guard, project, roots, scan, deps, start));
|
|
424
462
|
} catch (e) {
|
|
425
463
|
results.push({
|
|
426
464
|
name: guard.name,
|
|
427
465
|
ok: false,
|
|
428
466
|
ms: Math.round(performance.now() - start),
|
|
429
|
-
error:
|
|
467
|
+
error: errorText(e),
|
|
430
468
|
});
|
|
431
469
|
}
|
|
432
470
|
}
|
|
@@ -600,6 +638,7 @@ export function printGuardKitBanner(
|
|
|
600
638
|
if (error !== undefined) {
|
|
601
639
|
console.error(error);
|
|
602
640
|
process.exit(1);
|
|
641
|
+
// skip: process.exit is stubbed in tests, so control can fall through
|
|
603
642
|
return;
|
|
604
643
|
}
|
|
605
644
|
console.log(`kumiko-guards ${guardKitVersion()} - ${guardCount} guards registered`);
|
|
@@ -674,55 +713,65 @@ export function classifyRun(args: {
|
|
|
674
713
|
return { notApplicable, vacuous };
|
|
675
714
|
}
|
|
676
715
|
|
|
716
|
+
function printPassedResult(r: RunResult): void {
|
|
717
|
+
// Not applicable does not mean checked — that must stay visible,
|
|
718
|
+
// otherwise a standalone run reads like a complete one.
|
|
719
|
+
const scope = r.notApplicable
|
|
720
|
+
? r.skipReason === "outside-kinds"
|
|
721
|
+
? ` — skipped, repo kind outside guard kinds (${(r.scanKinds ?? []).join(", ")})`
|
|
722
|
+
: " — skipped, target repos not in checkout"
|
|
723
|
+
: ` (${r.matchedFiles ?? 0} files)`;
|
|
724
|
+
const frozen =
|
|
725
|
+
r.frozenFindings && r.frozenFindings > 0
|
|
726
|
+
? ` — ${r.frozenFindings} frozen security findings (baseline)`
|
|
727
|
+
: "";
|
|
728
|
+
console.log(` ✓ ${r.name} (${r.ms}ms)${scope}${frozen}`);
|
|
729
|
+
for (const w of r.warnings ?? []) {
|
|
730
|
+
console.log(` ! ${w.file}:${w.line} ${w.message}`);
|
|
731
|
+
}
|
|
732
|
+
}
|
|
733
|
+
|
|
734
|
+
function printViolationDetails(r: RunResult): void {
|
|
735
|
+
if (r.violatingRoots && r.violatingRoots.length > 0) {
|
|
736
|
+
console.error(
|
|
737
|
+
` 0 source files in: ${r.violatingRoots.join(", ")} — kumiko.json declares sourceRoots, scope "source" yields nothing there.`,
|
|
738
|
+
);
|
|
739
|
+
console.error(
|
|
740
|
+
" Check the manifest (kumiko.json) or the checkout; without a manifest the derived fallback packages/*/src or src/ applies.",
|
|
741
|
+
);
|
|
742
|
+
}
|
|
743
|
+
if (r.security) {
|
|
744
|
+
console.error(` [security] Findings without baseline coverage always FAIL.`);
|
|
745
|
+
}
|
|
746
|
+
for (const v of r.outcome?.violations ?? []) {
|
|
747
|
+
console.error(` ${v.file}:${v.line} ${v.message}`);
|
|
748
|
+
}
|
|
749
|
+
for (const w of r.warnings ?? []) {
|
|
750
|
+
console.error(` ! ${w.file}:${w.line} ${w.message}`);
|
|
751
|
+
}
|
|
752
|
+
if (r.hint) console.error(` → ${r.hint}`);
|
|
753
|
+
}
|
|
754
|
+
|
|
755
|
+
function printFailedResult(r: RunResult): void {
|
|
756
|
+
console.log(` ✗ ${r.name} (${r.ms}ms)`);
|
|
757
|
+
if (r.error) {
|
|
758
|
+
console.error(` THREW: ${r.error}`);
|
|
759
|
+
} else if (r.message) {
|
|
760
|
+
console.error(` ${r.message}`);
|
|
761
|
+
} else {
|
|
762
|
+
printViolationDetails(r);
|
|
763
|
+
}
|
|
764
|
+
}
|
|
765
|
+
|
|
677
766
|
export function reportResults(results: readonly RunResult[]): number {
|
|
678
767
|
let failed = 0;
|
|
679
768
|
for (const r of results) {
|
|
680
769
|
if (r.ok) {
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
? ` — skipped, repo kind outside guard kinds (${(r.scanKinds ?? []).join(", ")})`
|
|
686
|
-
: " — skipped, target repos not in checkout"
|
|
687
|
-
: ` (${r.matchedFiles ?? 0} files)`;
|
|
688
|
-
const frozen =
|
|
689
|
-
r.frozenFindings && r.frozenFindings > 0
|
|
690
|
-
? ` — ${r.frozenFindings} frozen security findings (baseline)`
|
|
691
|
-
: "";
|
|
692
|
-
console.log(` ✓ ${r.name} (${r.ms}ms)${scope}${frozen}`);
|
|
693
|
-
for (const w of r.warnings ?? []) {
|
|
694
|
-
console.log(` ! ${w.file}:${w.line} ${w.message}`);
|
|
695
|
-
}
|
|
696
|
-
continue;
|
|
697
|
-
}
|
|
698
|
-
failed++;
|
|
699
|
-
console.log(` ✗ ${r.name} (${r.ms}ms)`);
|
|
700
|
-
if (r.error) {
|
|
701
|
-
console.error(` THREW: ${r.error}`);
|
|
702
|
-
continue;
|
|
703
|
-
}
|
|
704
|
-
if (r.message) {
|
|
705
|
-
console.error(` ${r.message}`);
|
|
706
|
-
continue;
|
|
707
|
-
}
|
|
708
|
-
if (r.violatingRoots && r.violatingRoots.length > 0) {
|
|
709
|
-
console.error(
|
|
710
|
-
` 0 source files in: ${r.violatingRoots.join(", ")} — kumiko.json declares sourceRoots, scope "source" yields nothing there.`,
|
|
711
|
-
);
|
|
712
|
-
console.error(
|
|
713
|
-
" Check the manifest (kumiko.json) or the checkout; without a manifest the derived fallback packages/*/src or src/ applies.",
|
|
714
|
-
);
|
|
715
|
-
}
|
|
716
|
-
if (r.security) {
|
|
717
|
-
console.error(` [security] Findings without baseline coverage always FAIL.`);
|
|
718
|
-
}
|
|
719
|
-
for (const v of r.outcome?.violations ?? []) {
|
|
720
|
-
console.error(` ${v.file}:${v.line} ${v.message}`);
|
|
721
|
-
}
|
|
722
|
-
for (const w of r.warnings ?? []) {
|
|
723
|
-
console.error(` ! ${w.file}:${w.line} ${w.message}`);
|
|
770
|
+
printPassedResult(r);
|
|
771
|
+
} else {
|
|
772
|
+
failed++;
|
|
773
|
+
printFailedResult(r);
|
|
724
774
|
}
|
|
725
|
-
if (r.hint) console.error(` → ${r.hint}`);
|
|
726
775
|
}
|
|
727
776
|
return failed;
|
|
728
777
|
}
|
package/src/_lib/qn.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Shared helpers
|
|
3
|
-
*
|
|
4
|
-
*
|
|
2
|
+
* Shared helpers for qualified names (QN, e.g. "tickets:write:close").
|
|
3
|
+
* Extracted from guard-action-wiring.ts + guard-write-handler-qns.ts, which
|
|
4
|
+
* both had a byte-identical toKebab implementation inline.
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
export function toKebab(s: string): string {
|
|
@@ -11,11 +11,11 @@ export function toKebab(s: string): string {
|
|
|
11
11
|
.toLowerCase();
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
// Regex
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
//
|
|
19
|
-
// "tickets:write:close:"
|
|
14
|
+
// Regex for a valid write-handler QN.
|
|
15
|
+
// Allows 3 segments (feature:write:handler) or 4+ (feature:write:entity:verb, feature:write:domain:entity:verb).
|
|
16
|
+
// Accepts camelCase + kebab-case — normalization to kebab happens before the manifest match.
|
|
17
|
+
// Every segment is validated on its own (no ":" in the segment character set) — otherwise
|
|
18
|
+
// the last segment group would accept trailing/double colons like
|
|
19
|
+
// "tickets:write:close:" as valid.
|
|
20
20
|
export const VALID_QN_RE =
|
|
21
21
|
/^[a-zA-Z][a-zA-Z0-9-]*:write:[a-zA-Z][a-zA-Z0-9-]*(:[a-zA-Z][a-zA-Z0-9-]*)*$/;
|
package/src/_lib/roots.ts
CHANGED
|
@@ -41,9 +41,10 @@ export type RepoRoot = {
|
|
|
41
41
|
const warnedDerivedFallbacks = new Set<string>();
|
|
42
42
|
|
|
43
43
|
function warnDerivedFallbackOnce(message: string): void {
|
|
44
|
-
if (warnedDerivedFallbacks.has(message))
|
|
45
|
-
|
|
46
|
-
|
|
44
|
+
if (!warnedDerivedFallbacks.has(message)) {
|
|
45
|
+
warnedDerivedFallbacks.add(message);
|
|
46
|
+
console.error(message);
|
|
47
|
+
}
|
|
47
48
|
}
|
|
48
49
|
|
|
49
50
|
const manifestCache = new Map<string, LoadedRepoManifest | undefined>();
|