@cosmicdrift/kumiko-guards 0.355.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.
Files changed (39) hide show
  1. package/package.json +2 -2
  2. package/src/_lib/bun-lock-packages.ts +5 -5
  3. package/src/_lib/guard-kit.ts +180 -131
  4. package/src/_lib/qn.ts +9 -9
  5. package/src/_lib/roots.ts +4 -3
  6. package/src/_lib/security-baseline.ts +193 -116
  7. package/src/_lib/sql-inventory.ts +34 -32
  8. package/src/changes.json +6 -0
  9. package/src/check-as-casts.ts +196 -180
  10. package/src/check-complexity.ts +68 -69
  11. package/src/check-predicates.ts +93 -67
  12. package/src/check-secret-literals.ts +49 -38
  13. package/src/check-single-runtime-instance.ts +1 -1
  14. package/src/cli.ts +30 -17
  15. package/src/guard-access-denied-test.ts +49 -23
  16. package/src/guard-app-feature-structure.ts +92 -73
  17. package/src/guard-broker-subscribe.ts +17 -17
  18. package/src/guard-comment-lang.ts +126 -54
  19. package/src/guard-direct-entity-writes.ts +58 -45
  20. package/src/guard-escape-hatch-declared.ts +26 -23
  21. package/src/guard-html-escape.ts +112 -86
  22. package/src/guard-i18n-keys.ts +39 -36
  23. package/src/guard-i18n-locale-mount.ts +47 -40
  24. package/src/guard-i18n-ui-strings.ts +108 -92
  25. package/src/guard-lib-test-coverage.ts +37 -37
  26. package/src/guard-loadall-events.ts +17 -17
  27. package/src/guard-no-framed-extension-sections.ts +4 -4
  28. package/src/guard-no-temporal-polyfill-import.ts +19 -1
  29. package/src/guard-pii-annotations.ts +16 -16
  30. package/src/guard-primitives-discipline.ts +61 -65
  31. package/src/guard-screen-conventions.ts +2 -0
  32. package/src/guard-table-ddl.ts +56 -62
  33. package/src/guard-tenant-escalation.ts +42 -34
  34. package/src/guard-test-stack-drift.ts +6 -6
  35. package/src/guard-test-timeouts.ts +5 -4
  36. package/src/guard-text-field-stance.ts +22 -22
  37. package/src/guard-thin-wrappers.ts +42 -45
  38. package/src/guard-write-handler-qns.ts +29 -29
  39. 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.355.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.355.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 reason: string };
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, reason: "not valid JSON" };
79
+ return { ok: false, detail: "not valid JSON" };
80
80
  }
81
- if (!isParsedBunLock(parsed)) return { ok: false, reason: "unexpected top-level shape" };
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, reason: `entry "${lockKey}" is not a resolved-package tuple` };
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, reason: `entry "${lockKey}" is not readable as name@version` };
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
  }
@@ -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: new Date().toISOString().slice(0, 10),
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
- // Nothing to freeze: a missing baseline is equivalent to an empty one.
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
- file: args.file,
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 regressions.map((regression) => ({
290
- file: regression.file,
291
- line: opts?.resolveLine?.(regression.file) ?? 1,
292
- message: `${args.unit} over baseline: baseline=${regression.baseline} current=${regression.current} (+${regression.current - regression.baseline}). ${remediation}`,
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
- const scans = scan(guard, roots);
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: e instanceof Error ? (e.stack ?? e.message) : String(e),
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
- // Not applicable does not mean checked — that must stay visible,
682
- // otherwise a standalone run reads like a complete one.
683
- const scope = r.notApplicable
684
- ? r.skipReason === "outside-kinds"
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 für Qualified-Names (QN, z.B. "tickets:write:close").
3
- * Extrahiert aus guard-action-wiring.ts + guard-write-handler-qns.ts, die
4
- * beide eine byte-identische toKebab-Implementierung inline hatten.
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 für ein gültiges Write-Handler-QN.
15
- // Erlaubt 3 Segmente (feature:write:handler) oder 4+ (feature:write:entity:verb, feature:write:domain:entity:verb).
16
- // Akzeptiert camelCase + kebab-case — Normalisierung auf kebab erfolgt vor Manifest-Match.
17
- // Jedes Segment einzeln validiert (kein ":" im Segment-Zeichensatz) — sonst
18
- // akzeptiert die letzte Segment-Gruppe trailing/doppelte Colons wie
19
- // "tickets:write:close:" als valide.
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)) return;
45
- warnedDerivedFallbacks.add(message);
46
- console.error(message);
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>();